agent-cloud-kit 0.0.0-stage → 1.0.0-rc.1
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/LICENSE +21 -0
- package/README.md +232 -2
- package/dist/ca.js +30 -0
- package/dist/cli.js +418 -0
- package/dist/config.js +197 -0
- package/dist/constants.js +77 -0
- package/dist/detect.js +265 -0
- package/dist/docker.js +95 -0
- package/dist/doctor.js +200 -0
- package/dist/init.js +136 -0
- package/dist/installers.js +463 -0
- package/dist/launcher.js +140 -0
- package/dist/manual.js +223 -0
- package/dist/prepare/detect.js +29 -0
- package/dist/prepare/index.js +489 -0
- package/dist/prepare/prompts.js +28 -0
- package/dist/prepare/questions.js +5 -0
- package/dist/run.js +113 -0
- package/dist/session.js +246 -0
- package/dist/setup.js +277 -0
- package/dist/skill.js +148 -0
- package/dist/state.js +147 -0
- package/dist/steps.js +46 -0
- package/package.json +46 -4
- package/schema.json +72 -0
- package/skills/cloud-ready/SKILL.md +202 -0
- package/skills/cloud-ready/reference/compat.md +110 -0
- package/skills/cloud-ready/reference/knowledge.md +56 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @file Command line entry point of agent-cloud-kit: wires `setup`, `session`, `docker`, `doctor`,
|
|
4
|
+
* `init`, `manual` and `prepare` to their modules, relaunching or delegating to the version each
|
|
5
|
+
* repo pins.
|
|
6
|
+
*/
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
9
|
+
import { resolve } from "node:path";
|
|
10
|
+
import { createInterface } from "node:readline/promises";
|
|
11
|
+
import { pathToFileURL } from "node:url";
|
|
12
|
+
import { readConfig } from "./config.js";
|
|
13
|
+
import { CONFIG_FILE, DOCKERD_LOG, OUTPUT_PREFIX, RUNNING_PACKAGE_ROOT, homeDir, toolsDir } from "./constants.js";
|
|
14
|
+
import { doctor } from "./doctor.js";
|
|
15
|
+
import { dockerResponds, realDocker } from "./docker.js";
|
|
16
|
+
import { init } from "./init.js";
|
|
17
|
+
import { findRepos, packageBin, planSetup, relaunch } from "./launcher.js";
|
|
18
|
+
import { MANUAL_FILE, writeManual } from "./manual.js";
|
|
19
|
+
import { EXIT_INTERRUPTED, InterruptedError, branchDay, prepare } from "./prepare/index.js";
|
|
20
|
+
import { captureProcess, runProcess } from "./run.js";
|
|
21
|
+
import { session } from "./session.js";
|
|
22
|
+
import { setupRepo } from "./setup.js";
|
|
23
|
+
/** Help text shown by `--help`. */
|
|
24
|
+
const HELP = `${OUTPUT_PREFIX}prepares Claude Code cloud sessions.
|
|
25
|
+
|
|
26
|
+
Usage: agent-cloud-kit <command>
|
|
27
|
+
|
|
28
|
+
Commands:
|
|
29
|
+
setup [--repo <path>] prepare the machine for every repo in the home dir (or only <path>)
|
|
30
|
+
session report the machine state and wire the tools into the session
|
|
31
|
+
docker start the Docker daemon and wait until it answers
|
|
32
|
+
doctor check the network allowlist, tools, Docker and the machine state
|
|
33
|
+
init create .claude/cloud.json and register the SessionStart hook
|
|
34
|
+
manual write docs/agent-cloud.md from .claude/cloud.json
|
|
35
|
+
prepare [--resume] run locally: set the repo up for the cloud on a new branch and verify it in a cloud session
|
|
36
|
+
|
|
37
|
+
Options:
|
|
38
|
+
--help show this help
|
|
39
|
+
--version print the package version
|
|
40
|
+
`;
|
|
41
|
+
/** Environment variable with the project dir that Claude Code passes to hooks. */
|
|
42
|
+
const PROJECT_DIR_VAR = "CLAUDE_PROJECT_DIR";
|
|
43
|
+
/** Environment marker set on a delegated `session`, so it never delegates again. */
|
|
44
|
+
export const DELEGATED_ENV = "AGENT_CLOUD_KIT_DELEGATED";
|
|
45
|
+
/** Value of `DELEGATED_ENV` that marks a delegated run. */
|
|
46
|
+
const DELEGATED_VALUE = "1";
|
|
47
|
+
/** Option that limits setup to one repo. */
|
|
48
|
+
const REPO_OPTION = "--repo";
|
|
49
|
+
/** Timeout of the git call that finds the repo root, in milliseconds. */
|
|
50
|
+
const GIT_TIMEOUT_MS = 10_000;
|
|
51
|
+
/** Option of `prepare` that continues in an existing prepare worktree. */
|
|
52
|
+
const RESUME_OPTION = "--resume";
|
|
53
|
+
/** Exit code for success. */
|
|
54
|
+
const EXIT_OK = 0;
|
|
55
|
+
/** Exit code for failure or wrong usage. */
|
|
56
|
+
const EXIT_FAIL = 1;
|
|
57
|
+
/**
|
|
58
|
+
* Reads the package version from the package.json next to `src/` or `dist/`.
|
|
59
|
+
*
|
|
60
|
+
* @returns The version string.
|
|
61
|
+
* @throws When package.json cannot be read or parsed.
|
|
62
|
+
*/
|
|
63
|
+
function readVersion() {
|
|
64
|
+
const file = new URL("../package.json", import.meta.url);
|
|
65
|
+
const pkg = JSON.parse(readFileSync(file, "utf8"));
|
|
66
|
+
return pkg.version;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Turns an unknown error into its message.
|
|
70
|
+
*
|
|
71
|
+
* @param err - Anything thrown.
|
|
72
|
+
* @returns The message.
|
|
73
|
+
*/
|
|
74
|
+
function message(err) {
|
|
75
|
+
return err instanceof Error ? err.message : String(err);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Runs setup for one repo with this version and prints its summary.
|
|
79
|
+
*
|
|
80
|
+
* @param repo - Repo root.
|
|
81
|
+
* @param deps - CLI dependencies.
|
|
82
|
+
*/
|
|
83
|
+
async function runOne(repo, deps) {
|
|
84
|
+
const { config, errors } = readConfig(repo);
|
|
85
|
+
for (const error of errors)
|
|
86
|
+
deps.print(`${OUTPUT_PREFIX}${repo}: ${error}`);
|
|
87
|
+
if (config && config.version !== deps.packageVersion) {
|
|
88
|
+
deps.print(`${OUTPUT_PREFIX}running ${deps.packageVersion} although ${CONFIG_FILE} asks for ${config.version}`);
|
|
89
|
+
}
|
|
90
|
+
const state = await setupRepo(repo, {
|
|
91
|
+
toolsDir: toolsDir(deps.env),
|
|
92
|
+
run: deps.run,
|
|
93
|
+
packageVersion: deps.packageVersion,
|
|
94
|
+
env: deps.env,
|
|
95
|
+
docker: deps.docker,
|
|
96
|
+
packageRoot: deps.packageRoot,
|
|
97
|
+
});
|
|
98
|
+
const failed = state.steps.filter((s) => !s.ok).map((s) => s.name);
|
|
99
|
+
const detail = failed.length ? ` (${failed.join(", ")})` : "";
|
|
100
|
+
deps.print(`${OUTPUT_PREFIX}setup ${repo}: ${state.steps.length} steps, ${failed.length} failed${detail}`);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Carries out one item of the setup plan; may throw, the caller reports it and moves on.
|
|
104
|
+
*
|
|
105
|
+
* @param item - Plan item.
|
|
106
|
+
* @param deps - CLI dependencies.
|
|
107
|
+
*/
|
|
108
|
+
async function runPlanItem(item, deps) {
|
|
109
|
+
if (item.action === "skip") {
|
|
110
|
+
deps.print(`${OUTPUT_PREFIX}skipping ${item.repo}: ${item.reason ?? ""}`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (item.action === "run" || !item.version) {
|
|
114
|
+
await runOne(item.repo, deps);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
deps.print(`${OUTPUT_PREFIX}relaunching with ${item.version} for ${item.repo}`);
|
|
118
|
+
try {
|
|
119
|
+
await relaunch(item.repo, item.version, deps.run, deps.env);
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
deps.print(`${OUTPUT_PREFIX}relaunch with ${item.version} for ${item.repo} failed: ${message(err)}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Runs `setup`: one repo with `--repo`, otherwise every repo of the home dir in series (apt
|
|
127
|
+
* cannot run twice at once). Always exits 0: a failure is reported, never fatal.
|
|
128
|
+
*
|
|
129
|
+
* @param args - Arguments after `setup`.
|
|
130
|
+
* @param deps - CLI dependencies.
|
|
131
|
+
* @returns Exit code 0.
|
|
132
|
+
*/
|
|
133
|
+
async function setupCommand(args, deps) {
|
|
134
|
+
try {
|
|
135
|
+
const at = args.indexOf(REPO_OPTION);
|
|
136
|
+
if (at >= 0) {
|
|
137
|
+
const value = args[at + 1];
|
|
138
|
+
if (!value || value.startsWith("-"))
|
|
139
|
+
deps.print(`${OUTPUT_PREFIX}${REPO_OPTION} needs a path`);
|
|
140
|
+
else
|
|
141
|
+
await runOne(resolve(deps.cwd, value), deps);
|
|
142
|
+
return EXIT_OK;
|
|
143
|
+
}
|
|
144
|
+
const repos = findRepos(homeDir(deps.env));
|
|
145
|
+
if (!repos.length)
|
|
146
|
+
deps.print(`${OUTPUT_PREFIX}no repos found in ${homeDir(deps.env)}`);
|
|
147
|
+
for (const item of planSetup(repos, deps.packageVersion, readConfig)) {
|
|
148
|
+
try {
|
|
149
|
+
await runPlanItem(item, deps);
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
deps.print(`${OUTPUT_PREFIX}setup error for ${item.repo}: ${message(err)}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
deps.print(`${OUTPUT_PREFIX}setup error: ${message(err)}`);
|
|
158
|
+
}
|
|
159
|
+
return EXIT_OK;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Finds the repo of the session: `CLAUDE_PROJECT_DIR`, else the git toplevel, else the cwd.
|
|
163
|
+
*
|
|
164
|
+
* @param deps - CLI dependencies.
|
|
165
|
+
* @returns The repo root.
|
|
166
|
+
*/
|
|
167
|
+
function sessionRepo(deps) {
|
|
168
|
+
const fromEnv = deps.env[PROJECT_DIR_VAR];
|
|
169
|
+
if (fromEnv)
|
|
170
|
+
return fromEnv;
|
|
171
|
+
try {
|
|
172
|
+
return execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
173
|
+
cwd: deps.cwd,
|
|
174
|
+
encoding: "utf8",
|
|
175
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
176
|
+
timeout: GIT_TIMEOUT_MS,
|
|
177
|
+
}).trim();
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return deps.cwd;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Runs `session`, delegating to the installed version the repo pins when it differs from this
|
|
185
|
+
* one. A delegated run carries `DELEGATED_ENV` and never delegates again, so two versions can
|
|
186
|
+
* not bounce between each other. Always exits 0 so the session start is never blocked.
|
|
187
|
+
*
|
|
188
|
+
* @param deps - CLI dependencies.
|
|
189
|
+
* @returns Exit code 0.
|
|
190
|
+
*/
|
|
191
|
+
async function sessionCommand(deps) {
|
|
192
|
+
const repo = sessionRepo(deps);
|
|
193
|
+
const tools = toolsDir(deps.env);
|
|
194
|
+
const wanted = readConfig(repo).config?.version;
|
|
195
|
+
const delegated = deps.env[DELEGATED_ENV] === DELEGATED_VALUE;
|
|
196
|
+
if (!delegated && wanted && wanted !== deps.packageVersion && existsSync(packageBin(tools, wanted))) {
|
|
197
|
+
try {
|
|
198
|
+
await deps.run(packageBin(tools, wanted), ["session"], { env: { ...deps.env, [DELEGATED_ENV]: DELEGATED_VALUE } });
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
deps.print(`${OUTPUT_PREFIX}session ${wanted} failed: ${message(err)}`);
|
|
202
|
+
}
|
|
203
|
+
return EXIT_OK;
|
|
204
|
+
}
|
|
205
|
+
const lines = await session(repo, { env: deps.env, toolsDir: tools, run: deps.run, packageVersion: deps.packageVersion, docker: deps.docker });
|
|
206
|
+
for (const line of lines)
|
|
207
|
+
deps.print(line);
|
|
208
|
+
return EXIT_OK;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Runs `docker`: starts the daemon and waits for it.
|
|
212
|
+
*
|
|
213
|
+
* @param deps - CLI dependencies.
|
|
214
|
+
* @returns 0 when Docker answers, 1 otherwise.
|
|
215
|
+
*/
|
|
216
|
+
async function dockerCommand(deps) {
|
|
217
|
+
if (await deps.docker.ensure().catch(() => false))
|
|
218
|
+
return EXIT_OK;
|
|
219
|
+
deps.print(`${OUTPUT_PREFIX}dockerd did not start; see ${DOCKERD_LOG}`);
|
|
220
|
+
return EXIT_FAIL;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Runs `doctor` for the session repo.
|
|
224
|
+
*
|
|
225
|
+
* @param deps - CLI dependencies.
|
|
226
|
+
* @returns 0 when every check passes, 1 otherwise.
|
|
227
|
+
*/
|
|
228
|
+
async function doctorCommand(deps) {
|
|
229
|
+
const result = await doctor(sessionRepo(deps), {
|
|
230
|
+
capture: deps.capture ?? captureProcess,
|
|
231
|
+
toolsDir: toolsDir(deps.env),
|
|
232
|
+
packageVersion: deps.packageVersion,
|
|
233
|
+
dockerInfo: deps.dockerInfo ?? dockerResponds,
|
|
234
|
+
});
|
|
235
|
+
for (const line of result.lines)
|
|
236
|
+
deps.print(line);
|
|
237
|
+
return result.ok ? EXIT_OK : EXIT_FAIL;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Runs `init` for the session repo, copying the skill from `deps.packageRoot`.
|
|
241
|
+
*
|
|
242
|
+
* @param deps - CLI dependencies.
|
|
243
|
+
* @returns 0, or 1 when settings.json is not valid JSON or the skill cannot be copied.
|
|
244
|
+
*/
|
|
245
|
+
function initCommand(deps) {
|
|
246
|
+
let result;
|
|
247
|
+
try {
|
|
248
|
+
result = init(sessionRepo(deps), deps.packageVersion, deps.packageRoot ?? RUNNING_PACKAGE_ROOT);
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
deps.print(`${OUTPUT_PREFIX}${message(err)}`);
|
|
252
|
+
return EXIT_FAIL;
|
|
253
|
+
}
|
|
254
|
+
for (const line of result.lines)
|
|
255
|
+
deps.print(line);
|
|
256
|
+
return result.ok ? EXIT_OK : EXIT_FAIL;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Runs `manual`: writes `docs/agent-cloud.md` for the session repo.
|
|
260
|
+
*
|
|
261
|
+
* @param deps - CLI dependencies.
|
|
262
|
+
* @returns 0 on success, 1 when there is no usable config.
|
|
263
|
+
*/
|
|
264
|
+
function manualCommand(deps) {
|
|
265
|
+
try {
|
|
266
|
+
writeManual(sessionRepo(deps), deps.packageVersion);
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
deps.print(`${OUTPUT_PREFIX}${message(err)}`);
|
|
270
|
+
return EXIT_FAIL;
|
|
271
|
+
}
|
|
272
|
+
deps.print(`${OUTPUT_PREFIX}wrote ${MANUAL_FILE}`);
|
|
273
|
+
return EXIT_OK;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Asks one question on the terminal, showing the suggestion in brackets. The interface is closed
|
|
277
|
+
* after each answer so child processes (claude) get stdin back. Ctrl+C (which readline catches,
|
|
278
|
+
* so no process SIGINT arrives) rejects with `InterruptedError`.
|
|
279
|
+
*
|
|
280
|
+
* @param question - Question text.
|
|
281
|
+
* @param suggestion - Suggested answer, shown when not empty.
|
|
282
|
+
* @returns The raw answer.
|
|
283
|
+
* @throws InterruptedError on Ctrl+C.
|
|
284
|
+
* @example
|
|
285
|
+
* await askTerminal("Include these changes in the commit? [y/N]", ""); // "y"
|
|
286
|
+
*/
|
|
287
|
+
const askTerminal = async (question, suggestion) => {
|
|
288
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
289
|
+
try {
|
|
290
|
+
return await new Promise((resolveAnswer, reject) => {
|
|
291
|
+
rl.on("SIGINT", () => reject(new InterruptedError()));
|
|
292
|
+
rl.question(`${question}${suggestion ? ` [${suggestion}]` : ""}: `).then(resolveAnswer, reject);
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
finally {
|
|
296
|
+
rl.close();
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
/**
|
|
300
|
+
* Registers a process SIGINT listener (Ctrl+C while a child runs). Being registered, it replaces
|
|
301
|
+
* Node's default exit for every Ctrl+C; it exits 130 only when the handler says so, so a Ctrl+C
|
|
302
|
+
* meant for an interactive `claude` (same process group) does not end `prepare`.
|
|
303
|
+
*
|
|
304
|
+
* @param handler - Prints where the worktree is and says whether to exit.
|
|
305
|
+
* @returns Removes the listener.
|
|
306
|
+
* @example
|
|
307
|
+
* const stop = onProcessInterrupt(() => true); stop();
|
|
308
|
+
*/
|
|
309
|
+
function onProcessInterrupt(handler) {
|
|
310
|
+
const listener = () => {
|
|
311
|
+
if (handler())
|
|
312
|
+
process.exit(EXIT_INTERRUPTED);
|
|
313
|
+
};
|
|
314
|
+
process.on("SIGINT", listener);
|
|
315
|
+
return () => process.off("SIGINT", listener);
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Shows a text on the terminal and waits for Enter.
|
|
319
|
+
*
|
|
320
|
+
* @param text - Text to show.
|
|
321
|
+
* @example
|
|
322
|
+
* await waitTerminal("Press Enter");
|
|
323
|
+
*/
|
|
324
|
+
async function waitTerminal(text) {
|
|
325
|
+
await askTerminal(text, "");
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Runs `prepare` in the working directory.
|
|
329
|
+
*
|
|
330
|
+
* @param args - Arguments after `prepare`.
|
|
331
|
+
* @param deps - CLI dependencies.
|
|
332
|
+
* @returns 0 when the cloud session was started, 130 after Ctrl+C, 1 otherwise.
|
|
333
|
+
*/
|
|
334
|
+
async function prepareCommand(args, deps) {
|
|
335
|
+
const unknown = args.filter((arg) => arg !== RESUME_OPTION);
|
|
336
|
+
if (unknown.length) {
|
|
337
|
+
deps.print(`${OUTPUT_PREFIX}unknown option for prepare: ${unknown.join(" ")}`);
|
|
338
|
+
return EXIT_FAIL;
|
|
339
|
+
}
|
|
340
|
+
return prepare(deps.cwd, {
|
|
341
|
+
run: deps.run,
|
|
342
|
+
capture: deps.capture ?? captureProcess,
|
|
343
|
+
ask: deps.ask ?? askTerminal,
|
|
344
|
+
waitEnter: deps.waitEnter ?? waitTerminal,
|
|
345
|
+
print: deps.print,
|
|
346
|
+
packageVersion: deps.packageVersion,
|
|
347
|
+
packageRoot: deps.packageRoot ?? RUNNING_PACKAGE_ROOT,
|
|
348
|
+
today: deps.today ?? branchDay(new Date()),
|
|
349
|
+
isTty: deps.isTty ?? (process.stdin.isTTY === true && process.stdout.isTTY === true),
|
|
350
|
+
onInterrupt: deps.onInterrupt ?? onProcessInterrupt,
|
|
351
|
+
}, { resume: args.includes(RESUME_OPTION) });
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Runs the CLI.
|
|
355
|
+
*
|
|
356
|
+
* @param argv - Arguments after the executable name.
|
|
357
|
+
* @param deps - Dependencies; tests inject fakes.
|
|
358
|
+
* @returns The process exit code.
|
|
359
|
+
* @example
|
|
360
|
+
* process.exitCode = await main(["setup"], realDeps());
|
|
361
|
+
*/
|
|
362
|
+
export async function main(argv, deps) {
|
|
363
|
+
const [command, ...rest] = argv;
|
|
364
|
+
if (command === "setup")
|
|
365
|
+
return setupCommand(rest, deps);
|
|
366
|
+
if (command === "session")
|
|
367
|
+
return sessionCommand(deps);
|
|
368
|
+
if (command === "docker")
|
|
369
|
+
return dockerCommand(deps);
|
|
370
|
+
if (command === "doctor")
|
|
371
|
+
return doctorCommand(deps);
|
|
372
|
+
if (command === "init")
|
|
373
|
+
return initCommand(deps);
|
|
374
|
+
if (command === "manual")
|
|
375
|
+
return manualCommand(deps);
|
|
376
|
+
if (command === "prepare")
|
|
377
|
+
return prepareCommand(rest, deps);
|
|
378
|
+
if (command === "--version") {
|
|
379
|
+
deps.print(deps.packageVersion);
|
|
380
|
+
return EXIT_OK;
|
|
381
|
+
}
|
|
382
|
+
deps.print(HELP);
|
|
383
|
+
return command === undefined || command === "--help" ? EXIT_OK : EXIT_FAIL;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Builds the real dependencies of the CLI.
|
|
387
|
+
*
|
|
388
|
+
* @returns Dependencies backed by the process, the real runner and the real Docker.
|
|
389
|
+
*/
|
|
390
|
+
function realDeps() {
|
|
391
|
+
return {
|
|
392
|
+
env: process.env,
|
|
393
|
+
run: runProcess,
|
|
394
|
+
docker: realDocker,
|
|
395
|
+
packageVersion: readVersion(),
|
|
396
|
+
print: (line) => console.log(line),
|
|
397
|
+
cwd: process.cwd(),
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Tells whether this module is the process entry point (also when started through a symlink).
|
|
402
|
+
*
|
|
403
|
+
* @returns True when run as the CLI rather than imported.
|
|
404
|
+
*/
|
|
405
|
+
function isEntryPoint() {
|
|
406
|
+
const entry = process.argv[1];
|
|
407
|
+
if (!entry)
|
|
408
|
+
return false;
|
|
409
|
+
try {
|
|
410
|
+
return import.meta.url === pathToFileURL(realpathSync(entry)).href;
|
|
411
|
+
}
|
|
412
|
+
catch {
|
|
413
|
+
return false;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (isEntryPoint()) {
|
|
417
|
+
process.exitCode = await main(process.argv.slice(2), realDeps());
|
|
418
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Reader and validator for `<repo>/.claude/cloud.json`.
|
|
3
|
+
*/
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { CONFIG_FILE } from "./constants.js";
|
|
7
|
+
/** Top-level key that editors use to point at the JSON schema; allowed but not part of the config. */
|
|
8
|
+
const SCHEMA_KEY = "$schema";
|
|
9
|
+
/** Exact semver: no ranges, no prefixes. */
|
|
10
|
+
const EXACT_SEMVER = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/;
|
|
11
|
+
/** Top-level keys of `CloudConfig`; must match `schema.json#/properties` (minus `$schema`). */
|
|
12
|
+
export const CONFIG_KEYS = [
|
|
13
|
+
"version",
|
|
14
|
+
"aptRepos",
|
|
15
|
+
"apt",
|
|
16
|
+
"docker",
|
|
17
|
+
"setup",
|
|
18
|
+
"session",
|
|
19
|
+
"requiredEnv",
|
|
20
|
+
"notes",
|
|
21
|
+
"domains",
|
|
22
|
+
];
|
|
23
|
+
/** Keys accepted inside the `docker` object. */
|
|
24
|
+
const DOCKER_KEYS = ["pull"];
|
|
25
|
+
/** Keys that hold a list of strings. */
|
|
26
|
+
const LIST_KEYS = ["aptRepos", "apt", "setup", "session", "requiredEnv"];
|
|
27
|
+
/** A DNS host name: dot-separated labels, no scheme, port or path. */
|
|
28
|
+
const HOST_NAME = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
|
|
29
|
+
/**
|
|
30
|
+
* Tells whether a string is a plain DNS host name, the rule `domains` entries follow.
|
|
31
|
+
*
|
|
32
|
+
* @param host - Candidate host.
|
|
33
|
+
* @returns True for names like `api.example.com`; false with a scheme, port, path or one label.
|
|
34
|
+
* @example
|
|
35
|
+
* isHostName("api.example.com"); // true
|
|
36
|
+
* isHostName("https://api.example.com"); // false
|
|
37
|
+
*/
|
|
38
|
+
export function isHostName(host) {
|
|
39
|
+
return HOST_NAME.test(host);
|
|
40
|
+
}
|
|
41
|
+
/** Value of `docker.pull` that asks for image auto-detection. */
|
|
42
|
+
const PULL_AUTO = "auto";
|
|
43
|
+
/**
|
|
44
|
+
* Builds an error message prefixed with the config file name.
|
|
45
|
+
*
|
|
46
|
+
* @param message - What went wrong.
|
|
47
|
+
* @returns The prefixed message.
|
|
48
|
+
*/
|
|
49
|
+
function fail(message) {
|
|
50
|
+
return `${CONFIG_FILE}: ${message}`;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Tells whether a value is a plain JSON object.
|
|
54
|
+
*
|
|
55
|
+
* @param value - Any parsed JSON value.
|
|
56
|
+
* @returns True for non-null, non-array objects.
|
|
57
|
+
*/
|
|
58
|
+
function isRecord(value) {
|
|
59
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Tells whether a value is an array of strings.
|
|
63
|
+
*
|
|
64
|
+
* @param value - Any parsed JSON value.
|
|
65
|
+
* @returns True when every element is a string.
|
|
66
|
+
*/
|
|
67
|
+
function isStringList(value) {
|
|
68
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Validates `docker`, falling back to the default when it is invalid.
|
|
72
|
+
*
|
|
73
|
+
* @param value - Raw `docker` value, possibly undefined.
|
|
74
|
+
* @param errors - Collector that receives any problem found.
|
|
75
|
+
* @returns A valid docker section.
|
|
76
|
+
*/
|
|
77
|
+
function parseDocker(value, errors) {
|
|
78
|
+
if (value === undefined)
|
|
79
|
+
return { pull: PULL_AUTO };
|
|
80
|
+
if (!isRecord(value)) {
|
|
81
|
+
errors.push(fail('"docker" must be an object'));
|
|
82
|
+
return { pull: PULL_AUTO };
|
|
83
|
+
}
|
|
84
|
+
for (const key of Object.keys(value)) {
|
|
85
|
+
if (!DOCKER_KEYS.includes(key)) {
|
|
86
|
+
errors.push(fail(`unknown field "docker.${key}"`));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const pull = value.pull;
|
|
90
|
+
if (pull === undefined || pull === PULL_AUTO)
|
|
91
|
+
return { pull: PULL_AUTO };
|
|
92
|
+
if (pull === false || isStringList(pull))
|
|
93
|
+
return { pull };
|
|
94
|
+
errors.push(fail('"docker.pull" must be "auto", false or an array of strings'));
|
|
95
|
+
return { pull: PULL_AUTO };
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Validates a list-of-strings field, using `[]` when it is invalid or missing.
|
|
99
|
+
*
|
|
100
|
+
* @param raw - The whole parsed object.
|
|
101
|
+
* @param key - Field to read.
|
|
102
|
+
* @param errors - Collector that receives any problem found.
|
|
103
|
+
* @returns The list, or an empty list.
|
|
104
|
+
*/
|
|
105
|
+
function parseList(raw, key, errors) {
|
|
106
|
+
const value = raw[key];
|
|
107
|
+
if (value === undefined)
|
|
108
|
+
return [];
|
|
109
|
+
if (isStringList(value))
|
|
110
|
+
return value;
|
|
111
|
+
errors.push(fail(`"${key}" must be an array of strings`));
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Validates `domains`, dropping (and reporting) every entry that is not a plain host name.
|
|
116
|
+
*
|
|
117
|
+
* @param value - Raw `domains` value, possibly undefined.
|
|
118
|
+
* @param errors - Collector that receives any problem found.
|
|
119
|
+
* @returns The valid hosts.
|
|
120
|
+
* @example
|
|
121
|
+
* parseDomains(["api.example.com", "https://x"], errors); // ["api.example.com"], one error
|
|
122
|
+
*/
|
|
123
|
+
function parseDomains(value, errors) {
|
|
124
|
+
if (value === undefined)
|
|
125
|
+
return [];
|
|
126
|
+
if (!isStringList(value)) {
|
|
127
|
+
errors.push(fail('"domains" must be an array of strings'));
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
return value.filter((host) => {
|
|
131
|
+
const valid = isHostName(host);
|
|
132
|
+
if (!valid)
|
|
133
|
+
errors.push(fail(`"domains" has an invalid host: ${JSON.stringify(host)}`));
|
|
134
|
+
return valid;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Validates the parsed object and builds a normalized config.
|
|
139
|
+
*
|
|
140
|
+
* @param raw - Parsed JSON object.
|
|
141
|
+
* @param errors - Collector that receives any problem found.
|
|
142
|
+
* @returns The config, or undefined when `version` is missing or invalid.
|
|
143
|
+
*/
|
|
144
|
+
function parseConfig(raw, errors) {
|
|
145
|
+
const known = [...CONFIG_KEYS, SCHEMA_KEY];
|
|
146
|
+
for (const key of Object.keys(raw)) {
|
|
147
|
+
if (!known.includes(key))
|
|
148
|
+
errors.push(fail(`unknown field "${key}"`));
|
|
149
|
+
}
|
|
150
|
+
const version = raw.version;
|
|
151
|
+
if (typeof version !== "string" || !EXACT_SEMVER.test(version)) {
|
|
152
|
+
errors.push(fail('"version" must be an exact semver string such as "1.0.0"'));
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
const lists = Object.fromEntries(LIST_KEYS.map((key) => [key, parseList(raw, key, errors)]));
|
|
156
|
+
const config = {
|
|
157
|
+
version,
|
|
158
|
+
aptRepos: lists.aptRepos ?? [],
|
|
159
|
+
apt: lists.apt ?? [],
|
|
160
|
+
docker: parseDocker(raw.docker, errors),
|
|
161
|
+
setup: lists.setup ?? [],
|
|
162
|
+
session: lists.session ?? [],
|
|
163
|
+
requiredEnv: lists.requiredEnv ?? [],
|
|
164
|
+
domains: parseDomains(raw.domains, errors),
|
|
165
|
+
};
|
|
166
|
+
if (typeof raw.notes === "string")
|
|
167
|
+
config.notes = raw.notes;
|
|
168
|
+
else if (raw.notes !== undefined)
|
|
169
|
+
errors.push(fail('"notes" must be a string'));
|
|
170
|
+
return config;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Reads and validates a repo's cloud config without ever throwing.
|
|
174
|
+
*
|
|
175
|
+
* @param repo - Path of the repo root.
|
|
176
|
+
* @returns The config (absent when the file is missing or `version` is invalid) and the list of errors.
|
|
177
|
+
* @example
|
|
178
|
+
* readConfig("/home/user/my-repo"); // { config: { version: "1.0.0", ... }, errors: [] }
|
|
179
|
+
*/
|
|
180
|
+
export function readConfig(repo) {
|
|
181
|
+
const file = join(repo, CONFIG_FILE);
|
|
182
|
+
if (!existsSync(file))
|
|
183
|
+
return { errors: [] };
|
|
184
|
+
let raw;
|
|
185
|
+
try {
|
|
186
|
+
raw = JSON.parse(readFileSync(file, "utf8"));
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
190
|
+
return { errors: [fail(`cannot read file: ${reason}`)] };
|
|
191
|
+
}
|
|
192
|
+
if (!isRecord(raw))
|
|
193
|
+
return { errors: [fail("must contain a JSON object")] };
|
|
194
|
+
const errors = [];
|
|
195
|
+
const config = parseConfig(raw, errors);
|
|
196
|
+
return config ? { config, errors } : { errors };
|
|
197
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Fixed values shared by the agent-cloud-kit modules, each in a documented constant.
|
|
3
|
+
*/
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
/** Prefix of every line the CLI prints. */
|
|
6
|
+
export const OUTPUT_PREFIX = "agent-cloud-kit: ";
|
|
7
|
+
/** Path of the repo config file, relative to the repo root. */
|
|
8
|
+
export const CONFIG_FILE = ".claude/cloud.json";
|
|
9
|
+
/** Root of the running package: the folder above `dist/` (or `src/` in tests), which holds `skills/`. */
|
|
10
|
+
export const RUNNING_PACKAGE_ROOT = fileURLToPath(new URL("..", import.meta.url));
|
|
11
|
+
/** Name of this package on npm. */
|
|
12
|
+
export const PACKAGE = "agent-cloud-kit";
|
|
13
|
+
/**
|
|
14
|
+
* The npm spec the setup script installs: the major (`agent-cloud-kit@1`) for a stable version,
|
|
15
|
+
* the exact version for a prerelease, which a major range does not match.
|
|
16
|
+
*
|
|
17
|
+
* @param packageVersion - Version of the running package.
|
|
18
|
+
* @returns The spec.
|
|
19
|
+
* @example
|
|
20
|
+
* packageSpec("1.2.0"); // "agent-cloud-kit@1"
|
|
21
|
+
* packageSpec("1.0.0-rc.1"); // "agent-cloud-kit@1.0.0-rc.1"
|
|
22
|
+
*/
|
|
23
|
+
export function packageSpec(packageVersion) {
|
|
24
|
+
if (packageVersion.includes("-"))
|
|
25
|
+
return `${PACKAGE}@${packageVersion}`;
|
|
26
|
+
return `${PACKAGE}@${packageVersion.split(".")[0]}`;
|
|
27
|
+
}
|
|
28
|
+
/** Default root directory that holds the installed tools and the state files. */
|
|
29
|
+
export const TOOLS_DIR = "/opt/agent-cloud-kit";
|
|
30
|
+
/** Environment variable that overrides `TOOLS_DIR` (used by tests). */
|
|
31
|
+
export const TOOLS_DIR_ENV = "AGENT_CLOUD_KIT_DIR";
|
|
32
|
+
/** Default directory whose first-level folders are the cloned repos. */
|
|
33
|
+
export const HOME_DIR = "/home/user";
|
|
34
|
+
/** Environment variable that overrides `HOME_DIR` (used by tests). */
|
|
35
|
+
export const HOME_DIR_ENV = "AGENT_CLOUD_KIT_HOME";
|
|
36
|
+
/** Number of Docker images pulled at the same time. */
|
|
37
|
+
export const PULL_JOBS = 4;
|
|
38
|
+
/** Log file of the detached Docker daemon. */
|
|
39
|
+
export const DOCKERD_LOG = "/var/log/agent-cloud-kit-dockerd.log";
|
|
40
|
+
/** Seconds to wait for the Docker daemon to answer. */
|
|
41
|
+
export const DOCKER_WAIT_SECONDS = 60;
|
|
42
|
+
/** Total step time, in milliseconds, above which setup is reported as slow. */
|
|
43
|
+
export const SETUP_SLOW_MS = 240_000;
|
|
44
|
+
/** Time limit of one tool install step (a download or an install), in milliseconds. */
|
|
45
|
+
export const TOOL_INSTALL_TIMEOUT_MS = 120_000;
|
|
46
|
+
/** Time limit of one apt step (repo script, update or install), in milliseconds. */
|
|
47
|
+
export const APT_STEP_TIMEOUT_MS = 180_000;
|
|
48
|
+
/** Time limit of one `docker pull`, in milliseconds. */
|
|
49
|
+
export const DOCKER_PULL_TIMEOUT_MS = 180_000;
|
|
50
|
+
/** Time limit of one repo setup command, in milliseconds. */
|
|
51
|
+
export const SETUP_STEP_TIMEOUT_MS = 180_000;
|
|
52
|
+
/** Time limit of one `git fetch` at session start, in milliseconds. */
|
|
53
|
+
export const GIT_FETCH_TIMEOUT_MS = 60_000;
|
|
54
|
+
/** Default location of the cloud proxy CA bundle. */
|
|
55
|
+
export const PROXY_CA_DEFAULT = "/root/.ccr/ca-bundle.crt";
|
|
56
|
+
/**
|
|
57
|
+
* Resolves the tools directory, honoring the `AGENT_CLOUD_KIT_DIR` override.
|
|
58
|
+
*
|
|
59
|
+
* @param env - Process environment.
|
|
60
|
+
* @returns The override when set and non-empty, otherwise `TOOLS_DIR`.
|
|
61
|
+
* @example
|
|
62
|
+
* toolsDir({ AGENT_CLOUD_KIT_DIR: "/tmp/x" }); // "/tmp/x"
|
|
63
|
+
*/
|
|
64
|
+
export function toolsDir(env) {
|
|
65
|
+
return env[TOOLS_DIR_ENV] || TOOLS_DIR;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Resolves the directory that holds the cloned repos, honoring the `AGENT_CLOUD_KIT_HOME` override.
|
|
69
|
+
*
|
|
70
|
+
* @param env - Process environment.
|
|
71
|
+
* @returns The override when set and non-empty, otherwise `HOME_DIR`.
|
|
72
|
+
* @example
|
|
73
|
+
* homeDir({ AGENT_CLOUD_KIT_HOME: "/tmp/home" }); // "/tmp/home"
|
|
74
|
+
*/
|
|
75
|
+
export function homeDir(env) {
|
|
76
|
+
return env[HOME_DIR_ENV] || HOME_DIR;
|
|
77
|
+
}
|