spectater 0.0.0-stage → 0.2.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/src/agent.js ADDED
@@ -0,0 +1,184 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { constants } from 'node:fs';
4
+ import { open, readdir, realpath, rename, unlink } from 'node:fs/promises';
5
+ import http from 'node:http';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { createDiscovery, within } from './discovery.js';
9
+ import { readFileLimited, readLimited } from './files.js';
10
+ import { alive, privateDirectory } from './storage.js';
11
+
12
+ const bin = fileURLToPath(new URL('../bin/spectater.js', import.meta.url));
13
+ const serverFile = (directory, reviewId) => path.join(directory, `${reviewId}.server.json`);
14
+
15
+ export async function writeServer(directory, record) {
16
+ const file = serverFile(directory, record.reviewId);
17
+ const temp = `${file}.${randomUUID()}.tmp`;
18
+ const handle = await open(temp, 'wx', 0o600);
19
+ try {
20
+ await handle.writeFile(JSON.stringify(record));
21
+ } finally {
22
+ await handle.close();
23
+ }
24
+ await rename(temp, file);
25
+ }
26
+
27
+ export async function removeServer(directory, reviewId) {
28
+ await unlink(serverFile(directory, reviewId)).catch((error) => {
29
+ if (error.code !== 'ENOENT') throw error;
30
+ });
31
+ }
32
+
33
+ /** Returns the running server record for a review, removing records left by dead processes. */
34
+ export async function readServer(directory, reviewId) {
35
+ let handle;
36
+ try {
37
+ handle = await open(
38
+ serverFile(directory, reviewId),
39
+ constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
40
+ );
41
+ const stat = await handle.stat();
42
+ if (!stat.isFile() || stat.mode & 0o077 || stat.uid !== process.getuid())
43
+ throw new Error('Server record is not a private regular file.');
44
+ const record = JSON.parse(new TextDecoder().decode(await readLimited(handle, 65536)));
45
+ if (record.reviewId !== reviewId || !Number.isSafeInteger(record.pid))
46
+ throw new Error('Invalid server record.');
47
+ if (alive(record.pid)) return record;
48
+ } catch (error) {
49
+ if (error.code === 'ENOENT') return null;
50
+ throw new Error(`Cannot read server record: ${error.message}`);
51
+ } finally {
52
+ await handle?.close();
53
+ }
54
+ await removeServer(directory, reviewId);
55
+ return null;
56
+ }
57
+
58
+ export async function listServers(directory) {
59
+ let names;
60
+ try {
61
+ names = await readdir(directory);
62
+ } catch (error) {
63
+ if (error.code === 'ENOENT') return [];
64
+ throw error;
65
+ }
66
+ const records = [];
67
+ for (const name of names) {
68
+ const match = name.match(/^([a-f0-9]{64})\.server\.json$/);
69
+ const record = match && (await readServer(directory, match[1]).catch(() => null));
70
+ if (record) records.push(record);
71
+ }
72
+ return records.sort((a, b) => a.change.localeCompare(b.change));
73
+ }
74
+
75
+ /**
76
+ * Finds the review for a change. Running servers whose root contains the project are matched
77
+ * without native discovery; otherwise native discovery derives the identity.
78
+ */
79
+ export async function locate({ project = '.', change, store }, directory) {
80
+ if (!change) throw new Error('Supply a change name.');
81
+ if (!store) {
82
+ const cwd = await realpath(project);
83
+ const matches = (await listServers(directory)).filter(
84
+ (record) => record.change === change && (record.root === cwd || within(record.root, cwd)),
85
+ );
86
+ if (matches.length === 1) return { reviewId: matches[0].reviewId, record: matches[0] };
87
+ }
88
+ const manifest = await createDiscovery()({ project, change, store });
89
+ return {
90
+ reviewId: manifest.reviewId,
91
+ manifest,
92
+ record: await readServer(directory, manifest.reviewId),
93
+ };
94
+ }
95
+
96
+ /** Sends one agent request; resolves `{ status: 'stopped' }` when the server is gone. */
97
+ export function agentRequest(record, operation, input = {}, timeout = 10000) {
98
+ const url = new URL(record.url);
99
+ const body = JSON.stringify(input);
100
+ return new Promise((resolve, reject) => {
101
+ const request = http.request(
102
+ {
103
+ host: url.hostname,
104
+ port: url.port,
105
+ path: `/agent/${operation}`,
106
+ method: 'POST',
107
+ agent: false,
108
+ headers: {
109
+ Authorization: `Bearer ${url.hash.slice(1)}`,
110
+ 'Content-Type': 'application/json',
111
+ 'Content-Length': Buffer.byteLength(body),
112
+ },
113
+ },
114
+ (response) => {
115
+ const chunks = [];
116
+ response.on('data', (chunk) => chunks.push(chunk));
117
+ response.on('error', () => resolve({ status: 'stopped' }));
118
+ response.on('end', () => {
119
+ const text = Buffer.concat(chunks).toString('utf8').trim();
120
+ if (!text) return resolve({ status: 'stopped' });
121
+ try {
122
+ const result = JSON.parse(text);
123
+ if (response.statusCode !== 200 || result.status === 'error')
124
+ reject(new Error(result.error || 'Review request failed.'));
125
+ else resolve(result);
126
+ } catch (error) {
127
+ reject(error);
128
+ }
129
+ });
130
+ },
131
+ );
132
+ const timer = setTimeout(
133
+ () => request.destroy(new Error('Review server did not answer in time.')),
134
+ timeout,
135
+ );
136
+ request.once('close', () => clearTimeout(timer));
137
+ request.once('error', (error) =>
138
+ ['ECONNREFUSED', 'ECONNRESET'].includes(error.code)
139
+ ? resolve({ status: 'stopped' })
140
+ : reject(error),
141
+ );
142
+ request.end(body);
143
+ });
144
+ }
145
+
146
+ /** Starts `spectater serve` detached and resolves its server record once it is ready. */
147
+ export async function startDetached(options, directory, reviewId) {
148
+ await privateDirectory(directory);
149
+ const log = path.join(directory, `${reviewId}.log`);
150
+ const output = await open(log, 'w', 0o600);
151
+ const args = [bin, 'serve', options.change, '--project', options.project];
152
+ if (options.store) args.push('--store', options.store);
153
+ if (options.config) args.push('--config', options.config);
154
+ if (options.reopen) args.push('--reopen');
155
+ let child;
156
+ try {
157
+ child = spawn(process.execPath, args, {
158
+ detached: true,
159
+ stdio: ['ignore', output.fd, output.fd],
160
+ env: { ...process.env, SPECTATER_STATE_DIR: directory },
161
+ });
162
+ } finally {
163
+ await output.close();
164
+ }
165
+ let exited = null;
166
+ child.once('exit', (code) => {
167
+ exited = code ?? 1;
168
+ });
169
+ try {
170
+ for (let elapsed = 0; elapsed < 30000; elapsed += 100) {
171
+ const record = await readServer(directory, reviewId);
172
+ if (record?.pid === child.pid) return record;
173
+ if (exited !== null) break;
174
+ await new Promise((resolve) => setTimeout(resolve, 100));
175
+ }
176
+ } finally {
177
+ child.unref();
178
+ }
179
+ const detail = new TextDecoder()
180
+ .decode(await readFileLimited(log, 65536).catch(() => Buffer.alloc(0)))
181
+ .trim();
182
+ if (exited === null) child.kill('SIGTERM');
183
+ throw new Error(detail.replace(/^spectater: /, '') || 'Review server did not start.');
184
+ }
package/src/changes.js ADDED
@@ -0,0 +1,82 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { promisify } from 'node:util';
6
+
7
+ export function sourceChanges(state) {
8
+ if (!state.previousDocuments) return [];
9
+ const before = state.previousDocuments,
10
+ after = state.currentDocuments || {};
11
+ return [...new Set([...Object.keys(before), ...Object.keys(after)])]
12
+ .flatMap((id) => {
13
+ if (before[id]?.hash === after[id]?.hash) return [];
14
+ return [
15
+ {
16
+ id,
17
+ path: (after[id] || before[id]).path,
18
+ status: !before[id] ? 'added' : !after[id] ? 'removed' : 'changed',
19
+ beforeHash: before[id]?.hash,
20
+ afterHash: after[id]?.hash,
21
+ },
22
+ ];
23
+ })
24
+ .sort((a, b) => a.path.localeCompare(b.path));
25
+ }
26
+
27
+ export async function compareSources(state, id, run = promisify(execFile)) {
28
+ const change = sourceChanges(state).find((item) => item.id === id);
29
+ if (!change) throw new Error('No source comparison for this document.');
30
+ const directory = await mkdtemp(path.join(os.tmpdir(), 'spectater-diff-'));
31
+ try {
32
+ for (const [name, hash] of [
33
+ ['before', change.beforeHash],
34
+ ['after', change.afterHash],
35
+ ]) {
36
+ await writeFile(path.join(directory, name), hash ? state.snapshots[hash].source : '', {
37
+ mode: 0o600,
38
+ flag: 'wx',
39
+ });
40
+ }
41
+ const env = Object.fromEntries(
42
+ Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_')),
43
+ );
44
+ Object.assign(env, {
45
+ GIT_CONFIG_NOSYSTEM: '1',
46
+ GIT_CONFIG_GLOBAL: os.devNull,
47
+ GIT_ATTR_NOSYSTEM: '1',
48
+ LC_ALL: 'C',
49
+ });
50
+ let diff;
51
+ try {
52
+ ({ stdout: diff } = await run(
53
+ 'git',
54
+ [
55
+ '--no-pager',
56
+ '-c',
57
+ `core.attributesFile=${os.devNull}`,
58
+ 'diff',
59
+ '--no-index',
60
+ '--no-ext-diff',
61
+ '--no-textconv',
62
+ '--no-color',
63
+ '--text',
64
+ '--unified=3',
65
+ '--',
66
+ 'before',
67
+ 'after',
68
+ ],
69
+ { cwd: directory, env, timeout: 3000, maxBuffer: 256 * 1024 },
70
+ ));
71
+ } catch (error) {
72
+ if (error.code === 1 && typeof error.stdout === 'string') diff = error.stdout;
73
+ else
74
+ throw new Error(
75
+ 'Comparison unavailable. Git must be installed; comparisons are limited to 3 seconds and 256 KiB of output. Reading and feedback remain available.',
76
+ );
77
+ }
78
+ return { ...change, diff };
79
+ } finally {
80
+ await rm(directory, { recursive: true, force: true });
81
+ }
82
+ }
package/src/config.js ADDED
@@ -0,0 +1,44 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import { readFileLimited } from './files.js';
4
+
5
+ export const defaults = Object.freeze({ defaultView: 'rendered', openBrowser: true });
6
+ function configPath(env = process.env) {
7
+ return (
8
+ env.SPECTATER_CONFIG ||
9
+ path.join(env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'spectater', 'config.json')
10
+ );
11
+ }
12
+
13
+ export async function loadConfig(explicit, env = process.env) {
14
+ const file = explicit || configPath(env);
15
+ let text;
16
+ try {
17
+ text = new TextDecoder('utf-8', { fatal: true }).decode(await readFileLimited(file, 16384));
18
+ } catch (error) {
19
+ if (error.code === 'ENOENT' && !explicit && !env.SPECTATER_CONFIG) return { ...defaults };
20
+ throw new Error(`Cannot read SpecTater config: ${error.message}`);
21
+ }
22
+ try {
23
+ const config = JSON.parse(text);
24
+ if (!config || typeof config !== 'object' || Array.isArray(config))
25
+ throw new Error('Expected an object.');
26
+ for (const key of Object.keys(config))
27
+ if (!['defaultView', 'openBrowser', 'stateDirectory'].includes(key))
28
+ throw new Error(`Unknown setting: ${key}`);
29
+ if (config.defaultView !== undefined && !['rendered', 'source'].includes(config.defaultView))
30
+ throw new Error('defaultView must be rendered or source.');
31
+ if (config.openBrowser !== undefined && typeof config.openBrowser !== 'boolean')
32
+ throw new Error('openBrowser must be true or false.');
33
+ if (
34
+ config.stateDirectory !== undefined &&
35
+ (typeof config.stateDirectory !== 'string' || !path.isAbsolute(config.stateDirectory))
36
+ )
37
+ throw new Error('stateDirectory must be an absolute path.');
38
+ return { ...defaults, ...config };
39
+ } catch (error) {
40
+ throw new Error(
41
+ `Invalid SpecTater config: ${error.message} Fix it or remove the optional file to use defaults.`,
42
+ );
43
+ }
44
+ }
@@ -0,0 +1,158 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { constants } from 'node:fs';
4
+ import { lstat, open, realpath } from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import { promisify } from 'node:util';
7
+ import { readLimited } from './files.js';
8
+
9
+ export const hash = (value) => createHash('sha256').update(value).digest('hex');
10
+ export function within(root, file) {
11
+ const relative = path.relative(root, file);
12
+ return (
13
+ relative !== '' &&
14
+ !relative.startsWith(`..${path.sep}`) &&
15
+ relative !== '..' &&
16
+ !path.isAbsolute(relative)
17
+ );
18
+ }
19
+
20
+ export async function native(args, cwd) {
21
+ try {
22
+ const { stdout } = await promisify(execFile)('openspec', args, {
23
+ cwd,
24
+ timeout: 15000,
25
+ maxBuffer: 4 * 1024 * 1024,
26
+ env: { ...process.env, OPENSPEC_TELEMETRY: '0' },
27
+ });
28
+ return stdout;
29
+ } catch (error) {
30
+ throw new Error(
31
+ `OpenSpec failed: ${error.stderr?.trim() || error.stdout?.trim() || error.message}`,
32
+ );
33
+ }
34
+ }
35
+
36
+ export function createDiscovery() {
37
+ let version;
38
+ return (options) =>
39
+ discover(options, async (args, cwd) => {
40
+ if (args[0] !== '--version') return native(args, cwd);
41
+ version ??= await native(args, cwd);
42
+ return version;
43
+ });
44
+ }
45
+
46
+ export async function discover({ project, change, store }, run = native) {
47
+ if (!project || !change || !/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(change))
48
+ throw new Error('Supply --project and a valid --change id.');
49
+ const cwd = await realpath(project);
50
+ const version = (await run(['--version'], cwd)).trim();
51
+ const [major, minor] = version.split('.').map(Number);
52
+ if (!/^\d+\.\d+\.\d+/.test(version) || major < 1 || (major === 1 && minor < 15))
53
+ throw new Error(`OpenSpec ${version} is not supported. Install OpenSpec 1.15.0 or newer.`);
54
+ const selector = store ? ['--store', store] : [];
55
+ const json = async (args) => {
56
+ try {
57
+ return JSON.parse(await run([...args, ...selector, '--json'], cwd));
58
+ } catch (error) {
59
+ throw new Error(`OpenSpec JSON/discovery error: ${error.message}`);
60
+ }
61
+ };
62
+ const context = await json(['context']);
63
+ if (
64
+ !context.root?.path ||
65
+ !['nearest', 'declared', 'store', 'global_default'].includes(context.root.source)
66
+ )
67
+ throw new Error('Unsupported OpenSpec root contract.');
68
+ if (context.root.source === 'global_default' && !store)
69
+ throw new Error('Global default refused. Select an explicit --store id.');
70
+ const root = await realpath(context.root.path);
71
+ const list = await json(['list']);
72
+ if (!Array.isArray(list.changes) || !list.changes.some((item) => item.name === change))
73
+ throw new Error(`Change not found: ${change}`);
74
+ const status = await json(['status', '--change', change]);
75
+ if (
76
+ status.changeName !== change ||
77
+ typeof status.schemaName !== 'string' ||
78
+ !Array.isArray(status.artifacts) ||
79
+ !status.artifactPaths ||
80
+ !status.changeRoot ||
81
+ !status.root?.path
82
+ )
83
+ throw new Error('Unsupported OpenSpec status contract.');
84
+ if (
85
+ (await realpath(status.root.path)) !== root ||
86
+ !list.root?.path ||
87
+ (await realpath(list.root.path)) !== root
88
+ )
89
+ throw new Error('OpenSpec root changed during discovery.');
90
+ const changeRoot = await realpath(status.changeRoot);
91
+ if (!within(root, changeRoot)) throw new Error('Change escapes the selected OpenSpec root.');
92
+ const documents = [];
93
+ for (const artifact of status.artifacts) {
94
+ const outputs = status.artifactPaths[artifact.id]?.existingOutputPaths;
95
+ if (
96
+ typeof artifact.id !== 'string' ||
97
+ typeof artifact.status !== 'string' ||
98
+ !Array.isArray(outputs)
99
+ )
100
+ throw new Error('Unsupported OpenSpec artifact contract.');
101
+ for (const file of outputs) {
102
+ if (typeof file !== 'string' || !path.isAbsolute(file) || !within(changeRoot, file))
103
+ throw new Error('Artifact escapes the selected change.');
104
+ const canonical = await realpath(file);
105
+ if (!within(changeRoot, canonical))
106
+ throw new Error('Artifact symlink escapes the selected change.');
107
+ const relative = path.relative(changeRoot, file).split(path.sep).join('/');
108
+ if (!documents.some((item) => item.path === relative))
109
+ documents.push({
110
+ id: hash(relative).slice(0, 24),
111
+ path: relative,
112
+ file,
113
+ canonical,
114
+ supported: /\.md$/i.test(file),
115
+ artifact: artifact.id,
116
+ });
117
+ }
118
+ }
119
+ if (documents.length > 128) throw new Error('Review exceeds 128 artifacts.');
120
+ return {
121
+ root,
122
+ changeRoot,
123
+ change,
124
+ schemaName: status.schemaName,
125
+ source: context.root.source,
126
+ reviewId: hash(JSON.stringify([root, changeRoot])),
127
+ artifacts: status.artifacts,
128
+ documents,
129
+ };
130
+ }
131
+
132
+ export async function readDocument(review, document) {
133
+ if (
134
+ (await realpath(review.root)) !== review.root ||
135
+ (await realpath(review.changeRoot)) !== review.changeRoot
136
+ )
137
+ throw new Error('Planning identity changed. Reopen explicitly.');
138
+ const canonical = await realpath(document.file);
139
+ if (canonical !== document.canonical || !within(review.changeRoot, canonical))
140
+ throw new Error('Document identity changed or escaped the change.');
141
+ if (!(await lstat(canonical)).isFile()) throw new Error('Artifact is not a regular file.');
142
+ const handle = await open(
143
+ canonical,
144
+ constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
145
+ );
146
+ try {
147
+ const stat = await handle.stat();
148
+ if (!stat.isFile() || stat.size > 1024 * 1024)
149
+ throw new Error('Artifact must be a regular file smaller than 1 MiB.');
150
+ const bytes = await readLimited(handle, 1024 * 1024);
151
+ if (bytes.length > 1024 * 1024 || (await realpath(document.file)) !== canonical)
152
+ throw new Error('Document changed during read.');
153
+ const source = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
154
+ return { path: document.path, hash: hash(bytes), source };
155
+ } finally {
156
+ await handle.close();
157
+ }
158
+ }
package/src/files.js ADDED
@@ -0,0 +1,25 @@
1
+ import { constants } from 'node:fs';
2
+ import { open } from 'node:fs/promises';
3
+
4
+ export async function readLimited(handle, limit) {
5
+ const chunks = [];
6
+ let length = 0;
7
+ while (true) {
8
+ const chunk = Buffer.allocUnsafe(Math.min(65536, limit + 1 - length));
9
+ const { bytesRead } = await handle.read(chunk);
10
+ if (!bytesRead) return Buffer.concat(chunks, length);
11
+ length += bytesRead;
12
+ if (length > limit) throw new Error(`File exceeds ${limit} bytes.`);
13
+ chunks.push(chunk.subarray(0, bytesRead));
14
+ }
15
+ }
16
+
17
+ export async function readFileLimited(file, limit) {
18
+ const handle = await open(file, constants.O_RDONLY | constants.O_NONBLOCK);
19
+ try {
20
+ if (!(await handle.stat()).isFile()) throw new Error('Expected a regular file.');
21
+ return await readLimited(handle, limit);
22
+ } finally {
23
+ await handle.close();
24
+ }
25
+ }
package/src/hooks.js ADDED
@@ -0,0 +1,145 @@
1
+ import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ const COMMAND = 'spectater status --hook';
6
+ const managed = (value) => typeof value === 'string' && value.includes(COMMAND);
7
+
8
+ async function readText(file) {
9
+ try {
10
+ return await readFile(file, 'utf8');
11
+ } catch (error) {
12
+ if (error.code === 'ENOENT') return undefined;
13
+ throw error;
14
+ }
15
+ }
16
+
17
+ async function updateJson(file, update) {
18
+ const text = await readText(file);
19
+ const current = text === undefined ? {} : JSON.parse(text);
20
+ const next = update(structuredClone(current));
21
+ if (JSON.stringify(next) === JSON.stringify(current)) return 'unchanged';
22
+ await mkdir(path.dirname(file), { recursive: true });
23
+ await writeFile(file, `${JSON.stringify(next, null, 2)}\n`);
24
+ return text === undefined ? 'created' : 'updated';
25
+ }
26
+
27
+ // Claude Code and Codex share the SessionStart hook schema.
28
+ function sessionStart(remove) {
29
+ return (settings) => {
30
+ settings.hooks ||= {};
31
+ const groups = (settings.hooks.SessionStart || [])
32
+ .map((group) => ({ ...group, hooks: (group.hooks || []).filter((h) => !managed(h.command)) }))
33
+ .filter((group) => group.hooks.length);
34
+ if (!remove)
35
+ groups.push({ matcher: '', hooks: [{ type: 'command', command: COMMAND, timeout: 10 }] });
36
+ if (groups.length) settings.hooks.SessionStart = groups;
37
+ else delete settings.hooks.SessionStart;
38
+ if (!Object.keys(settings.hooks).length) delete settings.hooks;
39
+ return settings;
40
+ };
41
+ }
42
+
43
+ function copilot(settings) {
44
+ settings.version = 1;
45
+ settings.hooks ||= {};
46
+ const hooks = (settings.hooks.sessionStart || []).filter((h) => !managed(h.bash));
47
+ hooks.push({ type: 'command', bash: `${COMMAND} copilot`, timeoutSec: 10 });
48
+ settings.hooks.sessionStart = hooks;
49
+ return settings;
50
+ }
51
+
52
+ const OPENCODE_MARKER = '// Managed by spectater setup hooks.';
53
+ const openCodePlugin = `${OPENCODE_MARKER}
54
+ import { execFile } from 'node:child_process';
55
+
56
+ export const SpecTaterPlugin = async ({ directory }) => {
57
+ const seen = new Map();
58
+ const status = () =>
59
+ new Promise((resolve) =>
60
+ execFile('spectater', ['status', '--hook'], { cwd: directory, timeout: 10000 }, (error, stdout) =>
61
+ resolve(error ? '' : stdout.trim()),
62
+ ),
63
+ );
64
+ return {
65
+ 'experimental.chat.system.transform': async (input, output) => {
66
+ const key = input.sessionID ?? '';
67
+ if (!seen.has(key)) seen.set(key, await status());
68
+ if (seen.get(key)) output.system.push(seen.get(key));
69
+ },
70
+ };
71
+ };
72
+ `;
73
+
74
+ async function openCode(file, remove) {
75
+ const text = await readText(file);
76
+ if (text !== undefined && !text.startsWith(OPENCODE_MARKER))
77
+ throw new Error('refusing to replace an unmanaged plugin');
78
+ if (remove) {
79
+ if (text === undefined) return 'unchanged';
80
+ await rm(file);
81
+ return 'removed';
82
+ }
83
+ if (text === openCodePlugin) return 'unchanged';
84
+ await mkdir(path.dirname(file), { recursive: true });
85
+ await writeFile(file, openCodePlugin);
86
+ return text === undefined ? 'created' : 'updated';
87
+ }
88
+
89
+ async function exists(directory) {
90
+ try {
91
+ return (await stat(directory)).isDirectory();
92
+ } catch {
93
+ return false;
94
+ }
95
+ }
96
+
97
+ async function removeFile(file) {
98
+ if ((await readText(file)) === undefined) return 'unchanged';
99
+ await rm(file);
100
+ return 'removed';
101
+ }
102
+
103
+ /**
104
+ * Installs or removes SessionStart hooks for each agent whose configuration directory exists.
105
+ * Returns one result per agent and never throws.
106
+ */
107
+ export async function setupHooks({ remove = false, home = os.homedir(), env = process.env } = {}) {
108
+ const copilotHome = env.COPILOT_HOME || path.join(home, '.copilot');
109
+ const targets = [
110
+ {
111
+ agent: 'Claude Code',
112
+ directory: path.join(home, '.claude'),
113
+ file: path.join(home, '.claude', 'settings.json'),
114
+ apply: (file) => updateJson(file, sessionStart(remove)),
115
+ },
116
+ {
117
+ agent: 'Codex',
118
+ directory: path.join(home, '.codex'),
119
+ file: path.join(home, '.codex', 'hooks.json'),
120
+ apply: (file) => updateJson(file, sessionStart(remove)),
121
+ },
122
+ {
123
+ agent: 'OpenCode',
124
+ directory: path.join(home, '.config', 'opencode'),
125
+ file: path.join(home, '.config', 'opencode', 'plugins', 'spectater.js'),
126
+ apply: (file) => openCode(file, remove),
127
+ },
128
+ {
129
+ agent: 'GitHub Copilot CLI',
130
+ directory: copilotHome,
131
+ file: path.join(copilotHome, 'hooks', 'spectater.json'),
132
+ apply: (file) => (remove ? removeFile(file) : updateJson(file, copilot)),
133
+ },
134
+ ];
135
+ const results = [];
136
+ for (const { agent, directory, file, apply } of targets) {
137
+ try {
138
+ const result = (await exists(directory)) ? await apply(file) : 'skipped (not installed)';
139
+ results.push({ agent, file, result });
140
+ } catch (error) {
141
+ results.push({ agent, file, result: 'failed', error: error.message });
142
+ }
143
+ }
144
+ return results;
145
+ }