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 shadow;
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 session_prefix(argv: Vec<String>) -> (Option<String>, Vec<String>) {
let Some(first) = argv.get(1) else {
return (None, argv);
};
let looks_like_a_session =
first.len() > 1 && first.starts_with('s') && first[1..].chars().all(|c| c.is_ascii_digit());
if !looks_like_a_session {
return (None, argv);
}
let as_written: Vec<String> = std::iter::once(argv[0].clone())
.chain(argv.iter().skip(2).cloned())
.collect();
let mut through_sessions = as_written.clone();
through_sessions.insert(1, "s".to_string());
let launch = match (
Cli::try_parse_from(&through_sessions),
Cli::try_parse_from(&as_written),
) {
(Ok(_), _) => false,
(Err(_), Ok(cli)) => match &cli.cmd {
Cmd::Run(harness) => !harness.first().is_some_and(|name| is_a_session_verb(name)),
_ => true,
},
(Err(_), Err(_)) => false,
};
(
Some(first.clone()),
if launch { as_written } else { through_sessions },
)
}
fn is_a_session_verb(name: &str) -> bool {
use clap::Subcommand;
SessionsCmd::augment_subcommands(clap::Command::new("s"))
.get_subcommands()
.any(|c| c.get_name() == name || c.get_all_aliases().any(|a| a == name))
}
fn the_one_session(prefix: Option<String>, flag: Option<String>) -> Result<Option<String>> {
if let (Some(prefix), Some(flag)) = (&prefix, &flag) {
anyhow::bail!(
"this names the session twice — `{prefix}` and `{flag}`. Name it once:\n omh {prefix} …"
);
}
Ok(flag.or(prefix))
}
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 {
#[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: Option<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 {
#[command(hide = true)]
Ls,
Rm {
#[arg(long)]
force: bool,
},
Down,
Sync {
#[arg(long)]
base: Option<String>,
#[arg(long)]
down: bool,
},
Log {
#[arg(long)]
turns: bool,
},
Diff {
checkpoint: Option<usize>,
#[arg(long, conflicts_with = "checkpoint")]
base: Option<String>,
#[arg(long, short = 'p')]
patch: bool,
},
Commit {
#[arg(short = 'm', long)]
message: Option<String>,
#[arg(long)]
skip_carried: bool,
#[arg(long, conflicts_with = "message", num_args = 0..=1, default_missing_value = "")]
keep: Option<String>,
#[arg(long, requires = "keep", conflicts_with = "message")]
edit: bool,
#[arg(long)]
force: 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 mut palette = out::Palette::plain();
let outcome = (|| -> Result<()> {
let (named, argv) = session_prefix(std::env::args().collect());
let mut cli = Cli::parse_from(argv);
cli.session = the_one_session(named, cli.session.take())?;
let (format, resolved) = cli.output();
palette = resolved;
dispatch(
&cli,
&out::Ctx {
format,
palette: resolved,
},
)
})();
match outcome {
Ok(()) => std::process::ExitCode::SUCCESS,
Err(e) => {
eprint!("{}", out::problem(&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 { stop } => graph(&cwd, cli.session.as_deref(), *stop, ctx),
Cmd::Attach { editor } => attach(&cwd, cli.session.as_deref(), editor.as_deref(), ctx),
Cmd::Sessions { cmd: None } => sessions_ls(&cwd, cli.session.as_deref(), ctx),
Cmd::Sessions { cmd: Some(cmd) } => match cmd {
SessionsCmd::Rm { force } => {
let id = cli.session.as_deref().context(
"which session? name it first:\n omh s01 rm\n omh s lists them",
)?;
rm(&cwd, id, *force, ctx)
}
SessionsCmd::Ls => anyhow::bail!(
"there is no `ls` verb any more:\n omh s is the listing\n omh s01 is one row of it"
),
SessionsCmd::Down => down(&cwd, cli.session.as_deref(), ctx),
SessionsCmd::Sync { base, down } => {
sync(&cwd, cli.session.as_deref(), base.as_deref(), *down, ctx)
}
SessionsCmd::Log { turns } => log_cmd(&cwd, cli.session.as_deref(), *turns, ctx),
SessionsCmd::Diff {
checkpoint,
base,
patch,
} => diff(
&cwd,
cli.session.as_deref(),
*checkpoint,
base.as_deref(),
*patch,
ctx,
),
SessionsCmd::Commit {
message,
skip_carried,
keep,
edit,
force,
} => commit(
&cwd,
cli.session.as_deref(),
match keep.as_deref() {
Some(selection) => Landing::Keep {
selection,
edit: *edit,
},
None => Landing::Squash(message.as_deref()),
},
*skip_carried,
*force,
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,
) -> Result<container::Reuse> {
let probe = backend.exec_args(name, &image::probe_command(), false);
container::decide(
&session.id,
image::container_probe(backend.program(), &probe),
|| image::container_stamp(backend.program(), name),
plan,
)
}
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 = must_know(
image::container_running(backend.as_ref(), &name),
&session.id,
"start or reuse it",
)?;
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 {id} down\n \
or start a fresh one omh --new {}",
live.join(", "),
changed.join(", "),
adapter.name,
id = session.id,
),
container::Reuse::Restart(why) => {
ctx.warn(&format!(
"restarting the sandbox for {} — {}",
session.label(),
why.join(", ")
));
image::container_remove(backend.program(), &name)
.with_context(|| format!("replacing the sandbox for {}", session.id))?;
}
}
}
say_rules(&plan, ctx);
image::ensure_stack(backend.program(), adapter, recipe, &paths.repo)?;
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 !must_know(
image::container_running(backend.as_ref(), &container),
"the graph",
"stop it",
)? {
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 !must_know(
image::container_running(backend.as_ref(), &container),
"the graph",
"start it",
)? {
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| {
reapable(&image::container_running(
backend.as_ref(),
&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;
let mut unasked = 0usize;
for i in &ids {
let name = paths.container(i);
match image::container_running(backend.as_ref(), &name) {
image::Running::No => {
sessions.push((i.clone(), report::Stopped::WasNotRunning));
continue;
}
image::Running::Unknown(why) => {
unasked += 1;
ctx.warn(&format!("could not tell whether {i} is running: {why}"));
sessions.push((i.clone(), report::Stopped::CouldNotTell(why)));
continue;
}
image::Running::Yes => {}
}
match image::container_remove(backend.program(), &name) {
Ok(()) => sessions.push((i.clone(), report::Stopped::Yes)),
Err(e) => {
stuck += 1;
ctx.warn(&format!("{i} is still running: {e:#}"));
}
}
}
ctx.say(&report::Down { sessions });
anyhow::ensure!(
stuck == 0 && unasked == 0,
"{}",
[
(stuck, "would not stop"),
(unasked, "could not be asked — the runtime did not answer"),
]
.iter()
.filter(|(n, _)| *n > 0)
.map(|(n, what)| format!("{n} session{} {what}", if *n == 1 { "" } else { "s" }))
.collect::<Vec<_>>()
.join("; ")
);
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(), &paths.repo)?;
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 from_the_sandbox = doctor::parse(&String::from_utf8_lossy(&out.stdout));
let _ = session.remove(&paths.repo, "", &paths.shadows()); let outcomes = every_check(from_the_sandbox).map_err(|e| {
match crate::out::untrusted(String::from_utf8_lossy(&out.stderr).trim()) {
said if said.is_empty() => e,
said => anyhow::anyhow!("{e}\n{said}"),
}
})?;
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, only: Option<&str>, ctx: &out::Ctx) -> Result<()> {
let paths = Paths::discover(cwd)?;
if let Some(id) = only {
existing_session(&paths, Some(id))?;
}
let backend = match runtime::select(&runtime_preference(&paths), &|p| runtime::installed(p)) {
Ok(backend) => Some(backend),
Err(e) => {
ctx.warn(&format!("omh cannot say which sandboxes are up: {e:#}"));
None
}
};
let base = session::default_branch(&paths.repo);
let mut changed: Vec<(String, Vec<String>)> = Vec::new();
let mut unreadable: Vec<String> = Vec::new();
let sessions: Vec<report::Session> = session::list(&paths.worktrees())
.into_iter()
.map(|id| {
let sess = Session::new(&paths.worktrees(), id.clone());
let touched = sess.changed();
match &touched {
Ok(touched) => changed.push((id.clone(), touched.clone())),
Err(_) => unreadable.push(id.clone()),
}
report::Session {
running: backend.as_ref().map(|b| {
let asked = image::container_running(b.as_ref(), &paths.container(&id));
if let image::Running::Unknown(why) = &asked {
ctx.warn(&format!(
"could not tell whether {id}'s sandbox is running: {why}"
));
}
asked
}),
label: sess.label().to_string(),
work: Some(work_state(
&sess,
&paths.repo,
&base,
touched.as_ref().ok().map(Vec::len),
)),
behind: match sess.behind(&paths.repo, &base) {
Ok(n) => Some(n),
Err(e) => {
ctx.warn(&format!(
"could not tell how far behind {base} {id} is: {e:#}"
));
None
}
},
id,
}
})
.collect();
let overlaps = report::overlaps(&changed);
let (sessions, overlaps) = match only {
None => (sessions, overlaps),
Some(id) => {
let rows: Vec<_> = sessions.into_iter().filter(|s| s.id == id).collect();
anyhow::ensure!(
!rows.is_empty(),
"{id} was there when omh looked and is not there now — \
removed while this ran? `omh s` lists what is left"
);
(
rows,
overlaps
.into_iter()
.filter(|o| o.sessions.iter().any(|s| s == id))
.collect(),
)
}
};
ctx.say(&report::Sessions {
sessions,
leftovers: match only {
None => leftovers(&paths, backend.as_deref(), ctx),
Some(_) => Vec::new(),
},
overlaps,
unreadable,
base,
});
Ok(())
}
fn leftovers(paths: &Paths, backend: Option<&dyn runtime::Runtime>, ctx: &out::Ctx) -> Vec<String> {
let live = session::list(&paths.worktrees());
let mut found: Vec<String> = match std::fs::read_dir(paths.shadows()) {
Ok(entries) => entries
.flatten()
.filter_map(|e| {
let name = e.file_name().to_string_lossy().into_owned();
name.strip_suffix(".git").map(str::to_string)
})
.collect(),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(e) => {
ctx.warn(&format!(
"omh could not read {}, so orphaned sandbox repositories went unchecked: {e}",
paths.shadows().display()
));
Vec::new()
}
};
found.extend(
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()),
);
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,
uncommitted: Option<usize>,
) -> report::Work {
use report::Work;
let (uncommitted, unpushed) = match (uncommitted, session.unpushed()) {
(Some(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) {
Ok(0) => Work::Clean,
Ok(n) => Work::ToPush(n),
Err(_) => Work::Unknown,
},
}
}
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 | carry::Action::Refreshed
if paths.repo.join(item.path.trim_end_matches('/')).is_dir() =>
{
ctx.warn(&format!(
"carried {} — a directory, so `git clean` in the sandbox can \
still remove it. Carrying the files individually keeps them.",
item.path
));
}
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 {} 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(), &paths.repo)?;
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<_>>(),
&paths.repo,
)?;
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, "", &paths.shadows()) {
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 decided = auth::decided_by_files(&adapter);
let mut action = if decided {
report::Action::new(
"account-captured",
format!("`{account}` captured for {harness}"),
)
} else {
report::Action::new(
"account-recorded",
format!("`{account}` recorded for {harness} — login not confirmed"),
)
.note(format!(
"{harness} keeps its credentials where omh cannot read them, so only \
{harness} can say whether the login took"
))
.next(format!("omh doctor {harness}"))
};
action = action.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: None,
behind: match sess.behind(&paths.repo, &base) {
Ok(n) => Some(n),
Err(e) => {
ctx.warn(&format!(
"could not tell how far behind {base} {id} is: {e:#}"
));
None
}
},
id,
}
})
.collect(),
base,
});
Ok(())
}
fn sync(
cwd: &std::path::Path,
id: Option<&str>,
base: Option<&str>,
down: bool,
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));
stop_before_syncing(&paths, &session, down, ctx)?;
ctx.say(&sync_session(&paths, &session, &base)?);
Ok(())
}
fn sync_session(paths: &Paths, session: &Session, base: &str) -> Result<report::Synced> {
let branch = session
.branch
.as_deref()
.context("a scratch session has no base to move")?;
let was = session::head_of(&paths.repo, branch)?;
let onto = session::head_of(&paths.repo, base)?;
anyhow::ensure!(
was != onto,
"{} is already on {base}. Nothing to bring over",
session.id
);
let shadow = shadow::Shadow::new(&paths.shadows(), &session.id);
let checkpoint = shadow.checkpoint(&session.worktree, "Before omh brought the base forward")?;
let ours = format!("refs/{}", session.id);
let tree = session.tree(base)?;
session::name_tree(&paths.repo, &ours, &tree)?;
let merged = session::merge_three(&paths.repo, &was, base, &session.id);
let _ = session::unname_tree(&paths.repo, &ours);
let merged = merged?;
session.materialise(&merged.tree)?;
session.move_baseline(&paths.repo, &onto, &was)?;
shadow.record_base_moved(&session.worktree, &onto, &merged.conflicted)?;
let moved = session.commits_between(&paths.repo, &was, &onto)?;
let note = shadow
.leave_note(&shadow::note_for(base, moved, merged.conflicted.len()))
.err()
.map(|why| format!("{why:#}"));
Ok(report::Synced {
id: session.id.clone(),
moved,
base: base.to_string(),
onto,
conflicted: merged.conflicted,
checkpoint: checkpoint.is_some(),
note,
})
}
fn stop_before_syncing(paths: &Paths, session: &Session, down: bool, ctx: &out::Ctx) -> Result<()> {
let backend = runtime::select(&runtime_preference(paths), &|p| runtime::installed(p))
.with_context(|| {
format!(
"omh cannot tell whether {}'s sandbox is running, so it will not sync over it",
session.id
)
})?;
let name = paths.container(&session.id);
if !must_know(
image::container_running(backend.as_ref(), &name),
&session.id,
"sync over it",
)? {
return Ok(());
}
anyhow::ensure!(
down,
"{id} is running, and a sync moves files underneath it. What the agent believes \
the tree holds is in its conversation rather than on disk, so it would keep \
editing a version that no longer exists:\n \
omh {id} down stop it, then sync\n \
omh {id} sync --down both, if the turn is safe to interrupt",
id = session.id
);
ctx.progress(&format!("stopping {} first", session.id));
image::container_remove(backend.program(), &name)?;
Ok(())
}
fn log_cmd(cwd: &std::path::Path, id: Option<&str>, turns: bool, ctx: &out::Ctx) -> Result<()> {
let paths = Paths::discover(cwd)?;
let session = existing_session(&paths, id)?;
ctx.say(&log_report(&paths, &session, turns, ctx)?);
Ok(())
}
fn log_report(
paths: &Paths,
session: &Session,
turns: bool,
ctx: &out::Ctx,
) -> Result<report::Log> {
let shadow = shadow::Shadow::new(&paths.shadows(), &session.id);
let read = match std::fs::metadata(&shadow.seed_record) {
Ok(_) => shadow.checkpoints(&session.worktree)?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound && !shadow.gitdir.exists() => {
shadow::Checkpoints::default()
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => anyhow::bail!(
"{} has a sandbox repository at {} and no record of where it started. omh \
cannot tell you what the agent committed, and `omh {} rm` would remove the \
repository. Read it directly first:\n git --git-dir={} log",
session.id,
shadow.gitdir.display(),
session.id,
shadow.gitdir.display()
),
Err(e) => {
return Err(e).with_context(|| {
format!(
"reading where {} started, at {}",
session.id,
shadow.seed_record.display()
)
})
}
};
let base = session::default_branch(&paths.repo);
let behind = match session.behind(&paths.repo, &base) {
Ok(behind) => Some(behind),
Err(e) => {
ctx.warn(&format!(
"could not tell how far behind {base} this is: {e}"
));
None
}
};
let turns = match turns {
false => None,
true => Some(shadow.turn_log(&session.worktree)?),
};
Ok(report::Log {
id: session.id.clone(),
read,
behind,
base,
turns,
})
}
fn diff(
cwd: &std::path::Path,
id: Option<&str>,
checkpoint: Option<usize>,
base: Option<&str>,
patch: bool,
ctx: &out::Ctx,
) -> Result<()> {
let paths = Paths::discover(cwd)?;
let session = existing_session(&paths, id)?;
let report = diff_report(&paths, &session, checkpoint, base, patch, ctx)?;
let paged = patch && ctx.format == out::Format::Human && report.changed();
if !paged {
ctx.say(&report);
return Ok(());
}
let colour = match ctx.palette.is_plain() {
true => "never",
false => "always",
};
match checkpoint {
Some(number) => shadow::Shadow::new(&paths.shadows(), &session.id).stream_show(
&paths.repo,
&session.worktree,
number,
colour,
),
None => session.stream_diff(&report.base, colour),
}
}
fn diff_report(
paths: &Paths,
session: &Session,
checkpoint: Option<usize>,
base: Option<&str>,
patch: bool,
ctx: &out::Ctx,
) -> Result<report::Diff> {
let what = match patch {
true => session::What::Patch,
false => session::What::Summary,
};
let Some(number) = checkpoint else {
let base = base
.map(str::to_string)
.unwrap_or_else(|| session::default_branch(&paths.repo));
let body = session.diff(&base, what)?;
return Ok(report::Diff {
label: session.label().to_string(),
session: session.id.clone(),
checkpoint: None,
base,
what,
body,
});
};
let shadow = shadow::Shadow::new(&paths.shadows(), &session.id);
anyhow::ensure!(
shadow.seed_record.exists() || shadow.gitdir.exists(),
"there is no checkpoint {number} in this session. The agent has not committed \
anything here yet — its sandbox has never run"
);
let _ = ctx;
Ok(report::Diff {
label: format!("{} checkpoint {number}", session.id),
session: session.id.clone(),
checkpoint: Some(number),
base: "its parent".to_string(),
what,
body: shadow.show(&session.worktree, number, what)?,
})
}
fn what_to_keep(
shadow: &shadow::Shadow,
session: &Session,
selection: &str,
edit: bool,
terminal: bool,
keeps_a_selection: &dyn Fn() -> Result<bool>,
) -> Result<shadow::Keep> {
anyhow::ensure!(
!edit || selection.is_empty(),
"`--edit` opens the whole list for editing, so `--keep {selection} --edit` names \
what to take twice. Use one: `--keep {selection}` takes those, `--keep --edit` \
opens all of them"
);
if edit {
anyhow::ensure!(
terminal,
"`--edit` opens the list in your editor and there is no terminal here. \
git would run the list unedited and report success. Drop `--edit` to keep \
everything, or name what you want: `omh {} commit --keep 1,3-4`",
session.id
);
return Ok(shadow::Keep::Edit);
}
if selection.is_empty() {
return Ok(shadow::Keep::All);
}
match keeps_a_selection().with_context(|| {
format!(
"omh cannot tell whether this git can name checkpoints, so it will not guess. \
`omh {} commit --keep` takes them all and asks nothing new of git",
session.id
)
})? {
true => {}
false => anyhow::bail!(
"naming checkpoints needs a newer git than this one: `git cherry-pick` here has \
no `--empty`, which omh uses to drop a commit whose changes are already on the \
branch. `omh {} commit --keep` takes them all and works on any git omh supports",
session.id
),
}
let read = shadow.checkpoints(&session.worktree)?;
let mut ids = Vec::new();
for number in shadow::chosen(selection, read.commits.len())? {
let checkpoint = &read.commits[number - 1];
debug_assert_eq!(checkpoint.number, number);
anyhow::ensure!(
!checkpoint.landed,
"checkpoint {number} is already on {}. `omh {} log` draws the line: everything \
below it has been handed over, and handing it over again applies it twice",
session.branch.as_deref().unwrap_or("the branch"),
session.id
);
anyhow::ensure!(
checkpoint.touched.is_some(),
"checkpoint {number} is a merge, and omh will not choose which side of one to \
take. `omh {} commit --keep` replays the whole range instead — note that git \
flattens a merge when it does",
session.id
);
ids.push(checkpoint.id.clone());
}
Ok(shadow::Keep::These(ids))
}
fn every_check(from_the_sandbox: Vec<doctor::Outcome>) -> Result<Vec<doctor::Outcome>> {
anyhow::ensure!(
!from_the_sandbox.is_empty(),
"the probe produced no output — the sandbox did not run it"
);
Ok(from_the_sandbox
.into_iter()
.chain(doctor::git_checks())
.collect())
}
fn may_commit(id: &str, unresolved: &[String], force: bool) -> Result<()> {
if unresolved.is_empty() || force {
return Ok(());
}
let shown: Vec<&str> = unresolved.iter().take(5).map(String::as_str).collect();
let rest = unresolved.len().saturating_sub(shown.len());
anyhow::bail!(
"{id} still has {n} conflict marker{s} in its files:\n {lines}{more}\n\
Resolve them first, or:\n \
omh {id} commit --keep --force commit them anyway",
n = unresolved.len(),
s = if unresolved.len() == 1 { "" } else { "s" },
lines = shown.join("\n "),
more = match rest {
0 => String::new(),
n => format!("\n …and {n} more"),
}
);
}
fn reapable(running: &image::Running) -> bool {
matches!(running, image::Running::Yes)
}
fn must_know(running: image::Running, what: &str, doing: &str) -> Result<bool> {
match running {
image::Running::Yes => Ok(true),
image::Running::No => Ok(false),
image::Running::Unknown(why) => anyhow::bail!(
"omh could not tell whether {what} is running, so it will not {doing}: {why}"
),
}
}
#[derive(Debug)]
enum Snapshots {
None,
Kept(usize),
Unreadable(String),
}
fn may_remove(
paths: &Paths,
session: &Session,
snapshots: Snapshots,
force: bool,
) -> Result<Option<String>> {
let branch = format!("omh/{}", session.id);
let also = match &snapshots {
Snapshots::None => String::new(),
Snapshots::Kept(n) => format!(", and {n} turn snapshot{} omh took", plural(*n)),
Snapshots::Unreadable(why) => {
format!(", and omh could not tell how many turn snapshots go with it ({why})")
}
};
let snapshots = match snapshots {
Snapshots::Kept(n) => n,
Snapshots::Unreadable(_) => usize::MAX,
Snapshots::None => 0,
};
let reading = match snapshots {
0 => String::new(),
_ => format!(
"\n omh {} log --turns read the snapshots",
session.id
),
};
let (what, whether) = match at_stake(paths, session) {
AtStake::Nothing => {
return Ok(non_empty(match snapshots {
0 => String::new(),
usize::MAX => format!(
"omh could not tell how many turn snapshots go with {id}{also_why}. \
`omh {id} log --turns` would say",
id = session.id,
also_why = also
.split_once('(')
.map(|(_, w)| format!(" — {}", w.trim_end_matches(')')))
.unwrap_or_default()
),
n => format!(
"{n} turn snapshot{} omh took go with {id}. `omh {id} log --turns` reads them",
plural(n),
id = session.id
),
}))
}
AtStake::Work(what) => (what, "that no branch has"),
AtStake::Unknown(why) => (why, "and omh cannot say what that removes"),
};
anyhow::ensure!(
force,
"{id} has {what} {whether}{also}. Removing it deletes the only copy:\n \
omh {id} log read what is there\n \
omh {id} commit --keep put it on {branch}\n \
omh {id} commit -m \"…\" or take the files as they stand{reading}\n \
omh {id} rm --force remove it anyway",
id = session.id
);
Ok(None)
}
fn non_empty(s: String) -> Option<String> {
(!s.is_empty()).then_some(s)
}
#[derive(Debug)]
enum AtStake {
Nothing,
Work(String),
Unknown(String),
}
fn from_the_seed_record(
metadata: std::io::Result<()>,
gitdir_exists: bool,
shadow: &shadow::Shadow,
) -> Option<AtStake> {
match metadata {
Ok(()) => None,
Err(e) if e.kind() == std::io::ErrorKind::NotFound && !gitdir_exists => {
Some(AtStake::Nothing)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Some(AtStake::Unknown(format!(
"a sandbox repository at {} and no record of where it started",
shadow.gitdir.display()
))),
Err(e) => Some(AtStake::Unknown(format!(
"a sandbox omh could not read at {}: {e}",
shadow.seed_record.display()
))),
}
}
fn at_stake(paths: &Paths, session: &Session) -> AtStake {
let shadow = shadow::Shadow::new(&paths.shadows(), &session.id);
if let Some(answer) = from_the_seed_record(
std::fs::metadata(&shadow.seed_record).map(|_| ()),
shadow.gitdir.exists(),
&shadow,
) {
return answer;
}
match shadow.unkept(&session.worktree) {
Err(e) => AtStake::Unknown(format!("a sandbox omh could not read: {e}")),
Ok((0, 0)) => AtStake::Nothing,
Ok((0, files)) => AtStake::Work(format!("{files} uncommitted path{}", plural(files))),
Ok((commits, 0)) => AtStake::Work(format!("{commits} commit{}", plural(commits))),
Ok((commits, files)) => AtStake::Work(format!(
"{commits} commit{} and {files} uncommitted path{}",
plural(commits),
plural(files)
)),
}
}
fn plural(n: usize) -> &'static str {
match n {
1 => "",
_ => "s",
}
}
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` lists them",
session.id
);
Ok(session)
}
enum Landing<'a> {
Squash(Option<&'a str>),
Keep {
selection: &'a str,
edit: bool,
},
}
fn commit(
cwd: &std::path::Path,
id: Option<&str>,
landing: Landing,
skip_carried: bool,
force: bool,
ctx: &out::Ctx,
) -> Result<()> {
let paths = Paths::discover(cwd)?;
let session = existing_session(&paths, id)?;
let base = session::default_branch(&paths.repo);
may_commit(&session.id, &session.unresolved(&base)?, force)?;
let message = match landing {
Landing::Squash(message) => message,
Landing::Keep { selection, edit } => {
let branch = session
.branch
.as_deref()
.context("a scratch session has no branch to commit to")?;
let shadow = crate::shadow::Shadow::new(&paths.shadows(), &session.id);
let carried = config::policy_list(&paths, "carry_in");
let keep = what_to_keep(
&shadow,
&session,
selection,
edit,
std::io::IsTerminal::is_terminal(&std::io::stdin()),
&|| shadow::git_supports("cherry-pick", "--empty"),
)?;
let named = match &keep {
shadow::Keep::These(ids) => Some(ids.len()),
_ => None,
};
let landed = shadow.harvest(&paths.repo, &session.worktree, branch, &carried, keep)?;
if let Some(named) = named.filter(|named| *named > landed) {
ctx.warn(&format!(
"you named {named} checkpoint{}, and {landed} reached {branch} — git drops a \
commit whose changes are already there. `omh {} log` shows what is left",
if named == 1 { "" } else { "s" },
session.id
));
}
let n = session.commits(&paths.repo, &base);
warn_uncounted(&n, ctx, &base);
ctx.say(
&report::Action::new(
"committed",
match landed {
0 if shadow.landed().map(|l| l.is_some()).unwrap_or(true) => format!(
"nothing new to keep — everything {} has committed is already on \
the branch",
session.label()
),
0 => format!("nothing to keep — {} has made no commits", session.label()),
_ => format!(
"kept {landed} of {}'s own commits{}",
session.label(),
branch_tally(&n)
),
},
)
.data(serde_json::json!({
"session": session.id,
"branch": session.label(),
"kept": landed,
"commits": n.as_ref().ok(),
"base": base,
})),
);
return Ok(());
}
};
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);
warn_uncounted(&n, ctx, &base);
ctx.say(
&report::Action::new(
"committed",
format!("committed to {}{}", session.label(), branch_tally(&n)),
)
.data(serde_json::json!({
"session": session.id,
"branch": session.label(),
"commits": n.as_ref().ok(),
"base": base,
})),
);
Ok(())
}
fn warn_uncounted(n: &Result<usize>, ctx: &out::Ctx, base: &str) {
if let Err(e) = n {
ctx.warn(&format!(
"could not count this branch against {base} — {e:#}"
));
}
}
fn branch_tally(n: &Result<usize>) -> String {
match n {
Ok(n) => format!(
" ({n} {} on the branch)",
if *n == 1 { "commit" } else { "commits" }
),
Err(_) => String::new(),
}
}
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, force: bool, ctx: &out::Ctx) -> Result<()> {
session::validate_id(id)?;
let paths = Paths::discover(cwd)?;
let session = Session::new(&paths.worktrees(), id.to_string());
let snapshots =
match shadow::Shadow::new(&paths.shadows(), &session.id).turns(&session.worktree) {
Ok(None) => Snapshots::None,
Ok(Some(n)) => Snapshots::Kept(n),
Err(e) => Snapshots::Unreadable(format!("{e:#}")),
};
if let Some(note) = may_remove(&paths, &session, snapshots, force)? {
ctx.warn(note.trim());
}
if let Ok(backend) = runtime::select(&runtime_preference(&paths), &|p| runtime::installed(p)) {
let name = paths.container(id);
let up = image::container_running(backend.as_ref(), &name);
if let image::Running::Unknown(why) = &up {
ctx.warn(&format!(
"could not tell whether {id}'s sandbox was up, so its graph entry \
was left behind: {why}"
));
}
if matches!(up, image::Running::Yes) {
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();
}
if let Err(e) = image::container_remove(backend.program(), &name) {
ctx.warn(&format!(
"{id}'s container would not stop, and its worktree is going: it is left \
running against a directory that will not be there. `docker rm -f {name}` \
clears it — {e:#}"
));
}
}
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, &paths.shadows())? {
session::Removed::BranchKept(n) => {
let (kept, review) = match n {
Some(n) => (
format!(
"kept ({n} {} to review)",
if n == 1 { "commit" } else { "commits" }
),
format!("git log {base}..omh/{id}"),
),
None => (
format!("kept — omh could not count it against {base}"),
format!("git log omh/{id}"),
),
};
report::Action::new(
"session-removed",
format!("removed session {id}; branch omh/{id} {kept}"),
)
.next(review)
.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;
#[test]
fn a_tally_omh_could_not_take_is_absent_rather_than_zero() {
assert_eq!(branch_tally(&Ok(1)), " (1 commit on the branch)");
assert_eq!(branch_tally(&Ok(3)), " (3 commits on the branch)");
assert_eq!(
branch_tally(&Ok(0)),
" (0 commits on the branch)",
"a real zero is still an answer and still gets said"
);
assert_eq!(
branch_tally(&Err(anyhow::anyhow!("bad revision"))),
"",
"and a count nobody took says nothing at all"
);
}
const BUNDLED_ADAPTERS: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/adapters");
const BUNDLED_EDITORS: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/editors");
fn cli_argv(parts: &[&str]) -> Vec<String> {
std::iter::once("omh")
.chain(parts.iter().copied())
.map(str::to_string)
.collect()
}
#[test]
fn a_session_named_first_is_the_session_the_command_acts_on() {
assert_eq!(
session_prefix(cli_argv(&["s01", "diff"])),
(Some("s01".to_string()), cli_argv(&["s", "diff"]))
);
assert!(
!session_prefix(cli_argv(&["s01", "push", "fix/x"]))
.1
.contains(&"s01".to_string()),
"the id is lifted out, never left in the arguments"
);
assert_eq!(
session_prefix(cli_argv(&["s02", "commit", "--keep", "1,3"])),
(
Some("s02".to_string()),
cli_argv(&["s", "commit", "--keep", "1,3"])
)
);
assert_eq!(
session_prefix(cli_argv(&["s02", "commit", "--whatever"])),
(
Some("s02".to_string()),
cli_argv(&["s", "commit", "--whatever"])
),
"a session verb omh cannot parse is not a harness"
);
}
#[test]
fn a_session_named_first_also_works_for_what_sessions_has_no_verb_for() {
assert_eq!(
session_prefix(cli_argv(&["s01", "claude", "--resume", "x"])),
(
Some("s01".to_string()),
cli_argv(&["claude", "--resume", "x"])
)
);
assert_eq!(
session_prefix(cli_argv(&["s01", "attach", "zed"])),
(Some("s01".to_string()), cli_argv(&["attach", "zed"]))
);
let (named, argv) = session_prefix(cli_argv(&["s01", "graph"]));
assert_eq!(
the_one_session(named, Cli::try_parse_from(&argv).unwrap().session).unwrap(),
Some("s01".to_string()),
"the graph opens on the session named, not the one picked"
);
}
#[test]
fn a_session_with_nothing_to_do_is_asked_what_to_do() {
assert_eq!(
session_prefix(cli_argv(&["s01"])),
(Some("s01".to_string()), cli_argv(&["s"]))
);
}
#[test]
fn a_harness_flag_is_not_omh_naming_the_session_twice() {
assert_eq!(
session_prefix(cli_argv(&["s01", "claude", "-s", "some-session"])),
(
Some("s01".to_string()),
cli_argv(&["claude", "-s", "some-session"])
),
"the harness keeps its own flags"
);
}
#[test]
fn omhs_own_flags_may_sit_between_the_session_and_the_verb() {
assert_eq!(
session_prefix(cli_argv(&["s01", "--json", "diff"])),
(Some("s01".to_string()), cli_argv(&["s", "--json", "diff"]))
);
assert_eq!(
session_prefix(cli_argv(&["s01", "--dry-run", "claude"])),
(Some("s01".to_string()), cli_argv(&["--dry-run", "claude"])),
"and a harness is still a harness"
);
}
#[test]
fn no_test_attribute_was_stranded_from_its_function() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let mut stranded = Vec::new();
let mut checked = 0;
for dir in ["src", "tests"] {
for file in std::fs::read_dir(root.join(dir)).unwrap() {
let file = file.unwrap().path();
if file.extension().is_none_or(|e| e != "rs") {
continue;
}
checked += 1;
let body = std::fs::read_to_string(&file).unwrap();
let lines: Vec<&str> = body.lines().collect();
for (n, line) in lines.iter().enumerate() {
if line.trim() != "#[test]" {
continue;
}
let next = lines.get(n + 1).map(|l| l.trim()).unwrap_or("");
let attached = next.starts_with("fn ")
|| next.starts_with("async fn ")
|| (next.starts_with("#[") && next != "#[test]");
if !attached {
stranded.push(format!(
"{}:{}: followed by `{next}`",
file.display(),
n + 1
));
}
}
for (n, line) in lines.iter().enumerate() {
if line.trim().starts_with("#[")
&& lines
.get(n + 1)
.is_some_and(|l| l.trim().starts_with("///"))
{
stranded.push(format!(
"{}:{}: `{}` sits above a doc comment",
file.display(),
n + 1,
line.trim()
));
}
}
}
}
assert!(checked > 1, "the scan found no sources to read");
assert!(
stranded.is_empty(),
"`#[test]` separated from its function: {stranded:#?}"
);
}
#[test]
fn nothing_still_offers_a_verb_that_was_retired() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
const ON_PURPOSE: &str = "types the retired verb on purpose";
let gone: [String; 4] = [
format!("omh s {}", "ls"),
format!("omh sessions {}", "ls"),
format!("{:?}, {:?}", "s", "ls"), format!("{:?}, {:?}", "sessions", "ls"), ];
let mut found = Vec::new();
let mut read = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(at) = stack.pop() {
for entry in std::fs::read_dir(&at).unwrap().flatten() {
let path = entry.path();
if path.is_dir() {
if !matches!(
path.file_name().and_then(|n| n.to_str()),
Some("target") | Some(".git")
) {
stack.push(path);
}
continue;
}
if path.extension().is_none_or(|e| e != "rs" && e != "md") {
continue;
}
let body = std::fs::read_to_string(&path).unwrap();
read.push(path.strip_prefix(root).unwrap_or(&path).to_path_buf());
for (n, line) in body.lines().enumerate() {
if line.contains(ON_PURPOSE) {
continue;
}
for spelling in &gone {
if line.contains(spelling.as_str()) {
found.push(format!("{}:{}", path.display(), n + 1));
}
}
}
}
}
for must in ["README.md", "src/main.rs", "docs/commands.md"] {
assert!(
read.iter().any(|p| p == std::path::Path::new(must)),
"the scan never read {must}, so its silence says nothing"
);
}
assert!(
found.is_empty(),
"these still offer a command omh no longer accepts: {found:#?}"
);
}
#[test]
fn no_doc_comment_was_spliced_onto_itself() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let mut checked = 0;
let mut doubled = Vec::new();
for dir in ["src", "tests"] {
for file in std::fs::read_dir(root.join(dir)).unwrap() {
let file = file.unwrap().path();
if file.extension().is_none_or(|e| e != "rs") {
continue;
}
checked += 1;
for (n, line) in std::fs::read_to_string(&file).unwrap().lines().enumerate() {
if line.matches("///").count() > 1 {
doubled.push(format!("{}:{}: {}", file.display(), n + 1, line.trim()));
}
}
}
}
assert!(checked > 1, "the scan found no sources to read");
assert!(
doubled.is_empty(),
"doc comments spliced together: {doubled:#?}"
);
}
#[test]
fn the_session_lines_omh_prints_are_lines_omh_accepts() {
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut checked = 0;
for file in std::fs::read_dir(&src).unwrap() {
let file = file.unwrap().path();
if file.extension().is_none_or(|e| e != "rs") {
continue;
}
let body = std::fs::read_to_string(&file).unwrap();
let mut joined = String::new();
let mut continued = false;
for line in body.lines() {
let line = if continued { line.trim_start() } else { line };
match line.strip_suffix('\\') {
Some(head) if !head.ends_with('\\') => {
joined.push_str(head);
continued = true;
}
_ => {
joined.push_str(line);
joined.push('\n');
continued = false;
}
}
}
let body = joined;
for raw in body.lines() {
if raw.trim_start().starts_with("//") {
continue;
}
for (at, _) in raw.match_indices("omh ") {
let rest = &raw[at + "omh ".len()..];
let end = ["\\n", "\"", "`", "·", " ", ","]
.iter()
.filter_map(|stop| rest.find(stop))
.min()
.unwrap_or(rest.len());
let line = rest[..end].trim();
let filled = regex_lite_fill(line);
let words: Vec<&str> = filled.split_whitespace().collect();
let names_a_session = matches!(words.first(), Some(&"s" | &"sessions" | &"s01"))
&& words.get(1).is_some_and(|w| is_a_session_verb(w))
&& words.last().is_some_and(|w| !w.starts_with('-'));
if !names_a_session {
continue;
}
let argv: Vec<String> = std::iter::once("omh")
.chain(words)
.map(str::to_string)
.collect();
let (_, argv) = session_prefix(argv);
assert!(
Cli::try_parse_from(&argv).is_ok(),
"{} prints `omh {line}`, which omh does not accept",
file.file_name().unwrap().to_string_lossy()
);
checked += 1;
}
}
}
assert!(
checked >= 4,
"the scan found only {checked} session lines — it stopped reading, \
which is how this passes while saying nothing"
);
}
fn regex_lite_fill(line: &str) -> String {
let mut out = String::new();
let mut rest = line;
let mut first = true;
while let Some(open) = rest.find(['{', '<']) {
let close = if rest.as_bytes()[open] == b'{' {
'}'
} else {
'>'
};
let Some(shut) = rest[open..].find(close) else {
break;
};
out.push_str(&rest[..open]);
out.push_str(if first { "s01" } else { "1" });
first = false;
rest = &rest[open + shut + 1..];
}
out.push_str(rest);
out
}
#[test]
fn a_command_that_is_not_a_session_is_not_read_as_one() {
for line in [
vec!["s", "diff"],
vec!["init"],
vec!["claude"],
vec!["sessions", "log"],
vec!["sourcegraph"],
] {
assert_eq!(
session_prefix(cli_argv(&line)),
(None, cli_argv(&line)),
"{line:?} is not a session prefix"
);
}
}
#[test]
fn a_session_named_twice_is_refused_rather_than_picked() {
for flag in [
vec!["--session", "s02"],
vec!["--session=s02"],
vec!["-s", "s02"],
vec!["-ss02"],
] {
let line: Vec<&str> = std::iter::once("s01")
.chain(flag.iter().copied())
.chain(std::iter::once("diff"))
.collect();
let (prefix, argv) = session_prefix(cli_argv(&line));
let parsed = Cli::try_parse_from(&argv)
.unwrap_or_else(|e| panic!("{line:?} has to reach the parser: {e}"));
let err = the_one_session(prefix, parsed.session)
.expect_err("two names for one session is not something to guess at");
assert!(
err.to_string().contains("s01") && err.to_string().contains("s02"),
"the refusal has to name both, for {line:?}: {err}"
);
}
}
#[test]
fn a_harness_flag_is_still_not_omh_naming_the_session_twice() {
let (prefix, argv) = session_prefix(cli_argv(&["s01", "claude", "-s", "some-session"]));
let parsed = Cli::try_parse_from(&argv).expect("a launch is a valid line");
assert_eq!(
the_one_session(prefix, parsed.session).unwrap(),
Some("s01".to_string()),
"the harness keeps its own flags and the session stays the prefix's"
);
}
#[test]
fn the_log_reads_the_named_sessions_own_sandbox() {
let dir = tempfile::tempdir().unwrap();
let paths = Paths {
root: dir.path().join("home"),
repo: dir.path().join("repo"),
};
std::fs::create_dir_all(&paths.repo).unwrap();
let git = |args: &[&str]| {
let out = Command::new("git")
.current_dir(&paths.repo)
.args(args)
.output()
.expect("git must be installed to run this test");
assert!(out.status.success(), "git {args:?}: {out:?}");
};
git(&["init", "-q", "-b", "main"]);
git(&["config", "user.email", "t@example.com"]);
git(&["config", "user.name", "t"]);
git(&["commit", "-q", "--allow-empty", "-m", "root"]);
std::fs::create_dir_all(paths.shadows()).unwrap();
let mut sessions = Vec::new();
for (id, subject) in [("s01", "Only in s01"), ("s02", "Only in s02")] {
let session = Session::new(&paths.worktrees().join(id), id.to_string());
session.ensure(&paths.repo, "main").unwrap();
let shadow = shadow::Shadow::new(&paths.shadows(), id);
shadow.ensure(&session.worktree, &[]).unwrap();
std::fs::write(session.worktree.join("work.rs"), format!("// {subject}\n")).unwrap();
for args in [
vec!["add", "-A", "."],
vec!["commit", "-q", "--no-verify", "-m", subject],
] {
let out = Command::new("git")
.arg("--git-dir")
.arg(&shadow.gitdir)
.arg("--work-tree")
.arg(&session.worktree)
.args(&args)
.output()
.unwrap();
assert!(out.status.success(), "{args:?}: {out:?}");
}
sessions.push(session);
}
for (session, mine, theirs) in [
(&sessions[0], "Only in s01", "Only in s02"),
(&sessions[1], "Only in s02", "Only in s01"),
] {
let log = log_report(&paths, session, false, &out::Ctx::plain()).unwrap();
let subjects: Vec<&str> = log
.read
.commits
.iter()
.map(|c| c.subject.as_str())
.collect();
assert!(
subjects.contains(&mine) && !subjects.contains(&theirs),
"{}'s log is {}'s work: {subjects:?}",
session.id,
session.id
);
}
}
#[test]
fn keep_takes_a_selection_or_nothing_and_never_the_next_word() {
let parse = |args: &[&str]| {
let argv: Vec<String> = std::iter::once("omh")
.chain(args.iter().copied())
.map(str::to_string)
.collect();
match Cli::try_parse_from(&argv).map(|cli| cli.cmd) {
Ok(Cmd::Sessions {
cmd: Some(SessionsCmd::Commit { keep, edit, .. }),
}) => Ok((keep, edit)),
Ok(_) => panic!("{args:?} did not parse as a commit"),
Err(e) => Err(e.to_string()),
}
};
assert_eq!(parse(&["s", "commit"]).unwrap(), (None, false));
assert_eq!(
parse(&["s", "commit", "--keep"]).unwrap(),
(Some(String::new()), false),
"a bare --keep is not a missing value"
);
assert_eq!(
parse(&["s", "commit", "--keep", "1,3-4"]).unwrap(),
(Some("1,3-4".to_string()), false)
);
assert_eq!(
parse(&["s", "commit", "--keep", "--edit"]).unwrap(),
(Some(String::new()), true),
"--keep does not swallow the flag after it"
);
assert!(
parse(&["s", "commit", "--edit"])
.unwrap_err()
.contains("--keep"),
"--edit is about the list --keep takes"
);
assert!(
parse(&["s", "commit", "--keep", "-m", "x"])
.unwrap_err()
.contains("cannot be used with"),
"and squashing is the other way to land work, not a modifier of this one"
);
}
#[test]
fn a_commit_will_not_land_an_unresolved_conflict_unless_it_is_meant() {
let none: Vec<String> = vec![];
assert!(
may_commit("s01", &none, false).is_ok(),
"a clean tree commits"
);
let one = vec!["src/tap.rs:12: leftover conflict marker".to_string()];
let said = may_commit("s01", &one, false).unwrap_err().to_string();
assert!(
said.contains("src/tap.rs:12"),
"it says where, so the user is not sent hunting: {said}"
);
assert!(
said.contains("1 conflict marker in"),
"and one is not `1 conflict markers`: {said}"
);
assert!(
said.contains("--force"),
"and the way past is in the refusal: {said}"
);
assert!(may_commit("s01", &one, true).is_ok(), "`--force` means it");
let many: Vec<String> = (1..=40)
.map(|n| format!("src/big.rs:{n}: leftover conflict marker"))
.collect();
let said = may_commit("s01", &many, false).unwrap_err().to_string();
assert_eq!(
said.matches("leftover conflict marker").count(),
5,
"five lines, not forty: {said}"
);
assert!(
said.contains("40 conflict markers") && said.contains("…and 35 more"),
"and the count is still the whole truth: {said}"
);
}
#[test]
fn a_container_question_omh_could_not_answer_stops_the_command() {
assert!(must_know(image::Running::Yes, "s01", "sync over it").unwrap());
assert!(!must_know(image::Running::No, "s01", "sync over it").unwrap());
let refused = must_know(
image::Running::Unknown("daemon not reachable".into()),
"s01",
"sync over it",
)
.unwrap_err()
.to_string();
assert!(
refused.contains("sync over it"),
"it says what it declined to do: {refused}"
);
assert!(
refused.contains("daemon not reachable"),
"and the runtime's reason, which is the only actionable part: {refused}"
);
assert!(
refused.contains("s01") && !refused.contains("the sandbox"),
"and names what it asked about: {refused}"
);
let graph = must_know(
image::Running::Unknown("daemon not reachable".into()),
"the graph",
"stop it",
)
.unwrap_err()
.to_string();
assert!(graph.contains("the graph is running"), "got: {graph}");
}
#[test]
fn a_session_omh_cannot_ask_about_is_never_reaped() {
assert!(reapable(&image::Running::Yes), "a live one may be reaped");
assert!(
!reapable(&image::Running::No),
"one already down has nothing to reap"
);
assert!(
!reapable(&image::Running::Unknown("daemon down".into())),
"and one omh could not ask about is left alone — stopping a live \
session on a guess costs somebody's turn"
);
}
#[test]
fn a_removal_names_the_turn_snapshots_it_takes_without_refusing_over_them() {
let (paths, session, shadow) = a_session_with_two_checkpoints();
shadow
.harvest(
&paths.repo,
&session.worktree,
"omh/s01",
&[],
shadow::Keep::All,
)
.unwrap();
let note = may_remove(&paths, &session, Snapshots::Kept(12), false)
.expect("snapshots alone never stop a removal")
.expect("but they are said");
assert!(
note.contains("12 turn snapshots") && note.contains("log --turns"),
"named, with the way to read them: {note}"
);
assert_eq!(
may_remove(&paths, &session, Snapshots::None, false).unwrap(),
None,
"and a session that has none says nothing about them"
);
let (paths, unkept, _shadow) = a_session_with_two_checkpoints();
let refused = may_remove(&paths, &unkept, Snapshots::Kept(3), false)
.expect_err("unharvested commits still refuse")
.to_string();
assert!(
refused.contains("commit --keep"),
"the refusal is unchanged: {refused}"
);
assert!(
refused.contains("and 3 turn snapshots"),
"and says what else goes: {refused}"
);
assert!(
refused.contains("omh s01 log --turns"),
"with a command for it: {refused}"
);
for line in refused
.lines()
.skip(1)
.map(str::trim)
.filter(|l| !l.is_empty())
{
assert!(
line.starts_with("omh s01 "),
"a line the user cannot paste: {line}"
);
}
}
#[test]
fn a_sync_whose_note_cannot_be_written_still_succeeds_and_says_so() {
let (paths, session, shadow) = a_session_with_two_checkpoints();
let repo_git = |args: &[&str]| {
let out = Command::new("git")
.current_dir(&paths.repo)
.args(args)
.output()
.unwrap();
assert!(out.status.success(), "git {args:?}: {out:?}");
};
repo_git(&["checkout", "-q", "main"]);
std::fs::write(paths.repo.join("from-trunk.rs"), "fn trunk() {}\n").unwrap();
repo_git(&["add", "-A"]);
repo_git(&["commit", "-qm", "trunk moved"]);
std::fs::create_dir(shadow::note_file(&shadow.gitdir)).unwrap();
let synced = sync_session(&paths, &session, "main").expect("the sync itself is fine");
assert_eq!(synced.moved, 1, "the work still arrived");
assert!(
session.worktree.join("from-trunk.rs").exists(),
"and is on disk, which is what a failed command would deny"
);
let why = synced.note.expect("the failure is carried, not swallowed");
assert!(
why.contains("omh-note"),
"naming the file it could not write: {why}"
);
}
#[test]
fn a_sync_brings_trunk_over_and_leaves_the_agents_work_harvestable() {
let (paths, session, shadow) = a_session_with_two_checkpoints();
let repo_git = |args: &[&str]| {
let out = Command::new("git")
.current_dir(&paths.repo)
.args(args)
.output()
.unwrap();
assert!(out.status.success(), "git {args:?}: {out:?}");
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
std::fs::write(session.worktree.join("in-flight.rs"), "fn later() {}\n").unwrap();
let on_session = repo_git(&["rev-parse", "HEAD"]);
repo_git(&["checkout", "-q", "main"]);
std::fs::write(paths.repo.join("from-trunk.rs"), "fn trunk() {}\n").unwrap();
repo_git(&["add", "-A"]);
repo_git(&["commit", "-qm", "trunk moved"]);
let onto = repo_git(&["rev-parse", "HEAD"]);
let _ = on_session;
let synced = sync_session(&paths, &session, "main").unwrap();
assert_eq!(synced.moved, 1, "one commit arrived");
assert!(synced.conflicted.is_empty(), "and it merged cleanly");
assert!(synced.checkpoint, "the uncommitted work was checkpointed");
assert!(
session.worktree.join("from-trunk.rs").exists(),
"trunk's file reached the session"
);
assert_eq!(
repo_git(&["rev-parse", "omh/s01"]),
onto,
"the baseline moved, so `diff` measures the agent's work and not trunk's"
);
let sandbox_log = shadow.checkpoints(&session.worktree).unwrap();
let subjects: Vec<&str> = sandbox_log
.commits
.iter()
.map(|c| c.subject.as_str())
.collect();
assert!(
subjects.iter().any(|s| s.starts_with("base moved to")),
"a commit the agent can read: {subjects:?}"
);
assert!(
subjects.iter().any(|s| s.contains("Before omh brought")),
"and the point it can be undone from: {subjects:?}"
);
let note = std::fs::read_to_string(shadow::note_file(&shadow.gitdir))
.expect("a note was left where the hook reads");
assert!(
note.contains("main moved 1 commit") && note.contains("git show HEAD"),
"what moved, and where to read it: {note}"
);
assert_eq!(
synced.note, None,
"and nothing to report about leaving it: {synced:?}"
);
let landed = shadow
.harvest(
&paths.repo,
&session.worktree,
"omh/s01",
&[],
shadow::Keep::All,
)
.unwrap();
let on_branch = repo_git(&["log", "--format=%s", &format!("{onto}..omh/s01")]);
assert!(landed > 0, "the harvest took something: {on_branch}");
assert!(
on_branch.contains("one") && on_branch.contains("two"),
"the agent's own commits reached the branch: {on_branch}"
);
assert_eq!(
on_branch.matches("base moved to").count(),
0,
"and trunk's changes did not arrive a second time as the agent's: {on_branch}"
);
}
#[test]
fn a_session_holding_unkept_work_is_not_removed_without_being_asked() {
let (paths, session, shadow) = a_session_with_two_checkpoints();
let err = may_remove(&paths, &session, Snapshots::None, false)
.expect_err("two commits are on no branch anywhere");
let said = err.to_string();
assert!(
said.contains("s01 has 2 commits that no branch has"),
"it says how much is at stake, and agrees with itself about the number: {said}"
);
for line in said
.lines()
.skip(1)
.map(str::trim)
.filter(|l| !l.is_empty())
{
assert!(
line.starts_with("omh s01 "),
"a line the user cannot paste: {line}"
);
}
assert!(
said.contains("--keep") && said.contains("commit -m") && said.contains("--force"),
"put it on the branch, take the files as they stand, or mean it: {said}"
);
assert!(
said.contains("omh/s01"),
"and names the branch it would go on: {said}"
);
assert!(
may_remove(&paths, &session, Snapshots::None, true).is_ok(),
"`--force` means it"
);
let head = Command::new("git")
.arg("--git-dir")
.arg(&shadow.gitdir)
.args(["rev-parse", "HEAD"])
.output()
.unwrap();
std::fs::write(
&shadow.landed_record,
String::from_utf8_lossy(&head.stdout).trim(),
)
.unwrap();
assert!(
may_remove(&paths, &session, Snapshots::None, false).is_ok(),
"a session whose work is all on the branch removes quietly"
);
}
#[test]
fn work_the_sandbox_can_still_reach_counts_however_the_agent_left_it() {
let (paths, session, shadow) = a_session_with_two_checkpoints();
let sandbox_git = |args: &[&str]| {
let out = Command::new("git")
.arg("--git-dir")
.arg(&shadow.gitdir)
.arg("--work-tree")
.arg(&session.worktree)
.args(args)
.output()
.unwrap();
assert!(out.status.success(), "{args:?}: {out:?}");
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
let seed = shadow.seed().unwrap();
sandbox_git(&["reset", "-q", "--hard", &seed]);
assert!(
shadow
.checkpoints(&session.worktree)
.unwrap()
.commits
.is_empty(),
"the numbered list cannot see them — that is why this guard reads wider"
);
let err = may_remove(&paths, &session, Snapshots::None, false)
.expect_err("two commits are still in there and on no branch");
assert!(err.to_string().contains("2 commits"), "{err}");
std::fs::write(&shadow.landed_record, "0".repeat(40)).unwrap();
let err = may_remove(&paths, &session, Snapshots::None, false)
.expect_err("a record naming nothing this repository has is not an answer");
assert!(
err.to_string().contains("cannot say what that removes"),
"omh says it cannot tell rather than counting from a point it cannot place: {err}"
);
std::fs::remove_file(&shadow.landed_record).unwrap();
sandbox_git(&["checkout", "-q", "-b", "spike"]);
std::fs::write(session.worktree.join("spike.rs"), "fn spike() {}\n").unwrap();
sandbox_git(&["add", "-A", "."]);
sandbox_git(&["commit", "-q", "--no-verify", "-m", "a spike"]);
sandbox_git(&["checkout", "-q", "-"]);
let err = may_remove(&paths, &session, Snapshots::None, false).expect_err("three now");
assert!(err.to_string().contains("3 commits"), "{err}");
}
#[test]
fn what_the_seed_record_settles_on_its_own() {
let dir = tempfile::tempdir().unwrap();
let shadow = shadow::Shadow::new(dir.path(), "s01");
let missing = || std::io::Error::new(std::io::ErrorKind::NotFound, "no such file");
assert!(
from_the_seed_record(Ok(()), true, &shadow).is_none(),
"a record omh can read settles nothing on its own — the repository decides"
);
assert!(
matches!(
from_the_seed_record(Err(missing()), false, &shadow),
Some(AtStake::Nothing)
),
"no record and no repository: nothing ever ran here"
);
assert!(
matches!(
from_the_seed_record(Err(missing()), true, &shadow),
Some(AtStake::Unknown(_))
),
"a repository with no record of its start is not an empty session"
);
let denied = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
match from_the_seed_record(Err(denied), true, &shadow) {
Some(AtStake::Unknown(why)) => assert!(
why.contains("denied"),
"the reason reaches the user rather than being read as absence: {why}"
),
other => panic!("a record omh could not read is not an empty session: {other:?}"),
}
}
#[test]
fn a_sandbox_omh_cannot_read_is_asked_about_rather_than_assumed_empty() {
let (paths, session, shadow) = a_session_with_two_checkpoints();
std::fs::write(&shadow.landed_record, "").unwrap();
let err = may_remove(&paths, &session, Snapshots::None, false)
.expect_err("omh cannot tell what landed — that is a reason to ask");
assert!(
err.to_string().contains("cannot say what that removes"),
"it says it cannot tell, rather than naming a count it does not have: {err}"
);
assert!(
may_remove(&paths, &session, Snapshots::None, true).is_ok(),
"and `--force` is still the way past, so nobody is trapped"
);
let (paths, session, shadow) = a_session_with_two_checkpoints();
std::fs::remove_file(&shadow.seed_record).unwrap();
assert!(
may_remove(&paths, &session, Snapshots::None, false).is_err(),
"a repository omh cannot place is not an empty session"
);
let (paths, session, shadow) = a_session_with_two_checkpoints();
std::fs::remove_dir_all(&shadow.gitdir).unwrap();
std::fs::remove_file(&shadow.seed_record).unwrap();
assert!(
may_remove(&paths, &session, Snapshots::None, false).is_ok(),
"nothing there is nothing to lose"
);
let never_ran = Session::new(&paths.worktrees().join("s02"), "s02".to_string());
assert!(may_remove(&paths, &never_ran, Snapshots::None, false).is_ok());
}
#[test]
fn one_of_a_thing_is_said_in_the_singular() {
let (paths, session, shadow) = a_session_with_two_checkpoints();
let landed = Command::new("git")
.arg("--git-dir")
.arg(&shadow.gitdir)
.args(["rev-parse", "HEAD"])
.output()
.unwrap();
std::fs::write(
&shadow.landed_record,
String::from_utf8_lossy(&landed.stdout).trim(),
)
.unwrap();
std::fs::write(session.worktree.join("in-flight.rs"), "fn later() {}\n").unwrap();
assert!(
matches!(at_stake(&paths, &session), AtStake::Work(what) if what == "1 uncommitted path"),
"one path, said once"
);
let sandbox_git = |args: &[&str]| {
Command::new("git")
.arg("--git-dir")
.arg(&shadow.gitdir)
.arg("--work-tree")
.arg(&session.worktree)
.args(args)
.output()
.unwrap()
};
sandbox_git(&["add", "-A", "."]);
sandbox_git(&["commit", "-q", "--no-verify", "-m", "one more"]);
assert!(
matches!(at_stake(&paths, &session), AtStake::Work(what) if what == "1 commit"),
"and one commit, said once"
);
}
#[test]
fn a_selection_on_a_git_that_cannot_do_it_says_which_command_still_works() {
let (_paths, session, shadow) = a_session_with_two_checkpoints();
let err = what_to_keep(&shadow, &session, "1", false, false, &|| Ok(false))
.expect_err("this git cannot take a selection");
assert!(
err.to_string().contains("--empty"),
"the refusal names what git is missing: {err}"
);
assert!(
err.to_string().contains("--keep"),
"and what still works without it: {err}"
);
assert!(
what_to_keep(&shadow, &session, "1", false, false, &|| Ok(true)).is_ok(),
"and a git that can, does"
);
let never = || -> Result<bool> { panic!("`--keep` asked git a question it does not need") };
assert!(what_to_keep(&shadow, &session, "", false, false, &never).is_ok());
assert!(what_to_keep(&shadow, &session, "", true, true, &never).is_ok());
let err = what_to_keep(&shadow, &session, "9", false, false, &|| Ok(false))
.expect_err("this git cannot take a selection");
assert!(
err.to_string().contains("--empty"),
"the git is the answer, not the number: {err}"
);
let err = what_to_keep(&shadow, &session, "1", false, false, &|| {
Err(anyhow::anyhow!("no git on PATH"))
})
.expect_err("omh could not tell");
let printed = out::problem(&out::Palette::plain(), &err);
assert!(
printed.contains("no git on PATH"),
"git's own reason reaches the user: {printed}"
);
assert!(
!printed.contains("newer git"),
"and omh does not invent a diagnosis it cannot support: {printed}"
);
}
#[test]
fn host_checks_never_stand_in_for_a_probe_that_did_not_run() {
let sandbox = vec![doctor::Outcome {
name: "rules".into(),
ok: true,
detail: "reads".into(),
}];
let err = every_check(Vec::new()).expect_err("a sandbox that ran nothing is not a pass");
assert!(err.to_string().contains("did not run it"), "{err}");
let both = every_check(sandbox).unwrap();
assert_eq!(
both.first().map(|o| o.name.as_str()),
Some("rules"),
"the sandbox's answers first: {both:?}"
);
assert!(
both.iter().any(|o| o.name == "git on the host"),
"and the host's are appended: {both:?}"
);
}
#[test]
fn what_keep_and_edit_mean_together() {
let (paths, session, shadow) = a_session_with_two_checkpoints();
let keep = |selection: &str, edit: bool, terminal: bool| {
what_to_keep(&shadow, &session, selection, edit, terminal, &|| Ok(true))
};
let _ = &paths;
assert_eq!(
keep("", false, true).unwrap(),
shadow::Keep::All,
"a bare --keep takes everything and opens nothing, terminal or not"
);
assert_eq!(keep("", false, false).unwrap(), shadow::Keep::All);
assert_eq!(
keep("", true, true).unwrap(),
shadow::Keep::Edit,
"--edit is what asks for the list"
);
assert!(
keep("", true, false)
.unwrap_err()
.to_string()
.contains("no terminal"),
"and it needs somewhere to draw"
);
assert!(
keep("1", true, true)
.unwrap_err()
.to_string()
.contains("twice"),
"a selection and --edit name what to take twice"
);
}
#[test]
fn a_selection_naming_a_merge_is_refused_before_anything_moves() {
let (paths, session, shadow) = a_session_with_two_checkpoints();
let sandbox_git = |args: &[&str]| {
let out = Command::new("git")
.arg("--git-dir")
.arg(&shadow.gitdir)
.arg("--work-tree")
.arg(&session.worktree)
.args(args)
.output()
.unwrap();
assert!(out.status.success(), "{args:?}: {out:?}");
};
let seed = shadow.seed().unwrap();
sandbox_git(&["checkout", "-q", "-b", "side", &seed]);
std::fs::write(session.worktree.join("side.rs"), "fn side() {}\n").unwrap();
sandbox_git(&["add", "-A", "."]);
sandbox_git(&["commit", "-q", "--no-verify", "-m", "on the side"]);
sandbox_git(&["checkout", "-q", "-"]);
sandbox_git(&["merge", "-q", "--no-ff", "side", "-m", "Merge the side"]);
let read = shadow.checkpoints(&session.worktree).unwrap();
let merge = read
.commits
.iter()
.find(|c| c.touched.is_none())
.expect("the merge is a checkpoint");
let err = what_to_keep(
&shadow,
&session,
&merge.number.to_string(),
false,
false,
&|| Ok(true),
)
.expect_err("a merge cannot be picked on its own");
assert!(
err.to_string().contains("merge") && !err.to_string().contains("-m option"),
"refused in omh's words, not git's: {err}"
);
assert!(
!paths.repo.join(".git/omh-harvest-s01-scratch").exists(),
"and nothing was built to find that out"
);
}
#[test]
fn a_number_the_session_does_not_have_is_refused_with_the_range() {
let (_paths, session, shadow) = a_session_with_two_checkpoints();
for spec in ["9", "0", "two", "4-2", "1,1"] {
let err = what_to_keep(&shadow, &session, spec, false, false, &|| Ok(true))
.unwrap_err()
.to_string();
assert!(
err.contains("1 to 2") || err.contains("twice") || err.contains("backwards"),
"`{spec}` is refused against the session's own list: {err}"
);
}
}
fn a_session_with_two_checkpoints() -> (Paths, Session, shadow::Shadow) {
let dir = Box::leak(Box::new(tempfile::tempdir().unwrap()));
let paths = Paths {
root: dir.path().join("home"),
repo: dir.path().join("repo"),
};
std::fs::create_dir_all(&paths.repo).unwrap();
for args in [
vec!["init", "-q", "-b", "main"],
vec!["config", "user.email", "t@example.com"],
vec!["config", "user.name", "t"],
vec!["commit", "-q", "--allow-empty", "-m", "root"],
] {
let out = Command::new("git")
.current_dir(&paths.repo)
.args(&args)
.output()
.unwrap();
assert!(out.status.success(), "{args:?}: {out:?}");
}
std::fs::create_dir_all(paths.shadows()).unwrap();
let session = Session::new(&paths.worktrees().join("s01"), "s01".to_string());
session.ensure(&paths.repo, "main").unwrap();
let shadow = shadow::Shadow::new(&paths.shadows(), "s01");
shadow.ensure(&session.worktree, &[]).unwrap();
for name in ["one", "two"] {
std::fs::write(
session.worktree.join(format!("{name}.rs")),
format!("fn {name}() {{}}\n"),
)
.unwrap();
for args in [
vec!["add", "-A", "."],
vec!["commit", "-q", "--no-verify", "-m", name],
] {
let out = Command::new("git")
.arg("--git-dir")
.arg(&shadow.gitdir)
.arg("--work-tree")
.arg(&session.worktree)
.args(&args)
.output()
.unwrap();
assert!(out.status.success(), "{args:?}: {out:?}");
}
}
(paths, session, shadow)
}
#[test]
fn a_selection_naming_work_the_branch_already_has_is_refused() {
let dir = tempfile::tempdir().unwrap();
let paths = Paths {
root: dir.path().join("home"),
repo: dir.path().join("repo"),
};
std::fs::create_dir_all(&paths.repo).unwrap();
for args in [
vec!["init", "-q", "-b", "main"],
vec!["config", "user.email", "t@example.com"],
vec!["config", "user.name", "t"],
vec!["commit", "-q", "--allow-empty", "-m", "root"],
] {
let out = Command::new("git")
.current_dir(&paths.repo)
.args(&args)
.output()
.unwrap();
assert!(out.status.success(), "{args:?}: {out:?}");
}
std::fs::create_dir_all(paths.shadows()).unwrap();
let session = Session::new(&paths.worktrees().join("s01"), "s01".to_string());
session.ensure(&paths.repo, "main").unwrap();
let shadow = shadow::Shadow::new(&paths.shadows(), "s01");
shadow.ensure(&session.worktree, &[]).unwrap();
let mut ids = Vec::new();
for name in ["one", "two"] {
std::fs::write(
session.worktree.join(format!("{name}.rs")),
format!("fn {name}() {{}}\n"),
)
.unwrap();
for args in [
vec!["add", "-A", "."],
vec!["commit", "-q", "--no-verify", "-m", name],
] {
let out = Command::new("git")
.arg("--git-dir")
.arg(&shadow.gitdir)
.arg("--work-tree")
.arg(&session.worktree)
.args(&args)
.output()
.unwrap();
assert!(out.status.success(), "{args:?}: {out:?}");
}
let head = Command::new("git")
.arg("--git-dir")
.arg(&shadow.gitdir)
.args(["rev-parse", "HEAD"])
.output()
.unwrap();
ids.push(String::from_utf8_lossy(&head.stdout).trim().to_string());
}
std::fs::write(&shadow.landed_record, format!("{}\n", ids[0])).unwrap();
let err = what_to_keep(&shadow, &session, "1", false, false, &|| Ok(true))
.expect_err("checkpoint 1 is already on the branch");
assert!(
err.to_string().contains('1') && err.to_string().contains("already"),
"the refusal names the number and says why: {err}"
);
assert!(
what_to_keep(&shadow, &session, "2", false, false, &|| Ok(true)).is_ok(),
"and the one that has not landed is still keepable"
);
}
#[test]
fn a_sandbox_repository_with_no_record_of_its_start_is_not_an_empty_session() {
let dir = tempfile::tempdir().unwrap();
let paths = Paths {
root: dir.path().join("home"),
repo: dir.path().join("repo"),
};
std::fs::create_dir_all(&paths.repo).unwrap();
for args in [
vec!["init", "-q", "-b", "main"],
vec!["config", "user.email", "t@example.com"],
vec!["config", "user.name", "t"],
vec!["commit", "-q", "--allow-empty", "-m", "root"],
] {
Command::new("git")
.current_dir(&paths.repo)
.args(&args)
.output()
.unwrap();
}
std::fs::create_dir_all(paths.shadows()).unwrap();
let session = Session::new(&paths.worktrees().join("s01"), "s01".to_string());
session.ensure(&paths.repo, "main").unwrap();
let log = log_report(&paths, &session, false, &out::Ctx::plain()).unwrap();
assert!(
log.read.commits.is_empty(),
"asking before the agent has run is an ordinary thing to do"
);
let shadow = shadow::Shadow::new(&paths.shadows(), "s01");
shadow.ensure(&session.worktree, &[]).unwrap();
std::fs::remove_file(&shadow.seed_record).unwrap();
let err = log_report(&paths, &session, false, &out::Ctx::plain())
.expect_err("a repository omh cannot place is not an empty session");
assert!(
err.to_string().contains("rm"),
"and the refusal warns about the move that would destroy it: {err}"
);
}
#[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.sections.iter().any(|s| s.name == "git-rules"),
"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"
);
}
}