Skip to main content

eval_magic/cli/
mod.rs

1//! CLI surface: command-tree definition and dispatch.
2//!
3//! A `clap` derive tree owns flag parsing and the generated help.
4//!
5//! The command tree lives in [`args`]; the per-command handlers live in
6//! [`commands`], grouped by concern. This module is the thin coordinator: parse,
7//! dispatch, and the shared context/iteration helpers the handlers reuse.
8
9use std::path::{Path, PathBuf};
10
11use anyhow::{anyhow, bail};
12use clap::Parser;
13
14use crate::core::{DetectInput, Harness, RunContext, detect_run_context};
15
16mod args;
17mod commands;
18mod help;
19mod run;
20
21use args::{Cli, Commands, CommonArgs, RunArgs};
22use commands::*;
23
24/// Parse process arguments, initialize the harness descriptor registry (every
25/// layer, including an optional `--harness-file`), dispatch to the selected
26/// subcommand, and return its result. Called by the binary entry point.
27pub fn run() -> anyhow::Result<()> {
28    let cli = Cli::parse();
29    // The hidden guard hooks fire on every PreToolUse in a dispatched session
30    // and only ever need the embedded descriptors — skip layered discovery so
31    // a broken user descriptor can't add noise or latency per tool call (the
32    // lazy registry fallback serves them embedded-only).
33    let is_guard_hook = matches!(
34        cli.command,
35        Some(Commands::Guard { .. } | Commands::GuardCodex { .. } | Commands::GuardHook { .. })
36    );
37    if !is_guard_hook {
38        crate::adapters::registry::init_registry(cli.harness_file.as_deref().map(Path::new))?;
39    }
40    dispatch(cli.command)
41}
42
43fn dispatch(command: Option<Commands>) -> anyhow::Result<()> {
44    // No subcommand means the default `run` action.
45    let command = command.unwrap_or(Commands::Run(RunArgs {
46        common: CommonArgs {
47            skill_dir: None,
48            skill: None,
49            iteration: None,
50            mode: None,
51            harness: None,
52            workspace_dir: None,
53            only: None,
54            skip: None,
55            overwrite: false,
56        },
57        baseline: None,
58        bootstrap: None,
59        dry_run: false,
60        no_stage: false,
61        guard: false,
62        no_guard: false,
63        stage_name: None,
64        plan_mode: false,
65        runs: 1,
66        agent_model: None,
67        judge_model: None,
68        label: None,
69    }));
70
71    match command {
72        Commands::Run(args) => run_run(args),
73        Commands::DispatchTask(args) => run_dispatch_task(args),
74        Commands::Ingest(args) => run_ingest(args),
75        Commands::Finalize(args) => run_finalize(args),
76        Commands::Init(args) => run_init(args),
77        Commands::Validate(args) => run_validate(args),
78        Commands::TeardownGuard(_) => run_teardown_guard(),
79        Commands::Guard { marker } => run_guard(marker),
80        Commands::GuardCodex { marker } => run_guard_codex(marker),
81        Commands::GuardHook { harness, marker } => run_guard_hook(&harness, marker),
82        Commands::RecordRuns(args) => run_record_runs(args),
83        Commands::FillTranscripts(args) => run_fill_transcripts(args),
84        Commands::DetectStrayWrites(args) => run_detect_stray_writes(args),
85        Commands::Grade(args) => run_grade(args),
86        Commands::Aggregate(args) => run_aggregate(args),
87        Commands::Harness(args) => run_harness(args),
88        Commands::Snapshot(args) => run_snapshot(args),
89        Commands::Teardown(args) => run_teardown(args),
90        Commands::PromoteBaseline(args) => run_promote_baseline(args),
91    }
92}
93
94/// Resolve a [`RunContext`] from the shared flags (skill dir/name, workspace,
95/// harness). Used by every post-dispatch stage handler.
96pub(crate) fn run_context_from(args: &CommonArgs) -> anyhow::Result<RunContext> {
97    run_context_with_bootstrap(args, None)
98}
99
100/// Like [`run_context_from`], but threads an optional `--bootstrap` file (only
101/// the `run` orchestrator consumes it; post-dispatch stages pass `None`).
102pub(crate) fn run_context_with_bootstrap(
103    args: &CommonArgs,
104    bootstrap: Option<String>,
105) -> anyhow::Result<RunContext> {
106    // `--harness` parses as a plain string so runtime-loaded descriptors can
107    // name harnesses clap never saw; resolution against the registry happens
108    // here, after parsing.
109    let harness = args.harness.as_deref().map(Harness::resolve).transpose()?;
110    Ok(detect_run_context(DetectInput {
111        skill_dir: args.skill_dir.clone(),
112        skill: args.skill.clone(),
113        bootstrap,
114        workspace_dir: args.workspace_dir.clone(),
115        harness,
116        cwd: None,
117    })?)
118}
119
120/// Split a comma-separated `--only`/`--skip` value into trimmed, non-empty ids.
121pub(crate) fn parse_id_list(v: Option<&str>) -> Option<Vec<String>> {
122    v.map(|s| {
123        s.split(',')
124            .map(|x| x.trim().to_string())
125            .filter(|x| !x.is_empty())
126            .collect()
127    })
128}
129
130/// Render a fully self-sufficient target selector for the current run context.
131///
132/// Always names `--skill-dir`, `--skill`, and `--workspace-dir` (all three are
133/// always populated in [`RunContext`] and always re-resolve), so the printed
134/// "Next:" commands are copy-pasteable from any cwd — not just the one `run`
135/// happened to start in. The absolute `--workspace-dir` is what lets the human
136/// run `ingest`/`finalize` from a per-`(group, condition)` env dir: without it,
137/// `workspace_root` would default to `<cwd>/.eval-magic` (`detect_run_context`)
138/// and the iteration tree above the env would not resolve.
139pub(crate) fn command_target_args(ctx: &RunContext) -> String {
140    format!(
141        " --skill-dir {} --skill {} --workspace-dir {}",
142        ctx.skill_dir.display(),
143        ctx.skill_name,
144        ctx.workspace_root.display(),
145    )
146}
147
148/// Resolve the explicit iteration, or default to the latest existing
149/// `iteration-<n>` under `<workspace>/<skill>`.
150pub(crate) fn resolve_iteration(ctx: &RunContext, iteration: Option<u32>) -> anyhow::Result<u32> {
151    if let Some(iteration) = iteration {
152        return Ok(iteration);
153    }
154
155    let skill_workspace = ctx.workspace_root.join(&ctx.skill_name);
156    let entries = std::fs::read_dir(&skill_workspace).map_err(|_| {
157        anyhow!(
158            "missing --iteration (no iterations found for {})",
159            ctx.skill_name
160        )
161    })?;
162    entries
163        .flatten()
164        .filter_map(|entry| {
165            entry
166                .file_name()
167                .to_string_lossy()
168                .strip_prefix("iteration-")
169                .and_then(|n| n.parse::<u32>().ok())
170        })
171        .max()
172        .ok_or_else(|| {
173            anyhow!(
174                "missing --iteration (no iterations found for {})",
175                ctx.skill_name
176            )
177        })
178}
179
180/// The iteration directory for a run: `<workspace>/<skill>/iteration-<n>`.
181/// Defaults to the latest existing iteration when `--iteration` is absent.
182pub(crate) fn iteration_dir(ctx: &RunContext, iteration: Option<u32>) -> anyhow::Result<PathBuf> {
183    let iteration = resolve_iteration(ctx, iteration)?;
184    let dir = ctx
185        .workspace_root
186        .join(&ctx.skill_name)
187        .join(format!("iteration-{iteration}"));
188    if !dir.is_dir() {
189        bail!("not found: {}", dir.display());
190    }
191    Ok(dir)
192}
193
194/// The env directories a run staged under `iteration_dir`: one
195/// `env-<group>-<condition>/` per `(group, condition)`. A best-effort directory
196/// scan (returns empty when the dir can't be read), used by `teardown`/`finalize`
197/// to walk every env's write guard. Preferred over reading `dispatch.json` because
198/// it has no parse-failure mode, needs no path re-basing (recorded env dirs can be
199/// relative), and the only `env-*` children of an iteration dir are the staged envs.
200pub(crate) fn staged_env_roots(iteration_dir: &Path) -> Vec<PathBuf> {
201    let Ok(entries) = std::fs::read_dir(iteration_dir) else {
202        return Vec::new();
203    };
204    entries
205        .flatten()
206        .filter(|e| e.path().is_dir())
207        .filter(|e| {
208            let name = e.file_name();
209            let name = name.to_string_lossy();
210            name == "env" || name.starts_with("env-")
211        })
212        .map(|e| e.path())
213        .collect()
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use std::fs;
220    use std::path::Path;
221    use tempfile::TempDir;
222
223    /// Create `<root>/<parent>/<name>/SKILL.md` and return the skill subdir.
224    fn make_skill(root: &Path, parent: &str, name: &str) -> PathBuf {
225        let subdir = root.join(parent).join(name);
226        fs::create_dir_all(&subdir).unwrap();
227        fs::write(
228            subdir.join("SKILL.md"),
229            format!("---\nname: {name}\ndescription: test\n---\n\nbody\n"),
230        )
231        .unwrap();
232        subdir
233    }
234
235    /// The selector must be copy-pasteable: even when `run` was invoked from
236    /// inside the skill dir (the case that used to render an empty selector), it
237    /// must name both `--skill-dir` and `--skill`, and re-resolve to the same
238    /// skill from an unrelated cwd.
239    #[test]
240    fn target_args_are_self_sufficient_when_run_from_inside_skill_dir() {
241        let tmp = TempDir::new().unwrap();
242        let root = fs::canonicalize(tmp.path()).unwrap();
243        let skill_subdir = make_skill(&root, "skills", "mr-review");
244
245        // Mimic `run` started from inside the skill dir: no --skill-dir/--skill.
246        let ctx = detect_run_context(DetectInput {
247            cwd: Some(skill_subdir.clone()),
248            ..Default::default()
249        })
250        .unwrap();
251
252        let args = command_target_args(&ctx);
253        assert!(
254            args.contains("--skill-dir"),
255            "selector names --skill-dir: {args}"
256        );
257        assert!(
258            args.contains("--skill mr-review"),
259            "selector names --skill: {args}"
260        );
261
262        // Round-trip: feeding the rendered selector back from an unrelated cwd
263        // resolves the same skill.
264        let other = root.join("elsewhere");
265        fs::create_dir_all(&other).unwrap();
266        let resolved = detect_run_context(DetectInput {
267            skill_dir: Some(ctx.skill_dir.display().to_string()),
268            skill: Some(ctx.skill_name.clone()),
269            cwd: Some(other),
270            ..Default::default()
271        })
272        .unwrap();
273        assert_eq!(resolved.skill_subdir, ctx.skill_subdir);
274    }
275
276    /// The human runs `ingest`/`finalize` from a per-`(group, condition)` env dir.
277    /// Without an explicit workspace root those commands default `workspace_root`
278    /// to `<cwd>/.eval-magic` and bail "not found", so the selector must carry an
279    /// absolute `--workspace-dir` pointing at the real workspace above the env.
280    #[test]
281    fn target_args_carry_absolute_workspace_dir() {
282        let tmp = TempDir::new().unwrap();
283        let root = fs::canonicalize(tmp.path()).unwrap();
284        let skill_subdir = make_skill(&root, "skills", "mr-review");
285
286        let ctx = detect_run_context(DetectInput {
287            cwd: Some(skill_subdir),
288            ..Default::default()
289        })
290        .unwrap();
291
292        let args = command_target_args(&ctx);
293        assert!(
294            args.contains(&format!("--workspace-dir {}", ctx.workspace_root.display())),
295            "selector names absolute --workspace-dir: {args}"
296        );
297        assert!(
298            ctx.workspace_root.is_absolute(),
299            "workspace_root is absolute: {}",
300            ctx.workspace_root.display()
301        );
302
303        // Round-trip from an env-like cwd below the workspace: feeding the
304        // selector's roots back resolves the SAME workspace, not
305        // `<cwd>/.eval-magic`.
306        let env_like = ctx
307            .workspace_root
308            .join("mr-review")
309            .join("iteration-1")
310            .join("env");
311        fs::create_dir_all(&env_like).unwrap();
312        let resolved = detect_run_context(DetectInput {
313            skill_dir: Some(ctx.skill_dir.display().to_string()),
314            skill: Some(ctx.skill_name.clone()),
315            workspace_dir: Some(ctx.workspace_root.display().to_string()),
316            cwd: Some(env_like),
317            ..Default::default()
318        })
319        .unwrap();
320        assert_eq!(resolved.workspace_root, ctx.workspace_root);
321    }
322}