@indigo-gr/claude-code-station 0.3.3 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,40 @@ 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
+ ## [0.4.1] — 2026-11-08
8
+
9
+ 0.4.0 was tagged in the work repository but never published; its changes ship in 0.4.1.
10
+
11
+ ### Added
12
+ - **Version and copyright on screen** — both looks show `ccs v<version>` and `Copyright © INDIGO GROUP LLC.` in the frame's top border (fzf's border label: after the title in the simple look, new in the classic look). The label costs no list row and survives `change-header`. On a narrow terminal the label shortens instead of running over the frame's corners: the title goes first (simple), then the word `Copyright` (`© INDIGO GROUP LLC.`), and the version stays. `ccs --help` prints the copyright too.
13
+ - **Update notice** — when npm's latest is newer than the running version, the classic look prints one line with the upgrade command above the picker. The simple look says so in the list's frame and prints the command after the picker closes. No notice for a prerelease on either side, or when nothing is known about the latest version.
14
+ - **What goes over the network**: at most once a day (an hour after a failed attempt), a background process sends one HTTPS GET to `https://registry.npmjs.org/-/package/%40indigo-gr%2Fclaude-code-station/dist-tags`. It carries no ccs version, repo, session or machine details. See "Update check" in the README.
15
+ - **It cannot slow or stop ccs**: the launcher only reads the last answer from `~/.cache/ccs/update-check` and never waits for the request, which has a 3-second limit. Offline, ccs behaves as before.
16
+ - **Turn it off** with `CCS_NO_UPDATE_CHECK=1` (no request, the cache is not read). `CCS_UPDATE_REGISTRY` points the check at another registry.
17
+ - `author` in `package.json`.
18
+
19
+ ### Changed
20
+ - The copyright holder is now INDIGO GROUP LLC. (`LICENSE`, `package.json`, README, on screen). The license stays MIT.
21
+ - `tput` is no longer required.
22
+ - **Works on npm 12 as is, and needs Node 22** — npm 12 blocks dependency install scripts by default, also for a global install, so with `better-sqlite3` 11 the plain `npm install -g` reported success and left a `ccs` that could not start (this applies to earlier versions of ccs). `better-sqlite3` 13 ships prebuilt native modules, so no script is needed. It requires Node 22 or newer, and so does ccs now (`engines`, `install.sh`, README). On a platform with no prebuilt module, `ccs` says so. An existing `state.db` is reused.
23
+
24
+ ### Fixed
25
+ - **Terminal width** — `tput cols 2>/dev/null` inside `$()` answered 80 at any width, so the classic look's short guide line for narrow terminals never appeared. ccs now asks the terminal with `stty size` (an exported `COLUMNS` wins), so terminals under 80 columns get that short guide line.
26
+ - **Copy install** — without a `package.json` next to `bin/` (`install.sh`, the README's manual install), `ccs` exited with code 2 and no message before doing anything. It now runs as `ccs v0.0.0-dev`, and a copy install gets no update check or notice.
27
+ - **Another package's version** — `ccs` took its version from whatever `package.json` sat above `bin/`, so a copy install could show another project's version. It now reads the file only when its `name` is this package; otherwise, or when the file cannot be read or parsed, it runs as `ccs v0.0.0-dev`.
28
+ - **Simple look**: the no-match message stayed only some of the time, because the guide line overwrote it; it now stays until the search matches again.
29
+ - A scan that fails on opening the cache no longer leaves its lock behind (later scans said "another scan is in progress" for 5 minutes and hid the real error).
30
+ - The install instructions add `~/.claude/scripts` to the end of `PATH`, so it cannot shadow system commands. If you followed the earlier instruction, you may want to change that line in your shell profile.
31
+
32
+ ### Security
33
+ - **A repos.yml field can no longer choose the command** — a tab, newline or unit separator in `icon` moved the row's columns, so picking that repo (or one of its sessions) ran what the field named instead of the repo's command. Icons are now cleaned wherever they are drawn, `ccs-list` drops a row of the wrong shape, and `bin/ccs` refuses a command with shell metacharacters or control characters.
34
+ - **Wildcards in `command`** are passed as written (`claude *` gets the argument `*`). Splitting into words is unchanged.
35
+ - **A session's cwd** with a `..` segment is no longer taken from the session file, so it can neither open outside `$HOME` nor reach the clipboard through `^Y`.
36
+ - **The preview hides secrets before cutting text**, so a key across the cut no longer shows its beginning (messages, `cwd`, `gitBranch`, `version`).
37
+ - **The scan stays inside a repo**: a file in `handoff/` or `pendings/`, or the folder itself, is not read when it is a symlink out of the repo. A symlink that stays inside the repo still works.
38
+ - **The scan runs nothing a repo configures**: git runs with `core.fsmonitor` off and no hooks, and a scan no longer rewrites a repo's index.
39
+ - **Display text** loses C1 controls and text-direction marks too; `description`, `tags` and `icon` are now cleaned in the classic preview as well. Emoji and Japanese text are unchanged.
40
+
7
41
  ## [0.3.3] — 2026-11-06
8
42
 
9
43
  ### Changed
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Daiske Watanabe
3
+ Copyright (c) 2026 INDIGO GROUP LLC.
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Claude Code Station — ccs
2
2
 
3
3
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
4
- [![Node 20+](https://img.shields.io/badge/node-%3E%3D20-brightgreen.svg)](https://nodejs.org/)
4
+ [![Node 22+](https://img.shields.io/badge/node-%3E%3D22-brightgreen.svg)](https://nodejs.org/)
5
5
 
6
6
  A fast, fzf-powered launcher and session picker for [Claude Code](https://docs.anthropic.com/en/docs/claude-code). Pick a project to launch fresh **or** resume any past session — all from a single fuzzy finder, across every repo you own.
7
7
 
@@ -38,7 +38,7 @@ A fast, fzf-powered launcher and session picker for [Claude Code](https://docs.a
38
38
 
39
39
  ### Prerequisites / 前提条件
40
40
 
41
- - [Node.js](https://nodejs.org/) **20+**
41
+ - [Node.js](https://nodejs.org/) **22+**. `better-sqlite3` ships prebuilt modules for macOS and Linux (glibc, musl) on x64 and arm64; anywhere else it has to be built from source (not tested) / それ以外の環境はソースから作る必要がある(未確認)
42
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
  - The friendlier look (`ui: simple`, below) needs **fzf ≥ 0.61.0** (`--ghost`; the frame and labels need 0.58+). On an older fzf ccs opens the classic look and says so in one line / やさしい見た目(`ui: simple`)は fzf 0.61.0 以上。古い fzf ではいつもの見た目で開き、1 行で知らせる
44
44
  - [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI (`claude`)
@@ -62,7 +62,7 @@ npm install -g @indigo-gr/claude-code-station
62
62
  #### From GitHub (latest unreleased) / GitHub直接(未リリース版を使いたい時)
63
63
 
64
64
  ```bash
65
- npm install -g github:indigo-gr/claude-code-station
65
+ npm install -g --allow-git=root github:indigo-gr/claude-code-station
66
66
  ```
67
67
 
68
68
  ### Copy install (checkout) / コピーインストール
@@ -84,7 +84,7 @@ cp bin/ccs bin/ccs-*.ts bin/ccs-*.sh ~/.claude/scripts/
84
84
  chmod +x ~/.claude/scripts/ccs ~/.claude/scripts/ccs-delete.sh
85
85
 
86
86
  # Add to ~/.zshrc or ~/.bashrc
87
- export PATH="$HOME/.claude/scripts:$PATH"
87
+ export PATH="$PATH:$HOME/.claude/scripts"
88
88
  ```
89
89
 
90
90
  ---
@@ -331,6 +331,18 @@ Schema details: [`docs/design/sqlite-schema.md`](docs/design/sqlite-schema.md).
331
331
 
332
332
  ---
333
333
 
334
+ ## Update check / 更新の確認
335
+
336
+ ccs tells you when npm has a newer version. The classic look prints one line with the upgrade command above the picker. The simple look says so in the list's frame and prints the command after the picker closes. / npm に新しいバージョンがあると知らせる。classic は一覧の上に上げ方を 1 行、simple は枠に知らせを出し、閉じた後に上げ方を出す。
337
+
338
+ - **What is sent, and where** — one HTTPS `GET https://registry.npmjs.org/-/package/%40indigo-gr%2Fclaude-code-station/dist-tags`, at most once a day (an hour after a failed attempt). It carries no ccs version, repo, session or machine details; the registry sees only the request itself, your IP address and Node's default User-Agent. / 送るのは公開情報を読む GET が 1 日に 1 回まで(失敗の後は 1 時間後)。ccs のバージョン・リポジトリ・会話・マシンの情報は送らない。npm が受け取るのはリクエストそのもの・IP アドレス・Node の既定の User-Agent だけ
339
+ - **Never in the way** — the check runs in the background with a 3-second limit and its answer is used on the next launch; startup only reads `~/.cache/ccs/update-check` (or `$XDG_CACHE_HOME/ccs/update-check`). Offline, or when the answer is unusable, ccs behaves as before. HTTP proxy settings are not used, so behind a proxy the check simply fails quietly. / 確認は裏で行い、結果は次の起動で使う。起動を遅らせず、オフラインでも止まらない。プロキシの設定は使わないので、プロキシの内側では確認が静かに失敗する
340
+ - **When there is no notice** — when you are on the latest version or newer, when npm's latest is a prerelease, and when your own version is a prerelease or a copy install (`0.0.0-dev`). A failed check shows no notice of its own, but a version found by an earlier check is still reported. / 最新かそれより新しい時・npm の latest がプレリリースの時・手元がプレリリースかコピーインストールの時は知らせない。確認の失敗だけでは知らせないが、前回までに見つけたバージョンは知らせる
341
+ - **Turn it off** — set `CCS_NO_UPDATE_CHECK=1` (any non-empty value): no request, and the cache is not read. / `CCS_NO_UPDATE_CHECK=1`(空でない値)で止まる。通信せず、キャッシュも読まない
342
+ - **Another registry** — `CCS_UPDATE_REGISTRY=https://registry.example.com` asks that server instead. / `CCS_UPDATE_REGISTRY` で問い合わせ先を替えられる
343
+
344
+ ---
345
+
334
346
  ## Migration from ccr v0.1.x / ccr v0.1.x からの移行
335
347
 
336
348
  Existing sessions are stored under `~/.claude/projects/*/*.jsonl` — **nothing to migrate manually**. ccs auto-discovers them on first scan.
@@ -403,4 +415,10 @@ Issues and PRs welcome. See [`REVIEW.md`](REVIEW.md) for review standards and [`
403
415
 
404
416
  ## License / ライセンス
405
417
 
406
- MIT. See [LICENSE](LICENSE).
418
+ MIT. Copyright © INDIGO GROUP LLC. See [LICENSE](LICENSE). / ライセンスは MIT、著作権者は INDIGO GROUP LLC.
419
+
420
+ The version on screen leads to everything about that release / 画面のバージョンから、そのリリースの中身をたどれる:
421
+
422
+ - **Source** — git tag `vX.Y.Z` in [indigo-gr/claude-code-station](https://github.com/indigo-gr/claude-code-station/tags) / ソースは同じ番号の tag
423
+ - **Changes** — the `[X.Y.Z]` section of [CHANGELOG.md](CHANGELOG.md) / 変更点は CHANGELOG の同じ番号の節
424
+ - **Terms** — [LICENSE](LICENSE) at that tag / 配布条件はその tag の LICENSE
package/bin/ccs CHANGED
@@ -18,8 +18,24 @@ SCRIPT_DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
18
18
  # Single source of truth: read version from package.json next to bin/.
19
19
  # Falls back to "0.0.0-dev" for copy-install layouts (install.sh drops bin/
20
20
  # scripts into ~/.claude/scripts/ without package.json) or if the read fails.
21
- VERSION=$(awk -F'"' '/"version":/{print $4; exit}' "${SCRIPT_DIR}/../package.json" 2>/dev/null)
22
- VERSION="${VERSION:-0.0.0-dev}"
21
+ # Only this package's own file counts: install.sh copies into ~/.claude/scripts/,
22
+ # where ../package.json can belong to something else entirely, and its version
23
+ # must not reach the screen or the update check. A bash match, no extra process.
24
+ CCS_PACKAGE='@indigo-gr/claude-code-station'
25
+ CCS_INSTALL_CMD="npm install -g ${CCS_PACKAGE}"
26
+ VERSION="0.0.0-dev"
27
+ read_package_version() {
28
+ local file="${SCRIPT_DIR}/../package.json" json=""
29
+ local name_re='"name"[[:space:]]*:[[:space:]]*"([^"]*)"'
30
+ local version_re='"version"[[:space:]]*:[[:space:]]*"([0-9A-Za-z.+-]+)"'
31
+ [[ -f "$file" && -r "$file" ]] || return 0
32
+ json=$(<"$file") || return 0
33
+ [[ "$json" =~ $name_re ]] || return 0
34
+ [[ "${BASH_REMATCH[1]}" == "$CCS_PACKAGE" ]] || return 0
35
+ [[ "$json" =~ $version_re ]] || return 0
36
+ VERSION="${BASH_REMATCH[1]}"
37
+ }
38
+ read_package_version 2>/dev/null || true
23
39
 
24
40
  # Prefer a package-local tsx over the PATH one: the npm install brings tsx
25
41
  # as a dependency (../node_modules from the package's bin/), and the copy
@@ -38,6 +54,8 @@ CCS_SCAN="${SCRIPT_DIR}/ccs-scan.ts"
38
54
  CCS_PREVIEW="${SCRIPT_DIR}/ccs-preview.ts"
39
55
  CCS_DELETE="${SCRIPT_DIR}/ccs-delete.sh"
40
56
  CCS_INIT="${SCRIPT_DIR}/ccs-init.ts"
57
+ CCS_UPDATE="${SCRIPT_DIR}/ccs-update-check.ts"
58
+ COPYRIGHT='Copyright © INDIGO GROUP LLC.'
41
59
 
42
60
  # ── CCR_CMD -> CCS_CMD migration shim ────────────────────────────────────────
43
61
  if [[ -n "${CCR_CMD:-}" && -z "${CCS_CMD:-}" ]]; then
@@ -72,7 +90,6 @@ check_deps() {
72
90
  fi
73
91
  command -v node &>/dev/null || missing+=("node")
74
92
  command -v claude &>/dev/null || missing+=("claude (Claude Code CLI)")
75
- command -v tput &>/dev/null || missing+=("tput")
76
93
 
77
94
  if [[ ${#missing[@]} -gt 0 ]]; then
78
95
  echo "❌ Missing dependencies:" >&2
@@ -123,6 +140,9 @@ Environment:
123
140
  CCR_CMD Deprecated — still honored with a warning if CCS_CMD unset
124
141
  CCS_UI simple | classic — overrides repos.yml defaults.ui
125
142
  (the --ui= flag wins over both)
143
+ CCS_NO_UPDATE_CHECK Any non-empty value stops the update check and its notice
144
+ (no network request, the cache is not read)
145
+ CCS_UPDATE_REGISTRY Registry to ask instead of https://registry.npmjs.org
126
146
 
127
147
  Examples:
128
148
  ccs # Full launcher
@@ -132,7 +152,9 @@ Examples:
132
152
  ccs --dangerously-skip-permissions # Pass-through flag to claude
133
153
 
134
154
  Config: ~/.config/ccs/repos.yml
135
- Cache: ~/.cache/ccs/state.db
155
+ Cache: ~/.cache/ccs/state.db, ~/.cache/ccs/update-check
156
+
157
+ ${COPYRIGHT} MIT License.
136
158
  HELP
137
159
  }
138
160
 
@@ -277,8 +299,23 @@ if [[ "$UI" == "simple" ]] && ! fzf_at_least 0 61; then
277
299
  UI="classic"
278
300
  fi
279
301
 
302
+ # better-sqlite3 ships prebuilt modules for the common platforms; anywhere else
303
+ # one has to be built from source, which ccs does not do or test.
304
+ native_module_missing() {
305
+ grep -Eq 'Cannot find module .*better-sqlite3' "$SCAN_ERR_FILE" 2>/dev/null
306
+ }
307
+ print_native_module_hint() {
308
+ native_module_missing || return 0
309
+ if [[ "$UI" == "simple" ]]; then
310
+ echo "[ccs] この環境($(uname -s)-$(uname -m))向けの部品(better-sqlite3)が入っていません。ソースから作る必要があります。" >&2
311
+ else
312
+ echo "[ccs] better-sqlite3 has no prebuilt module for this platform ($(uname -s)-$(uname -m)); it has to be built from source." >&2
313
+ fi
314
+ }
315
+
280
316
  if [[ "$UI" == "classic" ]]; then
281
317
  cat "$SCAN_ERR_FILE" >&2
318
+ print_native_module_hint
282
319
  if (( SCAN_FAILED )); then
283
320
  if (( DO_REFRESH )); then
284
321
  echo "[ccs] refresh failed, continuing with stale DB" >&2
@@ -292,7 +329,9 @@ fi
292
329
  # the scan was quiet). Printed by print_scan_notes.
293
330
  print_scan_notes() {
294
331
  [[ "$UI" == "simple" ]] || return 0
295
- if (( SCAN_FAILED )); then
332
+ if native_module_missing; then
333
+ print_native_module_hint
334
+ elif (( SCAN_FAILED )); then
296
335
  echo "[ccs] 設定ファイル(~/.config/ccs/repos.yml)を読めなかったので、前の一覧を出しました。下の行を直してから、もう一度 ccs と打ってください。" >&2
297
336
  sed 's/^/ /' "$SCAN_ERR_FILE" >&2
298
337
  elif [[ -s "$SCAN_ERR_FILE" ]]; then
@@ -392,13 +431,111 @@ REFRESH_BIND=(--bind "ctrl-r:execute('${TSX}' '${CCS_SCAN}' --force --quiet)+rel
392
431
 
393
432
  # ── Header (adaptive to terminal width) ──────────────────────────────────────
394
433
  # Note: ^D deletes SESSIONS only; repos are managed via ~/.config/ccs/repos.yml
395
- COLS=$(tput cols 2>/dev/null || echo 80)
434
+ # Asks the terminal itself: `tput cols 2>/dev/null` inside $() sees no terminal
435
+ # on stdout or stderr and answers 80 at any width. COLUMNS wins when exported.
436
+ terminal_cols() {
437
+ local size=""
438
+ if [[ "${COLUMNS:-}" =~ ^[1-9][0-9]*$ ]]; then
439
+ echo "$COLUMNS"
440
+ return 0
441
+ fi
442
+ size=$(stty size 2>/dev/null </dev/tty) || size=""
443
+ size="${size##* }"
444
+ if [[ "$size" =~ ^[1-9][0-9]*$ ]]; then echo "$size"; else echo 80; fi
445
+ }
446
+ COLS=$(terminal_cols)
396
447
  if (( COLS >= 80 )); then
397
448
  HEADER=$'Enter: launch | ^Y: copy cmd | ^I: copy ID/path | ^R: refresh\n^D: del session (edit repos.yml to remove a repo) | Esc: cancel'
398
449
  else
399
450
  HEADER=$'⏎launch ^Y:cmd ^I:ID ^R:refresh\n^D:del[resume] Esc:quit'
400
451
  fi
401
452
 
453
+ # ── Version and copyright (fzf border label) ─────────────────────────────────
454
+ # The frame already exists in both looks, so the label costs no list row, and
455
+ # change-header cannot replace it. fzf draws a label that is too long over the
456
+ # frame's corners, so take the longest form that fits: the title goes first, then
457
+ # the word Copyright, and the version stays. Computed once at start, like the
458
+ # header above. Separators are plain spaces: `·` and `→` are East Asian Ambiguous.
459
+ # label_fits: 2 for the corner and first dash on the left, 2 on the right so
460
+ # the label never touches the closing corner.
461
+ label_fits() { (( COLS >= $1 + 4 )); }
462
+ VERSION_TAG="ccs v${VERSION}"
463
+ COPYRIGHT_SHORT='© INDIGO GROUP LLC.'
464
+ TITLE='Claude Code をはじめる'
465
+ TAG_W=${#VERSION_TAG} COPYRIGHT_W=29 COPYRIGHT_SHORT_W=19 TITLE_W=22
466
+ if [[ "$UI" == "simple" ]]; then
467
+ PAD=' '
468
+ else
469
+ PAD=' '
470
+ fi
471
+ PAD_W=$(( ${#PAD} * 2 ))
472
+ if [[ "$UI" == "simple" ]] && label_fits $(( PAD_W + TITLE_W + 2 + TAG_W + 2 + COPYRIGHT_W )); then
473
+ BORDER_LABEL="${PAD}${TITLE} ${VERSION_TAG} ${COPYRIGHT}${PAD}"
474
+ elif label_fits $(( PAD_W + TAG_W + 2 + COPYRIGHT_W )); then
475
+ BORDER_LABEL="${PAD}${VERSION_TAG} ${COPYRIGHT}${PAD}"
476
+ elif label_fits $(( PAD_W + TAG_W + 2 + COPYRIGHT_SHORT_W )); then
477
+ BORDER_LABEL="${PAD}${VERSION_TAG} ${COPYRIGHT_SHORT}${PAD}"
478
+ else
479
+ BORDER_LABEL="${PAD}${VERSION_TAG}${PAD}"
480
+ fi
481
+
482
+ # ── Update notice ────────────────────────────────────────────────────────────
483
+ # Startup only reads the cache (no process but `date`). When the cache is a day
484
+ # old, ccs-update-check.ts refreshes it in the background for the NEXT launch:
485
+ # never waited for, silent, with its own time limit. Only a plain X.Y.Z on both
486
+ # sides compares, so a prerelease or a dev checkout gets no notice, and the
487
+ # cache's text is safe to put in an fzf option. Set UPDATE_LATEST when the npm
488
+ # latest is newer than this version.
489
+ UPDATE_LATEST=""
490
+ UPDATE_INTERVAL=86400
491
+ version_is_newer() { # version_is_newer CANDIDATE CURRENT (both X.Y.Z)
492
+ local a b ai bi i
493
+ IFS=. read -r -a a <<< "$1"
494
+ IFS=. read -r -a b <<< "$2"
495
+ for i in 0 1 2; do
496
+ ai=$((10#${a[$i]})) bi=$((10#${b[$i]}))
497
+ (( ai > bi )) && return 0
498
+ (( ai < bi )) && return 1
499
+ done
500
+ return 1
501
+ }
502
+ check_for_update() {
503
+ [[ -z "${CCS_NO_UPDATE_CHECK:-}" ]] || return 0
504
+ local plain='^[0-9]{1,9}\.[0-9]{1,9}\.[0-9]{1,9}$'
505
+ [[ "$VERSION" =~ $plain ]] || return 0
506
+
507
+ local cache="${XDG_CACHE_HOME:-$HOME/.cache}/ccs/update-check"
508
+ local key value latest="" checked=0
509
+ if [[ -f "$cache" && -r "$cache" ]]; then
510
+ while IFS='=' read -r key value || [[ -n "$key" ]]; do
511
+ case "$key" in
512
+ latest) latest="$value" ;;
513
+ checked) checked="$value" ;;
514
+ esac
515
+ done < "$cache"
516
+ fi
517
+ if [[ "$latest" =~ $plain ]] && version_is_newer "$latest" "$VERSION"; then
518
+ UPDATE_LATEST="$latest"
519
+ fi
520
+
521
+ # stdout not a terminal = a script or a test, not someone at the picker.
522
+ [[ -t 1 ]] || return 0
523
+ [[ "$checked" =~ ^[0-9]{1,12}$ ]] || checked=0
524
+ local age=$(( $(date +%s) - 10#$checked ))
525
+ if (( age >= UPDATE_INTERVAL || age < 0 )); then
526
+ ( "${TSX}" "${CCS_UPDATE}" "${CCS_PACKAGE}" </dev/null >/dev/null 2>&1 & )
527
+ fi
528
+ return 0
529
+ }
530
+ check_for_update || true
531
+
532
+ # simple: the frame says a new version is out; how to get it is printed once the
533
+ # picker has closed, where it can be read and copied.
534
+ print_update_note() {
535
+ [[ -n "$UPDATE_LATEST" ]] || return 0
536
+ echo "[ccs] 新しい ccs v${UPDATE_LATEST} が出ています(いまは v${VERSION})。上げるには: ${CCS_INSTALL_CMD}" >&2
537
+ }
538
+
402
539
  # ── Launch fzf (simple) ──────────────────────────────────────────────────────
403
540
  # Same rows, same bindings and the same post-pick path as the classic look;
404
541
  # only the frame, the words and where the badges live differ (the list shows
@@ -415,11 +552,18 @@ fi
415
552
  # every edit goes back to the top match instead
416
553
  # result:…down the cursor never rests on a heading row after the list
417
554
  # (re)loads or a filter finishes
555
+ # zero/result with no match fzf fires zero, result, focus, in that order,
556
+ # and focus would put the guide line back over the no-match
557
+ # message. So zero unbinds focus and result rebinds it once
558
+ # there are rows; the result binding already runs bash, so a
559
+ # cursor move still costs no process
418
560
  if [[ "$UI" == "simple" ]]; then
419
561
  SIMPLE_HEADER=' Enter: ひらく ↑↓ かマウス: えらぶ Esc: やめる'
420
562
  LIST_LABEL=' えらぶ '
421
563
  if (( SCAN_FAILED )); then
422
564
  LIST_LABEL=' えらぶ ⚠️ 設定ファイルを読めませんでした(Esc でやめると理由が出ます) '
565
+ elif [[ -n "$UPDATE_LATEST" ]] && (( COLS >= 44 )); then
566
+ LIST_LABEL=" えらぶ ccs v${UPDATE_LATEST} が出ています "
423
567
  fi
424
568
  LIST_FAILED=0
425
569
  ROWS=$("${TSX}" "${CCS_LIST}" "${LIST_FLAGS[@]}" 2>/dev/null) || LIST_FAILED=1
@@ -450,7 +594,7 @@ if [[ "$UI" == "simple" ]]; then
450
594
  --with-nth=1 \
451
595
  --no-sort --no-hscroll \
452
596
  --style=full:rounded --border=rounded \
453
- --border-label=' Claude Code をはじめる ' --border-label-pos=3 \
597
+ --border-label="${BORDER_LABEL}" --border-label-pos=3 \
454
598
  --list-label="${LIST_LABEL}" --input-label=' さがす ' --preview-label=' 中身 ' \
455
599
  --header="${SIMPLE_HEADER}" --header-border=none \
456
600
  --ghost='ここに文字を打つと、しぼりこめます' \
@@ -464,11 +608,12 @@ if [[ "$UI" == "simple" ]]; then
464
608
  "${REFRESH_BIND[@]}" \
465
609
  "${GROUP_BINDS[@]}" \
466
610
  --bind "focus:change-header(${SIMPLE_HEADER})" \
467
- --bind "zero:change-header(${MSG_NO_MATCH})" \
611
+ --bind "zero:unbind(focus)+change-header(${MSG_NO_MATCH})" \
468
612
  --bind "change:first" \
469
- --bind "result:transform:bash -c 'case \$1 in separator:*) echo down ;; esac' _ {4}" \
613
+ --bind "result:transform:bash -c 'case \$1 in separator:*) echo \"rebind(focus)+down\" ;; ?*) echo \"rebind(focus)\" ;; esac' _ {4}" \
470
614
  ) || FZF_RC=$?
471
615
  print_scan_notes
616
+ print_update_note
472
617
  if (( FZF_RC != 0 )) || [[ -z "$SELECTED" ]]; then
473
618
  if (( FZF_RC == 2 )); then
474
619
  echo "[ccs] 一覧をうまく開けませんでした。今の見た目で開くには: ccs --ui=classic" >&2
@@ -481,6 +626,12 @@ fi
481
626
 
482
627
  # ── Launch fzf ───────────────────────────────────────────────────────────────
483
628
  if [[ "$UI" == "classic" ]]; then
629
+ # The picker opens below this line (--height), and the header beside the preview
630
+ # is too narrow for the command, so the notice goes above the frame. It costs no
631
+ # list row and stays in the scrollback after the picker closes.
632
+ if [[ -n "$UPDATE_LATEST" ]]; then
633
+ echo "[ccs] Update v${VERSION} -> v${UPDATE_LATEST}: ${CCS_INSTALL_CMD}" >&2
634
+ fi
484
635
  SELECTED=$("${TSX}" "${CCS_LIST}" "${LIST_FLAGS[@]+"${LIST_FLAGS[@]}"}" | fzf \
485
636
  --ansi \
486
637
  --delimiter=$'\t' \
@@ -495,6 +646,7 @@ SELECTED=$("${TSX}" "${CCS_LIST}" "${LIST_FLAGS[@]+"${LIST_FLAGS[@]}"}" | fzf \
495
646
  --height='80%' \
496
647
  --layout=reverse \
497
648
  --border \
649
+ --border-label="${BORDER_LABEL}" --border-label-pos=3 \
498
650
  --prompt='ccs> ' \
499
651
  --tabstop=4 \
500
652
  ) || exit 0
@@ -533,6 +685,13 @@ if [[ -z "$ROW_CMD" ]]; then
533
685
  exit 1
534
686
  fi
535
687
 
688
+ # Defense-in-depth copy of the policy ccs-config.ts enforces on command at load
689
+ # time. Extra columns would also land here, joined by \x1f, and be refused.
690
+ if [[ "$ROW_CMD" =~ [\;\&\|\<\>\$\`\"\'\\] || "$ROW_CMD" =~ [[:cntrl:]] ]]; then
691
+ echo "❌ Refusing the selected row's command (shell metacharacters/control chars)" >&2
692
+ exit 1
693
+ fi
694
+
536
695
  # ── Per-row command is authoritative ─────────────────────────────────────────
537
696
  # CCS_CMD env is resolved by ccs-config.ts as a fallback for repos without
538
697
  # explicit `command:` (priority: repos[].command > defaults.command > CCS_CMD > "claude").
@@ -569,8 +728,8 @@ esac
569
728
 
570
729
  # Path validation (traversal prevention). Must be $HOME itself or a child
571
730
  # path ("$HOME"/*): a bare prefix match would also accept sibling dirs like
572
- # /Users/xEVIL (audit L-1).
573
- if [[ "$ROW_CWD" != "$HOME" && "$ROW_CWD" != "$HOME"/* ]]; then
731
+ # /Users/xEVIL (audit L-1). A `..` segment could walk back out of $HOME.
732
+ if [[ ( "$ROW_CWD" != "$HOME" && "$ROW_CWD" != "$HOME"/* ) || "/${ROW_CWD}/" == */../* ]]; then
574
733
  if [[ "$UI" == "simple" ]]; then
575
734
  echo "⚠️ ホームの外のフォルダ(${ROW_CWD})は開けないので、いまいるフォルダで開きます" >&2
576
735
  else
@@ -614,7 +773,9 @@ case "$KIND" in
614
773
  else
615
774
  echo "🔄 Resuming session ${KEY} in $(pwd)..."
616
775
  fi
776
+ set -f # split ROW_CMD into words, but expand no wildcard in it
617
777
  ${ROW_CMD} --resume "${KEY}" "${PASS_ARGS[@]+"${PASS_ARGS[@]}"}"
778
+ set +f
618
779
  ;;
619
780
  new)
620
781
  if [[ "$UI" == "simple" ]]; then
@@ -622,6 +783,8 @@ case "$KIND" in
622
783
  else
623
784
  echo "🚀 Launching ${KEY} in $(pwd)..."
624
785
  fi
786
+ set -f # split ROW_CMD into words, but expand no wildcard in it
625
787
  ${ROW_CMD} "${PASS_ARGS[@]+"${PASS_ARGS[@]}"}"
788
+ set +f
626
789
  ;;
627
790
  esac
package/bin/ccs-list.ts CHANGED
@@ -30,6 +30,7 @@ import { openDb, getMeta, reposV3Columns } from "./ccs-db.ts";
30
30
  import { getPaths } from "./ccs-config.ts";
31
31
  import { SHELL_METACHARS } from "./ccs-sanitize.ts";
32
32
  import {
33
+ displayIcon,
33
34
  truncate,
34
35
  formatRelativeTime,
35
36
  formatDateTime,
@@ -268,7 +269,7 @@ function repoToRow({ repo: r, parent, prefix }: GroupedRepo): string {
268
269
  parentName && parentName !== displayName
269
270
  ? ` \x1b[2m‹${parentName}›\x1b[0m`
270
271
  : "";
271
- const label = `${prefix}${r.icon || "📁"} ${displayName}${parentHint}${LABEL_SEP}`;
272
+ const label = `${prefix}${displayIcon(r.icon)} ${displayName}${parentHint}${LABEL_SEP}`;
272
273
  const desc = truncate(r.description || "", DESC_MAX);
273
274
  if (r.launch === 0) {
274
275
  // Group header (`launch: false`): display-only, so no badges (the header
@@ -301,6 +302,10 @@ interface SessionRowFull {
301
302
  repo_command: string | null;
302
303
  }
303
304
 
305
+ function isWellFormedRow(row: string): boolean {
306
+ return row.split("\t").length === 6 && !/[\n\r\x1f]/.test(row);
307
+ }
308
+
304
309
  function sessionToRow(s: SessionRowFull, defaultCommand: string): string {
305
310
  // Mapped sessions use the registered repo icon + name — the unique `name`,
306
311
  // deliberately not the repos.yml `label`, so session rows read the same as
@@ -318,7 +323,7 @@ function sessionToRow(s: SessionRowFull, defaultCommand: string): string {
318
323
  : s.cwd
319
324
  ? truncate(s.cwd.replace(homedir(), "~"), DESC_MAX)
320
325
  : "(unknown)";
321
- const mapIcon = s.repo_display ? s.repo_icon || "📁" : "❓";
326
+ const mapIcon = s.repo_display ? displayIcon(s.repo_icon) : "❓";
322
327
  const label = `🔄 ${mapIcon} ${displayName}${LABEL_SEP}`;
323
328
  const desc = truncate(s.topic || "", DESC_MAX);
324
329
  const badges = `[${formatDateTime(s.last_activity_at)}]`;
@@ -660,7 +665,13 @@ function main(): number {
660
665
  lines.push(...sessionRows);
661
666
  }
662
667
 
663
- process.stdout.write(lines.join("\n") + (lines.length > 0 ? "\n" : ""));
668
+ // Last check: bin/ccs takes the columns by position and runs column 6, so a
669
+ // row of any other shape is dropped, never handed over.
670
+ const rows = lines.filter(isWellFormedRow);
671
+ if (rows.length !== lines.length) {
672
+ process.stderr.write(`[ccs-list] skipped ${lines.length - rows.length} malformed row(s)\n`);
673
+ }
674
+ process.stdout.write(rows.join("\n") + (rows.length > 0 ? "\n" : ""));
664
675
  return 0;
665
676
  } catch (err) {
666
677
  const msg = err instanceof Error ? err.message : String(err);
@@ -8,6 +8,7 @@ import { readdir, readFile, stat } from "node:fs/promises";
8
8
  import { join } from "node:path";
9
9
  import { homedir } from "node:os";
10
10
  import { maskSecrets } from "./ccs-secrets.ts";
11
+ import { stripControlChars } from "./ccs-sanitize.ts";
11
12
  import {
12
13
  extractText,
13
14
  truncate,
@@ -19,6 +20,11 @@ const PROJECTS_DIR = join(homedir(), ".claude", "projects");
19
20
  const MAX_PREVIEW_MESSAGES = 20;
20
21
  const MAX_MSG_LEN = 200;
21
22
 
23
+ /** Hide secrets before cutting: a key across the cut no longer matches its pattern. */
24
+ function safeText(text: string, max: number): string {
25
+ return truncate(maskSecrets(stripControlChars(text)), max);
26
+ }
27
+
22
28
  // extractText / truncate / UUID_RE / MAX_JSONL_SIZE come from ccs-utils.ts —
23
29
  // shared with the scanner so the preview and topic extraction can no longer
24
30
  // drift apart (review A-1/A-2). The preview passes includeToolBlocks below
@@ -128,7 +134,7 @@ export async function renderSessionPreview(
128
134
  : entry.type === "user"
129
135
  ? "👤"
130
136
  : "🤖",
131
- text: maskSecrets(truncate(text, MAX_MSG_LEN)),
137
+ text: safeText(text, MAX_MSG_LEN),
132
138
  });
133
139
  // Memory optimization: keep at most 2x display limit
134
140
  if (messages.length > MAX_PREVIEW_MESSAGES * 2) {
@@ -160,9 +166,9 @@ export async function renderSessionPreview(
160
166
  // treatment as message text: maskSecrets for credential leakage (audit
161
167
  // M-2), truncate to strip control chars / escape sequences (audit NEW-1).
162
168
  console.log("━━━ Session Info ━━━");
163
- console.log(`📁 ${maskSecrets(truncate(cwd, 200))}`);
164
- if (gitBranch) console.log(`🌿 ${maskSecrets(truncate(gitBranch, 100))}`);
165
- if (version) console.log(`📌 Claude ${maskSecrets(truncate(version, 50))}`);
169
+ console.log(`📁 ${safeText(cwd, 200)}`);
170
+ if (gitBranch) console.log(`🌿 ${safeText(gitBranch, 100)}`);
171
+ if (version) console.log(`📌 Claude ${safeText(version, 50)}`);
166
172
  console.log(`💬 ${messages.length} messages`);
167
173
  console.log("━━━ Conversation ━━━");
168
174
  console.log();
@@ -20,6 +20,7 @@ import { getPaths } from "./ccs-config.ts";
20
20
  import { openDb, reposV3Columns, type DbHandle } from "./ccs-db.ts";
21
21
  import { renderSessionPreview } from "./ccs-preview-session.ts";
22
22
  import {
23
+ displayIcon,
23
24
  formatRelativeTime,
24
25
  formatDateTime,
25
26
  formatFriendlyTime,
@@ -29,6 +30,7 @@ import {
29
30
  tildePath,
30
31
  uiFromArgv,
31
32
  } from "./ccs-utils.ts";
33
+ import { stripControlChars, stripControlCharsKeepLines } from "./ccs-sanitize.ts";
32
34
 
33
35
  // ---------------------------------------------------------------------------
34
36
  // ANSI helpers
@@ -225,9 +227,9 @@ function renderRepoPreview(name: string, cwd: string): void {
225
227
  // showing the name.
226
228
  const displayName = repoDisplayName(repo);
227
229
  const nameHint = displayName !== repo.name ? ` ${dim(`(${repo.name})`)}` : "";
228
- console.log(`${hdr(`${repo.icon || "📁"} ${displayName}`)}${nameHint}`);
230
+ console.log(`${hdr(`${displayIcon(repo.icon)} ${displayName}`)}${nameHint}`);
229
231
  console.log(dim(repo.path));
230
- if (repo.description) console.log(repo.description);
232
+ if (repo.description) console.log(stripControlCharsKeepLines(repo.description));
231
233
  console.log("");
232
234
 
233
235
  // --- Git ---
@@ -337,7 +339,7 @@ function renderRepoPreview(name: string, cwd: string): void {
337
339
  const tags = JSON.parse(repo.tags_json || "[]") as unknown;
338
340
  if (Array.isArray(tags) && tags.length > 0) {
339
341
  console.log(divider("Tags"));
340
- console.log(tags.map(String).join(", "));
342
+ console.log(tags.map((t) => stripControlChars(String(t))).join(", "));
341
343
  }
342
344
  } catch {
343
345
  // ignore
@@ -395,7 +397,7 @@ function renderGroupPreview(name: string, cwd: string): void {
395
397
  return;
396
398
  }
397
399
 
398
- console.log(`${hdr(`${group.icon || "📁"} ${repoDisplayName(group)}`)}`);
400
+ console.log(`${hdr(`${displayIcon(group.icon)} ${repoDisplayName(group)}`)}`);
399
401
  console.log(dim(group.path));
400
402
  if (group.description) console.log(truncate(group.description, 200));
401
403
  console.log("");
@@ -418,7 +420,7 @@ function renderGroupPreview(name: string, cwd: string): void {
418
420
  entries.forEach((r, i) => {
419
421
  const rel = truncate(relative(group.path, r.path), 60) + "/";
420
422
  const tag = r.launch === 0 ? ` ${dim("(group)")}` : "";
421
- console.log(` ${r.icon || "📁"} ${names[i].padEnd(width)} ${dim(rel)}${tag}`);
423
+ console.log(` ${displayIcon(r.icon)} ${names[i].padEnd(width)} ${dim(rel)}${tag}`);
422
424
  });
423
425
  } catch (err) {
424
426
  const msg = err instanceof Error ? err.message : String(err);
@@ -26,10 +26,16 @@ export function hasShellMetachars(value: string): boolean {
26
26
  return SHELL_METACHARS.test(value);
27
27
  }
28
28
 
29
- // All C0 control characters plus DEL. \n and \t are NOT exempted: every ccs
30
- // display surface is single-line-per-field (TSV rows, preview lines), and the
31
- // TSV protocol between ccs-list and fzf breaks on embedded tabs/newlines.
32
- const CONTROL_CHARS_RE = /[\x00-\x1f\x7f]+/g;
29
+ // All C0 control characters, DEL and C1 (U+0080-U+009F: a terminal may read
30
+ // U+009B as ESC [), plus the marks that reorder displayed text (U+061C,
31
+ // U+200E-F, U+202A-E, U+2066-9). Emoji joiners and variation selectors stay.
32
+ // \n and \t are NOT exempted: every ccs display surface is
33
+ // single-line-per-field (TSV rows, preview lines), and the TSV protocol between
34
+ // ccs-list and fzf breaks on embedded tabs/newlines.
35
+ const CONTROL_CHARS_RE =
36
+ /[\x00-\x1f\x7f-\x9f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]+/g;
37
+ const CONTROL_CHARS_EXCEPT_NEWLINE_RE =
38
+ /[\x00-\x09\x0b-\x1f\x7f-\x9f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]+/g;
33
39
 
34
40
  /**
35
41
  * Strip C0 control characters (incl. ESC) and DEL from display text,
@@ -41,6 +47,11 @@ export function stripControlChars(text: string): string {
41
47
  return text.replace(CONTROL_CHARS_RE, " ").replace(/\s+/g, " ").trim();
42
48
  }
43
49
 
50
+ /** The same cleaning for text shown as several lines: only newlines survive. */
51
+ export function stripControlCharsKeepLines(text: string): string {
52
+ return text.replace(CONTROL_CHARS_EXCEPT_NEWLINE_RE, " ");
53
+ }
54
+
44
55
  /**
45
56
  * Sanitize a session-provided cwd at intake (audit H-1).
46
57
  *
@@ -53,5 +64,8 @@ export function sanitizeSessionCwd(cwd: string): string | null {
53
64
  if (cwd.length === 0) return null;
54
65
  if (hasShellMetachars(cwd)) return null;
55
66
  if (!cwd.startsWith("/")) return null;
67
+ // A `..` segment walks out of the checks made on the prefix (the $HOME gate
68
+ // in bin/ccs), and this value also reaches the clipboard through Ctrl-Y.
69
+ if (cwd.split("/").includes("..")) return null;
56
70
  return cwd;
57
71
  }
package/bin/ccs-scan.ts CHANGED
@@ -15,7 +15,7 @@
15
15
 
16
16
  import { execFile } from "node:child_process";
17
17
  import { promisify } from "node:util";
18
- import { open, readdir, stat } from "node:fs/promises";
18
+ import { open, readdir, realpath, stat } from "node:fs/promises";
19
19
  import {
20
20
  closeSync,
21
21
  existsSync,
@@ -81,12 +81,25 @@ const PREVIEW_FILE_LIMIT = 10;
81
81
  // Utilities
82
82
  // ---------------------------------------------------------------------------
83
83
 
84
+ const SAFE_GIT_ARGS = [
85
+ "--no-optional-locks",
86
+ "-c",
87
+ "diff.autoRefreshIndex=false",
88
+ "-c",
89
+ "core.fsmonitor=false",
90
+ "-c",
91
+ "core.hooksPath=/dev/null",
92
+ ];
93
+
84
94
  async function runGit(
85
95
  cwd: string,
86
96
  args: string[],
87
97
  ): Promise<{ stdout: string; stderr: string } | null> {
88
98
  try {
89
- const res = await execFileP("git", args, {
99
+ // A repo's own .git/config can name programs. These are the ones a scan
100
+ // would run: an fsmonitor, and the post-index-change hook when status or
101
+ // diff rewrites the index. A scan only reads, so neither rewrites it.
102
+ const res = await execFileP("git", [...SAFE_GIT_ARGS, ...args], {
90
103
  cwd,
91
104
  timeout: GIT_TIMEOUT_MS,
92
105
  maxBuffer: 4 * 1024 * 1024,
@@ -216,12 +229,24 @@ async function readFirstLine(filePath: string): Promise<string | null> {
216
229
  }
217
230
  }
218
231
 
232
+ /** A symlink is followed only when it ends up inside the repo. */
233
+ async function realPathInside(path: string, realRoot: string): Promise<string | null> {
234
+ const real = await realpath(path);
235
+ return real === realRoot || real.startsWith(`${realRoot}/`) ? real : null;
236
+ }
237
+
219
238
  async function listDirPreview(
220
239
  dirPath: string,
240
+ repoPath: string,
221
241
  ): Promise<{ count: number; previews: PreviewFile[] }> {
222
242
  if (!existsSync(dirPath)) return { count: 0, previews: [] };
223
243
  let entries: string[];
244
+ let realRoot: string;
224
245
  try {
246
+ realRoot = await realpath(repoPath);
247
+ if ((await realPathInside(dirPath, realRoot)) === null) {
248
+ return { count: 0, previews: [] };
249
+ }
225
250
  entries = await readdir(dirPath);
226
251
  } catch {
227
252
  return { count: 0, previews: [] };
@@ -230,8 +255,9 @@ async function listDirPreview(
230
255
 
231
256
  const results = await Promise.all(
232
257
  visible.map(async (name): Promise<PreviewFile | null> => {
233
- const full = join(dirPath, name);
234
258
  try {
259
+ const full = await realPathInside(join(dirPath, name), realRoot);
260
+ if (full === null) return null;
235
261
  const st = await stat(full);
236
262
  if (!st.isFile()) return null;
237
263
  const line = await readFirstLine(full);
@@ -391,11 +417,11 @@ async function scanOneRepo(
391
417
  }
392
418
 
393
419
  // --- Workspace dirs ---
394
- const handoff = await listDirPreview(join(repo.path, "handoff"));
420
+ const handoff = await listDirPreview(join(repo.path, "handoff"), repo.path);
395
421
  stats.handoff_count = handoff.count;
396
422
  handoffPreviews = handoff.previews;
397
423
 
398
- const pending = await listDirPreview(join(repo.path, "pendings"));
424
+ const pending = await listDirPreview(join(repo.path, "pendings"), repo.path);
399
425
  stats.pending_count = pending.count;
400
426
  pendingPreviews = pending.previews;
401
427
 
@@ -647,7 +673,15 @@ export async function scan(opts: ScanOptions = {}): Promise<ScanResult> {
647
673
  };
648
674
  }
649
675
 
650
- const handle = openDb(paths.stateDb);
676
+ // A DB that cannot open must not leave the lock behind: every later scan
677
+ // would say "another scan is in progress" and hide the real error.
678
+ let handle: ReturnType<typeof openDb>;
679
+ try {
680
+ handle = openDb(paths.stateDb);
681
+ } catch (err) {
682
+ releaseLock();
683
+ throw err;
684
+ }
651
685
 
652
686
  try {
653
687
  syncReposToDb(handle.db, config.repos);
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * ccs-update-check.ts — ask the npm registry for the package's latest version
4
+ * and remember the answer for bin/ccs.
5
+ *
6
+ * bin/ccs starts this in the background (at most once a day) and never waits
7
+ * for it: the launcher only reads the cache this file writes, so a slow or
8
+ * missing network cannot delay or stop the picker. Nothing is printed and the
9
+ * exit code is always 0.
10
+ *
11
+ * Sent: one HTTPS GET for the package's public dist-tags. No ccs version, repo,
12
+ * session or machine details are in it.
13
+ *
14
+ * Cache: <cacheDir>/update-check, two key=value lines:
15
+ * latest=<X.Y.Z, or empty when the registry's latest is not a plain release>
16
+ * checked=<unix seconds>
17
+ * The registry's answer is untrusted text that ends up in a terminal and in an
18
+ * fzf option, so only a plain X.Y.Z is ever stored. bin/ccs validates the file
19
+ * again when it reads it.
20
+ */
21
+
22
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
23
+ import { join } from "node:path";
24
+
25
+ import { getPaths } from "./ccs-config.ts";
26
+
27
+ const DEFAULT_REGISTRY = "https://registry.npmjs.org";
28
+ const REQUEST_TIMEOUT_MS = 3_000;
29
+ const HARD_EXIT_MS = 5_000;
30
+ // bin/ccs asks again once `checked` is a day old (UPDATE_INTERVAL there). A
31
+ // failed attempt is back-dated so the next try comes after an hour, not a day.
32
+ const CHECK_INTERVAL_S = 86_400;
33
+ const RETRY_AFTER_FAILURE_S = 3_600;
34
+
35
+ const PLAIN_RELEASE = /^[0-9]{1,9}\.[0-9]{1,9}\.[0-9]{1,9}$/;
36
+
37
+ export function isPlainRelease(version: unknown): version is string {
38
+ return typeof version === "string" && PLAIN_RELEASE.test(version);
39
+ }
40
+
41
+ /** Registry base URL: CCS_UPDATE_REGISTRY when it is http(s), else npm's. */
42
+ export function registryBase(value: string | undefined): string {
43
+ if (value && /^https?:\/\/[^\s]+$/.test(value)) return value.replace(/\/+$/, "");
44
+ return DEFAULT_REGISTRY;
45
+ }
46
+
47
+ export function distTagsUrl(base: string, pkg: string): string {
48
+ return `${base}/-/package/${encodeURIComponent(pkg)}/dist-tags`;
49
+ }
50
+
51
+ /**
52
+ * The version the cache should hold after a successful answer: a plain release,
53
+ * or "" when `latest` is a prerelease. Throws on an answer with no usable
54
+ * `latest` (treated like a failed request).
55
+ */
56
+ export function latestFromDistTags(body: unknown): string {
57
+ if (typeof body !== "object" || body === null) throw new Error("not an object");
58
+ const latest = (body as { latest?: unknown }).latest;
59
+ if (typeof latest !== "string") throw new Error("no latest tag");
60
+ return isPlainRelease(latest) ? latest : "";
61
+ }
62
+
63
+ export function renderCache(latest: string, checked: number): string {
64
+ return `latest=${latest}\nchecked=${checked}\n`;
65
+ }
66
+
67
+ /** The last good version in an existing cache file ("" when there is none). */
68
+ export function cachedLatest(text: string): string {
69
+ for (const line of text.split("\n")) {
70
+ if (line.startsWith("latest=")) {
71
+ const v = line.slice("latest=".length);
72
+ return isPlainRelease(v) ? v : "";
73
+ }
74
+ }
75
+ return "";
76
+ }
77
+
78
+ function writeCache(cacheDir: string, latest: string, checked: number): void {
79
+ mkdirSync(cacheDir, { recursive: true, mode: 0o700 });
80
+ const file = join(cacheDir, "update-check");
81
+ const tmp = `${file}.${process.pid}.tmp`;
82
+ writeFileSync(tmp, renderCache(latest, checked), { mode: 0o600 });
83
+ renameSync(tmp, file);
84
+ }
85
+
86
+ function readCachedLatest(cacheDir: string): string {
87
+ try {
88
+ return cachedLatest(readFileSync(join(cacheDir, "update-check"), "utf-8"));
89
+ } catch {
90
+ return "";
91
+ }
92
+ }
93
+
94
+ async function fetchLatest(url: string): Promise<string> {
95
+ const res = await fetch(url, {
96
+ headers: { accept: "application/json" },
97
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
98
+ });
99
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
100
+ return latestFromDistTags(await res.json());
101
+ }
102
+
103
+ async function main(): Promise<void> {
104
+ const pkg = process.argv[2];
105
+ if (!pkg) return;
106
+ // A stalled DNS lookup or body must not leave a process behind.
107
+ setTimeout(() => process.exit(0), HARD_EXIT_MS);
108
+
109
+ const { cacheDir } = getPaths();
110
+ const now = Math.floor(Date.now() / 1000);
111
+ try {
112
+ const url = distTagsUrl(registryBase(process.env.CCS_UPDATE_REGISTRY), pkg);
113
+ writeCache(cacheDir, await fetchLatest(url), now);
114
+ } catch {
115
+ // Offline, timed out, bad answer: keep what the last good answer said.
116
+ try {
117
+ const retryAt = now - CHECK_INTERVAL_S + RETRY_AFTER_FAILURE_S;
118
+ writeCache(cacheDir, readCachedLatest(cacheDir), retryAt);
119
+ } catch {
120
+ // Cache not writable: nothing to remember, nothing to report.
121
+ }
122
+ }
123
+ }
124
+
125
+ // Run only as a script, so the test suite can import the helpers.
126
+ if (process.argv[1]?.endsWith("ccs-update-check.ts")) {
127
+ main().then(
128
+ () => process.exit(0),
129
+ () => process.exit(0),
130
+ );
131
+ }
package/bin/ccs-utils.ts CHANGED
@@ -46,6 +46,11 @@ 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
+ /** A repos.yml icon as drawn: never a tab or an escape, 📁 when nothing is left. */
50
+ export function displayIcon(icon: string | null | undefined): string {
51
+ return stripControlChars(icon ?? "") || "📁";
52
+ }
53
+
49
54
  // ---------------------------------------------------------------------------
50
55
  // Repo display name & nesting
51
56
  // ---------------------------------------------------------------------------
@@ -1,6 +1,6 @@
1
1
  # v0.2.0 Static Review Notes
2
2
 
3
- Severity per `~/.claude/rules/common/code-review.md`:
3
+ Severity:
4
4
  CRITICAL (block) · HIGH (warn) · MEDIUM (info) · LOW (note)
5
5
 
6
6
  ---
@@ -2,7 +2,7 @@
2
2
 
3
3
  Collected from the second-round code review (CR2, 2026-04-14) across four specialist reviews (TypeScript / Security / Database / General). **None of these are ship blockers for v0.2.0** — all HIGH-severity findings and the critical MEDIUM items were addressed in Phase 6 (see `docs/v0.2.0-review-notes.md` §Phase 6 fixes). The items below are deliberate deferrals, scheduled for v0.2.1 or later as scope permits.
4
4
 
5
- Severity conventions per `~/.claude/rules/common/code-review.md`:
5
+ Severity conventions:
6
6
  CRITICAL (block) · HIGH (warn) · MEDIUM (info) · LOW (note) · INFO (optional)
7
7
 
8
8
  ---
@@ -202,7 +202,7 @@ Follow-up batch (same branch, second pass on user request):
202
202
  **File**: — (release process, not code)
203
203
  **Issue**: The account-free distribution path is DONE — `npm install -g github:indigo-gr/claude-code-station` works (bin/ccs resolves $0 through npm's global-bin symlink, tsx ships as a dependency and is preferred from the package-local node_modules, `files` field set; verified end-to-end via `npm pack` + sandbox-prefix global install). What remains is only publishing to the npm REGISTRY for discoverability/version pinning.
204
204
  **Fix sketch**: npm account → claim package name → `npm publish` (repo side is ready); optional release automation.
205
- **Why deferred**: Pure publish decision. Tracked in `~/.claude/pendings/ccs-npm-registry-publish.md` (owner decision).
205
+ **Why deferred**: Pure publish decision (owner decision).
206
206
 
207
207
  ### [HIGH] `install.sh --auto-install` — auto-install missing dependencies
208
208
  **File**: `install.sh`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigo-gr/claude-code-station",
3
- "version": "0.3.3",
3
+ "version": "0.4.1",
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": {
@@ -18,6 +18,7 @@
18
18
  "session",
19
19
  "launcher"
20
20
  ],
21
+ "author": "INDIGO GROUP LLC.",
21
22
  "license": "MIT",
22
23
  "repository": {
23
24
  "type": "git",
@@ -32,10 +33,10 @@
32
33
  "CHANGELOG.md"
33
34
  ],
34
35
  "engines": {
35
- "node": ">=20"
36
+ "node": ">=22"
36
37
  },
37
38
  "dependencies": {
38
- "better-sqlite3": "^11.3.0",
39
+ "better-sqlite3": "^13.0.3",
39
40
  "tsx": "^4.19.0",
40
41
  "yaml": "^2.6.0"
41
42
  },