gitnexushub 0.3.0 → 0.4.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.
@@ -0,0 +1,415 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * GitNexus Enterprise Hook
4
+ *
5
+ * PreToolUse - POST /api/repos/:id/augment, prepend text to tool result
6
+ * PostToolUse - after git mutations, compare local HEAD vs hub last_commit,
7
+ * emit staleness hint to nudge the agent toward `gnx sync`
8
+ *
9
+ * Resolves cwd -> hub_repo_id via ~/.gitnexus/connect-registry.json.
10
+ * Reads auth from ~/.gitnexus/config.json (written by `gnx connect`).
11
+ *
12
+ * Hard 1500ms timeout on all HTTP calls. Silent failure on error so the
13
+ * original tool runs unchanged. GITNEXUS_NO_AUGMENT=1 disables entirely.
14
+ *
15
+ * CJS, runs as `node gitnexus-enterprise-hook.cjs` without a build step.
16
+ * Dependencies: Node stdlib only (fs, path, os, http, https, child_process,
17
+ * url).
18
+ */
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const os = require('os');
23
+ const http = require('http');
24
+ const https = require('https');
25
+ const { spawnSync } = require('child_process');
26
+ const { URL } = require('url');
27
+
28
+ const HOOK_TIMEOUT_MS = 1500;
29
+ const CONFIG_PATH = path.join(os.homedir(), '.gitnexus', 'config.json');
30
+ const REGISTRY_PATH = path.join(os.homedir(), '.gitnexus', 'connect-registry.json');
31
+ const META_CACHE_DIR = path.join(os.homedir(), '.gitnexus', 'meta-cache');
32
+ const META_CACHE_TTL_MS = 30_000;
33
+
34
+ /**
35
+ * Read the hook event JSON from stdin (file descriptor 0). Claude Code /
36
+ * Cursor / OpenCode all send the event payload as a single blob on stdin.
37
+ * Any parse failure returns an empty object so downstream code can fall
38
+ * through to the silent-failure path.
39
+ */
40
+ function readInput() {
41
+ try {
42
+ return JSON.parse(fs.readFileSync(0, 'utf-8'));
43
+ } catch {
44
+ return {};
45
+ }
46
+ }
47
+
48
+ function readConfig() {
49
+ try {
50
+ return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ function readRegistry() {
57
+ try {
58
+ const parsed = JSON.parse(fs.readFileSync(REGISTRY_PATH, 'utf-8'));
59
+ return Array.isArray(parsed.entries) ? parsed.entries : [];
60
+ } catch {
61
+ return [];
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Resolve the editor's current working directory to the best-matching
67
+ * registry entry.
68
+ *
69
+ * Mirrors the semantics of gitnexus-connect/src/registry.ts
70
+ * resolveCwdToRepo (longest-path match + symlink normalization +
71
+ * path-separator boundary) but reimplemented here with sync fs APIs so
72
+ * the hook has no runtime dependency on the compiled connect bundle.
73
+ * Windows paths are lowercased before comparison so D:\foo and d:\foo
74
+ * match.
75
+ */
76
+ function resolveCwdToRepo(cwd, entries) {
77
+ if (!entries.length) return null;
78
+
79
+ let resolved;
80
+ try {
81
+ resolved = fs.realpathSync(path.resolve(cwd));
82
+ } catch {
83
+ resolved = path.resolve(cwd);
84
+ }
85
+
86
+ const isWin = process.platform === 'win32';
87
+ const norm = isWin ? resolved.toLowerCase() : resolved;
88
+ const sep = path.sep;
89
+
90
+ let best = null;
91
+ let bestLen = 0;
92
+
93
+ for (const entry of entries) {
94
+ if (!entry || !entry.localPath || !entry.hubRepoId) continue;
95
+ let ep;
96
+ try {
97
+ ep = fs.realpathSync(path.resolve(entry.localPath));
98
+ } catch {
99
+ ep = path.resolve(entry.localPath);
100
+ }
101
+ const nep = isWin ? ep.toLowerCase() : ep;
102
+ const matched = norm === nep || norm.startsWith(nep + sep);
103
+ if (matched && nep.length > bestLen) {
104
+ best = entry;
105
+ bestLen = nep.length;
106
+ }
107
+ }
108
+
109
+ return best;
110
+ }
111
+
112
+ /**
113
+ * Extract a usable search pattern from the tool input, or null if the
114
+ * tool is not one we augment. Mirrors the OSS engine's best-effort
115
+ * extraction:
116
+ * - Grep: direct pattern field
117
+ * - Glob: first word-like token after a slash or star
118
+ * - Bash: rg/grep command — skip flags and flag values, first bare
119
+ * token wins
120
+ */
121
+ function extractPattern(toolName, toolInput) {
122
+ if (toolName === 'Grep') {
123
+ return (toolInput && toolInput.pattern) || null;
124
+ }
125
+
126
+ if (toolName === 'Glob') {
127
+ const raw = (toolInput && toolInput.pattern) || '';
128
+ const m = raw.match(/[*\/]([a-zA-Z][a-zA-Z0-9_-]{2,})/);
129
+ return m ? m[1] : null;
130
+ }
131
+
132
+ if (toolName === 'Bash') {
133
+ const cmd = (toolInput && toolInput.command) || '';
134
+ if (!/\brg\b|\bgrep\b/.test(cmd)) return null;
135
+
136
+ const tokens = cmd.split(/\s+/);
137
+ let foundCmd = false;
138
+ let skipNext = false;
139
+ const flagsWithValues = new Set([
140
+ '-e',
141
+ '-f',
142
+ '-m',
143
+ '-A',
144
+ '-B',
145
+ '-C',
146
+ '-g',
147
+ '--glob',
148
+ '-t',
149
+ '--type',
150
+ '--include',
151
+ '--exclude',
152
+ ]);
153
+
154
+ for (const token of tokens) {
155
+ if (skipNext) {
156
+ skipNext = false;
157
+ continue;
158
+ }
159
+ if (!foundCmd) {
160
+ if (/\brg$|\bgrep$/.test(token)) foundCmd = true;
161
+ continue;
162
+ }
163
+ if (token.startsWith('-')) {
164
+ if (flagsWithValues.has(token)) skipNext = true;
165
+ continue;
166
+ }
167
+ const cleaned = token.replace(/['"]/g, '');
168
+ return cleaned.length >= 3 ? cleaned : null;
169
+ }
170
+ }
171
+
172
+ return null;
173
+ }
174
+
175
+ /**
176
+ * POST JSON with a hard 1500ms timeout. Resolves to
177
+ * {status: number, body: any} on success
178
+ * null on any error / timeout / non-JSON
179
+ *
180
+ * Never throws so callers can use the short-circuit pattern
181
+ * const res = await httpPostJson(...); if (!res) return;
182
+ * and rely on silent-failure semantics.
183
+ */
184
+ function httpPostJson(urlStr, headers, body) {
185
+ return new Promise((resolve) => {
186
+ try {
187
+ const u = new URL(urlStr);
188
+ const mod = u.protocol === 'https:' ? https : http;
189
+ const payload = JSON.stringify(body);
190
+ const req = mod.request(
191
+ {
192
+ method: 'POST',
193
+ hostname: u.hostname,
194
+ port: u.port || (u.protocol === 'https:' ? 443 : 80),
195
+ path: u.pathname + u.search,
196
+ headers: Object.assign(
197
+ {
198
+ 'Content-Type': 'application/json',
199
+ 'Content-Length': Buffer.byteLength(payload),
200
+ },
201
+ headers || {},
202
+ ),
203
+ timeout: HOOK_TIMEOUT_MS,
204
+ },
205
+ (res) => {
206
+ let data = '';
207
+ res.on('data', (c) => (data += c));
208
+ res.on('end', () => {
209
+ try {
210
+ resolve({ status: res.statusCode, body: JSON.parse(data) });
211
+ } catch {
212
+ resolve({ status: res.statusCode, body: null });
213
+ }
214
+ });
215
+ },
216
+ );
217
+ req.on('error', () => resolve(null));
218
+ req.on('timeout', () => {
219
+ try {
220
+ req.destroy();
221
+ } catch {
222
+ /* ignore */
223
+ }
224
+ resolve(null);
225
+ });
226
+ req.write(payload);
227
+ req.end();
228
+ } catch {
229
+ resolve(null);
230
+ }
231
+ });
232
+ }
233
+
234
+ /**
235
+ * GET JSON with a hard 1500ms timeout. Same contract as httpPostJson.
236
+ */
237
+ function httpGetJson(urlStr, headers) {
238
+ return new Promise((resolve) => {
239
+ try {
240
+ const u = new URL(urlStr);
241
+ const mod = u.protocol === 'https:' ? https : http;
242
+ const req = mod.request(
243
+ {
244
+ method: 'GET',
245
+ hostname: u.hostname,
246
+ port: u.port || (u.protocol === 'https:' ? 443 : 80),
247
+ path: u.pathname + u.search,
248
+ headers: headers || {},
249
+ timeout: HOOK_TIMEOUT_MS,
250
+ },
251
+ (res) => {
252
+ let data = '';
253
+ res.on('data', (c) => (data += c));
254
+ res.on('end', () => {
255
+ try {
256
+ resolve({ status: res.statusCode, body: JSON.parse(data) });
257
+ } catch {
258
+ resolve(null);
259
+ }
260
+ });
261
+ },
262
+ );
263
+ req.on('error', () => resolve(null));
264
+ req.on('timeout', () => {
265
+ try {
266
+ req.destroy();
267
+ } catch {
268
+ /* ignore */
269
+ }
270
+ resolve(null);
271
+ });
272
+ req.end();
273
+ } catch {
274
+ resolve(null);
275
+ }
276
+ });
277
+ }
278
+
279
+ /**
280
+ * Emit the Claude Code / Cursor / OpenCode hookSpecificOutput envelope.
281
+ * The editor will prepend `message` to the tool's output before handing
282
+ * control back to the model.
283
+ */
284
+ function sendResponse(event, message) {
285
+ process.stdout.write(
286
+ JSON.stringify({
287
+ hookSpecificOutput: { hookEventName: event, additionalContext: message },
288
+ }) + '\n',
289
+ );
290
+ }
291
+
292
+ async function handlePreToolUse(input, config, entry) {
293
+ const toolName = input.tool_name || '';
294
+ if (toolName !== 'Grep' && toolName !== 'Glob' && toolName !== 'Bash') return;
295
+
296
+ const pattern = extractPattern(toolName, input.tool_input || {});
297
+ if (!pattern || pattern.length < 3) return;
298
+
299
+ const res = await httpPostJson(
300
+ `${config.hubUrl}/api/repos/${entry.hubRepoId}/augment`,
301
+ { Authorization: `Bearer ${config.hubToken}` },
302
+ { pattern },
303
+ );
304
+ if (!res || res.status !== 200 || !res.body || !res.body.text) return;
305
+
306
+ const text = String(res.body.text).trim();
307
+ if (!text) return;
308
+ sendResponse('PreToolUse', text);
309
+ }
310
+
311
+ /**
312
+ * Read the meta cache for a given repo, returning null if absent or
313
+ * older than META_CACHE_TTL_MS. The cache keeps the hook from hammering
314
+ * /meta on every git commit in a batch — editors can fire multiple
315
+ * PostToolUse events in quick succession.
316
+ */
317
+ function readMetaCache(repoId) {
318
+ try {
319
+ const p = path.join(META_CACHE_DIR, `${repoId}.json`);
320
+ const stat = fs.statSync(p);
321
+ if (Date.now() - stat.mtimeMs > META_CACHE_TTL_MS) return null;
322
+ return JSON.parse(fs.readFileSync(p, 'utf-8'));
323
+ } catch {
324
+ return null;
325
+ }
326
+ }
327
+
328
+ function writeMetaCache(repoId, meta) {
329
+ try {
330
+ fs.mkdirSync(META_CACHE_DIR, { recursive: true });
331
+ fs.writeFileSync(path.join(META_CACHE_DIR, `${repoId}.json`), JSON.stringify(meta));
332
+ } catch {
333
+ /* ignore — cache is best-effort */
334
+ }
335
+ }
336
+
337
+ async function handlePostToolUse(input, config, entry) {
338
+ if (input.tool_name !== 'Bash') return;
339
+ const cmd = (input.tool_input && input.tool_input.command) || '';
340
+ if (!/\bgit\s+(commit|merge|rebase|cherry-pick|pull)(\s|$)/.test(cmd)) return;
341
+
342
+ // Only nudge when the git command actually succeeded. Some editors
343
+ // omit exit_code; treat missing as success so we don't silently
344
+ // skip legitimate mutations.
345
+ const exitCode = input.tool_output && input.tool_output.exit_code;
346
+ if (exitCode !== undefined && exitCode !== 0) return;
347
+
348
+ let localHead = '';
349
+ try {
350
+ const r = spawnSync('git', ['rev-parse', 'HEAD'], {
351
+ cwd: input.cwd || process.cwd(),
352
+ encoding: 'utf-8',
353
+ timeout: 2000,
354
+ });
355
+ localHead = (r.stdout || '').trim();
356
+ } catch {
357
+ return;
358
+ }
359
+ if (!localHead) return;
360
+
361
+ let meta = readMetaCache(entry.hubRepoId);
362
+ if (!meta) {
363
+ const res = await httpGetJson(`${config.hubUrl}/api/repos/${entry.hubRepoId}/meta`, {
364
+ Authorization: `Bearer ${config.hubToken}`,
365
+ });
366
+ if (!res || res.status !== 200 || !res.body) return;
367
+ meta = res.body;
368
+ writeMetaCache(entry.hubRepoId, meta);
369
+ }
370
+
371
+ if (meta.last_commit === localHead) return;
372
+ const shortOld = meta.last_commit ? String(meta.last_commit).slice(0, 7) : 'none';
373
+ sendResponse(
374
+ 'PostToolUse',
375
+ `GitNexus index is stale (last indexed: ${shortOld}). Run \`gnx sync\` to update.`,
376
+ );
377
+ }
378
+
379
+ async function main() {
380
+ if (process.env.GITNEXUS_NO_AUGMENT === '1') return;
381
+
382
+ const input = readInput();
383
+ const event = input.hook_event_name;
384
+ if (event !== 'PreToolUse' && event !== 'PostToolUse') return;
385
+
386
+ const config = readConfig();
387
+ if (!config || !config.hubToken || !config.hubUrl) return;
388
+
389
+ const entries = readRegistry();
390
+ const cwd = input.cwd || process.cwd();
391
+ const entry = resolveCwdToRepo(cwd, entries);
392
+ if (!entry) return;
393
+
394
+ try {
395
+ if (event === 'PreToolUse') {
396
+ await handlePreToolUse(input, config, entry);
397
+ } else {
398
+ await handlePostToolUse(input, config, entry);
399
+ }
400
+ } catch (err) {
401
+ if (process.env.GITNEXUS_DEBUG) {
402
+ process.stderr.write(
403
+ `gitnexus hook error: ${(err && err.message ? err.message : String(err)).slice(0, 300)}\n`,
404
+ );
405
+ }
406
+ }
407
+ }
408
+
409
+ main().catch((err) => {
410
+ if (process.env.GITNEXUS_DEBUG) {
411
+ process.stderr.write(
412
+ `gitnexus hook fatal: ${(err && err.message ? err.message : String(err)).slice(0, 300)}\n`,
413
+ );
414
+ }
415
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexushub",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",
@@ -36,10 +36,12 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "commander": "^12.0.0",
39
- "picocolors": "^1.1.1"
39
+ "picocolors": "^1.1.1",
40
+ "tar-stream": "^3.1.8"
40
41
  },
41
42
  "devDependencies": {
42
43
  "@types/node": "^20.19.37",
44
+ "@types/tar-stream": "^3.1.4",
43
45
  "tsx": "^4.0.0",
44
46
  "typescript": "^5.4.5",
45
47
  "vitest": "^4.1.4"
@@ -49,6 +51,7 @@
49
51
  },
50
52
  "files": [
51
53
  "dist",
54
+ "hooks",
52
55
  "skills"
53
56
  ],
54
57
  "publishConfig": {}
@@ -15,7 +15,7 @@ For any task involving code understanding, debugging, impact analysis, or refact
15
15
  2. **Match your task to a skill below** and **read that skill file**
16
16
  3. **Follow the skill's workflow and checklist**
17
17
 
18
- > If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first.
18
+ > If step 1 warns the index is stale, run `gnx sync` in the terminal first.
19
19
 
20
20
  ## Skills
21
21
 
@@ -22,7 +22,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru
22
22
  4. Plan update order: interfaces → implementations → callers → tests
23
23
  ```
24
24
 
25
- > If "Index is stale" → run `npx gitnexus analyze` in terminal.
25
+ > If "Index is stale" → run `gnx sync` in terminal.
26
26
 
27
27
  ## Checklists
28
28