@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/bin/ccs-init.ts CHANGED
@@ -14,13 +14,15 @@
14
14
  */
15
15
 
16
16
  import { readdir } from "node:fs/promises";
17
- import { readFileSync, writeFileSync } from "node:fs";
17
+ import { readFileSync } from "node:fs";
18
18
  import { homedir } from "node:os";
19
19
  import { basename, join, relative } from "node:path";
20
20
  import { parseDocument, isSeq } from "yaml";
21
21
 
22
22
  import { ensureConfigDir, getPaths, loadConfig } from "./ccs-config.ts";
23
+ import { EditStop, runEditSync } from "./ccs-config-edit.ts";
23
24
  import { hasShellMetachars } from "./ccs-sanitize.ts";
25
+ import { pathToFileURL } from "node:url";
24
26
 
25
27
  // ---------------------------------------------------------------------------
26
28
  // Discovery
@@ -143,6 +145,27 @@ export function buildCandidates(
143
145
  // YAML append (comment/format-preserving)
144
146
  // ---------------------------------------------------------------------------
145
147
 
148
+ /**
149
+ * `repos: [{name: a, path: ~/a}]` (one line, YAML flow style) is valid, but
150
+ * appending to it keeps it on one line, and tools that read repos.yml line by
151
+ * line (`name:` and `path:` under each `- ` item) see none of it. ccs init
152
+ * only writes the block form, so it refuses here and leaves the file as is.
153
+ * `flow_style_repos` is the reason word for scripts to match on.
154
+ */
155
+ export function rejectFlowStyleRepos(repos: unknown): void {
156
+ if (isSeq(repos) && repos.flow) {
157
+ throw new Error(
158
+ "flow_style_repos: repos.yml lists `repos:` on one line ([...]); ccs init only adds to a block list. " +
159
+ "Rewrite it with one `- name: ...` / `path: ...` item per entry, then run ccs init --auto-discover again. repos.yml unchanged",
160
+ );
161
+ }
162
+ }
163
+
164
+ /** The repos.yml source's `repos:` node, for the checks before any scan. */
165
+ function reposNodeOf(source: string): unknown {
166
+ return parseDocument(source).get("repos", true);
167
+ }
168
+
146
169
  /**
147
170
  * Append candidates to the `repos:` sequence of a repos.yml source string.
148
171
  * parseDocument round-trips comments and the user's existing formatting —
@@ -157,6 +180,7 @@ export function appendReposToYaml(
157
180
  if (!isSeq(repos)) {
158
181
  throw new Error("repos.yml: top-level `repos` sequence not found");
159
182
  }
183
+ rejectFlowStyleRepos(repos);
160
184
  for (const c of candidates) {
161
185
  repos.add(doc.createNode({ name: c.name, path: c.path }));
162
186
  }
@@ -168,25 +192,41 @@ export function appendReposToYaml(
168
192
  }
169
193
 
170
194
  /**
171
- * Write candidates into repos.yml with a `.bak` backup, then re-run the full
172
- * loadConfig() validation. On any validation failure the original file is
173
- * restored — `ccs init` must never leave a config the launcher cannot load.
195
+ * Write candidates into repos.yml through the common write path
196
+ * (ccs-config-edit: lock, check, `.bak`, write in place, check again, put the
197
+ * old text back on failure) — `ccs init` must never leave a config the
198
+ * launcher cannot load. A one-line `repos:` is refused before anything else,
199
+ * even when the file fails the other checks.
174
200
  */
175
201
  export function appendAndValidate(
176
202
  reposYmlPath: string,
177
203
  candidates: Candidate[],
178
204
  ): void {
179
- const source = readFileSync(reposYmlPath, "utf-8");
180
- const updated = appendReposToYaml(source, candidates);
181
- writeFileSync(reposYmlPath + ".bak", source, { mode: 0o600 });
182
- writeFileSync(reposYmlPath, updated, { mode: 0o600 });
205
+ rejectFlowStyleRepos(reposNodeOf(readFileSync(reposYmlPath, "utf-8")));
183
206
  try {
184
- loadConfig();
207
+ runEditSync({
208
+ reposYml: reposYmlPath,
209
+ transform: ({ doc }) => {
210
+ const repos = doc.get("repos", true);
211
+ if (!isSeq(repos)) {
212
+ throw new Error("repos.yml: top-level `repos` sequence not found");
213
+ }
214
+ rejectFlowStyleRepos(repos);
215
+ for (const c of candidates) {
216
+ repos.add(doc.createNode({ name: c.name, path: c.path }));
217
+ }
218
+ },
219
+ });
185
220
  } catch (err) {
186
- writeFileSync(reposYmlPath, source, { mode: 0o600 });
187
- throw new Error(
188
- `[ccs-init] validation failed after append — repos.yml restored (backup kept at .bak): ${err instanceof Error ? err.message : String(err)}`,
189
- );
221
+ if (err instanceof EditStop && err.code === "validation_after_write_failed") {
222
+ throw new Error(
223
+ `[ccs-init] validation failed after append — repos.yml restored (backup kept at .bak): ${err.message}`,
224
+ );
225
+ }
226
+ if (err instanceof EditStop) {
227
+ throw new Error(`${err.code}: ${err.message}`);
228
+ }
229
+ throw err;
190
230
  }
191
231
  }
192
232
 
@@ -208,6 +248,8 @@ async function cliDiscover(argv: string[]): Promise<number> {
208
248
  const maxDepth = parseDepth(argv);
209
249
  ensureConfigDir();
210
250
  const config = loadConfig(); // throws ConfigError on a broken repos.yml
251
+ // Refuse before the $HOME scan and the picker, not after the user chose.
252
+ rejectFlowStyleRepos(reposNodeOf(readFileSync(getPaths().reposYml, "utf-8")));
211
253
  const existing: ExistingConfig = {
212
254
  names: new Set(config.repos.map((r) => r.name)),
213
255
  paths: new Set(config.repos.map((r) => r.path)),
@@ -274,7 +316,9 @@ async function main(): Promise<number> {
274
316
  }
275
317
  }
276
318
 
277
- if (import.meta.url === `file://${process.argv[1]}`) {
319
+ // pathToFileURL, not `file://${argv[1]}`: a folder with a space (or any
320
+ // character a URL escapes) in its name would never match.
321
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
278
322
  main().then(
279
323
  // exitCode, not process.exit(): bin/ccs captures discover's candidates
280
324
  // with $(...), a pipe, and exiting right after the writes dropped every
@@ -0,0 +1,124 @@
1
+ /**
2
+ * ccs-list-json.ts — `ccs list --json [--with-paths]`.
3
+ *
4
+ * repos.yml after ccs's checks, read directly (not the cache: no wait for a
5
+ * scan, nothing stale after --no-scan). Names, labels and descriptions are
6
+ * printed — that is what this command is for — but paths only with
7
+ * --with-paths, and never `command`, `cwd`, `custom` or `scan`.
8
+ * Contract: docs/design/machine-interface.md 2.2.
9
+ *
10
+ * Creates nothing: a missing repos.yml is exit 4 (config_missing), not a
11
+ * template.
12
+ */
13
+
14
+ import { statSync } from "node:fs";
15
+
16
+ import { readConfig, type RepoEntry } from "./ccs-config.ts";
17
+ import {
18
+ cliOutcome,
19
+ configFailure,
20
+ emit,
21
+ okOutcome,
22
+ onInterrupt,
23
+ parseFlags,
24
+ wantsJson,
25
+ type Outcome,
26
+ } from "./ccs-machine.ts";
27
+ import { readLogicalGroups } from "./ccs-groups.ts";
28
+ import { findParentRepo } from "./ccs-utils.ts";
29
+
30
+ const COMMAND = "list";
31
+
32
+ function isDirectory(p: string): boolean {
33
+ if (!p) return false;
34
+ try {
35
+ return statSync(p).isDirectory();
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ /** One repos[] item of the output, in the documented key order. */
42
+ export function listItem(
43
+ repo: RepoEntry,
44
+ parent: RepoEntry | null,
45
+ withPaths: boolean,
46
+ ): Record<string, unknown> {
47
+ const item: Record<string, unknown> = {
48
+ name: repo.name,
49
+ label: repo.label,
50
+ description: repo.description,
51
+ icon: repo.icon,
52
+ kind: repo.launch ? "repo" : "group",
53
+ launch: repo.launch,
54
+ lead: repo.lead,
55
+ disabled: repo.disabled,
56
+ parent: parent ? parent.name : null,
57
+ tags: [...repo.tags],
58
+ exists: isDirectory(repo.path),
59
+ };
60
+ if (withPaths) item.path = repo.path || null;
61
+ return item;
62
+ }
63
+
64
+ /**
65
+ * `groups[]`: the logical groups in defaults.groups_dir, read now (not from
66
+ * the cache), with their members' repos.yml names in the file's order.
67
+ */
68
+ function logicalGroupsJson(dir: string, repos: RepoEntry[]): Record<string, unknown>[] {
69
+ return readLogicalGroups(dir, repos).groups.map((g) => ({
70
+ id: g.id,
71
+ label: g.label,
72
+ kind: "logical",
73
+ members: g.members,
74
+ unregistered_members: g.unregistered,
75
+ }));
76
+ }
77
+
78
+ /** The outcome of `ccs list` for these arguments (no printing). */
79
+ export function listOutcome(args: readonly string[]): Outcome {
80
+ const flags = parseFlags(args, { booleans: ["--json", "--with-paths"] });
81
+ if (!flags.ok) return cliOutcome(flags.code);
82
+ if (flags.positionals.length > 0 || flags.rest.length > 0) {
83
+ return cliOutcome("extra_argument");
84
+ }
85
+ if (!flags.booleans.has("--json")) {
86
+ return cliOutcome(
87
+ "json_required",
88
+ {},
89
+ "`ccs list` needs --json (to pick a repo by hand, run ccs)",
90
+ );
91
+ }
92
+ const withPaths = flags.booleans.has("--with-paths");
93
+
94
+ let repos: RepoEntry[];
95
+ let groupsDir: string | null = null;
96
+ try {
97
+ const result = readConfig();
98
+ if (!result.found) {
99
+ return cliOutcome(
100
+ "config_missing",
101
+ {},
102
+ "no repos.yml yet (config_missing); `ccs init` creates one",
103
+ );
104
+ }
105
+ repos = result.config.repos;
106
+ groupsDir = result.config.settings.groupsDir;
107
+ } catch (err) {
108
+ return configFailure(err);
109
+ }
110
+
111
+ // The list tree's rule (ccs-list): the deepest enabled entry whose path
112
+ // holds this one; a disabled entry is never anyone's parent.
113
+ const enabled = repos.filter((r) => !r.disabled);
114
+ return okOutcome({
115
+ repos: repos.map((r) => listItem(r, findParentRepo(r, enabled), withPaths)),
116
+ groups: groupsDir ? logicalGroupsJson(groupsDir, repos) : [],
117
+ });
118
+ }
119
+
120
+ export function runList(args: readonly string[]): number {
121
+ const json = wantsJson(args);
122
+ onInterrupt(COMMAND, json);
123
+ return emit(COMMAND, listOutcome(args), json);
124
+ }
package/bin/ccs-list.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * `launch: false`), `resume` (past session) or `separator`.
10
10
  *
11
11
  * Modes:
12
- * --current-only sessions whose cwd matches process.cwd()
12
+ * --current-only sessions whose cwd matches CCS_CWD (else process.cwd())
13
13
  * --repos-only only NEW repo rows (and group headers)
14
14
  * --sessions-only only RESUME session rows
15
15
  * (default) NEW first (alpha, nested repos in a tree under their
@@ -23,13 +23,17 @@
23
23
  * row; no badge column, no tree glyphs, plain-word times
24
24
  *
25
25
  * --print-ui print the `defaults.ui` the last scan stored, then exit
26
+ * --print-settings print `ui=<mode>` and `update_check=<on|off>` as the
27
+ * last scan stored them, then exit
26
28
  */
27
29
 
28
30
  import { homedir } from "node:os";
29
31
  import { openDb, getMeta, reposV3Columns } from "./ccs-db.ts";
32
+ import { groupsFromMeta } from "./ccs-groups.ts";
30
33
  import { getPaths } from "./ccs-config.ts";
31
34
  import { SHELL_METACHARS } from "./ccs-sanitize.ts";
32
35
  import {
36
+ displayIcon,
33
37
  truncate,
34
38
  formatRelativeTime,
35
39
  formatDateTime,
@@ -43,6 +47,7 @@ import {
43
47
  uiFromArgv,
44
48
  type UiMode,
45
49
  } from "./ccs-utils.ts";
50
+ import { pathToFileURL } from "node:url";
46
51
 
47
52
  // ---------------------------------------------------------------------------
48
53
  // Constants
@@ -70,6 +75,7 @@ interface Flags {
70
75
  sessionsOnly: boolean;
71
76
  ui: UiMode;
72
77
  printUi: boolean;
78
+ printSettings: boolean;
73
79
  }
74
80
 
75
81
  function parseArgs(argv: string[]): Flags {
@@ -79,6 +85,7 @@ function parseArgs(argv: string[]): Flags {
79
85
  sessionsOnly: argv.includes("--sessions-only"),
80
86
  ui: uiFromArgv(argv),
81
87
  printUi: argv.includes("--print-ui"),
88
+ printSettings: argv.includes("--print-settings"),
82
89
  };
83
90
  }
84
91
 
@@ -268,7 +275,7 @@ function repoToRow({ repo: r, parent, prefix }: GroupedRepo): string {
268
275
  parentName && parentName !== displayName
269
276
  ? ` \x1b[2m‹${parentName}›\x1b[0m`
270
277
  : "";
271
- const label = `${prefix}${r.icon || "📁"} ${displayName}${parentHint}${LABEL_SEP}`;
278
+ const label = `${prefix}${displayIcon(r.icon)} ${displayName}${parentHint}${LABEL_SEP}`;
272
279
  const desc = truncate(r.description || "", DESC_MAX);
273
280
  if (r.launch === 0) {
274
281
  // Group header (`launch: false`): display-only, so no badges (the header
@@ -301,6 +308,10 @@ interface SessionRowFull {
301
308
  repo_command: string | null;
302
309
  }
303
310
 
311
+ function isWellFormedRow(row: string): boolean {
312
+ return row.split("\t").length === 6 && !/[\n\r\x1f]/.test(row);
313
+ }
314
+
304
315
  function sessionToRow(s: SessionRowFull, defaultCommand: string): string {
305
316
  // Mapped sessions use the registered repo icon + name — the unique `name`,
306
317
  // deliberately not the repos.yml `label`, so session rows read the same as
@@ -318,7 +329,7 @@ function sessionToRow(s: SessionRowFull, defaultCommand: string): string {
318
329
  : s.cwd
319
330
  ? truncate(s.cwd.replace(homedir(), "~"), DESC_MAX)
320
331
  : "(unknown)";
321
- const mapIcon = s.repo_display ? s.repo_icon || "📁" : "❓";
332
+ const mapIcon = s.repo_display ? displayIcon(s.repo_icon) : "❓";
322
333
  const label = `🔄 ${mapIcon} ${displayName}${LABEL_SEP}`;
323
334
  const desc = truncate(s.topic || "", DESC_MAX);
324
335
  const badges = `[${formatDateTime(s.last_activity_at)}]`;
@@ -447,17 +458,77 @@ function simpleRepoRow(n: SimpleNode, nameCol: number): string {
447
458
  return [label, desc, "", `new:${r.name}`, cwd, r.command || "claude"].join("\t");
448
459
  }
449
460
 
450
- function simpleRepoRows(repos: RepoRowFull[]): string[] {
461
+ /** A logical group (defaults.groups_dir) with the listed repos it holds. */
462
+ interface LogicalView {
463
+ id: string;
464
+ label: string;
465
+ members: RepoRowFull[];
466
+ }
467
+
468
+ /** Match the stored groups to the listed (enabled) repos; empty groups go. */
469
+ function logicalGroupsOf(
470
+ groups: { id: string; label: string; members: string[] }[],
471
+ repos: RepoRowFull[],
472
+ ): LogicalView[] {
473
+ const byName = new Map(repos.map((r) => [r.name, r]));
474
+ return groups
475
+ .map((g) => ({
476
+ id: g.id,
477
+ label: g.label,
478
+ members: g.members.map((n) => byName.get(n)).filter((r): r is RepoRowFull => r !== undefined),
479
+ }))
480
+ .filter((g) => g.members.length > 0);
481
+ }
482
+
483
+ /** The icon of a logical group's header (not a repos.yml entry, so no icon of its own). */
484
+ const LOGICAL_ICON = "🗂";
485
+
486
+ /**
487
+ * Classic rows of the logical groups: a display-only header (KIND=group, key
488
+ * `@<id>`, so the preview looks in the logical groups first) and the members
489
+ * under it, drawn as a tree.
490
+ */
491
+ function logicalGroupRows(groups: LogicalView[]): string[] {
492
+ const lines: string[] = [];
493
+ for (const g of groups) {
494
+ const label = `${displayIcon(LOGICAL_ICON)} ${truncate(g.label, 40)}${LABEL_SEP}`;
495
+ lines.push([label, "", "", `group:@${g.id}`, "", ""].join("\t"));
496
+ g.members.forEach((r, i) => {
497
+ const last = i === g.members.length - 1;
498
+ lines.push(repoToRow({ repo: r, parent: null, prefix: ` ${last ? "└ " : "├ "}` }));
499
+ });
500
+ }
501
+ return lines;
502
+ }
503
+
504
+ /** Simple rows of a logical group: the bold header, then its members one level in. */
505
+ function simpleLogicalRows(g: LogicalView, nameCol: number): string[] {
506
+ const head = `${padDisplay(LOGICAL_ICON, 2)} ${truncate(g.label, 40)}`;
507
+ const gap = " ".repeat(Math.max(0, nameCol - displayWidth(head)));
508
+ const styled = `${padDisplay(LOGICAL_ICON, 2)} ${BOLD}${truncate(g.label, 40)}${RESET}${gap}`;
509
+ return [
510
+ [`${styled} `, "", "", `group:@${g.id}`, "", ""].join("\t"),
511
+ ...g.members.map((r) => simpleRepoRow({ repo: r, parent: null, depth: 1 }, nameCol)),
512
+ ];
513
+ }
514
+
515
+ function simpleRepoRows(repos: RepoRowFull[], logical: LogicalView[] = []): string[] {
451
516
  const { groups, projects } = simpleSections(repos);
452
517
  const all = [...groups, ...projects];
518
+ const logicalHeads = logical.flatMap((g) => [
519
+ `${padDisplay(LOGICAL_ICON, 2)} ${truncate(g.label, 40)}`,
520
+ ...g.members.map((r) => simpleHead({ repo: r, parent: null, depth: 1 })),
521
+ ]);
453
522
  const nameCol = Math.min(
454
523
  SIMPLE_NAME_COL_MAX,
455
- Math.max(0, ...all.map((n) => displayWidth(simpleHead(n)))),
524
+ Math.max(0, ...all.map((n) => displayWidth(simpleHead(n))), ...logicalHeads.map(displayWidth)),
456
525
  );
457
526
  const lines: string[] = [];
458
- if (groups.length > 0) {
527
+ if (groups.length > 0 || logical.length > 0) {
459
528
  lines.push(simpleHeading("グループ"));
460
529
  for (const n of groups) lines.push(simpleRepoRow(n, nameCol));
530
+ // After the launch: false headers.
531
+ for (const g of logical) lines.push(...simpleLogicalRows(g, nameCol));
461
532
  }
462
533
  if (projects.length > 0) {
463
534
  lines.push(simpleHeading("プロジェクト"));
@@ -549,10 +620,35 @@ function printUi(stateDb: string): number {
549
620
  return 0;
550
621
  }
551
622
 
623
+ /**
624
+ * `--print-settings`: what the last scan stored, as the pre-scan prints it
625
+ * (`ui=<mode>` and `update_check=<on|off>`). Any failure prints the defaults
626
+ * (classic, on): the launcher must always get an answer it can use.
627
+ */
628
+ function printSettings(stateDb: string): number {
629
+ let ui: UiMode = "classic";
630
+ let updateCheck = "on";
631
+ try {
632
+ const handle = openDb(stateDb, { readonly: true, skipMigrate: true });
633
+ try {
634
+ const v = getMeta(handle.db, "defaults_ui");
635
+ if (isUiMode(v)) ui = v;
636
+ if (getMeta(handle.db, "defaults_update_check") === "off") updateCheck = "off";
637
+ } finally {
638
+ handle.close();
639
+ }
640
+ } catch {
641
+ // keep the defaults
642
+ }
643
+ process.stdout.write(`ui=${ui}\nupdate_check=${updateCheck}\n`);
644
+ return 0;
645
+ }
646
+
552
647
  function main(): number {
553
648
  const flags = parseArgs(process.argv.slice(2));
554
649
  const paths = getPaths();
555
650
  if (flags.printUi) return printUi(paths.stateDb);
651
+ if (flags.printSettings) return printSettings(paths.stateDb);
556
652
  const simple = flags.ui === "simple";
557
653
  // openDb() is called INSIDE the try so that a cold-start failure (state.db
558
654
  // missing before first scan) is caught and rendered as a friendly hint
@@ -584,12 +680,17 @@ function main(): number {
584
680
  ORDER BY r.name COLLATE NOCASE ASC`,
585
681
  )
586
682
  .all() as RepoRowFull[];
683
+ // defaults.groups_dir: logical groups the last scan matched to these
684
+ // repos (none when the setting is off).
685
+ const logical = logicalGroupsOf(groupsFromMeta(getMeta(db, "logical_groups")), repos);
587
686
  if (simple) {
588
- lines.push(...simpleRepoRows(repos));
687
+ lines.push(...simpleRepoRows(repos, logical));
589
688
  } else {
590
689
  for (const g of groupRepos(repos)) {
591
690
  lines.push(repoToRow(g));
592
691
  }
692
+ // After every project row, before the conversations.
693
+ lines.push(...logicalGroupRows(logical));
593
694
  }
594
695
  }
595
696
 
@@ -605,7 +706,9 @@ function main(): number {
605
706
  ? metaCommand
606
707
  : "claude";
607
708
 
608
- const filterCwd = flags.currentOnly ? process.cwd() : null;
709
+ // CCS_CWD: the folder ccs was run in (bin/ccs starts this module from
710
+ // its own folder; see ccs-tsx.sh).
711
+ const filterCwd = flags.currentOnly ? process.env.CCS_CWD || process.cwd() : null;
609
712
  const baseSelect = `SELECT s.uuid, s.repo_name, s.cwd, s.last_activity_at, s.topic,
610
713
  r.name AS repo_display, r.icon AS repo_icon, r.command AS repo_command
611
714
  FROM sessions s
@@ -660,7 +763,13 @@ function main(): number {
660
763
  lines.push(...sessionRows);
661
764
  }
662
765
 
663
- process.stdout.write(lines.join("\n") + (lines.length > 0 ? "\n" : ""));
766
+ // Last check: bin/ccs takes the columns by position and runs column 6, so a
767
+ // row of any other shape is dropped, never handed over.
768
+ const rows = lines.filter(isWellFormedRow);
769
+ if (rows.length !== lines.length) {
770
+ process.stderr.write(`[ccs-list] skipped ${lines.length - rows.length} malformed row(s)\n`);
771
+ }
772
+ process.stdout.write(rows.join("\n") + (rows.length > 0 ? "\n" : ""));
664
773
  return 0;
665
774
  } catch (err) {
666
775
  const msg = err instanceof Error ? err.message : String(err);
@@ -690,7 +799,9 @@ function main(): number {
690
799
  }
691
800
  }
692
801
 
693
- if (import.meta.url === `file://${process.argv[1]}`) {
802
+ // pathToFileURL, not `file://${argv[1]}`: a folder with a space (or any
803
+ // character a URL escapes) in its name would never match.
804
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
694
805
  // exitCode, not process.exit(): stdout is a pipe into fzf, and pipe writes
695
806
  // are async on macOS. Exiting right after the write dropped everything past
696
807
  // the pipe buffer — as little as 512 bytes, so the list ended after 3 repos