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-db.ts ADDED
@@ -0,0 +1,404 @@
1
+ /**
2
+ * ccs-db.ts - SQLite initialization & migration module for ccs v0.2.0
3
+ *
4
+ * Responsibilities:
5
+ * - Open better-sqlite3 connection with secure defaults
6
+ * - Apply idempotent schema migrations inside per-migration transactions
7
+ * - Provide prepared-statement helpers for repo sync (upsert/list/delete)
8
+ *
9
+ * Source of truth: docs/design/sqlite-schema.md
10
+ */
11
+
12
+ import Database from "better-sqlite3";
13
+ import { mkdirSync, chmodSync, existsSync } from "node:fs";
14
+ import { dirname } from "node:path";
15
+
16
+ export const CURRENT_SCHEMA_VERSION = 2;
17
+
18
+ export interface DbHandle {
19
+ db: Database.Database;
20
+ close(): void;
21
+ }
22
+
23
+ export interface OpenDbOptions {
24
+ /** Skip migrate() — useful for hot preview paths on an already-initialized DB. */
25
+ skipMigrate?: boolean;
26
+ /** Open the SQLite connection in read-only mode. */
27
+ readonly?: boolean;
28
+ }
29
+
30
+ export interface RepoRow {
31
+ name: string;
32
+ path: string;
33
+ description: string;
34
+ command: string;
35
+ cwd: string | null;
36
+ tags_json: string;
37
+ icon: string;
38
+ disabled: 0 | 1;
39
+ scan_enabled: 0 | 1;
40
+ custom_json: string;
41
+ config_hash: string;
42
+ created_at: string;
43
+ updated_at: string;
44
+ }
45
+
46
+ interface Migration {
47
+ version: number;
48
+ up: string;
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Migrations
53
+ // ---------------------------------------------------------------------------
54
+
55
+ const MIGRATION_V1 = `
56
+ CREATE TABLE IF NOT EXISTS schema_version (
57
+ version INTEGER PRIMARY KEY,
58
+ applied_at TEXT NOT NULL DEFAULT (datetime('now'))
59
+ );
60
+
61
+ CREATE TABLE IF NOT EXISTS repos (
62
+ name TEXT PRIMARY KEY,
63
+ path TEXT NOT NULL,
64
+ description TEXT NOT NULL DEFAULT '',
65
+ command TEXT NOT NULL DEFAULT '',
66
+ cwd TEXT,
67
+ tags_json TEXT NOT NULL DEFAULT '[]',
68
+ icon TEXT NOT NULL DEFAULT '📁',
69
+ disabled INTEGER NOT NULL DEFAULT 0,
70
+ scan_enabled INTEGER NOT NULL DEFAULT 1,
71
+ custom_json TEXT NOT NULL DEFAULT '{}',
72
+ config_hash TEXT NOT NULL,
73
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
74
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
75
+ );
76
+
77
+ CREATE INDEX IF NOT EXISTS idx_repos_path ON repos(path);
78
+ CREATE INDEX IF NOT EXISTS idx_repos_disabled ON repos(disabled);
79
+
80
+ CREATE TABLE IF NOT EXISTS repo_stats (
81
+ name TEXT PRIMARY KEY,
82
+ is_git INTEGER NOT NULL DEFAULT 0,
83
+ branch TEXT,
84
+ last_commit_hash TEXT,
85
+ last_commit_subject TEXT,
86
+ last_commit_at TEXT,
87
+ uncommitted_files INTEGER NOT NULL DEFAULT 0,
88
+ uncommitted_insertions INTEGER NOT NULL DEFAULT 0,
89
+ uncommitted_deletions INTEGER NOT NULL DEFAULT 0,
90
+ handoff_count INTEGER NOT NULL DEFAULT 0,
91
+ pending_count INTEGER NOT NULL DEFAULT 0,
92
+ claude_room_latest TEXT,
93
+ claude_room_latest_at TEXT,
94
+ session_count_total INTEGER NOT NULL DEFAULT 0,
95
+ session_last_at TEXT,
96
+ scanned_at TEXT NOT NULL DEFAULT (datetime('now')),
97
+ scan_duration_ms INTEGER NOT NULL DEFAULT 0,
98
+ scan_error TEXT,
99
+ FOREIGN KEY (name) REFERENCES repos(name) ON DELETE CASCADE
100
+ );
101
+
102
+ CREATE INDEX IF NOT EXISTS idx_repo_stats_scanned_at ON repo_stats(scanned_at);
103
+ CREATE INDEX IF NOT EXISTS idx_repo_stats_session_last_at ON repo_stats(session_last_at DESC);
104
+
105
+ CREATE TABLE IF NOT EXISTS sessions (
106
+ uuid TEXT PRIMARY KEY,
107
+ repo_name TEXT,
108
+ project_dir TEXT NOT NULL,
109
+ cwd TEXT NOT NULL,
110
+ branch TEXT,
111
+ started_at TEXT NOT NULL,
112
+ last_activity_at TEXT NOT NULL,
113
+ message_count INTEGER NOT NULL DEFAULT 0,
114
+ topic TEXT,
115
+ summary TEXT,
116
+ jsonl_size INTEGER NOT NULL DEFAULT 0,
117
+ jsonl_mtime TEXT NOT NULL,
118
+ indexed_at TEXT NOT NULL DEFAULT (datetime('now')),
119
+ FOREIGN KEY (repo_name) REFERENCES repos(name) ON DELETE SET NULL
120
+ );
121
+
122
+ CREATE INDEX IF NOT EXISTS idx_sessions_repo ON sessions(repo_name, last_activity_at DESC);
123
+ CREATE INDEX IF NOT EXISTS idx_sessions_last_activity ON sessions(last_activity_at DESC);
124
+ CREATE INDEX IF NOT EXISTS idx_sessions_cwd ON sessions(cwd);
125
+
126
+ CREATE TABLE IF NOT EXISTS handoff_files (
127
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
128
+ repo_name TEXT NOT NULL,
129
+ filename TEXT NOT NULL,
130
+ size INTEGER NOT NULL DEFAULT 0,
131
+ mtime TEXT NOT NULL,
132
+ first_line TEXT,
133
+ FOREIGN KEY (repo_name) REFERENCES repos(name) ON DELETE CASCADE
134
+ );
135
+
136
+ CREATE INDEX IF NOT EXISTS idx_handoff_repo ON handoff_files(repo_name, mtime DESC);
137
+
138
+ CREATE TABLE IF NOT EXISTS pending_items (
139
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
140
+ repo_name TEXT NOT NULL,
141
+ filename TEXT NOT NULL,
142
+ size INTEGER NOT NULL DEFAULT 0,
143
+ mtime TEXT NOT NULL,
144
+ first_line TEXT,
145
+ FOREIGN KEY (repo_name) REFERENCES repos(name) ON DELETE CASCADE
146
+ );
147
+
148
+ CREATE INDEX IF NOT EXISTS idx_pending_repo ON pending_items(repo_name, mtime DESC);
149
+ `;
150
+
151
+ // v2: key/value store for scan-time resolved settings. First consumer is
152
+ // `defaults_command` — the resolved launch-command fallback (defaults.command
153
+ // > CCS_CMD > "claude") that ccs-list needs for sessions not mapped to any
154
+ // repo (review A-8). ccs-list opens the DB readonly+skipMigrate, so it must
155
+ // tolerate this table being absent on a not-yet-migrated cache (getMeta).
156
+ const MIGRATION_V2 = `
157
+ CREATE TABLE IF NOT EXISTS meta (
158
+ key TEXT PRIMARY KEY,
159
+ value TEXT NOT NULL
160
+ );
161
+ `;
162
+
163
+ const migrations: Migration[] = [
164
+ { version: 1, up: MIGRATION_V1 },
165
+ { version: 2, up: MIGRATION_V2 },
166
+ // future: { version: 3, up: `...` },
167
+ ];
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // Migration runner
171
+ // ---------------------------------------------------------------------------
172
+
173
+ export function getCurrentSchemaVersion(db: Database.Database): number {
174
+ const row = db
175
+ .prepare(
176
+ `SELECT name FROM sqlite_master WHERE type='table' AND name='schema_version'`,
177
+ )
178
+ .get() as { name?: string } | undefined;
179
+ if (!row) return 0;
180
+ const v = db
181
+ .prepare(`SELECT MAX(version) AS v FROM schema_version`)
182
+ .get() as { v: number | null } | undefined;
183
+ return v?.v ?? 0;
184
+ }
185
+
186
+ export function migrate(db: Database.Database): void {
187
+ const current = getCurrentSchemaVersion(db);
188
+ const pending = migrations.filter((m) => m.version > current);
189
+ for (const m of pending) {
190
+ try {
191
+ db.transaction(() => {
192
+ db.exec(m.up);
193
+ db.prepare(`INSERT INTO schema_version (version) VALUES (?)`).run(
194
+ m.version,
195
+ );
196
+ })();
197
+ process.stderr.write(`[ccs] applied migration v${m.version}\n`);
198
+ } catch (err) {
199
+ const msg = err instanceof Error ? err.message : String(err);
200
+ throw new Error(`[ccs-db] migration v${m.version} failed: ${msg}`);
201
+ }
202
+ }
203
+ }
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // Open / close
207
+ // ---------------------------------------------------------------------------
208
+
209
+ export function openDb(
210
+ stateDbPath: string,
211
+ opts: OpenDbOptions = {},
212
+ ): DbHandle {
213
+ const readOnly = opts.readonly === true;
214
+
215
+ if (readOnly) {
216
+ if (!existsSync(stateDbPath)) {
217
+ throw new Error(
218
+ `[ccs-db] state.db not found at ${stateDbPath} — run \`ccs --refresh\` first`,
219
+ );
220
+ }
221
+ } else {
222
+ // Ensure parent directory exists with restrictive permissions.
223
+ const parent = dirname(stateDbPath);
224
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
225
+ }
226
+
227
+ const db = readOnly
228
+ ? new Database(stateDbPath, { readonly: true })
229
+ : new Database(stateDbPath);
230
+
231
+ // PRAGMAs: foreign keys must be ON before migrations.
232
+ // journal_mode/synchronous are session-scoped write pragmas — skip on readonly.
233
+ if (!readOnly) {
234
+ db.pragma("journal_mode = WAL");
235
+ db.pragma("synchronous = NORMAL");
236
+ // Scan-workload throughput pragmas (backlog: PRAGMA tuning). Negative
237
+ // cache_size is KiB (≈8MB page cache); temp_store=MEMORY keeps sort/temp
238
+ // b-trees off disk; mmap_size lets reads go through the page cache
239
+ // mapping (64MB ≫ any ccs-sized DB). All session-scoped, write path only.
240
+ db.pragma("cache_size = -8000");
241
+ db.pragma("temp_store = MEMORY");
242
+ db.pragma("mmap_size = 67108864");
243
+ }
244
+ db.pragma("foreign_keys = ON");
245
+ // WAL allows one writer at a time; concurrent `--force` scans (Ctrl-R right
246
+ // after `ccs --refresh`, or two terminals) would otherwise fail immediately
247
+ // with SQLITE_BUSY (audit NEW-4). 3s is ample for ccs-sized transactions.
248
+ db.pragma("busy_timeout = 3000");
249
+
250
+ if (!readOnly) {
251
+ // Tighten file permissions, including WAL side files which inherit the
252
+ // main DB's content. Best-effort, but a failure is no longer silent
253
+ // (audit L-4): on umask-hostile or foreign filesystems the cache may stay
254
+ // group/world-readable, and the user should know.
255
+ for (const suffix of ["", "-wal", "-shm"]) {
256
+ const p = stateDbPath + suffix;
257
+ if (suffix !== "" && !existsSync(p)) continue;
258
+ try {
259
+ chmodSync(p, 0o600);
260
+ } catch {
261
+ process.stderr.write(
262
+ `[ccs-db] warning: could not chmod 0600 ${p} — cache may be readable by other users\n`,
263
+ );
264
+ }
265
+ }
266
+ }
267
+
268
+ if (!opts.skipMigrate && !readOnly) {
269
+ migrate(db);
270
+ }
271
+
272
+ return {
273
+ db,
274
+ close() {
275
+ try {
276
+ // Passive checkpoint flushes WAL pages back to the main DB without
277
+ // blocking other readers. Safe to ignore errors (checkpoint is hygiene,
278
+ // not correctness). Readonly connections cannot checkpoint — skip.
279
+ if (!readOnly) {
280
+ db.pragma("wal_checkpoint(PASSIVE)");
281
+ }
282
+ } catch {
283
+ // ignore
284
+ }
285
+ db.close();
286
+ },
287
+ };
288
+ }
289
+
290
+ // ---------------------------------------------------------------------------
291
+ // Repo helpers (used by ccs-scan.ts to sync repos.yml -> DB)
292
+ // ---------------------------------------------------------------------------
293
+
294
+ type RepoInsert = Omit<RepoRow, "created_at" | "updated_at">;
295
+
296
+ export function upsertRepo(db: Database.Database, row: RepoInsert): void {
297
+ // Update updated_at only when config_hash changes; created_at is preserved
298
+ // on conflict via excluded.created_at being ignored.
299
+ const stmt = db.prepare(`
300
+ INSERT INTO repos (
301
+ name, path, description, command, cwd, tags_json, icon,
302
+ disabled, scan_enabled, custom_json, config_hash,
303
+ created_at, updated_at
304
+ ) VALUES (
305
+ @name, @path, @description, @command, @cwd, @tags_json, @icon,
306
+ @disabled, @scan_enabled, @custom_json, @config_hash,
307
+ datetime('now'), datetime('now')
308
+ )
309
+ ON CONFLICT(name) DO UPDATE SET
310
+ path = excluded.path,
311
+ description = excluded.description,
312
+ command = excluded.command,
313
+ cwd = excluded.cwd,
314
+ tags_json = excluded.tags_json,
315
+ icon = excluded.icon,
316
+ disabled = excluded.disabled,
317
+ scan_enabled = excluded.scan_enabled,
318
+ custom_json = excluded.custom_json,
319
+ config_hash = excluded.config_hash,
320
+ updated_at = CASE
321
+ WHEN repos.config_hash != excluded.config_hash
322
+ THEN datetime('now')
323
+ ELSE repos.updated_at
324
+ END
325
+ `);
326
+ stmt.run(row);
327
+ }
328
+
329
+ export function getAllRepos(db: Database.Database): RepoRow[] {
330
+ return db
331
+ .prepare(
332
+ `SELECT name, path, description, command, cwd, tags_json, icon,
333
+ disabled, scan_enabled, custom_json, config_hash,
334
+ created_at, updated_at
335
+ FROM repos
336
+ ORDER BY name`,
337
+ )
338
+ .all() as RepoRow[];
339
+ }
340
+
341
+ /**
342
+ * Delete a single session row by UUID using a bound parameter.
343
+ *
344
+ * Exists so shell callers (ccs-delete.sh via ccs-delete-session.ts) never
345
+ * have to assemble SQL strings themselves (audit M-3 — the old sqlite3 CLI
346
+ * one-liner interpolated the UUID into the statement; safe only as long as
347
+ * the upstream regex gate held).
348
+ *
349
+ * Returns the number of rows removed (0 when the UUID was not cached).
350
+ */
351
+ export function deleteSession(db: Database.Database, uuid: string): number {
352
+ const res = db.prepare(`DELETE FROM sessions WHERE uuid = ?`).run(uuid);
353
+ return res.changes;
354
+ }
355
+
356
+ /** Upsert one key into the meta table (schema v2). */
357
+ export function setMeta(
358
+ db: Database.Database,
359
+ key: string,
360
+ value: string,
361
+ ): void {
362
+ db.prepare(
363
+ `INSERT INTO meta (key, value) VALUES (?, ?)
364
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
365
+ ).run(key, value);
366
+ }
367
+
368
+ /**
369
+ * Read one key from the meta table. Returns null when the key is absent OR
370
+ * when the table itself does not exist yet — readonly/skipMigrate consumers
371
+ * (ccs-list) can hit a schema-v1 cache that predates migration v2, and a
372
+ * missing setting must degrade to the caller's default, not crash the list.
373
+ */
374
+ export function getMeta(db: Database.Database, key: string): string | null {
375
+ try {
376
+ const row = db
377
+ .prepare(`SELECT value FROM meta WHERE key = ?`)
378
+ .get(key) as { value: string } | undefined;
379
+ return row?.value ?? null;
380
+ } catch {
381
+ return null;
382
+ }
383
+ }
384
+
385
+ export function deleteReposNotIn(
386
+ db: Database.Database,
387
+ names: string[],
388
+ ): number {
389
+ if (names.length === 0) {
390
+ // Preserve existing semantic: empty array deletes all repos.
391
+ const res = db.prepare(`DELETE FROM repos`).run();
392
+ return res.changes;
393
+ }
394
+ // Use json_each(JSON array) to avoid SQLite's 999-variable bind limit
395
+ // when syncing large numbers of repos. JSON1 is default-enabled since
396
+ // SQLite 3.38 (bundled with better-sqlite3).
397
+ const res = db
398
+ .prepare(
399
+ `DELETE FROM repos
400
+ WHERE name NOT IN (SELECT value FROM json_each(?))`,
401
+ )
402
+ .run(JSON.stringify(names));
403
+ return res.changes;
404
+ }
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * ccs-delete-session.ts — Remove one session row from the state cache.
4
+ *
5
+ * Invoked by ccs-delete.sh after the JSONL file has been deleted, so the
6
+ * stale row disappears from the fzf list on the next reload. Uses a bound
7
+ * parameter via ccs-db.ts (audit M-3) instead of interpolating the UUID into
8
+ * a sqlite3 CLI string.
9
+ *
10
+ * Usage: tsx ccs-delete-session.ts <session-uuid>
11
+ * Exit codes: 0 = row deleted or not present, 1 = bad args / DB failure.
12
+ */
13
+
14
+ import { existsSync } from "node:fs";
15
+ import { getPaths } from "./ccs-config.ts";
16
+ import { openDb, deleteSession } from "./ccs-db.ts";
17
+ import { UUID_RE } from "./ccs-utils.ts";
18
+
19
+ function main(): number {
20
+ const uuid = process.argv[2] ?? "";
21
+ if (!UUID_RE.test(uuid)) {
22
+ process.stderr.write("[ccs-delete-session] invalid session UUID\n");
23
+ return 1;
24
+ }
25
+
26
+ const paths = getPaths();
27
+ if (!existsSync(paths.stateDb)) {
28
+ // No cache, nothing to clean up.
29
+ return 0;
30
+ }
31
+
32
+ try {
33
+ const handle = openDb(paths.stateDb, { skipMigrate: true });
34
+ try {
35
+ deleteSession(handle.db, uuid);
36
+ } finally {
37
+ handle.close();
38
+ }
39
+ return 0;
40
+ } catch (err) {
41
+ process.stderr.write(
42
+ `[ccs-delete-session] ${err instanceof Error ? err.message : String(err)}\n`,
43
+ );
44
+ return 1;
45
+ }
46
+ }
47
+
48
+ process.exit(main());
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env bash
2
+ # ccs-delete.sh - Delete a Claude Code session file with confirmation
3
+ # Args: sessionId
4
+
5
+ set -euo pipefail
6
+
7
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
8
+
9
+ # Same package-local tsx preference as bin/ccs (npm / copy install layouts);
10
+ # this script is invoked by path (never symlinked), so no $0 resolution here.
11
+ if [[ -x "${SCRIPT_DIR}/../node_modules/.bin/tsx" ]]; then
12
+ TSX="${SCRIPT_DIR}/../node_modules/.bin/tsx"
13
+ elif [[ -x "${SCRIPT_DIR}/node_modules/.bin/tsx" ]]; then
14
+ TSX="${SCRIPT_DIR}/node_modules/.bin/tsx"
15
+ else
16
+ TSX="tsx"
17
+ fi
18
+
19
+ SESSION_ID="${1:-}"
20
+ UUID_RE='^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
21
+
22
+ # bash renders `read -p` prompts only when stdin is a TTY — with piped stdin
23
+ # (tests, scripted use) the prompt silently vanishes. Print prompts to stderr
24
+ # explicitly so they are always visible; in a TTY this looks identical, since
25
+ # `read -p` writes to stderr too.
26
+ prompt_read() { # prompt_read <prompt> [varname]
27
+ printf '%s' "$1" >&2
28
+ IFS= read -r "${2:-REPLY}"
29
+ }
30
+
31
+ if [[ -z "$SESSION_ID" ]] || [[ ! "$SESSION_ID" =~ $UUID_RE ]]; then
32
+ echo "❌ Invalid session ID"
33
+ prompt_read "Press Enter to continue..."
34
+ exit 1
35
+ fi
36
+
37
+ PROJECTS_DIR="$HOME/.claude/projects"
38
+ TARGET=""
39
+
40
+ for dir in "$PROJECTS_DIR"/*/; do
41
+ FILE="${dir}${SESSION_ID}.jsonl"
42
+ if [[ -f "$FILE" ]]; then
43
+ TARGET="$FILE"
44
+ break
45
+ fi
46
+ done
47
+
48
+ if [[ -z "$TARGET" ]]; then
49
+ echo "❌ Session file not found"
50
+ prompt_read "Press Enter to continue..."
51
+ exit 1
52
+ fi
53
+
54
+ # Show file info. du can fail if the file vanishes between the find loop and
55
+ # here (concurrent cleanup) — under `set -e` that would kill the script with
56
+ # no message, so fall back to "?" instead.
57
+ SIZE=$(du -h "$TARGET" 2>/dev/null | cut -f1 || echo "?")
58
+ echo "━━━ Delete Session ━━━"
59
+ echo "📄 $TARGET"
60
+ echo "📏 $SIZE"
61
+ echo ""
62
+ prompt_read "Delete this session? (y/N): " confirm
63
+
64
+ if [[ "$confirm" == "y" || "$confirm" == "Y" ]]; then
65
+ # Wrap rm explicitly: under `set -e` a bare rm failure (permissions, file
66
+ # gone) would exit the script before the trailing "Press Enter" prompt, so
67
+ # the fzf execute pane closes with no message and the user has no way to
68
+ # know the delete failed (review C-2).
69
+ if ! rm "$TARGET"; then
70
+ echo "❌ Failed to delete: $TARGET" >&2
71
+ prompt_read "Press Enter to continue..."
72
+ exit 1
73
+ fi
74
+ # Also remove subagents directory if it exists. The final-form check is a
75
+ # defensive guard on the rm -rf argument: it must still look like
76
+ # <projects>/<dir>/<validated-uuid> at deletion time (audit L-2).
77
+ SUBAGENT_DIR="${PROJECTS_DIR}/$(basename "$(dirname "$TARGET")")/${SESSION_ID}"
78
+ if [[ -d "$SUBAGENT_DIR" && "$SUBAGENT_DIR" == "$PROJECTS_DIR/"*"/$SESSION_ID" ]]; then
79
+ if ! rm -rf "$SUBAGENT_DIR"; then
80
+ # Main JSONL is already gone — report and continue to cache cleanup.
81
+ echo "[ccs] warning: could not remove subagent dir: $SUBAGENT_DIR" >&2
82
+ fi
83
+ fi
84
+ # Remove the stale row from the SQLite cache so the session disappears
85
+ # from the fzf list immediately on reload (Ctrl-D → +reload). Uses a bound
86
+ # parameter via better-sqlite3 (audit M-3) and reports failures instead of
87
+ # swallowing them (audit L-3).
88
+ if [[ "$TSX" != "tsx" ]] || command -v tsx &>/dev/null; then
89
+ if ! "${TSX}" "${SCRIPT_DIR}/ccs-delete-session.ts" "$SESSION_ID"; then
90
+ echo "[ccs] warning: cache row cleanup failed — a stale row may remain until the next refresh" >&2
91
+ fi
92
+ else
93
+ echo "[ccs] warning: tsx not found — cache row not removed (run ccs --refresh)" >&2
94
+ fi
95
+ echo "✅ Deleted"
96
+ else
97
+ echo "Cancelled"
98
+ fi
99
+
100
+ prompt_read "Press Enter to continue..."