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

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/bin/ccs CHANGED
@@ -3,6 +3,10 @@
3
3
  # https://github.com/indigo-gr/claude-code-station
4
4
 
5
5
  set -euo pipefail
6
+ # bash 5 turns checkwinsize on by default, also in scripts: after any program
7
+ # run in the foreground it resets COLUMNS to the terminal's width, so an
8
+ # exported COLUMNS would lose to the real width (see terminal_cols).
9
+ shopt -u checkwinsize
6
10
 
7
11
  # Resolve $0 through symlinks before taking dirname: `npm install -g
8
12
  # github:indigo-gr/claude-code-station` links this script into the global
@@ -18,8 +22,24 @@ SCRIPT_DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
18
22
  # Single source of truth: read version from package.json next to bin/.
19
23
  # Falls back to "0.0.0-dev" for copy-install layouts (install.sh drops bin/
20
24
  # 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}"
25
+ # Only this package's own file counts: install.sh copies into ~/.claude/scripts/,
26
+ # where ../package.json can belong to something else entirely, and its version
27
+ # must not reach the screen or the update check. A bash match, no extra process.
28
+ CCS_PACKAGE='@indigo-gr/claude-code-station'
29
+ CCS_INSTALL_CMD="npm install -g ${CCS_PACKAGE}"
30
+ VERSION="0.0.0-dev"
31
+ read_package_version() {
32
+ local file="${SCRIPT_DIR}/../package.json" json=""
33
+ local name_re='"name"[[:space:]]*:[[:space:]]*"([^"]*)"'
34
+ local version_re='"version"[[:space:]]*:[[:space:]]*"([0-9A-Za-z.+-]+)"'
35
+ [[ -f "$file" && -r "$file" ]] || return 0
36
+ json=$(<"$file") || return 0
37
+ [[ "$json" =~ $name_re ]] || return 0
38
+ [[ "${BASH_REMATCH[1]}" == "$CCS_PACKAGE" ]] || return 0
39
+ [[ "$json" =~ $version_re ]] || return 0
40
+ VERSION="${BASH_REMATCH[1]}"
41
+ }
42
+ read_package_version 2>/dev/null || true
23
43
 
24
44
  # Prefer a package-local tsx over the PATH one: the npm install brings tsx
25
45
  # as a dependency (../node_modules from the package's bin/), and the copy
@@ -33,11 +53,21 @@ else
33
53
  TSX="tsx"
34
54
  fi
35
55
 
56
+ # Every module starts through ccs-tsx.sh, from ccs's own folder: Node reads
57
+ # the package.json above the CURRENT folder for tsx's preload, and a broken
58
+ # one there would stop every module (the user's folder goes on in CCS_CWD).
59
+ # TSX stays the tsx to check for; TSX_RUN is how a module is started.
60
+ export CCS_TSX_REAL="$TSX"
61
+ CCS_TSX_SH="${SCRIPT_DIR}/ccs-tsx.sh"
62
+ TSX_RUN=(bash "$CCS_TSX_SH")
63
+
36
64
  CCS_LIST="${SCRIPT_DIR}/ccs-list.ts"
37
65
  CCS_SCAN="${SCRIPT_DIR}/ccs-scan.ts"
38
66
  CCS_PREVIEW="${SCRIPT_DIR}/ccs-preview.ts"
39
67
  CCS_DELETE="${SCRIPT_DIR}/ccs-delete.sh"
40
68
  CCS_INIT="${SCRIPT_DIR}/ccs-init.ts"
69
+ CCS_UPDATE="${SCRIPT_DIR}/ccs-update-check.ts"
70
+ COPYRIGHT='Copyright © INDIGO GROUP LLC.'
41
71
 
42
72
  # ── CCR_CMD -> CCS_CMD migration shim ────────────────────────────────────────
43
73
  if [[ -n "${CCR_CMD:-}" && -z "${CCS_CMD:-}" ]]; then
@@ -45,6 +75,431 @@ if [[ -n "${CCR_CMD:-}" && -z "${CCS_CMD:-}" ]]; then
45
75
  export CCS_CMD="${CCR_CMD}"
46
76
  fi
47
77
 
78
+ # ── Machine interface: doctor, list, sessions, repo, config, open ────────────
79
+ # Words for GUI launchers, scripts and other tools (docs/design/
80
+ # machine-interface.md). Only as the FIRST argument: `ccs --new doctor` still
81
+ # passes `doctor` on to the launched command. Handled before the dependency
82
+ # check and the update check: these commands never open fzf, read stdin, go
83
+ # to the network or create the config template or the cache. Exit codes and
84
+ # the JSON shape are the contract; stderr is for people and never carries a
85
+ # repo path or name.
86
+ CCS_MACHINE="${SCRIPT_DIR}/ccs-machine.ts"
87
+ CCS_DOCTOR="${SCRIPT_DIR}/ccs-doctor.ts"
88
+ NODE_MIN_MAJOR=22 # package.json engines.node
89
+ FZF_MIN=(0 42) # README and install.sh
90
+ FZF_HEADERS_MIN=(0 45) # fzf's transform action (group headers stay open)
91
+ CHECK_TIMEOUT=5 # node --version, fzf --version
92
+ DOCTOR_STAGE2_TIMEOUT=20
93
+
94
+ # The values a hand-built answer may carry: words, a version, a time.
95
+ json_version() { # json_version VALUE → "VALUE" or null
96
+ if [[ "$1" =~ ^[0-9A-Za-z.+-]{1,32}$ ]]; then printf '"%s"' "$1"; else printf 'null'; fi
97
+ }
98
+ utc_now() { date -u +%Y-%m-%dT%H:%M:%SZ; }
99
+
100
+ # The outer object (machine-interface.md 1.3), for the cases where no .ts
101
+ # module can answer. Words only, so printf is enough.
102
+ machine_outer() { # machine_outer COMMAND STATUS
103
+ printf '"schema_version":"1.0.0","product_id":"ccs","product_version":%s,"command":"%s","written_at":"%s","status":"%s"' \
104
+ "$(json_version "$VERSION")" "$1" "$(utc_now)" "$2"
105
+ }
106
+ machine_fail() { # machine_fail COMMAND JSON(0|1) STATUS EXIT CODE [DETAIL] [NOTE]
107
+ local detail=""
108
+ [[ -z "${6:-}" ]] || detail=",\"detail\":\"$6\""
109
+ if [[ "$2" == 1 ]]; then
110
+ printf '{%s,"reasons":[{"vocab":"ccs.cli","code":"%s"%s}]}\n' "$(machine_outer "$1" "$3")" "$5" "$detail"
111
+ fi
112
+ echo "[ccs] ${1}: ${3}: ${5}${6:+ (${6})}${7:+ — ${7}}" >&2
113
+ exit "$4"
114
+ }
115
+ args_want_json() { # args_want_json ARGS... — `--json` before a bare `--`
116
+ local a
117
+ for a in "$@"; do
118
+ [[ "$a" == "--" ]] && return 1
119
+ [[ "$a" == "--json" ]] && return 0
120
+ done
121
+ return 1
122
+ }
123
+
124
+ # run_timed SECONDS OUT ERR CMD... — run CMD with stdin from /dev/null, its
125
+ # output in the two files, for at most SECONDS. Sets TIMED_RC and TIMED_OUT.
126
+ # A polling loop, not timeout(1): macOS has none, and a background `sleep`
127
+ # would outlive the command.
128
+ # While it runs, this shell's own stderr goes to /dev/null: bash reports a job
129
+ # it had to kill ("<script path>: line N: PID Terminated …"), and that line
130
+ # names the install path. The command's stderr goes to ERR either way.
131
+ run_timed() {
132
+ local limit=$1 out=$2 err=$3 pid ticks=0
133
+ shift 3
134
+ TIMED_OUT=0 TIMED_RC=0
135
+ exec 3>&2 2>/dev/null
136
+ "$@" >"$out" 2>"$err" </dev/null &
137
+ pid=$!
138
+ MACHINE_CHILD=$pid
139
+ while kill -0 "$pid" 2>/dev/null; do
140
+ if (( ticks >= limit * 10 )); then
141
+ kill -TERM "$pid" 2>/dev/null || true
142
+ sleep 0.2
143
+ kill -KILL "$pid" 2>/dev/null || true
144
+ TIMED_OUT=1
145
+ break
146
+ fi
147
+ sleep 0.1
148
+ ticks=$((ticks + 1))
149
+ done
150
+ wait "$pid" 2>/dev/null || TIMED_RC=$?
151
+ exec 2>&3 3>&-
152
+ MACHINE_CHILD=""
153
+ }
154
+
155
+ # machine_tmp_dir NAME — a fresh 0700 folder for this run in TMPDIR, or in
156
+ # /tmp when TMPDIR is missing or not writable. Sets MACHINE_TMP and
157
+ # MACHINE_TMP_BASE (the folder it was made in). The .ts modules run with
158
+ # TMPDIR set to MACHINE_TMP, so what tsx puts in its temporary folder goes
159
+ # when the folder is removed at exit.
160
+ machine_tmp_dir() {
161
+ local base
162
+ for base in "${TMPDIR:-/tmp}" /tmp; do
163
+ base="${base%/}"
164
+ [[ -n "$base" ]] || continue
165
+ if MACHINE_TMP=$(mktemp -d "${base}/$1.XXXXXX" 2>/dev/null); then
166
+ MACHINE_TMP_BASE="$base"
167
+ return 0
168
+ fi
169
+ done
170
+ return 1
171
+ }
172
+
173
+ version_at_least() { # version_at_least X.Y MAJOR MINOR
174
+ [[ "$1" =~ ^([0-9]+)\.([0-9]+) ]] || return 1
175
+ local maj=$((10#${BASH_REMATCH[1]})) min=$((10#${BASH_REMATCH[2]}))
176
+ (( maj > $2 || (maj == $2 && min >= $3) ))
177
+ }
178
+
179
+ # Node prints these when it cannot load a module at all (doctor's `modules`).
180
+ stage2_load_error() { # stage2_load_error STDERR_FILE → word on stdout
181
+ if grep -Eq 'Invalid package config|ERR_INVALID_PACKAGE_CONFIG' "$1" 2>/dev/null; then
182
+ echo invalid_package_config
183
+ elif grep -Eq 'ERR_MODULE_NOT_FOUND|Cannot find module|ERR_UNKNOWN_FILE_EXTENSION|ERR_REQUIRE_ESM|SyntaxError' "$1" 2>/dev/null; then
184
+ echo module_load_failed
185
+ fi
186
+ }
187
+
188
+ # ── ccs doctor [--json] ──
189
+ # Stage 1 (here, in bash): what must hold before any .ts module can load.
190
+ # Stage 2 (ccs-doctor.ts): the modules, better-sqlite3, repos.yml, the cache;
191
+ # it prints the whole answer. If stage 2 gives none, the answer is built here
192
+ # from stage 1. Exit 0 works, 1 does not, 2 bad arguments, 3 cannot tell.
193
+ run_doctor() {
194
+ local bad=0 a
195
+ DOCTOR_JSON=0
196
+ for a in "$@"; do
197
+ case "$a" in
198
+ --json) DOCTOR_JSON=1 ;;
199
+ *) bad=1 ;;
200
+ esac
201
+ done
202
+ if (( bad )); then
203
+ if (( DOCTOR_JSON )); then
204
+ printf '{%s,"installed":"installed","running":"not_applicable","reasons":[{"vocab":"ccs.cli","code":"unknown_option"}]}\n' \
205
+ "$(machine_outer doctor usage)"
206
+ fi
207
+ echo "[ccs] doctor takes only --json (usage: unknown_option)" >&2
208
+ exit 2
209
+ fi
210
+
211
+ if ! machine_tmp_dir ccs-doctor; then
212
+ if (( DOCTOR_JSON )); then
213
+ printf '{%s,"installed":"installed","running":"not_applicable","reasons":[{"vocab":"ccs.cli","code":"internal_error"}]}\n' \
214
+ "$(machine_outer doctor failed)"
215
+ fi
216
+ echo "[ccs] doctor: failed: internal_error — cannot create a temporary folder (TMPDIR or /tmp)" >&2
217
+ exit 1
218
+ fi
219
+ DOCTOR_TMP="$MACHINE_TMP"
220
+ MACHINE_CHILD=""
221
+ trap 'rm -rf "$DOCTOR_TMP"' EXIT
222
+ trap 'doctor_interrupted' INT
223
+ doctor_interrupted() {
224
+ [[ -z "$MACHINE_CHILD" ]] || kill -TERM "$MACHINE_CHILD" 2>/dev/null || true
225
+ if (( DOCTOR_JSON )); then
226
+ printf '{%s,"installed":"installed","running":"not_applicable","reasons":[{"vocab":"ccs.cli","code":"interrupted"}]}\n' \
227
+ "$(machine_outer doctor interrupted)"
228
+ fi
229
+ exit 130
230
+ }
231
+ local out="$DOCTOR_TMP/out" err="$DOCTOR_TMP/err" v
232
+
233
+ # node: present, and at least engines.node.
234
+ local c_node c_tsx c_fzf c_claude c_modules
235
+ if ! command -v node >/dev/null 2>&1; then
236
+ c_node="fail:node_missing"
237
+ else
238
+ run_timed "$CHECK_TIMEOUT" "$out" "$err" node --version
239
+ v=$(head -n 1 "$out" 2>/dev/null || true)
240
+ if (( TIMED_OUT )); then
241
+ c_node="unknown:check_timeout"
242
+ elif (( TIMED_RC == 0 )) && [[ "$v" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
243
+ v="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}"
244
+ if (( 10#${BASH_REMATCH[1]} < NODE_MIN_MAJOR )); then
245
+ c_node="fail:node_too_old:$v"
246
+ else
247
+ c_node="ok:ok:$v"
248
+ fi
249
+ else
250
+ c_node="unknown:check_crashed"
251
+ fi
252
+ fi
253
+
254
+ # tsx: the package's own, or one on PATH.
255
+ if [[ "$TSX" == "tsx" ]] && ! command -v tsx >/dev/null 2>&1; then
256
+ c_tsx="fail:tsx_missing"
257
+ else
258
+ c_tsx="ok:ok"
259
+ fi
260
+
261
+ # fzf: present and at least 0.42.0; under 0.45 group headers do not stay open.
262
+ if ! command -v fzf >/dev/null 2>&1; then
263
+ c_fzf="fail:fzf_missing"
264
+ else
265
+ run_timed "$CHECK_TIMEOUT" "$out" "$err" fzf --version
266
+ v=$(head -n 1 "$out" 2>/dev/null || true)
267
+ if (( TIMED_OUT )); then
268
+ c_fzf="unknown:check_timeout"
269
+ elif (( TIMED_RC == 0 )) && [[ "$v" =~ ^([0-9]+\.[0-9]+(\.[0-9]+)?) ]]; then
270
+ v="${BASH_REMATCH[1]}"
271
+ if ! version_at_least "$v" "${FZF_MIN[@]}"; then
272
+ c_fzf="fail:fzf_too_old:$v"
273
+ elif ! version_at_least "$v" "${FZF_HEADERS_MIN[@]}"; then
274
+ c_fzf="warn:fzf_too_old_for_headers:$v"
275
+ else
276
+ c_fzf="ok:ok:$v"
277
+ fi
278
+ else
279
+ c_fzf="unknown:check_crashed"
280
+ fi
281
+ fi
282
+
283
+ # claude: on PATH (never started).
284
+ if command -v claude >/dev/null 2>&1; then c_claude="ok:ok"; else c_claude="fail:claude_missing"; fi
285
+
286
+ # Stage 2, when Node and tsx can run it.
287
+ if [[ "$c_node" == ok:* && "$c_tsx" == ok:* ]]; then
288
+ local limit="$DOCTOR_STAGE2_TIMEOUT"
289
+ [[ "${CCS_DOCTOR_TIMEOUT:-}" =~ ^[1-9][0-9]{0,2}$ ]] && limit="$CCS_DOCTOR_TIMEOUT"
290
+ local mode=()
291
+ (( DOCTOR_JSON )) && mode=(--json)
292
+ run_timed "$limit" "$out" "$err" env TMPDIR="$DOCTOR_TMP" TSX_DISABLE_CACHE=1 \
293
+ "${TSX_RUN[@]}" "$CCS_DOCTOR" ${mode[@]+"${mode[@]}"} \
294
+ --node "$c_node" --tsx "$c_tsx" --fzf "$c_fzf" --claude "$c_claude"
295
+ if (( TIMED_OUT )); then
296
+ c_modules="unknown:check_timeout"
297
+ elif [[ "$TIMED_RC" =~ ^[013]$ && -s "$out" ]] && { (( ! DOCTOR_JSON )) || [[ "$(head -c 1 "$out")" == "{" ]]; }; then
298
+ cat "$out"
299
+ exit "$TIMED_RC"
300
+ else
301
+ v=$(stage2_load_error "$err")
302
+ if [[ -n "$v" ]]; then c_modules="fail:$v"; else c_modules="unknown:check_crashed"; fi
303
+ fi
304
+ else
305
+ c_modules="skipped:blocked"
306
+ fi
307
+
308
+ doctor_answer_from_stage1 "$DOCTOR_JSON" "$c_node" "$c_tsx" "$c_modules" "$c_fzf" "$c_claude"
309
+ }
310
+
311
+ # The answer without stage 2: stage 1's checks, `modules` as found, the rest
312
+ # skipped (blocked). Same rules as ccs-doctor.ts's decide().
313
+ doctor_answer_from_stage1() { # JSON NODE TSX MODULES FZF CLAUDE
314
+ local json=$1
315
+ shift
316
+ local names=(node tsx modules sqlite fzf claude config config_layout cache terminal groups_dir)
317
+ local values=("$1" "$2" "$3" "skipped:blocked" "$4" "$5" "skipped:blocked" "skipped:blocked" "skipped:blocked" "skipped:blocked" "skipped:blocked")
318
+ local required=" node tsx modules sqlite fzf claude config "
319
+ local i st code ver v checks="" fails="" unknowns="" rows=""
320
+ for i in "${!names[@]}"; do
321
+ IFS=: read -r st code ver <<< "${values[$i]}"
322
+ [[ -n "$checks" ]] && checks+=","
323
+ checks+="{\"name\":\"${names[$i]}\",\"status\":\"${st}\",\"code\":\"${code}\""
324
+ [[ -n "$ver" ]] && checks+=",\"version\":$(json_version "$ver")"
325
+ checks+="}"
326
+ if [[ "$required" == *" ${names[$i]} "* ]]; then
327
+ [[ "$st" == fail ]] && fails+="${fails:+,}{\"vocab\":\"ccs.health\",\"code\":\"${code}\"}"
328
+ [[ "$st" == unknown ]] && unknowns+="${unknowns:+,}{\"vocab\":\"ccs.health\",\"code\":\"${code}\"}"
329
+ fi
330
+ v="$(printf '%-14s%-9s%s' "${names[$i]}" "$st" "$([[ "$code" == ok ]] || echo "$code")")${ver:+ version=$ver}"
331
+ rows+=" ${v%"${v##*[! ]}"}"$'\n'
332
+ done
333
+ local status rc reasons
334
+ if [[ -n "$fails" ]]; then
335
+ status=failed rc=1 reasons="$fails"
336
+ elif [[ -n "$unknowns" ]]; then
337
+ status=unverifiable rc=3 reasons="$unknowns"
338
+ else
339
+ status=ok rc=0 reasons='{"vocab":"ccs.health","code":"ok"}'
340
+ fi
341
+ local os arch
342
+ os=$(uname -s 2>/dev/null | tr '[:upper:]' '[:lower:]')
343
+ arch=$(uname -m 2>/dev/null)
344
+ case "$arch" in
345
+ x86_64|amd64) arch=x64 ;;
346
+ aarch64) arch=arm64 ;;
347
+ esac
348
+ [[ "$os" =~ ^[a-z0-9_]{1,32}$ ]] || os=unknown
349
+ [[ "$arch" =~ ^[a-z0-9_]{1,32}$ ]] || arch=unknown
350
+ if [[ "$json" == 1 ]]; then
351
+ printf '{%s,"installed":"installed","running":"not_applicable","reasons":[%s],"checks":[%s],"platform":{"os":"%s","arch":"%s"}}\n' \
352
+ "$(machine_outer doctor "$status")" "$reasons" "$checks" "$os" "$arch"
353
+ else
354
+ case "$status" in
355
+ ok) echo "ccs doctor: ccs works (ok)" ;;
356
+ failed) echo "ccs doctor: ccs does not work (failed)" ;;
357
+ *) echo "ccs doctor: could not tell (${status})" ;;
358
+ esac
359
+ printf '%s' "$rows"
360
+ fi
361
+ exit "$rc"
362
+ }
363
+
364
+ # ── ccs list | sessions | repo | config | open ──
365
+ # One .ts entry (ccs-machine.ts) answers all of them. Its stdout is passed on
366
+ # as is; when it gives nothing (Node cannot load the modules), the answer is
367
+ # built here. stderr is held so Node's own dump (it names files) stays out.
368
+ run_machine() {
369
+ local word=$1
370
+ # `command` names the subcommand too (repo add, config get), known words only.
371
+ case "${1}:${2:-}" in
372
+ repo:add|repo:remove|config:get|config:set) word="$1 $2" ;;
373
+ esac
374
+ MACHINE_JSON=0
375
+ args_want_json "${@:2}" && MACHINE_JSON=1
376
+ if ! command -v node >/dev/null 2>&1; then
377
+ machine_fail "$word" "$MACHINE_JSON" missing_prerequisite 4 dependency_missing node
378
+ fi
379
+ if [[ "$TSX" == "tsx" ]] && ! command -v tsx >/dev/null 2>&1; then
380
+ machine_fail "$word" "$MACHINE_JSON" missing_prerequisite 4 dependency_missing tsx
381
+ fi
382
+ machine_tmp_dir ccs-machine ||
383
+ machine_fail "$word" "$MACHINE_JSON" failed 1 internal_error "" "cannot create a temporary folder (TMPDIR or /tmp)"
384
+ trap 'rm -rf "$MACHINE_TMP"' EXIT
385
+ MACHINE_ERR="$MACHINE_TMP/err"
386
+ # The child gets SIGINT too and answers it itself (130 and the outer object).
387
+ local rc=0 out="" got_int=0 load_error
388
+ trap 'got_int=1' INT
389
+ out=$(TMPDIR="$MACHINE_TMP" TSX_DISABLE_CACHE=1 "${TSX_RUN[@]}" "${CCS_MACHINE}" "$@" 2>"$MACHINE_ERR" </dev/null) || rc=$?
390
+ trap - INT
391
+ load_error=$(stage2_load_error "$MACHINE_ERR")
392
+ # The module answered: JSON on stdout, or (without --json) its own line.
393
+ if [[ -n "$out" ]] || { (( ! MACHINE_JSON )) && [[ -z "$load_error" ]] && (( ! got_int )); }; then
394
+ [[ -z "$out" ]] || printf '%s\n' "$out"
395
+ cat "$MACHINE_ERR" >&2
396
+ exit "$rc"
397
+ fi
398
+ if (( got_int )); then
399
+ machine_fail "$word" "$MACHINE_JSON" interrupted 130 interrupted
400
+ fi
401
+ if [[ "$load_error" == invalid_package_config ]]; then
402
+ machine_fail "$word" "$MACHINE_JSON" failed 1 invalid_package_config "" \
403
+ "a package.json above ccs cannot be parsed, so Node will not load ccs's modules; fix or remove that file"
404
+ fi
405
+ machine_fail "$word" "$MACHINE_JSON" failed 1 internal_error "" "${load_error:-no answer from the module}"
406
+ }
407
+
408
+ # ── Starting the launch command (the list and `ccs open --here`) ──
409
+ # command_is_safe CMD — the defense-in-depth copy of the policy ccs-config.ts
410
+ # enforces on `command` at load time (shell metacharacters, control chars).
411
+ command_is_safe() {
412
+ [[ ! "$1" =~ [\;\&\|\<\>\$\`\"\'\\] && ! "$1" =~ [[:cntrl:]] ]]
413
+ }
414
+ # run_launch_command KIND KEY CMD [ARGS...] — run CMD in the current folder:
415
+ # `new` as is, `resume` with --resume KEY. Direct invocation (NOT exec) so
416
+ # shell functions / aliases like `opr` work; CMD is split into words with no
417
+ # wildcard expansion.
418
+ run_launch_command() {
419
+ local kind=$1 key=$2 cmd=$3
420
+ shift 3
421
+ set -f
422
+ # shellcheck disable=SC2086
423
+ if [[ "$kind" == resume ]]; then
424
+ ${cmd} --resume "$key" "$@"
425
+ else
426
+ ${cmd} "$@"
427
+ fi
428
+ set +f
429
+ }
430
+
431
+ # ── ccs open (--repo NAME | --session UUID) [--here | --new-window] … ──
432
+ # ccs-open.ts decides (every check of the list, without its fallbacks); for
433
+ # --here it leaves the plan in a file and this shell starts the command in
434
+ # this terminal, after checking the plan once more. --new-window is done
435
+ # entirely by the module (it hands a one-shot .command file to the app).
436
+ run_open() {
437
+ MACHINE_JSON=0
438
+ args_want_json "$@" && MACHINE_JSON=1
439
+ if ! command -v node >/dev/null 2>&1; then
440
+ machine_fail open "$MACHINE_JSON" missing_prerequisite 4 dependency_missing node
441
+ fi
442
+ if [[ "$TSX" == "tsx" ]] && ! command -v tsx >/dev/null 2>&1; then
443
+ machine_fail open "$MACHINE_JSON" missing_prerequisite 4 dependency_missing tsx
444
+ fi
445
+ machine_tmp_dir ccs-open-plan ||
446
+ machine_fail open "$MACHINE_JSON" failed 1 internal_error "" "cannot create a temporary folder (TMPDIR or /tmp)"
447
+ OPEN_TMP="$MACHINE_TMP"
448
+ trap 'rm -rf "$OPEN_TMP"' EXIT
449
+ local tty=0 rc=0 out="" got_int=0 load_error a seen=0
450
+ [[ -t 0 && -t 1 ]] && tty=1
451
+ # Arguments after a bare `--` go to the launched command.
452
+ local pass=()
453
+ for a in "$@"; do
454
+ if (( seen )); then pass+=("$a"); elif [[ "$a" == "--" ]]; then seen=1; fi
455
+ done
456
+ trap 'got_int=1' INT
457
+ # The launcher file of --new-window must outlive this run: it goes to the
458
+ # folder OPEN_TMP was made in, not into OPEN_TMP (removed at exit).
459
+ out=$(CCS_OPEN_PLAN="$OPEN_TMP/plan" CCS_OPEN_TTY="$tty" CCS_OPEN_TMPDIR="$MACHINE_TMP_BASE" \
460
+ TMPDIR="$OPEN_TMP" TSX_DISABLE_CACHE=1 "${TSX_RUN[@]}" "${CCS_MACHINE}" open "$@" 2>"$OPEN_TMP/err" </dev/null) || rc=$?
461
+ trap - INT
462
+ load_error=$(stage2_load_error "$OPEN_TMP/err")
463
+ if [[ -n "$out" || -s "$OPEN_TMP/plan" ]] || { (( ! MACHINE_JSON )) && [[ -z "$load_error" ]] && (( ! got_int )); }; then
464
+ [[ -z "$out" ]] || printf '%s\n' "$out"
465
+ cat "$OPEN_TMP/err" >&2
466
+ (( rc == 0 )) && [[ -s "$OPEN_TMP/plan" ]] || exit "$rc"
467
+ else
468
+ (( got_int )) && machine_fail open "$MACHINE_JSON" interrupted 130 interrupted
469
+ [[ "$load_error" == invalid_package_config ]] &&
470
+ machine_fail open "$MACHINE_JSON" failed 1 invalid_package_config
471
+ machine_fail open "$MACHINE_JSON" failed 1 internal_error "" "${load_error:-no answer from the module}"
472
+ fi
473
+
474
+ # --here: the plan, checked again before anything runs.
475
+ local fields=() f
476
+ while IFS= read -r -d '' f; do fields+=("$f"); done < "$OPEN_TMP/plan"
477
+ (( ${#fields[@]} == 4 )) || machine_fail open "$MACHINE_JSON" failed 1 internal_error
478
+ local kind=${fields[0]} key=${fields[1]} cwd=${fields[2]} cmd=${fields[3]}
479
+ case "$kind" in
480
+ resume) [[ "$key" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]] ||
481
+ machine_fail open "$MACHINE_JSON" rejected 6 invalid_uuid ;;
482
+ new) command_is_safe "$key" || machine_fail open "$MACHINE_JSON" rejected 6 unsafe_characters ;;
483
+ *) machine_fail open "$MACHINE_JSON" failed 1 internal_error ;;
484
+ esac
485
+ command_is_safe "$cmd" || machine_fail open "$MACHINE_JSON" rejected 6 command_unsafe
486
+ if [[ ( "$cwd" != "$HOME" && "$cwd" != "$HOME"/* ) || "/${cwd}/" == */../* ]]; then
487
+ machine_fail open "$MACHINE_JSON" rejected 6 path_outside_home
488
+ fi
489
+ cd "$cwd" 2>/dev/null || machine_fail open "$MACHINE_JSON" rejected 6 path_not_found
490
+ rm -rf "$OPEN_TMP"
491
+ trap - EXIT
492
+ # The exit code from here on is the launched command's (outside the table).
493
+ run_launch_command "$kind" "$key" "$cmd" ${pass[@]+"${pass[@]}"}
494
+ exit 0
495
+ }
496
+
497
+ case "${1:-}" in
498
+ doctor) shift; run_doctor "$@" ;;
499
+ open) shift; run_open "$@" ;;
500
+ list|sessions|repo|config) run_machine "$@" ;;
501
+ esac
502
+
48
503
  # ── Clipboard helper (cross-platform) ────────────────────────────────────────
49
504
  clipboard_cmd() {
50
505
  if command -v pbcopy &>/dev/null; then
@@ -72,7 +527,6 @@ check_deps() {
72
527
  fi
73
528
  command -v node &>/dev/null || missing+=("node")
74
529
  command -v claude &>/dev/null || missing+=("claude (Claude Code CLI)")
75
- command -v tput &>/dev/null || missing+=("tput")
76
530
 
77
531
  if [[ ${#missing[@]} -gt 0 ]]; then
78
532
  echo "❌ Missing dependencies:" >&2
@@ -107,6 +561,35 @@ Usage:
107
561
  which to add to repos.yml via fzf multi-select
108
562
  ccs [options] Unknown options pass through to launched command
109
563
 
564
+ For scripts and tools (no fzf, no questions, no network; --json prints one
565
+ JSON answer on stdout, and the exit code says what happened):
566
+ ccs doctor [--json] Does ccs work here? Exit 0 works, 1 does not,
567
+ 2 bad arguments, 3 could not tell
568
+ ccs list --json [--with-paths]
569
+ repos.yml entries after ccs's checks (paths only
570
+ with --with-paths)
571
+ ccs sessions --json [--project NAME] [--limit N] [--with-titles] [--no-scan]
572
+ Past conversations per project, newest first
573
+ (titles only with --with-titles)
574
+ ccs repo add PATH [--name NAME] [--description TEXT] [--json]
575
+ ccs repo remove NAME [--json]
576
+ Add a folder under \$HOME to repos.yml, or take an
577
+ entry out (the folder is never touched)
578
+ ccs config get [--json]
579
+ ccs config set [--repo NAME] KEY VALUE [--json]
580
+ KEY is defaults.ui, defaults.terminal,
581
+ defaults.update_check, or label / description
582
+ of the entry named by --repo
583
+ ccs open (--repo NAME | --session UUID) [--here | --new-window]
584
+ Start Claude Code without the list: in this terminal
585
+ (--here, the default), or in a new window of
586
+ defaults.terminal (--new-window, macOS)
587
+ Exit codes: 0 ok, 1 failed, 2 bad arguments, 3 could not tell,
588
+ 4 missing prerequisite, 5 not found, 6 refused, 7 partly done,
589
+ 130 interrupted. doctor, list, sessions, repo, config and open are
590
+ commands only as the first argument; \`ccs --new doctor\` still passes
591
+ doctor on.
592
+
110
593
  Controls:
111
594
  Enter Launch (or resume) selected entry; does nothing on a
112
595
  group header (an entry with launch: false)
@@ -123,6 +606,10 @@ Environment:
123
606
  CCR_CMD Deprecated — still honored with a warning if CCS_CMD unset
124
607
  CCS_UI simple | classic — overrides repos.yml defaults.ui
125
608
  (the --ui= flag wins over both)
609
+ CCS_NO_UPDATE_CHECK Any non-empty value stops the update check and its notice
610
+ (no network request, the cache is not read); so does
611
+ defaults.update_check: false in repos.yml
612
+ CCS_UPDATE_REGISTRY Registry to ask instead of https://registry.npmjs.org
126
613
 
127
614
  Examples:
128
615
  ccs # Full launcher
@@ -132,7 +619,9 @@ Examples:
132
619
  ccs --dangerously-skip-permissions # Pass-through flag to claude
133
620
 
134
621
  Config: ~/.config/ccs/repos.yml
135
- Cache: ~/.cache/ccs/state.db
622
+ Cache: ~/.cache/ccs/state.db, ~/.cache/ccs/update-check
623
+
624
+ ${COPYRIGHT} MIT License.
136
625
  HELP
137
626
  }
138
627
 
@@ -157,13 +646,13 @@ if [[ "${1:-}" == "init" ]]; then
157
646
  fi
158
647
 
159
648
  if (( ! AUTO )); then
160
- "${TSX}" "${CCS_INIT}" scaffold
649
+ "${TSX_RUN[@]}" "${CCS_INIT}" scaffold
161
650
  exit $?
162
651
  fi
163
652
 
164
653
  check_deps
165
654
  echo "🔍 Scanning \$HOME for git repositories..." >&2
166
- CANDIDATES="$("${TSX}" "${CCS_INIT}" discover ${INIT_ARGS[@]+"${INIT_ARGS[@]}"})" || exit 1
655
+ CANDIDATES="$("${TSX_RUN[@]}" "${CCS_INIT}" discover ${INIT_ARGS[@]+"${INIT_ARGS[@]}"})" || exit 1
167
656
  if [[ -z "$CANDIDATES" ]]; then
168
657
  echo "No new git repositories found (everything within depth is already registered)." >&2
169
658
  exit 0
@@ -179,9 +668,9 @@ if [[ "${1:-}" == "init" ]]; then
179
668
  --tabstop=4 \
180
669
  ) || exit 0
181
670
  [[ -z "$SELECTED" ]] && exit 0
182
- printf '%s\n' "$SELECTED" | "${TSX}" "${CCS_INIT}" append || exit 1
671
+ printf '%s\n' "$SELECTED" | "${TSX_RUN[@]}" "${CCS_INIT}" append || exit 1
183
672
  # Index the new repos right away so the next `ccs` shows them with stats.
184
- "${TSX}" "${CCS_SCAN}" --force --quiet || {
673
+ "${TSX_RUN[@]}" "${CCS_SCAN}" --force --quiet || {
185
674
  echo "[ccs] scan failed — run \`ccs --refresh\` to index the new repos" >&2
186
675
  }
187
676
  exit 0
@@ -245,27 +734,46 @@ fi
245
734
  # it exactly as before (then the same "continuing with stale DB" line); the
246
735
  # simple look keeps it for after the picker closes, where it can be read.
247
736
  UI_CFG=""
737
+ UPDATE_CHECK_CFG="" # off = repos.yml defaults.update_check: false
248
738
  SCAN_RAN=0
249
739
  SCAN_FAILED=0
740
+ # The trap first, so a second mktemp that fails does not leave the first file.
741
+ SCAN_ERR_FILE="" LIST_ERR_FILE=""
742
+ trap 'rm -f ${SCAN_ERR_FILE:+"$SCAN_ERR_FILE"} ${LIST_ERR_FILE:+"$LIST_ERR_FILE"}' EXIT
250
743
  SCAN_ERR_FILE=$(mktemp "${TMPDIR:-/tmp}/ccs-scan-err.XXXXXX")
251
- trap 'rm -f "$SCAN_ERR_FILE"' EXIT
744
+ LIST_ERR_FILE=$(mktemp "${TMPDIR:-/tmp}/ccs-list-err.XXXXXX")
252
745
  if (( DO_REFRESH || ! NO_SCAN )); then
253
746
  SCAN_RAN=1
254
- SCAN_ARGS=(--quiet --print-ui)
747
+ SCAN_ARGS=(--quiet --print-settings)
255
748
  (( DO_REFRESH )) && SCAN_ARGS=(--force "${SCAN_ARGS[@]}")
256
- if SCAN_OUT=$("${TSX}" "${CCS_SCAN}" "${SCAN_ARGS[@]}" 2>"$SCAN_ERR_FILE"); then
749
+ if SCAN_OUT=$("${TSX_RUN[@]}" "${CCS_SCAN}" "${SCAN_ARGS[@]}" 2>"$SCAN_ERR_FILE"); then
257
750
  case "$SCAN_OUT" in
258
751
  *ui=simple*) UI_CFG="simple" ;;
259
752
  *ui=classic*) UI_CFG="classic" ;;
260
753
  esac
754
+ case "$SCAN_OUT" in
755
+ *update_check=off*) UPDATE_CHECK_CFG="off" ;;
756
+ *update_check=on*) UPDATE_CHECK_CFG="on" ;;
757
+ esac
261
758
  else
262
759
  SCAN_FAILED=1
263
760
  fi
264
761
  fi
265
762
 
763
+ # No scan this launch (--no-scan, or it failed): the settings the last scan
764
+ # stored, in one read — only when one of them is still needed.
266
765
  UI="${UI_REQ:-${UI_CFG}}"
267
- if [[ -z "$UI" ]]; then
268
- UI=$("${TSX}" "${CCS_LIST}" --print-ui 2>/dev/null) || UI="classic"
766
+ if [[ -z "$UI" || ( -z "$UPDATE_CHECK_CFG" && -z "${CCS_NO_UPDATE_CHECK:-}" ) ]]; then
767
+ SETTINGS_OUT=$("${TSX_RUN[@]}" "${CCS_LIST}" --print-settings 2>/dev/null) || SETTINGS_OUT=""
768
+ if [[ -z "$UI" ]]; then
769
+ case "$SETTINGS_OUT" in
770
+ *ui=simple*) UI="simple" ;;
771
+ *) UI="classic" ;;
772
+ esac
773
+ fi
774
+ case "$SETTINGS_OUT" in
775
+ *update_check=off*) UPDATE_CHECK_CFG="off" ;;
776
+ esac
269
777
  fi
270
778
  [[ "$UI" == "simple" ]] || UI="classic"
271
779
 
@@ -277,8 +785,73 @@ if [[ "$UI" == "simple" ]] && ! fzf_at_least 0 61; then
277
785
  UI="classic"
278
786
  fi
279
787
 
788
+ # Node takes a .ts module's type from the nearest package.json above it, and
789
+ # refuses to load any module when that file cannot be parsed. bin/package.json
790
+ # keeps the search inside the package, but a copy install (install.sh copies
791
+ # only ccs*) sits under someone else's directories. No rescan or refresh can
792
+ # get past that, so instead of an empty list and exit 0, ccs stops here with
793
+ # one line naming the file. The reason word (invalid_package_config) is the
794
+ # same in both looks, for scripts and the self-check to match on.
795
+ # text_is_plain TEXT — true when TEXT holds nothing a terminal acts on: no C0
796
+ # control or DEL, no C1 control (UTF-8 C2 80–9F), no mark that reorders text
797
+ # (U+061C, U+200E–F, U+202A–E, U+2066–9). Byte-wise (LC_ALL=C), so it is the
798
+ # same on every locale and bash 3.2.
799
+ text_is_plain() {
800
+ local LC_ALL=C
801
+ [[ ! "$1" =~ [[:cntrl:]] ]] || return 1
802
+ case "$1" in
803
+ *$'\xc2'[$'\x80'-$'\x9f']* | *$'\xd8\x9c'* | *$'\xe2\x80'[$'\x8e'$'\x8f'$'\xaa'-$'\xae']* | *$'\xe2\x81'[$'\xa6'-$'\xa9']*)
804
+ return 1 ;;
805
+ esac
806
+ }
807
+
808
+ stop_on_package_config() { # stop_on_package_config STDERR_FILE
809
+ local line bad="" found=0
810
+ local re='Invalid package config (.*package\.json)'
811
+ [[ -s "$1" ]] || return 0
812
+ while IFS= read -r line || [[ -n "$line" ]]; do
813
+ if [[ "$line" =~ $re ]]; then
814
+ found=1 bad="${BASH_REMATCH[1]}"
815
+ break
816
+ fi
817
+ [[ "$line" == *ERR_INVALID_PACKAGE_CONFIG* ]] && found=1
818
+ done < "$1"
819
+ (( found )) || return 0
820
+ # Only a package.json at or above ccs's own folder can stop the modules:
821
+ # they start from that folder (ccs-tsx.sh), never from the user's. The
822
+ # same words in another failure's message do not stop the list.
823
+ if [[ -n "$bad" && "$bad" == */package.json && "${SCRIPT_DIR}/" != "${bad%/package.json}/"* ]]; then
824
+ return 0
825
+ fi
826
+ # The path comes from Node's message, i.e. from folder names: one a terminal
827
+ # would act on (ESC, C1, text-direction marks) is not printed.
828
+ text_is_plain "$bad" || bad=""
829
+ if [[ "$UI" == "simple" ]]; then
830
+ echo "[ccs] 一覧を開けません(invalid_package_config): ${bad:-ccs より上のフォルダの package.json} が壊れていて、Node が ccs の部品を読めません。このファイルを直すか消してから、もう一度 ccs と打ってください。" >&2
831
+ else
832
+ echo "[ccs] cannot open the list (invalid_package_config): Node cannot parse ${bad:-a package.json above ccs}, so it will not load ccs's modules. Fix or remove that file, then run ccs again." >&2
833
+ fi
834
+ exit 1
835
+ }
836
+ (( SCAN_FAILED )) && stop_on_package_config "$SCAN_ERR_FILE"
837
+
838
+ # better-sqlite3 ships prebuilt modules for the common platforms; anywhere else
839
+ # one has to be built from source, which ccs does not do or test.
840
+ native_module_missing() {
841
+ grep -Eq 'Cannot find module .*better-sqlite3' "$SCAN_ERR_FILE" 2>/dev/null
842
+ }
843
+ print_native_module_hint() {
844
+ native_module_missing || return 0
845
+ if [[ "$UI" == "simple" ]]; then
846
+ echo "[ccs] この環境($(uname -s)-$(uname -m))向けの部品(better-sqlite3)が入っていません。ソースから作る必要があります。" >&2
847
+ else
848
+ echo "[ccs] better-sqlite3 has no prebuilt module for this platform ($(uname -s)-$(uname -m)); it has to be built from source." >&2
849
+ fi
850
+ }
851
+
280
852
  if [[ "$UI" == "classic" ]]; then
281
853
  cat "$SCAN_ERR_FILE" >&2
854
+ print_native_module_hint
282
855
  if (( SCAN_FAILED )); then
283
856
  if (( DO_REFRESH )); then
284
857
  echo "[ccs] refresh failed, continuing with stale DB" >&2
@@ -292,7 +865,9 @@ fi
292
865
  # the scan was quiet). Printed by print_scan_notes.
293
866
  print_scan_notes() {
294
867
  [[ "$UI" == "simple" ]] || return 0
295
- if (( SCAN_FAILED )); then
868
+ if native_module_missing; then
869
+ print_native_module_hint
870
+ elif (( SCAN_FAILED )); then
296
871
  echo "[ccs] 設定ファイル(~/.config/ccs/repos.yml)を読めなかったので、前の一覧を出しました。下の行を直してから、もう一度 ccs と打ってください。" >&2
297
872
  sed 's/^/ /' "$SCAN_ERR_FILE" >&2
298
873
  elif [[ -s "$SCAN_ERR_FILE" ]]; then
@@ -360,7 +935,7 @@ DELETE_BIND=(--bind "ctrl-d:execute(
360
935
  resume:*) bash '${CCS_DELETE}' \"\${kk#resume:}\" ;;
361
936
  new:*|group:*) echo '${MSG_NO_REPO_DELETE}'; read -r -n1 _ ;;
362
937
  esac
363
- )+reload('${TSX}' '${CCS_LIST}'${LIST_ARGS_STR})")
938
+ )+reload(bash '${CCS_TSX_SH}' '${CCS_LIST}'${LIST_ARGS_STR})")
364
939
 
365
940
  # enter / double-click on a group header (KIND=group, a repos.yml entry with
366
941
  # `launch: false`) keep the picker open instead of accepting the row. That
@@ -388,17 +963,118 @@ elif fzf_at_least 0 45; then
388
963
  fi
389
964
 
390
965
  # ctrl-r: force refresh
391
- REFRESH_BIND=(--bind "ctrl-r:execute('${TSX}' '${CCS_SCAN}' --force --quiet)+reload('${TSX}' '${CCS_LIST}'${LIST_ARGS_STR})")
966
+ REFRESH_BIND=(--bind "ctrl-r:execute(bash '${CCS_TSX_SH}' '${CCS_SCAN}' --force --quiet)+reload(bash '${CCS_TSX_SH}' '${CCS_LIST}'${LIST_ARGS_STR})")
392
967
 
393
968
  # ── Header (adaptive to terminal width) ──────────────────────────────────────
394
969
  # Note: ^D deletes SESSIONS only; repos are managed via ~/.config/ccs/repos.yml
395
- COLS=$(tput cols 2>/dev/null || echo 80)
970
+ # Asks the terminal itself: `tput cols 2>/dev/null` inside $() sees no terminal
971
+ # on stdout or stderr and answers 80 at any width. COLUMNS wins when exported.
972
+ terminal_cols() {
973
+ local size=""
974
+ if [[ "${COLUMNS:-}" =~ ^[1-9][0-9]*$ ]]; then
975
+ echo "$COLUMNS"
976
+ return 0
977
+ fi
978
+ size=$(stty size 2>/dev/null </dev/tty) || size=""
979
+ size="${size##* }"
980
+ if [[ "$size" =~ ^[1-9][0-9]*$ ]]; then echo "$size"; else echo 80; fi
981
+ }
982
+ COLS=$(terminal_cols)
396
983
  if (( COLS >= 80 )); then
397
984
  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
985
  else
399
986
  HEADER=$'⏎launch ^Y:cmd ^I:ID ^R:refresh\n^D:del[resume] Esc:quit'
400
987
  fi
401
988
 
989
+ # ── Version and copyright (fzf border label) ─────────────────────────────────
990
+ # The frame already exists in both looks, so the label costs no list row, and
991
+ # change-header cannot replace it. fzf draws a label that is too long over the
992
+ # frame's corners, so take the longest form that fits: the title goes first, then
993
+ # the word Copyright, and the version stays. Computed once at start, like the
994
+ # header above. Separators are plain spaces: `·` and `→` are East Asian Ambiguous.
995
+ # label_fits: 2 for the corner and first dash on the left, 2 on the right so
996
+ # the label never touches the closing corner.
997
+ label_fits() { (( COLS >= $1 + 4 )); }
998
+ VERSION_TAG="ccs v${VERSION}"
999
+ COPYRIGHT_SHORT='© INDIGO GROUP LLC.'
1000
+ TITLE='Claude Code をはじめる'
1001
+ TAG_W=${#VERSION_TAG} COPYRIGHT_W=29 COPYRIGHT_SHORT_W=19 TITLE_W=22
1002
+ if [[ "$UI" == "simple" ]]; then
1003
+ PAD=' '
1004
+ else
1005
+ PAD=' '
1006
+ fi
1007
+ PAD_W=$(( ${#PAD} * 2 ))
1008
+ if [[ "$UI" == "simple" ]] && label_fits $(( PAD_W + TITLE_W + 2 + TAG_W + 2 + COPYRIGHT_W )); then
1009
+ BORDER_LABEL="${PAD}${TITLE} ${VERSION_TAG} ${COPYRIGHT}${PAD}"
1010
+ elif label_fits $(( PAD_W + TAG_W + 2 + COPYRIGHT_W )); then
1011
+ BORDER_LABEL="${PAD}${VERSION_TAG} ${COPYRIGHT}${PAD}"
1012
+ elif label_fits $(( PAD_W + TAG_W + 2 + COPYRIGHT_SHORT_W )); then
1013
+ BORDER_LABEL="${PAD}${VERSION_TAG} ${COPYRIGHT_SHORT}${PAD}"
1014
+ else
1015
+ BORDER_LABEL="${PAD}${VERSION_TAG}${PAD}"
1016
+ fi
1017
+
1018
+ # ── Update notice ────────────────────────────────────────────────────────────
1019
+ # Startup only reads the cache (no process but `date`). When the cache is a day
1020
+ # old, ccs-update-check.ts refreshes it in the background for the NEXT launch:
1021
+ # never waited for, silent, with its own time limit. Only a plain X.Y.Z on both
1022
+ # sides compares, so a prerelease or a dev checkout gets no notice, and the
1023
+ # cache's text is safe to put in an fzf option. Set UPDATE_LATEST when the npm
1024
+ # latest is newer than this version.
1025
+ UPDATE_LATEST=""
1026
+ UPDATE_INTERVAL=86400
1027
+ version_is_newer() { # version_is_newer CANDIDATE CURRENT (both X.Y.Z)
1028
+ local a b ai bi i
1029
+ IFS=. read -r -a a <<< "$1"
1030
+ IFS=. read -r -a b <<< "$2"
1031
+ for i in 0 1 2; do
1032
+ ai=$((10#${a[$i]})) bi=$((10#${b[$i]}))
1033
+ (( ai > bi )) && return 0
1034
+ (( ai < bi )) && return 1
1035
+ done
1036
+ return 1
1037
+ }
1038
+ check_for_update() {
1039
+ [[ -z "${CCS_NO_UPDATE_CHECK:-}" ]] || return 0
1040
+ # repos.yml defaults.update_check: false — the same stop (no network, the
1041
+ # cache is not read, no notice).
1042
+ [[ "$UPDATE_CHECK_CFG" != "off" ]] || return 0
1043
+ local plain='^[0-9]{1,9}\.[0-9]{1,9}\.[0-9]{1,9}$'
1044
+ [[ "$VERSION" =~ $plain ]] || return 0
1045
+
1046
+ local cache="${XDG_CACHE_HOME:-$HOME/.cache}/ccs/update-check"
1047
+ local key value latest="" checked=0
1048
+ if [[ -f "$cache" && -r "$cache" ]]; then
1049
+ while IFS='=' read -r key value || [[ -n "$key" ]]; do
1050
+ case "$key" in
1051
+ latest) latest="$value" ;;
1052
+ checked) checked="$value" ;;
1053
+ esac
1054
+ done < "$cache"
1055
+ fi
1056
+ if [[ "$latest" =~ $plain ]] && version_is_newer "$latest" "$VERSION"; then
1057
+ UPDATE_LATEST="$latest"
1058
+ fi
1059
+
1060
+ # stdout not a terminal = a script or a test, not someone at the picker.
1061
+ [[ -t 1 ]] || return 0
1062
+ [[ "$checked" =~ ^[0-9]{1,12}$ ]] || checked=0
1063
+ local age=$(( $(date +%s) - 10#$checked ))
1064
+ if (( age >= UPDATE_INTERVAL || age < 0 )); then
1065
+ ( "${TSX_RUN[@]}" "${CCS_UPDATE}" "${CCS_PACKAGE}" </dev/null >/dev/null 2>&1 & )
1066
+ fi
1067
+ return 0
1068
+ }
1069
+ check_for_update || true
1070
+
1071
+ # simple: the frame says a new version is out; how to get it is printed once the
1072
+ # picker has closed, where it can be read and copied.
1073
+ print_update_note() {
1074
+ [[ -n "$UPDATE_LATEST" ]] || return 0
1075
+ echo "[ccs] 新しい ccs v${UPDATE_LATEST} が出ています(いまは v${VERSION})。上げるには: ${CCS_INSTALL_CMD}" >&2
1076
+ }
1077
+
402
1078
  # ── Launch fzf (simple) ──────────────────────────────────────────────────────
403
1079
  # Same rows, same bindings and the same post-pick path as the classic look;
404
1080
  # only the frame, the words and where the badges live differ (the list shows
@@ -415,14 +1091,22 @@ fi
415
1091
  # every edit goes back to the top match instead
416
1092
  # result:…down the cursor never rests on a heading row after the list
417
1093
  # (re)loads or a filter finishes
1094
+ # zero/result with no match fzf fires zero, result, focus, in that order,
1095
+ # and focus would put the guide line back over the no-match
1096
+ # message. So zero unbinds focus and result rebinds it once
1097
+ # there are rows; the result binding already runs bash, so a
1098
+ # cursor move still costs no process
418
1099
  if [[ "$UI" == "simple" ]]; then
419
1100
  SIMPLE_HEADER=' Enter: ひらく ↑↓ かマウス: えらぶ Esc: やめる'
420
1101
  LIST_LABEL=' えらぶ '
421
1102
  if (( SCAN_FAILED )); then
422
1103
  LIST_LABEL=' えらぶ ⚠️ 設定ファイルを読めませんでした(Esc でやめると理由が出ます) '
1104
+ elif [[ -n "$UPDATE_LATEST" ]] && (( COLS >= 44 )); then
1105
+ LIST_LABEL=" えらぶ ccs v${UPDATE_LATEST} が出ています "
423
1106
  fi
424
1107
  LIST_FAILED=0
425
- ROWS=$("${TSX}" "${CCS_LIST}" "${LIST_FLAGS[@]}" 2>/dev/null) || LIST_FAILED=1
1108
+ ROWS=$("${TSX_RUN[@]}" "${CCS_LIST}" "${LIST_FLAGS[@]}" 2>"$LIST_ERR_FILE") || LIST_FAILED=1
1109
+ (( LIST_FAILED )) && stop_on_package_config "$LIST_ERR_FILE"
426
1110
  # A bash match, not `printf | grep -q`: grep -q exits at the first hit,
427
1111
  # printf then dies of SIGPIPE, and pipefail turns that into "no rows".
428
1112
  if [[ "$ROWS" != *$'\t'new:* && "$ROWS" != *$'\t'resume:* ]]; then
@@ -450,25 +1134,26 @@ if [[ "$UI" == "simple" ]]; then
450
1134
  --with-nth=1 \
451
1135
  --no-sort --no-hscroll \
452
1136
  --style=full:rounded --border=rounded \
453
- --border-label=' Claude Code をはじめる ' --border-label-pos=3 \
1137
+ --border-label="${BORDER_LABEL}" --border-label-pos=3 \
454
1138
  --list-label="${LIST_LABEL}" --input-label=' さがす ' --preview-label=' 中身 ' \
455
1139
  --header="${SIMPLE_HEADER}" --header-border=none \
456
1140
  --ghost='ここに文字を打つと、しぼりこめます' \
457
1141
  --prompt='🔎 ' --pointer='▶' --highlight-line \
458
1142
  --info=inline-right --layout=reverse --height=100% \
459
1143
  --color='label:blue:bold,border:gray,header:gray,pointer:blue,hl:red,hl+:red' \
460
- --preview="'${TSX}' '${CCS_PREVIEW}' {4} {5} --ui=simple" \
1144
+ --preview="bash '${CCS_TSX_SH}' '${CCS_PREVIEW}' {4} {5} --ui=simple" \
461
1145
  --preview-window='right,50%,wrap,<50(down,40%,wrap)' \
462
1146
  "${CLIP_BINDS[@]+"${CLIP_BINDS[@]}"}" \
463
1147
  "${DELETE_BIND[@]}" \
464
1148
  "${REFRESH_BIND[@]}" \
465
1149
  "${GROUP_BINDS[@]}" \
466
1150
  --bind "focus:change-header(${SIMPLE_HEADER})" \
467
- --bind "zero:change-header(${MSG_NO_MATCH})" \
1151
+ --bind "zero:unbind(focus)+change-header(${MSG_NO_MATCH})" \
468
1152
  --bind "change:first" \
469
- --bind "result:transform:bash -c 'case \$1 in separator:*) echo down ;; esac' _ {4}" \
1153
+ --bind "result:transform:bash -c 'case \$1 in separator:*) echo \"rebind(focus)+down\" ;; ?*) echo \"rebind(focus)\" ;; esac' _ {4}" \
470
1154
  ) || FZF_RC=$?
471
1155
  print_scan_notes
1156
+ print_update_note
472
1157
  if (( FZF_RC != 0 )) || [[ -z "$SELECTED" ]]; then
473
1158
  if (( FZF_RC == 2 )); then
474
1159
  echo "[ccs] 一覧をうまく開けませんでした。今の見た目で開くには: ccs --ui=classic" >&2
@@ -481,12 +1166,29 @@ fi
481
1166
 
482
1167
  # ── Launch fzf ───────────────────────────────────────────────────────────────
483
1168
  if [[ "$UI" == "classic" ]]; then
484
- SELECTED=$("${TSX}" "${CCS_LIST}" "${LIST_FLAGS[@]+"${LIST_FLAGS[@]}"}" | fzf \
1169
+ # The picker opens below this line (--height), and the header beside the preview
1170
+ # is too narrow for the command, so the notice goes above the frame. It costs no
1171
+ # list row and stays in the scrollback after the picker closes.
1172
+ if [[ -n "$UPDATE_LATEST" ]]; then
1173
+ echo "[ccs] Update v${VERSION} -> v${UPDATE_LATEST}: ${CCS_INSTALL_CMD}" >&2
1174
+ fi
1175
+ # The rows are read before fzf starts, so a list that cannot load at all stops
1176
+ # with its reason instead of opening empty. Any other failure prints what the
1177
+ # list said and opens with the rows it gave (each one already checked by
1178
+ # ccs-list, and checked again below before anything runs), so a row picked
1179
+ # from them launches. Until 0.4.x the list was piped into fzf and such a
1180
+ # failure ended ccs without launching, even after a pick.
1181
+ ROWS=""
1182
+ if ! ROWS=$("${TSX_RUN[@]}" "${CCS_LIST}" "${LIST_FLAGS[@]+"${LIST_FLAGS[@]}"}" 2>"$LIST_ERR_FILE"); then
1183
+ stop_on_package_config "$LIST_ERR_FILE"
1184
+ fi
1185
+ cat "$LIST_ERR_FILE" >&2
1186
+ SELECTED=$({ [[ -z "$ROWS" ]] || printf '%s\n' "$ROWS"; } | fzf \
485
1187
  --ansi \
486
1188
  --delimiter=$'\t' \
487
1189
  --with-nth=1..3 \
488
1190
  --header="${HEADER}" \
489
- --preview="'${TSX}' '${CCS_PREVIEW}' {4} {5}" \
1191
+ --preview="bash '${CCS_TSX_SH}' '${CCS_PREVIEW}' {4} {5}" \
490
1192
  --preview-window='right:55%:wrap' \
491
1193
  "${CLIP_BINDS[@]+"${CLIP_BINDS[@]}"}" \
492
1194
  "${DELETE_BIND[@]}" \
@@ -495,6 +1197,7 @@ SELECTED=$("${TSX}" "${CCS_LIST}" "${LIST_FLAGS[@]+"${LIST_FLAGS[@]}"}" | fzf \
495
1197
  --height='80%' \
496
1198
  --layout=reverse \
497
1199
  --border \
1200
+ --border-label="${BORDER_LABEL}" --border-label-pos=3 \
498
1201
  --prompt='ccs> ' \
499
1202
  --tabstop=4 \
500
1203
  ) || exit 0
@@ -533,6 +1236,13 @@ if [[ -z "$ROW_CMD" ]]; then
533
1236
  exit 1
534
1237
  fi
535
1238
 
1239
+ # Defense-in-depth copy of the policy ccs-config.ts enforces on command at load
1240
+ # time. Extra columns would also land here, joined by \x1f, and be refused.
1241
+ if ! command_is_safe "$ROW_CMD"; then
1242
+ echo "❌ Refusing the selected row's command (shell metacharacters/control chars)" >&2
1243
+ exit 1
1244
+ fi
1245
+
536
1246
  # ── Per-row command is authoritative ─────────────────────────────────────────
537
1247
  # CCS_CMD env is resolved by ccs-config.ts as a fallback for repos without
538
1248
  # explicit `command:` (priority: repos[].command > defaults.command > CCS_CMD > "claude").
@@ -569,8 +1279,8 @@ esac
569
1279
 
570
1280
  # Path validation (traversal prevention). Must be $HOME itself or a child
571
1281
  # 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
1282
+ # /Users/xEVIL (audit L-1). A `..` segment could walk back out of $HOME.
1283
+ if [[ ( "$ROW_CWD" != "$HOME" && "$ROW_CWD" != "$HOME"/* ) || "/${ROW_CWD}/" == */../* ]]; then
574
1284
  if [[ "$UI" == "simple" ]]; then
575
1285
  echo "⚠️ ホームの外のフォルダ(${ROW_CWD})は開けないので、いまいるフォルダで開きます" >&2
576
1286
  else
@@ -605,8 +1315,8 @@ tilde_pwd() {
605
1315
  }
606
1316
 
607
1317
  # ── Execute ──────────────────────────────────────────────────────────────────
608
- # Direct invocation (NOT exec) so shell functions / aliases like `opr` work.
609
- # shellcheck disable=SC2086
1318
+ # run_launch_command: direct invocation (NOT exec), ROW_CMD split into words
1319
+ # with no wildcard expansion — the same path `ccs open --here` takes.
610
1320
  case "$KIND" in
611
1321
  resume)
612
1322
  if [[ "$UI" == "simple" ]]; then
@@ -614,7 +1324,6 @@ case "$KIND" in
614
1324
  else
615
1325
  echo "🔄 Resuming session ${KEY} in $(pwd)..."
616
1326
  fi
617
- ${ROW_CMD} --resume "${KEY}" "${PASS_ARGS[@]+"${PASS_ARGS[@]}"}"
618
1327
  ;;
619
1328
  new)
620
1329
  if [[ "$UI" == "simple" ]]; then
@@ -622,6 +1331,6 @@ case "$KIND" in
622
1331
  else
623
1332
  echo "🚀 Launching ${KEY} in $(pwd)..."
624
1333
  fi
625
- ${ROW_CMD} "${PASS_ARGS[@]+"${PASS_ARGS[@]}"}"
626
1334
  ;;
627
1335
  esac
1336
+ run_launch_command "$KIND" "$KEY" "$ROW_CMD" "${PASS_ARGS[@]+"${PASS_ARGS[@]}"}"