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/src/tools.ts DELETED
@@ -1,331 +0,0 @@
1
- import type { DatabaseSync } from 'node:sqlite';
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.ts';
6
- import type { Tool } from './mcp.ts';
7
-
8
- /** cwd is the agent's workspace (claims are relative to it); worktree is the repository checkout inside it;
9
- * project, when known, is the project folder that holds the main checkout and every agent's workspace. */
10
- export interface Ctx { db: DatabaseSync; agent: string; cwd: string; worktree: string; project?: string }
11
-
12
- /** Is `file` inside `dir`? False for paths on another drive or above it. */
13
- export function insideDir(dir: string, file: string): boolean {
14
- const rel = path.relative(dir, path.resolve(dir, file));
15
- return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
16
- }
17
-
18
- /**
19
- * The claim key for a path an agent names: workspace-relative with forward slashes. Absolute paths and
20
- * paths starting with the repository directory are taken as given; any other relative path is read as
21
- * repository-relative (what the schema used to say, and what agents working inside the repo naturally
22
- * write). Paths outside the workspace are refused: chord cannot coordinate them.
23
- */
24
- export function claimPath(ctx: Ctx, file: string): string {
25
- const repoDir = path.relative(ctx.cwd, ctx.worktree).split(path.sep).join('/');
26
- const asGiven = normFile(ctx, file);
27
- let abs: string;
28
- if (path.isAbsolute(file) || !repoDir || repoDir === '.' || /^\.\.(\/|$)/.test(asGiven)) abs = path.resolve(ctx.cwd, file);
29
- else if (asGiven === repoDir || asGiven.startsWith(repoDir + '/')) {
30
- // "rich/console.py" is the workspace path of the repository's console.py, or the repository path of a package
31
- // named like the repository (rich/rich/console.py); whichever exists is meant, and both existing is ambiguous
32
- const asWorkspace = path.resolve(ctx.cwd, file), asRepo = path.resolve(ctx.worktree, file);
33
- const w = fs.existsSync(asWorkspace), r = fs.existsSync(asRepo);
34
- if (w && r && asWorkspace !== asRepo) throw new Error(`${file} is ambiguous: it could be ${normFile(ctx, asWorkspace)} or ${normFile(ctx, asRepo)}; give the path from your workspace root`);
35
- abs = r && !w ? asRepo : asWorkspace;
36
- } else abs = path.resolve(ctx.worktree, file);
37
- if (!insideDir(ctx.cwd, abs)) throw new Error(`${file} is outside your workspace; chord only coordinates files under ${ctx.cwd}`);
38
- return normFile(ctx, abs);
39
- }
40
-
41
- /** How long a latecomer waits for replies before it may write anyway. */
42
- export const REPLY_TIMEOUT_MS = 10 * 60 * 1000;
43
- /** How long before its claim a latecomer's message to an occupant still counts as asking about that file. */
44
- export const ASK_WINDOW_MS = 2 * 60 * 1000;
45
-
46
- const str = (desc: string) => ({ type: 'string', description: desc });
47
-
48
- function req(args: Record<string, unknown>, key: string): string {
49
- const v = args[key];
50
- if (typeof v !== 'string' || !v.trim()) throw new Error(`${key} is required`);
51
- return v;
52
- }
53
-
54
- /** Worktree-relative path with forward slashes, so the same file matches across worktrees. */
55
- export function normFile(ctx: Ctx, file: string): string {
56
- return path.relative(ctx.cwd, path.resolve(ctx.cwd, file)).split(path.sep).join('/');
57
- }
58
-
59
- export function peers(ctx: Ctx) {
60
- return db.listAgents(ctx.db).filter(a => a.name !== ctx.agent).map(a => ({
61
- name: a.name, tool: a.tool, status: a.status, cwd: a.cwd, task: a.task, scope: a.scope,
62
- }));
63
- }
64
-
65
- /** Online agents who claimed `file` before `mine` did. */
66
- function earlierOccupants(ctx: Ctx, mine: db.Claim): db.Claim[] {
67
- const before = (c: db.Claim) => c.created_at < mine.created_at || (c.created_at === mine.created_at && c.rid < mine.rid);
68
- return db.claimsFor(ctx.db, mine.file).filter(c => c.agent !== ctx.agent && before(c) && db.getAgent(ctx.db, c.agent)?.status !== 'offline');
69
- }
70
-
71
- export type WriteCheck = { ok: true } | { ok: false; reason: string };
72
-
73
- /**
74
- * May `ctx.agent` write `file` now? It must hold a claim, and if others claimed the file first it must
75
- * have told each of them what it will do and heard back. An occupant who does not answer within
76
- * REPLY_TIMEOUT_MS of being told is taken as having no objection. Any exchange since this agent's session
77
- * started counts, so a peer who explained their scope before we even claimed is not asked again.
78
- */
79
- export function canWrite(ctx: Ctx, file: string, now = Date.now()): WriteCheck {
80
- const rel = normFile(ctx, file);
81
- const mine = db.claimsFor(ctx.db, rel).find(c => c.agent === ctx.agent);
82
- if (!mine) return { ok: false, reason: `${rel} is not claimed by you. Call claim(file, intent, kind) first.` };
83
- const others = earlierOccupants(ctx, mine);
84
- if (!others.length) return { ok: true };
85
- const myStart = db.getAgent(ctx.db, ctx.agent)?.started_at ?? mine.created_at;
86
- const notAsked: string[] = [];
87
- const waiting: { agent: string; left: number }[] = [];
88
- for (const o of others) {
89
- // Only what both current sessions have said to each other counts: a relaunched peer starts afresh. And the
90
- // question must belong to this claim: a message sent long before it, about something else, is not notice of
91
- // the change; two minutes before the claim allows for saying hello first. The reply may be older (a peer who
92
- // explained its scope in advance has answered already).
93
- const since = Math.max(myStart, db.getAgent(ctx.db, o.agent)?.started_at ?? 0);
94
- const askedAt = db.firstMessageAt(ctx.db, ctx.agent, o.agent, Math.max(since, mine.created_at - ASK_WINDOW_MS));
95
- if (askedAt === null) notAsked.push(o.agent);
96
- else if (!db.repliedSince(ctx.db, o.agent, ctx.agent, since) && now - askedAt < REPLY_TIMEOUT_MS) waiting.push({ agent: o.agent, left: REPLY_TIMEOUT_MS - (now - askedAt) });
97
- }
98
- if (notAsked.length) {
99
- return { ok: false, reason: `${rel} is also claimed by ${notAsked.join(', ')}. Read their claim (claim tool output) and send each of them a message explaining what you intend to change before writing.` };
100
- }
101
- if (waiting.length) {
102
- const left = Math.ceil(Math.max(...waiting.map(w => w.left)) / 60000);
103
- return { ok: false, reason: `waiting for a reply from ${waiting.map(w => w.agent).join(', ')} about ${rel} (you may write without one in ${left} min). Work on something else meanwhile.` };
104
- }
105
- return { ok: true };
106
- }
107
-
108
- // 64 MB of output: a project's post-merge hook may print a great deal, and the default 1 MB would turn a merge that
109
- // already happened into an exception
110
- const git = (cwd: string, ...args: string[]) => execFileSync('git', args, { cwd, encoding: 'utf8', env: db.gitEnv(), stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024 }).trim();
111
- const gitOk = (cwd: string, ...args: string[]) => { try { git(cwd, ...args); return true; } catch { return false; } };
112
-
113
- const stderrOf = (e: unknown) => ((e as { stderr?: string }).stderr ?? (e as Error).message).trim();
114
- const sleepSync = (ms: number) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
115
-
116
- /** Tracked files with real changes, staged or not, ignoring line-ending differences and whatever happens inside
117
- * submodules; '' when clean. */
118
- export function dirtyTracked(cwd: string): string {
119
- const unstaged = git(cwd, 'diff', '--name-only', '--ignore-cr-at-eol', '--ignore-submodules=dirty');
120
- const staged = git(cwd, 'diff', '--cached', '--name-only', '--ignore-cr-at-eol', '--ignore-submodules=dirty');
121
- return [...new Set([...unstaged.split('\n'), ...staged.split('\n')].filter(Boolean))].join('\n');
122
- }
123
-
124
- /** git, retried for up to five seconds when another process (an IDE polling git status, say) holds index.lock. */
125
- function gitRetry(cwd: string, ...args: string[]): string {
126
- const t0 = Date.now();
127
- for (let wait = 50; ; wait = Math.min(wait * 2, 500)) {
128
- try { return git(cwd, ...args); } catch (e) {
129
- if (!/index\.lock.*(File exists|exists)/i.test(stderrOf(e)) || Date.now() - t0 > 5000) throw e;
130
- sleepSync(wait);
131
- }
132
- }
133
- }
134
-
135
- /** Run fn holding an exclusive lock file. Waits up to `waitMs`; a lock older than `staleMs` is taken over. */
136
- export function withLock<T>(file: string, fn: () => T, waitMs = 240_000, staleMs = 180_000): T {
137
- const t0 = Date.now();
138
- for (;;) {
139
- try { fs.writeFileSync(file, String(process.pid), { flag: 'wx' }); break; } catch (e) {
140
- if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e;
141
- try { if (Date.now() - fs.statSync(file).mtimeMs > staleMs) { fs.unlinkSync(file); continue; } } catch { continue; }
142
- if (Date.now() - t0 > waitMs) throw new Error(`another agent has been finishing for over ${waitMs / 1000}s; try again later`);
143
- sleepSync(150 + Math.random() * 200);
144
- }
145
- }
146
- try { return fn(); } finally { try { fs.unlinkSync(file); } catch { /* already gone */ } }
147
- }
148
-
149
- /** Rebase the agent's branch onto main and fast-forward main to it. Atomic from the agent's point of view.
150
- * Finishes are serialised with a lock file: git cannot run two merges in one checkout concurrently. */
151
- export function finish(ctx: Ctx) {
152
- const t0 = Date.now();
153
- return withLock(db.finishLockPath(), () => { db.log(`finish: lock acquired after ${Date.now() - t0}ms`); return finishLocked(ctx); });
154
- }
155
-
156
- function finishLocked(ctx: Ctx) {
157
- const wt = ctx.worktree;
158
- // A rebase in progress is only ours to clean up when finish itself started it (a finish killed mid-rebase leaves
159
- // the marker behind). Anyone else's rebase, perhaps with conflicts already resolved, is left alone.
160
- const marker = path.resolve(wt, git(wt, 'rev-parse', '--git-path', 'chord-finish'));
161
- for (const dir of ['rebase-merge', 'rebase-apply']) {
162
- if (!fs.existsSync(path.resolve(wt, git(wt, 'rev-parse', '--git-path', dir)))) continue;
163
- if (!fs.existsSync(marker)) throw new Error('a rebase is in progress in your worktree that finish did not start; run `git rebase --continue` (after resolving conflicts) or `git rebase --abort` first');
164
- git(wt, 'rebase', '--abort'); break;
165
- }
166
- fs.rmSync(marker, { force: true });
167
- // a finish killed mid-operation can leave the worktree's own index.lock behind; one older than a minute is debris
168
- const indexLock = path.resolve(wt, git(wt, 'rev-parse', '--git-path', 'index.lock'));
169
- try { if (Date.now() - fs.statSync(indexLock).mtimeMs > 60_000) { fs.unlinkSync(indexLock); db.log('finish: removed a stale index.lock in the worktree'); } } catch { /* none */ }
170
- // Untracked files (build output, a venv) are not in the way of a rebase and must not be forced into a commit.
171
- // Line-ending-only differences are ignored: a repository with CRLF files and a "* text" attribute shows every
172
- // such file as modified the moment it is checked out, and no agent can commit that away.
173
- if (dirtyTracked(wt)) throw new Error('your worktree has uncommitted changes to tracked files; commit them first');
174
- // What status still reports now differs only in line endings. git will not rebase over it, and nothing done in a
175
- // worktree can clear it: the repository needs a renormalised commit, which is the user's call.
176
- const phantom = git(wt, 'status', '--porcelain', '--untracked-files=no', '--ignore-submodules=dirty').split('\n').filter(Boolean).map(l => l.replace(/^[ MADRCU?!]{1,2}\s+/, ''));
177
- if (phantom.length) throw new Error(`these files differ from the index only in their line endings (a .gitattributes text rule the repository never renormalised for): ${phantom.slice(0, 10).join(', ')}. git cannot rebase over that and no worktree can clear it; ask the user to run "git add --renormalize ." and commit in the main checkout, after which worktrees made from that commit are clean. Nothing was merged.`);
178
- const root = db.mainRoot(wt);
179
- if (dirtyTracked(root)) throw new Error('the main checkout has uncommitted changes; ask the user to commit or stash them');
180
- const branch = git(wt, 'rev-parse', '--abbrev-ref', 'HEAD');
181
- if (branch === 'HEAD') throw new Error('your worktree is not on its branch (detached HEAD); check out your chord/ branch first');
182
- const main = git(root, 'rev-parse', '--abbrev-ref', 'HEAD');
183
- if (main === 'HEAD') throw new Error('the main checkout is not on a branch (detached HEAD); ask the user to check out a branch there');
184
- fs.writeFileSync(marker, `${Date.now()}\n`);
185
- try {
186
- gitRetry(wt, 'rebase', main);
187
- fs.rmSync(marker, { force: true });
188
- } catch (e) {
189
- // a conflict is a conflict; anything else (a pre-rebase hook saying no, a lock) is reported as what it is
190
- const conflicted = !!git(wt, 'diff', '--name-only', '--diff-filter=U');
191
- try { git(wt, 'rebase', '--abort'); } catch { /* nothing to abort */ }
192
- fs.rmSync(marker, { force: true });
193
- if (!conflicted) throw new Error(`rebase onto ${main} failed (not a conflict); nothing was merged.\n${stderrOf(e)}`);
194
- db.log(`finish: rebase of ${branch} onto ${main} conflicted; aborted`);
195
- db.addEvent(ctx.db, ctx.agent, 'conflict', null, `rebase of ${branch} onto ${main} conflicted; aborted, nothing merged`);
196
- throw new Error(`rebase onto ${main} failed; nothing was merged. Ask the user how to resolve it.\n${stderrOf(e)}`);
197
- }
198
- const subjects = git(root, 'log', '--format=%s', `${main}..${branch}`).split('\n').filter(Boolean);
199
- // A submodule commit the branch points at must exist in the main checkout's copy of the submodule, or the
200
- // fast-forward leaves main pointing at a commit that is nowhere but inside this agent's worktree.
201
- for (const line of git(root, 'diff', '--raw', main, branch).split('\n')) {
202
- const m = /^:\d{6} 160000 [0-9a-f]+ ([0-9a-f]+) [AM]\t(.+)$/.exec(line);
203
- if (!m) continue;
204
- const [, sha, sub] = m;
205
- if (!gitOk(path.join(root, sub), 'cat-file', '-e', `${sha}^{commit}`)) throw new Error(`${sub} is a submodule and your branch points it at commit ${sha.slice(0, 7)}, which the main checkout's submodule does not have; push that commit to the submodule's remote (and fetch it in ${path.join(root, sub)}) before finishing. Nothing was merged.`);
206
- }
207
- if (!subjects.length) {
208
- db.log(`finish: ${branch} has nothing beyond ${main}; claims released, nothing merged`);
209
- db.releaseAll(ctx.db, ctx.agent, 'finish');
210
- return { merged: [] as string[], into: main, notified: [] as string[], note: 'your branch has no commits beyond main; nothing to merge' };
211
- }
212
- // Files the branch adds must not already sit untracked in the main checkout: the fast-forward would stop half-way,
213
- // leaving some of them written and every later finish of this agent failing on them.
214
- const added = git(root, 'diff', '--name-only', '-z', '--diff-filter=A', main, branch).split('\0').filter(Boolean);
215
- const blocking = added.filter(f => fs.existsSync(path.join(root, f)));
216
- if (blocking.length) throw new Error(`cannot fast-forward ${main}: these files exist in the main checkout (${root}) but are not tracked there: ${blocking.join(', ')}. Ask the user to move or delete them; nothing was merged.`);
217
- try {
218
- gitRetry(root, 'merge', '--ff-only', branch);
219
- } catch (e) {
220
- // a noisy post-merge hook can make git exit non-zero after the merge went through
221
- if (!gitOk(root, 'merge-base', '--is-ancestor', branch, main)) {
222
- // undo what a half-done fast-forward wrote: files of ours that now sit untracked in the main checkout
223
- for (const f of added) {
224
- const p = path.join(root, f);
225
- if (fs.existsSync(p) && !gitOk(root, 'ls-files', '--error-unmatch', f) && git(root, 'hash-object', p) === git(root, 'rev-parse', `${branch}:${f}`)) fs.rmSync(p);
226
- }
227
- db.addEvent(ctx.db, ctx.agent, 'merge-failed', null, `fast-forward of ${main} to ${branch} failed: ${stderrOf(e).split('\n')[0]}`);
228
- throw new Error(`fast-forward of ${main} failed; your branch is rebased and intact, the main checkout was left as it was. ${stderrOf(e)}`);
229
- }
230
- db.log(`finish: merge reported an error but ${branch} is on ${main}; carrying on: ${stderrOf(e).split('\n')[0]}`);
231
- }
232
- db.log(`finish: merged ${branch} into ${main} (${subjects.length} commit(s))`);
233
- db.addEvent(ctx.db, ctx.agent, 'merge', null, `${subjects.length} commit(s) into ${main}: ${subjects.join('; ')}`);
234
- db.releaseAll(ctx.db, ctx.agent, 'finish');
235
- const online = db.listAgents(ctx.db).filter(a => a.name !== ctx.agent && a.status !== 'offline').map(a => a.name);
236
- if (online.length) db.send(ctx.db, ctx.agent, online, `${ctx.agent} merged into ${main}:\n${subjects.map(s => `- ${s}`).join('\n')}\nNo action is needed: finish() rebases your branch for you when you call it. This is a notification; do not reply.`, 'notice');
237
- return { merged: subjects, into: main, notified: online };
238
- }
239
-
240
- export function tools(ctx: Ctx): Tool[] {
241
- const list: Tool[] = [
242
- {
243
- name: 'register',
244
- description: 'Declare who you are and what you are working on. Call this once, first thing in the session.',
245
- inputSchema: {
246
- type: 'object',
247
- properties: { task: str('One sentence: what you are doing'), scope: str('Directory or glob you expect to touch (for others to read, not an authorisation)') },
248
- required: ['task'],
249
- },
250
- handler: a => {
251
- db.setTask(ctx.db, ctx.agent, req(a, 'task'), typeof a.scope === 'string' ? a.scope : null);
252
- return { registered: ctx.agent, peers: peers(ctx) };
253
- },
254
- },
255
- {
256
- name: 'list_agents',
257
- description: 'List the other agents working in this repository, with their task and status.',
258
- inputSchema: { type: 'object', properties: {} },
259
- handler: () => peers(ctx),
260
- },
261
- {
262
- name: 'claim',
263
- description: 'Declare a file you are about to create or change. Required before any write to it. If others already hold the file you must message each of them and hear back before writing.',
264
- inputSchema: {
265
- type: 'object',
266
- properties: {
267
- file: str('The file, as a path from your working directory (e.g. <repo>/src/x.ts) or relative to the repository root (src/x.ts)'),
268
- intent: str('One sentence: what you will change and why'),
269
- kind: { type: 'string', enum: ['add', 'modify', 'delete', 'rename'] },
270
- symbols: str('Optional: functions or blocks you will touch'),
271
- },
272
- required: ['file', 'intent', 'kind'],
273
- },
274
- handler: a => {
275
- const file = claimPath(ctx, req(a, 'file'));
276
- const kind = req(a, 'kind');
277
- if (!['add', 'modify', 'delete', 'rename'].includes(kind)) throw new Error('kind must be add, modify, delete or rename');
278
- db.addClaim(ctx.db, { agent: ctx.agent, file, intent: req(a, 'intent'), kind, symbols: typeof a.symbols === 'string' ? a.symbols : null });
279
- const mine = db.claimsFor(ctx.db, file).find(c => c.agent === ctx.agent)!;
280
- const others = earlierOccupants(ctx, mine);
281
- db.log(`claim ${file} by ${ctx.agent} (cwd ${ctx.cwd}): rows=${JSON.stringify(db.claimsFor(ctx.db, file).map(c => [c.agent, c.created_at]))} agents=${JSON.stringify(db.listAgents(ctx.db).map(a => [a.name, a.status, a.pid]))} others=${others.map(o => o.agent)}`);
282
- return {
283
- claimed: file,
284
- must_talk_to: others.map(o => ({ agent: o.agent, intent: o.intent, kind: o.kind, symbols: o.symbols })),
285
- ...(others.length ? { next: 'Send each agent above a message describing your change. You may write once each has replied, or after 10 minutes.' } : {}),
286
- };
287
- },
288
- },
289
- {
290
- name: 'release',
291
- description: 'Drop your claim on a file you no longer intend to change.',
292
- inputSchema: { type: 'object', properties: { file: str('The file, as given to claim()') }, required: ['file'] },
293
- handler: a => { const file = claimPath(ctx, req(a, 'file')); db.removeClaim(ctx.db, ctx.agent, file); return { released: file }; },
294
- },
295
- {
296
- name: 'send',
297
- description: 'Send a message to one or more agents by name. Delivery is asynchronous; do not wait for a reply.',
298
- inputSchema: {
299
- type: 'object',
300
- properties: {
301
- to: { description: 'Agent name, or array of names', anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] },
302
- message: str('The message'),
303
- },
304
- required: ['to', 'message'],
305
- },
306
- handler: a => {
307
- const to = Array.isArray(a.to) ? a.to.map(String) : [req(a, 'to')];
308
- const unknown = to.filter(n => !db.getAgent(ctx.db, n));
309
- if (unknown.length) throw new Error(`unknown agent(s): ${unknown.join(', ')}`);
310
- const offline = to.filter(n => db.getAgent(ctx.db, n)!.status === 'offline');
311
- if (offline.length) throw new Error(`${offline.join(', ')} is offline; nobody would read the message`);
312
- db.send(ctx.db, ctx.agent, to, req(a, 'message'));
313
- return { sent_to: to };
314
- },
315
- },
316
- {
317
- name: 'inbox',
318
- description: 'Read and clear your unread messages.',
319
- inputSchema: { type: 'object', properties: {} },
320
- handler: () => db.takeUnread(ctx.db, ctx.agent).map(m => ({ from: m.sender, at: new Date(m.created_at).toISOString(), message: m.body })),
321
- },
322
- {
323
- name: 'finish',
324
- description: 'Your work is committed on your branch: rebase it onto main, fast-forward main, release all your claims and tell the other agents. On a rebase conflict nothing is changed; stop and ask the user.',
325
- inputSchema: { type: 'object', properties: {} },
326
- handler: () => finish(ctx),
327
- },
328
- ];
329
- // every call sweeps dead agents first
330
- return list.map(t => ({ ...t, handler: a => { db.sweep(ctx.db); return t.handler(a); } }));
331
- }
package/src/trust.ts DELETED
@@ -1,71 +0,0 @@
1
- // Claude Code asks "do you trust this folder?" once per working directory and records the answer in
2
- // ~/.claude.json. Every agent workspace is a new directory, so without this each launch asks again.
3
- // The workspace only holds a worktree of a repository the user already chose to run chord in.
4
- import fs from 'node:fs';
5
- import os from 'node:os';
6
- import path from 'node:path';
7
-
8
- export const claudeConfigPath = () => path.join(os.homedir(), '.claude.json');
9
- // Claude Code keys projects by the cwd with forward slashes, even on Windows
10
- const norm = (p: string) => p.replace(/\\/g, '/').toLowerCase();
11
-
12
- /** Whether the user has answered yes to Claude Code's "do you trust this folder?" dialogue for `dir`. */
13
- export function claudeTrusts(dir: string, file = claudeConfigPath()): boolean {
14
- try {
15
- const projects = JSON.parse(fs.readFileSync(file, 'utf8')).projects ?? {};
16
- const key = Object.keys(projects).find(k => norm(k) === norm(dir));
17
- return key !== undefined && projects[key]?.hasTrustDialogAccepted === true;
18
- } catch { return false; }
19
- }
20
-
21
- /** Mark `workspace` as trusted for Claude Code. Returns false when Claude Code has no config yet (nothing to do)
22
- * or the entry was already there. Writes atomically; other fields of the file are left untouched. */
23
- export function pretrustClaude(workspace: string, file = claudeConfigPath()): boolean {
24
- let raw: string;
25
- try { raw = fs.readFileSync(file, 'utf8'); } catch { return false; }
26
- let cfg: Record<string, any>;
27
- try { cfg = JSON.parse(raw); } catch { return false; } // not ours to repair
28
- if (typeof cfg !== 'object' || cfg === null) return false;
29
- const projects = (cfg.projects ??= {});
30
- const key = Object.keys(projects).find(k => norm(k) === norm(workspace)) ?? workspace.replace(/\\/g, '/');
31
- if (projects[key]?.hasTrustDialogAccepted === true) return false;
32
- projects[key] = projects[key]
33
- ? { ...projects[key], hasTrustDialogAccepted: true } // existing entry: flip the flag, keep everything else
34
- : { allowedTools: [], mcpContextUris: [], mcpServers: {}, enabledMcpjsonServers: [], disabledMcpjsonServers: [], hasTrustDialogAccepted: true, hasClaudeMdExternalIncludesApproved: false, hasClaudeMdExternalIncludesWarningShown: false };
35
- const tmp = `${file}.chord-${process.pid}.tmp`;
36
- fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2));
37
- fs.renameSync(tmp, file);
38
- return true;
39
- }
40
-
41
- /** Which of a repository's .mcp.json servers the user has approved or rejected in Claude Code, and whether they
42
- * approved them all at once. Claude asks per server when it runs in the repository; chord has no prompt, so it only
43
- * starts what the user has already approved there. */
44
- export function claudeMcpChoices(dir: string, file = claudeConfigPath()): { all: boolean; enabled: string[]; disabled: string[] } {
45
- try {
46
- const projects = JSON.parse(fs.readFileSync(file, 'utf8')).projects ?? {};
47
- const key = Object.keys(projects).find(k => norm(k) === norm(dir));
48
- const p = key ? projects[key] : {};
49
- return { all: p.enableAllProjectMcpServers === true, enabled: p.enabledMcpjsonServers ?? [], disabled: p.disabledMcpjsonServers ?? [] };
50
- } catch { return { all: false, enabled: [], disabled: [] }; }
51
- }
52
-
53
- export const codexConfigPath = () => path.join(os.homedir(), '.codex', 'config.toml');
54
-
55
- /** Codex keeps a trust hash and an on/off switch per hook in ~/.codex/config.toml. One keypress in its
56
- * "Hooks need review" screen switches a hook off, and chord then silently loses the feature behind it, so
57
- * chord names the switched-off hooks at launch. Returns Codex event names such as "session_start". */
58
- export function disabledCodexHooks(file = codexConfigPath()): string[] {
59
- let text: string;
60
- try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
61
- const off: string[] = [];
62
- let event: string | null = null; // the chord hook whose [hooks.state...] table we are in, if any
63
- for (const raw of text.split('\n')) {
64
- const line = raw.trim();
65
- if (line.startsWith('[')) {
66
- const m = /^\[hooks\.state\.['"].*<session-flags>.*:([a-z_]+):\d+:\d+['"]\]/.exec(line);
67
- event = m ? m[1] : null;
68
- } else if (event && /^enabled\s*=\s*false\b/.test(line)) off.push(event);
69
- }
70
- return off;
71
- }