gitnexushub 0.3.0 → 0.4.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.
package/dist/api.d.ts CHANGED
@@ -50,6 +50,28 @@ export interface RepoDetail {
50
50
  phase: string;
51
51
  } | null;
52
52
  }
53
+ export interface RepoMeta {
54
+ full_name: string;
55
+ last_commit: string | null;
56
+ indexed_at: string | null;
57
+ graph_epoch: number;
58
+ stats: Record<string, number>;
59
+ }
60
+ export interface SyncMetadata {
61
+ local_head: string;
62
+ local_branch?: string;
63
+ dirty?: boolean;
64
+ }
65
+ export interface SyncResult {
66
+ status: 'queued' | 'already_fresh';
67
+ job_id: string | null;
68
+ }
69
+ export interface SyncJobStatus {
70
+ status: string;
71
+ progress: number;
72
+ phase: string | null;
73
+ error: string | null;
74
+ }
53
75
  export declare class HubAPI {
54
76
  private hubUrl;
55
77
  private token;
@@ -64,4 +86,10 @@ export declare class HubAPI {
64
86
  getConnectContext(repoFullName: string): Promise<ConnectContext>;
65
87
  indexRepo(fullName: string): Promise<IndexResult>;
66
88
  getRepo(repoId: string): Promise<RepoDetail>;
89
+ meta(repoId: string): Promise<RepoMeta>;
90
+ sync(repoId: string, params: {
91
+ metadata: SyncMetadata;
92
+ tarball: NodeJS.ReadableStream;
93
+ }): Promise<SyncResult>;
94
+ syncStatus(repoId: string, jobId: string): Promise<SyncJobStatus>;
67
95
  }
package/dist/api.js CHANGED
@@ -64,4 +64,43 @@ export class HubAPI {
64
64
  async getRepo(repoId) {
65
65
  return this.request(`/api/repos/${repoId}`);
66
66
  }
67
+ async meta(repoId) {
68
+ return this.request(`/api/repos/${repoId}/meta`);
69
+ }
70
+ async sync(repoId, params) {
71
+ // Gzip the tarball stream into a Buffer. For now, buffer in memory —
72
+ // typical repo size is <200MB. Streaming multipart via fetch is nontrivial
73
+ // in Node and not worth the complexity for the first cut.
74
+ const { createGzip } = await import('zlib');
75
+ const gz = params.tarball.pipe(createGzip());
76
+ const chunks = [];
77
+ for await (const chunk of gz) {
78
+ chunks.push(chunk);
79
+ }
80
+ const gzBuf = Buffer.concat(chunks);
81
+ const form = new FormData();
82
+ form.append('metadata', JSON.stringify(params.metadata));
83
+ // Blob + filename triggers fetch to set up the file-part headers correctly
84
+ form.append('tarball', new Blob([gzBuf]), 'tarball.tar.gz');
85
+ const url = `${this.hubUrl}/api/repos/${repoId}/sync`;
86
+ const res = await fetch(url, {
87
+ method: 'POST',
88
+ // IMPORTANT: do NOT set Content-Type — fetch sets it with the multipart boundary
89
+ headers: this.authHeaders,
90
+ body: form,
91
+ });
92
+ if (!res.ok) {
93
+ const body = await res.json().catch(() => ({ error: res.statusText }));
94
+ // Preserve the HTTP status so the CLI can branch on 503/409
95
+ // (at-capacity / conflict) and present friendlier hints rather
96
+ // than a generic "Sync failed" line.
97
+ const err = new Error(body.error || `HTTP ${res.status}`);
98
+ err.statusCode = res.status;
99
+ throw err;
100
+ }
101
+ return res.json();
102
+ }
103
+ async syncStatus(repoId, jobId) {
104
+ return this.request(`/api/repos/${repoId}/sync/${jobId}`);
105
+ }
67
106
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Shared CLI helpers — log output and auth resolution.
3
+ *
4
+ * Extracted from index.ts so the individual command-flow files
5
+ * (connect-command.ts, etc.) can be imported and unit-tested without
6
+ * pulling in commander's top-level program.parse() side effect.
7
+ */
8
+ import { HubAPI } from './api.js';
9
+ import type { EditorId, EditorConfig } from './editors/types.js';
10
+ export declare const EDITORS: Record<EditorId, EditorConfig>;
11
+ export declare const ok: (msg: string) => void;
12
+ export declare const info: (msg: string) => void;
13
+ export declare const warn: (msg: string) => void;
14
+ export declare const fail: (msg: string) => void;
15
+ export declare const DEFAULT_HUB_URL: string;
16
+ /**
17
+ * Resolve token + Hub URL from args/config. Exits on failure.
18
+ */
19
+ export declare function resolveAuth(tokenArg?: string, hubOpt?: string): Promise<{
20
+ api: HubAPI;
21
+ hubUrl: string;
22
+ token: string;
23
+ }>;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Shared CLI helpers — log output and auth resolution.
3
+ *
4
+ * Extracted from index.ts so the individual command-flow files
5
+ * (connect-command.ts, etc.) can be imported and unit-tested without
6
+ * pulling in commander's top-level program.parse() side effect.
7
+ */
8
+ import pc from 'picocolors';
9
+ import { loadConfig, saveConfig } from './config.js';
10
+ import { HubAPI } from './api.js';
11
+ import { claudeCodeEditor } from './editors/claude-code.js';
12
+ import { cursorEditor } from './editors/cursor.js';
13
+ import { windsurfEditor } from './editors/windsurf.js';
14
+ import { opencodeEditor } from './editors/opencode.js';
15
+ export const EDITORS = {
16
+ 'claude-code': claudeCodeEditor,
17
+ cursor: cursorEditor,
18
+ windsurf: windsurfEditor,
19
+ opencode: opencodeEditor,
20
+ };
21
+ export const ok = (msg) => console.log(` ${pc.green('✔')} ${msg}`);
22
+ export const info = (msg) => console.log(` ${pc.cyan('ℹ')} ${msg}`);
23
+ export const warn = (msg) => console.log(` ${pc.yellow('⚠')} ${msg}`);
24
+ export const fail = (msg) => console.error(` ${pc.red('✗')} ${msg}`);
25
+ export const DEFAULT_HUB_URL = process.env.GITNEXUS_HUB_URL || 'https://gitnexus-enterprise-production.up.railway.app';
26
+ /**
27
+ * Resolve token + Hub URL from args/config. Exits on failure.
28
+ */
29
+ export async function resolveAuth(tokenArg, hubOpt) {
30
+ const config = await loadConfig();
31
+ const token = tokenArg || config.hubToken;
32
+ const hubUrl = hubOpt || config.hubUrl || DEFAULT_HUB_URL;
33
+ if (!token) {
34
+ fail('No API token provided.');
35
+ console.error('');
36
+ console.error(` Usage: ${pc.cyan('npx gitnexushub gnx_YOUR_TOKEN --editor cursor')}`);
37
+ console.error(` Generate a token at: ${pc.cyan(hubUrl + '/settings/tokens')}`);
38
+ console.error('');
39
+ process.exit(1);
40
+ }
41
+ if (!token.startsWith('gnx_')) {
42
+ fail(`Invalid token format. Tokens must start with ${pc.bold('gnx_')}`);
43
+ console.error('');
44
+ process.exit(1);
45
+ }
46
+ const api = new HubAPI(hubUrl, token);
47
+ const user = await api.getMe().catch((err) => {
48
+ fail(`Authentication failed: ${err.message}`);
49
+ console.error(` Check your token and try again.`);
50
+ console.error('');
51
+ process.exit(1);
52
+ });
53
+ ok(`Hello, ${pc.bold(user.name)}!`);
54
+ console.log('');
55
+ await saveConfig({ hubToken: token, hubUrl });
56
+ return { api, hubUrl, token };
57
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * `gnx connect` flow — register Hub MCP, install skills, install hooks,
3
+ * and write project context files.
4
+ *
5
+ * Extracted from index.ts so it can be imported (and unit-tested) without
6
+ * triggering commander's top-level program.parse() side effect.
7
+ */
8
+ /**
9
+ * Resolve the path to the shipped hook script (`gitnexus-enterprise-hook.cjs`).
10
+ *
11
+ * Lives at `hooks/` in the package root, sibling to `dist/` and `src/`:
12
+ * npm install → <npm-prefix>/lib/node_modules/gitnexushub/hooks/gitnexus-enterprise-hook.cjs
13
+ * dev (tsx) → <repo>/gitnexus-connect/hooks/gitnexus-enterprise-hook.cjs
14
+ *
15
+ * Both resolve via `..` from the file's own location. For the dev case,
16
+ * `import.meta.url` points at `src/connect-command.ts`, so `..` from `src/` →
17
+ * package root. For the dist case, `import.meta.url` points at
18
+ * `dist/connect-command.js`, so `..` from `dist/` → package root.
19
+ */
20
+ export declare function resolveHookScriptPath(): string;
21
+ /**
22
+ * Connect flow entry point — called from `gnx connect` CLI action and
23
+ * (for testability) directly from unit tests.
24
+ */
25
+ export declare function runConnect(tokenArg: string | undefined, opts: {
26
+ editor?: string;
27
+ hub?: string;
28
+ skipProject?: boolean;
29
+ }): Promise<undefined>;
@@ -0,0 +1,169 @@
1
+ /**
2
+ * `gnx connect` flow — register Hub MCP, install skills, install hooks,
3
+ * and write project context files.
4
+ *
5
+ * Extracted from index.ts so it can be imported (and unit-tested) without
6
+ * triggering commander's top-level program.parse() side effect.
7
+ */
8
+ import path from 'path';
9
+ import { fileURLToPath } from 'url';
10
+ import pc from 'picocolors';
11
+ import { loadConfig } from './config.js';
12
+ import { generateConnectContext } from './content.js';
13
+ import { isGitRepo, getGitRemoteUrl, parseGitRemote, matchRepo } from './project.js';
14
+ import { writeProjectContext } from './context.js';
15
+ import { detectInstalledEditors } from './editors/detect.js';
16
+ import { installClaudeCodeHook, installCursorHook, installOpenCodeHook, } from './hooks-installer.js';
17
+ import { ok, info, warn, fail, resolveAuth, EDITORS } from './cli-helpers.js';
18
+ /**
19
+ * Resolve the path to the shipped hook script (`gitnexus-enterprise-hook.cjs`).
20
+ *
21
+ * Lives at `hooks/` in the package root, sibling to `dist/` and `src/`:
22
+ * npm install → <npm-prefix>/lib/node_modules/gitnexushub/hooks/gitnexus-enterprise-hook.cjs
23
+ * dev (tsx) → <repo>/gitnexus-connect/hooks/gitnexus-enterprise-hook.cjs
24
+ *
25
+ * Both resolve via `..` from the file's own location. For the dev case,
26
+ * `import.meta.url` points at `src/connect-command.ts`, so `..` from `src/` →
27
+ * package root. For the dist case, `import.meta.url` points at
28
+ * `dist/connect-command.js`, so `..` from `dist/` → package root.
29
+ */
30
+ export function resolveHookScriptPath() {
31
+ const here = path.dirname(fileURLToPath(import.meta.url));
32
+ return path.resolve(here, '..', 'hooks', 'gitnexus-enterprise-hook.cjs');
33
+ }
34
+ /**
35
+ * Connect flow entry point — called from `gnx connect` CLI action and
36
+ * (for testability) directly from unit tests.
37
+ */
38
+ export async function runConnect(tokenArg, opts) {
39
+ const { api, hubUrl, token } = await resolveAuth(tokenArg, opts.hub);
40
+ // ── Resolve editor ─────────────────────────────────────────────
41
+ const config = await loadConfig();
42
+ let editorId;
43
+ if (opts.editor) {
44
+ if (!(opts.editor in EDITORS)) {
45
+ fail(`Unknown editor: ${pc.bold(opts.editor)}`);
46
+ console.error(` Supported: ${pc.cyan(Object.keys(EDITORS).join(', '))}`);
47
+ console.error('');
48
+ return process.exit(1);
49
+ }
50
+ editorId = opts.editor;
51
+ }
52
+ else if (!config.hubToken) {
53
+ const detected = await detectInstalledEditors();
54
+ if (detected.length === 1) {
55
+ editorId = detected[0];
56
+ info(`Auto-detected editor: ${pc.bold(EDITORS[editorId].name)}`);
57
+ }
58
+ else if (detected.length > 1) {
59
+ warn('Multiple editors detected. Please specify one:');
60
+ for (const id of detected) {
61
+ console.error(` ${pc.cyan('--editor ' + id)}`);
62
+ }
63
+ console.error('');
64
+ return process.exit(1);
65
+ }
66
+ else {
67
+ fail('No editor detected. Please specify one:');
68
+ console.error(` ${pc.cyan('--editor claude-code | cursor | windsurf | opencode')}`);
69
+ console.error('');
70
+ return process.exit(1);
71
+ }
72
+ }
73
+ // ── Configure editor MCP ────────────────────────────────────────
74
+ let skills = [];
75
+ if (editorId) {
76
+ const editor = EDITORS[editorId];
77
+ info(`Configuring ${pc.bold(editor.name)}...`);
78
+ const result = await editor.configure(hubUrl, token);
79
+ if (result.success) {
80
+ ok(result.message);
81
+ if (result.overrodeCli) {
82
+ console.log('');
83
+ console.log(` ${pc.cyan('⬆')} ${pc.bold('GitNexus open-source detected — upgraded to Hub')}`);
84
+ console.log(` ${pc.dim('Remote indexing. Deeper analysis. PR blast radius. Auto-reindex on push.')}`);
85
+ console.log(` ${pc.dim('Your tools (query, context, impact) are unchanged — just faster and smarter.')}`);
86
+ }
87
+ }
88
+ else {
89
+ fail(result.message);
90
+ }
91
+ try {
92
+ const bundled = await generateConnectContext('_', {});
93
+ skills = bundled.skills;
94
+ }
95
+ catch {
96
+ // Skills load failed — continue without
97
+ }
98
+ if (editor.installSkills && skills.length > 0) {
99
+ const count = await editor.installSkills(skills);
100
+ if (count > 0) {
101
+ ok(`${count} skills installed`);
102
+ }
103
+ }
104
+ // ── Install editor hooks (PreToolUse augmentation + PostToolUse staleness) ──
105
+ try {
106
+ const hookScriptPath = resolveHookScriptPath();
107
+ if (editorId === 'claude-code') {
108
+ await installClaudeCodeHook({ hookScriptPath });
109
+ ok('Hooks installed');
110
+ }
111
+ else if (editorId === 'cursor') {
112
+ await installCursorHook({ hookScriptPath });
113
+ ok('Hooks installed');
114
+ }
115
+ else if (editorId === 'opencode') {
116
+ await installOpenCodeHook({ hookScriptPath });
117
+ ok('Hooks installed');
118
+ }
119
+ // windsurf: no hook system, skip silently
120
+ }
121
+ catch (err) {
122
+ warn(`Hook install failed: ${err.message}`);
123
+ }
124
+ }
125
+ // ── Write project context ───────────────────────────────────────
126
+ if (!opts.skipProject && isGitRepo()) {
127
+ const remoteUrl = getGitRemoteUrl();
128
+ const remoteFullName = remoteUrl ? parseGitRemote(remoteUrl) : null;
129
+ if (remoteFullName) {
130
+ console.log('');
131
+ info(`Project: ${pc.bold(remoteFullName)}`);
132
+ try {
133
+ const repos = await api.listRepos();
134
+ const matched = matchRepo(remoteFullName, repos);
135
+ if (matched) {
136
+ if (matched.status === 'ready') {
137
+ const ctx = await generateConnectContext(matched.fullName, matched.stats || {});
138
+ const result = await writeProjectContext(process.cwd(), ctx);
139
+ for (const file of result.files) {
140
+ ok(file);
141
+ }
142
+ }
143
+ else {
144
+ warn(`Repo status: ${pc.yellow(matched.status)} ${pc.dim('(waiting for indexing)')}`);
145
+ }
146
+ }
147
+ else {
148
+ warn('Repo not indexed on Hub yet');
149
+ console.log(` Add it at: ${pc.cyan(hubUrl)}`);
150
+ }
151
+ }
152
+ catch (err) {
153
+ fail(`Failed to fetch project context: ${err.message}`);
154
+ }
155
+ }
156
+ else {
157
+ console.log('');
158
+ warn('No GitHub remote found — skipping project context');
159
+ }
160
+ }
161
+ else if (!opts.skipProject) {
162
+ console.log('');
163
+ info('Not inside a git repo — skipping project context');
164
+ }
165
+ // ── Summary ────────────────────────────────────────────────────
166
+ console.log('');
167
+ console.log(` ${pc.green('✔')} ${pc.bold('Done!')} Open your editor — GitNexus MCP is ready.`);
168
+ console.log('');
169
+ }
@@ -19,6 +19,14 @@ async function configure(hubUrl, token) {
19
19
  existing.mcpServers = {};
20
20
  }
21
21
  const overrodeCli = !!(existing.mcpServers.gitnexus && 'command' in existing.mcpServers.gitnexus);
22
+ // Claude Code's `~/.claude.json` accepts `type: "http"` for
23
+ // remote HTTP MCP servers. This is the shape that
24
+ // `claude mcp add --transport http <url>` writes and the format
25
+ // documented in the official docs (e.g. Stripe + Notion remote
26
+ // examples). The MCP registry spec uses `"streamable-http"`
27
+ // internally, but the CLI config format uses the short `"http"`
28
+ // alias — commit c602f2e crossed the wires and used the registry
29
+ // value, which silently breaks on current Claude Code versions.
22
30
  existing.mcpServers.gitnexus = {
23
31
  type: 'http',
24
32
  url: mcpUrl,
@@ -11,8 +11,12 @@ import { readJsonFile, writeJsonFile } from '../utils.js';
11
11
  import { HUB_SKILLS } from '../content.js';
12
12
  import { computeFingerprint, getDeviceName } from '../fingerprint.js';
13
13
  function getMcpConfig(hubUrl, token) {
14
+ // Cursor's `~/.cursor/mcp.json` identifies remote HTTP servers by
15
+ // the presence of `url` — the docs at cursor.com/docs/context/mcp
16
+ // do NOT document a `type` field for remote entries, so we omit
17
+ // it rather than send a registry-shape value that Cursor may
18
+ // start validating later.
14
19
  return {
15
- type: 'streamable-http',
16
20
  url: `${hubUrl}/mcp`,
17
21
  headers: {
18
22
  Authorization: `Bearer ${token}`,
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Per-editor hook installers.
3
+ *
4
+ * Writes idempotent editor-specific hook config files that point at
5
+ * the shipped gitnexus-enterprise-hook.cjs script. Called from the
6
+ * `gnx connect` flow after the MCP config is written.
7
+ */
8
+ export interface InstallOptions {
9
+ /** Defaults to os.homedir(). Injectable for tests. */
10
+ homeDir?: string;
11
+ /** Absolute path to the shipped hook script. */
12
+ hookScriptPath: string;
13
+ }
14
+ /**
15
+ * Install the Claude Code PreToolUse + PostToolUse hooks.
16
+ * Returns the absolute path to the written hooks.json.
17
+ */
18
+ export declare function installClaudeCodeHook(opts: InstallOptions): Promise<string>;
19
+ /**
20
+ * Install the Cursor beforeShellExecution hook.
21
+ * Returns the absolute path to the written hooks.json.
22
+ */
23
+ export declare function installCursorHook(opts: InstallOptions): Promise<string>;
24
+ /**
25
+ * Install the OpenCode plugin stub.
26
+ * Returns the absolute path to the written TypeScript file.
27
+ *
28
+ * The plugin is currently a stub — the actual tool.before / tool.after
29
+ * wiring delegating to gitnexus-enterprise-hook.cjs will be filled in
30
+ * when we have concrete users on OpenCode. For M4.2 we write the file
31
+ * so gnx connect reports success and the user has a template to extend.
32
+ */
33
+ export declare function installOpenCodeHook(opts: InstallOptions): Promise<string>;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Per-editor hook installers.
3
+ *
4
+ * Writes idempotent editor-specific hook config files that point at
5
+ * the shipped gitnexus-enterprise-hook.cjs script. Called from the
6
+ * `gnx connect` flow after the MCP config is written.
7
+ */
8
+ import fs from 'fs/promises';
9
+ import path from 'path';
10
+ import os from 'os';
11
+ function getHome(opts) {
12
+ return opts.homeDir ?? os.homedir();
13
+ }
14
+ async function writeJsonIdempotent(filePath, obj) {
15
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
16
+ const body = JSON.stringify(obj, null, 2) + '\n';
17
+ await fs.writeFile(filePath, body);
18
+ }
19
+ /**
20
+ * Install the Claude Code PreToolUse + PostToolUse hooks.
21
+ * Returns the absolute path to the written hooks.json.
22
+ */
23
+ export async function installClaudeCodeHook(opts) {
24
+ const home = getHome(opts);
25
+ const hooksDir = path.join(home, '.claude', 'plugins', 'gitnexus-enterprise', 'hooks');
26
+ 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}"`;
32
+ const config = {
33
+ hooks: {
34
+ PreToolUse: [
35
+ {
36
+ matcher: 'Grep|Glob|Bash',
37
+ hooks: [
38
+ {
39
+ type: 'command',
40
+ command: `node ${quotedScriptPath}`,
41
+ timeout: 5,
42
+ },
43
+ ],
44
+ },
45
+ ],
46
+ PostToolUse: [
47
+ {
48
+ matcher: 'Bash',
49
+ hooks: [
50
+ {
51
+ type: 'command',
52
+ command: `node ${quotedScriptPath}`,
53
+ timeout: 5,
54
+ },
55
+ ],
56
+ },
57
+ ],
58
+ },
59
+ };
60
+ await writeJsonIdempotent(hooksJsonPath, config);
61
+ return hooksJsonPath;
62
+ }
63
+ /**
64
+ * Install the Cursor beforeShellExecution hook.
65
+ * Returns the absolute path to the written hooks.json.
66
+ */
67
+ export async function installCursorHook(opts) {
68
+ const home = getHome(opts);
69
+ 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);
80
+ return hooksJsonPath;
81
+ }
82
+ /**
83
+ * Install the OpenCode plugin stub.
84
+ * Returns the absolute path to the written TypeScript file.
85
+ *
86
+ * The plugin is currently a stub — the actual tool.before / tool.after
87
+ * wiring delegating to gitnexus-enterprise-hook.cjs will be filled in
88
+ * when we have concrete users on OpenCode. For M4.2 we write the file
89
+ * so gnx connect reports success and the user has a template to extend.
90
+ */
91
+ export async function installOpenCodeHook(opts) {
92
+ const home = getHome(opts);
93
+ const pluginDir = path.join(home, '.opencode', 'plugin');
94
+ const pluginPath = path.join(pluginDir, 'gitnexus-enterprise.ts');
95
+ const content = `// GitNexus Enterprise OpenCode plugin
96
+ // Stub — delegates to gitnexus-enterprise-hook.cjs via child process.
97
+ // Hook script: ${opts.hookScriptPath}
98
+
99
+ export default {
100
+ async toolBefore(ctx: { tool: string }) {
101
+ if (!['Grep', 'Glob', 'Bash'].includes(ctx.tool)) return;
102
+ // TODO: spawn gitnexus-enterprise-hook.cjs with the tool context on stdin
103
+ // and forward additionalContext from stdout.
104
+ },
105
+ async toolAfter(ctx: { tool: string }) {
106
+ if (ctx.tool !== 'Bash') return;
107
+ // TODO: spawn gitnexus-enterprise-hook.cjs for PostToolUse staleness check
108
+ },
109
+ };
110
+ `;
111
+ await fs.mkdir(pluginDir, { recursive: true });
112
+ await fs.writeFile(pluginPath, content);
113
+ return pluginPath;
114
+ }