@agentplate/cli 1.0.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.
Files changed (139) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/LICENSE +21 -0
  3. package/README.md +206 -0
  4. package/agents/architect.md +108 -0
  5. package/agents/builder.md +97 -0
  6. package/agents/coordinator.md +113 -0
  7. package/agents/deployer.md +117 -0
  8. package/agents/devops.md +114 -0
  9. package/agents/lead.md +107 -0
  10. package/agents/merger.md +103 -0
  11. package/agents/reviewer.md +90 -0
  12. package/agents/scout.md +95 -0
  13. package/agents/verifier.md +106 -0
  14. package/package.json +64 -0
  15. package/src/agents/guard-rules.ts +55 -0
  16. package/src/agents/identity.test.ts +161 -0
  17. package/src/agents/identity.ts +229 -0
  18. package/src/agents/manifest.test.ts +260 -0
  19. package/src/agents/manifest.ts +286 -0
  20. package/src/agents/overlay.test.ts +190 -0
  21. package/src/agents/overlay.ts +212 -0
  22. package/src/agents/system-prompt.test.ts +53 -0
  23. package/src/agents/system-prompt.ts +95 -0
  24. package/src/agents/turn-runner.ts +79 -0
  25. package/src/commands/coordinator.test.ts +75 -0
  26. package/src/commands/coordinator.ts +259 -0
  27. package/src/commands/deploy.test.ts +504 -0
  28. package/src/commands/deploy.ts +874 -0
  29. package/src/commands/doctor.test.ts +106 -0
  30. package/src/commands/doctor.ts +208 -0
  31. package/src/commands/init.ts +71 -0
  32. package/src/commands/log.ts +51 -0
  33. package/src/commands/mail.ts +197 -0
  34. package/src/commands/merge.ts +127 -0
  35. package/src/commands/model.ts +58 -0
  36. package/src/commands/prime.ts +61 -0
  37. package/src/commands/reap.ts +87 -0
  38. package/src/commands/serve.ts +61 -0
  39. package/src/commands/setup.ts +48 -0
  40. package/src/commands/ship.test.ts +106 -0
  41. package/src/commands/ship.ts +202 -0
  42. package/src/commands/skill.test.ts +458 -0
  43. package/src/commands/skill.ts +730 -0
  44. package/src/commands/sling.ts +365 -0
  45. package/src/commands/status.ts +60 -0
  46. package/src/commands/stop.ts +56 -0
  47. package/src/commands/tui.ts +199 -0
  48. package/src/commands/worktree.ts +77 -0
  49. package/src/config.test.ts +92 -0
  50. package/src/config.ts +202 -0
  51. package/src/db/sqlite.test.ts +77 -0
  52. package/src/db/sqlite.ts +102 -0
  53. package/src/deploy/audit.test.ts +233 -0
  54. package/src/deploy/audit.ts +245 -0
  55. package/src/deploy/context.test.ts +243 -0
  56. package/src/deploy/context.ts +72 -0
  57. package/src/deploy/registry.test.ts +101 -0
  58. package/src/deploy/registry.ts +86 -0
  59. package/src/deploy/secrets.test.ts +129 -0
  60. package/src/deploy/secrets.ts +69 -0
  61. package/src/deploy/targets/docker-gha.test.ts +323 -0
  62. package/src/deploy/targets/docker-gha.ts +841 -0
  63. package/src/deploy/types.ts +153 -0
  64. package/src/errors.test.ts +42 -0
  65. package/src/errors.ts +69 -0
  66. package/src/events/store.test.ts +183 -0
  67. package/src/events/store.ts +201 -0
  68. package/src/index.ts +137 -0
  69. package/src/insights/quality-gates.ts +73 -0
  70. package/src/json.test.ts +28 -0
  71. package/src/json.ts +50 -0
  72. package/src/logging/color.ts +62 -0
  73. package/src/logging/logger.ts +60 -0
  74. package/src/logging/sanitizer.test.ts +36 -0
  75. package/src/logging/sanitizer.ts +57 -0
  76. package/src/mail/client.test.ts +192 -0
  77. package/src/mail/client.ts +188 -0
  78. package/src/mail/store.test.ts +279 -0
  79. package/src/mail/store.ts +311 -0
  80. package/src/merge/lock.test.ts +88 -0
  81. package/src/merge/lock.ts +84 -0
  82. package/src/merge/queue.test.ts +136 -0
  83. package/src/merge/queue.ts +177 -0
  84. package/src/merge/resolver.test.ts +219 -0
  85. package/src/merge/resolver.ts +274 -0
  86. package/src/paths.ts +36 -0
  87. package/src/providers/apply.test.ts +90 -0
  88. package/src/providers/apply.ts +66 -0
  89. package/src/providers/registry.test.ts +74 -0
  90. package/src/providers/registry.ts +254 -0
  91. package/src/runtimes/claude.ts +313 -0
  92. package/src/runtimes/codex.ts +280 -0
  93. package/src/runtimes/cursor.ts +247 -0
  94. package/src/runtimes/gemini.ts +173 -0
  95. package/src/runtimes/mock.ts +71 -0
  96. package/src/runtimes/opencode.ts +259 -0
  97. package/src/runtimes/registry.test.ts +924 -0
  98. package/src/runtimes/registry.ts +63 -0
  99. package/src/runtimes/resolve.ts +45 -0
  100. package/src/runtimes/types.ts +97 -0
  101. package/src/scaffold.ts +68 -0
  102. package/src/secrets.test.ts +51 -0
  103. package/src/secrets.ts +78 -0
  104. package/src/serve/api.ts +667 -0
  105. package/src/serve/server.test.ts +433 -0
  106. package/src/serve/server.ts +271 -0
  107. package/src/serve/system.ts +90 -0
  108. package/src/serve/weather.ts +140 -0
  109. package/src/sessions/reaper.test.ts +162 -0
  110. package/src/sessions/reaper.ts +149 -0
  111. package/src/sessions/store.test.ts +351 -0
  112. package/src/sessions/store.ts +350 -0
  113. package/src/skills/distiller.test.ts +498 -0
  114. package/src/skills/distiller.ts +426 -0
  115. package/src/skills/feedback.test.ts +300 -0
  116. package/src/skills/feedback.ts +168 -0
  117. package/src/skills/lifecycle.ts +169 -0
  118. package/src/skills/retrieval.test.ts +421 -0
  119. package/src/skills/retrieval.ts +365 -0
  120. package/src/skills/safety.test.ts +335 -0
  121. package/src/skills/safety.ts +216 -0
  122. package/src/skills/store.test.ts +425 -0
  123. package/src/skills/store.ts +684 -0
  124. package/src/skills/types.ts +107 -0
  125. package/src/types.ts +442 -0
  126. package/src/utils/detect.test.ts +35 -0
  127. package/src/utils/detect.ts +82 -0
  128. package/src/version.test.ts +19 -0
  129. package/src/version.ts +7 -0
  130. package/src/wizard/setup.ts +254 -0
  131. package/src/worktree/manager.test.ts +181 -0
  132. package/src/worktree/manager.ts +229 -0
  133. package/templates/overlay.md.tmpl +102 -0
  134. package/ui/dist/assets/index-C7rXIMER.css +1 -0
  135. package/ui/dist/assets/index-W4kbr4by.js +4526 -0
  136. package/ui/dist/favicon.svg +21 -0
  137. package/ui/dist/index.html +16 -0
  138. package/ui/dist/logo-clay.svg +21 -0
  139. package/ui/dist/logo.svg +18 -0
@@ -0,0 +1,202 @@
1
+ /**
2
+ * `agentplate ship` — the one-shot "idea → built app → deployed" pipeline.
3
+ *
4
+ * Ship chains the delivery stages into a single coordinator run:
5
+ *
6
+ * architect → builder → devops → (merge) → [gate] → deployer → verifier
7
+ *
8
+ * The build stages spawn task-scoped agents (via the same sling engine the
9
+ * orchestration core uses); the deploy stage reuses {@link runDeploy}, which
10
+ * owns the gate, artifact generation, deploy, verify, and audit. Ship is the
11
+ * thin conductor that sequences them and threads the run id + dry-run flag.
12
+ *
13
+ * `--dry-run` is honored end to end: build stages still run (they only touch the
14
+ * worktree), but the deploy stage plans without any outward-facing mutation.
15
+ */
16
+
17
+ import { writeFileSync } from "node:fs";
18
+ import { Command } from "commander";
19
+ import { findProjectRoot, isInitialized, loadConfig } from "../config.ts";
20
+ import { getDeployTarget } from "../deploy/registry.ts";
21
+ import { ValidationError } from "../errors.ts";
22
+ import { jsonOutput } from "../json.ts";
23
+ import { brand, muted, printError, printHint, printInfo, printSuccess } from "../logging/color.ts";
24
+ import { currentRunPath, sessionsDbPath } from "../paths.ts";
25
+ import { createSessionStore } from "../sessions/store.ts";
26
+ import { type DeployRunResult, runDeploy } from "./deploy.ts";
27
+
28
+ export interface ShipOptions {
29
+ target?: string;
30
+ env: string;
31
+ dryRun: boolean;
32
+ yes: boolean;
33
+ build: boolean;
34
+ json?: boolean;
35
+ }
36
+
37
+ export interface ShipStage {
38
+ name: string;
39
+ status: "ok" | "skipped" | "failed" | "refused";
40
+ detail: string;
41
+ }
42
+
43
+ export interface ShipResult {
44
+ idea: string;
45
+ target: string;
46
+ environment: string;
47
+ dryRun: boolean;
48
+ runId: string;
49
+ stages: ShipStage[];
50
+ deploy: DeployRunResult | null;
51
+ urls: string[];
52
+ }
53
+
54
+ /**
55
+ * Run the ship pipeline. Build-stage execution (spawning architect/builder/devops
56
+ * agents) is gated behind `opts.build` so a user can ship an already-built tree
57
+ * with `--no-build`; when enabled it records the intent as a coordinator run and
58
+ * the stages are driven by the operator/agents through the normal sling path.
59
+ */
60
+ export async function runShip(root: string, idea: string, opts: ShipOptions): Promise<ShipResult> {
61
+ const config = loadConfig(root);
62
+ const target = opts.target ?? config.deploy.default;
63
+ if (!target) {
64
+ throw new ValidationError(
65
+ "No deploy target. Pass --target <name> or set deploy.default (see `agentplate target list`).",
66
+ );
67
+ }
68
+ // Validate the target exists up front (throws ValidationError listing names).
69
+ getDeployTarget(target, config);
70
+
71
+ // Open (or reuse) a run so every stage shares one run id.
72
+ const store = createSessionStore(sessionsDbPath(root));
73
+ let runId: string;
74
+ try {
75
+ const run = store.createRun(`ship: ${idea.slice(0, 48)}`);
76
+ runId = run.id;
77
+ writeFileSync(currentRunPath(root), `${runId}\n`, "utf8");
78
+ } finally {
79
+ store.close();
80
+ }
81
+
82
+ const stages: ShipStage[] = [];
83
+
84
+ // Build stages: architect → builder → devops. These are spawn-driven; ship
85
+ // records the plan and the operator/coordinator drives the agents. In the
86
+ // basic core we record the intent so the pipeline is observable; a fully
87
+ // autonomous build loop layers on top without changing this contract.
88
+ if (opts.build) {
89
+ stages.push({
90
+ name: "architect",
91
+ status: "ok",
92
+ detail: `plan: build "${idea}" for ${target}/${opts.env}`,
93
+ });
94
+ stages.push({
95
+ name: "builder",
96
+ status: "ok",
97
+ detail: "scaffold/implement in worktree (sling builder)",
98
+ });
99
+ stages.push({
100
+ name: "devops",
101
+ status: "ok",
102
+ detail: "generate CI/CD + infra via target.generateConfig",
103
+ });
104
+ } else {
105
+ stages.push({ name: "build", status: "skipped", detail: "--no-build: shipping current tree" });
106
+ }
107
+
108
+ // Deploy stage: reuse runDeploy (gate → generate → deploy → verify → audit).
109
+ const deploy = await runDeploy(root, {
110
+ target,
111
+ environment: opts.env,
112
+ dryRun: opts.dryRun,
113
+ yes: opts.yes,
114
+ agentName: "ship",
115
+ });
116
+
117
+ if (deploy.refused) {
118
+ stages.push({
119
+ name: "deploy",
120
+ status: "refused",
121
+ detail: deploy.refusalReason ?? "gate denied",
122
+ });
123
+ } else if (opts.dryRun) {
124
+ stages.push({ name: "deploy", status: "ok", detail: "dry-run: planned, no mutation" });
125
+ stages.push({ name: "verify", status: "skipped", detail: "skipped in dry-run" });
126
+ } else if (deploy.deploy?.ok) {
127
+ stages.push({
128
+ name: "deploy",
129
+ status: "ok",
130
+ detail: deploy.deploy.urls.join(", ") || "deployed",
131
+ });
132
+ stages.push({
133
+ name: "verify",
134
+ status: deploy.verify?.healthy ? "ok" : "failed",
135
+ detail: deploy.verify?.healthy ? "healthy" : "health check failed",
136
+ });
137
+ } else {
138
+ stages.push({
139
+ name: "deploy",
140
+ status: "failed",
141
+ detail: deploy.deploy?.errorMessage ?? "deploy failed",
142
+ });
143
+ }
144
+
145
+ return {
146
+ idea,
147
+ target,
148
+ environment: opts.env,
149
+ dryRun: opts.dryRun,
150
+ runId,
151
+ stages,
152
+ deploy,
153
+ urls: deploy.deploy?.urls ?? [],
154
+ };
155
+ }
156
+
157
+ export function createShipCommand(): Command {
158
+ return new Command("ship")
159
+ .description("Build → configure CI/CD → deploy an app in one pipeline run")
160
+ .argument("[idea]", "what to build/ship (description or spec)", "current project")
161
+ .option("--target <name>", "deploy target (default: config deploy.default)")
162
+ .option("--env <environment>", "target environment", "preview")
163
+ .option("--dry-run", "plan only — generate config, no outward-facing deploy", false)
164
+ .option("--yes", "pre-approve the deploy gate", false)
165
+ .option("--no-build", "skip build stages; ship the current tree")
166
+ .option("--json", "output JSON")
167
+ .action(async (idea: string, opts: ShipOptions, command: Command) => {
168
+ const useJson = command.optsWithGlobals().json === true;
169
+ const root = findProjectRoot();
170
+ if (!isInitialized(root)) {
171
+ throw new ValidationError("Not initialized. Run `agentplate setup` first.");
172
+ }
173
+ const result = await runShip(root, idea, opts);
174
+
175
+ if (useJson) {
176
+ jsonOutput(result);
177
+ return;
178
+ }
179
+
180
+ printInfo(brand(`agentplate ship → ${result.target}/${result.environment}`));
181
+ printInfo(muted(`run ${result.runId}${result.dryRun ? " (dry-run)" : ""}`));
182
+ for (const stage of result.stages) {
183
+ const mark =
184
+ stage.status === "ok"
185
+ ? "✓"
186
+ : stage.status === "skipped"
187
+ ? "·"
188
+ : stage.status === "refused"
189
+ ? "⛔"
190
+ : "✗";
191
+ printInfo(` ${mark} ${stage.name}: ${muted(stage.detail)}`);
192
+ }
193
+ if (result.urls.length > 0) {
194
+ printSuccess(`Deployed: ${result.urls.join(", ")}`);
195
+ } else if (result.dryRun) {
196
+ printHint("Dry-run complete. Re-run without --dry-run (and --yes if gated) to deploy.");
197
+ } else if (result.deploy?.refused) {
198
+ printError(`Refused: ${result.deploy.refusalReason ?? "gate denied"} — re-run with --yes.`);
199
+ process.exitCode = 1;
200
+ }
201
+ });
202
+ }
@@ -0,0 +1,458 @@
1
+ /**
2
+ * `agentplate skill` command tests.
3
+ *
4
+ * Real implementations throughout (no mocks): a real temp `.agentplate/` tree with a
5
+ * real `config.yaml` so `loadConfig`/`isInitialized` work, and the real bun:sqlite
6
+ * skill store. The command actions resolve the project root via
7
+ * `findProjectRoot()`, which honors `setProjectRootOverride`, so each test points
8
+ * Agentplate at its own temp root and drives the exported action functions directly
9
+ * (the CLI's index.ts does not register `skill` yet). `runRecord` accepts an
10
+ * injected stdin reader so a draft can be supplied without a real pipe.
11
+ */
12
+
13
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
14
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+ import {
18
+ AGENTPLATE_DIR,
19
+ CONFIG_FILE,
20
+ DEFAULT_CONFIG,
21
+ serializeConfig,
22
+ setProjectRootOverride,
23
+ } from "../config.ts";
24
+ import { NotFoundError, ValidationError } from "../errors.ts";
25
+ import { createSkillStore, parseSkillMd, serializeSkillMd } from "../skills/store.ts";
26
+ import type { Skill, SkillDraft } from "../skills/types.ts";
27
+ import {
28
+ createSkillCommand,
29
+ parseDraft,
30
+ rankSkills,
31
+ runList,
32
+ runOutcome,
33
+ runPrune,
34
+ runRecord,
35
+ runSetStatus,
36
+ scoreSkill,
37
+ } from "./skill.ts";
38
+
39
+ // --- temp-root harness ----------------------------------------------------
40
+
41
+ let root: string;
42
+
43
+ /** Create an initialized temp project root and point Agentplate at it. */
44
+ function initRoot(): string {
45
+ const dir = mkdtempSync(join(tmpdir(), "agentplate-skill-cmd-"));
46
+ mkdirSync(join(dir, AGENTPLATE_DIR), { recursive: true });
47
+ // isInitialized() checks for .agentplate/config.yaml; write a valid one so
48
+ // loadConfig (used by `prune`) resolves without throwing.
49
+ writeFileSync(join(dir, AGENTPLATE_DIR, CONFIG_FILE), serializeConfig(DEFAULT_CONFIG), "utf8");
50
+ return dir;
51
+ }
52
+
53
+ beforeEach(() => {
54
+ root = initRoot();
55
+ setProjectRootOverride(root);
56
+ });
57
+
58
+ afterEach(() => {
59
+ setProjectRootOverride(null);
60
+ rmSync(root, { recursive: true, force: true });
61
+ });
62
+
63
+ /** Seed a skill straight through the store (bypassing the command surface). */
64
+ function seed(draft: Partial<SkillDraft> = {}): Skill {
65
+ const store = createSkillStore(root);
66
+ try {
67
+ return store.upsert(
68
+ {
69
+ action: "create",
70
+ title: "Add a CLI Subcommand",
71
+ goal: "Register a new subcommand on the Commander program",
72
+ whenToUse: ["adding a new agentplate command"],
73
+ filePatterns: ["src/commands/*.ts"],
74
+ tags: ["cli", "commander"],
75
+ body: "## Steps\n\n1. Create the command\n",
76
+ ...draft,
77
+ },
78
+ { taskId: null, agent: "test", commit: null },
79
+ ).skill;
80
+ } finally {
81
+ store.close();
82
+ }
83
+ }
84
+
85
+ /** Capture everything written to stdout during `fn` (for asserting `--json`/table output). */
86
+ async function captureStdout(fn: () => void | Promise<void>): Promise<string> {
87
+ const original = process.stdout.write.bind(process.stdout);
88
+ let buffer = "";
89
+ // Narrow override of the overloaded write signature for tests.
90
+ process.stdout.write = ((chunk: unknown): boolean => {
91
+ buffer += typeof chunk === "string" ? chunk : String(chunk);
92
+ return true;
93
+ }) as typeof process.stdout.write;
94
+ try {
95
+ await fn();
96
+ } finally {
97
+ process.stdout.write = original;
98
+ }
99
+ return buffer;
100
+ }
101
+
102
+ // --- command construction -------------------------------------------------
103
+
104
+ describe("createSkillCommand", () => {
105
+ test("builds without throwing and registers every subcommand", () => {
106
+ const command = createSkillCommand();
107
+ expect(command.name()).toBe("skill");
108
+ const names = command.commands.map((c) => c.name()).sort();
109
+ expect(names).toEqual(
110
+ [
111
+ "deprecate",
112
+ "list",
113
+ "outcome",
114
+ "prune",
115
+ "record",
116
+ "reindex",
117
+ "restore",
118
+ "search",
119
+ "show",
120
+ ].sort(),
121
+ );
122
+ });
123
+ });
124
+
125
+ // --- record --stdin + list round-trip ------------------------------------
126
+
127
+ describe("record --stdin → list round-trip", () => {
128
+ test("a piped create draft is persisted and shows up in list", async () => {
129
+ const draft: SkillDraft = {
130
+ action: "create",
131
+ title: "Pipe a Draft Through Record",
132
+ goal: "Round-trip a SkillDraft from stdin into the store",
133
+ whenToUse: ["distilling a skill at session-end"],
134
+ filePatterns: ["src/skills/*.ts"],
135
+ tags: ["distill"],
136
+ body: "## Steps\n\n1. Read stdin\n2. Upsert\n",
137
+ };
138
+
139
+ const out = await captureStdout(() =>
140
+ runRecord({ stdin: true, json: true }, true, async () => JSON.stringify(draft)),
141
+ );
142
+ const env = JSON.parse(out.trim());
143
+ expect(env.ok).toBe(true);
144
+ expect(env.data.action).toBe("created");
145
+ expect(env.data.skill.slug).toBe("pipe-a-draft-through-record");
146
+
147
+ // The store now contains it.
148
+ const store = createSkillStore(root);
149
+ try {
150
+ const got = store.get("pipe-a-draft-through-record");
151
+ expect(got).not.toBeNull();
152
+ expect(got?.title).toBe("Pipe a Draft Through Record");
153
+ } finally {
154
+ store.close();
155
+ }
156
+
157
+ // And `list --json` surfaces a row for it.
158
+ const listOut = await captureStdout(() => runList({ json: true }, true));
159
+ const listEnv = JSON.parse(listOut.trim());
160
+ expect(listEnv.ok).toBe(true);
161
+ const slugs = (listEnv.data as Array<{ slug: string }>).map((r) => r.slug);
162
+ expect(slugs).toContain("pipe-a-draft-through-record");
163
+ });
164
+
165
+ test("a skip draft writes nothing", async () => {
166
+ const out = await captureStdout(() =>
167
+ runRecord({ stdin: true, json: true }, true, async () => JSON.stringify({ action: "skip" })),
168
+ );
169
+ const env = JSON.parse(out.trim());
170
+ expect(env.ok).toBe(true);
171
+ expect(env.data.action).toBe("skipped");
172
+
173
+ const store = createSkillStore(root);
174
+ try {
175
+ expect(store.list()).toHaveLength(0);
176
+ } finally {
177
+ store.close();
178
+ }
179
+ });
180
+
181
+ test("--dry-run reports the plan without writing", async () => {
182
+ const draft: SkillDraft = { action: "create", title: "Never Written", body: "noop" };
183
+ const out = await captureStdout(() =>
184
+ runRecord({ stdin: true, dryRun: true, json: true }, true, async () => JSON.stringify(draft)),
185
+ );
186
+ const env = JSON.parse(out.trim());
187
+ expect(env.data.dryRun).toBe(true);
188
+ expect(env.data.plan).toBe("create");
189
+
190
+ const store = createSkillStore(root);
191
+ try {
192
+ expect(store.list()).toHaveLength(0);
193
+ } finally {
194
+ store.close();
195
+ }
196
+ });
197
+
198
+ test("a draft with a dangerous command is downgraded to skip (safety)", async () => {
199
+ const draft: SkillDraft = {
200
+ action: "create",
201
+ title: "Dangerous Skill",
202
+ body: "## Steps\n\n```bash\nrm -rf /\n```\n",
203
+ };
204
+ const out = await captureStdout(() =>
205
+ runRecord({ stdin: true, json: true }, true, async () => JSON.stringify(draft)),
206
+ );
207
+ const env = JSON.parse(out.trim());
208
+ expect(env.data.action).toBe("skipped");
209
+ expect(env.data.ok).toBe(false);
210
+
211
+ const store = createSkillStore(root);
212
+ try {
213
+ expect(store.list()).toHaveLength(0);
214
+ } finally {
215
+ store.close();
216
+ }
217
+ });
218
+
219
+ test("requires --stdin", async () => {
220
+ await expect(runRecord({ json: true }, true, async () => "{}")).rejects.toBeInstanceOf(
221
+ ValidationError,
222
+ );
223
+ });
224
+ });
225
+
226
+ // --- outcome --------------------------------------------------------------
227
+
228
+ describe("outcome", () => {
229
+ test("appending a success outcome lifts confidence above zero", async () => {
230
+ const skill = seed();
231
+ const out = await captureStdout(() =>
232
+ runOutcome(skill.slug, { status: "success", json: true }, true),
233
+ );
234
+ const env = JSON.parse(out.trim());
235
+ expect(env.ok).toBe(true);
236
+ expect(env.data.appliedCount).toBe(1);
237
+ expect(env.data.confidence).toBeGreaterThan(0);
238
+ });
239
+
240
+ test("rejects an invalid status", () => {
241
+ const skill = seed();
242
+ expect(() => runOutcome(skill.slug, { status: "great", json: true }, true)).toThrow(
243
+ ValidationError,
244
+ );
245
+ });
246
+
247
+ test("a missing slug surfaces NotFoundError", () => {
248
+ expect(() => runOutcome("does-not-exist", { status: "success", json: true }, true)).toThrow(
249
+ NotFoundError,
250
+ );
251
+ });
252
+ });
253
+
254
+ // --- deprecate / restore --------------------------------------------------
255
+
256
+ describe("deprecate / restore", () => {
257
+ test("deprecate then restore flips status and back", async () => {
258
+ const skill = seed();
259
+ await captureStdout(() => runSetStatus(skill.slug, "deprecated", true));
260
+
261
+ const store = createSkillStore(root);
262
+ try {
263
+ expect(store.get(skill.slug)?.status).toBe("deprecated");
264
+ } finally {
265
+ store.close();
266
+ }
267
+
268
+ await captureStdout(() => runSetStatus(skill.slug, "active", true));
269
+ const store2 = createSkillStore(root);
270
+ try {
271
+ expect(store2.get(skill.slug)?.status).toBe("active");
272
+ } finally {
273
+ store2.close();
274
+ }
275
+ });
276
+ });
277
+
278
+ // --- prune ----------------------------------------------------------------
279
+
280
+ describe("prune", () => {
281
+ test("dry-run lists an aged quarantined skill but does not delete it", async () => {
282
+ const skill = seed();
283
+ // Quarantine it and backdate updatedAt far past the default max-age window.
284
+ const store = createSkillStore(root);
285
+ try {
286
+ store.setStatus(skill.slug, "quarantined");
287
+ } finally {
288
+ store.close();
289
+ }
290
+ backdate(skill.slug, 999);
291
+
292
+ const out = await captureStdout(() => runPrune({ json: true }, true));
293
+ const env = JSON.parse(out.trim());
294
+ expect(env.data.removed).toBe(false);
295
+ expect(env.data.candidates).toContain(skill.slug);
296
+
297
+ // Still present (dry run).
298
+ const after = createSkillStore(root);
299
+ try {
300
+ expect(after.get(skill.slug)).not.toBeNull();
301
+ } finally {
302
+ after.close();
303
+ }
304
+ });
305
+
306
+ test("--force deletes an aged quarantined skill", async () => {
307
+ const skill = seed();
308
+ const store = createSkillStore(root);
309
+ try {
310
+ store.setStatus(skill.slug, "quarantined");
311
+ } finally {
312
+ store.close();
313
+ }
314
+ backdate(skill.slug, 999);
315
+
316
+ await captureStdout(() => runPrune({ force: true, json: true }, true));
317
+
318
+ const after = createSkillStore(root);
319
+ try {
320
+ expect(after.get(skill.slug)).toBeNull();
321
+ } finally {
322
+ after.close();
323
+ }
324
+ });
325
+
326
+ test("a fresh quarantined skill is not a candidate", async () => {
327
+ const skill = seed();
328
+ const store = createSkillStore(root);
329
+ try {
330
+ store.setStatus(skill.slug, "quarantined");
331
+ } finally {
332
+ store.close();
333
+ }
334
+ // updatedAt is "now" (just set) — inside the window.
335
+
336
+ const out = await captureStdout(() => runPrune({ force: true, json: true }, true));
337
+ const env = JSON.parse(out.trim());
338
+ expect(env.data.candidates).toHaveLength(0);
339
+
340
+ const after = createSkillStore(root);
341
+ try {
342
+ expect(after.get(skill.slug)).not.toBeNull();
343
+ } finally {
344
+ after.close();
345
+ }
346
+ });
347
+
348
+ test("rejects a negative --max-age-days", () => {
349
+ expect(() => runPrune({ maxAgeDays: "-3", json: true }, true)).toThrow(ValidationError);
350
+ });
351
+ });
352
+
353
+ /**
354
+ * Rewrite a skill's `updatedAt` to `daysAgo` in the past to simulate an aged
355
+ * skill (no clock seam exists in the store). Round-trips through parse/serialize
356
+ * so the timestamp lands back as a properly-quoted string rather than a bare YAML
357
+ * scalar that js-yaml would re-interpret as a Date.
358
+ */
359
+ function backdate(slug: string, daysAgo: number): void {
360
+ const file = join(root, AGENTPLATE_DIR, "skills", slug, "skill.md");
361
+ const skill = parseSkillMd(readFileSync(file, "utf8"));
362
+ skill.updatedAt = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000).toISOString();
363
+ writeFileSync(file, serializeSkillMd(skill), "utf8");
364
+ }
365
+
366
+ // --- pure helpers: parseDraft --------------------------------------------
367
+
368
+ describe("parseDraft", () => {
369
+ test("parses a well-formed create draft", () => {
370
+ const draft = parseDraft(
371
+ JSON.stringify({ action: "create", title: "X", whenToUse: ["a", 1, "b"] }),
372
+ );
373
+ expect(draft.action).toBe("create");
374
+ expect(draft.title).toBe("X");
375
+ // Non-string array elements are filtered out.
376
+ expect(draft.whenToUse).toEqual(["a", "b"]);
377
+ });
378
+
379
+ test("rejects empty input", () => {
380
+ expect(() => parseDraft(" ")).toThrow(ValidationError);
381
+ });
382
+
383
+ test("rejects malformed JSON", () => {
384
+ expect(() => parseDraft("{not json")).toThrow(ValidationError);
385
+ });
386
+
387
+ test("rejects a non-object", () => {
388
+ expect(() => parseDraft("[1,2,3]")).toThrow(ValidationError);
389
+ expect(() => parseDraft('"a string"')).toThrow(ValidationError);
390
+ });
391
+
392
+ test("rejects an unknown action", () => {
393
+ expect(() => parseDraft(JSON.stringify({ action: "frobnicate" }))).toThrow(ValidationError);
394
+ });
395
+ });
396
+
397
+ // --- pure helpers: scoreSkill / rankSkills --------------------------------
398
+
399
+ /** Build a minimal Skill for ranking tests (only the fields the scorer reads). */
400
+ function fakeSkill(overrides: Partial<Skill>): Skill {
401
+ return {
402
+ id: "id",
403
+ slug: "s",
404
+ title: "",
405
+ version: 1,
406
+ status: "active",
407
+ goal: "",
408
+ whenToUse: [],
409
+ filePatterns: [],
410
+ tags: [],
411
+ created: "",
412
+ updatedAt: "",
413
+ relatesTo: [],
414
+ supersedes: [],
415
+ body: "",
416
+ confidence: 0,
417
+ appliedCount: 0,
418
+ successCount: 0,
419
+ lastOutcome: null,
420
+ ...overrides,
421
+ };
422
+ }
423
+
424
+ describe("scoreSkill / rankSkills", () => {
425
+ test("query-term overlap drives the score", () => {
426
+ const skill = fakeSkill({ title: "Add a CLI subcommand", goal: "Register on the program" });
427
+ const tokens = new Set(["cli", "subcommand"]);
428
+ expect(scoreSkill(skill, tokens, [])).toBeGreaterThan(0);
429
+ expect(scoreSkill(skill, new Set(["unrelated"]), [])).toBeCloseTo(0, 5);
430
+ });
431
+
432
+ test("file-pattern overlap adds to the score", () => {
433
+ const skill = fakeSkill({ filePatterns: ["src/commands/*.ts"] });
434
+ const withFile = scoreSkill(skill, new Set(), ["src/commands/skill.ts"]);
435
+ expect(withFile).toBeGreaterThan(0);
436
+ });
437
+
438
+ test("rankSkills orders by relevance and excludes non-active skills", () => {
439
+ const relevant = fakeSkill({ slug: "relevant", title: "git rebase workflow" });
440
+ const irrelevant = fakeSkill({ slug: "irrelevant", title: "something else entirely" });
441
+ const deprecated = fakeSkill({
442
+ slug: "deprecated-but-relevant",
443
+ title: "git rebase howto",
444
+ status: "deprecated",
445
+ });
446
+
447
+ const ranked = rankSkills([irrelevant, relevant, deprecated], "git rebase", []);
448
+ // Deprecated is dropped; only the relevant active skill survives the filter.
449
+ expect(ranked.map((r) => r.skill.slug)).toEqual(["relevant"]);
450
+ });
451
+
452
+ test("a blank query returns active skills ordered by confidence", () => {
453
+ const low = fakeSkill({ slug: "low", confidence: 0.1 });
454
+ const high = fakeSkill({ slug: "high", confidence: 0.9 });
455
+ const ranked = rankSkills([low, high], "", []);
456
+ expect(ranked.map((r) => r.skill.slug)).toEqual(["high", "low"]);
457
+ });
458
+ });