project-cypher 0.7.0 → 0.9.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 +76 -23
- package/package.json +1 -1
package/bin/cypher.js
CHANGED
|
@@ -1,9 +1,52 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// project-cypher: runs on your computer, exposes chosen folders to the Cypher phone app and runs Claude Code in them.
|
|
3
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
|
|
6
|
-
|
|
4
|
+
const CFG_DIR = process.env.CYPHER_HOME || path.join(os.homedir(), '.cypher'), CFG = path.join(CFG_DIR, 'config.json');
|
|
5
|
+
const ROOT_DIR = path.join(CFG_DIR, 'root'), REG = path.join(ROOT_DIR, 'projects.json'), ROOT_MD = path.join(ROOT_DIR, 'CLAUDE.md');
|
|
6
|
+
// Registry = ROOT_DIR/projects.json: [{id,name,path,summary}]. Root (Claude) and `cypher add` both edit it, so always validate on read.
|
|
7
|
+
function readRegistry() {
|
|
8
|
+
let raw; try { raw = JSON.parse(fs.readFileSync(REG, 'utf8')); } catch { return []; }
|
|
9
|
+
if (!Array.isArray(raw)) return []; const seen = new Set(['root']);
|
|
10
|
+
return raw.filter(p => p && typeof p.id === 'string' && /^[a-z0-9][a-z0-9-]{0,40}$/.test(p.id) && !seen.has(p.id) && seen.add(p.id) && typeof p.path === 'string' && path.isAbsolute(p.path) && (() => { try { return fs.statSync(p.path).isDirectory(); } catch { return false; } })())
|
|
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
|
+
}
|
|
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' }; } };
|
|
15
|
+
const load = () => { const c = rawCfg(); if (c.projects?.length && !fs.existsSync(REG)) writeRegistry(c.projects); delete c.projects; return { ...c, projects: readRegistry() }; };
|
|
16
|
+
const save = c => { const { projects, ...rest } = c; fs.mkdirSync(CFG_DIR, { recursive: true }); fs.writeFileSync(CFG, JSON.stringify(rest, null, 2), { mode: 0o600 }); };
|
|
17
|
+
const ROOT_TEMPLATE = (machine, ws) => `# Root
|
|
18
|
+
|
|
19
|
+
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.
|
|
20
|
+
|
|
21
|
+
Files (this folder): \`projects.json\` is the registry the app reads. \`CLAUDE.md\` is this file: instructions plus your memory.
|
|
22
|
+
Workspace: ${ws} (new projects are created inside it)
|
|
23
|
+
|
|
24
|
+
## What you do
|
|
25
|
+
1. **Add an existing project.** The user gives a folder path. Check it exists (ls), look at README / package.json read-only to understand it, then add an entry to projects.json: {"id","name","path","summary"}. id = lowercase letters, digits and hyphens, unique, never "root". path = absolute. summary = one line. Store the real location, never copy the folder.
|
|
26
|
+
2. **Create a new project.** Make a folder inside the workspace (workspace/<id>), run git init, register it in projects.json, and tell the user the path. Ask what it is if they did not say.
|
|
27
|
+
3. **Remove a project.** Remove only the registry entry. Never delete folders.
|
|
28
|
+
4. **Status.** Use read-only commands (uptime, df -h, free -h, uname -a, ls, cat) and projects.json. Answer briefly: load, disk, memory, each project and whether its folder still exists.
|
|
29
|
+
5. **Memory.** Keep the Memory section below current: one line per project (the gist) plus any user preferences. Update it whenever you add, change or learn something.
|
|
30
|
+
|
|
31
|
+
## Rules
|
|
32
|
+
- projects.json must stay valid JSON: read it, change it, write the whole file back.
|
|
33
|
+
- Replies are short, plain text, for a phone screen.
|
|
34
|
+
- When a project is added or created, tell the user to open it from the Projects list to build.
|
|
35
|
+
- Never run destructive commands. Do not change files outside this folder and the workspace.
|
|
36
|
+
|
|
37
|
+
## Memory
|
|
38
|
+
(nothing yet)
|
|
39
|
+
`;
|
|
40
|
+
function ensureRoot(workspace) {
|
|
41
|
+
const c = rawCfg(); const ws = path.resolve(workspace || c.workspace || path.join(os.homedir(), 'cypher-projects'));
|
|
42
|
+
fs.mkdirSync(ROOT_DIR, { recursive: true }); fs.mkdirSync(ws, { recursive: true });
|
|
43
|
+
if (!fs.existsSync(REG)) writeRegistry([]);
|
|
44
|
+
if (!fs.existsSync(ROOT_MD)) fs.writeFileSync(ROOT_MD, ROOT_TEMPLATE(os.hostname(), ws));
|
|
45
|
+
else { const t = fs.readFileSync(ROOT_MD, 'utf8'); const n = t.replace(/^Workspace: .*$/m, `Workspace: ${ws} (new projects are created inside it)`); if (n !== t) fs.writeFileSync(ROOT_MD, n); }
|
|
46
|
+
if (c.workspace !== ws) { c.workspace = ws; save(c); }
|
|
47
|
+
return ws;
|
|
48
|
+
}
|
|
49
|
+
const ROOT_TOOLS = 'Read,Edit,Write,Glob,Grep,Bash(ls:*),Bash(mkdir:*),Bash(git init:*),Bash(cat:*),Bash(uptime),Bash(df:*),Bash(free:*),Bash(uname:*),Bash(hostname),Bash(whoami),Bash(date),Bash(pwd)';
|
|
7
50
|
const slug = p => path.basename(p).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'project';
|
|
8
51
|
const httpOf = s => s.replace(/\/$/, ''), wsOf = s => httpOf(s).replace(/^http/, 'ws');
|
|
9
52
|
|
|
@@ -26,7 +69,14 @@ async function doLink(server) {
|
|
|
26
69
|
const k = load(); k.server = base; k.token = j.token; k.machineId = j.machineId; save(k); console.log('linked.'); return;
|
|
27
70
|
}
|
|
28
71
|
}
|
|
72
|
+
const dropToken = msg => { const k = rawCfg(); k.token = ''; delete k.machineId; save(k); console.log(msg); };
|
|
29
73
|
const cmds = {
|
|
74
|
+
// Unlink this computer: tells the server, forgets the token. Projects/Root folder stay on disk.
|
|
75
|
+
async disconnect() {
|
|
76
|
+
const c = load(); if (!c.token) return console.log('already disconnected.');
|
|
77
|
+
await fetch(httpOf(c.server) + '/unlink', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ token: c.token }) }).catch(() => null);
|
|
78
|
+
dropToken('disconnected. This computer is no longer linked to your app. Run cypher init to link again.');
|
|
79
|
+
},
|
|
30
80
|
// First-run setup: checks Claude Code, reaches the server, shows the QR, adds this folder, goes online.
|
|
31
81
|
async init(...argv) {
|
|
32
82
|
const si = argv.indexOf('--server'); const server = si >= 0 ? argv[si + 1] : undefined; const c = load(); const base = httpOf(server || c.server);
|
|
@@ -50,15 +100,13 @@ const cmds = {
|
|
|
50
100
|
console.log(`[ok] Server ${base}`);
|
|
51
101
|
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
102
|
else await doLink(base);
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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; }
|
|
103
|
+
// Workspace = where Root creates new projects. Default: the folder you ran init in.
|
|
104
|
+
const cur = rawCfg().workspace; const cwd = process.cwd();
|
|
105
|
+
let ws = cur || cwd;
|
|
106
|
+
if (!cur) { const a2 = await ask(`Where should Root create new projects? [${cwd}] `); if (a2) ws = path.resolve(a2.replace(/^~/, os.homedir())); }
|
|
107
|
+
ws = ensureRoot(ws);
|
|
108
|
+
console.log(`\nRoot is ready. New projects will be created in ${ws}`);
|
|
109
|
+
console.log('Open Project Cypher and chat with Root: add existing folders, create projects, ask for status.');
|
|
62
110
|
console.log('\nOnline soon. Keep this window open (Ctrl+C to stop). Next time just run: cypher connect');
|
|
63
111
|
cmds.connect();
|
|
64
112
|
},
|
|
@@ -94,35 +142,39 @@ const cmds = {
|
|
|
94
142
|
},
|
|
95
143
|
add(dir = '.') {
|
|
96
144
|
const p = path.resolve(dir); if (!fs.existsSync(p)) return console.log('no such folder: ' + p);
|
|
97
|
-
const
|
|
98
|
-
let id = slug(p), n = 2; while (
|
|
99
|
-
|
|
145
|
+
ensureRoot(); const list = readRegistry(); if (list.some(x => x.path === p)) return console.log('already added');
|
|
146
|
+
let id = slug(p), n = 2; if (id === 'root') id = 'root-project'; while (list.some(x => x.id === id)) id = slug(p) + '-' + n++;
|
|
147
|
+
list.push({ id, name: path.basename(p), path: p, summary: '' }); writeRegistry(list); console.log(`added ${id} (${p})`);
|
|
100
148
|
},
|
|
101
|
-
remove(id) {
|
|
149
|
+
remove(id) { writeRegistry(readRegistry().filter(x => x.id !== id)); console.log('removed ' + id); },
|
|
102
150
|
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
151
|
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
152
|
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
153
|
connect() {
|
|
154
|
+
const announce = () => [{ id: 'root', name: 'Root', root: true }, ...readRegistry().map(({ id, name, summary }) => ({ id, name, summary }))];
|
|
106
155
|
const WebSocket = require('ws'); let c = load(); if (!c.token) return console.log('not linked yet. Run: cypher link');
|
|
107
|
-
|
|
156
|
+
ensureRoot();
|
|
108
157
|
const runs = new Map(); let retry = 1000;
|
|
109
158
|
const open = () => {
|
|
110
159
|
c = { ...c, ...load() };
|
|
111
160
|
const ws = new WebSocket(`${wsOf(c.server)}/agent?token=${encodeURIComponent(c.token)}`);
|
|
112
161
|
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:
|
|
162
|
+
ws.on('open', () => { ws.opened = true; retry = 1000; tx({ type: 'hello', name: os.hostname(), projects: announce() }); console.log(`connected as ${os.hostname()} with Root + ${readRegistry().length} project(s). Ctrl+C to stop.`); });
|
|
114
163
|
// pick up `cypher add` / `remove` while running: re-announce projects when the config changes
|
|
115
|
-
let sig = JSON.stringify(
|
|
116
|
-
const watch = setInterval(() => { const n = JSON.stringify(
|
|
164
|
+
let sig = JSON.stringify(announce());
|
|
165
|
+
const watch = setInterval(() => { const a = announce(), n = JSON.stringify(a); if (n !== sig) { sig = n; tx({ type: 'hello', name: os.hostname(), projects: a }); console.log(`projects updated: ${a.length - 1}`); } }, 2000);
|
|
117
166
|
ws.on('close', () => clearInterval(watch));
|
|
167
|
+
ws.on('unexpected-response', (req, res) => { if (res.statusCode === 401) { dropToken('This computer is not linked (removed from the app). Run cypher init to link again.'); process.exit(0); } else { req.destroy(); } });
|
|
118
168
|
ws.on('message', raw => {
|
|
119
169
|
let m; try { m = JSON.parse(raw); } catch { return; }
|
|
120
|
-
|
|
170
|
+
if (m.type === 'revoked') { runs.forEach(r => r.kill('SIGINT')); dropToken('Disconnected from the app. Run cypher init to link again.'); process.exit(0); }
|
|
171
|
+
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 };
|
|
121
172
|
if (m.type === 'stop') return runs.get(m.projectId)?.kill('SIGINT');
|
|
122
173
|
if (m.type !== 'prompt') return;
|
|
123
174
|
if (!proj) return tx({ ...ctx, type: 'error', message: 'unknown project' });
|
|
124
175
|
if (runs.has(proj.id)) return tx({ ...ctx, type: 'error', message: 'Claude is still working on this project.' });
|
|
125
176
|
const cfg = load(); const args = ['-p', m.text, '--output-format', 'stream-json', '--verbose', '--permission-mode', cfg.permissionMode || 'acceptEdits'];
|
|
177
|
+
if (proj.root) { const wsd = rawCfg().workspace; args.push('--allowedTools', ROOT_TOOLS); if (wsd) args.push('--add-dir', wsd); }
|
|
126
178
|
if (typeof m.model === 'string' && /^[a-z][a-z0-9.\-\[\]]{0,60}$/i.test(m.model) && m.model !== 'default') args.push('--model', m.model);
|
|
127
179
|
if (cfg.sessions?.[proj.id]) args.push('--resume', cfg.sessions[proj.id]);
|
|
128
180
|
console.log(`[${proj.id}] ${m.text.slice(0, 60)}`);
|
|
@@ -135,7 +187,7 @@ const cmds = {
|
|
|
135
187
|
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) }); }
|
|
136
188
|
} else if (e.type === 'result') {
|
|
137
189
|
if (e.session_id) { const k = load(); k.sessions = { ...k.sessions, [proj.id]: e.session_id }; save(k); }
|
|
138
|
-
tx({ ...ctx, type: 'done', error: !!e.is_error, cost: e.total_cost_usd });
|
|
190
|
+
runs.delete(proj.id); tx({ ...ctx, type: 'done', error: !!e.is_error, cost: e.total_cost_usd });
|
|
139
191
|
}
|
|
140
192
|
};
|
|
141
193
|
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); } });
|
|
@@ -154,8 +206,9 @@ const help = () => console.log(`Project Cypher
|
|
|
154
206
|
cypher init first-time setup: checks Claude Code, links to the app (QR), adds this folder, goes online
|
|
155
207
|
cypher link [server-url] link this computer to your phone (QR or code), add this folder, go online
|
|
156
208
|
cypher pair <server-url> <code> alternative: pair with a code made in the app
|
|
157
|
-
cypher add [folder]
|
|
209
|
+
cypher add [folder] register a project folder (or just ask Root in the app)
|
|
158
210
|
cypher projects | remove <id> list / remove projects
|
|
211
|
+
cypher disconnect unlink this computer from the app
|
|
159
212
|
cypher connect go online, run Claude Code when the app asks
|
|
160
213
|
cypher status | latest server check / latest app build
|
|
161
214
|
|