Wrap a CLI
Bring a CLI with a login, setup, several accounts or an admin face under HCP: the setup family, connect, doctor, gated writes, entities, the control split.
On this page
- Before you start
- Start from a draft
- Map the lifecycle to command families
- Setup: read and write
- Health and discovery: read
- Validate before mutate: read
- Connect or link: write, gated by pack.connect
- Domain ops
- Dry-run
- Declare the wrap
- Declare Binding mode explicitly
- Multi-entity and multi-tenant arguments
- Control plane versus runtime
- Bootstrap with pass-through, then promote
- Anti-patterns
- Proof
- Related
Most packs enter HCP as adapter: wrap, which names the commands a tool
already has and gives each a rule without changing the tool. This page covers
the CLI that has grown a login, a setup step, several accounts or an admin
face, starting from the draft hcpx wrap writes.
Before you start#
The CLI needs these habits first:
| Habit | Why HCP needs it |
|---|---|
--json (or JSON-default) output | Agents parse |
| Non-zero exit on failure | Orchestrators branch |
A doctor or status command | Health without minting |
Non-interactive flags (-y) on anything that would prompt | Agents hang |
| Named subcommands | Policy can only cover things with names |
A capability that exists only as an MCP server needs a CLI first.
Start from a draft#
hcpx wrap mail --class gated-write --jsonhcpx wrap reads mail --help with no TTY and drafts the three files in
packs/mail: one command per usage line it can parse, a Policy row for each,
confirm and an approval preview on the ones that sound risky, and
wrap.workspace.mode: project so the CLI runs in the caller's project. Review
what its next list names, then run hcpx check ./packs/mail --init-evals and
hcpx check ./packs/mail --probe --agent until no error is left. The
CLI reference lists the options.
Map the lifecycle to command families#
HCP turns the habits production CLIs share into families with a default Policy.
Setup: read and write (HCP/1.1)#
mail setup status --json # read — idempotent; detected hosts, Skill merge state
mail setup all -y --json # write — apply; -y default on the agent path
mail setup update --json # write — refresh Skill after a pack upgradeThe pack keys are setup.status, setup.apply and setup.update. All three
are idempotent, and the agent path never opens a wizard.
Health and discovery: read#
mail doctor --json # auth edge, binaries, config, capabilities
mail status --json # narrower than doctor
mail capabilities --json # feature discovery before sendDoctor reports Binding health, must not mint a long-lived secret as a side
effect, and exits 0 when healthy, 2 on a warning and 1 on failure.
Validate before mutate: read#
pulse validate --file ./envelope.json --jsonValidating a prospective write is a read.
Connect or link: write, gated by pack.connect#
mail connect --json
finance connect bank --entity acme-eu --jsonConnect establishes Binding. It is operation: write but needs the
pack.connect capability instead of verb.exec.write, and it belongs under
Setup in the Skill so agents run it once.
Domain ops#
| Shape | Policy |
|---|---|
list get recv brief | read |
send create sync categorize | write |
transfer delete publish | write + confirm |
Dry-run#
Offer --dry-run on expensive or destructive writes only when the system can
preview without persisting anything. Policy stays write.
Declare the wrap#
"kind": "wrap",
"cliBin": "mail",
"binding": { "mode": "user", "hub": "keyring", "family": "office" },
"wrap": { "bin": "mail", "workspace": { "mode": "project" }, "agent_env": { "MAIL_OUTPUT": "json" } },
"verbs": {
"setup.status": { "cli": ["mail", "setup", "status"] },
"setup.apply": { "cli": ["mail", "setup", "all", "-y"], "policy": "write" },
"connect": { "cli": ["mail", "connect"], "policy": "write" },
"doctor": { "cli": ["mail", "doctor"] },
"list": { "cli": ["mail", "list", "--folder={{folder}}"] },
"get": { "cli": ["mail", "get", "{{id}}"] },
"send": { "cli": ["mail", "send", "--to", "{{to}}", "--subject", "{{subject}}", "--confirm"], "policy": "write" }
}The runtime renders cli[] with args, appends the JSON flag and spawns the
allow-listed bin in the caller's project without a shell. A CLI that selects
JSON its own way (-o json, --json name,url, or JSON by default) sets
json_flag.
Declare Binding mode explicitly#
| Mode | When |
|---|---|
user | The CLI acts with a delegated user credential |
local | The CLI keeps credentials in a machine-local store |
mothership | The CLI is an operator or fleet tool; see Control |
public | No credential |
Never infer the mode from whatever token sits in the environment. Mock is explicit in the envelope, and an env-flag mock fails closed in sandbox.
Multi-entity and multi-tenant arguments#
CLIs often take --entity, --workspace or --account. Each maps to its own
home in HCP, and pack arguments alone never select the tenant.
| CLI concern | HCP home |
|---|---|
| Which organisation | Runtime tenant, from the Principal's session, never a pack arg |
| Which shared project | Runtime project, when privacy.land: brain |
| Which local workspace directory | Land local (cwd jail), which is not a tenant |
| Which linked account or mailbox | Binding + command args (--folder, account id) |
| Which domain entity | Command args, still scoped by tenant. --entity only when Binding mints per entity |
Control plane versus runtime#
Keep the split many ecosystems make between an admin CLI (link, provision) and a user CLI (send, receive).
| Approach | When |
|---|---|
| Two packs | Different Binding modes, or very different RBAC |
| One pack, two namespaces | Same Binding family; mark connect.* clearly |
Different Binding modes always mean two packs, and an operator bin must never be
reachable from an end-user Principal just because it is on PATH.
Bootstrap with pass-through, then promote#
Pass-through gets a binary under Policy in an hour:
"pass_through": { "allowedSubcommands": ["status", "list"], "maxArgv": 6 }Then promote the hot path to named commands with Policy rows and delete the allow-list, because a wrap that stays pass-through does not conform.
Anti-patterns#
| Anti-pattern | Do instead |
|---|---|
Secrets in HARNESS.md or the manifest | Binding mint |
Interactive --login on the agent path | Separate human connect; Sense binding.degraded |
One mega-command run | Named commands with Policy |
| Different auth for UI vs agent | Shared Binding |
| Teaching an MCP tool list as the pack | Pack commands; MCP is an optional export |
| Host-absolute paths in Skill steps | Portable bins; runtime documents env |
Proof#
hcpx check ./packs/mail --probe --strict # quality rules, live probes, eval cases
hcpx lint ./packs/mail --json
hcpx install ./packs/mail --json && hcpx activate mail --json
hcpx exec mail list --json -- folder=inbox # one read
hcpx exec mail send --json -- to=a@b.c subject=Hi # → confirm required
hcpx exec mail send --json -- to=a@b.c subject=Hi confirm=true # mock/paperTesting explains what each step proves.