1use 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
25pub fn run() -> anyhow::Result<()> {
28 dispatch(Cli::parse().command)
29}
30
31fn dispatch(command: Option<Commands>) -> anyhow::Result<()> {
32 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
83pub(crate) fn run_context_from(args: &CommonArgs) -> anyhow::Result<RunContext> {
86 run_context_with_bootstrap(args, None)
87}
88
89pub(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
106pub(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
116pub(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
135pub(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
167pub(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
181pub(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
204pub(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 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 #[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 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 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 #[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 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 assert_eq!(
373 resolve_subagents_dir(DispatchMechanism::Cli, None, None).unwrap(),
374 None
375 );
376 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 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 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}