means.cli: Command-Line Tools#

The cli subpackage implements the console scripts declared in pyproject.toml, one module per command. Each module exposes a main() entry point (the target of its [project.scripts] entry) plus the domain functions that do the work.

Remote telemetry is structural and content-free: operation names are stable means.* identifiers, and attributes are limited to bounded kinds, counts, and error codes. Account and user names, file paths, memos, dates, amounts, report contents, model responses, and ledger command output remain absent from telemetry. tests/test_telemetry.py enforces that boundary across every direct Logfire call in the means package; shared-library and exporter behavior is covered in my-basis and the fleet observability contract. Supplying a Logfire token is the explicit opt-in that enables these structural events in local or development runs.

I load — Parse Configured Ledgers#

means.cli.load.load_ledgers(directory: Annotated[Path, PathType(path_type=dir)]) → list[Ledger]#

Set up the means system and parse every configured user’s ledger.

Users that share a ledger file with an earlier (parent) user are appended to that ledger’s users list instead of producing a duplicate Ledger.

Parameters:

directory – The means data directory containing parameters and ledger files.

Returns:

One Ledger per distinct ledger file.

means.cli.load.parse_args() → Namespace#

Parse command-line arguments for the load command.

means.cli.load.main()#

Run the load command: parse arguments and load all configured ledgers.

II grow — Regenerate the Account Tree#

means.cli.grow.process_seed(parent: Account, tree: dict | None) → Generator[Account, None, None]#

Yield the parent account and, recursively, every descendant in the seed tree.

Parameters:
  • parent – The account path accumulated so far.

  • tree – The subtree of child names below parent, or None at a leaf.

Yields:

Each account path in depth-first order, starting with parent itself.

means.cli.grow.load_seed(source: Annotated[Path, PathType(path_type=file)], users: list[str]) → tuple[dict, dict]#

Load the seed file and split it into mode and per-user override trees.

Parameters:
  • source – The accounts_seed.yaml file to parse.

  • users – All user uids, each of which must be present in the seed data.

Returns:

A tuple of the mode tree and the per-user override tree.

means.cli.grow.grow_user_section(mode: str, user: str, tree: dict, utree: dict) → list[str]#

Render the account declarations for one mode/user pair.

Parameters:
  • mode – The top-level account mode (e.g. funds, costs).

  • user – The user uid this section belongs to.

  • tree – The shared seed tree for this mode.

  • utree – The per-user override tree.

Returns:

The lines of the section, starting with a wrapped comment header.

means.cli.grow.grow(source: Annotated[Path, PathType(path_type=file)] | None = None, target: Annotated[Path, PathType(path_type=file)] | None = None) → None#

Grow the accounts tree in accounts.ledger from the seed file.

Expands the dictionary content of accounts_seed.yaml into explicit account declarations, ensuring consistency across all users.

Parameters:
  • source – The seed file to read; defaults to Account.SEED_FILE.

  • target – The accounts file to write; defaults to Account.ACCT_FILE.

means.cli.grow.parse_args() → Namespace#

Parse command-line arguments for the grow command.

means.cli.grow.main()#

Run the grow command: load ledgers, then regrow the accounts file.

III ingest — Import Bank Statements#

means.cli.ingest.ingest_file(ledger: Ledger, file: Annotated[Path, PathType(path_type=file)], uid: str) → list[Transaction]#

Ingest a single .csv file into a list of transactions.

Parameters:
  • ledger – The ledger that owns the file.

  • file – The downloaded .csv file to ingest.

  • uid – The uid of the BankAccount the file belongs to.

Returns:

The cleaned transactions parsed from the file.

means.cli.ingest.ingest(ledger: Ledger, keep: bool = False) → None#

Ingest every new .csv file for a ledger, then merge, write, and archive.

Parameters:
  • ledger – The ledger to ingest new files into.

  • keep – If True, leave the original .csv files in place instead of archiving them.

means.cli.ingest.parse_args() → Namespace#

Parse command-line arguments for the ingest command.

means.cli.ingest.main()#

Run the ingest command: load ledgers, optionally grow accounts, then ingest.

IV categorize — Resolve Unknown Transactions#

means.cli.categorize.categorize_ledger(ledger: Ledger) → None#

Start an interactive program to categorize any remaining unknowns in the ledger.

means.cli.categorize.categorize_transaction(ledger: Ledger, tr: Transaction, bank: dict[str, list[Account]]) → bool#

Interactively categorize a transaction using an fzf fuzzy-finder.

Parameters:
  • ledger – The ledger the transaction belongs to.

  • tr – The unknown transaction to categorize.

  • bank – A mutable mapping of cleaned memos to previously chosen accounts.

Returns:

False if the user chose to exit the loop, True otherwise.

means.cli.categorize.parse_args() → Namespace#

Parse command line arguments.

means.cli.categorize.main()#

The main function for the “categorize” command, which starts an interactive program.

V suggest — LLM-Assisted Templates#

suggest is an explicitly remote, advisory layer over the deterministic template system. Install the optional SDK and provide the standard Anthropic API key environment variable:

uv tool install 'my-means[llms]'
export ANTHROPIC_API_KEY=your-api-key
export MEANS_LLM_MODEL=claude-haiku-4-5-20251001  # optional default

Preview proposals without changing templates.yaml, then run again and confirm a reviewed merge:

suggest --directory /path/to/means --dry-run
suggest --directory /path/to/means

The request contains uncategorized memo strings, current templates, and the account structure. It contains no API key and no telemetry content. --dry-run prevents the local write, not the remote request. Before confirmation, proposals must use known account paths, add rather than replace patterns, and contain bounded literal pattern text without regex metacharacters plus text labels. Existing hand-authored regex templates remain supported; the restriction applies only to untrusted model additions. The default Claude Haiku 4.5 model is economical and can be replaced through MEANS_LLM_MODEL or --model without changing code.

means.cli.suggest.build_prompt(memos: list[str], templates_text: str, accounts_text: str) → str#

Build the LLM prompt asking for new template entries.

Parameters:
  • memos – The uncategorized memo strings to be classified.

  • templates_text – The raw text of the existing templates.yaml.

  • accounts_text – The raw text of the account structure file.

Returns:

The full prompt to send to the model.

means.cli.suggest.suggest(root_ledger: Path, templates_file: Path, accounts_file: Path, dry_run: bool = False, *, model: str | None = None) → None#

Ask an LLM for new template entries and optionally apply them.

Streams the model’s reasoning to stdout, extracts its fenced YAML block, and — after user confirmation — deep-merges the new entries into templates.yaml.

Parameters:
  • root_ledger – The root ledger file to query for unknown payees.

  • templates_file – The templates.yaml file to update.

  • accounts_file – The account structure file given to the model as context.

  • dry_run – If True, print suggestions without prompting to apply them.

  • model – Anthropic model ID. Defaults to MEANS_LLM_MODEL, then Claude Haiku 4.5.

Raises:
  • ValueError – If the model configuration or proposed template data is invalid.

  • RuntimeError – If the optional SDK is missing or a local input changes during review.

means.cli.suggest.parse_args() → Namespace#

Parse command-line arguments for the suggest command.

means.cli.suggest.main() → None#

Run the suggest command against the configured means directory.

VI means-plan — Conditional Cashflow and Debt Planning#

means-plan projects explicit YAML or JSON assumptions without reading or mutating a Ledger. It is a conditional deterministic projection, not a prediction or financial advice.

Project one strategy or compare every implemented strategy:

means-plan project --scenario fictional-plan.yaml --strategy avalanche --json
means-plan compare --scenario fictional-plan.yaml --csv --output comparison.csv

JSON is the default machine-readable output; CSV is a rectangular spreadsheet-oriented subset. Reports go to stdout unless --output names an explicit owner-only file. The comparison’s recommended field is a rule-selected strategy under the disclosed comparison objective, not advice. Zero warnings means no implemented rule fired, not that the scenario is safe or complete.

The complete fictional scenario schema, exact-cent and date rules, warning meanings, selection objective, privacy boundary, and limitations are in the conditional planning guide.

class means.cli.plan.ExitCode(*values)#

Stable command exit codes.

OK = 0#
INPUT = 2#
OUTPUT = 3#
means.cli.plan.parse_args(argv: list[str] | None = None) → Namespace#

Parse the planning command line.

means.cli.plan.run(args: Namespace) → str#

Calculate and render the selected planning report.

means.cli.plan.main(argv: list[str] | None = None) → int#

Run the planning command.

VII report — Tabular Finance Reports#

class means.cli.report.Editor(*, directory: ~typing.Annotated[~pathlib.Path, ~pydantic.types.PathType(path_type=dir)], reports: dict[str, ~typing.Annotated[~pandas.DataFrame, ~pydantic.types.GetPydanticSchema(get_pydantic_core_schema=~my.utils.SyntaxUtils.SyntaxUtils.pyd_schemify.<locals>.<lambda>, get_pydantic_json_schema=None)]] = {}, sheet_id: str = '')#

Generate reports on the state of finances across one or more ledger files.

REGISTRY: ClassVar[dict[str, Reporter]] = {'overview': <function Editor.overview>, 'raw': <function Editor.raw>, 'shared': <function Editor.shared>}#
LEDGERS: ClassVar[list[Ledger]] = []#
directory: pyd.DirectoryPath#
reports: dict[str, DataFrameField]#
sheet_id: str#
report(fn_name: str) → dict[str, DataFrame]#

Generate a report by calling the specified function.

property transactions: Iterator[Transaction]#

Yield all transactions across all ledgers.

static export_repayment_map(**kwargs: dict[str, dict[str, float]]) → DataFrame#

Export a nested dict of repayments into a dataframe.

Returns:

A dataframe with columns type, from_user, to_user, and amount.

prep_shared_costs(df: DataFrame, users: list[str]) → DataFrame#

Prepare a dataframe of shared costs for further processing.

track_shared_spent(df: DataFrame) → DataFrame#

Calculate the total amount contributed by each user per month to each cost account.

track_shared_given(df: DataFrame) → DataFrame#

Calculate the total amount contributed by each user per month.

split(transactions: Iterable[Transaction]) → Iterable[Transaction]#

Split transactions with more than 2 posts into multiple transactions with 2 posts.

build_tr_dataframe(transactions: Iterable[Transaction]) → DataFrame#

Create a dataframe with one row per account pair.

build_post_dataframe(transactions: Iterable[Transaction]) → DataFrame#

Create a dataframe with one row per post (i.e. account), rather than per transaction.

calculate_contributions(df: DataFrame, users: list[str]) → dict[str, dict[str, float]]#

Calculate the net contributions between all users, including the shared account sh.

Parameters:
  • df – DataFrame with columns u0, u1, amnt, and type

  • users – List of all users involved, excluding sh

Returns:

A nested mapping of each user to the net amount contributed to every other user, including the shared sh account. Each self-contribution is zero.

settle_debts(edges: dict[str, dict[str, float]], users: list[str]) → DataFrame#

Determine how much each user owes to every other user in order to settle all debts.

Parameters:
  • edges – Output of calculate_contributions(), describing the net contributions between all users, including sh.

  • users – List of all users involved, excluding sh. This is necessary to determine the split of shared costs, and to separate debtors from creditors.

overview() → dict[str, DataFrame]#

Index and validate all ledgers, reporting a simple summary of transactions & balances.

shared() → dict[str, DataFrame]#

Generate a report describing the state of “shared” finances between a pair of users.

Works by finding relevant transactions, and then balancing them separately to determine whether any money is owed by either party to the other.

In this situation, relevant transactions are both those that involve the costs:sh: accounts, and those that are direct transfers between the funds: accounts of the participants

It is assumed that the split of costs is even, i.e. 0.5.

raw() → dict[str, DataFrame]#

Output raw transaction histories for each ledger file.

upload() → None#

Upload all .csv files written since last upload to the specified Google Sheet.

means.cli.report.register(fn: R) → R#

Decorator to register a report function in the Reporter registry.

means.cli.report.parse_args() → Namespace#

Parse command-line arguments for the reporting script.

means.cli.report.main()#

Generate and upload reports.

VIII means-bank — Stage Read-Only Provider Changes#

means-bank separates the networked fetch boundary from deterministic planning and explicit local apply. Provider credentials and access tokens are injected through environment variables; they are never command-line options. The generated sidecar is the only Ledger file the command may replace.

The first connection is an explicit human-gated setup operation. Prepare an owner-only Proton Pass environment manifest containing PLAID_CLIENT_ID, PLAID_SECRET, and PLAID_ENV, then run the wrapper through pass-cli run (pp is an optional shell alias):

pass-cli run --env-file ~/.local/state/means/bank-sync.env.pp -- \
  means-bank-pp-connect \
  --directory ~/my/self/_0_resources/means \
  --connection fictional-primary \
  --access-token-env PLAID_ACCESS_FICTIONAL \
  --vault Personal \
  --item-title plaid_fictional_primary

The wrapper opens Hosted Link, waits for the operator to finish authorization, writes the exchanged access token directly to a new Proton Pass custom item over standard input, and records only its pass:// reference and protected connection identity in owner-only local state. It refuses reconnect-in-place so a different provider Item cannot silently inherit old source history.

The personal means directory declares only non-secret local policy in parameters/parameters.yaml. unsupported_accounts is the explicit gap ledger; every account that cannot use the active provider remains visible in status rather than disappearing from coverage:

bank_sync:
  state: ~/.local/state/means/bank-sync.sqlite3
  manual_ledger: robb.ledger
  root_ledger: root.ledger
  sidecar: generated/bank-sync.ledger
  since: 2026-04-22
  unsupported_accounts:
    - debts:r:unsupported_card

The recurring workflow is deliberately staged:

  1. Inspect connection readiness and explicit gaps without injecting credentials.

    means-bank --directory ~/my/self/_0_resources/means status --json
    

    A new connection reports fetch_required; only a historically complete committed fetch makes every configured connection ready.

  2. Inject references and fetch provider source changes, then inspect status again.

    pass-cli run --env-file ~/.local/state/means/bank-sync.env.pp -- \
      means-bank --directory ~/my/self/_0_resources/means fetch --json
    means-bank --directory ~/my/self/_0_resources/means status --json
    
  3. List locally observed accounts by opaque handle and bind each one to an existing Ledger account.

    means-bank --directory ~/my/self/_0_resources/means accounts --json
    means-bank --directory ~/my/self/_0_resources/means bind \
      --account account-opaque-example \
      --ledger-account funds:r:checking
    
  4. Create an owner-read-only preview without changing the live sidecar.

    means-bank --directory ~/my/self/_0_resources/means plan --json
    
  5. Inspect the returned preview_path, then apply its exact immutable plan_id.

    means-bank --directory ~/my/self/_0_resources/means apply \
      --plan plan-opaque-example --json
    

Applying performs no network calls. It rejects unresolved account, transfer, currency, or manual-ledger overlap blockers; changed source or ledger hashes; altered previews; invalid Ledger output; and unknown crash-recovery state. A repeated plan and apply over unchanged source is a no-op.

class means.cli.bank.ExitCode(*values)#

Stable process exit codes for automation.

OK = 0#
USAGE = 2#
AUTH = 10#
NETWORK = 11#
BLOCKED = 12#
STALE_PLAN = 13#
VALIDATION = 14#
LOCKED = 15#
RECOVERY = 16#
class means.cli.bank.BankSyncPaths(*, directory: Path, state: Path, manual_ledger: Path, root_ledger: Path, sidecar: Path, since: date, gaps: tuple[str, ...] = ())#

Resolved local paths and projection cutoff.

directory: Path#
state: Path#
manual_ledger: Path#
root_ledger: Path#
sidecar: Path#
since: date#
gaps: tuple[str, ...]#
classmethod from_directory(directory: Path) → BankSyncPaths#

Load non-secret bank-sync policy from the personal means directory.

means.cli.bank.build_parser() → ArgumentParser#

Build the complete bank-sync command parser.

means.cli.bank.parse_args(argv: list[str] | None = None) → Namespace#

Parse and validate command-line arguments.

means.cli.bank.connect(args: Namespace, paths: BankSyncPaths) → int#

Run the human-gated Plaid Hosted Link bootstrap.

means.cli.bank.status(args: Namespace, paths: BankSyncPaths) → int#

Report counts-only coverage, freshness, and recovery state.

means.cli.bank.accounts(args: Namespace, paths: BankSyncPaths) → int#

List locally stored account metadata without provider identifiers or balances.

means.cli.bank.bind_account(args: Namespace, paths: BankSyncPaths) → int#

Bind one opaque provider account to an existing Ledger account.

means.cli.bank.fetch(args: Namespace, paths: BankSyncPaths) → int#

Fetch every configured connection into the source journal.

means.cli.bank.plan(args: Namespace, paths: BankSyncPaths) → ReconciliationPlan#

Build and persist a network-free reconciliation plan.

means.cli.bank.apply(args: Namespace, paths: BankSyncPaths) → ApplyResult#

Apply one immutable plan without network access.

means.cli.bank.recover(args: Namespace, paths: BankSyncPaths) → list[ApplyResult]#

Recover interrupted sidecar replacements.

means.cli.bank.main(argv: list[str] | None = None) → int#

Run the selected bank-sync operation.

means.cli.bank_pp.build_parser() → ArgumentParser#

Build the explicit Proton Pass secret-bootstrap parser.

means.cli.bank_pp.main(argv: list[str] | None = None) → int#

Authorize one Item, save its token, and record only non-secret local configuration.