Skip to main content

invoice_cli/
cli.rs

1use clap::{Parser, Subcommand};
2
3#[derive(Parser, Debug)]
4#[command(name = "invoice", version, about = "Beautiful invoices from the CLI")]
5pub struct Cli {
6    /// Emit JSON envelope on stdout (auto-detected when piped)
7    #[arg(long, global = true)]
8    pub json: bool,
9    /// Suppress human output
10    #[arg(long, global = true)]
11    pub quiet: bool,
12    #[command(subcommand)]
13    pub command: Commands,
14}
15
16#[derive(Subcommand, Debug)]
17pub enum Commands {
18    /// Manage issuers (the companies you invoice AS — supports multiple)
19    #[command(visible_alias = "issuer", subcommand)]
20    Issuers(IssuerCmd),
21
22    /// Manage clients (the companies you invoice TO)
23    #[command(subcommand)]
24    Clients(ClientCmd),
25
26    /// Manage reusable products/line-items
27    #[command(subcommand)]
28    Products(ProductCmd),
29
30    /// Create, list, show, render, or mark invoices
31    #[command(subcommand)]
32    Invoices(InvoiceCmd),
33
34    /// Template operations (list, preview, set default)
35    #[command(subcommand)]
36    Template(TemplateCmd),
37
38    /// Show / edit config
39    #[command(subcommand)]
40    Config(ConfigCmd),
41
42    /// Self-describing JSON manifest for agents
43    #[command(alias = "info")]
44    AgentInfo,
45
46    /// Install the embedded skill file to ~/.claude/skills/
47    #[command(subcommand)]
48    Skill(SkillCmd),
49
50    /// Run dependency & config diagnostics
51    Doctor,
52
53    /// Self-update from GitHub Releases
54    Update {
55        /// Don't install, just report latest version
56        #[arg(long)]
57        check: bool,
58    },
59}
60
61#[derive(Subcommand, Debug)]
62pub enum IssuerCmd {
63    /// Add a new issuer
64    #[command(alias = "new")]
65    Add {
66        slug: String,
67        #[arg(long)]
68        name: String,
69        #[arg(long)]
70        legal_name: Option<String>,
71        #[arg(long, default_value = "sg")]
72        jurisdiction: String,
73        #[arg(long)]
74        tax_registered: bool,
75        #[arg(long)]
76        tax_id: Option<String>,
77        #[arg(long)]
78        company_no: Option<String>,
79        #[arg(long)]
80        address: String,
81        #[arg(long)]
82        email: Option<String>,
83        #[arg(long)]
84        phone: Option<String>,
85        /// Bank / payment detail line as "Label: Value". Repeat for each
86        /// line. Example:
87        ///   --bank-line "Bank: DBS" --bank-line "Account: 1234567890"
88        ///   --bank-line "Bank Code: 7171" --bank-line "SWIFT: DBSSSGSG"
89        /// Lines render as a two-column list on the invoice PDF.
90        #[arg(long = "bank-line")]
91        bank_line: Vec<String>,
92        #[arg(long, default_value = "vienna")]
93        template: String,
94        /// Path to a logo image (PNG/SVG/JPG). Rendered in template header.
95        #[arg(long)]
96        logo: Option<String>,
97        /// Default directory for `invoices render` output when --out is
98        /// omitted. Leading `~/` is expanded. Example:
99        ///   --output-dir "~/Documents/Invoices/Paperfoot"
100        #[arg(long)]
101        output_dir: Option<String>,
102        /// Default notes auto-populated into new invoices (free-form
103        /// multi-line). Use for payment terms, reverse-charge disclaimers,
104        /// etc.
105        #[arg(long)]
106        notes: Option<String>,
107    },
108    /// Edit an existing issuer — pass only the fields you want to change
109    Edit {
110        slug: String,
111        #[arg(long)]
112        name: Option<String>,
113        #[arg(long)]
114        legal_name: Option<String>,
115        #[arg(long)]
116        jurisdiction: Option<String>,
117        #[arg(long)]
118        tax_registered: Option<bool>,
119        #[arg(long)]
120        tax_id: Option<String>,
121        #[arg(long)]
122        company_no: Option<String>,
123        #[arg(long)]
124        tagline: Option<String>,
125        #[arg(long)]
126        address: Option<String>,
127        #[arg(long)]
128        email: Option<String>,
129        #[arg(long)]
130        phone: Option<String>,
131        /// Bank / payment detail line as "Label: Value". Repeat for each
132        /// line. When any --bank-line is passed, REPLACES all existing
133        /// bank details on the issuer.
134        #[arg(long = "bank-line")]
135        bank_line: Vec<String>,
136        /// Remove all bank details from the issuer.
137        #[arg(long)]
138        bank_clear: bool,
139        #[arg(long)]
140        template: Option<String>,
141        #[arg(long)]
142        currency: Option<String>,
143        #[arg(long)]
144        symbol: Option<String>,
145        #[arg(long)]
146        number_format: Option<String>,
147        #[arg(long)]
148        logo: Option<String>,
149        /// Remove the logo from the issuer (falls back to the star mark).
150        #[arg(long)]
151        logo_clear: bool,
152        /// Default directory for `invoices render` output when --out is
153        /// omitted. Leading `~/` is expanded.
154        #[arg(long)]
155        output_dir: Option<String>,
156        /// Default notes auto-populated into new invoices.
157        #[arg(long)]
158        notes: Option<String>,
159    },
160    /// Shorthand: change the issuer's default template
161    SetTemplate { slug: String, template: String },
162    #[command(alias = "ls")]
163    List,
164    #[command(alias = "get")]
165    Show { slug: String },
166    #[command(alias = "rm")]
167    Delete { slug: String },
168}
169
170#[derive(Subcommand, Debug)]
171pub enum ClientCmd {
172    #[command(alias = "new")]
173    Add {
174        slug: String,
175        #[arg(long)]
176        name: String,
177        #[arg(long)]
178        attn: Option<String>,
179        #[arg(long)]
180        country: Option<String>,
181        #[arg(long)]
182        tax_id: Option<String>,
183        #[arg(long)]
184        address: String,
185        #[arg(long)]
186        email: Option<String>,
187        #[arg(long)]
188        notes: Option<String>,
189        /// Default issuer slug — `invoices new` uses this when `--as` omitted
190        #[arg(long)]
191        default_issuer: Option<String>,
192        /// Preferred template for this client's invoices
193        #[arg(long)]
194        default_template: Option<String>,
195    },
196    /// Edit an existing client — pass only the fields you want to change
197    Edit {
198        slug: String,
199        #[arg(long)]
200        name: Option<String>,
201        #[arg(long)]
202        attn: Option<String>,
203        #[arg(long)]
204        country: Option<String>,
205        #[arg(long)]
206        tax_id: Option<String>,
207        #[arg(long)]
208        address: Option<String>,
209        #[arg(long)]
210        email: Option<String>,
211        #[arg(long)]
212        notes: Option<String>,
213        #[arg(long)]
214        default_issuer: Option<String>,
215        #[arg(long)]
216        default_template: Option<String>,
217    },
218    /// Shorthand: pin a default issuer for this client
219    SetIssuer { slug: String, issuer_slug: String },
220    /// Shorthand: pin a preferred template for this client
221    SetTemplate { slug: String, template: String },
222    #[command(alias = "ls")]
223    List,
224    #[command(alias = "get")]
225    Show { slug: String },
226    #[command(alias = "rm")]
227    Delete { slug: String },
228}
229
230#[derive(Subcommand, Debug)]
231pub enum ProductCmd {
232    #[command(alias = "new")]
233    Add {
234        slug: String,
235        #[arg(long)]
236        description: String,
237        #[arg(long)]
238        subtitle: Option<String>,
239        #[arg(long, default_value = "unit")]
240        unit: String,
241        /// Unit price as a decimal (e.g. 220.00)
242        #[arg(long)]
243        price: String,
244        #[arg(long)]
245        currency: String,
246        #[arg(long, default_value = "0")]
247        tax_rate: String,
248    },
249    /// Edit an existing product — pass only the fields you want to change
250    Edit {
251        slug: String,
252        #[arg(long)]
253        description: Option<String>,
254        #[arg(long)]
255        subtitle: Option<String>,
256        #[arg(long)]
257        unit: Option<String>,
258        #[arg(long)]
259        price: Option<String>,
260        #[arg(long)]
261        currency: Option<String>,
262        #[arg(long)]
263        tax_rate: Option<String>,
264    },
265    #[command(alias = "ls")]
266    List,
267    #[command(alias = "get")]
268    Show { slug: String },
269    #[command(alias = "rm")]
270    Delete { slug: String },
271}
272
273#[derive(Subcommand, Debug)]
274pub enum InvoiceCmd {
275    /// Create a new invoice
276    New {
277        /// Issuer slug (the "as" — whose invoice is this?). Optional if the
278        /// client has a `default_issuer` pinned.
279        #[arg(long)]
280        r#as: Option<String>,
281        /// Client slug
282        #[arg(long)]
283        client: String,
284        /// Item in the form: "product-slug" OR "Description:qty:price:rate"
285        #[arg(long = "item")]
286        items: Vec<String>,
287        /// Due date (e.g. "2026-05-17" or "7d"). Defaults to one week
288        /// after issue.
289        #[arg(long, default_value = "7d")]
290        due: String,
291        /// Terms label (default: "Pay in full")
292        #[arg(long, default_value = "Pay in full")]
293        terms: String,
294        #[arg(long)]
295        notes: Option<String>,
296        /// Currency override (otherwise uses issuer's default)
297        #[arg(long)]
298        currency: Option<String>,
299        /// Reverse-charge flag (EU B2B cross-border)
300        #[arg(long)]
301        reverse_charge: bool,
302        /// Payment URL (Stripe Payment Link, EPC-QR, any URL) encoded as QR
303        #[arg(long)]
304        pay_link: Option<String>,
305        /// Invoice-level discount rate (percent, e.g. "10" for 10% off subtotal)
306        #[arg(long)]
307        discount_rate: Option<String>,
308        /// Invoice-level fixed discount in major units (e.g. "50.00")
309        #[arg(long)]
310        discount_fixed: Option<String>,
311    },
312    /// Edit an existing DRAFT invoice's metadata (issued/paid/void invoices
313    /// are immutable — use a credit note instead).
314    Edit {
315        number: String,
316        #[arg(long)]
317        client: Option<String>,
318        #[arg(long)]
319        due: Option<String>,
320        #[arg(long)]
321        terms: Option<String>,
322        #[arg(long)]
323        notes: Option<String>,
324        #[arg(long)]
325        currency: Option<String>,
326        #[arg(long)]
327        pay_link: Option<String>,
328        #[arg(long)]
329        reverse_charge: Option<bool>,
330        #[arg(long)]
331        discount_rate: Option<String>,
332        #[arg(long)]
333        discount_fixed: Option<String>,
334    },
335    /// Manage line items on a DRAFT invoice
336    #[command(subcommand)]
337    Items(InvoiceItemCmd),
338    /// Issue a credit note against an existing invoice
339    CreditNote {
340        /// Source invoice number
341        number: String,
342        /// Copy ALL line items from source and reverse their quantities.
343        /// Mutually exclusive with --item.
344        #[arg(long, conflicts_with = "items")]
345        full: bool,
346        /// Explicit items to include on the credit note (same format as
347        /// `invoices new --item`). Positive refund amounts are stored as
348        /// credits automatically.
349        #[arg(long = "item")]
350        items: Vec<String>,
351        #[arg(long)]
352        notes: Option<String>,
353        #[arg(long)]
354        pay_link: Option<String>,
355    },
356    /// Ageing report for unpaid invoices, bucketed 0-30 / 31-60 / 61-90 / 90+
357    Aging {
358        #[arg(long = "as")]
359        issuer: Option<String>,
360    },
361    /// Export invoices as CSV / JSON — month-end accountant handoff
362    Export {
363        /// YYYY-MM-DD inclusive lower bound on issue_date
364        #[arg(long)]
365        from: Option<String>,
366        /// YYYY-MM-DD inclusive upper bound on issue_date
367        #[arg(long)]
368        to: Option<String>,
369        /// csv | json (default csv)
370        #[arg(long, default_value = "csv")]
371        format: String,
372        /// Output path. Defaults to stdout.
373        #[arg(long, short)]
374        out: Option<String>,
375        #[arg(long = "as")]
376        issuer: Option<String>,
377    },
378    /// Clone an existing invoice's line items into a new draft — same client,
379    /// new number + dates. Handy for recurring billing.
380    Duplicate {
381        number: String,
382        /// Override the client (defaults to the source invoice's client)
383        #[arg(long)]
384        client: Option<String>,
385        /// Override the issuer (defaults to the source invoice's issuer)
386        #[arg(long = "as")]
387        r#as: Option<String>,
388        /// New due date (e.g. "2026-05-17" or "7d"). Defaults to "7d".
389        #[arg(long, default_value = "7d")]
390        due: String,
391    },
392    #[command(alias = "ls")]
393    List {
394        #[arg(long)]
395        status: Option<String>,
396        #[arg(long = "as")]
397        issuer: Option<String>,
398        /// Only show invoices past due date and not paid/void
399        #[arg(long)]
400        overdue: bool,
401    },
402    #[command(alias = "get")]
403    Show { number: String },
404    /// Render invoice to PDF
405    Render {
406        number: String,
407        /// Template to use (overrides issuer default)
408        #[arg(long)]
409        template: Option<String>,
410        /// Output path (defaults to ./invoice-<number>.pdf)
411        #[arg(long, short)]
412        out: Option<String>,
413        /// Open the PDF after rendering (macOS open / linux xdg-open)
414        #[arg(long)]
415        open: bool,
416    },
417    /// Mark status (draft/issued/paid/void)
418    Mark { number: String, status: String },
419    #[command(alias = "rm")]
420    Delete {
421        number: String,
422        /// Allow deleting a non-draft invoice. Breaks number-sequence
423        /// integrity — prefer `mark void` or credit note in most cases.
424        #[arg(long)]
425        force: bool,
426    },
427}
428
429#[derive(Subcommand, Debug)]
430pub enum InvoiceItemCmd {
431    /// Add a line item to a draft invoice
432    Add {
433        number: String,
434        /// Item spec: "product-slug[:qty]" OR "Description:qty:price[:rate]"
435        spec: String,
436        #[arg(long)]
437        subtitle: Option<String>,
438        #[arg(long)]
439        discount_rate: Option<String>,
440        #[arg(long)]
441        discount_fixed: Option<String>,
442    },
443    /// Remove the item at `position` (zero-indexed) from a draft invoice
444    #[command(alias = "rm")]
445    Remove { number: String, position: i64 },
446    /// Edit the item at `position` — any subset of fields
447    Edit {
448        number: String,
449        position: i64,
450        #[arg(long)]
451        description: Option<String>,
452        #[arg(long)]
453        subtitle: Option<String>,
454        #[arg(long)]
455        qty: Option<String>,
456        #[arg(long)]
457        unit: Option<String>,
458        #[arg(long)]
459        price: Option<String>,
460        #[arg(long)]
461        tax_rate: Option<String>,
462        #[arg(long)]
463        discount_rate: Option<String>,
464        #[arg(long)]
465        discount_fixed: Option<String>,
466    },
467}
468
469#[derive(Subcommand, Debug)]
470pub enum TemplateCmd {
471    #[command(alias = "ls")]
472    List,
473    /// Render a preview with synthetic data
474    Preview {
475        name: String,
476        #[arg(long, short)]
477        out: Option<String>,
478    },
479}
480
481#[derive(Subcommand, Debug)]
482pub enum ConfigCmd {
483    Show,
484    Path,
485    Set { key: String, value: String },
486}
487
488#[derive(Subcommand, Debug)]
489pub enum SkillCmd {
490    Install,
491}