@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/CHANGELOG.md +37 -0
- package/README.md +67 -1
- package/bin/ccs +572 -26
- package/bin/ccs-config-edit.ts +431 -0
- package/bin/ccs-config.ts +324 -44
- package/bin/ccs-delete.sh +2 -1
- package/bin/ccs-doctor.ts +432 -0
- package/bin/ccs-groups.ts +167 -0
- package/bin/ccs-init.ts +58 -14
- package/bin/ccs-list-json.ts +124 -0
- package/bin/ccs-list.ts +107 -7
- package/bin/ccs-machine.ts +467 -0
- package/bin/ccs-open.ts +304 -0
- package/bin/ccs-preview-session.ts +4 -1
- package/bin/ccs-preview.ts +68 -1
- package/bin/ccs-repo.ts +433 -0
- package/bin/ccs-scan.ts +41 -4
- package/bin/ccs-sessions.ts +174 -0
- package/bin/ccs-tsx.sh +16 -0
- package/bin/package.json +3 -0
- package/docs/design/machine-interface.md +860 -0
- package/docs/design/repos-yml-schema.md +225 -76
- package/package.json +1 -1
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
|
-
"${
|
|
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="$("${
|
|
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" | "${
|
|
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
|
-
"${
|
|
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
|
-
|
|
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-
|
|
747
|
+
SCAN_ARGS=(--quiet --print-settings)
|
|
277
748
|
(( DO_REFRESH )) && SCAN_ARGS=(--force "${SCAN_ARGS[@]}")
|
|
278
|
-
if SCAN_OUT=$("${
|
|
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
|
-
|
|
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('${
|
|
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('${
|
|
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
|
-
( "${
|
|
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=$("${
|
|
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="'${
|
|
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
|
-
|
|
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="'${
|
|
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
|
|
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
|
-
#
|
|
768
|
-
#
|
|
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[@]}"}"
|