gitnexushub 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,11 +12,13 @@ import { claudeCodeEditor } from './editors/claude-code.js';
12
12
  import { cursorEditor } from './editors/cursor.js';
13
13
  import { windsurfEditor } from './editors/windsurf.js';
14
14
  import { opencodeEditor } from './editors/opencode.js';
15
+ import { kiroEditor } from './editors/kiro.js';
15
16
  export const EDITORS = {
16
17
  'claude-code': claudeCodeEditor,
17
18
  cursor: cursorEditor,
18
19
  windsurf: windsurfEditor,
19
20
  opencode: opencodeEditor,
21
+ kiro: kiroEditor,
20
22
  };
21
23
  export const ok = (msg) => console.log(` ${pc.green('✔')} ${msg}`);
22
24
  export const info = (msg) => console.log(` ${pc.cyan('ℹ')} ${msg}`);
@@ -13,7 +13,7 @@ import { generateConnectContext } from './content.js';
13
13
  import { isGitRepo, getGitRemoteUrl, parseGitRemote, matchRepo } from './project.js';
14
14
  import { writeProjectContext } from './context.js';
15
15
  import { detectInstalledEditors } from './editors/detect.js';
16
- import { installClaudeCodeHook, installCursorHook, installOpenCodeHook, } from './hooks-installer.js';
16
+ import { installClaudeCodeHook, installCursorHook, installKiroHook, installOpenCodeHook, } from './hooks-installer.js';
17
17
  import { ok, info, warn, fail, resolveAuth, EDITORS } from './cli-helpers.js';
18
18
  /**
19
19
  * Resolve the path to the shipped hook script (`gitnexus-enterprise-hook.cjs`).
@@ -65,7 +65,7 @@ export async function runConnect(tokenArg, opts) {
65
65
  }
66
66
  else {
67
67
  fail('No editor detected. Please specify one:');
68
- console.error(` ${pc.cyan('--editor claude-code | cursor | windsurf | opencode')}`);
68
+ console.error(` ${pc.cyan('--editor claude-code | cursor | windsurf | opencode | kiro')}`);
69
69
  console.error('');
70
70
  return process.exit(1);
71
71
  }
@@ -116,6 +116,10 @@ export async function runConnect(tokenArg, opts) {
116
116
  await installOpenCodeHook({ hookScriptPath });
117
117
  ok('Hooks installed');
118
118
  }
119
+ else if (editorId === 'kiro') {
120
+ await installKiroHook({ hookScriptPath });
121
+ ok('Hooks installed');
122
+ }
119
123
  // windsurf: no hook system, skip silently
120
124
  }
121
125
  catch (err) {
package/dist/context.js CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import fs from 'fs/promises';
7
7
  import path from 'path';
8
- import { upsertMarkedSection, removeMarkedSection } from './utils.js';
8
+ import { upsertMarkedSection, removeMarkedSection, fileExists } from './utils.js';
9
9
  export async function writeProjectContext(projectDir, ctx) {
10
10
  const files = [];
11
11
  // Write CLAUDE.md
@@ -16,6 +16,31 @@ export async function writeProjectContext(projectDir, ctx) {
16
16
  const agentsPath = path.join(projectDir, 'AGENTS.md');
17
17
  const agentsResult = await upsertMarkedSection(agentsPath, ctx.agentsMd);
18
18
  files.push(`AGENTS.md (${agentsResult})`);
19
+ // Write Kiro steering doc — Kiro doesn't read AGENTS.md / CLAUDE.md,
20
+ // so we mirror the same content into .kiro/steering/gitnexus.md with
21
+ // `inclusion: always` frontmatter so it's loaded into every Kiro
22
+ // chat in this workspace.
23
+ //
24
+ // Unlike CLAUDE.md / AGENTS.md (which may carry hand-written user
25
+ // content alongside the gitnexus block), this file is dedicated to
26
+ // gitnexus — it's named gitnexus.md, lives in a gitnexus-managed
27
+ // path, and isn't expected to host anything else. So we rewrite the
28
+ // whole file every run instead of using upsertMarkedSection. The
29
+ // previous marker-based approach broke because the YAML frontmatter
30
+ // sat *outside* the gitnexus markers (Kiro requires frontmatter on
31
+ // line 1), so each re-run's "before" prefix re-included the existing
32
+ // frontmatter and stacked another copy on top — Bugbot caught the
33
+ // accumulation on commit de24e98c.
34
+ const kiroSteeringPath = path.join(projectDir, '.kiro', 'steering', 'gitnexus.md');
35
+ const kiroBody = `---\n` +
36
+ `inclusion: always\n` +
37
+ `description: GitNexus code intelligence — graph-backed query, context, and impact tools\n` +
38
+ `---\n\n` +
39
+ `${ctx.agentsMd}\n`;
40
+ const kiroExisted = await fileExists(kiroSteeringPath);
41
+ await fs.mkdir(path.dirname(kiroSteeringPath), { recursive: true });
42
+ await fs.writeFile(kiroSteeringPath, kiroBody, 'utf-8');
43
+ files.push(`.kiro/steering/gitnexus.md (${kiroExisted ? 'updated' : 'created'})`);
19
44
  // Write skills to .claude/skills/gitnexus/
20
45
  if (ctx.skills.length > 0) {
21
46
  const skillsDir = path.join(projectDir, '.claude', 'skills', 'gitnexus');
@@ -30,13 +55,27 @@ export async function writeProjectContext(projectDir, ctx) {
30
55
  }
31
56
  export async function removeProjectContext(projectDir) {
32
57
  const removed = [];
33
- // Remove marked sections from CLAUDE.md and AGENTS.md
58
+ // CLAUDE.md / AGENTS.md may carry user content; only strip the
59
+ // gitnexus-marked block.
34
60
  for (const name of ['CLAUDE.md', 'AGENTS.md']) {
35
61
  const filePath = path.join(projectDir, name);
36
62
  if (await removeMarkedSection(filePath)) {
37
63
  removed.push(name);
38
64
  }
39
65
  }
66
+ // The Kiro steering doc is dedicated to gitnexus (no user content
67
+ // ever lives here, no markers are written), so delete the file
68
+ // outright when it exists.
69
+ const kiroSteeringPath = path.join(projectDir, '.kiro', 'steering', 'gitnexus.md');
70
+ if (await fileExists(kiroSteeringPath)) {
71
+ try {
72
+ await fs.unlink(kiroSteeringPath);
73
+ removed.push('.kiro/steering/gitnexus.md');
74
+ }
75
+ catch {
76
+ // Already gone
77
+ }
78
+ }
40
79
  // Remove .claude/skills/gitnexus/ directory
41
80
  const skillsDir = path.join(projectDir, '.claude', 'skills', 'gitnexus');
42
81
  try {
@@ -27,6 +27,11 @@ const PROBES = [
27
27
  name: 'OpenCode',
28
28
  dirs: [path.join(os.homedir(), '.config', 'opencode')],
29
29
  },
30
+ {
31
+ id: 'kiro',
32
+ name: 'Kiro',
33
+ dirs: [path.join(os.homedir(), '.kiro')],
34
+ },
30
35
  ];
31
36
  export async function detectInstalledEditors() {
32
37
  const found = [];
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Kiro Editor Setup
3
+ *
4
+ * Writes MCP config to ~/.kiro/settings/mcp.json
5
+ * Installs skill content as steering files in ~/.kiro/steering/
6
+ *
7
+ * Kiro (kiro.dev) doesn't have a SKILL.md system like Claude Code or
8
+ * Cursor — its closest concept is "steering" (always-on guidance docs
9
+ * with optional frontmatter for fileMatch / manual inclusion modes).
10
+ * We emit each Hub skill as `~/.kiro/steering/gitnexus-<name>.md` with
11
+ * `inclusion: manual` so the user pulls them in via `#gitnexus-<name>`
12
+ * in chat — same mental model as `/gitnexus-<name>` in Claude Code.
13
+ */
14
+ import type { EditorConfig } from './types.js';
15
+ export declare const kiroEditor: EditorConfig;
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Kiro Editor Setup
3
+ *
4
+ * Writes MCP config to ~/.kiro/settings/mcp.json
5
+ * Installs skill content as steering files in ~/.kiro/steering/
6
+ *
7
+ * Kiro (kiro.dev) doesn't have a SKILL.md system like Claude Code or
8
+ * Cursor — its closest concept is "steering" (always-on guidance docs
9
+ * with optional frontmatter for fileMatch / manual inclusion modes).
10
+ * We emit each Hub skill as `~/.kiro/steering/gitnexus-<name>.md` with
11
+ * `inclusion: manual` so the user pulls them in via `#gitnexus-<name>`
12
+ * in chat — same mental model as `/gitnexus-<name>` in Claude Code.
13
+ */
14
+ import os from 'os';
15
+ import path from 'path';
16
+ import fs from 'fs/promises';
17
+ import { readJsonFile, writeJsonFile } from '../utils.js';
18
+ import { HUB_SKILLS } from '../content.js';
19
+ import { computeFingerprint, getDeviceName } from '../fingerprint.js';
20
+ function getMcpConfig(hubUrl, token) {
21
+ // Kiro's `~/.kiro/settings/mcp.json` is documented at
22
+ // kiro.dev/docs/mcp/configuration. Remote HTTP MCP servers are
23
+ // identified by the presence of `url`. `disabled: false` and the
24
+ // `autoApprove` array are optional — we leave them off so Kiro
25
+ // falls back to its sensible defaults (auto-approval prompts on
26
+ // first call).
27
+ return {
28
+ url: `${hubUrl}/mcp`,
29
+ headers: {
30
+ Authorization: `Bearer ${token}`,
31
+ 'X-Device-Fingerprint': computeFingerprint(),
32
+ 'X-Device-Name': getDeviceName(),
33
+ },
34
+ };
35
+ }
36
+ async function configure(hubUrl, token) {
37
+ const mcpPath = path.join(os.homedir(), '.kiro', 'settings', 'mcp.json');
38
+ try {
39
+ const existing = (await readJsonFile(mcpPath)) || {};
40
+ if (!existing.mcpServers || typeof existing.mcpServers !== 'object') {
41
+ existing.mcpServers = {};
42
+ }
43
+ const overrodeCli = !!(existing.mcpServers.gitnexus && 'command' in existing.mcpServers.gitnexus);
44
+ existing.mcpServers.gitnexus = getMcpConfig(hubUrl, token);
45
+ await writeJsonFile(mcpPath, existing);
46
+ return { success: true, message: 'MCP configured in ~/.kiro/settings/mcp.json', overrodeCli };
47
+ }
48
+ catch (err) {
49
+ return { success: false, message: `Failed: ${err.message}` };
50
+ }
51
+ }
52
+ /**
53
+ * Wrap raw skill content in a steering frontmatter block so Kiro
54
+ * treats it as a manually-included guidance doc. Without the
55
+ * `inclusion: manual` line Kiro defaults to `always`, which would
56
+ * shove every GitNexus skill into every chat turn — far too noisy.
57
+ */
58
+ function toSteeringDoc(name, body) {
59
+ return `---
60
+ inclusion: manual
61
+ description: ${name} (GitNexus skill — invoke via #${name})
62
+ ---
63
+
64
+ ${body.trim()}
65
+ `;
66
+ }
67
+ async function installSkills(skills) {
68
+ const steeringDir = path.join(os.homedir(), '.kiro', 'steering');
69
+ let installed = 0;
70
+ await fs.mkdir(steeringDir, { recursive: true });
71
+ for (const skill of skills) {
72
+ try {
73
+ const filePath = path.join(steeringDir, `${skill.name}.md`);
74
+ await fs.writeFile(filePath, toSteeringDoc(skill.name, skill.content), 'utf-8');
75
+ installed++;
76
+ }
77
+ catch {
78
+ // Skip on error
79
+ }
80
+ }
81
+ return installed;
82
+ }
83
+ async function unconfigure() {
84
+ const mcpPath = path.join(os.homedir(), '.kiro', 'settings', 'mcp.json');
85
+ try {
86
+ const existing = await readJsonFile(mcpPath);
87
+ if (existing?.mcpServers?.gitnexus) {
88
+ delete existing.mcpServers.gitnexus;
89
+ await writeJsonFile(mcpPath, existing);
90
+ }
91
+ return { success: true, message: 'MCP removed from ~/.kiro/settings/mcp.json' };
92
+ }
93
+ catch (err) {
94
+ return { success: false, message: `Failed: ${err.message}` };
95
+ }
96
+ }
97
+ async function removeSkills() {
98
+ const steeringDir = path.join(os.homedir(), '.kiro', 'steering');
99
+ let removed = 0;
100
+ for (const name of HUB_SKILLS) {
101
+ try {
102
+ await fs.rm(path.join(steeringDir, `${name}.md`), { force: true });
103
+ removed++;
104
+ }
105
+ catch {
106
+ /* already gone */
107
+ }
108
+ }
109
+ return removed;
110
+ }
111
+ export const kiroEditor = {
112
+ id: 'kiro',
113
+ name: 'Kiro',
114
+ configure,
115
+ unconfigure,
116
+ installSkills,
117
+ removeSkills,
118
+ };
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Editor Types
3
3
  */
4
- export type EditorId = 'claude-code' | 'cursor' | 'windsurf' | 'opencode';
4
+ export type EditorId = 'claude-code' | 'cursor' | 'windsurf' | 'opencode' | 'kiro';
5
5
  export interface EditorConfig {
6
6
  id: EditorId;
7
7
  name: string;
@@ -17,10 +17,42 @@ export interface InstallOptions {
17
17
  */
18
18
  export declare function installClaudeCodeHook(opts: InstallOptions): Promise<string>;
19
19
  /**
20
- * Install the Cursor beforeShellExecution hook.
20
+ * Install the Cursor preToolUse + postToolUse hooks.
21
21
  * Returns the absolute path to the written hooks.json.
22
+ *
23
+ * Cursor 2.4+ hooks live at ~/.cursor/hooks.json with a
24
+ * { version: 1, hooks: { <eventName>: [{ command, ... }] } }
25
+ * shape (cursor.com/docs/hooks). We register `preToolUse` for
26
+ * graph-context augmentation on Shell/Read/Write, and `postToolUse`
27
+ * for staleness detection after `git commit/merge/...` and for
28
+ * edit-closure capture on Edit/Write/MultiEdit.
29
+ *
30
+ * The earlier 0.6.0 implementation used `beforeShellExecution`,
31
+ * which:
32
+ * 1. fires *before* shell execution — wrong direction for the
33
+ * git-staleness post-commit nudge,
34
+ * 2. sends a different stdin shape (no `tool_name`/`tool_input`),
35
+ * so the hook script silently bails on the event check,
36
+ * 3. has no documented context-injection envelope.
37
+ * Switching to pre/postToolUse fixes all three.
22
38
  */
23
39
  export declare function installCursorHook(opts: InstallOptions): Promise<string>;
40
+ /**
41
+ * Install the Kiro graph-context + staleness hooks.
42
+ * Returns the absolute paths to both written hook files.
43
+ *
44
+ * Kiro hooks (kiro.dev/docs/hooks/types) live as one JSON file per
45
+ * hook under `.kiro/hooks/<name>.kiro.hook`. Trigger names are kebab-
46
+ * case ("pre-tool-use" / "post-tool-use"), matching the Claude Code
47
+ * PreToolUse/PostToolUse semantics. The gitnexus hook script handles
48
+ * both — it normalises the kebab-case event name back to PascalCase
49
+ * internally so a single script serves Kiro + Claude Code + Cursor +
50
+ * OpenCode.
51
+ *
52
+ * We register two hook files because Kiro's hook spec is one trigger
53
+ * per file; bundling both into a single file would silently lose one.
54
+ */
55
+ export declare function installKiroHook(opts: InstallOptions): Promise<string[]>;
24
56
  /**
25
57
  * Install the OpenCode plugin stub.
26
58
  * Returns the absolute path to the written TypeScript file.
@@ -16,6 +16,35 @@ async function writeJsonIdempotent(filePath, obj) {
16
16
  const body = JSON.stringify(obj, null, 2) + '\n';
17
17
  await fs.writeFile(filePath, body);
18
18
  }
19
+ /**
20
+ * Quote a path for embedding in a shell-style command string. The
21
+ * hook spec across Claude Code / Cursor / Kiro is a literal shell
22
+ * command, not an argv array — so spaces (`John Doe`) and stray
23
+ * quotes both have to survive shell parsing. Wraps in `"..."` and
24
+ * escapes any `"` already inside the path so the shell sees a single
25
+ * argument.
26
+ *
27
+ * Realistic risk of `"` inside the path is near-zero (npm install
28
+ * dirs, Windows profile dirs), but matching the same defensive quoting
29
+ * the OSS gitnexus/src/cli/setup.ts uses keeps the two installers
30
+ * symmetrical.
31
+ */
32
+ function shellQuote(p) {
33
+ return `"${p.replace(/"/g, '\\"')}"`;
34
+ }
35
+ /**
36
+ * Read an existing JSON file, returning null if the file is absent
37
+ * or unparseable. Used by installers that need to merge into a
38
+ * shared config rather than clobber it.
39
+ */
40
+ async function readJsonOrNull(filePath) {
41
+ try {
42
+ return JSON.parse(await fs.readFile(filePath, 'utf-8'));
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ }
19
48
  /**
20
49
  * Install the Claude Code PreToolUse + PostToolUse hooks.
21
50
  * Returns the absolute path to the written hooks.json.
@@ -24,11 +53,15 @@ export async function installClaudeCodeHook(opts) {
24
53
  const home = getHome(opts);
25
54
  const hooksDir = path.join(home, '.claude', 'plugins', 'gitnexus-enterprise', 'hooks');
26
55
  const hooksJsonPath = path.join(hooksDir, 'hooks.json');
27
- // Double-quote the absolute path so shell splitting doesn't break
28
- // when the install dir contains a space (e.g. Windows profiles at
29
- // `C:\Users\John Doe\...`). The hook spec is a shell-style command
30
- // string, not an argv array, so quoting is the only knob available.
31
- const quotedScriptPath = `"${opts.hookScriptPath}"`;
56
+ // Double-quote (and escape any embedded quotes) so shell splitting
57
+ // doesn't break when the install dir contains a space (e.g. Windows
58
+ // profiles at `C:\Users\John Doe\...`). The hook spec is a shell-
59
+ // style command string, not an argv array, so quoting is the only
60
+ // knob available. The `--agent=claude-code` flag tells the hook
61
+ // script which editor it's running under so /api/activity captures
62
+ // get the right agentName (the stdin payload alone can't
63
+ // distinguish editors that share the PascalCase MCP event names).
64
+ const command = `node ${shellQuote(opts.hookScriptPath)} --agent=claude-code`;
32
65
  const config = {
33
66
  hooks: {
34
67
  PreToolUse: [
@@ -37,7 +70,7 @@ export async function installClaudeCodeHook(opts) {
37
70
  hooks: [
38
71
  {
39
72
  type: 'command',
40
- command: `node ${quotedScriptPath}`,
73
+ command,
41
74
  timeout: 5,
42
75
  },
43
76
  ],
@@ -49,7 +82,7 @@ export async function installClaudeCodeHook(opts) {
49
82
  hooks: [
50
83
  {
51
84
  type: 'command',
52
- command: `node ${quotedScriptPath}`,
85
+ command,
53
86
  timeout: 5,
54
87
  },
55
88
  ],
@@ -61,24 +94,92 @@ export async function installClaudeCodeHook(opts) {
61
94
  return hooksJsonPath;
62
95
  }
63
96
  /**
64
- * Install the Cursor beforeShellExecution hook.
97
+ * Install the Cursor preToolUse + postToolUse hooks.
65
98
  * Returns the absolute path to the written hooks.json.
99
+ *
100
+ * Cursor 2.4+ hooks live at ~/.cursor/hooks.json with a
101
+ * { version: 1, hooks: { <eventName>: [{ command, ... }] } }
102
+ * shape (cursor.com/docs/hooks). We register `preToolUse` for
103
+ * graph-context augmentation on Shell/Read/Write, and `postToolUse`
104
+ * for staleness detection after `git commit/merge/...` and for
105
+ * edit-closure capture on Edit/Write/MultiEdit.
106
+ *
107
+ * The earlier 0.6.0 implementation used `beforeShellExecution`,
108
+ * which:
109
+ * 1. fires *before* shell execution — wrong direction for the
110
+ * git-staleness post-commit nudge,
111
+ * 2. sends a different stdin shape (no `tool_name`/`tool_input`),
112
+ * so the hook script silently bails on the event check,
113
+ * 3. has no documented context-injection envelope.
114
+ * Switching to pre/postToolUse fixes all three.
66
115
  */
67
116
  export async function installCursorHook(opts) {
68
117
  const home = getHome(opts);
69
118
  const hooksJsonPath = path.join(home, '.cursor', 'hooks.json');
70
- // Same shell-quoting rationale as installClaudeCodeHook — the
71
- // command is a shell string, not argv, so the absolute path must
72
- // be quoted to survive spaces.
73
- const config = {
74
- beforeShellExecution: {
75
- command: `node "${opts.hookScriptPath}"`,
76
- timeout: 5000,
77
- },
78
- };
79
- await writeJsonIdempotent(hooksJsonPath, config);
119
+ const command = `node ${shellQuote(opts.hookScriptPath)} --agent=cursor`;
120
+ // Merge into the user's existing ~/.cursor/hooks.json instead of
121
+ // clobbering it that file is the GLOBAL Cursor hooks config and
122
+ // may already carry the user's own audit / lint hooks. Idempotent:
123
+ // re-running replaces only the gitnexus entries (matched by their
124
+ // command containing "gitnexus-enterprise-hook") and leaves
125
+ // unrelated event arrays + entries intact.
126
+ const existing = (await readJsonOrNull(hooksJsonPath)) || {};
127
+ if (typeof existing.version !== 'number')
128
+ existing.version = 1;
129
+ if (!existing.hooks || typeof existing.hooks !== 'object')
130
+ existing.hooks = {};
131
+ function upsertGitNexus(eventName, matcher, timeout) {
132
+ const arr = Array.isArray(existing.hooks[eventName])
133
+ ? existing.hooks[eventName]
134
+ : [];
135
+ const filtered = arr.filter((e) => !e.command?.includes('--agent=cursor'));
136
+ filtered.push({ matcher, command, timeout });
137
+ existing.hooks[eventName] = filtered;
138
+ }
139
+ upsertGitNexus('preToolUse', 'Shell|Read|Write', 5);
140
+ upsertGitNexus('postToolUse', 'Shell|Edit|Write|MultiEdit', 5);
141
+ await fs.mkdir(path.dirname(hooksJsonPath), { recursive: true });
142
+ await fs.writeFile(hooksJsonPath, JSON.stringify(existing, null, 2) + '\n');
80
143
  return hooksJsonPath;
81
144
  }
145
+ /**
146
+ * Install the Kiro graph-context + staleness hooks.
147
+ * Returns the absolute paths to both written hook files.
148
+ *
149
+ * Kiro hooks (kiro.dev/docs/hooks/types) live as one JSON file per
150
+ * hook under `.kiro/hooks/<name>.kiro.hook`. Trigger names are kebab-
151
+ * case ("pre-tool-use" / "post-tool-use"), matching the Claude Code
152
+ * PreToolUse/PostToolUse semantics. The gitnexus hook script handles
153
+ * both — it normalises the kebab-case event name back to PascalCase
154
+ * internally so a single script serves Kiro + Claude Code + Cursor +
155
+ * OpenCode.
156
+ *
157
+ * We register two hook files because Kiro's hook spec is one trigger
158
+ * per file; bundling both into a single file would silently lose one.
159
+ */
160
+ export async function installKiroHook(opts) {
161
+ const home = getHome(opts);
162
+ const hooksDir = path.join(home, '.kiro', 'hooks');
163
+ // `--agent=kiro` tags /api/activity captures so distillation can
164
+ // split rows by editor — Kiro's kebab-case events would also let
165
+ // detectAgent infer "kiro", but explicit > inferred.
166
+ const cmd = `node ${shellQuote(opts.hookScriptPath)} --agent=kiro`;
167
+ const preHookPath = path.join(hooksDir, 'gitnexus-graph-context.kiro.hook');
168
+ const postHookPath = path.join(hooksDir, 'gitnexus-staleness-check.kiro.hook');
169
+ await writeJsonIdempotent(preHookPath, {
170
+ name: 'gitnexus-graph-context',
171
+ description: 'Inject GitNexus graph context (callers, callees, impact) before Grep/Glob/Bash tool calls',
172
+ trigger: { type: 'pre-tool-use' },
173
+ actions: [{ type: 'shell', command: cmd }],
174
+ });
175
+ await writeJsonIdempotent(postHookPath, {
176
+ name: 'gitnexus-staleness-check',
177
+ description: 'Capture file edits (edit-closure) and nudge to gnx sync after git commit/merge/rebase',
178
+ trigger: { type: 'post-tool-use' },
179
+ actions: [{ type: 'shell', command: cmd }],
180
+ });
181
+ return [preHookPath, postHookPath];
182
+ }
82
183
  /**
83
184
  * Install the OpenCode plugin stub.
84
185
  * Returns the absolute path to the written TypeScript file.
@@ -95,6 +196,12 @@ export async function installOpenCodeHook(opts) {
95
196
  const content = `// GitNexus Enterprise OpenCode plugin
96
197
  // Stub — delegates to gitnexus-enterprise-hook.cjs via child process.
97
198
  // Hook script: ${opts.hookScriptPath}
199
+ //
200
+ // When wiring up the real spawn() call, pass --agent=opencode so
201
+ // /api/activity captures land with agentName='opencode' (otherwise
202
+ // the script defaults to 'claude-code', which corrupts distillation).
203
+ // Example:
204
+ // spawn('node', [HOOK_PATH, '--agent=opencode'], { stdio: ... })
98
205
 
99
206
  export default {
100
207
  async toolBefore(ctx: { tool: string }) {
package/dist/index.js CHANGED
@@ -57,7 +57,7 @@ program
57
57
  .command('connect', { isDefault: true })
58
58
  .description('Register Hub MCP, install skills, and write project files')
59
59
  .argument('[token]', 'gnx_ API token (optional if already saved)')
60
- .option('--editor <name>', 'Editor to configure: claude-code | cursor | windsurf | opencode')
60
+ .option('--editor <name>', 'Editor to configure: claude-code | cursor | windsurf | opencode | kiro')
61
61
  .option('--hub <url>', `Hub URL (default: saved config or ${DEFAULT_HUB_URL})`)
62
62
  .option('--skip-project', 'Only configure MCP, skip project files')
63
63
  .action(connectAction);
@@ -65,7 +65,7 @@ program
65
65
  program
66
66
  .command('disconnect')
67
67
  .description('Remove GitNexus MCP config, skills, and project files')
68
- .option('--editor <name>', 'Editor to unconfigure: claude-code | cursor | windsurf | opencode')
68
+ .option('--editor <name>', 'Editor to unconfigure: claude-code | cursor | windsurf | opencode | kiro')
69
69
  .action(async (opts) => {
70
70
  try {
71
71
  printBanner();
@@ -311,11 +311,103 @@ function httpGetJson(urlStr, headers) {
311
311
  }
312
312
 
313
313
  /**
314
- * Emit the Claude Code / Cursor / OpenCode hookSpecificOutput envelope.
315
- * The editor will prepend `message` to the tool's output before handing
316
- * control back to the model.
314
+ * Map raw `hook_event_name` strings (as written by each editor) onto
315
+ * the Claude Code-canonical PascalCase form the rest of this script
316
+ * uses. Returns null if the event isn't one we handle.
317
+ *
318
+ * Claude Code / OpenCode → "PreToolUse" / "PostToolUse" (PascalCase)
319
+ * Cursor 2.4+ → "preToolUse" / "postToolUse" (camelCase, see cursor.com/docs/hooks)
320
+ * Kiro IDE → "pre-tool-use" / "post-tool-use" (kebab-case, see kiro.dev/docs/hooks/types)
321
+ *
322
+ * Three different conventions; one script. Without this normalisation
323
+ * Cursor and Kiro hooks fire but the script bails on the event check
324
+ * and emits nothing — which is exactly the bug v0.6.0 shipped with.
325
+ */
326
+ function normaliseEvent(rawEvent) {
327
+ if (rawEvent === 'PreToolUse' || rawEvent === 'pre-tool-use' || rawEvent === 'preToolUse') {
328
+ return 'PreToolUse';
329
+ }
330
+ if (
331
+ rawEvent === 'PostToolUse' ||
332
+ rawEvent === 'post-tool-use' ||
333
+ rawEvent === 'postToolUse'
334
+ ) {
335
+ return 'PostToolUse';
336
+ }
337
+ return null;
338
+ }
339
+
340
+ /**
341
+ * Read an explicit agent override from argv. Each hook installer
342
+ * appends `--agent=<id>` to the spawn command so `/api/activity/*`
343
+ * captures can split rows by editor (claude-code vs cursor vs
344
+ * opencode vs kiro). The stdin payload alone can't distinguish
345
+ * Cursor from Claude Code from OpenCode — they all emit
346
+ * `hook_event_name: PreToolUse` — so the agent identity has to be
347
+ * baked in at install time.
348
+ *
349
+ * Returns null when the flag isn't present; callers fall back to
350
+ * stdin-based detection.
351
+ */
352
+ function parseAgentArg() {
353
+ for (const arg of process.argv.slice(2)) {
354
+ const m = arg.match(/^--agent=(.+)$/);
355
+ if (m) return m[1];
356
+ }
357
+ return null;
358
+ }
359
+
360
+ /**
361
+ * Detect which editor invoked us, used to tag /api/activity captures
362
+ * with the right `agentName`. Priority:
363
+ * 1. `--agent=<id>` argv flag (set by the hook installer at
364
+ * install time — the only reliable way to distinguish editors
365
+ * that share the PascalCase MCP convention)
366
+ * 2. kebab-case `pre-tool-use` / `post-tool-use` event → `kiro`
367
+ * (Kiro is the only editor emitting kebab-case)
368
+ * 3. fallback → `claude-code` (matches the historical hardcoded
369
+ * value, so older installs without the argv flag keep working)
317
370
  */
318
- function sendResponse(event, message) {
371
+ function detectAgent(rawEvent) {
372
+ const explicit = parseAgentArg();
373
+ if (explicit) return explicit;
374
+ if (rawEvent === 'pre-tool-use' || rawEvent === 'post-tool-use') return 'kiro';
375
+ if (rawEvent === 'preToolUse' || rawEvent === 'postToolUse') return 'cursor';
376
+ return 'claude-code';
377
+ }
378
+
379
+ /**
380
+ * Normalise the editor-specific tool name onto the Claude Code-
381
+ * canonical PascalCase shape the rest of this script uses. Cursor
382
+ * calls the shell tool `Shell` (cursor.com/docs/hooks postToolUse
383
+ * example), Claude Code / Kiro / OpenCode call it `Bash`. Other
384
+ * tool names match across editors so far.
385
+ */
386
+ function normaliseToolName(raw) {
387
+ if (raw === 'Shell') return 'Bash';
388
+ return raw || '';
389
+ }
390
+
391
+ /**
392
+ * Emit the response in the format the source editor expects. Three
393
+ * envelopes today, one script:
394
+ *
395
+ * Claude Code / OpenCode → `hookSpecificOutput` envelope
396
+ * ({ hookSpecificOutput: { hookEventName, additionalContext } })
397
+ * Cursor 2.4+ → `additional_context` (snake_case top-level)
398
+ * ({ additional_context: "..." }) see cursor.com/docs/hooks
399
+ * Kiro IDE → plain stdout (kiro.dev/docs/hooks/actions
400
+ * "the stdout output of the command is added to the agent's context")
401
+ */
402
+ function sendResponse(event, agentName, message) {
403
+ if (agentName === 'kiro') {
404
+ process.stdout.write(message + '\n');
405
+ return;
406
+ }
407
+ if (agentName === 'cursor') {
408
+ process.stdout.write(JSON.stringify({ additional_context: message }) + '\n');
409
+ return;
410
+ }
319
411
  process.stdout.write(
320
412
  JSON.stringify({
321
413
  hookSpecificOutput: { hookEventName: event, additionalContext: message },
@@ -323,8 +415,8 @@ function sendResponse(event, message) {
323
415
  );
324
416
  }
325
417
 
326
- async function handlePreToolUse(input, config, entry) {
327
- const toolName = input.tool_name || '';
418
+ async function handlePreToolUse(input, config, entry, agentName) {
419
+ const toolName = normaliseToolName(input.tool_name);
328
420
  if (toolName !== 'Grep' && toolName !== 'Glob' && toolName !== 'Bash') return;
329
421
 
330
422
  const pattern = extractPattern(toolName, input.tool_input || {});
@@ -339,7 +431,7 @@ async function handlePreToolUse(input, config, entry) {
339
431
 
340
432
  const text = String(res.body.text).trim();
341
433
  if (!text) return;
342
- sendResponse('PreToolUse', text);
434
+ sendResponse('PreToolUse', agentName, text);
343
435
  }
344
436
 
345
437
  /**
@@ -376,8 +468,8 @@ function writeMetaCache(repoId, meta) {
376
468
  *
377
469
  * Best-effort: never blocks the editor, never re-tries, swallows errors.
378
470
  */
379
- async function handleEditObservation(input, config, entry) {
380
- const tool = input.tool_name;
471
+ async function handleEditObservation(input, config, entry, agentName) {
472
+ const tool = normaliseToolName(input.tool_name);
381
473
  if (tool !== 'Edit' && tool !== 'Write' && tool !== 'MultiEdit') return;
382
474
 
383
475
  // Only fire when the edit succeeded. Some editors omit exit info; treat
@@ -401,19 +493,21 @@ async function handleEditObservation(input, config, entry) {
401
493
  filePath,
402
494
  line,
403
495
  tool,
404
- agentName: 'claude-code',
496
+ agentName,
405
497
  });
406
498
  }
407
499
 
408
- async function handlePostToolUse(input, config, entry) {
500
+ async function handlePostToolUse(input, config, entry, agentName) {
501
+ const toolName = normaliseToolName(input.tool_name);
502
+
409
503
  // Fan out: edit observations are independent of the git-staleness check.
410
504
  // Both run on PostToolUse; we don't want one to block the other.
411
- if (input.tool_name === 'Edit' || input.tool_name === 'Write' || input.tool_name === 'MultiEdit') {
412
- await handleEditObservation(input, config, entry);
505
+ if (toolName === 'Edit' || toolName === 'Write' || toolName === 'MultiEdit') {
506
+ await handleEditObservation(input, config, entry, agentName);
413
507
  return;
414
508
  }
415
509
 
416
- if (input.tool_name !== 'Bash') return;
510
+ if (toolName !== 'Bash') return;
417
511
  const cmd = (input.tool_input && input.tool_input.command) || '';
418
512
  if (!/\bgit\s+(commit|merge|rebase|cherry-pick|pull)(\s|$)/.test(cmd)) return;
419
513
 
@@ -451,6 +545,7 @@ async function handlePostToolUse(input, config, entry) {
451
545
  const shortOld = meta.last_commit ? String(meta.last_commit).slice(0, 7) : 'none';
452
546
  sendResponse(
453
547
  'PostToolUse',
548
+ agentName,
454
549
  `GitNexus index is stale (last indexed: ${shortOld}). Run \`gnx sync\` to update.`,
455
550
  );
456
551
  }
@@ -542,8 +637,11 @@ async function main() {
542
637
  if (process.env.GITNEXUS_NO_AUGMENT === '1') return;
543
638
 
544
639
  const input = readInput();
545
- const event = input.hook_event_name;
546
- if (event !== 'PreToolUse' && event !== 'PostToolUse') return;
640
+ const rawEvent = input.hook_event_name;
641
+ const event = normaliseEvent(rawEvent);
642
+ if (!event) return;
643
+
644
+ const agentName = detectAgent(rawEvent);
547
645
 
548
646
  const config = readConfig();
549
647
  if (!config || !config.hubToken || !config.hubUrl) return;
@@ -562,9 +660,9 @@ async function main() {
562
660
 
563
661
  try {
564
662
  if (event === 'PreToolUse') {
565
- await handlePreToolUse(input, config, entry);
663
+ await handlePreToolUse(input, config, entry, agentName);
566
664
  } else {
567
- await handlePostToolUse(input, config, entry);
665
+ await handlePostToolUse(input, config, entry, agentName);
568
666
  }
569
667
  } catch (err) {
570
668
  if (process.env.GITNEXUS_DEBUG) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexushub",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Connect your editor to GitNexus Hub — one command MCP setup + project context",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",