Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 69 additions & 9 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,41 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.6.0] - 2026-08-27

### Upgrading from 0.5.x

**Typed `get` commands now print the resource, not the API's envelope.** This
is the breaking change in this release. `apps get <id>` emitted
`{"app":{"id":…}}` and now emits `{"id":…}`, so `jq -r .id` returns the id
where it previously returned `null` — the reason for the change. Anything
reading *through* the wrapper must drop that hop: `.app.id` becomes `.id`,
`.userView.user.id` becomes `.id`, and `--fields function.id` becomes
`--fields id`. Nothing is lost — every key the envelope carried beside the
resource, `expanded` included, is now a top-level sibling. Two things
deliberately did **not** change: mutation output (`apps create` still returns
`{"app":…}`, so `.app.id` is still right there) and `c1i api`, which is a raw
passthrough. One asymmetry to know: `mcp servers get` has no `id` field at all
— its identity is `connectorId` — so read `.connectorId` there.

Two exit codes also changed. Both are narrower failures that previously
reported success or a generic error, so a script that branched on them needs a
look:

- `auth whoami` on a `200` carrying no usable identity (`null`, `{}`, or an
all-null identity) now exits **6** ("C1 failed") instead of **0**. A body
that is not a JSON object at all — truncated, empty, an array, a scalar —
also moves from the generic **1** to **6**.
- A `--fields` spec naming a wrapper key now **exits 2** rather than
returning less. 0.5.x documented `--fields function.id` as supported, and
`--fields userView`/`app`/`taskView` resolved; against unwrapped output they
match nothing, which is already a usage error. Migrate to the unqualified
name (`--fields id`).
- No other exit code moves. `c1i api`'s new partial-result warning goes to
**stderr** only; stdout is byte-for-byte unchanged, so a parser reading
stdout is unaffected. A caller folding stderr into stdout with `2>&1` will
see the new line.

### Added

- **`c1i api` now warns when it discards a pagination cursor.** A bare `api`
Expand Down Expand Up @@ -163,6 +198,32 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Changed

- **BREAKING: every typed `get` now prints the resource itself, not the API's
wrapper object.** Before, `apps get <id>` printed
`{"app":{"id":"…",…}}` and `users get <id>` printed
`{"userView":{"user":{"id":"…",…},…},"expanded":[…]}`, so `jq -r .id`
returned `null` at exit 0 on all twelve of them, while every `list` row
already exposed a flat `.id`. The resource's own fields are now at the top
level: `jq -r .id` works, and `--fields id` yields `{"id":"…"}` instead of
rebuilding the wrapper as `{"app":{"id":"…"}}`. **Anyone reading `.app.id`,
`.userView.user.id`, `.policy.id`, `.taskView.task.id`,
`.appEntitlementView.appEntitlement.id`, `.mcpServer.connectorId`,
`.tool.id`, `.profile.id`, `.catalogEntry.id`, `.automation.id`,
`.function.id` must now read `.id` (`.connectorId` for `mcp servers get`,
whose resource has no `id` field at all).** Affected:
`apps|policies|automations|functions|users|requests|entitlements get`,
`mcp servers get`, `mcp tools get`, `mcp toolsets get`,
`mcp toolsets get-by-entitlement`, `mcp servers catalog get`. Nothing is
dropped: everything the envelope carried beside the resource -- `expanded`
on the three `*View` responses, plus their `objectPermissions`, `userId`,
and `*Path` keys -- is preserved as a top-level sibling, so
`.expanded` is now read at the top level too. Naming a wrapper key in
`--fields` is correspondingly gone: `users get <id> --fields userView` is now
a zero-match usage error (exit 2), because that key is no longer in the
output. An unrecognized response shape is passed through unchanged rather
than partially unwrapped. Mutation output is unaffected: `apps create` still
answers under an `app` key, so `jq -r .app.id` remains correct there.

- **`--page-size` now says it is a request, not a promise, and every list
command says it the same way.** Measured against a live tenant, one page of
`apps list --page-size 10` returned 23 rows, `--page-size 25` returned 42,
Expand Down Expand Up @@ -334,20 +395,18 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
Both refusals are raw-string literals, so the line a user pastes from their
terminal greps straight back to the check that emitted it.

Re-measured while extracting the shared wait primitive: the help text now
states the discriminating half of the observation rather than just the
coverage -- `appOwners` was `[]` on all 46 apps *including the 45 that
`GET .../ownerids` reported owners for*, so an empty `appOwners` is not
evidence an app has no owners.

- **`.claude/worktrees/` is gitignored.** Agent worktrees land there, and
untracked they stamped every local build `+dirty` -- a string that reaches
the wire in the user-agent and the MCP gateway handshake's
`clientInfo.version` -- and a `git add -A` would have committed one as a
gitlink (mode 160000), an embedded-repo pointer no clone can resolve.

Re-measured while extracting the shared wait primitive, and the help text now
states the discriminating half of the observation rather than just the
coverage: `appOwners` was `[]` on all 46 apps then in the tenant --
*including the 45 that `GET .../ownerids` reported owners for* -- and on a
freshly created app immediately after `set-owners --wait` confirmed two
owners had provisioned. (The 47/46 counts above were the earlier pass; the
apps it created for the test have since been deleted.)

## [0.5.0] - 2026-08-21

### Added
Expand Down Expand Up @@ -1419,7 +1478,8 @@ First changelog entry; releases through v0.1.5 predate this file (see the

- CI enforces `gofmt` via golangci-lint; module-wide formatting normalized.

[Unreleased]: https://github.com/ConductorOne/c1i/compare/v0.5.0...HEAD
[Unreleased]: https://github.com/ConductorOne/c1i/compare/v0.6.0...HEAD
[0.6.0]: https://github.com/ConductorOne/c1i/releases/tag/v0.6.0
[0.5.0]: https://github.com/ConductorOne/c1i/releases/tag/v0.5.0
[0.4.1]: https://github.com/ConductorOne/c1i/releases/tag/v0.4.1
[0.4.0]: https://github.com/ConductorOne/c1i/releases/tag/v0.4.0
Expand Down
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,8 +199,9 @@ tracked root file outside its allowlist. Stage explicit paths rather than `-A`.
client errors with `%w` so `errors.As` can classify them, and wrap a bad
flag/arg combination in `&usageError{}` so it exits 2 — a bare `fmt.Errorf`
silently becomes exit 1.
- Retries (429/5xx + transport, idempotent-aware) live in the client
(`internal/client/client.go`); commands get them for free via `newClient`.
- Retries (429/5xx + transport, idempotent-aware) live in
`internal/transport/transport.go`; `internal/client` only forwards the
options through. Commands get them for free via `newClient`.
- **Help text is a claim about the server.** Don't state a default, scope, or
restriction in a `Long`/flag description you haven't seen the API honor. Quote
the server's own error string when documenting a restriction so it's greppable
Expand Down
35 changes: 22 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -448,15 +448,19 @@ c1i functions get <id> --fields id,displayName,publishedCommitId
to a **case- and separator-insensitive** match. So `--fields displayName`
resolves whether the output uses `displayName` (single-object reads) or
`display_name` (list rows); the output keeps the source key's own spelling.
- Single-object `get` commands pass through the API response as-is, which
wraps the resource under the endpoint's own top-level key (`function`,
`app`, `automation`, `userView.user`, ...). You don't need to know that key:
a name that doesn't match at the top level is also searched for inside the
wrapper, so `--fields id,displayName` on `functions get` finds
`function.id`/`function.displayName` automatically. The full path
(`--fields function.id`) still works too and is tried first. If the same
name exists at more than one depth, the shallowest match wins; a tie at the
same depth resolves to the alphabetically first full path, deterministically.
- Single-object `get` commands print the resource itself, so `--fields id`
yields `{"id": ...}` and `jq -r .id` works. The API wraps the resource under
its own key (`app`, `function`, `userView.user`, ...); `get` unwraps that and
keeps every other envelope key — `expanded` among them — as a top-level
sibling. Do **not** write the wrapper into a path: `--fields function.id`
no longer resolves, because there is no `function` key left.
- `c1i api` is a raw passthrough and still returns the envelope. There, and
for any genuinely nested field, a name that doesn't match at the top level is
also searched for deeper: the shallowest match wins, and a tie at the same
depth resolves to the alphabetically first full path, deterministically.
- Mutation output (`apps create` returns `{"app": ...}`) also keeps the
envelope, but is never projected at all — `--fields` cannot blank a success
message, so no search happens there.
- A `--fields` spec that matches **nothing at all** in the response (a typo, or
a field that truly doesn't exist) is a usage error (exit `2`), not a silent
`{}`. This is a zero-match check only: `--fields id,dispalyName` (typo) still
Expand Down Expand Up @@ -555,10 +559,15 @@ remote error (exit `6`) rather than looping.
This applies to every command built on the shared transport: the REST client,
the MCP gateway, and the login handshake, so the path and redirect guards,
`--debug` tracing, and `--max-retries` cover the gateway and login too, not just
REST commands. It does **not** apply to the `docs` subcommands that fetch —
`docs search`, `docs page`, `docs openapi`, `docs endpoints`, `docs endpoint` —
which call Go's default HTTP client directly: no path or redirect guard there,
and `--debug` and `--max-retries` are both inert.
REST commands. **None of those four** applies to the `docs` subcommands that
fetch — `docs search`, `docs page`, `docs openapi`, `docs endpoints`,
`docs endpoint` — which call Go's default HTTP client directly: no path or
redirect guard there, and `--debug` and `--max-retries` are both inert.

One narrower carve-out inside login: the device-flow token poll forces its own
retry count to zero, because RFC 8628's polling interval already *is* that
call's retry strategy and a second layer underneath would double the delays.
`--max-retries` still governs the rest of the handshake.

A bad id is the only cause of a refused `3xx` observed so far, which is why it
maps to exit `2` — a redirect on an otherwise well-formed request would not be
Expand Down
2 changes: 1 addition & 1 deletion cmd/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ campaign ID from a URL is the access review `id` directly.
`--fields`/`C1I_FIELDS` can't blank a success message.
- Casing differs by mode: list rows are snake_case (`app_id`,
`display_name`); single-object reads carry the API's own camelCase
(`userView`, `displayName`). `--fields` matches either casing at any
(`displayName`, `createdAt`). `--fields` matches either casing at any
depth — `jq` doesn't, so check a row's actual keys before writing a filter.
- Values keep their real JSON types: booleans and numbers are never
stringified, so `jq 'select(.enabled)'` and numeric comparisons behave.
Expand Down
2 changes: 1 addition & 1 deletion cmd/apps_get.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ var appsGetCmd = &cobra.Command{
return fmt.Errorf("API error: %w", err)
}

return writeObject(cmd, data)
return writeResource(cmd, data, "id")
},
}

Expand Down
2 changes: 1 addition & 1 deletion cmd/automations_get.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ var automationsGetCmd = &cobra.Command{
return fmt.Errorf("API error: %w", err)
}

return writeObject(cmd, data)
return writeResource(cmd, data, "id")
},
}

Expand Down
2 changes: 1 addition & 1 deletion cmd/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (
// global configuration resolved by cobra/viper (currently the retry budget).
// All commands go through this helper so cross-cutting client options are
// wired in one place rather than at every call site.
func newClient(cmd *cobra.Command, baseURL string) (*client.Client, error) {
var newClient = func(cmd *cobra.Command, baseURL string) (*client.Client, error) {
return client.New(cmd.Context(), baseURL,
client.WithMaxRetries(viper.GetInt("max_retries")),
client.WithDebug(viper.GetBool("debug")),
Expand Down
26 changes: 15 additions & 11 deletions cmd/docs_guide.go
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,10 @@ of anything specific to what you're about to model.
"--wait" polls "GET .../ownerids" until the owner appears (or times out) and
prints "Owners provisioned on app ... after ...". Without "--wait", the PUT
returns immediately but the owner takes roughly 96-129s to show up in
"GET .../ownerids" (four measured writes; a fifth had not landed at 108s) --
"GET .../ownerids" -- four measured "set-owners" writes, a fifth still
pending at 108s. Read through "apps owners" instead, and across
"set-owners", "add-owner", "remove-owner" and the owner "apps create"
assigns, the spread is wider: 45-150s --
see "Verify" below for why "apps get"'s "appOwners" field is not the way to
check this.

Expand Down Expand Up @@ -458,7 +461,7 @@ matters.

## Common failures

- "apps set-owners" exits 1 with a 400 naming a regex pattern
- "apps set-owners" exits 2 with a 400 naming a regex pattern
(^[a-zA-Z0-9]{27}$) -> the app id or a "--user-id" isn't a real 27-char
C1 id -> fix the id; retrying as-is won't help.
- "entitlements list --app-id <id>" returns nothing at exit 0 for a
Expand Down Expand Up @@ -614,7 +617,7 @@ waiting on an approver, not that the revoke failed.
| Symptom | Cause | Fix |
|---|---|---|
| 403 target user is not allowed to request that resource (exit 3) | The entitlement isn't reachable through any request catalog for this user | Configure it in the C1 console (App > Access requests), or target an entitlement that already allows requests |
| 409 duplicate ticket found, with a task id in the error details (exit 1) | An open task for this exact app + entitlement + user already exists | Act on that task id ("tasks list --state open") instead of creating another |
| 409 duplicate ticket found, with a task id in the error details (exit 2) | An open task for this exact app + entitlement + user already exists | Act on that task id ("tasks list --state open") instead of creating another |
| required flag(s) "app-id", "entitlement-id" not set (exit 2) | Both are required on "requests create grant"/"revoke" | Fix the invocation |
| An auth failure resolving the caller's own id when "--user-id" is omitted (exit 3) | Surfaces before the request call itself runs | Re-authenticate rather than retry as-is |
| "grants list" returns nothing right after approval | Eventual consistency | Re-run with "--wait --wait-min 1" so it blocks until the grant lands; a bare "--wait" settles on the empty set in ~10s and exits 0, which reads as a denial |
Expand Down Expand Up @@ -664,18 +667,18 @@ from a "c1i tasks list" row, a notification, or the output of
c1i requests get "$TASK_ID"

In the JSON, check:
- taskView.task.policy.policy.displayName / .id — which policy is
- .policy.policy.displayName / .id — which policy is
driving this task. (An entitlement's own grantPolicyId/revokePolicyId
can be empty — inherited from the app's default (see
"c1i apps get <app-id>") — so don't infer the governing policy from
the entitlement alone; the task view always has the resolved one.)
- taskView.task.policy.current.id — the step this task is on right now.
- .policy.current.id — the step this task is on right now.
This is exactly the value tasks approve/deny send as policyStepId.
- taskView.task.policy.next — steps still to come. Empty means your
- .policy.next — steps still to come. Empty means your
approval, if it's the last one, closes the task; non-empty means
another step (often another approver) follows.
- taskView.task.stepApproverIds — user ids allowed to act on this step.
- taskView.task.actions — what YOU specifically can do on this task
- .stepApproverIds — user ids allowed to act on this step.
- .actions — what YOU specifically can do on this task
right now. If TASK_ACTION_TYPE_APPROVE (or _DENY) isn't listed, don't
call approve/deny — it will be rejected even though you can read the
task, and even if you're the requester.
Expand Down Expand Up @@ -705,7 +708,7 @@ from a "c1i tasks list" row, a notification, or the output of

c1i requests get "$TASK_ID"

Check taskView.task.state, not outcome, for whether anything is still
Check .state, not outcome, for whether anything is still
pending. outcome is omitted while it sits at *_OUTCOME_UNSPECIFIED; the
NDJSON views ("tasks list", "requests list") drop the key entirely in that
case rather than print the sentinel. Its absence there means "no result
Expand All @@ -723,12 +726,13 @@ checking once immediately.

## Common failures

- action not permitted on tasks approve/tasks deny (exit 1) — the
- action not permitted on tasks approve/tasks deny — the exit code
follows the API's status for the refusal, which is not recorded here; the
authenticated identity isn't on the task's current policy step,
confirmed ahead of time by actions in step 2 omitting
TASK_ACTION_TYPE_APPROVE/_DENY.
- could not determine the current policy step for task ...; pass
--policy-step-id explicitly, on tasks approve (exit 1) — no current step
--policy-step-id explicitly, on tasks approve (exit 2) — no current step
could be derived (approve requires one). tasks deny never fails this way
on the same task; it just proceeds without a step. Supply
--policy-step-id explicitly once you've read it via step 2's response.
Expand Down
47 changes: 47 additions & 0 deletions cmd/docs_guide_exit_codes_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package cmd

import (
"regexp"
"strconv"
"strings"
"testing"

"github.com/ConductorOne/c1i/internal/client"
)

// Checks the guides' exit-code claims against exitCode(), but only where an
// HTTP status shares the line: a client-side failure has no status to check
// against.
var (
// Covers all three forms the guides use: "(exit N)", "exits N", "exit N".
guideExitClaimRe = regexp.MustCompile(`\bexits? (\d)\b`)
guideStatusRe = regexp.MustCompile(`\b(4\d\d|5\d\d)\b`)
)

func TestGuideExitCodeClaimsMatchTheTaxonomy(t *testing.T) {
checked := 0
for name, body := range docsGuides {
for _, line := range strings.Split(body, "\n") {
claim := guideExitClaimRe.FindStringSubmatch(line)
if claim == nil {
continue
}
status := guideStatusRe.FindString(line)
if status == "" {
continue // no status on this line; nothing to check it against
}
code, _ := strconv.Atoi(status)
want := exitCode(&client.APIError{StatusCode: code})
got, _ := strconv.Atoi(claim[1])
checked++
if got != want {
t.Errorf("guide %q claims (exit %d) beside a %d, but a %d maps to exit %d:\n %s",
name, got, code, code, want, strings.TrimSpace(line))
}
}
}
// Floor measured, not guessed: four such pairs exist today.
if checked < 4 {
t.Fatalf("only %d status/exit pairs found across the guides, want at least 4; either the extraction regressed or a guide dropped a pair -- if the latter, lower the floor", checked)
}
}
2 changes: 1 addition & 1 deletion cmd/entitlements_get.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ entitlement ID.`,
return fmt.Errorf("API error: %w", err)
}

return writeObject(cmd, data)
return writeResource(cmd, data, "id")
},
}

Expand Down
Loading
Loading