agent-kitchen 0.5.0 → 0.7.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.
@@ -0,0 +1,29 @@
1
+ /**
2
+ * @file The texts of the commit and the PR of `apply --commit`. They are always in Spanish,
3
+ * whatever language the interface shows: a repo's history should not change language with the
4
+ * terminal of whoever ran the command. A step reads as its Spanish menu order.
5
+ */
6
+ import { DECISIONS } from "./decisions.js";
7
+ import { es } from "./texts/es.js";
8
+ import { phrasesIn } from "./texts/index.js";
9
+ /** The subject of the commit and the title of the PR. */
10
+ export const COMMIT_TITLE = "chore: agent-kitchen apply";
11
+ /** The line of the decisions file. */
12
+ export const DECISIONS_LINE = `Guardar qué piezas usa el repo en ${DECISIONS}`;
13
+ /**
14
+ * A step's line in the commit and the PR.
15
+ * @param text - What the step does.
16
+ * @returns The line, without the dash.
17
+ * @example commitLine({ kind: "flow-install", version: "0.2.0" }) // "Instalar issue-flow 0.2.0: config del flujo, hooks y scripts"
18
+ */
19
+ export function commitLine(text) {
20
+ return phrasesIn(es, text).menu;
21
+ }
22
+ /**
23
+ * The body of the PR.
24
+ * @param lines - What the commit lists.
25
+ * @returns The body.
26
+ */
27
+ export function prBody(lines) {
28
+ return `Cambios de \`agent-kitchen apply\`:\n\n${lines.map((line) => `- ${line}`).join("\n")}\n`;
29
+ }
@@ -0,0 +1,54 @@
1
+ import type { PieceStatus, Step } from "./piece.js";
2
+ /** The file with the decisions, relative to the repo. */
3
+ export declare const DECISIONS = ".agents/kitchen.json";
4
+ /** The pieces a repo can decide on, in the order sync runs them. */
5
+ export declare const DECIDABLE: string[];
6
+ /** What was read from the decisions file. */
7
+ export interface Saved {
8
+ /** The valid decisions of the decidable pieces. */
9
+ pieces: Record<string, boolean>;
10
+ /** The whole file, to write back without losing what is not ours. */
11
+ raw: Record<string, unknown>;
12
+ /** What was wrong in the file, in Spanish, for the user. */
13
+ warnings: string[];
14
+ }
15
+ /**
16
+ * Reads the decisions of a repo.
17
+ * @param root - The repo.
18
+ * @returns The decisions, or null if the file does not exist.
19
+ * @throws KitError if the file is not valid JSON.
20
+ * @example readDecisions(root)?.pieces // { "claude-repo-guard": false }
21
+ */
22
+ export declare function readDecisions(root: string): Saved | null;
23
+ /**
24
+ * The decidable pieces with no decision yet.
25
+ * @param saved - What was read, or null if there is no file.
26
+ * @returns Their names; all of them without a file.
27
+ * @example undecided(null) // ["issue-flow", "claude-cloud", ...]
28
+ */
29
+ export declare function undecided(saved: Saved | null): string[];
30
+ /**
31
+ * The pieces the repo decided not to have.
32
+ * @param saved - What was read, or null if there is no file.
33
+ * @returns Their names.
34
+ * @example off({ pieces: { skills: false }, raw: {}, warnings: [] }) // ["skills"]
35
+ */
36
+ export declare function off(saved: Saved | null): string[];
37
+ /**
38
+ * Settles the pieces that have no decision from what the repo has and what was chosen.
39
+ * @param statuses - What each piece found.
40
+ * @param chosen - The steps the user picked.
41
+ * @param previous - The decisions already made; they never change here.
42
+ * @returns `previous` plus a decision for each decidable piece without one: installed or
43
+ * with a chosen step is true, offered steps all unchosen is false, no steps leaves it open.
44
+ * @example decide([{ name: "skills", installed: true, summary: "", state: "", steps: [] }], [], {}) // { skills: true }
45
+ */
46
+ export declare function decide(statuses: PieceStatus[], chosen: Step[], previous: Record<string, boolean>): Record<string, boolean>;
47
+ /**
48
+ * Writes the decisions, keeping every other key of the file.
49
+ * @param root - The repo.
50
+ * @param pieces - The decisions to write.
51
+ * @param raw - The whole file as it was read (empty if there was none).
52
+ * @example writeDecisions(root, { "issue-flow": true }, {})
53
+ */
54
+ export declare function writeDecisions(root: string, pieces: Record<string, boolean>, raw: Record<string, unknown>): void;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * @file What the repo wants of each piece, kept in `.agents/kitchen.json`: `true` to have it,
3
+ * `false` to leave it out for good. Only the pieces that are a choice carry a decision;
4
+ * `skills-manager` does not, because it is the tool that installs the others' skills and is
5
+ * always kept up to date, never opted in or out.
6
+ */
7
+ import { isObject, readJson, writeJson } from "./repo.js";
8
+ import { NAME as AGENTS_MD } from "./pieces/agents-md.js";
9
+ import { NAME as CLOUD } from "./pieces/cloud.js";
10
+ import { NAME as GUARD } from "./pieces/guard.js";
11
+ import { NAME as ISSUE_FLOW } from "./pieces/issue-flow.js";
12
+ import { NAME as SKILLS } from "./pieces/skills.js";
13
+ /** The file with the decisions, relative to the repo. */
14
+ export const DECISIONS = ".agents/kitchen.json";
15
+ /** The pieces a repo can decide on, in the order sync runs them. */
16
+ export const DECIDABLE = [ISSUE_FLOW, CLOUD, GUARD, SKILLS, AGENTS_MD];
17
+ /**
18
+ * Reads the decisions of a repo.
19
+ * @param root - The repo.
20
+ * @returns The decisions, or null if the file does not exist.
21
+ * @throws KitError if the file is not valid JSON.
22
+ * @example readDecisions(root)?.pieces // { "claude-repo-guard": false }
23
+ */
24
+ export function readDecisions(root) {
25
+ const raw = readJson(root, DECISIONS);
26
+ if (raw === null)
27
+ return null;
28
+ const stored = isObject(raw.pieces) ? raw.pieces : {};
29
+ const pieces = {};
30
+ const warnings = [];
31
+ for (const name of DECIDABLE) {
32
+ if (!(name in stored))
33
+ continue;
34
+ const value = stored[name];
35
+ if (typeof value === "boolean")
36
+ pieces[name] = value;
37
+ else
38
+ warnings.push(`${DECISIONS}: "${name}" no es true ni false; se trata como sin decisión.`);
39
+ }
40
+ return { pieces, raw, warnings };
41
+ }
42
+ /**
43
+ * The decidable pieces with no decision yet.
44
+ * @param saved - What was read, or null if there is no file.
45
+ * @returns Their names; all of them without a file.
46
+ * @example undecided(null) // ["issue-flow", "claude-cloud", ...]
47
+ */
48
+ export function undecided(saved) {
49
+ return DECIDABLE.filter((name) => saved?.pieces[name] === undefined);
50
+ }
51
+ /**
52
+ * The pieces the repo decided not to have.
53
+ * @param saved - What was read, or null if there is no file.
54
+ * @returns Their names.
55
+ * @example off({ pieces: { skills: false }, raw: {}, warnings: [] }) // ["skills"]
56
+ */
57
+ export function off(saved) {
58
+ return DECIDABLE.filter((name) => saved?.pieces[name] === false);
59
+ }
60
+ /**
61
+ * Settles the pieces that have no decision from what the repo has and what was chosen.
62
+ * @param statuses - What each piece found.
63
+ * @param chosen - The steps the user picked.
64
+ * @param previous - The decisions already made; they never change here.
65
+ * @returns `previous` plus a decision for each decidable piece without one: installed or
66
+ * with a chosen step is true, offered steps all unchosen is false, no steps leaves it open.
67
+ * @example decide([{ name: "skills", installed: true, summary: "", state: "", steps: [] }], [], {}) // { skills: true }
68
+ */
69
+ export function decide(statuses, chosen, previous) {
70
+ const result = { ...previous };
71
+ for (const status of statuses) {
72
+ if (!DECIDABLE.includes(status.name) || status.name in previous)
73
+ continue;
74
+ if (status.installed || chosen.some((step) => step.piece === status.name))
75
+ result[status.name] = true;
76
+ else if (status.steps.length > 0)
77
+ result[status.name] = false;
78
+ }
79
+ return result;
80
+ }
81
+ /**
82
+ * Writes the decisions, keeping every other key of the file.
83
+ * @param root - The repo.
84
+ * @param pieces - The decisions to write.
85
+ * @param raw - The whole file as it was read (empty if there was none).
86
+ * @example writeDecisions(root, { "issue-flow": true }, {})
87
+ */
88
+ export function writeDecisions(root, pieces, raw) {
89
+ const rawPieces = isObject(raw.pieces) ? raw.pieces : {};
90
+ writeJson(root, DECISIONS, { ...raw, pieces: { ...rawPieces, ...pieces } });
91
+ }
@@ -0,0 +1,53 @@
1
+ /** What to do with the changes: leave them, commit on the base, on a branch, or a branch with a PR. */
2
+ export type FinishChoice = "leave" | "base" | "branch" | "pr";
3
+ /**
4
+ * The base branch, as issue-flow takes it: `baseBranch` of `.agents/flow.json`, or origin's
5
+ * default as git knows it, or `main`.
6
+ * @param root - The repo.
7
+ * @returns The branch.
8
+ * @example baseBranch(root) // "main"
9
+ */
10
+ export declare function baseBranch(root: string): string;
11
+ /**
12
+ * The files that differ from HEAD, tracked or not, one by one, without the repo's worktrees.
13
+ * @param root - The repo.
14
+ * @returns Their paths.
15
+ * @example changedPaths(root) // ["package.json", "package-lock.json"]
16
+ */
17
+ export declare function changedPaths(root: string): string[];
18
+ /**
19
+ * Checks, before any step, that the repo has no changes and is on its base branch.
20
+ * @param root - The repo.
21
+ * @param base - The base branch.
22
+ * @throws KitError saying what is wrong and what to do.
23
+ */
24
+ export declare function checkReady(root: string, base: string): void;
25
+ /**
26
+ * The branch for today's changes: a new name, also against origin's branches git knows of.
27
+ * @param root - The repo.
28
+ * @param date - Today.
29
+ * @returns `chore/agent-kitchen-apply-AAAA-MM-DD`, or with `-2`, `-3`… if taken.
30
+ * @example branchName(root, new Date()) // "chore/agent-kitchen-apply-2026-11-01"
31
+ */
32
+ export declare function branchName(root: string, date: Date): string;
33
+ /** What `finish` needs to know. */
34
+ export interface FinishOptions {
35
+ base: string;
36
+ branch: string;
37
+ steps: string[];
38
+ paths: string[];
39
+ manager: string;
40
+ packageChanged?: boolean;
41
+ }
42
+ /**
43
+ * Does what the person chose with the changes.
44
+ * @param root - The repo, on the base, with the changes of the run.
45
+ * @param choice - What to do.
46
+ * @param options - The base, the new branch's name, the steps that ran, the files to commit (the ones shown), the package manager and whether package.json changed.
47
+ * @returns The lines to show and whether it all worked.
48
+ * @example await finish(root, "pr", { base: "main", branch: "chore/agent-kitchen-apply-2026-11-01", steps, manager: "npm" })
49
+ */
50
+ export declare function finish(root: string, choice: FinishChoice, options: FinishOptions): Promise<{
51
+ ok: boolean;
52
+ lines: string[];
53
+ }>;
@@ -0,0 +1,252 @@
1
+ /**
2
+ * @file What `apply --commit` does with git: before any step, it checks that the repo has no
3
+ * changes (the repo's worktrees under it do not count) and is on its base branch; after the
4
+ * steps, it commits what changed where the person chose. A new branch is made by passing
5
+ * through it for the length of the command: `git switch -c`, the commit there (so a hook that
6
+ * forbids commits on the base does not stop it), the push and the PR if asked, and back to
7
+ * the base, which never moves. git runs its hooks; nothing here skips them.
8
+ */
9
+ import { spawnSync } from "node:child_process";
10
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join, relative } from "node:path";
13
+ import { COMMIT_TITLE as TITLE, prBody } from "./commit-texts.js";
14
+ import { KitError } from "./output.js";
15
+ import { FLOW_CONFIG } from "./piece.js";
16
+ import { dependencyVersion, readJson } from "./repo.js";
17
+ import { captureNpxWithout, isRelease, npmLatest } from "./tools.js";
18
+ /** github-rest on npm, which opens the PR (REST, never gh's GraphQL). */
19
+ const GITHUB_REST = "@ramon-sg/github-rest";
20
+ /**
21
+ * Runs git in the repo.
22
+ * @param root - The repo.
23
+ * @param args - The arguments.
24
+ * @param inherit - True to show its output on the terminal, for a push that may ask for credentials.
25
+ * @returns How it went.
26
+ */
27
+ function git(root, args, inherit = false) {
28
+ const result = spawnSync("git", args, { cwd: root, encoding: "utf8", stdio: inherit ? "inherit" : "pipe" });
29
+ return { ok: result.status === 0, out: (result.stdout ?? "").trimEnd(), err: (result.stderr ?? "").trim() };
30
+ }
31
+ /**
32
+ * The base branch, as issue-flow takes it: `baseBranch` of `.agents/flow.json`, or origin's
33
+ * default as git knows it, or `main`.
34
+ * @param root - The repo.
35
+ * @returns The branch.
36
+ * @example baseBranch(root) // "main"
37
+ */
38
+ export function baseBranch(root) {
39
+ const flow = readJson(root, FLOW_CONFIG);
40
+ if (typeof flow?.baseBranch === "string" && flow.baseBranch !== "")
41
+ return flow.baseBranch;
42
+ const head = git(root, ["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"]);
43
+ return head.ok && head.out.startsWith("origin/") ? head.out.slice("origin/".length) : "main";
44
+ }
45
+ /**
46
+ * The repo's other worktrees that live inside it, relative to it.
47
+ * @param root - The repo.
48
+ * @returns Their folders, like `.claude/worktrees/feat+1-x`.
49
+ */
50
+ function innerWorktrees(root) {
51
+ const list = git(root, ["worktree", "list", "--porcelain"]);
52
+ if (!list.ok)
53
+ return [];
54
+ return list.out
55
+ .split("\n")
56
+ .filter((line) => line.startsWith("worktree "))
57
+ .map((line) => relative(root, line.slice("worktree ".length)))
58
+ .filter((path) => path !== "" && !path.startsWith(".."));
59
+ }
60
+ /**
61
+ * The files that differ from HEAD, tracked or not, one by one, without the repo's worktrees.
62
+ * @param root - The repo.
63
+ * @returns Their paths.
64
+ * @example changedPaths(root) // ["package.json", "package-lock.json"]
65
+ */
66
+ export function changedPaths(root) {
67
+ const status = git(root, ["status", "--porcelain", "-uall", "-z"]);
68
+ if (!status.ok)
69
+ throw new KitError(`no pude leer git status: ${status.err}`);
70
+ const worktrees = innerWorktrees(root);
71
+ const entries = status.out.split("\0").filter((entry) => entry !== "");
72
+ const paths = [];
73
+ for (let index = 0; index < entries.length; index += 1) {
74
+ const entry = entries[index] ?? "";
75
+ if (/[RC]/.test(entry.slice(0, 2)))
76
+ index += 1;
77
+ const path = entry.slice(3).replace(/\/$/, "");
78
+ if (!worktrees.some((tree) => path === tree || path.startsWith(`${tree}/`)))
79
+ paths.push(path);
80
+ }
81
+ return paths;
82
+ }
83
+ /**
84
+ * Checks, before any step, that the repo has no changes and is on its base branch.
85
+ * @param root - The repo.
86
+ * @param base - The base branch.
87
+ * @throws KitError saying what is wrong and what to do.
88
+ */
89
+ export function checkReady(root, base) {
90
+ const branch = git(root, ["branch", "--show-current"]).out;
91
+ if (branch === "")
92
+ throw new KitError(`--commit necesita estar en ${base}, y HEAD no está en ninguna rama.`);
93
+ if (branch !== base)
94
+ throw new KitError(`--commit necesita estar en ${base}, la rama base, y estás en ${branch}.`);
95
+ const paths = changedPaths(root);
96
+ if (paths.length > 0)
97
+ throw new KitError(`--commit necesita el repo sin cambios, y hay cambios en: ${paths.join(", ")}. Commitéalos o guárdalos aparte y corre otra vez.`);
98
+ }
99
+ /**
100
+ * The branch for today's changes: a new name, also against origin's branches git knows of.
101
+ * @param root - The repo.
102
+ * @param date - Today.
103
+ * @returns `chore/agent-kitchen-apply-AAAA-MM-DD`, or with `-2`, `-3`… if taken.
104
+ * @example branchName(root, new Date()) // "chore/agent-kitchen-apply-2026-11-01"
105
+ */
106
+ export function branchName(root, date) {
107
+ const pad = (n) => String(n).padStart(2, "0");
108
+ const name = `chore/agent-kitchen-apply-${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
109
+ const taken = (candidate) => git(root, ["rev-parse", "-q", "--verify", `refs/heads/${candidate}`]).ok || git(root, ["rev-parse", "-q", "--verify", `refs/remotes/origin/${candidate}`]).ok;
110
+ if (!taken(name))
111
+ return name;
112
+ let n = 2;
113
+ while (taken(`${name}-${n}`))
114
+ n += 1;
115
+ return `${name}-${n}`;
116
+ }
117
+ /**
118
+ * Stages the files shown to the person, taken literally, and commits them, with the hooks.
119
+ * @param root - The repo.
120
+ * @param paths - The files.
121
+ * @param steps - The steps that ran, for the body.
122
+ * @returns How git went.
123
+ */
124
+ function commit(root, paths, steps) {
125
+ const add = git(root, ["--literal-pathspecs", "add", "-A", "--", ...paths]);
126
+ if (!add.ok)
127
+ return add;
128
+ return git(root, ["commit", "-q", "-m", TITLE, "-m", steps.map((step) => `- ${step}`).join("\n")]);
129
+ }
130
+ /**
131
+ * The version of github-rest to open the PR with: the repo's exact one, or npm's latest.
132
+ * @param root - The repo.
133
+ * @returns The version, or null if neither is known.
134
+ */
135
+ async function githubRestVersion(root) {
136
+ const own = dependencyVersion(readJson(root, "package.json"), GITHUB_REST);
137
+ if (own !== null && isRelease(own))
138
+ return own;
139
+ return npmLatest(GITHUB_REST);
140
+ }
141
+ /**
142
+ * Opens the PR with github-rest, without GH_REPO, so the repo is origin's.
143
+ * @param root - The repo, on the branch.
144
+ * @param branch - The branch.
145
+ * @param base - The base.
146
+ * @param steps - The steps, for the body.
147
+ * @returns The PR's URL, or why it failed.
148
+ */
149
+ async function openPr(root, branch, base, steps) {
150
+ const version = await githubRestVersion(root);
151
+ if (version === null)
152
+ return { error: `no sé qué versión de ${GITHUB_REST} usar.` };
153
+ const dir = mkdtempSync(join(tmpdir(), "agent-kitchen-pr-"));
154
+ const bodyFile = join(dir, "body.md");
155
+ writeFileSync(bodyFile, prBody(steps));
156
+ try {
157
+ const result = await captureNpxWithout(root, [`${GITHUB_REST}@${version}`, "pr", "create", "--head", branch, "--base", base, "--title", TITLE, "--body-file", bodyFile], ["GH_REPO"]);
158
+ const url = result.out.trim().split("\n").at(-1) ?? "";
159
+ return result.status === 0 && url !== "" ? { url } : { error: `${result.err}${result.out}`.trim() || "github-rest falló sin decir por qué." };
160
+ }
161
+ finally {
162
+ rmSync(dir, { recursive: true, force: true });
163
+ }
164
+ }
165
+ /**
166
+ * What the person can run to open the PR by hand.
167
+ * @param branch - The branch.
168
+ * @param base - The base.
169
+ * @returns The command.
170
+ */
171
+ function prCommand(branch, base) {
172
+ return `npx @ramon-sg/github-rest pr create --head ${branch} --base ${base} --title "${TITLE}" --body "..."`;
173
+ }
174
+ /**
175
+ * Pushes the branch and opens its PR, saying what to run if either fails.
176
+ * @param root - The repo, on the branch.
177
+ * @param branch - The branch.
178
+ * @param base - The base.
179
+ * @param steps - The steps, for the body.
180
+ * @param lines - Where to add what happened.
181
+ * @returns Whether both worked.
182
+ */
183
+ async function pushAndOpen(root, branch, base, steps, lines) {
184
+ const pushed = git(root, ["push", "-u", "origin", branch], true);
185
+ if (!pushed.ok) {
186
+ lines.push(`No pude subir la rama. Falta: git push -u origin ${branch}, y luego ${prCommand(branch, base)}`);
187
+ return false;
188
+ }
189
+ const pr = await openPr(root, branch, base, steps);
190
+ if ("url" in pr) {
191
+ lines.push(`PR: ${pr.url}`);
192
+ return true;
193
+ }
194
+ lines.push(`La rama está en origin, pero no pude abrir el PR: ${pr.error}`, `Falta: ${prCommand(branch, base)}`);
195
+ return false;
196
+ }
197
+ /**
198
+ * Does what the person chose with the changes.
199
+ * @param root - The repo, on the base, with the changes of the run.
200
+ * @param choice - What to do.
201
+ * @param options - The base, the new branch's name, the steps that ran, the files to commit (the ones shown), the package manager and whether package.json changed.
202
+ * @returns The lines to show and whether it all worked.
203
+ * @example await finish(root, "pr", { base: "main", branch: "chore/agent-kitchen-apply-2026-11-01", steps, manager: "npm" })
204
+ */
205
+ export async function finish(root, choice, options) {
206
+ const { base, branch, steps, paths } = options;
207
+ if (choice === "leave")
208
+ return { ok: true, lines: ["Falta: revisa el diff y haz commit."] };
209
+ if (choice === "base") {
210
+ const done = commit(root, paths, steps);
211
+ if (!done.ok)
212
+ return { ok: false, lines: [`No pude commitear en ${base}; los cambios quedan sin commit.`, done.err || done.out] };
213
+ return { ok: true, lines: [`Commit en ${base}. Falta: git push cuando quieras.`] };
214
+ }
215
+ const moved = git(root, ["switch", "-q", "-c", branch]);
216
+ if (!moved.ok)
217
+ return { ok: false, lines: [`No pude crear la rama ${branch}; los cambios quedan sin commit.`, moved.err] };
218
+ const lines = [];
219
+ let ok = true;
220
+ let committed = false;
221
+ try {
222
+ const done = commit(root, paths, steps);
223
+ if (!done.ok) {
224
+ ok = false;
225
+ lines.push("No pude commitear.", done.err || done.out);
226
+ }
227
+ else {
228
+ committed = true;
229
+ lines.push(`Commit en la rama ${branch}.`);
230
+ if (choice === "pr")
231
+ ok = await pushAndOpen(root, branch, base, steps, lines);
232
+ }
233
+ }
234
+ finally {
235
+ const back = git(root, ["switch", "-q", base]);
236
+ if (!back.ok) {
237
+ ok = false;
238
+ lines.push(`No pude volver a ${base}: ${back.err}. Sigues en ${branch}.`);
239
+ }
240
+ else if (!committed) {
241
+ git(root, ["branch", "-q", "-d", branch]);
242
+ lines.push(`Vuelves a ${base} con los cambios sin commit.`);
243
+ }
244
+ else
245
+ lines.push(`Vuelves a ${base}, sin los cambios: están en ${branch}.`);
246
+ }
247
+ if (!committed)
248
+ return { ok: false, lines };
249
+ if (options.packageChanged === true)
250
+ lines.push(`node_modules quedó con lo de la rama; si sigues en ${base}, corre '${options.manager} install'.`);
251
+ return { ok, lines };
252
+ }
package/dist/menu.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { FinishChoice } from "./git-finish.js";
1
2
  import type { Candidate, Step } from "./piece.js";
2
3
  /** What the person wants after seeing the table. */
3
4
  export type Path = "all" | "pick" | "leave";
@@ -14,6 +15,20 @@ export declare function menuOptions(steps: Step[]): {
14
15
  }[];
15
16
  initialValues: number[];
16
17
  };
18
+ /**
19
+ * The pieces menu's options: each piece by its name, ticked if the repo uses it.
20
+ * @param names - The pieces, in the order to show them.
21
+ * @param ticked - The ones ticked at the start.
22
+ * @returns The options and the ones ticked at the start.
23
+ * @example pieceOptions(["issue-flow", "skills"], ["skills"]).initialValues // ["skills"]
24
+ */
25
+ export declare function pieceOptions(names: string[], ticked: string[]): {
26
+ options: {
27
+ value: string;
28
+ label: string;
29
+ }[];
30
+ initialValues: string[];
31
+ };
17
32
  /**
18
33
  * The skills menu's options: grouped by catalog, each with its description cut short.
19
34
  * @param candidates - What the catalogs offer.
@@ -39,6 +54,14 @@ export declare function askPath(count: number): Promise<Path>;
39
54
  * @example await askSteps(steps)
40
55
  */
41
56
  export declare function askSteps(steps: Step[]): Promise<Step[] | null>;
57
+ /**
58
+ * Asks which pieces the repo uses.
59
+ * @param names - The pieces.
60
+ * @param ticked - The ones ticked at the start.
61
+ * @returns The picked pieces, or null if the person cancelled.
62
+ * @example await askPieces(["issue-flow", "skills"], ["skills"])
63
+ */
64
+ export declare function askPieces(names: string[], ticked: string[]): Promise<string[] | null>;
42
65
  /**
43
66
  * Asks which skills to install.
44
67
  * @param candidates - What the catalogs offer.
@@ -54,3 +77,13 @@ export declare function askSkills(candidates: Candidate[], ticked: string[]): Pr
54
77
  * @example await askReplace(["planning"])
55
78
  */
56
79
  export declare function askReplace(own: string[]): Promise<boolean | null>;
80
+ /**
81
+ * Asks, after the steps, what to do with the changes. Leaving them is the default, and
82
+ * cancelling leaves them too.
83
+ * @param base - The base branch.
84
+ * @param branch - The name of the new branch.
85
+ * @param failed - Whether a step failed, to warn above the question.
86
+ * @returns The answer.
87
+ * @example await askFinish("main", "chore/agent-kitchen-apply-2026-11-01", false) // "pr"
88
+ */
89
+ export declare function askFinish(base: string, branch: string, failed: boolean): Promise<FinishChoice>;
package/dist/menu.js CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { styleText } from "node:util";
8
8
  import { confirm, groupMultiselect, isCancel, MULTISELECT_INSTRUCTIONS, multiselect, select, SELECT_INSTRUCTIONS } from "@clack/prompts";
9
+ import { stepLabel } from "./texts/index.js";
9
10
  /** Width a skill's description is cut to in the menu. */
10
11
  const DESCRIPTION_WIDTH = 70;
11
12
  /**
@@ -24,10 +25,20 @@ function spanishKeys() {
24
25
  */
25
26
  export function menuOptions(steps) {
26
27
  return {
27
- options: steps.map((step, index) => ({ value: index, label: step.label })),
28
+ options: steps.map((step, index) => ({ value: index, label: stepLabel(step) })),
28
29
  initialValues: steps.flatMap((step, index) => (step.checked ? [index] : [])),
29
30
  };
30
31
  }
32
+ /**
33
+ * The pieces menu's options: each piece by its name, ticked if the repo uses it.
34
+ * @param names - The pieces, in the order to show them.
35
+ * @param ticked - The ones ticked at the start.
36
+ * @returns The options and the ones ticked at the start.
37
+ * @example pieceOptions(["issue-flow", "skills"], ["skills"]).initialValues // ["skills"]
38
+ */
39
+ export function pieceOptions(names, ticked) {
40
+ return { options: names.map((name) => ({ value: name, label: name })), initialValues: names.filter((name) => ticked.includes(name)) };
41
+ }
31
42
  /**
32
43
  * The skills menu's options: grouped by catalog, each with its description cut short.
33
44
  * @param candidates - What the catalogs offer.
@@ -72,6 +83,19 @@ export async function askSteps(steps) {
72
83
  return null;
73
84
  return steps.filter((_, index) => picked.includes(index));
74
85
  }
86
+ /**
87
+ * Asks which pieces the repo uses.
88
+ * @param names - The pieces.
89
+ * @param ticked - The ones ticked at the start.
90
+ * @returns The picked pieces, or null if the person cancelled.
91
+ * @example await askPieces(["issue-flow", "skills"], ["skills"])
92
+ */
93
+ export async function askPieces(names, ticked) {
94
+ spanishKeys();
95
+ const { options, initialValues } = pieceOptions(names, ticked);
96
+ const picked = await multiselect({ message: "¿Qué piezas usa este repo?", options, initialValues, required: false });
97
+ return isCancel(picked) ? null : picked;
98
+ }
75
99
  /**
76
100
  * Asks which skills to install.
77
101
  * @param candidates - What the catalogs offer.
@@ -98,3 +122,26 @@ export async function askReplace(own) {
98
122
  });
99
123
  return isCancel(answer) ? null : answer;
100
124
  }
125
+ /**
126
+ * Asks, after the steps, what to do with the changes. Leaving them is the default, and
127
+ * cancelling leaves them too.
128
+ * @param base - The base branch.
129
+ * @param branch - The name of the new branch.
130
+ * @param failed - Whether a step failed, to warn above the question.
131
+ * @returns The answer.
132
+ * @example await askFinish("main", "chore/agent-kitchen-apply-2026-11-01", false) // "pr"
133
+ */
134
+ export async function askFinish(base, branch, failed) {
135
+ spanishKeys();
136
+ const answer = await select({
137
+ message: failed ? "Algún paso falló. ¿Qué hago con los cambios?" : "¿Qué hago con los cambios?",
138
+ initialValue: "leave",
139
+ options: [
140
+ { value: "leave", label: "Dejarlos sin commit" },
141
+ { value: "base", label: `Commit en ${base}` },
142
+ { value: "branch", label: `Commit en una rama nueva, ${branch}`, hint: `${base} queda como está` },
143
+ { value: "pr", label: "Commit en una rama nueva, push y PR", hint: `contra ${base}` },
144
+ ],
145
+ });
146
+ return isCancel(answer) ? "leave" : answer;
147
+ }
package/dist/piece.d.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  * writes the few lines that join two pieces, the one line of AGENTS.md, which has no tool,
8
8
  * and the version of skills-manager in package.json, which agent-kitchen is the one to run.
9
9
  */
10
+ import type { StepText } from "./texts/step-text.js";
10
11
  /** The issue flow on npm. */
11
12
  export declare const FLOW_PACKAGE = "@ramon-sg/issue-flow";
12
13
  /** The GitHub repo of the issue flow, which is also the catalog of its skills. */
@@ -36,10 +37,15 @@ export interface SkillPick {
36
37
  picked: string[];
37
38
  replace: string[];
38
39
  }
39
- /** One step a piece offers: what it does, what it downloads, and how it runs. */
40
+ /**
41
+ * One step a piece offers: what it does (as data; texts/ turns it into words for each place
42
+ * it shows), what it downloads, and how it runs. `fresh` marks a step of a piece the repo has
43
+ * not decided on yet.
44
+ */
40
45
  export interface Step {
41
46
  piece: string;
42
- label: string;
47
+ text: StepText;
48
+ fresh?: boolean;
43
49
  checked: boolean;
44
50
  downloads: string[];
45
51
  skills?: SkillPick;
@@ -100,10 +100,8 @@ export async function agentsMdStatus(root, flowComing) {
100
100
  const summary = "regla de versiones instaladas en AGENTS.md";
101
101
  if (!lack.rule && !lack.import)
102
102
  return { name: NAME, summary, installed: true, state: "al día", steps: [] };
103
- const files = [...(lack.rule ? ["AGENTS.md"] : []), ...(lack.import ? ["CLAUDE.md"] : [])];
104
- const label = lack.rule ? `Agregar a AGENTS.md la regla de versiones instaladas${lack.import ? " (y @AGENTS.md en CLAUDE.md)" : ""}` : "Agregar @AGENTS.md a CLAUDE.md, para que Claude Code lea AGENTS.md";
105
103
  const state = lack.rule ? "falta la regla" : "CLAUDE.md no importa AGENTS.md";
106
- return { name: NAME, summary, installed: !lack.rule, state, steps: [{ piece: NAME, checked: true, downloads: [], label: `${label}: ${files.join(", ")}`, run: async (context) => write(context) }] };
104
+ return { name: NAME, summary, installed: !lack.rule, state, steps: [{ piece: NAME, checked: true, downloads: [], text: { kind: "agents-md", rule: lack.rule, claudeImport: lack.import }, run: async (context) => write(context) }] };
107
105
  }
108
106
  /**
109
107
  * Whether AGENTS.md has the rule and CLAUDE.md, if there is one, imports it. Only noted.
@@ -28,7 +28,7 @@ function stepFor(config, latest) {
28
28
  piece: NAME,
29
29
  checked: true,
30
30
  downloads: [spec],
31
- label: `Instalar claude-cloud ${latest ?? "1.x"}: prepara las sesiones en la nube de Claude`,
31
+ text: { kind: "cloud-install", version: latest },
32
32
  run: async (context) => {
33
33
  const run = await runNpx(context.root, [spec, "init"]);
34
34
  if (run.ok)
@@ -44,7 +44,7 @@ function stepFor(config, latest) {
44
44
  piece: NAME,
45
45
  checked: true,
46
46
  downloads: [],
47
- label: `Actualizar claude-cloud ${version ?? "?"} → ${latest} (la versión que fija ${CONFIG})`,
47
+ text: { kind: "cloud-update", from: version, to: latest, config: CONFIG },
48
48
  run: async (context) => {
49
49
  const current = readJson(context.root, CONFIG) ?? {};
50
50
  const schema = typeof current.$schema === "string" ? current.$schema.replace(/@ramon-sg\/claude-cloud@[^/]+\//, `${PACKAGE}@${latest}/`) : undefined;