Skip to content

feat(outpost): Outpost API client and command tree - #348

Draft
leggetter wants to merge 11 commits into
mainfrom
feat/outpost-api-client
Draft

feat(outpost): Outpost API client and command tree#348
leggetter wants to merge 11 commits into
mainfrom
feat/outpost-api-client

Conversation

@leggetter

Copy link
Copy Markdown
Collaborator

Adds Outpost support to the CLI: the API client layer and the full hookdeck outpost command tree. Part of #346. MCP (Phase 3) is not in this PR — it follows separately so the shared-plumbing refactor can be reviewed on its own.

Draft: the commands work and are tested, but have not been exercised by real users. The intent is to cut a v2.6.0-beta.1 from this branch, test in the real world, then merge.

What's here

hookdeck outpost
├── tenant            list · get · upsert · delete · token · portal
├── destination       list · get · create · update · delete · enable · disable
├── destination-type  list · get
├── event             list · get · retry
├── attempt           list · get
├── publish
├── topic             list
├── metrics           events · attempts
├── config            get · set · custom-domain (get · set · delete)
└── status

Plus the client layer (pkg/hookdeck/outpost_*.go), a destination-type schema cache, an outpost acceptance slice, and docs.

Three things worth a reviewer's attention

1. Config and credentials are key=value pairs, not flat flags.

hookdeck outpost destination create --tenant-id acme --type webhook \
  --config url=https://example.com/hooks

This departs from the flat-flag convention (--url, --bearer-token) used by gateway commands. Cobra registers flags at init, but destination fields differ per type and are only known after fetching the schema — declaring them would mean a network call before a command could parse its own arguments. The schema is still used, for validation and help. Dotted paths (--config a.b=c) are supported so a nested type shipping server-side would not need a CLI release.

This creates a genuine inconsistency: --config means "JSON object" in gateway commands and "key=value" here. Tracked in #347 with a proposed resolution (accept both formats in gateway — additive, so a minor).

2. --type <type> --help lists that type's fields.

Since --config alone cannot say which keys are valid, help does. Cobra parses flags before running the help function, so once a type is named it shows exactly that type's fields — cache-first, degrading silently to static help when unauthenticated, offline, or cold. Plain --help is unchanged and needs no network or credentials.

REFERENCE.md is provably unaffected: the generator reads command metadata directly and never invokes help. Verified with warm and cold caches, and pinned by a test asserting help never rewrites Long or flag usage.

3. outpost publish needs a Project API key.

It is the one command that does not accept the credentials hookdeck login stores, so it has its own --api-key defaulting to HOOKDECK_API_KEY, in the same shape as hookdeck ci. Without one it fails with its own guidance rather than a bare 401. Documented in README and AGENTS.md.

Two bugs the live tests caught

Both were invisible to unit tests, which is why a build-tagged live suite exists (test/acceptance/outpost_live_test.go, outpostlive):

  • Destination-type options is [{label, value}], not []string. Decoding the real endpoint failed outright. The stub fixture had encoded the wrong shape, which is exactly why the unit tests passed. Surfaced on kafka — a type missing from the published OpenAPI enum but live in the API.
  • Only HTTP 200 was treated as success. The Event Gateway API answers 200 to everything so this never surfaced, but Outpost uses 201 on create and 202 on publish/retry — every write failed. Fixed with an opt-in Client.AcceptAnySuccessStatus, set on the Outpost client only, so gateway behaviour is unchanged.

Testing

  • go build, go vet, go test ./... green
  • outpost acceptance slice run locally against a real Outpost project before committing, plus the gateway slice for the project-gate rejection test
  • Live suite run against the real API: tenant and destination CRUD, the topics union decoding the bare "*" form, publish, retry, event and attempt reads
  • CI gains a fourth acceptance slice backed by HOOKDECK_CLI_OUTPOST_TESTING_API_KEYthis PR is its first run

Notes for review

  • 8 commits, each self-contained; reviewing commit-by-commit is likely easier than the combined diff
  • Eight pkg/hookdeck/*.go files show as modified but are gofmt-only — they were already unformatted on main
  • Error assertions in the acceptance tests read stdout, not stderr, because that is where the CLI currently writes errors (Epic: make the CLI safe and predictable for agents and automation #340). Commented so they fail loudly if that contract changes rather than passing vacuously

leggetter and others added 10 commits August 14, 2026 15:42
First phase of Outpost support (#346): the API client layer that the
`hookdeck outpost` commands and MCP server will be built on. No user-facing
commands yet.

Client:
- Outpost API base URL, a separate client instance, and config resolution
  including a hidden --outpost-api-base for dev
- IsOutpostProject alongside IsGatewayProject
- Per-resource methods for tenants, destinations, events, attempts, retry,
  publish, topics, destination types, metrics, managed config, custom domain
  and status
- Destination type schemas fetched and cached per API host and project, so
  --type validation follows the API rather than a hardcoded list

Two shapes worth calling out. The `topics` field is a union — either "*" or an
array — so it decodes through a dedicated type rather than []string. Publish
takes a Project API key as a bearer token, which the stored CLI key cannot
satisfy, so it sends through a clone with no stored credential.

Live tests (build tag `outpostlive`) exercise the client against a real
project and found two bugs that the stub-based unit tests could not:

- destination-type `options` is [{label, value}], not []string; the stub
  fixture had encoded the wrong shape, which is why the unit tests passed
- only HTTP 200 was treated as success. The Event Gateway API answers 200 to
  everything, so this never surfaced, but Outpost uses 201 on create and 202
  on publish/retry, so every write failed. Fixed with an opt-in
  Client.AcceptAnySuccessStatus, set on the Outpost client only

Docs: README gains a key capability matrix and a way to tell which credential
you hold; AGENTS.md gains the same diagnosis for agents plus the acceptance
key table. The config field named api_key holds a CLI client key regardless of
origin, which is easy to misread.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
Adds the `hookdeck outpost` group with an Outpost-project gate mirroring the
Gateway one, plus the tenant command tree: list, get, upsert, delete, token
and portal.

The gate matters for the error message rather than for safety. Pointing an
outpost command at a Gateway project otherwise returns a 404, which reads as
"no such tenant" instead of "you are on the wrong project"; it now says which
type the project is and how to switch.

Tenants are created through upsert because their IDs are chosen by the caller
rather than generated. Delete names the destination count in its prompt, since
that is the part most likely to have been forgotten.

`--id` joins the empty-value guard list. It is a filter rather than an
identifier, but the failure is worse: an empty value drops the filter, so
`--id "$UNSET"` silently widens the query to everything rather than narrowing
it. Verified against a real project, along with the no-terminal delete path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
Adds `hookdeck outpost destination` — list, get, create, update, delete, enable
and disable — with --tenant-id persistent across the group, since every
destination endpoint is tenant-scoped.

Deviation from the plan worth noting. The plan called for flat per-field flags
(--config-url, --credential-secret). That is not implementable here: Cobra
registers flags at init, but destination fields differ per type and are only
known after fetching the schema, so declaring them would mean a network call
before every command could parse its own arguments. Config and credentials are
repeatable key=value pairs instead (--config url=https://example.com), with
--config-file and --credentials-file as escape hatches.

The schema is still used, for validation rather than flag registration: unknown
keys, missing required fields, values outside a declared option set and values
failing a declared pattern are all rejected before the request, naming the
exact flag to fix and pointing at `destination-type get <type>` for the field
list. Per AGENTS.md, a schema that cannot be fetched warns and continues rather
than blocking a valid command.

Update reads the existing destination to recover its type, so callers do not
have to repeat --type just to get their config validated, and refuses an update
with no fields rather than silently succeeding.

Verified against a real project: create, list, get, update, enable, disable,
schema validation, unknown type, missing tenant, and the no-terminal delete.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
Adds `hookdeck outpost destination-type list|get`, and makes
`destination create --type <type> --help` list that type's fields.

Dynamic help is the answer to the discoverability cost of key=value config
flags: `--config` alone cannot say which keys are valid, because the fields
belong to the Outpost deployment rather than the CLI. Cobra parses flags before
running the help function, so once a user has named a --type we can show
exactly the fields it accepts, sourced from the same schema used for
validation.

Three properties this holds to:

- Plain `--help` is untouched and needs no network or credentials. It only
  gains a line saying how to get per-type detail.
- Cache first. The schema cache is already per host and project with a 24h TTL,
  so the warm path is a local file read. A cold cache allows one request bounded
  at 2s, and only when credentials exist; unauthenticated, offline and cold-cache
  runs all fall back to static help rather than erroring or hanging.
- REFERENCE.md cannot be affected. The generator reads Long and the flag
  definitions directly and never invokes help, so generated docs stay identical
  whatever is cached locally. Verified with warm and cold caches, and pinned by
  a test asserting help never rewrites Long or flag usage.

One non-obvious detail: Cobra returns flag.ErrHelp before running the
cobra.OnInitialize hooks, so on the help path the config is not loaded yet.
Without initialising it the client has no base URL or project and the cache —
keyed on both — is never found.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
`--config a.b=c` now builds a nested object. Flat keys are unchanged, so this
is a no-op for every destination type that exists today.

It is added now because of what the key=value design is for. Outpost's
destination types are defined by the deployment rather than the CLI, which is
why fields are not hardcoded — but that cuts both ways: a nested type could
ship server-side just as easily as a new flat one. Flat-only parsing would
leave such a type impossible to create until we shipped a CLI fix, which is
precisely the failure the design exists to avoid. Paths cost nothing today and
remove that cliff.

The syntax follows Helm's --set (a.b.c=v, with a file as the escape hatch)
rather than being invented here. A literal dot can be escaped as `a\.b`; no
field key in either product contains one today, so that exists to avoid a
corner rather than to solve a present problem.

Validation now skips nested values instead of rejecting them. The schema
describes flat fields, so it cannot say whether a nested shape is valid, and
per AGENTS.md a client-side guess must not block a command the API would
accept.

Checked against the live API while deciding this: all 9 destination types are
flat and every value is a string on the wire. The /destination-types endpoint
reports some fields as key_value_map or checkbox, but those are form-rendering
hints — sending custom_headers as an object returns it normalised to a
JSON-encoded string, identical to sending a string. Context in #347.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
…d status commands

Completes the outpost command tree.

- event list/get/retry, attempt list/get — the debugging surface. Attempts carry
  the response code the destination returned, which is what you actually need
  when delivery is failing.
- publish — the one command with different auth. The publish API takes a Project
  API key as a bearer token and does not accept the credentials `hookdeck login`
  stores, so it has its own --api-key defaulting to HOOKDECK_API_KEY. Without
  one it fails with an actionableError explaining why, rather than surfacing a
  bare 401 that the generic handler would rewrite into "your API key is invalid
  or expired" — true but useless, since the stored key is never valid here.
- topic list — reports the fix when no topics are configured, since an empty
  list leaves the project unable to deliver anything.
- metrics events/attempts — reports when results were truncated at the row
  limit, so a partial answer is not mistaken for a complete one.
- config get/set and config custom-domain — set takes KEY=VALUE arguments with
  --unset to restore a default, and --dry-run showing before/after per key.
  These settings apply to every tenant in the project, so the diff matters.
- status — the first thing to check when configuration changes have not taken
  effect yet.

Attempt list uses the tenant-scoped route when exactly one tenant and one
destination are given, and the general one otherwise; results are identical
either way.

Verified against a real project: publish end to end with matched destinations,
retry recorded as a manual second attempt, dry-run confirmed not to apply,
pagination, and the missing-key error path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
Adds test/acceptance/outpost_test.go behind the `outpost` build tag, covering
tenant and destination lifecycles, destination types, publish and inspect,
metrics, config, and the validation error paths.

The suite needs its own project. Every `hookdeck outpost` command requires an
Outpost project, so the Gateway keys the existing slices use would be rejected
by the project gate before any request is made. NewOutpostCLIRunner reads
HOOKDECK_CLI_OUTPOST_TESTING_API_KEY, which is a Project API key doing double
duty: exchanged via `hookdeck ci` for the CLI credentials most commands use, and
passed directly to `outpost publish`, which does not accept CLI credentials.

The Gateway-rejection test lives in the gateway slice rather than this one,
because asserting that a Gateway project is refused needs a Gateway project.

Two things worth noting for anyone extending this:

- Error assertions read stdout, not stderr. The CLI prints errors to stdout
  today (see #340, which tracks moving them); `go run` writes its own "exit
  status 1" to stderr, so asserting there passes vacuously. The tests are
  commented so this fails loudly if the contract changes rather than silently
  checking the wrong stream.
- Tenants are uniquely named per run and removed in t.Cleanup. The project is
  shared between local runs and CI, and a failed run can leave data behind, so
  nothing assumes it starts empty.

Both suites were run locally against the real project before committing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
Adds the generated REFERENCE.md block for the outpost command tree, a README
section, and the publish key exception to AGENTS.md.

The generator's table of contents is a hand-maintained list rather than being
derived from headings, so Outpost was added there — along with Metrics, which
had been missing since it was introduced.

Both docs lead with the two things that are genuinely surprising: config and
credential fields are key=value pairs because they belong to the Outpost
deployment rather than the CLI, and publish needs a Project API key because it
is the one command that does not accept the credentials `hookdeck login`
stores.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
Raises CLI-level acceptance coverage from 22/30 to 26/30 leaf commands. All
four reuse data the existing tests already create, so they add coverage without
adding setup.

The tenant token assertion checks shape rather than contents — three JWT
segments, and that the raw tenant id is not readable in it. The token is a real
credential, so a test should not print or match on its payload.

The four commands still uncovered are the tenant portal and its custom domain.
They are not omitted casually: `custom-domain set` configures a real DNS-verified
hostname on the shared project, and `tenant portal` returns 404 until one exists.
Covering them safely needs a dedicated throwaway domain. They are the least
proven surface and should be called out as such in beta release notes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajsob136PyRh6nc92w5L3
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant