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.
- package/bin/cypher.js +45 -9
- 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
|
|
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 ||
|
|
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
|
-
|
|
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 === '
|
|
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);
|
|
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.
|
|
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"
|
|
16
|
+
"ws": "^8.18.0"
|
|
17
17
|
},
|
|
18
18
|
"keywords": [
|
|
19
19
|
"claude",
|