|
Linchpin CLI One command line tool for WordPress and agent workflows — git worktree management, local environment switching, and deterministic verbs agents can call without approval prompts. |
|
| A Linchpin project · Actively maintained |
|
npm install -g @linchpinagency/cli
linchpin --helpContents · What this is · Command surface · Requirements · Install · Set up a project · Daily use · Staying up to date · Uninstall · Configuration · Hooks · Agents, output modes and exit codes · Troubleshooting · Development · Releases
linchpin is a single binary that carries every repeatable piece of Linchpin's WordPress
workflow. It is not a wrapper around one thing — commands are declared in a registry that
generates --help, flag parsing, effect classification and (soon) shell completions from one
definition each, so the surface grows without the tool getting harder to learn.
Three properties hold across every command:
One local WordPress install, many branches. A plugin or theme repo can have any number of
git worktrees, but a local WordPress install has exactly one directory slot for it.
linchpin wt switch repoints that slot's symlink at the worktree you want, so one install
serves every branch without copying files or re-checking-out.
Built to be driven by an agent. Every command is classified read, write or
destructive, takes file paths rather than piped heredocs, emits a JSON envelope on request,
and uses documented exit codes. Claude Code, Codex, Cursor and Conductor can call it without
tripping approval prompts that cannot be permanently allowlisted.
It never blocks on a prompt nobody can answer. Interactivity is decided from whether a TTY
is attached, not from whether CI is set — because inside an agent CI is unset and no stream
is a TTY, which is exactly the combination that makes a naive wizard hang forever.
Deeper background lives in docs/: worktrees and the symlink
swap, configuration, hooks,
and agent integration.
linchpin --help # every command, grouped by topic
linchpin <command> --help # flags, examples and description for one| Command | What it does | Effect |
|---|---|---|
wt ls / wt current |
List worktrees, or report the active one and its symlink | read |
wt switch [ref] |
Repoint the WordPress plugin/theme symlink at a worktree | write |
wt new / wt get / wt extract |
Create a worktree from a new branch, a remote branch, or the current one | write |
wt mv / wt del / wt gone |
Rename, remove, or prune worktrees whose remote branch is gone | destructive |
wt cd / wt home |
Print a worktree path for cd "$(…)" |
read |
wt use |
Detach the base worktree onto the current worktree's commit | write |
wt copy <path> / wt link <path> |
Copy or symlink a file from the base worktree into this one | write |
wt config init / wt config show |
Create or inspect .linchpin.json |
write / read |
wt invoke <hook> |
Run a lifecycle hook by hand | write |
shell-init |
Emit the shell wrapper that lets wt switch change your directory |
read |
version |
Print the installed version and whether a newer one is published | read |
update |
Install the latest published version | write |
The effect column is what each subcommand does to the world — the classification skills use to
decide what an agent may run without asking. wt is still registered as a single
destructive passthrough to the legacy dispatcher, because the safe reading of a group
containing del is the most dangerous verb in it; the per-subcommand effects above land as each
one is ported.
linchpin repo <task> — connecting a repository to the release infrastructure in one command —
is specified but not yet built. See docs/repo-tasks.md.
- Node.js 22.12+ and npm (the
enginesfloor CI tests against). git2.37+, for worktree support.- A local WordPress environment: Studio,
wp-env, or LocalWP. - Your plugin, theme or
wp-contentrepository cloned somewhere stable, e.g.~/Documents/GitHub/<name>. - Optional:
fzf, which turns the site and worktree pickers into fuzzy finders.
Install it globally — this is a tool you run against many repositories, not a project dependency.
npm install -g @linchpinagency/cli # npm
pnpm add -g @linchpinagency/cli # pnpm
bun add -g @linchpinagency/cli # bun
yarn global add @linchpinagency/cli # yarn 1.x only; yarn 2+ has no global addVerify the install, which is also the fastest way to confirm your PATH picked up your package
manager's global bin directory:
linchpin version
# @linchpinagency/cli 1.1.3
# Up to date (checked just now)If your shell reports command not found, the global bin directory is missing from PATH.
npm prefix -g prints it; add $(npm prefix -g)/bin to your shell profile.
A child process cannot change its parent shell's directory. Without the wrapper, linchpin wt switch repoints the symlink but leaves your shell sitting in the old worktree. Add this to
~/.zshrc, ~/.bashrc or ~/.config/fish/config.fish:
eval "$(linchpin shell-init)"The shell is detected from $SHELL; force one with linchpin shell-init --shell fish. If you
would rather not add anything to your profile, wrap the command instead —
cd "$(linchpin wt switch feature/x)" — which works because path output goes to stdout while
everything informational goes to stderr.
For contributing, or to run an unreleased branch:
git clone https://github.com/linchpin/cli.git
cd cli
npm install
npm run build
npm link # puts this working tree on your PATH as `linchpin`linchpin version reports a source install and will tell you to use git pull rather than a
package manager. Undo it with npm unlink -g @linchpinagency/cli.
Run this once per repository, from the base worktree (the original clone, not a worktree):
cd ~/Documents/GitHub/my-plugin
linchpin wt config initIn a terminal you are walked through five questions, and the answers become .linchpin.json:
- Agents — which agent base path(s) you use (Conductor, Claude Code, Codex, or a custom path). Pick several if you work under more than one, so worktrees are found wherever they were created; this is what avoids detached-HEAD surprises when switching between agents. With more than one, you also choose a default for new worktrees.
- Plugin, theme, or wp-content — what this repo is. Pre-select it with
--type <plugin|theme|wp-content>. Usewp-contentwhen the repo is an entire wp-content directory, which is common on client projects named after the client. - Slug / symlink name — the WordPress directory name, defaulting to the repo directory
name (or
wp-contentfor a wp-content project). - Environment(s) — pick Studio, LocalWP, wp-env or Other. Studio and LocalWP list your
sites to choose from (
fzfif installed); wp-env asks for the WordPress root; Other asks for a name and a full path. - Default environment — which one
linchpin wt switchuses when--envis omitted.
Paths are then built for you:
| Environment | Path built |
|---|---|
| Studio | ~/Studio/<site>/wp-content/plugins|themes/<slug> |
| LocalWP | ~/Local Sites/<site>/app/public/wp-content/plugins|themes/<slug> |
| wp-env | <root you gave>/wp-content/plugins|themes/<slug> |
Finally, if the target already exists as a real directory rather than a symlink, you are
asked to back it up (.bkp suffix), delete it, or skip that environment. Nothing is replaced
silently.
Re-running config init on a repo that already has .linchpin.json offers Overwrite,
Edit (keep what is there and add environments), or Cancel.
For scripts, CI, or an agent, skip the prompts entirely:
linchpin wt config init --type plugin --plugin-slug my-plugin --no-interactive
linchpin wt config show # what the CLI actually resolvedlinchpin wt new feature/checkout # new branch + worktree
linchpin wt get feature/existing # attach an existing remote branch
cd "$(linchpin wt switch feature/checkout --env studio)"That third line is the whole point: your one WordPress install now loads that worktree. Review the branch, then move on:
linchpin wt ls # every worktree for this repo
linchpin wt current --link --env studio # what the symlink points at right now
cd "$(linchpin wt switch)" # no argument in a TTY: pick from a list
linchpin wt del # clean up once the branch is mergedWith no argument and no TTY, wt switch uses the current worktree rather than prompting —
which is what lets an agent call it safely.
Guardrails, so a switch can't quietly eat your work:
- An existing symlink target is repointed.
- An existing real directory is refused unless you pass
--force. wt delrefuses a worktree with uncommitted changes or an unmerged branch unless forced.
The CLI knows what version it is and whether a newer one has been published.
When a newer version exists, a notice is written to stderr after your command completes:
Update available: 1.1.3 → 1.2.0
Run: linchpin update
Four things make that notice safe to leave on:
- It costs nothing. The version is read from a small cache file, never from the network, so no command waits on a registry round trip. When the cache is more than 24 hours old a detached background process refreshes it and exits; nothing blocks on it.
- It never touches stdout.
cd "$(linchpin wt switch)"andeval "$(linchpin shell-init)"keep working, and a--jsonenvelope stays the only thing on stdout. - Machine readers never see it. It is suppressed in
--jsonand--quietmode, in CI, and when an agent is driving. An agent that wants the facts asks for them:linchpin version --check --json. - It is one line, and you can turn it off. Set
LINCHPIN_NO_UPDATE_NOTIFIER=1(or the conventionalNO_UPDATE_NOTIFIER=1).
linchpin --version # just the number, for scripts that parse it
linchpin version # version + cached update state + how it was installed
linchpin version --check # ask the registry now, then cache the answer
linchpin version --check --jsonlinchpin version always exits 0, including when the registry is unreachable — it is safe
in a shell prompt or a status line. The JSON form carries everything a bug report or an agent
needs:
{
"version": 1, "ok": true, "command": "version",
"data": {
"name": "@linchpinagency/cli",
"current": "1.1.3",
"latest": "1.2.0",
"updateAvailable": true,
"checkedAt": "2026-08-26T14:17:31.655Z",
"source": "registry",
"checkError": null,
"install": {
"manager": "npm", "scope": "global",
"path": "/opt/homebrew/lib/node_modules/@linchpinagency/cli/dist/cli.js",
"updateCommand": "npm install -g @linchpinagency/cli@latest"
},
"cachePath": "/Users/you/.cache/linchpin/update-check.json",
"node": "24.14.1"
}
}linchpin update # install the latest published version
linchpin update --dry-run # print the command it would run, and stop
linchpin update --check # read-only; exits 3 if an update is pendingupdate works out how this copy was installed — from the path it is running from, not a
guess — and runs the matching command, so a pnpm or bun install is never handed an
npm install -g that would leave two copies shadowing each other:
| How it was installed | What linchpin update runs |
|---|---|
| npm, global | npm install -g @linchpinagency/cli@latest |
| npm, project-local | npm install @linchpinagency/cli@latest |
| pnpm | pnpm add -g @linchpinagency/cli@latest |
| bun | bun add -g @linchpinagency/cli@latest |
| yarn 1.x | yarn global add @linchpinagency/cli@latest |
npx |
Nothing — each run already fetches the latest |
source checkout / npm link |
Nothing. It tells you to git pull && npm install && npm run build |
--check exits 3 ("precondition not met") when an update is pending and 0 when there is
nothing to do, so it can gate a job without any parsing:
linchpin update --check || echo "CLI is behind — releasing with an old toolchain"| Variable | Effect |
|---|---|
LINCHPIN_NO_UPDATE_NOTIFIER / NO_UPDATE_NOTIFIER |
Never print the update notice |
LINCHPIN_REGISTRY |
Registry to check, for a mirror or an air-gapped network. Falls back to npm_config_registry, then npmjs.org |
LINCHPIN_CACHE_DIR |
Where the update-check cache lives. Defaults to $XDG_CACHE_HOME/linchpin, then ~/.cache/linchpin |
LINCHPIN_OUTPUT |
json, quiet, human — set the output mode once instead of per call |
NO_COLOR / FORCE_COLOR |
Standard colour control |
Remove the binary with whichever package manager installed it — linchpin version names it
under install.manager if you are unsure:
npm uninstall -g @linchpinagency/cli
pnpm remove -g @linchpinagency/cli
bun remove -g @linchpinagency/cli
yarn global remove @linchpinagency/cli
npm unlink -g @linchpinagency/cli # a source install made with npm linkThen clean up the two things that live outside the package. First the update-check cache —
linchpin version --json reports its exact location as cachePath, and by default it is:
rm -rf ~/.cache/linchpinSecond, delete the eval "$(linchpin shell-init)" line from your shell profile, or every new
shell will print command not found.
Nothing else is left behind. In particular:
.linchpin.jsonand.linchpin/hooks/are project files, committed to the repository and shared with your team. Uninstalling the CLI does not touch them, and it should not — a teammate still needs them.- Your worktrees and symlinks are untouched. They are plain git worktrees and plain
symlinks; the CLI only ever pointed them at each other. Remove worktrees with
git worktree remove(orlinchpin wt delbefore uninstalling) and delete a symlinked plugin slot withrm— you are deleting a link, not your code.
.linchpin.json lives in the base repository root. linchpin wt config init writes it; this is
what it writes. Full reference in docs/configuration.md.
{
"agents": {
"codex": "~/Documents/GitHub",
"conductor": "~/conductor"
},
"defaultAgent": "codex",
"wordpress": {
"contentType": "plugin",
"pluginSlug": "my-plugin",
"defaultEnvironment": "studio",
"environments": {
"studio": "/Users/you/Studio/mysite/wp-content/plugins/my-plugin",
"localwp": "/Users/you/Local Sites/mysite/app/public/wp-content/plugins/my-plugin"
}
}
}A repo that is an entire wp-content directory, under a name that is not wp-content:
{
"wordpress": {
"contentType": "wp-content",
"symlinkName": "client-wp-content",
"defaultEnvironment": "localwp",
"environments": {
"localwp": "/Users/you/Local Sites/site/app/public/client-wp-content"
}
}
}Notes that save an afternoon:
- One agent or many. A single agent uses
agentplus an optionalagentBasePath; several useagents(name → base path) plus an optionaldefaultAgent. Defaults: Conductor~/conductor, Claude Code~/Documents, Codex~/Documents/GitHub. With several configured, every path is searched, so the right base repo is found no matter which agent made the worktree. defaultEnvironmentmay be omitted; the first environment key wins.~is expanded. Anything else should be absolute.
Twelve lifecycle points let a project run its own build, cache flush or fixup around each
operation. A hook is a file at .linchpin/hooks/<name>, sourced in a subshell with the
worktree as the working directory:
pre-switch · post-switch · pre-new · post-new · pre-get · post-get ·
pre-extract · post-extract · pre-mv · post-mv · pre-del · post-del
# .linchpin/hooks/post-switch — rebuild whatever the new branch needs
composer install
npm install && npm run buildLINCHPIN_BRANCH and LINCHPIN_WORKTREE are always set; switch hooks also get
LINCHPIN_ENVIRONMENT. Run one by hand with linchpin wt invoke post-switch. Details and the
full environment contract: docs/hooks.md.
Mode is decided once at startup: an explicit --json / --plain / --quiet flag, then
LINCHPIN_OUTPUT, then whether stdout is a TTY. Warnings and notices always go to stderr so
stdout stays parseable.
linchpin wt ls --json
linchpin version --check --jsonIn --json mode stdout carries exactly one envelope and stderr stays empty — including on
failure, which is precisely when structured output matters most. changed distinguishes a real
mutation from a no-op.
{"version":1,"ok":true,"command":"wt switch","changed":true,"data":{"branch":"feature-b"}}| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Unexpected error |
| 2 | Validation or usage error |
| 3 | Precondition not met |
| 4 | Authentication required or rejected |
| 5 | Refused by a safety check |
CI is unset inside Claude Code while no stream is a TTY. Anything that gates prompting
on a CI check alone classifies an agent as interactive and blocks forever. The non-TTY check is
the safety net. More on why this shapes the whole design:
docs/agent-integration.md.
| Symptom | Fix |
|---|---|
command not found: linchpin |
The global bin dir is not on PATH. Add $(npm prefix -g)/bin |
Missing .linchpin.json |
Run linchpin wt config init in the base worktree, not a worktree |
Environment '<name>' is not configured |
Add the key under wordpress.environments, or pass --env with one that exists |
Target exists and is not a symlink |
A real directory is in the plugin slot. Back it up, or pass --force if replacing it is intended |
Worktree has uncommitted changes on delete |
Commit or stash first, or linchpin wt del --force |
fzf is not installed |
Install fzf, or pass a branch or path directly: linchpin wt cd <ref> |
| Your shell stays in the old worktree after a switch | Install the wrapper: eval "$(linchpin shell-init)", or use cd "$(linchpin wt switch …)" |
| Update notice will not go away | You are on an older version. linchpin update, or silence it with LINCHPIN_NO_UPDATE_NOTIFIER=1 |
Could not reach the npm registry |
Offline, or behind a mirror. Set LINCHPIN_REGISTRY |
npm install
npm run typecheck # tsc --noEmit
npm run build # tsdown -> dist/
npm test # builds first, then node --testTypeScript and ESM, built with tsdown. Every runtime dependency lives in
devDependencies and is bundled into dist/, so the published package installs with zero
transitive dependencies. Un-ported CommonJS still lives in legacy/, which carries its own
package.json declaring "type": "commonjs"; it is being drained into src/ command by
command.
Adding a command means adding one defineCommand() definition — flags come from its Zod schema,
help grouping from meta.group, examples from meta.examples, and its effect classification
from read / write / destructive. Nothing is hand-wired twice.
The test suite never reaches the network: the update checker is exercised against a local
registry stub, and the shared fixture sets LINCHPIN_NO_UPDATE_NOTIFIER so no test can be
perturbed by a real release.
Husky enforces Conventional Commits on commit-msg (npm run prepare installs it):
feat(LINCHPIN-4850): add release automation
.github/workflows/ci.yml runs on every pull request and on pushes to main: typecheck, build
and tests across Node 22.12 (the engines floor) and 24.
It also gates on agent-readiness using
cli-agent-lint, which grades a CLI A–F across 34
checks covering flow safety, token efficiency, self-description, automation safety and
predictability. CI fails if the score drops below a recorded floor, and the floor rises whenever
the score does, so a gain can't be given back silently.
| Recorded | Score | Where | What moved |
|---|---|---|---|
| 2026-08-06 | 77.2% (B) | local | Baseline, pre-rewrite surface |
| 2026-08-06 | 80.7% (B) | local | Command registry — usage examples in help, actionable errors, control characters rejected in argv |
| 2026-08-06 | 84.7% (B) | CI | Dual-mode contract — --json, --quiet, --no-color, documented exit codes |
.claude/ directory that doesn't exist in a clean
checkout, so local runs read roughly 1.7 points high.
Still outstanding: shell completions and schema introspection (SD-3/SD-4), env-var auth (FS-4,
arrives with linchpin task), skill/context files (SD-5, arrives with the bundled skills), and
a --timeout flag (PV-1).
One check stays a warning on purpose. SD-1 wants errors to be JSON on stderr by default;
this CLI is human-readable by default and structured only when asked (--json), matching gh
and wrangler.
Releases are managed by release-please in GitHub Actions:
- Pushes to
mainrun.github/workflows/release-please.yml. release-pleaseopens or updates a release PR from the conventional commits since the last release.- Merging that PR creates the GitHub release and tag.
- The
publish-npmjob then builds, tests and publishes to npm with provenance.
Step 4 authenticates with the NPM_TOKEN repository secret, and that token needs write access
to the whole @linchpinagency scope — a granular npm token only ever covers packages that
existed when it was created, so one minted before a package's first publish cannot publish it.
npm answers an unauthorized write with 404, not 403, so the symptom is a confusing
E404 … PUT https://registry.npmjs.org/@linchpinagency%2fcli, not a permission error. The job
verifies the credential before building so that failure names its own cause.
A publish that failed for a credential reason needs no new release: fix the token and re-run the
publish-npm job on the existing tag.
