First-class command-line client that drives the playtesthub backend over its real wire protocol. Exists so humans and AI agents can exercise the app end-to-end without touching the two frontends, and so the e2e test suite (PRD §4.1, engineering.md §3.3) has a single reusable harness.
Referenced from STATUS.md M1 phase 10, engineering.md §2, and PRD §4.7.
- Give a human operator a frictionless way to poke any RPC during development, bug-repro, and demo walkthroughs.
- Give an AI agent (Claude Code, local automation) a self-describing surface for exercising flows — no scraping the proto, no hand-rolled curl against the grpc-gateway.
- Serve as the only e2e harness. The
e2e/*_test.gosuite shells out topthsubcommands against an in-process server + testcontainers-postgres. One code path, one set of bugs. - Dogfood the wire contract. Every proto/HTTP-annotation change surfaces in the CLI on the next build; stale contracts fail loudly instead of quietly.
- Not a prod admin tool.
pthis for developers and test harnesses. Studio admins use the Extend App UI. - Not a load generator. Perf runs live in
scripts/loadtest/(PRD §6 / §7). - No stateful session / REPL. One command, one RPC, one exit code. Composition via shell.
- No config files. Env vars + flags only, matching backend convention (PRD §5.9).
- No SDK layer. Consumers do not import
pthas a Go package. Everything is a subcommand; the CLI is the surface.
- Binary:
pth. Source atcmd/pth/. - Language: Go. Reuses the generated stubs in
pkg/pb/— zero extra toolchain. - Transport: gRPC directly against
:6565(the native port), not the grpc-gateway REST proxy. Faithful exercise of the wire contract and identical semantics to what the Svelte player app's REST calls get routed through. - Distribution:
go install github.com/anggorodewanto/playtesthub/cmd/pth@latestfor dev boxes; built into the backend Docker image for e2e. - Output: JSON on stdout (one document per call, pipeable to
jq). Human-readable error + gRPC status code on stderr. Exit code is0on gRPCOK, non-zero on any other status.
pth [global flags] <domain> <action> [action flags]
pth <meta command>
Global flags (all overridable per invocation; most have env-var fallbacks so scripted flows stay terse):
| Flag | Env var | Purpose |
|---|---|---|
--addr |
PTH_ADDR |
gRPC endpoint. Default localhost:6565. |
--base-path |
PTH_BASE_PATH |
Backend BASE_PATH if the server is fronted by a prefix; used when talking to Extend rather than a local instance. |
--namespace |
PTH_NAMESPACE |
AGS namespace. Required for any auth flow and for AGS-scoped RPCs. |
--profile |
PTH_PROFILE |
Named profile in the credentials store (§7). Default default. Lets you juggle prod / sandbox / per-test-user sessions without re-logging-in. |
--token |
PTH_TOKEN |
Override: raw AGS IAM bearer token, passed verbatim. Bypasses the credentials store — useful for CI where the token is already in a secret. |
--anon |
— | Send no Authorization metadata. For unauth RPCs (GetPlaytest unauth variant). |
--timeout |
PTH_TIMEOUT |
Per-call gRPC deadline. Default 10s. |
--insecure |
PTH_INSECURE |
TLS-off for local work. Default true when --addr is loopback. |
-v / --verbose |
— | Log the outgoing RPC + headers (token redacted) to stderr. |
Token resolution order: --token > PTH_TOKEN > credentials store (§7) keyed by (addr, namespace, profile). --anon short-circuits all of them.
These exist so a fresh operator — person or agent — can discover the surface without reading this document.
pth version— build metadata (git SHA, proto schema version, Go version).pth doctor— connectivity + auth smoke test. AttemptsGetPlaytest(unauth) against a known sentinel slug; reports gRPC status, round-trip latency, and the server'sBASE_PATH. Non-zero exit if anything fails.pth describe— emits a JSON catalogue of every subcommand: name, milestone, required flags, optional flags, description, example. Stable schema (cli-schema.v1). AI agents read this instead of parsing--helpprose.pth auth …— login / logout / whoami against a real AGS IAM. Full detail in §7.pth <cmd> --help— GNU-style help for humans.pth <cmd> --dry-run— prints the gRPC request body (JSON) that would be sent, and exits. Does not open a connection.
Commands mirror PRD §4.7, grouped by domain. Each command lands in the same milestone as the RPC it wraps — the CLI grows with the backend, not ahead of it.
Auth group (wraps AGS IAM directly, not a playtesthub RPC):
| Command | Purpose |
|---|---|
pth auth login --discord [--manual] [--no-browser] |
Interactive Discord-federated login (§7.1). Captures an AGS access token via loopback callback or manual paste. |
pth auth login --password --username <u> [--password-stdin] |
AGS IAM ROPC grant for a native AGS user — the path test users and admins with AGS credentials use (§7.2). Password via TTY prompt by default; --password-stdin for CI. |
pth auth logout [--profile <p>] |
Clears the stored credential. |
pth auth whoami |
Prints {userId, namespace, expiresAt, loginMode} for the active token; non-zero exit if expired or missing. |
pth auth token |
Prints the active bearer token to stdout. For piping into other tools. |
User group (AGS IAM admin endpoints; admin token required):
| Command | Purpose |
|---|---|
pth user create [--count <n>] [--country <iso2>] |
Creates one or more AGS test users in $PTH_NAMESPACE via POST /iam/v4/admin/namespaces/{ns}/test_users. AGS generates the username/password/email itself and skips email verification — --count defaults to 1, --country defaults to US. Emits {userId, username, password, emailAddress} per user (single object when count=1, array when count>1). No Discord federation — the user has no Discord ID claim, so backend Signup falls back to the raw IAM sub per PRD §10 M1. |
pth user delete --id <userId> |
Destructive — prompts for yes unless --yes. Hits DELETE /iam/v3/admin/namespaces/{ns}/users/{userId}/information. Used by e2e teardown. |
pth user login-as --id <userId> [--password-stdin] |
Convenience: password-login as a previously-created test user and store the credential under a named profile. Looks up the username via GET /iam/v3/admin/namespaces/{ns}/users/{userId} (admin scope) then runs the ROPC grant. The password (AGS-generated by pth user create) must be supplied on stdin or via the TTY prompt. |
Playtest + applicant group (playtesthub RPCs):
| Command | Wraps RPC | Notes |
|---|---|---|
pth playtest get-public --slug <s> |
GetPlaytest (unauth) |
--anon implied. |
pth playtest get-player --slug <s> |
GetPlaytestForPlayer |
Requires player token. |
pth playtest get --id <id> |
GetPlaytest (admin) |
Admin token. |
pth playtest list |
ListPlaytests |
Admin. |
pth playtest create --slug <s> --title <t> [--distribution-model STEAM_KEYS|AGS_CAMPAIGN|ADT] [--nda-required] [--nda-text @file.md] [--starts-at <ts>] [--ends-at <ts>] [--platforms STEAM,XBOX,...] [--description <d>] [--banner-image-url <url>] [--initial-code-quantity <n>] [--auto-approve --auto-approve-limit <n>] [--adt-namespace <n>] [--adt-game-id <id>] [--adt-build-id <id>] [--adt-fallback-url <url>] |
CreatePlaytest |
M5.A: --auto-approve + --auto-approve-limit (1..100,000) enable the auto-approve path. M5.B: pass --distribution-model=ADT with the four --adt-* flags to create an ADT playtest. M2: --initial-code-quantity is required for --distribution-model=AGS_CAMPAIGN. |
pth playtest edit --id <id> [--title <t>] [--description <d>] [--banner-image-url <url>] [--platforms <csv>] [--starts-at <ts>] [--ends-at <ts>] [--nda-required] [--nda-text @file] [--auto-approve --auto-approve-limit <n>] |
EditPlaytest |
Only PRD-whitelisted fields. ADT identifiers (adt_namespace/adt_game_id/adt_build_id) are immutable post-create; only --adt-fallback-url would be editable (currently surfaced via the admin UI, not yet exposed on the CLI). |
pth playtest delete --id <id> |
SoftDeletePlaytest |
Idempotent. |
pth playtest transition --id <id> --to <status> |
TransitionPlaytestStatus |
|
pth applicant signup --slug <s> --platforms STEAM,XBOX |
Signup |
Requires player token. The proto field is slug, so we surface --slug here for symmetry with playtest get-public --slug. |
pth applicant status --slug <s> |
GetApplicantStatus |
Player's own. Same --slug reasoning as above. |
| Command | Wraps RPC |
|---|---|
pth applicant accept-nda --playtest <id> |
AcceptNDA |
pth applicant list --playtest <id> [--status <s>] [--cursor <c>] |
ListApplicants |
pth applicant approve --id <id> |
ApproveApplicant |
pth applicant reject --id <id> [--reason <r>] |
RejectApplicant |
pth applicant retry-dm --id <id> |
RetryDM |
pth applicant get-code --playtest <id> |
GetGrantedCode |
pth code upload --playtest <id> --file <csv> |
UploadCodes |
pth code top-up --playtest <id> --quantity <n> |
TopUpCodes |
pth code sync-from-ags --playtest <id> |
SyncFromAGS |
pth code pool --playtest <id> |
GetCodePool |
| Command | Wraps RPC |
|---|---|
pth survey create --playtest <id> --from <yaml> |
CreateSurvey |
pth survey edit --id <id> --from <yaml> |
EditSurvey |
pth survey get --playtest <id> |
GetSurvey |
pth survey submit --playtest <id> --from <yaml> |
SubmitSurveyResponse |
pth survey responses --playtest <id> [--survey <sid>] [--cursor <c>] |
ListSurveyResponses |
pth audit list --playtest <id> [--actor <a>] [--action <a>] [--cursor <c>] |
ListAuditLog |
pth applicant retry-failed-dms --playtest <id> |
RetryFailedDms |
| Command | Wraps RPC | Notes |
|---|---|---|
pth playtest schedule-info --id <id> |
AdminGetPlaytest (read-only) |
Echoes the playtest's startsAt / endsAt window + the worker's next tick boundary. Used to debug the auto-DRAFT→OPEN→CLOSED transitions (PRD §5.1, docs/STATUS_M4.md). |
PRD §4.8 / docs/runbooks/adt-linking.md. All seven RPCs require admin auth + --namespace; all accept --dry-run to print the request JSON without dialling.
| Command | Wraps RPC | Notes |
|---|---|---|
pth adt linkage list |
ListADTLinkages |
Lists live adt_linkage rows for the caller's studio. Identity columns only — no credential payload. |
pth adt linkage start |
StartADTLink |
Returns {linkUrl, state}. Open linkUrl in a browser, complete ADT-side sign-in, capture state + adt_namespace from the redirect, then run complete. |
pth adt linkage complete --state <s> --adt-namespace <ns> |
CompleteADTLink |
Idempotent on duplicate (studio, adt_namespace); replay with a consumed state returns InvalidArgument. |
pth adt linkage unlink --id <adt_linkage_id> |
UnlinkADT |
Idempotent; best-effort DELETE against ADT to drop its side's flag. |
pth adt linkage recover --adt-namespace <ns> |
RecoverADTLinkage |
Adopts an orphan ADT-side flag (flag present on ADT, no local row) without an OAuth round-trip. Probes ADT first; FailedPrecondition if no flag exists, AlreadyExists if a non-deleted local row already covers (studio, adt_namespace). |
pth adt build list --linkage-id <id> --game-id <gid> |
ListADTBuilds |
Defense-in-depth check: CreatePlaytest ADT branch reuses the same path to verify the picked adt_build_id belongs to (adt_namespace, adt_game_id). Each row carries buildType (ADT build_type): buildinfo builds are downloadable; smartbuild (and any other type) cannot mint a download URL and are greyed out / non-selectable in the admin picker. The list is unfiltered (both types returned); ADT also accepts a server-side ?buildType=buildinfo|smartbuild filter, which playtesthub deliberately does not send so operators see the full set. |
pth adt build check --playtest-id <id> |
CheckADTBuild |
Probes whether the playtest's current build can still mint a download URL (same call as ApproveApplicant) and persists adt_build_status. healthy=false + UNAVAILABLE when ADT returns build-not-found; surfaces the dead build on the detail page without an approval attempt (M5.C). |
pth adt games list --linkage-id <id> |
ListADTGames |
Drives the admin build-picker top-level dropdown so operators no longer type the adt_game_id by hand. STATUS_M5.md B12. |
pth adt diagnostics |
GetADTClientDiagnostics |
Reports which adt.Client kind the bootapp wired (http vs mem) plus the presence (booleans only — never values) of every env var that feeds the gate (PLUGIN_GRPC_SERVER_AUTH_ENABLED, ADT_BASE_URL, AGS_BASE_URL, AGS_IAM_CLIENT_ID, AGS_IAM_CLIENT_SECRET). Use when UnlinkADT appears to soft-delete locally but ADT still reports the linkage — mem here means the boot gate silently fell back and ADT-side propagation is a no-op. |
PRD §5.7 / docs/runbooks/announcement-broadcast.md. Admin-authored; subject + message bodies are PII-sensitive and never logged.
| Command | Wraps RPC | Notes |
|---|---|---|
pth announcement create --playtest-id <id> --subject <s> --message <m> [--send-to ALL|APPROVED_ONLY|PENDING_ONLY] |
CreateAnnouncement |
Subject 1–200 chars; message 1–4000 chars. Default filter APPROVED_ONLY. Fan-out is async through the existing M2 DM queue; the response carries {announcementId, recipientCount}. |
pth announcement list --playtest-id <id> |
ListAnnouncements |
Returns past announcements with {date, subject, recipientCount, status: Sent|Sending|Failed}. |
Composite commands that run a whole PRD §4.1 golden flow end-to-end in one shot. Each flow prints one JSON document per step to stdout (one line per step, NDJSON), so the caller can grep/jq through the sequence. Flows are the bread-and-butter surface for the e2e suite.
| Command | Milestone | Steps |
|---|---|---|
pth flow golden-m1 --slug <s> |
M1 | create-playtest → transition OPEN → signup (synthetic player) → assert status=PENDING. 4 NDJSON steps. |
pth flow golden-m2 --slug <s> [--auto-approve --auto-approve-limit <n>] |
M2 / M5.A | golden-m1 → accept-nda → upload codes → approve → assert status=APPROVED + code visible. 7 NDJSON steps. With --auto-approve: upload-codes is hoisted before signup and the manual approve step is replaced by assert-applicant-auto-approved. |
pth flow golden-m3 --slug <s> [--auto-approve --auto-approve-limit <n>] |
M3 / M5.A | golden-m2 → create survey → submit response → list responses. 10 NDJSON steps. M5.A variant inherits the M2 auto-approve reorder. |
pth flow golden-m4 --slug <s> |
M4 | playtest window enforcement: create with past startsAt + future endsAt → wait for worker tick → assert auto-transitioned to OPEN → fast-forward endsAt → assert CLOSED. 4 NDJSON steps. |
pth flow golden-m5 --slug <s> |
M5.B | golden-m1 prefix → link-adt-start → link-adt-complete → list-builds → create-playtest with distributionModel=ADT --auto-approve --auto-approve-limit 5 → transition OPEN → signup → assert-auto-approved → assert-adt-download-info-non-empty. 11 NDJSON steps. Uses adt.MemClient end-to-end; the live HTTPClient round-trip is an operator validation step on a deployed namespace (see docs/runbooks/adt-linking.md). |
Each flow takes --admin-token and --player-token flags (or their --fake-jwt equivalents) so it can run against any environment. Flows are pure CLI composition — they do not ship their own gRPC client; they invoke the single-RPC subcommands in-process.
pth talks to real AGS IAM only. There is no fake-JWT / dev-bypass mode — every token is minted by AGS IAM in the configured namespace. Two login flows cover the human-vs-automation split.
Mirrors the player-side browser flow byte-for-byte: Discord OAuth in the user's browser → CLI loopback receives the Discord authorization code → CLI POSTs the code to the backend's Player.ExchangeDiscordCode RPC → backend runs the AGS platform-token grant and returns AGS access + refresh tokens. Used when a human wants to exercise playtesthub as a real Discord-federated player, or when validating the federation path itself works.
This flow does not hit AGS IAM's /iam/v3/oauth/authorize endpoint. STATUS.md M1 phase 9.2 + 9.3 documented that the AGS authorization-code grant fails with invalid_grant: justice platform account not found on shared-cloud game namespaces because that codepath skips Justice-platform-account autocreation. The platform-token grant (which Player.ExchangeDiscordCode wraps) is the one AGS path that works for player-side Discord login on shared cloud.
Sequence:
- CLI binds a one-shot loopback HTTP listener at
http://127.0.0.1:<PTH_DISCORD_LOOPBACK_PORT>/callback(default port14565). The port is fixed rather than ephemeral — see "Setup requirement" below for why. - CLI constructs the Discord OAuth authorize URL:
https://discord.com/oauth2/authorize?response_type=code&client_id=<PTH_DISCORD_CLIENT_ID>&redirect_uri=http://127.0.0.1:<port>/callback&state=<random>&scope=identify+email. - CLI prints the URL to stderr and — unless
--no-browser— opens it viaxdg-open/open/start. User authenticates with Discord; Discord redirects to the loopback URL with?code=...&state=.... - Loopback handler validates
state, then POSTs{"code": "...", "redirect_uri": "http://127.0.0.1:<port>/callback"}to<PTH_BACKEND_REST_URL>/v1/player/discord/exchange(the grpc-gateway REST surface — gRPC's native port can't carry the unauth REST mapping). Backend runs the AGS platform-token grant and returns{access_token, refresh_token, expires_in, token_type}. - CLI renders a "you can close this tab" page, shuts down the listener, writes the AGS tokens + a synthetic
userId(decoded from the JWTsubclaim) to the credentials store withloginMode="discord"(§7.3).
--manual bypasses the loopback listener: CLI prints the Discord authorize URL, user logs in in any browser, user copy-pastes the final redirect URL (or just the code param) back into the CLI prompt. CLI then performs steps 4–5. Use this when the user can't hit 127.0.0.1:14565 from their browser (remote dev box, port already in use, restricted network).
--no-browser prints the URL and waits for callback but does not auto-open — useful over SSH with browser-on-laptop port forwarding.
--dry-run prints the constructed authorize URL + listener address + exchange URL to stdout (one JSON object) and exits 0 without binding the listener or POSTing anything. Establishes the pattern reused by every other subcommand for offline introspection.
Required env vars (no global flag equivalents — CLI-only secret-of-config that the rest of the surface doesn't need):
| Env var | Purpose |
|---|---|
PTH_DISCORD_CLIENT_ID |
The Discord OAuth Client ID (public). Same value the player Vite bundle reads from player/public/config.json as discordClientId. |
PTH_DISCORD_LOOPBACK_PORT |
Port the loopback listener binds to. Default 14565. Must match the value registered on Discord + AGS Admin Portal — see below. |
PTH_BACKEND_REST_URL |
HTTPS base URL of the backend's grpc-gateway (e.g. https://<ags-host>/ext-<ns>-<app>). The exchange POST goes here, not the gRPC --addr. |
Setup requirement: register http://127.0.0.1:14565/callback (or whatever fixed port PTH_DISCORD_LOOPBACK_PORT resolves to) as an allowed redirect URI in two places:
- Discord developer portal → OAuth2 → Redirects. Discord matches byte-for-byte including port — random ephemeral ports cannot work here. The fixed-port choice exists specifically to make this allowlist a one-time operator step.
- AGS Admin Portal → Login Methods → Platforms → Discord → RedirectUri. AGS forwards this exact value to Discord's
/oauth2/tokenwhen redeeming the code; mismatch →invalid_grant: Invalid "redirect_uri" in request.Seedocs/runbooks/setup-ags-discord.md§ "Three URLs that must agree byte-for-byte" — the same constraint that governs the player flow applies here, and the implication is the same: one Discord-platform credential per redirect URI per AGS tenant. Operators who want Discord login to work for both the player web app and the CLI need either two AGS namespaces (each with its own Discord platform credential) or two Discord OAuth applications targeting one AGS tenant via separate platform configs.
Procedure documented in docs/runbooks/setup-ags-discord.md § "CLI loopback origin (pth auth login --discord)". --manual mode is governed by the same allowlist constraints — the pasted redirect URL still has to match a registered value.
AGS IAM ROPC grant against a native (non-federated) AGS user. Used for:
- Test players created via
pth user create— no Discord account required. - Admins logging in with their AGS Admin Portal credentials for scripted admin work.
- All CI / e2e runs.
Password is read from a TTY prompt by default; --password-stdin consumes one line from stdin for headless use. The password never appears in flags, argv, or shell history.
Stored at ~/.config/playtesthub/credentials.json (Linux/macOS) / %APPDATA%\playtesthub\credentials.json (Windows). File perms 0600, directory perms 0700; CLI refuses to read the file if perms are looser and prints remediation.
Schema:
{
"version": 1,
"profiles": {
"default": { "addr": "...", "namespace": "...", "userId": "...", "loginMode": "discord", "accessToken": "...", "refreshToken": "...", "expiresAt": "..." },
"test-admin": { "addr": "...", "namespace": "...", "userId": "...", "loginMode": "password", "accessToken": "...", "refreshToken": "...", "expiresAt": "..." },
"test-player-a": { ... }
}
}--profile <name> selects which profile to use for any subsequent call. CLI auto-refreshes a token that is within 60s of expiry using the stored refresh token; if the refresh fails, the CLI prompts the user to re-login with a clear message (e.g. token for profile 'default' has expired — run 'pth auth login --discord' to re-authenticate).
E2E tests drive the CLI like any other consumer:
- At suite setup, an admin-credentialed profile logs in once via
pth auth login --passwordusing credentials sourced from CI secrets. - Each test creates one or more throwaway test users via
pth user create(capturing the AGS-generated username + password from stdout), then logs each in as its own profile viapth user login-as --id <userId> --password-stdin. - Test exercises the flow using
--profile test-player-<n>for player calls and--profile test-adminfor admin calls. - Teardown calls
pth user delete --id <userId> --yesfor each created user and clears the test profiles.
Because tests run against the user's own AGS namespace (not a dedicated e2e namespace), tests must use unique slugs per run so a failed teardown does not poison the next run. Slug strategy: e2e-<timestamp>-<random>. AGS owns username generation for test users (no caller-pinned name) so collision is not a concern. The e2e harness owns its own cleanup scheduler that deletes stale test rows older than 24h as a belt-and-braces.
Single-RPC commands:
- stdout: one JSON document — the unmarshalled proto response. Protobuf field names, not Go field names.
null/ absent fields omitted perprotojson. - stderr: empty on success;
gRPC <CODE>: <message>\non failure. - exit code:
0on gRPCOK;1forInvalidArgument/NotFound/client errors;2forUnavailable/DeadlineExceeded/transport errors;3for local flag-parse / env errors.
Flow commands:
- stdout: NDJSON, one step per line. Each line is
{"step":"signup","status":"OK","response":{...}}on success or{"step":"approve","status":"FAILED","error":{"code":"FailedPrecondition","message":"..."}}on failure. - Flow stops at the first failed step and exits non-zero.
--dry-run for single-RPC commands prints the request JSON to stdout and exits 0 without opening a connection.
Lives at cmd/pth/. This is the first cmd/ dir in the tree — the template is flat, but a standalone binary justifies it.
cmd/pth/
├── main.go # flag parsing + dispatch
├── globals.go # global-flag parser + dial-time bearer resolution
├── auth.go # `pth auth …` subcommand dispatcher (login/logout/whoami/token)
├── credstore.go # credentials.json read/write + profile selection
├── iamclient.go # AGS IAM ROPC + refresh-token grants
├── discord.go # Discord-direct → backend ExchangeDiscordCode flow + --manual
├── output.go # JSON/NDJSON writers, exit-code mapping
├── version.go # `pth version`
├── doctor.go # `pth doctor`
├── describe.go # `pth describe` catalogue (phase 10.6)
├── registry.go # source-of-truth command catalogue (phase 10.6)
├── user.go # user create/delete/login-as — AGS IAM admin endpoints (phase 10.4)
├── playtest.go # playtest subcommands (10.5 fans this out)
├── applicant.go # applicant subcommands (phase 10.5)
├── flow.go # `pth flow golden-m1` (phase 10.6)
├── testdata/describe.golden.json # CI diff-check (phase 10.6)
└── *_test.go # unit tests (see below)
Tests:
- Unit (
cmd/pth/*_test.go): flag parsing, output formatting, exit-code mapping,describecatalogue stability. Mock the gRPC client at thepb.PlaytesthubServiceClientinterface boundary. - Integration — none. The CLI's integration path is the e2e suite.
- E2E (
e2e/*_test.go): boot backend in-process + testcontainers-postgres, shell out to thepthbinary, assert on stdout/exit code. The M1 phase 10 e2e test (STATUS.md) is the first consumer; M2/M3 e2e tests extend the same harness.
CI gate: pth describe output is regenerated and diff-checked on every PR (same discipline as the admin codegen gate in engineering.md §5). This catches silent command-catalogue drift before it reaches AI consumers.
The CLI is the primary surface for AI-driven exercise of the app. Four deliberate choices support this:
- Self-describing:
pth describegives a stable JSON catalogue so an agent can enumerate the surface without reading this doc or the proto. - Dry-run:
pth <cmd> --dry-runlets an agent validate a request shape before committing an action — a cheap way to avoid destructive mistakes mid-conversation. - Deterministic output: one JSON doc per RPC, protobuf field names, no prose. No log noise on stdout. Agents can
jqwithout brittle parsers. - Scriptable auth:
pth user create+pth auth login --password --password-stdinlets an agent mint and log in as an isolated test user without any interactive step. Destructive endpoints (user delete) still require--yesso an agent can't silently wipe a real user.
Agents should prefer pth flow golden-m* for reproducing the PRD golden flow; single-RPC subcommands are for targeted probing. For any multi-step scenario, an agent should create a dedicated test user per run rather than reusing a profile — keeps runs independent and teardown scoped.
| Milestone | Deliverable |
|---|---|
| M1 phase 10 | pth binary; meta commands (doctor, describe, version, --dry-run); auth group (login --discord loopback + --manual, login --password, logout, whoami, token) with credentials store + refresh; user group (create, delete, login-as) against AGS IAM admin endpoints; all M1 playtest/applicant subcommands (§6.1); pth flow golden-m1. E2E test (phase 11) consumes it. |
| M2 | All M2 subcommands (§6.2); pth flow golden-m2. |
| M3 | All M3 subcommands (§6.3); pth flow golden-m3. |
Update it. The CLI surface is a public contract for humans, AI agents, and the e2e harness — silent drift here is worse than silent drift in internal code. If an RPC is added, renamed, or removed, the matching row in §6 changes in the same commit as the proto change.