@alter-ai/cli 0.1.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +25 -188
  2. package/dist/cli.js +364 -72
  3. package/package.json +4 -2
package/README.md CHANGED
@@ -1,208 +1,45 @@
1
- # `@alter-ai/cli` — the Alter Vault command-line interface
1
+ # `@alter-ai/cli`
2
2
 
3
- `alter` is a command-line client for the Alter Vault dev portal. It authenticates with a Personal Access Token (PAT) minted from the dashboard and exposes the same dashboard operations as scriptable commands.
3
+ Command-line client for the Alter Vault dev portal. Authenticates with a Personal Access Token (PAT) and exposes the dashboard's resource model as scriptable commands.
4
+
5
+ 📖 **Full docs:** [docs.alterauth.com/reference/cli](https://docs.alterauth.com/reference/cli)
4
6
 
5
7
  ## Install
6
8
 
7
9
  ```bash
8
10
  npm install -g @alter-ai/cli
9
- # verify
10
11
  alter --version
11
12
  ```
12
13
 
13
- ## Quick start
14
-
15
- The fastest interactive sign-in is the browser-dance flow — `alter auth login` opens the dashboard, you click **Authorize**, and the CLI receives the freshly-minted token on a localhost listener:
16
-
17
- ```bash
18
- alter auth login
19
- # alter: opening browser at https://dashboard.alterauth.com/cli-auth
20
- # (waiting up to 2 minutes for you to approve)…
21
- # alter: signed in via browser-dance flow.
22
-
23
- alter auth status
24
- ```
25
-
26
- For headless / CI environments, mint a PAT manually from the dashboard (**Settings → Personal Access Tokens → New token**) and feed it to the CLI through one of the channels below:
27
-
28
- **Token file (recommended for local headless use):** keeps the value out of `process.argv` (visible to other users via `ps`) and out of shell history.
29
-
30
- ```bash
31
- umask 077 && echo "alter_pat_xxxxxxxxxxxxxxxxxxxxxxxx_yyyyyy" > ~/alter-pat.txt
32
- alter auth login --token-file ~/alter-pat.txt
33
- ```
34
-
35
- **Stdin:**
36
-
37
- ```bash
38
- pbpaste | alter auth login --token-stdin # macOS
39
- xclip -o -selection clipboard | alter auth login --token-stdin # Linux
40
- ```
41
-
42
- **Env var (CI):** skip persistence entirely. The SDK reads `ALTER_PAT` directly; it never lands in argv.
14
+ ## Sign in
43
15
 
44
16
  ```bash
45
- export ALTER_PAT="alter_pat_xxxxxxxxxxxxxxxxxxxxxxxx_yyyyyy"
46
- alter apps list
17
+ alter auth login # interactive browser flow
18
+ ALTER_PAT=alter_pat_... alter apps list # headless / CI
47
19
  ```
48
20
 
49
- **Inline `--token <pat>` is supported but discouraged** — the value lands in `process.argv` (visible to other local users via `ps aux`) and shell history. Use only on single-user machines and rotate afterwards.
50
-
51
21
  ## Commands
52
22
 
53
- The CLI mirrors the dashboard's resource model. Every namespace lives at `alter <namespace> <verb>`:
54
-
55
- ```
56
- auth login | status | logout Sign in / out + token introspection
57
- apps list | create | show | update | delete Manage applications
58
- keys list | mint | show | rotate | revoke | rename Manage runtime API keys
59
- agents list | create | show | update | revoke Managed-agent identities (+ mint-key, list-keys, revoke-key)
60
- providers list | list-catalog | create | show | update | delete OAuth provider integrations per app
61
- managed-secrets
62
- templates | list | show | create | delete | rotate | access | users |
63
- grants {list, list-for-agent, create, update, revoke} |
64
- groups {list, show} Managed-secret credentials, grants, and access (CRUD + autocomplete helpers)
65
- policy show-app View app-level policy (org-wide policy is dashboard-only)
66
- audit list | show | portal-actions | grant-events | traces Dev-portal audit log
67
- pats whoami Same as `auth status`, under the `pats` namespace
68
- link <app-id> | --status Pin an app to the current directory tree
69
- unlink Clear the workspace pin
70
- completion install | print Generate shell completions (bash/zsh/fish)
71
- self-update --to <v> | --dry-run Upgrade the CLI via npm
72
- sdk-passthrough request <method> <path> [...] Raw authenticated request (escape hatch for routes the CLI doesn't model)
73
- ```
74
-
75
- PAT lifecycle (mint, revoke) remains **dashboard-only** in v1 — a PAT cannot mint or revoke another PAT, including its own. Operators use the dashboard for those actions. Org-wide policy and identity-provider configuration are also dashboard-only per the destructive-action policy in CLAUDE.md.
76
-
77
- Every list / show command accepts `--output=json|table|jsonl` (default: `json` for pipelines, `table` for interactive). Object commands default to `json`. Use `--fields a,b,c` at the top level to project to specific keys (see [Field selection](#field-selection---fields)).
78
-
79
- ## Authentication
80
-
81
- The CLI resolves credentials in this order (highest precedence first):
82
-
83
- 1. **`ALTER_PAT` environment variable** — canonical CI / headless source.
84
- 2. **OS keychain** (macOS Keychain, Linux Secret Service / `gnome-keyring`, Windows Credential Manager) via the optional `keytar` native module. This is the default location after a successful `alter auth login`.
85
- 3. **Plaintext file** `~/.config/alter/auth.toml` (XDG-compliant, mode `0600`). Used as the fallback when `keytar` failed to build on the host (e.g. missing `libsecret-1-dev` on Linux). The CLI prints a warning at login time when it falls back to this path.
86
-
87
- The `--base-url` flag and the `ALTER_BASE_URL` environment variable both require an `https://` URL — non-HTTPS schemes (`http`, `file`, `gopher`, etc.) are rejected at login time so a misconfigured backend URL cannot exfiltrate the PAT in clear text or to an unintended target.
88
-
89
- If keytar isn't loading on your host, install the native build tools and re-install:
90
-
91
- ```bash
92
- # macOS
93
- xcode-select --install
94
- # Linux (Debian/Ubuntu — adjust for your distro)
95
- sudo apt install libsecret-1-dev gnome-keyring
96
- # Windows — install windows-build-tools or VS Build Tools
97
-
98
- npm install -g @alter-ai/cli
99
- ```
100
-
101
- ## Workspace config (`alter link`)
102
-
103
- If you work primarily on one app, run `alter link <app-id>` once in the project root. The CLI writes a tiny `.alter/config.yaml` that pins the default app for every subsequent `alter keys`, `alter agents`, `alter providers`, and `alter policy show-app` invocation in that directory tree. No more retyping the UUID.
104
-
105
- ```bash
106
- cd ~/code/my-product
107
- alter link app_abc123
108
- # alter: pinned app_id=app_abc123 in /Users/me/code/my-product/.alter/config.yaml
109
- # alter: appended `.alter/` to .gitignore so the pin isn't committed.
110
-
111
- # from anywhere in this tree, --app becomes optional:
112
- alter keys list
113
- alter agents create --name worker --type service
114
- alter policy show-app
115
-
116
- # show the current pin
117
- alter link --status
118
-
119
- # clear the pin
120
- alter unlink
121
23
  ```
122
-
123
- `app_id` precedence (highest first):
124
-
125
- 1. `--app <id>` on the command line
126
- 2. `ALTER_APP_ID` env var
127
- 3. The nearest `.alter/config.yaml` found by walking up from `process.cwd()`
128
- 4. Error: `no app selected` (exit code 2)
129
-
130
- Discovery walks up the directory tree until it finds an `.alter/` directory OR crosses your home directory (whichever comes first). The home-dir bound prevents a misplaced config in `$HOME` from silently pinning every shell.
131
-
132
- If your project lives in a git repo, `alter link` appends `.alter/` to `.gitignore` so the workspace pin doesn't get committed — same posture as `vercel link`. The pin is personal-to-the-checkout.
133
-
134
- ## Field selection (`--fields`)
135
-
136
- Narrow the JSON / JSONL output to specific top-level keys with `--fields a,b,c` at the top level — useful for scripts that only need one or two columns. Inert when paired with `--output=table` (the table columns are already a pre-defined slice). Missing fields render as `null` to keep the column visible.
137
-
138
- ```bash
139
- # List apps, keep only id + name
140
- alter apps list --fields id,name
141
- # [
142
- # { "id": "app_abc", "name": "demo" },
143
- # ...
144
- # ]
145
-
146
- # Single object — same projection rule
147
- alter apps show app_abc --fields id,name,environment
148
-
149
- # JSONL — one projected object per line
150
- alter audit list --fields timestamp,action --output=jsonl
24
+ auth login | status | logout
25
+ apps list | create | show | update | archive | unarchive | delete
26
+ keys list | mint | show | rotate | revoke | rename
27
+ agents list | create | show | update | revoke (+ mint-key, list-keys, revoke-key)
28
+ providers list | list-catalog | create | show | update | delete
29
+ managed-secrets list | show | create | rotate | delete | templates | access | users
30
+ grants {list, list-for-agent, create, update, revoke}
31
+ groups {list, show}
32
+ policy show-app
33
+ audit list | show | portal-actions | grant-events | traces
34
+ pats whoami
35
+ link / unlink pin an app to the current directory
36
+ completion install | print
37
+ self-update --to <v>
38
+ sdk-passthrough request <grant-id> --url <url>
151
39
  ```
152
40
 
153
- The `--fields` value is `,`-separated. Spaces around commas are ignored; spaces INSIDE a field name are rejected up front (catches the common `--fields 'id name'` typo with a clear error rather than silently dropping the second name).
154
-
155
- ## Exit codes
156
-
157
- `alter` returns a structured exit code so scripts can branch on the failure mode without parsing stderr text. Codes are stable contract — operators can rely on them across releases.
158
-
159
- | Code | Name | Meaning |
160
- |------|------------------|-----------------------------------------------------------------------------------------------|
161
- | 0 | OK | Command succeeded. |
162
- | 1 | ERROR | Generic runtime failure (uncategorized — including unknown SDK / network errors). |
163
- | 2 | USAGE | Bad flag, arg, or input format. Always paired with a stderr line naming the offending input. |
164
- | 3 | AUTH | Not signed in, or PAT revoked / expired. **Remediation:** re-run `alter auth login`. |
165
- | 4 | NOT_FOUND | Resource not found — 404 from the backend, or a referenced local file is missing. |
166
- | 5 | CONFLICT | 409 from the backend — most commonly a type-to-confirm mismatch or dependent-resource block. |
167
- | 6 | RATE_LIMIT | 429 from the backend — retry with backoff. |
168
- | 7 | FORBIDDEN | 403 from the backend — PAT is valid but lacks the required scope. **Remediation:** re-mint the PAT with broader scopes (or switch PATs); `alter auth login` alone does NOT help. |
169
- | 8 | CANCELLED | Operator declined an interactive prompt (type-to-confirm mismatch on a destructive action, "no" at a y/N gate). Distinct from `ERROR` (1) — the CLI did nothing wrong, the operator chose not to proceed. Pass `--yes` or `--confirm <name>` in CI to skip the prompt. |
170
-
171
- Example:
172
-
173
- ```bash
174
- # Probe whether an app exists without erroring on the not-found case.
175
- # Capture $? into a local variable BEFORE running anything else; ``$?``
176
- # is clobbered by every command, so a stray ``log_attempt`` between the
177
- # ``if`` and ``elif`` would silently break the not-found branch.
178
- alter apps show "$APP_ID" --output=json > /dev/null 2>&1
179
- status=$?
180
- if [ "$status" -eq 0 ]; then
181
- echo "app exists"
182
- elif [ "$status" -eq 4 ]; then
183
- echo "app not found"
184
- elif [ "$status" -eq 7 ]; then
185
- echo "PAT lacks dashboard_apps:read — re-mint with broader scopes"
186
- else
187
- echo "unexpected error" && exit 1
188
- fi
189
- ```
190
-
191
- Backend-thrown errors flow through `withClient` and get mapped from HTTP status to exit code automatically (401 → 3, 403 → 7, 404 → 4, 409 → 5, 429 → 6, everything else → 1). Validation errors raised by the CLI itself (e.g. malformed `--limit`) exit 2.
192
-
193
- ## Scope
194
-
195
- This CLI ships the full dev-portal command surface — `auth`, `apps`, `keys`, `agents`, `providers`, `managed-secrets`, `policy` (read-only at the app level), `audit`, `pats`, `link` / `unlink`, `completion`, `self-update`, and `sdk-passthrough` as a typed-route escape hatch. Backend routes are PAT-callable via `dashboard_*` scopes (see the [scope catalog](https://docs.alterauth.com/api-reference/scopes) for the full list).
196
-
197
- **Managed secrets — destructive verb tier:** `alter managed-secrets delete <secret-id>` cascade-revokes every grant and delegation tied to the secret, removes the stored credential from secret storage, and writes cascade audit log entries — irrecoverable. The route is gated by `dashboard_secrets:delete` (NOT bundled into `:write` or `:admin`) AND requires `?confirm=<slug>` matching the target secret's slug. The CLI prompts interactively when stdin is a TTY; CI must pass `--confirm <slug>` explicitly. Mirrors `alter apps delete`. Soft-delete operations on grants (`grants revoke`) use the recoverable `:write` tier.
198
-
199
- **Managed secrets — credential intake:** `create` and `rotate` accept the credential value through three channels, in decreasing safety order: `--credential-value -` reads one line from stdin (preferred for CI piping), `--credential-value @/path/to/file` reads from a file, and `--credential-value <value>` accepts the value inline with a stderr warning about shell-history leakage. Multi-field templates (those whose backend Pydantic model requires more than a single primary credential string) take `--credentials @file.json` (a JSON object of string fields, takes precedence) or repeated `--credential-field key=value` flags.
200
-
201
- **Dashboard-only operations** (intentional, not scope gaps):
41
+ All commands accept `--output=json|table|jsonl` and `--fields a,b,c`. See [scripting](https://docs.alterauth.com/reference/cli/scripting) for exit codes and CI patterns, and [authentication](https://docs.alterauth.com/reference/cli/authentication) for token storage details.
202
42
 
203
- - PAT mint / revoke — a PAT cannot manage another PAT, including its own.
204
- - Org-wide key policy (`/organizations/current/key-policy`) — reading the response body is a security-posture fingerprint, so the route refuses PAT auth on both reads and writes.
205
- - Identity-provider configuration — affects every grant in the org; never settable by a single scripted call.
206
- - App-level policy *writes* / *deletes* — only `policy show-app` is exposed by the CLI; mutations remain dashboard-only until a CLI use case emerges.
43
+ ## License
207
44
 
208
- When the CLI doesn't model a route you need, drop down to `alter sdk-passthrough request <METHOD> <path>` — it forwards the request with the current PAT and prints the raw JSON.
45
+ MIT
package/dist/cli.js CHANGED
@@ -1,8 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // src/cli.ts
4
- import { Command as Command14 } from "commander";
5
-
6
3
  // src/exit-codes.ts
7
4
  var EXIT_ERROR = 1;
8
5
  var EXIT_USAGE = 2;
@@ -176,7 +173,7 @@ async function maybePrintUpdateBanner(currentVersion, argv2 = process.argv.slice
176
173
  // package.json
177
174
  var package_default = {
178
175
  name: "@alter-ai/cli",
179
- version: "0.1.0",
176
+ version: "0.3.1",
180
177
  description: "Command-line interface for the Alter Vault dev portal \u2014 scripted dashboard automation.",
181
178
  type: "module",
182
179
  bin: {
@@ -192,7 +189,9 @@ var package_default = {
192
189
  dev: "tsx src/cli.ts",
193
190
  test: "vitest run",
194
191
  "test:watch": "vitest",
195
- typecheck: "tsc --noEmit",
192
+ "docs:check": "tsx scripts/check-docs-drift.ts",
193
+ "docs:print": "tsx scripts/check-docs-drift.ts --print",
194
+ typecheck: "tsc --noEmit && tsc -p tsconfig.scripts.json",
196
195
  lint: "eslint src/ tests/ --ext .ts",
197
196
  format: "prettier --write 'src/**/*.ts' 'tests/**/*.ts'"
198
197
  },
@@ -224,6 +223,9 @@ var package_default = {
224
223
  }
225
224
  };
226
225
 
226
+ // src/program.ts
227
+ import { Command as Command14 } from "commander";
228
+
227
229
  // src/commands/agents.ts
228
230
  import { Command } from "commander";
229
231
 
@@ -440,10 +442,23 @@ var GrantRevokedError = class extends ReAuthRequiredError {
440
442
  };
441
443
  var CredentialRevokedError = class extends ReAuthRequiredError {
442
444
  grantId;
443
- constructor(message, grantId, details) {
445
+ // Recovery-context fields. Populated by the backend at the raise
446
+ // site in token_service so callers can mint a Connect session for
447
+ // the same (user, provider) without re-fetching the grant. See
448
+ // docs/planning/CONNECT_RECOVERY_CONTEXT.md.
449
+ providerId;
450
+ appUserId;
451
+ // Recovery context is appended AFTER `details` so the existing
452
+ // positional contract `new CredentialRevokedError("msg", "g-1",
453
+ // detailsObj)` keeps compiling AND keeps assigning detailsObj to
454
+ // the `details` slot — only the wire-parsing site uses the new
455
+ // positional slots.
456
+ constructor(message, grantId, details, providerId, appUserId) {
444
457
  super(message, details);
445
458
  this.name = "CredentialRevokedError";
446
459
  this.grantId = grantId;
460
+ this.providerId = providerId;
461
+ this.appUserId = appUserId;
447
462
  }
448
463
  };
449
464
  var GrantDeletedError = class extends ReAuthRequiredError {
@@ -453,9 +468,19 @@ var GrantDeletedError = class extends ReAuthRequiredError {
453
468
  }
454
469
  };
455
470
  var GrantNotFoundError = class extends BackendError {
456
- constructor(message, details) {
471
+ providerId;
472
+ agentId;
473
+ appUserId;
474
+ // Recovery context is appended AFTER `details` so the existing
475
+ // positional contract `new GrantNotFoundError("msg", detailsObj)`
476
+ // keeps compiling AND keeps assigning detailsObj to the `details`
477
+ // slot — only the wire-parsing site uses the new positional slots.
478
+ constructor(message, details, providerId, agentId, appUserId) {
457
479
  super(message, details);
458
480
  this.name = "GrantNotFoundError";
481
+ this.providerId = providerId;
482
+ this.agentId = agentId;
483
+ this.appUserId = appUserId;
459
484
  }
460
485
  };
461
486
  var AmbiguousGrantError = class extends BackendError {
@@ -468,23 +493,43 @@ var AmbiguousGrantError = class extends BackendError {
468
493
  // UUIDs only (no emails / names) — same backend hygiene rule that
469
494
  // applies to the rest of the cross-tenant probe surface.
470
495
  appUserIds;
471
- constructor(message, providerId, accountIdentifiers, accountWasProvided, appUserIds, details) {
496
+ // Populated for the managed-secret grant-level flavor: one user
497
+ // delegated multiple managed-secret grants sharing the same
498
+ // template to the same agent. The SDK caller picks one via
499
+ // `grantId=` on the next request. UUIDs only.
500
+ grantIds;
501
+ constructor(message, providerId, accountIdentifiers, accountWasProvided, appUserIds, details, grantIds) {
472
502
  super(message, details);
473
503
  this.name = "AmbiguousGrantError";
474
504
  this.providerId = providerId;
475
505
  this.accountIdentifiers = accountIdentifiers ?? [];
476
506
  this.accountWasProvided = accountWasProvided ?? false;
477
507
  this.appUserIds = appUserIds ?? [];
508
+ this.grantIds = grantIds ?? [];
478
509
  }
479
510
  };
480
511
  var NoDelegatedGrantError = class extends BackendError {
512
+ // `providerId` + `agentId` shipped in the pre-recovery PR, so they
513
+ // KEEP their positional slots. `appUserId` is new in this PR and
514
+ // appended AFTER `details` so the existing 4-arg call shape
515
+ // `new NoDelegatedGrantError(msg, providerId, agentId, details)`
516
+ // continues to work — only the wire-parsing site uses the new
517
+ // appUserId slot.
481
518
  providerId;
482
519
  agentId;
483
- constructor(message, providerId, agentId, details) {
520
+ // Populated when the resolution applied a user filter. Diagnostic /
521
+ // correlation field only — the recovery helper does NOT auto-bind
522
+ // the session to the user. Pass `userToken=` to
523
+ // `createConnectSessionForError` (or configure `userTokenGetter` on
524
+ // the client) when user binding is required. See
525
+ // docs/planning/CONNECT_RECOVERY_CONTEXT.md.
526
+ appUserId;
527
+ constructor(message, providerId, agentId, details, appUserId) {
484
528
  super(message, details);
485
529
  this.name = "NoDelegatedGrantError";
486
530
  this.providerId = providerId;
487
531
  this.agentId = agentId;
532
+ this.appUserId = appUserId;
488
533
  }
489
534
  };
490
535
  var PolicyViolationError = class extends BackendError {
@@ -2895,7 +2940,7 @@ function _extractAdditionalCredentials(token) {
2895
2940
  return _additionalCredsStore.get(token);
2896
2941
  }
2897
2942
  var _fetch;
2898
- var SDK_VERSION = "0.14.0";
2943
+ var SDK_VERSION = "0.15.0";
2899
2944
  var SDK_USER_AGENT = `alter-sdk-node/${SDK_VERSION}`;
2900
2945
  var HTTP_FORBIDDEN = 403;
2901
2946
  var HTTP_NO_CONTENT2 = 204;
@@ -3506,7 +3551,10 @@ ${effectiveConstraints}`;
3506
3551
  if (errorCode === "credential_revoked") {
3507
3552
  throw new CredentialRevokedError(
3508
3553
  errorData.message ?? "Credential has been revoked. User must re-authorize.",
3509
- errorData.grant_id
3554
+ errorData.grant_id,
3555
+ errorData,
3556
+ typeof errorData.provider_id === "string" ? errorData.provider_id : void 0,
3557
+ typeof errorData.app_user_id === "string" ? errorData.app_user_id : void 0
3510
3558
  );
3511
3559
  }
3512
3560
  if (errorData.error === "scope_mismatch") {
@@ -3546,13 +3594,17 @@ ${effectiveConstraints}`;
3546
3594
  const appUserIds = Array.isArray(errorData.app_user_ids) ? errorData.app_user_ids.filter(
3547
3595
  (v) => typeof v === "string"
3548
3596
  ) : [];
3597
+ const grantIds = Array.isArray(errorData.grant_ids) ? errorData.grant_ids.filter(
3598
+ (v) => typeof v === "string"
3599
+ ) : [];
3549
3600
  throw new AmbiguousGrantError(
3550
3601
  errorData.message ?? "JWT identity resolved to multiple grants. Pass the 'account' parameter to disambiguate.",
3551
3602
  typeof errorData.provider_id === "string" ? errorData.provider_id : void 0,
3552
3603
  accountIdentifiers,
3553
3604
  errorData.account_was_provided === true,
3554
3605
  appUserIds,
3555
- errorData
3606
+ errorData,
3607
+ grantIds
3556
3608
  );
3557
3609
  }
3558
3610
  if (errorData.error === "no_delegated_grant") {
@@ -3560,7 +3612,8 @@ ${effectiveConstraints}`;
3560
3612
  errorData.message ?? "Agent has no active delegation or managed-secret grant for this provider.",
3561
3613
  typeof errorData.provider_id === "string" ? errorData.provider_id : void 0,
3562
3614
  typeof errorData.agent_id === "string" ? errorData.agent_id : void 0,
3563
- errorData
3615
+ errorData,
3616
+ typeof errorData.app_user_id === "string" ? errorData.app_user_id : void 0
3564
3617
  );
3565
3618
  }
3566
3619
  throw new BackendError(
@@ -3577,9 +3630,21 @@ ${effectiveConstraints}`;
3577
3630
  }
3578
3631
  if (response.status === HTTP_NOT_FOUND) {
3579
3632
  const errorData = await __VaultClient.#safeParseJson(response);
3633
+ if (errorData.error === "no_delegated_grant") {
3634
+ throw new NoDelegatedGrantError(
3635
+ errorData.message ?? "Agent has no active delegation or managed-secret grant for this provider.",
3636
+ typeof errorData.provider_id === "string" ? errorData.provider_id : void 0,
3637
+ typeof errorData.agent_id === "string" ? errorData.agent_id : void 0,
3638
+ errorData,
3639
+ typeof errorData.app_user_id === "string" ? errorData.app_user_id : void 0
3640
+ );
3641
+ }
3580
3642
  throw new GrantNotFoundError(
3581
3643
  errorData.message ?? "OAuth grant not found for the given grant_id",
3582
- errorData
3644
+ errorData,
3645
+ typeof errorData.provider_id === "string" ? errorData.provider_id : void 0,
3646
+ typeof errorData.agent_id === "string" ? errorData.agent_id : void 0,
3647
+ typeof errorData.app_user_id === "string" ? errorData.app_user_id : void 0
3583
3648
  );
3584
3649
  }
3585
3650
  if (response.status === HTTP_BAD_REQUEST || response.status === HTTP_BAD_GATEWAY) {
@@ -3595,7 +3660,9 @@ ${effectiveConstraints}`;
3595
3660
  throw new CredentialRevokedError(
3596
3661
  errorData.message ?? "Underlying credential has been revoked. User must re-authorize.",
3597
3662
  errorData.grant_id,
3598
- errorData
3663
+ errorData,
3664
+ typeof errorData.provider_id === "string" ? errorData.provider_id : void 0,
3665
+ typeof errorData.app_user_id === "string" ? errorData.app_user_id : void 0
3599
3666
  );
3600
3667
  }
3601
3668
  throw new BackendError(
@@ -4787,7 +4854,14 @@ ${effectiveConstraints}`;
4787
4854
  }
4788
4855
  sessionBody.agent = options.agent;
4789
4856
  }
4790
- if (this.#userTokenGetter) {
4857
+ if (options.userToken !== void 0) {
4858
+ if (typeof options.userToken !== "string" || options.userToken.length === 0) {
4859
+ throw new AlterValueError(
4860
+ "userToken must be a non-empty string when provided"
4861
+ );
4862
+ }
4863
+ sessionBody.user_token = options.userToken;
4864
+ } else if (this.#userTokenGetter) {
4791
4865
  sessionBody.user_token = await this.#getUserToken();
4792
4866
  }
4793
4867
  const sessionPath = "/sdk/oauth/connect/session";
@@ -4951,10 +5025,43 @@ ${effectiveConstraints}`;
4951
5025
  } else {
4952
5026
  console.log(`Open this URL to authorize: ${session.connectUrl}`);
4953
5027
  }
5028
+ return await this.pollConnectSession(session.sessionToken, {
5029
+ timeoutMs,
5030
+ pollIntervalMs
5031
+ });
5032
+ }
5033
+ /**
5034
+ * Poll a Connect session to completion.
5035
+ *
5036
+ * Use this when your code minted the Connect session itself (e.g.,
5037
+ * via {@link createConnectSession} for a custom UI flow, or via
5038
+ * {@link createConnectSessionForError} for a recovery flow) and
5039
+ * you need to block until the user finishes the consent screen.
5040
+ * {@link connect} is the all-in-one convenience that mints + opens
5041
+ * the browser + polls; this method is the polling half on its own.
5042
+ *
5043
+ * @param sessionToken - A session token from
5044
+ * {@link createConnectSession} or
5045
+ * {@link createConnectSessionForError}.
5046
+ * @param options - `timeoutMs` (default 300000 = 5 min) and
5047
+ * `pollIntervalMs` (default 2000). Milliseconds per ecosystem
5048
+ * convention; the Python SDK's equivalent uses seconds.
5049
+ * @returns One `ConnectResult` per provider the user completed
5050
+ * within the session (multi-provider Connect sessions yield
5051
+ * multiple results).
5052
+ * @throws ConnectTimeoutError if the session didn't complete within
5053
+ * `timeoutMs`.
5054
+ * @throws ConnectFlowError / ConnectDeniedError / ConnectConfigError
5055
+ * for user denial, session expiry, or unrecognized status.
5056
+ * @throws AlterSDKError if the SDK instance has been closed.
5057
+ */
5058
+ async pollConnectSession(sessionToken, options) {
5059
+ this.#assertNotClosed();
5060
+ const timeoutMs = options?.timeoutMs ?? 3e5;
5061
+ const pollIntervalMs = options?.pollIntervalMs ?? 2e3;
4954
5062
  const deadline = Date.now() + timeoutMs;
4955
- while (Date.now() < deadline) {
4956
- await new Promise((resolve2) => setTimeout(resolve2, pollIntervalMs));
4957
- const pollResult = await this.#pollSession(session.sessionToken);
5063
+ while (true) {
5064
+ const pollResult = await this.#pollSession(sessionToken);
4958
5065
  const pollStatus = pollResult.status;
4959
5066
  if (pollStatus === "completed") {
4960
5067
  const grantsData = pollResult.grants ?? [];
@@ -4997,12 +5104,95 @@ ${effectiveConstraints}`;
4997
5104
  { status: pollStatus }
4998
5105
  );
4999
5106
  }
5107
+ const remaining = deadline - Date.now();
5108
+ if (remaining <= 0) break;
5109
+ const sleepMs = Math.min(pollIntervalMs, remaining);
5110
+ await new Promise((resolve2) => setTimeout(resolve2, sleepMs));
5000
5111
  }
5001
5112
  throw new ConnectTimeoutError(
5002
5113
  `OAuth flow did not complete within ${Math.round(timeoutMs / 1e3)} seconds. The user may not have finished authorizing in the browser.`,
5003
5114
  { timeoutMs }
5004
5115
  );
5005
5116
  }
5117
+ /**
5118
+ * Mint a recovery Connect session from a typed error.
5119
+ *
5120
+ * Use this in the catch block of an identity-mode or agent-mode
5121
+ * request that failed because the user hasn't authorized the
5122
+ * provider yet ({@link NoDelegatedGrantError}), or because the
5123
+ * underlying credential is permanently broken
5124
+ * ({@link CredentialRevokedError}), or because the resolved grant
5125
+ * doesn't exist ({@link GrantNotFoundError} with identity-mode
5126
+ * context).
5127
+ *
5128
+ * What the method reuses from the error:
5129
+ * - `providerId` → threaded into `allowedProviders=[...]`. Required;
5130
+ * missing context throws `AlterValueError`.
5131
+ * - `agentId` → threaded into `agent=` so the recovery session
5132
+ * re-binds the same delegation target. Required for
5133
+ * `NoDelegatedGrantError` recovery; absent on
5134
+ * `GrantNotFoundError` / `CredentialRevokedError` for user-direct
5135
+ * grants, in which case the call falls through to non-delegated
5136
+ * recovery.
5137
+ *
5138
+ * What the method does NOT reuse from the error:
5139
+ * - `appUserId` is exposed on the typed error for caller use
5140
+ * (logging, audit correlation, deciding which user to re-prompt)
5141
+ * but is NOT threaded into the recovery session.
5142
+ * `createConnectSession` binds the session via `userToken` (JWT),
5143
+ * not `appUserId`. To bind the session, pass `userToken=`
5144
+ * explicitly or configure `userTokenGetter` on the SDK client.
5145
+ *
5146
+ * @example
5147
+ * ```ts
5148
+ * try {
5149
+ * await vault.request(HttpMethod.GET, "https://...", { provider: "<provider-id>" });
5150
+ * } catch (e) {
5151
+ * if (e instanceof NoDelegatedGrantError) {
5152
+ * const session = await vault.createConnectSessionForError(e, {
5153
+ * allowedOrigin: "https://app.example.com",
5154
+ * });
5155
+ * redirectUser(session.connectUrl);
5156
+ * const results = await vault.pollConnectSession(session.sessionToken);
5157
+ * // Retry with results[0].grantId
5158
+ * }
5159
+ * }
5160
+ * ```
5161
+ *
5162
+ * @param error - The typed exception. Must carry `providerId`; if
5163
+ * `undefined` (direct-mode 404 from a stale grant_id), recovery
5164
+ * isn't derivable and this method throws `AlterValueError`
5165
+ * rather than guessing.
5166
+ * @param options - Standard `createConnectSession` options. Supplied
5167
+ * per call so the recovery session matches the deployment
5168
+ * shape (popup, mobile redirect, headless). The convenience
5169
+ * method doesn't infer these from the error.
5170
+ * @throws AlterValueError if `error.providerId` is `undefined`.
5171
+ */
5172
+ async createConnectSessionForError(error, options) {
5173
+ const providerId = error.providerId;
5174
+ if (providerId === void 0) {
5175
+ throw new AlterValueError(
5176
+ "Cannot mint a recovery Connect session: the typed error has no providerId context. This usually means the original call used direct grantId mode and the grantId was stale \u2014 there's no (user, provider) tuple to recover. Catch GrantNotFoundError separately and call createConnectSession() directly with the providers the user should re-authorize."
5177
+ );
5178
+ }
5179
+ const agentId = error.agentId;
5180
+ if (error instanceof NoDelegatedGrantError && agentId === void 0) {
5181
+ throw new AlterValueError(
5182
+ "Cannot mint a delegated recovery Connect session: the typed NoDelegatedGrantError has no agentId context. The backend may be on a version predating the recovery-context fields, or the wire payload was malformed. Upgrade the backend, or catch this and call createConnectSession() explicitly with the right agent=."
5183
+ );
5184
+ }
5185
+ return await this.createConnectSession({
5186
+ allowedProviders: [providerId],
5187
+ allowedOrigin: options?.allowedOrigin,
5188
+ returnUrl: options?.returnUrl,
5189
+ metadata: options?.metadata,
5190
+ grantPolicy: options?.grantPolicy,
5191
+ requiredScopes: options?.requiredScopes,
5192
+ agent: agentId,
5193
+ userToken: options?.userToken
5194
+ });
5195
+ }
5006
5196
  /**
5007
5197
  * Trigger IDP login for end user via browser.
5008
5198
  *
@@ -6539,8 +6729,13 @@ var _keytarCache;
6539
6729
  async function loadKeytar() {
6540
6730
  if (_keytarCache !== void 0) return _keytarCache;
6541
6731
  try {
6542
- const mod = await import("keytar");
6543
- _keytarCache = mod;
6732
+ const raw = await import("keytar");
6733
+ const candidate = raw.default ?? raw;
6734
+ if (typeof candidate.getPassword === "function" && typeof candidate.setPassword === "function" && typeof candidate.deletePassword === "function") {
6735
+ _keytarCache = candidate;
6736
+ } else {
6737
+ _keytarCache = null;
6738
+ }
6544
6739
  } catch {
6545
6740
  _keytarCache = null;
6546
6741
  }
@@ -6548,7 +6743,7 @@ async function loadKeytar() {
6548
6743
  }
6549
6744
  function printPlaintextFallbackWarning() {
6550
6745
  process.stderr.write(
6551
- "alter: WARNING \u2014 saving the PAT to a plaintext file (~/.config/alter/auth.toml).\n The OS keychain (keytar) is not available on this host. Install the\n native build tools and re-run `npm install -g @alter-ai/cli` to enable\n secure storage:\n macOS: xcode-select --install\n Linux: sudo apt install libsecret-1-dev gnome-keyring (or distro equivalent)\n Windows: install windows-build-tools or VS Build Tools\n"
6746
+ "alter: WARNING \u2014 saving the PAT to a plaintext file (~/.config/alter/auth.toml, mode 0600).\n The OS keychain (keytar) is not usable on this host. Common causes:\n - keytar's native module failed to install (Linux: install libsecret-1-dev +\n gnome-keyring; Windows: install VS Build Tools; macOS: usually pre-installed).\n - The native module is present but its API surface doesn't match what the CLI\n expects (file a bug against @alter-ai/cli with your node + npm versions).\n If the cleartext fallback is unacceptable on this host, revoke the PAT from the\n dashboard's `Personal Access Tokens` page after use.\n"
6552
6747
  );
6553
6748
  }
6554
6749
  async function loadStoredAuth2() {
@@ -6642,11 +6837,11 @@ async function clearStoredAuth2() {
6642
6837
 
6643
6838
  // src/portal-client.ts
6644
6839
  import { platform, release } from "os";
6645
- var DEFAULT_BASE_URL = "https://api.alterauth.com";
6840
+ var DEFAULT_BASE_URL = "https://backend.alterauth.com";
6646
6841
  var PAT_API_PREFIX = "/api/v1/dev-portal";
6647
6842
  var HTTP_ERROR_THRESHOLD = 400;
6648
6843
  var DEFAULT_TIMEOUT_MS = 3e4;
6649
- var CLI_VERSION = "0.1.0";
6844
+ var CLI_VERSION = "0.3.1";
6650
6845
  var USER_AGENT = buildUserAgent();
6651
6846
  function buildUserAgent() {
6652
6847
  let osTag = "";
@@ -7577,6 +7772,31 @@ var ManagedSecretsNamespace = class {
7577
7772
  );
7578
7773
  return expectDict(body, "managed_secrets.rotate", 200);
7579
7774
  }
7775
+ /**
7776
+ * Set the per-secret user → agent delegation policy. Requires
7777
+ * ``dashboard_secrets:write``. Controls whether a group-typed grant on
7778
+ * this secret may be delegated to an agent (``allow_group_source``) and
7779
+ * the per-delegation TTL ceiling (``max_delegation_ttl_days``).
7780
+ *
7781
+ * REPLACE semantics — the backend overwrites the whole policy object,
7782
+ * so the command always sends the full intended state (omitting
7783
+ * ``max_delegation_ttl_days`` lets the backend apply its 90-day default).
7784
+ */
7785
+ async setDelegationPolicy(appId, secretId, options) {
7786
+ const app = encodePathParam(appId, "appId");
7787
+ const secret = encodePathParam(secretId, "secretId");
7788
+ const payload = filterUndefined({
7789
+ allow_group_source: options.allow_group_source,
7790
+ max_delegation_ttl_days: options.max_delegation_ttl_days
7791
+ });
7792
+ const body = await this.#client._call(
7793
+ "PUT",
7794
+ `/apps/${app}/managed-secrets/${secret}/delegation-policy`,
7795
+ "managed_secrets.set_delegation_policy",
7796
+ { jsonBody: payload }
7797
+ );
7798
+ return expectDict(body, "managed_secrets.set_delegation_policy", 200);
7799
+ }
7580
7800
  /**
7581
7801
  * List grants on a managed secret. Requires ``dashboard_secrets:read``.
7582
7802
  *
@@ -9383,7 +9603,7 @@ var DEFAULT_SCOPES = [
9383
9603
  "dashboard_secrets:write"
9384
9604
  ];
9385
9605
  function deriveDashboardUrl(baseUrl) {
9386
- return baseUrl.replace(/^https:\/\/api\./, "https://dashboard.");
9606
+ return baseUrl.replace(/^https:\/\/backend\./, "https://portal.");
9387
9607
  }
9388
9608
  function defaultOpenBrowser(url) {
9389
9609
  let command;
@@ -9500,7 +9720,7 @@ async function runBrowserDance(options) {
9500
9720
  server.close();
9501
9721
  reject(
9502
9722
  new Error(
9503
- "browser-dance callback came from an unexpected origin \u2014 refusing to accept. Re-run ``alter auth login`` and verify the dashboard URL."
9723
+ `browser-dance callback came from an unexpected origin \u2014 refusing to accept. Expected origin ${JSON.stringify(dashboardOrigin)}; received Origin: ${JSON.stringify(origin || "(none)")}, Referer: ${JSON.stringify(referer || "(none)")}. If your dashboard isn't at the regex-derived host, re-run with --dashboard-url <https-url> pointing at the actual dashboard origin.`
9504
9724
  )
9505
9725
  );
9506
9726
  return;
@@ -9634,8 +9854,21 @@ async function runBrowserDance(options) {
9634
9854
  pat,
9635
9855
  ...options.baseUrl ? { baseUrl: options.baseUrl } : {}
9636
9856
  });
9857
+ const smokeTest = options.smokeTest ?? defaultSmokeTest;
9858
+ await smokeTest(pat, options.baseUrl);
9637
9859
  process.stdout.write("alter: signed in via browser-dance flow.\n");
9638
9860
  }
9861
+ async function defaultSmokeTest(pat, baseUrl) {
9862
+ const client = new DashboardClient({
9863
+ pat,
9864
+ ...baseUrl ? { baseUrl } : {}
9865
+ });
9866
+ try {
9867
+ await client.pats.whoami();
9868
+ } finally {
9869
+ await client.close();
9870
+ }
9871
+ }
9639
9872
 
9640
9873
  // src/scope-catalog.ts
9641
9874
  var DASHBOARD_RESOURCE_VERBS = {
@@ -9849,7 +10082,7 @@ async function loginCommand(options) {
9849
10082
  let resolvedBaseUrl2;
9850
10083
  try {
9851
10084
  const envBaseUrl = process.env.ALTER_BASE_URL?.trim() || null;
9852
- resolvedBaseUrl2 = options.baseUrl ? validateBaseUrl(options.baseUrl) : envBaseUrl ? validateBaseUrl(envBaseUrl) : "https://api.alterauth.com";
10085
+ resolvedBaseUrl2 = options.baseUrl ? validateBaseUrl(options.baseUrl) : envBaseUrl ? validateBaseUrl(envBaseUrl) : "https://backend.alterauth.com";
9853
10086
  } catch (e) {
9854
10087
  err(e instanceof Error ? e.message : String(e));
9855
10088
  process.exit(EXIT_USAGE);
@@ -9875,10 +10108,8 @@ async function loginCommand(options) {
9875
10108
  process.exit(EXIT_USAGE);
9876
10109
  }
9877
10110
  }
9878
- let bindOverride = null;
9879
- if (options.bindIp === false) {
9880
- bindOverride = { bind: "none" };
9881
- } else if (typeof options.bindIp === "string") {
10111
+ let bindOverride = { bind: "none" };
10112
+ if (typeof options.bindIp === "string") {
9882
10113
  let normalisedCidr;
9883
10114
  try {
9884
10115
  normalisedCidr = validateBindIpFlag(options.bindIp);
@@ -9897,6 +10128,19 @@ async function loginCommand(options) {
9897
10128
  });
9898
10129
  return;
9899
10130
  } catch (e) {
10131
+ if (e instanceof PortalBackendError) {
10132
+ const isAuthTier = e.statusCode === 401 || e.statusCode === 403;
10133
+ if (isAuthTier) {
10134
+ err(`PAT was minted but cannot authenticate: ${e.message}`);
10135
+ } else {
10136
+ err(`PAT was minted but the backend rejected the smoke test: ${e.message}`);
10137
+ }
10138
+ process.exit(exitCodeForHttpStatus(e.statusCode));
10139
+ }
10140
+ if (e instanceof NetworkError) {
10141
+ err(`PAT was minted but the backend is unreachable: ${e.message}. Check connectivity and retry.`);
10142
+ process.exit(EXIT_ERROR);
10143
+ }
9900
10144
  err(e instanceof Error ? e.message : String(e));
9901
10145
  process.exit(EXIT_ERROR);
9902
10146
  }
@@ -10061,16 +10305,16 @@ function buildAuthCommand() {
10061
10305
  "Override the backend URL (must be https://). Defaults to ALTER_BASE_URL or production."
10062
10306
  ).option(
10063
10307
  "--dashboard-url <url>",
10064
- "Override the dashboard URL used by the browser-dance flow. Defaults to the backend URL with the leftmost `api.` subdomain swapped for `dashboard.`. Use this when the dashboard isn't at the regex-derivable host (e.g. staging / self-hosted setups). Must use https://."
10308
+ "Override the dashboard URL used by the browser-dance flow. Defaults to the backend URL with the leftmost `backend.` subdomain swapped for `portal.`. Use this when the dashboard isn't at the regex-derivable host (e.g. staging / self-hosted setups). Must use https://."
10065
10309
  ).option(
10066
10310
  "--scopes <list>",
10067
10311
  "Comma-separated scope list to request on the browser-dance consent page (e.g. 'dashboard_apps:read,dashboard_keys:write'). Defaults to the full read+write set across the CLI surface \u2014 enough for every non-destructive day-to-day command. Opt in to admin / delete tiers explicitly: 'dashboard_keys:admin' for runtime-key admin ops, 'dashboard_apps:delete' for cascade-delete an app (note: dashboard_apps does NOT support :admin \u2014 the Destructive-Action Policy separates :delete as its own verb). Each entry is validated client-side against the dashboard scope catalog \u2014 typos fail fast. Mutually exclusive with --token / --token-file / --token-stdin \u2014 pre-minted PATs have their scope set baked at mint time on the dashboard's Settings page; combining the flags is a usage error."
10068
10312
  ).option(
10069
10313
  "--no-bind-ip",
10070
- "Mint the PAT WITHOUT an IP allowlist. The resulting token works from any source IP \u2014 useful when the dashboard sits behind a CDN whose edge IPs aren't yet trusted by the backend (in that case the default 'bind to caller IP' resolves to the CDN edge instead of the operator's real IP, leaving the PAT unusable from the CLI). Browser-dance flow only."
10314
+ "Explicit opt-out of IP binding. This is now the DEFAULT for the browser-dance flow, so the flag is a no-op alias retained for scripts that pass it explicitly. The resulting PAT works from any source IP. Browser-dance flow only."
10071
10315
  ).option(
10072
10316
  "--bind-ip <cidr>",
10073
- "Mint the PAT bound to the operator-supplied IP / CIDR (e.g. '203.0.113.5' for a single host, '203.0.113.0/24' for a range, or an IPv6 equivalent). The backend's automatic caller-IP bind is bypassed; the supplied range becomes the entire allowlist. Use this when the operator's egress IP is known but the dashboard sits behind a CDN whose trusted-proxy list isn't yet populated on the backend. If both --bind-ip and --no-bind-ip appear, the one that appears LAST on the command line takes effect (Commander last-wins). Browser-dance flow only."
10317
+ "Mint the PAT bound to the operator-supplied IP / CIDR (e.g. '203.0.113.5' for a single host, '203.0.113.0/24' for a range, or an IPv6 equivalent). The supplied range becomes the entire allowlist on the minted PAT. Use this when the egress IP is known and stable \u2014 e.g. minting a token for a CI runner whose outbound CIDR you control. Interactive developer-laptop logins should leave this unset (default is now unbound; see --no-bind-ip). If both --bind-ip and --no-bind-ip appear, the one that appears LAST on the command line takes effect (Commander last-wins). Browser-dance flow only."
10074
10318
  ).action(async (options) => {
10075
10319
  await loginCommand(options);
10076
10320
  });
@@ -10469,7 +10713,7 @@ function buildKeysCommand() {
10469
10713
  }
10470
10714
  );
10471
10715
  keys.command("rotate").description(
10472
- "Rotate a key (new plaintext returned ONCE; old key enters grace until revoke). Requires dashboard_keys:admin scope."
10716
+ "Rotate a key (new plaintext returned ONCE; old key enters grace until revoke). Requires dashboard_keys:write scope."
10473
10717
  ).option("--app <app-id>", "App ID. Falls back to ALTER_APP_ID env or .alter/config.yaml").requiredOption("--key <key-id>", "Key ID", parseUuidArgument("--key")).option(
10474
10718
  "--scopes <list>",
10475
10719
  "Optional new scope set (defaults to the key's current scopes)"
@@ -11478,7 +11722,14 @@ function buildManagedSecretsCommand() {
11478
11722
  ).option(
11479
11723
  "--injection-rule <@file.json>",
11480
11724
  "Path-prefixed JSON file containing the additional_injections array"
11481
- ).option("--label <label>", "Base-grant display label").option("--account-identifier <id>", "Optional account identifier metadata").option("--account-display-name <name>", "Optional account display name metadata").option("--output <format>", "Output format: json|table (default: json)", "json").action(
11725
+ ).option("--label <label>", "Base-grant display label").option("--account-identifier <id>", "Optional account identifier metadata").option("--account-display-name <name>", "Optional account display name metadata").option(
11726
+ "--allow-group-delegation",
11727
+ "Allow members of a group-typed grant on this secret to delegate it to an agent (default: off)"
11728
+ ).option(
11729
+ "--max-delegation-ttl-days <days>",
11730
+ "Cap on a single delegation's lifetime, in days (1..1825, default 90)",
11731
+ parseBoundedInt("--max-delegation-ttl-days", 1, 5 * 365)
11732
+ ).option("--output <format>", "Output format: json|table (default: json)", "json").action(
11482
11733
  async (options) => {
11483
11734
  const format = coerceOutputFormat(options.output);
11484
11735
  const appId = resolveAppIdOrExit(options.app);
@@ -11501,7 +11752,9 @@ function buildManagedSecretsCommand() {
11501
11752
  "injectionRule",
11502
11753
  "label",
11503
11754
  "accountIdentifier",
11504
- "accountDisplayName"
11755
+ "accountDisplayName",
11756
+ "allowGroupDelegation",
11757
+ "maxDelegationTtlDays"
11505
11758
  ].filter((k) => {
11506
11759
  if (k === "credentialType") return options.credentialType !== "bearer_token";
11507
11760
  return options[k] !== void 0;
@@ -11587,6 +11840,16 @@ function buildManagedSecretsCommand() {
11587
11840
  if (options.accountDisplayName !== void 0) {
11588
11841
  body.account_display_name = options.accountDisplayName;
11589
11842
  }
11843
+ if (options.allowGroupDelegation !== void 0 || options.maxDelegationTtlDays !== void 0) {
11844
+ const delegationPolicy = {};
11845
+ if (options.allowGroupDelegation !== void 0) {
11846
+ delegationPolicy.allow_group_source = options.allowGroupDelegation;
11847
+ }
11848
+ if (options.maxDelegationTtlDays !== void 0) {
11849
+ delegationPolicy.max_delegation_ttl_days = options.maxDelegationTtlDays;
11850
+ }
11851
+ body.delegation_policy = delegationPolicy;
11852
+ }
11590
11853
  await withClient(async (client) => {
11591
11854
  const row = await client.managedSecrets.create(
11592
11855
  resolveAppIdOrExit(options.app),
@@ -11596,6 +11859,29 @@ function buildManagedSecretsCommand() {
11596
11859
  });
11597
11860
  }
11598
11861
  );
11862
+ root.command("set-delegation-policy <secret-id>").description(
11863
+ "Set the user \u2192 agent delegation policy on a managed secret. REPLACES the current policy (omitting --allow-group-delegation turns group delegation OFF). Requires dashboard_secrets:write."
11864
+ ).option("--app <app-id>", "App ID. Falls back to ALTER_APP_ID env or .alter/config.yaml").option(
11865
+ "--allow-group-delegation",
11866
+ "Allow members of a group-typed grant on this secret to delegate it to an agent (default: off)"
11867
+ ).option(
11868
+ "--max-delegation-ttl-days <days>",
11869
+ "Cap on a single delegation's lifetime, in days (1..1825; omit for the 90-day default)",
11870
+ parseBoundedInt("--max-delegation-ttl-days", 1, 5 * 365)
11871
+ ).option("--output <format>", "Output format: json|table (default: json)", "json").action(
11872
+ async (rawSecretId, options) => {
11873
+ const format = coerceOutputFormat(options.output);
11874
+ const appId = resolveAppIdOrExit(options.app);
11875
+ const secretId = parseUuidArgument("<secret-id>")(rawSecretId);
11876
+ await withClient(async (client) => {
11877
+ const row = await client.managedSecrets.setDelegationPolicy(appId, secretId, {
11878
+ allow_group_source: options.allowGroupDelegation === true,
11879
+ max_delegation_ttl_days: options.maxDelegationTtlDays
11880
+ });
11881
+ emit(format, row);
11882
+ });
11883
+ }
11884
+ );
11599
11885
  root.command("delete <secret-id>").description(
11600
11886
  "Cascade-delete a managed secret. Requires dashboard_secrets:delete scope (NOT bundled into :write). Cascade-revokes grants + delegations + audit anchors and vault-deletes the credential."
11601
11887
  ).option("--app <app-id>", "App ID").option(
@@ -12348,6 +12634,46 @@ function buildSelfUpdateCommand() {
12348
12634
  });
12349
12635
  }
12350
12636
 
12637
+ // src/program.ts
12638
+ function buildProgram() {
12639
+ const program2 = new Command14();
12640
+ program2.name("alter").description("Alter Vault command-line interface").version(package_default.version);
12641
+ program2.option(
12642
+ "--fields <list>",
12643
+ "Comma-separated top-level keys to keep in JSON output (e.g. ``--fields id,name``). Inert with --output=table."
12644
+ );
12645
+ program2.hook("preAction", (thisCommand) => {
12646
+ setGlobalFields(void 0);
12647
+ const raw = thisCommand.opts().fields;
12648
+ if (raw !== void 0) {
12649
+ try {
12650
+ setGlobalFields(parseFieldsList(raw));
12651
+ } catch (e) {
12652
+ process.stderr.write(
12653
+ `alter: ${e instanceof Error ? e.message : String(e)}
12654
+ `
12655
+ );
12656
+ process.exit(EXIT_USAGE);
12657
+ }
12658
+ }
12659
+ });
12660
+ program2.addCommand(buildAuthCommand());
12661
+ program2.addCommand(buildAppsCommand());
12662
+ program2.addCommand(buildKeysCommand());
12663
+ program2.addCommand(buildAgentsCommand());
12664
+ program2.addCommand(buildProvidersCommand());
12665
+ program2.addCommand(buildManagedSecretsCommand());
12666
+ program2.addCommand(buildPolicyCommand());
12667
+ program2.addCommand(buildAuditCommand());
12668
+ program2.addCommand(buildPatsCommand());
12669
+ program2.addCommand(buildLinkCommand());
12670
+ program2.addCommand(buildUnlinkCommand());
12671
+ program2.addCommand(buildCompletionCommand());
12672
+ program2.addCommand(buildSdkPassthroughCommand());
12673
+ program2.addCommand(buildSelfUpdateCommand());
12674
+ return program2;
12675
+ }
12676
+
12351
12677
  // src/cli.ts
12352
12678
  var COMMANDER_USAGE_CODES = /* @__PURE__ */ new Set([
12353
12679
  "commander.missingArgument",
@@ -12411,41 +12737,7 @@ function rewriteLegacySelfUpdateVersionFlag(argv2) {
12411
12737
  }
12412
12738
  return rewritten;
12413
12739
  }
12414
- var program = new Command14();
12415
- program.name("alter").description("Alter Vault command-line interface").version(package_default.version);
12416
- program.option(
12417
- "--fields <list>",
12418
- "Comma-separated top-level keys to keep in JSON output (e.g. ``--fields id,name``). Inert with --output=table."
12419
- );
12420
- program.hook("preAction", (thisCommand) => {
12421
- setGlobalFields(void 0);
12422
- const raw = thisCommand.opts().fields;
12423
- if (raw !== void 0) {
12424
- try {
12425
- setGlobalFields(parseFieldsList(raw));
12426
- } catch (e) {
12427
- process.stderr.write(
12428
- `alter: ${e instanceof Error ? e.message : String(e)}
12429
- `
12430
- );
12431
- process.exit(EXIT_USAGE);
12432
- }
12433
- }
12434
- });
12435
- program.addCommand(buildAuthCommand());
12436
- program.addCommand(buildAppsCommand());
12437
- program.addCommand(buildKeysCommand());
12438
- program.addCommand(buildAgentsCommand());
12439
- program.addCommand(buildProvidersCommand());
12440
- program.addCommand(buildManagedSecretsCommand());
12441
- program.addCommand(buildPolicyCommand());
12442
- program.addCommand(buildAuditCommand());
12443
- program.addCommand(buildPatsCommand());
12444
- program.addCommand(buildLinkCommand());
12445
- program.addCommand(buildUnlinkCommand());
12446
- program.addCommand(buildCompletionCommand());
12447
- program.addCommand(buildSdkPassthroughCommand());
12448
- program.addCommand(buildSelfUpdateCommand());
12740
+ var program = buildProgram();
12449
12741
  applyExitOverride(program);
12450
12742
  var argv = rewriteLegacySelfUpdateVersionFlag(process.argv);
12451
12743
  async function main() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alter-ai/cli",
3
- "version": "0.1.0",
3
+ "version": "0.3.1",
4
4
  "description": "Command-line interface for the Alter Vault dev portal — scripted dashboard automation.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,9 @@
16
16
  "dev": "tsx src/cli.ts",
17
17
  "test": "vitest run",
18
18
  "test:watch": "vitest",
19
- "typecheck": "tsc --noEmit",
19
+ "docs:check": "tsx scripts/check-docs-drift.ts",
20
+ "docs:print": "tsx scripts/check-docs-drift.ts --print",
21
+ "typecheck": "tsc --noEmit && tsc -p tsconfig.scripts.json",
20
22
  "lint": "eslint src/ tests/ --ext .ts",
21
23
  "format": "prettier --write 'src/**/*.ts' 'tests/**/*.ts'"
22
24
  },