agent-cloud-kit 0.0.0-stage → 1.0.0-rc.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/LICENSE +21 -0
- package/README.md +229 -2
- package/dist/ca.js +30 -0
- package/dist/cli.js +418 -0
- package/dist/config.js +197 -0
- package/dist/constants.js +62 -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 +211 -0
- package/dist/prepare/detect.js +57 -0
- package/dist/prepare/index.js +492 -0
- package/dist/prepare/prompts.js +43 -0
- package/dist/prepare/questions.js +70 -0
- package/dist/run.js +113 -0
- package/dist/session.js +245 -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 +179 -0
- package/skills/cloud-ready/reference/compat.md +110 -0
- package/skills/cloud-ready/reference/knowledge.md +56 -0
package/dist/detect.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Deduces tool versions, Docker images and the default branch from a repo's files.
|
|
3
|
+
*/
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
/** Tools whose versions can be pinned. */
|
|
8
|
+
export const TOOLS = ["node", "bun", "deno", "pnpm", "yarn", "uv"];
|
|
9
|
+
/** Branch name used when nothing else can be determined. */
|
|
10
|
+
const FALLBACK_BRANCH = "main";
|
|
11
|
+
/** Maximum directory depth (root is 0) searched for Dockerfiles. */
|
|
12
|
+
const DOCKERFILE_MAX_DEPTH = 3;
|
|
13
|
+
/** Directories never searched for Dockerfiles. */
|
|
14
|
+
const SKIPPED_DIRS = ["node_modules", ".git"];
|
|
15
|
+
/** Alternative names that map to a supported tool. */
|
|
16
|
+
const TOOL_ALIASES = { nodejs: "node" };
|
|
17
|
+
/** Matches `docker-compose*.yml|yaml` and `compose*.yml|yaml`. */
|
|
18
|
+
const COMPOSE_FILE = /^(docker-)?compose([.-][^/]*)?\.ya?ml$/;
|
|
19
|
+
/** Matches an `image:` line in a compose file. */
|
|
20
|
+
const IMAGE_LINE = /^\s*(?:-\s+)?image:\s*(\S+)/;
|
|
21
|
+
/** Matches a pinned `FROM` line in a Dockerfile. */
|
|
22
|
+
const FROM_LINE = /^FROM\s+(?:--\S+\s+)*(\S+@sha256:\S+)/i;
|
|
23
|
+
/** Milliseconds after which a git call is abandoned, so session start never stalls. */
|
|
24
|
+
const GIT_TIMEOUT_MS = 10_000;
|
|
25
|
+
/** Environment that keeps git non-interactive and its output in English. */
|
|
26
|
+
const GIT_ENV = { GIT_TERMINAL_PROMPT: "0", LC_ALL: "C" };
|
|
27
|
+
/** Matches the `HEAD branch:` line of `git remote show`. */
|
|
28
|
+
const HEAD_BRANCH_LINE = /^\s*HEAD branch:\s*(\S+)/m;
|
|
29
|
+
/**
|
|
30
|
+
* Reads a file as text.
|
|
31
|
+
*
|
|
32
|
+
* @param path - File to read.
|
|
33
|
+
* @returns Its contents, or undefined when missing or unreadable.
|
|
34
|
+
*/
|
|
35
|
+
function readText(path) {
|
|
36
|
+
try {
|
|
37
|
+
return existsSync(path) ? readFileSync(path, "utf8") : undefined;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Resolves a name to a supported tool.
|
|
45
|
+
*
|
|
46
|
+
* @param name - Name as written in the source file.
|
|
47
|
+
* @returns The tool, or undefined when unsupported.
|
|
48
|
+
*/
|
|
49
|
+
function toTool(name) {
|
|
50
|
+
const mapped = TOOL_ALIASES[name] ?? name;
|
|
51
|
+
return TOOLS.includes(mapped) ? mapped : undefined;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Parses `.tool-versions` lines (`name version`).
|
|
55
|
+
*
|
|
56
|
+
* @param text - File contents.
|
|
57
|
+
* @returns Detected versions.
|
|
58
|
+
*/
|
|
59
|
+
function parseToolVersions(text) {
|
|
60
|
+
const found = {};
|
|
61
|
+
for (const line of text.split("\n")) {
|
|
62
|
+
const [name, version] = line.replace(/#.*/, "").trim().split(/\s+/);
|
|
63
|
+
const tool = name ? toTool(name) : undefined;
|
|
64
|
+
if (tool && version)
|
|
65
|
+
found[tool] ??= version;
|
|
66
|
+
}
|
|
67
|
+
return found;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Parses the `[tools]` section of `mise.toml` (`name = "version"`).
|
|
71
|
+
*
|
|
72
|
+
* @param text - File contents.
|
|
73
|
+
* @returns Detected versions.
|
|
74
|
+
*/
|
|
75
|
+
function parseMise(text) {
|
|
76
|
+
const found = {};
|
|
77
|
+
let inTools = false;
|
|
78
|
+
for (const raw of text.split("\n")) {
|
|
79
|
+
const line = raw.trim();
|
|
80
|
+
if (line.startsWith("["))
|
|
81
|
+
inTools = line === "[tools]";
|
|
82
|
+
const match = inTools ? /^"?([\w.-]+)"?\s*=\s*["']([^"']+)["']/.exec(line) : null;
|
|
83
|
+
const tool = match?.[1] ? toTool(match[1]) : undefined;
|
|
84
|
+
if (tool && match?.[2])
|
|
85
|
+
found[tool] ??= match[2];
|
|
86
|
+
}
|
|
87
|
+
return found;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Parses `.nvmrc`.
|
|
91
|
+
*
|
|
92
|
+
* @param text - File contents.
|
|
93
|
+
* @returns The node version, if any.
|
|
94
|
+
*/
|
|
95
|
+
function parseNvmrc(text) {
|
|
96
|
+
const version = text.trim().replace(/^v/, "");
|
|
97
|
+
return version ? { node: version } : {};
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Parses `package.json#packageManager` (`name@version+hash`).
|
|
101
|
+
*
|
|
102
|
+
* @param text - File contents.
|
|
103
|
+
* @returns Detected versions.
|
|
104
|
+
*/
|
|
105
|
+
function parsePackageManager(text) {
|
|
106
|
+
try {
|
|
107
|
+
const value = JSON.parse(text).packageManager;
|
|
108
|
+
const match = typeof value === "string" ? /^([^@]+)@([^+]+)/.exec(value) : null;
|
|
109
|
+
const tool = match?.[1] ? toTool(match[1]) : undefined;
|
|
110
|
+
return tool && match?.[2] ? { [tool]: match[2] } : {};
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return {};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Reads pinned tool versions; the first source that defines a tool wins.
|
|
118
|
+
*
|
|
119
|
+
* @param repo - Path of the repo root.
|
|
120
|
+
* @returns Versions by tool; unsupported tools are ignored.
|
|
121
|
+
* @example
|
|
122
|
+
* readToolVersions("/home/user/repo"); // { node: "24.14.1", bun: "1.4.2" }
|
|
123
|
+
*/
|
|
124
|
+
export function readToolVersions(repo) {
|
|
125
|
+
const sources = [
|
|
126
|
+
[".tool-versions", parseToolVersions],
|
|
127
|
+
["mise.toml", parseMise],
|
|
128
|
+
[".nvmrc", parseNvmrc],
|
|
129
|
+
["package.json", parsePackageManager],
|
|
130
|
+
];
|
|
131
|
+
const result = {};
|
|
132
|
+
for (const [file, parse] of sources) {
|
|
133
|
+
const text = readText(join(repo, file));
|
|
134
|
+
if (text !== undefined)
|
|
135
|
+
Object.assign(result, { ...parse(text), ...result });
|
|
136
|
+
}
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Extracts `image:` values from compose files in the repo root.
|
|
141
|
+
*
|
|
142
|
+
* @param repo - Path of the repo root.
|
|
143
|
+
* @returns Image references, unfiltered.
|
|
144
|
+
*/
|
|
145
|
+
function composeImages(repo) {
|
|
146
|
+
const images = [];
|
|
147
|
+
let entries = [];
|
|
148
|
+
try {
|
|
149
|
+
entries = readdirSync(repo);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return images;
|
|
153
|
+
}
|
|
154
|
+
for (const entry of entries) {
|
|
155
|
+
if (!COMPOSE_FILE.test(entry))
|
|
156
|
+
continue;
|
|
157
|
+
for (const line of (readText(join(repo, entry)) ?? "").split("\n")) {
|
|
158
|
+
const match = IMAGE_LINE.exec(line);
|
|
159
|
+
if (match?.[1])
|
|
160
|
+
images.push(match[1].replace(/^["']|["']$/g, ""));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return images;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Extracts digest-pinned `FROM` images from Dockerfiles up to a depth.
|
|
167
|
+
*
|
|
168
|
+
* @param dir - Directory to walk.
|
|
169
|
+
* @param depth - Depth of `dir` (root is 0).
|
|
170
|
+
* @returns Image references, unfiltered.
|
|
171
|
+
*/
|
|
172
|
+
function dockerfileImages(dir, depth) {
|
|
173
|
+
const images = [];
|
|
174
|
+
let entries = [];
|
|
175
|
+
try {
|
|
176
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return images;
|
|
180
|
+
}
|
|
181
|
+
for (const entry of entries) {
|
|
182
|
+
if (entry.isDirectory()) {
|
|
183
|
+
const deeper = depth < DOCKERFILE_MAX_DEPTH && !SKIPPED_DIRS.includes(entry.name);
|
|
184
|
+
if (deeper)
|
|
185
|
+
images.push(...dockerfileImages(join(dir, entry.name), depth + 1));
|
|
186
|
+
}
|
|
187
|
+
else if (entry.name === "Dockerfile" || entry.name.startsWith("Dockerfile.")) {
|
|
188
|
+
for (const line of (readText(join(dir, entry.name)) ?? "").split("\n")) {
|
|
189
|
+
const match = FROM_LINE.exec(line.trim());
|
|
190
|
+
if (match?.[1])
|
|
191
|
+
images.push(match[1]);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return images;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Finds Docker images referenced by compose files and pinned in Dockerfiles.
|
|
199
|
+
*
|
|
200
|
+
* @param repo - Path of the repo root.
|
|
201
|
+
* @returns Sorted, unique image references without variables (`$`).
|
|
202
|
+
* @example
|
|
203
|
+
* detectImages("/home/user/repo"); // ["oven/bun:1@sha256:abc", "postgres:16"]
|
|
204
|
+
*/
|
|
205
|
+
export function detectImages(repo) {
|
|
206
|
+
try {
|
|
207
|
+
const all = [...composeImages(repo), ...dockerfileImages(repo, 0)];
|
|
208
|
+
return [...new Set(all.filter((image) => !image.includes("$")))].sort();
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return [];
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Chooses the images a config asks to pull; setup and session share it so their fingerprints agree.
|
|
216
|
+
*
|
|
217
|
+
* @param config - Repo config.
|
|
218
|
+
* @param repo - Path of the repo root.
|
|
219
|
+
* @returns `detectImages(repo)` for `"auto"`, the explicit list, or `[]` for `false`.
|
|
220
|
+
* @example
|
|
221
|
+
* configImages({ ...config, docker: { pull: ["postgres:16"] } }, "/home/user/repo"); // ["postgres:16"]
|
|
222
|
+
*/
|
|
223
|
+
export function configImages(config, repo) {
|
|
224
|
+
const pull = config.docker.pull;
|
|
225
|
+
if (pull === "auto")
|
|
226
|
+
return detectImages(repo);
|
|
227
|
+
return pull === false ? [] : pull;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Runs git synchronously and returns trimmed stdout.
|
|
231
|
+
*
|
|
232
|
+
* @param repo - Working directory.
|
|
233
|
+
* @param args - Git arguments.
|
|
234
|
+
* @returns Output, or undefined when git fails.
|
|
235
|
+
*/
|
|
236
|
+
function gitOutput(repo, args) {
|
|
237
|
+
try {
|
|
238
|
+
return execFileSync("git", args, {
|
|
239
|
+
cwd: repo,
|
|
240
|
+
encoding: "utf8",
|
|
241
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
242
|
+
timeout: GIT_TIMEOUT_MS,
|
|
243
|
+
env: { ...process.env, ...GIT_ENV },
|
|
244
|
+
}).trim();
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Finds the repo's default branch; never throws.
|
|
252
|
+
*
|
|
253
|
+
* @param repo - Path of the repo root.
|
|
254
|
+
* @returns The branch from `origin/HEAD`, else from `git remote show origin`, else `main`.
|
|
255
|
+
* @example
|
|
256
|
+
* defaultBranch("/home/user/repo"); // "main"
|
|
257
|
+
*/
|
|
258
|
+
export function defaultBranch(repo) {
|
|
259
|
+
const ref = gitOutput(repo, ["symbolic-ref", "refs/remotes/origin/HEAD"]);
|
|
260
|
+
const fromRef = ref?.replace(/^refs\/remotes\/origin\//, "");
|
|
261
|
+
if (fromRef)
|
|
262
|
+
return fromRef;
|
|
263
|
+
const fromRemote = HEAD_BRANCH_LINE.exec(gitOutput(repo, ["remote", "show", "origin"]) ?? "")?.[1];
|
|
264
|
+
return fromRemote && fromRemote !== "(unknown)" ? fromRemote : FALLBACK_BRANCH;
|
|
265
|
+
}
|
package/dist/docker.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Starts the Docker daemon detached (cloud sessions have no init system) and waits for it.
|
|
3
|
+
*/
|
|
4
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
5
|
+
import { closeSync, openSync } from "node:fs";
|
|
6
|
+
import { DOCKERD_LOG, DOCKER_WAIT_SECONDS } from "./constants.js";
|
|
7
|
+
/** Timeout of each probe command, in milliseconds. */
|
|
8
|
+
const PROBE_TIMEOUT_MS = 10_000;
|
|
9
|
+
/** Delay between readiness checks, in milliseconds. */
|
|
10
|
+
const POLL_MS = 1000;
|
|
11
|
+
/** Milliseconds in a second. */
|
|
12
|
+
const MS_PER_SECOND = 1000;
|
|
13
|
+
/** Message returned when there is no `dockerd` executable on PATH. */
|
|
14
|
+
export const DOCKERD_MISSING = "dockerd not installed";
|
|
15
|
+
/**
|
|
16
|
+
* Tells whether a command exits with code 0.
|
|
17
|
+
*
|
|
18
|
+
* @param cmd - Executable.
|
|
19
|
+
* @param args - Arguments.
|
|
20
|
+
* @returns True on exit code 0; false on any failure, including a missing executable.
|
|
21
|
+
*/
|
|
22
|
+
function succeeds(cmd, args) {
|
|
23
|
+
try {
|
|
24
|
+
execFileSync(cmd, args, { stdio: "ignore", timeout: PROBE_TIMEOUT_MS });
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Tells whether the daemon already answers.
|
|
33
|
+
*
|
|
34
|
+
* @returns True when `docker info` succeeds.
|
|
35
|
+
* @example
|
|
36
|
+
* if (!dockerResponds()) console.log("docker is down");
|
|
37
|
+
*/
|
|
38
|
+
export function dockerResponds() {
|
|
39
|
+
return succeeds("docker", ["info"]);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Starts `dockerd` detached, logging to `DOCKERD_LOG`. Does nothing when Docker already
|
|
43
|
+
* answers or when one is already running. Never throws.
|
|
44
|
+
*
|
|
45
|
+
* @param probe - Command probe; the real one when omitted (tests inject a fake).
|
|
46
|
+
* @returns `DOCKERD_MISSING` when there is no `dockerd` on PATH, another error message when the
|
|
47
|
+
* daemon could not be started, undefined otherwise.
|
|
48
|
+
* @example
|
|
49
|
+
* const problem = startDockerDetached();
|
|
50
|
+
*/
|
|
51
|
+
export function startDockerDetached(probe = succeeds) {
|
|
52
|
+
if (probe("docker", ["info"]))
|
|
53
|
+
return undefined;
|
|
54
|
+
if (!probe("sh", ["-c", "command -v dockerd"]))
|
|
55
|
+
return DOCKERD_MISSING;
|
|
56
|
+
if (probe("pgrep", ["-x", "dockerd"]))
|
|
57
|
+
return undefined;
|
|
58
|
+
try {
|
|
59
|
+
const log = openSync(DOCKERD_LOG, "a");
|
|
60
|
+
try {
|
|
61
|
+
const child = spawn("dockerd", [], { detached: true, stdio: ["ignore", log, log] });
|
|
62
|
+
child.on("error", () => { });
|
|
63
|
+
child.unref();
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
closeSync(log);
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
return err instanceof Error ? err.message : String(err);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Starts the daemon and waits for it to answer, up to `DOCKER_WAIT_SECONDS`. Returns false at
|
|
76
|
+
* once when the daemon cannot be started at all (for example, no `dockerd` installed).
|
|
77
|
+
*
|
|
78
|
+
* @param probe - Command probe; the real one when omitted (tests inject a fake).
|
|
79
|
+
* @returns True when Docker answers in time; false when it never does or cannot start.
|
|
80
|
+
* @example
|
|
81
|
+
* if (await ensureDocker()) console.log("docker is up");
|
|
82
|
+
*/
|
|
83
|
+
export async function ensureDocker(probe = succeeds) {
|
|
84
|
+
if (startDockerDetached(probe))
|
|
85
|
+
return false;
|
|
86
|
+
const deadline = Date.now() + DOCKER_WAIT_SECONDS * MS_PER_SECOND;
|
|
87
|
+
while (Date.now() < deadline) {
|
|
88
|
+
if (probe("docker", ["info"]))
|
|
89
|
+
return true;
|
|
90
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_MS));
|
|
91
|
+
}
|
|
92
|
+
return probe("docker", ["info"]);
|
|
93
|
+
}
|
|
94
|
+
/** The real Docker operations. */
|
|
95
|
+
export const realDocker = { start: () => startDockerDetached(), ensure: () => ensureDocker() };
|
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The `doctor` command: checks the network allowlist, the pinned tools, Docker and the
|
|
3
|
+
* recorded machine state, and reports one line per check.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { readConfig } from "./config.js";
|
|
8
|
+
import { CONFIG_FILE, OUTPUT_PREFIX } from "./constants.js";
|
|
9
|
+
import { TOOLS, readToolVersions } from "./detect.js";
|
|
10
|
+
import { isValidRequest, toolExecutable } from "./installers.js";
|
|
11
|
+
import { expectedFingerprint } from "./session.js";
|
|
12
|
+
import { SKILL_DIR, skillVersion } from "./skill.js";
|
|
13
|
+
import { readState } from "./state.js";
|
|
14
|
+
/** Hosts the setup needs to reach through the cloud proxy. */
|
|
15
|
+
export const DOMAINS = ["registry.npmjs.org", "nodejs.org", "registry-1.docker.io", "pypi.org", "api.github.com"];
|
|
16
|
+
/** Time limit of each HEAD request, in seconds, as a curl argument. */
|
|
17
|
+
const PROBE_TIMEOUT_SECONDS = "5";
|
|
18
|
+
/** HTTP status the proxy answers with when a host is not on the allowlist. */
|
|
19
|
+
const BLOCKED_STATUS = "403";
|
|
20
|
+
/** Status curl prints when it got no HTTP answer at all. */
|
|
21
|
+
const NO_ANSWER_STATUS = "000";
|
|
22
|
+
/** Milliseconds in a second. */
|
|
23
|
+
const MS_PER_SECOND = 1000;
|
|
24
|
+
/** Decimals shown in step durations. */
|
|
25
|
+
const DURATION_DECIMALS = 1;
|
|
26
|
+
/** Repo-relative Claude Code project settings. */
|
|
27
|
+
const SETTINGS_FILE = ".claude/settings.json";
|
|
28
|
+
/**
|
|
29
|
+
* Maps a pass/fail boolean to a check level.
|
|
30
|
+
*
|
|
31
|
+
* @param passed - Whether the check passed.
|
|
32
|
+
* @returns `"ok"` or `"fail"`.
|
|
33
|
+
* @example
|
|
34
|
+
* verdict(false); // "fail"
|
|
35
|
+
*/
|
|
36
|
+
function verdict(passed) {
|
|
37
|
+
return passed ? "ok" : "fail";
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Checks that a host answers through the proxy with a curl HEAD request: a 403 or a curl failure
|
|
41
|
+
* counts as blocked, any other status as reachable.
|
|
42
|
+
*
|
|
43
|
+
* @param host - Host name.
|
|
44
|
+
* @param capture - Runs curl and returns its stdout.
|
|
45
|
+
* @returns The check outcome.
|
|
46
|
+
*/
|
|
47
|
+
async function checkDomain(host, capture) {
|
|
48
|
+
const args = ["-sS", "-o", "/dev/null", "-w", "%{http_code}", "-I", "--max-time", PROBE_TIMEOUT_SECONDS, `https://${host}/`];
|
|
49
|
+
try {
|
|
50
|
+
const status = (await capture("curl", args)).trim();
|
|
51
|
+
const ok = status !== BLOCKED_STATUS && status !== NO_ANSWER_STATUS;
|
|
52
|
+
return { text: `network ${host}: ${ok ? "ok" : "blocked"}`, level: verdict(ok) };
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return { text: `network ${host}: blocked`, level: "fail" };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Lists the hosts to probe: the fixed ones, then the configured ones that are not repeated.
|
|
60
|
+
*
|
|
61
|
+
* @param repo - Repo root.
|
|
62
|
+
* @returns Host names without duplicates.
|
|
63
|
+
* @example
|
|
64
|
+
* domainsToProbe("/home/user/app"); // ["registry.npmjs.org", ..., "api.example.com"]
|
|
65
|
+
*/
|
|
66
|
+
function domainsToProbe(repo) {
|
|
67
|
+
return [...new Set([...DOMAINS, ...(readConfig(repo).config?.domains ?? [])])];
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Checks that every pinned tool has its executable installed; an invalid version or a node
|
|
71
|
+
* version setup never resolved is a failed check, not an exception.
|
|
72
|
+
*
|
|
73
|
+
* @param repo - Repo root.
|
|
74
|
+
* @param toolsDir - Tools directory.
|
|
75
|
+
* @param state - Recorded state, for the versions setup resolved.
|
|
76
|
+
* @returns One check per pinned tool.
|
|
77
|
+
*/
|
|
78
|
+
function checkTools(repo, toolsDir, state) {
|
|
79
|
+
const versions = readToolVersions(repo);
|
|
80
|
+
return TOOLS.flatMap((tool) => {
|
|
81
|
+
const version = versions[tool];
|
|
82
|
+
if (!version)
|
|
83
|
+
return [];
|
|
84
|
+
if (!isValidRequest(tool, version))
|
|
85
|
+
return [{ text: `tool ${tool} ${version}: invalid version`, level: "fail" }];
|
|
86
|
+
const executable = toolExecutable(tool, version, toolsDir, state?.resolved ?? {});
|
|
87
|
+
if (!executable)
|
|
88
|
+
return [{ text: `tool ${tool} ${version}: not resolved (setup did not install it)`, level: "fail" }];
|
|
89
|
+
const present = existsSync(executable);
|
|
90
|
+
return [{ text: `tool ${tool} ${version}: ${present ? "ok" : "missing"}`, level: verdict(present) }];
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Checks the repo config, the recorded state, its fingerprint and the step durations.
|
|
95
|
+
*
|
|
96
|
+
* @param repo - Repo root.
|
|
97
|
+
* @param ctx - Dependencies.
|
|
98
|
+
* @returns The checks, in order.
|
|
99
|
+
*/
|
|
100
|
+
function checkState(repo, ctx) {
|
|
101
|
+
if (!existsSync(join(repo, CONFIG_FILE)))
|
|
102
|
+
return [{ text: `${CONFIG_FILE}: missing`, level: "fail" }];
|
|
103
|
+
const { config, errors } = readConfig(repo);
|
|
104
|
+
const out = errors.map((text) => ({ text: `${CONFIG_FILE}: ${text}`, level: "fail" }));
|
|
105
|
+
if (!config)
|
|
106
|
+
return out;
|
|
107
|
+
const state = readState(ctx.toolsDir, repo);
|
|
108
|
+
if (!state)
|
|
109
|
+
return [...out, { text: "state: missing (setup did not run)", level: "fail" }];
|
|
110
|
+
const same = expectedFingerprint(repo, config, state) === state.fingerprint;
|
|
111
|
+
out.push({ text: `fingerprint: ${same ? "ok" : "mismatch"}`, level: verdict(same) });
|
|
112
|
+
for (const s of state.steps) {
|
|
113
|
+
const seconds = (s.ms / MS_PER_SECOND).toFixed(DURATION_DECIMALS);
|
|
114
|
+
out.push({ text: `step ${s.name}: ${s.ok ? "ok" : "failed"} (${seconds}s)`, level: verdict(s.ok) });
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Warns about each plugin enabled in the project settings: plugins do not reach cloud sessions.
|
|
120
|
+
* Missing or malformed settings give no warnings.
|
|
121
|
+
*
|
|
122
|
+
* @param repo - Repo root.
|
|
123
|
+
* @returns One warning per enabled plugin.
|
|
124
|
+
* @example
|
|
125
|
+
* checkPlugins("/home/user/app"); // [{ text: "plugin x@m is enabled but ...", level: "warn" }]
|
|
126
|
+
*/
|
|
127
|
+
function checkPlugins(repo) {
|
|
128
|
+
try {
|
|
129
|
+
const settings = JSON.parse(readFileSync(join(repo, SETTINGS_FILE), "utf8"));
|
|
130
|
+
const plugins = settings?.enabledPlugins;
|
|
131
|
+
if (typeof plugins !== "object" || plugins === null)
|
|
132
|
+
return [];
|
|
133
|
+
return Object.entries(plugins)
|
|
134
|
+
.filter(([, enabled]) => enabled === true)
|
|
135
|
+
.map(([name]) => ({
|
|
136
|
+
text: `warn: plugin ${name} is enabled but is not in the repo; it will not reach the cloud (copy its skills with skills-manager)`,
|
|
137
|
+
level: "warn",
|
|
138
|
+
}));
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Warns when the installed `cloud-ready` skill is of another version than the package.
|
|
146
|
+
*
|
|
147
|
+
* @param repo - Repo root.
|
|
148
|
+
* @param packageVersion - Version of this package.
|
|
149
|
+
* @returns A warning, or nothing when there is no skill or the versions match.
|
|
150
|
+
* @example
|
|
151
|
+
* checkSkill("/home/user/app", "1.0.0"); // [{ text: "warn: .agents/skills/cloud-ready is 0.9.0, ...", level: "warn" }]
|
|
152
|
+
*/
|
|
153
|
+
function checkSkill(repo, packageVersion) {
|
|
154
|
+
const installed = skillVersion(repo);
|
|
155
|
+
if (installed === undefined || installed === packageVersion)
|
|
156
|
+
return [];
|
|
157
|
+
return [
|
|
158
|
+
{
|
|
159
|
+
text: `warn: ${SKILL_DIR} is ${installed}, the package is ${packageVersion}; run npx agent-cloud-kit@${packageVersion} init`,
|
|
160
|
+
level: "warn",
|
|
161
|
+
},
|
|
162
|
+
];
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Runs one group of checks, turning anything it throws into a failed check.
|
|
166
|
+
*
|
|
167
|
+
* @param name - Group name, for the failed line.
|
|
168
|
+
* @param fn - Produces the checks.
|
|
169
|
+
* @returns The checks, or one failed check with the error.
|
|
170
|
+
*/
|
|
171
|
+
async function guarded(name, fn) {
|
|
172
|
+
try {
|
|
173
|
+
return await fn();
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
return [{ text: `${name}: error: ${err instanceof Error ? err.message : String(err)}`, level: "fail" }];
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Runs every diagnostic for a repo; never throws.
|
|
181
|
+
*
|
|
182
|
+
* @param repo - Repo root.
|
|
183
|
+
* @param ctx - Dependencies (curl capture, tools dir, package version, Docker probe).
|
|
184
|
+
* @returns The report lines and whether everything passed.
|
|
185
|
+
* @example
|
|
186
|
+
* const { lines, ok } = await doctor("/home/user/app", { capture: captureProcess, toolsDir, packageVersion: "1.0.0", dockerInfo });
|
|
187
|
+
*/
|
|
188
|
+
export async function doctor(repo, ctx) {
|
|
189
|
+
const checks = [];
|
|
190
|
+
checks.push(...(await guarded("network", async () => Promise.all(domainsToProbe(repo).map((host) => checkDomain(host, ctx.capture))))));
|
|
191
|
+
checks.push(...(await guarded("tools", () => checkTools(repo, ctx.toolsDir, readState(ctx.toolsDir, repo)))));
|
|
192
|
+
checks.push(...(await guarded("docker", () => {
|
|
193
|
+
const docker = ctx.dockerInfo();
|
|
194
|
+
return [{ text: `docker: ${docker ? "ok" : "missing"}`, level: verdict(docker) }];
|
|
195
|
+
})));
|
|
196
|
+
checks.push(...(await guarded("state", () => checkState(repo, ctx))));
|
|
197
|
+
checks.push(...(await guarded("plugins", () => checkPlugins(repo))));
|
|
198
|
+
checks.push(...(await guarded("skill", () => checkSkill(repo, ctx.packageVersion))));
|
|
199
|
+
return { lines: checks.map((c) => OUTPUT_PREFIX + c.text), ok: checks.every((c) => c.level !== "fail") };
|
|
200
|
+
}
|
package/dist/init.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The `init` command: scaffolds `.claude/cloud.json`, registers the SessionStart hook in
|
|
3
|
+
* `.claude/settings.json` without touching anything else, and installs the `cloud-ready` skill.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { CONFIG_FILE, OUTPUT_PREFIX, PACKAGE } from "./constants.js";
|
|
8
|
+
import { installSkill, linkSkill } from "./skill.js";
|
|
9
|
+
/** Path of the Claude Code settings file, relative to the repo root. */
|
|
10
|
+
const SETTINGS_FILE = ".claude/settings.json";
|
|
11
|
+
/** Text that marks a SessionStart hook as ours. */
|
|
12
|
+
const HOOK_MARKER = "agent-cloud-kit session";
|
|
13
|
+
/** Timeout of the hook, in seconds. */
|
|
14
|
+
const HOOK_TIMEOUT_SECONDS = 600;
|
|
15
|
+
/** Indentation of the files written. */
|
|
16
|
+
const JSON_INDENT = 2;
|
|
17
|
+
/** Command of the SessionStart hook: does nothing outside cloud sessions. */
|
|
18
|
+
const HOOK_COMMAND = "[ \"$CLAUDE_CODE_REMOTE\" = true ] || exit 0; /opt/agent-cloud-kit/bin/agent-cloud-kit session || echo 'agent-cloud-kit: the environment setup did not run; see npx agent-cloud-kit doctor'";
|
|
19
|
+
/** The hook `init` registers. */
|
|
20
|
+
const HOOK = { type: "command", command: HOOK_COMMAND, timeout: HOOK_TIMEOUT_SECONDS };
|
|
21
|
+
/**
|
|
22
|
+
* Tells whether a value is a plain JSON object.
|
|
23
|
+
*
|
|
24
|
+
* @param value - Any parsed JSON value.
|
|
25
|
+
* @returns True for non-null, non-array objects.
|
|
26
|
+
*/
|
|
27
|
+
function isRecord(value) {
|
|
28
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Builds the outcome for a settings file that cannot be updated safely: it is left untouched and
|
|
32
|
+
* the user gets the hook to add by hand.
|
|
33
|
+
*
|
|
34
|
+
* @param reason - What is wrong with the file.
|
|
35
|
+
* @returns The failed outcome.
|
|
36
|
+
*/
|
|
37
|
+
function addByHand(reason) {
|
|
38
|
+
const entry = JSON.stringify({ hooks: [HOOK] });
|
|
39
|
+
return { line: `${SETTINGS_FILE} ${reason}, left untouched; add this entry to hooks.SessionStart by hand: ${entry}`, ok: false };
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Writes a JSON file with a trailing newline, creating its folder.
|
|
43
|
+
*
|
|
44
|
+
* @param file - Absolute path.
|
|
45
|
+
* @param value - Value to serialize.
|
|
46
|
+
*/
|
|
47
|
+
function writeJson(file, value) {
|
|
48
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
49
|
+
writeFileSync(file, `${JSON.stringify(value, null, JSON_INDENT)}\n`);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Tells whether some SessionStart entry already runs `agent-cloud-kit session`.
|
|
53
|
+
*
|
|
54
|
+
* @param entries - The `hooks.SessionStart` array.
|
|
55
|
+
* @returns True when any hook command contains the marker.
|
|
56
|
+
*/
|
|
57
|
+
function hasHook(entries) {
|
|
58
|
+
return entries.some((entry) => {
|
|
59
|
+
const hooks = entry?.hooks;
|
|
60
|
+
return Array.isArray(hooks) && hooks.some((h) => String(h?.command ?? "").includes(HOOK_MARKER));
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Creates `.claude/cloud.json` unless it exists.
|
|
65
|
+
*
|
|
66
|
+
* @param repo - Repo root.
|
|
67
|
+
* @param packageVersion - Version of this package.
|
|
68
|
+
* @returns A line describing the outcome.
|
|
69
|
+
*/
|
|
70
|
+
function scaffoldConfig(repo, packageVersion) {
|
|
71
|
+
const file = join(repo, CONFIG_FILE);
|
|
72
|
+
if (existsSync(file))
|
|
73
|
+
return `${CONFIG_FILE} already exists, left as is`;
|
|
74
|
+
writeJson(file, {
|
|
75
|
+
$schema: `https://unpkg.com/${PACKAGE}@${packageVersion}/schema.json`,
|
|
76
|
+
version: packageVersion,
|
|
77
|
+
aptRepos: [],
|
|
78
|
+
apt: [],
|
|
79
|
+
docker: { pull: "auto" },
|
|
80
|
+
setup: [],
|
|
81
|
+
session: [],
|
|
82
|
+
requiredEnv: [],
|
|
83
|
+
domains: [],
|
|
84
|
+
});
|
|
85
|
+
return `created ${CONFIG_FILE}`;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Adds the SessionStart hook to `.claude/settings.json`, keeping everything else.
|
|
89
|
+
*
|
|
90
|
+
* @param repo - Repo root.
|
|
91
|
+
* @returns The outcome line and whether it succeeded.
|
|
92
|
+
*/
|
|
93
|
+
function registerHook(repo) {
|
|
94
|
+
const file = join(repo, SETTINGS_FILE);
|
|
95
|
+
let settings = {};
|
|
96
|
+
if (existsSync(file)) {
|
|
97
|
+
try {
|
|
98
|
+
settings = JSON.parse(readFileSync(file, "utf8"));
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return addByHand("is not valid JSON");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (!isRecord(settings))
|
|
105
|
+
return addByHand("is not a JSON object");
|
|
106
|
+
const hooks = settings.hooks ?? {};
|
|
107
|
+
if (!isRecord(hooks))
|
|
108
|
+
return addByHand('has a "hooks" that is not an object');
|
|
109
|
+
const entries = hooks.SessionStart ?? [];
|
|
110
|
+
if (!Array.isArray(entries))
|
|
111
|
+
return addByHand('has a "hooks.SessionStart" that is not an array');
|
|
112
|
+
if (hasHook(entries))
|
|
113
|
+
return { line: `${SETTINGS_FILE} already has the SessionStart hook`, ok: true };
|
|
114
|
+
writeJson(file, { ...settings, hooks: { ...hooks, SessionStart: [...entries, { hooks: [HOOK] }] } });
|
|
115
|
+
return { line: `added the SessionStart hook to ${SETTINGS_FILE}`, ok: true };
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Sets a repo up for agent-cloud-kit; safe to run again. It is the only place that copies the
|
|
119
|
+
* `cloud-ready` skill into the repo (and links it for Claude Code).
|
|
120
|
+
*
|
|
121
|
+
* @param repo - Repo root.
|
|
122
|
+
* @param packageVersion - Version of this package, written into the config and the skill.
|
|
123
|
+
* @param packageRoot - Root of this package, which holds `skills/cloud-ready`.
|
|
124
|
+
* @returns What it did, and whether settings.json could be updated.
|
|
125
|
+
* @throws When the skill cannot be copied.
|
|
126
|
+
* @example
|
|
127
|
+
* init("/home/user/app", "1.0.0", "/usr/lib/node_modules/agent-cloud-kit").lines.forEach((l) => console.log(l));
|
|
128
|
+
*/
|
|
129
|
+
export function init(repo, packageVersion, packageRoot) {
|
|
130
|
+
const config = scaffoldConfig(repo, packageVersion);
|
|
131
|
+
const hook = registerHook(repo);
|
|
132
|
+
const skill = installSkill(repo, packageRoot, packageVersion);
|
|
133
|
+
const link = linkSkill(repo);
|
|
134
|
+
const lines = [config, hook.line, skill, ...(link ? [link] : [])];
|
|
135
|
+
return { lines: lines.map((l) => OUTPUT_PREFIX + l), ok: hook.ok };
|
|
136
|
+
}
|