@indigo-gr/claude-code-station 0.3.3 → 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.
- package/CHANGELOG.md +71 -0
- package/LICENSE +1 -1
- package/README.md +89 -5
- package/bin/ccs +739 -30
- package/bin/ccs-config-edit.ts +431 -0
- package/bin/ccs-config.ts +324 -44
- package/bin/ccs-delete.sh +2 -1
- package/bin/ccs-doctor.ts +432 -0
- package/bin/ccs-groups.ts +167 -0
- package/bin/ccs-init.ts +58 -14
- package/bin/ccs-list-json.ts +124 -0
- package/bin/ccs-list.ts +121 -10
- package/bin/ccs-machine.ts +467 -0
- package/bin/ccs-open.ts +304 -0
- package/bin/ccs-preview-session.ts +14 -5
- package/bin/ccs-preview.ts +75 -6
- package/bin/ccs-repo.ts +433 -0
- package/bin/ccs-sanitize.ts +18 -4
- package/bin/ccs-scan.ts +81 -10
- package/bin/ccs-sessions.ts +174 -0
- package/bin/ccs-tsx.sh +16 -0
- package/bin/ccs-update-check.ts +131 -0
- package/bin/ccs-utils.ts +5 -0
- package/bin/package.json +3 -0
- package/docs/design/machine-interface.md +860 -0
- package/docs/design/repos-yml-schema.md +225 -76
- package/docs/v0.2.0-review-notes.md +1 -1
- package/docs/v0.2.1-backlog.md +2 -2
- package/package.json +4 -3
package/bin/ccs-open.ts
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ccs-open.ts — `ccs open (--repo <name> | --session <uuid>) [--here |
|
|
3
|
+
* --new-window] [--terminal <app>] [--json] [-- <args>…]`.
|
|
4
|
+
*
|
|
5
|
+
* Starts Claude Code without fzf, through the same rules as a row picked in
|
|
6
|
+
* the list: the working folder must be under $HOME, the command passes the
|
|
7
|
+
* metacharacter check, a session ID must look like one. Callers pass a name
|
|
8
|
+
* or a UUID — never a path or a command. Contract:
|
|
9
|
+
* docs/design/machine-interface.md 4.6.
|
|
10
|
+
*
|
|
11
|
+
* This module decides; it does not start anything in this terminal.
|
|
12
|
+
* --here bin/ccs gives it CCS_OPEN_PLAN (a file) and CCS_OPEN_TTY.
|
|
13
|
+
* On success the plan (kind, key, cwd, command; NUL-separated)
|
|
14
|
+
* goes to that file, stdout stays empty, and bin/ccs checks it
|
|
15
|
+
* again and runs the command as the list does.
|
|
16
|
+
* --new-window macOS only: a one-shot `.command` file that runs
|
|
17
|
+
* `ccs open --here …` is handed to the terminal app with
|
|
18
|
+
* `open -a`, and this returns without waiting. The file
|
|
19
|
+
* deletes itself (and its folder) when it starts.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
accessSync,
|
|
24
|
+
constants,
|
|
25
|
+
existsSync,
|
|
26
|
+
mkdtempSync,
|
|
27
|
+
realpathSync,
|
|
28
|
+
rmSync,
|
|
29
|
+
statSync,
|
|
30
|
+
writeFileSync,
|
|
31
|
+
} from "node:fs";
|
|
32
|
+
import { homedir, tmpdir } from "node:os";
|
|
33
|
+
import { delimiter, dirname, join } from "node:path";
|
|
34
|
+
import { spawnSync } from "node:child_process";
|
|
35
|
+
import { fileURLToPath } from "node:url";
|
|
36
|
+
|
|
37
|
+
import {
|
|
38
|
+
getPaths,
|
|
39
|
+
isTerminalApp,
|
|
40
|
+
isUnderHome,
|
|
41
|
+
TERMINAL_APPS,
|
|
42
|
+
readConfig,
|
|
43
|
+
type CcsConfig,
|
|
44
|
+
type TerminalApp,
|
|
45
|
+
} from "./ccs-config.ts";
|
|
46
|
+
import {
|
|
47
|
+
cliOutcome,
|
|
48
|
+
configFailure,
|
|
49
|
+
emit,
|
|
50
|
+
hasAnswered,
|
|
51
|
+
okOutcome,
|
|
52
|
+
parseFlags,
|
|
53
|
+
wantsJson,
|
|
54
|
+
type Outcome,
|
|
55
|
+
} from "./ccs-machine.ts";
|
|
56
|
+
import { sanitizeSessionCwd, SHELL_METACHARS } from "./ccs-sanitize.ts";
|
|
57
|
+
|
|
58
|
+
const COMMAND = "open";
|
|
59
|
+
const BIN_DIR = dirname(fileURLToPath(import.meta.url));
|
|
60
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
61
|
+
|
|
62
|
+
/** Each terminal app: the name `open -a` takes and where the .app may live. */
|
|
63
|
+
export const TERMINAL_APP_INFO: Readonly<
|
|
64
|
+
Record<TerminalApp, { openName: string; bundles: (home: string) => string[] }>
|
|
65
|
+
> = {
|
|
66
|
+
terminal: {
|
|
67
|
+
openName: "Terminal",
|
|
68
|
+
bundles: () => [
|
|
69
|
+
"/System/Applications/Utilities/Terminal.app",
|
|
70
|
+
"/Applications/Utilities/Terminal.app",
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** The app's bundle is in one of its fixed places (Finder is never asked). */
|
|
76
|
+
export function terminalAppInstalled(app: TerminalApp, home: string = homedir()): boolean {
|
|
77
|
+
return TERMINAL_APP_INFO[app].bundles(home).some((p) => {
|
|
78
|
+
try {
|
|
79
|
+
return statSync(p).isDirectory();
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The program a command line starts is runnable (absolute, or found on PATH). */
|
|
87
|
+
export function programFound(command: string, pathEnv: string = process.env.PATH ?? ""): boolean {
|
|
88
|
+
const program = command.trim().split(/\s+/)[0] ?? "";
|
|
89
|
+
if (program === "") return false;
|
|
90
|
+
const runnable = (p: string): boolean => {
|
|
91
|
+
try {
|
|
92
|
+
accessSync(p, constants.X_OK);
|
|
93
|
+
return statSync(p).isFile();
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
if (program.includes("/")) return runnable(program);
|
|
99
|
+
return pathEnv.split(delimiter).some((dir) => dir !== "" && runnable(join(dir, program)));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** What to start, after every check. */
|
|
103
|
+
export interface LaunchPlan {
|
|
104
|
+
kind: "new" | "resume";
|
|
105
|
+
/** The repo's name, or the session's UUID. */
|
|
106
|
+
key: string;
|
|
107
|
+
cwd: string;
|
|
108
|
+
command: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
type Resolved = { ok: true; plan: LaunchPlan } | { ok: false; outcome: Outcome };
|
|
112
|
+
|
|
113
|
+
function fail(outcome: Outcome): Resolved {
|
|
114
|
+
return { ok: false, outcome };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The env's launch command (CCS_CMD, then CCR_CMD), as ccs-config resolves it. */
|
|
118
|
+
function envCommand(): string {
|
|
119
|
+
return process.env.CCS_CMD || process.env.CCR_CMD || "claude";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Find the target and run every check but the terminal's (4.6 table). */
|
|
123
|
+
export async function resolveTarget(
|
|
124
|
+
target: { repo?: string; session?: string },
|
|
125
|
+
config: CcsConfig | null,
|
|
126
|
+
configError: unknown,
|
|
127
|
+
): Promise<Resolved> {
|
|
128
|
+
let plan: LaunchPlan;
|
|
129
|
+
if (target.repo !== undefined) {
|
|
130
|
+
if (configError !== undefined) return fail(configFailure(configError));
|
|
131
|
+
if (!config) return fail(cliOutcome("config_missing", {}, "no repos.yml yet (config_missing)"));
|
|
132
|
+
const repo = config.repos.find((r) => r.name === target.repo);
|
|
133
|
+
if (!repo) return fail(cliOutcome("repo_not_found"));
|
|
134
|
+
if (!repo.launch) return fail(cliOutcome("not_launchable"));
|
|
135
|
+
if (repo.disabled) return fail(cliOutcome("repo_disabled"));
|
|
136
|
+
plan = { kind: "new", key: repo.name, cwd: repo.cwd || repo.path, command: repo.command };
|
|
137
|
+
} else {
|
|
138
|
+
const uuid = target.session ?? "";
|
|
139
|
+
if (!UUID_RE.test(uuid)) return fail(cliOutcome("invalid_uuid"));
|
|
140
|
+
const { stateDb } = getPaths();
|
|
141
|
+
if (!existsSync(stateDb)) {
|
|
142
|
+
return fail(cliOutcome("cache_missing", {}, "no cache yet (cache_missing); open the list once, or run ccs --refresh"));
|
|
143
|
+
}
|
|
144
|
+
let row: { cwd: string; repo_name: string | null } | undefined;
|
|
145
|
+
try {
|
|
146
|
+
const { openDb } = await import("./ccs-db.ts");
|
|
147
|
+
const handle = openDb(stateDb, { readonly: true, skipMigrate: true });
|
|
148
|
+
try {
|
|
149
|
+
row = handle.db
|
|
150
|
+
.prepare(`SELECT cwd, repo_name FROM sessions WHERE uuid = ?`)
|
|
151
|
+
.get(uuid) as typeof row;
|
|
152
|
+
} finally {
|
|
153
|
+
handle.db.close();
|
|
154
|
+
}
|
|
155
|
+
} catch {
|
|
156
|
+
return fail(cliOutcome("cache_not_readable", {}, "cannot open the cache (cache_not_readable)"));
|
|
157
|
+
}
|
|
158
|
+
if (!row) return fail(cliOutcome("session_not_found"));
|
|
159
|
+
const cwd = typeof row.cwd === "string" ? sanitizeSessionCwd(row.cwd) : null;
|
|
160
|
+
if (!cwd) return fail(cliOutcome("session_cwd_unknown"));
|
|
161
|
+
// The launch command: the matching entry's, else the default chain.
|
|
162
|
+
if (configError !== undefined) return fail(configFailure(configError));
|
|
163
|
+
const repo = config?.repos.find((r) => r.name === row?.repo_name);
|
|
164
|
+
const command = repo ? repo.command : (config?.defaults.command ?? envCommand());
|
|
165
|
+
plan = { kind: "resume", key: uuid, cwd, command };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (plan.cwd === "") return fail(cliOutcome("path_not_found"));
|
|
169
|
+
// Unlike the list, never fall back to the current folder: a caller would
|
|
170
|
+
// not notice that Claude Code started somewhere else.
|
|
171
|
+
if (plan.cwd.split("/").includes("..") || !isUnderHome(plan.cwd)) {
|
|
172
|
+
return fail(cliOutcome("path_outside_home"));
|
|
173
|
+
}
|
|
174
|
+
let isDir = false;
|
|
175
|
+
try {
|
|
176
|
+
isDir = statSync(plan.cwd).isDirectory();
|
|
177
|
+
} catch {
|
|
178
|
+
isDir = false;
|
|
179
|
+
}
|
|
180
|
+
if (!isDir) return fail(cliOutcome("path_not_found"));
|
|
181
|
+
if (SHELL_METACHARS.test(plan.command) || plan.command.trim() === "") {
|
|
182
|
+
return fail(cliOutcome("command_unsafe"));
|
|
183
|
+
}
|
|
184
|
+
if (!programFound(plan.command)) {
|
|
185
|
+
return fail(cliOutcome("dependency_missing", { detail: "claude" }, "the launch command was not found on PATH (dependency_missing)"));
|
|
186
|
+
}
|
|
187
|
+
return { ok: true, plan };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The `.command` file's text: deletes itself, then runs `ccs open --here`. */
|
|
191
|
+
export function launcherScript(ccsPath: string, target: { repo?: string; session?: string }): string {
|
|
192
|
+
const flag = target.repo !== undefined ? `--repo '${target.repo}'` : `--session '${target.session}'`;
|
|
193
|
+
return (
|
|
194
|
+
"#!/bin/sh\n" +
|
|
195
|
+
'rm -f -- "$0"; rmdir -- "$(dirname -- "$0")" 2>/dev/null\n' +
|
|
196
|
+
`exec '${ccsPath}' open --here ${flag}\n`
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export interface OpenRun {
|
|
201
|
+
outcome: Outcome;
|
|
202
|
+
/** --here, checks passed: the plan was written; print nothing. */
|
|
203
|
+
silent: boolean;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function openRun(args: readonly string[]): Promise<OpenRun> {
|
|
207
|
+
const answer = (outcome: Outcome): OpenRun => ({ outcome, silent: false });
|
|
208
|
+
const flags = parseFlags(args, {
|
|
209
|
+
booleans: ["--json", "--here", "--new-window"],
|
|
210
|
+
values: ["--repo", "--session", "--terminal"],
|
|
211
|
+
});
|
|
212
|
+
if (!flags.ok) return answer(cliOutcome(flags.code));
|
|
213
|
+
if (flags.positionals.length > 0) return answer(cliOutcome("extra_argument"));
|
|
214
|
+
const repo = flags.values.get("--repo");
|
|
215
|
+
const session = flags.values.get("--session");
|
|
216
|
+
if (repo !== undefined && session !== undefined) return answer(cliOutcome("conflicting_options"));
|
|
217
|
+
if (repo === undefined && session === undefined) return answer(cliOutcome("missing_argument"));
|
|
218
|
+
const here = flags.booleans.has("--here");
|
|
219
|
+
const newWindow = flags.booleans.has("--new-window");
|
|
220
|
+
const terminalFlag = flags.values.get("--terminal");
|
|
221
|
+
if (here && newWindow) return answer(cliOutcome("conflicting_options"));
|
|
222
|
+
if (!newWindow && (terminalFlag !== undefined)) return answer(cliOutcome("conflicting_options"));
|
|
223
|
+
if (newWindow && flags.rest.length > 0) return answer(cliOutcome("conflicting_options"));
|
|
224
|
+
if (terminalFlag !== undefined && !isTerminalApp(terminalFlag)) {
|
|
225
|
+
return answer(cliOutcome("invalid_argument", {}, `--terminal takes ${TERMINAL_APPS.join(" or ")}`));
|
|
226
|
+
}
|
|
227
|
+
const target = { repo, session };
|
|
228
|
+
|
|
229
|
+
let config: CcsConfig | null = null;
|
|
230
|
+
let configError: unknown;
|
|
231
|
+
try {
|
|
232
|
+
const result = readConfig();
|
|
233
|
+
if (result.found) config = result.config;
|
|
234
|
+
} catch (err) {
|
|
235
|
+
configError = err;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (!newWindow) {
|
|
239
|
+
const resolved = await resolveTarget(target, config, configError);
|
|
240
|
+
if (!resolved.ok) return answer(resolved.outcome);
|
|
241
|
+
if (process.env.CCS_OPEN_TTY !== "1") {
|
|
242
|
+
return answer(cliOutcome("no_tty", {}, "ccs open --here needs a terminal (no_tty); use --new-window from a script"));
|
|
243
|
+
}
|
|
244
|
+
const planFile = process.env.CCS_OPEN_PLAN;
|
|
245
|
+
if (!planFile) return answer(cliOutcome("internal_error", {}, "run ccs open through bin/ccs"));
|
|
246
|
+
const { kind, key, cwd, command } = resolved.plan;
|
|
247
|
+
writeFileSync(planFile, [kind, key, cwd, command].join("\0") + "\0", { mode: 0o600 });
|
|
248
|
+
return { outcome: okOutcome({ mode: "here" }), silent: true };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// --new-window (macOS only)
|
|
252
|
+
if (process.platform !== "darwin") {
|
|
253
|
+
return answer(cliOutcome("unsupported_os", {}, "ccs open --new-window works on macOS only (unsupported_os)"));
|
|
254
|
+
}
|
|
255
|
+
if (configError !== undefined && terminalFlag === undefined) {
|
|
256
|
+
return answer(configFailure(configError));
|
|
257
|
+
}
|
|
258
|
+
const app = (terminalFlag as TerminalApp | undefined) ?? config?.settings.terminal ?? null;
|
|
259
|
+
if (!app) {
|
|
260
|
+
return answer(cliOutcome("terminal_not_set", {}, "choose a terminal app first: ccs config set defaults.terminal terminal (terminal_not_set)"));
|
|
261
|
+
}
|
|
262
|
+
if (!terminalAppInstalled(app)) return answer(cliOutcome("terminal_app_missing"));
|
|
263
|
+
const resolved = await resolveTarget(target, config, configError);
|
|
264
|
+
if (!resolved.ok) return answer(resolved.outcome);
|
|
265
|
+
|
|
266
|
+
let ccsPath: string;
|
|
267
|
+
try {
|
|
268
|
+
ccsPath = realpathSync(join(BIN_DIR, "ccs"));
|
|
269
|
+
} catch {
|
|
270
|
+
return answer(cliOutcome("internal_error"));
|
|
271
|
+
}
|
|
272
|
+
if (SHELL_METACHARS.test(ccsPath)) return answer(cliOutcome("unsafe_install_path"));
|
|
273
|
+
|
|
274
|
+
let dir: string;
|
|
275
|
+
try {
|
|
276
|
+
// bin/ccs runs this module with TMPDIR pointing into its own temporary
|
|
277
|
+
// folder (removed when it exits); the launcher must outlive that.
|
|
278
|
+
dir = mkdtempSync(join(process.env.CCS_OPEN_TMPDIR || tmpdir(), "ccs-open-")); // 0700
|
|
279
|
+
writeFileSync(join(dir, "ccs-open.command"), launcherScript(ccsPath, target), { mode: 0o700 });
|
|
280
|
+
} catch {
|
|
281
|
+
return answer(cliOutcome("launch_failed", {}, "could not write the launcher file (launch_failed)"));
|
|
282
|
+
}
|
|
283
|
+
const res = spawnSync("open", ["-a", TERMINAL_APP_INFO[app].openName, join(dir, "ccs-open.command")], {
|
|
284
|
+
stdio: "ignore",
|
|
285
|
+
timeout: 15_000,
|
|
286
|
+
});
|
|
287
|
+
if (res.status !== 0) {
|
|
288
|
+
rmSync(dir, { recursive: true, force: true });
|
|
289
|
+
return answer(cliOutcome("launch_failed", {}, "the terminal app did not take the launcher (launch_failed)"));
|
|
290
|
+
}
|
|
291
|
+
return answer(okOutcome({ mode: "new_window", terminal: app }));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export async function runOpen(args: readonly string[]): Promise<number> {
|
|
295
|
+
const json = wantsJson(args);
|
|
296
|
+
process.on("SIGINT", () => {
|
|
297
|
+
if (hasAnswered()) return;
|
|
298
|
+
process.exitCode = emit(COMMAND, cliOutcome("interrupted"), json);
|
|
299
|
+
process.exit();
|
|
300
|
+
});
|
|
301
|
+
const { outcome, silent } = await openRun(args);
|
|
302
|
+
if (silent) return 0;
|
|
303
|
+
return emit(COMMAND, outcome, json);
|
|
304
|
+
}
|
|
@@ -8,17 +8,24 @@ import { readdir, readFile, stat } from "node:fs/promises";
|
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
10
|
import { maskSecrets } from "./ccs-secrets.ts";
|
|
11
|
+
import { stripControlChars } from "./ccs-sanitize.ts";
|
|
11
12
|
import {
|
|
12
13
|
extractText,
|
|
13
14
|
truncate,
|
|
14
15
|
MAX_JSONL_SIZE,
|
|
15
16
|
UUID_RE,
|
|
16
17
|
} from "./ccs-utils.ts";
|
|
18
|
+
import { pathToFileURL } from "node:url";
|
|
17
19
|
|
|
18
20
|
const PROJECTS_DIR = join(homedir(), ".claude", "projects");
|
|
19
21
|
const MAX_PREVIEW_MESSAGES = 20;
|
|
20
22
|
const MAX_MSG_LEN = 200;
|
|
21
23
|
|
|
24
|
+
/** Hide secrets before cutting: a key across the cut no longer matches its pattern. */
|
|
25
|
+
function safeText(text: string, max: number): string {
|
|
26
|
+
return truncate(maskSecrets(stripControlChars(text)), max);
|
|
27
|
+
}
|
|
28
|
+
|
|
22
29
|
// extractText / truncate / UUID_RE / MAX_JSONL_SIZE come from ccs-utils.ts —
|
|
23
30
|
// shared with the scanner so the preview and topic extraction can no longer
|
|
24
31
|
// drift apart (review A-1/A-2). The preview passes includeToolBlocks below
|
|
@@ -128,7 +135,7 @@ export async function renderSessionPreview(
|
|
|
128
135
|
: entry.type === "user"
|
|
129
136
|
? "👤"
|
|
130
137
|
: "🤖",
|
|
131
|
-
text:
|
|
138
|
+
text: safeText(text, MAX_MSG_LEN),
|
|
132
139
|
});
|
|
133
140
|
// Memory optimization: keep at most 2x display limit
|
|
134
141
|
if (messages.length > MAX_PREVIEW_MESSAGES * 2) {
|
|
@@ -160,9 +167,9 @@ export async function renderSessionPreview(
|
|
|
160
167
|
// treatment as message text: maskSecrets for credential leakage (audit
|
|
161
168
|
// M-2), truncate to strip control chars / escape sequences (audit NEW-1).
|
|
162
169
|
console.log("━━━ Session Info ━━━");
|
|
163
|
-
console.log(`📁 ${
|
|
164
|
-
if (gitBranch) console.log(`🌿 ${
|
|
165
|
-
if (version) console.log(`📌 Claude ${
|
|
170
|
+
console.log(`📁 ${safeText(cwd, 200)}`);
|
|
171
|
+
if (gitBranch) console.log(`🌿 ${safeText(gitBranch, 100)}`);
|
|
172
|
+
if (version) console.log(`📌 Claude ${safeText(version, 50)}`);
|
|
166
173
|
console.log(`💬 ${messages.length} messages`);
|
|
167
174
|
console.log("━━━ Conversation ━━━");
|
|
168
175
|
console.log();
|
|
@@ -175,7 +182,9 @@ export async function renderSessionPreview(
|
|
|
175
182
|
}
|
|
176
183
|
|
|
177
184
|
// CLI bootstrap: only run when invoked directly (not when imported)
|
|
178
|
-
|
|
185
|
+
// pathToFileURL, not `file://${argv[1]}`: a folder with a space (or any
|
|
186
|
+
// character a URL escapes) in its name would never match.
|
|
187
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
179
188
|
const sessionId = process.argv[2];
|
|
180
189
|
if (!sessionId) {
|
|
181
190
|
console.log("Usage: ccs-preview-session.ts <session-uuid>");
|
package/bin/ccs-preview.ts
CHANGED
|
@@ -17,9 +17,11 @@ import { existsSync } from "node:fs";
|
|
|
17
17
|
import { homedir } from "node:os";
|
|
18
18
|
import { relative } from "node:path";
|
|
19
19
|
import { getPaths } from "./ccs-config.ts";
|
|
20
|
-
import { openDb, reposV3Columns, type DbHandle } from "./ccs-db.ts";
|
|
20
|
+
import { getMeta, openDb, reposV3Columns, type DbHandle } from "./ccs-db.ts";
|
|
21
|
+
import { groupsFromMeta } from "./ccs-groups.ts";
|
|
21
22
|
import { renderSessionPreview } from "./ccs-preview-session.ts";
|
|
22
23
|
import {
|
|
24
|
+
displayIcon,
|
|
23
25
|
formatRelativeTime,
|
|
24
26
|
formatDateTime,
|
|
25
27
|
formatFriendlyTime,
|
|
@@ -29,6 +31,7 @@ import {
|
|
|
29
31
|
tildePath,
|
|
30
32
|
uiFromArgv,
|
|
31
33
|
} from "./ccs-utils.ts";
|
|
34
|
+
import { stripControlChars, stripControlCharsKeepLines } from "./ccs-sanitize.ts";
|
|
32
35
|
|
|
33
36
|
// ---------------------------------------------------------------------------
|
|
34
37
|
// ANSI helpers
|
|
@@ -225,9 +228,9 @@ function renderRepoPreview(name: string, cwd: string): void {
|
|
|
225
228
|
// showing the name.
|
|
226
229
|
const displayName = repoDisplayName(repo);
|
|
227
230
|
const nameHint = displayName !== repo.name ? ` ${dim(`(${repo.name})`)}` : "";
|
|
228
|
-
console.log(`${hdr(`${repo.icon
|
|
231
|
+
console.log(`${hdr(`${displayIcon(repo.icon)} ${displayName}`)}${nameHint}`);
|
|
229
232
|
console.log(dim(repo.path));
|
|
230
|
-
if (repo.description) console.log(repo.description);
|
|
233
|
+
if (repo.description) console.log(stripControlCharsKeepLines(repo.description));
|
|
231
234
|
console.log("");
|
|
232
235
|
|
|
233
236
|
// --- Git ---
|
|
@@ -337,7 +340,7 @@ function renderRepoPreview(name: string, cwd: string): void {
|
|
|
337
340
|
const tags = JSON.parse(repo.tags_json || "[]") as unknown;
|
|
338
341
|
if (Array.isArray(tags) && tags.length > 0) {
|
|
339
342
|
console.log(divider("Tags"));
|
|
340
|
-
console.log(tags.map(String).join(", "));
|
|
343
|
+
console.log(tags.map((t) => stripControlChars(String(t))).join(", "));
|
|
341
344
|
}
|
|
342
345
|
} catch {
|
|
343
346
|
// ignore
|
|
@@ -360,7 +363,72 @@ function renderRepoPreview(name: string, cwd: string): void {
|
|
|
360
363
|
// Group-header preview (repos.yml `launch: false`)
|
|
361
364
|
// ---------------------------------------------------------------------------
|
|
362
365
|
|
|
366
|
+
/**
|
|
367
|
+
* A logical group's header (`group:@<id>`, from defaults.groups_dir): its
|
|
368
|
+
* label and the entries listed under it. Both looks. Never throws. False
|
|
369
|
+
* when the cache holds no such group (a repos.yml header may be named `@…`).
|
|
370
|
+
*/
|
|
371
|
+
function renderLogicalGroup(id: string, simple: boolean): boolean {
|
|
372
|
+
const paths = getPaths();
|
|
373
|
+
let handle: DbHandle | null = null;
|
|
374
|
+
try {
|
|
375
|
+
handle = openDb(paths.stateDb, { readonly: true, skipMigrate: true });
|
|
376
|
+
const db = handle.db;
|
|
377
|
+
const group = groupsFromMeta(getMeta(db, "logical_groups")).find((g) => g.id === id);
|
|
378
|
+
if (!group) return false;
|
|
379
|
+
const listed = db
|
|
380
|
+
.prepare(
|
|
381
|
+
`SELECT name, path, description, icon, ${reposV3Columns(db)}
|
|
382
|
+
FROM repos WHERE disabled = 0`,
|
|
383
|
+
)
|
|
384
|
+
.all() as GroupMemberView[];
|
|
385
|
+
const entries = group.members
|
|
386
|
+
.map((n) => listed.find((r) => r.name === n))
|
|
387
|
+
.filter((r): r is GroupMemberView => r !== undefined);
|
|
388
|
+
const title = truncate(group.label, 60);
|
|
389
|
+
if (simple) {
|
|
390
|
+
cardTitle(`🗂 グループ「${title}」`);
|
|
391
|
+
console.log(" ここは見出しです。開くときは、下のプロジェクトをえらんでください");
|
|
392
|
+
console.log("");
|
|
393
|
+
if (entries.length === 0) {
|
|
394
|
+
console.log(" このグループには、一覧に出るプロジェクトがありません");
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
cardField(
|
|
398
|
+
"🗂 ",
|
|
399
|
+
`中のプロジェクト(${entries.length})`,
|
|
400
|
+
entries.map((r) => {
|
|
401
|
+
const desc = r.description ? ` ${dim(truncate(r.description, 40))}` : "";
|
|
402
|
+
return `${truncate(r.icon || "", 8) || "📁"} ${repoDisplayName(r)}${desc}`;
|
|
403
|
+
}),
|
|
404
|
+
);
|
|
405
|
+
return true;
|
|
406
|
+
}
|
|
407
|
+
console.log(hdr(`🗂 ${title}`));
|
|
408
|
+
console.log("");
|
|
409
|
+
console.log(divider("Group"));
|
|
410
|
+
console.log("Logical group (defaults.groups_dir) — not launchable.");
|
|
411
|
+
console.log("Pick one of the entries below to start a session.");
|
|
412
|
+
console.log("");
|
|
413
|
+
console.log(divider(`Entries (${entries.length})`));
|
|
414
|
+
for (const r of entries) {
|
|
415
|
+
const desc = r.description ? ` ${dim(truncate(r.description, 60))}` : "";
|
|
416
|
+
console.log(` ${displayIcon(r.icon)} ${repoDisplayName(r)}${desc}`);
|
|
417
|
+
}
|
|
418
|
+
return true;
|
|
419
|
+
} catch {
|
|
420
|
+
return false;
|
|
421
|
+
} finally {
|
|
422
|
+
try {
|
|
423
|
+
handle?.close();
|
|
424
|
+
} catch {
|
|
425
|
+
// ignore
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
363
430
|
function renderGroupPreview(name: string, cwd: string): void {
|
|
431
|
+
if (name.startsWith("@") && renderLogicalGroup(name.slice(1), false)) return;
|
|
364
432
|
const paths = getPaths();
|
|
365
433
|
if (!existsSync(paths.stateDb)) {
|
|
366
434
|
console.log(`${hdr("📁 " + name)}`);
|
|
@@ -395,7 +463,7 @@ function renderGroupPreview(name: string, cwd: string): void {
|
|
|
395
463
|
return;
|
|
396
464
|
}
|
|
397
465
|
|
|
398
|
-
console.log(`${hdr(`${group.icon
|
|
466
|
+
console.log(`${hdr(`${displayIcon(group.icon)} ${repoDisplayName(group)}`)}`);
|
|
399
467
|
console.log(dim(group.path));
|
|
400
468
|
if (group.description) console.log(truncate(group.description, 200));
|
|
401
469
|
console.log("");
|
|
@@ -418,7 +486,7 @@ function renderGroupPreview(name: string, cwd: string): void {
|
|
|
418
486
|
entries.forEach((r, i) => {
|
|
419
487
|
const rel = truncate(relative(group.path, r.path), 60) + "/";
|
|
420
488
|
const tag = r.launch === 0 ? ` ${dim("(group)")}` : "";
|
|
421
|
-
console.log(` ${r.icon
|
|
489
|
+
console.log(` ${displayIcon(r.icon)} ${names[i].padEnd(width)} ${dim(rel)}${tag}`);
|
|
422
490
|
});
|
|
423
491
|
} catch (err) {
|
|
424
492
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -611,6 +679,7 @@ function renderSimpleRepoCard(name: string, cwd: string): void {
|
|
|
611
679
|
}
|
|
612
680
|
|
|
613
681
|
function renderSimpleGroupCard(name: string): void {
|
|
682
|
+
if (name.startsWith("@") && renderLogicalGroup(name.slice(1), true)) return;
|
|
614
683
|
withDb((db) => {
|
|
615
684
|
const listed = db
|
|
616
685
|
.prepare(
|