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/CHANGELOG.md +176 -0
- package/LICENSE +21 -0
- package/README.md +311 -0
- package/bin/ccs +376 -0
- package/bin/ccs-config.ts +528 -0
- package/bin/ccs-db.ts +404 -0
- package/bin/ccs-delete-session.ts +48 -0
- package/bin/ccs-delete.sh +100 -0
- package/bin/ccs-init.ts +287 -0
- package/bin/ccs-list.ts +363 -0
- package/bin/ccs-preview-session.ts +147 -0
- package/bin/ccs-preview.ts +368 -0
- package/bin/ccs-sanitize.ts +57 -0
- package/bin/ccs-scan-sessions.ts +402 -0
- package/bin/ccs-scan.ts +734 -0
- package/bin/ccs-secrets.ts +104 -0
- package/bin/ccs-time.ts +27 -0
- package/bin/ccs-utils.ts +161 -0
- package/docs/design/repos-yml-schema.md +217 -0
- package/docs/design/sqlite-schema.md +253 -0
- package/docs/v0.2.0-regression-checklist.md +40 -0
- package/docs/v0.2.0-review-notes.md +151 -0
- package/docs/v0.2.1-backlog.md +225 -0
- package/package.json +44 -0
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..."
|