mod annotate;
mod config;
mod daemon;
mod export;
mod failure;
mod fleet;
mod gc;
mod harness_def;
mod json;
#[cfg(test)]
mod licenses;
mod runtime;
mod sha1;
mod sha256;
mod stats;
mod ui;
mod version;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use config::ResolvedInvocation;
use runtime::Runtime;
fn fleet_route_name(skill: &ResolvedInvocation) -> Option<&str> {
fleet::route_name(&skill.name, &skill.skill_source)
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
std::process::exit(run(&args));
}
fn run(args: &[String]) -> i32 {
let cli = match parse_cli(args) {
Ok(c) => c,
Err(e) => {
eprintln!("scsh: {e}");
eprintln!("try 'scsh --help'");
return 2;
}
};
let profile = cli.profile.as_deref();
if let Some(n) = cli.retries {
std::env::set_var("SCSH_RETRIES", n.to_string());
}
match cli.mode {
Mode::Help(topic) => {
print_help(topic);
0
}
Mode::Version => {
println!("scsh {}", version_id());
0
}
Mode::InitDemo => init_demo(),
Mode::InitBeautifulDemo => init_beautiful_demo(),
Mode::Demo { name } => demo_cmd(name.as_deref()),
Mode::InstallSkills => install_skills(false, &cli.sources, cli.global),
Mode::UpdateSkills => install_skills(true, &cli.sources, cli.global),
Mode::List => {
if cli.json {
list_profiles_json(cli.override_dot_scsh_yml.as_deref())
} else {
preflight_then(Action::List, profile, cli.verbose, cli.override_dot_scsh_yml.as_deref())
}
}
Mode::CheckProfile => check_profile_cmd(profile, cli.override_dot_scsh_yml.as_deref()),
Mode::Probe => probe_cmd(profile, cli.override_dot_scsh_yml.as_deref(), cli.json),
Mode::Run => match cli.def.as_deref() {
Some(name) => preflight_then_def(name, cli.failures.session.as_deref(), cli.resume_from.as_deref()),
None => preflight_then(Action::Run, profile, cli.verbose, cli.override_dot_scsh_yml.as_deref()),
},
Mode::UiDemo { frames } => ui::demo::run(frames),
Mode::Daemon { action } => daemon_cmd(action),
Mode::DaemonServe { mode, port } => daemon_serve(mode, port),
Mode::Failures => failures_cmd(&cli.failures),
Mode::Stats => stats_cmd(&cli.failures, profile),
Mode::Prune => prune_cmd(cli.prune_now),
Mode::Gc => gc_cmd(&cli.gc),
Mode::AnnotateCasts => annotate_casts_cmd(&cli.annotate_paths, cli.json),
Mode::ExportCasts => export_casts_cmd(&cli.export_paths, cli.output.as_deref(), cli.json),
Mode::BuildImages => {
build_images_cmd(&cli.build_harnesses, cli.build_force, cli.build_rebuild_base, cli.failures.session.clone())
}
}
}
fn annotate_model() -> String {
std::env::var("SCSH_ANNOTATE_MODEL").ok().filter(|s| !s.is_empty()).unwrap_or_else(|| "gpt-5.6-luna".into())
}
fn annotate_casts_cmd(paths: &[String], json_flag: bool) -> i32 {
use std::io::IsTerminal;
let as_json = json_flag || !std::io::stdout().is_terminal();
if paths.is_empty() {
return annotate_error(
as_json,
2,
"give one or more .cast files, e.g. scsh annotate-cast ~/.scsh/sessions/<session>/casts/foo.cast",
);
}
if !annotate::host_can_annotate() {
return annotate_error(as_json, 1, "Codex not available (need the `codex` CLI and a Codex login)");
}
if std::env::var_os("SCSH_AUTO_ANNOTATE").is_none() {
for path in paths {
let _ = std::fs::remove_file(annotate::suppression_marker(Path::new(path)));
}
}
let model = annotate_model();
let parent = paths.iter().find_map(|p| parent_session_from_cast_path(p));
let daemon_session = if daemon::Client::daemon_alive() {
let session_id = daemon::new_session_id();
let client = daemon::Client::new(session_id);
if client.register_session_with_workflow(
daemon::INTERNAL_REPO,
"",
Some("annotate"),
"annotate",
&[],
None,
parent.as_deref(),
) {
if !as_json {
eprintln!("annotate: track progress at {}", client.session_url());
}
Some(client)
} else {
None
}
} else {
None
};
let heartbeat = daemon_session.as_ref().map(|c| c.start_heartbeat(Duration::from_secs(5)));
let mut sidecars: Vec<(String, String)> = Vec::new(); let mut next_idx = 0usize;
for path in paths {
if !as_json {
eprint!("annotate {path} … ");
}
let proc_idx = if daemon_session.is_some() {
let idx = next_idx;
next_idx += 1;
let stem = std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("cast");
let mut label = format!("annotate · {stem}");
if label.len() > 60 {
label.truncate(57);
label.push('…');
}
if let Some(c) = &daemon_session {
c.proc_add(
idx,
&label,
daemon::ProcKind::Annotate,
None,
Some("codex"),
Some(model.as_str()),
None,
None,
Some(path.as_str()),
None,
);
c.proc_start(idx);
c.proc_note(idx, "summarizing…");
}
Some(idx)
} else {
None
};
let started = std::time::Instant::now();
let record_cast = daemon_session.as_ref().filter(|_| annotate::can_record_annotate()).map(|c| {
let casts = runtime::session_casts_dir(c.session_id());
let _ = std::fs::create_dir_all(&casts);
let stem = std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("cast");
casts.join(format!(
"annotate-{}-{}-utc-{}.cast",
stem.replace('/', "_"),
runtime::format_utc_timestamp(now_secs()),
runtime::random_nonce_6()
))
});
if let (Some(c), Some(idx), Some(cast)) = (&daemon_session, proc_idx, &record_cast) {
c.proc_cast(idx, &cast.to_string_lossy());
}
match annotate::annotate_cast(std::path::Path::new(path), &model, record_cast.as_deref()) {
Ok(result) => {
if let (Some(c), Some(idx)) = (&daemon_session, proc_idx) {
if let Some(ref cast) = result.cast_path {
c.proc_cast(idx, &cast.to_string_lossy());
}
c.proc_finish(idx, daemon::ProcStatus::Ok, None, None, started.elapsed().as_secs_f64());
}
if !as_json {
eprintln!("✓ {}", result.sidecar.display());
}
sidecars.push((path.clone(), result.sidecar.to_string_lossy().into_owned()));
}
Err(err) => {
if let (Some(c), Some(idx)) = (&daemon_session, proc_idx) {
c.proc_finish(
idx,
daemon::ProcStatus::Fail,
Some(err.failure_reason()),
Some(&err.to_string()),
started.elapsed().as_secs_f64(),
);
}
if !as_json {
eprintln!("✗ {err}");
eprintln!(" → {}", err.hint());
}
}
}
}
if let Some(flag) = heartbeat {
flag.store(false, std::sync::atomic::Ordering::Relaxed);
}
if let Some(c) = daemon_session {
c.finish_session();
}
if as_json {
let items: Vec<String> = sidecars
.iter()
.map(|(cast, side)| format!(" {{ \"cast\": {}, \"sidecar\": {} }}", json::quote(cast), json::quote(side)))
.collect();
println!("{{\n \"annotated\": [\n{}\n ]\n}}", items.join(",\n"));
}
if sidecars.len() == paths.len() {
0
} else {
1
}
}
fn annotate_error(as_json: bool, code: i32, message: &str) -> i32 {
if as_json {
println!("{{ \"Error\": {{ \"message\": {} }} }}", json::quote(message));
} else {
eprintln!("annotate-cast: {message}");
}
code
}
fn export_casts_cmd(paths: &[String], output: Option<&str>, json_flag: bool) -> i32 {
use std::io::IsTerminal;
let streaming = output == Some("-");
let as_json = !streaming && (json_flag || !std::io::stdout().is_terminal());
if paths.is_empty() {
return export_error(
as_json,
2,
"give one or more .cast files, e.g. scsh export-cast ~/.scsh/sessions/<session>/casts/foo.cast",
);
}
if output.is_some() && paths.len() != 1 {
return export_error(as_json, 2, &format!("-o applies to exactly one cast ({} given)", paths.len()));
}
let mut entries: Vec<String> = Vec::new(); let mut worst = 0;
for path in paths {
let cast_path = std::path::Path::new(path);
match export_one_cast(cast_path, output) {
Ok((out_name, bytes, chapters, warning)) => {
if !as_json && !streaming {
ok(&format!("{path} → {out_name} ({bytes} bytes, {chapters} chapters)"));
} else if streaming && !json_flag {
eprintln!("✓ {path} → stdout ({bytes} bytes, {chapters} chapters)");
}
let warn_field = warning.map(|w| format!(", \"warning\": {}", json::quote(&w))).unwrap_or_default();
entries.push(format!(
" {{ \"input\": {}, \"output\": {}, \"bytes\": {bytes}, \"chapters\": {chapters}{warn_field} }}",
json::quote(path),
json::quote(&out_name),
));
}
Err((problem, fix)) => {
fail(&format!("{path}: {problem}"));
hint(&fix);
entries.push(format!(" {{ \"input\": {}, \"error\": {} }}", json::quote(path), json::quote(&problem)));
worst = worst.max(1);
}
}
}
if as_json {
println!("{{\n \"exported\": [\n{}\n ]\n}}", entries.join(",\n"));
}
worst
}
type ExportSummary = (String, usize, usize, Option<String>);
fn export_one_cast(cast_path: &Path, output: Option<&str>) -> Result<ExportSummary, (String, String)> {
let ndjson = std::fs::read_to_string(cast_path).map_err(|e| {
(format!("cannot read the cast: {e}"), format!("check the path — is {} a recording file?", cast_path.display()))
})?;
let (annotation, warning) = match export::load_sidecar(cast_path) {
export::Sidecar::Found(a) => (Some(a), None),
export::Sidecar::Absent => (None, None),
export::Sidecar::Malformed(p) => {
let w = format!("malformed sidecar {} — exporting without summary/chapters", p.display());
warn(&w);
(None, Some(w))
}
};
let stem = export::cast_stem(cast_path);
let page = export::render_page(&ndjson, &stem, annotation.as_ref()).map_err(|e| {
(
e.to_string(),
"give an asciinema recording (asciicast v1/v2/v3), e.g. one under ~/.scsh/sessions/<session>/casts/".to_string(),
)
})?;
let chapters = annotation.as_ref().map(|a| a.chapters.len()).unwrap_or(0);
let out_name = if output == Some("-") {
use std::io::Write;
let mut stdout = std::io::stdout();
stdout.write_all(page.as_bytes()).and_then(|()| stdout.flush()).map_err(|e| {
(format!("cannot stream the page: {e}"), "check what stdout is connected to and retry".to_string())
})?;
"stdout".to_string()
} else {
let out_path = output.map(std::path::PathBuf::from).unwrap_or_else(|| export::default_output_path(cast_path));
atomic_write(&out_path, page.as_bytes()).map_err(|e| {
(
format!("cannot write {}: {e}", out_path.display()),
"pick a writable output path with -o <file> (or fix the directory permissions)".to_string(),
)
})?;
out_path.display().to_string()
};
Ok((out_name, page.len(), chapters, warning))
}
fn export_error(as_json: bool, code: i32, message: &str) -> i32 {
if as_json {
println!("{{ \"Error\": {{ \"message\": {} }} }}", json::quote(message));
} else {
eprintln!("export-cast: {message}");
}
code
}
fn annotate_run_casts(
root: &Path, cast_paths: Vec<std::path::PathBuf>, client: Option<&daemon::Client>, next_proc_index: &mut usize,
) {
for cast in &cast_paths {
cache_attach_chapters(root, cast);
}
let pending: Vec<std::path::PathBuf> = cast_paths
.into_iter()
.filter(|c| {
daemon::chapters_sidecar_path(&c.to_string_lossy())
.map(|s| {
annotate::sidecar_is_stale(c, &s)
&& !annotation_in_progress(c)
&& !annotate::automatic_annotation_suppressed(c)
})
.unwrap_or(false)
})
.collect();
if pending.is_empty() || !annotate::host_can_annotate() {
return;
}
eprintln!("scsh: annotating {} cast(s) with codex · {} …", pending.len(), annotate_model());
let model = annotate_model();
let mut done = 0;
if let Some(client) = client {
std::thread::scope(|scope| {
let mut handles = Vec::with_capacity(pending.len());
for cast in pending {
let idx = *next_proc_index;
*next_proc_index += 1;
let stem = cast.file_stem().and_then(|s| s.to_str()).unwrap_or("cast");
let mut label = format!("annotate · {stem}");
if label.len() > 60 {
label.truncate(57);
label.push('…');
}
client.proc_add(
idx,
&label,
daemon::ProcKind::Annotate,
None,
Some("codex"),
Some(model.as_str()),
None,
None,
Some(cast.to_string_lossy().as_ref()),
None,
);
client.proc_start(idx);
client.proc_note(idx, "summarizing…");
let model = model.clone();
let session_id = client.session_id().to_string();
handles.push(scope.spawn(move || {
let started = std::time::Instant::now();
let stem = cast.file_stem().and_then(|s| s.to_str()).unwrap_or("cast");
let record_cast = annotate::can_record_annotate().then(|| {
let casts_dir = runtime::session_casts_dir(&session_id);
let _ = std::fs::create_dir_all(&casts_dir);
casts_dir.join(format!(
"annotate-{}-{}-utc-{}.cast",
stem.replace('/', "_"),
runtime::format_utc_timestamp(now_secs()),
runtime::random_nonce_6()
))
});
if let Some(ref rc) = record_cast {
client.proc_cast(idx, &rc.to_string_lossy());
}
let result = annotate::annotate_cast(&cast, &model, record_cast.as_deref());
let elapsed = started.elapsed().as_secs_f64();
match &result {
Ok(res) => {
if let Some(ref cpath) = res.cast_path {
client.proc_cast(idx, &cpath.to_string_lossy());
} else {
client.proc_cast(idx, "");
}
client.proc_finish(idx, daemon::ProcStatus::Ok, None, None, elapsed);
}
Err(err) => client.proc_finish(
idx,
daemon::ProcStatus::Fail,
Some(err.failure_reason()),
Some(&err.to_string()),
elapsed,
),
}
(cast, result.is_ok())
}));
}
for h in handles {
if let Ok((cast, true)) = h.join() {
done += 1;
cache_attach_chapters(root, &cast);
}
}
});
} else {
let handles: Vec<_> = pending
.into_iter()
.map(|cast| {
let model = model.clone();
std::thread::spawn(move || {
let result = annotate::annotate_cast_with(&cast, &model, annotate::run_codex);
if let Err(err) = &result {
eprintln!("scsh: annotate failed for {}: {err}", cast.display());
}
(cast, result.is_ok())
})
})
.collect();
for h in handles {
if let Ok((cast, true)) = h.join() {
done += 1;
cache_attach_chapters(root, &cast);
}
}
}
eprintln!("scsh: annotated {done} cast(s)");
}
fn annotation_marker(cast: &Path) -> PathBuf {
let mut marker = cast.as_os_str().to_os_string();
marker.push(".annotating");
PathBuf::from(marker)
}
const ANNOTATION_MARKER_TTL: Duration = Duration::from_secs(15 * 60);
fn annotation_in_progress(cast: &Path) -> bool {
let marker = annotation_marker(cast);
let Ok(meta) = std::fs::metadata(&marker) else {
return false;
};
let fresh = meta.modified().ok().and_then(|m| m.elapsed().ok()).is_some_and(|age| age < ANNOTATION_MARKER_TTL);
if !fresh {
let _ = std::fs::remove_file(&marker);
}
fresh
}
fn spawn_cast_annotation(cast: &Path) {
if !annotate::host_can_annotate() || annotate::automatic_annotation_suppressed(cast) {
return;
}
let Some(sidecar) = daemon::chapters_sidecar_path(&cast.to_string_lossy()) else {
return;
};
if !annotate::sidecar_is_stale(cast, &sidecar) {
return;
}
if annotation_in_progress(cast) {
return;
}
let marker = annotation_marker(cast);
if std::fs::OpenOptions::new().write(true).create_new(true).open(&marker).is_err() {
return;
}
let Ok(exe) = std::env::current_exe() else {
let _ = std::fs::remove_file(marker);
return;
};
let script = format!(
"trap 'rm -f {marker}' EXIT; SCSH_AUTO_ANNOTATE=1 {exe} annotate-cast {cast} --json >/dev/null 2>&1",
marker = runtime::shell_quote(&marker.to_string_lossy()),
exe = runtime::shell_quote(&exe.to_string_lossy()),
cast = runtime::shell_quote(&cast.to_string_lossy()),
);
let mut cmd = Command::new("sh");
cmd.args(["-c", &script]).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
unsafe {
cmd.pre_exec(daemon::daemon_detach_child);
}
}
if cmd.spawn().is_err() {
let _ = std::fs::remove_file(marker);
}
}
fn parent_session_from_cast_path(path: &str) -> Option<String> {
let mut comps = std::path::Path::new(path).components();
while let Some(c) = comps.next() {
if c.as_os_str() == "sessions" {
return comps.next().map(|c| c.as_os_str().to_string_lossy().into_owned()).filter(|s| !s.is_empty());
}
}
None
}
#[derive(Clone)]
enum Mode {
Help(HelpTopic),
Version,
InitDemo,
InitBeautifulDemo,
InstallSkills,
UpdateSkills,
List,
CheckProfile,
Probe,
Run,
UiDemo {
frames: bool,
},
Daemon {
action: DaemonAction,
},
DaemonServe {
mode: daemon::DaemonMode,
port: u16,
},
Failures,
Stats,
Prune,
Gc,
AnnotateCasts,
ExportCasts,
BuildImages,
Demo {
name: Option<String>,
},
}
#[derive(Clone, Copy)]
enum DaemonAction {
Start,
Stop,
Restart,
Status,
}
#[derive(Clone)]
enum HelpTopic {
Overview,
Run,
Config,
Internals,
Cache,
Agent,
Defs,
ExitCodes,
Command(String),
}
const COMMAND_NAMES: &[&str] = &[
"run",
"list",
"build-images",
"check-profile",
"probe",
"init-demo-project",
"init-beautiful-demo",
"demo",
"installskills",
"updateskills",
"daemon",
"failures",
"stats",
"prune",
"gc",
"annotate-cast",
"export-cast",
"version",
];
fn help_command_alias(token: &str) -> Option<&'static str> {
let canonical = match token {
"run" => "run",
"list" | "ls" => "list",
"build-images" | "build-image" | "buildimages" => "build-images",
"check-profile" | "checkprofile" => "check-profile",
"probe" => "probe",
"init-demo-project" | "init" | "init-demo" => "init-demo-project",
"init-beautiful-demo" | "init-beautiful-demo-project" => "init-beautiful-demo",
"demo" | "demos" => "demo",
"installskills" | "install-skills" => "installskills",
"updateskills" | "update-skills" => "updateskills",
"daemon" => "daemon",
"failures" => "failures",
"stats" => "stats",
"prune" => "prune",
"gc" => "gc",
"annotate-cast" | "annotate-casts" | "annotate" => "annotate-cast",
"export-cast" | "export-casts" | "export" => "export-cast",
"version" => "version",
_ => return None,
};
Some(canonical)
}
fn version_id() -> String {
version::display()
}
enum Action {
List,
Run,
}
struct Cli {
mode: Mode,
profile: Option<String>,
sources: Vec<String>,
annotate_paths: Vec<String>,
export_paths: Vec<String>,
output: Option<String>,
verbose: bool,
json: bool,
failures: FailuresOpts,
prune_now: bool,
gc: gc::GcOpts,
build_harnesses: Vec<String>,
build_force: bool,
build_rebuild_base: bool,
def: Option<String>,
resume_from: Option<String>,
retries: Option<u32>,
override_dot_scsh_yml: Option<PathBuf>,
global: bool,
}
#[derive(Default)]
struct FailuresOpts {
session: Option<String>,
skill: Option<String>,
reason: Option<String>,
stats: bool,
last: Option<usize>,
harness: Option<String>,
model: Option<String>,
raw: bool,
}
fn parse_cli(args: &[String]) -> Result<Cli, String> {
let mut mode: Option<Mode> = None;
let mut profiles: Vec<String> = Vec::new();
let mut sources: Vec<String> = Vec::new();
let mut annotate_paths: Vec<String> = Vec::new();
let mut export_paths: Vec<String> = Vec::new();
let mut output: Option<String> = None;
let mut verbose = false;
let mut json = false;
let mut frames = false;
let mut failures = FailuresOpts::default();
let mut prune_now = false;
let mut gc = gc::GcOpts::default();
let mut saw_gc_dry_run = false;
let mut saw_gc_apply = false;
let mut saw_gc_flag = false;
let mut build_harnesses: Vec<String> = Vec::new();
let mut build_force = false;
let mut build_rebuild_base = false;
let mut def: Option<String> = None;
let mut resume_from: Option<String> = None;
let mut retries: Option<u32> = None;
let mut override_dot_scsh_yml: Option<PathBuf> = None;
let mut global = false;
let mut i = 0;
while i < args.len() {
let m = match args[i].as_str() {
"help" | "-h" | "--help" => {
let topic = match args.get(i + 1).map(|s| s.as_str()) {
Some("run") => {
i += 1;
HelpTopic::Run
}
Some(".scsh.yml") | Some("scsh.yml") | Some(".scsh.yaml") | Some("scsh.yaml") | Some("config")
| Some("yaml") | Some("yml") | Some("schema") => {
i += 1;
HelpTopic::Config
}
Some("internals") | Some("internal") => {
i += 1;
HelpTopic::Internals
}
Some("cache") | Some("caching") => {
i += 1;
HelpTopic::Cache
}
Some("agent") | Some("agents") | Some("agent-first") => {
i += 1;
HelpTopic::Agent
}
Some("def") | Some("defs") | Some("definition") | Some("definitions") | Some("harness")
| Some(".harness") | Some("workflow") | Some("workflows") => {
i += 1;
HelpTopic::Defs
}
Some("exitcodes") | Some("exit-codes") | Some("exit") => {
i += 1;
HelpTopic::ExitCodes
}
Some(other) if help_command_alias(other).is_some() => {
i += 1;
HelpTopic::Command(help_command_alias(other).unwrap().to_string())
}
Some(other) if !other.starts_with('-') => {
return Err(format!(
"unknown help topic '{other}' (commands: {}; topics: agent, .scsh.yml, def, internals, cache, exitcodes)",
COMMAND_NAMES.join(", ")
));
}
_ => HelpTopic::Overview,
};
Some(Mode::Help(topic))
}
"version" | "-V" | "--version" => Some(Mode::Version),
"run" => Some(Mode::Run),
"list" | "ls" => Some(Mode::List),
"check-profile" => {
i += 1;
let name = args.get(i).ok_or("check-profile needs a profile name, e.g. scsh check-profile multiply")?;
if name.trim().is_empty() {
return Err("check-profile name must not be empty".into());
}
profiles.push(name.clone());
Some(Mode::CheckProfile)
}
"probe" => Some(Mode::Probe),
"__ui-demo" => Some(Mode::UiDemo { frames: false }),
"--frames" => {
frames = true;
None
}
"init-demo-project" | "init" | "--init-demo-project" => Some(Mode::InitDemo),
"init-beautiful-demo" | "init-beautiful-demo-project" => Some(Mode::InitBeautifulDemo),
"demo" | "demos" => {
let name = match args.get(i + 1).map(|s| s.as_str()) {
Some(n) if !n.starts_with('-') => {
i += 1;
Some(n.to_string())
}
_ => None,
};
Some(Mode::Demo { name })
}
"installskills" => Some(Mode::InstallSkills),
"updateskills" => Some(Mode::UpdateSkills),
"failures" => Some(Mode::Failures),
"stats" => Some(Mode::Stats),
"--session" | "--skill" | "--reason" | "--harness" | "--model" => {
let flag = args[i].clone();
i += 1;
let value = args.get(i).ok_or_else(|| format!("{flag} needs a value"))?.clone();
match flag.as_str() {
"--session" => failures.session = Some(value),
"--skill" => failures.skill = Some(value),
"--harness" => failures.harness = Some(value),
"--model" => failures.model = Some(value),
_ => failures.reason = Some(value),
}
None
}
"--stats" => {
failures.stats = true;
None
}
"--raw" => {
failures.raw = true;
None
}
"--last" => {
i += 1;
let n = args.get(i).ok_or("--last needs a number (0 = all)")?;
failures.last = Some(n.parse().map_err(|_| format!("bad --last value '{n}'"))?);
None
}
"annotate-cast" | "annotate-casts" => Some(Mode::AnnotateCasts),
"export-cast" | "export-casts" => Some(Mode::ExportCasts),
"-o" | "--output" => {
i += 1;
let value = args.get(i).ok_or("-o needs a path (or `-` for stdout), e.g. scsh export-cast foo.cast -o -")?;
output = Some(value.clone());
None
}
"build-images" => Some(Mode::BuildImages),
"--force" => {
build_force = true;
None
}
"--rebuild-base" => {
build_rebuild_base = true;
None
}
"prune" => Some(Mode::Prune),
"--now" => {
prune_now = true;
None
}
"gc" => Some(Mode::Gc),
"--apply" => {
saw_gc_flag = true;
saw_gc_apply = true;
gc.apply = true;
None
}
"--dry-run" => {
saw_gc_flag = true;
saw_gc_dry_run = true;
gc.apply = false;
None
}
"--days" => {
saw_gc_flag = true;
i += 1;
let n = args.get(i).ok_or("--days needs a number (e.g. --days 30)")?;
gc.days = n.parse().map_err(|_| format!("bad --days value '{n}'"))?;
None
}
"--keep" => {
saw_gc_flag = true;
i += 1;
let n = args.get(i).ok_or("--keep needs a number (e.g. --keep 50)")?;
gc.keep = n.parse().map_err(|_| format!("bad --keep value '{n}'"))?;
None
}
"--legacy" => {
saw_gc_flag = true;
gc.legacy = true;
None
}
"daemon" => {
i += 1;
let sub = args.get(i).ok_or("daemon needs a subcommand: start, stop, restart, or status")?;
let action = match sub.as_str() {
"start" => DaemonAction::Start,
"stop" => DaemonAction::Stop,
"restart" => DaemonAction::Restart,
"status" => DaemonAction::Status,
other => return Err(format!("unknown daemon subcommand '{other}' (try: start, stop, restart, status)")),
};
Some(Mode::Daemon { action })
}
"__daemon-serve" => {
let mut mode = daemon::DaemonMode::Ephemeral;
let mut port = daemon::daemon_port();
loop {
i += 1;
match args.get(i).map(|s| s.as_str()) {
None => break,
Some("--mode") => {
i += 1;
let m = args.get(i).ok_or("__daemon-serve --mode needs persistent or ephemeral")?;
mode = daemon::DaemonMode::parse(m).ok_or_else(|| format!("bad daemon mode '{m}'"))?;
}
Some("--port") => {
i += 1;
let p = args.get(i).ok_or("__daemon-serve --port needs a number")?;
port = p.parse().map_err(|_| format!("bad port '{p}'"))?;
}
Some(other) if other.starts_with('-') => {
return Err(format!("unknown __daemon-serve option '{other}'"));
}
Some(_) => break,
}
}
Some(Mode::DaemonServe { mode, port })
}
"--profile" | "--profiles" => {
i += 1;
let name = args.get(i).ok_or("--profile needs a name, e.g. --profile code-review (or default,code-review)")?;
if name.trim().is_empty() {
return Err("--profile name must not be empty".into());
}
profiles.push(name.clone());
None
}
"--def" => {
i += 1;
let name = args.get(i).ok_or("--def needs a harness-definition name, e.g. scsh run --def add")?;
if name.trim().is_empty() {
return Err("--def name must not be empty".into());
}
def = Some(name.clone());
if mode.is_none() {
Some(Mode::Run)
} else {
None
}
}
"--retries" => {
i += 1;
let n = args.get(i).ok_or("--retries needs a count, e.g. --retries 10 (0 = never restart)")?;
retries = Some(n.parse().map_err(|_| format!("bad --retries value '{n}'"))?);
None
}
"--resume-from" => {
i += 1;
let id = args.get(i).ok_or("--resume-from needs a session id, e.g. --resume-from qtsiuf")?;
if id.trim().is_empty() {
return Err("--resume-from session id must not be empty".into());
}
resume_from = Some(id.trim().to_string());
None
}
"--override-dot-scsh-yml" => {
i += 1;
let path =
args.get(i).ok_or("--override-dot-scsh-yml needs a path, e.g. --override-dot-scsh-yml ~/.scsh/.scsh.yml")?;
if path.trim().is_empty() {
return Err("--override-dot-scsh-yml path must not be empty".into());
}
override_dot_scsh_yml = Some(PathBuf::from(path));
None
}
"--verbose" | "-v" => {
verbose = true;
None
}
"--json" => {
json = true;
None
}
"--global" => {
global = true;
None
}
other if matches!(mode, Some(Mode::Run | Mode::Probe)) && !other.starts_with('-') => {
profiles.push(other.to_string());
None
}
other if matches!(mode, Some(Mode::InstallSkills | Mode::UpdateSkills)) && !other.starts_with('-') => {
sources.push(other.to_string());
None
}
other if matches!(mode, Some(Mode::AnnotateCasts)) && !other.starts_with('-') => {
annotate_paths.push(other.to_string());
None
}
other if matches!(mode, Some(Mode::ExportCasts)) && !other.starts_with('-') => {
export_paths.push(other.to_string());
None
}
other if matches!(mode, Some(Mode::BuildImages)) && !other.starts_with('-') => {
build_harnesses.push(other.to_string());
None
}
other => return Err(format!("unknown command or option '{other}' (try 'scsh help')")),
};
if let Some(m) = m {
if mode.is_some() {
return Err("only one command may be given at a time".into());
}
mode = Some(m);
}
i += 1;
}
let mode = match mode.unwrap_or(Mode::Help(HelpTopic::Overview)) {
Mode::UiDemo { .. } => Mode::UiDemo { frames },
other => other,
};
let profile = if profiles.is_empty() { None } else { Some(profiles.join(",")) };
if profile.is_some() && !matches!(mode, Mode::Run | Mode::Probe | Mode::CheckProfile | Mode::Stats) {
return Err(
"profiles only apply to 'run', 'probe', and 'stats' (e.g. `scsh run code-review` or `scsh stats --profile code-review`)"
.into(),
);
}
if !sources.is_empty() && !matches!(mode, Mode::InstallSkills | Mode::UpdateSkills) {
return Err("a skills source (git URL) only applies to 'installskills' or 'updateskills'".into());
}
if global && !matches!(mode, Mode::InstallSkills | Mode::UpdateSkills) {
return Err("--global only applies to 'installskills' or 'updateskills'".into());
}
if verbose && !matches!(mode, Mode::List) {
return Err("--verbose only applies to 'list'".into());
}
if json && !matches!(mode, Mode::List | Mode::Probe | Mode::AnnotateCasts | Mode::ExportCasts) {
return Err("--json only applies to 'list', 'probe', 'annotate-cast', and 'export-cast'".into());
}
if (failures.reason.is_some() || failures.stats) && !matches!(mode, Mode::Failures) {
return Err("--reason/--stats only apply to 'failures'".into());
}
if (failures.harness.is_some() || failures.model.is_some() || failures.raw) && !matches!(mode, Mode::Stats) {
return Err("--harness/--model/--raw only apply to 'stats'".into());
}
if failures.session.is_some() && !matches!(mode, Mode::Failures | Mode::Stats | Mode::BuildImages | Mode::Run) {
return Err("--session only applies to 'failures', 'stats', 'build-images', or 'run'".into());
}
if (failures.skill.is_some() || failures.last.is_some()) && !matches!(mode, Mode::Failures | Mode::Stats) {
return Err("--skill/--last only apply to 'failures' or 'stats'".into());
}
if (build_force || build_rebuild_base) && !matches!(mode, Mode::BuildImages) {
return Err("--force/--rebuild-base only apply to 'build-images'".into());
}
if prune_now && !matches!(mode, Mode::Prune) {
return Err("--now only applies to 'prune' (e.g. `scsh prune --now`)".into());
}
if saw_gc_flag && !matches!(mode, Mode::Gc) {
return Err("--apply/--dry-run/--days/--keep/--legacy only apply to 'gc'".into());
}
if saw_gc_apply && saw_gc_dry_run {
return Err("pass either --apply or --dry-run, not both".into());
}
if !annotate_paths.is_empty() && !matches!(mode, Mode::AnnotateCasts) {
return Err("cast paths only apply to 'annotate-cast'".into());
}
if output.is_some() && !matches!(mode, Mode::ExportCasts) {
return Err("-o only applies to 'export-cast' (e.g. `scsh export-cast foo.cast -o -`)".into());
}
if def.is_some() && !matches!(mode, Mode::Run) {
return Err("--def only applies to 'run' (e.g. `scsh run --def add`)".into());
}
if def.is_some() && profile.is_some() {
return Err("--def selects a harness definition, not a profile — don't combine them".into());
}
if resume_from.is_some() && def.is_none() {
return Err(
"--resume-from only applies to 'run --def' (e.g. `scsh run --def gorgeous-pipeline --resume-from qtsiuf`)".into(),
);
}
if retries.is_some() && !matches!(mode, Mode::Run) {
return Err("--retries only applies to 'run' (e.g. `scsh run --def greet --retries 3`)".into());
}
if override_dot_scsh_yml.is_some() && !matches!(mode, Mode::Run | Mode::List | Mode::CheckProfile | Mode::Probe) {
return Err("--override-dot-scsh-yml only applies to 'run', 'list', 'check-profile', and 'probe'".into());
}
if override_dot_scsh_yml.is_some() && def.is_some() {
return Err("--override-dot-scsh-yml and --def are mutually exclusive".into());
}
Ok(Cli {
mode,
profile,
sources,
annotate_paths,
export_paths,
output,
verbose,
json,
failures,
prune_now,
gc,
build_harnesses,
build_force,
build_rebuild_base,
def,
resume_from,
retries,
override_dot_scsh_yml,
global,
})
}
fn requested_profiles(spec: Option<&str>) -> std::collections::BTreeSet<String> {
match spec {
None => std::iter::once("default".to_string()).collect(),
Some(s) => s.split([',', ';']).map(str::trim).filter(|p| !p.is_empty()).map(str::to_string).collect(),
}
}
fn select_invocations(cfg: &config::Config, profile: Option<&str>) -> Vec<ResolvedInvocation> {
let want = requested_profiles(profile);
config::expand_invocations(cfg)
.into_iter()
.filter(|s| want.contains(s.profile.as_deref().unwrap_or("default")))
.collect()
}
fn declared_profiles(cfg: &config::Config) -> Vec<String> {
let mut out = Vec::new();
for inv in config::expand_invocations(cfg) {
let p = inv.profile.as_deref().unwrap_or("default").to_string();
if !out.contains(&p) {
out.push(p);
}
}
out
}
fn preflight_git_repo_clean(is_run: bool) -> Result<PathBuf, i32> {
if runtime::which("git").is_none() {
fail("git is not installed or not on PATH");
hint(install_git_hint());
return Err(1);
}
let root = match git_root() {
Ok(r) => r,
Err(_) => {
fail("not inside a git repository");
hint(&format!("create one here with: {}", bold("git init .")));
return Err(1);
}
};
if is_run {
let dirty = uncommitted_changes(&root);
if !dirty.is_empty() {
fail(
"working tree has uncommitted changes — scsh runs a clone of committed state, \
so they would not be in the container",
);
let shown = dirty.len().min(10);
for p in &dirty[..shown] {
hint(&format!("uncommitted: {p}"));
}
if dirty.len() > shown {
hint(&format!("\u{2026}and {} more", dirty.len() - shown));
}
hint(&format!(
"commit or stash them first, then re-run: {}",
bold("git add -A && git commit -m \"Committing unstaged changes to run scsh.\"")
));
return Err(1);
}
if !tmp_is_gitignored(&root) {
fail("/tmp is not gitignored in this repository");
if !root.join(".scsh.yml").is_file() {
hint(&format!("get a ready-to-run project in one command: {}", bold("scsh init-demo-project")));
hint("(writes .scsh.yml, gitignores /tmp, scaffolds example skills, and commits)");
} else {
hint(&format!("let scsh add /tmp to .gitignore and commit it: {}", bold("scsh init-demo-project")));
}
return Err(1);
}
let _ = std::fs::create_dir_all(root.join("tmp"));
}
Ok(root)
}
fn preflight_runtime_engine(is_run: bool) -> Result<Runtime, i32> {
let rt = match runtime::detect_runtime() {
Some(rt) => rt,
None => {
let cands = runtime::runtime_candidates(cfg!(target_os = "macos")).join(", ");
fail(&format!("no container runtime found (looked for: {cands})"));
hint(install_runtime_hint());
return Err(1);
}
};
if rt.name == "docker" && runtime::is_snap_confined(&rt.path) {
hint("this is snap-packaged Docker, which can't bind-mount the system temp dir;");
hint("if skills fail to start, use Podman instead (e.g. SCSH_RUNTIME=podman)");
}
if is_run && !ui::engine::is_running(&rt.name) {
fail(&format!("{} is installed but not running", ui::engine::display_name(&rt.name)));
if let Some(cmd) = ui::engine::start_command(&rt.name, ui::Os::current()) {
hint(&format!("start it with: {}", bold(&cmd)));
}
hint("then re-run 'scsh run'");
return Err(1);
}
Ok(rt)
}
fn preflight_then_def(name: &str, session: Option<&str>, resume_from: Option<&str>) -> i32 {
if runtime::which("git").is_none() {
fail("git is not installed or not on PATH");
hint(install_git_hint());
return 1;
}
let root = match git_root() {
Ok(r) => r,
Err(_) => {
fail("not inside a git repository");
hint(&format!("create one here with: {}", bold("git init .")));
return 1;
}
};
let blockers = def_run_blockers(&root);
if !blockers.is_empty() {
fail("this repository is not ready to run a harness definition");
for b in &blockers {
hint(b);
}
hint(&format!("get a ready-to-run project in one command: {}", bold("scsh init-demo-project")));
return 1;
}
let scratch = scratch_root(&root).unwrap_or("tmp");
let discovery = harness_def::discover(&root);
for w in &discovery.warnings {
warn(&format!("harness definition: {w}"));
}
let def = match discovery.find(name) {
Some(d) => d.clone(),
None => {
fail(&format!("no harness definition named '{name}'"));
let names: Vec<&str> = discovery.defs.iter().map(|d| d.name.as_str()).collect();
if names.is_empty() {
hint("no definitions found — add one under .harness/ or ~/.harness/");
} else {
hint(&format!("available: {}", names.join(", ")));
}
return 1;
}
};
let mut problems = Vec::new();
for p in &def.params {
match std::env::var(&p.name) {
Ok(v) => {
if let Err(e) = p.validate_value(&v) {
problems.push(e);
}
}
Err(_) => {
if p.required && p.default.is_none() {
problems.push(format!("param '{}' is required — set it in the environment", p.name));
}
}
}
}
if !problems.is_empty() {
fail(&format!("harness definition '{name}' has {} parameter problem{}", problems.len(), plural(problems.len())));
for e in &problems {
hint(e);
}
return 1;
}
let rt = match preflight_runtime_engine(true) {
Ok(rt) => rt,
Err(code) => return code,
};
if def.is_workflow() {
return run_workflow(&rt, &root, &def, session, resume_from);
}
if resume_from.is_some() {
fail(&format!("'{name}' is a flat definition — --resume-from only applies to workflow definitions (steps:)"));
hint("flat routes are independent; just run the definition again");
return 1;
}
if name == "doctor" {
doctor_preflight(&rt);
}
let cfg = config::Config { skills: vec![def.to_skill()], terminal: config::Terminal::default() };
let mut invocations = config::expand_invocations(&cfg);
for inv in &mut invocations {
inv.delivery = match &def.task {
Some(task) => config::SkillDelivery::DirectPrompt(task.clone()),
None => config::SkillDelivery::Repo,
};
inv.result = format!("{scratch}/{}.json", inv.name);
}
ok(&format!("git · repo {} · clean · {scratch}/ ignored · def {name}", display_path(&root)));
let model_probe = runtime::OpencodeModelProbe::for_selected(&invocations);
let mut runnable: Vec<&ResolvedInvocation> = Vec::new();
for inv in &invocations {
if let Err(msg) = runtime::check_skill_host(inv.harness, inv.model.as_deref(), &model_probe) {
warn(&format!("skipping '{}' — {msg}", inv.name));
continue;
}
runnable.push(inv);
}
if runnable.is_empty() {
fail("no routes to run — every agent was unavailable on this host");
hint("check the agent CLIs and credentials (try: scsh run --def doctor)");
return 1;
}
let session_id = session.filter(|s| !s.is_empty()).map(str::to_string).unwrap_or_else(daemon::new_session_id);
build_and_run(&rt, &root, &runnable, Some(name), &session_id, "definition")
}
struct StepState {
skipped: bool,
outputs: std::collections::HashMap<String, String>,
}
fn scratch_root(root: &Path) -> Option<&'static str> {
if git_status_ok(root, &["check-ignore", "-q", ".harness/tmp"]) {
Some(".harness/tmp")
} else if git_status_ok(root, &["check-ignore", "-q", "tmp"]) {
Some("tmp")
} else {
None
}
}
pub(crate) fn def_run_blockers(root: &Path) -> Vec<String> {
let mut out = Vec::new();
if git_capture(root, &["rev-parse", "--verify", "HEAD"]).is_none() {
out.push("the repository has no commits yet (scsh runs a clone of committed state)".into());
}
let dirty = uncommitted_changes(root);
if !dirty.is_empty() {
out.push(format!("the working tree has {} uncommitted change{}", dirty.len(), plural(dirty.len())));
}
if scratch_root(root).is_none() {
out.push("neither tmp/ nor .harness/tmp is gitignored (scsh needs a gitignored scratch dir)".into());
}
out
}
fn resolve_ref(
reference: &harness_def::Ref, def: &harness_def::HarnessDef, state: &std::collections::HashMap<String, StepState>,
) -> Option<String> {
match reference {
harness_def::Ref::Param(n) => {
std::env::var(n).ok().or_else(|| def.params.iter().find(|p| &p.name == n).and_then(|p| p.default.clone()))
}
harness_def::Ref::StepField { step, field } => state.get(step).and_then(|s| s.outputs.get(field).cloned()),
}
}
fn resolve_input(
reference: &harness_def::Ref, def: &harness_def::HarnessDef, state: &std::collections::HashMap<String, StepState>,
loop_prev: &std::collections::HashMap<String, StepState>,
) -> String {
resolve_ref(reference, def, state).or_else(|| resolve_ref(reference, def, loop_prev)).unwrap_or_default()
}
fn parse_result_object(content: &str) -> Result<std::collections::HashMap<String, json::Value>, String> {
let value = json::parse(content).map_err(|e| format!("result is not valid JSON: {e}"))?;
let json::Value::Object(obj) = value else {
return Err("result is not a JSON object".into());
};
let mut out = std::collections::HashMap::new();
for (k, v) in obj {
if out.insert(k.clone(), v).is_some() {
return Err(format!("result contains duplicate field '{k}'"));
}
}
Ok(out)
}
#[derive(Clone, Copy)]
struct WorkflowResultContract<'a> {
outputs: &'a [harness_def::OutputField],
require_do_while_repeat: bool,
}
fn extract_step_outputs(
content: &str, contract: WorkflowResultContract<'_>,
) -> Result<std::collections::HashMap<String, String>, String> {
let obj = parse_result_object(content)?;
let mut out = std::collections::HashMap::new();
let expected = contract.outputs.len() + usize::from(contract.require_do_while_repeat);
if obj.len() != expected {
let mut extras: Vec<&str> = obj
.keys()
.map(String::as_str)
.filter(|name| {
!contract.outputs.iter().any(|field| field.name == *name)
&& !(contract.require_do_while_repeat && *name == "SCSH_DO_WHILE_REPEAT")
})
.collect();
extras.sort_unstable();
if !extras.is_empty() {
return Err(format!("result contains undeclared field{}: {}", plural(extras.len()), extras.join(", ")));
}
}
for f in contract.outputs {
let Some(value) = obj.get(&f.name) else {
return Err(format!("result is missing the '{}' field", f.name));
};
let rendered = match (f.ty, value) {
(harness_def::OutputType::String, json::Value::String(value)) => value.clone(),
(harness_def::OutputType::Int, json::Value::Number(value))
if value.is_finite() && value.fract() == 0.0 && value.abs() < 9.0e15 =>
{
format!("{}", *value as i64)
}
(harness_def::OutputType::Bool, json::Value::Bool(value)) => value.to_string(),
(harness_def::OutputType::Enum, json::Value::String(value)) if f.choices.iter().any(|choice| choice == value) => {
value.clone()
}
(harness_def::OutputType::StringList, json::Value::Array(values))
if values.iter().all(|value| matches!(value, json::Value::String(_))) =>
{
json::write(value)
}
(harness_def::OutputType::Enum, json::Value::String(value)) => {
return Err(format!("output '{}' must be one of: {} (got '{value}')", f.name, f.choices.join(", ")));
}
(harness_def::OutputType::String, _) => return Err(format!("output '{}' must be a string", f.name)),
(harness_def::OutputType::Int, _) => return Err(format!("output '{}' must be an integer", f.name)),
(harness_def::OutputType::Bool, _) => return Err(format!("output '{}' must be true or false", f.name)),
(harness_def::OutputType::Enum, _) => {
return Err(format!("output '{}' must be one of: {}", f.name, f.choices.join(", ")));
}
(harness_def::OutputType::StringList, _) => {
return Err(format!("output '{}' must be an array of strings", f.name));
}
};
out.insert(f.name.clone(), rendered);
}
if contract.require_do_while_repeat {
match obj.get("SCSH_DO_WHILE_REPEAT") {
Some(json::Value::Bool(value)) => {
out.insert("SCSH_DO_WHILE_REPEAT".into(), value.to_string());
}
Some(_) => return Err("'SCSH_DO_WHILE_REPEAT' must be a boolean".into()),
None => return Err("result is missing the 'SCSH_DO_WHILE_REPEAT' field".into()),
}
}
Ok(out)
}
fn restored_step_result(
results_dir: &Path, run_id: &str, contract: WorkflowResultContract<'_>,
) -> Option<(String, std::collections::HashMap<String, String>, PathBuf)> {
let path = results_dir.join(format!("{}.json", run_id.replace('/', "_")));
let content = std::fs::read_to_string(&path).ok()?;
let outputs = extract_step_outputs(&content, contract).ok()?;
Some((content, outputs, path))
}
fn step_invocation(
step: &harness_def::Step, run_id: &str, session_dir_rel: &str, inputs: Vec<(String, String)>,
) -> ResolvedInvocation {
ResolvedInvocation {
name: run_id.to_string(),
skill_source: step.id.clone(),
harness: step.agent.harness,
model: step.agent.model.clone(),
effort: step.agent.effort.clone(),
retry_for: step.retry_for,
retry_signature_cap: step.retry_signature_cap,
timeout: None,
inactivity_timeout: None,
env: inputs
.into_iter()
.map(|(key, value)| config::EnvVar { key, rule: config::EnvRule::Constant(value) })
.collect(),
profile: None,
commits: step.commits,
result: format!("{session_dir_rel}/{run_id}.json"),
terminal: config::Terminal::default(),
delivery: config::SkillDelivery::DirectPrompt(step.render_skill_body()),
artifacts: step.artifacts.iter().map(|a| format!("{session_dir_rel}/{a}")).collect(),
}
}
fn schema_repair_invocation(invocation: &ResolvedInvocation, error: &str) -> ResolvedInvocation {
let mut repaired = invocation.clone();
if let config::SkillDelivery::DirectPrompt(prompt) = &mut repaired.delivery {
prompt.push_str(&format!(
"\n\n## Result correction retry\n\nThe previous attempt completed its work but wrote an invalid result: {error}. Run the task once more from this clean source revision and write a complete result matching the Output contract exactly. This is the only correction retry.\n"
));
}
repaired
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RetryDecision {
Stop,
StopBreaker,
Browser,
Schema,
Automatic,
}
#[allow(clippy::too_many_arguments)]
fn retry_decision(
fail_reason: Option<&str>, restart_requested: bool, schema_retry_available: bool, policy: failure::RetryPolicy,
budget_spent_secs: u64, consecutive_identical: u32, retry_enabled: bool, tui: bool, first_attempt: bool,
) -> RetryDecision {
if restart_requested {
return RetryDecision::Browser;
}
if !retry_enabled {
return RetryDecision::Stop;
}
if fail_reason == Some(failure::reason::RESULT_INVALID) && schema_retry_available {
return RetryDecision::Schema;
}
let Some(reason) = fail_reason else {
return RetryDecision::Stop;
};
if failure::verdict(reason, tui, first_attempt) != failure::Verdict::Retryable {
return RetryDecision::Stop;
}
if consecutive_identical > policy.signature_cap {
return RetryDecision::StopBreaker;
}
if budget_spent_secs >= policy.budget_secs {
return RetryDecision::Stop;
}
RetryDecision::Automatic
}
struct RouteRetryState {
policy: failure::RetryPolicy,
salt: u64,
first_failure_at: Option<Instant>,
last_signature: Option<String>,
consecutive_identical: u32,
auto_retries_used: u32,
}
impl RouteRetryState {
fn new(retry_for: Option<u64>, signature_cap: Option<u32>, session_id: &str, route: &str) -> RouteRetryState {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
session_id.hash(&mut hasher);
route.hash(&mut hasher);
RouteRetryState {
policy: failure::RetryPolicy::resolve(retry_for, signature_cap),
salt: hasher.finish(),
first_failure_at: None,
last_signature: None,
consecutive_identical: 0,
auto_retries_used: 0,
}
}
fn observe_failure(&mut self, run: &SkillRun) {
self.first_failure_at.get_or_insert_with(Instant::now);
let signature =
failure::failure_signature(run.fail_reason.as_deref().unwrap_or("unknown"), run.fail_detail.as_deref());
if self.last_signature.as_deref() == Some(signature.as_str()) {
self.consecutive_identical += 1;
} else {
self.last_signature = Some(signature);
self.consecutive_identical = 1;
}
}
fn budget_spent_secs(&self) -> u64 {
self.first_failure_at.map(|at| at.elapsed().as_secs()).unwrap_or(0)
}
fn budget_left_secs(&self) -> u64 {
self.policy.budget_secs.saturating_sub(self.budget_spent_secs())
}
fn next_backoff_secs(&mut self) -> u64 {
let delay = self.policy.backoff_delay_secs(self.auto_retries_used, self.salt);
self.auto_retries_used += 1;
delay
}
fn mark_breaker_tripped(&self, run: &mut SkillRun) {
let raw = run.fail_reason.clone().unwrap_or_else(|| "unknown".into());
run.fail_detail = Some(format!(
"{} consecutive attempts failed identically (last: {raw}); retrying further would burn tokens on a deterministic failure",
self.consecutive_identical
));
run.fail_reason = Some(failure::reason::RETRIES_EXHAUSTED_IDENTICAL.into());
}
}
fn backoff_sleep_interruptible(delay_secs: u64, session_id: &str, proc_index: usize) -> bool {
for _ in 0..delay_secs {
if daemon::consume_proc_restart(session_id, proc_index) {
return true;
}
std::thread::sleep(Duration::from_secs(1));
}
false
}
#[allow(clippy::too_many_arguments)]
fn run_workflow_step_with_retries(
invocation: &ResolvedInvocation, step: &harness_def::Step, rt: &Runtime, root: &Path, secs: u64,
initial_proc: ui::screen::Proc, ui: &ui::screen::LiveUi, base: Option<&str>,
daemon_client: Option<std::sync::Arc<daemon::Client>>, session_id: &str,
) -> SkillRun {
let contract = WorkflowResultContract {
outputs: &step.outputs,
require_do_while_repeat: step.do_while.is_some()
&& !step.outputs.iter().any(|output| output.name == "SCSH_DO_WHILE_REPEAT"),
};
let mut retry = RouteRetryState::new(step.retry_for, step.retry_signature_cap, session_id, &invocation.name);
let mut proc = initial_proc;
let mut proc_index = proc.index();
let mut attempts = 0u64;
let mut schema_retry_used = false;
let mut repaired_invocation: Option<ResolvedInvocation> = None;
loop {
attempts += 1;
let attempt_started = Instant::now();
let current_invocation = repaired_invocation.as_ref().unwrap_or(invocation);
let mut run = run_one_skill(
current_invocation,
rt,
root,
secs,
proc.clone(),
base,
base,
Some(contract),
daemon_client.clone(),
session_id,
);
run.duration_secs = attempt_started.elapsed().as_secs_f64();
run.proc_index = proc_index;
run.attempts = attempts;
if run.ok {
daemon::consume_proc_restart(session_id, proc_index);
return run;
}
retry.observe_failure(&run);
let restart_requested = daemon::consume_proc_restart(session_id, proc_index);
let invalid_result = run.fail_reason.as_deref() == Some(failure::reason::RESULT_INVALID);
let decision = retry_decision(
run.fail_reason.as_deref(),
restart_requested,
!schema_retry_used,
retry.policy,
retry.budget_spent_secs(),
retry.consecutive_identical,
failure::retry_enabled(),
invocation.harness.is_tui(),
attempts == 1,
);
if decision == RetryDecision::Stop || decision == RetryDecision::StopBreaker {
if invalid_result {
let why = format!(
"invalid workflow result: {}",
run.fail_detail.as_deref().unwrap_or("result did not match the declared schema")
);
let detail = skill_fail_detail(&why, invocation.harness, run.run_dir.as_deref(), run.log.as_deref());
proc.finish_fail(failure::reason::RESULT_INVALID, Some(&detail));
}
if decision == RetryDecision::StopBreaker {
retry.mark_breaker_tripped(&mut run);
}
return run;
}
let reason = match decision {
RetryDecision::Browser => failure::reason::RESTART_REQUESTED,
RetryDecision::Schema => failure::reason::RESULT_INVALID,
RetryDecision::Automatic => run.fail_reason.as_deref().unwrap_or("unknown"),
RetryDecision::Stop | RetryDecision::StopBreaker => unreachable!("terminal decisions returned above"),
};
failure::log_retry(session_id, &invocation.name, invocation.harness.as_str(), invocation.model.as_deref(), reason);
if decision == RetryDecision::Schema {
schema_retry_used = true;
let error = run.fail_detail.as_deref().unwrap_or("result did not match the declared schema");
repaired_invocation = Some(schema_repair_invocation(invocation, error));
}
if invalid_result {
let why = format!(
"invalid workflow result: {}",
run.fail_detail.as_deref().unwrap_or("result did not match the declared schema")
);
let detail = skill_fail_detail(&why, invocation.harness, run.run_dir.as_deref(), run.log.as_deref());
proc.finish_fail(failure::reason::RESULT_INVALID, Some(&detail));
if !keep_run_dirs() {
if let Some(clone) = &run.clone_dir {
let _ = std::fs::remove_dir_all(clone);
}
}
}
let label = format!("{}: {} (retry)", invocation.harness.as_str(), invocation.name);
let next = ui.proc(label.clone(), false);
if let Some(client) = &daemon_client {
client.proc_add(
next.index(),
&label,
daemon::ProcKind::Skill,
Some(&invocation.name),
Some(invocation.harness.as_str()),
invocation.model.as_deref(),
Some(&step.id),
None,
None,
Some(proc_index),
);
}
proc_index = next.index();
proc = next;
if decision == RetryDecision::Automatic {
let delay = retry.next_backoff_secs();
proc.note(&format!(
"retrying in ~{} after {reason} (attempt {}, retry budget {} left)",
ui::clock::format_elapsed(delay as f64),
attempts + 1,
ui::clock::format_elapsed(retry.budget_left_secs() as f64),
));
if backoff_sleep_interruptible(delay, session_id, proc_index) {
proc.note("retrying now (browser restart)");
}
} else {
proc.note(&format!("retrying after {reason}"));
}
}
}
fn ensure_workflow_images(
rt: &Runtime, ui: &ui::screen::LiveUi, daemon_client: &Option<std::sync::Arc<daemon::Client>>,
harnesses: &[config::Harness], session_id: &str,
) -> Result<(), (String, i32)> {
let (uid, gid) = runtime::host_ids();
let df = runtime::dockerfile_for_runtime(&rt.name);
if rt.name == "container" && runtime::apple_dockerfile_too_large(&df) {
return Err((runtime::apple_dockerfile_too_large_message(df.len()), 1));
}
let tz = runtime::host_timezone();
let build_one =
|label: String, tag: &str, target: &str, fp: &str, harness: Option<config::Harness>| -> Result<(), (String, i32)> {
let p = ui.proc(label.clone(), false);
if let Some(c) = daemon_client {
c.proc_add(
p.index(),
&label,
daemon::ProcKind::Build,
None,
harness.map(|h| h.as_str()),
None,
None,
None,
None,
None,
);
}
p.start();
let stem = harness.map(|h| h.as_str()).unwrap_or("base");
match run_build(&p, &rt.name, tag, target, &df, uid, gid, fp, false, daemon_client.as_deref(), stem, session_id) {
Ok(()) => {
p.finish_ok(None);
Ok(())
}
Err(e) => {
p.finish_fail(failure::reason::BUILD_FAILED, Some(&e.0));
Err(e)
}
}
};
let base_fp = runtime::base_image_fingerprint(&df, uid, gid, &tz);
if !runtime::image_is_up_to_date(&rt.name, runtime::BASE_IMAGE_TAG, &base_fp) {
build_one(
format!("using {} · build base", backend_name(&rt.name)),
runtime::BASE_IMAGE_TAG,
runtime::BASE_IMAGE_TARGET,
&base_fp,
None,
)?;
}
for &h in harnesses {
let spec = runtime::image_build_spec(h, &df, uid, gid, &tz);
if !runtime::image_is_up_to_date(&rt.name, &spec.tag, &spec.fingerprint) {
build_one(
format!("using {} · build {}", backend_name(&rt.name), h.as_str()),
&spec.tag,
&spec.target,
&spec.fingerprint,
Some(h),
)?;
}
}
Ok(())
}
fn run_workflow(
rt: &Runtime, root: &Path, def: &harness_def::HarnessDef, session: Option<&str>, resume_from: Option<&str>,
) -> i32 {
use std::collections::HashMap;
ui::signals::install();
let resume: Option<(String, PathBuf)> = resume_from.and_then(|old| {
let dir = runtime::session_results_dir(old);
if dir.is_dir() {
Some((old.to_string(), dir))
} else {
warn(&format!(
"nothing to resume from session '{old}' — no results under {} — running every step",
dir.display()
));
None
}
});
if let Some((old, dir)) = &resume {
ok(&format!("resuming from session {old} — completed step results under {} are reused", dir.display()));
}
let session_id = session.filter(|s| !s.is_empty()).map(str::to_string).unwrap_or_else(daemon::new_session_id);
let recipe_params: Vec<(String, String)> =
def.params.iter().filter_map(|p| std::env::var(&p.name).ok().map(|v| (p.name.clone(), v))).collect();
daemon::write_start_recipe(&session_id, Some(&def.name), None, &recipe_params);
let mut daemon_session = DaemonSession { client: None, ping_active: None, registered: false };
if daemon::ensure_for_run().is_ok() {
let client = std::sync::Arc::new(daemon::Client::new(session_id.clone()));
let skill_meta: Vec<(&str, &str)> = def.steps.iter().map(|s| (s.id.as_str(), s.agent.harness.as_str())).collect();
let workflow = daemon::workflow_meta_from_def(def);
if client.register_session_with_workflow(
&repo_path_for_session(root),
¤t_branch(root),
Some(&def.name),
"workflow",
&skill_meta,
workflow.as_ref(),
None,
) {
ok(&format!("track progress at {}", client.session_url()));
let ping_active = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
let ping_flag = std::sync::Arc::clone(&ping_active);
let ping_client = std::sync::Arc::clone(&client);
std::thread::spawn(move || {
while ping_flag.load(std::sync::atomic::Ordering::Relaxed) {
ping_client.ping();
std::thread::sleep(Duration::from_secs(2));
}
});
daemon_session.client = Some(client);
daemon_session.ping_active = Some(ping_active);
daemon_session.registered = true;
}
}
let daemon_client = daemon_session.client.clone();
let ui = ui::screen::LiveUi::new(console::user_attended_stderr(), daemon_client.clone());
let mut harnesses = Vec::new();
for s in &def.steps {
if !harnesses.contains(&s.agent.harness) {
harnesses.push(s.agent.harness);
}
}
if let Err((msg, code)) = ensure_workflow_images(rt, &ui, &daemon_client, &harnesses, &session_id) {
ui.finish();
fail(&msg);
return code;
}
let secs = now_secs();
let stamp = runtime::format_utc_timestamp(secs);
let mut base = git_capture(root, &["rev-parse", "HEAD"]).map(|s| s.trim().to_string());
let original_base = base.clone();
let session_dir_rel = format!("{}/scsh/{session_id}", scratch_root(root).unwrap_or("tmp"));
let mut do_while_end_for: HashMap<String, String> = HashMap::new();
let mut do_while_bodies: HashMap<String, Vec<String>> = HashMap::new();
for end in def.steps.iter().filter(|s| s.do_while.is_some()) {
let body: Vec<String> = harness_def::do_while_body(&def.steps, end).into_iter().map(str::to_string).collect();
for id in &body {
do_while_end_for.insert(id.clone(), end.id.clone());
}
do_while_bodies.insert(end.id.clone(), body);
}
let total_steps = def.steps.len();
let mut step_procs: HashMap<String, ui::screen::Proc> = HashMap::new();
for (i, s) in def.steps.iter().enumerate() {
if s.is_loop() || do_while_end_for.contains_key(&s.id) {
continue; }
let label = format!("{}: {}", s.agent.harness.as_str(), s.id);
let p = ui.proc(label.clone(), false);
let mut note = format!("step {}/{total_steps}", i + 1);
if !s.needs.is_empty() {
note.push_str(&format!(" · needs {}", s.needs.join(", ")));
}
if let Some(c) = &daemon_client {
c.proc_add(
p.index(),
&label,
daemon::ProcKind::Skill,
Some(&s.id),
Some(s.agent.harness.as_str()),
s.agent.model.as_deref(),
Some(&s.id),
None,
None,
None,
);
}
p.note(¬e);
step_procs.insert(s.id.clone(), p);
}
let mut next_annotate_idx = step_procs.values().map(|p| p.index()).max().map(|m| m + 1).unwrap_or(0);
ui.pin_board_to_top();
let mut state: HashMap<String, StepState> = HashMap::new();
let mut repeat_done: HashMap<String, usize> = HashMap::new();
let mut loop_prev: HashMap<String, StepState> = HashMap::new();
let mut ran_count = 0usize;
let mut skipped_count = 0usize;
let mut failure: Option<String> = None;
while state.len() < def.steps.len() && failure.is_none() {
let ready: Vec<&harness_def::Step> = def
.steps
.iter()
.filter(|s| !state.contains_key(&s.id) && s.needs.iter().all(|n| state.contains_key(n)))
.collect();
if ready.is_empty() {
break; }
let mut to_run: Vec<&harness_def::Step> = Vec::new();
for s in ready {
let skipped_need = s.needs.iter().find(|n| state.get(*n).is_some_and(|st| st.skipped));
let when_ok = s.when.as_ref().is_none_or(|w| harness_def::when_holds(w, &|r| resolve_ref(r, def, &state)));
if skipped_need.is_some() || !when_ok {
let why = match skipped_need {
Some(n) => format!("skipped — needs '{n}', which was skipped"),
None => "skipped — its when: gate is false".to_string(),
};
if let Some(p) = step_procs.remove(&s.id) {
p.finish_skipped(&why);
}
skipped_count += 1;
state.insert(s.id.clone(), StepState { skipped: true, outputs: HashMap::new() });
} else {
to_run.push(s);
}
}
if to_run.is_empty() {
continue;
}
ran_count += to_run.len();
let mut invs: Vec<ResolvedInvocation> = Vec::new();
let mut procs: Vec<ui::screen::Proc> = Vec::new();
let mut live_steps: Vec<&harness_def::Step> = Vec::new();
let mut run_ids: Vec<String> = Vec::new();
let mut restored_runs: Vec<(String, SkillRun)> = Vec::new();
for s in &to_run {
let mut inputs: Vec<(String, String)> = Vec::new();
for b in &s.inputs {
inputs.push((b.name.clone(), resolve_input(&b.source, def, &state, &loop_prev)));
}
let loop_key = do_while_end_for.get(&s.id).map(String::as_str).unwrap_or(&s.id);
let iteration = repeat_done.get(loop_key).copied().unwrap_or(0) + 1;
let run_id = if let Some(end) = do_while_end_for.get(&s.id) {
format!("{}-while-{}-{iteration}", s.id, end)
} else {
s.iteration_run_id(iteration)
};
let p = if s.is_loop() || do_while_end_for.contains_key(&s.id) {
let label = format!("{}: {} · iteration {iteration}", s.agent.harness.as_str(), s.id);
let p = ui.proc(label.clone(), false);
if let Some(c) = &daemon_client {
c.proc_add(
p.index(),
&label,
daemon::ProcKind::Skill,
Some(&run_id),
Some(s.agent.harness.as_str()),
s.agent.model.as_deref(),
Some(&s.id),
None,
None,
None,
);
}
p.note(&format!(
"{} iteration {iteration}",
if do_while_end_for.contains_key(&s.id) { "do-while" } else { s.loop_kind() }
));
p
} else {
step_procs.remove(&s.id).expect("every undecided step has its pre-declared proc")
};
if let Some((old_sid, results_dir)) = &resume {
let contract = WorkflowResultContract {
outputs: &s.outputs,
require_do_while_repeat: s.do_while.is_some()
&& !s.outputs.iter().any(|output| output.name == "SCSH_DO_WHILE_REPEAT"),
};
if let Some((content, outputs, old_path)) = restored_step_result(results_dir, &run_id, contract) {
if let Some(dest) = fleet::persist_skill_result(&session_id, &run_id, &old_path) {
if let Some(c) = &daemon_client {
c.proc_result(p.index(), &dest);
}
}
p.finish_ok(Some(&format!("restored from session {old_sid} — result reused, no container run")));
let mut run = SkillRun::cached(None, content, Some(outputs));
run.proc_index = p.index();
restored_runs.push((run_id.clone(), run));
run_ids.push(run_id);
continue;
}
}
invs.push(step_invocation(s, &run_id, &session_dir_rel, inputs));
procs.push(p);
live_steps.push(s);
run_ids.push(run_id);
}
let mut results: Vec<(String, SkillRun)> = std::thread::scope(|scope| {
let ui_ref = &ui;
let handles: Vec<_> = invs
.iter()
.zip(procs)
.zip(live_steps.iter())
.map(|((inv, p), step)| {
let dc = daemon_client.clone();
let base_ref = base.as_deref();
let id = inv.name.clone();
let sid = session_id.as_str();
scope.spawn(move || {
let run = run_workflow_step_with_retries(inv, step, rt, root, secs, p, ui_ref, base_ref, dc, sid);
(id, run)
})
})
.collect();
handles
.into_iter()
.map(|h| {
h.join()
.unwrap_or_else(|_| (String::new(), SkillRun::failed(failure::reason::THREAD_PANICKED, None, None, None)))
})
.collect()
});
results.append(&mut restored_runs);
let mut by_id: HashMap<String, SkillRun> = results.into_iter().collect();
for (s, run_id) in to_run.iter().zip(&run_ids) {
let Some(run) = by_id.remove(run_id) else { continue };
if !run.ok {
failure = Some(format!("step '{}' failed ({})", s.id, run.fail_reason.as_deref().unwrap_or("unknown")));
break;
}
match run.workflow_outputs.clone() {
Some(outputs) => {
let loop_key = do_while_end_for.get(&s.id).map(String::as_str).unwrap_or(&s.id);
let completed = repeat_done.get(loop_key).copied().unwrap_or(0) + 1;
let break_requested = s.break_loop && outputs.get("SCSH_LOOP_BREAK").is_some_and(|value| value == "true");
let again = if break_requested {
false
} else if let Some(total) = s.repeat {
completed < total
} else if s.do_while.is_some() {
let holds = outputs.get("SCSH_DO_WHILE_REPEAT").is_some_and(|value| value == "true");
if holds && completed >= harness_def::DO_WHILE_MAX_ITERATIONS {
failure = Some(format!(
"step '{}' hit the do-while backstop ({} iterations) with its condition still true",
s.id,
harness_def::DO_WHILE_MAX_ITERATIONS
));
break;
}
holds
} else {
false
};
if break_requested {
state.insert(s.id.clone(), StepState { skipped: false, outputs });
if let Some(body) = do_while_bodies.get(loop_key) {
for id in body.iter().filter(|id| *id != &s.id) {
if state.contains_key(id) {
continue;
}
let skipped = def.steps.iter().find(|step| &step.id == id).expect("validated loop step");
let run_id = format!("{}-while-{}-{completed}", skipped.id, loop_key);
let label = format!("{}: {} · iteration {completed}", skipped.agent.harness.as_str(), skipped.id);
let p = ui.proc(label.clone(), false);
if let Some(c) = &daemon_client {
c.proc_add(
p.index(),
&label,
daemon::ProcKind::Skill,
Some(&run_id),
Some(skipped.agent.harness.as_str()),
skipped.agent.model.as_deref(),
Some(&skipped.id),
None,
None,
None,
);
}
p.finish_skipped(&format!("skipped — '{}' broke the loop", s.id));
skipped_count += 1;
state.insert(id.clone(), StepState { skipped: true, outputs: HashMap::new() });
}
}
} else if again {
repeat_done.insert(loop_key.to_string(), completed);
loop_prev.insert(s.id.clone(), StepState { skipped: false, outputs });
if let Some(body) = do_while_bodies.get(loop_key) {
for id in body {
state.remove(id);
}
}
} else {
state.insert(s.id.clone(), StepState { skipped: false, outputs });
}
}
None => {
failure = Some(format!("step '{}' completed without validated workflow outputs", s.id));
break;
}
}
if s.commits {
if let (Some(b), Some(clone)) = (base.as_deref(), run.clone_dir.as_ref()) {
let inv = invs.iter().find(|i| i.name == *run_id).expect("invocation for step in this wave");
match integrate_commits(root, clone, b, &s.id, &stamp) {
Ok(None) => {}
Ok(Some(Integration::Applied { count, range })) => {
ok(&format!(
"{}: brought in {count} commit{} (rebased onto {})",
s.id,
plural(count),
current_branch(root)
));
base = git_capture(root, &["rev-parse", "HEAD"]).map(|s| s.trim().to_string());
if let (Some(from), Some(to)) = (original_base.as_deref(), base.as_deref()) {
pack_job_diff(root, &session_id, from, to);
}
pack_step_diff(root, &session_id, inv, &run, range, daemon_client.as_deref());
}
Ok(Some(Integration::Saved { branch, count, range })) => {
if let Some((_, tip)) = &range {
base = Some(tip.clone());
}
if let (Some(from), Some(to)) = (original_base.as_deref(), base.as_deref()) {
pack_job_diff(root, &session_id, from, to);
}
warn(&format!(
"{}: {count} commit{} didn't rebase cleanly — saved to branch {branch} (inspect, then merge/cherry-pick)",
s.id,
plural(count)
));
pack_step_diff(root, &session_id, inv, &run, range, daemon_client.as_deref());
}
Err(e) => warn(&format!("{}: could not bring in commits — {e}", s.id)),
}
}
}
if run.ok && !keep_run_dirs() {
if let Some(clone) = &run.clone_dir {
let _ = std::fs::remove_dir_all(clone);
}
}
}
}
if let Some(reason) = failure.as_deref() {
for (_, proc) in step_procs.drain() {
proc.finish_skipped(&format!("not run — workflow stopped after {reason}"));
skipped_count += 1;
}
}
ui.finish();
if let Some(msg) = failure {
fail(&msg);
return 1;
}
if let (Some(from), Some(to)) = (original_base.as_deref(), base.as_deref()) {
pack_job_diff(root, &session_id, from, to);
}
ok(&format!(
"workflow '{}' complete — {ran_count} step{} ran{}",
def.name,
plural(ran_count),
if skipped_count > 0 { format!(", {skipped_count} skipped") } else { String::new() }
));
annotate_run_casts(root, session_skill_casts(&session_id), daemon_client.as_deref(), &mut next_annotate_idx);
0
}
fn doctor_preflight(rt: &Runtime) {
let statuses = runtime::image_statuses(&rt.name);
for agent in config::Harness::ALL {
let built = statuses.iter().any(|s| s.name == agent.as_str() && s.exists);
let creds = runtime::check_harness_host(agent).is_ok();
let img = if built { "image built" } else { "image missing (build it: scsh build-images)" };
let cred = if creds { "creds present" } else { "creds missing" };
let line = format!("{}: {img}, {cred}", agent.as_str());
if built && creds {
ok(&line);
} else {
hint(&line);
}
}
}
fn preflight_then(action: Action, profile: Option<&str>, verbose: bool, override_yml: Option<&Path>) -> i32 {
let is_run = matches!(action, Action::Run);
let root = match preflight_git_repo_clean(is_run) {
Ok(r) => r,
Err(code) => return code,
};
let (cfg, override_skills_root) = match resolve_config_for_run(&root, override_yml, profile) {
Ok(v) => v,
Err(code) => return code,
};
let rt = match preflight_runtime_engine(is_run) {
Ok(rt) => rt,
Err(code) => return code,
};
match action {
Action::List => {
ok(&preflight_summary(&root, &cfg, &rt));
list_skills(&cfg, &rt, &root, verbose)
}
Action::Run => {
if profile.is_some() {
let declared = declared_profiles(&cfg);
let unknown: Vec<String> =
requested_profiles(profile).into_iter().filter(|p| p != "default" && !declared.contains(p)).collect();
if !unknown.is_empty() {
fail(&format!("unknown profile{}: {}", plural(unknown.len()), unknown.join(", ")));
let mut avail = vec!["default".to_string()];
avail.extend(declared.iter().map(|s| s.to_string()));
hint(&format!("available: {} (see them with: scsh list)", avail.join(", ")));
return 1;
}
}
let mut selected = select_invocations(&cfg, profile);
if selected.is_empty() {
let scope = profile.unwrap_or("default");
fail(&format!("nothing to run \u{2014} the '{scope}' profile is empty"));
hint("see the available profiles and their skills: scsh list");
hint("then pick one: scsh run --profile <name>");
return 1;
}
if let Some(skills_root) = &override_skills_root {
if let Err(e) = attach_override_skill_bodies(&mut selected, skills_root) {
fail(&e);
return 1;
}
}
let prof = profile.map(|p| format!(" · profile {p}")).unwrap_or_default();
let via = override_yml.map(|p| format!(" · override {}", p.display())).unwrap_or_default();
ok(&format!("git · repo {} · clean · /tmp ignored{prof}{via}", display_path(&root)));
let model_probe = runtime::OpencodeModelProbe::for_selected(&selected);
let mut runnable: Vec<&ResolvedInvocation> = Vec::new();
for skill in &selected {
if let Err(msg) = runtime::check_skill_host(skill.harness, skill.model.as_deref(), &model_probe) {
warn(&format!("skipping '{}' — {msg}", skill.name));
continue;
}
if matches!(skill.delivery, config::SkillDelivery::Repo) {
let skill_md = root.join(".skills").join(&skill.skill_source).join("SKILL.md");
if !skill_md.is_file() {
fail(&format!(
"skill source missing: .skills/{}/SKILL.md (invocation '{}')",
skill.skill_source, skill.name
));
return 1;
}
}
runnable.push(skill);
}
if runnable.is_empty() {
fail("no skills to run — every selected skill was skipped (harness or model unavailable on this host)");
hint("see DEMO.md step 1 — probe add-opencode-gpt-5.4-mini-fast and add-claude-sonnet-4-6");
return 1;
}
let session_id = daemon::new_session_id();
build_and_run(&rt, &root, &runnable, profile, &session_id, "profile")
}
}
}
fn resolve_config_for_run(
root: &Path, override_yml: Option<&Path>, profile: Option<&str>,
) -> Result<(config::Config, Option<PathBuf>), i32> {
if let Some(override_path) = override_yml {
let yml = match resolve_override_yml(override_path) {
Ok(p) => p,
Err(e) => {
fail(&e);
return Err(1);
}
};
let bundle = yml.parent().unwrap_or(Path::new(".")).to_path_buf();
return Ok((load_validated_yml(&yml)?, Some(bundle)));
}
let cfg_path = root.join(".scsh.yml");
let repo_cfg = if cfg_path.is_file() { Some(load_validated_yml(&cfg_path)?) } else { None };
let wanted: Vec<String> = requested_profiles(profile).into_iter().filter(|p| p != "default").collect();
if let Some(cfg) = &repo_cfg {
let declared = declared_profiles(cfg);
if wanted.is_empty() || wanted.iter().all(|w| declared.contains(w)) {
return Ok((repo_cfg.unwrap(), None));
}
}
let global_yml = runtime::scsh_home().join(".scsh.yml");
if global_yml.is_file() {
let global_cfg = load_validated_yml(&global_yml)?;
let declared = declared_profiles(&global_cfg);
if (!wanted.is_empty() && wanted.iter().all(|w| declared.contains(w))) || repo_cfg.is_none() {
let what = if repo_cfg.is_none() {
"no .scsh.yml in this repo".to_string()
} else {
format!(
"profile{} {} not in this repo's .scsh.yml",
plural(wanted.len()),
wanted.iter().map(|w| format!("'{w}'")).collect::<Vec<_>>().join(", ")
)
};
ok(&format!("{what} — using the global manifest {}", display_path(&global_yml)));
let bundle = global_yml.parent().unwrap_or(Path::new(".")).to_path_buf();
return Ok((global_cfg, Some(bundle)));
}
}
match repo_cfg {
Some(cfg) => Ok((cfg, None)),
None => {
fail(".scsh.yml not found — this repository isn't set up for scsh yet");
hint(&format!("get a ready-to-run project in one command: {}", bold("scsh init-demo-project")));
hint("(writes .scsh.yml, gitignores /tmp, scaffolds example skills, and commits)");
hint(&format!(
"or install the skills once, machine-wide: {} (then any repo can run their profiles)",
bold("scsh installskills --global")
));
Err(1)
}
}
}
fn resolve_override_yml(path: &Path) -> Result<PathBuf, String> {
let p = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir().map_err(|e| format!("could not resolve cwd for --override-dot-scsh-yml: {e}"))?.join(path)
};
if !p.is_file() {
return Err(format!("--override-dot-scsh-yml path not found: {}", p.display()));
}
Ok(p)
}
fn load_validated_yml(yml_path: &Path) -> Result<config::Config, i32> {
let src = match std::fs::read_to_string(yml_path) {
Ok(s) => s,
Err(e) => {
fail(&format!("could not read {}: {e}", yml_path.display()));
return Err(1);
}
};
match config::validate(&src) {
Ok(c) => Ok(c),
Err(errs) => {
let n = errs.len();
fail(&format!("{} does not match the schema ({n} problem{})", yml_path.display(), if n == 1 { "" } else { "s" }));
for e in &errs {
hint(e);
}
hint("fix the file to match the schema (see 'scsh --help' or the README)");
Err(1)
}
}
}
fn attach_override_skill_bodies(invocations: &mut [ResolvedInvocation], skills_root: &Path) -> Result<(), String> {
for inv in invocations {
let path = skills_root.join(".skills").join(&inv.skill_source).join("SKILL.md");
let body =
std::fs::read_to_string(&path).map_err(|e| format!("override skill missing at {}: {e}", path.display()))?;
inv.delivery = config::SkillDelivery::GlobalInstall(body);
}
Ok(())
}
fn session_skill_casts(session_id: &str) -> Vec<std::path::PathBuf> {
std::fs::read_dir(runtime::session_casts_dir(session_id))
.map(|entries| {
entries
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "cast"))
.filter(|p| !p.file_name().is_some_and(|n| n.to_string_lossy().starts_with("build-")))
.collect()
})
.unwrap_or_default()
}
fn preflight_summary(root: &Path, cfg: &config::Config, rt: &Runtime) -> String {
let names = cfg.skills.iter().map(|s| s.name.as_str()).collect::<Vec<_>>().join(", ");
let n = cfg.skills.len();
format!(
"git · repo {} · .scsh.yml valid ({n} skill{}: {names}) · using {}",
display_path(root),
if n == 1 { "" } else { "s" },
backend_name(&rt.name)
)
}
fn backend_name(runtime: &str) -> &str {
match runtime {
"docker" => "docker",
"podman" => "podman",
"container" => "Apple Containers",
other => other,
}
}
fn display_path(p: &Path) -> String {
if let Some(home) = std::env::var_os("HOME") {
if let Ok(rest) = p.strip_prefix(PathBuf::from(home)) {
return if rest.as_os_str().is_empty() { "~".to_string() } else { format!("~/{}", rest.display()) };
}
}
p.display().to_string()
}
fn uncommitted_changes(root: &std::path::Path) -> Vec<String> {
let status = match git_capture(root, &["status", "--porcelain"]) {
Some(s) => s,
None => return Vec::new(),
};
let mut out: Vec<String> = Vec::new();
for line in status.lines() {
if line.len() < 4 {
continue;
}
let mut path = line[3..].trim();
if let Some(idx) = path.find(" -> ") {
path = &path[idx + 4..];
}
let path = path.trim_matches('"');
if !path.is_empty() && !out.iter().any(|p| p == path) {
out.push(path.to_string());
}
}
out
}
fn tmp_is_gitignored(root: &std::path::Path) -> bool {
Command::new("git")
.arg("-C")
.arg(root)
.args(["check-ignore", "-q", "tmp"])
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn list_skills(cfg: &config::Config, rt: &Runtime, root: &std::path::Path, verbose: bool) -> i32 {
let expanded = config::expand_invocations(cfg);
println!();
println!(
"{} {}",
h_head("Profiles & skills"),
h_dim(&format!(
"\u{2014} {} invocation{} · run one with `scsh run --profile <name>`",
expanded.len(),
plural(expanded.len())
))
);
let mut groups: Vec<(String, Vec<&ResolvedInvocation>)> =
vec![("default".to_string(), expanded.iter().filter(|s| s.profile.is_none()).collect())];
for p in declared_profiles(cfg) {
if p == "default" {
continue;
}
groups.push((p.clone(), expanded.iter().filter(|s| s.profile.as_deref() == Some(p.as_str())).collect()));
}
for (name, members) in &groups {
if members.is_empty() {
let note = if name == "default" { "\u{2014} empty (a bare `scsh run` is a no-op)" } else { "\u{2014} empty" };
println!(" {} {}", h_head(&format!("{name} (0)")), h_dim(note));
continue;
}
let how = if name == "default" { "scsh run".to_string() } else { format!("scsh run --profile {name}") };
println!(" {} {}", h_head(&format!("{name} ({})", members.len())), h_dim(&format!("\u{2014} {how}")));
for s in members {
let mut notes = String::new();
if s.commits {
notes.push_str(" \u{b7} commits back");
}
let env: Vec<&str> = s.env.iter().map(|e| e.key.as_str()).collect();
if !env.is_empty() {
notes.push_str(&format!(" \u{b7} env: {}", env.join(", ")));
}
help_row(&s.name, &format!("\u{2192} {}{notes}", s.result));
}
}
if verbose {
let skills = &expanded[..];
let (uid, gid) = runtime::host_ids();
let df = runtime::dockerfile_for_runtime(&rt.name);
let mut harnesses: std::collections::BTreeSet<config::Harness> = std::collections::BTreeSet::new();
for s in skills.iter() {
harnesses.insert(s.harness);
}
println!("\n{}", h_head("Images"));
if rt.name == "container" {
println!(
"{}",
h_dim("--- Dockerfile for Apple Containers (comments stripped; gRPC header ≤16KB, apple/container#735) ---")
);
} else {
println!("{}", h_dim("--- generated Dockerfile (in memory; shared base + per-harness targets) ---"));
}
print!("{df}");
let host_tz = runtime::host_timezone();
let base_fp = runtime::base_image_fingerprint(&df, uid, gid, &host_tz);
println!("--- build {} first (shared toolchain; agent uid={uid} gid={gid}) ---", runtime::BASE_IMAGE_TARGET);
print_build_command(
&rt.name,
runtime::BASE_IMAGE_TAG,
runtime::BASE_IMAGE_TARGET,
&df,
uid,
gid,
&host_tz,
&base_fp,
);
let specs: Vec<runtime::ImageBuildSpec> =
harnesses.iter().map(|h| runtime::image_build_spec(*h, &df, uid, gid, &host_tz)).collect();
for spec in &specs {
println!("--- build {} (harness layer on top of {}) ---", spec.target, runtime::BASE_IMAGE_TARGET);
print_build_command(&rt.name, &spec.tag, &spec.target, &df, uid, gid, &host_tz, &spec.fingerprint);
}
println!("\n{}", h_head("Per-skill commands"));
for skill in skills {
let name = runtime::run_dir_name(now_secs(), &skill.name, &rt.name);
let run_dir = format!("/tmp/{name}");
let tag = runtime::image_tag(skill.harness);
let cmd = runtime::harness_command(
skill.harness,
skill.model.as_deref(),
skill.effort.as_deref(),
&skill.skill_source,
&skill.result,
skill.terminal,
&skill.delivery,
);
let model = skill.model.as_deref().unwrap_or("(harness default)");
let timeout = skill.timeout.map(|t| format!("{t}s")).unwrap_or_else(|| "none".into());
println!(
"\n[{}] skill={} harness={} model={model} timeout={timeout}",
skill.name,
skill.skill_source,
skill.harness.as_str()
);
if runtime::uses_git_transport(&rt.name) {
println!(" push: git push {run_dir}/{} HEAD refs/remotes/origin/*", runtime::TRANSPORT_BARE);
println!(
" run: container clones git://<gateway>:<port>/{} (gateway from ip route; port in SCSH_GIT_PORT)",
runtime::TRANSPORT_BARE
);
} else {
println!(" clone: {}", runtime::shell_join(&runtime::clone_command(&root.to_string_lossy(), &run_dir)));
}
match resolve_env(&skill.env) {
Ok(env) => {
let vols: Vec<(String, String)> = runtime::harness_volumes(skill.harness);
let vol_refs: Vec<(&str, &str)> = vols.iter().map(|(h, m)| (h.as_str(), m.as_str())).collect();
let repo_mount = if runtime::uses_git_transport(&rt.name) {
runtime::RepoMountMode::TmpOnly
} else {
runtime::RepoMountMode::Full
};
let run = runtime::run_command(&rt.name, &tag, &run_dir, &name, &env, &vol_refs, &cmd, repo_mount);
println!(" run: {}", runtime::shell_join(&run));
}
Err(message) => println!(" run: (skill would be REFUSED before running — {message})"),
}
println!(" after: require '{}', then copy it back into the repo (backing up any existing file)", skill.result);
}
} else {
println!("{}", h_dim(" run `scsh list --verbose` to also see the image Dockerfile and exact commands"));
}
println!();
0
}
fn load_config_for_inspection(override_yml: Option<&Path>, profile: Option<&str>) -> Result<config::Config, i32> {
if runtime::which("git").is_none() {
fail("git is not installed or not on PATH");
hint(install_git_hint());
return Err(1);
}
let root = match git_root() {
Ok(r) => r,
Err(_) => {
fail("not inside a git repository");
hint(&format!("create one here with: {}", bold("git init .")));
return Err(1);
}
};
let (cfg, _) = resolve_config_for_run(&root, override_yml, profile)?;
Ok(cfg)
}
fn profile_groups(cfg: &config::Config) -> Vec<(String, Vec<String>)> {
let expanded = config::expand_invocations(cfg);
let mut groups: Vec<(String, Vec<String>)> =
vec![("default".to_string(), expanded.iter().filter(|s| s.profile.is_none()).map(|s| s.name.clone()).collect())];
for p in declared_profiles(cfg) {
if p == "default" {
continue;
}
let members =
expanded.iter().filter(|s| s.profile.as_deref() == Some(p.as_str())).map(|s| s.name.clone()).collect();
groups.push((p, members));
}
groups
}
fn list_profiles_json(override_yml: Option<&Path>) -> i32 {
let cfg = match load_config_for_inspection(override_yml, None) {
Ok(c) => c,
Err(code) => return code,
};
let groups = profile_groups(&cfg);
let mut out = String::from("{\n \"profiles\": [\n");
for (i, (name, skills)) in groups.iter().enumerate() {
let names = skills.iter().map(|s| json::quote(s)).collect::<Vec<_>>().join(", ");
out.push_str(&format!(" {{ \"name\": {}, \"skills\": [{}] }}", json::quote(name), names));
out.push_str(if i + 1 < groups.len() { ",\n" } else { "\n" });
}
out.push_str(" ]\n}");
println!("{out}");
0
}
fn check_profile_cmd(profile: Option<&str>, override_yml: Option<&Path>) -> i32 {
let name = match profile {
Some(p) => p,
None => {
fail("check-profile needs a profile name, e.g. scsh check-profile multiply");
return 2;
}
};
let cfg = match load_config_for_inspection(override_yml, Some(name)) {
Ok(c) => c,
Err(code) => return code,
};
let count = select_invocations(&cfg, Some(name)).len();
if count > 0 {
ok(&format!("profile '{name}' has {count} skill{}", plural(count)));
return 0;
}
if name == "default" || declared_profiles(&cfg).iter().any(|p| p == name) {
fail(&format!("profile '{name}' exists but has no skills"));
} else {
fail(&format!("no such profile '{name}'"));
let mut avail = declared_profiles(&cfg);
if !avail.iter().any(|p| p == "default") {
avail.insert(0, "default".to_string());
}
hint(&format!("available: {}", avail.join(", ")));
}
1
}
fn probe_cmd(profile: Option<&str>, override_yml: Option<&Path>, json_flag: bool) -> i32 {
let cfg = match load_config_for_inspection(override_yml, profile) {
Ok(c) => c,
Err(code) => return code,
};
let selected: Vec<config::ResolvedInvocation> =
if profile.is_some() { select_invocations(&cfg, profile) } else { config::expand_invocations(&cfg) };
if selected.is_empty() {
match profile {
Some(p) => fail(&format!("nothing to probe — the '{p}' profile is empty")),
None => fail("nothing to probe — the config declares no skills"),
}
hint("see the available profiles and their skills: scsh list");
return 2;
}
let model_probe = runtime::OpencodeModelProbe::for_selected(&selected);
let mut routes: Vec<(config::Harness, Option<String>)> = Vec::new();
for inv in &selected {
let key = (inv.harness, inv.model.clone());
if !routes.contains(&key) {
routes.push(key);
}
}
let rows: Vec<(&'static str, Option<String>, Result<(), String>)> = routes
.iter()
.map(|(harness, model)| {
(harness.as_str(), model.clone(), runtime::check_skill_host(*harness, model.as_deref(), &model_probe))
})
.collect();
let available = rows.iter().filter(|(_, _, res)| res.is_ok()).count();
if json_flag {
let mut out = String::from("{\n \"routes\": [\n");
for (i, (harness, model, res)) in rows.iter().enumerate() {
out.push_str(&format!(" {{ \"harness\": {}, ", json::quote(harness)));
match model {
Some(m) => out.push_str(&format!("\"model\": {}, ", json::quote(m))),
None => out.push_str("\"model\": null, "),
}
match res {
Ok(()) => out.push_str("\"available\": true }"),
Err(e) => out.push_str(&format!("\"available\": false, \"reason\": {} }}", json::quote(e))),
}
out.push_str(if i + 1 < rows.len() { ",\n" } else { "\n" });
}
out.push_str(&format!(" ],\n \"available\": {available},\n \"total\": {}\n}}", rows.len()));
println!("{out}");
} else {
for (harness, model, res) in &rows {
let route = match model {
Some(m) => format!("{harness} · {m}"),
None => (*harness).to_string(),
};
match res {
Ok(()) => ok(&route),
Err(e) => fail(&format!("{route} — {e}")),
}
}
let summary = format!("{available} of {} route{} available", rows.len(), plural(rows.len()));
if available > 0 {
ok(&summary);
} else {
fail(&summary);
hint("log in to at least one agent CLI on this host, then re-run");
}
}
if available > 0 {
0
} else {
1
}
}
fn daemon_cmd(action: DaemonAction) -> i32 {
match action {
DaemonAction::Start => match daemon::start_persistent() {
Ok(()) => {
ok(&format!("session browser daemon listening on {}", daemon::base_url(daemon::daemon_port())));
0
}
Err(e) => {
fail(&format!("could not start daemon: {e}"));
hint("→ check SCSH_DAEMON_PORT and whether another process is already listening on that port");
1
}
},
DaemonAction::Stop => match daemon::stop() {
Ok(true) => {
ok("session browser daemon stopped");
0
}
Ok(false) => {
fail("session browser daemon is not running");
hint("→ start it with: scsh daemon start");
1
}
Err(e) => {
fail(&format!("could not stop daemon: {e}"));
hint("→ check SCSH_DAEMON_PORT and stale files under $TMPDIR/scsh-daemon/");
1
}
},
DaemonAction::Restart => {
let _ = daemon::stop();
match daemon::start_persistent() {
Ok(()) => {
ok(&format!("session browser daemon restarted on {}", daemon::base_url(daemon::daemon_port())));
0
}
Err(e) => {
fail(&format!("could not restart daemon: {e}"));
hint("→ check SCSH_DAEMON_PORT and whether another process is listening on that port");
1
}
}
}
DaemonAction::Status => {
let port = daemon::daemon_port();
if daemon::Client::daemon_alive() {
let running =
daemon::daemon_reported_version(port).unwrap_or_else(|| "unknown (older than this feature)".into());
let where_at = daemon::base_url(port);
match daemon::read_live_pid(port) {
Some(pid) => ok(&format!("session browser daemon running (pid {pid}, scsh {running}) on {where_at}")),
None => ok(&format!("session browser daemon responding (scsh {running}) on {where_at}")),
}
let installed = crate::version::display();
if daemon_version_is_stale(&running, &installed) {
hint(&format!("→ the installed scsh is {installed}; restart to run it in the daemon: scsh daemon restart"));
}
0
} else if let Some(pid) = daemon::read_live_pid(port) {
fail(&format!("session browser daemon pid {pid} exists but is not responding on {}", daemon::base_url(port)));
hint("→ recover with: scsh daemon restart");
1
} else {
fail("session browser daemon is not running");
hint("→ start it with: scsh daemon start");
1
}
}
}
}
fn daemon_version_is_stale(running: &str, installed: &str) -> bool {
running != installed && !running.starts_with("unknown")
}
fn daemon_serve(mode: daemon::DaemonMode, port: u16) -> i32 {
let server = daemon::Server::new(mode, port);
match server.run() {
Ok(()) => 0,
Err(e) => {
fail(&format!("session browser daemon exited: {e}"));
hint("→ check SCSH_DAEMON_PORT and logs from the child process");
1
}
}
}
fn failures_cmd(opts: &FailuresOpts) -> i32 {
let mut events = failure::read_events();
if let Some(s) = &opts.session {
events.retain(|e| e.session.as_deref() == Some(s.as_str()));
}
if let Some(s) = &opts.skill {
events.retain(|e| e.skill.as_deref() == Some(s.as_str()));
}
if let Some(r) = &opts.reason {
events.retain(|e| e.reason == *r);
}
if events.is_empty() {
println!("no recorded failures match");
hint(&format!("failure log: {}", failure::log_path().display()));
return 0;
}
if opts.stats {
print_failure_stats(&events);
return 0;
}
let keep = match opts.last {
Some(0) => events.len(),
Some(n) => n,
None => 50,
};
let start = events.len().saturating_sub(keep);
if start > 0 {
println!("… {start} earlier event(s) hidden — rerun with --last 0 for all");
}
for e in &events[start..] {
print_failure_event(e);
}
0
}
fn print_failure_event(e: &failure::FailureEvent) {
let when = runtime::format_utc_timestamp(e.ts);
if e.kind == "run_summary" {
let profile = e.profile.as_deref().unwrap_or("(no profile)");
let session = e.session.as_deref().unwrap_or("?");
println!(
"{when} run failed: {}/{} skills (profile {profile}, session {session})",
e.failed.unwrap_or(0),
e.total.unwrap_or(0)
);
return;
}
let mut parts = Vec::new();
if let Some(s) = &e.session {
parts.push(format!("session={s}"));
}
if let Some(s) = &e.skill {
parts.push(format!("skill={s}"));
}
if let Some(s) = &e.subject {
parts.push(format!("proc={s}"));
}
if let Some(h) = &e.harness {
let model = e.model.as_deref().unwrap_or("(harness default)");
parts.push(format!("route={h}·{model}"));
}
let verb = if e.kind == "retry" { "retried" } else { "failed" };
println!("{when} [{}] {verb} {}", e.reason, parts.join(" "));
if let Some(d) = &e.detail {
for line in d.lines() {
println!(" {line}");
}
}
}
fn print_failure_stats(events: &[failure::FailureEvent]) {
use std::collections::BTreeMap;
let mut routes: BTreeMap<String, (usize, usize, BTreeMap<String, usize>)> = BTreeMap::new();
let mut reasons: BTreeMap<String, usize> = BTreeMap::new();
for e in events {
if e.kind == "run_summary" {
continue;
}
*reasons.entry(e.reason.clone()).or_default() += 1;
if let Some(h) = &e.harness {
let route = format!("{h} · {}", e.model.as_deref().unwrap_or("(harness default)"));
let entry = routes.entry(route).or_default();
if e.kind == "retry" {
entry.1 += 1;
} else {
entry.0 += 1;
*entry.2.entry(e.reason.clone()).or_default() += 1;
}
}
}
if routes.is_empty() {
println!("no route-attributed failures recorded yet (routes appear on failed skill events)");
} else {
println!("failures by route (harness · model):");
for (route, (fails, retries, by_reason)) in &routes {
let mut reason_bits: Vec<String> = by_reason.iter().map(|(r, n)| format!("{r} ×{n}")).collect();
reason_bits.sort();
let retry_note = if *retries > 0 { format!(", {retries} retried") } else { String::new() };
println!(" {route}: {fails} failure(s){retry_note} — {}", reason_bits.join(", "));
}
}
println!();
println!("failures by reason (all events):");
for (reason, n) in &reasons {
println!(" {reason}: {n}");
}
}
fn stats_cmd(opts: &FailuresOpts, profile: Option<&str>) -> i32 {
let records = stats::read_records();
let matches_common = |r: &stats::StatRecord| {
if let Some(s) = &opts.session {
if r.session != *s {
return false;
}
}
if let Some(p) = profile {
if r.profile.as_deref() != Some(p) {
return false;
}
}
true
};
let skill_rows: Vec<&stats::StatRecord> = records
.iter()
.filter(|r| r.kind == "skill" && matches_common(r))
.filter(|r| {
opts.skill.as_deref().is_none_or(|s| r.skill.as_deref() == Some(s) || r.skill_source.as_deref() == Some(s))
})
.filter(|r| opts.harness.as_deref().is_none_or(|h| r.harness.as_deref() == Some(h)))
.filter(|r| opts.model.as_deref().is_none_or(|m| r.model.as_deref() == Some(m)))
.collect();
let run_rows: Vec<&stats::StatRecord> = records.iter().filter(|r| r.kind == "run" && matches_common(r)).collect();
if skill_rows.is_empty() && run_rows.is_empty() {
println!("no recorded runs match");
hint(&format!("stats file: {}", stats::stats_path().display()));
return 0;
}
if opts.raw {
print_stats_raw(&skill_rows, &run_rows, opts.last);
return 0;
}
print_run_aggregates(&run_rows);
print_skill_aggregates(&skill_rows);
hint(&format!("stats file: {} (individual rows: scsh stats --raw)", stats::stats_path().display()));
0
}
fn print_stats_raw(skill_rows: &[&stats::StatRecord], run_rows: &[&stats::StatRecord], last: Option<usize>) {
let mut rows: Vec<&stats::StatRecord> = skill_rows.iter().chain(run_rows.iter()).copied().collect();
rows.sort_by_key(|r| r.ts);
let keep = match last {
Some(0) => rows.len(),
Some(n) => n,
None => 50,
};
let start = rows.len().saturating_sub(keep);
if start > 0 {
println!("… {start} earlier row(s) hidden — rerun with --last 0 for all");
}
for r in &rows[start..] {
let when = runtime::format_utc_timestamp(r.ts);
if r.kind == "run" {
println!(
"{when} run {:>7.1}s profile={} session={} skills={}/{} ok commits={} loc={}",
r.duration_secs,
r.profile.as_deref().unwrap_or("(default)"),
r.session,
r.skills_total.unwrap_or(0) - r.skills_failed.unwrap_or(0),
r.skills_total.unwrap_or(0),
r.commits,
r.loc_total(),
);
} else {
let route = r.route_label();
let outcome = r.outcome.as_deref().unwrap_or("?");
let retry = if r.attempts > 1 { " (retried)" } else { "" };
println!(
"{when} skill {:>7.1}s {} {route} {outcome}{retry} commits={} loc={}",
r.duration_secs,
r.skill_source.as_deref().or(r.skill.as_deref()).unwrap_or("?"),
r.commits,
r.loc_total(),
);
}
}
}
fn print_run_aggregates(run_rows: &[&stats::StatRecord]) {
if run_rows.is_empty() {
return;
}
use std::collections::BTreeMap;
let mut by_profile: BTreeMap<String, Vec<&stats::StatRecord>> = BTreeMap::new();
for r in run_rows {
by_profile.entry(r.profile.clone().unwrap_or_else(|| "(default)".into())).or_default().push(r);
}
println!("runs by profile:");
for (profile, rows) in &by_profile {
let n = rows.len() as f64;
let mean_secs: f64 = rows.iter().map(|r| r.duration_secs).sum::<f64>() / n;
let mean_commits: f64 = rows.iter().map(|r| r.commits as f64).sum::<f64>() / n;
let mean_loc: f64 = rows.iter().map(|r| r.loc_total() as f64).sum::<f64>() / n;
let failed_runs = rows.iter().filter(|r| r.skills_failed.unwrap_or(0) > 0).count();
println!(
" {profile}: {} run(s), avg {:.0}s, avg workload {:.1} commits / {:.0} LOC{}",
rows.len(),
mean_secs,
mean_commits,
mean_loc,
if failed_runs > 0 { format!(", {failed_runs} with failures") } else { String::new() },
);
}
println!();
}
fn print_skill_aggregates(skill_rows: &[&stats::StatRecord]) {
if skill_rows.is_empty() {
return;
}
use std::collections::BTreeMap;
let mut groups: BTreeMap<(String, String), Vec<&stats::StatRecord>> = BTreeMap::new();
for r in skill_rows {
let skill = r.skill_source.clone().or_else(|| r.skill.clone()).unwrap_or_else(|| "?".into());
groups.entry((skill, r.route_label())).or_default().push(r);
}
let skill_w = groups.keys().map(|(s, _)| s.len()).max().unwrap_or(5).max(5);
let route_w = groups.keys().map(|(_, r)| r.len()).max().unwrap_or(5).max(5);
println!("skills by route (durations exclude cache hits):");
println!(
" {:<skill_w$} {:<route_w$} {:>4} {:>3} {:>4} {:>5} {:>5} {:>7} {:>7} {:>7} {:>8} {:>7}",
"skill", "route", "runs", "ok", "fail", "cache", "retry", "avg s", "min s", "max s", "~commits", "~LOC"
);
for ((skill, route), rows) in &groups {
let agg = stats::aggregate_skills(rows);
println!(
" {:<skill_w$} {:<route_w$} {:>4} {:>3} {:>4} {:>5} {:>5} {:>7.1} {:>7.1} {:>7.1} {:>8.1} {:>7.0}",
skill,
route,
agg.runs,
agg.ok,
agg.failed,
agg.cached,
agg.retried,
agg.mean_secs,
agg.min_secs,
agg.max_secs,
agg.mean_commits,
agg.mean_loc,
);
}
}
fn prune_cmd(now_flag: bool) -> i32 {
let port = daemon::daemon_port();
let queue = daemon::prune::PruneQueue::load(port);
if !now_flag {
if queue.jobs.is_empty() {
ok("run-dir prune queue is empty");
return 0;
}
let now = daemon::now_unix_secs();
println!("{} pending run-dir prune job(s):", queue.jobs.len());
for j in &queue.jobs {
let outcome = if j.outcome_ok { "ok" } else { "failed" };
let when =
if now >= j.eligible_at { "eligible now".to_string() } else { format!("eligible in {}s", j.eligible_at - now) };
println!(" {} ({outcome} run, {when})", j.run_dir);
}
hint("delete every eligible dir now with: scsh prune --now");
return 0;
}
let before = queue.jobs.len();
if daemon::daemon_port_reachable(port) {
if !daemon::post_once(port, "/api/v1/prune/tick", "{}") {
fail("session browser daemon is running but rejected the prune request");
return 1;
}
} else {
let mut q = queue;
let _ = q.tick(daemon::now_unix_secs());
q.save(port);
}
let after = daemon::prune::PruneQueue::load(port).jobs.len();
ok(&format!("prune pass complete: {before} job(s) before, {after} remaining"));
0
}
fn gc_cmd(opts: &gc::GcOpts) -> i32 {
let home = runtime::scsh_home();
let plan = gc::plan(&home, opts, gc::now_unix_secs());
if plan.candidates.is_empty() {
ok("nothing to reclaim");
return 0;
}
for c in &plan.candidates {
println!(" {} {}", c.path.display(), gc::human_bytes(c.bytes));
}
if opts.apply {
let freed = gc::apply_plan(&plan);
ok(&format!("deleted {} path(s); freed {}", plan.candidates.len(), gc::human_bytes(freed)));
} else {
ok(&format!(
"reclaimable: {} across {} path(s) (dry-run)",
gc::human_bytes(plan.total_bytes),
plan.candidates.len()
));
hint(&format!("delete with: {}", bold("scsh gc --apply")));
}
0
}
fn repo_path_for_session(root: &Path) -> String {
daemon::absolutize_repo_path(root)
}
struct DaemonSession {
client: Option<std::sync::Arc<daemon::Client>>,
ping_active: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
registered: bool,
}
impl DaemonSession {
fn cleanup(&mut self) {
if let Some(flag) = self.ping_active.take() {
flag.store(false, std::sync::atomic::Ordering::Relaxed);
}
if let Some(c) = self.client.take() {
if self.registered {
c.finish_session();
ok(&format!("job {}", c.session_url()));
} else {
c.flush();
}
}
}
}
impl Drop for DaemonSession {
fn drop(&mut self) {
self.cleanup();
}
}
fn build_and_run(
rt: &Runtime, root: &std::path::Path, skills: &[&ResolvedInvocation], profile: Option<&str>, session: &str,
kind: &str,
) -> i32 {
ui::signals::install();
let session_id = session.to_string();
let mut daemon_session = DaemonSession { client: None, ping_active: None, registered: false };
match daemon::ensure_for_run() {
Ok(()) => {
let client = std::sync::Arc::new(daemon::Client::new(session_id.clone()));
let skill_meta: Vec<(&str, &str)> = skills.iter().map(|s| (s.name.as_str(), s.harness.as_str())).collect();
if client.register_session(&repo_path_for_session(root), ¤t_branch(root), profile, kind, &skill_meta) {
ok(&format!("track progress at {}", client.session_url()));
let ping_active = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
let ping_flag = std::sync::Arc::clone(&ping_active);
let ping_client = std::sync::Arc::clone(&client);
std::thread::spawn(move || {
while ping_flag.load(std::sync::atomic::Ordering::Relaxed) {
ping_client.ping();
std::thread::sleep(Duration::from_secs(2));
}
});
daemon_session.client = Some(client);
daemon_session.ping_active = Some(ping_active);
daemon_session.registered = true;
} else {
hint(&format!("session browser daemon is up but registration failed; try {}", client.session_url()));
}
}
Err(e) => {
hint(&format!("session browser daemon unavailable ({e}); continuing without live browser UI"));
}
}
let daemon_client = daemon_session.client.clone();
let (uid, gid) = runtime::host_ids();
let secs = now_secs();
if !keep_run_dirs() {
let swept = sweep_stale_run_dirs(secs);
if swept > 0 {
hint(&format!("swept {swept} stale run dir{} from /tmp", plural(swept)));
}
}
let base = git_capture(root, &["rev-parse", "HEAD"]).map(|s| s.trim().to_string());
let needs_opencode = skills.iter().any(|s| s.harness == config::Harness::Opencode);
let needs_claude = skills.iter().any(|s| s.harness == config::Harness::Claude);
let needs_codex = skills.iter().any(|s| s.harness == config::Harness::Codex);
if needs_opencode && opencode_auth_enabled() && runtime::opencode_auth_ready() {
ok("opencode creds found (auth.json and opencode config forwarded into the run clone)");
}
if needs_claude && runtime::claude_container_auth_ready() {
let via =
if runtime::claude_oauth_token().is_some() { "CLAUDE_CODE_OAUTH_TOKEN" } else { "~/.claude/.credentials.json" };
ok(&format!("claude credentials found ({via} forwarded into claude skills)"));
}
if needs_codex && codex_auth_enabled() && runtime::codex_container_auth_ready() {
let via = if runtime::codex_auth_file_on_host().is_some() { "~/.codex/auth.json" } else { "OPENAI_API_KEY" };
ok(&format!("codex credentials found ({via} forwarded into codex skills)"));
}
let needs_grok = skills.iter().any(|s| s.harness == config::Harness::Grok);
if needs_grok && grok_auth_enabled() && runtime::grok_container_auth_ready() {
let via = if runtime::grok_auth_file_on_host().is_some() { "~/.grok/auth.json" } else { "XAI_API_KEY" };
ok(&format!("grok credentials found ({via} forwarded into grok skills)"));
}
let needs_cursor = skills.iter().any(|s| s.harness == config::Harness::Cursor);
if needs_cursor && cursor_auth_enabled() && runtime::cursor_container_auth_ready() {
let via = if runtime::cursor_api_key().is_some() {
"CURSOR_API_KEY"
} else if runtime::cursor_auth_file_on_host().is_some() {
"auth.json"
} else {
"macOS keychain"
};
ok(&format!("cursor credentials found ({via} forwarded into cursor skills)"));
}
let ui = ui::screen::LiveUi::new(console::user_attended_stderr(), daemon_client.clone());
let df = runtime::dockerfile_for_runtime(&rt.name);
if rt.name == "container" && runtime::apple_dockerfile_too_large(&df) {
fail(&runtime::apple_dockerfile_too_large_message(df.len()));
return 1;
}
let tz = runtime::host_timezone();
let mut harness_list = Vec::new();
let mut seen_harness = std::collections::BTreeSet::new();
for s in skills {
if seen_harness.insert(s.harness) {
harness_list.push(s.harness);
}
}
let rt_name = rt.name.clone();
let base_fp = runtime::base_image_fingerprint(&df, uid, gid, &tz);
let base_needs_build = !runtime::image_is_up_to_date(&rt_name, runtime::BASE_IMAGE_TAG, &base_fp);
let mut harness_builds: Vec<runtime::ImageBuildSpec> = Vec::new();
for &h in &harness_list {
let spec = runtime::image_build_spec(h, &df, uid, gid, &tz);
if !runtime::image_is_up_to_date(&rt_name, &spec.tag, &spec.fingerprint) {
harness_builds.push(spec);
}
}
let any_image_build = base_needs_build || !harness_builds.is_empty();
let mut base_build = None;
if base_needs_build {
let base_label = format!("using {} · build base", backend_name(&rt.name));
let p = ui.proc(base_label.clone(), false);
if let Some(c) = &daemon_client {
c.proc_add(p.index(), &base_label, daemon::ProcKind::Build, None, None, None, None, None, None, None);
}
base_build = Some(p);
}
let mut harness_build_procs: Vec<ui::screen::Proc> = Vec::with_capacity(harness_builds.len());
for spec in &harness_builds {
let label = format!("using {} · build {}", backend_name(&rt.name), spec.harness.as_str());
let p = ui.proc(label.clone(), false);
if let Some(c) = &daemon_client {
c.proc_add(
p.index(),
&label,
daemon::ProcKind::Build,
None,
Some(spec.harness.as_str()),
None,
None,
None,
None,
None,
);
}
harness_build_procs.push(p);
}
let mut skill_procs = Vec::with_capacity(skills.len());
for skill in skills {
let label = format!("{}: {}", skill.harness.as_str(), skill.name);
let p = ui.proc(label.clone(), false);
if let Some(c) = &daemon_client {
c.proc_add(
p.index(),
&label,
daemon::ProcKind::Skill,
Some(skill.name.as_str()),
Some(skill.harness.as_str()),
skill.model.as_deref(),
Some(skill.skill_source.as_str()),
fleet_route_name(skill),
None,
None,
);
}
if any_image_build {
p.note("waiting for image build…");
}
skill_procs.push(p);
}
ui.pin_board_to_top();
let mut build_failed = if let Some(ref base) = base_build {
base.start();
match run_build(
base,
&rt_name,
runtime::BASE_IMAGE_TAG,
runtime::BASE_IMAGE_TARGET,
&df,
uid,
gid,
&base_fp,
false,
daemon_client.as_deref(),
"base",
&session_id,
) {
Ok(()) => {
base.finish_ok(None);
None
}
Err(e) => {
base.finish_fail(failure::reason::BUILD_FAILED, Some(&e.0));
Some(e)
}
}
} else {
None
};
if build_failed.is_none() {
build_failed = std::thread::scope(|scope| {
let session_ref = session_id.as_str();
let handles: Vec<_> = harness_build_procs
.iter()
.zip(harness_builds.iter())
.map(|(build, spec)| {
let rt_name = &rt_name;
let df = &df;
let daemon_client = &daemon_client;
scope.spawn(move || {
build.start();
match run_build(
build,
rt_name,
&spec.tag,
&spec.target,
df,
uid,
gid,
&spec.fingerprint,
false,
daemon_client.as_deref(),
spec.harness.as_str(),
session_ref,
) {
Ok(()) => {
build.finish_ok(None);
None
}
Err(e) => {
build.finish_fail(failure::reason::BUILD_FAILED, Some(&e.0));
Some(e)
}
}
})
})
.collect();
handles
.into_iter()
.filter_map(|h| h.join().unwrap_or_else(|_| Some(("image build thread panicked".to_string(), 1))))
.next()
});
}
if let Some((msg, code)) = build_failed {
ui.finish();
fail(&msg);
return code;
}
let base_ref = base.as_deref();
for p in &skill_procs {
p.note("starting…");
}
let run_started = std::time::Instant::now();
let workload = stats::workload_of_repo(root);
let outcomes: Vec<SkillRun> = std::thread::scope(|scope| {
let dc = daemon_client.clone();
let ui_ref = &ui;
let session_ref = session_id.as_str();
let handles: Vec<_> = skills
.iter()
.zip(skill_procs)
.map(|(&skill, p)| {
let dc = dc.clone();
let first_index = p.index();
scope.spawn(move || {
let mut retry = RouteRetryState::new(skill.retry_for, skill.retry_signature_cap, session_ref, &skill.name);
let mut proc = p;
let mut proc_index = first_index;
let mut attempts = 0u64;
loop {
attempts += 1;
let attempt_started = std::time::Instant::now();
let mut run = run_one_skill(skill, rt, root, secs, proc, base_ref, None, None, dc.clone(), session_ref);
run.duration_secs = attempt_started.elapsed().as_secs_f64();
run.proc_index = proc_index;
run.attempts = attempts;
if run.ok {
daemon::consume_proc_restart(session_ref, proc_index);
return run;
}
retry.observe_failure(&run);
let restart_requested = daemon::consume_proc_restart(session_ref, proc_index);
let decision = retry_decision(
run.fail_reason.as_deref(),
restart_requested,
false,
retry.policy,
retry.budget_spent_secs(),
retry.consecutive_identical,
failure::retry_enabled(),
skill.harness.is_tui(),
attempts == 1,
);
match decision {
RetryDecision::Stop => return run,
RetryDecision::StopBreaker => {
retry.mark_breaker_tripped(&mut run);
return run;
}
RetryDecision::Browser | RetryDecision::Schema | RetryDecision::Automatic => {}
}
let reason = if restart_requested {
failure::reason::RESTART_REQUESTED
} else {
run.fail_reason.as_deref().unwrap_or("unknown")
};
failure::log_retry(session_ref, &skill.name, skill.harness.as_str(), skill.model.as_deref(), reason);
let label = format!("{}: {} (retry)", skill.harness.as_str(), skill.name);
let next = ui_ref.proc(label.clone(), false);
if let Some(c) = &dc {
c.proc_add(
next.index(),
&label,
daemon::ProcKind::Skill,
Some(skill.name.as_str()),
Some(skill.harness.as_str()),
skill.model.as_deref(),
Some(skill.skill_source.as_str()),
fleet_route_name(skill),
None,
Some(proc_index),
);
}
proc_index = next.index();
proc = next;
if decision == RetryDecision::Automatic {
let delay = retry.next_backoff_secs();
proc.note(&format!(
"retrying in ~{} after {reason} (attempt {}, retry budget {} left)",
ui::clock::format_elapsed(delay as f64),
attempts + 1,
ui::clock::format_elapsed(retry.budget_left_secs() as f64),
));
if backoff_sleep_interruptible(delay, session_ref, proc_index) {
proc.note("retrying now (browser restart)");
}
}
}
})
})
.collect();
handles
.into_iter()
.zip(skills)
.map(|(h, skill)| {
h.join().unwrap_or_else(|_| {
failure::log_skill(
failure::reason::THREAD_PANICKED,
&skill.name,
"skill thread panicked before reporting outcome",
);
SkillRun::failed(failure::reason::THREAD_PANICKED, None, None, None)
})
})
.collect()
});
ui.finish();
{
use daemon::{ProcKind, ProcRecord, ProcStatus};
let mut fake_procs = Vec::with_capacity(skills.len());
for (skill, o) in skills.iter().zip(outcomes.iter()) {
let safe = skill.name.replace('/', "_");
let result_path = runtime::session_results_dir(&session_id).join(format!("{safe}.json"));
let result_path = result_path.is_file().then(|| result_path.to_string_lossy().into_owned());
fake_procs.push(ProcRecord {
index: o.proc_index,
previous_attempt: None,
label: format!("{}: {}", skill.harness.as_str(), skill.name),
kind: ProcKind::Skill,
status: if o.graceful_shutdown {
ProcStatus::Graceful
} else if o.ok {
ProcStatus::Ok
} else {
ProcStatus::Fail
},
skill_name: Some(skill.name.clone()),
harness: Some(skill.harness.as_str().to_string()),
model: skill.model.clone(),
started_at: None,
note: None,
detail: o.result_content.as_deref().and_then(json::message),
fail_reason: o.fail_reason.clone(),
elapsed: Some(o.duration_secs),
lines: vec![],
container_name: None,
container_runtime: None,
cast_path: None,
diff_path: None,
skill_source: Some(skill.skill_source.clone()),
route: fleet_route_name(skill).map(str::to_string),
result_path,
annotate_target: None,
});
}
let _ = fleet::write_rollups(&session_id, &fake_procs);
}
let n = outcomes.len();
let failed = outcomes.iter().filter(|o| !o.ok).count();
for (skill, o) in skills.iter().zip(outcomes.iter()).filter(|(_, o)| !o.ok) {
if let Some(dir) = &o.run_dir {
hint(&format!("run dir kept: {dir}"));
}
if let Some(log) = &o.log {
hint(&format!("output log: {log}"));
}
let reason = o.fail_reason.as_deref().unwrap_or("unknown");
let mut detail = String::new();
if let Some(d) = &o.run_dir {
detail.push_str(&format!("run dir: {d}\n"));
}
if let Some(l) = &o.log {
detail.push_str(&format!("output log: {l}"));
}
failure::log_failed_skill(
&session_id,
&skill.name,
skill.harness.as_str(),
skill.model.as_deref(),
reason,
detail.trim(),
);
}
if failed > 0 {
failure::log_run_summary(&session_id, profile, failed, n);
hint(&format!("failure log: {} (browse with `scsh failures`)", failure::log_path().display()));
}
{
let branch = current_branch(root);
let repo = repo_path_for_session(root);
for (skill, o) in skills.iter().zip(outcomes.iter()) {
let outcome = if o.cached {
"cached"
} else if o.ok {
"ok"
} else {
"fail"
};
stats::record(&stats::StatRecord {
ts: secs,
kind: "skill".into(),
session: session_id.clone(),
repo: repo.clone(),
branch: branch.clone(),
profile: profile.map(str::to_string),
skill: Some(skill.name.clone()),
skill_source: Some(skill.skill_source.clone()),
harness: Some(skill.harness.as_str().to_string()),
model: skill.model.clone(),
effort: skill.effort.clone(),
outcome: Some(outcome.into()),
fail_reason: o.fail_reason.clone(),
attempts: o.attempts,
duration_secs: o.duration_secs,
commits: workload.commits,
loc_added: workload.loc_added,
loc_deleted: workload.loc_deleted,
skills_total: None,
skills_failed: None,
});
}
stats::record(&stats::StatRecord {
ts: secs,
kind: "run".into(),
session: session_id.clone(),
repo,
branch,
profile: profile.map(str::to_string),
attempts: 1,
duration_secs: run_started.elapsed().as_secs_f64(),
commits: workload.commits,
loc_added: workload.loc_added,
loc_deleted: workload.loc_deleted,
skills_total: Some(n as u64),
skills_failed: Some(failed as u64),
..Default::default()
});
}
if let Some(base) = &base {
let stamp = runtime::format_utc_timestamp(secs);
for (skill, o) in skills.iter().zip(outcomes.iter()) {
if !skill.commits {
continue;
}
let integration = if let Some(clone) = &o.clone_dir {
integrate_commits(root, clone, base, &skill.name, &stamp)
} else if let Some(patch) = &o.cached_commits {
apply_cached_commits(root, patch, &skill.name, &stamp)
} else {
continue;
};
match integration {
Ok(None) => {}
Ok(Some(Integration::Applied { count, range })) => {
ok(&format!(
"{}: brought in {count} commit{} (rebased onto {})",
skill.name,
plural(count),
current_branch(root)
));
pack_step_diff(root, &session_id, skill, o, range, daemon_session.client.as_deref());
}
Ok(Some(Integration::Saved { branch, count, range })) => {
warn(&format!(
"{}: {count} commit{} didn't rebase cleanly — saved to branch {branch} (inspect, then merge/cherry-pick)",
skill.name,
plural(count)
));
pack_step_diff(root, &session_id, skill, o, range, daemon_session.client.as_deref());
}
Err(e) => warn(&format!("{}: could not bring in commits — {e}", skill.name)),
}
}
if let Some(head) = git_capture(root, &["rev-parse", "HEAD"]) {
pack_job_diff(root, &session_id, base, head.trim());
}
}
if !keep_run_dirs() {
for o in outcomes.iter().filter(|o| o.ok) {
if let Some(clone) = &o.clone_dir {
let _ = std::fs::remove_dir_all(clone);
}
}
}
if let Some(c) = &daemon_session.client {
ok(&format!("session recordings & live board: {}", c.session_url()));
}
let mut next_annotate_idx = outcomes.iter().map(|o| o.proc_index).max().map(|m| m + 1).unwrap_or(0);
annotate_run_casts(root, session_skill_casts(&session_id), daemon_session.client.as_deref(), &mut next_annotate_idx);
if failed == 0 {
ok(&format!("all {n} skill{} completed successfully", plural(n)));
0
} else {
fail(&format!("{failed} of {n} skill{} failed", plural(n)));
1
}
}
struct SkillRun {
ok: bool,
proc_index: usize,
fail_reason: Option<String>,
fail_detail: Option<String>,
cached: bool,
duration_secs: f64,
attempts: u64,
run_dir: Option<String>,
log: Option<String>,
clone_dir: Option<PathBuf>,
cached_commits: Option<String>,
result_content: Option<String>,
workflow_outputs: Option<std::collections::HashMap<String, String>>,
graceful_shutdown: bool,
}
impl SkillRun {
fn base() -> SkillRun {
SkillRun {
ok: false,
proc_index: 0,
fail_reason: None,
fail_detail: None,
cached: false,
duration_secs: 0.0,
attempts: 1,
run_dir: None,
log: None,
clone_dir: None,
cached_commits: None,
result_content: None,
workflow_outputs: None,
graceful_shutdown: false,
}
}
fn ok(
log: String, clone_dir: Option<PathBuf>, result_content: Option<String>,
workflow_outputs: Option<std::collections::HashMap<String, String>>,
) -> SkillRun {
SkillRun { ok: true, log: Some(log), clone_dir, result_content, workflow_outputs, ..SkillRun::base() }
}
fn graceful(
log: String, clone_dir: Option<PathBuf>, result_content: Option<String>,
workflow_outputs: Option<std::collections::HashMap<String, String>>,
) -> SkillRun {
SkillRun {
ok: true,
graceful_shutdown: true,
log: Some(log),
clone_dir,
result_content,
workflow_outputs,
..SkillRun::base()
}
}
fn failed(reason: &str, run_dir: Option<String>, log: Option<String>, clone_dir: Option<PathBuf>) -> SkillRun {
SkillRun { fail_reason: Some(reason.into()), run_dir, log, clone_dir, ..SkillRun::base() }
}
fn with_fail_detail(mut self, why: &str) -> SkillRun {
self.fail_detail = Some(why.to_string());
self
}
fn invalid_result(
detail: String, run_dir: String, log: String, clone_dir: Option<PathBuf>, result_content: String,
) -> SkillRun {
SkillRun {
fail_reason: Some(failure::reason::RESULT_INVALID.into()),
fail_detail: Some(detail),
run_dir: Some(run_dir),
log: Some(log),
clone_dir,
result_content: Some(result_content),
..SkillRun::base()
}
}
fn cached(
cached_commits: Option<String>, result_content: String,
workflow_outputs: Option<std::collections::HashMap<String, String>>,
) -> SkillRun {
SkillRun {
ok: true,
cached: true,
cached_commits,
result_content: Some(result_content),
workflow_outputs,
..SkillRun::base()
}
}
}
fn skill_fail_detail(why: &str, harness: config::Harness, run_dir: Option<&str>, log: Option<&str>) -> String {
let mut parts = vec![why.to_string()];
if let Some(d) = run_dir {
parts.push(format!("run dir: {d}"));
}
if let Some(l) = log {
parts.push(format!("output log: {l}"));
if runtime::harness_verbose_enabled() {
match harness {
config::Harness::Claude => parts.push(format!("claude debug log: {l}.debug")),
config::Harness::Codex => parts.push(format!("codex final message: {l}.last")),
config::Harness::Grok => parts.push(format!("grok debug log: {l}.debug")),
config::Harness::Cursor => {}
config::Harness::Opencode => {}
}
}
}
parts.join("\n")
}
fn cast_tail_text(cast: &Path) -> String {
const READ_BYTES: u64 = 32 * 1024;
const KEEP_CHARS: usize = 4 * 1024;
use std::io::{Read, Seek, SeekFrom};
let Ok(mut file) = std::fs::File::open(cast) else { return String::new() };
let len = file.metadata().map(|m| m.len()).unwrap_or(0);
let start = len.saturating_sub(READ_BYTES);
if file.seek(SeekFrom::Start(start)).is_err() {
return String::new();
}
let mut raw = Vec::new();
if file.read_to_end(&mut raw).is_err() {
return String::new();
}
let raw = String::from_utf8_lossy(&raw);
let mut text = String::new();
for line in raw.lines().skip(if start > 0 { 1 } else { 0 }) {
let Ok(json::Value::Array(event)) = json::parse(line) else { continue };
if let (Some(json::Value::String(kind)), Some(json::Value::String(data))) = (event.get(1), event.get(2)) {
if kind == "o" {
text.push_str(&console::strip_ansi_codes(data));
}
}
}
if text.len() > KEEP_CHARS {
let mut cut = text.len() - KEEP_CHARS;
while !text.is_char_boundary(cut) {
cut += 1;
}
text.split_off(cut)
} else {
text
}
}
fn apple_container_lost_shell_response(last: Option<&str>) -> bool {
last.is_some_and(|line| {
line.contains("failed to send signal") && line.contains("missing signal in xpc message") && line.contains("signal")
})
}
fn inner_harness_result_is_good(run_dir: &Path, result_rel: &str, commits: bool) -> bool {
let exit = run_dir.join(format!("{}.exit", runtime::RUN_LOG_REL));
let exit_zero = std::fs::read_to_string(exit).is_ok_and(|code| code.trim() == "0");
if !exit_zero {
return false;
}
let result_good = std::fs::read_to_string(run_dir.join(result_rel))
.ok()
.and_then(|body| json::parse(&body).ok())
.is_some_and(|value| matches!(value, json::Value::Object(_)));
if !result_good || !commits {
return result_good;
}
git_capture(&run_dir.join(runtime::PULL_BARE), &["for-each-ref", "--format=%(refname)", "refs/heads"])
.is_some_and(|refs| !refs.trim().is_empty())
}
fn interrupted_harness_result_is_recoverable(run_dir: &Path, result_rel: &str) -> bool {
run_dir.join(result_rel).is_file()
}
fn wait_for_inner_harness_result(run_dir: &Path, result_rel: &str, commits: bool, limit: Duration) -> bool {
let deadline = Instant::now() + limit;
loop {
if inner_harness_result_is_good(run_dir, result_rel, commits) {
return true;
}
if Instant::now() >= deadline {
return false;
}
std::thread::sleep(Duration::from_millis(200));
}
}
#[allow(clippy::too_many_arguments)]
fn run_one_skill(
skill: &ResolvedInvocation, rt: &Runtime, root: &Path, secs: u64, spinner: ui::screen::Proc, base: Option<&str>,
source_revision: Option<&str>, result_contract: Option<WorkflowResultContract<'_>>,
daemon_client: Option<std::sync::Arc<daemon::Client>>, session_id: &str,
) -> SkillRun {
spinner.start();
let run_started = Instant::now();
let env = match resolve_env(&skill.env) {
Ok(mut e) => {
e.push(("SCSH_RESULT".to_string(), skill.result.clone()));
e
}
Err(message) => {
spinner.finish_fail(failure::reason::ENV_UNRESOLVED, Some(&message));
return SkillRun::failed(failure::reason::ENV_UNRESOLVED, None, None, None);
}
};
let key = cache_key_at(root, skill, &env, source_revision);
if let Some(key) = &key {
if let Some(entry) = cache_lookup(root, key) {
let workflow_outputs = result_contract.and_then(|contract| extract_step_outputs(&entry.result, contract).ok());
let valid = result_contract.is_none() || workflow_outputs.is_some();
if valid && restore_cached_result(root, &skill.result, &entry.result).is_ok() {
let provenance = cache_hit_provenance(entry.cached_at, entry.elapsed);
let line = match json::message(&entry.result) {
Some(m) => format!("{} ({provenance})", first_line(&m)),
None => format!("({provenance})"),
};
if let (Some(c), Some(cast)) = (&daemon_client, &entry.cast) {
if let Some(src) = &entry.chapters {
if let Some(dest) = daemon::chapters_sidecar_path(&cast.to_string_lossy()) {
let _ = std::fs::copy(src, dest);
}
}
c.proc_cast(spinner.index(), &cast.to_string_lossy());
}
let host_result = root.join(&skill.result);
if let Some(path) = fleet::persist_skill_result(session_id, &skill.name, &host_result) {
if let Some(c) = &daemon_client {
c.proc_result(spinner.index(), &path);
}
}
spinner.finish_ok(Some(&line));
return SkillRun::cached(entry.commits, entry.result, workflow_outputs);
}
}
}
spinner.note("preparing repo…");
let run_dir = match prepare_run_dir(secs, &skill.name, &rt.name) {
Ok(d) => d,
Err(e) => {
spinner.finish_fail(failure::reason::RUN_DIR, Some(&e));
return SkillRun::failed(failure::reason::RUN_DIR, None, None, None);
}
};
let run_dir_str = run_dir.to_string_lossy().into_owned();
let git_transport = runtime::uses_git_transport(&rt.name);
let mut git_daemon = None;
if git_transport {
if let Err(e) = prepare_git_transport(root, &run_dir, skill.commits, source_revision, &spinner) {
spinner.finish_fail(failure::reason::GIT_TRANSPORT, Some(&e));
return SkillRun::failed(failure::reason::GIT_TRANSPORT, Some(run_dir_str), None, None);
}
match GitTransport::start(&run_dir) {
Ok(d) => git_daemon = Some(d),
Err(e) => {
spinner.finish_fail(failure::reason::GIT_DAEMON, Some(&e));
return SkillRun::failed(failure::reason::GIT_DAEMON, Some(run_dir_str), None, None);
}
}
} else if let Err(e) = clone_into(root, &run_dir, source_revision, &spinner) {
spinner.finish_fail(failure::reason::CLONE, Some(&e));
return SkillRun::failed(failure::reason::CLONE, Some(run_dir_str), None, None);
}
let clone_dir = Some(run_dir.clone());
if let Err(e) = materialize_skill_body(&run_dir, git_transport, skill) {
spinner.finish_fail(failure::reason::CLONE, Some(&e));
return SkillRun::failed(failure::reason::CLONE, Some(run_dir_str), None, clone_dir);
}
if let Some(parent) = Path::new(&skill.result).parent() {
if !parent.as_os_str().is_empty() {
let _ = std::fs::create_dir_all(run_dir.join(parent));
}
}
spinner.note(&format!("{} run…", skill.harness.as_str()));
let name = run_dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| skill.name.clone());
let log_path = run_dir.join(runtime::RUN_LOG_REL);
if let Some(parent) = log_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let log = log_path.to_string_lossy().into_owned();
let opencode_forward = if skill.harness == config::Harness::Opencode && opencode_auth_enabled() {
forward_opencode(&run_dir)
} else {
None
};
let claude_auth = if skill.harness == config::Harness::Claude && claude_auth_enabled() {
forward_claude_auth(&run_dir)
} else {
None
};
let codex_auth =
if skill.harness == config::Harness::Codex && codex_auth_enabled() { forward_codex(&run_dir) } else { None };
let grok_auth =
if skill.harness == config::Harness::Grok && grok_auth_enabled() { forward_grok(&run_dir) } else { None };
let cursor_auth =
if skill.harness == config::Harness::Cursor && cursor_auth_enabled() { forward_cursor(&run_dir) } else { false };
let tag = runtime::image_tag(skill.harness);
let mut vols: Vec<(String, String)> = runtime::harness_volumes(skill.harness);
if git_transport && skill.result.starts_with(".harness/tmp/") {
let host = run_dir.join(".harness").join("tmp");
let _ = std::fs::create_dir_all(&host);
vols.push((host.to_string_lossy().into_owned(), format!("{}/.harness/tmp", runtime::AGENT_REPO)));
}
let vol_refs: Vec<(&str, &str)> = vols.iter().map(|(h, m)| (h.as_str(), m.as_str())).collect();
let mut container_env = env.clone();
if skill.harness == config::Harness::Claude {
if let Some(token) = runtime::claude_oauth_token() {
container_env.push((runtime::CLAUDE_OAUTH_TOKEN_ENV.to_string(), token));
}
}
if skill.harness == config::Harness::Codex {
if let Ok(key) = std::env::var(runtime::OPENAI_API_KEY_ENV) {
if !key.is_empty() {
container_env.push((runtime::OPENAI_API_KEY_ENV.to_string(), key));
}
}
}
if skill.harness == config::Harness::Grok {
if let Ok(key) = std::env::var(runtime::XAI_API_KEY_ENV) {
if !key.is_empty() {
container_env.push((runtime::XAI_API_KEY_ENV.to_string(), key));
}
}
}
if skill.harness == config::Harness::Cursor {
if let Some(key) = runtime::cursor_api_key() {
container_env.push((runtime::CURSOR_API_KEY_ENV.to_string(), key));
}
}
container_env.extend(runtime::harness_container_env(skill.harness));
if let Some(d) = &git_daemon {
container_env.extend(d.env());
}
let harness = runtime::harness_command(
skill.harness,
skill.model.as_deref(),
skill.effort.as_deref(),
&skill.skill_source,
&skill.result,
skill.terminal,
&skill.delivery,
);
let cmd = if git_transport {
runtime::git_transport_entry(&harness, skill.commits, SCSH_COMMIT_NAME, SCSH_COMMIT_EMAIL)
} else {
harness
};
let repo_mount = if git_transport { runtime::RepoMountMode::TmpOnly } else { runtime::RepoMountMode::Full };
let run = runtime::run_command(&rt.name, &tag, &run_dir_str, &name, &container_env, &vol_refs, &cmd, repo_mount);
let timeout = skill.timeout.map(Duration::from_secs);
let inactivity_secs = config::effective_inactivity_timeout(skill.harness, skill.inactivity_timeout);
let watch = ui::screen::ActivityWatch {
file: run_dir.join(runtime::RUN_CAST_REL),
limit: Duration::from_secs(inactivity_secs),
};
let _container = ui::signals::ContainerGuard::new(&rt.name, &name);
if let Some(c) = &daemon_client {
c.container_event(spinner.index(), "start", &name, &rt.name);
c.proc_cast(spinner.index(), &run_dir.join(runtime::RUN_CAST_REL).to_string_lossy());
}
let result = spinner.run_watched(&run[0], &run[1..], timeout, Some(&watch));
let mut graceful_shutdown_reason: Option<&'static str> = None;
let result = match result {
Ok((false, ui::screen::Killed::No, last))
if rt.name == "container" && apple_container_lost_shell_response(last.as_deref()) =>
{
let recovery_limit = timeout
.map(|limit| limit.saturating_sub(run_started.elapsed()))
.unwrap_or_else(|| Duration::from_secs(inactivity_secs));
if wait_for_inner_harness_result(&run_dir, &skill.result, skill.commits, recovery_limit) {
graceful_shutdown_reason =
Some("accepted the valid result and inner exit 0 after Apple Container lost its shell response");
Ok((true, ui::screen::Killed::No, last))
} else {
Ok((false, ui::screen::Killed::No, last))
}
}
Ok((false, killed, last)) if interrupted_harness_result_is_recoverable(&run_dir, &skill.result) => {
graceful_shutdown_reason = Some(match killed {
ui::screen::Killed::Inactive => "accepted the valid result after the inactivity watchdog stopped the harness",
ui::screen::Killed::Timeout => "accepted the valid result after the wall-clock watchdog stopped the harness",
ui::screen::Killed::No => "accepted the valid result despite the harness exiting non-zero during teardown",
});
Ok((true, killed, last))
}
other => other,
};
ui::signals::stop_container(&rt.name, &name);
if let Some(c) = &daemon_client {
c.container_event(spinner.index(), "stop", &name, &rt.name);
}
let durable_cast = persist_run_artifacts(session_id, &run_dir, &skill.name, secs);
if let (Some(c), Some(durable)) = (&daemon_client, &durable_cast) {
c.proc_cast(spinner.index(), durable);
}
if let Some(p) = &claude_auth {
let _ = std::fs::remove_dir_all(p);
}
if let Some(p) = &opencode_forward {
let _ = std::fs::remove_dir_all(p);
}
if let Some(p) = &codex_auth {
scrub_codex_credentials(p);
}
if let Some(p) = &grok_auth {
scrub_grok_credentials(p);
}
if cursor_auth {
scrub_cursor_credentials(&run_dir);
}
match result {
Ok((true, _, _)) => {
if let Some(durable) = durable_cast.as_deref() {
spawn_cast_annotation(Path::new(durable));
}
}
Ok((false, ui::screen::Killed::Timeout, _)) => {
schedule_run_dir_prune_backup(daemon_client.as_ref(), &run_dir_str, &name, &rt.name, false);
let why = format!("timed out after {}s", skill.timeout.unwrap_or(0));
let detail = skill_fail_detail(&why, skill.harness, Some(&run_dir_str), Some(&log));
spinner.finish_fail(failure::reason::CONTAINER_TIMEOUT, Some(&detail));
return SkillRun::failed(failure::reason::CONTAINER_TIMEOUT, Some(run_dir_str), Some(log), clone_dir)
.with_fail_detail(&why);
}
Ok((false, ui::screen::Killed::Inactive, _)) => {
schedule_run_dir_prune_backup(daemon_client.as_ref(), &run_dir_str, &name, &rt.name, false);
let why = format!("no new screen content for {inactivity_secs}s (inactivity_timeout)");
let detail = skill_fail_detail(&why, skill.harness, Some(&run_dir_str), Some(&log));
spinner.finish_fail(failure::reason::CONTAINER_INACTIVE, Some(&detail));
return SkillRun::failed(failure::reason::CONTAINER_INACTIVE, Some(run_dir_str), Some(log), clone_dir)
.with_fail_detail(&why);
}
Ok((false, ui::screen::Killed::No, last)) => {
schedule_run_dir_prune_backup(daemon_client.as_ref(), &run_dir_str, &name, &rt.name, false);
let tail = spinner.tail_lines(failure::FAILURE_TAIL_LINES);
let why = failure::failure_excerpt(last.as_deref(), &tail, "harness exited non-zero (no output captured)");
let sample = format!("{why}\n{}", cast_tail_text(&run_dir.join(runtime::RUN_CAST_REL)));
let detail = skill_fail_detail(&why, skill.harness, Some(&run_dir_str), Some(&log));
let reason = if failure::harness_reported_overload(&sample) {
failure::reason::HARNESS_OVERLOADED
} else if failure::harness_reported_disconnect(&sample) {
failure::reason::HARNESS_DISCONNECTED
} else if failure::harness_reported_auth_rejection(&sample) {
failure::reason::HARNESS_AUTH_REJECTED
} else {
failure::reason::HARNESS_NONZERO
};
spinner.finish_fail(reason, Some(&detail));
return SkillRun::failed(reason, Some(run_dir_str), Some(log), clone_dir).with_fail_detail(&why);
}
Err(e) => {
schedule_run_dir_prune_backup(daemon_client.as_ref(), &run_dir_str, &name, &rt.name, false);
let why = format!("could not run container: {e}");
let detail = skill_fail_detail(&why, skill.harness, Some(&run_dir_str), Some(&log));
spinner.finish_fail(failure::reason::CONTAINER_RUN, Some(&detail));
return SkillRun::failed(failure::reason::CONTAINER_RUN, Some(run_dir_str), Some(log), clone_dir)
.with_fail_detail(&why);
}
}
for artifact in &skill.artifacts {
if let Err(e) = collect_skill_result(root, &run_dir, artifact, secs) {
schedule_run_dir_prune_backup(daemon_client.as_ref(), &run_dir_str, &name, &rt.name, false);
let why = format!("declared artifact: {e}");
let detail = skill_fail_detail(&why, skill.harness, Some(&run_dir_str), Some(&log));
spinner.finish_fail(failure::reason::RESULT_MISSING, Some(&detail));
return SkillRun::failed(failure::reason::RESULT_MISSING, Some(run_dir_str), Some(log), clone_dir)
.with_fail_detail(&why);
}
}
match collect_skill_result(root, &run_dir, &skill.result, secs) {
Ok(dest) => {
let content = match std::fs::read_to_string(&dest) {
Ok(content) => content,
Err(error) => {
schedule_run_dir_prune_backup(daemon_client.as_ref(), &run_dir_str, &name, &rt.name, false);
let why = format!("could not read collected result '{}': {error}", skill.result);
let detail = skill_fail_detail(&why, skill.harness, Some(&run_dir_str), Some(&log));
spinner.finish_fail(failure::reason::RESULT_MISSING, Some(&detail));
return SkillRun::failed(failure::reason::RESULT_MISSING, Some(run_dir_str), Some(log), clone_dir)
.with_fail_detail(&why);
}
};
let workflow_outputs = match result_contract.map(|contract| extract_step_outputs(&content, contract)) {
Some(Err(error)) => {
if let Some(path) = fleet::persist_skill_result(session_id, &skill.name, Path::new(&dest)) {
if let Some(c) = &daemon_client {
c.proc_result(spinner.index(), &path);
}
}
spinner.note("result schema invalid; preparing one correction retry…");
return SkillRun::invalid_result(error, run_dir_str, log, clone_dir, content);
}
Some(Ok(outputs)) => Some(outputs),
None => None,
};
if let Some(key) = &key {
let commits =
if skill.commits { base.and_then(|b| commit_patch(&runtime::commits_fetch_path(&run_dir), b)) } else { None };
cache_store(
root,
key,
&content,
commits.as_deref(),
Some(run_started.elapsed().as_secs_f64()),
durable_cast.as_deref().map(Path::new),
);
}
let message = json::message(&content);
let headline = message.as_deref().map(first_line).unwrap_or(skill.result.as_str());
if let Some(path) = fleet::persist_skill_result(session_id, &skill.name, Path::new(&dest)) {
if let Some(c) = &daemon_client {
c.proc_result(spinner.index(), &path);
}
}
if let Some(reason) = graceful_shutdown_reason {
let detail = format!("{headline}\n\nGraceful shutdown: {reason}.");
spinner.finish_graceful(Some(&detail));
} else {
spinner.finish_ok(Some(headline));
}
schedule_run_dir_prune_backup(daemon_client.as_ref(), &run_dir_str, &name, &rt.name, true);
if graceful_shutdown_reason.is_some() {
SkillRun::graceful(log, clone_dir, Some(content), workflow_outputs)
} else {
SkillRun::ok(log, clone_dir, Some(content), workflow_outputs)
}
}
Err(e) => {
schedule_run_dir_prune_backup(daemon_client.as_ref(), &run_dir_str, &name, &rt.name, false);
let detail = skill_fail_detail(&e, skill.harness, Some(&run_dir_str), Some(&log));
spinner.finish_fail(failure::reason::RESULT_MISSING, Some(&detail));
SkillRun::failed(failure::reason::RESULT_MISSING, Some(run_dir_str), Some(log), clone_dir).with_fail_detail(&e)
}
}
}
fn persist_run_artifacts(session_id: &str, run_dir: &Path, skill_name: &str, epoch_secs: u64) -> Option<String> {
let stem = format!("{skill_name}-{}-utc-{}", runtime::format_utc_timestamp(epoch_secs), runtime::random_nonce_6());
let logs_dir = runtime::session_logs_dir(session_id);
if std::fs::create_dir_all(&logs_dir).is_ok() {
for (rel, ext) in [
(runtime::RUN_LOG_REL.to_string(), "log"),
(format!("{}.debug", runtime::RUN_LOG_REL), "debug.log"),
(format!("{}.last", runtime::RUN_LOG_REL), "last.log"),
(format!("{}.exit", runtime::RUN_LOG_REL), "exit"),
(format!("{}.tuidebug", runtime::RUN_LOG_REL), "tuidebug"),
] {
let src = run_dir.join(&rel);
if src.is_file() {
let _ = std::fs::copy(&src, logs_dir.join(format!("{stem}.{ext}")));
}
}
}
let cast_src = run_dir.join(runtime::RUN_CAST_REL);
if !cast_src.is_file() {
return None;
}
let casts_dir = runtime::session_casts_dir(session_id);
std::fs::create_dir_all(&casts_dir).ok()?;
let dest = casts_dir.join(format!("{stem}.cast"));
std::fs::copy(&cast_src, &dest).ok()?;
Some(dest.to_string_lossy().into_owned())
}
fn restore_cached_result(root: &Path, result_rel: &str, content: &str) -> std::io::Result<()> {
let dest = root.join(result_rel);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(dest, content)
}
fn first_line(s: &str) -> &str {
s.lines().next().unwrap_or(s)
}
fn plural(n: usize) -> &'static str {
if n == 1 {
""
} else {
"s"
}
}
const STALE_RUN_DIR_SECS: u64 = 24 * 60 * 60;
fn sweep_stale_run_dirs(now: u64) -> usize {
sweep_stale_run_dirs_in(Path::new("/tmp"), now, STALE_RUN_DIR_SECS)
}
fn sweep_stale_run_dirs_in(dir: &Path, now: u64, max_age: u64) -> usize {
let mut removed = 0;
let Ok(entries) = std::fs::read_dir(dir) else {
return 0;
};
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if !runtime::is_scsh_run_dir_name(&name) {
continue;
}
let path = entry.path();
if !path.is_dir() {
continue;
}
let stale = std::fs::metadata(&path)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| now.saturating_sub(d.as_secs()) >= max_age)
.unwrap_or(false);
if stale && std::fs::remove_dir_all(&path).is_ok() {
removed += 1;
}
}
removed
}
fn prepare_run_dir(secs: u64, skill: &str, runtime: &str) -> Result<PathBuf, String> {
if runtime == "container" {
for _ in 1..=100 {
let base = runtime::run_dir_name(secs, skill, runtime);
let dir = PathBuf::from("/tmp").join(&base);
match std::fs::create_dir(&dir) {
Ok(()) => return Ok(dir),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(format!("could not create run dir {}: {e}", dir.display())),
}
}
return Err("could not create a unique run dir under /tmp".into());
}
let base = runtime::run_dir_name(secs, skill, runtime);
for n in 1..=100 {
let dir = PathBuf::from("/tmp").join(if n == 1 { base.clone() } else { format!("{base}-{n}") });
match std::fs::create_dir(&dir) {
Ok(()) => return Ok(dir),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(format!("could not create run dir {}: {e}", dir.display())),
}
}
Err("could not create a unique run dir under /tmp".into())
}
fn clone_into(
root: &Path, run_dir: &Path, source_revision: Option<&str>, spinner: &ui::screen::Proc,
) -> Result<(), String> {
spinner.note("cloning…");
let cmd = runtime::clone_command(&root.to_string_lossy(), &run_dir.to_string_lossy());
let (ok, last) = spinner.run(&cmd[0], &cmd[1..]).map_err(|e| format!("failed to run git clone: {e}"))?;
if !ok {
return Err(match last {
Some(l) if !l.is_empty() => format!("git clone failed: {l}"),
_ => "git clone failed".to_string(),
});
}
materialize_branches(run_dir);
if let Some(revision) = source_revision {
checkout_workflow_revision(run_dir, revision)?;
}
set_clone_identity(run_dir);
spinner.note("checking clone integrity…");
let fsck = runtime::fsck_command(&run_dir.to_string_lossy());
spinner.emit("git fsck --no-progress…");
let fsck_started = Instant::now();
let (ok, last) = spinner.run(&fsck[0], &fsck[1..]).map_err(|e| format!("failed to run git fsck: {e}"))?;
let fsck_secs = fsck_started.elapsed().as_secs_f64();
spinner.emit(&format!("git fsck {} ({})", if ok { "ok" } else { "failed" }, ui::clock::format_elapsed(fsck_secs),));
if !ok {
return Err(match last {
Some(l) if !l.is_empty() => format!("git fsck failed on run clone: {l}"),
_ => "git fsck failed on run clone".to_string(),
});
}
Ok(())
}
fn materialize_skill_body(run_dir: &Path, _git_transport: bool, skill: &ResolvedInvocation) -> Result<(), String> {
let write = |rel: &str, body: &str| -> Result<(), String> {
let path = run_dir.join(rel);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("could not create {}: {e}", parent.display()))?;
}
std::fs::write(&path, body).map_err(|e| format!("could not write {}: {e}", path.display()))
};
match &skill.delivery {
config::SkillDelivery::Repo | config::SkillDelivery::DirectPrompt(_) => Ok(()),
config::SkillDelivery::GlobalInstall(body) => {
write(&format!("{}/{}/SKILL.md", skill.harness.global_skills_rel(), skill.skill_source), body)
}
}
}
fn prepare_git_transport(
root: &Path, run_dir: &Path, commits: bool, source_revision: Option<&str>, spinner: &ui::screen::Proc,
) -> Result<(), String> {
std::fs::create_dir_all(run_dir.join("tmp"))
.map_err(|e| format!("could not create {}: {e}", run_dir.join("tmp").display()))?;
spinner.note("pushing…");
let bare = run_dir.join(runtime::TRANSPORT_BARE);
runtime::push_transport_refs(root, &bare).map_err(|e| {
spinner.emit(&format!("git push failed: {e}"));
e
})?;
if let Some(revision) = source_revision {
select_transport_workflow_revision(&bare, revision)?;
}
if commits {
let pull = run_dir.join(runtime::PULL_BARE);
runtime::init_bare_repo(&pull)?;
if source_revision.is_some() && !git_status_ok(&pull, &["symbolic-ref", "HEAD", "refs/heads/scsh-workflow"]) {
return Err(format!("could not select the workflow branch in {}", pull.display()));
}
}
Ok(())
}
fn checkout_workflow_revision(repo: &Path, revision: &str) -> Result<(), String> {
if git_status_ok(repo, &["checkout", "--quiet", "-B", "scsh-workflow", revision]) {
Ok(())
} else {
Err(format!("could not check out workflow revision {revision}"))
}
}
fn select_transport_workflow_revision(bare: &Path, revision: &str) -> Result<(), String> {
if !git_status_ok(bare, &["update-ref", "refs/heads/scsh-workflow", revision]) {
return Err(format!("workflow revision {revision} is not available in {}", bare.display()));
}
if !git_status_ok(bare, &["symbolic-ref", "HEAD", "refs/heads/scsh-workflow"]) {
return Err(format!("could not select the workflow revision in {}", bare.display()));
}
Ok(())
}
struct GitTransport {
pid: u32,
pid_file: PathBuf,
port: u16,
}
impl GitTransport {
fn start(run_dir: &Path) -> Result<Self, String> {
let port = runtime::pick_ephemeral_port()?;
let base = run_dir.to_string_lossy();
let pid_file = run_dir.join("scsh-git-daemon.pid");
let status = Command::new("git")
.args([
"daemon",
"--detach",
"--reuseaddr",
&format!("--base-path={base}"),
"--export-all",
"--enable=receive-pack",
&format!("--port={port}"),
"--listen=0.0.0.0",
"--log-destination=none",
&format!("--pid-file={}", pid_file.to_string_lossy()),
])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map_err(|e| format!("could not start git daemon: {e}"))?;
if !status.success() {
return Err(format!("git daemon exited with {status}"));
}
let deadline = Instant::now() + Duration::from_secs(2);
let pid = loop {
if let Ok(pid) = std::fs::read_to_string(&pid_file).as_deref().map(str::trim).unwrap_or("").parse::<u32>() {
break pid;
}
if Instant::now() >= deadline {
return Err(format!("git daemon did not publish a valid PID to {} within 2s", pid_file.display()));
}
std::thread::sleep(Duration::from_millis(20));
};
Ok(Self { pid, pid_file, port })
}
fn env(&self) -> Vec<(String, String)> {
vec![(runtime::GIT_TRANSPORT_PORT_ENV.to_string(), self.port.to_string())]
}
}
impl Drop for GitTransport {
fn drop(&mut self) {
let _ =
Command::new("kill").arg("-TERM").arg(self.pid.to_string()).stdout(Stdio::null()).stderr(Stdio::null()).status();
let _ = std::fs::remove_file(&self.pid_file);
}
}
pub(crate) const SCSH_COMMIT_NAME: &str = "dkorolev-neon-elon-bot";
pub(crate) const SCSH_COMMIT_EMAIL: &str = "dmitry.korolev+elon-presley@gmail.com";
fn set_clone_identity(run_dir: &Path) {
let _ = git_capture(run_dir, &["config", "user.email", SCSH_COMMIT_EMAIL]);
let _ = git_capture(run_dir, &["config", "user.name", SCSH_COMMIT_NAME]);
}
fn materialize_branches(run_dir: &std::path::Path) {
let current = git_capture(run_dir, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap_or_default();
let refs = match git_capture(run_dir, &["for-each-ref", "--format=%(refname:short)", "refs/remotes/origin"]) {
Some(r) => r,
None => return,
};
for b in runtime::local_branches_to_create(&refs, current.trim()) {
let _ = Command::new("git")
.arg("-C")
.arg(run_dir)
.args(["branch", "--force", &b, &format!("origin/{b}")])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
}
fn claude_auth_enabled() -> bool {
!matches!(std::env::var("SCSH_NO_CLAUDE_AUTH").ok().as_deref(), Some("1") | Some("true"))
}
fn opencode_auth_enabled() -> bool {
!matches!(std::env::var("SCSH_NO_OPENCODE_AUTH").ok().as_deref(), Some("1") | Some("true"))
}
fn codex_auth_enabled() -> bool {
!matches!(std::env::var("SCSH_NO_CODEX_AUTH").ok().as_deref(), Some("1") | Some("true"))
}
fn grok_auth_enabled() -> bool {
!matches!(std::env::var("SCSH_NO_GROK_AUTH").ok().as_deref(), Some("1") | Some("true"))
}
fn cursor_auth_enabled() -> bool {
!matches!(std::env::var("SCSH_NO_CURSOR_AUTH").ok().as_deref(), Some("1") | Some("true"))
}
fn keep_run_dirs() -> bool {
matches!(std::env::var("SCSH_KEEP_RUNS").ok().as_deref(), Some("1") | Some("true"))
}
fn schedule_run_dir_prune_backup(
daemon_client: Option<&std::sync::Arc<daemon::Client>>, run_dir: &str, container_name: &str, runtime: &str,
outcome_ok: bool,
) {
if keep_run_dirs() {
return;
}
if let Some(c) = daemon_client {
c.schedule_run_dir_prune(run_dir, container_name, runtime, outcome_ok);
}
}
fn forward_opencode(run_dir: &Path) -> Option<PathBuf> {
let home = std::env::var_os("HOME").map(PathBuf::from)?;
let xdg_data = std::env::var_os("XDG_DATA_HOME");
let xdg_config = std::env::var_os("XDG_CONFIG_HOME");
let auth_src = runtime::opencode_auth_in(xdg_data.as_deref(), Some(home.as_os_str())).filter(|p| p.is_file())?;
let data_dir = run_dir.join(runtime::OPENCODE_DATA_REL); let cfg_dir = run_dir.join(runtime::OPENCODE_CONFIG_REL); std::fs::create_dir_all(&data_dir).ok()?;
std::fs::create_dir_all(&cfg_dir).ok()?;
std::fs::copy(&auth_src, data_dir.join("auth.json")).ok()?;
if let Some(cfg) = runtime::opencode_config_json_in(xdg_config.as_deref(), Some(home.as_os_str())) {
let _ = std::fs::copy(&cfg, cfg_dir.join("opencode.json"));
}
if let Some(cfg) = runtime::opencode_config_jsonc_in(xdg_config.as_deref(), Some(home.as_os_str())) {
let _ = std::fs::copy(&cfg, cfg_dir.join("opencode.jsonc"));
}
Some(data_dir)
}
fn forward_claude_auth(run_dir: &Path) -> Option<PathBuf> {
let home = std::env::var_os("HOME").map(PathBuf::from);
let token = runtime::claude_oauth_token();
let keychain_creds = runtime::claude_keychain_credentials_json();
let host_json = home.as_ref().map(|h| h.join(".claude.json")).filter(|p| p.is_file());
let host_creds = home.as_ref().map(|h| h.join(".claude").join(".credentials.json")).filter(|p| p.is_file());
if token.is_none() && keychain_creds.is_none() && host_creds.is_none() && host_json.is_none() {
return None;
}
let root = run_dir.join(runtime::CLAUDE_AUTH_REL);
let claude_dir = root.join(".claude");
std::fs::create_dir_all(&claude_dir).ok()?;
let creds_dest = claude_dir.join(".credentials.json");
let json_dest = claude_dir.join(".claude.json");
if let Some(src) = &host_creds {
let _ = std::fs::copy(src, &creds_dest);
} else if let Some(blob) = &keychain_creds {
let _ = std::fs::write(&creds_dest, blob);
} else if let Some(t) = &token {
let _ = write_claude_credentials_file(&claude_dir, t);
}
if let Some(src) = &host_json {
write_claude_identity(src, &json_dest);
}
seed_claude_tui_config(&json_dest);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
for f in [&json_dest, &creds_dest] {
if let Ok(p) = f.canonicalize() {
let _ = std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600));
}
}
}
Some(root)
}
fn write_claude_identity(host_json: &Path, dest: &Path) {
use crate::json::Value;
let Some(Value::Object(host)) = std::fs::read_to_string(host_json).ok().and_then(|t| json::parse(&t).ok()) else {
return;
};
let mut identity: Vec<(String, Value)> = Vec::new();
for key in ["oauthAccount", "userID"] {
if let Some((_, v)) = host.iter().find(|(k, _)| k == key) {
identity.push((key.to_string(), v.clone()));
}
}
let _ = std::fs::write(dest, json::write(&Value::Object(identity)));
}
fn seed_claude_tui_config(json_path: &Path) {
use crate::json::Value;
fn set(obj: &mut Vec<(String, Value)>, key: &str, val: Value) {
if let Some(slot) = obj.iter_mut().find(|(k, _)| k == key) {
slot.1 = val;
} else {
obj.push((key.to_string(), val));
}
}
let mut root = match std::fs::read_to_string(json_path).ok().and_then(|t| json::parse(&t).ok()) {
Some(Value::Object(o)) => o,
_ => Vec::new(),
};
set(&mut root, "autoUpdates", Value::Bool(false));
set(&mut root, "hasCompletedOnboarding", Value::Bool(true));
set(&mut root, "bypassPermissionsModeAccepted", Value::Bool(true));
let repo_project = Value::Object(vec![
("hasTrustDialogAccepted".to_string(), Value::Bool(true)),
("hasCompletedProjectOnboarding".to_string(), Value::Bool(true)),
("bypassPermissionsModeAccepted".to_string(), Value::Bool(true)),
]);
let merged_into_existing = match root.iter_mut().find(|(k, _)| k == "projects") {
Some((_, Value::Object(projects))) => {
set(projects, runtime::AGENT_REPO, repo_project.clone());
true
}
_ => false,
};
if !merged_into_existing {
set(&mut root, "projects", Value::Object(vec![(runtime::AGENT_REPO.to_string(), repo_project)]));
}
let _ = std::fs::write(json_path, json::write(&Value::Object(root)));
}
fn forward_codex(run_dir: &Path) -> Option<PathBuf> {
let host_home = runtime::codex_home_on_host()?;
let auth = host_home.join("auth.json");
let config = host_home.join("config.toml");
if !auth.is_file() && !config.is_file() {
return None;
}
let dest = run_dir.join(runtime::CODEX_FORWARD_REL);
std::fs::create_dir_all(&dest).ok()?;
for name in ["auth.json", "config.toml"] {
let src = host_home.join(name);
if src.is_file() {
std::fs::copy(&src, dest.join(name)).ok()?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(dest.join(name), std::fs::Permissions::from_mode(0o600));
}
}
}
{
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(dest.join("config.toml")) {
let _ = write!(f, "\n[projects.\"{}\"]\ntrust_level = \"trusted\"\n", runtime::AGENT_REPO);
}
}
Some(dest)
}
fn scrub_codex_credentials(codex_dir: &Path) {
for name in ["auth.json", "config.toml"] {
let _ = std::fs::remove_file(codex_dir.join(name));
}
}
fn forward_grok(run_dir: &Path) -> Option<PathBuf> {
let host_home = runtime::grok_home_on_host()?;
let auth = host_home.join("auth.json");
if !auth.is_file() {
return None;
}
let dest = run_dir.join(runtime::GROK_FORWARD_REL);
std::fs::create_dir_all(&dest).ok()?;
for name in ["auth.json", "agent_id", "active_sessions.json", "config.toml", "user-settings.json"] {
let src = host_home.join(name);
if src.is_file() {
std::fs::copy(&src, dest.join(name)).ok()?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(dest.join(name), std::fs::Permissions::from_mode(0o600));
}
}
}
Some(dest)
}
fn scrub_grok_credentials(grok_dir: &Path) {
for name in ["auth.json", "config.toml", "user-settings.json"] {
let _ = std::fs::remove_file(grok_dir.join(name));
}
}
fn forward_cursor(run_dir: &Path) -> bool {
let mut forwarded = false;
if let Some(host_home) = runtime::cursor_home_on_host() {
let dest = run_dir.join(runtime::CURSOR_FORWARD_REL);
let mut any = false;
for name in ["cli-config.json", "mcp.json"] {
let src = host_home.join(name);
if src.is_file() && std::fs::create_dir_all(&dest).is_ok() && std::fs::copy(&src, dest.join(name)).is_ok() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(dest.join(name), std::fs::Permissions::from_mode(0o600));
}
any = true;
}
}
forwarded |= any;
}
let auth_dest = run_dir.join(runtime::CURSOR_AUTH_FORWARD_REL);
if let Some(src) = runtime::cursor_auth_file_on_host() {
if std::fs::create_dir_all(&auth_dest).is_ok() && std::fs::copy(&src, auth_dest.join("auth.json")).is_ok() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(auth_dest.join("auth.json"), std::fs::Permissions::from_mode(0o600));
}
forwarded = true;
}
} else if let Some(access) = runtime::cursor_keychain_access_token() {
let refresh = runtime::cursor_keychain_refresh_token().unwrap_or_else(|| access.clone());
if std::fs::create_dir_all(&auth_dest).is_ok() {
let body = format!(r#"{{"accessToken":{},"refreshToken":{}}}"#, json::quote(&access), json::quote(&refresh));
if std::fs::write(auth_dest.join("auth.json"), body).is_ok() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(auth_dest.join("auth.json"), std::fs::Permissions::from_mode(0o600));
}
forwarded = true;
}
}
}
forwarded
}
fn scrub_cursor_credentials(run_dir: &Path) {
for name in ["cli-config.json", "mcp.json"] {
let _ = std::fs::remove_file(run_dir.join(runtime::CURSOR_FORWARD_REL).join(name));
}
let _ = std::fs::remove_file(run_dir.join(runtime::CURSOR_AUTH_FORWARD_REL).join("auth.json"));
}
fn write_claude_credentials_file(claude_dir: &Path, token: &str) -> Option<()> {
let path = claude_dir.join(".credentials.json");
let body = format!("{{\"claudeAiOauth\":{{\"accessToken\":{}}}}}", json::quote(token));
std::fs::write(&path, body).ok()?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
}
Some(())
}
fn resolve_env(env: &[config::EnvVar]) -> Result<Vec<(String, String)>, String> {
use config::EnvRule;
let mut out = Vec::new();
for var in env {
match &var.rule {
EnvRule::Default { src, default } => {
let value = std::env::var(src).unwrap_or_else(|_| default.clone());
out.push((var.key.clone(), value));
}
EnvRule::Require { src, message } => match std::env::var(src) {
Ok(v) => out.push((var.key.clone(), v)),
Err(_) => {
return Err(if message.is_empty() { format!("{src} is required but not set") } else { message.clone() });
}
},
EnvRule::Constant(val) => out.push((var.key.clone(), val.clone())),
}
}
Ok(out)
}
fn collect_skill_result(root: &Path, run_dir: &Path, result: &str, secs: u64) -> Result<String, String> {
let produced = run_dir.join(result);
if !produced.is_file() {
let ctx = failure::missing_result_context(run_dir, result);
return Err(format!("did not produce its result file '{result}'{ctx}"));
}
let dest = root.join(result);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("could not create {}: {e}", parent.display()))?;
}
if dest.exists() {
let name = dest.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let backup = dest.with_file_name(runtime::backup_name(&name, secs));
std::fs::rename(&dest, &backup).map_err(|e| format!("could not back up existing {}: {e}", dest.display()))?;
}
std::fs::copy(&produced, &dest).map_err(|e| format!("could not copy result to {}: {e}", dest.display()))?;
Ok(dest.to_string_lossy().into_owned())
}
fn git_capture(dir: &std::path::Path, args: &[&str]) -> Option<String> {
let out = Command::new("git").arg("-C").arg(dir).args(args).output().ok()?;
out.status.success().then(|| String::from_utf8_lossy(&out.stdout).into_owned())
}
fn git_status_ok(dir: &std::path::Path, args: &[&str]) -> bool {
Command::new("git").arg("-C").arg(dir).args(args).output().map(|o| o.status.success()).unwrap_or(false)
}
fn current_branch(root: &Path) -> String {
git_capture(root, &["rev-parse", "--abbrev-ref", "HEAD"])
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "HEAD".into())
}
enum Integration {
Applied { count: usize, range: Option<(String, String)> },
Saved { branch: String, count: usize, range: Option<(String, String)> },
}
fn integrate_commits(
root: &Path, run_dir: &Path, base: &str, skill: &str, stamp: &str,
) -> Result<Option<Integration>, String> {
let source = runtime::commits_fetch_path(run_dir);
let tip = match git_capture(&source, &["rev-parse", "HEAD"]) {
Some(t) => t.trim().to_string(),
None => return Err("could not read the clone's HEAD".into()),
};
if tip == base {
return Ok(None); }
let fetch_path = source.to_string_lossy();
if !git_status_ok(root, &["fetch", "--no-tags", "--quiet", &fetch_path, "HEAD"]) {
return Err("could not fetch the skill's commits from its clone".into());
}
let range = format!("{base}..{tip}");
let count =
git_capture(root, &["rev-list", "--count", &range]).and_then(|s| s.trim().parse::<usize>().ok()).unwrap_or(0);
if count == 0 {
return Ok(None);
}
let before = git_capture(root, &["rev-parse", "HEAD"]).map(|s| s.trim().to_string());
if git_status_ok(root, &["cherry-pick", "--keep-redundant-commits", &range]) {
let after = git_capture(root, &["rev-parse", "HEAD"]).map(|s| s.trim().to_string());
Ok(Some(Integration::Applied { count, range: before.zip(after) }))
} else {
let _ = git_status_ok(root, &["cherry-pick", "--abort"]);
let branch = incoming_branch_name(skill, stamp, &tip);
if !git_status_ok(root, &["branch", "--force", &branch, &tip]) {
return Err(format!("commits didn't rebase cleanly and the fallback branch '{branch}' could not be created"));
}
Ok(Some(Integration::Saved { branch, count, range: Some((base.to_string(), tip)) }))
}
}
fn pack_step_diff(
root: &Path, session_id: &str, skill: &ResolvedInvocation, outcome: &SkillRun, range: Option<(String, String)>,
daemon_client: Option<&daemon::Client>,
) {
let Some((from, to)) = range else {
return;
};
let dir = runtime::session_diffs_dir(session_id);
if std::fs::create_dir_all(&dir).is_err() {
return;
}
let out = dir.join(format!("{}-p{}.html", runtime::sanitize_component(&skill.name), outcome.proc_index));
let title = format!("scsh job {session_id} · {} commits", skill.name);
let result = Command::new("packdiff")
.arg(format!("{from}..{to}"))
.args(["-C", &root.to_string_lossy(), "-o", &out.to_string_lossy(), "--title", &title, "--json"])
.env("PACKDIFF_SYSTEM_USER_EMAIL", SCSH_COMMIT_EMAIL)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.output();
match result {
Ok(output) if output.status.success() && out.is_file() => {
ok(&format!("{}: commits diff packed — {}", skill.name, out.display()));
if let Some(c) = daemon_client {
c.proc_diff(outcome.proc_index, &out.to_string_lossy());
}
}
Ok(output) => {
let detail = packdiff_failure_detail(&output.stdout);
hint(&format!(
"{}: packdiff could not pack the commits diff (skipped{})",
skill.name,
detail.as_deref().map(|d| format!(": {d}")).unwrap_or_default()
));
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
hint(
"packdiff not found — `cargo install packdiff --version 0.4.5 --locked` to browse each step's commits from the job page",
);
}
Err(e) => hint(&format!("{}: packdiff failed to start — {e}", skill.name)),
}
}
fn pack_job_diff(root: &Path, session_id: &str, from: &str, to: &str) {
if from == to {
return;
}
let dir = runtime::session_diffs_dir(session_id);
if std::fs::create_dir_all(&dir).is_err() {
return;
}
let out = dir.join("job.html");
let next = dir.join("job.next.html");
let title = format!("scsh job {session_id} · all commits");
let result = Command::new("packdiff")
.arg(format!("{from}..{to}"))
.args(["-C", &root.to_string_lossy(), "-o", &next.to_string_lossy(), "--title", &title, "--json"])
.env("PACKDIFF_SYSTEM_USER_EMAIL", SCSH_COMMIT_EMAIL)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.output();
if result.is_ok_and(|output| output.status.success() && next.is_file()) {
if std::fs::rename(&next, &out).is_ok() {
ok(&format!("whole-job commits diff packed — {}", out.display()));
}
} else {
let _ = std::fs::remove_file(next);
}
}
fn packdiff_failure_detail(stdout: &[u8]) -> Option<String> {
let text = std::str::from_utf8(stdout).ok()?.trim();
if text.is_empty() {
return None;
}
if let Some(i) = text.find("\"message\"") {
let after = &text[i + "\"message\"".len()..];
if let Some(rest) = after.trim_start().strip_prefix(':') {
let rest = rest.trim_start();
if let Some(s) = rest.strip_prefix('"') {
let mut out = String::new();
let mut chars = s.chars();
while let Some(c) = chars.next() {
match c {
'\\' => match chars.next() {
Some('n') => out.push('\n'),
Some('t') => out.push('\t'),
Some('"') => out.push('"'),
Some('\\') => out.push('\\'),
Some(o) => {
out.push('\\');
out.push(o);
}
None => break,
},
'"' => break,
other => out.push(other),
}
}
if !out.is_empty() {
return Some(out);
}
}
}
}
let trimmed = text.trim_start().trim_start_matches('{').trim_start();
if let Some(key) = trimmed.strip_prefix('"') {
let name: String = key.chars().take_while(|c| *c != '"').collect();
if !name.is_empty() && name != "Packed" {
return Some(name);
}
}
None
}
fn incoming_branch_name(skill: &str, stamp: &str, tip: &str) -> String {
let short: String = tip.chars().take(7).collect();
format!("scsh/incoming/{}-{}-utc-{}", runtime::sanitize_component(skill), stamp, short)
}
fn cache_dir(root: &Path) -> PathBuf {
root.join(scratch_root(root).unwrap_or("tmp")).join(".sccache")
}
#[cfg(test)]
fn cache_key(root: &Path, skill: &ResolvedInvocation, env: &[(String, String)]) -> Option<String> {
cache_key_at(root, skill, env, None)
}
fn cache_key_at(
root: &Path, skill: &ResolvedInvocation, env: &[(String, String)], source_revision: Option<&str>,
) -> Option<String> {
if !skill.artifacts.is_empty() {
return None;
}
let treeish = source_revision.map(|r| format!("{r}^{{tree}}")).unwrap_or_else(|| "HEAD^{tree}".to_string());
let tree = git_capture(root, &["rev-parse", &treeish])?.trim().to_string();
let mut blob = String::new();
blob.push_str("scsh-cache v2\n");
blob.push_str(&format!("repo-tree={tree}\n"));
blob.push_str(&format!("invocation={}\n", skill.name));
blob.push_str(&format!("skill={}\n", skill.skill_source));
blob.push_str(&format!("harness={}\n", skill.harness.as_str()));
blob.push_str(&format!("model={}\n", skill.model.as_deref().unwrap_or("")));
blob.push_str(&format!("effort={}\n", skill.effort.as_deref().unwrap_or("")));
blob.push_str("skill-files:\n");
for (rel, hash) in skill_file_hashes(root, &skill.skill_source) {
blob.push_str(&format!("{rel} {hash}\n"));
}
if let Some(body) = skill.delivery.body() {
blob.push_str(&format!("body={}\n", sha256::sha256_hex(body.as_bytes())));
}
blob.push_str("env:\n");
let mut pairs: Vec<&(String, String)> = env.iter().filter(|(k, _)| k != "SCSH_RESULT").collect();
pairs.sort_by(|a, b| a.0.cmp(&b.0));
for (k, v) in pairs {
blob.push_str(&format!("{k}={v}\n"));
}
Some(sha256::sha256_hex(blob.as_bytes()))
}
fn skill_file_hashes(root: &Path, name: &str) -> Vec<(String, String)> {
let dir = root.join(".skills").join(name);
let mut found: Vec<(String, PathBuf)> = Vec::new();
collect_files(&dir, &dir, &mut found);
found.sort();
found.into_iter().map(|(rel, abs)| (rel, sha256::sha256_hex(&std::fs::read(&abs).unwrap_or_default()))).collect()
}
fn collect_files(base: &Path, dir: &Path, out: &mut Vec<(String, PathBuf)>) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_files(base, &path, out);
} else if path.is_file() {
if let Ok(rel) = path.strip_prefix(base) {
out.push((rel.to_string_lossy().replace('\\', "/"), path));
}
}
}
}
struct CacheEntry {
result: String,
commits: Option<String>,
elapsed: Option<f64>,
cached_at: Option<u64>,
cast: Option<PathBuf>,
chapters: Option<PathBuf>,
}
fn cache_lookup(root: &Path, key: &str) -> Option<CacheEntry> {
let dir = cache_dir(root);
let json_path = dir.join(format!("{key}.json"));
let text = std::fs::read_to_string(&json_path).ok()?;
let cast = dir.join(format!("{key}.cast"));
let chapters = dir.join(format!("{key}.chapters.json"));
let cached_at = json::field(&text, "cached_at").and_then(|s| s.trim().parse::<u64>().ok()).or_else(|| {
std::fs::metadata(&json_path)
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
});
Some(CacheEntry {
result: json::field(&text, "result")?,
commits: json::field(&text, "commits"),
elapsed: json::field(&text, "elapsed").and_then(|s| s.trim().parse::<f64>().ok()),
cached_at,
cast: cast.is_file().then_some(cast),
chapters: chapters.is_file().then_some(chapters),
})
}
fn cache_store(
root: &Path, key: &str, content: &str, commits: Option<&str>, elapsed: Option<f64>, cast_src: Option<&Path>,
) {
let dir = cache_dir(root);
if std::fs::create_dir_all(&dir).is_err() {
return;
}
let mut fields = vec![format!("\"result\": {}", json::quote(content))];
if let Some(patch) = commits {
fields.push(format!("\"commits\": {}", json::quote(patch)));
}
if let Some(e) = elapsed {
fields.push(format!("\"elapsed\": {}", json::quote(&format!("{e}"))));
}
fields.push(format!("\"cached_at\": {}", json::quote(&format!("{}", now_secs()))));
let _ = std::fs::write(dir.join(format!("{key}.json")), format!("{{{}}}\n", fields.join(", ")));
if let Some(src) = cast_src.filter(|p| p.is_file()) {
let _ = std::fs::copy(src, dir.join(format!("{key}.cast")));
let _ = std::fs::write(PathBuf::from(format!("{}.sccache-key", src.display())), key);
if let Some(chapters) = daemon::chapters_sidecar_path(&src.to_string_lossy()) {
if chapters.is_file() {
let _ = std::fs::copy(&chapters, dir.join(format!("{key}.chapters.json")));
}
}
}
}
fn cache_attach_chapters(root: &Path, cast: &Path) {
let Some(chapters) = daemon::chapters_sidecar_path(&cast.to_string_lossy()) else {
return;
};
if !chapters.is_file() {
return;
}
let key_path = PathBuf::from(format!("{}.sccache-key", cast.display()));
let Ok(key) = std::fs::read_to_string(&key_path) else {
return;
};
let key = key.trim();
if key.is_empty() {
return;
}
let dest = cache_dir(root).join(format!("{key}.chapters.json"));
let _ = std::fs::copy(&chapters, dest);
}
fn format_cached_at(epoch_secs: u64) -> String {
let raw = runtime::format_utc_timestamp(epoch_secs); if raw.len() >= 15 {
format!("{}-{}-{} {}:{} UTC", &raw[0..4], &raw[4..6], &raw[6..8], &raw[9..11], &raw[11..13])
} else {
raw
}
}
fn cache_hit_provenance(cached_at: Option<u64>, source_elapsed: Option<f64>) -> String {
let mut parts = vec!["cached".to_string()];
if let Some(at) = cached_at {
parts.push(format_cached_at(at));
}
if let Some(elapsed) = source_elapsed {
parts.push(format!("source run took {}", ui::clock::format_elapsed(elapsed)));
}
parts.join(" · ")
}
fn commit_patch(clone: &Path, base: &str) -> Option<String> {
let out = git_capture(clone, &["format-patch", &format!("{base}..HEAD"), "--stdout"])?;
if out.trim().is_empty() {
None
} else {
Some(out)
}
}
fn apply_cached_commits(root: &Path, patch: &str, skill: &str, stamp: &str) -> Result<Option<Integration>, String> {
let before = git_capture(root, &["rev-parse", "HEAD"]).map(|s| s.trim().to_string());
let staged = std::env::temp_dir().join(format!("scsh-replay-{}-{stamp}.patch", std::process::id()));
std::fs::write(&staged, patch).map_err(|_| "could not stage the cached commits".to_string())?;
let applied = git_status_ok(root, &["am", "--keep-cr", &staged.to_string_lossy()]);
let _ = std::fs::remove_file(&staged);
if applied {
let count = before
.as_deref()
.and_then(|b| git_capture(root, &["rev-list", "--count", &format!("{b}..HEAD")]))
.and_then(|s| s.trim().parse::<usize>().ok())
.unwrap_or(1);
let after = git_capture(root, &["rev-parse", "HEAD"]).map(|s| s.trim().to_string());
return Ok(Some(Integration::Applied { count, range: before.zip(after) }));
}
let _ = git_status_ok(root, &["am", "--abort"]);
let saved = cache_dir(root).join(format!("incoming-{}-{stamp}.patch", runtime::sanitize_component(skill)));
let _ = std::fs::create_dir_all(cache_dir(root));
let _ = std::fs::write(&saved, patch);
Err(format!(
"cached commits didn't apply cleanly — saved the patch to {} (apply with: git am <file>)",
saved.display()
))
}
fn now_secs() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
#[allow(clippy::too_many_arguments)]
fn run_build(
build: &ui::screen::Proc, runtime_name: &str, tag: &str, target: &str, dockerfile: &str, uid: u32, gid: u32,
fingerprint: &str, no_cache: bool, daemon_client: Option<&daemon::Client>, cast_stem: &str, session_id: &str,
) -> Result<(), (String, i32)> {
let tz = runtime::host_timezone();
let started = |e: std::io::Error| (format!("failed to start '{runtime_name}': {e}"), 1);
if runtime_name == "container" && runtime::apple_dockerfile_too_large(dockerfile) {
return Err((runtime::apple_dockerfile_too_large_message(dockerfile.len()), 1));
}
if runtime::asciinema_available() {
return run_build_tui(
build,
runtime_name,
tag,
target,
dockerfile,
uid,
gid,
&tz,
fingerprint,
no_cache,
daemon_client,
cast_stem,
session_id,
);
}
hint(
"asciinema not found on PATH — image builds fall back to a text log (install asciinema for the TUI cast player)",
);
let (ok, last) = match runtime::build_method(runtime_name) {
runtime::BuildMethod::Stdin => {
let cmd = runtime::build_command_stdin(runtime_name, tag, target, uid, gid, &tz, fingerprint, no_cache);
build.run_with_stdin(&cmd[0], &cmd[1..], dockerfile.as_bytes()).map_err(started)?
}
runtime::BuildMethod::ContextDir => {
let dir = make_temp_dir().map_err(|e| (format!("could not create build context: {e}"), 1))?;
let path = dir.join(runtime::CONTEXT_DOCKERFILE_NAME);
if let Err(e) = std::fs::write(&path, dockerfile) {
let _ = std::fs::remove_dir_all(&dir);
return Err((format!("could not write Dockerfile to build context: {e}"), 1));
}
let cmd = runtime::build_command_context(
runtime_name,
tag,
target,
&dir.to_string_lossy(),
uid,
gid,
&tz,
fingerprint,
no_cache,
);
let out = build.run(&cmd[0], &cmd[1..]).map_err(started);
let _ = std::fs::remove_dir_all(&dir);
out?
}
};
if ok {
Ok(())
} else {
Err(image_build_failure(runtime_name, target, tag, build, last.as_deref()))
}
}
fn image_build_failure(
runtime_name: &str, target: &str, tag: &str, build: &ui::screen::Proc, last: Option<&str>,
) -> (String, i32) {
let tail = build.tail_lines(failure::FAILURE_TAIL_LINES);
let excerpt = failure::failure_excerpt(last, &tail, "build produced no output");
let detail = if runtime_name == "container" {
runtime::rewrite_apple_build_failure(&excerpt).unwrap_or(excerpt)
} else {
excerpt
};
(format!("image build failed (runtime={runtime_name}, target={target}, tag={tag}): {detail}"), 1)
}
#[allow(clippy::too_many_arguments)]
fn run_build_tui(
build: &ui::screen::Proc, runtime_name: &str, tag: &str, target: &str, dockerfile: &str, uid: u32, gid: u32,
tz: &str, fingerprint: &str, no_cache: bool, daemon_client: Option<&daemon::Client>, cast_stem: &str,
session_id: &str,
) -> Result<(), (String, i32)> {
let casts_dir = runtime::session_casts_dir(session_id);
std::fs::create_dir_all(&casts_dir)
.map_err(|e| (format!("could not create cast dir {}: {e}", casts_dir.display()), 1))?;
let cast_path = casts_dir.join(format!(
"build-{cast_stem}-{}-utc-{}.cast",
runtime::format_utc_timestamp(now_secs()),
runtime::random_nonce_6()
));
let cast_path_str = cast_path.to_string_lossy().into_owned();
let dir = make_temp_dir().map_err(|e| (format!("could not create build context: {e}"), 1))?;
let df_path = dir.join(runtime::CONTEXT_DOCKERFILE_NAME);
if let Err(e) = std::fs::write(&df_path, dockerfile) {
let _ = std::fs::remove_dir_all(&dir);
return Err((format!("could not write Dockerfile to build context: {e}"), 1));
}
let build_argv = runtime::build_command_context(
runtime_name,
tag,
target,
&dir.to_string_lossy(),
uid,
gid,
tz,
fingerprint,
no_cache,
);
let build_shell = runtime::shell_join(&build_argv);
let term = config::Terminal::default();
let rec = runtime::asciinema_rec_argv(&cast_path_str, term.cols, term.rows, &build_shell);
if let Some(c) = daemon_client {
c.proc_cast(build.index(), &cast_path_str);
}
let started = |e: std::io::Error| {
let _ = std::fs::remove_dir_all(&dir);
(format!("failed to start asciinema: {e}"), 1)
};
let (ok, last) = build.run(&rec[0], &rec[1..]).map_err(started)?;
let _ = std::fs::remove_dir_all(&dir);
if let Some(c) = daemon_client {
c.proc_cast(build.index(), &cast_path_str);
}
if ok {
Ok(())
} else {
Err(image_build_failure(runtime_name, target, tag, build, last.as_deref()))
}
}
fn make_temp_dir() -> std::io::Result<PathBuf> {
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
let dir = std::env::temp_dir().join(format!("scsh-build-{}-{nanos}", std::process::id()));
std::fs::create_dir_all(&dir)?;
Ok(dir)
}
fn build_images_cmd(names: &[String], force: bool, rebuild_base: bool, session: Option<String>) -> i32 {
ui::signals::install();
let mut base_only = false;
let mut selected: Vec<config::Harness> = Vec::new();
if names.is_empty() {
selected.extend(config::Harness::ALL);
} else {
for n in names {
if n == "base" {
base_only = true;
continue;
}
match config::Harness::parse(n) {
Some(h) if selected.contains(&h) => {}
Some(h) => selected.push(h),
None => {
fail(&format!("unknown image '{n}' (known: base, {})", config::Harness::known().join(", ")));
return 2;
}
}
}
}
let rt = match runtime::detect_runtime() {
Some(rt) => rt,
None => {
fail("no container runtime found (docker, podman, or Apple `container`)");
return 1;
}
};
if !ui::engine::is_running(&rt.name) {
fail(&format!("{} is installed but not running", ui::engine::display_name(&rt.name)));
if let Some(cmd) = ui::engine::start_command(&rt.name, ui::Os::current()) {
hint(&format!("start it with: {}", bold(&cmd)));
}
return 1;
}
let session_id = session.filter(|s| !s.is_empty()).unwrap_or_else(daemon::new_session_id);
let mut daemon_session = DaemonSession { client: None, ping_active: None, registered: false };
match daemon::ensure_for_run() {
Ok(()) => {
let client = std::sync::Arc::new(daemon::Client::new(session_id.clone()));
if client.register_session("(image builds)", "", Some("build-images"), "build", &[]) {
ok(&format!("track progress at {}", client.session_url()));
let ping_active = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
let ping_flag = std::sync::Arc::clone(&ping_active);
let ping_client = std::sync::Arc::clone(&client);
std::thread::spawn(move || {
while ping_flag.load(std::sync::atomic::Ordering::Relaxed) {
ping_client.ping();
std::thread::sleep(Duration::from_secs(2));
}
});
daemon_session.client = Some(client);
daemon_session.ping_active = Some(ping_active);
daemon_session.registered = true;
}
}
Err(e) => hint(&format!("session browser daemon unavailable ({e}); continuing without live browser UI")),
}
let daemon_client = daemon_session.client.clone();
let ui = ui::screen::LiveUi::new(console::user_attended_stderr(), daemon_client.clone());
let df = runtime::dockerfile_for_runtime(&rt.name);
if rt.name == "container" && runtime::apple_dockerfile_too_large(&df) {
fail(&runtime::apple_dockerfile_too_large_message(df.len()));
return 1;
}
let (uid, gid) = runtime::host_ids();
let tz = runtime::host_timezone();
let rt_name = rt.name.clone();
let base_fp = runtime::base_image_fingerprint(&df, uid, gid, &tz);
let base_stale = !runtime::image_is_up_to_date(&rt_name, runtime::BASE_IMAGE_TAG, &base_fp);
let build_base = base_stale || rebuild_base || (base_only && force);
let mut harness_builds: Vec<runtime::ImageBuildSpec> = Vec::new();
for &h in &selected {
let spec = runtime::image_build_spec(h, &df, uid, gid, &tz);
if force || rebuild_base || !runtime::image_is_up_to_date(&rt_name, &spec.tag, &spec.fingerprint) {
harness_builds.push(spec);
} else {
ok(&format!("{} up to date", spec.tag));
}
}
if !build_base {
ok(&format!("{} up to date", runtime::BASE_IMAGE_TAG));
}
if !build_base && harness_builds.is_empty() {
ui.finish();
ok("nothing to build — every selected image is up to date (use --force to rebuild anyway)");
return 0;
}
let mut base_build = None;
if build_base {
let label = format!("using {} · build base", backend_name(&rt_name));
let p = ui.proc(label.clone(), false);
if let Some(c) = &daemon_client {
c.proc_add(p.index(), &label, daemon::ProcKind::Build, None, None, None, None, None, None, None);
}
base_build = Some(p);
}
let mut harness_build_procs: Vec<ui::screen::Proc> = Vec::with_capacity(harness_builds.len());
for spec in &harness_builds {
let label = format!("using {} · build {}", backend_name(&rt_name), spec.harness.as_str());
let p = ui.proc(label.clone(), false);
if let Some(c) = &daemon_client {
c.proc_add(
p.index(),
&label,
daemon::ProcKind::Build,
None,
Some(spec.harness.as_str()),
None,
None,
None,
None,
None,
);
}
harness_build_procs.push(p);
}
ui.pin_board_to_top();
let mut build_failed = if let Some(ref base) = base_build {
base.start();
match run_build(
base,
&rt_name,
runtime::BASE_IMAGE_TAG,
runtime::BASE_IMAGE_TARGET,
&df,
uid,
gid,
&base_fp,
rebuild_base,
daemon_client.as_deref(),
"base",
&session_id,
) {
Ok(()) => {
base.finish_ok(None);
None
}
Err(e) => {
base.finish_fail(failure::reason::BUILD_FAILED, Some(&e.0));
Some(e)
}
}
} else {
None
};
if build_failed.is_none() {
build_failed = std::thread::scope(|scope| {
let session_ref = session_id.as_str();
let handles: Vec<_> = harness_build_procs
.iter()
.zip(harness_builds.iter())
.map(|(build, spec)| {
let rt_name = &rt_name;
let df = &df;
let daemon_client = &daemon_client;
scope.spawn(move || {
build.start();
match run_build(
build,
rt_name,
&spec.tag,
&spec.target,
df,
uid,
gid,
&spec.fingerprint,
force,
daemon_client.as_deref(),
spec.harness.as_str(),
session_ref,
) {
Ok(()) => {
build.finish_ok(None);
None
}
Err(e) => {
build.finish_fail(failure::reason::BUILD_FAILED, Some(&e.0));
Some(e)
}
}
})
})
.collect();
handles
.into_iter()
.filter_map(|h| h.join().unwrap_or_else(|_| Some(("image build thread panicked".to_string(), 1))))
.next()
});
}
ui.finish();
if let Some((msg, code)) = build_failed {
fail(&msg);
return code;
}
let built = harness_builds.len() + usize::from(build_base);
ok(&format!("built {built} image{}", plural(built)));
0
}
#[allow(clippy::too_many_arguments)]
fn print_build_command(
runtime_name: &str, tag: &str, target: &str, _dockerfile: &str, uid: u32, gid: u32, tz: &str, fingerprint: &str,
) {
match runtime::build_method(runtime_name) {
runtime::BuildMethod::Stdin => {
let build = runtime::build_command_stdin(runtime_name, tag, target, uid, gid, tz, fingerprint, false);
println!("{}", runtime::shell_join(&build));
}
runtime::BuildMethod::ContextDir => {
let ctx = std::env::temp_dir().join("scsh-build-XXXXXX");
let build = runtime::build_command_context(
runtime_name,
tag,
target,
&ctx.to_string_lossy(),
uid,
gid,
tz,
fingerprint,
false,
);
println!("{}", runtime::shell_join(&build));
println!("{}", h_dim("# in-memory Dockerfile written to an ephemeral context dir"));
}
}
}
fn init_demo() -> i32 {
if runtime::which("git").is_none() {
fail("git is not installed or not on PATH");
hint(install_git_hint());
return 1;
}
let root = match git_root() {
Ok(r) => r,
Err(_) => {
fail("not inside a git repository");
hint(&format!("create one here with: {}", bold("git init .")));
return 1;
}
};
let path = root.join(".scsh.yml");
if path.exists() {
fail(&format!(".scsh.yml already exists at {} — not overwriting", path.display()));
hint("delete it first if you want a fresh demo config");
return 1;
}
if let Err(e) = std::fs::write(&path, config::demo_yaml()) {
fail(&format!("could not write {}: {e}", path.display()));
return 1;
}
ok(&format!("wrote demo config to {}", path.display()));
match ensure_tmp_gitignored(&root) {
Ok(true) => ok("added '/tmp' to .gitignore (keeps build scratch and result copies untracked)"),
Ok(false) => {} Err(e) => hint(&format!("could not update .gitignore automatically ({e}); add a '/tmp' line yourself")),
}
let mut skill_paths: Vec<String> = Vec::new();
for (rel, body, executable) in config::demo_skills() {
let dest = root.join(rel);
if dest.exists() {
hint(&format!("kept existing {rel} (not overwritten)"));
continue;
}
if let Some(parent) = dest.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
hint(&format!("could not create {}: {e}", parent.display()));
continue;
}
}
match std::fs::write(&dest, body) {
Ok(()) => {
#[cfg(unix)]
if executable {
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o755));
}
skill_paths.push(rel.to_string());
}
Err(e) => hint(&format!("could not write {}: {e}", dest.display())),
}
}
if !skill_paths.is_empty() {
let n = skill_paths.len();
ok(&format!("scaffolded {n} example-skill file{} under .skills/", if n == 1 { "" } else { "s" }));
}
let links = link_skill_hosts(&root);
if !links.is_empty() {
let n = links.len();
ok(&format!(
"linked {n} harness skill dir{} → .skills (so the harness finds the skills)",
if n == 1 { "" } else { "s" }
));
}
let mut staged = vec![".scsh.yml".to_string()];
if root.join(".gitignore").exists() {
staged.push(".gitignore".to_string());
}
staged.extend(skill_paths);
staged.extend(links);
match commit_scaffold(&root, &staged, "Add scsh demo project (config + skills)") {
Ok(()) => {
ok("committed the scaffold");
let remaining = uncommitted_changes(&root);
if remaining.is_empty() {
println!("\nThe project is committed and clean. Next:");
println!(" {} {}", bold("scsh run"), h_dim("# build the image and run the .scsh.yml skills in parallel"));
} else {
fail("the repo still has uncommitted changes — a real run needs a clean working tree");
hint(&format!("commit or stash them, then run {}:", bold("scsh")));
hint(&format!("{}", bold("git add -A && git commit -m \"wip\"")));
}
}
Err(e) => {
hint(&format!("couldn't commit the scaffold automatically ({e})"));
println!("\nNext: commit the scaffold, then run 'scsh' (a run clones committed state):");
println!(" {}", bold("git add -A && git commit -m \"add scsh demo project\""));
println!(" {} {}", bold("scsh run"), h_dim("# build the image and run the .scsh.yml skills in parallel"));
}
}
print_skill_usage();
0
}
fn init_beautiful_demo() -> i32 {
if runtime::which("git").is_none() {
fail("git is not installed or not on PATH");
hint(install_git_hint());
return 1;
}
let root = match git_root() {
Ok(r) => r,
Err(_) => {
fail("not inside a git repository");
hint(&format!("create one here with: {}", bold("git init .")));
return 1;
}
};
let dirty = uncommitted_changes(&root);
let tracked = git_capture(&root, &["ls-files"]).unwrap_or_default();
if !dirty.is_empty() || !tracked.trim().is_empty() {
fail("the beautiful demo scaffold needs a clean, empty git repository");
for path in tracked.lines().chain(dirty.iter().map(String::as_str)).take(10) {
hint(&format!("already present: {path}"));
}
hint("create a fresh directory, run `git init`, then run `scsh init-beautiful-demo`");
return 1;
}
let files = [
("wordstats.py", include_str!("demo_beautiful/wordstats.py"), true),
("test_wordstats.py", include_str!("demo_beautiful/test_wordstats.py"), false),
("README.md", include_str!("demo_beautiful/README.md"), false),
("CONTRIBUTING.md", include_str!("demo_beautiful/CONTRIBUTING.md"), false),
];
for (rel, _, _) in files {
if root.join(rel).exists() {
fail(&format!("{rel} already exists — not overwriting"));
hint("use a fresh repository for the beautiful-loop demo");
return 1;
}
}
match ensure_tmp_gitignored(&root) {
Ok(true) => ok("added '/tmp' to .gitignore"),
Ok(false) => {}
Err(e) => {
fail(&format!("could not update .gitignore: {e}"));
return 1;
}
}
let mut staged = vec![".gitignore".to_string()];
for (rel, body, executable) in files {
let dest = root.join(rel);
if let Err(e) = std::fs::write(&dest, body) {
fail(&format!("could not write {}: {e}", dest.display()));
return 1;
}
#[cfg(unix)]
if executable {
use std::os::unix::fs::PermissionsExt;
if let Err(e) = std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o755)) {
fail(&format!("could not make {} executable: {e}", dest.display()));
return 1;
}
}
staged.push(rel.to_string());
}
ok("scaffolded the beautiful-loop word-counting project");
match commit_scaffold(&root, &staged, "Add beautiful loop demo scaffold.") {
Ok(()) => {
ok("committed the scaffold");
println!("\nThe project is committed and clean. Next:");
println!(" {}", bold("python3 -m unittest -v"));
println!(" {}", bold("scsh run --def demo-beautiful-loop"));
0
}
Err(e) => {
fail(&format!("could not commit the beautiful demo scaffold: {e}"));
hint("configure git user.name and user.email, commit the scaffold, then run the workflow");
1
}
}
}
fn commit_scaffold(root: &Path, paths: &[String], message: &str) -> Result<(), String> {
let add = Command::new("git").arg("-C").arg(root).arg("add").arg("--").args(paths).output();
let add = add.map_err(|e| format!("git add: {e}"))?;
if !add.status.success() {
return Err(String::from_utf8_lossy(&add.stderr).trim().to_string());
}
let out = Command::new("git")
.arg("-C")
.arg(root)
.args(["commit", "-q", "-m", message])
.output()
.map_err(|e| format!("git commit: {e}"))?;
if out.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
}
#[derive(Default)]
struct InstallCounts {
installed: u32,
updated: u32,
already: u32,
differing: Vec<String>,
}
impl InstallCounts {
fn merge(&mut self, other: InstallCounts) {
self.installed += other.installed;
self.updated += other.updated;
self.already += other.already;
self.differing.extend(other.differing);
}
}
fn install_skills(overwrite: bool, sources: &[String], global: bool) -> i32 {
if global {
return install_skills_global(overwrite, sources);
}
if runtime::which("git").is_none() {
fail("git is not installed or not on PATH");
hint(install_git_hint());
return 1;
}
let root = match git_root() {
Ok(r) => r,
Err(_) => {
fail("not inside a git repository");
hint(&format!("create one here with: {}", bold("git init .")));
return 1;
}
};
let dirty = uncommitted_changes(&root);
if !dirty.is_empty() {
fail(
"working tree has uncommitted changes — commit or stash them so the install lands as a clean, reviewable diff",
);
let shown = dirty.len().min(10);
for p in &dirty[..shown] {
hint(&format!("uncommitted: {p}"));
}
if dirty.len() > shown {
hint(&format!("\u{2026}and {} more", dirty.len() - shown));
}
hint(&format!("commit or stash them first, then re-run: {}", bold("git add -A && git commit -m \"wip\"")));
return 1;
}
match ensure_tmp_gitignored(&root) {
Ok(true) => ok("added '/tmp' to .gitignore (keeps skill results + cache untracked)"),
Ok(false) => {}
Err(e) => hint(&format!("could not update .gitignore automatically ({e}); add a '/tmp' line yourself")),
}
let mut counts = InstallCounts::default();
if sources.is_empty() {
counts.merge(install_bundled(&root, overwrite));
} else {
for url in sources {
match install_from_repo(&root, overwrite, url) {
Ok(c) => counts.merge(c),
Err(code) => return code,
}
}
}
let any = report_install_counts(&counts, sources, false);
let links = link_skill_hosts(&root);
if !links.is_empty() {
ok(&format!("linked {} harness skill dir{} → .skills", links.len(), if links.len() == 1 { "" } else { "s" }));
}
if !any && links.is_empty() {
ok("skills already installed; nothing to do");
}
if sources.is_empty() {
hint("installed all five code-review reviewer specialties");
hint(
"delivery-pipeline skills install from source: scsh installskills https://github.com/dkorolev/beautiful-skills",
);
hint("run /scsh-harness-demo-and-selftest for the basic harness demo, or `scsh run --def demo-beautiful-loop` for the review loop");
}
0
}
fn report_install_counts(counts: &InstallCounts, sources: &[String], global: bool) -> bool {
let InstallCounts { installed, updated, already, differing } = counts;
if *installed > 0 {
ok(&format!("installed {installed} skill file{} under .skills/", plural(*installed as usize)));
}
if *updated > 0 {
ok(&format!("updated {updated} skill file{}", plural(*updated as usize)));
}
if *already > 0 {
ok(&format!("{already} skill file{} already installed (identical)", plural(*already as usize)));
}
for rel in differing {
hint(&format!("kept your modified {rel} (it differs from the source)"));
}
if !differing.is_empty() {
let mut cmd = String::from("scsh updateskills");
if global {
cmd.push_str(" --global");
}
if !sources.is_empty() {
cmd.push(' ');
cmd.push_str(&sources.join(" "));
}
hint(&format!("to replace them with the source's version, run: {}", bold(&cmd)));
}
*installed > 0 || *updated > 0 || *already > 0 || !differing.is_empty()
}
fn install_skills_global(overwrite: bool, sources: &[String]) -> i32 {
let home = runtime::scsh_home();
if let Err(e) = std::fs::create_dir_all(&home) {
fail(&format!("could not create {}: {e}", home.display()));
return 1;
}
if !sources.is_empty() && runtime::which("git").is_none() {
fail("git is not installed or not on PATH (needed to clone the skills source)");
hint(install_git_hint());
return 1;
}
let mut counts = InstallCounts::default();
if sources.is_empty() {
counts.merge(install_bundled(&home, overwrite));
} else {
for url in sources {
match install_from_repo(&home, overwrite, url) {
Ok(c) => counts.merge(c),
Err(code) => return code,
}
}
}
let any = report_install_counts(&counts, sources, true);
let links = link_agent_global_skills(&home);
if !any && links == 0 {
ok("skills already installed; nothing to do");
}
ok(&format!("global install: {} (manifest + .skills/)", display_path(&home)));
hint("any git repo can now run these profiles — a repo's own .scsh.yml still wins for the profiles it declares");
if sources.is_empty() {
hint("installed all five code-review reviewer specialties");
hint("try it from any repo: scsh check-profile code-review && scsh run code-review");
hint("delivery-pipeline skills install from source: scsh installskills --global https://github.com/dkorolev/beautiful-skills");
}
0
}
#[cfg(unix)]
fn link_agent_global_skills(scsh_home: &Path) -> usize {
const AGENT_DIRS: &[&str] = &[".claude", ".cursor", ".codex", ".opencode", ".agents"];
let Some(user_home) = std::env::var_os("HOME").filter(|s| !s.is_empty()).map(PathBuf::from) else {
hint("HOME is not set — skipped linking the skills into agent skills dirs");
return 0;
};
let target_dir = scsh_home.join(".skills");
let mut skills: Vec<(String, PathBuf)> = std::fs::read_dir(&target_dir)
.map(|entries| {
entries
.flatten()
.map(|e| e.path())
.filter(|p| p.join("SKILL.md").is_file())
.filter_map(|p| p.file_name().map(|n| (n.to_string_lossy().into_owned(), p.clone())))
.filter(|(name, _)| !name.starts_with(INTERNAL_PREFIX))
.collect()
})
.unwrap_or_default();
skills.sort();
let mut made = 0usize;
let mut linked_agents: Vec<String> = Vec::new();
for agent in AGENT_DIRS {
let skills_dir = user_home.join(agent).join("skills");
let Ok(meta) = skills_dir.symlink_metadata() else {
let linked = std::fs::create_dir_all(user_home.join(agent)).is_ok()
&& std::os::unix::fs::symlink(&target_dir, &skills_dir).is_ok();
if linked {
made += 1;
linked_agents.push(format!("~/{agent}/skills"));
} else {
hint(&format!("could not link {} — skipped", skills_dir.display()));
}
continue;
};
if meta.file_type().is_symlink() {
continue; }
let mut fresh = 0usize;
for (name, target) in &skills {
let link = skills_dir.join(name);
match std::fs::read_link(&link) {
Ok(existing) if &existing == target => continue, Ok(_) | Err(_) if link.symlink_metadata().is_ok() => {
hint(&format!("kept the existing {} (not touching it)", display_path(&link)));
continue;
}
_ => {}
}
if std::os::unix::fs::symlink(target, &link).is_ok() {
fresh += 1;
}
}
if fresh > 0 {
made += fresh;
linked_agents.push(format!("~/{agent}/skills ({fresh})"));
}
}
if made > 0 {
ok(&format!("linked agent skills dirs → {}: {}", display_path(&target_dir), linked_agents.join(", ")));
}
made
}
#[cfg(not(unix))]
fn link_agent_global_skills(_scsh_home: &Path) -> usize {
0
}
fn install_bundled(root: &Path, overwrite: bool) -> InstallCounts {
let mut c = InstallCounts::default();
for (rel, body) in config::bundled_skills() {
write_one(&root.join(rel), body.as_bytes(), rel, overwrite, &mut c);
}
merge_bundled_manifest(root);
c
}
fn merge_bundled_manifest(root: &Path) {
let source = config::bundled_skills_manifest();
let local_path = root.join(".scsh.yml");
let local_text = std::fs::read_to_string(&local_path).unwrap_or_default();
let existing: std::collections::BTreeSet<String> = config::validate(&local_text)
.map(|cfg| cfg.skills.into_iter().map(|skill| skill.name).collect())
.unwrap_or_default();
let mut append = String::new();
let mut added = Vec::new();
let mut conflicts = Vec::new();
for (rel, _) in config::bundled_skills() {
let Some(name) = Path::new(rel).parent().and_then(Path::file_name).and_then(|name| name.to_str()) else {
continue;
};
let Some(block) = config::extract_skill_block(source, name) else { continue };
if existing.contains(name) {
conflicts.push(name.to_string());
} else {
append.push_str(&block);
added.push(name.to_string());
}
}
for name in &conflicts {
hint(&format!("kept your existing '{name}' entry in .scsh.yml (conflicts with bundled manifest)"));
}
if append.is_empty() {
return;
}
let merged = if local_text.trim().is_empty() {
format!("{CONSUMER_MANIFEST_HEADER}{append}")
} else {
let mut text = local_text;
if !text.ends_with('\n') {
text.push('\n');
}
text.push_str(&append);
text
};
if config::validate(&merged).is_ok() && write_file(&local_path, merged.as_bytes()) {
ok(&format!("added {} bundled skill{} to .scsh.yml: {}", added.len(), plural(added.len()), added.join(", ")));
} else {
hint(
"installed the bundled skill files, but merging their profiles would make .scsh.yml invalid — left it unchanged",
);
}
}
const CONSUMER_MANIFEST_HEADER: &str = "\
# .scsh.yml — Scoped Skills Helper. Skills below were added by `scsh installskills`.
# The whole file is just your skills; scsh builds them on a built-in base image.
# Run `scsh help .scsh.yml` for the schema, or `scsh help` for commands.
skills:
";
const INTERNAL_PREFIX: &str = "internal-";
fn install_from_repo(root: &Path, overwrite: bool, url: &str) -> Result<InstallCounts, i32> {
let clone = std::env::temp_dir().join(format!("scsh-installskills-{}-{}", std::process::id(), now_secs()));
let _ = std::fs::remove_dir_all(&clone); let cloned = Command::new("git")
.args(["clone", "--depth", "1", url])
.arg(&clone)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !cloned {
fail(&format!("could not clone {url}"));
hint("check the URL and your network/credentials, then try again");
let _ = std::fs::remove_dir_all(&clone);
return Err(1);
}
let manifest = clone.join(".scsh.yml");
let result = if manifest.is_file() {
install_from_manifest(root, overwrite, url, &clone, &manifest)
} else {
install_all_skill_dirs(root, overwrite, url, &clone)
};
let _ = std::fs::remove_dir_all(&clone);
result
}
fn install_all_skill_dirs(root: &Path, overwrite: bool, url: &str, clone: &Path) -> Result<InstallCounts, i32> {
let mut c = InstallCounts::default();
let mut names: Vec<String> = Vec::new();
let mut skipped: Vec<String> = Vec::new();
if let Ok(entries) = std::fs::read_dir(clone.join(".skills")) {
let mut dirs: Vec<PathBuf> = entries.flatten().map(|e| e.path()).filter(|p| p.join("SKILL.md").is_file()).collect();
dirs.sort();
for dir in dirs {
let name = dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
if name.starts_with(INTERNAL_PREFIX) {
skipped.push(name); continue;
}
copy_skill_dir(root, &dir, &name, overwrite, &mut c);
names.push(name);
}
}
if names.is_empty() {
fail(&format!("no skills found in {url} (expected .skills/<name>/SKILL.md)"));
return Err(1);
}
ok(&format!("from {url}: {} skill{} — {}", names.len(), plural(names.len()), names.join(", ")));
if !skipped.is_empty() {
ok(&format!("skipped {} authoring-only (internal-*): {}", skipped.len(), skipped.join(", ")));
}
Ok(c)
}
fn install_from_manifest(
root: &Path, overwrite: bool, url: &str, clone: &Path, manifest: &Path,
) -> Result<InstallCounts, i32> {
let src_text = match std::fs::read_to_string(manifest) {
Ok(t) => t,
Err(e) => {
fail(&format!("{url}: could not read its .scsh.yml: {e}"));
return Err(1);
}
};
let cfg = match config::validate(&src_text) {
Ok(c) => c,
Err(errs) => {
fail(&format!("{url}: its .scsh.yml does not match the schema ({} problem{})", errs.len(), plural(errs.len())));
for e in &errs {
hint(e);
}
return Err(1);
}
};
let local_path = root.join(".scsh.yml");
let local_text = std::fs::read_to_string(&local_path).unwrap_or_default();
let existing: std::collections::BTreeSet<String> =
config::validate(&local_text).map(|c| c.skills.into_iter().map(|s| s.name).collect()).unwrap_or_default();
let mut c = InstallCounts::default();
let mut installed: Vec<String> = Vec::new();
let mut skipped: Vec<String> = Vec::new();
let mut added: Vec<String> = Vec::new();
let mut refreshed: Vec<String> = Vec::new();
let mut conflicts: Vec<String> = Vec::new();
let mut append = String::new();
let mut merged = local_text.clone();
for skill in &cfg.skills {
if !skill.autoinstall || skill.name.starts_with(INTERNAL_PREFIX) {
skipped.push(skill.name.clone());
continue;
}
let dir = clone.join(".skills").join(&skill.name);
if !dir.join("SKILL.md").is_file() {
hint(&format!("{}: listed in .scsh.yml but has no .skills/{}/SKILL.md — skipped", skill.name, skill.name));
continue;
}
copy_skill_dir(root, &dir, &skill.name, overwrite, &mut c);
if !installed.contains(&skill.name) {
installed.push(skill.name.clone());
}
if existing.contains(&skill.name) {
if overwrite {
if let Some(block) = config::extract_skill_block(&src_text, &skill.name) {
if let Some(replaced) = config::replace_skill_block(&merged, &skill.name, &block) {
if replaced != merged {
merged = replaced;
refreshed.push(skill.name.clone());
}
}
}
} else {
conflicts.push(skill.name.clone());
}
continue;
}
if let Some(block) = config::extract_skill_block(&src_text, &skill.name) {
append.push_str(&block);
added.push(skill.name.clone());
}
}
if installed.is_empty() {
fail(&format!("{url}: its .scsh.yml lists no installable skills (all authoring-only or missing)"));
return Err(1);
}
ok(&format!("from {url}: {} skill{} — {}", installed.len(), plural(installed.len()), installed.join(", ")));
if !skipped.is_empty() {
ok(&format!("skipped {} authoring-only (autoinstall: false or internal-*): {}", skipped.len(), skipped.join(", ")));
}
if !conflicts.is_empty() {
for name in &conflicts {
hint(&format!("kept your existing '{name}' entry in .scsh.yml (conflicts with source manifest)"));
}
}
if !append.is_empty() {
merged = if merged.trim().is_empty() {
format!("{CONSUMER_MANIFEST_HEADER}{append}")
} else {
if !merged.ends_with('\n') {
merged.push('\n');
}
merged.push_str(&append);
merged
};
}
if merged == local_text {
if conflicts.is_empty() && refreshed.is_empty() {
ok("the installed skills were already declared in .scsh.yml");
}
} else {
if config::validate(&merged).is_ok() && write_file(&local_path, merged.as_bytes()) {
if !refreshed.is_empty() {
ok(&format!(
"updated {} skill declaration{} in .scsh.yml: {}",
refreshed.len(),
plural(refreshed.len()),
refreshed.join(", ")
));
}
if !added.is_empty() {
ok(&format!("added {} skill{} to .scsh.yml: {}", added.len(), plural(added.len()), added.join(", ")));
}
} else {
hint("installed the skill files, but updating .scsh.yml would make it invalid — left the manifest unchanged");
let affected: Vec<&str> = refreshed.iter().chain(&added).map(String::as_str).collect();
hint(&format!("update by hand: {}", affected.join(", ")));
}
}
Ok(c)
}
fn copy_skill_dir(root: &Path, src: &Path, name: &str, overwrite: bool, c: &mut InstallCounts) {
let dest_dir = root.join(".skills").join(name);
let mut files = Vec::new();
collect_files(src, src, &mut files);
files.sort();
for (rel, abs) in files {
if let Ok(body) = std::fs::read(&abs) {
write_one(&dest_dir.join(&rel), &body, &format!(".skills/{name}/{rel}"), overwrite, c);
}
}
}
fn write_one(dest: &Path, body: &[u8], shown: &str, overwrite: bool, c: &mut InstallCounts) {
if dest.is_file() {
let same = std::fs::read(dest).map(|d| d == body).unwrap_or(false);
if same {
c.already += 1;
} else if overwrite {
if write_file(dest, body) {
c.updated += 1;
}
} else {
c.differing.push(shown.to_string());
}
} else if write_file(dest, body) {
c.installed += 1;
}
}
fn write_file(dest: &Path, body: &[u8]) -> bool {
if let Some(parent) = dest.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
hint(&format!("could not create {}: {e}", parent.display()));
return false;
}
}
match atomic_write(dest, body) {
Ok(()) => true,
Err(e) => {
hint(&format!("could not write {}: {e}", dest.display()));
false
}
}
}
#[cfg(unix)]
fn link_skill_hosts(root: &Path) -> Vec<String> {
const HOSTS: &[&str] = &[".opencode/skills", ".claude/skills", ".cursor/skills", ".agents/skills", ".codex/skills"];
let mut made = Vec::new();
for host in HOSTS {
let link = root.join(host);
if link.symlink_metadata().is_ok() {
continue; }
let linked = link.parent().map(|p| std::fs::create_dir_all(p).is_ok()).unwrap_or(false)
&& std::os::unix::fs::symlink("../.skills", &link).is_ok();
if linked {
made.push((*host).to_string());
}
}
made
}
#[cfg(not(unix))]
fn link_skill_hosts(_root: &Path) -> Vec<String> {
Vec::new()
}
fn print_skill_usage() {
println!("\nThe demo .scsh.yml runs `add` on two routes by default (opencode+GPT, claude+Sonnet),");
println!("plus `subtract` (C - D) — a second commit-enabled step, so one run brings in two");
println!("commits from two different steps. `multiply` (X * Y) lives in the `multiply` profile");
println!("because it REQUIRES X and Y. `demo-pr` is the multi-agent fake-PR skill; the built-in");
println!("`smoke-pr-claude` / `smoke-pr-codex` / `smoke-pr-grok` / `smoke-pr-cursor` defs smoke one");
println!("harness at a time (feature note + PR-DESCRIPTION.md). scsh resolves the env you");
println!(
"forward (or refuses the skill). Examples — successes ({}) and the intended refusal ({}):",
ok_mark(),
refused_mark()
);
println!();
example("scsh run", "add 2 + 3 = 5, subtract 10 - 4 = 6 (both commit)", true);
example("A=10 B=20 scsh run", "add forwards your A,B -> 10 + 20 = 30", true);
example("X=6 Y=7 scsh run --profile multiply", "also runs multiply -> 6 * 7 = 42", true);
example("scsh run --profile multiply", "multiply REFUSED — X is required by ${X}", false);
example("scsh run demo-pr-claude-sonnet", "fake PR on claude (⇄ commits diff + Description)", true);
example("scsh run --def smoke-pr-claude", "builtin smoke fake-PR on Claude only", true);
println!();
let (var, def, req) = (env_syntax("${VAR}"), env_syntax("${VAR:-default}"), env_syntax("${VAR:?msg}"));
println!("The env syntax: {var} requires VAR, {def} injects a default, {req}");
println!("requires it with your message, and a bare literal is just that literal.");
println!("When a skill finishes, scsh prints the message from its JSON result file (e.g.");
println!("\"6 * 7 = 42\"), not just the file path. Preview the resolved env without containers:");
println!(" {} (shows every skill and the profile that runs it).", bold("scsh list"));
println!(
"Or from any clean repo (no scaffold): {} / {} / {} / {}.",
bold("scsh run --def smoke-pr-claude"),
bold("smoke-pr-codex"),
bold("smoke-pr-grok"),
bold("smoke-pr-cursor")
);
}
fn env_syntax(token: &str) -> console::StyledObject<&str> {
console::style(token).cyan()
}
fn ok_mark() -> console::StyledObject<&'static str> {
console::style("\u{2713}").green()
}
fn refused_mark() -> console::StyledObject<&'static str> {
console::style("\u{2717}").dim()
}
fn example(cmd: &str, comment: &str, ok: bool) {
let mark = if ok { ok_mark() } else { refused_mark() };
let cmd = console::style(format!("{cmd:<35}")).bold();
println!(" {cmd} {} {mark}", dim_comment(comment));
}
fn dim_comment(comment: &str) -> String {
let mut out = format!("{}", h_dim("# "));
let mut rest = comment;
while let Some(start) = rest.find("${") {
if let Some(end) = rest[start..].find('}') {
let end = start + end + 1; out.push_str(&format!("{}", h_dim(&rest[..start])));
out.push_str(&format!("{}", env_syntax(&rest[start..end])));
rest = &rest[end..];
continue;
}
break;
}
out.push_str(&format!("{}", h_dim(rest)));
out
}
fn ensure_tmp_gitignored(root: &std::path::Path) -> Result<bool, String> {
let added = if tmp_is_gitignored(root) {
false
} else {
let path = root.join(".gitignore");
let mut content = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(format!("could not read {}: {e}", path.display())),
};
if !content.is_empty() && !content.ends_with('\n') {
content.push('\n');
}
content.push_str("# scsh uses the system temp dir for build scratch; never track a local /tmp.\n/tmp\n");
atomic_write(&path, content.as_bytes()).map_err(|e| format!("could not write {}: {e}", path.display()))?;
true
};
std::fs::create_dir_all(root.join("tmp")).map_err(|e| format!("could not create {}/tmp: {e}", root.display()))?;
Ok(added)
}
pub fn atomic_write(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
let mut tmp_name = path.as_os_str().to_owned();
tmp_name.push(format!(".tmp.{}", std::process::id()));
let tmp = PathBuf::from(tmp_name);
let written = (|| {
use std::io::Write;
let mut file = std::fs::File::create(&tmp)?;
file.write_all(bytes)?;
file.sync_all()?;
if let Ok(meta) = std::fs::metadata(path) {
std::fs::set_permissions(&tmp, meta.permissions())?;
}
std::fs::rename(&tmp, path)
})();
if written.is_err() {
let _ = std::fs::remove_file(&tmp);
}
written
}
fn git_root() -> Result<PathBuf, String> {
let out = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.output()
.map_err(|e| format!("failed to run git: {e}"))?;
if !out.status.success() {
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
}
Ok(PathBuf::from(String::from_utf8_lossy(&out.stdout).trim()))
}
fn git_root_of(dir: &Path) -> Option<PathBuf> {
let path = git_capture(dir, &["rev-parse", "--show-toplevel"])?;
let path = path.trim();
(!path.is_empty()).then(|| PathBuf::from(path))
}
fn ok(msg: &str) {
println!("{} {msg}", console::style("\u{2713}").green().bold());
}
fn fail(msg: &str) {
eprintln!("{} {msg}", console::style("\u{2717}").red().bold().for_stderr());
}
fn hint(msg: &str) {
eprintln!(" {} {msg}", console::style("\u{2192}").cyan().for_stderr());
}
fn warn(msg: &str) {
eprintln!("{} {msg}", console::style("\u{26a0}").yellow().bold().for_stderr());
}
fn bold(s: &str) -> console::StyledObject<&str> {
console::style(s).bold().for_stderr()
}
fn install_git_hint() -> &'static str {
if cfg!(target_os = "macos") {
"install it with: brew install git (or: xcode-select --install)"
} else {
"install it with your package manager, e.g.: sudo apt-get install git"
}
}
fn install_runtime_hint() -> &'static str {
if cfg!(target_os = "macos") {
"install Apple 'container' (https://github.com/apple/container), Docker Desktop, or Podman"
} else {
"install Docker (https://docs.docker.com/engine/install/) or Podman (https://podman.io)"
}
}
fn h_head(s: &str) -> console::StyledObject<&str> {
console::style(s).cyan().bold()
}
fn h_dim(s: &str) -> console::StyledObject<&str> {
console::style(s).dim()
}
fn help_row(name: &str, desc: &str) {
println!(" {} {} {}", h_dim("\u{2022}"), console::style(format!("{name:<25}")).bold(), h_dim(desc));
}
fn help_cont(desc: &str) {
println!(" {}", h_dim(desc));
}
fn print_help(topic: HelpTopic) {
match topic {
HelpTopic::Overview => print_help_overview(),
HelpTopic::Run => print_help_run(),
HelpTopic::Config => print_help_config(),
HelpTopic::Internals => print_help_internals(),
HelpTopic::Cache => print_help_cache(),
HelpTopic::Agent => print_help_agent(),
HelpTopic::Defs => print_help_defs(),
HelpTopic::ExitCodes => print_help_exitcodes(),
HelpTopic::Command(name) if name == "run" => print_help_run(),
HelpTopic::Command(name) => print_help_command(&name),
}
}
fn print_help_exitcodes() {
println!();
println!("{} {}", h_head("Exit codes"), console::style("\u{2014} the same across every command").bold());
println!();
help_row("0", "Success — the command did what was asked.");
help_row("1", "Failure — a skill failed, a check did not pass, or the operation could not complete.");
help_row("2", "Usage error — a bad argument, unknown command, or missing required value.");
println!();
println!("{}", h_dim(" scsh defines no command-specific exit codes; these three are the whole set."));
println!();
}
const DEMOS: &[(&str, &str, &str)] = &[(
"agent-fleet",
"One agent fans \"explain this codebase\" out to claude, codex, and cursor; one blocking run.",
include_str!("../AGENT-FLEET-DEMO.md"),
)];
fn demo_cmd(name: Option<&str>) -> i32 {
let Some(requested) = name else {
println!();
println!(
"{} {}",
h_head("Demos"),
console::style("\u{2014} print one with `scsh demo <name>`, then follow it").bold()
);
println!();
for (demo_name, blurb, _) in DEMOS {
help_row(demo_name, blurb);
}
println!();
println!("{}", h_dim(" Each demo is a self-contained markdown walkthrough: hand the output to an"));
println!("{}", h_dim(" agent (or follow it yourself) from any directory not inside a git repo."));
println!();
return 0;
};
let normalized = requested.to_ascii_lowercase();
let normalized = normalized.trim_end_matches(".md").trim_end_matches("-demo");
match DEMOS.iter().find(|(demo_name, _, _)| *demo_name == normalized) {
Some((_, _, markdown)) => {
print!("{markdown}");
0
}
None => {
let names: Vec<&str> = DEMOS.iter().map(|(demo_name, _, _)| *demo_name).collect();
eprintln!("scsh: no demo named '{requested}' (available: {})", names.join(", "));
eprintln!("try 'scsh demo'");
2
}
}
}
fn print_help_agent() {
println!();
println!("{} {}", h_head("agent"), console::style("\u{2014} drive scsh from another agent or harness").bold());
println!();
println!("{}", h_dim("You are an agent. scsh is your fan-out primitive: one command runs a repo's"));
println!("{}", h_dim("skills as separate agent jobs in parallel \u{2014} each agent (claude, codex, cursor,"));
println!("{}", h_dim("opencode, grok) in its own container on a clean clone \u{2014} and BLOCKS until every"));
println!("{}", h_dim("job has written its result file. You never poll and never scrape a TTY:"));
println!("{}", h_dim("everything you need is exit codes and JSON."));
println!();
println!("{}", h_head("The loop"));
help_row("1. DISCOVER", "scsh list --json \u{2014} profiles, skills, routes, and each job's result path.");
help_row("2. GATE", "scsh check-profile <p> \u{2014} exit 0 iff the profile exists (runtime-free).");
help_cont("scsh probe [p\u{2026}] --json \u{2014} which agent\u{b7}model routes can run on this host.");
help_row("3. RUN", "scsh run [p\u{2026}] \u{2014} synchronous; exit 0 = every job succeeded. Pass skill");
help_cont("inputs as environment variables (declared in .scsh.yml `env:`).");
help_row("4. COLLECT", "Read each job's result file \u{2014} the paths came from step 1, one per route,");
help_cont("always under the repo's gitignored tmp/.");
help_row("5. RE-RUN", "Same commit + same env = instant cached result; re-running is free.");
println!();
println!("{}", h_head("Bring your own work to any repo"));
println!("{}", h_dim(" scsh run --override-dot-scsh-yml <bundle>/.scsh.yml"));
help_cont("Run an external bundle's skills (config + sibling .skills/) against ANY clean");
help_cont("git repo: the target needs no .scsh.yml and no .skills/, and stays byte-clean \u{2014}");
help_cont("results land only under its gitignored tmp/. probe/list/check-profile take the");
help_cont("same flag, so the whole loop above works without installing anything.");
println!();
println!("{}", h_head("What scsh enforces (so you don't have to)"));
help_row("committed state", "A real run insists on a clean tree and runs the COMMIT, never the tree.");
help_row("route skipping", "Unavailable routes are skipped; the run fails only when ALL are skipped.");
help_row("preflight", "Missing required env is refused before any container starts.");
help_row("exit codes", "0 ok \u{b7} 1 failure \u{b7} 2 usage \u{2014} the whole set (scsh help exitcodes).");
println!();
println!("{}", h_dim(" Worked end-to-end example: `scsh demo agent-fleet` prints AGENT-FLEET-DEMO.md \u{2014}"));
println!(
"{}",
h_dim(" one agent (you) fans \u{201c}explain this codebase\u{201d} out to claude, codex, and cursor")
);
println!("{}", h_dim(" as three agent jobs, waits on one blocking run, then synthesizes the three"));
println!("{}", h_dim(" JSON results. No checkout path needed \u{2014} follow what the command prints."));
println!();
}
fn print_help_command(name: &str) {
println!();
let (tagline, synopsis, body): (&str, &str, &[(&str, &str)]) = match name {
"list" => (
"list skills by profile",
"scsh list [--verbose] [--json]",
&[
("--verbose", "Show the full per-skill build/run commands (human-readable)."),
("--json", "Machine-readable listing; on by default when stdout is not a TTY."),
],
),
"check-profile" => (
"test whether a profile exists",
"scsh check-profile <name> [--override-dot-scsh-yml <path>]",
&[
("<name>", "Exit 0 iff that profile exists with >=1 skill; runtime-free (no build)."),
("--override-dot-scsh-yml <path>", "Check against this external `.scsh.yml` instead of the repo's."),
],
),
"probe" => (
"which model routes are runnable on this host",
"scsh probe [profile…] [--json]",
&[
(
"[profile…]",
"Probe those profiles' harness·model routes (deduped across skills); no profile = every route in the config. Exit 0 when at least one route is available, 1 when none is — gate a fleet with `scsh probe code-review && scsh run code-review`.",
),
("--json", "Machine-readable routes with per-route availability and the reason when unavailable."),
("--override-dot-scsh-yml <path>", "Probe against this external `.scsh.yml` instead of the repo's."),
],
),
"init-demo-project" => (
"scaffold a demo project",
"scsh init-demo-project",
&[("(no flags)", "Write and commit a small demo .scsh.yml + skills into the current dir.")],
),
"init-beautiful-demo" => (
"scaffold the code-review loop demo",
"scsh init-beautiful-demo",
&[("(no flags)", "Write and commit the tiny word-counting project used by demo-beautiful-loop.")],
),
"demo" => (
"print an embedded agent-followable walkthrough",
"scsh demo [name]",
&[
("(no name)", "List the available demos."),
(
"agent-fleet",
"One agent fans \"explain this codebase\" out to claude, codex, and cursor as three agent jobs through one blocking `scsh run`, then synthesizes the three JSON results. Prints AGENT-FLEET-DEMO.md verbatim — no checkout path needed.",
),
],
),
"installskills" => (
"install skills into this repo (or machine-wide)",
"scsh installskills [--global] [git-url…]",
&[
("[git-url…]", "Install from the bundled skills, or from one or more source repos in order."),
(
"--global",
"Install under $SCSH_HOME (~/.scsh) instead: skills + a global manifest that run/list/check-profile fall back to when the repo's .scsh.yml lacks the profile, plus symlinks into each present agent's ~/.<agent>/skills. No git repo needed.",
),
],
),
"updateskills" => (
"update skills and manifest blocks",
"scsh updateskills [--global] [git-url…]",
&[(
"[git-url…]",
"Like installskills, but overwrites each skill's local files and existing source manifest block.",
)],
),
"daemon" => (
"the session-browser daemon",
"scsh daemon <start|stop|restart|status>",
&[
("start", "Run a persistent daemon until `daemon stop`."),
("stop", "Stop the running daemon."),
("restart", "Stop then start (persistent)."),
("status", "Exit 0 when the daemon is listening."),
("SCSH_DAEMON_PORT", "Listen port (default 7274)."),
(
"SCSH_HOME",
"Dir for the session store and permanent per-session artifacts (sessions/<id>/casts|logs; default ~/.scsh).",
),
],
),
"failures" => (
"browse the failure log",
"scsh failures [--session S] [--skill N] [--reason C] [--last N] [--stats]",
&[
("--session/--skill/--reason", "Filter by session id, skill name, or reason code."),
("--last N", "Show only the last N entries (0 = all)."),
("--stats", "Aggregate counts per reason instead of listing entries."),
],
),
"stats" => (
"run durations & workload per route",
"scsh stats [--skill N] [--profile P] [--harness H] [--model M] [--raw] [--last N]",
&[
("--skill/--profile", "Filter by skill name or profile."),
("--harness/--model", "Filter by harness or model route."),
("--raw", "One row per run instead of aggregates."),
("--last N", "Limit to the last N runs."),
],
),
"prune" => (
"the run-dir cleanup queue",
"scsh prune [--now]",
&[("--now", "Force a janitor pass now instead of just showing the queue.")],
),
"gc" => (
"reclaim old session artifact dirs",
"scsh gc [--dry-run] | scsh gc --apply [--days N] [--keep N] [--legacy]",
&[
("(default)", "Dry-run: list reclaimable paths under $SCSH_HOME/sessions/."),
("--dry-run", "Explicit dry-run (same as the default)."),
("--apply", "Actually delete candidates (required to free disk)."),
("--days N", "Only dirs older than N days by mtime (default 30)."),
("--keep N", "Always retain the N newest session dirs (default 50)."),
("--legacy", "Also remove top-level $SCSH_HOME/casts/ and recordings/."),
],
),
"build-images" => (
"build the container images outside a run",
"scsh build-images [harness…] [--force] [--rebuild-base] [--session <id>]",
&[
("[harness…]", "Harness images to build (opencode, claude, codex, grok, cursor); none = all."),
("--force", "Rebuild the selected harness images even when up to date (--no-cache)."),
("--rebuild-base", "Force-rebuild the shared base image first (--no-cache), then the selection."),
("--session <id>", "Report into this session id (used by the dashboard's Build buttons)."),
],
),
"annotate-cast" => (
"summarize + chapter recordings",
"scsh annotate-cast <cast…> [--json]",
&[
("<cast…>", "One or more .cast files to annotate via Codex on Luna."),
("--json", "Emit the written sidecar paths as JSON; on by default when stdout is not a TTY."),
("SCSH_ANNOTATE_MODEL", "Override the model (default gpt-5.6-luna)."),
],
),
"export-cast" => (
"render casts to offline HTML pages",
"scsh export-cast <cast…> [-o <file>] [--json]",
&[
("<cast…>", "One or more .cast files; each renders to <stem>.html next to it."),
("-o <file>", "Output path (exactly one cast); `-o -` streams the page to stdout."),
("--json", "Per-cast {input, output, bytes, chapters} JSON; on by default when stdout is not a TTY."),
("<stem>.chapters.json", "A summary+chapters sidecar next to the cast renders into the page when present;"),
("", "a malformed sidecar is a warning — the cast still exports without it."),
("(license)", "Exported pages embed the first-party beecast-player — everything is MIT,"),
("", "no third-party code or license in any page. See LICENSE.md."),
],
),
"version" => {
("print the version", "scsh version", &[("(no flags)", "Print the version with the build's git hash.")])
}
_ => ("", "scsh help", &[]),
};
println!("{} {}", h_head(name), console::style(format!("\u{2014} {tagline}")).bold());
println!();
println!("{}", h_head("Synopsis"));
println!("{}", h_dim(&format!(" {synopsis}")));
println!();
if !body.is_empty() {
println!("{}", h_head("Flags & arguments"));
for (flag, desc) in body {
help_row(flag, desc);
}
println!();
}
println!("{}", h_dim(" See `scsh help` for all commands, `scsh help exitcodes` for exit codes."));
println!();
}
fn print_help_overview() {
println!();
println!(
"{} {} {}",
console::style("scsh").cyan().bold(),
h_dim(&version_id()),
console::style("\u{2014} Scoped Skills Helper").bold()
);
println!("{}", h_dim("Run a git repo's scoped skills in parallel \u{2014} each in its own ephemeral"));
println!("{}", h_dim("container, on a clean clone of your repo \u{2014} all from one .scsh.yml."));
println!();
println!(
"{} {} {}",
h_head("Usage:"),
console::style("scsh <command> [options]").bold(),
h_dim("\u{2014} a bare `scsh` prints this help")
);
println!();
println!("{}", h_head("Commands:"));
help_row("run [profile…]", "Build the image; run skills in parallel.");
help_cont("See `scsh help run` for profiles, preflight, and exit codes.");
help_row("list (ls)", "List skills by profile (--verbose, --json).");
help_row("build-images [harness…]", "Build the base + harness images outside a run (--force, --rebuild-base).");
help_row("check-profile <name>", "Exit 0 when the profile exists and has skills.");
help_row("probe [profile…]", "Exit 0 when at least one harness·model route is runnable on this host.");
help_row("init-demo-project", "Scaffold and commit a demo project.");
help_row("init-beautiful-demo", "Scaffold and commit the code-review loop demo.");
help_row("demo [name]", "Print an embedded agent-followable walkthrough (no name lists them).");
help_row("installskills [url…]", "Install skills (bundled or from git URLs); --global installs machine-wide.");
help_row("updateskills [url…]", "Reinstall skills, overwriting local copies (--global for the machine-wide set).");
help_row("daemon", "start | stop | restart | status");
help_cont("Browse run output at http://127.0.0.1:7274 (override: SCSH_DAEMON_PORT).");
help_row("failures", "Browse the failure log (--session, --skill, --reason, --last, --stats).");
help_row("stats", "Durations & workload per skill/route (--skill, --profile, --harness, --model, --raw).");
help_row("prune [--now]", "Show the run-dir cleanup queue; --now forces a pass.");
help_row("gc [--apply]", "Reclaim old $SCSH_HOME/sessions/ dirs (dry-run default; --days/--keep/--legacy).");
help_row("annotate-cast <cast…>", "Summarize + chapter recordings via Codex / Luna (--json).");
help_row("export-cast <cast…>", "Render recordings to self-contained offline HTML pages (-o, --json).");
help_row("version", "Print the version (with the build's git hash).");
help_row("help [topic]", "Show this help, or one of the topics below.");
println!();
println!("{}", h_head("More help:"));
help_row("scsh help agent", "Driving scsh from another agent or harness? Start here.");
help_row("scsh help <command>", "Focused help for any command above (e.g. `scsh help stats`).");
help_row("scsh help run", "How to run skills: profiles, preflight, exit codes, env vars.");
help_row("scsh help .scsh.yml", "The project config file: every field + env syntax.");
help_row("scsh help internals", "How a run works: clone, containers, auth, live board.");
help_row("scsh help cache", "How results are cached, and when a re-run is a hit.");
help_row("scsh help def", "Harness definitions: flat tasks, workflow steps, loops (repeat / do-while).");
help_row("scsh help exitcodes", "The exit-code table (0 ok, 1 failure, 2 usage).");
println!();
println!("{}", h_head("Options:"));
help_row("--profile <names>", "With `run`: only these profiles (`default` = no-profile skills).");
help_row("--override-dot-scsh-yml <path>", "With `run`/`list`/`check-profile`: use this `.scsh.yml`");
help_cont("(+ sibling `.skills/`) instead of the repo's — no install into the target tree.");
help_row("--verbose", "With list: also print the Dockerfile and exact commands.");
help_row("--json", "With list: print profiles and skills as JSON.");
println!();
println!("{}", h_dim("`run` bakes a dev toolchain into the image (python3/uv, Go, Rust, gh, aws, gcloud,"));
println!("{}", h_dim("kubectl, psql, protoc, \u{2026}; no Java) and builds it with this machine's timezone."));
println!("{}", h_dim("Full toolchain list: scsh help internals."));
println!();
println!("{} {}", h_dim("Aliases:"), h_dim("--help/-h \u{00b7} --version/-V \u{00b7} --init-demo-project"));
println!();
}
fn print_help_run() {
println!();
println!("{} {}", h_head("run"), console::style("\u{2014} run scoped skills in parallel").bold());
println!();
println!("{}", h_head("Synopsis"));
println!("{}", h_dim(" scsh run [profile…] [--override-dot-scsh-yml <path>]"));
println!();
println!("{}", h_head("Discover what to run (before `run`)"));
help_row("scsh list", "Every skill by profile — result path, harness, env (human-readable).");
help_row("scsh list --json", "Same, as JSON — preferred for scripts and agents.");
help_row("scsh check-profile <name>", "Exit 0 iff that profile exists with at least one skill (no runtime).");
help_row("scsh probe [profile…]", "Exit 0 iff at least one of its harness·model routes is runnable here.");
println!();
println!("{}", h_head("External config (no install into the target repo)"));
help_row("--override-dot-scsh-yml <path>", "Use this `.scsh.yml` and its sibling `.skills/` instead of the repo's.");
println!("{}", h_dim(" The target tree stays clean: skill bodies are injected into the run clone only."));
println!("{}", h_dim(" Works with `run`, `list`, and `check-profile`. Mutually exclusive with `--def`."));
println!("{}", h_dim(" Without the flag, a profile the repo's .scsh.yml does not declare falls back to the"));
println!("{}", h_dim(" global manifest $SCSH_HOME/.scsh.yml (installed by `scsh installskills --global`);"));
println!("{}", h_dim(" the repo's own config always wins for the profiles it declares."));
println!();
println!("{}", h_head("Profile selection"));
println!("{}", h_dim(" Skills with no `profile:` belong to the reserved `default` profile."));
println!("{}", h_dim(" A skill with `profile: X` runs only when you select profile X."));
help_row("scsh run", "Run `default` only (skills with no profile).");
help_row("scsh run code-review", "Run one named profile.");
help_row("scsh run a b", "Run several profiles (same as `scsh run --profile a,b`).");
help_row("scsh run --profile a,b", "Comma/semicolon-separated profile list.");
println!("{}", h_dim(" If every skill is profiled, bare `scsh run` is a no-op that lists profiles."));
println!();
println!("{}", h_head("Preflight (fails fast — message names one fix)"));
print!(
r#" 1. git is installed
2. current directory is inside a git repository
3. working tree is clean (scsh clones COMMITTED state only)
4. .scsh.yml exists and matches the schema
5. tmp/ is gitignored (build scratch + results stay untracked)
6. a container runtime is available (macOS: container → docker → podman;
otherwise docker → podman; override with SCSH_RUNTIME=<name>)
7. the runtime engine is running (scsh prints how to start it)
"#
);
println!();
println!("{}", h_head("Watch it live"));
println!("{}", h_dim(" Every run registers with the session browser and prints a clickable deep link"));
println!("{}", h_dim(" (http://127.0.0.1:7274/job/<id>, port from SCSH_DAEMON_PORT) at the start"));
println!("{}", h_dim(" AND as one of the last lines — recordings, logs, and live TUIs live there."));
println!();
println!("{}", h_head("What `run` does (summary)"));
println!("{}", h_dim(" Builds one image per harness needed (`scsh-opencode`, `scsh-claude`), then runs"));
println!("{}", h_dim(" every selected skill in parallel — each in its own container. On Linux/docker/podman"));
println!("{}", h_dim(" the run dir is bind-mounted at /home/agent/repo; on macOS Apple Container scsh"));
println!("{}", h_dim(" git-pushes into a bare repo and the container clones via a local git daemon."));
println!("{}", h_dim(" Skills must not git fetch/pull remotes inside. After exit, scsh copies each"));
println!("{}", h_dim(" Skills with `commits: true` may also bring commits back via local cherry-pick."));
println!(
"{}",
h_dim(
" Unavailable harnesses and opencode models are skipped; \
the run fails only when every selected skill is skipped.",
)
);
println!();
println!("{}", h_head("Exit codes"));
help_row("0", "Every selected skill that ran finished successfully (skipped harnesses are OK).");
help_row("non-zero", "At least one skill failed, or every selected skill was skipped/unavailable.");
println!();
println!("{}", h_head("Useful environment variables"));
help_row("SCSH_RUNTIME", "Force container runtime: docker, podman, or container (Apple).");
help_row(
"SCSH_GIT_TRANSPORT",
"Force git push/fetch transport (1) or bind-mount clone (0). Ignored on macOS Apple Container.",
);
help_row(
runtime::GIT_TRANSPORT_HOST_ENV,
"Override git-daemon host IP inside the container (default: ip route gateway).",
);
help_row("SCSH_KEEP_RUNS=1", "Keep every /tmp/scsh-*-run-* clone (also skips stale sweep).");
help_row("SCSH_NO_OPENCODE_AUTH=1", "Do not forward opencode credentials into containers.");
help_row("SCSH_NO_CLAUDE_AUTH=1", "Do not forward Claude credentials into containers.");
help_row("SCSH_NO_CURSOR_AUTH=1", "Do not forward Cursor credentials into containers.");
println!();
println!("{}", h_head("After a run"));
println!("{}", h_dim(" Read each skill's declared `result` path (usually under tmp/). On failure, scsh"));
println!("{}", h_dim(" prints the kept run-clone path — inspect tmp/scsh-run.log there for full harness output."));
println!();
println!("{}", h_head("See also"));
help_row("scsh help internals", "Repo sync, auth forwarding, live board, image contents.");
help_row("scsh help .scsh.yml", "Config schema: harness, invocations, env, commits, timeout.");
help_row("scsh help cache", "When an identical re-run is served from tmp/.sccache/.");
println!();
}
#[allow(clippy::print_literal)]
fn print_help_config() {
println!();
println!("{} {}", h_head(".scsh.yml"), console::style("\u{2014} the project config file").bold());
println!("{}", h_dim("The whole file is just your skills; scsh owns the container command. The base"));
println!(
"{}",
h_dim("image is built in (Debian + shared base + per-harness CLI) — no version/project/image header.")
);
println!();
print!(
"{}",
r#" terminal: # optional: PTY size for harness runs (and their .cast recordings)
cols: 200 # default 200 (20..500)
rows: 50 # default 50 (10..200)
skills: # one entry per .skills/<name>/ folder
add: # key must match the skill directory name
harness: opencode # direct run — OR use invocations: for a matrix (below)
# harnesses: opencode | claude | codex | grok | cursor
model: openai/... # optional; the model the harness passes to the tool
effort: high # optional; reasoning effort (codex: minimal..xhigh, grok:
# low..max, cursor: low..high as --model slug suffixes). With
# invocations: a default routes may override; harnesses
# without an effort knob ignore it
timeout: 600 # optional; seconds — kill the container & fail if exceeded
inactivity_timeout: 1800 # optional; seconds the recorded screen may show nothing new
# before the run is killed as stuck. Default 1800 (30 minutes).
# Per-route override
# under invocations: is allowed too.
env: # optional; host vars to forward (-e) into the container
- A: ${A} # require A — refuse the skill if A is unset
- B: ${B:-5} # forward B, or inject the default 5 when unset
- X: ${X:?msg} # require X, refusing with your message
profile: extra # optional; run only under --profile extra (not by default)
commits: true # optional; bring commits the skill makes back onto your
# branch (rebased; or saved to scsh/incoming/<skill>-…
# if they don't apply cleanly). A real, repeatable side
# effect — run twice and you get the commit twice.
autoinstall: false # optional; default true. false = authoring-only: `installskills`
# won't copy it into a consumer repo (an `internal-` name does the same)
invocations: # optional matrix — each route expands to `{skill}-{route}`
opencode-gpt: # at run and install time; per-route profile/commits/
# inactivity_timeout override
harness: opencode
model: openai/...
result: tmp/x.json # required; use {name} in the path when `invocations:` is set
"#
);
println!();
println!("{}", h_head("Env value syntax"));
println!("{}", h_dim(" scsh resolves each value on the host, then forwards it (or refuses the skill):"));
help_row("${VAR} or $VAR", "require VAR; refuse the skill if it is unset.");
help_row("${VAR:-default}", "forward VAR, or inject `default` when unset (${VAR:-} = empty).");
help_row("${VAR:?message}", "require VAR; refuse with your `message` if unset.");
help_row("literal", "a bare value like `A: A` is the literal string \"A\".");
println!();
println!();
println!("{}", h_head("Profiles"));
println!("{}", h_dim(" No `profile:` = the reserved `default` profile (runs on a bare `scsh run`). A skill"));
println!("{}", h_dim(" with `profile: X` runs only under `--profile X`; pass a list (`--profile a,b`) to run"));
println!("{}", h_dim(" several. If every skill is profiled, `scsh run` is a no-op that lists the profiles."));
println!("{}", h_dim(" Discover them programmatically (runtime-free): `scsh list --json`, or gate a script on"));
println!("{}", h_dim(" `scsh check-profile <name>` (exit 0 iff that profile exists with at least one skill)."));
println!();
println!("{}", h_head("Sharing skills (install sources)"));
println!("{}", h_dim(" When another repo runs `scsh installskills <this-repo>`, scsh installs every skill in"));
println!("{}", h_dim(" this manifest EXCEPT those marked `autoinstall: false` or named `internal-*` (both"));
println!("{}", h_dim(" authoring-only), merging the rest verbatim into that repo's own .scsh.yml."));
println!("{}", h_dim(" Existing skill keys in the consumer are left untouched — scsh warns on conflicts."));
println!();
println!("{}", h_dim("Harness commands (inside the container, all recorded as interactive TUIs):"));
println!("{}", h_dim(" opencode: opencode -m <model> --prompt \"Run the skill defined in .skills/<source>/…\""));
println!(
"{}",
h_dim(
" claude: claude --permission-mode bypassPermissions \"Run the skill defined in .skills/<source>/…\" \
(CLAUDE_CODE_OAUTH_TOKEN or ~/.claude/.credentials.json)",
)
);
println!(
"{}",
h_dim(
" codex: codex --dangerously-bypass-approvals-and-sandbox -m <model> \"Run the skill defined in .skills/<source>/…\" \
(~/.codex/auth.json or OPENAI_API_KEY)",
)
);
println!(
"{}",
h_dim(
" grok: grok --always-approve -m <model> --effort <level> \"Run the skill defined in .skills/<source>/…\" \
(~/.grok/auth.json or XAI_API_KEY)",
)
);
println!(
"{}",
h_dim(
" cursor: cursor-agent --force --sandbox disabled --model <model> \"Run the skill defined in .skills/<source>/…\" \
(macOS keychain / auth.json / CURSOR_API_KEY)",
)
);
println!();
}
fn print_help_internals() {
println!();
println!("{} {}", h_head("Internals"), console::style("\u{2014} how a run works").bold());
println!();
println!("{}", h_head("Preflight order"));
println!("{}", h_dim(" A real `run` is repo-hygiene-first and fails in this order; the message names"));
println!("{}", h_dim(" exactly what's wrong and the one command to fix it."));
print!(
r#" 1. git is installed
2. the current directory is inside a git repository
3. the working tree is clean (run only; scsh clones COMMITTED state)
4. .scsh.yml exists, and matches the schema
5. /tmp is gitignored (run only; build scratch + results stay untracked)
6. a container runtime is available (macOS: container -> docker -> podman;
otherwise docker -> podman; override with SCSH_RUNTIME=podman)
7. the runtime's engine is running (run only; scsh prints how to start it)
(list runs only the non-run checks: git, repo, config, runtime.)
"#
);
println!();
println!("{}", h_head("How a run works"));
print!(
r#" scsh builds the shared base image (`scsh-base:latest`) first — or skips it when the tag
already matches the embedded Dockerfile fingerprint — then builds the needed per-harness
images (`scsh-opencode`, `scsh-claude`, `scsh-codex`, `scsh-grok`, `scsh-cursor`) on top IN PARALLEL,
skipping any whose tag already matches. Each build is version-checked during the build. Then, for
EVERY selected skill in parallel, it prepares a /tmp run dir (scsh-YYYYMMDD-HHMMSS-utc-run-<invocation> on docker/podman,
or scsh-<nonce>-run-<invocation> on Apple container — ≤ 64 chars, middle-truncated with .. when
needed) and runs the skill's harness in its own container. On docker/podman/Linux the host
git-clones into the run dir and bind-mounts it at /home/agent/repo. On macOS Apple Container
scsh git-pushes into a bare transport repo and the container clones from a per-run git daemon
(only run_dir/tmp is bind-mounted — results, logs, forwarded auth). The repo lives UNDER the
agent's home, not as it, so harness scratch stays out of the tree.
scsh injects SCSH_RESULT=<result path> into every container so one skill folder can
serve multiple invocations with different result files.
Repo sync — push IN, pull OUT (never GitHub from inside the container):
Host push IN: git clone + bind-mount (docker/podman/Linux), or git push to transport.git +
container git clone via git:// (Apple Container on macOS). Skills must not git fetch, pull,
push, or clone remotes inside. After the container exits, scsh on the HOST pulls OUT: (1) the
result file — always (from bind-mounted tmp/); (2) new commits — only when commits: true AND
the skill committed — via local git fetch from the run clone or pull.git and cherry-pick.
scsh never pushes to any remote. Reviewer skills are review-only (no commits).
Each skill MUST produce its declared `result` file. Missing after the container
exits -> that skill fails and the whole invocation exits non-zero; otherwise scsh
copies the result back into your repo, moving any existing file aside to
<name>.bak.YYYYMMDD-HHMMSS-utc. All skills run regardless, so one run reports
every skill's outcome.
Auth: opencode skills copy the host ~/.local/share/opencode/auth.json and
~/.config/opencode/opencode.json (plus optional opencode.jsonc) into each run clone,
then bind-mount from there (needed for custom providers such as Nebius GLM;
opt out: SCSH_NO_OPENCODE_AUTH=1).
Claude skills use host CLAUDE_CODE_OAUTH_TOKEN (from `claude setup-token`) and/or
~/.claude/.credentials.json, copied into the run dir and bind-mounted into the container
(opt out: SCSH_NO_CLAUDE_AUTH=1).
Codex skills copy the host ~/.codex/auth.json and config.toml (from `codex login`) into the
run clone's gitignored tmp/.codex — the image's CODEX_HOME — and forward OPENAI_API_KEY when
set; the credentials are scrubbed from the run dir after the container exits
(opt out: SCSH_NO_CODEX_AUTH=1). Codex is the recommended native harness for GPT models.
Grok skills work the same way: host ~/.grok/auth.json + config.toml (from `grok login`)
are copied into tmp/.grok — the image's GROK_HOME — XAI_API_KEY is forwarded when set,
and credentials are scrubbed after exit (opt out: SCSH_NO_GROK_AUTH=1). Grok is the
recommended native harness for Grok models.
Cursor skills copy the host ~/.cursor/cli-config.json and optional mcp.json into tmp/.cursor,
OAuth tokens from ~/.config/cursor/auth.json or the macOS login keychain into tmp/.config/cursor/auth.json,
CURSOR_API_KEY is forwarded when set, and credentials are scrubbed after exit (opt out: SCSH_NO_CURSOR_AUTH=1). Cursor is
the native harness for Cursor Agent models (Composer, etc.).
Harness runs at full verbosity (OpenCode DEBUG + --print-logs; Claude --verbose --debug;
Codex RUST_LOG tracing + its final message appended to the log; Grok --debug + its debug
log appended; Cursor --output-format stream-json);
every line is teed to tmp/scsh-run.log and the session browser daemon (opt out: SCSH_QUIET=1).
A transient infra failure (timeout, provider overload, container/clone error) is retried once on a fresh
clone (opt out: SCSH_NO_RETRY=1); failures land in `scsh failures` with stable reason codes.
Every skill outcome is also recorded durably in ~/.scsh/stats.jsonl — route, duration,
attempts, and the branch workload (commits + LOC over main) — browse with `scsh stats`.
Unavailable harnesses and opencode models are skipped; a run fails only when every selected skill is skipped.
Every line of harness output is teed to <run_dir>/tmp/scsh-run.log for inspection.
The Dockerfile is embedded at compile time (`src/Dockerfile`). docker/podman get it
on stdin; Apple Containers gets a comment-stripped copy written to a temp context dir
(Apple has no stdin build, and rejects Dockerfiles ≥ 16KB — apple/container#735).
Your repository is modified only by the result copies (into the gitignored tmp/).
Cleanup: a skill's container is --rm, and its /tmp clone is host-side scratch. After a
SUCCESSFUL skill scsh removes that clone; a FAILED skill's clone is kept for inspection
(its path is printed). Stale clones from past runs (>24h old) are swept at the next run's
start. Keep every clone with SCSH_KEEP_RUNS=1 (also skips the sweep).
The live board: on a terminal the build and every skill are drawn as collapsible rows,
inline in the normal buffer (no alternate screen, so your scrollback keeps working). Each row
carries a [0]..[9], [A]..[Z] label on the left: press that digit or letter to expand/collapse the row
(scsh turns on the terminal's keyboard-enhancement protocol so Ctrl+digit works too; without it, the
plain digit toggles — or click the row if the mouse is forwarded). Expanding shows the proc's
output, each line stamped with its time relative to that proc's start. SCROLL with the wheel,
↑↓, PgUp/PgDn or Home/End (e/c expand/collapse all; Ctrl-C aborts). On finish scsh wipes the
live region and leaves a compact ✓/✗ summary. Off a TTY it falls back to plain ▶ / ✓ / ✗ lines.
"#
);
println!();
println!("{}", h_head("What's in the image"));
print!(
r#" A glibc Debian-slim base, baked with a broad dev/CLI toolchain so skills work with
no setup step. Built once, then cached and reused (the first run does the build):
languages/build python3 (+ uv), Go, Rust (cargo), C/C++ (gcc/g++/make/cmake),
perl, gawk, node
harness images scsh-opencode (+ opencode-ai), scsh-claude (+ @anthropic-ai/claude-code)
data/CLI jq, yq, ripgrep, shellcheck, git (+ git-lfs), gh, sqlite3,
psql, protoc, curl/wget, tar/gzip/xz/zip/unzip, patch, tree
cloud aws (v2), gcloud + gsutil, kubectl
networking ping, traceroute, dig/nslookup, nc, ss/ip, whois, socat
Java is intentionally NOT installed (nothing here is JVM; a JDK adds ~300 MB).
The image is built with the TIMEZONE OF THE MACHINE BUILDING IT (scsh passes the
host's TZ as a build arg), so timestamps a skill produces match your machine.
It is platform-agnostic: the same Dockerfile builds on x86_64 and arm64 (arch is
resolved at build time, no hardcoded-arch downloads). On macOS, Apple Containers is
the default runtime — keep src/Dockerfile under 15KB so builds stay under Apple's
16KB gRPC header limit.
"#
);
println!();
}
fn print_help_defs() {
println!();
println!(
"{} {}",
h_head("Harness definitions"),
console::style("\u{2014} run one task or a workflow, no .scsh.yml needed").bold()
);
println!();
println!("{}", h_dim(" A definition is a YAML file describing a runnable job: `scsh run --def <name>`."));
println!(
"{}",
h_dim(" Discovery (later shadows earlier): built-ins < ~/.harness/<name>.yml < <repo>/.harness/<name>.yml.")
);
println!("{}", h_dim(" Params are declared under `params:` and supplied as environment variables at run time."));
println!();
println!("{}", h_head("Flat form — one task, many agents"));
print!(
r#" description: "…" one line, shown in listings
params: typed inputs (string/int/bool/enum), optional `default:`
A: {{ type: int, default: 2 }}
task: | the prompt; materialized into the run clone as .skills/<name>/SKILL.md
invocations: the agent matrix — same schema as a .scsh.yml skill's invocations
c: {{ harness: claude, model: sonnet }}
"#
);
println!();
println!("{}", h_head("Workflow form — a DAG of steps"));
print!(
r#" steps: a block map; each step runs on its own agent, in its own clone
<id>:
agent: harness: claude|codex|cursor|grok|opencode; optional model:, effort:
prompt: | intent only — scsh appends the machine I/O contract
inputs: env vars for the step: NAME: params.X or NAME: stepid.field
output: typed result fields the step must write to $SCSH_RESULT (JSON)
n: {{ type: int }} types: string | int | bool | enum (with `choices: a, b, c`)
needs: a, b DAG edges — steps whose completion this step waits for
when: gate — run only if every condition holds (else the step is skipped)
a.kind: code scalar = equality; or one operator: eq/ne/lt/lte/gt/gte/in
artifacts: out.txt extra files written next to $SCSH_RESULT, copied to the session dir
commits: true bring the step's commits back onto the caller's branch (packdiff'd)
"#
);
println!();
println!("{}", h_head("Loops"));
print!(
r#" repeat: 3 run this one step N times, sequentially — each iteration is its
own run and commit boundary; the job graph grows as iterations start.
do-while: <first-step> on the loop body's FINAL step, naming the body's FIRST step. The body
(everything between them, per `needs`) runs at least once, then repeats
while the final step's result JSON sets the boolean
`SCSH_DO_WHILE_REPEAT` to true. scsh has no comparison language here —
an agent decides. A backstop caps runaway loops at 25 iterations.
break: true on the loop body's FIRST step, lets that step exit before the rest of
the body runs. It must declare the boolean output `SCSH_LOOP_BREAK`;
true exits this loop, false continues through the body normally.
Loop-carried inputs: a body step may reference the FINAL step's output with no `needs:` edge
— it receives the PREVIOUS iteration's value (empty on round one). This
is the loop's data channel: feedback flows between rounds as typed
outputs under gitignored tmp/, never as committed files.
"#
);
println!();
println!("{}", h_head("Retries and resume"));
print!(
r#" A failed step retries automatically — fresh clone, fresh container, a new attempt row
linked to the failed one — for as long as its wall-clock retry budget allows (default
30m; provider incidents are measured in hours, so retries are too). Retryable:
container/runtime trouble, provider overload/disconnects, and non-zero harness exits (a
harness dying at startup is infrastructure). Retries back off exponentially with jitter;
a route failing the SAME way 5 times in a row trips a circuit breaker instead of burning
tokens until dawn. An invalid result gets one schema-correction retry. Tune per skill,
route, or def step with retry_for: (90s/45m/8h) and retry_signature_cap:, or run-wide
with SCSH_RETRY_FOR / SCSH_RETRY_SIGNATURE_CAP; SCSH_NO_RETRY=1 disables everything.
Beyond one run, every job is presumed worth finishing: on terminal failure the daemon
restarts it (resuming completed workflow steps) with 5m→60m backoff, up to the job's
retries budget — 10 by default, `scsh run --retries N` or the browser start form's
retries field to change it, 0 to opt out — stopping early after 3 identical job
failures or a human's Force stop.
scsh run --def <name> --resume-from <session> restores every step whose validated result
the named session already produced (loop iterations included, commits already on the branch)
and runs only the rest. The session browser's "Restart remaining" button on a failed job
does exactly this; "Restart from scratch" re-runs everything.
"#
);
println!();
println!(
"{}",
h_head("Executable examples (built in — read them with `scsh run --def <name>` or in src/harness_defs/)")
);
help_row("demo-loop-repeat", "Fixed loop: increment and commit number.txt three times.");
help_row("demo-loop-do-while", "Agent-driven loop: increment until a compare step says stop.");
help_row("demo-loop-break", "Early exit: check first, then skip the rest of the loop body.");
help_row("demo-beautiful-loop", "Review loop: initial panel -> decide -> fix -> fresh panel; decide can break");
help_cont("before any fix. Feedback rides the loop-carried channel, so review comments never");
help_cont("enter git history.");
help_row("gorgeous-pipeline", "The review loop on YOUR branch: prepare PR-DESCRIPTION.md, then the 15-route");
help_cont("Opus/Codex/Cursor fleet loops decide -> fix -> review until the approval bar is met.");
println!();
}
fn print_help_cache() {
println!();
println!("{} {}", h_head("Cache"), console::style("\u{2014} content-addressed skill results").bold());
println!();
println!("{}", h_dim("scsh caches each skill's result and reuses it when nothing that matters changed —"));
println!("{}", h_dim("a cache hit returns the result instantly, with no clone, no container, no model call."));
println!();
println!("{}", h_head("The cache key"));
print!(
r#" Before running a skill, scsh hashes (sha256) a deterministic blob of:
• the repo's committed content (the git HEAD tree),
• the skill's own files (SKILL.md + scripts), and
• the resolved environment forwarded to the skill (sorted).
Same commit + same skill + same env => same key => a hit. Change any of them
(edit a file, pass A=9, tweak the skill) and the key changes => a miss.
"#
);
println!();
println!("{}", h_head("Where it lives"));
print!(
r#" Under the repo's gitignored tmp/: tmp/.sccache/<sha256>.json (plus .cast / .chapters.json),
and nowhere else. Each entry holds the skill's result, when it was cached, the original
duration and recording, chapters once annotation finishes, AND — for a commit-enabled skill —
the commits it made (journaled as a git patch). On a hit scsh restores the result file, prints
it with "(cached · <when>)", restores the cast + chapters, and replays any journaled commits;
on a miss it runs and stores them.
"#
);
println!();
println!("{}", h_head("Commits are journaled and replayed (a hit reproduces them)"));
print!(
r#" A commit-enabled skill (commits: true) changes the repo when it commits, so the very
next run sees a NEW HEAD tree => a different key => a miss => it runs (and commits) again.
But the commits ARE journaled in the cache. Revert to the same committed state (e.g.
git reset --hard to before the skill's commit) and run again => the key matches => a HIT:
scsh restores the result AND replays the journaled commits, so the commit reappears on top.
A hit reproduces the full side effect, not just the result. (If a replay can't apply
cleanly, scsh saves the patch under tmp/.sccache/ and leaves your branch alone.)
"#
);
println!();
println!("{}", h_head("The author you'll recognize (a tripwire)"));
print!(
r#" scsh stamps the commits a skill makes with a deliberately unmistakable author —
dkorolev-neon-elon-bot <dmitry.korolev+elon-presley@gmail.com> (yes, a neon-cyberpunk
Elon). It is never a real contributor. These commits are LOCAL-ONLY by design: scsh
rebases them onto your branch, it never pushes. So if that face ever shows up in a code
review or a pushed commit list, that's your signal — you pushed something you shouldn't
have. Go check.
"#
);
println!();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn apple_shell_response_recovery_requires_valid_json_and_inner_exit_zero() {
assert!(apple_container_lost_shell_response(Some(
r#"failed to send signal: ["signal": 15, "error": invalidArgument: "missing signal in xpc message"]"#
)));
assert!(!apple_container_lost_shell_response(Some("ordinary harness failure")));
let dir = std::env::temp_dir().join(format!("scsh-graceful-{}", runtime::random_nonce_6()));
std::fs::create_dir_all(dir.join("tmp/scsh/job")).unwrap();
std::fs::write(dir.join(format!("{}.exit", runtime::RUN_LOG_REL)), "0\n").unwrap();
std::fs::write(dir.join("tmp/scsh/job/result.json"), r#"{"result":"ok"}"#).unwrap();
assert!(inner_harness_result_is_good(&dir, "tmp/scsh/job/result.json", false));
std::fs::write(dir.join(format!("{}.exit", runtime::RUN_LOG_REL)), "1\n").unwrap();
assert!(!inner_harness_result_is_good(&dir, "tmp/scsh/job/result.json", false));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn interrupted_harness_with_a_result_rejoins_the_normal_validation_path() {
let dir = std::env::temp_dir().join(format!("scsh-inactive-result-{}", runtime::random_nonce_6()));
let result = "tmp/scsh/job/result.json";
std::fs::create_dir_all(dir.join("tmp/scsh/job")).unwrap();
assert!(!interrupted_harness_result_is_recoverable(&dir, result));
std::fs::write(dir.join(result), "not valid JSON yet").unwrap();
assert!(interrupted_harness_result_is_recoverable(&dir, result));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn workflow_results_are_strictly_typed_before_they_become_terminal() {
let outputs = [
harness_def::OutputField {
name: "grade".into(),
ty: harness_def::OutputType::Enum,
choices: vec!["excellent".into(), "good".into()],
},
harness_def::OutputField { name: "comments".into(), ty: harness_def::OutputType::StringList, choices: vec![] },
];
let contract = WorkflowResultContract { outputs: &outputs, require_do_while_repeat: true };
let valid =
extract_step_outputs(r#"{"grade":"good","comments":["one","two"],"SCSH_DO_WHILE_REPEAT":false}"#, contract)
.unwrap();
assert_eq!(valid.get("grade").map(String::as_str), Some("good"));
assert_eq!(valid.get("comments").map(String::as_str), Some(r#"["one","two"]"#));
assert_eq!(valid.get("SCSH_DO_WHILE_REPEAT").map(String::as_str), Some("false"));
let missing = extract_step_outputs(r#"{"grade":"good","comments":[]}"#, contract).unwrap_err();
assert!(missing.contains("SCSH_DO_WHILE_REPEAT") && missing.contains("missing"));
let wrong =
extract_step_outputs(r#"{"grade":"good","comments":"one\n\ntwo","SCSH_DO_WHILE_REPEAT":false}"#, contract)
.unwrap_err();
assert!(wrong.contains("array of strings"));
let extra = extract_step_outputs(
r#"{"grade":"good","comments":[],"comment_count":0,"SCSH_DO_WHILE_REPEAT":false}"#,
contract,
)
.unwrap_err();
assert!(extra.contains("undeclared field") && extra.contains("comment_count"));
}
#[test]
fn workflow_schema_repair_keeps_the_invocation_identity_and_names_the_error() {
let definition = harness_def::builtin_defs()
.into_iter()
.find_map(|(name, source)| {
(name == "demo-loop-do-while").then(|| harness_def::validate(name, source, harness_def::DefSource::Builtin))
})
.expect("built-in definition exists")
.expect("built-in definition validates");
let step = definition.steps.first().expect("demo has a first step");
let invocation = step_invocation(step, "increment", "tmp/scsh/session", Vec::new());
let repaired = schema_repair_invocation(&invocation, "result is missing the 'value' field");
assert_eq!(repaired.name, invocation.name);
assert_eq!(repaired.result, invocation.result);
match repaired.delivery {
config::SkillDelivery::DirectPrompt(prompt) => {
assert!(prompt.contains("Result correction retry"));
assert!(prompt.contains("missing the 'value' field"));
assert!(prompt.contains("only correction retry"));
}
_ => panic!("workflow steps always use a direct prompt"),
}
}
#[test]
fn retry_decision_is_budgeted_breaker_capped_and_browser_restarts_always_win() {
use RetryDecision::{Automatic, Browser, Schema, Stop, StopBreaker};
let policy = failure::RetryPolicy {
budget_secs: 30 * 60,
backoff_initial_secs: 60,
backoff_cap_secs: 15 * 60,
signature_cap: 5,
};
let decide = |reason: &'static str, spent: u64, identical: u32| {
retry_decision(Some(reason), false, false, policy, spent, identical, true, true, true)
};
assert_eq!(decide(failure::reason::HARNESS_NONZERO, 0, 1), Automatic);
assert_eq!(decide(failure::reason::CONTAINER_TIMEOUT, 29 * 60, 1), Automatic);
assert_eq!(decide(failure::reason::CONTAINER_TIMEOUT, 30 * 60, 1), Stop);
assert_eq!(decide(failure::reason::HARNESS_DISCONNECTED, 0, 1), Automatic);
assert_eq!(decide(failure::reason::HARNESS_NONZERO, 0, 5), Automatic);
assert_eq!(decide(failure::reason::HARNESS_NONZERO, 0, 6), StopBreaker);
assert_eq!(decide(failure::reason::RESULT_INVALID, 0, 1), Stop);
assert_eq!(
retry_decision(Some(failure::reason::RESULT_INVALID), false, true, policy, 0, 1, true, true, true),
Schema
);
assert_eq!(
retry_decision(Some(failure::reason::CONTAINER_TIMEOUT), false, false, policy, 0, 1, false, true, true),
Stop
);
assert_eq!(
retry_decision(Some(failure::reason::HARNESS_NONZERO), true, false, policy, u64::MAX, 99, false, true, true),
Browser,
"a browser restart ignores budget, breaker, and SCSH_NO_RETRY"
);
assert_eq!(
retry_decision(Some(failure::reason::HARNESS_AUTH_REJECTED), false, false, policy, 0, 1, true, true, true),
Stop
);
assert_eq!(
retry_decision(Some(failure::reason::HARNESS_AUTH_REJECTED), false, false, policy, 0, 1, true, true, false),
Automatic
);
}
use std::ffi::OsString;
#[test]
fn daemon_version_staleness_nudges_only_on_a_real_mismatch() {
assert!(!daemon_version_is_stale("1.29.5", "1.29.5"));
assert!(!daemon_version_is_stale("1.29.5 (abc1234)", "1.29.5 (abc1234)"));
assert!(daemon_version_is_stale("1.29.4", "1.29.5"));
assert!(daemon_version_is_stale("1.29.5 (aaaaaaa)", "1.29.5 (bbbbbbb)"));
assert!(!daemon_version_is_stale("unknown (older than this feature)", "1.29.5"));
}
#[test]
fn stale_annotation_markers_self_heal_and_fresh_ones_block() {
let dir = std::env::temp_dir().join(format!("scsh-marker-{}", runtime::random_nonce_6()));
std::fs::create_dir_all(&dir).unwrap();
let cast = dir.join("run.cast");
std::fs::write(&cast, "{}\n").unwrap();
assert!(!annotation_in_progress(&cast));
let marker = annotation_marker(&cast);
std::fs::write(&marker, "").unwrap();
assert!(annotation_in_progress(&cast));
assert!(marker.exists(), "a fresh marker is honored, not deleted");
let stale = std::time::SystemTime::now() - (ANNOTATION_MARKER_TTL + Duration::from_secs(60));
std::fs::File::options().write(true).open(&marker).unwrap().set_modified(stale).unwrap();
assert!(!annotation_in_progress(&cast));
assert!(!marker.exists(), "the stale marker was removed");
assert!(!annotation_in_progress(&cast));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn parent_session_from_cast_path_extracts_id() {
assert_eq!(
parent_session_from_cast_path("/Users/x/.scsh/sessions/abcdef/casts/foo.cast").as_deref(),
Some("abcdef")
);
assert_eq!(parent_session_from_cast_path("/tmp/orphan.cast"), None);
assert_eq!(parent_session_from_cast_path("/sessions/"), None);
}
#[test]
fn atomic_write_lands_whole_and_failure_keeps_the_previous_file() {
let base = std::env::temp_dir().join(format!("scsh-atomicwrite-{}", runtime::random_nonce_6()));
std::fs::create_dir_all(&base).unwrap();
let path = base.join("state.txt");
atomic_write(&path, b"first").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"first");
atomic_write(&path, b"second").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"second");
assert_eq!(std::fs::read_dir(&base).unwrap().count(), 1);
let tmp = base.join(format!("state.txt.tmp.{}", std::process::id()));
std::fs::create_dir_all(&tmp).unwrap();
assert!(atomic_write(&path, b"third").is_err());
assert_eq!(std::fs::read(&path).unwrap(), b"second");
std::fs::remove_dir_all(&base).unwrap();
}
#[test]
fn persist_run_artifacts_copies_cast_and_logs_with_shared_stem() {
let _env = runtime::test_env_lock();
let base = std::env::temp_dir().join(format!("scsh-persist-test-{}", runtime::random_nonce_6()));
let home = base.join("scsh-home");
let run_dir = base.join("run");
std::fs::create_dir_all(run_dir.join("tmp")).unwrap();
std::fs::write(run_dir.join(runtime::RUN_CAST_REL), "cast-bytes").unwrap();
std::fs::write(run_dir.join(runtime::RUN_LOG_REL), "log-bytes").unwrap();
std::fs::write(run_dir.join(format!("{}.debug", runtime::RUN_LOG_REL)), "debug-bytes").unwrap();
std::fs::create_dir_all(&home).unwrap();
let prev = std::env::var_os("SCSH_HOME");
std::env::set_var("SCSH_HOME", &home);
let cast = persist_run_artifacts("sessab", &run_dir, "add", 1_700_000_000).unwrap();
match prev {
Some(v) => std::env::set_var("SCSH_HOME", v),
None => std::env::remove_var("SCSH_HOME"),
}
let casts_dir = home.join("sessions").join("sessab").join("casts");
assert!(cast.starts_with(casts_dir.to_string_lossy().as_ref()), "cast under sessions/<id>/casts: {cast}");
let stem = std::path::Path::new(&cast).file_stem().unwrap().to_string_lossy().into_owned();
assert!(stem.starts_with("add-"), "stem starts with skill name: {stem}");
assert_eq!(std::fs::read_to_string(&cast).unwrap(), "cast-bytes");
let logs = home.join("sessions").join("sessab").join("logs");
assert_eq!(std::fs::read_to_string(logs.join(format!("{stem}.log"))).unwrap(), "log-bytes");
assert_eq!(std::fs::read_to_string(logs.join(format!("{stem}.debug.log"))).unwrap(), "debug-bytes");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn opencode_auth_path_prefers_xdg_then_home() {
let xdg = OsString::from("/data");
let home = OsString::from("/home/u");
assert_eq!(runtime::opencode_auth_in(Some(&xdg), Some(&home)), Some(PathBuf::from("/data/opencode/auth.json")));
assert_eq!(
runtime::opencode_auth_in(None, Some(&home)),
Some(PathBuf::from("/home/u/.local/share/opencode/auth.json"))
);
let empty = OsString::from("");
assert_eq!(
runtime::opencode_auth_in(Some(&empty), Some(&home)),
Some(PathBuf::from("/home/u/.local/share/opencode/auth.json"))
);
assert_eq!(runtime::opencode_auth_in(None, None), None);
}
#[test]
fn sweep_removes_only_matching_stale_run_dirs() {
let base = std::env::temp_dir().join(format!("scsh-sweeptest-{}-{}", std::process::id(), now_secs()));
std::fs::create_dir_all(&base).unwrap();
let run = base.join("scsh-20231114-221320-utc-run-add");
let run_apple = base.join("scsh-abcdef-run-add");
let install = base.join("scsh-installskills-1-2");
let other = base.join("some-other-dir");
let run_file = base.join("scsh-19700101-000000-utc-run-x"); std::fs::create_dir(&run).unwrap();
std::fs::create_dir(&run_apple).unwrap();
std::fs::create_dir(&install).unwrap();
std::fs::create_dir(&other).unwrap();
std::fs::write(&run_file, b"").unwrap();
let now = now_secs();
assert_eq!(sweep_stale_run_dirs_in(&base, now, u64::MAX), 0);
assert!(run.is_dir());
assert_eq!(sweep_stale_run_dirs_in(&base, now, 0), 2);
assert!(!run.exists(), "the UTC-stamped run dir is removed");
assert!(!run_apple.exists(), "the Apple-container run dir is removed");
assert!(install.is_dir(), "a non run-dir scsh dir is left alone");
assert!(other.is_dir(), "an unrelated dir is left alone");
assert!(run_file.is_file(), "a matching *file* (not a dir) is left alone");
std::fs::remove_dir_all(&base).unwrap();
}
#[test]
fn run_positional_args_are_profiles() {
let cli = |a: &[&str]| parse_cli(&a.iter().map(|s| s.to_string()).collect::<Vec<_>>());
let c = cli(&["run", "foo"]).unwrap();
assert!(matches!(c.mode, Mode::Run));
assert_eq!(c.profile.as_deref(), Some("foo"));
assert_eq!(cli(&["run", "foo", "bar"]).unwrap().profile.as_deref(), Some("foo,bar"));
assert_eq!(cli(&["run", "--profile", "foo,bar"]).unwrap().profile.as_deref(), Some("foo,bar"));
assert_eq!(cli(&["run", "--profile", "foo", "bar"]).unwrap().profile.as_deref(), Some("foo,bar"));
assert_eq!(cli(&["run"]).unwrap().profile, None);
assert!(cli(&["foo"]).is_err(), "a bare token without `run` is an unknown command");
assert!(cli(&["list", "foo"]).is_err(), "profiles don't apply to `list`");
assert!(cli(&["run", "--nope"]).is_err(), "an unknown flag after `run` is not a profile");
}
#[test]
fn override_dot_scsh_yml_parses_on_run_list_check() {
let cli = |a: &[&str]| parse_cli(&a.iter().map(|s| s.to_string()).collect::<Vec<_>>());
let c = cli(&["run", "code-review", "--override-dot-scsh-yml", "/tmp/bundle/.scsh.yml"]).unwrap();
assert!(matches!(c.mode, Mode::Run));
assert_eq!(c.profile.as_deref(), Some("code-review"));
assert_eq!(c.override_dot_scsh_yml.as_deref(), Some(std::path::Path::new("/tmp/bundle/.scsh.yml")));
let c = cli(&["check-profile", "code-review", "--override-dot-scsh-yml", "/x.yml"]).unwrap();
assert!(matches!(c.mode, Mode::CheckProfile));
assert_eq!(c.override_dot_scsh_yml.as_deref(), Some(std::path::Path::new("/x.yml")));
assert!(cli(&["run", "--def", "add", "--override-dot-scsh-yml", "/x.yml"]).is_err());
assert!(cli(&["version", "--override-dot-scsh-yml", "/x.yml"]).is_err());
}
#[test]
fn retries_parses_on_run_only() {
let cli = |a: &[&str]| parse_cli(&a.iter().map(|s| s.to_string()).collect::<Vec<_>>());
let c = cli(&["run", "--def", "greet", "--retries", "3"]).unwrap();
assert!(matches!(c.mode, Mode::Run));
assert_eq!(c.retries, Some(3));
assert_eq!(cli(&["run", "--retries", "0"]).unwrap().retries, Some(0), "0 opts out of supervision");
assert_eq!(cli(&["run"]).unwrap().retries, None, "absent = the daemon's default budget");
assert!(cli(&["run", "--retries", "many"]).is_err(), "the count is a number");
assert!(cli(&["list", "--retries", "3"]).is_err(), "run-only flag");
}
#[test]
fn resume_from_parses_on_def_runs_only() {
let cli = |a: &[&str]| parse_cli(&a.iter().map(|s| s.to_string()).collect::<Vec<_>>());
let c = cli(&["run", "--def", "greet", "--resume-from", "qtsiuf"]).unwrap();
assert!(matches!(c.mode, Mode::Run));
assert_eq!(c.def.as_deref(), Some("greet"));
assert_eq!(c.resume_from.as_deref(), Some("qtsiuf"));
assert!(cli(&["run", "--resume-from", "qtsiuf"]).is_err(), "resume without --def has nothing to restore into");
assert!(cli(&["run", "code-review", "--resume-from", "qtsiuf"]).is_err(), "profiles have no resume");
assert!(cli(&["run", "--def", "greet", "--resume-from"]).is_err(), "the flag needs a session id");
assert!(cli(&["run", "--def", "greet", "--resume-from", " "]).is_err(), "…a non-empty one");
}
#[test]
fn workflow_steps_parse_retry_policy_keys() {
let yml = r#"description: "retry keys"
steps:
one:
agent:
harness: claude
prompt: do it
retry_for: 8h
retry_signature_cap: 2
output:
done:
type: bool
"#;
let def = harness_def::validate("wf", yml, harness_def::DefSource::Builtin).expect("valid def");
assert_eq!(def.steps[0].retry_for, Some(8 * 3600));
assert_eq!(def.steps[0].retry_signature_cap, Some(2));
let bad = yml.replace("retry_for: 8h", "retry_for: whenever");
let err = harness_def::validate("wf", &bad, harness_def::DefSource::Builtin).unwrap_err();
assert!(err.iter().any(|e| e.contains("'steps.one.retry_for' must be a positive duration")), "{err:?}");
}
#[test]
fn restored_step_result_restores_only_valid_results() {
let outputs =
[harness_def::OutputField { name: "grade".into(), ty: harness_def::OutputType::String, choices: vec![] }];
let contract = WorkflowResultContract { outputs: &outputs, require_do_while_repeat: false };
let dir = std::env::temp_dir().join(format!("scsh-restore-{}", runtime::random_nonce_6()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("review.json"), r#"{"grade":"good"}"#).unwrap();
std::fs::write(dir.join("drifted.json"), r#"{"score":5}"#).unwrap();
let (content, outputs_map, path) = restored_step_result(&dir, "review", contract).expect("valid result restores");
assert_eq!(content, r#"{"grade":"good"}"#);
assert_eq!(outputs_map.get("grade").map(String::as_str), Some("good"));
assert!(path.ends_with("review.json"));
assert!(restored_step_result(&dir, "review-while-collect-2", contract).is_none(), "missing iteration re-runs");
assert!(restored_step_result(&dir, "drifted", contract).is_none(), "an invalid result never restores");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn probe_parses_profiles_json_and_override() {
let cli = |a: &[&str]| parse_cli(&a.iter().map(|s| s.to_string()).collect::<Vec<_>>());
let c = cli(&["probe"]).unwrap();
assert!(matches!(c.mode, Mode::Probe));
assert_eq!(c.profile, None);
let c = cli(&["probe", "code-review"]).unwrap();
assert!(matches!(c.mode, Mode::Probe));
assert_eq!(c.profile.as_deref(), Some("code-review"));
let c = cli(&["probe", "code-review", "--json", "--override-dot-scsh-yml", "/x.yml"]).unwrap();
assert!(c.json);
assert_eq!(c.override_dot_scsh_yml.as_deref(), Some(std::path::Path::new("/x.yml")));
assert_eq!(cli(&["probe", "a", "b"]).unwrap().profile.as_deref(), Some("a,b"));
assert!(cli(&["probe", "--global"]).is_err());
assert!(cli(&["check-profile", "x", "--json"]).is_err(), "--json doesn't apply to check-profile");
}
#[test]
fn global_flag_parses_on_installskills_and_updateskills_only() {
let cli = |a: &[&str]| parse_cli(&a.iter().map(|s| s.to_string()).collect::<Vec<_>>());
let c = cli(&["installskills", "--global"]).unwrap();
assert!(matches!(c.mode, Mode::InstallSkills));
assert!(c.global);
let c = cli(&["installskills", "--global", "https://example.com/skills.git"]).unwrap();
assert!(c.global);
assert_eq!(c.sources, vec!["https://example.com/skills.git".to_string()]);
let c = cli(&["updateskills", "--global"]).unwrap();
assert!(matches!(c.mode, Mode::UpdateSkills));
assert!(c.global);
assert!(!cli(&["installskills"]).unwrap().global);
assert!(cli(&["run", "--global"]).is_err());
assert!(cli(&["list", "--global"]).is_err());
assert!(cli(&["version", "--global"]).is_err());
}
#[test]
fn failures_command_parses_filters_and_stats() {
let cli = |a: &[&str]| parse_cli(&a.iter().map(|s| s.to_string()).collect::<Vec<_>>());
let c = cli(&["failures"]).unwrap();
assert!(matches!(c.mode, Mode::Failures));
assert!(!c.failures.stats);
let c = cli(&["failures", "--session", "abc123", "--skill", "add", "--reason", "clone_failed"]).unwrap();
assert_eq!(c.failures.session.as_deref(), Some("abc123"));
assert_eq!(c.failures.skill.as_deref(), Some("add"));
assert_eq!(c.failures.reason.as_deref(), Some("clone_failed"));
let c = cli(&["failures", "--stats", "--last", "0"]).unwrap();
assert!(c.failures.stats);
assert_eq!(c.failures.last, Some(0));
assert!(cli(&["failures", "--last", "many"]).is_err(), "--last needs a number");
assert!(cli(&["run", "--stats"]).is_err(), "failure filters don't apply to run");
assert!(cli(&["list", "--session", "abc"]).is_err(), "failure filters don't apply to list");
}
#[test]
fn stats_command_parses_filters() {
let cli = |a: &[&str]| parse_cli(&a.iter().map(|s| s.to_string()).collect::<Vec<_>>());
let c = cli(&["stats"]).unwrap();
assert!(matches!(c.mode, Mode::Stats));
let c =
cli(&["stats", "--skill", "conventions-reviewer", "--harness", "codex", "--model", "gpt-5.6-terra"]).unwrap();
assert_eq!(c.failures.skill.as_deref(), Some("conventions-reviewer"));
assert_eq!(c.failures.harness.as_deref(), Some("codex"));
assert_eq!(c.failures.model.as_deref(), Some("gpt-5.6-terra"));
let c = cli(&["stats", "--profile", "code-review", "--raw", "--last", "10"]).unwrap();
assert_eq!(c.profile.as_deref(), Some("code-review"));
assert!(c.failures.raw);
assert_eq!(c.failures.last, Some(10));
assert!(cli(&["run", "--raw"]).is_err(), "--raw only applies to stats");
assert!(cli(&["failures", "--harness", "codex"]).is_err(), "--harness only applies to stats");
assert!(cli(&["stats", "--reason", "clone_failed"]).is_err(), "--reason only applies to failures");
}
#[test]
fn prune_command_parses_now_flag() {
let cli = |a: &[&str]| parse_cli(&a.iter().map(|s| s.to_string()).collect::<Vec<_>>());
let c = cli(&["prune"]).unwrap();
assert!(matches!(c.mode, Mode::Prune));
assert!(!c.prune_now);
assert!(cli(&["prune", "--now"]).unwrap().prune_now);
assert!(cli(&["run", "--now"]).is_err(), "--now only applies to prune");
}
#[test]
fn gc_command_parses_flags() {
let cli = |a: &[&str]| parse_cli(&a.iter().map(|s| s.to_string()).collect::<Vec<_>>());
let c = cli(&["gc"]).unwrap();
assert!(matches!(c.mode, Mode::Gc));
assert!(!c.gc.apply);
assert_eq!(c.gc.days, gc::DEFAULT_DAYS);
assert_eq!(c.gc.keep, gc::DEFAULT_KEEP);
assert!(!c.gc.legacy);
let c = cli(&["gc", "--apply", "--days", "7", "--keep", "10", "--legacy"]).unwrap();
assert!(c.gc.apply);
assert_eq!(c.gc.days, 7);
assert_eq!(c.gc.keep, 10);
assert!(c.gc.legacy);
assert!(!cli(&["gc", "--dry-run"]).unwrap().gc.apply);
assert!(cli(&["gc", "--apply", "--dry-run"]).is_err());
assert!(cli(&["run", "--apply"]).is_err(), "gc flags don't apply to run");
assert!(cli(&["gc", "--days", "x"]).is_err());
}
#[test]
fn opencode_config_path_prefers_xdg_then_home() {
let xdg = OsString::from("/cfg");
let home = OsString::from("/home/u");
assert_eq!(runtime::opencode_config_dir(Some(&xdg), Some(&home)), Some(PathBuf::from("/cfg/opencode")));
assert_eq!(runtime::opencode_config_dir(None, Some(&home)), Some(PathBuf::from("/home/u/.config/opencode")));
}
#[test]
fn forward_opencode_copies_auth_and_config_into_the_run_clone() {
let base = std::env::temp_dir().join(format!("scsh-oc-fwd-{}-{}", std::process::id(), now_secs()));
let host = base.join("host");
std::fs::create_dir_all(host.join(".local/share/opencode")).unwrap();
std::fs::create_dir_all(host.join(".config/opencode")).unwrap();
std::fs::write(host.join(".local/share/opencode/auth.json"), "{\"k\":1}").unwrap();
std::fs::write(host.join(".config/opencode/opencode.json"), "{\"provider\":1}").unwrap();
let run = base.join("run");
std::fs::create_dir_all(&run).unwrap();
let prev_home = std::env::var_os("HOME");
let prev_xdg_d = std::env::var_os("XDG_DATA_HOME");
let prev_xdg_c = std::env::var_os("XDG_CONFIG_HOME");
std::env::set_var("HOME", &host);
std::env::remove_var("XDG_DATA_HOME");
std::env::remove_var("XDG_CONFIG_HOME");
let out = forward_opencode(&run);
match prev_home {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
if let Some(v) = prev_xdg_d {
std::env::set_var("XDG_DATA_HOME", v);
}
if let Some(v) = prev_xdg_c {
std::env::set_var("XDG_CONFIG_HOME", v);
}
assert!(out.is_some(), "forwarding happened");
assert!(run.join("tmp/.xdg-data/opencode/auth.json").is_file(), "auth rides the repo mount");
assert!(run.join("tmp/.config/opencode/opencode.json").is_file(), "config rides the repo mount");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn packdiff_failure_detail_reads_machine_mode_errors() {
let unknown = br#"{
"UnknownRef": {
"repo": "/tmp/r",
"ref": "nope",
"message": "unknown ref in \"/tmp/r\": nope",
"stage": "ref",
"exit_code": 4
}
}"#;
assert_eq!(packdiff_failure_detail(unknown).as_deref(), Some("unknown ref in \"/tmp/r\": nope"));
assert_eq!(
packdiff_failure_detail(br#"{ "NotAGitRepository": { "stage": "repo", "exit_code": 3 } }"#).as_deref(),
Some("NotAGitRepository")
);
assert_eq!(packdiff_failure_detail(br#"{ "Packed": { "out": "x.html" } }"#), None);
assert_eq!(packdiff_failure_detail(b""), None);
}
#[test]
fn cached_skill_run_carries_result_json_for_workflow_outputs() {
let outputs = std::collections::HashMap::from([("sum".to_string(), "5".to_string())]);
let run = SkillRun::cached(None, r#"{"sum":5}"#.into(), Some(outputs));
assert!(run.ok && run.cached);
assert_eq!(run.result_content.as_deref(), Some(r#"{"sum":5}"#));
assert_eq!(run.workflow_outputs.as_ref().and_then(|fields| fields.get("sum")).map(String::as_str), Some("5"));
}
use std::sync::atomic::{AtomicUsize, Ordering};
static MT: AtomicUsize = AtomicUsize::new(0);
fn mt_dir(tag: &str) -> PathBuf {
let n = MT.fetch_add(1, Ordering::Relaxed);
let d = std::env::temp_dir().join(format!("scsh-mt-{tag}-{}-{}-{n}", std::process::id(), now_secs()));
std::fs::create_dir_all(&d).unwrap();
d
}
fn g(dir: &Path, args: &[&str]) {
assert!(git_status_ok(dir, args), "git {args:?} should succeed in {}", dir.display());
}
fn head(dir: &Path) -> String {
git_capture(dir, &["rev-parse", "HEAD"]).unwrap().trim().to_string()
}
fn repo(tag: &str) -> PathBuf {
let d = mt_dir(tag);
g(&d, &["init", "-q", "."]);
g(&d, &["config", "user.email", "t@e.st"]);
g(&d, &["config", "user.name", "tester"]);
std::fs::write(d.join("README"), "base\n").unwrap();
g(&d, &["add", "-A"]);
g(&d, &["commit", "-qm", "base"]);
d
}
fn clone_and_commit(src: &Path, tag: &str, file: &str, contents: &str, msg: &str) -> PathBuf {
let d = mt_dir(tag);
assert!(
Command::new("git")
.args(["clone", "-q", &src.to_string_lossy(), &d.to_string_lossy()])
.status()
.map(|s| s.success())
.unwrap_or(false),
"clone should succeed"
);
set_clone_identity(&d);
std::fs::write(d.join(file), contents).unwrap();
g(&d, &["add", "-A"]);
g(&d, &["commit", "-qm", msg]);
d
}
#[test]
fn incoming_branch_name_is_distinct_and_descriptive() {
let n = incoming_branch_name("add", "20231114-221320", "abcdef1234567");
assert_eq!(n, "scsh/incoming/add-20231114-221320-utc-abcdef1");
assert!(incoming_branch_name("My Skill!", "S", "deadbeef").starts_with("scsh/incoming/my-skill-S-utc-"));
}
#[test]
fn integrate_rebases_clean_commits_onto_the_branch() {
let caller = repo("clean-caller");
let base = head(&caller);
let clone = clone_and_commit(&caller, "clean-clone", "foo.txt", "hi\n", "add foo");
let outcome = integrate_commits(&caller, &clone, &base, "add", "STAMP").unwrap();
let range = match outcome {
Some(Integration::Applied { count: 1, range }) => range,
other => panic!("expected 1 applied commit, got {:?}", other.is_some()),
};
let (from, to) = range.expect("Applied carries the caller-repo range");
assert_eq!(from, base);
assert_eq!(to, head(&caller));
assert_eq!(git_capture(&caller, &["rev-list", "--count", &format!("{from}..{to}")]).unwrap().trim(), "1");
assert_eq!(std::fs::read_to_string(caller.join("foo.txt")).unwrap(), "hi\n");
assert_eq!(git_capture(&caller, &["status", "--porcelain"]).unwrap().trim(), "");
assert_eq!(git_capture(&caller, &["rev-list", "--count", &format!("{base}..HEAD")]).unwrap().trim(), "1");
assert_eq!(git_capture(&caller, &["log", "-1", "--format=%ae"]).unwrap().trim(), SCSH_COMMIT_EMAIL);
assert_eq!(git_capture(&caller, &["log", "-1", "--format=%an"]).unwrap().trim(), SCSH_COMMIT_NAME);
}
#[test]
fn integrate_rebases_second_skill_onto_advanced_head() {
let caller = repo("two-caller");
let base = head(&caller);
let c1 = clone_and_commit(&caller, "two-c1", "a.txt", "A\n", "add a");
let c2 = clone_and_commit(&caller, "two-c2", "b.txt", "B\n", "add b");
assert!(matches!(integrate_commits(&caller, &c1, &base, "s1", "S").unwrap(), Some(Integration::Applied { .. })));
assert!(matches!(integrate_commits(&caller, &c2, &base, "s2", "S").unwrap(), Some(Integration::Applied { .. })));
assert!(caller.join("a.txt").is_file() && caller.join("b.txt").is_file(), "both skills' files land");
assert_eq!(git_capture(&caller, &["rev-list", "--count", &format!("{base}..HEAD")]).unwrap().trim(), "2");
}
#[test]
fn integrate_saves_conflicting_commits_to_a_branch() {
let caller = repo("conf-caller");
let base = head(&caller);
let c1 = clone_and_commit(&caller, "conf-c1", "shared.txt", "one\n", "shared one");
let c2 = clone_and_commit(&caller, "conf-c2", "shared.txt", "two\n", "shared two");
integrate_commits(&caller, &c1, &base, "s1", "S").unwrap();
let outcome = integrate_commits(&caller, &c2, &base, "s2", "S").unwrap();
let (branch, range) = match outcome {
Some(Integration::Saved { branch, count: 1, range }) => (branch, range),
other => panic!("expected the conflicting commit to be saved to a branch, got {:?}", other.is_some()),
};
let (from, to) = range.expect("Saved carries the base..tip range");
assert_eq!(from, base);
assert_eq!(git_capture(&caller, &["rev-list", "--count", &format!("{from}..{to}")]).unwrap().trim(), "1");
assert_eq!(std::fs::read_to_string(caller.join("shared.txt")).unwrap(), "one\n");
assert_eq!(git_capture(&caller, &["status", "--porcelain"]).unwrap().trim(), "", "no half-applied cherry-pick");
assert!(branch.starts_with("scsh/incoming/s2-"));
assert_eq!(git_capture(&caller, &["cat-file", "-t", &branch]).unwrap().trim(), "commit");
}
#[test]
fn saved_rewrite_is_the_revision_seen_by_the_next_workflow_wave() {
let caller = repo("workflow-rewrite-caller");
std::fs::write(caller.join("feature.txt"), "first version\n").unwrap();
g(&caller, &["add", "-A"]);
g(&caller, &["commit", "-qm", "feature"]);
let base = head(&caller);
let rewritten = mt_dir("workflow-rewrite-run");
assert!(Command::new("git")
.args(["clone", "-q", &caller.to_string_lossy(), &rewritten.to_string_lossy()])
.status()
.unwrap()
.success());
set_clone_identity(&rewritten);
g(&rewritten, &["reset", "--hard", "HEAD~1"]);
std::fs::write(rewritten.join("feature.txt"), "authoritative rewrite\n").unwrap();
g(&rewritten, &["add", "-A"]);
g(&rewritten, &["commit", "-qm", "rewrite feature"]);
let outcome = integrate_commits(&caller, &rewritten, &base, "fix", "S").unwrap();
let (branch, tip) = match outcome {
Some(Integration::Saved { branch, range: Some((_, tip)), .. }) => (branch, tip),
_ => panic!("rewritten history should be preserved on an incoming branch"),
};
assert_eq!(head(&caller), base, "the caller branch remains untouched");
assert_eq!(git_capture(&caller, &["rev-parse", &branch]).unwrap().trim(), tip);
let next = mt_dir("workflow-next-wave");
assert!(Command::new("git")
.args(["clone", "-q", &caller.to_string_lossy(), &next.to_string_lossy()])
.status()
.unwrap()
.success());
checkout_workflow_revision(&next, &tip).unwrap();
assert_eq!(std::fs::read_to_string(next.join("feature.txt")).unwrap(), "authoritative rewrite\n");
let bare = mt_dir("workflow-transport").join("transport.git");
runtime::push_transport_refs(&caller, &bare).unwrap();
select_transport_workflow_revision(&bare, &tip).unwrap();
assert_eq!(git_capture(&bare, &["rev-parse", "HEAD"]).unwrap().trim(), tip);
}
#[test]
fn integrate_is_a_noop_when_the_skill_added_no_commits() {
let caller = repo("noop-caller");
let base = head(&caller);
let d = mt_dir("noop-clone");
assert!(Command::new("git")
.args(["clone", "-q", &caller.to_string_lossy(), &d.to_string_lossy()])
.status()
.unwrap()
.success());
assert!(integrate_commits(&caller, &d, &base, "add", "S").unwrap().is_none());
}
#[test]
fn commits_are_a_side_effect_run_twice_adds_twice() {
let caller = repo("twice-caller");
let base1 = head(&caller);
let r1 = clone_and_commit(&caller, "twice-r1", "log.txt", "x\n", "log x");
integrate_commits(&caller, &r1, &base1, "add", "S").unwrap();
let base2 = head(&caller); let r2 = clone_and_commit(&caller, "twice-r2", "log.txt", "x\nx\n", "log x again");
integrate_commits(&caller, &r2, &base2, "add", "S").unwrap();
assert_eq!(std::fs::read_to_string(caller.join("log.txt")).unwrap(), "x\nx\n");
assert_eq!(git_capture(&caller, &["rev-list", "--count", &format!("{base1}..HEAD")]).unwrap().trim(), "2");
}
fn mk_inv(name: &str) -> config::ResolvedInvocation {
config::ResolvedInvocation {
name: name.into(),
skill_source: name.into(),
harness: config::Harness::Opencode,
model: None,
effort: None,
timeout: None,
inactivity_timeout: None,
retry_for: None,
retry_signature_cap: None,
env: Vec::new(),
profile: None,
commits: false,
result: "tmp/r.json".into(),
terminal: config::Terminal::default(),
delivery: config::SkillDelivery::Repo,
artifacts: Vec::new(),
}
}
#[test]
fn cache_key_is_deterministic_and_sensitive() {
let caller = repo("ck");
std::fs::create_dir_all(caller.join(".skills/add")).unwrap();
std::fs::write(caller.join(".skills/add/SKILL.md"), "name: add\nbody\n").unwrap();
g(&caller, &["add", "-A"]);
g(&caller, &["commit", "-qm", "skill"]);
let s = mk_inv("add");
let env = vec![("A".to_string(), "2".to_string()), ("B".to_string(), "3".to_string())];
let k1 = cache_key(&caller, &s, &env).unwrap();
assert_eq!(k1.len(), 64, "key is a sha256 hex digest");
assert_eq!(cache_key(&caller, &s, &env).unwrap(), k1);
let env_rev = vec![("B".to_string(), "3".to_string()), ("A".to_string(), "2".to_string())];
assert_eq!(cache_key(&caller, &s, &env_rev).unwrap(), k1);
let env2 = vec![("A".to_string(), "9".to_string()), ("B".to_string(), "3".to_string())];
assert_ne!(cache_key(&caller, &s, &env2).unwrap(), k1);
let env_a2 = vec![("A".into(), "2".to_string()), ("SCSH_RESULT".into(), "tmp/scsh/aaa/x.json".to_string())];
let env_a2b = vec![("A".into(), "2".to_string()), ("SCSH_RESULT".into(), "tmp/scsh/zzz/x.json".to_string())];
let env_a7 = vec![("A".into(), "7".to_string()), ("SCSH_RESULT".into(), "tmp/scsh/aaa/x.json".to_string())];
assert_eq!(
cache_key(&caller, &s, &env_a2).unwrap(),
cache_key(&caller, &s, &env_a2b).unwrap(),
"output path is not part of the key"
);
assert_ne!(
cache_key(&caller, &s, &env_a2).unwrap(),
cache_key(&caller, &s, &env_a7).unwrap(),
"an input value changes the key"
);
let mut with_body = s.clone();
with_body.delivery = config::SkillDelivery::DirectPrompt("do X".into());
let mut with_body2 = s.clone();
with_body2.delivery = config::SkillDelivery::DirectPrompt("do Y".into());
assert_ne!(
cache_key(&caller, &with_body, &env).unwrap(),
cache_key(&caller, &with_body2, &env).unwrap(),
"body change busts the cache"
);
std::fs::write(caller.join("other.txt"), "x").unwrap();
g(&caller, &["add", "-A"]);
g(&caller, &["commit", "-qm", "change"]);
assert_ne!(cache_key(&caller, &s, &env).unwrap(), k1);
let before_skill_edit = cache_key(&caller, &s, &env).unwrap();
std::fs::write(caller.join(".skills/add/SKILL.md"), "name: add\nNEW body\n").unwrap();
g(&caller, &["add", "-A"]);
g(&caller, &["commit", "-qm", "edit skill"]);
assert_ne!(cache_key(&caller, &s, &env).unwrap(), before_skill_edit);
}
#[test]
fn cache_store_lookup_and_restore_roundtrip() {
let caller = repo("cs");
let key = "deadbeef";
assert!(cache_lookup(&caller, key).is_none(), "empty cache misses");
let result = r#"{"result": "2 + 3 = 5"}"#;
let cast_src = caller.join("orig.cast");
std::fs::write(&cast_src, "cast-bytes").unwrap();
let chapters_src = caller.join("orig.chapters.json");
std::fs::write(&chapters_src, r#"{"summary":"hi","chapters":[{"t":1.0,"title":"start"}]}"#).unwrap();
cache_store(&caller, key, result, None, Some(12.5), Some(&cast_src));
assert!(caller.join("tmp/.sccache").join(format!("{key}.json")).is_file());
let entry = cache_lookup(&caller, key).expect("hit");
assert_eq!(entry.result, result);
assert!(entry.commits.is_none(), "no commits journaled for a non-committing skill");
assert_eq!(entry.elapsed, Some(12.5), "the original duration round-trips");
assert!(entry.cached_at.is_some(), "cached_at is stamped on store");
let cast = entry.cast.expect("the cast was cached");
assert_eq!(std::fs::read_to_string(&cast).unwrap(), "cast-bytes", "the recording round-trips");
let chapters = entry.chapters.expect("chapters were cached with the cast");
assert!(std::fs::read_to_string(&chapters).unwrap().contains("\"summary\""), "chapters round-trip");
assert_eq!(std::fs::read_to_string(format!("{}.sccache-key", cast_src.display())).unwrap().trim(), key);
restore_cached_result(&caller, "tmp/add_result.json", result).unwrap();
assert_eq!(std::fs::read_to_string(caller.join("tmp/add_result.json")).unwrap(), result);
assert_eq!(json::message(result).as_deref(), Some("2 + 3 = 5"));
let patch = r#"From abc Mon Sep 17 00:00:00 2001
Subject: [PATCH] add: 2 + 3 = 5
"diff" body
"#;
cache_store(&caller, "withcommit", result, Some(patch), None, None);
let e2 = cache_lookup(&caller, "withcommit").expect("hit");
assert_eq!(e2.result, result);
assert_eq!(e2.commits.as_deref(), Some(patch));
assert!(e2.elapsed.is_none() && e2.cast.is_none(), "optional fields absent when not stored");
assert!(e2.cached_at.is_some(), "cached_at still stamped without a cast");
}
#[test]
fn cache_attach_chapters_copies_sidecar_after_annotate() {
let caller = repo("chap-cache");
let key = "cafechap";
let cast_src = caller.join("run.cast");
std::fs::write(&cast_src, "cast").unwrap();
cache_store(&caller, key, r#"{"ok":true}"#, None, Some(1.0), Some(&cast_src));
assert!(cache_lookup(&caller, key).unwrap().chapters.is_none());
std::fs::write(caller.join("run.chapters.json"), r#"{"summary":"done","chapters":[{"t":0.5,"title":"hi"}]}"#)
.unwrap();
cache_attach_chapters(&caller, &cast_src);
let entry = cache_lookup(&caller, key).expect("hit");
let chapters = entry.chapters.expect("chapters attached into the cache");
assert!(std::fs::read_to_string(chapters).unwrap().contains("done"));
}
#[test]
fn format_cached_at_is_human_utc() {
assert_eq!(format_cached_at(0), "1970-01-01 00:00 UTC");
assert_eq!(format_cached_at(1_700_000_000), "2023-11-14 22:13 UTC");
}
#[test]
fn cache_hit_labels_source_duration_as_provenance() {
assert_eq!(
cache_hit_provenance(Some(1_700_000_000), Some(480.946)),
"cached · 2023-11-14 22:13 UTC · source run took 8m 0s"
);
assert_eq!(cache_hit_provenance(None, None), "cached");
}
#[test]
fn cached_commits_are_replayed() {
let caller = repo("replay");
let base = git_capture(&caller, &["rev-parse", "HEAD"]).unwrap().trim().to_string();
std::fs::write(caller.join("note.txt"), "hi\n").unwrap();
g(&caller, &["add", "-A"]);
g(&caller, &["commit", "-qm", "add note"]);
let patch = commit_patch(&caller, &base).expect("a commit patch");
g(&caller, &["reset", "--hard", &base]);
assert!(!caller.join("note.txt").exists(), "reverted to base");
let res = apply_cached_commits(&caller, &patch, "demo", "20260101-000000");
assert!(
matches!(res, Ok(Some(Integration::Applied { count: 1, range: Some(_) }))),
"expected Applied{{count:1}} with a packable range"
);
assert!(caller.join("note.txt").exists(), "replayed file is present");
assert_eq!(git_capture(&caller, &["log", "-1", "--format=%s"]).unwrap().trim(), "add note");
assert_ne!(git_capture(&caller, &["rev-parse", "HEAD"]).unwrap().trim(), base, "HEAD advanced past base");
}
#[test]
fn step_invocation_routes_artifacts_into_the_session_dir_and_bypasses_the_cache() {
let step = harness_def::Step {
id: "summarize".into(),
agent: harness_def::StepAgent { harness: config::Harness::Grok, model: Some("grok-4.5".into()), effort: None },
task: harness_def::StepTask::Prompt("p".into()),
inputs: Vec::new(),
outputs: Vec::new(),
when: None,
needs: vec!["add".into()],
artifacts: vec!["summary.txt".into()],
commits: false,
repeat: None,
retry_for: None,
retry_signature_cap: None,
do_while: None,
break_loop: false,
};
let inv = step_invocation(&step, "summarize", "tmp/scsh/abcdef", Vec::new());
assert_eq!(inv.result, "tmp/scsh/abcdef/summarize.json");
assert_eq!(inv.artifacts, vec!["tmp/scsh/abcdef/summary.txt".to_string()]);
let caller = repo("artifacts-nocache");
assert!(cache_key(&caller, &inv, &[]).is_none(), "artifact steps must bypass the cache");
}
#[test]
fn resolve_input_falls_back_to_the_previous_loop_iteration() {
let (_, src) = harness_def::builtin_defs().into_iter().find(|(n, _)| *n == "demo-beautiful-loop").unwrap();
let def = harness_def::validate("demo-beautiful-loop", src, harness_def::DefSource::Builtin).unwrap();
let feedback = harness_def::Ref::StepField { step: "decide".into(), field: "feedback".into() };
let mut state = std::collections::HashMap::new();
let mut loop_prev = std::collections::HashMap::new();
assert_eq!(resolve_input(&feedback, &def, &state, &loop_prev), "", "round one: no feedback yet");
loop_prev.insert(
"decide".to_string(),
StepState { skipped: false, outputs: [("feedback".to_string(), "add tests".to_string())].into() },
);
assert_eq!(resolve_input(&feedback, &def, &state, &loop_prev), "add tests", "round two reads the saved round");
state.insert(
"decide".to_string(),
StepState { skipped: false, outputs: [("feedback".to_string(), "current".to_string())].into() },
);
assert_eq!(resolve_input(&feedback, &def, &state, &loop_prev), "current", "live state beats the carried value");
}
#[test]
fn global_install_lands_in_the_harness_skills_dir_on_both_transports() {
let base = std::env::temp_dir().join(format!("scsh-global-skill-{}", runtime::random_nonce_6()));
let inv = |harness: config::Harness| config::ResolvedInvocation {
name: "greet".into(),
skill_source: "greet".into(),
harness,
model: None,
effort: None,
timeout: None,
inactivity_timeout: None,
retry_for: None,
retry_signature_cap: None,
env: Vec::new(),
profile: None,
commits: false,
result: "tmp/greet.json".into(),
terminal: config::Terminal::default(),
delivery: config::SkillDelivery::GlobalInstall("# greet\nsay hi\n".into()),
artifacts: Vec::new(),
};
for git_transport in [false, true] {
let run_dir = base.join(format!("claude-{git_transport}"));
std::fs::create_dir_all(&run_dir).unwrap();
materialize_skill_body(&run_dir, git_transport, &inv(config::Harness::Claude)).unwrap();
let installed = run_dir.join("tmp/.claude-auth/.claude/skills/greet/SKILL.md");
assert!(installed.is_file(), "git_transport={git_transport}");
assert!(!run_dir.join(".skills").exists(), "the checkout never contains a global skill");
}
let run_dir = base.join("opencode");
std::fs::create_dir_all(&run_dir).unwrap();
materialize_skill_body(&run_dir, false, &inv(config::Harness::Opencode)).unwrap();
assert!(run_dir.join("tmp/.scsh-skills/greet/SKILL.md").is_file());
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn ensure_tmp_gitignored_adds_rule_and_creates_dir() {
let root = repo("tmp-ensure");
assert!(!root.join("tmp").is_dir());
assert!(!tmp_is_gitignored(&root));
assert!(ensure_tmp_gitignored(&root).unwrap());
assert!(tmp_is_gitignored(&root));
assert!(root.join("tmp").is_dir());
assert!(!ensure_tmp_gitignored(&root).unwrap());
assert!(root.join("tmp").is_dir());
}
}