mod adapter;
mod auth;
mod base;
mod bundled;
mod carry;
mod config;
mod container;
mod detect;
mod doctor;
mod editor;
mod hook;
mod idle;
mod image;
mod mcp;
mod memory;
mod notice;
mod persist;
mod profile;
mod render;
mod rules;
mod runtime;
mod selection;
mod session;
mod settings;
mod ssh;
mod why;
use adapter::Adapter;
use anyhow::Context;
use anyhow::Result;
use clap::{Parser, Subcommand};
use profile::{Paths, Profile};
use session::Session;
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)]
new: bool,
#[arg(long, short = 'a', global = true)]
account: Option<String>,
#[command(subcommand)]
cmd: Cmd,
}
pub const RESERVED: [&str; 18] = [
"init", "doctor", "d", "auth", "ls", "attach", "a", "sessions", "s", "config", "c", "graph",
"why", "memory", "help", "use", "unuse", "repo",
];
#[derive(Subcommand)]
enum Cmd {
Init,
#[command(visible_alias = "d")]
Doctor { harness: Option<String> },
Why {
thing: String,
},
Graph {
session: Option<String>,
#[arg(long)]
stop: bool,
},
Auth {
harness: String,
#[arg(default_value = auth::DEFAULT_ACCOUNT)]
account: String,
},
Ls,
#[command(visible_alias = "a")]
Attach {
editor: Option<String>,
},
#[command(visible_alias = "s")]
Sessions {
#[command(subcommand)]
cmd: SessionsCmd,
},
#[command(visible_alias = "c")]
Config {
#[command(subcommand)]
cmd: Option<ConfigCmd>,
},
Repo {
#[command(subcommand)]
cmd: Option<RepoCmd>,
},
Use {
capability: Option<String>,
name: Option<String>,
#[arg(long)]
all: bool,
},
Unuse { capability: String, name: String },
Memory {
#[command(subcommand)]
cmd: Option<MemoryCmd>,
},
#[command(external_subcommand)]
Run(Vec<String>),
}
#[derive(Subcommand)]
enum McpCmd {
Ls,
Add {
name: String,
command: String,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
#[arg(long = "env", value_parser = parse_env)]
env: Vec<(String, String)>,
},
Rm { name: String },
Import {
harness: String,
#[arg(long)]
file: Option<std::path::PathBuf>,
#[arg(long)]
force: bool,
},
}
#[derive(Subcommand)]
enum SessionsCmd {
Ls,
Rm { session: String },
Down { session: Option<String> },
Diff {
session: Option<String>,
#[arg(long)]
base: Option<String>,
},
Commit {
#[arg(short = 'm', long)]
message: Option<String>,
#[arg(long)]
skip_carried: bool,
},
Push {
name: Option<String>,
#[arg(long)]
pr: bool,
},
}
#[derive(Subcommand)]
enum ConfigCmd {
Set {
key: String,
value: String,
#[arg(long, value_parser = parse_layer, hide = true)]
layer: Option<config::Layer>,
},
Unset {
key: String,
#[arg(long, value_parser = parse_layer, hide = true)]
layer: Option<config::Layer>,
},
Edit {
capability: Option<String>,
name: Option<String>,
#[arg(long, value_parser = parse_layer, hide = true)]
layer: Option<config::Layer>,
},
Mcp {
#[command(subcommand)]
cmd: McpCmd,
},
}
#[derive(Subcommand)]
enum RepoCmd {
Enable { feature: String },
Disable { feature: String },
Set {
key: String,
value: String,
#[arg(long)]
shared: bool,
},
Unset {
key: String,
#[arg(long)]
shared: bool,
},
}
#[derive(Subcommand)]
enum MemoryCmd {
Remember {
#[arg(long)]
expected: String,
#[arg(long)]
observed: String,
#[arg(long)]
evidence: String,
#[arg(long = "answers")]
answers: Vec<String>,
#[arg(long = "relates-to")]
relates_to: Vec<String>,
#[arg(long)]
invalidated_by: Option<String>,
#[arg(long)]
source: Option<String>,
#[arg(long, value_parser = parse_if_exists, default_value = "error")]
if_exists: memory::IfExists,
},
#[command(hide = true)]
Serve {
#[arg(long)]
team: std::path::PathBuf,
#[arg(long)]
local: std::path::PathBuf,
#[arg(long)]
session: Option<String>,
},
Promote {
#[arg(required = true)]
keys: Vec<String>,
},
Stale,
Lint,
Rm {
key: String,
#[arg(long, value_parser = parse_note_layer)]
layer: Option<memory::Layer>,
#[arg(long)]
at: Option<String>,
},
}
fn main() -> Result<()> {
#[cfg(unix)]
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
}
let cli = Cli::parse();
let cwd = std::env::current_dir()?;
match &cli.cmd {
Cmd::Init => init(&cwd),
Cmd::Auth { harness, account } => auth_cmd(&cwd, harness, account),
Cmd::Ls => ls(&cwd),
Cmd::Doctor { harness } => doctor_cmd(&cwd, harness.as_deref(), cli.dry_run),
Cmd::Why { thing } => why_cmd(&cwd, thing),
Cmd::Graph { session, stop } => graph(&cwd, session.as_deref(), *stop),
Cmd::Attach { editor } => attach(&cwd, cli.session.as_deref(), editor.as_deref()),
Cmd::Sessions { cmd } => match cmd {
SessionsCmd::Ls => sessions_ls(&cwd),
SessionsCmd::Rm { session } => rm(&cwd, session),
SessionsCmd::Down { session } => down(&cwd, session.as_deref()),
SessionsCmd::Diff { session, base } => diff(
&cwd,
session.as_deref().or(cli.session.as_deref()),
base.as_deref(),
),
SessionsCmd::Commit {
message,
skip_carried,
} => commit(
&cwd,
cli.session.as_deref(),
message.as_deref(),
*skip_carried,
),
SessionsCmd::Push { name, pr } => {
push(&cwd, cli.session.as_deref(), name.as_deref(), *pr)
}
},
Cmd::Config { cmd } => match cmd {
None => show_config(&cwd),
Some(ConfigCmd::Set { key, value, layer }) => {
set(&cwd, key, value, layer_or(*layer, config::Layer::Personal))
}
Some(ConfigCmd::Unset { key, layer }) => {
unset(&cwd, key, layer_or(*layer, config::Layer::Personal))
}
Some(ConfigCmd::Edit {
capability,
name,
layer,
}) => edit(
&cwd,
capability.as_deref(),
name.as_deref(),
layer_or(*layer, config::Layer::Personal),
),
Some(ConfigCmd::Mcp { cmd }) => mcp(&cwd, cmd, cli.dry_run),
},
Cmd::Repo { cmd } => match cmd {
None => show_repo(&cwd),
Some(RepoCmd::Enable { feature }) => feature_switch(&cwd, feature, true),
Some(RepoCmd::Disable { feature }) => feature_switch(&cwd, feature, false),
Some(RepoCmd::Set { key, value, shared }) => set(&cwd, key, value, repo_layer(*shared)),
Some(RepoCmd::Unset { key, shared }) => unset(&cwd, key, repo_layer(*shared)),
},
Cmd::Use {
capability,
name,
all,
} => use_cmd(&cwd, capability.as_deref(), name.as_deref(), *all),
Cmd::Unuse { capability, name } => unuse_cmd(&cwd, capability, name),
Cmd::Memory { cmd } => match cmd {
None => memory_ls(&cwd),
Some(MemoryCmd::Lint) => memory_lint(&cwd),
Some(MemoryCmd::Stale) => memory_stale(&cwd),
Some(MemoryCmd::Promote { keys }) => memory_promote(&cwd, keys),
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()),
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(),
),
},
Cmd::Run(argv) => run(&cwd, argv, &cli),
}
}
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 session_up(
paths: &Paths,
profile: &Profile,
adapter: &Adapter,
session: &Session,
opts: container::Options,
) -> Result<(Box<dyn runtime::Runtime>, String)> {
let backend = runtime::select(&runtime_preference(paths), &|p| runtime::installed(p))?;
let name = paths.container(&session.id);
if image::container_running(backend.program(), &name) {
return Ok((backend, name));
}
let mut opts = opts;
match memory::deliver::ensure(
backend.program(),
paths,
std::path::Path::new(env!("CARGO_MANIFEST_DIR")),
) {
Ok(bin) => opts.memory_bin = Some(bin),
Err(e) => {
eprintln!("omh: memory server unavailable — {e:#}");
opts.memory_bin = None;
}
}
say_selection(profile, &opts.repo);
let plan = container::plan(paths, profile, adapter, session, &[], opts)?;
plan.validate(&backend.caps())?;
say_rules(&plan);
image::ensure(backend.program(), adapter)?;
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>) -> 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)?;
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)?;
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)?;
}
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,
},
)?;
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));
match ed {
Some(ed) if runtime::installed(&ed.bin) => {
let cmd = ed.command(&alias);
println!("omh: opening {} in {}", ssh::url(&alias), ed.name);
let ok = Command::new(&cmd[0])
.args(&cmd[1..])
.status()
.map(|s| s.success());
if !matches!(ok, Ok(true)) {
eprintln!("omh: {} did not open the session", ed.name);
println!("\n {}", ssh::url(&alias));
println!(" ssh {alias}");
}
}
other => {
if let Some(ed) = other {
println!("omh: `{}` is not installed on this machine\n", ed.bin);
} else if let Some(w) = &wanted {
println!("omh: no editor named `{w}` — see `omh ls`\n");
}
println!("session {} is up\n", session.id);
println!(" {}", ssh::url(&alias));
println!(" ssh {alias}\n");
for e in editor::Editor::load_dir(&paths.editors())? {
println!(" {:<8} {}", e.name, e.command(&alias).join(" "));
}
}
}
Ok(())
}
fn graph(cwd: &std::path::Path, _id: Option<&str>, stop: bool) -> Result<()> {
let paths = Paths::discover(cwd)?;
let backend = runtime::select(&runtime_preference(&paths), &|p| runtime::installed(p))?;
let container = base::ui_container(&paths.repo_name());
if stop {
if !image::container_running(backend.program(), &container) {
println!("the graph is not running");
return Ok(());
}
image::container_remove(backend.program(), &container)?;
println!("omh: graph stopped; sessions keep running");
return Ok(());
}
let port = base::ui_port(&container);
if !image::container_running(backend.program(), &container) {
let _ = image::container_remove(backend.program(), &container);
let names: Vec<String> = Adapter::load_dir(&paths.adapters())?
.into_iter()
.map(|a| a.name)
.collect();
let harness = detect::preferred_harness(&names, &|h| runtime::installed(h))
.context("no adapters installed — run `omh init`")?;
let adapter = Adapter::find(&paths.adapters(), &harness)?;
image::ensure(backend.program(), &adapter)?;
let out = Command::new(backend.program())
.args(base::ui_run_args(
&image::tag_for(&adapter),
&container,
&paths.cache_volume(),
port,
))
.output()?;
if !out.status.success() {
anyhow::bail!(
"could not start the graph: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
std::thread::sleep(std::time::Duration::from_millis(1500));
}
let url = format!("http://127.0.0.1:{port}");
println!("omh: graph at {url}");
println!(" every session's graph for this repo, in one place");
println!(" stop with: omh graph --stop");
let _ = Command::new(if cfg!(target_os = "macos") {
"open"
} else {
"xdg-open"
})
.arg(&url)
.status();
Ok(())
}
fn reap_idle(paths: &Paths, launching: &str) {
let Some(raw) = policy_value(paths, "idle_timeout") else {
return;
};
let Some(timeout) = idle::parse_duration(&raw) else {
eprintln!("omh: ignoring idle_timeout `{raw}` — expected a duration like 30m, 2h, 90s");
return;
};
let Ok(backend) = runtime::select(&runtime_preference(paths), &|p| runtime::installed(p))
else {
return;
};
let running: Vec<(String, Option<std::time::SystemTime>)> = session::list(&paths.worktrees())
.into_iter()
.filter(|id| image::container_running(backend.program(), &paths.container(id)))
.map(|id| {
let last = idle::last_used(&paths.runs(), &id);
(id, last)
})
.collect();
for id in idle::expired(&running, timeout, std::time::SystemTime::now(), launching) {
match image::container_remove(backend.program(), &paths.container(&id)) {
Ok(()) => {
eprintln!("omh: stopped {id} — idle over {raw} (worktree and branch survive)")
}
Err(e) => eprintln!("omh: could not stop idle session {id}: {e}"),
}
}
}
fn down(cwd: &std::path::Path, id: Option<&str>) -> 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()),
};
for i in &ids {
let name = paths.container(i);
if image::container_running(backend.program(), &name) {
match image::container_remove(backend.program(), &name) {
Ok(()) => println!("stopped {i}; worktree and branch survive"),
Err(e) => eprintln!("omh: {i} is still running: {e}"),
}
} else {
println!("{i} was not running");
}
}
Ok(())
}
fn doctor_cmd(cwd: &std::path::Path, harness: Option<&str>, dry_run: bool) -> 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 checks = doctor::checks(&profile, &adapter, &own, &repo)?;
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() {
println!("nothing to check: the profile is empty");
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,
};
if let Some(account_dir) = &account {
auth::prepare(&adapter, account_dir, auth::GUEST_HOME)?;
}
say_selection(&profile, &opts.repo);
let mut plan = container::plan(&paths, &profile, &adapter, &session, &[], opts)?;
say_rules(&plan);
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 {
println!("{}", doctor::probe_script(&checks));
return Ok(());
}
image::ensure(backend.program(), &adapter)?;
image::ensure_network(backend.program(), &plan.network)?;
match &account {
Some(a) => println!(
"omh doctor: {name} (in {}, account {})\n",
image::tag_for(&adapter),
a.file_name().unwrap_or_default().to_string_lossy()
),
None => println!(
"omh doctor: {name} (in {}, no account — credentials unchecked)\n",
image::tag_for(&adapter)
),
}
let out = Command::new(backend.program())
.args(backend.args(&plan))
.output()?;
let outcomes = doctor::parse(&String::from_utf8_lossy(&out.stdout));
let _ = session.remove(&paths.repo, "");
for o in &outcomes {
println!(
" {} {:<10} {}",
if o.ok { "\u{2713}" } else { "\u{2717}" },
o.name,
o.detail
);
}
if outcomes.is_empty() {
anyhow::bail!(
"the probe produced no output — the sandbox did not run it\n{}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
if !doctor::passed(&outcomes) {
anyhow::bail!(
"{} of {} checks failed",
outcomes.iter().filter(|o| !o.ok).count(),
outcomes.len()
);
}
println!(
"\n all {} checks passed — {name}'s adapter paths are verified",
outcomes.len()
);
Ok(())
}
fn sessions_ls(cwd: &std::path::Path) -> Result<()> {
let paths = Paths::discover(cwd)?;
let backend = runtime::select(&runtime_preference(&paths), &|p| runtime::installed(p)).ok();
let base = session::default_branch(&paths.repo);
let sessions = session::list(&paths.worktrees());
if sessions.is_empty() {
println!("no sessions");
}
for id in sessions {
let sess = Session::new(&paths.worktrees(), id.clone());
let up = backend
.as_ref()
.map(|b| image::container_running(b.program(), &paths.container(&id)))
.unwrap_or(false);
let drift = match sess.behind(&paths.repo, &base) {
0 => String::new(),
n => format!(" ({n} behind {base})"),
};
println!(
" {id:<8} {:<14} {:<9} {:<20}{drift}",
sess.label(),
if up { "up" } else { "stopped" },
work_state(&sess, &paths.repo, &base),
);
}
Ok(())
}
fn work_state(session: &Session, repo: &std::path::Path, base: &str) -> String {
let (uncommitted, unpushed) = match (session.uncommitted(), session.unpushed()) {
(Ok(uncommitted), Ok(unpushed)) => (uncommitted, unpushed),
_ => return "?".into(),
};
if let n @ 1.. = uncommitted {
return format!("{n} uncommitted");
}
match unpushed {
Some(n @ 1..) => format!("{n} to push"),
Some(_) => match session.published_as() {
Ok(Some(target)) => format!("→ {target}"),
Ok(None) => String::new(),
Err(_) => "?".into(),
},
None => match session.commits(repo, base) {
0 => String::new(),
n => format!("{n} to push"),
},
}
}
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>,
) -> 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(),
};
}
match memory::remember(&paths, &input, if_exists)? {
memory::Wrote::Created(path) => println!("recorded {}", path.display()),
memory::Wrote::Replaced(path) => {
println!(
"replaced {} — the note that was there is gone",
path.display()
)
}
memory::Wrote::Skipped(key) => println!("`{key}` is already recorded; left alone"),
}
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(&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) -> Result<()> {
use memory::expiry::Verdict;
let paths = Paths::discover(cwd)?;
let judged = memory::expiry::judge(&paths, &memory::load(&paths)?)?;
fn heading(verdict: &Verdict) -> Option<&'static str> {
match verdict {
Verdict::Stale { .. } => Some("stale"),
Verdict::Unknown { .. } => Some("omh cannot tell"),
Verdict::NoTrigger => Some("no expiry — carries only its date"),
Verdict::Fresh => None,
}
}
let mut printed = false;
for group in [
"stale",
"omh cannot tell",
"no expiry — carries only its date",
] {
let members: Vec<&memory::expiry::Judged> = judged
.iter()
.filter(|j| heading(&j.verdict) == Some(group))
.collect();
if members.is_empty() {
continue;
}
if printed {
println!();
}
printed = true;
println!("{group}:");
for j in members {
print!(" {:<44} {:<5} {}", j.key, j.layer.to_string(), j.recorded);
match &j.verdict {
Verdict::Stale { because } | Verdict::Unknown { because } => {
println!(" — {because}")
}
Verdict::NoTrigger | Verdict::Fresh => println!(),
}
}
}
let count = |f: fn(&Verdict) -> bool| judged.iter().filter(|j| f(&j.verdict)).count();
let fresh = count(|v| matches!(v, Verdict::Fresh));
let stale = count(|v| matches!(v, Verdict::Stale { .. }));
let unknown = count(|v| matches!(v, Verdict::Unknown { .. }));
if !printed && fresh == 0 {
println!("no notes yet");
} else if fresh > 0 {
println!("\n{fresh} still current");
}
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]) -> 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 {
eprintln!("omh: {}", b.say());
}
anyhow::bail!("promoted nothing");
}
};
memory::promote::apply(&steps)?;
print!("{}", memory::promote::report(&steps, &paths));
Ok(())
}
fn memory_ls(cwd: &std::path::Path) -> Result<()> {
let paths = Paths::discover(cwd)?;
let notes = memory::load(&paths)?;
if notes.is_empty() {
println!("no notes yet — the store fills as work surprises the agent");
return Ok(());
}
print!("{}", memory::render_list(¬es));
Ok(())
}
fn memory_lint(cwd: &std::path::Path) -> Result<()> {
let paths = Paths::discover(cwd)?;
let found = memory::lint(&paths)?;
if found.is_empty() {
println!("no violations");
return Ok(());
}
for v in &found {
let mark = match v.rule.severity() {
memory::Severity::Refused => "refused",
memory::Severity::Warning => "warning",
};
println!("{mark:<8} {:<6} {}", v.layer.to_string(), v.detail);
}
println!();
for (rule, count) in memory::tally(&found) {
println!(" {count:>3} {rule:?}");
}
let refused = memory::refused(&found);
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>,
) -> Result<()> {
let paths = Paths::discover(cwd)?;
let removed = memory::remove(&paths, layer, key, at)?;
println!("removed {key} ({})", removed.layer);
if removed.layer.is_committed() {
println!(" it was committed — teammates keep it until you commit the deletion");
}
if !removed.inbound.is_empty() {
println!(
" still linked from {} — those links now dangle, and `omh memory lint` lists them",
removed.inbound.join(", ")
);
}
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) -> Result<()> {
let paths = Paths::discover(cwd)?;
match cmd {
McpCmd::Ls => show_servers(cwd),
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)?;
println!("wrote → {}", w.path.display());
if !env.is_empty() {
println!(
"note: this env applies in every repo. For one repo only, \
put [mcp.{name}.env] in .omh/{}",
settings::LOCAL
);
}
Ok(())
}
McpCmd::Rm { name } => {
if config::mcp_remove(&paths, name)? {
println!("removed {name} from your catalogue");
} else {
println!("{name} is not in your catalogue");
}
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 report = config::mcp_import(&paths, incoming, *force, dry_run)?;
println!("import from {harness} ({})", source.display());
for name in &report.added {
println!(" + {name}");
}
for name in &report.unchanged {
println!(" = {name} (already identical)");
}
for name in &report.conflicts {
println!(" ! {name} (differs — keeping yours; --force to overwrite)");
}
if report.added.is_empty() && report.conflicts.is_empty() && report.unchanged.is_empty()
{
println!(" (no servers found)");
}
if dry_run {
println!("\n--dry-run: nothing written");
} else if !report.added.is_empty() {
println!("\nwrote → {}", config::mcp_path(&paths).display());
}
Ok(())
}
}
}
fn repo_has_selection(paths: &Paths) -> Result<bool> {
config::declares(paths, config::Layer::Shared, config::USE)
}
fn show_servers(cwd: &std::path::Path) -> Result<()> {
let paths = Paths::discover(cwd)?;
let servers = config::servers(&paths)?;
println!("mcp:");
if servers.is_empty() {
println!(" (nothing set)");
}
for s in servers {
println!(" {:<16} {:<28} ← {}", s.key, s.value, s.layer.whose());
}
Ok(())
}
fn use_cmd(
cwd: &std::path::Path,
capability: Option<&str>,
name: Option<&str>,
all: bool,
) -> 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)?;
for w in write_lists(&paths, &lists)? {
println!("resynced to your catalogue — wrote → {}", w.path.display());
}
for (cap, names) in &lists {
println!(" {:<11} {}", cap.to_string(), names.len());
}
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 {
println!("{cap}/{name} is already used here");
return Ok(());
}
if !already {
names.push(name.to_string());
}
let written = write_lists(
&paths,
&std::collections::BTreeMap::from([(cap, names.clone())]),
)?;
if was_open {
println!(
"{cap} was following your whole catalogue; wrote its {} entries as the list",
names.len()
);
}
for w in written {
println!("using {cap}/{name} — wrote → {}", w.path.display());
}
Ok(())
}
fn unuse_cmd(cwd: &std::path::Path, key: &str, name: &str) -> 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);
if was_open {
println!(
"{cap} was following your whole catalogue; wrote its remaining {} entries as the list",
names.len()
);
}
for w in write_lists(&paths, &std::collections::BTreeMap::from([(cap, names)]))? {
println!(
"no longer using {cap}/{name} — wrote → {}",
w.path.display()
);
}
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.dedup_by(|a, b| a.path == b.path);
Ok(out)
}
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 catalogue_names(paths: &Paths, cap: adapter::Capability) -> Result<Vec<String>> {
let manifest = base::Manifest::load_dir(&paths.base())?;
let owned = manifest.owns();
Ok(Profile::resolve(paths)
.entries(cap)?
.into_iter()
.filter(|n| !owned.get(&cap).is_some_and(|o| o.contains_key(n)))
.collect())
}
fn show_config(cwd: &std::path::Path) -> Result<()> {
let paths = Paths::discover(cwd)?;
let profile = Profile::resolve(&paths);
println!(
"your defaults {}",
config::Layer::Personal.file(&paths).display()
);
let yours: Vec<config::Setting> = config::policy(&paths)?
.into_iter()
.filter(|s| s.layer == config::Layer::Personal)
.collect();
if yours.is_empty() {
println!(" (nothing set)");
}
for s in yours {
println!(" {:<16} {}", s.key, s.value);
}
println!("\nyour catalogue {}", paths.root.display());
for cap in adapter::Capability::ALL {
let entries = profile.entries(cap)?;
println!(
" {:<11} {:>2} {}",
cap.to_string(),
entries.len(),
entries.join(", ")
);
}
Ok(())
}
fn show_repo(cwd: &std::path::Path) -> 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)?;
println!("this repo {}", paths.repo.join(".omh").display());
println!("\nsettings");
let settings = config::policy(&paths)?;
if settings.is_empty() {
println!(" (nothing set)");
}
for s in settings {
let shadowed = if s.shadows.is_empty() {
String::new()
} else {
let names: Vec<_> = s.shadows.iter().map(|l| l.to_string()).collect();
format!(" (overrides {})", names.join(", "))
};
println!(" {:<16} {:<24} ← {}{shadowed}", s.key, s.value, s.layer);
}
println!("\nomh's features");
let mut features: Vec<&str> = manifest
.entries
.iter()
.map(|e| e.feature.as_str())
.collect();
features.sort();
features.dedup();
for feature in features {
let state = if policy.off.contains(feature) {
"off here"
} else {
"on"
};
println!(" {feature:<16} {state}");
}
println!("\nusing");
for cap in adapter::Capability::ALL {
let entries = profile.entries(cap)?;
let unselected = policy.selection.unselected(cap, &entries);
let summary = match policy.selection.order(cap) {
None => "everything".to_string(),
Some(order) => {
let taken: Vec<&str> = order
.iter()
.filter(|n| entries.iter().any(|e| e == *n))
.map(String::as_str)
.collect();
if taken.is_empty() {
"nothing".to_string()
} else {
taken.join(", ")
}
}
};
let note = if unselected.is_empty() {
String::new()
} else {
format!(
" ({} not selected: {})",
unselected.len(),
unselected.join(", ")
)
};
println!(" {:<11} {summary}{note}", cap.to_string());
}
for line in notice::selection(&profile, &policy.selection)? {
println!("\n{line}");
}
Ok(())
}
fn layer_or(named: Option<config::Layer>, default: config::Layer) -> 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",
};
eprintln!(
"omh: --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) -> Result<()> {
let paths = Paths::discover(cwd)?;
let w = config::set(&paths, key, value, layer)?;
println!("wrote → {}", w.path.display());
if w.committed {
println!(
"warning: the {} layer is COMMITTED — never put a secret here",
w.layer
);
}
Ok(())
}
fn unset(cwd: &std::path::Path, key: &str, layer: config::Layer) -> Result<()> {
let paths = Paths::discover(cwd)?;
if config::unset(&paths, key, layer)? {
println!("removed {key} from the {layer} layer");
} else {
println!("{key} was not set in the {layer} layer");
}
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) -> 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(", ")
);
}
for layer in config::declaring(&paths, config::OMH, feature)? {
let w = config::write_feature(&paths, layer, feature, on)?;
println!(
"{feature} is {} here — wrote → {}",
if on { "on" } else { "off" },
w.path.display()
);
}
if !on {
println!("nothing was uninstalled; the next repo gets it back");
}
Ok(())
}
fn say_rules(plan: &container::Plan) {
for notice in plan.rules.notices() {
eprintln!("omh: {notice}");
}
}
fn say_hooks(paths: &Paths) -> Option<notice::Record> {
match notice::hooks(paths, &detect::stacks(&paths.repo)) {
Ok((notices, record)) => {
for notice in notices {
eprintln!("omh: {notice}");
}
Some(record)
}
Err(e) => {
eprintln!("omh: could not check this repo's hooks — {e:#}");
None
}
}
}
fn say_selection(profile: &Profile, repo: &settings::RepoPolicy) {
match notice::selection(profile, &repo.selection) {
Ok(notices) => {
for notice in notices {
eprintln!("omh: {notice}");
}
}
Err(e) => eprintln!("omh: could not check what this repo uses — {e:#}"),
}
}
fn remember_hooks(record: Option<notice::Record>) {
if let Some(record) = record {
if let Err(e) = record.commit() {
eprintln!("omh: this repo's hooks were not recorded — {e:#}");
}
}
}
fn carry_in(paths: &Paths, session: &Session) -> 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 => eprintln!("omh: carried {}", item.path),
carry::Action::Refreshed => eprintln!("omh: refreshed {}", item.path),
carry::Action::AlreadyTracked => eprintln!(
"omh: warning: carry_in lists {} — git already tracks it, so the worktree\n\
\x20 has it already. Not carried; drop it with `omh repo set carry_in`.",
item.path
),
carry::Action::Missing => {
eprintln!(
"omh: warning: carry_in lists {} — not in this checkout",
item.path
)
}
carry::Action::Unchanged => {}
}
}
Ok(())
}
fn run(cwd: &std::path::Path, argv: &[String], cli: &Cli) -> 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 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,
};
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)?;
reap_idle(&paths, &session.id);
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);
say_selection(&profile, &opts.repo);
let hooks_seen = say_hooks(&paths);
let status_line = match plan.degradation() {
Some(d) => format!("omh: {} on {} — {d}", adapter.name, session.label()),
None => format!("omh: {} on {}", adapter.name, session.label()),
};
if cli.dry_run {
println!("{status_line}");
println!("worktree {}", session.worktree.display());
println!(
"\n{} {}",
backend.program(),
backend.args(&plan).join(" \\\n ")
);
return Ok(());
}
let (backend, name) = session_up(
&paths,
&profile,
&adapter,
&session,
container::Options {
tty: false,
..opts.clone()
},
)?;
remember_hooks(hooks_seen);
eprintln!("{status_line}");
let status = Command::new(backend.program())
.args(backend.exec_args(&name, &plan.argv, true))
.status()?;
eprintln!("\nomh: review 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) -> 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();
for s in detect::stacks(&paths.repo) {
let from = format!("{}, detected from {}", s.name, s.marker);
for (suffix, command) in [("test", s.test), ("format", s.format)] {
derived.insert(
format!("{}-{suffix}", s.name),
why::Derived {
from: from.clone(),
command: command.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,
};
print!(
"{}",
why::render_with_source(&catalog, &catalog.why(thing), &version, &source)
);
Ok(())
}
#[allow(dead_code)]
fn init(cwd: &std::path::Path) -> Result<()> {
let paths = Paths::discover(cwd)?;
let adapters = install_bundled_adapters(&paths)?;
let editors = install_bundled(&paths.editors(), bundled::Shipped::Editors)?;
install_bundled(&paths.base(), bundled::Shipped::Base)?;
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 stacks = detect::stacks(&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",
)?;
for stack in &stacks {
for (name, body) in stack_hooks(stack) {
write_if_absent(&repo_omh.join("hooks").join(name), &body)?;
}
}
if !repo_has_selection(&paths)? {
let lists = catalogue_lists(&paths)?;
config::write_selection(&paths, config::Layer::Shared, &lists)?;
}
let gitignore = paths.repo.join(".omh/.gitignore");
ensure_line(&gitignore, settings::LOCAL)?;
println!("omh init — decided, asked nothing\n");
println!(" harnesses {} ({})", adapters.len(), adapters.join(", "));
println!(" editors {} ({})", editors.len(), editors.join(", "));
match &harness {
Some(h) => println!(
" harness {h}{}",
if runtime::installed(h) {
" (found on your host)"
} else {
" (default; nothing detected on host)"
}
),
None => println!(" harness none — no adapters available"),
}
if stacks.is_empty() {
println!(
" stack none detected — write your test and format hooks into \
.omh/hooks/"
);
} else {
for s in &stacks {
println!(
" stack {} (from {}) → test `{}`, format `{}`",
s.name, s.marker, s.test, s.format
);
}
}
match seed_store(&paths) {
Ok(report) => println!(" memory {report}"),
Err(e) => println!(" memory not seeded: {e:#}"),
}
if stacks.len() > 1 {
println!(
"\n ! {} stacks detected; hooks were written for all of them.\n \
drop the ones you do not want: .omh/hooks/",
stacks.len()
);
}
println!("\n catalogue {}", paths.root.display());
println!(" this repo {} (committed)", repo_omh.display());
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)) {
println!(" image {} (already built)", image::tag_for(&adapter));
} else {
println!(
"\n building {} — first run only\n",
image::tag_for(&adapter)
);
image::ensure(backend.program(), &adapter)?;
println!("\n image {}", image::tag_for(&adapter));
}
}
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(_) => println!(
" graph indexing in background → {}",
paths.cache_volume()
),
Err(e) => println!(" graph could not start indexing: {e}"),
}
}
println!("\n base set ({})", manifest.version);
for (name, why) in manifest.rationale() {
println!(" {name:<10} {why}");
}
println!("\n omh why <name> what it costs, what was considered instead, how to remove it");
println!("\nnot yet done: recall, cost accounting.");
println!("next: omh {}", harness.as_deref().unwrap_or("config"));
Ok(())
}
fn install_bundled_adapters(paths: &Paths) -> Result<Vec<String>> {
install_bundled(&paths.adapters(), bundled::Shipped::Adapters)?;
Ok(Adapter::load_dir(&paths.adapters())?
.into_iter()
.map(|a| a.name)
.collect())
}
fn install_bundled(dest: &std::path::Path, kind: bundled::Shipped) -> 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_extension("toml.yours");
std::fs::write(&backup, &existing)
.with_context(|| format!("saving your {name} as {}", backup.display()))?;
eprintln!(
" 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 stack_hooks(stack: &detect::Stack) -> [(String, String); 2] {
let [test, format] = notice::stack_hook_names(stack);
[
(
format!("{test}.json"),
format!("{{ \"on\": \"turn-end\", \"run\": \"{}\" }}\n", stack.test),
),
(
format!("{format}.json"),
format!(
"{{ \"on\": \"after-tool\", \"tools\": [\"edit\"], \"run\": \"{}\" }}\n",
stack.format
),
),
]
}
fn auth_cmd(cwd: &std::path::Path, harness: &str, account: &str) -> 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,
},
)?;
plan.validate(&backend.caps())?;
image::ensure_network(backend.program(), &plan.network)?;
println!(
"omh auth: logging {harness} in as `{account}`{}",
if already { " (re-authenticating)" } else { "" }
);
println!(" credentials → {}", account_dir.display());
if let Some(hint) = &adapter.login {
println!(" next → {hint}");
}
println!();
let status = Command::new(backend.program())
.args(backend.args(&plan))
.status()?;
if let Err(e) = session.remove(&paths.repo, "") {
eprintln!("omh: warning: 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")))?;
println!("\nomh: `{account}` captured for {harness}");
let all = auth::accounts(&paths, &adapter);
if all.len() > 1 {
println!(" accounts: {}", all.join(", "));
println!(" choose per project with `omh repo set account <name>`");
}
Ok(())
}
fn ls(cwd: &std::path::Path) -> Result<()> {
let paths = Paths::discover(cwd)?;
println!("harnesses:");
let adapters = Adapter::load_dir(&paths.adapters())?;
if adapters.is_empty() {
println!(" (none — add {}/<name>.toml)", paths.adapters().display());
}
for a in &adapters {
let accounts = auth::accounts(&paths, a);
let creds = if accounts.is_empty() {
"not authed".to_string()
} else {
accounts.join(", ")
};
println!(" {:<10} {}", a.name, creds);
}
let editors = editor::Editor::load_dir(&paths.editors())?;
if !editors.is_empty() {
println!("\neditors:");
for e in &editors {
let state = if runtime::installed(&e.bin) {
"installed"
} else {
"not installed"
};
println!(" {:<10} {state}", e.name);
}
}
println!("\nsessions:");
let sessions = session::list(&paths.worktrees());
if sessions.is_empty() {
println!(" (none)");
}
let base = session::default_branch(&paths.repo);
for id in sessions {
let sess = Session::new(&paths.worktrees(), id.clone());
let drift = match sess.behind(&paths.repo, &base) {
0 => String::new(),
n => format!(" ({n} behind {base})"),
};
println!(" {id:<10} {}{drift}", sess.label());
}
Ok(())
}
fn diff(cwd: &std::path::Path, id: Option<&str>, base: Option<&str>) -> Result<()> {
let paths = Paths::discover(cwd)?;
let session = existing_session(&paths, id)?;
let base = base
.map(str::to_string)
.unwrap_or_else(|| session::default_branch(&paths.repo));
let out = session.diff(&paths.repo, &base)?;
if out.trim().is_empty() {
println!("no changes on {} (against {base})", session.label());
} else {
print!("{out}");
}
Ok(())
}
fn existing_session(paths: &Paths, explicit: Option<&str>) -> Result<Session> {
let id = match explicit {
Some(id) => {
session::validate_id(id)?;
id.to_string()
}
None => session::current(&paths.worktrees())
.context("no sessions yet — start one with `omh claude`")?,
};
let session = Session::new(&paths.worktrees(), id);
anyhow::ensure!(
session.worktree.exists(),
"no session {} — `omh s ls` lists them",
session.id
);
Ok(session)
}
fn commit(
cwd: &std::path::Path,
id: Option<&str>,
message: Option<&str>,
skip_carried: bool,
) -> Result<()> {
let paths = Paths::discover(cwd)?;
let session = existing_session(&paths, id)?;
let carried = config::policy_list(&paths, "carry_in");
let policy = if skip_carried {
session::Carried::skipping(&carried)
} else {
session::Carried::refusing(&carried)
};
session.commit(message, policy)?;
let base = session::default_branch(&paths.repo);
let n = session.commits(&paths.repo, &base);
let s = if n == 1 { "commit" } else { "commits" };
println!("committed to {} ({n} {s} on the branch)", session.label());
Ok(())
}
fn push(cwd: &std::path::Path, id: Option<&str>, name: Option<&str>, pr: bool) -> Result<()> {
let paths = Paths::discover(cwd)?;
let session = existing_session(&paths, id)?;
let target = session.push(name)?;
println!(" {} → origin/{target}", session.label());
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) -> Result<()> {
session::validate_id(id)?;
let paths = Paths::discover(cwd)?;
let session = Session::new(&paths.worktrees(), id.to_string());
if let Ok(backend) = runtime::select(&runtime_preference(&paths), &|p| runtime::installed(p)) {
let name = paths.container(id);
if image::container_running(backend.program(), &name) {
let project = base::project_name(&paths.repo_name(), id);
let _ = Command::new(backend.program())
.args(backend.exec_args(&name, &base::drop_graph_command(&project), false))
.output();
}
}
let base = session::default_branch(&paths.repo);
match session.remove(&paths.repo, &base)? {
session::Removed::BranchKept => {
let n = session.commits(&paths.repo, &base);
let s = if n == 1 { "commit" } else { "commits" };
println!("removed session {id}; branch omh/{id} kept ({n} {s} to review)");
println!(" review with git log {base}..omh/{id}");
println!(" discard with git branch -D omh/{id}");
}
session::Removed::BranchDropped => {
println!("removed session {id}; branch omh/{id} dropped (no commits)");
}
session::Removed::NoBranch => println!("removed session {id}"),
}
if let Ok(notes) = memory::load(&paths) {
if let Some(line) = memory::session_nudge(¬es, id) {
println!("{line}");
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
const BUNDLED_ADAPTERS: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/adapters");
const BUNDLED_EDITORS: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/editors");
#[test]
fn resolved_reads_the_manifest_and_this_repos_settings() {
let dir = tempfile::tempdir().unwrap();
let paths = Paths {
root: dir.path().join("home"),
repo: dir.path().join("repo"),
};
let write = |p: std::path::PathBuf, body: &str| {
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, body).unwrap();
};
install_bundled(&paths.base(), bundled::Shipped::Base).unwrap();
write(
paths.root.join("mcp.json"),
r#"{"mcpServers":{"codegraph":{"command":"c"},"memory":{"command":"omh"}}}"#,
);
let (own, _) = resolved(&paths).unwrap();
assert!(
!own.hooks.is_empty() && !own.sections.is_empty(),
"a launch must be given what the manifest ships"
);
assert!(
own.hooks.iter().any(|h| h.name.starts_with("graph-")),
"with every feature on, the graph hooks are what `[omh]` below removes: {:?}",
own.hooks.iter().map(|h| h.name).collect::<Vec<_>>()
);
write(
paths.repo.join(".omh/settings.toml"),
"[omh]\ncodegraph = false\n\n[mcp.memory.env]\nOMH_TEST = \"seen\"\n",
);
let (off, policy) = resolved(&paths).unwrap();
assert!(
!off.hooks.iter().any(|h| h.name.starts_with("graph-")),
"and `[omh]` in this repo has to reach it: {:?}",
off.hooks.iter().map(|h| h.name).collect::<Vec<_>>()
);
assert!(
off.hooks.iter().any(|h| h.name == "git-unavailable"),
"without taking a different feature with it"
);
assert_eq!(
policy.mcp_env["memory"]["OMH_TEST"], "seen",
"a per-repo MCP environment has to reach the plan too"
);
assert!(
policy.disabled_servers.contains("codegraph"),
"the feature's server travels with the feature: {:?}",
policy.disabled_servers
);
}
#[test]
fn a_detected_stack_gets_a_hook_that_runs_its_commands() {
for stack in detect::KNOWN {
let hooks = stack_hooks(&stack);
let by = |suffix: &str| {
hooks
.iter()
.find(|(name, _)| name.ends_with(suffix))
.map(|(_, body)| body.clone())
.unwrap_or_else(|| panic!("{} has no {suffix}", stack.name))
};
let test = by("-test.json");
assert!(test.contains(stack.test), "must run the tests: {test}");
assert!(test.contains("\"turn-end\""), "at turn end: {test}");
let format = by("-format.json");
assert!(
format.contains(stack.format),
"must format the code: {format}"
);
assert!(
format.contains("\"edit\""),
"when a file is written: {format}"
);
for (name, body) in hooks {
crate::hook::Hook::parse(&body, &name).unwrap_or_else(|e| {
panic!(
"{} seeds a file omh cannot read: {e:#} in {body}",
stack.name
)
});
}
}
}
#[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).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).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 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).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).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");
}
}
}
}