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
@@ -1146,7 +1146,7 @@ pub fn connect(
|
||||
};
|
||||
// The owner's command (INVARIANT 6).
|
||||
common.ask_upgrades = true;
|
||||
match connect_agent(&common, agent, &book) {
|
||||
match connect_and_home(&common, agent, &book) {
|
||||
Ok(lines) => {
|
||||
for line in lines {
|
||||
println!("{line}");
|
||||
@@ -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`:
|
||||
/// their token kept in its token file (made if none is), and `wanderbooks
|
||||
/// 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)
|
||||
.map_err(|e| e.to_string())?
|
||||
.to_string();
|
||||
// The PATH first; then where the agents' installers put them (a
|
||||
// viewer started from the desktop has a short PATH).
|
||||
// ($HOME: a test's own home is never the account's.)
|
||||
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 program = find_agent_program(cli).ok_or_else(|| {
|
||||
format!(
|
||||
"{label} isn't installed here (no `{cli}` on the PATH, nor where its installer puts it)"
|
||||
)
|
||||
})?;
|
||||
let mut lines = Vec::new();
|
||||
// The token: kept in its file, or made now.
|
||||
let found =
|
||||
@@ -1313,6 +1260,417 @@ pub fn connect_agent(common: &Common, agent: &str, book: &str) -> Result<Vec<Str
|
||||
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
|
||||
/// token file (INVARIANT 4: kept first, or taken back).
|
||||
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}");
|
||||
}
|
||||
Reference in new issue
Block a user