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",
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    /// Write a local-hook forwarder into a non-Husky core.hooksPath directory.
98    #[arg(long)]
99    pub inject_forwarder: bool,
100}
101
102#[derive(Debug, Args)]
103pub struct ReviewArgs {
104    #[command(subcommand)]
105    pub command: Option<ReviewCommand>,
106
107    #[arg(value_name = "SHA", conflicts_with = "staged")]
108    pub target: Option<String>,
109
110    #[arg(long)]
111    pub staged: bool,
112
113    #[arg(long, value_enum, value_name = "SCOPE", default_value_t = ReviewScope::Commit)]
114    pub scope: ReviewScope,
115
116    #[arg(long, value_name = "REF")]
117    pub base: Option<String>,
118
119    #[arg(long, value_enum, value_name = "AGENT")]
120    pub watched_agent: Option<Agent>,
121
122    #[arg(long, value_enum, value_name = "HARNESS")]
123    pub reviewer_harness: Option<ReviewerHarness>,
124
125    #[arg(long, value_name = "MODEL")]
126    pub watched_model: Option<String>,
127
128    #[arg(long, value_name = "MODEL")]
129    pub reviewer_model: Option<String>,
130
131    #[arg(long, value_enum, value_name = "EFFORT")]
132    pub reviewer_effort: Option<crate::config::Effort>,
133
134    #[arg(long)]
135    pub allow_same_model: bool,
136
137    #[arg(long)]
138    pub strict_two_pass: bool,
139
140    #[arg(long, value_enum, value_name = "HARNESS")]
141    pub arbiter_harness: Option<ReviewerHarness>,
142
143    #[arg(long, value_name = "MODEL")]
144    pub arbiter_model: Option<String>,
145
146    #[arg(long, value_enum, value_name = "EFFORT")]
147    pub arbiter_effort: Option<crate::config::Effort>,
148
149    /// Sic the adversarial reviewer in a loop until N lies or N fuckups.
150    #[arg(long)]
151    pub strict_goal: bool,
152
153    #[arg(long, value_name = "N")]
154    pub stop_after_lies: Option<u32>,
155
156    #[arg(long, value_name = "N")]
157    pub stop_after_fuckups: Option<u32>,
158
159    #[arg(long, value_name = "N")]
160    pub max_passes: Option<u32>,
161}
162
163#[derive(Debug, Subcommand)]
164pub enum ReviewCommand {
165    /// Show tracked review run status, or all known runs when no id is provided.
166    Status {
167        #[arg(value_name = "RUN_ID")]
168        run_id: Option<String>,
169    },
170    /// Show the latest completed/failed review run, or a specific run.
171    Result {
172        #[arg(value_name = "RUN_ID")]
173        run_id: Option<String>,
174    },
175    /// Cancel a review run and remove it from the review queue.
176    ///
177    /// Queued runs and running runs whose worker has died are cancelled directly.
178    /// Pass `--force` to kill a running run whose worker is still alive.
179    Cancel {
180        #[arg(value_name = "RUN_ID")]
181        run_id: String,
182
183        /// Kill the worker process of a still-running run before cancelling it.
184        #[arg(long)]
185        force: bool,
186    },
187}
188
189#[derive(Debug, Args)]
190#[command(group(
191    ArgGroup::new("gate_mode")
192        .required(true)
193        .args(["pre_push", "commit_msg", "pre_tool_use"])
194))]
195pub struct GateArgs {
196    #[arg(long, value_name = "RANGE", conflicts_with = "commit_msg")]
197    pub pre_push: Option<String>,
198
199    #[arg(long, value_name = "FILE", conflicts_with = "pre_push")]
200    pub commit_msg: Option<PathBuf>,
201
202    #[arg(long, value_name = "FILE", requires = "commit_msg")]
203    pub claim_file: Option<PathBuf>,
204
205    #[arg(long, value_name = "FILE", requires = "commit_msg")]
206    pub diff_file: Option<PathBuf>,
207
208    #[arg(long = "fake-marker", value_name = "TOKEN", requires = "commit_msg")]
209    pub fake_markers: Vec<String>,
210
211    /// Enforcement gate: block a mutating tool call when the ledger has unresolved
212    /// rejections beyond the configured threshold.
213    #[arg(long, conflicts_with_all = ["pre_push", "commit_msg"])]
214    pub pre_tool_use: bool,
215
216    /// The tool name being gated (for `--pre-tool-use`).
217    #[arg(long, value_name = "NAME", requires = "pre_tool_use")]
218    pub tool: Option<String>,
219}
220
221#[derive(Debug, Args)]
222pub struct ReinjectArgs {
223    #[arg(long, value_enum)]
224    pub agent: Agent,
225}
226
227#[derive(Debug, Args)]
228pub struct LedgerArgs {
229    #[command(subcommand)]
230    pub command: LedgerCommand,
231}
232
233#[derive(Debug, Subcommand)]
234pub enum LedgerCommand {
235    List,
236    Show {
237        #[arg(value_name = "SHA")]
238        sha: String,
239    },
240    Resolve {
241        #[arg(value_name = "SHA")]
242        sha: String,
243    },
244    Waive {
245        #[arg(value_name = "SHA")]
246        sha: String,
247
248        #[arg(long, value_name = "REASON")]
249        reason: String,
250    },
251    Stats,
252}
253
254#[derive(Debug, Args)]
255pub struct WatchArgs {
256    #[arg(long, value_enum, value_name = "AGENT")]
257    pub watched_agent: Option<Agent>,
258
259    #[arg(long, value_enum, value_name = "HARNESS")]
260    pub reviewer_harness: Option<ReviewerHarness>,
261
262    #[arg(long, value_name = "MODEL")]
263    pub watched_model: Option<String>,
264
265    #[arg(long, value_name = "MODEL")]
266    pub reviewer_model: Option<String>,
267
268    #[arg(long, value_enum, value_name = "EFFORT")]
269    pub reviewer_effort: Option<crate::config::Effort>,
270
271    #[arg(long)]
272    pub allow_same_model: bool,
273
274    /// Drain the review queue exactly once and exit (deterministic; used in CI).
275    #[arg(long)]
276    pub once: bool,
277
278    /// Poll interval in seconds when running as a daemon (ignored with --once).
279    #[arg(long, value_name = "SECONDS", default_value_t = 5)]
280    pub poll_secs: u64,
281}
282
283#[derive(Debug, Args)]
284pub struct SkillsArgs {
285    #[command(subcommand)]
286    pub command: SkillsCommand,
287}
288
289#[derive(Debug, Subcommand)]
290pub enum SkillsCommand {
291    /// Print the embedded skill document to stdout.
292    Echo,
293    /// Write the skill document to <dir>/truth-mirror/SKILL.md.
294    Install {
295        /// Target skills directory (defaults to `.agents/skills`).
296        #[arg(long, value_name = "PATH")]
297        dir: Option<PathBuf>,
298
299        /// Overwrite an existing skill file.
300        #[arg(long)]
301        force: bool,
302    },
303}
304
305#[derive(Debug, Args)]
306pub struct HookDispatchArgs {
307    #[arg(value_enum)]
308    pub hook: HookName,
309
310    #[arg(value_name = "ARGS")]
311    pub args: Vec<String>,
312}
313
314#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
315#[value(rename_all = "kebab-case")]
316pub enum HookName {
317    CommitMsg,
318    PostCommit,
319    PrePush,
320}
321
322impl HookName {
323    pub fn as_str(self) -> &'static str {
324        match self {
325            Self::CommitMsg => "commit-msg",
326            Self::PostCommit => "post-commit",
327            Self::PrePush => "pre-push",
328        }
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use clap::CommandFactory;
335
336    use super::Cli;
337
338    #[test]
339    fn clap_contract_is_valid() {
340        Cli::command().debug_assert();
341    }
342}