project-cypher 0.6.1 → 0.8.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 +67 -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
 
@@ -50,15 +93,13 @@ const cmds = {
50
93
  console.log(`[ok] Server ${base}`);
51
94
  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
95
  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; }
96
+ // Workspace = where Root creates new projects. Default: the folder you ran init in.
97
+ const cur = rawCfg().workspace; const cwd = process.cwd();
98
+ let ws = cur || cwd;
99
+ if (!cur) { const a2 = await ask(`Where should Root create new projects? [${cwd}] `); if (a2) ws = path.resolve(a2.replace(/^~/, os.homedir())); }
100
+ ws = ensureRoot(ws);
101
+ console.log(`\nRoot is ready. New projects will be created in ${ws}`);
102
+ console.log('Open Project Cypher and chat with Root: add existing folders, create projects, ask for status.');
62
103
  console.log('\nOnline soon. Keep this window open (Ctrl+C to stop). Next time just run: cypher connect');
63
104
  cmds.connect();
64
105
  },
@@ -94,35 +135,38 @@ const cmds = {
94
135
  },
95
136
  add(dir = '.') {
96
137
  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})`);
138
+ ensureRoot(); const list = readRegistry(); if (list.some(x => x.path === p)) return console.log('already added');
139
+ let id = slug(p), n = 2; if (id === 'root') id = 'root-project'; while (list.some(x => x.id === id)) id = slug(p) + '-' + n++;
140
+ list.push({ id, name: path.basename(p), path: p, summary: '' }); writeRegistry(list); console.log(`added ${id} (${p})`);
100
141
  },
101
- remove(id) { const c = load(); c.projects = c.projects.filter(x => x.id !== id); save(c); console.log('removed ' + id); },
142
+ remove(id) { writeRegistry(readRegistry().filter(x => x.id !== id)); console.log('removed ' + id); },
102
143
  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
144
  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
145
  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
146
  connect() {
147
+ const announce = () => [{ id: 'root', name: 'Root', root: true }, ...readRegistry().map(({ id, name, summary }) => ({ id, name, summary }))];
106
148
  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>');
149
+ ensureRoot();
108
150
  const runs = new Map(); let retry = 1000;
109
151
  const open = () => {
110
152
  c = { ...c, ...load() };
111
153
  const ws = new WebSocket(`${wsOf(c.server)}/agent?token=${encodeURIComponent(c.token)}`);
112
154
  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.`); });
155
+ 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
156
  // 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);
157
+ let sig = JSON.stringify(announce());
158
+ 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
159
  ws.on('close', () => clearInterval(watch));
118
160
  ws.on('message', raw => {
119
161
  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 };
162
+ 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
163
  if (m.type === 'stop') return runs.get(m.projectId)?.kill('SIGINT');
122
164
  if (m.type !== 'prompt') return;
123
165
  if (!proj) return tx({ ...ctx, type: 'error', message: 'unknown project' });
124
166
  if (runs.has(proj.id)) return tx({ ...ctx, type: 'error', message: 'Claude is still working on this project.' });
125
167
  const cfg = load(); const args = ['-p', m.text, '--output-format', 'stream-json', '--verbose', '--permission-mode', cfg.permissionMode || 'acceptEdits'];
168
+ if (proj.root) { const wsd = rawCfg().workspace; args.push('--allowedTools', ROOT_TOOLS); if (wsd) args.push('--add-dir', wsd); }
169
+ if (typeof m.model === 'string' && /^[a-z][a-z0-9.\-\[\]]{0,60}$/i.test(m.model) && m.model !== 'default') args.push('--model', m.model);
126
170
  if (cfg.sessions?.[proj.id]) args.push('--resume', cfg.sessions[proj.id]);
127
171
  console.log(`[${proj.id}] ${m.text.slice(0, 60)}`);
128
172
  const ch = spawn('claude', args, { cwd: proj.path, stdio: ['ignore', 'pipe', 'pipe'] }); runs.set(proj.id, ch);
@@ -134,7 +178,7 @@ const cmds = {
134
178
  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) }); }
135
179
  } else if (e.type === 'result') {
136
180
  if (e.session_id) { const k = load(); k.sessions = { ...k.sessions, [proj.id]: e.session_id }; save(k); }
137
- tx({ ...ctx, type: 'done', error: !!e.is_error, cost: e.total_cost_usd });
181
+ runs.delete(proj.id); tx({ ...ctx, type: 'done', error: !!e.is_error, cost: e.total_cost_usd });
138
182
  }
139
183
  };
140
184
  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); } });
@@ -153,7 +197,7 @@ const help = () => console.log(`Project Cypher
153
197
  cypher init first-time setup: checks Claude Code, links to the app (QR), adds this folder, goes online
154
198
  cypher link [server-url] link this computer to your phone (QR or code), add this folder, go online
155
199
  cypher pair <server-url> <code> alternative: pair with a code made in the app
156
- cypher add [folder] expose a project folder to the app
200
+ cypher add [folder] register a project folder (or just ask Root in the app)
157
201
  cypher projects | remove <id> list / remove projects
158
202
  cypher connect go online, run Claude Code when the app asks
159
203
  cypher status | latest server check / latest app build
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-cypher",
3
- "version": "0.6.1",
3
+ "version": "0.8.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"