1use 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
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 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
80pub(crate) fn run_context_from(args: &CommonArgs) -> anyhow::Result<RunContext> {
83 run_context_with_bootstrap(args, None)
84}
85
86pub(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
102pub(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
112pub(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
125pub(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
157pub(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
171pub(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 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 #[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 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 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 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}