agent-kitchen 0.0.0-stage → 0.3.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,151 @@
1
+ /**
2
+ * @file The issue flow (`@ramon-sg/issue-flow`): its CLI, hooks and config, and its skills.
3
+ * The repo's version is the one in package.json. Installing runs its `init` at the latest
4
+ * version; updating runs its `upgrade` at the new one and then moves its skills to the same
5
+ * tag, because the skills call the CLI. If only the skills are off, it moves them alone.
6
+ * `init` runs with `--yes`: the person answered agent-kitchen's questions before the steps.
7
+ */
8
+ import { FLOW_CATALOG, FLOW_PACKAGE } from "../piece.js";
9
+ import { dependencyVersion, readJson } from "../repo.js";
10
+ import { catalogSkills, manifestCatalogs, pinCatalog, updateSkills } from "../skills-files.js";
11
+ import { captureNpx, compareVersions, isRelease, npmLatest, runNpx } from "../tools.js";
12
+ /** The piece's name, as `--skip` takes it. */
13
+ export const NAME = "issue-flow";
14
+ /** How the catalog's ref is named when it is off. */
15
+ const SKILLS_CATALOG_LABEL = "el catálogo";
16
+ /** The first version whose `doctor` prints `--json`. */
17
+ const JSON_DOCTOR = "0.2.0";
18
+ /**
19
+ * The issue flow's version in the repo, as package.json has it: an exact release when the
20
+ * flow set it up, or anything else (a range, a `file:` link) someone wrote by hand.
21
+ * @param root - The repo.
22
+ * @returns The version in package.json, or null.
23
+ * @example flowVersion(root) // "0.2.0"
24
+ */
25
+ export function flowSpec(root) {
26
+ return dependencyVersion(readJson(root, "package.json"), FLOW_PACKAGE);
27
+ }
28
+ /**
29
+ * The issue flow's release in the repo: what package.json pins, if it is an exact release.
30
+ * @param root - The repo.
31
+ * @returns The version, or null if there is none or it is not a release.
32
+ * @example flowVersion(root) // "0.2.0"
33
+ */
34
+ export function flowVersion(root) {
35
+ const spec = flowSpec(root);
36
+ return spec !== null && isRelease(spec) ? spec : null;
37
+ }
38
+ /**
39
+ * The flow's skills that are not at a version's tag, or the catalog pinned elsewhere.
40
+ * @param root - The repo.
41
+ * @param version - The flow's version.
42
+ * @returns Their names; the catalog's ref as well if it is off.
43
+ */
44
+ function skillsOff(root, version) {
45
+ const tag = `v${version}`;
46
+ const off = catalogSkills(root, FLOW_CATALOG).filter((skill) => skill.tag !== tag).map((skill) => `${skill.name} en ${skill.tag ?? "sin tag"}`);
47
+ const source = manifestCatalogs(root).find((item) => item.catalog === FLOW_CATALOG);
48
+ return source !== undefined && source.ref !== tag ? [...off, `${SKILLS_CATALOG_LABEL} en ${source.ref ?? "la última"}`] : off;
49
+ }
50
+ /**
51
+ * Moves the flow's skills to a version's tag: the catalog's ref in the manifest, and then
52
+ * `skills-manager update` on them.
53
+ * @param context - Where it runs.
54
+ * @param version - The flow's version.
55
+ * @returns Whether it worked, or true if the repo has none of the flow's skills.
56
+ */
57
+ async function moveSkills(context, version) {
58
+ if (!pinCatalog(context.root, FLOW_CATALOG, `v${version}`))
59
+ return true;
60
+ const names = catalogSkills(context.root, FLOW_CATALOG).map((skill) => skill.name);
61
+ const failed = await updateSkills(context.root, names);
62
+ if (failed === null) {
63
+ context.note(`skills del flujo en v${version}: ${names.join(", ")}`);
64
+ return true;
65
+ }
66
+ context.fail(`skills-manager no actualizó las skills del flujo; corre '${failed.command}' y luego agent-kitchen otra vez.`, failed.run.output);
67
+ return false;
68
+ }
69
+ /**
70
+ * Runs the flow's own command at a version.
71
+ * @param context - Where it runs.
72
+ * @param version - The version.
73
+ * @param args - The command and its flags.
74
+ * @returns Whether it exited 0.
75
+ */
76
+ async function runFlow(context, version, args) {
77
+ const run = await runNpx(context.root, [`${FLOW_PACKAGE}@${version}`, ...args]);
78
+ if (run.ok)
79
+ return true;
80
+ context.fail(`issue-flow ${args[0] ?? ""} falló; corre 'npx ${FLOW_PACKAGE}@${version} ${args.join(" ")}'.`, run.output);
81
+ return false;
82
+ }
83
+ /**
84
+ * The step the repo needs, if any.
85
+ * @param installed - The repo's version, or null.
86
+ * @param latest - The latest on npm, or null.
87
+ * @param root - The repo.
88
+ * @returns The step, or null.
89
+ */
90
+ function stepFor(installed, latest, root) {
91
+ if (installed === null && latest !== null)
92
+ return { piece: NAME, checked: true, downloads: [`${FLOW_PACKAGE}@${latest}`], label: `Instalar issue-flow ${latest}: config del flujo, hooks y scripts`, run: (context) => runFlow(context, latest, ["init", "--yes"]) };
93
+ if (installed !== null && latest !== null && compareVersions(latest, installed) > 0)
94
+ return { piece: NAME, checked: true, downloads: [`${FLOW_PACKAGE}@${latest}`], label: `Actualizar issue-flow ${installed} → ${latest}, con sus hooks y sus skills`, run: async (context) => (await runFlow(context, latest, ["upgrade"])) && (await moveSkills(context, latest)) };
95
+ if (installed !== null && skillsOff(root, installed).length > 0)
96
+ return { piece: NAME, checked: true, downloads: [], label: `Poner las skills del flujo en v${installed}, la versión de issue-flow`, run: (context) => moveSkills(context, installed) };
97
+ return null;
98
+ }
99
+ /**
100
+ * What the repo has of the issue flow, and the step it needs.
101
+ * @param root - The repo.
102
+ * @returns The piece's status.
103
+ * @example (await issueFlowStatus(root)).state // "0.1.0, hay 0.2.0"
104
+ */
105
+ export async function issueFlowStatus(root) {
106
+ const summary = "rama, worktree y PR en draft por issue";
107
+ const spec = flowSpec(root);
108
+ if (spec !== null && !isRelease(spec))
109
+ return { name: NAME, summary, installed: true, state: `package.json tiene ${spec}, no una versión exacta; no se toca`, steps: [] };
110
+ const installed = flowVersion(root);
111
+ const latest = await npmLatest(FLOW_PACKAGE);
112
+ const step = stepFor(installed, latest, root);
113
+ const known = latest === null ? " (npm no responde)" : "";
114
+ const state = installed === null ? `no instalado${known}` : latest !== null && compareVersions(latest, installed) > 0 ? `${installed}, hay ${latest}` : `${installed}, al día${known}`;
115
+ return { name: NAME, summary, installed: installed !== null, state, steps: step === null ? [] : [step] };
116
+ }
117
+ /**
118
+ * The flow's own doctor at the repo's version, and whether its skills are at that tag.
119
+ * @param root - The repo.
120
+ * @returns The checks.
121
+ * @example (await issueFlowChecks(root))[0].name // "issue-flow: .agents/flow.json"
122
+ */
123
+ export async function issueFlowChecks(root) {
124
+ const spec = flowSpec(root);
125
+ if (spec === null)
126
+ return [{ name: NAME, status: "info", detail: "no instalado" }];
127
+ const installed = flowVersion(root);
128
+ if (installed === null)
129
+ return [{ name: NAME, status: "info", detail: `package.json tiene ${spec}, no una versión exacta` }];
130
+ if (compareVersions(installed, JSON_DOCTOR) < 0)
131
+ return [{ name: NAME, status: "info", detail: `${installed} no tiene doctor --json: corre npx agent-kitchen para actualizarlo` }];
132
+ const result = await captureNpx(root, [`${FLOW_PACKAGE}@${installed}`, "doctor", "--json"]);
133
+ let found;
134
+ try {
135
+ found = JSON.parse(result?.out ?? "");
136
+ }
137
+ catch {
138
+ found = null;
139
+ }
140
+ const own = Array.isArray(found)
141
+ ? found.map((check) => ({ name: `${NAME}: ${String(check.name)}`, status: check.ok === true ? "ok" : "fail", detail: String(check.detail) }))
142
+ : [{ name: NAME, status: "fail", detail: `no se pudo correr su doctor; corre 'npx ${FLOW_PACKAGE}@${installed} doctor'` }];
143
+ const off = skillsOff(root, installed);
144
+ const count = catalogSkills(root, FLOW_CATALOG).length;
145
+ const skills = count === 0 && off.length === 0
146
+ ? { name: `${NAME}: skills`, status: "info", detail: "ninguna instalada" }
147
+ : off.length === 0
148
+ ? { name: `${NAME}: skills`, status: "ok", detail: `${count} en v${installed}` }
149
+ : { name: `${NAME}: skills`, status: "fail", detail: `${off.join(", ")}; issue-flow está en ${installed}: corre npx agent-kitchen` };
150
+ return [...own, skills];
151
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * @file The skills, through `skills-manager`: the flow's catalog (pinned to the flow's
3
+ * version, because its skills call the CLI) and the general catalogs (each skill at its
4
+ * own tag). agent-kitchen reads what the catalogs offer (`--candidates`), lets the person
5
+ * pick before any step runs, and then installs the pick with `--select`, so
6
+ * `skills-manager` asks nothing. The first time every skill comes ticked. Later it offers
7
+ * to move the general skills to their catalog's latest tag, and, unticked, the skills a
8
+ * catalog has that the repo does not take yet. The flow's skills move with the flow
9
+ * (pieces/issue-flow.ts), never here.
10
+ *
11
+ * A skill the repo already has as its own (a folder the lock does not know) is replaced
12
+ * only if the person allows it (`--replace`); `skills-manager` keeps its `project.md`.
13
+ */
14
+ import { type Candidate, type Check, type PieceStatus, type SkillPick } from "../piece.js";
15
+ /** The piece's name, as `--skip` takes it. */
16
+ export declare const NAME = "skills";
17
+ /**
18
+ * Every skill some candidates bring: their own and what they require, all the way down,
19
+ * as `skills-manager` follows it.
20
+ * @param candidates - Every candidate offered.
21
+ * @param ids - The candidates picked.
22
+ * @returns The skills' names.
23
+ * @example skillsOf(candidates, ["catalog:ramon-sg/issue-flow/close-pr"]) // ["close-pr", "pr-review-comments", "github-issues"]
24
+ */
25
+ export declare function skillsOf(candidates: Candidate[], ids: string[]): string[];
26
+ /**
27
+ * The repo's own skills a pick would replace: each picked skill and what it requires.
28
+ * @param root - The repo.
29
+ * @param pick - The pick.
30
+ * @returns Their names.
31
+ * @example ownInPick(root, step.skills) // ["planning"]
32
+ */
33
+ export declare function ownInPick(root: string, pick: SkillPick): string[];
34
+ /**
35
+ * Drops from a pick the skills that would replace one of the repo's own (or that require
36
+ * one), for when the person does not allow replacing them.
37
+ * @param pick - The pick.
38
+ * @param own - The repo's own skills that stay.
39
+ * @returns The pick without them.
40
+ * @example keepOwn(pick, ["planning"]).picked
41
+ */
42
+ export declare function keepOwn(pick: SkillPick, own: string[]): SkillPick;
43
+ /**
44
+ * What the repo has of the skills, and the steps it can take.
45
+ * @param root - The repo.
46
+ * @param withFlow - Whether this run may install the flow (it is not left out with `--skip`).
47
+ * @returns The piece's status.
48
+ * @example (await skillsStatus(root, true)).state // "6 instaladas"
49
+ */
50
+ export declare function skillsStatus(root: string, withFlow: boolean): Promise<PieceStatus>;
51
+ /**
52
+ * Whether `skills-manager check` passes: disk, lock and manifest agree.
53
+ * @param root - The repo.
54
+ * @returns The checks.
55
+ * @example (await skillsChecks(root))[0].status // "ok"
56
+ */
57
+ export declare function skillsChecks(root: string): Promise<Check[]>;
@@ -0,0 +1,251 @@
1
+ /**
2
+ * @file The skills, through `skills-manager`: the flow's catalog (pinned to the flow's
3
+ * version, because its skills call the CLI) and the general catalogs (each skill at its
4
+ * own tag). agent-kitchen reads what the catalogs offer (`--candidates`), lets the person
5
+ * pick before any step runs, and then installs the pick with `--select`, so
6
+ * `skills-manager` asks nothing. The first time every skill comes ticked. Later it offers
7
+ * to move the general skills to their catalog's latest tag, and, unticked, the skills a
8
+ * catalog has that the repo does not take yet. The flow's skills move with the flow
9
+ * (pieces/issue-flow.ts), never here.
10
+ *
11
+ * A skill the repo already has as its own (a folder the lock does not know) is replaced
12
+ * only if the person allows it (`--replace`); `skills-manager` keeps its `project.md`.
13
+ */
14
+ import { FLOW_CATALOG, FLOW_PACKAGE, GENERAL_CATALOGS, SKILLS_MANIFEST } from "../piece.js";
15
+ import { isObject, readJson } from "../repo.js";
16
+ import { catalogSkills, lockedSkills, manifestCatalogs, ownSkills, pinCatalog, skillsManagerSpec, updateSkills } from "../skills-files.js";
17
+ import { captureNpx, compareVersions, latestTag, npmLatest, runNpx } from "../tools.js";
18
+ import { flowVersion } from "./issue-flow.js";
19
+ /** The piece's name, as `--skip` takes it. */
20
+ export const NAME = "skills";
21
+ /** The skills picked when the catalogs cannot be read: the flow's, and the audit `close-pr` uses. */
22
+ const DEFAULT_SKILLS = [
23
+ ...["github-issues", "start-issue", "close-pr", "pr-review-comments", "pr-worktree"].map((name) => `catalog:${FLOW_CATALOG}/${name}`),
24
+ "catalog:ramon-sg/agent-skills/code-audit",
25
+ ];
26
+ /** A release tag. */
27
+ const RELEASE_TAG = /^v\d+\.\d+\.\d+$/;
28
+ /**
29
+ * The `--catalog` flags: the flow's at a version, if there is one, and the general ones.
30
+ * @param flow - The flow's version, or null.
31
+ * @returns The flags.
32
+ */
33
+ function catalogArgs(flow) {
34
+ const catalogs = [...(flow === null ? [] : [`${FLOW_CATALOG}#v${flow}`]), ...GENERAL_CATALOGS];
35
+ return catalogs.flatMap((catalog) => ["--catalog", catalog]);
36
+ }
37
+ /**
38
+ * Reads one candidate of `--candidates`.
39
+ * @param value - An item of its output.
40
+ * @returns The candidate, or null if it is not one.
41
+ */
42
+ function toCandidate(value) {
43
+ if (!isObject(value) || typeof value.id !== "string" || typeof value.name !== "string")
44
+ return null;
45
+ const names = (field) => (Array.isArray(field) ? field.map(String) : []);
46
+ return {
47
+ id: value.id,
48
+ name: value.name,
49
+ catalog: typeof value.catalog === "string" ? value.catalog : "",
50
+ description: typeof value.description === "string" ? value.description : "",
51
+ skills: names(value.skills),
52
+ requires: names(value.requires),
53
+ };
54
+ }
55
+ /**
56
+ * Every skill some candidates bring: their own and what they require, all the way down,
57
+ * as `skills-manager` follows it.
58
+ * @param candidates - Every candidate offered.
59
+ * @param ids - The candidates picked.
60
+ * @returns The skills' names.
61
+ * @example skillsOf(candidates, ["catalog:ramon-sg/issue-flow/close-pr"]) // ["close-pr", "pr-review-comments", "github-issues"]
62
+ */
63
+ export function skillsOf(candidates, ids) {
64
+ const byName = new Map(candidates.map((candidate) => [candidate.name, candidate]));
65
+ const found = new Set();
66
+ const pending = candidates.filter((candidate) => ids.includes(candidate.id)).flatMap((candidate) => [...candidate.skills, ...candidate.requires]);
67
+ while (pending.length > 0) {
68
+ const name = pending.pop();
69
+ if (name === undefined || found.has(name))
70
+ continue;
71
+ found.add(name);
72
+ pending.push(...(byName.get(name)?.requires ?? []));
73
+ }
74
+ return [...found];
75
+ }
76
+ /**
77
+ * The catalog of a candidate's id (`catalog:<owner>/<repo>/<name>`).
78
+ * @param id - The id.
79
+ * @returns The catalog, as owner/repo.
80
+ */
81
+ function catalogOf(id) {
82
+ return id.replace(/^catalog:/, "").split("/").slice(0, 2).join("/");
83
+ }
84
+ /**
85
+ * What the catalogs offer that the manifest does not take yet, as `skills-manager` lists it.
86
+ * @param root - The repo.
87
+ * @param flow - The flow's version whose catalog to read, or null.
88
+ * @returns The candidates, or null if `skills-manager` could not tell.
89
+ */
90
+ async function readCandidates(root, flow) {
91
+ const command = readJson(root, SKILLS_MANIFEST) === null ? "init" : "add";
92
+ const result = await captureNpx(root, [skillsManagerSpec(root), command, "--candidates", ...catalogArgs(flow)], true);
93
+ let found;
94
+ try {
95
+ found = JSON.parse(result?.out ?? "");
96
+ }
97
+ catch {
98
+ return null;
99
+ }
100
+ if (!Array.isArray(found))
101
+ return null;
102
+ return found.flatMap((item) => toCandidate(item) ?? []);
103
+ }
104
+ /**
105
+ * The repo's own skills a pick would replace: each picked skill and what it requires.
106
+ * @param root - The repo.
107
+ * @param pick - The pick.
108
+ * @returns Their names.
109
+ * @example ownInPick(root, step.skills) // ["planning"]
110
+ */
111
+ export function ownInPick(root, pick) {
112
+ return ownSkills(root, skillsOf(pick.candidates, pick.picked));
113
+ }
114
+ /**
115
+ * Drops from a pick the skills that would replace one of the repo's own (or that require
116
+ * one), for when the person does not allow replacing them.
117
+ * @param pick - The pick.
118
+ * @param own - The repo's own skills that stay.
119
+ * @returns The pick without them.
120
+ * @example keepOwn(pick, ["planning"]).picked
121
+ */
122
+ export function keepOwn(pick, own) {
123
+ const picked = pick.picked.filter((id) => !skillsOf(pick.candidates, [id]).some((name) => own.includes(name)));
124
+ return { ...pick, picked, replace: [] };
125
+ }
126
+ /**
127
+ * Installs the picked skills with `--select`, replacing the own ones the person allowed.
128
+ * @param context - Where it runs.
129
+ * @param pick - What to install.
130
+ * @returns Whether `skills-manager` worked, or true if nothing was picked.
131
+ */
132
+ async function install(context, pick) {
133
+ const flow = flowVersion(context.root);
134
+ const picked = flow === null ? pick.picked.filter((id) => catalogOf(id) !== FLOW_CATALOG) : pick.picked;
135
+ if (picked.length < pick.picked.length)
136
+ context.note("sin las skills del flujo, porque issue-flow no está instalado");
137
+ if (picked.length === 0) {
138
+ context.note("ninguna skill que instalar");
139
+ return true;
140
+ }
141
+ const spec = skillsManagerSpec(context.root);
142
+ const command = readJson(context.root, SKILLS_MANIFEST) === null ? "init" : "add";
143
+ const args = [spec, command, ...catalogArgs(flow), "--select", picked.join(","), ...(pick.replace.length === 0 ? [] : ["--replace", pick.replace.join(",")])];
144
+ const run = await runNpx(context.root, args);
145
+ if (!run.ok) {
146
+ context.fail(`skills-manager no instaló las skills; corre 'npx ${args.join(" ")}'.`, run.output);
147
+ return false;
148
+ }
149
+ const names = pick.candidates.filter((candidate) => picked.includes(candidate.id)).map((candidate) => candidate.name);
150
+ context.note(names.length === 0 ? `${picked.length} skills` : names.join(", "));
151
+ if (pick.replace.length > 0)
152
+ context.note(`reemplazadas (con su project.md): ${pick.replace.join(", ")}`);
153
+ return true;
154
+ }
155
+ /**
156
+ * The step that moves a general catalog's skills to its latest tag, if they are behind. A
157
+ * catalog that follows a branch has no tag to move to, so it gets no step.
158
+ * @param root - The repo.
159
+ * @param catalog - The catalog.
160
+ * @param ref - The ref the manifest pins it to, or null.
161
+ * @returns The step, or null.
162
+ */
163
+ async function updateStep(root, catalog, ref) {
164
+ if (ref !== null && !RELEASE_TAG.test(ref))
165
+ return null;
166
+ const latest = await latestTag(catalog);
167
+ if (latest === null)
168
+ return null;
169
+ const behind = catalogSkills(root, catalog).filter((skill) => skill.tag !== null && RELEASE_TAG.test(skill.tag) && compareVersions(latest, skill.tag.replace(/^v/, "")) > 0);
170
+ if (behind.length === 0)
171
+ return null;
172
+ const from = [...new Set(behind.map((skill) => skill.tag ?? "?"))].join(", ");
173
+ return {
174
+ piece: NAME,
175
+ checked: true,
176
+ downloads: [skillsManagerSpec(root)],
177
+ label: `Actualizar skills de ${catalog} ${from} → v${latest}: ${behind.map((skill) => skill.name).join(", ")}`,
178
+ run: async (context) => {
179
+ if (ref !== null)
180
+ pinCatalog(context.root, catalog, `v${latest}`);
181
+ const failed = await updateSkills(context.root, behind.map((skill) => skill.name));
182
+ if (failed === null)
183
+ return true;
184
+ context.fail(`skills-manager no actualizó ${catalog}; corre '${failed.command}' y luego agent-kitchen otra vez.`, failed.run.output);
185
+ return false;
186
+ },
187
+ };
188
+ }
189
+ /**
190
+ * The flow's version whose catalog to offer: the repo's, or the latest when the repo does
191
+ * not have the flow yet (it is installed before the skills), unless the flow is left out.
192
+ * @param root - The repo.
193
+ * @param withFlow - Whether this run may install the flow.
194
+ * @returns The version, or null if there is none.
195
+ */
196
+ async function offeredFlow(root, withFlow) {
197
+ return flowVersion(root) ?? (withFlow ? await npmLatest(FLOW_PACKAGE) : null);
198
+ }
199
+ /**
200
+ * What the repo has of the skills, and the steps it can take.
201
+ * @param root - The repo.
202
+ * @param withFlow - Whether this run may install the flow (it is not left out with `--skip`).
203
+ * @returns The piece's status.
204
+ * @example (await skillsStatus(root, true)).state // "6 instaladas"
205
+ */
206
+ export async function skillsStatus(root, withFlow) {
207
+ const summary = "skills del flujo, planes y auditorías";
208
+ const flow = await offeredFlow(root, withFlow);
209
+ const candidatesRead = readCandidates(root, flow);
210
+ if (readJson(root, SKILLS_MANIFEST) === null) {
211
+ const candidates = await candidatesRead;
212
+ const fallback = DEFAULT_SKILLS.filter((id) => flow !== null || catalogOf(id) !== FLOW_CATALOG).map((id) => {
213
+ const name = id.split("/").at(-1) ?? id;
214
+ return { id, name, catalog: catalogOf(id), description: "", skills: [name], requires: [] };
215
+ });
216
+ const offered = candidates ?? fallback;
217
+ const catalogs = [...new Set(offered.map((candidate) => candidate.catalog))];
218
+ const label = `Instalar ${offered.length} skills de ${catalogs.join(" y ")}`;
219
+ const skills = { candidates: offered, picked: offered.map((candidate) => candidate.id), replace: [] };
220
+ const state = candidates === null ? "no instaladas (no se pudieron leer los catálogos)" : "no instaladas";
221
+ return { name: NAME, summary, installed: false, state, steps: [{ piece: NAME, checked: true, downloads: [], label, skills, run: (context) => install(context, skills) }] };
222
+ }
223
+ const general = manifestCatalogs(root).filter((source) => source.catalog !== FLOW_CATALOG);
224
+ const steps = (await Promise.all(general.map((source) => updateStep(root, source.catalog, source.ref)))).flatMap((step) => step ?? []);
225
+ const locked = lockedSkills(root);
226
+ const candidates = await candidatesRead;
227
+ const fresh = (candidates ?? []).filter((candidate) => locked[candidate.name] === undefined);
228
+ if (fresh.length > 0) {
229
+ const skills = { candidates: fresh, picked: [], replace: [] };
230
+ steps.push({ piece: NAME, checked: false, downloads: [skillsManagerSpec(root)], label: `Agregar skills nuevas (${fresh.length}): ${fresh.map((candidate) => candidate.name).join(", ")}`, skills, run: (context) => install(context, skills) });
231
+ }
232
+ const count = manifestCatalogs(root).reduce((total, source) => total + catalogSkills(root, source.catalog).length, 0);
233
+ const behind = steps.filter((step) => step.checked).length;
234
+ const unread = candidates === null ? " (no se pudieron leer los catálogos)" : "";
235
+ return { name: NAME, summary, installed: true, state: `${count} instaladas${behind > 0 ? ", algunas atrasadas" : ", al día"}${unread}`, steps };
236
+ }
237
+ /**
238
+ * Whether `skills-manager check` passes: disk, lock and manifest agree.
239
+ * @param root - The repo.
240
+ * @returns The checks.
241
+ * @example (await skillsChecks(root))[0].status // "ok"
242
+ */
243
+ export async function skillsChecks(root) {
244
+ if (readJson(root, SKILLS_MANIFEST) === null)
245
+ return [{ name: NAME, status: "info", detail: "no instaladas" }];
246
+ const spec = skillsManagerSpec(root);
247
+ const result = await captureNpx(root, [spec, "check"]);
248
+ if (result?.status === 0)
249
+ return [{ name: NAME, status: "ok", detail: "skills-manager check pasa" }];
250
+ return [{ name: NAME, status: "fail", detail: `el disco, el lock y el manifiesto no coinciden: corre 'npx ${spec} check'` }];
251
+ }
package/dist/repo.d.ts ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Whether a value is a plain JSON object.
3
+ * @param value - The value.
4
+ * @returns True for an object that is not an array nor null.
5
+ * @example isObject({}) // true
6
+ */
7
+ export declare function isObject(value: unknown): value is Record<string, unknown>;
8
+ /**
9
+ * The main checkout of the repo a folder is in.
10
+ * @param cwd - The folder.
11
+ * @param command - The command that runs, for the error.
12
+ * @returns Its root, with symlinks resolved.
13
+ * @throws KitError outside a git checkout or inside a worktree.
14
+ * @example mainCheckout(process.cwd(), "agent-kitchen") // "/code/shop"
15
+ */
16
+ export declare function mainCheckout(cwd: string, command: string): string;
17
+ /**
18
+ * Reads a JSON object of the repo.
19
+ * @param root - The repo.
20
+ * @param path - The file, relative to the repo.
21
+ * @returns The object, or null if the file does not exist.
22
+ * @throws KitError if it is not valid JSON or not an object: it is never overwritten then.
23
+ * @example readJson(root, ".claude/cloud.json")
24
+ */
25
+ export declare function readJson(root: string, path: string): Record<string, unknown> | null;
26
+ /**
27
+ * Writes a JSON file of the repo, indented by two spaces, creating its folder.
28
+ * @param root - The repo.
29
+ * @param path - The file, relative to the repo.
30
+ * @param value - What to write.
31
+ * @example writeJson(root, ".claude/cloud.json", { version: "1.1.0" })
32
+ */
33
+ export declare function writeJson(root: string, path: string, value: unknown): void;
34
+ /**
35
+ * The version of a dependency in `package.json`, development or not.
36
+ * @param pkg - The package.json, or null.
37
+ * @param name - The package.
38
+ * @returns The version, or null if it is not there.
39
+ * @example dependencyVersion(pkg, "@ramon-sg/issue-flow") // "0.2.0"
40
+ */
41
+ export declare function dependencyVersion(pkg: Record<string, unknown> | null, name: string): string | null;
42
+ /**
43
+ * The repo's package manager: the `packageManager` field, then its lockfile, then npm.
44
+ * @param root - The repo.
45
+ * @returns `npm`, `pnpm`, `bun` or `yarn`.
46
+ * @example packageManager(root) // "pnpm"
47
+ */
48
+ export declare function packageManager(root: string): string;
package/dist/repo.js ADDED
@@ -0,0 +1,117 @@
1
+ /**
2
+ * @file The repo agent-kitchen works on: its main checkout (never a worktree, whose files are
3
+ * not the ones the team commits from), the JSON files the pieces keep in it, and its
4
+ * package manager.
5
+ */
6
+ import { spawnSync } from "node:child_process";
7
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { KitError } from "./output.js";
10
+ /**
11
+ * Whether a value is a plain JSON object.
12
+ * @param value - The value.
13
+ * @returns True for an object that is not an array nor null.
14
+ * @example isObject({}) // true
15
+ */
16
+ export function isObject(value) {
17
+ return typeof value === "object" && value !== null && !Array.isArray(value);
18
+ }
19
+ /**
20
+ * Runs git in a folder.
21
+ * @param cwd - The folder.
22
+ * @param args - The arguments.
23
+ * @returns Its trimmed stdout, or null if it failed.
24
+ */
25
+ function git(cwd, args) {
26
+ const result = spawnSync("git", args, { cwd, encoding: "utf8" });
27
+ return result.status === 0 ? result.stdout.trim() : null;
28
+ }
29
+ /**
30
+ * The main checkout of the repo a folder is in.
31
+ * @param cwd - The folder.
32
+ * @param command - The command that runs, for the error.
33
+ * @returns Its root, with symlinks resolved.
34
+ * @throws KitError outside a git checkout or inside a worktree.
35
+ * @example mainCheckout(process.cwd(), "agent-kitchen") // "/code/shop"
36
+ */
37
+ export function mainCheckout(cwd, command) {
38
+ const common = git(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
39
+ const top = git(cwd, ["rev-parse", "--show-toplevel"]);
40
+ if (common === null || top === null)
41
+ throw new KitError(`corre ${command} dentro del repo.`);
42
+ const main = realpathSync(dirname(common));
43
+ if (realpathSync(top) !== main)
44
+ throw new KitError(`corre ${command} en la copia principal, ${main}, no en un worktree.`);
45
+ return main;
46
+ }
47
+ /**
48
+ * Reads a JSON object of the repo.
49
+ * @param root - The repo.
50
+ * @param path - The file, relative to the repo.
51
+ * @returns The object, or null if the file does not exist.
52
+ * @throws KitError if it is not valid JSON or not an object: it is never overwritten then.
53
+ * @example readJson(root, ".claude/cloud.json")
54
+ */
55
+ export function readJson(root, path) {
56
+ const file = join(root, path);
57
+ if (!existsSync(file))
58
+ return null;
59
+ let value;
60
+ try {
61
+ value = JSON.parse(readFileSync(file, "utf8"));
62
+ }
63
+ catch (error) {
64
+ throw new KitError(`${path} no es JSON válido (${error instanceof Error ? error.message : String(error)}); arréglalo y corre otra vez.`);
65
+ }
66
+ if (!isObject(value))
67
+ throw new KitError(`${path} no es un objeto JSON; arréglalo y corre otra vez.`);
68
+ return value;
69
+ }
70
+ /**
71
+ * Writes a JSON file of the repo, indented by two spaces, creating its folder.
72
+ * @param root - The repo.
73
+ * @param path - The file, relative to the repo.
74
+ * @param value - What to write.
75
+ * @example writeJson(root, ".claude/cloud.json", { version: "1.1.0" })
76
+ */
77
+ export function writeJson(root, path, value) {
78
+ const file = join(root, path);
79
+ mkdirSync(dirname(file), { recursive: true });
80
+ writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
81
+ }
82
+ /**
83
+ * The version of a dependency in `package.json`, development or not.
84
+ * @param pkg - The package.json, or null.
85
+ * @param name - The package.
86
+ * @returns The version, or null if it is not there.
87
+ * @example dependencyVersion(pkg, "@ramon-sg/issue-flow") // "0.2.0"
88
+ */
89
+ export function dependencyVersion(pkg, name) {
90
+ for (const field of ["devDependencies", "dependencies"]) {
91
+ const deps = pkg?.[field];
92
+ if (isObject(deps) && typeof deps[name] === "string")
93
+ return deps[name];
94
+ }
95
+ return null;
96
+ }
97
+ /** The lockfile of each package manager, in the order they are looked for. */
98
+ const LOCKFILES = [
99
+ ["bun.lock", "bun"],
100
+ ["bun.lockb", "bun"],
101
+ ["pnpm-lock.yaml", "pnpm"],
102
+ ["yarn.lock", "yarn"],
103
+ ["package-lock.json", "npm"],
104
+ ];
105
+ /**
106
+ * The repo's package manager: the `packageManager` field, then its lockfile, then npm.
107
+ * @param root - The repo.
108
+ * @returns `npm`, `pnpm`, `bun` or `yarn`.
109
+ * @example packageManager(root) // "pnpm"
110
+ */
111
+ export function packageManager(root) {
112
+ const pkg = readJson(root, "package.json");
113
+ const field = typeof pkg?.packageManager === "string" ? pkg.packageManager.split("@")[0] : undefined;
114
+ if (field !== undefined && field !== "")
115
+ return field;
116
+ return LOCKFILES.find(([file]) => existsSync(join(root, file)))?.[1] ?? "npm";
117
+ }