project-cypher 0.15.0 → 0.16.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/bin/cypher.js +42 -7
- package/package.json +1 -1
package/bin/cypher.js
CHANGED
|
@@ -218,16 +218,48 @@ const cmds = {
|
|
|
218
218
|
logs() { try { console.log(fs.readFileSync(LOG, 'utf8').split('\n').slice(-40).join('\n')); } catch { console.log('no log yet'); } },
|
|
219
219
|
async status() { const c = load(); const r = await fetch(httpOf(c.server) + '/health').then(r => r.json()).catch(() => null); console.log(`server ${c.server}: ${r?.ok ? 'online' : 'unreachable'}\ntoken: ${c.token ? 'set' : 'missing (cypher login)'}\nprojects: ${c.projects.length}`); },
|
|
220
220
|
async latest() { const c = load(); const l = await (await fetch(httpOf(c.server) + '/latest')).json(); console.log(`${l.versionName} (code ${l.versionCode})\n${l.apkUrl}`); },
|
|
221
|
+
// Hidden: MCP server that claude calls when a tool needs permission. Asks the local agent, which asks the phone.
|
|
222
|
+
'mcp-approve'() {
|
|
223
|
+
const out = o => process.stdout.write(JSON.stringify(o) + '\n'); let buf = '';
|
|
224
|
+
const ask = async a => {
|
|
225
|
+
try {
|
|
226
|
+
const r = await fetch(process.env.CYPHER_APPROVE_URL, { method: 'POST', headers: { 'content-type': 'application/json', 'x-cypher-key': process.env.CYPHER_APPROVE_KEY },
|
|
227
|
+
body: JSON.stringify({ reqId: process.env.CYPHER_REQ, projectId: process.env.CYPHER_PROJ, tool: a.tool_name, input: a.input || {} }) });
|
|
228
|
+
return await r.json();
|
|
229
|
+
} catch (e) { return { allow: false, message: 'Could not reach the Cypher agent: ' + e.message }; }
|
|
230
|
+
};
|
|
231
|
+
process.stdin.on('data', d => { buf += d; let i; while ((i = buf.indexOf('\n')) >= 0) { const l = buf.slice(0, i); buf = buf.slice(i + 1); if (!l.trim()) continue; let m; try { m = JSON.parse(l); } catch { continue; }
|
|
232
|
+
if (m.method === 'initialize') out({ jsonrpc: '2.0', id: m.id, result: { protocolVersion: m.params?.protocolVersion || '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'cypher', version: '1' } } });
|
|
233
|
+
else if (m.method === 'tools/list') out({ jsonrpc: '2.0', id: m.id, result: { tools: [{ name: 'approve', description: 'Ask the user on their phone to approve a tool use', inputSchema: { type: 'object', properties: { tool_name: { type: 'string' }, input: { type: 'object' }, tool_use_id: { type: 'string' } }, required: ['tool_name', 'input'] } }] } });
|
|
234
|
+
else if (m.method === 'tools/call') { const a = m.params?.arguments || {}; ask(a).then(r => out({ jsonrpc: '2.0', id: m.id, result: { content: [{ type: 'text', text: JSON.stringify(r.allow ? { behavior: 'allow', updatedInput: a.input || {} } : { behavior: 'deny', message: r.message || 'The user denied this on their phone.' }) }] } })); }
|
|
235
|
+
else if (m.id !== undefined) out({ jsonrpc: '2.0', id: m.id, result: {} });
|
|
236
|
+
} });
|
|
237
|
+
},
|
|
221
238
|
connect() {
|
|
222
239
|
const announce = () => [{ id: 'root', name: 'Root', root: true }, ...readRegistry().map(({ id, name, summary }) => ({ id, name, summary }))];
|
|
223
240
|
const WebSocket = require('ws'); let c = load(); if (!c.token) return console.log('not linked yet. Run: cypher link');
|
|
224
241
|
ensureRoot();
|
|
225
|
-
const runs = new Map(); let retry = 1000;
|
|
242
|
+
const runs = new Map(); let retry = 1000; let tx = () => false;
|
|
243
|
+
// Approvals: claude -> mcp-approve -> this local server -> phone (WS) -> back. Unanswered requests are denied after 10 minutes.
|
|
244
|
+
const pending = new Map(), AKEY = require('crypto').randomBytes(24).toString('hex'); let AURL = '';
|
|
245
|
+
const summ = (t, i) => String(i.command || i.file_path || i.path || i.url || i.pattern || JSON.stringify(i)).slice(0, 400);
|
|
246
|
+
const settle = (id, allow, message) => { const p = pending.get(id); if (!p) return; pending.delete(id); clearTimeout(p.timer); p.res.end(JSON.stringify({ allow, message })); if (!allow && message) tx({ type: 'approval-closed', id, reqId: p.msg.reqId, projectId: p.msg.projectId, reason: message }); };
|
|
247
|
+
const asrv = require('http').createServer((req, res) => {
|
|
248
|
+
if (req.method !== 'POST' || req.headers['x-cypher-key'] !== AKEY) { res.statusCode = 403; return res.end('{}'); }
|
|
249
|
+
let b = ''; req.on('data', d => (b += d)); req.on('end', () => {
|
|
250
|
+
let a; try { a = JSON.parse(b); } catch { res.statusCode = 400; return res.end('{}'); }
|
|
251
|
+
const id = require('crypto').randomBytes(6).toString('hex');
|
|
252
|
+
const msg = { type: 'approval', id, reqId: a.reqId, projectId: a.projectId, tool: String(a.tool || '').slice(0, 80), summary: summ(a.tool, a.input || {}) };
|
|
253
|
+
pending.set(id, { res, msg, timer: setTimeout(() => settle(id, false, 'No answer from the phone within 10 minutes.'), 600000) });
|
|
254
|
+
console.log(`[${a.projectId}] approval asked: ${msg.tool} ${msg.summary.slice(0, 60)}`);
|
|
255
|
+
if (!tx(msg)) console.log('(phone not reachable yet; will resend when connected)');
|
|
256
|
+
});
|
|
257
|
+
}).listen(0, '127.0.0.1', () => { AURL = `http://127.0.0.1:${asrv.address().port}/approve`; });
|
|
226
258
|
const open = () => {
|
|
227
259
|
c = { ...c, ...load() };
|
|
228
260
|
const ws = new WebSocket(`${wsOf(c.server)}/agent?token=${encodeURIComponent(c.token)}`);
|
|
229
|
-
|
|
230
|
-
ws.on('open', () => { ws.opened = true; retry = 1000; tx({ type: 'hello', name: devName(), projects: announce() }); console.log(`connected as ${devName()} with Root + ${readRegistry().length} project(s). Ctrl+C to stop.`); });
|
|
261
|
+
tx = m => ws.readyState === 1 && (ws.send(JSON.stringify(m)), true);
|
|
262
|
+
ws.on('open', () => { ws.opened = true; retry = 1000; tx({ type: 'hello', name: devName(), projects: announce() }); pending.forEach(p => tx(p.msg)); console.log(`connected as ${devName()} with Root + ${readRegistry().length} project(s). Ctrl+C to stop.`); });
|
|
231
263
|
// pick up `cypher add` / `remove` while running: re-announce projects when the config changes
|
|
232
264
|
let sig = JSON.stringify(announce());
|
|
233
265
|
const watch = setInterval(() => { const a = announce(), n = JSON.stringify(a); if (n !== sig) { sig = n; tx({ type: 'hello', name: devName(), projects: a }); console.log(`projects updated: ${a.length - 1}`); } }, 2000);
|
|
@@ -237,7 +269,8 @@ const cmds = {
|
|
|
237
269
|
let m; try { m = JSON.parse(raw); } catch { return; }
|
|
238
270
|
if (m.type === 'revoked') { runs.forEach(r => r.kill('SIGINT')); dropToken('Disconnected from the app. Run cypher init to link again.'); process.exit(0); }
|
|
239
271
|
const proj = m.projectId === 'root' ? { id: 'root', path: ROOT_DIR, root: true } : readRegistry().find(p => p.id === m.projectId); const ctx = { reqId: m.reqId, projectId: m.projectId };
|
|
240
|
-
if (m.type === '
|
|
272
|
+
if (m.type === 'approve') return settle(m.id, !!m.allow, m.allow ? '' : 'The user denied this on their phone.');
|
|
273
|
+
if (m.type === 'stop') { pending.forEach((p, id) => p.msg.projectId === m.projectId && settle(id, false, 'Stopped from the phone.')); const r = runs.get(m.projectId); if (r) { r.stopped = true; r.kill('SIGINT'); } return; }
|
|
241
274
|
if (m.type === 'rmproject') { if (m.projectId !== 'root') writeRegistry(readRegistry().filter(x => x.id !== m.projectId)); return; }
|
|
242
275
|
if (m.type === 'reset') { const k = load(); if (k.sessions) { delete k.sessions[m.projectId]; save(k); } return; }
|
|
243
276
|
if (m.type !== 'prompt') return;
|
|
@@ -262,6 +295,7 @@ const cmds = {
|
|
|
262
295
|
if (saved.length) args.push('--add-dir', upDir);
|
|
263
296
|
if (typeof m.model === 'string' && /^[a-z][a-z0-9.\-\[\]]{0,60}$/i.test(m.model) && m.model !== 'default') args.push('--model', m.model);
|
|
264
297
|
if (cfg.sessions?.[proj.id]) args.push('--resume', cfg.sessions[proj.id]);
|
|
298
|
+
if (AURL) args.push('--mcp-config', JSON.stringify({ mcpServers: { cypher: { command: process.execPath, args: [path.resolve(__filename), 'mcp-approve'], env: { CYPHER_APPROVE_URL: AURL, CYPHER_APPROVE_KEY: AKEY, CYPHER_REQ: String(m.reqId || ''), CYPHER_PROJ: proj.id } } } }), '--permission-prompt-tool', 'mcp__cypher__approve');
|
|
265
299
|
console.log(`[${proj.id}] ${String(m.text || '').slice(0, 60)}${saved.length ? ' (+' + saved.length + ' files)' : ''}`);
|
|
266
300
|
const ch = spawn('claude', args, { cwd: proj.path, stdio: ['ignore', 'pipe', 'pipe'] }); runs.set(proj.id, ch);
|
|
267
301
|
let buf = '', err = '';
|
|
@@ -272,13 +306,14 @@ const cmds = {
|
|
|
272
306
|
else if (b.type === 'tool_use') { const i = b.input || {}; tx({ ...ctx, type: 'tool', name: b.name, summary: String(i.file_path || i.command || i.pattern || i.path || i.url || i.description || '').slice(0, 120) }); }
|
|
273
307
|
} else if (e.type === 'result') {
|
|
274
308
|
if (e.session_id) { const k = load(); k.sessions = { ...k.sessions, [proj.id]: e.session_id }; save(k); }
|
|
275
|
-
runs.delete(proj.id); tx({ ...ctx, type: 'done', error: !!e.is_error, cost: e.total_cost_usd });
|
|
309
|
+
ch.finished = true; runs.delete(proj.id); tx({ ...ctx, type: 'done', error: !!e.is_error, stopped: !!ch.stopped, cost: e.total_cost_usd });
|
|
276
310
|
}
|
|
277
311
|
};
|
|
278
312
|
ch.stdout.on('data', d => { buf += d; let i; while ((i = buf.indexOf('\n')) >= 0) { line(buf.slice(0, i)); buf = buf.slice(i + 1); } });
|
|
279
313
|
ch.stderr.on('data', d => (err += d));
|
|
280
|
-
ch.on('error', e => { runs.delete(proj.id); tx({ ...ctx, type: 'error', message: e.code === 'ENOENT' ? 'Claude Code is not installed on that machine.' : e.message }); });
|
|
281
|
-
ch.on('close', code => { runs.delete(proj.id);
|
|
314
|
+
ch.on('error', e => { ch.finished = true; runs.delete(proj.id); tx({ ...ctx, type: 'error', message: e.code === 'ENOENT' ? 'Claude Code is not installed on that machine.' : e.message }); });
|
|
315
|
+
ch.on('close', code => { runs.delete(proj.id); pending.forEach((p, id) => p.msg.reqId === m.reqId && settle(id, false, 'Run ended.')); if (ch.finished) return;
|
|
316
|
+
if (ch.stopped) tx({ ...ctx, type: 'done', stopped: true }); else tx({ ...ctx, type: 'error', message: (err.trim().split('\n').pop() || 'claude exited ' + code).slice(0, 200) }); });
|
|
282
317
|
});
|
|
283
318
|
ws.on('close', () => { console.log('disconnected, retrying...'); if (!ws.opened) console.log('(if this keeps failing with 401, the computer was removed: pair again)'); setTimeout(open, retry); retry = Math.min(retry * 2, 15000); });
|
|
284
319
|
ws.on('error', e => console.log('connection error: ' + e.message));
|