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.
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * ccs-preview-session.ts - Display conversation history in fzf preview pane
4
+ * Args: sessionId
5
+ */
6
+
7
+ import { readdir, readFile, stat } from "node:fs/promises";
8
+ import { join } from "node:path";
9
+ import { homedir } from "node:os";
10
+ import { maskSecrets } from "./ccs-secrets.ts";
11
+ import {
12
+ extractText,
13
+ truncate,
14
+ MAX_JSONL_SIZE,
15
+ UUID_RE,
16
+ } from "./ccs-utils.ts";
17
+
18
+ const PROJECTS_DIR = join(homedir(), ".claude", "projects");
19
+ const MAX_PREVIEW_MESSAGES = 20;
20
+ const MAX_MSG_LEN = 200;
21
+
22
+ // extractText / truncate / UUID_RE / MAX_JSONL_SIZE come from ccs-utils.ts —
23
+ // shared with the scanner so the preview and topic extraction can no longer
24
+ // drift apart (review A-1/A-2). The preview passes includeToolBlocks below
25
+ // because tool activity is part of the conversation flow it renders.
26
+
27
+ export async function renderSessionPreview(sessionId: string): Promise<void> {
28
+ if (!sessionId) {
29
+ console.log("No session ID provided");
30
+ return;
31
+ }
32
+
33
+ // UUID validation (injection prevention)
34
+ if (!UUID_RE.test(sessionId)) {
35
+ console.log("Invalid session ID format");
36
+ return;
37
+ }
38
+
39
+ // Find session file
40
+ let targetFile = "";
41
+ try {
42
+ const projDirs = await readdir(PROJECTS_DIR);
43
+ for (const projDir of projDirs) {
44
+ const projPath = join(PROJECTS_DIR, projDir);
45
+ try {
46
+ const files = await readdir(projPath);
47
+ const match = files.find((f) => f === `${sessionId}.jsonl`);
48
+ if (match) {
49
+ targetFile = join(projPath, match);
50
+ break;
51
+ }
52
+ } catch {
53
+ continue;
54
+ }
55
+ }
56
+ } catch {
57
+ console.log("Cannot read projects directory");
58
+ return;
59
+ }
60
+
61
+ if (!targetFile) {
62
+ console.log("Session file not found");
63
+ return;
64
+ }
65
+
66
+ // File size check
67
+ try {
68
+ const stats = await stat(targetFile);
69
+ if (stats.size > MAX_JSONL_SIZE) {
70
+ console.log(`⚠️ File too large (${Math.round(stats.size / 1024 / 1024)}MB). Skipping preview.`);
71
+ return;
72
+ }
73
+ } catch {
74
+ console.log("Cannot stat session file");
75
+ return;
76
+ }
77
+
78
+ const content = await readFile(targetFile, "utf-8");
79
+ const lines = content.split("\n").filter((l) => l.trim());
80
+
81
+ let cwd = "";
82
+ let gitBranch = "";
83
+ let version = "";
84
+ const messages: { role: string; text: string }[] = [];
85
+
86
+ for (const line of lines) {
87
+ try {
88
+ const entry = JSON.parse(line);
89
+
90
+ if (!cwd && entry.cwd) cwd = entry.cwd;
91
+ if (!gitBranch && entry.gitBranch) gitBranch = entry.gitBranch;
92
+ if (!version && entry.version) version = entry.version;
93
+
94
+ if (
95
+ (entry.type === "user" || entry.type === "assistant") &&
96
+ entry.message?.content
97
+ ) {
98
+ const text = extractText(entry.message.content, {
99
+ includeToolBlocks: true,
100
+ });
101
+ if (text) {
102
+ messages.push({
103
+ role: entry.type === "user" ? "👤" : "🤖",
104
+ text: maskSecrets(truncate(text, MAX_MSG_LEN)),
105
+ });
106
+ // Memory optimization: keep at most 2x display limit
107
+ if (messages.length > MAX_PREVIEW_MESSAGES * 2) {
108
+ messages.splice(0, messages.length - MAX_PREVIEW_MESSAGES);
109
+ }
110
+ }
111
+ }
112
+ } catch {
113
+ // skip
114
+ }
115
+ }
116
+
117
+ // Header — cwd/gitBranch/version are untrusted JSONL fields read straight
118
+ // from disk (NOT the sanitized DB copy), so apply the same two-step
119
+ // treatment as message text: maskSecrets for credential leakage (audit
120
+ // M-2), truncate to strip control chars / escape sequences (audit NEW-1).
121
+ console.log("━━━ Session Info ━━━");
122
+ console.log(`📁 ${maskSecrets(truncate(cwd, 200))}`);
123
+ if (gitBranch) console.log(`🌿 ${maskSecrets(truncate(gitBranch, 100))}`);
124
+ if (version) console.log(`📌 Claude ${maskSecrets(truncate(version, 50))}`);
125
+ console.log(`💬 ${messages.length} messages`);
126
+ console.log("━━━ Conversation ━━━");
127
+ console.log();
128
+
129
+ const display = messages.slice(-MAX_PREVIEW_MESSAGES);
130
+ for (const msg of display) {
131
+ console.log(`${msg.role} ${msg.text}`);
132
+ console.log();
133
+ }
134
+ }
135
+
136
+ // CLI bootstrap: only run when invoked directly (not when imported)
137
+ if (import.meta.url === `file://${process.argv[1]}`) {
138
+ const sessionId = process.argv[2];
139
+ if (!sessionId) {
140
+ console.log("Usage: ccs-preview-session.ts <session-uuid>");
141
+ process.exit(1);
142
+ }
143
+ renderSessionPreview(sessionId).catch((err) => {
144
+ console.error(`[ccs-preview-session] fatal: ${err instanceof Error ? err.message : String(err)}`);
145
+ process.exit(1);
146
+ });
147
+ }
@@ -0,0 +1,368 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * ccs-preview.ts — fzf preview pane dispatcher for ccs v0.2.0
4
+ *
5
+ * Invoked by fzf as: tsx bin/ccs-preview.ts <KIND:KEY> <CWD>
6
+ * KIND=new → render repo preview (DB-backed state summary)
7
+ * KIND=resume → delegate to renderSessionPreview (conversation history)
8
+ *
9
+ * All errors print to stdout (fzf reads stdout for preview) and exit 0.
10
+ */
11
+
12
+ import { existsSync } from "node:fs";
13
+ import { getPaths } from "./ccs-config.ts";
14
+ import { openDb, type DbHandle } from "./ccs-db.ts";
15
+ import { renderSessionPreview } from "./ccs-preview-session.ts";
16
+ import {
17
+ formatRelativeTime,
18
+ formatDateTime,
19
+ truncate,
20
+ } from "./ccs-utils.ts";
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // ANSI helpers
24
+ // ---------------------------------------------------------------------------
25
+
26
+ const C = {
27
+ reset: "\x1b[0m",
28
+ bold: "\x1b[1m",
29
+ dim: "\x1b[2m",
30
+ headerName: "\x1b[1;36m", // bold cyan
31
+ divider: "\x1b[1;90m", // bold gray
32
+ };
33
+
34
+ function hdr(name: string): string {
35
+ return `${C.headerName}${name}${C.reset}`;
36
+ }
37
+ function dim(s: string): string {
38
+ return `${C.dim}${s}${C.reset}`;
39
+ }
40
+ function label(s: string): string {
41
+ return `${C.bold}${s}${C.reset}`;
42
+ }
43
+ function divider(title: string): string {
44
+ // ─── Title ──── (bold gray)
45
+ const base = `─── ${title} `;
46
+ const pad = Math.max(0, 40 - base.length);
47
+ return `${C.divider}${base}${"─".repeat(pad)}${C.reset}`;
48
+ }
49
+
50
+ // Time formatting and truncation come from ccs-utils.ts — the single source
51
+ // shared with the list renderer (review A-1/A-3/K-8). Unparseable timestamps
52
+ // render as "-" (the utils default), never as the raw DB string.
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // DB row types (narrow views)
56
+ // ---------------------------------------------------------------------------
57
+
58
+ interface RepoView {
59
+ name: string;
60
+ path: string;
61
+ description: string;
62
+ icon: string;
63
+ tags_json: string;
64
+ custom_json: string;
65
+ }
66
+
67
+ interface StatsView {
68
+ is_git: number;
69
+ branch: string | null;
70
+ last_commit_hash: string | null;
71
+ last_commit_subject: string | null;
72
+ last_commit_at: string | null;
73
+ uncommitted_files: number;
74
+ uncommitted_insertions: number;
75
+ uncommitted_deletions: number;
76
+ handoff_count: number;
77
+ pending_count: number;
78
+ claude_room_latest: string | null;
79
+ claude_room_latest_at: string | null;
80
+ session_count_total: number;
81
+ session_last_at: string | null;
82
+ }
83
+
84
+ interface FileRow {
85
+ filename: string;
86
+ mtime: string;
87
+ }
88
+
89
+ interface SessionRow {
90
+ last_activity_at: string;
91
+ topic: string | null;
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Integrations rendering
96
+ // ---------------------------------------------------------------------------
97
+
98
+ const KNOWN_INTEGRATIONS: { key: string; label: string; format?: (v: string) => string }[] = [
99
+ { key: "plane_project_id", label: "Plane" },
100
+ { key: "attio_workspace", label: "Attio" },
101
+ { key: "notion_db", label: "Notion" },
102
+ { key: "linear_team", label: "Linear" },
103
+ {
104
+ key: "slack_channel",
105
+ label: "Slack",
106
+ format: (v) => (v.startsWith("#") ? v : `#${v}`),
107
+ },
108
+ { key: "github_repo", label: "GitHub" },
109
+ { key: "figma_file", label: "Figma" },
110
+ ];
111
+ const KNOWN_KEYS = new Set(KNOWN_INTEGRATIONS.map((i) => i.key));
112
+ // Secondary keys that exist only to augment known ones — not shown standalone
113
+ const AUX_KEYS = new Set(["plane_url"]);
114
+
115
+ function renderIntegrations(customJson: string): string[] {
116
+ let custom: Record<string, unknown>;
117
+ try {
118
+ custom = JSON.parse(customJson || "{}");
119
+ } catch {
120
+ return [];
121
+ }
122
+ const keys = Object.keys(custom).filter((k) => !AUX_KEYS.has(k));
123
+ if (keys.length === 0) return [];
124
+
125
+ const lines: string[] = [];
126
+ lines.push(divider("🔗 Integrations"));
127
+
128
+ for (const { key, label: lbl, format } of KNOWN_INTEGRATIONS) {
129
+ if (!(key in custom)) continue;
130
+ const raw = custom[key];
131
+ if (raw === null || raw === undefined || raw === "") continue;
132
+ const val = format ? format(String(raw)) : String(raw);
133
+ lines.push(` ${label(lbl + ":").padEnd(10)} ✅ ${truncate(val, 50)}`);
134
+ }
135
+
136
+ const unknown = keys.filter((k) => !KNOWN_KEYS.has(k));
137
+ if (unknown.length > 0) {
138
+ lines.push(` ${label("Other:")}`);
139
+ for (const k of unknown) {
140
+ const v = custom[k];
141
+ if (v === null || v === undefined) continue;
142
+ const vs = typeof v === "string" ? v : JSON.stringify(v);
143
+ lines.push(` ${k}: ${truncate(vs, 40)}`);
144
+ }
145
+ }
146
+ return lines;
147
+ }
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // Repo preview
151
+ // ---------------------------------------------------------------------------
152
+
153
+ function renderRepoPreview(name: string, cwd: string): void {
154
+ const paths = getPaths();
155
+ if (!existsSync(paths.stateDb)) {
156
+ console.log(`${hdr("📁 " + name)}`);
157
+ console.log(dim(cwd || ""));
158
+ console.log("");
159
+ console.log("No state cache yet — run `ccs --refresh` to scan.");
160
+ return;
161
+ }
162
+
163
+ let handle: DbHandle | null = null;
164
+ try {
165
+ handle = openDb(paths.stateDb, { readonly: true, skipMigrate: true });
166
+ const db = handle.db;
167
+
168
+ const repo = db
169
+ .prepare(
170
+ `SELECT name, path, description, icon, tags_json, custom_json
171
+ FROM repos WHERE name = ?`,
172
+ )
173
+ .get(name) as RepoView | undefined;
174
+
175
+ if (!repo) {
176
+ console.log(`${hdr("📁 " + name)}`);
177
+ console.log(dim(cwd || ""));
178
+ console.log("");
179
+ console.log(`Repo "${name}" not found in cache.`);
180
+ return;
181
+ }
182
+
183
+ const stats = db
184
+ .prepare(
185
+ `SELECT is_git, branch, last_commit_hash, last_commit_subject, last_commit_at,
186
+ uncommitted_files, uncommitted_insertions, uncommitted_deletions,
187
+ handoff_count, pending_count, claude_room_latest, claude_room_latest_at,
188
+ session_count_total, session_last_at
189
+ FROM repo_stats WHERE name = ?`,
190
+ )
191
+ .get(name) as StatsView | undefined;
192
+
193
+ // --- Header ---
194
+ console.log(`${hdr(`${repo.icon || "📁"} ${repo.name}`)}`);
195
+ console.log(dim(repo.path));
196
+ if (repo.description) console.log(repo.description);
197
+ console.log("");
198
+
199
+ // --- Git ---
200
+ if (stats && stats.is_git === 1) {
201
+ console.log(divider("Git"));
202
+ const branch = stats.branch || "(detached)";
203
+ console.log(`${label("Branch:".padEnd(14))} ${branch}`);
204
+ if (stats.last_commit_hash) {
205
+ const rel = formatRelativeTime(stats.last_commit_at);
206
+ const shortHash = stats.last_commit_hash.slice(0, 7);
207
+ const subj = truncate(stats.last_commit_subject || "", 60);
208
+ console.log(`${label("Last commit:".padEnd(14))} ${rel}`);
209
+ console.log(`${"".padEnd(14)} ${shortHash} ${subj}`);
210
+ }
211
+ const f = stats.uncommitted_files;
212
+ if (f === 0 && stats.uncommitted_insertions === 0 && stats.uncommitted_deletions === 0) {
213
+ console.log(`${label("Uncommitted:".padEnd(14))} clean`);
214
+ } else {
215
+ console.log(
216
+ `${label("Uncommitted:".padEnd(14))} ${f} file(s) (+${stats.uncommitted_insertions} -${stats.uncommitted_deletions})`,
217
+ );
218
+ }
219
+ console.log("");
220
+ }
221
+
222
+ // --- Workspace (handoff / pending / claude-room) ---
223
+ if (
224
+ stats &&
225
+ (stats.handoff_count > 0 ||
226
+ stats.pending_count > 0 ||
227
+ stats.claude_room_latest)
228
+ ) {
229
+ console.log(divider("Workspace"));
230
+
231
+ if (stats.handoff_count > 0) {
232
+ console.log(`${label("Handoff:".padEnd(14))} ⚠️ ${stats.handoff_count} file(s)`);
233
+ const rows = db
234
+ .prepare(
235
+ `SELECT filename, mtime FROM handoff_files
236
+ WHERE repo_name = ? ORDER BY mtime DESC LIMIT 3`,
237
+ )
238
+ .all(name) as FileRow[];
239
+ for (const r of rows) {
240
+ console.log(` • ${truncate(r.filename, 50)} (${formatRelativeTime(r.mtime)})`);
241
+ }
242
+ }
243
+
244
+ if (stats.pending_count > 0) {
245
+ console.log(`${label("Pending:".padEnd(14))} 📝 ${stats.pending_count} item(s)`);
246
+ const rows = db
247
+ .prepare(
248
+ `SELECT filename, mtime FROM pending_items
249
+ WHERE repo_name = ? ORDER BY mtime DESC LIMIT 3`,
250
+ )
251
+ .all(name) as FileRow[];
252
+ for (const r of rows) {
253
+ console.log(` • ${truncate(r.filename, 50)} (${formatRelativeTime(r.mtime)})`);
254
+ }
255
+ }
256
+
257
+ if (stats.claude_room_latest) {
258
+ const latest = truncate(stats.claude_room_latest, 40);
259
+ const when = formatRelativeTime(stats.claude_room_latest_at);
260
+ console.log(`${label("Claude room:".padEnd(14))} latest: ${latest} (${when})`);
261
+ }
262
+ console.log("");
263
+ }
264
+
265
+ // --- Sessions ---
266
+ console.log(divider("Sessions"));
267
+ if (!stats || stats.session_count_total === 0) {
268
+ console.log("No sessions yet — ready to start");
269
+ } else {
270
+ console.log(`${label("Total:".padEnd(14))} ${stats.session_count_total} sessions`);
271
+ console.log(`${label("Last activity:".padEnd(14))} ${formatRelativeTime(stats.session_last_at)}`);
272
+ // Same population as the Total count above (repo_stats aggregates
273
+ // WHERE repo_name = ?) — mixing in a cwd fallback here made Total and
274
+ // Recent disagree (audit logic H-3). Sessions under the repo path are
275
+ // mapped to repo_name at scan time, including subdirectories.
276
+ const rows = db
277
+ .prepare(
278
+ `SELECT last_activity_at, topic FROM sessions
279
+ WHERE repo_name = ?
280
+ ORDER BY last_activity_at DESC LIMIT 3`,
281
+ )
282
+ .all(name) as SessionRow[];
283
+ if (rows.length > 0) {
284
+ console.log(label("Recent:"));
285
+ for (const r of rows) {
286
+ const dt = formatDateTime(r.last_activity_at);
287
+ const topic = truncate(r.topic || "(no topic)", 50);
288
+ console.log(` • ${dt} — ${topic}`);
289
+ }
290
+ }
291
+ }
292
+ console.log("");
293
+
294
+ // --- Integrations ---
295
+ const intLines = renderIntegrations(repo.custom_json || "{}");
296
+ if (intLines.length > 0) {
297
+ for (const line of intLines) console.log(line);
298
+ console.log("");
299
+ }
300
+
301
+ // --- Tags ---
302
+ try {
303
+ const tags = JSON.parse(repo.tags_json || "[]") as unknown;
304
+ if (Array.isArray(tags) && tags.length > 0) {
305
+ console.log(divider("Tags"));
306
+ console.log(tags.map(String).join(", "));
307
+ }
308
+ } catch {
309
+ // ignore
310
+ }
311
+ } catch (err) {
312
+ const msg = err instanceof Error ? err.message : String(err);
313
+ console.log(`${hdr("📁 " + name)}`);
314
+ console.log("");
315
+ console.log(`Preview error: ${msg}`);
316
+ } finally {
317
+ try {
318
+ handle?.close();
319
+ } catch {
320
+ // ignore
321
+ }
322
+ }
323
+ }
324
+
325
+ // ---------------------------------------------------------------------------
326
+ // Main dispatcher
327
+ // ---------------------------------------------------------------------------
328
+
329
+ async function main(): Promise<void> {
330
+ const kindKey = process.argv[2] || "";
331
+ const cwd = process.argv[3] || "";
332
+
333
+ if (!kindKey) {
334
+ console.log("Usage: ccs-preview <KIND:KEY> <CWD>");
335
+ return;
336
+ }
337
+
338
+ const colonIdx = kindKey.indexOf(":");
339
+ if (colonIdx < 0) {
340
+ console.log(`Invalid argument: ${kindKey} (expected KIND:KEY)`);
341
+ return;
342
+ }
343
+ const kind = kindKey.slice(0, colonIdx);
344
+ const key = kindKey.slice(colonIdx + 1);
345
+
346
+ try {
347
+ if (kind === "resume") {
348
+ await renderSessionPreview(key);
349
+ } else if (kind === "new") {
350
+ renderRepoPreview(key, cwd);
351
+ } else if (kind === "separator") {
352
+ // Divider row — intentionally blank preview.
353
+ return;
354
+ } else {
355
+ console.log(`Unknown kind: ${kind}`);
356
+ }
357
+ } catch (err) {
358
+ const msg = err instanceof Error ? err.message : String(err);
359
+ console.log(`Preview error: ${msg}`);
360
+ }
361
+ }
362
+
363
+ main().catch((err) => {
364
+ console.log(
365
+ `Preview fatal: ${err instanceof Error ? err.message : String(err)}`,
366
+ );
367
+ process.exit(0);
368
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * ccs-sanitize.ts — Shared input-sanitization helpers for ccs.
3
+ *
4
+ * Single source of truth for the shell-metacharacter policy and control
5
+ * character stripping. Two consumers:
6
+ * - ccs-config.ts: rejects repos.yml fields at load time (trusted-ish input)
7
+ * - ccs-scan.ts: normalizes session JSONL fields at intake (untrusted
8
+ * input — any process able to write ~/.claude/projects can plant values)
9
+ *
10
+ * Threat model (audit H-1 / NEW-1, 2026-06-12): session `cwd`/`topic` reach
11
+ * the fzf list, the preview pane, and the Ctrl-Y clipboard command. Shell
12
+ * metacharacters enable deferred command injection on paste; raw ESC/OSC
13
+ * sequences enable terminal spoofing (fzf runs with --ansi). Both classes are
14
+ * neutralized here, at the trust boundary.
15
+ */
16
+
17
+ // Shell metacharacters forbidden in path/cwd/command values that are ever
18
+ // interpolated into a shell line (fzf execute bindings, clipboard, final
19
+ // launch in bin/ccs). \x00-\x1f covers TAB, NL, CR, ESC and all other C0
20
+ // control characters, so this single class blocks command injection and
21
+ // terminal-escape injection at once.
22
+ export const SHELL_METACHARS = /[;&|<>$`"'\\\n\r\x00-\x1f]/;
23
+
24
+ /** True when the value contains any shell metacharacter / control char. */
25
+ export function hasShellMetachars(value: string): boolean {
26
+ return SHELL_METACHARS.test(value);
27
+ }
28
+
29
+ // All C0 control characters plus DEL. \n and \t are NOT exempted: every ccs
30
+ // display surface is single-line-per-field (TSV rows, preview lines), and the
31
+ // TSV protocol between ccs-list and fzf breaks on embedded tabs/newlines.
32
+ const CONTROL_CHARS_RE = /[\x00-\x1f\x7f]+/g;
33
+
34
+ /**
35
+ * Strip C0 control characters (incl. ESC) and DEL from display text,
36
+ * collapsing each run into a single space. Defense against terminal-escape
37
+ * injection (audit NEW-1) for fields that are rendered but never executed:
38
+ * topic, summary, branch, first_line, commit subjects.
39
+ */
40
+ export function stripControlChars(text: string): string {
41
+ return text.replace(CONTROL_CHARS_RE, " ").replace(/\s+/g, " ").trim();
42
+ }
43
+
44
+ /**
45
+ * Sanitize a session-provided cwd at intake (audit H-1).
46
+ *
47
+ * Unlike display text, cwd is later interpolated into a shell `cd` command
48
+ * (clipboard via Ctrl-Y, launch via bin/ccs), so a tainted value cannot be
49
+ * "cleaned" — it must be rejected outright. Returns null when the value is
50
+ * unusable; callers store the "unknown" sentinel instead.
51
+ */
52
+ export function sanitizeSessionCwd(cwd: string): string | null {
53
+ if (cwd.length === 0) return null;
54
+ if (hasShellMetachars(cwd)) return null;
55
+ if (!cwd.startsWith("/")) return null;
56
+ return cwd;
57
+ }