1use 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
24pub fn run() -> anyhow::Result<()> {
28 let cli = Cli::parse();
29 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 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
94pub(crate) fn run_context_from(args: &CommonArgs) -> anyhow::Result<RunContext> {
97 run_context_with_bootstrap(args, None)
98}
99
100pub(crate) fn run_context_with_bootstrap(
103 args: &CommonArgs,
104 bootstrap: Option<String>,
105) -> anyhow::Result<RunContext> {
106 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
120pub(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
130pub(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
148pub(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
180pub(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
194pub(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 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 #[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 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 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 #[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 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}