Closes findings P3-1 and P3-2 from the 2026-05-05 CLI/API/MCP↔GUI parity
audit (cowork/cli-gui-parity-audit-2026-05-05/RESULTS.md). Both findings
flagged hidden defaults that the CLI was sending without exposing them
to operators: `force=false` baked into every renew payload, and a silent
fallback to `reason="unspecified"` whenever --reason was omitted.
P3-1 — promote --force on `certs renew` (full end-to-end plumbing)
The pre-2026-05-05 CLI sent `{"force": false}` in the renew body. The
API handler never decoded it — a textbook "lying field" per the
operator's CLAUDE.md "complete path, not the easy path" rule: the body
field stored a value, claimed to do something, and silently did nothing
because the wire never reached the consumer. Adding a --force flag that
also went unread would have created another lying field.
This commit takes the complete path:
service.CertificateService.TriggerRenewal grew a `force bool` parameter
(internal/service/certificate.go). When force=true, the
RenewalInProgress block is overridden so operators can recover stuck
in-flight renewals where a previous job hung without releasing the
status flag. Archived and Expired remain terminal blockers regardless
of force — those are semantic dead-ends that --force should not paper
over (archived = decommissioned, expired = issue a new cert instead of
renewing a dead one).
handler.CertificateHandler.TriggerRenewal parses force from
?force=true (or ?force=1) query param, OR {"force": true} JSON body,
whichever the client picks. Defaults to false. Passes through to the
service.
internal/cli/client.go::RenewCertificate(id, force bool) sends
?force=true on the URL when --force is set. The historical hardcoded
`{"force": false}` body is gone — no more lying field.
cmd/cli/main.go dispatches `certs renew <id> [--force]` (ID-first
flag-second convention matches the existing `agents retire <id>
[--force]`).
P3-2 — require --reason on `certs revoke` (Option A: strict refusal)
The pre-2026-05-05 CLI dropped to `--reason unspecified` whenever the
operator omitted the flag. Compliance reporting (RFC 5280 §5.3.1, PCI-
DSS §3.6, HIPAA §164.312) relies on the reason code being meaningful;
silent fallback defeats the audit trail because every revocation looks
identical.
cmd/cli/main.go dispatch refuses to send when --reason is empty,
prints the canonical RFC 5280 §5.3.1 reason-code menu, and exits
non-zero.
internal/cli/client.go exposes ValidRevokeReasons() returning the
canonical camelCase list (unspecified, keyCompromise, caCompromise,
affiliationChanged, superseded, cessationOfOperation, certificateHold,
removeFromCRL, privilegeWithdrawn, aaCompromise) and
NormalizeRevokeReason() that accepts both camelCase and snake_case
inputs and normalises to the canonical wire form. Off-list reasons
are rejected at dispatch with the menu re-printed.
Test pins:
internal/cli/client_test.go::TestClient_RenewCertificate_ForceFlag —
--force=true sends ?force=true with empty body; --force=false sends
no query and no body.
internal/cli/client_test.go::TestNormalizeRevokeReason +
TestValidRevokeReasons — canonical-camelCase + snake_case + reject-
off-enum behaviour.
cmd/cli/dispatch_test.go::TestHandleCerts_Revoke_RequiresReason +
TestHandleCerts_Revoke_RejectsUnknownReason +
TestHandleCerts_Renew_ForceFlag — dispatch-layer pins for the same
contracts.
internal/api/handler/certificate_handler_test.go::TestTriggerRenewal_
ForceQueryParam — query-param passthrough (no-flag, force=true,
force=1, force=false) flows through to the service-layer parameter.
internal/service/certificate_test.go::TestTriggerRenewal_
ForceOverridesInProgress — force=false preserves the
RenewalInProgress block; force=true clears it.
Existing TestTriggerRenewal_Archived extended to assert force=true
still blocks Archived (terminal-state guarantee).
Docs: docs/reference/cli.md updated with the --force example for renew
and the strict --reason semantics for revoke (including snake_case
input acceptance).
Acceptance gate (verified):
- go build ./cmd/server/... ./cmd/agent/... ./cmd/cli/...
./cmd/mcp-server/... clean.
- go vet ./... clean.
- go test -short -count=1 ./... pass repo-wide.
- bash scripts/ci-guards/openapi-handler-parity.sh clean
(router 178, OpenAPI 144, exceptions 36 — unchanged; we add
parameter parsing, not routes).
- gofmt -l clean.
5.2 KiB
certctl CLI
Last reviewed: 2026-05-05
certctl-cli is the command-line interface to certctl. It wraps the REST API as terminal commands so operators and CI/CD pipelines can drive certctl without writing curl invocations.
Install
go install github.com/certctl-io/certctl/cmd/cli@latest
The binary lands at $GOBIN/cli (or $HOME/go/bin/cli if GOBIN is unset). Rename to certctl-cli if you prefer.
Configure
The CLI reads three environment variables:
export CERTCTL_SERVER_URL=https://localhost:8443
export CERTCTL_API_KEY=your-api-key
export CERTCTL_SERVER_CA_BUNDLE_PATH=/path/to/ca.crt
Or pass them per-invocation:
certctl-cli --server https://localhost:8443 --api-key your-key --ca-bundle ca.crt certs list
For local development against a self-signed bootstrap cert, --insecure skips TLS verification. Never set this in production.
Command groups
The CLI is organized by resource:
certctl-cli certs [list|get|renew|revoke]
certctl-cli agents [list|get]
certctl-cli jobs [list|get|cancel]
certctl-cli import [bulk PEM import]
certctl-cli est [enroll|reenroll]
certctl-cli status [server health + summary stats]
certctl-cli version [CLI + server version]
Common workflows
List + filter certificates
# All certs
certctl-cli certs list
# Filter by environment
certctl-cli certs list --env production
# JSON output (default is table)
certctl-cli certs list --format json
# Sort + paginate
certctl-cli certs list --sort -expires_at --limit 50
# Time-range filter (RFC 3339)
certctl-cli certs list --expires-before 2026-06-01T00:00:00Z
# Sparse fields — only return the columns you need
certctl-cli certs list --fields id,common_name,expires_at,status
Trigger renewal
certctl-cli certs renew mc-api-prod
# Returns the job id; track with: certctl-cli jobs get <job-id>
# Recovery: clear a stuck in-flight renewal so a new one can start
certctl-cli certs renew mc-api-prod --force
--force clears the server-side RenewalInProgress block — used when a previous renewal job hung without releasing the status flag. --force does NOT override Archived or Expired (those are terminal states; archived = decommissioned, expired = issue a new cert instead of renewing a dead one).
Revoke
# Single revoke — --reason is REQUIRED (no silent fallback to 'unspecified')
certctl-cli certs revoke mc-api-prod --reason keyCompromise
# snake_case is accepted and normalised to camelCase before dispatch
certctl-cli certs revoke mc-api-prod --reason key_compromise
# Bulk revoke by filter
certctl-cli certs revoke --profile prof-deprecated --reason superseded
certctl-cli certs revoke --team t-payments --reason cessationOfOperation
certctl-cli certs revoke --issuer iss-old-vault --reason caCompromise
--reason is mandatory: omitting it prints the canonical RFC 5280 §5.3.1 menu and exits non-zero. Compliance reporting (PCI-DSS §3.6, HIPAA §164.312) relies on the reason code being meaningful, so the CLI no longer falls back silently. Valid camelCase set: unspecified, keyCompromise, caCompromise, affiliationChanged, superseded, cessationOfOperation, certificateHold, removeFromCRL, privilegeWithdrawn, aaCompromise. snake_case variants (key_compromise, cessation_of_operation, etc.) are accepted and normalised.
Bulk import
# Import a directory of PEMs
certctl-cli import /etc/letsencrypt/live/
# Import a single concatenated bundle
certctl-cli import certs.pem
Each cert lands in the inventory as Unmanaged (per the discovery model). Triage from the dashboard or via certctl-cli certs claim <id> once you've decided to actively manage it.
EST enrollment
# Enroll a new device cert via EST simpleenroll
certctl-cli est enroll --csr device.csr --output device.crt
# Re-enroll (renew) an existing device cert
certctl-cli est reenroll --csr device.csr --client-cert device.crt --client-key device.key
Server status
certctl-cli status
# Health: ok
# Total certificates: 145
# Expiring (30d): 12
# Active jobs: 3
# Pending renewals: 8
Output formats
--format table(default) — human-readable terminal output--format json— JSON for piping intojq, scripts, dashboards
The CLI is built with Go's standard library only — no external dependencies. The binary is small (~10MB) and statically linked.
Wiring into CI/CD
Common pattern: a CI step that issues a cert from your internal CA, deploys it via certctl, and verifies the deploy:
certctl-cli certs renew mc-api-prod --wait
certctl-cli jobs get $(certctl-cli certs renew mc-api-prod --json | jq -r '.job_id') --wait
certctl-cli certs get mc-api-prod --json | jq -r '.expires_at'
The --wait flag blocks until the job reaches a terminal state (Completed / Failed / Cancelled), which is what CI scripts actually need.
Related docs
docs/reference/api.md— the OpenAPI 3.1 spec the CLI wrapsdocs/reference/mcp.md— the MCP server that exposes the same surface to AI assistantsdocs/contributor/qa-prerequisites.md— local environment setup before the CLI can talk to a server