@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.
package/bin/ccs-config.ts CHANGED
@@ -45,10 +45,31 @@ export interface RepoEntry {
45
45
  configHash: string;
46
46
  }
47
47
 
48
+ /**
49
+ * Terminal apps `ccs open --new-window` can open (defaults.terminal). Only
50
+ * apps whose way of opening was tried on a real Mac are listed, and the list
51
+ * is only added to (iTerm2 did not run the launcher file when tried).
52
+ */
53
+ export const TERMINAL_APPS = ["terminal"] as const;
54
+ export type TerminalApp = (typeof TERMINAL_APPS)[number];
55
+
56
+ export function isTerminalApp(v: unknown): v is TerminalApp {
57
+ return typeof v === "string" && (TERMINAL_APPS as readonly string[]).includes(v);
58
+ }
59
+
48
60
  export interface CcsConfig {
49
61
  version: 1;
50
62
  /** ui: list/preview look — "classic" (default) or "simple" (friendly, Japanese). */
51
63
  defaults: { command: string; ui: UiMode };
64
+ /** The other `defaults.*` keys (kept apart so `defaults` stays as the contract tests pin it). */
65
+ settings: {
66
+ /** defaults.terminal: the app `ccs open --new-window` opens; null = not chosen. */
67
+ terminal: TerminalApp | null;
68
+ /** defaults.update_check: false stops the update check like CCS_NO_UPDATE_CHECK. */
69
+ updateCheck: boolean;
70
+ /** defaults.groups_dir, `~` expanded and resolved; null = off. */
71
+ groupsDir: string | null;
72
+ };
52
73
  repos: RepoEntry[];
53
74
  }
54
75
 
@@ -60,13 +81,58 @@ export interface ConfigPaths {
60
81
  readme: string;
61
82
  }
62
83
 
84
+ /**
85
+ * Why repos.yml failed ccs's checks, as a word machines can match on
86
+ * (docs/design/machine-interface.md: the `config` check of `ccs doctor` and
87
+ * the `detail` of `config_invalid`). Words are only ever added.
88
+ */
89
+ export type ConfigErrorCode =
90
+ | "config_not_readable"
91
+ | "yaml_parse_error"
92
+ | "not_a_mapping"
93
+ | "unsupported_version"
94
+ | "defaults_invalid"
95
+ | "command_unsafe"
96
+ | "repos_empty"
97
+ | "entry_invalid"
98
+ | "name_unsafe"
99
+ | "duplicate_name"
100
+ | "path_outside_home"
101
+ | "path_unsafe"
102
+ | "custom_too_large";
103
+
63
104
  export class ConfigError extends Error {
64
- constructor(message: string) {
105
+ /** The reason word. Errors built without one count as entry_invalid. */
106
+ readonly code: ConfigErrorCode;
107
+ /** Index into repos[] when one entry is at fault. */
108
+ readonly entryIndex: number | undefined;
109
+ constructor(
110
+ message: string,
111
+ code: ConfigErrorCode = "entry_invalid",
112
+ entryIndex?: number,
113
+ ) {
65
114
  super(message);
66
115
  this.name = "ConfigError";
116
+ this.code = code;
117
+ this.entryIndex = entryIndex;
67
118
  }
68
119
  }
69
120
 
121
+ /**
122
+ * Something about repos.yml worth knowing that does not stop ccs. The launcher
123
+ * prints `message`; the machine interface counts by `code` and prints nothing
124
+ * (the message names paths and repos).
125
+ */
126
+ export interface ConfigWarning {
127
+ code: "unknown_key" | "path_missing";
128
+ message: string;
129
+ }
130
+
131
+ export type WarningSink = (w: ConfigWarning) => void;
132
+
133
+ /** The launcher's sink: each warning as one stderr line, as before. */
134
+ const consoleSink: WarningSink = (w) => console.warn(w.message);
135
+
70
136
  // ---------------------------------------------------------------------------
71
137
  // Constants
72
138
  // ---------------------------------------------------------------------------
@@ -133,7 +199,7 @@ export function getPaths(): ConfigPaths {
133
199
  };
134
200
  }
135
201
 
136
- function expandPath(p: string): string {
202
+ export function expandPath(p: string): string {
137
203
  const home = homedir();
138
204
  if (p === "~") return home;
139
205
  if (p.startsWith("~/")) return join(home, p.slice(2));
@@ -150,7 +216,7 @@ function expandPath(p: string): string {
150
216
  // realpath of $HOME — otherwise `~/escape -> /` style symlinks smuggle
151
217
  // the repo outside $HOME while passing the lexical check. Both sides are
152
218
  // realpath'd so macOS /var -> /private/var aliasing compares cleanly.
153
- function isUnderHome(absPath: string): boolean {
219
+ export function isUnderHome(absPath: string): boolean {
154
220
  const home = homedir();
155
221
  const lexical = resolve(absPath);
156
222
  if (lexical !== home && !lexical.startsWith(home + "/")) return false;
@@ -174,7 +240,7 @@ function isUnderHome(absPath: string): boolean {
174
240
  // ccs-sanitize.ts for the policy rationale.
175
241
 
176
242
  function rejectShellMetachars(
177
- field: string,
243
+ field: "path" | "cwd" | "command",
178
244
  value: string,
179
245
  idx: number,
180
246
  reposYmlPath: string,
@@ -182,6 +248,8 @@ function rejectShellMetachars(
182
248
  if (SHELL_METACHARS.test(value)) {
183
249
  throw new ConfigError(
184
250
  `${reposYmlPath}: repos[${idx}].${field} contains shell metacharacter(s): ${JSON.stringify(value)}`,
251
+ field === "command" ? "command_unsafe" : "path_unsafe",
252
+ idx,
185
253
  );
186
254
  }
187
255
  }
@@ -241,14 +309,55 @@ function isPlainObject(v: unknown): v is Record<string, unknown> {
241
309
  );
242
310
  }
243
311
 
312
+ /** True when an array or object contains itself (directly or deeper down). */
313
+ function containsItself(value: unknown, path: Set<object> = new Set()): boolean {
314
+ if (typeof value !== "object" || value === null) return false;
315
+ if (path.has(value)) return true;
316
+ path.add(value);
317
+ const kids = Array.isArray(value) ? value : Object.values(value);
318
+ for (const kid of kids) {
319
+ if (containsItself(kid, path)) return true;
320
+ }
321
+ path.delete(value);
322
+ return false;
323
+ }
324
+
325
+ /**
326
+ * A repo-entry flag (disabled, launch, lead, scan): absent means the default,
327
+ * anything but a YAML true/false is an error. Boolean() would read a quoted
328
+ * "false" as true and flip the flag without a word.
329
+ */
330
+ function readBoolean(
331
+ raw: Record<string, unknown>,
332
+ key: string,
333
+ fallback: boolean,
334
+ index: number,
335
+ reposYmlPath: string,
336
+ ): boolean {
337
+ const value = raw[key];
338
+ if (value === undefined) return fallback;
339
+ if (typeof value !== "boolean") {
340
+ throw new ConfigError(
341
+ `${reposYmlPath}: repos[${index}].${key} must be true or false (got ${JSON.stringify(value)})`,
342
+ "entry_invalid",
343
+ index,
344
+ );
345
+ }
346
+ return value;
347
+ }
348
+
244
349
  function warnUnknownKeys(
245
350
  obj: Record<string, unknown>,
246
351
  known: Set<string>,
247
352
  context: string,
353
+ sink: WarningSink,
248
354
  ): void {
249
355
  for (const key of Object.keys(obj)) {
250
356
  if (!known.has(key)) {
251
- console.warn(`[ccs] unknown key "${key}" in ${context} (ignored)`);
357
+ sink({
358
+ code: "unknown_key",
359
+ message: `[ccs] unknown key "${key}" in ${context} (ignored)`,
360
+ });
252
361
  }
253
362
  }
254
363
  }
@@ -290,18 +399,23 @@ function resolveRepoEntry(
290
399
  index: number,
291
400
  defaultsCommand: string | undefined,
292
401
  reposYmlPath: string,
402
+ sink: WarningSink,
293
403
  ): RepoEntry {
294
404
  if (!isPlainObject(raw)) {
295
405
  throw new ConfigError(
296
406
  `${reposYmlPath}: repos[${index}] must be an object`,
407
+ "entry_invalid",
408
+ index,
297
409
  );
298
410
  }
299
- warnUnknownKeys(raw, KNOWN_REPO_KEYS, `repos[${index}]`);
411
+ warnUnknownKeys(raw, KNOWN_REPO_KEYS, `repos[${index}]`, sink);
300
412
 
301
413
  const name = raw.name;
302
414
  if (typeof name !== "string" || name.length === 0) {
303
415
  throw new ConfigError(
304
416
  `${reposYmlPath}: repos[${index}].name is required and must be a non-empty string`,
417
+ "entry_invalid",
418
+ index,
305
419
  );
306
420
  }
307
421
  // Same policy as path/cwd/command (audit NEW-3): the name lands in fzf's
@@ -310,30 +424,24 @@ function resolveRepoEntry(
310
424
  if (SHELL_METACHARS.test(name)) {
311
425
  throw new ConfigError(
312
426
  `${reposYmlPath}: repos[${index}].name contains shell metacharacter(s) or control char(s): ${JSON.stringify(name)}`,
427
+ "name_unsafe",
428
+ index,
313
429
  );
314
430
  }
315
431
 
316
- const disabled =
317
- raw.disabled === undefined ? false : Boolean(raw.disabled);
432
+ const disabled = readBoolean(raw, "disabled", false, index, reposYmlPath);
318
433
 
319
434
  // launch — `launch: false` makes the entry a display-only group header:
320
435
  // listed, with the repos inside its path nested under it, but never
321
- // launched. Strictly typed, unlike disabled/scan: Boolean("false") is true,
322
- // so a quoted value would silently turn the header back into a launcher.
323
- let launch = true;
324
- if (raw.launch !== undefined) {
325
- if (typeof raw.launch !== "boolean") {
326
- throw new ConfigError(
327
- `${reposYmlPath}: repos[${index}].launch must be true or false (got ${JSON.stringify(raw.launch)})`,
328
- );
329
- }
330
- launch = raw.launch;
331
- }
436
+ // launched.
437
+ const launch = readBoolean(raw, "launch", true, index, reposYmlPath);
332
438
  // A header exists to hold its children, which nest by path — without one
333
439
  // it would have nothing under it (command/cwd, by contrast, go unused).
334
440
  if (!launch && raw.path === undefined) {
335
441
  throw new ConfigError(
336
442
  `${reposYmlPath}: repos[${index}].path is required when launch is false`,
443
+ "entry_invalid",
444
+ index,
337
445
  );
338
446
  }
339
447
 
@@ -343,19 +451,24 @@ function resolveRepoEntry(
343
451
  if (typeof raw.path !== "string") {
344
452
  throw new ConfigError(
345
453
  `${reposYmlPath}: repos[${index}].path must be a string`,
454
+ "entry_invalid",
455
+ index,
346
456
  );
347
457
  }
348
458
  resolvedPath = resolve(expandPath(raw.path));
349
459
  if (!isUnderHome(resolvedPath)) {
350
460
  throw new ConfigError(
351
461
  `${reposYmlPath}: repos[${index}].path "${raw.path}" is outside $HOME (security)`,
462
+ "path_outside_home",
463
+ index,
352
464
  );
353
465
  }
354
466
  rejectShellMetachars("path", resolvedPath, index, reposYmlPath);
355
467
  if (!disabled && !existsSync(resolvedPath)) {
356
- console.warn(
357
- `[ccs] path not found: ${raw.path} (repos[${index}] "${name}") — set disabled: true to silence`,
358
- );
468
+ sink({
469
+ code: "path_missing",
470
+ message: `[ccs] path not found: ${raw.path} (repos[${index}] "${name}") — set disabled: true to silence`,
471
+ });
359
472
  }
360
473
  }
361
474
 
@@ -365,12 +478,16 @@ function resolveRepoEntry(
365
478
  if (typeof raw.cwd !== "string") {
366
479
  throw new ConfigError(
367
480
  `${reposYmlPath}: repos[${index}].cwd must be a string`,
481
+ "entry_invalid",
482
+ index,
368
483
  );
369
484
  }
370
485
  resolvedCwd = resolve(expandPath(raw.cwd));
371
486
  if (!isUnderHome(resolvedCwd)) {
372
487
  throw new ConfigError(
373
488
  `${reposYmlPath}: repos[${index}].cwd "${raw.cwd}" is outside $HOME (security)`,
489
+ "path_outside_home",
490
+ index,
374
491
  );
375
492
  }
376
493
  rejectShellMetachars("cwd", resolvedCwd, index, reposYmlPath);
@@ -382,6 +499,8 @@ function resolveRepoEntry(
382
499
  if (typeof raw.command !== "string") {
383
500
  throw new ConfigError(
384
501
  `${reposYmlPath}: repos[${index}].command must be a string`,
502
+ "entry_invalid",
503
+ index,
385
504
  );
386
505
  }
387
506
  command = raw.command;
@@ -395,9 +514,11 @@ function resolveRepoEntry(
395
514
  // metachar here would break the last line of defense.
396
515
  rejectShellMetachars("command", command, index, reposYmlPath);
397
516
 
398
- // description
517
+ // description — a blank `description:` (YAML null) means unset, not "null".
399
518
  const description =
400
- raw.description === undefined ? "" : String(raw.description);
519
+ raw.description === undefined || raw.description === null
520
+ ? ""
521
+ : String(raw.description);
401
522
 
402
523
  // label — display name for the NEW row and the preview title; `name` stays
403
524
  // the unique key (row key, DB, session rows). Display-only like
@@ -410,23 +531,16 @@ function resolveRepoEntry(
410
531
  if (typeof raw.label !== "string") {
411
532
  throw new ConfigError(
412
533
  `${reposYmlPath}: repos[${index}].label must be a string`,
534
+ "entry_invalid",
535
+ index,
413
536
  );
414
537
  }
415
538
  label = raw.label;
416
539
  }
417
540
 
418
541
  // lead — the entry that leads its group (a director, a coordinator): listed
419
- // first among its siblings, and marked 👑 in the simple look. Strictly
420
- // typed like `launch`, so a quoted "false" cannot turn into true.
421
- let lead = false;
422
- if (raw.lead !== undefined) {
423
- if (typeof raw.lead !== "boolean") {
424
- throw new ConfigError(
425
- `${reposYmlPath}: repos[${index}].lead must be true or false (got ${JSON.stringify(raw.lead)})`,
426
- );
427
- }
428
- lead = raw.lead;
429
- }
542
+ // first among its siblings, and marked 👑 in the simple look.
543
+ const lead = readBoolean(raw, "lead", false, index, reposYmlPath);
430
544
 
431
545
  // tags
432
546
  let tags: string[] = [];
@@ -434,16 +548,19 @@ function resolveRepoEntry(
434
548
  if (!Array.isArray(raw.tags) || !raw.tags.every((t) => typeof t === "string")) {
435
549
  throw new ConfigError(
436
550
  `${reposYmlPath}: repos[${index}].tags must be an array of strings`,
551
+ "entry_invalid",
552
+ index,
437
553
  );
438
554
  }
439
555
  tags = [...raw.tags];
440
556
  }
441
557
 
442
558
  // scan
443
- const scan = raw.scan === undefined ? true : Boolean(raw.scan);
559
+ const scan = readBoolean(raw, "scan", true, index, reposYmlPath);
444
560
 
445
- // icon
446
- const icon = raw.icon === undefined ? "📁" : String(raw.icon);
561
+ // icon — a blank `icon:` (YAML null) means unset, so the default shows.
562
+ const icon =
563
+ raw.icon === undefined || raw.icon === null ? "📁" : String(raw.icon);
447
564
 
448
565
  // custom
449
566
  let custom: Record<string, unknown> = {};
@@ -451,6 +568,8 @@ function resolveRepoEntry(
451
568
  if (!isPlainObject(raw.custom)) {
452
569
  throw new ConfigError(
453
570
  `${reposYmlPath}: repos[${index}].custom must be an object`,
571
+ "entry_invalid",
572
+ index,
454
573
  );
455
574
  }
456
575
  // Reject bloated `custom` blobs before they reach downstream code.
@@ -458,7 +577,9 @@ function resolveRepoEntry(
458
577
  const customJson = JSON.stringify(raw.custom);
459
578
  if (customJson.length > 64_000) {
460
579
  throw new ConfigError(
461
- `${reposYmlPath}: repos[${index}].custom exceeds 64KB JSON size limit (got ${customJson.length} bytes)`,
580
+ `${reposYmlPath}: repos[${index}].custom exceeds the 64KB limit (measured as 64,000 characters of JSON; got ${customJson.length})`,
581
+ "custom_too_large",
582
+ index,
462
583
  );
463
584
  }
464
585
  custom = { ...raw.custom };
@@ -486,50 +607,147 @@ function resolveRepoEntry(
486
607
  // Top-level load
487
608
  // ---------------------------------------------------------------------------
488
609
 
489
- export function loadConfig(): CcsConfig {
490
- ensureConfigDir();
610
+ export interface LoadConfigOptions {
611
+ /** false: create nothing (no template, no README); only the cache dir. */
612
+ scaffold?: boolean;
613
+ /** Where warnings go (default: one stderr line each). */
614
+ sink?: WarningSink;
615
+ }
616
+
617
+ /**
618
+ * The launcher's load: scaffolds a missing config (first run), then parses it,
619
+ * printing each warning on stderr. Throws ConfigError.
620
+ *
621
+ * With `scaffold: false` (the machine interface's scan) nothing but the cache
622
+ * directory is created, and a missing repos.yml is config_not_readable.
623
+ */
624
+ export function loadConfig(opts: LoadConfigOptions = {}): CcsConfig {
491
625
  const paths = getPaths();
626
+ if (opts.scaffold === false) {
627
+ mkdirSync(paths.cacheDir, { recursive: true, mode: 0o700 });
628
+ } else {
629
+ ensureConfigDir();
630
+ }
631
+
632
+ let source: string;
633
+ try {
634
+ source = readFileSync(paths.reposYml, "utf-8");
635
+ } catch (err) {
636
+ throw new ConfigError(
637
+ `${paths.reposYml}: cannot read config file (${(err as Error).message})`,
638
+ "config_not_readable",
639
+ );
640
+ }
641
+ return parseConfigSource(
642
+ source,
643
+ paths.reposYml,
644
+ opts.sink ?? consoleSink,
645
+ opts.scaffold !== false,
646
+ );
647
+ }
492
648
 
649
+ /** What readConfig found: no file at all, or the parsed config. */
650
+ export type ReadConfigResult =
651
+ | { found: false }
652
+ | { found: true; config: CcsConfig; warnings: ConfigWarning[]; source: string };
653
+
654
+ /**
655
+ * The read-only load for the machine interface (`ccs doctor`, `ccs list
656
+ * --json`): creates nothing (no directory, no template, no README) and prints
657
+ * nothing — warnings come back to the caller. A missing repos.yml is
658
+ * `{ found: false }`, not an error. Throws ConfigError like loadConfig.
659
+ */
660
+ export function readConfig(): ReadConfigResult {
661
+ const paths = getPaths();
662
+ if (!existsSync(paths.reposYml)) return { found: false };
493
663
  let source: string;
494
664
  try {
495
665
  source = readFileSync(paths.reposYml, "utf-8");
496
666
  } catch (err) {
497
667
  throw new ConfigError(
498
668
  `${paths.reposYml}: cannot read config file (${(err as Error).message})`,
669
+ "config_not_readable",
499
670
  );
500
671
  }
672
+ const warnings: ConfigWarning[] = [];
673
+ const config = parseConfigSource(source, paths.reposYml, (w) => warnings.push(w), false);
674
+ return { found: true, config, warnings, source };
675
+ }
676
+
677
+ /**
678
+ * Validate and resolve the text of a repos.yml. Throws ConfigError.
679
+ *
680
+ * `yamlWarnings: false` keeps the YAML parser's own warnings (an unknown tag,
681
+ * …) off stderr: Node prints them with the offending line, and that line is
682
+ * repos.yml's content. The machine interface always passes false.
683
+ */
684
+ export function parseConfigSource(
685
+ source: string,
686
+ reposYmlPath: string,
687
+ sink: WarningSink = consoleSink,
688
+ yamlWarnings = true,
689
+ ): CcsConfig {
690
+ const paths = { reposYml: reposYmlPath };
501
691
 
502
692
  let parsed: unknown;
503
693
  try {
504
- parsed = parseYaml(source);
694
+ parsed = yamlWarnings ? parseYaml(source) : parseYaml(source, { logLevel: "error" });
505
695
  } catch (err) {
506
696
  throw new ConfigError(
507
697
  `${paths.reposYml}: YAML parse error: ${(err as Error).message}`,
698
+ "yaml_parse_error",
508
699
  );
509
700
  }
510
701
 
511
702
  if (!isPlainObject(parsed)) {
512
703
  throw new ConfigError(
513
704
  `${paths.reposYml}: top-level must be a mapping/object`,
705
+ "not_a_mapping",
706
+ );
707
+ }
708
+
709
+ // An anchor used inside itself (`disabled: &x [*x]`) gives a value that
710
+ // contains itself; every later check (JSON.stringify, the entry hash) would
711
+ // crash on it instead of naming the key.
712
+ if (Array.isArray(parsed.repos)) {
713
+ parsed.repos.forEach((entry, i) => {
714
+ if (containsItself(entry)) {
715
+ throw new ConfigError(
716
+ `${paths.reposYml}: repos[${i}] has a value that refers to itself (a YAML anchor used inside itself)`,
717
+ "entry_invalid",
718
+ i,
719
+ );
720
+ }
721
+ });
722
+ }
723
+ if (containsItself(parsed)) {
724
+ throw new ConfigError(
725
+ `${paths.reposYml}: a value refers to itself (a YAML anchor used inside itself)`,
726
+ "yaml_parse_error",
514
727
  );
515
728
  }
516
729
 
517
- warnUnknownKeys(parsed, KNOWN_TOP_KEYS, "top-level");
730
+ warnUnknownKeys(parsed, KNOWN_TOP_KEYS, "top-level", sink);
518
731
 
519
732
  // version
520
733
  if (parsed.version !== 1) {
521
734
  throw new ConfigError(
522
735
  `${paths.reposYml}: unsupported version: ${String(parsed.version)}, only 1 is supported`,
736
+ "unsupported_version",
523
737
  );
524
738
  }
525
739
 
526
740
  // defaults
527
741
  let defaultsCommand: string | undefined;
528
742
  let defaultsUi: UiMode = "classic";
743
+ let defaultsTerminal: TerminalApp | null = null;
744
+ let defaultsUpdateCheck = true;
745
+ let defaultsGroupsDir: string | null = null;
529
746
  if (parsed.defaults !== undefined) {
530
747
  if (!isPlainObject(parsed.defaults)) {
531
748
  throw new ConfigError(
532
749
  `${paths.reposYml}: defaults must be an object`,
750
+ "defaults_invalid",
533
751
  );
534
752
  }
535
753
  const defaults = parsed.defaults;
@@ -537,6 +755,7 @@ export function loadConfig(): CcsConfig {
537
755
  if (typeof defaults.command !== "string") {
538
756
  throw new ConfigError(
539
757
  `${paths.reposYml}: defaults.command must be a string`,
758
+ "defaults_invalid",
540
759
  );
541
760
  }
542
761
  defaultsCommand = defaults.command;
@@ -548,10 +767,57 @@ export function loadConfig(): CcsConfig {
548
767
  if (!isUiMode(defaults.ui)) {
549
768
  throw new ConfigError(
550
769
  `${paths.reposYml}: defaults.ui must be one of ${UI_MODES.join(" / ")} (got ${JSON.stringify(defaults.ui)})`,
770
+ "defaults_invalid",
551
771
  );
552
772
  }
553
773
  defaultsUi = defaults.ui;
554
774
  }
775
+ // terminal — `ccs open --new-window` picks its app from this list only.
776
+ if (defaults.terminal !== undefined && defaults.terminal !== null) {
777
+ if (!isTerminalApp(defaults.terminal)) {
778
+ throw new ConfigError(
779
+ `${paths.reposYml}: defaults.terminal must be one of ${TERMINAL_APPS.join(" / ")} (got ${JSON.stringify(defaults.terminal)})`,
780
+ "defaults_invalid",
781
+ );
782
+ }
783
+ defaultsTerminal = defaults.terminal;
784
+ }
785
+ // update_check — true or false only, as strict as the entry flags: a
786
+ // quoted "false" must not silently leave the check on.
787
+ if (defaults.update_check !== undefined && defaults.update_check !== null) {
788
+ if (typeof defaults.update_check !== "boolean") {
789
+ throw new ConfigError(
790
+ `${paths.reposYml}: defaults.update_check must be true or false (got ${JSON.stringify(defaults.update_check)})`,
791
+ "defaults_invalid",
792
+ );
793
+ }
794
+ defaultsUpdateCheck = defaults.update_check;
795
+ }
796
+ // groups_dir — a folder of group definition files other tools keep
797
+ // (ccs-groups.ts). Held to the same rules as a repo `path`: under $HOME,
798
+ // no shell metacharacters.
799
+ if (defaults.groups_dir !== undefined && defaults.groups_dir !== null) {
800
+ if (typeof defaults.groups_dir !== "string" || defaults.groups_dir === "") {
801
+ throw new ConfigError(
802
+ `${paths.reposYml}: defaults.groups_dir must be a folder path`,
803
+ "defaults_invalid",
804
+ );
805
+ }
806
+ const dir = resolve(expandPath(defaults.groups_dir));
807
+ if (!isUnderHome(dir)) {
808
+ throw new ConfigError(
809
+ `${paths.reposYml}: defaults.groups_dir "${defaults.groups_dir}" is outside $HOME (security)`,
810
+ "path_outside_home",
811
+ );
812
+ }
813
+ if (SHELL_METACHARS.test(dir)) {
814
+ throw new ConfigError(
815
+ `${paths.reposYml}: defaults.groups_dir contains shell metacharacter(s): ${JSON.stringify(defaults.groups_dir)}`,
816
+ "path_unsafe",
817
+ );
818
+ }
819
+ defaultsGroupsDir = dir;
820
+ }
555
821
  }
556
822
 
557
823
  // defaults.command resolution: explicit defaults > env > "claude"
@@ -564,6 +830,7 @@ export function loadConfig(): CcsConfig {
564
830
  if (SHELL_METACHARS.test(resolvedDefaultsCommand)) {
565
831
  throw new ConfigError(
566
832
  `${paths.reposYml}: defaults.command (resolved from defaults.command / CCS_CMD / CCR_CMD) contains shell metacharacter(s): ${JSON.stringify(resolvedDefaultsCommand)}`,
833
+ "command_unsafe",
567
834
  );
568
835
  }
569
836
 
@@ -571,11 +838,12 @@ export function loadConfig(): CcsConfig {
571
838
  if (!Array.isArray(parsed.repos) || parsed.repos.length === 0) {
572
839
  throw new ConfigError(
573
840
  `${paths.reposYml}: repos must be a non-empty array`,
841
+ "repos_empty",
574
842
  );
575
843
  }
576
844
 
577
845
  const repos: RepoEntry[] = parsed.repos.map((raw, i) =>
578
- resolveRepoEntry(raw, i, defaultsCommand, paths.reposYml),
846
+ resolveRepoEntry(raw, i, defaultsCommand, paths.reposYml, sink),
579
847
  );
580
848
 
581
849
  // uniqueness check
@@ -586,20 +854,32 @@ export function loadConfig(): CcsConfig {
586
854
  byName.set(r.name, list);
587
855
  });
588
856
  const dupes: string[] = [];
857
+ let firstDupeIndex: number | undefined;
589
858
  for (const [n, idxs] of byName) {
590
859
  if (idxs.length > 1) {
591
860
  dupes.push(`"${n}" at indexes [${idxs.join(", ")}]`);
861
+ // The entry that repeats an earlier name (the second occurrence).
862
+ if (firstDupeIndex === undefined || idxs[1] < firstDupeIndex) {
863
+ firstDupeIndex = idxs[1];
864
+ }
592
865
  }
593
866
  }
594
867
  if (dupes.length > 0) {
595
868
  throw new ConfigError(
596
869
  `${paths.reposYml}: duplicate name ${dupes.join("; ")}`,
870
+ "duplicate_name",
871
+ firstDupeIndex,
597
872
  );
598
873
  }
599
874
 
600
875
  return {
601
876
  version: 1,
602
877
  defaults: { command: resolvedDefaultsCommand, ui: defaultsUi },
878
+ settings: {
879
+ terminal: defaultsTerminal,
880
+ updateCheck: defaultsUpdateCheck,
881
+ groupsDir: defaultsGroupsDir,
882
+ },
603
883
  repos,
604
884
  };
605
885
  }
package/bin/ccs-delete.sh CHANGED
@@ -86,7 +86,8 @@ if [[ "$confirm" == "y" || "$confirm" == "Y" ]]; then
86
86
  # parameter via better-sqlite3 (audit M-3) and reports failures instead of
87
87
  # swallowing them (audit L-3).
88
88
  if [[ "$TSX" != "tsx" ]] || command -v tsx &>/dev/null; then
89
- if ! "${TSX}" "${SCRIPT_DIR}/ccs-delete-session.ts" "$SESSION_ID"; then
89
+ # Through ccs-tsx.sh: started from ccs's own folder (see that file).
90
+ if ! CCS_TSX_REAL="$TSX" bash "${SCRIPT_DIR}/ccs-tsx.sh" "${SCRIPT_DIR}/ccs-delete-session.ts" "$SESSION_ID"; then
90
91
  echo "[ccs] warning: cache row cleanup failed — a stale row may remain until the next refresh" >&2
91
92
  fi
92
93
  else