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
|
@@ -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
|
+
}
|