Compare commits

..
2 Commits
Author SHA1 Message Date
awkawbandClaude Sonnet 5 f8e08af810 Set repository in Cargo.toml now that there's a remote
Gitea (origin) is canonical with a push mirror to GitHub, same setup
as invoiceninja-sdk. Points at the GitHub mirror to match that
crate's Cargo.toml and clear cargo's "no documentation, homepage or
repository" publish warning.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-26 14:49:50 -04:00
awkawbandClaude Sonnet 5 dde10e522a Initial commit: clients resource scaffold over invoiceninja-sdk
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>
2026-10-26 11:47:15 -04:00
18 changed files with 2307 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
# Copy this file to `.cargo/config.toml` (gitignored, so this stays
# per-developer) to build against a local `../invoiceninjars-sdk` checkout
# instead of the published crates.io release of `invoiceninja-sdk`. Useful
# for testing an SDK change here before it's published.
#
# Cargo's `paths` override requires the local checkout's `[package] version`
# in its Cargo.toml to match the version requirement in this crate's
# Cargo.toml (currently "0.2.0") — if you bump the SDK's version locally
# while working on the next release, this override stops matching until
# this crate's `Cargo.toml` is bumped to match (or the SDK version is reset).
#
# Delete `.cargo/config.toml` (or just this line) to go back to the
# published version.
paths = ["../invoiceninjars-sdk"]
+18
View File
@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.rs]
indent_style = space
indent_size = 4
[*.{yml,yaml,toml,nix}]
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false
+7
View File
@@ -0,0 +1,7 @@
/target
.devenv/
.direnv/
*.swp
/.cargo/config.toml
/result
/result-*
+156
View File
@@ -0,0 +1,156 @@
# 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](https://www.invoiceninja.com/)
v5 API, built on [`invoiceninja-sdk`](https://crates.io/crates/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`](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):
```sh
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-sdk` checkout is needed to build this crate anymore.
`cargo build` fetches `invoiceninja-sdk` from 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.example` to `.cargo/config.toml` (gitignored, so it
stays per-developer and never affects CI or anyone else's build) to get
a `paths` override back onto `../invoiceninjars-sdk`. It requires that
checkout's `[package] version` to match this crate's `invoiceninja-sdk`
requirement in `Cargo.toml` — bump both together if the SDK's working
copy moves to an unreleased version. Delete `.cargo/config.toml` to 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 own `GOALS.md`. The default caret requirement already blocks
picking that up automatically; bumping the version in `Cargo.toml` is a
deliberate, reviewed step, not something `cargo update` should 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
```sh
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` — all `clap` derive types. `Cli` holds the global
options (`--base-url`, `--token`, `--api-password`, `--output`) and the
top-level `Command` subcommand enum. **`--token` can't be clap-`required`**
because clap rejects `required` combined with `global` (global is what
lets `--token` appear after the resource subcommand too, not just before
it) — the missing case is caught in `config::build_client` instead, with a
message naming both the flag and the env var. Don't try to make it
`required` again; the comment on the field explains the panic you'd get.
- `src/config.rs` — `build_client` turns a parsed `Cli` into an
`InvoiceNinjaClient`, including the manual token check above.
- `src/body.rs` — how `create`/`update` get their JSON body (`--data`
inline, `--file` from disk or `-` for stdin, or stdin by default), plus
`merge_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:
1. Fetches the current record with `get_client`.
2. Serializes it to `serde_json::Value` and shallow-merges the caller's
`--data`/`--file` JSON on top (`body::merge_shallow`).
3. 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:
1. Add a variant to `Command` in `src/cli.rs` (e.g. `Invoices(ResourceArgs)`)
— `ResourceArgs` and `Action` are already generic across resources, reuse
them as-is.
2. Add `src/commands/<resource>.rs` mirroring `clients.rs`: a `run` dispatcher
over `Action`, `list`/`get`/`create`/`update`/`delete` functions calling
the matching `list_<resource>`/`get_<resource>`/… methods on
`InvoiceNinjaClient`, and a `print_table` choosing that resource's own key
columns.
3. Register the module in `src/commands/mod.rs` and the dispatch arm in
`main.rs`'s `match cli.command`.
4. Check `../invoiceninjars-sdk/src/client.rs` for whether the resource has a
`/bulk` route (most do; a few — `client_gateway_tokens`, `companies`,
`system_logs` — don't, per that crate's `crud_methods!` vs
`resource_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.
Generated
+1333
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "invoiceninja-cli"
version = "0.1.0"
edition = "2021"
# Matches the SDK's own MSRV. clap 4.6.7 declares 1.85 (see GOALS.md in
# invoiceninjars-sdk), which is under this, so the SDK's floor is still the
# binding one. Recheck both together if either dependency tree moves.
rust-version = "1.88"
description = "A command-line client for the Invoice Ninja v5 API, built on invoiceninja-sdk"
license = "MIT"
repository = "https://github.com/awkawb1/invoiceninja-cli"
# Slugs checked against https://crates.io/api/v1/category_slugs, same as
# invoiceninja-sdk's own Cargo.toml.
categories = ["command-line-utilities"]
keywords = ["invoiceninja", "invoicing", "billing", "cli"]
[dependencies]
# Published on crates.io as of 0.2.0 (2026-10-25); no longer a path
# dependency on a sibling checkout. Pre-1.0, a y-bump (0.2.x -> 0.3.0) is a
# breaking release per that crate's own GOALS.md, so stick with the default
# caret requirement rather than widening it — it already blocks that
# automatically while still taking 0.2.x patch releases.
invoiceninja-sdk = "0.2.0"
clap = { version = "4", features = ["derive", "env"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+130
View File
@@ -0,0 +1,130 @@
# Goals
What this crate is trying to be, what it is deliberately not, and what is
still open. Same purpose as `invoiceninjars-sdk`'s own `GOALS.md`: several
choices here look like oversights until you know the reasoning, and that
reasoning should live here rather than only in commit messages.
## What this is for
A command-line way to drive the Invoice Ninja v5 API through
`invoiceninja-sdk`, for the cases Rust code is the wrong tool for: a quick
"what does this endpoint actually return", a one-off fix from a terminal, or
a shell script that doesn't want to be a Rust program. The SDK's own
`GOALS.md` names this exact need — its `examples/` cover ad-hoc probing
today, and this crate is the standalone version of that.
It is a client over the SDK, not a second implementation of anything the SDK
already does. If a command needs logic beyond "turn flags and JSON into one
SDK call", that is usually a sign the logic belongs in the SDK instead.
## Goals
### 1. Every command is a thin translation, with one documented exception
`list`, `get`, `create`, and `delete` do nothing but turn CLI flags into a
`ListParams`/model/id and call the matching SDK method. **`update` is the
one exception**, and it stays an exception rather than a pattern to extend
casually: it fetches the record, overlays the caller's JSON on top, and
writes the merged result back, because Invoice Ninja's `PUT` is a full
replace and the SDK's models default an omitted field rather than leaving it
alone. See the comment in `src/commands/clients.rs` and the matching section
in `CLAUDE.md`. Any other deviation from "thin translation" should be
written down the same way.
### 2. `--output json` is always the full, lossless record
The table view is a convenience subset — whatever columns a resource's
`print_table` picks — and it is allowed to leave things out. `--output json`
is not: it is always the model's own `Serialize` output, including
`extra`, so it is a drop-in input to `--data`/`--file` for a later `update`
or `create` and a reliable thing to pipe into `jq`. This is the CLI-level
version of the SDK's "never lose data in a round trip" goal.
### 3. One pattern across resources
Adding a resource should be mechanical, not a new design each time: a
`Command` variant, a `src/commands/<resource>.rs` with the same
`run`/`list`/`get`/`create`/`update`/`delete`/`print_table` shape as
`clients.rs`, and a registration line in two places. `CLAUDE.md`'s "Adding a
resource" section is the checklist; if a resource needs something the
template doesn't cover (bulk, uploads, entity actions, non-CRUD endpoints),
that is scope for a deliberate addition to the template, not a one-off.
### 4. Scriptable by default
Errors go to stderr and a failure exits non-zero; nothing is printed to
stdout on an error path that a script could mistake for a result. Secrets
(`--token`, `--api-password`) never appear in `--help` or in any echoed
default — see `hide_env_values` on each in `src/cli.rs`. The SDK already
keeps both out of its own `Debug` output; this crate's job is not to undo
that by printing them itself.
### 5. Inherit the SDK's safety defaults rather than re-deciding them
This crate does not expose its own retry/timeout policy or add a write path
the SDK doesn't already consider safe. If the SDK decides `POST` isn't
replayed on a 5xx because it might double-create an invoice, that reasoning
applies here unchanged — the CLI is not a place to relax it for convenience.
## Non-goals
- **No Cargo workspace with the SDK.** Explained in `CLAUDE.md`: the SDK is
its own git repo with its own CI and crates.io release, mirrored read-only
to GitHub. Now that the SDK is published (0.2.0, 2026-10-25), the
dependency is a plain crates.io version requirement — there isn't even a
path dependency linking the two build graphs anymore, let alone a
workspace. This doesn't mean co-development across both crates is gone:
`.cargo/config.toml.example` documents a per-developer `paths` override
back onto a local `../invoiceninjars-sdk` checkout, gitignored so it never
becomes the default for anyone else or for CI. See `CLAUDE.md`.
- **No interactive mode or TUI.** Every command should work non-interactively
from a script; a prompt-driven mode would be a different tool.
- **No local state, cache, or config file — yet.** Credentials and the base
URL come from flags or environment variables only, resolved fresh on every
invocation. See the open question below on multi-account profiles before
assuming this is permanent.
- **No stability promise before 1.0.** Same posture as the SDK: flags are a
compatibility surface too, and pre-1.0 they can change. Record breaking
changes somewhere legible (a `CHANGELOG.md`, once there's enough history to
need one) rather than letting them be discovered at the command line.
## Open questions
These are genuinely undecided; deciding one is a change to this file.
- **Bulk actions.** The SDK exposes `bulk_<resource>` (archive/restore/delete
plus resource-specific actions) on every resource with a `/bulk` route.
Nothing here calls it yet. Worth having, but a bulk `delete` from a CLI
invites a worse mistake than a one-record `delete` — whether that needs a
confirmation prompt, a `--yes` flag, or a dry-run preview is unresolved.
- **`--dry-run` for `create`/`update`/`delete`.** Would print what the merged
body or the target of a delete would be without sending the request.
Cheap for `create`/`delete`; for `update` it would mean exposing the
fetch-merge result for inspection before the write half runs.
- **Multi-account profiles.** Flags and env vars cover one account per
invocation. A config file keyed by profile name (base URL + token) would
help anyone working against more than one instance regularly, at the cost
of being the first place this crate stores anything on disk.
- **How closely should list flags track `ListParams`?** Right now `clients
list` exposes exactly what `ListParams` has typed setters for, plus
`--param key=value` for the rest. A per-resource convenience filter (e.g.
a `--unpaid` shorthand for `client_status=unpaid`) would read nicer but
means hand-picking which filters earn a dedicated flag per resource.
- **Whether this crate gets its own CI.** No longer blocked on needing a
sibling checkout — the SDK dependency switched from a path to a crates.io
version requirement (`invoiceninja-sdk = "0.2.0"`) once 0.2.0 was published
on 2026-10-25. A workflow here can now just `cargo build` against the
registry like any other Rust project; it doesn't need to check out
`invoiceninjars-sdk` at all.
- **Whether the Nix packaging goes further than this repo's own flake.**
`flake.nix` now has `packages.default`/`devShells.default` (built with a
pinned `rust-overlay` toolchain, for parity between `nix build` and
`nix develop`) and `overlays.default` (for another flake to pull
`invoiceninja-cli` into its own `pkgs`, built with *that* nixpkgs's own
`rustPlatform` instead of the pin). The overlay path means this crate's
`rust-version = "1.88"` has to be met by whatever rustc the consuming
channel ships — there's no guard here if it isn't, just a build failure
over there. Whether that's worth submitting to nixpkgs proper (and how an
MSRV bump would then need to be communicated beyond this repo) is
unresolved.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 bryant
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+46
View File
@@ -1,2 +1,48 @@
# invoiceninja-cli
A command-line client for the [Invoice Ninja](https://www.invoiceninja.com/)
v5 API, built on [`invoiceninja-sdk`](https://crates.io/crates/invoiceninja-sdk).
> **Early scaffold.** Only the `clients` resource is wired up so far, as the
> template for adding the rest — see `CLAUDE.md` for the pattern.
> [`GOALS.md`](GOALS.md) covers what the crate is for and what it is not.
## Development
Enter the dev shell (Rust toolchain via `rust-overlay`, matching the SDK's):
```sh
nix develop
```
## Usage
Every subcommand needs an API token and, for a self-hosted install, a base
URL. Both can be passed as flags or environment variables (the same names
the SDK's own examples use):
```sh
export IN_API_TOKEN=...
# export IN_BASE_URL=https://your-domain.example/api/v1 # self-hosted only
invoiceninja-cli clients list
invoiceninja-cli clients list --all --status active,archived
invoiceninja-cli clients get <id>
invoiceninja-cli clients create --data '{"name": "Acme Inc"}'
invoiceninja-cli clients update <id> --data '{"city": "Springfield"}'
invoiceninja-cli clients delete <id>
```
`create` and `update` read the record body as JSON, from `--data` inline,
`--file <path>` (`-` for stdin), or stdin if neither flag is given.
`update` fetches the current record first and overlays the given fields on
top of it, so you only need to mention what's changing — see the comment in
`src/body.rs` for why that matters (Invoice Ninja's `PUT` is a full replace,
not a patch).
Add `--output json` to any command for the full record instead of the
table view's few key columns:
```sh
invoiceninja-cli --output json clients get <id>
```
Generated
+82
View File
@@ -0,0 +1,82 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1791914995,
"narHash": "sha256-Zf/xfQEAasnwnmEfHqW/4hjDE/s44yn2hD4RuVIZRjA=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "b7a79d3fea1e214f6d668d521cecec0c01e26544",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1792773339,
"narHash": "sha256-rZLrzrC6i+jnTAlvPZHogKDulY7WWDEv9hJeHieO7Zw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "3ec636362e1816bc2f0888fa0fa2c69f1bc9e504",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1792913333,
"narHash": "sha256-wjoZGhxH/Yr+OsFRpNyV9DpMGQIdNEjjAOinfzQvadk=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "fad80eb74614f7dc37c1c83df8caae64361eda22",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+89
View File
@@ -0,0 +1,89 @@
{
description = "invoiceninja-cli: a command-line client for the Invoice Ninja v5 API";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
rust-overlay.url = "github:oxalica/rust-overlay";
rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, rust-overlay, flake-utils, ... }:
let
cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml);
# Shared between `packages.default` (built below with the pinned
# rust-overlay toolchain, for parity with `nix develop`) and
# `overlays.default` (built with whatever `rustPlatform` the
# *consuming* nixpkgs provides, per the usual nixpkgs packaging
# convention — see the note on `overlays.default` below). Takes its
# inputs as a plain attrset so both call sites, and `callPackage`,
# can hand it the pieces however they're sourced.
mkInvoiceninjaCli = { lib, rustPlatform, pkg-config, openssl }:
rustPlatform.buildRustPackage {
pname = "invoiceninja-cli";
version = cargoToml.package.version;
src = self;
cargoLock.lockFile = ./Cargo.lock;
nativeBuildInputs = [ pkg-config ];
buildInputs = [ openssl ];
meta = {
description = cargoToml.package.description;
license = lib.licenses.mit;
mainProgram = "invoiceninja-cli";
platforms = lib.platforms.unix;
};
};
in
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs {
inherit system;
overlays = [ rust-overlay.overlays.default ];
};
# Kept in step with invoiceninjars-sdk's own `devVersion`, so the two
# sibling crates build with the same toolchain during development.
devVersion = "1.99.1";
rustToolchain = pkgs.rust-bin.stable.${devVersion}.default.override {
extensions = [ "rust-src" "rust-analyzer" "clippy" "rustfmt" ];
};
# Built with the pinned dev toolchain, not nixpkgs' own rustc, so a
# `nix build` matches what `nix develop` compiles with.
rustPlatform = pkgs.makeRustPlatform {
cargo = rustToolchain;
rustc = rustToolchain;
};
in
{
devShells.default = pkgs.mkShell {
packages = [ rustToolchain pkgs.pkg-config pkgs.openssl ];
RUST_SRC_PATH = "${rustToolchain}/lib/rustlib/src/rust/library";
};
# Unlike invoiceninjars-sdk, this crate has a `[[bin]]` target, so
# `nix build` produces an installable binary, not just a test gate.
# `invoiceninja-sdk` resolves from crates.io (see Cargo.toml), so
# unlike before this no longer needs a `../invoiceninjars-sdk`
# checkout to be present in the sandbox.
packages.default = mkInvoiceninjaCli {
inherit (pkgs) lib pkg-config openssl;
inherit rustPlatform;
};
}) // {
# For a flake that wants `invoiceninja-cli` in its own `pkgs`, not for
# this flake's own `packages.default`/`devShells.default` above (those
# keep the pinned rust-overlay toolchain). `callPackage` resolves
# `rustPlatform` from the *consuming* nixpkgs, which means it builds
# with whatever rustc that channel ships — this crate's `rust-version
# = "1.88"` in Cargo.toml has to be met there too, same as any other
# nixpkgs Rust package; this overlay doesn't carry the rust-overlay
# pin along with it.
overlays.default = final: _prev: {
invoiceninja-cli = final.callPackage mkInvoiceninjaCli { };
};
};
}
+50
View File
@@ -0,0 +1,50 @@
use std::fs;
use std::io::Read;
use serde_json::Value;
use crate::cli::BodyArgs;
/// Reads the JSON body a `create`/`update` command was given: `--data`
/// inline, `--file` from disk (`-` for stdin), or stdin if neither flag was
/// passed. Errors if the result isn't a JSON object — every create/update
/// endpoint here takes a record, not a bare array or scalar.
pub fn load(body: &BodyArgs) -> Result<Value, Box<dyn std::error::Error>> {
let text = match (&body.data, &body.file) {
(Some(data), None) => data.clone(),
(None, Some(path)) if path.as_os_str() == "-" => read_stdin()?,
(None, Some(path)) => fs::read_to_string(path)?,
(None, None) => read_stdin()?,
// clap's `conflicts_with` on both fields rules this out before we get here.
(Some(_), Some(_)) => unreachable!("--data and --file are mutually exclusive"),
};
let value: Value = serde_json::from_str(&text)?;
if !value.is_object() {
return Err(format!("expected a JSON object, got {value}").into());
}
Ok(value)
}
fn read_stdin() -> Result<String, Box<dyn std::error::Error>> {
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
Ok(buf)
}
/// Overlays `patch`'s keys onto `base`, one level deep.
///
/// `update` uses this so a caller can send just the fields they're changing
/// instead of retyping the whole record. That matters because Invoice
/// Ninja's `PUT` is a full replace — there's no PATCH route — and the SDK's
/// models default any field the caller omits to `""` / `0` / `false` rather
/// than leaving it alone. Sending the patch by itself would blank out every
/// field it didn't mention; merging onto a freshly-fetched copy keeps them.
pub fn merge_shallow(mut base: Value, patch: Value) -> Value {
if let (Some(base_map), Value::Object(patch_map)) = (base.as_object_mut(), patch) {
for (k, v) in patch_map {
base_map.insert(k, v);
}
}
base
}
+130
View File
@@ -0,0 +1,130 @@
use std::path::PathBuf;
use clap::{Args, Parser, Subcommand, ValueEnum};
/// A command-line client for the Invoice Ninja v5 API, built on the
/// `invoiceninja-sdk` crate.
#[derive(Parser)]
#[command(name = "invoiceninja-cli", version, about)]
pub struct Cli {
/// API root, including the `/api/v1` suffix. Defaults to the hosted app
/// (`invoicing.co`); a self-hosted install must pass this.
#[arg(long, env = "IN_BASE_URL", global = true)]
pub base_url: Option<String>,
/// API token. Required for every call.
///
/// Typed `Option` rather than clap-`required`: clap refuses to combine
/// `required` with `global` (needed so this can also be passed after the
/// subcommand), so the missing case is checked in `config::build_client`
/// instead, with a message that names both ways to supply it.
/// `hide_env_values` keeps it out of `--help`, which otherwise echoes
/// env-sourced defaults back verbatim.
#[arg(long, env = "IN_API_TOKEN", global = true, hide_env_values = true)]
pub token: Option<String>,
/// Account password, needed only for the endpoints Invoice Ninja marks
/// `password_protected` (reading or writing a user, purging a client).
#[arg(long, env = "IN_API_PASSWORD", global = true, hide_env_values = true)]
pub api_password: Option<String>,
#[arg(long, global = true, value_enum, default_value_t = OutputFormat::Table)]
pub output: OutputFormat,
#[command(subcommand)]
pub command: Command,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
pub enum OutputFormat {
/// A few key columns, one record per line. Which columns count as "key"
/// is a per-resource choice, made in that resource's command module.
Table,
/// The full record(s), pretty-printed. Always lossless — use this to see
/// every field a table view leaves out, including `extra`.
Json,
}
/// One variant per resource. Only `clients` is wired up so far; it's the
/// template for adding the rest of the SDK's ~34 resources the same way —
/// see CLAUDE.md.
#[derive(Subcommand)]
pub enum Command {
/// Manage clients
Clients(ResourceArgs),
}
#[derive(Args)]
pub struct ResourceArgs {
#[command(subcommand)]
pub action: Action,
}
/// The five actions every resource exposes. `Create`/`Update` share
/// `BodyArgs` because both take a JSON record; see `body::load`.
#[derive(Subcommand)]
pub enum Action {
/// List records (one page by default; pass --all to follow pagination)
List(ListArgs),
/// Fetch one record by id
Get { id: String },
/// Create a record from a JSON body
Create(BodyArgs),
/// Update a record from a JSON body, keeping fields you don't mention.
///
/// Fetches the record, overlays the given JSON on top, and writes the
/// merged result back — see the comment in `commands::clients::update`
/// for why a plain PUT of just the patch would blank out the rest.
Update {
id: String,
#[command(flatten)]
body: BodyArgs,
},
/// Delete (soft-delete) a record by id
Delete { id: String },
}
/// Mirrors the parameters every `ListParams` accepts in the SDK — see
/// `invoiceninja_sdk::models::ListParams`.
#[derive(Args)]
pub struct ListArgs {
#[arg(long)]
pub page: Option<u64>,
#[arg(long)]
pub per_page: Option<u64>,
#[arg(long)]
pub sort: Option<String>,
#[arg(long)]
pub filter: Option<String>,
/// e.g. "active,archived,deleted" to include archived/deleted records
#[arg(long)]
pub status: Option<String>,
/// Comma-separated relationships to eager-load, e.g. "contacts"
#[arg(long)]
pub include: Option<String>,
/// Extra query parameter, as key=value. Repeatable, for the per-resource
/// filters `ListParams::param` covers (`client_id`, `client_status`, …).
#[arg(long = "param", value_parser = parse_key_val)]
pub params: Vec<(String, String)>,
/// Follow pagination and return every matching record instead of one
/// page (`list_all_*` in the SDK rather than `list_*`).
#[arg(long)]
pub all: bool,
}
#[derive(Args)]
pub struct BodyArgs {
/// The record body, as a JSON object, given inline.
#[arg(long, conflicts_with = "file")]
pub data: Option<String>,
/// The record body, as a JSON object, read from a file ("-" for stdin).
#[arg(long, conflicts_with = "data")]
pub file: Option<PathBuf>,
}
fn parse_key_val(s: &str) -> Result<(String, String), String> {
let (k, v) = s
.split_once('=')
.ok_or_else(|| format!("expected key=value, got `{s}`"))?;
Ok((k.to_string(), v.to_string()))
}
+143
View File
@@ -0,0 +1,143 @@
//! `clients` subcommand. First resource wired up, and the template for
//! adding the rest — see the "Adding a resource" section in CLAUDE.md.
use invoiceninja_sdk::models::{Client, ListParams};
use invoiceninja_sdk::InvoiceNinjaClient;
use crate::body;
use crate::cli::{Action, BodyArgs, ListArgs, OutputFormat, ResourceArgs};
use crate::output;
pub async fn run(
client: &InvoiceNinjaClient,
args: ResourceArgs,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
match args.action {
Action::List(list_args) => list(client, list_args, format).await,
Action::Get { id } => get(client, &id, format).await,
Action::Create(body_args) => create(client, body_args, format).await,
Action::Update { id, body } => update(client, &id, body, format).await,
Action::Delete { id } => delete(client, &id).await,
}
}
async fn list(
client: &InvoiceNinjaClient,
args: ListArgs,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let mut params = ListParams::new();
if let Some(page) = args.page {
params = params.page(page);
}
if let Some(per_page) = args.per_page {
params = params.per_page(per_page);
}
if let Some(sort) = args.sort {
params = params.sort(sort);
}
if let Some(filter) = args.filter {
params = params.filter(filter);
}
if let Some(status) = args.status {
params = params.status(status);
}
if let Some(include) = args.include {
params = params.include(include);
}
for (k, v) in args.params {
params = params.param(k, v);
}
let clients = if args.all {
client.list_all_clients(&params).await?
} else {
client.list_clients(&params).await?
};
render(&clients, format)
}
async fn get(
client: &InvoiceNinjaClient,
id: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let found = client.get_client(id).await?;
render(std::slice::from_ref(&found), format)
}
async fn create(
client: &InvoiceNinjaClient,
body_args: BodyArgs,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let value = body::load(&body_args)?;
// Missing fields in `value` decode fine: every non-Option field on
// `Client` is `#[serde(default, deserialize_with = "null_to_default")]`,
// so a partial object (e.g. just `{"name": "..."}`) fills the rest with
// the model's defaults, same as the API does server-side on create.
let model: Client = serde_json::from_value(value)?;
let created = client.create_client(&model).await?;
render(std::slice::from_ref(&created), format)
}
async fn update(
client: &InvoiceNinjaClient,
id: &str,
body_args: BodyArgs,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let patch = body::load(&body_args)?;
let current = client.get_client(id).await?;
let merged = body::merge_shallow(serde_json::to_value(&current)?, patch);
let model: Client = serde_json::from_value(merged)?;
let updated = client.update_client(id, &model).await?;
render(std::slice::from_ref(&updated), format)
}
async fn delete(client: &InvoiceNinjaClient, id: &str) -> Result<(), Box<dyn std::error::Error>> {
client.delete_client(id).await?;
println!("deleted {id}");
Ok(())
}
fn render(clients: &[Client], format: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
match format {
OutputFormat::Json => output::print_json(clients)?,
OutputFormat::Table => print_table(clients),
}
Ok(())
}
/// The columns considered "key" for a client. Every resource module picks
/// its own this way — there's no generic notion of which fields matter
/// enough for a table view.
fn print_table(clients: &[Client]) {
if clients.is_empty() {
println!("(no records)");
return;
}
println!(
"{:<20} {:<30} {:>12} {:>12}",
"id", "name", "balance", "paid_to_date"
);
for c in clients {
println!(
"{:<20} {:<30} {:>12.2} {:>12.2}",
c.id.as_deref().unwrap_or(""),
truncate(&c.name, 30),
c.balance,
c.paid_to_date
);
}
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
s.chars().take(max.saturating_sub(1)).collect::<String>() + "…"
}
}
+1
View File
@@ -0,0 +1 @@
pub mod clients;
+25
View File
@@ -0,0 +1,25 @@
use invoiceninja_sdk::{InvoiceNinjaClient, HOSTED_BASE_URL};
use crate::cli::Cli;
/// Builds the SDK client from the parsed CLI args: the base URL (defaulting
/// to the hosted app), the token, and the optional account password for the
/// `password_protected` endpoints.
///
/// Checks for a missing token itself rather than leaning on clap, since
/// `token` can't be marked clap-`required` — see the comment on `Cli::token`.
pub fn build_client(cli: &Cli) -> Result<InvoiceNinjaClient, Box<dyn std::error::Error>> {
let token = cli
.token
.clone()
.ok_or("missing API token: pass --token or set IN_API_TOKEN")?;
let base_url = cli
.base_url
.clone()
.unwrap_or_else(|| HOSTED_BASE_URL.to_string());
let client = InvoiceNinjaClient::new(base_url, token)?;
match &cli.api_password {
Some(password) => Ok(client.with_api_password(password.clone())?),
None => Ok(client),
}
}
+26
View File
@@ -0,0 +1,26 @@
mod body;
mod cli;
mod commands;
mod config;
mod output;
use clap::Parser;
use cli::{Cli, Command};
#[tokio::main]
async fn main() {
if let Err(err) = run().await {
eprintln!("error: {err}");
std::process::exit(1);
}
}
async fn run() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
let client = config::build_client(&cli)?;
match cli.command {
Command::Clients(args) => commands::clients::run(&client, args, cli.output).await,
}
}
+9
View File
@@ -0,0 +1,9 @@
use serde::Serialize;
/// Prints a value as pretty JSON. Used for every resource's `--output json`
/// — the table view is per-resource (see `commands::clients::print_table`),
/// but JSON formatting isn't, since it's just the model's own `Serialize`.
pub fn print_json<T: Serialize + ?Sized>(value: &T) -> Result<(), Box<dyn std::error::Error>> {
println!("{}", serde_json::to_string_pretty(value)?);
Ok(())
}