project-cypher 0.7.0 → 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.
- package/bin/cypher.js +66 -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
|
|
|
@@ -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
|
-
//
|
|
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; }
|
|
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,37 @@ 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
|
|
98
|
-
let id = slug(p), n = 2; while (
|
|
99
|
-
|
|
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) {
|
|
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
|
-
|
|
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:
|
|
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(
|
|
116
|
-
const watch = setInterval(() => { const n = JSON.stringify(
|
|
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 =
|
|
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); }
|
|
126
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);
|
|
127
170
|
if (cfg.sessions?.[proj.id]) args.push('--resume', cfg.sessions[proj.id]);
|
|
128
171
|
console.log(`[${proj.id}] ${m.text.slice(0, 60)}`);
|
|
@@ -135,7 +178,7 @@ const cmds = {
|
|
|
135
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) }); }
|
|
136
179
|
} else if (e.type === 'result') {
|
|
137
180
|
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 });
|
|
181
|
+
runs.delete(proj.id); tx({ ...ctx, type: 'done', error: !!e.is_error, cost: e.total_cost_usd });
|
|
139
182
|
}
|
|
140
183
|
};
|
|
141
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); } });
|
|
@@ -154,7 +197,7 @@ const help = () => console.log(`Project Cypher
|
|
|
154
197
|
cypher init first-time setup: checks Claude Code, links to the app (QR), adds this folder, goes online
|
|
155
198
|
cypher link [server-url] link this computer to your phone (QR or code), add this folder, go online
|
|
156
199
|
cypher pair <server-url> <code> alternative: pair with a code made in the app
|
|
157
|
-
cypher add [folder]
|
|
200
|
+
cypher add [folder] register a project folder (or just ask Root in the app)
|
|
158
201
|
cypher projects | remove <id> list / remove projects
|
|
159
202
|
cypher connect go online, run Claude Code when the app asks
|
|
160
203
|
cypher status | latest server check / latest app build
|