gitnexushub 0.6.0 → 0.7.1

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;
@@ -10,6 +10,22 @@ export interface InstallOptions {
10
10
  homeDir?: string;
11
11
  /** Absolute path to the shipped hook script. */
12
12
  hookScriptPath: string;
13
+ /**
14
+ * Absolute path to the `node` binary that should run the hook
15
+ * script. Defaults to `process.execPath` — the node binary
16
+ * currently running the installer, which is the safest cross-
17
+ * platform guarantee that the hook spawn will find an interpreter.
18
+ *
19
+ * Why this matters: GUI editor processes (Cursor.app on macOS, Kiro
20
+ * launched from Dock) inherit a minimal `PATH` that often excludes
21
+ * `~/.local/node/bin`, `/opt/homebrew/bin`, etc. — wherever the
22
+ * user installed node. A hook command of `node "..."` then fails
23
+ * to spawn (`/bin/sh: node: command not found`) and the editor
24
+ * silently marks the hook as failed (Cursor renders this as a
25
+ * cosmetic red X in the Hooks tab; the hook never runs). Embedding
26
+ * the absolute path sidesteps PATH entirely. Tests can override.
27
+ */
28
+ nodePath?: string;
13
29
  }
14
30
  /**
15
31
  * Install the Claude Code PreToolUse + PostToolUse hooks.
@@ -17,10 +33,57 @@ export interface InstallOptions {
17
33
  */
18
34
  export declare function installClaudeCodeHook(opts: InstallOptions): Promise<string>;
19
35
  /**
20
- * Install the Cursor beforeShellExecution hook.
36
+ * Install the Cursor preToolUse + postToolUse hooks.
21
37
  * Returns the absolute path to the written hooks.json.
38
+ *
39
+ * Cursor 2.4+ hooks live at ~/.cursor/hooks.json with a
40
+ * { version: 1, hooks: { <eventName>: [{ command, ... }] } }
41
+ * shape (cursor.com/docs/hooks). We register `preToolUse` for
42
+ * graph-context augmentation on Shell/Read/Write, and `postToolUse`
43
+ * for staleness detection after `git commit/merge/...` and for
44
+ * edit-closure capture on Edit/Write/MultiEdit.
45
+ *
46
+ * The earlier 0.6.0 implementation used `beforeShellExecution`,
47
+ * which:
48
+ * 1. fires *before* shell execution — wrong direction for the
49
+ * git-staleness post-commit nudge,
50
+ * 2. sends a different stdin shape (no `tool_name`/`tool_input`),
51
+ * so the hook script silently bails on the event check,
52
+ * 3. has no documented context-injection envelope.
53
+ * Switching to pre/postToolUse fixes all three.
22
54
  */
23
55
  export declare function installCursorHook(opts: InstallOptions): Promise<string>;
56
+ /**
57
+ * Install Kiro hooks — graph-tools nudge (askAgent) + edit-closure
58
+ * capture (runCommand). Returns the absolute paths to both files.
59
+ *
60
+ * Kiro's actual hook schema (verified against a Kiro-UI-generated
61
+ * file, kiro.dev/docs/hooks notwithstanding) differs in three places
62
+ * from what 0.7.0 shipped:
63
+ *
64
+ * 1. Top-level shape is `{when, then, version, name, enabled}` —
65
+ * not the `{trigger, actions[]}` shape we were writing.
66
+ * 2. Trigger type values are camelCase (`postToolUse`) — not the
67
+ * kebab-case (`post-tool-use`) we emitted.
68
+ * 3. Action type for shell commands is `runCommand` — not `shell`.
69
+ *
70
+ * Plus three runtime constraints Kiro disclosed when asked directly:
71
+ *
72
+ * - `runCommand` actions get NO stdin payload, NO env vars for
73
+ * tool metadata, and NO `${variable}` substitution. So unlike
74
+ * Claude Code / Cursor, the script can't read the just-edited
75
+ * file path off stdin — we have to scan the working tree
76
+ * ourselves (see runKiroEditCapture in the hook script).
77
+ * - `runCommand` stdout is NOT fed back to the agent. Staleness
78
+ * nudges and graph-context augmentation are invisible via this
79
+ * channel — only `askAgent` actions can inject prompt text.
80
+ * - The graph-tools nudge therefore uses `askAgent`; capture uses
81
+ * `runCommand` plus the `--kiro-edit-capture` flag.
82
+ *
83
+ * `~/.kiro/hooks/` (user-level) IS loaded by Kiro despite some docs
84
+ * suggesting workspace-only — verified empirically in this session.
85
+ */
86
+ export declare function installKiroHook(opts: InstallOptions): Promise<string[]>;
24
87
  /**
25
88
  * Install the OpenCode plugin stub.
26
89
  * Returns the absolute path to the written TypeScript file.
@@ -11,11 +11,43 @@ import os from 'os';
11
11
  function getHome(opts) {
12
12
  return opts.homeDir ?? os.homedir();
13
13
  }
14
+ function getNode(opts) {
15
+ return opts.nodePath ?? process.execPath;
16
+ }
14
17
  async function writeJsonIdempotent(filePath, obj) {
15
18
  await fs.mkdir(path.dirname(filePath), { recursive: true });
16
19
  const body = JSON.stringify(obj, null, 2) + '\n';
17
20
  await fs.writeFile(filePath, body);
18
21
  }
22
+ /**
23
+ * Quote a path for embedding in a shell-style command string. The
24
+ * hook spec across Claude Code / Cursor / Kiro is a literal shell
25
+ * command, not an argv array — so spaces (`John Doe`) and stray
26
+ * quotes both have to survive shell parsing. Wraps in `"..."` and
27
+ * escapes any `"` already inside the path so the shell sees a single
28
+ * argument.
29
+ *
30
+ * Realistic risk of `"` inside the path is near-zero (npm install
31
+ * dirs, Windows profile dirs), but matching the same defensive quoting
32
+ * the OSS gitnexus/src/cli/setup.ts uses keeps the two installers
33
+ * symmetrical.
34
+ */
35
+ function shellQuote(p) {
36
+ return `"${p.replace(/"/g, '\\"')}"`;
37
+ }
38
+ /**
39
+ * Read an existing JSON file, returning null if the file is absent
40
+ * or unparseable. Used by installers that need to merge into a
41
+ * shared config rather than clobber it.
42
+ */
43
+ async function readJsonOrNull(filePath) {
44
+ try {
45
+ return JSON.parse(await fs.readFile(filePath, 'utf-8'));
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
19
51
  /**
20
52
  * Install the Claude Code PreToolUse + PostToolUse hooks.
21
53
  * Returns the absolute path to the written hooks.json.
@@ -24,11 +56,15 @@ export async function installClaudeCodeHook(opts) {
24
56
  const home = getHome(opts);
25
57
  const hooksDir = path.join(home, '.claude', 'plugins', 'gitnexus-enterprise', 'hooks');
26
58
  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}"`;
59
+ // Double-quote (and escape any embedded quotes) so shell splitting
60
+ // doesn't break when the install dir contains a space (e.g. Windows
61
+ // profiles at `C:\Users\John Doe\...`). The hook spec is a shell-
62
+ // style command string, not an argv array, so quoting is the only
63
+ // knob available. The `--agent=claude-code` flag tells the hook
64
+ // script which editor it's running under so /api/activity captures
65
+ // get the right agentName (the stdin payload alone can't
66
+ // distinguish editors that share the PascalCase MCP event names).
67
+ const command = `${shellQuote(getNode(opts))} ${shellQuote(opts.hookScriptPath)} --agent=claude-code`;
32
68
  const config = {
33
69
  hooks: {
34
70
  PreToolUse: [
@@ -37,7 +73,7 @@ export async function installClaudeCodeHook(opts) {
37
73
  hooks: [
38
74
  {
39
75
  type: 'command',
40
- command: `node ${quotedScriptPath}`,
76
+ command,
41
77
  timeout: 5,
42
78
  },
43
79
  ],
@@ -49,7 +85,7 @@ export async function installClaudeCodeHook(opts) {
49
85
  hooks: [
50
86
  {
51
87
  type: 'command',
52
- command: `node ${quotedScriptPath}`,
88
+ command,
53
89
  timeout: 5,
54
90
  },
55
91
  ],
@@ -61,24 +97,120 @@ export async function installClaudeCodeHook(opts) {
61
97
  return hooksJsonPath;
62
98
  }
63
99
  /**
64
- * Install the Cursor beforeShellExecution hook.
100
+ * Install the Cursor preToolUse + postToolUse hooks.
65
101
  * Returns the absolute path to the written hooks.json.
102
+ *
103
+ * Cursor 2.4+ hooks live at ~/.cursor/hooks.json with a
104
+ * { version: 1, hooks: { <eventName>: [{ command, ... }] } }
105
+ * shape (cursor.com/docs/hooks). We register `preToolUse` for
106
+ * graph-context augmentation on Shell/Read/Write, and `postToolUse`
107
+ * for staleness detection after `git commit/merge/...` and for
108
+ * edit-closure capture on Edit/Write/MultiEdit.
109
+ *
110
+ * The earlier 0.6.0 implementation used `beforeShellExecution`,
111
+ * which:
112
+ * 1. fires *before* shell execution — wrong direction for the
113
+ * git-staleness post-commit nudge,
114
+ * 2. sends a different stdin shape (no `tool_name`/`tool_input`),
115
+ * so the hook script silently bails on the event check,
116
+ * 3. has no documented context-injection envelope.
117
+ * Switching to pre/postToolUse fixes all three.
66
118
  */
67
119
  export async function installCursorHook(opts) {
68
120
  const home = getHome(opts);
69
121
  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);
122
+ const command = `${shellQuote(getNode(opts))} ${shellQuote(opts.hookScriptPath)} --agent=cursor`;
123
+ // Merge into the user's existing ~/.cursor/hooks.json instead of
124
+ // clobbering it that file is the GLOBAL Cursor hooks config and
125
+ // may already carry the user's own audit / lint hooks. Idempotent:
126
+ // re-running replaces only the gitnexus entries (matched by their
127
+ // command containing "gitnexus-enterprise-hook") and leaves
128
+ // unrelated event arrays + entries intact.
129
+ const existing = (await readJsonOrNull(hooksJsonPath)) || {};
130
+ if (typeof existing.version !== 'number')
131
+ existing.version = 1;
132
+ if (!existing.hooks || typeof existing.hooks !== 'object')
133
+ existing.hooks = {};
134
+ function upsertGitNexus(eventName, matcher, timeout) {
135
+ const arr = Array.isArray(existing.hooks[eventName])
136
+ ? existing.hooks[eventName]
137
+ : [];
138
+ const filtered = arr.filter((e) => !e.command?.includes('--agent=cursor'));
139
+ filtered.push({ matcher, command, timeout });
140
+ existing.hooks[eventName] = filtered;
141
+ }
142
+ upsertGitNexus('preToolUse', 'Shell|Read|Write', 5);
143
+ upsertGitNexus('postToolUse', 'Shell|Edit|Write|MultiEdit', 5);
144
+ await fs.mkdir(path.dirname(hooksJsonPath), { recursive: true });
145
+ await fs.writeFile(hooksJsonPath, JSON.stringify(existing, null, 2) + '\n');
80
146
  return hooksJsonPath;
81
147
  }
148
+ /**
149
+ * Install Kiro hooks — graph-tools nudge (askAgent) + edit-closure
150
+ * capture (runCommand). Returns the absolute paths to both files.
151
+ *
152
+ * Kiro's actual hook schema (verified against a Kiro-UI-generated
153
+ * file, kiro.dev/docs/hooks notwithstanding) differs in three places
154
+ * from what 0.7.0 shipped:
155
+ *
156
+ * 1. Top-level shape is `{when, then, version, name, enabled}` —
157
+ * not the `{trigger, actions[]}` shape we were writing.
158
+ * 2. Trigger type values are camelCase (`postToolUse`) — not the
159
+ * kebab-case (`post-tool-use`) we emitted.
160
+ * 3. Action type for shell commands is `runCommand` — not `shell`.
161
+ *
162
+ * Plus three runtime constraints Kiro disclosed when asked directly:
163
+ *
164
+ * - `runCommand` actions get NO stdin payload, NO env vars for
165
+ * tool metadata, and NO `${variable}` substitution. So unlike
166
+ * Claude Code / Cursor, the script can't read the just-edited
167
+ * file path off stdin — we have to scan the working tree
168
+ * ourselves (see runKiroEditCapture in the hook script).
169
+ * - `runCommand` stdout is NOT fed back to the agent. Staleness
170
+ * nudges and graph-context augmentation are invisible via this
171
+ * channel — only `askAgent` actions can inject prompt text.
172
+ * - The graph-tools nudge therefore uses `askAgent`; capture uses
173
+ * `runCommand` plus the `--kiro-edit-capture` flag.
174
+ *
175
+ * `~/.kiro/hooks/` (user-level) IS loaded by Kiro despite some docs
176
+ * suggesting workspace-only — verified empirically in this session.
177
+ */
178
+ export async function installKiroHook(opts) {
179
+ const home = getHome(opts);
180
+ const hooksDir = path.join(home, '.kiro', 'hooks');
181
+ const editCaptureCmd = `${shellQuote(getNode(opts))} ${shellQuote(opts.hookScriptPath)} --agent=kiro --kiro-edit-capture`;
182
+ const preferGraphHookPath = path.join(hooksDir, 'gitnexus-prefer-graph-tools.kiro.hook');
183
+ const editCaptureHookPath = path.join(hooksDir, 'gitnexus-edit-capture.kiro.hook');
184
+ // 1. Steer the agent toward graph tools before shell searches.
185
+ // askAgent is the only Kiro action whose prompt actually reaches
186
+ // the agent context; runCommand stdout is discarded.
187
+ await writeJsonIdempotent(preferGraphHookPath, {
188
+ name: 'gitnexus-prefer-graph-tools',
189
+ version: '1',
190
+ enabled: true,
191
+ description: 'Before running shell searches (grep/find/rg), prefer GitNexus graph tools for richer code context',
192
+ when: { type: 'preToolUse', toolTypes: ['shell'] },
193
+ then: {
194
+ type: 'askAgent',
195
+ prompt: "Before running this shell command: if it's a search (grep, find, glob, rg), prefer the GitNexus MCP tools instead — `gitnexus_query` for concept search, `gitnexus_context` for symbol lookup, `gitnexus_cypher` for structural queries. Only fall back to shell search if GitNexus can't answer it.",
196
+ },
197
+ });
198
+ // 2. Edit-closure capture. Fires after every write tool. Hook
199
+ // script runs `git status --porcelain` in the workspace root,
200
+ // posts each dirty file to /api/activity/edit-observed. Coarser
201
+ // than Claude Code / Cursor (catches all dirty files, not just
202
+ // the one the agent just touched) — but it's the only signal
203
+ // Kiro's hook plumbing exposes.
204
+ await writeJsonIdempotent(editCaptureHookPath, {
205
+ name: 'gitnexus-edit-capture',
206
+ version: '1',
207
+ enabled: true,
208
+ description: 'Capture file edits for distillation by scanning the working tree after write tool calls',
209
+ when: { type: 'postToolUse', toolTypes: ['write'] },
210
+ then: { type: 'runCommand', command: editCaptureCmd, timeout: 5 },
211
+ });
212
+ return [preferGraphHookPath, editCaptureHookPath];
213
+ }
82
214
  /**
83
215
  * Install the OpenCode plugin stub.
84
216
  * Returns the absolute path to the written TypeScript file.
@@ -95,6 +227,12 @@ export async function installOpenCodeHook(opts) {
95
227
  const content = `// GitNexus Enterprise OpenCode plugin
96
228
  // Stub — delegates to gitnexus-enterprise-hook.cjs via child process.
97
229
  // Hook script: ${opts.hookScriptPath}
230
+ //
231
+ // When wiring up the real spawn() call, pass --agent=opencode so
232
+ // /api/activity captures land with agentName='opencode' (otherwise
233
+ // the script defaults to 'claude-code', which corrupts distillation).
234
+ // Example:
235
+ // spawn('node', [HOOK_PATH, '--agent=opencode'], { stdio: ... })
98
236
 
99
237
  export default {
100
238
  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,147 @@ 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.
317
325
  */
318
- function sendResponse(event, message) {
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)
370
+ */
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
+ * Parse a `tool_output` field that may be either an object (Claude
393
+ * Code / Kiro / OpenCode native shape) or a JSON-stringified blob
394
+ * (Cursor wraps the result as a string per cursor.com/docs/hooks).
395
+ * Returns the parsed object, or the original value if it isn't a
396
+ * string we can decode. Caller still has to check for null.
397
+ */
398
+ function parseToolOutput(out) {
399
+ if (typeof out === 'string') {
400
+ try {
401
+ return JSON.parse(out);
402
+ } catch {
403
+ return null;
404
+ }
405
+ }
406
+ return out;
407
+ }
408
+
409
+ /**
410
+ * Get the exit code from a normalised tool_output, accepting either
411
+ * `exit_code` (Claude Code / snake_case) or `exitCode` (Cursor /
412
+ * camelCase). Returns null when neither is present so callers can
413
+ * treat that as "succeeded" (some editors omit exit info entirely).
414
+ */
415
+ function readExitCode(out) {
416
+ if (!out || typeof out !== 'object') return null;
417
+ if (typeof out.exit_code === 'number') return out.exit_code;
418
+ if (typeof out.exitCode === 'number') return out.exitCode;
419
+ return null;
420
+ }
421
+
422
+ /**
423
+ * Emit the response in the format the source editor expects. Four
424
+ * envelopes today, one script:
425
+ *
426
+ * Claude Code / OpenCode → `hookSpecificOutput` envelope
427
+ * ({ hookSpecificOutput: { hookEventName, additionalContext } })
428
+ * Cursor 2.4+ preToolUse → `permission` field is REQUIRED (cursor.com/docs/hooks)
429
+ * plus optional `additional_context` for prompt injection
430
+ * Cursor 2.4+ postToolUse → `additional_context` only
431
+ * Kiro IDE → plain stdout (kiro.dev/docs/hooks/actions —
432
+ * except `runCommand` actions are fire-and-forget; stdout is NOT
433
+ * fed back into agent context per Kiro's own clarification)
434
+ */
435
+ function sendResponse(event, agentName, message) {
436
+ if (agentName === 'kiro') {
437
+ // Kiro `runCommand` action discards stdout. Writing it is harmless
438
+ // — it just doesn't reach the agent. We still emit so manual
439
+ // testing / log inspection has something to read.
440
+ process.stdout.write(message + '\n');
441
+ return;
442
+ }
443
+ if (agentName === 'cursor') {
444
+ // Cursor preToolUse rejects entries that don't include `permission`
445
+ // — without it the Hooks tab logs the call as failed (cosmetic red
446
+ // X) even when our capture POST succeeded behind the scenes. We
447
+ // always allow; gating tool calls isn't gitnexus's job.
448
+ const envelope =
449
+ event === 'PreToolUse'
450
+ ? { permission: 'allow', additional_context: message }
451
+ : { additional_context: message };
452
+ process.stdout.write(JSON.stringify(envelope) + '\n');
453
+ return;
454
+ }
319
455
  process.stdout.write(
320
456
  JSON.stringify({
321
457
  hookSpecificOutput: { hookEventName: event, additionalContext: message },
@@ -323,23 +459,48 @@ function sendResponse(event, message) {
323
459
  );
324
460
  }
325
461
 
326
- async function handlePreToolUse(input, config, entry) {
327
- const toolName = input.tool_name || '';
328
- if (toolName !== 'Grep' && toolName !== 'Glob' && toolName !== 'Bash') return;
329
-
330
- const pattern = extractPattern(toolName, input.tool_input || {});
331
- if (!pattern || pattern.length < 3) return;
462
+ /**
463
+ * Cursor preToolUse with no augmentation message still has to return
464
+ * `permission: "allow"` or the IDE logs the call as failed. Use this
465
+ * when the script's other handlers decide not to inject context but
466
+ * we still need to clear the permission gate.
467
+ */
468
+ function sendCursorAllow() {
469
+ process.stdout.write(JSON.stringify({ permission: 'allow' }) + '\n');
470
+ }
332
471
 
333
- const res = await httpPostJson(
334
- `${config.hubUrl}/api/repos/${entry.hubRepoId}/augment`,
335
- authHeaders(config),
336
- { pattern },
337
- );
338
- if (!res || res.status !== 200 || !res.body || !res.body.text) return;
472
+ async function handlePreToolUse(input, config, entry, agentName) {
473
+ const toolName = normaliseToolName(input.tool_name);
474
+
475
+ // Cursor's preToolUse matcher includes Read + Write so the hook
476
+ // also fires for those, but our augment flow only handles search
477
+ // tools. Track whether we actually injected context so we can
478
+ // satisfy Cursor's permission contract on the no-augment path.
479
+ let injected = false;
480
+
481
+ if (toolName === 'Grep' || toolName === 'Glob' || toolName === 'Bash') {
482
+ const pattern = extractPattern(toolName, input.tool_input || {});
483
+ if (pattern && pattern.length >= 3) {
484
+ const res = await httpPostJson(
485
+ `${config.hubUrl}/api/repos/${entry.hubRepoId}/augment`,
486
+ authHeaders(config),
487
+ { pattern },
488
+ );
489
+ if (res && res.status === 200 && res.body && res.body.text) {
490
+ const text = String(res.body.text).trim();
491
+ if (text) {
492
+ sendResponse('PreToolUse', agentName, text);
493
+ injected = true;
494
+ }
495
+ }
496
+ }
497
+ }
339
498
 
340
- const text = String(res.body.text).trim();
341
- if (!text) return;
342
- sendResponse('PreToolUse', text);
499
+ // Cursor preToolUse REQUIRES `permission` in the response; without
500
+ // it the Hooks tab marks every fire as failed (red X). Allow the
501
+ // tool unconditionally when we have nothing else to say — gating
502
+ // tool execution isn't gitnexus's responsibility.
503
+ if (!injected && agentName === 'cursor') sendCursorAllow();
343
504
  }
344
505
 
345
506
  /**
@@ -376,15 +537,20 @@ function writeMetaCache(repoId, meta) {
376
537
  *
377
538
  * Best-effort: never blocks the editor, never re-tries, swallows errors.
378
539
  */
379
- async function handleEditObservation(input, config, entry) {
380
- const tool = input.tool_name;
540
+ async function handleEditObservation(input, config, entry, agentName) {
541
+ const tool = normaliseToolName(input.tool_name);
381
542
  if (tool !== 'Edit' && tool !== 'Write' && tool !== 'MultiEdit') return;
382
543
 
383
544
  // Only fire when the edit succeeded. Some editors omit exit info; treat
384
- // missing as success so we don't drop legitimate edits.
385
- const out = input.tool_output;
386
- if (out && out.exit_code !== undefined && out.exit_code !== 0) return;
387
- if (out && out.error) return;
545
+ // missing as success so we don't drop legitimate edits. Cursor wraps
546
+ // tool_output as a JSON-stringified blob; everyone else passes an
547
+ // object parseToolOutput normalises both. Cursor uses `exitCode`
548
+ // (camel) while Claude Code uses `exit_code` (snake) — readExitCode
549
+ // accepts either.
550
+ const out = parseToolOutput(input.tool_output);
551
+ const exitCode = readExitCode(out);
552
+ if (exitCode !== null && exitCode !== 0) return;
553
+ if (out && typeof out === 'object' && out.error) return;
388
554
 
389
555
  const filePath = (input.tool_input && input.tool_input.file_path) || '';
390
556
  if (!filePath || typeof filePath !== 'string') return;
@@ -401,27 +567,30 @@ async function handleEditObservation(input, config, entry) {
401
567
  filePath,
402
568
  line,
403
569
  tool,
404
- agentName: 'claude-code',
570
+ agentName,
405
571
  });
406
572
  }
407
573
 
408
- async function handlePostToolUse(input, config, entry) {
574
+ async function handlePostToolUse(input, config, entry, agentName) {
575
+ const toolName = normaliseToolName(input.tool_name);
576
+
409
577
  // Fan out: edit observations are independent of the git-staleness check.
410
578
  // 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);
579
+ if (toolName === 'Edit' || toolName === 'Write' || toolName === 'MultiEdit') {
580
+ await handleEditObservation(input, config, entry, agentName);
413
581
  return;
414
582
  }
415
583
 
416
- if (input.tool_name !== 'Bash') return;
584
+ if (toolName !== 'Bash') return;
417
585
  const cmd = (input.tool_input && input.tool_input.command) || '';
418
586
  if (!/\bgit\s+(commit|merge|rebase|cherry-pick|pull)(\s|$)/.test(cmd)) return;
419
587
 
420
- // Only nudge when the git command actually succeeded. Some editors
421
- // omit exit_code; treat missing as success so we don't silently
422
- // skip legitimate mutations.
423
- const exitCode = input.tool_output && input.tool_output.exit_code;
424
- if (exitCode !== undefined && exitCode !== 0) return;
588
+ // Only nudge when the git command actually succeeded. Same parse
589
+ // shenanigans as handleEditObservation Cursor stringifies and uses
590
+ // camelCase. Treat missing exit info as success.
591
+ const out = parseToolOutput(input.tool_output);
592
+ const exitCode = readExitCode(out);
593
+ if (exitCode !== null && exitCode !== 0) return;
425
594
 
426
595
  let localHead = '';
427
596
  try {
@@ -451,6 +620,7 @@ async function handlePostToolUse(input, config, entry) {
451
620
  const shortOld = meta.last_commit ? String(meta.last_commit).slice(0, 7) : 'none';
452
621
  sendResponse(
453
622
  'PostToolUse',
623
+ agentName,
454
624
  `GitNexus index is stale (last indexed: ${shortOld}). Run \`gnx sync\` to update.`,
455
625
  );
456
626
  }
@@ -538,12 +708,101 @@ async function maybeNudgeUpgrade(config) {
538
708
  }
539
709
  }
540
710
 
711
+ /**
712
+ * Kiro edit-capture mode. Kiro's `runCommand` action provides no stdin
713
+ * payload, no env vars for tool metadata, and no argument substitution
714
+ * — so we can't know which file the agent just edited. Instead, fire
715
+ * this on Kiro's `postToolUse` toolTypes:["write"] hook and scan the
716
+ * git working tree for changed files via `git status --porcelain`.
717
+ * Each dirty path POSTs to /api/activity/edit-observed so distillation
718
+ * gets per-file edit-closure data, even though the precision is
719
+ * coarser than Claude Code / Cursor (catches all dirty files in the
720
+ * tree, not just the one the agent just touched).
721
+ *
722
+ * cwd defaults to process.cwd() which Kiro sets to the workspace root.
723
+ * Resolving to a registry repo + reading the auth token mirrors the
724
+ * standard PostToolUse path.
725
+ */
726
+ async function runKiroEditCapture(agentName) {
727
+ const config = readConfig();
728
+ if (!config || !config.hubToken || !config.hubUrl) return;
729
+
730
+ const entries = readRegistry();
731
+ const cwd = process.cwd();
732
+ const entry = resolveCwdToRepo(cwd, entries);
733
+ if (!entry) return;
734
+
735
+ // Two parallel git invocations: tracked-file modifications via
736
+ // `git diff --name-only HEAD` (catches modified + staged + deleted-
737
+ // tracked), and untracked files via `git ls-files --others
738
+ // --exclude-standard`. Both produce one absolute-relative path per
739
+ // line, no status-code prefix — far more robust than parsing
740
+ // `git status --porcelain` (whose XY field varies across git
741
+ // versions and edge cases).
742
+ const collected = new Set();
743
+ for (const args of [
744
+ ['diff', '--name-only', 'HEAD'],
745
+ ['ls-files', '--others', '--exclude-standard'],
746
+ ]) {
747
+ try {
748
+ const r = spawnSync('git', args, { cwd, encoding: 'utf-8', timeout: 2000 });
749
+ const out = (r.stdout || '').trim();
750
+ if (!out) continue;
751
+ for (const line of out.split('\n')) {
752
+ const trimmed = line.trim();
753
+ if (trimmed) collected.add(trimmed);
754
+ }
755
+ } catch {
756
+ // Best effort — proceed with whatever we got from prior args.
757
+ }
758
+ }
759
+ if (collected.size === 0) return;
760
+
761
+ // Cap at 20 files per fire so a big rewrite or a fresh branch
762
+ // pull doesn't hammer the hub with hundreds of edit-observed
763
+ // posts. The 20 cap is arbitrary but matches the per-call
764
+ // ladybugdb worker pool's typical session length.
765
+ let posted = 0;
766
+ for (const filePath of collected) {
767
+ if (posted >= 20) break;
768
+ const absPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
769
+ await httpPostJson(`${config.hubUrl}/api/activity/edit-observed`, authHeaders(config), {
770
+ sessionId: null,
771
+ repoId: entry.hubRepoId,
772
+ filePath: absPath,
773
+ line: null,
774
+ tool: 'Write',
775
+ agentName,
776
+ });
777
+ posted++;
778
+ }
779
+ }
780
+
541
781
  async function main() {
542
782
  if (process.env.GITNEXUS_NO_AUGMENT === '1') return;
543
783
 
784
+ // Kiro short-circuit: when invoked with --kiro-edit-capture, skip
785
+ // the standard stdin-driven flow entirely (Kiro's runCommand
786
+ // doesn't pipe stdin) and run the porcelain scan instead. The
787
+ // installer wires this flag onto Kiro's postToolUse:write hook.
788
+ if (process.argv.slice(2).includes('--kiro-edit-capture')) {
789
+ const agentName = parseAgentArg() || 'kiro';
790
+ try {
791
+ await runKiroEditCapture(agentName);
792
+ } catch (err) {
793
+ if (process.env.GITNEXUS_DEBUG) {
794
+ process.stderr.write(`kiro edit-capture: ${(err && err.message) || String(err)}\n`);
795
+ }
796
+ }
797
+ return;
798
+ }
799
+
544
800
  const input = readInput();
545
- const event = input.hook_event_name;
546
- if (event !== 'PreToolUse' && event !== 'PostToolUse') return;
801
+ const rawEvent = input.hook_event_name;
802
+ const event = normaliseEvent(rawEvent);
803
+ if (!event) return;
804
+
805
+ const agentName = detectAgent(rawEvent);
547
806
 
548
807
  const config = readConfig();
549
808
  if (!config || !config.hubToken || !config.hubUrl) return;
@@ -556,15 +815,32 @@ async function main() {
556
815
  }
557
816
 
558
817
  const entries = readRegistry();
559
- const cwd = input.cwd || process.cwd();
818
+ // Cursor 3.x spawns hooks from ~/.cursor (NOT the workspace) and
819
+ // sets `cwd: ""` in the stdin payload. The actual project root is
820
+ // in `workspace_roots[0]`. Without this fallback, resolveCwdToRepo
821
+ // looks for a registry entry at ~/.cursor → no match → script
822
+ // bails before posting /api/activity/edit-observed. Other editors
823
+ // (Claude Code, Kiro) set `cwd` themselves, so the fallback chain
824
+ // hits their value first.
825
+ const cwd =
826
+ (Array.isArray(input.workspace_roots) && input.workspace_roots[0]) ||
827
+ input.cwd ||
828
+ process.cwd();
560
829
  const entry = resolveCwdToRepo(cwd, entries);
561
- if (!entry) return;
830
+ if (!entry) {
831
+ // Cursor preToolUse must still emit `permission: "allow"` even
832
+ // when we have nothing else to say — without it the IDE flags the
833
+ // hook entry as failed (cosmetic red X). All other paths exit
834
+ // silently as before.
835
+ if (event === 'PreToolUse' && agentName === 'cursor') sendCursorAllow();
836
+ return;
837
+ }
562
838
 
563
839
  try {
564
840
  if (event === 'PreToolUse') {
565
- await handlePreToolUse(input, config, entry);
841
+ await handlePreToolUse(input, config, entry, agentName);
566
842
  } else {
567
- await handlePostToolUse(input, config, entry);
843
+ await handlePostToolUse(input, config, entry, agentName);
568
844
  }
569
845
  } catch (err) {
570
846
  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.1",
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",