chord-cli 0.1.0 → 0.1.1

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/README.md CHANGED
@@ -63,6 +63,8 @@ With Node 24 and git on the machine:
63
63
  npm install -g chord-cli
64
64
  ```
65
65
 
66
+ On Linux add `--allow-scripts=node-pty`, with python3, make and g++ present: node-pty ships no Linux binary and builds one on install.
67
+
66
68
  Or from source:
67
69
 
68
70
  ```
package/dist/clean.js ADDED
@@ -0,0 +1,93 @@
1
+ // chord clean: remove the workspaces, worktrees and branches of agents that are gone.
2
+ import { execFileSync } from 'node:child_process';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import * as db from "./db.js";
6
+ import { setupMarker } from "./launch.js";
7
+ const git = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8', env: db.gitEnv(), stdio: ['ignore', 'pipe', 'pipe'] }).trim();
8
+ /** Remove offline agents (all of them, or the named ones). An agent whose branch has commits that are not on
9
+ * main, or whose worktree has uncommitted work, is kept unless `force`; an online agent is always kept. Directories
10
+ * under chord/agents/ with no database row (a launch that died before the tool started) are treated the same way. */
11
+ export function clean(project, d, names = [], force = false) {
12
+ db.sweep(d);
13
+ const main = git(project.repo, 'rev-parse', '--abbrev-ref', 'HEAD');
14
+ const agents = db.listAgents(d).filter(a => (names.length ? names.includes(a.name) : a.status === 'offline'));
15
+ const result = { removed: [], kept: [] };
16
+ const kept = (name, why) => result.kept.push({ name, why });
17
+ const remove = (a, hasRow) => {
18
+ try {
19
+ if (a.status !== 'offline') {
20
+ kept(a.name, 'still online');
21
+ return;
22
+ }
23
+ const branch = `chord/${a.name}`;
24
+ const hasBranch = git(project.repo, 'branch', '--list', branch) !== '';
25
+ if (hasBranch && !force) {
26
+ let merged = true;
27
+ try {
28
+ git(project.repo, 'merge-base', '--is-ancestor', branch, main);
29
+ }
30
+ catch {
31
+ merged = false;
32
+ }
33
+ if (!merged) {
34
+ kept(a.name, `${branch} has commits that are not on ${main}; use --force to discard them`);
35
+ return;
36
+ }
37
+ }
38
+ // work that was never committed (an agent stopped half-way) is just as much work as an unmerged commit
39
+ if (!force && fs.existsSync(path.join(a.worktree, '.git'))) {
40
+ let dirty = '';
41
+ try {
42
+ dirty = git(a.worktree, 'status', '--porcelain');
43
+ }
44
+ catch { /* a broken worktree holds nothing to keep */ }
45
+ if (dirty) {
46
+ kept(a.name, `${a.worktree} has uncommitted changes; use --force to discard them`);
47
+ return;
48
+ }
49
+ }
50
+ // The directory goes first. On Windows a directory some process is using (a shell left open in it) cannot be
51
+ // removed; then the branch and the database row stay too, so the next clean sees the agent whole.
52
+ if (fs.existsSync(a.worktree)) {
53
+ try {
54
+ git(project.repo, 'worktree', 'remove', '--force', a.worktree);
55
+ }
56
+ catch { /* fall through to rm */ }
57
+ }
58
+ try {
59
+ fs.rmSync(a.cwd, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
60
+ }
61
+ catch { /* checked below */ }
62
+ git(project.repo, 'worktree', 'prune');
63
+ if (fs.existsSync(a.cwd)) {
64
+ kept(a.name, `${a.cwd} could not be removed, probably because a process is using it; close it and run clean again`);
65
+ return;
66
+ }
67
+ if (hasBranch)
68
+ git(project.repo, 'branch', '-D', branch);
69
+ fs.rmSync(setupMarker(project, a.name), { force: true });
70
+ if (hasRow)
71
+ db.removeAgent(d, a.name);
72
+ result.removed.push(a.name);
73
+ }
74
+ catch (e) {
75
+ kept(a.name, `error: ${e.message.split('\n')[0]}`);
76
+ }
77
+ };
78
+ for (const missing of names.filter(n => !agents.some(a => a.name === n) && !fs.existsSync(path.join(project.dir, 'chord', 'agents', n))))
79
+ kept(missing, 'no such agent');
80
+ for (const a of agents)
81
+ remove(a, true);
82
+ // directories with no row: a launch that died before the tool registered, or a database that was deleted
83
+ const agentsDir = path.join(project.dir, 'chord', 'agents');
84
+ const known = new Set(db.listAgents(d).map(a => a.name.toLowerCase()));
85
+ let orphans = [];
86
+ try {
87
+ orphans = fs.readdirSync(agentsDir).filter(n => !known.has(n.toLowerCase()) && (!names.length || names.includes(n)));
88
+ }
89
+ catch { /* no agents directory yet */ }
90
+ for (const n of orphans)
91
+ remove({ name: n, status: 'offline', cwd: path.join(agentsDir, n), worktree: path.join(agentsDir, n, project.repoName) }, false);
92
+ return result;
93
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,327 @@
1
+ #!/usr/bin/env node
2
+ // chord <claude|codex> [--name <agent>] [tool args...] launch an agent in its own workspace
3
+ // chord ls [--all] agents and file claims, this project or all
4
+ // chord messages [agent] [-n N] message history
5
+ // chord events [agent] [-n N] claim, release, merge and conflict history
6
+ // chord stop <agent> quit an agent once it is idle
7
+ // chord clean [--force] [agent...] remove offline agents' workspaces, worktrees and branches
8
+ import { execFileSync } from 'node:child_process';
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { createInterface } from 'node:readline/promises';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { clean } from "./clean.js";
14
+ import * as db from "./db.js";
15
+ import { mergeUserSettings, prepare } from "./launch.js";
16
+ import { NeedsRestructure, knownProjects, resolveProject, restructure, restructureBlocker, validateRepoName } from "./project.js";
17
+ import { resolveCommand, run } from "./pty.js";
18
+ import { disabledCodexHooks, pretrustClaude } from "./trust.js";
19
+ const USAGE = 'usage: chord <claude|codex> [--name <agent>] [tool args...]\n chord ls [--all]\n chord messages [agent] [-n N]\n chord events [agent] [-n N]\n chord stop <agent>\n chord clean [--force] [agent...]\n';
20
+ const gitOut = (cwd, ...args) => {
21
+ try {
22
+ return execFileSync('git', args, { cwd, encoding: 'utf8', env: db.gitEnv(), stdio: ['ignore', 'pipe', 'ignore'] }).trim();
23
+ }
24
+ catch {
25
+ return undefined;
26
+ }
27
+ };
28
+ /** "12s", "3m", "2h", "5d": how long ago. */
29
+ export function ago(ms) {
30
+ const s = Math.max(0, Math.round(ms / 1000));
31
+ return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m` : s < 86400 ? `${Math.floor(s / 3600)}h` : `${Math.floor(s / 86400)}d`;
32
+ }
33
+ /** Local time of day, with the date in front when it is not today. */
34
+ export function when(t, now = Date.now()) {
35
+ const d = new Date(t), pad = (n) => String(n).padStart(2, '0');
36
+ const time = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
37
+ return d.toDateString() === new Date(now).toDateString() ? time : `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${time}`;
38
+ }
39
+ /** Agents (with unread messages, time since the last sign of life, commits on their branch not yet on main) and
40
+ * claims. `repo` is the main checkout; without it the branch column shows "?". */
41
+ export function ls(d, repo, now = Date.now()) {
42
+ db.sweep(d);
43
+ const agents = db.listAgents(d);
44
+ const claims = db.allClaims(d);
45
+ const main = repo ? gitOut(repo, 'rev-parse', '--abbrev-ref', 'HEAD') : undefined;
46
+ const ahead = (name) => { if (!repo || !main)
47
+ return '?'; const n = gitOut(repo, 'rev-list', '--count', `${main}..chord/${name}`); return n === undefined ? '-' : `+${n}`; };
48
+ const unread = (name) => d.prepare('select count(*) c from messages where recipient = ? and read_at is null').get(name).c;
49
+ const row = (a) => [a.name.padEnd(28), a.tool.padEnd(7), a.status.padEnd(8), String(unread(a.name)).padEnd(6), ago(now - (a.heartbeat_at ?? a.updated_at)).padEnd(7), ahead(a.name).padEnd(6), `${a.task ?? '-'}${a.scope ? ` [${a.scope}]` : ''}`].join(' ');
50
+ const header = ['agent'.padEnd(28), 'tool'.padEnd(7), 'status'.padEnd(8), 'unread', 'active'.padEnd(7), 'branch', 'task'].join(' ');
51
+ const out = agents.length ? [header, ...agents.map(row)] : ['no agents'];
52
+ out.push('', ...(claims.length ? claims.map(c => `${c.file.padEnd(40)} ${c.agent.padEnd(28)} ${c.kind.padEnd(7)} ${c.intent}`) : ['no claims']));
53
+ return out.join('\n') + '\n';
54
+ }
55
+ export function lsAll() {
56
+ const projects = knownProjects();
57
+ if (!projects.length)
58
+ return 'no chord projects on this machine yet\n';
59
+ const repoOf = (dir) => { try {
60
+ return dir ? resolveProject(dir).repo : undefined;
61
+ }
62
+ catch {
63
+ return undefined;
64
+ } };
65
+ return projects.map(p => {
66
+ try {
67
+ return `== ${p.dir ?? p.stateDir}\n${ls(db.openDb(p.dbFile), repoOf(p.dir ?? undefined))}`;
68
+ }
69
+ catch (e) {
70
+ return `== ${p.dir ?? p.stateDir}\n(unreadable: ${e.message})\n`;
71
+ }
72
+ }).join('\n');
73
+ }
74
+ /** chord messages [agent] [-n N]: the latest messages, oldest first, one line each. */
75
+ export function messages(d, agent, limit = 50, now = Date.now()) {
76
+ const ms = db.listMessages(d, agent, limit);
77
+ if (!ms.length)
78
+ return agent ? `no messages to or from ${agent}\n` : 'no messages\n';
79
+ const oneLine = (s) => s.replace(/\s+/g, ' ').trim();
80
+ return ms.map(m => `${when(m.created_at, now)} ${m.sender} -> ${m.recipient}${m.kind !== 'message' ? ` [${m.kind}]` : ''}${m.read_at ? '' : ' (unread)'}: ${oneLine(m.body)}`).join('\n') + '\n';
81
+ }
82
+ /** chord events [agent] [-n N]: claims, releases, merges and conflicts, oldest first. */
83
+ export function events(d, agent, limit = 50, now = Date.now()) {
84
+ const es = db.listEvents(d, agent, limit);
85
+ if (!es.length)
86
+ return agent ? `no events for ${agent}\n` : 'no events\n';
87
+ return es.map(e => `${when(e.at, now)} ${e.agent.padEnd(28)} ${e.kind.padEnd(8)} ${[e.file, e.detail].filter(Boolean).join(' ')}`).join('\n') + '\n';
88
+ }
89
+ /** chord stop <agent>: the wrapper quits the tool once the agent is idle and nothing is typed. */
90
+ export function stop(d, name) {
91
+ if (db.requestStop(d, name))
92
+ return `stop requested: ${name} will quit when its current turn ends and its input box is empty\n`;
93
+ throw new Error(db.getAgent(d, name) ? `${name} is already offline` : `no such agent: ${name}`);
94
+ }
95
+ /** [agent] and -n N for the history commands. */
96
+ export function listArgs(rest) {
97
+ let agent, limit = 50;
98
+ for (let i = 0; i < rest.length; i++) {
99
+ if (rest[i] === '-n') {
100
+ limit = Number(rest[++i]);
101
+ if (!Number.isInteger(limit) || limit <= 0)
102
+ throw new Error('-n needs a positive number');
103
+ }
104
+ else if (rest[i].startsWith('-'))
105
+ throw new Error(`unknown option ${rest[i]}`);
106
+ else if (agent)
107
+ throw new Error('give at most one agent name');
108
+ else
109
+ agent = rest[i];
110
+ }
111
+ return { agent, limit };
112
+ }
113
+ /** `--name <x>` or `--name=<x>` anywhere before the tool's own arguments. */
114
+ /** The user's own `--settings <json|file>` for Claude, removed from the tool arguments: passed through as is it would
115
+ * replace chord's --settings wholesale and switch the hooks off, so chord folds it under its own instead. */
116
+ export function takeSettings(rest) {
117
+ let raw;
118
+ for (let i = 0; i < rest.length; i++) {
119
+ const a = rest[i];
120
+ if (a === '--settings') {
121
+ raw = rest[i + 1];
122
+ if (raw === undefined || raw.startsWith('-'))
123
+ throw new Error('--settings needs a value');
124
+ rest.splice(i, 2);
125
+ break;
126
+ }
127
+ if (a.startsWith('--settings=')) {
128
+ raw = a.slice(11);
129
+ rest.splice(i, 1);
130
+ break;
131
+ }
132
+ }
133
+ if (raw === undefined)
134
+ return undefined;
135
+ const text = fs.existsSync(raw) ? fs.readFileSync(raw, 'utf8') : raw;
136
+ let parsed;
137
+ try {
138
+ parsed = JSON.parse(text);
139
+ }
140
+ catch {
141
+ throw new Error(`--settings must be a JSON object or a path to a JSON file: ${raw}`);
142
+ }
143
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
144
+ throw new Error('--settings must be a JSON object');
145
+ return parsed;
146
+ }
147
+ /** Tool flags that would silently disable chord's hooks or MCP server: refused rather than launched half-working. */
148
+ export function refuseHookKillers(tool, rest) {
149
+ if (tool === 'claude') {
150
+ for (const flag of ['--bare', '--safe-mode', '--disable-all-hooks'])
151
+ if (rest.includes(flag))
152
+ throw new Error(`${flag} switches off the hooks chord relies on; run plain claude for that`);
153
+ }
154
+ else {
155
+ for (let i = 0; i < rest.length; i++) {
156
+ const v = rest[i] === '-c' || rest[i] === '--config' ? rest[i + 1] ?? '' : rest[i].startsWith('--config=') ? rest[i].slice(9) : '';
157
+ if (/^(hooks\b|mcp_servers\.chord\b)/.test(v))
158
+ throw new Error(`"-c ${v}" would replace chord's own hooks or MCP server; chord sets these itself`);
159
+ // Codex's Windows sandbox runs commands as a restricted user that cannot start chord's hooks (nor reach the
160
+ // state under the user's profile), so claims would go unchecked; refused rather than run half-working
161
+ const sandbox = rest[i] === '-s' || rest[i] === '--sandbox' ? rest[i + 1] ?? '' : rest[i].startsWith('--sandbox=') ? rest[i].slice(10) : /^sandbox_mode\s*=\s*"?([\w-]+)/.exec(v)?.[1] ?? '';
162
+ if (process.platform === 'win32' && sandbox && sandbox !== 'danger-full-access')
163
+ throw new Error(`Codex's "${sandbox}" sandbox cannot run chord's hooks on Windows (the sandboxed user cannot start them), so file claims would not be checked; run without -s, or with -s danger-full-access`);
164
+ }
165
+ }
166
+ }
167
+ export function takeName(rest) {
168
+ for (let i = 0; i < rest.length; i++) {
169
+ const a = rest[i];
170
+ if (a === '--name') {
171
+ const v = rest[i + 1];
172
+ if (!v || v.startsWith('-'))
173
+ throw new Error('--name needs a value');
174
+ rest.splice(i, 2);
175
+ return v;
176
+ }
177
+ if (a.startsWith('--name=')) {
178
+ rest.splice(i, 1);
179
+ const v = a.slice(7);
180
+ if (!v)
181
+ throw new Error('--name needs a value');
182
+ return v;
183
+ }
184
+ }
185
+ return undefined;
186
+ }
187
+ /** The folder is a repository root: offer to move it into a child directory so it fits the project layout.
188
+ * Throws with guidance when that is impossible or declined. */
189
+ async function offerRestructure(e) {
190
+ const example = path.join(e.dir, e.suggestedName);
191
+ const blocker = restructureBlocker(e.dir);
192
+ if (blocker)
193
+ throw new Error(`${e.message}; ${blocker}`);
194
+ if (!process.stdin.isTTY)
195
+ throw new Error(`${e.message}. Move it into a subfolder (e.g. ${example}) and run chord in ${e.dir} again`);
196
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
197
+ try {
198
+ process.stderr.write(`chord: ${e.dir} is itself a git repository.\n` +
199
+ `A chord project is a folder that contains the repository as a subfolder, next to chord's own working directory.\n` +
200
+ `Move everything in ${e.dir} into ${example}${path.sep} ? Open editors, other terminals' current directory and absolute paths in IDE settings will need updating.\n`);
201
+ for (let attempt = 0; attempt < 3; attempt++) {
202
+ const answer = (await rl.question(`[Y/n/other name] (${e.suggestedName}): `)).trim();
203
+ if (/^n(o)?$/i.test(answer))
204
+ throw new Error('not moved; move the repository into a subfolder yourself and run chord in the parent folder');
205
+ const name = answer === '' || /^y(es)?$/i.test(answer) ? e.suggestedName : answer;
206
+ const invalid = validateRepoName(name) ?? (fs.existsSync(path.join(e.dir, name)) ? `${name} already exists` : null);
207
+ if (invalid) {
208
+ process.stderr.write(`chord: ${invalid}; try another name\n`);
209
+ continue;
210
+ }
211
+ const target = restructure(e.dir, name);
212
+ process.stderr.write(`chord: moved the repository to ${target}\n`);
213
+ return;
214
+ }
215
+ throw new Error('no usable name given; nothing was moved');
216
+ }
217
+ finally {
218
+ rl.close();
219
+ }
220
+ }
221
+ async function main(argv) {
222
+ const [cmd, ...rest] = argv;
223
+ if (cmd === 'ls') {
224
+ if (rest.includes('--all')) {
225
+ process.stdout.write(lsAll());
226
+ return 0;
227
+ }
228
+ let project;
229
+ try {
230
+ project = resolveProject(process.cwd());
231
+ }
232
+ catch (e) {
233
+ if (e instanceof NeedsRestructure)
234
+ throw new Error(`${e.message}; run \`chord claude\` or \`chord codex\` here to set the project up, or \`chord ls --all\` for every project`);
235
+ throw new Error(`${e.message}; use \`chord ls --all\` for every project on this machine`);
236
+ }
237
+ const file = db.dbPath(project.stateDir);
238
+ if (!fs.existsSync(file)) {
239
+ process.stdout.write(`no agents have run in ${project.dir} yet\n`);
240
+ return 0;
241
+ }
242
+ process.stdout.write(ls(db.openDb(file), project.repo));
243
+ return 0;
244
+ }
245
+ if (cmd === 'messages' || cmd === 'events' || cmd === 'stop') {
246
+ const project = resolveProject(process.cwd());
247
+ const file = db.dbPath(project.stateDir);
248
+ if (!fs.existsSync(file)) {
249
+ process.stdout.write(`no agents have run in ${project.dir} yet\n`);
250
+ return 0;
251
+ }
252
+ const d = db.openDb(file);
253
+ if (cmd === 'stop') {
254
+ if (rest.length !== 1) {
255
+ process.stderr.write(USAGE);
256
+ return 2;
257
+ }
258
+ process.stdout.write(stop(d, rest[0]));
259
+ return 0;
260
+ }
261
+ const { agent, limit } = listArgs(rest);
262
+ process.stdout.write(cmd === 'messages' ? messages(d, agent, limit) : events(d, agent, limit));
263
+ return 0;
264
+ }
265
+ if (cmd === 'clean') {
266
+ const project = resolveProject(process.cwd());
267
+ const file = db.dbPath(project.stateDir);
268
+ if (!fs.existsSync(file)) {
269
+ process.stdout.write(`no agents have run in ${project.dir} yet\n`);
270
+ return 0;
271
+ }
272
+ const force = rest.includes('--force');
273
+ const r = clean(project, db.openDb(file), rest.filter(a => a !== '--force'), force);
274
+ for (const n of r.removed)
275
+ process.stdout.write(`removed ${n}\n`);
276
+ for (const k of r.kept)
277
+ process.stdout.write(`kept ${k.name}: ${k.why}\n`);
278
+ if (!r.removed.length && !r.kept.length)
279
+ process.stdout.write('nothing to clean\n');
280
+ return 0;
281
+ }
282
+ if (cmd !== 'claude' && cmd !== 'codex') {
283
+ process.stderr.write(USAGE);
284
+ return 2;
285
+ }
286
+ const name = takeName(rest);
287
+ const userSettings = cmd === 'claude' ? takeSettings(rest) : undefined;
288
+ refuseHookKillers(cmd, rest);
289
+ resolveCommand(cmd); // fail before creating a workspace when the tool is not installed
290
+ let launch;
291
+ try {
292
+ launch = prepare(cmd, process.cwd(), name);
293
+ }
294
+ catch (e) {
295
+ if (!(e instanceof NeedsRestructure))
296
+ throw e;
297
+ await offerRestructure(e);
298
+ launch = prepare(cmd, process.cwd(), name);
299
+ }
300
+ process.stderr.write(`chord: agent ${launch.name} in ${launch.cwd}\n`);
301
+ if (userSettings)
302
+ launch.args = mergeUserSettings(launch.args, userSettings);
303
+ if (cmd === 'claude') { // otherwise every new workspace asks "do you trust this folder?"; a failure here only brings that dialogue back
304
+ try {
305
+ pretrustClaude(launch.cwd);
306
+ }
307
+ catch (e) {
308
+ process.stderr.write(`chord: could not record the workspace as trusted in ~/.claude.json (${e.message}); Claude may ask you to trust the folder\n`);
309
+ }
310
+ }
311
+ if (cmd === 'codex') {
312
+ const off = disabledCodexHooks();
313
+ if (off.length)
314
+ process.stderr.write(`chord: Codex has chord's ${off.join(', ')} hook(s) switched off in ~/.codex/config.toml, so the matching chord features will not work. Remove the "enabled = false" line(s) under the [hooks.state.'...<session-flags>...'] tables to switch them back on.\n`);
315
+ }
316
+ return run(cmd, launch, rest);
317
+ }
318
+ // run only as the entry point (argv[1] may be a symlink such as an npm bin link)
319
+ const isMain = (() => { try {
320
+ return fs.realpathSync.native(process.argv[1] ?? '') === fs.realpathSync.native(fileURLToPath(import.meta.url));
321
+ }
322
+ catch {
323
+ return false;
324
+ } })();
325
+ if (isMain) {
326
+ main(process.argv.slice(2)).then(code => process.exit(code), e => { process.stderr.write(`chord: ${e.message}\n`); process.exit(1); });
327
+ }