From 0442ab804e1ed6a116289c67e88d5b990a515254 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 29 Oct 2026 16:16:43 -0600 Subject: [PATCH] feat(host): books homes and starting an agent in one (P11b) D-27: each book gets a visible folder, /Wanderbooks/, where an agent works. It holds AGENTS.md and CLAUDE.md (three lines), the project-level .mcp.json entry (wanderbooks mcp --book ID) and .claude/settings.json allowing mcp__wanderbooks-ID__*, and nothing of the books: no data, key, token or path into the data root (invariant I-1 stays: the books are hidden, not here). The two JSON files are merged into and the text files written only when absent, so a person's edits stay. Named by the book id, which never changes, not by the company name, which is set after the books are made. The viewer's New books... and wanderbooks connect make the home; connect_agent is unchanged. start_agent says how to run Claude Code or Codex in it, or hands back the vendor's installer (never bundled). Nothing is marked trusted for any agent (Q-7: recommended answer, let Codex ask). First-run helpers (state, set up the private folder, first owner passphrase) let the viewer onboard with no terminal. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- Cargo.lock | 79 +++++ crates/ledger-gui/Cargo.toml | 2 + crates/ledger-host/src/commands.rs | 442 +++++++++++++++++++++++-- crates/ledger-host/tests/books_home.rs | 252 ++++++++++++++ 4 files changed, 740 insertions(+), 35 deletions(-) create mode 100644 crates/ledger-host/tests/books_home.rs diff --git a/Cargo.lock b/Cargo.lock index f2daba1..7ac7ee0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -167,6 +167,31 @@ dependencies = [ "memchr", ] +[[package]] +name = "alacritty_terminal" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda177466b9524d59f1b12f0dd30b68696788e9992a7e959021c4a0ed96fcf59" +dependencies = [ + "base64 0.22.1", + "bitflags 2.13.2", + "home", + "libc", + "log", + "miow", + "parking_lot", + "piper", + "polling", + "regex-automata", + "rustix 1.1.5", + "rustix-openpty", + "serde", + "signal-hook", + "unicode-width", + "vte", + "windows-sys 0.59.0", +] + [[package]] name = "alloc-no-stdlib" version = "2.0.4" @@ -3049,6 +3074,15 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "http" version = "1.5.0" @@ -3956,6 +3990,7 @@ dependencies = [ name = "ledger-gui" version = "0.1.0" dependencies = [ + "alacritty_terminal", "chrono", "eframe", "egui", @@ -4370,6 +4405,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "miow" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -6226,6 +6270,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustix-openpty" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de16c7c59892b870a6336f185dc10943517f1327447096bbb7bb32cd85e2393" +dependencies = [ + "errno", + "libc", + "rustix 1.1.5", +] + [[package]] name = "rustls" version = "0.23.45" @@ -6612,6 +6667,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b29ffa27d3d59a049bb328258de100f8012848d1b08d444e08bbc02ac468435e" +dependencies = [ + "libc", + "signal-hook-registry", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -8166,6 +8231,20 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" +[[package]] +name = "vte" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" +dependencies = [ + "arrayvec", + "bitflags 2.13.2", + "cursor-icon", + "log", + "memchr", + "serde", +] + [[package]] name = "wait-timeout" version = "0.2.1" diff --git a/crates/ledger-gui/Cargo.toml b/crates/ledger-gui/Cargo.toml index 6229a84..5557ba5 100644 --- a/crates/ledger-gui/Cargo.toml +++ b/crates/ledger-gui/Cargo.toml @@ -32,6 +32,8 @@ rust_decimal = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" zeroize = "1" +# The dock: a terminal emulator over a PTY (ConPTY on Windows), P11b +alacritty_terminal = "0.26" [dev-dependencies] chrono = "0.4" diff --git a/crates/ledger-host/src/commands.rs b/crates/ledger-host/src/commands.rs index e08056d..e6dc090 100644 --- a/crates/ledger-host/src/commands.rs +++ b/crates/ledger-host/src/commands.rs @@ -1146,7 +1146,7 @@ pub fn connect( }; // The owner's command (INVARIANT 6). common.ask_upgrades = true; - match connect_agent(&common, agent, &book) { + match connect_and_home(&common, agent, &book) { Ok(lines) => { for line in lines { println!("{line}"); @@ -1173,40 +1173,11 @@ pub fn connect_agent(common: &Common, agent: &str, book: &str) -> Result>()) - .unwrap_or_default() - .into_iter() - .chain(usual) - .filter(|d| d.is_absolute()) - .map(|d| d.join(cli)) - .find(|p| { - use std::os::unix::fs::PermissionsExt; - std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0) - }) - .ok_or_else(|| { - format!("{label} isn't installed here (no `{cli}` on the PATH, nor where its installer puts it)") - })?; + let program = find_agent_program(cli).ok_or_else(|| { + format!( + "{label} isn't installed here (no `{cli}` on the PATH, nor where its installer puts it)" + ) + })?; let mut lines = Vec::new(); // The token: kept in its file, or made now. let found = @@ -1289,6 +1260,407 @@ pub fn connect_agent(common: &Common, agent: &str, book: &str) -> Result, + home: Option<&Path>, +) -> Option { + let usual = [ + ".local/bin", + ".npm-global/bin", + ".bun/bin", + ".cargo/bin", + ".volta/bin", + ] + .into_iter() + .filter_map(|d| home.map(|h| h.join(d))) + .chain( + ["/usr/local/bin", "/opt/homebrew/bin"] + .into_iter() + .map(PathBuf::from), + ); + path.map(|p| std::env::split_paths(p).collect::>()) + .unwrap_or_default() + .into_iter() + .chain(usual) + .filter(|d| d.is_absolute()) + .map(|d| d.join(cli)) + .find(|p| is_program(p)) +} + +/// Whether `p` is a file this account can run. +fn is_program(p: &Path) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0) + } + #[cfg(not(unix))] + { + p.is_file() + } +} + +/// This account's home, from the environment. +fn home_dir_env() -> Option { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .filter(|h| h.is_absolute()) +} + +/// The agent program `cli` ("claude" or "codex") for this account. +fn find_agent_program(cli: &str) -> Option { + find_agent_in( + cli, + std::env::var_os("PATH").as_deref(), + home_dir_env().as_deref(), + ) +} + +/// How a person gets an agent's program: the vendor's own installer, +/// never bundled (D-27). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Installer { + /// The agent's name. + pub label: &'static str, + /// The vendor's install command, to run in a terminal. + pub command: &'static str, + /// The vendor's page about it. + pub page: &'static str, +} + +/// The vendor's installer for `agent` ("claude" or "codex"). +pub fn installer(agent: &str) -> Option { + match agent { + "claude" => Some(Installer { + label: "Claude Code", + command: if cfg!(windows) { + "irm https://claude.ai/install.ps1 | iex" + } else { + "curl -fsSL https://claude.ai/install.sh | bash" + }, + page: "https://code.claude.com/docs/en/setup", + }), + "codex" => Some(Installer { + label: "Codex", + command: "npm install -g @openai/codex", + page: "https://developers.openai.com/codex/cli", + }), + _ => None, + } +} + +/// The folder holding the user's documents: `$XDG_DOCUMENTS_DIR` if +/// absolute, else `Documents` in the home (the same word on every system +/// this program runs on). +pub fn documents_dir() -> Option { + std::env::var_os("XDG_DOCUMENTS_DIR") + .map(PathBuf::from) + .filter(|d| d.is_absolute()) + .or_else(|| home_dir_env().map(|h| h.join("Documents"))) +} + +/// The books home of `book` (D-27): a visible folder, +/// `/Wanderbooks/`, where an agent works on that book. It +/// holds the agent's pointer and settings for the book and nothing of the +/// books themselves (see [`make_books_home_in`]). Named by the book's id, +/// which never changes, not by the company's name, which is set after the +/// books are made and can change. +pub fn books_home(book: &str) -> PathBuf { + books_home_in( + &documents_dir().unwrap_or_else(|| PathBuf::from("Documents")), + book, + ) +} + +/// [`books_home`] under the given documents folder. +pub fn books_home_in(documents: &Path, book: &str) -> PathBuf { + documents.join("Wanderbooks").join(book) +} + +/// The program an agent's pointer file names: the AppImage's own file when +/// this runs from one (its mount goes away with the process), else this +/// executable as it is on disk. +fn pointer_program() -> Result { + if let Some(image) = std::env::var_os("APPIMAGE") + .map(PathBuf::from) + .filter(|p| p.is_absolute() && p.is_file()) + { + return Ok(image); + } + crate::start::this_executable() +} + +/// Make (or refresh) the books home of `book` under `documents`, for the +/// program `exe` (D-27). It holds exactly four files: +/// +/// ```text +/// AGENTS.md, CLAUDE.md three lines saying whose books these are and +/// how to reach them (written when absent: a +/// person's edits stay) +/// .mcp.json the project-level MCP entry: ` mcp --book ` +/// .claude/settings.json allows `mcp__wanderbooks-__*`, so the daily +/// flow doesn't prompt +/// ``` +/// +/// No books data, no key, no token, no path into the data root: the entry +/// finds the root the way every `wanderbooks` command does. The two JSON +/// files are merged into, so anything else a person keeps in them stays. +/// Trust in the folder is the person's to give: nothing here marks it +/// trusted for any agent (Q-7). +pub fn make_books_home_in( + documents: &Path, + book: &str, + company: Option<&str>, + exe: &Path, +) -> Result { + let book = ledger_store::root::BookId::parse(book) + .map_err(|e| e.to_string())? + .to_string(); + if !documents.is_absolute() { + return Err("there is no documents folder to make the books home in".into()); + } + let home = books_home_in(documents, &book); + let write_err = + |what: &Path, e: std::io::Error| format!("couldn't write {}: {}", what.display(), e.kind()); + std::fs::create_dir_all(home.join(".claude")).map_err(|e| write_err(&home, e))?; + let name = format!("wanderbooks-{book}"); + // The three lines, for both agents' file names. + let whose = company + .map(str::trim) + .filter(|c| !c.is_empty()) + .unwrap_or(&book); + let text = format!( + "These are {whose}'s books.\nConnect the wanderbooks MCP and call `books.start`.\nThe books live in the app's private folder, not here.\n" + ); + for file in ["AGENTS.md", "CLAUDE.md"] { + let path = home.join(file); + if !path.exists() { + std::fs::write(&path, &text).map_err(|e| write_err(&path, e))?; + } + } + let merge = |path: PathBuf, + edit: &dyn Fn(&mut serde_json::Map)| + -> Result<(), String> { + let mut doc = std::fs::read_to_string(&path) + .ok() + .and_then(|t| serde_json::from_str::(&t).ok()) + .filter(serde_json::Value::is_object) + .unwrap_or_else(|| serde_json::json!({})); + if let Some(map) = doc.as_object_mut() { + edit(map); + } + let mut out = serde_json::to_string_pretty(&doc).map_err(|e| e.to_string())?; + out.push('\n'); + std::fs::write(&path, out).map_err(|e| write_err(&path, e)) + }; + let entry = serde_json::json!({ + "command": exe.to_string_lossy(), + "args": ["mcp", "--book", book], + }); + merge(home.join(".mcp.json"), &|doc| { + let servers = doc + .entry("mcpServers") + .or_insert_with(|| serde_json::json!({})); + if let Some(servers) = servers.as_object_mut() { + servers.insert(name.clone(), entry.clone()); + } + })?; + let rule = format!("mcp__{name}__*"); + merge(home.join(".claude").join("settings.json"), &|doc| { + let perms = doc + .entry("permissions") + .or_insert_with(|| serde_json::json!({})); + if let Some(perms) = perms.as_object_mut() { + let allow = perms + .entry("allow") + .or_insert_with(|| serde_json::json!([])); + if let Some(allow) = allow.as_array_mut() + && !allow.iter().any(|r| r.as_str() == Some(rule.as_str())) + { + allow.push(serde_json::Value::String(rule.clone())); + } + } + })?; + Ok(home) +} + +/// [`make_books_home_in`] for this account: its documents folder and this +/// program. +pub fn make_books_home(book: &str, company: Option<&str>) -> Result { + let documents = documents_dir().ok_or("this account has no documents folder")?; + make_books_home_in(&documents, book, company, &pointer_program()?) +} + +/// [`connect_agent`], and the books home made beside it (D-27: the agent's +/// own list gets the user-scope entry as ever; the home adds the +/// project-level one). What was done, a line each. +pub fn connect_and_home(common: &Common, agent: &str, book: &str) -> Result, String> { + let mut lines = connect_agent(common, agent, book)?; + lines.push(home_line(book)); + Ok(lines) +} + +/// [`new_books`], and the books home made beside them (D-27): the viewer's +/// "New books…". The home is a convenience: its trouble is said, and the +/// books stay made. +pub fn new_books_and_home(common: &Common, id: &str, currency: &str) -> Result { + let made = new_books(common, id, currency)?; + Ok(format!("{made} {}", home_line(id.trim()))) +} + +/// The books home made, said in a line (its trouble, too: the home is a +/// convenience, never a reason to fail what was asked). +fn home_line(book: &str) -> String { + match make_books_home(book, None) { + Ok(home) => format!( + "The books home is {}: start your agent there.", + home.display() + ), + Err(why) => format!("The books home wasn't made: {why}."), + } +} + +/// What starting an agent in a books home needs (the dock runs it on a +/// terminal of its own). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AgentStart { + /// The program, its arguments, the folder it starts in and the + /// environment it is given. + Ready { + /// The agent's name. + label: &'static str, + /// The program. + program: PathBuf, + /// Its arguments. + args: Vec, + /// The books home. + dir: PathBuf, + /// Environment to add. + env: Vec<(String, String)>, + }, + /// The agent isn't installed: how to get it. + Missing(Installer), +} + +/// Start `agent` ("claude" or "codex") on `book` (D-27): make sure the +/// books home is there, and say how to run the agent in it. Claude Code +/// and Codex ask about trusting the folder themselves the first time (Q-7: +/// the app marks nothing trusted). +pub fn start_agent(agent: &str, book: &str) -> Result { + let documents = documents_dir().ok_or("this account has no documents folder")?; + start_agent_in( + &documents, + &pointer_program()?, + std::env::var_os("PATH").as_deref(), + home_dir_env().as_deref(), + agent, + book, + ) +} + +/// [`start_agent`] with its surroundings given: the documents folder, the +/// program the books home points at, the `PATH` and the home that find the +/// agent. +pub fn start_agent_in( + documents: &Path, + exe: &Path, + path: Option<&std::ffi::OsStr>, + home: Option<&Path>, + agent: &str, + book: &str, +) -> Result { + let how = installer(agent).ok_or_else(|| format!("{agent:?}: claude or codex"))?; + let cli = if agent == "claude" { "claude" } else { "codex" }; + let dir = make_books_home_in(documents, book, None, exe)?; + let Some(program) = find_agent_in(cli, path, home) else { + return Ok(AgentStart::Missing(how)); + }; + Ok(AgentStart::Ready { + label: how.label, + program, + args: Vec::new(), + dir, + env: vec![ + ("TERM".into(), "xterm-256color".into()), + ("COLORTERM".into(), "truecolor".into()), + ], + }) +} + +/// Where a first run stands (P11b: onboarding with no terminal): whether +/// the books' private folder is there, which books it holds, and whether +/// the owner passphrase is set. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct FirstRun { + /// The private folder (data root) exists and can be opened. + pub root: bool, + /// The ids of the books it holds. + pub books: Vec, + /// The owner passphrase is set. + pub passphrase_set: bool, +} + +/// [`FirstRun`] for the root `common` names (else the one setup recorded). +pub fn first_run_state(common: &Common) -> FirstRun { + let Ok(found) = ledger_store::root::Root::find(common.root.as_deref()) else { + return FirstRun::default(); + }; + FirstRun { + root: true, + books: found + .list_books() + .map(|b| b.iter().map(|id| id.as_str().to_string()).collect()) + .unwrap_or_default(), + passphrase_set: found + .auth_dir() + .ok() + .and_then(|a| ledger_store::passphrase::is_set(&a).ok()) + .unwrap_or(false), + } +} + +/// `wanderbooks setup` for a viewer with no terminal: make the books' +/// private folder (`common.root`, else this account's default; plan 3.4). +/// What was done, a line each. The folder's place is not said. +pub fn set_up_root(common: &Common) -> Result, String> { + let home = ledger_store::root::account_home().map_err(|e| e.to_string())?; + let xdg = std::env::var_os("XDG_DATA_HOME").map(PathBuf::from); + let (_, report) = + ledger_store::root::Root::setup(&home, common.root.as_deref(), xdg.as_deref()) + .map_err(|e| e.to_string())?; + Ok(vec![ + if report.created { + "Made the books' private folder. Only your account can open it." + } else { + "The books' private folder is there. Only your account can open it." + } + .to_string(), + ]) +} + +/// Set the owner passphrase for the first time, from a viewer (a person +/// typed it there, twice). Changing one in force is `wanderbooks +/// passphrase`'s: it asks for the one in force. +pub fn set_first_passphrase(common: &Common, new: &str) -> Result<(), String> { + use ledger_store::passphrase; + let found = + ledger_store::root::Root::find(common.root.as_deref()).map_err(|e| e.to_string())?; + let auth = found.auth_dir().map_err(|e| e.to_string())?; + if passphrase::is_set(&auth).map_err(|e| e.to_string())? { + return Err( + "an owner passphrase is set already: `wanderbooks passphrase` changes it".into(), + ); + } + passphrase::check_length(new).map_err(|e| e.to_string())?; + passphrase::set(&auth, new, None).map_err(|e| e.to_string()) +} + /// Make the token `name` for `book` (the server does) and keep it in its /// token file (INVARIANT 4: kept first, or taken back). fn make_token( diff --git a/crates/ledger-host/tests/books_home.rs b/crates/ledger-host/tests/books_home.rs new file mode 100644 index 0000000..042b928 --- /dev/null +++ b/crates/ledger-host/tests/books_home.rs @@ -0,0 +1,252 @@ +//! Books homes (P11b, D-27): the folder an agent works in for a book. It +//! holds the agent's pointer and settings and nothing of the books. + +use ledger_host::commands::{ + AgentStart, books_home_in, find_agent_in, installer, make_books_home_in, start_agent_in, +}; +use std::path::{Path, PathBuf}; + +/// Every file under `dir`, relative, sorted. +fn names(dir: &Path) -> Vec { + fn walk(base: &Path, dir: &Path, out: &mut Vec) { + for e in std::fs::read_dir(dir).unwrap() { + let p = e.unwrap().path(); + if p.is_dir() { + walk(base, &p, out); + } else { + out.push(p.strip_prefix(base).unwrap().to_string_lossy().into_owned()); + } + } + } + let mut out = Vec::new(); + walk(dir, dir, &mut out); + out.sort(); + out +} + +#[test] +fn books_home_holds_no_books_data() { + let docs = tempfile::tempdir().unwrap(); + // A data root with a journal, a key and a token file, elsewhere. + let root = tempfile::tempdir().unwrap(); + let book = root.path().join("books").join("acme"); + std::fs::create_dir_all(&book).unwrap(); + std::fs::write(book.join("journal.jsonl"), "{}").unwrap(); + let exe = PathBuf::from("/usr/bin/wanderbooks"); + + let home = make_books_home_in(docs.path(), "acme", Some("Acme Inc."), &exe).unwrap(); + assert_eq!(home, books_home_in(docs.path(), "acme")); + assert_eq!(home, docs.path().join("Wanderbooks").join("acme")); + // Exactly these four files. + assert_eq!( + names(&home), + [ + ".claude/settings.json", + ".mcp.json", + "AGENTS.md", + "CLAUDE.md" + ] + ); + // None names the data root, a token, a key or a journal. + for f in names(&home) { + let text = std::fs::read_to_string(home.join(&f)).unwrap(); + for bad in [ + root.path().to_str().unwrap(), + "token", + "journal", + "vault", + "key", + ] { + assert!(!text.contains(bad), "{f} mentions {bad:?}: {text}"); + } + } + let agents = std::fs::read_to_string(home.join("AGENTS.md")).unwrap(); + assert_eq!(agents.lines().count(), 3); + assert!(agents.contains("Acme Inc.'s books"), "{agents}"); + assert!(agents.contains("books.start")); + assert_eq!( + agents, + std::fs::read_to_string(home.join("CLAUDE.md")).unwrap() + ); + // The pointer and the allowance. + let mcp: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(home.join(".mcp.json")).unwrap()).unwrap(); + assert_eq!( + mcp["mcpServers"]["wanderbooks-acme"], + serde_json::json!({"command": "/usr/bin/wanderbooks", "args": ["mcp", "--book", "acme"]}) + ); + let settings: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(home.join(".claude/settings.json")).unwrap()) + .unwrap(); + assert_eq!( + settings["permissions"]["allow"], + serde_json::json!(["mcp__wanderbooks-acme__*"]) + ); +} + +#[test] +fn a_books_home_made_again_keeps_what_a_person_kept() { + let docs = tempfile::tempdir().unwrap(); + let exe = PathBuf::from("/usr/bin/wanderbooks"); + let home = make_books_home_in(docs.path(), "acme", None, &exe).unwrap(); + // Their words in AGENTS.md; their own server and rule in the JSON. + std::fs::write(home.join("AGENTS.md"), "Mine.\n").unwrap(); + std::fs::write( + home.join(".mcp.json"), + r#"{"mcpServers":{"other":{"command":"x"}}}"#, + ) + .unwrap(); + std::fs::write( + home.join(".claude/settings.json"), + r#"{"permissions":{"allow":["Bash(ls)"]},"model":"opus"}"#, + ) + .unwrap(); + let exe2 = PathBuf::from("/opt/wanderbooks"); + make_books_home_in(docs.path(), "acme", None, &exe2).unwrap(); + make_books_home_in(docs.path(), "acme", None, &exe2).unwrap(); + assert_eq!( + std::fs::read_to_string(home.join("AGENTS.md")).unwrap(), + "Mine.\n" + ); + let mcp: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(home.join(".mcp.json")).unwrap()).unwrap(); + assert_eq!(mcp["mcpServers"]["other"]["command"], "x"); + assert_eq!( + mcp["mcpServers"]["wanderbooks-acme"]["command"], + "/opt/wanderbooks" + ); + let settings: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(home.join(".claude/settings.json")).unwrap()) + .unwrap(); + assert_eq!(settings["model"], "opus"); + assert_eq!( + settings["permissions"]["allow"], + serde_json::json!(["Bash(ls)", "mcp__wanderbooks-acme__*"]), + "once, however often it is made" + ); +} + +#[test] +fn a_books_home_is_never_made_for_a_name_that_is_not_an_id() { + let docs = tempfile::tempdir().unwrap(); + let exe = PathBuf::from("/usr/bin/wanderbooks"); + for bad in ["../x", "a/b", "", "A B"] { + assert!( + make_books_home_in(docs.path(), bad, None, &exe).is_err(), + "{bad:?}" + ); + } + assert!(std::fs::read_dir(docs.path()).unwrap().next().is_none()); + // And not into a relative place. + assert!(make_books_home_in(Path::new("Documents"), "acme", None, &exe).is_err()); +} + +#[cfg(unix)] +#[test] +fn an_agent_is_found_on_the_path_or_where_its_installer_puts_it() { + use std::os::unix::fs::PermissionsExt; + let bin = tempfile::tempdir().unwrap(); + let home = tempfile::tempdir().unwrap(); + let docs = tempfile::tempdir().unwrap(); + let exe = PathBuf::from("/usr/bin/wanderbooks"); + let path = std::ffi::OsString::from(bin.path()); + // Neither: missing, with the vendor's installer to offer. + let nowhere = std::ffi::OsString::from("/nonexistent"); + match start_agent_in( + docs.path(), + &exe, + Some(&nowhere), + Some(home.path()), + "claude", + "acme", + ) + .unwrap() + { + AgentStart::Missing(i) => { + assert_eq!(i, installer("claude").unwrap()); + assert!(i.command.contains("claude.ai/install"), "{}", i.command); + } + other => panic!("{other:?}"), + } + // On the PATH. + let claude = bin.path().join("claude"); + std::fs::write(&claude, "#!/bin/sh\n").unwrap(); + std::fs::set_permissions(&claude, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!( + find_agent_in("claude", Some(&path), Some(home.path())), + Some(claude.clone()) + ); + // A file that cannot be run is not the agent. + let codex = bin.path().join("codex"); + std::fs::write(&codex, "x").unwrap(); + assert_eq!(find_agent_in("codex", Some(&path), Some(home.path())), None); + // Where its installer puts it. + let local = home.path().join(".local/bin"); + std::fs::create_dir_all(&local).unwrap(); + let codex = local.join("codex"); + std::fs::write(&codex, "#!/bin/sh\n").unwrap(); + std::fs::set_permissions(&codex, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!( + find_agent_in("codex", Some(&nowhere), Some(home.path())), + Some(codex) + ); + // Starting: in the books home, nothing marked trusted. + match start_agent_in( + docs.path(), + &exe, + Some(&path), + Some(home.path()), + "claude", + "acme", + ) + .unwrap() + { + AgentStart::Ready { + program, + dir, + args, + env, + label, + } => { + assert_eq!(program, claude); + assert_eq!(dir, books_home_in(docs.path(), "acme")); + assert_eq!(label, "Claude Code"); + assert!(args.is_empty()); + assert!( + env.iter() + .any(|(k, v)| k == "TERM" && v == "xterm-256color") + ); + } + other => panic!("{other:?}"), + } + assert!(start_agent_in(docs.path(), &exe, None, None, "gemini", "acme").is_err()); +} + +#[test] +fn a_first_run_reads_where_it_stands_and_sets_the_first_passphrase_once() { + use ledger_host::commands::{Common, FirstRun, first_run_state, set_first_passphrase}; + let home = tempfile::tempdir().unwrap(); + let place = home.path().join("data"); + let common = Common { + root: Some(place.clone()), + ..Default::default() + }; + // Nothing there yet. + assert_eq!(first_run_state(&common), FirstRun::default()); + // Set up (what `wanderbooks setup --root` does), without a terminal. + ledger_store::root::Root::setup(home.path(), Some(&place), None).unwrap(); + assert_eq!( + first_run_state(&common), + FirstRun { + root: true, + books: vec![], + passphrase_set: false + } + ); + // Too short is refused; a good one is set; a second first one is refused. + assert!(set_first_passphrase(&common, "short").is_err()); + set_first_passphrase(&common, "a long enough passphrase").unwrap(); + assert!(first_run_state(&common).passphrase_set); + let again = set_first_passphrase(&common, "another long passphrase").unwrap_err(); + assert!(again.contains("wanderbooks passphrase"), "{again}"); +}