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/dist/manual.js ADDED
@@ -0,0 +1,211 @@
1
+ /**
2
+ * @file The `manual` command: generates `docs/agent-cloud.md` (in Spanish) from the repo's cloud config
3
+ * keeping everything a person or agent wrote outside the generated block.
4
+ */
5
+ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
6
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
+ import { readConfig } from "./config.js";
8
+ import { CONFIG_FILE, OUTPUT_PREFIX, PACKAGE } from "./constants.js";
9
+ /** Path of the manual, relative to the repo. */
10
+ export const MANUAL_FILE = "docs/agent-cloud.md";
11
+ /** Marker that opens the generated block. */
12
+ export const BEGIN = "<!-- agent-cloud-kit:begin -->";
13
+ /** Marker that closes the generated block. */
14
+ export const END = "<!-- agent-cloud-kit:end -->";
15
+ /** Text of a new manual after the generated block, where findings are kept. */
16
+ const FINDINGS = "## Hallazgos\n\n_Lo que el agente descubra de la nube va aquí._\n";
17
+ /** Prefix of an https URL. */
18
+ const HTTPS_PREFIX = "https://";
19
+ /** Setup script the environment must run, in a `bash` block. */
20
+ const SETUP_SCRIPT = `#!/bin/bash\nnpx -y ${PACKAGE}@1 setup`;
21
+ /** The "how it works" section, five lines. */
22
+ const HOW_IT_WORKS = [
23
+ "## Cómo funciona",
24
+ "",
25
+ "1. agent-kitchen instala las herramientas y los ganchos en el repo.",
26
+ `2. \`${PACKAGE} prepare\` configura el repo con la skill y deja lista la lista de dominios y variables.`,
27
+ "3. Tú configuras el entorno en claude.ai con lo que indica la siguiente sección.",
28
+ "4. Una sesión en la nube verifica que el entorno quedó bien.",
29
+ "5. El PR guarda el resultado.",
30
+ ].join("\n");
31
+ /**
32
+ * Extracts the host of an apt repo URL.
33
+ *
34
+ * @param url - Apt repo entry of the config.
35
+ * @returns The host, or undefined when it is not a parseable https URL.
36
+ * @example
37
+ * repoHost("https://apt.example.com/repo"); // "apt.example.com"
38
+ */
39
+ function repoHost(url) {
40
+ if (!url.startsWith(HTTPS_PREFIX))
41
+ return undefined;
42
+ try {
43
+ return new URL(url).hostname || undefined;
44
+ }
45
+ catch {
46
+ return undefined;
47
+ }
48
+ }
49
+ /**
50
+ * Lists the hosts the cloud environment must allow: `domains` plus the apt repo hosts.
51
+ *
52
+ * @param config - Repo config.
53
+ * @returns Sorted hosts without duplicates.
54
+ * @example
55
+ * allowedHosts({ ...config, domains: ["b.com"], aptRepos: ["https://a.com/x"] }); // ["a.com", "b.com"]
56
+ */
57
+ function allowedHosts(config) {
58
+ const hosts = config.aptRepos.map(repoHost).filter((host) => host !== undefined);
59
+ return [...new Set([...config.domains, ...hosts])].sort();
60
+ }
61
+ /**
62
+ * Renders the "Configurar el entorno en claude.ai" section.
63
+ *
64
+ * @param config - Repo config.
65
+ * @returns The section, without a trailing newline.
66
+ * @example
67
+ * renderEnvironmentSection(config).startsWith("## Configurar el entorno en claude.ai"); // true
68
+ */
69
+ export function renderEnvironmentSection(config) {
70
+ const hosts = allowedHosts(config);
71
+ const lines = [
72
+ "## Configurar el entorno en claude.ai",
73
+ "",
74
+ "`claude --cloud` usa el entorno que elijas con `/remote-env` en una terminal (queda en tu configuración de usuario). Configura ese entorno en claude.ai/code:",
75
+ "",
76
+ "### Network access",
77
+ "",
78
+ 'Elige **Custom**, marca "Also include default list of common package managers" y agrega estos dominios:',
79
+ "",
80
+ ...(hosts.length ? hosts.map((host) => `- ${host}`) : ["ninguno extra"]),
81
+ "",
82
+ "### Setup script",
83
+ "",
84
+ "```bash",
85
+ SETUP_SCRIPT,
86
+ "```",
87
+ "",
88
+ "### Variables de entorno",
89
+ "",
90
+ ];
91
+ if (config.requiredEnv.length) {
92
+ lines.push(...config.requiredEnv.map((name) => `- \`${name}\``));
93
+ lines.push("", "Solo los nombres; los valores los pones tú en el entorno. Ojo: quien use el entorno puede leerlas, y no llegan al setup script (solo a la sesión).");
94
+ }
95
+ else {
96
+ lines.push("Ninguna.");
97
+ }
98
+ lines.push("", "### Claude GitHub App", "", "El Claude GitHub App debe estar instalado en el repo. Sin él, la sesión no puede pushear.");
99
+ return lines.join("\n");
100
+ }
101
+ /** The "how to verify" section: what a fresh cloud session checks. */
102
+ const VERIFY_SECTION = [
103
+ "## Verificar el entorno",
104
+ "",
105
+ "En una sesión nueva en la nube, revisa esto:",
106
+ "",
107
+ `1. \`${PACKAGE} doctor\` muestra todo en \`ok\` y termina con salida 0.`,
108
+ "2. `node_modules` existe.",
109
+ "3. Las versiones de `.tool-versions` son las que están instaladas.",
110
+ `4. Al empezar la sesión no hay líneas \`${OUTPUT_PREFIX.trim()}\` de falla.`,
111
+ ].join("\n");
112
+ /**
113
+ * Resolves the `notes` of the config: the content of the file when it is a relative path to an
114
+ * existing file inside the repo, else the text as is. Absolute paths and escapes are plain text.
115
+ *
116
+ * @param repo - Repo root.
117
+ * @param notes - Value of `notes` in the config, if any.
118
+ * @returns The notes text to show, if any.
119
+ * @example
120
+ * resolveNotes("/home/user/app", ".claude/cloud-notes.md"); // content of that file
121
+ * resolveNotes("/home/user/app", "Sin GPU."); // "Sin GPU."
122
+ */
123
+ export function resolveNotes(repo, notes) {
124
+ if (!notes || isAbsolute(notes))
125
+ return notes;
126
+ const file = resolve(repo, notes);
127
+ const inside = (path, root) => {
128
+ const rel = relative(root, path);
129
+ return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
130
+ };
131
+ try {
132
+ if (!inside(file, repo) || !statSync(file).isFile())
133
+ return notes;
134
+ if (!inside(realpathSync(file), realpathSync(repo)))
135
+ return notes;
136
+ return readFileSync(file, "utf8");
137
+ }
138
+ catch {
139
+ return notes;
140
+ }
141
+ }
142
+ /**
143
+ * Renders the generated block of the manual, markers included.
144
+ *
145
+ * @param input - Config, package version and optional notes text (default: `config.notes`).
146
+ * @returns The block.
147
+ * @example
148
+ * renderManual({ config, packageVersion: "1.0.0" }).startsWith(BEGIN); // true
149
+ */
150
+ export function renderManual(input) {
151
+ const notes = input.notes ?? input.config.notes;
152
+ const parts = [
153
+ BEGIN,
154
+ `<!-- Generado por ${PACKAGE} ${input.packageVersion}. No edites entre las marcas. -->`,
155
+ "# Nube de Claude Code",
156
+ HOW_IT_WORKS,
157
+ renderEnvironmentSection(input.config),
158
+ VERIFY_SECTION,
159
+ `## Qué corre en la nube\n\n${notes?.trim() ? notes.trim() : "Nada anotado."}`,
160
+ END,
161
+ ];
162
+ return parts.join("\n\n");
163
+ }
164
+ /**
165
+ * Puts the generated block into the manual text: replaces what is between the markers, or prepends
166
+ * the block when there are none; everything else stays untouched.
167
+ *
168
+ * @param existing - Current file content, if any.
169
+ * @param block - Output of `renderManual`.
170
+ * @returns The new file content.
171
+ * @example
172
+ * mergeManual(undefined, block); // block + "\n\n## Hallazgos ..."
173
+ */
174
+ export function mergeManual(existing, block) {
175
+ if (existing === undefined)
176
+ return `${block}\n\n${FINDINGS}`;
177
+ const start = existing.indexOf(BEGIN);
178
+ const end = existing.indexOf(END, start + 1);
179
+ if (start >= 0 && end >= 0)
180
+ return existing.slice(0, start) + block + existing.slice(end + END.length);
181
+ return `${block}\n\n${existing}`;
182
+ }
183
+ /**
184
+ * Writes `docs/agent-cloud.md` for a repo, atomically.
185
+ *
186
+ * @param repo - Repo root.
187
+ * @param packageVersion - Version of this package.
188
+ * @returns The path written.
189
+ * @throws When `.claude/cloud.json` is missing or invalid.
190
+ * @example
191
+ * writeManual("/home/user/app", "1.0.0"); // "/home/user/app/docs/agent-cloud.md"
192
+ */
193
+ export function writeManual(repo, packageVersion) {
194
+ const { config, errors } = readConfig(repo);
195
+ if (!config)
196
+ throw new Error(errors[0] ?? `${CONFIG_FILE} not found; run init first`);
197
+ const file = join(repo, MANUAL_FILE);
198
+ const existing = existsSync(file) ? readFileSync(file, "utf8") : undefined;
199
+ const block = renderManual({ config, packageVersion, notes: resolveNotes(repo, config.notes) });
200
+ mkdirSync(dirname(file), { recursive: true });
201
+ const temp = `${file}.${process.pid}.tmp`;
202
+ try {
203
+ writeFileSync(temp, mergeManual(existing, block));
204
+ renameSync(temp, file);
205
+ }
206
+ catch (err) {
207
+ rmSync(temp, { force: true });
208
+ throw err;
209
+ }
210
+ return file;
211
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * @file Pure parts of `prepare`: secret-name suggestions and preflight checks.
3
+ */
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ /** File, in the repo root, whose keys are suggested as secret names. */
7
+ const ENV_EXAMPLE_FILE = ".env.example";
8
+ /** Matches a `KEY=value` line (optionally prefixed by `export`) and captures the key. */
9
+ const ENV_LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/;
10
+ /** Flag `claude` must advertise in its help to launch cloud sessions. */
11
+ const CLOUD_FLAG = "--cloud";
12
+ /**
13
+ * Suggests secret names from the keys of `.env.example` in the repo root.
14
+ *
15
+ * @param repo Repo root.
16
+ * @returns Keys in file order, without duplicates; empty when the file does not exist.
17
+ * @example
18
+ * suggestSecrets("/repo"); // ["API_TOKEN", "DB_URL"]
19
+ */
20
+ export function suggestSecrets(repo) {
21
+ const file = join(repo, ENV_EXAMPLE_FILE);
22
+ if (!existsSync(file))
23
+ return [];
24
+ const keys = [];
25
+ for (const line of readFileSync(file, "utf8").split(/\r?\n/)) {
26
+ if (line.trimStart().startsWith("#"))
27
+ continue;
28
+ const key = ENV_LINE.exec(line)?.[1];
29
+ if (key !== undefined && !keys.includes(key))
30
+ keys.push(key);
31
+ }
32
+ return keys;
33
+ }
34
+ /**
35
+ * Checks that `prepare` can run.
36
+ *
37
+ * @param probe What was observed about the environment.
38
+ * @returns Problems in English, in a fixed order; empty when everything is fine.
39
+ * @example
40
+ * preflight({ isTty: false, hasOrigin: true, clean: true }); // ["prepare needs an interactive terminal", ...]
41
+ */
42
+ export function preflight(probe) {
43
+ const problems = [];
44
+ if (!probe.isTty)
45
+ problems.push("prepare needs an interactive terminal");
46
+ if (probe.gitRoot === undefined)
47
+ problems.push("not inside a git repository");
48
+ if (!probe.hasOrigin)
49
+ problems.push("the repository has no 'origin' remote");
50
+ if (!probe.clean)
51
+ problems.push("the working tree has uncommitted changes; commit or stash them first");
52
+ if (probe.claudeHelp === undefined)
53
+ problems.push("claude is not installed or not on the PATH");
54
+ else if (!probe.claudeHelp.includes(CLOUD_FLAG))
55
+ problems.push("claude does not support --cloud; update Claude Code");
56
+ return problems;
57
+ }