@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
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ccs-config-edit.ts — the one way ccs writes repos.yml.
|
|
3
|
+
*
|
|
4
|
+
* `ccs repo add`, `ccs repo remove`, `ccs config set` and the append of
|
|
5
|
+
* `ccs init --auto-discover` all go through editSteps(): lock, read and check
|
|
6
|
+
* the current file, let the caller change the parsed document, write it in
|
|
7
|
+
* place with a `.bak` of the previous text, check it again, and put the old
|
|
8
|
+
* text back when that check fails. Contract: docs/design/machine-interface.md
|
|
9
|
+
* chapter 3.
|
|
10
|
+
*
|
|
11
|
+
* The steps are a generator so the same code runs two ways: straight through
|
|
12
|
+
* (runEditSync, for ccs init) and with a pause at each step where a SIGINT
|
|
13
|
+
* can land (runEditAsync, for the machine commands: Node runs a signal
|
|
14
|
+
* handler only between ticks, never inside synchronous code). An interrupt
|
|
15
|
+
* after the write puts the old text back before the lock is dropped.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
closeSync,
|
|
20
|
+
existsSync,
|
|
21
|
+
mkdirSync,
|
|
22
|
+
openSync,
|
|
23
|
+
readFileSync,
|
|
24
|
+
rmdirSync,
|
|
25
|
+
statSync,
|
|
26
|
+
unlinkSync,
|
|
27
|
+
writeFileSync,
|
|
28
|
+
writeSync,
|
|
29
|
+
} from "node:fs";
|
|
30
|
+
import { dirname } from "node:path";
|
|
31
|
+
import { parseDocument, stringify, type Document } from "yaml";
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
ConfigError,
|
|
35
|
+
getPaths,
|
|
36
|
+
parseConfigSource,
|
|
37
|
+
type CcsConfig,
|
|
38
|
+
type ConfigWarning,
|
|
39
|
+
} from "./ccs-config.ts";
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Stops
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
/** Reason words an edit can stop with (a subset of the ccs.cli vocabulary). */
|
|
46
|
+
export type EditCode =
|
|
47
|
+
| "config_missing"
|
|
48
|
+
| "config_not_readable"
|
|
49
|
+
| "config_invalid"
|
|
50
|
+
| "config_changed"
|
|
51
|
+
| "config_locked"
|
|
52
|
+
| "write_failed"
|
|
53
|
+
| "validation_after_write_failed"
|
|
54
|
+
| "restore_failed"
|
|
55
|
+
| "interrupted"
|
|
56
|
+
// the callers' own input checks
|
|
57
|
+
| "missing_argument"
|
|
58
|
+
| "extra_argument"
|
|
59
|
+
| "path_outside_home"
|
|
60
|
+
| "path_not_found"
|
|
61
|
+
| "path_not_directory"
|
|
62
|
+
| "path_taken"
|
|
63
|
+
| "name_taken"
|
|
64
|
+
| "unsafe_characters"
|
|
65
|
+
| "needs_quoting"
|
|
66
|
+
| "invalid_value"
|
|
67
|
+
| "key_not_settable"
|
|
68
|
+
| "last_entry"
|
|
69
|
+
| "flow_style_repos"
|
|
70
|
+
| "repo_not_found";
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Why an edit stopped. `message` is for people and may name the file; the
|
|
74
|
+
* machine commands print only the code (and `configError`'s word).
|
|
75
|
+
*/
|
|
76
|
+
export class EditStop extends Error {
|
|
77
|
+
readonly code: EditCode;
|
|
78
|
+
/** config_invalid: the check that failed. */
|
|
79
|
+
readonly configError: ConfigError | undefined;
|
|
80
|
+
constructor(code: EditCode, message: string, configError?: ConfigError) {
|
|
81
|
+
super(message);
|
|
82
|
+
this.name = "EditStop";
|
|
83
|
+
this.code = code;
|
|
84
|
+
this.configError = configError;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Value checks shared by the writing commands
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
// C0, DEL, C1 and the marks that reorder displayed text (same set as
|
|
93
|
+
// ccs-sanitize's stripControlChars).
|
|
94
|
+
const CONTROL_CHAR_RE = /[\x00-\x1f\x7f-\x9f--]/;
|
|
95
|
+
|
|
96
|
+
export function hasControlChars(value: string): boolean {
|
|
97
|
+
return CONTROL_CHAR_RE.test(value);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Length in code points (a surrogate pair counts once). */
|
|
101
|
+
export function codePointLength(value: string): number {
|
|
102
|
+
return [...value].length;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* True when YAML would quote this value (1.6): `2024`, `true`, `~`, `a: b`,
|
|
107
|
+
* `#x`, a leading space. Tools that read repos.yml line by line take the text
|
|
108
|
+
* after `name:` / `path:` as is, so ccs writes those two only when they need
|
|
109
|
+
* no quotes.
|
|
110
|
+
*/
|
|
111
|
+
export function needsQuoting(value: string): boolean {
|
|
112
|
+
return stringify(value, { lineWidth: 0 }) !== value + "\n";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The YAML node of `repos:` is a one-line list (`repos: [ … ]`). */
|
|
116
|
+
export function reposIsFlow(doc: Document): boolean {
|
|
117
|
+
const repos = doc.get("repos", true) as { flow?: boolean } | undefined;
|
|
118
|
+
return Boolean(repos && typeof repos === "object" && repos.flow);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// The lock
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
/** A lock older than this is a crashed writer's; it is taken over. */
|
|
126
|
+
const LOCK_STALE_MS = 10_000;
|
|
127
|
+
|
|
128
|
+
/** Take `<repos.yml>.lock` (O_EXCL); returns the release, or throws EditStop. */
|
|
129
|
+
function acquireLock(lockPath: string): () => void {
|
|
130
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
131
|
+
try {
|
|
132
|
+
const fd = openSync(lockPath, "wx", 0o600);
|
|
133
|
+
try {
|
|
134
|
+
writeSync(fd, `${process.pid} ${new Date().toISOString()}\n`);
|
|
135
|
+
} finally {
|
|
136
|
+
closeSync(fd);
|
|
137
|
+
}
|
|
138
|
+
return () => {
|
|
139
|
+
try {
|
|
140
|
+
unlinkSync(lockPath);
|
|
141
|
+
} catch {
|
|
142
|
+
// already gone
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
} catch (err) {
|
|
146
|
+
if ((err as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
147
|
+
throw new EditStop("write_failed", `cannot create ${lockPath}: ${(err as Error).message}`);
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
const st = statSync(lockPath);
|
|
151
|
+
if (Date.now() - st.mtimeMs <= LOCK_STALE_MS) break;
|
|
152
|
+
unlinkSync(lockPath); // stale — take over
|
|
153
|
+
} catch {
|
|
154
|
+
// vanished between the two calls — try the create again
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
throw new EditStop("config_locked", "another ccs is writing repos.yml (config_locked); try again");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// The steps
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
export interface EditContext {
|
|
166
|
+
/** The parsed document to change in place. */
|
|
167
|
+
doc: Document;
|
|
168
|
+
/** The current config after ccs's checks; null when the file is new. */
|
|
169
|
+
config: CcsConfig | null;
|
|
170
|
+
/** The text read in step 2 (empty when the file is new). */
|
|
171
|
+
source: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface EditOptions {
|
|
175
|
+
/** repos.yml to edit (default: the XDG location). */
|
|
176
|
+
reposYml?: string;
|
|
177
|
+
/**
|
|
178
|
+
* When repos.yml does not exist: the first text to edit (the file and its
|
|
179
|
+
* directory are created, 0600 / 0700). Without it a missing file stops
|
|
180
|
+
* with config_missing.
|
|
181
|
+
*/
|
|
182
|
+
createWith?: string;
|
|
183
|
+
/**
|
|
184
|
+
* Change ctx.doc. Throw EditStop to refuse (nothing is written). Return
|
|
185
|
+
* false when nothing needs to change (nothing is written either).
|
|
186
|
+
*/
|
|
187
|
+
transform: (ctx: EditContext) => boolean | void;
|
|
188
|
+
/** Test hook: replaces the check after the write. */
|
|
189
|
+
validateAfterWrite?: (text: string, reposYml: string) => void;
|
|
190
|
+
/** Test hook: replaces the write that puts the old text back. */
|
|
191
|
+
restore?: (reposYml: string, text: string) => void;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface EditResult {
|
|
195
|
+
changed: boolean;
|
|
196
|
+
createdConfig: boolean;
|
|
197
|
+
/** The config after the edit (before it, when nothing changed). */
|
|
198
|
+
config: CcsConfig;
|
|
199
|
+
warnings: ConfigWarning[];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Options toString() needs to keep lines the user wrote as they are. */
|
|
203
|
+
const TO_STRING = { lineWidth: 0, flowCollectionPadding: false } as const;
|
|
204
|
+
|
|
205
|
+
function check(text: string, reposYml: string, warnings?: ConfigWarning[]): CcsConfig {
|
|
206
|
+
return parseConfigSource(text, reposYml, (w) => warnings?.push(w), false);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The edit, one yield per point where an interrupt may stop it. Throws
|
|
211
|
+
* EditStop. Drive it with runEditSync or runEditAsync.
|
|
212
|
+
*/
|
|
213
|
+
export function* editSteps(opts: EditOptions): Generator<void, EditResult, void> {
|
|
214
|
+
const reposYml = opts.reposYml ?? getPaths().reposYml;
|
|
215
|
+
let creating = false;
|
|
216
|
+
// The first directory mkdir created (taken back when nothing was written).
|
|
217
|
+
let madeDir: string | undefined;
|
|
218
|
+
if (!existsSync(reposYml)) {
|
|
219
|
+
if (opts.createWith === undefined) {
|
|
220
|
+
throw new EditStop("config_missing", "no repos.yml yet (config_missing); `ccs init` creates one");
|
|
221
|
+
}
|
|
222
|
+
creating = true;
|
|
223
|
+
try {
|
|
224
|
+
madeDir = mkdirSync(dirname(reposYml), { recursive: true, mode: 0o700 });
|
|
225
|
+
} catch (err) {
|
|
226
|
+
throw new EditStop("write_failed", `cannot create the config directory: ${(err as Error).message}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const dropMadeDir = (): void => {
|
|
230
|
+
if (!madeDir || existsSync(reposYml)) return;
|
|
231
|
+
// Innermost first; rmdir refuses a directory that is not empty.
|
|
232
|
+
for (let d = dirname(reposYml); d.length >= madeDir.length; d = dirname(d)) {
|
|
233
|
+
try {
|
|
234
|
+
rmdirSync(d);
|
|
235
|
+
} catch {
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
// 1. lock
|
|
242
|
+
let release: () => void;
|
|
243
|
+
try {
|
|
244
|
+
release = acquireLock(reposYml + ".lock");
|
|
245
|
+
} catch (err) {
|
|
246
|
+
dropMadeDir();
|
|
247
|
+
throw err;
|
|
248
|
+
}
|
|
249
|
+
let wrote = false;
|
|
250
|
+
let settled = false; // the old text is back (or never left)
|
|
251
|
+
let source = "";
|
|
252
|
+
const putBack = (): void => {
|
|
253
|
+
if (!wrote || settled) return;
|
|
254
|
+
if (creating) {
|
|
255
|
+
unlinkSync(reposYml);
|
|
256
|
+
} else if (opts.restore) {
|
|
257
|
+
opts.restore(reposYml, source);
|
|
258
|
+
} else {
|
|
259
|
+
writeFileSync(reposYml, source, { mode: 0o600 });
|
|
260
|
+
}
|
|
261
|
+
settled = true;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
yield;
|
|
266
|
+
|
|
267
|
+
// 2. read and check what is there now
|
|
268
|
+
let config: CcsConfig | null = null;
|
|
269
|
+
const warnings: ConfigWarning[] = [];
|
|
270
|
+
if (creating) {
|
|
271
|
+
if (existsSync(reposYml)) {
|
|
272
|
+
throw new EditStop("config_changed", "repos.yml appeared while ccs was about to create it (config_changed); run again");
|
|
273
|
+
}
|
|
274
|
+
source = opts.createWith ?? "";
|
|
275
|
+
} else {
|
|
276
|
+
try {
|
|
277
|
+
source = readFileSync(reposYml, "utf-8");
|
|
278
|
+
} catch (err) {
|
|
279
|
+
throw new EditStop("config_not_readable", `cannot read repos.yml (${(err as Error).message})`);
|
|
280
|
+
}
|
|
281
|
+
try {
|
|
282
|
+
config = check(source, reposYml, warnings);
|
|
283
|
+
} catch (err) {
|
|
284
|
+
if (err instanceof ConfigError) {
|
|
285
|
+
throw new EditStop("config_invalid", err.message, err);
|
|
286
|
+
}
|
|
287
|
+
throw err;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
yield;
|
|
291
|
+
|
|
292
|
+
// 3–4. the caller's checks and change
|
|
293
|
+
const doc = parseDocument(source, { logLevel: "error" });
|
|
294
|
+
const result = opts.transform({ doc, config, source: creating ? "" : source });
|
|
295
|
+
const text = doc.toString(TO_STRING);
|
|
296
|
+
if (result === false || (!creating && text === source)) {
|
|
297
|
+
return { changed: false, createdConfig: false, config: config as CcsConfig, warnings };
|
|
298
|
+
}
|
|
299
|
+
yield;
|
|
300
|
+
|
|
301
|
+
// 5. changed under us since step 2?
|
|
302
|
+
if (creating ? existsSync(reposYml) : readCurrent(reposYml) !== source) {
|
|
303
|
+
throw new EditStop("config_changed", "repos.yml changed while ccs was editing it (config_changed); run again");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// 6. backup (one generation, like ccs init)
|
|
307
|
+
if (!creating) {
|
|
308
|
+
try {
|
|
309
|
+
writeFileSync(reposYml + ".bak", source, { mode: 0o600 });
|
|
310
|
+
} catch (err) {
|
|
311
|
+
throw new EditStop("write_failed", `cannot write repos.yml.bak (${(err as Error).message}); repos.yml unchanged`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// 7. write in place: a symlinked repos.yml stays a symlink
|
|
316
|
+
try {
|
|
317
|
+
wrote = true;
|
|
318
|
+
writeFileSync(reposYml, text, { mode: 0o600 });
|
|
319
|
+
} catch (err) {
|
|
320
|
+
try {
|
|
321
|
+
putBack();
|
|
322
|
+
} catch {
|
|
323
|
+
throw new EditStop("restore_failed", restoreHint(reposYml, creating));
|
|
324
|
+
}
|
|
325
|
+
throw new EditStop("write_failed", `cannot write repos.yml (${(err as Error).message}); left as it was`);
|
|
326
|
+
}
|
|
327
|
+
yield;
|
|
328
|
+
|
|
329
|
+
// 8. check what was written
|
|
330
|
+
const after: ConfigWarning[] = [];
|
|
331
|
+
let written: CcsConfig;
|
|
332
|
+
try {
|
|
333
|
+
if (opts.validateAfterWrite) opts.validateAfterWrite(text, reposYml);
|
|
334
|
+
written = check(readCurrent(reposYml) ?? "", reposYml, after);
|
|
335
|
+
} catch (err) {
|
|
336
|
+
try {
|
|
337
|
+
putBack();
|
|
338
|
+
} catch {
|
|
339
|
+
throw new EditStop("restore_failed", restoreHint(reposYml, creating));
|
|
340
|
+
}
|
|
341
|
+
throw new EditStop(
|
|
342
|
+
"validation_after_write_failed",
|
|
343
|
+
`the written repos.yml failed ccs's checks, so the previous text was put back: ${err instanceof Error ? err.message : String(err)}`,
|
|
344
|
+
err instanceof ConfigError ? err : undefined,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
yield;
|
|
348
|
+
settled = true;
|
|
349
|
+
return { changed: true, createdConfig: creating, config: written, warnings: after };
|
|
350
|
+
} catch (err) {
|
|
351
|
+
// An interrupt (or anything unexpected) after the write: old text back.
|
|
352
|
+
if (wrote && !settled) {
|
|
353
|
+
try {
|
|
354
|
+
putBack();
|
|
355
|
+
} catch {
|
|
356
|
+
throw new EditStop("restore_failed", restoreHint(reposYml, creating));
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
throw err;
|
|
360
|
+
} finally {
|
|
361
|
+
release();
|
|
362
|
+
dropMadeDir();
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function readCurrent(path: string): string | null {
|
|
367
|
+
try {
|
|
368
|
+
return readFileSync(path, "utf-8");
|
|
369
|
+
} catch {
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function restoreHint(reposYml: string, creating: boolean): string {
|
|
375
|
+
return creating
|
|
376
|
+
? "the new repos.yml failed ccs's checks and could not be removed (restore_failed); delete it by hand"
|
|
377
|
+
: `could not put the previous repos.yml back (restore_failed); it is in ${reposYml}.bak — copy that file over repos.yml`;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Run the steps straight through (no interrupt points). Throws EditStop. */
|
|
381
|
+
export function runEditSync(opts: EditOptions): EditResult {
|
|
382
|
+
const gen = editSteps(opts);
|
|
383
|
+
let step = gen.next();
|
|
384
|
+
while (!step.done) step = gen.next();
|
|
385
|
+
return step.value;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Milliseconds to wait at each step (test hook: lets a test send SIGINT in
|
|
390
|
+
* the middle of an edit). 0–10000; anything else is ignored.
|
|
391
|
+
*/
|
|
392
|
+
function testPauseMs(): number {
|
|
393
|
+
const raw = process.env.CCS_TEST_EDIT_PAUSE_MS ?? "";
|
|
394
|
+
if (!/^[0-9]{1,5}$/.test(raw)) return 0;
|
|
395
|
+
const n = Number(raw);
|
|
396
|
+
return n <= 10_000 ? n : 0;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Run the steps with a pause at each one where SIGINT can arrive. On SIGINT
|
|
401
|
+
* the edit is undone (the old text back, the lock dropped) and EditStop
|
|
402
|
+
* `interrupted` is thrown.
|
|
403
|
+
*/
|
|
404
|
+
export async function runEditAsync(opts: EditOptions): Promise<EditResult> {
|
|
405
|
+
let interrupted = false;
|
|
406
|
+
let wake: (() => void) | null = null;
|
|
407
|
+
const onSigint = (): void => {
|
|
408
|
+
interrupted = true;
|
|
409
|
+
wake?.();
|
|
410
|
+
};
|
|
411
|
+
process.on("SIGINT", onSigint);
|
|
412
|
+
const pause = testPauseMs();
|
|
413
|
+
const gen = editSteps(opts);
|
|
414
|
+
try {
|
|
415
|
+
let step = gen.next();
|
|
416
|
+
while (!step.done) {
|
|
417
|
+
await new Promise<void>((resolve) => {
|
|
418
|
+
wake = resolve;
|
|
419
|
+
if (pause > 0) setTimeout(resolve, pause);
|
|
420
|
+
else setImmediate(resolve);
|
|
421
|
+
});
|
|
422
|
+
wake = null;
|
|
423
|
+
step = interrupted
|
|
424
|
+
? gen.throw(new EditStop("interrupted", "interrupted; repos.yml left as it was"))
|
|
425
|
+
: gen.next();
|
|
426
|
+
}
|
|
427
|
+
return step.value;
|
|
428
|
+
} finally {
|
|
429
|
+
process.off("SIGINT", onSigint);
|
|
430
|
+
}
|
|
431
|
+
}
|