mod adapter;
mod ask;
mod auth;
mod base;
mod bundled;
mod carry;
mod config;
mod container;
mod derive;
mod detect;
mod doctor;
mod editor;
mod facts;
mod hook;
mod idle;
mod image;
mod mcp;
mod memory;
mod notice;
mod out;
mod persist;
mod profile;
mod render;
mod report;
mod rules;
mod runtime;
mod selection;
mod session;
mod settings;
mod ssh;
mod stack;
mod why;
use adapter::Adapter;
use anyhow::Context;
use anyhow::Result;
use clap::{Parser, Subcommand};
use profile::{Paths, Profile};
use session::Session;
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use std::process::Command;
#[derive(Parser)]
#[command(name = "omh", version, about, long_about = None)]
struct Cli {
#[arg(long, global = true)]
dry_run: bool,
#[arg(long, short, global = true)]
session: Option<String>,
#[arg(long, global = true, conflicts_with = "session")]
new: bool,
#[arg(long, short = 'a', global = true)]
account: Option<String>,
#[arg(long, global = true)]
json: bool,
#[arg(long, global = true, value_name = "WHEN", default_value = "auto")]
color: out::Color,
#[command(subcommand)]
cmd: Cmd,
}
impl Cli {
fn output(&self) -> (out::Format, out::Palette) {
if self.json {
return (out::Format::Json, out::Palette::plain());
}
let no_color = std::env::var("NO_COLOR").ok();
let palette = out::Palette::resolve(
self.color,
no_color.as_deref(),
std::io::IsTerminal::is_terminal(&std::io::stdout()),
);
(out::Format::Human, palette)
}
}
fn omh_globals() -> Vec<String> {
use clap::CommandFactory;
Cli::command()
.get_arguments()
.filter(|a| a.is_global_set())
.filter_map(|a| a.get_long().map(|long| format!("--{long}")))
.collect()
}
fn passthrough(argv: &[String], globals: &[String]) -> Result<Vec<String>> {
let mut out = vec![argv[0].clone()];
let mut rest = argv[1..].iter();
for arg in rest.by_ref() {
if arg == "--" {
break;
}
if globals.iter().any(|g| g == arg) {
anyhow::bail!(
"`{arg}` is omh's flag, not {}'s, and everything after a harness \
name belongs to the harness\n \
try omh {arg} {}\n \
or omh {} -- {arg} to pass it on regardless",
argv[0],
argv[0],
argv[0]
);
}
out.push(arg.clone());
}
out.extend(rest.cloned());
Ok(out)
}
pub const RESERVED: [&str; 19] = [
"init", "doctor", "d", "auth", "ls", "attach", "a", "sessions", "s", "config", "c", "graph",
"why", "memory", "help", "use", "unuse", "repo", "import",
];
#[derive(Subcommand)]
enum Cmd {
Init,
#[command(visible_alias = "d")]
Doctor { harness: Option<String> },
Why {
thing: String,
},
Graph {
session: Option<String>,
#[arg(long)]
stop: bool,
},
Auth {
harness: String,
#[arg(default_value = auth::DEFAULT_ACCOUNT)]
account: String,
},
Ls,
#[command(visible_alias = "a")]
Attach {
editor: Option<String>,
},
#[command(visible_alias = "s")]
Sessions {
#[command(subcommand)]
cmd: SessionsCmd,
},
#[command(visible_alias = "c")]
Config {
#[command(subcommand)]
cmd: Option<ConfigCmd>,
},
Repo {
#[command(subcommand)]
cmd: Option<RepoCmd>,
},
Use {
capability: Option<String>,
name: Option<String>,
#[arg(long)]
all: bool,
},
Unuse { capability: String, name: String },
Memory {
#[command(subcommand)]
cmd: Option<MemoryCmd>,
},
Import {
capability: String,
harness: String,
#[arg(long)]
from: Option<std::path::PathBuf>,
},
#[command(external_subcommand)]
Run(Vec<String>),
}
#[derive(Subcommand)]
enum McpCmd {
Ls,
Add {
name: String,
command: String,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
#[arg(long = "env", value_parser = parse_env)]
env: Vec<(String, String)>,
},
Rm { name: String },
Import {
harness: String,
#[arg(long)]
file: Option<std::path::PathBuf>,
#[arg(long)]
force: bool,
},
}
#[derive(Subcommand)]
enum SessionsCmd {
Ls,
Rm { session: String },
Down { session: Option<String> },
Diff {
session: Option<String>,
#[arg(long)]
base: Option<String>,
},
Commit {
#[arg(short = 'm', long)]
message: Option<String>,
#[arg(long)]
skip_carried: bool,
},
Push {
name: Option<String>,
#[arg(long)]
pr: bool,
},
}
#[derive(Subcommand)]
enum ConfigCmd {
Set {
key: String,
value: String,
#[arg(long, value_parser = parse_layer, hide = true)]
layer: Option<config::Layer>,
},
Unset {
key: String,
#[arg(long, value_parser = parse_layer, hide = true)]
layer: Option<config::Layer>,
},
Edit {
capability: Option<String>,
name: Option<String>,
#[arg(long, value_parser = parse_layer, hide = true)]
layer: Option<config::Layer>,
},
Mcp {
#[command(subcommand)]
cmd: McpCmd,
},
}
#[derive(Subcommand)]
enum RepoCmd {
Enable { feature: String },
Disable { feature: String },
Set {
key: String,
value: String,
#[arg(long)]
shared: bool,
},
Unset {
key: String,
#[arg(long)]
shared: bool,
},
}
#[derive(Subcommand)]
enum MemoryCmd {
Remember {
#[arg(long)]
expected: String,
#[arg(long)]
observed: String,
#[arg(long)]
evidence: String,
#[arg(long = "answers")]
answers: Vec<String>,
#[arg(long = "relates-to")]
relates_to: Vec<String>,
#[arg(long)]
invalidated_by: Option<String>,
#[arg(long)]
source: Option<String>,
#[arg(long, value_parser = parse_if_exists, default_value = "error")]
if_exists: memory::IfExists,
},
#[command(hide = true)]
Serve {
#[arg(long)]
team: std::path::PathBuf,
#[arg(long)]
local: std::path::PathBuf,
#[arg(long)]
session: Option<String>,
},
Promote {
#[arg(required = true)]
keys: Vec<String>,
},
Stale,
Lint,
Rm {
key: String,
#[arg(long, value_parser = parse_note_layer)]
layer: Option<memory::Layer>,
#[arg(long)]
at: Option<String>,
},
}
fn main() -> std::process::ExitCode {
#[cfg(unix)]
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
}
let cli = Cli::parse();
let (format, palette) = cli.output();
let ctx = out::Ctx { format, palette };
match dispatch(&cli, &ctx) {
Ok(()) => std::process::ExitCode::SUCCESS,
Err(e) => {
eprint!("{}", out::problem(&ctx.palette, &e));
std::process::ExitCode::FAILURE
}
}
}
fn dispatch(cli: &Cli, ctx: &out::Ctx) -> Result<()> {
let cwd = std::env::current_dir()?;
match &cli.cmd {
Cmd::Init => init(&cwd, ctx),
Cmd::Auth { harness, account } => auth_cmd(&cwd, harness, account, ctx),
Cmd::Ls => ls(&cwd, ctx),
Cmd::Doctor { harness } => doctor_cmd(&cwd, harness.as_deref(), cli.dry_run, ctx),
Cmd::Why { thing } => why_cmd(&cwd, thing, ctx),
Cmd::Graph { session, stop } => graph(&cwd, session.as_deref(), *stop, ctx),
Cmd::Attach { editor } => attach(&cwd, cli.session.as_deref(), editor.as_deref(), ctx),
Cmd::Sessions { cmd } => match cmd {
SessionsCmd::Ls => sessions_ls(&cwd, ctx),
SessionsCmd::Rm { session } => rm(&cwd, session, ctx),
SessionsCmd::Down { session } => down(&cwd, session.as_deref(), ctx),
SessionsCmd::Diff { session, base } => diff(
&cwd,
session.as_deref().or(cli.session.as_deref()),
base.as_deref(),
ctx,
),
SessionsCmd::Commit {
message,
skip_carried,
} => commit(
&cwd,
cli.session.as_deref(),
message.as_deref(),
*skip_carried,
ctx,
),
SessionsCmd::Push { name, pr } => {
push(&cwd, cli.session.as_deref(), name.as_deref(), *pr, ctx)
}
},
Cmd::Config { cmd } => match cmd {
None => show_config(&cwd, ctx),
Some(ConfigCmd::Set { key, value, layer }) => set(
&cwd,
key,
value,
layer_or(*layer, config::Layer::Personal, ctx),
ctx,
),
Some(ConfigCmd::Unset { key, layer }) => unset(
&cwd,
key,
layer_or(*layer, config::Layer::Personal, ctx),
ctx,
),
Some(ConfigCmd::Edit {
capability,
name,
layer,
}) => edit(
&cwd,
capability.as_deref(),
name.as_deref(),
layer_or(*layer, config::Layer::Personal, ctx),
),
Some(ConfigCmd::Mcp { cmd }) => mcp(&cwd, cmd, cli.dry_run, ctx),
},
Cmd::Repo { cmd } => match cmd {
None => show_repo(&cwd, ctx),
Some(RepoCmd::Enable { feature }) => feature_switch(&cwd, feature, true, ctx),
Some(RepoCmd::Disable { feature }) => feature_switch(&cwd, feature, false, ctx),
Some(RepoCmd::Set { key, value, shared }) => {
set(&cwd, key, value, repo_layer(*shared), ctx)
}
Some(RepoCmd::Unset { key, shared }) => unset(&cwd, key, repo_layer(*shared), ctx),
},
Cmd::Use {
capability,
name,
all,
} => use_cmd(&cwd, capability.as_deref(), name.as_deref(), *all, ctx),
Cmd::Unuse { capability, name } => unuse_cmd(&cwd, capability, name, ctx),
Cmd::Import {
capability,
harness,
from,
} => import_cmd(&cwd, capability, harness, from.as_deref(), ctx),
Cmd::Memory { cmd } => match cmd {
None => memory_ls(&cwd, ctx),
Some(MemoryCmd::Lint) => memory_lint(&cwd, ctx),
Some(MemoryCmd::Stale) => memory_stale(&cwd, ctx),
Some(MemoryCmd::Promote { keys }) => memory_promote(&cwd, keys, ctx),
Some(MemoryCmd::Serve {
team,
local,
session,
}) => memory_serve(team.clone(), local.clone(), session.clone()),
Some(MemoryCmd::Rm { key, layer, at }) => {
memory_rm(&cwd, key, *layer, at.as_deref(), ctx)
}
Some(MemoryCmd::Remember {
expected,
observed,
evidence,
answers,
relates_to,
invalidated_by,
source,
if_exists,
}) => memory_remember(
&cwd,
memory::Remembered {
expected: expected.clone(),
observed: observed.clone(),
evidence: evidence.clone(),
answers: answers.clone(),
relates_to: relates_to.clone(),
invalidated_by: invalidated_by.clone(),
source: source.clone().unwrap_or_default(),
recorded: memory::today(),
},
*if_exists,
cli.session.as_deref(),
ctx,
),
},
Cmd::Run(argv) => run(&cwd, &passthrough(argv, &omh_globals())?, cli, ctx),
}
}
fn tool_hint(name: &str, harnesses: &[String], editors: &[String]) -> String {
if editors.iter().any(|e| e == name) {
return format!("`{name}` is an editor — try `omh attach {name}`");
}
if RESERVED.contains(&name) {
return format!("`{name}` is a command — see `omh {name} --help`");
}
format!(
"unknown harness `{name}`\n available: {}",
harnesses.join(", ")
)
}
fn unknown_tool(paths: &Paths, name: &str, original: anyhow::Error) -> anyhow::Error {
let harnesses: Vec<String> = Adapter::load_dir(&paths.adapters())
.unwrap_or_default()
.into_iter()
.map(|a| a.name)
.collect();
if harnesses.is_empty() {
return original;
}
let editors: Vec<String> = editor::Editor::load_dir(&paths.editors())
.unwrap_or_default()
.into_iter()
.map(|e| e.name)
.collect();
anyhow::anyhow!("{}", tool_hint(name, &harnesses, &editors))
}
fn reuse_decision(
backend: &dyn runtime::Runtime,
name: &str,
plan: &container::Plan,
session: &Session,
) -> container::Reuse {
let probe = backend.exec_args(
name,
&[
"sh".into(),
"-c".into(),
format!("ls -1 {} 2>/dev/null || true", persist::SOCKET_DIR),
],
false,
);
let Some(listing) = image::container_probe(backend.program(), &probe) else {
return container::reuse(false, &Default::default(), plan, &[]);
};
container::reuse(
true,
&image::container_stamp(backend.program(), name),
plan,
&persist::live(&session.id, &listing),
)
}
fn session_up(
paths: &Paths,
profile: &Profile,
adapter: &Adapter,
session: &Session,
opts: container::Options,
recipe: &[&str],
ctx: &out::Ctx,
) -> Result<(Box<dyn runtime::Runtime>, String)> {
let backend = runtime::select(&runtime_preference(paths), &|p| runtime::installed(p))?;
let name = paths.container(&session.id);
let running = image::container_running(backend.program(), &name);
let mut opts = opts;
match memory::deliver::ensure(
backend.program(),
paths,
std::path::Path::new(env!("CARGO_MANIFEST_DIR")),
) {
Ok(bin) => opts.memory_bin = Some(bin),
Err(e) => {
ctx.warn(&format!("memory server unavailable — {e:#}"));
opts.memory_bin = None;
}
}
say_selection(paths, profile, &opts.repo, ctx);
let plan = container::plan(paths, profile, adapter, session, &[], opts)?;
plan.validate(&backend.caps())?;
if running {
match reuse_decision(backend.as_ref(), &name, &plan, session) {
container::Reuse::Attach => return Ok((backend, name)),
container::Reuse::Blocked { live, changed } => anyhow::bail!(
"session {id} is running {} and cannot be reused for this launch \
({})\n stop it with omh s down {id}\n \
or start a fresh one omh --new {}",
live.join(", "),
changed.join(", "),
adapter.name,
id = session.id,
),
container::Reuse::Restart(why) => {
ctx.progress(&format!(
"restarting the sandbox for {} — {}",
session.label(),
why.join(", ")
));
let _ = image::container_remove(backend.program(), &name);
}
}
}
say_rules(&plan, ctx);
image::ensure_stack(backend.program(), adapter, recipe)?;
image::ensure_network(backend.program(), &plan.network)?;
let key = ssh::ensure_key(&paths.keys())?;
let pubkey = std::fs::read_to_string(key.with_extension("pub"))?;
let port = ssh::port(&paths.repo_name(), &session.id);
let _ = image::container_remove(backend.program(), &name); let args = backend.up_args(&plan, &name, port, pubkey.trim());
let out = Command::new(backend.program()).args(&args).output()?;
if !out.status.success() {
anyhow::bail!(
"starting session {}: {}",
session.id,
String::from_utf8_lossy(&out.stderr).trim()
);
}
let project = base::project_name(&paths.repo_name(), &session.id);
let _ = Command::new(backend.program())
.args(backend.exec_args(
&name,
&[
base::GRAPH_BIN.into(),
"cli".into(),
"index_repository".into(),
"--repo-path".into(),
container_workdir().into(),
"--name".into(),
project,
"--mode".into(),
"fast".into(),
],
false,
))
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
Ok((backend, name))
}
fn attach(
cwd: &std::path::Path,
id: Option<&str>,
chosen: Option<&str>,
ctx: &out::Ctx,
) -> Result<()> {
let paths = Paths::discover(cwd)?;
let profile = Profile::resolve(&paths);
let names: Vec<String> = Adapter::load_dir(&paths.adapters())?
.into_iter()
.map(|a| a.name)
.collect();
let harness = detect::preferred_harness(&names, &|h| runtime::installed(h))
.context("no adapters installed — run `omh init`")?;
let adapter = Adapter::find(&paths.adapters(), &harness)?;
let (own, repo) = resolved(&paths)?;
let mut sandbox = sandbox(&paths, &adapter, &repo)?;
if let Ok(backend) = runtime::select(&runtime_preference(&paths), &|p| runtime::installed(p)) {
sandbox.top_up(
&paths,
backend.program(),
&adapter,
&profile.sources(adapter::Capability::Hooks)?,
&own,
&repo,
ctx,
)?;
}
std::fs::create_dir_all(paths.worktrees())?;
let id = session::pick(&paths.worktrees(), id, false);
let session = Session::new(&paths.worktrees(), id);
session.ensure(&paths.repo, &session::default_branch(&paths.repo))?;
carry_in(&paths, &session, ctx)?;
let _ = idle::touch(&paths.runs(), &session.id);
let configured = policy_value(&paths, "account");
let account = auth::resolve_for_launch(&paths, &adapter, None, configured.as_deref())?
.map(|a| auth::dir(&paths, &adapter.name, &a));
if let Some(account_dir) = &account {
auth::prepare(&adapter, account_dir, auth::GUEST_HOME)?;
}
for d in render::held_back(
&profile.sources(adapter::Capability::Hooks)?,
&own,
&repo,
&sandbox.resolves,
)? {
ctx.warn(&format!("`{}` needs {} — held back", d.name, d.wanted));
}
session_up(
&paths,
&profile,
&adapter,
&session,
container::Options {
staging: container::Staging::Apply,
persist: persist::Mode::None,
tty: false,
account_dir: account,
memory_bin: memory::deliver::available(&paths),
base: Some(session::default_branch(&paths.repo)),
omh: own,
repo,
image: sandbox.tag.clone(),
resolves: sandbox.resolves.clone(),
},
&sandbox.recipe(),
ctx,
)?;
let home = dirs::home_dir().context("no home directory")?;
let alias = ssh::host_alias(&paths.repo_name(), &session.id);
let key = ssh::ensure_key(&paths.keys())?;
let blocks: Vec<String> = session::list(&paths.worktrees())
.into_iter()
.map(|s| {
ssh::config_block(
&ssh::host_alias(&paths.repo_name(), &s),
ssh::port(&paths.repo_name(), &s),
&key,
)
})
.collect();
ssh::write_hosts(&home.join(".ssh/config.d/omh"), &blocks)?;
ssh::ensure_include(&home.join(".ssh/config"))?;
let fallback = std::env::var("OMH_EDITOR")
.or_else(|_| std::env::var("EDITOR"))
.ok()
.and_then(|e| {
let base = std::path::Path::new(&e)
.file_name()?
.to_string_lossy()
.into_owned();
Some(base)
});
let wanted = chosen.map(str::to_string).or(fallback);
let ed = wanted
.as_deref()
.and_then(|n| editor::Editor::find(&paths.editors(), n));
let editors: Vec<(String, String)> = editor::Editor::load_dir(&paths.editors())?
.into_iter()
.map(|e| (e.name.clone(), e.command(&alias).join(" ")))
.collect();
let opened_in = match ed {
Some(ed) if runtime::installed(&ed.bin) => {
let cmd = ed.command(&alias);
let ok = Command::new(&cmd[0])
.args(&cmd[1..])
.status()
.map(|s| s.success());
if matches!(ok, Ok(true)) {
Some(ed.name.clone())
} else {
ctx.warn(&format!("{} did not open the session", ed.name));
None
}
}
other => {
if let Some(ed) = other {
ctx.warn(&format!("`{}` is not installed on this machine", ed.bin));
} else if let Some(w) = &wanted {
ctx.warn(&format!("no editor named `{w}` — see `omh ls`"));
}
None
}
};
ctx.say(&report::Attached {
session: session.id.clone(),
url: ssh::url(&alias),
alias,
opened_in,
editors,
});
Ok(())
}
fn graph(cwd: &std::path::Path, _id: Option<&str>, stop: bool, ctx: &out::Ctx) -> Result<()> {
let paths = Paths::discover(cwd)?;
let backend = runtime::select(&runtime_preference(&paths), &|p| runtime::installed(p))?;
let container = base::ui_container(&paths.repo_name());
if stop {
if !image::container_running(backend.program(), &container) {
ctx.say(
&report::Action::new("graph-not-running", "the graph is not running")
.data(serde_json::json!({ "running": false })),
);
return Ok(());
}
image::container_remove(backend.program(), &container)?;
ctx.say(
&report::Action::new("graph-stopped", "graph stopped; sessions keep running")
.data(serde_json::json!({ "running": false })),
);
return Ok(());
}
let port = base::ui_port(&container);
if !image::container_running(backend.program(), &container) {
let _ = image::container_remove(backend.program(), &container);
let names: Vec<String> = Adapter::load_dir(&paths.adapters())?
.into_iter()
.map(|a| a.name)
.collect();
let harness = detect::preferred_harness(&names, &|h| runtime::installed(h))
.context("no adapters installed — run `omh init`")?;
let adapter = Adapter::find(&paths.adapters(), &harness)?;
image::ensure(backend.program(), &adapter)?;
let out = Command::new(backend.program())
.args(base::ui_run_args(
&image::tag_for(&adapter),
&container,
&paths.cache_volume(),
port,
))
.output()?;
if !out.status.success() {
anyhow::bail!(
"could not start the graph: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
std::thread::sleep(std::time::Duration::from_millis(1500));
}
let url = format!("http://127.0.0.1:{port}");
ctx.say(
&report::Action::new("graph-started", format!("graph at {url}"))
.next("omh graph --stop")
.data(serde_json::json!({ "url": url, "port": port, "running": true })),
);
ctx.hint("every session's graph for this repo, in one place");
let _ = Command::new(if cfg!(target_os = "macos") {
"open"
} else {
"xdg-open"
})
.arg(&url)
.status();
Ok(())
}
fn reap_idle(paths: &Paths, launching: &str, ctx: &out::Ctx) {
let Some(raw) = policy_value(paths, "idle_timeout") else {
return;
};
let Some(timeout) = idle::parse_duration(&raw) else {
ctx.warn(&format!(
"ignoring idle_timeout `{raw}` — expected a duration like 30m, 2h, 90s"
));
return;
};
let Ok(backend) = runtime::select(&runtime_preference(paths), &|p| runtime::installed(p))
else {
return;
};
let running: Vec<(String, Option<std::time::SystemTime>)> = session::list(&paths.worktrees())
.into_iter()
.filter(|id| image::container_running(backend.program(), &paths.container(id)))
.map(|id| {
let last = idle::last_used(&paths.runs(), &id);
(id, last)
})
.collect();
for id in idle::expired(&running, timeout, std::time::SystemTime::now(), launching) {
match image::container_remove(backend.program(), &paths.container(&id)) {
Ok(()) => ctx.progress(&format!(
"stopped {id} — idle over {raw} (worktree and branch survive)"
)),
Err(e) => ctx.warn(&format!("could not stop idle session {id}: {e}")),
}
}
}
fn down(cwd: &std::path::Path, id: Option<&str>, ctx: &out::Ctx) -> Result<()> {
let paths = Paths::discover(cwd)?;
let backend = runtime::select(&runtime_preference(&paths), &|p| runtime::installed(p))?;
let ids = match id {
Some(i) => vec![i.to_string()],
None => session::list(&paths.worktrees()),
};
let mut sessions = Vec::new();
let mut stuck = 0usize;
for i in &ids {
let name = paths.container(i);
if !image::container_running(backend.program(), &name) {
sessions.push((i.clone(), false));
continue;
}
match image::container_remove(backend.program(), &name) {
Ok(()) => sessions.push((i.clone(), true)),
Err(e) => {
stuck += 1;
ctx.warn(&format!("{i} is still running: {e}"));
}
}
}
ctx.say(&report::Down { sessions });
anyhow::ensure!(
stuck == 0,
"{stuck} session{} would not stop",
if stuck == 1 { "" } else { "s" }
);
Ok(())
}
fn doctor_cmd(
cwd: &std::path::Path,
harness: Option<&str>,
dry_run: bool,
ctx: &out::Ctx,
) -> Result<()> {
let paths = Paths::discover(cwd)?;
let profile = Profile::resolve(&paths);
let name = match harness {
Some(h) => h.to_string(),
None => {
let names: Vec<String> = Adapter::load_dir(&paths.adapters())?
.into_iter()
.map(|a| a.name)
.collect();
detect::preferred_harness(&names, &|h| runtime::installed(h))
.context("no adapters installed — run `omh init`")?
}
};
let adapter = Adapter::find(&paths.adapters(), &name)?;
let configured = policy_value(&paths, "account");
let account = auth::resolve_for_launch(&paths, &adapter, None, configured.as_deref())
.unwrap_or(None)
.map(|a| auth::dir(&paths, &name, &a));
let (own, repo) = resolved(&paths)?;
let mut sandbox = sandbox(&paths, &adapter, &repo)?;
if let Ok(backend) = runtime::select(&runtime_preference(&paths), &|p| runtime::installed(p)) {
sandbox.top_up(
&paths,
backend.program(),
&adapter,
&profile.sources(adapter::Capability::Hooks)?,
&own,
&repo,
ctx,
)?;
}
let mut checks = doctor::checks(&profile, &adapter, &own, &repo, &sandbox.resolves)?;
if account.is_some() {
checks.extend(doctor::credential_checks(&adapter));
}
let declared = render::parse_layers(&profile.sources(adapter::Capability::Mcp)?)?;
if let Some(server) = declared
.get(memory::tools::SERVER_KEY)
.filter(|_| !repo.disabled_servers.contains(memory::tools::SERVER_KEY))
{
checks.extend(doctor::memory_checks(server));
}
if checks.is_empty() {
ctx.say(
&report::Action::new(
"doctor-nothing-to-check",
"nothing to check: the profile is empty",
)
.data(serde_json::json!({ "harness": name, "checks": 0 })),
);
return Ok(());
}
let session = Session::scratch(paths.scratch("doctor"), "doctor".into());
session.ensure(&paths.repo, "")?;
let opts = container::Options {
staging: container::Staging::Apply,
persist: persist::Mode::None,
tty: false,
account_dir: account.clone(),
memory_bin: memory::deliver::available(&paths),
base: Some(session::default_branch(&paths.repo)),
omh: own,
repo,
image: sandbox.tag.clone(),
resolves: sandbox.resolves.clone(),
};
if let Some(account_dir) = &account {
auth::prepare(&adapter, account_dir, auth::GUEST_HOME)?;
}
say_selection(&paths, &profile, &opts.repo, ctx);
let mut plan = container::plan(&paths, &profile, &adapter, &session, &[], opts)?;
say_rules(&plan, ctx);
plan.argv = vec!["sh".into(), "-c".into(), doctor::probe_script(&checks)];
let backend = runtime::select(&runtime_preference(&paths), &|p| runtime::installed(p))?;
plan.validate(&backend.caps())?;
if dry_run {
ctx.say(&report::Probe {
script: doctor::probe_script(&checks),
checks: checks.iter().map(|c| c.name.clone()).collect(),
});
return Ok(());
}
image::ensure_stack(backend.program(), &adapter, &sandbox.recipe())?;
image::ensure_network(backend.program(), &plan.network)?;
let account_name = account
.as_ref()
.map(|a| a.file_name().unwrap_or_default().to_string_lossy().into());
ctx.progress(&match &account_name {
Some(a) => format!("checking {name} in {} as {a}…", sandbox.tag),
None => format!(
"checking {name} in {} — no account, so credentials go unchecked…",
sandbox.tag
),
});
let out = Command::new(backend.program())
.args(backend.args(&plan))
.output()?;
let outcomes = doctor::parse(&String::from_utf8_lossy(&out.stdout));
let _ = session.remove(&paths.repo, "");
if outcomes.is_empty() {
anyhow::bail!(
"the probe produced no output — the sandbox did not run it\n{}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
let report = report::Doctor {
harness: name,
tag: sandbox.tag.clone(),
account: account_name,
outcomes,
};
ctx.say(&report);
if !report.passed() {
anyhow::bail!(
"{} of {} checks failed",
report.failed(),
report.outcomes.len()
);
}
Ok(())
}
fn sessions_ls(cwd: &std::path::Path, ctx: &out::Ctx) -> Result<()> {
let paths = Paths::discover(cwd)?;
let backend = runtime::select(&runtime_preference(&paths), &|p| runtime::installed(p)).ok();
let base = session::default_branch(&paths.repo);
let sessions = session::list(&paths.worktrees())
.into_iter()
.map(|id| {
let sess = Session::new(&paths.worktrees(), id.clone());
report::Session {
running: backend
.as_ref()
.map(|b| image::container_running(b.program(), &paths.container(&id)))
.unwrap_or(false),
label: sess.label().to_string(),
work: Some(work_state(&sess, &paths.repo, &base)),
behind: sess.behind(&paths.repo, &base),
id,
}
})
.collect();
ctx.say(&report::Sessions {
sessions,
leftovers: leftovers(&paths, backend.as_deref()),
base,
});
Ok(())
}
fn leftovers(paths: &Paths, backend: Option<&dyn runtime::Runtime>) -> Vec<String> {
let live = session::list(&paths.worktrees());
let mut found: Vec<String> = std::fs::read_dir(paths.runs())
.into_iter()
.flatten()
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|id| idle::last_used(&paths.runs(), id).is_some())
.collect();
if let Some(backend) = backend {
let prefix = paths.container("");
if let Ok(out) = Command::new(backend.program())
.args(["ps", "-a", "--format", "{{.Names}}"])
.output()
{
found.extend(
String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|n| n.trim().strip_prefix(&prefix))
.map(str::to_string),
);
}
}
found.retain(|id| !live.contains(id));
found.sort();
found.dedup();
found
}
fn work_state(session: &Session, repo: &std::path::Path, base: &str) -> report::Work {
use report::Work;
let (uncommitted, unpushed) = match (session.uncommitted(), session.unpushed()) {
(Ok(uncommitted), Ok(unpushed)) => (uncommitted, unpushed),
_ => return Work::Unknown,
};
if let n @ 1.. = uncommitted {
return Work::Uncommitted(n);
}
match unpushed {
Some(n @ 1..) => Work::ToPush(n),
Some(_) => match session.published_as() {
Ok(Some(target)) => Work::Published(target),
Ok(None) => Work::Clean,
Err(_) => Work::Unknown,
},
None => match session.commits(repo, base) {
0 => Work::Clean,
n => Work::ToPush(n),
},
}
}
fn policy_value(paths: &Paths, key: &str) -> Option<String> {
config::policy(paths)
.ok()?
.into_iter()
.find(|s| s.key == key)
.map(|s| s.value)
}
fn runtime_preference(paths: &Paths) -> String {
policy_value(paths, "runtime").unwrap_or_else(|| "auto".into())
}
fn parse_layer(s: &str) -> std::result::Result<config::Layer, String> {
s.parse().map_err(|e: anyhow::Error| e.to_string())
}
fn parse_note_layer(s: &str) -> std::result::Result<memory::Layer, String> {
s.parse().map_err(|e: anyhow::Error| e.to_string())
}
pub fn container_workdir() -> &'static str {
"/work"
}
fn parse_if_exists(s: &str) -> std::result::Result<memory::IfExists, String> {
match s {
"error" => Ok(memory::IfExists::Error),
"skip" => Ok(memory::IfExists::Skip),
"suffix" => Ok(memory::IfExists::Suffix),
"override" => Ok(memory::IfExists::Override),
other => Err(format!(
"unknown --if-exists `{other}` (error, skip, suffix, override)"
)),
}
}
fn memory_remember(
cwd: &std::path::Path,
mut input: memory::Remembered,
if_exists: memory::IfExists,
session: Option<&str>,
ctx: &out::Ctx,
) -> Result<()> {
let paths = Paths::discover(cwd)?;
if input.source.trim().is_empty() {
input.source = match session {
Some(id) => format!("session {id}, cli"),
None => "cli".into(),
};
}
ctx.say(&match memory::remember(&paths, &input, if_exists)? {
memory::Wrote::Created(path) => {
report::Action::new("note-recorded", format!("recorded {}", path.display()))
.data(serde_json::json!({ "path": path.display().to_string(), "replaced": false }))
}
memory::Wrote::Replaced(path) => report::Action::new(
"note-replaced",
format!(
"replaced {} — the note that was there is gone",
path.display()
),
)
.data(serde_json::json!({ "path": path.display().to_string(), "replaced": true })),
memory::Wrote::Skipped(key) => report::Action::new(
"note-already-there",
format!("`{key}` is already recorded; left alone"),
)
.data(serde_json::json!({ "key": key, "replaced": false })),
});
Ok(())
}
fn seed_store(paths: &Paths) -> Result<String> {
let templates = memory::templates(paths)?;
let today = memory::today();
let dir = memory::Layer::Team.dir(paths);
let mut written = 0;
let mut skipped = 0;
let mut stubs = Vec::new();
for doc in memory::ingest::documents(&paths.repo)? {
let note = memory::ingest::stub(&doc, &templates, &today)?;
stubs.push(note.key.clone());
match memory::ingest::write(&dir, ¬e, memory::IfExists::Skip)? {
true => written += 1,
false => skipped += 1,
}
}
let seeds = detect::seeds(
&stack::load_all(&paths.stacks(), &paths.repo_stacks())?,
&paths.repo,
);
if let Some(note) =
memory::ingest::overview(&paths.repo_name(), &seeds, &stubs, &templates, &today)?
{
if memory::ingest::write(&dir, ¬e, memory::IfExists::Skip)? {
written += 1;
} else {
skipped += 1;
}
}
if written == 0 && skipped == 0 {
return Ok("nothing to derive yet".into());
}
Ok(format!(
"{written} note{} written, {skipped} already there",
if written == 1 { "" } else { "s" }
))
}
fn memory_serve(
team: std::path::PathBuf,
local: std::path::PathBuf,
session: Option<String>,
) -> Result<()> {
let mut server = memory::tools::Server {
team,
local,
templates: memory::shipped_templates(),
session: session
.or_else(|| std::env::var("OMH_SESSION").ok())
.unwrap_or_else(|| "unknown".into()),
client: None,
today: memory::today,
};
let stdin = std::io::stdin().lock();
let stdout = std::io::stdout().lock();
mcp::serve(stdin, stdout, &mut server)
}
fn memory_stale(cwd: &std::path::Path, ctx: &out::Ctx) -> Result<()> {
use memory::expiry::Verdict;
let paths = Paths::discover(cwd)?;
let judged = memory::expiry::judge(&paths, &memory::load(&paths)?)?;
fn age(verdict: &Verdict) -> report::Age {
match verdict {
Verdict::Stale { .. } => report::Age::Stale,
Verdict::Unknown { .. } => report::Age::Unknown,
Verdict::NoTrigger => report::Age::NoTrigger,
Verdict::Fresh => report::Age::Fresh,
}
}
let report = report::Stale {
judged: judged
.iter()
.map(|j| report::Judged {
key: j.key.clone(),
layer: j.layer.to_string(),
recorded: j.recorded.clone(),
age: age(&j.verdict),
because: match &j.verdict {
Verdict::Stale { because } | Verdict::Unknown { because } => {
Some(because.clone())
}
Verdict::NoTrigger | Verdict::Fresh => None,
},
})
.collect(),
};
let stale = report.count(report::Age::Stale);
let unknown = report.count(report::Age::Unknown);
ctx.say(&report);
if stale > 0 {
anyhow::bail!(
"{stale} note{} the world has moved past",
if stale == 1 { "" } else { "s" }
);
}
if unknown > 0 {
std::process::exit(2);
}
Ok(())
}
fn memory_promote(cwd: &std::path::Path, keys: &[String], ctx: &out::Ctx) -> Result<()> {
let paths = Paths::discover(cwd)?;
let notes = memory::load(&paths)?;
let repo = paths.repo.clone();
let steps = match memory::promote::plan(¬es, &paths, keys, &|p: &std::path::Path| {
memory::promote::git_ignores(&repo, p)
}) {
Ok(steps) => steps,
Err(blocked) => {
for b in &blocked {
ctx.warn(&b.say());
}
anyhow::bail!("promoted nothing");
}
};
memory::promote::apply(&steps)?;
ctx.say(&report::Promoted {
text: memory::promote::report(&steps, &paths),
keys: steps.iter().map(|s| s.key.clone()).collect(),
});
Ok(())
}
fn memory_ls(cwd: &std::path::Path, ctx: &out::Ctx) -> Result<()> {
let paths = Paths::discover(cwd)?;
ctx.say(&report::Notes {
notes: memory::load(&paths)?,
});
Ok(())
}
fn memory_lint(cwd: &std::path::Path, ctx: &out::Ctx) -> Result<()> {
let paths = Paths::discover(cwd)?;
let found = memory::lint(&paths)?;
let tally = memory::tally(&found);
let report = report::Lint {
violations: found,
tally,
};
ctx.say(&report);
let refused = report.refused();
if refused > 0 {
anyhow::bail!(
"{refused} violation{} the schema refuses",
if refused == 1 { "" } else { "s" }
);
}
Ok(())
}
fn memory_rm(
cwd: &std::path::Path,
key: &str,
layer: Option<memory::Layer>,
at: Option<&str>,
ctx: &out::Ctx,
) -> Result<()> {
let paths = Paths::discover(cwd)?;
let removed = memory::remove(&paths, layer, key, at)?;
let mut action =
report::Action::new("note-removed", format!("removed {key} ({})", removed.layer)).data(
serde_json::json!({
"key": key,
"layer": removed.layer.to_string(),
"committed": removed.layer.is_committed(),
"inbound": removed.inbound,
}),
);
if removed.layer.is_committed() {
action = action.note("it was committed — teammates keep it until you commit the deletion");
}
if !removed.inbound.is_empty() {
action = action.note(format!(
"still linked from {} — those links now dangle, and `omh memory lint` lists them",
removed.inbound.join(", ")
));
}
ctx.say(&action);
Ok(())
}
fn parse_env(s: &str) -> std::result::Result<(String, String), String> {
s.split_once('=')
.map(|(k, v)| (k.to_string(), v.to_string()))
.ok_or_else(|| format!("expected KEY=VALUE, got `{s}`"))
}
fn mcp(cwd: &std::path::Path, cmd: &McpCmd, dry_run: bool, ctx: &out::Ctx) -> Result<()> {
let paths = Paths::discover(cwd)?;
match cmd {
McpCmd::Ls => show_servers(cwd, ctx),
McpCmd::Add {
name,
command,
args,
env,
} => {
let server = render::Server {
command: command.clone(),
args: args.clone(),
env: env.iter().cloned().collect(),
};
let w = config::mcp_add(&paths, name, server)?;
let mut action =
report::Action::new("mcp-added", format!("wrote → {}", w.path.display())).data(
serde_json::json!({ "server": name, "path": w.path.display().to_string() }),
);
if !env.is_empty() {
action = action.note(format!(
"this env applies in every repo. For one repo only, put \
[mcp.{name}.env] in .omh/{}",
settings::LOCAL
));
}
ctx.say(&action);
Ok(())
}
McpCmd::Rm { name } => {
let removed = config::mcp_remove(&paths, name)?;
ctx.say(
&report::Action::new(
if removed { "mcp-removed" } else { "mcp-absent" },
if removed {
format!("removed {name} from your catalogue")
} else {
format!("{name} is not in your catalogue")
},
)
.data(serde_json::json!({ "server": name, "removed": removed })),
);
Ok(())
}
McpCmd::Import {
harness,
file,
force,
} => {
let adapter = Adapter::find(&paths.adapters(), harness)?;
let binding = adapter
.supports(adapter::Capability::Mcp)
.with_context(|| format!("{harness} has no MCP capability to import from"))?;
let home = dirs::home_dir().context("no home directory")?;
let source = match file {
Some(f) => f.clone(),
None => {
let template = binding.import.as_deref().with_context(|| {
format!("adapter {harness} does not say where to import from; pass --file")
})?;
adapter::expand_host(template, &home, &paths.repo)
}
};
let raw = std::fs::read_to_string(&source).with_context(|| {
format!(
"reading {} — pass --file to point somewhere else",
source.display()
)
})?;
let incoming = render::parse(binding.render, &raw)?;
let outcome = config::mcp_import(&paths, incoming, *force, dry_run)?;
let wrote = (!dry_run && !outcome.added.is_empty())
.then(|| config::mcp_path(&paths).display().to_string());
let considered = outcome
.added
.iter()
.map(|name| report::Considered {
name: name.clone(),
verdict: report::Verdict::Took,
detail: String::new(),
})
.chain(outcome.unchanged.iter().map(|name| report::Considered {
name: name.clone(),
verdict: report::Verdict::Kept,
detail: "already identical".into(),
}))
.chain(outcome.conflicts.iter().map(|name| report::Considered {
name: name.clone(),
verdict: report::Verdict::Conflict,
detail: "differs — keeping yours; --force to overwrite".into(),
}))
.collect();
ctx.say(&report::Imported {
what: harness.clone(),
source: source.display().to_string(),
considered,
noun: "servers".into(),
dry_run,
wrote,
selected_in: Vec::new(),
});
Ok(())
}
}
}
fn repo_has_selection(paths: &Paths) -> Result<bool> {
config::declares(paths, config::Layer::Shared, config::USE)
}
fn show_servers(cwd: &std::path::Path, ctx: &out::Ctx) -> Result<()> {
let paths = Paths::discover(cwd)?;
ctx.say(&report::Servers {
servers: config::servers(&paths)?
.into_iter()
.map(|s| report::Setting {
key: s.key,
value: s.value,
whose: Some(s.layer.whose().to_string()),
})
.collect(),
});
Ok(())
}
fn use_cmd(
cwd: &std::path::Path,
capability: Option<&str>,
name: Option<&str>,
all: bool,
ctx: &out::Ctx,
) -> Result<()> {
let paths = Paths::discover(cwd)?;
if all {
if capability.is_some() {
anyhow::bail!("`--all` resyncs every capability — it takes no arguments");
}
let lists = catalogue_lists(&paths)?;
ctx.say(&report::Resynced {
wrote: write_lists(&paths, &lists)?
.into_iter()
.map(|w| w.path.display().to_string())
.collect(),
counts: lists
.iter()
.map(|(cap, names)| (cap.to_string(), names.len()))
.collect(),
});
return Ok(());
}
let (Some(key), Some(name)) = (capability, name) else {
anyhow::bail!(
"omh use <capability> <name>, or omh use --all\n capabilities: {}",
capability_list()
);
};
let (cap, mut names, was_open) = current_list(&paths, key, name)?;
let available = catalogue_names(&paths, cap)?;
if !available.iter().any(|n| n == name) {
anyhow::bail!(
"your catalogue has no {cap} called `{name}`. `omh config edit {cap} {name}` \
creates it.\n {cap}: {}",
if available.is_empty() {
"(empty)".to_string()
} else {
available.join(", ")
}
);
}
let already = names.iter().any(|n| n == name);
if already && !was_open {
ctx.say(
&report::Action::new(
"capability-already-used",
format!("{cap}/{name} is already used here"),
)
.data(serde_json::json!({
"capability": cap.to_string(),
"name": name,
"changed": false,
})),
);
return Ok(());
}
if !already {
names.push(name.to_string());
}
let written = write_lists(
&paths,
&std::collections::BTreeMap::from([(cap, names.clone())]),
)?;
let froze = was_open.then(|| {
format!(
"{cap} was following your whole catalogue; wrote its {} entries as the list",
names.len()
)
});
let paths = written_paths(&written);
let mut action = report::Action::new("capability-used", format!("using {cap}/{name}")).data(
serde_json::json!({
"capability": cap.to_string(),
"name": name,
"changed": true,
"froze_selection": was_open,
"paths": paths,
}),
);
if let Some(line) = &froze {
action = action.note(line);
}
for path in &paths {
action = action.note(format!("wrote → {path}"));
}
ctx.say(&action);
Ok(())
}
fn written_paths(written: &[config::Written]) -> Vec<String> {
written
.iter()
.map(|w| w.path.display().to_string())
.collect()
}
fn unuse_cmd(cwd: &std::path::Path, key: &str, name: &str, ctx: &out::Ctx) -> Result<()> {
let paths = Paths::discover(cwd)?;
let (cap, mut names, was_open) = current_list(&paths, key, name)?;
if !names.iter().any(|n| n == name) {
anyhow::bail!(
"{cap}/{name} is not used here. `omh repo` lists what is.\n \
using: {}",
if names.is_empty() {
"nothing".to_string()
} else {
names.join(", ")
}
);
}
names.retain(|n| n != name);
let froze = was_open.then(|| {
format!(
"{cap} was following your whole catalogue; wrote its remaining {} entries as the list",
names.len()
)
});
let remaining = names.len();
let written = write_lists(&paths, &std::collections::BTreeMap::from([(cap, names)]))?;
let paths = written_paths(&written);
let mut action =
report::Action::new("capability-unused", format!("no longer using {cap}/{name}")).data(
serde_json::json!({
"capability": cap.to_string(),
"name": name,
"froze_selection": was_open,
"remaining": remaining,
"paths": paths,
}),
);
if let Some(line) = &froze {
action = action.note(line);
}
for path in &paths {
action = action.note(format!("wrote → {path}"));
}
ctx.say(&action);
Ok(())
}
fn write_lists(
paths: &Paths,
lists: &std::collections::BTreeMap<adapter::Capability, Vec<String>>,
) -> Result<Vec<config::Written>> {
let mut out = Vec::new();
for (cap, names) in lists {
let one = std::collections::BTreeMap::from([(*cap, names.clone())]);
for layer in config::declaring(paths, config::USE, &cap.to_string())? {
out.push(config::write_selection(paths, layer, &one)?);
}
}
out.sort_by(|a, b| a.path.cmp(&b.path));
out.dedup_by(|a, b| a.path == b.path);
Ok(out)
}
fn import_cmd(
cwd: &std::path::Path,
capability: &str,
harness: &str,
from: Option<&std::path::Path>,
ctx: &out::Ctx,
) -> Result<()> {
let cap = adapter::Capability::from_key(capability).with_context(|| {
format!(
"`{capability}` is not a capability — expected {}",
capability_list()
)
})?;
let paths = Paths::discover(cwd)?;
let adapter = Adapter::find(&paths.adapters(), harness)?;
let binding = adapter
.supports(cap)
.with_context(|| format!("{harness} has no {cap} for omh to read"))?;
let source = match from {
Some(f) => f.to_path_buf(),
None => {
let template = binding.import.as_deref().with_context(|| {
format!(
"{harness} keeps its {cap} somewhere omh cannot read — \
`omh import {capability} {harness} --from <path>` if you know where"
)
})?;
let home = dirs::home_dir().context("no home directory")?;
adapter::expand_host(template, &home, &paths.repo)
}
};
if !source.exists() {
ctx.say(
&report::Action::new(
"import-nothing-there",
format!("{harness} has no {cap} here ({})", source.display()),
)
.data(serde_json::json!({
"harness": harness,
"capability": cap.to_string(),
"source": source.display().to_string(),
"exists": false,
})),
);
return Ok(());
}
match cap {
adapter::Capability::Hooks => import_hooks(&paths, &adapter, binding, &source, ctx),
adapter::Capability::Mcp => anyhow::bail!(
"MCP servers are `omh config mcp import {harness}` — a server is a \
record in one file, not an entry with its own"
),
_ => import_entries(&paths, harness, cap, binding.render, &source, ctx),
}
}
fn import_entries(
paths: &Paths,
harness: &str,
cap: adapter::Capability,
render: adapter::Render,
source: &std::path::Path,
ctx: &out::Ctx,
) -> Result<()> {
let dest = paths.root.join(cap.source());
let entries: Vec<(String, std::path::PathBuf)> = match render {
adapter::Render::Concat => vec![(format!("{harness}.md"), source.to_path_buf())],
_ => {
let mut found = Vec::new();
let listing = std::fs::read_dir(source)
.with_context(|| format!("reading {}", source.display()))?;
for entry in listing {
let path = entry
.with_context(|| format!("reading {}", source.display()))?
.path();
let name = path.file_name().unwrap_or_default().to_string_lossy();
found.push((name.into_owned(), path));
}
found.sort();
found
}
};
let mut considered = Vec::new();
for (name, from) in entries {
let stem = std::path::Path::new(&name)
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
if let Err(e) = selection::validate_entry_name(&stem, cap, source) {
considered.push(report::Considered {
name,
verdict: report::Verdict::Skipped,
detail: format!("{e:#}"),
});
continue;
}
let to = dest.join(if from.is_dir() {
stem.clone()
} else {
name.clone()
});
if to.exists() {
considered.push(report::Considered {
name: stem,
verdict: report::Verdict::Kept,
detail: "already in your catalogue".into(),
});
continue;
}
considered.push(match copy_entry(&from, &to) {
Ok(()) => report::Considered {
name: stem,
verdict: report::Verdict::Took,
detail: String::new(),
},
Err(e) => report::Considered {
name: stem,
verdict: report::Verdict::Skipped,
detail: format!("{e:#}"),
},
});
}
let took = considered
.iter()
.any(|c| c.verdict == report::Verdict::Took);
ctx.say(&report::Imported {
what: format!("{harness} {cap}"),
source: source.display().to_string(),
considered,
noun: cap.to_string(),
dry_run: false,
wrote: took.then(|| dest.display().to_string()),
selected_in: Vec::new(),
});
Ok(())
}
fn copy_entry(from: &std::path::Path, to: &std::path::Path) -> Result<()> {
refuse_symlinks(from)?;
if let Err(e) = copy_tree(from, to) {
let undone = if to.is_dir() {
std::fs::remove_dir_all(to)
} else {
std::fs::remove_file(to)
};
if let Err(u) = undone {
return Err(e).with_context(|| {
format!(
"and {} could not be removed ({u}) — a partial copy is still \
there, and the next import will report it as an entry you \
already have. Delete it before re-running.",
to.display()
)
});
}
return Err(e);
}
Ok(())
}
fn refuse_symlinks(from: &std::path::Path) -> Result<()> {
let meta =
std::fs::symlink_metadata(from).with_context(|| format!("reading {}", from.display()))?;
anyhow::ensure!(
!meta.file_type().is_symlink(),
"{} is a symlink, and omh will not copy one into a catalogue that is \
mounted into every sandbox",
from.display()
);
if meta.is_dir() {
let listing =
std::fs::read_dir(from).with_context(|| format!("reading {}", from.display()))?;
for entry in listing {
refuse_symlinks(&entry?.path())?;
}
}
Ok(())
}
fn copy_tree(from: &std::path::Path, to: &std::path::Path) -> Result<()> {
if from.is_dir() {
std::fs::create_dir_all(to)?;
let listing =
std::fs::read_dir(from).with_context(|| format!("reading {}", from.display()))?;
for entry in listing {
let child = entry?.path();
let name = child
.file_name()
.context("a path from read_dir has a name")?;
copy_tree(&child, &to.join(name))?;
}
return Ok(());
}
if let Some(parent) = to.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(from, to).with_context(|| format!("copying {}", from.display()))?;
Ok(())
}
fn importable(paths: &Paths, harnesses: &[String]) -> Vec<String> {
let Some(home) = dirs::home_dir() else {
return Vec::new();
};
let mut out = Vec::new();
for name in harnesses {
let Ok(adapter) = Adapter::find(&paths.adapters(), name) else {
continue;
};
let Some(binding) = adapter.supports(adapter::Capability::Hooks) else {
continue;
};
let Some(template) = binding.import.as_deref() else {
continue;
};
let source = adapter::expand_host(template, &home, &paths.repo);
let raw = match std::fs::read_to_string(&source) {
Ok(raw) => raw,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => {
out.push(format!(
"import {name}'s hooks are at {} and omh could not read \
it ({e})",
source.display()
));
continue;
}
};
let Ok(vocab) = hook::Vocabulary::of(binding, &adapter.tools) else {
continue;
};
let (found, residue) = match render::parse_hooks(&raw, &vocab) {
Ok(v) => v,
Err(e) => {
out.push(format!(
"import {name} has hooks in {} that omh could not read \
({e:#}) — omh import hooks {name} to see why",
source.display()
));
continue;
}
};
if found.is_empty() && residue.is_empty() {
continue;
}
out.push(format!(
"import {name} has {} hook{} omh can read{} — omh import hooks {name}",
found.len(),
if found.len() == 1 { "" } else { "s" },
if residue.is_empty() {
String::new()
} else {
format!(" and {} it cannot", residue.len())
}
));
}
for name in harnesses {
let Ok(adapter) = Adapter::find(&paths.adapters(), name) else {
continue;
};
for cap in adapter::Capability::ALL {
if matches!(cap, adapter::Capability::Hooks | adapter::Capability::Mcp) {
continue;
}
let Some(template) = adapter.supports(cap).and_then(|b| b.import.as_deref()) else {
continue;
};
let source = adapter::expand_host(template, &home, &paths.repo);
let held = match std::fs::read_dir(&source) {
Ok(listing) => listing.count(),
Err(_) if source.is_file() => 1,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => 0,
Err(e) => {
out.push(format!(
"import {name}'s {cap} are at {} and omh could not \
read it ({e})",
source.display()
));
continue;
}
};
if held > 0 {
out.push(format!(
"import {name} has {held} {cap} — omh import {cap} {name}"
));
}
}
}
out
}
fn import_hooks(
paths: &Paths,
adapter: &Adapter,
binding: &adapter::Binding,
source: &std::path::Path,
ctx: &out::Ctx,
) -> Result<()> {
let harness = &adapter.name;
let raw =
std::fs::read_to_string(source).with_context(|| format!("reading {}", source.display()))?;
let vocab = hook::Vocabulary::of(binding, &adapter.tools)
.with_context(|| format!("reading {harness}'s vocabulary backwards"))?;
let (found, residue) = render::parse_hooks(&raw, &vocab)?;
let manifest = base::Manifest::load_dir(&paths.base())?;
let reserved: std::collections::BTreeSet<String> = manifest
.owns()
.get(&adapter::Capability::Hooks)
.map(|owned| owned.keys().cloned().collect())
.unwrap_or_default();
let dir = paths.repo.join(".omh/hooks");
let mut considered = Vec::new();
let mut written = Vec::new();
for (name, hook) in &found {
if reserved.contains(name) {
considered.push(report::Considered {
name: name.clone(),
verdict: report::Verdict::Skipped,
detail: "omh ships a hook by that name".into(),
});
continue;
}
let path = dir.join(format!("{name}.json"));
if path.exists() {
considered.push(report::Considered {
name: name.clone(),
verdict: report::Verdict::Kept,
detail: "already here, left as it is".into(),
});
continue;
}
std::fs::create_dir_all(&dir)?;
std::fs::write(&path, format!("{}\n", serde_json::to_string_pretty(hook)?))?;
considered.push(report::Considered {
name: name.clone(),
verdict: report::Verdict::Took,
detail: hook.does().to_string(),
});
written.push(name.clone());
}
let mut selected_in = Vec::new();
if !written.is_empty() && repo_has_selection(paths)? {
let (cap, mut names, _) = current_list(paths, "hooks", &written[0])?;
names.extend(written.iter().cloned());
names.sort();
names.dedup();
let lists = std::collections::BTreeMap::from([(cap, names)]);
for w in write_lists(paths, &lists)? {
selected_in.push(w.path.display().to_string());
}
}
for d in &residue {
considered.push(report::Considered {
name: d.name.clone(),
verdict: report::Verdict::Left,
detail: d.wanted.clone(),
});
}
ctx.say(&report::Imported {
what: format!("{harness} hooks"),
source: source.display().to_string(),
considered,
noun: "hooks".into(),
dry_run: false,
wrote: (!written.is_empty()).then(|| dir.display().to_string()),
selected_in,
});
Ok(())
}
fn current_list(
paths: &Paths,
key: &str,
name: &str,
) -> Result<(adapter::Capability, Vec<String>, bool)> {
let cap = adapter::Capability::from_key(key).with_context(|| {
format!(
"`{key}` is not a capability — expected {}",
capability_list()
)
})?;
let manifest = base::Manifest::load_dir(&paths.base())?;
let policy = settings::resolve(paths, &manifest)?;
let file = config::Layer::Shared.file(paths);
selection::validate_entry_name(name, cap, &file)?;
if let Some(feature) = manifest
.owns()
.get(&cap)
.and_then(|owned| owned.get(name))
.cloned()
{
anyhow::bail!(
"{cap}/{name} is omh's — part of the `{feature}` feature. `[use]` names \
your entries; a feature is all or nothing, so `omh repo enable {feature}` \
and `omh repo disable {feature}` are its switches."
);
}
match policy.selection.order(cap) {
Some(names) => Ok((cap, names.to_vec(), false)),
None => Ok((cap, catalogue_names(paths, cap)?, true)),
}
}
fn catalogue_lists(
paths: &Paths,
) -> Result<std::collections::BTreeMap<adapter::Capability, Vec<String>>> {
let mut out = std::collections::BTreeMap::new();
for cap in adapter::Capability::ALL {
out.insert(cap, catalogue_names(paths, cap)?);
}
Ok(out)
}
fn covered_here(
hook_dirs: &[std::path::PathBuf],
detected: &[&stack::Definition],
) -> Result<BTreeSet<String>> {
Ok(render::declared_stacks(hook_dirs)?
.into_values()
.flatten()
.filter(|named| detected.iter().any(|d| &d.name == named))
.collect())
}
fn applicable_hooks(
names: Vec<String>,
declared: &BTreeMap<String, Option<String>>,
detected: &BTreeSet<String>,
) -> Vec<String> {
names
.into_iter()
.filter(|n| match declared.get(n) {
Some(Some(stack)) => detected.contains(stack),
_ => true,
})
.collect()
}
fn catalogue_names(paths: &Paths, cap: adapter::Capability) -> Result<Vec<String>> {
let manifest = base::Manifest::load_dir(&paths.base())?;
let owned = manifest.owns();
let profile = Profile::resolve(paths);
let names: Vec<String> = profile
.entries(cap)?
.into_iter()
.filter(|n| !owned.get(&cap).is_some_and(|o| o.contains_key(n)))
.collect();
if cap != adapter::Capability::Hooks {
return Ok(names);
}
let defs = stack::load_all(&paths.stacks(), &paths.repo_stacks())?;
let detected: BTreeSet<String> = stack::detected(&defs, &paths.repo)
.into_iter()
.map(|d| d.name.clone())
.collect();
let declared = render::declared_stacks(&profile.sources(cap)?)?;
Ok(applicable_hooks(names, &declared, &detected))
}
fn show_config(cwd: &std::path::Path, ctx: &out::Ctx) -> Result<()> {
let paths = Paths::discover(cwd)?;
let profile = Profile::resolve(&paths);
let mut catalogue = Vec::new();
for cap in adapter::Capability::ALL {
catalogue.push(report::Catalogue {
capability: cap.to_string(),
entries: profile.entries(cap)?,
});
}
ctx.say(&report::Config {
defaults_file: config::Layer::Personal.file(&paths).display().to_string(),
settings: config::policy(&paths)?
.into_iter()
.filter(|s| s.layer == config::Layer::Personal)
.map(|s| report::Setting {
key: s.key,
value: s.value,
whose: None,
})
.collect(),
catalogue_dir: paths.root.display().to_string(),
catalogue,
});
Ok(())
}
fn show_repo(cwd: &std::path::Path, ctx: &out::Ctx) -> Result<()> {
let paths = Paths::discover(cwd)?;
let profile = Profile::resolve(&paths);
let manifest = base::Manifest::load_dir(&paths.base())?;
let policy = settings::resolve(&paths, &manifest)?;
let settings = config::policy(&paths)?
.into_iter()
.map(|s| report::Effective {
key: s.key,
value: s.value,
layer: s.layer.to_string(),
shadows: s.shadows.iter().map(|l| l.to_string()).collect(),
})
.collect();
let mut names: Vec<&str> = manifest
.entries
.iter()
.map(|e| e.feature.as_str())
.collect();
names.sort();
names.dedup();
let features = names
.into_iter()
.map(|feature| report::Feature {
name: feature.to_string(),
on: !policy.off.contains(feature),
})
.collect();
let mut using = Vec::new();
for cap in adapter::Capability::ALL {
let entries = profile.entries(cap)?;
let unselected = policy.selection.unselected(cap, &entries);
using.push(report::Using {
capability: cap.to_string(),
selected: policy.selection.order(cap).map(|order| {
order
.iter()
.filter(|n| entries.iter().any(|e| e == *n))
.cloned()
.collect()
}),
unselected,
});
}
ctx.say(&report::Repo {
dir: paths.repo.join(".omh").display().to_string(),
settings,
features,
using,
notices: notice::selection(&profile, &policy.selection, &catalogue_lists(&paths)?)?,
});
Ok(())
}
fn layer_or(named: Option<config::Layer>, default: config::Layer, ctx: &out::Ctx) -> config::Layer {
let Some(layer) = named else {
return default;
};
let replacement = match layer {
config::Layer::Personal => "omh config set",
config::Layer::Shared => "omh repo set --shared",
config::Layer::Local => "omh repo set",
};
ctx.warn(&format!(
"--layer {layer} is going away — that is `{replacement}` now. \
Two scopes, two commands: `omh config` is you, `omh repo` is this checkout."
));
layer
}
fn repo_layer(shared: bool) -> config::Layer {
if shared {
config::Layer::Shared
} else {
config::Layer::Local
}
}
fn set(
cwd: &std::path::Path,
key: &str,
value: &str,
layer: config::Layer,
ctx: &out::Ctx,
) -> Result<()> {
let paths = Paths::discover(cwd)?;
let w = config::set(&paths, key, value, layer)?;
ctx.say(
&report::Action::new("setting-written", format!("wrote → {}", w.path.display())).data(
serde_json::json!({
"key": key,
"value": value,
"layer": w.layer.to_string(),
"committed": w.committed,
"path": w.path.display().to_string(),
}),
),
);
if w.committed {
ctx.warn(&format!(
"the {} layer is COMMITTED — never put a secret here",
w.layer
));
}
Ok(())
}
fn unset(cwd: &std::path::Path, key: &str, layer: config::Layer, ctx: &out::Ctx) -> Result<()> {
let paths = Paths::discover(cwd)?;
let removed = config::unset(&paths, key, layer)?;
ctx.say(
&report::Action::new(
if removed {
"setting-removed"
} else {
"setting-absent"
},
if removed {
format!("removed {key} from the {layer} layer")
} else {
format!("{key} was not set in the {layer} layer")
},
)
.data(serde_json::json!({
"key": key,
"layer": layer.to_string(),
"removed": removed,
})),
);
Ok(())
}
fn edit(
cwd: &std::path::Path,
capability: Option<&str>,
name: Option<&str>,
layer: config::Layer,
) -> Result<()> {
let paths = Paths::discover(cwd)?;
let file = match capability {
None => layer.file(&paths),
Some(key) => {
let cap = adapter::Capability::from_key(key).with_context(|| {
format!(
"`{key}` is not a capability — expected {}",
capability_list()
)
})?;
let dir = paths.root.join(cap.source());
match name {
None => dir,
Some(name) => {
selection::validate_entry_name(name, cap, &dir)?;
dir.join(name)
}
}
}
};
if let Some(parent) = file.parent() {
std::fs::create_dir_all(parent)?;
}
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".into());
Command::new(editor).arg(&file).status()?;
Ok(())
}
fn capability_list() -> String {
adapter::Capability::ALL
.iter()
.map(adapter::Capability::to_string)
.collect::<Vec<_>>()
.join(", ")
}
fn feature_switch(cwd: &std::path::Path, feature: &str, on: bool, ctx: &out::Ctx) -> Result<()> {
let paths = Paths::discover(cwd)?;
let manifest = base::Manifest::load_dir(&paths.base())?;
let features: std::collections::BTreeSet<&str> = manifest
.entries
.iter()
.map(|e| e.feature.as_str())
.collect();
if !features.contains(feature) {
if let Some(entry) = manifest.entry(feature) {
anyhow::bail!(
"`{feature}` is part of the `{}` feature, not a feature itself. \
A feature is all or nothing — `omh repo disable {}` switches all of it off.",
entry.feature,
entry.feature
);
}
anyhow::bail!(
"`{feature}` is not one of omh's features ({}). \
A catalogue entry of yours is `omh use`/`omh unuse`.",
features.into_iter().collect::<Vec<_>>().join(", ")
);
}
let mut written = Vec::new();
for layer in config::declaring(&paths, config::OMH, feature)? {
written.push(config::write_feature(&paths, layer, feature, on)?);
}
let paths = written_paths(&written);
let mut action = report::Action::new(
if on { "feature-on" } else { "feature-off" },
format!("{feature} is {} here", if on { "on" } else { "off" }),
)
.data(serde_json::json!({
"feature": feature,
"on": on,
"paths": paths,
}));
if !on {
action = action.note("nothing was uninstalled; the next repo gets it back");
}
for path in &paths {
action = action.note(format!("wrote → {path}"));
}
ctx.say(&action);
Ok(())
}
fn say_rules(plan: &container::Plan, ctx: &out::Ctx) {
for notice in plan.rules.notices() {
ctx.warn(¬ice.to_string());
}
}
fn say_hooks(paths: &Paths, ctx: &out::Ctx) -> Option<notice::Record> {
let defs = match stack::load_all(&paths.stacks(), &paths.repo_stacks()) {
Ok(defs) => defs,
Err(e) => {
ctx.warn(&format!(
"could not read your stacks, so this repo's hooks went unchecked — {e:#}"
));
return None;
}
};
let dirs = match Profile::resolve(paths).sources(adapter::Capability::Hooks) {
Ok(dirs) => dirs,
Err(e) => {
ctx.warn(&format!("could not read your hooks — {e:#}"));
return None;
}
};
let declared = match render::declared_stacks(&dirs) {
Ok(declared) => declared,
Err(e) => {
ctx.warn(&format!(
"could not read your hooks, so drift went unchecked — {e:#}"
));
return None;
}
};
let detected = stack::detected(&defs, &paths.repo);
match notice::hooks(paths, &detected, &declared) {
Ok((notices, record)) => {
for notice in notices {
ctx.warn(¬ice.to_string());
}
Some(record)
}
Err(e) => {
ctx.warn(&format!("could not check this repo's hooks — {e:#}"));
None
}
}
}
fn say_selection(paths: &Paths, profile: &Profile, repo: &settings::RepoPolicy, ctx: &out::Ctx) {
let applicable = match catalogue_lists(paths) {
Ok(lists) => lists,
Err(e) => {
ctx.warn(&format!("could not check what this repo uses — {e:#}"));
return;
}
};
match notice::selection(profile, &repo.selection, &applicable) {
Ok(notices) => {
for notice in notices {
ctx.warn(¬ice.to_string());
}
}
Err(e) => ctx.warn(&format!("could not check what this repo uses — {e:#}")),
}
}
fn remember_hooks(record: Option<notice::Record>, ctx: &out::Ctx) {
if let Some(record) = record {
if let Err(e) = record.commit() {
ctx.warn(&format!("this repo's hooks were not recorded — {e:#}"));
}
}
}
fn carry_in(paths: &Paths, session: &Session, ctx: &out::Ctx) -> Result<()> {
carry::hide_staged_rules(&session.worktree)?;
let patterns = config::policy_list(paths, "carry_in");
if patterns.is_empty() {
return Ok(());
}
for item in carry::apply(&paths.repo, &session.worktree, &patterns)? {
match item.action {
carry::Action::Copied => ctx.progress(&format!("carried {}", item.path)),
carry::Action::Refreshed => ctx.progress(&format!("refreshed {}", item.path)),
carry::Action::AlreadyTracked => ctx.warn(&format!(
"carry_in lists {} — git already tracks it, so the worktree has it \
already. Not carried; drop it with `omh repo set carry_in`.",
item.path
)),
carry::Action::Missing => ctx.warn(&format!(
"carry_in lists {} — not in this checkout",
item.path
)),
carry::Action::Unchanged => {}
}
}
Ok(())
}
fn run(cwd: &std::path::Path, argv: &[String], cli: &Cli, ctx: &out::Ctx) -> Result<()> {
let paths = Paths::discover(cwd)?;
let name = &argv[0];
let adapter =
Adapter::find(&paths.adapters(), name).map_err(|e| unknown_tool(&paths, name, e))?;
let profile = Profile::resolve(&paths);
let configured = policy_value(&paths, "account");
let account = auth::resolve_for_launch(
&paths,
&adapter,
cli.account.as_deref(),
configured.as_deref(),
)?
.map(|a| auth::dir(&paths, name, &a));
if let Some(account_dir) = &account {
auth::prepare(&adapter, account_dir, auth::GUEST_HOME)?;
}
let base = session::default_branch(&paths.repo);
let (own, repo) = resolved(&paths)?;
let mut sandbox = sandbox(&paths, &adapter, &repo)?;
if !cli.dry_run {
if let Ok(backend) =
runtime::select(&runtime_preference(&paths), &|p| runtime::installed(p))
{
sandbox.top_up(
&paths,
backend.program(),
&adapter,
&profile.sources(adapter::Capability::Hooks)?,
&own,
&repo,
ctx,
)?;
}
}
let opts = container::Options {
staging: if cli.dry_run {
container::Staging::Skip
} else {
container::Staging::Apply
},
persist: policy_value(&paths, "persistence")
.as_deref()
.unwrap_or("dtach")
.parse()?,
tty: true,
account_dir: account,
memory_bin: memory::deliver::available(&paths),
base: Some(base.clone()),
omh: own,
repo,
image: sandbox.tag.clone(),
resolves: sandbox.resolves.clone(),
};
std::fs::create_dir_all(paths.worktrees())?;
if let Some(explicit) = cli.session.as_deref() {
session::validate_id(explicit)?;
}
let id = session::pick(&paths.worktrees(), cli.session.as_deref(), cli.new);
let session = Session::new(&paths.worktrees(), id);
if opts.staging == container::Staging::Apply {
session.ensure(&paths.repo, &base)?;
carry_in(&paths, &session, ctx)?;
reap_idle(&paths, &session.id, ctx);
let _ = idle::touch(&paths.runs(), &session.id);
}
let plan = container::plan(
&paths,
&profile,
&adapter,
&session,
&argv[1..],
opts.clone(),
)?;
let backend = runtime::select(&runtime_preference(&paths), &|p| runtime::installed(p))?;
plan.validate(&backend.caps())?;
say_rules(&plan, ctx);
say_selection(&paths, &profile, &opts.repo, ctx);
let hooks_seen = say_hooks(&paths, ctx);
let status_line = match plan.degradation() {
Some(d) => format!("{} on {} — {d}", adapter.name, session.label()),
None => format!("{} on {}", adapter.name, session.label()),
};
if cli.dry_run {
ctx.say(&report::DryRun {
status: status_line,
worktree: session.worktree.display().to_string(),
argv: std::iter::once(backend.program().to_string())
.chain(backend.args(&plan))
.collect(),
});
return Ok(());
}
let (backend, name) = session_up(
&paths,
&profile,
&adapter,
&session,
container::Options {
tty: false,
..opts.clone()
},
&sandbox.recipe(),
ctx,
)?;
remember_hooks(hooks_seen, ctx);
ctx.announce(&status_line);
let status = Command::new(backend.program())
.args(backend.exec_args(&name, &plan.argv, true))
.status()?;
ctx.hint(&format!("\nreview with omh s diff {}", session.id));
std::process::exit(status.code().unwrap_or(1));
}
fn resolved(paths: &Paths) -> Result<(base::Own, settings::RepoPolicy)> {
let manifest = base::Manifest::load_dir(&paths.base())?;
let repo = settings::resolve(paths, &manifest)?;
let installed = config::servers(paths)?.into_iter().map(|s| s.key).collect();
Ok((base::own(&manifest, &repo.off, &installed)?, repo))
}
fn why_cmd(cwd: &std::path::Path, thing: &str, ctx: &out::Ctx) -> Result<()> {
let paths = Paths::discover(cwd)?;
let manifest = base::Manifest::load_dir(&paths.base())?;
let mut installed = config::servers(&paths)?;
installed.extend(config::hooks(&paths)?);
let baselines: std::collections::BTreeMap<String, String> = manifest
.entries
.iter()
.filter_map(|e| e.command.clone().map(|c| (e.name.clone(), c)))
.collect();
let mut derived = std::collections::BTreeMap::new();
let stack_defs = stack::load_all(&paths.stacks(), &paths.repo_stacks())?;
let detected = stack::detected(&stack_defs, &paths.repo);
let (own, repo_policy) = resolved(&paths)?;
let merged = render::merge_hooks(
&Profile::resolve(&paths).sources(adapter::Capability::Hooks)?,
&own,
&repo_policy,
)?;
for (name, hook) in &merged {
let Some(stack) = hook.stack.as_deref() else {
continue;
};
let Some(def) = detected.iter().find(|d| d.name == stack) else {
continue;
};
derived.insert(
name.clone(),
why::Derived {
from: format!("{}, detected from {}", def.name, def.marker),
command: hook.does().to_string(),
layer: config::Layer::Shared,
},
);
}
let source = manifest.source();
let version = manifest.version.clone();
let catalog = why::Catalog {
off: settings::resolve(&paths, &manifest)?.off,
manifest: &manifest,
baselines,
installed,
derived,
};
ctx.say(&report::Why {
thing: thing.to_string(),
text: why::render_with_source(&catalog, &catalog.why(thing), &version, &source),
});
Ok(())
}
fn init(cwd: &std::path::Path, ctx: &out::Ctx) -> Result<()> {
let paths = Paths::discover(cwd)?;
let mut summary = report::Init::default();
let adapters = install_bundled_adapters(&paths, ctx)?;
let editors = install_bundled(&paths.editors(), bundled::Shipped::Editors, ctx)?;
install_bundled(&paths.base(), bundled::Shipped::Base, ctx)?;
install_bundled(&paths.stacks(), bundled::Shipped::Stacks, ctx)?;
install_bundled(&paths.hooks(), bundled::Shipped::Hooks, ctx)?;
install_bundled(&paths.markers(), bundled::Shipped::Markers, ctx)?;
let manifest = base::Manifest::load_dir(&paths.base())?;
std::fs::create_dir_all(paths.worktrees())?;
for cap in adapter::Capability::ALL {
if cap != adapter::Capability::Mcp {
std::fs::create_dir_all(paths.root.join(cap.source()))?;
}
}
let stack_defs = stack::load_all(&paths.stacks(), &paths.repo_stacks())?;
let stacks = stack::detected(&stack_defs, &paths.repo);
let names: Vec<String> = adapters.to_vec();
let harness = detect::preferred_harness(&names, &|h| runtime::installed(h));
let repo_omh = paths.repo.join(".omh");
std::fs::create_dir_all(repo_omh.join("hooks"))?;
for layer in memory::Layer::ALL {
std::fs::create_dir_all(layer.dir(&paths))?;
}
write_if_absent(&repo_omh.join(memory::TEMPLATES), memory::SHIPPED_KEYS)?;
let base_mcp =
serde_json::to_string_pretty(&serde_json::json!({ "mcpServers": manifest.servers() }))?
+ "\n";
write_if_absent(&config::mcp_path(&paths), &base_mcp)?;
write_if_absent(
&repo_omh.join("settings.toml"),
"# What this repo decided. Settings at the top level; `[omh]` switches\n\
# omh's own features off here without uninstalling anything.\n\
#\n\
# Untracked files the worktree needs — a worktree holds only tracked\n\
# files, so without this the agent lands somewhere that cannot run your\n\
# app. This is the ONLY path by which a secret reaches the agent, so\n\
# keep it short and explicit. node_modules belongs in the image, not here.\n\
#\n\
# carry_in = [\".env.local\", \"certs/\"]\n\
carry_in = []\n\
\n\
# [omh]\n\
# codegraph = false\n",
)?;
let covered = covered_here(&[paths.hooks()], &stacks)?;
let derived = derive::hooks(
&paths.repo,
&settings::resolve(&paths, &manifest)?.provision,
&covered,
);
if !derived.is_empty() {
std::fs::create_dir_all(repo_omh.join("hooks"))?;
for d in &derived {
write_if_absent(
&repo_omh.join("hooks").join(format!("{}.json", d.name)),
&format!("{}\n", serde_json::to_string_pretty(&d.hook)?),
)?;
}
}
let markers = stack::markers(&paths.markers())?;
let unclaimed = stack::unclaimed(&markers, &stack_defs, &paths.repo);
let has_test = covered.iter().any(|s| stacks.iter().any(|d| &d.name == s))
|| derived.iter().any(|d| d.hook.on == hook::Event::TurnEnd)
|| repo_omh.join("hooks").join("test.json").exists();
let (asked, answered) = questions(&repo_omh, &unclaimed, has_test, ctx)?;
let stack_defs = stack::load_all(&paths.stacks(), &paths.repo_stacks())?;
let stacks = stack::detected(&stack_defs, &paths.repo);
if !repo_has_selection(&paths)? {
let lists = catalogue_lists(&paths)?;
config::write_selection(&paths, config::Layer::Shared, &lists)?;
} else {
let mine: Vec<String> = derived
.iter()
.map(|d| d.name.clone())
.chain(answered.iter().cloned())
.collect();
if !mine.is_empty() {
let (cap, mut names, _) = current_list(&paths, "hooks", &mine[0])?;
names.extend(mine);
names.sort();
names.dedup();
let lists = std::collections::BTreeMap::from([(cap, names)]);
write_lists(&paths, &lists)?;
}
}
let gitignore = paths.repo.join(".omh/.gitignore");
ensure_line(&gitignore, settings::LOCAL)?;
let mut held_back: Vec<hook::Dropped> = Vec::new();
if let Some(h) = &harness {
let backend = runtime::select(&runtime_preference(&paths), &|p| runtime::installed(p))?;
let adapter = Adapter::find(&paths.adapters(), h)?;
if image::exists(backend.program(), &image::tag_for(&adapter)) {
summary.image = Some(format!("{} (already built)", image::tag_for(&adapter)));
} else {
ctx.progress(&format!(
"building {} — first run only…",
image::tag_for(&adapter)
));
image::ensure(backend.program(), &adapter)?;
summary.image = Some(image::tag_for(&adapter));
}
let detected = stack::detected(&stack_defs, &paths.repo);
let candidates: Vec<(String, Option<&str>)> = detected
.iter()
.flat_map(|d| {
d.provides
.iter()
.map(move |p| (stack::key(&d.name, &p.name), p.when.as_deref()))
})
.collect();
{
let answered = if candidates.is_empty() {
Vec::new()
} else {
match Command::new(backend.program())
.args(stack::predicate_args(
&image::tag_for(&adapter),
&paths.repo,
&stack::predicate_script(&candidates),
))
.output()
{
Ok(out) if !out.status.success() => {
summary.provision_problems.push(format!(
"the sandbox could not be asked ({}) — nothing recorded",
out.status
));
for line in String::from_utf8_lossy(&out.stderr).lines().take(3) {
summary.provision_problems.push(line.to_string());
}
Vec::new()
}
Ok(out) => doctor::parse(&String::from_utf8_lossy(&out.stdout)),
Err(e) => {
summary.provision_problems.push(format!(
"could not ask the sandbox ({e}) — nothing recorded"
));
Vec::new()
}
}
};
for a in answered.iter().filter(|a| !a.ok) {
if let stack::Verdict::CouldNotAnswer(code) = stack::verdict(a) {
summary.provision_problems.push(format!(
"{}'s condition could not answer{} — not applied",
a.name,
code.map(|c| format!(" (exit {c})")).unwrap_or_default()
));
}
}
if let Some(fired) = fired_from(candidates.len(), &answered) {
let recorded = record_resolution(&paths, &fired)?;
for key in recorded.iter().filter(|(_, on)| **on).map(|(k, _)| k) {
summary.provisioned.push(key.clone());
}
let (own, repo) = resolved(&paths)?;
let sandbox = sandbox(&paths, &adapter, &repo)?;
image::ensure_stack(backend.program(), &adapter, &sandbox.recipe())?;
if sandbox.tag != image::tag_for(&adapter) {
summary.stack_image = Some(sandbox.tag.clone());
}
let hook_dirs = Profile::resolve(&paths).sources(adapter::Capability::Hooks)?;
let mut sandbox = sandbox;
sandbox.top_up(
&paths,
backend.program(),
&adapter,
&hook_dirs,
&own,
&repo,
ctx,
)?;
for name in &sandbox.owed {
if sandbox.resolves.get(name) == Some(&false) {
summary
.provision_problems
.push(format!("{name} did not resolve after installing"));
}
}
held_back = render::held_back(&hook_dirs, &own, &repo, &sandbox.resolves)?;
}
}
}
summary.asked = asked;
summary.adapters = adapters.clone();
summary.editors = editors.clone();
summary.harness_on_host = harness.as_deref().is_some_and(runtime::installed);
summary.harness = harness.clone();
summary.stacks = stacks
.iter()
.map(|s| (s.name.clone(), s.marker.clone()))
.collect();
summary.held_back = held_back
.iter()
.map(|d| (d.name.clone(), d.wanted.clone()))
.collect();
summary.importable = importable(&paths, &adapters);
summary.memory = match seed_store(&paths) {
Ok(report) => report,
Err(e) => format!("not seeded: {e:#}"),
};
summary.catalogue_dir = paths.root.display().to_string();
summary.repo_dir = repo_omh.display().to_string();
if let Some(h) = &harness {
let backend = runtime::select(&runtime_preference(&paths), &|p| runtime::installed(p))?;
let adapter = Adapter::find(&paths.adapters(), h)?;
let args = base::index_args(
&image::tag_for(&adapter),
&paths.cache_volume(),
&paths.repo,
&paths.repo_name(),
);
match Command::new(backend.program())
.args(&args)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
{
Ok(_) => {
summary.graph = Some(format!("indexing in background → {}", paths.cache_volume()))
}
Err(e) => summary.graph = Some(format!("could not start indexing: {e}")),
}
}
summary.base_set = manifest.version.to_string();
summary.rationale = manifest
.rationale()
.into_iter()
.map(|(name, why)| (name.to_string(), why.to_string()))
.collect();
summary.next_command = harness.as_deref().unwrap_or("config").to_string();
ctx.say(&summary);
Ok(())
}
fn install_bundled_adapters(paths: &Paths, ctx: &out::Ctx) -> Result<Vec<String>> {
install_bundled(&paths.adapters(), bundled::Shipped::Adapters, ctx)?;
Ok(Adapter::load_dir(&paths.adapters())?
.into_iter()
.map(|a| a.name)
.collect())
}
fn questions(
repo_omh: &std::path::Path,
unclaimed: &[&stack::Marker],
has_test: bool,
ctx: &out::Ctx,
) -> Result<(usize, Vec<String>)> {
if unclaimed.is_empty() && has_test {
return Ok((0, Vec::new()));
}
if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
return Ok((0, Vec::new()));
}
let stdin = std::io::stdin();
let (asked, answers) = ask_all(
unclaimed,
has_test,
&mut stdin.lock(),
&mut std::io::stderr(),
)?;
let mut hooks = Vec::new();
for a in answers {
let path = repo_omh.join(&a.path);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
write_if_absent(&path, &a.body)?;
ctx.progress(&a.said);
if a.path.starts_with("hooks") {
if let Some(stem) = a.path.file_stem() {
hooks.push(stem.to_string_lossy().into_owned());
}
}
}
Ok((asked, hooks))
}
fn ask_all(
unclaimed: &[&stack::Marker],
has_test: bool,
input: &mut dyn std::io::BufRead,
out: &mut dyn std::io::Write,
) -> Result<(usize, Vec<ask::Answer>)> {
let mut asked = 0usize;
let mut answers = Vec::new();
for marker in unclaimed {
asked += 1;
match ask::how_is_it_installed(marker, input, out)? {
Some(a) => answers.push(a),
None => break,
}
}
if !has_test {
asked += 1;
if let Some(a) = ask::what_tests_it(input, out)? {
answers.push(a);
}
}
Ok((asked, answers))
}
fn install_bundled(
dest: &std::path::Path,
kind: bundled::Shipped,
ctx: &out::Ctx,
) -> Result<Vec<String>> {
std::fs::create_dir_all(dest)
.with_context(|| format!("creating {} for the bundled {}", dest.display(), kind.dir()))?;
for &bundled::File { name, contents } in kind.files() {
let target = dest.join(name);
let existing = match std::fs::read(&target) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(e) => return Err(e).with_context(|| format!("reading {}", target.display())),
};
if !existing.is_empty() && existing != contents.as_bytes() {
let backup = target.with_file_name(format!("{name}.yours"));
std::fs::write(&backup, &existing)
.with_context(|| format!("saving your {name} as {}", backup.display()))?;
ctx.warn(&format!(
"replaced {} — yours saved as {name}.yours",
target.display()
));
}
std::fs::write(&target, contents)
.with_context(|| format!("writing {}", target.display()))?;
}
let mut names: Vec<String> = Vec::new();
for entry in std::fs::read_dir(dest).with_context(|| format!("reading {}", dest.display()))? {
let path = entry
.with_context(|| format!("listing {}", dest.display()))?
.path();
if path.extension().is_some_and(|x| x == "toml") {
names.push(path.file_stem().unwrap().to_string_lossy().into_owned());
}
}
names.sort();
Ok(names)
}
fn ensure_line(path: &std::path::Path, line: &str) -> Result<()> {
let existing = std::fs::read_to_string(path).unwrap_or_default();
if existing.lines().any(|l| l.trim() == line) {
return Ok(());
}
std::fs::create_dir_all(path.parent().unwrap())?;
let mut out = existing;
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
out.push_str(line);
out.push('\n');
std::fs::write(path, out)?;
Ok(())
}
fn write_if_absent(path: &std::path::Path, contents: &str) -> Result<()> {
if !path.exists() {
std::fs::write(path, contents)?;
}
Ok(())
}
fn fired_from(asked: usize, answered: &[doctor::Outcome]) -> Option<BTreeSet<String>> {
if asked == 0 {
return Some(BTreeSet::new());
}
if answered.len() != asked {
return None;
}
Some(
answered
.iter()
.filter(|o| stack::verdict(o) == stack::Verdict::Applies)
.map(|o| o.name.clone())
.collect(),
)
}
fn record_resolution(paths: &Paths, fired: &BTreeSet<String>) -> Result<BTreeMap<String, bool>> {
let recorded = stack::reconcile(
&config::read_provision(paths, config::Layer::Shared)?,
fired,
);
config::write_provision(paths, config::Layer::Shared, &recorded)?;
Ok(recorded)
}
fn installs_for<'a>(
detected: &[&'a stack::Definition],
resolved: &BTreeMap<String, bool>,
) -> Vec<&'a str> {
detected
.iter()
.flat_map(|d| d.provides.iter().map(move |p| (d, p)))
.filter(|(d, p)| resolved.get(&stack::key(&d.name, &p.name)) == Some(&true))
.filter_map(|(_, p)| p.install.as_deref())
.collect()
}
fn needs_of(
detected: &[&stack::Definition],
resolved: &BTreeMap<String, bool>,
) -> BTreeSet<String> {
detected
.iter()
.flat_map(|d| d.provides.iter().map(move |p| (d, p)))
.filter(|(d, p)| resolved.get(&stack::key(&d.name, &p.name)) == Some(&true))
.flat_map(|(_, p)| p.needs.iter().cloned())
.collect()
}
fn probe_targets(
hook_dirs: &[PathBuf],
own: &base::Own,
repo: &settings::RepoPolicy,
owed: &BTreeSet<String>,
) -> Result<BTreeSet<String>> {
let mut wanted = render::hook_programs(hook_dirs, own, repo)?;
wanted.extend(owed.iter().cloned());
Ok(wanted)
}
fn measured_or_reason(
ok: bool,
stdout: &str,
stderr: &str,
) -> Result<Vec<doctor::Outcome>, String> {
if !ok {
let mut reason = String::from("could not ask the sandbox what it has");
for line in stderr.lines().filter(|l| !l.trim().is_empty()).take(3) {
reason.push_str("\n ");
reason.push_str(line);
}
return Err(reason);
}
Ok(doctor::parse(stdout))
}
fn measure(
program: &str,
paths: &Paths,
tag: &str,
wanted: &BTreeSet<String>,
ctx: &out::Ctx,
) -> Result<BTreeMap<String, bool>> {
let mut facts = facts::Facts::load(paths);
let unseen = facts.unseen(tag, wanted);
if !unseen.is_empty() {
let borrowed: Vec<&str> = unseen.iter().map(String::as_str).collect();
let ran = Command::new(program)
.args(image::probe_args(tag, &doctor::probe_programs(&borrowed)))
.output();
let outcomes = match ran {
Ok(out) => measured_or_reason(
out.status.success(),
&String::from_utf8_lossy(&out.stdout),
&String::from_utf8_lossy(&out.stderr),
),
Err(e) => Err(format!("could not ask the sandbox what it has ({e})")),
};
let outcomes = outcomes.unwrap_or_else(|reason| {
ctx.warn(&reason);
Vec::new()
});
if !outcomes.is_empty() {
facts.learn(tag, &outcomes);
if let Err(e) = facts.save(paths) {
ctx.warn(&format!(
"measurements not cached ({e:#}) — the sandbox is asked again next time"
));
}
}
}
Ok(facts.about(tag))
}
struct Sandbox {
installs: Vec<String>,
tag: String,
resolves: BTreeMap<String, bool>,
owed: BTreeSet<String>,
}
impl Sandbox {
fn recipe(&self) -> Vec<&str> {
self.installs.iter().map(String::as_str).collect()
}
#[allow(clippy::too_many_arguments)]
fn top_up(
&mut self,
paths: &Paths,
program: &str,
adapter: &Adapter,
hook_dirs: &[PathBuf],
own: &base::Own,
repo: &settings::RepoPolicy,
ctx: &out::Ctx,
) -> Result<()> {
let recipe: Vec<String> = self.installs.clone();
image::ensure_stack(
program,
adapter,
&recipe.iter().map(String::as_str).collect::<Vec<_>>(),
)?;
let wanted = probe_targets(hook_dirs, own, repo, &self.owed)?;
self.resolves = measure(program, paths, &self.tag, &wanted, ctx)?;
Ok(())
}
}
fn sandbox(paths: &Paths, adapter: &Adapter, repo: &settings::RepoPolicy) -> Result<Sandbox> {
let defs = stack::load_all(&paths.stacks(), &paths.repo_stacks())?;
let detected = stack::detected(&defs, &paths.repo);
let installs: Vec<String> = installs_for(&detected, &repo.provision)
.into_iter()
.map(str::to_string)
.collect();
let tag = image::stack_tag(
adapter,
&installs.iter().map(String::as_str).collect::<Vec<_>>(),
);
let resolves = facts::Facts::load(paths).about(&tag);
let owed = needs_of(&detected, &repo.provision);
Ok(Sandbox {
installs,
tag,
resolves,
owed,
})
}
fn auth_cmd(cwd: &std::path::Path, harness: &str, account: &str, ctx: &out::Ctx) -> Result<()> {
let paths = Paths::discover(cwd)?;
let profile = Profile::resolve(&paths);
let adapter = Adapter::find(&paths.adapters(), harness)?;
if adapter.creds.is_empty() {
anyhow::bail!(
"adapter {harness} declares no credential paths, so there is nothing to capture"
);
}
auth::validate_name(account)?;
let account_dir = auth::dir(&paths, harness, account);
let already = auth::is_captured(&paths, &adapter, account);
auth::prepare(&adapter, &account_dir, "/home/agent")?;
let backend = runtime::select(&runtime_preference(&paths), &|p| runtime::installed(p))?;
image::ensure(backend.program(), &adapter)?;
let session = Session::scratch(paths.scratch("auth"), "auth".into());
session.ensure(&paths.repo, "")?;
let (own, repo) = resolved(&paths)?;
let plan = container::plan(
&paths,
&profile,
&adapter,
&session,
&[],
container::Options {
staging: container::Staging::Apply,
persist: persist::Mode::None,
tty: true,
account_dir: Some(account_dir.clone()),
memory_bin: memory::deliver::available(&paths),
base: None,
omh: own,
repo,
image: image::tag_for(&adapter),
resolves: BTreeMap::new(),
},
)?;
plan.validate(&backend.caps())?;
image::ensure_network(backend.program(), &plan.network)?;
ctx.progress(&format!(
"logging {harness} in as `{account}`{} — credentials → {}{}",
if already { " (re-authenticating)" } else { "" },
account_dir.display(),
match &adapter.login {
Some(hint) => format!("\nnext → {hint}"),
None => String::new(),
}
));
let status = Command::new(backend.program())
.args(backend.args(&plan))
.status()?;
if let Err(e) = session.remove(&paths.repo, "") {
ctx.warn(&format!("could not remove the auth worktree: {e}"));
}
let unfilled: Vec<std::path::PathBuf> =
auth::unfilled(&adapter, &account_dir, auth::GUEST_HOME)
.iter()
.map(|guest| {
account_dir.join(
guest
.strip_prefix(auth::GUEST_HOME)
.unwrap_or(guest.as_path()),
)
})
.collect();
auth::login_outcome(status.success(), &unfilled)
.map_err(|e| e.context(format!("run `omh auth {harness} {account}` again")))?;
let all = auth::accounts(&paths, &adapter);
let mut action = report::Action::new(
"account-captured",
format!("`{account}` captured for {harness}"),
)
.data(serde_json::json!({
"harness": harness,
"account": account,
"reauthenticated": already,
"credentials": account_dir.display().to_string(),
"accounts": all,
}));
if all.len() > 1 {
action = action
.note(format!("accounts: {}", all.join(", ")))
.next("omh repo set account <name>");
}
ctx.say(&action);
Ok(())
}
fn ls(cwd: &std::path::Path, ctx: &out::Ctx) -> Result<()> {
let paths = Paths::discover(cwd)?;
let base = session::default_branch(&paths.repo);
ctx.say(&report::Inventory {
harnesses: Adapter::load_dir(&paths.adapters())?
.iter()
.map(|a| report::Harness {
name: a.name.clone(),
accounts: auth::accounts(&paths, a),
})
.collect(),
adapters_dir: paths.adapters().display().to_string(),
editors: editor::Editor::load_dir(&paths.editors())?
.iter()
.map(|e| report::Editor {
name: e.name.clone(),
installed: runtime::installed(&e.bin),
})
.collect(),
sessions: session::list(&paths.worktrees())
.into_iter()
.map(|id| {
let sess = Session::new(&paths.worktrees(), id.clone());
report::Session {
label: sess.label().to_string(),
work: None,
running: false,
behind: sess.behind(&paths.repo, &base),
id,
}
})
.collect(),
base,
});
Ok(())
}
fn diff(cwd: &std::path::Path, id: Option<&str>, base: Option<&str>, ctx: &out::Ctx) -> Result<()> {
let paths = Paths::discover(cwd)?;
let session = existing_session(&paths, id)?;
let base = base
.map(str::to_string)
.unwrap_or_else(|| session::default_branch(&paths.repo));
let summary = session.diff(&paths.repo, &base)?;
ctx.say(&report::Diff {
label: session.label().to_string(),
base,
summary,
});
Ok(())
}
fn existing_session(paths: &Paths, explicit: Option<&str>) -> Result<Session> {
let id = match explicit {
Some(id) => {
session::validate_id(id)?;
id.to_string()
}
None => session::current(&paths.worktrees())
.context("no sessions yet — start one with `omh claude`")?,
};
let session = Session::new(&paths.worktrees(), id);
anyhow::ensure!(
session.worktree.exists(),
"no session {} — `omh s ls` lists them",
session.id
);
Ok(session)
}
fn commit(
cwd: &std::path::Path,
id: Option<&str>,
message: Option<&str>,
skip_carried: bool,
ctx: &out::Ctx,
) -> Result<()> {
let paths = Paths::discover(cwd)?;
let session = existing_session(&paths, id)?;
let carried = config::policy_list(&paths, "carry_in");
let policy = if skip_carried {
session::Carried::skipping(&carried)
} else {
session::Carried::refusing(&carried)
};
session.commit(message, policy)?;
let base = session::default_branch(&paths.repo);
let n = session.commits(&paths.repo, &base);
let s = if n == 1 { "commit" } else { "commits" };
ctx.say(
&report::Action::new(
"committed",
format!("committed to {} ({n} {s} on the branch)", session.label()),
)
.data(serde_json::json!({
"session": session.id,
"branch": session.label(),
"commits": n,
"base": base,
})),
);
Ok(())
}
fn push(
cwd: &std::path::Path,
id: Option<&str>,
name: Option<&str>,
pr: bool,
ctx: &out::Ctx,
) -> Result<()> {
let paths = Paths::discover(cwd)?;
let session = existing_session(&paths, id)?;
let target = session.push(name)?;
ctx.say(
&report::Action::new("pushed", format!("{} → origin/{target}", session.label())).data(
serde_json::json!({
"session": session.id,
"branch": session.label(),
"target": target,
}),
),
);
if !pr {
return Ok(());
}
anyhow::ensure!(
runtime::installed("gh"),
"gh is not installed; open it with\n gh pr create --head {target}"
);
let status = Command::new("gh")
.current_dir(&session.worktree)
.args(["pr", "create", "--head", &target])
.status()
.context("running gh pr create")?;
anyhow::ensure!(status.success(), "gh pr create did not open a pull request");
Ok(())
}
fn rm(cwd: &std::path::Path, id: &str, ctx: &out::Ctx) -> Result<()> {
session::validate_id(id)?;
let paths = Paths::discover(cwd)?;
let session = Session::new(&paths.worktrees(), id.to_string());
if let Ok(backend) = runtime::select(&runtime_preference(&paths), &|p| runtime::installed(p)) {
let name = paths.container(id);
if image::container_running(backend.program(), &name) {
let project = base::project_name(&paths.repo_name(), id);
let _ = Command::new(backend.program())
.args(backend.exec_args(&name, &base::drop_graph_command(&project), false))
.output();
}
let _ = image::container_remove(backend.program(), &name);
}
let _ = std::fs::remove_dir_all(paths.runs().join(id));
let base = session::default_branch(&paths.repo);
let action = match session.remove(&paths.repo, &base)? {
session::Removed::BranchKept => {
let n = session.commits(&paths.repo, &base);
let s = if n == 1 { "commit" } else { "commits" };
report::Action::new(
"session-removed",
format!("removed session {id}; branch omh/{id} kept ({n} {s} to review)"),
)
.next(format!("git log {base}..omh/{id}"))
.next(format!("git branch -D omh/{id}"))
.data(serde_json::json!({
"session": id,
"branch": format!("omh/{id}"),
"branch_kept": true,
"commits": n,
}))
}
session::Removed::BranchDropped => report::Action::new(
"session-removed",
format!("removed session {id}; branch omh/{id} dropped (no commits)"),
)
.data(serde_json::json!({
"session": id,
"branch": format!("omh/{id}"),
"branch_kept": false,
"commits": 0,
})),
session::Removed::NoBranch => {
report::Action::new("session-removed", format!("removed session {id}"))
.data(serde_json::json!({ "session": id, "branch_kept": false }))
}
};
ctx.say(&action);
if let Ok(notes) = memory::load(&paths) {
if let Some(line) = memory::session_nudge(¬es, id) {
ctx.hint(&line);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
const BUNDLED_ADAPTERS: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/adapters");
const BUNDLED_EDITORS: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/editors");
#[test]
fn resolved_reads_the_manifest_and_this_repos_settings() {
let dir = tempfile::tempdir().unwrap();
let paths = Paths {
root: dir.path().join("home"),
repo: dir.path().join("repo"),
};
let write = |p: std::path::PathBuf, body: &str| {
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, body).unwrap();
};
install_bundled(&paths.base(), bundled::Shipped::Base, &out::Ctx::plain()).unwrap();
write(
paths.root.join("mcp.json"),
r#"{"mcpServers":{"codegraph":{"command":"c"},"memory":{"command":"omh"}}}"#,
);
let (own, _) = resolved(&paths).unwrap();
assert!(
!own.hooks.is_empty() && !own.sections.is_empty(),
"a launch must be given what the manifest ships"
);
assert!(
own.hooks.iter().any(|h| h.name.starts_with("graph-")),
"with every feature on, the graph hooks are what `[omh]` below removes: {:?}",
own.hooks.iter().map(|h| h.name).collect::<Vec<_>>()
);
write(
paths.repo.join(".omh/settings.toml"),
"[omh]\ncodegraph = false\n\n[mcp.memory.env]\nOMH_TEST = \"seen\"\n",
);
let (off, policy) = resolved(&paths).unwrap();
assert!(
!off.hooks.iter().any(|h| h.name.starts_with("graph-")),
"and `[omh]` in this repo has to reach it: {:?}",
off.hooks.iter().map(|h| h.name).collect::<Vec<_>>()
);
assert!(
off.hooks.iter().any(|h| h.name == "git-unavailable"),
"without taking a different feature with it"
);
assert_eq!(
policy.mcp_env["memory"]["OMH_TEST"], "seen",
"a per-repo MCP environment has to reach the plan too"
);
assert!(
policy.disabled_servers.contains("codegraph"),
"the feature's server travels with the feature: {:?}",
policy.disabled_servers
);
}
fn outcome(name: &str, ok: bool, detail: &str) -> doctor::Outcome {
doctor::Outcome {
name: name.into(),
ok,
detail: detail.into(),
}
}
#[test]
fn a_resolution_nobody_measured_is_never_recorded() {
assert_eq!(
fired_from(3, &[]),
None,
"three provides asked, none answered — the container never ran"
);
}
#[test]
fn a_partial_report_is_not_a_resolution() {
let truncated = [outcome("rust/toolchain", true, "applies")];
assert_eq!(
fired_from(2, &truncated),
None,
"two provides asked, one answered — the container died mid-script"
);
}
#[test]
fn nothing_to_ask_is_an_answer_and_clears_a_stale_resolution() {
assert_eq!(
fired_from(0, &[]),
Some(std::collections::BTreeSet::new()),
"no candidates is a measured 'nothing applies', not a failure to measure"
);
}
#[test]
fn only_the_provides_that_applied_are_recorded() {
let answered = [
outcome("rust/toolchain", true, "applies"),
outcome("node/pnpm", false, "1 does not apply"),
outcome("node/bun", false, "2 could not answer"),
];
assert_eq!(
fired_from(3, &answered),
Some(std::collections::BTreeSet::from([
"rust/toolchain".to_string()
]))
);
}
#[test]
fn installs_are_the_recorded_recipes_in_file_order() {
let defs = stack::load_dir(std::path::Path::new(concat!(
env!("CARGO_MANIFEST_DIR"),
"/stacks"
)))
.unwrap();
let node = defs.iter().find(|d| d.name == "node").expect("node ships");
let all: BTreeMap<String, bool> = node
.provides
.iter()
.map(|p| (stack::key(&node.name, &p.name), true))
.collect();
let got = installs_for(&[node], &all);
let expected: Vec<&str> = node
.provides
.iter()
.filter_map(|p| p.install.as_deref())
.collect();
assert_eq!(got, expected, "order or filtering changed");
assert!(
!got.is_empty() && got.len() < node.provides.len(),
"node must have both kinds of provide for this to prove anything: {got:?}"
);
}
#[test]
fn only_the_recorded_recipes_run_and_the_file_decides_their_order() {
fn provide(name: &str, install: Option<&str>) -> stack::Provide {
stack::Provide {
name: name.into(),
needs: vec![name.into()],
when: None,
install: install.map(str::to_string),
because: "a fixture".into(),
measured: Vec::new(),
}
}
let def = stack::Definition {
name: "fixture".into(),
marker: "fixture.toml".into(),
provides: vec![
provide("zulu", Some("install zulu")),
provide("alpha", Some("install alpha")),
provide("asserted", None),
provide("mike", Some("install mike")),
],
};
let resolved: BTreeMap<String, bool> =
["fixture/zulu", "fixture/alpha", "fixture/asserted"]
.iter()
.map(|k| ((*k).to_string(), true))
.collect();
assert_eq!(
installs_for(&[&def], &resolved),
vec!["install zulu", "install alpha"],
"a provide the resolution does not name must contribute no recipe, \
and sorted order is not file order"
);
}
#[test]
fn a_provide_somebody_opted_out_of_is_not_installed() {
fn provide(name: &str, install: &str) -> stack::Provide {
stack::Provide {
name: name.into(),
needs: vec![name.into()],
when: None,
install: Some(install.into()),
because: "a fixture".into(),
measured: Vec::new(),
}
}
let def = stack::Definition {
name: "rust".into(),
marker: "Cargo.toml".into(),
provides: vec![
provide("toolchain", "install rustup"),
provide("linker", "apt-get install -y gcc"),
],
};
let resolved = BTreeMap::from([
("rust/toolchain".to_string(), true),
("rust/linker".to_string(), false),
]);
assert_eq!(
installs_for(&[&def], &resolved),
vec!["install rustup"],
"the predicate said the linker applies; a person said not here, and \
a person outranks a predicate"
);
}
#[test]
fn a_repo_that_provisions_runs_a_different_image() {
let dir = tempfile::tempdir().unwrap();
let paths = Paths {
root: dir.path().join("home"),
repo: dir.path().join("repo"),
};
std::fs::create_dir_all(paths.stacks()).unwrap();
std::fs::create_dir_all(&paths.repo).unwrap();
std::fs::write(paths.repo.join("package.json"), "{}").unwrap();
std::fs::copy(
std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/stacks/node.toml")),
paths.stacks().join("node.toml"),
)
.unwrap();
let adapter = Adapter::find(std::path::Path::new(BUNDLED_ADAPTERS), "claude").unwrap();
let with = |keys: &[&str]| {
let mut repo = settings::RepoPolicy::default();
for k in keys {
repo.provision.insert((*k).to_string(), true);
}
sandbox(&paths, &adapter, &repo).unwrap().tag
};
let nothing = with(&[]);
let pnpm = with(&["node/pnpm"]);
let yarn = with(&["node/yarn"]);
assert_eq!(
nothing,
image::tag_for(&adapter),
"a repo that provisions nothing runs the harness image, not an \
empty layer on top of it"
);
assert_ne!(pnpm, nothing, "provisioning changes the image");
assert_ne!(
pnpm, yarn,
"same stack, same marker, different lockfile — and a shared image \
would hand the yarn repo pnpm and nothing else"
);
assert_eq!(
pnpm,
with(&["node/pnpm"]),
"and it is stable, or every launch rebuilds"
);
}
#[test]
fn a_probe_that_ran_and_failed_is_a_reason_not_a_measurement() {
let failed = measured_or_reason(false, "", "Error: No such image: omh/x:abc\n");
let Err(reason) = failed else {
panic!("a failed container was read as a sandbox with nothing in it");
};
assert!(
reason.contains("could not ask the sandbox"),
"the reason has to say nobody was asked: {reason}"
);
assert!(
reason.contains("No such image"),
"and carry what the runtime said, or it names no cause: {reason}"
);
assert_eq!(
measured_or_reason(true, "ok\tcargo\tresolves\n", "")
.expect("a successful probe is an answer")
.len(),
1
);
assert_eq!(measured_or_reason(true, "", ""), Ok(Vec::new()));
}
#[test]
fn the_reason_carries_a_few_lines_of_evidence_not_a_page() {
let noisy: String = (0..40).map(|i| format!("line {i}\n")).collect();
let Err(reason) = measured_or_reason(false, "", &noisy) else {
panic!("must be a reason");
};
assert_eq!(
reason.lines().count(),
4,
"one reason and three lines of evidence: {reason}"
);
}
fn unclaimed(stacks: &[&str]) -> Vec<stack::Marker> {
stacks
.iter()
.map(|s| stack::Marker {
file: format!("{s}.manifest"),
stack: (*s).to_string(),
})
.collect()
}
fn exchange(
markers: &[stack::Marker],
has_test: bool,
typed: &str,
) -> (usize, Vec<ask::Answer>) {
let refs: Vec<&stack::Marker> = markers.iter().collect();
let mut out = Vec::new();
ask_all(
&refs,
has_test,
&mut std::io::BufReader::new(typed.as_bytes()),
&mut out,
)
.unwrap()
}
#[test]
fn declining_one_question_stops_the_rest() {
let three = unclaimed(&["elixir", "ruby", "php"]);
let (asked, answers) = exchange(&three, true, "\n");
assert_eq!(asked, 1, "one question put, and no more after the decline");
assert!(answers.is_empty());
assert_eq!(exchange(&three, true, ""), (1, Vec::new()));
}
#[test]
fn the_count_is_what_was_put_not_what_was_answered() {
let one = unclaimed(&["elixir"]);
let (asked, answers) = exchange(&one, true, "\n");
assert_eq!((asked, answers.len()), (1, 0));
let (asked, answers) = exchange(&one, true, "apt-get install -y elixir\nmix\n");
assert_eq!((asked, answers.len()), (1, 1));
assert_eq!(answers[0].path, std::path::Path::new("stacks/elixir.toml"));
}
#[test]
fn a_repo_with_nothing_unknown_is_asked_nothing() {
assert_eq!(exchange(&[], true, "mix test\n"), (0, Vec::new()));
}
#[test]
fn a_project_with_no_way_to_test_itself_is_asked_about_that_alone() {
let (asked, answers) = exchange(&[], false, "mix test\n");
assert_eq!(asked, 1);
assert_eq!(answers[0].path, std::path::Path::new("hooks/test.json"));
}
#[test]
fn what_the_catalogue_covers_elsewhere_covers_nothing_here() {
let dir = tempfile::tempdir().unwrap();
let hooks = dir.path().join("hooks");
std::fs::create_dir_all(&hooks).unwrap();
std::fs::write(
hooks.join("rust-test.json"),
r#"{"on":"turn-end","stack":"rust","run":"cargo test"}"#,
)
.unwrap();
std::fs::write(
hooks.join("shellcheck.json"),
r#"{"on":"turn-end","run":"shellcheck ./x.sh"}"#,
)
.unwrap();
let dirs = [hooks];
let rust = stack::Definition {
name: "rust".into(),
marker: "Cargo.toml".into(),
provides: Vec::new(),
};
assert_eq!(
covered_here(&dirs, &[]).unwrap(),
BTreeSet::new(),
"a repo that is no ecosystem omh ships a hook for is covered by \
none of them — this is the C project with a Makefile, and the \
whole runner path depends on it"
);
assert_eq!(
covered_here(&dirs, &[&rust]).unwrap(),
["rust".to_string()].into_iter().collect(),
"and a rust repo is covered, so its Makefile earns no second hook"
);
}
#[test]
fn a_hook_for_an_ecosystem_this_repo_is_not_is_not_offered() {
let declared = BTreeMap::from([
("rust-test".to_string(), Some("rust".to_string())),
("go-test".to_string(), Some("go".to_string())),
("shellcheck".to_string(), None),
]);
let names = vec![
"rust-test".to_string(),
"go-test".to_string(),
"shellcheck".to_string(),
"mine".to_string(),
];
let detected: BTreeSet<String> = ["rust".to_string()].into_iter().collect();
assert_eq!(
applicable_hooks(names.clone(), &declared, &detected),
vec![
"rust-test".to_string(),
"shellcheck".to_string(),
"mine".to_string()
],
"only the hook naming an ecosystem this repo is not comes out"
);
assert_eq!(
applicable_hooks(names, &declared, &BTreeSet::new()),
vec!["shellcheck".to_string(), "mine".to_string()]
);
}
#[test]
fn the_sandbox_is_asked_about_both_what_stacks_promised_and_what_hooks_run() {
let dir = tempfile::tempdir().unwrap();
let hooks = dir.path().join("hooks");
std::fs::create_dir_all(&hooks).unwrap();
std::fs::write(
hooks.join("lint.json"),
r#"{"on":"turn-end","run":"shellcheck ./x.sh"}"#,
)
.unwrap();
let def = stack::Definition {
name: "rust".into(),
marker: "Cargo.toml".into(),
provides: vec![stack::Provide {
name: "toolchain".into(),
needs: vec!["cargo".into(), "rustc".into()],
when: None,
install: Some("install rustup".into()),
because: "a fixture".into(),
measured: Vec::new(),
}],
};
let mut repo = settings::RepoPolicy::default();
repo.provision.insert("rust/toolchain".to_string(), true);
let owed = needs_of(&[&def], &repo.provision);
let asked = probe_targets(&[hooks], &Default::default(), &repo, &owed).unwrap();
assert_eq!(
asked,
BTreeSet::from([
"cargo".to_string(),
"rustc".to_string(),
"shellcheck".to_string()
]),
"asking about only one of the two lists leaves the other unmeasured"
);
}
#[test]
fn only_what_was_provisioned_owes_a_program() {
fn provide(name: &str, need: &str, install: Option<&str>) -> stack::Provide {
stack::Provide {
name: name.into(),
needs: vec![need.into()],
when: None,
install: install.map(str::to_string),
because: "a fixture".into(),
measured: Vec::new(),
}
}
let def = stack::Definition {
name: "node".into(),
marker: "package.json".into(),
provides: vec![
provide("runtime", "node", None),
provide("pnpm", "pnpm", Some("corepack enable pnpm")),
provide("yarn", "yarn", Some("corepack enable yarn")),
provide("bun", "bun", Some("npm install -g bun")),
],
};
let resolved = BTreeMap::from([
("node/runtime".to_string(), true),
("node/pnpm".to_string(), true),
("node/yarn".to_string(), false),
]);
assert_eq!(
needs_of(&[&def], &resolved),
BTreeSet::from(["node".to_string(), "pnpm".to_string()]),
"an assertion with no recipe is still owed; an opt-out and an \
absence are not"
);
}
#[test]
fn a_stacks_directory_omh_cannot_read_is_reported_rather_than_read_as_empty() {
let dir = tempfile::tempdir().unwrap();
let paths = Paths {
root: dir.path().join("home"),
repo: dir.path().join("repo"),
};
std::fs::create_dir_all(paths.stacks()).unwrap();
std::fs::create_dir_all(&paths.repo).unwrap();
std::fs::write(paths.stacks().join("rust.toml"), "this is not toml {{{").unwrap();
assert!(
say_hooks(&paths, &out::Ctx::plain()).is_none(),
"a report built on stacks that would not load is a wrong report"
);
}
#[test]
fn the_resolution_is_read_and_written_in_the_committed_layer() {
let dir = tempfile::tempdir().unwrap();
let paths = Paths {
root: dir.path().join("home"),
repo: dir.path().join("repo"),
};
let write = |layer: config::Layer, body: &str| {
let f = layer.file(&paths);
std::fs::create_dir_all(f.parent().unwrap()).unwrap();
std::fs::write(f, body).unwrap();
};
write(
config::Layer::Shared,
"[provision]\n\"rust/toolchain\" = true\n",
);
let local_before = "[provision]\n\"node/pnpm\" = false\n";
write(config::Layer::Local, local_before);
let fired: BTreeSet<String> = ["rust/toolchain", "node/pnpm"]
.iter()
.map(|k| (*k).to_string())
.collect();
record_resolution(&paths, &fired).unwrap();
let shared = std::fs::read_to_string(config::Layer::Shared.file(&paths)).unwrap();
let parsed: toml::Table = toml::from_str(&shared).expect("still TOML");
assert_eq!(
parsed["provision"]["rust/toolchain"].as_bool(),
Some(true),
"the resolution must land in the committed file: {shared}"
);
assert_eq!(
parsed["provision"]["node/pnpm"].as_bool(),
Some(true),
"a laptop's opt-out must not be read back as the team's: {shared}"
);
assert_eq!(
std::fs::read_to_string(config::Layer::Local.file(&paths)).unwrap(),
local_before,
"the local layer is somebody's own file and init does not edit it"
);
}
fn fake_runtime(dir: &std::path::Path, present: &[&str], absent: &[&str]) -> String {
let log = dir.join("calls.log");
let mut body = String::from("#!/bin/sh\n");
body.push_str(&format!(
"printf 'CALL %s\\n' \"$*\" | tr -d '\\n' >> {}; printf '\\n' >> {}\n",
log.display(),
log.display()
));
for p in present {
body.push_str(&format!("printf 'ok\\t{p}\\tresolves\\n'\n"));
}
for p in absent {
body.push_str(&format!("printf 'fail\\t{p}\\tnot installed\\n'\n"));
}
let bin = dir.join("fake-runtime");
std::fs::write(&bin, body).unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
bin.to_string_lossy().to_string()
}
fn calls(dir: &std::path::Path) -> Vec<String> {
std::fs::read_to_string(dir.join("calls.log"))
.unwrap_or_default()
.lines()
.map(str::to_string)
.collect()
}
fn probes(dir: &std::path::Path) -> Vec<String> {
calls(dir)
.into_iter()
.filter(|c| c.contains("--pull=never"))
.collect()
}
fn a_sandbox(tag: &str, owed: &[&str]) -> Sandbox {
Sandbox {
installs: Vec::new(),
tag: tag.to_string(),
resolves: BTreeMap::new(),
owed: owed.iter().map(|s| (*s).to_string()).collect(),
}
}
fn measurement_fixture(dir: &std::path::Path) -> (Paths, Adapter) {
let paths = Paths {
root: dir.join("home"),
repo: dir.join("repo"),
};
std::fs::create_dir_all(&paths.repo).unwrap();
let adapter = Adapter::find(std::path::Path::new(BUNDLED_ADAPTERS), "claude").unwrap();
(paths, adapter)
}
#[cfg(unix)]
#[test]
fn a_second_launch_asks_the_image_nothing() {
let dir = tempfile::tempdir().unwrap();
let (paths, adapter) = measurement_fixture(dir.path());
let runtime = fake_runtime(dir.path(), &["cargo"], &["cc"]);
let own = base::Own::default();
let repo = settings::RepoPolicy::default();
let mut first = a_sandbox("omh/claude:abc123", &["cargo", "cc"]);
first
.top_up(
&paths,
&runtime,
&adapter,
&[],
&own,
&repo,
&out::Ctx::plain(),
)
.unwrap();
assert_eq!(probes(dir.path()).len(), 1, "the first launch must ask");
assert_eq!(
first.resolves.get("cargo"),
Some(&true),
"and keep what it was told: {:?}",
first.resolves
);
assert_eq!(first.resolves.get("cc"), Some(&false));
assert!(
paths.facts().exists(),
"and write it down, or the next launch asks again"
);
let mut second = a_sandbox("omh/claude:abc123", &["cargo", "cc"]);
second
.top_up(
&paths,
&runtime,
&adapter,
&[],
&own,
&repo,
&out::Ctx::plain(),
)
.unwrap();
assert_eq!(
probes(dir.path()).len(),
1,
"a repo whose hooks and stacks have not changed must start no container"
);
assert_eq!(
second.resolves.get("cc"),
Some(&false),
"and still know what was measured before: {:?}",
second.resolves
);
}
#[cfg(unix)]
#[test]
fn the_probe_asks_this_image_about_what_it_owes() {
let dir = tempfile::tempdir().unwrap();
let (paths, adapter) = measurement_fixture(dir.path());
let runtime = fake_runtime(dir.path(), &["cargo"], &[]);
let mut sb = a_sandbox("omh/claude:abc123", &["cargo"]);
sb.top_up(
&paths,
&runtime,
&adapter,
&[],
&base::Own::default(),
&settings::RepoPolicy::default(),
&out::Ctx::plain(),
)
.unwrap();
let probe = probes(dir.path()).join("\n");
assert!(
probe.contains("omh/claude:abc123"),
"the probe must run in the image this session will run: {probe}"
);
assert!(
probe.contains("cargo"),
"and ask about what the stacks promised: {probe}"
);
let raw = std::fs::read_to_string(paths.facts()).unwrap();
assert!(
raw.contains("omh/claude:abc123"),
"and file the answer under that image's tag: {raw}"
);
}
#[cfg(unix)]
#[test]
fn an_image_is_made_sure_of_before_it_is_asked_anything() {
let dir = tempfile::tempdir().unwrap();
let (paths, adapter) = measurement_fixture(dir.path());
let runtime = fake_runtime(dir.path(), &["cargo"], &[]);
let mut sb = a_sandbox("omh/claude:abc123", &["cargo"]);
sb.top_up(
&paths,
&runtime,
&adapter,
&[],
&base::Own::default(),
&settings::RepoPolicy::default(),
&out::Ctx::plain(),
)
.unwrap();
let all = calls(dir.path());
let built = all
.iter()
.position(|c| !c.contains("--pull=never"))
.expect("the image has to be made sure of at all");
let asked = all
.iter()
.position(|c| c.contains("--pull=never"))
.expect("and then asked");
assert!(
built < asked,
"a probe against an image nobody built learns nothing: {all:?}"
);
}
#[cfg(unix)]
#[test]
fn a_probe_that_cannot_run_suppresses_nothing() {
let dir = tempfile::tempdir().unwrap();
let (paths, adapter) = measurement_fixture(dir.path());
let bin = dir.path().join("failing-runtime");
std::fs::write(&bin, "#!/bin/sh\nexit 1\n").unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
let mut sb = a_sandbox("omh/claude:abc123", &["cargo"]);
let _ = sb.top_up(
&paths,
&bin.to_string_lossy(),
&adapter,
&[],
&base::Own::default(),
&settings::RepoPolicy::default(),
&out::Ctx::plain(),
);
assert_eq!(
sb.resolves.get("cargo"),
None,
"silence is cannot-tell, and cannot-tell is never a measured \
absence: {:?}",
sb.resolves
);
}
fn provisioned_fixture(paths: &Paths) {
std::fs::create_dir_all(paths.stacks()).unwrap();
std::fs::create_dir_all(&paths.repo).unwrap();
std::fs::write(paths.repo.join("fixture.toml"), "").unwrap();
std::fs::write(
paths.stacks().join("fixture.toml"),
r#"
name = "fixture"
marker = "fixture.toml"
[[provide]]
name = "zulu"
needs = ["zulu"]
install = "install zulu"
because = "a fixture"
[[provide]]
name = "alpha"
needs = ["alpha"]
install = "install alpha"
because = "a fixture"
[[provide]]
name = "declined"
needs = ["declined"]
install = "install declined"
because = "a fixture"
"#,
)
.unwrap();
}
fn fixture_policy() -> settings::RepoPolicy {
let mut repo = settings::RepoPolicy::default();
repo.provision.insert("fixture/zulu".to_string(), true);
repo.provision.insert("fixture/alpha".to_string(), true);
repo.provision.insert("fixture/declined".to_string(), false);
repo
}
#[test]
fn the_layer_a_sandbox_names_is_the_layer_its_recipe_builds() {
let dir = tempfile::tempdir().unwrap();
let paths = Paths {
root: dir.path().join("home"),
repo: dir.path().join("repo"),
};
provisioned_fixture(&paths);
let adapter = Adapter::find(std::path::Path::new(BUNDLED_ADAPTERS), "claude").unwrap();
let sb = sandbox(&paths, &adapter, &fixture_policy()).unwrap();
assert_eq!(
sb.recipe(),
vec!["install zulu", "install alpha"],
"file order is install order, and an opt-out contributes no recipe"
);
assert_ne!(
sb.tag,
image::tag_for(&adapter),
"this fixture must provision something or it proves nothing"
);
assert_eq!(
image::stack_tag(&adapter, &sb.recipe()),
sb.tag,
"the recipe handed to `ensure_stack` must build the tag `plan` runs, \
or a session runs an image nothing built"
);
}
#[test]
fn a_sandbox_carries_what_it_owes_and_what_is_already_known() {
let dir = tempfile::tempdir().unwrap();
let paths = Paths {
root: dir.path().join("home"),
repo: dir.path().join("repo"),
};
provisioned_fixture(&paths);
let adapter = Adapter::find(std::path::Path::new(BUNDLED_ADAPTERS), "claude").unwrap();
let repo = fixture_policy();
let first = sandbox(&paths, &adapter, &repo).unwrap();
assert_eq!(
first.owed,
BTreeSet::from(["zulu".to_string(), "alpha".to_string()]),
"a provide somebody opted out of was never installed and owes \
nothing: {:?}",
first.owed
);
assert!(
first.resolves.is_empty(),
"and nothing has been measured about this image yet"
);
let mut facts = facts::Facts::default();
facts.learn(
&first.tag,
&[doctor::Outcome {
name: "alpha".into(),
ok: false,
detail: "not installed in the sandbox".into(),
}],
);
facts.save(&paths).unwrap();
let second = sandbox(&paths, &adapter, &repo).unwrap();
assert_eq!(
second.resolves.get("alpha"),
Some(&false),
"a sandbox must arrive knowing what was measured about its own \
tag: {:?}",
second.resolves
);
}
#[test]
fn no_unqualified_write_can_reach_version_control() {
assert!(
!repo_layer(false).is_committed(),
"omh repo set holds carry_in paths and MCP env"
);
assert!(
!config::Layer::Personal.is_committed(),
"omh config set writes your own file"
);
assert!(
repo_layer(true).is_committed(),
"and --shared is how you say you meant it"
);
}
#[test]
fn reserved_lists_every_command_and_alias() {
for sub in Cli::command().get_subcommands() {
let name = sub.get_name();
assert!(
RESERVED.contains(&name),
"command `{name}` missing from RESERVED"
);
for alias in sub.get_visible_aliases() {
assert!(
RESERVED.contains(&alias),
"alias `{alias}` missing from RESERVED"
);
}
}
}
#[test]
fn no_bundled_definition_shadows_a_command() {
for a in Adapter::load_dir(std::path::Path::new(BUNDLED_ADAPTERS)).unwrap() {
assert!(
!RESERVED.contains(&a.name.as_str()),
"adapter `{}` is a command",
a.name
);
}
for e in editor::Editor::load_dir(std::path::Path::new(BUNDLED_EDITORS)).unwrap() {
assert!(
!RESERVED.contains(&e.name.as_str()),
"editor `{}` is a command",
e.name
);
}
}
#[test]
fn naming_an_editor_where_a_harness_goes_names_the_fix() {
let hint = tool_hint("zed", &["claude".into()], &["zed".into()]);
assert!(hint.contains("omh attach zed"), "got: {hint}");
}
#[test]
fn an_unknown_word_lists_the_harnesses() {
let hint = tool_hint(
"emacs",
&["claude".into(), "opencode".into()],
&["zed".into()],
);
assert!(
hint.contains("claude") && hint.contains("opencode"),
"got: {hint}"
);
assert!(!hint.contains("attach"), "not an editor: {hint}");
}
#[test]
fn a_command_typed_as_a_harness_points_at_its_help() {
let hint = tool_hint("config", &["claude".into()], &[]);
assert!(hint.contains("omh config --help"), "got: {hint}");
}
#[test]
fn bundled_definitions_are_refreshed_not_just_seeded() {
let d = tempfile::tempdir().unwrap();
let dest = d.path().join("adapters");
std::fs::create_dir_all(&dest).unwrap();
std::fs::write(dest.join("claude.toml"), "name = \"stale\"\n").unwrap();
install_bundled(&dest, bundled::Shipped::Adapters, &out::Ctx::plain()).unwrap();
let shipped =
std::fs::read_to_string(std::path::Path::new(BUNDLED_ADAPTERS).join("claude.toml"))
.unwrap();
assert_eq!(
std::fs::read_to_string(dest.join("claude.toml")).unwrap(),
shipped
);
}
#[test]
fn the_file_it_replaces_is_kept_verbatim() {
let d = tempfile::tempdir().unwrap();
let dest = d.path().join("adapters");
std::fs::create_dir_all(&dest).unwrap();
let mine = "name = \"mine, edited\"\n";
std::fs::write(dest.join("claude.toml"), mine).unwrap();
install_bundled(&dest, bundled::Shipped::Adapters, &out::Ctx::plain()).unwrap();
assert_eq!(
std::fs::read_to_string(dest.join("claude.toml.yours")).unwrap(),
mine,
"the replaced file must be recoverable byte for byte"
);
}
#[test]
fn a_replaced_file_is_kept_under_the_name_omh_names() {
for kind in bundled::ALL {
let d = tempfile::tempdir().unwrap();
let dest = d.path().join(kind.dir());
std::fs::create_dir_all(&dest).unwrap();
let first = kind.files()[0].name;
let mine = "this is what I wrote\n";
std::fs::write(dest.join(first), mine).unwrap();
install_bundled(&dest, kind, &out::Ctx::plain()).unwrap();
let backup = dest.join(format!("{first}.yours"));
assert_eq!(
std::fs::read_to_string(&backup).ok().as_deref(),
Some(mine),
"{}: an edit must be recoverable at {}",
kind.dir(),
backup.display()
);
}
}
#[test]
fn an_edit_omh_cannot_read_as_text_is_still_backed_up() {
let d = tempfile::tempdir().unwrap();
let dest = d.path().join("adapters");
std::fs::create_dir_all(&dest).unwrap();
let mine = b"name = \"caf\xe9\"\n"; std::fs::write(dest.join("claude.toml"), mine).unwrap();
install_bundled(&dest, bundled::Shipped::Adapters, &out::Ctx::plain()).unwrap();
assert_eq!(
std::fs::read(dest.join("claude.toml.yours")).unwrap(),
mine,
"bytes omh cannot decode are still bytes it must not discard"
);
}
#[test]
fn definitions_omh_does_not_ship_are_left_alone() {
let d = tempfile::tempdir().unwrap();
let dest = d.path().join("adapters");
std::fs::create_dir_all(&dest).unwrap();
std::fs::write(dest.join("mine.toml"), "name = \"mine\"\n").unwrap();
install_bundled(&dest, bundled::Shipped::Adapters, &out::Ctx::plain()).unwrap();
assert_eq!(
std::fs::read_to_string(dest.join("mine.toml")).unwrap(),
"name = \"mine\"\n"
);
}
#[test]
fn every_alias_is_a_single_letter() {
for sub in Cli::command().get_subcommands() {
for alias in sub.get_visible_aliases() {
assert_eq!(alias.chars().count(), 1, "`{alias}` is not a shortcut");
}
}
}
fn argv(parts: &[&str]) -> Vec<String> {
parts.iter().map(|s| s.to_string()).collect()
}
#[test]
fn omhs_own_flag_after_the_harness_name_is_refused() {
let err = passthrough(&argv(&["opencode", "--dry-run"]), &omh_globals()).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("--dry-run"), "name the flag: {msg}");
assert!(
msg.contains("omh --dry-run opencode"),
"and show the form that works: {msg}"
);
}
#[test]
fn a_flag_the_harness_owns_passes_through_untouched() {
let given = argv(&["claude", "--resume", "x"]);
assert_eq!(passthrough(&given, &omh_globals()).unwrap(), given);
}
#[test]
fn short_flags_belong_to_the_harness() {
let given = argv(&["claude", "-s", "something"]);
assert_eq!(passthrough(&given, &omh_globals()).unwrap(), given);
}
#[test]
fn a_double_dash_hands_the_rest_to_the_harness() {
let out = passthrough(&argv(&["claude", "--", "--dry-run"]), &omh_globals()).unwrap();
assert_eq!(out, argv(&["claude", "--dry-run"]));
}
#[test]
fn only_the_arguments_are_inspected_not_the_harness_name() {
let given = argv(&["--dry-run"]);
assert_eq!(passthrough(&given, &omh_globals()).unwrap(), given);
}
#[test]
fn no_command_writes_to_a_stream_behind_the_output_layer() {
let source = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/src/main.rs"))
.expect("this file is readable from its own test");
let offenders: Vec<(usize, &str)> = source
.lines()
.enumerate()
.map(|(i, line)| (i + 1, line.trim()))
.filter(|(_, line)| {
["println!", "print!(", "eprintln!", "eprint!("]
.iter()
.any(|m| line.starts_with(m))
})
.collect();
assert_eq!(
offenders.len(),
1,
"every write goes through out::Ctx but the error sink in `main` — found {offenders:#?}"
);
assert!(
offenders[0].1.contains("out::problem"),
"and the one exemption is the error renderer, not something new — got {:?}",
offenders[0]
);
}
#[test]
fn every_global_flag_is_covered_without_anyone_listing_them() {
let globals = omh_globals();
let declared: Vec<String> = Cli::command()
.get_arguments()
.filter(|a| a.is_global_set())
.filter_map(|a| a.get_long().map(|l| format!("--{l}")))
.collect();
assert!(!declared.is_empty(), "the parser must have globals at all");
for flag in declared {
assert!(globals.contains(&flag), "{flag} is not guarded");
assert!(
passthrough(&argv(&["claude", &flag]), &globals).is_err(),
"{flag} reaches the harness"
);
}
}
#[test]
#[cfg(unix)]
fn an_edit_omh_cannot_open_at_all_is_never_treated_as_absent() {
use std::os::unix::fs::PermissionsExt;
let d = tempfile::tempdir().unwrap();
let dest = d.path().join("adapters");
std::fs::create_dir_all(&dest).unwrap();
let target = dest.join("claude.toml");
std::fs::write(&target, "name = \"mine\"\n").unwrap();
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o200)).unwrap();
let outcome = install_bundled(&dest, bundled::Shipped::Adapters, &out::Ctx::plain());
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644)).unwrap();
let e = format!(
"{:#}",
outcome.expect_err("a file omh could not read must not be overwritten in silence")
);
assert!(e.contains("claude.toml"), "and it names the file: {e}");
assert_eq!(
std::fs::read_to_string(&target).unwrap(),
"name = \"mine\"\n",
"and the edit is still there"
);
}
#[test]
fn a_hook_that_belongs_to_nothing_is_offered_everywhere() {
let declared = BTreeMap::from([
("rust-test".to_string(), Some("rust".to_string())),
("go-test".to_string(), Some("go".to_string())),
("graph-refresh".to_string(), None),
]);
let names = vec![
"rust-test".to_string(),
"go-test".to_string(),
"graph-refresh".to_string(),
"never-declared".to_string(),
];
let rust: BTreeSet<String> = ["rust".to_string()].into_iter().collect();
assert_eq!(
applicable_hooks(names, &declared, &rust),
vec![
"rust-test".to_string(),
"graph-refresh".to_string(),
"never-declared".to_string()
],
"an ecosystem hook is filtered by the ecosystem; nothing else is"
);
}
}