mod adapter;
mod auth;
mod base;
mod bundled;
mod carry;
mod config;
mod container;
mod detect;
mod doctor;
mod editor;
mod idle;
mod image;
mod mcp;
mod memory;
mod persist;
mod profile;
mod render;
mod runtime;
mod session;
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; 15] = [
"init", "doctor", "d", "auth", "ls", "attach", "a", "sessions", "s", "config", "c", "graph",
"why", "memory", "help",
];
#[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>,
},
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)>,
#[arg(long, value_parser = parse_layer)]
layer: Option<config::Layer>,
},
Rm {
name: String,
#[arg(long, value_parser = parse_layer)]
layer: Option<config::Layer>,
},
Import {
harness: String,
#[arg(long)]
file: Option<std::path::PathBuf>,
#[arg(long)]
force: bool,
#[arg(long, value_parser = parse_layer)]
layer: Option<config::Layer>,
},
}
#[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)]
layer: Option<config::Layer>,
},
Unset {
key: String,
#[arg(long, value_parser = parse_layer)]
layer: Option<config::Layer>,
},
Edit {
#[arg(long, value_parser = parse_layer)]
layer: Option<config::Layer>,
},
Mcp {
#[command(subcommand)]
cmd: McpCmd,
},
}
#[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, None),
Some(ConfigCmd::Set { key, value, layer }) => set(&cwd, key, value, *layer),
Some(ConfigCmd::Unset { key, layer }) => unset(&cwd, key, *layer),
Some(ConfigCmd::Edit { layer }) => edit(&cwd, *layer),
Some(ConfigCmd::Mcp { cmd }) => mcp(&cwd, cmd, cli.dry_run),
},
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;
}
}
let plan = container::plan(paths, profile, adapter, session, &[], opts)?;
plan.validate(&backend.caps())?;
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)?;
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),
},
)?;
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 mut checks = doctor::checks(&profile, &adapter);
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) {
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),
};
if let Some(account_dir) = &account {
auth::prepare(&adapter, account_dir, auth::GUEST_HOME)?;
}
let mut plan = container::plan(&paths, &profile, &adapter, &session, &[], opts)?;
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_config(cwd, Some("mcp")),
McpCmd::Add {
name,
command,
args,
env,
layer,
} => {
let server = render::Server {
command: command.clone(),
args: args.clone(),
env: env.iter().cloned().collect(),
};
let w = config::mcp_add(
&paths,
layer.unwrap_or(config::Layer::DEFAULT_WRITE),
name,
server,
)?;
println!("wrote → {}", w.path.display());
if w.committed {
println!(
"warning: the {} layer is COMMITTED — MCP env often holds tokens",
w.layer
);
}
Ok(())
}
McpCmd::Rm { name, layer } => {
let layer = layer.unwrap_or(config::Layer::DEFAULT_WRITE);
if config::mcp_remove(&paths, layer, name)? {
println!("removed {name} from the {layer} layer");
} else {
println!("{name} was not set in the {layer} layer");
}
Ok(())
}
McpCmd::Import {
harness,
file,
force,
layer,
} => {
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 layer = layer.unwrap_or(config::Layer::DEFAULT_WRITE);
let report = config::mcp_import(&paths, layer, 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 → {}", layer.dir(&paths).join("mcp.json").display());
}
Ok(())
}
}
}
fn show_config(cwd: &std::path::Path, section: Option<&str>) -> Result<()> {
let paths = Paths::discover(cwd)?;
let sections: Vec<(&str, Vec<config::Setting>)> = match section {
Some("policy") => vec![("policy", config::policy(&paths)?)],
Some("mcp") => vec![("mcp", config::servers(&paths)?)],
None => vec![
("policy", config::policy(&paths)?),
("mcp", config::servers(&paths)?),
],
Some(other) => anyhow::bail!("unknown section `{other}` — expected policy or mcp"),
};
for (name, settings) in sections {
println!("{name}:");
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} {:<28} ← {}{shadowed}", s.key, s.value, s.layer);
}
println!();
}
Ok(())
}
fn set(cwd: &std::path::Path, key: &str, value: &str, layer: Option<config::Layer>) -> Result<()> {
let paths = Paths::discover(cwd)?;
let w = config::set(
&paths,
key,
value,
layer.unwrap_or(config::Layer::DEFAULT_WRITE),
)?;
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: Option<config::Layer>) -> Result<()> {
let paths = Paths::discover(cwd)?;
let layer = layer.unwrap_or(config::Layer::DEFAULT_WRITE);
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, layer: Option<config::Layer>) -> Result<()> {
let paths = Paths::discover(cwd)?;
let layer = layer.unwrap_or(config::Layer::DEFAULT_WRITE);
let dir = layer.dir(&paths);
std::fs::create_dir_all(&dir)?;
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".into());
Command::new(editor).arg(&dir).status()?;
Ok(())
}
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; remove it with `omh config edit`.",
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 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),
};
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 base = session::default_branch(&paths.repo);
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())?;
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()
},
)?;
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 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 mut baselines: std::collections::BTreeMap<String, String> = manifest
.entries
.iter()
.filter_map(|e| e.command.clone().map(|c| (e.name.clone(), c)))
.collect();
for h in base::hooks() {
baselines.insert(h.name.to_string(), h.command.clone());
}
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 {
manifest: &manifest,
baselines,
installed,
derived,
};
print!(
"{}",
why::render_with_source(&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.root.join("profile/skills"))?;
std::fs::create_dir_all(paths.worktrees())?;
let stacks = detect::stacks(&paths.repo);
let names: Vec<String> = adapters.to_vec();
let harness = detect::preferred_harness(&names, &|h| runtime::installed(h));
let shared = paths.repo.join(".omh/profile");
let local = paths.repo.join(".omh/local");
for dir in [&shared, &local] {
std::fs::create_dir_all(dir.join("skills"))?;
}
std::fs::create_dir_all(shared.join("hooks"))?;
for layer in memory::Layer::ALL {
std::fs::create_dir_all(layer.dir(&paths))?;
}
write_if_absent(&paths.repo.join(".omh/keys.toml"), memory::SHIPPED_KEYS)?;
let agents = shared.join("AGENTS.md");
write_if_absent(&agents, &detect::agents_md(&stacks))?;
if append_section_if_absent(&agents, "## Memory", &detect::memory_rules())? {
println!(" memory added the note rules to .omh/profile/AGENTS.md");
}
if append_section_if_absent(&agents, "## Git", &detect::git_rules())? {
println!(" git added the git notice to .omh/profile/AGENTS.md");
}
let base_mcp =
serde_json::to_string_pretty(&serde_json::json!({ "mcpServers": manifest.servers() }))?
+ "\n";
write_if_absent(&shared.join("mcp.json"), &base_mcp)?;
write_if_absent(
&shared.join("policy.toml"),
"# 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",
)?;
for h in base::hooks() {
write_if_absent(
&shared.join("hooks").join(format!("{}.json", h.name)),
&(serde_json::to_string_pretty(&serde_json::json!({
"event": h.event,
"matcher": h.matcher,
"command": h.command,
}))? + "\n"),
)?;
}
for stack in &stacks {
write_if_absent(
&shared
.join("hooks")
.join(format!("{}-test.json", stack.name)),
&format!(
"{{ \"event\": \"Stop\", \"matcher\": \"\", \"command\": \"{}\" }}\n",
stack.test
),
)?;
write_if_absent(
&shared.join("hooks").join(format!("{}-format.json", stack.name)),
&format!(
"{{ \"event\": \"PostToolUse\", \"matcher\": \"Edit|Write\", \"command\": \"{}\" }}\n",
stack.format
),
)?;
}
ensure_line(&paths.repo.join(".omh/.gitignore"), "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 — add commands to .omh/profile/AGENTS.md");
} 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/profile/hooks/",
stacks.len()
);
}
println!("\n layers {} (committed)", shared.display());
println!(" {} (gitignored)", local.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 append_section_if_absent(path: &std::path::Path, heading: &str, section: &str) -> Result<bool> {
let existing = match std::fs::read_to_string(path) {
Ok(existing) => existing,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
};
if existing.lines().any(|line| line.trim() == heading) {
return Ok(false);
}
let mut out = existing;
if !out.ends_with('\n') {
out.push('\n');
}
out.push_str(section);
std::fs::write(path, out)?;
Ok(true)
}
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 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),
},
)?;
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 config 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 a_heading_that_merely_starts_with_memory_is_not_the_memory_section() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("AGENTS.md");
std::fs::write(
&path,
"# Project rules\n\n## Memory management\n\nArena, not GC.\n",
)
.unwrap();
assert!(
append_section_if_absent(&path, "## Memory", &detect::memory_rules()).unwrap(),
"a different section that happens to share a prefix is not this one"
);
assert!(std::fs::read_to_string(&path)
.unwrap()
.contains("## Memory management"));
}
#[test]
fn an_unreadable_agents_md_is_an_error_rather_than_a_quiet_no_op() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("AGENTS.md");
std::fs::write(&path, [0x23, 0x20, 0xff, 0xfe, 0x0a]).unwrap();
let err = append_section_if_absent(&path, "## Memory", &detect::memory_rules())
.expect_err("an unreadable file must be reported, not skipped");
assert!(
err.to_string().contains("AGENTS.md"),
"the error must name the file: {err}"
);
}
#[test]
fn an_agents_md_that_predates_the_notice_still_gets_it() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("AGENTS.md");
let human = "# Project rules\n\n## House style\n\nTabs, and no adverbs.\n";
std::fs::write(&path, human).unwrap();
assert!(append_section_if_absent(&path, "## Git", &detect::git_rules()).unwrap());
let body = std::fs::read_to_string(&path).unwrap();
assert!(body.starts_with(human), "a human's file must survive whole");
assert!(body.contains("omh s commit"), "the notice must arrive");
assert!(!append_section_if_absent(&path, "## Git", &detect::git_rules()).unwrap());
assert_eq!(
body,
std::fs::read_to_string(&path).unwrap(),
"`init` is re-runnable; a second pass must not stack a second copy"
);
}
#[test]
fn an_agents_md_that_predates_the_store_still_gets_the_note_rules() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("AGENTS.md");
let human = "# Project rules\n\n## House style\n\nTabs, and no adverbs.\n";
std::fs::write(&path, human).unwrap();
assert!(append_section_if_absent(&path, "## Memory", &detect::memory_rules()).unwrap());
let body = std::fs::read_to_string(&path).unwrap();
assert!(body.starts_with(human), "a human's file must survive whole");
assert!(body.contains("## Memory"), "the rules must arrive");
assert!(!append_section_if_absent(&path, "## Memory", &detect::memory_rules()).unwrap());
assert_eq!(
body,
std::fs::read_to_string(&path).unwrap(),
"appending twice duplicates the rules"
);
}
#[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");
}
}
}
}