Compare commits
4
Commits
45784266b5
...
2a00f4ef33
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a00f4ef33
|
||
|
|
54d2775a33
|
||
|
|
01d41c0daf
|
||
|
|
005a96055a
|
No files matched your search
@@ -2,3 +2,4 @@
|
|||||||
*.orig
|
*.orig
|
||||||
.DS_Store
|
.DS_Store
|
||||||
site/dist/
|
site/dist/
|
||||||
|
.netlify/
|
||||||
@@ -64,7 +64,7 @@ New books, one command (installs Rust and wanderbooks if needed, makes the
|
|||||||
books dir with the MCP server registered for it, and starts the agent):
|
books dir with the MCP server registered for it, and starts the agent):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://git.swaits.com/swaits/wanderbooks/raw/branch/main/install.sh | sh -s -- ~/Books/Acme
|
curl -fsSL https://wanderbooks.ai/install.sh | sh -s -- ~/Books/Acme
|
||||||
```
|
```
|
||||||
|
|
||||||
Already installed: `wanderbooks new ~/Books/Acme`. In a books dir, `just`
|
Already installed: `wanderbooks new ~/Books/Acme`. In a books dir, `just`
|
||||||
|
|||||||
@@ -270,20 +270,24 @@ pub fn show_account(ui: &mut Ui, b: &Books, id: u64, shell: &mut Shell<'_>) {
|
|||||||
theme::regular(12.5),
|
theme::regular(12.5),
|
||||||
p.muted,
|
p.muted,
|
||||||
);
|
);
|
||||||
painter.text(
|
// Leave room for the debit, credit, and balance columns.
|
||||||
|
let text_w = r.width() - 90.0 - amt * 3.0 - 12.0;
|
||||||
|
widgets::clipped_text(
|
||||||
|
&painter,
|
||||||
egui::pos2(r.left() + 90.0, y),
|
egui::pos2(r.left() + 90.0, y),
|
||||||
egui::Align2::LEFT_CENTER,
|
&desc,
|
||||||
desc,
|
|
||||||
font.clone(),
|
font.clone(),
|
||||||
p.text,
|
p.text,
|
||||||
|
text_w,
|
||||||
);
|
);
|
||||||
if !sub.is_empty() {
|
if !sub.is_empty() {
|
||||||
painter.text(
|
widgets::clipped_text(
|
||||||
|
&painter,
|
||||||
egui::pos2(r.left() + 90.0, r.center().y + 9.0),
|
egui::pos2(r.left() + 90.0, r.center().y + 9.0),
|
||||||
egui::Align2::LEFT_CENTER,
|
&sub,
|
||||||
sub,
|
|
||||||
theme::regular(11.5),
|
theme::regular(11.5),
|
||||||
p.faint,
|
p.faint,
|
||||||
|
text_w,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
for (i, v) in [d, c].iter().enumerate() {
|
for (i, v) in [d, c].iter().enumerate() {
|
||||||
|
|||||||
@@ -368,12 +368,13 @@ fn coming_up(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
|||||||
theme::regular(12.5),
|
theme::regular(12.5),
|
||||||
if late { p.negative } else { p.muted },
|
if late { p.negative } else { p.muted },
|
||||||
);
|
);
|
||||||
ui.painter().text(
|
widgets::clipped_text(
|
||||||
|
ui.painter(),
|
||||||
r.left_center() + vec2(70.0, 0.0),
|
r.left_center() + vec2(70.0, 0.0),
|
||||||
egui::Align2::LEFT_CENTER,
|
|
||||||
&it.label,
|
&it.label,
|
||||||
theme::regular(13.0),
|
theme::regular(13.0),
|
||||||
p.text,
|
p.text,
|
||||||
|
r.width() - 70.0 - 130.0,
|
||||||
);
|
);
|
||||||
let (txt, c) = if it.amount > Decimal::ZERO {
|
let (txt, c) = if it.amount > Decimal::ZERO {
|
||||||
(format!("+{}", fmt::money(it.amount)), p.positive)
|
(format!("+{}", fmt::money(it.amount)), p.positive)
|
||||||
@@ -405,7 +406,11 @@ fn biggest_outflows(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
|||||||
let mut out: Vec<&crate::books::EntryInfo> = b
|
let mut out: Vec<&crate::books::EntryInfo> = b
|
||||||
.entries
|
.entries
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|e| e.date > since && e.cash_effect < Decimal::ZERO)
|
.filter(|e| {
|
||||||
|
e.status == ledger_core::EntryStatus::Posted
|
||||||
|
&& e.date > since
|
||||||
|
&& e.cash_effect < Decimal::ZERO
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
out.sort_by_key(|e| e.cash_effect);
|
out.sort_by_key(|e| e.cash_effect);
|
||||||
out.truncate(8);
|
out.truncate(8);
|
||||||
@@ -431,12 +436,13 @@ fn biggest_outflows(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
|||||||
theme::regular(12.5),
|
theme::regular(12.5),
|
||||||
p.muted,
|
p.muted,
|
||||||
);
|
);
|
||||||
ui.painter().text(
|
widgets::clipped_text(
|
||||||
|
ui.painter(),
|
||||||
r.left_center() + vec2(70.0, 0.0),
|
r.left_center() + vec2(70.0, 0.0),
|
||||||
egui::Align2::LEFT_CENTER,
|
|
||||||
&e.description,
|
&e.description,
|
||||||
theme::regular(13.0),
|
theme::regular(13.0),
|
||||||
p.text,
|
p.text,
|
||||||
|
r.width() - 70.0 - 130.0,
|
||||||
);
|
);
|
||||||
ui.painter().text(
|
ui.painter().text(
|
||||||
r.right_center() - vec2(6.0, 0.0),
|
r.right_center() - vec2(6.0, 0.0),
|
||||||
|
|||||||
@@ -267,20 +267,33 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
|
|||||||
for &i in &e.events {
|
for &i in &e.events {
|
||||||
let line = &b.journal[i];
|
let line = &b.journal[i];
|
||||||
let ev = super::journal::describe_any(b, line);
|
let ev = super::journal::describe_any(b, line);
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal_top(|ui| {
|
||||||
ui.label(RichText::new(ev.icon).color(p.muted));
|
ui.label(RichText::new(ev.icon).color(p.muted));
|
||||||
let r = widgets::link(ui, RichText::new(&ev.text).font(theme::regular(12.5)));
|
ui.vertical(|ui| {
|
||||||
|
ui.spacing_mut().item_spacing.y = 0.0;
|
||||||
|
let r = ui.add(
|
||||||
|
egui::Label::new(RichText::new(&ev.text).font(theme::regular(12.5)))
|
||||||
|
.wrap()
|
||||||
|
.sense(egui::Sense::click()),
|
||||||
|
);
|
||||||
|
if r.hovered() {
|
||||||
|
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||||
|
}
|
||||||
if r.clicked() {
|
if r.clicked() {
|
||||||
shell.go(Route::JournalEvent(line.seq));
|
shell.go(Route::JournalEvent(line.seq));
|
||||||
}
|
}
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
|
||||||
ui.label(
|
ui.label(
|
||||||
RichText::new(format!("#{} · {}", line.seq, fmt::timestamp(&line.ts)))
|
RichText::new(format!(
|
||||||
.font(theme::regular(11.5))
|
"event {} · {}",
|
||||||
|
line.seq,
|
||||||
|
fmt::timestamp(&line.ts)
|
||||||
|
))
|
||||||
|
.font(theme::regular(11.0))
|
||||||
.color(p.faint),
|
.color(p.faint),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
ui.add_space(4.0);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -401,12 +401,13 @@ fn list(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
|
|||||||
egui::FontId::proportional(15.0),
|
egui::FontId::proportional(15.0),
|
||||||
p.muted,
|
p.muted,
|
||||||
);
|
);
|
||||||
painter.text(
|
widgets::clipped_text(
|
||||||
|
&painter,
|
||||||
egui::pos2(rect.left() + 80.0, y),
|
egui::pos2(rect.left() + 80.0, y),
|
||||||
egui::Align2::LEFT_CENTER,
|
|
||||||
&ev.text,
|
&ev.text,
|
||||||
theme::regular(13.0),
|
theme::regular(13.0),
|
||||||
p.text,
|
p.text,
|
||||||
|
rect.width() - 80.0 - 200.0,
|
||||||
);
|
);
|
||||||
painter.text(
|
painter.text(
|
||||||
egui::pos2(rect.right() - 8.0, y),
|
egui::pos2(rect.right() - 8.0, y),
|
||||||
|
|||||||
@@ -433,39 +433,51 @@ fn recent(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
|||||||
}
|
}
|
||||||
shown += 1;
|
shown += 1;
|
||||||
let fresh = line.seq > b.fresh_from_seq && since_update < 8.0;
|
let fresh = line.seq > b.fresh_from_seq && since_update < 8.0;
|
||||||
let row = egui::Frame::new()
|
// One painted row: icon, the event (cut with an ellipsis
|
||||||
.fill(if fresh {
|
// before the timestamp column), the timestamp.
|
||||||
p.new_highlight
|
let (r, resp) = ui.allocate_exact_size(
|
||||||
} else {
|
egui::vec2(ui.available_width(), 30.0),
|
||||||
egui::Color32::TRANSPARENT
|
egui::Sense::click(),
|
||||||
})
|
|
||||||
.corner_radius(egui::CornerRadius::same(6))
|
|
||||||
.inner_margin(egui::Margin::symmetric(6, 3))
|
|
||||||
.show(ui, |ui| {
|
|
||||||
ui.set_width(ui.available_width());
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label(RichText::new(ev.icon).size(15.0).color(p.muted));
|
|
||||||
let r = widgets::link(
|
|
||||||
ui,
|
|
||||||
RichText::new(&ev.text).font(theme::regular(13.0)),
|
|
||||||
);
|
);
|
||||||
if r.clicked() {
|
if fresh {
|
||||||
go =
|
ui.painter()
|
||||||
Some(ev.route.clone().unwrap_or(Route::JournalEvent(line.seq)));
|
.rect_filled(r, egui::CornerRadius::same(6), p.new_highlight);
|
||||||
|
} else if resp.hovered() {
|
||||||
|
ui.painter()
|
||||||
|
.rect_filled(r, egui::CornerRadius::same(6), p.hover);
|
||||||
}
|
}
|
||||||
ui.with_layout(
|
if resp.hovered() {
|
||||||
egui::Layout::right_to_left(egui::Align::Center),
|
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||||
|ui| {
|
}
|
||||||
ui.label(
|
let painter = ui.painter_at(r);
|
||||||
RichText::new(fmt::timestamp(&line.ts))
|
let y = r.center().y;
|
||||||
.font(theme::regular(11.5))
|
painter.text(
|
||||||
.color(p.faint),
|
egui::pos2(r.left() + 8.0, y),
|
||||||
|
egui::Align2::LEFT_CENTER,
|
||||||
|
ev.icon,
|
||||||
|
egui::FontId::proportional(15.0),
|
||||||
|
p.muted,
|
||||||
);
|
);
|
||||||
},
|
let ts =
|
||||||
|
painter.layout_no_wrap(fmt::timestamp(&line.ts), theme::regular(11.5), p.faint);
|
||||||
|
let ts_w = ts.size().x;
|
||||||
|
painter.galley(
|
||||||
|
egui::pos2(r.right() - 8.0 - ts_w, y - ts.size().y / 2.0),
|
||||||
|
ts,
|
||||||
|
p.faint,
|
||||||
);
|
);
|
||||||
});
|
widgets::clipped_text(
|
||||||
});
|
&painter,
|
||||||
let _ = (row, i);
|
egui::pos2(r.left() + 32.0, y),
|
||||||
|
&ev.text,
|
||||||
|
theme::regular(13.0),
|
||||||
|
p.text,
|
||||||
|
r.width() - 32.0 - ts_w - 28.0,
|
||||||
|
);
|
||||||
|
if resp.clicked() {
|
||||||
|
go = Some(ev.route.clone().unwrap_or(Route::JournalEvent(line.seq)));
|
||||||
|
}
|
||||||
|
let _ = i;
|
||||||
}
|
}
|
||||||
if shown == 0 {
|
if shown == 0 {
|
||||||
widgets::empty(ui, icon::CLOCK, "Nothing yet", "");
|
widgets::empty(ui, icon::CLOCK, "Nothing yet", "");
|
||||||
|
|||||||
@@ -289,3 +289,27 @@ pub fn segmented<T: PartialEq + Copy>(ui: &mut Ui, value: &mut T, options: &[(T,
|
|||||||
});
|
});
|
||||||
changed
|
changed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Left-aligned single-line text, vertically centered at `pos`, cut with
|
||||||
|
/// an ellipsis at `max_width` so it never runs into whatever is to its
|
||||||
|
/// right (an amount, a date).
|
||||||
|
pub fn clipped_text(
|
||||||
|
painter: &egui::Painter,
|
||||||
|
pos: egui::Pos2,
|
||||||
|
text: &str,
|
||||||
|
font: egui::FontId,
|
||||||
|
color: Color32,
|
||||||
|
max_width: f32,
|
||||||
|
) {
|
||||||
|
use egui::text::{LayoutJob, TextFormat, TextWrapping};
|
||||||
|
let mut job = LayoutJob::single_section(text.to_string(), TextFormat::simple(font, color));
|
||||||
|
job.wrap = TextWrapping {
|
||||||
|
max_width: max_width.max(10.0),
|
||||||
|
max_rows: 1,
|
||||||
|
break_anywhere: true,
|
||||||
|
overflow_character: Some('…'),
|
||||||
|
};
|
||||||
|
let galley = painter.layout_job(job);
|
||||||
|
let y = pos.y - galley.size().y / 2.0;
|
||||||
|
painter.galley(egui::pos2(pos.x, y), galley, color);
|
||||||
|
}
|
||||||
@@ -76,7 +76,13 @@ fn screenshots() {
|
|||||||
}
|
}
|
||||||
let books = data::load_once(&source).unwrap();
|
let books = data::load_once(&source).unwrap();
|
||||||
let mut harness = Harness::builder()
|
let mut harness = Harness::builder()
|
||||||
.with_size(egui::vec2(1440.0, 920.0))
|
.with_size(egui::vec2(
|
||||||
|
1440.0,
|
||||||
|
std::env::var("SHOT_HEIGHT")
|
||||||
|
.ok()
|
||||||
|
.and_then(|h| h.parse().ok())
|
||||||
|
.unwrap_or(920.0),
|
||||||
|
))
|
||||||
.build_eframe(|cc| App::with_books(&cc.egui_ctx, books, dark, route.clone()));
|
.build_eframe(|cc| App::with_books(&cc.egui_ctx, books, dark, route.clone()));
|
||||||
harness.run_steps(4);
|
harness.run_steps(4);
|
||||||
let ctx = harness.ctx.clone();
|
let ctx = harness.ctx.clone();
|
||||||
|
|||||||
@@ -101,11 +101,27 @@ impl ReceiptPolicy {
|
|||||||
|
|
||||||
/// Human-readable policy summary for previews and errors.
|
/// Human-readable policy summary for previews and errors.
|
||||||
pub fn describe(&self) -> String {
|
pub fn describe(&self) -> String {
|
||||||
|
if self.threshold().is_zero() {
|
||||||
|
"receipts required on every entry".to_string()
|
||||||
|
} else {
|
||||||
format!(
|
format!(
|
||||||
"receipts required when entry debits are strictly greater than {}",
|
"receipts required when entry debits are strictly greater than {}",
|
||||||
self.threshold()
|
self.threshold()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The receipt rule, phrased for the agent's operating instructions.
|
||||||
|
pub fn rule(&self) -> String {
|
||||||
|
if self.threshold().is_zero() {
|
||||||
|
"every entry needs at least one receipt".to_string()
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"any entry moving more than ${} in debits needs at least one receipt",
|
||||||
|
self.threshold().round_dp(2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Normalize a description for split detection.
|
/// Normalize a description for split detection.
|
||||||
fn normalize_description(desc: &str) -> String {
|
fn normalize_description(desc: &str) -> String {
|
||||||
|
|||||||
@@ -4051,7 +4051,11 @@ impl ServerHandler for BooksServer {
|
|||||||
.enable_resources()
|
.enable_resources()
|
||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
.with_instructions(INSTRUCTIONS)
|
// Tell the agent the policy these books actually use.
|
||||||
|
.with_instructions(INSTRUCTIONS.replace(
|
||||||
|
"any entry moving more than $25.00 in debits needs at least one receipt",
|
||||||
|
&self.policy.rule(),
|
||||||
|
))
|
||||||
.with_server_info(Implementation::new(
|
.with_server_info(Implementation::new(
|
||||||
"wanderbooks",
|
"wanderbooks",
|
||||||
env!("CARGO_PKG_VERSION"),
|
env!("CARGO_PKG_VERSION"),
|
||||||
|
|||||||
@@ -1586,3 +1586,54 @@ fn mcp_vendor_1099_over_stdio() {
|
|||||||
assert_eq!(v["blobs_verified"], 1);
|
assert_eq!(v["blobs_verified"], 1);
|
||||||
assert!(v["events"].as_u64().unwrap() > 10);
|
assert!(v["events"].as_u64().unwrap() > 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mcp_receipts_always_required_when_threshold_is_zero() {
|
||||||
|
let dir = test_dir("always-receipts");
|
||||||
|
let toml = dir.join("wanderbooks.toml");
|
||||||
|
std::fs::write(&toml, "[books]\nreceipt_threshold = \"0.00\"\n").unwrap();
|
||||||
|
let mut mcp = Mcp::spawn_with(
|
||||||
|
&dir.join("books-data"),
|
||||||
|
&[std::ffi::OsStr::new("--books-toml"), toml.as_os_str()],
|
||||||
|
);
|
||||||
|
let init = mcp.request(
|
||||||
|
"initialize",
|
||||||
|
json!({
|
||||||
|
"protocolVersion": "2025-06-18",
|
||||||
|
"capabilities": {},
|
||||||
|
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let instructions = init["result"]["instructions"].as_str().unwrap();
|
||||||
|
assert!(
|
||||||
|
instructions.contains("every entry needs at least one receipt"),
|
||||||
|
"the agent is told the real policy: {instructions}"
|
||||||
|
);
|
||||||
|
assert!(!instructions.contains("$25.00"));
|
||||||
|
mcp.notify("notifications/initialized");
|
||||||
|
for (code, name, typ) in [("1000", "Bank", "asset"), ("6000", "Fees", "expense")] {
|
||||||
|
mcp.call(
|
||||||
|
"books.add_account",
|
||||||
|
json!({"code": code, "name": name, "account_type": typ}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let e = mcp.call(
|
||||||
|
"books.draft_entry",
|
||||||
|
json!({"date": "2026-10-01", "description": "Bank fee"}),
|
||||||
|
);
|
||||||
|
let id = e["entry_id"].as_u64().unwrap();
|
||||||
|
for (account, side) in [("6000", "debit"), ("1000", "credit")] {
|
||||||
|
mcp.call(
|
||||||
|
"books.add_line",
|
||||||
|
json!({"entry_id": id, "account": account, "side": side, "amount": "5.00"}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let err = mcp.call_err(
|
||||||
|
"books.post_entry",
|
||||||
|
json!({"entry_id": id, "confirmed": true}),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
err.contains("receipt"),
|
||||||
|
"a $5 entry needs a receipt too: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# wanderbooks installer. One line on a new machine:
|
# 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
|
# curl -fsSL https://wanderbooks.ai/install.sh | sh -s -- ~/Books/Acme
|
||||||
#
|
#
|
||||||
# Linux and macOS. On Windows, run it inside WSL2 (Ubuntu): the GUI opens as
|
# Linux and macOS. On Windows, run it inside WSL2 (Ubuntu): the GUI opens as
|
||||||
# a normal window on Windows 11.
|
# a normal window on Windows 11.
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
set shell := ["bash", "-cu"]
|
set shell := ["bash", "-cu"]
|
||||||
|
|
||||||
|
# The Netlify CLI, fetched on demand.
|
||||||
|
netlify := "npx --yes netlify-cli@27"
|
||||||
|
|
||||||
# List recipes
|
# List recipes
|
||||||
default:
|
default:
|
||||||
@just --list
|
@just --list
|
||||||
@@ -41,6 +44,10 @@ site:
|
|||||||
cp site/index.html install.sh site/dist/
|
cp site/index.html install.sh site/dist/
|
||||||
cp -r site/assets site/dist/
|
cp -r site/assets site/dist/
|
||||||
|
|
||||||
# Publish the website: just publish user@host:/var/www/wanderbooks
|
# Deploy the website to wanderbooks.ai (Netlify; `netlify login` once first)
|
||||||
publish dest: site
|
deploy: site
|
||||||
rsync -av --delete site/dist/ "{{dest}}"
|
{{netlify}} deploy --prod --dir site/dist --no-build
|
||||||
|
|
||||||
|
# Deploy a preview of the website and print its URL
|
||||||
|
deploy-preview: site
|
||||||
|
{{netlify}} deploy --dir site/dist --no-build
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# wanderbooks.ai: the static site in site/, plus install.sh at the root.
|
||||||
|
# `just site` builds the same thing locally; `just deploy` publishes it.
|
||||||
|
[build]
|
||||||
|
command = "rm -rf site/dist && mkdir -p site/dist && cp site/index.html install.sh site/dist/ && cp -r site/assets site/dist/"
|
||||||
|
publish = "site/dist"
|
||||||
|
|
||||||
|
# curl | sh reads plain text; never let a CDN cache an old installer for long.
|
||||||
|
[[headers]]
|
||||||
|
for = "/install.sh"
|
||||||
|
[headers.values]
|
||||||
|
Content-Type = "text/plain; charset=utf-8"
|
||||||
|
Cache-Control = "public, max-age=300"
|
||||||
|
|
||||||
|
[[headers]]
|
||||||
|
for = "/assets/*"
|
||||||
|
[headers.values]
|
||||||
|
Cache-Control = "public, max-age=86400"
|
||||||
+9
-3
@@ -5,6 +5,12 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>wanderbooks — own your books, hire an AI bookkeeper</title>
|
<title>wanderbooks — own your books, hire an AI bookkeeper</title>
|
||||||
<meta name="description" content="Real double-entry bookkeeping in a folder you own, kept by an AI bookkeeper you talk to. No subscription, no lock-in, every number traceable to its receipt. An open-source alternative to QuickBooks Online.">
|
<meta name="description" content="Real double-entry bookkeeping in a folder you own, kept by an AI bookkeeper you talk to. No subscription, no lock-in, every number traceable to its receipt. An open-source alternative to QuickBooks Online.">
|
||||||
|
<link rel="canonical" href="https://wanderbooks.ai/">
|
||||||
|
<meta property="og:title" content="wanderbooks — own your books, hire an AI bookkeeper">
|
||||||
|
<meta property="og:description" content="Real double-entry bookkeeping in a folder you own, kept by an AI bookkeeper you talk to. No subscription, no lock-in.">
|
||||||
|
<meta property="og:url" content="https://wanderbooks.ai/">
|
||||||
|
<meta property="og:image" content="https://wanderbooks.ai/assets/overview.png">
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
@@ -117,7 +123,7 @@ footer { padding: 72px 0 48px; color: var(--faint); font-size: 14px; display: fl
|
|||||||
<p class="lede">wanderbooks is real double-entry accounting that lives in <b>a folder you own</b>, kept by <b>an AI bookkeeper you talk to</b>. It finds your receipts, records and categorizes everything, fixes its mistakes the way an accountant would, and hands your CPA a clean package. No subscription. No lock-in. Every number traceable to its receipt.</p>
|
<p class="lede">wanderbooks is real double-entry accounting that lives in <b>a folder you own</b>, kept by <b>an AI bookkeeper you talk to</b>. It finds your receipts, records and categorizes everything, fixes its mistakes the way an accountant would, and hands your CPA a clean package. No subscription. No lock-in. Every number traceable to its receipt.</p>
|
||||||
<div class="install">
|
<div class="install">
|
||||||
<span class="prompt">$</span>
|
<span class="prompt">$</span>
|
||||||
<code id="cmd">curl -fsSL https://git.swaits.com/swaits/wanderbooks/raw/branch/main/install.sh | sh -s -- ~/Books/YourCompany</code>
|
<code id="cmd">curl -fsSL https://wanderbooks.ai/install.sh | sh -s -- ~/Books/YourCompany</code>
|
||||||
<button class="copy" id="copy">Copy</button>
|
<button class="copy" id="copy">Copy</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="note">One line installs everything, makes your books, and starts your bookkeeper. Linux and macOS; Windows via WSL2. Free and open source; you bring <a href="https://claude.com/claude-code">Claude</a>.</p>
|
<p class="note">One line installs everything, makes your books, and starts your bookkeeper. Linux and macOS; Windows via WSL2. Free and open source; you bring <a href="https://claude.com/claude-code">Claude</a>.</p>
|
||||||
@@ -250,10 +256,10 @@ footer { padding: 72px 0 48px; color: var(--faint); font-size: 14px; display: fl
|
|||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
// When served next to install.sh, point the one-liner at this site.
|
// On a preview deploy, point the one-liner at the preview's own install.sh.
|
||||||
(function () {
|
(function () {
|
||||||
var cmd = document.getElementById("cmd");
|
var cmd = document.getElementById("cmd");
|
||||||
if (location.protocol === "https:") {
|
if (location.protocol === "https:" && location.hostname !== "wanderbooks.ai") {
|
||||||
cmd.textContent = "curl -fsSL " + location.origin + "/install.sh | sh -s -- ~/Books/YourCompany";
|
cmd.textContent = "curl -fsSL " + location.origin + "/install.sh | sh -s -- ~/Books/YourCompany";
|
||||||
}
|
}
|
||||||
document.getElementById("copy").addEventListener("click", function () {
|
document.getElementById("copy").addEventListener("click", function () {
|
||||||
|
|||||||
Reference in new issue
Block a user