@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.
- package/CHANGELOG.md +54 -0
- package/LICENSE +21 -0
- package/README.md +206 -0
- package/agents/architect.md +108 -0
- package/agents/builder.md +97 -0
- package/agents/coordinator.md +113 -0
- package/agents/deployer.md +117 -0
- package/agents/devops.md +114 -0
- package/agents/lead.md +107 -0
- package/agents/merger.md +103 -0
- package/agents/reviewer.md +90 -0
- package/agents/scout.md +95 -0
- package/agents/verifier.md +106 -0
- package/package.json +64 -0
- package/src/agents/guard-rules.ts +55 -0
- package/src/agents/identity.test.ts +161 -0
- package/src/agents/identity.ts +229 -0
- package/src/agents/manifest.test.ts +260 -0
- package/src/agents/manifest.ts +286 -0
- package/src/agents/overlay.test.ts +190 -0
- package/src/agents/overlay.ts +212 -0
- package/src/agents/system-prompt.test.ts +53 -0
- package/src/agents/system-prompt.ts +95 -0
- package/src/agents/turn-runner.ts +79 -0
- package/src/commands/coordinator.test.ts +75 -0
- package/src/commands/coordinator.ts +259 -0
- package/src/commands/deploy.test.ts +504 -0
- package/src/commands/deploy.ts +874 -0
- package/src/commands/doctor.test.ts +106 -0
- package/src/commands/doctor.ts +208 -0
- package/src/commands/init.ts +71 -0
- package/src/commands/log.ts +51 -0
- package/src/commands/mail.ts +197 -0
- package/src/commands/merge.ts +127 -0
- package/src/commands/model.ts +58 -0
- package/src/commands/prime.ts +61 -0
- package/src/commands/reap.ts +87 -0
- package/src/commands/serve.ts +61 -0
- package/src/commands/setup.ts +48 -0
- package/src/commands/ship.test.ts +106 -0
- package/src/commands/ship.ts +202 -0
- package/src/commands/skill.test.ts +458 -0
- package/src/commands/skill.ts +730 -0
- package/src/commands/sling.ts +365 -0
- package/src/commands/status.ts +60 -0
- package/src/commands/stop.ts +56 -0
- package/src/commands/tui.ts +199 -0
- package/src/commands/worktree.ts +77 -0
- package/src/config.test.ts +92 -0
- package/src/config.ts +202 -0
- package/src/db/sqlite.test.ts +77 -0
- package/src/db/sqlite.ts +102 -0
- package/src/deploy/audit.test.ts +233 -0
- package/src/deploy/audit.ts +245 -0
- package/src/deploy/context.test.ts +243 -0
- package/src/deploy/context.ts +72 -0
- package/src/deploy/registry.test.ts +101 -0
- package/src/deploy/registry.ts +86 -0
- package/src/deploy/secrets.test.ts +129 -0
- package/src/deploy/secrets.ts +69 -0
- package/src/deploy/targets/docker-gha.test.ts +323 -0
- package/src/deploy/targets/docker-gha.ts +841 -0
- package/src/deploy/types.ts +153 -0
- package/src/errors.test.ts +42 -0
- package/src/errors.ts +69 -0
- package/src/events/store.test.ts +183 -0
- package/src/events/store.ts +201 -0
- package/src/index.ts +137 -0
- package/src/insights/quality-gates.ts +73 -0
- package/src/json.test.ts +28 -0
- package/src/json.ts +50 -0
- package/src/logging/color.ts +62 -0
- package/src/logging/logger.ts +60 -0
- package/src/logging/sanitizer.test.ts +36 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/client.test.ts +192 -0
- package/src/mail/client.ts +188 -0
- package/src/mail/store.test.ts +279 -0
- package/src/mail/store.ts +311 -0
- package/src/merge/lock.test.ts +88 -0
- package/src/merge/lock.ts +84 -0
- package/src/merge/queue.test.ts +136 -0
- package/src/merge/queue.ts +177 -0
- package/src/merge/resolver.test.ts +219 -0
- package/src/merge/resolver.ts +274 -0
- package/src/paths.ts +36 -0
- package/src/providers/apply.test.ts +90 -0
- package/src/providers/apply.ts +66 -0
- package/src/providers/registry.test.ts +74 -0
- package/src/providers/registry.ts +254 -0
- package/src/runtimes/claude.ts +313 -0
- package/src/runtimes/codex.ts +280 -0
- package/src/runtimes/cursor.ts +247 -0
- package/src/runtimes/gemini.ts +173 -0
- package/src/runtimes/mock.ts +71 -0
- package/src/runtimes/opencode.ts +259 -0
- package/src/runtimes/registry.test.ts +924 -0
- package/src/runtimes/registry.ts +63 -0
- package/src/runtimes/resolve.ts +45 -0
- package/src/runtimes/types.ts +97 -0
- package/src/scaffold.ts +68 -0
- package/src/secrets.test.ts +51 -0
- package/src/secrets.ts +78 -0
- package/src/serve/api.ts +667 -0
- package/src/serve/server.test.ts +433 -0
- package/src/serve/server.ts +271 -0
- package/src/serve/system.ts +90 -0
- package/src/serve/weather.ts +140 -0
- package/src/sessions/reaper.test.ts +162 -0
- package/src/sessions/reaper.ts +149 -0
- package/src/sessions/store.test.ts +351 -0
- package/src/sessions/store.ts +350 -0
- package/src/skills/distiller.test.ts +498 -0
- package/src/skills/distiller.ts +426 -0
- package/src/skills/feedback.test.ts +300 -0
- package/src/skills/feedback.ts +168 -0
- package/src/skills/lifecycle.ts +169 -0
- package/src/skills/retrieval.test.ts +421 -0
- package/src/skills/retrieval.ts +365 -0
- package/src/skills/safety.test.ts +335 -0
- package/src/skills/safety.ts +216 -0
- package/src/skills/store.test.ts +425 -0
- package/src/skills/store.ts +684 -0
- package/src/skills/types.ts +107 -0
- package/src/types.ts +442 -0
- package/src/utils/detect.test.ts +35 -0
- package/src/utils/detect.ts +82 -0
- package/src/version.test.ts +19 -0
- package/src/version.ts +7 -0
- package/src/wizard/setup.ts +254 -0
- package/src/worktree/manager.test.ts +181 -0
- package/src/worktree/manager.ts +229 -0
- package/templates/overlay.md.tmpl +102 -0
- package/ui/dist/assets/index-C7rXIMER.css +1 -0
- package/ui/dist/assets/index-W4kbr4by.js +4526 -0
- package/ui/dist/favicon.svg +21 -0
- package/ui/dist/index.html +16 -0
- package/ui/dist/logo-clay.svg +21 -0
- 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 };
|