hydra-nano 0.1.0 → 0.2.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 +84 -156
- package/package.json +13 -3
- package/skills/hydra/scripts/hooks/route-tests.mjs +42 -12
- package/skills/hydra/scripts/hooks/session-guide.mjs +21 -5
- package/skills/hydra/scripts/hydra.mjs +74 -16
- package/skills/hydra/scripts/install.mjs +20 -1
- package/skills/hydra/scripts/lib/claude_hooks.mjs +69 -4
- package/skills/hydra/scripts/lib/integrations.mjs +251 -0
- package/skills/hydra/scripts/lib/ledger.mjs +106 -7
- package/skills/hydra/scripts/lib/test_logs.mjs +10 -6
- package/skills/hydra/scripts/mcp.mjs +154 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Hydra as an MCP server (stdio, JSON-RPC 2.0, one message per line), for any coding tool that
|
|
3
|
+
// speaks MCP: Codex, Cursor, OpenCode, Gemini CLI, Antigravity and others. Three tools only, where
|
|
4
|
+
// Hydra pays: git history, test/build runs and OS inspection. Reading and searching source stay with
|
|
5
|
+
// the agent's own tools (forced delegation measured +72% tokens). Each tool runs the Hydra CLI in a
|
|
6
|
+
// child process, so heads stay read-only, the model never writes a command, and every call lands in
|
|
7
|
+
// the local ledger with the client's name as its agent.
|
|
8
|
+
import { execFile } from 'node:child_process';
|
|
9
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { simpleCommands, testRun } from './lib/claude_hooks.mjs';
|
|
13
|
+
|
|
14
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const CLI = path.join(HERE, 'hydra.mjs');
|
|
16
|
+
const VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
|
|
17
|
+
const RUN_TIMEOUT_MS = 30 * 60_000;
|
|
18
|
+
|
|
19
|
+
const INSTRUCTIONS = 'Hydra runs noisy read-only commands on a small local model and returns only the lines that matter. '
|
|
20
|
+
+ 'Use hydra_git for git history, diff, blame and "what changed" questions instead of reading git log or git diff output. '
|
|
21
|
+
+ 'Use hydra_run for test and build commands; it returns failing tests, assertion diffs and locations, or a short pass summary. '
|
|
22
|
+
+ 'Keep using your own tools to read and search source files; Hydra does not save tokens there.';
|
|
23
|
+
|
|
24
|
+
const cwdProperty = { type: 'string', description: 'Absolute path of the repository or project to work in. Defaults to the server\'s working directory.' };
|
|
25
|
+
const TOOLS = [
|
|
26
|
+
{ name: 'hydra_git', description: 'Answer a read-only question about this git repository (history, diffs, blame, branches, stashes) and return only the matching lines.',
|
|
27
|
+
inputSchema: { type: 'object', properties: { question: { type: 'string', description: 'The question, in plain words.' }, cwd: cwdProperty }, required: ['question'] } },
|
|
28
|
+
{ name: 'hydra_run', description: 'Run one test or build command (npm test, pytest, cargo test, go test, tsc, and similar) and return its digest. Shell operators are refused.',
|
|
29
|
+
inputSchema: { type: 'object', properties: { command: { type: 'string', description: 'One test or build command, for example "npm test" or "pytest -q tests/test_api.py".' }, cwd: cwdProperty }, required: ['command'] } },
|
|
30
|
+
{ name: 'hydra_os', description: 'Answer a read-only question about this machine (ports, processes, disk, memory, system, environment variable names).',
|
|
31
|
+
inputSchema: { type: 'object', properties: { question: { type: 'string', description: 'The question, in plain words.' } }, required: ['question'] } },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
let client = 'mcp-client';
|
|
35
|
+
|
|
36
|
+
// The repository's package.json when run from a checkout; an installed skill folder has none.
|
|
37
|
+
const version = (() => {
|
|
38
|
+
try { return JSON.parse(readFileSync(path.join(HERE, '..', '..', '..', 'package.json'), 'utf8')).version ?? '0'; } catch { return '0'; }
|
|
39
|
+
})();
|
|
40
|
+
|
|
41
|
+
/** Split one command into argv the way a shell would for plain words and quotes, or null when it
|
|
42
|
+
* holds anything a shell would interpret (operators, redirection, expansion, env prefixes). */
|
|
43
|
+
export function argvOf(command) {
|
|
44
|
+
if (typeof command !== 'string' || !command.trim() || simpleCommands(command).length !== 1) return null;
|
|
45
|
+
const argv = [];
|
|
46
|
+
let word = null;
|
|
47
|
+
let quote = null;
|
|
48
|
+
for (let i = 0; i < command.length; i++) {
|
|
49
|
+
const c = command[i];
|
|
50
|
+
if (quote === "'") { if (c === "'") quote = null; else word += c; continue; }
|
|
51
|
+
if (quote === '"') {
|
|
52
|
+
if (c === '"') quote = null;
|
|
53
|
+
else if (c === '\\' && /["\\$`]/.test(command[i + 1] ?? '')) word += command[++i];
|
|
54
|
+
else if (c === '$' || c === '`') return null;
|
|
55
|
+
else word += c;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (c === "'" || c === '"') { quote = c; word ??= ''; continue; }
|
|
59
|
+
if (/\s/.test(c)) { if (word !== null) argv.push(word); word = null; continue; }
|
|
60
|
+
if (/[;&|<>()$`*?~{}\[\]#!]/.test(c)) return null;
|
|
61
|
+
if (c === '\\') { word = (word ?? '') + (command[++i] ?? ''); continue; }
|
|
62
|
+
word = (word ?? '') + c;
|
|
63
|
+
}
|
|
64
|
+
if (quote) return null;
|
|
65
|
+
if (word !== null) argv.push(word);
|
|
66
|
+
if (!argv.length || /^\w+=/.test(argv[0])) return null;
|
|
67
|
+
return argv;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function workdir(cwd) {
|
|
71
|
+
if (cwd === undefined) return process.cwd();
|
|
72
|
+
if (typeof cwd !== 'string' || !path.isAbsolute(cwd) || !existsSync(cwd) || !statSync(cwd).isDirectory()) {
|
|
73
|
+
throw new Error('cwd must be the absolute path of an existing directory');
|
|
74
|
+
}
|
|
75
|
+
return cwd;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function hydra(args, cwd) {
|
|
79
|
+
return new Promise(resolve => {
|
|
80
|
+
execFile(process.execPath, [CLI, ...args], {
|
|
81
|
+
cwd, timeout: RUN_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024,
|
|
82
|
+
env: { ...process.env, HYDRA_AGENT: client, HYDRA_VIA: 'mcp' },
|
|
83
|
+
}, (error, stdout, stderr) => {
|
|
84
|
+
const unavailable = /hydra unavailable:|hydra: /.test(stderr) && !stdout.trim();
|
|
85
|
+
resolve({ text: (stdout || stderr || String(error?.message ?? '')).trim(), isError: Boolean(unavailable || (error && !stdout.trim())) });
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function callTool(name, args = {}) {
|
|
91
|
+
if (name === 'hydra_git') {
|
|
92
|
+
if (typeof args.question !== 'string' || !args.question.trim()) throw new Error('question is required');
|
|
93
|
+
return hydra(['ask', '--head', 'git', args.question], workdir(args.cwd));
|
|
94
|
+
}
|
|
95
|
+
if (name === 'hydra_os') {
|
|
96
|
+
if (typeof args.question !== 'string' || !args.question.trim()) throw new Error('question is required');
|
|
97
|
+
return hydra(['os', args.question], process.cwd());
|
|
98
|
+
}
|
|
99
|
+
if (name === 'hydra_run') {
|
|
100
|
+
const argv = argvOf(args.command);
|
|
101
|
+
if (!argv || testRun(args.command.trim()) !== args.command.trim()) {
|
|
102
|
+
throw new Error('hydra_run takes one test or build command with no shell operators, redirection or env prefix, such as "npm test" or "pytest -q"');
|
|
103
|
+
}
|
|
104
|
+
return hydra(['run', '--', ...argv], workdir(args.cwd));
|
|
105
|
+
}
|
|
106
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function handle(message) {
|
|
110
|
+
const { id, method, params = {} } = message ?? {};
|
|
111
|
+
const reply = result => ({ jsonrpc: '2.0', id, result });
|
|
112
|
+
const failure = (code, text) => ({ jsonrpc: '2.0', id, error: { code, message: text } });
|
|
113
|
+
if (id === undefined || id === null) return null; // A notification: nothing to answer.
|
|
114
|
+
if (method === 'initialize') {
|
|
115
|
+
client = String(params.clientInfo?.name ?? client).slice(0, 80);
|
|
116
|
+
const protocolVersion = VERSIONS.includes(params.protocolVersion) ? params.protocolVersion : VERSIONS[0];
|
|
117
|
+
return reply({ protocolVersion, capabilities: { tools: { listChanged: false } },
|
|
118
|
+
serverInfo: { name: 'hydra', version }, instructions: INSTRUCTIONS });
|
|
119
|
+
}
|
|
120
|
+
if (method === 'ping') return reply({});
|
|
121
|
+
if (method === 'tools/list') return reply({ tools: TOOLS });
|
|
122
|
+
if (method === 'tools/call') {
|
|
123
|
+
try {
|
|
124
|
+
const { text, isError } = await callTool(params.name, params.arguments ?? {});
|
|
125
|
+
return reply({ content: [{ type: 'text', text }], isError });
|
|
126
|
+
} catch (error) {
|
|
127
|
+
// A bad argument is a tool result the agent can read and correct, not a protocol error.
|
|
128
|
+
return reply({ content: [{ type: 'text', text: `hydra: ${error.message}` }], isError: true });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return failure(-32601, `Method not found: ${method}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function serve() {
|
|
135
|
+
let buffer = '';
|
|
136
|
+
process.stdin.setEncoding('utf8');
|
|
137
|
+
process.stdin.on('data', chunk => {
|
|
138
|
+
buffer += chunk;
|
|
139
|
+
let newline;
|
|
140
|
+
while ((newline = buffer.indexOf('\n')) >= 0) {
|
|
141
|
+
const line = buffer.slice(0, newline).trim();
|
|
142
|
+
buffer = buffer.slice(newline + 1);
|
|
143
|
+
if (!line) continue;
|
|
144
|
+
let message;
|
|
145
|
+
try { message = JSON.parse(line); } catch {
|
|
146
|
+
process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } })}\n`);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
handle(message).then(answer => { if (answer) process.stdout.write(`${JSON.stringify(answer)}\n`); });
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) serve();
|