# ============================================================================= # Projet : AIR — Accounting Intermediate Representation # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : cli.py # Description : Standalone CLI — AIR home, compile, incremental recompile, statements, exports. # ============================================================================= """AIR standalone command line. AIR needs no third-party system: keep your books in a managed local AIR home (hash-chained ledger + archived documents + exports) and produce every statement in several formats. QuickBooks/CSV are optional export targets. # one-time: create your books directory python -m sdk.cli init --home books --policies alsl/policies/ca-qc-2026.yaml # day to day: compile events into your books (document is archived) python -m sdk.cli compile doc.yaml --home books --report trial-balance # a source document changed? post only the delta (reversal + replacement) python -m sdk.cli recompile old.yaml new.yaml --home books # statements any time, any format python -m sdk.cli report balance-sheet --home books --format markdown python -m sdk.cli status --home books # optional exports (no QuickBooks account required for qbo-export) python -m sdk.cli compile doc.yaml --home books --backend csv python -m sdk.cli compile doc.yaml --home books --backend qbo-export """ from __future__ import annotations import argparse import hashlib import json import sys from pathlib import Path from aic.compiler import compile_document from aic.diagnostics import CompilationError, Diagnostic from aic.incremental import recompile from alsl.loader import load_policy_set from backends.generic_csv.backend import GenericCsvBackend from backends.native.backend import NativeLedgerBackend from backends.quickbooks.backend import QuickBooksBackend from core.document_io import load_air_document from core.events import AirDocument from core.journal import CompiledJournal from ingestion.approval import ApprovalQueue from ingestion.extractor import MockExtractor from ingestion.pipeline import DEFAULT_CONFIDENCE_THRESHOLD, Route, ingest from kernel.ledger import Ledger from kernel.reporting import FORMATS, REPORTS from kernel.workspace import Workspace, WorkspaceError def _print_diags(diags: list[Diagnostic]) -> None: for d in diags: print(d.render(), file=sys.stderr) def _resolve(args: argparse.Namespace) -> tuple[Workspace | None, str, str]: """Resolve (workspace, policies_path, ledger_path) from --home/--policies.""" workspace: Workspace | None = None if getattr(args, "home", None): workspace = Workspace.open(args.home) policies = getattr(args, "policies", None) or ( workspace.default_policies() if workspace else None ) if not policies: raise WorkspaceError( "no policy set: pass --policies, or init the home with a default " "(air init --home --policies )" ) ledger = str(workspace.ledger_path) if workspace else getattr( args, "ledger", "books/ledger.jsonl" ) return workspace, policies, ledger def _idempotency_key(*parts: str) -> str: return hashlib.sha256("".join(parts).encode("utf-8")).hexdigest()[:16] def _dispatch_backend( args: argparse.Namespace, workspace: Workspace | None, ledger_path: str, journal: CompiledJournal, key: str, ) -> str: """Post the journal to the chosen backend; returns a human summary.""" if args.backend == "csv": out = (workspace.exports_dir if workspace else Path(args.out)) backend = GenericCsvBackend(out) receipt = backend.post(backend.compile(journal), key) return f"{len(journal.entries)} entries -> CSV {receipt.reference}" if args.backend == "qbo-export": # Offline QuickBooks-shaped JSON export: NO QuickBooks account needed. payload = QuickBooksBackend().compile(journal) body = json.dumps( {"_author": "Simon-Pierre Boucher ", "journal_entries": payload.body}, indent=2, default=str, ) out_dir = workspace.exports_dir if workspace else Path(args.out) out_dir.mkdir(parents=True, exist_ok=True) target = out_dir / f"qbo_journal_{key}.json" target.write_text(body, encoding="utf-8") return f"{len(journal.entries)} entries -> QBO JSON export {target}" backend = NativeLedgerBackend(ledger_path) receipt = backend.post(backend.compile(journal), key) for name in getattr(args, "report", None) or []: print(REPORTS[name](backend.ledger, args.format)) return ( f"{len(journal.entries)} entries -> native ledger {receipt.reference} " f"(+{receipt.details['entries_appended']} appended)" ) def _init(args: argparse.Namespace) -> int: workspace = Workspace.init(args.home, name=args.name, policies=args.policies) print(f"initialized AIR home at {workspace.root} " f"(default policies: {args.policies or 'none'})") return 0 def _status(args: argparse.Namespace) -> int: status = Workspace.open(args.home).status() for k, v in status.items(): print(f"{k:20} {v}") return 0 def _compile(args: argparse.Namespace) -> int: workspace, policies_path, ledger_path = _resolve(args) document = load_air_document(args.document) policies = load_policy_set(policies_path) try: journal, diagnostics = compile_document( document, policies, optimize=getattr(args, "optimize", False)) except CompilationError as exc: _print_diags(exc.diagnostics) return 1 _print_diags(diagnostics) key = _idempotency_key( Path(args.document).read_text(encoding="utf-8"), policies.name, policies.version, ) summary = _dispatch_backend(args, workspace, ledger_path, journal, key) if summary: print(f"compiled {summary}") if workspace: archived = workspace.archive_document(args.document) print(f"document archived: {archived}") return 0 def _recompile(args: argparse.Namespace) -> int: workspace, policies_path, ledger_path = _resolve(args) old_doc = load_air_document(args.old_document) new_doc = load_air_document(args.new_document) policies = load_policy_set(policies_path) try: result = recompile(old_doc, new_doc, policies) except CompilationError as exc: _print_diags(exc.diagnostics) return 1 _print_diags(result.diagnostics) diff = result.diff print(f"diff: +{len(diff.added)} added, -{len(diff.removed)} removed, " f"~{len(diff.changed)} changed, ={len(diff.unchanged)} unchanged") if diff.is_empty(): print("nothing to post: documents compile identically") return 0 key = _idempotency_key( Path(args.old_document).read_text(encoding="utf-8"), Path(args.new_document).read_text(encoding="utf-8"), policies.name, policies.version, ) summary = _dispatch_backend(args, workspace, ledger_path, result.journal, key) if summary: print(f"posted {len(result.reversals)} reversal(s) + " f"{len(result.new_entries)} new -> {summary}") if workspace: workspace.archive_document(args.new_document) return 0 def _verify(args: argparse.Namespace) -> int: _, policies_path, _ = _resolve(args) document = load_air_document(args.document) policies = load_policy_set(policies_path) try: journal, diagnostics = compile_document(document, policies) except CompilationError as exc: _print_diags(exc.diagnostics) return 1 _print_diags(diagnostics) print(f"OK: {len(document.events)} events -> {len(journal.entries)} balanced entries") return 0 def _compile_and_post(workspace: Workspace, policies_path: str, document: AirDocument, key: str) -> str: policies = load_policy_set(policies_path) journal, diagnostics = compile_document(document, policies) _print_diags(diagnostics) backend = NativeLedgerBackend(workspace.ledger_path) receipt = backend.post(backend.compile(journal), key) return (f"{len(journal.entries)} entries posted to {receipt.reference} " f"(+{receipt.details['entries_appended']} appended)") def _ingest(args: argparse.Namespace) -> int: workspace, policies_path, _ = _resolve(args) assert workspace is not None text = Path(args.source).read_text(encoding="utf-8") if args.llm: from ingestion.extractor import ClaudeExtractor extractor = ClaudeExtractor() else: extractor = MockExtractor() from decimal import Decimal outcome = ingest(text, extractor, threshold=Decimal(args.threshold)) if outcome.route is Route.AUTO_APPROVED: assert outcome.document is not None key = _idempotency_key(text, "ingest") try: summary = _compile_and_post(workspace, policies_path, outcome.document, key) except CompilationError as exc: _print_diags(exc.diagnostics) return 1 workspace.archive_document(args.source) print(f"auto-approved (confidence {outcome.extraction.confidence}): {summary}") return 0 queue = ApprovalQueue(workspace.root) item_id = queue.submit(outcome, text) print(f"routed to human review: {item_id}") for reason in outcome.reasons: print(f" reason: {reason}") for error in outcome.validation_errors: print(f" schema: {error}") print(f"review with: air inbox --home {workspace.root} | " f"air approve {item_id} --home {workspace.root} --approver ") return 0 def _inbox(args: argparse.Namespace) -> int: workspace = Workspace.open(args.home) items = ApprovalQueue(workspace.root).pending() if not items: print("inbox empty: nothing awaiting review") return 0 for item in items: print(f"{item.id} confidence={item.confidence} " f"events={len(item.events)}") for reason in item.reasons: print(f" reason: {reason}") for error in item.validation_errors: print(f" schema: {error}") return 0 def _approve(args: argparse.Namespace) -> int: workspace, policies_path, _ = _resolve(args) assert workspace is not None queue = ApprovalQueue(workspace.root) document = queue.approve(args.item_id, approver=args.approver) key = _idempotency_key(args.item_id, "approve") try: summary = _compile_and_post(workspace, policies_path, document, key) except CompilationError as exc: _print_diags(exc.diagnostics) return 1 print(f"approved by {args.approver}: {summary}") return 0 def _reject(args: argparse.Namespace) -> int: workspace = Workspace.open(args.home) ApprovalQueue(workspace.root).reject(args.item_id, reason=args.reason) print(f"rejected {args.item_id}") return 0 def _reconcile(args: argparse.Namespace) -> int: from kernel.bank_formats import parse_statement from kernel.reconcile import cash_movements, reconcile workspace = Workspace.open(args.home) ledger = Ledger(path=workspace.ledger_path) transactions = parse_statement(Path(args.statement)) result = reconcile(transactions, cash_movements(ledger, args.cash_account), tolerance_days=args.tolerance_days) print(result.render()) return 0 if result.is_clean else 2 # 2: differences found (not an error) def _audit(args: argparse.Namespace) -> int: from kernel.audit import AuditLog workspace = Workspace.open(args.home) log = AuditLog(path=workspace.root / "audit.jsonl") if not log.verify_chain(): print("error: audit log hash chain verification FAILED", file=sys.stderr) return 1 if not log.records: print("audit log empty: no syscalls recorded yet") return 0 for record in log.records: status = "OK " if record["status"] == "ok" else "ERR" detail = f" <- {record['detail']}" if record.get("detail") else "" print(f"#{record['seq']:04d} {status} {record['actor']:<16} " f"{record['syscall']:<20} {json.dumps(record['params'])}{detail}") print(f"\n{len(log.records)} syscalls, hash chain VALID") return 0 def _report(args: argparse.Namespace) -> int: ledger_path = ( Workspace.open(args.home).ledger_path if args.home else Path(args.ledger) ) ledger = Ledger(path=Path(ledger_path)) if not ledger.verify_chain(): print("error: ledger hash chain verification FAILED", file=sys.stderr) return 1 print(REPORTS[args.name](ledger, args.format)) return 0 def _add_common(p: argparse.ArgumentParser, *, backend: bool = True) -> None: p.add_argument("--home", help="AIR home directory (managed books)") p.add_argument("--policies", help="ALSL policy set (default: the home's)") p.add_argument("--ledger", default="books/ledger.jsonl", help="ledger path when no --home is used") if backend: p.add_argument("--backend", choices=["native", "csv", "qbo-export"], default="native") p.add_argument("--out", default="out", help="export directory when no --home is used") def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( prog="air", description="AIR — the language of accounting (standalone mode)" ) sub = parser.add_subparsers(dest="command", required=True) p = sub.add_parser("init", help="create a managed AIR home (your books directory)") p.add_argument("--home", required=True) p.add_argument("--name", default="my-books") p.add_argument("--policies", help="default ALSL policy set for this home") p.set_defaults(func=_init) p = sub.add_parser("status", help="show AIR home health: entries, chain, archives") p.add_argument("--home", required=True) p.set_defaults(func=_status) p = sub.add_parser("compile", help="compile AIR events and post to a backend") p.add_argument("document") _add_common(p) p.add_argument("--optimize", action="store_true", help="enable optimization passes: duplicate detection, " "netting, payment fusion") p.add_argument("--report", action="append", choices=sorted(REPORTS)) p.add_argument("--format", choices=FORMATS, default="text") p.set_defaults(func=_compile) p = sub.add_parser( "recompile", help="incremental compile: diff two AIR documents, post reversal + replacement", ) p.add_argument("old_document") p.add_argument("new_document") _add_common(p) p.set_defaults(func=_recompile) p = sub.add_parser("verify", help="compile without posting; print diagnostics") p.add_argument("document") _add_common(p, backend=False) p.set_defaults(func=_verify) p = sub.add_parser( "ingest", help="extract AIR events from a source document; route by confidence", ) p.add_argument("source", help="text file (invoice text, email, OCR output)") p.add_argument("--home", required=True) p.add_argument("--policies") p.add_argument("--threshold", default=str(DEFAULT_CONFIDENCE_THRESHOLD), help="auto-approve confidence threshold (default 0.85)") p.add_argument("--llm", action="store_true", help="use the Claude extractor (needs an Anthropic API key); " "default is the offline mock extractor") p.set_defaults(func=_ingest) p = sub.add_parser("inbox", help="list extractions awaiting human review") p.add_argument("--home", required=True) p.set_defaults(func=_inbox) p = sub.add_parser("approve", help="approve a pending extraction and post it") p.add_argument("item_id") p.add_argument("--home", required=True) p.add_argument("--policies") p.add_argument("--approver", required=True) p.set_defaults(func=_approve) p = sub.add_parser("reject", help="reject a pending extraction") p.add_argument("item_id") p.add_argument("--home", required=True) p.add_argument("--reason", default="") p.set_defaults(func=_reject) p = sub.add_parser( "reconcile", help="match a bank statement (camt.053 / MT940 / CSV) against the books", ) p.add_argument("statement") p.add_argument("--home", required=True) p.add_argument("--cash-account", default="1000") p.add_argument("--tolerance-days", type=int, default=3) p.set_defaults(func=_reconcile) p = sub.add_parser("audit", help="show and verify the agent syscall audit log") p.add_argument("--home", required=True) p.set_defaults(func=_audit) p = sub.add_parser("report", help="generate a statement from the books") p.add_argument("name", choices=sorted(REPORTS)) p.add_argument("--home") p.add_argument("--ledger", default="books/ledger.jsonl") p.add_argument("--format", choices=FORMATS, default="text") p.set_defaults(func=_report) args = parser.parse_args(argv) try: return int(args.func(args)) except WorkspaceError as exc: print(f"error: {exc}", file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())