hydra-nano 0.1.0 → 0.3.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.
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync } from 'node:fs';
2
+ import { existsSync, readFileSync, statSync } from 'node:fs';
3
3
  import { spawnSync } from 'node:child_process';
4
4
  import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
@@ -10,11 +10,13 @@ import * as git from './lib/git.mjs';
10
10
  import * as test_logs from './lib/test_logs.mjs';
11
11
  import * as read from './lib/read.mjs';
12
12
  import * as osHead from './lib/os.mjs';
13
- import { record, stats, tokens } from './lib/ledger.mjs';
13
+ import { findings, metered, record, stats, tokens } from './lib/ledger.mjs';
14
14
  import { chat, hasModel, BACKEND, modelFor, Unavailable } from './lib/runtime.mjs';
15
15
  import { route } from './lib/route.mjs';
16
16
  import { stopLocal } from './lib/local.mjs';
17
17
  import { hookStatus, installHooks, removeHooks } from './lib/claude_hooks.mjs';
18
+ import * as integrations from './lib/integrations.mjs';
19
+ import * as refactor from './lib/refactor.mjs';
18
20
 
19
21
  const HEADS = { git, files, test: test_logs, read, os: osHead };
20
22
 
@@ -22,19 +24,31 @@ const USAGE = `usage:
22
24
  hydra ask "<question>" [--head git|files|test|read|os] answer a question about the repo or system
23
25
  hydra search <term...> search codebase with definition ranking
24
26
  hydra read <file> [symbol|--lines <start-end>] slice a file, symbol, or show outline
27
+ hydra smell [file|dir|--diff|--staged] detect Refactoring.Guru code smells & anti-patterns
28
+ hydra refactor <file> [symbol|--lines <start-end>] step-by-step refactoring recipe for a function or range
29
+ hydra design [pattern|query] [--file <file>] GoF design pattern advisor & architecture guide
25
30
  hydra run [--head test] -- <command...> run a command and digest test/build output
26
31
  hydra os [question...] inspect ports, processes, disk, system
27
32
  hydra stats token accounting for past calls
33
+ hydra improve [--since <date>] [--json] what the local telemetry says to improve
28
34
  hydra doctor check that the local model is ready
29
35
  hydra setup show runtime setup instructions
30
36
  hydra stop stop Hydra's managed llama-server
31
- hydra hooks install|remove|status Claude Code hooks that route tests and git questions here`;
37
+ hydra hooks install|remove|status Claude Code hooks that route tests and git questions here
38
+ hydra agents install|remove|status [tool...] Hydra's MCP server and hooks in codex, cursor, opencode,
39
+ gemini-cli, antigravity and antigravity-ide (default: every tool found)
40
+ hydra mcp run the MCP server on stdio`;
32
41
 
33
42
  function fail(code, message) {
34
43
  console.error(message);
35
44
  process.exit(code);
36
45
  }
37
46
 
47
+ // The footer reports a saving; when there is none (short output passed through whole) it is pure cost.
48
+ function withFooter(text, head, raw, returned, seconds) {
49
+ return returned < raw ? `${text}\n\n[hydra ${head} · read ~${raw} tok locally, returned ~${returned} tok · ${seconds}s]` : text;
50
+ }
51
+
38
52
  async function ask(args) {
39
53
  const flag = args.indexOf('--head');
40
54
  const forced = flag >= 0 ? args.splice(flag, 2)[1] : undefined;
@@ -47,15 +61,15 @@ async function ask(args) {
47
61
  if (head === 'git' && !repo) fail(1, 'hydra: the git head needs a git repository, and this directory is not in one');
48
62
 
49
63
  const started = Date.now();
50
- const headChat = opts => chat({ ...opts, model: modelFor(head) });
51
- const sections = await HEADS[head].gather(question, cwd, headChat);
52
- const { text, rawChars } = await answer(question, sections, headChat);
64
+ const meter = metered(opts => chat({ ...opts, model: modelFor(head) }));
65
+ const sections = await HEADS[head].gather(question, cwd, meter.chat);
66
+ const { text, rawChars } = await answer(question, sections, meter.chat);
53
67
  const raw = tokens(rawChars);
54
68
  const returned = tokens(text.length);
55
69
  const seconds = Number(((Date.now() - started) / 1000).toFixed(1));
56
70
 
57
- record({ ts: new Date().toISOString(), head, question, raw, returned, seconds });
58
- console.log(`${text}\n\n[hydra ${head} · read ~${raw} tok locally, returned ~${returned} tok · ${seconds}s]`);
71
+ record({ ts: new Date().toISOString(), head, question, raw, returned, seconds, modelCalls: meter.modelCalls, modelSeconds: meter.modelSeconds });
72
+ console.log(withFooter(text, head, raw, returned, seconds));
59
73
  }
60
74
 
61
75
  async function runCommand(args) {
@@ -67,13 +81,13 @@ async function runCommand(args) {
67
81
  const stdinData = readFileSync(0, 'utf8');
68
82
  if (stdinData.trim()) {
69
83
  const started = Date.now();
70
- const testChat = opts => chat({ ...opts, model: modelFor('test') });
71
- const { text, rawChars, exitCode } = await test_logs.digest({ cmd: 'pipe', out: stdinData, exitCode: 1 }, testChat);
84
+ const meter = metered(opts => chat({ ...opts, model: modelFor('test') }));
85
+ const { text, rawChars, exitCode } = await test_logs.digest({ cmd: 'pipe', out: stdinData, exitCode: 1 }, meter.chat);
72
86
  const raw = tokens(rawChars);
73
87
  const returned = tokens(text.length);
74
88
  const seconds = Number(((Date.now() - started) / 1000).toFixed(1));
75
- record({ ts: new Date().toISOString(), head: 'test', question: 'pipe', raw, returned, seconds });
76
- console.log(`${text}\n\n[hydra test · read ~${raw} tok locally, returned ~${returned} tok · ${seconds}s]`);
89
+ record({ ts: new Date().toISOString(), head: 'test', question: 'pipe', raw, returned, seconds, modelCalls: meter.modelCalls, modelSeconds: meter.modelSeconds });
90
+ console.log(withFooter(text, 'test', raw, returned, seconds));
77
91
  if (exitCode !== 0) process.exit(exitCode);
78
92
  return;
79
93
  }
@@ -85,18 +99,20 @@ async function runCommand(args) {
85
99
  const cwd = process.cwd();
86
100
  const started = Date.now();
87
101
  const res = await run(prog, progArgs, cwd);
88
- const testChat = opts => chat({ ...opts, model: modelFor('test') });
102
+ const commandSeconds = Number(((Date.now() - started) / 1000).toFixed(1));
103
+ const meter = metered(opts => chat({ ...opts, model: modelFor('test') }));
89
104
  const { text, rawChars, exitCode } = await test_logs.digest({
90
105
  cmd: cmdArgs.join(' '),
91
106
  out: res.out,
92
107
  err: res.err,
93
108
  exitCode: res.exitCode,
94
- }, testChat);
109
+ }, meter.chat);
95
110
  const raw = tokens(rawChars);
96
111
  const returned = tokens(text.length);
97
112
  const seconds = Number(((Date.now() - started) / 1000).toFixed(1));
98
- record({ ts: new Date().toISOString(), head: 'test', question: cmdArgs.join(' '), raw, returned, seconds });
99
- console.log(`${text}\n\n[hydra test · read ~${raw} tok locally, returned ~${returned} tok · ${seconds}s]`);
113
+ record({ ts: new Date().toISOString(), head: 'test', question: cmdArgs.join(' '), raw, returned, seconds,
114
+ commandSeconds, exit: exitCode, modelCalls: meter.modelCalls, modelSeconds: meter.modelSeconds });
115
+ console.log(withFooter(text, 'test', raw, returned, seconds));
100
116
  if (exitCode !== 0) process.exit(exitCode);
101
117
  }
102
118
 
@@ -147,21 +163,21 @@ async function readCommand(args) {
147
163
  seconds,
148
164
  });
149
165
 
150
- console.log(`${res.text}\n\n[hydra read · read ~${raw} tok locally, returned ~${returned} tok · ${seconds}s]`);
166
+ console.log(withFooter(res.text, 'read', raw, returned, seconds));
151
167
  }
152
168
 
153
169
  async function osCommand(args) {
154
170
  const question = args.join(' ').trim() || 'system overview';
155
171
  const started = Date.now();
156
- const osChat = opts => chat({ ...opts, model: modelFor('os') });
157
- const sections = await osHead.gather(question, process.cwd(), osChat);
158
- const { text, rawChars } = await answer(question, sections, osChat);
172
+ const meter = metered(opts => chat({ ...opts, model: modelFor('os') }));
173
+ const sections = await osHead.gather(question, process.cwd(), meter.chat);
174
+ const { text, rawChars } = await answer(question, sections, meter.chat);
159
175
  const raw = tokens(rawChars);
160
176
  const returned = tokens(text.length);
161
177
  const seconds = Number(((Date.now() - started) / 1000).toFixed(1));
162
178
 
163
- record({ ts: new Date().toISOString(), head: 'os', question, raw, returned, seconds });
164
- console.log(`${text}\n\n[hydra os · read ~${raw} tok locally, returned ~${returned} tok · ${seconds}s]`);
179
+ record({ ts: new Date().toISOString(), head: 'os', question, raw, returned, seconds, modelCalls: meter.modelCalls, modelSeconds: meter.modelSeconds });
180
+ console.log(withFooter(text, 'os', raw, returned, seconds));
165
181
  }
166
182
 
167
183
  async function searchCommand(args) {
@@ -201,7 +217,7 @@ async function searchCommand(args) {
201
217
  seconds,
202
218
  });
203
219
 
204
- console.log(`${text}\n\n[hydra files · read ~${raw} tok locally, returned ~${returned} tok · ${seconds}s]`);
220
+ console.log(withFooter(text, 'files', raw, returned, seconds));
205
221
  }
206
222
 
207
223
  function showStats() {
@@ -218,6 +234,30 @@ Estimate: output size / 4. It counts the output an agent would have read by runn
218
234
  the commands itself, and leaves out the extra turns those commands would have cost.`);
219
235
  }
220
236
 
237
+ function improve(args) {
238
+ const flag = args.indexOf('--since');
239
+ const since = flag >= 0 ? args[flag + 1] : undefined;
240
+ if (flag >= 0 && (!since || Number.isNaN(Date.parse(since)))) fail(1, 'usage: hydra improve [--since <date>] [--json]');
241
+ const report = findings(undefined, { since: since && new Date(since).toISOString() });
242
+ if (args.includes('--json')) return console.log(JSON.stringify(report, null, 2));
243
+ const lines = [`since ${report.since ?? 'the start'}: ${report.calls} calls; ${report.sessions.usingHydra} of ${report.sessions.seen} recorded sessions used Hydra`];
244
+ for (const [head, h] of Object.entries(report.perHead)) {
245
+ lines.push(`${head.padEnd(6)} ${h.calls} calls, saved ~${h.saved} tok (${Math.round(h.savedShare * 100)}%), ` +
246
+ `${h.costMore} cost more than raw, p50 ${h.p50Seconds}s, p90 ${h.p90Seconds}s, ${h.modelCalls} model calls`);
247
+ for (const row of h.costMoreExamples) lines.push(` cost more: ${row.question.slice(0, 80)} (${row.raw} -> ${row.returned})`);
248
+ }
249
+ for (const [agent, a] of Object.entries(report.agents)) {
250
+ lines.push(`agent ${agent}: ${a.calls} calls (${a.viaMcp} through MCP), ${a.denials} runs turned back, ${a.sessions} sessions started`);
251
+ }
252
+ const { denials, followedByHydra, trimmed, rawBypasses } = report.hooks;
253
+ const { decisions, p50Ms, p90Ms } = report.hooks;
254
+ lines.push(`hooks ${decisions} Bash calls checked (p50 ${p50Ms} ms, p90 ${p90Ms} ms); ${denials} test/build runs turned back, ` +
255
+ `${followedByHydra} followed by a Hydra run within 5 minutes; ${trimmed} let through already cut short (| tail -n N)`);
256
+ for (const command of rawBypasses) lines.push(` HYDRA_RAW=1 bypass (check for a false match): ${command.slice(0, 100)}`);
257
+ for (const error of report.errors) lines.push(`error ${error.ts} ${error.command}: ${error.error}`);
258
+ console.log(lines.join('\n'));
259
+ }
260
+
221
261
  async function doctor() {
222
262
  const checks = new Map();
223
263
  const modelStatuses = await Promise.all(
@@ -258,6 +298,180 @@ This command prints instructions only; it does not install, download or launch a
258
298
  }
259
299
  }
260
300
 
301
+ async function smellCommand(args) {
302
+ const isJson = args.includes('--json');
303
+ const isDiff = args.includes('--diff');
304
+ const isStaged = args.includes('--staged');
305
+ const target = args.filter(a => !a.startsWith('--'))[0] || (isDiff || isStaged ? '.' : process.cwd());
306
+
307
+ const started = Date.now();
308
+ let text = '';
309
+ let rawChars = 0;
310
+
311
+ if (isDiff || isStaged) {
312
+ const gitArgs = ['diff'];
313
+ if (isStaged) gitArgs.push('--staged');
314
+ const diffRes = await run('git', gitArgs, process.cwd());
315
+ const diffOutput = diffRes.out || '';
316
+ rawChars = diffOutput.length;
317
+ const diffReport = refactor.scanDiff(diffOutput);
318
+ if (isJson) {
319
+ text = JSON.stringify(diffReport, null, 2);
320
+ } else {
321
+ if (!diffReport.filesWithSmells) {
322
+ text = '✔ No code smells introduced in this diff.';
323
+ } else {
324
+ text = `[hydra smell] ${diffReport.filesWithSmells} changed files with smells:\n\n` +
325
+ diffReport.reports.map(r => refactor.formatSmellReport(r)).join('\n\n');
326
+ }
327
+ }
328
+ } else {
329
+ const resolved = path.resolve(target);
330
+ if (!existsSync(resolved)) fail(1, `hydra smell: path not found: ${target}`);
331
+ const isDir = statSync(resolved).isDirectory();
332
+ if (isDir) {
333
+ const dirReport = refactor.scanDirectory(resolved);
334
+ rawChars = dirReport.reports.reduce((s, r) => s + r.totalLines * 30, 100);
335
+ if (isJson) {
336
+ text = JSON.stringify(dirReport, null, 2);
337
+ } else {
338
+ if (!dirReport.filesWithSmells) {
339
+ text = `✔ ${target}: No code smells detected across scanned files.`;
340
+ } else {
341
+ text = `[hydra smell] ${dirReport.totalSmells} smells across ${dirReport.filesWithSmells} files in ${target}:\n\n` +
342
+ dirReport.reports.map(r => refactor.formatSmellReport(r)).join('\n\n');
343
+ }
344
+ }
345
+ } else {
346
+ const report = refactor.scanFile(resolved);
347
+ rawChars = report.totalLines * 35;
348
+ text = isJson ? JSON.stringify(report, null, 2) : refactor.formatSmellReport(report);
349
+ }
350
+ }
351
+
352
+ const raw = tokens(rawChars);
353
+ const returned = tokens(text.length);
354
+ const seconds = Number(((Date.now() - started) / 1000).toFixed(2));
355
+
356
+ record({
357
+ ts: new Date().toISOString(),
358
+ head: 'refactor',
359
+ question: `smell ${args.join(' ')}`,
360
+ raw,
361
+ returned,
362
+ seconds,
363
+ });
364
+
365
+ console.log(withFooter(text, 'smell', raw, returned, seconds));
366
+ }
367
+
368
+ async function refactorCommand(args) {
369
+ const isJson = args.includes('--json');
370
+ const cleanArgs = args.filter(a => a !== '--json');
371
+ if (cleanArgs.length === 0) fail(1, 'usage: hydra refactor <file> [symbol|--lines <start-end>]');
372
+
373
+ let fileArg = null;
374
+ let symbol = null;
375
+ let lines = null;
376
+
377
+ for (let i = 0; i < cleanArgs.length; i++) {
378
+ const arg = cleanArgs[i];
379
+ if (arg === '--lines' && i + 1 < cleanArgs.length) {
380
+ lines = cleanArgs[++i];
381
+ } else if (arg.startsWith('--lines=')) {
382
+ lines = arg.slice('--lines='.length);
383
+ } else if (/^\d+[:-]\d+$/.test(arg)) {
384
+ lines = arg;
385
+ } else if (!fileArg) {
386
+ fileArg = arg;
387
+ } else if (!symbol) {
388
+ symbol = arg;
389
+ }
390
+ }
391
+
392
+ if (!fileArg) fail(1, 'usage: hydra refactor <file> [symbol|--lines <start-end>]');
393
+ const resolved = path.resolve(fileArg);
394
+ if (!existsSync(resolved) || statSync(resolved).isDirectory()) {
395
+ fail(1, `hydra refactor: file not found: ${fileArg}`);
396
+ }
397
+
398
+ const started = Date.now();
399
+ const content = readFileSync(resolved, 'utf8');
400
+
401
+ let startLine, endLine;
402
+ if (lines) {
403
+ const [s, e] = lines.split(/[:-]/).map(Number);
404
+ startLine = s;
405
+ endLine = e;
406
+ }
407
+
408
+ const recipe = refactor.generateRecipe(content, { symbol, startLine, endLine });
409
+ const rawChars = content.length;
410
+ const text = isJson ? JSON.stringify(recipe, null, 2) : refactor.formatRecipe(recipe);
411
+
412
+ const raw = tokens(rawChars);
413
+ const returned = tokens(text.length);
414
+ const seconds = Number(((Date.now() - started) / 1000).toFixed(2));
415
+
416
+ record({
417
+ ts: new Date().toISOString(),
418
+ head: 'refactor',
419
+ question: `refactor ${fileArg}${symbol ? ` ${symbol}` : ''}`,
420
+ raw,
421
+ returned,
422
+ seconds,
423
+ });
424
+
425
+ console.log(withFooter(text, 'refactor', raw, returned, seconds));
426
+ }
427
+
428
+ async function designCommand(args) {
429
+ const isJson = args.includes('--json');
430
+ let fileArg = null;
431
+ const words = [];
432
+
433
+ for (let i = 0; i < args.length; i++) {
434
+ const arg = args[i];
435
+ if (arg === '--json') continue;
436
+ if (arg === '--file' && i + 1 < args.length) {
437
+ fileArg = args[++i];
438
+ } else if (arg.startsWith('--file=')) {
439
+ fileArg = arg.slice('--file='.length);
440
+ } else {
441
+ words.push(arg);
442
+ }
443
+ }
444
+
445
+ const query = words.join(' ').trim();
446
+ let codeSnippet = '';
447
+ if (fileArg) {
448
+ const resolved = path.resolve(fileArg);
449
+ if (existsSync(resolved) && !statSync(resolved).isDirectory()) {
450
+ codeSnippet = readFileSync(resolved, 'utf8').slice(0, 5000);
451
+ }
452
+ }
453
+
454
+ const started = Date.now();
455
+ const adv = refactor.advisePattern(query, codeSnippet);
456
+ const text = isJson ? JSON.stringify(adv, null, 2) : refactor.formatPattern(adv);
457
+
458
+ const seconds = Number(((Date.now() - started) / 1000).toFixed(2));
459
+
460
+ // A pattern lookup replaces no command output, so it has no raw baseline: it counts as neither a
461
+ // saving nor a cost, and its size is kept apart.
462
+ record({
463
+ ts: new Date().toISOString(),
464
+ head: 'refactor',
465
+ question: `design ${query}`,
466
+ raw: 0,
467
+ returned: 0,
468
+ replyTokens: tokens(text.length),
469
+ seconds,
470
+ });
471
+
472
+ console.log(text);
473
+ }
474
+
261
475
  const [command, ...rest] = process.argv.slice(2);
262
476
  const commands = {
263
477
  ask: () => ask(rest),
@@ -266,14 +480,43 @@ const commands = {
266
480
  run: () => runCommand(rest),
267
481
  test: () => runCommand(rest),
268
482
  read: () => readCommand(rest),
483
+ smell: () => smellCommand(rest),
484
+ refactor: () => refactorCommand(rest),
485
+ design: () => designCommand(rest),
269
486
  os: () => osCommand(rest),
270
487
  stats: showStats,
488
+ improve: () => improve(rest),
271
489
  doctor,
272
490
  setup,
273
491
  stop: () => console.log(stopLocal() ? 'Hydra local server stopped.' : 'No managed Hydra server is running.'),
274
492
  hooks: () => hooksCommand(rest[0]),
493
+ agents: () => agentsCommand(rest),
494
+ mcp: () => import('./mcp.mjs').then(server => server.serve()),
275
495
  };
276
496
 
497
+ function agentsCommand(args) {
498
+ const [action, ...words] = args;
499
+ const ids = words.filter(word => !word.startsWith('--'));
500
+ if (action === 'status') {
501
+ for (const row of integrations.status()) {
502
+ console.log(`${row.id.padEnd(15)} ${row.present ? 'found ' : 'not found'} mcp ${row.mcp ? 'yes' : 'no '}${'hooks' in row ? ` hooks ${row.hooks ? 'yes' : 'no'}` : ''}${'skill' in row ? ` skill ${row.skill ? 'yes' : 'no'}` : ''}`);
503
+ }
504
+ console.log(`claude-code ${hookStatus().length ? 'hooks yes' : 'hooks no'} (skill and hooks; see hydra hooks status)`);
505
+ return;
506
+ }
507
+ if (!['install', 'remove'].includes(action)) fail(1, 'usage: hydra agents install|remove|status [codex cursor opencode gemini-cli antigravity antigravity-ide] [--no-hooks]');
508
+ const scripts = action === 'install'
509
+ ? integrations.installSharedScripts(path.dirname(fileURLToPath(import.meta.url))) : integrations.sharedScripts();
510
+ const targets = ids.length ? ids : action === 'install' ? integrations.detected(scripts) : Object.keys(integrations.tools(scripts));
511
+ if (!targets.length) return console.log('No supported coding tool found (codex, cursor, opencode, gemini-cli, antigravity, antigravity-ide).');
512
+ for (const result of integrations.apply(targets, action, { scripts, hooks: !words.includes('--no-hooks') })) {
513
+ if (result.error) { console.log(`${result.id}: ${result.error}`); continue; }
514
+ console.log(`${result.id}: ${result.changes.length ? result.changes.join('; ') : action === 'install' ? 'nothing changed' : 'nothing to remove'}`);
515
+ for (const message of result.manual) console.log(` ${message.replace(/\n/g, '\n ')}`);
516
+ if (result.note) console.log(` ${result.note}`);
517
+ }
518
+ }
519
+
277
520
  function hooksCommand(action) {
278
521
  const scripts = path.dirname(fileURLToPath(import.meta.url));
279
522
  if (action === 'install') console.log(`Hydra hooks registered in ${installHooks(scripts)}.`);
@@ -286,6 +529,7 @@ try {
286
529
  if (!commands[command]) fail(1, USAGE);
287
530
  await commands[command]();
288
531
  } catch (e) {
532
+ record({ ts: new Date().toISOString(), kind: 'error', command, error: String(e?.message ?? e).slice(0, 300) });
289
533
  if (!(e instanceof Unavailable)) throw e;
290
534
  fail(2, `hydra unavailable: ${e.message}`);
291
535
  }
@@ -7,6 +7,7 @@ import { execFileSync } from 'node:child_process';
7
7
  import { installRuntime, findNpx } from './lib/bootstrap.mjs';
8
8
  import { ensureLocal, home } from './lib/local.mjs';
9
9
  import { claudeDir, installHooks } from './lib/claude_hooks.mjs';
10
+ import * as integrations from './lib/integrations.mjs';
10
11
 
11
12
  const here = path.dirname(fileURLToPath(import.meta.url));
12
13
  try {
@@ -15,7 +16,10 @@ try {
15
16
  if (!manifest.model) throw new Error('GGUF publication is not ready; no downloads or installation performed');
16
17
  // Install for the agents named with --agent, or else for every agent found on this machine.
17
18
  const named = process.argv.flatMap((arg, i) => arg === '--agent' ? [process.argv[i + 1]] : []);
18
- const found = [['claude-code', claudeDir()], ['codex', path.join(os.homedir(), '.codex')]].filter(([, dir]) => existsSync(dir)).map(([id]) => id);
19
+ // The skill goes where an agent has no hook to route it: Claude Code (plus its hooks), Codex and Antigravity.
20
+ // Other tools get the MCP server and a hook instead (below), so their context does not carry both.
21
+ const found = [['claude-code', claudeDir()], ['codex', path.join(os.homedir(), '.codex')], ['antigravity', path.join(os.homedir(), '.gemini', 'antigravity')]]
22
+ .filter(([, dir]) => existsSync(dir)).map(([id]) => id);
19
23
  const agents = named.length ? named : found;
20
24
  if (!process.argv.includes('--runtime-only')) {
21
25
  if (!agents.length) throw new Error('No Claude Code (~/.claude) or Codex (~/.codex) found; name one with --agent <skills CLI agent ID>');
@@ -42,5 +46,20 @@ The server stays running until stopped or the computer restarts. Download time d
42
46
  Test and build runs now go through Hydra, and new sessions are told to ask Hydra about git history.
43
47
  Remove them with: node "${path.join(skill, 'hydra.mjs')}" hooks remove`);
44
48
  }
49
+ // Every other coding tool found here: Hydra's MCP server, plus its test/build hook where the tool has hooks.
50
+ if (!process.argv.includes('--runtime-only') && !process.argv.includes('--no-agents')) {
51
+ const scripts = integrations.installSharedScripts(here);
52
+ const tools = integrations.detected(scripts);
53
+ if (tools.length) {
54
+ console.log(`Registering Hydra's MCP server${process.argv.includes('--no-hooks') ? '' : ' and test/build hook'} in ${tools.join(', ')} ` +
55
+ `(each edited file is backed up once as <file>.before-hydra; skip with --no-agents):`);
56
+ for (const result of integrations.apply(tools, 'install', { scripts, hooks: !process.argv.includes('--no-hooks') })) {
57
+ console.log(` ${result.id}: ${result.changes.join('; ') || 'nothing changed'}`);
58
+ for (const message of result.manual) console.log(` ${message.replace(/\n/g, '\n ')}`);
59
+ if (result.note) console.log(` ${result.note}`);
60
+ }
61
+ console.log(` Remove them with: node "${path.join(scripts, 'hydra.mjs')}" agents remove`);
62
+ }
63
+ }
45
64
  console.log('Hydra skill and local runtime ready. Run the installed scripts/hydra.mjs doctor to verify.');
46
65
  } catch (error) { console.error(`hydra-install: ${error.message}`); process.exitCode = 2; }
@@ -29,12 +29,116 @@ const RUNNERS = [
29
29
  export const claudeDir = () => process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
30
30
  export const settingsFile = () => path.join(claudeDir(), 'settings.json');
31
31
 
32
- /** The test/build run inside a shell command, or null. Hydra's own runs and HYDRA_RAW=1 pass. */
32
+ /** The simple commands of a shell line: split on &&, ||, ;, | and newlines outside quotes, so a
33
+ * quoted grep pattern such as "test\|pytest" is never mistaken for a command of its own. A heredoc
34
+ * body (<<EOF ... EOF) is data, not commands, and is skipped whole. With pipes false, a pipeline
35
+ * (a | b) stays one piece. */
36
+ export function simpleCommands(command, { pipes = true } = {}) {
37
+ const parts = [];
38
+ let current = '';
39
+ let quote = null;
40
+ const heredocs = [];
41
+ for (let i = 0; i < command.length; i++) {
42
+ const c = command[i];
43
+ const heredoc = !quote && c === '<' && command[i + 1] === '<' && command[i + 2] !== '<'
44
+ && /^<<-?\s*(['"]?)([A-Za-z_]\w*)\1/.exec(command.slice(i));
45
+ if (heredoc) {
46
+ heredocs.push(heredoc[2]);
47
+ current += heredoc[0];
48
+ i += heredoc[0].length - 1;
49
+ continue;
50
+ }
51
+ if (!quote && c === '\n' && heredocs.length) {
52
+ parts.push(current);
53
+ current = '';
54
+ // Skip each body up to the line that holds only its delimiter.
55
+ for (const delimiter of heredocs.splice(0)) {
56
+ const end = command.slice(i + 1).split('\n').findIndex(line => line.trim() === delimiter);
57
+ if (end < 0) return parts.map(part => part.trim()).filter(Boolean);
58
+ i += command.slice(i + 1).split('\n').slice(0, end + 1).join('\n').length + 1;
59
+ }
60
+ continue;
61
+ }
62
+ if (quote) {
63
+ if (quote === '"' && c === '\\') { current += c + (command[++i] ?? ''); continue; }
64
+ if (c === quote) quote = null;
65
+ current += c;
66
+ } else if (c === "'" || c === '"') {
67
+ quote = c;
68
+ current += c;
69
+ } else if (c === '\\') {
70
+ current += c + (command[++i] ?? '');
71
+ } else if ((c === '&' || c === '|') && command[i + 1] === c) {
72
+ parts.push(current);
73
+ current = '';
74
+ i++;
75
+ } else if (c === '|' && !pipes) {
76
+ current += c;
77
+ } else if (c === ';' || c === '|' || c === '\n') {
78
+ parts.push(current);
79
+ current = '';
80
+ } else {
81
+ current += c;
82
+ }
83
+ }
84
+ parts.push(current);
85
+ return parts.map(part => part.trim()).filter(Boolean);
86
+ }
87
+
88
+ const ownRun = command => /hydra(?:\.mjs)?"?\s+(?:run|test)\b/.test(command);
89
+
90
+ // A later pipeline stage that keeps at most this many lines leaves little for Hydra to save.
91
+ const FEW_LINES = 40;
92
+
93
+ /** A pipeline stage that cuts its input to a few lines or a count: tail/head of at most FEW_LINES
94
+ * lines (10 when not given), grep -c, or wc. tail -f and tail -n +N can return everything. */
95
+ function cutsShort(stage) {
96
+ const [name, ...args] = stage.split(/\s+/);
97
+ if (name === 'wc') return true;
98
+ if (name === 'grep' || name === 'rg') return args.some(arg => /^-[a-zA-Z]*c[a-zA-Z]*$/.test(arg) || arg === '--count');
99
+ if (name !== 'tail' && name !== 'head') return false;
100
+ let lines = 10;
101
+ for (let i = 0; i < args.length; i++) {
102
+ const arg = args[i];
103
+ if (/^-[a-zA-Z]*[fF]/.test(arg) || arg.startsWith('--follow')) return false;
104
+ const value = /^-\d+$/.test(arg) ? arg.slice(1) : arg === '-n' || arg === '--lines' ? args[++i]
105
+ : /^-n./.test(arg) ? arg.slice(2) : arg.startsWith('--lines=') ? arg.slice(8) : null;
106
+ if (value === null) continue;
107
+ if (!/^\d+$/.test(value ?? '')) return false;
108
+ lines = Number(value);
109
+ }
110
+ return lines <= FEW_LINES;
111
+ }
112
+
113
+ /** Each test/build run in a shell line, and whether its own pipeline cuts the output short. */
114
+ function runsIn(command) {
115
+ return simpleCommands(command, { pipes: false }).flatMap(pipeline => {
116
+ const stages = simpleCommands(pipeline).map(part => part.replace(/^(?:\w+=\S*\s+)+/, ''));
117
+ const at = stages.findIndex(part => RUNNERS.some(re => re.test(part)));
118
+ return at < 0 ? [] : [{ run: stages[at].replace(/\s+2>&1\s*$/, ''), cut: stages.slice(at + 1).some(cutsShort) }];
119
+ });
120
+ }
121
+
122
+ const passesAnyway = command => !command || ownRun(command) || /\bHYDRA_RAW=1\b/.test(command);
123
+
124
+ /** The test/build run inside a shell command that would put its whole log in context, or null.
125
+ * Hydra's own runs, HYDRA_RAW=1 and runs already cut to a few lines (| tail -n 15) pass. */
33
126
  export function testRun(command) {
34
- if (!command || /hydra(?:\.mjs)?\s+(?:run|test)\b/.test(command) || /\bHYDRA_RAW=1\b/.test(command)) return null;
35
- const run = command.split(/&&|\|\||;|\|/).map(part => part.trim().replace(/^(?:\w+=\S*\s+)+/, ''))
36
- .find(part => RUNNERS.some(re => re.test(part)));
37
- return run ? run.replace(/\s+2>&1\s*$/, '') : null;
127
+ if (passesAnyway(command)) return null;
128
+ return runsIn(command).find(run => !run.cut)?.run ?? null;
129
+ }
130
+
131
+ /** A command whose every test/build run is already cut to a few lines by its own pipeline. */
132
+ export function trimmedRun(command) {
133
+ if (passesAnyway(command)) return false;
134
+ const runs = runsIn(command);
135
+ return runs.length > 0 && runs.every(run => run.cut);
136
+ }
137
+
138
+ /** A test/build run let through by HYDRA_RAW=1: the agent wanted more than Hydra's digest. */
139
+ export function rawBypass(command) {
140
+ return Boolean(command) && /\bHYDRA_RAW=1\b/.test(command) && !ownRun(command)
141
+ && runsIn(command.replace(/\bHYDRA_RAW=1\s+/g, '')).length > 0;
38
142
  }
39
143
 
40
144
  function load(file) {