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.
- package/README.md +96 -153
- package/package.json +15 -4
- package/skills/hydra/SKILL.md +13 -19
- package/skills/hydra/scripts/hooks/route-tests.mjs +45 -13
- package/skills/hydra/scripts/hooks/session-guide.mjs +21 -5
- package/skills/hydra/scripts/hydra.mjs +267 -23
- package/skills/hydra/scripts/install.mjs +20 -1
- package/skills/hydra/scripts/lib/claude_hooks.mjs +109 -5
- package/skills/hydra/scripts/lib/integrations.mjs +288 -0
- package/skills/hydra/scripts/lib/ledger.mjs +110 -7
- package/skills/hydra/scripts/lib/refactor.mjs +1122 -0
- package/skills/hydra/scripts/lib/test_logs.mjs +10 -6
- package/skills/hydra/scripts/mcp.mjs +179 -0
|
@@ -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
|
|
51
|
-
const sections = await HEADS[head].gather(question, cwd,
|
|
52
|
-
const { text, rawChars } = await answer(question, sections,
|
|
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(
|
|
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
|
|
71
|
-
const { text, rawChars, exitCode } = await test_logs.digest({ cmd: 'pipe', out: stdinData, exitCode: 1 },
|
|
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(
|
|
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
|
|
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
|
-
},
|
|
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
|
-
|
|
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(
|
|
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
|
|
157
|
-
const sections = await osHead.gather(question, process.cwd(),
|
|
158
|
-
const { text, rawChars } = await answer(question, sections,
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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 (
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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) {
|