project-cypher 0.15.0 → 0.16.1
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 +49 -7
- package/package.json +1 -1
package/bin/cypher.js
CHANGED
|
@@ -218,16 +218,55 @@ 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: r.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 pendingInput = new Map(), pending = new Map(), AKEY = require('crypto').randomBytes(24).toString('hex'); let AURL = '';
|
|
245
|
+
const summ = (t, i) => {
|
|
246
|
+
if (i.command) return String(i.command).slice(0, 600);
|
|
247
|
+
const head = i.file_path || i.path || i.url || i.pattern || '';
|
|
248
|
+
const rest = Object.entries(i).filter(([k, v]) => !['file_path', 'path', 'url', 'pattern', 'description'].includes(k) && v != null && typeof v !== 'object').map(([k, v]) => `${k}: ${String(v).slice(0, 160)}`);
|
|
249
|
+
return [head, ...rest].filter(Boolean).join('\n').slice(0, 600) || (i.description || '');
|
|
250
|
+
};
|
|
251
|
+
const settle = (id, allow, message, answers) => { const p = pending.get(id); if (!p) return; pending.delete(id); clearTimeout(p.timer); const inp = pendingInput.get(id) || {}; pendingInput.delete(id); p.res.end(JSON.stringify({ allow, message, updatedInput: answers && typeof answers === 'object' ? { ...inp, answers } : undefined })); if (!allow && message) tx({ type: 'approval-closed', id, reqId: p.msg.reqId, projectId: p.msg.projectId, reason: message }); };
|
|
252
|
+
const asrv = require('http').createServer((req, res) => {
|
|
253
|
+
if (req.method !== 'POST' || req.headers['x-cypher-key'] !== AKEY) { res.statusCode = 403; return res.end('{}'); }
|
|
254
|
+
let b = ''; req.on('data', d => (b += d)); req.on('end', () => {
|
|
255
|
+
let a; try { a = JSON.parse(b); } catch { res.statusCode = 400; return res.end('{}'); }
|
|
256
|
+
const id = require('crypto').randomBytes(6).toString('hex');
|
|
257
|
+
const msg = { type: 'approval', id, reqId: a.reqId, projectId: a.projectId, tool: String(a.tool || '').slice(0, 80), summary: summ(a.tool, a.input || {}) };
|
|
258
|
+
if (a.tool === 'AskUserQuestion' && Array.isArray(a.input?.questions)) msg.questions = a.input.questions.slice(0, 4).map(q => ({ question: String(q.question || '').slice(0, 500), header: String(q.header || '').slice(0, 60), multiSelect: !!q.multiSelect, options: (q.options || []).slice(0, 6).map(o => ({ label: String(o.label || '').slice(0, 120), description: String(o.description || '').slice(0, 240) })) }));
|
|
259
|
+
pendingInput.set(id, a.input || {});
|
|
260
|
+
pending.set(id, { res, msg, timer: setTimeout(() => settle(id, false, 'No answer from the phone within 10 minutes.'), 600000) });
|
|
261
|
+
console.log(`[${a.projectId}] approval asked: ${msg.tool} ${msg.summary.slice(0, 60)}`);
|
|
262
|
+
if (!tx(msg)) console.log('(phone not reachable yet; will resend when connected)');
|
|
263
|
+
});
|
|
264
|
+
}).listen(0, '127.0.0.1', () => { AURL = `http://127.0.0.1:${asrv.address().port}/approve`; });
|
|
226
265
|
const open = () => {
|
|
227
266
|
c = { ...c, ...load() };
|
|
228
267
|
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.`); });
|
|
268
|
+
tx = m => ws.readyState === 1 && (ws.send(JSON.stringify(m)), true);
|
|
269
|
+
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
270
|
// pick up `cypher add` / `remove` while running: re-announce projects when the config changes
|
|
232
271
|
let sig = JSON.stringify(announce());
|
|
233
272
|
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 +276,8 @@ const cmds = {
|
|
|
237
276
|
let m; try { m = JSON.parse(raw); } catch { return; }
|
|
238
277
|
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
278
|
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 === '
|
|
279
|
+
if (m.type === 'approve') return settle(m.id, !!m.allow, m.allow ? '' : (m.answers ? '' : 'The user denied this on their phone.'), m.allow && m.answers && typeof m.answers === 'object' ? Object.fromEntries(Object.entries(m.answers).slice(0, 8).map(([k, v]) => [String(k).slice(0, 500), String(v).slice(0, 1000)])) : undefined);
|
|
280
|
+
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
281
|
if (m.type === 'rmproject') { if (m.projectId !== 'root') writeRegistry(readRegistry().filter(x => x.id !== m.projectId)); return; }
|
|
242
282
|
if (m.type === 'reset') { const k = load(); if (k.sessions) { delete k.sessions[m.projectId]; save(k); } return; }
|
|
243
283
|
if (m.type !== 'prompt') return;
|
|
@@ -262,6 +302,7 @@ const cmds = {
|
|
|
262
302
|
if (saved.length) args.push('--add-dir', upDir);
|
|
263
303
|
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
304
|
if (cfg.sessions?.[proj.id]) args.push('--resume', cfg.sessions[proj.id]);
|
|
305
|
+
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
306
|
console.log(`[${proj.id}] ${String(m.text || '').slice(0, 60)}${saved.length ? ' (+' + saved.length + ' files)' : ''}`);
|
|
266
307
|
const ch = spawn('claude', args, { cwd: proj.path, stdio: ['ignore', 'pipe', 'pipe'] }); runs.set(proj.id, ch);
|
|
267
308
|
let buf = '', err = '';
|
|
@@ -272,13 +313,14 @@ const cmds = {
|
|
|
272
313
|
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
314
|
} else if (e.type === 'result') {
|
|
274
315
|
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 });
|
|
316
|
+
ch.finished = true; runs.delete(proj.id); tx({ ...ctx, type: 'done', error: !!e.is_error, stopped: !!ch.stopped, cost: e.total_cost_usd });
|
|
276
317
|
}
|
|
277
318
|
};
|
|
278
319
|
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
320
|
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);
|
|
321
|
+
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 }); });
|
|
322
|
+
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;
|
|
323
|
+
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
324
|
});
|
|
283
325
|
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
326
|
ws.on('error', e => console.log('connection error: ' + e.message));
|