@bunizao/cli-kit 0.5.1 → 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
@@ -187,8 +305,10 @@ spin.stop("Uploaded 2 files");
187
305
  ui.outro("Done");
188
306
  ```
189
307
 
190
- `select` switches to type-to-filter above `AUTOCOMPLETE_FROM` choices. Ctrl+C inside a
191
- prompt throws a `cancelled` error (exit 130). `confirm(plan, options)` is built on the same
308
+ `select` switches to type-to-filter above `AUTOCOMPLETE_FROM` choices, or always with
309
+ `{ search: true }`; every typed word must appear in the label or hint, in any order.
310
+ `{ initial }` highlights a row. A multi-step picker passes `{ back: true }` and gets `BACK`
311
+ when the person presses Escape. Ctrl+C inside a prompt throws a `cancelled` error (exit 130). `confirm(plan, options)` is built on the same
192
312
  layer and keeps its contract: `--dry-run` prints the plan, `--yes` skips the question, a pipe
193
313
  without `--yes` is a usage error.
194
314
 
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/dist/ui.d.ts CHANGED
@@ -9,6 +9,19 @@ export interface Choice<T> {
9
9
  readonly label: string;
10
10
  readonly hint?: string;
11
11
  }
12
+ /** What `select` resolves when the person presses Escape on a prompt opened with `back: true`. */
13
+ export declare const BACK: unique symbol;
14
+ export interface SelectOptions<T> {
15
+ /** Type-to-filter at any length; a short list still wants it when the person knows the name. */
16
+ readonly search?: boolean;
17
+ /** The row highlighted when the list opens. */
18
+ readonly initial?: T;
19
+ /**
20
+ * Escape resolves `BACK` instead of cancelling, so a multi-step picker can return to the
21
+ * previous step. Ctrl+C still cancels.
22
+ */
23
+ readonly back?: boolean;
24
+ }
12
25
  export interface UiOptions {
13
26
  /** Keyboard input. Defaults to `process.stdin`. */
14
27
  readonly input?: NodeJS.ReadStream;
@@ -30,6 +43,8 @@ export interface Spinner {
30
43
  /** End the step and leave `message` (or the last running text) as the permanent line. */
31
44
  stop(message?: string): void;
32
45
  error(message?: string): void;
46
+ /** End the step and erase it, for progress whose outcome is printed elsewhere. */
47
+ clear(): void;
33
48
  }
34
49
  export interface Ui {
35
50
  readonly interactive: boolean;
@@ -47,10 +62,14 @@ export interface Ui {
47
62
  spinner(): Spinner;
48
63
  /**
49
64
  * Let the person pick one row. Above `AUTOCOMPLETE_FROM` choices the list becomes
50
- * type-to-filter, because arrowing through thirty forum threads is not a picker.
65
+ * type-to-filter, because arrowing through thirty forum threads is not a picker. The
66
+ * filter matches every typed word anywhere in the label or hint, in any order.
51
67
  * Without a terminal this throws a usage error that lists the choices.
52
68
  */
53
- select<T>(message: string, choices: readonly Choice<T>[]): Promise<T>;
69
+ select<T>(message: string, choices: readonly Choice<T>[], options: SelectOptions<T> & {
70
+ readonly back: true;
71
+ }): Promise<T | typeof BACK>;
72
+ select<T>(message: string, choices: readonly Choice<T>[], options?: SelectOptions<T>): Promise<T>;
54
73
  /** Yes or no. Without a terminal this throws a usage error. */
55
74
  confirm(message: string, options?: {
56
75
  readonly initial?: boolean;
package/dist/ui.js CHANGED
@@ -6,6 +6,8 @@ import * as clack from "@clack/prompts";
6
6
  import { isAgentEnvironment } from "./audience.js";
7
7
  import { colorEnabled, painter } from "./color.js";
8
8
  import { CliError } from "./errors.js";
9
+ /** What `select` resolves when the person presses Escape on a prompt opened with `back: true`. */
10
+ export const BACK = Symbol("cli-kit.back");
9
11
  export const AUTOCOMPLETE_FROM = 8;
10
12
  /**
11
13
  * Every CLI in the family draws the same way: prompts, steps and spinners on stderr
@@ -18,8 +20,15 @@ export function createUi(options = {}) {
18
20
  const output = options.output ?? process.stderr;
19
21
  const interactive = options.interactive
20
22
  ?? (Boolean(input.isTTY) && Boolean(output.isTTY) && !isAgentEnvironment(options.env));
21
- const common = { input, output, ...(options.signal ? { signal: options.signal } : {}) };
22
- const plain = (text) => output.write(`${text}\n`);
23
+ // Escape erases the abandoned step and the answer above it, so going back looks like
24
+ // the earlier question was never answered. That needs to know how many rows each prompt
25
+ // drew; clack redraws with relative moves, so counting newlines and cursor-up/down
26
+ // sequences written through the ui gives it.
27
+ let row = 0;
28
+ const answered = [];
29
+ const drawn = interactive ? trackRows(output, delta => { row += delta; }) : output;
30
+ const common = { input, output: drawn, ...(options.signal ? { signal: options.signal } : {}) };
31
+ const plain = (text) => drawn.write(`${text}\n`);
23
32
  const paint = painter(colorEnabled(output, options.env));
24
33
  return {
25
34
  interactive,
@@ -30,7 +39,7 @@ export function createUi(options = {}) {
30
39
  return;
31
40
  }
32
41
  const lines = art.replace(/^\n+|\s+$/gu, "").split("\n").map(line => paint("cyan", line));
33
- output.write(`${lines.join("\n")}\n${tagline ? `${paint("dim", tagline)}\n` : ""}\n`);
42
+ drawn.write(`${lines.join("\n")}\n${tagline ? `${paint("dim", tagline)}\n` : ""}\n`);
34
43
  },
35
44
  intro: title => (interactive ? clack.intro(title, common) : plain(title)),
36
45
  outro: message => (interactive ? clack.outro(message, common) : plain(message)),
@@ -41,11 +50,31 @@ export function createUi(options = {}) {
41
50
  spinner() {
42
51
  if (interactive) {
43
52
  const inner = clack.spinner(common);
53
+ // Clack's spinner holds stdin in raw mode and answers Ctrl+C with process.exit(0),
54
+ // which skips the caller's cleanup and leaves half-written files behind. Handing
55
+ // the key back to the terminal makes it a real SIGINT: the spinner erases itself,
56
+ // the CLI's own handler runs, and a CLI without one still dies as Ctrl+C always has.
57
+ const onInterrupt = () => {
58
+ process.off("SIGINT", onInterrupt);
59
+ inner.clear();
60
+ if (!process.listenerCount("SIGINT"))
61
+ process.kill(process.pid, "SIGINT");
62
+ };
63
+ const end = (finish) => {
64
+ process.off("SIGINT", onInterrupt);
65
+ finish();
66
+ };
44
67
  return {
45
- start: message => inner.start(message),
68
+ start: message => {
69
+ inner.start(message);
70
+ if (process.stdin.isTTY)
71
+ process.stdin.setRawMode(false);
72
+ process.prependListener("SIGINT", onInterrupt);
73
+ },
46
74
  message: message => inner.message(message),
47
- stop: message => inner.stop(message),
48
- error: message => inner.error(message),
75
+ stop: message => end(() => inner.stop(message)),
76
+ error: message => end(() => inner.error(message)),
77
+ clear: () => end(() => inner.clear()),
49
78
  };
50
79
  }
51
80
  // A pipe is read after the fact: only the outcome of a step is worth a line.
@@ -55,9 +84,10 @@ export function createUi(options = {}) {
55
84
  message: message => { running = message; },
56
85
  stop: message => plain(message ?? running),
57
86
  error: message => plain(`error: ${message ?? running}`),
87
+ clear: () => { running = ""; },
58
88
  };
59
89
  },
60
- async select(message, choices) {
90
+ async select(message, choices, selectOptions = {}) {
61
91
  if (!choices.length)
62
92
  throw new CliError("unexpected", `Nothing to choose for "${message}".`);
63
93
  if (!interactive) {
@@ -65,10 +95,33 @@ export function createUi(options = {}) {
65
95
  }
66
96
  // `Option<T>` is conditional on T being primitive, which an open generic cannot resolve.
67
97
  const list = choices.map(c => ({ value: c.value, label: c.label, ...(c.hint ? { hint: c.hint } : {}) }));
68
- const picked = choices.length >= AUTOCOMPLETE_FROM
69
- ? await clack.autocomplete({ message, options: list, placeholder: "Type to filter", maxItems: AUTOCOMPLETE_FROM, ...common })
70
- : await clack.select({ message, options: list, ...common });
71
- return unwrap(picked);
98
+ const initial = selectOptions.initial !== undefined ? { initialValue: selectOptions.initial } : {};
99
+ // Clack reports Escape and Ctrl+C as the same cancel, so the key itself tells them apart.
100
+ let escaped = false;
101
+ const onKey = (_, key) => { escaped = key?.name === "escape"; };
102
+ if (selectOptions.back)
103
+ input.on("keypress", onKey);
104
+ const start = row;
105
+ let picked;
106
+ try {
107
+ picked = selectOptions.search || choices.length >= AUTOCOMPLETE_FROM
108
+ ? await clack.autocomplete({ message, options: list, placeholder: "Type to filter", maxItems: AUTOCOMPLETE_FROM, filter: matchesWords, ...initial, ...common })
109
+ : await clack.select({ message, options: list, ...initial, ...common });
110
+ }
111
+ finally {
112
+ input.off("keypress", onKey);
113
+ }
114
+ if (selectOptions.back && escaped && clack.isCancel(picked)) {
115
+ // The answer above is erased too only when nothing was drawn between it and this step.
116
+ const previous = answered.at(-1)?.end === start ? answered.pop() : undefined;
117
+ const up = row - (previous?.start ?? start);
118
+ if (up > 0)
119
+ drawn.write(`\u001b[${up}A\r\u001b[J`);
120
+ return BACK;
121
+ }
122
+ const value = unwrap(picked);
123
+ answered.push({ start, end: row });
124
+ return value;
72
125
  },
73
126
  async confirm(message, confirmOptions = {}) {
74
127
  if (!interactive)
@@ -117,6 +170,32 @@ export function createUi(options = {}) {
117
170
  },
118
171
  };
119
172
  }
173
+ // "2086 week 5" should find "FIT2086 · Week 5": each typed word on its own, anywhere in
174
+ // the label or hint. A number starts a run of digits, so "5" finds "Week 5" and "2086"
175
+ // finds "FIT2086", but "5" does not find "Week 15".
176
+ function matchesWords(search, option) {
177
+ const text = `${option.label ?? ""} ${option.hint ?? ""}`.toLowerCase();
178
+ return search.toLowerCase().split(/\s+/u).filter(Boolean).every(word => /^\d+$/u.test(word)
179
+ ? new RegExp(`(?<!\\d)${word}`, "u").test(text)
180
+ : text.includes(word));
181
+ }
182
+ // Everything else about the stream (columns, isTTY, resize events) is the real one.
183
+ function trackRows(output, moved) {
184
+ const write = (chunk, ...rest) => {
185
+ for (const match of String(chunk).matchAll(/\u001b\[(\d*)([AB])|\n/gu)) {
186
+ moved(match[0] === "\n" ? 1 : (match[2] === "A" ? -1 : 1) * Number(match[1] || 1));
187
+ }
188
+ return output.write(chunk, ...rest);
189
+ };
190
+ return new Proxy(output, {
191
+ get(target, property) {
192
+ if (property === "write")
193
+ return write;
194
+ const value = Reflect.get(target, property, target);
195
+ return typeof value === "function" ? value.bind(target) : value;
196
+ },
197
+ });
198
+ }
120
199
  /** Ctrl+C or Escape inside a prompt is the person's decision, reported with exit 130 like a signal. */
121
200
  function unwrap(value) {
122
201
  if (clack.isCancel(value))
package/package.json CHANGED
@@ -1,12 +1,16 @@
1
1
  {
2
2
  "name": "@bunizao/cli-kit",
3
- "version": "0.5.1",
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": [