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