@indigo-gr/claude-code-station 0.4.1 → 0.5.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.
@@ -0,0 +1,467 @@
1
+ /**
2
+ * ccs-machine.ts — the machine interface's common ground.
3
+ *
4
+ * GUI launchers, scripts and other tools call ccs without a terminal:
5
+ * `ccs doctor`, `ccs list --json`, `ccs sessions --json`, `ccs repo
6
+ * add|remove`, `ccs config get|set` and `ccs open`. Every one of them shares what lives here: the outer JSON object,
7
+ * the exit codes and their status words, the two reason vocabularies, flag
8
+ * parsing, and the SIGINT rule. The contract is docs/design/machine-interface.md
9
+ * (chapter 1); change that document before changing the shapes here.
10
+ *
11
+ * Run as a script, this module dispatches every machine word but `doctor`
12
+ * (bin/ccs runs bin/ccs-doctor.ts itself, in two stages):
13
+ *
14
+ * tsx ccs-machine.ts list --json [--with-paths]
15
+ * tsx ccs-machine.ts sessions --json [--project <name>] [--limit <n>] …
16
+ * tsx ccs-machine.ts repo add|remove … config get|set …
17
+ * tsx ccs-machine.ts open … (through bin/ccs: it starts the command)
18
+ *
19
+ * None of these commands opens fzf, reads stdin, asks anything, goes to the
20
+ * network, or creates the config template. Only `repo add` may create
21
+ * repos.yml, and only `sessions` (its scan) writes the cache.
22
+ */
23
+
24
+ import { readFileSync } from "node:fs";
25
+ import { dirname, join } from "node:path";
26
+ import { fileURLToPath, pathToFileURL } from "node:url";
27
+
28
+ import { ConfigError } from "./ccs-config.ts";
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Shapes
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /** The machine interface's own version: minor = additions, major = breaks. */
35
+ export const SCHEMA_VERSION = "1.0.0";
36
+
37
+ export const PRODUCT_ID = "ccs";
38
+
39
+ /** The package whose package.json may name the version (as bin/ccs reads it). */
40
+ const CCS_PACKAGE = "@indigo-gr/claude-code-station";
41
+
42
+ /** Status words, one per exit code (machine-interface.md 1.4). */
43
+ export type Status =
44
+ | "ok"
45
+ | "failed"
46
+ | "usage"
47
+ | "unverifiable"
48
+ | "missing_prerequisite"
49
+ | "not_found"
50
+ | "rejected"
51
+ | "partial"
52
+ | "interrupted";
53
+
54
+ export const EXIT_CODE: Readonly<Record<Status, number>> = {
55
+ ok: 0,
56
+ failed: 1,
57
+ usage: 2,
58
+ unverifiable: 3,
59
+ missing_prerequisite: 4,
60
+ not_found: 5,
61
+ rejected: 6,
62
+ partial: 7,
63
+ interrupted: 130,
64
+ };
65
+
66
+ export type Vocab = "ccs.health" | "ccs.cli";
67
+
68
+ /** Reason words of every command but doctor (machine-interface.md 1.5). */
69
+ export type CliCode =
70
+ | "ok"
71
+ | "unknown_option"
72
+ | "missing_argument"
73
+ | "extra_argument"
74
+ | "invalid_argument"
75
+ | "json_required"
76
+ | "conflicting_options"
77
+ | "config_not_readable"
78
+ | "cache_not_readable"
79
+ | "config_missing"
80
+ | "config_invalid"
81
+ | "cache_missing"
82
+ | "no_tty"
83
+ | "unsupported_os"
84
+ | "terminal_not_set"
85
+ | "terminal_app_missing"
86
+ | "unsafe_install_path"
87
+ | "dependency_missing"
88
+ | "repo_not_found"
89
+ | "session_not_found"
90
+ | "path_outside_home"
91
+ | "path_not_found"
92
+ | "path_not_directory"
93
+ | "path_taken"
94
+ | "name_taken"
95
+ | "unsafe_characters"
96
+ | "needs_quoting"
97
+ | "invalid_value"
98
+ | "invalid_uuid"
99
+ | "key_not_settable"
100
+ | "last_entry"
101
+ | "flow_style_repos"
102
+ | "not_launchable"
103
+ | "repo_disabled"
104
+ | "session_cwd_unknown"
105
+ | "command_unsafe"
106
+ | "config_changed"
107
+ | "config_locked"
108
+ | "write_failed"
109
+ | "validation_after_write_failed"
110
+ | "restore_failed"
111
+ | "launch_failed"
112
+ | "invalid_package_config"
113
+ | "internal_error"
114
+ | "interrupted";
115
+
116
+ /** The status each ccs.cli word goes with (the table in 1.5). */
117
+ export const CLI_CODE_STATUS: Readonly<Record<CliCode, Status>> = {
118
+ ok: "ok",
119
+ unknown_option: "usage",
120
+ missing_argument: "usage",
121
+ extra_argument: "usage",
122
+ invalid_argument: "usage",
123
+ json_required: "usage",
124
+ conflicting_options: "usage",
125
+ config_not_readable: "unverifiable",
126
+ cache_not_readable: "unverifiable",
127
+ config_missing: "missing_prerequisite",
128
+ config_invalid: "missing_prerequisite",
129
+ cache_missing: "missing_prerequisite",
130
+ no_tty: "missing_prerequisite",
131
+ unsupported_os: "missing_prerequisite",
132
+ terminal_not_set: "missing_prerequisite",
133
+ terminal_app_missing: "missing_prerequisite",
134
+ unsafe_install_path: "missing_prerequisite",
135
+ dependency_missing: "missing_prerequisite",
136
+ repo_not_found: "not_found",
137
+ session_not_found: "not_found",
138
+ path_outside_home: "rejected",
139
+ path_not_found: "rejected",
140
+ path_not_directory: "rejected",
141
+ path_taken: "rejected",
142
+ name_taken: "rejected",
143
+ unsafe_characters: "rejected",
144
+ needs_quoting: "rejected",
145
+ invalid_value: "rejected",
146
+ invalid_uuid: "rejected",
147
+ key_not_settable: "rejected",
148
+ last_entry: "rejected",
149
+ flow_style_repos: "rejected",
150
+ not_launchable: "rejected",
151
+ repo_disabled: "rejected",
152
+ session_cwd_unknown: "rejected",
153
+ command_unsafe: "rejected",
154
+ config_changed: "failed",
155
+ config_locked: "failed",
156
+ write_failed: "failed",
157
+ validation_after_write_failed: "failed",
158
+ restore_failed: "partial",
159
+ launch_failed: "failed",
160
+ invalid_package_config: "failed",
161
+ internal_error: "failed",
162
+ interrupted: "interrupted",
163
+ };
164
+
165
+ export interface Reason {
166
+ vocab: Vocab;
167
+ code: string;
168
+ /** config_invalid: the config check's word (ConfigErrorCode). */
169
+ detail?: string;
170
+ /** config_invalid: index into repos[] when one entry is at fault. */
171
+ entry_index?: number;
172
+ }
173
+
174
+ /** The outer object every command prints (1.3), in this key order. */
175
+ export interface Envelope {
176
+ schema_version: string;
177
+ product_id: string;
178
+ product_version: string;
179
+ command: string;
180
+ written_at: string;
181
+ status: Status;
182
+ reasons: Reason[];
183
+ [key: string]: unknown;
184
+ }
185
+
186
+ /** What a command decided: its exit status, reasons and own keys. */
187
+ export interface Outcome {
188
+ status: Status;
189
+ reasons: Reason[];
190
+ /** Keys after `reasons` (only on success, per 1.3). */
191
+ body?: Record<string, unknown>;
192
+ /** One line for people on stderr (never a repo path or name). */
193
+ note?: string;
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // Product version
198
+ // ---------------------------------------------------------------------------
199
+
200
+ const VERSION_RE = /^[0-9A-Za-z.+-]{1,32}$/;
201
+
202
+ /**
203
+ * The version `ccs --version` prints: package.json next to bin/, only when it
204
+ * is this package's; `0.0.0-dev` otherwise (copy install, unreadable file).
205
+ * Same rule as bin/ccs's read_package_version.
206
+ */
207
+ export function productVersion(binDir: string = BIN_DIR): string {
208
+ try {
209
+ const raw = JSON.parse(readFileSync(join(binDir, "..", "package.json"), "utf-8"));
210
+ if (
211
+ raw &&
212
+ typeof raw === "object" &&
213
+ raw.name === CCS_PACKAGE &&
214
+ typeof raw.version === "string" &&
215
+ VERSION_RE.test(raw.version)
216
+ ) {
217
+ return raw.version;
218
+ }
219
+ } catch {
220
+ // fall through
221
+ }
222
+ return "0.0.0-dev";
223
+ }
224
+
225
+ const BIN_DIR = dirname(fileURLToPath(import.meta.url));
226
+
227
+ // ---------------------------------------------------------------------------
228
+ // Building and printing
229
+ // ---------------------------------------------------------------------------
230
+
231
+ export function envelope(
232
+ command: string,
233
+ status: Status,
234
+ reasons: Reason[],
235
+ body: Record<string, unknown> = {},
236
+ ): Envelope {
237
+ return {
238
+ schema_version: SCHEMA_VERSION,
239
+ product_id: PRODUCT_ID,
240
+ product_version: productVersion(),
241
+ command,
242
+ written_at: new Date().toISOString(),
243
+ status,
244
+ reasons,
245
+ ...body,
246
+ };
247
+ }
248
+
249
+ /** One ccs.cli reason, its status taken from the table. */
250
+ export function cliOutcome(
251
+ code: CliCode,
252
+ extra: Partial<Reason> = {},
253
+ note?: string,
254
+ ): Outcome {
255
+ return {
256
+ status: CLI_CODE_STATUS[code],
257
+ reasons: [{ vocab: "ccs.cli", code, ...extra }],
258
+ note,
259
+ };
260
+ }
261
+
262
+ export function okOutcome(body: Record<string, unknown>): Outcome {
263
+ return { status: "ok", reasons: [{ vocab: "ccs.cli", code: "ok" }], body };
264
+ }
265
+
266
+ /**
267
+ * Print an outcome and return its exit code. With --json, stdout gets exactly
268
+ * one JSON object and a newline, on success and on failure alike; the body is
269
+ * printed only on success (1.3). The note goes to stderr either way.
270
+ */
271
+ /** One answer per process: set once emit() has run. */
272
+ let answered = false;
273
+
274
+ /**
275
+ * True once this process has given its answer. SIGINT handlers check it: tsx
276
+ * passes a SIGINT on to the module's process, which then gets it twice (from
277
+ * the terminal's process group and from tsx), and the second one must not
278
+ * print a second JSON object (1.8: exactly one).
279
+ */
280
+ export function hasAnswered(): boolean {
281
+ return answered;
282
+ }
283
+
284
+ export function emit(command: string, outcome: Outcome, json: boolean): number {
285
+ if (answered) return EXIT_CODE[outcome.status];
286
+ answered = true;
287
+ if (json) {
288
+ const body = outcome.status === "ok" ? outcome.body : undefined;
289
+ process.stdout.write(
290
+ JSON.stringify(envelope(command, outcome.status, outcome.reasons, body)) + "\n",
291
+ );
292
+ }
293
+ if (outcome.note) process.stderr.write(`[ccs] ${outcome.note}\n`);
294
+ else if (outcome.status !== "ok") {
295
+ const words = outcome.reasons
296
+ .map((r) => (r.detail ? `${r.code} (${r.detail})` : r.code))
297
+ .join(", ");
298
+ process.stderr.write(`[ccs] ${command}: ${outcome.status}: ${words}\n`);
299
+ }
300
+ return EXIT_CODE[outcome.status];
301
+ }
302
+
303
+ /**
304
+ * The reason for a repos.yml that could not be used (1.5): no file →
305
+ * config_missing (4), no permission → config_not_readable (3), anything the
306
+ * checks refuse → config_invalid (4) with the check's word in `detail`.
307
+ */
308
+ export function configFailure(err: unknown): Outcome {
309
+ if (err instanceof ConfigError) {
310
+ if (err.code === "config_not_readable") {
311
+ return cliOutcome("config_not_readable", {}, "cannot read repos.yml (config_not_readable)");
312
+ }
313
+ const extra: Partial<Reason> = { detail: err.code };
314
+ if (err.entryIndex !== undefined) extra.entry_index = err.entryIndex;
315
+ return cliOutcome(
316
+ "config_invalid",
317
+ extra,
318
+ `repos.yml fails ccs's checks (config_invalid: ${err.code}` +
319
+ (err.entryIndex !== undefined ? `, repos[${err.entryIndex}]` : "") +
320
+ "); run `ccs doctor` or open the list to see the message",
321
+ );
322
+ }
323
+ return cliOutcome("internal_error");
324
+ }
325
+
326
+ // ---------------------------------------------------------------------------
327
+ // Flags
328
+ // ---------------------------------------------------------------------------
329
+
330
+ export interface FlagSpec {
331
+ /** Flags without a value (`--json`). */
332
+ booleans: readonly string[];
333
+ /** Flags taking a value: `--limit 5` or `--limit=5`. */
334
+ values?: readonly string[];
335
+ }
336
+
337
+ export type ParsedFlags =
338
+ | {
339
+ ok: true;
340
+ booleans: Set<string>;
341
+ values: Map<string, string>;
342
+ positionals: string[];
343
+ /** Everything after a bare `--`, untouched. */
344
+ rest: string[];
345
+ }
346
+ | { ok: false; code: "unknown_option" | "missing_argument" };
347
+
348
+ /**
349
+ * Parse a command's arguments against its flags. Unknown flags are an error
350
+ * (unknown_option) — the machine interface never passes flags on, except
351
+ * after a bare `--`.
352
+ */
353
+ export function parseFlags(argv: readonly string[], spec: FlagSpec): ParsedFlags {
354
+ const booleans = new Set<string>();
355
+ const values = new Map<string, string>();
356
+ const positionals: string[] = [];
357
+ const valueFlags = new Set(spec.values ?? []);
358
+ for (let i = 0; i < argv.length; i++) {
359
+ const a = argv[i];
360
+ if (a === "--") {
361
+ return { ok: true, booleans, values, positionals, rest: argv.slice(i + 1) };
362
+ }
363
+ if (!a.startsWith("-") || a === "-") {
364
+ positionals.push(a);
365
+ continue;
366
+ }
367
+ const eq = a.indexOf("=");
368
+ const name = eq === -1 ? a : a.slice(0, eq);
369
+ if (spec.booleans.includes(name) && eq === -1) {
370
+ booleans.add(name);
371
+ } else if (valueFlags.has(name)) {
372
+ if (eq !== -1) {
373
+ values.set(name, a.slice(eq + 1));
374
+ } else if (i + 1 < argv.length) {
375
+ values.set(name, argv[++i]);
376
+ } else {
377
+ return { ok: false, code: "missing_argument" };
378
+ }
379
+ } else {
380
+ return { ok: false, code: "unknown_option" };
381
+ }
382
+ }
383
+ return { ok: true, booleans, values, positionals, rest: [] };
384
+ }
385
+
386
+ /** True when `--json` is among the arguments (before a bare `--`). */
387
+ export function wantsJson(argv: readonly string[]): boolean {
388
+ for (const a of argv) {
389
+ if (a === "--") return false;
390
+ if (a === "--json") return true;
391
+ }
392
+ return false;
393
+ }
394
+
395
+ // ---------------------------------------------------------------------------
396
+ // SIGINT
397
+ // ---------------------------------------------------------------------------
398
+
399
+ /**
400
+ * On SIGINT: run `cleanup` (undo a half-done write, drop a lock), print the
401
+ * interrupted outcome and exit 130 (1.4). The read-only commands have nothing
402
+ * to undo, but still answer with the outer object.
403
+ */
404
+ export function onInterrupt(command: string, json: boolean, cleanup?: () => void): void {
405
+ process.on("SIGINT", () => {
406
+ if (answered) return;
407
+ try {
408
+ cleanup?.();
409
+ } finally {
410
+ process.exitCode = emit(command, cliOutcome("interrupted"), json);
411
+ process.exit();
412
+ }
413
+ });
414
+ }
415
+
416
+ // ---------------------------------------------------------------------------
417
+ // Dispatch
418
+ // ---------------------------------------------------------------------------
419
+
420
+ /** First-argument words bin/ccs hands to the machine interface. */
421
+ export const MACHINE_WORDS = ["doctor", "list", "sessions", "repo", "config", "open"] as const;
422
+
423
+ async function main(argv: string[]): Promise<number> {
424
+ const [word, ...args] = argv;
425
+ const json = wantsJson(args);
426
+ switch (word) {
427
+ case "list": {
428
+ const { runList } = await import("./ccs-list-json.ts");
429
+ return runList(args);
430
+ }
431
+ case "sessions": {
432
+ const { sessionsOutcome } = await import("./ccs-sessions.ts");
433
+ onInterrupt("sessions", json);
434
+ return emit("sessions", await sessionsOutcome(args), json);
435
+ }
436
+ case "repo":
437
+ case "config": {
438
+ const { runRepoOrConfig } = await import("./ccs-repo.ts");
439
+ return runRepoOrConfig(word, args);
440
+ }
441
+ case "open": {
442
+ const { runOpen } = await import("./ccs-open.ts");
443
+ return runOpen(args);
444
+ }
445
+ default:
446
+ return emit(
447
+ String(word ?? ""),
448
+ cliOutcome("internal_error", {}, "unknown machine command"),
449
+ json,
450
+ );
451
+ }
452
+ }
453
+
454
+ // pathToFileURL, not `file://${argv[1]}`: a folder with a space (or any
455
+ // character a URL escapes) in its name would never match.
456
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
457
+ const argv = process.argv.slice(2);
458
+ const command = argv[0] ?? "";
459
+ const json = wantsJson(argv.slice(1));
460
+ main(argv)
461
+ .then((code) => {
462
+ process.exitCode = code;
463
+ })
464
+ .catch(() => {
465
+ process.exitCode = emit(command, cliOutcome("internal_error"), json);
466
+ });
467
+ }