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.
Files changed (2) hide show
  1. package/bin/cypher.js +76 -23
  2. 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 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 }); };
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
- // 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; }
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 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})`);
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) { const c = load(); c.projects = c.projects.filter(x => x.id !== id); save(c); console.log('removed ' + 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
- if (!c.projects.length) return console.log('add a project first: cypher add <folder>');
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: c.projects.map(({ id, name }) => ({ id, name })) }); console.log(`connected as ${os.hostname()} with ${c.projects.length} project(s). Ctrl+C to stop.`); });
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(load().projects.map(p => p.id));
116
- const watch = setInterval(() => { const n = JSON.stringify(load().projects.map(p => p.id)); if (n !== sig) { sig = n; const ps = load().projects; tx({ type: 'hello', name: os.hostname(), projects: ps.map(({ id, name }) => ({ id, name })) }); console.log(`projects updated: ${ps.length}`); } }, 2000);
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
- const proj = load().projects.find(p => p.id === m.projectId); const ctx = { reqId: m.reqId, projectId: m.projectId };
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] expose a project folder to the app
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-cypher",
3
- "version": "0.7.0",
3
+ "version": "0.9.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"