agent-cloud-kit 1.0.0-rc.2 → 1.0.0-rc.4

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/README.md CHANGED
@@ -43,6 +43,10 @@ platform does not tell you:
43
43
  commits, pushes and opens a draft PR.
44
44
  - **En la rama actual, sin commit:** does the same in your checkout and stops there; you review
45
45
  with `git diff`, commit, push, and then run `npx agent-cloud-kit verify`.
46
+
47
+ Before Claude opens, a short screen says what will happen and waits for Enter. If the repo uses
48
+ [claude-repo-guard](https://github.com/ramon-sg/claude-repo-guard) and has its `claude:free`
49
+ script, Claude opens through it, so the skill can write the files the guard blocks.
46
50
  3. **Configure the environment in claude.ai** following `docs/agent-cloud.md`, which `prepare`
47
51
  generated from your `.claude/cloud.json`. The document keeps growing: when the skill, locally
48
52
  or in the cloud, finds that a domain or an environment variable is missing, it adds it to
package/dist/cli.js CHANGED
@@ -323,14 +323,24 @@ function onProcessInterrupt(handler) {
323
323
  return () => process.off("SIGINT", listener);
324
324
  }
325
325
  /**
326
- * Shows a text on the terminal and waits for Enter.
326
+ * Shows a text on the terminal and waits for Enter. Ctrl+C rejects with `InterruptedError`.
327
327
  *
328
- * @param text - Text to show.
328
+ * @param text - Text to show, printed as is.
329
+ * @throws InterruptedError on Ctrl+C.
329
330
  * @example
330
- * await waitTerminal("Presiona Enter");
331
+ * await waitTerminal("Presiona Enter ");
331
332
  */
332
333
  async function waitTerminal(text) {
333
- await askTerminal(text, "");
334
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
335
+ try {
336
+ await new Promise((resolveAnswer, reject) => {
337
+ rl.on("SIGINT", () => reject(new InterruptedError()));
338
+ rl.question(text).then(resolveAnswer, reject);
339
+ });
340
+ }
341
+ finally {
342
+ rl.close();
343
+ }
334
344
  }
335
345
  /**
336
346
  * Runs `prepare` in the working directory.
@@ -385,6 +395,7 @@ function prepareDeps(deps) {
385
395
  packageRoot: deps.packageRoot ?? RUNNING_PACKAGE_ROOT,
386
396
  today: deps.today ?? branchDay(new Date()),
387
397
  isTty: deps.isTty ?? (process.stdin.isTTY === true && process.stdout.isTTY === true),
398
+ color: deps.isTty === undefined && process.stdout.isTTY === true && !deps.env.NO_COLOR,
388
399
  onInterrupt: deps.onInterrupt ?? onProcessInterrupt,
389
400
  };
390
401
  }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * @file The screen `prepare` shows before opening Claude Code: a small cloud, what will happen and
3
+ * what the user has to do, in a few colored lines.
4
+ */
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ /** Script of claude-repo-guard that opens Claude without sandbox nor permission prompts. */
8
+ export const FREE_SCRIPT = "claude:free";
9
+ /**
10
+ * Wraps a text in an ANSI code when color is on.
11
+ *
12
+ * @param on - Whether color is on.
13
+ * @param code - SGR code, like `36` for cyan.
14
+ * @param text - Text.
15
+ * @returns The text, colored or not.
16
+ * @example
17
+ * paint(false, "36", "a"); // "a"
18
+ */
19
+ function paint(on, code, text) {
20
+ return on ? `\x1b[${code}m${text}\x1b[0m` : text;
21
+ }
22
+ /**
23
+ * Renders the screen shown before opening Claude Code (without the Enter line, which the caller
24
+ * asks).
25
+ *
26
+ * @param input - Place, mode and color.
27
+ * @returns The lines.
28
+ * @example
29
+ * renderBanner({ place: "pr", free: false, color: false })[2]; // " .-( ). Ahora se abre Claude Code"
30
+ */
31
+ export function renderBanner(input) {
32
+ const c = (code, text) => paint(input.color, code, text);
33
+ const cloud = (text) => c("36", text);
34
+ const step = (n, text) => ` ${c("1;35", String(n))} ${text}`;
35
+ const after = input.place === "pr" ? "prepare hace commit, push y PR" : "quedan los cambios sin commit";
36
+ const lines = [
37
+ "",
38
+ cloud(" .--."),
39
+ `${cloud(" .-( ).")} ${c("1", "Ahora se abre Claude Code")}`,
40
+ `${cloud(" (___.__)__)")} ${c("2", "con la skill cloud-ready")}`,
41
+ "",
42
+ step(1, "Claude revisa el repo y te propone cómo prepararlo para la nube"),
43
+ step(2, "Responde sus preguntas"),
44
+ step(3, `Cuando diga ${c("1", "configure terminó")}, sal con ${c("1;33", "/exit")} ${c("2", `→ ${after}`)}`),
45
+ ];
46
+ if (input.free)
47
+ lines.push("", c("2", ` Se abre con ${FREE_SCRIPT} (sin sandbox ni preguntas de permisos): el repo usa claude-repo-guard`));
48
+ lines.push("");
49
+ return lines;
50
+ }
51
+ /**
52
+ * The line asked after the banner.
53
+ *
54
+ * @param color - Whether to use ANSI colors.
55
+ * @returns The text.
56
+ * @example
57
+ * enterLine(false); // " ⏎ Enter abre Claude Code · Ctrl+C cancela "
58
+ */
59
+ export function enterLine(color) {
60
+ return ` ${paint(color, "1;32", "⏎ Enter")} abre Claude Code ${paint(color, "2", "· Ctrl+C cancela")} `;
61
+ }
62
+ /**
63
+ * The line asked while the user configures the environment in claude.ai.
64
+ *
65
+ * @param color - Whether to use ANSI colors.
66
+ * @returns The text.
67
+ * @example
68
+ * continueLine(false); // "\n ⏎ Enter cuando hayas configurado el entorno en claude.ai · abre la sesión cloud que lo verifica "
69
+ */
70
+ export function continueLine(color) {
71
+ return `\n ${paint(color, "1;32", "⏎ Enter")} cuando hayas configurado el entorno en claude.ai ${paint(color, "2", "· abre la sesión cloud que lo verifica")} `;
72
+ }
73
+ /** Config file claude-repo-guard installs; the free mode is used only when it is there. */
74
+ const GUARD_CONFIG = ".claude/repo-guard.config.json";
75
+ /**
76
+ * Tells whether the repo uses claude-repo-guard and has its `claude:free` script. Both are
77
+ * required: the free mode drops the sandbox and the permission prompts, so a script with that
78
+ * name alone is not enough.
79
+ *
80
+ * @param repo - Repo root.
81
+ * @returns True when the guard config exists and `package.json` has the script.
82
+ * @example
83
+ * hasFreeScript("/home/u/app"); // true
84
+ */
85
+ export function hasFreeScript(repo) {
86
+ const file = join(repo, "package.json");
87
+ if (!existsSync(file) || !existsSync(join(repo, GUARD_CONFIG)))
88
+ return false;
89
+ try {
90
+ const scripts = JSON.parse(readFileSync(file, "utf8")).scripts;
91
+ return typeof scripts?.[FREE_SCRIPT] === "string";
92
+ }
93
+ catch {
94
+ return false;
95
+ }
96
+ }
97
+ /**
98
+ * The command that opens Claude with a prompt: the repo's `claude:free` script through its package
99
+ * manager, or `claude` itself.
100
+ *
101
+ * @param repo - Repo root.
102
+ * @param prompt - First prompt.
103
+ * @returns Command and arguments.
104
+ * @example
105
+ * claudeCommand("/home/u/app", "/cloud-ready configure"); // { cmd: "npm", args: ["run", "claude:free", "--", "/cloud-ready configure"] }
106
+ */
107
+ export function claudeCommand(repo, prompt) {
108
+ if (!hasFreeScript(repo))
109
+ return { cmd: "claude", args: [prompt] };
110
+ const manager = packageManager(repo);
111
+ if (manager === "npm")
112
+ return { cmd: "npm", args: ["run", FREE_SCRIPT, "--", prompt] };
113
+ return { cmd: manager, args: ["run", FREE_SCRIPT, prompt] };
114
+ }
115
+ /** Lockfiles that tell the package manager, checked in order. */
116
+ const LOCKFILES = [
117
+ ["pnpm-lock.yaml", "pnpm"],
118
+ ["bun.lock", "bun"],
119
+ ["bun.lockb", "bun"],
120
+ ["yarn.lock", "yarn"],
121
+ ];
122
+ /**
123
+ * The repo's package manager: `packageManager` in `package.json`, else the lockfile, else npm.
124
+ *
125
+ * @param repo - Repo root.
126
+ * @returns `npm`, `pnpm`, `yarn` or `bun`.
127
+ * @example
128
+ * packageManager("/home/u/app"); // "pnpm"
129
+ */
130
+ function packageManager(repo) {
131
+ try {
132
+ const field = JSON.parse(readFileSync(join(repo, "package.json"), "utf8")).packageManager;
133
+ const name = typeof field === "string" ? field.split("@")[0] : undefined;
134
+ if (name === "npm" || name === "pnpm" || name === "yarn" || name === "bun")
135
+ return name;
136
+ }
137
+ catch {
138
+ // NOTE: no readable package.json; fall back to the lockfile.
139
+ }
140
+ return LOCKFILES.find(([file]) => existsSync(join(repo, file)))?.[1] ?? "npm";
141
+ }
@@ -6,11 +6,13 @@
6
6
  import { existsSync, lstatSync, statSync } from "node:fs";
7
7
  import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
8
8
  import { readConfig } from "../config.js";
9
- import { CONFIG_FILE, OUTPUT_PREFIX, PACKAGE } from "../constants.js";
9
+ import { CONFIG_FILE, PACKAGE } from "../constants.js";
10
10
  import { defaultBranch } from "../detect.js";
11
11
  import { init } from "../init.js";
12
12
  import { MANUAL_FILE, renderEnvironmentSection, writeManual } from "../manual.js";
13
+ import { claudeCommand, continueLine, enterLine, hasFreeScript, renderBanner } from "./banner.js";
13
14
  import { preflight } from "./detect.js";
15
+ import { formatLine, initKind, nextSteps, styleMarkdown, unprefixed } from "./ui.js";
14
16
  import { configurePrompt, verifyPrompt } from "./prompts.js";
15
17
  /** Prefix of the branch `prepare` creates; the day follows it. */
16
18
  const BRANCH_PREFIX = "chore/cloud-ready-";
@@ -35,17 +37,12 @@ const INCLUDE_QUESTION = "¿Incluir estos cambios en el commit? [s/N]";
35
37
  /** Answers to `INCLUDE_QUESTION` that mean yes. */
36
38
  const YES_ANSWER = /^(s|si|sí|y|yes)$/i;
37
39
  /** Text shown while the user configures the environment in claude.ai. */
38
- const WAIT_TEXT = "Configura el entorno en claude.ai y luego presiona Enter";
39
40
  /** Matches a GitHub remote URL (https or ssh) and captures `owner/repo`. */
40
41
  const GITHUB_REMOTE = /github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/;
41
42
  /** Matches a pull request URL and captures its number. */
42
43
  const PR_URL = /\/pull\/(\d+)/;
43
44
  /** Command that continues a prepare run; `npx` because a first-time user does not have the binary on PATH. */
44
45
  const RESUME_COMMAND = `npx ${PACKAGE} prepare --resume`;
45
- /** Printed just before the local agent starts: `prepare` continues only when `claude` exits. */
46
- const EXIT_CLAUDE_HINT = `${OUTPUT_PREFIX}cuando la skill diga que configure terminó, sal de claude (/exit) y prepare hará el commit, el push y abrirá el PR`;
47
- /** Printed before the local agent starts in the current branch: nothing is committed. */
48
- const EXIT_CLAUDE_HINT_HERE = `${OUTPUT_PREFIX}cuando la skill diga que configure terminó, sal de claude (/exit)`;
49
46
  /** Question of the place menu. */
50
47
  const PLACE_QUESTION = "¿Dónde preparo el repo?";
51
48
  /** Command that launches the cloud verification of the current branch. */
@@ -78,6 +75,18 @@ export function branchDay(date) {
78
75
  const pad = (n) => String(n).padStart(2, "0");
79
76
  return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}`;
80
77
  }
78
+ /**
79
+ * Prints one line with the icon and color of its kind (or the plain prefix without color).
80
+ *
81
+ * @param deps - Dependencies (`print`, `color`).
82
+ * @param kind - Kind of line.
83
+ * @param text - Text, without prefix.
84
+ * @example
85
+ * say(deps, "ok", "PR en borrador: https://github.com/acme/app/pull/7");
86
+ */
87
+ function say(deps, kind, text) {
88
+ deps.print(formatLine(kind, text, deps.color ?? false));
89
+ }
81
90
  /**
82
91
  * Says where the worktree is and how to continue there.
83
92
  *
@@ -253,11 +262,11 @@ async function findResume(repo, deps) {
253
262
  */
254
263
  async function createAndConfigure(repo, branch, worktree, deps, progress) {
255
264
  if (existsSync(worktree)) {
256
- deps.print(`${OUTPUT_PREFIX}${worktree} ya existe; bórralo (git worktree remove ${worktree}) o continúa ahí con ${RESUME_COMMAND}`);
265
+ say(deps, "fail", `${worktree} ya existe; bórralo (git worktree remove ${worktree}) o continúa ahí con ${RESUME_COMMAND}`);
257
266
  return false;
258
267
  }
259
268
  if ((await tryCapture(deps, "git", ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], repo)) !== undefined) {
260
- deps.print(`${OUTPUT_PREFIX}la rama ${branch} ya existe; bórrala (git branch -D ${branch}) o corre prepare otro día`);
269
+ say(deps, "fail", `la rama ${branch} ya existe; bórrala (git branch -D ${branch}) o corre prepare otro día`);
261
270
  return false;
262
271
  }
263
272
  const base = `origin/${defaultBranch(repo)}`;
@@ -265,38 +274,40 @@ async function createAndConfigure(repo, branch, worktree, deps, progress) {
265
274
  await deps.run("git", ["fetch", "-q", "origin"], { cwd: repo });
266
275
  await deps.run("git", ["worktree", "add", "-b", branch, worktree, base], { cwd: repo });
267
276
  progress.worktree = worktree;
277
+ say(deps, "ok", `se creó el worktree ${worktree} en la rama ${branch}`);
268
278
  }
269
279
  catch (err) {
270
- deps.print(`${OUTPUT_PREFIX}no se pudo crear el worktree: ${message(err)}`);
280
+ say(deps, "fail", `no se pudo crear el worktree: ${message(err)}`);
271
281
  return false;
272
282
  }
273
283
  const stopped = resumeLine("el agente local se detuvo", worktree);
274
284
  try {
275
285
  const result = init(worktree, deps.packageVersion, deps.packageRoot);
276
286
  for (const line of result.lines)
277
- deps.print(line);
287
+ say(deps, initKind(line), unprefixed(line));
278
288
  if (!result.ok) {
279
- deps.print(stopped);
289
+ say(deps, "warn", stopped);
280
290
  return false;
281
291
  }
282
292
  }
283
293
  catch (err) {
284
- deps.print(`${OUTPUT_PREFIX}${message(err)}`);
285
- deps.print(stopped);
294
+ say(deps, "fail", `${message(err)}`);
295
+ say(deps, "warn", stopped);
286
296
  return false;
287
297
  }
288
- deps.print(EXIT_CLAUDE_HINT);
289
298
  try {
290
- await runInteractive(deps, progress, [configurePrompt()], worktree);
299
+ await runConfigure(deps, progress, "pr", worktree);
291
300
  }
292
- catch {
293
- deps.print(stopped);
301
+ catch (err) {
302
+ if (err instanceof InterruptedError)
303
+ throw err;
304
+ say(deps, "warn", stopped);
294
305
  return false;
295
306
  }
296
307
  const problem = configureProblem(worktree);
297
308
  if (problem !== undefined) {
298
- deps.print(`${OUTPUT_PREFIX}configure no terminó: ${problem}`);
299
- deps.print(resumeLine("prepare se detuvo", worktree));
309
+ say(deps, "fail", `configure no terminó: ${problem}`);
310
+ say(deps, "warn", resumeLine("prepare se detuvo", worktree));
300
311
  return false;
301
312
  }
302
313
  return true;
@@ -323,44 +334,45 @@ async function commitAndPush(worktree, branch, deps) {
323
334
  const status = await deps.capture("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], { cwd: worktree });
324
335
  const others = statusPaths(status).filter((path) => !covered(path, known));
325
336
  if (others.length) {
326
- deps.print(`${OUTPUT_PREFIX}el agente local también cambió:`);
337
+ say(deps, "warn", `el agente local también cambió:`);
327
338
  for (const path of others)
328
- deps.print(` ${path}`);
339
+ deps.print(` ${path}`);
329
340
  if (YES_ANSWER.test((await deps.ask(INCLUDE_QUESTION, "")).trim())) {
330
341
  await deps.run("git", ["add", "--", ...others], { cwd: worktree });
331
342
  }
332
343
  else {
333
344
  await deps.run("git", ["reset", "-q", "--", ...others], { cwd: worktree });
334
- deps.print(`${OUTPUT_PREFIX}quedó sin commitear en ${worktree}`);
345
+ say(deps, "warn", `quedó sin commitear en ${worktree}`);
335
346
  }
336
347
  }
337
348
  const staged = await deps.capture("git", ["diff", "--cached", "--name-only"], { cwd: worktree });
338
349
  if (staged.trim())
339
350
  await deps.run("git", ["commit", "-m", COMMIT_MESSAGE], { cwd: worktree });
340
351
  else
341
- deps.print(`${OUTPUT_PREFIX}no hay nada nuevo que commitear`);
352
+ say(deps, "same", `no hay nada nuevo que commitear`);
342
353
  await deps.run("git", ["push", "-u", "origin", branch], { cwd: worktree });
354
+ say(deps, "ok", `se subió la rama ${branch}`);
343
355
  }
344
356
  catch (err) {
345
357
  if (err instanceof InterruptedError)
346
358
  throw err;
347
- deps.print(`${OUTPUT_PREFIX}no se pudo hacer commit y push: ${message(err)}; corrígelo en ${worktree} y corre ${RESUME_COMMAND} ahí`);
359
+ say(deps, "fail", `no se pudo hacer commit y push: ${message(err)}; corrígelo en ${worktree} y corre ${RESUME_COMMAND} ahí`);
348
360
  return false;
349
361
  }
350
362
  const existing = await tryCapture(deps, "gh", ["pr", "view", branch, "--json", "number", "--jq", ".number"], worktree);
351
363
  if (existing !== undefined && PR_NUMBER.test(existing)) {
352
- deps.print(`${OUTPUT_PREFIX}se reutiliza el PR abierto #${existing}`);
364
+ say(deps, "ok", `se reutiliza el PR abierto #${existing}`);
353
365
  return { pr: existing };
354
366
  }
355
367
  try {
356
368
  const url = await deps.capture("gh", ["pr", "create", "--draft", "--fill"], { cwd: worktree });
357
- deps.print(`${OUTPUT_PREFIX}PR en borrador: ${url.trim()}`);
369
+ say(deps, "ok", `PR en borrador: ${url.trim()}`);
358
370
  return { pr: PR_URL.exec(url)?.[1] };
359
371
  }
360
372
  catch {
361
373
  const origin = await tryCapture(deps, "git", ["remote", "get-url", "origin"], worktree);
362
374
  const link = compareLink(origin, branch);
363
- deps.print(`${OUTPUT_PREFIX}no se pudo abrir el PR con gh; ábrelo a mano${link ? `: ${link}` : ` desde la rama ${branch}`}`);
375
+ say(deps, "warn", `no se pudo abrir el PR con gh; ábrelo a mano${link ? `: ${link}` : ` desde la rama ${branch}`}`);
364
376
  return {};
365
377
  }
366
378
  }
@@ -385,14 +397,14 @@ export async function prepare(repo, deps, options = {}) {
385
397
  const problems = preflight({ isTty: deps.isTty, gitRoot, hasOrigin, claudeHelp });
386
398
  if (problems.length || gitRoot === undefined) {
387
399
  for (const problem of problems)
388
- deps.print(`${OUTPUT_PREFIX}${problem}`);
400
+ say(deps, "fail", `${problem}`);
389
401
  return EXIT_FAIL;
390
402
  }
391
403
  const progress = {};
392
404
  const stop = deps.onInterrupt?.(() => {
393
405
  if (progress.childOwnsTerminal)
394
406
  return false;
395
- deps.print(interruptedLine(progress));
407
+ say(deps, "warn", interruptedLine(progress));
396
408
  return true;
397
409
  });
398
410
  try {
@@ -401,7 +413,7 @@ export async function prepare(repo, deps, options = {}) {
401
413
  catch (err) {
402
414
  if (!(err instanceof InterruptedError))
403
415
  throw err;
404
- deps.print(interruptedLine(progress));
416
+ say(deps, "warn", interruptedLine(progress));
405
417
  return EXIT_INTERRUPTED;
406
418
  }
407
419
  finally {
@@ -414,34 +426,54 @@ export async function prepare(repo, deps, options = {}) {
414
426
  *
415
427
  * @param deps - Dependencies.
416
428
  * @param progress - Marks the child as owning the terminal.
417
- * @param args - Arguments of `claude`.
429
+ * @param command - Command and arguments that open Claude.
418
430
  * @param cwd - Worktree.
419
431
  * @returns Resolves when `claude` exits 0.
420
432
  * @throws When `claude` exits non-zero.
421
433
  * @example
422
- * await runInteractive(deps, progress, ["--cloud", prompt], "/home/u/app-cloud-ready");
434
+ * await runInteractive(deps, progress, { cmd: "claude", args: ["--cloud", prompt] }, "/home/u/app-cloud-ready");
423
435
  */
424
- async function runInteractive(deps, progress, args, cwd) {
436
+ async function runInteractive(deps, progress, command, cwd) {
425
437
  progress.childOwnsTerminal = true;
426
438
  try {
427
- await deps.run("claude", args, { cwd });
439
+ await deps.run(command.cmd, command.args, { cwd });
428
440
  }
429
441
  finally {
430
442
  progress.childOwnsTerminal = false;
431
443
  }
432
444
  }
445
+ /**
446
+ * Shows the screen before Claude Code opens, waits for Enter, and runs the local agent with
447
+ * `cloud-ready configure` (through the repo's `claude:free` script when it has one).
448
+ *
449
+ * @param deps - Dependencies.
450
+ * @param progress - Marks the child as owning the terminal.
451
+ * @param place - Where `prepare` works.
452
+ * @param cwd - Folder where Claude runs.
453
+ * @returns Resolves when Claude exits 0.
454
+ * @throws InterruptedError on Ctrl+C at the Enter line; an error when Claude exits non-zero.
455
+ * @example
456
+ * await runConfigure(deps, progress, "pr", "/home/u/app-cloud-ready");
457
+ */
458
+ async function runConfigure(deps, progress, place, cwd) {
459
+ const color = deps.color ?? false;
460
+ for (const line of renderBanner({ place, free: hasFreeScript(cwd), color }))
461
+ deps.print(line);
462
+ await deps.waitEnter(enterLine(color));
463
+ await runInteractive(deps, progress, claudeCommand(cwd, configurePrompt()), cwd);
464
+ }
433
465
  /**
434
466
  * The line printed on Ctrl+C: where the worktree is, when it exists.
435
467
  *
436
468
  * @param progress - What exists so far.
437
469
  * @returns The line.
438
470
  * @example
439
- * interruptedLine({}); // "agent-cloud-kit: interrumpido; no se creó nada"
471
+ * interruptedLine({}); // "interrumpido; no se creó nada"
440
472
  */
441
473
  function interruptedLine(progress) {
442
474
  if (progress.here)
443
- return `${OUTPUT_PREFIX}interrumpido; lo que alcanzó a cambiar quedó sin commit en tu rama`;
444
- return progress.worktree === undefined ? `${OUTPUT_PREFIX}interrumpido; no se creó nada` : resumeLine("interrumpido", progress.worktree);
475
+ return "interrumpido; lo que alcanzó a cambiar quedó sin commit en tu rama";
476
+ return progress.worktree === undefined ? "interrumpido; no se creó nada" : resumeLine("interrumpido", progress.worktree);
445
477
  }
446
478
  /**
447
479
  * Everything after the preflight: resume or create the worktree, commit, wait, launch.
@@ -461,21 +493,21 @@ async function prepareSteps(gitRoot, deps, options, progress) {
461
493
  if (options.resume) {
462
494
  const found = await findResume(gitRoot, deps);
463
495
  if (!found) {
464
- deps.print(`${OUTPUT_PREFIX}no hay nada que reanudar: corre prepare --resume en el worktree *${WORKTREE_SUFFIX}, sobre una rama ${BRANCH_PREFIX}*`);
496
+ say(deps, "fail", `no hay nada que reanudar: corre prepare --resume en el worktree *${WORKTREE_SUFFIX}, sobre una rama ${BRANCH_PREFIX}*`);
465
497
  return EXIT_FAIL;
466
498
  }
467
499
  ({ worktree, branch } = found);
468
500
  progress.worktree = worktree;
469
501
  const problem = configureProblem(worktree);
470
502
  if (problem) {
471
- deps.print(`${OUTPUT_PREFIX}configure no terminó en ${worktree}: ${problem}; corre claude "/cloud-ready configure" ahí primero y luego ${RESUME_COMMAND}`);
503
+ say(deps, "fail", `configure no terminó en ${worktree}: ${problem}; corre claude "/cloud-ready configure" ahí primero y luego ${RESUME_COMMAND}`);
472
504
  return EXIT_FAIL;
473
505
  }
474
506
  }
475
507
  else {
476
508
  const place = options.place ?? (await askPlace(gitRoot, deps));
477
509
  if (place === null) {
478
- deps.print(`${OUTPUT_PREFIX}no se cambió nada`);
510
+ say(deps, "same", `no se cambió nada`);
479
511
  return EXIT_FAIL;
480
512
  }
481
513
  if (place === "here")
@@ -490,16 +522,17 @@ async function prepareSteps(gitRoot, deps, options, progress) {
490
522
  return EXIT_FAIL;
491
523
  const config = readConfig(worktree).config;
492
524
  if (config)
493
- deps.print(renderEnvironmentSection(config, deps.packageVersion));
494
- await deps.waitEnter(WAIT_TEXT);
525
+ for (const line of styleMarkdown(renderEnvironmentSection(config, deps.packageVersion), deps.color ?? false))
526
+ deps.print(line);
527
+ await deps.waitEnter(continueLine(deps.color ?? false));
495
528
  try {
496
- await runInteractive(deps, progress, ["--cloud", verifyPrompt({ branch, pr: pushed.pr })], worktree);
529
+ await runInteractive(deps, progress, { cmd: "claude", args: ["--cloud", verifyPrompt({ branch, pr: pushed.pr })] }, worktree);
497
530
  }
498
531
  catch (err) {
499
- deps.print(`${OUTPUT_PREFIX}falló claude --cloud: ${message(err)}; corre ${RESUME_COMMAND} en ${worktree} para reintentar`);
532
+ say(deps, "fail", `falló claude --cloud: ${message(err)}; corre ${RESUME_COMMAND} en ${worktree} para reintentar`);
500
533
  return EXIT_FAIL;
501
534
  }
502
- deps.print(`${OUTPUT_PREFIX}la sesión cloud avisará con un push a ${branch} y un comentario en el PR`);
535
+ say(deps, "ok", `la sesión cloud avisará con un push a ${branch} y un comentario en el PR`);
503
536
  return EXIT_OK;
504
537
  }
505
538
  /**
@@ -530,45 +563,49 @@ async function askPlace(gitRoot, deps) {
530
563
  */
531
564
  async function prepareHere(gitRoot, deps, progress) {
532
565
  progress.here = true;
533
- const stopped = `${OUTPUT_PREFIX}prepare se detuvo; lo que alcanzó a cambiar quedó sin commit en tu rama`;
566
+ const stopped = `prepare se detuvo; lo que alcanzó a cambiar quedó sin commit en tu rama`;
534
567
  try {
535
568
  const result = init(gitRoot, deps.packageVersion, deps.packageRoot);
536
569
  for (const line of result.lines)
537
- deps.print(line);
570
+ say(deps, initKind(line), unprefixed(line));
538
571
  if (!result.ok) {
539
- deps.print(stopped);
572
+ say(deps, "warn", stopped);
540
573
  return EXIT_FAIL;
541
574
  }
542
575
  }
543
576
  catch (err) {
544
- deps.print(`${OUTPUT_PREFIX}${message(err)}`);
545
- deps.print(stopped);
577
+ say(deps, "fail", `${message(err)}`);
578
+ say(deps, "warn", stopped);
546
579
  return EXIT_FAIL;
547
580
  }
548
- deps.print(EXIT_CLAUDE_HINT_HERE);
549
581
  try {
550
- await runInteractive(deps, progress, [configurePrompt()], gitRoot);
582
+ await runConfigure(deps, progress, "here", gitRoot);
551
583
  }
552
- catch {
553
- deps.print(stopped);
584
+ catch (err) {
585
+ if (err instanceof InterruptedError)
586
+ throw err;
587
+ say(deps, "warn", stopped);
554
588
  return EXIT_FAIL;
555
589
  }
556
590
  const problem = configureProblem(gitRoot);
557
591
  if (problem !== undefined) {
558
- deps.print(`${OUTPUT_PREFIX}configure no terminó: ${problem}; corre claude "/cloud-ready configure" y luego prepare otra vez`);
592
+ say(deps, "fail", `configure no terminó: ${problem}; corre claude "/cloud-ready configure" y luego prepare otra vez`);
559
593
  return EXIT_FAIL;
560
594
  }
561
595
  try {
562
596
  writeManual(gitRoot, deps.packageVersion);
563
597
  }
564
598
  catch (err) {
565
- deps.print(`${OUTPUT_PREFIX}no se pudo escribir ${MANUAL_FILE}: ${message(err)}`);
599
+ say(deps, "fail", `no se pudo escribir ${MANUAL_FILE}: ${message(err)}`);
566
600
  return EXIT_FAIL;
567
601
  }
568
602
  const config = readConfig(gitRoot).config;
569
603
  if (config)
570
- deps.print(renderEnvironmentSection(config, deps.packageVersion));
571
- deps.print(`${OUTPUT_PREFIX}listo, sin commit. Revisa los cambios con git diff, haz commit y push, configura el entorno en claude.ai (${MANUAL_FILE}) y corre ${VERIFY_COMMAND}`);
604
+ for (const line of styleMarkdown(renderEnvironmentSection(config, deps.packageVersion), deps.color ?? false))
605
+ deps.print(line);
606
+ const steps = ["Revisa los cambios con git diff", "Haz commit y push", `Configura el entorno en claude.ai (${MANUAL_FILE})`, `Corre ${VERIFY_COMMAND}`];
607
+ for (const line of nextSteps("Listo, sin commit", steps, deps.color ?? false))
608
+ deps.print(line);
572
609
  return EXIT_OK;
573
610
  }
574
611
  /**
@@ -589,30 +626,30 @@ export async function verifyBranch(repo, deps) {
589
626
  const problems = preflight({ isTty: deps.isTty, gitRoot, hasOrigin, claudeHelp }, "verify");
590
627
  if (problems.length || gitRoot === undefined) {
591
628
  for (const problem of problems)
592
- deps.print(`${OUTPUT_PREFIX}${problem}`);
629
+ say(deps, "fail", `${problem}`);
593
630
  return EXIT_FAIL;
594
631
  }
595
632
  const branch = await tryCapture(deps, "git", ["rev-parse", "--abbrev-ref", "HEAD"], gitRoot);
596
633
  if (branch === undefined || branch === "HEAD") {
597
- deps.print(`${OUTPUT_PREFIX}no estás en una rama; cámbiate a la rama que quieres verificar`);
634
+ say(deps, "fail", `no estás en una rama; cámbiate a la rama que quieres verificar`);
598
635
  return EXIT_FAIL;
599
636
  }
600
637
  const problem = configureProblem(gitRoot);
601
638
  if (problem !== undefined) {
602
- deps.print(`${OUTPUT_PREFIX}el repo no está preparado (${problem}); corre npx ${PACKAGE} prepare primero`);
639
+ say(deps, "fail", `el repo no está preparado (${problem}); corre npx ${PACKAGE} prepare primero`);
603
640
  return EXIT_FAIL;
604
641
  }
605
642
  const notes = readConfig(gitRoot).config?.notes ?? DEFAULT_NOTES_FILE;
606
643
  const files = [CONFIG_FILE, MANUAL_FILE, notes].filter((path) => entryExists(join(gitRoot, path)));
607
644
  const dirty = await tryCapture(deps, "git", ["status", "--porcelain", "--", ...files], gitRoot);
608
645
  if (dirty) {
609
- deps.print(`${OUTPUT_PREFIX}hay cambios sin commit en la preparación; haz commit y push: la nube solo ve lo que está en origin`);
646
+ say(deps, "fail", `hay cambios sin commit en la preparación; haz commit y push: la nube solo ve lo que está en origin`);
610
647
  return EXIT_FAIL;
611
648
  }
612
649
  const head = await tryCapture(deps, "git", ["rev-parse", "HEAD"], gitRoot);
613
650
  const remote = (await tryCapture(deps, "git", ["ls-remote", "origin", `refs/heads/${branch}`], gitRoot))?.split(/\s/)[0];
614
651
  if (!head || remote !== head) {
615
- deps.print(`${OUTPUT_PREFIX}la rama ${branch} no está subida o tiene commits sin subir; haz git push y vuelve a correr verify`);
652
+ say(deps, "fail", `la rama ${branch} no está subida o tiene commits sin subir; haz git push y vuelve a correr verify`);
616
653
  return EXIT_FAIL;
617
654
  }
618
655
  const found = await tryCapture(deps, "gh", ["pr", "view", branch, "--json", "number", "--jq", ".number"], gitRoot);
@@ -620,15 +657,15 @@ export async function verifyBranch(repo, deps) {
620
657
  const progress = {};
621
658
  const stop = deps.onInterrupt?.(() => !progress.childOwnsTerminal);
622
659
  try {
623
- await runInteractive(deps, progress, ["--cloud", verifyPrompt({ branch, pr })], gitRoot);
660
+ await runInteractive(deps, progress, { cmd: "claude", args: ["--cloud", verifyPrompt({ branch, pr })] }, gitRoot);
624
661
  }
625
662
  catch (err) {
626
- deps.print(`${OUTPUT_PREFIX}falló claude --cloud: ${message(err)}`);
663
+ say(deps, "fail", `falló claude --cloud: ${message(err)}`);
627
664
  return EXIT_FAIL;
628
665
  }
629
666
  finally {
630
667
  stop?.();
631
668
  }
632
- deps.print(`${OUTPUT_PREFIX}la sesión cloud avisará con un push a ${branch}${pr ? " y un comentario en el PR" : ""}`);
669
+ say(deps, "ok", `la sesión cloud avisará con un push a ${branch}${pr ? " y un comentario en el PR" : ""}`);
633
670
  return EXIT_OK;
634
671
  }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * @file How `prepare` and `verify` show their lines: an icon and a color per kind in a terminal,
3
+ * or the plain `agent-cloud-kit: ` prefix otherwise (logs, pipes, tests).
4
+ */
5
+ import { OUTPUT_PREFIX } from "../constants.js";
6
+ /** Icon and SGR color code of each kind. */
7
+ const STYLE = {
8
+ ok: { icon: "✔", code: "32" },
9
+ same: { icon: "●", code: "2" },
10
+ info: { icon: "›", code: "36" },
11
+ warn: { icon: "▲", code: "33" },
12
+ fail: { icon: "✖", code: "31" },
13
+ };
14
+ /**
15
+ * Wraps a text in an SGR code when color is on.
16
+ *
17
+ * @param on - Whether color is on.
18
+ * @param code - SGR code.
19
+ * @param text - Text.
20
+ * @returns The text, colored or not.
21
+ * @example
22
+ * paint(true, "1", "a"); // "\x1b[1ma\x1b[0m"
23
+ */
24
+ export function paint(on, code, text) {
25
+ return on ? `\x1b[${code}m${text}\x1b[0m` : text;
26
+ }
27
+ /**
28
+ * Formats one line.
29
+ *
30
+ * @param kind - Kind of line.
31
+ * @param text - Text, without prefix.
32
+ * @param color - Whether the output is a color terminal.
33
+ * @returns ` ✔ text` (icon colored; the text dimmed for `same`) or `agent-cloud-kit: text`.
34
+ * @example
35
+ * formatLine("ok", "se creó .claude/cloud.json", false); // "agent-cloud-kit: se creó .claude/cloud.json"
36
+ */
37
+ export function formatLine(kind, text, color) {
38
+ if (!color)
39
+ return `${OUTPUT_PREFIX}${text}`;
40
+ const { icon, code } = STYLE[kind];
41
+ return ` ${paint(true, code, icon)} ${kind === "same" ? paint(true, "2", text) : text}`;
42
+ }
43
+ /**
44
+ * Guesses the kind of a line `init` printed, from its wording.
45
+ *
46
+ * @param text - Line, with or without the prefix.
47
+ * @returns `ok` for something done, `same` for something already there, `warn` otherwise.
48
+ * @example
49
+ * initKind("agent-cloud-kit: se creó .claude/cloud.json"); // "ok"
50
+ */
51
+ export function initKind(text) {
52
+ const plain = text.startsWith(OUTPUT_PREFIX) ? text.slice(OUTPUT_PREFIX.length) : text;
53
+ if (/^se (creó|agregó|instaló|enlazó|actualizó|escribió)(\s|$)/.test(plain))
54
+ return "ok";
55
+ if (/\bya (existe|tiene|está)\b/.test(plain))
56
+ return "same";
57
+ return "warn";
58
+ }
59
+ /**
60
+ * Removes the `agent-cloud-kit: ` prefix of a line, if it has it.
61
+ *
62
+ * @param text - Line.
63
+ * @returns The line without prefix.
64
+ * @example
65
+ * unprefixed("agent-cloud-kit: hola"); // "hola"
66
+ */
67
+ export function unprefixed(text) {
68
+ return text.startsWith(OUTPUT_PREFIX) ? text.slice(OUTPUT_PREFIX.length) : text;
69
+ }
70
+ /**
71
+ * Styles a Markdown section for the terminal: headings in bold (and cyan for `##`), code fences
72
+ * dropped and their lines indented, list items as bullets. Without color, the text is unchanged.
73
+ *
74
+ * @param markdown - Section text.
75
+ * @param color - Whether the output is a color terminal.
76
+ * @returns The lines.
77
+ * @example
78
+ * styleMarkdown("## A\n- b", true); // ["", " \x1b[1;36mA\x1b[0m", " • b"]
79
+ */
80
+ export function styleMarkdown(markdown, color) {
81
+ if (!color)
82
+ return markdown.split("\n");
83
+ const out = [];
84
+ let inCode = false;
85
+ for (const line of markdown.split("\n")) {
86
+ if (line.startsWith("```")) {
87
+ inCode = !inCode;
88
+ continue;
89
+ }
90
+ if (inCode)
91
+ out.push(` ${paint(true, "33", line)}`);
92
+ else if (line.startsWith("### "))
93
+ out.push(` ${paint(true, "1", line.slice(4))}`);
94
+ else if (line.startsWith("## "))
95
+ out.push("", ` ${paint(true, "1;36", line.slice(3))}`);
96
+ else if (line.startsWith("- "))
97
+ out.push(` ${paint(true, "36", "•")} ${inline(line.slice(2))}`);
98
+ else
99
+ out.push(line === "" ? "" : ` ${inline(line)}`);
100
+ }
101
+ return out;
102
+ }
103
+ /**
104
+ * Styles inline Markdown: `**bold**` in bold and `` `code` `` in yellow, without the marks.
105
+ *
106
+ * @param text - One line.
107
+ * @returns The styled line.
108
+ * @example
109
+ * inline("Elige **Custom**"); // "Elige \x1b[1mCustom\x1b[0m"
110
+ */
111
+ function inline(text) {
112
+ return text.replace(/\*\*([^*]+)\*\*/g, (_m, bold) => paint(true, "1", bold)).replace(/`([^`]+)`/g, (_m, code) => paint(true, "33", code));
113
+ }
114
+ /**
115
+ * Renders a "what now" block: a title and numbered steps.
116
+ *
117
+ * @param title - Title.
118
+ * @param steps - Steps, in order.
119
+ * @param color - Whether the output is a color terminal.
120
+ * @returns The lines (without color: one prefixed line with the steps joined).
121
+ * @example
122
+ * nextSteps("Listo", ["haz commit"], false); // ["agent-cloud-kit: Listo. 1) haz commit"]
123
+ */
124
+ export function nextSteps(title, steps, color) {
125
+ if (!color)
126
+ return [`${OUTPUT_PREFIX}${title}. ${steps.map((s, i) => `${i + 1}) ${s}`).join("; ")}`];
127
+ return ["", ` ${paint(true, "1;32", "✔")} ${paint(true, "1", title)}`, "", ...steps.map((s, i) => ` ${paint(true, "1;35", String(i + 1))} ${s}`), ""];
128
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-cloud-kit",
3
- "version": "1.0.0-rc.2",
3
+ "version": "1.0.0-rc.4",
4
4
  "description": "Zero-dependency CLI that prepares Claude Code cloud sessions.",
5
5
  "type": "module",
6
6
  "license": "MIT",