Skip to main content

tsafe_cli/
cli.rs

1use clap::{Parser, Subcommand, ValueEnum};
2pub use clap_complete::Shell;
3
4const ROOT_LONG_ABOUT: &str = "Manage secrets in a local encrypted vault instead of scattering them across `.env` files, shell history, and ad-hoc runtime exports.\n\nThe core-only release family centers on local encrypted vault CRUD, `exec`/contracts, profiles, snapshots, audit, and `doctor`, plus the default-core Azure Key Vault pull, biometric/quick-unlock, and team workflows when they are compiled into this `tsafe` binary. Some named stack shapes also include the terminal UI and/or the `agent` workflow as explicit companion/runtime claims. Broader gated non-core lanes such as AWS, GCP, browser/nativehost, plugins, and other additive surfaces appear only when they are compiled into this binary and shipped by the chosen stack. Companion runtimes such as `tsafe-agent` are installed and released separately. Use `tsafe build-info` when you need the compiled truth for the running binary.";
5
6const ROOT_AFTER_HELP: &str = "Privacy and local evidence:\n  tsafe does not phone home. It does not send product analytics,\n  crash reports, update pings, secret names, or secret values to a\n  vendor service.\n\n  Audit logs are local per-profile JSONL receipts. CloudEvents and Splunk\n  formats are explicit export shapes for those local receipts; they are\n  not hidden telemetry.\n\n  Network access occurs only through explicit networked features you invoke\n  or configure, such as provider pull/push, HIBP checks, one-time sharing,\n  or an operator-selected audit export pipeline.\n\nCompiled truth:\n  tsafe build-info\n\nCompanion note:\n  `tsafe-agent` is installed and released separately from the `tsafe` CLI binary.\n\nSee also:\n  man tsafe\n  tsafe explain\n  tsafe <command> --help\n  docs/index.md in the repository";
7
8const DOCTOR_LONG_ABOUT: &str = "Diagnose vault health: file presence, snapshots, env vars, secret expiry, and operator-facing health hints.\n\nPrints a colour-coded report. Use `--json` for machine-readable monitoring output.";
9
10const DOCTOR_AFTER_HELP: &str =
11    "Examples:\n  tsafe doctor\n  tsafe doctor --json\n  tsafe --profile prod doctor";
12
13const AUDIT_AFTER_HELP: &str = "Examples:\n  tsafe audit\n  tsafe audit --limit 100\n  tsafe audit --explain\n  tsafe audit --explain --json\n  tsafe audit --cell-id doom-cell-001\n  tsafe audit-verify\n  tsafe audit-verify --json";
14
15const ROTATE_DUE_AFTER_HELP: &str =
16    "Examples:\n  tsafe rotate-due\n  tsafe rotate-due --json\n  tsafe rotate-due --fail   # for scripts: non-zero if overdue";
17
18const BUILD_INFO_AFTER_HELP: &str = "Examples:\n  tsafe build-info\n  tsafe build-info --json";
19
20#[derive(Parser)]
21#[command(
22    name = "tsafe",
23    about = "tsafe — local encrypted secret runtime for vaults, exec/contracts, and operator workflows",
24    long_about = ROOT_LONG_ABOUT,
25    version,
26    arg_required_else_help = true,
27    after_help = ROOT_AFTER_HELP
28)]
29pub struct Cli {
30    /// Named vault / profile. Defaults to the persisted default (or 'default'). Override with TSAFE_PROFILE env var.
31    #[arg(short, long, global = true, env = "TSAFE_PROFILE")]
32    pub profile: Option<String>,
33
34    #[command(subcommand)]
35    pub command: Commands,
36}
37
38#[derive(Subcommand)]
39pub enum Commands {
40    /// Initialise a new encrypted vault for the current profile.
41    ///
42    /// Creates the vault file for this profile under the platform data directory. Prompts for a master password twice.
43    ///
44    /// On an interactive terminal, after the vault is created you may be offered "quick unlock":
45    /// storing the password in the OS credential store (Touch ID / Face ID / Windows Hello / device PIN
46    /// where the OS supports it). You can accept, defer, or skip; run `tsafe biometric enable` anytime.
47    ///
48    /// If `tsafe config set-backup-vault main` (or `default`) is set, the new vault's master password is
49    /// also stored under `profile-passwords/<profile>` in that vault when possible.
50    ///
51    #[command(after_help = "Examples:\n  tsafe init\n  tsafe --profile prod init")]
52    Init,
53
54    /// View or change global settings (config.json): password backup target, default profile, etc.
55    ///
56    /// Use `config set-backup-vault main` so every new vault's master password is also stored under
57    /// `profile-passwords/<profile>` in the `main` vault (requires that vault to exist and be unlockable when you create more profiles).
58    #[command(
59        after_help = "Examples:\n  tsafe config show\n  tsafe config set-backup-vault main\n  tsafe config set-backup-vault default\n  tsafe config set-backup-vault off\n  tsafe config set-exec-mode hardened\n  tsafe config set-exec-redact-output on\n  tsafe config add-exec-extra-strip OPENAI_API_KEY"
60    )]
61    Config {
62        #[command(subcommand)]
63        action: ConfigAction,
64    },
65
66    /// Store or update a secret in the vault.
67    ///
68    /// If VALUE is omitted on a TTY, you are prompted with masked input (typically `*` per character).
69    /// Piped / non-interactive stdin reads a single line.
70    /// Keys may be namespaced with `.` or `-` (e.g. `github.com.token`, `db-prod.PASSWORD`).
71    ///
72    /// If the key already exists the command will prompt for confirmation (on a TTY)
73    /// or exit with an error (non-TTY). Pass --overwrite to skip the check.
74    ///
75    #[command(
76        after_help = "Examples:\n  tsafe set DB_PASSWORD supersecret\n  tsafe set github.com.token ghp_xxx --tag env=prod\n  tsafe set API_KEY --overwrite  # replace existing without prompt"
77    )]
78    Set {
79        /// Secret key (e.g. DB_PASSWORD, github.com.token).
80        key: String,
81        /// Secret value. Omit for a masked TTY prompt or a line from stdin when piped.
82        value: Option<String>,
83        /// Attach tags as KEY=VALUE pairs (repeatable).
84        #[arg(short, long = "tag", value_name = "KEY=VALUE")]
85        tags: Vec<String>,
86        /// Overwrite the key if it already exists — skips the confirmation prompt.
87        #[arg(long)]
88        overwrite: bool,
89    },
90
91    /// Retrieve a secret and print its plaintext value.
92    ///
93    /// Use --copy to copy to clipboard instead of printing; the clipboard is cleared after 30 s.
94    /// Use --version to retrieve a previous version (0=current, 1=previous, etc.).
95    ///
96    #[command(
97        after_help = "Examples:\n  tsafe get DB_PASSWORD\n  tsafe get API_KEY --copy\n  tsafe get DB_PASSWORD --version 1"
98    )]
99    Get {
100        /// Secret key.
101        key: String,
102        /// Copy value to clipboard and clear after 30 seconds (does not print).
103        #[arg(short, long)]
104        copy: bool,
105        /// Retrieve a previous version (0=current, 1=previous, etc.).
106        #[arg(long)]
107        version: Option<usize>,
108    },
109
110    /// Permanently remove a secret from the vault.
111    ///
112    /// The deletion is recorded in the audit log and a snapshot is taken before removal.
113    ///
114    #[command(after_help = "Examples:\n  tsafe delete OLD_TOKEN")]
115    Delete {
116        /// Secret key.
117        key: String,
118    },
119
120    /// List all secret key names stored in the vault.
121    ///
122    /// Use --tag to filter by attached metadata.
123    /// Use --ns to filter to a specific namespace (e.g. "cds-adf").
124    ///
125    #[command(
126        after_help = "Examples:\n  tsafe list\n  tsafe list --tag env=prod\n  tsafe list --ns cds-adf"
127    )]
128    List {
129        /// Filter to secrets with this tag (KEY=VALUE). Repeatable.
130        #[arg(short, long = "tag", value_name = "KEY=VALUE")]
131        tags: Vec<String>,
132        /// Filter to keys in this namespace (stored as `<ns>/<KEY>`).
133        #[arg(long)]
134        ns: Option<String>,
135    },
136
137    /// Print secrets to stdout in the chosen format.
138    ///
139    /// Formats: env (default), dotenv, powershell, json, github-actions, yaml, docker-env.
140    /// Use --ns to export only keys from a namespace; the prefix is stripped
141    /// so the output contains plain KEY=VALUE (e.g. APP_PW not cds-adf/APP_PW).
142    ///
143    #[command(
144        after_help = "Examples:\n  tsafe export\n  tsafe export --format powershell > secrets.ps1\n  tsafe export --format github-actions --tag env=ci\n  tsafe export --ns cds-adf --format dotenv > .env\n  tsafe export --format yaml > secrets.yaml\n  tsafe export --format docker-env > .env"
145    )]
146    Export {
147        /// Output format.
148        #[arg(short, long, default_value = "env")]
149        format: ExportFormat,
150        /// Limit to specific keys (all keys if omitted).
151        keys: Vec<String>,
152        /// Filter to secrets with this tag (KEY=VALUE). Repeatable.
153        #[arg(short, long = "tag", value_name = "KEY=VALUE")]
154        tags: Vec<String>,
155        /// Filter to keys in this namespace; prefix is stripped in output.
156        #[arg(long)]
157        ns: Option<String>,
158    },
159
160    /// Execute a command with secrets injected into its environment.
161    ///
162    /// Secrets are injected as env vars; the child inherits all other env vars.
163    /// Ctrl-C is forwarded to the child and tsafe exits with the child's exit code.
164    /// Use --ns to inject only secrets from a namespace (prefix stripped from var names).
165    ///
166    /// Use --contract to load a named authority contract from the nearest .tsafe.yml manifest.
167    /// A contract declares profile, namespace, allowed secrets, required secrets, allowed targets,
168    /// and trust posture as a reusable, auditable policy. Explicit flags still override contract values.
169    #[command(
170        name = "exec",
171        after_help = "Examples:\n  tsafe exec -- dotnet run\n  tsafe exec -- docker-compose up\n  tsafe exec --ns cds-adf -- python pipeline.py\n  tsafe exec --dry-run\n  tsafe exec --plan -- npm start\n  tsafe exec --require API_KEY,DB_URL -- npm test\n  tsafe exec --no-inherit -- node index.js\n  tsafe exec --only PATH,HOME -- python script.py\n  tsafe exec --minimal -- pytest\n  tsafe exec --mode hardened -- npm test\n  tsafe exec --keys OPENAI_API_KEY,DB_URL -- npm test\n  tsafe exec --env MY_API_KEY=VAULT_API_KEY -- npm test\n  tsafe exec --preset minimal -- npm test\n  tsafe exec --timeout 30 -- npm test\n  tsafe exec --redact-output -- npm test\n  tsafe exec --contract deploy -- terraform apply\n  tsafe exec --contract ci-tests --dry-run"
172    )]
173    Exec {
174        /// Load a named authority contract from the nearest .tsafe.yml (or .tsafe.json) manifest.
175        /// The contract sets the profile, namespace, allowed/required secrets, allowed targets, and
176        /// trust posture. Explicit flags (--ns, --keys, --mode, etc.) still override contract values.
177        #[arg(long, value_name = "NAME")]
178        contract: Option<String>,
179        /// Inject only secrets from this namespace; prefix is stripped from env var names.
180        #[arg(long)]
181        ns: Option<String>,
182        /// Inject only these vault keys (after `--ns` prefix stripping). Comma-separated or repeat flag.
183        /// Missing selected keys abort the run so narrower injection does not silently degrade.
184        #[arg(long, value_name = "KEY", value_delimiter = ',', action = clap::ArgAction::Append)]
185        keys: Vec<String>,
186        /// Trust preset for this run. `standard` keeps broad compatibility, `hardened` applies a stricter preset,
187        /// and `custom` uses your persisted exec trust settings. Explicit flags still override the preset.
188        #[arg(long)]
189        mode: Option<ExecModeSetting>,
190        /// Kill the child process after this many seconds and exit non-zero. Default: no timeout.
191        #[arg(long, value_name = "SECONDS")]
192        timeout: Option<u64>,
193        /// Preset for inherited parent environment. `minimal` keeps only PATH and a safe core set
194        /// (equivalent to --minimal). `full` inherits the full parent environment minus the strip list
195        /// (equivalent to the default). Explicit --no-inherit, --minimal, and --only override this.
196        #[arg(long, value_name = "PRESET")]
197        preset: Option<ExecPresetSetting>,
198        /// List env var names that would be injected (sorted, one per line) and exit 0; no command is run.
199        #[arg(long)]
200        dry_run: bool,
201        /// Show a human-readable plan: profile, namespace, injected names, --require checks,
202        /// parent env strips, and a copy-paste run line. Exit 0; no command is run.
203        #[arg(long)]
204        plan: bool,
205        /// Start from a clean environment: no parent env vars are inherited. Only vault secrets
206        /// (and any --only keys) are visible to the child. Mutually exclusive with --only and --minimal.
207        #[arg(long, conflicts_with_all = ["only", "minimal"])]
208        no_inherit: bool,
209        /// Inherit only a safe minimal set of parent env vars (PATH, HOME, USER, TMPDIR, LANG,
210        /// TERM, SSH_AUTH_SOCK, etc.) plus vault secrets. No tokens or credentials leak through.
211        /// Mutually exclusive with --no-inherit and --only.
212        #[arg(long, conflicts_with_all = ["no_inherit", "only"])]
213        minimal: bool,
214        /// Inherit only these parent env vars (comma-separated or repeat flag); all others are
215        /// stripped. Vault secrets are then added on top. Mutually exclusive with --no-inherit and --minimal.
216        #[arg(long, value_name = "KEY", value_delimiter = ',', action = clap::ArgAction::Append, conflicts_with_all = ["no_inherit", "minimal"])]
217        only: Vec<String>,
218        /// Require these vault keys (after --ns mapping) to be present. Comma-separated or repeat flag.
219        #[arg(long, value_name = "KEY", value_delimiter = ',', action = clap::ArgAction::Append)]
220        require: Vec<String>,
221        /// Map a vault key to a different env var name in the child process.
222        /// Format: ENV_VAR=VAULT_KEY  (e.g. --env MY_DB=PROD_SECRET injects the vault value of
223        /// PROD_SECRET under the name MY_DB). When --keys is also given, only vault keys that are
224        /// in the --keys allowlist may be referenced; other vault keys are rejected with an error.
225        /// Repeat the flag for multiple mappings.
226        #[arg(long = "env", value_name = "ENV_VAR=VAULT_KEY", action = clap::ArgAction::Append)]
227        env_mappings: Vec<String>,
228        /// Abort if any injected name is a known high-risk env var (e.g. NODE_OPTIONS, LD_PRELOAD).
229        /// Redundant: this is now the default. Kept for backwards compatibility.
230        #[arg(long, conflicts_with = "allow_dangerous_env")]
231        deny_dangerous_env: bool,
232        /// Allow injection of known high-risk env var names (e.g. LD_PRELOAD, NODE_OPTIONS).
233        /// By default, dangerous names abort exec. Use this flag to inject them with a warning instead.
234        #[arg(long, conflicts_with = "deny_dangerous_env")]
235        allow_dangerous_env: bool,
236        /// Replace exact vault secret values in the child's stdout/stderr with `[REDACTED]`.
237        /// Useful for agent/tool wrappers where you trust the command less than the vault.
238        #[arg(long, conflicts_with = "no_redact_output")]
239        redact_output: bool,
240        /// Force raw child stdout/stderr even if config enables exec output redaction by default.
241        #[arg(long, conflicts_with = "redact_output")]
242        no_redact_output: bool,
243        /// Command and its arguments (omit when using --dry-run or --plan).
244        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
245        cmd: Vec<String>,
246    },
247
248    /// Import secrets from a `.env` file or another supported export source.
249    ///
250    /// `.env` paths work in every build. Some builds may also accept additional
251    /// source names for password-manager or browser CSV exports.
252    ///
253    /// When `--from` is a named export source, `--file` is required.
254    /// Skips keys that already exist unless --overwrite is passed.
255    ///
256    /// Use --ns to prefix all imported keys with a namespace, e.g. "cds-adf".
257    /// Keys are stored as `<ns>/<KEY>` allowing multiple projects in one vault
258    /// without collision (e.g. cds-adf/APP_PW vs mail-automation/APP_PW).
259    ///
260    #[command(
261        after_help = "Examples:\n  tsafe import --from .env\n  tsafe import --from .env.production --overwrite\n  tsafe import --from ../cds-adf/.env --ns cds-adf\n  tsafe import --from .env --dry-run"
262    )]
263    /// If `--from` is a **relative** path that does not exist, the error includes extra hints and
264    /// searches **downward** from the current directory (bounded depth; skips `target/`, `node_modules/`, `.git/`, etc.)
265    /// for files with the **same name** (e.g. `.env`) so you can copy-paste a suggested `tsafe import --from '…'` line.
266    Import {
267        /// `.env` file path or another supported source name for this build.
268        #[arg(long, default_value = ".env")]
269        from: String,
270        /// Export file path (required when `--from` is a named export source).
271        #[arg(long)]
272        file: Option<String>,
273        /// Overwrite existing keys (skip by default).
274        #[arg(long)]
275        overwrite: bool,
276        /// Skip duplicate keys silently instead of erroring (applies to both
277        /// within-file duplicates and keys already in the vault).
278        #[arg(long)]
279        skip_duplicates: bool,
280        /// Namespace prefix to prepend to imported keys (e.g. "cds-adf").
281        /// Keys are stored as `<ns>/<KEY>`, preventing collisions across projects.
282        #[arg(long)]
283        ns: Option<String>,
284        /// Show what would be imported without writing any secrets to the vault.
285        /// Prints each key and whether it would be skipped (existing) or imported.
286        #[arg(long)]
287        dry_run: bool,
288    },
289
290    /// Map browser domains to vault profiles for the browser extension.
291    ///
292    /// The extension uses these mappings to choose which vault profile to
293    /// query when filling credentials on a given domain.
294    ///
295    #[command(
296        after_help = "Examples:\n  tsafe browser-profile add github.com\n  tsafe browser-profile add paypal.com --profile finance\n  tsafe browser-profile list\n  tsafe browser-profile remove paypal.com"
297    )]
298    #[cfg(feature = "browser")]
299    #[command(name = "browser-profile")]
300    BrowserProfile {
301        #[command(subcommand)]
302        action: BrowserProfileAction,
303    },
304
305    /// Register or unregister the native messaging host for the browser extension.
306    ///
307    /// Writes the per-user manifest/registration files needed by supported browsers on the
308    /// current OS. This command does not require browser-profile mappings or vault access.
309    ///
310    #[command(
311        after_help = "Examples:\n  tsafe browser-native-host detect\n  tsafe browser-native-host register --extension-id <chromium-id>\n  tsafe browser-native-host unregister"
312    )]
313    #[cfg(feature = "nativehost")]
314    #[command(name = "browser-native-host")]
315    BrowserNativeHost {
316        #[command(subcommand)]
317        action: BrowserNativeHostAction,
318    },
319
320    /// Re-encrypt all secrets with a new master password (vault re-key).
321    ///
322    /// Prompts for the current password, then the new password twice (unless non-interactive).
323    /// For automation / CI, set `TSAFE_PASSWORD` (current) and `TSAFE_NEW_MASTER_PASSWORD` (new);
324    /// confirmation is skipped when both are set (no OS keychain prompt in that case — run `biometric enable` after).
325    /// After interactive rotation, you are offered an OS keychain update so quick unlock matches the new password.
326    /// A snapshot is taken automatically before rotation. `tsafe doctor` suggests periodic rotation.
327    ///
328    #[command(after_help = "Examples:\n  tsafe rotate\n  tsafe --profile prod rotate")]
329    Rotate,
330
331    /// Re-encrypt the vault with a new master password and update the biometric credential.
332    ///
333    /// Prompts for the current password (or reads from TSAFE_PASSWORD), then the new password
334    /// twice (or reads from TSAFE_NEW_MASTER_PASSWORD).  The vault is written atomically via a
335    /// temp-file rename.  If biometric quick-unlock is active, the stored credential is re-stored
336    /// under the new password so subsequent unlocks continue to work.
337    ///
338    /// If the vault re-encryption succeeds but the biometric re-store fails, a warning is emitted
339    /// directing the user to `tsafe biometric re-enroll`.
340    ///
341    #[command(
342        name = "rotate-key",
343        after_help = "Examples:\n  tsafe rotate-key\n  tsafe --profile prod rotate-key"
344    )]
345    RotateKey {
346        /// Profile to re-key (defaults to the active profile).
347        #[arg(short, long)]
348        profile: Option<String>,
349    },
350
351    /// Manage profiles (named vaults).
352    ///
353    /// Each profile is an independent vault file under the platform data `vaults/` directory.
354    ///
355    #[command(
356        after_help = "Examples:\n  tsafe profile list\n  tsafe profile delete staging\n  tsafe profile delete staging --force"
357    )]
358    Profile {
359        #[command(subcommand)]
360        action: ProfileAction,
361    },
362
363    /// Display recent audit log entries for the current profile in human-readable form.
364    #[command(after_help = AUDIT_AFTER_HELP)]
365    Audit {
366        /// Number of entries to display.
367        #[arg(short, long, default_value_t = 20)]
368        limit: usize,
369        /// Check all secret values against Have I Been Pwned (k-anonymity, no full hash sent).
370        #[arg(long, conflicts_with = "explain")]
371        hibp: bool,
372        /// Show a session-style explanation (grouped operations, exec authority summaries).
373        #[arg(long)]
374        explain: bool,
375        /// With `--explain`, print JSON instead of human text.
376        #[arg(long, requires = "explain")]
377        json: bool,
378        /// Filter entries to those with this CellOS cell ID in their audit context.
379        #[arg(long, value_name = "CELL_ID")]
380        cell_id: Option<String>,
381    },
382
383    /// Cross-check authority contracts against a CellOS policy pack.
384    ///
385    /// Loads authority contracts from the nearest `.tsafe.yml` and compares each
386    /// contract's `allowed_secrets` against `allowedSecretRefs` in the CellOS
387    /// policy pack JSON.  Reports mismatches and exits non-zero if any are found.
388    ///
389    /// Use `--policy-file` as an alias for `--cellos-policy` (both accepted).
390    ///
391    #[command(
392        after_help = "Examples:\n  tsafe validate --cellos-policy doom-airgapped-policy.json\n  tsafe validate --policy-file policy.json\n  tsafe validate --policy-file policy.json --json"
393    )]
394    Validate {
395        /// Path to the CellOS policy pack JSON file.
396        #[arg(long, value_name = "PATH", conflicts_with = "policy_file")]
397        cellos_policy: Option<std::path::PathBuf>,
398        /// Alias for --cellos-policy. Path to the policy pack JSON file.
399        #[arg(long, value_name = "PATH", conflicts_with = "cellos_policy")]
400        policy_file: Option<std::path::PathBuf>,
401        /// Emit machine-readable JSON output (exit codes are preserved).
402        #[arg(long)]
403        json: bool,
404    },
405
406    /// Manage local vault snapshots.
407    ///
408    /// Snapshots are encrypted copies of the vault file, taken automatically before
409    /// every write operation. Use them to recover from accidental changes.
410    ///
411    #[command(after_help = "Examples:\n  tsafe snapshot list\n  tsafe snapshot restore")]
412    Snapshot {
413        #[command(subcommand)]
414        action: SnapshotAction,
415    },
416
417    /// Pull secrets from Azure Key Vault into the local vault.
418    ///
419    /// Requires TSAFE_AKV_URL and either a service principal
420    /// (AZURE_TENANT_ID + AZURE_CLIENT_ID + AZURE_CLIENT_SECRET) or
421    /// a managed identity (IMDS, automatic inside Azure VMs / ACI).
422    ///
423    #[command(
424        after_help = "Examples:\n  tsafe kv-pull\n  tsafe kv-pull --prefix MYAPP_ --overwrite"
425    )]
426    #[cfg(feature = "akv-pull")]
427    KvPull {
428        /// Only import secrets whose names start with this prefix (case-insensitive).
429        /// Omit to pull all secrets.
430        #[arg(long)]
431        prefix: Option<String>,
432
433        /// Overwrite existing local secrets (skip conflicts by default).
434        #[arg(long)]
435        overwrite: bool,
436        /// Failure handling mode for provider/network errors.
437        #[arg(long, value_enum, default_value = "fail-all")]
438        on_error: PullOnError,
439    },
440
441    /// Push local vault secrets to Azure Key Vault (upsert semantics).
442    ///
443    /// Requires TSAFE_AKV_URL and either a service principal
444    /// (AZURE_TENANT_ID + AZURE_CLIENT_ID + AZURE_CLIENT_SECRET) or
445    /// a managed identity (IMDS, automatic inside Azure VMs / ACI).
446    ///
447    /// Local keys are reverse-normalised to Azure Key Vault format:
448    /// MY_SECRET → my-secret. Two local keys that normalise to the same
449    /// provider name are detected as a collision and abort pre-flight.
450    ///
451    /// Remote-only keys are left untouched unless --delete-missing is passed.
452    /// A pre-flight diff is always shown before writing. No secret values
453    /// are printed — only key names and 12-char SHA-256 hash prefixes.
454    ///
455    #[command(
456        after_help = "Examples:\n  tsafe kv-push --dry-run\n  tsafe kv-push --yes\n  tsafe kv-push --prefix MYAPP_ --yes\n  tsafe kv-push --delete-missing --yes"
457    )]
458    #[cfg(feature = "akv-pull")]
459    KvPush {
460        /// Only push secrets whose local key names start with this prefix (case-insensitive).
461        #[arg(long)]
462        prefix: Option<String>,
463
464        /// Only push secrets in this namespace (stored as `<ns>/KEY`).
465        #[arg(long)]
466        ns: Option<String>,
467
468        /// Show the diff without writing anything (always exits 0).
469        #[arg(long)]
470        dry_run: bool,
471
472        /// Skip the confirmation prompt (required in non-TTY / CI contexts).
473        #[arg(long)]
474        yes: bool,
475
476        /// Also delete remote secrets that are absent locally within the filtered scope.
477        /// Off by default — opt-in to avoid accidental mass deletion.
478        /// AKV uses soft-delete (30-day recoverable window).
479        #[arg(long)]
480        delete_missing: bool,
481    },
482
483    /// Share a vault secret as a one-time HTTPS link via a configured OTS (one-time secret) service.
484    ///
485    /// Set `TSAFE_OTS_BASE_URL` to your service HTTPS origin (no default). The CLI POSTs JSON
486    /// `{"secret","ttl"}` to `{base}{TSAFE_OTS_CREATE_PATH}` (default path `/create`) and prints the returned `url`.
487    ///
488    /// The one-time URL is printed to stdout — never the secret value. Use any server that implements this contract.
489    ///
490    #[command(
491        after_help = "Examples:\n  tsafe share-once DB_PASSWORD\n  tsafe share-once API_KEY --ttl 10m"
492    )]
493    #[cfg(feature = "ots-sharing")]
494    #[command(name = "share-once")]
495    ShareOnce {
496        /// Secret key to share.
497        key: String,
498        /// Link expiry (sent to the service; many accept 10m, 1h, 24h).
499        #[arg(short, long, default_value = "1h")]
500        ttl: String,
501    },
502
503    /// Receive a secret from a one-time link (from `share-once` or any compatible OTS server).
504    ///
505    /// POSTs to the exact HTTPS URL. The response may be JSON (`secret`, `plaintext`, or `value`) or HTML with
506    /// `<div id="secret-content">...</div>`.
507    ///
508    /// Optionally store the retrieved value directly into the vault with --store.
509    ///
510    #[command(
511        after_help = "Examples:\n  tsafe receive-once 'https://ots.example.com/s/abc123'\n  tsafe receive-once '<URL>' --store DB_PASSWORD"
512    )]
513    #[cfg(feature = "ots-sharing")]
514    #[command(name = "receive-once", visible_alias = "snap-receive")]
515    ReceiveOnce {
516        /// The full one-time URL (fragment `#...` is ignored).
517        url: String,
518        /// Store the received secret in the vault under this key name instead of printing it.
519        #[arg(long)]
520        store: Option<String>,
521    },
522
523    /// Generate a cryptographically random secret and store it in the vault.
524    ///
525    /// Uses a CSPRNG. Default length 32, character set 'alnum'.
526    ///
527    #[command(
528        after_help = "Examples:\n  tsafe gen DB_PASSWORD\n  tsafe gen SESSION_KEY --length 64 --charset hex --print\n  tsafe gen TEMP_PASSWORD --exclude-ambiguous --print"
529    )]
530    Gen {
531        /// Key name to store the generated secret under.
532        key: String,
533        /// Length of the generated secret in characters (ignored if --words is set).
534        #[arg(short = 'l', long, default_value_t = 32)]
535        length: usize,
536        /// Character set: alnum (default), alpha, numeric, hex, symbol.
537        #[arg(short = 'c', long, default_value = "alnum")]
538        charset: String,
539        /// Generate a passphrase of N random words instead of a random string.
540        #[arg(short = 'w', long)]
541        words: Option<usize>,
542        /// Attach tags as KEY=VALUE pairs (repeatable).
543        #[arg(short = 't', long = "tag", value_name = "KEY=VALUE")]
544        tags: Vec<String>,
545        /// Print the generated value to stdout (otherwise the value is only in the vault).
546        #[arg(long)]
547        print: bool,
548        /// Remove visually ambiguous characters (0, O, l, 1, I) from the charset.
549        /// Useful when the secret will be read aloud or transcribed manually.
550        #[arg(long)]
551        exclude_ambiguous: bool,
552    },
553
554    /// Show key-level changes between the current vault and its most-recent snapshot.
555    ///
556    /// Highlights added, removed, and modified keys — values are never shown.
557    ///
558    #[command(after_help = "Examples:\n  tsafe diff\n  tsafe --profile staging diff")]
559    Diff,
560
561    /// Compare key names across two profiles without decrypting any values.
562    ///
563    /// Highlights keys present in one profile but missing from the other.
564    ///
565    #[command(
566        after_help = "Examples:\n  tsafe compare staging\n  tsafe --profile dev compare prod"
567    )]
568    Compare {
569        /// Second profile to compare against the active --profile.
570        profile_b: String,
571    },
572
573    /// Show version history for a secret.
574    ///
575    /// Lists all stored versions with timestamps. Version 0 is the current
576    /// value; higher numbers are older. Use `tsafe get KEY --version N` to
577    /// retrieve a specific version.
578    ///
579    #[command(after_help = "Examples:\n  tsafe history DB_PASSWORD")]
580    History {
581        /// Secret key.
582        key: String,
583    },
584
585    /// Move or rename a secret within the vault, or to a different profile.
586    ///
587    /// Within a profile this is an atomic rename: key name, namespace prefix,
588    /// tags and full version history are all preserved.
589    ///
590    #[command(
591        after_help = "Examples:\n  tsafe mv DB_HOST infra/DB_HOST        (add namespace)\n  tsafe mv infra/DB_HOST DB_HOST        (remove namespace)\n  tsafe mv DB_HOST --to-profile prod    (move to other profile, same key)\n  tsafe mv DB_HOST --to-profile prod NEW_NAME  (move + rename)"
592    )]
593    Mv {
594        /// Source secret key.
595        source: String,
596
597        /// Destination key name.  Omit when using --to-profile to keep the same key name.
598        dest: Option<String>,
599
600        /// Move the secret to this profile (cross-profile move).
601        #[arg(long, value_name = "PROFILE")]
602        to_profile: Option<String>,
603
604        /// Overwrite the destination key if it already exists.
605        #[arg(long, short = 'f')]
606        force: bool,
607    },
608
609    /// Install a secret-scanning git pre-commit hook in the current repo.
610    ///
611    /// Scans staged files for hardcoded secrets on every `git commit`.
612    ///
613    #[command(
614        after_help = "Examples:\n  tsafe hook-install\n  tsafe hook-install --dir /path/to/repo"
615    )]
616    #[cfg(feature = "git-helpers")]
617    HookInstall {
618        /// Repo root directory; defaults to walking up from the current directory.
619        #[arg(long)]
620        dir: Option<String>,
621    },
622
623    /// Export local audit log entries to stdout or a file as JSONL, Splunk-compatible JSON, or CloudEvents JSONL.
624    #[command(
625        after_help = "CloudEvents and Splunk formats are export shapes for local audit receipts; they do not send data unless an operator wires stdout or the output file into a shipper.\n\nExamples:\n  tsafe audit-export --format json --output audit.jsonl\n  tsafe audit-export --format cloud-events --output audit.cloudevents.jsonl\n  tsafe audit-export --format splunk"
626    )]
627    AuditExport {
628        /// Output format.
629        #[arg(short, long, value_enum, default_value = "json")]
630        format: AuditExportFormat,
631        /// Write to a file instead of stdout.
632        #[arg(short, long)]
633        output: Option<String>,
634    },
635
636    /// Report HMAC chain coverage for the audit log of the current profile.
637    ///
638    /// Reads all entries from the audit log file and counts how many carry a
639    /// `prev_entry_hmac` field (written by a C8-capable tsafe build) versus
640    /// how many are unchained (written before C8 or at a session boundary).
641    ///
642    /// IMPORTANT — ephemeral-key limitation: the HMAC chain key is generated
643    /// fresh on every tsafe session and is never persisted.  This command
644    /// cannot perform cryptographic verification of entries from a closed
645    /// session; it can only report chain coverage (presence of the field).
646    /// To detect within-session tampering, use AuditLog::verify_chain() from
647    /// a live session handle.
648    ///
649    /// Exit codes: 0 = log is structurally valid (or empty), 2 = at least one
650    /// entry could not be parsed as JSON.
651    #[command(
652        after_help = "Examples:\n  tsafe audit-verify\n  tsafe audit-verify --json\n  tsafe --profile prod audit-verify"
653    )]
654    AuditVerify {
655        /// Emit machine-readable JSON output.
656        #[arg(long)]
657        json: bool,
658    },
659
660    /// Set or remove a rotation policy on a secret.
661    ///
662    /// Policies are stored as tags and checked by `tsafe doctor` and `tsafe rotate-due`.
663    ///
664    #[command(
665        after_help = "Examples:\n  tsafe policy set DB_PASSWORD --rotate-every 90d\n  tsafe policy remove DB_PASSWORD"
666    )]
667    Policy {
668        #[command(subcommand)]
669        action: PolicyAction,
670    },
671
672    /// List secrets that are overdue for rotation (per `rotate_policy` tags).
673    ///
674    /// Checks the `rotate_policy` tag against the secret's `updated_at` timestamp.
675    /// Use `--json` for automation; `--fail` exits with status 1 when anything is overdue (CI/cron).
676    ///
677    /// Set policies with: `tsafe policy set KEY --rotate-every 90d`
678    #[command(after_help = ROTATE_DUE_AFTER_HELP)]
679    RotateDue {
680        /// Print JSON to stdout (`overdue_count` + `items` with key, days_overdue, policy).
681        #[arg(long)]
682        json: bool,
683        /// Exit with status 1 when one or more secrets are overdue.
684        #[arg(long)]
685        fail: bool,
686    },
687
688    /// Pull secrets from a HashiCorp Vault KV v2 store.
689    ///
690    /// Requires TSAFE_HCP_URL or --addr and VAULT_TOKEN (or --token).
691    ///
692    #[command(
693        after_help = "Examples:\n  tsafe vault-pull --addr http://vault:8200 --prefix myapp/\n  tsafe vault-pull  # uses TSAFE_HCP_URL + VAULT_TOKEN"
694    )]
695    #[cfg(feature = "cloud-pull-vault")]
696    VaultPull {
697        /// HashiCorp Vault address. Defaults to TSAFE_HCP_URL or http://127.0.0.1:8200.
698        #[arg(long)]
699        addr: Option<String>,
700        /// Vault token. Defaults to VAULT_TOKEN env var.
701        /// Deprecated: passing the token as a CLI argument exposes it in the process
702        /// table. Store the token in tsafe and use `tsafe exec -- tsafe vault-pull`
703        /// so the token is injected securely without appearing in the process table.
704        #[arg(long)]
705        token: Option<String>,
706        /// KV v2 mount path. Defaults to "secret".
707        #[arg(long)]
708        mount: Option<String>,
709        /// Only import secrets under this path prefix.
710        #[arg(long)]
711        prefix: Option<String>,
712        /// Overwrite existing local secrets (skip conflicts by default).
713        #[arg(long)]
714        overwrite: bool,
715    },
716
717    /// Pull fields from a 1Password item via the `op` CLI.
718    ///
719    /// Requires the 1Password CLI (`op`) installed and authenticated.
720    ///
721    #[command(
722        after_help = "Examples:\n  tsafe op-pull 'Database Credentials'\n  tsafe op-pull abc123xyz --op-vault Personal"
723    )]
724    #[cfg(feature = "cloud-pull-1password")]
725    OpPull {
726        /// Item title or ID.
727        item: String,
728        /// 1Password vault name (uses the default vault if omitted).
729        #[arg(long = "op-vault")]
730        op_vault: Option<String>,
731        /// Overwrite existing local secrets (skip conflicts by default).
732        #[arg(long)]
733        overwrite: bool,
734    },
735
736    /// Import Login items from Bitwarden into the local vault via the `bw` CLI.
737    ///
738    /// Bitwarden REST API ciphers are always E2E encrypted client-side. This command
739    /// shells to the `bw` CLI (which handles local decryption) rather than calling
740    /// the REST API directly — the same pattern as `tsafe op-pull` for 1Password.
741    ///
742    /// Requires TSAFE_BW_CLIENT_ID, TSAFE_BW_CLIENT_SECRET, and TSAFE_BW_PASSWORD
743    /// (master password for `bw unlock`). The `bw` CLI must be installed and on PATH.
744    ///
745    /// Item names are normalised: spaces and hyphens become underscores, uppercase.
746    /// Login.Username → ITEM_NAME_USERNAME, Login.Password → ITEM_NAME_PASSWORD.
747    /// Custom text/hidden fields → ITEM_NAME_<FIELD_NAME>. Boolean fields are skipped.
748    ///
749    #[command(
750        name = "bw-pull",
751        after_help = "Examples:\n  tsafe bw-pull\n  tsafe bw-pull --bw-folder my-folder-id --overwrite\n  tsafe bw-pull --bw-client-id org.abc --bw-password-env MY_BW_PW"
752    )]
753    #[cfg(feature = "cloud-pull-bitwarden")]
754    BwPull {
755        /// Bitwarden API client ID. Reads TSAFE_BW_CLIENT_ID if not set.
756        #[arg(long = "bw-client-id")]
757        bw_client_id: Option<String>,
758        /// Bitwarden API client secret. Reads TSAFE_BW_CLIENT_SECRET if not set.
759        #[arg(long = "bw-client-secret")]
760        bw_client_secret: Option<String>,
761        /// Bitwarden API base URL (for self-hosted / Vaultwarden).
762        /// Default: https://api.bitwarden.com
763        #[arg(long = "bw-api-url")]
764        bw_api_url: Option<String>,
765        /// Bitwarden identity base URL (for self-hosted / Vaultwarden).
766        /// Default: https://identity.bitwarden.com
767        #[arg(long = "bw-identity-url")]
768        bw_identity_url: Option<String>,
769        /// Bitwarden folder ID to filter items. Imports all items when omitted.
770        #[arg(long = "bw-folder")]
771        bw_folder: Option<String>,
772        /// Name of the env var holding the Bitwarden master password for `bw unlock`.
773        /// Default: TSAFE_BW_PASSWORD
774        #[arg(long = "bw-password-env")]
775        bw_password_env: Option<String>,
776        /// Overwrite existing local secrets (skip conflicts by default).
777        #[arg(long)]
778        overwrite: bool,
779        /// Failure handling mode for provider/network errors.
780        #[arg(long, value_enum, default_value = "fail-all")]
781        on_error: PullOnError,
782        /// Show which items would be imported without writing any secrets.
783        #[arg(long)]
784        dry_run: bool,
785    },
786
787    /// Import secrets from a KeePass `.kdbx` file into the local vault.
788    ///
789    /// Opens a local KeePass database using the master password (from the env var
790    /// named by --kp-password-env, default TSAFE_KP_PASSWORD) and/or a key file.
791    ///
792    /// Entry titles are used as key prefixes. Standard fields (UserName, Password, URL)
793    /// map to TITLE_USERNAME, TITLE_PASSWORD, TITLE_URL.  Custom fields map to
794    /// TITLE_<FIELD_NAME_NORMALISED>.  Notes are skipped.
795    ///
796    #[command(
797        after_help = "Examples:\n  tsafe kp-pull --kp-path /home/user/vault.kdbx\n  tsafe kp-pull --kp-path ~/db.kdbx --kp-password-env MY_KP_PW --kp-group Infra\n  tsafe kp-pull --kp-path db.kdbx --kp-keyfile ~/my.keyx"
798    )]
799    #[cfg(feature = "cloud-pull-keepass")]
800    KpPull {
801        /// Absolute path to the `.kdbx` database file.
802        #[arg(long = "kp-path")]
803        kp_path: String,
804        /// Name of the env var that holds the master password.
805        /// Defaults to TSAFE_KP_PASSWORD.
806        #[arg(long = "kp-password-env", default_value = "TSAFE_KP_PASSWORD")]
807        kp_password_env: String,
808        /// Path to a KeePass key file (optional).
809        #[arg(long = "kp-keyfile")]
810        kp_keyfile: Option<String>,
811        /// Only import entries from this group name (case-insensitive).
812        #[arg(long = "kp-group")]
813        kp_group: Option<String>,
814        /// When set, also traverse descendant groups under the matched group.
815        #[arg(long = "kp-recursive")]
816        kp_recursive: bool,
817        /// Overwrite existing local secrets (skip conflicts by default).
818        #[arg(long)]
819        overwrite: bool,
820        /// Failure handling mode for provider/network errors.
821        #[arg(long, value_enum, default_value = "fail-all")]
822        on_error: PullOnError,
823    },
824
825    /// Import secrets from AWS Secrets Manager into the local vault.
826    ///
827    /// Authenticates via (in order): static env vars (AWS_ACCESS_KEY_ID +
828    /// AWS_SECRET_ACCESS_KEY), ECS task role, or IMDSv2 (EC2 instance profile).
829    ///
830    /// Region is read from AWS_DEFAULT_REGION / AWS_REGION or --region.
831    ///
832    /// Secret names are normalised: slashes and hyphens become underscores and
833    /// the result is uppercased (e.g. `myapp/db-password` → `MYAPP_DB_PASSWORD`).
834    ///
835    #[command(
836        after_help = "Examples:\n  tsafe aws-pull --region us-east-1\n  tsafe aws-pull --prefix myapp/ --overwrite"
837    )]
838    #[cfg(feature = "cloud-pull-aws")]
839    AwsPull {
840        /// AWS region (overrides AWS_DEFAULT_REGION / AWS_REGION).
841        #[arg(long)]
842        region: Option<String>,
843        /// Only import secrets whose names start with this prefix.
844        #[arg(long)]
845        prefix: Option<String>,
846        /// Overwrite existing local secrets (skip conflicts by default).
847        #[arg(long)]
848        overwrite: bool,
849        /// Failure handling mode for provider/network errors.
850        #[arg(long, value_enum, default_value = "fail-all")]
851        on_error: PullOnError,
852    },
853
854    /// Import secrets from GCP Secret Manager into the local vault.
855    ///
856    /// Authenticates via (in order): GOOGLE_OAUTH_TOKEN env var, GCE/Cloud Run/GKE
857    /// metadata server, or ADC file (gcloud auth application-default login).
858    ///
859    /// Project is read from GOOGLE_CLOUD_PROJECT / GCLOUD_PROJECT or --project.
860    ///
861    /// Secret names are normalised: hyphens and dots become underscores and
862    /// the result is uppercased (e.g. `db-password` → `DB_PASSWORD`).
863    ///
864    #[command(
865        after_help = "Examples:\n  tsafe gcp-pull --project my-gcp-project\n  tsafe gcp-pull --prefix myapp- --overwrite"
866    )]
867    #[cfg(feature = "cloud-pull-gcp")]
868    GcpPull {
869        /// GCP project ID (overrides GOOGLE_CLOUD_PROJECT / GCLOUD_PROJECT).
870        #[arg(long)]
871        project: Option<String>,
872        /// Only import secrets whose names start with this prefix.
873        #[arg(long)]
874        prefix: Option<String>,
875        /// Overwrite existing local secrets (skip conflicts by default).
876        #[arg(long)]
877        overwrite: bool,
878        /// Failure handling mode for provider/network errors.
879        #[arg(long, value_enum, default_value = "fail-all")]
880        on_error: PullOnError,
881    },
882
883    /// Push local vault secrets to GCP Secret Manager (upsert semantics).
884    ///
885    /// Authenticates via (in order): GOOGLE_OAUTH_TOKEN env var, GCE/Cloud Run/GKE
886    /// metadata server, or ADC file (gcloud auth application-default login).
887    ///
888    /// Project is read from GOOGLE_CLOUD_PROJECT / GCLOUD_PROJECT or --project.
889    ///
890    /// GCP Secret Manager uses a two-call pattern for new secrets: create the
891    /// secret resource, then add a version. Existing secrets only need a new version.
892    ///
893    /// Local keys are reverse-normalised to GCP format:
894    /// MY_SECRET → my-secret. Two local keys that normalise to the same
895    /// provider name are detected as a collision and abort pre-flight.
896    ///
897    /// Remote-only keys are left untouched unless --delete-missing is passed.
898    /// A pre-flight diff is always shown before writing. No secret values
899    /// are printed — only key names and 12-char SHA-256 hash prefixes.
900    ///
901    #[command(
902        after_help = "Examples:\n  tsafe gcp-push --project my-project --dry-run\n  tsafe gcp-push --project my-project --yes\n  tsafe gcp-push --prefix MYAPP_ --yes\n  tsafe gcp-push --delete-missing --yes"
903    )]
904    #[cfg(feature = "cloud-pull-gcp")]
905    GcpPush {
906        /// GCP project ID (overrides GOOGLE_CLOUD_PROJECT / GCLOUD_PROJECT).
907        #[arg(long)]
908        project: Option<String>,
909
910        /// Only push secrets whose local key names start with this prefix (case-insensitive).
911        #[arg(long)]
912        prefix: Option<String>,
913
914        /// Only push secrets in this namespace (stored as `<ns>/KEY`).
915        #[arg(long)]
916        ns: Option<String>,
917
918        /// Show the diff without writing anything (always exits 0).
919        #[arg(long)]
920        dry_run: bool,
921
922        /// Skip the confirmation prompt (required in non-TTY / CI contexts).
923        #[arg(long)]
924        yes: bool,
925
926        /// Also delete remote secrets absent locally within the filtered scope.
927        /// Off by default — opt-in to avoid accidental mass deletion.
928        /// Note: GCP Secret Manager deletion requires the Secret Manager Admin API.
929        #[arg(long)]
930        delete_missing: bool,
931    },
932
933    /// Import parameters from AWS SSM Parameter Store into the local vault.
934    ///
935    /// Authenticates via (in order): static env vars (AWS_ACCESS_KEY_ID +
936    /// AWS_SECRET_ACCESS_KEY), ECS task role, or IMDSv2 (EC2 instance profile).
937    ///
938    /// Region is read from AWS_DEFAULT_REGION / AWS_REGION or --region.
939    /// Parameters are fetched recursively under the given path.
940    /// SecureString parameters are decrypted automatically (WithDecryption=true).
941    ///
942    /// Parameter names are normalised: leading `/` stripped, remaining `/` and `-`
943    /// become `_`, uppercased (e.g. `/myapp/db-password` → `MYAPP_DB_PASSWORD`).
944    ///
945    #[command(
946        after_help = "Examples:\n  tsafe ssm-pull --region us-east-1 --path /myapp/prod/\n  tsafe ssm-pull --path /shared/ --overwrite"
947    )]
948    #[cfg(feature = "cloud-pull-aws")]
949    SsmPull {
950        /// AWS region (overrides AWS_DEFAULT_REGION / AWS_REGION).
951        #[arg(long)]
952        region: Option<String>,
953        /// Parameter path prefix (e.g. `/myapp/prod/`). Defaults to `/` (all parameters).
954        #[arg(long)]
955        path: Option<String>,
956        /// Overwrite existing local secrets (skip conflicts by default).
957        #[arg(long)]
958        overwrite: bool,
959        /// Failure handling mode for provider/network errors.
960        #[arg(long, value_enum, default_value = "fail-all")]
961        on_error: PullOnError,
962    },
963
964    /// Push local vault secrets to AWS Secrets Manager (upsert semantics).
965    ///
966    /// Authenticates via (in order): static env vars (AWS_ACCESS_KEY_ID +
967    /// AWS_SECRET_ACCESS_KEY), ECS task role, or IMDSv2 (EC2 instance profile).
968    ///
969    /// Local keys are reverse-normalised to AWS Secrets Manager format:
970    /// MY_SECRET → my-secret. Two local keys that normalise to the same
971    /// provider name are detected as a collision and abort pre-flight.
972    ///
973    /// Remote-only secrets are left untouched unless --delete-missing is passed.
974    /// A pre-flight diff is always shown before writing. No secret values
975    /// are printed — only key names and 12-char SHA-256 hash prefixes.
976    ///
977    #[command(
978        after_help = "Examples:\n  tsafe aws-push --dry-run\n  tsafe aws-push --yes\n  tsafe aws-push --prefix myapp/ --yes\n  tsafe aws-push --delete-missing --yes"
979    )]
980    #[cfg(feature = "cloud-pull-aws")]
981    AwsPush {
982        /// AWS region (overrides AWS_DEFAULT_REGION / AWS_REGION).
983        #[arg(long)]
984        region: Option<String>,
985        /// Only push secrets whose local key names start with this prefix (case-insensitive).
986        #[arg(long)]
987        prefix: Option<String>,
988        /// Show the diff without writing anything (always exits 0).
989        #[arg(long)]
990        dry_run: bool,
991        /// Skip the confirmation prompt (required in non-TTY / CI contexts).
992        #[arg(long)]
993        yes: bool,
994        /// Also delete remote secrets absent locally within the filtered scope.
995        /// Off by default — opt-in to avoid accidental mass deletion.
996        #[arg(long)]
997        delete_missing: bool,
998    },
999
1000    /// Push local vault secrets to AWS SSM Parameter Store (upsert semantics).
1001    ///
1002    /// Authenticates via (in order): static env vars (AWS_ACCESS_KEY_ID +
1003    /// AWS_SECRET_ACCESS_KEY), ECS task role, or IMDSv2 (EC2 instance profile).
1004    ///
1005    /// Local keys are reverse-normalised to SSM parameter names:
1006    /// given `--path /myapp/`, MYAPP_DB_PASSWORD → /myapp/db-password.
1007    ///
1008    /// Remote-only parameters are left untouched unless --delete-missing is passed.
1009    /// A pre-flight diff is always shown before writing. No secret values
1010    /// are printed — only key names and 12-char SHA-256 hash prefixes.
1011    ///
1012    #[command(
1013        after_help = "Examples:\n  tsafe ssm-push --path /myapp/ --dry-run\n  tsafe ssm-push --path /myapp/ --yes\n  tsafe ssm-push --path /myapp/ --delete-missing --yes"
1014    )]
1015    #[cfg(feature = "cloud-pull-aws")]
1016    SsmPush {
1017        /// AWS region (overrides AWS_DEFAULT_REGION / AWS_REGION).
1018        #[arg(long)]
1019        region: Option<String>,
1020        /// SSM path prefix that scopes the push (e.g. `/myapp/`).
1021        #[arg(long)]
1022        path: Option<String>,
1023        /// Show the diff without writing anything (always exits 0).
1024        #[arg(long)]
1025        dry_run: bool,
1026        /// Skip the confirmation prompt (required in non-TTY / CI contexts).
1027        #[arg(long)]
1028        yes: bool,
1029        /// Also delete remote parameters absent locally within the path scope.
1030        /// Off by default — opt-in to avoid accidental mass deletion.
1031        #[arg(long)]
1032        delete_missing: bool,
1033    },
1034
1035    /// Print a shell completion script and exit.
1036    ///
1037    #[command(
1038        after_help = "Examples:\n  tsafe completions powershell | Out-String | Invoke-Expression"
1039    )]
1040    Completions {
1041        /// Shell to generate completions for.
1042        shell: Shell,
1043    },
1044
1045    /// Output completion candidates for use by shell completion scripts (internal).
1046    ///
1047    /// Called by the patched completion scripts generated by `tsafe completions`.
1048    /// Not intended for direct use.
1049    #[command(name = "_completions-data", hide = true)]
1050    CompletionsData {
1051        /// Type of completion data to emit: `profiles` or `contracts`.
1052        data_type: String,
1053    },
1054
1055    /// Diagnose vault health: file presence, snapshots, env vars, secret expiry, and operator-facing health hints.
1056    #[command(long_about = DOCTOR_LONG_ABOUT, after_help = DOCTOR_AFTER_HELP)]
1057    Doctor {
1058        /// Emit machine-readable JSON and use health exit codes (0=healthy, 1=warning, 2=critical).
1059        #[arg(long)]
1060        json: bool,
1061    },
1062
1063    /// Explain a concept in the terminal (`exec`, namespaces, compiled agent/browser pull lanes, …).
1064    ///
1065    /// Omit the topic to list available explanations.
1066    ///
1067    #[command(
1068        after_help = "Examples:\n  tsafe explain\n  tsafe explain exec\n  tsafe explain exec-security"
1069    )]
1070    Explain {
1071        /// Topic to print (omit to list all topics).
1072        #[arg(value_name = "TOPIC")]
1073        topic: Option<crate::explain::ExplainTopic>,
1074    },
1075
1076    /// Remove a stale vault lock file (use after a crash leaves the vault locked).
1077    ///
1078    /// Deletes `<profile>.vault.lock` if it exists. Safe to run — the lock is
1079    /// advisory only. Use when `tsafe` reports "vault is locked by another process"
1080    /// but no other process is actually running.
1081    ///
1082    #[command(after_help = "Examples:\n  tsafe unlock\n  tsafe --profile prod unlock")]
1083    Unlock,
1084
1085    /// Launch the full-screen interactive terminal UI.
1086    ///
1087    /// Supports add/edit/delete/reveal/rotate/snapshot restore and audit log viewing.
1088    /// Press ? inside the TUI for a contextual keyboard reference.
1089    ///
1090    #[command(after_help = "Examples:\n  tsafe ui\n  tsafe --profile prod ui")]
1091    #[cfg(feature = "tui")]
1092    Ui,
1093
1094    /// Render a secret value as a QR code in the terminal.
1095    ///
1096    /// Opens the vault, retrieves KEY, prints the QR code to stdout, then waits
1097    /// for Enter before clearing — so the value is never left on-screen.
1098    ///
1099    #[command(after_help = "Examples:\n  tsafe qr WIFI_PASSWORD\n  tsafe qr API_KEY")]
1100    Qr {
1101        /// Secret key whose value to render as a QR code.
1102        key: String,
1103    },
1104
1105    /// Store a TOTP secret and retrieve live codes.
1106    ///
1107    /// add: store a TOTP seed for the given key
1108    /// get: compute and print the current 6-digit code
1109    ///
1110    #[command(
1111        after_help = "Examples:\n  tsafe totp add GITHUB_2FA JBSWY3DPEHPK3PXP\n  tsafe totp get GITHUB_2FA"
1112    )]
1113    Totp {
1114        #[command(subcommand)]
1115        action: TotpAction,
1116    },
1117
1118    /// Pin a secret to the top of lists.
1119    ///
1120    #[command(
1121        after_help = "Examples:\n  tsafe pin DB_PASSWORD\n  tsafe --profile prod pin API_KEY"
1122    )]
1123    Pin { key: String },
1124
1125    /// Remove pin from a secret.
1126    ///
1127    #[command(after_help = "Examples:\n  tsafe unpin DB_PASSWORD")]
1128    Unpin { key: String },
1129
1130    /// Create an alias: ALIAS_NAME resolves to an existing KEY.
1131    ///
1132    /// tsafe get ALIAS_NAME returns the value of KEY.
1133    /// Use tsafe alias --list to view all aliases.
1134    ///
1135    #[command(
1136        after_help = "Examples:\n  tsafe alias DB_PASS DATABASE_PASSWORD\n  tsafe alias --list"
1137    )]
1138    Alias {
1139        /// Key this alias should resolve to (omit with --list to view all aliases).
1140        target_key: Option<String>,
1141        /// Name of the alias to create.
1142        alias_name: Option<String>,
1143        /// List all aliases in the vault.
1144        #[arg(long)]
1145        list: bool,
1146    },
1147
1148    /// Replace `{{KEY}}` placeholders in a file with vault secret values.
1149    ///
1150    /// Reads the input file, replaces each `{{KEY}}` with the corresponding
1151    /// vault secret, and writes to stdout (or `--output PATH`).
1152    ///
1153    #[command(
1154        after_help = "Examples:\n  tsafe template config.yml.tmpl > config.yml\n  tsafe template app.conf.tmpl --output app.conf"
1155    )]
1156    Template {
1157        /// Input template file containing {{KEY}} placeholders.
1158        file: String,
1159        /// Write output to a file instead of stdout.
1160        #[arg(short, long)]
1161        output: Option<String>,
1162        /// Ignore missing keys instead of failing.
1163        #[arg(long)]
1164        ignore_missing: bool,
1165    },
1166
1167    /// Read stdin and replace any vault secret values with `[REDACTED]`.
1168    ///
1169    /// Useful for piping logs through to scrub sensitive values.
1170    ///
1171    #[command(
1172        after_help = "Examples:\n  cargo test 2>&1 | tsafe redact\n  tsafe exec -- myapp | tsafe redact"
1173    )]
1174    Redact,
1175
1176    /// Manage the repo-local tsafe tooling inventory.
1177    ///
1178    /// The inventory lives under `.tsafe/tooling/` and records secret slots,
1179    /// consumers, and rotation expectations. It never stores secret values.
1180    #[command(
1181        after_help = "Examples:\n  tsafe tooling init --namespace databricks/athn_dev/\n  tsafe tooling check --json\n  tsafe tooling suggest --namespace databricks/athn_dev/ --section ci-cd-spn --key ci_secret --purpose \"SPN secret\" --consumer \"ADO service connection\" --rotation \"365d KV policy\" --apply"
1182    )]
1183    Tooling {
1184        #[command(subcommand)]
1185        action: ToolingAction,
1186    },
1187
1188    /// Show the active build profile label and compile-time capabilities.
1189    ///
1190    /// This reports the compiled truth for the running `tsafe` binary only.
1191    /// Companion runtimes such as `tsafe-agent` have separate install and release truth.
1192    #[command(name = "build-info", after_help = BUILD_INFO_AFTER_HELP)]
1193    BuildInfo {
1194        /// Emit machine-readable JSON output.
1195        #[arg(long)]
1196        json: bool,
1197    },
1198
1199    #[cfg(feature = "plugins")]
1200    /// Run a tool with its required vault secrets injected automatically.
1201    ///
1202    /// Each plugin knows which vault keys map to which environment variables for the
1203    /// named tool.  Run `tsafe plugin` (no args) to list available plugins.
1204    ///
1205    /// Missing optional keys are silently skipped; missing required keys abort with an error.
1206    ///
1207    #[command(
1208        after_help = "Examples:\n  tsafe plugin gh repo list\n  tsafe plugin aws s3 ls --bucket my-bucket\n  tsafe plugin az group list --subscription my-sub\n  tsafe plugin          (list all available plugins)"
1209    )]
1210    Plugin {
1211        /// Tool name (e.g. gh, aws, az, docker, npm, pypi, terraform). Omit to list.
1212        tool: Option<String>,
1213        /// Arguments to pass to the tool.
1214        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
1215        args: Vec<String>,
1216    },
1217
1218    /// Act as a git credential helper (install/get/store/erase protocol).
1219    ///
1220    /// Run `tsafe credential-helper install` once to configure git to use tsafe
1221    /// as the credential store. Git will then call `tsafe credential-helper get`,
1222    /// `store`, or `erase` automatically.
1223    ///
1224    /// In `get` mode, reads protocol/host from stdin and returns username/password
1225    /// from the vault. Keys are matched by `<HOST>_USERNAME` / `<HOST>_PASSWORD`
1226    /// pattern, or by tags `host=<HOST>`.
1227    #[cfg(feature = "git-helpers")]
1228    #[command(
1229        name = "credential-helper",
1230        after_help = "Examples:\n  tsafe credential-helper install\n  tsafe credential-helper install --global\n  git credential fill  # (git calls tsafe automatically after install)"
1231    )]
1232    CredentialHelper {
1233        /// Git credential helper action.
1234        #[arg(value_enum, default_value = "install")]
1235        action: CredentialHelperOperation,
1236        /// For `install`: configure at the --global level (user-wide).
1237        /// By default, configures the local repository git config.
1238        #[arg(long)]
1239        global: bool,
1240    },
1241
1242    /// Collaboration service commands (team membership, DEK delivery, recovery).
1243    ///
1244    /// Scaffolding only in Tranche 2 — no network calls are made.
1245    /// Enable with `--features collab`.
1246    ///
1247    #[command(
1248        after_help = "Examples:\n  tsafe collab join <team-id>\n  tsafe collab status <team-id>"
1249    )]
1250    #[cfg(feature = "collab")]
1251    Collab {
1252        #[command(subcommand)]
1253        action: CollabAction,
1254    },
1255
1256    /// Add an SSH key from the vault to the running ssh-agent.
1257    ///
1258    /// The key is passed via stdin to `ssh-add -` so it never touches disk.
1259    ///
1260    #[command(after_help = "Examples:\n  tsafe ssh-add SSH_KEY\n  tsafe ssh-add id_ed25519")]
1261    #[cfg(feature = "ssh")]
1262    SshAdd {
1263        /// Vault key name containing the SSH private key.
1264        key: String,
1265    },
1266
1267    /// Import an SSH private key file into the vault.
1268    ///
1269    #[command(
1270        after_help = "Examples:\n  tsafe ssh-import ~/.ssh/id_ed25519\n  tsafe ssh-import ~/.ssh/id_rsa --name SSH_RSA_KEY"
1271    )]
1272    #[cfg(feature = "ssh")]
1273    SshImport {
1274        /// Path to the SSH private key file.
1275        path: String,
1276        /// Vault key name to store under (defaults to filename).
1277        #[arg(long)]
1278        name: Option<String>,
1279        /// Attach tags as KEY=VALUE pairs (repeatable).
1280        #[arg(short, long = "tag", value_name = "KEY=VALUE")]
1281        tags: Vec<String>,
1282    },
1283
1284    /// SSH key inventory and operations.
1285    ///
1286    /// Subcommands: list, public-key, generate, config, agent
1287    ///
1288    #[command(
1289        after_help = "Examples:\n  tsafe ssh list\n  tsafe ssh public-key my_ed25519_key\n  tsafe ssh generate my_key\n  tsafe ssh generate my_key --type rsa\n  tsafe ssh config\n  eval $(tsafe ssh-agent)"
1290    )]
1291    #[cfg(feature = "ssh")]
1292    Ssh {
1293        #[command(subcommand)]
1294        action: SshAction,
1295    },
1296
1297    /// List namespaces or copy/move all keys under one prefix to another.
1298    ///
1299    /// A namespace is any key-prefix of the form `<name>/KEY`. They are not stored
1300    /// explicitly — this command introspects the key names in the vault.
1301    ///
1302    #[command(
1303        after_help = "Examples:\n  tsafe ns list\n  tsafe ns copy prod staging\n  tsafe ns move oldapp newapp --force"
1304    )]
1305    Ns {
1306        #[command(subcommand)]
1307        action: NsAction,
1308    },
1309
1310    /// Pull secrets from all sources defined in `.tsafe.yml`.
1311    ///
1312    /// Searches upward from the current directory for `.tsafe.yml` or `.tsafe.json`
1313    /// and executes each pull source in manifest order (sequential; see ADR-012).
1314    ///
1315    /// Use --dry-run to preview which sources would be invoked without making any
1316    /// live API calls. Note: collision detection is not available in dry-run mode —
1317    /// detecting key conflicts requires fetching keys from each provider.
1318    ///
1319    /// Use --source to narrow execution to one or more named sources. Sources are
1320    /// named with the `name` field in the manifest. Multiple --source flags are OR'd.
1321    ///
1322    #[command(
1323        after_help = "Examples:\n  tsafe pull\n  tsafe pull --config path/to/.tsafe.yml\n  tsafe pull --dry-run\n  tsafe pull --source prod-akv\n  tsafe pull --source prod-akv --source staging-aws"
1324    )]
1325    #[cfg(feature = "multi-pull")]
1326    Pull {
1327        /// Path to config file (auto-detected if omitted).
1328        #[arg(long)]
1329        config: Option<String>,
1330        /// Overwrite all existing secrets (overrides per-source settings).
1331        #[arg(long)]
1332        overwrite: bool,
1333        /// Failure handling mode for source errors in multi-source pull.
1334        #[arg(long, value_enum, default_value = "fail-all")]
1335        on_error: PullOnError,
1336        /// Preview which sources would be invoked without making any live API calls.
1337        /// Collision detection is not available in dry-run mode.
1338        #[arg(long)]
1339        dry_run: bool,
1340        /// Narrow execution to sources with this `name` label (repeatable).
1341        /// Sources without a `name` field are excluded when any --source filter is active.
1342        #[arg(long = "source", value_name = "LABEL", action = clap::ArgAction::Append)]
1343        sources: Vec<String>,
1344    },
1345
1346    /// Push local vault secrets to all destinations defined in `.tsafe.yml`.
1347    ///
1348    /// Searches upward from the current directory for `.tsafe.yml` or `.tsafe.json`
1349    /// and executes each push destination in manifest order (sequential; see ADR-030).
1350    ///
1351    /// Use --dry-run to preview which destinations would be invoked without making
1352    /// any live API calls or writes.
1353    ///
1354    /// Use --source to narrow execution to one or more named destinations. Destinations
1355    /// are named with the `name` field in the manifest. Multiple --source flags are OR'd.
1356    ///
1357    /// A pre-flight diff is shown before any writes. Secret values are never printed —
1358    /// only key names and 12-char SHA-256 hash prefixes are shown (ADR-030).
1359    ///
1360    #[command(
1361        after_help = "Examples:\n  tsafe push\n  tsafe push --config path/to/.tsafe.yml\n  tsafe push --dry-run\n  tsafe push --source prod-akv\n  tsafe push --yes\n  tsafe push --on-error skip-failed"
1362    )]
1363    #[cfg(feature = "akv-pull")]
1364    Push {
1365        /// Path to config file (auto-detected if omitted).
1366        #[arg(long, value_name = "PATH")]
1367        config: Option<std::path::PathBuf>,
1368        /// Narrow execution to destinations with this `name` label (repeatable).
1369        /// Destinations without a `name` field are excluded when any --source filter is active.
1370        #[arg(long = "source", value_name = "LABEL", action = clap::ArgAction::Append)]
1371        source: Vec<String>,
1372        /// Show the diff without writing anything (always exits 0).
1373        #[arg(long)]
1374        dry_run: bool,
1375        /// Skip confirmation prompts (required in non-TTY / CI contexts).
1376        #[arg(long)]
1377        yes: bool,
1378        /// Also delete remote secrets that are absent locally within each destination's scope.
1379        /// Off by default — opt-in to avoid accidental mass deletion (ADR-030).
1380        #[arg(long)]
1381        delete_missing: bool,
1382        /// Failure handling mode for destination errors.
1383        #[arg(long, value_enum, default_value = "fail-all")]
1384        on_error: PushOnError,
1385    },
1386
1387    /// Synchronise a vault file with a git remote.
1388    ///
1389    /// Fetches the remote branch, performs a per-key three-way merge between
1390    /// the common ancestor, the local vault, and the remote vault, then commits
1391    /// and pushes the merged result.
1392    ///
1393    /// Conflicts (both sides edited the same key) are resolved by last-write-wins
1394    /// using the secret's `updated_at` timestamp. Conflicts are reported but do
1395    /// not block the sync.
1396    ///
1397    #[command(
1398        after_help = "Examples:\n  tsafe sync\n  tsafe sync --remote origin --branch main\n  tsafe sync --dry-run"
1399    )]
1400    #[cfg(feature = "git-helpers")]
1401    #[command(name = "sync")]
1402    Sync {
1403        /// Git remote name.
1404        #[arg(long, default_value = "origin")]
1405        remote: String,
1406        /// Git branch to sync with.
1407        #[arg(long, default_value = "main")]
1408        branch: String,
1409        /// Vault file path relative to repo root (auto-detected if omitted).
1410        #[arg(long)]
1411        file: Option<String>,
1412        /// Show what would change without modifying anything.
1413        #[arg(long)]
1414        dry_run: bool,
1415    },
1416
1417    /// Manage team vaults (multi-recipient age encryption).
1418    ///
1419    /// Team vaults use X25519 (age) keypairs so multiple people can decrypt
1420    /// the same vault without sharing a password.
1421    ///
1422    #[command(
1423        after_help = "Examples:\n  tsafe team init --identity ~/.age/key.txt\n  tsafe team add-member age1qyqszqgpqyqszqgpqyqszqgpqyqszqgp...\n  tsafe team members"
1424    )]
1425    #[cfg(feature = "team-core")]
1426    Team {
1427        #[command(subcommand)]
1428        action: TeamAction,
1429    },
1430
1431    /// Enable or disable biometric / keyring unlock for the current profile.
1432    ///
1433    /// When enabled, the vault password is stored in the OS credential store
1434    /// (macOS Keychain, Windows Credential Manager, Linux Secret Service).
1435    /// The credential store is itself protected by biometric or PIN.
1436    ///
1437    /// After `tsafe init`, the CLI may offer the same setup interactively ("quick unlock").
1438    /// You can always run `biometric enable` later if you skipped it.
1439    ///
1440    #[command(
1441        after_help = "Examples:\n  tsafe biometric enable\n  tsafe biometric disable\n  tsafe biometric status"
1442    )]
1443    #[cfg(feature = "biometric")]
1444    Biometric {
1445        #[command(subcommand)]
1446        action: BiometricAction,
1447    },
1448
1449    /// Manage the per-process vault unlock agent.
1450    ///
1451    /// `tsafe agent unlock` prints terminal approval text, may show an OS notification,
1452    /// then prompts for the vault password once and starts a background agent that holds
1453    /// it in memory.  The token it prints must be set in the calling process's environment
1454    /// as `TSAFE_AGENT_SOCK` — all subsequent `tsafe` invocations that inherit that
1455    /// env var will be granted vault access without re-entering the password.
1456    ///
1457    /// Requests must present the session token and come from a live OS-reported peer
1458    /// PID; the unlock process PID is recorded for audit/context, not as the only
1459    /// process allowed to use the session.
1460    ///
1461    #[command(
1462        after_help = "Examples:\n  tsafe agent unlock              # unlock for 30 minutes (default)\n  tsafe agent unlock --ttl 8h     # unlock for 8 hours\n  tsafe agent unlock --ttl 30m --absolute-ttl 8h\n  tsafe agent status              # check whether the current agent socket is reachable\n  tsafe agent lock                # immediately revoke the session"
1463    )]
1464    #[cfg(feature = "agent")]
1465    Agent {
1466        #[command(subcommand)]
1467        action: AgentAction,
1468    },
1469
1470    /// First-party MCP server — exposes tsafe to MCP-aware hosts (Claude
1471    /// Desktop, Cursor, Continue, Windsurf, Codex) over stdio JSON-RPC.
1472    ///
1473    /// Each `tsafe-mcp` process binds to exactly one profile; request-time
1474    /// profile or scope widening is rejected. See ADR-006
1475    /// (docs/architecture/ADR-006-mcp-server.md) and design doc §5.2 for the
1476    /// full surface.
1477    ///
1478    /// `tsafe mcp install <host>` writes the per-host MCP config file. The
1479    /// resulting host launch shells out to `tsafe-mcp serve` directly, not
1480    /// through this CLI.
1481    #[command(
1482        after_help = "Examples:\n  tsafe mcp serve --profile ops --contract cordance-diagnostics --workdir .\n  tsafe mcp config codex --name tsafe-cordance --profile ops --contract cordance-diagnostics --workdir .\n  tsafe mcp doctor --code missing_contract --contract cordance-diagnostics --workdir . --json\n  tsafe mcp install claude --allowed-keys \"demo/*\"\n  tsafe mcp install cursor --project . --allowed-keys \"demo/*\"\n  tsafe mcp serve --allowed-keys \"demo/*\" --allow-reveal\n  tsafe mcp uninstall claude\n  tsafe mcp status"
1483    )]
1484    #[cfg(feature = "mcp")]
1485    Mcp {
1486        #[command(subcommand)]
1487        action: McpCliAction,
1488    },
1489
1490    /// Run a git command with vault credentials injected automatically.
1491    ///
1492    /// Opens the vault, reads `ADO_PAT` (or the key named by TSAFE_GIT_PAT_KEY),
1493    /// and injects it as a git `http.extraHeader` so HTTPS remotes authenticate
1494    /// without embedding tokens in URLs.
1495    ///
1496    /// Detects the nearest `.git` directory automatically — no repo flags needed.
1497    /// Exits with git's exit code.
1498    ///
1499    /// Override the PAT key name:  $env:TSAFE_GIT_PAT_KEY = "MY_GIT_PAT"
1500    ///
1501    #[command(
1502        after_help = "Examples:\n  tsafe git push ado main\n  tsafe git pull\n  tsafe git fetch --all\n  tsafe -p work git push origin main"
1503    )]
1504    #[cfg(feature = "git-helpers")]
1505    #[command(name = "git")]
1506    Git {
1507        /// git subcommand and its arguments (e.g. `push ado main`).
1508        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
1509        args: Vec<String>,
1510    },
1511
1512    /// Run the tsafe attestation scanner over a repo.
1513    ///
1514    /// The scanner is the Phase 3 port of algol's secret-detection +
1515    /// env-authority scanner. It walks a repo tree, emits a JSON / markdown
1516    /// / text report, and writes a CloudEvents `algol.scan.v1` envelope
1517    /// for the audit trail. Default-on per Phase 3 of the algol→tsafe
1518    /// migration — no `--experimental-scan` flag needed.
1519    ///
1520    /// Honest disclosure: scanner P/R = 1.000/1.000 on the synthetic N=100
1521    /// corpus at `tsafe/benchmarks/scanner-corpus/v1/` (see
1522    /// `ecosystem-catalog/portfolio-algol-tsafe-phase2-1-precision-recovery-2026-05-21.md`
1523    /// for confidence intervals). Real-world false-positive / negative
1524    /// rates may differ; report issues to the tsafe repo. Scanner uses
1525    /// BLAKE3 content fingerprints per ec ADR-0003 (wire-format change
1526    /// from algol's SHA-256 — see CHANGELOG).
1527    #[command(
1528        after_help = "Examples:\n  tsafe attest scan                         # scan current dir\n  tsafe attest scan ./my-repo               # scan specific repo\n  tsafe attest scan --format json -o scan.json\n  tsafe attest scan --strict                # non-zero exit on any secret finding\n  tsafe attest scan --extra-paths ./submodule"
1529    )]
1530    Attest {
1531        #[command(subcommand)]
1532        action: AttestAction,
1533    },
1534}
1535
1536/// Collab subcommands — scaffolding for D3.5 (Tranche 2).
1537/// No network calls in this release; real implementation is Tranche 3+.
1538#[derive(Subcommand)]
1539#[cfg(feature = "collab")]
1540pub enum CollabAction {
1541    /// Join a collaboration team (scaffolding — prints status and exits 0).
1542    Join {
1543        /// Team ID to join.
1544        team_id: String,
1545    },
1546    /// Show collaboration status for a team (scaffolding — prints status and exits 0).
1547    Status {
1548        /// Team ID to query.
1549        team_id: String,
1550    },
1551}
1552
1553#[derive(Subcommand)]
1554pub enum SnapshotAction {
1555    /// List snapshots for the current profile.
1556    List,
1557    /// Restore the most-recent snapshot, overwriting the current vault.
1558    Restore,
1559}
1560
1561/// Global config.json settings (not tied to `--profile`).
1562#[derive(Subcommand)]
1563pub enum ConfigAction {
1564    /// Show config file path, default profile, exec trust settings, and password-backup settings.
1565    Show,
1566    /// After each new password vault is created, copy its master password into this vault at `profile-passwords/<new-profile>` (recovery / main-vault bridging). Common values: `main`, `default`. Use `off` to disable.
1567    #[command(name = "set-backup-vault")]
1568    SetBackupVault {
1569        /// Target vault profile (`main`, `default`) or `off` to clear.
1570        target: String,
1571    },
1572    /// Persist whether normal vault opens should automatically try OS quick unlock.
1573    #[command(name = "set-auto-quick-unlock")]
1574    SetAutoQuickUnlock {
1575        /// `on` to allow automatic keychain reads, `off` to require agent / env / typed password instead.
1576        mode: ToggleSetting,
1577    },
1578    /// Persist the retry cooldown, in seconds, after an automatic quick-unlock failure.
1579    #[command(name = "set-quick-unlock-retry-cooldown")]
1580    SetQuickUnlockRetryCooldown {
1581        /// Seconds to wait before the next automatic keychain attempt. Use `0` to disable the cooldown.
1582        seconds: u64,
1583    },
1584    /// Persist the default exec trust mode.
1585    #[command(name = "set-exec-mode")]
1586    SetExecMode {
1587        /// One of: `standard`, `hardened`, `custom`.
1588        mode: ExecModeSetting,
1589    },
1590    /// Persist whether `tsafe exec` should redact child stdout/stderr by default.
1591    #[command(name = "set-exec-redact-output")]
1592    SetExecRedactOutput {
1593        /// `on` to redact child output by default, `off` to leave it raw unless `--redact-output` is passed.
1594        mode: ToggleSetting,
1595    },
1596    /// Persist the inherit strategy used when exec mode is `custom`.
1597    #[command(name = "set-exec-custom-inherit")]
1598    SetExecCustomInherit {
1599        /// One of: `full`, `minimal`, `clean`.
1600        mode: ExecCustomInheritSetting,
1601    },
1602    /// Persist whether dangerous injected env names should abort exec when mode is `custom`.
1603    #[command(name = "set-exec-custom-deny-dangerous-env")]
1604    SetExecCustomDenyDangerousEnv {
1605        /// `on` to abort, `off` to warn only.
1606        mode: ToggleSetting,
1607    },
1608    /// Add a parent environment variable name to the extra strip list for `tsafe exec`.
1609    #[command(name = "add-exec-extra-strip")]
1610    AddExecExtraStrip {
1611        /// Environment variable name, e.g. OPENAI_API_KEY.
1612        name: String,
1613    },
1614    /// Remove a parent environment variable name from the extra strip list for `tsafe exec`.
1615    #[command(name = "remove-exec-extra-strip")]
1616    RemoveExecExtraStrip {
1617        /// Environment variable name, e.g. OPENAI_API_KEY.
1618        name: String,
1619    },
1620}
1621
1622#[derive(Clone, Copy, ValueEnum)]
1623pub enum ToggleSetting {
1624    /// Enable the setting.
1625    On,
1626    /// Disable the setting.
1627    Off,
1628}
1629
1630#[derive(Clone, Copy, ValueEnum)]
1631pub enum ExecModeSetting {
1632    /// Broad compatibility: full inherited env (minus strip list), raw output, and abort on dangerous injected names by default.
1633    Standard,
1634    /// Stricter preset: minimal inherited env, redacted output, and deny dangerous injected names.
1635    Hardened,
1636    /// Use persisted custom exec trust settings from config.json.
1637    Custom,
1638}
1639
1640/// Controls which host environment variables the child process inherits.
1641#[derive(Clone, Copy, ValueEnum)]
1642pub enum ExecPresetSetting {
1643    /// Inherit only PATH and a safe core set (HOME, USER, TMPDIR, LANG, TERM, SSH_AUTH_SOCK, etc.)
1644    /// plus vault secrets. No tokens or credentials from the parent environment leak through.
1645    Minimal,
1646    /// Inherit the full parent environment minus the known-sensitive strip list. This is the
1647    /// current default behavior when no preset or inheritance flag is given.
1648    Full,
1649}
1650
1651#[derive(Clone, Copy, ValueEnum)]
1652pub enum ExecCustomInheritSetting {
1653    /// Full inherited parent env (minus strip list).
1654    Full,
1655    /// Minimal inherited env plus vault secrets.
1656    Minimal,
1657    /// No inherited parent env; only vault secrets.
1658    Clean,
1659}
1660
1661#[derive(Subcommand)]
1662pub enum ProfileAction {
1663    /// List all profiles that have an existing vault.
1664    List,
1665    /// Permanently delete a profile vault.
1666    Delete {
1667        name: String,
1668        /// Skip the confirmation prompt.
1669        #[arg(long)]
1670        force: bool,
1671    },
1672    /// Set the default profile used when -p / TSAFE_PROFILE is not specified.
1673    ///
1674    #[command(after_help = "Examples:\n  tsafe profile set-default work")]
1675    SetDefault {
1676        /// Profile name to use as the new default.
1677        name: String,
1678    },
1679    /// Rename a profile (renames the vault file and updates the default if needed).
1680    ///
1681    #[command(after_help = "Examples:\n  tsafe profile rename old new")]
1682    Rename {
1683        /// Existing profile name.
1684        from: String,
1685        /// New profile name.
1686        to: String,
1687    },
1688}
1689
1690#[derive(Clone, ValueEnum)]
1691pub enum ExportFormat {
1692    /// KEY=VALUE (posix, one per line)
1693    Env,
1694    /// export KEY="VALUE" (bash/zsh source-able)
1695    Dotenv,
1696    /// $env:KEY = "VALUE" (PowerShell source-able)
1697    Powershell,
1698    /// JSON object
1699    Json,
1700    /// ::add-mask::VALUE + KEY=VALUE (GitHub Actions GITHUB_ENV format)
1701    GithubActions,
1702    /// YAML mapping (KEY: "VALUE" per entry)
1703    Yaml,
1704    /// KEY=VALUE per line suitable for Docker --env-file (alias for env, Docker-compatible)
1705    DockerEnv,
1706    /// TOML flat top-level table (KEY = "VALUE" per entry)
1707    Toml,
1708}
1709
1710#[derive(Clone, ValueEnum)]
1711pub enum AuditExportFormat {
1712    /// JSONL (one JSON object per line, same as stored on disk)
1713    Json,
1714    /// Splunk HEC-compatible JSON events
1715    Splunk,
1716    /// CloudEvents 1.0 JSONL (application/cloudevents+json per line)
1717    CloudEvents,
1718}
1719
1720/// Actions for `tsafe audit` subcommand.
1721///
1722/// `Rotate` is a stub reserved for the audit-log rotation handler implemented
1723/// in `cmd_audit_cmd.rs` by a separate agent.  The variant is declared here so
1724/// that `cli.rs` is the single source of truth for the CLI surface.
1725#[derive(Subcommand)]
1726#[allow(dead_code)]
1727pub enum AuditAction {
1728    /// Rotate (trim) the audit log to keep only the most-recent entries.
1729    ///
1730    /// Reserved — handler implemented in `cmd_audit_cmd.rs`.
1731    #[command(
1732        after_help = "Examples:\n  tsafe audit rotate --keep 1000\n  tsafe audit rotate --max-size-mb 10"
1733    )]
1734    Rotate {
1735        /// Maximum audit log size in megabytes before trimming.
1736        #[arg(long, default_value_t = 50)]
1737        max_size_mb: u64,
1738        /// Number of most-recent entries to keep after trimming.
1739        #[arg(long, default_value_t = 5000)]
1740        keep: u32,
1741    },
1742}
1743
1744#[derive(Clone, Copy, ValueEnum)]
1745pub enum CredentialHelperOperation {
1746    /// Install tsafe as the git credential helper in git config.
1747    Install,
1748    Get,
1749    Store,
1750    Erase,
1751}
1752
1753#[cfg(feature = "ssh")]
1754#[derive(Subcommand)]
1755pub enum SshAction {
1756    /// List SSH keys stored in the vault (tagged type=ssh or containing PRIVATE KEY).
1757    #[command(after_help = "Examples:\n  tsafe ssh list")]
1758    List,
1759
1760    /// Extract the public key from a stored SSH private key.
1761    ///
1762    /// Prints the OpenSSH public key in authorized_keys format to stdout.
1763    #[command(
1764        name = "public-key",
1765        after_help = "Examples:\n  tsafe ssh public-key my_ed25519_key\n  tsafe ssh public-key SSH_ID_ED25519"
1766    )]
1767    PublicKey {
1768        /// Vault key name containing the SSH private key.
1769        key: String,
1770    },
1771
1772    /// Generate a new SSH key pair and store the private key in the vault.
1773    ///
1774    /// Uses a CSPRNG (no subprocess). The private key is stored encrypted in
1775    /// the vault; the public key is printed to stdout.
1776    #[command(
1777        after_help = "Examples:\n  tsafe ssh generate my_deploy_key\n  tsafe ssh generate my_rsa_key --type rsa --bits 4096\n  tsafe ssh generate ci_key --comment \"ci@example.com\" --print"
1778    )]
1779    Generate {
1780        /// Vault key name to store the generated private key under.
1781        key: String,
1782        /// Key type: ed25519 (default, recommended) or rsa.
1783        #[arg(long, value_name = "TYPE", default_value = "ed25519")]
1784        r#type: SshKeyType,
1785        /// RSA key size in bits (only used with --type rsa; default 4096).
1786        #[arg(long, value_name = "BITS", default_value = "4096")]
1787        bits: u32,
1788        /// Comment to embed in the key (e.g. an email address).
1789        #[arg(long, value_name = "COMMENT")]
1790        comment: Option<String>,
1791        /// Print the public key to stdout after storing the private key.
1792        #[arg(long)]
1793        print: bool,
1794    },
1795
1796    /// Print an ~/.ssh/config snippet that points IdentityAgent at tsafe.
1797    ///
1798    /// Pipe or append the output to ~/.ssh/config.
1799    #[command(
1800        name = "config",
1801        after_help = "Examples:\n  tsafe ssh config\n  tsafe ssh config --host '*.corp.example'\n  tsafe ssh config >> ~/.ssh/config"
1802    )]
1803    Config {
1804        /// SSH Host pattern (defaults to `*`).
1805        #[arg(long, value_name = "PATTERN")]
1806        host: Option<String>,
1807    },
1808
1809    /// Start a persistent SSH agent serving vault keys on a Unix socket.
1810    ///
1811    /// Keys are loaded once at startup and served for the configured TTL.
1812    /// On Windows this subcommand prints a clear error — Unix socket required.
1813    ///
1814    /// Eval idiom:  eval $(tsafe ssh-agent)
1815    #[command(
1816        name = "agent",
1817        after_help = "Examples:\n  eval $(tsafe ssh agent)\n  tsafe ssh agent --ttl 4h\n  tsafe ssh agent --sock /run/user/1000/tsafe.sock"
1818    )]
1819    Agent {
1820        /// How long loaded keys remain valid (e.g. 8h, 30m, 1h30m). Default 8h.
1821        #[arg(long, value_name = "DURATION")]
1822        ttl: Option<String>,
1823        /// Override the Unix socket path.
1824        #[arg(long, value_name = "PATH")]
1825        sock: Option<String>,
1826    },
1827}
1828
1829#[cfg(feature = "ssh")]
1830#[derive(Clone, Copy, ValueEnum)]
1831pub enum SshKeyType {
1832    Ed25519,
1833    Rsa,
1834}
1835
1836#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
1837pub enum PullOnError {
1838    /// Abort immediately on first source/provider error.
1839    FailAll,
1840    /// Skip failed source and continue remaining sources.
1841    SkipFailed,
1842    /// Continue and only warn on source/provider errors.
1843    WarnOnly,
1844}
1845
1846#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
1847pub enum PushOnError {
1848    /// Abort immediately on first source error.
1849    FailAll,
1850    /// Log the error, skip the failed source, and continue with the next.
1851    SkipFailed,
1852}
1853
1854#[derive(Subcommand)]
1855pub enum BrowserProfileAction {
1856    /// Add or update a domain → vault-profile mapping.
1857    ///
1858    /// DOMAIN may be an exact hostname or a wildcard pattern (e.g. *.corp.example).
1859    /// Defaults to the active --profile if --profile is omitted.
1860    ///
1861    #[command(
1862        after_help = "Examples:\n  tsafe browser-profile add github.com\n  tsafe browser-profile add corp.example --profile work"
1863    )]
1864    Add {
1865        /// Domain or pattern (e.g. github.com, *.corp.example).
1866        domain: String,
1867        /// Vault profile to use for this domain. Defaults to the active --profile.
1868        #[arg(long)]
1869        profile: Option<String>,
1870    },
1871    /// List all domain → vault-profile mappings.
1872    ///
1873    #[command(after_help = "Examples:\n  tsafe browser-profile list")]
1874    List,
1875    /// Remove the mapping for DOMAIN.
1876    ///
1877    #[command(after_help = "Examples:\n  tsafe browser-profile remove github.com")]
1878    Remove {
1879        /// Domain or pattern to remove.
1880        domain: String,
1881    },
1882}
1883
1884#[derive(Subcommand)]
1885pub enum BrowserNativeHostAction {
1886    /// Write the per-browser native-messaging-host manifest pointing at
1887    /// `tsafe-nativehost`. Per-user; never elevates. On Windows it writes
1888    /// Chromium-family HKCU registry keys for a 32-char extension ID, or a
1889    /// Firefox filesystem manifest under `%APPDATA%\Mozilla\NativeMessagingHosts\`
1890    /// for an email-style or UUID-style Firefox addon ID. On macOS/Linux it
1891    /// skips browsers that are not installed.
1892    ///
1893    /// `--extension-id` is REQUIRED — defaulting to a known development ID
1894    /// would let any installed extension with that ID talk to your vault.
1895    /// Chromium ID: 32-char string at `chrome://extensions` (Developer mode).
1896    /// Firefox ID: the `gecko.id` value from `browser_specific_settings` in
1897    /// the extension manifest (e.g. `tsafe@tsafe.dev`).
1898    Register {
1899        /// The extension ID to allow. Chromium-family: 32-character lowercase
1900        /// ID from `chrome://extensions`. Firefox: email-style addon ID
1901        /// (e.g. `tsafe@tsafe.dev`) or UUID in curly braces.
1902        #[arg(long)]
1903        extension_id: String,
1904    },
1905    /// Remove the per-browser manifest files (and HKCU keys on Windows).
1906    Unregister,
1907    /// Detect the native-host binary location and print the manifest paths that
1908    /// `register` would write for each installed browser — without writing
1909    /// anything. Use this when you do not yet know your extension ID:
1910    ///
1911    ///   1. Run `tsafe browser-native-host detect` to confirm the binary is
1912    ///      found and see which browsers are detected.
1913    ///   2. Load the extension in your browser, find your extension ID at
1914    ///      `chrome://extensions` (Developer mode), then run:
1915    ///      tsafe browser-native-host register --extension-id ID
1916    ///
1917    /// On Windows, prints the HKCU registry keys and manifest directory that
1918    /// would be written; never modifies registry or filesystem.
1919    Detect,
1920}
1921
1922#[derive(Clone, Copy, ValueEnum)]
1923pub enum TotpAlgorithm {
1924    /// HMAC-SHA1 (default; most compatible)
1925    Sha1,
1926    /// HMAC-SHA256
1927    Sha256,
1928    /// HMAC-SHA512
1929    Sha512,
1930}
1931
1932impl TotpAlgorithm {
1933    pub fn as_uri_str(self) -> &'static str {
1934        match self {
1935            Self::Sha1 => "SHA1",
1936            Self::Sha256 => "SHA256",
1937            Self::Sha512 => "SHA512",
1938        }
1939    }
1940}
1941
1942#[derive(Subcommand)]
1943pub enum TotpAction {
1944    /// Store a TOTP seed for KEY. Accepts a raw base32 secret or an otpauth:// URI.
1945    ///
1946    #[command(
1947        after_help = "Examples:\n  tsafe totp add GITHUB_2FA JBSWY3DPEHPK3PXP\n  tsafe totp add CORP_2FA JBSWY3DPEHPK3PXP --digits 8 --period 60\n  tsafe totp add CORP_2FA JBSWY3DPEHPK3PXP --algorithm sha256"
1948    )]
1949    Add {
1950        /// Vault key name to store under.
1951        key: String,
1952        /// Base32-encoded TOTP secret or full otpauth:// URI.
1953        secret: String,
1954        /// HMAC algorithm to use (default: sha1, most widely supported).
1955        #[arg(long, default_value = "sha1")]
1956        algorithm: TotpAlgorithm,
1957        /// Number of digits in each OTP code (default: 6; some services use 8).
1958        #[arg(long, default_value_t = 6)]
1959        digits: u32,
1960        /// Time step in seconds (default: 30; some services use 60).
1961        #[arg(long, default_value_t = 30)]
1962        period: u64,
1963    },
1964    /// Print the current TOTP code + seconds remaining.
1965    ///
1966    #[command(after_help = "Examples:\n  tsafe totp get GITHUB_2FA")]
1967    Get {
1968        /// Vault key name where the TOTP seed is stored.
1969        key: String,
1970    },
1971}
1972
1973#[derive(Subcommand)]
1974pub enum NsAction {
1975    /// List all namespaces present in the vault (inferred from key prefixes).
1976    List,
1977    /// Copy every secret under FROM/ to TO/ (same suffix). Source keys stay.
1978    ///
1979    #[command(after_help = "Examples:\n  tsafe ns copy prod staging")]
1980    Copy {
1981        /// Namespace prefix to read from (keys must be `FROM/...`).
1982        from: String,
1983        /// Namespace prefix to write to (`TO/<same-suffix>`).
1984        to: String,
1985        /// Overwrite destination keys if they already exist.
1986        #[arg(long)]
1987        force: bool,
1988    },
1989    /// Move every secret under FROM/ to TO/ (same suffix). Source keys are removed.
1990    ///
1991    #[command(after_help = "Examples:\n  tsafe ns move prod staging")]
1992    Move {
1993        /// Namespace prefix to read from and delete after rename.
1994        from: String,
1995        /// Namespace prefix to write to (`TO/<same-suffix>`).
1996        to: String,
1997        /// Overwrite destination keys if they already exist.
1998        #[arg(long)]
1999        force: bool,
2000    },
2001}
2002
2003/// Repo-local `.tsafe/tooling` inventory commands.
2004#[derive(Subcommand)]
2005pub enum ToolingAction {
2006    /// Create `.tsafe/tooling/keys.ini`, policy, and README scaffolding.
2007    Init {
2008        /// Repository root. Defaults to the current directory.
2009        #[arg(long, default_value = ".")]
2010        root: std::path::PathBuf,
2011        /// Vault namespace prefix to use for this repo, e.g. databricks/athn_dev/.
2012        #[arg(long)]
2013        namespace: Option<String>,
2014        /// Replace existing scaffold files.
2015        #[arg(long)]
2016        force: bool,
2017    },
2018    /// Validate `.tsafe/tooling/keys.ini`.
2019    Check {
2020        /// Repository root. Defaults to the current directory.
2021        #[arg(long, default_value = ".")]
2022        root: std::path::PathBuf,
2023        /// Emit machine-readable JSON output.
2024        #[arg(long)]
2025        json: bool,
2026    },
2027    /// Suggest a missing secret slot and optionally add it to `keys.ini`.
2028    Suggest {
2029        /// Repository root. Defaults to the current directory.
2030        #[arg(long, default_value = ".")]
2031        root: std::path::PathBuf,
2032        /// Vault namespace prefix for the suggested key.
2033        #[arg(long)]
2034        namespace: String,
2035        /// Inventory section to append under.
2036        #[arg(long)]
2037        section: Option<String>,
2038        /// Key name. Relative keys are prefixed with `--namespace`.
2039        #[arg(long)]
2040        key: String,
2041        /// Human purpose for this secret slot.
2042        #[arg(long)]
2043        purpose: String,
2044        /// Tool, pipeline, or runbook that consumes this secret.
2045        #[arg(long)]
2046        consumer: String,
2047        /// Rotation expectation, e.g. static, manual, 365d KV policy.
2048        #[arg(long)]
2049        rotation: String,
2050        /// Write the suggested slot to `keys.ini`. Without this, preview only.
2051        #[arg(long)]
2052        apply: bool,
2053        /// Reason or task context for the receipt.
2054        #[arg(long, default_value = "operator suggestion")]
2055        reason: String,
2056    },
2057}
2058
2059/// `tsafe mcp ...` subcommands per design §5.2 / ADR-006. Gated behind the
2060/// `mcp` feature.
2061#[derive(Subcommand)]
2062#[cfg(feature = "mcp")]
2063pub enum McpCliAction {
2064    /// Start the MCP stdio JSON-RPC server bound to the current profile.
2065    ///
2066    /// Hosts invoke this directly via the entry written by `tsafe mcp install`.
2067    /// Running it interactively is mainly useful for diagnostics.
2068    Serve {
2069        /// Comma-separated glob list of vault keys this server may expose.
2070        #[arg(long, value_name = "GLOBS")]
2071        allowed_keys: Option<String>,
2072        /// Comma-separated glob list of vault keys to exclude even when
2073        /// matched by `--allowed-keys` or the bound contract.
2074        #[arg(long, value_name = "GLOBS")]
2075        denied_keys: Option<String>,
2076        /// Bind an authority contract for stricter scope and per-key intent.
2077        #[arg(long, value_name = "NAME")]
2078        contract: Option<String>,
2079        /// Bind this server to a single repository/work directory.
2080        #[arg(long, value_name = "PATH")]
2081        workdir: Option<String>,
2082        /// Enable the opt-in `tsafe_reveal` tool. Off by default.
2083        #[arg(long)]
2084        allow_reveal: bool,
2085        /// Label written to the audit `source` field (e.g. `mcp:claude:1234`).
2086        #[arg(long, value_name = "LABEL")]
2087        audit_source: Option<String>,
2088    },
2089    /// Write an MCP server entry into the host's config file.
2090    ///
2091    /// Refuses to write without an explicit `--allowed-keys` or `--contract`.
2092    Install {
2093        /// Host name. One of: claude, cursor, continue, windsurf, codex.
2094        host: String,
2095        /// Server entry name. Defaults to `tsafe-<profile>`.
2096        #[arg(long)]
2097        name: Option<String>,
2098        /// Write to the global per-user config (default when no scope flag is set).
2099        #[arg(long)]
2100        global: bool,
2101        /// Write to a project-local config under the given directory.
2102        #[arg(long, value_name = "DIR")]
2103        project: Option<String>,
2104        /// Print the proposed file change without modifying disk.
2105        #[arg(long)]
2106        dry_run: bool,
2107        /// Comma-separated glob list of vault keys the server may expose.
2108        #[arg(long, value_name = "GLOBS")]
2109        allowed_keys: Option<String>,
2110        /// Comma-separated glob list of vault keys to exclude.
2111        #[arg(long, value_name = "GLOBS")]
2112        denied_keys: Option<String>,
2113        /// Bind an authority contract for stricter scope.
2114        #[arg(long, value_name = "NAME")]
2115        contract: Option<String>,
2116        /// Bind the installed server entry to a single repository/work directory.
2117        #[arg(long, value_name = "PATH")]
2118        workdir: Option<String>,
2119        /// Enable the opt-in `tsafe_reveal` tool on the installed entry.
2120        #[arg(long)]
2121        allow_reveal: bool,
2122    },
2123    /// Emit a safe MCP config entry for a host without embedding secret values.
2124    Config {
2125        /// Host name. Currently: codex.
2126        host: String,
2127        /// Server entry name. Defaults to `tsafe-<profile>`.
2128        #[arg(long)]
2129        name: Option<String>,
2130        /// Bind an authority contract for command execution.
2131        #[arg(long, value_name = "NAME")]
2132        contract: Option<String>,
2133        /// Bind this server entry to a single repository/work directory.
2134        #[arg(long, value_name = "PATH")]
2135        workdir: Option<String>,
2136    },
2137    /// Remove an MCP server entry from the host's config file.
2138    Uninstall {
2139        /// Host name. One of: claude, cursor, continue, windsurf, codex.
2140        host: String,
2141        /// Server entry name. Defaults to `tsafe-<profile>`.
2142        #[arg(long)]
2143        name: Option<String>,
2144    },
2145    /// Render model-safe remediation for a bound MCP denial code.
2146    Doctor {
2147        /// Bound MCP authority denial code, e.g. missing_contract.
2148        #[arg(long, value_name = "DENIAL_CODE")]
2149        code: String,
2150        /// Bound authority contract name.
2151        #[arg(long, value_name = "NAME")]
2152        contract: String,
2153        /// Bound repository or work directory.
2154        #[arg(long, value_name = "PATH")]
2155        workdir: String,
2156        /// Existing receipt id if the denial already produced one.
2157        #[arg(long, value_name = "ID")]
2158        receipt_id: Option<String>,
2159        /// Emit structured JSON instead of the human summary.
2160        #[arg(long)]
2161        json: bool,
2162    },
2163    /// Print binary version + resolved scope. Lightweight diagnostic;
2164    /// does not speak JSON-RPC.
2165    Status,
2166}
2167
2168#[cfg(feature = "mcp")]
2169pub enum McpAction {
2170    Serve {
2171        allowed_keys: Option<String>,
2172        denied_keys: Option<String>,
2173        contract: Option<String>,
2174        workdir: Option<String>,
2175        allow_reveal: bool,
2176        audit_source: Option<String>,
2177    },
2178    Install {
2179        host: String,
2180        name: Option<String>,
2181        global: bool,
2182        project: Option<String>,
2183        dry_run: bool,
2184        allowed_keys: Option<String>,
2185        denied_keys: Option<String>,
2186        contract: Option<String>,
2187        workdir: Option<String>,
2188        allow_reveal: bool,
2189    },
2190    Config {
2191        host: String,
2192        name: Option<String>,
2193        contract: Option<String>,
2194        workdir: Option<String>,
2195    },
2196    Uninstall {
2197        host: String,
2198        name: Option<String>,
2199    },
2200    Status,
2201}
2202
2203#[derive(Subcommand)]
2204pub enum AgentAction {
2205    /// Prompt for approval + vault password, then start the background agent daemon.
2206    ///
2207    /// Prints a shell export line to stdout:
2208    ///   $env:TSAFE_AGENT_SOCK = "..."   # PowerShell
2209    ///   export TSAFE_AGENT_SOCK="..."   # bash/zsh
2210    ///
2211    /// Copy-paste or eval this line in the calling shell/process that needs access.
2212    Unlock {
2213        /// Idle TTL — how long the agent stays alive without a vault request.
2214        /// Resets on each vault access. Common values include 15m, 1h, and 4h. Default: 30m.
2215        #[arg(long, default_value = "30m")]
2216        ttl: String,
2217        /// Absolute TTL — hard cap regardless of activity. Default: 8h.
2218        /// Must be >= idle TTL. Common values include 8h, 12h, and 24h.
2219        #[arg(long, default_value = "8h")]
2220        absolute_ttl: String,
2221    },
2222    /// Immediately revoke the current session and stop the agent.
2223    Lock,
2224    /// Show whether the current agent socket is reachable.
2225    ///
2226    /// Use `--json` for a stable machine-readable output (ADR-029). Consumers such
2227    /// as the VS Code extension and tray agent depend on this flag. The schema
2228    /// `version` field must be checked before reading any other field.
2229    #[command(
2230        after_help = "Examples:\n  tsafe agent status\n  tsafe agent status --json\n  tsafe --profile prod agent status --json"
2231    )]
2232    Status {
2233        /// Emit a stable JSON object to stdout (schema version \"1\").
2234        /// See docs/decisions/agent-status-json-contract.md (ADR-029) for the full schema.
2235        #[arg(long)]
2236        json: bool,
2237    },
2238}
2239
2240#[derive(Subcommand)]
2241pub enum TeamAction {
2242    /// Create a new team vault encrypted to your age identity.
2243    ///
2244    #[command(after_help = "Examples:\n  tsafe team init --identity ~/.age/key.txt")]
2245    Init {
2246        /// Path to your age identity file (contains AGE-SECRET-KEY-1...).
2247        #[arg(long)]
2248        identity: String,
2249    },
2250    /// Add a team member by their age public key.
2251    ///
2252    #[command(after_help = "Examples:\n  tsafe team add-member age1qyqszqgp...")]
2253    AddMember {
2254        /// age X25519 public key (starts with "age1...").
2255        public_key: String,
2256        /// Path to your age identity file (for re-wrapping the DEK).
2257        #[arg(long)]
2258        identity: String,
2259    },
2260    /// Remove a team member and re-encrypt all secrets with a new key.
2261    ///
2262    #[command(after_help = "Examples:\n  tsafe team remove-member age1qyqszqgp...")]
2263    RemoveMember {
2264        /// age X25519 public key to remove.
2265        public_key: String,
2266        /// Path to your age identity file (for re-keying).
2267        #[arg(long)]
2268        identity: String,
2269    },
2270    /// List current team members (public keys).
2271    Members,
2272    /// Generate a new age identity (keypair) and print the JSON block to add
2273    /// to `.tsafe/team-keys.json` via a PR.
2274    ///
2275    /// The private key is saved to `~/.age/tsafe-<profile>.txt`.
2276    /// The public key is printed as a ready-to-paste JSON entry.
2277    ///
2278    #[command(
2279        after_help = "Examples:\n  tsafe team keygen\n  tsafe team keygen --name \"Alice Smith\" --email alice@corp.example"
2280    )]
2281    Keygen {
2282        /// Your display name for the team-keys entry.
2283        #[arg(long)]
2284        name: Option<String>,
2285        /// Your email for the team-keys entry.
2286        #[arg(long)]
2287        email: Option<String>,
2288    },
2289    /// Print your age public key from an existing identity file.
2290    ///
2291    #[command(
2292        after_help = "Examples:\n  tsafe team show-key\n  tsafe team show-key --identity ~/.age/key.txt"
2293    )]
2294    ShowKey {
2295        /// Path to identity file (default: `~/.age/tsafe-<profile>.txt`).
2296        #[arg(long)]
2297        identity: Option<String>,
2298    },
2299    /// Reconcile vault recipients with `.tsafe/team-keys.json`.
2300    ///
2301    /// Adds any new members found in the keys file. Removes members no longer
2302    /// listed. Re-keys the vault if the member list changed.
2303    ///
2304    #[command(after_help = "Examples:\n  tsafe team sync-keys --identity ~/.age/key.txt")]
2305    SyncKeys {
2306        /// Path to your age identity file (required for re-wrapping the DEK).
2307        #[arg(long)]
2308        identity: String,
2309        /// Path to team-keys.json (auto-detected if omitted).
2310        #[arg(long)]
2311        keys_file: Option<String>,
2312    },
2313}
2314
2315#[derive(Subcommand)]
2316pub enum BiometricAction {
2317    /// Store the vault password in the OS credential store (same as accepting quick unlock after `tsafe init`).
2318    Enable,
2319    /// Remove the vault password from the OS credential store.
2320    Disable,
2321    /// Check if biometric/keyring unlock is configured for this profile.
2322    Status,
2323    /// Re-enroll biometric/keyring unlock after a stale-credential error.
2324    ///
2325    /// Use this when `tsafe` reports "stale biometric credential" — for example after
2326    /// rotating the vault password (`tsafe rotate`) or after enrolling a new fingerprint.
2327    /// This is equivalent to `tsafe biometric disable` followed by `tsafe biometric enable`
2328    /// but makes the recovery intent explicit and prints a confirmation.
2329    #[command(name = "re-enroll")]
2330    ReEnroll,
2331}
2332
2333#[cfg(test)]
2334mod tests {
2335    use super::*;
2336    use clap::CommandFactory;
2337
2338    fn has_subcommand(command: &clap::Command, name: &str) -> bool {
2339        command
2340            .get_subcommands()
2341            .any(|subcommand| subcommand.get_name() == name)
2342    }
2343
2344    #[test]
2345    fn root_command_visibility_matches_feature_gates() {
2346        let command = Cli::command();
2347
2348        assert!(has_subcommand(&command, "tooling"));
2349        assert_eq!(has_subcommand(&command, "ui"), cfg!(feature = "tui"));
2350        assert_eq!(
2351            has_subcommand(&command, "kv-pull"),
2352            cfg!(feature = "akv-pull")
2353        );
2354        assert_eq!(
2355            has_subcommand(&command, "aws-pull"),
2356            cfg!(feature = "cloud-pull-aws")
2357        );
2358        assert_eq!(
2359            has_subcommand(&command, "gcp-pull"),
2360            cfg!(feature = "cloud-pull-gcp")
2361        );
2362        assert_eq!(
2363            has_subcommand(&command, "gcp-push"),
2364            cfg!(feature = "cloud-pull-gcp")
2365        );
2366        assert_eq!(
2367            has_subcommand(&command, "ssm-pull"),
2368            cfg!(feature = "cloud-pull-aws")
2369        );
2370        assert_eq!(
2371            has_subcommand(&command, "aws-push"),
2372            cfg!(feature = "cloud-pull-aws")
2373        );
2374        assert_eq!(
2375            has_subcommand(&command, "ssm-push"),
2376            cfg!(feature = "cloud-pull-aws")
2377        );
2378        assert_eq!(
2379            has_subcommand(&command, "vault-pull"),
2380            cfg!(feature = "cloud-pull-vault")
2381        );
2382        assert_eq!(
2383            has_subcommand(&command, "op-pull"),
2384            cfg!(feature = "cloud-pull-1password")
2385        );
2386        assert_eq!(
2387            has_subcommand(&command, "pull"),
2388            cfg!(feature = "multi-pull")
2389        );
2390        assert_eq!(
2391            has_subcommand(&command, "share-once"),
2392            cfg!(feature = "ots-sharing")
2393        );
2394        assert_eq!(
2395            has_subcommand(&command, "receive-once"),
2396            cfg!(feature = "ots-sharing")
2397        );
2398        assert_eq!(
2399            has_subcommand(&command, "browser-profile"),
2400            cfg!(feature = "browser")
2401        );
2402        assert_eq!(
2403            has_subcommand(&command, "browser-native-host"),
2404            cfg!(feature = "nativehost")
2405        );
2406        assert_eq!(has_subcommand(&command, "ssh-add"), cfg!(feature = "ssh"));
2407        assert_eq!(
2408            has_subcommand(&command, "ssh-import"),
2409            cfg!(feature = "ssh")
2410        );
2411        assert_eq!(
2412            has_subcommand(&command, "plugin"),
2413            cfg!(feature = "plugins")
2414        );
2415        assert_eq!(
2416            has_subcommand(&command, "hook-install"),
2417            cfg!(feature = "git-helpers")
2418        );
2419        assert_eq!(
2420            has_subcommand(&command, "git"),
2421            cfg!(feature = "git-helpers")
2422        );
2423        assert_eq!(
2424            has_subcommand(&command, "sync"),
2425            cfg!(feature = "git-helpers")
2426        );
2427        assert_eq!(
2428            has_subcommand(&command, "credential-helper"),
2429            cfg!(feature = "git-helpers")
2430        );
2431        assert_eq!(
2432            has_subcommand(&command, "biometric"),
2433            cfg!(feature = "biometric")
2434        );
2435        assert_eq!(
2436            has_subcommand(&command, "team"),
2437            cfg!(feature = "team-core")
2438        );
2439        assert_eq!(has_subcommand(&command, "agent"), cfg!(feature = "agent"));
2440    }
2441
2442    #[test]
2443    fn tooling_suggest_parses_inventory_request() {
2444        let cli = Cli::try_parse_from([
2445            "tsafe",
2446            "tooling",
2447            "suggest",
2448            "--root",
2449            ".",
2450            "--namespace",
2451            "databricks/athn_dev/",
2452            "--section",
2453            "ci-cd-spn",
2454            "--key",
2455            "ci_secret",
2456            "--purpose",
2457            "SPN secret",
2458            "--consumer",
2459            "ADO service connection athn-dev-sc",
2460            "--rotation",
2461            "365d KV policy",
2462            "--reason",
2463            "terraform deployment",
2464            "--apply",
2465        ])
2466        .unwrap();
2467
2468        match cli.command {
2469            Commands::Tooling {
2470                action:
2471                    ToolingAction::Suggest {
2472                        root,
2473                        namespace,
2474                        section,
2475                        key,
2476                        purpose,
2477                        consumer,
2478                        rotation,
2479                        apply,
2480                        reason,
2481                    },
2482            } => {
2483                assert_eq!(root, std::path::PathBuf::from("."));
2484                assert_eq!(namespace, "databricks/athn_dev/");
2485                assert_eq!(section.as_deref(), Some("ci-cd-spn"));
2486                assert_eq!(key, "ci_secret");
2487                assert_eq!(purpose, "SPN secret");
2488                assert_eq!(consumer, "ADO service connection athn-dev-sc");
2489                assert_eq!(rotation, "365d KV policy");
2490                assert_eq!(reason, "terraform deployment");
2491                assert!(apply);
2492            }
2493            _ => panic!("expected tooling suggest action"),
2494        }
2495    }
2496
2497    #[test]
2498    #[cfg(feature = "mcp")]
2499    fn mcp_config_parses_bound_contract_flags() {
2500        let cli = Cli::try_parse_from([
2501            "tsafe",
2502            "mcp",
2503            "config",
2504            "codex",
2505            "--name",
2506            "tsafe-cordance",
2507            "--profile",
2508            "ops",
2509            "--contract",
2510            "cordance-diagnostics",
2511            "--workdir",
2512            "C:\\Users\\0ryant\\prj\\cordance",
2513        ])
2514        .unwrap();
2515
2516        assert_eq!(cli.profile.as_deref(), Some("ops"));
2517        match cli.command {
2518            Commands::Mcp {
2519                action:
2520                    McpCliAction::Config {
2521                        host,
2522                        name,
2523                        contract,
2524                        workdir,
2525                    },
2526            } => {
2527                assert_eq!(host, "codex");
2528                assert_eq!(name.as_deref(), Some("tsafe-cordance"));
2529                assert_eq!(contract.as_deref(), Some("cordance-diagnostics"));
2530                assert_eq!(workdir.as_deref(), Some("C:\\Users\\0ryant\\prj\\cordance"));
2531            }
2532            _ => panic!("expected mcp config action"),
2533        }
2534    }
2535
2536    #[test]
2537    #[cfg(feature = "mcp")]
2538    fn mcp_serve_parses_profile_contract_and_workdir_after_action() {
2539        let cli = Cli::try_parse_from([
2540            "tsafe",
2541            "mcp",
2542            "serve",
2543            "--profile",
2544            "ops",
2545            "--contract",
2546            "cordance-diagnostics",
2547            "--workdir",
2548            "C:\\Users\\0ryant\\prj\\cordance",
2549        ])
2550        .unwrap();
2551
2552        assert_eq!(cli.profile.as_deref(), Some("ops"));
2553        match cli.command {
2554            Commands::Mcp {
2555                action:
2556                    McpCliAction::Serve {
2557                        contract, workdir, ..
2558                    },
2559            } => {
2560                assert_eq!(contract.as_deref(), Some("cordance-diagnostics"));
2561                assert_eq!(workdir.as_deref(), Some("C:\\Users\\0ryant\\prj\\cordance"));
2562            }
2563            _ => panic!("expected mcp serve action"),
2564        }
2565    }
2566
2567    #[test]
2568    #[cfg(feature = "mcp")]
2569    fn mcp_doctor_parses_denial_contract_workdir_and_json() {
2570        let cli = Cli::try_parse_from([
2571            "tsafe",
2572            "mcp",
2573            "doctor",
2574            "--profile",
2575            "ops",
2576            "--code",
2577            "missing_contract",
2578            "--contract",
2579            "cordance-diagnostics",
2580            "--workdir",
2581            "C:\\Users\\0ryant\\prj\\cordance",
2582            "--json",
2583        ])
2584        .unwrap();
2585
2586        assert_eq!(cli.profile.as_deref(), Some("ops"));
2587        match cli.command {
2588            Commands::Mcp {
2589                action:
2590                    McpCliAction::Doctor {
2591                        code,
2592                        contract,
2593                        workdir,
2594                        receipt_id,
2595                        json,
2596                    },
2597            } => {
2598                assert_eq!(code, "missing_contract");
2599                assert_eq!(contract, "cordance-diagnostics");
2600                assert_eq!(workdir, "C:\\Users\\0ryant\\prj\\cordance");
2601                assert_eq!(receipt_id, None);
2602                assert!(json);
2603            }
2604            _ => panic!("expected mcp doctor action"),
2605        }
2606    }
2607
2608    #[test]
2609    #[cfg(feature = "mcp")]
2610    fn mcp_help_documents_bound_serve_and_codex_config_forms() {
2611        let mut command = Cli::command();
2612        let mcp = command.find_subcommand_mut("mcp").unwrap();
2613        let mut help = Vec::new();
2614        mcp.write_long_help(&mut help).unwrap();
2615        let help = String::from_utf8(help).unwrap();
2616
2617        assert!(help
2618            .contains("tsafe mcp serve --profile ops --contract cordance-diagnostics --workdir ."));
2619        assert!(help.contains(
2620            "tsafe mcp config codex --name tsafe-cordance --profile ops --contract cordance-diagnostics --workdir ."
2621        ));
2622        assert!(help.contains(
2623            "tsafe mcp doctor --code missing_contract --contract cordance-diagnostics --workdir . --json"
2624        ));
2625    }
2626}
2627
2628#[derive(Subcommand)]
2629pub enum PolicyAction {
2630    /// Set a rotation policy on a secret.
2631    ///
2632    #[command(after_help = "Examples:\n  tsafe policy set DB_PASSWORD --rotate-every 90d")]
2633    Set {
2634        /// Secret key.
2635        key: String,
2636        /// Rotation interval (e.g. 90d, 30d, 7d).
2637        #[arg(long)]
2638        rotate_every: String,
2639    },
2640    /// Remove the rotation policy from a secret.
2641    ///
2642    #[command(after_help = "Examples:\n  tsafe policy remove DB_PASSWORD")]
2643    Remove {
2644        /// Secret key.
2645        key: String,
2646    },
2647}
2648
2649/// Attestation subcommands — Phases 3 and 4 of the algol→tsafe migration.
2650///
2651/// - `scan` (Phase 3) — secret + env-authority scanner.
2652/// - `run`  (Phase 4) — env-injection enforcement harness; emits
2653///   RunEvidence + CloudEvents audit trail.
2654#[derive(Subcommand)]
2655pub enum AttestAction {
2656    /// Scan a repo for committed secrets and env-authority signals.
2657    ///
2658    /// Defaults to scanning the current directory. The scanner is the
2659    /// Phase 3 port of the algol Phase 2.1 scanner; see crate docs for
2660    /// the full provenance trail.
2661    ///
2662    /// Scanner P/R on synthetic N=100 corpus: 1.000 / 1.000. Real-world
2663    /// rates may differ. See
2664    /// `ecosystem-catalog/portfolio-algol-tsafe-phase2-1-precision-recovery-2026-05-21.md`
2665    /// for the verdict.
2666    #[command(
2667        after_help = "Examples:\n  tsafe attest scan\n  tsafe attest scan ./repo\n  tsafe attest scan --format json -o scan.json\n  tsafe attest scan --strict   # exit code 2 if any secret-class finding\n\nHonest disclosure: synthetic N=100 corpus (Phase 2.1); real repos may differ. Wire format: `tsafe.scan.v1` (BLAKE3 fingerprints) since Phase 4."
2668    )]
2669    Scan {
2670        /// Repo path to scan (defaults to current directory).
2671        path: Option<std::path::PathBuf>,
2672        /// Exit with code 2 if any secret-class finding is present.
2673        ///
2674        /// Secret-class kinds: `ENV_FILE`, `HARDCODED_SECRET`, `PRIVATE_KEY`.
2675        /// `SECRET_PLACEHOLDER` (Phase 2.1 — placeholder/comment context)
2676        /// is NOT counted as a secret finding.
2677        #[arg(long)]
2678        strict: bool,
2679        /// Additional paths to scan (repeatable). Findings from all paths
2680        /// are merged into a single report.
2681        #[arg(long = "extra-paths", value_name = "PATH", num_args = 0..)]
2682        extra_paths: Vec<std::path::PathBuf>,
2683        /// Output format.
2684        #[arg(long, default_value = "text")]
2685        format: AttestScanFormat,
2686        /// Write the report to this file (otherwise printed to stdout).
2687        #[arg(short, long, value_name = "FILE")]
2688        output: Option<std::path::PathBuf>,
2689    },
2690    /// Run a command under env-injection enforcement.
2691    ///
2692    /// Loads an `AttestContract`, strips the parent env, injects declared
2693    /// variables from the configured sources, spawns the command, and
2694    /// emits a `RunEvidence` artifact + CloudEvents audit-trail entry.
2695    ///
2696    /// Phase 4 wire formats: `tsafe.run.v1` RunEvidence,
2697    /// `tsafe.audit_event.v1` audit events, BLAKE3 fingerprints.
2698    /// Legacy `algol.*` schemas + SHA-256 hashes are accepted on parse
2699    /// during the v1.x compat window.
2700    #[command(
2701        after_help = "Examples:\n  tsafe attest run -- echo hello\n  tsafe attest run --contract tsafe.contract.yaml -- npm test\n  tsafe attest run --emit-run-evidence run.json --audit-trail audit.ndjson -- true\n  tsafe attest run --no-sign -- npm test  # opt out of Ed25519 attestation\n\nHonest disclosure: Phase 4 lift from algol/src/enforce.rs @ 6956cfd. Linux + macOS first; Windows is experimental (env_clear() is portable, but PATH semantics differ — see ec Phase 0 audit §3 Option (c)).\n\nPhase 5 (this version) adds Ed25519 authorship signatures by default: the producer's keyring entry (or an auto-generated one with stderr warning) signs the emitted RunEvidence under the `tsafe.run_evidence.v1` domain tag. Pubkey trust is TOFU; verify operator-pinned pubkeys out of band (`tsafe attest verify --pubkey <key>`).\n\nThe child process inherits ONLY the explicitly declared env vars + the safe baseline. There is no escape hatch — if a required env is unresolvable, the run aborts before spawn."
2702    )]
2703    Run {
2704        /// Path to the `AttestContract` to enforce. Defaults to
2705        /// `tsafe.contract.yaml` in the current directory.
2706        #[arg(long, value_name = "PATH")]
2707        contract: Option<std::path::PathBuf>,
2708        /// Path to write the `RunEvidence` artifact. Defaults to
2709        /// `tsafe-run.json` in the current directory.
2710        #[arg(long = "emit-run-evidence", value_name = "PATH")]
2711        emit_run_evidence: Option<std::path::PathBuf>,
2712        /// Path to the audit-trail NDJSON log. Each line is a CloudEvents
2713        /// envelope. Defaults to `tsafe-audit-events.ndjson`.
2714        #[arg(long = "audit-trail", value_name = "PATH")]
2715        audit_trail: Option<std::path::PathBuf>,
2716        /// Allow the supplied command to differ from the contract's
2717        /// `command` field. Disabled by default; useful only for testing.
2718        #[arg(long = "allow-command-override")]
2719        allow_command_override: bool,
2720        /// Phase 5: sign the emitted `RunEvidence` with the per-profile
2721        /// Ed25519 keyring entry. Default is ON — if no key is
2722        /// provisioned, one is auto-generated on first use with a stderr
2723        /// warning (`tsafe attest key generate` is the explicit form).
2724        /// Use `--no-sign` to opt out.
2725        #[arg(long = "sign-run-evidence", overrides_with = "no_sign")]
2726        sign_run_evidence: bool,
2727        /// Phase 5: explicitly disable Ed25519 signing of the emitted
2728        /// `RunEvidence`. Overrides the default-on `--sign-run-evidence`
2729        /// behaviour.
2730        #[arg(long = "no-sign", overrides_with = "sign_run_evidence")]
2731        no_sign: bool,
2732        /// Command to execute under enforcement. Pass after `--`.
2733        #[arg(last = true, required = true)]
2734        command: Vec<String>,
2735    },
2736    /// Verify the Ed25519 signature on a `RunEvidence` artifact.
2737    ///
2738    /// Phase 5 (this version) emits artifacts with a `signature` field
2739    /// carrying an Ed25519 signature over the canonical encoding of every
2740    /// other field. `verify` re-derives the canonical bytes, prepends
2741    /// the `tsafe.run_evidence.v1` domain tag, and checks the signature.
2742    ///
2743    /// Without `--pubkey`, the embedded pubkey on the artifact is used
2744    /// (TOFU); a stderr warning is emitted reminding the operator to pin
2745    /// the pubkey out of band. With `--pubkey <base64url>`, the supplied
2746    /// key takes precedence.
2747    ///
2748    /// Exit codes:
2749    /// - `0` — signature is valid
2750    /// - `5` — artifact has no `signature` field
2751    /// - `6` — signature verification failed (tampered or wrong key)
2752    /// - other — internal error
2753    #[command(
2754        after_help = "Examples:\n  tsafe attest verify ./tsafe-run.json\n  tsafe attest verify ./tsafe-run.json --pubkey AAAAC3NzaC1lZDI1NTE5...\n\nHonest disclosure: signatures bind authorship to the artifact's content at sign time. They do NOT establish trust in the producer — that requires out-of-band pubkey verification. The default TOFU path is convenience-tier, not security-tier."
2755    )]
2756    Verify {
2757        /// Path to the `RunEvidence` JSON artifact to verify.
2758        evidence: std::path::PathBuf,
2759        /// Operator-supplied verifying key (base64url-encoded, no
2760        /// padding, 32 bytes after decoding). If omitted, the pubkey
2761        /// embedded in the artifact is used (TOFU; see honest
2762        /// disclosure on `--help`).
2763        #[arg(long, value_name = "BASE64URL")]
2764        pubkey: Option<String>,
2765    },
2766    /// Manage the `tsafe-attest` Ed25519 signing key for a profile.
2767    ///
2768    /// The key lives in the OS credential store under the per-profile
2769    /// `tsafe-attest-signing-key` account name. `tsafe attest run`
2770    /// signs emitted `RunEvidence` artifacts with this key by default;
2771    /// `tsafe attest verify` can use the embedded pubkey or an
2772    /// operator-supplied one.
2773    Key {
2774        #[command(subcommand)]
2775        action: AttestKeyAction,
2776    },
2777}
2778
2779/// Subcommands of `tsafe attest key` — operator-facing keyring
2780/// management for the Phase 5 signing flow.
2781#[derive(Subcommand)]
2782pub enum AttestKeyAction {
2783    /// Generate a fresh Ed25519 keypair and store the signing half in
2784    /// the OS credential store under the active profile.
2785    ///
2786    /// Refuses to overwrite an existing entry unless `--force` is
2787    /// passed. Prints the corresponding base64url-encoded pubkey to
2788    /// stdout so operators can pin it out of band.
2789    Generate {
2790        /// Overwrite any existing signing key for this profile.
2791        #[arg(long)]
2792        force: bool,
2793    },
2794    /// Print the base64url-encoded verifying key (pubkey) for the
2795    /// active profile.
2796    ///
2797    /// Fails non-zero if no signing key is provisioned for this
2798    /// profile.
2799    Pubkey,
2800    /// Remove the signing key for the active profile from the OS
2801    /// credential store. Subsequent `tsafe attest run` calls auto-
2802    /// generate a new key on first use (with stderr warning) unless
2803    /// `--no-sign` is passed.
2804    Remove,
2805}
2806
2807/// Output format for `tsafe attest scan`.
2808#[derive(Clone, Copy, Debug, ValueEnum)]
2809pub enum AttestScanFormat {
2810    /// Pretty-printed JSON `ScanReport` artifact.
2811    Json,
2812    /// Human-readable markdown summary + finding table.
2813    Markdown,
2814    /// Compact text summary (default).
2815    Text,
2816}