diff --git a/AGENTS.md b/AGENTS.md index 65ebe848..a6508989 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,42 +24,50 @@ User-facing intro: [README.md](README.md). Contributor handbook: | Manifest output | `dist/skills/manifest.json`, `dist/skills/skill-menu.json` (CLI entries live under `cliEntries`) | | Per-skill ZIPs | `dist/skills/.zip` | -## How skills become wizard commands — the `cli:` block - -Every skill's `config.yaml` may declare an optional `cli:` block that tells the -wizard whether and how to expose the skill as a CLI command. It's compiled into -`cliEntries` in `dist/skills/skill-menu.json`, which the wizard fetches at -runtime. **Adding or renaming a skill-backed command is a context-mill release — -no wizard code change.** The full schema, the YAML→command mapping, and the -promotion criterion live in +## How skills become wizard commands (hats) — the `cli:` block + +A skill that surfaces as a typed wizard command is a **hat** — you wear a +different hat to do a different thing. Every skill's `config.yaml` may declare an +optional `cli:` block that tells the wizard whether the skill is a hat (and how +it's named), a plain skill, or internal. It's compiled into `hatEntries` in +`dist/skills/skill-menu.json`, which the wizard fetches at runtime. **Adding or +renaming a hat is a context-mill release — no wizard code change.** The full +schema, the YAML→hat mapping, and the promotion criterion live in [CONTRIBUTING.md](CONTRIBUTING.md#how-skills-get-into-the-wizard-cli). Quick shape: ```yaml cli: - role: command # command | skill | internal - parentCommand: audit # optional — nests this command under another - command: events # the user-typed word; required when role is command + role: hat # hat | skill | internal + parentHat: audit # optional — nests this hat under another + hat: events # the user-typed word; required when role is hat ``` The parser is `parseCliBlock` in `scripts/lib/skill-generator.js`. It enforces: -- `role` is one of `command`, `skill`, `internal` (default: `skill` if no `cli:` block is set at all) -- `command` and `parentCommand` are kebab-case, 2–20 characters +- `role` is one of `hat`, `skill`, `internal` (default: `skill` if no `cli:` block is set at all) +- `hat` and `parentHat` are kebab-case, 2–20 characters - Neither field is a yargs reserved word (`help`, `version`, `completion`) or a wizard internal flag (`playground`, `benchmark`, `yara-report`, `local-mcp`, `ci`, `skill`) - `default` (optional, boolean) marks the leaf `wizard ` runs by default (and pre-highlights it in the picker once a family has several) Failures throw at build time, before drift can ship to the wizard. -**Flat vs. family rule:** a public command is flat when there's only one option +> **Legacy spellings:** the pre-rename keys `role: command`, `command:`, and +> `parentCommand:` are still accepted on input and normalized to `hat` / +> `hat:` / `parentHat:`. New skills should use the `hat` vocabulary. The build +> also still emits a legacy `cliEntries` mirror (old `role: command` shape) +> alongside `hatEntries` so the current wizard keeps resolving; drop it once the +> wizard reads `hatEntries`. + +**Flat vs. family rule:** a public hat is flat when there's only one option today, a family when the user must pick. Don't pre-create `wizard migrate ` while there's only one vendor — restructure to a family when a second lands. See [CONTRIBUTING.md § Flat vs. family](CONTRIBUTING.md#flat-vs-family--the-convention). ### When you're about to change a `cli:` block -1. Read [CONTRIBUTING.md § Promotion criterion for `role: command`](CONTRIBUTING.md#promotion-criterion-for-role-command). +1. Read [CONTRIBUTING.md § Promotion criterion for `role: hat`](CONTRIBUTING.md#promotion-criterion-for-role-hat). 2. Run `npm test` — the parser's suite (`scripts/lib/tests/cli-block.test.js`) covers every naming-convention case. -3. Run `npm run build` — confirm the entry appears (or disappears) under `cliEntries` in `dist/skills/skill-menu.json` with the values you expect. +3. Run `npm run build` — confirm the entry appears (or disappears) under `hatEntries` in `dist/skills/skill-menu.json` with the values you expect. 4. The wizard resolves new entries at runtime, so no wizard release is required unless the change needs wizard-side hooks (custom outro, content blocks, abort cases). ## Wizard CLI command mapping (old → new) @@ -89,7 +97,7 @@ longer exist — only some keep an alias. | `wizard audit web-analytics` | *(wizard-native, not a skill here)* | **Commands vs. skills:** those audit subcommands **are** skills, promoted to -commands via `cli: role: command`. A `role: skill` skill is reachable only via +commands (hats) via `cli: role: hat`. A `role: skill` skill is reachable only via `wizard skill `. Same machinery, two surfaces — so `wizard audit ` picks an audit area, it does **not** take a skill name. @@ -114,10 +122,10 @@ npm run dev # Partial-rebuild dev server with watch ## Repository conventions - Skill content lives in markdown, never in JS/TS. The build pipeline reads YAML configs and stitches markdown together; it doesn't generate prose. -- The `cli:` block is the **single source of truth** for the wizard's command surface for any skill. Don't duplicate command names in the wizard repo; they're derived from the manifest. -- `additionalProperties: false` is set on the JSON Schema — adding a new field to the manifest shape is a coordinated change (bump the schema, bump consumer types in the wizard). See [PostHog/wizard CONTRIBUTING.md](https://github.com/PostHog/wizard/blob/main/CONTRIBUTING.md) for the wizard-side contract. +- The `cli:` block is the **single source of truth** for the wizard's command surface for any skill. Don't duplicate hat names in the wizard repo; they're derived from the manifest. +- `additionalProperties: false` is set on the JSON Schema — adding a new field to the manifest shape (e.g. the `hatEntries` array) is a coordinated change (bump the schema, bump consumer types in the wizard). See [PostHog/wizard CONTRIBUTING.md](https://github.com/PostHog/wizard/blob/main/CONTRIBUTING.md) for the wizard-side contract. ## Companion projects -- **[wizard](https://github.com/PostHog/wizard)** — the CLI that consumes the manifest at build time and turns each `role: command` entry into a registered command. +- **[wizard](https://github.com/PostHog/wizard)** — the CLI that consumes the manifest at build time and turns each `role: hat` entry into a registered command. - **[warlock](https://github.com/PostHog/warlock)** — the security scanner used by the wizard. Unrelated to skill content but lives alongside in the same engineering scope. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 25b69ee9..44ac3681 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,46 +8,54 @@ manifest, see the [README](README.md). Every skill ships with a `config.yaml`. An optional `cli:` block on that config tells the PostHog wizard whether and how this skill appears as a -command. The block is parsed in `scripts/lib/skill-generator.js` and -emitted as `cliEntries` inside `dist/skills/skill-menu.json`. The wizard -fetches `skill-menu.json` at runtime and registers each entry as a -command, so adding a new skill-backed command is a context-mill release +**hat** — a typed wizard command (you wear a different hat to do a different +thing). The block is parsed in `scripts/lib/skill-generator.js` and +emitted as `hatEntries` inside `dist/skills/skill-menu.json`. The wizard +fetches `skill-menu.json` at runtime and registers each hat as a +command, so adding a new hat is a context-mill release — no wizard release needed. +> **Legacy spellings:** the pre-rename keys `role: command`, `command:`, and +> `parentCommand:` are still accepted on input and normalized to `hat` / +> `hat:` / `parentHat:`. The build also emits a legacy `cliEntries` mirror +> (old `role: command` shape) next to `hatEntries` so the current wizard keeps +> resolving; both go away once the wizard reads `hatEntries`. Write new skills +> with the `hat` vocabulary below. + ### The `cli:` block schema ```yaml type: docs-only description: Audit captured events cli: - role: command # command | skill | internal - parentCommand: audit # the command this skill nests under (optional) - command: events # the user-typed word; required when role is command - default: true # optional — the leaf `wizard ` runs by default + role: hat # hat | skill | internal + parentHat: audit # the hat this skill nests under (optional) + hat: events # the user-typed word; required when role is hat + default: true # optional — the leaf `wizard ` runs by default ``` Three values for `role`: | Role | Where it shows up | |---|---| -| `command` | Registered as `wizard ` (or `wizard ` if no parent). The user-facing CLI. | +| `hat` | Registered as `wizard ` (or `wizard ` if no parent). The user-facing CLI command. | | `skill` | Reachable only via `wizard skill `. The full discoverable set. | | `internal` | Hidden everywhere. Only reachable via `wizard --skill=` (a dev escape hatch). Useful for in-progress skills that aren't ready to expose. | Skills with **no** `cli:` block default to the `skill` role — they're discoverable via `wizard skill list` but don't get a top-level command. -The same skill can be either surface: `audit-events` sets `role: command` so it's +The same skill can be either surface: `audit-events` sets `role: hat` so it's `wizard audit events`; a `role: skill` skill is only `wizard skill `. One mechanism, two surfaces — so `wizard audit ` chooses an audit area, it does **not** take a skill name. ### Flat vs. family — the convention -> A command is **flat** when there's only one option today, +> A hat is **flat** when there's only one option today, > **a family** when the user must pick among multiple distinct things. -Don't pre-create a family form for a single-option command. If only one +Don't pre-create a family form for a single-option hat. If only one migration vendor exists, the command is `wizard migrate` — not `wizard migrate statsig`. When a second vendor arrives, restructure to a family at that moment and document the UX change in the wizard's @@ -55,23 +63,23 @@ release notes. Forced abstraction (`wizard migrate ` with one vendor) is worse than the breaking change you'd cause later — that change is real and worth notifying users about explicitly. -### Migrating a flat command into a family +### Migrating a flat hat into a family -When a flat command needs to grow into a family (a second option arrived, +When a flat hat needs to grow into a family (a second option arrived, the original is being renamed, etc.), the `cli:` block restructures like this: ```yaml -# Before — flat command. Registers `wizard investigate`. +# Before — flat hat. Registers `wizard investigate`. cli: - role: command - command: investigate + role: hat + hat: investigate # After — family with a subcommand. Registers `wizard investigate events`. cli: - role: command - parentCommand: investigate - command: events + role: hat + parentHat: investigate + hat: events ``` **This is a breaking change for users.** Anyone scripting the old flat @@ -87,7 +95,7 @@ family picker. Treat this exactly like any other breaking CLI change: family, mark that leaf `default: true` so `wizard investigate` → Enter still runs the intended action with one keystroke. -Going the other way — collapsing a family back to a flat command — works +Going the other way — collapsing a family back to a flat hat — works the same way and is also a breaking change. Don't do it casually. ### Naming rule — no shorthand for product names @@ -110,22 +118,22 @@ to relearn an abbreviation we changed our mind on later is not. ### Mapping table — YAML on the left, registered command on the right ```yaml -# 1. Flat command (single option today) +# 1. Flat hat (single option today) cli: → wizard revenue-analytics - role: command - command: revenue-analytics + role: hat + hat: revenue-analytics -# 2. Nested command inside an existing family +# 2. Nested hat inside an existing family cli: → wizard audit feature-flags - role: command - parentCommand: audit - command: feature-flags + role: hat + parentHat: audit + hat: feature-flags # 3. Default leaf — what `wizard audit` runs with no subcommand cli: → wizard audit all - role: command `wizard audit` runs - parentCommand: audit this leaf by default - command: all (and pre-highlights it + role: hat `wizard audit` runs + parentHat: audit this leaf by default + hat: all (and pre-highlights it default: true in the picker later). # 4. Skill-only (reachable via `wizard skill `) @@ -135,9 +143,9 @@ cli: → wizard skill ` should get by default. At most one leaf per family should be marked. -## Promotion criterion for `role: command` +## Promotion criterion for `role: hat` -The wizard's command surface is **curated, not inclusive**. Every command +The wizard's command surface is **curated, not inclusive**. Every hat is one we're willing to teach in our docs, announce, and support for end users — not just every skill we've authored. -A skill should be promoted to `role: command` when **all** of these are +A skill should be promoted to `role: hat` when **all** of these are true: 1. **It's user-facing, not infrastructure.** The skill represents a setup, @@ -173,16 +181,16 @@ true: users. If the skill is still iterating on what it does or how it prompts the agent, ship it as `role: skill` first. Promote when the shape has held for a release or two. -4. **It plays well with the family it lives in.** If `parentCommand: +4. **It plays well with the family it lives in.** If `parentHat: audit`, the skill should slot alongside the other audits at the same level of abstraction. Don't put a one-off in an existing family just because the words overlap. 5. **A wizard maintainer has reviewed the role change.** Adding to the command surface is a permanent commitment to that name. Loop in the wizard - docs team / maintainers on PRs that change a skill to `role: command`. + docs team / maintainers on PRs that change a skill to `role: hat`. -When in doubt, ship as `role: skill`. Promoting from skill to command is -cheap; demoting from command to skill breaks user scripts. +When in doubt, ship as `role: skill`. Promoting from skill to hat is +cheap; demoting from hat to skill breaks user scripts. ## Adding a new skill @@ -192,21 +200,21 @@ The base path is the same regardless of the skill's CLI role: 2. Add a `config.yaml` declaring `type`, `description`, `variants`, etc. See an existing skill (e.g. `audit-events`, `migrate`) for the shape. 3. Add a `description.md` template and any `references/*.md` files. -4. If the skill should be a wizard command, add a `cli:` block per the - schema above. +4. If the skill should be a wizard command, add a `cli:` block (a hat) per + the schema above. 5. Run `npm test && npm run build`. The build emits the new skill into `dist/skills/.zip` and lists it in the manifest. -## Adding a new command +## Adding a new hat -When you've decided your skill meets the `role: command` criterion: +When you've decided your skill meets the `role: hat` criterion: 1. Add the `cli:` block to the skill's `config.yaml` with `role: - command`, the right `parentCommand` (if it nests under an existing - family), and `command`. -2. Confirm `npm run build` emits the entry under `cliEntries` inside - `dist/skills/skill-menu.json` with the right `parentCommand` / - `command` values. The wizard picks it up on its next invocation + hat`, the right `parentHat` (if it nests under an existing + family), and `hat`. +2. Confirm `npm run build` emits the entry under `hatEntries` inside + `dist/skills/skill-menu.json` with the right `parentHat` / + `hat` values. The wizard picks it up on its next invocation (no wizard release needed). 3. No wizard PR is needed for skill-backed public commands. If you also need wizard-side hooks (custom outro, content blocks, abort cases), @@ -232,7 +240,8 @@ manifest is published before the wizard tries to consume it. - Skill schema details: `scripts/lib/skill-generator.js` (`parseCliBlock`, `expandSkillGroups`, JSDoc typedef for the `cli:` block) -- CLI entries emit: `scripts/lib/build-phases.js` (`generateCliEntries`) +- Hat entries emit: `scripts/lib/build-phases.js` (`generateHatEntries`, plus + `toLegacyCliEntries` for the back-compat `cliEntries` mirror) - Tests for the cli block parser: `scripts/lib/tests/cli-block.test.js` - The wizard's side of the contract: [PostHog/wizard CONTRIBUTING.md](https://github.com/PostHog/wizard/blob/main/CONTRIBUTING.md) diff --git a/README.md b/README.md index b4ec4a37..2601b2ee 100644 --- a/README.md +++ b/README.md @@ -92,8 +92,9 @@ The build script automatically discovers, orders, and generates URIs for all res ## Wizard CLI commands Skills in this repo declare how they surface as wizard commands via a `cli:` -block in their `config.yaml`. That mechanism — `role`, `parentCommand`, -`command`, flat vs. family — is documented in +block in their `config.yaml`. A skill promoted to a typed command is a **hat** +(you wear a different hat to do a different thing). That mechanism — `role`, +`parentHat`, `hat`, flat vs. family — is documented in [`CONTRIBUTING.md`](CONTRIBUTING.md#how-skills-get-into-the-wizard-cli). The CLI was overhauled to consolidate commands into a smaller, extensible @@ -123,7 +124,7 @@ surface. If you (or your agent) knew an older command, here's where it went: | `wizard audit web-analytics` | *(wizard-native, not a skill in this repo)* | > **Commands vs. skills:** those audit subcommands **are** skills, promoted to -> commands via `cli: role: command`. A skill with `role: skill` is reachable only +> commands (hats) via `cli: role: hat`. A skill with `role: skill` is reachable only > through `wizard skill `. Same machinery, two surfaces — so > `wizard audit ` picks an audit area, it does **not** take a skill > name. diff --git a/context/skills/audit-autocapture/config.yaml b/context/skills/audit-autocapture/config.yaml index 261ce7ec..01b1cdab 100644 --- a/context/skills/audit-autocapture/config.yaml +++ b/context/skills/audit-autocapture/config.yaml @@ -3,9 +3,9 @@ template: description.md description: Audit a PostHog autocapture setup for correctness and cost-optimization opportunities tags: [best-practices] cli: - role: command - parentCommand: audit - command: autocapture + role: hat + parentHat: audit + hat: autocapture references: preamble: "**Read ONLY this file.** Do not read any other reference file until this one tells you to." shared_docs: diff --git a/context/skills/audit-events/config.yaml b/context/skills/audit-events/config.yaml index 196ce206..ff69e637 100644 --- a/context/skills/audit-events/config.yaml +++ b/context/skills/audit-events/config.yaml @@ -3,9 +3,9 @@ template: description.md description: Audit a PostHog integration's event capture quality and cost-optimization opportunities tags: [best-practices] cli: - role: command - parentCommand: audit - command: events + role: hat + parentHat: audit + hat: events references: preamble: "**Read ONLY this file.** Do not read any other reference file until this one tells you to." shared_docs: diff --git a/context/skills/audit-feature-flags/config.yaml b/context/skills/audit-feature-flags/config.yaml index a76ec32e..0209682d 100644 --- a/context/skills/audit-feature-flags/config.yaml +++ b/context/skills/audit-feature-flags/config.yaml @@ -3,9 +3,9 @@ template: description.md description: Audit a PostHog integration's feature flag usage for correctness and cost-optimization opportunities tags: [best-practices] cli: - role: command - parentCommand: audit - command: feature-flags + role: hat + parentHat: audit + hat: feature-flags references: preamble: "**Read ONLY this file.** Do not read any other reference file until this one tells you to." shared_docs: diff --git a/context/skills/audit-identify/config.yaml b/context/skills/audit-identify/config.yaml index 66c30f9d..0725842f 100644 --- a/context/skills/audit-identify/config.yaml +++ b/context/skills/audit-identify/config.yaml @@ -3,9 +3,9 @@ template: description.md description: Audit a PostHog integration's $identify implementation for correctness and cost-optimization opportunities tags: [best-practices] cli: - role: command - parentCommand: audit - command: identify + role: hat + parentHat: audit + hat: identify references: preamble: "**Read ONLY this file.** Do not read any other reference file until this one tells you to." shared_docs: diff --git a/context/skills/audit-session-replay/config.yaml b/context/skills/audit-session-replay/config.yaml index d9e63324..28969483 100644 --- a/context/skills/audit-session-replay/config.yaml +++ b/context/skills/audit-session-replay/config.yaml @@ -3,9 +3,9 @@ template: description.md description: Audit a PostHog session replay setup for correctness and cost-optimization opportunities tags: [best-practices] cli: - role: command - parentCommand: audit - command: session-replay + role: hat + parentHat: audit + hat: session-replay references: preamble: "**Read ONLY this file.** Do not read any other reference file until this one tells you to." shared_docs: diff --git a/context/skills/audit/config.yaml b/context/skills/audit/config.yaml index 45a7cb42..fb0eab08 100644 --- a/context/skills/audit/config.yaml +++ b/context/skills/audit/config.yaml @@ -3,9 +3,9 @@ template: description.md description: Audit an existing PostHog integration for correctness and best practices tags: [best-practices] cli: - role: command - parentCommand: audit - command: all + role: hat + parentHat: audit + hat: all default: true references: preamble: "**Read ONLY this file.** Do not read any other reference file until this one tells you to." diff --git a/context/skills/migrate/config.yaml b/context/skills/migrate/config.yaml index c56409d5..111350ea 100644 --- a/context/skills/migrate/config.yaml +++ b/context/skills/migrate/config.yaml @@ -4,8 +4,8 @@ category: migrate description: Migrate an existing analytics or feature-flag vendor to PostHog. Replaces SDK call sites in-place, removes the source package, and writes a migration report. Replacement-only, doesn't adds new instrumentation. tags: [migration] cli: - role: command - command: migrate + role: hat + hat: migrate references: preamble: "**Read ONLY this file.** Do not read any other reference file until this one tells you to." shared_docs: [] diff --git a/context/skills/revenue-analytics/config.yaml b/context/skills/revenue-analytics/config.yaml index 1a595620..56d0a556 100644 --- a/context/skills/revenue-analytics/config.yaml +++ b/context/skills/revenue-analytics/config.yaml @@ -4,8 +4,8 @@ template: description.md description: Set up Stripe revenue analytics with PostHog tags: [revenue-analytics, stripe] cli: - role: command - command: revenue-analytics + role: hat + hat: revenue-analytics shared_docs: - https://posthog.com/docs/revenue-analytics/connect-to-customers.md - https://posthog.com/docs/getting-started/identify-users.md diff --git a/scripts/lib/build-phases.js b/scripts/lib/build-phases.js index c886556f..7b0ce035 100644 --- a/scripts/lib/build-phases.js +++ b/scripts/lib/build-phases.js @@ -192,16 +192,20 @@ function writeManifestAndMenu({ allSkills, docContents, distDir, configDir, vers }); } - // The CLI entries are the lookup table the wizard's runtime resolver uses - // (parentCommand + command -> skillId). They live inside skill-menu.json - // so the wizard can reach them through the existing fetchSkillMenu path. - const cliEntries = generateCliEntries({ allSkills }); + // The hat entries are the lookup table the wizard's runtime resolver uses + // (parentHat + hat -> skillId). They live inside skill-menu.json so the + // wizard can reach them through the existing fetchSkillMenu path. + const hatEntries = generateHatEntries({ allSkills }); const skillMenu = { version: manifest.version, buildVersion: manifest.buildVersion, categories: skillsByCategory, - cliEntries, + hatEntries, + // Back-compat: the wizard still reads `cliEntries` with the pre-rename + // `role: command` shape. Mirror it until the wizard migrates to + // `hatEntries`, then drop this. + cliEntries: toLegacyCliEntries(hatEntries), }; fs.writeFileSync(path.join(skillsDir, 'skill-menu.json'), JSON.stringify(skillMenu, null, 2)); @@ -209,9 +213,9 @@ function writeManifestAndMenu({ allSkills, docContents, distDir, configDir, vers } /** - * Build the CLI entries array from the expanded skill list. Used by + * Build the hat entries array from the expanded skill list. Used by * `writeManifestAndMenu` (which embeds the result in `skill-menu.json` - * under `cliEntries`) and exercised directly by tests. Throws on an + * under `hatEntries`) and exercised directly by tests. Throws on an * invalid `default:` arrangement (see `validateDefault`) so the * build fails before bad data reaches the wizard. * @@ -220,14 +224,14 @@ function writeManifestAndMenu({ allSkills, docContents, distDir, configDir, vers * `manifest.json`) and are not emitted here. * * Entry shape: - * { skillId, role, command?, parentCommand?, default?, displayName, description } + * { skillId, role, hat?, parentHat?, default?, displayName, description } * - * Entries are sorted by role (command first, then skill, then internal), - * then by `parentCommand`/`command` so diffs in `skill-menu.json` stay + * Entries are sorted by role (hat first, then skill, then internal), + * then by `parentHat`/`hat` so diffs in `skill-menu.json` stay * reviewable. */ -function generateCliEntries({ allSkills }) { - const roleOrder = { command: 0, skill: 1, internal: 2 }; +function generateHatEntries({ allSkills }) { + const roleOrder = { hat: 0, skill: 1, internal: 2 }; const entries = allSkills .filter(s => s.cli) .map(s => { @@ -235,8 +239,8 @@ function generateCliEntries({ allSkills }) { skillId: s.id, role: s.cli.role, }; - if (s.cli.parentCommand) entry.parentCommand = s.cli.parentCommand; - if (s.cli.command) entry.command = s.cli.command; + if (s.cli.parentHat) entry.parentHat = s.cli.parentHat; + if (s.cli.hat) entry.hat = s.cli.hat; if (s.cli.default) entry.default = true; entry.displayName = s.displayName; entry.description = s.description; @@ -245,17 +249,38 @@ function generateCliEntries({ allSkills }) { .sort((a, b) => { const roleDiff = roleOrder[a.role] - roleOrder[b.role]; if (roleDiff !== 0) return roleDiff; - const parentDiff = (a.parentCommand || '').localeCompare(b.parentCommand || ''); + const parentDiff = (a.parentHat || '').localeCompare(b.parentHat || ''); if (parentDiff !== 0) return parentDiff; - return (a.command || '').localeCompare(b.command || ''); + return (a.hat || '').localeCompare(b.hat || ''); }); validateDefault(entries); return entries; } +/** + * Project the canonical hat entries back into the pre-rename `cliEntries` + * shape (`role: command`, `command`, `parentCommand`). Kept so the wizard, + * which still reads `cliEntries`, keeps resolving until it migrates to + * `hatEntries`. Drop this once the wizard reads `hatEntries` directly. + */ +function toLegacyCliEntries(hatEntries) { + return hatEntries.map(e => { + const legacy = { + skillId: e.skillId, + role: e.role === 'hat' ? 'command' : e.role, + }; + if (e.parentHat) legacy.parentCommand = e.parentHat; + if (e.hat) legacy.command = e.hat; + if (e.default) legacy.default = true; + legacy.displayName = e.displayName; + legacy.description = e.description; + return legacy; + }); +} + /** * Enforce the `default:` rules: at most one default leaf per family - * (grouped by `parentCommand`), and no `default` without a `parentCommand` + * (grouped by `parentHat`), and no `default` without a `parentHat` * (nothing to highlight). Checked here because a family spans multiple skill * directories. Throws naming the offending `skillId`s. */ @@ -263,19 +288,19 @@ function validateDefault(entries) { const defaultByParent = new Map(); for (const entry of entries) { if (!entry.default) continue; - if (!entry.parentCommand) { + if (!entry.parentHat) { throw new Error( - `cli.default is only valid on a leaf inside a family (a command with a parentCommand); "${entry.skillId}" sets default but has no parentCommand`, + `cli.default is only valid on a leaf inside a family (a hat with a parentHat); "${entry.skillId}" sets default but has no parentHat`, ); } - const siblings = defaultByParent.get(entry.parentCommand) || []; + const siblings = defaultByParent.get(entry.parentHat) || []; siblings.push(entry.skillId); - defaultByParent.set(entry.parentCommand, siblings); + defaultByParent.set(entry.parentHat, siblings); } - for (const [parentCommand, skillIds] of defaultByParent) { + for (const [parentHat, skillIds] of defaultByParent) { if (skillIds.length > 1) { throw new Error( - `Family "${parentCommand}" has more than one cli.default leaf (${skillIds.join(', ')}); at most one is allowed`, + `Family "${parentHat}" has more than one cli.default leaf (${skillIds.join(', ')}); at most one is allowed`, ); } } @@ -365,7 +390,8 @@ export { zipSkillToBuffer, createBundledArchive, generateManifest, - generateCliEntries, + generateHatEntries, + toLegacyCliEntries, writeManifestAndMenu, reconcileOrphans, partialRebuild, diff --git a/scripts/lib/cli-block-validation.js b/scripts/lib/cli-block-validation.js index 7b20bf70..25f79df2 100644 --- a/scripts/lib/cli-block-validation.js +++ b/scripts/lib/cli-block-validation.js @@ -6,7 +6,12 @@ * the rationale. Failures throw at build time, before drift can ship. */ -export const CLI_ROLES = ['command', 'skill', 'internal']; +export const CLI_ROLES = ['hat', 'skill', 'internal']; + +// Pre-"hats" role spellings still accepted on input and normalized to the +// canonical value. Keeps older `config.yaml` blocks (and anything mid-migration) +// building. See parseCliBlock in skill-generator.js. +export const LEGACY_ROLE_ALIASES = { command: 'hat' }; const KEBAB_CASE = /^[a-z][a-z0-9-]*$/; const NAME_MIN_LENGTH = 2; @@ -28,14 +33,14 @@ const INTERNAL_FLAG_NAMES = new Set([ ]); /** - * Validate a `command` / `parentCommand` value: kebab-case, length 2–20, no + * Validate a `hat` / `parentHat` value: kebab-case, length 2–20, no * yargs reserved words, no wizard internal-flag collisions. Throws on failure. * * @param {string} name * @param {string} field the cli-block field being checked (for error text) * @param {string} context human-readable label for error messages */ -export function validateCommandName(name, field, context) { +export function validateHatName(name, field, context) { if (name.length < NAME_MIN_LENGTH || name.length > NAME_MAX_LENGTH) { throw new Error( `${context}: cli.${field} "${name}" must be ${NAME_MIN_LENGTH}–${NAME_MAX_LENGTH} characters`, diff --git a/scripts/lib/skill-generator.js b/scripts/lib/skill-generator.js index c766669b..6d4f08d6 100644 --- a/scripts/lib/skill-generator.js +++ b/scripts/lib/skill-generator.js @@ -9,26 +9,32 @@ /** * Optional `cli:` block in a skill's `config.yaml` — declares whether and how - * the skill appears in the wizard CLI. Parsed by `parseCliBlock`, propagated by - * `expandSkillGroups`, emitted into `dist/skills/cli-manifest.json` (the wizard - * snapshots that manifest to derive its skill-backed command surface). + * the skill appears in the wizard CLI. A skill is a **hat** (a typed wizard + * command — you wear a different hat to do a different thing), a plain `skill`, + * or `internal`. Parsed by `parseCliBlock`, propagated by `expandSkillGroups`, + * emitted into `dist/skills/skill-menu.json` (the wizard snapshots that manifest + * to derive its skill-backed command surface). * - * Full schema, the YAML→command mapping, the flat-vs-family convention, and the + * Full schema, the YAML→hat mapping, the flat-vs-family convention, and the * naming rules live in CONTRIBUTING.md § "How skills get into the wizard CLI". * + * Legacy note: the pre-rename spellings `role: command`, `command:`, and + * `parentCommand:` are still accepted on input and normalized to `hat` / + * `hat:` / `parentHat:`. + * * @typedef {Object} CliRoleBlock - * @property {'command' | 'skill' | 'internal'} role - * How the skill appears: a typed `command`, a `skill` reachable via - * `wizard skill `, or `internal` (hidden). Skills with no `cli:` block - * default to `skill` and are not emitted into `cli-manifest.json`. - * @property {string} [command] + * @property {'hat' | 'skill' | 'internal'} role + * How the skill appears: a `hat` (typed wizard command), a `skill` reachable + * via `wizard skill `, or `internal` (hidden). Skills with no `cli:` block + * default to `skill` and are not emitted as hats. + * @property {string} [hat] * The user-typed word that registers this skill (e.g. `'feature-flags'` in - * `wizard audit feature-flags`). Required when `role` is `'command'`; + * `wizard audit feature-flags`). Required when `role` is `'hat'`; * defaults to the variant id when omitted, except the magic `id: all` - * variant, which requires an explicit `command`. Use the full PostHog + * variant, which requires an explicit `hat`. Use the full PostHog * product name, not a shorthand. - * @property {string} [parentCommand] - * The command this skill nests under (e.g. `'audit'`). Omit for flat commands. + * @property {string} [parentHat] + * The hat this skill nests under (e.g. `'audit'`). Omit for flat hats. * @property {boolean} [default] * When true, this leaf is pre-highlighted in the family's interactive picker * (`wizard ` → Enter runs it). The picker still opens (discovery + @@ -41,7 +47,7 @@ import path from 'path'; import yaml from 'js-yaml'; import matter from 'gray-matter'; import { processExample, loadSkipPatterns, mergeSkipPatterns, defaultPlugins } from './example-processor.js'; -import { CLI_ROLES, validateCommandName } from './cli-block-validation.js'; +import { CLI_ROLES, LEGACY_ROLE_ALIASES, validateHatName } from './cli-block-validation.js'; /** * Load YAML config file @@ -56,8 +62,11 @@ function loadYaml(configPath) { * Returns `null` when the block is absent, throws on malformed input. * * Naming-convention checks (kebab-case, length 2–20, no reserved words, - * no internal-flag collisions) run on every `command` and `parentCommand` - * value before the resolved block is returned. + * no internal-flag collisions) run on the `hat` and `parentHat` values + * before the resolved block is returned. + * + * The pre-rename spellings (`role: command`, `command:`, `parentCommand:`) are + * accepted as aliases and normalized to `hat` / `hat:` / `parentHat:`. * * `context` is a human-readable label used in error messages (e.g. * `'Skill group "audit-events"'` or @@ -65,38 +74,49 @@ function loadYaml(configPath) { * * @param {unknown} raw * @param {string} context - * @returns {{ role: 'command' | 'skill' | 'internal', command?: string, parentCommand?: string, default?: boolean } | null} + * @returns {{ role: 'hat' | 'skill' | 'internal', hat?: string, parentHat?: string, default?: boolean } | null} */ function parseCliBlock(raw, context) { if (raw == null) return null; if (typeof raw !== 'object' || Array.isArray(raw)) { throw new Error(`${context}: cli block must be an object`); } - const { role, command, parentCommand, default: isDefault, ...rest } = raw; + const { + role: rawRole, + hat, + parentHat, + command, // legacy alias for `hat` + parentCommand, // legacy alias for `parentHat` + default: isDefault, + ...rest + } = raw; const unknownKeys = Object.keys(rest); if (unknownKeys.length > 0) { throw new Error(`${context}: cli block has unknown keys: ${unknownKeys.join(', ')}`); } - if (!role) { + if (!rawRole) { throw new Error(`${context}: cli.role is required`); } + const role = LEGACY_ROLE_ALIASES[rawRole] ?? rawRole; if (!CLI_ROLES.includes(role)) { - throw new Error(`${context}: cli.role must be one of ${CLI_ROLES.join(', ')} (got "${role}")`); + throw new Error(`${context}: cli.role must be one of ${CLI_ROLES.join(', ')} (got "${rawRole}")`); } + const hatName = hat ?? command; + const parentHatName = parentHat ?? parentCommand; const result = { role }; - if (command != null) { - if (typeof command !== 'string' || command.length === 0) { - throw new Error(`${context}: cli.command must be a non-empty string when set`); + if (hatName != null) { + if (typeof hatName !== 'string' || hatName.length === 0) { + throw new Error(`${context}: cli.hat must be a non-empty string when set`); } - validateCommandName(command, 'command', context); - result.command = command; + validateHatName(hatName, 'hat', context); + result.hat = hatName; } - if (parentCommand != null) { - if (typeof parentCommand !== 'string' || parentCommand.length === 0) { - throw new Error(`${context}: cli.parentCommand must be a non-empty string when set`); + if (parentHatName != null) { + if (typeof parentHatName !== 'string' || parentHatName.length === 0) { + throw new Error(`${context}: cli.parentHat must be a non-empty string when set`); } - validateCommandName(parentCommand, 'parentCommand', context); - result.parentCommand = parentCommand; + validateHatName(parentHatName, 'parentHat', context); + result.parentHat = parentHatName; } if (isDefault != null) { if (typeof isDefault !== 'boolean') { @@ -109,13 +129,13 @@ function parseCliBlock(raw, context) { /** * Merge a group-level cli block with a variant-level override and fill in - * the implicit command name for the `command` role. Returns `null` when + * the implicit hat name for the `hat` role. Returns `null` when * neither level declared a block. * - * For `role: 'command'`, the command name falls back to the variant's - * short id (e.g. parentCommand `migrate` + variant `statsig` → + * For `role: 'hat'`, the hat name falls back to the variant's + * short id (e.g. parentHat `migrate` + variant `statsig` → * `wizard migrate statsig`). The `id: 'all'` variant is special — its - * skill id collapses to the group key, so the command name has to be + * skill id collapses to the group key, so the hat name has to be * set explicitly at the group level. * * @param {ReturnType} groupCli @@ -126,19 +146,19 @@ function parseCliBlock(raw, context) { function resolveVariantCli(groupCli, variantCli, variant, groupKey) { if (!groupCli && !variantCli) return null; const merged = { ...(groupCli ?? {}), ...(variantCli ?? {}) }; - if (merged.role === 'command' && !merged.command) { + if (merged.role === 'hat' && !merged.hat) { if (variant.id === 'all') { throw new Error( - `Skill group "${groupKey}", variant "all": cli.command is required at the group level when role is command and the variant id is "all"`, + `Skill group "${groupKey}", variant "all": cli.hat is required at the group level when role is hat and the variant id is "all"`, ); } - merged.command = variant.id; + merged.hat = variant.id; // The fallback value bypassed parseCliBlock's checks, so validate it // here too — a variant id like "help" or "CamelCase" must not slip - // through into the manifest just because it wasn't typed as a command. - validateCommandName( - merged.command, - 'command', + // through into the manifest just because it wasn't typed as a hat. + validateHatName( + merged.hat, + 'hat', `Skill group "${groupKey}", variant "${variant.id}"`, ); } diff --git a/scripts/lib/tests/cli-block.test.js b/scripts/lib/tests/cli-block.test.js index 53187e1e..6f51569e 100644 --- a/scripts/lib/tests/cli-block.test.js +++ b/scripts/lib/tests/cli-block.test.js @@ -9,7 +9,7 @@ import { expandSkillGroups, serializeSkill, } from '../skill-generator.js'; -import { generateCliEntries } from '../build-phases.js'; +import { generateHatEntries, toLegacyCliEntries } from '../build-phases.js'; function createFixture(tree, baseDir) { for (const [name, content] of Object.entries(tree)) { @@ -29,22 +29,22 @@ describe('parseCliBlock', () => { expect(parseCliBlock(null, 'ctx')).toBeNull(); }); - it('accepts a minimal command block with parentCommand and command', () => { + it('accepts a minimal hat block with parentHat and hat', () => { const result = parseCliBlock( - { role: 'command', parentCommand: 'audit', command: 'events' }, + { role: 'hat', parentHat: 'audit', hat: 'events' }, 'ctx', ); - expect(result).toEqual({ role: 'command', parentCommand: 'audit', command: 'events' }); + expect(result).toEqual({ role: 'hat', parentHat: 'audit', hat: 'events' }); }); - it('accepts a flat command block with only command', () => { - expect(parseCliBlock({ role: 'command', command: 'revenue' }, 'ctx')).toEqual({ - role: 'command', - command: 'revenue', + it('accepts a flat hat block with only hat', () => { + expect(parseCliBlock({ role: 'hat', hat: 'revenue' }, 'ctx')).toEqual({ + role: 'hat', + hat: 'revenue', }); }); - it('accepts a skill block with no command/parentCommand', () => { + it('accepts a skill block with no hat/parentHat', () => { expect(parseCliBlock({ role: 'skill' }, 'ctx')).toEqual({ role: 'skill' }); }); @@ -53,7 +53,7 @@ describe('parseCliBlock', () => { }); it('throws when role is missing', () => { - expect(() => parseCliBlock({ command: 'events' }, 'ctx')).toThrow(/cli\.role is required/); + expect(() => parseCliBlock({ hat: 'events' }, 'ctx')).toThrow(/cli\.role is required/); }); it('throws on an unknown role value', () => { @@ -61,68 +61,83 @@ describe('parseCliBlock', () => { }); it('rejects non-object inputs', () => { - expect(() => parseCliBlock('command', 'ctx')).toThrow(/must be an object/); - expect(() => parseCliBlock(['command'], 'ctx')).toThrow(/must be an object/); + expect(() => parseCliBlock('hat', 'ctx')).toThrow(/must be an object/); + expect(() => parseCliBlock(['hat'], 'ctx')).toThrow(/must be an object/); }); - it('rejects empty-string command or parentCommand', () => { - expect(() => parseCliBlock({ role: 'command', command: '' }, 'ctx')).toThrow(/cli\.command must be a non-empty string/); - expect(() => parseCliBlock({ role: 'command', parentCommand: '' }, 'ctx')).toThrow(/cli\.parentCommand must be a non-empty string/); + it('rejects empty-string hat or parentHat', () => { + expect(() => parseCliBlock({ role: 'hat', hat: '' }, 'ctx')).toThrow(/cli\.hat must be a non-empty string/); + expect(() => parseCliBlock({ role: 'hat', parentHat: '' }, 'ctx')).toThrow(/cli\.parentHat must be a non-empty string/); }); it('rejects unknown keys in the block', () => { - expect(() => parseCliBlock({ role: 'command', command: 'events', extra: true }, 'ctx')).toThrow(/unknown keys: extra/); + expect(() => parseCliBlock({ role: 'hat', hat: 'events', extra: true }, 'ctx')).toThrow(/unknown keys: extra/); + }); + + describe('legacy command spellings', () => { + it('normalizes role: command to role: hat', () => { + expect(parseCliBlock({ role: 'command', command: 'events' }, 'ctx')).toEqual({ + role: 'hat', + hat: 'events', + }); + }); + + it('normalizes command/parentCommand to hat/parentHat', () => { + expect( + parseCliBlock({ role: 'command', parentCommand: 'audit', command: 'events' }, 'ctx'), + ).toEqual({ role: 'hat', parentHat: 'audit', hat: 'events' }); + }); }); describe('naming convention enforcement', () => { - it('rejects non-kebab-case command names', () => { - expect(() => parseCliBlock({ role: 'command', command: 'CamelCase' }, 'ctx')) + it('rejects non-kebab-case hat names', () => { + expect(() => parseCliBlock({ role: 'hat', hat: 'CamelCase' }, 'ctx')) .toThrow(/must be kebab-case/); - expect(() => parseCliBlock({ role: 'command', command: 'snake_case' }, 'ctx')) + expect(() => parseCliBlock({ role: 'hat', hat: 'snake_case' }, 'ctx')) .toThrow(/must be kebab-case/); - expect(() => parseCliBlock({ role: 'command', command: '1leading-digit' }, 'ctx')) + expect(() => parseCliBlock({ role: 'hat', hat: '1leading-digit' }, 'ctx')) .toThrow(/must be kebab-case/); }); - it('rejects too-short command names', () => { - expect(() => parseCliBlock({ role: 'command', command: 'a' }, 'ctx')) + it('rejects too-short hat names', () => { + expect(() => parseCliBlock({ role: 'hat', hat: 'a' }, 'ctx')) .toThrow(/must be 2–20 characters/); }); - it('rejects too-long command names', () => { + it('rejects too-long hat names', () => { const longName = 'a-very-very-very-long-name'; - expect(() => parseCliBlock({ role: 'command', command: longName }, 'ctx')) + expect(() => parseCliBlock({ role: 'hat', hat: longName }, 'ctx')) .toThrow(/must be 2–20 characters/); }); it('rejects yargs reserved words', () => { for (const word of ['help', 'version', 'completion']) { - expect(() => parseCliBlock({ role: 'command', command: word }, 'ctx')) + expect(() => parseCliBlock({ role: 'hat', hat: word }, 'ctx')) .toThrow(/yargs reserved word/); } }); it('rejects names that collide with internal wizard flags', () => { for (const flag of ['playground', 'benchmark', 'yara-report', 'local-mcp', 'ci', 'skill']) { - expect(() => parseCliBlock({ role: 'command', command: flag }, 'ctx')) + expect(() => parseCliBlock({ role: 'hat', hat: flag }, 'ctx')) .toThrow(/wizard internal flag/); } }); - it('applies the same checks to parentCommand', () => { - expect(() => parseCliBlock({ role: 'command', parentCommand: 'help', command: 'events' }, 'ctx')) + it('applies the same checks to parentHat', () => { + expect(() => parseCliBlock({ role: 'hat', parentHat: 'help', hat: 'events' }, 'ctx')) .toThrow(/yargs reserved word/); - expect(() => parseCliBlock({ role: 'command', parentCommand: 'NotKebab', command: 'events' }, 'ctx')) + expect(() => parseCliBlock({ role: 'hat', parentHat: 'NotKebab', hat: 'events' }, 'ctx')) .toThrow(/must be kebab-case/); }); it('accepts hyphenated names within the 2-20 char range', () => { const result = parseCliBlock({ - role: 'command', - parentCommand: 'audit', - command: 'session-replay', + role: 'hat', + parentHat: 'audit', + hat: 'session-replay', }, 'ctx'); - expect(result.command).toBe('session-replay'); + expect(result.hat).toBe('session-replay'); }); }); }); @@ -132,51 +147,51 @@ describe('resolveVariantCli', () => { expect(resolveVariantCli(null, null, { id: 'all' }, 'group-key')).toBeNull(); }); - it('defaults command to the variant id for the command role', () => { + it('defaults hat to the variant id for the hat role', () => { const result = resolveVariantCli( - { role: 'command', parentCommand: 'migrate' }, + { role: 'hat', parentHat: 'migrate' }, null, { id: 'statsig' }, 'migrate', ); - expect(result).toEqual({ role: 'command', parentCommand: 'migrate', command: 'statsig' }); + expect(result).toEqual({ role: 'hat', parentHat: 'migrate', hat: 'statsig' }); }); - it('requires explicit command when variant id is "all"', () => { + it('requires explicit hat when variant id is "all"', () => { expect(() => - resolveVariantCli({ role: 'command', parentCommand: 'audit' }, null, { id: 'all' }, 'audit'), - ).toThrow(/command is required at the group level/); + resolveVariantCli({ role: 'hat', parentHat: 'audit' }, null, { id: 'all' }, 'audit'), + ).toThrow(/hat is required at the group level/); }); - it('validates the variant id when it is used as the fallback command', () => { + it('validates the variant id when it is used as the fallback hat', () => { // A reserved word or non-kebab id must be rejected even though it was - // never typed as an explicit command. + // never typed as an explicit hat. expect(() => - resolveVariantCli({ role: 'command', parentCommand: 'audit' }, null, { id: 'help' }, 'audit'), + resolveVariantCli({ role: 'hat', parentHat: 'audit' }, null, { id: 'help' }, 'audit'), ).toThrow(/yargs reserved word/); expect(() => - resolveVariantCli({ role: 'command', parentCommand: 'migrate' }, null, { id: 'CamelCase' }, 'migrate'), + resolveVariantCli({ role: 'hat', parentHat: 'migrate' }, null, { id: 'CamelCase' }, 'migrate'), ).toThrow(/must be kebab-case/); }); it('lets variant-level cli override group-level fields', () => { const merged = resolveVariantCli( - { role: 'command', parentCommand: 'audit', command: 'all' }, - { command: 'comprehensive' }, + { role: 'hat', parentHat: 'audit', hat: 'all' }, + { hat: 'comprehensive' }, { id: 'all' }, 'audit', ); - expect(merged).toEqual({ role: 'command', parentCommand: 'audit', command: 'comprehensive' }); + expect(merged).toEqual({ role: 'hat', parentHat: 'audit', hat: 'comprehensive' }); }); it('lets variant-level cli flip the role from the group default', () => { const merged = resolveVariantCli( - { role: 'command', parentCommand: 'audit', command: 'events' }, + { role: 'hat', parentHat: 'audit', hat: 'events' }, { role: 'skill' }, { id: 'all' }, 'audit-events', ); - expect(merged).toEqual({ role: 'skill', parentCommand: 'audit', command: 'events' }); + expect(merged).toEqual({ role: 'skill', parentHat: 'audit', hat: 'events' }); }); }); @@ -200,7 +215,7 @@ describe('expandSkillGroups with cli blocks', () => { 'audit-events': { type: 'docs-only', template: 'description.md', - cli: { role: 'command', parentCommand: 'audit', command: 'events' }, + cli: { role: 'hat', parentHat: 'audit', hat: 'events' }, variants: [{ id: 'all', display_name: 'PostHog audit — events' }], }, }; @@ -208,13 +223,13 @@ describe('expandSkillGroups with cli blocks', () => { expect(skills).toHaveLength(1); expect(skills[0].id).toBe('audit-events'); expect(skills[0]._cli).toEqual({ - role: 'command', - parentCommand: 'audit', - command: 'events', + role: 'hat', + parentHat: 'audit', + hat: 'events', }); }); - it('defaults command to variant id for migrate-style user-pick families', () => { + it('defaults hat to variant id for migrate-style user-pick families', () => { createFixture({ skills: { migrate: { 'description.md': '# Migrate' }, @@ -224,7 +239,7 @@ describe('expandSkillGroups with cli blocks', () => { migrate: { type: 'docs-only', template: 'description.md', - cli: { role: 'command', parentCommand: 'migrate' }, + cli: { role: 'hat', parentHat: 'migrate' }, variants: [ { id: 'statsig', display_name: 'Statsig → PostHog' }, { id: 'amplitude', display_name: 'Amplitude → PostHog' }, @@ -233,14 +248,36 @@ describe('expandSkillGroups with cli blocks', () => { }; const skills = expandSkillGroups(config, tmpDir); expect(skills[0]._cli).toEqual({ - role: 'command', - parentCommand: 'migrate', - command: 'statsig', + role: 'hat', + parentHat: 'migrate', + hat: 'statsig', }); expect(skills[1]._cli).toEqual({ - role: 'command', - parentCommand: 'migrate', - command: 'amplitude', + role: 'hat', + parentHat: 'migrate', + hat: 'amplitude', + }); + }); + + it('normalizes a legacy command block to the hat shape', () => { + createFixture({ + skills: { + 'audit-events': { 'description.md': '# Audit events' }, + }, + }, tmpDir); + const config = { + 'audit-events': { + type: 'docs-only', + template: 'description.md', + cli: { role: 'command', parentCommand: 'audit', command: 'events' }, + variants: [{ id: 'all', display_name: 'PostHog audit — events' }], + }, + }; + const skills = expandSkillGroups(config, tmpDir); + expect(skills[0]._cli).toEqual({ + role: 'hat', + parentHat: 'audit', + hat: 'events', }); }); @@ -272,7 +309,7 @@ describe('expandSkillGroups with cli blocks', () => { 'audit-events': { type: 'docs-only', template: 'description.md', - cli: { role: 'command', parentCommand: 'audit', command: 'events' }, + cli: { role: 'hat', parentHat: 'audit', hat: 'events' }, variants: [{ id: 'all', display_name: 'PostHog audit — events' }], }, integration: { @@ -285,33 +322,33 @@ describe('expandSkillGroups with cli blocks', () => { const tagged = expanded.find(s => s.id === 'audit-events'); const untagged = expanded.find(s => s.id === 'integration-django'); expect(serializeSkill(tagged).cli).toEqual({ - role: 'command', - parentCommand: 'audit', - command: 'events', + role: 'hat', + parentHat: 'audit', + hat: 'events', }); expect(serializeSkill(untagged)).not.toHaveProperty('cli'); }); }); -describe('generateCliEntries', () => { +describe('generateHatEntries', () => { it('emits only skills with a cli block', () => { const skills = [ { id: 'integration-django', displayName: 'Django', description: 'd' }, { id: 'audit-events', displayName: 'Audit events', description: 'a', - cli: { role: 'command', parentCommand: 'audit', command: 'events' } }, + cli: { role: 'hat', parentHat: 'audit', hat: 'events' } }, ]; - const entries = generateCliEntries({ allSkills: skills }); + const entries = generateHatEntries({ allSkills: skills }); expect(entries).toHaveLength(1); expect(entries[0].skillId).toBe('audit-events'); }); it('returns an empty array when no skills declare a cli block', () => { - const entries = generateCliEntries({ allSkills: [] }); + const entries = generateHatEntries({ allSkills: [] }); expect(entries).toEqual([]); }); - it('omits command and parentCommand when not set on the cli block', () => { - const entries = generateCliEntries({ + it('omits hat and parentHat when not set on the cli block', () => { + const entries = generateHatEntries({ allSkills: [ { id: 'doctor', displayName: 'Doctor', description: 'd', cli: { role: 'skill' } }, @@ -325,60 +362,90 @@ describe('generateCliEntries', () => { }); }); - it('sorts entries by role, then parentCommand, then command', () => { - const entries = generateCliEntries({ + it('sorts entries by role, then parentHat, then hat', () => { + const entries = generateHatEntries({ allSkills: [ { id: 'b-skill', displayName: 'B', description: 'd', cli: { role: 'skill' } }, { id: 'a-int', displayName: 'A', description: 'd', cli: { role: 'internal' } }, { id: 'audit-events', displayName: 'AE', description: 'd', - cli: { role: 'command', parentCommand: 'audit', command: 'events' } }, + cli: { role: 'hat', parentHat: 'audit', hat: 'events' } }, { id: 'audit-all', displayName: 'A', description: 'd', - cli: { role: 'command', parentCommand: 'audit', command: 'all' } }, + cli: { role: 'hat', parentHat: 'audit', hat: 'all' } }, { id: 'revenue', displayName: 'R', description: 'd', - cli: { role: 'command', command: 'revenue' } }, + cli: { role: 'hat', hat: 'revenue' } }, ], }); const order = entries.map(e => e.skillId); - // command flat (no parent) sorts before grouped 'audit', then skill, then internal + // hat flat (no parent) sorts before grouped 'audit', then skill, then internal expect(order).toEqual(['revenue', 'audit-all', 'audit-events', 'b-skill', 'a-int']); }); it('carries default:true through into the entry', () => { - const entries = generateCliEntries({ + const entries = generateHatEntries({ allSkills: [ { id: 'audit-all', displayName: 'Audit', description: 'd', - cli: { role: 'command', parentCommand: 'audit', command: 'all', default: true } }, + cli: { role: 'hat', parentHat: 'audit', hat: 'all', default: true } }, ], }); expect(entries[0]).toMatchObject({ skillId: 'audit-all', - parentCommand: 'audit', - command: 'all', + parentHat: 'audit', + hat: 'all', default: true, }); }); it('throws when a family has more than one default leaf', () => { expect(() => - generateCliEntries({ + generateHatEntries({ allSkills: [ { id: 'audit-all', displayName: 'A', description: 'd', - cli: { role: 'command', parentCommand: 'audit', command: 'all', default: true } }, + cli: { role: 'hat', parentHat: 'audit', hat: 'all', default: true } }, { id: 'audit-events', displayName: 'AE', description: 'd', - cli: { role: 'command', parentCommand: 'audit', command: 'events', default: true } }, + cli: { role: 'hat', parentHat: 'audit', hat: 'events', default: true } }, ], }), ).toThrow(/Family "audit" has more than one cli\.default leaf/); }); - it('throws when default is set on a flat command with no parentCommand', () => { + it('throws when default is set on a flat hat with no parentHat', () => { expect(() => - generateCliEntries({ + generateHatEntries({ allSkills: [ { id: 'revenue', displayName: 'R', description: 'd', - cli: { role: 'command', command: 'revenue', default: true } }, + cli: { role: 'hat', hat: 'revenue', default: true } }, ], }), ).toThrow(/only valid on a leaf inside a family/); }); }); + +describe('toLegacyCliEntries', () => { + it('projects hat entries back into the pre-rename command shape', () => { + const hatEntries = generateHatEntries({ + allSkills: [ + { id: 'audit-all', displayName: 'A', description: 'd', + cli: { role: 'hat', parentHat: 'audit', hat: 'all', default: true } }, + { id: 'doctor', displayName: 'Doctor', description: 'd', + cli: { role: 'skill' } }, + ], + }); + expect(toLegacyCliEntries(hatEntries)).toEqual([ + { + skillId: 'audit-all', + role: 'command', + parentCommand: 'audit', + command: 'all', + default: true, + displayName: 'A', + description: 'd', + }, + { + skillId: 'doctor', + role: 'skill', + displayName: 'Doctor', + description: 'd', + }, + ]); + }); +});