mod config;
mod json;
mod runtime;
mod sha256;
mod ui;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
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),
}
}
#[derive(Clone, Copy)]
enum Mode {
Help(HelpTopic),
Version,
InitDemo,
InstallSkills,
UpdateSkills,
List,
CheckProfile,
Run,
UiDemo { frames: bool },
}
#[derive(Clone, Copy)]
enum HelpTopic {
Overview,
Config,
Internals,
Cache,
}
mod build_info {
include!(concat!(env!("OUT_DIR"), "/scsh_build_info.rs"));
}
fn version_id() -> String {
let v = env!("CARGO_PKG_VERSION");
let git = build_info::GIT_DESCRIBE;
let git = if git.is_empty() { runtime_git_describe().unwrap_or_default() } else { git.to_string() };
if git.is_empty() {
v.to_string()
} else {
format!("{v} ({git})")
}
}
fn runtime_git_describe() -> Option<String> {
let mut dir = std::env::current_dir().ok()?;
for _ in 0..32 {
if dir.join(".git").exists() {
return runtime_git_describe_in(&dir);
}
dir = dir.parent()?.to_path_buf();
}
None
}
fn runtime_git_describe_in(repo: &std::path::Path) -> Option<String> {
let out = std::process::Command::new("git")
.arg("-C")
.arg(repo)
.args(["rev-parse", "HEAD"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let hash: String = String::from_utf8_lossy(&out.stdout).trim().chars().take(7).collect();
if hash.is_empty() {
return None;
}
let dirty = std::process::Command::new("git")
.arg("-C")
.arg(repo)
.args(["status", "--porcelain"])
.output()
.ok()
.filter(|o| o.status.success())
.is_some_and(|o| !o.stdout.is_empty());
Some(if dirty { format!("{hash}-dirty") } else { hash })
}
enum Action {
List,
Run,
}
struct Cli {
mode: Mode,
profile: Option<String>,
sources: Vec<String>,
verbose: bool,
json: 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 verbose = false;
let mut json = false;
let mut frames = 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(".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(other) if !other.starts_with('-') => {
return Err(format!("unknown help topic '{other}' (topics: .scsh.yml, internals, cache)"));
}
_ => 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),
"--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 => 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) {
return Err(
"profiles only apply to 'run' (e.g. `scsh run code-review` or `scsh run --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) {
return Err("--json only applies to 'list' (e.g. `scsh list --json`)".into());
}
Ok(Cli { mode, profile, sources, verbose, json })
}
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 mut runnable: Vec<&ResolvedInvocation> = Vec::new();
for skill in &selected {
if let Err(msg) = runtime::check_harness_host(skill.harness) {
warn(&format!(
"skipping '{}' — {} harness unavailable ({msg})",
skill.name,
skill.harness.as_str()
));
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 harness is unavailable on this host");
hint("see DEMO.md step 1 — probe add-opencode-gpt-5.4-mini-fast, add-claude-sonnet-4-6, and add-opencode-glm-5.2");
return 1;
}
build_and_run(&rt, &root, &runnable)
}
}
}
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) = 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();
for h in harnesses {
let tag = runtime::image_tag(h);
let target = runtime::image_target(h);
let fingerprint = runtime::image_build_fingerprint(&df, target, uid, gid, &host_tz);
match runtime::build_method(&rt.name) {
runtime::BuildMethod::Stdin => {
let build = runtime::build_command_stdin(&rt.name, &tag, target, uid, gid, &host_tz, &fingerprint);
println!("--- build {target} (Dockerfile streamed to stdin; agent uid={uid} gid={gid}) ---");
println!("{}", runtime::shell_join(&build));
}
runtime::BuildMethod::ContextDir => {
let ctx = std::env::temp_dir().join("scsh-build-XXXXXX");
let build = runtime::build_command_context(
&rt.name, &tag, target, &ctx.to_string_lossy(), uid, gid, &host_tz, &fingerprint,
);
println!("--- build {target} (in-memory Dockerfile written to an ephemeral context dir) ---");
println!("{}", runtime::shell_join(&build));
}
}
}
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.skill_source, &skill.result);
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()
);
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 run = runtime::run_command(&rt.name, &tag, &run_dir, &name, &env, &vol_refs, &cmd);
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 build_and_run(rt: &Runtime, root: &std::path::Path, skills: &[&ResolvedInvocation]) -> i32 {
ui::signals::install();
let (uid, gid) = 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);
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)"));
}
let ui = ui::screen::LiveUi::new(console::user_attended_stderr());
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 mut build_procs = Vec::with_capacity(harness_list.len());
for &h in &harness_list {
build_procs.push(ui.proc(format!("using {} · build {}", backend_name(&rt.name), h.as_str()), true));
}
let mut skill_procs = Vec::with_capacity(skills.len());
for skill in skills {
let p = ui.proc(format!("{}: {}", skill.harness.as_str(), skill.name), false);
p.note("waiting for image build…");
skill_procs.push(p);
}
ui.pin_board_to_top();
let build_failed: Option<(String, i32)> = std::thread::scope(|scope| {
harness_list
.into_iter()
.zip(build_procs)
.map(|(h, build)| {
let rt_name = rt_name.clone();
let df = df.clone();
let tz = tz.clone();
scope.spawn(move || {
let tag = runtime::image_tag(h);
let target = runtime::image_target(h);
let fingerprint = runtime::image_build_fingerprint(&df, target, uid, gid, &tz);
build.start();
if runtime::image_is_up_to_date(&rt_name, &tag, &fingerprint) {
build.finish_ok(Some("up to date"));
return Ok(());
}
match run_build(&build, &rt_name, &tag, target, &df, uid, gid, &fingerprint) {
Ok(()) => {
build.finish_ok(None);
Ok(())
}
Err(e) => {
build.finish_fail(Some(&e.0));
Err(e)
}
}
})
})
.collect::<Vec<_>>()
.into_iter()
.find_map(|h| {
h.join()
.unwrap_or_else(|_| Err(("harness image build thread panicked".into(), 1)))
.err()
})
});
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 outcomes: Vec<SkillRun> = std::thread::scope(|scope| {
let handles: Vec<_> = skills
.iter()
.zip(skill_procs)
.map(|(&skill, p)| scope.spawn(move || run_one_skill(skill, rt, root, secs, p, base_ref)))
.collect();
handles.into_iter().map(|h| h.join().unwrap_or_else(|_| SkillRun::failed(None, None, None))).collect()
});
ui.finish();
let n = outcomes.len();
let failed = outcomes.iter().filter(|o| !o.ok).count();
for o in 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}"));
}
}
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,
run_dir: Option<String>,
log: Option<String>,
clone_dir: Option<PathBuf>,
cached_commits: Option<String>,
}
impl SkillRun {
fn ok(log: String, clone_dir: Option<PathBuf>) -> SkillRun {
SkillRun { ok: true, run_dir: None, log: Some(log), clone_dir, cached_commits: None }
}
fn failed(run_dir: Option<String>, log: Option<String>, clone_dir: Option<PathBuf>) -> SkillRun {
SkillRun { ok: false, run_dir, log, clone_dir, cached_commits: None }
}
fn cached(cached_commits: Option<String>) -> SkillRun {
SkillRun { ok: true, run_dir: None, log: None, clone_dir: None, cached_commits }
}
}
fn run_one_skill(
skill: &ResolvedInvocation, rt: &Runtime, root: &Path, secs: u64, spinner: ui::screen::Proc, base: Option<&str>,
) -> 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(Some(&message));
return SkillRun::failed(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("cloning…");
let run_dir = match prepare_run_dir(secs, &skill.name, &rt.name) {
Ok(d) => d,
Err(e) => {
spinner.finish_fail(Some(&e));
return SkillRun::failed(None, None, None);
}
};
let run_dir_str = run_dir.to_string_lossy().into_owned();
if let Err(e) = clone_into(root, &run_dir, &spinner) {
spinner.finish_fail(Some(&e));
return SkillRun::failed(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 tag = runtime::image_tag(skill.harness);
let cmd = runtime::harness_command(skill.harness, skill.model.as_deref(), &skill.skill_source, &skill.result);
let vols: Vec<(String, String)> = if let Some(ref auth_root) = claude_auth {
runtime::claude_auth_mounts(auth_root)
} else 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));
}
}
let run = runtime::run_command(&rt.name, &tag, &run_dir_str, &name, &container_env, &vol_refs, &cmd);
let timeout = skill.timeout.map(Duration::from_secs);
let _container = ui::signals::ContainerGuard::new(&rt.name, &name);
let result = spinner.run_timed(&run[0], &run[1..], timeout);
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);
}
match result {
Ok((true, _, _)) => {}
Ok((false, true, _)) => {
ui::signals::stop_container(&rt.name, &name);
let why = format!("timed out after {}s", skill.timeout.unwrap_or(0));
spinner.finish_fail(Some(&why));
return SkillRun::failed(Some(run_dir_str), Some(log), clone_dir);
}
Ok((false, false, last)) => {
let why = match last {
Some(l) if !l.is_empty() => format!("harness exited non-zero ({l})"),
_ => "harness exited non-zero".into(),
};
spinner.finish_fail(Some(&why));
return SkillRun::failed(Some(run_dir_str), Some(log), clone_dir);
}
Err(e) => {
let why = format!("could not run container: {e}");
spinner.finish_fail(Some(&why));
return SkillRun::failed(Some(run_dir_str), None, 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(&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));
SkillRun::ok(log, clone_dir)
}
Err(e) => {
spinner.finish_fail(Some(&e));
SkillRun::failed(Some(run_dir_str), Some(log), clone_dir)
}
}
}
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> {
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);
Ok(())
}
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 keep_run_dirs() -> bool {
matches!(std::env::var("SCSH_KEEP_RUNS").ok().as_deref(), Some("1") | Some("true"))
}
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 host_claude = home.as_ref().filter(|h| h.join(".claude").is_dir());
let host_json = home.as_ref().filter(|h| h.join(".claude.json").is_file());
let host_creds = host_claude
.as_ref()
.map(|h| h.join(".claude").join(".credentials.json"))
.filter(|p| p.is_file());
if token.is_none() && host_creds.is_none() && host_claude.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()?;
if let Some(h) = host_claude {
copy_dir_all(&h.join(".claude"), &claude_dir).ok()?;
}
if let Some(h) = host_json {
std::fs::copy(h.join(".claude.json"), root.join(".claude.json")).ok()?;
}
if let Some(t) = &token {
write_claude_credentials_file(&claude_dir, t)?;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(json) = root.join(".claude.json").canonicalize() {
let _ = std::fs::set_permissions(&json, std::fs::Permissions::from_mode(0o600));
}
if let Ok(creds) = claude_dir.join(".credentials.json").canonicalize() {
let _ = std::fs::set_permissions(&creds, std::fs::Permissions::from_mode(0o600));
}
}
Some(root)
}
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 copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let path = entry.path();
let dest = dst.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_dir_all(&path, &dest)?;
} else {
std::fs::copy(&path, &dest)?;
}
}
Ok(())
}
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() {
return Err(format!("did not produce its result file '{result}'"));
}
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, clone: &Path, base: &str, skill: &str, stamp: &str,
) -> Result<Option<Integration>, String> {
let tip = match git_capture(clone, &["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); }
if !git_status_ok(root, &["fetch", "--no-tags", "--quiet", &clone.to_string_lossy(), "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 v1\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("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 host_ids() -> (u32, u32) {
(id_value("-u").unwrap_or(1000), id_value("-g").unwrap_or(1000))
}
fn id_value(flag: &str) -> Option<u32> {
let out = Command::new("id").arg(flag).output().ok()?;
out.status.success().then(|| String::from_utf8_lossy(&out.stdout).trim().parse().ok()).flatten()
}
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,
) -> 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);
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,
);
let out = build.run(&cmd[0], &cmd[1..]).map_err(started);
let _ = std::fs::remove_dir_all(&dir); out?
}
};
if ok {
Ok(())
} else {
let msg = match last {
Some(l) if !l.is_empty() => format!("image build failed: {l}"),
_ => "image build failed".into(),
};
Err((msg, 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 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 three routes by default (opencode+GPT, claude+Sonnet,");
println!("opencode+GLM); `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 print_help(topic: HelpTopic) {
match topic {
HelpTopic::Overview => print_help_overview(),
HelpTopic::Config => print_help_config(),
HelpTopic::Internals => print_help_internals(),
HelpTopic::Cache => print_help_cache(),
}
}
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 and run the skills in parallel (bare names select profiles).");
help_row(
"list (alias: ls)",
"List every skill by profile — result, commits, env (--verbose: + internals; --json: machine-readable).",
);
help_row("check-profile <name>", "Exit 0 if a profile exists with at least one skill (runtime-free; for scripts).");
help_row("init-demo-project", "Scaffold + commit a ready-to-run demo project.");
help_row(
"installskills [url…]",
"Install skills — bundled, or one+ repos' (merging each .scsh.yml); needs a clean tree.",
);
help_row("updateskills [url…]", "Reinstall skills, overwriting files — bundled or repos'.");
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 .scsh.yml", "The project config file: every field + env syntax.");
help_row("scsh help internals", "How a run works: preflight, clone, image, results.");
help_row("scsh help cache", "How results are cached, and when a re-run is a hit.");
println!();
println!("{}", h_head("Options:"));
help_row("[profile…] / --profile <names>", "run only these profiles — bare after `run` (`run a b`) or a comma/semicolon list (`default` = the no-profile skills).");
help_row("--verbose", "with list, also print the image Dockerfile and exact commands.");
help_row("--json", "with list, print the profiles and their skills as JSON (runtime-free).");
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 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_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#" skills: # the only top-level key: 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)
model: openai/... # optional; the model the harness passes to the tool
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!();
}
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 one image per harness needed (`scsh-opencode`, `scsh-claude`) from a shared
base in parallel (skipping any whose tag already matches the embedded Dockerfile fingerprint),
version-checking each CLI during the build. Then, for EVERY selected skill in
parallel, it makes a fresh full clone of this repo into 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 — all branches) and runs the skill's harness
in its own container with that clone bind-mounted at /home/agent/repo (the WORKDIR).
The clone is mounted UNDER the agent's home, not as it, so the harness's home-dir
scratch (~/.cache, ~/.config, ~/.npm) stays out of the cloned tree. Files the skill
writes are owned by you on the host.
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):
scsh git-clones on the HOST into /tmp/scsh-*-run-* and bind-mounts at /home/agent/repo
(push into the container). Skills must not git fetch, pull, push, or clone inside.
After the container exits, scsh on the HOST pulls OUT: (1) the result file — always;
(2) new commits from the run clone — only when commits: true AND the skill committed —
via local fetch from the clone path and cherry-pick (never from GitHub). 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).
Unavailable harnesses 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 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 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,
timeout: None,
env: Vec::new(),
profile: None,
commits: false,
result: "tmp/r.json".into(),
}
}
#[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");
}
}