@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.
- package/CHANGELOG.md +37 -0
- package/README.md +67 -1
- package/bin/ccs +572 -26
- 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 +107 -7
- package/bin/ccs-machine.ts +467 -0
- package/bin/ccs-open.ts +304 -0
- package/bin/ccs-preview-session.ts +4 -1
- package/bin/ccs-preview.ts +68 -1
- package/bin/ccs-repo.ts +433 -0
- package/bin/ccs-scan.ts +41 -4
- package/bin/ccs-sessions.ts +174 -0
- package/bin/ccs-tsx.sh +16 -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/package.json +1 -1
package/bin/ccs-repo.ts
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ccs-repo.ts — the writing commands and the settings read:
|
|
3
|
+
*
|
|
4
|
+
* ccs repo add <path> [--name <name>] [--description <text>] [--with-paths] [--json]
|
|
5
|
+
* ccs repo remove <name> [--json]
|
|
6
|
+
* ccs config get [--with-paths] [--json]
|
|
7
|
+
* ccs config set [--repo <name>] <key> <value> [--json]
|
|
8
|
+
*
|
|
9
|
+
* Every write goes through ccs-config-edit (lock, check, `.bak`, write in
|
|
10
|
+
* place, check again, put back). Only the values a screen can offer are
|
|
11
|
+
* settable: a fixed list of words, and an entry's label and description —
|
|
12
|
+
* never a command string or a path (people who want those edit the file).
|
|
13
|
+
* Contract: docs/design/machine-interface.md 2.4, 3 and 4.1–4.5.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { realpathSync, statSync } from "node:fs";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { basename, relative, resolve } from "node:path";
|
|
19
|
+
import { isMap, isSeq, type Document } from "yaml";
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
expandPath,
|
|
23
|
+
isTerminalApp,
|
|
24
|
+
isUnderHome,
|
|
25
|
+
readConfig,
|
|
26
|
+
TERMINAL_APPS,
|
|
27
|
+
type CcsConfig,
|
|
28
|
+
} from "./ccs-config.ts";
|
|
29
|
+
import {
|
|
30
|
+
codePointLength,
|
|
31
|
+
EditStop,
|
|
32
|
+
hasControlChars,
|
|
33
|
+
needsQuoting,
|
|
34
|
+
reposIsFlow,
|
|
35
|
+
runEditAsync,
|
|
36
|
+
type EditCode,
|
|
37
|
+
} from "./ccs-config-edit.ts";
|
|
38
|
+
import {
|
|
39
|
+
cliOutcome,
|
|
40
|
+
configFailure,
|
|
41
|
+
emit,
|
|
42
|
+
hasAnswered,
|
|
43
|
+
okOutcome,
|
|
44
|
+
parseFlags,
|
|
45
|
+
wantsJson,
|
|
46
|
+
type CliCode,
|
|
47
|
+
type Outcome,
|
|
48
|
+
} from "./ccs-machine.ts";
|
|
49
|
+
import { SHELL_METACHARS } from "./ccs-sanitize.ts";
|
|
50
|
+
import { UI_MODES } from "./ccs-utils.ts";
|
|
51
|
+
|
|
52
|
+
const NAME_MAX = 100;
|
|
53
|
+
const LABEL_MAX = 80;
|
|
54
|
+
const DESCRIPTION_MAX = 200;
|
|
55
|
+
|
|
56
|
+
/** The first lines of a repos.yml that `repo add` creates. */
|
|
57
|
+
const NEW_CONFIG_HEAD = "# Claude Code Station — Repository Definitions\nversion: 1\n";
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// SIGINT outside an edit
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
let editing = false;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* SIGINT before or after the write: answer interrupted (130). During the
|
|
67
|
+
* write, runEditAsync handles it (puts the old text back first).
|
|
68
|
+
*/
|
|
69
|
+
function interruptOutsideEdit(command: string, json: boolean): void {
|
|
70
|
+
process.on("SIGINT", () => {
|
|
71
|
+
if (editing || hasAnswered()) return;
|
|
72
|
+
process.exitCode = emit(command, cliOutcome("interrupted"), json);
|
|
73
|
+
process.exit();
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function edit(opts: Parameters<typeof runEditAsync>[0]) {
|
|
78
|
+
editing = true;
|
|
79
|
+
try {
|
|
80
|
+
return await runEditAsync(opts);
|
|
81
|
+
} finally {
|
|
82
|
+
editing = false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** An EditStop as an outcome; the message (it may name the file) stays off stderr. */
|
|
87
|
+
function stopOutcome(err: unknown): Outcome {
|
|
88
|
+
if (err instanceof EditStop) {
|
|
89
|
+
if (err.code === "config_invalid" && err.configError) return configFailure(err.configError);
|
|
90
|
+
if (err.code === "validation_after_write_failed" || err.code === "restore_failed") {
|
|
91
|
+
const extra = err.configError ? { detail: err.configError.code } : {};
|
|
92
|
+
return cliOutcome(
|
|
93
|
+
err.code,
|
|
94
|
+
extra,
|
|
95
|
+
err.code === "restore_failed"
|
|
96
|
+
? "could not put the previous repos.yml back (restore_failed); the previous text is in repos.yml.bak"
|
|
97
|
+
: "the written repos.yml failed ccs's checks; the previous text was put back (validation_after_write_failed)",
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return cliOutcome(err.code as CliCode);
|
|
101
|
+
}
|
|
102
|
+
return cliOutcome("internal_error");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function stop(code: EditCode, message = code): never {
|
|
106
|
+
throw new EditStop(code, message);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// repo add
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
/** `~/…` for a path under $HOME (`~/` for $HOME itself: a bare `~` is YAML null). */
|
|
114
|
+
export function tildeForm(abs: string, home: string = homedir()): string {
|
|
115
|
+
if (abs === home) return "~/";
|
|
116
|
+
return `~/${relative(home, abs)}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function realOrSame(p: string): string {
|
|
120
|
+
try {
|
|
121
|
+
return realpathSync(p);
|
|
122
|
+
} catch {
|
|
123
|
+
return p;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Checks on the name (4.1): length, characters, quoting. */
|
|
128
|
+
function checkName(name: string): void {
|
|
129
|
+
if (name.length === 0 || codePointLength(name) > NAME_MAX) stop("invalid_value");
|
|
130
|
+
if (SHELL_METACHARS.test(name)) stop("unsafe_characters");
|
|
131
|
+
if (needsQuoting(name)) stop("needs_quoting");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function checkText(value: string, max: number): void {
|
|
135
|
+
if (hasControlChars(value) || codePointLength(value) > max) stop("invalid_value");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function repoAdd(args: readonly string[]): Promise<Outcome> {
|
|
139
|
+
const flags = parseFlags(args, {
|
|
140
|
+
booleans: ["--json", "--with-paths"],
|
|
141
|
+
values: ["--name", "--description"],
|
|
142
|
+
});
|
|
143
|
+
if (!flags.ok) return cliOutcome(flags.code);
|
|
144
|
+
if (flags.rest.length > 0 || flags.positionals.length > 1) return cliOutcome("extra_argument");
|
|
145
|
+
const rawPath = flags.positionals[0];
|
|
146
|
+
if (rawPath === undefined || rawPath === "") return cliOutcome("missing_argument");
|
|
147
|
+
const wantName = flags.values.get("--name");
|
|
148
|
+
const description = flags.values.get("--description");
|
|
149
|
+
const home = homedir();
|
|
150
|
+
// A relative path is taken from the folder ccs was run in (CCS_CWD: bin/ccs
|
|
151
|
+
// starts this module from its own folder; see ccs-tsx.sh).
|
|
152
|
+
const abs = resolve(process.env.CCS_CWD || process.cwd(), expandPath(rawPath));
|
|
153
|
+
|
|
154
|
+
let added: { name: string; path: string } | null = null;
|
|
155
|
+
try {
|
|
156
|
+
const result = await edit({
|
|
157
|
+
createWith: NEW_CONFIG_HEAD,
|
|
158
|
+
transform: ({ doc, config }) => {
|
|
159
|
+
if (config && reposIsFlow(doc)) stop("flow_style_repos");
|
|
160
|
+
if (!isUnderHome(abs)) stop("path_outside_home");
|
|
161
|
+
let st;
|
|
162
|
+
try {
|
|
163
|
+
st = statSync(abs);
|
|
164
|
+
} catch {
|
|
165
|
+
stop("path_not_found");
|
|
166
|
+
}
|
|
167
|
+
if (!st.isDirectory()) stop("path_not_directory");
|
|
168
|
+
if (SHELL_METACHARS.test(abs)) stop("unsafe_characters");
|
|
169
|
+
const written = tildeForm(abs, home);
|
|
170
|
+
if (needsQuoting(written)) stop("needs_quoting");
|
|
171
|
+
const repos = config?.repos ?? [];
|
|
172
|
+
const real = realOrSame(abs);
|
|
173
|
+
if (repos.some((r) => r.path !== "" && (r.path === abs || realOrSame(r.path) === real))) {
|
|
174
|
+
stop("path_taken");
|
|
175
|
+
}
|
|
176
|
+
const taken = new Set(repos.map((r) => r.name));
|
|
177
|
+
let name: string;
|
|
178
|
+
if (wantName !== undefined) {
|
|
179
|
+
checkName(wantName);
|
|
180
|
+
if (taken.has(wantName)) stop("name_taken");
|
|
181
|
+
name = wantName;
|
|
182
|
+
} else {
|
|
183
|
+
const base = abs === home ? "home" : basename(abs);
|
|
184
|
+
checkName(base);
|
|
185
|
+
name = base;
|
|
186
|
+
for (let i = 2; taken.has(name); i++) name = `${base}-${i}`;
|
|
187
|
+
checkName(name);
|
|
188
|
+
}
|
|
189
|
+
if (description !== undefined) checkText(description, DESCRIPTION_MAX);
|
|
190
|
+
|
|
191
|
+
const item: Record<string, string> = { name, path: written };
|
|
192
|
+
if (description !== undefined && description !== "") item.description = description;
|
|
193
|
+
if (config) {
|
|
194
|
+
const node = doc.get("repos", true);
|
|
195
|
+
if (!isSeq(node)) stop("config_invalid");
|
|
196
|
+
node.add(doc.createNode(item));
|
|
197
|
+
} else {
|
|
198
|
+
doc.set("repos", doc.createNode([item]));
|
|
199
|
+
}
|
|
200
|
+
added = { name, path: abs };
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
const done = added as { name: string; path: string } | null;
|
|
204
|
+
if (!done) return cliOutcome("internal_error");
|
|
205
|
+
const repo: Record<string, unknown> = { name: done.name, created_config: result.createdConfig };
|
|
206
|
+
if (flags.booleans.has("--with-paths")) repo.path = done.path;
|
|
207
|
+
return okOutcome({ repo });
|
|
208
|
+
} catch (err) {
|
|
209
|
+
return stopOutcome(err);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// repo remove
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
export async function repoRemove(args: readonly string[]): Promise<Outcome> {
|
|
218
|
+
const flags = parseFlags(args, { booleans: ["--json"] });
|
|
219
|
+
if (!flags.ok) return cliOutcome(flags.code);
|
|
220
|
+
if (flags.rest.length > 0 || flags.positionals.length > 1) return cliOutcome("extra_argument");
|
|
221
|
+
const name = flags.positionals[0];
|
|
222
|
+
if (name === undefined || name === "") return cliOutcome("missing_argument");
|
|
223
|
+
try {
|
|
224
|
+
await edit({
|
|
225
|
+
transform: ({ doc, config }) => {
|
|
226
|
+
const repos = (config as CcsConfig).repos;
|
|
227
|
+
const index = repos.findIndex((r) => r.name === name);
|
|
228
|
+
if (index === -1) stop("repo_not_found");
|
|
229
|
+
if (repos.length === 1) stop("last_entry");
|
|
230
|
+
const node = doc.get("repos", true);
|
|
231
|
+
if (!isSeq(node)) stop("config_invalid");
|
|
232
|
+
node.items.splice(index, 1);
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
return okOutcome({ repo: { name } });
|
|
236
|
+
} catch (err) {
|
|
237
|
+
return stopOutcome(err);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// config get
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
/** The values `config set` takes, per key (shown by `config get` as `allowed`). */
|
|
246
|
+
export const SETTABLE_DEFAULTS = {
|
|
247
|
+
"defaults.ui": [...UI_MODES] as unknown[],
|
|
248
|
+
"defaults.terminal": [...TERMINAL_APPS] as unknown[],
|
|
249
|
+
"defaults.update_check": [true, false] as unknown[],
|
|
250
|
+
} as const;
|
|
251
|
+
|
|
252
|
+
export function configGet(args: readonly string[]): Outcome {
|
|
253
|
+
const flags = parseFlags(args, { booleans: ["--json", "--with-paths"] });
|
|
254
|
+
if (!flags.ok) return cliOutcome(flags.code);
|
|
255
|
+
if (flags.positionals.length > 0 || flags.rest.length > 0) return cliOutcome("extra_argument");
|
|
256
|
+
let config: CcsConfig | null = null;
|
|
257
|
+
try {
|
|
258
|
+
const result = readConfig();
|
|
259
|
+
if (result.found) config = result.config;
|
|
260
|
+
} catch (err) {
|
|
261
|
+
return configFailure(err);
|
|
262
|
+
}
|
|
263
|
+
const groupsDir: Record<string, unknown> = { set: Boolean(config?.settings.groupsDir) };
|
|
264
|
+
if (flags.booleans.has("--with-paths") && config?.settings.groupsDir) {
|
|
265
|
+
groupsDir.path = resolve(expandPath(config.settings.groupsDir));
|
|
266
|
+
}
|
|
267
|
+
return okOutcome({
|
|
268
|
+
settings: {
|
|
269
|
+
"defaults.ui": config?.defaults.ui ?? "classic",
|
|
270
|
+
"defaults.terminal": config?.settings.terminal ?? null,
|
|
271
|
+
"defaults.update_check": config?.settings.updateCheck ?? true,
|
|
272
|
+
"defaults.groups_dir": groupsDir,
|
|
273
|
+
},
|
|
274
|
+
allowed: SETTABLE_DEFAULTS,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** `config get` without --json: one `key = value` line each (for people). */
|
|
279
|
+
export function configGetText(outcome: Outcome): string {
|
|
280
|
+
const settings = (outcome.body?.settings ?? {}) as Record<string, unknown>;
|
|
281
|
+
return Object.entries(settings)
|
|
282
|
+
.map(([k, v]) => {
|
|
283
|
+
if (v && typeof v === "object") {
|
|
284
|
+
const o = v as { set?: boolean; path?: string };
|
|
285
|
+
return `${k} = ${o.path ?? (o.set ? "(set)" : "(not set)")}`;
|
|
286
|
+
}
|
|
287
|
+
return `${k} = ${v === null ? "(not set)" : String(v)}`;
|
|
288
|
+
})
|
|
289
|
+
.join("\n");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
// config set
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
type SetKey =
|
|
297
|
+
| "defaults.ui"
|
|
298
|
+
| "defaults.terminal"
|
|
299
|
+
| "defaults.update_check"
|
|
300
|
+
| "label"
|
|
301
|
+
| "description";
|
|
302
|
+
|
|
303
|
+
const DEFAULT_KEYS = new Set<SetKey>(["defaults.ui", "defaults.terminal", "defaults.update_check"]);
|
|
304
|
+
const ENTRY_KEYS = new Set<SetKey>(["label", "description"]);
|
|
305
|
+
|
|
306
|
+
/** The value to write for this key, or undefined to delete the key. Throws EditStop. */
|
|
307
|
+
function parseSetValue(key: SetKey, raw: string): unknown {
|
|
308
|
+
switch (key) {
|
|
309
|
+
case "defaults.ui":
|
|
310
|
+
if (!(UI_MODES as readonly string[]).includes(raw)) stop("invalid_value");
|
|
311
|
+
return raw;
|
|
312
|
+
case "defaults.terminal":
|
|
313
|
+
if (raw === "") return undefined;
|
|
314
|
+
if (!isTerminalApp(raw)) stop("invalid_value");
|
|
315
|
+
return raw;
|
|
316
|
+
case "defaults.update_check":
|
|
317
|
+
if (raw === "true") return true;
|
|
318
|
+
if (raw === "false") return false;
|
|
319
|
+
return stop("invalid_value");
|
|
320
|
+
case "label":
|
|
321
|
+
checkText(raw, LABEL_MAX);
|
|
322
|
+
return raw === "" ? undefined : raw;
|
|
323
|
+
case "description":
|
|
324
|
+
checkText(raw, DESCRIPTION_MAX);
|
|
325
|
+
return raw === "" ? undefined : raw;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Put `defaults:` before `repos:` when there is none (line readers stop at `repos:`). */
|
|
330
|
+
function ensureDefaults(doc: Document): void {
|
|
331
|
+
if (doc.has("defaults")) return;
|
|
332
|
+
const top = doc.contents;
|
|
333
|
+
if (!isMap(top)) stop("config_invalid");
|
|
334
|
+
const pair = doc.createPair("defaults", {});
|
|
335
|
+
const at = top.items.findIndex((p) => {
|
|
336
|
+
const k = p.key as { value?: unknown } | unknown;
|
|
337
|
+
return (k && typeof k === "object" && "value" in k ? k.value : k) === "repos";
|
|
338
|
+
});
|
|
339
|
+
if (at === -1) top.items.push(pair);
|
|
340
|
+
else top.items.splice(at, 0, pair);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export async function configSet(args: readonly string[]): Promise<Outcome> {
|
|
344
|
+
const flags = parseFlags(args, { booleans: ["--json"], values: ["--repo"] });
|
|
345
|
+
if (!flags.ok) return cliOutcome(flags.code);
|
|
346
|
+
if (flags.rest.length > 0 || flags.positionals.length > 2) return cliOutcome("extra_argument");
|
|
347
|
+
if (flags.positionals.length < 2) return cliOutcome("missing_argument");
|
|
348
|
+
const [keyRaw, valueRaw] = flags.positionals;
|
|
349
|
+
const isDefaultKey = DEFAULT_KEYS.has(keyRaw as SetKey);
|
|
350
|
+
if (!isDefaultKey && !ENTRY_KEYS.has(keyRaw as SetKey)) {
|
|
351
|
+
return cliOutcome("key_not_settable", {}, "only defaults.ui, defaults.terminal, defaults.update_check, label and description can be set");
|
|
352
|
+
}
|
|
353
|
+
const key = keyRaw as SetKey;
|
|
354
|
+
const repoName = flags.values.get("--repo");
|
|
355
|
+
if (isDefaultKey && repoName !== undefined) return cliOutcome("extra_argument");
|
|
356
|
+
if (!isDefaultKey && repoName === undefined) return cliOutcome("missing_argument");
|
|
357
|
+
|
|
358
|
+
try {
|
|
359
|
+
const result = await edit({
|
|
360
|
+
transform: ({ doc, config }) => {
|
|
361
|
+
const value = parseSetValueOrRepoFirst(key, valueRaw, repoName, config as CcsConfig);
|
|
362
|
+
const path: (string | number)[] = isDefaultKey
|
|
363
|
+
? ["defaults", key.slice("defaults.".length)]
|
|
364
|
+
: ["repos", (config as CcsConfig).repos.findIndex((r) => r.name === repoName), key];
|
|
365
|
+
const current = doc.getIn(path);
|
|
366
|
+
if (value === undefined) {
|
|
367
|
+
if (current === undefined || current === null) return false;
|
|
368
|
+
doc.deleteIn(path);
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
if (current === value) return false;
|
|
372
|
+
if (isDefaultKey) {
|
|
373
|
+
ensureDefaults(doc);
|
|
374
|
+
if (!isMap(doc.get("defaults", true))) stop("config_invalid");
|
|
375
|
+
}
|
|
376
|
+
doc.setIn(path, value);
|
|
377
|
+
return true;
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
return okOutcome({ key, changed: result.changed });
|
|
381
|
+
} catch (err) {
|
|
382
|
+
return stopOutcome(err);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** For an entry key, a missing entry (5) comes before a bad value (6). */
|
|
387
|
+
function parseSetValueOrRepoFirst(
|
|
388
|
+
key: SetKey,
|
|
389
|
+
raw: string,
|
|
390
|
+
repoName: string | undefined,
|
|
391
|
+
config: CcsConfig,
|
|
392
|
+
): unknown {
|
|
393
|
+
if (repoName !== undefined && !config.repos.some((r) => r.name === repoName)) {
|
|
394
|
+
stop("repo_not_found");
|
|
395
|
+
}
|
|
396
|
+
return parseSetValue(key, raw);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ---------------------------------------------------------------------------
|
|
400
|
+
// Dispatch
|
|
401
|
+
// ---------------------------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
/** `ccs repo …` and `ccs config …`: returns the exit code. */
|
|
404
|
+
export async function runRepoOrConfig(word: "repo" | "config", args: readonly string[]): Promise<number> {
|
|
405
|
+
const json = wantsJson(args);
|
|
406
|
+
const sub = args[0];
|
|
407
|
+
const rest = args.slice(1);
|
|
408
|
+
const command = sub && !sub.startsWith("-") ? `${word} ${sub}` : word;
|
|
409
|
+
interruptOutsideEdit(command, json);
|
|
410
|
+
if (sub === undefined || sub.startsWith("-")) {
|
|
411
|
+
return emit(command, cliOutcome("missing_argument", {}, `usage: ccs ${word} ${word === "repo" ? "add|remove" : "get|set"} …`), json);
|
|
412
|
+
}
|
|
413
|
+
// A write without --json: nothing on stdout, one line on stderr (1.8).
|
|
414
|
+
const written = (outcome: Outcome): number => {
|
|
415
|
+
if (!json && outcome.status === "ok" && !outcome.note) {
|
|
416
|
+
const changed = (outcome.body as { changed?: boolean } | undefined)?.changed;
|
|
417
|
+
outcome.note = `${command}: ok${changed === false ? " (already set; nothing written)" : ""}`;
|
|
418
|
+
}
|
|
419
|
+
return emit(command, outcome, json);
|
|
420
|
+
};
|
|
421
|
+
if (word === "repo" && sub === "add") return written(await repoAdd(rest));
|
|
422
|
+
if (word === "repo" && sub === "remove") return written(await repoRemove(rest));
|
|
423
|
+
if (word === "config" && sub === "set") return written(await configSet(rest));
|
|
424
|
+
if (word === "config" && sub === "get") {
|
|
425
|
+
const outcome = configGet(rest);
|
|
426
|
+
if (!json && outcome.status === "ok") {
|
|
427
|
+
process.stdout.write(configGetText(outcome) + "\n");
|
|
428
|
+
return 0;
|
|
429
|
+
}
|
|
430
|
+
return emit(command, outcome, json);
|
|
431
|
+
}
|
|
432
|
+
return emit(command, cliOutcome("invalid_argument", {}, `unknown subcommand for ccs ${word}`), json);
|
|
433
|
+
}
|
package/bin/ccs-scan.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { join } from "node:path";
|
|
|
28
28
|
import type Database from "better-sqlite3";
|
|
29
29
|
|
|
30
30
|
import { loadConfig, getPaths, type RepoEntry } from "./ccs-config.ts";
|
|
31
|
+
import { groupsToMeta, readLogicalGroups } from "./ccs-groups.ts";
|
|
31
32
|
import {
|
|
32
33
|
openDb,
|
|
33
34
|
upsertRepo,
|
|
@@ -40,6 +41,7 @@ import { maskSecrets } from "./ccs-secrets.ts";
|
|
|
40
41
|
import { stripControlChars } from "./ccs-sanitize.ts";
|
|
41
42
|
import { nowIso, type UiMode } from "./ccs-utils.ts";
|
|
42
43
|
import { scanSessions } from "./ccs-scan-sessions.ts";
|
|
44
|
+
import { pathToFileURL } from "node:url";
|
|
43
45
|
|
|
44
46
|
const execFileP = promisify(execFile);
|
|
45
47
|
|
|
@@ -52,6 +54,12 @@ export interface ScanOptions {
|
|
|
52
54
|
ttlSeconds?: number;
|
|
53
55
|
parallelism?: number;
|
|
54
56
|
scanSessions?: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* The machine interface's scan (`ccs sessions`): repos.yml is read without
|
|
59
|
+
* scaffolding (no template, no README; only the cache dir) and its
|
|
60
|
+
* warnings are not printed.
|
|
61
|
+
*/
|
|
62
|
+
quietConfig?: boolean;
|
|
55
63
|
}
|
|
56
64
|
|
|
57
65
|
export interface ScanResult {
|
|
@@ -65,6 +73,8 @@ export interface ScanResult {
|
|
|
65
73
|
lockSkipped?: boolean;
|
|
66
74
|
/** repos.yml `defaults.ui` as loaded by this run (set even when lock-skipped). */
|
|
67
75
|
ui?: UiMode;
|
|
76
|
+
/** repos.yml `defaults.update_check` (set even when lock-skipped). */
|
|
77
|
+
updateCheck?: boolean;
|
|
68
78
|
}
|
|
69
79
|
|
|
70
80
|
// ---------------------------------------------------------------------------
|
|
@@ -652,8 +662,11 @@ export async function scan(opts: ScanOptions = {}): Promise<ScanResult> {
|
|
|
652
662
|
const parallelism = opts.parallelism ?? DEFAULT_PARALLELISM;
|
|
653
663
|
const scanSessionsFlag = opts.scanSessions ?? true;
|
|
654
664
|
|
|
655
|
-
// loadConfig() runs ensureConfigDir(),
|
|
656
|
-
|
|
665
|
+
// loadConfig() runs ensureConfigDir() (or, quiet, creates the cache dir),
|
|
666
|
+
// so cacheDir exists before the lock.
|
|
667
|
+
const config = opts.quietConfig
|
|
668
|
+
? loadConfig({ scaffold: false, sink: () => {} })
|
|
669
|
+
: loadConfig();
|
|
657
670
|
const paths = getPaths();
|
|
658
671
|
|
|
659
672
|
const releaseLock = acquireScanLock(paths.cacheDir);
|
|
@@ -670,6 +683,7 @@ export async function scan(opts: ScanOptions = {}): Promise<ScanResult> {
|
|
|
670
683
|
durationMs: Date.now() - started,
|
|
671
684
|
lockSkipped: true,
|
|
672
685
|
ui: config.defaults.ui,
|
|
686
|
+
updateCheck: config.settings.updateCheck,
|
|
673
687
|
};
|
|
674
688
|
}
|
|
675
689
|
|
|
@@ -692,6 +706,16 @@ export async function scan(opts: ScanOptions = {}): Promise<ScanResult> {
|
|
|
692
706
|
// repos.yml `defaults.ui`, for the `ccs --no-scan` path (bin/ccs reads it
|
|
693
707
|
// back through `ccs-list --print-ui` when no scan ran this launch).
|
|
694
708
|
setMeta(handle.db, "defaults_ui", config.defaults.ui);
|
|
709
|
+
// repos.yml `defaults.update_check`, read back the same way (`ccs-list
|
|
710
|
+
// --print-settings`) when no scan ran this launch.
|
|
711
|
+
setMeta(handle.db, "defaults_update_check", config.settings.updateCheck ? "on" : "off");
|
|
712
|
+
// defaults.groups_dir: the logical groups, matched to repos.yml by path,
|
|
713
|
+
// for the list to read (an empty list when the setting is off, so
|
|
714
|
+
// removing it clears the headers).
|
|
715
|
+
const groups = config.settings.groupsDir
|
|
716
|
+
? readLogicalGroups(config.settings.groupsDir, config.repos).groups
|
|
717
|
+
: [];
|
|
718
|
+
setMeta(handle.db, "logical_groups", groupsToMeta(groups));
|
|
695
719
|
|
|
696
720
|
// Order matters (audit C-1): scanOneRepo aggregates session counts FROM
|
|
697
721
|
// the sessions table, so sessions must be refreshed first — otherwise
|
|
@@ -720,6 +744,7 @@ export async function scan(opts: ScanOptions = {}): Promise<ScanResult> {
|
|
|
720
744
|
sessionsSkipped,
|
|
721
745
|
durationMs: Date.now() - started,
|
|
722
746
|
ui: config.defaults.ui,
|
|
747
|
+
updateCheck: config.settings.updateCheck,
|
|
723
748
|
};
|
|
724
749
|
} finally {
|
|
725
750
|
handle.close();
|
|
@@ -736,12 +761,14 @@ function parseArgs(argv: string[]): {
|
|
|
736
761
|
noSessions: boolean;
|
|
737
762
|
quiet: boolean;
|
|
738
763
|
printUi: boolean;
|
|
764
|
+
printSettings: boolean;
|
|
739
765
|
} {
|
|
740
766
|
return {
|
|
741
767
|
force: argv.includes("--force"),
|
|
742
768
|
noSessions: argv.includes("--no-sessions"),
|
|
743
769
|
quiet: argv.includes("--quiet"),
|
|
744
770
|
printUi: argv.includes("--print-ui"),
|
|
771
|
+
printSettings: argv.includes("--print-settings"),
|
|
745
772
|
};
|
|
746
773
|
}
|
|
747
774
|
|
|
@@ -767,7 +794,15 @@ async function main(): Promise<{ code: number; out: string }> {
|
|
|
767
794
|
`(${res.sessionsSkipped} cached) in ${secs}s\n`,
|
|
768
795
|
);
|
|
769
796
|
}
|
|
770
|
-
|
|
797
|
+
// --print-settings: `ui=` and `update_check=` (bin/ccs's pre-scan);
|
|
798
|
+
// --print-ui: `ui=` alone, as before.
|
|
799
|
+
let out = "";
|
|
800
|
+
if (args.printSettings && res.ui) {
|
|
801
|
+
out = `ui=${res.ui}\nupdate_check=${res.updateCheck === false ? "off" : "on"}\n`;
|
|
802
|
+
} else if (args.printUi && res.ui) {
|
|
803
|
+
out = `ui=${res.ui}\n`;
|
|
804
|
+
}
|
|
805
|
+
return { code: 0, out };
|
|
771
806
|
} catch (err) {
|
|
772
807
|
process.stderr.write(
|
|
773
808
|
`[ccs-scan] fatal: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
@@ -776,7 +811,9 @@ async function main(): Promise<{ code: number; out: string }> {
|
|
|
776
811
|
}
|
|
777
812
|
}
|
|
778
813
|
|
|
779
|
-
|
|
814
|
+
// pathToFileURL, not `file://${argv[1]}`: a folder with a space (or any
|
|
815
|
+
// character a URL escapes) in its name would never match.
|
|
816
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
780
817
|
main().then(
|
|
781
818
|
({ code, out }) => {
|
|
782
819
|
// Exit only after stdout drained: pipe writes are async on macOS, and
|