project-cypher 0.14.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 +45 -9
  2. package/package.json +2 -2
package/bin/cypher.js CHANGED
@@ -11,7 +11,8 @@ function readRegistry() {
11
11
  .map(p => ({ id: p.id, name: String(p.name || p.id).slice(0, 60), path: p.path, summary: String(p.summary || '').slice(0, 300) }));
12
12
  }
13
13
  const writeRegistry = list => { fs.mkdirSync(ROOT_DIR, { recursive: true }); fs.writeFileSync(REG, JSON.stringify(list, null, 2)); };
14
- const rawCfg = () => { try { return JSON.parse(fs.readFileSync(CFG, 'utf8')); } catch { return { server: 'http://140.238.248.30:3300', token: '', sessions: {}, permissionMode: 'acceptEdits' }; } };
14
+ const SERVER = 'https://hub.reubenreny.in/cypher', OLD_SERVERS = ['http://140.238.248.30:3300'];
15
+ const rawCfg = () => { try { const c = JSON.parse(fs.readFileSync(CFG, 'utf8')); if (!c.server || OLD_SERVERS.includes(String(c.server).replace(/\/$/, ''))) c.server = SERVER; return c; } catch { return { server: SERVER, token: '', sessions: {}, permissionMode: 'acceptEdits' }; } };
15
16
  const load = () => { const c = rawCfg(); if (c.projects?.length && !fs.existsSync(REG)) writeRegistry(c.projects); delete c.projects; return { ...c, projects: readRegistry() }; };
16
17
  const save = c => { const { projects, ...rest } = c; fs.mkdirSync(CFG_DIR, { recursive: true }); fs.writeFileSync(CFG, JSON.stringify(rest, null, 2), { mode: 0o600 }); };
17
18
  const ROOT_TEMPLATE = (machine, ws) => `# Root
@@ -199,7 +200,7 @@ const cmds = {
199
200
  console.log(`This fully removes Cypher from this computer:\n - unlinks it from your phone app\n - stops and deletes the background service\n - deletes ${CFG_DIR} (login, project list, Root notes, chat sessions)\nYour actual project folders are NOT touched.`);
200
201
  if (id !== '--yes' && !rest.includes('--yes') && !yes(await ask('Continue? [y/N] '), false)) return console.log('cancelled.');
201
202
  const c = rawCfg();
202
- if (c.token) await fetch(httpOf(c.server || 'http://140.238.248.30:3300') + '/unlink', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ token: c.token }) }).then(() => console.log('[ok] unlinked from the app')).catch(() => console.log('[!!] could not reach the server; remove this computer from the app Projects list'));
203
+ if (c.token) await fetch(httpOf(c.server || SERVER) + '/unlink', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ token: c.token }) }).then(() => console.log('[ok] unlinked from the app')).catch(() => console.log('[!!] could not reach the server; remove this computer from the app Projects list'));
203
204
  SVC.remove(); console.log('[ok] background service removed');
204
205
  fs.rmSync(CFG_DIR, { recursive: true, force: true }); console.log('[ok] deleted ' + CFG_DIR);
205
206
  console.log('\nClean. To reinstall: npm i -g project-cypher@latest && cypher init\nTo also remove the program itself: npm uninstall -g project-cypher');
@@ -217,16 +218,48 @@ const cmds = {
217
218
  logs() { try { console.log(fs.readFileSync(LOG, 'utf8').split('\n').slice(-40).join('\n')); } catch { console.log('no log yet'); } },
218
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}`); },
219
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
+ },
220
238
  connect() {
221
239
  const announce = () => [{ id: 'root', name: 'Root', root: true }, ...readRegistry().map(({ id, name, summary }) => ({ id, name, summary }))];
222
240
  const WebSocket = require('ws'); let c = load(); if (!c.token) return console.log('not linked yet. Run: cypher link');
223
241
  ensureRoot();
224
- 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`; });
225
258
  const open = () => {
226
259
  c = { ...c, ...load() };
227
260
  const ws = new WebSocket(`${wsOf(c.server)}/agent?token=${encodeURIComponent(c.token)}`);
228
- const tx = m => ws.readyState === 1 && ws.send(JSON.stringify(m));
229
- 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.`); });
230
263
  // pick up `cypher add` / `remove` while running: re-announce projects when the config changes
231
264
  let sig = JSON.stringify(announce());
232
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);
@@ -236,7 +269,8 @@ const cmds = {
236
269
  let m; try { m = JSON.parse(raw); } catch { return; }
237
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); }
238
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 };
239
- 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; }
240
274
  if (m.type === 'rmproject') { if (m.projectId !== 'root') writeRegistry(readRegistry().filter(x => x.id !== m.projectId)); return; }
241
275
  if (m.type === 'reset') { const k = load(); if (k.sessions) { delete k.sessions[m.projectId]; save(k); } return; }
242
276
  if (m.type !== 'prompt') return;
@@ -261,6 +295,7 @@ const cmds = {
261
295
  if (saved.length) args.push('--add-dir', upDir);
262
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);
263
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');
264
299
  console.log(`[${proj.id}] ${String(m.text || '').slice(0, 60)}${saved.length ? ' (+' + saved.length + ' files)' : ''}`);
265
300
  const ch = spawn('claude', args, { cwd: proj.path, stdio: ['ignore', 'pipe', 'pipe'] }); runs.set(proj.id, ch);
266
301
  let buf = '', err = '';
@@ -271,13 +306,14 @@ const cmds = {
271
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) }); }
272
307
  } else if (e.type === 'result') {
273
308
  if (e.session_id) { const k = load(); k.sessions = { ...k.sessions, [proj.id]: e.session_id }; save(k); }
274
- 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 });
275
310
  }
276
311
  };
277
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); } });
278
313
  ch.stderr.on('data', d => (err += d));
279
- 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 }); });
280
- 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) }); });
281
317
  });
282
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); });
283
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.14.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"
@@ -13,7 +13,7 @@
13
13
  },
14
14
  "dependencies": {
15
15
  "qrcode-terminal": "^0.12.0",
16
- "ws": "^8.18.0", "qrcode-terminal": "^0.12.0"
16
+ "ws": "^8.18.0"
17
17
  },
18
18
  "keywords": [
19
19
  "claude",