@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-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,10 +23,13 @@
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 {
@@ -44,6 +47,7 @@ import {
44
47
  uiFromArgv,
45
48
  type UiMode,
46
49
  } from "./ccs-utils.ts";
50
+ import { pathToFileURL } from "node:url";
47
51
 
48
52
  // ---------------------------------------------------------------------------
49
53
  // Constants
@@ -71,6 +75,7 @@ interface Flags {
71
75
  sessionsOnly: boolean;
72
76
  ui: UiMode;
73
77
  printUi: boolean;
78
+ printSettings: boolean;
74
79
  }
75
80
 
76
81
  function parseArgs(argv: string[]): Flags {
@@ -80,6 +85,7 @@ function parseArgs(argv: string[]): Flags {
80
85
  sessionsOnly: argv.includes("--sessions-only"),
81
86
  ui: uiFromArgv(argv),
82
87
  printUi: argv.includes("--print-ui"),
88
+ printSettings: argv.includes("--print-settings"),
83
89
  };
84
90
  }
85
91
 
@@ -452,17 +458,77 @@ function simpleRepoRow(n: SimpleNode, nameCol: number): string {
452
458
  return [label, desc, "", `new:${r.name}`, cwd, r.command || "claude"].join("\t");
453
459
  }
454
460
 
455
- 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[] {
456
516
  const { groups, projects } = simpleSections(repos);
457
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
+ ]);
458
522
  const nameCol = Math.min(
459
523
  SIMPLE_NAME_COL_MAX,
460
- Math.max(0, ...all.map((n) => displayWidth(simpleHead(n)))),
524
+ Math.max(0, ...all.map((n) => displayWidth(simpleHead(n))), ...logicalHeads.map(displayWidth)),
461
525
  );
462
526
  const lines: string[] = [];
463
- if (groups.length > 0) {
527
+ if (groups.length > 0 || logical.length > 0) {
464
528
  lines.push(simpleHeading("グループ"));
465
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));
466
532
  }
467
533
  if (projects.length > 0) {
468
534
  lines.push(simpleHeading("プロジェクト"));
@@ -554,10 +620,35 @@ function printUi(stateDb: string): number {
554
620
  return 0;
555
621
  }
556
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
+
557
647
  function main(): number {
558
648
  const flags = parseArgs(process.argv.slice(2));
559
649
  const paths = getPaths();
560
650
  if (flags.printUi) return printUi(paths.stateDb);
651
+ if (flags.printSettings) return printSettings(paths.stateDb);
561
652
  const simple = flags.ui === "simple";
562
653
  // openDb() is called INSIDE the try so that a cold-start failure (state.db
563
654
  // missing before first scan) is caught and rendered as a friendly hint
@@ -589,12 +680,17 @@ function main(): number {
589
680
  ORDER BY r.name COLLATE NOCASE ASC`,
590
681
  )
591
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);
592
686
  if (simple) {
593
- lines.push(...simpleRepoRows(repos));
687
+ lines.push(...simpleRepoRows(repos, logical));
594
688
  } else {
595
689
  for (const g of groupRepos(repos)) {
596
690
  lines.push(repoToRow(g));
597
691
  }
692
+ // After every project row, before the conversations.
693
+ lines.push(...logicalGroupRows(logical));
598
694
  }
599
695
  }
600
696
 
@@ -610,7 +706,9 @@ function main(): number {
610
706
  ? metaCommand
611
707
  : "claude";
612
708
 
613
- 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;
614
712
  const baseSelect = `SELECT s.uuid, s.repo_name, s.cwd, s.last_activity_at, s.topic,
615
713
  r.name AS repo_display, r.icon AS repo_icon, r.command AS repo_command
616
714
  FROM sessions s
@@ -701,7 +799,9 @@ function main(): number {
701
799
  }
702
800
  }
703
801
 
704
- 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) {
705
805
  // exitCode, not process.exit(): stdout is a pipe into fzf, and pipe writes
706
806
  // are async on macOS. Exiting right after the write dropped everything past
707
807
  // the pipe buffer — as little as 512 bytes, so the list ended after 3 repos