feat: one-command onboarding — wanderbooks new, install.sh, justfile
wanderbooks new DIR installs the server and GUI if missing, writes the books dir (AGENTS.md + CLAUDE.md, wanderbooks.toml, justfile, .mcp.json, .claude/settings.json approving the server), commits, and starts claude onboarding. install.sh: Rust + cargo install + wanderbooks new in one line. The tax summary labels its net when accounts are uncategorized. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
This commit is contained in:
8 files changed
+501
-191
No files matched your search
Generated
+1
@@ -3369,6 +3369,7 @@ checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
|
|||||||
name = "ledger-cli"
|
name = "ledger-cli"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"toml 1.1.8+spec-1.1.0",
|
"toml 1.1.8+spec-1.1.0",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -57,12 +57,18 @@ See [`plan/STATUS.md`](plan/STATUS.md) for where things stand and
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo test # whole suite
|
cargo test # whole suite
|
||||||
cargo run -p ledger-cli -- init ~/books/acme # scaffold a books dir: mise.toml + AGENT.md
|
|
||||||
cargo run -p ledger-mcp -- --data-dir /tmp/books # MCP server over stdio
|
cargo run -p ledger-mcp -- --data-dir /tmp/books # MCP server over stdio
|
||||||
cargo run -p ledger-gui -- --demo # the desktop view, on a demo company
|
cargo run -p ledger-gui -- --demo # the desktop view, on a demo company
|
||||||
```
|
```
|
||||||
Then `cd ~/books/acme`: `mise trust && mise install`, `mise run build`,
|
New books, one command (installs Rust and wanderbooks if needed, makes the
|
||||||
`mise run mcp-add`, and start `claude` — the agent onboards from AGENT.md.
|
books dir with the MCP server registered for it, and starts the agent):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://git.swaits.com/swaits/wanderbooks/raw/branch/main/install.sh | sh -s -- ~/Books/Acme
|
||||||
|
```
|
||||||
|
|
||||||
|
Already installed: `wanderbooks new ~/Books/Acme`. In a books dir, `just`
|
||||||
|
lists the shortcuts (`just gui`, `just agent`, `just backup`).
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
@@ -72,6 +78,7 @@ crates/ledger-store/ # journal + SQLite projection + blob store
|
|||||||
crates/ledger-mcp/ # MCP server: 64 tools, resources, receipt policy
|
crates/ledger-mcp/ # MCP server: 64 tools, resources, receipt policy
|
||||||
crates/ledger-import/ # QBO CSV import, IIF/XLSX export
|
crates/ledger-import/ # QBO CSV import, IIF/XLSX export
|
||||||
crates/ledger-config/ # wanderbooks.toml company settings
|
crates/ledger-config/ # wanderbooks.toml company settings
|
||||||
|
crates/ledger-cli/ # `wanderbooks new`: one-command books dir + agent
|
||||||
crates/ledger-gui/ # read-only live desktop view (egui)
|
crates/ledger-gui/ # read-only live desktop view (egui)
|
||||||
docs/ # requirements, architecture, oracle, parity, glossary
|
docs/ # requirements, architecture, oracle, parity, glossary
|
||||||
plan/ # STATUS.md, roadmap.md
|
plan/ # STATUS.md, roadmap.md
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ version = "0.1.0"
|
|||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
description = "wanderbooks CLI: scaffold new books directories (mise.toml + AGENT.md)."
|
description = "wanderbooks CLI: one command from nothing to a working books directory."
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "wanderbooks"
|
name = "wanderbooks"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
serde_json = "1"
|
||||||
toml = "1"
|
toml = "1"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|
||||||
|
|||||||
+328
-133
@@ -1,59 +1,77 @@
|
|||||||
//! `wanderbooks` CLI — the bootstrap for AI-native bookkeeping.
|
//! `wanderbooks` CLI: one command from nothing to talking with your
|
||||||
|
//! bookkeeper.
|
||||||
//!
|
//!
|
||||||
//! Usage: `wanderbooks init [DIR] [--force]`
|
//! ```text
|
||||||
|
//! wanderbooks new [DIR] [--force] [--no-start] (alias: init)
|
||||||
|
//! wanderbooks gui [DIR]
|
||||||
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! Scaffolds a new books directory: `mise.toml` (pinned toolchain + tasks),
|
//! `new` makes a complete books directory and starts the agent in it:
|
||||||
//! `AGENT.md` (the agent operating contract), `wanderbooks.toml` (company
|
//!
|
||||||
//! profile, filled in during onboarding), and an empty `books-data/` dir.
|
//! 1. Finds `ledger-mcp` and `ledger-gui` next to this binary or on the
|
||||||
//! From there: `mise trust && mise install`, `mise run build`,
|
//! PATH, and installs them with cargo if they're missing.
|
||||||
//! `mise run mcp-add`, then start `claude`/`codex` in the dir and let the
|
//! 2. Writes `AGENTS.md` (the bookkeeper's operating contract, with a
|
||||||
//! agent take the onboarding from AGENT.md.
|
//! `CLAUDE.md` that imports it), `wanderbooks.toml`, a `justfile`,
|
||||||
|
//! `.gitignore`, and `books-data/`.
|
||||||
|
//! 3. Registers the MCP server for this directory only: `.mcp.json`, plus
|
||||||
|
//! `.claude/settings.json` approving it, so Claude Code loads the tools
|
||||||
|
//! without `claude mcp add` or a prompt.
|
||||||
|
//! 4. `git init`s the directory (its history is the archive) and commits.
|
||||||
|
//! 5. Starts `claude` in the directory with the onboarding prompt, unless
|
||||||
|
//! `--no-start` or there's no terminal.
|
||||||
|
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
/// Absolute path of the wanderbooks repo checkout this binary was built from.
|
/// Where `wanderbooks update` installs from.
|
||||||
fn repo_root() -> PathBuf {
|
const REPO_URL: &str = "https://git.swaits.com/swaits/wanderbooks";
|
||||||
|
|
||||||
|
/// The checkout this binary was built from, if it still exists.
|
||||||
|
fn repo_root() -> Option<PathBuf> {
|
||||||
let raw = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
let raw = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||||
raw.canonicalize().unwrap_or(raw)
|
let root = raw.canonicalize().ok()?;
|
||||||
|
root.join("crates/ledger-mcp/Cargo.toml")
|
||||||
|
.exists()
|
||||||
|
.then_some(root)
|
||||||
}
|
}
|
||||||
|
|
||||||
const MISE_TOML: &str = r#"# wanderbooks books dir — provisioned by `mise`.
|
const JUSTFILE: &str = r#"# wanderbooks books dir. Run `just` to list recipes.
|
||||||
# Bootstrap: `mise trust && mise install` then `mise run build && mise run mcp-add`.
|
# Day to day you talk to the agent (`just agent`); the recipes are the
|
||||||
|
# handful of things you might do yourself.
|
||||||
|
|
||||||
[tools]
|
set shell := ["bash", "-cu"]
|
||||||
rust = "stable"
|
|
||||||
|
|
||||||
[tasks.build]
|
# List recipes
|
||||||
description = "Build ledger-mcp from the wanderbooks repo checkout"
|
default:
|
||||||
dir = "{{REPO}}"
|
@just --list
|
||||||
run = "cargo build --release -p ledger-mcp"
|
|
||||||
|
|
||||||
[tasks.mcp-add]
|
# Start a bookkeeping session with the agent
|
||||||
description = "Register wanderbooks with Claude Code (stdio transport; OS user boundary is the auth)"
|
agent:
|
||||||
run = 'claude mcp add wanderbooks -- "{{REPO}}/target/release/ledger-mcp" --data-dir "{{BOOKS}}/books-data"'
|
claude
|
||||||
|
|
||||||
[tasks.backup]
|
# Open the live, read-only view of these books
|
||||||
description = "Commit the journal — the git history of this dir is the archive"
|
gui:
|
||||||
run = 'git add -A && (git commit -m "books backup $(date -u +%Y-%m-%dT%H:%M:%SZ)" || true)'
|
"@@LEDGER_GUI@@" --data-dir books-data --books-toml wanderbooks.toml
|
||||||
|
|
||||||
|
# Commit the journal: the git history of this dir is the archive
|
||||||
|
backup:
|
||||||
|
git add -A && (git commit -qm "books backup $(date -u +%Y-%m-%dT%H:%M:%SZ)" && echo committed || echo "nothing to commit")
|
||||||
|
|
||||||
|
# Update wanderbooks (server, GUI, CLI) to the latest version
|
||||||
|
update:
|
||||||
|
cargo install --locked --git @@REPO_URL@@ ledger-cli ledger-mcp ledger-gui
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
const AGENT_MD: &str = r#"# AGENT.md — wanderbooks books dir
|
const AGENTS_MD: &str = r#"# AGENTS.md — wanderbooks books dir
|
||||||
|
|
||||||
This directory is a **wanderbooks** books dir: AI-native double-entry
|
This directory is a **wanderbooks** books dir: AI-native double-entry
|
||||||
bookkeeping. You (the AI agent) are the bookkeeper. The human talks to you;
|
bookkeeping. You (the AI agent) are the bookkeeper. The human talks to you;
|
||||||
you drive the books through the `wanderbooks` MCP server. There is no
|
you drive the books through the `wanderbooks` MCP server (registered for
|
||||||
bookkeeping UI; the numbers you show the human *are* the interface.
|
this directory in `.mcp.json`). The humans read the books in the GUI
|
||||||
|
(`just gui`): read-only and live, so whatever you post appears there within
|
||||||
## Bootstrap (once, in order)
|
a second. Everything else they see, you show them.
|
||||||
|
|
||||||
1. `mise trust && mise install` — installs the pinned Rust toolchain.
|
|
||||||
2. `mise run build` — builds `ledger-mcp` from the wanderbooks repo.
|
|
||||||
3. `mise run mcp-add` — registers the server with Claude Code over stdio.
|
|
||||||
(Codex: add the same command + `--data-dir` to your MCP config instead.)
|
|
||||||
4. Restart the session so the MCP tools load, then continue below.
|
|
||||||
|
|
||||||
## The MCP contract
|
## The MCP contract
|
||||||
|
|
||||||
@@ -66,204 +84,381 @@ bookkeeping UI; the numbers you show the human *are* the interface.
|
|||||||
- **Posted is forever.** Corrections are reversing entries linked to the
|
- **Posted is forever.** Corrections are reversing entries linked to the
|
||||||
original, never edits, never deletes.
|
original, never edits, never deletes.
|
||||||
- **Receipts:** attach with `attach_receipt` and link to the entry. Anything
|
- **Receipts:** attach with `attach_receipt` and link to the entry. Anything
|
||||||
over $25 requires a receipt — the server enforces this, don't argue with it.
|
over the receipt threshold ($25 unless `wanderbooks.toml` says otherwise)
|
||||||
|
requires a receipt — the server enforces this, don't argue with it. One
|
||||||
|
file per receipt: PDF preferred, an image is fine.
|
||||||
- **Show the numbers.** After every mutation, show the affected balances.
|
- **Show the numbers.** After every mutation, show the affected balances.
|
||||||
When the human questions a figure, trace it to journal entries.
|
When the human questions a figure, trace it to journal entries.
|
||||||
|
|
||||||
## First run: onboarding
|
## First run: onboarding
|
||||||
|
|
||||||
If `books-data/journal.jsonl` is missing or empty, onboard before recording
|
If `books-data/journal.jsonl` is missing or empty, onboard before recording
|
||||||
anything:
|
anything. Keep it conversational; ask a few things at a time.
|
||||||
|
|
||||||
1. Ask: company legal name, entity type, owners and equity split, base
|
1. Ask: company legal name, entity type (sole proprietorship or
|
||||||
currency, fiscal year start.
|
single-member LLC, partnership or multi-member LLC, S corp, C corp),
|
||||||
2. Set up the chart of accounts: propose a sensible default for the entity
|
owners and their shares, fiscal year start, and whether they want
|
||||||
type and let the human adjust, or import one they hand you.
|
accrual or cash basis.
|
||||||
3. Record opening balances as one balanced opening entry. Verify the trial
|
2. Propose a chart of accounts with `books.chart_template` for that entity
|
||||||
balance balances before moving on.
|
type and those owners. Walk the human through it, adjust, then import it
|
||||||
4. Write the answers into `wanderbooks.toml` under `[company]`.
|
with `books.import_chart_csv`. Put the template's settings (members,
|
||||||
5. If they're migrating from another system, import from wherever the books
|
tax form, close target) in `wanderbooks.toml`.
|
||||||
live now — QBO/CSV via `ledger-import`, bank or card CSVs, email receipts,
|
3. Fill in `wanderbooks.toml` `[company]`: name, entity type, tax
|
||||||
a spreadsheet, whatever they point you at. Diff trial balance, P&L,
|
classification, formation state and date, EIN, address. Don't store
|
||||||
balance sheet, and cash flow against the old system **to the cent**
|
SSNs or full account numbers anywhere in this directory.
|
||||||
before calling the migration done.
|
4. Record opening balances as one balanced opening entry (or the owners'
|
||||||
|
first contributions). Verify the trial balance before moving on.
|
||||||
|
5. File the formation documents they have (articles, EIN letter,
|
||||||
|
operating agreement) with `books.file_document`.
|
||||||
|
6. If they're migrating from another system, import from wherever the books
|
||||||
|
live now — QBO/CSV, bank or card CSVs, email receipts, a spreadsheet,
|
||||||
|
whatever they point you at. Diff trial balance, P&L, balance sheet, and
|
||||||
|
cash flow against the old system **to the cent** before calling the
|
||||||
|
migration done.
|
||||||
|
7. Tell them about `just gui`, then commit with `just backup`.
|
||||||
|
|
||||||
## Ongoing
|
## Ongoing
|
||||||
|
|
||||||
- Every transaction: draft, preview, confirm with the human, post, attach
|
- Every transaction: draft, preview, confirm with the human, post, attach
|
||||||
the receipt.
|
the receipt. Tag payments to contractors with their vendor (1099s).
|
||||||
- `books.digest` is the review surface — "what did you do with my books
|
- `books.digest` is the review surface — "what did you do with my books
|
||||||
lately." Offer it; don't make the human ask.
|
lately." Offer it; don't make the human ask.
|
||||||
- Reports are pure functions of the journal. If a number looks wrong, the
|
- Reports are pure functions of the journal. If a number looks wrong, the
|
||||||
journal is the evidence — read it, don't guess.
|
journal is the evidence — read it, don't guess.
|
||||||
- Back up: run `mise run backup` after every session that posted entries.
|
- Back up: `just backup` after every session that posted entries. The git
|
||||||
The git history of this directory is the archive; the journal file is the
|
history of this directory is the archive; `books-data/journal.jsonl` is
|
||||||
system of record and the SQLite database is disposable.
|
the system of record and the SQLite database is disposable.
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
const CLAUDE_MD: &str = "@AGENTS.md\n";
|
||||||
|
|
||||||
const WANDERBOOKS_TOML: &str = r#"# wanderbooks books dir config. [company] is filled in during onboarding —
|
const WANDERBOOKS_TOML: &str = r#"# wanderbooks books dir config. [company] is filled in during onboarding —
|
||||||
# the agent asks, the human answers, the agent writes it here.
|
# the agent asks, the human answers, the agent writes it here.
|
||||||
|
|
||||||
[company]
|
[company]
|
||||||
name = ""
|
name = ""
|
||||||
entity_type = ""
|
|
||||||
owners = ""
|
|
||||||
base_currency = "USD"
|
base_currency = "USD"
|
||||||
fiscal_year_start = "01-01"
|
fiscal_year_start = "01-01"
|
||||||
|
|
||||||
[books]
|
[books]
|
||||||
data_dir = "books-data"
|
data_dir = "books-data"
|
||||||
|
accounting_basis = "accrual"
|
||||||
receipt_threshold = "25.00"
|
receipt_threshold = "25.00"
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
const GITIGNORE: &str = r#"# The SQLite projection is disposable — it rebuilds from journal.jsonl.
|
const GITIGNORE: &str = r#"# The SQLite projection is disposable — it rebuilds from journal.jsonl.
|
||||||
books-data/wanderbooks.db
|
books-data/wanderbooks.db
|
||||||
books-data/wanderbooks.db-*
|
books-data/wanderbooks.db-*
|
||||||
|
# Generated exports can always be regenerated from the journal.
|
||||||
|
exports/
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
const START_PROMPT: &str = "Hi! These are brand-new books. Please set them up with me.";
|
||||||
|
|
||||||
fn print_usage() {
|
fn print_usage() {
|
||||||
eprintln!("usage: wanderbooks init [DIR] [--force]");
|
eprintln!("usage: wanderbooks new [DIR] [--force] [--no-start]");
|
||||||
eprintln!(" DIR books directory to create (default: current directory)");
|
eprintln!(" wanderbooks gui [DIR]");
|
||||||
eprintln!(" --force overwrite an existing wanderbooks.toml");
|
eprintln!();
|
||||||
|
eprintln!(" new make a books directory and start the agent in it (alias: init)");
|
||||||
|
eprintln!(" gui open the live, read-only view of a books directory");
|
||||||
|
eprintln!(" DIR the books directory (default: current directory)");
|
||||||
|
eprintln!(" --force rewrite the scaffold in an existing books directory");
|
||||||
|
eprintln!(" --no-start don't start the agent afterwards");
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render(template: &str, repo: &str, books: &str) -> String {
|
/// A binary next to this one (cargo installs them side by side), else on
|
||||||
template
|
/// the PATH.
|
||||||
.replace("{{REPO}}", repo)
|
fn find_binary(name: &str) -> Option<PathBuf> {
|
||||||
.replace("{{BOOKS}}", books)
|
if let Some(dir) = env::current_exe()
|
||||||
|
.ok()
|
||||||
|
.and_then(|e| e.parent().map(Path::to_path_buf))
|
||||||
|
{
|
||||||
|
let p = dir.join(name);
|
||||||
|
if p.is_file() {
|
||||||
|
return Some(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
env::var_os("PATH").and_then(|paths| {
|
||||||
|
env::split_paths(&paths)
|
||||||
|
.map(|d| d.join(name))
|
||||||
|
.find(|p| p.is_file())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Install the server and GUI with cargo: from this checkout when there is
|
||||||
|
/// one, else from the repo.
|
||||||
|
fn install_binaries() -> Result<(), String> {
|
||||||
|
let mut cmd = Command::new("cargo");
|
||||||
|
cmd.args(["install", "--locked"]);
|
||||||
|
match repo_root() {
|
||||||
|
Some(root) => {
|
||||||
|
eprintln!(
|
||||||
|
"Installing ledger-mcp and ledger-gui from {} (a few minutes, once)…",
|
||||||
|
root.display()
|
||||||
|
);
|
||||||
|
// `--path` takes one crate at a time.
|
||||||
|
for c in ["ledger-mcp", "ledger-gui"] {
|
||||||
|
let ok = Command::new("cargo")
|
||||||
|
.args(["install", "--locked", "--path"])
|
||||||
|
.arg(root.join("crates").join(c))
|
||||||
|
.status()
|
||||||
|
.map_err(|e| {
|
||||||
|
format!("can't run cargo: {e} (install Rust from https://rustup.rs)")
|
||||||
|
})?
|
||||||
|
.success();
|
||||||
|
if !ok {
|
||||||
|
return Err(format!("cargo install of {c} failed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
eprintln!(
|
||||||
|
"Installing ledger-mcp and ledger-gui from {REPO_URL} (a few minutes, once)…"
|
||||||
|
);
|
||||||
|
cmd.args(["--git", REPO_URL, "ledger-mcp", "ledger-gui"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let ok = cmd
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("can't run cargo: {e} (install Rust from https://rustup.rs)"))?
|
||||||
|
.success();
|
||||||
|
if ok {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("cargo install failed".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn binaries() -> Result<(PathBuf, PathBuf), String> {
|
||||||
|
if let (Some(m), Some(g)) = (find_binary("ledger-mcp"), find_binary("ledger-gui")) {
|
||||||
|
return Ok((m, g));
|
||||||
|
}
|
||||||
|
install_binaries()?;
|
||||||
|
match (find_binary("ledger-mcp"), find_binary("ledger-gui")) {
|
||||||
|
(Some(m), Some(g)) => Ok((m, g)),
|
||||||
|
_ => Err(
|
||||||
|
"installed, but ledger-mcp/ledger-gui aren't on the PATH; add ~/.cargo/bin to it"
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_string(s: &str) -> String {
|
||||||
|
let mut out = String::from("\"");
|
||||||
|
for c in s.chars() {
|
||||||
|
match c {
|
||||||
|
'"' => out.push_str("\\\""),
|
||||||
|
'\\' => out.push_str("\\\\"),
|
||||||
|
'\n' => out.push_str("\\n"),
|
||||||
|
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
|
||||||
|
c => out.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push('"');
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `.mcp.json`: the wanderbooks server for this directory only.
|
||||||
|
fn mcp_json(ledger_mcp: &Path, books: &Path) -> String {
|
||||||
|
format!(
|
||||||
|
"{{\n \"mcpServers\": {{\n \"wanderbooks\": {{\n \"command\": {},\n \"args\": [\"--data-dir\", {}]\n }}\n }}\n}}\n",
|
||||||
|
json_string(&ledger_mcp.to_string_lossy()),
|
||||||
|
json_string(&books.join("books-data").to_string_lossy())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `.claude/settings.json`: approve this directory's server, and its tools,
|
||||||
|
/// so the first session starts working instead of asking. The server still
|
||||||
|
/// previews every posting for the human to confirm.
|
||||||
|
const CLAUDE_SETTINGS: &str = r#"{
|
||||||
|
"enabledMcpjsonServers": ["wanderbooks"],
|
||||||
|
"permissions": {
|
||||||
|
"allow": ["mcp__wanderbooks"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
/// `git init` the dir unless already inside a repo; commit the scaffold.
|
/// `git init` the dir unless already inside a repo; commit the scaffold.
|
||||||
/// Failures are warnings, not errors — the scaffold is what matters.
|
/// Failures are warnings, not errors — the scaffold is what matters.
|
||||||
fn git_bootstrap(dir: &Path) {
|
fn git_bootstrap(dir: &Path) {
|
||||||
let inside = Command::new("git")
|
let git = |args: &[&str]| {
|
||||||
.args(["rev-parse", "--is-inside-work-tree"])
|
Command::new("git")
|
||||||
|
.args(args)
|
||||||
.current_dir(dir)
|
.current_dir(dir)
|
||||||
.output()
|
.output()
|
||||||
.is_ok_and(|o| o.status.success());
|
.is_ok_and(|o| o.status.success())
|
||||||
if !inside {
|
};
|
||||||
let init_ok = Command::new("git")
|
if !git(&["rev-parse", "--is-inside-work-tree"]) && !git(&["init", "-q"]) {
|
||||||
.arg("init")
|
eprintln!("warning: git init failed; skipping the first commit");
|
||||||
.current_dir(dir)
|
|
||||||
.output()
|
|
||||||
.is_ok_and(|o| o.status.success());
|
|
||||||
if !init_ok {
|
|
||||||
eprintln!("warning: git init failed; skipping scaffold commit");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
if !git(&["add", "-A"]) {
|
||||||
let add = Command::new("git")
|
eprintln!("warning: git add failed; skipping the first commit");
|
||||||
.args(["add", "-A"])
|
|
||||||
.current_dir(dir)
|
|
||||||
.output();
|
|
||||||
if !add.is_ok_and(|o| o.status.success()) {
|
|
||||||
eprintln!("warning: git add failed; skipping scaffold commit");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let _ = Command::new("git")
|
// Use the person's identity when they have one; fall back so a fresh
|
||||||
.args([
|
// machine still gets a first commit.
|
||||||
|
let has_identity = git(&["config", "user.email"]);
|
||||||
|
let mut args = Vec::new();
|
||||||
|
if !has_identity {
|
||||||
|
args.extend([
|
||||||
"-c",
|
"-c",
|
||||||
"user.name=wanderbooks",
|
"user.name=wanderbooks",
|
||||||
"-c",
|
"-c",
|
||||||
"user.email=wanderbooks@localhost",
|
"user.email=wanderbooks@localhost",
|
||||||
"commit",
|
]);
|
||||||
"-m",
|
}
|
||||||
"init: wanderbooks books dir scaffold",
|
args.extend(["commit", "-qm", "init: wanderbooks books dir"]);
|
||||||
])
|
git(&args);
|
||||||
.current_dir(dir)
|
|
||||||
.output();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scaffold a books directory. Returns the dir on success.
|
/// Write the books directory. Returns it, canonical.
|
||||||
fn init(dir: &Path, force: bool) -> Result<PathBuf, String> {
|
fn scaffold(
|
||||||
|
dir: &Path,
|
||||||
|
force: bool,
|
||||||
|
ledger_mcp: &Path,
|
||||||
|
ledger_gui: &Path,
|
||||||
|
) -> Result<PathBuf, String> {
|
||||||
fs::create_dir_all(dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
|
fs::create_dir_all(dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
|
||||||
let dir = dir
|
let dir = dir
|
||||||
.canonicalize()
|
.canonicalize()
|
||||||
.map_err(|e| format!("cannot resolve {}: {e}", dir.display()))?;
|
.map_err(|e| format!("cannot resolve {}: {e}", dir.display()))?;
|
||||||
|
if dir.join("wanderbooks.toml").exists() && !force {
|
||||||
let marker = dir.join("wanderbooks.toml");
|
|
||||||
if marker.exists() && !force {
|
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"{} is already a books dir (wanderbooks.toml exists); use --force to overwrite",
|
"{} is already a books dir (wanderbooks.toml exists); use --force to rewrite the scaffold",
|
||||||
dir.display()
|
dir.display()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
let justfile = JUSTFILE
|
||||||
let repo = repo_root();
|
.replace("@@LEDGER_GUI@@", &ledger_gui.to_string_lossy())
|
||||||
let repo_s = repo.to_string_lossy();
|
.replace("@@REPO_URL@@", REPO_URL);
|
||||||
let books_s = dir.to_string_lossy();
|
let mut files: Vec<(&str, String)> = vec![
|
||||||
|
("justfile", justfile),
|
||||||
let files: &[(&str, String)] = &[
|
("AGENTS.md", AGENTS_MD.to_string()),
|
||||||
("mise.toml", render(MISE_TOML, &repo_s, &books_s)),
|
("CLAUDE.md", CLAUDE_MD.to_string()),
|
||||||
("AGENT.md", AGENT_MD.to_string()),
|
|
||||||
("wanderbooks.toml", WANDERBOOKS_TOML.to_string()),
|
|
||||||
(".gitignore", GITIGNORE.to_string()),
|
(".gitignore", GITIGNORE.to_string()),
|
||||||
|
(".mcp.json", mcp_json(ledger_mcp, &dir)),
|
||||||
];
|
];
|
||||||
|
// The company profile is the person's data: never overwrite it.
|
||||||
|
if !dir.join("wanderbooks.toml").exists() {
|
||||||
|
files.push(("wanderbooks.toml", WANDERBOOKS_TOML.to_string()));
|
||||||
|
}
|
||||||
for (name, content) in files {
|
for (name, content) in files {
|
||||||
fs::write(dir.join(name), content).map_err(|e| format!("cannot write {name}: {e}"))?;
|
fs::write(dir.join(name), content).map_err(|e| format!("cannot write {name}: {e}"))?;
|
||||||
}
|
}
|
||||||
|
fs::create_dir_all(dir.join(".claude")).map_err(|e| format!("cannot create .claude: {e}"))?;
|
||||||
|
fs::write(dir.join(".claude/settings.json"), CLAUDE_SETTINGS)
|
||||||
|
.map_err(|e| format!("cannot write .claude/settings.json: {e}"))?;
|
||||||
fs::create_dir_all(dir.join("books-data"))
|
fs::create_dir_all(dir.join("books-data"))
|
||||||
.map_err(|e| format!("cannot create books-data: {e}"))?;
|
.map_err(|e| format!("cannot create books-data: {e}"))?;
|
||||||
fs::write(dir.join("books-data").join(".gitkeep"), "")
|
fs::write(dir.join("books-data/.gitkeep"), "")
|
||||||
.map_err(|e| format!("cannot write .gitkeep: {e}"))?;
|
.map_err(|e| format!("cannot write .gitkeep: {e}"))?;
|
||||||
|
|
||||||
git_bootstrap(&dir);
|
git_bootstrap(&dir);
|
||||||
Ok(dir)
|
Ok(dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_terminal() -> bool {
|
||||||
|
use std::io::IsTerminal;
|
||||||
|
std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace this process with `claude` in the books dir, onboarding.
|
||||||
|
fn start_agent(dir: &Path) -> Result<(), String> {
|
||||||
|
let Some(claude) = find_binary("claude") else {
|
||||||
|
println!(
|
||||||
|
"Claude Code isn't installed. Install it, then run `claude` in {}.",
|
||||||
|
dir.display()
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
println!("Starting your bookkeeper…");
|
||||||
|
let mut cmd = Command::new(claude);
|
||||||
|
cmd.arg(START_PROMPT).current_dir(dir);
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
|
let err = cmd.exec();
|
||||||
|
Err(format!("can't start claude: {err}"))
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
{
|
||||||
|
cmd.status()
|
||||||
|
.map_err(|e| format!("can't start claude: {e}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new(dir: &Path, force: bool, start: bool) -> Result<(), String> {
|
||||||
|
let (ledger_mcp, ledger_gui) = binaries()?;
|
||||||
|
let dir = scaffold(dir, force, &ledger_mcp, &ledger_gui)?;
|
||||||
|
println!("Books ready in {}", dir.display());
|
||||||
|
println!(" just gui the live, read-only view of the books");
|
||||||
|
println!(" just agent a session with your bookkeeper");
|
||||||
|
println!(" just backup commit the journal");
|
||||||
|
if start && is_terminal() {
|
||||||
|
start_agent(&dir)?;
|
||||||
|
} else {
|
||||||
|
println!("Next: cd {} && claude", dir.display());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gui(dir: &Path) -> Result<(), String> {
|
||||||
|
let gui = find_binary("ledger-gui")
|
||||||
|
.ok_or("ledger-gui isn't installed; run `wanderbooks new` or `just update`")?;
|
||||||
|
let status = Command::new(gui)
|
||||||
|
.arg("--data-dir")
|
||||||
|
.arg(dir.join("books-data"))
|
||||||
|
.arg("--books-toml")
|
||||||
|
.arg(dir.join("wanderbooks.toml"))
|
||||||
|
.status()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if status.success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("ledger-gui exited with an error".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let args = env::args().skip(1);
|
|
||||||
let mut dir: Option<PathBuf> = None;
|
let mut dir: Option<PathBuf> = None;
|
||||||
let mut force = false;
|
let mut force = false;
|
||||||
|
let mut start = true;
|
||||||
let mut cmd: Option<String> = None;
|
let mut cmd: Option<String> = None;
|
||||||
|
for arg in env::args().skip(1) {
|
||||||
for arg in args {
|
|
||||||
match arg.as_str() {
|
match arg.as_str() {
|
||||||
"--help" | "-h" => {
|
"--help" | "-h" => {
|
||||||
print_usage();
|
print_usage();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
"--force" => force = true,
|
"--force" => force = true,
|
||||||
|
"--no-start" => start = false,
|
||||||
s if s.starts_with('-') => {
|
s if s.starts_with('-') => {
|
||||||
eprintln!("unknown flag: {s}");
|
eprintln!("unknown flag: {s}");
|
||||||
print_usage();
|
print_usage();
|
||||||
std::process::exit(2);
|
std::process::exit(2);
|
||||||
}
|
}
|
||||||
|
s if cmd.is_none() => cmd = Some(s.to_string()),
|
||||||
|
s if dir.is_none() => dir = Some(PathBuf::from(s)),
|
||||||
s => {
|
s => {
|
||||||
if cmd.is_none() {
|
|
||||||
cmd = Some(s.to_string());
|
|
||||||
} else if dir.is_none() {
|
|
||||||
dir = Some(PathBuf::from(s));
|
|
||||||
} else {
|
|
||||||
eprintln!("unexpected argument: {s}");
|
eprintln!("unexpected argument: {s}");
|
||||||
print_usage();
|
print_usage();
|
||||||
std::process::exit(2);
|
std::process::exit(2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
match cmd.as_deref() {
|
|
||||||
Some("init") => {
|
|
||||||
let dir = dir.unwrap_or_else(|| PathBuf::from("."));
|
let dir = dir.unwrap_or_else(|| PathBuf::from("."));
|
||||||
match init(&dir, force) {
|
let result = match cmd.as_deref() {
|
||||||
Ok(d) => {
|
Some("new") | Some("init") => new(&dir, force, start),
|
||||||
println!("initialized books dir: {}", d.display());
|
Some("gui") => gui(&dir),
|
||||||
println!("next:");
|
|
||||||
println!(" cd {}", d.display());
|
|
||||||
println!(" mise trust && mise install");
|
|
||||||
println!(" mise run build && mise run mcp-add");
|
|
||||||
println!("then start claude (or codex) here and follow AGENT.md");
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("error: {e}");
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
_ => {
|
||||||
print_usage();
|
print_usage();
|
||||||
std::process::exit(2);
|
std::process::exit(2);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
if let Err(e) = result {
|
||||||
|
eprintln!("error: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+106
-53
@@ -1,82 +1,123 @@
|
|||||||
//! Integration tests for `wanderbooks init`: the scaffold lands complete,
|
//! Integration tests for `wanderbooks new`: one command lands a complete
|
||||||
//! the TOML parses, re-init is refused without `--force`.
|
//! books dir with the MCP server registered for it, the TOML and JSON
|
||||||
|
//! parse, and re-running is refused without `--force` (which never
|
||||||
|
//! touches the company profile).
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
fn bin() -> PathBuf {
|
fn bin() -> PathBuf {
|
||||||
PathBuf::from(env!("CARGO_BIN_EXE_wanderbooks"))
|
PathBuf::from(env!("CARGO_BIN_EXE_wanderbooks"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_init(dir: &std::path::Path, extra: &[&str]) -> (bool, String) {
|
/// Stand-ins for the installed binaries, so tests never run cargo install.
|
||||||
let mut cmd = Command::new(bin());
|
fn fake_bins(tmp: &Path) -> PathBuf {
|
||||||
cmd.arg("init").arg(dir);
|
let dir = tmp.join("bin");
|
||||||
for e in extra {
|
fs::create_dir_all(&dir).unwrap();
|
||||||
cmd.arg(e);
|
for b in ["ledger-mcp", "ledger-gui"] {
|
||||||
|
fs::write(dir.join(b), "#!/bin/sh\n").unwrap();
|
||||||
}
|
}
|
||||||
let out = cmd.output().expect("run wanderbooks");
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(tmp: &Path, args: &[&str]) -> (bool, String) {
|
||||||
|
let path = format!(
|
||||||
|
"{}:{}",
|
||||||
|
fake_bins(tmp).display(),
|
||||||
|
std::env::var("PATH").unwrap_or_default()
|
||||||
|
);
|
||||||
|
let out = Command::new(bin())
|
||||||
|
.args(args)
|
||||||
|
.arg("--no-start")
|
||||||
|
.env("PATH", path)
|
||||||
|
.output()
|
||||||
|
.expect("run wanderbooks");
|
||||||
let mut s = String::from_utf8_lossy(&out.stdout).into_owned();
|
let mut s = String::from_utf8_lossy(&out.stdout).into_owned();
|
||||||
s.push_str(&String::from_utf8_lossy(&out.stderr));
|
s.push_str(&String::from_utf8_lossy(&out.stderr));
|
||||||
(out.status.success(), s)
|
(out.status.success(), s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn new(tmp: &Path, books: &Path, extra: &[&str]) -> (bool, String) {
|
||||||
|
let mut args = vec!["new", books.to_str().unwrap()];
|
||||||
|
args.extend(extra);
|
||||||
|
run(tmp, &args)
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scaffold_lands_complete() {
|
fn scaffold_lands_complete() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let books = tmp.path().join("acme-books");
|
let books = tmp.path().join("acme-books");
|
||||||
let (ok, _) = run_init(&books, &[]);
|
let (ok, out) = new(tmp.path(), &books, &[]);
|
||||||
assert!(ok, "init should succeed");
|
assert!(ok, "new should succeed: {out}");
|
||||||
for f in ["mise.toml", "AGENT.md", "wanderbooks.toml", ".gitignore"] {
|
for f in [
|
||||||
|
"justfile",
|
||||||
|
"AGENTS.md",
|
||||||
|
"CLAUDE.md",
|
||||||
|
"wanderbooks.toml",
|
||||||
|
".gitignore",
|
||||||
|
".mcp.json",
|
||||||
|
".claude/settings.json",
|
||||||
|
] {
|
||||||
let p = books.join(f);
|
let p = books.join(f);
|
||||||
assert!(p.exists(), "{f} exists");
|
assert!(p.exists(), "{f} exists");
|
||||||
assert!(!fs::read_to_string(&p).unwrap().is_empty(), "{f} non-empty");
|
assert!(!fs::read_to_string(&p).unwrap().is_empty(), "{f} non-empty");
|
||||||
}
|
}
|
||||||
assert!(books.join("books-data").is_dir(), "books-data dir exists");
|
assert!(books.join("books-data").is_dir(), "books-data dir exists");
|
||||||
|
assert!(books.join(".git").is_dir(), "fresh dir gets git init");
|
||||||
|
let log = Command::new("git")
|
||||||
|
.args(["log", "--oneline"])
|
||||||
|
.current_dir(&books)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
books.join(".git").is_dir(),
|
String::from_utf8_lossy(&log.stdout).contains("init"),
|
||||||
"fresh dir gets git init for the journal archive"
|
"scaffold committed"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mise_toml_parses_and_names_repo_and_books() {
|
fn the_server_is_registered_for_this_dir() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let books = tmp.path().join("b");
|
let books = tmp.path().join("b");
|
||||||
let (ok, _) = run_init(&books, &[]);
|
assert!(new(tmp.path(), &books, &[]).0);
|
||||||
assert!(ok);
|
let books = books.canonicalize().unwrap();
|
||||||
let raw = fs::read_to_string(books.join("mise.toml")).unwrap();
|
let mcp: serde_json::Value =
|
||||||
let doc: toml::Value = toml::from_str(&raw).expect("mise.toml parses");
|
serde_json::from_str(&fs::read_to_string(books.join(".mcp.json")).unwrap()).unwrap();
|
||||||
assert!(doc.get("tools").is_some(), "[tools] present");
|
let server = &mcp["mcpServers"]["wanderbooks"];
|
||||||
let tasks = doc.get("tasks").expect("[tasks] present");
|
assert!(server["command"].as_str().unwrap().ends_with("ledger-mcp"));
|
||||||
for t in ["build", "mcp-add", "backup"] {
|
assert_eq!(
|
||||||
assert!(tasks.get(t).is_some(), "task {t} present");
|
server["args"],
|
||||||
|
serde_json::json!(["--data-dir", books.join("books-data").to_string_lossy()])
|
||||||
|
);
|
||||||
|
let settings: serde_json::Value =
|
||||||
|
serde_json::from_str(&fs::read_to_string(books.join(".claude/settings.json")).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
settings["enabledMcpjsonServers"],
|
||||||
|
serde_json::json!(["wanderbooks"])
|
||||||
|
);
|
||||||
|
// The justfile opens this dir's books in the GUI.
|
||||||
|
let just = fs::read_to_string(books.join("justfile")).unwrap();
|
||||||
|
assert!(just.contains("ledger-gui\" --data-dir books-data"));
|
||||||
|
for recipe in ["agent:", "gui:", "backup:", "update:"] {
|
||||||
|
assert!(just.contains(recipe), "justfile has {recipe}");
|
||||||
}
|
}
|
||||||
// The build task must point at the repo checkout; mcp-add at the books dir.
|
assert_eq!(
|
||||||
let build_run = tasks["build"]["run"].as_str().unwrap();
|
fs::read_to_string(books.join("CLAUDE.md")).unwrap().trim(),
|
||||||
assert!(
|
"@AGENTS.md"
|
||||||
build_run.contains("cargo build"),
|
|
||||||
"build runs cargo: {build_run}"
|
|
||||||
);
|
|
||||||
let mcp_run = tasks["mcp-add"]["run"].as_str().unwrap();
|
|
||||||
assert!(
|
|
||||||
mcp_run.contains("ledger-mcp") && mcp_run.contains("books-data"),
|
|
||||||
"mcp-add wires the server to this books dir: {mcp_run}"
|
|
||||||
);
|
|
||||||
let books_s = books.to_string_lossy();
|
|
||||||
assert!(
|
|
||||||
mcp_run.contains(books_s.as_ref()),
|
|
||||||
"mcp-add references the books dir path"
|
|
||||||
);
|
);
|
||||||
|
let toml: toml::Value =
|
||||||
|
toml::from_str(&fs::read_to_string(books.join("wanderbooks.toml")).unwrap()).unwrap();
|
||||||
|
assert_eq!(toml["books"]["data_dir"].as_str(), Some("books-data"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn agent_md_carries_the_contract() {
|
fn agents_md_carries_the_contract() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let books = tmp.path().join("b");
|
let books = tmp.path().join("b");
|
||||||
let (ok, _) = run_init(&books, &[]);
|
assert!(new(tmp.path(), &books, &[]).0);
|
||||||
assert!(ok);
|
let md = fs::read_to_string(books.join("AGENTS.md")).unwrap();
|
||||||
let md = fs::read_to_string(books.join("AGENT.md")).unwrap();
|
|
||||||
for phrase in [
|
for phrase in [
|
||||||
"preview",
|
"preview",
|
||||||
"Posted is forever",
|
"Posted is forever",
|
||||||
@@ -84,26 +125,39 @@ fn agent_md_carries_the_contract() {
|
|||||||
"receipt",
|
"receipt",
|
||||||
"journal.jsonl",
|
"journal.jsonl",
|
||||||
"onboard",
|
"onboard",
|
||||||
|
"chart_template",
|
||||||
"to the cent",
|
"to the cent",
|
||||||
|
"just gui",
|
||||||
] {
|
] {
|
||||||
assert!(md.contains(phrase), "AGENT.md mentions {phrase:?}");
|
assert!(md.contains(phrase), "AGENTS.md mentions {phrase:?}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reinit_refused_without_force() {
|
fn rerun_refused_without_force_and_never_clobbers_the_profile() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let books = tmp.path().join("b");
|
let books = tmp.path().join("b");
|
||||||
assert!(run_init(&books, &[]).0);
|
assert!(new(tmp.path(), &books, &[]).0);
|
||||||
let (ok, out) = run_init(&books, &[]);
|
let (ok, out) = new(tmp.path(), &books, &[]);
|
||||||
assert!(!ok, "second init without --force fails");
|
assert!(!ok, "second new without --force fails");
|
||||||
assert!(out.contains("--force"), "error points at --force: {out}");
|
assert!(out.contains("--force"), "error points at --force: {out}");
|
||||||
let (ok, _) = run_init(&books, &["--force"]);
|
fs::write(
|
||||||
assert!(ok, "second init with --force succeeds");
|
books.join("wanderbooks.toml"),
|
||||||
|
"[company]\nname = \"Acme LLC\"\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let (ok, _) = new(tmp.path(), &books, &["--force"]);
|
||||||
|
assert!(ok, "second new with --force succeeds");
|
||||||
|
assert!(
|
||||||
|
fs::read_to_string(books.join("wanderbooks.toml"))
|
||||||
|
.unwrap()
|
||||||
|
.contains("Acme LLC"),
|
||||||
|
"--force rewrites the scaffold, never the company profile"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn init_inside_existing_repo_skips_git_init() {
|
fn init_is_an_alias_and_respects_an_existing_repo() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let books = tmp.path().join("b");
|
let books = tmp.path().join("b");
|
||||||
fs::create_dir_all(&books).unwrap();
|
fs::create_dir_all(&books).unwrap();
|
||||||
@@ -115,8 +169,7 @@ fn init_inside_existing_repo_skips_git_init() {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.success()
|
.success()
|
||||||
);
|
);
|
||||||
let (ok, _) = run_init(&books, &[]);
|
let (ok, out) = run(tmp.path(), &["init", books.to_str().unwrap()]);
|
||||||
assert!(ok);
|
assert!(ok, "{out}");
|
||||||
// Still a valid repo (we didn't nest another one) and scaffold landed.
|
|
||||||
assert!(books.join("wanderbooks.toml").exists());
|
assert!(books.join("wanderbooks.toml").exists());
|
||||||
}
|
}
|
||||||
@@ -773,9 +773,14 @@ fn tax(b: &Books, s: NaiveDate, e: NaiveDate) -> Table {
|
|||||||
sec(&mut t, "Income", &ts.income);
|
sec(&mut t, "Income", &ts.income);
|
||||||
sec(&mut t, "Cost of goods sold", &ts.cost_of_goods);
|
sec(&mut t, "Cost of goods sold", &ts.cost_of_goods);
|
||||||
sec(&mut t, "Deductions", &ts.deductions);
|
sec(&mut t, "Deductions", &ts.deductions);
|
||||||
|
let net = if ts.uncategorized.is_empty() {
|
||||||
|
"Net"
|
||||||
|
} else {
|
||||||
|
"Net of categorized accounts"
|
||||||
|
};
|
||||||
t.row(
|
t.row(
|
||||||
Kind::Grand,
|
Kind::Grand,
|
||||||
"Net",
|
net,
|
||||||
vec![Cell::of(ts.book_net()), Cell::of(ts.tax_net())],
|
vec![Cell::of(ts.book_net()), Cell::of(ts.tax_net())],
|
||||||
);
|
);
|
||||||
if !ts.uncategorized.is_empty() {
|
if !ts.uncategorized.is_empty() {
|
||||||
|
|||||||
Executable
+41
@@ -0,0 +1,41 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# wanderbooks installer. One line on a new machine:
|
||||||
|
#
|
||||||
|
# curl -fsSL https://git.swaits.com/swaits/wanderbooks/raw/branch/main/install.sh | sh -s -- ~/Books/Acme
|
||||||
|
#
|
||||||
|
# Installs Rust if needed, then the wanderbooks CLI, MCP server, and GUI
|
||||||
|
# (into ~/.cargo/bin), then — given a directory — makes your books there and
|
||||||
|
# starts the agent. Without a directory it just installs.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
REPO="https://git.swaits.com/swaits/wanderbooks"
|
||||||
|
|
||||||
|
say() { printf '\033[1m%s\033[0m\n' "$*"; }
|
||||||
|
|
||||||
|
if ! command -v cargo >/dev/null 2>&1; then
|
||||||
|
say "Installing Rust (rustup)…"
|
||||||
|
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
. "$HOME/.cargo/env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
say "Installing wanderbooks (a few minutes, once)…"
|
||||||
|
cargo install --locked --git "$REPO" ledger-cli ledger-mcp ledger-gui
|
||||||
|
|
||||||
|
if ! command -v just >/dev/null 2>&1; then
|
||||||
|
say "Installing just (for the books dir's shortcuts)…"
|
||||||
|
cargo install --locked just
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v claude >/dev/null 2>&1; then
|
||||||
|
say "Claude Code isn't installed: see https://claude.com/claude-code"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ $# -gt 0 ]; then
|
||||||
|
# Hand the terminal to the agent when there is one.
|
||||||
|
if [ -t 1 ] && [ -e /dev/tty ]; then
|
||||||
|
exec "$HOME/.cargo/bin/wanderbooks" new "$@" </dev/tty
|
||||||
|
fi
|
||||||
|
exec "$HOME/.cargo/bin/wanderbooks" new "$@" --no-start
|
||||||
|
fi
|
||||||
|
say "Done. Make your books with: wanderbooks new ~/Books/YourCompany"
|
||||||
@@ -267,3 +267,10 @@ interface is MCP, not a cloned GUI. The first storage + MCP slice is done
|
|||||||
balance series and history, P&L by interval, flows), ledger-config
|
balance series and history, P&L by interval, flows), ledger-config
|
||||||
(extracted from ledger-mcp; full profile, cash_accounts). Fonts: Inter
|
(extracted from ledger-mcp; full profile, cash_accounts). Fonts: Inter
|
||||||
with tnum frozen in and private-use glyphs removed (Phosphor icons).
|
with tnum frozen in and private-use glyphs removed (Phosphor icons).
|
||||||
|
- 2026-10-12: One-command onboarding. `wanderbooks new DIR` installs the
|
||||||
|
server and GUI if missing, writes AGENTS.md (+ CLAUDE.md importing it),
|
||||||
|
wanderbooks.toml, a justfile (agent, gui, backup, update), .gitignore,
|
||||||
|
.mcp.json (the server registered for that dir only) and
|
||||||
|
.claude/settings.json (approved, no prompt), commits, and starts claude
|
||||||
|
with the onboarding prompt. `install.sh` does Rust + cargo install +
|
||||||
|
`wanderbooks new` in one line. mise is no longer part of the scaffold.
|
||||||
Reference in new issue
Block a user