@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/CHANGELOG.md +71 -0
- package/LICENSE +1 -1
- package/README.md +89 -5
- package/bin/ccs +739 -30
- 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 +121 -10
- package/bin/ccs-machine.ts +467 -0
- package/bin/ccs-open.ts +304 -0
- package/bin/ccs-preview-session.ts +14 -5
- package/bin/ccs-preview.ts +75 -6
- package/bin/ccs-repo.ts +433 -0
- package/bin/ccs-sanitize.ts +18 -4
- package/bin/ccs-scan.ts +81 -10
- package/bin/ccs-sessions.ts +174 -0
- package/bin/ccs-tsx.sh +16 -0
- package/bin/ccs-update-check.ts +131 -0
- package/bin/ccs-utils.ts +5 -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/docs/v0.2.0-review-notes.md +1 -1
- package/docs/v0.2.1-backlog.md +2 -2
- package/package.json +4 -3
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
|
-
|
|
22
|
-
|
|
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
|
-
"${
|
|
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="$("${
|
|
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" | "${
|
|
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
|
-
"${
|
|
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
|
-
|
|
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-
|
|
747
|
+
SCAN_ARGS=(--quiet --print-settings)
|
|
255
748
|
(( DO_REFRESH )) && SCAN_ARGS=(--force "${SCAN_ARGS[@]}")
|
|
256
|
-
if SCAN_OUT=$("${
|
|
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
|
-
|
|
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
|
|
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('${
|
|
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('${
|
|
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
|
-
|
|
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=$("${
|
|
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=
|
|
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="'${
|
|
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
|
-
|
|
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="'${
|
|
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
|
-
#
|
|
609
|
-
#
|
|
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[@]}"}"
|