project-cypher 0.1.0 → 0.5.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 +150 -13
- package/package.json +21 -6
package/bin/cypher.js
CHANGED
|
@@ -1,17 +1,154 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// project-cypher
|
|
3
|
-
const
|
|
4
|
-
const
|
|
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
|
-
|
|
7
|
-
async
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
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
|
+
const cwd = process.cwd(); const k = load();
|
|
54
|
+
if (!k.projects.some(p => p.path === cwd) && cwd !== os.homedir() && yes(await ask(`Add this folder as a project (${cwd})? [Y/n] `), true)) cmds.add('.');
|
|
55
|
+
if (!load().projects.length) { console.log('\nNo project yet. In a project folder run: cypher add . then: cypher connect'); return; }
|
|
56
|
+
console.log('\nGoing online. Keep this window open (Ctrl+C to stop). Next time: cypher connect');
|
|
57
|
+
cmds.connect();
|
|
58
|
+
},
|
|
59
|
+
// One command to link this computer to the phone: shows a QR + code, waits for the app, adds the current folder, goes online.
|
|
60
|
+
async link(server) { await doLink(server); cmds.add('.'); cmds.connect(); },
|
|
61
|
+
async _unused(server) {
|
|
62
|
+
const c = load(); const base = httpOf(server || c.server);
|
|
63
|
+
const r = await fetch(base + '/link/start', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: os.hostname() }) }).catch(() => null);
|
|
64
|
+
if (!r || !r.ok) return console.log('cannot reach ' + base);
|
|
65
|
+
const { code, linkId } = await r.json();
|
|
66
|
+
console.log('\nOpen Project Cypher on your phone and scan this, or type the code.\n');
|
|
67
|
+
require('qrcode-terminal').generate(`cypher:${base}|${code}`, { small: true });
|
|
68
|
+
console.log(`\n code: ${code.slice(0, 3)} ${code.slice(3)} (valid 10 minutes)\n\nWaiting for the app...`);
|
|
69
|
+
for (;;) {
|
|
70
|
+
const w = await fetch(`${base}/link/wait/${linkId}`).catch(() => null);
|
|
71
|
+
if (!w) { await new Promise(r => setTimeout(r, 2000)); continue; }
|
|
72
|
+
if (w.status === 404) return console.log('code expired. Run cypher link again.');
|
|
73
|
+
const j = await w.json(); if (j.pending) continue;
|
|
74
|
+
const k = load(); k.server = base; k.token = j.token; k.machineId = j.machineId; save(k);
|
|
75
|
+
console.log('linked.'); break;
|
|
76
|
+
}
|
|
77
|
+
cmds.add('.'); cmds.connect();
|
|
78
|
+
},
|
|
79
|
+
async pair(server, code) {
|
|
80
|
+
if (!server || !code) return console.log('usage: cypher pair <server-url> <code> (get the code in the app: Projects > Add computer)');
|
|
81
|
+
const r = await fetch(httpOf(server) + '/pair', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ code, name: os.hostname() }) });
|
|
82
|
+
const j = await r.json().catch(() => ({})); if (!r.ok) return console.log('pairing failed: ' + (j.error || r.status));
|
|
83
|
+
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');
|
|
84
|
+
},
|
|
85
|
+
login(server, token) {
|
|
86
|
+
if (!server || !token) return console.log('usage: cypher login <server-url> <token>');
|
|
87
|
+
const c = load(); c.server = httpOf(server); c.token = token; save(c); console.log('saved. Next: cypher add <folder>, then cypher connect');
|
|
88
|
+
},
|
|
89
|
+
add(dir = '.') {
|
|
90
|
+
const p = path.resolve(dir); if (!fs.existsSync(p)) return console.log('no such folder: ' + p);
|
|
91
|
+
const c = load(); if (c.projects.some(x => x.path === p)) return console.log('already added');
|
|
92
|
+
let id = slug(p), n = 2; while (c.projects.some(x => x.id === id)) id = slug(p) + '-' + n++;
|
|
93
|
+
c.projects.push({ id, name: path.basename(p), path: p }); save(c); console.log(`added ${id} (${p})`);
|
|
94
|
+
},
|
|
95
|
+
remove(id) { const c = load(); c.projects = c.projects.filter(x => x.id !== id); save(c); console.log('removed ' + id); },
|
|
96
|
+
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}`)); },
|
|
97
|
+
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}`); },
|
|
98
|
+
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}`); },
|
|
99
|
+
connect() {
|
|
100
|
+
const WebSocket = require('ws'); let c = load(); if (!c.token) return console.log('not linked yet. Run: cypher link');
|
|
101
|
+
if (!c.projects.length) return console.log('add a project first: cypher add <folder>');
|
|
102
|
+
const runs = new Map(); let retry = 1000;
|
|
103
|
+
const open = () => {
|
|
104
|
+
c = { ...c, ...load() };
|
|
105
|
+
const ws = new WebSocket(`${wsOf(c.server)}/agent?token=${encodeURIComponent(c.token)}`);
|
|
106
|
+
const tx = m => ws.readyState === 1 && ws.send(JSON.stringify(m));
|
|
107
|
+
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.`); });
|
|
108
|
+
ws.on('message', raw => {
|
|
109
|
+
let m; try { m = JSON.parse(raw); } catch { return; }
|
|
110
|
+
const proj = c.projects.find(p => p.id === m.projectId); const ctx = { reqId: m.reqId, projectId: m.projectId };
|
|
111
|
+
if (m.type === 'stop') return runs.get(m.projectId)?.kill('SIGINT');
|
|
112
|
+
if (m.type !== 'prompt') return;
|
|
113
|
+
if (!proj) return tx({ ...ctx, type: 'error', message: 'unknown project' });
|
|
114
|
+
if (runs.has(proj.id)) return tx({ ...ctx, type: 'error', message: 'Claude is still working on this project.' });
|
|
115
|
+
const cfg = load(); const args = ['-p', m.text, '--output-format', 'stream-json', '--verbose', '--permission-mode', cfg.permissionMode || 'acceptEdits'];
|
|
116
|
+
if (cfg.sessions?.[proj.id]) args.push('--resume', cfg.sessions[proj.id]);
|
|
117
|
+
console.log(`[${proj.id}] ${m.text.slice(0, 60)}`);
|
|
118
|
+
const ch = spawn('claude', args, { cwd: proj.path, stdio: ['ignore', 'pipe', 'pipe'] }); runs.set(proj.id, ch);
|
|
119
|
+
let buf = '', err = '';
|
|
120
|
+
const line = l => {
|
|
121
|
+
let e; try { e = JSON.parse(l); } catch { return; }
|
|
122
|
+
if (e.type === 'assistant') for (const b of e.message?.content || []) {
|
|
123
|
+
if (b.type === 'text' && b.text) tx({ ...ctx, type: 'text', text: b.text });
|
|
124
|
+
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) }); }
|
|
125
|
+
} else if (e.type === 'result') {
|
|
126
|
+
if (e.session_id) { const k = load(); k.sessions = { ...k.sessions, [proj.id]: e.session_id }; save(k); }
|
|
127
|
+
tx({ ...ctx, type: 'done', error: !!e.is_error, cost: e.total_cost_usd });
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
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); } });
|
|
131
|
+
ch.stderr.on('data', d => (err += d));
|
|
132
|
+
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 }); });
|
|
133
|
+
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) }); });
|
|
134
|
+
});
|
|
135
|
+
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); });
|
|
136
|
+
ws.on('error', e => console.log('connection error: ' + e.message));
|
|
137
|
+
};
|
|
138
|
+
open();
|
|
12
139
|
},
|
|
13
140
|
};
|
|
14
|
-
const help = () => console.log(
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
141
|
+
const help = () => console.log(`Project Cypher
|
|
142
|
+
|
|
143
|
+
cypher init first-time setup: checks Claude Code, links to the app (QR), adds this folder, goes online
|
|
144
|
+
cypher link [server-url] link this computer to your phone (QR or code), add this folder, go online
|
|
145
|
+
cypher pair <server-url> <code> alternative: pair with a code made in the app
|
|
146
|
+
cypher add [folder] expose a project folder to the app
|
|
147
|
+
cypher projects | remove <id> list / remove projects
|
|
148
|
+
cypher connect go online, run Claude Code when the app asks
|
|
149
|
+
cypher status | latest server check / latest app build
|
|
150
|
+
|
|
151
|
+
Config: ~/.cypher/config.json (permissionMode defaults to acceptEdits)`);
|
|
152
|
+
const [c, ...a] = process.argv.slice(2);
|
|
153
|
+
if (!cmds[c]) { help(); process.exit(c && !['-h', '--help'].includes(c) ? 1 : 0); }
|
|
154
|
+
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.
|
|
4
|
-
"description": "
|
|
5
|
-
"bin": {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
"
|
|
3
|
+
"version": "0.5.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
|
}
|