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

feat(vscode): VS Code extension — chat / commit / PRs / specs / voice in-editor

Claude committed on May 25, 2026Parent: 0c3eee5
15 files changed+1281263fe719ebdc75cc28775c1d332c32ae1379a7a80
15 changed files+1281−2
Addededitor-extensions/vscode/.gitignore+4−0View fileUnifiedSplit
1out/
2node_modules/
3*.vsix
4.vscode-test/
Addededitor-extensions/vscode/.vscodeignore+12−0View fileUnifiedSplit
1.vscode/**
2.vscode-test/**
3src/**
4.gitignore
5.yarnrc
6**/tsconfig.json
7**/.eslintrc.json
8**/*.map
9**/*.ts
10node_modules/**
11**/__tests__/**
12.github/**
Addededitor-extensions/vscode/README.md+79−0View fileUnifiedSplit
1# Gluecron for VS Code
2
3AI-native git inside your editor — chat with the repo, ship PRs, run specs, voice-to-PR, and let Claude write your commit messages.
4
5> The Gluecron platform: https://gluecron.com
6
7## Features
8
9- **Repo chat** — sidebar chat grounded in the current repository (uses the same retrieval/citation pipeline as the web UI).
10- **AI commit messages** — click the sparkle in the Source Control title bar to drop a Conventional Commits–style message into the input box, drafted from your staged diff.
11- **Open in Gluecron** — jump from the active file (and line) straight to its blob page on the server.
12- **Pull requests / Issues / Standups** — sidebar webviews that embed the live web UI; everything works without a second auth round-trip.
13- **Ship a spec** — turn the current file into a Gluecron spec (auto-generates a draft PR).
14- **Voice-to-PR** — open the `/voice` console for phone-first dictation.
15
16## Install
17
18### From a .vsix (current path)
19
20```bash
21cd editor-extensions/vscode
22npm install
23npm run compile
24npx vsce package
25code --install-extension gluecron-vscode-0.1.0.vsix
26```
27
28### From the marketplace (coming soon)
29
30Once published, install from the Extensions panel by searching for **Gluecron**.
31
32## Configure
33
34| Setting | Default | Description |
35| --- | --- | --- |
36| `gluecron.host` | `https://gluecron.com` | Override for self-hosted instances. Accepts any URL the browser would. |
37| `gluecron.defaultBranch` | `main` | Branch used when constructing `…/blob/<branch>/…` deep-links. |
38
39Sign in with a Personal Access Token (`glc_…`). Create one at `${host}/settings/tokens` (the **admin** scope is required for the commit-message API).
40
41## Commands
42
43| Command | Default trigger |
44| --- | --- |
45| `Gluecron: Sign In (Personal Access Token)` | Command palette |
46| `Gluecron: Chat With This Repo` | Command palette / activity bar icon |
47| `Gluecron: Open Current File on Web` | Editor context menu |
48| `Gluecron: Open Pull Requests` | Command palette |
49| `Gluecron: Ship Current File as Spec` | Command palette |
50| `Gluecron: Voice-to-PR` | Command palette |
51| `Generate AI Commit Message` | Source Control title bar (`$(sparkle)`) |
52
53## Screenshots
54
55_Coming soon — drop PNGs into `editor-extensions/vscode/media/` and reference them here._
56
57- `media/chat.png` — sidebar chat
58- `media/commit.png` — sparkle in the SCM title bar
59- `media/pulls.png` — pulls sidebar
60
61## How it talks to the server
62
63- `POST /api/v2/ai/commit-message` for AI-drafted commit messages (see `src/lib/ai-commit-message.ts`).
64- `GET /api/v2/user` to validate PATs.
65- The chat / pulls / issues / standups sidebars are iframes pointing at the web UI's `?embed=1` views — no separate auth surface, no duplicated rendering code.
66
67## Development
68
69```bash
70npm install
71npm run watch # type-check on save
72npm run test # node --test (pure git URL parser unit tests)
73```
74
75To debug against a self-hosted Gluecron, set `gluecron.host` in your User Settings (e.g. `http://localhost:3000`) and reload the window.
76
77## License
78
79MIT — same as the rest of the Gluecron source.
Addededitor-extensions/vscode/media/gluecron.svg+6−0View fileUnifiedSplit
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
2 <circle cx="12" cy="12" r="9" />
3 <path d="M3 12h18" />
4 <path d="M12 3a14 14 0 0 1 0 18" />
5 <path d="M12 3a14 14 0 0 0 0 18" />
6</svg>
Addededitor-extensions/vscode/package.json+166−0View fileUnifiedSplit
1{
2 "name": "gluecron-vscode",
3 "displayName": "Gluecron",
4 "description": "AI-native git: chat with this repo, ship PRs, run specs, voice-to-PR",
5 "version": "0.1.0",
6 "publisher": "gluecron",
7 "license": "MIT",
8 "repository": {
9 "type": "git",
10 "url": "https://gluecron.com/ccantynz/Gluecron.com.git"
11 },
12 "engines": {
13 "vscode": "^1.85.0"
14 },
15 "categories": [
16 "SCM Providers",
17 "AI",
18 "Chat",
19 "Other"
20 ],
21 "keywords": [
22 "git",
23 "gluecron",
24 "ai",
25 "chat",
26 "pull-request",
27 "spec",
28 "voice"
29 ],
30 "main": "./out/extension.js",
31 "activationEvents": [
32 "onStartupFinished"
33 ],
34 "contributes": {
35 "configuration": {
36 "title": "Gluecron",
37 "properties": {
38 "gluecron.host": {
39 "type": "string",
40 "default": "https://gluecron.com",
41 "description": "Gluecron server URL (override for self-hosted instances)."
42 },
43 "gluecron.defaultBranch": {
44 "type": "string",
45 "default": "main",
46 "description": "Default branch used when constructing blob URLs."
47 }
48 }
49 },
50 "commands": [
51 {
52 "command": "gluecron.signIn",
53 "title": "Gluecron: Sign In (Personal Access Token)"
54 },
55 {
56 "command": "gluecron.signOut",
57 "title": "Gluecron: Sign Out"
58 },
59 {
60 "command": "gluecron.chatWithRepo",
61 "title": "Gluecron: Chat With This Repo"
62 },
63 {
64 "command": "gluecron.openInGluecron",
65 "title": "Gluecron: Open Current File on Web"
66 },
67 {
68 "command": "gluecron.openPRs",
69 "title": "Gluecron: Open Pull Requests"
70 },
71 {
72 "command": "gluecron.openIssues",
73 "title": "Gluecron: Open Issues"
74 },
75 {
76 "command": "gluecron.openStandups",
77 "title": "Gluecron: Open AI Standups"
78 },
79 {
80 "command": "gluecron.shipSpec",
81 "title": "Gluecron: Ship Current File as Spec"
82 },
83 {
84 "command": "gluecron.voiceToPR",
85 "title": "Gluecron: Voice-to-PR"
86 },
87 {
88 "command": "gluecron.aiCommitMessage",
89 "title": "Generate AI Commit Message",
90 "icon": "$(sparkle)"
91 }
92 ],
93 "viewsContainers": {
94 "activitybar": [
95 {
96 "id": "gluecron-sidebar",
97 "title": "Gluecron",
98 "icon": "media/gluecron.svg"
99 }
100 ]
101 },
102 "views": {
103 "gluecron-sidebar": [
104 {
105 "type": "webview",
106 "id": "gluecron.chat",
107 "name": "Chat",
108 "icon": "media/gluecron.svg",
109 "contextualTitle": "Repo Chat"
110 },
111 {
112 "type": "webview",
113 "id": "gluecron.pulls",
114 "name": "Pull Requests"
115 },
116 {
117 "type": "webview",
118 "id": "gluecron.issues",
119 "name": "Issues"
120 },
121 {
122 "type": "webview",
123 "id": "gluecron.standups",
124 "name": "AI Standups"
125 }
126 ]
127 },
128 "menus": {
129 "scm/title": [
130 {
131 "command": "gluecron.aiCommitMessage",
132 "group": "navigation",
133 "when": "scmProvider == git"
134 }
135 ],
136 "editor/context": [
137 {
138 "command": "gluecron.openInGluecron",
139 "group": "gluecron@1"
140 },
141 {
142 "command": "gluecron.chatWithRepo",
143 "group": "gluecron@2"
144 }
145 ],
146 "commandPalette": [
147 {
148 "command": "gluecron.signOut",
149 "when": "gluecron:signedIn"
150 }
151 ]
152 }
153 },
154 "scripts": {
155 "compile": "tsc -p .",
156 "watch": "tsc -watch -p .",
157 "package": "vsce package --no-dependencies",
158 "test": "node --test out/__tests__/extension.test.js"
159 },
160 "devDependencies": {
161 "@types/node": "^20.11.0",
162 "@types/vscode": "^1.85.0",
163 "@vscode/vsce": "^3.1.0",
164 "typescript": "^5.3.0"
165 }
166}
Addededitor-extensions/vscode/src/__tests__/extension.test.ts+123−0View fileUnifiedSplit
1/**
2 * Pure unit tests for the git-remote parser.
3 *
4 * Run with: node --test out/__tests__/extension.test.js
5 * (i.e. after `tsc -p .`) — keeps the test runner zero-dependency, matches
6 * the rest of the extension's "no extra dev deps" stance.
7 */
8
9import { strict as assert } from "node:assert";
10import { test } from "node:test";
11import {
12 buildBlobUrl,
13 isGluecronRemote,
14 parseGitRemote,
15} from "../git";
16
17test("parseGitRemote: https remote with .git suffix", () => {
18 const r = parseGitRemote("https://gluecron.com/ccantynz/Gluecron.com.git");
19 assert.deepEqual(r, {
20 owner: "ccantynz",
21 repo: "Gluecron.com",
22 host: "gluecron.com",
23 });
24});
25
26test("parseGitRemote: https remote without .git suffix", () => {
27 const r = parseGitRemote("https://gluecron.com/acme/widgets");
28 assert.deepEqual(r, { owner: "acme", repo: "widgets", host: "gluecron.com" });
29});
30
31test("parseGitRemote: scp-style git@host:owner/repo.git", () => {
32 const r = parseGitRemote("git@gluecron.com:ccantynz/Gluecron.com.git");
33 assert.deepEqual(r, {
34 owner: "ccantynz",
35 repo: "Gluecron.com",
36 host: "gluecron.com",
37 });
38});
39
40test("parseGitRemote: ssh:// URL with port", () => {
41 const r = parseGitRemote("ssh://git@gluecron.com:2222/acme/widgets.git");
42 assert.deepEqual(r, { owner: "acme", repo: "widgets", host: "gluecron.com" });
43});
44
45test("parseGitRemote: http localhost with port", () => {
46 const r = parseGitRemote("http://localhost:3000/me/repo.git");
47 assert.deepEqual(r, { owner: "me", repo: "repo", host: "localhost" });
48});
49
50test("parseGitRemote: URL with embedded credentials", () => {
51 const r = parseGitRemote("https://user:pat@gluecron.com/acme/widgets.git");
52 assert.deepEqual(r, { owner: "acme", repo: "widgets", host: "gluecron.com" });
53});
54
55test("parseGitRemote: nested path prefix takes the last two segments", () => {
56 const r = parseGitRemote("https://example.com/git/acme/widgets.git");
57 assert.deepEqual(r, { owner: "acme", repo: "widgets", host: "example.com" });
58});
59
60test("parseGitRemote: empty + garbage inputs return null", () => {
61 assert.equal(parseGitRemote(""), null);
62 assert.equal(parseGitRemote(" "), null);
63 assert.equal(parseGitRemote("not-a-url"), null);
64 assert.equal(parseGitRemote("https://gluecron.com/justowner"), null);
65 // @ts-expect-error — runtime guard for callers that lose types.
66 assert.equal(parseGitRemote(null), null);
67 // @ts-expect-error
68 assert.equal(parseGitRemote(undefined), null);
69});
70
71test("isGluecronRemote: matches plain hostname", () => {
72 assert.equal(isGluecronRemote("gluecron.com", "https://gluecron.com"), true);
73 assert.equal(isGluecronRemote("Gluecron.com", "https://gluecron.com"), true);
74 assert.equal(isGluecronRemote("gluecron.com", "https://example.com"), false);
75});
76
77test("isGluecronRemote: ignores port + path on the configured host", () => {
78 assert.equal(
79 isGluecronRemote("localhost", "http://localhost:3000/some/path"),
80 true
81 );
82});
83
84test("isGluecronRemote: handles a bare hostname (no scheme) config value", () => {
85 assert.equal(isGluecronRemote("gluecron.com", "gluecron.com"), true);
86});
87
88test("buildBlobUrl: composes owner/repo/branch/path", () => {
89 const u = buildBlobUrl(
90 "https://gluecron.com",
91 "acme",
92 "widgets",
93 "main",
94 "src/index.ts"
95 );
96 assert.equal(u, "https://gluecron.com/acme/widgets/blob/main/src/index.ts");
97});
98
99test("buildBlobUrl: appends 1-indexed line anchor", () => {
100 const u = buildBlobUrl(
101 "https://gluecron.com",
102 "acme",
103 "widgets",
104 "main",
105 "src/index.ts",
106 41
107 );
108 assert.equal(
109 u,
110 "https://gluecron.com/acme/widgets/blob/main/src/index.ts#L42"
111 );
112});
113
114test("buildBlobUrl: strips trailing slashes from host + leading slashes from path", () => {
115 const u = buildBlobUrl(
116 "https://gluecron.com/",
117 "acme",
118 "widgets",
119 "main",
120 "/src/index.ts"
121 );
122 assert.equal(u, "https://gluecron.com/acme/widgets/blob/main/src/index.ts");
123});
Addededitor-extensions/vscode/src/auth.ts+86−0View fileUnifiedSplit
1/**
2 * Token storage + sign-in flow.
3 *
4 * Tokens are persisted via `vscode.SecretStorage` (OS keychain on
5 * macOS/Windows/Linux). We never write them to settings.json or disk.
6 */
7
8import * as vscode from "vscode";
9
10export const SECRET_KEY = "gluecron.pat";
11const CONTEXT_KEY = "gluecron:signedIn";
12
13export async function getToken(context: vscode.ExtensionContext): Promise<string | undefined> {
14 return context.secrets.get(SECRET_KEY);
15}
16
17export async function setToken(
18 context: vscode.ExtensionContext,
19 token: string
20): Promise<void> {
21 await context.secrets.store(SECRET_KEY, token);
22 await vscode.commands.executeCommand("setContext", CONTEXT_KEY, true);
23}
24
25export async function clearToken(context: vscode.ExtensionContext): Promise<void> {
26 await context.secrets.delete(SECRET_KEY);
27 await vscode.commands.executeCommand("setContext", CONTEXT_KEY, false);
28}
29
30/**
31 * Prompt the user for a PAT, validate it against `/api/v2/user`, and store it.
32 */
33export async function signInFlow(
34 context: vscode.ExtensionContext,
35 host: string
36): Promise<boolean> {
37 const url = `${host.replace(/\/+$/, "")}/settings/tokens`;
38 const token = await vscode.window.showInputBox({
39 prompt: `Paste a Gluecron PAT (create one at ${url})`,
40 placeHolder: "glc_...",
41 password: true,
42 ignoreFocusOut: true,
43 validateInput: (v) => {
44 const t = (v || "").trim();
45 if (!t) return "Token is required";
46 if (!/^glc_/.test(t)) return "Token should start with 'glc_'";
47 return null;
48 },
49 });
50 if (!token) return false;
51
52 // Best-effort validation; don't block sign-in if the network is flaky —
53 // the user can still use the extension and we'll surface 401s later.
54 try {
55 const me = await fetch(`${host.replace(/\/+$/, "")}/api/v2/user`, {
56 headers: { authorization: `Bearer ${token.trim()}` },
57 });
58 if (me.ok) {
59 const body = (await me.json()) as { username?: string };
60 vscode.window.showInformationMessage(
61 `Gluecron: signed in as ${body.username || "(unknown)"}`
62 );
63 } else if (me.status === 401) {
64 vscode.window.showWarningMessage(
65 "Gluecron: token was rejected (401). Saving anyway — you can re-run sign-in."
66 );
67 }
68 } catch {
69 vscode.window.showWarningMessage(
70 "Gluecron: couldn't reach the server to validate your token. Saving locally."
71 );
72 }
73
74 await setToken(context, token.trim());
75 return true;
76}
77
78/**
79 * Restore the signed-in context flag at activation time.
80 */
81export async function restoreSignedInContext(
82 context: vscode.ExtensionContext
83): Promise<void> {
84 const t = await getToken(context);
85 await vscode.commands.executeCommand("setContext", CONTEXT_KEY, !!t);
86}
Addededitor-extensions/vscode/src/commands/ai-commit.ts+152−0View fileUnifiedSplit
1/**
2 * Generate an AI commit message for the currently-staged diff and drop
3 * it into the Source Control input box.
4 *
5 * Strategy:
6 * 1. Read the staged diff via `git diff --cached` in the workspace.
7 * 2. POST it to `${host}/api/v2/ai/commit-message` with the user's PAT.
8 * 3. Format `{ subject, body }` into a single string and assign it to
9 * the active git SCM repository's `inputBox.value`.
10 *
11 * We talk to the API directly rather than shelling out to the `gluecron`
12 * CLI — the CLI may not be installed in the user's $PATH, and the API
13 * surface is the same.
14 */
15
16import * as vscode from "vscode";
17import { execFile } from "node:child_process";
18import { promisify } from "node:util";
19import { getToken, signInFlow } from "../auth";
20import { getHost, pickWorkspaceFolder } from "../repo";
21
22const execFileP = promisify(execFile);
23
24/**
25 * VS Code's built-in git extension exposes a typed API; we declare just
26 * the bits we touch so the extension compiles without bundling
27 * `@types/vscode.git`.
28 */
29interface GitInputBox {
30 value: string;
31}
32interface GitRepository {
33 rootUri: vscode.Uri;
34 inputBox: GitInputBox;
35}
36interface GitApi {
37 repositories: GitRepository[];
38 getRepository?(uri: vscode.Uri): GitRepository | null;
39}
40interface GitExtensionExports {
41 getAPI(version: 1): GitApi;
42}
43
44async function getStagedDiff(cwd: string): Promise<string> {
45 const { stdout } = await execFileP("git", ["diff", "--cached"], {
46 cwd,
47 maxBuffer: 16 * 1024 * 1024, // 16 MiB — generous; API caps server-side too.
48 });
49 return stdout;
50}
51
52function pickRepository(cwd: string): GitRepository | null {
53 const git = vscode.extensions.getExtension<GitExtensionExports>("vscode.git");
54 if (!git) return null;
55 const api = git.isActive ? git.exports.getAPI(1) : null;
56 if (!api) return null;
57 if (api.getRepository) {
58 const found = api.getRepository(vscode.Uri.file(cwd));
59 if (found) return found;
60 }
61 return api.repositories[0] || null;
62}
63
64export async function runAiCommitMessage(
65 context: vscode.ExtensionContext
66): Promise<void> {
67 const folder = pickWorkspaceFolder();
68 if (!folder) {
69 vscode.window.showWarningMessage("Gluecron: no workspace open.");
70 return;
71 }
72
73 let token = await getToken(context);
74 if (!token) {
75 const ok = await signInFlow(context, getHost());
76 if (!ok) return;
77 token = await getToken(context);
78 }
79 if (!token) return;
80
81 let diff: string;
82 try {
83 diff = await getStagedDiff(folder.uri.fsPath);
84 } catch (err) {
85 vscode.window.showErrorMessage(
86 `Gluecron: failed to read staged diff (${(err as Error).message}).`
87 );
88 return;
89 }
90 if (!diff.trim()) {
91 vscode.window.showInformationMessage(
92 "Gluecron: nothing staged. Stage changes first (git add)."
93 );
94 return;
95 }
96
97 const message = await vscode.window.withProgress(
98 {
99 location: vscode.ProgressLocation.SourceControl,
100 title: "Gluecron: drafting commit message...",
101 cancellable: false,
102 },
103 async () => {
104 const url = `${getHost().replace(/\/+$/, "")}/api/v2/ai/commit-message`;
105 const res = await fetch(url, {
106 method: "POST",
107 headers: {
108 "content-type": "application/json",
109 accept: "application/json",
110 authorization: `Bearer ${token}`,
111 },
112 body: JSON.stringify({ diff, style: "conventional" }),
113 });
114 const text = await res.text();
115 if (!res.ok) {
116 throw new Error(`server returned ${res.status}: ${text.slice(0, 200)}`);
117 }
118 let json: { subject?: string; body?: string };
119 try {
120 json = JSON.parse(text);
121 } catch {
122 throw new Error("server returned non-JSON");
123 }
124 const subject = (json.subject || "").trim();
125 const body = (json.body || "").trim();
126 if (!subject) throw new Error("server returned an empty subject");
127 return body ? `${subject}\n\n${body}` : subject;
128 }
129 ).then(
130 (v) => v,
131 (err: Error) => {
132 vscode.window.showErrorMessage(`Gluecron: ${err.message}`);
133 return null;
134 }
135 );
136
137 if (!message) return;
138
139 const repo = pickRepository(folder.uri.fsPath);
140 if (repo) {
141 repo.inputBox.value = message;
142 vscode.window.showInformationMessage(
143 "Gluecron: commit message dropped into Source Control."
144 );
145 } else {
146 // Git extension isn't ready — fall back to copying the message.
147 await vscode.env.clipboard.writeText(message);
148 vscode.window.showInformationMessage(
149 "Gluecron: copied commit message to clipboard (git SCM not ready)."
150 );
151 }
152}
Addededitor-extensions/vscode/src/extension.ts+183−0View fileUnifiedSplit
1/**
2 * Gluecron VS Code extension entry point.
3 *
4 * Activation registers:
5 * - sign in / sign out commands (PAT stored in vscode.SecretStorage)
6 * - sidebar chat + pulls + issues + standups (iframe-embedded webviews)
7 * - "open current file in Gluecron" + "open PRs" deep-links
8 * - "ship spec" + "voice to PR" host shortcuts
9 * - "generate AI commit message" — wires up the SCM input box
10 */
11
12import * as vscode from "vscode";
13import { relative } from "node:path";
14import {
15 clearToken,
16 restoreSignedInContext,
17 signInFlow,
18} from "./auth";
19import {
20 getDefaultBranch,
21 getGluecronRepoInfo,
22 getHost,
23 hostUrl,
24 pickWorkspaceFolder,
25} from "./repo";
26import { buildBlobUrl } from "./git";
27import {
28 chatViewProvider,
29 issuesViewProvider,
30 pullsViewProvider,
31 standupsViewProvider,
32} from "./sidebar/iframe-view";
33import { runAiCommitMessage } from "./commands/ai-commit";
34
35export async function activate(context: vscode.ExtensionContext): Promise<void> {
36 const output = vscode.window.createOutputChannel("Gluecron");
37 context.subscriptions.push(output);
38 output.appendLine("Gluecron activated.");
39
40 await restoreSignedInContext(context);
41
42 // ── Commands ──────────────────────────────────────────────────────────
43
44 context.subscriptions.push(
45 vscode.commands.registerCommand("gluecron.signIn", async () => {
46 await signInFlow(context, getHost());
47 })
48 );
49
50 context.subscriptions.push(
51 vscode.commands.registerCommand("gluecron.signOut", async () => {
52 await clearToken(context);
53 vscode.window.showInformationMessage("Gluecron: signed out.");
54 })
55 );
56
57 context.subscriptions.push(
58 vscode.commands.registerCommand("gluecron.chatWithRepo", async () => {
59 // Reveal the sidebar then focus the chat view.
60 await vscode.commands.executeCommand("workbench.view.extension.gluecron-sidebar");
61 await vscode.commands.executeCommand("gluecron.chat.focus");
62 })
63 );
64
65 context.subscriptions.push(
66 vscode.commands.registerCommand("gluecron.openInGluecron", async () => {
67 const editor = vscode.window.activeTextEditor;
68 const folder = pickWorkspaceFolder();
69 const info = await getGluecronRepoInfo();
70 if (!folder || !info) {
71 vscode.window.showWarningMessage(
72 "Gluecron: no Gluecron remote detected for this workspace."
73 );
74 return;
75 }
76 const filePath = editor
77 ? relative(folder.uri.fsPath, editor.document.uri.fsPath)
78 : "";
79 const branch = getDefaultBranch();
80 const url = filePath
81 ? buildBlobUrl(
82 getHost(),
83 info.owner,
84 info.repo,
85 branch,
86 filePath,
87 editor?.selection.active.line
88 )
89 : hostUrl(`/${info.owner}/${info.repo}`);
90 await vscode.env.openExternal(vscode.Uri.parse(url));
91 })
92 );
93
94 context.subscriptions.push(
95 vscode.commands.registerCommand("gluecron.openPRs", async () => {
96 const info = await getGluecronRepoInfo();
97 const url = info
98 ? hostUrl(`/${info.owner}/${info.repo}/pulls`)
99 : hostUrl("/pulls");
100 await vscode.env.openExternal(vscode.Uri.parse(url));
101 })
102 );
103
104 context.subscriptions.push(
105 vscode.commands.registerCommand("gluecron.openIssues", async () => {
106 const info = await getGluecronRepoInfo();
107 const url = info
108 ? hostUrl(`/${info.owner}/${info.repo}/issues`)
109 : hostUrl("/issues");
110 await vscode.env.openExternal(vscode.Uri.parse(url));
111 })
112 );
113
114 context.subscriptions.push(
115 vscode.commands.registerCommand("gluecron.openStandups", async () => {
116 const info = await getGluecronRepoInfo();
117 const url = info
118 ? hostUrl(`/${info.owner}/${info.repo}/standups`)
119 : hostUrl("/standups");
120 await vscode.env.openExternal(vscode.Uri.parse(url));
121 })
122 );
123
124 context.subscriptions.push(
125 vscode.commands.registerCommand("gluecron.shipSpec", async () => {
126 const editor = vscode.window.activeTextEditor;
127 const folder = pickWorkspaceFolder();
128 const info = await getGluecronRepoInfo();
129 if (!editor || !folder || !info) {
130 vscode.window.showWarningMessage(
131 "Gluecron: open a file inside a Gluecron-hosted repo first."
132 );
133 return;
134 }
135 const rel = relative(folder.uri.fsPath, editor.document.uri.fsPath);
136 const url = hostUrl(
137 `/${info.owner}/${info.repo}/specs/new?path=${encodeURIComponent(rel)}`
138 );
139 await vscode.env.openExternal(vscode.Uri.parse(url));
140 })
141 );
142
143 context.subscriptions.push(
144 vscode.commands.registerCommand("gluecron.voiceToPR", async () => {
145 await vscode.env.openExternal(vscode.Uri.parse(hostUrl("/voice")));
146 })
147 );
148
149 context.subscriptions.push(
150 vscode.commands.registerCommand("gluecron.aiCommitMessage", async () => {
151 await runAiCommitMessage(context);
152 })
153 );
154
155 // ── Sidebar views ─────────────────────────────────────────────────────
156
157 context.subscriptions.push(
158 vscode.window.registerWebviewViewProvider(
159 "gluecron.chat",
160 chatViewProvider(),
161 { webviewOptions: { retainContextWhenHidden: true } }
162 ),
163 vscode.window.registerWebviewViewProvider(
164 "gluecron.pulls",
165 pullsViewProvider(),
166 { webviewOptions: { retainContextWhenHidden: true } }
167 ),
168 vscode.window.registerWebviewViewProvider(
169 "gluecron.issues",
170 issuesViewProvider(),
171 { webviewOptions: { retainContextWhenHidden: true } }
172 ),
173 vscode.window.registerWebviewViewProvider(
174 "gluecron.standups",
175 standupsViewProvider(),
176 { webviewOptions: { retainContextWhenHidden: true } }
177 )
178 );
179}
180
181export function deactivate(): void {
182 // nothing — VS Code cleans up registered subscriptions automatically.
183}
Addededitor-extensions/vscode/src/git.ts+106−0View fileUnifiedSplit
1/**
2 * Pure helpers for resolving the Gluecron owner/repo from a git remote URL.
3 *
4 * Kept VS-Code-free so the parser is unit-testable from plain node:test
5 * (see `src/__tests__/extension.test.ts`).
6 */
7
8export interface RepoInfo {
9 owner: string;
10 repo: string;
11 /** Hostname extracted from the remote (e.g. "gluecron.com"). */
12 host: string;
13}
14
15/**
16 * Parse a git remote URL into `{ owner, repo, host }`.
17 *
18 * Supports:
19 * - https://gluecron.com/owner/repo.git
20 * - https://user:pass@gluecron.com/owner/repo
21 * - http://localhost:3000/owner/repo.git
22 * - git@gluecron.com:owner/repo.git
23 * - ssh://git@gluecron.com:2222/owner/repo.git
24 *
25 * Returns `null` if the URL can't be parsed into owner/repo.
26 *
27 * NOTE: this parser does NOT filter by host — the caller decides whether
28 * the host matches `gluecron.host`. That keeps the parser host-agnostic
29 * and makes self-hosted instances work out of the box.
30 */
31export function parseGitRemote(url: string): RepoInfo | null {
32 if (!url || typeof url !== "string") return null;
33 const trimmed = url.trim();
34 if (!trimmed) return null;
35
36 // SCP-style: git@host:owner/repo[.git]
37 const scpMatch = trimmed.match(/^[^@\s]+@([^:\s]+):(.+?)(?:\.git)?\/?$/);
38 if (scpMatch) {
39 const host = scpMatch[1];
40 const path = scpMatch[2];
41 const [owner, repo] = splitPath(path);
42 if (owner && repo) return { owner, repo, host };
43 return null;
44 }
45
46 // Anything URL-ish (http/https/ssh/git protocols)
47 let parsed: URL | null = null;
48 try {
49 parsed = new URL(trimmed);
50 } catch {
51 parsed = null;
52 }
53 if (parsed) {
54 const host = parsed.host.replace(/:\d+$/, ""); // strip :port for display
55 const path = parsed.pathname.replace(/^\/+/, "").replace(/\.git\/?$/, "");
56 const [owner, repo] = splitPath(path);
57 if (owner && repo) return { owner, repo, host };
58 }
59 return null;
60}
61
62function splitPath(path: string): [string?, string?] {
63 const parts = path.split("/").filter(Boolean);
64 if (parts.length < 2) return [undefined, undefined];
65 // The owner/repo are always the LAST two segments — handles nested
66 // path prefixes like /git/owner/repo when self-hosted behind a path.
67 const owner = parts[parts.length - 2];
68 const repo = parts[parts.length - 1];
69 return [owner, repo];
70}
71
72/**
73 * Build a web URL pointing at the file in the Gluecron blob view.
74 */
75export function buildBlobUrl(
76 host: string,
77 owner: string,
78 repo: string,
79 branch: string,
80 filePath: string,
81 line?: number
82): string {
83 const cleanHost = host.replace(/\/+$/, "");
84 const cleanPath = filePath.replace(/^\/+/, "");
85 const base = `${cleanHost}/${owner}/${repo}/blob/${branch}/${cleanPath}`;
86 return typeof line === "number" && line >= 0 ? `${base}#L${line + 1}` : base;
87}
88
89/**
90 * Whether a remote's host matches the configured Gluecron host.
91 *
92 * - Exact hostname match (case-insensitive).
93 * - "localhost" matches any port-less local-style remote.
94 * - Self-hosted users override `gluecron.host` in settings; this helper
95 * reads the configured hostname only — never the protocol or path.
96 */
97export function isGluecronRemote(remoteHost: string, configuredHost: string): boolean {
98 if (!remoteHost) return false;
99 let configHostname: string;
100 try {
101 configHostname = new URL(configuredHost).hostname || configuredHost;
102 } catch {
103 configHostname = configuredHost.replace(/^[a-z]+:\/\//, "").split("/")[0] || configuredHost;
104 }
105 return remoteHost.toLowerCase() === configHostname.toLowerCase();
106}
Addededitor-extensions/vscode/src/repo.ts+90−0View fileUnifiedSplit
1/**
2 * Workspace-aware helpers that combine VS Code APIs with the pure parser
3 * in `git.ts`. Returns `null` whenever the active workspace can't be
4 * resolved to a Gluecron-hosted repo.
5 */
6
7import * as vscode from "vscode";
8import { execFile } from "node:child_process";
9import { promisify } from "node:util";
10import { parseGitRemote, isGluecronRemote, type RepoInfo } from "./git";
11
12const execFileP = promisify(execFile);
13
14export function getHost(): string {
15 const v = vscode.workspace
16 .getConfiguration("gluecron")
17 .get<string>("host");
18 return (v && v.trim()) || "https://gluecron.com";
19}
20
21export function getDefaultBranch(): string {
22 const v = vscode.workspace
23 .getConfiguration("gluecron")
24 .get<string>("defaultBranch");
25 return (v && v.trim()) || "main";
26}
27
28/**
29 * Pick a workspace folder — prefers the active editor's folder, falls back
30 * to the first workspace folder. Returns `null` if there is no workspace.
31 */
32export function pickWorkspaceFolder(): vscode.WorkspaceFolder | null {
33 const editor = vscode.window.activeTextEditor;
34 if (editor) {
35 const folder = vscode.workspace.getWorkspaceFolder(editor.document.uri);
36 if (folder) return folder;
37 }
38 const folders = vscode.workspace.workspaceFolders;
39 if (folders && folders.length > 0) return folders[0];
40 return null;
41}
42
43/**
44 * Resolve the current workspace's Gluecron repo. Returns `null` if there
45 * is no workspace, no git remote, or the remote isn't hosted on the
46 * configured Gluecron instance.
47 */
48export async function getRepoInfo(): Promise<RepoInfo | null> {
49 const folder = pickWorkspaceFolder();
50 if (!folder) return null;
51 let url: string;
52 try {
53 const { stdout } = await execFileP(
54 "git",
55 ["remote", "get-url", "origin"],
56 { cwd: folder.uri.fsPath }
57 );
58 url = stdout.trim();
59 } catch {
60 return null;
61 }
62 const info = parseGitRemote(url);
63 if (!info) return null;
64 if (!isGluecronRemote(info.host, getHost())) {
65 // We still return the parse — callers may want the bare info for
66 // building cross-host URLs — but most callers should use
67 // `getGluecronRepoInfo` which filters this out.
68 return info;
69 }
70 return info;
71}
72
73/**
74 * Like `getRepoInfo` but returns `null` for non-Gluecron remotes.
75 */
76export async function getGluecronRepoInfo(): Promise<RepoInfo | null> {
77 const info = await getRepoInfo();
78 if (!info) return null;
79 if (!isGluecronRemote(info.host, getHost())) return null;
80 return info;
81}
82
83/**
84 * Build a URL inside the configured Gluecron host.
85 */
86export function hostUrl(path: string): string {
87 const host = getHost().replace(/\/+$/, "");
88 const p = path.startsWith("/") ? path : `/${path}`;
89 return host + p;
90}
Addededitor-extensions/vscode/src/sidebar/iframe-view.ts+113−0View fileUnifiedSplit
1/**
2 * Generic WebviewViewProvider that embeds a Gluecron page in an iframe.
3 *
4 * We re-resolve the URL on every `resolveWebviewView` call so that
5 * workspace changes (different repo, different host) are picked up
6 * without a window reload.
7 *
8 * The chat view also passes `?embed=1` so the server can render a
9 * sidebar-friendly layout (no nav chrome).
10 */
11
12import * as vscode from "vscode";
13import { getGluecronRepoInfo, hostUrl } from "../repo";
14
15export type UrlBuilder = (repo: { owner: string; repo: string }) => string;
16
17export class IframeView implements vscode.WebviewViewProvider {
18 constructor(
19 private readonly emptyState: string,
20 private readonly buildUrl: UrlBuilder
21 ) {}
22
23 resolveWebviewView(
24 webviewView: vscode.WebviewView,
25 _ctx: vscode.WebviewViewResolveContext,
26 _token: vscode.CancellationToken
27 ): void | Thenable<void> {
28 webviewView.webview.options = {
29 enableScripts: true,
30 enableForms: true,
31 enableCommandUris: false,
32 };
33 this.render(webviewView);
34
35 // Re-render on host changes (settings) — covers self-host switches.
36 const sub = vscode.workspace.onDidChangeConfiguration((e) => {
37 if (e.affectsConfiguration("gluecron.host")) {
38 this.render(webviewView);
39 }
40 });
41 webviewView.onDidDispose(() => sub.dispose());
42 }
43
44 private async render(view: vscode.WebviewView): Promise<void> {
45 const info = await getGluecronRepoInfo();
46 if (!info) {
47 view.webview.html = htmlShell(emptyHtml(this.emptyState));
48 return;
49 }
50 const url = this.buildUrl(info);
51 view.webview.html = htmlShell(iframeHtml(url));
52 }
53}
54
55export function chatViewProvider(): IframeView {
56 return new IframeView(
57 "Open a folder backed by a Gluecron repository to start chatting.",
58 ({ owner, repo }) => hostUrl(`/${owner}/${repo}/chat?embed=1`)
59 );
60}
61
62export function pullsViewProvider(): IframeView {
63 return new IframeView(
64 "Open a Gluecron-hosted folder to see its pull requests.",
65 ({ owner, repo }) => hostUrl(`/${owner}/${repo}/pulls?embed=1`)
66 );
67}
68
69export function issuesViewProvider(): IframeView {
70 return new IframeView(
71 "Open a Gluecron-hosted folder to see its issues.",
72 ({ owner, repo }) => hostUrl(`/${owner}/${repo}/issues?embed=1`)
73 );
74}
75
76export function standupsViewProvider(): IframeView {
77 return new IframeView(
78 "Open a Gluecron-hosted folder to see AI standups.",
79 ({ owner, repo }) => hostUrl(`/${owner}/${repo}/standups?embed=1`)
80 );
81}
82
83function htmlShell(body: string): string {
84 // Note: we deliberately allow `frame-src` to whatever HTTP host we
85 // embed, but we keep `default-src` tight so an injected script can't
86 // execute anything inside the webview shell itself.
87 return `<!DOCTYPE html>
88<html>
89<head>
90<meta charset="utf-8" />
91<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; frame-src http: https:;" />
92<style>
93 html, body { margin: 0; padding: 0; height: 100%; background: var(--vscode-editor-background); color: var(--vscode-foreground); font-family: var(--vscode-font-family); }
94 iframe { width: 100%; height: 100%; border: 0; }
95 .empty { padding: 16px; font-size: 13px; line-height: 1.5; opacity: 0.8; }
96</style>
97</head>
98<body>
99${body}
100</body>
101</html>`;
102}
103
104function iframeHtml(url: string): string {
105 // Escape the URL for attribute context.
106 const safe = url.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
107 return `<iframe src="${safe}" sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"></iframe>`;
108}
109
110function emptyHtml(message: string): string {
111 const safe = message.replace(/&/g, "&amp;").replace(/</g, "&lt;");
112 return `<div class="empty">${safe}</div>`;
113}
Addededitor-extensions/vscode/tsconfig.json+20−0View fileUnifiedSplit
1{
2 "compilerOptions": {
3 "target": "ES2022",
4 "module": "commonjs",
5 "moduleResolution": "node",
6 "lib": ["ES2022", "DOM"],
7 "outDir": "./out",
8 "rootDir": "./src",
9 "sourceMap": true,
10 "strict": true,
11 "esModuleInterop": true,
12 "skipLibCheck": true,
13 "forceConsistentCasingInFileNames": true,
14 "resolveJsonModule": true,
15 "declaration": false,
16 "types": ["node", "vscode"]
17 },
18 "include": ["src/**/*.ts"],
19 "exclude": ["node_modules", "out", ".vscode-test"]
20}
Modifiedsrc/__tests__/install.test.ts+23−0View fileUnifiedSplit
6565 });
6666});
6767
68// ---------------------------------------------------------------------------
69// 1b. GET /install/vscode — VS Code extension landing
70// ---------------------------------------------------------------------------
71
72describe("install — GET /install/vscode", () => {
73 it("returns 200 with an HTML install page", async () => {
74 const res = await app.request("/install/vscode");
75 expect(res.status).toBe(200);
76 const ct = res.headers.get("content-type") || "";
77 expect(ct).toContain("text/html");
78 const body = await res.text();
79 expect(body).toContain("Gluecron for VS Code");
80 // Points users at the extension source.
81 expect(body).toContain("editor-extensions/vscode");
82 });
83
84 it("serves a cacheable response", async () => {
85 const res = await app.request("/install/vscode");
86 const cc = res.headers.get("cache-control") || "";
87 expect(cc).toContain("public");
88 });
89});
90
6891// ---------------------------------------------------------------------------
6992// 2. POST /api/v2/auth/install-token — auth contract
7093// ---------------------------------------------------------------------------
Modifiedsrc/routes/install.ts+118−2View fileUnifiedSplit
11/**
22 * Block L2 — one-command install.
33 *
4 * GET /install -> the bash installer (scripts/install.sh).
4 * GET /install -> the bash installer (scripts/install.sh).
5 * GET /install/vscode -> a tiny HTML landing for the VS Code extension
6 * with install instructions and a .vsix link if
7 * one has been uploaded to `public/`.
58 *
69 * Curl-able: `curl -sSL https://gluecron.com/install | bash`.
710 *
1215 */
1316
1417import { Hono } from "hono";
15import { readFileSync } from "fs";
18import { readFileSync, existsSync, statSync, readdirSync } from "fs";
1619import { join } from "path";
1720
1821const install = new Hono();
5659 return c.body(INSTALL_SCRIPT_SRC);
5760});
5861
62// ─── VS Code extension landing ──────────────────────────────────────────────
63//
64// We don't (yet) have a marketplace listing, so this page shows install
65// instructions and — if a .vsix has been dropped into `public/` — a
66// download link. The page is intentionally minimalist: it does NOT pull
67// in the main app layout so a misbehaving CSS change can't break the
68// install funnel.
69
70const MARKETPLACE_URL =
71 "https://marketplace.visualstudio.com/items?itemName=gluecron.gluecron-vscode";
72
73function findVsix(): { name: string; size: number } | null {
74 // Look under `public/` so the file is served by the static handler
75 // (Bun's `Bun.file` route at /:filename). Keep it deterministic —
76 // largest-version sorted last after lexicographic sort works for our
77 // semver naming.
78 const candidates = [
79 join(process.cwd(), "public"),
80 join(import.meta.dir, "..", "..", "public"),
81 ];
82 for (const dir of candidates) {
83 try {
84 if (!existsSync(dir)) continue;
85 const files = readdirSync(dir).filter((f) => f.endsWith(".vsix"));
86 if (files.length === 0) continue;
87 files.sort();
88 const pick = files[files.length - 1];
89 const st = statSync(join(dir, pick));
90 return { name: pick, size: st.size };
91 } catch {
92 // try the next candidate
93 }
94 }
95 return null;
96}
97
98function formatBytes(n: number): string {
99 if (n < 1024) return `${n} B`;
100 if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`;
101 return `${(n / (1024 * 1024)).toFixed(1)} MiB`;
102}
103
104function vscodeLandingHtml(vsix: { name: string; size: number } | null): string {
105 const vsixBlock = vsix
106 ? `
107 <p>
108 <a class="cta" href="/${encodeURIComponent(vsix.name)}" download>
109 Download ${vsix.name} (${formatBytes(vsix.size)})
110 </a>
111 </p>
112 <pre><code>code --install-extension ${vsix.name}</code></pre>`
113 : `
114 <p class="muted">
115 No <code>.vsix</code> uploaded yet — build one yourself:
116 </p>
117 <pre><code>git clone https://gluecron.com/ccantynz/Gluecron.com
118cd Gluecron.com/editor-extensions/vscode
119npm install
120npm run compile
121npx vsce package
122code --install-extension gluecron-vscode-0.1.0.vsix</code></pre>`;
123
124 return `<!DOCTYPE html>
125<html lang="en">
126<head>
127<meta charset="utf-8" />
128<title>Gluecron for VS Code</title>
129<meta name="viewport" content="width=device-width, initial-scale=1" />
130<style>
131 :root { color-scheme: dark light; }
132 body { font: 15px/1.55 system-ui, sans-serif; max-width: 640px; margin: 5rem auto; padding: 0 1rem; }
133 h1 { margin-top: 0; font-size: 1.6rem; }
134 .badge { display: inline-block; padding: 4px 10px; border-radius: 4px; background: #2c2c2c; color: #fff; font-size: 13px; text-decoration: none; }
135 .cta { display: inline-block; padding: 8px 14px; border-radius: 6px; background: #1f6feb; color: #fff; text-decoration: none; font-weight: 600; }
136 pre { background: #0e1116; color: #e6edf3; padding: 12px; border-radius: 6px; overflow-x: auto; }
137 code { font: 13px ui-monospace, monospace; }
138 .muted { opacity: 0.75; }
139 ul { padding-left: 1.2rem; }
140</style>
141</head>
142<body>
143 <h1>Gluecron for VS Code</h1>
144 <p>
145 Chat with this repo, ship PRs, run specs, voice-to-PR, and let Claude write
146 your commit messages — without leaving the editor.
147 </p>
148 <p>
149 <a class="badge" href="${MARKETPLACE_URL}" rel="nofollow">
150 Marketplace listing (coming soon)
151 </a>
152 </p>
153 <h2>Install</h2>
154 ${vsixBlock}
155 <h2>What you get</h2>
156 <ul>
157 <li>Sidebar <strong>repo chat</strong> grounded in the current repository</li>
158 <li>One-click <strong>AI commit messages</strong> in the SCM title bar</li>
159 <li><strong>Open in Gluecron</strong> deep-links for the active file / line</li>
160 <li>Embedded <strong>pull requests</strong>, <strong>issues</strong>, and <strong>AI standups</strong></li>
161 <li><strong>Ship spec</strong> + <strong>voice-to-PR</strong> shortcuts</li>
162 </ul>
163 <p class="muted">Source: <a href="/ccantynz/Gluecron.com/tree/main/editor-extensions/vscode">editor-extensions/vscode</a></p>
164</body>
165</html>`;
166}
167
168install.get("/install/vscode", (c) => {
169 const vsix = findVsix();
170 c.header("content-type", "text/html; charset=utf-8");
171 c.header("cache-control", "public, max-age=300");
172 return c.body(vscodeLandingHtml(vsix));
173});
174
59175export default install;
60176