vacant.cli¶
Console-script entrypoint declared in pyproject.toml as
vacant = "vacant.cli:main". Each subcommand maps to a function in
vacant.cli.commands; local key / logbook persistence lives in
vacant.cli.local_store.
commands
¶
Wired CLI commands.
Each command in this module replaces the _NOT_YET stub that shipped
with P0. The local-state store at ~/.vacant/<name>/ is owned by
vacant.cli.local_store; HTTP work goes through httpx.AsyncClient
against a registry URL (env VACANT_REGISTRY_URL or --registry).
A few commands (call, attest) require remote endpoints that ship
with PR-β (vacant serve + the wired-up /v1/submit_attestation).
Those subcommands degrade gracefully with a clear not available
yet exit code so the help surface is complete and a future PR can
enable them in place. F4 acceptance only requires the commands to run
end-to-end — the remote-only features have explicit pending tickets.
init_cmd
¶
init_cmd(name: str, insecure_demo: bool = Option(False, '--insecure-demo', help='Store the Ed25519 seed in plaintext key.json (mode 0600) instead of the OS keyring. Demo / CI use only — see SECURITY.md.')) -> None
Create a fresh keypair + seed logbook for name. (P2)
Writes ~/.vacant/<name>/{key.json,logbook.jsonl,meta.json} with
file mode 0600 on the key. The Ed25519 seed is stored in the OS
keyring by default (Keychain / Secret Service / Credential
Locker); pass --insecure-demo to fall back to plaintext on
hosts without a keyring backend.
Source code in src/vacant/cli/commands.py
status_cmd
¶
Show local vacants and their lifecycle states. (P1)
Source code in src/vacant/cli/commands.py
heartbeat_cmd
¶
heartbeat_cmd(name: str | None = Option(None, '--name', help='Local vacant name; defaults to VACANT_NAME.')) -> None
Manually trigger a heartbeat tick. (P1)
Source code in src/vacant/cli/commands.py
publish_cmd
¶
publish_cmd(capability: str = Option(..., '--capability', help='Capability text to advertise.'), endpoint: str | None = Option(None, '--endpoint', help='A2A endpoint URL.'), registry: str | None = Option(None, '--registry', help='Registry URL.'), name: str | None = Option(None, '--name', help='Local vacant name.'), base_model: str | None = Option(None, '--base-model', help="Base model identifier (e.g. 'claude-sonnet-4-6'). On the first publish, omitted → defaults to 'unknown'. On a republish, omitted → preserves the stored value (Pfix3 F2)."), base_model_family: str | None = Option(None, '--base-model-family', help="Base model family (e.g. 'claude'). Same null-vs-default semantics as --base-model.")) -> None
Flip LOCAL → ACTIVE (publish halo to registry). (P4)
Source code in src/vacant/cli/commands.py
unpublish_cmd
¶
Flip ACTIVE → LOCAL (visibility=NONE). (P4)
Note: this only flips the local meta; the registry record is
not revoked over HTTP yet (the /v1/revoke_halo endpoint
requires a P6 envelope, see rpc.py). Use the python
vacant.registry.halo.revoke_halo API for full withdrawal.
Source code in src/vacant/cli/commands.py
lineage_cmd
¶
lineage_cmd(vid: str, direction: str = Option('ancestors', '--direction', help='ancestors | descendants'), depth: int = Option(8, '--depth', min=1, max=32), registry: str | None = Option(None, '--registry', help='Registry URL.')) -> None
Print the parent_id chain for vid. (P4)
Source code in src/vacant/cli/commands.py
attest_cmd
¶
attest_cmd(target_vid: str, claim: str, name: str | None = Option(None, '--name', help='Local vacant name.')) -> None
Issue a peer attestation about target_vid. (P2)
Signs a PeerAttestation and stores it in
~/.vacant/<name>/attestations_issued.jsonl. The HTTP relay to
the registry's /v1/submit_attestation endpoint lands in PR-β
(the endpoint is currently a P6-envelope stub).
Source code in src/vacant/cli/commands.py
call_cmd
¶
call_cmd(vid: str, capability: str, text: str = Option('ping', '--text', help='Body text to send.'), registry: str | None = Option(None, '--registry', help='Registry URL.'), name: str | None = Option(None, '--name', help='Local vacant name.')) -> None
Send a request to a remote vacant. (P6)
Looks up the target's CapabilityCard via the registry's
/v1/capability_card/<vid> endpoint and dispatches a signed
envelope to card.endpoint. The --endpoint direct-known mode
lands with PR-β alongside vacant serve's /card route.
Source code in src/vacant/cli/commands.py
493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 | |
serve_cmd
¶
serve_cmd(port: int = Option(8443, '--port', '-p', help='HTTP bind port.'), host: str = Option('127.0.0.1', '--host', help='HTTP bind host.'), name: str | None = Option(None, '--name', help='Local vacant name.'), mcp: bool = Option(False, '--mcp', help='Also expose an MCP stdio server.'), endpoint: str | None = Option(None, '--endpoint', help='Public endpoint URL to advertise in /card (defaults to meta.endpoint).')) -> None
Start an HTTP A2A server for the local vacant. (P6)
The server listens on host:port and accepts inbound A2A
message/send requests at /a2a/message/send. The default
behaviour callback echoes the request text back, signed by the
vacant's own key — sufficient for the live-network acceptance test.
--mcp additionally launches an MCP stdio server in a worker
thread. This is what closes the "嫁接到客戶端" thesis claim: the
same vacant accepts both A2A HTTP and MCP stdio simultaneously.
Source code in src/vacant/cli/commands.py
mcp_cmd
¶
mcp_cmd(name: str | None = Option(None, '--name', help='Local vacant name to serve. Defaults to `$VACANT_NAME` or the single existing local vacant. If no local vacant exists, an ephemeral demo vacant is used (with a stderr WARN).')) -> None
Run the vacant as a pure-stdio MCP server. (D2 / Claude Code plugin)
No HTTP, no worker threads, no uvicorn — the process IS the
MCP server. Spawned by uvx vacant mcp from the
.claude-plugin/plugin.json manifest, which is what Claude Code
calls when a user runs /plugin install vacant. EOF on stdin
(the parent closing the pipe) ends the loop.
Identity resolution:
--name <n>⇒ load~/.vacant/<n>/- otherwise
$VACANT_NAME⇒ same - otherwise the only initialised local vacant ⇒ same
- nothing initialised ⇒ ephemeral in-memory demo vacant + a
stderr WARN telling the operator to run
vacant initfor a persistent identity.
Source code in src/vacant/cli/commands.py
711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 | |
install_cmd
¶
install_cmd(client: str = Argument(..., help='MCP client to register vacant with: claude-code | claude-desktop | cursor | windsurf | openclaw | hermes'), config_path: str | None = Option(None, '--config-path', help='Override the default config-file location for this client.'), name: str = Option('alice', '--name', help='VACANT_NAME env var written into the registered MCP entry (picks which `~/.vacant/<name>/` identity the spawned `vacant mcp` uses; defaults to `alice`).'), force: bool = Option(False, '--force', help="Overwrite an existing `vacant` entry in the client's config."), dry_run: bool = Option(False, '--dry-run', help='Print what would be written without touching any file.')) -> None
Register vacant as an MCP server with a local client. (Pfix4)
One unified entry point — the README's per-client one-liners (OpenClaw / Hermes / Claude Desktop / Cursor / Windsurf) all collapse to:
vacant install <client>
Idempotent: re-running with no flags is a no-op when an entry is
already in place. Pass --force to overwrite.
Source code in src/vacant/cli/commands.py
demo_cmd
¶
demo_cmd(scenario: str, substrate: str = Option('mock', '--substrate', '-s', help='mock | deterministic | anthropic | ollama | openai | gemini | mistral | hermes | openclaw'), seed: int | None = Option(None, '--seed', help='override default seed'), tail: bool = Option(False, '--tail', help='stream demo-store events to stdout instead of running'), db_path: str | None = Option(None, '--db', help='demo store path (default: var/demo.db)')) -> None
Run a demo scenario end-to-end. (P7)
Examples:
vacant demo law_firm vacant demo law-firm --seed=42 # hyphen accepted vacant demo self_replication --substrate=anthropic vacant demo law_firm --tail # tail events from demo store
Source code in src/vacant/cli/commands.py
local_store
¶
On-disk layout for local vacants (~/.vacant/<name>/).
A local vacant is the owner-side handle for a vacant: keypair stored
in the OS keyring (or a plaintext file in --insecure-demo mode), a
logbook persisted as .jsonl, and a small meta.json carrying
visibility state, capability text, and endpoint URL. Higher-level CLI
commands (vacant init, status, publish, heartbeat, attest,
call) read and write through this module.
Layout under ${VACANT_HOME:-~/.vacant}/<name>/:
key.json {"pubkey_hex": "...", "key_storage": "keyring"} (mode 0600)
or {"pubkey_hex": ..., "seed_hex": ..., "key_storage": "plaintext"}
logbook.jsonl one JSON-encoded LogEntry per line
meta.json LocalMeta — state / endpoint / capability_text / etc.
Key storage (F-D codex final blockers): the Ed25519 seed is sensitive
material — controlling it == owning the vacant. The default storage is
the OS keyring (Keychain on macOS, Secret Service on Linux, Credential
Locker on Windows) via the keyring package. The on-disk key.json
holds only the public key and a key_storage discriminator so external
tooling can verify signatures without unlocking the keychain.
If the host has no keyring backend (e.g. headless CI without DBus),
init_vacant(insecure_demo=False) raises rather than silently falling
back to plaintext. To opt into plaintext storage explicitly, pass
insecure_demo=True (the CLI surface is vacant init <name>
--insecure-demo); a stderr WARN is emitted and key.json is written
with the seed in the clear under mode 0600.
The --insecure-demo mode exists for two purposes only: live demos
where the operator is showing the file layout, and short-lived CI/test
flows. Do not use it on a host with real network exposure. See
SECURITY.md §"Local key storage" for the full risk model.
ENVELOPE_STATE_FILE
module-attribute
¶
Per-target chain state for outgoing calls (Pfix3 B6).
Keyed by target vacant_id_hex; tracks the last accepted envelope
on the request (caller → target) and response (target → caller)
chains so the next vacant call to the same target advances seq +
prev_envelope_hash correctly. Without this file the CLI defaulted
sequence_no=1 on every call → second call to a target was
rejected as replay by the server.
KEYRING_SERVICE
module-attribute
¶
service argument used for every keyring.set_password /
keyring.get_password call. Stable across versions so the OS keyring
entry survives upgrades.
LocalVacantError
¶
Bases: RuntimeError
Base class for local-store errors.
LocalVacantNotFound
¶
Bases: LocalVacantError
The named local vacant does not exist.
LocalVacantExists
¶
Bases: LocalVacantError
A local vacant with that name already exists.
LocalVacantKeyringUnavailable
¶
Bases: LocalVacantError
The default OS keyring is the fail / null backend.
Raised by init_vacant when the operator has not opted into
insecure_demo=True. The error message tells the operator how to
proceed: install a keyring backend or re-run with --insecure-demo.
LocalMeta
¶
Bases: BaseModel
Sidecar metadata. Visibility / capability / endpoint live here so
status can render them without opening the logbook.
key_storage
class-attribute
instance-attribute
¶
keyring (default, OS keyring) or plaintext (--insecure-demo).
Defaults to plaintext so LocalMeta files written before F-D
landed still load cleanly.
vacant_home
¶
Resolve the root directory: $VACANT_HOME or ~/.vacant.
vacant_dir
¶
Return the on-disk directory for vacant name. Validates the name
so callers cannot escape the home directory via path traversal.
Source code in src/vacant/cli/local_store.py
list_vacant_names
¶
Names of every initialised local vacant, sorted.
Source code in src/vacant/cli/local_store.py
current_name
¶
Resolve the active vacant: env VACANT_NAME, else the only one.
Raises LocalVacantNotFound if no vacant exists or multiple exist
without VACANT_NAME set.
Source code in src/vacant/cli/local_store.py
keyring_backend_available
¶
True iff the host's default keyring is a real backend.
The keyring library always returns some backend from
get_keyring(); on hosts without a working backend it returns
keyring.backends.fail.Keyring (a stub that raises on every
call). We detect that case by inspecting the module path so
callers can give a clear error before a write attempt fails.
Source code in src/vacant/cli/local_store.py
init_vacant
¶
init_vacant(name: str, *, insecure_demo: bool = False) -> tuple[VacantId, SigningKey]
Generate a fresh keypair and persist the local-vacant directory.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Local vacant name. Validated against path traversal. |
required |
insecure_demo
|
bool
|
If True, write the Ed25519 seed in plaintext into
|
False
|
Returns:
| Type | Description |
|---|---|
tuple[VacantId, SigningKey]
|
The new |
Raises:
| Type | Description |
|---|---|
LocalVacantExists
|
If |
LocalVacantKeyringUnavailable
|
If |
LocalVacantError
|
Any other failure (path traversal, keyring write error, …). |
Source code in src/vacant/cli/local_store.py
209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 | |
load_signing_key
¶
Load the Ed25519 signing key for name.
Looks up key_storage from meta.json (or, for legacy directories
without it, infers from key.json's key_storage field, then falls
back to the plaintext seed). Raises LocalVacantNotFound if the
directory is missing entirely; raises LocalVacantError if the
keyring entry has gone missing under us (e.g. operator cleared the
Keychain after init).
Source code in src/vacant/cli/local_store.py
load_envelope_state
¶
Load the per-target chain state for vacant call.
Returns {} if the file doesn't exist yet (first call). Schema:
{
"<target_vacant_id_hex>": {
"request": {"last_seq": int, "last_hash_hex": str},
"response": {"last_seq": int, "last_hash_hex": str}
}
}
Returned as dict[str, Any] because the file is JSON: leaf
values are ints + strs and the caller knows the schema. Strict
typing would force every read site through casts without buying
safety beyond the schema docstring above.
Source code in src/vacant/cli/local_store.py
save_envelope_state
¶
Atomically persist the envelope state. Uses tempfile +
os.replace so a crashed write does not leave a half-truncated
file (would otherwise cause the next call to replay seq=1).