@bunizao/cli-kit 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  createProgram,
27
27
  insertDefaultVerb,
28
28
  mutating,
29
+ SHARED_NOUNS,
29
30
  type NounSpec,
30
31
  } from "@bunizao/cli-kit";
31
32
 
@@ -35,17 +36,19 @@ const program = createProgram({
35
36
  description: "Example CLI",
36
37
  });
37
38
 
38
- const units = program.command("units").aliases(["courses", "projects"]);
39
- units.command("list").action(listUnits);
40
- units.command("show <unit>").action(showUnit);
41
- mutating(units.command("set <unit> <state>")).action(setUnitState);
39
+ const courses = program.command("courses").aliases(["units", "projects"]);
40
+ courses.command("list").action(listCourses);
41
+ courses.command("show <course>").action(showCourse);
42
+ const tasks = program.command("tasks");
43
+ tasks.command("list <course>").action(listTasks);
44
+ mutating(tasks.command("set <course> <task> <state>")).action(setTaskState);
42
45
 
43
46
  const nouns: readonly NounSpec[] = [
47
+ SHARED_NOUNS.courses,
44
48
  {
45
- name: "units",
46
- aliases: ["courses", "projects"],
47
- verbs: ["list", "show", "set"],
48
- defaultByArity: { 0: "list", 1: "show" },
49
+ name: "tasks",
50
+ verbs: ["list", "set"],
51
+ defaultByArity: { 1: "list" },
49
52
  valueFlags: [],
50
53
  },
51
54
  ];
@@ -163,13 +166,128 @@ aliases, positional arguments, options, enum values, nested commands, and `mutat
163
166
  commands at noun depth must use a verb exported in `VERBS`; unsupported verbs throw. `auth` and
164
167
  `skills` are action groups rather than domain nouns and are not assigned a `verb` field.
165
168
 
166
- The exit-code table, error vocabulary, and verb set are public versioned API. Changing one requires
167
- a major package release.
169
+ A top-level command named after a shared noun's alias throws, and so does a verb that shared noun
170
+ does not offer.
171
+
172
+ The exit-code table, error vocabulary, verb set, and shared nouns are public versioned API. Changing
173
+ one requires a major package release.
168
174
 
169
175
  The conformance suite is isolated under `conformance/`. Until all three CLIs have published their
170
176
  normalized releases, missing binaries are reported as skipped tests. Its CI workflow installs the
171
177
  published packages before running the suite.
172
178
 
179
+ ## Shared nouns
180
+
181
+ `SHARED_NOUNS` holds the nouns every CLI names the same way. A CLI that offers one uses its spec
182
+ as is, or with fewer verbs and its own `valueFlags`; domain nouns such as `tasks` or `lessons`
183
+ stay with each CLI.
184
+
185
+ | Noun | Aliases | Verbs | Default by arity |
186
+ |---|---|---|---|
187
+ | `courses` | `units`, `projects` | list, show, get | 0 list, 1 show |
188
+ | `grades` | | list | 0 list, 1 list |
189
+
190
+ `courses` is canonical because Ed and Moodle both call them courses; `units` is an Australian usage.
191
+
192
+ Each `grades` row carries `grade` and `max` as numbers or `null`, and a `status` from
193
+ `GRADE_STATUSES`: `graded`, `ungraded`, `unreleased` (marked but not released to the student),
194
+ `not_submitted`, or `unavailable` (the platform refused or failed for that row). Display strings
195
+ such as a letter grade or a range are optional extra fields.
196
+
197
+ ## Intents, skills and plugins
198
+
199
+ An `Intent` maps what a person says to the command and the MCP tool that answer it. One list of
200
+ intents is the single source for the skill, the MCP tool descriptions and the agent packaging:
201
+
202
+ ```ts
203
+ const INTENTS: readonly Intent[] = [
204
+ {
205
+ when: "my grades / what did I get",
206
+ what: "Graded items per course with grade, max and status.",
207
+ command: "example grades [COURSE]",
208
+ tool: "list_grades",
209
+ refs: "course code, name or id",
210
+ then: "show the item for feedback",
211
+ cost: "small",
212
+ },
213
+ ];
214
+
215
+ server.registerTool("list_grades", { description: toolDescription(INTENTS[0]) /* , schema */ }, handler);
216
+
217
+ const files = agentFiles({
218
+ skill: {
219
+ name: "example-cli",
220
+ description: "Reads Example courses, grades and deadlines",
221
+ useWhen: 'the user names Example or asks "what\'s due", "my grades"',
222
+ title: "Example CLI",
223
+ intents: INTENTS,
224
+ mcpServer: "example",
225
+ workflows: [{ title: "Submit an assignment", steps: ["...", "Check the submission status"] }],
226
+ program: commandsJson(program),
227
+ },
228
+ version,
229
+ repository: "https://github.com/example/example-cli",
230
+ author: { name: "example" },
231
+ mcpServers: { example: { command: "npx", args: ["-y", "example-cli", "mcp"] } },
232
+ });
233
+ writeAgentFiles(process.cwd(), files); // { check: true } returns stale paths for CI
234
+ ```
235
+
236
+ `agentFiles` returns, by path from the repository root:
237
+
238
+ - `skills/<name>/SKILL.md`, which `npx skills add owner/repo` and Claude Code both find. An agent
239
+ loads it whole, so it stays short: one table of what the user says against the CLI command and
240
+ the `server:tool` MCP tool, the workflows as copyable checklists, the rules and the setup. A CLI
241
+ with a mutating command gets the shared dry-run, confirm, apply, read-back workflow first. Each
242
+ tool's full description stays with the MCP server.
243
+ - The frontmatter description is `description` then "Use when `useWhen`". `renderSkill` throws
244
+ when the name or description breaks the Agent Skills limits (lowercase hyphenated name, 1024
245
+ characters, no `<` or `>`) or uses "I" or "you" outside quotes.
246
+ - `skills/<name>/references/commands.md`: every command with its arguments, options and whether
247
+ it mutates, plus the exit codes. Pages in `references` are written next to it.
248
+ - `skills/<name>/agents/openai.yaml`, when `openai` metadata is given.
249
+ - `.claude-plugin/plugin.json` and `.claude-plugin/marketplace.json`: the repository is a Claude
250
+ Code plugin, which loads `skills/` by default, and its own marketplace. Install with
251
+ `claude plugin marketplace add owner/repo`, then `claude plugin install example-cli@example-cli`.
252
+ A repository with a root `CLAUDE.md` gets one validator warning, since that file is not plugin
253
+ context; `claude plugin validate .` passes, `--strict` does not.
254
+
255
+ `writeAgentFiles` never deletes, so a CLI moving to this layout removes its old root `SKILL.md`,
256
+ `references/` and `agents/` itself.
257
+
258
+ An MCP server that runs on Cloudflare Workers imports `toolDescription` from
259
+ `@bunizao/cli-kit/intents`, which pulls in neither commander nor Node builtins.
260
+
261
+ `checkIntentCommands(program, nouns, intents)` throws when an intent's command no longer parses:
262
+ a renamed noun or verb, a removed option, or the wrong binary name. Call it from the CLI's tests
263
+ so the skill and the MCP descriptions cannot drift from the command tree.
264
+
265
+ ### Trigger evals
266
+
267
+ `evals/trigger.mjs` checks whether the descriptions route requests to the right skill. It puts
268
+ every sibling skill in one Codex workspace, so they compete as they do for a student who has all
269
+ three installed. A skill that steals its sibling's requests looks like the sibling undertriggering
270
+ when either is tested alone. For each query it records which `skills/<name>/` Codex opens first.
271
+
272
+ ```bash
273
+ node evals/trigger.mjs \
274
+ --skill ../moodle-cli/skills/moodle-cli \
275
+ --skill ../ontrack-cli/skills/ontrack-cli \
276
+ --skill ../edstem-cli/skills/edstem-cli \
277
+ --runs 3
278
+ ```
279
+
280
+ - The folder name is the skill name. `--pool` defaults to `evals/trigger-pool.json`: 29
281
+ requests, each with the skill it should reach or `none`. `--model` defaults to Codex's configured
282
+ model. `--out` keeps every run's pick and first command.
283
+ - Each Codex run is killed at its first command, and each skill's binary (`moodle`, `ontrack`,
284
+ `edstem`) is a failing stub first on `PATH`, so nothing reaches a real account. `HOME` is a
285
+ temporary folder that hides the user's own skills; `CODEX_HOME` keeps the sign-in.
286
+ - A run costs about 16k input tokens, so the default 29 × 3 is about 1.4M. Results move by about
287
+ one run per query between passes, so compare changes across three runs, not one.
288
+ - This measures Codex, not Claude. Claude Code reads the same folder layout and frontmatter, but
289
+ its routing can differ.
290
+
173
291
  ## Prompts and progress
174
292
 
175
293
  `createUi` gives every CLI the same interactive surface, drawn with `@clack/prompts` on
package/dist/index.d.ts CHANGED
@@ -4,9 +4,11 @@ export * from "./color.js";
4
4
  export * from "./confirm.js";
5
5
  export * from "./errors.js";
6
6
  export * from "./help.js";
7
+ export * from "./intents.js";
7
8
  export * from "./nouns.js";
8
9
  export * from "./output.js";
9
10
  export * from "./program.js";
10
11
  export * from "./report.js";
12
+ export * from "./skills.js";
11
13
  export * from "./ui.js";
12
14
  export * from "./theme.js";
package/dist/index.js CHANGED
@@ -4,9 +4,11 @@ export * from "./color.js";
4
4
  export * from "./confirm.js";
5
5
  export * from "./errors.js";
6
6
  export * from "./help.js";
7
+ export * from "./intents.js";
7
8
  export * from "./nouns.js";
8
9
  export * from "./output.js";
9
10
  export * from "./program.js";
10
11
  export * from "./report.js";
12
+ export * from "./skills.js";
11
13
  export * from "./ui.js";
12
14
  export * from "./theme.js";
@@ -0,0 +1,31 @@
1
+ import type { Command } from "commander";
2
+ import { type NounSpec } from "./nouns.js";
3
+ /**
4
+ * One thing a person asks for, mapped to the CLI command and the MCP tool that answer it.
5
+ * The skill, the MCP tool descriptions and the help text all render from these fields.
6
+ */
7
+ export interface Intent {
8
+ /** What the person says, e.g. "my grades / what did I get". */
9
+ readonly when: string;
10
+ /** What comes back, as a sentence. */
11
+ readonly what: string;
12
+ /** The command as typed, with placeholders such as UNIT; omitted for an MCP-only tool. */
13
+ readonly command?: string;
14
+ /** The MCP tool for the same request; omitted for a CLI-only command. */
15
+ readonly tool?: string;
16
+ /** Where nearby requests go instead, e.g. "get_thread for thread bodies". */
17
+ readonly instead?: string;
18
+ /** The references and arguments it accepts. */
19
+ readonly refs?: string;
20
+ /** The usual next step. */
21
+ readonly then?: string;
22
+ /** Rough response size. */
23
+ readonly cost?: string;
24
+ }
25
+ /** The MCP tool description for an intent: prose, because a model reads it. */
26
+ export declare function toolDescription(intent: Intent): string;
27
+ /**
28
+ * Throw when an intent's command no longer parses against the program: a renamed noun or verb,
29
+ * a wrong binary name, or an option that was removed. Run it in the CLI's tests.
30
+ */
31
+ export declare function checkIntentCommands(program: Command, nouns: readonly NounSpec[], intents: readonly Intent[]): void;
@@ -0,0 +1,70 @@
1
+ import { insertDefaultVerb } from "./nouns.js";
2
+ /** The MCP tool description for an intent: prose, because a model reads it. */
3
+ export function toolDescription(intent) {
4
+ return [
5
+ sentence(intent.what),
6
+ sentence(`Use when the user says "${intent.when}"`),
7
+ intent.instead && sentence(`Instead: ${intent.instead}`),
8
+ intent.refs && sentence(`Input: ${intent.refs}`),
9
+ intent.then && sentence(`Then: ${intent.then}`),
10
+ intent.cost && sentence(`Cost: ${intent.cost}`),
11
+ ].filter(Boolean).join(" ");
12
+ }
13
+ /**
14
+ * Throw when an intent's command no longer parses against the program: a renamed noun or verb,
15
+ * a wrong binary name, or an option that was removed. Run it in the CLI's tests.
16
+ */
17
+ export function checkIntentCommands(program, nouns, intents) {
18
+ const problems = intents.flatMap((intent) => (intent.command ? commandProblems(program, nouns, intent.command) : []));
19
+ if (problems.length)
20
+ throw new Error(`Intent commands out of date:\n${problems.join("\n")}`);
21
+ }
22
+ const ALWAYS_ACCEPTED = new Set(["-h", "--help", "-V", "--version"]);
23
+ function commandProblems(program, nouns, text) {
24
+ const [binary, ...rest] = words(text);
25
+ if (binary !== program.name())
26
+ return [`${text}: starts with "${binary}", not "${program.name()}"`];
27
+ const argv = insertDefaultVerb(rest.map(unwrapOptionalFlag), nouns);
28
+ const path = [program];
29
+ let unmatched;
30
+ for (const token of argv) {
31
+ if (token.startsWith("-"))
32
+ continue;
33
+ const current = path.at(-1);
34
+ const child = current.commands.find((command) => command.name() === token || command.aliases().includes(token));
35
+ if (!child) {
36
+ unmatched = token;
37
+ break;
38
+ }
39
+ path.push(child);
40
+ }
41
+ const problems = [];
42
+ const resolved = path.at(-1);
43
+ // A group that takes no arguments of its own must be followed by one of its commands.
44
+ if (unmatched !== undefined && resolved.commands.length && !resolved.registeredArguments.length) {
45
+ problems.push(`${text}: "${unmatched}" is not a command of "${path.map((command) => command.name()).join(" ")}"`);
46
+ }
47
+ const options = new Set(path.flatMap((command) => command.options.flatMap(optionNames)));
48
+ for (const token of argv) {
49
+ const flag = token.split("=")[0] ?? token;
50
+ if (flag.startsWith("-") && !ALWAYS_ACCEPTED.has(flag) && !options.has(flag)) {
51
+ problems.push(`${text}: unknown option "${flag}"`);
52
+ }
53
+ }
54
+ return problems;
55
+ }
56
+ // Quoted strings and bracketed placeholders such as <lesson id> stay one word.
57
+ function words(text) {
58
+ return text.replace(/^`|`$/g, "").match(/"[^"]*"|'[^']*'|<[^>]*>|\[[^\]]*\]|\S+/g) ?? [];
59
+ }
60
+ // `[--final]` documents an optional flag; check it like `--final`.
61
+ function unwrapOptionalFlag(token) {
62
+ const inner = /^\[(-[^\s\]]+)/.exec(token);
63
+ return inner?.[1] ?? token;
64
+ }
65
+ function optionNames(option) {
66
+ return [option.long, option.short].filter((name) => Boolean(name));
67
+ }
68
+ function sentence(text) {
69
+ return /[.!?]$/.test(text) ? text : `${text}.`;
70
+ }
package/dist/nouns.d.ts CHANGED
@@ -6,4 +6,30 @@ export interface NounSpec {
6
6
  /** Option flags whose following token is a value, not a positional argument. */
7
7
  readonly valueFlags?: readonly string[];
8
8
  }
9
+ /**
10
+ * Nouns every CLI names the same way. A CLI that offers one uses its spec as is, or with fewer
11
+ * verbs and its own valueFlags, so `moodle grades` and `edstem grades` read alike.
12
+ */
13
+ export declare const SHARED_NOUNS: {
14
+ readonly courses: {
15
+ readonly name: "courses";
16
+ readonly aliases: readonly ["units", "projects"];
17
+ readonly verbs: readonly ["list", "show", "get"];
18
+ readonly defaultByArity: {
19
+ readonly 0: "list";
20
+ readonly 1: "show";
21
+ };
22
+ };
23
+ readonly grades: {
24
+ readonly name: "grades";
25
+ readonly verbs: readonly ["list"];
26
+ readonly defaultByArity: {
27
+ readonly 0: "list";
28
+ readonly 1: "list";
29
+ };
30
+ };
31
+ };
32
+ /** The `status` of one row in `grades` output. */
33
+ export declare const GRADE_STATUSES: readonly ["graded", "ungraded", "unreleased", "not_submitted", "unavailable"];
34
+ export type GradeStatus = (typeof GRADE_STATUSES)[number];
9
35
  export declare function insertDefaultVerb(argv: readonly string[], nouns: readonly NounSpec[]): string[];
package/dist/nouns.js CHANGED
@@ -1,3 +1,22 @@
1
+ /**
2
+ * Nouns every CLI names the same way. A CLI that offers one uses its spec as is, or with fewer
3
+ * verbs and its own valueFlags, so `moodle grades` and `edstem grades` read alike.
4
+ */
5
+ export const SHARED_NOUNS = {
6
+ courses: {
7
+ name: "courses",
8
+ aliases: ["units", "projects"],
9
+ verbs: ["list", "show", "get"],
10
+ defaultByArity: { 0: "list", 1: "show" },
11
+ },
12
+ grades: {
13
+ name: "grades",
14
+ verbs: ["list"],
15
+ defaultByArity: { 0: "list", 1: "list" },
16
+ },
17
+ };
18
+ /** The `status` of one row in `grades` output. */
19
+ export const GRADE_STATUSES = ["graded", "ungraded", "unreleased", "not_submitted", "unavailable"];
1
20
  const GLOBAL_VALUE_FLAGS = new Set(["--fields", "-o", "--output"]);
2
21
  export function insertDefaultVerb(argv, nouns) {
3
22
  const result = [...argv];
package/dist/program.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { Command } from "commander";
2
2
  import { colorEnabled } from "./color.js";
3
3
  import { styledHelp } from "./help.js";
4
+ import { SHARED_NOUNS } from "./nouns.js";
4
5
  export const VERBS = ["list", "show", "read", "get", "search", "send", "submit", "set", "mark-read"];
5
6
  const VERB_SET = new Set(VERBS);
7
+ const SHARED = Object.values(SHARED_NOUNS);
6
8
  const mutations = new WeakSet();
7
9
  const ACTION_GROUPS = new Set(["auth", "skills"]);
8
10
  const ACTIONS = {
@@ -53,9 +55,20 @@ function describe(command, noun) {
53
55
  if (noun && ACTION_GROUPS.has(noun) && !ACTIONS[noun]?.has(command.name())) {
54
56
  throw new Error(`Unsupported action "${command.name()}" on group "${noun}".`);
55
57
  }
58
+ // A shared noun's name and aliases belong to that noun alone, whether used as a name or an alias.
59
+ for (const word of noun ? [] : [currentNoun, ...command.aliases()]) {
60
+ const owner = SHARED.find((spec) => spec.name === word || spec.aliases?.includes(word));
61
+ if (owner && owner.name !== currentNoun) {
62
+ throw new Error(`"${word}" belongs to the shared noun "${owner.name}"; name that command "${owner.name}".`);
63
+ }
64
+ }
56
65
  if (verb && !VERB_SET.has(verb)) {
57
66
  throw new Error(`Unsupported verb "${verb}" on noun "${noun}".`);
58
67
  }
68
+ const shared = SHARED.find((spec) => spec.name === noun);
69
+ if (verb && shared && !shared.verbs.includes(verb)) {
70
+ throw new Error(`Unsupported verb "${verb}" on shared noun "${noun}".`);
71
+ }
59
72
  return {
60
73
  name: command.name(),
61
74
  noun: currentNoun,
@@ -0,0 +1,92 @@
1
+ import type { Intent } from "./intents.js";
2
+ import type { ProgramDescription } from "./program.js";
3
+ export interface SkillOptions {
4
+ /** The skill and plugin name, e.g. "edstem-cli". */
5
+ readonly name: string;
6
+ /** What the skill does, in the third person. It is also the plugin's description. */
7
+ readonly description: string;
8
+ /**
9
+ * When an agent should load the skill, rendered after the description as "Use when …". Name the
10
+ * site and quote what people say; agents undertrigger, so list more phrases rather than fewer.
11
+ */
12
+ readonly useWhen: string;
13
+ readonly title: string;
14
+ /** Markdown between the title and the intent table: the surfaces and the reference vocabulary. */
15
+ readonly intro?: string;
16
+ readonly intents: readonly Intent[];
17
+ /** The MCP server name tools are qualified with: "moodle" renders `moodle:due`. Required when an intent has a tool. */
18
+ readonly mcpServer?: string;
19
+ /** Multi-step tasks rendered as checklists, after the shared one for mutating commands. */
20
+ readonly workflows?: readonly Workflow[];
21
+ /** Rules for this CLI only; the shared contract rules are always included. */
22
+ readonly rules?: readonly string[];
23
+ /** Markdown for the Setup section. */
24
+ readonly setup?: string;
25
+ /** `commandsJson(program)` output. */
26
+ readonly program: ProgramDescription;
27
+ }
28
+ export interface Workflow {
29
+ /** An imperative title, e.g. "Submit an assignment". */
30
+ readonly title: string;
31
+ /** In order; end with a step that checks the result. */
32
+ readonly steps: readonly string[];
33
+ }
34
+ /** A local stdio server, or a hosted Streamable HTTP server. */
35
+ export type McpServer = {
36
+ readonly command: string;
37
+ readonly args?: readonly string[];
38
+ readonly env?: Readonly<Record<string, string>>;
39
+ } | {
40
+ readonly type: "http";
41
+ readonly url: string;
42
+ readonly headers?: Readonly<Record<string, string>>;
43
+ };
44
+ export interface AgentFilesOptions {
45
+ readonly skill: SkillOptions;
46
+ readonly version: string;
47
+ /** The GitHub repository URL; it is also the plugin's homepage. */
48
+ readonly repository: string;
49
+ readonly author: {
50
+ readonly name: string;
51
+ readonly url?: string;
52
+ };
53
+ readonly license?: string;
54
+ readonly keywords?: readonly string[];
55
+ /** MCP servers the Claude Code plugin starts, keyed by server name. */
56
+ readonly mcpServers?: Readonly<Record<string, McpServer>>;
57
+ /** Codex skill metadata, written to agents/openai.yaml in the skill folder. */
58
+ readonly openai?: {
59
+ readonly displayName: string;
60
+ readonly shortDescription: string;
61
+ readonly defaultPrompt: string;
62
+ };
63
+ /** Hand-written reference pages by file name, written next to the command reference. */
64
+ readonly references?: Readonly<Record<string, string>>;
65
+ }
66
+ /** Where agentFiles puts the full command tree, relative to the skill folder; SKILL.md links to it. */
67
+ export declare const COMMAND_REFERENCE = "references/commands.md";
68
+ /** The frontmatter description: what the skill does, then when to use it. */
69
+ export declare function skillDescription(options: Pick<SkillOptions, "description" | "useWhen">): string;
70
+ /**
71
+ * SKILL.md for one CLI: what people say, what to run, the workflows, the rules and the setup.
72
+ * An agent loads it whole, so the command tree lives in COMMAND_REFERENCE and each MCP tool's
73
+ * full description stays with the server. Throws when the name or description breaks the
74
+ * Agent Skills limits.
75
+ */
76
+ export declare function renderSkill(options: SkillOptions): string;
77
+ /** The full command tree and the exit codes, written to COMMAND_REFERENCE. */
78
+ export declare function renderCommandReference(program: ProgramDescription): string;
79
+ /**
80
+ * Every agent packaging file for one CLI repository, keyed by path from the repository root:
81
+ * the skill folder skills/<name>/ (SKILL.md, its references and Codex's agents/openai.yaml), which
82
+ * `npx skills add` and Claude Code both find, plus the Claude Code plugin and its single-plugin
83
+ * marketplace.
84
+ */
85
+ export declare function agentFiles(options: AgentFilesOptions): Record<string, string>;
86
+ /**
87
+ * Write generated files under root. With `check`, write nothing and return the paths whose
88
+ * content differs, so CI can fail on a stale commit.
89
+ */
90
+ export declare function writeAgentFiles(root: string, files: Readonly<Record<string, string>>, options?: {
91
+ check?: boolean;
92
+ }): string[];
package/dist/skills.js ADDED
@@ -0,0 +1,225 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { stringify } from "yaml";
4
+ import { EXIT_CODES } from "./errors.js";
5
+ /** Where agentFiles puts the full command tree, relative to the skill folder; SKILL.md links to it. */
6
+ export const COMMAND_REFERENCE = "references/commands.md";
7
+ const RULES = {
8
+ narrow: "Start from the table row that matches the request: the narrowest command or tool answers sooner and costs fewer tokens than a broad listing.",
9
+ output: "Piped output is JSON. `--fields a,b` keeps only those top-level fields, which keeps long lists cheap.",
10
+ read: "`read` prints Markdown and changes nothing upstream, so it is safe to run unasked.",
11
+ mutate: "Change something upstream only when the user asked for that change: other people see it, and most changes cannot be undone.",
12
+ secrets: "Keep tokens, cookies and session files out of replies, logs and files; each one is a live sign-in.",
13
+ errors: "A failure prints one error with a `hint` on stderr; follow the hint before retrying.",
14
+ };
15
+ const MUTATION_WORKFLOW = {
16
+ title: "Change something upstream",
17
+ steps: [
18
+ "Check the user asked for this change, on these targets",
19
+ "Run the command with `--dry-run` and show the plan",
20
+ "Get the user's yes to that plan",
21
+ "Run it again with `--yes`",
22
+ "Read the result back with the matching `list` or `show` and report what changed",
23
+ ],
24
+ };
25
+ /** The frontmatter description: what the skill does, then when to use it. */
26
+ export function skillDescription(options) {
27
+ return `${sentence(options.description)} Use when ${sentence(options.useWhen)}`;
28
+ }
29
+ /**
30
+ * SKILL.md for one CLI: what people say, what to run, the workflows, the rules and the setup.
31
+ * An agent loads it whole, so the command tree lives in COMMAND_REFERENCE and each MCP tool's
32
+ * full description stays with the server. Throws when the name or description breaks the
33
+ * Agent Skills limits.
34
+ */
35
+ export function renderSkill(options) {
36
+ const description = skillDescription(options);
37
+ const problems = frontmatterProblems(options.name, description);
38
+ if (problems.length)
39
+ throw new Error(`Skill "${options.name}" frontmatter:\n${problems.join("\n")}`);
40
+ const binary = options.program.name;
41
+ const hasRead = someCommand(options.program, (command) => command.verb === "read");
42
+ const hasMutation = someCommand(options.program, (command) => command.mutating);
43
+ const workflows = [...(hasMutation ? [MUTATION_WORKFLOW] : []), ...(options.workflows ?? [])];
44
+ return [
45
+ stringifyFrontmatter({ name: options.name, description }),
46
+ `# ${options.title}`,
47
+ options.intro,
48
+ "## What the user says, and what to run",
49
+ intentTable(options),
50
+ workflows.length
51
+ ? ["## Workflows", "Copy the checklist into your reply and tick each step as it is done; the last step is what shows the change landed.", ...workflows.map(renderWorkflow)].join("\n\n")
52
+ : undefined,
53
+ "## Rules",
54
+ [
55
+ RULES.narrow,
56
+ RULES.output,
57
+ ...(hasRead ? [RULES.read] : []),
58
+ ...(hasMutation ? [RULES.mutate] : []),
59
+ RULES.secrets,
60
+ RULES.errors,
61
+ ...(options.rules ?? []),
62
+ `Every command, argument, option and exit code is in [${COMMAND_REFERENCE}](${COMMAND_REFERENCE}); \`${binary} commands --json\` is the source of truth.`,
63
+ ]
64
+ .map((rule) => `- ${rule}`)
65
+ .join("\n"),
66
+ options.setup ? `## Setup\n\n${options.setup}` : undefined,
67
+ ].filter((section) => Boolean(section)).join("\n\n") + "\n";
68
+ }
69
+ /** The full command tree and the exit codes, written to COMMAND_REFERENCE. */
70
+ export function renderCommandReference(program) {
71
+ const exitCodes = Object.entries(EXIT_CODES).map(([code, exit]) => [String(exit), code]);
72
+ return [
73
+ `# ${program.name} commands`,
74
+ `Generated from \`${program.name} commands --json\` for version ${program.version}.`,
75
+ markdownTable(["Command", "Description", "Arguments", "Options", "Mutating"], program.commands.flatMap((command) => commandRows(program.name, command))),
76
+ "## Exit codes",
77
+ "With `--json`, a failure is one `{ ok: false, error: { code, message, hint } }` object on stderr.",
78
+ markdownTable(["Exit", "Error code"], [["0", "success"], ...exitCodes]),
79
+ ].join("\n\n") + "\n";
80
+ }
81
+ /**
82
+ * Every agent packaging file for one CLI repository, keyed by path from the repository root:
83
+ * the skill folder skills/<name>/ (SKILL.md, its references and Codex's agents/openai.yaml), which
84
+ * `npx skills add` and Claude Code both find, plus the Claude Code plugin and its single-plugin
85
+ * marketplace.
86
+ */
87
+ export function agentFiles(options) {
88
+ const { skill } = options;
89
+ const folder = `skills/${skill.name}`;
90
+ const files = {
91
+ [`${folder}/SKILL.md`]: renderSkill(skill),
92
+ [`${folder}/${COMMAND_REFERENCE}`]: renderCommandReference(skill.program),
93
+ };
94
+ for (const [name, content] of Object.entries(options.references ?? {})) {
95
+ files[`${folder}/${dirname(COMMAND_REFERENCE)}/${name}`] = content;
96
+ }
97
+ if (options.openai) {
98
+ files[`${folder}/agents/openai.yaml`] = stringify({
99
+ interface: {
100
+ display_name: options.openai.displayName,
101
+ short_description: options.openai.shortDescription,
102
+ default_prompt: options.openai.defaultPrompt,
103
+ },
104
+ }, { lineWidth: 0 });
105
+ }
106
+ // The repository root is the plugin root; Claude Code loads skills/ from it by default.
107
+ files[".claude-plugin/plugin.json"] = json({
108
+ name: skill.name,
109
+ version: options.version,
110
+ description: skill.description,
111
+ author: options.author,
112
+ homepage: options.repository,
113
+ repository: options.repository,
114
+ ...(options.license ? { license: options.license } : {}),
115
+ ...(options.keywords?.length ? { keywords: options.keywords } : {}),
116
+ ...(options.mcpServers ? { mcpServers: options.mcpServers } : {}),
117
+ });
118
+ files[".claude-plugin/marketplace.json"] = json({
119
+ name: skill.name,
120
+ description: skill.description,
121
+ owner: options.author,
122
+ plugins: [{ name: skill.name, source: "./", description: skill.description }],
123
+ });
124
+ return files;
125
+ }
126
+ /**
127
+ * Write generated files under root. With `check`, write nothing and return the paths whose
128
+ * content differs, so CI can fail on a stale commit.
129
+ */
130
+ export function writeAgentFiles(root, files, options = {}) {
131
+ const stale = Object.entries(files)
132
+ .filter(([path, content]) => readOrEmpty(join(root, path)) !== content)
133
+ .map(([path]) => path);
134
+ if (options.check)
135
+ return stale;
136
+ for (const path of stale) {
137
+ mkdirSync(dirname(join(root, path)), { recursive: true });
138
+ writeFileSync(join(root, path), files[path], "utf8");
139
+ }
140
+ return stale;
141
+ }
142
+ // The limits from the Agent Skills spec, plus the third person: the description is read as
143
+ // part of the system prompt, where "I" and "you" point at the wrong speaker.
144
+ function frontmatterProblems(name, description) {
145
+ const problems = [];
146
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name) || name.length > 64)
147
+ problems.push(`name must be lowercase words joined by hyphens, at most 64 characters`);
148
+ if (/anthropic|claude/.test(name))
149
+ problems.push(`name must not contain "anthropic" or "claude"`);
150
+ if (description.length > 1024)
151
+ problems.push(`description is ${description.length} characters; the limit is 1024`);
152
+ if (/[<>]/.test(description))
153
+ problems.push("description must not contain < or >");
154
+ // Quoted phrases are what the user says, so "my grades" is fine there.
155
+ const unquoted = description.replace(/"[^"]*"|“[^”]*”/g, "");
156
+ const person = /\b(?:I|me|my|we|our|you|your)\b/i.exec(unquoted);
157
+ if (person)
158
+ problems.push(`description must be in the third person outside quotes; found "${person[0]}"`);
159
+ return problems;
160
+ }
161
+ function intentTable(options) {
162
+ const { intents, mcpServer } = options;
163
+ if (!mcpServer && intents.some((intent) => intent.tool)) {
164
+ throw new Error(`Skill "${options.name}" has MCP tools but no mcpServer to qualify them with.`);
165
+ }
166
+ const columns = [["The user says", (intent) => intent.when]];
167
+ if (intents.some((intent) => intent.command))
168
+ columns.push(["CLI", (intent) => (intent.command ? `\`${intent.command}\`` : "")]);
169
+ if (intents.some((intent) => intent.tool))
170
+ columns.push(["MCP tool", (intent) => (intent.tool ? `\`${mcpServer}:${intent.tool}\`` : "")]);
171
+ if (intents.some((intent) => intent.then))
172
+ columns.push(["Then", (intent) => intent.then ?? ""]);
173
+ return markdownTable(columns.map(([header]) => header), intents.map((intent) => columns.map(([, cell]) => cell(intent))));
174
+ }
175
+ // A fenced block, so the agent copies it verbatim and ticks it in its reply.
176
+ function renderWorkflow(workflow) {
177
+ return [`### ${workflow.title}`, ["```text", ...workflow.steps.map((step) => `- [ ] ${step}`), "```"].join("\n")].join("\n\n");
178
+ }
179
+ function someCommand(program, test) {
180
+ return program.commands.some(function visit(command) {
181
+ return test(command) || command.commands.some(visit);
182
+ });
183
+ }
184
+ function commandRows(binary, command, parents = []) {
185
+ const path = [...parents, command.name];
186
+ const row = [
187
+ `\`${[binary, ...path].join(" ")}\``,
188
+ command.description,
189
+ command.positionals.map((argument) => {
190
+ const name = argument.variadic ? `${argument.name}...` : argument.name;
191
+ return argument.required ? `<${name}>` : `[${name}]`;
192
+ }).join(" "),
193
+ command.options.map((option) => option.flags).join("<br>"),
194
+ command.mutating ? "yes" : "no",
195
+ ];
196
+ return [row, ...command.commands.flatMap((child) => commandRows(binary, child, path))];
197
+ }
198
+ function stringifyFrontmatter(fields) {
199
+ return `---\n${stringify(fields, { lineWidth: 0 })}---`;
200
+ }
201
+ function markdownTable(headers, rows) {
202
+ return [
203
+ `| ${headers.join(" | ")} |`,
204
+ `| ${headers.map(() => "---").join(" | ")} |`,
205
+ ...rows.map((row) => `| ${row.map(escapeCell).join(" | ")} |`),
206
+ ].join("\n");
207
+ }
208
+ function escapeCell(value) {
209
+ return value.replace(/\|/g, "\\|").replace(/\n/g, "<br>");
210
+ }
211
+ function sentence(text) {
212
+ const trimmed = text.trim();
213
+ return /[.!?]$/.test(trimmed) ? trimmed : `${trimmed}.`;
214
+ }
215
+ function json(value) {
216
+ return `${JSON.stringify(value, null, 2)}\n`;
217
+ }
218
+ function readOrEmpty(path) {
219
+ try {
220
+ return readFileSync(path, "utf8");
221
+ }
222
+ catch {
223
+ return "";
224
+ }
225
+ }
package/dist/theme.js CHANGED
@@ -10,8 +10,8 @@ const TONE_STYLE = {
10
10
  // Checked in order: a negated word ("not graded") must win over the word it contains.
11
11
  const TONE_WORDS = [
12
12
  ["muted", /^(?:-|—|none|n\/a|unknown)$|\b(?:not[ _](?:started|submitted|graded|attempted|available|yet)|no submission|un(?:attempted|graded|submitted)|archived|hidden|unlisted|inactive|closed|disabled)\b/iu],
13
- ["danger", /\b(?:overdue|late|fail(?:ed|ure)?|error|missing|rejected|need(?:s)?[ _]help|blocked|expired|redo|resubmit|demonstrate|exceeded)\b/iu],
14
- ["warning", /\b(?:pending|draft|working|in[ _]progress|due soon|today|tomorrow|awaiting|unanswered|scheduled|discuss|attempted|partial)\b/iu],
13
+ ["danger", /\b(?:overdue|late|fail(?:ed|ure)?|error|missing|rejected|need(?:s)?[ _]help|blocked|expired|redo|resubmit|demonstrate|exceeded|unavailable)\b/iu],
14
+ ["warning", /\b(?:pending|draft|working|in[ _]progress|due soon|today|tomorrow|awaiting|unanswered|scheduled|discuss|attempted|partial|unreleased)\b/iu],
15
15
  ["success", /\b(?:complete[d]?|done|submitted|graded|accepted|passed|resolved|answered|ready[ _]for[ _]feedback|active|signed in|ok|success(?:ful)?|enrolled)\b/iu],
16
16
  ["accent", /\b(?:announcement|pinned|staff|endorsed|official|urgent)\b/iu],
17
17
  ["info", /\b(?:question|post|note|general|info)\b/iu],
package/package.json CHANGED
@@ -1,12 +1,16 @@
1
1
  {
2
2
  "name": "@bunizao/cli-kit",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Shared command-line contract for Bunizao CLI tools",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": {
8
8
  "types": "./dist/index.d.ts",
9
9
  "import": "./dist/index.js"
10
+ },
11
+ "./intents": {
12
+ "types": "./dist/intents.d.ts",
13
+ "import": "./dist/intents.js"
10
14
  }
11
15
  },
12
16
  "files": [