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 +18 -1
- package/README.md +53 -4
- package/bin/ccs +50 -16
- package/bin/ccs-config.ts +45 -0
- package/bin/ccs-db.ts +51 -7
- package/bin/ccs-init.ts +6 -1
- package/bin/ccs-list.ts +97 -11
- package/bin/ccs-preview.ts +103 -3
- package/bin/ccs-scan.ts +6 -1
- package/bin/ccs-utils.ts +52 -6
- package/docs/design/repos-yml-schema.md +7 -2
- package/docs/design/sqlite-schema.md +9 -1
- package/package.json +1 -1
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
|
-
## [
|
|
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:*)
|
|
245
|
-
new:*)
|
|
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
|
|
251
|
-
# from ccs — repos.yml is the source of truth; edit it
|
|
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:*)
|
|
256
|
-
new:*)
|
|
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
|
-
|
|
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.
|
|
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/
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
179
|
-
|
|
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
|
|
275
|
-
lines.push(repoToRow(
|
|
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(
|
|
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
|
}
|
package/bin/ccs-preview.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
5
|
-
* bucketing, JSONL message-content extraction, and
|
|
6
|
-
* (review A-1/A-2/A-3/K-8). Before this module
|
|
7
|
-
* its own copy of these helpers with "keep in
|
|
8
|
-
* drifted. Behavioral differences must now be
|
|
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 | ✅ | — |
|
|
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
|
-
|
|
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