From 0442ab804e1ed6a116289c67e88d5b990a515254 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 29 Oct 2026 16:16:43 -0600 Subject: [PATCH 1/5] 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}"); +} From 8f2e3378e8a7b4b902816b005e7e85f33d332db9 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 29 Oct 2026 16:39:38 -0600 Subject: [PATCH 2/5] feat(viewer): the agent dock and the first-run page (P11b) D-27: a terminal panel along the bottom of the viewer (alacritty_terminal over a PTY) runs Claude Code (default) or Codex in the book's books home, resizable, collapsible, the choice remembered. The agent is an ordinary child process the owner describes (agent_start); the viewer stays read-only: the agent writes through the server like any other. A missing agent is offered through its vendor's installer (a command to copy), never bundled; no folder is marked trusted (Q-7, recommended answer: let Codex ask). Windows is not offered until its ConPTY checks are recorded. P11a found that onboarding with no terminal was false: the viewer opened only an existing book. With no books and an owner, wanderbooks gui now shows a first-run page: the private folder, the owner passphrase (typed in the viewer, optional), the first books, an agent, then the books open. connect and New books... make the books home. Tests: dock.rs, welcome.rs, books_home.rs (host and cli). Docs: testing.md record and For P12, architecture, parity, gui.md, README. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- README.md | 8 +- crates/ledger-cli/src/main.rs | 69 +- crates/ledger-cli/tests/books_home.rs | 152 ++++ crates/ledger-gui/src/app.rs | 101 ++- crates/ledger-gui/src/dock.rs | 1010 +++++++++++++++++++++++++ crates/ledger-gui/src/lib.rs | 38 +- crates/ledger-gui/src/owner.rs | 89 ++- crates/ledger-gui/src/welcome.rs | 460 +++++++++++ crates/ledger-gui/tests/dock.rs | 260 +++++++ crates/ledger-gui/tests/welcome.rs | 323 ++++++++ crates/ledger-host/src/commands.rs | 11 +- docs/architecture.md | 29 +- docs/parity.md | 8 + docs/testing.md | 80 ++ plan/gui.md | 15 +- 15 files changed, 2620 insertions(+), 33 deletions(-) create mode 100644 crates/ledger-cli/tests/books_home.rs create mode 100644 crates/ledger-gui/src/dock.rs create mode 100644 crates/ledger-gui/src/welcome.rs create mode 100644 crates/ledger-gui/tests/dock.rs create mode 100644 crates/ledger-gui/tests/welcome.rs diff --git a/README.md b/README.md index 02eebe6..7d1f907 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,13 @@ curl -fsSL https://wanderbooks.ai/install.sh | sh -s -- acme Already installed, three commands: `wanderbooks setup`, `wanderbooks books create acme`, `wanderbooks connect claude --book acme` (or `connect codex`). Then talk to the agent; `wanderbooks gui acme` shows the books, and its -Books menu backs them up, restores, starts new books and closes them. +Books menu backs them up, restores, starts new books and closes them. With +no terminal (the AppImage, a menu entry), opening the app with no books +shows a first-run page that does the same three steps (the private folder, +an owner passphrase, the first books) and connects an agent; a panel along +the bottom of the window then runs Claude Code or Codex in the books' own +folder, `~/Documents/Wanderbooks/` (optional: any agent can use the +books). Updating: an AppImage, or a `wanderbooks` you put somewhere yourself, brings itself to the newest release with `wanderbooks update` (it checks the diff --git a/crates/ledger-cli/src/main.rs b/crates/ledger-cli/src/main.rs index 94028d7..b9af5bd 100644 --- a/crates/ledger-cli/src/main.rs +++ b/crates/ledger-cli/src/main.rs @@ -71,7 +71,7 @@ impl ledger_gui::owner::Owner for ViewerOwner { } fn new_books(&self, id: &str, currency: &str) -> Result { - ledger_host::commands::new_books(&self.0, id, currency) + ledger_host::commands::new_books_and_home(&self.0, id, currency) } fn back_up_now(&self, book: &str) -> Result, String> { @@ -113,7 +113,60 @@ impl ledger_gui::owner::Owner for ViewerOwner { ledger_gui::owner::Agent::Claude => "claude", ledger_gui::owner::Agent::Codex => "codex", }; - ledger_host::commands::connect_agent(&self.0, agent, book) + ledger_host::commands::connect_and_home(&self.0, agent, book) + } + + fn first_run(&self) -> ledger_gui::owner::FirstRun { + let s = ledger_host::commands::first_run_state(&self.0); + ledger_gui::owner::FirstRun { + root: s.root, + books: s.books, + passphrase_set: s.passphrase_set, + } + } + + fn set_up(&self) -> Result, String> { + ledger_host::commands::set_up_root(&self.0) + } + + fn set_passphrase(&self, new: &str) -> Result<(), String> { + ledger_host::commands::set_first_passphrase(&self.0, new) + } + + fn hold(&self, book: &str) { + ledger_host::commands::hold_for_viewer(&self.0, Some(book.to_string())); + } + + fn agent_start( + &self, + agent: ledger_gui::owner::Agent, + book: &str, + ) -> Result { + use ledger_gui::owner::{AgentStart, Installer}; + let agent = match agent { + ledger_gui::owner::Agent::Claude => "claude", + ledger_gui::owner::Agent::Codex => "codex", + }; + Ok(match ledger_host::commands::start_agent(agent, book)? { + ledger_host::commands::AgentStart::Ready { + label, + program, + args, + dir, + env, + } => AgentStart::Ready { + label: label.to_string(), + program, + args, + dir, + env, + }, + ledger_host::commands::AgentStart::Missing(i) => AgentStart::Missing(Installer { + label: i.label.to_string(), + command: i.command.to_string(), + page: i.page.to_string(), + }), + }) } fn upgrade_format(&self, book: &str, passphrase: &str) -> Result, String> { @@ -148,14 +201,18 @@ fn run_gui(args: Vec) -> i32 { } let root = value("--root").map(PathBuf::from); let book = value("--book"); - if known && ledger_gui::book_in_root(root.as_deref(), book.as_deref()).is_ok() { - // The viewer asks about a format upgrade in its own window. + if known { + // The viewer asks about a format upgrade in its own window, makes + // the first books when there are none, and starts the agent in + // its dock. let common = ledger_host::commands::Common { - root, + root: root.clone(), ..Default::default() }; ledger_gui::owner::set_owner(std::sync::Arc::new(ViewerOwner(common.clone()))); - ledger_host::commands::hold_for_viewer(&common, book); + if ledger_gui::book_in_root(root.as_deref(), book.as_deref()).is_ok() { + ledger_host::commands::hold_for_viewer(&common, book); + } } } match ledger_gui::run("wanderbooks gui", args) { diff --git a/crates/ledger-cli/tests/books_home.rs b/crates/ledger-cli/tests/books_home.rs new file mode 100644 index 0000000..6292286 --- /dev/null +++ b/crates/ledger-cli/tests/books_home.rs @@ -0,0 +1,152 @@ +//! `wanderbooks connect` makes the book's books home (P11b, D-27): a folder in +//! the account's Documents holding the agent's pointer and nothing of the +//! books. Run with a home of the test's own: nothing here touches the account's +//! real Documents or data root. + +#![cfg(unix)] + +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +const KNOBS: &str = + "close_after=2500,backoff=50,exe_poll=100,init_wait=5000,queue_wait=30000,backup_quiet=300"; + +struct Home { + dir: tempfile::TempDir, +} + +impl Home { + fn new() -> Self { + let dir = tempfile::Builder::new() + .prefix("wb-books-home-") + .tempdir() + .unwrap(); + let out = Command::new(env!("CARGO_BIN_EXE_wanderbooks")) + .args(["setup", "--home"]) + .arg(dir.path()) + .env_remove("XDG_DATA_HOME") + .output() + .unwrap(); + assert!(out.status.success(), "{out:?}"); + Self { dir } + } + + fn root(&self) -> PathBuf { + self.dir.path().join(".local/share/wanderbooks") + } + + fn cmd(&self, mode: &str, path: &Path) -> Command { + let mut c = Command::new(env!("CARGO_BIN_EXE_wanderbooks")); + c.arg(mode) + .arg("--root") + .arg(self.root()) + .args(["--test-knobs", KNOBS]) + .env_clear() + .env("PATH", format!("{}:/usr/bin:/bin", path.display())) + .env("HOME", self.dir.path()) + .stdin(Stdio::null()); + c + } +} + +/// A stand-in `claude` that accepts whatever it is told. +fn fake_agent(home: &Home) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + let bin = home.dir.path().join("agent-bin"); + std::fs::create_dir_all(&bin).unwrap(); + let script = bin.join("claude"); + std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + bin +} + +#[test] +fn connect_makes_the_books_home_and_it_holds_no_books_data() { + let home = Home::new(); + let out = home + .cmd("books", Path::new("/nonexistent")) + .args(["create", "beta"]) + .output() + .unwrap(); + assert!(out.status.success(), "{out:?}"); + let docs = home.dir.path().join("Documents/Wanderbooks/beta"); + + // An agent that isn't installed: said, and no folder made for nothing. + let out = home + .cmd("connect", Path::new("/nonexistent")) + .args(["claude", "--book", "beta"]) + .output() + .unwrap(); + assert!(!out.status.success()); + assert!(!docs.exists(), "a home for an agent that isn't there"); + + // Connected: the user-scope entry as ever, and the books home beside it. + let bin = fake_agent(&home); + let out = home + .cmd("connect", &bin) + .args(["claude", "--book", "beta"]) + .output() + .unwrap(); + assert!(out.status.success(), "{out:?}"); + let said = String::from_utf8_lossy(&out.stdout); + assert!( + said.contains("Claude Code reaches the books beta"), + "{said}" + ); + assert!( + said.contains(&format!("The books home is {}", docs.display())), + "{said}" + ); + let mut names: Vec = Vec::new(); + for dir in [docs.clone(), docs.join(".claude")] { + for e in std::fs::read_dir(&dir).unwrap() { + let p = e.unwrap().path(); + if p.is_file() { + names.push( + p.strip_prefix(&docs) + .unwrap() + .to_string_lossy() + .into_owned(), + ); + } + } + } + names.sort(); + assert_eq!( + names, + [ + ".claude/settings.json", + ".mcp.json", + "AGENTS.md", + "CLAUDE.md" + ] + ); + // Nothing in it names the data root, a token or a key. + let root = home.root(); + for n in &names { + let text = std::fs::read_to_string(docs.join(n)).unwrap(); + assert!(!text.contains(root.to_str().unwrap()), "{n}: {text}"); + assert!(!text.contains("wb_"), "{n} holds a token: {text}"); + assert!(!text.contains("--root"), "{n}: {text}"); + } + let mcp: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(docs.join(".mcp.json")).unwrap()).unwrap(); + assert_eq!( + mcp["mcpServers"]["wanderbooks-beta"]["args"], + serde_json::json!(["mcp", "--book", "beta"]) + ); + // Run again: still four files, the rule once. + let out = home + .cmd("connect", &bin) + .args(["claude", "--book", "beta"]) + .output() + .unwrap(); + assert!(out.status.success(), "{out:?}"); + let settings: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(docs.join(".claude/settings.json")).unwrap()) + .unwrap(); + assert_eq!( + settings["permissions"]["allow"], + serde_json::json!(["mcp__wanderbooks-beta__*"]) + ); +} diff --git a/crates/ledger-gui/src/app.rs b/crates/ledger-gui/src/app.rs index 8aa63b2..8ea0ef3 100644 --- a/crates/ledger-gui/src/app.rs +++ b/crates/ledger-gui/src/app.rs @@ -151,24 +151,75 @@ pub struct App { menu: crate::menu::Menu, /// The owner's Books menu (owner.rs). owner_menu: crate::owner::OwnerMenu, + /// The agent dock along the bottom (dock.rs). + dock: crate::dock::Dock, + /// The first-run page, while there are no books to show (welcome.rs). + welcome: Option, + /// What a first run opens its books with: the data root named, and + /// "today". + first_run_source: (Option, Option), } impl App { /// The live app over a books directory. pub fn new(cc: &eframe::CreationContext<'_>, source: Source) -> Self { - // The scheme kept from last time; before schemes, a light/dark - // choice; else the system's mode (schemes INVARIANT 4). + let mut app = Self::new_without_books(cc); + app.loader = Some(Loader::spawn(source, cc.egui_ctx.clone())); + app + } + + /// The app on a first run: no books yet, so the page that sets them up + /// (welcome.rs), which opens them once they are made. `root` is the data + /// root named on the command line, if any. + pub fn first_run( + cc: &eframe::CreationContext<'_>, + root: Option, + today: Option, + state: crate::owner::FirstRun, + ) -> Self { + let mut app = Self::new_without_books(cc); + app.welcome = Some(crate::welcome::Welcome::new(state)); + app.first_run_source = (root, today); + app + } + + /// An app with no books and no loader yet: the scheme kept from last + /// time (before schemes, a light/dark choice; else the system's mode: + /// schemes INVARIANT 4) and the dock as it was left. + pub fn new_without_books(cc: &eframe::CreationContext<'_>) -> Self { let (scheme, filter) = restored( |key| cc.storage.and_then(|s| s.get_string(key)), cc.egui_ctx.global_style().visuals.dark_mode, ); let mut app = Self::bare(&cc.egui_ctx, scheme); app.filter = filter; + app.dock = crate::dock::Dock::restored(|key| cc.storage.and_then(|s| s.get_string(key))); app.receipts.thumb_dir = crate::receipts::thumb_dir(); - app.loader = Some(Loader::spawn(source, cc.egui_ctx.clone())); app } + /// The first-run page, if it is up (tests). + pub fn welcome(&self) -> Option<&crate::welcome::Welcome> { + self.welcome.as_ref() + } + + /// Open the books `id` of the data root named, as the first-run page + /// was asked to: the books come in as the loader reads them. + fn open_first_books(&mut self, ctx: &egui::Context, id: &str) { + let (root, today) = self.first_run_source.clone(); + match crate::book_in_root(root.as_deref(), Some(id)) { + Ok(data_dir) => { + self.loader = Some(Loader::spawn(Source { data_dir, today }, ctx.clone())); + self.welcome = None; + } + Err(why) => { + if let Some(w) = self.welcome.as_mut() { + w.refused(why); + } + } + } + } + /// An app showing fixed books (tests and screenshots). pub fn with_books(ctx: &egui::Context, books: Books, dark: bool, start: Route) -> Self { let mut app = Self::bare(ctx, schemes::own(dark)); @@ -198,6 +249,28 @@ impl App { live_label: false, menu: Default::default(), owner_menu: Default::default(), + dock: Default::default(), + welcome: None, + first_run_source: (None, None), + } + } + + /// The agent dock (tests). + pub fn dock_mut(&mut self) -> &mut crate::dock::Dock { + &mut self.dock + } + + /// The dock along the bottom, for books of a data root with an owner + /// to start the agent (dock.rs). + fn dock(&mut self, ui: &mut Ui) { + if !crate::dock::offered() { + return; + } + let (Some(owner), Some(books)) = (crate::owner::current(), self.books.clone()) else { + return; + }; + if let Some(book) = crate::owner::book_id(&books) { + self.dock.show(ui, &owner, &book); } } @@ -556,6 +629,10 @@ impl App { } fn handle_keys(&mut self, ui: &Ui) { + // The agent in the dock has the keyboard while it is clicked on. + if self.dock.has_keyboard() { + return; + } let (back, fwd, palette) = ui.input(|i| { ( (i.modifiers.alt && i.key_pressed(Key::ArrowLeft)) @@ -1636,6 +1713,22 @@ fn checks(b: &Books) -> Vec<(bool, String, String)> { impl eframe::App for App { fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) { + // A first run: the page that sets the books up, until there are some. + if self.welcome.is_some() && self.books.is_none() { + let p = pal(ui.ctx()); + let owner = crate::owner::current(); + let mut open = None; + if let (Some(w), Some(owner)) = (self.welcome.as_mut(), owner) { + egui::CentralPanel::no_frame() + .frame(Frame::new().fill(p.bg)) + .show(ui, |ui| w.show(ui, &owner)); + open = w.take_open(); + } + if let Some(id) = open { + self.open_first_books(&ui.ctx().clone(), &id); + } + return; + } let updates = self.loader.as_ref().map(|l| l.poll()).unwrap_or_default(); for u in updates { match u { @@ -1656,6 +1749,7 @@ impl eframe::App for App { let compact = ui.available_width() < COMPACT_BELOW; self.sidebar(ui, compact); self.topbar(ui); + self.dock(ui); let p = pal(ui.ctx()); let since_update = ui.input(|i| i.time) - self.updated_at; if since_update < 6.0 { @@ -1706,6 +1800,7 @@ impl eframe::App for App { storage.set_string(SCHEME_KEY, self.scheme.key()); storage.set_string(FILTER_KEY, self.filter.name().to_string()); storage.set_string("dark", self.scheme.dark.to_string()); + self.dock.keep(&mut |k, v| storage.set_string(k, v)); } } diff --git a/crates/ledger-gui/src/dock.rs b/crates/ledger-gui/src/dock.rs new file mode 100644 index 0000000..68591d2 --- /dev/null +++ b/crates/ledger-gui/src/dock.rs @@ -0,0 +1,1010 @@ +//! The agent dock (plan P11b, D-27): a terminal along the bottom of the +//! viewer's window that runs Claude Code (the default) or Codex in the +//! book's books home. +//! +//! ```text +//! GOAL: the owner talks to the agent that keeps the books without leaving +//! the app, and never has to: any other agent, anywhere, reaches the +//! same books through the MCP. +//! SOURCE: D-27 (the dock, the books home, "never required"); D-32 (no +//! helper, no confinement: the agent is an ordinary child process +//! of the viewer's program, started through the [`Owner`]); D-54; +//! Q-7 (Codex asks about trusting the folder itself). +//! INVARIANTS: +//! 1. The dock writes nothing of the books. The agent it runs writes +//! through the books server like any other agent; the viewer stays +//! read-only (tests/read_only.rs). +//! 2. The agent runs in the book's books home, a folder holding only its +//! pointer and settings; the dock never runs anything in the data +//! root and never marks a folder trusted. +//! 3. An agent that isn't installed is offered through its vendor's +//! installer (a command to copy and a page); nothing is installed by +//! the app and no agent is bundled. +//! 4. Keystrokes go to the agent only while the dock has the keyboard +//! (a click gives it, a click elsewhere takes it away): the viewer's +//! own shortcuts work everywhere else. Ctrl-C is the agent's. +//! 5. The terminal is as large as the panel: resizing the panel resizes +//! the agent's terminal, which redraws. +//! 6. The agent chosen and whether the dock is open are the viewer's +//! preferences, remembered; they are not the books'. +//! 7. Offered only where it has been checked to work (Linux and macOS): +//! a ConPTY agent on Windows must pass the four checks (it starts, +//! `/login` works, resize redraws, Ctrl-C reaches it) before it is. +//! ``` + +use crate::owner::{Agent, AgentStart, Installer, Owner}; +use alacritty_terminal::event::{Event as TermEvent, EventListener, WindowSize}; +use alacritty_terminal::event_loop::{EventLoop, EventLoopSender, Msg}; +use alacritty_terminal::grid::{Dimensions, Scroll}; +use alacritty_terminal::index::{Column, Line, Point, Side}; +use alacritty_terminal::selection::{Selection, SelectionType}; +use alacritty_terminal::sync::FairMutex; +use alacritty_terminal::term::cell::Flags; +use alacritty_terminal::term::{Config, Term, TermMode, point_to_viewport, viewport_to_point}; +use alacritty_terminal::tty; +use alacritty_terminal::vte::ansi::{Color as TermColor, NamedColor, Rgb}; +use egui::{Color32, Event, FontId, Key, Modifiers, Pos2, Rect, RichText, Sense, Ui, vec2}; +use egui_phosphor::regular as icon; +use std::borrow::Cow; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{Receiver, channel}; +use std::sync::{Arc, Mutex}; + +/// Whether the dock is offered on this system (INVARIANT 7). +pub fn offered() -> bool { + cfg!(unix) +} + +/// A size in character cells. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Cells { + /// Columns. + pub cols: u16, + /// Rows. + pub rows: u16, +} + +impl Dimensions for Cells { + fn total_lines(&self) -> usize { + self.rows as usize + } + fn screen_lines(&self) -> usize { + self.rows as usize + } + fn columns(&self) -> usize { + self.cols as usize + } +} + +/// What the terminal tells the dock: it listens for the emulator's events. +#[derive(Clone)] +struct Proxy { + wake: Arc, + to_pty: Arc>>, + size: Arc>, + exited: Arc, + title: Arc>, +} + +impl Proxy { + fn write(&self, text: String) { + if let Some(tx) = self + .to_pty + .lock() + .unwrap_or_else(|e| e.into_inner()) + .as_ref() + { + let _ = tx.send(Msg::Input(Cow::Owned(text.into_bytes()))); + } + } +} + +impl EventListener for Proxy { + fn send_event(&self, event: TermEvent) { + match event { + TermEvent::Wakeup | TermEvent::Bell => (self.wake)(), + // The terminal's own answers to the program's questions. + TermEvent::PtyWrite(text) => self.write(text), + TermEvent::TextAreaSizeRequest(format) => { + let size = *self.size.lock().unwrap_or_else(|e| e.into_inner()); + self.write(format(size)); + } + TermEvent::Title(t) => { + *self.title.lock().unwrap_or_else(|e| e.into_inner()) = t; + (self.wake)(); + } + TermEvent::ResetTitle => { + self.title.lock().unwrap_or_else(|e| e.into_inner()).clear(); + } + TermEvent::ChildExit(_) | TermEvent::Exit => { + self.exited.store(true, Ordering::SeqCst); + (self.wake)(); + } + _ => {} + } + } +} + +/// An agent running on a terminal of its own. +pub struct Session { + term: Arc>>, + to_pty: EventLoopSender, + size: Arc>, + cells: Cells, + exited: Arc, + title: Arc>, +} + +impl Session { + /// Run `program args` in `dir` with `env` added, on a terminal of `cells`; + /// `wake` is called when there is something new to draw. + pub fn spawn( + program: &std::path::Path, + args: &[String], + dir: &std::path::Path, + env: &[(String, String)], + cells: Cells, + wake: Arc, + ) -> Result { + let window = WindowSize { + num_lines: cells.rows, + num_cols: cells.cols, + cell_width: 1, + cell_height: 1, + }; + let options = tty::Options { + shell: Some(tty::Shell::new( + program.to_string_lossy().into_owned(), + args.to_vec(), + )), + working_directory: Some(dir.to_path_buf()), + drain_on_exit: true, + env: env.iter().cloned().collect(), + #[cfg(windows)] + escape_args: true, + }; + let pty = tty::new(&options, window, 0).map_err(|e| e.to_string())?; + let exited = Arc::new(AtomicBool::new(false)); + let to_pty = Arc::new(Mutex::new(None)); + let size = Arc::new(Mutex::new(window)); + let title = Arc::new(Mutex::new(String::new())); + let proxy = Proxy { + wake, + to_pty: to_pty.clone(), + size: size.clone(), + exited: exited.clone(), + title: title.clone(), + }; + let term = Arc::new(FairMutex::new(Term::new( + Config::default(), + &cells, + proxy.clone(), + ))); + let event_loop = + EventLoop::new(term.clone(), proxy, pty, true, false).map_err(|e| e.to_string())?; + let sender = event_loop.channel(); + *to_pty.lock().unwrap_or_else(|e| e.into_inner()) = Some(sender.clone()); + // Its thread ends on Shutdown, and its terminal's child with it. + drop(event_loop.spawn()); + Ok(Session { + term, + to_pty: sender, + size, + cells, + exited, + title, + }) + } + + /// Send keystrokes (or anything else the program is to read). + pub fn write(&self, bytes: impl Into>) { + let bytes = bytes.into(); + if !bytes.is_empty() { + let _ = self.to_pty.send(Msg::Input(bytes)); + } + } + + /// Make the terminal `cells` large (the program is told, and redraws). + pub fn resize(&mut self, cells: Cells) { + if cells == self.cells || cells.cols == 0 || cells.rows == 0 { + return; + } + self.cells = cells; + let window = WindowSize { + num_lines: cells.rows, + num_cols: cells.cols, + cell_width: 1, + cell_height: 1, + }; + *self.size.lock().unwrap_or_else(|e| e.into_inner()) = window; + self.term.lock().resize(cells); + let _ = self.to_pty.send(Msg::Resize(window)); + } + + /// The terminal's size now. + pub fn cells(&self) -> Cells { + self.cells + } + + /// Whether the program has ended. + pub fn exited(&self) -> bool { + self.exited.load(Ordering::SeqCst) + } + + /// The window title the program set, if any. + pub fn title(&self) -> String { + self.title.lock().unwrap_or_else(|e| e.into_inner()).clone() + } + + /// The screen as text, a line each, trailing blanks cut (tests and + /// accessibility). + pub fn text(&self) -> String { + let term = self.term.lock(); + let (rows, cols) = (term.screen_lines(), term.columns()); + let mut out = Vec::new(); + for line in 0..rows { + let start = Point::new(Line(line as i32), Column(0)); + let end = Point::new(Line(line as i32), Column(cols - 1)); + out.push(term.bounds_to_string(start, end).trim_end().to_string()); + } + out.join("\n") + } +} + +impl Drop for Session { + fn drop(&mut self) { + let _ = self.to_pty.send(Msg::Shutdown); + } +} + +/// The bytes a key sends to a program on a terminal (None: nothing, or the +/// key's text comes separately as typed text). `app_cursor` is the +/// program's application-cursor mode. +pub fn key_bytes(key: Key, mods: Modifiers, app_cursor: bool) -> Option> { + // xterm's modifier parameter: 1 + shift 1 + alt 2 + ctrl 4. + let param = 1 + u8::from(mods.shift) + 2 * u8::from(mods.alt) + 4 * u8::from(mods.ctrl); + let csi = |final_byte: char| -> Vec { + if param == 1 { + format!("\x1b[{final_byte}").into_bytes() + } else { + format!("\x1b[1;{param}{final_byte}").into_bytes() + } + }; + let arrow = |final_byte: char| -> Vec { + if param == 1 && app_cursor { + format!("\x1bO{final_byte}").into_bytes() + } else { + csi(final_byte) + } + }; + let tilde = |n: u8| -> Vec { + if param == 1 { + format!("\x1b[{n}~").into_bytes() + } else { + format!("\x1b[{n};{param}~").into_bytes() + } + }; + let with_alt = |mut b: Vec| { + if mods.alt { + b.insert(0, 0x1b); + } + b + }; + Some(match key { + Key::Enter if mods.shift => b"\n".to_vec(), + Key::Enter => with_alt(b"\r".to_vec()), + Key::Backspace if mods.ctrl => vec![0x08], + Key::Backspace => with_alt(vec![0x7f]), + Key::Tab if mods.shift => b"\x1b[Z".to_vec(), + Key::Tab => b"\t".to_vec(), + Key::Escape => vec![0x1b], + Key::ArrowUp => arrow('A'), + Key::ArrowDown => arrow('B'), + Key::ArrowRight => arrow('C'), + Key::ArrowLeft => arrow('D'), + Key::Home => arrow('H'), + Key::End => arrow('F'), + Key::PageUp => tilde(5), + Key::PageDown => tilde(6), + Key::Insert => tilde(2), + Key::Delete => tilde(3), + Key::F1 => b"\x1bOP".to_vec(), + Key::F2 => b"\x1bOQ".to_vec(), + Key::F3 => b"\x1bOR".to_vec(), + Key::F4 => b"\x1bOS".to_vec(), + Key::F5 => tilde(15), + Key::F6 => tilde(17), + Key::F7 => tilde(18), + Key::F8 => tilde(19), + Key::F9 => tilde(20), + Key::F10 => tilde(21), + Key::F11 => tilde(23), + Key::F12 => tilde(24), + Key::Space if mods.ctrl => vec![0], + // Ctrl-A to Ctrl-Z (Ctrl-C included: it is the agent's, INVARIANT 4), + // Alt adding the escape; text typed with Alt is the key's too. + k if mods.ctrl || mods.alt => { + let name = k.name(); + let c = name.chars().next().filter(|_| name.chars().count() == 1)?; + if mods.ctrl && c.is_ascii_alphabetic() { + with_alt(vec![c.to_ascii_lowercase() as u8 - b'a' + 1]) + } else if mods.ctrl { + match c { + '[' => vec![0x1b], + '\\' => vec![0x1c], + ']' => vec![0x1d], + '/' => vec![0x1f], + _ => return None, + } + } else { + let c = if mods.shift { + c.to_ascii_uppercase() + } else { + c.to_ascii_lowercase() + }; + with_alt(c.to_string().into_bytes()) + } + } + _ => return None, + }) +} + +/// The 16 colors of the terminal's palette, dark or light. +fn ansi16(dark: bool) -> [Color32; 16] { + let c = |r, g, b| Color32::from_rgb(r, g, b); + if dark { + [ + c(0x1c, 0x1c, 0x22), + c(0xe0, 0x6c, 0x75), + c(0x7f, 0xc0, 0x6e), + c(0xe5, 0xc0, 0x7b), + c(0x61, 0xaf, 0xef), + c(0xc6, 0x78, 0xdd), + c(0x56, 0xb6, 0xc2), + c(0xc8, 0xcc, 0xd4), + c(0x5c, 0x63, 0x70), + c(0xf0, 0x8b, 0x93), + c(0x98, 0xd3, 0x87), + c(0xf0, 0xd0, 0x8f), + c(0x7b, 0xc0, 0xf8), + c(0xd8, 0x92, 0xea), + c(0x73, 0xcb, 0xd6), + c(0xf2, 0xf4, 0xf8), + ] + } else { + [ + c(0x1b, 0x1b, 0x1f), + c(0xb9, 0x1c, 0x1c), + c(0x15, 0x80, 0x3d), + c(0x8a, 0x5a, 0x00), + c(0x1d, 0x4e, 0xd8), + c(0x86, 0x25, 0xa8), + c(0x0e, 0x74, 0x90), + c(0x6b, 0x6b, 0x74), + c(0x4a, 0x4a, 0x52), + c(0xdc, 0x26, 0x26), + c(0x16, 0xa3, 0x4a), + c(0xa1, 0x62, 0x07), + c(0x25, 0x63, 0xeb), + c(0xa8, 0x55, 0xf7), + c(0x08, 0x91, 0xb2), + c(0x9c, 0x9c, 0xa4), + ] + } +} + +/// A color the terminal asks for, as the dock draws it. +fn color(c: TermColor, dark: bool, fg: Color32, bg: Color32, bold: bool) -> Color32 { + let table = ansi16(dark); + match c { + TermColor::Spec(Rgb { r, g, b }) => Color32::from_rgb(r, g, b), + TermColor::Indexed(i) => match i { + 0..=7 if bold => table[i as usize + 8], + 0..=15 => table[i as usize], + 16..=231 => { + let n = i - 16; + let level = |v: u8| if v == 0 { 0 } else { 55 + 40 * v }; + Color32::from_rgb(level(n / 36), level(n % 36 / 6), level(n % 6)) + } + _ => { + let v = 8 + 10 * (i - 232); + Color32::from_rgb(v, v, v) + } + }, + TermColor::Named(n) => match n { + NamedColor::Foreground | NamedColor::BrightForeground | NamedColor::Cursor => fg, + NamedColor::Background => bg, + NamedColor::DimForeground => fg.gamma_multiply(0.6), + n if (n as usize) < 8 && bold => table[n as usize + 8], + n if (n as usize) < 16 => table[n as usize], + n => { + // Dim black .. dim white follow the cursor color. + let i = (n as usize).saturating_sub(NamedColor::DimBlack as usize); + table[i.min(7)].gamma_multiply(0.7) + } + }, + } +} + +/// What the dock is doing. +enum State { + /// Closed or not started: nothing runs. + Idle, + /// The owner is finding the agent and its books home. + Starting(Receiver>), + /// The agent runs. + Running(Box), + /// The agent isn't installed. + Missing(Installer), + /// It couldn't be started. + Failed(String), +} + +/// The dock's state and look: one per window. +pub struct Dock { + /// Whether the panel is open (INVARIANT 6). + pub open: bool, + /// The agent chosen (INVARIANT 6). + pub agent: Agent, + state: State, + /// The book it was started for. + book: Option, + /// Has the keyboard (INVARIANT 4). + focused: bool, +} + +impl Default for Dock { + fn default() -> Self { + Self { + open: false, + agent: Agent::Claude, + state: State::Idle, + book: None, + focused: false, + } + } +} + +impl Dock { + /// The dock as kept: the `agent` key and the `open` flag. + pub fn restored(get: impl Fn(&str) -> Option) -> Dock { + Dock { + open: get("dock_open").is_some_and(|v| v == "true"), + agent: get("dock_agent") + .and_then(|k| Agent::from_key(&k)) + .unwrap_or(Agent::Claude), + ..Default::default() + } + } + + /// Keep the preferences. + pub fn keep(&self, set: &mut dyn FnMut(&str, String)) { + set("dock_open", self.open.to_string()); + set("dock_agent", self.agent.key().to_string()); + } + + /// Whether the dock has the keyboard: the viewer's own shortcuts wait. + pub fn has_keyboard(&self) -> bool { + self.focused && self.open + } + + /// The screen of the running agent, as text (tests). + pub fn screen(&self) -> Option { + match &self.state { + State::Running(s) => Some(s.text()), + _ => None, + } + } + + /// The running agent's terminal size (tests). + pub fn cells(&self) -> Option { + match &self.state { + State::Running(s) => Some(s.cells()), + _ => None, + } + } + + /// Send keystrokes to the running agent (tests). + pub fn send(&self, bytes: &'static [u8]) { + if let State::Running(s) = &self.state { + s.write(bytes); + } + } + + /// Start (or restart) the agent for `book`: found and its books home + /// made off the UI thread, then run. + fn start(&mut self, ctx: &egui::Context, owner: &Arc, book: &str) { + let (tx, rx) = channel(); + let (o, agent, b, ctx) = (owner.clone(), self.agent, book.to_string(), ctx.clone()); + std::thread::spawn(move || { + let _ = tx.send(o.agent_start(agent, &b)); + ctx.request_repaint(); + }); + self.state = State::Starting(rx); + self.book = Some(book.to_string()); + } + + /// The panel along the bottom of the window, for `book`'s agent. + pub fn show(&mut self, ui: &mut Ui, owner: &Arc, book: &str) { + let p = crate::theme::pal(ui.ctx()); + // Another book is shown: its own agent, not the last one's. + if self.book.as_deref().is_some_and(|b| b != book) { + self.state = State::Idle; + self.book = None; + } + // Starting finished? + if let State::Starting(rx) = &self.state + && let Ok(done) = rx.try_recv() + { + let ctx = ui.ctx().clone(); + self.state = match done { + Ok(AgentStart::Ready { + program, + args, + dir, + env, + .. + }) => { + let wake: Arc = Arc::new(move || ctx.request_repaint()); + match Session::spawn( + &program, + &args, + &dir, + &env, + Cells { + cols: 100, + rows: 24, + }, + wake, + ) { + Ok(s) => { + self.focused = true; + State::Running(Box::new(s)) + } + Err(why) => State::Failed(why), + } + } + Ok(AgentStart::Missing(i)) => State::Missing(i), + Err(why) => State::Failed(why), + }; + } + if self.open && matches!(self.state, State::Idle) { + self.start(ui.ctx(), owner, book); + } + let header = 34.0; + let panel = // Its own id open and closed: a size is remembered for each. + egui::Panel::bottom(if self.open { "dock-open" } else { "dock-closed" }).frame( + egui::Frame::new() + .fill(p.surface) + .stroke(egui::Stroke::new(1.0, p.border)), + ); + let panel = if self.open { + panel + .resizable(true) + .default_size(280.0) + .size_range(120.0..=2000.0) + } else { + panel.resizable(false).exact_size(header) + }; + panel.show(ui, |ui| { + // The panel keeps the size it has, whatever it holds just now. + if self.open { + ui.set_min_height(ui.available_height()); + } + self.header(ui, owner, book); + if !self.open { + return; + } + ui.add_space(0.0); + self.body(ui, &p); + }); + } + + fn header(&mut self, ui: &mut Ui, owner: &Arc, book: &str) { + let p = crate::theme::pal(ui.ctx()); + let mut restart = false; + ui.horizontal(|ui| { + ui.set_height(30.0); + ui.add_space(8.0); + let chevron = if self.open { + icon::CARET_DOWN + } else { + icon::CARET_UP + }; + if ui + .add(egui::Button::new(RichText::new(chevron).size(14.0)).frame(false)) + .on_hover_text(if self.open { + "Collapse the agent dock" + } else { + "Open the agent dock" + }) + .clicked() + { + self.open = !self.open; + } + ui.label( + RichText::new(format!("{} Agent", icon::TERMINAL_WINDOW)) + .font(crate::theme::semibold(12.5)) + .color(p.text), + ); + for agent in [Agent::Claude, Agent::Codex] { + if ui + .selectable_label(self.agent == agent, agent.label()) + .on_hover_text(format!("Run {} in this book's books home", agent.label())) + .clicked() + && self.agent != agent + { + self.agent = agent; + self.open = true; + restart = true; + } + } + if self.open { + let running = matches!(self.state, State::Running(_)); + let ended = matches!(&self.state, State::Running(s) if s.exited()); + if (ended || !running) + && !matches!(self.state, State::Starting(_)) + && ui.button("Start").clicked() + { + restart = true; + } + if running + && !ended + && ui + .button(icon::ARROW_CLOCKWISE) + .on_hover_text("Restart the agent") + .clicked() + { + restart = true; + } + if let State::Running(s) = &self.state { + let title = s.title(); + if !title.is_empty() { + ui.label(RichText::new(title).size(12.0).color(p.faint)); + } + } + } else { + ui.label( + RichText::new("Optional: any agent can use these books") + .size(12.0) + .color(p.faint), + ); + } + }); + if restart { + self.state = State::Idle; + self.start(ui.ctx(), owner, book); + } + } + + fn body(&mut self, ui: &mut Ui, p: &crate::theme::Palette) { + match &mut self.state { + State::Idle => {} + State::Starting(_) => { + ui.label(RichText::new("Starting the agent…").color(p.muted)); + } + State::Failed(why) => { + ui.add_space(6.0); + ui.colored_label(p.warning, format!("{} {why}", icon::WARNING)); + } + State::Missing(i) => { + ui.add_space(6.0); + ui.label( + RichText::new(format!("{} isn't installed here.", i.label)) + .font(crate::theme::semibold(13.0)), + ); + ui.label( + "Install it with its vendor's installer, in a terminal, then press Start:", + ); + ui.horizontal(|ui| { + ui.code(&i.command); + if ui.button(format!("{} Copy", icon::COPY)).clicked() { + ui.ctx().copy_text(i.command.clone()); + } + }); + ui.hyperlink_to(format!("{} instructions", i.label), &i.page); + ui.label( + RichText::new( + "You never need this: any agent you run yourself reaches the same books.", + ) + .color(p.faint), + ); + } + State::Running(session) => { + let (focused, _) = terminal(ui, session, p, self.focused); + self.focused = focused; + } + } + } +} + +/// The terminal's cell, in points. +fn cell_size(ui: &Ui, font: &FontId) -> egui::Vec2 { + let w = ui.ctx().fonts_mut(|f| f.glyph_width(font, 'M')); + let h = ui.ctx().fonts_mut(|f| f.row_height(font)); + vec2(w.max(1.0), h.max(1.0)) +} + +/// Draw the terminal in the space left, feed it the keyboard while it has +/// it, and keep its size the panel's (INVARIANT 5). Whether it has the +/// keyboard now. +fn terminal( + ui: &mut Ui, + session: &mut Session, + p: &crate::theme::Palette, + had: bool, +) -> (bool, Rect) { + let font = FontId::monospace(13.0); + let cell = cell_size(ui, &font); + let avail = ui.available_size(); + let (rect, response) = ui.allocate_exact_size(avail, Sense::click_and_drag()); + let id = response.id; + let dark = ui.visuals().dark_mode; + // The keyboard: given by a click in the terminal, kept until a click + // outside it (INVARIANT 4). + let mut focused = had; + if response.clicked() || response.drag_started() { + focused = true; + } else if ui.input(|i| i.pointer.any_pressed()) + && !ui + .input(|i| i.pointer.interact_pos()) + .is_some_and(|pos| rect.contains(pos)) + { + focused = false; + } + if focused { + ui.memory_mut(|m| { + m.request_focus(id); + m.set_focus_lock_filter( + id, + egui::EventFilter { + tab: true, + horizontal_arrows: true, + vertical_arrows: true, + escape: true, + }, + ); + }); + } + // Its size: the panel's, in cells. + let cols = ((rect.width() / cell.x).floor() as i64).clamp(2, 1000) as u16; + let rows = ((rect.height() / cell.y).floor() as i64).clamp(1, 500) as u16; + session.resize(Cells { cols, rows }); + + // The mouse: select with a drag, scroll with the wheel. + let point_at = |pos: Pos2, offset: usize| -> Point { + let col = + (((pos.x - rect.min.x) / cell.x).floor().max(0.0) as usize).min(cols as usize - 1); + let line = + (((pos.y - rect.min.y) / cell.y).floor().max(0.0) as usize).min(rows as usize - 1); + viewport_to_point(offset, Point::new(line, Column(col))) + }; + { + let mut term = session.term.lock(); + let offset = term.grid().display_offset(); + if let Some(pos) = response.interact_pointer_pos() { + if response.drag_started() { + term.selection = Some(Selection::new( + SelectionType::Simple, + point_at(pos, offset), + Side::Left, + )); + } else if response.dragged() + && let Some(sel) = term.selection.as_mut() + { + sel.update(point_at(pos, offset), Side::Right); + } else if response.clicked() { + term.selection = None; + } + } + if response.hovered() { + let wheel = ui.input(|i| i.smooth_scroll_delta.y); + let lines = (wheel / cell.y).round() as i32; + if lines != 0 { + term.scroll_display(Scroll::Delta(lines)); + } + } + } + + // The keyboard. + if focused { + let app_cursor = session.term.lock().mode().contains(TermMode::APP_CURSOR); + let bracketed = session + .term + .lock() + .mode() + .contains(TermMode::BRACKETED_PASTE); + let events = ui.input(|i| i.events.clone()); + let mods = ui.input(|i| i.modifiers); + for ev in events { + match ev { + Event::Text(t) if !mods.alt && !mods.ctrl => { + session.write(t.into_bytes()); + } + Event::Paste(t) => { + let t = t.replace("\r\n", "\r").replace('\n', "\r"); + if bracketed { + session.write(format!("\x1b[200~{t}\x1b[201~").into_bytes()); + } else { + session.write(t.into_bytes()); + } + } + Event::Key { + key, + pressed: true, + modifiers, + .. + } => { + // Ctrl-Shift-C copies the selection; Ctrl-V is a paste (its + // own event), anything else a program's. + if modifiers.ctrl && modifiers.shift && key == Key::C { + if let Some(text) = session.term.lock().selection_to_string() { + ui.ctx().copy_text(text); + } + } else if modifiers.ctrl && !modifiers.shift && key == Key::V { + } else if let Some(bytes) = key_bytes(key, modifiers, app_cursor) { + // Typing back to the bottom. + session.term.lock().scroll_display(Scroll::Bottom); + session.write(bytes); + } + } + _ => {} + } + } + } + + // The picture. + let painter = ui.painter_at(rect); + let (fg, bg) = (p.text, p.surface); + painter.rect_filled(rect, 0.0, bg); + let term = session.term.lock(); + let content = term.renderable_content(); + let offset = content.display_offset; + for cell_at in content.display_iter { + let Some(vp) = point_to_viewport(offset, cell_at.point) else { + continue; + }; + let flags = cell_at.cell.flags; + if flags.contains(Flags::WIDE_CHAR_SPACER) { + continue; + } + let bold = flags.contains(Flags::BOLD); + let mut cfg = color(cell_at.cell.fg, dark, fg, bg, bold); + let mut cbg = color(cell_at.cell.bg, dark, fg, bg, false); + if flags.contains(Flags::INVERSE) { + std::mem::swap(&mut cfg, &mut cbg); + } + if flags.contains(Flags::DIM) { + cfg = cfg.gamma_multiply(0.7); + } + let selected = content + .selection + .is_some_and(|sel| sel.contains(cell_at.point)); + if selected { + cbg = p.selected; + cfg = p.text; + } + let width = if flags.contains(Flags::WIDE_CHAR) { + 2.0 + } else { + 1.0 + }; + let top_left = rect.min + vec2(vp.column.0 as f32 * cell.x, vp.line as f32 * cell.y); + let r = Rect::from_min_size(top_left, vec2(cell.x * width, cell.y)); + if cbg != bg { + painter.rect_filled(r, 0.0, cbg); + } + let c = cell_at.cell.c; + if c != ' ' && c != '\0' && !flags.contains(Flags::HIDDEN) { + painter.text(top_left, egui::Align2::LEFT_TOP, c, font.clone(), cfg); + } + if flags.intersects(Flags::ALL_UNDERLINES) { + let y = r.bottom() - 1.0; + painter.line_segment( + [Pos2::new(r.left(), y), Pos2::new(r.right(), y)], + (1.0, cfg), + ); + } + } + // The cursor, a block that is hollow when the keyboard is elsewhere. + if !content.mode.contains(TermMode::SHOW_CURSOR) { + return (focused, rect); + } + if let Some(vp) = point_to_viewport(offset, content.cursor.point) { + let top_left = rect.min + vec2(vp.column.0 as f32 * cell.x, vp.line as f32 * cell.y); + let r = Rect::from_min_size(top_left, cell); + if focused { + painter.rect_filled(r, 0.0, p.accent.gamma_multiply(0.8)); + } else { + painter.rect_stroke(r, 0.0, (1.0, p.accent), egui::StrokeKind::Inside); + } + } + (focused, rect) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keys_become_what_a_terminal_sends() { + let none = Modifiers::NONE; + let ctrl = Modifiers::CTRL; + assert_eq!(key_bytes(Key::C, ctrl, false), Some(vec![3])); + assert_eq!(key_bytes(Key::D, ctrl, false), Some(vec![4])); + assert_eq!(key_bytes(Key::A, ctrl, false), Some(vec![1])); + assert_eq!(key_bytes(Key::Enter, none, false), Some(b"\r".to_vec())); + assert_eq!(key_bytes(Key::Backspace, none, false), Some(vec![0x7f])); + assert_eq!(key_bytes(Key::Escape, none, false), Some(vec![0x1b])); + assert_eq!( + key_bytes(Key::ArrowUp, none, false), + Some(b"\x1b[A".to_vec()) + ); + assert_eq!( + key_bytes(Key::ArrowUp, none, true), + Some(b"\x1bOA".to_vec()) + ); + assert_eq!( + key_bytes(Key::ArrowLeft, ctrl, true), + Some(b"\x1b[1;5D".to_vec()), + "a modifier takes the arrow out of application mode" + ); + assert_eq!( + key_bytes(Key::Delete, none, false), + Some(b"\x1b[3~".to_vec()) + ); + assert_eq!( + key_bytes(Key::Tab, Modifiers::SHIFT, false), + Some(b"\x1b[Z".to_vec()) + ); + assert_eq!( + key_bytes(Key::B, Modifiers::ALT, false), + Some(b"\x1bb".to_vec()) + ); + // Plain letters are typed text, not keys. + assert_eq!(key_bytes(Key::A, none, false), None); + assert_eq!(key_bytes(Key::Space, none, false), None); + } + + #[test] + fn colors_follow_the_terminals_palette() { + let (fg, bg) = (Color32::WHITE, Color32::BLACK); + let red = color(TermColor::Named(NamedColor::Red), true, fg, bg, false); + assert_eq!(red, ansi16(true)[1]); + let bold_red = color(TermColor::Named(NamedColor::Red), true, fg, bg, true); + assert_eq!(bold_red, ansi16(true)[9]); + assert_eq!( + color( + TermColor::Named(NamedColor::Foreground), + true, + fg, + bg, + false + ), + fg + ); + assert_eq!( + color( + TermColor::Spec(Rgb { r: 1, g: 2, b: 3 }), + true, + fg, + bg, + false + ), + Color32::from_rgb(1, 2, 3) + ); + // The 6x6x6 cube and the grays. + assert_eq!( + color(TermColor::Indexed(16), true, fg, bg, false), + Color32::from_rgb(0, 0, 0) + ); + assert_eq!( + color(TermColor::Indexed(231), true, fg, bg, false), + Color32::from_rgb(255, 255, 255) + ); + assert_eq!( + color(TermColor::Indexed(232), true, fg, bg, false), + Color32::from_rgb(8, 8, 8) + ); + } +} diff --git a/crates/ledger-gui/src/lib.rs b/crates/ledger-gui/src/lib.rs index 31c5d86..3f40ffa 100644 --- a/crates/ledger-gui/src/lib.rs +++ b/crates/ledger-gui/src/lib.rs @@ -9,6 +9,7 @@ pub mod books; pub mod charts; pub mod data; pub mod demo; +pub mod dock; pub mod export; pub mod fmt; pub mod menu; @@ -22,6 +23,7 @@ mod schemes; #[cfg(test)] mod test_support; mod theme; +pub mod welcome; mod widgets; pub use app::App; @@ -53,8 +55,10 @@ impl std::error::Error for Exit {} /// a book of the data root (plan 3.6), the only books it opens (D-44): /// `--root`, else the root `wanderbooks setup` made, never the current /// directory or a settings file; `--book` names one, else the root's only -/// one (or the first, by id). `--demo` builds a demo company in a temp -/// directory and opens that. +/// one (or the first, by id). With no books yet, and an owner to set them +/// up (`wanderbooks gui`), it opens the first-run page (welcome.rs) instead +/// of ending. `--demo` builds a demo company in a temp directory and opens +/// that. pub fn run( prog: &str, args: impl IntoIterator, @@ -94,17 +98,26 @@ pub fn run( eprintln!("demo books: {}", dir.display()); data_dir = Some(dir.join("books-data")); } + // The books to show, or a first run (no books yet, and the program gave + // the viewer an owner to set them up: plan P11b), or the reason not. + let mut first_run = None; let data_dir = match data_dir { - Some(d) => d, + Some(d) => Some(d), None => match book_in_root(root.as_deref(), book.as_deref()) { - Ok(d) => d, - Err(why) => { - eprintln!("{why}"); - return Err(Box::new(Exit(2))); - } + Ok(d) => Some(d), + Err(why) => match owner::current() { + // Named books that aren't there are a mistake, not a first run. + Some(owner) if book.is_none() => { + first_run = Some(owner.first_run()); + None + } + _ => { + eprintln!("{why}"); + return Err(Box::new(Exit(2))); + } + }, }, }; - let source = Source { data_dir, today }; let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_title("wanderbooks") @@ -121,7 +134,12 @@ pub fn run( Ok(eframe::run_native( "wanderbooks", options, - Box::new(move |cc| Ok(Box::new(App::new(cc, source)))), + Box::new(move |cc| { + Ok(Box::new(match (data_dir, first_run) { + (Some(data_dir), _) => App::new(cc, Source { data_dir, today }), + (None, state) => App::first_run(cc, root, today, state.unwrap_or_default()), + })) + }), )?) } diff --git a/crates/ledger-gui/src/owner.rs b/crates/ledger-gui/src/owner.rs index bee5031..53c20d8 100644 --- a/crates/ledger-gui/src/owner.rs +++ b/crates/ledger-gui/src/owner.rs @@ -47,7 +47,24 @@ pub enum Agent { } impl Agent { - fn label(self) -> &'static str { + /// The key it is kept under (the viewer's preference) and the word + /// the owner's commands know it by. + pub fn key(self) -> &'static str { + match self { + Agent::Claude => "claude", + Agent::Codex => "codex", + } + } + + /// The agent kept under `key`. + pub fn from_key(key: &str) -> Option { + [Agent::Claude, Agent::Codex] + .into_iter() + .find(|a| a.key() == key) + } + + /// Its name. + pub fn label(self) -> &'static str { match self { Agent::Claude => "Claude Code", Agent::Codex => "Codex", @@ -55,6 +72,51 @@ impl Agent { } } +/// Where a first run stands: what exists already (the viewer's welcome +/// page starts from the first missing step). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct FirstRun { + /// The books' private folder is there. + pub root: bool, + /// The ids of the books in it. + pub books: Vec, + /// The owner passphrase is set. + pub passphrase_set: bool, +} + +/// 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: String, + /// The vendor's install command, for a terminal. + pub command: String, + /// The vendor's page about it. + pub page: String, +} + +/// What it takes to run an agent in a books home (the dock runs it on a +/// terminal of its own; nothing here starts a process). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AgentStart { + /// Run `program args` in `dir` with `env` added. + Ready { + /// The agent's name. + label: String, + /// The program. + program: std::path::PathBuf, + /// Its arguments. + args: Vec, + /// The books home it starts in. + dir: std::path::PathBuf, + /// Environment to add. + env: Vec<(String, String)>, + }, + /// The agent isn't installed: the vendor's installer. + Missing(Installer), +} + /// A restore previewed: what it would do, and what it is of (INVARIANT 3). #[derive(Clone, Debug, PartialEq, Eq)] pub struct RestorePlan { @@ -95,6 +157,26 @@ pub trait Owner: Send + Sync { /// The owner's go-ahead for the books `book`'s format upgrade, with the /// owner passphrase they typed (the server checks it). fn upgrade_format(&self, book: &str, passphrase: &str) -> Result, String>; + /// Where a first run stands (P11b: onboarding with no terminal). + fn first_run(&self) -> FirstRun { + FirstRun::default() + } + /// Make the books' private folder (what `wanderbooks setup` does). + fn set_up(&self) -> Result, String> { + Err("not here".into()) + } + /// Set the owner passphrase for the first time, as typed (twice) in the + /// viewer. + fn set_passphrase(&self, _new: &str) -> Result<(), String> { + Err("not here".into()) + } + /// Hold the books `book` open while the viewer runs (the viewer's lease). + fn hold(&self, _book: &str) {} + /// How to run `agent` in the books home of `book` (made if need be): the + /// dock starts it on a terminal of its own (D-27). + fn agent_start(&self, _agent: Agent, _book: &str) -> Result { + Err("not here".into()) + } } static OWNER: OnceLock> = OnceLock::new(); @@ -104,6 +186,11 @@ pub fn set_owner(owner: Arc) { let _ = OWNER.set(owner); } +/// The viewer's owner, if the program gave it one. +pub fn current() -> Option> { + owner() +} + fn owner() -> Option> { OWNER.get().cloned() } diff --git a/crates/ledger-gui/src/welcome.rs b/crates/ledger-gui/src/welcome.rs new file mode 100644 index 0000000..9f69667 --- /dev/null +++ b/crates/ledger-gui/src/welcome.rs @@ -0,0 +1,460 @@ +//! The first run (plan P11b, found by P11a: "onboards and posts an entry +//! with no terminal" was false while the viewer opened only an existing +//! book and `setup` and `books create` needed a terminal): the page the +//! viewer shows when there are no books yet. It sets up the books' private +//! folder, asks for the owner passphrase, makes the first books and connects +//! an agent, each through the [`Owner`], then opens the books. +//! +//! ```text +//! GOAL: a person who installed the app and opened it, with no terminal +//! and no admin password, has books, an agent that can keep them and +//! the view of them, in minutes. +//! SOURCE: plan/served.md P11a As built (Done-when 1: the AppImage "onboards +//! and posts an entry with no terminal"), P11b; D-19 (the viewer's +//! "New books…" and "Connect" menu items; one app); D-27 (the books +//! home, the dock, never required); D-29 (no passphrase handling +//! beyond the owner's own: the owner passphrase is typed here and +//! handed to the owner, as the upgrade dialog does). +//! INVARIANTS: +//! 1. The page only asks the [`Owner`]; nothing here writes a file of the +//! books or of the data root. +//! 2. Each step is shown from what exists now ([`FirstRun`]): a step done +//! (by the command line, say) is not asked again, and the page opens at +//! the first step missing. +//! 3. The owner passphrase may be left for later (the books work without +//! it); an agent may be left for later, or never: any agent reaches the +//! books through the MCP. +//! 4. A passphrase typed is wiped from memory once handed over, or when the +//! page goes. +//! 5. Each step runs off the UI thread and says what came of it, in +//! words; a refusal stays on the page, with the step. +//! 6. The books are opened only once they exist. +//! ``` + +use crate::owner::{Agent, AgentStart, FirstRun, Owner}; +use egui::{RichText, Ui}; +use egui_phosphor::regular as icon; +use std::sync::Arc; +use std::sync::mpsc::{Receiver, channel}; + +/// The least an owner passphrase may be (the store's rule, said here). +pub const MIN_PASSPHRASE: usize = ledger_store::passphrase::MIN_CHARS; + +/// What a step's worker sends back: what it said (or why it was refused), +/// and where the first run stands now. +type Done = (Result, String>, FirstRun); + +/// The first-run page's state. +pub struct Welcome { + state: FirstRun, + /// The id and currency typed for the first books. + id: String, + currency: String, + /// The passphrase typed, twice. + pass: String, + again: String, + /// The passphrase step was left for later. + pass_later: bool, + /// The agents asked for, and what came of each. + agents: Vec<(Agent, Result, String>)>, + /// The agents may be left for later. + agent_later: bool, + /// A step under way: what it is, the agent it connects (if it does), + /// and its answer. + job: Option<(String, Option, Receiver)>, + /// What the last step said or why it was refused. + said: Option, String>>, + /// The books to open, once the person says. + open: Option, +} + +impl Drop for Welcome { + fn drop(&mut self) { + zeroize::Zeroize::zeroize(&mut self.pass); + zeroize::Zeroize::zeroize(&mut self.again); + } +} + +impl Welcome { + /// The page for a first run that stands at `state`. + pub fn new(state: FirstRun) -> Self { + Self { + state, + id: String::new(), + currency: "USD".into(), + pass: String::new(), + again: String::new(), + pass_later: false, + agents: Vec::new(), + agent_later: false, + job: None, + said: None, + open: None, + } + } + + /// Where the first run stands. + pub fn state(&self) -> &FirstRun { + &self.state + } + + /// The books the person chose to open, once. + pub fn take_open(&mut self) -> Option { + self.open.take() + } + + /// Show why the books could not be opened. + pub fn refused(&mut self, why: String) { + self.said = Some(Err(why)); + } + + fn run( + &mut self, + ctx: &egui::Context, + what: &str, + agent: Option, + owner: &Arc, + work: impl FnOnce(&dyn Owner) -> Result, String> + Send + 'static, + ) { + let (tx, rx) = channel(); + let (o, ctx) = (owner.clone(), ctx.clone()); + std::thread::spawn(move || { + let done = work(o.as_ref()); + let _ = tx.send((done, o.first_run())); + ctx.request_repaint(); + }); + self.job = Some((what.to_string(), agent, rx)); + self.said = None; + } + + /// The page, in the window's central area. + pub fn show(&mut self, ui: &mut Ui, owner: &Arc) { + let p = crate::theme::pal(ui.ctx()); + if let Some((_, agent, rx)) = &self.job { + let agent = *agent; + match rx.try_recv() { + Ok((done, state)) => { + self.job = None; + self.state = state; + if let Some(agent) = agent { + self.agents.retain(|(a, _)| *a != agent); + self.agents.push((agent, done.clone())); + } + self.said = Some(done); + } + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + self.job = None; + self.said = Some(Err( + "it ended without an answer: look again at what exists, and try again" + .into(), + )); + self.state = owner.first_run(); + } + Err(std::sync::mpsc::TryRecvError::Empty) => {} + } + } + let busy = self.job.is_some(); + egui::ScrollArea::vertical().show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.set_max_width(640.0); + ui.add_space(36.0); + ui.label(RichText::new("Welcome to wanderbooks").font(crate::theme::bold(28.0))); + ui.label( + RichText::new( + "Your company's books, kept by an agent and checked by you. A few short steps; nothing here needs a terminal.", + ) + .color(p.muted), + ); + ui.add_space(18.0); + ui.with_layout(egui::Layout::top_down(egui::Align::Min), |ui| { + self.steps(ui, owner, busy); + }); + }); + }); + } + + /// A step's frame: its number, title and whether it is done. + fn card( + ui: &mut Ui, + n: usize, + title: &str, + done: bool, + active: bool, + body: impl FnOnce(&mut Ui), + ) { + let p = crate::theme::pal(ui.ctx()); + egui::Frame::new() + .fill(p.surface) + .stroke(egui::Stroke::new( + 1.0, + if active { p.accent } else { p.border }, + )) + .corner_radius(10) + .inner_margin(egui::Margin::symmetric(18, 14)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + let (mark, color) = if done { + (icon::CHECK_CIRCLE.to_string(), p.positive) + } else { + (format!("{n}."), if active { p.accent } else { p.faint }) + }; + ui.label( + RichText::new(mark) + .font(crate::theme::semibold(16.0)) + .color(color), + ); + ui.label(RichText::new(title).font(crate::theme::semibold(16.0))); + }); + if active || done { + body(ui); + } + }); + ui.add_space(10.0); + } + + fn steps(&mut self, ui: &mut Ui, owner: &Arc, busy: bool) { + let p = crate::theme::pal(ui.ctx()); + let st = self.state.clone(); + let have_books = !st.books.is_empty(); + // The first step missing is the one asked for (INVARIANT 2). + let now = if !st.root { + 1 + } else if !st.passphrase_set && !self.pass_later { + 2 + } else if !have_books { + 3 + } else if self.agents.iter().all(|(_, r)| r.is_err()) && !self.agent_later { + 4 + } else { + 5 + }; + + // 1. The private folder. + Self::card( + ui, + 1, + "A private place for your books", + st.root, + now == 1, + |ui| { + if st.root { + ui.label(RichText::new("Made. Only your account can open it.").color(p.muted)); + } else { + ui.label("Wanderbooks keeps your books in a folder that only your account can open. Your agent reaches them through the app, never by looking in the folder."); + if ui + .add_enabled(!busy, egui::Button::new("Set it up")) + .clicked() + { + let ctx = ui.ctx().clone(); + self.run(&ctx, "Setting up", None, owner, |o| o.set_up()); + } + } + }, + ); + + // 2. The owner passphrase. + Self::card( + ui, + 2, + "Your owner passphrase", + st.passphrase_set, + now == 2, + |ui| { + if st.passphrase_set { + ui.label(RichText::new("Set.").color(p.muted)); + return; + } + ui.label(format!( + "The books ask for it before anything only you may do, like moving them to a newer format. Pick one you will remember: {MIN_PASSPHRASE} characters or more. It is not kept anywhere you can read." + )); + ui.add_enabled( + !busy, + egui::TextEdit::singleline(&mut self.pass) + .password(true) + .hint_text("passphrase") + .desired_width(f32::INFINITY), + ); + ui.add_enabled( + !busy, + egui::TextEdit::singleline(&mut self.again) + .password(true) + .hint_text("the same again") + .desired_width(f32::INFINITY), + ); + let long = self.pass.chars().count() >= MIN_PASSPHRASE; + let same = self.pass == self.again; + if !self.pass.is_empty() && !long { + ui.colored_label( + p.warning, + format!("{MIN_PASSPHRASE} characters or more, please."), + ); + } else if !self.again.is_empty() && !same { + ui.colored_label(p.warning, "The two don't match."); + } + ui.horizontal(|ui| { + if ui + .add_enabled(!busy && long && same, egui::Button::new("Set passphrase")) + .clicked() + { + // Handed over, and gone from the page (INVARIANT 4). + let typed = zeroize::Zeroizing::new(std::mem::take(&mut self.pass)); + zeroize::Zeroize::zeroize(&mut self.again); + let ctx = ui.ctx().clone(); + self.run(&ctx, "Setting the passphrase", None, owner, move |o| { + o.set_passphrase(&typed) + .map(|()| vec!["The owner passphrase is set.".to_string()]) + }); + } + if ui + .add_enabled(!busy, egui::Button::new("Not now")) + .on_hover_text( + "The books work without it; `wanderbooks passphrase` sets it later", + ) + .clicked() + { + self.pass_later = true; + } + }); + }, + ); + + // 3. The first books. + let ready = st.root; + Self::card( + ui, + 3, + "Your first books", + have_books, + ready && now == 3, + |ui| { + if have_books { + ui.label( + RichText::new(format!("Made: {}.", st.books.join(", "))).color(p.muted), + ); + return; + } + ui.label("A short name for the books: lowercase letters, digits and hyphens (your company's name, say). You can name the company itself afterwards, with your agent."); + ui.add_enabled( + !busy, + egui::TextEdit::singleline(&mut self.id) + .hint_text("my-company") + .desired_width(f32::INFINITY), + ); + ui.label("Their base currency (fixed for good):"); + ui.add_enabled( + !busy, + egui::TextEdit::singleline(&mut self.currency).desired_width(120.0), + ); + let ok = !self.id.trim().is_empty() && !self.currency.trim().is_empty(); + if ui + .add_enabled(!busy && ok, egui::Button::new("Make the books")) + .clicked() + { + let (id, cur) = (self.id.trim().to_string(), self.currency.trim().to_string()); + let ctx = ui.ctx().clone(); + self.run(&ctx, "Making the books", None, owner, move |o| { + let made = o.new_books(&id, &cur)?; + // Held open while the viewer runs. + o.hold(&id); + Ok(vec![made]) + }); + } + }, + ); + + // 4. An agent. + let book = st.books.first().cloned(); + let step4_active = have_books && now == 4; + let agents = self.agents.clone(); + Self::card( + ui, + 4, + "An agent to keep them", + agents.iter().any(|(_, r)| r.is_ok()), + step4_active, + |ui| { + ui.label("An agent (Claude Code or Codex) records your transactions, checks receipts and answers questions, through the books' own tools. This connects it; the app also has a panel along the bottom of its window that runs it for you. You never need either: any agent you run yourself can reach the same books."); + for (agent, result) in &agents { + match result { + Ok(lines) => { + ui.label(RichText::new(lines.join(" ")).color(p.positive)); + } + Err(why) => { + ui.colored_label(p.warning, format!("{}: {why}", agent.label())); + } + } + } + let Some(book) = book.clone() else { + return; + }; + ui.horizontal(|ui| { + for agent in [Agent::Claude, Agent::Codex] { + if ui + .add_enabled( + !busy, + egui::Button::new(format!("Connect {}", agent.label())), + ) + .clicked() + { + let b = book.clone(); + let ctx = ui.ctx().clone(); + self.run(&ctx, "Connecting", Some(agent), owner, move |o| { + match o.connect(agent, &b) { + Ok(lines) => Ok(lines), + // Not installed: how to get it. + Err(why) => match o.agent_start(agent, &b) { + Ok(AgentStart::Missing(i)) => Err(format!( + "{why}. Install it with `{}` in a terminal ({}), then connect it here.", + i.command, i.page + )), + _ => Err(why), + }, + } + }); + } + } + if ui + .add_enabled(!busy, egui::Button::new("I'll use my own agent")) + .clicked() + { + self.agent_later = true; + } + }); + }, + ); + + // 5. Open. + if have_books { + let id = st.books[0].clone(); + ui.add_space(4.0); + ui.horizontal(|ui| { + if ui + .add_enabled( + !busy, + egui::Button::new( + RichText::new(format!("{} Open the books", icon::BOOK_OPEN)) + .font(crate::theme::semibold(14.0)), + ) + .min_size(egui::vec2(180.0, 36.0)), + ) + .clicked() + { + self.open = Some(id); + } + }); + } + + // What the last step said. + if let Some((what, _, _)) = &self.job { + ui.add_space(8.0); + ui.label(RichText::new(format!("{what}…")).color(p.muted)); + } else if let Some(said) = &self.said { + ui.add_space(8.0); + match said { + Ok(lines) => ui.label(RichText::new(lines.join(" ")).color(p.muted)), + Err(why) => ui.colored_label(p.warning, format!("{} {why}", icon::WARNING)), + }; + } + } +} diff --git a/crates/ledger-gui/tests/dock.rs b/crates/ledger-gui/tests/dock.rs new file mode 100644 index 0000000..12738d0 --- /dev/null +++ b/crates/ledger-gui/tests/dock.rs @@ -0,0 +1,260 @@ +//! The agent dock (dock.rs, plan P11b, D-27): it runs the chosen agent in +//! the book's books home, the agent's output reaches the panel, keys and +//! Ctrl-C reach the agent, a resize redraws; an agent that isn't installed +//! is offered through its vendor's installer; the dock writes nothing of +//! the books. Linux and macOS (a PTY); Windows is not offered. + +#![cfg(unix)] + +use chrono::NaiveDate; +use egui::{Key, Modifiers}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use ledger_core::Currency; +use ledger_gui::App; +use ledger_gui::data::{self, Source}; +use ledger_gui::nav::Route; +use ledger_gui::owner::{Agent, AgentStart, Installer, Owner, RestorePlan}; +use ledger_store::Store; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +/// An owner that only answers `agent_start`, and remembers what it was asked. +#[derive(Default)] +struct Fake { + answer: Mutex>>, + asked: Mutex>, +} + +impl Owner for Fake { + fn close_books(&self) -> Result { + Err("not here".into()) + } + fn new_books(&self, _: &str, _: &str) -> Result { + Err("not here".into()) + } + fn back_up_now(&self, _: &str) -> Result, String> { + Err("not here".into()) + } + fn plan_restore(&self, _: &str, _: Option, _: bool) -> Result { + Err("not here".into()) + } + fn restore(&self, _: &RestorePlan) -> Result, String> { + Err("not here".into()) + } + fn connect(&self, _: Agent, _: &str) -> Result, String> { + Err("not here".into()) + } + fn upgrade_format(&self, _: &str, _: &str) -> Result, String> { + Err("not here".into()) + } + fn agent_start(&self, agent: Agent, book: &str) -> Result { + self.asked.lock().unwrap().push(format!("{agent:?} {book}")); + self.answer + .lock() + .unwrap() + .clone() + .unwrap_or_else(|| Err("no answer set".into())) + } +} + +/// One test at a time: they share the one owner. +fn alone() -> std::sync::MutexGuard<'static, ()> { + static ONE: Mutex<()> = Mutex::new(()); + ONE.lock().unwrap_or_else(|e| e.into_inner()) +} + +fn fake() -> Arc { + static FAKE: OnceLock> = OnceLock::new(); + FAKE.get_or_init(|| { + let f = Arc::new(Fake::default()); + ledger_gui::owner::set_owner(f.clone()); + f + }) + .clone() +} + +/// Books in a data root, named `acme`. +fn books() -> (tempfile::TempDir, PathBuf, ledger_gui::books::Books) { + let dir = tempfile::Builder::new() + .prefix("wb-gui-dock-") + .tempdir() + .unwrap(); + std::fs::write(dir.path().join(ledger_store::root::MARKER_FILE), b"").unwrap(); + let book = dir.path().join("books").join("acme"); + std::fs::create_dir_all(&book).unwrap(); + drop(Store::create(&book, Currency::USD).unwrap()); + let b = data::load_once(&Source { + data_dir: book.clone(), + today: NaiveDate::from_ymd_opt(2026, 10, 15), + }) + .unwrap(); + (dir, book, b) +} + +/// A stand-in `claude`: says where it is, answers a typed line, and reports +/// Ctrl-C and a resize (`stty size`) without ending. +fn fake_claude(dir: &Path) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + let path = dir.join("claude"); + std::fs::write( + &path, + r#"#!/bin/bash +echo "fake-agent in $(basename "$(pwd)")" +trap 'echo got-interrupt' INT +trap 'echo "size $(stty size)"' WINCH +echo ready +while true; do + if read -r -t 0.1 line; then echo "you-said: $line"; fi +done +"#, + ) + .unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + path +} + +fn harness(b: ledger_gui::books::Books) -> Harness<'static, App> { + let mut h = Harness::builder() + .with_size(egui::vec2(1200.0, 900.0)) + .build_eframe(|cc| App::with_books(&cc.egui_ctx, b, false, Route::Overview)); + h.run_steps(3); + h +} + +/// SHOT_DIR set: the window as drawn, to look at. +fn shot(h: &mut Harness<'static, App>, name: &str) { + if let Ok(out) = std::env::var("SHOT_DIR") { + let out = PathBuf::from(out); + std::fs::create_dir_all(&out).unwrap(); + h.render() + .unwrap() + .save(out.join(format!("{name}.png"))) + .unwrap(); + } +} + +/// Run until the dock's screen holds `want`. +fn until(h: &mut Harness<'static, App>, want: &str) -> String { + let end = Instant::now() + Duration::from_secs(20); + loop { + h.run_steps(2); + let screen = h.state_mut().dock_mut().screen().unwrap_or_default(); + if screen.contains(want) { + return screen; + } + assert!( + Instant::now() < end, + "the dock never showed {want:?}; it shows:\n{screen}" + ); + std::thread::sleep(Duration::from_millis(20)); + } +} + +#[test] +fn dock_runs_the_chosen_agent() { + let _alone = alone(); + let f = fake(); + let tools = tempfile::tempdir().unwrap(); + let home = tempfile::tempdir().unwrap(); + let home = home.path().join("acme"); + std::fs::create_dir_all(&home).unwrap(); + *f.answer.lock().unwrap() = Some(Ok(AgentStart::Ready { + label: "Claude Code".into(), + program: fake_claude(tools.path()), + args: vec![], + dir: home.clone(), + env: vec![("TERM".into(), "xterm-256color".into())], + })); + let (_dir, book, b) = books(); + let before = std::fs::read(book.join("journal.jsonl")).ok(); + let mut h = harness(b); + assert!( + h.state_mut().dock_mut().screen().is_none(), + "nothing runs until the dock is opened" + ); + + // Opened: the chosen agent (Claude Code, the default) is started for + // this book, in its books home; its output reaches the panel. + h.state_mut().dock_mut().open = true; + let screen = until(&mut h, "ready"); + assert!(f.asked.lock().unwrap().contains(&"Claude acme".to_string())); + assert!(screen.contains("fake-agent in acme"), "{screen}"); + + // Typed text reaches it (and the terminal shows it, as a terminal does). + h.event(egui::Event::Text("hello".into())); + h.key_press(Key::Enter); + until(&mut h, "you-said: hello"); + + // Ctrl-C reaches the agent as an interrupt, and it goes on. + h.key_press_modifiers(Modifiers::CTRL, Key::C); + until(&mut h, "got-interrupt"); + + // A resize redraws: the program is told the new size. + let before_size = h.state_mut().dock_mut().cells().unwrap(); + h.set_size(egui::vec2(800.0, 900.0)); + h.run_steps(4); + let after = h.state_mut().dock_mut().cells().unwrap(); + assert!( + after.cols < before_size.cols, + "{before_size:?} -> {after:?}" + ); + until(&mut h, &format!("size {} {}", after.rows, after.cols)); + + shot(&mut h, "dock-running"); + + // Closing the window ends the agent with it (the session is dropped). + drop(h); + // The dock wrote nothing of the books. + assert_eq!(std::fs::read(book.join("journal.jsonl")).ok(), before); +} + +#[test] +fn agent_missing_offers_the_installer() { + let _alone = alone(); + let f = fake(); + *f.answer.lock().unwrap() = Some(Ok(AgentStart::Missing(Installer { + label: "Claude Code".into(), + command: "curl -fsSL https://claude.ai/install.sh | bash".into(), + page: "https://code.claude.com/docs/en/setup".into(), + }))); + let (_dir, _book, b) = books(); + let mut h = harness(b); + h.state_mut().dock_mut().open = true; + for _ in 0..100 { + h.run_steps(2); + if h.query_by_label_contains("isn't installed here").is_some() { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + h.get_by_label_contains("Claude Code isn't installed here"); + h.get_by_label_contains("curl -fsSL https://claude.ai/install.sh | bash"); + h.get_by_label_contains("Copy"); + shot(&mut h, "dock-missing"); + // Nothing runs; nothing was installed by the app. + assert!(h.state_mut().dock_mut().screen().is_none()); +} + +#[test] +fn a_chosen_agent_and_an_open_dock_are_kept() { + let _alone = alone(); + fake(); + // Preferences: the agent and whether it is open are the viewer's. + let kept = |k: &str| match k { + "dock_agent" => Some("codex".to_string()), + "dock_open" => Some("true".to_string()), + _ => None, + }; + let dock = ledger_gui::dock::Dock::restored(kept); + assert_eq!(dock.agent, Agent::Codex); + assert!(dock.open); + let mut seen = Vec::new(); + dock.keep(&mut |k, v| seen.push((k.to_string(), v))); + assert!(seen.contains(&("dock_agent".to_string(), "codex".to_string()))); + assert!(seen.contains(&("dock_open".to_string(), "true".to_string()))); + let none = ledger_gui::dock::Dock::restored(|_| None); + assert_eq!(none.agent, Agent::Claude, "Claude Code is the default"); + assert!(!none.open); +} diff --git a/crates/ledger-gui/tests/welcome.rs b/crates/ledger-gui/tests/welcome.rs new file mode 100644 index 0000000..05fdb0c --- /dev/null +++ b/crates/ledger-gui/tests/welcome.rs @@ -0,0 +1,323 @@ +//! The first-run page (welcome.rs, plan P11b): with no books yet, the viewer +//! sets up the private folder, asks for the owner passphrase, makes the first +//! books, connects an agent and opens the books, each through the owner and +//! none needing a terminal. + +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use ledger_core::Currency; +use ledger_gui::App; +use ledger_gui::owner::{Agent, FirstRun, Owner, RestorePlan}; +use ledger_store::root::{BookId, Root}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Duration; + +/// An owner over a real data root in a scratch folder, that records what it +/// was asked (the first run's steps), and refuses what it should. +#[derive(Default)] +struct Fake { + place: Mutex>, + asked: Mutex>, +} + +impl Fake { + fn reset(&self, home: PathBuf, root: PathBuf) { + *self.place.lock().unwrap() = Some((home, root)); + self.asked.lock().unwrap().clear(); + } + fn said(&self, what: String) { + self.asked.lock().unwrap().push(what); + } + fn root_path(&self) -> (PathBuf, PathBuf) { + self.place.lock().unwrap().clone().unwrap() + } +} + +impl Owner for Fake { + fn close_books(&self) -> Result { + Err("not here".into()) + } + fn new_books(&self, id: &str, currency: &str) -> Result { + self.said(format!("new {id} {currency}")); + let (_, root) = self.root_path(); + let root = Root::find(Some(&root)).map_err(|e| e.to_string())?; + let id = BookId::parse(id).map_err(|e| e.to_string())?; + root.create_book(&id, Currency::new(currency).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + Ok(format!("Made new books \"{id}\" ({currency}).")) + } + fn back_up_now(&self, _: &str) -> Result, String> { + Err("not here".into()) + } + fn plan_restore(&self, _: &str, _: Option, _: bool) -> Result { + Err("not here".into()) + } + fn restore(&self, _: &RestorePlan) -> Result, String> { + Err("not here".into()) + } + fn connect(&self, agent: Agent, book: &str) -> Result, String> { + self.said(format!("connect {agent:?} {book}")); + match agent { + Agent::Claude => Ok(vec!["Claude Code reaches the books.".into()]), + Agent::Codex => Err("Codex isn't installed here".into()), + } + } + fn upgrade_format(&self, _: &str, _: &str) -> Result, String> { + Err("not here".into()) + } + fn first_run(&self) -> FirstRun { + let (_, root) = self.root_path(); + let Ok(found) = Root::find(Some(&root)) else { + return FirstRun::default(); + }; + FirstRun { + root: true, + books: found + .list_books() + .unwrap() + .iter() + .map(|b| b.as_str().to_string()) + .collect(), + passphrase_set: ledger_store::passphrase::is_set(&found.auth_dir().unwrap()).unwrap(), + } + } + fn set_up(&self) -> Result, String> { + self.said("set up".into()); + let (home, root) = self.root_path(); + Root::setup(&home, Some(&root), None).map_err(|e| e.to_string())?; + Ok(vec!["Made the books' private folder.".into()]) + } + fn set_passphrase(&self, new: &str) -> Result<(), String> { + self.said(format!("passphrase of {} characters", new.chars().count())); + let (_, root) = self.root_path(); + let found = Root::find(Some(&root)).map_err(|e| e.to_string())?; + ledger_store::passphrase::set(&found.auth_dir().unwrap(), new, None) + .map_err(|e| e.to_string()) + } + fn hold(&self, book: &str) { + self.said(format!("hold {book}")); + } +} + +/// One test at a time: they share the one owner. +fn alone() -> std::sync::MutexGuard<'static, ()> { + static ONE: Mutex<()> = Mutex::new(()); + ONE.lock().unwrap_or_else(|e| e.into_inner()) +} + +fn fake() -> Arc { + static FAKE: OnceLock> = OnceLock::new(); + FAKE.get_or_init(|| { + let f = Arc::new(Fake::default()); + ledger_gui::owner::set_owner(f.clone()); + f + }) + .clone() +} + +fn harness(root: PathBuf, state: FirstRun) -> Harness<'static, App> { + let mut h = Harness::builder() + .with_size(egui::vec2(1000.0, 1100.0)) + .build_eframe(move |cc| { + App::first_run( + cc, + Some(root.clone()), + chrono::NaiveDate::from_ymd_opt(2026, 10, 15), + state.clone(), + ) + }); + h.run_steps(3); + h +} + +/// Run until `f` holds (a step's worker answered). +fn until(h: &mut Harness<'static, App>, what: &str, f: impl Fn(&Harness<'static, App>) -> bool) { + for _ in 0..300 { + h.run_steps(2); + if f(h) { + return; + } + std::thread::sleep(Duration::from_millis(10)); + } + shot(h, "welcome-stuck"); + panic!("never: {what}"); +} + +fn shot(h: &mut Harness<'static, App>, name: &str) { + if let Ok(out) = std::env::var("SHOT_DIR") { + let out = PathBuf::from(out); + std::fs::create_dir_all(&out).unwrap(); + h.render() + .unwrap() + .save(out.join(format!("{name}.png"))) + .unwrap(); + } +} + +fn scratch() -> (tempfile::TempDir, PathBuf, PathBuf) { + let dir = tempfile::Builder::new() + .prefix("wb-gui-welcome-") + .tempdir() + .unwrap(); + let home = dir.path().join("home"); + std::fs::create_dir_all(&home).unwrap(); + let root = dir.path().join("data"); + (dir, home, root) +} + +#[test] +fn first_run_onboards_with_no_terminal() { + let _alone = alone(); + let f = fake(); + let (_dir, home, root) = scratch(); + f.reset(home, root.clone()); + let mut h = harness(root.clone(), FirstRun::default()); + shot(&mut h, "welcome-1"); + + // 1. The private folder: set up from the page. + h.get_by_label("Set it up").click(); + until(&mut h, "the folder is made", |h| { + h.query_by_label_contains("Made. Only your account") + .is_some() + }); + assert!(h.state().welcome().unwrap().state().root); + + // 2. The passphrase: typed twice, handed to the owner, gone from the page. + assert_eq!( + h.query_all_by_role(egui::accesskit::Role::PasswordInput) + .count(), + 2, + "two password fields" + ); + for i in 0..2 { + h.get_all_by_role(egui::accesskit::Role::PasswordInput) + .nth(i) + .unwrap() + .focus(); + h.run_steps(2); + h.get_all_by_role(egui::accesskit::Role::PasswordInput) + .nth(i) + .unwrap() + .type_text("a long enough phrase"); + h.run_steps(2); + } + shot(&mut h, "welcome-2"); + h.get_by_label("Set passphrase").click(); + until(&mut h, "the passphrase is set", |h| { + h.state().welcome().unwrap().state().passphrase_set + }); + + // 3. The first books. + h.get_all_by_role(egui::accesskit::Role::TextInput) + .next() + .unwrap() + .focus(); + h.run_steps(2); + h.get_all_by_role(egui::accesskit::Role::TextInput) + .next() + .unwrap() + .type_text("acme"); + h.run_steps(2); + h.get_by_label("Make the books").click(); + until(&mut h, "the books are made", |h| { + h.state().welcome().unwrap().state().books == ["acme"] + }); + shot(&mut h, "welcome-3"); + + // 4. An agent: Claude Code connects; Codex isn't installed, and says so. + h.get_by_label("Connect Codex").click(); + until(&mut h, "Codex is refused", |h| { + h.query_all_by_label_contains("Codex isn't installed here") + .next() + .is_some() + }); + h.get_by_label("Connect Claude Code").click(); + until(&mut h, "Claude Code is connected", |h| { + h.query_all_by_label_contains("Claude Code reaches the books") + .next() + .is_some() + }); + shot(&mut h, "welcome-4"); + + // 5. Open the books: the viewer shows them. + h.get_by_label_contains("Open the books").click(); + until(&mut h, "the books are shown", |h| { + h.query_by_label_contains("Needs attention").is_some() + }); + assert!(h.state().welcome().is_none(), "the page is gone"); + shot(&mut h, "welcome-5"); + + // What the owner was asked, in order, and nothing else. + let asked = f.asked.lock().unwrap().clone(); + assert_eq!( + asked, + [ + "set up", + "passphrase of 20 characters", + "new acme USD", + "hold acme", + "connect Codex acme", + "connect Claude acme", + ] + ); +} + +#[test] +fn first_run_starts_at_the_first_step_missing_and_asks_nothing_done_again() { + let _alone = alone(); + let f = fake(); + let (_dir, home, root) = scratch(); + f.reset(home.clone(), root.clone()); + // Done at a terminal already: the folder and the passphrase. + f.set_up().unwrap(); + f.set_passphrase("a long enough phrase").unwrap(); + f.asked.lock().unwrap().clear(); + let state = f.first_run(); + assert_eq!( + state, + FirstRun { + root: true, + books: vec![], + passphrase_set: true + } + ); + let h = harness(root, state); + // No folder or passphrase to ask for; the books are. + assert!(h.query_by_label("Set it up").is_none()); + assert!(h.query_by_label("Set passphrase").is_none()); + h.get_by_label("Make the books"); + assert!(f.asked.lock().unwrap().is_empty()); +} + +#[test] +fn a_passphrase_too_short_or_not_repeated_is_not_sent_and_can_wait() { + let _alone = alone(); + let f = fake(); + let (_dir, home, root) = scratch(); + f.reset(home, root.clone()); + f.set_up().unwrap(); + f.asked.lock().unwrap().clear(); + let mut h = harness(root, f.first_run()); + h.get_all_by_role(egui::accesskit::Role::PasswordInput) + .next() + .unwrap() + .focus(); + h.run_steps(2); + h.get_all_by_role(egui::accesskit::Role::PasswordInput) + .next() + .unwrap() + .type_text("short"); + h.run_steps(3); + h.get_by_label_contains("characters or more, please"); + // Too short: the button does nothing. + h.get_by_label("Set passphrase").click(); + h.run_steps(3); + assert!(f.asked.lock().unwrap().is_empty()); + // Not now: the books are asked for next, and nothing was sent. + h.get_by_label("Not now").click(); + h.run_steps(3); + h.get_by_label("Make the books"); + assert!(f.asked.lock().unwrap().is_empty()); + assert!(!f.first_run().passphrase_set); +} diff --git a/crates/ledger-host/src/commands.rs b/crates/ledger-host/src/commands.rs index e6dc090..144cec1 100644 --- a/crates/ledger-host/src/commands.rs +++ b/crates/ledger-host/src/commands.rs @@ -1355,14 +1355,11 @@ pub fn installer(agent: &str) -> Option { } } -/// 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). +/// The folder holding the user's documents: `Documents` in the home (the +/// word the spec uses on every system this program runs on; nothing in the +/// environment moves it, so a test's own `HOME` is all a test needs). 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"))) + home_dir_env().map(|h| h.join("Documents")) } /// The books home of `book` (D-27): a visible folder, diff --git a/docs/architecture.md b/docs/architecture.md index 9e0572c..0f84a63 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -36,8 +36,8 @@ the specification in `bookspec/` (which never depends on the engine). | `ledger-import` | formats: QuickBooks CSV in; IIF, XLSX, Typst PDFs (statements, the invoice, labels, the audit workpaper) out | `ledger-core`, `ledger-reader` | | `ledger-package` | the shared read-only view over `Store` or `Snapshot` (`PackageBooks`): the CPA package, the 1099 worksheet, the invoice document, Needs attention | `ledger-core`, `ledger-store`, `ledger-config`, `ledger-import`, `ledger-vault` | | `ledger-mcp` | the books server: the MCP tools (122), resources, the handbook, scopes, the door and the access log, backups' scheduling | all of the above | -| `ledger-host` | the host and its leases, the agent bridge (`wanderbooks mcp`), tokens in 0600 files under the data root (D-63), the owner's commands, uploads and downloads, `update` and `rollback` (P11a) | `ledger-store`, `ledger-mcp`, `ledger-config` | -| `ledger-gui` | the viewer: read-only, live, egui; the Books menu hands work to the server | `ledger-core`, `ledger-store`, `ledger-config`, `ledger-import`, `ledger-package`, `ledger-vault`, `ledger-host` | +| `ledger-host` | the host and its leases, the agent bridge (`wanderbooks mcp`), tokens in 0600 files under the data root (D-63), the owner's commands, uploads and downloads, `update` and `rollback` (P11a), books homes and how to start an agent in one (P11b) | `ledger-store`, `ledger-mcp`, `ledger-config` | +| `ledger-gui` | the viewer: read-only, live, egui; the Books menu hands work to the server; the agent dock and the first-run page (P11b) | `ledger-core`, `ledger-store`, `ledger-config`, `ledger-import`, `ledger-package`, `ledger-vault`, `ledger-host` | | `ledger-cli` | `wanderbooks`, the one executable: every mode (`serve`, `server`, `host`, `mcp`, `gui`, `setup`, `books`, `token`, `connect`, `doctor`, …) and the over-the-wire test suites | everything | | `bookspec/checker`, `bookspec/runner` | the example schema and parser, the rule catalogue, the `Engine` trait; the per-rule runner | nothing of ours (the adapter in `ledger-core`'s tests depends on them) | @@ -116,6 +116,31 @@ appended journal lines; the UI renders an immutable view model rebuilt per change. Statements and series come from `ledger-core` (`reports`, `analysis`); the GUI only arranges them. See `plan/gui.md`. +Two things sit beside the read-only view, both through the `Owner` the +program gives the viewer (never a write of the books by the viewer): + +- **The agent dock** (`dock.rs`, P11b, D-27): a terminal panel along the + bottom of the window (`alacritty_terminal` over a PTY; ConPTY on Windows, + not offered until its four checks are recorded) that runs Claude Code or + Codex in the book's **books home**. The agent is an ordinary child process + of the viewer's program (D-32: no helper, no confinement) and writes the + books through the server like any other agent. The CLIs are found or + offered through their vendors' installers, never bundled; the app marks + no folder trusted (Q-7). Never required: any agent, anywhere, reaches the + same books through the MCP. +- **The first-run page** (`welcome.rs`): with no books and an owner to ask, + `wanderbooks gui` shows a page that sets up the private folder, the owner + passphrase, the first books and an agent, then opens the books, so an + installed app onboards with no terminal. + +A **books home** (`ledger_host::commands::books_home`) is a visible folder, +`/Wanderbooks/`, made by the viewer's "New books…" and by +`wanderbooks connect` (and again whenever the dock starts an agent). It holds +exactly `AGENTS.md`, `CLAUDE.md` (three lines), `.mcp.json` (the +project-level entry, `wanderbooks mcp --book ID`) and `.claude/settings.json` +(allowing `mcp__wanderbooks-ID__*`): no books data, key, token or path into +the data root (I-1 stays: the books are hidden, not here). + ## Shared read-only views (`ledger-package`) `ledger-package` is the shared read-only view: packages, the invoice PDF, diff --git a/docs/parity.md b/docs/parity.md index 5a3f598..eeaf8c8 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -247,3 +247,11 @@ Three new rows, none above changed status, and the floor stands. | Audit run: one programme of checks over the statements, the control accounts, closes, schedules, assets, evidence, drafts, tax categories, 1099 decisions, settings and the store's integrity (`books.audit`, `books.workpaper`) | An auditor's workpaper; QBO "Audit log"/"Review" and Xero's "Reports review" are a cross-check only | partial | rules.md B-45; `ledger_core::audit`, `ledger_store::audit`; tests `audit_detects_each_seeded_defect` (every code seeded), `audit_is_deterministic`, `workpaper_pdf_is_deterministic`; the fixture `p8_before` was written by the version before; `partial` until a hand-verified golden covers a workpaper | | Attestations of receipts, entries and periods, weighed against `policy.attestation_bar` when asked (`books.attest_receipt`, `books.review_entry`, `books.attest_period`, `books.attestations`) | An accountant's sign-off on a month | partial | `below_bar_not_counted`, `amount_mismatch_never_counts`, `stale_head_period_attestation_refused`; the method is the client's own claim, and the books say so | | The CPA package waits for a counted audit (`policy.cpa_package_requires_audit`) | A close checklist before handoff | partial | `cpa_package_refuses_without_a_counted_audit_unless_a_reason_is_given`; behind the feature `audits` | + +## P11b: the agent dock, books homes, first run (2026-10-29) + +| Area | Counterpart (a cross-check, not the spec) | Status | Notes | +|---|---|---|---| +| Books home: a visible folder per book, `/Wanderbooks/`, holding `AGENTS.md`, `CLAUDE.md`, `.mcp.json` and `.claude/settings.json` and nothing of the books | A QBO/Xero company has no counterpart; the point is the agent's workplace (D-27) | partial | `books_home_holds_no_books_data`, `a_books_home_made_again_keeps_what_a_person_kept` (`crates/ledger-host/tests/books_home.rs`); `partial` until the macOS home is tried | +| Agent dock: Claude Code or Codex in a terminal along the bottom of the viewer, started in the books home; never required | QBO's assistant panel is a cross-check only | partial | `dock_runs_the_chosen_agent`, `agent_missing_offers_the_installer` (`crates/ledger-gui/tests/dock.rs`); Linux checked; macOS by the CI run; Windows not offered (ConPTY unchecked) | +| First run with no terminal: the viewer sets up the private folder, the owner passphrase, the first books and an agent | QBO's "set up your company" flow | partial | `first_run_onboards_with_no_terminal` (`crates/ledger-gui/tests/welcome.rs`); the AppImage's whole path is the EM's to try on a clean machine | diff --git a/docs/testing.md b/docs/testing.md index 0aa3a95..385870f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -3987,3 +3987,83 @@ this repository's files live in `tests/bookspec.rs`: `every_vendored_pin_is_verb `rules_md_carries_every_id`. The examples named `bookspec/examples/...` in the older sections above are files of that repository. The checker's, the runner's and the derivation tests run in its own CI. + +## P11b: books homes, the agent dock, the first run (2026-10-29) + +Three layers, none touching the owner's books or data root: a home is made +under a scratch "documents" folder the test gives (`make_books_home_in` +takes it as an argument, so no test writes in an account's real +`~/Documents`); the dock runs a stand-in `claude` (a bash script) on a real +PTY under the viewer's harness; the first run is driven page by page +against an owner over a real scratch data root. + +`crates/ledger-host/tests/books_home.rs`: + +- `books_home_holds_no_books_data`: exactly four files + (`.claude/settings.json`, `.mcp.json`, `AGENTS.md`, `CLAUDE.md`); none + names the data root (a root with a journal sits beside the home), a token, + a key, a vault or a journal; `AGENTS.md` is three lines and names the + company and `books.start`; `.mcp.json` holds `wanderbooks-acme` running + ` mcp --book acme` and no `--root`; the settings allow + `mcp__wanderbooks-acme__*`. +- `a_books_home_made_again_keeps_what_a_person_kept`: a person's + `AGENTS.md`, their other MCP server and their other settings stay; the + entry and the rule are refreshed and appear once. +- `a_books_home_is_never_made_for_a_name_that_is_not_an_id`: `../x`, + `a/b`, the empty id and `A B` make nothing; a relative documents folder is + refused. +- `an_agent_is_found_on_the_path_or_where_its_installer_puts_it` + (`agent_missing_offers_the_installer`'s host half): not found gives the + vendor's installer; found on the PATH, a file that cannot be run is not + the agent, `~/.local/bin` is searched; `start_agent_in` returns the books + home as the directory and marks nothing trusted (Q-7). +- `a_first_run_reads_where_it_stands_and_sets_the_first_passphrase_once`. + +`crates/ledger-gui/tests/dock.rs` (Linux and macOS; a PTY): + +- `dock_runs_the_chosen_agent`: closed, nothing runs; opened, the owner is + asked for Claude Code on book `acme`, the program starts in the books + home (its own `pwd`), its output reaches the panel, typed text and Enter + reach it, a Ctrl-C key reaches it as an interrupt (`got-interrupt`; the + agent goes on), narrowing the window resizes the terminal and the program + is told the new size (`stty size` on its `WINCH` matches `Dock::cells`); + the journal is byte for byte as it was. +- `agent_missing_offers_the_installer`: the page names the agent, shows the + vendor's install command with a Copy button and starts nothing. +- `a_chosen_agent_and_an_open_dock_are_kept`: the preferences round trip, + Claude Code the default. +- Unit tests in `dock.rs`: `keys_become_what_a_terminal_sends`, + `colors_follow_the_terminals_palette`. + +`crates/ledger-gui/tests/welcome.rs`: + +- `first_run_onboards_with_no_terminal`: from nothing, "Set it up" makes the + private folder, two matching passphrase fields set the passphrase (the + owner is handed 20 characters, once), "Make the books" makes `acme` and + holds it, Codex is refused with its reason and Claude Code connects, + "Open the books" shows the books (Needs attention) and the page is gone; + the owner was asked exactly: set up, passphrase, new, hold, connect + Codex, connect Claude. +- `first_run_starts_at_the_first_step_missing_and_asks_nothing_done_again`. +- `a_passphrase_too_short_or_not_repeated_is_not_sent_and_can_wait`. + +Existing tests that cover the unchanged parts: `owner_menu.rs` (the menu +still hands each action to the owner), `read_only.rs`, `sidenav.rs` (the +sidebar's shape golden is unchanged: the dock sits right of the sidebar). + +**Not tested here:** macOS and Windows (no machine); the real `claude` and +`codex` (not installed on CI); the real `wanderbooks gui` first run +through the AppImage on a clean machine (the EM's). + +**For P12 (mutation targets):** `commands::make_books_home_in` (the +`!path.exists()` test that keeps a person's `AGENTS.md`; the merge that +dedupes the allow rule; `BookId::parse` before any path is joined); +`find_agent_in` (the order PATH then the installer folders; `is_program`'s +mode test); `start_agent_in` (the `Missing` arm); `first_run_state`'s three +fields; `set_first_passphrase`'s already-set refusal; `dock::key_bytes` (the +modifier parameter's arithmetic, the Ctrl-letter byte, the app-cursor +switch, the Alt escape); `dock::color`'s cube and gray ramps and its bold +brightening; `Session::resize`'s equality early return; the panel's +open/closed ids (a remembered size must not leak between them); +`Welcome::steps`' choice of `now` (each arm), the passphrase length and +equality gates, the `agent_later` flag. diff --git a/plan/gui.md b/plan/gui.md index e304177..9929e94 100644 --- a/plan/gui.md +++ b/plan/gui.md @@ -144,11 +144,20 @@ column and the tie-out badge (S6), write-offs on the timeline (S7), documents grouped by category and subject (P6). S4.5 adds the number, terms, bill-to and the issued copy to Save. +Landed 2026-10-29 (P11b): **the agent dock** along the bottom of the window +(Claude Code or Codex in the book's books home, over a PTY with +`alacritty_terminal`; collapsible and resizable; the choice remembered; +Linux checked, macOS by CI, Windows not offered until its ConPTY checks are +recorded; `dock.rs`, `tests/dock.rs`) and **the first-run page** (no books yet: +the private folder, the owner passphrase, the first books, an agent, then +the books open; `welcome.rs`, `tests/welcome.rs`), which is what lets an +installed app onboard with no terminal. Both go through the `Owner`; the +viewer still writes nothing of the books. + Next, in the plan's order: S4.5's page changes; P4's draft states in Needs attention; P11a (the installed app: the viewer is its face, with -"Check for updates" in the menu) and P11b (the agent dock along the -bottom of the window, Claude Code or Codex in it, started in the book's -books home; D-27). Not planned: a web build, which belongs to the hosted +"Check for updates" in the menu; P11b, the dock and the first run, is +landed, above). Not planned: a web build, which belongs to the hosted service (`plan/served.md` section 11, a browser viewer); the remote viewer replica (D-51: a backup clone opened read-only serves instead). Still open from the first version's list: a cash forecast with a From abcb2d6b27e0aac25eeda9791f7f42bf4417b3f4 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 29 Oct 2026 17:06:07 -0600 Subject: [PATCH 3/5] docs(plan): P11b as built (P11b) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- plan/served.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plan/served.md b/plan/served.md index fa58748..22009a7 100644 --- a/plan/served.md +++ b/plan/served.md @@ -1672,7 +1672,7 @@ Each unit follows the 8.0.4 template. "A-n" is an owner answer numbered in 8.3.1 5. **Files:** owns `crates/ledger-gui/src/dock.rs`, `commands::books_home`; touches `owner.rs`, `app.rs`, `commands.rs`. 6. **Tests and proofs:** named above. 7. **Done when:** the three tests pass on Linux; the dock runs Claude Code in a books home on Linux and macOS; Windows recorded as offered or not. -8. **As built:** none yet. +8. **As built:** (2026-10-29, branch `P11b`; the EM fills the commits.) **Books homes:** `ledger_host::commands::{books_home, books_home_in, make_books_home, make_books_home_in}` as specified, four files, the JSON two merged into and the two text files written only when absent (a person's edits stay). ~~``~~ → the folder is named by the book **id** (`~/Documents/Wanderbooks/`): the company name is set after the books are made and can change, the id cannot; `AGENTS.md` says the id in the company's place (a later unit may pass the name). `Documents` is `$HOME/Documents` only (no `XDG_DOCUMENTS_DIR`: an inherited variable would aim a test at the real folder; Windows `%USERPROFILE%\\Documents`, unbuilt). `.mcp.json` names `wanderbooks mcp --book ID` with no `--root` (a path into the data root is exactly what the home must not hold); the program is `$APPIMAGE` when set, else this executable. `connect_agent` is unchanged; `connect_and_home` (the `connect` command and the viewer's Connect) and `new_books_and_home` (the viewer's New books...) make the home; the plain `new_books`/`books create` do not (the in-process tests call them with the account's own home). **Q-7, built as the recommended answer:** nothing marks any folder trusted for Claude Code or Codex; each asks the person the first time. **Dock:** `crates/ledger-gui/src/dock.rs`, `alacritty_terminal` 0.26 over a PTY, collapsible and resizable, agent and open state kept in the viewer's preferences. ~~`Owner::start_agent(Agent, book) -> Child`~~ → `Owner::agent_start(Agent, book) -> Result`: a PTY is created by the terminal library, which spawns the child itself, so the owner (over `ledger_host::commands::start_agent`) says *what* to run (program, arguments, books home, `TERM`) or hands back the vendor's installer (`AgentStart::Missing`: a command to copy and a page; nothing installed by the app). Mouse selection (Ctrl-Shift-C copies), wheel scroll, bracketed paste and the usual keys work; not built: mouse reporting to the agent, a search, hyperlinks. **Windows is not offered** (`dock::offered()` is `cfg!(unix)`): the ConPTY checks need a Windows machine and the Windows host is P11c's; the code compiles for it in principle but is unchecked. macOS is by the CI run only. **First run (added by P11a's finding):** `crates/ledger-gui/src/welcome.rs`: with no books and an owner (the `wanderbooks` program gives it one for every `gui` run with its own flags), `ledger_gui::run` opens the page instead of exiting 2 (a named `--book` that is missing is still an error): the private folder (`set_up_root`), the owner passphrase typed twice in the viewer and handed to `set_first_passphrase` (optional; refuses when one is set), the first books (`new_books_and_home`, then the viewer's lease through `hold`), an agent (Connect Claude Code / Codex, with the installer said when it is missing; optional), then Open. It starts at the first step missing. New `Owner` methods (`first_run`, `set_up`, `set_passphrase`, `hold`, `agent_start`) have default bodies, so no other fake owner changed. **Tests:** `crates/ledger-host/tests/books_home.rs` (5), `crates/ledger-gui/tests/dock.rs` (3: `dock_runs_the_chosen_agent` on a real PTY with a stand-in `claude`, `agent_missing_offers_the_installer`, `a_chosen_agent_and_an_open_dock_are_kept`) plus `dock.rs` unit tests, `crates/ledger-gui/tests/welcome.rs` (3), `crates/ledger-cli/tests/books_home.rs` (connect makes the home). The sidebar's shape golden did not change (the dock sits right of the sidebar). **Done when:** (1) the three named tests pass on Linux; (2) Linux yes; macOS by CI only; (3) Windows recorded: not offered. **Not checked here:** the AppImage's whole first run on a clean machine (P11a's Done-when 1 still wants it); the real `claude` and `codex` (a stand-in was used). #### P11c: the native Windows port (D-62) From 2774680d654d8a55ea4e0aaad02d0978899c8250 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 29 Oct 2026 17:11:37 -0600 Subject: [PATCH 4/5] docs(plan): P11b and P13 step 6 landed; D-65 amended (Gitea only); the night's status Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- plan/HANDOFF.md | 2 +- plan/STATUS.md | 20 +++++++++++++++++++- plan/served.md | 6 +++--- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/plan/HANDOFF.md b/plan/HANDOFF.md index 4a238b2..30eede2 100644 --- a/plan/HANDOFF.md +++ b/plan/HANDOFF.md @@ -18,7 +18,7 @@ You are the engineering manager (EM) of a team implementing the remaining work o - `main` is `bfd453b6`. Landed since the hand-off: U0a, U0b, U0c, P7, P9a, P13 steps 1-3, P4b S4.1, S4.2, S4.3 (2026-10-26 to 2026-10-27), then S4.4, S5, S6, S7 and P6 (2026-10-29, merged from the paused branches by the planning session). Live on the owner's books: P0 to P3, Projects, P4b S1 (`document_standing`); everything behind `documents_v2` waits for the owner's upgrade (Q-5). - Nothing is built and unmerged. Branch `ci-fixes` (`76285434`, a fuzz target's renamed path and a regression test) is the one branch left to merge. -- Landed 2026-10-29 by wave 1 of this session's subagents: U0d, S4.5, P13 steps 4-5, P11a step 0 (the portable CI job; its keyring stores are being removed). Building: P4, U0e. Not started: P5, P8, P10, P11a steps 1-7, P11b, P11c, P12, P13 step 6 (the owner's call). +- Landed 2026-10-29 by this session's subagents in three waves: U0d, U0e, S4.5, P4, P5, P8, P10 (the runbook), P11a steps 0-4 plus the unsigned macOS bundle job, P11b, P13 steps 4-6 (the spec lives at `git.swaits.com/swaits/bookspec`). Building: P11c (branch `P11c`, proven by the portable CI jobs). Left: P11a steps 5-7 (signing deferred, D-64), P12, the owner's re-audit. - The plan's section 8 was rewritten on 2026-10-26 for this hand-off and revised on 2026-10-28 (section 0, "What changed on 2026-10-28"; `plan/2026-10-28_DRIFT_REPORT.md`). Sections 1 to 7 are the older design and carry strike-throughs (`~~text~~ → D-n`) and "As built" notes where the build or a decision departed; where section 8 and an earlier section disagree, section 8 is right. - Mutation testing is paused until unit P12 (decision D-33). Workers never run `cargo mutants`; each unit writes a "For P12" paragraph in `docs/testing.md` instead (recipe R-G). - The owner's live books are never touched by anyone on this team. The only books a worker uses are test fixtures and throwaway books made by `wanderbooks setup` in a scratch root. A unit's new writes are refused on the owner's books until the owner's format upgrade; merging never waits for that. diff --git a/plan/STATUS.md b/plan/STATUS.md index d83c4ee..288e925 100644 --- a/plan/STATUS.md +++ b/plan/STATUS.md @@ -1,6 +1,24 @@ # STATUS — 2026-10-28: nine units landed, five built on paused branches; P11a starts -## Now (2026-10-29, evening): wave 1 of the rest of the plan is landing +## Now (2026-10-29, night): waves 1 to 3 landed; P11c and P12 left + +**Landed today**, each merged with a compile and the unit's own tests +(D-60) and gated by GitHub's `check` on the push: U0d, U0e, S4.5, P4, +P5, P6 (from the paused wave), P8, P10's runbook, P11a steps 0-4 and +the unsigned macOS bundle job, P11b, P13 steps 4-6. The plan's units +still open: **P11c** (the native Windows port; its branch compiles and +runs the suite on windows-latest, two lock-semantics failures fixed, +macOS still refuses to start the server in CI and the next run prints +why), **P11a** steps 5-7 (signing deferred by D-64; the in-app accept +and the viewer's update notice need names; the Mac trial is the +owner's), **P12** (max testing, after P11c), and P10's re-audit itself +(the owner's run). Decisions today: D-60 to D-65. + +**The CI state** is the measure of the day: `main` must be green on +`check`; the portable macOS and Windows jobs are the two ports' gates +and red until they land. Every push is watched; a docs-only push is +never made alone (it cancels the running gate). + **Who builds.** The owner chose (the question tool) that this planning session is the EM and launches Sonnet subagents in parallel worktrees, diff --git a/plan/served.md b/plan/served.md index 22009a7..05c6434 100644 --- a/plan/served.md +++ b/plan/served.md @@ -835,10 +835,10 @@ One row per unit of work. A unit is what one engineer takes into one git worktre | P9c | Remote viewer replica, invitations | not planned (D-51) | | | | | | P10 | The re-audit runbook and its CI mirror; the owner runs the re-audit | not started | P5, P6, P8 | P11a, P13 | none | 1 (+ owner days) | | P11a | Packaging and updates: step 0 the platform groundwork, the trial, `wanderbooks update`/`rollback`, Linux packages, signing, macOS and Windows bundles | in progress: steps 0-4 and the macOS half of step 6 landed 2026-10-29 (update/rollback, Linux packages, AppImage with restart, the daily staged update, the unsigned `.dmg` job); step 5 (signing) deferred by D-64; left: the in-app accept and the viewer's update notice (names needed), the release workflow's first run (owner's click), the trials on a Mac | none (code); certificates and keys from the owner for the signing steps; Q-4 before the manifest step | everything | none | 20-25 | -| P11b | The agent dock and books homes | not started | P11a (an installed app to dock into) | P12 | none | 8-10 | +| P11b | The agent dock and books homes | landed 2026-10-29 (`f625848a`): the dock (a real PTY, Claude Code or Codex in a books home), books homes under `~/Documents/`, the first-run page with no terminal; macOS unverified until the portable job runs it; Windows not offered (P11c) | P11a (an installed app to dock into) | P12 | none | 8-10 | | P11c | The native Windows port: named pipes with the SID check, owner-only DACLs, file locking, the `%LOCALAPPDATA%` data root, the Windows CI job made gating (D-62) | in progress (wave 2, branch `P11c`) | U0e; P11a step 0 (the portable job) | P11b, P12 | none | 5-8 | | P12 | Max testing, again | not started | everything else merged | | none | 2-3 (+ machine time) | -| P13 | bookspec: checker crate, `Engine` trait, rule ids, the spec text, the runner | in progress: steps 1-5 landed (`579d0eed`, `d839e709`); step 6 (the spec's own public repository) decided 2026-10-29 (D-65): split out now | none for steps 1-4; the engine stable for step 5 | everything | none | 15-20 | +| P13 | bookspec: checker crate, `Engine` trait, rule ids, the spec text, the runner | steps 1-6 done: steps 1-5 landed (`579d0eed`, `d839e709`); step 6 landed 2026-10-29 (`d738c445`): the specification lives at `git.swaits.com/swaits/bookspec`, pinned by rev (D-65); ten stated rules still lack an example (the schema cannot show them yet), listed in the As-built block | none for steps 1-4; the engine stable for step 5 | everything | none | 15-20 | #### 8.0.2 Dependency graph and merge order @@ -2184,7 +2184,7 @@ The owner decisions of 2026-10-16 (at the top of this document) replace the form | D-62 | Native Windows (owner, 2026-10-29, the question tool, after P11a step 0 found the Windows side is a port, not cfg fixes: "macOS first, Windows port as its own unit") | **The first bundled app ships for macOS and Linux; the native Windows port is unit P11c**, built after U0e beside P11b; Windows stays WSL2-only meanwhile and the `portable (windows-latest)` job is allowed to fail until P11c lands. D-56's destination is unchanged, only its order. | P11a, P11b, P11c | | D-63 | Where agents' tokens live (owner, 2026-10-29, the question tool, on seeing P11a step 0 add Keychain and Credential Manager stores: "why are you talking about the OS keyring? I thought we made a decision to not use that garbage"; the plan had no such decision recorded, so this one records it) | **A 0600 file under the 0700 data root, on every OS; no OS keyring.** The data root is the trust boundary already (I-1: hidden, not locked, same user), so the keyring added no wall and cost headless servers a D-Bus session, CI a knob, and every OS its own store. Unit U0e removes the Secret Service, Keychain and Credential Manager stores and the `file_keyring` knob; 3.3's sentence is struck. | U0e, P9a's recipe, P11a, P11c | | D-64 | Q-4 answered (owner, 2026-10-29, the question tool: "Defer: ship unsigned builds to yourself first") | **P11a step 5 (the signed release list, the key ceremony, Apple and Microsoft code signing) is deferred until the first public release is near.** CI builds unsigned artifacts for the owner's own machines; `wanderbooks update` works from a local feed meanwhile (no compiled-in key). The two-key design of D-25 is unchanged, only its date. | P11a step 5, `release.yml` | -| D-65 | P13 step 6 (owner, 2026-10-29, the question tool: "Split it out now") | **The bookspec specification gets a public repository of its own now**, with this repository depending on it; the ten stated rules without examples and the three reserved numbers are published as such. | P13 step 6 | +| D-65 | P13 step 6 (owner, 2026-10-29, the question tool: "Split it out now"; then, when this session's token could not create a GitHub repository: "Gitea only, skip GitHub") | **The bookspec specification lives in a public repository of its own, `https://git.swaits.com/swaits/bookspec`** (history preserved; CC BY 4.0 for the text and examples, MIT OR Apache-2.0 for the code), and wanderbooks depends on it pinned by commit, so `just check` still runs every example. No GitHub copy. The ten stated rules without examples and the reserved numbers are published as such. | P13 step 6; `crates/ledger-core/Cargo.toml` | ### 10.2 Deferred From 2a6bac02d52d72848cec66e852dbe3222166c327 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 29 Oct 2026 17:21:43 -0600 Subject: [PATCH 5/5] test(cli): the IIF warning count ignores P8's audit notice; the harness asks the platform where the root is GitHub's check on 880278ea: `cpa_package_holds_the_year_and_its_evidence` counts the IIF transliteration warnings, and P8's "No counted audit stands behind this package" notice (a warning while the books hold no audit run) made four into five; the count now excludes it. The portable macOS job, now that the harness prints the server's log: "there is no books' private folder yet: run `wanderbooks setup` first". `Home::root()` spelled out `.local/share/wanderbooks`, the Linux data folder; `setup --home` on macOS makes the root under `Library/Application Support`. The harness asks `default_root` now, the same answer on every OS. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- crates/ledger-cli/tests/common/mod.rs | 6 +++++- crates/ledger-cli/tests/oracle_mcp.rs | 3 +++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/ledger-cli/tests/common/mod.rs b/crates/ledger-cli/tests/common/mod.rs index 027eb9d..05659c1 100644 --- a/crates/ledger-cli/tests/common/mod.rs +++ b/crates/ledger-cli/tests/common/mod.rs @@ -78,8 +78,12 @@ impl Home { } } + /// The root `setup --home` made: the platform's data folder under the + /// home (`.local/share` on Linux, `Library/Application Support` on + /// macOS), never a Linux path spelled out (the portable macOS job + /// found the server refusing a root that was never set up). pub fn root(&self) -> PathBuf { - self.dir.path().join(".local/share/wanderbooks") + ledger_store::root::default_root(self.dir.path()) } pub fn run(&self) -> PathBuf { diff --git a/crates/ledger-cli/tests/oracle_mcp.rs b/crates/ledger-cli/tests/oracle_mcp.rs index 564d5fa..061c7bc 100644 --- a/crates/ledger-cli/tests/oracle_mcp.rs +++ b/crates/ledger-cli/tests/oracle_mcp.rs @@ -3497,11 +3497,14 @@ fn cpa_package_holds_the_year_and_its_evidence() { // and the em dashes of the depreciation memos: written differently, so // said, the memos grouped (four lines for one name and four memos), in // warnings and the README alike. + // (The audit notice P8 adds while no counted audit stands behind the + // package is a warning too; it is not about the IIF.) let warnings: Vec = out["warnings"] .as_array() .unwrap() .iter() .map(|w| w.as_str().unwrap().to_string()) + .filter(|w| !w.starts_with("No counted audit")) .collect(); assert_eq!(warnings.len(), 4, "{out}"); let readme = std::str::from_utf8(&files["README.txt"]).unwrap();