merge: main into P11c (P11b's dock and books homes, the macOS harness root fix)

One conflict, ledger-host commands.rs: main moved the agent-program lookup
into find_agent_in; kept that and carried the Windows parts into it
(.exe/.cmd/.bat, npm's global folder). The harness already used
default_root, so main's fix and ours agree.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
swaitsandClaude Fable 5.1 committed 2026-10-29 17:26:43 -06:00
commit 84cbe0359c
23 files changed
+3398 -91

No files matched your search

+63 -6
View File
@@ -71,7 +71,7 @@ impl ledger_gui::owner::Owner for ViewerOwner {
}
fn new_books(&self, id: &str, currency: &str) -> Result<String, String> {
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<Vec<String>, 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<Vec<String>, 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<ledger_gui::owner::AgentStart, String> {
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<Vec<String>, String> {
@@ -147,14 +200,18 @@ fn run_gui(args: Vec<String>) -> 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) {
+152
View File
@@ -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<String> = 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__*"])
);
}
+4
View File
@@ -74,6 +74,10 @@ 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 {
ledger_store::root::default_root(self.dir.path())
}
+3
View File
@@ -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<String> = 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();