@indigo-gr/claude-code-station 0.4.1 → 0.5.0

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,174 @@
1
+ /**
2
+ * ccs-sessions.ts — `ccs sessions --json [--project <name>] [--limit <n>]
3
+ * [--with-titles] [--no-scan]`.
4
+ *
5
+ * Past conversations per project, newest first, at most --limit each. Only
6
+ * what a screen needs to offer "resume": the project's name, the time, the
7
+ * UUID to hand to `ccs open --session`, and (only with --with-titles) the
8
+ * first request's one line. Never the summary, the working folder, the
9
+ * branch or the conversation itself. Contract:
10
+ * docs/design/machine-interface.md 2.3.
11
+ *
12
+ * Before reading, the same incremental scan the list runs refreshes the cache
13
+ * (not with --no-scan, not without a repos.yml). Its stderr is held: the
14
+ * scan's lines can name repos and paths.
15
+ */
16
+
17
+ import { existsSync } from "node:fs";
18
+
19
+ import { getPaths, readConfig } from "./ccs-config.ts";
20
+ import {
21
+ cliOutcome,
22
+ configFailure,
23
+ okOutcome,
24
+ parseFlags,
25
+ type Outcome,
26
+ } from "./ccs-machine.ts";
27
+ import { maskSecrets } from "./ccs-secrets.ts";
28
+ import { stripControlChars } from "./ccs-sanitize.ts";
29
+
30
+ const LIMIT_DEFAULT = 5;
31
+ const LIMIT_MAX = 50;
32
+ const TITLE_MAX = 80;
33
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
34
+
35
+ /** One line, no control or reordering characters, at most 80 code points (+ …). */
36
+ export function sessionTitle(topic: string | null): string | null {
37
+ if (topic === null) return null;
38
+ const clean = stripControlChars(maskSecrets(stripControlChars(topic)));
39
+ if (clean === "") return null;
40
+ const points = [...clean];
41
+ return points.length > TITLE_MAX ? points.slice(0, TITLE_MAX).join("") + "…" : clean;
42
+ }
43
+
44
+ function isoOrNull(value: string | null): string | null {
45
+ if (!value) return null;
46
+ const t = Date.parse(value);
47
+ return Number.isNaN(t) ? null : new Date(t).toISOString();
48
+ }
49
+
50
+ /** Run the list's scan in this process with stderr and console.warn held back. */
51
+ async function quietScan(): Promise<boolean> {
52
+ const write = process.stderr.write;
53
+ const warn = console.warn;
54
+ process.stderr.write = (() => true) as typeof process.stderr.write;
55
+ console.warn = () => {};
56
+ try {
57
+ const { scan } = await import("./ccs-scan.ts");
58
+ const res = await scan({ quietConfig: true });
59
+ return !res.lockSkipped;
60
+ } catch {
61
+ return false;
62
+ } finally {
63
+ process.stderr.write = write;
64
+ console.warn = warn;
65
+ }
66
+ }
67
+
68
+ interface Row {
69
+ uuid: string;
70
+ repo_name: string | null;
71
+ last_activity_at: string | null;
72
+ topic: string | null;
73
+ mapped: number;
74
+ disabled: number | null;
75
+ }
76
+
77
+ export async function sessionsOutcome(args: readonly string[]): Promise<Outcome> {
78
+ const flags = parseFlags(args, {
79
+ booleans: ["--json", "--with-titles", "--no-scan"],
80
+ values: ["--project", "--limit"],
81
+ });
82
+ if (!flags.ok) return cliOutcome(flags.code);
83
+ if (flags.positionals.length > 0 || flags.rest.length > 0) return cliOutcome("extra_argument");
84
+ if (!flags.booleans.has("--json")) {
85
+ return cliOutcome("json_required", {}, "`ccs sessions` needs --json (to pick a conversation by hand, run ccs --resume)");
86
+ }
87
+ let limit = LIMIT_DEFAULT;
88
+ const rawLimit = flags.values.get("--limit");
89
+ if (rawLimit !== undefined) {
90
+ if (!/^[0-9]{1,3}$/.test(rawLimit) || Number(rawLimit) < 1 || Number(rawLimit) > LIMIT_MAX) {
91
+ return cliOutcome("invalid_argument", {}, `--limit takes 1–${LIMIT_MAX}`);
92
+ }
93
+ limit = Number(rawLimit);
94
+ }
95
+ const project = flags.values.get("--project");
96
+ const withTitles = flags.booleans.has("--with-titles");
97
+ const paths = getPaths();
98
+
99
+ // --project must name an entry of repos.yml as it is now.
100
+ if (project !== undefined) {
101
+ try {
102
+ const result = readConfig();
103
+ if (!result.found) {
104
+ return cliOutcome("config_missing", {}, "no repos.yml yet (config_missing)");
105
+ }
106
+ if (!result.config.repos.some((r) => r.name === project)) {
107
+ return cliOutcome("repo_not_found");
108
+ }
109
+ } catch (err) {
110
+ return configFailure(err);
111
+ }
112
+ }
113
+
114
+ let fresh = false;
115
+ if (!flags.booleans.has("--no-scan") && existsSync(paths.reposYml)) {
116
+ fresh = await quietScan();
117
+ }
118
+
119
+ if (!existsSync(paths.stateDb)) {
120
+ return cliOutcome("cache_missing", {}, "no cache yet (cache_missing); open the list once, or run ccs --refresh");
121
+ }
122
+ let rows: Row[];
123
+ try {
124
+ const { openDb } = await import("./ccs-db.ts");
125
+ const handle = openDb(paths.stateDb, { readonly: true, skipMigrate: true });
126
+ try {
127
+ rows = handle.db
128
+ .prepare(
129
+ `SELECT s.uuid, s.repo_name, s.last_activity_at, s.topic,
130
+ (r.name IS NOT NULL) AS mapped, r.disabled
131
+ FROM sessions s
132
+ LEFT JOIN repos r ON r.name = s.repo_name
133
+ ORDER BY s.last_activity_at DESC`,
134
+ )
135
+ .all() as Row[];
136
+ } finally {
137
+ handle.db.close();
138
+ }
139
+ } catch {
140
+ return cliOutcome("cache_not_readable", {}, "cannot open the cache (cache_not_readable)");
141
+ }
142
+
143
+ let unmapped = 0;
144
+ const byProject = new Map<string, Row[]>();
145
+ for (const row of rows) {
146
+ if (!UUID_RE.test(row.uuid)) continue;
147
+ if (!row.mapped || !row.repo_name) {
148
+ unmapped++;
149
+ continue;
150
+ }
151
+ if (row.disabled) continue;
152
+ if (project !== undefined && row.repo_name !== project) continue;
153
+ const list = byProject.get(row.repo_name) ?? [];
154
+ list.push(row);
155
+ byProject.set(row.repo_name, list);
156
+ }
157
+
158
+ // Rows come newest first, so the Map's insertion order is already the
159
+ // projects' order by their newest conversation.
160
+ const projects = [...byProject.entries()].map(([name, list]) => ({
161
+ name,
162
+ sessions: list.slice(0, limit).map((s) => {
163
+ const item: Record<string, unknown> = {
164
+ uuid: s.uuid,
165
+ last_activity_at: isoOrNull(s.last_activity_at),
166
+ };
167
+ if (withTitles) item.title = sessionTitle(s.topic);
168
+ return item;
169
+ }),
170
+ more: Math.max(0, list.length - limit),
171
+ }));
172
+
173
+ return okOutcome({ stale: !fresh, limit, projects, unmapped });
174
+ }
package/bin/ccs-tsx.sh ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env bash
2
+ # ccs-tsx.sh — start a ccs module with tsx from ccs's own folder.
3
+ #
4
+ # Usage (from bin/ccs, the fzf bindings and ccs-delete.sh):
5
+ # CCS_TSX_REAL=<tsx> bash ccs-tsx.sh <module.ts> [args…]
6
+ #
7
+ # Node resolves tsx's preload (--require) from the CURRENT folder and reads
8
+ # the nearest package.json above it on the way; a package.json there that
9
+ # Node cannot parse stops every module before it starts, even though ccs's
10
+ # own files are fine. So the module runs from the folder holding these
11
+ # scripts, and the folder the user ran ccs in is passed on in CCS_CWD (read
12
+ # where it matters: `ccs .` and `ccs repo add <relative path>`).
13
+ set -euo pipefail
14
+ export CCS_CWD="${CCS_CWD:-$PWD}"
15
+ cd "$(dirname "${BASH_SOURCE[0]}")"
16
+ exec "${CCS_TSX_REAL:-tsx}" "$@"
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "module"
3
+ }