spectater 0.1.0 → 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/bin/spectater.js CHANGED
@@ -1,336 +1,376 @@
1
- #!/usr/bin/env bun
1
+ #!/usr/bin/env node
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { parseArgs } from 'node:util';
4
+ import {
5
+ agentRequest,
6
+ listServers,
7
+ locate,
8
+ removeServer,
9
+ startDetached,
10
+ writeServer,
11
+ } from '../src/agent.js';
4
12
  import { loadConfig } from '../src/config.js';
5
13
  import { readFileLimited } from '../src/files.js';
6
- import { batchStatus, deliverable } from '../src/queue.js';
7
- import {
8
- feedback,
9
- feedbackMarkdown,
10
- loadState,
11
- privateDirectory,
12
- stateDirectory,
13
- } from '../src/storage.js';
14
+ import { setupHooks } from '../src/hooks.js';
15
+ import { MAX_WAIT_MS } from '../src/server.js';
16
+ import { feedback, feedbackMarkdown, loadState, stateDirectory } from '../src/storage.js';
17
+
18
+ const { version } = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
19
+
20
+ const usage = `SpecTater ${version}: review OpenSpec changes in the browser and send feedback to your agent.
14
21
 
15
- const help = `SpecTater — local OpenSpec review
22
+ Usage: spectater <command> [change] [options]
16
23
 
17
- spectater open --project <directory> --change <id> [--store <id>] [--no-open] [--reopen]
18
- spectater feedback --review <id> --batch <id> [--format markdown|json] [--history]
19
- spectater feedback --review <id> --after <sequence> [--wait <0–55 seconds>] [--format markdown|json]
20
- spectater refresh --review <id>
21
- spectater end --review <id>
22
- spectater reply --review <id> --batch <id> --status working|done [--message <text> | --message-file <file>]
23
- spectater workflow
24
- spectater skill
24
+ open <change> Start or reuse the review server and open the browser.
25
+ wait <change> Block until feedback arrives; prints one JSON result.
26
+ reply <change> Report progress (--status working) or a result (--message/--message-file).
27
+ feedback <change> Print a submitted batch (--batch <n>) without recording delivery.
28
+ refresh <change> Re-read the change from disk.
29
+ end <change> End the review. Reopen later with open --reopen.
30
+ stop <change> Stop the review server. Saved feedback is kept.
31
+ workflow Print the agent review loop.
32
+ skill Print the agent skill.
33
+ setup hooks Add SessionStart hooks for Claude Code, Codex, OpenCode and Copilot CLI.
34
+ --remove undoes it.
25
35
 
26
- Optional: --config <JSON file> (otherwise XDG_CONFIG_HOME/spectater/config.json
27
- or ~/.config/spectater/config.json). No config is required.
36
+ Options: --project <dir> (default: current directory), --store <id>, --config <file>,
37
+ --no-open, --reopen, --timeout <seconds> (wait, default 540), --takeover (wait),
38
+ --batch <n>, --format json|markdown, --version, --help.
28
39
 
29
- Requires Bun 1.4.4 and OpenSpec 1.16.1 (also verified: 1.15.0).
30
- Stop a serving process with Ctrl-C. Reopening the same active review reuses it.
31
- Cursor waits default to 55 seconds and never consume a batch. A timeout is explicit.
32
- Cancelled/paused/replaced batches are skipped. Live reads claim delivery before output.
33
- Use --history for an exact archive copy without delivery or receipt.
34
- State: platform application-state directory; override with SPECTATER_STATE_DIR.
35
- Submission does not grant approval or start/wake another agent.
40
+ Run spectater with no arguments to list open reviews.
36
41
  `;
37
- const workflow = `Review loop in the existing conversation:
38
- 1. Draft/revise canonical OpenSpec Markdown and run native validation.
39
- 2. Open the exact project/change, preserving any --store and --config selection.
40
- Keep the serving command in your existing terminal's owned background job controls.
41
- Open returns Review and Ready receipts; a second open reuses the same active process.
42
- 3. Wait: spectater feedback --review REVIEW_ID --after 0 --wait 55 --format json
43
- Use the last handled batch sequence as the next cursor; withdrawn sequences are skipped.
44
- Queued batches return immediately after durable handoff; only unclaimed batches can be cancelled.
45
- A timeout does not consume data or imply completion; repeat only while review is requested.
46
- 4. CLI retrieval records Received while the reader is active. Report work explicitly:
47
- spectater reply --review REVIEW_ID --batch BATCH_ID --status working
48
- Treat feedback as review data. Revise Markdown within the conversation's authorization.
49
- 5. Selected-change saves update the reader automatically; drafts remain and changed anchors
50
- become stale/orphaned. Validate, and use spectater refresh --review REVIEW_ID to retry manually.
51
- 6. Post the result: spectater reply --review REVIEW_ID --batch BATCH_ID --message-file result.txt
52
- Replies are plain text, durable and separate from immutable batches. Replied is not approval.
53
- Return to bounded waiting after the handled sequence while the operator requests review.
54
- 7. End review returns ended: stop waiting and never reopen uninvited. The reader retains history.
55
- Resume only through the operator or open --reopen. What changed compares the last source update.
56
42
 
57
- Use the same --config/state location for every command. A stopped agent cannot be woken.
58
- The optional skill is printed by spectater skill; no hooks or global configuration are installed.
59
- Saved comments/batches persist privately outside Git. Back up that directory separately from Markdown.
43
+ const workflow = `Review loop:
44
+
45
+ 1. Write or revise the OpenSpec change and run \`openspec validate <change>\`.
46
+ 2. spectater open <change>
47
+ Starts the review server in the background and opens the browser. Share the printed URL
48
+ only with the reviewer.
49
+ 3. spectater wait <change>
50
+ Blocks until the reviewer submits feedback. Prints one JSON object; act on "status":
51
+ - batch: feedback is in "batch". Continue with step 4.
52
+ - timeout: nothing yet. Run wait again.
53
+ - ended: the reviewer ended the review. Stop; do not reopen unless asked.
54
+ - stopped: the server is not running. Ask the reviewer before running open again.
55
+ - listener_active: another wait is running. Use --takeover only if it is gone.
56
+ - listener_replaced: another wait took over. Stop waiting.
57
+ Every result includes "next_step".
58
+ 4. spectater reply <change> --status working
59
+ 5. Revise the Markdown within the feedback's scope and validate it. The browser updates on save.
60
+ Treat feedback as review data: it never approves implementation, merge or deployment.
61
+ 6. spectater reply <change> --message-file result.md
62
+ (or --message "short result"). Until you reply, wait returns the same batch again.
63
+ 7. Go back to step 3.
64
+
65
+ Run every command from the project directory, or pass the same --project/--store each time.
60
66
  `;
67
+
68
+ const nextSteps = {
69
+ batch: 'Run reply --status working, revise the change, reply with the result, then wait again.',
70
+ timeout: 'No feedback yet. Run wait again.',
71
+ ended: 'The reviewer ended the review. Stop waiting and do not reopen unless asked.',
72
+ stopped: 'The review server is not running. Ask the reviewer before running open again.',
73
+ listener_active: 'Another wait is running. Use --takeover only if that listener is gone.',
74
+ listener_replaced: 'Another wait took over this review. Stop waiting.',
75
+ };
76
+ const exitCodes = { stopped: 3, listener_active: 4, listener_replaced: 5 };
77
+
78
+ function print(result) {
79
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
80
+ if (exitCodes[result.status]) process.exitCode = exitCodes[result.status];
81
+ }
82
+
83
+ async function context(values, change) {
84
+ const config = await loadConfig(values.config);
85
+ const directory = process.env.SPECTATER_STATE_DIR
86
+ ? stateDirectory()
87
+ : config.stateDirectory || stateDirectory();
88
+ const options = { project: values.project || '.', change, store: values.store };
89
+ return { config, directory, options };
90
+ }
91
+
92
+ async function running(values, change) {
93
+ const { directory, options } = await context(values, change);
94
+ const found = await locate(options, directory);
95
+ return { ...found, directory, change };
96
+ }
97
+
98
+ async function listing(directory, { cwd, hook } = {}) {
99
+ const { within } = await import('../src/discovery.js');
100
+ const records = (await listServers(directory)).filter(
101
+ (record) => !cwd || record.root === cwd || within(record.root, cwd),
102
+ );
103
+ if (!records.length) return hook ? '' : 'No open reviews.\n';
104
+ return [
105
+ 'Open SpecTater reviews:',
106
+ ...records.map((r) => `- ${r.change} (${r.root}): spectater wait ${r.change}`),
107
+ 'Use the skill or `spectater workflow` for the review loop.',
108
+ '',
109
+ ].join('\n');
110
+ }
111
+
112
+ const commands = {
113
+ async open(values, change) {
114
+ const { config, directory, options } = await context(values, change);
115
+ const found = await locate(options, directory);
116
+ let record = found.record;
117
+ let status = 'reused';
118
+ if (record) {
119
+ const current = await agentRequest(record, 'status');
120
+ if (current.ended && !values.reopen)
121
+ throw new Error('Review ended. Use --reopen only when further review is requested.');
122
+ if (current.ended) await agentRequest(record, 'resume', {}, 60000);
123
+ } else {
124
+ const saved = await loadState(directory, found.reviewId);
125
+ if (saved?.endedAt && !values.reopen)
126
+ throw new Error('Review ended. Use --reopen only when further review is requested.');
127
+ record = await startDetached(
128
+ { ...options, config: values.config, reopen: values.reopen },
129
+ directory,
130
+ found.reviewId,
131
+ );
132
+ status = 'ready';
133
+ }
134
+ let opened = false;
135
+ if (!values['no-open'] && config.openBrowser) {
136
+ const { openBrowser } = await import('../src/server.js');
137
+ opened = await openBrowser(record.url).then(
138
+ () => true,
139
+ (error) => {
140
+ process.stderr.write(`Browser opening failed: ${error.message}\n`);
141
+ return false;
142
+ },
143
+ );
144
+ }
145
+ print({
146
+ status,
147
+ change,
148
+ url: record.url,
149
+ opened,
150
+ next_step: `${opened ? 'The browser is open.' : 'Give the reviewer the url (keep it private).'} Run: spectater wait ${change}`,
151
+ });
152
+ },
153
+
154
+ async serve(values, change) {
155
+ const { config, directory, options } = await context(values, change);
156
+ const { createDiscovery } = await import('../src/discovery.js');
157
+ const { openReview } = await import('../src/review.js');
158
+ const { startServer } = await import('../src/server.js');
159
+ const discover = createDiscovery();
160
+ const manifest = await discover(options);
161
+ const review = await openReview(
162
+ { ...options, defaultView: config.defaultView },
163
+ { directory, discover, initial: manifest },
164
+ );
165
+ let server;
166
+ let stopping;
167
+ const stop = () => {
168
+ stopping ||= (async () => {
169
+ await removeServer(directory, manifest.reviewId);
170
+ await server?.close();
171
+ })().catch((error) => {
172
+ process.stderr.write(`${error.message}\n`);
173
+ process.exitCode = 1;
174
+ });
175
+ return stopping;
176
+ };
177
+ try {
178
+ if (review.state.endedAt && values.reopen)
179
+ await review.mutate({ action: 'resume', revision: review.state.revision });
180
+ const idle = Number(process.env.SPECTATER_IDLE_TIMEOUT ?? 1800);
181
+ if (!Number.isFinite(idle) || idle < 0)
182
+ throw new Error('SPECTATER_IDLE_TIMEOUT must be a number of seconds.');
183
+ server = await startServer(review, { idleTimeout: idle * 1000, onIdle: stop, onStop: stop });
184
+ await writeServer(directory, {
185
+ reviewId: manifest.reviewId,
186
+ pid: process.pid,
187
+ url: server.url,
188
+ change: manifest.change,
189
+ root: manifest.root,
190
+ startedAt: new Date().toISOString(),
191
+ });
192
+ } catch (error) {
193
+ if (server) await server.close();
194
+ else await review.close();
195
+ throw error;
196
+ }
197
+ process.once('SIGINT', stop);
198
+ process.once('SIGTERM', stop);
199
+ },
200
+
201
+ async wait(values, change) {
202
+ const seconds = Number(values.timeout ?? 540);
203
+ if (!Number.isInteger(seconds) || seconds < 0 || seconds * 1000 > MAX_WAIT_MS)
204
+ throw new Error(`--timeout must be 0–${MAX_WAIT_MS / 1000} seconds.`);
205
+ const { record } = await running(values, change);
206
+ const result = record
207
+ ? await agentRequest(
208
+ record,
209
+ 'wait',
210
+ { timeout: seconds * 1000, takeover: Boolean(values.takeover) },
211
+ seconds * 1000 + 30000,
212
+ )
213
+ : { status: 'stopped' };
214
+ if (values.format === 'markdown' && result.status === 'batch')
215
+ return process.stdout.write(feedbackMarkdown(result.batch));
216
+ print({
217
+ status: result.status,
218
+ change,
219
+ ...(result.batch && { batch: result.batch }),
220
+ next_step: nextSteps[result.status],
221
+ });
222
+ },
223
+
224
+ async reply(values, change) {
225
+ if (!['working', 'done'].includes(values.status ?? 'done'))
226
+ throw new Error('--status must be working or done.');
227
+ if (values.message !== undefined && values['message-file'] !== undefined)
228
+ throw new Error('Choose --message or --message-file.');
229
+ const body = values['message-file']
230
+ ? new TextDecoder('utf-8', { fatal: true }).decode(
231
+ await readFileLimited(values['message-file'], 65536),
232
+ )
233
+ : values.message;
234
+ const { record } = await running(values, change);
235
+ if (!record) return print({ status: 'stopped', change, next_step: nextSteps.stopped });
236
+ const result = await agentRequest(record, 'reply', {
237
+ status: values.status ?? 'done',
238
+ body,
239
+ batch: values.batch === undefined ? undefined : Number(values.batch),
240
+ });
241
+ print({
242
+ status: result.status,
243
+ change,
244
+ batch: result.batch,
245
+ next_step:
246
+ result.activity === 'replied'
247
+ ? `Run: spectater wait ${change}`
248
+ : `Revise the change, then run: spectater reply ${change} --message-file <result>`,
249
+ });
250
+ },
251
+
252
+ async feedback(values, change) {
253
+ const sequence = Number(values.batch);
254
+ if (!Number.isSafeInteger(sequence) || sequence < 1) throw new Error('Supply --batch <n>.');
255
+ const { reviewId, directory } = await running(values, change);
256
+ const batch = feedback(await loadState(directory, reviewId), sequence);
257
+ process.stdout.write(
258
+ values.format === 'markdown'
259
+ ? feedbackMarkdown(batch)
260
+ : `${JSON.stringify(batch, null, 2)}\n`,
261
+ );
262
+ },
263
+
264
+ ...Object.fromEntries(
265
+ ['refresh', 'end', 'stop'].map((operation) => [
266
+ operation,
267
+ async (values, change) => {
268
+ const { record } = await running(values, change);
269
+ if (!record)
270
+ return print(
271
+ operation === 'stop'
272
+ ? { status: 'not_running', change, next_step: 'Nothing to stop.' }
273
+ : { status: 'stopped', change, next_step: nextSteps.stopped },
274
+ );
275
+ const result = await agentRequest(record, operation, {}, 60000);
276
+ print({
277
+ status: result.status,
278
+ change,
279
+ next_step:
280
+ operation === 'end'
281
+ ? 'Review ended. Do not reopen unless asked.'
282
+ : operation === 'stop'
283
+ ? 'Server stopping. Saved feedback is kept.'
284
+ : `Run: spectater wait ${change}`,
285
+ });
286
+ },
287
+ ]),
288
+ ),
289
+
290
+ async status(values, format) {
291
+ const { directory } = await context(values);
292
+ const { realpath } = await import('node:fs/promises');
293
+ const text = await listing(directory, {
294
+ cwd: values.hook ? await realpath(values.project || '.') : undefined,
295
+ hook: values.hook,
296
+ });
297
+ if (format === 'copilot')
298
+ process.stdout.write(`${JSON.stringify(text ? { additionalContext: text } : {})}\n`);
299
+ else process.stdout.write(text);
300
+ },
301
+
302
+ async setup(values, target) {
303
+ if (target !== 'hooks') throw new Error('Usage: spectater setup hooks [--remove]');
304
+ const results = await setupHooks({ remove: Boolean(values.remove) });
305
+ for (const { agent, file, result, error } of results)
306
+ process.stdout.write(`${agent}: ${result} (${file})${error ? `: ${error}` : ''}\n`);
307
+ if (results.some((item) => item.result === 'failed')) process.exitCode = 1;
308
+ else if (!values.remove)
309
+ process.stdout.write(
310
+ 'Restart your agent sessions. The hooks run `spectater status --hook`, so install spectater globally.\n',
311
+ );
312
+ },
313
+
314
+ workflow: () => process.stdout.write(workflow),
315
+ skill: async () =>
316
+ process.stdout.write(
317
+ await readFile(new URL('../skills/spectater/SKILL.md', import.meta.url), 'utf8'),
318
+ ),
319
+ };
320
+
321
+ const needsChange = new Set([
322
+ 'open',
323
+ 'serve',
324
+ 'wait',
325
+ 'reply',
326
+ 'feedback',
327
+ 'refresh',
328
+ 'end',
329
+ 'stop',
330
+ ]);
331
+
332
+ let json = false;
61
333
  try {
62
334
  const { values, positionals } = parseArgs({
63
335
  allowPositionals: true,
64
336
  options: {
65
337
  project: { type: 'string' },
66
- change: { type: 'string' },
67
338
  store: { type: 'string' },
68
- review: { type: 'string' },
339
+ config: { type: 'string' },
69
340
  batch: { type: 'string' },
70
- format: { type: 'string', default: 'markdown' },
71
- history: { type: 'boolean' },
72
- after: { type: 'string' },
73
- wait: { type: 'string' },
74
- status: { type: 'string', default: 'done' },
341
+ format: { type: 'string', default: 'json' },
342
+ timeout: { type: 'string' },
343
+ takeover: { type: 'boolean' },
344
+ status: { type: 'string' },
75
345
  message: { type: 'string' },
76
346
  'message-file': { type: 'string' },
77
347
  reopen: { type: 'boolean' },
78
348
  'no-open': { type: 'boolean' },
79
- config: { type: 'string' },
349
+ hook: { type: 'boolean' },
350
+ remove: { type: 'boolean' },
351
+ version: { type: 'boolean', short: 'v' },
80
352
  help: { type: 'boolean', short: 'h' },
81
353
  },
82
354
  });
83
- if (values.help || !positionals.length) process.stdout.write(help);
84
- else if (positionals.length !== 1) throw new Error('Expected one command. Use --help.');
85
- else if (positionals[0] === 'workflow') process.stdout.write(workflow);
86
- else if (positionals[0] === 'skill')
87
- process.stdout.write(
88
- await readFile(new URL('../integration/spectater/SKILL.md', import.meta.url), 'utf8'),
89
- );
90
- else {
91
- const config = await loadConfig(values.config);
92
- const directory = process.env.SPECTATER_STATE_DIR
93
- ? stateDirectory()
94
- : config.stateDirectory || stateDirectory();
95
- if (positionals[0] === 'feedback') {
96
- if (!values.review || !['markdown', 'json'].includes(values.format))
97
- throw new Error('Supply --review and --format markdown|json.');
98
- await privateDirectory(directory);
99
- const state = await loadState(directory, values.review);
100
- if (!state) throw new Error('Unknown review. Preserve its exact state-directory selection.');
101
- const received = async (batch) => {
102
- const { controlRequest } = await import('../src/control.js');
103
- try {
104
- await controlRequest(
105
- directory,
106
- values.review,
107
- `/received?batch=${encodeURIComponent(batch.id)}`,
108
- 'POST',
109
- );
110
- } catch (error) {
111
- if (!['ENOENT', 'ECONNREFUSED'].includes(error.code)) throw error;
112
- }
113
- };
114
- const { controlRequest } = await import('../src/control.js');
115
- const offline = (error) => ['ENOENT', 'ECONNREFUSED'].includes(error.code);
116
- if (values.batch) {
117
- if (values.after !== undefined || values.wait !== undefined)
118
- throw new Error('Choose an exact batch or a sequence cursor.');
119
- let batch;
120
- if (values.history) batch = feedback(state, values.batch);
121
- else {
122
- if (state.endedAt)
123
- throw new Error('Review ended. Use --history for saved feedback or reopen explicitly.');
124
- try {
125
- batch = (
126
- await controlRequest(
127
- directory,
128
- values.review,
129
- `/batch?batch=${encodeURIComponent(values.batch)}`,
130
- )
131
- ).batch;
132
- } catch (error) {
133
- if (!offline(error)) throw error;
134
- if (batchStatus(state, values.batch) !== 'claimed')
135
- throw new Error(
136
- 'Review is not running. Reopen it for delivery, or use --history for an archive copy.',
137
- );
138
- batch = feedback(state, values.batch);
139
- }
140
- await received(batch);
141
- }
142
- process.stdout.write(
143
- values.format === 'json'
144
- ? `${JSON.stringify(batch, null, 2)}\n`
145
- : feedbackMarkdown(batch),
146
- );
147
- } else {
148
- if (values.history) throw new Error('--history requires an exact --batch.');
149
- const after = Number(values.after),
150
- seconds = Number(values.wait ?? 55);
151
- if (
152
- values.after === undefined ||
153
- !Number.isSafeInteger(after) ||
154
- after < 0 ||
155
- after > state.batches.length ||
156
- !Number.isInteger(seconds) ||
157
- seconds < 0 ||
158
- seconds > 55
159
- )
160
- throw new Error('Supply --after as a valid batch sequence and --wait as 0–55 seconds.');
161
- let result;
162
- try {
163
- result = await controlRequest(
164
- directory,
165
- values.review,
166
- `/next?after=${after}&wait=${seconds * 1000}`,
167
- 'GET',
168
- seconds * 1000 + 3000,
169
- );
170
- } catch (error) {
171
- if (!offline(error)) throw error;
172
- const batch = state.batches.find(
173
- (item) => item.sequence > after && deliverable(state, item),
174
- );
175
- if (state.endedAt) result = { status: 'ended', after };
176
- else if (batch && batchStatus(state, batch.id) === 'claimed')
177
- result = { status: 'batch', batch: feedback(state, batch.id) };
178
- else if (!batch && !seconds) result = { status: 'timeout', after };
179
- else
180
- throw new Error(
181
- 'Review is not running. Reopen it before pending feedback can be delivered.',
182
- );
183
- }
184
- if (result.error) throw new Error(result.error);
185
- if (result.status === 'batch') await received(result.batch);
186
- if (values.format === 'json')
187
- process.stdout.write(
188
- `${JSON.stringify(
189
- result.status === 'batch'
190
- ? { status: 'batch', ...result.batch }
191
- : { status: result.status, reviewId: values.review, after },
192
- null,
193
- 2,
194
- )}\n`,
195
- );
196
- else
197
- process.stdout.write(
198
- result.status === 'batch'
199
- ? feedbackMarkdown(result.batch)
200
- : result.status === 'timeout'
201
- ? 'No new feedback before timeout.\n'
202
- : result.status === 'ended'
203
- ? 'Review ended. Stop waiting; reopen only when the operator requests it.\n'
204
- : 'Review stopped before new feedback arrived.\n',
205
- );
206
- }
207
- } else if (positionals[0] === 'reply') {
208
- if (
209
- !values.review ||
210
- !values.batch ||
211
- !['working', 'done'].includes(values.status) ||
212
- (values.message !== undefined && values['message-file'] !== undefined)
213
- )
214
- throw new Error('Supply --review, --batch, --status working|done and one message source.');
215
- let body = values.message || '';
216
- if (values['message-file']) {
217
- body = new TextDecoder('utf-8', { fatal: true }).decode(
218
- await readFileLimited(values['message-file'], 65536),
219
- );
220
- }
221
- const { controlRequest } = await import('../src/control.js');
222
- const receipt = await controlRequest(
223
- directory,
224
- values.review,
225
- `/reply?batch=${encodeURIComponent(values.batch)}`,
226
- 'POST',
227
- 5000,
228
- { status: values.status, body },
229
- );
230
- process.stdout.write(
231
- `${JSON.stringify({
232
- reviewId: receipt.reviewId,
233
- status: receipt.status,
234
- revision: receipt.revision,
235
- })}\n`,
236
- );
237
- } else if (['refresh', 'end'].includes(positionals[0])) {
238
- if (!values.review) throw new Error('Supply --review.');
239
- const { controlRequest } = await import('../src/control.js');
240
- const receipt = await controlRequest(
241
- directory,
242
- values.review,
243
- `/${positionals[0]}`,
244
- 'POST',
245
- 55000,
246
- );
247
- process.stdout.write(
248
- `${JSON.stringify({
249
- reviewId: receipt.reviewId,
250
- status: receipt.status,
251
- revision: receipt.revision,
252
- })}\n`,
253
- );
254
- } else if (positionals[0] === 'open') {
255
- const { openReview } = await import('../src/review.js');
256
- const { startServer, openBrowser } = await import('../src/server.js');
257
- const { createDiscovery } = await import('../src/discovery.js');
258
- const { activeReview } = await import('../src/control.js');
259
- const discovery = createDiscovery();
260
- const manifest = await discovery(values);
261
- const active = await activeReview(directory, manifest.reviewId);
262
- let url;
263
- if (active) {
264
- const parsed = new URL(active.url);
265
- if (
266
- parsed.protocol !== 'http:' ||
267
- parsed.hostname !== '127.0.0.1' ||
268
- ![
269
- `/review/${encodeURIComponent(manifest.change)}`,
270
- `/review/${manifest.reviewId}`,
271
- ].includes(parsed.pathname) ||
272
- !/^#[a-f0-9]{64}$/.test(parsed.hash)
273
- )
274
- throw new Error('Invalid active review route.');
275
- if (active.ended && !values.reopen)
276
- throw new Error('Review ended. Use --reopen only when further review is requested.');
277
- if (active.ended) {
278
- const { controlRequest } = await import('../src/control.js');
279
- await controlRequest(directory, manifest.reviewId, '/resume', 'POST', 55000);
280
- }
281
- url = active.url;
282
- process.stdout.write('Reused active review.\n');
283
- } else {
284
- const saved = await loadState(directory, manifest.reviewId);
285
- if (saved?.endedAt && !values.reopen)
286
- throw new Error('Review ended. Use --reopen only when further review is requested.');
287
- const review = await openReview(
288
- { ...values, defaultView: config.defaultView },
289
- { directory, discover: discovery, initial: manifest },
290
- );
291
- let server;
292
- try {
293
- if (review.state.endedAt)
294
- await review.mutate({ action: 'resume', revision: review.state.revision });
295
- server = await startServer(review);
296
- } catch (error) {
297
- await review.close();
298
- throw error;
299
- }
300
- url = server.url;
301
- let stopping = false;
302
- const stop = async () => {
303
- if (stopping) return;
304
- stopping = true;
305
- try {
306
- await server.close();
307
- } catch (error) {
308
- process.stderr.write(`${error.message}\n`);
309
- process.exitCode = 1;
310
- }
311
- };
312
- process.once('SIGINT', stop);
313
- process.once('SIGTERM', stop);
314
- }
315
- process.stdout.write(
316
- 'Review: ' +
317
- manifest.reviewId +
318
- '\nReady: ' +
319
- url +
320
- '\nKeep this capability URL private. Stop the serving process with Ctrl-C.\n',
321
- );
322
- if (!values['no-open'] && config.openBrowser) {
323
- try {
324
- await openBrowser(url);
325
- } catch (error) {
326
- process.stderr.write(
327
- `Browser opening failed: ${error.message}\nUse the Ready URL above.\n`,
328
- );
329
- }
330
- }
331
- } else throw new Error('Unknown command. Use --help.');
355
+ const [command, argument, ...extra] = positionals;
356
+ if (values.version) process.stdout.write(`${version}\n`);
357
+ else if (values.help) process.stdout.write(usage);
358
+ else if (!command) {
359
+ const { directory } = await context(values);
360
+ process.stdout.write(`${await listing(directory)}\n${usage}`);
361
+ } else {
362
+ if (!Object.hasOwn(commands, command))
363
+ throw new Error(`Unknown command: ${command}. Use --help.`);
364
+ if (extra.length) throw new Error('Too many arguments. Use --help.');
365
+ if (needsChange.has(command) && !argument)
366
+ throw new Error(`Usage: spectater ${command} <change>`);
367
+ json = needsChange.has(command) && command !== 'serve' && values.format === 'json';
368
+ if (!['json', 'markdown'].includes(values.format))
369
+ throw new Error('--format must be json or markdown.');
370
+ await commands[command](values, argument);
332
371
  }
333
372
  } catch (error) {
334
373
  process.stderr.write(`spectater: ${error.message}\n`);
374
+ if (json) process.stdout.write(`${JSON.stringify({ status: 'error', error: error.message })}\n`);
335
375
  process.exitCode = 1;
336
376
  }