project-cypher 0.1.0 → 0.6.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 +156 -13
  2. package/package.json +21 -6
package/bin/cypher.js CHANGED
@@ -1,17 +1,160 @@
1
1
  #!/usr/bin/env node
2
- // project-cypher CLI. Server: $CYPHER_SERVER (default http://140.238.248.30:3300)
3
- const SERVER = (process.env.CYPHER_SERVER || 'http://140.238.248.30:3300').replace(/\/$/, '');
4
- const get = async p => { const r = await fetch(SERVER + p); if (!r.ok) throw new Error(`${p} returned ${r.status}`); return r.json(); };
2
+ // project-cypher: runs on your computer, exposes chosen folders to the Cypher phone app and runs Claude Code in them.
3
+ const fs = require('fs'), os = require('os'), path = require('path'), { spawn } = require('child_process');
4
+ const CFG_DIR = path.join(os.homedir(), '.cypher'), CFG = path.join(CFG_DIR, 'config.json');
5
+ const load = () => { try { return JSON.parse(fs.readFileSync(CFG, 'utf8')); } catch { return { server: 'http://140.238.248.30:3300', token: '', projects: [], sessions: {}, permissionMode: 'acceptEdits' }; } };
6
+ const save = c => { fs.mkdirSync(CFG_DIR, { recursive: true }); fs.writeFileSync(CFG, JSON.stringify(c, null, 2), { mode: 0o600 }); };
7
+ const slug = p => path.basename(p).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'project';
8
+ const httpOf = s => s.replace(/\/$/, ''), wsOf = s => httpOf(s).replace(/^http/, 'ws');
9
+
10
+ const ask = q => new Promise(r => { const rl = require('readline').createInterface({ input: process.stdin, output: process.stdout }); rl.question(q, a => { rl.close(); r(a.trim()); }); });
11
+ const yes = (a, d) => (a === '' ? d : /^y/i.test(a));
12
+ const has = (cmd, args = ['--version']) => { const r = require('child_process').spawnSync(cmd, args, { encoding: 'utf8' }); return r.error ? null : (r.stdout || r.stderr || '').trim().split('\n')[0]; };
13
+ async function doLink(server) {
14
+ const c = load(); const base = httpOf(server || c.server);
15
+ const r = await fetch(base + '/link/start', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: os.hostname() }) }).catch(() => null);
16
+ if (!r || !r.ok) { console.log('cannot reach ' + base); process.exit(1); }
17
+ const { code, linkId } = await r.json();
18
+ console.log('\nOpen Project Cypher on your phone and scan this, or type the code.\n');
19
+ require('qrcode-terminal').generate(`cypher:${base}|${code}`, { small: true });
20
+ console.log(`\n code: ${code.slice(0, 3)} ${code.slice(3)} (valid 10 minutes)\n\nWaiting for the app...`);
21
+ for (;;) {
22
+ const w = await fetch(`${base}/link/wait/${linkId}`).catch(() => null);
23
+ if (!w) { await new Promise(r => setTimeout(r, 2000)); continue; }
24
+ if (w.status === 404) { console.log('code expired. Run again.'); process.exit(1); }
25
+ const j = await w.json(); if (j.pending) continue;
26
+ const k = load(); k.server = base; k.token = j.token; k.machineId = j.machineId; save(k); console.log('linked.'); return;
27
+ }
28
+ }
5
29
  const cmds = {
6
- async status() { const [h, l] = await Promise.all([get('/health'), get('/latest')]); console.log(`server ${SERVER}: ${h.ok ? 'online' : 'down'}\nlatest build: ${l.versionName} (code ${l.versionCode})`); },
7
- async latest() { const l = await get('/latest'); console.log(`${l.versionName} (code ${l.versionCode})\n${l.apkUrl}${l.notes ? '\n' + l.notes : ''}`); },
8
- async download() {
9
- const l = await get('/latest'), fs = require('fs'), out = `cypher-v${l.versionCode}.apk`;
10
- const r = await fetch(l.apkUrl); if (!r.ok) throw new Error('download failed ' + r.status);
11
- fs.writeFileSync(out, Buffer.from(await r.arrayBuffer())); console.log('saved ' + out);
30
+ // First-run setup: checks Claude Code, reaches the server, shows the QR, adds this folder, goes online.
31
+ async init(...argv) {
32
+ const si = argv.indexOf('--server'); const server = si >= 0 ? argv[si + 1] : undefined; const c = load(); const base = httpOf(server || c.server);
33
+ console.log('\nProject Cypher setup\n');
34
+ const nodeMajor = +process.versions.node.split('.')[0];
35
+ console.log(nodeMajor >= 18 ? `[ok] Node ${process.versions.node}` : `[!!] Node ${process.versions.node}: need 18 or newer`); if (nodeMajor < 18) process.exit(1);
36
+ let cv = has('claude');
37
+ if (cv) console.log(`[ok] Claude Code ${cv}`);
38
+ else {
39
+ console.log('[!!] Claude Code is not installed.');
40
+ if (yes(await ask('Install it now with npm (npm i -g @anthropic-ai/claude-code)? [Y/n] '), true)) {
41
+ const r = require('child_process').spawnSync('npm', ['i', '-g', '@anthropic-ai/claude-code'], { stdio: 'inherit' });
42
+ cv = has('claude'); if (r.status !== 0 || !cv) { console.log('Install failed. See https://docs.claude.com/claude-code and run cypher init again.'); process.exit(1); }
43
+ console.log(`[ok] Claude Code ${cv}`);
44
+ } else { console.log('Install Claude Code, then run cypher init again.'); process.exit(1); }
45
+ }
46
+ const signedIn = ['.claude/.credentials.json', '.claude.json'].some(f => { try { return fs.readFileSync(path.join(os.homedir(), f), 'utf8').match(/oauthAccount|accessToken|claudeAiOauth/); } catch { return false; } }) || !!process.env.ANTHROPIC_API_KEY;
47
+ console.log(signedIn ? '[ok] Claude Code looks signed in' : '[??] Could not tell if Claude Code is signed in. If chats fail, run "claude" once and log in.');
48
+ const h = await fetch(base + '/health').then(r => r.json()).catch(() => null);
49
+ if (!h?.ok) { console.log(`[!!] Cannot reach the server ${base}. Check your internet (outbound port may be blocked).`); process.exit(1); }
50
+ console.log(`[ok] Server ${base}`);
51
+ if (c.token) { const again = await ask('This computer is already linked. Link again? [y/N] '); if (!yes(again, false)) { console.log('Keeping the existing link.'); } else await doLink(base); }
52
+ else await doLink(base);
53
+ // Pick the folder Claude works in. Default: where you ran this (even your home folder), so the app can chat right away.
54
+ let k = load();
55
+ if (!k.projects.length) {
56
+ const cwd = process.cwd();
57
+ let dir = cwd;
58
+ if (!yes(await ask(`Let Claude work in this folder? ${cwd} [Y/n] `), true)) dir = path.resolve((await ask('Folder path: ')).replace(/^~/, os.homedir()));
59
+ cmds.add(dir);
60
+ } else if (!k.projects.some(p => p.path === process.cwd()) && process.cwd() !== os.homedir() && yes(await ask(`Also add this folder (${process.cwd()})? [Y/n] `), true)) cmds.add('.');
61
+ if (!load().projects.length) { console.log('\nNo project added. Run: cypher add <folder> then: cypher connect'); return; }
62
+ console.log('\nOnline soon. Keep this window open (Ctrl+C to stop). Next time just run: cypher connect');
63
+ cmds.connect();
64
+ },
65
+ // One command to link this computer to the phone: shows a QR + code, waits for the app, adds the current folder, goes online.
66
+ async link(server) { await doLink(server); cmds.add('.'); cmds.connect(); },
67
+ async _unused(server) {
68
+ const c = load(); const base = httpOf(server || c.server);
69
+ const r = await fetch(base + '/link/start', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: os.hostname() }) }).catch(() => null);
70
+ if (!r || !r.ok) return console.log('cannot reach ' + base);
71
+ const { code, linkId } = await r.json();
72
+ console.log('\nOpen Project Cypher on your phone and scan this, or type the code.\n');
73
+ require('qrcode-terminal').generate(`cypher:${base}|${code}`, { small: true });
74
+ console.log(`\n code: ${code.slice(0, 3)} ${code.slice(3)} (valid 10 minutes)\n\nWaiting for the app...`);
75
+ for (;;) {
76
+ const w = await fetch(`${base}/link/wait/${linkId}`).catch(() => null);
77
+ if (!w) { await new Promise(r => setTimeout(r, 2000)); continue; }
78
+ if (w.status === 404) return console.log('code expired. Run cypher link again.');
79
+ const j = await w.json(); if (j.pending) continue;
80
+ const k = load(); k.server = base; k.token = j.token; k.machineId = j.machineId; save(k);
81
+ console.log('linked.'); break;
82
+ }
83
+ cmds.add('.'); cmds.connect();
84
+ },
85
+ async pair(server, code) {
86
+ if (!server || !code) return console.log('usage: cypher pair <server-url> <code> (get the code in the app: Projects > Add computer)');
87
+ const r = await fetch(httpOf(server) + '/pair', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ code, name: os.hostname() }) });
88
+ const j = await r.json().catch(() => ({})); if (!r.ok) return console.log('pairing failed: ' + (j.error || r.status));
89
+ const c = load(); c.server = httpOf(server); c.token = j.token; c.machineId = j.machineId; save(c); console.log('paired. Next: cypher add <folder>, then cypher connect');
90
+ },
91
+ login(server, token) {
92
+ if (!server || !token) return console.log('usage: cypher login <server-url> <token>');
93
+ const c = load(); c.server = httpOf(server); c.token = token; save(c); console.log('saved. Next: cypher add <folder>, then cypher connect');
94
+ },
95
+ add(dir = '.') {
96
+ const p = path.resolve(dir); if (!fs.existsSync(p)) return console.log('no such folder: ' + p);
97
+ const c = load(); if (c.projects.some(x => x.path === p)) return console.log('already added');
98
+ let id = slug(p), n = 2; while (c.projects.some(x => x.id === id)) id = slug(p) + '-' + n++;
99
+ c.projects.push({ id, name: path.basename(p), path: p }); save(c); console.log(`added ${id} (${p})`);
100
+ },
101
+ remove(id) { const c = load(); c.projects = c.projects.filter(x => x.id !== id); save(c); console.log('removed ' + id); },
102
+ 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}`)); },
103
+ 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}`); },
104
+ 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}`); },
105
+ connect() {
106
+ const WebSocket = require('ws'); let c = load(); if (!c.token) return console.log('not linked yet. Run: cypher link');
107
+ if (!c.projects.length) return console.log('add a project first: cypher add <folder>');
108
+ const runs = new Map(); let retry = 1000;
109
+ const open = () => {
110
+ c = { ...c, ...load() };
111
+ const ws = new WebSocket(`${wsOf(c.server)}/agent?token=${encodeURIComponent(c.token)}`);
112
+ const tx = m => ws.readyState === 1 && ws.send(JSON.stringify(m));
113
+ ws.on('open', () => { ws.opened = true; retry = 1000; tx({ type: 'hello', name: os.hostname(), projects: c.projects.map(({ id, name }) => ({ id, name })) }); console.log(`connected as ${os.hostname()} with ${c.projects.length} project(s). Ctrl+C to stop.`); });
114
+ ws.on('message', raw => {
115
+ let m; try { m = JSON.parse(raw); } catch { return; }
116
+ const proj = c.projects.find(p => p.id === m.projectId); const ctx = { reqId: m.reqId, projectId: m.projectId };
117
+ if (m.type === 'stop') return runs.get(m.projectId)?.kill('SIGINT');
118
+ if (m.type !== 'prompt') return;
119
+ if (!proj) return tx({ ...ctx, type: 'error', message: 'unknown project' });
120
+ if (runs.has(proj.id)) return tx({ ...ctx, type: 'error', message: 'Claude is still working on this project.' });
121
+ const cfg = load(); const args = ['-p', m.text, '--output-format', 'stream-json', '--verbose', '--permission-mode', cfg.permissionMode || 'acceptEdits'];
122
+ if (cfg.sessions?.[proj.id]) args.push('--resume', cfg.sessions[proj.id]);
123
+ console.log(`[${proj.id}] ${m.text.slice(0, 60)}`);
124
+ const ch = spawn('claude', args, { cwd: proj.path, stdio: ['ignore', 'pipe', 'pipe'] }); runs.set(proj.id, ch);
125
+ let buf = '', err = '';
126
+ const line = l => {
127
+ let e; try { e = JSON.parse(l); } catch { return; }
128
+ if (e.type === 'assistant') for (const b of e.message?.content || []) {
129
+ if (b.type === 'text' && b.text) tx({ ...ctx, type: 'text', text: b.text });
130
+ 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) }); }
131
+ } else if (e.type === 'result') {
132
+ if (e.session_id) { const k = load(); k.sessions = { ...k.sessions, [proj.id]: e.session_id }; save(k); }
133
+ tx({ ...ctx, type: 'done', error: !!e.is_error, cost: e.total_cost_usd });
134
+ }
135
+ };
136
+ 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); } });
137
+ ch.stderr.on('data', d => (err += d));
138
+ 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 }); });
139
+ 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) }); });
140
+ });
141
+ 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); });
142
+ ws.on('error', e => console.log('connection error: ' + e.message));
143
+ };
144
+ open();
12
145
  },
13
146
  };
14
- const help = () => console.log('Project Cypher\n\nUsage: cypher <command>\n\n status check the server and latest build\n latest show the latest version and APK link\n download save the latest APK here\n\nSet CYPHER_SERVER to use another server.');
15
- const c = process.argv[2];
16
- if (!c || c === '-h' || c === '--help' || !cmds[c]) { help(); process.exit(c && !['-h', '--help'].includes(c) ? 1 : 0); }
17
- cmds[c]().catch(e => { console.error('error: ' + e.message); process.exit(1); });
147
+ const help = () => console.log(`Project Cypher
148
+
149
+ cypher init first-time setup: checks Claude Code, links to the app (QR), adds this folder, goes online
150
+ cypher link [server-url] link this computer to your phone (QR or code), add this folder, go online
151
+ cypher pair <server-url> <code> alternative: pair with a code made in the app
152
+ cypher add [folder] expose a project folder to the app
153
+ cypher projects | remove <id> list / remove projects
154
+ cypher connect go online, run Claude Code when the app asks
155
+ cypher status | latest server check / latest app build
156
+
157
+ Config: ~/.cypher/config.json (permissionMode defaults to acceptEdits)`);
158
+ const [c, ...a] = process.argv.slice(2);
159
+ if (!cmds[c]) { help(); process.exit(c && !['-h', '--help'].includes(c) ? 1 : 0); }
160
+ Promise.resolve(cmds[c](...a)).catch(e => { console.error('error: ' + e.message); process.exit(1); });
package/package.json CHANGED
@@ -1,10 +1,25 @@
1
1
  {
2
2
  "name": "project-cypher",
3
- "version": "0.1.0",
4
- "description": "Command line for Project Cypher, the Claude Code command center.",
5
- "bin": { "cypher": "bin/cypher.js" },
6
- "files": ["bin"],
7
- "engines": { "node": ">=18" },
8
- "keywords": ["claude", "claude-code", "cypher", "cli"],
3
+ "version": "0.6.0",
4
+ "description": "Connect your machine's Claude Code projects to the Project Cypher phone app.",
5
+ "bin": {
6
+ "cypher": "bin/cypher.js"
7
+ },
8
+ "files": [
9
+ "bin"
10
+ ],
11
+ "engines": {
12
+ "node": ">=18"
13
+ },
14
+ "dependencies": {
15
+ "qrcode-terminal": "^0.12.0",
16
+ "ws": "^8.18.0", "qrcode-terminal": "^0.12.0"
17
+ },
18
+ "keywords": [
19
+ "claude",
20
+ "claude-code",
21
+ "cypher",
22
+ "cli"
23
+ ],
9
24
  "license": "MIT"
10
25
  }