@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-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-sanitize.ts
CHANGED
|
@@ -26,10 +26,16 @@ export function hasShellMetachars(value: string): boolean {
|
|
|
26
26
|
return SHELL_METACHARS.test(value);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
// All C0 control characters
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
29
|
+
// All C0 control characters, DEL and C1 (U+0080-U+009F: a terminal may read
|
|
30
|
+
// U+009B as ESC [), plus the marks that reorder displayed text (U+061C,
|
|
31
|
+
// U+200E-F, U+202A-E, U+2066-9). Emoji joiners and variation selectors stay.
|
|
32
|
+
// \n and \t are NOT exempted: every ccs display surface is
|
|
33
|
+
// single-line-per-field (TSV rows, preview lines), and the TSV protocol between
|
|
34
|
+
// ccs-list and fzf breaks on embedded tabs/newlines.
|
|
35
|
+
const CONTROL_CHARS_RE =
|
|
36
|
+
/[\x00-\x1f\x7f-\x9f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]+/g;
|
|
37
|
+
const CONTROL_CHARS_EXCEPT_NEWLINE_RE =
|
|
38
|
+
/[\x00-\x09\x0b-\x1f\x7f-\x9f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]+/g;
|
|
33
39
|
|
|
34
40
|
/**
|
|
35
41
|
* Strip C0 control characters (incl. ESC) and DEL from display text,
|
|
@@ -41,6 +47,11 @@ export function stripControlChars(text: string): string {
|
|
|
41
47
|
return text.replace(CONTROL_CHARS_RE, " ").replace(/\s+/g, " ").trim();
|
|
42
48
|
}
|
|
43
49
|
|
|
50
|
+
/** The same cleaning for text shown as several lines: only newlines survive. */
|
|
51
|
+
export function stripControlCharsKeepLines(text: string): string {
|
|
52
|
+
return text.replace(CONTROL_CHARS_EXCEPT_NEWLINE_RE, " ");
|
|
53
|
+
}
|
|
54
|
+
|
|
44
55
|
/**
|
|
45
56
|
* Sanitize a session-provided cwd at intake (audit H-1).
|
|
46
57
|
*
|
|
@@ -53,5 +64,8 @@ export function sanitizeSessionCwd(cwd: string): string | null {
|
|
|
53
64
|
if (cwd.length === 0) return null;
|
|
54
65
|
if (hasShellMetachars(cwd)) return null;
|
|
55
66
|
if (!cwd.startsWith("/")) return null;
|
|
67
|
+
// A `..` segment walks out of the checks made on the prefix (the $HOME gate
|
|
68
|
+
// in bin/ccs), and this value also reaches the clipboard through Ctrl-Y.
|
|
69
|
+
if (cwd.split("/").includes("..")) return null;
|
|
56
70
|
return cwd;
|
|
57
71
|
}
|