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:
commit
84cbe0359c
23 files changed
+3398
-91
No files matched your search
Generated
+79
@@ -167,6 +167,31 @@ dependencies = [
|
|||||||
"memchr",
|
"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]]
|
[[package]]
|
||||||
name = "alloc-no-stdlib"
|
name = "alloc-no-stdlib"
|
||||||
version = "2.0.4"
|
version = "2.0.4"
|
||||||
@@ -3049,6 +3074,15 @@ dependencies = [
|
|||||||
"digest 0.11.3",
|
"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]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -3956,6 +3990,7 @@ dependencies = [
|
|||||||
name = "ledger-gui"
|
name = "ledger-gui"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"alacritty_terminal",
|
||||||
"chrono",
|
"chrono",
|
||||||
"eframe",
|
"eframe",
|
||||||
"egui",
|
"egui",
|
||||||
@@ -4371,6 +4406,15 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "moxcms"
|
name = "moxcms"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
@@ -6227,6 +6271,17 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.45"
|
version = "0.23.45"
|
||||||
@@ -6613,6 +6668,16 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
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]]
|
[[package]]
|
||||||
name = "signal-hook-registry"
|
name = "signal-hook-registry"
|
||||||
version = "1.4.8"
|
version = "1.4.8"
|
||||||
@@ -8167,6 +8232,20 @@ version = "0.8.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
|
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]]
|
[[package]]
|
||||||
name = "wait-timeout"
|
name = "wait-timeout"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
|
|||||||
@@ -95,7 +95,13 @@ curl -fsSL https://wanderbooks.ai/install.sh | sh -s -- acme
|
|||||||
Already installed, three commands: `wanderbooks setup`, `wanderbooks books
|
Already installed, three commands: `wanderbooks setup`, `wanderbooks books
|
||||||
create acme`, `wanderbooks connect claude --book acme` (or `connect codex`).
|
create acme`, `wanderbooks connect claude --book acme` (or `connect codex`).
|
||||||
Then talk to the agent; `wanderbooks gui acme` shows the books, and its
|
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/<id>` (optional: any agent can use the
|
||||||
|
books).
|
||||||
|
|
||||||
Updating: an AppImage, or a `wanderbooks` you put somewhere yourself, brings
|
Updating: an AppImage, or a `wanderbooks` you put somewhere yourself, brings
|
||||||
itself to the newest release with `wanderbooks update` (it checks the
|
itself to the newest release with `wanderbooks update` (it checks the
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ impl ledger_gui::owner::Owner for ViewerOwner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn new_books(&self, id: &str, currency: &str) -> Result<String, String> {
|
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> {
|
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::Claude => "claude",
|
||||||
ledger_gui::owner::Agent::Codex => "codex",
|
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> {
|
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 root = value("--root").map(PathBuf::from);
|
||||||
let book = value("--book");
|
let book = value("--book");
|
||||||
if known && ledger_gui::book_in_root(root.as_deref(), book.as_deref()).is_ok() {
|
if known {
|
||||||
// The viewer asks about a format upgrade in its own window.
|
// 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 {
|
let common = ledger_host::commands::Common {
|
||||||
root,
|
root: root.clone(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
ledger_gui::owner::set_owner(std::sync::Arc::new(ViewerOwner(common.clone())));
|
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) {
|
match ledger_gui::run("wanderbooks gui", args) {
|
||||||
|
|||||||
@@ -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__*"])
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 {
|
pub fn root(&self) -> PathBuf {
|
||||||
ledger_store::root::default_root(self.dir.path())
|
ledger_store::root::default_root(self.dir.path())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3497,11 +3497,14 @@ fn cpa_package_holds_the_year_and_its_evidence() {
|
|||||||
// and the em dashes of the depreciation memos: written differently, so
|
// and the em dashes of the depreciation memos: written differently, so
|
||||||
// said, the memos grouped (four lines for one name and four memos), in
|
// said, the memos grouped (four lines for one name and four memos), in
|
||||||
// warnings and the README alike.
|
// 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"]
|
let warnings: Vec<String> = out["warnings"]
|
||||||
.as_array()
|
.as_array()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|w| w.as_str().unwrap().to_string())
|
.map(|w| w.as_str().unwrap().to_string())
|
||||||
|
.filter(|w| !w.starts_with("No counted audit"))
|
||||||
.collect();
|
.collect();
|
||||||
assert_eq!(warnings.len(), 4, "{out}");
|
assert_eq!(warnings.len(), 4, "{out}");
|
||||||
let readme = std::str::from_utf8(&files["README.txt"]).unwrap();
|
let readme = std::str::from_utf8(&files["README.txt"]).unwrap();
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ rust_decimal = "1"
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
zeroize = "1"
|
zeroize = "1"
|
||||||
|
# The dock: a terminal emulator over a PTY (ConPTY on Windows), P11b
|
||||||
|
alacritty_terminal = "0.26"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
|
|||||||
@@ -151,24 +151,75 @@ pub struct App {
|
|||||||
menu: crate::menu::Menu,
|
menu: crate::menu::Menu,
|
||||||
/// The owner's Books menu (owner.rs).
|
/// The owner's Books menu (owner.rs).
|
||||||
owner_menu: crate::owner::OwnerMenu,
|
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<crate::welcome::Welcome>,
|
||||||
|
/// What a first run opens its books with: the data root named, and
|
||||||
|
/// "today".
|
||||||
|
first_run_source: (Option<std::path::PathBuf>, Option<chrono::NaiveDate>),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
/// The live app over a books directory.
|
/// The live app over a books directory.
|
||||||
pub fn new(cc: &eframe::CreationContext<'_>, source: Source) -> Self {
|
pub fn new(cc: &eframe::CreationContext<'_>, source: Source) -> Self {
|
||||||
// The scheme kept from last time; before schemes, a light/dark
|
let mut app = Self::new_without_books(cc);
|
||||||
// choice; else the system's mode (schemes INVARIANT 4).
|
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<std::path::PathBuf>,
|
||||||
|
today: Option<chrono::NaiveDate>,
|
||||||
|
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(
|
let (scheme, filter) = restored(
|
||||||
|key| cc.storage.and_then(|s| s.get_string(key)),
|
|key| cc.storage.and_then(|s| s.get_string(key)),
|
||||||
cc.egui_ctx.global_style().visuals.dark_mode,
|
cc.egui_ctx.global_style().visuals.dark_mode,
|
||||||
);
|
);
|
||||||
let mut app = Self::bare(&cc.egui_ctx, scheme);
|
let mut app = Self::bare(&cc.egui_ctx, scheme);
|
||||||
app.filter = filter;
|
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.receipts.thumb_dir = crate::receipts::thumb_dir();
|
||||||
app.loader = Some(Loader::spawn(source, cc.egui_ctx.clone()));
|
|
||||||
app
|
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).
|
/// An app showing fixed books (tests and screenshots).
|
||||||
pub fn with_books(ctx: &egui::Context, books: Books, dark: bool, start: Route) -> Self {
|
pub fn with_books(ctx: &egui::Context, books: Books, dark: bool, start: Route) -> Self {
|
||||||
let mut app = Self::bare(ctx, schemes::own(dark));
|
let mut app = Self::bare(ctx, schemes::own(dark));
|
||||||
@@ -198,6 +249,28 @@ impl App {
|
|||||||
live_label: false,
|
live_label: false,
|
||||||
menu: Default::default(),
|
menu: Default::default(),
|
||||||
owner_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) {
|
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| {
|
let (back, fwd, palette) = ui.input(|i| {
|
||||||
(
|
(
|
||||||
(i.modifiers.alt && i.key_pressed(Key::ArrowLeft))
|
(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 {
|
impl eframe::App for App {
|
||||||
fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) {
|
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();
|
let updates = self.loader.as_ref().map(|l| l.poll()).unwrap_or_default();
|
||||||
for u in updates {
|
for u in updates {
|
||||||
match u {
|
match u {
|
||||||
@@ -1656,6 +1749,7 @@ impl eframe::App for App {
|
|||||||
let compact = ui.available_width() < COMPACT_BELOW;
|
let compact = ui.available_width() < COMPACT_BELOW;
|
||||||
self.sidebar(ui, compact);
|
self.sidebar(ui, compact);
|
||||||
self.topbar(ui);
|
self.topbar(ui);
|
||||||
|
self.dock(ui);
|
||||||
let p = pal(ui.ctx());
|
let p = pal(ui.ctx());
|
||||||
let since_update = ui.input(|i| i.time) - self.updated_at;
|
let since_update = ui.input(|i| i.time) - self.updated_at;
|
||||||
if since_update < 6.0 {
|
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(SCHEME_KEY, self.scheme.key());
|
||||||
storage.set_string(FILTER_KEY, self.filter.name().to_string());
|
storage.set_string(FILTER_KEY, self.filter.name().to_string());
|
||||||
storage.set_string("dark", self.scheme.dark.to_string());
|
storage.set_string("dark", self.scheme.dark.to_string());
|
||||||
|
self.dock.keep(&mut |k, v| storage.set_string(k, v));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -9,6 +9,7 @@ pub mod books;
|
|||||||
pub mod charts;
|
pub mod charts;
|
||||||
pub mod data;
|
pub mod data;
|
||||||
pub mod demo;
|
pub mod demo;
|
||||||
|
pub mod dock;
|
||||||
pub mod export;
|
pub mod export;
|
||||||
pub mod fmt;
|
pub mod fmt;
|
||||||
pub mod menu;
|
pub mod menu;
|
||||||
@@ -22,6 +23,7 @@ mod schemes;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test_support;
|
mod test_support;
|
||||||
mod theme;
|
mod theme;
|
||||||
|
pub mod welcome;
|
||||||
mod widgets;
|
mod widgets;
|
||||||
|
|
||||||
pub use app::App;
|
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):
|
/// 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
|
/// `--root`, else the root `wanderbooks setup` made, never the current
|
||||||
/// directory or a settings file; `--book` names one, else the root's only
|
/// 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
|
/// one (or the first, by id). With no books yet, and an owner to set them
|
||||||
/// directory and opens that.
|
/// 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(
|
pub fn run(
|
||||||
prog: &str,
|
prog: &str,
|
||||||
args: impl IntoIterator<Item = String>,
|
args: impl IntoIterator<Item = String>,
|
||||||
@@ -94,17 +98,26 @@ pub fn run(
|
|||||||
eprintln!("demo books: {}", dir.display());
|
eprintln!("demo books: {}", dir.display());
|
||||||
data_dir = Some(dir.join("books-data"));
|
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 {
|
let data_dir = match data_dir {
|
||||||
Some(d) => d,
|
Some(d) => Some(d),
|
||||||
None => match book_in_root(root.as_deref(), book.as_deref()) {
|
None => match book_in_root(root.as_deref(), book.as_deref()) {
|
||||||
Ok(d) => d,
|
Ok(d) => Some(d),
|
||||||
Err(why) => {
|
Err(why) => match owner::current() {
|
||||||
eprintln!("{why}");
|
// Named books that aren't there are a mistake, not a first run.
|
||||||
return Err(Box::new(Exit(2)));
|
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 {
|
let options = eframe::NativeOptions {
|
||||||
viewport: egui::ViewportBuilder::default()
|
viewport: egui::ViewportBuilder::default()
|
||||||
.with_title("wanderbooks")
|
.with_title("wanderbooks")
|
||||||
@@ -121,7 +134,12 @@ pub fn run(
|
|||||||
Ok(eframe::run_native(
|
Ok(eframe::run_native(
|
||||||
"wanderbooks",
|
"wanderbooks",
|
||||||
options,
|
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()),
|
||||||
|
}))
|
||||||
|
}),
|
||||||
)?)
|
)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,24 @@ pub enum Agent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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> {
|
||||||
|
[Agent::Claude, Agent::Codex]
|
||||||
|
.into_iter()
|
||||||
|
.find(|a| a.key() == key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Its name.
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Agent::Claude => "Claude Code",
|
Agent::Claude => "Claude Code",
|
||||||
Agent::Codex => "Codex",
|
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<String>,
|
||||||
|
/// 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<String>,
|
||||||
|
/// 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).
|
/// A restore previewed: what it would do, and what it is of (INVARIANT 3).
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct RestorePlan {
|
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
|
/// The owner's go-ahead for the books `book`'s format upgrade, with the
|
||||||
/// owner passphrase they typed (the server checks it).
|
/// owner passphrase they typed (the server checks it).
|
||||||
fn upgrade_format(&self, book: &str, passphrase: &str) -> Result<Vec<String>, String>;
|
fn upgrade_format(&self, book: &str, passphrase: &str) -> Result<Vec<String>, 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<Vec<String>, 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<AgentStart, String> {
|
||||||
|
Err("not here".into())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static OWNER: OnceLock<Arc<dyn Owner>> = OnceLock::new();
|
static OWNER: OnceLock<Arc<dyn Owner>> = OnceLock::new();
|
||||||
@@ -104,6 +186,11 @@ pub fn set_owner(owner: Arc<dyn Owner>) {
|
|||||||
let _ = OWNER.set(owner);
|
let _ = OWNER.set(owner);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The viewer's owner, if the program gave it one.
|
||||||
|
pub fn current() -> Option<Arc<dyn Owner>> {
|
||||||
|
owner()
|
||||||
|
}
|
||||||
|
|
||||||
fn owner() -> Option<Arc<dyn Owner>> {
|
fn owner() -> Option<Arc<dyn Owner>> {
|
||||||
OWNER.get().cloned()
|
OWNER.get().cloned()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Vec<String>, 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<Vec<String>, 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<Agent>, Receiver<Done>)>,
|
||||||
|
/// What the last step said or why it was refused.
|
||||||
|
said: Option<Result<Vec<String>, String>>,
|
||||||
|
/// The books to open, once the person says.
|
||||||
|
open: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String> {
|
||||||
|
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<Agent>,
|
||||||
|
owner: &Arc<dyn Owner>,
|
||||||
|
work: impl FnOnce(&dyn Owner) -> Result<Vec<String>, 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<dyn Owner>) {
|
||||||
|
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<dyn Owner>, 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)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Option<Result<AgentStart, String>>>,
|
||||||
|
asked: Mutex<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Owner for Fake {
|
||||||
|
fn close_books(&self) -> Result<String, String> {
|
||||||
|
Err("not here".into())
|
||||||
|
}
|
||||||
|
fn new_books(&self, _: &str, _: &str) -> Result<String, String> {
|
||||||
|
Err("not here".into())
|
||||||
|
}
|
||||||
|
fn back_up_now(&self, _: &str) -> Result<Vec<String>, String> {
|
||||||
|
Err("not here".into())
|
||||||
|
}
|
||||||
|
fn plan_restore(&self, _: &str, _: Option<String>, _: bool) -> Result<RestorePlan, String> {
|
||||||
|
Err("not here".into())
|
||||||
|
}
|
||||||
|
fn restore(&self, _: &RestorePlan) -> Result<Vec<String>, String> {
|
||||||
|
Err("not here".into())
|
||||||
|
}
|
||||||
|
fn connect(&self, _: Agent, _: &str) -> Result<Vec<String>, String> {
|
||||||
|
Err("not here".into())
|
||||||
|
}
|
||||||
|
fn upgrade_format(&self, _: &str, _: &str) -> Result<Vec<String>, String> {
|
||||||
|
Err("not here".into())
|
||||||
|
}
|
||||||
|
fn agent_start(&self, agent: Agent, book: &str) -> Result<AgentStart, String> {
|
||||||
|
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<Fake> {
|
||||||
|
static FAKE: OnceLock<Arc<Fake>> = 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);
|
||||||
|
}
|
||||||
@@ -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<Option<(PathBuf, PathBuf)>>,
|
||||||
|
asked: Mutex<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String, String> {
|
||||||
|
Err("not here".into())
|
||||||
|
}
|
||||||
|
fn new_books(&self, id: &str, currency: &str) -> Result<String, String> {
|
||||||
|
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<Vec<String>, String> {
|
||||||
|
Err("not here".into())
|
||||||
|
}
|
||||||
|
fn plan_restore(&self, _: &str, _: Option<String>, _: bool) -> Result<RestorePlan, String> {
|
||||||
|
Err("not here".into())
|
||||||
|
}
|
||||||
|
fn restore(&self, _: &RestorePlan) -> Result<Vec<String>, String> {
|
||||||
|
Err("not here".into())
|
||||||
|
}
|
||||||
|
fn connect(&self, agent: Agent, book: &str) -> Result<Vec<String>, 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<Vec<String>, 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<Vec<String>, 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<Fake> {
|
||||||
|
static FAKE: OnceLock<Arc<Fake>> = 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);
|
||||||
|
}
|
||||||
@@ -1146,7 +1146,7 @@ pub fn connect(
|
|||||||
};
|
};
|
||||||
// The owner's command (INVARIANT 6).
|
// The owner's command (INVARIANT 6).
|
||||||
common.ask_upgrades = true;
|
common.ask_upgrades = true;
|
||||||
match connect_agent(&common, agent, &book) {
|
match connect_and_home(&common, agent, &book) {
|
||||||
Ok(lines) => {
|
Ok(lines) => {
|
||||||
for line in lines {
|
for line in lines {
|
||||||
println!("{line}");
|
println!("{line}");
|
||||||
@@ -1160,20 +1160,6 @@ pub fn connect(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `p` is a file someone may run (Windows: a file; what it can
|
|
||||||
/// run as is told by its extension, which the caller chose).
|
|
||||||
fn is_runnable(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))]
|
|
||||||
{
|
|
||||||
std::fs::metadata(p).is_ok_and(|m| m.is_file())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Let the agent `agent` ("claude" or "codex") reach the books `book`:
|
/// Let the agent `agent` ("claude" or "codex") reach the books `book`:
|
||||||
/// their token kept in its token file (made if none is), and `wanderbooks
|
/// their token kept in its token file (made if none is), and `wanderbooks
|
||||||
/// mcp --book <book>` added to the agent's own list as
|
/// mcp --book <book>` added to the agent's own list as
|
||||||
@@ -1187,50 +1173,11 @@ pub fn connect_agent(common: &Common, agent: &str, book: &str) -> Result<Vec<Str
|
|||||||
let book = ledger_store::root::BookId::parse(book)
|
let book = ledger_store::root::BookId::parse(book)
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
.to_string();
|
.to_string();
|
||||||
// The PATH first; then where the agents' installers put them (a
|
let program = find_agent_program(cli).ok_or_else(|| {
|
||||||
// viewer started from the desktop has a short PATH).
|
format!(
|
||||||
// ($HOME: a test's own home is never the account's.)
|
"{label} isn't installed here (no `{cli}` on the PATH, nor where its installer puts it)"
|
||||||
let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" })
|
)
|
||||||
.map(PathBuf::from)
|
})?;
|
||||||
.filter(|h| h.is_absolute());
|
|
||||||
let usual = [
|
|
||||||
".local/bin",
|
|
||||||
".npm-global/bin",
|
|
||||||
".bun/bin",
|
|
||||||
".cargo/bin",
|
|
||||||
".volta/bin",
|
|
||||||
// Where npm puts global programs on Windows.
|
|
||||||
"AppData/Roaming/npm",
|
|
||||||
]
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|d| home.as_ref().map(|h| h.join(d)))
|
|
||||||
.chain(
|
|
||||||
["/usr/local/bin", "/opt/homebrew/bin"]
|
|
||||||
.into_iter()
|
|
||||||
.map(PathBuf::from),
|
|
||||||
);
|
|
||||||
let program = std::env::var_os("PATH")
|
|
||||||
.map(|p| std::env::split_paths(&p).collect::<Vec<_>>())
|
|
||||||
.unwrap_or_default()
|
|
||||||
.into_iter()
|
|
||||||
.chain(usual)
|
|
||||||
.filter(|d| d.is_absolute())
|
|
||||||
.flat_map(|d| {
|
|
||||||
// Windows runs a program by its extension.
|
|
||||||
let names: &[&str] = if cfg!(windows) {
|
|
||||||
&[".exe", ".cmd", ".bat"]
|
|
||||||
} else {
|
|
||||||
&[""]
|
|
||||||
};
|
|
||||||
names
|
|
||||||
.iter()
|
|
||||||
.map(|ext| d.join(format!("{cli}{ext}")))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
})
|
|
||||||
.find(|p| is_runnable(p))
|
|
||||||
.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();
|
let mut lines = Vec::new();
|
||||||
// The token: kept in its file, or made now.
|
// The token: kept in its file, or made now.
|
||||||
let found =
|
let found =
|
||||||
@@ -1313,6 +1260,417 @@ pub fn connect_agent(common: &Common, agent: &str, book: &str) -> Result<Vec<Str
|
|||||||
Ok(lines)
|
Ok(lines)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where an agent's program is: on the PATH, else where its own
|
||||||
|
/// installer puts it (a viewer started from the desktop has a short
|
||||||
|
/// PATH). `home` is the account's home (`$HOME`: a test's own home is never
|
||||||
|
/// the account's).
|
||||||
|
pub fn find_agent_in(
|
||||||
|
cli: &str,
|
||||||
|
path: Option<&std::ffi::OsStr>,
|
||||||
|
home: Option<&Path>,
|
||||||
|
) -> Option<PathBuf> {
|
||||||
|
let usual = [
|
||||||
|
".local/bin",
|
||||||
|
".npm-global/bin",
|
||||||
|
".bun/bin",
|
||||||
|
".cargo/bin",
|
||||||
|
".volta/bin",
|
||||||
|
// Where npm puts global programs on Windows.
|
||||||
|
"AppData/Roaming/npm",
|
||||||
|
]
|
||||||
|
.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::<Vec<_>>())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.chain(usual)
|
||||||
|
.filter(|d| d.is_absolute())
|
||||||
|
.flat_map(|d| {
|
||||||
|
// Windows runs a program by its extension.
|
||||||
|
let names: &[&str] = if cfg!(windows) {
|
||||||
|
&[".exe", ".cmd", ".bat"]
|
||||||
|
} else {
|
||||||
|
&[""]
|
||||||
|
};
|
||||||
|
names
|
||||||
|
.iter()
|
||||||
|
.map(|ext| d.join(format!("{cli}{ext}")))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.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<PathBuf> {
|
||||||
|
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<PathBuf> {
|
||||||
|
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<Installer> {
|
||||||
|
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: `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<PathBuf> {
|
||||||
|
home_dir_env().map(|h| h.join("Documents"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The books home of `book` (D-27): a visible folder,
|
||||||
|
/// `<Documents>/Wanderbooks/<book>`, 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<PathBuf, String> {
|
||||||
|
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: `<exe> mcp --book <id>`
|
||||||
|
/// .claude/settings.json allows `mcp__wanderbooks-<id>__*`, 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<PathBuf, String> {
|
||||||
|
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<String, serde_json::Value>)|
|
||||||
|
-> Result<(), String> {
|
||||||
|
let mut doc = std::fs::read_to_string(&path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|t| serde_json::from_str::<serde_json::Value>(&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<PathBuf, String> {
|
||||||
|
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<Vec<String>, 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<String, String> {
|
||||||
|
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<String>,
|
||||||
|
/// 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<AgentStart, String> {
|
||||||
|
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<AgentStart, String> {
|
||||||
|
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<String>,
|
||||||
|
/// 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<Vec<String>, 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
|
/// Make the token `name` for `book` (the server does) and keep it in its
|
||||||
/// token file (INVARIANT 4: kept first, or taken back).
|
/// token file (INVARIANT 4: kept first, or taken back).
|
||||||
fn make_token(
|
fn make_token(
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
//! 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.
|
||||||
|
|
||||||
|
#[cfg_attr(not(unix), allow(unused_imports))]
|
||||||
|
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<String> {
|
||||||
|
fn walk(base: &Path, dir: &Path, out: &mut Vec<String>) {
|
||||||
|
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}");
|
||||||
|
}
|
||||||
+27
-2
@@ -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-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-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-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-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 | `ledger-core`, `ledger-store`, `ledger-config`, `ledger-import`, `ledger-package`, `ledger-vault`, `ledger-host` |
|
| `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 |
|
| `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) |
|
| `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`,
|
change. Statements and series come from `ledger-core` (`reports`,
|
||||||
`analysis`); the GUI only arranges them. See `plan/gui.md`.
|
`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,
|
||||||
|
`<Documents>/Wanderbooks/<book id>`, 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`)
|
## Shared read-only views (`ledger-package`)
|
||||||
|
|
||||||
`ledger-package` is the shared read-only view: packages, the invoice PDF,
|
`ledger-package` is the shared read-only view: packages, the invoice PDF,
|
||||||
|
|||||||
@@ -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 |
|
| 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 |
|
| 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` |
|
| 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, `<Documents>/Wanderbooks/<id>`, 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 |
|
||||||
@@ -4045,3 +4045,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
|
`rules_md_carries_every_id`. The examples named `bookspec/examples/...` in the
|
||||||
older sections above are files of that repository. The checker's, the
|
older sections above are files of that repository. The checker's, the
|
||||||
runner's and the derivation tests run in its own CI.
|
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
|
||||||
|
`<exe> 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.
|
||||||
+1
-1
@@ -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).
|
- `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.
|
- 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.
|
- 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).
|
- 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.
|
- 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.
|
||||||
|
|||||||
+19
-1
@@ -1,6 +1,24 @@
|
|||||||
# STATUS — 2026-10-28: nine units landed, five built on paused branches; P11a starts
|
# 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
|
**Who builds.** The owner chose (the question tool) that this planning
|
||||||
session is the EM and launches Sonnet subagents in parallel worktrees,
|
session is the EM and launches Sonnet subagents in parallel worktrees,
|
||||||
|
|||||||
+12
-3
@@ -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,
|
documents grouped by category and subject (P6). S4.5 adds the number,
|
||||||
terms, bill-to and the issued copy to Save.
|
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
|
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
|
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
|
"Check for updates" in the menu; P11b, the dock and the first run, is
|
||||||
bottom of the window, Claude Code or Codex in it, started in the book's
|
landed, above). Not planned: a web build, which belongs to the hosted
|
||||||
books home; D-27). Not planned: a web build, which belongs to the hosted
|
|
||||||
service (`plan/served.md` section 11, a browser viewer); the remote
|
service (`plan/served.md` section 11, a browser viewer); the remote
|
||||||
viewer replica (D-51: a backup clone opened read-only serves instead).
|
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
|
Still open from the first version's list: a cash forecast with a
|
||||||
|
|||||||
+4
-4
@@ -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) | | | | |
|
| 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) |
|
| 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 |
|
| 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/<book id>`, 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 |
|
| 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) |
|
| 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
|
#### 8.0.2 Dependency graph and merge order
|
||||||
|
|
||||||
@@ -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`.
|
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.
|
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.
|
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). ~~`<Company>`~~ → the folder is named by the book **id** (`~/Documents/Wanderbooks/<id>`): 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<AgentStart, String>`: 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)
|
#### P11c: the native Windows port (D-62)
|
||||||
|
|
||||||
@@ -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-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-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-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
|
### 10.2 Deferred
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user