@indigo-gr/claude-code-station 0.4.1 → 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
@@ -49,6 +53,14 @@ else
49
53
  TSX="tsx"
50
54
  fi
51
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
+
52
64
  CCS_LIST="${SCRIPT_DIR}/ccs-list.ts"
53
65
  CCS_SCAN="${SCRIPT_DIR}/ccs-scan.ts"
54
66
  CCS_PREVIEW="${SCRIPT_DIR}/ccs-preview.ts"
@@ -63,6 +75,431 @@ if [[ -n "${CCR_CMD:-}" && -z "${CCS_CMD:-}" ]]; then
63
75
  export CCS_CMD="${CCR_CMD}"
64
76
  fi
65
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
+
66
503
  # ── Clipboard helper (cross-platform) ────────────────────────────────────────
67
504
  clipboard_cmd() {
68
505
  if command -v pbcopy &>/dev/null; then
@@ -124,6 +561,35 @@ Usage:
124
561
  which to add to repos.yml via fzf multi-select
125
562
  ccs [options] Unknown options pass through to launched command
126
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
+
127
593
  Controls:
128
594
  Enter Launch (or resume) selected entry; does nothing on a
129
595
  group header (an entry with launch: false)
@@ -141,7 +607,8 @@ Environment:
141
607
  CCS_UI simple | classic — overrides repos.yml defaults.ui
142
608
  (the --ui= flag wins over both)
143
609
  CCS_NO_UPDATE_CHECK Any non-empty value stops the update check and its notice
144
- (no network request, the cache is not read)
610
+ (no network request, the cache is not read); so does
611
+ defaults.update_check: false in repos.yml
145
612
  CCS_UPDATE_REGISTRY Registry to ask instead of https://registry.npmjs.org
146
613
 
147
614
  Examples:
@@ -179,13 +646,13 @@ if [[ "${1:-}" == "init" ]]; then
179
646
  fi
180
647
 
181
648
  if (( ! AUTO )); then
182
- "${TSX}" "${CCS_INIT}" scaffold
649
+ "${TSX_RUN[@]}" "${CCS_INIT}" scaffold
183
650
  exit $?
184
651
  fi
185
652
 
186
653
  check_deps
187
654
  echo "🔍 Scanning \$HOME for git repositories..." >&2
188
- CANDIDATES="$("${TSX}" "${CCS_INIT}" discover ${INIT_ARGS[@]+"${INIT_ARGS[@]}"})" || exit 1
655
+ CANDIDATES="$("${TSX_RUN[@]}" "${CCS_INIT}" discover ${INIT_ARGS[@]+"${INIT_ARGS[@]}"})" || exit 1
189
656
  if [[ -z "$CANDIDATES" ]]; then
190
657
  echo "No new git repositories found (everything within depth is already registered)." >&2
191
658
  exit 0
@@ -201,9 +668,9 @@ if [[ "${1:-}" == "init" ]]; then
201
668
  --tabstop=4 \
202
669
  ) || exit 0
203
670
  [[ -z "$SELECTED" ]] && exit 0
204
- printf '%s\n' "$SELECTED" | "${TSX}" "${CCS_INIT}" append || exit 1
671
+ printf '%s\n' "$SELECTED" | "${TSX_RUN[@]}" "${CCS_INIT}" append || exit 1
205
672
  # Index the new repos right away so the next `ccs` shows them with stats.
206
- "${TSX}" "${CCS_SCAN}" --force --quiet || {
673
+ "${TSX_RUN[@]}" "${CCS_SCAN}" --force --quiet || {
207
674
  echo "[ccs] scan failed — run \`ccs --refresh\` to index the new repos" >&2
208
675
  }
209
676
  exit 0
@@ -267,27 +734,46 @@ fi
267
734
  # it exactly as before (then the same "continuing with stale DB" line); the
268
735
  # simple look keeps it for after the picker closes, where it can be read.
269
736
  UI_CFG=""
737
+ UPDATE_CHECK_CFG="" # off = repos.yml defaults.update_check: false
270
738
  SCAN_RAN=0
271
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
272
743
  SCAN_ERR_FILE=$(mktemp "${TMPDIR:-/tmp}/ccs-scan-err.XXXXXX")
273
- trap 'rm -f "$SCAN_ERR_FILE"' EXIT
744
+ LIST_ERR_FILE=$(mktemp "${TMPDIR:-/tmp}/ccs-list-err.XXXXXX")
274
745
  if (( DO_REFRESH || ! NO_SCAN )); then
275
746
  SCAN_RAN=1
276
- SCAN_ARGS=(--quiet --print-ui)
747
+ SCAN_ARGS=(--quiet --print-settings)
277
748
  (( DO_REFRESH )) && SCAN_ARGS=(--force "${SCAN_ARGS[@]}")
278
- 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
279
750
  case "$SCAN_OUT" in
280
751
  *ui=simple*) UI_CFG="simple" ;;
281
752
  *ui=classic*) UI_CFG="classic" ;;
282
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
283
758
  else
284
759
  SCAN_FAILED=1
285
760
  fi
286
761
  fi
287
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.
288
765
  UI="${UI_REQ:-${UI_CFG}}"
289
- if [[ -z "$UI" ]]; then
290
- 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
291
777
  fi
292
778
  [[ "$UI" == "simple" ]] || UI="classic"
293
779
 
@@ -299,6 +785,56 @@ if [[ "$UI" == "simple" ]] && ! fzf_at_least 0 61; then
299
785
  UI="classic"
300
786
  fi
301
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
+
302
838
  # better-sqlite3 ships prebuilt modules for the common platforms; anywhere else
303
839
  # one has to be built from source, which ccs does not do or test.
304
840
  native_module_missing() {
@@ -399,7 +935,7 @@ DELETE_BIND=(--bind "ctrl-d:execute(
399
935
  resume:*) bash '${CCS_DELETE}' \"\${kk#resume:}\" ;;
400
936
  new:*|group:*) echo '${MSG_NO_REPO_DELETE}'; read -r -n1 _ ;;
401
937
  esac
402
- )+reload('${TSX}' '${CCS_LIST}'${LIST_ARGS_STR})")
938
+ )+reload(bash '${CCS_TSX_SH}' '${CCS_LIST}'${LIST_ARGS_STR})")
403
939
 
404
940
  # enter / double-click on a group header (KIND=group, a repos.yml entry with
405
941
  # `launch: false`) keep the picker open instead of accepting the row. That
@@ -427,7 +963,7 @@ elif fzf_at_least 0 45; then
427
963
  fi
428
964
 
429
965
  # ctrl-r: force refresh
430
- 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})")
431
967
 
432
968
  # ── Header (adaptive to terminal width) ──────────────────────────────────────
433
969
  # Note: ^D deletes SESSIONS only; repos are managed via ~/.config/ccs/repos.yml
@@ -501,6 +1037,9 @@ version_is_newer() { # version_is_newer CANDIDATE CURRENT (both X.Y.Z)
501
1037
  }
502
1038
  check_for_update() {
503
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
504
1043
  local plain='^[0-9]{1,9}\.[0-9]{1,9}\.[0-9]{1,9}$'
505
1044
  [[ "$VERSION" =~ $plain ]] || return 0
506
1045
 
@@ -523,7 +1062,7 @@ check_for_update() {
523
1062
  [[ "$checked" =~ ^[0-9]{1,12}$ ]] || checked=0
524
1063
  local age=$(( $(date +%s) - 10#$checked ))
525
1064
  if (( age >= UPDATE_INTERVAL || age < 0 )); then
526
- ( "${TSX}" "${CCS_UPDATE}" "${CCS_PACKAGE}" </dev/null >/dev/null 2>&1 & )
1065
+ ( "${TSX_RUN[@]}" "${CCS_UPDATE}" "${CCS_PACKAGE}" </dev/null >/dev/null 2>&1 & )
527
1066
  fi
528
1067
  return 0
529
1068
  }
@@ -566,7 +1105,8 @@ if [[ "$UI" == "simple" ]]; then
566
1105
  LIST_LABEL=" えらぶ ccs v${UPDATE_LATEST} が出ています "
567
1106
  fi
568
1107
  LIST_FAILED=0
569
- 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"
570
1110
  # A bash match, not `printf | grep -q`: grep -q exits at the first hit,
571
1111
  # printf then dies of SIGPIPE, and pipefail turns that into "no rows".
572
1112
  if [[ "$ROWS" != *$'\t'new:* && "$ROWS" != *$'\t'resume:* ]]; then
@@ -601,7 +1141,7 @@ if [[ "$UI" == "simple" ]]; then
601
1141
  --prompt='🔎 ' --pointer='▶' --highlight-line \
602
1142
  --info=inline-right --layout=reverse --height=100% \
603
1143
  --color='label:blue:bold,border:gray,header:gray,pointer:blue,hl:red,hl+:red' \
604
- --preview="'${TSX}' '${CCS_PREVIEW}' {4} {5} --ui=simple" \
1144
+ --preview="bash '${CCS_TSX_SH}' '${CCS_PREVIEW}' {4} {5} --ui=simple" \
605
1145
  --preview-window='right,50%,wrap,<50(down,40%,wrap)' \
606
1146
  "${CLIP_BINDS[@]+"${CLIP_BINDS[@]}"}" \
607
1147
  "${DELETE_BIND[@]}" \
@@ -632,12 +1172,23 @@ if [[ "$UI" == "classic" ]]; then
632
1172
  if [[ -n "$UPDATE_LATEST" ]]; then
633
1173
  echo "[ccs] Update v${VERSION} -> v${UPDATE_LATEST}: ${CCS_INSTALL_CMD}" >&2
634
1174
  fi
635
- SELECTED=$("${TSX}" "${CCS_LIST}" "${LIST_FLAGS[@]+"${LIST_FLAGS[@]}"}" | fzf \
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 \
636
1187
  --ansi \
637
1188
  --delimiter=$'\t' \
638
1189
  --with-nth=1..3 \
639
1190
  --header="${HEADER}" \
640
- --preview="'${TSX}' '${CCS_PREVIEW}' {4} {5}" \
1191
+ --preview="bash '${CCS_TSX_SH}' '${CCS_PREVIEW}' {4} {5}" \
641
1192
  --preview-window='right:55%:wrap' \
642
1193
  "${CLIP_BINDS[@]+"${CLIP_BINDS[@]}"}" \
643
1194
  "${DELETE_BIND[@]}" \
@@ -687,7 +1238,7 @@ fi
687
1238
 
688
1239
  # Defense-in-depth copy of the policy ccs-config.ts enforces on command at load
689
1240
  # time. Extra columns would also land here, joined by \x1f, and be refused.
690
- if [[ "$ROW_CMD" =~ [\;\&\|\<\>\$\`\"\'\\] || "$ROW_CMD" =~ [[:cntrl:]] ]]; then
1241
+ if ! command_is_safe "$ROW_CMD"; then
691
1242
  echo "❌ Refusing the selected row's command (shell metacharacters/control chars)" >&2
692
1243
  exit 1
693
1244
  fi
@@ -764,8 +1315,8 @@ tilde_pwd() {
764
1315
  }
765
1316
 
766
1317
  # ── Execute ──────────────────────────────────────────────────────────────────
767
- # Direct invocation (NOT exec) so shell functions / aliases like `opr` work.
768
- # 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.
769
1320
  case "$KIND" in
770
1321
  resume)
771
1322
  if [[ "$UI" == "simple" ]]; then
@@ -773,9 +1324,6 @@ case "$KIND" in
773
1324
  else
774
1325
  echo "🔄 Resuming session ${KEY} in $(pwd)..."
775
1326
  fi
776
- set -f # split ROW_CMD into words, but expand no wildcard in it
777
- ${ROW_CMD} --resume "${KEY}" "${PASS_ARGS[@]+"${PASS_ARGS[@]}"}"
778
- set +f
779
1327
  ;;
780
1328
  new)
781
1329
  if [[ "$UI" == "simple" ]]; then
@@ -783,8 +1331,6 @@ case "$KIND" in
783
1331
  else
784
1332
  echo "🚀 Launching ${KEY} in $(pwd)..."
785
1333
  fi
786
- set -f # split ROW_CMD into words, but expand no wildcard in it
787
- ${ROW_CMD} "${PASS_ARGS[@]+"${PASS_ARGS[@]}"}"
788
- set +f
789
1334
  ;;
790
1335
  esac
1336
+ run_launch_command "$KIND" "$KEY" "$ROW_CMD" "${PASS_ARGS[@]+"${PASS_ARGS[@]}"}"