project-cypher 0.17.2 → 0.20.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 +77 -5
- package/package.json +1 -1
package/bin/cypher.js
CHANGED
|
@@ -15,6 +15,29 @@ const SERVER = 'https://hub.reubenreny.in/cypher', OLD_SERVERS = ['http://140.23
|
|
|
15
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' }; } };
|
|
16
16
|
const load = () => { const c = rawCfg(); if (c.projects?.length && !fs.existsSync(REG)) writeRegistry(c.projects); delete c.projects; return { ...c, projects: readRegistry() }; };
|
|
17
17
|
const save = c => { const { projects, ...rest } = c; fs.mkdirSync(CFG_DIR, { recursive: true }); fs.writeFileSync(CFG, JSON.stringify(rest, null, 2), { mode: 0o600 }); };
|
|
18
|
+
// Claude Code's own session files for a folder: <claude config>/projects/<folder path with non-alphanumerics as '-'>/<sessionId>.jsonl (what `claude -r` lists).
|
|
19
|
+
const sessDir = cwd => path.join(process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'), 'projects', path.resolve(cwd).replace(/[^a-zA-Z0-9]/g, '-'));
|
|
20
|
+
const SID = /^[0-9a-f-]{36}$/i;
|
|
21
|
+
const jl = fp => fs.readFileSync(fp, 'utf8').split('\n').map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
22
|
+
const userText = c => (typeof c === 'string' ? c : (Array.isArray(c) ? c.filter(b => b.type === 'text').map(b => b.text).join('\n') : '')).trim();
|
|
23
|
+
const chatLines = es => { // plain user/assistant text only (no tool noise, no sidechains, no slash-command plumbing)
|
|
24
|
+
const out = [];
|
|
25
|
+
for (const e of es) {
|
|
26
|
+
if (e.isSidechain || e.isMeta || (e.type !== 'user' && e.type !== 'assistant')) continue;
|
|
27
|
+
const t = userText(e.message?.content); if (!t || /^<(command-|local-command|system-reminder)/.test(t)) continue;
|
|
28
|
+
const role = e.type === 'user' ? 'user' : 'bot';
|
|
29
|
+
if (role === 'bot' && out.length && out[out.length - 1].role === 'bot') out[out.length - 1].text += '\n\n' + t; else out.push({ role, text: t.slice(0, 20000) });
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
};
|
|
33
|
+
const listSessions = (cwd, current) => {
|
|
34
|
+
const d = sessDir(cwd); let files = []; try { files = fs.readdirSync(d).filter(f => f.endsWith('.jsonl')).map(f => ({ id: f.slice(0, -6), ts: fs.statSync(path.join(d, f)).mtimeMs })); } catch { return []; }
|
|
35
|
+
return files.filter(f => SID.test(f.id)).sort((a, b) => b.ts - a.ts).slice(0, 30).map(f => {
|
|
36
|
+
let title = '', n = 0; try { const es = jl(path.join(d, f.id + '.jsonl')); for (const e of es) { if (e.type === 'custom-title' && e.customTitle) title = e.customTitle; else if (e.type === 'ai-title' && e.aiTitle && !title) title = e.aiTitle; }
|
|
37
|
+
const chat = chatLines(es); n = chat.length; if (!title) title = chat.find(x => x.role === 'user')?.text || ''; } catch {}
|
|
38
|
+
return n ? { id: f.id, title: title.replace(/\s+/g, ' ').slice(0, 90), ts: f.ts, n, current: f.id === current } : null;
|
|
39
|
+
}).filter(Boolean);
|
|
40
|
+
};
|
|
18
41
|
const ROOT_TEMPLATE = (machine, ws) => `# Root
|
|
19
42
|
|
|
20
43
|
You are **Root**, the control assistant for this computer (${machine}). You talk to the user through the Project Cypher phone app. You are not a project and you do not build things yourself: projects have their own chats.
|
|
@@ -210,12 +233,37 @@ const cmds = {
|
|
|
210
233
|
projects() { const c = load(); if (!c.projects.length) return console.log('none yet. cypher add <folder>'); c.projects.forEach(p => console.log(`${p.id.padEnd(20)} ${p.path}`)); },
|
|
211
234
|
// Service control
|
|
212
235
|
start() { const c = load(); if (!c.token) return console.log('Not linked yet. Run: cypher init'); console.log(SVC.start() ? 'Cypher is running in the background and will start automatically when this computer turns on.' : 'Could not start the background service. Run it in this window instead: cypher connect'); },
|
|
236
|
+
restart() { const c = load(); if (!c.token) return console.log('Not linked yet. Run: cypher init'); console.log(SVC.start() ? 'Cypher restarted in the background.' : 'Could not restart the background service. Run it in this window instead: cypher connect'); },
|
|
213
237
|
stop() { console.log(SVC.stop() ? 'Cypher stopped and auto-start turned off. Run cypher start to bring it back.' : 'Nothing to stop (or the service is not installed).'); },
|
|
214
238
|
async stats() {
|
|
215
239
|
const c = load(); const r = await fetch(httpOf(c.server) + '/health').then(r => r.json()).catch(() => null);
|
|
216
240
|
console.log(`service: ${SVC.running() ? 'running' : 'stopped'}${SVC.enabled() ? ' (starts automatically)' : ' (auto-start off)'}\ninternet/server: ${r?.ok ? 'reachable' : 'unreachable'} ${c.server}\nlinked: ${c.token ? 'yes, as "' + devName() + '"' : 'no (run cypher init)'}\nprojects: ${c.projects.length}\nlog: ${LOG}`);
|
|
217
241
|
try { const t = fs.readFileSync(LOG, 'utf8').trim().split('\n').slice(-3).join('\n'); if (t) console.log('\nrecent:\n' + t); } catch {}
|
|
218
242
|
},
|
|
243
|
+
version() { console.log(require('../package.json').version); },
|
|
244
|
+
update() { // npm install -g project-cypher@latest, then restart the background service
|
|
245
|
+
const cur = require('../package.json').version; const here = fs.realpathSync(__filename);
|
|
246
|
+
if (!here.includes(path.sep + 'node_modules' + path.sep)) return console.log(`This copy (${cur}) runs from a source folder (${path.dirname(path.dirname(here))}), not from npm. Update it with git pull instead.`);
|
|
247
|
+
const q = require('child_process').spawnSync('npm', ['view', 'project-cypher', 'version', '--prefer-online'], { encoding: 'utf8', shell: process.platform === 'win32' });
|
|
248
|
+
const latest = (q.stdout || '').trim(); if (q.status !== 0 || !latest) return console.log('Could not reach npm to check for updates.');
|
|
249
|
+
const n = v => v.split('.').map(Number); const [x, y] = [n(latest), n(cur)]; const newer = x[0] - y[0] || x[1] - y[1] || x[2] - y[2];
|
|
250
|
+
if (newer <= 0) return console.log(`Already up to date (${cur}).`);
|
|
251
|
+
console.log(`Updating ${cur} -> ${latest} ...`);
|
|
252
|
+
const r = require('child_process').spawnSync('npm', ['i', '-g', 'project-cypher@latest', '--prefer-online'], { stdio: 'inherit', shell: process.platform === 'win32' });
|
|
253
|
+
if (r.status !== 0) return console.log('Update failed. Try: npm i -g project-cypher@latest (you may need sudo)');
|
|
254
|
+
if (SVC.running()) { SVC.stop(); SVC.start(); console.log('Background service restarted on the new version.'); }
|
|
255
|
+
console.log(`Now on ${latest}.`);
|
|
256
|
+
},
|
|
257
|
+
resume(which) { // continue the phone's Claude session for a project in the terminal (claude -r hides sessions made by claude -p)
|
|
258
|
+
const c = load(); const here = path.resolve('.');
|
|
259
|
+
const proj = which ? (which === 'root' ? { id: 'root', path: ROOT_DIR } : c.projects.find(p => p.id === which)) : c.projects.find(p => path.resolve(p.path) === here);
|
|
260
|
+
if (!proj) return console.log(which ? `No project "${which}". See: cypher projects` : 'This folder is not a Cypher project. Run: cypher resume <project-id> (ids: cypher projects)');
|
|
261
|
+
const id = c.sessions?.[proj.id]; if (!id) return console.log(`No phone session for ${proj.id} yet. Send a message from the app first.`);
|
|
262
|
+
console.log(`Resuming ${proj.id} (${id.slice(0, 8)}) in ${proj.path}`);
|
|
263
|
+
const r = require('child_process').spawnSync('claude', ['--resume', id], { cwd: proj.path, stdio: 'inherit' });
|
|
264
|
+
if (r.error) console.log(r.error.code === 'ENOENT' ? 'Claude Code is not installed here.' : r.error.message);
|
|
265
|
+
process.exit(r.status || 0);
|
|
266
|
+
},
|
|
219
267
|
logs() { try { console.log(fs.readFileSync(LOG, 'utf8').split('\n').slice(-40).join('\n')); } catch { console.log('no log yet'); } },
|
|
220
268
|
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}`); },
|
|
221
269
|
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}`); },
|
|
@@ -239,6 +287,7 @@ const cmds = {
|
|
|
239
287
|
connect() {
|
|
240
288
|
const autoOn = id => !!rawCfg().autoApprove?.[id];
|
|
241
289
|
const announce = () => [{ id: 'root', name: 'Root', root: true, auto: autoOn('root') }, ...readRegistry().map(({ id, name, summary }) => ({ id, name, summary, auto: autoOn(id) }))];
|
|
290
|
+
const hello = () => ({ type: 'hello', ver: require('../package.json').version, npm: fs.realpathSync(__filename).includes(path.sep + 'node_modules' + path.sep), name: devName(), projects: announce(), running: [...runs].map(([projectId, ch]) => ({ projectId, reqId: ch.reqId })) }); // running: lets the relay know which prompts are still in progress after a reconnect
|
|
242
291
|
const WebSocket = require('ws'); let c = load(); if (!c.token) return console.log('not linked yet. Run: cypher link');
|
|
243
292
|
ensureRoot();
|
|
244
293
|
const runs = new Map(); let retry = 1000; let tx = () => false;
|
|
@@ -274,10 +323,10 @@ const cmds = {
|
|
|
274
323
|
c = { ...c, ...load() };
|
|
275
324
|
const ws = new WebSocket(`${wsOf(c.server)}/agent?token=${encodeURIComponent(c.token)}`);
|
|
276
325
|
tx = m => ws.readyState === 1 && (ws.send(JSON.stringify(m)), true);
|
|
277
|
-
ws.on('open', () => { ws.opened = true; retry = 1000; tx(
|
|
326
|
+
ws.on('open', () => { ws.opened = true; retry = 1000; tx(hello()); pending.forEach(p => tx(p.msg)); console.log(`connected as ${devName()} with Root + ${readRegistry().length} project(s). Ctrl+C to stop.`); });
|
|
278
327
|
// pick up `cypher add` / `remove` while running: re-announce projects when the config changes
|
|
279
328
|
let sig = JSON.stringify(announce());
|
|
280
|
-
const watch = setInterval(() => { const a = announce(), n = JSON.stringify(a); if (n !== sig) { sig = n; tx(
|
|
329
|
+
const watch = setInterval(() => { const a = announce(), n = JSON.stringify(a); if (n !== sig) { sig = n; tx(hello()); console.log(`projects updated: ${a.length - 1}`); } }, 2000);
|
|
281
330
|
// One reconnect per socket, whatever ended it (close, 502 from the proxy, dead connection).
|
|
282
331
|
let lastPong = Date.now(); ws.on('pong', () => (lastPong = Date.now())); ws.on('ping', () => (lastPong = Date.now())); ws.on('message', () => (lastPong = Date.now()));
|
|
283
332
|
const hb = setInterval(() => { if (ws.readyState !== 1) return; if (Date.now() - lastPong > 70000) { console.log('no answer from the server for 70s, reconnecting'); ws.terminate(); return again(); } try { ws.ping(); } catch {} }, 25000);
|
|
@@ -290,8 +339,29 @@ const cmds = {
|
|
|
290
339
|
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);
|
|
291
340
|
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; }
|
|
292
341
|
if (m.type === 'rmproject') { if (m.projectId !== 'root') writeRegistry(readRegistry().filter(x => x.id !== m.projectId)); return; }
|
|
293
|
-
if (m.type === 'autoapprove') { const k = rawCfg(); k.autoApprove = { ...(k.autoApprove || {}) }; if (m.on) k.autoApprove[m.projectId] = true; else delete k.autoApprove[m.projectId]; save(k); console.log(`[${m.projectId}] auto-approve ${m.on ? 'ON' : 'off'}`); return tx(
|
|
342
|
+
if (m.type === 'autoapprove') { const k = rawCfg(); k.autoApprove = { ...(k.autoApprove || {}) }; if (m.on) k.autoApprove[m.projectId] = true; else delete k.autoApprove[m.projectId]; save(k); console.log(`[${m.projectId}] auto-approve ${m.on ? 'ON' : 'off'}`); return tx(hello()); }
|
|
294
343
|
if (m.type === 'reset') { const k = load(); if (k.sessions) { delete k.sessions[m.projectId]; save(k); } return; }
|
|
344
|
+
if (m.type === 'update') { // phone asked this computer to update itself (fixed npm command only)
|
|
345
|
+
const say = (ok, message) => tx({ type: 'updated', ok, message });
|
|
346
|
+
const cur = require('../package.json').version;
|
|
347
|
+
if (!fs.realpathSync(__filename).includes(path.sep + 'node_modules' + path.sep)) return say(false, 'This copy runs from a source folder. Update it with git pull.');
|
|
348
|
+
if (runs.size) return say(false, 'Claude is working. Try again when it finishes.');
|
|
349
|
+
const cp = require('child_process'); const win = process.platform === 'win32';
|
|
350
|
+
cp.execFile('npm', ['i', '-g', 'project-cypher@latest', '--prefer-online'], { shell: win, timeout: 300000 }, (err, so, se) => {
|
|
351
|
+
if (err) return say(false, ('Update failed: ' + (String(se).trim().split('\n').filter(Boolean).pop() || err.message)).slice(0, 200));
|
|
352
|
+
say(true, 'Updated. Restarting.'); console.log('updated from ' + cur + ' via the app');
|
|
353
|
+
setTimeout(() => { if (SVC.running()) SVC.start(); else process.exit(0); }, 800);
|
|
354
|
+
});
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (m.type === 'sessions') { if (!proj) return; return tx({ ...ctx, type: 'sessions', list: listSessions(proj.path, load().sessions?.[proj.id]) }); }
|
|
358
|
+
if (m.type === 'usesession') { // switch the project to an older Claude session and send its chat back
|
|
359
|
+
if (!proj || !SID.test(String(m.id))) return tx({ ...ctx, type: 'error', message: 'Unknown session.' });
|
|
360
|
+
if (runs.has(proj.id)) return tx({ ...ctx, type: 'error', message: 'Claude is working on this project. Stop it first.' });
|
|
361
|
+
let msgs; try { msgs = chatLines(jl(path.join(sessDir(proj.path), m.id + '.jsonl'))).slice(-80); } catch { return tx({ ...ctx, type: 'error', message: 'That session file is gone.' }); }
|
|
362
|
+
const k = load(); k.sessions = { ...k.sessions, [proj.id]: m.id }; save(k);
|
|
363
|
+
return tx({ ...ctx, type: 'sessionhist', id: m.id, msgs });
|
|
364
|
+
}
|
|
295
365
|
if (m.type !== 'prompt') return;
|
|
296
366
|
if (!proj) return tx({ ...ctx, type: 'error', message: 'unknown project' });
|
|
297
367
|
if (runs.has(proj.id)) return tx({ ...ctx, type: 'error', message: 'Claude is still working on this project.' });
|
|
@@ -316,7 +386,7 @@ const cmds = {
|
|
|
316
386
|
if (cfg.sessions?.[proj.id]) args.push('--resume', cfg.sessions[proj.id]);
|
|
317
387
|
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');
|
|
318
388
|
console.log(`[${proj.id}] ${String(m.text || '').slice(0, 60)}${saved.length ? ' (+' + saved.length + ' files)' : ''}`);
|
|
319
|
-
const ch = spawn('claude', args, { cwd: proj.path, stdio: ['ignore', 'pipe', 'pipe'] }); runs.set(proj.id, ch);
|
|
389
|
+
const ch = spawn('claude', args, { cwd: proj.path, stdio: ['ignore', 'pipe', 'pipe'] }); ch.reqId = m.reqId; runs.set(proj.id, ch);
|
|
320
390
|
let buf = '', err = '';
|
|
321
391
|
const line = l => {
|
|
322
392
|
let e; try { e = JSON.parse(l); } catch { return; }
|
|
@@ -350,7 +420,9 @@ const help = () => console.log(`Project Cypher
|
|
|
350
420
|
cypher remove FULL removal (unlink, service, all local data) for a clean reinstall
|
|
351
421
|
cypher disconnect unlink this computer from the app
|
|
352
422
|
cypher connect go online in this window (no background service)
|
|
353
|
-
cypher start | stop
|
|
423
|
+
cypher start | stop | restart start / stop / restart the background service (auto-starts at boot)
|
|
424
|
+
cypher resume [project-id] continue the phone's Claude session for this folder in the terminal
|
|
425
|
+
cypher update | version install the newest CLI from npm and restart the service / show version
|
|
354
426
|
cypher stats | logs service + connection status / recent log
|
|
355
427
|
cypher status | latest server check / latest app build
|
|
356
428
|
|