# =============================================================================
# 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())