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>
This commit is contained in:
2026-10-26 11:47:15 -04:00
co-authored by Claude Sonnet 5
commit dde10e522a
18 changed files with 2308 additions and 0 deletions
+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()))
}