hydra-nano 0.2.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 +15 -0
- package/package.json +3 -2
- package/skills/hydra/SKILL.md +13 -19
- package/skills/hydra/scripts/hooks/route-tests.mjs +6 -4
- package/skills/hydra/scripts/hydra.mjs +200 -14
- package/skills/hydra/scripts/lib/claude_hooks.mjs +49 -10
- package/skills/hydra/scripts/lib/integrations.mjs +38 -1
- package/skills/hydra/scripts/lib/ledger.mjs +8 -4
- package/skills/hydra/scripts/lib/refactor.mjs +1122 -0
- package/skills/hydra/scripts/mcp.mjs +25 -0
package/README.md
CHANGED
|
@@ -86,6 +86,18 @@ Through MCP, Hydra offers the three where it pays most: `hydra_git`, `hydra_run`
|
|
|
86
86
|
|
|
87
87
|
Receipts: [model card](https://huggingface.co/rahul05ranjan/hydra-nano-git) and [`evals/results`](https://github.com/rahul05ranjan/hydra/tree/main/evals/results).
|
|
88
88
|
|
|
89
|
+
<!-- usage-stats:begin (written by evals/usage/readme_stats.mjs) -->
|
|
90
|
+
**In real use.** Since 2026-10-17, Hydra has handled **118 calls** in **9 coding sessions** across **3 projects** on the maintainer's machine. Of **~375,638 tokens** of command output, **~18,781** reached the agent: **95% kept out of context**.
|
|
91
|
+
|
|
92
|
+
| Work | Calls | Output read locally | Reached the agent | Kept out |
|
|
93
|
+
|---|---|---|---|---|
|
|
94
|
+
| Test and build runs | 112 | ~374,873 | ~18,199 | **95%** |
|
|
95
|
+
| File slices | 1 | ~508 | ~283 | **44%** |
|
|
96
|
+
| Git questions | 5 | ~257 | ~299 | **-16%** |
|
|
97
|
+
|
|
98
|
+
<sub>From Hydra's local ledger; tokens are estimates (characters / 4). Updated 2026-10-20.</sub>
|
|
99
|
+
<!-- usage-stats:end -->
|
|
100
|
+
|
|
89
101
|
**Where Hydra does not help, measured.** In a controlled trial of 36 Claude Code sessions extracting functions from real source code, forcing that work through Hydra used **72% more tokens** than the agent's own tools. So Hydra leaves source reading and search to the agent, and routes only the noisy work: test and build logs, git history, system inspection. Full method and numbers in [evaluations](https://github.com/rahul05ranjan/hydra/blob/main/docs/evaluations.md).
|
|
90
102
|
|
|
91
103
|
## Commands
|
|
@@ -95,6 +107,9 @@ hydra ask "which branch am I on and what changed?" # git, files or os, routed
|
|
|
95
107
|
hydra run -- pytest -q # a test or build run, digested
|
|
96
108
|
hydra os "what is using port 8080?" # read-only system inspection
|
|
97
109
|
hydra read src/app.py create_app # one symbol, a line range or an outline
|
|
110
|
+
hydra smell src/ # detect Refactoring.Guru code smells (or --diff / --staged)
|
|
111
|
+
hydra refactor src/app.py process_order # actionable step-by-step refactoring recipe
|
|
112
|
+
hydra design "need flexible algorithms at runtime" # GoF design pattern advisor & architecture guide
|
|
98
113
|
hydra stats # tokens kept out of context
|
|
99
114
|
hydra improve --since 2026-10-17 # what the local telemetry says to improve
|
|
100
115
|
hydra agents status # Hydra in each coding tool
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hydra-nano",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Small local models that take the noisy work off your coding agent: git history, test and build digests and OS inspection run on your machine, and only the lines that matter reach Claude Code, Codex, Cursor, OpenCode, Gemini CLI or Antigravity. Skill, MCP server and hooks.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
|
@@ -52,7 +52,8 @@
|
|
|
52
52
|
"eval:frontier": "node evals/trial/frontier_trial.mjs --run",
|
|
53
53
|
"eval:real:prepare": "node evals/trial/real_repo_trial.mjs",
|
|
54
54
|
"eval:real": "node evals/trial/real_repo_trial.mjs --run",
|
|
55
|
-
"dashboard": "node dashboard/server.mjs"
|
|
55
|
+
"dashboard": "node dashboard/server.mjs",
|
|
56
|
+
"stats:readme": "node evals/usage/readme_stats.mjs"
|
|
56
57
|
},
|
|
57
58
|
"engines": {
|
|
58
59
|
"node": ">=18"
|
package/skills/hydra/SKILL.md
CHANGED
|
@@ -1,41 +1,35 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: hydra
|
|
3
|
-
description:
|
|
3
|
+
description: Run test and build commands, git history/diff/blame questions, and machine inspections (ports, processes, disk) through hydra, a local model that returns only the lines that matter. Use before running npm test, pytest, cargo test, tsc or a build, before reading git log, diff, show or blame output, and before ps, lsof or df dumps.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Hydra
|
|
7
7
|
|
|
8
|
-
`hydra` runs read-only
|
|
8
|
+
`hydra` runs a noisy read-only command on this machine and prints only the lines that answer the question; the raw output stays out of your context. It pays off where output is long and mostly noise: test and build logs, git history and diffs, process and port listings.
|
|
9
9
|
|
|
10
10
|
## Call it
|
|
11
11
|
|
|
12
|
-
From the repository the question is about
|
|
12
|
+
From the repository the question is about:
|
|
13
13
|
|
|
14
14
|
```
|
|
15
|
+
node scripts/hydra.mjs run -- <test or build command...>
|
|
15
16
|
node scripts/hydra.mjs ask --head git "<question>"
|
|
16
|
-
node scripts/hydra.mjs ask --head files "<question>"
|
|
17
|
-
node scripts/hydra.mjs ask --head read "<question>"
|
|
18
|
-
node scripts/hydra.mjs ask --head test "<question>"
|
|
19
|
-
node scripts/hydra.mjs ask --head os "<question>"
|
|
20
|
-
node scripts/hydra.mjs search <term...>
|
|
21
|
-
node scripts/hydra.mjs read <file> [symbol|--lines <start-end>|--outline]
|
|
22
|
-
node scripts/hydra.mjs run -- <command...>
|
|
23
17
|
node scripts/hydra.mjs os [question...]
|
|
24
18
|
```
|
|
25
19
|
|
|
26
|
-
`scripts/` is in this skill's directory.
|
|
27
|
-
|
|
28
|
-
-
|
|
29
|
-
-
|
|
30
|
-
-
|
|
20
|
+
`scripts/` is in this skill's directory.
|
|
21
|
+
|
|
22
|
+
- **Tests and builds:** `run --` executes the command and returns the failing tests, assertion diffs and source locations with the exit code, or a short pass summary. A run you already cut to a few lines yourself (`| tail -n 20`) gains nothing from Hydra.
|
|
23
|
+
- **Git:** ask one full question per call and name the file, branch, commit or author when you know it; the model may only use a path, revision or name that appears in your question. A short check such as `git status --short` is cheaper to run yourself.
|
|
24
|
+
- **Machine:** ports, processes, disk, memory, system info and environment variable names, with secrets withheld.
|
|
25
|
+
|
|
26
|
+
Read and search source files with your own tools: a measured trial found routing that work through Hydra cost 72% more tokens. Hydra also has `search`, `read`, `smell`, `refactor` and `design` commands (`node scripts/hydra.mjs` with no arguments lists them) for when the user asks for them.
|
|
31
27
|
|
|
32
28
|
## What it answers
|
|
33
29
|
|
|
30
|
+
- **test**: test and build output (`npm test`, `pytest`, `cargo test`, `go test`, `tsc` and others). On failure, the failing test name, assertion diff and the user's own file:line, with node_modules and framework frames dropped.
|
|
34
31
|
- **git**: current branch and sync state, uncommitted and staged changes, commit history, what this branch adds over a base branch, one commit's patch, blame, branches, worktrees, remotes, stashes, tags.
|
|
35
|
-
- **
|
|
36
|
-
- **read**: extracts symbol definitions (functions, classes, methods), line ranges (`--lines 10-50`), or structural definition outlines across TypeScript, JavaScript, Python, Rust, and Go without dumping whole files into context.
|
|
37
|
-
- **test**: runs and parses test and build outputs (`npm test`, `pytest`, `cargo test`, `tsc`). If tests fail, extracts the failing test name, assertion diff, and exact user file:line location, stripping node_modules and framework traces.
|
|
38
|
-
- **os**: inspects listening ports (`lsof`), running processes / CPU consumers (`ps`), disk space (`df -h`), and system info (`uptime`, `uname`) while strictly guarding against printing secrets.
|
|
32
|
+
- **os**: listening ports, running processes and CPU consumers, disk space, memory, system info, environment variable names, never their values.
|
|
39
33
|
|
|
40
34
|
Changes to the repository, and questions that need reasoning about what code does, stay with you.
|
|
41
35
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Shell-command hook: a test or build run goes through `hydra run --` (or the hydra_run MCP tool),
|
|
3
3
|
// whose digest keeps failing test names, assertion diffs and source locations and drops the rest.
|
|
4
|
-
//
|
|
4
|
+
// A run already cut to a few lines by its own pipeline (| tail -n 15, | grep -c) passes: Hydra would
|
|
5
|
+
// save little and a retry costs more. Prefix a command with HYDRA_RAW=1 when the raw log is really needed.
|
|
5
6
|
// --format picks the tool's hook protocol:
|
|
6
7
|
// claude, codex PreToolUse; command at tool_input.command; deny via hookSpecificOutput.
|
|
7
8
|
// gemini BeforeTool on run_shell_command; deny via {"decision": "deny", "reason"}.
|
|
@@ -10,7 +11,7 @@
|
|
|
10
11
|
// that simply passes is recorded without its text.
|
|
11
12
|
import path from 'node:path';
|
|
12
13
|
import { fileURLToPath } from 'node:url';
|
|
13
|
-
import { rawBypass, testRun } from '../lib/claude_hooks.mjs';
|
|
14
|
+
import { rawBypass, testRun, trimmedRun } from '../lib/claude_hooks.mjs';
|
|
14
15
|
import { record } from '../lib/ledger.mjs';
|
|
15
16
|
|
|
16
17
|
const hydra = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'hydra.mjs');
|
|
@@ -40,7 +41,7 @@ process.stdin.on('data', chunk => { input += chunk; }).on('end', () => {
|
|
|
40
41
|
...(session ? { session } : {}), hookMs: Math.round(process.uptime() * 1000) });
|
|
41
42
|
const run = testRun(command);
|
|
42
43
|
if (!run) {
|
|
43
|
-
record(rawBypass(command) ? event('raw') : event('pass', false));
|
|
44
|
+
record(rawBypass(command) ? event('raw') : trimmedRun(command) ? event('trimmed') : event('pass', false));
|
|
44
45
|
if (answer.pass) process.stdout.write(JSON.stringify(answer.pass));
|
|
45
46
|
return;
|
|
46
47
|
}
|
|
@@ -48,5 +49,6 @@ process.stdin.on('data', chunk => { input += chunk; }).on('end', () => {
|
|
|
48
49
|
process.stdout.write(JSON.stringify(answer.deny(`Test/build runs go through Hydra so the full log stays out of context. Run it as:\n` +
|
|
49
50
|
` node "${hydra}" run -- ${run}\n${format === 'claude' ? '' : 'or call the hydra_run tool with that command. '}` +
|
|
50
51
|
`(Keep any cd or env prefix before it.) Hydra returns the failing tests, assertion diffs and source locations ` +
|
|
51
|
-
`verbatim, or a short pass summary.
|
|
52
|
+
`verbatim, or a short pass summary. A run you cut to a few lines yourself (| tail -n 40 or less, or | grep ... | head -40) also passes. ` +
|
|
53
|
+
`If you truly need the raw log, prefix the original command with HYDRA_RAW=1.`)));
|
|
52
54
|
});
|
|
@@ -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';
|
|
@@ -16,6 +16,7 @@ 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
18
|
import * as integrations from './lib/integrations.mjs';
|
|
19
|
+
import * as refactor from './lib/refactor.mjs';
|
|
19
20
|
|
|
20
21
|
const HEADS = { git, files, test: test_logs, read, os: osHead };
|
|
21
22
|
|
|
@@ -23,6 +24,9 @@ const USAGE = `usage:
|
|
|
23
24
|
hydra ask "<question>" [--head git|files|test|read|os] answer a question about the repo or system
|
|
24
25
|
hydra search <term...> search codebase with definition ranking
|
|
25
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
|
|
26
30
|
hydra run [--head test] -- <command...> run a command and digest test/build output
|
|
27
31
|
hydra os [question...] inspect ports, processes, disk, system
|
|
28
32
|
hydra stats token accounting for past calls
|
|
@@ -32,7 +36,7 @@ const USAGE = `usage:
|
|
|
32
36
|
hydra stop stop Hydra's managed llama-server
|
|
33
37
|
hydra hooks install|remove|status Claude Code hooks that route tests and git questions here
|
|
34
38
|
hydra agents install|remove|status [tool...] Hydra's MCP server and hooks in codex, cursor, opencode,
|
|
35
|
-
gemini-cli and antigravity (default: every tool found)
|
|
39
|
+
gemini-cli, antigravity and antigravity-ide (default: every tool found)
|
|
36
40
|
hydra mcp run the MCP server on stdio`;
|
|
37
41
|
|
|
38
42
|
function fail(code, message) {
|
|
@@ -40,6 +44,11 @@ function fail(code, message) {
|
|
|
40
44
|
process.exit(code);
|
|
41
45
|
}
|
|
42
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
|
+
|
|
43
52
|
async function ask(args) {
|
|
44
53
|
const flag = args.indexOf('--head');
|
|
45
54
|
const forced = flag >= 0 ? args.splice(flag, 2)[1] : undefined;
|
|
@@ -60,7 +69,7 @@ async function ask(args) {
|
|
|
60
69
|
const seconds = Number(((Date.now() - started) / 1000).toFixed(1));
|
|
61
70
|
|
|
62
71
|
record({ ts: new Date().toISOString(), head, question, raw, returned, seconds, modelCalls: meter.modelCalls, modelSeconds: meter.modelSeconds });
|
|
63
|
-
console.log(
|
|
72
|
+
console.log(withFooter(text, head, raw, returned, seconds));
|
|
64
73
|
}
|
|
65
74
|
|
|
66
75
|
async function runCommand(args) {
|
|
@@ -78,7 +87,7 @@ async function runCommand(args) {
|
|
|
78
87
|
const returned = tokens(text.length);
|
|
79
88
|
const seconds = Number(((Date.now() - started) / 1000).toFixed(1));
|
|
80
89
|
record({ ts: new Date().toISOString(), head: 'test', question: 'pipe', raw, returned, seconds, modelCalls: meter.modelCalls, modelSeconds: meter.modelSeconds });
|
|
81
|
-
console.log(
|
|
90
|
+
console.log(withFooter(text, 'test', raw, returned, seconds));
|
|
82
91
|
if (exitCode !== 0) process.exit(exitCode);
|
|
83
92
|
return;
|
|
84
93
|
}
|
|
@@ -103,7 +112,7 @@ async function runCommand(args) {
|
|
|
103
112
|
const seconds = Number(((Date.now() - started) / 1000).toFixed(1));
|
|
104
113
|
record({ ts: new Date().toISOString(), head: 'test', question: cmdArgs.join(' '), raw, returned, seconds,
|
|
105
114
|
commandSeconds, exit: exitCode, modelCalls: meter.modelCalls, modelSeconds: meter.modelSeconds });
|
|
106
|
-
console.log(
|
|
115
|
+
console.log(withFooter(text, 'test', raw, returned, seconds));
|
|
107
116
|
if (exitCode !== 0) process.exit(exitCode);
|
|
108
117
|
}
|
|
109
118
|
|
|
@@ -154,7 +163,7 @@ async function readCommand(args) {
|
|
|
154
163
|
seconds,
|
|
155
164
|
});
|
|
156
165
|
|
|
157
|
-
console.log(
|
|
166
|
+
console.log(withFooter(res.text, 'read', raw, returned, seconds));
|
|
158
167
|
}
|
|
159
168
|
|
|
160
169
|
async function osCommand(args) {
|
|
@@ -168,7 +177,7 @@ async function osCommand(args) {
|
|
|
168
177
|
const seconds = Number(((Date.now() - started) / 1000).toFixed(1));
|
|
169
178
|
|
|
170
179
|
record({ ts: new Date().toISOString(), head: 'os', question, raw, returned, seconds, modelCalls: meter.modelCalls, modelSeconds: meter.modelSeconds });
|
|
171
|
-
console.log(
|
|
180
|
+
console.log(withFooter(text, 'os', raw, returned, seconds));
|
|
172
181
|
}
|
|
173
182
|
|
|
174
183
|
async function searchCommand(args) {
|
|
@@ -208,7 +217,7 @@ async function searchCommand(args) {
|
|
|
208
217
|
seconds,
|
|
209
218
|
});
|
|
210
219
|
|
|
211
|
-
console.log(
|
|
220
|
+
console.log(withFooter(text, 'files', raw, returned, seconds));
|
|
212
221
|
}
|
|
213
222
|
|
|
214
223
|
function showStats() {
|
|
@@ -240,10 +249,10 @@ function improve(args) {
|
|
|
240
249
|
for (const [agent, a] of Object.entries(report.agents)) {
|
|
241
250
|
lines.push(`agent ${agent}: ${a.calls} calls (${a.viaMcp} through MCP), ${a.denials} runs turned back, ${a.sessions} sessions started`);
|
|
242
251
|
}
|
|
243
|
-
const { denials, followedByHydra, rawBypasses } = report.hooks;
|
|
252
|
+
const { denials, followedByHydra, trimmed, rawBypasses } = report.hooks;
|
|
244
253
|
const { decisions, p50Ms, p90Ms } = report.hooks;
|
|
245
254
|
lines.push(`hooks ${decisions} Bash calls checked (p50 ${p50Ms} ms, p90 ${p90Ms} ms); ${denials} test/build runs turned back, ` +
|
|
246
|
-
`${followedByHydra} followed by a Hydra run within 5 minutes`);
|
|
255
|
+
`${followedByHydra} followed by a Hydra run within 5 minutes; ${trimmed} let through already cut short (| tail -n N)`);
|
|
247
256
|
for (const command of rawBypasses) lines.push(` HYDRA_RAW=1 bypass (check for a false match): ${command.slice(0, 100)}`);
|
|
248
257
|
for (const error of report.errors) lines.push(`error ${error.ts} ${error.command}: ${error.error}`);
|
|
249
258
|
console.log(lines.join('\n'));
|
|
@@ -289,6 +298,180 @@ This command prints instructions only; it does not install, download or launch a
|
|
|
289
298
|
}
|
|
290
299
|
}
|
|
291
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
|
+
|
|
292
475
|
const [command, ...rest] = process.argv.slice(2);
|
|
293
476
|
const commands = {
|
|
294
477
|
ask: () => ask(rest),
|
|
@@ -297,6 +480,9 @@ const commands = {
|
|
|
297
480
|
run: () => runCommand(rest),
|
|
298
481
|
test: () => runCommand(rest),
|
|
299
482
|
read: () => readCommand(rest),
|
|
483
|
+
smell: () => smellCommand(rest),
|
|
484
|
+
refactor: () => refactorCommand(rest),
|
|
485
|
+
design: () => designCommand(rest),
|
|
300
486
|
os: () => osCommand(rest),
|
|
301
487
|
stats: showStats,
|
|
302
488
|
improve: () => improve(rest),
|
|
@@ -313,16 +499,16 @@ function agentsCommand(args) {
|
|
|
313
499
|
const ids = words.filter(word => !word.startsWith('--'));
|
|
314
500
|
if (action === 'status') {
|
|
315
501
|
for (const row of integrations.status()) {
|
|
316
|
-
console.log(`${row.id.padEnd(
|
|
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'}` : ''}`);
|
|
317
503
|
}
|
|
318
|
-
console.log(`claude-code
|
|
504
|
+
console.log(`claude-code ${hookStatus().length ? 'hooks yes' : 'hooks no'} (skill and hooks; see hydra hooks status)`);
|
|
319
505
|
return;
|
|
320
506
|
}
|
|
321
|
-
if (!['install', 'remove'].includes(action)) fail(1, 'usage: hydra agents install|remove|status [codex cursor opencode gemini-cli antigravity] [--no-hooks]');
|
|
507
|
+
if (!['install', 'remove'].includes(action)) fail(1, 'usage: hydra agents install|remove|status [codex cursor opencode gemini-cli antigravity antigravity-ide] [--no-hooks]');
|
|
322
508
|
const scripts = action === 'install'
|
|
323
509
|
? integrations.installSharedScripts(path.dirname(fileURLToPath(import.meta.url))) : integrations.sharedScripts();
|
|
324
510
|
const targets = ids.length ? ids : action === 'install' ? integrations.detected(scripts) : Object.keys(integrations.tools(scripts));
|
|
325
|
-
if (!targets.length) return console.log('No supported coding tool found (codex, cursor, opencode, gemini-cli, antigravity).');
|
|
511
|
+
if (!targets.length) return console.log('No supported coding tool found (codex, cursor, opencode, gemini-cli, antigravity, antigravity-ide).');
|
|
326
512
|
for (const result of integrations.apply(targets, action, { scripts, hooks: !words.includes('--no-hooks') })) {
|
|
327
513
|
if (result.error) { console.log(`${result.id}: ${result.error}`); continue; }
|
|
328
514
|
console.log(`${result.id}: ${result.changes.length ? result.changes.join('; ') : action === 'install' ? 'nothing changed' : 'nothing to remove'}`);
|
|
@@ -31,8 +31,9 @@ export const settingsFile = () => path.join(claudeDir(), 'settings.json');
|
|
|
31
31
|
|
|
32
32
|
/** The simple commands of a shell line: split on &&, ||, ;, | and newlines outside quotes, so a
|
|
33
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.
|
|
35
|
-
|
|
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 } = {}) {
|
|
36
37
|
const parts = [];
|
|
37
38
|
let current = '';
|
|
38
39
|
let quote = null;
|
|
@@ -71,6 +72,8 @@ export function simpleCommands(command) {
|
|
|
71
72
|
parts.push(current);
|
|
72
73
|
current = '';
|
|
73
74
|
i++;
|
|
75
|
+
} else if (c === '|' && !pipes) {
|
|
76
|
+
current += c;
|
|
74
77
|
} else if (c === ';' || c === '|' || c === '\n') {
|
|
75
78
|
parts.push(current);
|
|
76
79
|
current = '';
|
|
@@ -84,22 +87,58 @@ export function simpleCommands(command) {
|
|
|
84
87
|
|
|
85
88
|
const ownRun = command => /hydra(?:\.mjs)?"?\s+(?:run|test)\b/.test(command);
|
|
86
89
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
+
});
|
|
91
120
|
}
|
|
92
121
|
|
|
93
|
-
|
|
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. */
|
|
94
126
|
export function testRun(command) {
|
|
95
|
-
if (
|
|
96
|
-
return
|
|
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);
|
|
97
136
|
}
|
|
98
137
|
|
|
99
138
|
/** A test/build run let through by HYDRA_RAW=1: the agent wanted more than Hydra's digest. */
|
|
100
139
|
export function rawBypass(command) {
|
|
101
140
|
return Boolean(command) && /\bHYDRA_RAW=1\b/.test(command) && !ownRun(command)
|
|
102
|
-
&&
|
|
141
|
+
&& runsIn(command.replace(/\bHYDRA_RAW=1\s+/g, '')).length > 0;
|
|
103
142
|
}
|
|
104
143
|
|
|
105
144
|
function load(file) {
|
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
// opencode ~/.config/opencode/opencode.json(c) mcp.hydra ~/.config/opencode/plugins/hydra.js tool.execute.before
|
|
9
9
|
// gemini-cli ~/.gemini/settings.json mcpServers.hydra same file, hooks.BeforeTool on run_shell_command
|
|
10
10
|
// antigravity ~/.gemini/antigravity/mcp_config.json none known; the MCP server's instructions guide it
|
|
11
|
+
// antigravity-ide ~/.gemini/config/mcp_config.json none known; the skill in ~/.gemini/config/skills/hydra guides it
|
|
12
|
+
//
|
|
13
|
+
// Antigravity IDE (com.google.antigravity-ide, data in ~/.gemini/antigravity-ide) is a different app from
|
|
14
|
+
// the older Antigravity (~/.gemini/antigravity): it reads MCP servers and skills from ~/.gemini/config.
|
|
11
15
|
//
|
|
12
16
|
// Every file Hydra edits is backed up once as <file>.before-hydra, written atomically, and only
|
|
13
17
|
// Hydra's own entries are added or removed. A file Hydra cannot edit safely (JSON with comments, or
|
|
@@ -24,7 +28,8 @@ const END = '# <<< hydra <<<';
|
|
|
24
28
|
/** Hydra's own copy of its scripts, which every tool's MCP entry and hook points at. */
|
|
25
29
|
export const sharedScripts = () => path.join(process.env.HYDRA_HOME ?? path.join(os.homedir(), '.hydra'), 'scripts');
|
|
26
30
|
|
|
27
|
-
/** Copy the skill's scripts to sharedScripts(), so registrations never point into an npx cache.
|
|
31
|
+
/** Copy the skill's scripts to sharedScripts(), so registrations never point into an npx cache.
|
|
32
|
+
* Its SKILL.md goes beside them, for tools that load skills from a folder of their own. */
|
|
28
33
|
export function installSharedScripts(from) {
|
|
29
34
|
const target = sharedScripts();
|
|
30
35
|
if (path.resolve(from) === path.resolve(target)) return target; // Already running from the shared copy.
|
|
@@ -33,6 +38,8 @@ export function installSharedScripts(from) {
|
|
|
33
38
|
cpSync(from, `${target}.hydra-tmp`, { recursive: true });
|
|
34
39
|
rmSync(target, { recursive: true, force: true });
|
|
35
40
|
renameSync(`${target}.hydra-tmp`, target);
|
|
41
|
+
const skill = path.join(from, '..', 'SKILL.md');
|
|
42
|
+
if (existsSync(skill)) copyFileSync(skill, path.join(path.dirname(target), 'SKILL.md'));
|
|
36
43
|
return target;
|
|
37
44
|
}
|
|
38
45
|
|
|
@@ -186,6 +193,33 @@ export const Hydra = async () => ({
|
|
|
186
193
|
};
|
|
187
194
|
}
|
|
188
195
|
|
|
196
|
+
/** The hydra skill folder (SKILL.md and the scripts it names) in a tool's own skills folder.
|
|
197
|
+
* An older hydra skill there is replaced; a folder that is not the hydra skill is left alone. */
|
|
198
|
+
function skillFolder(dir, scripts) {
|
|
199
|
+
const skillFile = path.join(dir, 'SKILL.md');
|
|
200
|
+
const ours = () => existsSync(skillFile) && /^name:\s*hydra\s*$/m.test(readFileSync(skillFile, 'utf8'));
|
|
201
|
+
return {
|
|
202
|
+
install() {
|
|
203
|
+
const source = path.join(path.dirname(scripts), 'SKILL.md');
|
|
204
|
+
if (!existsSync(source)) throw new Manual(dir, 'npx github:rahul05ranjan/hydra', `needs the hydra skill, but ${source} is missing; run the installer again`);
|
|
205
|
+
if (existsSync(dir) && !ours()) throw new Manual(dir, `the hydra skill folder (SKILL.md and scripts/) from ${path.dirname(scripts)}`, 'exists and is not the hydra skill');
|
|
206
|
+
rmSync(`${dir}.hydra-tmp`, { recursive: true, force: true });
|
|
207
|
+
mkdirSync(`${dir}.hydra-tmp`, { recursive: true });
|
|
208
|
+
copyFileSync(source, path.join(`${dir}.hydra-tmp`, 'SKILL.md'));
|
|
209
|
+
cpSync(scripts, path.join(`${dir}.hydra-tmp`, 'scripts'), { recursive: true });
|
|
210
|
+
rmSync(dir, { recursive: true, force: true });
|
|
211
|
+
renameSync(`${dir}.hydra-tmp`, dir);
|
|
212
|
+
return `${dir}: skill`;
|
|
213
|
+
},
|
|
214
|
+
remove() {
|
|
215
|
+
if (!ours()) return null;
|
|
216
|
+
rmSync(dir, { recursive: true, force: true });
|
|
217
|
+
return `${dir}: skill`;
|
|
218
|
+
},
|
|
219
|
+
status: () => ours() && existsSync(path.join(dir, 'scripts', 'hydra.mjs')),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
189
223
|
const opencodeConfig = dir => ['opencode.json', 'opencode.jsonc'].map(name => path.join(dir, name)).find(existsSync) ?? path.join(dir, 'opencode.json');
|
|
190
224
|
|
|
191
225
|
/** Every tool Hydra can join, with where it lives and what Hydra registers there. */
|
|
@@ -217,6 +251,9 @@ export function tools(scripts = sharedScripts()) {
|
|
|
217
251
|
BeforeTool: [{ matcher: 'run_shell_command', hooks: [{ name: 'hydra', type: 'command', command: hookCommand(scripts, 'route-tests.mjs', 'gemini'), timeout: 10_000 }] }] }) } },
|
|
218
252
|
antigravity: { dir: path.join(gemini, 'antigravity'), parts: {
|
|
219
253
|
mcp: jsonServer(path.join(gemini, 'antigravity', 'mcp_config.json'), 'mcpServers', mcp) } },
|
|
254
|
+
'antigravity-ide': { dir: path.join(gemini, 'antigravity-ide'), parts: {
|
|
255
|
+
mcp: jsonServer(path.join(gemini, 'config', 'mcp_config.json'), 'mcpServers', mcp),
|
|
256
|
+
skill: skillFolder(path.join(gemini, 'config', 'skills', 'hydra'), scripts) } },
|
|
220
257
|
};
|
|
221
258
|
}
|
|
222
259
|
|