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}"); +}