Repository navigation
feat: skills command installs each CLI's Claude Code skills; deprecate plugin (#201) - #203
Conversation
…e plugin
`<cli> skills install | update | status | uninstall` installs the CLI's
Claude Code skills as personal skills in `~/.claude/skills/<cli>-<skill>/`
(or under `$CLAUDE_CONFIG_DIR`). Skills come from apijack's built-ins
(`write-routine` for every CLI, `setup-api` for the bare apijack CLI) plus
the CLI's own: `.apijack/skills/` on the shared binary (requires `name` in
`.apijack.json`) or `createCli({ skillsDir })`. A same-named CLI skill
replaces a built-in, and `skills.builtin: false` drops them. `{{cli}}` in
`.md` files becomes the CLI's name.
Each install carries an ownership marker; install, update and uninstall
never touch a directory this CLI didn't install. `setup` offers to install
the skills, and a once-a-day notice (interactive terminals only) says when
`skills update` has something to refresh. Updates refresh installed skills.
Also:
- `routine dir` prints where routine files live
- `write-routine` and `setup-api` use CLI commands only (no MCP tools)
- `plugin` is deprecated: hidden from the bare listing, marked
`(deprecated)` in --help, warns when run, and `plugin install` no
longer copies skills
- the built-in `skills` command is registered last, so an API or project
command named (or aliased) `skills` wins
- A CLI name (createCli `name` / `programName`, or `.apijack.json` `name`) with no letter or digit now fails at startup instead of producing an empty prefix like `-write-routine`. Non-ASCII names are fine. - When an API group or project command owns the `skills` name, running it goes through the first-run setup like any other API command. - SKILL.md frontmatter with a BOM, CRLF, an empty block or a closing `---` at EOF keeps its description when the name is rewritten. - Symlinked skill directories and files are followed. A link back to a parent directory is skipped, and a broken link fails loudly. - `skills update` and `skills uninstall` say which directory they used. - The post-update refresh no longer tells users to uninstall the old plugin right before re-installing it.
`plugin install` no longer ships skills, and an upgrade re-runs it, so a user of the old plugin would lose their skills on upgrade. The post-update refresh now installs them the new way when the old plugin is present and this CLI has never installed skills itself (and the user hasn't declined). A CLI whose `skills` name belongs to an API group or project command isn't migrated, since it couldn't manage the result. If that refresh fails, the update doesn't re-run `plugin install`, so the plugin keeps its skills. The "old plugin is still installed" warning is dropped only when a plugin install actually follows. Also: skills and plugin paths use $HOME before os.homedir(). Bun fixes homedir() at startup, so tests that pointed HOME at a temp dir were reading and writing the real ~/.apijack.
garretpremo
left a comment
There was a problem hiding this comment.
🤖 Automated review by claude — generated by the
review-issueskill. Treat as advisory; a human still owns the merge decision.
The skills command, sources, templating, ownership, setup offer, notice, routine dir, the rewritten built-in skills and the plugin deprecation all meet #201's criteria, and CI is green (locally 1455/1456, where the one failure is tests/plugin/paths.test.ts checking that the checkout path contains apijack, which a /tmp worktree doesn't). One problem blocks the merge: the upgrade migration doesn't run on the one upgrade it's meant for, so users of the old plugin lose their skills.
Blocking:
- Old-plugin users still lose their skills when they upgrade to this release. The steps after an upgrade run in the old process. In every published version (v1.18.0 on
mainhas nopost-update.ts),src/updater.tsandsrc/commands/upgrade/upgrade.tsjust spawn<cli> plugin install, withoutAPIJACK_REFRESH_SKILLS. The child process runs this PR's code, and itsplugin installdeletes<pluginDir>/skills, runsclaude plugin install apijack@apijack, and installs nothing in their place. The newrefreshAfterUpdate/skillsAfterUpdateActionpath only runs one release later, after the skills are already gone. I reproduced it with a tempHOME, a stubclaudeonPATH, and a plugin marketplace that hadskills/write-routine/. RunningAPIJACK_SKIP_UPDATE=1 bun bin/apijack.ts plugin installthe way v1.18's updater does printed only the deprecation line. Afterwards the plugin'sskills/was gone,~/.claude/skillsdidn't exist, andskills statusshowed both skills asnot installed. The same setup withAPIJACK_REFRESH_SKILLS=then-plugin-installdid install them, so the migration logic works but nothing on the real upgrade path reaches it. This makes the PR's last acceptance criterion untrue, along with the docs line "An update installs the skills the new way for anyone who has the old plugin installed … so nobody loses them". Theskills wiringtest "an upgrade moves old-plugin users onto personal skills" only covers the env-var path. A likely fix is to move the migration intoplugin install, since that's the entry point old versions call. Before clearing<pluginDir>/skills, it would install personal skills when: the plugin had skills, this CLI owns no personal installs,seenis unset, the user hasn't declined, andskillsisn't taken by an API group or project command. If that install fails, it should leave the plugin's skills in place. Please add a test that runsplugin installwith noAPIJACK_REFRESH_SKILLSset.
Non-blocking observations
LEFTOVER_PLUGIN_WARNINGstill says the plugin "may duplicate these skills" after the newplugin installhas emptied itsskills/. It then suggestsclaude plugin uninstall apijack@apijack, which also removes the MCP server registration, and the PR says there's no clean replacement for that yet (#194). Consider mentioning that the plugin also provides the MCP server, or showing the warning only when<pluginDir>/skillsstill exists.ownedInstalls()(src/skills/store.ts) filters onentry.isDirectory(), which is false for a symlink, butskillStatuses()follows symlinks throughexistsSync/readMarker. So an install directory that's a symlink (e.g. managed by dotfiles) showsup to dateinskills status. Meanwhileuninstallskips it, and the notice andupdatetreat the CLI as having nothing installed.- If every name passed to
skills installis unknown (e.g.skills install write-routin), nothing is installed butseenis still set to every source. After that,updatetreats every skill as deliberately left out, andskillsAfterUpdateActionwon't migrate becauseseenis no longerundefined. Consider recordingseenonly when the install wrote something, or when all the names were valid. loadSources()runs on every eligible interactive command while installed skills are up to date, because the throttle only moves forward when the notice actually prints. So a project skill warning (e.g. a directory name with uppercase letters) is printed on stderr on every command. Fail-loudly may be intended, but it gets noisy.
Nitpicks
skills/write-routine/SKILL.mdstill has the heading# Writing apijack Routines;# Writing {{cli}} Routineswould match the rest of the templating.
…ade path reaches Review of #203 found the migration never ran on the real upgrade: the steps after an upgrade run in the old process, and every published version just spawns `<cli> plugin install`, which now clears the plugin's skills. The migration therefore lives in `plugin install`: - Before clearing the plugin's skills, it installs them as personal skills when the plugin still holds skills, this CLI has never installed any itself, and the user hasn't declined. - The plugin keeps its copies when that install fails, when any skill was skipped (a conflict), when the CLI ships no skills, or when the `skills` name belongs to an API group or project command. - The post-update refresh goes back to only updating installed skills. Also from the review: - the leftover-plugin warning shows only while the plugin still holds skills, and says uninstalling it also removes its MCP server - symlinked install directories are never treated as owned - `skills install` with only unknown names doesn't record `seen` - the daily notice loads skill sources without repeating warnings - write-routine's heading uses `{{cli}}`
garretpremo
left a comment
There was a problem hiding this comment.
🤖 Automated review by claude — generated by the
review-issueskill. Treat as advisory; a human still owns the merge decision.
2b6e3d5 fixes the blocker from the last review. The migration now runs inside plugin install, which is the command every published version's upgrade calls, so users of the old plugin no longer lose their skills. All of #201's criteria and this PR's extra upgrade criterion are met, and CI is green.
I re-ran last round's reproduction (temp HOME, a stub claude on PATH, a plugin with skills/write-routine and skills/setup-api) with APIJACK_SKIP_UPDATE=1 bun bin/apijack.ts plugin install and no APIJACK_REFRESH_SKILLS:
- Clean migration: installs
apijack-setup-apiandapijack-write-routine, then clears the plugin'sskills/.skills statusshows both asup to date, andseenis recorded. ~/.claude/skillsis a file: printsInstalling Claude Code skills failed (ENOTDIR …); keeping the plugin's copies.. The plugin'sskills/stays, and noseenis written.- Declined earlier: no personal skills are installed and the plugin's copies are cleared, as intended.
All of last round's non-blocking items are fixed too:
- The leftover warning is reworded and only shows while the plugin still holds skills.
- Symlinked installs count as
conflict, not as owned. seenisn't recorded when every name was a typo.- The notice no longer repeats project-skill warnings on every run.
- The
write-routineheading is templated.
Locally bun test gives 1464 pass, 1 fail. The failure is the same tests/plugin/paths.test.ts check that the checkout path contains apijack, which a /tmp worktree doesn't.
Non-blocking observations
- The PR description is out of date after 2b6e3d5. Under "Upgrading from the old plugin" it still says the post-update refresh does the migration, that
APIJACK_REFRESH_SKILLS=then-plugin-installtells the child aplugin installfollows, and that a failed refresh skipsplugin install. The commit removed all three. The test-plan count (1456) is also stale. Worth refreshing, since the release notes link back here. - The
migrateSkillsglue increateClihas no test. That's the closure insrc/cli-builder.ts:671-682, including the!skillsRegistered→ keep-copies branch. Each piece is unit-tested (pluginInstallActionintests/plugin/register.test.ts,skillsMigrateFromPluginActioninskills.test.ts), and the manual run above shows the path works. But last round's bug was exactly this kind: the logic worked and the real entry point never reached it. Acli-builderwiring test that runsplugin installwithinstallPlugin/claudestubbed would guard that. One case should have an API group namedskillsand assert the plugin keeps its copies. - A user who declined loses the plugin's copies without a message. If they declined the setup offer earlier, the next
plugin installclears the plugin's skills and prints only the deprecation line and "installed". Declining is a clear enough signal, but a one-line note (e.g. "removed the old plugin's copies of the skills; runskills installto get them back") would make it visible. Related: the doc line inCLAUDE.md/AGENTS.md/GEMINI.md/README.mdsays the migration covers "anyone who hasn't installed them throughskills installyet", without mentioning the decline exception.
garretpremo
left a comment
There was a problem hiding this comment.
🤖 Final review by claude — generated by the
final-reviewskill. CI is green, no blockers, soak window elapsed. Marking approved and merging.
Approved.
Outstanding observations from first-pass review
- The PR description is out of date after 2b6e3d5. Under "Upgrading from the old plugin" it still says the post-update refresh does the migration, that
APIJACK_REFRESH_SKILLS=then-plugin-installtells the child aplugin installfollows, and that a failed refresh skipsplugin install. The commit removed all three. The test-plan count (1456) is also stale. Worth refreshing, since the release notes link back here. - The
migrateSkillsglue increateClihas no test. That's the closure insrc/cli-builder.ts:671-682, including the!skillsRegistered→ keep-copies branch. Each piece is unit-tested (pluginInstallActionintests/plugin/register.test.ts,skillsMigrateFromPluginActioninskills.test.ts), and the manual run above shows the path works. But last round's bug was exactly this kind: the logic worked and the real entry point never reached it. Acli-builderwiring test that runsplugin installwithinstallPlugin/claudestubbed would guard that. One case should have an API group namedskillsand assert the plugin keeps its copies. - A user who declined loses the plugin's copies without a message. If they declined the setup offer earlier, the next
plugin installclears the plugin's skills and prints only the deprecation line and "installed". Declining is a clear enough signal, but a one-line note (e.g. "removed the old plugin's copies of the skills; runskills installto get them back") would make it visible. Related: the doc line inCLAUDE.md/AGENTS.md/GEMINI.md/README.mdsays the migration covers "anyone who hasn't installed them throughskills installyet", without mentioning the decline exception.
Closes #201
Summary
A new
skillscommand installs each CLI's Claude Code skills as personal skills, including skills the CLI defines itself.setupoffers to install them, and a quiet notice says when they've changed.pluginis deprecated.What changes
skillscommand (src/commands/skills/,src/skills/)skills install [names...] [--force],update,status [--json]anduninstallinstall into<claude-dir>/skills/<cli>-<skill>/, where<claude-dir>is$CLAUDE_CONFIG_DIRor~/.claude.write-routinefor every CLI, andsetup-apifor the bareapijackCLI..apijack/skills/(shared binary; requiresnamein.apijack.json, otherwise ignored with a warning) orcreateCli({ skillsDir }).skills.builtin: false/builtinSkills: falsedrops the built-ins.{{cli}}in.mdfiles becomes the CLI's name, and thename:frontmatter is rewritten to the install name..apijack-skill.jsonmarker plus the expected directory name decide what this CLI owns. Install, update and uninstall never touch anything else (--forcefor install only), including copies of an installed skill.not installed,up to date,outdated,conflict,orphaned.~/.apijack/skills-state.jsonper CLI records a declined offer, the last notice time, and which skills existed at the last install.updateonly adds skills that are new since then, never ones the user left out. Hand-edited, malformed state is ignored.Wiring
setupand the first-run setup offer to install, but only in a TTY, when the Claude dir exists, when nothing is installed yet, and when the user hasn't declined before.--json,-oor--quiet, forskills,mcporroutine run, or when nothing is installed.skillsis registered last, so an API group or project command named (or aliased)skillswins; that command then needs the first-run setup like any other.APIJACK_REFRESH_SKILLS, whichrun()handles before reading argv. Passingskills updateas arguments would have gone to an API's ownskillsgroup.name/programName, or.apijack.jsonname) now fails at startup. Non-ASCII names are fine.routine dir: new command that prints the routines directory.Built-in skills
write-routineandsetup-apiuse only CLI commands:-o routine-stepfor discovery,routine dir,routine validate,{{cli}}everywhere. No MCP tool references; a test enforces this.Upgrading from the old plugin
plugin installno longer ships skills, and every upgrade path runs it. Older versions'upgradeand update prompt spawn<cli> plugin installdirectly, and this version re-runs it after updating.plugin install. Before clearing the plugin's skills, it installs them as personal skills when the plugin still holds skills, this CLI has never installed any itself, and the user hasn't declined.skillsname.$HOMEbeforeos.homedir(). Bun fixeshomedir()at startup, so tests that pointedHOMEat a temp dir were reading and writing the real~/.apijack.plugindeprecated(deprecated)in--help. Running it prints a warning pointing toskills install.plugin installno longer copies skills, and clears the ones it copied before.claude mcp add … <cli> mcpas a replacement:<cli> mcpuses~/.<cli>/routineseven in project mode (MCP routine tools use ~/.<cli>/routines while the CLI uses the project's routines dir #194).Docs
routine dir,.apijack/skills/and theplugindeprecation inCLAUDE.md,AGENTS.md,GEMINI.mdandREADME.md.Acceptance criteria
skills install/update/status/uninstallbehave as described, including ownership and conflicts{{cli}}templating work on the shared binary and withcreateCli({ skillsDir })skillswins over the built-in, and the CLI still startsroutine dirprints the routines directory in project and global modepluginis hidden from bare help, marked deprecated in--help, and warns when runTest plan
bun test: 1465 pass, 0 fail (nothing under the real~/.apijackor~/.claudetouched)bun run lint: 0 errorsbunx tsc --noEmit -p .: no new errors (same 9 pre-existing files asdev)tests/skills/andsrc/commands/skills/; wiring intests/cli-builder.test.ts(skills wiring,plugin deprecation); shared binary intests/bin-apijack.integration.test.tsHOME, stubclaude, an old plugin holdingskills/write-routine/, andAPIJACK_SKIP_UPDATE=1 bun bin/apijack.ts plugin install(what v1.18's updater runs). It installsapijack-setup-apiandapijack-write-routineand then removes the plugin's copies. With a conflicting~/.claude/skills/apijack-write-routineit skips that skill and the plugin keeps its copies. With an unwritable skills dir it keeps the copies and says why.<cli>-write-routineand prints the reload hint; the notice fires once, is throttled on rerun and stays silent when piped; the first-run offer asks and remembers a decline