claude-code-station 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,287 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * ccs-init.ts — repos.yml bootstrap helpers for `ccs init`.
4
+ *
5
+ * Three modes, wired by bin/ccs:
6
+ * scaffold ensure ~/.config/ccs/ exists, print where config lives
7
+ * discover [--depth N] walk $HOME for git repos not yet in repos.yml and
8
+ * emit fzf candidate lines: "<name>\t<~/path>"
9
+ * append read selected candidate lines from stdin and append
10
+ * them to repos.yml (comment/format-preserving), with
11
+ * backup + post-write validation rollback
12
+ *
13
+ * Spec: docs/v0.2.1-backlog.md "ccs init --auto-discover".
14
+ */
15
+
16
+ import { readdir } from "node:fs/promises";
17
+ import { readFileSync, writeFileSync } from "node:fs";
18
+ import { homedir } from "node:os";
19
+ import { basename, join, relative } from "node:path";
20
+ import { parseDocument, isSeq } from "yaml";
21
+
22
+ import { ensureConfigDir, getPaths, loadConfig } from "./ccs-config.ts";
23
+ import { hasShellMetachars } from "./ccs-sanitize.ts";
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Discovery
27
+ // ---------------------------------------------------------------------------
28
+
29
+ const DEFAULT_MAX_DEPTH = 5;
30
+
31
+ // Non-hidden directories that never contain the user's own repos. Hidden
32
+ // directories (.venv, .cache, .Trash*, .next, ...) are skipped wholesale by
33
+ // the dot rule below, so only visible noise needs listing here.
34
+ const EXCLUDED_DIR_NAMES = new Set([
35
+ "node_modules",
36
+ "Library",
37
+ "Applications",
38
+ "__pycache__",
39
+ "dist",
40
+ "build",
41
+ "target",
42
+ ]);
43
+
44
+ export interface DiscoverOptions {
45
+ /** Levels below `home` to descend (default 5). */
46
+ maxDepth?: number;
47
+ /** Walk root — overridable for tests (default homedir()). */
48
+ home?: string;
49
+ }
50
+
51
+ /**
52
+ * Find git checkout roots under $HOME. A directory containing a `.git`
53
+ * entry (dir for normal clones, file for worktrees/submodules) is emitted
54
+ * and NOT descended into — nested repos inside a found repo are the found
55
+ * repo's business. Symlinked directories are skipped (cycle safety).
56
+ *
57
+ * Exception: the walk root itself ($HOME-as-dotfiles-repo pattern) is
58
+ * emitted but still descended, otherwise one `.git` at $HOME would mask
59
+ * every real project below it.
60
+ */
61
+ export async function discoverGitRepos(
62
+ opts: DiscoverOptions = {},
63
+ ): Promise<string[]> {
64
+ const home = opts.home ?? homedir();
65
+ const maxDepth = opts.maxDepth ?? DEFAULT_MAX_DEPTH;
66
+ const found: string[] = [];
67
+
68
+ async function walk(dir: string, depth: number): Promise<void> {
69
+ let entries;
70
+ try {
71
+ entries = await readdir(dir, { withFileTypes: true });
72
+ } catch {
73
+ return; // unreadable dir — not ours to report
74
+ }
75
+ if (entries.some((e) => e.name === ".git")) {
76
+ found.push(dir);
77
+ if (depth > 0) return; // stop at repo roots (root-dir exception above)
78
+ }
79
+ if (depth >= maxDepth) return;
80
+ const subdirs = entries.filter(
81
+ (e) =>
82
+ e.isDirectory() &&
83
+ !e.name.startsWith(".") &&
84
+ !EXCLUDED_DIR_NAMES.has(e.name),
85
+ );
86
+ await Promise.all(subdirs.map((e) => walk(join(dir, e.name), depth + 1)));
87
+ }
88
+
89
+ await walk(home, 0);
90
+ return found.sort();
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Candidate building
95
+ // ---------------------------------------------------------------------------
96
+
97
+ export interface Candidate {
98
+ name: string;
99
+ /** Stored form: "~" or "~/<relative>" so repos.yml stays portable. */
100
+ path: string;
101
+ }
102
+
103
+ export interface ExistingConfig {
104
+ names: Set<string>;
105
+ /** Resolved absolute repo paths (RepoEntry.path). */
106
+ paths: Set<string>;
107
+ }
108
+
109
+ /**
110
+ * Turn discovered absolute paths into repos.yml candidates: subtract repos
111
+ * already registered, skip paths the SHELL_METACHARS policy would reject at
112
+ * load time anyway, suggest names from the directory basename, and uniquify
113
+ * against existing + already-suggested names ("foo", "foo-2", ...).
114
+ */
115
+ export function buildCandidates(
116
+ foundAbsPaths: string[],
117
+ existing: ExistingConfig,
118
+ home: string,
119
+ ): Candidate[] {
120
+ const out: Candidate[] = [];
121
+ const taken = new Set(existing.names);
122
+ for (const abs of foundAbsPaths) {
123
+ if (existing.paths.has(abs)) continue;
124
+ if (hasShellMetachars(abs)) {
125
+ process.stderr.write(
126
+ `[ccs-init] skipped (shell metacharacters in path): ${JSON.stringify(abs)}\n`,
127
+ );
128
+ continue;
129
+ }
130
+ const base = abs === home ? "home" : basename(abs);
131
+ let name = base;
132
+ for (let i = 2; taken.has(name); i++) name = `${base}-${i}`;
133
+ taken.add(name);
134
+ out.push({
135
+ name,
136
+ path: abs === home ? "~" : `~/${relative(home, abs)}`,
137
+ });
138
+ }
139
+ return out;
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // YAML append (comment/format-preserving)
144
+ // ---------------------------------------------------------------------------
145
+
146
+ /**
147
+ * Append candidates to the `repos:` sequence of a repos.yml source string.
148
+ * parseDocument round-trips comments and the user's existing formatting —
149
+ * never re-serialize the whole config from plain objects.
150
+ */
151
+ export function appendReposToYaml(
152
+ source: string,
153
+ candidates: Candidate[],
154
+ ): string {
155
+ const doc = parseDocument(source);
156
+ const repos = doc.get("repos", true);
157
+ if (!isSeq(repos)) {
158
+ throw new Error("repos.yml: top-level `repos` sequence not found");
159
+ }
160
+ for (const c of candidates) {
161
+ repos.add(doc.createNode({ name: c.name, path: c.path }));
162
+ }
163
+ // lineWidth: 0 — never re-fold long lines the user wrote on one line;
164
+ // flowCollectionPadding: false — keep `tags: [work]`, not `[ work ]`.
165
+ // Without these, toString() normalizes UNTOUCHED lines (observed on a real
166
+ // config: every tags entry repadded + a long description folded in two).
167
+ return doc.toString({ lineWidth: 0, flowCollectionPadding: false });
168
+ }
169
+
170
+ /**
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.
174
+ */
175
+ export function appendAndValidate(
176
+ reposYmlPath: string,
177
+ candidates: Candidate[],
178
+ ): 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 });
183
+ try {
184
+ loadConfig();
185
+ } 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
+ );
190
+ }
191
+ }
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // CLI
195
+ // ---------------------------------------------------------------------------
196
+
197
+ function parseDepth(argv: string[]): number {
198
+ const i = argv.indexOf("--depth");
199
+ if (i < 0) return DEFAULT_MAX_DEPTH;
200
+ const n = parseInt(argv[i + 1] ?? "", 10);
201
+ if (!Number.isFinite(n) || n < 1 || n > 12) {
202
+ throw new Error(`[ccs-init] --depth must be an integer 1-12, got: ${argv[i + 1]}`);
203
+ }
204
+ return n;
205
+ }
206
+
207
+ async function cliDiscover(argv: string[]): Promise<number> {
208
+ const maxDepth = parseDepth(argv);
209
+ ensureConfigDir();
210
+ const config = loadConfig(); // throws ConfigError on a broken repos.yml
211
+ const existing: ExistingConfig = {
212
+ names: new Set(config.repos.map((r) => r.name)),
213
+ paths: new Set(config.repos.map((r) => r.path)),
214
+ };
215
+ const home = homedir();
216
+ const found = await discoverGitRepos({ maxDepth, home });
217
+ const candidates = buildCandidates(found, existing, home);
218
+ for (const c of candidates) {
219
+ process.stdout.write(`${c.name}\t${c.path}\n`);
220
+ }
221
+ return 0;
222
+ }
223
+
224
+ async function cliAppend(): Promise<number> {
225
+ const stdin = readFileSync(0, "utf-8");
226
+ const candidates: Candidate[] = [];
227
+ for (const line of stdin.split("\n")) {
228
+ if (!line.trim()) continue;
229
+ const tab = line.indexOf("\t");
230
+ if (tab <= 0) {
231
+ process.stderr.write(`[ccs-init] malformed candidate line skipped: ${JSON.stringify(line)}\n`);
232
+ continue;
233
+ }
234
+ candidates.push({ name: line.slice(0, tab), path: line.slice(tab + 1) });
235
+ }
236
+ if (candidates.length === 0) {
237
+ process.stderr.write("[ccs-init] nothing selected — repos.yml unchanged\n");
238
+ return 0;
239
+ }
240
+ const paths = getPaths();
241
+ appendAndValidate(paths.reposYml, candidates);
242
+ process.stderr.write(
243
+ `[ccs-init] added ${candidates.length} repo(s) to ${paths.reposYml}\n`,
244
+ );
245
+ return 0;
246
+ }
247
+
248
+ function cliScaffold(): number {
249
+ ensureConfigDir();
250
+ const paths = getPaths();
251
+ process.stdout.write(
252
+ `Config ready: ${paths.reposYml}\n` +
253
+ `Edit it directly, or run \`ccs init --auto-discover\` to scan $HOME for git repos.\n`,
254
+ );
255
+ return 0;
256
+ }
257
+
258
+ async function main(): Promise<number> {
259
+ const mode = process.argv[2] ?? "";
260
+ const rest = process.argv.slice(3);
261
+ try {
262
+ if (mode === "discover") return await cliDiscover(rest);
263
+ if (mode === "append") return await cliAppend();
264
+ if (mode === "scaffold") return cliScaffold();
265
+ process.stderr.write(
266
+ "Usage: ccs-init.ts <scaffold | discover [--depth N] | append>\n",
267
+ );
268
+ return 1;
269
+ } catch (err) {
270
+ process.stderr.write(
271
+ `[ccs-init] ${err instanceof Error ? err.message : String(err)}\n`,
272
+ );
273
+ return 1;
274
+ }
275
+ }
276
+
277
+ if (import.meta.url === `file://${process.argv[1]}`) {
278
+ main().then(
279
+ (code) => process.exit(code),
280
+ (err) => {
281
+ process.stderr.write(
282
+ `[ccs-init] fatal: ${err instanceof Error ? err.message : String(err)}\n`,
283
+ );
284
+ process.exit(1);
285
+ },
286
+ );
287
+ }
@@ -0,0 +1,363 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * ccs-list.ts — SQL-driven row builder for the ccs fzf launcher (v0.2.0)
4
+ *
5
+ * Outputs tab-separated rows for fzf consumption:
6
+ * <LABEL>\t<DESCRIPTION>\t<BADGES>\t<KIND>:<KEY>\t<CWD>\t<COMMAND>
7
+ *
8
+ * Modes:
9
+ * --current-only sessions whose cwd matches process.cwd()
10
+ * --repos-only only NEW repo rows
11
+ * --sessions-only only RESUME session rows
12
+ * (default) NEW first (alpha), then RESUME (last_activity_at DESC)
13
+ */
14
+
15
+ import { homedir } from "node:os";
16
+ import { openDb, getMeta } from "./ccs-db.ts";
17
+ import { getPaths } from "./ccs-config.ts";
18
+ import { SHELL_METACHARS } from "./ccs-sanitize.ts";
19
+ import { truncate, formatRelativeTime, formatDateTime } from "./ccs-utils.ts";
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Constants
23
+ // ---------------------------------------------------------------------------
24
+
25
+ const DESC_MAX = 60;
26
+ const BADGES_MAX = 60;
27
+ const KNOWN_INTEGRATION_KEYS = [
28
+ "plane_project_id",
29
+ "attio_workspace",
30
+ "notion_db",
31
+ "linear_team",
32
+ "slack_channel",
33
+ "github_repo",
34
+ "figma_file",
35
+ ];
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // CLI
39
+ // ---------------------------------------------------------------------------
40
+
41
+ interface Flags {
42
+ currentOnly: boolean;
43
+ reposOnly: boolean;
44
+ sessionsOnly: boolean;
45
+ }
46
+
47
+ function parseArgs(argv: string[]): Flags {
48
+ return {
49
+ currentOnly: argv.includes("--current-only"),
50
+ reposOnly: argv.includes("--repos-only"),
51
+ sessionsOnly: argv.includes("--sessions-only"),
52
+ };
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Helpers
57
+ // ---------------------------------------------------------------------------
58
+
59
+ // truncate / formatRelativeTime / formatDateTime come from ccs-utils.ts —
60
+ // the single source shared with the preview pane (review A-1/A-3/K-8), so
61
+ // list badges and preview text can no longer drift in thresholds or language.
62
+
63
+ function clipBadges(badges: string[], max: number): string {
64
+ const out: string[] = [];
65
+ let len = 0;
66
+ for (const b of badges) {
67
+ const add = (out.length === 0 ? 0 : 1) + b.length;
68
+ if (len + add > max) break;
69
+ out.push(b);
70
+ len += add;
71
+ }
72
+ return out.join(" ");
73
+ }
74
+
75
+ function parseCustom(json: string): Record<string, unknown> {
76
+ try {
77
+ const v = JSON.parse(json);
78
+ if (v && typeof v === "object" && !Array.isArray(v)) {
79
+ return v as Record<string, unknown>;
80
+ }
81
+ } catch {
82
+ // ignore
83
+ }
84
+ return {};
85
+ }
86
+
87
+ function integrationShortNames(custom: Record<string, unknown>): string[] {
88
+ const names: string[] = [];
89
+ for (const key of KNOWN_INTEGRATION_KEYS) {
90
+ if (custom[key] === undefined || custom[key] === null || custom[key] === "") continue;
91
+ // map key -> short label
92
+ const short = key
93
+ .replace(/_project_id$/, "")
94
+ .replace(/_workspace$/, "")
95
+ .replace(/_db$/, "")
96
+ .replace(/_team$/, "")
97
+ .replace(/_channel$/, "")
98
+ .replace(/_repo$/, "")
99
+ .replace(/_file$/, "");
100
+ names.push(short);
101
+ }
102
+ return names;
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Row builders
107
+ // ---------------------------------------------------------------------------
108
+
109
+ interface RepoRowFull {
110
+ name: string;
111
+ path: string;
112
+ description: string;
113
+ command: string;
114
+ cwd: string | null;
115
+ icon: string;
116
+ disabled: number;
117
+ scan_enabled: number;
118
+ custom_json: string;
119
+ // stats (LEFT JOINed, may be null)
120
+ is_git: number | null;
121
+ branch: string | null;
122
+ uncommitted_insertions: number | null;
123
+ uncommitted_deletions: number | null;
124
+ handoff_count: number | null;
125
+ pending_count: number | null;
126
+ session_last_at: string | null;
127
+ }
128
+
129
+ function buildRepoBadges(r: RepoRowFull): string {
130
+ const badges: string[] = [];
131
+
132
+ // scan: false repos keep their last-scanned stats forever — flag the
133
+ // staleness so the user doesn't read a frozen badge as current state
134
+ // (audit logic M-2).
135
+ if (r.scan_enabled === 0) {
136
+ badges.push(`[scan off]`);
137
+ }
138
+
139
+ if (r.is_git === 1) {
140
+ const branch = r.branch ?? "?";
141
+ const ins = r.uncommitted_insertions ?? 0;
142
+ const del = r.uncommitted_deletions ?? 0;
143
+ if (ins === 0 && del === 0) {
144
+ badges.push(`[${branch} clean]`);
145
+ } else {
146
+ badges.push(`[${branch} +${ins}/-${del}]`);
147
+ }
148
+ }
149
+
150
+ if ((r.handoff_count ?? 0) > 0) {
151
+ badges.push(`[⚠️ handoff×${r.handoff_count}]`);
152
+ }
153
+ if ((r.pending_count ?? 0) > 0) {
154
+ badges.push(`[📝 pending×${r.pending_count}]`);
155
+ }
156
+
157
+ if (r.session_last_at) {
158
+ const ht = formatRelativeTime(r.session_last_at, "");
159
+ if (ht) badges.push(`[🔄 ${ht}]`);
160
+ } else {
161
+ badges.push(`[💤 未使用]`);
162
+ }
163
+
164
+ const custom = parseCustom(r.custom_json);
165
+ const names = integrationShortNames(custom).slice(0, 3);
166
+ if (names.length > 0) {
167
+ badges.push(`[🔗 ${names.join(",")}]`);
168
+ }
169
+
170
+ return clipBadges(badges, BADGES_MAX);
171
+ }
172
+
173
+ // Dim vertical bar separator (ANSI 2 = faint) inserted between the label
174
+ // column and the description column to visually distinguish them. fzf is
175
+ // invoked with --ansi so these codes render as styling.
176
+ const LABEL_SEP = " \x1b[2m│\x1b[0m";
177
+
178
+ function repoToRow(r: RepoRowFull): string {
179
+ const label = `${r.icon || "📁"} ${r.name}${LABEL_SEP}`;
180
+ const desc = truncate(r.description || "", DESC_MAX);
181
+ const badges = buildRepoBadges(r);
182
+ const cwd = r.cwd && r.cwd.length > 0 ? r.cwd : r.path;
183
+ const command = r.command || "claude";
184
+ return [
185
+ label,
186
+ desc,
187
+ badges,
188
+ `new:${r.name}`,
189
+ cwd,
190
+ command,
191
+ ].join("\t");
192
+ }
193
+
194
+ interface SessionRowFull {
195
+ uuid: string;
196
+ repo_name: string | null;
197
+ cwd: string;
198
+ last_activity_at: string;
199
+ topic: string | null;
200
+ // from repos (LEFT JOIN)
201
+ repo_display: string | null;
202
+ repo_icon: string | null;
203
+ repo_command: string | null;
204
+ }
205
+
206
+ function sessionToRow(s: SessionRowFull, defaultCommand: string): string {
207
+ // Mapped sessions use the registered repo icon + name.
208
+ // Unmapped sessions get ❓ as a visible reminder: either a one-off run
209
+ // or a repo that hasn't been added to repos.yml yet.
210
+ // Ternary tests s.repo_display directly so TS narrows string|null → string
211
+ // without a non-null assertion.
212
+ // Unmapped sessions fall back to showing their cwd. That value is gated at
213
+ // scan time, but route it through truncate() anyway so the label column
214
+ // gets the same control-char stripping as every other column (audit NEW-1
215
+ // defense-in-depth; fzf renders this column with --ansi).
216
+ const displayName = s.repo_display
217
+ ? s.repo_display
218
+ : s.cwd
219
+ ? truncate(s.cwd.replace(homedir(), "~"), DESC_MAX)
220
+ : "(unknown)";
221
+ const mapIcon = s.repo_display ? s.repo_icon || "📁" : "❓";
222
+ const label = `🔄 ${mapIcon} ${displayName}${LABEL_SEP}`;
223
+ const desc = truncate(s.topic || "", DESC_MAX);
224
+ const badges = `[${formatDateTime(s.last_activity_at)}]`;
225
+ // Unmapped sessions fall back to the scan-time resolved default
226
+ // (defaults.command > CCS_CMD > "claude"), not a hardcoded "claude" — the
227
+ // documented priority chain applies to every launchable row (review A-8).
228
+ const command = s.repo_command || defaultCommand;
229
+ return [
230
+ label,
231
+ desc,
232
+ badges,
233
+ `resume:${s.uuid}`,
234
+ s.cwd,
235
+ command,
236
+ ].join("\t");
237
+ }
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // Main
241
+ // ---------------------------------------------------------------------------
242
+
243
+ function main(): number {
244
+ const flags = parseArgs(process.argv.slice(2));
245
+ const paths = getPaths();
246
+ // openDb() is called INSIDE the try so that a cold-start failure (state.db
247
+ // missing before first scan) is caught and rendered as a friendly hint
248
+ // instead of leaking a raw stack trace to fzf.
249
+ let close: (() => void) | undefined;
250
+ try {
251
+ const handle = openDb(paths.stateDb, { readonly: true, skipMigrate: true });
252
+ const db = handle.db;
253
+ close = handle.close;
254
+
255
+ const lines: string[] = [];
256
+
257
+ const wantRepos = !flags.sessionsOnly && !flags.currentOnly;
258
+ const wantSessions = !flags.reposOnly;
259
+
260
+ if (wantRepos) {
261
+ const repos = db
262
+ .prepare(
263
+ `SELECT r.name, r.path, r.description, r.command, r.cwd, r.icon,
264
+ r.disabled, r.scan_enabled, r.custom_json,
265
+ s.is_git, s.branch, s.uncommitted_insertions,
266
+ s.uncommitted_deletions, s.handoff_count, s.pending_count,
267
+ s.session_last_at
268
+ FROM repos r
269
+ LEFT JOIN repo_stats s ON s.name = r.name
270
+ WHERE r.disabled = 0
271
+ ORDER BY r.name COLLATE NOCASE ASC`,
272
+ )
273
+ .all() as RepoRowFull[];
274
+ for (const r of repos) {
275
+ lines.push(repoToRow(r));
276
+ }
277
+ }
278
+
279
+ if (wantSessions) {
280
+ // Launch-command fallback for unmapped sessions (review A-8), written
281
+ // by ccs-scan at sync time. getMeta returns null on a schema-v1 cache
282
+ // that predates the meta table — degrade to "claude". The metachar
283
+ // check is display-side defense in depth: the value was validated at
284
+ // config load, but this row is re-executed unquoted by bin/ccs.
285
+ const metaCommand = getMeta(db, "defaults_command");
286
+ const defaultCommand =
287
+ metaCommand && !SHELL_METACHARS.test(metaCommand)
288
+ ? metaCommand
289
+ : "claude";
290
+
291
+ const filterCwd = flags.currentOnly ? process.cwd() : null;
292
+ const baseSelect = `SELECT s.uuid, s.repo_name, s.cwd, s.last_activity_at, s.topic,
293
+ r.name AS repo_display, r.icon AS repo_icon, r.command AS repo_command
294
+ FROM sessions s
295
+ LEFT JOIN repos r ON r.name = s.repo_name`;
296
+ // --current-only filters in SQL so idx_sessions_cwd is usable
297
+ // (review A-10). The prefix match is expressed as a half-open range
298
+ // [cwd + "/", cwd + "0") — "0" is the code point after "/" — because
299
+ // LIKE is case-insensitive by default and would bypass the index.
300
+ const rows = (
301
+ filterCwd
302
+ ? db
303
+ .prepare(
304
+ `${baseSelect}
305
+ WHERE s.cwd = ? OR (s.cwd >= ? AND s.cwd < ?)
306
+ ORDER BY s.last_activity_at DESC`,
307
+ )
308
+ .all(filterCwd, filterCwd + "/", filterCwd + "0")
309
+ : db
310
+ .prepare(`${baseSelect}
311
+ ORDER BY s.last_activity_at DESC`)
312
+ .all()
313
+ ) as SessionRowFull[];
314
+ const sessionRows: string[] = [];
315
+ for (const s of rows) {
316
+ sessionRows.push(sessionToRow(s, defaultCommand));
317
+ }
318
+ // Insert a section separator between NEW repos and RESUME sessions
319
+ // when both are present. The separator row uses KIND=separator so
320
+ // bin/ccs recognises it and exits cleanly if the user selects it.
321
+ // Guard uses the explicit wantRepos flag (not lines.length) so future
322
+ // additions to `lines` before this block can't spuriously trigger it.
323
+ if (wantRepos && sessionRows.length > 0) {
324
+ const bar = "─".repeat(20);
325
+ const label = `\x1b[2m${bar}\x1b[0m \x1b[1;90m Past Sessions \x1b[0m\x1b[2m${bar}\x1b[0m`;
326
+ lines.push([label, "", "", "separator:-", "", ""].join("\t"));
327
+ }
328
+ lines.push(...sessionRows);
329
+ }
330
+
331
+ process.stdout.write(lines.join("\n") + (lines.length > 0 ? "\n" : ""));
332
+ return 0;
333
+ } catch (err) {
334
+ const msg = err instanceof Error ? err.message : String(err);
335
+ // Cold-start case: state.db not yet built by the scanner.
336
+ // The readonly path in openDb() raises its own "[ccs-db] state.db not
337
+ // found" message BEFORE better-sqlite3 ever runs (review C-5) — that
338
+ // pattern must be matched here or the friendly hint below is dead code.
339
+ // The better-sqlite3 native patterns stay as a guard for races where the
340
+ // file disappears between the existsSync check and the open.
341
+ const isMissing =
342
+ /state\.db not found/i.test(msg) ||
343
+ /unable to open database file/i.test(msg) ||
344
+ /database (file )?does not exist/i.test(msg) ||
345
+ /ENOENT/.test(msg);
346
+ if (isMissing) {
347
+ process.stderr.write(
348
+ "[ccs-list] state.db not found. Run `ccs --refresh` first to build the cache.\n",
349
+ );
350
+ } else {
351
+ process.stderr.write(`[ccs-list] fatal: ${msg}\n`);
352
+ }
353
+ return 1;
354
+ } finally {
355
+ // close may be undefined if openDb() itself threw before the destructure
356
+ // completed, so guard against it.
357
+ close?.();
358
+ }
359
+ }
360
+
361
+ if (import.meta.url === `file://${process.argv[1]}`) {
362
+ process.exit(main());
363
+ }