Skip to main content

truth_mirror/
cli.rs

1use std::path::PathBuf;
2
3use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum};
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Parser)]
7#[command(
8    name = "truth-mirror",
9    version,
10    about = "Truthfulness gate and reviewer harness for coding agents.",
11    propagate_version = true
12)]
13pub struct Cli {
14    #[arg(
15        long,
16        global = true,
17        env = "TRUTH_MIRROR_STATE_DIR",
18        default_value = ".truth-mirror",
19        value_name = "DIR"
20    )]
21    pub state_dir: PathBuf,
22
23    #[arg(long, global = true, env = "TRUTH_MIRROR_CONFIG", value_name = "FILE")]
24    pub config: Option<PathBuf>,
25
26    #[command(subcommand)]
27    pub command: Commands,
28}
29
30#[derive(Debug, Subcommand)]
31pub enum Commands {
32    /// Install, uninstall, or preview agent hook shims.
33    InstallHooks(InstallHooksArgs),
34    /// Review a commit or the staged diff with a separate reviewer model.
35    Review(ReviewArgs),
36    /// Run deterministic repository gates.
37    Gate(GateArgs),
38    /// Reinject unresolved findings into an agent prompt surface.
39    Reinject(ReinjectArgs),
40    /// Inspect or update the dual ledger.
41    Ledger(LedgerArgs),
42    /// Run the post-commit reviewer loop.
43    Watch(WatchArgs),
44    /// Print or install the embedded truth-mirror skill document.
45    Skills(SkillsArgs),
46    /// Internal git hook dispatcher.
47    #[command(hide = true)]
48    HookDispatch(HookDispatchArgs),
49}
50
51#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, ValueEnum)]
52#[serde(rename_all = "kebab-case")]
53pub enum Agent {
54    Claude,
55    Codex,
56    Pi,
57}
58
59#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, ValueEnum)]
60#[serde(rename_all = "kebab-case")]
61pub enum ReviewerHarness {
62    Claude,
63    Codex,
64    Pi,
65    Gemini,
66    Opencode,
67    Custom,
68}
69
70#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, ValueEnum)]
71#[serde(rename_all = "kebab-case")]
72pub enum ReviewScope {
73    Commit,
74    Staged,
75    Auto,
76    WorkingTree,
77    Branch,
78}
79
80#[derive(Debug, Args)]
81pub struct InstallHooksArgs {
82    #[arg(long)]
83    pub claude: bool,
84
85    #[arg(long)]
86    pub codex: bool,
87
88    #[arg(long)]
89    pub pi: bool,
90
91    #[arg(long)]
92    pub uninstall: bool,
93
94    #[arg(long)]
95    pub dry_run: bool,
96}
97
98#[derive(Debug, Args)]
99pub struct ReviewArgs {
100    #[command(subcommand)]
101    pub command: Option<ReviewCommand>,
102
103    #[arg(value_name = "SHA", conflicts_with = "staged")]
104    pub target: Option<String>,
105
106    #[arg(long)]
107    pub staged: bool,
108
109    #[arg(long, value_enum, value_name = "SCOPE", default_value_t = ReviewScope::Commit)]
110    pub scope: ReviewScope,
111
112    #[arg(long, value_name = "REF")]
113    pub base: Option<String>,
114
115    #[arg(long, value_enum, value_name = "AGENT")]
116    pub watched_agent: Option<Agent>,
117
118    #[arg(long, value_enum, value_name = "HARNESS")]
119    pub reviewer_harness: Option<ReviewerHarness>,
120
121    #[arg(long, value_name = "MODEL")]
122    pub watched_model: Option<String>,
123
124    #[arg(long, value_name = "MODEL")]
125    pub reviewer_model: Option<String>,
126
127    #[arg(long, value_enum, value_name = "EFFORT")]
128    pub reviewer_effort: Option<crate::config::Effort>,
129
130    #[arg(long)]
131    pub allow_same_model: bool,
132
133    #[arg(long)]
134    pub strict_two_pass: bool,
135
136    #[arg(long, value_enum, value_name = "HARNESS")]
137    pub arbiter_harness: Option<ReviewerHarness>,
138
139    #[arg(long, value_name = "MODEL")]
140    pub arbiter_model: Option<String>,
141
142    #[arg(long, value_enum, value_name = "EFFORT")]
143    pub arbiter_effort: Option<crate::config::Effort>,
144
145    /// Sic the adversarial reviewer in a loop until N lies or N fuckups.
146    #[arg(long)]
147    pub strict_goal: bool,
148
149    #[arg(long, value_name = "N")]
150    pub stop_after_lies: Option<u32>,
151
152    #[arg(long, value_name = "N")]
153    pub stop_after_fuckups: Option<u32>,
154
155    #[arg(long, value_name = "N")]
156    pub max_passes: Option<u32>,
157}
158
159#[derive(Debug, Subcommand)]
160pub enum ReviewCommand {
161    /// Show tracked review run status, or all known runs when no id is provided.
162    Status {
163        #[arg(value_name = "RUN_ID")]
164        run_id: Option<String>,
165    },
166    /// Show the latest completed/failed review run, or a specific run.
167    Result {
168        #[arg(value_name = "RUN_ID")]
169        run_id: Option<String>,
170    },
171    /// Cancel a review run and remove it from the review queue.
172    ///
173    /// Queued runs and running runs whose worker has died are cancelled directly.
174    /// Pass `--force` to kill a running run whose worker is still alive.
175    Cancel {
176        #[arg(value_name = "RUN_ID")]
177        run_id: String,
178
179        /// Kill the worker process of a still-running run before cancelling it.
180        #[arg(long)]
181        force: bool,
182    },
183}
184
185#[derive(Debug, Args)]
186#[command(group(
187    ArgGroup::new("gate_mode")
188        .required(true)
189        .args(["pre_push", "commit_msg", "pre_tool_use"])
190))]
191pub struct GateArgs {
192    #[arg(long, value_name = "RANGE", conflicts_with = "commit_msg")]
193    pub pre_push: Option<String>,
194
195    #[arg(long, value_name = "FILE", conflicts_with = "pre_push")]
196    pub commit_msg: Option<PathBuf>,
197
198    #[arg(long, value_name = "FILE", requires = "commit_msg")]
199    pub claim_file: Option<PathBuf>,
200
201    #[arg(long, value_name = "FILE", requires = "commit_msg")]
202    pub diff_file: Option<PathBuf>,
203
204    #[arg(long = "fake-marker", value_name = "TOKEN", requires = "commit_msg")]
205    pub fake_markers: Vec<String>,
206
207    /// Enforcement gate: block a mutating tool call when the ledger has unresolved
208    /// rejections beyond the configured threshold.
209    #[arg(long, conflicts_with_all = ["pre_push", "commit_msg"])]
210    pub pre_tool_use: bool,
211
212    /// The tool name being gated (for `--pre-tool-use`).
213    #[arg(long, value_name = "NAME", requires = "pre_tool_use")]
214    pub tool: Option<String>,
215}
216
217#[derive(Debug, Args)]
218pub struct ReinjectArgs {
219    #[arg(long, value_enum)]
220    pub agent: Agent,
221}
222
223#[derive(Debug, Args)]
224pub struct LedgerArgs {
225    #[command(subcommand)]
226    pub command: LedgerCommand,
227}
228
229#[derive(Debug, Subcommand)]
230pub enum LedgerCommand {
231    List,
232    Show {
233        #[arg(value_name = "SHA")]
234        sha: String,
235    },
236    Resolve {
237        #[arg(value_name = "SHA")]
238        sha: String,
239    },
240    Waive {
241        #[arg(value_name = "SHA")]
242        sha: String,
243
244        #[arg(long, value_name = "REASON")]
245        reason: String,
246    },
247    Stats,
248}
249
250#[derive(Debug, Args)]
251pub struct WatchArgs {
252    #[arg(long, value_enum, value_name = "AGENT")]
253    pub watched_agent: Option<Agent>,
254
255    #[arg(long, value_enum, value_name = "HARNESS")]
256    pub reviewer_harness: Option<ReviewerHarness>,
257
258    #[arg(long, value_name = "MODEL")]
259    pub watched_model: Option<String>,
260
261    #[arg(long, value_name = "MODEL")]
262    pub reviewer_model: Option<String>,
263
264    #[arg(long, value_enum, value_name = "EFFORT")]
265    pub reviewer_effort: Option<crate::config::Effort>,
266
267    #[arg(long)]
268    pub allow_same_model: bool,
269
270    /// Drain the review queue exactly once and exit (deterministic; used in CI).
271    #[arg(long)]
272    pub once: bool,
273
274    /// Poll interval in seconds when running as a daemon (ignored with --once).
275    #[arg(long, value_name = "SECONDS", default_value_t = 5)]
276    pub poll_secs: u64,
277}
278
279#[derive(Debug, Args)]
280pub struct SkillsArgs {
281    #[command(subcommand)]
282    pub command: SkillsCommand,
283}
284
285#[derive(Debug, Subcommand)]
286pub enum SkillsCommand {
287    /// Print the embedded skill document to stdout.
288    Echo,
289    /// Write the skill document to <dir>/truth-mirror/SKILL.md.
290    Install {
291        /// Target skills directory (defaults to `.agents/skills`).
292        #[arg(long, value_name = "PATH")]
293        dir: Option<PathBuf>,
294
295        /// Overwrite an existing skill file.
296        #[arg(long)]
297        force: bool,
298    },
299}
300
301#[derive(Debug, Args)]
302pub struct HookDispatchArgs {
303    #[arg(value_enum)]
304    pub hook: HookName,
305
306    #[arg(value_name = "ARGS")]
307    pub args: Vec<String>,
308}
309
310#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
311#[value(rename_all = "kebab-case")]
312pub enum HookName {
313    CommitMsg,
314    PostCommit,
315    PrePush,
316}
317
318impl HookName {
319    pub fn as_str(self) -> &'static str {
320        match self {
321            Self::CommitMsg => "commit-msg",
322            Self::PostCommit => "post-commit",
323            Self::PrePush => "pre-push",
324        }
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use clap::CommandFactory;
331
332    use super::Cli;
333
334    #[test]
335    fn clap_contract_is_valid() {
336        Cli::command().debug_assert();
337    }
338}