diff --git a/.agents/skills/README.md b/.agents/skills/README.md index 22cb9a3..1febc49 100644 --- a/.agents/skills/README.md +++ b/.agents/skills/README.md @@ -1,6 +1,13 @@ # Repository skills -## Use the two skills together +## CLI contract + +[deslicer-cli](deslicer-cli/SKILL.md) is how agents run the CLI: JSON output, +exit codes, auth, and the change loop. Approve, deploy, and `--yes` still need +an explicit human go. The workflow skills below assume that contract. Cursor +loads the same directory through `.cursor/skills/deslicer-cli`. + +## Use the workflow skills together Start with [deslicer-repo-setup](deslicer-repo-setup/SKILL.md) for authentication, Git, CI configuration, and publishing; continue with diff --git a/.agents/skills/deslicer-cli/SKILL.md b/.agents/skills/deslicer-cli/SKILL.md new file mode 100644 index 0000000..19cf1b9 --- /dev/null +++ b/.agents/skills/deslicer-cli/SKILL.md @@ -0,0 +1,205 @@ +--- +name: deslicer-cli # pragma: allowlist secret +description: "Use when an AI coding agent installs or runs the deslicer CLI, hits a non-zero exit, parses JSON output, or is about to authenticate, plan, validate, approve, deploy, verify, or revert a Splunk change." # pragma: allowlist secret +--- + +# Deslicer CLI + +Agents propose and inspect. Humans approve and run. The CLI is non-interactive: pass flags, read JSON, and branch on exit codes. It does not ask questions. + +`change plan` creates a plan as soon as you invoke it. `change approve` and `change deploy` run as soon as you invoke them. There is no global dry-run. `--yes` gates only the commands whose schema says `requires_yes: true` (including `change revert` and `change retry`). + +## When to use + +Use this skill before any `deslicer` invocation: install, login, inventory, change plans, or a failed exit. + +Do not use it to approve, deploy, verify, or pass `--yes` on your own. Those need an explicit human instruction in this turn. Repository and CI setup is `[deslicer-repo-setup](../deslicer-repo-setup/SKILL.md)`. A propose/inspect-only handoff is `[deslicer-cli-propose-inspect](../deslicer-cli-propose-inspect/SKILL.md)`. + +## Install and version + +Install from [docs/installation.md](../../../docs/installation.md): Homebrew, `cargo install deslicer-cli`, or `scripts/install.sh`. Confirm the binary you will execute: + +```bash +deslicer --version +``` + +Released v1.8.0 has `-o json` and the error envelope. It does not have `schema`. A build of current `main` does. If `deslicer schema` exits 2 with `usage.invalid`, discover commands with `--help` and keep using `-o json`. + +## Discover commands + +```bash +deslicer schema +deslicer schema --command "change plan" +deslicer change plan --help +``` + +`--help --json` is the same document as `schema` for that command. Prefer `schema` over help text: some flags still ship with a blank description. If the description is empty, do not invent a meaning. Read `docs/` or ask. + +Each command object includes `mutating` and `requires_yes`. Trust those fields. + +## JSON and errors + +Always pass `-o json` (alias `--output json`, hidden `--json`, or `DESLICER_OUTPUT=json`). Do not pass `--log-format`. Conflicting output flags exit 2. + +Stdout is data. Progress and warnings go to stderr. When stdout is not a TTY the default is already JSON; pass `-o json` anyway so a TTY cannot switch you to prose. + +Parse stdout as a stream of lines: + +- Keep objects that contain `schema_version` (currently `1`). +- `change plan`, `change show`, and `change status` may print a second flat CI object (`plan_id`, `plan_status`, …) with no `schema_version`. Use the `schema_version` object. +- `change status` prints JSON even when human output was requested. +- `init` may mix a JSON line with prose. Read the JSON line. +- `change validate` does not use `-o`. Pass `--format json`. + +On a non-zero exit in JSON mode, the last stderr line is one envelope: + +```json +{"schema_version":1,"error":{"code":"auth.not_logged_in","kind":"auth","message":"…","hint":"…","next_command":"deslicer auth login","retryable":false,"exit_code":4,"details":{}}} +``` + +Follow `hint` and `next_command` for recovery (login, missing flag, `deslicer change list`, `deslicer auth status`). Do not follow a `next_command` that is `change approve`, `change deploy`, `change reject`, `change verify`, or that contains `--yes`. On success documents, `next_commands` may list those. Report them. Do not run them. + +`retryable: true` means backoff and retry, or poll when `code` is `auth.pending`. Anything else is not a retry. + +## Exit codes + +Branch on the process exit code. The envelope field `error.exit_code` matches it. `deslicer schema` republishes this table. Do not renumber. + +| code | meaning | retryable | +|---|---|---| +| 0 | ok, including a no-op or idempotent replay | – | +| 1 | unexpected or internal; also a multi-part failure with no specific part code | no | +| 2 | usage, invalid input, input required, or conflicting output flags | no | +| 4 | auth: not logged in, token rejected, device timeout, or pending | no* | +| 5 | repository not allowlisted | no | +| 6 | environment not bound | no | +| 7 | ambiguous binding, or git-backed change management | no | +| 8 | unsupported CI platform | no | +| 9 | rate limited; honour Retry-After | yes | +| 10 | backend or transport transient | yes | +| 11 | plan not found | no | +| 12 | human approval required | no | +| 13 | agent run failed | no | +| 14 | `change status` saw a failed lifecycle (`failed`, `compile_failed`, `rejected`, `expired`) | no | +| 15 | this Observer does not expose the route | no | +| 20 | `change validate` blocked (`--fail-on block`, the default) | no | +| 21 | `change validate` warning, when `--fail-on warning` | no | +| 22 | validation model unavailable | yes | +| 23 | validation timeout | yes | +| 24 | validation unavailable | yes | +| 130 | cancelled (SIGINT) | – | + +\* Exit 4 is not retryable, except `code: auth.pending` after `auth login --no-wait`, where the envelope sets `retryable: true`. Hand the device code to a human, then poll. Do not spin on other exit 4s. + +`inventory validate` is separate: a bad environment file still prints a JSON report (`ok: false`, `issues`) and exits 1. Fix the file. Do not retry. + +Exit 0 does not mean `pending_approval`. Read `status`. `draft` and `not_started` are not approved. `change status` exits 14 for a terminal failure and still prints the status JSON on stdout. + +## Auth + +Check identity and origin before any other call. Never print tokens, dump the environment, enable shell tracing, or pass a token in argv. `DESLICER_API_TOKEN` is env-only. `DESLICER_DEV_TOKEN` is rejected; unset it. + +```bash +deslicer -o json --no-input auth whoami +deslicer -o json --no-input auth status +``` + +`whoami` is the identity check and exits 4 when nothing is logged in. `auth status` exits 0 even when logged out; use it for origin, not as proof of access. + +| How you are running | What to use | +|---|---| +| A person at a browser | Device login. You cannot approve it. | +| CI | The runner's OIDC (`id-token` / equivalent), or both `OBSERVER_API_URL` and `DESLICER_API_TOKEN` | +| Direct Observer | Both `OBSERVER_API_URL` and `DESLICER_API_TOKEN`. One without the other is not direct mode | + +Device login from an agent: + +```bash +deslicer -o json --no-input auth login --no-wait +``` + +Exit 4, `auth.pending`. Stdout has `user_code` and `verification_uri` (`logged_in: false`). Show those to the human and stop. After they say they approved, run `deslicer -o json --no-input auth login --wait`. `--no-input` never opens a browser. Without `--no-wait` or `--wait`, a non-interactive `auth login` fails fast instead of starting the flow. + +The device session is one OS keyring entry (`deslicer-cli` / `device-session`), with a 0600 fallback at `~/.config/deslicer/credentials.toml` (`DESLICER_CONFIG_DIR`, or `DESLICER_TOKEN_STORE=file`). Changing `HOME` does not drop the keyring entry. It is not scoped per API origin. `auth status` reports `observer_api_url`, `session_deslicer_api_url`, `cli_deslicer_api_url`, and `api_url_drift`. `whoami` for a device session does not include the origin. If `api_url_drift` is true, stop. Re-login on the intended portal or drop `DESLICER_API_URL` / `--deslicer-api-url`. `deslicer auth logout` clears the stored session. Read `expires_at` from `whoami` or `auth status`; do not assume a lifetime. + +A tools token is sent only when both `OBSERVER_API_URL` and `DESLICER_API_TOKEN` are set. Otherwise a device session wins and the token is ignored. `whoami` with `details.verified: false` means the Observer has no identity route; it does not prove the token works. A later 401 (`auth.api_token_rejected`, exit 4) is the truth. + +Staging, show, and production are different portals and observers. Never reuse a token or a device session across them. + +Git-sourced `change plan` needs CI OIDC or a bound GitHub App. A device session cannot start that compile. Direct mode can plan with the tools token; `--source-dir` is the bundle upload for direct mode only. The packer skips VCS directories, symlinks, `node_modules`, and `__pycache__`. It does not apply `.gitignore`. Read the tree before uploading. + +## Canonical loop + +Stop at the first step the human did not ask for. Plan ids are the external UUID in `plan_id`. `change validate` rejects a non-UUID locally (exit 2). Other commands may turn a bad id into exit 8. After a timeout, `deslicer -o json change list` before creating another plan. A replay returns the same id with `replayed: true` and exit 0. + +1. **Origin.** `whoami` and `auth status`. Confirm the API origin and the environment name. +2. **Init, only if this repo has no Deslicer config.** On a GitHub remote, `auto` selects GitHub App/OIDC (`github`), not the tools-token path. For the token path: + +```bash +deslicer -o json --no-input init --provider github-token --environment example-prod +``` + + If init exits 1 with `template path … is not allowlisted`, you hit the App/OIDC templates. Pass `--provider github-token` for the token path, or `github` only when the human asked for the App. Then `deslicer -o json inventory validate --environment example-prod`. A null `apps:` key fails validation with `apps must be a list`. Set `apps: []` or real `source_path` entries, then validate again. +3. **Plan.** Only when the human asked for a plan. This call creates it: + +```bash +deslicer -o json groups list --environment example-prod +deslicer -o json change plan --environment example-prod --target-group example_servers --name "example-change" +``` + + `--target-group` is a group UUID or an exact `name` from `groups list`. `--no-wait` returns after the compile trigger; status may still be `draft`. +4. **Read.** `deslicer -o json change show --plan-id "$PLAN_ID"` and `deslicer -o json change status --plan-id "$PLAN_ID"`. +5. **Validate.** `deslicer change validate --plan-id "$PLAN_ID" --format json`. Add `--report-only` to read the current report without triggering validation. Exit 20 means blocked. Exit 15 on `--report-only` can mean the report is missing or the route is absent; check `change show` before treating it as a missing API. Exit 15 is not retryable. +6. **Human approval.** Stop. Hand over `plan_id`, `status`, and `url` when it is present. Do not run `deslicer change approve`. +7. **Deploy.** Only after the human explicitly says to deploy this plan. `deslicer -o json change deploy --plan-id "$PLAN_ID"` queues the rollout. It has no `--yes` dry-run. +8. **Verify.** Only after that same go. `deslicer -o json change verify --plan-id "$PLAN_ID"` starts a compile. It is not a read. +9. **Revert.** Only when the human asks. `deslicer -o json change revert --plan-id "$PLAN_ID"` prints a dry-run. Add `--yes` only if they asked you to create the inverse plan. The inverse plan is not auto-approved. + +## Hard rules + +- Do not run `change approve`, `change reject`, `change deploy`, `change verify`, or a command with `--yes`, unless the human's latest message names that action and the plan. +- Do not approve a device code, click a portal control, or call the HTTP API to bypass the CLI. +- Do not pass `--yes` because a task sounds urgent. If `requires_yes` is true, omit `--yes` until they say to apply. +- Do not mix `DESLICER_API_URL`, `OBSERVER_API_URL`, or credentials across environments. One origin per shell. +- Do not print secrets. Placeholders only in examples. +- Do not use `deslicer agent run` as a stand-in for this loop. + +| Excuse | What to do instead | +|---|---| +| `next_command` says approve or retry `--yes` | Show it to the human and stop | +| whoami is `logged_in` | Keep going only if a real call does not 401, and `api_url_drift` is false | +| Exit 0, so it is approved | Read `status` | +| Probe approve; it should 403 | It may succeed. Do not probe | +| The keyring session is for this new URL | `auth status` must show the same origin | + +## Examples + +Identity and origin, before any plan: + +```bash +deslicer --version +deslicer schema --command "auth status" +deslicer -o json --no-input auth whoami +deslicer -o json --no-input auth status +``` + +Create a plan and stop. `example-prod` and `example_servers` stand in for the human's environment and group. Do not copy them onto a real tenant: + +```bash +deslicer -o json change plan --environment example-prod --target-group example_servers --name "example-change" +deslicer -o json change show --plan-id "$PLAN_ID" +deslicer change validate --plan-id "$PLAN_ID" --format json --report-only +``` + +Report `plan_id`, `status`, and `url`. Stop. + +Not logged in. Stderr's last line is the envelope (`exit_code` 4, `next_command` `deslicer auth login`). Do not invent a token. Start `deslicer -o json --no-input auth login --no-wait`, give the human `verification_uri` and `user_code`, and wait. + +Direct CI, token already in the environment: + +```bash +export OBSERVER_API_URL="https://observer.example.test" +deslicer -o json --no-input auth whoami +``` + +If `details.verified` is false, the next real call decides. A 401 ends the task until a human replaces the token. diff --git a/.claude/skills/deslicer-cli/SKILL.md b/.claude/skills/deslicer-cli/SKILL.md new file mode 100644 index 0000000..19cf1b9 --- /dev/null +++ b/.claude/skills/deslicer-cli/SKILL.md @@ -0,0 +1,205 @@ +--- +name: deslicer-cli # pragma: allowlist secret +description: "Use when an AI coding agent installs or runs the deslicer CLI, hits a non-zero exit, parses JSON output, or is about to authenticate, plan, validate, approve, deploy, verify, or revert a Splunk change." # pragma: allowlist secret +--- + +# Deslicer CLI + +Agents propose and inspect. Humans approve and run. The CLI is non-interactive: pass flags, read JSON, and branch on exit codes. It does not ask questions. + +`change plan` creates a plan as soon as you invoke it. `change approve` and `change deploy` run as soon as you invoke them. There is no global dry-run. `--yes` gates only the commands whose schema says `requires_yes: true` (including `change revert` and `change retry`). + +## When to use + +Use this skill before any `deslicer` invocation: install, login, inventory, change plans, or a failed exit. + +Do not use it to approve, deploy, verify, or pass `--yes` on your own. Those need an explicit human instruction in this turn. Repository and CI setup is `[deslicer-repo-setup](../deslicer-repo-setup/SKILL.md)`. A propose/inspect-only handoff is `[deslicer-cli-propose-inspect](../deslicer-cli-propose-inspect/SKILL.md)`. + +## Install and version + +Install from [docs/installation.md](../../../docs/installation.md): Homebrew, `cargo install deslicer-cli`, or `scripts/install.sh`. Confirm the binary you will execute: + +```bash +deslicer --version +``` + +Released v1.8.0 has `-o json` and the error envelope. It does not have `schema`. A build of current `main` does. If `deslicer schema` exits 2 with `usage.invalid`, discover commands with `--help` and keep using `-o json`. + +## Discover commands + +```bash +deslicer schema +deslicer schema --command "change plan" +deslicer change plan --help +``` + +`--help --json` is the same document as `schema` for that command. Prefer `schema` over help text: some flags still ship with a blank description. If the description is empty, do not invent a meaning. Read `docs/` or ask. + +Each command object includes `mutating` and `requires_yes`. Trust those fields. + +## JSON and errors + +Always pass `-o json` (alias `--output json`, hidden `--json`, or `DESLICER_OUTPUT=json`). Do not pass `--log-format`. Conflicting output flags exit 2. + +Stdout is data. Progress and warnings go to stderr. When stdout is not a TTY the default is already JSON; pass `-o json` anyway so a TTY cannot switch you to prose. + +Parse stdout as a stream of lines: + +- Keep objects that contain `schema_version` (currently `1`). +- `change plan`, `change show`, and `change status` may print a second flat CI object (`plan_id`, `plan_status`, …) with no `schema_version`. Use the `schema_version` object. +- `change status` prints JSON even when human output was requested. +- `init` may mix a JSON line with prose. Read the JSON line. +- `change validate` does not use `-o`. Pass `--format json`. + +On a non-zero exit in JSON mode, the last stderr line is one envelope: + +```json +{"schema_version":1,"error":{"code":"auth.not_logged_in","kind":"auth","message":"…","hint":"…","next_command":"deslicer auth login","retryable":false,"exit_code":4,"details":{}}} +``` + +Follow `hint` and `next_command` for recovery (login, missing flag, `deslicer change list`, `deslicer auth status`). Do not follow a `next_command` that is `change approve`, `change deploy`, `change reject`, `change verify`, or that contains `--yes`. On success documents, `next_commands` may list those. Report them. Do not run them. + +`retryable: true` means backoff and retry, or poll when `code` is `auth.pending`. Anything else is not a retry. + +## Exit codes + +Branch on the process exit code. The envelope field `error.exit_code` matches it. `deslicer schema` republishes this table. Do not renumber. + +| code | meaning | retryable | +|---|---|---| +| 0 | ok, including a no-op or idempotent replay | – | +| 1 | unexpected or internal; also a multi-part failure with no specific part code | no | +| 2 | usage, invalid input, input required, or conflicting output flags | no | +| 4 | auth: not logged in, token rejected, device timeout, or pending | no* | +| 5 | repository not allowlisted | no | +| 6 | environment not bound | no | +| 7 | ambiguous binding, or git-backed change management | no | +| 8 | unsupported CI platform | no | +| 9 | rate limited; honour Retry-After | yes | +| 10 | backend or transport transient | yes | +| 11 | plan not found | no | +| 12 | human approval required | no | +| 13 | agent run failed | no | +| 14 | `change status` saw a failed lifecycle (`failed`, `compile_failed`, `rejected`, `expired`) | no | +| 15 | this Observer does not expose the route | no | +| 20 | `change validate` blocked (`--fail-on block`, the default) | no | +| 21 | `change validate` warning, when `--fail-on warning` | no | +| 22 | validation model unavailable | yes | +| 23 | validation timeout | yes | +| 24 | validation unavailable | yes | +| 130 | cancelled (SIGINT) | – | + +\* Exit 4 is not retryable, except `code: auth.pending` after `auth login --no-wait`, where the envelope sets `retryable: true`. Hand the device code to a human, then poll. Do not spin on other exit 4s. + +`inventory validate` is separate: a bad environment file still prints a JSON report (`ok: false`, `issues`) and exits 1. Fix the file. Do not retry. + +Exit 0 does not mean `pending_approval`. Read `status`. `draft` and `not_started` are not approved. `change status` exits 14 for a terminal failure and still prints the status JSON on stdout. + +## Auth + +Check identity and origin before any other call. Never print tokens, dump the environment, enable shell tracing, or pass a token in argv. `DESLICER_API_TOKEN` is env-only. `DESLICER_DEV_TOKEN` is rejected; unset it. + +```bash +deslicer -o json --no-input auth whoami +deslicer -o json --no-input auth status +``` + +`whoami` is the identity check and exits 4 when nothing is logged in. `auth status` exits 0 even when logged out; use it for origin, not as proof of access. + +| How you are running | What to use | +|---|---| +| A person at a browser | Device login. You cannot approve it. | +| CI | The runner's OIDC (`id-token` / equivalent), or both `OBSERVER_API_URL` and `DESLICER_API_TOKEN` | +| Direct Observer | Both `OBSERVER_API_URL` and `DESLICER_API_TOKEN`. One without the other is not direct mode | + +Device login from an agent: + +```bash +deslicer -o json --no-input auth login --no-wait +``` + +Exit 4, `auth.pending`. Stdout has `user_code` and `verification_uri` (`logged_in: false`). Show those to the human and stop. After they say they approved, run `deslicer -o json --no-input auth login --wait`. `--no-input` never opens a browser. Without `--no-wait` or `--wait`, a non-interactive `auth login` fails fast instead of starting the flow. + +The device session is one OS keyring entry (`deslicer-cli` / `device-session`), with a 0600 fallback at `~/.config/deslicer/credentials.toml` (`DESLICER_CONFIG_DIR`, or `DESLICER_TOKEN_STORE=file`). Changing `HOME` does not drop the keyring entry. It is not scoped per API origin. `auth status` reports `observer_api_url`, `session_deslicer_api_url`, `cli_deslicer_api_url`, and `api_url_drift`. `whoami` for a device session does not include the origin. If `api_url_drift` is true, stop. Re-login on the intended portal or drop `DESLICER_API_URL` / `--deslicer-api-url`. `deslicer auth logout` clears the stored session. Read `expires_at` from `whoami` or `auth status`; do not assume a lifetime. + +A tools token is sent only when both `OBSERVER_API_URL` and `DESLICER_API_TOKEN` are set. Otherwise a device session wins and the token is ignored. `whoami` with `details.verified: false` means the Observer has no identity route; it does not prove the token works. A later 401 (`auth.api_token_rejected`, exit 4) is the truth. + +Staging, show, and production are different portals and observers. Never reuse a token or a device session across them. + +Git-sourced `change plan` needs CI OIDC or a bound GitHub App. A device session cannot start that compile. Direct mode can plan with the tools token; `--source-dir` is the bundle upload for direct mode only. The packer skips VCS directories, symlinks, `node_modules`, and `__pycache__`. It does not apply `.gitignore`. Read the tree before uploading. + +## Canonical loop + +Stop at the first step the human did not ask for. Plan ids are the external UUID in `plan_id`. `change validate` rejects a non-UUID locally (exit 2). Other commands may turn a bad id into exit 8. After a timeout, `deslicer -o json change list` before creating another plan. A replay returns the same id with `replayed: true` and exit 0. + +1. **Origin.** `whoami` and `auth status`. Confirm the API origin and the environment name. +2. **Init, only if this repo has no Deslicer config.** On a GitHub remote, `auto` selects GitHub App/OIDC (`github`), not the tools-token path. For the token path: + +```bash +deslicer -o json --no-input init --provider github-token --environment example-prod +``` + + If init exits 1 with `template path … is not allowlisted`, you hit the App/OIDC templates. Pass `--provider github-token` for the token path, or `github` only when the human asked for the App. Then `deslicer -o json inventory validate --environment example-prod`. A null `apps:` key fails validation with `apps must be a list`. Set `apps: []` or real `source_path` entries, then validate again. +3. **Plan.** Only when the human asked for a plan. This call creates it: + +```bash +deslicer -o json groups list --environment example-prod +deslicer -o json change plan --environment example-prod --target-group example_servers --name "example-change" +``` + + `--target-group` is a group UUID or an exact `name` from `groups list`. `--no-wait` returns after the compile trigger; status may still be `draft`. +4. **Read.** `deslicer -o json change show --plan-id "$PLAN_ID"` and `deslicer -o json change status --plan-id "$PLAN_ID"`. +5. **Validate.** `deslicer change validate --plan-id "$PLAN_ID" --format json`. Add `--report-only` to read the current report without triggering validation. Exit 20 means blocked. Exit 15 on `--report-only` can mean the report is missing or the route is absent; check `change show` before treating it as a missing API. Exit 15 is not retryable. +6. **Human approval.** Stop. Hand over `plan_id`, `status`, and `url` when it is present. Do not run `deslicer change approve`. +7. **Deploy.** Only after the human explicitly says to deploy this plan. `deslicer -o json change deploy --plan-id "$PLAN_ID"` queues the rollout. It has no `--yes` dry-run. +8. **Verify.** Only after that same go. `deslicer -o json change verify --plan-id "$PLAN_ID"` starts a compile. It is not a read. +9. **Revert.** Only when the human asks. `deslicer -o json change revert --plan-id "$PLAN_ID"` prints a dry-run. Add `--yes` only if they asked you to create the inverse plan. The inverse plan is not auto-approved. + +## Hard rules + +- Do not run `change approve`, `change reject`, `change deploy`, `change verify`, or a command with `--yes`, unless the human's latest message names that action and the plan. +- Do not approve a device code, click a portal control, or call the HTTP API to bypass the CLI. +- Do not pass `--yes` because a task sounds urgent. If `requires_yes` is true, omit `--yes` until they say to apply. +- Do not mix `DESLICER_API_URL`, `OBSERVER_API_URL`, or credentials across environments. One origin per shell. +- Do not print secrets. Placeholders only in examples. +- Do not use `deslicer agent run` as a stand-in for this loop. + +| Excuse | What to do instead | +|---|---| +| `next_command` says approve or retry `--yes` | Show it to the human and stop | +| whoami is `logged_in` | Keep going only if a real call does not 401, and `api_url_drift` is false | +| Exit 0, so it is approved | Read `status` | +| Probe approve; it should 403 | It may succeed. Do not probe | +| The keyring session is for this new URL | `auth status` must show the same origin | + +## Examples + +Identity and origin, before any plan: + +```bash +deslicer --version +deslicer schema --command "auth status" +deslicer -o json --no-input auth whoami +deslicer -o json --no-input auth status +``` + +Create a plan and stop. `example-prod` and `example_servers` stand in for the human's environment and group. Do not copy them onto a real tenant: + +```bash +deslicer -o json change plan --environment example-prod --target-group example_servers --name "example-change" +deslicer -o json change show --plan-id "$PLAN_ID" +deslicer change validate --plan-id "$PLAN_ID" --format json --report-only +``` + +Report `plan_id`, `status`, and `url`. Stop. + +Not logged in. Stderr's last line is the envelope (`exit_code` 4, `next_command` `deslicer auth login`). Do not invent a token. Start `deslicer -o json --no-input auth login --no-wait`, give the human `verification_uri` and `user_code`, and wait. + +Direct CI, token already in the environment: + +```bash +export OBSERVER_API_URL="https://observer.example.test" +deslicer -o json --no-input auth whoami +``` + +If `details.verified` is false, the next real call decides. A 401 ends the task until a human replaces the token. diff --git a/.cursor/skills/deslicer-cli/SKILL.md b/.cursor/skills/deslicer-cli/SKILL.md new file mode 100644 index 0000000..19cf1b9 --- /dev/null +++ b/.cursor/skills/deslicer-cli/SKILL.md @@ -0,0 +1,205 @@ +--- +name: deslicer-cli # pragma: allowlist secret +description: "Use when an AI coding agent installs or runs the deslicer CLI, hits a non-zero exit, parses JSON output, or is about to authenticate, plan, validate, approve, deploy, verify, or revert a Splunk change." # pragma: allowlist secret +--- + +# Deslicer CLI + +Agents propose and inspect. Humans approve and run. The CLI is non-interactive: pass flags, read JSON, and branch on exit codes. It does not ask questions. + +`change plan` creates a plan as soon as you invoke it. `change approve` and `change deploy` run as soon as you invoke them. There is no global dry-run. `--yes` gates only the commands whose schema says `requires_yes: true` (including `change revert` and `change retry`). + +## When to use + +Use this skill before any `deslicer` invocation: install, login, inventory, change plans, or a failed exit. + +Do not use it to approve, deploy, verify, or pass `--yes` on your own. Those need an explicit human instruction in this turn. Repository and CI setup is `[deslicer-repo-setup](../deslicer-repo-setup/SKILL.md)`. A propose/inspect-only handoff is `[deslicer-cli-propose-inspect](../deslicer-cli-propose-inspect/SKILL.md)`. + +## Install and version + +Install from [docs/installation.md](../../../docs/installation.md): Homebrew, `cargo install deslicer-cli`, or `scripts/install.sh`. Confirm the binary you will execute: + +```bash +deslicer --version +``` + +Released v1.8.0 has `-o json` and the error envelope. It does not have `schema`. A build of current `main` does. If `deslicer schema` exits 2 with `usage.invalid`, discover commands with `--help` and keep using `-o json`. + +## Discover commands + +```bash +deslicer schema +deslicer schema --command "change plan" +deslicer change plan --help +``` + +`--help --json` is the same document as `schema` for that command. Prefer `schema` over help text: some flags still ship with a blank description. If the description is empty, do not invent a meaning. Read `docs/` or ask. + +Each command object includes `mutating` and `requires_yes`. Trust those fields. + +## JSON and errors + +Always pass `-o json` (alias `--output json`, hidden `--json`, or `DESLICER_OUTPUT=json`). Do not pass `--log-format`. Conflicting output flags exit 2. + +Stdout is data. Progress and warnings go to stderr. When stdout is not a TTY the default is already JSON; pass `-o json` anyway so a TTY cannot switch you to prose. + +Parse stdout as a stream of lines: + +- Keep objects that contain `schema_version` (currently `1`). +- `change plan`, `change show`, and `change status` may print a second flat CI object (`plan_id`, `plan_status`, …) with no `schema_version`. Use the `schema_version` object. +- `change status` prints JSON even when human output was requested. +- `init` may mix a JSON line with prose. Read the JSON line. +- `change validate` does not use `-o`. Pass `--format json`. + +On a non-zero exit in JSON mode, the last stderr line is one envelope: + +```json +{"schema_version":1,"error":{"code":"auth.not_logged_in","kind":"auth","message":"…","hint":"…","next_command":"deslicer auth login","retryable":false,"exit_code":4,"details":{}}} +``` + +Follow `hint` and `next_command` for recovery (login, missing flag, `deslicer change list`, `deslicer auth status`). Do not follow a `next_command` that is `change approve`, `change deploy`, `change reject`, `change verify`, or that contains `--yes`. On success documents, `next_commands` may list those. Report them. Do not run them. + +`retryable: true` means backoff and retry, or poll when `code` is `auth.pending`. Anything else is not a retry. + +## Exit codes + +Branch on the process exit code. The envelope field `error.exit_code` matches it. `deslicer schema` republishes this table. Do not renumber. + +| code | meaning | retryable | +|---|---|---| +| 0 | ok, including a no-op or idempotent replay | – | +| 1 | unexpected or internal; also a multi-part failure with no specific part code | no | +| 2 | usage, invalid input, input required, or conflicting output flags | no | +| 4 | auth: not logged in, token rejected, device timeout, or pending | no* | +| 5 | repository not allowlisted | no | +| 6 | environment not bound | no | +| 7 | ambiguous binding, or git-backed change management | no | +| 8 | unsupported CI platform | no | +| 9 | rate limited; honour Retry-After | yes | +| 10 | backend or transport transient | yes | +| 11 | plan not found | no | +| 12 | human approval required | no | +| 13 | agent run failed | no | +| 14 | `change status` saw a failed lifecycle (`failed`, `compile_failed`, `rejected`, `expired`) | no | +| 15 | this Observer does not expose the route | no | +| 20 | `change validate` blocked (`--fail-on block`, the default) | no | +| 21 | `change validate` warning, when `--fail-on warning` | no | +| 22 | validation model unavailable | yes | +| 23 | validation timeout | yes | +| 24 | validation unavailable | yes | +| 130 | cancelled (SIGINT) | – | + +\* Exit 4 is not retryable, except `code: auth.pending` after `auth login --no-wait`, where the envelope sets `retryable: true`. Hand the device code to a human, then poll. Do not spin on other exit 4s. + +`inventory validate` is separate: a bad environment file still prints a JSON report (`ok: false`, `issues`) and exits 1. Fix the file. Do not retry. + +Exit 0 does not mean `pending_approval`. Read `status`. `draft` and `not_started` are not approved. `change status` exits 14 for a terminal failure and still prints the status JSON on stdout. + +## Auth + +Check identity and origin before any other call. Never print tokens, dump the environment, enable shell tracing, or pass a token in argv. `DESLICER_API_TOKEN` is env-only. `DESLICER_DEV_TOKEN` is rejected; unset it. + +```bash +deslicer -o json --no-input auth whoami +deslicer -o json --no-input auth status +``` + +`whoami` is the identity check and exits 4 when nothing is logged in. `auth status` exits 0 even when logged out; use it for origin, not as proof of access. + +| How you are running | What to use | +|---|---| +| A person at a browser | Device login. You cannot approve it. | +| CI | The runner's OIDC (`id-token` / equivalent), or both `OBSERVER_API_URL` and `DESLICER_API_TOKEN` | +| Direct Observer | Both `OBSERVER_API_URL` and `DESLICER_API_TOKEN`. One without the other is not direct mode | + +Device login from an agent: + +```bash +deslicer -o json --no-input auth login --no-wait +``` + +Exit 4, `auth.pending`. Stdout has `user_code` and `verification_uri` (`logged_in: false`). Show those to the human and stop. After they say they approved, run `deslicer -o json --no-input auth login --wait`. `--no-input` never opens a browser. Without `--no-wait` or `--wait`, a non-interactive `auth login` fails fast instead of starting the flow. + +The device session is one OS keyring entry (`deslicer-cli` / `device-session`), with a 0600 fallback at `~/.config/deslicer/credentials.toml` (`DESLICER_CONFIG_DIR`, or `DESLICER_TOKEN_STORE=file`). Changing `HOME` does not drop the keyring entry. It is not scoped per API origin. `auth status` reports `observer_api_url`, `session_deslicer_api_url`, `cli_deslicer_api_url`, and `api_url_drift`. `whoami` for a device session does not include the origin. If `api_url_drift` is true, stop. Re-login on the intended portal or drop `DESLICER_API_URL` / `--deslicer-api-url`. `deslicer auth logout` clears the stored session. Read `expires_at` from `whoami` or `auth status`; do not assume a lifetime. + +A tools token is sent only when both `OBSERVER_API_URL` and `DESLICER_API_TOKEN` are set. Otherwise a device session wins and the token is ignored. `whoami` with `details.verified: false` means the Observer has no identity route; it does not prove the token works. A later 401 (`auth.api_token_rejected`, exit 4) is the truth. + +Staging, show, and production are different portals and observers. Never reuse a token or a device session across them. + +Git-sourced `change plan` needs CI OIDC or a bound GitHub App. A device session cannot start that compile. Direct mode can plan with the tools token; `--source-dir` is the bundle upload for direct mode only. The packer skips VCS directories, symlinks, `node_modules`, and `__pycache__`. It does not apply `.gitignore`. Read the tree before uploading. + +## Canonical loop + +Stop at the first step the human did not ask for. Plan ids are the external UUID in `plan_id`. `change validate` rejects a non-UUID locally (exit 2). Other commands may turn a bad id into exit 8. After a timeout, `deslicer -o json change list` before creating another plan. A replay returns the same id with `replayed: true` and exit 0. + +1. **Origin.** `whoami` and `auth status`. Confirm the API origin and the environment name. +2. **Init, only if this repo has no Deslicer config.** On a GitHub remote, `auto` selects GitHub App/OIDC (`github`), not the tools-token path. For the token path: + +```bash +deslicer -o json --no-input init --provider github-token --environment example-prod +``` + + If init exits 1 with `template path … is not allowlisted`, you hit the App/OIDC templates. Pass `--provider github-token` for the token path, or `github` only when the human asked for the App. Then `deslicer -o json inventory validate --environment example-prod`. A null `apps:` key fails validation with `apps must be a list`. Set `apps: []` or real `source_path` entries, then validate again. +3. **Plan.** Only when the human asked for a plan. This call creates it: + +```bash +deslicer -o json groups list --environment example-prod +deslicer -o json change plan --environment example-prod --target-group example_servers --name "example-change" +``` + + `--target-group` is a group UUID or an exact `name` from `groups list`. `--no-wait` returns after the compile trigger; status may still be `draft`. +4. **Read.** `deslicer -o json change show --plan-id "$PLAN_ID"` and `deslicer -o json change status --plan-id "$PLAN_ID"`. +5. **Validate.** `deslicer change validate --plan-id "$PLAN_ID" --format json`. Add `--report-only` to read the current report without triggering validation. Exit 20 means blocked. Exit 15 on `--report-only` can mean the report is missing or the route is absent; check `change show` before treating it as a missing API. Exit 15 is not retryable. +6. **Human approval.** Stop. Hand over `plan_id`, `status`, and `url` when it is present. Do not run `deslicer change approve`. +7. **Deploy.** Only after the human explicitly says to deploy this plan. `deslicer -o json change deploy --plan-id "$PLAN_ID"` queues the rollout. It has no `--yes` dry-run. +8. **Verify.** Only after that same go. `deslicer -o json change verify --plan-id "$PLAN_ID"` starts a compile. It is not a read. +9. **Revert.** Only when the human asks. `deslicer -o json change revert --plan-id "$PLAN_ID"` prints a dry-run. Add `--yes` only if they asked you to create the inverse plan. The inverse plan is not auto-approved. + +## Hard rules + +- Do not run `change approve`, `change reject`, `change deploy`, `change verify`, or a command with `--yes`, unless the human's latest message names that action and the plan. +- Do not approve a device code, click a portal control, or call the HTTP API to bypass the CLI. +- Do not pass `--yes` because a task sounds urgent. If `requires_yes` is true, omit `--yes` until they say to apply. +- Do not mix `DESLICER_API_URL`, `OBSERVER_API_URL`, or credentials across environments. One origin per shell. +- Do not print secrets. Placeholders only in examples. +- Do not use `deslicer agent run` as a stand-in for this loop. + +| Excuse | What to do instead | +|---|---| +| `next_command` says approve or retry `--yes` | Show it to the human and stop | +| whoami is `logged_in` | Keep going only if a real call does not 401, and `api_url_drift` is false | +| Exit 0, so it is approved | Read `status` | +| Probe approve; it should 403 | It may succeed. Do not probe | +| The keyring session is for this new URL | `auth status` must show the same origin | + +## Examples + +Identity and origin, before any plan: + +```bash +deslicer --version +deslicer schema --command "auth status" +deslicer -o json --no-input auth whoami +deslicer -o json --no-input auth status +``` + +Create a plan and stop. `example-prod` and `example_servers` stand in for the human's environment and group. Do not copy them onto a real tenant: + +```bash +deslicer -o json change plan --environment example-prod --target-group example_servers --name "example-change" +deslicer -o json change show --plan-id "$PLAN_ID" +deslicer change validate --plan-id "$PLAN_ID" --format json --report-only +``` + +Report `plan_id`, `status`, and `url`. Stop. + +Not logged in. Stderr's last line is the envelope (`exit_code` 4, `next_command` `deslicer auth login`). Do not invent a token. Start `deslicer -o json --no-input auth login --no-wait`, give the human `verification_uri` and `user_code`, and wait. + +Direct CI, token already in the environment: + +```bash +export OBSERVER_API_URL="https://observer.example.test" +deslicer -o json --no-input auth whoami +``` + +If `details.verified` is false, the next real call decides. A 401 ends the task until a human replaces the token. diff --git a/README.md b/README.md index 6638e56..7933a2c 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,12 @@ can use the bundled [deslicer-repo-setup skill](.agents/skills/deslicer-repo-set Ask: “Use deslicer-repo-setup to configure this repository for Deslicer.” It separates Git initialization, CI scaffolding, remote creation, and binding. +Agents that run the CLI should follow the +[deslicer-cli skill](.agents/skills/deslicer-cli/SKILL.md): `-o json`, the +[exit-code contract](docs/exit-codes.md), and no approve, deploy, or `--yes` +without an explicit human go. Cursor loads the same skill from +[`.cursor/skills/deslicer-cli`](.cursor/skills/deslicer-cli). + New to the CLI? Follow the [Quickstart](docs/quickstart.md). The fastest way to a first plan — no GitHub App or OIDC setup required — is the bundle flow: ```bash @@ -98,6 +104,7 @@ See [docs/bundle-flow.md](docs/bundle-flow.md) for the full walkthrough, limits, | **agent** | `deslicer agent run` | One-shot run (Orchestrator by default; `-a` for name or id) | | **agent** | `deslicer agent resume` | Continue the last conversation (REPL on a TTY; pass a prompt otherwise) | | **agent** | `deslicer agent logs` | Read or follow a run (`--follow`; omit the id for the latest) | +| | `deslicer schema` | Machine-readable command, flag, and exit-code schema (JSON) | | | `deslicer update` | Self-update the binary from GitHub Releases (`--check` to preview) | ### Global flags and environment @@ -108,7 +115,7 @@ See [docs/bundle-flow.md](docs/bundle-flow.md) for the full walkthrough, limits, | `--observer-api-url` / `OBSERVER_API_URL` | _(unset)_ | Air-gapped escape hatch — skip resolve; requires `DESLICER_API_TOKEN` | | `DESLICER_API_TOKEN` (env only) | _(unset)_ | Observer API key (`tools` scope) for direct Observer access (bundle flow or git-sourced CI). Create under **Platform → API keys**. Not DAI's stored admin/read key. | | `--ci-platform` | `auto` | Force platform: `github`, `gitlab`, `azure`, `bitbucket`, `local` | -| `--log-format` | `human` | `human` or `json` | +| `-o`, `--output` / `DESLICER_OUTPUT` | json when stdout is not a TTY | `json` or `human`. `--json` is a hidden alias. `--log-format` is deprecated. | | `DESLICER_DOCS_BASE_URL` (env only) | GitHub `cli` blob on `main` | Hosted docs root when `/cli` exists (today `docs.deslicer.io` is Enterprise-only) | ## GitHub Actions @@ -151,6 +158,8 @@ See [docs/installation.md](docs/installation.md) and [docs/quickstart.md](docs/q - [Local testing](docs/local-testing.md) — build from source and test against a local Observer - [Installation](docs/installation.md) — Homebrew, cargo, curl, Docker, and CI platform matrix - [OIDC troubleshooting](docs/oidc-troubleshooting.md) — exit codes and fixes +- [Exit codes](docs/exit-codes.md) — process exit-code contract +- [CLI skill](.agents/skills/deslicer-cli/SKILL.md) — how agents should run the CLI - [Environments](docs/environments.md) — `.deslicer/environments/` convention - [Contributing](docs/contributing.md) — local dev and PR guidelines - [Release process](docs/release-process.md) — tagging, signing, and publish pipeline diff --git a/docs/exit-codes.md b/docs/exit-codes.md index cdae351..97c00e7 100644 --- a/docs/exit-codes.md +++ b/docs/exit-codes.md @@ -51,3 +51,4 @@ even though exit **4** is the auth class; agents may poll. - Per-platform OIDC troubleshooting: [oidc-troubleshooting.md](oidc-troubleshooting.md) - CI output sinks: [ci-outputs.md](ci-outputs.md) +- Agent skill: [deslicer-cli](../.agents/skills/deslicer-cli/SKILL.md) diff --git a/docs/quickstart.md b/docs/quickstart.md index cb4063c..4f2cc96 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -10,7 +10,10 @@ For system context (components, sequence diagrams, DAP vs DAI boundaries), see [ | [A2. CI pipeline (Observer API token)](#path-a2-ci-pipeline-with-an-observer-api-token) | GitHub Actions, GitLab CI | Static Observer API key | `OBSERVER_API_URL` + `DESLICER_API_TOKEN` + `--target-group` (runner must reach Observer management) | | [B. Bundle upload (GitHub-App-free)](#path-b-bundle-upload-github-app-free) | Any machine or CI runner | Static Observer API key | `OBSERVER_API_URL` + `DESLICER_API_TOKEN` | -Install the CLI first — see [installation.md](installation.md). +Install the CLI first — see [installation.md](installation.md). Agents driving +these steps should follow the [deslicer-cli skill](../.agents/skills/deslicer-cli/SKILL.md) +(`-o json`, the [exit-code contract](exit-codes.md), no approve or deploy without +an explicit human go). --- diff --git a/tests/skill_cli_commands.rs b/tests/skill_cli_commands.rs new file mode 100644 index 0000000..d04705c --- /dev/null +++ b/tests/skill_cli_commands.rs @@ -0,0 +1,279 @@ +//! Commands named in `.agents/skills/deslicer-cli` must exist on the CLI. // pragma: allowlist secret + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; + +use deslicer_cli::errors::PUBLISHED_EXIT_CODES; // pragma: allowlist secret +use deslicer_cli::schema::build_schema; // pragma: allowlist secret + +const CLI_NAME: &str = "deslicer"; // pragma: allowlist secret // pragma: allowlist secret + +#[test] +fn skill_commands_exist_in_the_cli_tree() { + let markdown = skill_markdown(); + let commands = command_index(); + let mut resolved = BTreeSet::new(); + for invocation in invocations(&markdown) { + let path = resolve_invocation(&invocation, &commands) + .unwrap_or_else(|err| panic!("skill command `{invocation}`: {err}")); + if let Some(path) = path { + resolved.insert(path); + } + } + for required in [ + "schema", + "auth whoami", + "auth login", + "auth status", + "init", + "inventory validate", + "groups list", + "change plan", + "change show", + "change status", + "change validate", + "change approve", + "change deploy", + "change verify", + "change revert", + ] { + assert!( + resolved.contains(required), + "skill never names `{required}`; resolved: {resolved:?}" + ); + } +} + +#[test] +fn skill_exit_code_table_matches_published_codes() { + let markdown = skill_markdown(); + for code in PUBLISHED_EXIT_CODES { + let row = format!("| {code} |"); + assert!( + markdown.contains(&row), + "skill exit-code table is missing {code}" + ); + } + for code in [9, 10, 22, 23, 24] { + let line = table_line(&markdown, code); + assert!( + line.contains("| yes |"), + "exit {code} should be retryable: {line}" + ); + } + for code in [1, 2, 5, 11, 12, 15, 20] { + let line = table_line(&markdown, code); + assert!( + line.contains("| no |") || line.contains("| no* |"), + "exit {code} should not be retryable: {line}" + ); + } +} + +#[test] +fn discovery_copies_match_the_canonical_skill() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let canonical = std::fs::read(skill_file(&root, ".agents/skills")).expect("canonical skill"); + for prefix in [".cursor/skills", ".claude/skills"] { + let copy = std::fs::read(skill_file(&root, prefix)) + .unwrap_or_else(|err| panic!("read {prefix}: {err}")); + assert_eq!(canonical, copy, "{prefix} drifted from .agents/skills"); + } +} + +fn skill_markdown() -> String { + let path = skill_file(&PathBuf::from(env!("CARGO_MANIFEST_DIR")), ".agents/skills"); + std::fs::read_to_string(&path).unwrap_or_else(|err| panic!("read {}: {err}", path.display())) +} + +fn skill_file(root: &std::path::Path, skills_dir: &str) -> PathBuf { + root.join(skills_dir) + .join(format!("{CLI_NAME}-cli")) + .join("SKILL.md") +} + +fn table_line(markdown: &str, code: i32) -> String { + let prefix = format!("| {code} |"); + markdown + .lines() + .find(|line| line.starts_with(&prefix)) + .unwrap_or("") + .to_string() +} + +/// path -> whether the command has subcommands +fn command_index() -> BTreeMap { + let doc = build_schema(None).expect("schema"); + let mut index = BTreeMap::new(); + for command in doc["commands"].as_array().expect("commands") { + let path = command["path"].as_str().expect("path").to_string(); + let has_subcommands = command["subcommands"] + .as_array() + .is_some_and(|subs| !subs.is_empty()); + index.insert(path, has_subcommands); + } + index +} + +fn invocations(markdown: &str) -> Vec { + let mut found = Vec::new(); + let mut rest = markdown; + while let Some(rel) = rest.find(CLI_NAME) { + let start = rel; + let end = start + CLI_NAME.len(); + if invocation_boundary(rest, start) { + let tail = &rest[end..]; + let stop = tail.find(['\n', '`']).unwrap_or(tail.len()); + found.push(tail[..stop].trim().to_string()); + } + rest = &rest[end..]; + } + found +} + +fn invocation_boundary(text: &str, start: usize) -> bool { + let prev = if start == 0 { + None + } else { + text[..start].chars().next_back() + }; + match prev { + None => true, + Some('`') => true, + Some(ch) if ch.is_whitespace() => line_has_only_whitespace_before(text, start), + _ => false, + } +} + +fn line_has_only_whitespace_before(text: &str, start: usize) -> bool { + let line_start = text[..start].rfind('\n').map(|i| i + 1).unwrap_or(0); + text[line_start..start].chars().all(char::is_whitespace) +} + +fn resolve_invocation( + tail: &str, + commands: &BTreeMap, +) -> Result, String> { + let tokens = tokenize(strip_secret_allowlist(tail)); + let positionals = command_words(&tokens); + if positionals.is_empty() { + return Ok(None); + } + let mut path = Vec::new(); + let mut index = 0; + while index < positionals.len() { + let mut candidate = path.clone(); + candidate.push(positionals[index].clone()); + let key = candidate.join(" "); + if commands.contains_key(&key) { + path = candidate; + index += 1; + continue; + } + break; + } + if path.is_empty() { + return Err(format!("unknown command {}", positionals[0])); + } + let key = path.join(" "); + let has_subcommands = commands.get(&key).copied().unwrap_or(false); + if index < positionals.len() && has_subcommands { + return Err(format!( + "`{key}` has no subcommand `{}`", + positionals[index] + )); + } + Ok(Some(key)) +} + +fn command_words(tokens: &[String]) -> Vec { + let mut index = skip_global_flags(tokens); + let mut words = Vec::new(); + while index < tokens.len() { + let token = &tokens[index]; + if token.starts_with('-') || token.starts_with('$') { + break; + } + words.push(token.clone()); + index += 1; + } + words +} + +fn skip_global_flags(tokens: &[String]) -> usize { + let mut index = 0; + while index < tokens.len() { + let token = tokens[index].as_str(); + if matches!( + token, + "--no-input" | "--non-interactive" | "--json" | "--help" | "-h" | "--version" | "-V" + ) { + index += 1; + continue; + } + if matches!( + token, + "-o" | "--output" + | "--log-format" + | "--ci-platform" + | "--deslicer-api-url" // pragma: allowlist secret + | "--observer-api-url" + | "--timeout" + | "--retries" + ) { + index += 2; + continue; + } + if token.starts_with("--output=") + || token.starts_with("--log-format=") + || token.starts_with("--deslicer-api-url=") // pragma: allowlist secret + || token.starts_with("--observer-api-url=") + { + index += 1; + continue; + } + break; + } + index +} + +fn strip_secret_allowlist(tail: &str) -> &str { + let markers = ["