Commit1ab8aceunknown_key
feat(jetbrains): JetBrains plugin — chat / commit / PRs / specs / voice in IntelliJ + WebStorm + GoLand + PyCharm
24 files changed+1262−01ab8aceea5b49eb4e3880777e9f1e337bbaf93df
24 changed files+1262−0
Addededitor-extensions/jetbrains/CONTRIBUTING.md+82−0View fileUnifiedSplit
@@ -0,0 +1,82 @@
1# Contributing — Gluecron JetBrains plugin
2
3## Prerequisites
4
5- **JDK 17** (Temurin / Zulu / Oracle — any will do).
6- The IntelliJ Platform Gradle Plugin downloads its own IDE distribution
7 during the build, so you don't need to install IntelliJ separately.
8- **Gradle is NOT pinned in this repo** — use the wrapper that gradle
9 generates for you, or invoke an installed `gradle` from the JetBrains
10 Toolbox / SDKMan. The dev container intentionally does not preinstall it.
11
12## Build the plugin .zip
13
14```bash
15cd editor-extensions/jetbrains
16./gradlew buildPlugin
17```
18
19Output lands at:
20
21```
22build/distributions/gluecron-jetbrains-0.1.0.zip
23```
24
25Drop the zip into IDEA via **Settings → Plugins → ⚙ → Install Plugin from
26Disk…** to test locally.
27
28## Run the plugin in a sandbox IDE
29
30```bash
31./gradlew runIde
32```
33
34Spins up an IntelliJ IDEA Community sandbox with the plugin pre-installed.
35Useful for clicking through the actions without restarting your daily IDE.
36
37## Publish to the JetBrains Marketplace
38
39```bash
40# 1. Get a publish token from https://plugins.jetbrains.com/author/me/tokens
41export JETBRAINS_MARKETPLACE_TOKEN=...
42
43# 2. Optional — push to a non-default channel ("eap" / "nightly" / etc.)
44export JETBRAINS_PUBLISH_CHANNEL=default
45
46# 3. Build + upload
47./gradlew publishPlugin
48```
49
50`publishPlugin` re-runs `buildPlugin`, signs nothing extra (the marketplace
51takes unsigned plugins), and uploads via the documented REST API.
52
53## Source layout
54
55```
56editor-extensions/jetbrains/
57 build.gradle.kts ← IntelliJ Platform plugin + ktor + serialization
58 settings.gradle.kts
59 gradle.properties
60 src/main/kotlin/com/gluecron/plugin/
61 GluecronPlugin.kt ← post-startup activity / constants
62 auth/AuthService.kt ← PasswordSafe-backed PAT storage
63 api/GluecronClient.kt ← ktor HTTP client for /api/v2
64 git/RepoResolver.kt ← git remote → owner/repo
65 actions/ ← all menu actions
66 toolwindow/GluecronToolWindow.kt
67 src/main/resources/
68 META-INF/plugin.xml
69 META-INF/pluginIcon.svg ← marketplace icon
70 icons/toolWindowIcon.svg
71 icons/commitMessageIcon.svg
72```
73
74## Code style
75
76- Kotlin official style (matches `kotlin.code.style=official` in
77 `gradle.properties`).
78- Keep new actions narrow: one file each, with a doc-block explaining
79 what menu they live in and why.
80- Don't add new server endpoints from here — talk to the existing
81 `/api/v2/*` surface so the VS Code, CLI, and JetBrains extensions
82 stay in sync.
Addededitor-extensions/jetbrains/README.md+83−0View fileUnifiedSplit
@@ -0,0 +1,83 @@
1# Gluecron for JetBrains IDEs
2
3AI-native git inside IntelliJ IDEA, WebStorm, GoLand, PyCharm, RustRover, and
4Rider — chat with the repo, draft commit messages, ship specs, voice-to-PR.
5
6> The Gluecron platform: https://gluecron.com
7
8## Features
9
10- **Repo chat** — sidebar tool window grounded in the current repository.
11- **AI commit messages** — sparkle button on the VCS commit dialog drops a
12 Conventional Commits–style message into the input from your staged diff.
13- **Pull requests / Issues / Standups** — embedded JCEF browser tabs that
14 point at the live web UI; no second auth round-trip.
15- **Ship a spec** — turn the current file into a Gluecron spec (auto-drafts
16 a PR).
17- **Voice-to-PR** — opens the `/voice` console in your browser.
18
19## Install
20
21### From the JetBrains Marketplace (coming soon)
22
231. **Settings** → **Plugins** → search for **Gluecron**.
242. Install, then restart the IDE.
25
26### Sideload a .zip (current path)
27
281. Build the plugin once:
29 ```bash
30 cd editor-extensions/jetbrains
31 ./gradlew buildPlugin
32 ```
332. The artifact is written to
34 `build/distributions/gluecron-jetbrains-0.1.0.zip`.
353. In the IDE: **Settings** → **Plugins** → gear icon →
36 **Install Plugin from Disk…** and pick the zip. Restart when prompted.
37
38Sideloads can also be served from the host: visit
39`https://gluecron.com/install/jetbrains` for marketplace + sideload links.
40
41## Configure
42
43| Setting | Source | Description |
44| --- | --- | --- |
45| `host` | env `GLUECRON_HOST` | Override for self-hosted instances (defaults to `https://gluecron.com`). |
46| `token` | env `GLUECRON_TOKEN` or IDE password safe | Personal access token (`glc_…`). |
47
48Sign in with **Tools → Gluecron → Sign In (Personal Access Token)**. Create a
49PAT at `https://gluecron.com/settings/tokens` (the **admin** scope is
50required for the commit-message API).
51
52## Commands
53
54| Command | Where you'll find it |
55| --- | --- |
56| Sign In (Personal Access Token) | Tools → Gluecron |
57| Chat with This Repo | Tools → Gluecron · Editor right-click menu |
58| Open Pull Requests | Tools → Gluecron · VCS menu |
59| Open Issues | Tools → Gluecron |
60| Open AI Standups | Tools → Gluecron |
61| Ship Current File as Spec | Tools → Gluecron · Editor right-click menu |
62| Voice-to-PR | Tools → Gluecron |
63| Generate AI Commit Message | VCS commit dialog (sparkle icon) |
64
65## Screenshots
66
67_Coming soon — drop PNGs into `editor-extensions/jetbrains/screenshots/` and
68reference them here:_
69
70- `screenshots/chat.png` — Gluecron tool window with the Chat tab open.
71- `screenshots/commit.png` — sparkle button in the commit dialog.
72- `screenshots/prs.png` — embedded PRs tab.
73
74## How it talks to the server
75
76- `POST /api/v2/ai/commit-message` — staged diff → commit text.
77- `GET /api/v2/user` — PAT validation on sign-in.
78- Tool-window tabs embed `/:owner/:repo/{chat,pulls,issues,standups}?embed=1`
79 inside a JCEF browser — same `?embed=1` views as the VS Code extension.
80
81## License
82
83MIT — same as the rest of the Gluecron source.
Addededitor-extensions/jetbrains/build.gradle.kts+88−0View fileUnifiedSplit
@@ -0,0 +1,88 @@
1/*
2 * Gradle build for the Gluecron JetBrains plugin.
3 *
4 * Uses the IntelliJ Platform Gradle Plugin v1.17+ (the still-stable line
5 * that targets IC-2024.1 across IDEA, WebStorm, GoLand, PyCharm, RustRover
6 * and Rider). The plugin builds a single .zip via `./gradlew buildPlugin`.
7 *
8 * NOTE: gradle itself is intentionally NOT installed in the dev container —
9 * this file just declares everything. CI / contributors invoke gradle via
10 * the wrapper (`./gradlew`) which downloads its own JDK + gradle dist.
11 */
12
13plugins {
14 id("java")
15 id("org.jetbrains.kotlin.jvm") version "1.9.24"
16 id("org.jetbrains.kotlin.plugin.serialization") version "1.9.24"
17 id("org.jetbrains.intellij") version "1.17.4"
18}
19
20group = "com.gluecron"
21version = "0.1.0"
22
23repositories {
24 mavenCentral()
25}
26
27// -----------------------------------------------------------------------
28// IntelliJ Platform target.
29//
30// IC-2024.1 is the floor: it is the earliest 2024.x platform release and
31// is binary-compatible with every JetBrains IDE 2024.1+. We list all the
32// IDEs we want to load into via `plugins`/`type` overrides during
33// `runIde` — for buildPlugin, the IC base is enough.
34// -----------------------------------------------------------------------
35
36intellij {
37 version.set("2024.1")
38 type.set("IC")
39 plugins.set(listOf("Git4Idea")) // VCS APIs (CheckinHandlerFactory etc.)
40}
41
42// -----------------------------------------------------------------------
43// Compile + run targets.
44// -----------------------------------------------------------------------
45
46java {
47 sourceCompatibility = JavaVersion.VERSION_17
48 targetCompatibility = JavaVersion.VERSION_17
49}
50
51kotlin {
52 jvmToolchain(17)
53}
54
55dependencies {
56 // Kotlin stdlib is bundled with the platform, but pin it for IDE happiness.
57 implementation("org.jetbrains.kotlin:kotlin-stdlib")
58
59 // JSON for /api/v2 payloads.
60 implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
61
62 // ktor-client for HTTP. CIO engine has no native deps so it works
63 // identically on macOS/Linux/Windows.
64 implementation("io.ktor:ktor-client-core:2.3.12")
65 implementation("io.ktor:ktor-client-cio:2.3.12")
66 implementation("io.ktor:ktor-client-content-negotiation:2.3.12")
67 implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.12")
68}
69
70tasks {
71 patchPluginXml {
72 sinceBuild.set("241") // 2024.1
73 untilBuild.set("251.*") // 2025.1 — bump as we test newer platforms
74 }
75
76 // Tighten the build artifact name so the install/jetbrains route can
77 // reference a stable zip name.
78 buildPlugin {
79 archiveBaseName.set("gluecron-jetbrains")
80 }
81
82 // Marketplace publishing is opt-in. Tokens come from env so we don't
83 // accidentally commit them; CI populates them when we choose to ship.
84 publishPlugin {
85 token.set(providers.environmentVariable("JETBRAINS_MARKETPLACE_TOKEN").orElse(""))
86 channels.set(listOf(providers.environmentVariable("JETBRAINS_PUBLISH_CHANNEL").getOrElse("default")))
87 }
88}
Addededitor-extensions/jetbrains/gradle.properties+5−0View fileUnifiedSplit
@@ -0,0 +1,5 @@
1# Gradle JVM args — IntelliJ build needs a comfortable heap.
2org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
3org.gradle.parallel=true
4org.gradle.caching=true
5kotlin.code.style=official
Addededitor-extensions/jetbrains/settings.gradle.kts+5−0View fileUnifiedSplit
@@ -0,0 +1,5 @@
1/*
2 * Single-module project. Keeping settings.gradle.kts minimal so the
3 * IntelliJ Platform Gradle Plugin can do its thing with zero ceremony.
4 */
5rootProject.name = "gluecron-jetbrains"
Addededitor-extensions/jetbrains/src/main/kotlin/com/gluecron/plugin/GluecronPlugin.kt+32−0View fileUnifiedSplit
@@ -0,0 +1,32 @@
1package com.gluecron.plugin
2
3import com.intellij.openapi.diagnostic.Logger
4import com.intellij.openapi.startup.ProjectActivity
5import com.intellij.openapi.project.Project
6
7/**
8 * Plugin "entry point" — JetBrains plugins do most wiring via plugin.xml
9 * extension points, so this class exists mainly so we have a single
10 * place to log activation and to surface plugin-wide constants.
11 *
12 * Implements [ProjectActivity] (the modern replacement for
13 * StartupActivity) so we run once per opened project. Heavy lifting
14 * happens lazily inside individual actions / services.
15 */
16class GluecronPlugin : ProjectActivity {
17
18 override suspend fun execute(project: Project) {
19 LOG.info("Gluecron plugin activated for project: ${project.name}")
20 }
21
22 companion object {
23 const val PLUGIN_ID = "com.gluecron.plugin"
24
25 /** Configuration default — overrideable via env GLUECRON_HOST. */
26 val DEFAULT_HOST: String =
27 System.getenv("GLUECRON_HOST")?.takeIf { it.isNotBlank() }
28 ?: "https://gluecron.com"
29
30 private val LOG = Logger.getInstance(GluecronPlugin::class.java)
31 }
32}
Addededitor-extensions/jetbrains/src/main/kotlin/com/gluecron/plugin/actions/ChatWithRepoAction.kt+28−0View fileUnifiedSplit
@@ -0,0 +1,28 @@
1package com.gluecron.plugin.actions
2
3import com.intellij.openapi.actionSystem.AnAction
4import com.intellij.openapi.actionSystem.AnActionEvent
5import com.intellij.openapi.wm.ToolWindowManager
6
7/**
8 * Reveals the Gluecron tool window and focuses its Chat sub-view.
9 *
10 * The tool window itself is registered in plugin.xml — we just open it
11 * here. This mirrors `gluecron.chatWithRepo` in the VS Code extension.
12 */
13class ChatWithRepoAction : AnAction() {
14
15 override fun actionPerformed(e: AnActionEvent) {
16 val project = e.project ?: return
17 val tw = ToolWindowManager.getInstance(project).getToolWindow(TOOL_WINDOW_ID) ?: return
18 tw.activate({
19 // The tool window factory installs a JTabbedPane with Chat
20 // as the first tab, so simply showing the window puts the
21 // user on Chat. We don't need to fiddle with content here.
22 }, true, true)
23 }
24
25 companion object {
26 const val TOOL_WINDOW_ID = "Gluecron"
27 }
28}
Addededitor-extensions/jetbrains/src/main/kotlin/com/gluecron/plugin/actions/GenerateCommitMessageAction.kt+126−0View fileUnifiedSplit
@@ -0,0 +1,126 @@
1package com.gluecron.plugin.actions
2
3import com.gluecron.plugin.GluecronPlugin
4import com.gluecron.plugin.api.GluecronClient
5import com.gluecron.plugin.auth.AuthService
6import com.intellij.openapi.actionSystem.AnAction
7import com.intellij.openapi.actionSystem.AnActionEvent
8import com.intellij.openapi.actionSystem.CommonDataKeys
9import com.intellij.openapi.application.ApplicationManager
10import com.intellij.openapi.progress.ProgressIndicator
11import com.intellij.openapi.progress.Task
12import com.intellij.openapi.project.Project
13import com.intellij.openapi.ui.Messages
14import com.intellij.openapi.vcs.CheckinProjectPanel
15import com.intellij.openapi.vcs.VcsDataKeys
16import kotlinx.coroutines.runBlocking
17import java.io.File
18
19/**
20 * Generates an AI commit message for the staged diff and writes it into
21 * the VCS commit dialog's message field.
22 *
23 * Wiring:
24 * - plugin.xml registers this action under `Vcs.MessageActionGroup`
25 * so JetBrains shows it as a button next to the commit-message
26 * textarea (the same place the sparkle lives in VS Code).
27 * - We read the staged diff by shelling out to `git diff --cached`.
28 * IntelliJ's VCS API has fancier helpers, but the shell-out keeps
29 * this code identical to the CLI / VS Code paths and works with
30 * any git-CLI version the user already has.
31 * - We POST to `/api/v2/ai/commit-message` and drop the result into
32 * the active `CheckinProjectPanel#commitMessage`.
33 */
34class GenerateCommitMessageAction : AnAction() {
35
36 override fun actionPerformed(e: AnActionEvent) {
37 val project = e.project ?: return
38 val token = AuthService.getInstance().getToken()
39 if (token.isNullOrBlank()) {
40 Messages.showInfoMessage(
41 project,
42 "Sign in first: Tools → Gluecron → Sign In.",
43 "Gluecron",
44 )
45 return
46 }
47 val panel: CheckinProjectPanel? =
48 e.getData(VcsDataKeys.COMMIT_MESSAGE_CONTROL) as? CheckinProjectPanel
49 ?: e.getData(CommonDataKeys.PSI_FILE)?.let { null }
50
51 // The commit-message control isn't always exposed; if the
52 // dialog isn't visible we still draft the message and stash it
53 // on the clipboard, matching VS Code's fallback behaviour.
54 runDraftTask(project, token, panel)
55 }
56
57 private fun runDraftTask(
58 project: Project,
59 token: String,
60 panel: CheckinProjectPanel?,
61 ) {
62 object : Task.Backgroundable(project, "Gluecron: drafting commit message…", false) {
63 override fun run(indicator: ProgressIndicator) {
64 indicator.isIndeterminate = true
65 val cwd = project.basePath ?: return notify(project, "No project root.")
66 val diff = runCatching { stagedDiff(cwd) }.getOrElse {
67 return notify(project, "Failed to read staged diff: ${it.message}")
68 }
69 if (diff.isBlank()) {
70 return notify(project, "Nothing staged. Stage changes first (git add).")
71 }
72 val client = GluecronClient(
73 host = GluecronPlugin.DEFAULT_HOST,
74 token = token,
75 )
76 val msg = runCatching {
77 runBlocking { client.aiCommitMessage(diff) }
78 }.getOrElse {
79 return notify(project, "Gluecron: ${it.message}")
80 }
81 val composed = listOf(msg.subject.trim(), msg.body.trim())
82 .filter { it.isNotEmpty() }
83 .joinToString("\n\n")
84 if (composed.isEmpty()) {
85 return notify(project, "Server returned an empty subject.")
86 }
87 ApplicationManager.getApplication().invokeLater {
88 if (panel != null) {
89 panel.commitMessage = composed
90 } else {
91 // Clipboard fallback — same UX as VS Code when
92 // the SCM input box isn't ready.
93 val sel = java.awt.datatransfer.StringSelection(composed)
94 java.awt.Toolkit.getDefaultToolkit().systemClipboard.setContents(sel, sel)
95 Messages.showInfoMessage(
96 project,
97 "Commit message copied to clipboard (commit dialog not open).",
98 "Gluecron",
99 )
100 }
101 }
102 }
103 }.queue()
104 }
105
106 private fun notify(project: Project, msg: String) {
107 ApplicationManager.getApplication().invokeLater {
108 Messages.showInfoMessage(project, msg, "Gluecron")
109 }
110 }
111
112 /**
113 * `git diff --cached` over the project root. We deliberately cap the
114 * output via Java's process buffer rather than streaming — the
115 * server-side endpoint caps payload size separately.
116 */
117 private fun stagedDiff(cwd: String): String {
118 val p = ProcessBuilder("git", "diff", "--cached")
119 .directory(File(cwd))
120 .redirectErrorStream(false)
121 .start()
122 val text = p.inputStream.bufferedReader().readText()
123 p.waitFor()
124 return text
125 }
126}
Addededitor-extensions/jetbrains/src/main/kotlin/com/gluecron/plugin/actions/OpenIssuesAction.kt+23−0View fileUnifiedSplit
@@ -0,0 +1,23 @@
1package com.gluecron.plugin.actions
2
3import com.gluecron.plugin.GluecronPlugin
4import com.gluecron.plugin.git.RepoResolver
5import com.intellij.ide.BrowserUtil
6import com.intellij.openapi.actionSystem.AnAction
7import com.intellij.openapi.actionSystem.AnActionEvent
8
9/**
10 * Opens the current repo's issues page in the browser, with a sensible
11 * dashboard fallback for non-Gluecron workspaces.
12 */
13class OpenIssuesAction : AnAction() {
14
15 override fun actionPerformed(e: AnActionEvent) {
16 val project = e.project
17 val host = GluecronPlugin.DEFAULT_HOST.trimEnd('/')
18 val target = project?.let { RepoResolver.resolveGluecron(it) }?.let {
19 "$host/${it.owner}/${it.repo}/issues"
20 } ?: "$host/issues"
21 BrowserUtil.browse(target)
22 }
23}
Addededitor-extensions/jetbrains/src/main/kotlin/com/gluecron/plugin/actions/OpenPRsAction.kt+23−0View fileUnifiedSplit
@@ -0,0 +1,23 @@
1package com.gluecron.plugin.actions
2
3import com.gluecron.plugin.GluecronPlugin
4import com.gluecron.plugin.git.RepoResolver
5import com.intellij.ide.BrowserUtil
6import com.intellij.openapi.actionSystem.AnAction
7import com.intellij.openapi.actionSystem.AnActionEvent
8
9/**
10 * Opens the current repo's pull-requests page in the user's browser.
11 * Falls back to the global /pulls dashboard when we can't resolve a repo.
12 */
13class OpenPRsAction : AnAction() {
14
15 override fun actionPerformed(e: AnActionEvent) {
16 val project = e.project
17 val host = GluecronPlugin.DEFAULT_HOST.trimEnd('/')
18 val target = project?.let { RepoResolver.resolveGluecron(it) }?.let {
19 "$host/${it.owner}/${it.repo}/pulls"
20 } ?: "$host/pulls"
21 BrowserUtil.browse(target)
22 }
23}
Addededitor-extensions/jetbrains/src/main/kotlin/com/gluecron/plugin/actions/OpenStandupsAction.kt+23−0View fileUnifiedSplit
@@ -0,0 +1,23 @@
1package com.gluecron.plugin.actions
2
3import com.gluecron.plugin.GluecronPlugin
4import com.gluecron.plugin.git.RepoResolver
5import com.intellij.ide.BrowserUtil
6import com.intellij.openapi.actionSystem.AnAction
7import com.intellij.openapi.actionSystem.AnActionEvent
8
9/**
10 * Opens the per-repo AI Standups page in the browser. Falls back to the
11 * global /standups view when no Gluecron remote is attached.
12 */
13class OpenStandupsAction : AnAction() {
14
15 override fun actionPerformed(e: AnActionEvent) {
16 val project = e.project
17 val host = GluecronPlugin.DEFAULT_HOST.trimEnd('/')
18 val target = project?.let { RepoResolver.resolveGluecron(it) }?.let {
19 "$host/${it.owner}/${it.repo}/standups"
20 } ?: "$host/standups"
21 BrowserUtil.browse(target)
22 }
23}
Addededitor-extensions/jetbrains/src/main/kotlin/com/gluecron/plugin/actions/ShipSpecAction.kt+41−0View fileUnifiedSplit
@@ -0,0 +1,41 @@
1package com.gluecron.plugin.actions
2
3import com.gluecron.plugin.GluecronPlugin
4import com.gluecron.plugin.git.RepoResolver
5import com.intellij.ide.BrowserUtil
6import com.intellij.openapi.actionSystem.AnAction
7import com.intellij.openapi.actionSystem.AnActionEvent
8import com.intellij.openapi.actionSystem.CommonDataKeys
9import com.intellij.openapi.ui.Messages
10import java.net.URLEncoder
11import java.nio.charset.StandardCharsets
12
13/**
14 * Treat the current editor file as a Gluecron spec and ship it.
15 *
16 * For parity with the VS Code command, we don't upload the content here
17 * — we deep-link to `/specs/new?path=<rel>` and let the server's spec
18 * wizard read the file from the user's git working tree on the
19 * subsequent draft-PR step. Keeps auth + diff handling on the server.
20 */
21class ShipSpecAction : AnAction() {
22
23 override fun actionPerformed(e: AnActionEvent) {
24 val project = e.project ?: return
25 val file = e.getData(CommonDataKeys.VIRTUAL_FILE)
26 val info = RepoResolver.resolveGluecron(project)
27 if (file == null || info == null) {
28 Messages.showWarningDialog(
29 project,
30 "Open a file inside a Gluecron-hosted repo first.",
31 "Gluecron — Ship Spec",
32 )
33 return
34 }
35 val base = project.basePath ?: return
36 val rel = file.path.removePrefix(base).trimStart('/')
37 val encoded = URLEncoder.encode(rel, StandardCharsets.UTF_8)
38 val host = GluecronPlugin.DEFAULT_HOST.trimEnd('/')
39 BrowserUtil.browse("$host/${info.owner}/${info.repo}/specs/new?path=$encoded")
40 }
41}
Addededitor-extensions/jetbrains/src/main/kotlin/com/gluecron/plugin/actions/SignInAction.kt+46−0View fileUnifiedSplit
@@ -0,0 +1,46 @@
1package com.gluecron.plugin.actions
2
3import com.gluecron.plugin.auth.AuthService
4import com.intellij.openapi.actionSystem.AnAction
5import com.intellij.openapi.actionSystem.AnActionEvent
6import com.intellij.openapi.ui.Messages
7
8/**
9 * Prompts the user for a Gluecron personal access token and stashes it
10 * in the IDE password safe.
11 *
12 * The token is NOT round-tripped to the server here — the AuthService
13 * (and the API client) will surface 401s on the next request. We keep
14 * sign-in fast & offline-friendly to match the VS Code flow.
15 */
16class SignInAction : AnAction() {
17
18 override fun actionPerformed(e: AnActionEvent) {
19 val current = AuthService.getInstance().getToken()
20 val prompt = if (current == null) {
21 "Paste a Gluecron PAT (create one at https://gluecron.com/settings/tokens)"
22 } else {
23 "Replace the saved token (current token starts with '${current.take(6)}…')"
24 }
25 val token = Messages.showInputDialog(
26 e.project,
27 prompt,
28 "Gluecron — Sign In",
29 null,
30 )?.trim()
31 if (token.isNullOrEmpty()) return
32 if (!token.startsWith("glc_")) {
33 Messages.showWarningDialog(
34 e.project,
35 "Token should start with 'glc_'. Saving anyway — you can re-run sign-in.",
36 "Gluecron",
37 )
38 }
39 AuthService.getInstance().setToken(token)
40 Messages.showInfoMessage(
41 e.project,
42 "Token saved to the IDE password safe.",
43 "Gluecron",
44 )
45 }
46}
Addededitor-extensions/jetbrains/src/main/kotlin/com/gluecron/plugin/actions/VoiceToPRAction.kt+22−0View fileUnifiedSplit
@@ -0,0 +1,22 @@
1package com.gluecron.plugin.actions
2
3import com.gluecron.plugin.GluecronPlugin
4import com.intellij.ide.BrowserUtil
5import com.intellij.openapi.actionSystem.AnAction
6import com.intellij.openapi.actionSystem.AnActionEvent
7
8/**
9 * Opens the `/voice` console in the user's browser — the same shortcut
10 * the VS Code extension exposes.
11 *
12 * We don't try to embed the voice UI inside the IDE: it relies on the
13 * browser's WebRTC + microphone permissions, which behave better in a
14 * real browser tab than in a JCEF wrapper.
15 */
16class VoiceToPRAction : AnAction() {
17
18 override fun actionPerformed(e: AnActionEvent) {
19 val host = GluecronPlugin.DEFAULT_HOST.trimEnd('/')
20 BrowserUtil.browse("$host/voice")
21 }
22}
Addededitor-extensions/jetbrains/src/main/kotlin/com/gluecron/plugin/api/GluecronClient.kt+104−0View fileUnifiedSplit
@@ -0,0 +1,104 @@
1package com.gluecron.plugin.api
2
3import com.gluecron.plugin.GluecronPlugin
4import com.gluecron.plugin.auth.AuthService
5import io.ktor.client.HttpClient
6import io.ktor.client.call.body
7import io.ktor.client.engine.cio.CIO
8import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
9import io.ktor.client.request.get
10import io.ktor.client.request.headers
11import io.ktor.client.request.post
12import io.ktor.client.request.setBody
13import io.ktor.client.statement.HttpResponse
14import io.ktor.http.ContentType
15import io.ktor.http.HttpHeaders
16import io.ktor.http.HttpStatusCode
17import io.ktor.http.contentType
18import io.ktor.serialization.kotlinx.json.json
19import kotlinx.serialization.Serializable
20import kotlinx.serialization.json.Json
21
22/**
23 * Thin wrapper around `/api/v2` on a Gluecron server.
24 *
25 * Mirrors the slice that the VS Code extension uses so behaviour stays
26 * consistent across editors:
27 *
28 * - GET /api/v2/user — token validation
29 * - POST /api/v2/ai/commit-message — staged-diff → commit text
30 *
31 * Construction is cheap (ktor reuses the engine) but tests can swap a
32 * custom HttpClient via the secondary constructor.
33 */
34class GluecronClient(
35 private val host: String = GluecronPlugin.DEFAULT_HOST,
36 private val token: String? = AuthService.getInstance().getToken(),
37 private val client: HttpClient = defaultClient(),
38) {
39
40 private val baseUrl: String = host.trimEnd('/')
41
42
43 data class User(val username: String? = null, val email: String? = null)
44
45
46 data class CommitMessageRequest(val diff: String, val style: String = "conventional")
47
48
49 data class CommitMessageResponse(val subject: String = "", val body: String = "")
50
51 /**
52 * Probe `/api/v2/user` with the active token. Returns the user record
53 * on success, null on 401/network error — the caller decides how to
54 * surface that to the user.
55 */
56 suspend fun whoami(): User? = runCatching {
57 val res: HttpResponse = client.get("$baseUrl/api/v2/user") {
58 authHeader()
59 }
60 if (res.status != HttpStatusCode.OK) return@runCatching null
61 res.body<User>()
62 }.getOrNull()
63
64 /**
65 * Ask the server to draft a commit message from a unified-diff blob.
66 * Throws on any non-2xx — VCS handlers wrap this in their progress UI.
67 */
68 suspend fun aiCommitMessage(diff: String): CommitMessageResponse {
69 val res: HttpResponse = client.post("$baseUrl/api/v2/ai/commit-message") {
70 authHeader()
71 contentType(ContentType.Application.Json)
72 setBody(CommitMessageRequest(diff = diff))
73 }
74 if (!res.status.isSuccess()) {
75 error("Gluecron API ${res.status.value}: ${res.body<String>().take(200)}")
76 }
77 return res.body()
78 }
79
80 private fun io.ktor.client.request.HttpRequestBuilder.authHeader() {
81 val t = token
82 if (!t.isNullOrBlank()) {
83 headers {
84 append(HttpHeaders.Authorization, "Bearer $t")
85 append(HttpHeaders.Accept, ContentType.Application.Json.toString())
86 }
87 }
88 }
89
90 companion object {
91 private val LAX_JSON = Json {
92 ignoreUnknownKeys = true
93 isLenient = true
94 encodeDefaults = true
95 }
96
97 fun defaultClient(): HttpClient = HttpClient(CIO) {
98 install(ContentNegotiation) { json(LAX_JSON) }
99 expectSuccess = false
100 }
101 }
102}
103
104private fun HttpStatusCode.isSuccess(): Boolean = value in 200..299
Addededitor-extensions/jetbrains/src/main/kotlin/com/gluecron/plugin/auth/AuthService.kt+72−0View fileUnifiedSplit
@@ -0,0 +1,72 @@
1package com.gluecron.plugin.auth
2
3import com.intellij.credentialStore.CredentialAttributes
4import com.intellij.credentialStore.Credentials
5import com.intellij.credentialStore.generateServiceName
6import com.intellij.ide.passwordSafe.PasswordSafe
7import com.intellij.openapi.application.ApplicationManager
8import com.intellij.openapi.components.Service
9import com.intellij.openapi.diagnostic.Logger
10
11/**
12 * Token storage for the Gluecron PAT.
13 *
14 * Resolution order (matches the VS Code extension's behaviour):
15 * 1. IDE password safe (CredentialAttributes — OS keychain on
16 * macOS/Windows/Linux when available, encrypted file fallback).
17 * 2. Environment variable `GLUECRON_TOKEN` — handy for headless setups
18 * and JetBrains Gateway sessions where the keychain isn't reachable.
19 *
20 * Never stored in plain settings.xml — tokens leak through every backup
21 * surface IDEA has.
22 */
23
24class AuthService {
25
26 private val attributes: CredentialAttributes by lazy {
27 CredentialAttributes(generateServiceName(SERVICE_NAME, TOKEN_KEY))
28 }
29
30 /**
31 * Returns the active token, preferring the IDE password safe.
32 * Returns null if no token is configured.
33 */
34 fun getToken(): String? {
35 val stored = PasswordSafe.instance.getPassword(attributes)
36 if (!stored.isNullOrBlank()) return stored.trim()
37 val env = System.getenv("GLUECRON_TOKEN")
38 if (!env.isNullOrBlank()) return env.trim()
39 return null
40 }
41
42 /**
43 * Persist a PAT in the password safe. Pass null/blank to clear.
44 */
45 fun setToken(value: String?) {
46 val trimmed = value?.trim().orEmpty()
47 if (trimmed.isEmpty()) {
48 PasswordSafe.instance.set(attributes, null)
49 LOG.info("Gluecron token cleared from password safe.")
50 return
51 }
52 PasswordSafe.instance.set(attributes, Credentials(USER_PLACEHOLDER, trimmed))
53 LOG.info("Gluecron token stored in password safe.")
54 }
55
56 /** True if any token (safe or env) is available. */
57 fun isSignedIn(): Boolean = !getToken().isNullOrBlank()
58
59 companion object {
60 private const val SERVICE_NAME = "Gluecron"
61 private const val TOKEN_KEY = "personal-access-token"
62 // PasswordSafe wants a user — the value is meaningless to us but
63 // some backends key on it, so use a stable placeholder.
64 private const val USER_PLACEHOLDER = "gluecron"
65
66 private val LOG = Logger.getInstance(AuthService::class.java)
67
68
69 fun getInstance(): AuthService =
70 ApplicationManager.getApplication().getService(AuthService::class.java)
71 }
72}
Addededitor-extensions/jetbrains/src/main/kotlin/com/gluecron/plugin/git/RepoResolver.kt+102−0View fileUnifiedSplit
@@ -0,0 +1,102 @@
1package com.gluecron.plugin.git
2
3import com.gluecron.plugin.GluecronPlugin
4import com.intellij.openapi.diagnostic.Logger
5import com.intellij.openapi.project.Project
6import java.io.File
7import java.net.URI
8
9/**
10 * Owner/repo resolver — shells out to `git remote get-url origin` and
11 * parses the URL with the same rules as the VS Code extension's
12 * `git.ts#parseGitRemote`. Kept dependency-free so we can unit-test the
13 * parser without an IDE.
14 */
15data class RepoInfo(val owner: String, val repo: String, val host: String)
16
17object RepoResolver {
18
19 private val LOG = Logger.getInstance(RepoResolver::class.java)
20
21 /**
22 * Run `git remote get-url origin` in the project root (the first
23 * content root). Returns null if there is no project, no remote, or
24 * the URL doesn't parse.
25 */
26 fun resolve(project: Project): RepoInfo? {
27 val root = project.basePath ?: return null
28 val url = runGit(root, "remote", "get-url", "origin") ?: return null
29 return parseGitRemote(url)
30 }
31
32 /**
33 * Returns the resolver result only when the remote is on the
34 * configured Gluecron host. Plays the same role as
35 * `getGluecronRepoInfo` in the VS Code extension.
36 */
37 fun resolveGluecron(project: Project, configuredHost: String = GluecronPlugin.DEFAULT_HOST): RepoInfo? {
38 val info = resolve(project) ?: return null
39 return if (isGluecronRemote(info.host, configuredHost)) info else null
40 }
41
42 /**
43 * Parse any of the URL flavours git understands:
44 * - https://gluecron.com/owner/repo.git
45 * - https://user:pw@gluecron.com/owner/repo
46 * - http://localhost:3000/owner/repo.git
47 * - git@gluecron.com:owner/repo.git
48 * - ssh://git@gluecron.com:2222/owner/repo.git
49 */
50 fun parseGitRemote(raw: String): RepoInfo? {
51 val url = raw.trim()
52 if (url.isEmpty()) return null
53
54 // SCP-style: user@host:owner/repo[.git]
55 val scp = SCP_REGEX.matchEntire(url)
56 if (scp != null) {
57 val host = scp.groupValues[1]
58 val (owner, repo) = splitPath(scp.groupValues[2]) ?: return null
59 return RepoInfo(owner, repo, host)
60 }
61
62 // URL forms (http, https, ssh, git)
63 val parsed = runCatching { URI(url) }.getOrNull() ?: return null
64 val host = parsed.host?.lowercase() ?: return null
65 val path = parsed.path?.trimStart('/')?.removeSuffix(".git")?.removeSuffix("/") ?: return null
66 val (owner, repo) = splitPath(path) ?: return null
67 return RepoInfo(owner, repo, host)
68 }
69
70 fun isGluecronRemote(remoteHost: String, configuredHost: String): Boolean {
71 if (remoteHost.isBlank()) return false
72 val cfgHost = runCatching { URI(configuredHost).host }.getOrNull()
73 ?: configuredHost.substringAfter("://").substringBefore('/')
74 return remoteHost.equals(cfgHost, ignoreCase = true)
75 }
76
77 private fun splitPath(path: String): Pair<String, String>? {
78 val parts = path.split('/').filter { it.isNotEmpty() }
79 if (parts.size < 2) return null
80 // Owner/repo are always the LAST two segments — handles path-prefixed
81 // self-hosted setups like /git/owner/repo.
82 return parts[parts.size - 2] to parts[parts.size - 1]
83 }
84
85 private fun runGit(cwd: String, vararg args: String): String? {
86 return try {
87 val p = ProcessBuilder(listOf("git") + args.toList())
88 .directory(File(cwd))
89 .redirectErrorStream(false)
90 .start()
91 val out = p.inputStream.bufferedReader().readText()
92 val finished = p.waitFor()
93 if (finished != 0) null else out.trim().ifEmpty { null }
94 } catch (e: Exception) {
95 LOG.debug("git ${args.joinToString(" ")} failed: ${e.message}")
96 null
97 }
98 }
99
100 // SCP form has no `//` after the scheme — URI() refuses it.
101 private val SCP_REGEX = Regex("""^[^@\s]+@([^:\s]+):(.+?)(?:\.git)?/?$""")
102}
Addededitor-extensions/jetbrains/src/main/kotlin/com/gluecron/plugin/toolwindow/GluecronToolWindow.kt+73−0View fileUnifiedSplit
@@ -0,0 +1,73 @@
1package com.gluecron.plugin.toolwindow
2
3import com.gluecron.plugin.GluecronPlugin
4import com.gluecron.plugin.git.RepoResolver
5import com.intellij.openapi.project.Project
6import com.intellij.openapi.wm.ToolWindow
7import com.intellij.openapi.wm.ToolWindowFactory
8import com.intellij.ui.components.JBLabel
9import com.intellij.ui.components.JBTabbedPane
10import com.intellij.ui.jcef.JBCefApp
11import com.intellij.ui.jcef.JBCefBrowser
12import java.awt.BorderLayout
13import javax.swing.JComponent
14import javax.swing.JPanel
15import javax.swing.SwingConstants
16
17/**
18 * Tool window factory — registered in plugin.xml under id "Gluecron".
19 *
20 * Lays out four JCEF browser tabs (Chat / PRs / Issues / Standups),
21 * each pointing at the relevant `?embed=1` URL on the configured host.
22 * If JCEF is unavailable (e.g. JetBrains Gateway thin client) we
23 * fall back to a friendly notice that links out to the web UI.
24 */
25class GluecronToolWindow : ToolWindowFactory {
26
27 override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
28 val tabs = JBTabbedPane()
29 val host = GluecronPlugin.DEFAULT_HOST.trimEnd('/')
30 val info = RepoResolver.resolveGluecron(project)
31
32 if (info == null) {
33 tabs.addTab("Gluecron", emptyState("Open a folder backed by a Gluecron-hosted repo to see chat, PRs, issues, and standups here."))
34 } else {
35 val base = "$host/${info.owner}/${info.repo}"
36 tabs.addTab("Chat", browserPanel("$base/chat?embed=1"))
37 tabs.addTab("PRs", browserPanel("$base/pulls?embed=1"))
38 tabs.addTab("Issues", browserPanel("$base/issues?embed=1"))
39 tabs.addTab("Standups", browserPanel("$base/standups?embed=1"))
40 }
41
42 val panel = JPanel(BorderLayout()).apply {
43 add(tabs, BorderLayout.CENTER)
44 }
45 val content = toolWindow.contentManager.factory.createContent(panel, "", false)
46 toolWindow.contentManager.addContent(content)
47 }
48
49 override fun shouldBeAvailable(project: Project): Boolean = true
50
51 private fun browserPanel(url: String): JComponent {
52 if (!JBCefApp.isSupported()) {
53 return emptyState(
54 "Embedded browser (JCEF) isn't available in this IDE distribution. " +
55 "Open $url in your browser instead.",
56 )
57 }
58 val browser = JBCefBrowser(url)
59 return JPanel(BorderLayout()).apply {
60 add(browser.component, BorderLayout.CENTER)
61 }
62 }
63
64 private fun emptyState(text: String): JComponent {
65 return JPanel(BorderLayout()).apply {
66 val label = JBLabel(
67 "<html><div style='padding:16px; max-width:320px'>$text</div></html>",
68 SwingConstants.LEFT,
69 )
70 add(label, BorderLayout.NORTH)
71 }
72 }
73}
Addededitor-extensions/jetbrains/src/main/resources/META-INF/plugin.xml+132−0View fileUnifiedSplit
@@ -0,0 +1,132 @@
1<idea-plugin>
2 <id>com.gluecron.plugin</id>
3 <name>Gluecron</name>
4 <vendor email="hello@gluecron.com" url="https://gluecron.com">gluecron</vendor>
5
6 <description><![CDATA[
7 <h2>Gluecron — AI-native git in your IDE</h2>
8 <p>
9 Chat with the current repo, ship PRs, run specs, draft commit messages,
10 and jump to the Gluecron web UI without leaving JetBrains. Works in
11 IntelliJ IDEA, WebStorm, GoLand, PyCharm, RustRover, and Rider.
12 </p>
13 <ul>
14 <li><b>Repo chat</b> — sidebar tool window grounded in the active repo.</li>
15 <li><b>AI commit messages</b> — sparkle button on the VCS commit dialog.</li>
16 <li><b>PRs / Issues / Standups</b> — embedded views of the live web UI.</li>
17 <li><b>Ship spec</b> + <b>Voice-to-PR</b> — open the right flow with one click.</li>
18 </ul>
19 ]]></description>
20
21 <change-notes><![CDATA[
22 <ul>
23 <li><b>0.1.0</b> — initial release. Mirrors the VS Code extension surface.</li>
24 </ul>
25 ]]></change-notes>
26
27 <!--
28 com.intellij.modules.platform is enough for every JetBrains IDE 2024.1+;
29 we additionally depend on Git4Idea so VCS-bound actions compile against
30 the same APIs the bundled git integration uses.
31 -->
32 <depends>com.intellij.modules.platform</depends>
33 <depends>Git4Idea</depends>
34
35 <!-- Project-level startup. -->
36 <extensions defaultExtensionNs="com.intellij">
37 <postStartupActivity implementation="com.gluecron.plugin.GluecronPlugin"/>
38
39 <!--
40 Sidebar tool window. We register the icon under META-INF/pluginIcon,
41 but JetBrains expects tool-window icons to live under /icons.
42 -->
43 <toolWindow
44 id="Gluecron"
45 anchor="right"
46 icon="/icons/toolWindowIcon.svg"
47 factoryClass="com.gluecron.plugin.toolwindow.GluecronToolWindow"/>
48
49 <!--
50 Application-level service (token storage).
51 -->
52 <applicationService serviceImplementation="com.gluecron.plugin.auth.AuthService"/>
53 </extensions>
54
55 <!--
56 Actions and where they live in the menus.
57 -->
58 <actions>
59 <group id="Gluecron.MainGroup" text="Gluecron" popup="true" icon="/icons/toolWindowIcon.svg">
60 <add-to-group group-id="ToolsMenu" anchor="last"/>
61 </group>
62
63 <action
64 id="Gluecron.SignIn"
65 class="com.gluecron.plugin.actions.SignInAction"
66 text="Sign In (Personal Access Token)"
67 description="Store a Gluecron PAT in the IDE password safe.">
68 <add-to-group group-id="Gluecron.MainGroup" anchor="first"/>
69 </action>
70
71 <action
72 id="Gluecron.ChatWithRepo"
73 class="com.gluecron.plugin.actions.ChatWithRepoAction"
74 text="Chat with This Repo"
75 description="Open the Gluecron tool window and focus the chat view.">
76 <add-to-group group-id="Gluecron.MainGroup"/>
77 <add-to-group group-id="EditorPopupMenu" anchor="last"/>
78 </action>
79
80 <action
81 id="Gluecron.OpenPRs"
82 class="com.gluecron.plugin.actions.OpenPRsAction"
83 text="Open Pull Requests"
84 description="Open the current repo's pull-requests page in your browser.">
85 <add-to-group group-id="Gluecron.MainGroup"/>
86 <add-to-group group-id="VcsGroups" anchor="last"/>
87 </action>
88
89 <action
90 id="Gluecron.OpenIssues"
91 class="com.gluecron.plugin.actions.OpenIssuesAction"
92 text="Open Issues"
93 description="Open the current repo's issues page in your browser.">
94 <add-to-group group-id="Gluecron.MainGroup"/>
95 </action>
96
97 <action
98 id="Gluecron.OpenStandups"
99 class="com.gluecron.plugin.actions.OpenStandupsAction"
100 text="Open AI Standups"
101 description="Open the current repo's AI Standups page in your browser.">
102 <add-to-group group-id="Gluecron.MainGroup"/>
103 </action>
104
105 <action
106 id="Gluecron.ShipSpec"
107 class="com.gluecron.plugin.actions.ShipSpecAction"
108 text="Ship Current File as Spec"
109 description="Send the active file to Gluecron's spec wizard.">
110 <add-to-group group-id="Gluecron.MainGroup"/>
111 <add-to-group group-id="EditorPopupMenu" anchor="last"/>
112 </action>
113
114 <action
115 id="Gluecron.VoiceToPR"
116 class="com.gluecron.plugin.actions.VoiceToPRAction"
117 text="Voice-to-PR"
118 description="Open the Gluecron voice console.">
119 <add-to-group group-id="Gluecron.MainGroup"/>
120 </action>
121
122 <action
123 id="Gluecron.GenerateCommitMessage"
124 class="com.gluecron.plugin.actions.GenerateCommitMessageAction"
125 text="Generate AI Commit Message"
126 description="Draft a commit message from the staged diff and drop it into the dialog."
127 icon="/icons/commitMessageIcon.svg">
128 <add-to-group group-id="Vcs.MessageActionGroup" anchor="last"/>
129 <add-to-group group-id="Gluecron.MainGroup"/>
130 </action>
131 </actions>
132</idea-plugin>
Addededitor-extensions/jetbrains/src/main/resources/META-INF/pluginIcon.svg+8−0View fileUnifiedSplit
@@ -0,0 +1,8 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40" fill="none">
2 <!-- Marketplace pluginIcon: 40x40 per JetBrains spec. Mirrors the
3 globe-with-meridians mark used by the VS Code extension. -->
4 <circle cx="20" cy="20" r="15" stroke="#1f6feb" stroke-width="2.4"/>
5 <path d="M5 20h30" stroke="#1f6feb" stroke-width="2.4" stroke-linecap="round"/>
6 <path d="M20 5a22 22 0 0 1 0 30" stroke="#1f6feb" stroke-width="2.4" stroke-linecap="round"/>
7 <path d="M20 5a22 22 0 0 0 0 30" stroke="#1f6feb" stroke-width="2.4" stroke-linecap="round"/>
8</svg>
Addededitor-extensions/jetbrains/src/main/resources/icons/commitMessageIcon.svg+7−0View fileUnifiedSplit
@@ -0,0 +1,7 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
2 <!-- Four-pointed sparkle, JetBrains-action icon convention. -->
3 <path d="M8 1.5 9.2 6 13.5 8 9.2 10 8 14.5 6.8 10 2.5 8 6.8 6 Z"
4 fill="currentColor"/>
5 <path d="M13 1.5 13.5 3.5 15.5 4 13.5 4.5 13 6.5 12.5 4.5 10.5 4 12.5 3.5 Z"
6 fill="currentColor" opacity="0.6"/>
7</svg>
Addededitor-extensions/jetbrains/src/main/resources/icons/toolWindowIcon.svg+8−0View fileUnifiedSplit
@@ -0,0 +1,8 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 13 13" fill="none">
2 <!-- 13x13 is the JetBrains-recommended size for tool-window icons.
3 Single-color so IntelliJ can recolor for light/dark themes. -->
4 <circle cx="6.5" cy="6.5" r="5" stroke="currentColor" stroke-width="1.1"/>
5 <path d="M1.5 6.5h10" stroke="currentColor" stroke-width="1.1" stroke-linecap="round"/>
6 <path d="M6.5 1.5a7 7 0 0 1 0 10" stroke="currentColor" stroke-width="1.1" stroke-linecap="round"/>
7 <path d="M6.5 1.5a7 7 0 0 0 0 10" stroke="currentColor" stroke-width="1.1" stroke-linecap="round"/>
8</svg>
Modifiedsrc/__tests__/install.test.ts+25−0View fileUnifiedSplit
@@ -172,6 +172,31 @@ describe("install — GET /install/vscode", () => {
172172 });
173173});
174174
175// ---------------------------------------------------------------------------
176// 1c. GET /install/jetbrains — JetBrains plugin landing
177// ---------------------------------------------------------------------------
178
179describe("install — GET /install/jetbrains", () => {
180 it("returns 200 with an HTML install page", async () => {
181 const res = await app.request("/install/jetbrains");
182 expect(res.status).toBe(200);
183 const ct = res.headers.get("content-type") || "";
184 expect(ct).toContain("text/html");
185 const body = await res.text();
186 expect(body).toContain("Gluecron for JetBrains");
187 // Points users at the plugin source + names the JetBrains IDEs we cover.
188 expect(body).toContain("editor-extensions/jetbrains");
189 expect(body).toContain("IntelliJ");
190 expect(body).toContain("WebStorm");
191 });
192
193 it("serves a cacheable response", async () => {
194 const res = await app.request("/install/jetbrains");
195 const cc = res.headers.get("cache-control") || "";
196 expect(cc).toContain("public");
197 });
198});
199
175200// ---------------------------------------------------------------------------
176201// 2. POST /api/v2/auth/install-token — auth contract
177202// ---------------------------------------------------------------------------
Modifiedsrc/routes/install.ts+104−0View fileUnifiedSplit
@@ -304,4 +304,108 @@ install.get("/install/vscode", (c) => {
304304 return c.body(vscodeLandingHtml(vsix));
305305});
306306
307// ─── JetBrains plugin landing ───────────────────────────────────────────────
308//
309// Same pattern as the VS Code landing above: marketplace badge + sideload
310// .zip link if `./gradlew buildPlugin` has been run and the artifact is
311// staged under `public/`. The plugin .zip is named
312// `gluecron-jetbrains-<version>.zip` (declared in build.gradle.kts).
313
314const JETBRAINS_MARKETPLACE_URL =
315 "https://plugins.jetbrains.com/plugin/com.gluecron.plugin";
316
317function findJetbrainsZip(): { name: string; size: number } | null {
318 const candidates = [
319 join(process.cwd(), "public"),
320 join(import.meta.dir, "..", "..", "public"),
321 ];
322 for (const dir of candidates) {
323 try {
324 if (!existsSync(dir)) continue;
325 const files = readdirSync(dir).filter(
326 (f) => f.startsWith("gluecron-jetbrains-") && f.endsWith(".zip")
327 );
328 if (files.length === 0) continue;
329 files.sort();
330 const pick = files[files.length - 1];
331 const st = statSync(join(dir, pick));
332 return { name: pick, size: st.size };
333 } catch {
334 // try the next candidate
335 }
336 }
337 return null;
338}
339
340function jetbrainsLandingHtml(
341 zip: { name: string; size: number } | null
342): string {
343 const zipBlock = zip
344 ? `
345 <p>
346 <a class="cta" href="/${encodeURIComponent(zip.name)}" download>
347 Download ${zip.name} (${formatBytes(zip.size)})
348 </a>
349 </p>
350 <p class="muted">In your IDE: <b>Settings → Plugins → ⚙ → Install Plugin from Disk…</b></p>`
351 : `
352 <p class="muted">
353 No plugin <code>.zip</code> uploaded yet — build one yourself:
354 </p>
355 <pre><code>git clone https://gluecron.com/ccantynz/Gluecron.com
356cd Gluecron.com/editor-extensions/jetbrains
357./gradlew buildPlugin
358# artifact: build/distributions/gluecron-jetbrains-0.1.0.zip</code></pre>`;
359
360 return `<!DOCTYPE html>
361<html lang="en">
362<head>
363<meta charset="utf-8" />
364<title>Gluecron for JetBrains</title>
365<meta name="viewport" content="width=device-width, initial-scale=1" />
366<style>
367 :root { color-scheme: dark light; }
368 body { font: 15px/1.55 system-ui, sans-serif; max-width: 640px; margin: 5rem auto; padding: 0 1rem; }
369 h1 { margin-top: 0; font-size: 1.6rem; }
370 .badge { display: inline-block; padding: 4px 10px; border-radius: 4px; background: #2c2c2c; color: #fff; font-size: 13px; text-decoration: none; }
371 .cta { display: inline-block; padding: 8px 14px; border-radius: 6px; background: #1f6feb; color: #fff; text-decoration: none; font-weight: 600; }
372 pre { background: #0e1116; color: #e6edf3; padding: 12px; border-radius: 6px; overflow-x: auto; }
373 code { font: 13px ui-monospace, monospace; }
374 .muted { opacity: 0.75; }
375 ul { padding-left: 1.2rem; }
376</style>
377</head>
378<body>
379 <h1>Gluecron for JetBrains IDEs</h1>
380 <p>
381 AI-native git inside IntelliJ IDEA, WebStorm, GoLand, PyCharm,
382 RustRover, and Rider — chat with the repo, draft commit messages,
383 ship specs, voice-to-PR.
384 </p>
385 <p>
386 <a class="badge" href="${JETBRAINS_MARKETPLACE_URL}" rel="nofollow">
387 Marketplace listing (coming soon)
388 </a>
389 </p>
390 <h2>Install</h2>
391 ${zipBlock}
392 <h2>What you get</h2>
393 <ul>
394 <li>Sidebar <strong>tool window</strong> with Chat / PRs / Issues / Standups tabs</li>
395 <li>One-click <strong>AI commit messages</strong> in the VCS commit dialog</li>
396 <li><strong>Ship spec</strong> + <strong>Voice-to-PR</strong> from the Tools menu</li>
397 <li>Works on every JetBrains IDE 2024.1+ (IDEA, WebStorm, GoLand, PyCharm, RustRover, Rider)</li>
398 </ul>
399 <p class="muted">Source: <a href="/ccantynz/Gluecron.com/tree/main/editor-extensions/jetbrains">editor-extensions/jetbrains</a></p>
400</body>
401</html>`;
402}
403
404install.get("/install/jetbrains", (c) => {
405 const zip = findJetbrainsZip();
406 c.header("content-type", "text/html; charset=utf-8");
407 c.header("cache-control", "public, max-age=300");
408 return c.body(jetbrainsLandingHtml(zip));
409});
410
307411export default install;
308412