@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,433 @@
1
+ /**
2
+ * ccs-repo.ts — the writing commands and the settings read:
3
+ *
4
+ * ccs repo add <path> [--name <name>] [--description <text>] [--with-paths] [--json]
5
+ * ccs repo remove <name> [--json]
6
+ * ccs config get [--with-paths] [--json]
7
+ * ccs config set [--repo <name>] <key> <value> [--json]
8
+ *
9
+ * Every write goes through ccs-config-edit (lock, check, `.bak`, write in
10
+ * place, check again, put back). Only the values a screen can offer are
11
+ * settable: a fixed list of words, and an entry's label and description —
12
+ * never a command string or a path (people who want those edit the file).
13
+ * Contract: docs/design/machine-interface.md 2.4, 3 and 4.1–4.5.
14
+ */
15
+
16
+ import { realpathSync, statSync } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { basename, relative, resolve } from "node:path";
19
+ import { isMap, isSeq, type Document } from "yaml";
20
+
21
+ import {
22
+ expandPath,
23
+ isTerminalApp,
24
+ isUnderHome,
25
+ readConfig,
26
+ TERMINAL_APPS,
27
+ type CcsConfig,
28
+ } from "./ccs-config.ts";
29
+ import {
30
+ codePointLength,
31
+ EditStop,
32
+ hasControlChars,
33
+ needsQuoting,
34
+ reposIsFlow,
35
+ runEditAsync,
36
+ type EditCode,
37
+ } from "./ccs-config-edit.ts";
38
+ import {
39
+ cliOutcome,
40
+ configFailure,
41
+ emit,
42
+ hasAnswered,
43
+ okOutcome,
44
+ parseFlags,
45
+ wantsJson,
46
+ type CliCode,
47
+ type Outcome,
48
+ } from "./ccs-machine.ts";
49
+ import { SHELL_METACHARS } from "./ccs-sanitize.ts";
50
+ import { UI_MODES } from "./ccs-utils.ts";
51
+
52
+ const NAME_MAX = 100;
53
+ const LABEL_MAX = 80;
54
+ const DESCRIPTION_MAX = 200;
55
+
56
+ /** The first lines of a repos.yml that `repo add` creates. */
57
+ const NEW_CONFIG_HEAD = "# Claude Code Station — Repository Definitions\nversion: 1\n";
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // SIGINT outside an edit
61
+ // ---------------------------------------------------------------------------
62
+
63
+ let editing = false;
64
+
65
+ /**
66
+ * SIGINT before or after the write: answer interrupted (130). During the
67
+ * write, runEditAsync handles it (puts the old text back first).
68
+ */
69
+ function interruptOutsideEdit(command: string, json: boolean): void {
70
+ process.on("SIGINT", () => {
71
+ if (editing || hasAnswered()) return;
72
+ process.exitCode = emit(command, cliOutcome("interrupted"), json);
73
+ process.exit();
74
+ });
75
+ }
76
+
77
+ async function edit(opts: Parameters<typeof runEditAsync>[0]) {
78
+ editing = true;
79
+ try {
80
+ return await runEditAsync(opts);
81
+ } finally {
82
+ editing = false;
83
+ }
84
+ }
85
+
86
+ /** An EditStop as an outcome; the message (it may name the file) stays off stderr. */
87
+ function stopOutcome(err: unknown): Outcome {
88
+ if (err instanceof EditStop) {
89
+ if (err.code === "config_invalid" && err.configError) return configFailure(err.configError);
90
+ if (err.code === "validation_after_write_failed" || err.code === "restore_failed") {
91
+ const extra = err.configError ? { detail: err.configError.code } : {};
92
+ return cliOutcome(
93
+ err.code,
94
+ extra,
95
+ err.code === "restore_failed"
96
+ ? "could not put the previous repos.yml back (restore_failed); the previous text is in repos.yml.bak"
97
+ : "the written repos.yml failed ccs's checks; the previous text was put back (validation_after_write_failed)",
98
+ );
99
+ }
100
+ return cliOutcome(err.code as CliCode);
101
+ }
102
+ return cliOutcome("internal_error");
103
+ }
104
+
105
+ function stop(code: EditCode, message = code): never {
106
+ throw new EditStop(code, message);
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // repo add
111
+ // ---------------------------------------------------------------------------
112
+
113
+ /** `~/…` for a path under $HOME (`~/` for $HOME itself: a bare `~` is YAML null). */
114
+ export function tildeForm(abs: string, home: string = homedir()): string {
115
+ if (abs === home) return "~/";
116
+ return `~/${relative(home, abs)}`;
117
+ }
118
+
119
+ function realOrSame(p: string): string {
120
+ try {
121
+ return realpathSync(p);
122
+ } catch {
123
+ return p;
124
+ }
125
+ }
126
+
127
+ /** Checks on the name (4.1): length, characters, quoting. */
128
+ function checkName(name: string): void {
129
+ if (name.length === 0 || codePointLength(name) > NAME_MAX) stop("invalid_value");
130
+ if (SHELL_METACHARS.test(name)) stop("unsafe_characters");
131
+ if (needsQuoting(name)) stop("needs_quoting");
132
+ }
133
+
134
+ function checkText(value: string, max: number): void {
135
+ if (hasControlChars(value) || codePointLength(value) > max) stop("invalid_value");
136
+ }
137
+
138
+ export async function repoAdd(args: readonly string[]): Promise<Outcome> {
139
+ const flags = parseFlags(args, {
140
+ booleans: ["--json", "--with-paths"],
141
+ values: ["--name", "--description"],
142
+ });
143
+ if (!flags.ok) return cliOutcome(flags.code);
144
+ if (flags.rest.length > 0 || flags.positionals.length > 1) return cliOutcome("extra_argument");
145
+ const rawPath = flags.positionals[0];
146
+ if (rawPath === undefined || rawPath === "") return cliOutcome("missing_argument");
147
+ const wantName = flags.values.get("--name");
148
+ const description = flags.values.get("--description");
149
+ const home = homedir();
150
+ // A relative path is taken from the folder ccs was run in (CCS_CWD: bin/ccs
151
+ // starts this module from its own folder; see ccs-tsx.sh).
152
+ const abs = resolve(process.env.CCS_CWD || process.cwd(), expandPath(rawPath));
153
+
154
+ let added: { name: string; path: string } | null = null;
155
+ try {
156
+ const result = await edit({
157
+ createWith: NEW_CONFIG_HEAD,
158
+ transform: ({ doc, config }) => {
159
+ if (config && reposIsFlow(doc)) stop("flow_style_repos");
160
+ if (!isUnderHome(abs)) stop("path_outside_home");
161
+ let st;
162
+ try {
163
+ st = statSync(abs);
164
+ } catch {
165
+ stop("path_not_found");
166
+ }
167
+ if (!st.isDirectory()) stop("path_not_directory");
168
+ if (SHELL_METACHARS.test(abs)) stop("unsafe_characters");
169
+ const written = tildeForm(abs, home);
170
+ if (needsQuoting(written)) stop("needs_quoting");
171
+ const repos = config?.repos ?? [];
172
+ const real = realOrSame(abs);
173
+ if (repos.some((r) => r.path !== "" && (r.path === abs || realOrSame(r.path) === real))) {
174
+ stop("path_taken");
175
+ }
176
+ const taken = new Set(repos.map((r) => r.name));
177
+ let name: string;
178
+ if (wantName !== undefined) {
179
+ checkName(wantName);
180
+ if (taken.has(wantName)) stop("name_taken");
181
+ name = wantName;
182
+ } else {
183
+ const base = abs === home ? "home" : basename(abs);
184
+ checkName(base);
185
+ name = base;
186
+ for (let i = 2; taken.has(name); i++) name = `${base}-${i}`;
187
+ checkName(name);
188
+ }
189
+ if (description !== undefined) checkText(description, DESCRIPTION_MAX);
190
+
191
+ const item: Record<string, string> = { name, path: written };
192
+ if (description !== undefined && description !== "") item.description = description;
193
+ if (config) {
194
+ const node = doc.get("repos", true);
195
+ if (!isSeq(node)) stop("config_invalid");
196
+ node.add(doc.createNode(item));
197
+ } else {
198
+ doc.set("repos", doc.createNode([item]));
199
+ }
200
+ added = { name, path: abs };
201
+ },
202
+ });
203
+ const done = added as { name: string; path: string } | null;
204
+ if (!done) return cliOutcome("internal_error");
205
+ const repo: Record<string, unknown> = { name: done.name, created_config: result.createdConfig };
206
+ if (flags.booleans.has("--with-paths")) repo.path = done.path;
207
+ return okOutcome({ repo });
208
+ } catch (err) {
209
+ return stopOutcome(err);
210
+ }
211
+ }
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // repo remove
215
+ // ---------------------------------------------------------------------------
216
+
217
+ export async function repoRemove(args: readonly string[]): Promise<Outcome> {
218
+ const flags = parseFlags(args, { booleans: ["--json"] });
219
+ if (!flags.ok) return cliOutcome(flags.code);
220
+ if (flags.rest.length > 0 || flags.positionals.length > 1) return cliOutcome("extra_argument");
221
+ const name = flags.positionals[0];
222
+ if (name === undefined || name === "") return cliOutcome("missing_argument");
223
+ try {
224
+ await edit({
225
+ transform: ({ doc, config }) => {
226
+ const repos = (config as CcsConfig).repos;
227
+ const index = repos.findIndex((r) => r.name === name);
228
+ if (index === -1) stop("repo_not_found");
229
+ if (repos.length === 1) stop("last_entry");
230
+ const node = doc.get("repos", true);
231
+ if (!isSeq(node)) stop("config_invalid");
232
+ node.items.splice(index, 1);
233
+ },
234
+ });
235
+ return okOutcome({ repo: { name } });
236
+ } catch (err) {
237
+ return stopOutcome(err);
238
+ }
239
+ }
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // config get
243
+ // ---------------------------------------------------------------------------
244
+
245
+ /** The values `config set` takes, per key (shown by `config get` as `allowed`). */
246
+ export const SETTABLE_DEFAULTS = {
247
+ "defaults.ui": [...UI_MODES] as unknown[],
248
+ "defaults.terminal": [...TERMINAL_APPS] as unknown[],
249
+ "defaults.update_check": [true, false] as unknown[],
250
+ } as const;
251
+
252
+ export function configGet(args: readonly string[]): Outcome {
253
+ const flags = parseFlags(args, { booleans: ["--json", "--with-paths"] });
254
+ if (!flags.ok) return cliOutcome(flags.code);
255
+ if (flags.positionals.length > 0 || flags.rest.length > 0) return cliOutcome("extra_argument");
256
+ let config: CcsConfig | null = null;
257
+ try {
258
+ const result = readConfig();
259
+ if (result.found) config = result.config;
260
+ } catch (err) {
261
+ return configFailure(err);
262
+ }
263
+ const groupsDir: Record<string, unknown> = { set: Boolean(config?.settings.groupsDir) };
264
+ if (flags.booleans.has("--with-paths") && config?.settings.groupsDir) {
265
+ groupsDir.path = resolve(expandPath(config.settings.groupsDir));
266
+ }
267
+ return okOutcome({
268
+ settings: {
269
+ "defaults.ui": config?.defaults.ui ?? "classic",
270
+ "defaults.terminal": config?.settings.terminal ?? null,
271
+ "defaults.update_check": config?.settings.updateCheck ?? true,
272
+ "defaults.groups_dir": groupsDir,
273
+ },
274
+ allowed: SETTABLE_DEFAULTS,
275
+ });
276
+ }
277
+
278
+ /** `config get` without --json: one `key = value` line each (for people). */
279
+ export function configGetText(outcome: Outcome): string {
280
+ const settings = (outcome.body?.settings ?? {}) as Record<string, unknown>;
281
+ return Object.entries(settings)
282
+ .map(([k, v]) => {
283
+ if (v && typeof v === "object") {
284
+ const o = v as { set?: boolean; path?: string };
285
+ return `${k} = ${o.path ?? (o.set ? "(set)" : "(not set)")}`;
286
+ }
287
+ return `${k} = ${v === null ? "(not set)" : String(v)}`;
288
+ })
289
+ .join("\n");
290
+ }
291
+
292
+ // ---------------------------------------------------------------------------
293
+ // config set
294
+ // ---------------------------------------------------------------------------
295
+
296
+ type SetKey =
297
+ | "defaults.ui"
298
+ | "defaults.terminal"
299
+ | "defaults.update_check"
300
+ | "label"
301
+ | "description";
302
+
303
+ const DEFAULT_KEYS = new Set<SetKey>(["defaults.ui", "defaults.terminal", "defaults.update_check"]);
304
+ const ENTRY_KEYS = new Set<SetKey>(["label", "description"]);
305
+
306
+ /** The value to write for this key, or undefined to delete the key. Throws EditStop. */
307
+ function parseSetValue(key: SetKey, raw: string): unknown {
308
+ switch (key) {
309
+ case "defaults.ui":
310
+ if (!(UI_MODES as readonly string[]).includes(raw)) stop("invalid_value");
311
+ return raw;
312
+ case "defaults.terminal":
313
+ if (raw === "") return undefined;
314
+ if (!isTerminalApp(raw)) stop("invalid_value");
315
+ return raw;
316
+ case "defaults.update_check":
317
+ if (raw === "true") return true;
318
+ if (raw === "false") return false;
319
+ return stop("invalid_value");
320
+ case "label":
321
+ checkText(raw, LABEL_MAX);
322
+ return raw === "" ? undefined : raw;
323
+ case "description":
324
+ checkText(raw, DESCRIPTION_MAX);
325
+ return raw === "" ? undefined : raw;
326
+ }
327
+ }
328
+
329
+ /** Put `defaults:` before `repos:` when there is none (line readers stop at `repos:`). */
330
+ function ensureDefaults(doc: Document): void {
331
+ if (doc.has("defaults")) return;
332
+ const top = doc.contents;
333
+ if (!isMap(top)) stop("config_invalid");
334
+ const pair = doc.createPair("defaults", {});
335
+ const at = top.items.findIndex((p) => {
336
+ const k = p.key as { value?: unknown } | unknown;
337
+ return (k && typeof k === "object" && "value" in k ? k.value : k) === "repos";
338
+ });
339
+ if (at === -1) top.items.push(pair);
340
+ else top.items.splice(at, 0, pair);
341
+ }
342
+
343
+ export async function configSet(args: readonly string[]): Promise<Outcome> {
344
+ const flags = parseFlags(args, { booleans: ["--json"], values: ["--repo"] });
345
+ if (!flags.ok) return cliOutcome(flags.code);
346
+ if (flags.rest.length > 0 || flags.positionals.length > 2) return cliOutcome("extra_argument");
347
+ if (flags.positionals.length < 2) return cliOutcome("missing_argument");
348
+ const [keyRaw, valueRaw] = flags.positionals;
349
+ const isDefaultKey = DEFAULT_KEYS.has(keyRaw as SetKey);
350
+ if (!isDefaultKey && !ENTRY_KEYS.has(keyRaw as SetKey)) {
351
+ return cliOutcome("key_not_settable", {}, "only defaults.ui, defaults.terminal, defaults.update_check, label and description can be set");
352
+ }
353
+ const key = keyRaw as SetKey;
354
+ const repoName = flags.values.get("--repo");
355
+ if (isDefaultKey && repoName !== undefined) return cliOutcome("extra_argument");
356
+ if (!isDefaultKey && repoName === undefined) return cliOutcome("missing_argument");
357
+
358
+ try {
359
+ const result = await edit({
360
+ transform: ({ doc, config }) => {
361
+ const value = parseSetValueOrRepoFirst(key, valueRaw, repoName, config as CcsConfig);
362
+ const path: (string | number)[] = isDefaultKey
363
+ ? ["defaults", key.slice("defaults.".length)]
364
+ : ["repos", (config as CcsConfig).repos.findIndex((r) => r.name === repoName), key];
365
+ const current = doc.getIn(path);
366
+ if (value === undefined) {
367
+ if (current === undefined || current === null) return false;
368
+ doc.deleteIn(path);
369
+ return true;
370
+ }
371
+ if (current === value) return false;
372
+ if (isDefaultKey) {
373
+ ensureDefaults(doc);
374
+ if (!isMap(doc.get("defaults", true))) stop("config_invalid");
375
+ }
376
+ doc.setIn(path, value);
377
+ return true;
378
+ },
379
+ });
380
+ return okOutcome({ key, changed: result.changed });
381
+ } catch (err) {
382
+ return stopOutcome(err);
383
+ }
384
+ }
385
+
386
+ /** For an entry key, a missing entry (5) comes before a bad value (6). */
387
+ function parseSetValueOrRepoFirst(
388
+ key: SetKey,
389
+ raw: string,
390
+ repoName: string | undefined,
391
+ config: CcsConfig,
392
+ ): unknown {
393
+ if (repoName !== undefined && !config.repos.some((r) => r.name === repoName)) {
394
+ stop("repo_not_found");
395
+ }
396
+ return parseSetValue(key, raw);
397
+ }
398
+
399
+ // ---------------------------------------------------------------------------
400
+ // Dispatch
401
+ // ---------------------------------------------------------------------------
402
+
403
+ /** `ccs repo …` and `ccs config …`: returns the exit code. */
404
+ export async function runRepoOrConfig(word: "repo" | "config", args: readonly string[]): Promise<number> {
405
+ const json = wantsJson(args);
406
+ const sub = args[0];
407
+ const rest = args.slice(1);
408
+ const command = sub && !sub.startsWith("-") ? `${word} ${sub}` : word;
409
+ interruptOutsideEdit(command, json);
410
+ if (sub === undefined || sub.startsWith("-")) {
411
+ return emit(command, cliOutcome("missing_argument", {}, `usage: ccs ${word} ${word === "repo" ? "add|remove" : "get|set"} …`), json);
412
+ }
413
+ // A write without --json: nothing on stdout, one line on stderr (1.8).
414
+ const written = (outcome: Outcome): number => {
415
+ if (!json && outcome.status === "ok" && !outcome.note) {
416
+ const changed = (outcome.body as { changed?: boolean } | undefined)?.changed;
417
+ outcome.note = `${command}: ok${changed === false ? " (already set; nothing written)" : ""}`;
418
+ }
419
+ return emit(command, outcome, json);
420
+ };
421
+ if (word === "repo" && sub === "add") return written(await repoAdd(rest));
422
+ if (word === "repo" && sub === "remove") return written(await repoRemove(rest));
423
+ if (word === "config" && sub === "set") return written(await configSet(rest));
424
+ if (word === "config" && sub === "get") {
425
+ const outcome = configGet(rest);
426
+ if (!json && outcome.status === "ok") {
427
+ process.stdout.write(configGetText(outcome) + "\n");
428
+ return 0;
429
+ }
430
+ return emit(command, outcome, json);
431
+ }
432
+ return emit(command, cliOutcome("invalid_argument", {}, `unknown subcommand for ccs ${word}`), json);
433
+ }
package/bin/ccs-scan.ts CHANGED
@@ -28,6 +28,7 @@ import { join } from "node:path";
28
28
  import type Database from "better-sqlite3";
29
29
 
30
30
  import { loadConfig, getPaths, type RepoEntry } from "./ccs-config.ts";
31
+ import { groupsToMeta, readLogicalGroups } from "./ccs-groups.ts";
31
32
  import {
32
33
  openDb,
33
34
  upsertRepo,
@@ -40,6 +41,7 @@ import { maskSecrets } from "./ccs-secrets.ts";
40
41
  import { stripControlChars } from "./ccs-sanitize.ts";
41
42
  import { nowIso, type UiMode } from "./ccs-utils.ts";
42
43
  import { scanSessions } from "./ccs-scan-sessions.ts";
44
+ import { pathToFileURL } from "node:url";
43
45
 
44
46
  const execFileP = promisify(execFile);
45
47
 
@@ -52,6 +54,12 @@ export interface ScanOptions {
52
54
  ttlSeconds?: number;
53
55
  parallelism?: number;
54
56
  scanSessions?: boolean;
57
+ /**
58
+ * The machine interface's scan (`ccs sessions`): repos.yml is read without
59
+ * scaffolding (no template, no README; only the cache dir) and its
60
+ * warnings are not printed.
61
+ */
62
+ quietConfig?: boolean;
55
63
  }
56
64
 
57
65
  export interface ScanResult {
@@ -65,6 +73,8 @@ export interface ScanResult {
65
73
  lockSkipped?: boolean;
66
74
  /** repos.yml `defaults.ui` as loaded by this run (set even when lock-skipped). */
67
75
  ui?: UiMode;
76
+ /** repos.yml `defaults.update_check` (set even when lock-skipped). */
77
+ updateCheck?: boolean;
68
78
  }
69
79
 
70
80
  // ---------------------------------------------------------------------------
@@ -652,8 +662,11 @@ export async function scan(opts: ScanOptions = {}): Promise<ScanResult> {
652
662
  const parallelism = opts.parallelism ?? DEFAULT_PARALLELISM;
653
663
  const scanSessionsFlag = opts.scanSessions ?? true;
654
664
 
655
- // loadConfig() runs ensureConfigDir(), so cacheDir exists before the lock.
656
- const config = loadConfig();
665
+ // loadConfig() runs ensureConfigDir() (or, quiet, creates the cache dir),
666
+ // so cacheDir exists before the lock.
667
+ const config = opts.quietConfig
668
+ ? loadConfig({ scaffold: false, sink: () => {} })
669
+ : loadConfig();
657
670
  const paths = getPaths();
658
671
 
659
672
  const releaseLock = acquireScanLock(paths.cacheDir);
@@ -670,6 +683,7 @@ export async function scan(opts: ScanOptions = {}): Promise<ScanResult> {
670
683
  durationMs: Date.now() - started,
671
684
  lockSkipped: true,
672
685
  ui: config.defaults.ui,
686
+ updateCheck: config.settings.updateCheck,
673
687
  };
674
688
  }
675
689
 
@@ -692,6 +706,16 @@ export async function scan(opts: ScanOptions = {}): Promise<ScanResult> {
692
706
  // repos.yml `defaults.ui`, for the `ccs --no-scan` path (bin/ccs reads it
693
707
  // back through `ccs-list --print-ui` when no scan ran this launch).
694
708
  setMeta(handle.db, "defaults_ui", config.defaults.ui);
709
+ // repos.yml `defaults.update_check`, read back the same way (`ccs-list
710
+ // --print-settings`) when no scan ran this launch.
711
+ setMeta(handle.db, "defaults_update_check", config.settings.updateCheck ? "on" : "off");
712
+ // defaults.groups_dir: the logical groups, matched to repos.yml by path,
713
+ // for the list to read (an empty list when the setting is off, so
714
+ // removing it clears the headers).
715
+ const groups = config.settings.groupsDir
716
+ ? readLogicalGroups(config.settings.groupsDir, config.repos).groups
717
+ : [];
718
+ setMeta(handle.db, "logical_groups", groupsToMeta(groups));
695
719
 
696
720
  // Order matters (audit C-1): scanOneRepo aggregates session counts FROM
697
721
  // the sessions table, so sessions must be refreshed first — otherwise
@@ -720,6 +744,7 @@ export async function scan(opts: ScanOptions = {}): Promise<ScanResult> {
720
744
  sessionsSkipped,
721
745
  durationMs: Date.now() - started,
722
746
  ui: config.defaults.ui,
747
+ updateCheck: config.settings.updateCheck,
723
748
  };
724
749
  } finally {
725
750
  handle.close();
@@ -736,12 +761,14 @@ function parseArgs(argv: string[]): {
736
761
  noSessions: boolean;
737
762
  quiet: boolean;
738
763
  printUi: boolean;
764
+ printSettings: boolean;
739
765
  } {
740
766
  return {
741
767
  force: argv.includes("--force"),
742
768
  noSessions: argv.includes("--no-sessions"),
743
769
  quiet: argv.includes("--quiet"),
744
770
  printUi: argv.includes("--print-ui"),
771
+ printSettings: argv.includes("--print-settings"),
745
772
  };
746
773
  }
747
774
 
@@ -767,7 +794,15 @@ async function main(): Promise<{ code: number; out: string }> {
767
794
  `(${res.sessionsSkipped} cached) in ${secs}s\n`,
768
795
  );
769
796
  }
770
- return { code: 0, out: args.printUi && res.ui ? `ui=${res.ui}\n` : "" };
797
+ // --print-settings: `ui=` and `update_check=` (bin/ccs's pre-scan);
798
+ // --print-ui: `ui=` alone, as before.
799
+ let out = "";
800
+ if (args.printSettings && res.ui) {
801
+ out = `ui=${res.ui}\nupdate_check=${res.updateCheck === false ? "off" : "on"}\n`;
802
+ } else if (args.printUi && res.ui) {
803
+ out = `ui=${res.ui}\n`;
804
+ }
805
+ return { code: 0, out };
771
806
  } catch (err) {
772
807
  process.stderr.write(
773
808
  `[ccs-scan] fatal: ${err instanceof Error ? err.message : String(err)}\n`,
@@ -776,7 +811,9 @@ async function main(): Promise<{ code: number; out: string }> {
776
811
  }
777
812
  }
778
813
 
779
- if (import.meta.url === `file://${process.argv[1]}`) {
814
+ // pathToFileURL, not `file://${argv[1]}`: a folder with a space (or any
815
+ // character a URL escapes) in its name would never match.
816
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
780
817
  main().then(
781
818
  ({ code, out }) => {
782
819
  // Exit only after stdout drained: pipe writes are async on macOS, and