This skill is a supplement to the axi skill, scoped
to one specific job: shipping an axi-sdk-js CLI inside a Claude Code skill so a single
npx skills add <owner>/<repo> delivers a ready-to-run, agent-facing tool with no install step.
Division of labor — keep them straight:
axi skill (upstream) |
axi-skills skill (this one) |
|---|---|
| What the CLI prints — TOON output, minimal schemas, content-first home view, structured errors, contextual disclosure | How the CLI ships as a skill — esbuild bundle, committed .mjs, shim, SessionStart hook, SKILL.md generation, CI gate |
| Read it first / keep it open while writing command handlers | Read it when you're packaging, building, or distributing |
If you're deciding what a command should output, that's the axi skill. If you're deciding how
the tool gets built and installed, you're in the right place.
When this pattern is worth it
The deciding question is does the tool make sense as a standalone install? A general-purpose
tool an agent would reach for in any repo does — publish it to npm and let agents run it via
npx -y mytool (the lavish-axi model). The
AXI-in-skill pattern is for tools that don't stand alone. Three cases (building on
agent-skills#7):
- The tool only makes sense alongside a skill. It's significantly more usable as part of a skill that supplies the broader functionality or usage protocols the tool assumes. The canonical example, specops: the CLI is a thin determinism layer (readiness, ordering, the DAG) over a files-first methodology — it's near-meaningless without the spec/plan protocol the skill teaches. Shipping the protocol (prose) and the tool together is the whole point.
- A skill needs helper scripts, and those scripts benefit from AXI principles. When a skill would ship helper scripts anyway, building them as an AXI gets TOON output, structured errors, content-first home views, and a session hook "for free" — worth it once the scripts do enough that output quality matters.
- You want private distribution. For a tool you don't want on a public registry,
npx skills add <owner>/<private-repo>is a better distribution channel than publishing to npm — the committed bundle means a clone just runs, no registry, no install.
If none of these hold — the tool is genuinely standalone and public — prefer npm. And if the scripts are trivial and rarely called, a couple of plain zero-dep scripts beat any build pipeline.
The core idea: a committed, self-contained bundle
One esbuild step compiles your TypeScript CLI source into a single .mjs with every
dependency inlined (axi-sdk-js, @toon-format/toon, …), committed into the skill's
scripts/. It runs under plain node ≥ 20 on any machine that has only the skill installed —
no npm install, no node_modules, no npm publish. The repo is the skill.
Two alternatives were tried and rejected for this use case:
- Publish to npm, call via
npx -y mytool— splits the code across repos and adds a release pipeline, and the tool is fetched from the registry on demand (needs network; no good for private/offline/reproducible cases). Fine for a standalone public tool — the lavish-axi skill is the public example of this model — but overkill when the CLI lives with the skill and must run offline. - A bootstrap wrapper that
npm installs on first call — adds per-machine first-run friction. agent-skills#7 concluded: the committed-bundle route works; drop the bootstrap wrapper.
Choose your repo layout
The recipe is identical; only paths change. Three real shapes:
- Single-skill repo (canonical: specops) —
CLI source in
src/cli/, one skill inskills/<name>/. Start here if you can; it shows the pattern in isolation. - Multi-skill repo (canonical: claude-assist) —
several skills, one shared build.
build-cli.tsandbuild-skill.tseach iterate aTARGETSarray; source lives inpackages/*/src/axi/. - CLI alongside a server app — CLI in
src/cli/shares types with the server; the bundle is committed underskills/<name>/scripts/. One repo, no cross-repo drift.
What you'll end up with
<repo>/
src/cli/ # CLI source (or packages/*/src/axi/ for multi-skill)
bin.ts # entry: await main()
cli.ts # runAxiCli() wiring + command registry
reference.ts # SINGLE SOURCE OF TRUTH: DESCRIPTION + COMMAND_GROUPS
invocation.ts # cliInvocation() — resolved path for emitted examples
skill.ts # spliceGeneratedRegions() — SKILL.md marker splicer
commands/{home,hook,...}.ts # handlers (home doubles as the hook payload)
scripts/
build-cli.ts # esbuild → committed .mjs, version-stamp, --check drift gate
build-skill.ts # splice generated regions into SKILL.md, --check
skills/<name>/
SKILL.md # hand-authored prose + machine-generated regions
scripts/
<tool> # bash shim (executable) — the documented invocation
<tool>.mjs # COMMITTED self-contained bundle (linguist-generated)
.gitattributes # marks <tool>.mjs linguist-generated
.github/workflows/check.yml # type-check + drift gate (--check) + tests
package.json # devDeps: axi-sdk-js, @toon-format/toon, esbuild (pinned)
Ready-to-adapt versions of every build/config file are in references/templates/.
The recipe
Work top to bottom; each step has a deep-dive reference and a template.
-
Add build deps (pinned).
axi-sdk-js,@toon-format/toon,esbuildas devDependencies, pinned to exact versions so the committed bundle's bytes are reproducible. Add npm scriptsbuild:cli,build:skill, and a combinedcheckthat runs both with--check. -
Make
reference.tsthe single source of truth. ExportDESCRIPTIONand aCOMMAND_GROUPSarray of{group, commands:[{usage, summary}]}. The home view's help, the--helptext, and the generated SKILL.md region all derive from this one structure — so the skill doc can never drift from the implementation. Seereferences/skill-md-generation.md. -
Wire
cli.tswithrunAxiCli(). Registerhome, your commands, and ahookcommand. Watch the two SDK quirks that bite everyone (flags-before-command rejected; the home view is zero-arg) — both covered inreferences/build-pipeline.mdandreferences/session-hooks.md. -
Emit resolved invocation paths, never bare names. The bundle is not on
PATH. UsecliInvocation()(template:references/templates/invocation.ts) so every emitted example, help line, and hook hint points at the real path. A baremytoolmakes an agent assume it's on PATH and the call fails. Detail inreferences/build-pipeline.md. -
Build the bundle + shim.
scripts/build-cli.tsruns esbuild, stamps the version fromgit describe, and offers a--checkdrift gate. Commit<tool>.mjsand an executable bash shim<tool>. Full reasoning (incl. the version-normalization trick) inreferences/build-pipeline.md. -
Generate the SKILL.md command reference. Mark a region with
<!-- BEGIN/END GENERATED: command-reference -->and letbuild-skill.tssplice it fromCOMMAND_GROUPS. Prose outside the markers is never touched. Seereferences/skill-md-generation.md. -
Add the SessionStart hook command.
hook install|uninstall|statuswith project (default) and global scope, built on the SDK'scomputeSessionStartHookUpdate. The project/global path distinction (${CLAUDE_PROJECT_DIR}vs absolute) is the subtle part — seereferences/session-hooks.md. -
Split home from dashboard (if you have live data). A lean, offline-safe
home(identity + reference) for the cheap every-session global hook; a richerdashboard(live state) for on-demand or project-scoped use. Seereferences/home-vs-dashboard.md. -
Gate drift in CI. A
check.ymlthat runstype-checkandbun run check(both--checkbuilds) withfetch-depth: 0sogit describeis stable. Template:references/templates/check.yml.
Hard-won gotchas (the ones that cost hours)
These are distilled from building specops and claude-assist (and other internal AXI tools), and are enumerated upstream in kunchenguid/axi#46. Internalize them before you start — most are invisible until they bite.
- A
.mjs-named bundle never auto-installs hooks. The SDK infers auto-install from the bin filename and silently no-ops on any filename containing a.. So you don't passhooks: false(it isn't even in the publishedAxiCliOptionstype in 0.1.7) — you manage hooks explicitly via yourhookcommand and rely on this implicit no-op. runAxiClirejects flags before the command.mytool --dir xfails ("Flags must come after the command"). The no-args home view is therefore strictly zero-arg — it can't take a leading--dir. Lean on the SessionStart working directory instead, or register an explicithomecommand for the flag-bearing form.- The bundle is not on PATH. Always emit resolved paths via
cliInvocation(). - Version drift would fail
--checkforever. Committing the bundle changes the git sha (and CI may lack tags), so the embeddedgit describeversion always differs. Normalize the version literal out of both sides before comparing — covered inreferences/build-pipeline.md. Writedoesn't set +x. The shim and.mjsmust be committed executable (chmod 755).os.homedir()ignores$HOME. Don't try to fake$HOMEto test global hook install — it writes to your real~/.claude/settings.json. Install-then-uninstall against the real path and assert other hooks survive.- Mark the bundle
linguist-generatedin.gitattributesso the giant generated.mjscollapses in PR diffs.
Templates
references/templates/ holds adaptable starting points — copy, then rename mytool/MyTool
to your CLI:
build-cli.ts— esbuild bundle + version stamp +--checkdrift gatebuild-skill.ts— SKILL.md region splicer (single- and multi-skillTARGETSforms)invocation.ts—cliInvocation()resolved-path helperskill.ts—spliceGeneratedRegions()marker engine +COMMAND_GROUPS→ markdownshim— the three-line bash shimgitattributes— thelinguist-generatedlinecheck.yml— the CI drift gate