claude-code-station 0.2.2 → 0.2.4

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 CHANGED
@@ -4,7 +4,24 @@ All notable changes to this project are documented in this file.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
- ## [Unreleased]
7
+ ## [0.2.4] — 2026-10-21
8
+
9
+ ### Added
10
+ - **Group headers (`launch: false`)** — a repos.yml entry with `launch: false` is listed as a display-only header row (`KIND=group:<name>`) for a parent folder that must not be launched; the repos inside its `path` nest under it by the existing deepest-path rule. Enter (and double-click) on a header keeps the picker open (fzf 0.45+ `transform` binding, detected from `fzf --version`; older fzf accepts the row and ccs exits quietly, like the separator). `^I` copies the header's path, `^Y` copies nothing, `^D` shows the same "edit repos.yml" note as repo rows. The preview shows the header's path, description, a not-launchable note, and its entries (display names + relative dirs). Headers carry no badges and are skipped by the repo scan. `path` is required for a header; `launch` must be a real boolean (a quoted `"false"` is rejected instead of being coerced to `true`).
11
+ - **Display names (`label`)** — an optional `label` is shown instead of `name` in the repo row, the `‹parent›` hint and the preview title (with the `name` dimmed after it). `name` stays the unique key everywhere else; past-session rows keep showing `name`. Display-only like `description`: shell metacharacters are allowed and control characters are stripped at render time.
12
+
13
+ ### Changed
14
+ - **Nested repos render as a tree** — children use `├ ` for earlier siblings and `└ ` for the last one, and deeper levels draw a `│ ` rail while an ancestor still has later siblings (previously every child used `└ `). The dim `‹parent›` hint uses the parent's display name and is omitted when it equals the row's own display name.
15
+ - **Schema v3** — `repos.launch INTEGER NOT NULL DEFAULT 1` and `repos.label TEXT NOT NULL DEFAULT ''` (plain `ADD COLUMN`). ccs-list / ccs-preview (readonly, no migration) synthesize the defaults on a not-yet-migrated v2 cache, and 0.2.3 keeps working against a v3 cache: its repos statements name explicit columns, so the new columns take their defaults on insert and are never read.
16
+
17
+ ### Fixed
18
+ - **Empty columns no longer shift the picked row** — `bin/ccs` parsed the picked row with `IFS=$'\t' read`, and TAB is IFS whitespace, so an empty column was merged away: a repo with no `description` could not be launched (`Unknown row kind`, because KIND:KEY held the cwd). Tabs are now swapped for `\x1f` before the read, which keeps empty fields.
19
+ - A repos.yml entry without `path` no longer counts as the parent of every other repo in the list nesting (an empty path matched every absolute path as a prefix).
20
+
21
+ ## [0.2.3] — 2026-10-14
22
+
23
+ ### Added
24
+ - **Nested repos are grouped in the list** — a repo whose `path` sits inside another listed repo's `path` now comes right after that repo, indented (` └ 📁 child ‹parent›`), instead of wherever its name sorts. The nearest enclosing path wins (the same deepest-path rule that maps sessions to repos), deeper nesting indents further, and siblings stay name-sorted. The dim `‹parent›` suffix keeps the relation readable when a fzf query hides the parent row, and a query for the parent's name matches its children too. Derived from `path` at list time — no `repos.yml` key and no cache migration. A disabled parent is not listed, so its children stay top-level.
8
25
 
9
26
  ## [0.2.2] — 2026-06-21
10
27
 
package/README.md CHANGED
@@ -39,7 +39,7 @@ A fast, fzf-powered launcher and session picker for [Claude Code](https://docs.a
39
39
  ### Prerequisites / 前提条件
40
40
 
41
41
  - [Node.js](https://nodejs.org/) **20+**
42
- - [fzf](https://github.com/junegunn/fzf) **≥ 0.42.0** — fuzzy finder (the `change-header` binding used by the copy-toast requires 0.42+; released Aug 2023)
42
+ - [fzf](https://github.com/junegunn/fzf) **≥ 0.42.0** — fuzzy finder (the `change-header` binding used by the copy-toast requires 0.42+; released Aug 2023). With **0.45+** Enter on a group header (`launch: false`) keeps the list open; on older fzf it just closes ccs without launching anything / 0.45 以上なら、グループ見出しの上で Enter を押してもリストは開いたまま(それより古い fzf では何も起動せずに閉じる)
43
43
  - [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI (`claude`)
44
44
  - [tsx](https://github.com/privatenumber/tsx) — TypeScript runner. **Bundled** as a package dependency; a global `tsx` (`npm install -g tsx`) is only needed for the bare-checkout workflow. / npmインストール時は同梱されるため、グローバルtsxは checkout 直接運用時のみ必要
45
45
 
@@ -156,6 +156,46 @@ repos:
156
156
 
157
157
  Precedence: `repos[].command` > `defaults.command` > `$CCS_CMD` > `"claude"`.
158
158
 
159
+ ### Group headers and labels / グループ見出しと表示名
160
+
161
+ For a parent folder that should only hold its children — never be launched itself — mark it `launch: false`. Its children nest under it by `path` (no extra key), and `label` gives any entry a display name of its own. / 中の子を束ねるだけで、自分は起動しない親フォルダには `launch: false` を付ける。子は `path` で自動的にその下に並ぶ(追加のキーは不要)。`label` で行に出す名前を `name` と別にできる。
162
+
163
+ | Key | Type | Default | Meaning |
164
+ |---|---|---|---|
165
+ | `launch` | bool | `true` | `false` = display-only **group header**: listed with its children under it, but Enter does not launch it (`command`/`cwd` are ignored). `path` is still required — it decides which repos nest under the header. Must be a real boolean (`"false"` in quotes is an error). / `false` で表示だけの見出し行になる(Enter で起動しない。`command`/`cwd` は使われない)。`path` は必須(どの子がこの下に並ぶかを決める)。引用符付きの `"false"` はエラー |
166
+ | `label` | string | `""` (show `name`) | Name shown in the repo row and the preview title instead of `name`. `name` stays the unique key (it is still what past-session rows show), so two entries may share a label. Display-only: control characters are stripped. / 行とプレビューの見出しに `name` の代わりに出す名前。一意のキーは `name` のまま(過去セッションの行も `name` で出る)なので、同じ `label` を複数の項目に付けてもよい。表示専用で、制御文字は取り除かれる |
167
+
168
+ ```yaml
169
+ repos:
170
+ - name: Studio
171
+ path: ~/studio
172
+ launch: false # header only — Enter does not launch ~/studio
173
+ icon: "🎬"
174
+ description: Production team
175
+ - name: Studio-hq
176
+ path: ~/studio/hq
177
+ label: Studio # shown instead of "Studio-hq"
178
+ icon: "🎬"
179
+ description: Coordination hq/
180
+ - name: Studio-web
181
+ path: ~/studio/web
182
+ label: Web team
183
+ icon: "🌐"
184
+ description: Website web/
185
+ - name: Studio-app
186
+ path: ~/studio/app
187
+ label: App team
188
+ icon: "📱"
189
+ description: Mobile app/
190
+ ```
191
+
192
+ ```
193
+ 🎬 Studio │ Production team ← header: Enter does nothing
194
+ ├ 📱 App team ‹Studio› │ Mobile app/
195
+ ├ 🎬 Studio │ Coordination hq/
196
+ └ 🌐 Web team ‹Studio› │ Website web/
197
+ ```
198
+
159
199
  ### Preview pane sample / プレビュー表示例
160
200
 
161
201
  ```
@@ -192,9 +232,9 @@ Notion: ✅ abc123
192
232
 
193
233
  | Key | Action |
194
234
  |---|---|
195
- | Enter | Launch (NEW) or resume (RESUME) / 起動または再開 |
196
- | Ctrl-Y | Copy `cd … && cmd [--resume …]` to clipboard; fzf stays open with a "📋 Copied" header / クリップボードコピー後もfzfは開いたまま、ヘッダーに📋通知 |
197
- | Ctrl-I | Copy session UUID or repo path; fzf stays open / コピー後も開いたまま |
235
+ | Enter | Launch (NEW) or resume (RESUME); does nothing on a group header / 起動または再開(グループ見出しの上では何もしない) |
236
+ | Ctrl-Y | Copy `cd … && cmd [--resume …]` to clipboard; fzf stays open with a "📋 Copied" header (nothing to copy on a group header) / クリップボードコピー後もfzfは開いたまま、ヘッダーに📋通知(グループ見出しはコピーする物なし) |
237
+ | Ctrl-I | Copy session UUID or repo path (group headers too); fzf stays open / コピー後も開いたまま(グループ見出しはパス) |
198
238
  | Ctrl-R | Refresh DB and reload list / DB再走査してリロード |
199
239
  | Ctrl-D | Delete session (RESUME rows only). To remove a repo, edit `~/.config/ccs/repos.yml` directly / セッション削除はRESUME行のみ。リポ削除は`repos.yml`を直接編集 |
200
240
  | Esc / Ctrl-C | Cancel / キャンセル |
@@ -202,6 +242,15 @@ Notion: ✅ abc123
202
242
  **Visual cues / 見た目のガイド:**
203
243
  - NEW rows and RESUME rows are separated by a dim `── Past Sessions ──` divider. Selecting the divider is a no-op.
204
244
  - RESUME sessions mapped to a registered repo show that repo's icon (e.g., `🔄 📁 ClaudeCode`). Unmapped sessions (cwd that isn't in `repos.yml`) show `❓` — a visual cue that the session was either a one-off or that the repo isn't registered yet.
245
+ - A repo whose `path` sits inside another registered repo's `path` is listed right under that repo as a tree (`├` / `└`, with `│` continuing past deeper levels), with the parent's name dimmed at the end. Nothing to configure — the nesting follows from `path`. The dim `‹parent›` keeps the relation visible when a search hides the parent row, and searching for the parent's name lists its children too; it is left out when it would just repeat the row's own name. A parent marked `launch: false` is a [group header](#group-headers-and-labels--グループ見出しと表示名). / `path` が別の登録リポの `path` の中にあるリポは、そのリポのすぐ下にツリー(`├` / `└`、深い段は `│` でつなぐ)で並び、行の末尾に親の名前が薄く出る。設定は要らない(`path` から決まる)。絞り込みで親の行が隠れても `‹親›` で親子がわかり、親の名前で絞り込むと子も一緒に出る。行の名前と同じになる時は `‹親›` を出さない。`launch: false` の親はグループ見出しになる。
246
+
247
+ ```
248
+ 📁 my-company
249
+ ├ 📁 mobile-app ‹my-company›
250
+ │ └ 📁 ios ‹mobile-app›
251
+ └ 📁 web-app ‹my-company›
252
+ 📁 side-project
253
+ ```
205
254
 
206
255
  ### Workflows / 主な使い方
207
256
 
package/bin/ccs CHANGED
@@ -106,9 +106,10 @@ Usage:
106
106
  ccs [options] Unknown options pass through to launched command
107
107
 
108
108
  Controls:
109
- Enter Launch (or resume) selected entry
109
+ Enter Launch (or resume) selected entry; does nothing on a
110
+ group header (an entry with launch: false)
110
111
  Ctrl-Y Copy the effective shell command to clipboard
111
- Ctrl-I Copy session UUID (resume) or repo path (new)
112
+ Ctrl-I Copy session UUID (resume) or repo path (new/group)
112
113
  Ctrl-R Refresh DB and reload list
113
114
  Ctrl-D Delete session (resume rows only)
114
115
  Esc / Ctrl-C Cancel
@@ -230,6 +231,8 @@ if [[ -n "$CLIP_CMD" ]]; then
230
231
  # cwd/uuid are %q-quoted: the clipboard line gets pasted into a live shell,
231
232
  # so a tainted cwd must arrive as an inert literal, never as syntax (audit
232
233
  # H-1 defense-in-depth — intake sanitization in ccs-scan is the first gate).
234
+ # group:* (display-only header) and separator rows have no command, so
235
+ # nothing is built or copied for them.
233
236
  CLIP_BINDS+=(--bind "ctrl-y:execute-silent(
234
237
  kk={4}; cwd={5}; cmd={6};
235
238
  case \"\$kk\" in
@@ -237,26 +240,48 @@ if [[ -n "$CLIP_CMD" ]]; then
237
240
  new:*) printf 'cd %q && %s' \"\$cwd\" \"\$cmd\" | ${CLIP_CMD} ;;
238
241
  esac
239
242
  )+change-header(📋 Copied command to clipboard — ⏎: launch | ^Y: copy again)")
240
- # ctrl-i: copy UUID (resume) or repo path (new)
243
+ # ctrl-i: copy UUID (resume) or repo path (new, group header)
241
244
  CLIP_BINDS+=(--bind "ctrl-i:execute-silent(
242
245
  kk={4};
243
246
  case \"\$kk\" in
244
- resume:*) printf '%s' \"\${kk#resume:}\" | ${CLIP_CMD} ;;
245
- new:*) printf '%s' {5} | ${CLIP_CMD} ;;
247
+ resume:*) printf '%s' \"\${kk#resume:}\" | ${CLIP_CMD} ;;
248
+ new:*|group:*) printf '%s' {5} | ${CLIP_CMD} ;;
246
249
  esac
247
250
  )+change-header(📋 Copied ID/path to clipboard — ⏎: launch | ^I: copy again)")
248
251
  fi
249
252
 
250
- # ctrl-d: delete session (resume only). Repo (new) rows cannot be deleted
251
- # from ccs — repos.yml is the source of truth; edit it directly to remove.
253
+ # ctrl-d: delete session (resume only). Repo (new) rows and group headers
254
+ # cannot be deleted from ccs — repos.yml is the source of truth; edit it
255
+ # directly to remove.
252
256
  DELETE_BIND=(--bind "ctrl-d:execute(
253
257
  kk={4};
254
258
  case \"\$kk\" in
255
- resume:*) bash '${CCS_DELETE}' \"\${kk#resume:}\" ;;
256
- new:*) echo '[ccs] Repos can only be removed by editing ~/.config/ccs/repos.yml (press any key)'; read -r -n1 _ ;;
259
+ resume:*) bash '${CCS_DELETE}' \"\${kk#resume:}\" ;;
260
+ new:*|group:*) echo '[ccs] Repos can only be removed by editing ~/.config/ccs/repos.yml (press any key)'; read -r -n1 _ ;;
257
261
  esac
258
262
  )+reload('${TSX}' '${CCS_LIST}'${LIST_ARGS_STR})")
259
263
 
264
+ # enter / double-click on a group header (KIND=group, a repos.yml entry with
265
+ # `launch: false`) keep the picker open instead of accepting the row. That
266
+ # needs fzf's transform action (0.45+); on an older fzf the row is accepted
267
+ # as usual and the post-selection check below exits quietly, like the
268
+ # separator row. The `transform:` form takes the rest of the string as its
269
+ # argument, so the `)` of the case patterns cannot end it early. fzf runs the
270
+ # command with $SHELL -c; the test itself goes through `bash -c` so a
271
+ # non-POSIX login shell (fish) cannot break Enter on every row.
272
+ fzf_has_transform() {
273
+ local v
274
+ v=$(fzf --version 2>/dev/null) || return 1
275
+ [[ "$v" =~ ^([0-9]+)\.([0-9]+) ]] || return 1
276
+ (( 10#${BASH_REMATCH[1]} > 0 || 10#${BASH_REMATCH[2]} >= 45 ))
277
+ }
278
+ GROUP_BINDS=()
279
+ if fzf_has_transform; then
280
+ for key in enter double-click; do
281
+ GROUP_BINDS+=(--bind "${key}:transform:bash -c 'case \$1 in group:*) echo ignore ;; *) echo accept ;; esac' _ {4}")
282
+ done
283
+ fi
284
+
260
285
  # ctrl-r: force refresh
261
286
  REFRESH_BIND=(--bind "ctrl-r:execute('${TSX}' '${CCS_SCAN}' --force --quiet)+reload('${TSX}' '${CCS_LIST}'${LIST_ARGS_STR})")
262
287
 
@@ -280,6 +305,7 @@ SELECTED=$("${TSX}" "${CCS_LIST}" "${LIST_FLAGS[@]+"${LIST_FLAGS[@]}"}" | fzf \
280
305
  "${CLIP_BINDS[@]+"${CLIP_BINDS[@]}"}" \
281
306
  "${DELETE_BIND[@]}" \
282
307
  "${REFRESH_BIND[@]}" \
308
+ "${GROUP_BINDS[@]+"${GROUP_BINDS[@]}"}" \
283
309
  --height='80%' \
284
310
  --layout=reverse \
285
311
  --border \
@@ -293,7 +319,13 @@ SELECTED=$("${TSX}" "${CCS_LIST}" "${LIST_FLAGS[@]+"${LIST_FLAGS[@]}"}" | fzf \
293
319
  # 6-col TSV from ccs-list.ts: LABEL, DESCRIPTION, BADGES, KIND:KEY, CWD, COMMAND.
294
320
  # Use IFS-based read so values are never resplit by shell word-splitting
295
321
  # (H6 fix). The three `_` placeholders skip columns 1-3.
296
- IFS=$'\t' read -r _ _ _ KIND_KEY ROW_CWD ROW_CMD <<< "$SELECTED"
322
+ # Tabs are swapped for US (\x1f) first: TAB is IFS whitespace, so a plain
323
+ # `IFS=$'\t' read` merges runs of tabs, and an empty column (a repo without a
324
+ # description, a group header's empty BADGES) shifted every later field —
325
+ # KIND_KEY came back holding the cwd. \x1f is not IFS whitespace, so empty
326
+ # fields survive, and it never occurs inside a row: every column is stripped
327
+ # of (or rejected for) control characters upstream.
328
+ IFS=$'\x1f' read -r _ _ _ KIND_KEY ROW_CWD ROW_CMD <<< "${SELECTED//$'\t'/$'\x1f'}"
297
329
 
298
330
  if [[ -z "$KIND_KEY" ]]; then
299
331
  echo "❌ Could not parse selection" >&2
@@ -303,9 +335,11 @@ fi
303
335
  KIND="${KIND_KEY%%:*}"
304
336
  KEY="${KIND_KEY#*:}"
305
337
 
306
- # Separator row is a non-actionable divider (empty ROW_CWD/ROW_CMD by design).
338
+ # Separator row is a non-actionable divider (empty ROW_CWD/ROW_CMD by design),
339
+ # and a group header (`launch: false`) is display-only (empty ROW_CMD) — it
340
+ # only gets here when the enter binding above is unavailable (fzf < 0.45).
307
341
  # Short-circuit here so the downstream ROW_CMD empty-check doesn't misfire.
308
- [[ "$KIND" == "separator" ]] && exit 0
342
+ [[ "$KIND" == "separator" || "$KIND" == "group" ]] && exit 0
309
343
 
310
344
  if [[ -z "$ROW_CMD" ]]; then
311
345
  echo "❌ Could not parse selection" >&2
@@ -335,9 +369,9 @@ case "$KIND" in
335
369
  exit 1
336
370
  fi
337
371
  ;;
338
- separator)
339
- # Defensive: separator should already be short-circuited above. Kept as
340
- # a safety net in case row-parse order ever changes.
372
+ separator|group)
373
+ # Defensive: separator/group should already be short-circuited above.
374
+ # Kept as a safety net in case row-parse order ever changes.
341
375
  exit 0
342
376
  ;;
343
377
  *)
@@ -348,7 +382,7 @@ esac
348
382
 
349
383
  # Path validation (traversal prevention). Must be $HOME itself or a child
350
384
  # path ("$HOME"/*): a bare prefix match would also accept sibling dirs like
351
- # /Users/daiskeEVIL (audit L-1).
385
+ # /Users/xEVIL (audit L-1).
352
386
  if [[ "$ROW_CWD" != "$HOME" && "$ROW_CWD" != "$HOME"/* ]]; then
353
387
  echo "⚠️ cwd is outside \$HOME: ${ROW_CWD}" >&2
354
388
  echo "Launching in current directory instead." >&2
package/bin/ccs-config.ts CHANGED
@@ -34,6 +34,10 @@ export interface RepoEntry {
34
34
  disabled: boolean;
35
35
  scan: boolean;
36
36
  icon: string;
37
+ /** false = display-only group header: listed, never launched. */
38
+ launch: boolean;
39
+ /** Display name for the NEW row / preview ("" = show `name`). */
40
+ label: string;
37
41
  custom: Record<string, unknown>;
38
42
  configHash: string;
39
43
  }
@@ -74,6 +78,8 @@ const KNOWN_REPO_KEYS = new Set([
74
78
  "disabled",
75
79
  "scan",
76
80
  "icon",
81
+ "launch",
82
+ "label",
77
83
  "custom",
78
84
  ]);
79
85
 
@@ -304,6 +310,27 @@ function resolveRepoEntry(
304
310
  const disabled =
305
311
  raw.disabled === undefined ? false : Boolean(raw.disabled);
306
312
 
313
+ // launch — `launch: false` makes the entry a display-only group header:
314
+ // listed, with the repos inside its path nested under it, but never
315
+ // launched. Strictly typed, unlike disabled/scan: Boolean("false") is true,
316
+ // so a quoted value would silently turn the header back into a launcher.
317
+ let launch = true;
318
+ if (raw.launch !== undefined) {
319
+ if (typeof raw.launch !== "boolean") {
320
+ throw new ConfigError(
321
+ `${reposYmlPath}: repos[${index}].launch must be true or false (got ${JSON.stringify(raw.launch)})`,
322
+ );
323
+ }
324
+ launch = raw.launch;
325
+ }
326
+ // A header exists to hold its children, which nest by path — without one
327
+ // it would have nothing under it (command/cwd, by contrast, go unused).
328
+ if (!launch && raw.path === undefined) {
329
+ throw new ConfigError(
330
+ `${reposYmlPath}: repos[${index}].path is required when launch is false`,
331
+ );
332
+ }
333
+
307
334
  // path
308
335
  let resolvedPath = "";
309
336
  if (raw.path !== undefined) {
@@ -366,6 +393,22 @@ function resolveRepoEntry(
366
393
  const description =
367
394
  raw.description === undefined ? "" : String(raw.description);
368
395
 
396
+ // label — display name for the NEW row and the preview title; `name` stays
397
+ // the unique key (row key, DB, session rows). Display-only like
398
+ // description: it never reaches a shell line (bin/ccs skips the LABEL
399
+ // column; no fzf binding references it), so shell metachars are allowed
400
+ // ("R&D", "Dad's notes") and control chars are stripped at render time
401
+ // (repoDisplayName -> truncate). A blank `label:` (YAML null) means unset.
402
+ let label = "";
403
+ if (raw.label !== undefined && raw.label !== null) {
404
+ if (typeof raw.label !== "string") {
405
+ throw new ConfigError(
406
+ `${reposYmlPath}: repos[${index}].label must be a string`,
407
+ );
408
+ }
409
+ label = raw.label;
410
+ }
411
+
369
412
  // tags
370
413
  let tags: string[] = [];
371
414
  if (raw.tags !== undefined) {
@@ -412,6 +455,8 @@ function resolveRepoEntry(
412
455
  disabled,
413
456
  scan,
414
457
  icon,
458
+ launch,
459
+ label,
415
460
  custom,
416
461
  configHash: hashEntry(raw),
417
462
  };
package/bin/ccs-db.ts CHANGED
@@ -13,7 +13,7 @@ import Database from "better-sqlite3";
13
13
  import { mkdirSync, chmodSync, existsSync } from "node:fs";
14
14
  import { dirname } from "node:path";
15
15
 
16
- export const CURRENT_SCHEMA_VERSION = 2;
16
+ export const CURRENT_SCHEMA_VERSION = 3;
17
17
 
18
18
  export interface DbHandle {
19
19
  db: Database.Database;
@@ -37,6 +37,10 @@ export interface RepoRow {
37
37
  icon: string;
38
38
  disabled: 0 | 1;
39
39
  scan_enabled: 0 | 1;
40
+ /** schema v3: 0 = display-only group header (repos.yml `launch: false`). */
41
+ launch: 0 | 1;
42
+ /** schema v3: repos.yml `label` ("" = display `name`). */
43
+ label: string;
40
44
  custom_json: string;
41
45
  config_hash: string;
42
46
  created_at: string;
@@ -160,10 +164,23 @@ CREATE TABLE IF NOT EXISTS meta (
160
164
  );
161
165
  `;
162
166
 
167
+ // v3: repos.yml `launch` / `label` (display-only group headers and display
168
+ // names). Plain ADD COLUMN with defaults, so older ccs builds keep working
169
+ // against a v3 cache: their INSERTs name explicit columns (the new ones take
170
+ // the defaults) and their SELECTs name explicit columns (the new ones are
171
+ // never read). The other direction — a v3 reader (ccs-list/ccs-preview,
172
+ // readonly+skipMigrate) meeting a not-yet-migrated v2 cache — goes through
173
+ // reposV3Columns().
174
+ const MIGRATION_V3 = `
175
+ ALTER TABLE repos ADD COLUMN launch INTEGER NOT NULL DEFAULT 1;
176
+ ALTER TABLE repos ADD COLUMN label TEXT NOT NULL DEFAULT '';
177
+ `;
178
+
163
179
  const migrations: Migration[] = [
164
180
  { version: 1, up: MIGRATION_V1 },
165
181
  { version: 2, up: MIGRATION_V2 },
166
- // future: { version: 3, up: `...` },
182
+ { version: 3, up: MIGRATION_V3 },
183
+ // future: { version: 4, up: `...` },
167
184
  ];
168
185
 
169
186
  // ---------------------------------------------------------------------------
@@ -291,7 +308,10 @@ export function openDb(
291
308
  // Repo helpers (used by ccs-scan.ts to sync repos.yml -> DB)
292
309
  // ---------------------------------------------------------------------------
293
310
 
294
- type RepoInsert = Omit<RepoRow, "created_at" | "updated_at">;
311
+ // launch/label (schema v3) are optional and default to a plain launchable
312
+ // entry, matching the column defaults.
313
+ type RepoInsert = Omit<RepoRow, "created_at" | "updated_at" | "launch" | "label"> &
314
+ Partial<Pick<RepoRow, "launch" | "label">>;
295
315
 
296
316
  export function upsertRepo(db: Database.Database, row: RepoInsert): void {
297
317
  // Update updated_at only when config_hash changes; created_at is preserved
@@ -299,11 +319,11 @@ export function upsertRepo(db: Database.Database, row: RepoInsert): void {
299
319
  const stmt = db.prepare(`
300
320
  INSERT INTO repos (
301
321
  name, path, description, command, cwd, tags_json, icon,
302
- disabled, scan_enabled, custom_json, config_hash,
322
+ disabled, scan_enabled, launch, label, custom_json, config_hash,
303
323
  created_at, updated_at
304
324
  ) VALUES (
305
325
  @name, @path, @description, @command, @cwd, @tags_json, @icon,
306
- @disabled, @scan_enabled, @custom_json, @config_hash,
326
+ @disabled, @scan_enabled, @launch, @label, @custom_json, @config_hash,
307
327
  datetime('now'), datetime('now')
308
328
  )
309
329
  ON CONFLICT(name) DO UPDATE SET
@@ -315,6 +335,8 @@ export function upsertRepo(db: Database.Database, row: RepoInsert): void {
315
335
  icon = excluded.icon,
316
336
  disabled = excluded.disabled,
317
337
  scan_enabled = excluded.scan_enabled,
338
+ launch = excluded.launch,
339
+ label = excluded.label,
318
340
  custom_json = excluded.custom_json,
319
341
  config_hash = excluded.config_hash,
320
342
  updated_at = CASE
@@ -323,14 +345,14 @@ export function upsertRepo(db: Database.Database, row: RepoInsert): void {
323
345
  ELSE repos.updated_at
324
346
  END
325
347
  `);
326
- stmt.run(row);
348
+ stmt.run({ ...row, launch: row.launch ?? 1, label: row.label ?? "" });
327
349
  }
328
350
 
329
351
  export function getAllRepos(db: Database.Database): RepoRow[] {
330
352
  return db
331
353
  .prepare(
332
354
  `SELECT name, path, description, command, cwd, tags_json, icon,
333
- disabled, scan_enabled, custom_json, config_hash,
355
+ disabled, scan_enabled, launch, label, custom_json, config_hash,
334
356
  created_at, updated_at
335
357
  FROM repos
336
358
  ORDER BY name`,
@@ -338,6 +360,28 @@ export function getAllRepos(db: Database.Database): RepoRow[] {
338
360
  .all() as RepoRow[];
339
361
  }
340
362
 
363
+ /**
364
+ * SELECT-list fragment for the schema-v3 repos columns, `launch, label`,
365
+ * qualified with `alias` when given.
366
+ *
367
+ * ccs-list and ccs-preview open the DB readonly+skipMigrate, so they can meet
368
+ * a v2 cache that predates migration v3 (`ccs --no-scan` right after an
369
+ * upgrade, or a pre-scan that failed). There the columns are synthesized with
370
+ * their defaults, and every repo renders exactly as it did on v2: launchable,
371
+ * no label. Same degrade-don't-crash rule as getMeta() for the v2 meta table.
372
+ */
373
+ export function reposV3Columns(db: Database.Database, alias = ""): string {
374
+ const have = new Set(
375
+ (db.prepare(`PRAGMA table_info(repos)`).all() as Array<{ name: string }>)
376
+ .map((c) => c.name),
377
+ );
378
+ const q = alias ? `${alias}.` : "";
379
+ return [
380
+ have.has("launch") ? `${q}launch` : `1 AS launch`,
381
+ have.has("label") ? `${q}label` : `'' AS label`,
382
+ ].join(", ");
383
+ }
384
+
341
385
  /**
342
386
  * Delete a single session row by UUID using a bound parameter.
343
387
  *
package/bin/ccs-init.ts CHANGED
@@ -276,7 +276,12 @@ async function main(): Promise<number> {
276
276
 
277
277
  if (import.meta.url === `file://${process.argv[1]}`) {
278
278
  main().then(
279
- (code) => process.exit(code),
279
+ // exitCode, not process.exit(): bin/ccs captures discover's candidates
280
+ // with $(...), a pipe, and exiting right after the writes dropped every
281
+ // candidate past the pipe buffer (same bug as ccs-list, 2026-10-07).
282
+ (code) => {
283
+ process.exitCode = code;
284
+ },
280
285
  (err) => {
281
286
  process.stderr.write(
282
287
  `[ccs-init] fatal: ${err instanceof Error ? err.message : String(err)}\n`,
package/bin/ccs-list.ts CHANGED
@@ -5,18 +5,28 @@
5
5
  * Outputs tab-separated rows for fzf consumption:
6
6
  * <LABEL>\t<DESCRIPTION>\t<BADGES>\t<KIND>:<KEY>\t<CWD>\t<COMMAND>
7
7
  *
8
+ * KIND is `new` (launchable repo), `group` (display-only header, repos.yml
9
+ * `launch: false`), `resume` (past session) or `separator`.
10
+ *
8
11
  * Modes:
9
12
  * --current-only sessions whose cwd matches process.cwd()
10
- * --repos-only only NEW repo rows
13
+ * --repos-only only NEW repo rows (and group headers)
11
14
  * --sessions-only only RESUME session rows
12
- * (default) NEW first (alpha), then RESUME (last_activity_at DESC)
15
+ * (default) NEW first (alpha, nested repos in a tree under their
16
+ * parent), then RESUME (last_activity_at DESC)
13
17
  */
14
18
 
15
19
  import { homedir } from "node:os";
16
- import { openDb, getMeta } from "./ccs-db.ts";
20
+ import { openDb, getMeta, reposV3Columns } from "./ccs-db.ts";
17
21
  import { getPaths } from "./ccs-config.ts";
18
22
  import { SHELL_METACHARS } from "./ccs-sanitize.ts";
19
- import { truncate, formatRelativeTime, formatDateTime } from "./ccs-utils.ts";
23
+ import {
24
+ truncate,
25
+ formatRelativeTime,
26
+ formatDateTime,
27
+ repoDisplayName,
28
+ findParentRepo,
29
+ } from "./ccs-utils.ts";
20
30
 
21
31
  // ---------------------------------------------------------------------------
22
32
  // Constants
@@ -115,6 +125,8 @@ interface RepoRowFull {
115
125
  icon: string;
116
126
  disabled: number;
117
127
  scan_enabled: number;
128
+ launch: number;
129
+ label: string;
118
130
  custom_json: string;
119
131
  // stats (LEFT JOINed, may be null)
120
132
  is_git: number | null;
@@ -175,9 +187,66 @@ function buildRepoBadges(r: RepoRowFull): string {
175
187
  // invoked with --ansi so these codes render as styling.
176
188
  const LABEL_SEP = " \x1b[2m│\x1b[0m";
177
189
 
178
- function repoToRow(r: RepoRowFull): string {
179
- const label = `${r.icon || "📁"} ${r.name}${LABEL_SEP}`;
190
+ interface GroupedRepo {
191
+ repo: RepoRowFull;
192
+ parent: RepoRowFull | null;
193
+ /** Tree glyphs drawn before the icon ("" at the top level). */
194
+ prefix: string;
195
+ }
196
+
197
+ // Nest each repo under the listed repo whose path most deeply contains its
198
+ // own — the same deepest-path-wins rule buildRepoResolver uses for sessions
199
+ // (findParentRepo), so no repos.yml key is needed. Children follow their
200
+ // parent, siblings keep the incoming (SQL, name-sorted) order. A disabled
201
+ // parent is not in the list, so its children move up to the next listed
202
+ // ancestor (or the top level).
203
+ //
204
+ // Children are drawn as a tree: `├ ` for a sibling with more after it, `└ `
205
+ // for the last one, and for every ancestor level below the top a `│ ` rail
206
+ // while that ancestor still has later siblings (blank once it was the last).
207
+ function groupRepos(repos: RepoRowFull[]): GroupedRepo[] {
208
+ const children = new Map<RepoRowFull | null, RepoRowFull[]>();
209
+ for (const r of repos) {
210
+ const parent = findParentRepo(r, repos);
211
+ const siblings = children.get(parent) ?? [];
212
+ siblings.push(r);
213
+ children.set(parent, siblings);
214
+ }
215
+ const out: GroupedRepo[] = [];
216
+ const walk = (parent: RepoRowFull | null, rails: string): void => {
217
+ const kids = children.get(parent) ?? [];
218
+ kids.forEach((r, i) => {
219
+ const last = i === kids.length - 1;
220
+ const prefix = parent ? ` ${rails}${last ? "└ " : "├ "}` : "";
221
+ out.push({ repo: r, parent, prefix });
222
+ // Top-level repos draw no glyph of their own, so they add no rail.
223
+ walk(r, parent ? rails + (last ? " " : "│ ") : "");
224
+ });
225
+ };
226
+ walk(null, "");
227
+ return out;
228
+ }
229
+
230
+ function repoToRow({ repo: r, parent, prefix }: GroupedRepo): string {
231
+ // Rows show the repos.yml `label` when set (repoDisplayName); `name` stays
232
+ // the row key. The dim ‹parent› suffix keeps the relation readable when a
233
+ // fzf query hides the parent row, and lets a query for the parent's name
234
+ // match its children too. It is dropped when it would only repeat the
235
+ // row's own name (an entry labelled `Studio` under a `Studio` header).
236
+ const displayName = repoDisplayName(r);
237
+ const parentName = parent ? repoDisplayName(parent) : null;
238
+ const parentHint =
239
+ parentName && parentName !== displayName
240
+ ? ` \x1b[2m‹${parentName}›\x1b[0m`
241
+ : "";
242
+ const label = `${prefix}${r.icon || "📁"} ${displayName}${parentHint}${LABEL_SEP}`;
180
243
  const desc = truncate(r.description || "", DESC_MAX);
244
+ if (r.launch === 0) {
245
+ // Group header (`launch: false`): display-only, so no badges (the header
246
+ // is not scanned) and no COMMAND. CWD carries the path for ^I and the
247
+ // preview; bin/ccs never launches a `group:` row.
248
+ return [label, desc, "", `group:${r.name}`, r.path, ""].join("\t");
249
+ }
181
250
  const badges = buildRepoBadges(r);
182
251
  const cwd = r.cwd && r.cwd.length > 0 ? r.cwd : r.path;
183
252
  const command = r.command || "claude";
@@ -204,7 +273,9 @@ interface SessionRowFull {
204
273
  }
205
274
 
206
275
  function sessionToRow(s: SessionRowFull, defaultCommand: string): string {
207
- // Mapped sessions use the registered repo icon + name.
276
+ // Mapped sessions use the registered repo icon + name — the unique `name`,
277
+ // deliberately not the repos.yml `label`, so session rows read the same as
278
+ // before labels existed.
208
279
  // Unmapped sessions get ❓ as a visible reminder: either a one-off run
209
280
  // or a repo that hasn't been added to repos.yml yet.
210
281
  // Ternary tests s.repo_display directly so TS narrows string|null → string
@@ -258,10 +329,12 @@ function main(): number {
258
329
  const wantSessions = !flags.reposOnly;
259
330
 
260
331
  if (wantRepos) {
332
+ // launch/label come from schema v3; reposV3Columns() synthesizes the
333
+ // defaults when this readonly connection meets a v2 cache.
261
334
  const repos = db
262
335
  .prepare(
263
336
  `SELECT r.name, r.path, r.description, r.command, r.cwd, r.icon,
264
- r.disabled, r.scan_enabled, r.custom_json,
337
+ r.disabled, r.scan_enabled, ${reposV3Columns(db, "r")}, r.custom_json,
265
338
  s.is_git, s.branch, s.uncommitted_insertions,
266
339
  s.uncommitted_deletions, s.handoff_count, s.pending_count,
267
340
  s.session_last_at
@@ -271,8 +344,8 @@ function main(): number {
271
344
  ORDER BY r.name COLLATE NOCASE ASC`,
272
345
  )
273
346
  .all() as RepoRowFull[];
274
- for (const r of repos) {
275
- lines.push(repoToRow(r));
347
+ for (const g of groupRepos(repos)) {
348
+ lines.push(repoToRow(g));
276
349
  }
277
350
  }
278
351
 
@@ -359,5 +432,18 @@ function main(): number {
359
432
  }
360
433
 
361
434
  if (import.meta.url === `file://${process.argv[1]}`) {
362
- process.exit(main());
435
+ // exitCode, not process.exit(): stdout is a pipe into fzf, and pipe writes
436
+ // are async on macOS. Exiting right after the write dropped everything past
437
+ // the pipe buffer — as little as 512 bytes, so the list ended after 3 repos
438
+ // (2026-10-07). Returning lets Node flush the whole list first.
439
+ //
440
+ // The flip side: fzf may exit (pick / Esc) before reading every row, and
441
+ // the pending write then fails with EPIPE. Those rows have no reader, so
442
+ // end quietly with main()'s status — an unhandled EPIPE would print a stack
443
+ // trace and exit 1, which pipefail in bin/ccs turns into a dropped launch.
444
+ process.stdout.on("error", (err: NodeJS.ErrnoException) => {
445
+ if (err.code === "EPIPE") process.exit(process.exitCode ?? 0);
446
+ throw err;
447
+ });
448
+ process.exitCode = main();
363
449
  }
@@ -4,19 +4,23 @@
4
4
  *
5
5
  * Invoked by fzf as: tsx bin/ccs-preview.ts <KIND:KEY> <CWD>
6
6
  * KIND=new → render repo preview (DB-backed state summary)
7
+ * KIND=group → render group-header preview (path + nested entries)
7
8
  * KIND=resume → delegate to renderSessionPreview (conversation history)
8
9
  *
9
10
  * All errors print to stdout (fzf reads stdout for preview) and exit 0.
10
11
  */
11
12
 
12
13
  import { existsSync } from "node:fs";
14
+ import { relative } from "node:path";
13
15
  import { getPaths } from "./ccs-config.ts";
14
- import { openDb, type DbHandle } from "./ccs-db.ts";
16
+ import { openDb, reposV3Columns, type DbHandle } from "./ccs-db.ts";
15
17
  import { renderSessionPreview } from "./ccs-preview-session.ts";
16
18
  import {
17
19
  formatRelativeTime,
18
20
  formatDateTime,
19
21
  truncate,
22
+ repoDisplayName,
23
+ findParentRepo,
20
24
  } from "./ccs-utils.ts";
21
25
 
22
26
  // ---------------------------------------------------------------------------
@@ -60,10 +64,20 @@ interface RepoView {
60
64
  path: string;
61
65
  description: string;
62
66
  icon: string;
67
+ label: string;
63
68
  tags_json: string;
64
69
  custom_json: string;
65
70
  }
66
71
 
72
+ interface GroupMemberView {
73
+ name: string;
74
+ path: string;
75
+ description: string;
76
+ icon: string;
77
+ launch: number;
78
+ label: string;
79
+ }
80
+
67
81
  interface StatsView {
68
82
  is_git: number;
69
83
  branch: string | null;
@@ -167,7 +181,8 @@ function renderRepoPreview(name: string, cwd: string): void {
167
181
 
168
182
  const repo = db
169
183
  .prepare(
170
- `SELECT name, path, description, icon, tags_json, custom_json
184
+ `SELECT name, path, description, icon, tags_json, custom_json,
185
+ ${reposV3Columns(db)}
171
186
  FROM repos WHERE name = ?`,
172
187
  )
173
188
  .get(name) as RepoView | undefined;
@@ -191,7 +206,12 @@ function renderRepoPreview(name: string, cwd: string): void {
191
206
  .get(name) as StatsView | undefined;
192
207
 
193
208
  // --- Header ---
194
- console.log(`${hdr(`${repo.icon || "📁"} ${repo.name}`)}`);
209
+ // Titled with the repos.yml `label` like the list row; when one is set,
210
+ // the dim `name` after it ties the entry to its session rows, which keep
211
+ // showing the name.
212
+ const displayName = repoDisplayName(repo);
213
+ const nameHint = displayName !== repo.name ? ` ${dim(`(${repo.name})`)}` : "";
214
+ console.log(`${hdr(`${repo.icon || "📁"} ${displayName}`)}${nameHint}`);
195
215
  console.log(dim(repo.path));
196
216
  if (repo.description) console.log(repo.description);
197
217
  console.log("");
@@ -322,6 +342,84 @@ function renderRepoPreview(name: string, cwd: string): void {
322
342
  }
323
343
  }
324
344
 
345
+ // ---------------------------------------------------------------------------
346
+ // Group-header preview (repos.yml `launch: false`)
347
+ // ---------------------------------------------------------------------------
348
+
349
+ function renderGroupPreview(name: string, cwd: string): void {
350
+ const paths = getPaths();
351
+ if (!existsSync(paths.stateDb)) {
352
+ console.log(`${hdr("📁 " + name)}`);
353
+ console.log(dim(cwd || ""));
354
+ console.log("");
355
+ console.log("No state cache yet — run `ccs --refresh` to scan.");
356
+ return;
357
+ }
358
+
359
+ let handle: DbHandle | null = null;
360
+ try {
361
+ handle = openDb(paths.stateDb, { readonly: true, skipMigrate: true });
362
+ const db = handle.db;
363
+
364
+ // The listed (enabled) repos, in list order: children are resolved with
365
+ // the same findParentRepo rule over the same population as ccs-list, so
366
+ // the entries here are exactly the rows drawn under the header.
367
+ const listed = db
368
+ .prepare(
369
+ `SELECT name, path, description, icon, ${reposV3Columns(db)}
370
+ FROM repos WHERE disabled = 0
371
+ ORDER BY name COLLATE NOCASE ASC`,
372
+ )
373
+ .all() as GroupMemberView[];
374
+ const group = listed.find((r) => r.name === name);
375
+
376
+ if (!group) {
377
+ console.log(`${hdr("📁 " + name)}`);
378
+ console.log(dim(cwd || ""));
379
+ console.log("");
380
+ console.log(`Group "${name}" not found in cache.`);
381
+ return;
382
+ }
383
+
384
+ console.log(`${hdr(`${group.icon || "📁"} ${repoDisplayName(group)}`)}`);
385
+ console.log(dim(group.path));
386
+ if (group.description) console.log(truncate(group.description, 200));
387
+ console.log("");
388
+
389
+ console.log(divider("Group"));
390
+ console.log("Group header (launch: false) — not launchable.");
391
+ console.log("Pick one of the entries below to start a session.");
392
+ console.log("");
393
+
394
+ const entries = listed.filter(
395
+ (r) => r !== group && findParentRepo(r, listed) === group,
396
+ );
397
+ console.log(divider(`Entries (${entries.length})`));
398
+ if (entries.length === 0) {
399
+ console.log("No repos inside this path yet.");
400
+ return;
401
+ }
402
+ const names = entries.map((r) => repoDisplayName(r));
403
+ const width = Math.max(...names.map((n) => n.length));
404
+ entries.forEach((r, i) => {
405
+ const rel = truncate(relative(group.path, r.path), 60) + "/";
406
+ const tag = r.launch === 0 ? ` ${dim("(group)")}` : "";
407
+ console.log(` ${r.icon || "📁"} ${names[i].padEnd(width)} ${dim(rel)}${tag}`);
408
+ });
409
+ } catch (err) {
410
+ const msg = err instanceof Error ? err.message : String(err);
411
+ console.log(`${hdr("📁 " + name)}`);
412
+ console.log("");
413
+ console.log(`Preview error: ${msg}`);
414
+ } finally {
415
+ try {
416
+ handle?.close();
417
+ } catch {
418
+ // ignore
419
+ }
420
+ }
421
+ }
422
+
325
423
  // ---------------------------------------------------------------------------
326
424
  // Main dispatcher
327
425
  // ---------------------------------------------------------------------------
@@ -348,6 +446,8 @@ async function main(): Promise<void> {
348
446
  await renderSessionPreview(key);
349
447
  } else if (kind === "new") {
350
448
  renderRepoPreview(key, cwd);
449
+ } else if (kind === "group") {
450
+ renderGroupPreview(key, cwd);
351
451
  } else if (kind === "separator") {
352
452
  // Divider row — intentionally blank preview.
353
453
  return;
package/bin/ccs-scan.ts CHANGED
@@ -144,6 +144,8 @@ function syncReposToDb(
144
144
  icon: r.icon,
145
145
  disabled: r.disabled ? 1 : 0,
146
146
  scan_enabled: r.scan ? 1 : 0,
147
+ launch: r.launch ? 1 : 0,
148
+ label: r.label,
147
149
  custom_json: JSON.stringify(r.custom),
148
150
  config_hash: r.configHash,
149
151
  });
@@ -520,8 +522,11 @@ async function scanAllRepos(
520
522
  preserveSessionAgg: boolean;
521
523
  },
522
524
  ): Promise<ReposScanSummary> {
525
+ // Group headers (`launch: false`) are skipped too: their list row carries
526
+ // no badges and their preview shows only the group, so git/workspace stats
527
+ // for the header's own path would never be displayed.
523
528
  const allRepos = getAllRepos(db).filter(
524
- (r) => r.disabled === 0 && r.scan_enabled === 1,
529
+ (r) => r.disabled === 0 && r.scan_enabled === 1 && r.launch === 1,
525
530
  );
526
531
 
527
532
  // TTL check: read existing scanned_at per repo
package/bin/ccs-utils.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * ccs-utils.ts — Shared helpers for ccs scan/list/preview modules.
3
3
  *
4
- * Single source of truth for display-text truncation, relative-time
5
- * bucketing, JSONL message-content extraction, and cross-module constants
6
- * (review A-1/A-2/A-3/K-8). Before this module existed, each renderer kept
7
- * its own copy of these helpers with "keep in sync" comments — and they
8
- * drifted. Behavioral differences must now be expressed as explicit options,
9
- * never as parallel implementations.
4
+ * Single source of truth for display-text truncation, repo display names and
5
+ * nesting, relative-time bucketing, JSONL message-content extraction, and
6
+ * cross-module constants (review A-1/A-2/A-3/K-8). Before this module
7
+ * existed, each renderer kept its own copy of these helpers with "keep in
8
+ * sync" comments — and they drifted. Behavioral differences must now be
9
+ * expressed as explicit options, never as parallel implementations.
10
10
  */
11
11
 
12
12
  import { stripControlChars } from "./ccs-sanitize.ts";
@@ -46,6 +46,52 @@ export function truncate(s: string, max: number): string {
46
46
  return flat.length <= max ? flat : flat.slice(0, max - 1) + "…";
47
47
  }
48
48
 
49
+ // ---------------------------------------------------------------------------
50
+ // Repo display name & nesting
51
+ // ---------------------------------------------------------------------------
52
+
53
+ /** Display cap for repos.yml `label` (it is a name, not a sentence). */
54
+ export const LABEL_MAX = 40;
55
+
56
+ /**
57
+ * Name shown for a repo in the NEW row and the repo/group preview: the
58
+ * repos.yml `label` when set, else the unique `name`. The label is
59
+ * display-only (never part of a row key or a shell line), so like
60
+ * `description` it is not rejected at load time — control chars are stripped
61
+ * here by truncate(). A label that strips down to nothing falls back to name.
62
+ */
63
+ export function repoDisplayName(r: { name: string; label?: string | null }): string {
64
+ return truncate(r.label ?? "", LABEL_MAX) || r.name;
65
+ }
66
+
67
+ /**
68
+ * True when `child` sits strictly inside `parent` (path-segment aware). An
69
+ * empty parent (a repos.yml entry without `path`) contains nothing — as a
70
+ * bare prefix it would have claimed every absolute path.
71
+ */
72
+ export function isUnderPath(child: string, parent: string): boolean {
73
+ if (!parent) return false;
74
+ return child.startsWith(parent.endsWith("/") ? parent : parent + "/");
75
+ }
76
+
77
+ /**
78
+ * The repo in `repos` whose path most deeply contains `repo`'s own — the
79
+ * deepest-path-wins rule buildRepoResolver uses for sessions. Shared by the
80
+ * list tree (ccs-list) and the group preview (ccs-preview) so the two can
81
+ * never disagree on whose child a repo is. Null for a top-level repo.
82
+ */
83
+ export function findParentRepo<T extends { path: string }>(
84
+ repo: T,
85
+ repos: T[],
86
+ ): T | null {
87
+ let parent: T | null = null;
88
+ for (const p of repos) {
89
+ if (p === repo || !isUnderPath(repo.path, p.path)) continue;
90
+ if (!parent || p.path.length > parent.path.length) parent = p;
91
+ }
92
+ return parent;
93
+ }
94
+
49
95
  // ---------------------------------------------------------------------------
50
96
  // Time formatting
51
97
  // ---------------------------------------------------------------------------
@@ -24,7 +24,7 @@ repos: # リポジトリ定義配列(必須、1件以
24
24
 
25
25
  | フィールド | 型 | 必須 | デフォルト | 説明 |
26
26
  |---|---|:---:|---|---|
27
- | `name` | string | ✅ | — | 表示名(fzf行に出る、日本語可) |
27
+ | `name` | string | ✅ | — | 一意のキー兼、既定の表示名(fzf行に出る、日本語可)。`label` があれば新規起動行とプレビューは `label` を出す。過去セッションの行は常に `name` |
28
28
  | `path` | string | ✅* | — | リポジトリのフルパス。`~` 展開あり |
29
29
  | `description` | string | — | `""` | プレビュー補助情報 |
30
30
  | `command` | string | — | `defaults.command` or `claude` | 起動コマンド。特殊な場合は丸ごと上書き |
@@ -33,9 +33,11 @@ repos: # リポジトリ定義配列(必須、1件以
33
33
  | `disabled` | bool | — | `false` | true ならリストから除外 |
34
34
  | `scan` | bool | — | `true` | false なら状態走査をスキップ(非git含む) |
35
35
  | `icon` | string | — | `📁` | 表示アイコン(絵文字1文字推奨) |
36
+ | `launch` | bool | — | `true` | false なら表示だけのグループ見出し(行の種類 `group:<name>`)。Enter で起動しない・バッジなし・状態走査しない。`command`/`cwd` は使われない。`path` の中のリポがこの下に並ぶ。真偽値のみ(`"false"` などの文字列はエラー) |
37
+ | `label` | string | — | `""` | 新規起動行・`‹親›` の表示・プレビュー見出しに `name` の代わりに出す名前。表示専用(シェルメタ文字は可、制御文字は表示時に除去)。空・空白だけなら `name` |
36
38
  | `custom` | object | — | `{}` | 外部システム連携情報。任意のキー/値ペア(後述) |
37
39
 
38
- \* `path` は `command` に `cwd:` が含まれる特殊ケースを除き必須。
40
+ \* `path` は `command` に `cwd:` が含まれる特殊ケースを除き必須。`launch: false` の項目では常に必須(無いとエラー)。
39
41
 
40
42
  ### `custom` フィールド(外部システム連携)
41
43
 
@@ -107,6 +109,9 @@ repos:
107
109
  | `defaults.command`(CCS_CMD/CCR_CMD 由来含む)もロード時に同検証(レビューA-6) | `~/.config/ccs/repos.yml: defaults.command (resolved from defaults.command / CCS_CMD / CCR_CMD) contains shell metacharacter(s): "..."` |
108
110
  | `custom` のJSONサイズ 64KB 上限 | `~/.config/ccs/repos.yml: repos[0].custom exceeds 64KB JSON size limit` |
109
111
  | `disabled: true` の場合 scan/command 無視 | — |
112
+ | `launch` は真偽値のみ(`Boolean()` で丸めない: `"false"` が true になるのを防ぐ) | `~/.config/ccs/repos.yml: repos[0].launch must be true or false (got "false")` |
113
+ | `launch: false` は `path` 必須 | `~/.config/ccs/repos.yml: repos[0].path is required when launch is false` |
114
+ | `label` は文字列のみ(空の `label:` = YAML の null は未指定扱い) | `~/.config/ccs/repos.yml: repos[0].label must be a string` |
110
115
 
111
116
  ## 例1: シンプル
112
117
 
@@ -59,8 +59,14 @@ CREATE TABLE repos (
59
59
 
60
60
  CREATE INDEX idx_repos_path ON repos(path);
61
61
  CREATE INDEX idx_repos_disabled ON repos(disabled);
62
+
63
+ -- v3: repos.yml の launch / label(ADD COLUMN なので列は表の末尾に付く)
64
+ ALTER TABLE repos ADD COLUMN launch INTEGER NOT NULL DEFAULT 1; -- 0 = 表示だけのグループ見出し
65
+ ALTER TABLE repos ADD COLUMN label TEXT NOT NULL DEFAULT ''; -- 表示名(空なら name)
62
66
  ```
63
67
 
68
+ v3 の列の互換: 古い版(0.2.3 以前)は repos の INSERT/SELECT で列名を明示しているので、v3 のキャッシュでも動く(新しい列は INSERT で既定値になり、SELECT では読まれない)。逆に readonly・マイグレーションなしで開く ccs-list / ccs-preview が v2 のキャッシュに当たった時は、`reposV3Columns()` が `1 AS launch, '' AS label` で既定値を補う(`getMeta` と同じ「落ちずに既定値へ」の扱い)。
69
+
64
70
  ### repo_stats
65
71
 
66
72
  ```sql
@@ -168,6 +174,7 @@ CREATE TABLE meta (
168
174
  |---------|---------|---------|
169
175
  | 1 | v0.2.0 init | 初期テーブル群(repos, repo_stats, sessions, handoff_files, pending_items) |
170
176
  | 2 | 2026-06-12 レビュークリーンアップ(v0.2.1) | meta テーブル追加(キー/値ストア、defaults_command 保存用) |
177
+ | 3 | 2026-10-21 グループ見出しと表示名(未リリース) | repos に `launch`(既定 1)・`label`(既定 '')を追加 |
171
178
 
172
179
  ## 走査フロー
173
180
 
@@ -202,7 +209,8 @@ ccs 起動
202
209
  const migrations = [
203
210
  { version: 1, up: `CREATE TABLE schema_version (...); ...初期DDL...` },
204
211
  { version: 2, up: `CREATE TABLE meta (...);` },
205
- // 以降 version: 3, 4, ... を追加
212
+ { version: 3, up: `ALTER TABLE repos ADD COLUMN launch ...; ALTER TABLE repos ADD COLUMN label ...;` },
213
+ // 以降 version: 4, 5, ... を追加
206
214
  ];
207
215
 
208
216
  function migrate(db: Database) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-station",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Claude Code Station — fzf-powered launcher and session picker for Claude Code with multi-repo support and SQLite-backed state cache",
5
5
  "type": "module",
6
6
  "bin": {