A command-line client for the Invoice Ninja v5 API, built on the invoiceninja-sdk crate (consumed from crates.io, 0.2.0). Only the clients resource is wired up so far, as the template the rest follow - see CLAUDE.md and GOALS.md for the reasoning behind the shape of things (the update fetch-merge-write pattern, why there's no Cargo workspace with the SDK, the local-checkout override for co-development, and the Nix flake packaging via packages.default and overlays.default). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
7.6 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this is
A command-line client for the Invoice Ninja
v5 API, built on invoiceninja-sdk,
consumed as a normal crates.io dependency (not a path dependency on a sibling
checkout — see "Development environment" below for how that changed).
That crate's own GOALS.md (at ../invoiceninjars-sdk/GOALS.md, if you have
a checkout for reference) explains why it's a separate crate rather than a
feature of the library — dependencies, MSRV, and versioning, all pre-1.0.
Read that GOALS.md before making a design call here; this project exists
downstream of decisions made there.
This project's own GOALS.md states what it is for, what it
deliberately is not, and what is still undecided — read it before making a
design call, the same way its counterpart in the SDK works. If a change
would contradict a stated goal or settle an open question, update
GOALS.md in the same change.
Early scaffold. Only the clients resource is wired up, as the template
for the other ~34. See "Adding a resource" below before writing the next one.
Development environment
Nix-managed, with its own flake.nix (not shared with the SDK's):
nix develop
The dev toolchain was originally pinned to match invoiceninjars-sdk's own
(devVersion in each flake.nix), from back when this crate needed a sibling
checkout to build at all. That's no longer a hard requirement now that the
SDK is on crates.io, but the pin hasn't been revisited — if the two drift,
nothing here forces them back in sync, since the two flakes are independent
files.
As of 0.2.0 (2026-10-25), the SDK is published on crates.io and this
crate depends on it as a normal version requirement
(invoiceninja-sdk = "0.2.0" in Cargo.toml), not a path dependency. That
means:
- No
../invoiceninjars-sdkcheckout is needed to build this crate anymore.cargo buildfetchesinvoiceninja-sdkfrom the registry like any other dependency. - Picking up a change made in the SDK's working copy now requires that
change to be published as a new crates.io release first by default — there
is no more "edit the sibling checkout, rebuild here" loop out of the box.
For active co-development across both crates, copy
.cargo/config.toml.exampleto.cargo/config.toml(gitignored, so it stays per-developer and never affects CI or anyone else's build) to get apathsoverride back onto../invoiceninjars-sdk. It requires that checkout's[package] versionto match this crate'sinvoiceninja-sdkrequirement inCargo.toml— bump both together if the SDK's working copy moves to an unreleased version. Delete.cargo/config.tomlto go back to the published release. - Pre-1.0, a y-bump (
0.2.x→0.3.0) in the SDK is a breaking release per that crate's ownGOALS.md. The default caret requirement already blocks picking that up automatically; bumping the version inCargo.tomlis a deliberate, reviewed step, not somethingcargo updateshould do on its own.
Watch the SDK's own package/crate name. It has changed at least once
already (invoiceninjars → invoiceninja-sdk, crate path invoiceninja_sdk)
without a corresponding change here landing first. If a build fails with "no
matching package named invoiceninja-sdk found" or an unresolved
invoiceninja_sdk:: path, check the crates.io listing (or
../invoiceninjars-sdk/Cargo.toml's [package] name, if you have a
checkout) first.
Common commands
cargo build
cargo run -- clients list # needs IN_API_TOKEN, or --token
cargo fmt --check
cargo clippy --all-targets
No test suite yet — nothing here has behavior worth testing beyond what
clap's derive and the SDK's own test suite already cover. Add one (clap's
Cli::try_parse_from is the usual way to test argument parsing without
spawning a process) when a command does more than translate flags into an
SDK call.
Architecture
src/cli.rs— allclapderive types.Cliholds the global options (--base-url,--token,--api-password,--output) and the top-levelCommandsubcommand enum.--tokencan't be clap-requiredbecause clap rejectsrequiredcombined withglobal(global is what lets--tokenappear after the resource subcommand too, not just before it) — the missing case is caught inconfig::build_clientinstead, with a message naming both the flag and the env var. Don't try to make itrequiredagain; the comment on the field explains the panic you'd get.src/config.rs—build_clientturns a parsedCliinto anInvoiceNinjaClient, including the manual token check above.src/body.rs— howcreate/updateget their JSON body (--datainline,--filefrom disk or-for stdin, or stdin by default), plusmerge_shallow, a one-level-deep object overlay.src/output.rs—print_json, shared by every resource's--output json. Table rendering is not here; it's per-resource (see below), since there's no generic notion of which columns matter.src/commands/clients.rs— the one resource implemented so far, and the template for the rest.
The update fetch-merge-write pattern
update is not a thin wrapper over the SDK's update_client. It:
- Fetches the current record with
get_client. - Serializes it to
serde_json::Valueand shallow-merges the caller's--data/--fileJSON on top (body::merge_shallow). - Deserializes the merged value back into the model and calls
update_client.
This exists because Invoice Ninja's PUT is a full replace — there's no
PATCH route — and every non-Option field on an SDK model decodes a missing
key as that type's default ("", 0, false; see
invoiceninja_sdk's null_to_default). Sending the caller's patch alone
would silently blank out every field they didn't mention. Keep this
pattern for every resource's update — it's the CLI-level version of the
SDK's own "never lose data in a round trip" goal, not a one-off for clients.
create has no such step: a partial JSON body decodes straight into the
model, since the same defaulting that makes a bare patch dangerous for
update is exactly what you want when creating a record whose fields you
haven't all thought about (the API does the equivalent server-side).
Adding a resource
Each resource beyond clients should follow the same shape:
- Add a variant to
Commandinsrc/cli.rs(e.g.Invoices(ResourceArgs)) —ResourceArgsandActionare already generic across resources, reuse them as-is. - Add
src/commands/<resource>.rsmirroringclients.rs: arundispatcher overAction,list/get/create/update/deletefunctions calling the matchinglist_<resource>/get_<resource>/… methods onInvoiceNinjaClient, and aprint_tablechoosing that resource's own key columns. - Register the module in
src/commands/mod.rsand the dispatch arm inmain.rs'smatch cli.command. - Check
../invoiceninjars-sdk/src/client.rsfor whether the resource has a/bulkroute (most do; a few —client_gateway_tokens,companies,system_logs— don't, per that crate'scrud_methods!vsresource_methods!split). This CLI doesn't expose bulk actions yet either way; that's a separate addition.
Resources with entity actions, document uploads, or non-CRUD endpoints
(reports, charts, search, statics, PDF downloads, …) will need more than
this template once they're added — the SDK's own CLAUDE.md describes which
resources have which extras.