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::PathBuf;
10
11use anyhow::{anyhow, bail};
12use clap::Parser;
13
14use crate::adapters::{config_dir_from_env, resolve_subagents_dir_for_session};
15use crate::core::{DetectInput, Harness, RunContext, detect_run_context};
16
17mod args;
18mod commands;
19mod help;
20mod run;
21
22use args::{Cli, Commands, CommonArgs, RunArgs};
23use commands::*;
24
25/// Parse process arguments, dispatch to the selected subcommand, and return its
26/// result. Called by the binary entry point.
27pub fn run() -> anyhow::Result<()> {
28    dispatch(Cli::parse().command)
29}
30
31fn dispatch(command: Option<Commands>) -> anyhow::Result<()> {
32    // No subcommand means the default `run` action.
33    let command = command.unwrap_or(Commands::Run(RunArgs {
34        common: CommonArgs {
35            skill_dir: None,
36            skill: None,
37            iteration: None,
38            mode: None,
39            harness: None,
40            workspace_dir: None,
41            subagents_dir: None,
42            session_id: None,
43            only: None,
44            skip: None,
45            overwrite: false,
46        },
47        baseline: None,
48        bootstrap: None,
49        dry_run: false,
50        no_stage: false,
51        guard: false,
52        stage_name: None,
53        plan_mode: false,
54        runs: 1,
55        agent_model: None,
56        judge_model: None,
57        label: None,
58    }));
59
60    match command {
61        Commands::Run(args) => run_run(args),
62        Commands::Ingest(args) => run_ingest(args),
63        Commands::Finalize(args) => run_finalize(args),
64        Commands::Init(args) => run_init(args),
65        Commands::Validate(args) => run_validate(args),
66        Commands::TeardownGuard(_) => run_teardown_guard(),
67        Commands::Guard { marker } => run_guard(marker),
68        Commands::GuardCodex { marker } => run_guard_codex(marker),
69        Commands::RecordRuns(args) => run_record_runs(args),
70        Commands::FillTranscripts(args) => run_fill_transcripts(args),
71        Commands::DetectStrayWrites(args) => run_detect_stray_writes(args),
72        Commands::Grade(args) => run_grade(args),
73        Commands::Aggregate(args) => run_aggregate(args),
74        Commands::Snapshot(args) => run_snapshot(args),
75        Commands::Teardown(args) => run_teardown(args),
76        Commands::PromoteBaseline(args) => run_promote_baseline(args),
77    }
78}
79
80/// Resolve a [`RunContext`] from the shared flags (skill dir/name, workspace,
81/// harness). Used by every post-dispatch stage handler.
82pub(crate) fn run_context_from(args: &CommonArgs) -> anyhow::Result<RunContext> {
83    run_context_with_bootstrap(args, None)
84}
85
86/// Like [`run_context_from`], but threads an optional `--bootstrap` file (only
87/// the `run` orchestrator consumes it; post-dispatch stages pass `None`).
88pub(crate) fn run_context_with_bootstrap(
89    args: &CommonArgs,
90    bootstrap: Option<String>,
91) -> anyhow::Result<RunContext> {
92    Ok(detect_run_context(DetectInput {
93        skill_dir: args.skill_dir.clone(),
94        skill: args.skill.clone(),
95        bootstrap,
96        workspace_dir: args.workspace_dir.clone(),
97        harness: args.harness,
98        cwd: None,
99    })?)
100}
101
102/// Split a comma-separated `--only`/`--skip` value into trimmed, non-empty ids.
103pub(crate) fn parse_id_list(v: Option<&str>) -> Option<Vec<String>> {
104    v.map(|s| {
105        s.split(',')
106            .map(|x| x.trim().to_string())
107            .filter(|x| !x.is_empty())
108            .collect()
109    })
110}
111
112/// Render a fully self-sufficient target selector for the current run context.
113///
114/// Always names both `--skill-dir` and `--skill` (both are always populated in
115/// [`RunContext`] and always re-resolve), so the printed "Next:" commands are
116/// copy-pasteable from any cwd — not just the one `run` happened to start in.
117pub(crate) fn command_target_args(ctx: &RunContext) -> String {
118    format!(
119        " --skill-dir {} --skill {}",
120        ctx.skill_dir.display(),
121        ctx.skill_name
122    )
123}
124
125/// Resolve the explicit iteration, or default to the latest existing
126/// `iteration-<n>` under `<workspace>/<skill>`.
127pub(crate) fn resolve_iteration(ctx: &RunContext, iteration: Option<u32>) -> anyhow::Result<u32> {
128    if let Some(iteration) = iteration {
129        return Ok(iteration);
130    }
131
132    let skill_workspace = ctx.workspace_root.join(&ctx.skill_name);
133    let entries = std::fs::read_dir(&skill_workspace).map_err(|_| {
134        anyhow!(
135            "missing --iteration (no iterations found for {})",
136            ctx.skill_name
137        )
138    })?;
139    entries
140        .flatten()
141        .filter_map(|entry| {
142            entry
143                .file_name()
144                .to_string_lossy()
145                .strip_prefix("iteration-")
146                .and_then(|n| n.parse::<u32>().ok())
147        })
148        .max()
149        .ok_or_else(|| {
150            anyhow!(
151                "missing --iteration (no iterations found for {})",
152                ctx.skill_name
153            )
154        })
155}
156
157/// The iteration directory for a run: `<workspace>/<skill>/iteration-<n>`.
158/// Defaults to the latest existing iteration when `--iteration` is absent.
159pub(crate) fn iteration_dir(ctx: &RunContext, iteration: Option<u32>) -> anyhow::Result<PathBuf> {
160    let iteration = resolve_iteration(ctx, iteration)?;
161    let dir = ctx
162        .workspace_root
163        .join(&ctx.skill_name)
164        .join(format!("iteration-{iteration}"));
165    if !dir.is_dir() {
166        bail!("not found: {}", dir.display());
167    }
168    Ok(dir)
169}
170
171/// Resolve the subagents transcript dir for a Claude Code stage that reads
172/// transcripts. Precedence: an explicit `--subagents-dir` (validated to exist)
173/// wins; otherwise resolve from a session id — the `--session-id` flag if given,
174/// else the `CLAUDE_CODE_SESSION_ID` env var Claude Code sets in the
175/// orchestrating agent's shell — locating
176/// `<config>/projects/<cwd-slug>/<session-id>/subagents/` (scanning `projects/*`
177/// if the cwd slug differs). Codex/OpenCode read `outputs/codex-events.jsonl`,
178/// so they resolve to `None`.
179pub(crate) fn resolve_subagents_dir(
180    harness: Harness,
181    subagents_dir: Option<&str>,
182    session_id: Option<&str>,
183) -> anyhow::Result<Option<PathBuf>> {
184    if harness != Harness::ClaudeCode {
185        return Ok(None);
186    }
187    if let Some(dir) = subagents_dir {
188        let path = PathBuf::from(dir);
189        if !path.exists() {
190            bail!("subagents-dir not found: {}", path.display());
191        }
192        return Ok(Some(path));
193    }
194    let session = session_id
195        .map(str::to_string)
196        .or_else(|| std::env::var("CLAUDE_CODE_SESSION_ID").ok())
197        .filter(|s| !s.trim().is_empty());
198    let Some(session) = session else {
199        bail!(
200            "could not auto-resolve the subagents dir: CLAUDE_CODE_SESSION_ID is not set. \
201             Re-run inside the Claude Code session that dispatched the subagents, or pass \
202             --session-id <id> or --subagents-dir <path>."
203        );
204    };
205    let config_dir = config_dir_from_env();
206    let cwd = std::env::current_dir()?;
207    match resolve_subagents_dir_for_session(&config_dir, &cwd, &session) {
208        Some(path) => Ok(Some(path)),
209        None => bail!(
210            "no subagents dir found for session {session} under {}/projects/. The session may \
211             not have dispatched any subagents (or lives under a different CLAUDE_CONFIG_DIR). \
212             Pass --subagents-dir <path> to override.",
213            config_dir.display()
214        ),
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use std::fs;
222    use std::path::Path;
223    use tempfile::TempDir;
224
225    /// Create `<root>/<parent>/<name>/SKILL.md` and return the skill subdir.
226    fn make_skill(root: &Path, parent: &str, name: &str) -> PathBuf {
227        let subdir = root.join(parent).join(name);
228        fs::create_dir_all(&subdir).unwrap();
229        fs::write(
230            subdir.join("SKILL.md"),
231            format!("---\nname: {name}\ndescription: test\n---\n\nbody\n"),
232        )
233        .unwrap();
234        subdir
235    }
236
237    /// The selector must be copy-pasteable: even when `run` was invoked from
238    /// inside the skill dir (the case that used to render an empty selector), it
239    /// must name both `--skill-dir` and `--skill`, and re-resolve to the same
240    /// skill from an unrelated cwd.
241    #[test]
242    fn target_args_are_self_sufficient_when_run_from_inside_skill_dir() {
243        let tmp = TempDir::new().unwrap();
244        let root = fs::canonicalize(tmp.path()).unwrap();
245        let skill_subdir = make_skill(&root, "skills", "mr-review");
246
247        // Mimic `run` started from inside the skill dir: no --skill-dir/--skill.
248        let ctx = detect_run_context(DetectInput {
249            cwd: Some(skill_subdir.clone()),
250            ..Default::default()
251        })
252        .unwrap();
253
254        let args = command_target_args(&ctx);
255        assert!(
256            args.contains("--skill-dir"),
257            "selector names --skill-dir: {args}"
258        );
259        assert!(
260            args.contains("--skill mr-review"),
261            "selector names --skill: {args}"
262        );
263
264        // Round-trip: feeding the rendered selector back from an unrelated cwd
265        // resolves the same skill.
266        let other = root.join("elsewhere");
267        fs::create_dir_all(&other).unwrap();
268        let resolved = detect_run_context(DetectInput {
269            skill_dir: Some(ctx.skill_dir.display().to_string()),
270            skill: Some(ctx.skill_name.clone()),
271            cwd: Some(other),
272            ..Default::default()
273        })
274        .unwrap();
275        assert_eq!(resolved.skill_subdir, ctx.skill_subdir);
276    }
277
278    #[test]
279    fn resolve_subagents_dir_is_none_for_non_claude_harness() {
280        // Codex/OpenCode never read a subagents dir, so resolution is a no-op
281        // even when a dir is passed.
282        assert_eq!(
283            resolve_subagents_dir(Harness::Codex, Some("/whatever"), None).unwrap(),
284            None
285        );
286        assert_eq!(
287            resolve_subagents_dir(Harness::OpenCode, None, None).unwrap(),
288            None
289        );
290    }
291
292    #[test]
293    fn resolve_subagents_dir_uses_existing_explicit_dir() {
294        let tmp = TempDir::new().unwrap();
295        let resolved = resolve_subagents_dir(
296            Harness::ClaudeCode,
297            Some(&tmp.path().display().to_string()),
298            None,
299        )
300        .unwrap();
301        assert_eq!(resolved, Some(tmp.path().to_path_buf()));
302    }
303
304    #[test]
305    fn resolve_subagents_dir_errors_when_explicit_dir_missing() {
306        let err = resolve_subagents_dir(
307            Harness::ClaudeCode,
308            Some("/no/such/subagents/dir/xyz"),
309            None,
310        )
311        .unwrap_err();
312        assert!(
313            err.to_string().contains("subagents-dir not found"),
314            "got: {err}"
315        );
316    }
317}