@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 +34 -0
- package/LICENSE +1 -1
- package/README.md +23 -5
- package/bin/ccs +174 -11
- package/bin/ccs-list.ts +14 -3
- package/bin/ccs-preview-session.ts +10 -4
- package/bin/ccs-preview.ts +7 -5
- package/bin/ccs-sanitize.ts +18 -4
- package/bin/ccs-scan.ts +40 -6
- package/bin/ccs-update-check.ts +131 -0
- package/bin/ccs-utils.ts +5 -0
- package/docs/v0.2.0-review-notes.md +1 -1
- package/docs/v0.2.1-backlog.md +2 -2
- package/package.json +4 -3
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
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Claude Code Station — ccs
|
|
2
2
|
|
|
3
3
|
[](LICENSE)
|
|
4
|
-
[](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/) **
|
|
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
|
|
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
|
-
|
|
22
|
-
|
|
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
|
|
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
|
-
|
|
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=
|
|
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
|
|
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
|
-
|
|
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:
|
|
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(`📁 ${
|
|
164
|
-
if (gitBranch) console.log(`🌿 ${
|
|
165
|
-
if (version) console.log(`📌 Claude ${
|
|
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();
|
package/bin/ccs-preview.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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);
|
package/bin/ccs-sanitize.ts
CHANGED
|
@@ -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
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|
package/docs/v0.2.1-backlog.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
"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": ">=
|
|
36
|
+
"node": ">=22"
|
|
36
37
|
},
|
|
37
38
|
"dependencies": {
|
|
38
|
-
"better-sqlite3": "^
|
|
39
|
+
"better-sqlite3": "^13.0.3",
|
|
39
40
|
"tsx": "^4.19.0",
|
|
40
41
|
"yaml": "^2.6.0"
|
|
41
42
|
},
|