Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit1ab8aceunknown_key

feat(jetbrains): JetBrains plugin — chat / commit / PRs / specs / voice in IntelliJ + WebStorm + GoLand + PyCharm

Claude committed on May 26, 2026Parent: 5ca514a
24 files changed+126201ab8aceea5b49eb4e3880777e9f1e337bbaf93df
24 changed files+1262−0
Addededitor-extensions/jetbrains/CONTRIBUTING.md+82−0View fileUnifiedSplit
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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 @Serializable
43 data class User(val username: String? = null, val email: String? = null)
44
45 @Serializable
46 data class CommitMessageRequest(val diff: String, val style: String = "conventional")
47
48 @Serializable
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
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@Service(Service.Level.APP)
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 @JvmStatic
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
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
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
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&nbsp;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
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
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
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
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
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&nbsp;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