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    /// Note: secret values of 2 characters or fewer are not redacted, to avoid
1209    /// shredding unrelated text. Do not rely on `redact` to mask very short
1210    /// secrets (e.g. 1–2 character PINs).
1211    #[command(
1212        after_help = "Examples:\n  cargo test 2>&1 | tsafe redact\n  tsafe exec -- myapp | tsafe redact"
1213    )]
1214    Redact,
1215
1216    /// Manage the repo-local tsafe tooling inventory.
1217    ///
1218    /// The inventory lives under `.tsafe/tooling/` and records secret slots,
1219    /// consumers, and rotation expectations. It never stores secret values.
1220    #[command(
1221        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"
1222    )]
1223    Tooling {
1224        #[command(subcommand)]
1225        action: ToolingAction,
1226    },
1227
1228    /// Show the active build profile label and compile-time capabilities.
1229    ///
1230    /// This reports the compiled truth for the running `tsafe` binary only.
1231    /// Companion runtimes such as `tsafe-agent` have separate install and release truth.
1232    #[command(name = "build-info", after_help = BUILD_INFO_AFTER_HELP)]
1233    BuildInfo {
1234        /// Emit machine-readable JSON output.
1235        #[arg(long)]
1236        json: bool,
1237    },
1238
1239    #[cfg(feature = "plugins")]
1240    /// Run a tool with its required vault secrets injected automatically.
1241    ///
1242    /// Each plugin knows which vault keys map to which environment variables for the
1243    /// named tool.  Run `tsafe plugin` (no args) to list available plugins.
1244    ///
1245    /// Missing optional keys are silently skipped; missing required keys abort with an error.
1246    ///
1247    #[command(
1248        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)"
1249    )]
1250    Plugin {
1251        /// Tool name (e.g. gh, aws, az, docker, npm, pypi, terraform). Omit to list.
1252        tool: Option<String>,
1253        /// Arguments to pass to the tool.
1254        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
1255        args: Vec<String>,
1256    },
1257
1258    /// Act as a git credential helper (install/get/store/erase protocol).
1259    ///
1260    /// Run `tsafe credential-helper install` once to configure git to use tsafe
1261    /// as the credential store. Git will then call `tsafe credential-helper get`,
1262    /// `store`, or `erase` automatically.
1263    ///
1264    /// In `get` mode, reads protocol/host from stdin and returns username/password
1265    /// from the vault. Keys are matched by `<HOST>_USERNAME` / `<HOST>_PASSWORD`
1266    /// pattern, or by tags `host=<HOST>`.
1267    #[cfg(feature = "git-helpers")]
1268    #[command(
1269        name = "credential-helper",
1270        after_help = "Examples:\n  tsafe credential-helper install\n  tsafe credential-helper install --global\n  git credential fill  # (git calls tsafe automatically after install)"
1271    )]
1272    CredentialHelper {
1273        /// Git credential helper action.
1274        #[arg(value_enum, default_value = "install")]
1275        action: CredentialHelperOperation,
1276        /// For `install`: configure at the --global level (user-wide).
1277        /// By default, configures the local repository git config.
1278        #[arg(long)]
1279        global: bool,
1280    },
1281
1282    /// Collaboration service commands (team membership, DEK delivery, recovery).
1283    ///
1284    /// Scaffolding only in Tranche 2 — no network calls are made.
1285    /// Enable with `--features collab`.
1286    ///
1287    #[command(
1288        after_help = "Examples:\n  tsafe collab join <team-id>\n  tsafe collab status <team-id>"
1289    )]
1290    #[cfg(feature = "collab")]
1291    Collab {
1292        #[command(subcommand)]
1293        action: CollabAction,
1294    },
1295
1296    /// Add an SSH key from the vault to the running ssh-agent.
1297    ///
1298    /// The key is passed via stdin to `ssh-add -` so it never touches disk.
1299    ///
1300    #[command(after_help = "Examples:\n  tsafe ssh-add SSH_KEY\n  tsafe ssh-add id_ed25519")]
1301    #[cfg(feature = "ssh")]
1302    SshAdd {
1303        /// Vault key name containing the SSH private key.
1304        key: String,
1305    },
1306
1307    /// Import an SSH private key file into the vault.
1308    ///
1309    #[command(
1310        after_help = "Examples:\n  tsafe ssh-import ~/.ssh/id_ed25519\n  tsafe ssh-import ~/.ssh/id_rsa --name SSH_RSA_KEY"
1311    )]
1312    #[cfg(feature = "ssh")]
1313    SshImport {
1314        /// Path to the SSH private key file.
1315        path: String,
1316        /// Vault key name to store under (defaults to filename).
1317        #[arg(long)]
1318        name: Option<String>,
1319        /// Attach tags as KEY=VALUE pairs (repeatable).
1320        #[arg(short, long = "tag", value_name = "KEY=VALUE")]
1321        tags: Vec<String>,
1322    },
1323
1324    /// SSH key inventory and operations.
1325    ///
1326    /// Subcommands: list, public-key, generate, config, agent
1327    ///
1328    #[command(
1329        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)"
1330    )]
1331    #[cfg(feature = "ssh")]
1332    Ssh {
1333        #[command(subcommand)]
1334        action: SshAction,
1335    },
1336
1337    /// List namespaces or copy/move all keys under one prefix to another.
1338    ///
1339    /// A namespace is any key-prefix of the form `<name>/KEY`. They are not stored
1340    /// explicitly — this command introspects the key names in the vault.
1341    ///
1342    #[command(
1343        after_help = "Examples:\n  tsafe ns list\n  tsafe ns copy prod staging\n  tsafe ns move oldapp newapp --force"
1344    )]
1345    Ns {
1346        #[command(subcommand)]
1347        action: NsAction,
1348    },
1349
1350    /// Pull secrets from all sources defined in `.tsafe.yml`.
1351    ///
1352    /// Searches upward from the current directory for `.tsafe.yml` or `.tsafe.json`
1353    /// and executes each pull source in manifest order (sequential; see ADR-012).
1354    ///
1355    /// Use --dry-run to preview which sources would be invoked without making any
1356    /// live API calls. Note: collision detection is not available in dry-run mode —
1357    /// detecting key conflicts requires fetching keys from each provider.
1358    ///
1359    /// Use --source to narrow execution to one or more named sources. Sources are
1360    /// named with the `name` field in the manifest. Multiple --source flags are OR'd.
1361    ///
1362    #[command(
1363        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"
1364    )]
1365    #[cfg(feature = "multi-pull")]
1366    Pull {
1367        /// Path to config file (auto-detected if omitted).
1368        #[arg(long)]
1369        config: Option<String>,
1370        /// Overwrite all existing secrets (overrides per-source settings).
1371        #[arg(long)]
1372        overwrite: bool,
1373        /// Failure handling mode for source errors in multi-source pull.
1374        #[arg(long, value_enum, default_value = "fail-all")]
1375        on_error: PullOnError,
1376        /// Preview which sources would be invoked without making any live API calls.
1377        /// Collision detection is not available in dry-run mode.
1378        #[arg(long)]
1379        dry_run: bool,
1380        /// Narrow execution to sources with this `name` label (repeatable).
1381        /// Sources without a `name` field are excluded when any --source filter is active.
1382        #[arg(long = "source", value_name = "LABEL", action = clap::ArgAction::Append)]
1383        sources: Vec<String>,
1384    },
1385
1386    /// Push local vault secrets to all destinations defined in `.tsafe.yml`.
1387    ///
1388    /// Searches upward from the current directory for `.tsafe.yml` or `.tsafe.json`
1389    /// and executes each push destination in manifest order (sequential; see ADR-030).
1390    ///
1391    /// Use --dry-run to preview which destinations would be invoked without making
1392    /// any live API calls or writes.
1393    ///
1394    /// Use --source to narrow execution to one or more named destinations. Destinations
1395    /// are named with the `name` field in the manifest. Multiple --source flags are OR'd.
1396    ///
1397    /// A pre-flight diff is shown before any writes. Secret values are never printed —
1398    /// only key names and 12-char SHA-256 hash prefixes are shown (ADR-030).
1399    ///
1400    #[command(
1401        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"
1402    )]
1403    #[cfg(feature = "akv-pull")]
1404    Push {
1405        /// Path to config file (auto-detected if omitted).
1406        #[arg(long, value_name = "PATH")]
1407        config: Option<std::path::PathBuf>,
1408        /// Narrow execution to destinations with this `name` label (repeatable).
1409        /// Destinations without a `name` field are excluded when any --source filter is active.
1410        #[arg(long = "source", value_name = "LABEL", action = clap::ArgAction::Append)]
1411        source: Vec<String>,
1412        /// Show the diff without writing anything (always exits 0).
1413        #[arg(long)]
1414        dry_run: bool,
1415        /// Skip confirmation prompts (required in non-TTY / CI contexts).
1416        #[arg(long)]
1417        yes: bool,
1418        /// Also delete remote secrets that are absent locally within each destination's scope.
1419        /// Off by default — opt-in to avoid accidental mass deletion (ADR-030).
1420        #[arg(long)]
1421        delete_missing: bool,
1422        /// Failure handling mode for destination errors.
1423        #[arg(long, value_enum, default_value = "fail-all")]
1424        on_error: PushOnError,
1425    },
1426
1427    /// Synchronise a vault file with a git remote.
1428    ///
1429    /// Fetches the remote branch, performs a per-key three-way merge between
1430    /// the common ancestor, the local vault, and the remote vault, then commits
1431    /// and pushes the merged result.
1432    ///
1433    /// Conflicts (both sides edited the same key) are resolved by last-write-wins
1434    /// using the secret's `updated_at` timestamp. Conflicts are reported but do
1435    /// not block the sync.
1436    ///
1437    #[command(
1438        after_help = "Examples:\n  tsafe sync\n  tsafe sync --remote origin --branch main\n  tsafe sync --dry-run"
1439    )]
1440    #[cfg(feature = "git-helpers")]
1441    #[command(name = "sync")]
1442    Sync {
1443        /// Git remote name.
1444        #[arg(long, default_value = "origin")]
1445        remote: String,
1446        /// Git branch to sync with.
1447        #[arg(long, default_value = "main")]
1448        branch: String,
1449        /// Vault file path relative to repo root (auto-detected if omitted).
1450        #[arg(long)]
1451        file: Option<String>,
1452        /// Show what would change without modifying anything.
1453        #[arg(long)]
1454        dry_run: bool,
1455    },
1456
1457    /// Manage team vaults (multi-recipient age encryption).
1458    ///
1459    /// Team vaults use X25519 (age) keypairs so multiple people can decrypt
1460    /// the same vault without sharing a password.
1461    ///
1462    #[command(
1463        after_help = "Examples:\n  tsafe team init --identity ~/.age/key.txt\n  tsafe team add-member age1qyqszqgpqyqszqgpqyqszqgpqyqszqgp...\n  tsafe team members"
1464    )]
1465    #[cfg(feature = "team-core")]
1466    Team {
1467        #[command(subcommand)]
1468        action: TeamAction,
1469    },
1470
1471    /// Enable or disable biometric / keyring unlock for the current profile.
1472    ///
1473    /// When enabled, the vault password is stored in the OS credential store
1474    /// (macOS Keychain, Windows Credential Manager, Linux Secret Service).
1475    /// The credential store is itself protected by biometric or PIN.
1476    ///
1477    /// After `tsafe init`, the CLI may offer the same setup interactively ("quick unlock").
1478    /// You can always run `biometric enable` later if you skipped it.
1479    ///
1480    #[command(
1481        after_help = "Examples:\n  tsafe biometric enable\n  tsafe biometric disable\n  tsafe biometric status"
1482    )]
1483    #[cfg(feature = "biometric")]
1484    Biometric {
1485        #[command(subcommand)]
1486        action: BiometricAction,
1487    },
1488
1489    /// Manage the per-process vault unlock agent.
1490    ///
1491    /// `tsafe agent unlock` prints terminal approval text, may show an OS notification,
1492    /// then prompts for the vault password once and starts a background agent that holds
1493    /// it in memory.  The token it prints must be set in the calling process's environment
1494    /// as `TSAFE_AGENT_SOCK` — all subsequent `tsafe` invocations that inherit that
1495    /// env var will be granted vault access without re-entering the password.
1496    ///
1497    /// Requests must present the session token and come from a live OS-reported peer
1498    /// PID; the unlock process PID is recorded for audit/context, not as the only
1499    /// process allowed to use the session.
1500    ///
1501    #[command(
1502        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"
1503    )]
1504    #[cfg(feature = "agent")]
1505    Agent {
1506        #[command(subcommand)]
1507        action: AgentAction,
1508    },
1509
1510    /// First-party MCP server — exposes tsafe to MCP-aware hosts (Claude
1511    /// Desktop, Cursor, Continue, Windsurf, Codex) over stdio JSON-RPC.
1512    ///
1513    /// Each `tsafe-mcp` process binds to exactly one profile; request-time
1514    /// profile or scope widening is rejected. See ADR-006
1515    /// (docs/architecture/ADR-006-mcp-server.md) and design doc §5.2 for the
1516    /// full surface.
1517    ///
1518    /// `tsafe mcp install <host>` writes the per-host MCP config file. The
1519    /// resulting host launch shells out to `tsafe-mcp serve` directly, not
1520    /// through this CLI.
1521    #[command(
1522        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"
1523    )]
1524    #[cfg(feature = "mcp")]
1525    Mcp {
1526        #[command(subcommand)]
1527        action: McpCliAction,
1528    },
1529
1530    /// Run a git command with vault credentials injected automatically.
1531    ///
1532    /// Opens the vault, reads `ADO_PAT` (or the key named by TSAFE_GIT_PAT_KEY),
1533    /// and injects it as a git `http.extraHeader` so HTTPS remotes authenticate
1534    /// without embedding tokens in URLs.
1535    ///
1536    /// Detects the nearest `.git` directory automatically — no repo flags needed.
1537    /// Exits with git's exit code.
1538    ///
1539    /// Override the PAT key name:  $env:TSAFE_GIT_PAT_KEY = "MY_GIT_PAT"
1540    ///
1541    #[command(
1542        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"
1543    )]
1544    #[cfg(feature = "git-helpers")]
1545    #[command(name = "git")]
1546    Git {
1547        /// git subcommand and its arguments (e.g. `push ado main`).
1548        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
1549        args: Vec<String>,
1550    },
1551
1552    /// Run the tsafe attestation scanner over a repo.
1553    ///
1554    /// The scanner is the Phase 3 port of algol's secret-detection +
1555    /// env-authority scanner. It walks a repo tree, emits a JSON / markdown
1556    /// / text report, and writes a CloudEvents `algol.scan.v1` envelope
1557    /// for the audit trail. Default-on per Phase 3 of the algol→tsafe
1558    /// migration — no `--experimental-scan` flag needed.
1559    ///
1560    /// Honest disclosure: scanner P/R = 1.000/1.000 on the synthetic N=100
1561    /// corpus at `tsafe/benchmarks/scanner-corpus/v1/` (see
1562    /// `ecosystem-catalog/portfolio-algol-tsafe-phase2-1-precision-recovery-2026-05-21.md`
1563    /// for confidence intervals). Real-world false-positive / negative
1564    /// rates may differ; report issues to the tsafe repo. Scanner uses
1565    /// BLAKE3 content fingerprints per ec ADR-0003 (wire-format change
1566    /// from algol's SHA-256 — see CHANGELOG).
1567    #[command(
1568        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"
1569    )]
1570    Attest {
1571        #[command(subcommand)]
1572        action: AttestAction,
1573    },
1574}
1575
1576/// Collab subcommands — scaffolding for D3.5 (Tranche 2).
1577/// No network calls in this release; real implementation is Tranche 3+.
1578#[derive(Subcommand)]
1579#[cfg(feature = "collab")]
1580pub enum CollabAction {
1581    /// Join a collaboration team (scaffolding — prints status and exits 0).
1582    Join {
1583        /// Team ID to join.
1584        team_id: String,
1585    },
1586    /// Show collaboration status for a team (scaffolding — prints status and exits 0).
1587    Status {
1588        /// Team ID to query.
1589        team_id: String,
1590    },
1591}
1592
1593#[derive(Subcommand)]
1594pub enum SnapshotAction {
1595    /// List snapshots for the current profile.
1596    List,
1597    /// Restore the most-recent snapshot, overwriting the current vault.
1598    Restore,
1599}
1600
1601#[derive(Subcommand)]
1602pub enum MobileAction {
1603    /// Start or accept a desktop-to-mobile enrollment flow.
1604    #[command(after_help = MOBILE_ENROLL_AFTER_HELP)]
1605    Enroll {
1606        #[command(subcommand)]
1607        action: MobileEnrollAction,
1608    },
1609}
1610
1611#[derive(Subcommand)]
1612pub enum MobileEnrollAction {
1613    /// Emit a public enrollment start envelope for mobile scanning.
1614    #[command(after_help = MOBILE_ENROLL_START_AFTER_HELP)]
1615    Start {
1616        /// Repository binding as OWNER/NAME.
1617        #[arg(long, value_name = "OWNER/NAME")]
1618        repo: String,
1619        /// Git branch/ref name for the mobile pull binding.
1620        #[arg(long, default_value = "main")]
1621        branch: String,
1622        /// Path to the team membership file in the repository.
1623        #[arg(long, default_value = ".tsafe/team-keys.json")]
1624        team_keys_path: String,
1625        /// Path to the encrypted vault ciphertext in the repository.
1626        #[arg(long)]
1627        vault_path: Option<String>,
1628        /// Enrollment expiry in minutes.
1629        #[arg(long, default_value_t = 10)]
1630        ttl_minutes: i64,
1631        /// Desktop display name shown to the mobile device.
1632        #[arg(long)]
1633        display_name: Option<String>,
1634        /// Emit the enrollment envelope as pretty JSON.
1635        #[arg(long)]
1636        json: bool,
1637        /// Emit a single-frame QR payload JSON wrapper around the envelope.
1638        #[arg(long)]
1639        qr: bool,
1640    },
1641    /// Validate a mobile enrollment response, record its public recipient, and optionally rewrap the team vault.
1642    #[command(after_help = MOBILE_ENROLL_ACCEPT_AFTER_HELP)]
1643    Accept {
1644        /// Enrollment start envelope path produced by `mobile enroll start`.
1645        #[arg(long, value_name = "PATH")]
1646        start: std::path::PathBuf,
1647        /// Mobile response envelope path, or '-' to read stdin.
1648        #[arg(long, value_name = "PATH|-")]
1649        response: String,
1650        /// Team keys file to update. Defaults to the path bound in the start envelope.
1651        #[arg(long, value_name = "PATH")]
1652        team_keys: Option<std::path::PathBuf>,
1653        /// Operator age identity file used to rewrap the source-local team vault for the mobile recipient.
1654        #[arg(long, value_name = "PATH")]
1655        identity: Option<std::path::PathBuf>,
1656        /// Emit a machine-readable acceptance receipt.
1657        #[arg(long)]
1658        json: bool,
1659    },
1660}
1661
1662/// Global config.json settings (not tied to `--profile`).
1663#[derive(Subcommand)]
1664pub enum ConfigAction {
1665    /// Show config file path, default profile, exec trust settings, and password-backup settings.
1666    Show,
1667    /// 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.
1668    #[command(name = "set-backup-vault")]
1669    SetBackupVault {
1670        /// Target vault profile (`main`, `default`) or `off` to clear.
1671        target: String,
1672    },
1673    /// Persist whether normal vault opens should automatically try OS quick unlock.
1674    #[command(name = "set-auto-quick-unlock")]
1675    SetAutoQuickUnlock {
1676        /// `on` to allow automatic keychain reads, `off` to require agent / env / typed password instead.
1677        mode: ToggleSetting,
1678    },
1679    /// Persist the retry cooldown, in seconds, after an automatic quick-unlock failure.
1680    #[command(name = "set-quick-unlock-retry-cooldown")]
1681    SetQuickUnlockRetryCooldown {
1682        /// Seconds to wait before the next automatic keychain attempt. Use `0` to disable the cooldown.
1683        seconds: u64,
1684    },
1685    /// Persist the default exec trust mode.
1686    #[command(name = "set-exec-mode")]
1687    SetExecMode {
1688        /// One of: `standard`, `hardened`, `custom`.
1689        mode: ExecModeSetting,
1690    },
1691    /// Persist whether `tsafe exec` should redact child stdout/stderr by default.
1692    #[command(name = "set-exec-redact-output")]
1693    SetExecRedactOutput {
1694        /// `on` to redact child output by default, `off` to leave it raw unless `--redact-output` is passed.
1695        mode: ToggleSetting,
1696    },
1697    /// Persist the inherit strategy used when exec mode is `custom`.
1698    #[command(name = "set-exec-custom-inherit")]
1699    SetExecCustomInherit {
1700        /// One of: `full`, `minimal`, `clean`.
1701        mode: ExecCustomInheritSetting,
1702    },
1703    /// Persist whether dangerous injected env names should abort exec when mode is `custom`.
1704    #[command(name = "set-exec-custom-deny-dangerous-env")]
1705    SetExecCustomDenyDangerousEnv {
1706        /// `on` to abort, `off` to warn only.
1707        mode: ToggleSetting,
1708    },
1709    /// Add a parent environment variable name to the extra strip list for `tsafe exec`.
1710    #[command(name = "add-exec-extra-strip")]
1711    AddExecExtraStrip {
1712        /// Environment variable name, e.g. OPENAI_API_KEY.
1713        name: String,
1714    },
1715    /// Remove a parent environment variable name from the extra strip list for `tsafe exec`.
1716    #[command(name = "remove-exec-extra-strip")]
1717    RemoveExecExtraStrip {
1718        /// Environment variable name, e.g. OPENAI_API_KEY.
1719        name: String,
1720    },
1721}
1722
1723#[derive(Clone, Copy, ValueEnum)]
1724pub enum ToggleSetting {
1725    /// Enable the setting.
1726    On,
1727    /// Disable the setting.
1728    Off,
1729}
1730
1731#[derive(Clone, Copy, ValueEnum)]
1732pub enum ExecModeSetting {
1733    /// Broad compatibility: full inherited env (minus strip list), raw output, and abort on dangerous injected names by default.
1734    Standard,
1735    /// Stricter preset: minimal inherited env, redacted output, and deny dangerous injected names.
1736    Hardened,
1737    /// Use persisted custom exec trust settings from config.json.
1738    Custom,
1739}
1740
1741/// Controls which host environment variables the child process inherits.
1742#[derive(Clone, Copy, ValueEnum)]
1743pub enum ExecPresetSetting {
1744    /// Inherit only PATH and a safe core set (HOME, USER, TMPDIR, LANG, TERM, SSH_AUTH_SOCK, etc.)
1745    /// plus vault secrets. No tokens or credentials from the parent environment leak through.
1746    Minimal,
1747    /// Inherit the full parent environment minus the known-sensitive strip list. This is the
1748    /// current default behavior when no preset or inheritance flag is given.
1749    Full,
1750}
1751
1752#[derive(Clone, Copy, ValueEnum)]
1753pub enum ExecCustomInheritSetting {
1754    /// Full inherited parent env (minus strip list).
1755    Full,
1756    /// Minimal inherited env plus vault secrets.
1757    Minimal,
1758    /// No inherited parent env; only vault secrets.
1759    Clean,
1760}
1761
1762#[derive(Subcommand)]
1763pub enum ProfileAction {
1764    /// List all profiles that have an existing vault.
1765    List,
1766    /// Permanently delete a profile vault.
1767    Delete {
1768        name: String,
1769        /// Skip the confirmation prompt.
1770        #[arg(long)]
1771        force: bool,
1772    },
1773    /// Set the default profile used when -p / TSAFE_PROFILE is not specified.
1774    ///
1775    #[command(after_help = "Examples:\n  tsafe profile set-default work")]
1776    SetDefault {
1777        /// Profile name to use as the new default.
1778        name: String,
1779    },
1780    /// Rename a profile (renames the vault file and updates the default if needed).
1781    ///
1782    #[command(after_help = "Examples:\n  tsafe profile rename old new")]
1783    Rename {
1784        /// Existing profile name.
1785        from: String,
1786        /// New profile name.
1787        to: String,
1788    },
1789}
1790
1791#[derive(Clone, ValueEnum)]
1792pub enum ExportFormat {
1793    /// KEY=VALUE (posix, one per line)
1794    Env,
1795    /// export KEY="VALUE" (bash/zsh source-able)
1796    Dotenv,
1797    /// $env:KEY = "VALUE" (PowerShell source-able)
1798    Powershell,
1799    /// JSON object
1800    Json,
1801    /// ::add-mask::VALUE + KEY=VALUE (GitHub Actions GITHUB_ENV format)
1802    GithubActions,
1803    /// YAML mapping (KEY: "VALUE" per entry)
1804    Yaml,
1805    /// KEY=VALUE per line suitable for Docker --env-file (alias for env, Docker-compatible)
1806    DockerEnv,
1807    /// TOML flat top-level table (KEY = "VALUE" per entry)
1808    Toml,
1809}
1810
1811#[derive(Clone, ValueEnum)]
1812pub enum AuditExportFormat {
1813    /// JSONL (one JSON object per line, same as stored on disk)
1814    Json,
1815    /// Splunk HEC-compatible JSON events
1816    Splunk,
1817    /// CloudEvents 1.0 JSONL (application/cloudevents+json per line)
1818    CloudEvents,
1819}
1820
1821/// Actions for `tsafe audit` subcommand.
1822///
1823/// `Rotate` is a stub reserved for the audit-log rotation handler implemented
1824/// in `cmd_audit_cmd.rs` by a separate agent.  The variant is declared here so
1825/// that `cli.rs` is the single source of truth for the CLI surface.
1826#[derive(Subcommand)]
1827#[allow(dead_code)]
1828pub enum AuditAction {
1829    /// Rotate (trim) the audit log to keep only the most-recent entries.
1830    ///
1831    /// Reserved — handler implemented in `cmd_audit_cmd.rs`.
1832    #[command(
1833        after_help = "Examples:\n  tsafe audit rotate --keep 1000\n  tsafe audit rotate --max-size-mb 10"
1834    )]
1835    Rotate {
1836        /// Maximum audit log size in megabytes before trimming.
1837        #[arg(long, default_value_t = 50)]
1838        max_size_mb: u64,
1839        /// Number of most-recent entries to keep after trimming.
1840        #[arg(long, default_value_t = 5000)]
1841        keep: u32,
1842    },
1843}
1844
1845#[derive(Clone, Copy, ValueEnum)]
1846pub enum CredentialHelperOperation {
1847    /// Install tsafe as the git credential helper in git config.
1848    Install,
1849    Get,
1850    Store,
1851    Erase,
1852}
1853
1854#[cfg(feature = "ssh")]
1855#[derive(Subcommand)]
1856pub enum SshAction {
1857    /// List SSH keys stored in the vault (tagged type=ssh or containing PRIVATE KEY).
1858    #[command(after_help = "Examples:\n  tsafe ssh list")]
1859    List,
1860
1861    /// Extract the public key from a stored SSH private key.
1862    ///
1863    /// Prints the OpenSSH public key in authorized_keys format to stdout.
1864    #[command(
1865        name = "public-key",
1866        after_help = "Examples:\n  tsafe ssh public-key my_ed25519_key\n  tsafe ssh public-key SSH_ID_ED25519"
1867    )]
1868    PublicKey {
1869        /// Vault key name containing the SSH private key.
1870        key: String,
1871    },
1872
1873    /// Generate a new SSH key pair and store the private key in the vault.
1874    ///
1875    /// Uses a CSPRNG (no subprocess). The private key is stored encrypted in
1876    /// the vault; the public key is printed to stdout.
1877    #[command(
1878        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"
1879    )]
1880    Generate {
1881        /// Vault key name to store the generated private key under.
1882        key: String,
1883        /// Key type: ed25519 (default, recommended) or rsa.
1884        #[arg(long, value_name = "TYPE", default_value = "ed25519")]
1885        r#type: SshKeyType,
1886        /// RSA key size in bits (only used with --type rsa; default 4096).
1887        #[arg(long, value_name = "BITS", default_value = "4096")]
1888        bits: u32,
1889        /// Comment to embed in the key (e.g. an email address).
1890        #[arg(long, value_name = "COMMENT")]
1891        comment: Option<String>,
1892        /// Print the public key to stdout after storing the private key.
1893        #[arg(long)]
1894        print: bool,
1895    },
1896
1897    /// Print an ~/.ssh/config snippet that points IdentityAgent at tsafe.
1898    ///
1899    /// Pipe or append the output to ~/.ssh/config.
1900    #[command(
1901        name = "config",
1902        after_help = "Examples:\n  tsafe ssh config\n  tsafe ssh config --host '*.corp.example'\n  tsafe ssh config >> ~/.ssh/config"
1903    )]
1904    Config {
1905        /// SSH Host pattern (defaults to `*`).
1906        #[arg(long, value_name = "PATTERN")]
1907        host: Option<String>,
1908    },
1909
1910    /// Start a persistent SSH agent serving vault keys on a Unix socket.
1911    ///
1912    /// Keys are loaded once at startup and served for the configured TTL.
1913    /// On Windows this subcommand prints a clear error — Unix socket required.
1914    ///
1915    /// Eval idiom:  eval $(tsafe ssh-agent)
1916    #[command(
1917        name = "agent",
1918        after_help = "Examples:\n  eval $(tsafe ssh agent)\n  tsafe ssh agent --ttl 4h\n  tsafe ssh agent --sock /run/user/1000/tsafe.sock"
1919    )]
1920    Agent {
1921        /// How long loaded keys remain valid (e.g. 8h, 30m, 1h30m). Default 8h.
1922        #[arg(long, value_name = "DURATION")]
1923        ttl: Option<String>,
1924        /// Override the Unix socket path.
1925        #[arg(long, value_name = "PATH")]
1926        sock: Option<String>,
1927    },
1928}
1929
1930#[cfg(feature = "ssh")]
1931#[derive(Clone, Copy, ValueEnum)]
1932pub enum SshKeyType {
1933    Ed25519,
1934    Rsa,
1935}
1936
1937#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
1938pub enum PullOnError {
1939    /// Abort immediately on first source/provider error.
1940    FailAll,
1941    /// Skip failed source and continue remaining sources.
1942    SkipFailed,
1943    /// Continue and only warn on source/provider errors.
1944    WarnOnly,
1945}
1946
1947#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
1948pub enum PushOnError {
1949    /// Abort immediately on first source error.
1950    FailAll,
1951    /// Log the error, skip the failed source, and continue with the next.
1952    SkipFailed,
1953}
1954
1955#[derive(Subcommand)]
1956pub enum BrowserProfileAction {
1957    /// Add or update a domain → vault-profile mapping.
1958    ///
1959    /// DOMAIN may be an exact hostname or a wildcard pattern (e.g. *.corp.example).
1960    /// Defaults to the active --profile if --profile is omitted.
1961    ///
1962    #[command(
1963        after_help = "Examples:\n  tsafe browser-profile add github.com\n  tsafe browser-profile add corp.example --profile work"
1964    )]
1965    Add {
1966        /// Domain or pattern (e.g. github.com, *.corp.example).
1967        domain: String,
1968        /// Vault profile to use for this domain. Defaults to the active --profile.
1969        #[arg(long)]
1970        profile: Option<String>,
1971    },
1972    /// List all domain → vault-profile mappings.
1973    ///
1974    #[command(after_help = "Examples:\n  tsafe browser-profile list")]
1975    List,
1976    /// Remove the mapping for DOMAIN.
1977    ///
1978    #[command(after_help = "Examples:\n  tsafe browser-profile remove github.com")]
1979    Remove {
1980        /// Domain or pattern to remove.
1981        domain: String,
1982    },
1983}
1984
1985#[derive(Subcommand)]
1986pub enum BrowserNativeHostAction {
1987    /// Write the per-browser native-messaging-host manifest pointing at
1988    /// `tsafe-nativehost`. Per-user; never elevates. On Windows it writes
1989    /// Chromium-family HKCU registry keys for a 32-char extension ID, or a
1990    /// Firefox filesystem manifest under `%APPDATA%\Mozilla\NativeMessagingHosts\`
1991    /// for an email-style or UUID-style Firefox addon ID. On macOS/Linux it
1992    /// skips browsers that are not installed.
1993    ///
1994    /// `--extension-id` is REQUIRED — defaulting to a known development ID
1995    /// would let any installed extension with that ID talk to your vault.
1996    /// Chromium ID: 32-char string at `chrome://extensions` (Developer mode).
1997    /// Firefox ID: the `gecko.id` value from `browser_specific_settings` in
1998    /// the extension manifest (e.g. `tsafe@tsafe.dev`).
1999    Register {
2000        /// The extension ID to allow. Chromium-family: 32-character lowercase
2001        /// ID from `chrome://extensions`. Firefox: email-style addon ID
2002        /// (e.g. `tsafe@tsafe.dev`) or UUID in curly braces.
2003        #[arg(long)]
2004        extension_id: String,
2005    },
2006    /// Remove the per-browser manifest files (and HKCU keys on Windows).
2007    Unregister,
2008    /// Detect the native-host binary location and print the manifest paths that
2009    /// `register` would write for each installed browser — without writing
2010    /// anything. Use this when you do not yet know your extension ID:
2011    ///
2012    ///   1. Run `tsafe browser-native-host detect` to confirm the binary is
2013    ///      found and see which browsers are detected.
2014    ///   2. Load the extension in your browser, find your extension ID at
2015    ///      `chrome://extensions` (Developer mode), then run:
2016    ///      tsafe browser-native-host register --extension-id ID
2017    ///
2018    /// On Windows, prints the HKCU registry keys and manifest directory that
2019    /// would be written; never modifies registry or filesystem.
2020    Detect,
2021}
2022
2023#[derive(Clone, Copy, ValueEnum)]
2024pub enum TotpAlgorithm {
2025    /// HMAC-SHA1 (default; most compatible)
2026    Sha1,
2027    /// HMAC-SHA256
2028    Sha256,
2029    /// HMAC-SHA512
2030    Sha512,
2031}
2032
2033impl TotpAlgorithm {
2034    pub fn as_uri_str(self) -> &'static str {
2035        match self {
2036            Self::Sha1 => "SHA1",
2037            Self::Sha256 => "SHA256",
2038            Self::Sha512 => "SHA512",
2039        }
2040    }
2041}
2042
2043#[derive(Subcommand)]
2044pub enum TotpAction {
2045    /// Store a TOTP seed for KEY. Accepts a raw base32 secret or an otpauth:// URI.
2046    ///
2047    #[command(
2048        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"
2049    )]
2050    Add {
2051        /// Vault key name to store under.
2052        key: String,
2053        /// Base32-encoded TOTP secret or full otpauth:// URI.
2054        secret: String,
2055        /// HMAC algorithm to use. Raw seeds default to sha1; otpauth URI parameters are preserved.
2056        #[arg(long)]
2057        algorithm: Option<TotpAlgorithm>,
2058        /// Number of digits in each OTP code. Raw seeds default to 6; otpauth URI parameters are preserved.
2059        #[arg(long)]
2060        digits: Option<u32>,
2061        /// Time step in seconds. Raw seeds default to 30; otpauth URI parameters are preserved.
2062        #[arg(long)]
2063        period: Option<u64>,
2064    },
2065    /// Print the current TOTP code + seconds remaining.
2066    ///
2067    #[command(after_help = "Examples:\n  tsafe totp get GITHUB_2FA")]
2068    Get {
2069        /// Vault key name where the TOTP seed is stored.
2070        key: String,
2071    },
2072}
2073
2074#[derive(Subcommand)]
2075pub enum NsAction {
2076    /// List all namespaces present in the vault (inferred from key prefixes).
2077    List,
2078    /// Copy every secret under FROM/ to TO/ (same suffix). Source keys stay.
2079    ///
2080    #[command(after_help = "Examples:\n  tsafe ns copy prod staging")]
2081    Copy {
2082        /// Namespace prefix to read from (keys must be `FROM/...`).
2083        from: String,
2084        /// Namespace prefix to write to (`TO/<same-suffix>`).
2085        to: String,
2086        /// Overwrite destination keys if they already exist.
2087        #[arg(long)]
2088        force: bool,
2089    },
2090    /// Move every secret under FROM/ to TO/ (same suffix). Source keys are removed.
2091    ///
2092    #[command(after_help = "Examples:\n  tsafe ns move prod staging")]
2093    Move {
2094        /// Namespace prefix to read from and delete after rename.
2095        from: String,
2096        /// Namespace prefix to write to (`TO/<same-suffix>`).
2097        to: String,
2098        /// Overwrite destination keys if they already exist.
2099        #[arg(long)]
2100        force: bool,
2101    },
2102}
2103
2104/// Repo-local `.tsafe/tooling` inventory commands.
2105#[derive(Subcommand)]
2106pub enum ToolingAction {
2107    /// Create `.tsafe/tooling/keys.ini`, policy, and README scaffolding.
2108    Init {
2109        /// Repository root. Defaults to the current directory.
2110        #[arg(long, default_value = ".")]
2111        root: std::path::PathBuf,
2112        /// Vault namespace prefix to use for this repo, e.g. databricks/athn_dev/.
2113        #[arg(long)]
2114        namespace: Option<String>,
2115        /// Replace existing scaffold files.
2116        #[arg(long)]
2117        force: bool,
2118    },
2119    /// Validate `.tsafe/tooling/keys.ini`.
2120    Check {
2121        /// Repository root. Defaults to the current directory.
2122        #[arg(long, default_value = ".")]
2123        root: std::path::PathBuf,
2124        /// Emit machine-readable JSON output.
2125        #[arg(long)]
2126        json: bool,
2127    },
2128    /// Suggest a missing secret slot and optionally add it to `keys.ini`.
2129    Suggest {
2130        /// Repository root. Defaults to the current directory.
2131        #[arg(long, default_value = ".")]
2132        root: std::path::PathBuf,
2133        /// Vault namespace prefix for the suggested key.
2134        #[arg(long)]
2135        namespace: String,
2136        /// Inventory section to append under.
2137        #[arg(long)]
2138        section: Option<String>,
2139        /// Key name. Relative keys are prefixed with `--namespace`.
2140        #[arg(long)]
2141        key: String,
2142        /// Human purpose for this secret slot.
2143        #[arg(long)]
2144        purpose: String,
2145        /// Tool, pipeline, or runbook that consumes this secret.
2146        #[arg(long)]
2147        consumer: String,
2148        /// Rotation expectation, e.g. static, manual, 365d KV policy.
2149        #[arg(long)]
2150        rotation: String,
2151        /// Write the suggested slot to `keys.ini`. Without this, preview only.
2152        #[arg(long)]
2153        apply: bool,
2154        /// Reason or task context for the receipt.
2155        #[arg(long, default_value = "operator suggestion")]
2156        reason: String,
2157    },
2158}
2159
2160/// `tsafe mcp ...` subcommands per design §5.2 / ADR-006. Gated behind the
2161/// `mcp` feature.
2162#[derive(Subcommand)]
2163#[cfg(feature = "mcp")]
2164pub enum McpCliAction {
2165    /// Start the MCP stdio JSON-RPC server bound to the current profile.
2166    ///
2167    /// Hosts invoke this directly via the entry written by `tsafe mcp install`.
2168    /// Running it interactively is mainly useful for diagnostics.
2169    Serve {
2170        /// Comma-separated glob list of vault keys this server may expose.
2171        #[arg(long, value_name = "GLOBS")]
2172        allowed_keys: Option<String>,
2173        /// Comma-separated glob list of vault keys to exclude even when
2174        /// matched by `--allowed-keys` or the bound contract.
2175        #[arg(long, value_name = "GLOBS")]
2176        denied_keys: Option<String>,
2177        /// Bind an authority contract for stricter scope and per-key intent.
2178        #[arg(long, value_name = "NAME")]
2179        contract: Option<String>,
2180        /// Bind this server to a single repository/work directory.
2181        #[arg(long, value_name = "PATH")]
2182        workdir: Option<String>,
2183        /// Enable the opt-in `tsafe_reveal` tool. Off by default.
2184        #[arg(long)]
2185        allow_reveal: bool,
2186        /// Label written to the audit `source` field (e.g. `mcp:claude:1234`).
2187        #[arg(long, value_name = "LABEL")]
2188        audit_source: Option<String>,
2189    },
2190    /// Write an MCP server entry into the host's config file.
2191    ///
2192    /// Refuses to write without an explicit `--allowed-keys` or `--contract`.
2193    Install {
2194        /// Host name. One of: claude, cursor, continue, windsurf, codex.
2195        host: String,
2196        /// Server entry name. Defaults to `tsafe-<profile>`.
2197        #[arg(long)]
2198        name: Option<String>,
2199        /// Write to the global per-user config (default when no scope flag is set).
2200        #[arg(long)]
2201        global: bool,
2202        /// Write to a project-local config under the given directory.
2203        #[arg(long, value_name = "DIR")]
2204        project: Option<String>,
2205        /// Print the proposed file change without modifying disk.
2206        #[arg(long)]
2207        dry_run: bool,
2208        /// Comma-separated glob list of vault keys the server may expose.
2209        #[arg(long, value_name = "GLOBS")]
2210        allowed_keys: Option<String>,
2211        /// Comma-separated glob list of vault keys to exclude.
2212        #[arg(long, value_name = "GLOBS")]
2213        denied_keys: Option<String>,
2214        /// Bind an authority contract for stricter scope.
2215        #[arg(long, value_name = "NAME")]
2216        contract: Option<String>,
2217        /// Bind the installed server entry to a single repository/work directory.
2218        #[arg(long, value_name = "PATH")]
2219        workdir: Option<String>,
2220        /// Enable the opt-in `tsafe_reveal` tool on the installed entry.
2221        #[arg(long)]
2222        allow_reveal: bool,
2223    },
2224    /// Emit a safe MCP config entry for a host without embedding secret values.
2225    Config {
2226        /// Host name. Currently: codex.
2227        host: String,
2228        /// Server entry name. Defaults to `tsafe-<profile>`.
2229        #[arg(long)]
2230        name: Option<String>,
2231        /// Bind an authority contract for command execution.
2232        #[arg(long, value_name = "NAME")]
2233        contract: Option<String>,
2234        /// Bind this server entry to a single repository/work directory.
2235        #[arg(long, value_name = "PATH")]
2236        workdir: Option<String>,
2237    },
2238    /// Remove an MCP server entry from the host's config file.
2239    Uninstall {
2240        /// Host name. One of: claude, cursor, continue, windsurf, codex.
2241        host: String,
2242        /// Server entry name. Defaults to `tsafe-<profile>`.
2243        #[arg(long)]
2244        name: Option<String>,
2245    },
2246    /// Render model-safe remediation for a bound MCP denial code.
2247    Doctor {
2248        /// Bound MCP authority denial code, e.g. missing_contract.
2249        #[arg(long, value_name = "DENIAL_CODE")]
2250        code: String,
2251        /// Bound authority contract name.
2252        #[arg(long, value_name = "NAME")]
2253        contract: String,
2254        /// Bound repository or work directory.
2255        #[arg(long, value_name = "PATH")]
2256        workdir: String,
2257        /// Existing receipt id if the denial already produced one.
2258        #[arg(long, value_name = "ID")]
2259        receipt_id: Option<String>,
2260        /// Emit structured JSON instead of the human summary.
2261        #[arg(long)]
2262        json: bool,
2263    },
2264    /// Print binary version + resolved scope. Lightweight diagnostic;
2265    /// does not speak JSON-RPC.
2266    Status,
2267}
2268
2269#[cfg(feature = "mcp")]
2270pub enum McpAction {
2271    Serve {
2272        allowed_keys: Option<String>,
2273        denied_keys: Option<String>,
2274        contract: Option<String>,
2275        workdir: Option<String>,
2276        allow_reveal: bool,
2277        audit_source: Option<String>,
2278    },
2279    Install {
2280        host: String,
2281        name: Option<String>,
2282        global: bool,
2283        project: Option<String>,
2284        dry_run: bool,
2285        allowed_keys: Option<String>,
2286        denied_keys: Option<String>,
2287        contract: Option<String>,
2288        workdir: Option<String>,
2289        allow_reveal: bool,
2290    },
2291    Config {
2292        host: String,
2293        name: Option<String>,
2294        contract: Option<String>,
2295        workdir: Option<String>,
2296    },
2297    Uninstall {
2298        host: String,
2299        name: Option<String>,
2300    },
2301    Status,
2302}
2303
2304#[derive(Subcommand)]
2305pub enum AgentAction {
2306    /// Prompt for approval + vault password, then start the background agent daemon.
2307    ///
2308    /// Prints a shell export line to stdout:
2309    ///   $env:TSAFE_AGENT_SOCK = "..."   # PowerShell
2310    ///   export TSAFE_AGENT_SOCK="..."   # bash/zsh
2311    ///
2312    /// Copy-paste or eval this line in the calling shell/process that needs access.
2313    Unlock {
2314        /// Idle TTL — how long the agent stays alive without a vault request.
2315        /// Resets on each vault access. Common values include 15m, 1h, and 4h. Default: 30m.
2316        #[arg(long, default_value = "30m")]
2317        ttl: String,
2318        /// Absolute TTL — hard cap regardless of activity. Default: 8h.
2319        /// Must be >= idle TTL. Common values include 8h, 12h, and 24h.
2320        #[arg(long, default_value = "8h")]
2321        absolute_ttl: String,
2322    },
2323    /// Immediately revoke the current session and stop the agent.
2324    Lock,
2325    /// Show whether the current agent socket is reachable.
2326    ///
2327    /// Use `--json` for a stable machine-readable output (ADR-029). Consumers such
2328    /// as the VS Code extension and tray agent depend on this flag. The schema
2329    /// `version` field must be checked before reading any other field.
2330    #[command(
2331        after_help = "Examples:\n  tsafe agent status\n  tsafe agent status --json\n  tsafe --profile prod agent status --json"
2332    )]
2333    Status {
2334        /// Emit a stable JSON object to stdout (schema version \"1\").
2335        /// See docs/decisions/agent-status-json-contract.md (ADR-029) for the full schema.
2336        #[arg(long)]
2337        json: bool,
2338    },
2339}
2340
2341#[derive(Subcommand)]
2342pub enum TeamAction {
2343    /// Create a new team vault encrypted to your age identity.
2344    ///
2345    #[command(after_help = "Examples:\n  tsafe team init --identity ~/.age/key.txt")]
2346    Init {
2347        /// Path to your age identity file (contains AGE-SECRET-KEY-1...).
2348        #[arg(long)]
2349        identity: String,
2350    },
2351    /// Add a team member by their age public key.
2352    ///
2353    #[command(after_help = "Examples:\n  tsafe team add-member age1qyqszqgp...")]
2354    AddMember {
2355        /// age X25519 public key (starts with "age1...").
2356        public_key: String,
2357        /// Path to your age identity file (for re-wrapping the DEK).
2358        #[arg(long)]
2359        identity: String,
2360    },
2361    /// Remove a team member and re-encrypt all secrets with a new key.
2362    ///
2363    #[command(after_help = "Examples:\n  tsafe team remove-member age1qyqszqgp...")]
2364    RemoveMember {
2365        /// age X25519 public key to remove.
2366        public_key: String,
2367        /// Path to your age identity file (for re-keying).
2368        #[arg(long)]
2369        identity: String,
2370    },
2371    /// List current team members (public keys).
2372    Members,
2373    /// Generate a new age identity (keypair) and print the JSON block to add
2374    /// to `.tsafe/team-keys.json` via a PR.
2375    ///
2376    /// The private key is saved to `~/.age/tsafe-<profile>.txt`.
2377    /// The public key is printed as a ready-to-paste JSON entry.
2378    ///
2379    #[command(
2380        after_help = "Examples:\n  tsafe team keygen\n  tsafe team keygen --name \"Alice Smith\" --email alice@corp.example"
2381    )]
2382    Keygen {
2383        /// Your display name for the team-keys entry.
2384        #[arg(long)]
2385        name: Option<String>,
2386        /// Your email for the team-keys entry.
2387        #[arg(long)]
2388        email: Option<String>,
2389    },
2390    /// Print your age public key from an existing identity file.
2391    ///
2392    #[command(
2393        after_help = "Examples:\n  tsafe team show-key\n  tsafe team show-key --identity ~/.age/key.txt"
2394    )]
2395    ShowKey {
2396        /// Path to identity file (default: `~/.age/tsafe-<profile>.txt`).
2397        #[arg(long)]
2398        identity: Option<String>,
2399    },
2400    /// Reconcile vault recipients with `.tsafe/team-keys.json`.
2401    ///
2402    /// Adds any new members found in the keys file. Removes members no longer
2403    /// listed. Re-keys the vault if the member list changed.
2404    ///
2405    #[command(after_help = "Examples:\n  tsafe team sync-keys --identity ~/.age/key.txt")]
2406    SyncKeys {
2407        /// Path to your age identity file (required for re-wrapping the DEK).
2408        #[arg(long)]
2409        identity: String,
2410        /// Path to team-keys.json (auto-detected if omitted).
2411        #[arg(long)]
2412        keys_file: Option<String>,
2413    },
2414}
2415
2416#[derive(Subcommand)]
2417pub enum BiometricAction {
2418    /// Store the vault password in the OS credential store (same as accepting quick unlock after `tsafe init`).
2419    Enable,
2420    /// Remove the vault password from the OS credential store.
2421    Disable,
2422    /// Check if biometric/keyring unlock is configured for this profile.
2423    Status,
2424    /// Re-enroll biometric/keyring unlock after a stale-credential error.
2425    ///
2426    /// Use this when `tsafe` reports "stale biometric credential" — for example after
2427    /// rotating the vault password (`tsafe rotate`) or after enrolling a new fingerprint.
2428    /// This is equivalent to `tsafe biometric disable` followed by `tsafe biometric enable`
2429    /// but makes the recovery intent explicit and prints a confirmation.
2430    #[command(name = "re-enroll")]
2431    ReEnroll,
2432}
2433
2434#[cfg(test)]
2435mod tests {
2436    use super::*;
2437    use clap::CommandFactory;
2438
2439    fn has_subcommand(command: &clap::Command, name: &str) -> bool {
2440        command
2441            .get_subcommands()
2442            .any(|subcommand| subcommand.get_name() == name)
2443    }
2444
2445    #[test]
2446    fn root_command_visibility_matches_feature_gates() {
2447        let command = Cli::command();
2448
2449        assert!(has_subcommand(&command, "tooling"));
2450        assert_eq!(has_subcommand(&command, "ui"), cfg!(feature = "tui"));
2451        assert_eq!(
2452            has_subcommand(&command, "kv-pull"),
2453            cfg!(feature = "akv-pull")
2454        );
2455        assert_eq!(
2456            has_subcommand(&command, "aws-pull"),
2457            cfg!(feature = "cloud-pull-aws")
2458        );
2459        assert_eq!(
2460            has_subcommand(&command, "gcp-pull"),
2461            cfg!(feature = "cloud-pull-gcp")
2462        );
2463        assert_eq!(
2464            has_subcommand(&command, "gcp-push"),
2465            cfg!(feature = "cloud-pull-gcp")
2466        );
2467        assert_eq!(
2468            has_subcommand(&command, "ssm-pull"),
2469            cfg!(feature = "cloud-pull-aws")
2470        );
2471        assert_eq!(
2472            has_subcommand(&command, "aws-push"),
2473            cfg!(feature = "cloud-pull-aws")
2474        );
2475        assert_eq!(
2476            has_subcommand(&command, "ssm-push"),
2477            cfg!(feature = "cloud-pull-aws")
2478        );
2479        assert_eq!(
2480            has_subcommand(&command, "vault-pull"),
2481            cfg!(feature = "cloud-pull-vault")
2482        );
2483        assert_eq!(
2484            has_subcommand(&command, "op-pull"),
2485            cfg!(feature = "cloud-pull-1password")
2486        );
2487        assert_eq!(
2488            has_subcommand(&command, "pull"),
2489            cfg!(feature = "multi-pull")
2490        );
2491        assert_eq!(
2492            has_subcommand(&command, "share-once"),
2493            cfg!(feature = "ots-sharing")
2494        );
2495        assert_eq!(
2496            has_subcommand(&command, "receive-once"),
2497            cfg!(feature = "ots-sharing")
2498        );
2499        assert_eq!(
2500            has_subcommand(&command, "browser-profile"),
2501            cfg!(feature = "browser")
2502        );
2503        assert_eq!(
2504            has_subcommand(&command, "browser-native-host"),
2505            cfg!(feature = "nativehost")
2506        );
2507        assert_eq!(has_subcommand(&command, "ssh-add"), cfg!(feature = "ssh"));
2508        assert_eq!(
2509            has_subcommand(&command, "ssh-import"),
2510            cfg!(feature = "ssh")
2511        );
2512        assert_eq!(
2513            has_subcommand(&command, "plugin"),
2514            cfg!(feature = "plugins")
2515        );
2516        assert_eq!(
2517            has_subcommand(&command, "hook-install"),
2518            cfg!(feature = "git-helpers")
2519        );
2520        assert_eq!(
2521            has_subcommand(&command, "git"),
2522            cfg!(feature = "git-helpers")
2523        );
2524        assert_eq!(
2525            has_subcommand(&command, "sync"),
2526            cfg!(feature = "git-helpers")
2527        );
2528        assert_eq!(
2529            has_subcommand(&command, "credential-helper"),
2530            cfg!(feature = "git-helpers")
2531        );
2532        assert_eq!(
2533            has_subcommand(&command, "biometric"),
2534            cfg!(feature = "biometric")
2535        );
2536        assert_eq!(
2537            has_subcommand(&command, "team"),
2538            cfg!(feature = "team-core")
2539        );
2540        assert_eq!(has_subcommand(&command, "agent"), cfg!(feature = "agent"));
2541    }
2542
2543    #[test]
2544    fn tooling_suggest_parses_inventory_request() {
2545        let cli = Cli::try_parse_from([
2546            "tsafe",
2547            "tooling",
2548            "suggest",
2549            "--root",
2550            ".",
2551            "--namespace",
2552            "databricks/athn_dev/",
2553            "--section",
2554            "ci-cd-spn",
2555            "--key",
2556            "ci_secret",
2557            "--purpose",
2558            "SPN secret",
2559            "--consumer",
2560            "ADO service connection athn-dev-sc",
2561            "--rotation",
2562            "365d KV policy",
2563            "--reason",
2564            "terraform deployment",
2565            "--apply",
2566        ])
2567        .unwrap();
2568
2569        match cli.command {
2570            Commands::Tooling {
2571                action:
2572                    ToolingAction::Suggest {
2573                        root,
2574                        namespace,
2575                        section,
2576                        key,
2577                        purpose,
2578                        consumer,
2579                        rotation,
2580                        apply,
2581                        reason,
2582                    },
2583            } => {
2584                assert_eq!(root, std::path::PathBuf::from("."));
2585                assert_eq!(namespace, "databricks/athn_dev/");
2586                assert_eq!(section.as_deref(), Some("ci-cd-spn"));
2587                assert_eq!(key, "ci_secret");
2588                assert_eq!(purpose, "SPN secret");
2589                assert_eq!(consumer, "ADO service connection athn-dev-sc");
2590                assert_eq!(rotation, "365d KV policy");
2591                assert_eq!(reason, "terraform deployment");
2592                assert!(apply);
2593            }
2594            _ => panic!("expected tooling suggest action"),
2595        }
2596    }
2597
2598    #[test]
2599    #[cfg(feature = "mcp")]
2600    fn mcp_config_parses_bound_contract_flags() {
2601        let cli = Cli::try_parse_from([
2602            "tsafe",
2603            "mcp",
2604            "config",
2605            "codex",
2606            "--name",
2607            "tsafe-cordance",
2608            "--profile",
2609            "ops",
2610            "--contract",
2611            "cordance-diagnostics",
2612            "--workdir",
2613            "C:\\Users\\0ryant\\prj\\cordance",
2614        ])
2615        .unwrap();
2616
2617        assert_eq!(cli.profile.as_deref(), Some("ops"));
2618        match cli.command {
2619            Commands::Mcp {
2620                action:
2621                    McpCliAction::Config {
2622                        host,
2623                        name,
2624                        contract,
2625                        workdir,
2626                    },
2627            } => {
2628                assert_eq!(host, "codex");
2629                assert_eq!(name.as_deref(), Some("tsafe-cordance"));
2630                assert_eq!(contract.as_deref(), Some("cordance-diagnostics"));
2631                assert_eq!(workdir.as_deref(), Some("C:\\Users\\0ryant\\prj\\cordance"));
2632            }
2633            _ => panic!("expected mcp config action"),
2634        }
2635    }
2636
2637    #[test]
2638    #[cfg(feature = "mcp")]
2639    fn mcp_serve_parses_profile_contract_and_workdir_after_action() {
2640        let cli = Cli::try_parse_from([
2641            "tsafe",
2642            "mcp",
2643            "serve",
2644            "--profile",
2645            "ops",
2646            "--contract",
2647            "cordance-diagnostics",
2648            "--workdir",
2649            "C:\\Users\\0ryant\\prj\\cordance",
2650        ])
2651        .unwrap();
2652
2653        assert_eq!(cli.profile.as_deref(), Some("ops"));
2654        match cli.command {
2655            Commands::Mcp {
2656                action:
2657                    McpCliAction::Serve {
2658                        contract, workdir, ..
2659                    },
2660            } => {
2661                assert_eq!(contract.as_deref(), Some("cordance-diagnostics"));
2662                assert_eq!(workdir.as_deref(), Some("C:\\Users\\0ryant\\prj\\cordance"));
2663            }
2664            _ => panic!("expected mcp serve action"),
2665        }
2666    }
2667
2668    #[test]
2669    #[cfg(feature = "mcp")]
2670    fn mcp_doctor_parses_denial_contract_workdir_and_json() {
2671        let cli = Cli::try_parse_from([
2672            "tsafe",
2673            "mcp",
2674            "doctor",
2675            "--profile",
2676            "ops",
2677            "--code",
2678            "missing_contract",
2679            "--contract",
2680            "cordance-diagnostics",
2681            "--workdir",
2682            "C:\\Users\\0ryant\\prj\\cordance",
2683            "--json",
2684        ])
2685        .unwrap();
2686
2687        assert_eq!(cli.profile.as_deref(), Some("ops"));
2688        match cli.command {
2689            Commands::Mcp {
2690                action:
2691                    McpCliAction::Doctor {
2692                        code,
2693                        contract,
2694                        workdir,
2695                        receipt_id,
2696                        json,
2697                    },
2698            } => {
2699                assert_eq!(code, "missing_contract");
2700                assert_eq!(contract, "cordance-diagnostics");
2701                assert_eq!(workdir, "C:\\Users\\0ryant\\prj\\cordance");
2702                assert_eq!(receipt_id, None);
2703                assert!(json);
2704            }
2705            _ => panic!("expected mcp doctor action"),
2706        }
2707    }
2708
2709    #[test]
2710    #[cfg(feature = "mcp")]
2711    fn mcp_help_documents_bound_serve_and_codex_config_forms() {
2712        let mut command = Cli::command();
2713        let mcp = command.find_subcommand_mut("mcp").unwrap();
2714        let mut help = Vec::new();
2715        mcp.write_long_help(&mut help).unwrap();
2716        let help = String::from_utf8(help).unwrap();
2717
2718        assert!(help
2719            .contains("tsafe mcp serve --profile ops --contract cordance-diagnostics --workdir ."));
2720        assert!(help.contains(
2721            "tsafe mcp config codex --name tsafe-cordance --profile ops --contract cordance-diagnostics --workdir ."
2722        ));
2723        assert!(help.contains(
2724            "tsafe mcp doctor --code missing_contract --contract cordance-diagnostics --workdir . --json"
2725        ));
2726    }
2727}
2728
2729#[derive(Subcommand)]
2730pub enum PolicyAction {
2731    /// Set a rotation policy on a secret.
2732    ///
2733    #[command(after_help = "Examples:\n  tsafe policy set DB_PASSWORD --rotate-every 90d")]
2734    Set {
2735        /// Secret key.
2736        key: String,
2737        /// Rotation interval (e.g. 90d, 30d, 7d).
2738        #[arg(long)]
2739        rotate_every: String,
2740    },
2741    /// Remove the rotation policy from a secret.
2742    ///
2743    #[command(after_help = "Examples:\n  tsafe policy remove DB_PASSWORD")]
2744    Remove {
2745        /// Secret key.
2746        key: String,
2747    },
2748}
2749
2750/// Attestation subcommands — Phases 3 and 4 of the algol→tsafe migration.
2751///
2752/// - `scan` (Phase 3) — secret + env-authority scanner.
2753/// - `run`  (Phase 4) — env-injection enforcement harness; emits
2754///   RunEvidence + CloudEvents audit trail.
2755#[derive(Subcommand)]
2756pub enum AttestAction {
2757    /// Scan a repo for committed secrets and env-authority signals.
2758    ///
2759    /// Defaults to scanning the current directory. The scanner is the
2760    /// Phase 3 port of the algol Phase 2.1 scanner; see crate docs for
2761    /// the full provenance trail.
2762    ///
2763    /// Scanner P/R on synthetic N=100 corpus: 1.000 / 1.000. Real-world
2764    /// rates may differ. See
2765    /// `ecosystem-catalog/portfolio-algol-tsafe-phase2-1-precision-recovery-2026-05-21.md`
2766    /// for the verdict.
2767    #[command(
2768        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."
2769    )]
2770    Scan {
2771        /// Repo path to scan (defaults to current directory).
2772        path: Option<std::path::PathBuf>,
2773        /// Exit with code 2 if any secret-class finding is present.
2774        ///
2775        /// Secret-class kinds: `ENV_FILE`, `HARDCODED_SECRET`, `PRIVATE_KEY`.
2776        /// `SECRET_PLACEHOLDER` (Phase 2.1 — placeholder/comment context)
2777        /// is NOT counted as a secret finding.
2778        #[arg(long)]
2779        strict: bool,
2780        /// Additional paths to scan (repeatable). Findings from all paths
2781        /// are merged into a single report.
2782        #[arg(long = "extra-paths", value_name = "PATH", num_args = 0..)]
2783        extra_paths: Vec<std::path::PathBuf>,
2784        /// Output format.
2785        #[arg(long, default_value = "text")]
2786        format: AttestScanFormat,
2787        /// Write the report to this file (otherwise printed to stdout).
2788        #[arg(short, long, value_name = "FILE")]
2789        output: Option<std::path::PathBuf>,
2790    },
2791    /// Run a command under env-injection enforcement.
2792    ///
2793    /// Loads an `AttestContract`, strips the parent env, injects declared
2794    /// variables from the configured sources, spawns the command, and
2795    /// emits a `RunEvidence` artifact + CloudEvents audit-trail entry.
2796    ///
2797    /// Phase 4 wire formats: `tsafe.run.v1` RunEvidence,
2798    /// `tsafe.audit_event.v1` audit events, BLAKE3 fingerprints.
2799    /// Legacy `algol.*` schemas + SHA-256 hashes are accepted on parse
2800    /// during the v1.x compat window.
2801    #[command(
2802        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."
2803    )]
2804    Run {
2805        /// Path to the `AttestContract` to enforce. Defaults to
2806        /// `tsafe.contract.yaml` in the current directory.
2807        #[arg(long, value_name = "PATH")]
2808        contract: Option<std::path::PathBuf>,
2809        /// Path to write the `RunEvidence` artifact. Defaults to
2810        /// `tsafe-run.json` in the current directory.
2811        #[arg(long = "emit-run-evidence", value_name = "PATH")]
2812        emit_run_evidence: Option<std::path::PathBuf>,
2813        /// Path to the audit-trail NDJSON log. Each line is a CloudEvents
2814        /// envelope. Defaults to `tsafe-audit-events.ndjson`.
2815        #[arg(long = "audit-trail", value_name = "PATH")]
2816        audit_trail: Option<std::path::PathBuf>,
2817        /// Allow the supplied command to differ from the contract's
2818        /// `command` field. Disabled by default; useful only for testing.
2819        #[arg(long = "allow-command-override")]
2820        allow_command_override: bool,
2821        /// Phase 5: sign the emitted `RunEvidence` with the per-profile
2822        /// Ed25519 keyring entry. Default is ON — if no key is
2823        /// provisioned, one is auto-generated on first use with a stderr
2824        /// warning (`tsafe attest key generate` is the explicit form).
2825        /// Use `--no-sign` to opt out.
2826        #[arg(long = "sign-run-evidence", overrides_with = "no_sign")]
2827        sign_run_evidence: bool,
2828        /// Phase 5: explicitly disable Ed25519 signing of the emitted
2829        /// `RunEvidence`. Overrides the default-on `--sign-run-evidence`
2830        /// behaviour.
2831        #[arg(long = "no-sign", overrides_with = "sign_run_evidence")]
2832        no_sign: bool,
2833        /// Command to execute under enforcement. Pass after `--`.
2834        #[arg(last = true, required = true)]
2835        command: Vec<String>,
2836    },
2837    /// Verify the Ed25519 signature on a `RunEvidence` artifact.
2838    ///
2839    /// Phase 5 (this version) emits artifacts with a `signature` field
2840    /// carrying an Ed25519 signature over the canonical encoding of every
2841    /// other field. `verify` re-derives the canonical bytes, prepends
2842    /// the `tsafe.run_evidence.v1` domain tag, and checks the signature.
2843    ///
2844    /// Without `--pubkey`, the embedded pubkey on the artifact is used
2845    /// (TOFU); a stderr warning is emitted reminding the operator to pin
2846    /// the pubkey out of band. With `--pubkey <base64url>`, the supplied
2847    /// key takes precedence.
2848    ///
2849    /// Exit codes:
2850    /// - `0` — signature is valid
2851    /// - `5` — artifact has no `signature` field
2852    /// - `6` — signature verification failed (tampered or wrong key)
2853    /// - `7` — `--require-pinned`: signer is not a pinned trusted identity
2854    /// - other — internal error
2855    #[command(
2856        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."
2857    )]
2858    Verify {
2859        /// Path to the `RunEvidence` JSON artifact to verify.
2860        ///
2861        /// `allow_hyphen_values` lets the path begin with `-` (e.g. a
2862        /// relative path or an unusual filename) without clap treating
2863        /// it as a flag.
2864        #[arg(allow_hyphen_values = true)]
2865        evidence: std::path::PathBuf,
2866        /// Operator-supplied verifying key (base64url-encoded, no
2867        /// padding, 32 bytes after decoding). If omitted, the pubkey
2868        /// embedded in the artifact is used (TOFU; see honest
2869        /// disclosure on `--help`).
2870        ///
2871        /// `allow_hyphen_values` lets the base64url value begin with `-`
2872        /// (a valid character in the URL-safe alphabet) without clap
2873        /// treating it as a flag.
2874        #[arg(long, value_name = "BASE64URL", allow_hyphen_values = true)]
2875        pubkey: Option<String>,
2876        /// Fail closed (exit code 7) unless the signing key matches an
2877        /// identity pinned in the trust store (`tsafe attest trust
2878        /// add`). This converts TOFU verification into identity-anchored
2879        /// verification: the cryptographic check still runs, AND the
2880        /// signer must be a key the operator has explicitly trusted.
2881        /// When combined with `--pubkey`, both must agree.
2882        #[arg(long = "require-pinned")]
2883        require_pinned: bool,
2884    },
2885    /// Manage the `tsafe-attest` Ed25519 signing key for a profile.
2886    ///
2887    /// The key lives in the OS credential store under the per-profile
2888    /// `tsafe-attest-signing-key` account name. `tsafe attest run`
2889    /// signs emitted `RunEvidence` artifacts with this key by default;
2890    /// `tsafe attest verify` can use the embedded pubkey or an
2891    /// operator-supplied one.
2892    Key {
2893        #[command(subcommand)]
2894        action: AttestKeyAction,
2895    },
2896    /// Manage the pinned-pubkey trust store used by
2897    /// `tsafe attest verify --require-pinned`.
2898    ///
2899    /// The trust store binds operator-chosen identity names to pinned
2900    /// Ed25519 public keys, closing the Phase 5 TOFU gap: once a
2901    /// producer's pubkey is pinned out of band, `verify --require-pinned`
2902    /// will fail closed for any artifact NOT signed by a pinned identity.
2903    ///
2904    /// The store lives at `<config-root>/trust-store.json` (honoring
2905    /// `TSAFE_VAULT_DIR`). It holds only public keys — no secrets.
2906    Trust {
2907        #[command(subcommand)]
2908        action: AttestTrustAction,
2909    },
2910}
2911
2912/// Subcommands of `tsafe attest trust` — operator-facing management of
2913/// the pinned-pubkey trust store (Phase 5 TOFU-gap closure).
2914#[derive(Subcommand)]
2915pub enum AttestTrustAction {
2916    /// Pin an identity name to a base64url-encoded Ed25519 pubkey.
2917    ///
2918    /// The pubkey is validated (decodes to a 32-byte Ed25519 key) before
2919    /// it is persisted, so a malformed pin can never enter the store.
2920    /// Refuses to clobber an existing name — `remove` then `add` to
2921    /// rotate.
2922    Add {
2923        /// Operator-chosen identity name (e.g. `ci-prod`).
2924        name: String,
2925        /// Base64url-encoded (no padding) Ed25519 verifying key — the
2926        /// value `tsafe attest key pubkey` prints.
2927        ///
2928        /// `allow_hyphen_values` lets the base64url value begin with `-`
2929        /// (a valid character in the URL-safe alphabet) without clap
2930        /// treating it as a flag.
2931        #[arg(allow_hyphen_values = true)]
2932        pubkey: String,
2933    },
2934    /// List all pinned identities (name + pubkey).
2935    List,
2936    /// Remove a pinned identity by name. Errors if absent.
2937    Remove {
2938        /// Identity name to unpin.
2939        name: String,
2940    },
2941}
2942
2943/// Subcommands of `tsafe attest key` — operator-facing keyring
2944/// management for the Phase 5 signing flow.
2945#[derive(Subcommand)]
2946pub enum AttestKeyAction {
2947    /// Generate a fresh Ed25519 keypair and store the signing half in
2948    /// the OS credential store under the active profile.
2949    ///
2950    /// Refuses to overwrite an existing entry unless `--force` is
2951    /// passed. Prints the corresponding base64url-encoded pubkey to
2952    /// stdout so operators can pin it out of band.
2953    Generate {
2954        /// Overwrite any existing signing key for this profile.
2955        #[arg(long)]
2956        force: bool,
2957    },
2958    /// Print the base64url-encoded verifying key (pubkey) for the
2959    /// active profile.
2960    ///
2961    /// Fails non-zero if no signing key is provisioned for this
2962    /// profile.
2963    Pubkey,
2964    /// Remove the signing key for the active profile from the OS
2965    /// credential store. Subsequent `tsafe attest run` calls auto-
2966    /// generate a new key on first use (with stderr warning) unless
2967    /// `--no-sign` is passed.
2968    Remove,
2969}
2970
2971/// Output format for `tsafe attest scan`.
2972#[derive(Clone, Copy, Debug, ValueEnum)]
2973pub enum AttestScanFormat {
2974    /// Pretty-printed JSON `ScanReport` artifact.
2975    Json,
2976    /// Human-readable markdown summary + finding table.
2977    Markdown,
2978    /// Compact text summary (default).
2979    Text,
2980}