@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,730 @@
1
+ /**
2
+ * `agentplate skill` — operate on the self-improving skill library.
3
+ *
4
+ * Skills live on disk as `.agentplate/skills/<slug>/` directories (skill.md +
5
+ * outcomes.jsonl) with a derived FTS index; the {@link createSkillStore} in
6
+ * `../skills/store.ts` is the single read/write path. This command is the
7
+ * operator (and Stop-hook) surface over that store:
8
+ *
9
+ * list — tabular roster (slug, title, confidence, applied/success, status)
10
+ * show — print a skill's full skill.md
11
+ * search — rank skills by a query (+ optional --files globs) and print the top
12
+ * record — read a JSON SkillDraft from stdin, scrub it, and upsert (manual
13
+ * distillation path; the Stop hook pipes a draft here)
14
+ * outcome — append a success/partial/failure outcome to a skill
15
+ * prune — remove quarantined skills past the max-age window (dry-run default)
16
+ * reindex — rebuild the FTS index from the skill.md source of truth
17
+ * deprecate — mark a skill deprecated (excluded from retrieval)
18
+ * restore — return a deprecated/quarantined skill to active
19
+ *
20
+ * `--json` is read via `command.optsWithGlobals().json === true` (each subcommand
21
+ * still declares `--json`), matching the house pattern in `mail.ts`.
22
+ *
23
+ * Ranking note: the planned `retrieval.scoreSkill` lives in a sibling module
24
+ * built in parallel and is not importable here yet, so `search` ranks via a
25
+ * small, self-contained {@link scoreSkill} (token overlap on title/goal/
26
+ * whenToUse/tags + file-pattern overlap, confidence as the tie-break) operating
27
+ * over `store.list()`. It produces {@link RankedSkill} values from the
28
+ * authoritative skill types and can be swapped for the shared retrieval scorer
29
+ * without changing this command's surface.
30
+ */
31
+
32
+ import { Command } from "commander";
33
+ import { findProjectRoot, isInitialized, loadConfig } from "../config.ts";
34
+ import { NotFoundError, ValidationError } from "../errors.ts";
35
+ import { jsonOutput } from "../json.ts";
36
+ import { accent, brand, muted, printInfo, printSuccess, printWarning } from "../logging/color.ts";
37
+ import { sanitizeSkillDraft } from "../skills/safety.ts";
38
+ import { createSkillStore, type SkillStore, serializeSkillMd } from "../skills/store.ts";
39
+ import type { RankedSkill, Skill, SkillDraft, SkillStatus } from "../skills/types.ts";
40
+ import type { OutcomeStatus } from "../types.ts";
41
+
42
+ /** Provenance stamped onto manually-recorded skills (the operator path). */
43
+ const OPERATOR_PROVENANCE = { taskId: null, agent: "operator", commit: null } as const;
44
+
45
+ /**
46
+ * Resolve the project root, throwing if Agentplate is not initialized there.
47
+ * Every subcommand calls this first so an uninitialized project fails fast and
48
+ * uniformly (matching the rest of the CLI).
49
+ */
50
+ function requireInit(): string {
51
+ const root = findProjectRoot();
52
+ if (!isInitialized(root)) {
53
+ throw new ValidationError("Not initialized. Run `agentplate setup` first.");
54
+ }
55
+ return root;
56
+ }
57
+
58
+ /** Read the `--json` global flag off the action's trailing Command instance. */
59
+ function wantsJson(command: Command): boolean {
60
+ return command.optsWithGlobals().json === true;
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Pure helpers (search ranking + formatting) — unit-tested directly
65
+ // ---------------------------------------------------------------------------
66
+
67
+ /** Statuses excluded from retrieval / search results. */
68
+ const RETRIEVABLE_STATUS: SkillStatus = "active";
69
+
70
+ /**
71
+ * Split free text into lowercase alphanumeric tokens (length >= 2), de-duplicated.
72
+ * Used to compare a query against a skill's searchable text.
73
+ */
74
+ function tokenize(text: string): Set<string> {
75
+ const tokens = new Set<string>();
76
+ for (const raw of text.toLowerCase().split(/[^a-z0-9]+/)) {
77
+ if (raw.length >= 2) tokens.add(raw);
78
+ }
79
+ return tokens;
80
+ }
81
+
82
+ /** Concatenate the searchable text of a skill (title, goal, when-to-use, tags). */
83
+ function searchableText(skill: Skill): string {
84
+ return [skill.title, skill.goal, skill.whenToUse.join(" "), skill.tags.join(" ")].join(" ");
85
+ }
86
+
87
+ /**
88
+ * Translate a glob (supporting `*`, `**`, and `?`) into a `RegExp` anchored to
89
+ * the whole string. `**` matches across path separators; `*` matches within a
90
+ * segment; `?` matches a single non-separator character. Everything else is
91
+ * escaped literally. Used for `--files` ↔ `filePatterns` overlap.
92
+ */
93
+ function globToRegExp(glob: string): RegExp {
94
+ let out = "";
95
+ for (let i = 0; i < glob.length; i++) {
96
+ const ch = glob[i];
97
+ if (ch === "*") {
98
+ if (glob[i + 1] === "*") {
99
+ out += ".*";
100
+ i++;
101
+ } else {
102
+ out += "[^/]*";
103
+ }
104
+ } else if (ch === "?") {
105
+ out += "[^/]";
106
+ } else if (ch !== undefined && /[.+^${}()|[\]\\]/.test(ch)) {
107
+ out += `\\${ch}`;
108
+ } else if (ch !== undefined) {
109
+ out += ch;
110
+ }
111
+ }
112
+ return new RegExp(`^${out}$`);
113
+ }
114
+
115
+ /**
116
+ * Does a concrete file path match a skill's glob pattern, OR vice-versa? We test
117
+ * both directions so a query path (`src/commands/skill.ts`) matches a skill's
118
+ * pattern (`src/commands/*.ts`), and a query glob (`src/**`) matches a skill's
119
+ * literal pattern (`src/index.ts`).
120
+ */
121
+ function fileMatchesPattern(file: string, pattern: string): boolean {
122
+ if (file === pattern) return true;
123
+ try {
124
+ if (globToRegExp(pattern).test(file)) return true;
125
+ if (globToRegExp(file).test(pattern)) return true;
126
+ } catch {
127
+ // A malformed glob never matches rather than throwing into the ranking loop.
128
+ return false;
129
+ }
130
+ return false;
131
+ }
132
+
133
+ /**
134
+ * Relevance score of a single skill against a query and optional file globs.
135
+ *
136
+ * Components:
137
+ * - **text overlap**: fraction of the (tokenized) query terms that appear in the
138
+ * skill's searchable text, weighted heavily (the primary signal),
139
+ * - **file overlap**: fraction of the query's file globs that hit at least one of
140
+ * the skill's `filePatterns` (0 when no files were supplied),
141
+ * - **confidence**: the earned Wilson confidence, as a small additive tie-break so
142
+ * two equally-relevant skills order by track record.
143
+ *
144
+ * Returns a score in roughly `[0, 1+]`; absolute magnitude is unimportant, only
145
+ * the ordering. A skill with no query-term overlap and no file overlap scores
146
+ * `confidence * tieBreak` only, so a blank query degrades to "best skills first".
147
+ */
148
+ export function scoreSkill(skill: Skill, queryTokens: Set<string>, files: string[]): number {
149
+ // Text overlap: how many query terms the skill's text contains.
150
+ let textOverlap = 0;
151
+ if (queryTokens.size > 0) {
152
+ const skillTokens = tokenize(searchableText(skill));
153
+ let hits = 0;
154
+ for (const term of queryTokens) {
155
+ if (skillTokens.has(term)) hits++;
156
+ }
157
+ textOverlap = hits / queryTokens.size;
158
+ }
159
+
160
+ // File overlap: how many supplied globs match at least one of the skill's patterns.
161
+ let fileOverlap = 0;
162
+ if (files.length > 0 && skill.filePatterns.length > 0) {
163
+ let hits = 0;
164
+ for (const file of files) {
165
+ if (skill.filePatterns.some((pattern) => fileMatchesPattern(file, pattern))) hits++;
166
+ }
167
+ fileOverlap = hits / files.length;
168
+ }
169
+
170
+ // Weighted sum; confidence is a sub-unit tie-break so it never outranks real
171
+ // query relevance.
172
+ return textOverlap * 1.0 + fileOverlap * 0.75 + skill.confidence * 0.1;
173
+ }
174
+
175
+ /**
176
+ * Rank `skills` against a query + file globs, dropping non-active skills and any
177
+ * with a zero score when a query/files were actually supplied (so an empty result
178
+ * is honest rather than padded). With neither a query nor files, every active
179
+ * skill is returned ordered by confidence (the scorer's tie-break term).
180
+ */
181
+ export function rankSkills(skills: Skill[], query: string, files: string[]): RankedSkill[] {
182
+ const queryTokens = tokenize(query);
183
+ const hasFilter = queryTokens.size > 0 || files.length > 0;
184
+
185
+ const ranked: RankedSkill[] = [];
186
+ for (const skill of skills) {
187
+ if (skill.status !== RETRIEVABLE_STATUS) continue;
188
+ const score = scoreSkill(skill, queryTokens, files);
189
+ if (hasFilter && score <= 0) continue;
190
+ ranked.push({ skill, score });
191
+ }
192
+
193
+ // Highest score first; slug as a stable tiebreak so output is deterministic.
194
+ ranked.sort((a, b) => {
195
+ if (a.score !== b.score) return b.score - a.score;
196
+ return a.skill.slug < b.skill.slug ? -1 : a.skill.slug > b.skill.slug ? 1 : 0;
197
+ });
198
+ return ranked;
199
+ }
200
+
201
+ /** Format a 0..1 confidence as a fixed-width two-decimal string (e.g. `0.42`). */
202
+ function formatConfidence(value: number): string {
203
+ return value.toFixed(2);
204
+ }
205
+
206
+ /** A compact `applied/success` cell; success may be fractional (weighted partials). */
207
+ function formatTrack(skill: Skill): string {
208
+ const success = Number.isInteger(skill.successCount)
209
+ ? String(skill.successCount)
210
+ : skill.successCount.toFixed(1);
211
+ return `${skill.appliedCount}/${success}`;
212
+ }
213
+
214
+ /** One JSON-safe row describing a skill (the `--json` shape for list/search). */
215
+ interface SkillRow {
216
+ slug: string;
217
+ title: string;
218
+ status: SkillStatus;
219
+ confidence: number;
220
+ appliedCount: number;
221
+ successCount: number;
222
+ }
223
+
224
+ /** Project a {@link Skill} to its compact row shape. */
225
+ function toRow(skill: Skill): SkillRow {
226
+ return {
227
+ slug: skill.slug,
228
+ title: skill.title,
229
+ status: skill.status,
230
+ confidence: skill.confidence,
231
+ appliedCount: skill.appliedCount,
232
+ successCount: skill.successCount,
233
+ };
234
+ }
235
+
236
+ /** Right-pad (or truncate with an ellipsis) a string to an exact display width. */
237
+ function pad(text: string, width: number): string {
238
+ if (text.length === width) return text;
239
+ if (text.length < width) return text + " ".repeat(width - text.length);
240
+ if (width <= 1) return text.slice(0, width);
241
+ return `${text.slice(0, width - 1)}…`;
242
+ }
243
+
244
+ /**
245
+ * Print a roster of skills as an aligned table. Columns: slug, title, conf,
246
+ * applied/success, status. A header is printed once; an empty list prints a muted
247
+ * placeholder instead of bare headers.
248
+ */
249
+ function printSkillTable(skills: Skill[]): void {
250
+ if (skills.length === 0) {
251
+ printInfo(muted("(no skills)"));
252
+ return;
253
+ }
254
+ const SLUG_W = 28;
255
+ const TITLE_W = 36;
256
+ printInfo(
257
+ muted(
258
+ `${pad("SLUG", SLUG_W)} ${pad("TITLE", TITLE_W)} ${pad("CONF", 4)} ${pad("A/S", 7)} STATUS`,
259
+ ),
260
+ );
261
+ for (const skill of skills) {
262
+ const slug = brand(pad(skill.slug, SLUG_W));
263
+ const title = pad(skill.title, TITLE_W);
264
+ const conf = pad(formatConfidence(skill.confidence), 4);
265
+ const track = pad(formatTrack(skill), 7);
266
+ const status = colorStatus(skill.status);
267
+ printInfo(`${slug} ${title} ${conf} ${track} ${status}`);
268
+ }
269
+ }
270
+
271
+ /** Color a lifecycle status for the table's STATUS column. */
272
+ function colorStatus(status: SkillStatus): string {
273
+ if (status === "active") return status;
274
+ if (status === "deprecated") return muted(status);
275
+ return accent(status);
276
+ }
277
+
278
+ /** Narrow an arbitrary string to a {@link SkillStatus}, or throw a ValidationError. */
279
+ function parseStatusFilter(value: string): SkillStatus {
280
+ if (value === "active" || value === "deprecated" || value === "quarantined") return value;
281
+ throw new ValidationError(
282
+ `Invalid --status "${value}" (expected active | deprecated | quarantined)`,
283
+ );
284
+ }
285
+
286
+ /** Narrow an arbitrary string to an {@link OutcomeStatus}, or throw a ValidationError. */
287
+ function parseOutcomeStatus(value: string): OutcomeStatus {
288
+ if (value === "success" || value === "partial" || value === "failure") return value;
289
+ throw new ValidationError(`Invalid --status "${value}" (expected success | partial | failure)`);
290
+ }
291
+
292
+ /**
293
+ * Parse a JSON {@link SkillDraft} from a string (stdin). Validates that it is an
294
+ * object with a known `action`; everything else is left to the store/safety layer
295
+ * which already coerce optional fields. Throws {@link ValidationError} on malformed
296
+ * JSON or a missing/invalid action so the caller never feeds garbage to the store.
297
+ */
298
+ export function parseDraft(input: string): SkillDraft {
299
+ const trimmed = input.trim();
300
+ if (trimmed === "") {
301
+ throw new ValidationError("No draft on stdin (expected a JSON SkillDraft)");
302
+ }
303
+ let parsed: unknown;
304
+ try {
305
+ parsed = JSON.parse(trimmed);
306
+ } catch (error) {
307
+ throw new ValidationError(`Invalid JSON on stdin: ${(error as Error).message}`);
308
+ }
309
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
310
+ throw new ValidationError("Draft must be a JSON object");
311
+ }
312
+ const obj = parsed as Record<string, unknown>;
313
+ const action = obj.action;
314
+ if (action !== "create" && action !== "update" && action !== "skip") {
315
+ throw new ValidationError('Draft "action" must be one of: create, update, skip');
316
+ }
317
+
318
+ // Build a clean SkillDraft, carrying only known fields with the right shapes.
319
+ const draft: SkillDraft = { action };
320
+ if (typeof obj.targetSlug === "string") draft.targetSlug = obj.targetSlug;
321
+ if (typeof obj.title === "string") draft.title = obj.title;
322
+ if (typeof obj.goal === "string") draft.goal = obj.goal;
323
+ if (Array.isArray(obj.whenToUse)) draft.whenToUse = obj.whenToUse.filter(isStr);
324
+ if (Array.isArray(obj.filePatterns)) draft.filePatterns = obj.filePatterns.filter(isStr);
325
+ if (Array.isArray(obj.tags)) draft.tags = obj.tags.filter(isStr);
326
+ if (typeof obj.body === "string") draft.body = obj.body;
327
+ return draft;
328
+ }
329
+
330
+ /** Type guard used to filter parsed arrays down to strings. */
331
+ function isStr(value: unknown): value is string {
332
+ return typeof value === "string";
333
+ }
334
+
335
+ // ---------------------------------------------------------------------------
336
+ // Subcommand actions (exported so tests can drive them without spawning the CLI)
337
+ // ---------------------------------------------------------------------------
338
+
339
+ /** Options accepted by `agentplate skill list`. */
340
+ export interface ListOptions {
341
+ status?: string;
342
+ json?: boolean;
343
+ }
344
+
345
+ /** `agentplate skill list` — print every skill (optionally status-filtered). */
346
+ export function runList(opts: ListOptions, useJson: boolean): void {
347
+ const root = requireInit();
348
+ const store = createSkillStore(root);
349
+ try {
350
+ const statusFilter = opts.status === undefined ? undefined : parseStatusFilter(opts.status);
351
+ const skills = store.list(statusFilter ? { status: statusFilter } : undefined);
352
+ if (useJson) jsonOutput(skills.map(toRow));
353
+ else printSkillTable(skills);
354
+ } finally {
355
+ store.close();
356
+ }
357
+ }
358
+
359
+ /** `agentplate skill show <slug>` — print a skill's full skill.md. */
360
+ export function runShow(slug: string, useJson: boolean): void {
361
+ const root = requireInit();
362
+ const store = createSkillStore(root);
363
+ try {
364
+ const skill = store.get(slug);
365
+ if (skill === null) throw new NotFoundError(`Skill "${slug}" not found`);
366
+ if (useJson) jsonOutput(skill);
367
+ else printInfo(serializeSkillMd(skill).trimEnd());
368
+ } finally {
369
+ store.close();
370
+ }
371
+ }
372
+
373
+ /** Options accepted by `agentplate skill search`. */
374
+ export interface SearchOptions {
375
+ files?: string[];
376
+ json?: boolean;
377
+ }
378
+
379
+ /** `agentplate skill search <query>` — rank active skills by relevance and print the top. */
380
+ export function runSearch(query: string, opts: SearchOptions, useJson: boolean): void {
381
+ const root = requireInit();
382
+ const store = createSkillStore(root);
383
+ try {
384
+ const files = opts.files ?? [];
385
+ const ranked = rankSkills(store.list(), query, files);
386
+ if (useJson) {
387
+ jsonOutput(ranked.map((r) => ({ ...toRow(r.skill), score: r.score })));
388
+ return;
389
+ }
390
+ if (ranked.length === 0) {
391
+ printInfo(muted("(no matching skills)"));
392
+ return;
393
+ }
394
+ for (const { skill, score } of ranked) {
395
+ printInfo(
396
+ `${brand(pad(skill.slug, 28))} ${muted(`score ${score.toFixed(3)}`)} ${skill.title}`,
397
+ );
398
+ if (skill.goal !== "") printInfo(muted(` ${skill.goal}`));
399
+ }
400
+ } finally {
401
+ store.close();
402
+ }
403
+ }
404
+
405
+ /** Options accepted by `agentplate skill record`. */
406
+ export interface RecordOptions {
407
+ stdin?: boolean;
408
+ dryRun?: boolean;
409
+ json?: boolean;
410
+ }
411
+
412
+ /**
413
+ * `agentplate skill record --stdin` — read a JSON {@link SkillDraft} from stdin, scrub
414
+ * it via {@link sanitizeSkillDraft}, and (unless `--dry-run`) upsert it with
415
+ * operator provenance. This is the manual distillation path and the Stop-hook
416
+ * target. A `skip` draft (or one downgraded to skip by a fatal safety violation)
417
+ * never writes.
418
+ *
419
+ * `readStdin` is injected so tests can supply a draft string without a real pipe.
420
+ */
421
+ export async function runRecord(
422
+ opts: RecordOptions,
423
+ useJson: boolean,
424
+ readStdin: () => Promise<string> = () => Bun.stdin.text(),
425
+ ): Promise<void> {
426
+ if (!opts.stdin) {
427
+ throw new ValidationError(
428
+ "`agentplate skill record` requires --stdin (pipe a JSON SkillDraft)",
429
+ );
430
+ }
431
+ const root = requireInit();
432
+ const store = createSkillStore(root);
433
+ try {
434
+ const draft = parseDraft(await readStdin());
435
+ const report = sanitizeSkillDraft(draft);
436
+
437
+ // A skip draft, or a fatal safety violation that forces a skip, writes nothing.
438
+ const effectiveAction = report.ok ? report.redactedDraft.action : "skip";
439
+
440
+ if (opts.dryRun) {
441
+ const plan =
442
+ effectiveAction === "skip"
443
+ ? "skip (no write)"
444
+ : effectiveAction === "update"
445
+ ? `update "${report.redactedDraft.targetSlug ?? "?"}"`
446
+ : `create "${report.redactedDraft.title ?? "?"}"`;
447
+ if (useJson) {
448
+ jsonOutput({
449
+ dryRun: true,
450
+ plan: effectiveAction,
451
+ ok: report.ok,
452
+ violations: report.violations,
453
+ });
454
+ } else {
455
+ printInfo(`${accent("dry-run")} would ${plan}`);
456
+ for (const v of report.violations) printWarning(v);
457
+ }
458
+ return;
459
+ }
460
+
461
+ if (effectiveAction === "skip") {
462
+ if (useJson) {
463
+ jsonOutput({ action: "skipped", ok: report.ok, violations: report.violations });
464
+ } else {
465
+ printWarning(report.ok ? "Draft action was 'skip' — nothing recorded." : "Draft skipped:");
466
+ for (const v of report.violations) printWarning(v);
467
+ }
468
+ return;
469
+ }
470
+
471
+ const result = store.upsert(report.redactedDraft, { ...OPERATOR_PROVENANCE });
472
+ if (useJson) {
473
+ jsonOutput({ action: result.action, skill: result.skill, violations: report.violations });
474
+ } else {
475
+ printSuccess(
476
+ `${result.action === "created" ? "Created" : "Updated"} skill ${result.skill.slug}`,
477
+ );
478
+ for (const v of report.violations) printWarning(v);
479
+ }
480
+ } finally {
481
+ store.close();
482
+ }
483
+ }
484
+
485
+ /** Options accepted by `agentplate skill outcome`. */
486
+ export interface OutcomeOptions {
487
+ status: string;
488
+ note?: string;
489
+ json?: boolean;
490
+ }
491
+
492
+ /** `agentplate skill outcome <slug> --status …` — append an outcome and recompute confidence. */
493
+ export function runOutcome(slug: string, opts: OutcomeOptions, useJson: boolean): void {
494
+ const root = requireInit();
495
+ const store = createSkillStore(root);
496
+ try {
497
+ const status = parseOutcomeStatus(opts.status);
498
+ const updated = store.appendOutcome(slug, {
499
+ status,
500
+ agent: "operator",
501
+ taskId: null,
502
+ gates: null,
503
+ ts: new Date().toISOString(),
504
+ ...(opts.note !== undefined ? { note: opts.note } : {}),
505
+ });
506
+ if (useJson) jsonOutput(toRow(updated));
507
+ else
508
+ printSuccess(
509
+ `Recorded ${status} for ${slug} (confidence ${formatConfidence(updated.confidence)}, ${formatTrack(updated)})`,
510
+ );
511
+ } finally {
512
+ store.close();
513
+ }
514
+ }
515
+
516
+ /** Options accepted by `agentplate skill prune`. */
517
+ export interface PruneOptions {
518
+ dryRun?: boolean;
519
+ force?: boolean;
520
+ maxAgeDays?: string;
521
+ json?: boolean;
522
+ }
523
+
524
+ /**
525
+ * `agentplate skill prune` — remove quarantined skills older than the max-age window.
526
+ *
527
+ * The age window defaults to `config.skills.prune.maxAgeDays` and may be
528
+ * overridden with `--max-age-days`. Only `quarantined` skills are eligible (active
529
+ * and deprecated skills are never auto-deleted). The default is a DRY RUN that
530
+ * merely lists the candidates; an actual delete requires `--force` (and is
531
+ * suppressed by `--dry-run` even if `--force` is also passed).
532
+ */
533
+ export function runPrune(opts: PruneOptions, useJson: boolean): void {
534
+ const root = requireInit();
535
+ const config = loadConfig(root);
536
+ const store = createSkillStore(root);
537
+ try {
538
+ const maxAgeDays =
539
+ opts.maxAgeDays !== undefined ? Number(opts.maxAgeDays) : config.skills.prune.maxAgeDays;
540
+ if (!Number.isFinite(maxAgeDays) || maxAgeDays < 0) {
541
+ throw new ValidationError(`Invalid --max-age-days "${opts.maxAgeDays}" (expected >= 0)`);
542
+ }
543
+
544
+ const cutoffMs = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
545
+ const candidates = store
546
+ .list({ status: "quarantined" })
547
+ .filter((skill) => isOlderThan(skill.updatedAt, cutoffMs));
548
+
549
+ // Dry run unless --force is given (and --dry-run always wins).
550
+ const willRemove = opts.force === true && opts.dryRun !== true;
551
+
552
+ if (willRemove) {
553
+ for (const skill of candidates) store.remove(skill.slug);
554
+ }
555
+
556
+ const slugs = candidates.map((s) => s.slug);
557
+ if (useJson) {
558
+ jsonOutput({ removed: willRemove, maxAgeDays, candidates: slugs });
559
+ return;
560
+ }
561
+ if (candidates.length === 0) {
562
+ printInfo(muted("(no quarantined skills past the max-age window)"));
563
+ return;
564
+ }
565
+ if (willRemove) {
566
+ printSuccess(`Pruned ${candidates.length} skill(s): ${slugs.join(", ")}`);
567
+ } else {
568
+ printInfo(`${accent("dry-run")} ${candidates.length} candidate(s) (pass --force to delete):`);
569
+ for (const slug of slugs) printInfo(muted(` ${slug}`));
570
+ }
571
+ } finally {
572
+ store.close();
573
+ }
574
+ }
575
+
576
+ /**
577
+ * Is an ISO timestamp strictly older than `cutoffMs`? An unparseable/empty
578
+ * timestamp is treated as NOT old (we never delete a skill whose age we can't
579
+ * establish), keeping prune conservative.
580
+ */
581
+ function isOlderThan(iso: string, cutoffMs: number): boolean {
582
+ const t = Date.parse(iso);
583
+ if (Number.isNaN(t)) return false;
584
+ return t < cutoffMs;
585
+ }
586
+
587
+ /** `agentplate skill reindex` — rebuild the FTS index from skill.md files. */
588
+ export function runReindex(useJson: boolean): void {
589
+ const root = requireInit();
590
+ const store = createSkillStore(root);
591
+ try {
592
+ const count = store.reindex();
593
+ if (useJson) jsonOutput({ reindexed: count });
594
+ else printSuccess(`Reindexed ${count} skill(s)`);
595
+ } finally {
596
+ store.close();
597
+ }
598
+ }
599
+
600
+ /** `agentplate skill deprecate|restore <slug>` — flip a skill's lifecycle status. */
601
+ export function runSetStatus(slug: string, status: SkillStatus, useJson: boolean): void {
602
+ const root = requireInit();
603
+ const store = createSkillStore(root);
604
+ try {
605
+ // setStatus throws NotFoundError for a missing slug; surface that as-is.
606
+ store.setStatus(slug, status);
607
+ if (useJson) jsonOutput({ slug, status });
608
+ else printSuccess(`Set ${slug} → ${status}`);
609
+ } finally {
610
+ store.close();
611
+ }
612
+ }
613
+
614
+ // ---------------------------------------------------------------------------
615
+ // Command wiring
616
+ // ---------------------------------------------------------------------------
617
+
618
+ function listCommand(): Command {
619
+ return new Command("list")
620
+ .description("List skills (slug, title, confidence, applied/success, status)")
621
+ .option("--status <status>", "filter by lifecycle status (active|deprecated|quarantined)")
622
+ .option("--json", "output JSON")
623
+ .action((opts: ListOptions, command: Command) => {
624
+ runList(opts, wantsJson(command));
625
+ });
626
+ }
627
+
628
+ function showCommand(): Command {
629
+ return new Command("show")
630
+ .description("Print a skill's full skill.md")
631
+ .argument("<slug>", "skill slug")
632
+ .option("--json", "output JSON")
633
+ .action((slug: string, _opts: { json?: boolean }, command: Command) => {
634
+ runShow(slug, wantsJson(command));
635
+ });
636
+ }
637
+
638
+ function searchCommand(): Command {
639
+ return new Command("search")
640
+ .description("Rank active skills by a query (+ optional --files) and print the top")
641
+ .argument("<query>", "search query")
642
+ .option("--files <glob...>", "file globs to weight relevance by")
643
+ .option("--json", "output JSON")
644
+ .action((query: string, opts: SearchOptions, command: Command) => {
645
+ runSearch(query, opts, wantsJson(command));
646
+ });
647
+ }
648
+
649
+ function recordCommand(): Command {
650
+ return new Command("record")
651
+ .description("Record a skill from a JSON SkillDraft piped on stdin")
652
+ .option("--stdin", "read the draft from stdin (required)")
653
+ .option("--dry-run", "show what would happen without writing")
654
+ .option("--json", "output JSON")
655
+ .action(async (opts: RecordOptions, command: Command) => {
656
+ await runRecord(opts, wantsJson(command));
657
+ });
658
+ }
659
+
660
+ function outcomeCommand(): Command {
661
+ return new Command("outcome")
662
+ .description("Append a success/partial/failure outcome to a skill")
663
+ .argument("<slug>", "skill slug")
664
+ .requiredOption("--status <status>", "success | partial | failure")
665
+ .option("--note <text>", "optional note for the outcome line")
666
+ .option("--json", "output JSON")
667
+ .action((slug: string, opts: OutcomeOptions, command: Command) => {
668
+ runOutcome(slug, opts, wantsJson(command));
669
+ });
670
+ }
671
+
672
+ function pruneCommand(): Command {
673
+ return new Command("prune")
674
+ .description("Remove quarantined skills past the max-age window (dry-run by default)")
675
+ .option("--dry-run", "list candidates without deleting (default behavior)")
676
+ .option("--force", "actually delete the candidates")
677
+ .option("--max-age-days <n>", "override config.skills.prune.maxAgeDays")
678
+ .option("--json", "output JSON")
679
+ .action((opts: PruneOptions, command: Command) => {
680
+ runPrune(opts, wantsJson(command));
681
+ });
682
+ }
683
+
684
+ function reindexCommand(): Command {
685
+ return new Command("reindex")
686
+ .description("Rebuild the FTS index from skill.md files")
687
+ .option("--json", "output JSON")
688
+ .action((_opts: { json?: boolean }, command: Command) => {
689
+ runReindex(wantsJson(command));
690
+ });
691
+ }
692
+
693
+ function deprecateCommand(): Command {
694
+ return new Command("deprecate")
695
+ .description("Mark a skill deprecated (excluded from retrieval)")
696
+ .argument("<slug>", "skill slug")
697
+ .option("--json", "output JSON")
698
+ .action((slug: string, _opts: { json?: boolean }, command: Command) => {
699
+ runSetStatus(slug, "deprecated", wantsJson(command));
700
+ });
701
+ }
702
+
703
+ function restoreCommand(): Command {
704
+ return new Command("restore")
705
+ .description("Restore a deprecated/quarantined skill to active")
706
+ .argument("<slug>", "skill slug")
707
+ .option("--json", "output JSON")
708
+ .action((slug: string, _opts: { json?: boolean }, command: Command) => {
709
+ runSetStatus(slug, "active", wantsJson(command));
710
+ });
711
+ }
712
+
713
+ /** Build the `agentplate skill` command tree. */
714
+ export function createSkillCommand(): Command {
715
+ return new Command("skill")
716
+ .description("Operate on the self-improving skill library")
717
+ .addCommand(listCommand())
718
+ .addCommand(showCommand())
719
+ .addCommand(searchCommand())
720
+ .addCommand(recordCommand())
721
+ .addCommand(outcomeCommand())
722
+ .addCommand(pruneCommand())
723
+ .addCommand(reindexCommand())
724
+ .addCommand(deprecateCommand())
725
+ .addCommand(restoreCommand());
726
+ }
727
+
728
+ // Re-export the store type so test files importing this module get a single
729
+ // surface for the store handle they assert against.
730
+ export type { SkillStore };