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.
Files changed (2) hide show
  1. package/bin/cypher.js +49 -7
  2. 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
- const tx = m => ws.readyState === 1 && ws.send(JSON.stringify(m));
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 === 'stop') return runs.get(m.projectId)?.kill('SIGINT');
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); if (code && !err.includes('SIGINT')) tx({ ...ctx, type: 'error', message: (err.trim().split('\n').pop() || 'claude exited ' + code).slice(0, 200) }); });
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));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-cypher",
3
- "version": "0.15.0",
3
+ "version": "0.16.1",
4
4
  "description": "Connect your machine's Claude Code projects to the Project Cypher phone app.",
5
5
  "bin": {
6
6
  "cypher": "bin/cypher.js"