pple CLI
The Builder's terminal tool: three commands between a PPLE ID and a live app, and everything else a Builder can do in the Console.
pple auth login # sign in with your PPLE ID (browser, no password in the terminal)
pple init # create an app (asks for App Name) and set up its code
pple init --name "Sign-up" # same, without a prompt (for agents/scripts)
pple init brave-otter # set up an app that already exists on the platform
pple deploy -m "Add the form" # send the changes and put them live
pple upgrade # update pple itself from npm
pple help # every command; `pple <command> help` explains one
Builder Parity
Anything a Builder can view or change in the Console can be viewed or changed here (ADR-0020). Platform Admin work (the approval queue, the all-apps index, direct edits, Move owner, running a Code Review, admin Retire / Purge / Delete) stays in the Console, and the CLI has no command for it.
| Area | Commands |
|---|---|
| Account | auth login, auth logout, whoami, agreement |
| Apps | apps, init, info, rename, retire, retry-setup |
| Deploys | deploy, redeploy, deploys [<id>] (reads a failed deploy's log) |
| Config | env list, env set, env delete |
| Publication | promote, invites [add|remove], roles, request publish|change|limit|retire, requests |
| People | collaborators [add|remove] |
| Notes and reviews | notes [add], reviews [<id>] |
Every command that works on one app takes --app <slug> and otherwise uses
the App Link of the folder it runs in. Read commands take --json and print
the platform's own payload; Thai text is the default. pple invites and
pple collaborators print full phone numbers, as the Console does for an
Owner.
A CI test keeps this true. test/builder-parity.test.ts reads the Console's
api object (console/web/console.ts) and fails when a method that isn't
Platform Admin work has neither a command that names it in covers
(src/registry.ts) nor an entry with a reason in the test's EXEMPT list.
It proves a command exists, not that it behaves the same; permissions stay
aligned because only the Provisioner enforces them. CLI CI runs whenever
console/web/console.ts changes. A merged command reaches Builders only in a
CLI release (ADR-0008), so the published CLI can briefly lag the Console.
Asking for help is safe
pple help, pple <command> help, pple <command> <subcommand> help, --help
and -h explain a command and nothing else: they never reach the network,
change anything, ask a question, need a sign-in, or run the daily update check.
help counts only directly after the command's own words, so
pple info --app help still means an app with that slug and
pple env set API_URL help still sets the value help; --help and -h
win anywhere. Agents explore with --help, so this is checked for every
command in test/help.test.ts.
Consequential actions
At a terminal these ask a person, as the Console does. Run with nobody there
(an agent, a script, a pipe) each one refuses unless given the specific value
being confirmed, so the target is named in the Builder's transcript. There is
no generic --yes.
| Action | Unattended runs must pass |
|---|---|
pple retire | --confirm <slug> |
pple collaborators add <phone> | the lookup's token, on a second run: pple collaborators add --confirm <token> |
pple request publish --address <name> | --confirm <name> |
pple agreement | nothing works: only a person at a terminal, or the Console, can accept |
The Skill tells agents to get the Builder's OK before passing any of these.
Git is an implementation detail, and so is GitHub. A Builder is told what happened to their app — "Sending your changes…", "Volunteer Sign-up is live at https://brave-otter.ppleth.ai" — never what happened to a branch, and never that there is an account somewhere they should go and get. Only a genuine conflict surfaces the tools underneath, and even then with an instruction rather than a stack trace.
Reaching the app's code
App repos are private and the Builder's machine has no credential for them.
Before each command that touches code, the CLI asks the Provisioner for a
token scoped to that one repo, keeps it in memory, and hands it to git
through the child process's environment: GIT_ASKPASS points at
askpass/pple-askpass.mjs, which reads the token
back out of that environment when git asks for a password. origin stays a
plain URL, so the token never reaches argv (world-readable via ps),
.git/config, or any other file. Commits are authored as the Builder's PPLE
ID identity, which the same response supplies — the pusher is the platform's
GitHub App, and would otherwise be the only name in an app's history. The
reasoning is ADR-0005.
How the commands work
pple auth login runs the OAuth device authorization grant against PPLE
ID: the CLI prints a code, opens the verification page, and polls until the
Builder approves it. Tokens land in $XDG_CONFIG_HOME/pple/credentials.json
(mode 0600) — never inside an app folder, which is a repo the platform
owns. pple whoami renews an expiring token silently via the refresh token.
pple init creates a Builder App (prompt for App Name, or --name) via
the same Provisioner POST /apps the Console uses, waits until the app is
ready, then scaffolds it — or, given an App Slug, scaffolds an app that
already exists. Either way it fetches the app's code, copies the golden-path
template under the app's own name, writes .pple/app.json linking the folder
to the app, and installs the Skill and Platform Rules the Builder's AI coding
agent reads (ADR-0001, ADR-0019). The App Slug stays platform-generated;
bare pple init in a folder that already has an App Link is refused.
pple deploy records everything in
the folder, sends it to the app's repo — merging in a Collaborator's work if
there is any — then watches the platform build it and reports the live URL,
or the build output that explains why nothing changed. It needs no
arguments: the link file says which app. Account-only commands
(auth login / logout / whoami) never touch the Skill or the Platform Rules.
Skill and Platform Rules are not in this package. pple init, deploy,
info and env, run in an app's folder, ask the platform for the current
hash of both and download one whole version only when it differs from the copy
on disk, so a merge to main reaches Builders on their next command with no CLI
release (ADR-0019). The copy is written once per agent, in the format it
reads, and every path is git-ignored:
| Agent | Skill | Platform Rules |
|---|---|---|
| Claude Code | .claude/skills/pple-platform/ | .claude/rules/pple-platform.md |
| Cursor | .agents/skills/pple-platform/ | .cursor/rules/pple-platform.mdc (alwaysApply) |
| Codex | .agents/skills/pple-platform/ | .pple/rules.md, by the one-line pointer in AGENTS.md |
| Gemini CLI | .agents/skills/pple-platform/ | none |
Which folder is refreshed is the one the command was run in, never the app
--app names; outside an app folder nothing happens, and asking for help never
refreshes. A refresh says something only when files changed (first install, a
location put back, a new version), and says so on stderr, so --json output
stays parseable. A refresh that fails warns, keeps the last copy and never
blocks the command; each location is replaced by renaming a finished copy over
the old one, so a download that dies halfway leaves nothing half-written.
Sibling skills, other rule files and an AGENTS.md the Builder wrote are never
touched: the pointer is created only when the file is missing. The version the
platform sends is untrusted — a file path that would leave its folder refuses
the whole version.
pple apps, rename, retire, retry-setup, redeploy, deploys,
promote, invites, request, requests, roles, collaborators, notes,
reviews are the Console's buttons from the terminal; each calls the same
Provisioner route the Console does. request publish and request change
upload an icon from a file path (--icon) the way the Console's picker does,
and name roles by key or Thai label, resolved against the Provisioner's
GET /role-options (pple roles) — the list the Console's picker shows.
pple upgrade asks npm what the latest @pplethai/cli is and, when
this copy is one npm's global root holds, installs it — which is also how
the golden-path template moves forward (ADR-0007; the Skill no longer
travels with the CLI, ADR-0019). Any
other install channel is told the manual npm install -g @pplethai/cli
line rather than having a second copy installed over the first, and so is a
failed install, carrying npm's own reason. The CLI never uses sudo: a global
folder the Builder can't write to is theirs to fix, not the CLI's to force.
Staying current
About once a day, before running the command it was given, the CLI asks npm
whether there is a newer pple. It runs before the command because that is
the only moment an upgrade is free: nothing is half-done, so the new version
can simply be handed the same command line. At a terminal the Builder is
asked, and on a yes the CLI installs the new version and re-runs the original
command on it. With nobody at the keyboard — the AFK agent runs the CLI is
built to serve — it prints one line about the new version and gets on with the
command; it never asks a question that could hang.
The check is written to fail towards the command. A registry that can't be
reached says nothing at all and costs one request, not one per command:
update-check.json in the CLI's config directory records that npm was
asked, not what it answered. A failed upgrade prints npm's own reason and
the manual line, then offers to carry on with the current CLI (unattended:
carries on). Two ways to turn the whole thing off:
PPLE_NO_UPDATE_CHECK=1 pple deploy # this run, or exported in CI
echo '{ "updateCheck": false }' > ~/.config/pple/config.json # this machine
Layout
| File | What it holds |
|---|---|
src/registry.ts | the command table: every command, declared once with its help and what it covers; resolves a command line |
src/command.ts | what a command is, and parsing its options |
src/help.ts | the text of pple help, built from the table |
src/cli.ts | runs one command line: help first, then the update check, then the command |
src/target.ts | which app a command acts on (--app, else the App Link) |
src/commands/ | the commands, each taking Deps and what it was given |
src/labels.ts, src/requests.ts | the Thai a Builder reads for platform values and Requests |
src/deps.ts | every seam: fetch, clock, subprocesses, output, template dir |
src/device-flow.ts | RFC 8628 against PPLE ID |
src/api.ts | the Provisioner API, and the only place that knows its error codes |
src/git.ts | git, and the only place that names it or holds the repo token |
src/scaffold.ts | copying the golden-path template |
src/guidance.ts | refreshing the Skill and Platform Rules into an app folder |
src/npm.ts | npm: the registry's latest version, and the install channel |
src/update.ts | the daily update check, its cadence state, and its opt-outs |
askpass/pple-askpass.mjs | what git runs to be told the app's repo token |
Development
npm install
npm test # drives whole commands against a fake Provisioner and real git
npm run typecheck
npm run build # bundles the template, then esbuild → dist/bin.js
Tests run the real command path — real git in a temp directory, a fake
platform behind Deps.fetch, and a virtual clock so polling loops finish
instantly. test/support/harness.ts is the entry point.
App repos are served in tests by git http-backend behind Basic auth
(test/support/git-server.ts), accepting only tokens the fake platform
minted. A local path would let every clone and push succeed with no credential
at all, and nothing would show that the platform's token is what opens a
private repo.
The published package carries its own copy of the golden-path template
(npm run build:template copies ../template). Running from a checkout falls
back to that repo path directly. The Skill is not bundled: tests serve it from
the fake platform. PPLE_TEMPLATE_DIR, PPLE_API_BASE, PPLE_SSO_BASE, PPLE_CLIENT_ID,
PPLE_REPO_BASE, and PPLE_REGISTRY_BASE override the defaults for staging
work.
CLI release
A full Phase 1 launch includes a CLI release: shipping
@pplethai/cli (bin: pple) to the npm latest dist-tag, which is what
Builders install and self-update from. This is not part of the Cloudflare
Worker deploy — a Worker rollback must never ship or unship a CLI version.
Releases happen through the CLI release workflow
(.github/workflows/cli-release.yml): anyone with repository write dispatches
it on main, and it re-runs typecheck, test, and build, asks the release gate
whether the version in package.json may reach the registry, publishes, and
only then tags the commit cli-vX.Y.Z. The one exception is the bootstrap
publish that creates the package on npm, which a human runs once — see
docs/deploy-phase1.md §3 for both.
The version comes from package.json on main — the workflow takes no
version input, and refuses to release a version npm already holds. Nobody
edits that version by hand: a PR changing what this package ships carries a
Changeset (npx changeset at the repo root, or a skip-changeset label if it
ships nothing), and the Version Packages PR spends those Changesets into
package.json and CHANGELOG.md. So main may sit ahead of npm — a version
here is prepared, not shipped. See .changeset/README.md
and ADR-0008.
Past the bootstrap publish that creates the package, there is no supported way
to release from a laptop. The release gate is scripts/release-gate.mjs, and
it answers locally too:
node scripts/release-gate.mjs release # may this version reach npm?
npm publish --access public --dry-run # inspect the tarball, upload nothing
Builders install with npm install -g @pplethai/cli.