claude-code-station 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/ccs ADDED
@@ -0,0 +1,376 @@
1
+ #!/usr/bin/env bash
2
+ # ccs - Claude Code Station: fzf-powered launcher (NEW repos + RESUME sessions)
3
+ # https://github.com/indigo-gr/claude-code-station
4
+
5
+ set -euo pipefail
6
+
7
+ VERSION="0.2.0"
8
+
9
+ # Resolve $0 through symlinks before taking dirname: `npm install -g
10
+ # github:indigo-gr/claude-code-station` links this script into the global
11
+ # bin dir, and the SYMLINK's dirname has none of the sibling .ts modules.
12
+ SOURCE="$0"
13
+ while [[ -L "$SOURCE" ]]; do
14
+ DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
15
+ SOURCE="$(readlink "$SOURCE")"
16
+ if [[ "$SOURCE" != /* ]]; then SOURCE="${DIR}/${SOURCE}"; fi
17
+ done
18
+ SCRIPT_DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
19
+
20
+ # Prefer a package-local tsx over the PATH one: the npm install brings tsx
21
+ # as a dependency (../node_modules from the package's bin/), and the copy
22
+ # install (install.sh) symlinks node_modules next to the scripts. Fall back
23
+ # to a globally installed tsx for the bare-checkout workflow.
24
+ if [[ -x "${SCRIPT_DIR}/../node_modules/.bin/tsx" ]]; then
25
+ TSX="${SCRIPT_DIR}/../node_modules/.bin/tsx"
26
+ elif [[ -x "${SCRIPT_DIR}/node_modules/.bin/tsx" ]]; then
27
+ TSX="${SCRIPT_DIR}/node_modules/.bin/tsx"
28
+ else
29
+ TSX="tsx"
30
+ fi
31
+
32
+ CCS_LIST="${SCRIPT_DIR}/ccs-list.ts"
33
+ CCS_SCAN="${SCRIPT_DIR}/ccs-scan.ts"
34
+ CCS_PREVIEW="${SCRIPT_DIR}/ccs-preview.ts"
35
+ CCS_DELETE="${SCRIPT_DIR}/ccs-delete.sh"
36
+ CCS_INIT="${SCRIPT_DIR}/ccs-init.ts"
37
+
38
+ # ── CCR_CMD -> CCS_CMD migration shim ────────────────────────────────────────
39
+ if [[ -n "${CCR_CMD:-}" && -z "${CCS_CMD:-}" ]]; then
40
+ echo "[ccs] CCR_CMD is deprecated, please rename to CCS_CMD (using CCR_CMD value for now)" >&2
41
+ export CCS_CMD="${CCR_CMD}"
42
+ fi
43
+
44
+ # ── Clipboard helper (cross-platform) ────────────────────────────────────────
45
+ clipboard_cmd() {
46
+ if command -v pbcopy &>/dev/null; then
47
+ echo "pbcopy"
48
+ elif command -v xclip &>/dev/null; then
49
+ echo "xclip -selection clipboard"
50
+ elif command -v xsel &>/dev/null; then
51
+ echo "xsel --clipboard --input"
52
+ elif command -v wl-copy &>/dev/null; then
53
+ echo "wl-copy"
54
+ else
55
+ echo ""
56
+ fi
57
+ }
58
+ CLIP_CMD=$(clipboard_cmd)
59
+
60
+ # ── Dependency check ─────────────────────────────────────────────────────────
61
+ check_deps() {
62
+ local missing=()
63
+ command -v fzf &>/dev/null || missing+=("fzf")
64
+ # tsx: a resolved package-local binary counts; only require a PATH tsx
65
+ # when no local one was found.
66
+ if [[ "$TSX" == "tsx" ]]; then
67
+ command -v tsx &>/dev/null || missing+=("tsx (npm install -g tsx)")
68
+ fi
69
+ command -v node &>/dev/null || missing+=("node")
70
+ command -v claude &>/dev/null || missing+=("claude (Claude Code CLI)")
71
+ command -v tput &>/dev/null || missing+=("tput")
72
+
73
+ if [[ ${#missing[@]} -gt 0 ]]; then
74
+ echo "❌ Missing dependencies:" >&2
75
+ for dep in "${missing[@]}"; do
76
+ echo " - $dep" >&2
77
+ done
78
+ echo "" >&2
79
+ echo "Install with:" >&2
80
+ echo " brew install fzf # or: apt install fzf" >&2
81
+ echo " npm install -g tsx" >&2
82
+ exit 1
83
+ fi
84
+ }
85
+
86
+ # ── Help / version ───────────────────────────────────────────────────────────
87
+ print_help() {
88
+ cat <<HELP
89
+ ccs v${VERSION} - Claude Code Station (fzf-powered launcher)
90
+
91
+ Usage:
92
+ ccs Mixed list: NEW repos + RESUME sessions
93
+ ccs . Sessions whose cwd is (under) current dir
94
+ ccs --new Only NEW repo entries
95
+ ccs --resume Only past sessions
96
+ ccs --refresh Force pre-scan before showing list
97
+ ccs --no-scan Skip implicit pre-scan (use stale DB)
98
+ ccs init Create ~/.config/ccs/repos.yml from the template
99
+ ccs init --auto-discover [--depth N]
100
+ Scan \$HOME for git repos (default depth 5) and pick
101
+ which to add to repos.yml via fzf multi-select
102
+ ccs [options] Unknown options pass through to launched command
103
+
104
+ Controls:
105
+ Enter Launch (or resume) selected entry
106
+ Ctrl-Y Copy the effective shell command to clipboard
107
+ Ctrl-I Copy session UUID (resume) or repo path (new)
108
+ Ctrl-R Refresh DB and reload list
109
+ Ctrl-D Delete session (resume rows only)
110
+ Esc / Ctrl-C Cancel
111
+
112
+ Environment:
113
+ CCS_CMD Fallback launch command for repos with no explicit
114
+ command. Per-repo command in repos.yml always wins.
115
+ Example: export CCS_CMD="opr claude"
116
+ CCR_CMD Deprecated — still honored with a warning if CCS_CMD unset
117
+
118
+ Examples:
119
+ ccs # Full launcher
120
+ ccs . # Resume current-dir session
121
+ ccs --new # Pick a repo to launch fresh
122
+ ccs --refresh # Force DB rebuild
123
+ ccs --dangerously-skip-permissions # Pass-through flag to claude
124
+
125
+ Config: ~/.config/ccs/repos.yml
126
+ Cache: ~/.cache/ccs/state.db
127
+ HELP
128
+ }
129
+
130
+ # ── init subcommand ──────────────────────────────────────────────────────────
131
+ # `ccs init` scaffolds the config; `ccs init --auto-discover [--depth N]`
132
+ # scans $HOME for git repos and appends fzf-selected ones to repos.yml.
133
+ # Handled before the launcher arg loop — init never opens the main list.
134
+ if [[ "${1:-}" == "init" ]]; then
135
+ shift
136
+ AUTO=0
137
+ INIT_ARGS=()
138
+ for a in "$@"; do
139
+ case "$a" in
140
+ --auto-discover) AUTO=1 ;;
141
+ *) INIT_ARGS+=("$a") ;;
142
+ esac
143
+ done
144
+
145
+ if [[ "$TSX" == "tsx" ]] && ! command -v tsx &>/dev/null; then
146
+ echo "❌ tsx not found (npm install -g tsx)" >&2
147
+ exit 1
148
+ fi
149
+
150
+ if (( ! AUTO )); then
151
+ "${TSX}" "${CCS_INIT}" scaffold
152
+ exit $?
153
+ fi
154
+
155
+ check_deps
156
+ echo "🔍 Scanning \$HOME for git repositories..." >&2
157
+ CANDIDATES="$("${TSX}" "${CCS_INIT}" discover ${INIT_ARGS[@]+"${INIT_ARGS[@]}"})" || exit 1
158
+ if [[ -z "$CANDIDATES" ]]; then
159
+ echo "No new git repositories found (everything within depth is already registered)." >&2
160
+ exit 0
161
+ fi
162
+ SELECTED=$(printf '%s\n' "$CANDIDATES" | fzf \
163
+ --multi \
164
+ --delimiter=$'\t' \
165
+ --header=$'TAB: toggle | Enter: add selected to repos.yml | Esc: cancel' \
166
+ --prompt='ccs init> ' \
167
+ --height='80%' \
168
+ --layout=reverse \
169
+ --border \
170
+ --tabstop=4 \
171
+ ) || exit 0
172
+ [[ -z "$SELECTED" ]] && exit 0
173
+ printf '%s\n' "$SELECTED" | "${TSX}" "${CCS_INIT}" append || exit 1
174
+ # Index the new repos right away so the next `ccs` shows them with stats.
175
+ "${TSX}" "${CCS_SCAN}" --force --quiet || {
176
+ echo "[ccs] scan failed — run \`ccs --refresh\` to index the new repos" >&2
177
+ }
178
+ exit 0
179
+ fi
180
+
181
+ # ── Argument parsing ─────────────────────────────────────────────────────────
182
+ LIST_FLAGS=()
183
+ PASS_ARGS=()
184
+ DO_REFRESH=0
185
+ NO_SCAN=0
186
+
187
+ for arg in "$@"; do
188
+ case "$arg" in
189
+ .) LIST_FLAGS+=("--current-only") ;;
190
+ --new) LIST_FLAGS+=("--repos-only") ;;
191
+ --resume) LIST_FLAGS+=("--sessions-only") ;;
192
+ --refresh) DO_REFRESH=1 ;;
193
+ --no-scan) NO_SCAN=1 ;;
194
+ --help|-h) print_help; exit 0 ;;
195
+ --version|-v) echo "ccs v${VERSION}"; exit 0 ;;
196
+ *) PASS_ARGS+=("$arg") ;;
197
+ esac
198
+ done
199
+
200
+ check_deps
201
+
202
+ # ── Pre-scan ─────────────────────────────────────────────────────────────────
203
+ if (( DO_REFRESH )); then
204
+ "${TSX}" "${CCS_SCAN}" --force --quiet || {
205
+ echo "[ccs] refresh failed, continuing with stale DB" >&2
206
+ }
207
+ elif (( ! NO_SCAN )); then
208
+ "${TSX}" "${CCS_SCAN}" --quiet || {
209
+ echo "[ccs] scan failed, continuing with stale DB" >&2
210
+ }
211
+ fi
212
+
213
+ # ── fzf bindings ─────────────────────────────────────────────────────────────
214
+ # Row layout (tab-separated columns):
215
+ # 1: LABEL 2: DESCRIPTION 3: BADGES 4: KIND:KEY 5: CWD 6: COMMAND
216
+
217
+ LIST_ARGS_STR=""
218
+ for f in "${LIST_FLAGS[@]+"${LIST_FLAGS[@]}"}"; do
219
+ LIST_ARGS_STR+=" ${f}"
220
+ done
221
+
222
+ CLIP_BINDS=()
223
+ if [[ -n "$CLIP_CMD" ]]; then
224
+ # ctrl-y: build "cd CWD && COMMAND [--resume UUID]" depending on kind ({4})
225
+ # Keep fzf open (no +abort) and briefly flash a header confirming the copy.
226
+ # cwd/uuid are %q-quoted: the clipboard line gets pasted into a live shell,
227
+ # so a tainted cwd must arrive as an inert literal, never as syntax (audit
228
+ # H-1 defense-in-depth — intake sanitization in ccs-scan is the first gate).
229
+ CLIP_BINDS+=(--bind "ctrl-y:execute-silent(
230
+ kk={4}; cwd={5}; cmd={6};
231
+ case \"\$kk\" in
232
+ resume:*) uuid=\${kk#resume:}; printf 'cd %q && %s --resume %q' \"\$cwd\" \"\$cmd\" \"\$uuid\" | ${CLIP_CMD} ;;
233
+ new:*) printf 'cd %q && %s' \"\$cwd\" \"\$cmd\" | ${CLIP_CMD} ;;
234
+ esac
235
+ )+change-header(📋 Copied command to clipboard — ⏎: launch | ^Y: copy again)")
236
+ # ctrl-i: copy UUID (resume) or repo path (new)
237
+ CLIP_BINDS+=(--bind "ctrl-i:execute-silent(
238
+ kk={4};
239
+ case \"\$kk\" in
240
+ resume:*) printf '%s' \"\${kk#resume:}\" | ${CLIP_CMD} ;;
241
+ new:*) printf '%s' {5} | ${CLIP_CMD} ;;
242
+ esac
243
+ )+change-header(📋 Copied ID/path to clipboard — ⏎: launch | ^I: copy again)")
244
+ fi
245
+
246
+ # ctrl-d: delete session (resume only). Repo (new) rows cannot be deleted
247
+ # from ccs — repos.yml is the source of truth; edit it directly to remove.
248
+ DELETE_BIND=(--bind "ctrl-d:execute(
249
+ kk={4};
250
+ case \"\$kk\" in
251
+ resume:*) bash '${CCS_DELETE}' \"\${kk#resume:}\" ;;
252
+ new:*) echo '[ccs] Repos can only be removed by editing ~/.config/ccs/repos.yml (press any key)'; read -r -n1 _ ;;
253
+ esac
254
+ )+reload('${TSX}' '${CCS_LIST}'${LIST_ARGS_STR})")
255
+
256
+ # ctrl-r: force refresh
257
+ REFRESH_BIND=(--bind "ctrl-r:execute('${TSX}' '${CCS_SCAN}' --force --quiet)+reload('${TSX}' '${CCS_LIST}'${LIST_ARGS_STR})")
258
+
259
+ # ── Header (adaptive to terminal width) ──────────────────────────────────────
260
+ # Note: ^D deletes SESSIONS only; repos are managed via ~/.config/ccs/repos.yml
261
+ COLS=$(tput cols 2>/dev/null || echo 80)
262
+ if (( COLS >= 80 )); then
263
+ 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'
264
+ else
265
+ HEADER=$'⏎launch ^Y:cmd ^I:ID ^R:refresh\n^D:del[resume] Esc:quit'
266
+ fi
267
+
268
+ # ── Launch fzf ───────────────────────────────────────────────────────────────
269
+ SELECTED=$("${TSX}" "${CCS_LIST}" "${LIST_FLAGS[@]+"${LIST_FLAGS[@]}"}" | fzf \
270
+ --ansi \
271
+ --delimiter=$'\t' \
272
+ --with-nth=1..3 \
273
+ --header="${HEADER}" \
274
+ --preview="'${TSX}' '${CCS_PREVIEW}' {4} {5}" \
275
+ --preview-window='right:55%:wrap' \
276
+ "${CLIP_BINDS[@]+"${CLIP_BINDS[@]}"}" \
277
+ "${DELETE_BIND[@]}" \
278
+ "${REFRESH_BIND[@]}" \
279
+ --height='80%' \
280
+ --layout=reverse \
281
+ --border \
282
+ --prompt='ccs> ' \
283
+ --tabstop=4 \
284
+ ) || exit 0
285
+
286
+ [[ -z "$SELECTED" ]] && exit 0
287
+
288
+ # ── Parse selected row ───────────────────────────────────────────────────────
289
+ # 6-col TSV from ccs-list.ts: LABEL, DESCRIPTION, BADGES, KIND:KEY, CWD, COMMAND.
290
+ # Use IFS-based read so values are never resplit by shell word-splitting
291
+ # (H6 fix). The three `_` placeholders skip columns 1-3.
292
+ IFS=$'\t' read -r _ _ _ KIND_KEY ROW_CWD ROW_CMD <<< "$SELECTED"
293
+
294
+ if [[ -z "$KIND_KEY" ]]; then
295
+ echo "❌ Could not parse selection" >&2
296
+ exit 1
297
+ fi
298
+
299
+ KIND="${KIND_KEY%%:*}"
300
+ KEY="${KIND_KEY#*:}"
301
+
302
+ # Separator row is a non-actionable divider (empty ROW_CWD/ROW_CMD by design).
303
+ # Short-circuit here so the downstream ROW_CMD empty-check doesn't misfire.
304
+ [[ "$KIND" == "separator" ]] && exit 0
305
+
306
+ if [[ -z "$ROW_CMD" ]]; then
307
+ echo "❌ Could not parse selection" >&2
308
+ exit 1
309
+ fi
310
+
311
+ # ── Per-row command is authoritative ─────────────────────────────────────────
312
+ # CCS_CMD env is resolved by ccs-config.ts as a fallback for repos without
313
+ # explicit `command:` (priority: repos[].command > defaults.command > CCS_CMD > "claude").
314
+ # Do NOT override here — that would break special-command repos like Strapi.
315
+
316
+ # ── Validation ───────────────────────────────────────────────────────────────
317
+ case "$KIND" in
318
+ resume)
319
+ if [[ ! "$KEY" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]]; then
320
+ echo "❌ Invalid session ID format: ${KEY}" >&2
321
+ exit 1
322
+ fi
323
+ ;;
324
+ new)
325
+ # Reject shell metacharacters, quotes/backslash AND control chars
326
+ # (incl. ESC — audit NEW-2). Mirrors the SHELL_METACHARS policy that
327
+ # ccs-config.ts enforces on repo names at load time; this is the
328
+ # defense-in-depth copy for rows coming back out of fzf.
329
+ if [[ "$KEY" =~ [\;\&\|\<\>\$\`\"\'\\] || "$KEY" =~ [[:cntrl:]] ]]; then
330
+ echo "❌ Invalid repo name (shell metacharacters/control chars): ${KEY}" >&2
331
+ exit 1
332
+ fi
333
+ ;;
334
+ separator)
335
+ # Defensive: separator should already be short-circuited above. Kept as
336
+ # a safety net in case row-parse order ever changes.
337
+ exit 0
338
+ ;;
339
+ *)
340
+ echo "❌ Unknown row kind: ${KIND}" >&2
341
+ exit 1
342
+ ;;
343
+ esac
344
+
345
+ # Path validation (traversal prevention). Must be $HOME itself or a child
346
+ # path ("$HOME"/*): a bare prefix match would also accept sibling dirs like
347
+ # /Users/daiskeEVIL (audit L-1).
348
+ if [[ "$ROW_CWD" != "$HOME" && "$ROW_CWD" != "$HOME"/* ]]; then
349
+ echo "⚠️ cwd is outside \$HOME: ${ROW_CWD}" >&2
350
+ echo "Launching in current directory instead." >&2
351
+ ROW_CWD="."
352
+ fi
353
+
354
+ # Note: shell metachars in ROW_CMD are hard-rejected at config-load time by
355
+ # bin/ccs-config.ts (SHELL_METACHARS regex covers `;&|<>$`"'\` + control chars).
356
+ # The informational rm/sudo/; warning that used to live here was removed in
357
+ # Phase 6 because the metachar rejection is now the authoritative guard.
358
+
359
+ # ── cd ───────────────────────────────────────────────────────────────────────
360
+ if ! cd "$ROW_CWD" 2>/dev/null; then
361
+ echo "⚠️ Directory ${ROW_CWD} not found. Launching in $(pwd) instead." >&2
362
+ fi
363
+
364
+ # ── Execute ──────────────────────────────────────────────────────────────────
365
+ # Direct invocation (NOT exec) so shell functions / aliases like `opr` work.
366
+ # shellcheck disable=SC2086
367
+ case "$KIND" in
368
+ resume)
369
+ echo "🔄 Resuming session ${KEY} in $(pwd)..."
370
+ ${ROW_CMD} --resume "${KEY}" "${PASS_ARGS[@]+"${PASS_ARGS[@]}"}"
371
+ ;;
372
+ new)
373
+ echo "🚀 Launching ${KEY} in $(pwd)..."
374
+ ${ROW_CMD} "${PASS_ARGS[@]+"${PASS_ARGS[@]}"}"
375
+ ;;
376
+ esac