mod annotate;
mod config;
mod daemon;
mod failure;
mod json;
mod runtime;
mod sha1;
mod sha256;
mod stats;
mod ui;
mod version;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant};
use config::ResolvedInvocation;
use runtime::Runtime;
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();
match cli.mode {
Mode::Help(topic) => {
print_help(topic);
0
}
Mode::Version => {
println!("scsh {}", version_id());
0
}
Mode::InitDemo => init_demo(),
Mode::InstallSkills => install_skills(false, &cli.sources),
Mode::UpdateSkills => install_skills(true, &cli.sources),
Mode::List => {
if cli.json {
list_profiles_json()
} else {
preflight_then(Action::List, profile, cli.verbose)
}
}
Mode::CheckProfile => check_profile_cmd(profile),
Mode::Run => preflight_then(Action::Run, profile, cli.verbose),
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::AnnotateCasts => annotate_casts_cmd(&cli.annotate_paths, 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(|| "composer-2.5-fast".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 tmp/casts/foo.cast");
}
if !annotate::host_can_annotate() {
return annotate_error(as_json, 1, "cursor-agent not available (need the `cursor-agent` CLI and a cursor login)");
}
let model = annotate_model();
let mut sidecars: Vec<(String, String)> = Vec::new(); for path in paths {
if !as_json {
eprint!("annotate {path} … ");
}
match annotate::annotate_cast_with(std::path::Path::new(path), &model, annotate::run_cursor_agent) {
Some(sidecar) => {
if !as_json {
eprintln!("✓ {}", sidecar.display());
}
sidecars.push((path.clone(), sidecar.to_string_lossy().into_owned()));
}
None if !as_json => eprintln!("✗ (no annotation produced)"),
None => {}
}
}
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 annotate_run_casts(cast_paths: Vec<std::path::PathBuf>) {
let pending: Vec<std::path::PathBuf> = cast_paths
.into_iter()
.filter(|c| daemon::chapters_sidecar_path(&c.to_string_lossy()).map(|s| !s.exists()).unwrap_or(false))
.collect();
if pending.is_empty() || !annotate::host_can_annotate() {
return;
}
eprintln!("scsh: annotating {} cast(s) with cursor · {} …", pending.len(), annotate_model());
let model = annotate_model();
let handles: Vec<_> = pending
.into_iter()
.map(|cast| {
let model = model.clone();
std::thread::spawn(move || annotate::annotate_cast_with(&cast, &model, annotate::run_cursor_agent).is_some())
})
.collect();
let done = handles.into_iter().filter_map(|h| h.join().ok()).filter(|&ok| ok).count();
eprintln!("scsh: annotated {done} cast(s)");
}
#[derive(Clone)]
enum Mode {
Help(HelpTopic),
Version,
InitDemo,
InstallSkills,
UpdateSkills,
List,
CheckProfile,
Run,
UiDemo {
frames: bool,
},
Daemon {
action: DaemonAction,
},
DaemonServe {
mode: daemon::DaemonMode,
port: u16,
},
Failures,
Stats,
Prune,
AnnotateCasts,
BuildImages,
}
#[derive(Clone, Copy)]
enum DaemonAction {
Start,
Stop,
Restart,
Status,
}
#[derive(Clone)]
enum HelpTopic {
Overview,
Run,
Config,
Internals,
Cache,
ExitCodes,
Command(String),
}
const COMMAND_NAMES: &[&str] = &[
"run",
"list",
"build-images",
"check-profile",
"init-demo-project",
"installskills",
"updateskills",
"daemon",
"failures",
"stats",
"prune",
"annotate-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",
"init-demo-project" | "init" | "init-demo" => "init-demo-project",
"installskills" | "install-skills" => "installskills",
"updateskills" | "update-skills" => "updateskills",
"daemon" => "daemon",
"failures" => "failures",
"stats" => "stats",
"prune" => "prune",
"annotate-cast" | "annotate-casts" | "annotate" => "annotate-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>,
verbose: bool,
json: bool,
failures: FailuresOpts,
prune_now: bool,
build_harnesses: Vec<String>,
build_force: bool,
build_rebuild_base: 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 verbose = false;
let mut json = false;
let mut frames = false;
let mut failures = FailuresOpts::default();
let mut prune_now = false;
let mut build_harnesses: Vec<String> = Vec::new();
let mut build_force = false;
let mut build_rebuild_base = 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("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: .scsh.yml, 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)
}
"__ui-demo" => Some(Mode::UiDemo { frames: false }),
"--frames" => {
frames = true;
None
}
"init-demo-project" | "init" | "--init-demo-project" => Some(Mode::InitDemo),
"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),
"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
}
"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
}
"--verbose" | "-v" => {
verbose = true;
None
}
"--json" => {
json = true;
None
}
other if matches!(mode, Some(Mode::Run)) && !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::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::CheckProfile | Mode::Stats) {
return Err(
"profiles only apply to 'run' 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 verbose && !matches!(mode, Mode::List) {
return Err("--verbose only applies to 'list'".into());
}
if json && !matches!(mode, Mode::List | Mode::AnnotateCasts) {
return Err("--json only applies to 'list' and 'annotate-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());
}
let shared_query_flags = failures.session.is_some() || failures.skill.is_some() || failures.last.is_some();
if shared_query_flags && !matches!(mode, Mode::Failures | Mode::Stats | Mode::BuildImages) {
return Err("--session/--skill/--last only apply to 'failures' or 'stats'".into());
}
if matches!(mode, Mode::BuildImages) && (failures.skill.is_some() || failures.last.is_some()) {
return Err("--skill/--last do not apply to 'build-images'".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 !annotate_paths.is_empty() && !matches!(mode, Mode::AnnotateCasts) {
return Err("cast paths only apply to 'annotate-cast'".into());
}
Ok(Cli {
mode,
profile,
sources,
annotate_paths,
verbose,
json,
failures,
prune_now,
build_harnesses,
build_force,
build_rebuild_base,
})
}
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_then(action: Action, profile: Option<&str>, verbose: bool) -> i32 {
let is_run = matches!(action, Action::Run);
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;
}
};
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 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 1;
}
}
let cfg_path = root.join(".scsh.yml");
if !cfg_path.is_file() {
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)");
return 1;
}
let src = match std::fs::read_to_string(&cfg_path) {
Ok(s) => s,
Err(e) => {
fail(&format!("could not read .scsh.yml: {e}"));
return 1;
}
};
let cfg = match config::validate(&src) {
Ok(c) => c,
Err(errs) => {
let n = errs.len();
fail(&format!(".scsh.yml does not match the schema ({n} problem{})", 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)");
return 1;
}
};
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 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 1;
}
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 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;
}
let prof = profile.map(|p| format!(" · profile {p}")).unwrap_or_default();
ok(&format!("git · repo {} · clean · /tmp ignored{prof}", 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;
}
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 cast_dir = root.join("tmp").join("casts");
let before = casts_snapshot(&cast_dir);
let code = build_and_run(&rt, &root, &runnable, profile);
annotate_run_casts(new_casts_since(&cast_dir, &before));
code
}
}
}
fn casts_snapshot(dir: &std::path::Path) -> std::collections::BTreeSet<std::path::PathBuf> {
std::fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|x| x.to_str()) == Some("cast"))
.collect()
}
fn new_casts_since(
dir: &std::path::Path, before: &std::collections::BTreeSet<std::path::PathBuf>,
) -> Vec<std::path::PathBuf> {
casts_snapshot(dir).into_iter().filter(|p| !before.contains(p)).collect()
}
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();
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"));
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,
);
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() -> 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_path = root.join(".scsh.yml");
if !cfg_path.is_file() {
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")));
return Err(1);
}
let src = match std::fs::read_to_string(&cfg_path) {
Ok(s) => s,
Err(e) => {
fail(&format!("could not read .scsh.yml: {e}"));
return Err(1);
}
};
match config::validate(&src) {
Ok(cfg) => Ok(cfg),
Err(errs) => {
let n = errs.len();
fail(&format!(".scsh.yml does not match the schema ({n} problem{})", if n == 1 { "" } else { "s" }));
for e in &errs {
hint(e);
}
Err(1)
}
}
}
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() -> i32 {
let cfg = match load_config_for_inspection() {
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>) -> 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() {
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 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() {
if let Some(pid) = daemon::read_live_pid(port) {
ok(&format!("session browser daemon running (pid {pid}) on {}", daemon::base_url(port)));
} else {
ok(&format!("session browser daemon responding on {}", daemon::base_url(port)));
}
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_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 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!("session {}", 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>) -> i32 {
ui::signals::install();
let session_id = 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()));
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, &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 bind-mounted when present)");
}
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();
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(), true);
if let Some(c) = &daemon_client {
c.proc_add(p.index(), &base_label, daemon::ProcKind::Build, 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(), true);
if let Some(c) = &daemon_client {
c.proc_add(p.index(), &label, daemon::ProcKind::Build, None, Some(spec.harness.as_str()), 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(),
);
}
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)
{
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 handles: Vec<_> = harness_build_procs
.iter()
.zip(harness_builds.iter())
.map(|(build, spec)| {
let rt_name = &rt_name;
let df = &df;
scope.spawn(move || {
build.start();
match run_build(build, rt_name, &spec.tag, &spec.target, df, uid, gid, &spec.fingerprint, false) {
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();
scope.spawn(move || {
let attempt_started = std::time::Instant::now();
let mut first = run_one_skill(skill, rt, root, secs, p, base_ref, dc.clone());
first.duration_secs = attempt_started.elapsed().as_secs_f64();
if first.ok {
return first;
}
let transient = first.fail_reason.as_deref().is_some_and(|r| {
failure::is_transient(r) || (r == failure::reason::RESULT_MISSING && skill.harness.is_tui())
});
if !transient || !failure::retry_enabled() {
return first;
}
let reason = first.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 p2 = ui_ref.proc(label.clone(), false);
if let Some(c) = &dc {
c.proc_add(
p2.index(),
&label,
daemon::ProcKind::Skill,
Some(skill.name.as_str()),
Some(skill.harness.as_str()),
skill.model.as_deref(),
);
}
let retry_started = std::time::Instant::now();
let mut second = run_one_skill(skill, rt, root, secs, p2, base_ref, dc);
second.duration_secs = retry_started.elapsed().as_secs_f64();
second.attempts = 2;
second
})
})
.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();
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 })) => ok(&format!(
"{}: brought in {count} commit{} (rebased onto {})",
skill.name,
plural(count),
current_branch(root)
)),
Ok(Some(Integration::Saved { branch, count })) => warn(&format!(
"{}: {count} commit{} didn't rebase cleanly — saved to branch {branch} (inspect, then merge/cherry-pick)",
skill.name,
plural(count)
)),
Err(e) => warn(&format!("{}: could not bring in commits — {e}", skill.name)),
}
}
}
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 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,
fail_reason: Option<String>,
cached: bool,
duration_secs: f64,
attempts: u64,
run_dir: Option<String>,
log: Option<String>,
clone_dir: Option<PathBuf>,
cached_commits: Option<String>,
}
impl SkillRun {
fn base() -> SkillRun {
SkillRun {
ok: false,
fail_reason: None,
cached: false,
duration_secs: 0.0,
attempts: 1,
run_dir: None,
log: None,
clone_dir: None,
cached_commits: None,
}
}
fn ok(log: String, clone_dir: Option<PathBuf>) -> SkillRun {
SkillRun { ok: true, log: Some(log), clone_dir, ..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 cached(cached_commits: Option<String>) -> SkillRun {
SkillRun { ok: true, cached: true, cached_commits, ..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 run_one_skill(
skill: &ResolvedInvocation, rt: &Runtime, root: &Path, secs: u64, spinner: ui::screen::Proc, base: Option<&str>,
daemon_client: Option<std::sync::Arc<daemon::Client>>,
) -> SkillRun {
spinner.start();
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(root, skill, &env);
if let Some(key) = &key {
if let Some(entry) = cache_lookup(root, key) {
if restore_cached_result(root, &skill.result, &entry.result).is_ok() {
let line = match json::message(&entry.result) {
Some(m) => format!("{} (cached)", first_line(&m)),
None => "(cached)".to_string(),
};
spinner.finish_ok(Some(&line));
return SkillRun::cached(entry.commits);
}
}
}
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, &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, &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 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
};
if opencode_forward.is_some() {
prepare_opencode_mount_dirs(&run_dir);
}
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 vols: Vec<(String, String)> = if let Some(ref forward) = opencode_forward {
runtime::opencode_forward_mounts(forward)
} else {
runtime::harness_volumes(skill.harness)
};
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,
);
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 _container = ui::signals::ContainerGuard::new(&rt.name, &name);
if let Some(c) = &daemon_client {
c.container_event(spinner.index(), "start", &name);
c.proc_cast(spinner.index(), &run_dir.join(runtime::RUN_CAST_REL).to_string_lossy());
}
let result = spinner.run_timed(&run[0], &run[1..], timeout);
if let Some(c) = &daemon_client {
c.container_event(spinner.index(), "stop", &name);
}
if let Some(durable) = persist_run_artifacts(root, &run_dir, &skill.name, secs) {
if let Some(c) = &daemon_client {
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, _, _)) => {}
Ok((false, true, _)) => {
ui::signals::stop_container(&rt.name, &name);
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);
}
Ok((false, false, 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 detail = skill_fail_detail(&why, skill.harness, Some(&run_dir_str), Some(&log));
spinner.finish_fail(failure::reason::HARNESS_NONZERO, Some(&detail));
return SkillRun::failed(failure::reason::HARNESS_NONZERO, Some(run_dir_str), Some(log), clone_dir);
}
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);
}
}
match collect_skill_result(root, &run_dir, &skill.result, secs) {
Ok(dest) => {
let content = std::fs::read_to_string(&dest).ok();
if let (Some(key), Some(c)) = (&key, &content) {
let commits =
if skill.commits { base.and_then(|b| commit_patch(&runtime::commits_fetch_path(&run_dir), b)) } else { None };
cache_store(root, key, c, commits.as_deref());
}
let message = content.as_deref().and_then(json::message);
let headline = message.as_deref().map(first_line).unwrap_or(skill.result.as_str());
spinner.finish_ok(Some(headline));
schedule_run_dir_prune_backup(daemon_client.as_ref(), &run_dir_str, &name, &rt.name, true);
SkillRun::ok(log, clone_dir)
}
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)
}
}
}
fn persist_run_artifacts(root: &Path, 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 = root.join("tmp").join("logs");
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 = root.join("tmp").join("casts");
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, 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);
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 prepare_git_transport(root: &Path, run_dir: &Path, commits: bool, 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 commits {
runtime::init_bare_repo(&run_dir.join(runtime::PULL_BARE))?;
}
Ok(())
}
struct GitTransport {
child: std::process::Child,
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 child = Command::new("git")
.args([
"daemon",
"--reuseaddr",
&format!("--base-path={base}"),
"--export-all",
"--enable=receive-pack",
&format!("--port={port}"),
"--listen=0.0.0.0",
])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.map_err(|e| format!("could not start git daemon: {e}"))?;
std::thread::sleep(Duration::from_millis(100));
Ok(Self { child, 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 _ = self.child.kill();
let _ = self.child.wait();
}
}
const SCSH_COMMIT_NAME: &str = "dkorolev-neon-elon-bot";
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 root = run_dir.join(runtime::OPENCODE_FORWARD_REL);
let xdg_dir = root.join("xdg/opencode");
let cfg_dir = root.join("config/opencode");
std::fs::create_dir_all(&xdg_dir).ok()?;
std::fs::create_dir_all(&cfg_dir).ok()?;
std::fs::copy(&auth_src, xdg_dir.join("auth.json")).ok()?;
if let Some(cfg) = runtime::opencode_config_json_in(xdg_config.as_deref(), Some(home.as_os_str())) {
std::fs::copy(&cfg, cfg_dir.join("opencode.json")).ok()?;
}
if let Some(cfg) = runtime::opencode_config_jsonc_in(xdg_config.as_deref(), Some(home.as_os_str())) {
std::fs::copy(&cfg, cfg_dir.join("opencode.jsonc")).ok()?;
}
Some(root)
}
fn prepare_opencode_mount_dirs(run_dir: &Path) {
let _ = std::fs::create_dir_all(run_dir.join(runtime::AGENT_XDG_DATA_REL).join("opencode"));
}
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", "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() {
if std::fs::create_dir_all(&dest).is_ok() {
if 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() {
if 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 },
Saved { branch: String, count: usize },
}
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);
}
if git_status_ok(root, &["cherry-pick", "--keep-redundant-commits", &range]) {
Ok(Some(Integration::Applied { count }))
} 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 }))
}
}
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("tmp").join(".sccache")
}
fn cache_key(root: &Path, skill: &ResolvedInvocation, env: &[(String, String)]) -> Option<String> {
let tree = git_capture(root, &["rev-parse", "HEAD^{tree}"])?.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"));
}
blob.push_str("env:\n");
let mut pairs: Vec<&(String, String)> = env.iter().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>,
}
fn cache_lookup(root: &Path, key: &str) -> Option<CacheEntry> {
let text = std::fs::read_to_string(cache_dir(root).join(format!("{key}.json"))).ok()?;
Some(CacheEntry { result: json::field(&text, "result")?, commits: json::field(&text, "commits") })
}
fn cache_store(root: &Path, key: &str, content: &str, commits: Option<&str>) {
let dir = cache_dir(root);
if std::fs::create_dir_all(&dir).is_err() {
return;
}
let entry = match commits {
Some(patch) => format!("{{\"result\": {}, \"commits\": {}}}\n", json::quote(content), json::quote(patch)),
None => format!("{{\"result\": {}}}\n", json::quote(content)),
};
let _ = std::fs::write(dir.join(format!("{key}.json")), entry);
}
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);
return Ok(Some(Integration::Applied { count }));
}
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)
}
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,
) -> Result<(), (String, i32)> {
let tz = runtime::host_timezone();
let started = |e: std::io::Error| (format!("failed to start '{runtime_name}': {e}"), 1);
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 {
let tail = build.tail_lines(failure::FAILURE_TAIL_LINES);
let excerpt = failure::failure_excerpt(last.as_deref(), &tail, "build produced no output");
Err((format!("image build failed (runtime={runtime_name}, target={target}, tag={tag}): {excerpt}"), 1))
}
}
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 selected: Vec<config::Harness> = Vec::new();
if names.is_empty() {
selected.extend(config::Harness::ALL);
} else {
for n in names {
match config::Harness::parse(n) {
Some(h) if selected.contains(&h) => {}
Some(h) => selected.push(h),
None => {
fail(&format!("unknown harness '{n}' (known: {})", 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"), &[]) {
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();
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;
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(), true);
if let Some(c) = &daemon_client {
c.proc_add(p.index(), &label, daemon::ProcKind::Build, 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(), true);
if let Some(c) = &daemon_client {
c.proc_add(p.index(), &label, daemon::ProcKind::Build, None, Some(spec.harness.as_str()), 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,
) {
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 handles: Vec<_> = harness_build_procs
.iter()
.zip(harness_builds.iter())
.map(|(build, spec)| {
let rt_name = &rt_name;
let df = &df;
scope.spawn(move || {
build.start();
match run_build(build, rt_name, &spec.tag, &spec.target, df, uid, gid, &spec.fingerprint, force) {
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
}
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) {
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 commit_scaffold(root: &Path, paths: &[String]) -> 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", "Add scsh demo project (config + skills)"])
.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]) -> 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);
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 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 cmd = if sources.is_empty() {
"scsh updateskills".to_string()
} else {
format!("scsh updateskills {}", sources.join(" "))
};
hint(&format!("to replace them with the source's version, run: {}", bold(&cmd)));
}
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 installed == 0 && updated == 0 && already == 0 && differing.is_empty() && links.is_empty() {
ok("skills already installed; nothing to do");
}
if sources.is_empty() {
hint("that's scsh's bundled demo/self-test — run /scsh-harness-demo-and-selftest to exercise scsh end to end");
hint(&format!(
"for real skills, point me at a repo, e.g. {}",
bold("scsh installskills https://github.com/dkorolev/beautiful-skills")
));
}
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);
}
c
}
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 conflicts: Vec<String> = Vec::new();
let mut append = String::new();
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) {
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() {
if conflicts.is_empty() {
ok("the installed skills were already declared in .scsh.yml");
}
} else {
let merged = if local_text.trim().is_empty() {
format!("{CONSUMER_MANIFEST_HEADER}{append}")
} else {
let mut t = local_text.clone();
if !t.ends_with('\n') {
t.push('\n');
}
t.push_str(&append);
t
};
if config::validate(&merged).is_ok() && write_file(&local_path, merged.as_bytes()) {
ok(&format!("added {} skill{} to .scsh.yml: {}", added.len(), plural(added.len()), added.join(", ")));
} else {
hint(
"installed the skill files, but merging them into .scsh.yml would make it invalid — left .scsh.yml unchanged",
);
hint(&format!("add by hand: {}", added.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 std::fs::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!("`multiply` (X * Y) lives in the `multiply` profile because it REQUIRES X");
println!("and Y. scsh resolves the env you forward (or refuses the skill). Examples — successes");
println!("({}) and the intended refusal scsh guards against ({}):", ok_mark(), refused_mark());
println!();
example("scsh run", "add with defaults A=2 B=3 -> 2 + 3 = 5", 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);
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"));
}
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> {
if tmp_is_gitignored(root) {
return Ok(false);
}
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");
std::fs::write(&path, content).map_err(|e| format!("could not write {}: {e}", path.display()))?;
Ok(true)
}
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 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::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!();
}
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>",
&[("<name>", "Exit 0 iff that profile exists with >=1 skill; runtime-free (no build).")],
),
"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.")],
),
"installskills" => (
"install skills into this repo",
"scsh installskills [git-url…]",
&[("[git-url…]", "Install from the bundled skills, or from one or more source repos in order.")],
),
"updateskills" => (
"reinstall skills, overwriting",
"scsh updateskills [git-url…]",
&[("[git-url…]", "Like installskills, but overwrites local copies of each skill.")],
),
"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 DB (default ~/.scsh). See DAEMON.md."),
],
),
"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.")],
),
"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 cursor-agent on the Composer model."),
("--json", "Emit the written sidecar paths as JSON; on by default when stdout is not a TTY."),
("SCSH_ANNOTATE_MODEL", "Override the model (default composer-2.5-fast)."),
],
),
"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("init-demo-project", "Scaffold and commit a demo project.");
help_row("installskills [url…]", "Install skills (bundled or from git URLs).");
help_row("updateskills [url…]", "Reinstall skills, overwriting local copies.");
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("annotate-cast <cast…>", "Summarize + chapter recordings via cursor/Composer (--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 <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 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("--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…]"));
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).");
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("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!();
}
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
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 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):"));
println!("{}", h_dim(" opencode: opencode -m <model> run \"run skill <source>\""));
println!(
"{}",
h_dim(
" claude: claude -p \"Run .skills/<source>/SKILL.md …\" \
(CLAUDE_CODE_OAUTH_TOKEN or ~/.claude/.credentials.json)",
)
);
println!(
"{}",
h_dim(
" codex: codex exec -m <model> \"Run .skills/<source>/SKILL.md …\" \
(~/.codex/auth.json or OPENAI_API_KEY) — the native harness for GPT models",
)
);
println!(
"{}",
h_dim(
" grok: grok -p \"Run .skills/<source>/SKILL.md …\" -m <model> --effort <level> \
(~/.grok/auth.json or XAI_API_KEY) — the native harness for Grok models",
)
);
println!(
"{}",
h_dim(
" cursor: cursor-agent -p --force --trust \"Run .skills/<source>/SKILL.md …\" \
(macOS keychain / auth.json / CURSOR_API_KEY) — the native harness for Cursor models",
)
);
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, 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 generated in memory (streamed to the builder's stdin), and 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).
"#
);
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, and nowhere else. Each
entry holds the skill's result 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)", and replays any journaled commits; on a miss it runs and stores both.
"#
);
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::*;
use std::ffi::OsString;
#[test]
fn persist_run_artifacts_copies_cast_and_logs_with_shared_stem() {
let base = std::env::temp_dir().join(format!("scsh-persist-test-{}", runtime::random_nonce_6()));
let root = base.join("repo");
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(&root).unwrap();
let cast = persist_run_artifacts(&root, &run_dir, "add", 1_700_000_000).unwrap();
let casts_dir = root.join("tmp").join("casts");
assert!(cast.starts_with(casts_dir.to_string_lossy().as_ref()), "cast in tmp/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 = root.join("tmp").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 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.5"]).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.5"));
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 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 prepare_opencode_mount_dirs_creates_xdg_parent() {
let run = std::env::temp_dir().join(format!("scsh-auth-{}-{}", std::process::id(), now_secs()));
std::fs::create_dir_all(&run).unwrap();
prepare_opencode_mount_dirs(&run);
assert!(run.join("tmp/.xdg-data/opencode").is_dir());
let _ = std::fs::remove_dir_all(&run);
}
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();
assert!(matches!(outcome, Some(Integration::Applied { count: 1 })), "expected 1 applied commit");
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 = match outcome {
Some(Integration::Saved { branch, count: 1 }) => branch,
other => panic!("expected the conflicting commit to be saved to a branch, got {:?}", other.is_some()),
};
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 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,
env: Vec::new(),
profile: None,
commits: false,
result: "tmp/r.json".into(),
terminal: config::Terminal::default(),
}
}
#[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);
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"}"#;
cache_store(&caller, key, result, None);
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");
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));
let e2 = cache_lookup(&caller, "withcommit").expect("hit");
assert_eq!(e2.result, result);
assert_eq!(e2.commits.as_deref(), Some(patch));
}
#[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 }))), "expected Applied{{count:1}}");
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");
}
}