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::adapters::{config_dir_from_env, resolve_subagents_dir_for_session};
15use crate::core::{DetectInput, DispatchMechanism, 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            run_mode: None,
41            workspace_dir: None,
42            subagents_dir: None,
43            session_id: None,
44            only: None,
45            skip: None,
46            overwrite: false,
47        },
48        baseline: None,
49        bootstrap: None,
50        dry_run: false,
51        no_stage: false,
52        guard: false,
53        stage_name: None,
54        plan_mode: false,
55        runs: 1,
56        agent_model: None,
57        judge_model: None,
58        label: None,
59    }));
60
61    match command {
62        Commands::Run(args) => run_run(args),
63        Commands::Ingest(args) => run_ingest(args),
64        Commands::Finalize(args) => run_finalize(args),
65        Commands::SwitchCondition(args) => run_switch_condition(args),
66        Commands::ResetBatch(args) => run_reset_batch(args),
67        Commands::Init(args) => run_init(args),
68        Commands::Validate(args) => run_validate(args),
69        Commands::TeardownGuard(_) => run_teardown_guard(),
70        Commands::Guard { marker } => run_guard(marker),
71        Commands::GuardCodex { marker } => run_guard_codex(marker),
72        Commands::RecordRuns(args) => run_record_runs(args),
73        Commands::FillTranscripts(args) => run_fill_transcripts(args),
74        Commands::DetectStrayWrites(args) => run_detect_stray_writes(args),
75        Commands::Grade(args) => run_grade(args),
76        Commands::Aggregate(args) => run_aggregate(args),
77        Commands::Snapshot(args) => run_snapshot(args),
78        Commands::Teardown(args) => run_teardown(args),
79        Commands::PromoteBaseline(args) => run_promote_baseline(args),
80    }
81}
82
83/// Resolve a [`RunContext`] from the shared flags (skill dir/name, workspace,
84/// harness). Used by every post-dispatch stage handler.
85pub(crate) fn run_context_from(args: &CommonArgs) -> anyhow::Result<RunContext> {
86    run_context_with_bootstrap(args, None)
87}
88
89/// Like [`run_context_from`], but threads an optional `--bootstrap` file (only
90/// the `run` orchestrator consumes it; post-dispatch stages pass `None`).
91pub(crate) fn run_context_with_bootstrap(
92    args: &CommonArgs,
93    bootstrap: Option<String>,
94) -> anyhow::Result<RunContext> {
95    Ok(detect_run_context(DetectInput {
96        skill_dir: args.skill_dir.clone(),
97        skill: args.skill.clone(),
98        bootstrap,
99        workspace_dir: args.workspace_dir.clone(),
100        harness: args.harness,
101        run_mode: args.run_mode,
102        cwd: None,
103    })?)
104}
105
106/// Split a comma-separated `--only`/`--skip` value into trimmed, non-empty ids.
107pub(crate) fn parse_id_list(v: Option<&str>) -> Option<Vec<String>> {
108    v.map(|s| {
109        s.split(',')
110            .map(|x| x.trim().to_string())
111            .filter(|x| !x.is_empty())
112            .collect()
113    })
114}
115
116/// Render a fully self-sufficient target selector for the current run context.
117///
118/// Always names `--skill-dir`, `--skill`, and `--workspace-dir` (all three are
119/// always populated in [`RunContext`] and always re-resolve), so the printed
120/// "Next:" commands are copy-pasteable from any cwd — not just the one `run`
121/// happened to start in. The absolute `--workspace-dir` is what lets the isolated
122/// session run `ingest`/`finalize`/`switch-condition` from `cwd = iteration-N/env/`:
123/// without it, `workspace_root` would default to `<cwd>/.eval-magic`
124/// (`detect_run_context`) and the iteration tree above the env would not resolve.
125pub(crate) fn command_target_args(ctx: &RunContext) -> String {
126    format!(
127        " --skill-dir {} --skill {} --workspace-dir {} --run-mode {}",
128        ctx.skill_dir.display(),
129        ctx.skill_name,
130        ctx.workspace_root.display(),
131        ctx.run_mode.as_str()
132    )
133}
134
135/// Resolve the explicit iteration, or default to the latest existing
136/// `iteration-<n>` under `<workspace>/<skill>`.
137pub(crate) fn resolve_iteration(ctx: &RunContext, iteration: Option<u32>) -> anyhow::Result<u32> {
138    if let Some(iteration) = iteration {
139        return Ok(iteration);
140    }
141
142    let skill_workspace = ctx.workspace_root.join(&ctx.skill_name);
143    let entries = std::fs::read_dir(&skill_workspace).map_err(|_| {
144        anyhow!(
145            "missing --iteration (no iterations found for {})",
146            ctx.skill_name
147        )
148    })?;
149    entries
150        .flatten()
151        .filter_map(|entry| {
152            entry
153                .file_name()
154                .to_string_lossy()
155                .strip_prefix("iteration-")
156                .and_then(|n| n.parse::<u32>().ok())
157        })
158        .max()
159        .ok_or_else(|| {
160            anyhow!(
161                "missing --iteration (no iterations found for {})",
162                ctx.skill_name
163            )
164        })
165}
166
167/// The iteration directory for a run: `<workspace>/<skill>/iteration-<n>`.
168/// Defaults to the latest existing iteration when `--iteration` is absent.
169pub(crate) fn iteration_dir(ctx: &RunContext, iteration: Option<u32>) -> anyhow::Result<PathBuf> {
170    let iteration = resolve_iteration(ctx, iteration)?;
171    let dir = ctx
172        .workspace_root
173        .join(&ctx.skill_name)
174        .join(format!("iteration-{iteration}"));
175    if !dir.is_dir() {
176        bail!("not found: {}", dir.display());
177    }
178    Ok(dir)
179}
180
181/// The env directories a run staged under `iteration_dir`: the single `env/` for
182/// the InSession mechanism, or one `env-<group>-<condition>/` per `(group, condition)`
183/// for Cli. A best-effort directory scan (returns empty when the dir can't be read),
184/// used by `teardown`/`finalize` to walk every env's write guard. Preferred over
185/// reading `dispatch.json` because it has no parse-failure mode, needs no path
186/// re-basing (recorded env dirs can be relative), and the only `env`/`env-*` children
187/// of an iteration dir are the staged envs.
188pub(crate) fn staged_env_roots(iteration_dir: &Path) -> Vec<PathBuf> {
189    let Ok(entries) = std::fs::read_dir(iteration_dir) else {
190        return Vec::new();
191    };
192    entries
193        .flatten()
194        .filter(|e| e.path().is_dir())
195        .filter(|e| {
196            let name = e.file_name();
197            let name = name.to_string_lossy();
198            name == "env" || name.starts_with("env-")
199        })
200        .map(|e| e.path())
201        .collect()
202}
203
204/// Resolve the subagents transcript dir for an in-session stage that reads
205/// transcripts. The subagents dir is the `InSession` transcript source, so this
206/// is keyed on the dispatch *mechanism*, not the harness: `Cli`-mechanism runs
207/// (Codex; Claude Code hybrid/headless) read each task's `outputs/<events>.jsonl`
208/// and resolve to `None` — they must never bail on a missing
209/// `CLAUDE_CODE_SESSION_ID`. For the `InSession` mechanism (Claude Code
210/// interactive), precedence is: an explicit `--subagents-dir` (validated to
211/// exist) wins; otherwise resolve from a session id — the `--session-id` flag if
212/// given, else the `CLAUDE_CODE_SESSION_ID` env var Claude Code sets in the
213/// orchestrating agent's shell — locating
214/// `<config>/projects/<cwd-slug>/<session-id>/subagents/` (scanning `projects/*`
215/// if the cwd slug differs).
216pub(crate) fn resolve_subagents_dir(
217    mechanism: DispatchMechanism,
218    subagents_dir: Option<&str>,
219    session_id: Option<&str>,
220) -> anyhow::Result<Option<PathBuf>> {
221    if mechanism != DispatchMechanism::InSession {
222        return Ok(None);
223    }
224    if let Some(dir) = subagents_dir {
225        let path = PathBuf::from(dir);
226        if !path.exists() {
227            bail!("subagents-dir not found: {}", path.display());
228        }
229        return Ok(Some(path));
230    }
231    let session = session_id
232        .map(str::to_string)
233        .or_else(|| std::env::var("CLAUDE_CODE_SESSION_ID").ok())
234        .filter(|s| !s.trim().is_empty());
235    let Some(session) = session else {
236        bail!(
237            "could not auto-resolve the subagents dir: CLAUDE_CODE_SESSION_ID is not set. \
238             Re-run inside the Claude Code session that dispatched the subagents, or pass \
239             --session-id <id> or --subagents-dir <path>."
240        );
241    };
242    let config_dir = config_dir_from_env();
243    let cwd = std::env::current_dir()?;
244    match resolve_subagents_dir_for_session(&config_dir, &cwd, &session) {
245        Some(path) => Ok(Some(path)),
246        None => bail!(
247            "no subagents dir found for session {session} under {}/projects/. The session may \
248             not have dispatched any subagents (or lives under a different CLAUDE_CONFIG_DIR). \
249             Pass --subagents-dir <path> to override.",
250            config_dir.display()
251        ),
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use std::fs;
259    use std::path::Path;
260    use tempfile::TempDir;
261
262    /// Create `<root>/<parent>/<name>/SKILL.md` and return the skill subdir.
263    fn make_skill(root: &Path, parent: &str, name: &str) -> PathBuf {
264        let subdir = root.join(parent).join(name);
265        fs::create_dir_all(&subdir).unwrap();
266        fs::write(
267            subdir.join("SKILL.md"),
268            format!("---\nname: {name}\ndescription: test\n---\n\nbody\n"),
269        )
270        .unwrap();
271        subdir
272    }
273
274    /// The selector must be copy-pasteable: even when `run` was invoked from
275    /// inside the skill dir (the case that used to render an empty selector), it
276    /// must name both `--skill-dir` and `--skill`, and re-resolve to the same
277    /// skill from an unrelated cwd.
278    #[test]
279    fn target_args_are_self_sufficient_when_run_from_inside_skill_dir() {
280        let tmp = TempDir::new().unwrap();
281        let root = fs::canonicalize(tmp.path()).unwrap();
282        let skill_subdir = make_skill(&root, "skills", "mr-review");
283
284        // Mimic `run` started from inside the skill dir: no --skill-dir/--skill.
285        let ctx = detect_run_context(DetectInput {
286            cwd: Some(skill_subdir.clone()),
287            ..Default::default()
288        })
289        .unwrap();
290
291        let args = command_target_args(&ctx);
292        assert!(
293            args.contains("--skill-dir"),
294            "selector names --skill-dir: {args}"
295        );
296        assert!(
297            args.contains("--skill mr-review"),
298            "selector names --skill: {args}"
299        );
300
301        // Round-trip: feeding the rendered selector back from an unrelated cwd
302        // resolves the same skill.
303        let other = root.join("elsewhere");
304        fs::create_dir_all(&other).unwrap();
305        let resolved = detect_run_context(DetectInput {
306            skill_dir: Some(ctx.skill_dir.display().to_string()),
307            skill: Some(ctx.skill_name.clone()),
308            cwd: Some(other),
309            ..Default::default()
310        })
311        .unwrap();
312        assert_eq!(resolved.skill_subdir, ctx.skill_subdir);
313    }
314
315    /// The isolated session runs `ingest`/`finalize`/`switch-condition` from
316    /// `cwd = iteration-N/env/`. Without an explicit workspace root those commands
317    /// default `workspace_root` to `<cwd>/.eval-magic` and bail "not found",
318    /// so the selector must carry an absolute `--workspace-dir` pointing at the
319    /// real workspace above the env.
320    #[test]
321    fn target_args_carry_absolute_workspace_dir() {
322        let tmp = TempDir::new().unwrap();
323        let root = fs::canonicalize(tmp.path()).unwrap();
324        let skill_subdir = make_skill(&root, "skills", "mr-review");
325
326        let ctx = detect_run_context(DetectInput {
327            cwd: Some(skill_subdir),
328            ..Default::default()
329        })
330        .unwrap();
331
332        let args = command_target_args(&ctx);
333        assert!(
334            args.contains(&format!("--workspace-dir {}", ctx.workspace_root.display())),
335            "selector names absolute --workspace-dir: {args}"
336        );
337        assert!(
338            ctx.workspace_root.is_absolute(),
339            "workspace_root is absolute: {}",
340            ctx.workspace_root.display()
341        );
342
343        // Round-trip from an env-like cwd below the workspace: feeding the
344        // selector's roots back resolves the SAME workspace, not
345        // `<cwd>/.eval-magic`.
346        let env_like = ctx
347            .workspace_root
348            .join("mr-review")
349            .join("iteration-1")
350            .join("env");
351        fs::create_dir_all(&env_like).unwrap();
352        let resolved = detect_run_context(DetectInput {
353            skill_dir: Some(ctx.skill_dir.display().to_string()),
354            skill: Some(ctx.skill_name.clone()),
355            workspace_dir: Some(ctx.workspace_root.display().to_string()),
356            cwd: Some(env_like),
357            ..Default::default()
358        })
359        .unwrap();
360        assert_eq!(resolved.workspace_root, ctx.workspace_root);
361    }
362
363    #[test]
364    fn resolve_subagents_dir_is_none_for_cli_mechanism() {
365        // The subagents dir is the InSession transcript source. Cli-mechanism
366        // runs (Codex; Claude Code hybrid/headless) read each task's events file,
367        // so resolution is a no-op — and must NOT bail on a missing
368        // CLAUDE_CODE_SESSION_ID. This is the regression: the old harness-keyed
369        // gate forced session resolution for Claude Code and aborted under
370        // hybrid/headless. The Cli arm returns before reading any env var, so this
371        // is deterministic regardless of the test runner's environment.
372        assert_eq!(
373            resolve_subagents_dir(DispatchMechanism::Cli, None, None).unwrap(),
374            None
375        );
376        // A passed --subagents-dir is ignored in Cli mode (the events file is the
377        // source), so it resolves to None without touching the filesystem.
378        assert_eq!(
379            resolve_subagents_dir(DispatchMechanism::Cli, Some("/whatever"), None).unwrap(),
380            None
381        );
382    }
383
384    #[test]
385    fn resolve_subagents_dir_uses_existing_explicit_dir() {
386        // InSession (Claude Code interactive): an explicit, existing
387        // --subagents-dir wins over any session-id resolution.
388        let tmp = TempDir::new().unwrap();
389        let resolved = resolve_subagents_dir(
390            DispatchMechanism::InSession,
391            Some(&tmp.path().display().to_string()),
392            None,
393        )
394        .unwrap();
395        assert_eq!(resolved, Some(tmp.path().to_path_buf()));
396    }
397
398    #[test]
399    fn resolve_subagents_dir_errors_when_explicit_dir_missing() {
400        // InSession with an explicit --subagents-dir that doesn't exist is a hard
401        // error (not a silent fallback to session-id resolution).
402        let err = resolve_subagents_dir(
403            DispatchMechanism::InSession,
404            Some("/no/such/subagents/dir/xyz"),
405            None,
406        )
407        .unwrap_err();
408        assert!(
409            err.to_string().contains("subagents-dir not found"),
410            "got: {err}"
411        );
412    }
413}