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.
Files changed (2) hide show
  1. package/bin/cypher.js +42 -7
  2. 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
- 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.`); });
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 === 'stop') return runs.get(m.projectId)?.kill('SIGINT');
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); if (code && !err.includes('SIGINT')) tx({ ...ctx, type: 'error', message: (err.trim().split('\n').pop() || 'claude exited ' + code).slice(0, 200) }); });
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));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-cypher",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
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"