@indigo-gr/claude-code-station 0.3.3 → 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/CHANGELOG.md +71 -0
- package/LICENSE +1 -1
- package/README.md +89 -5
- package/bin/ccs +739 -30
- package/bin/ccs-config-edit.ts +431 -0
- package/bin/ccs-config.ts +324 -44
- package/bin/ccs-delete.sh +2 -1
- package/bin/ccs-doctor.ts +432 -0
- package/bin/ccs-groups.ts +167 -0
- package/bin/ccs-init.ts +58 -14
- package/bin/ccs-list-json.ts +124 -0
- package/bin/ccs-list.ts +121 -10
- package/bin/ccs-machine.ts +467 -0
- package/bin/ccs-open.ts +304 -0
- package/bin/ccs-preview-session.ts +14 -5
- package/bin/ccs-preview.ts +75 -6
- package/bin/ccs-repo.ts +433 -0
- package/bin/ccs-sanitize.ts +18 -4
- package/bin/ccs-scan.ts +81 -10
- package/bin/ccs-sessions.ts +174 -0
- package/bin/ccs-tsx.sh +16 -0
- package/bin/ccs-update-check.ts +131 -0
- package/bin/ccs-utils.ts +5 -0
- package/bin/package.json +3 -0
- package/docs/design/machine-interface.md +860 -0
- package/docs/design/repos-yml-schema.md +225 -76
- package/docs/v0.2.0-review-notes.md +1 -1
- package/docs/v0.2.1-backlog.md +2 -2
- package/package.json +4 -3
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
|
-
|
|
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:
|
|
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
|
-
|
|
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.
|
|
322
|
-
|
|
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
|
-
|
|
357
|
-
|
|
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
|
|
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.
|
|
420
|
-
|
|
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
|
|
559
|
+
const scan = readBoolean(raw, "scan", true, index, reposYmlPath);
|
|
444
560
|
|
|
445
|
-
// icon
|
|
446
|
-
const 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
|
|
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
|
|
490
|
-
|
|
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
|
-
|
|
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
|