beancount-cli 0.2.12__tar.gz → 0.2.14__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. {beancount_cli-0.2.12 → beancount_cli-0.2.14}/.gitignore +1 -0
  2. {beancount_cli-0.2.12 → beancount_cli-0.2.14}/CHANGELOG.md +21 -0
  3. {beancount_cli-0.2.12 → beancount_cli-0.2.14}/PKG-INFO +6 -6
  4. {beancount_cli-0.2.12 → beancount_cli-0.2.14}/pyproject.toml +6 -9
  5. beancount_cli-0.2.14/src/beancount_cli/__init__.py +1 -0
  6. {beancount_cli-0.2.12 → beancount_cli-0.2.14}/src/beancount_cli/adapters.py +28 -0
  7. beancount_cli-0.2.14/src/beancount_cli/commands/account.py +202 -0
  8. beancount_cli-0.2.14/src/beancount_cli/commands/commodity.py +193 -0
  9. {beancount_cli-0.2.12 → beancount_cli-0.2.14}/src/beancount_cli/commands/common.py +24 -0
  10. {beancount_cli-0.2.12 → beancount_cli-0.2.14}/src/beancount_cli/commands/price.py +103 -18
  11. {beancount_cli-0.2.12 → beancount_cli-0.2.14}/src/beancount_cli/commands/report.py +6 -10
  12. {beancount_cli-0.2.12 → beancount_cli-0.2.14}/src/beancount_cli/commands/transaction.py +11 -6
  13. {beancount_cli-0.2.12 → beancount_cli-0.2.14}/src/beancount_cli/models.py +39 -2
  14. {beancount_cli-0.2.12 → beancount_cli-0.2.14}/src/beancount_cli/services.py +146 -5
  15. {beancount_cli-0.2.12 → beancount_cli-0.2.14}/tests/test_advanced.py +2 -4
  16. {beancount_cli-0.2.12 → beancount_cli-0.2.14}/tests/test_cli.py +73 -9
  17. {beancount_cli-0.2.12 → beancount_cli-0.2.14}/tests/test_coverage_gap.py +19 -6
  18. beancount_cli-0.2.14/tests/test_price_cash_currency.py +121 -0
  19. {beancount_cli-0.2.12 → beancount_cli-0.2.14}/tests/test_services.py +17 -1
  20. beancount_cli-0.2.12/src/beancount_cli/__init__.py +0 -1
  21. beancount_cli-0.2.12/src/beancount_cli/commands/account.py +0 -112
  22. beancount_cli-0.2.12/src/beancount_cli/commands/commodity.py +0 -100
  23. {beancount_cli-0.2.12 → beancount_cli-0.2.14}/LICENSE +0 -0
  24. {beancount_cli-0.2.12 → beancount_cli-0.2.14}/README.md +0 -0
  25. {beancount_cli-0.2.12 → beancount_cli-0.2.14}/src/beancount_cli/cli.py +0 -0
  26. {beancount_cli-0.2.12 → beancount_cli-0.2.14}/src/beancount_cli/commands/__init__.py +0 -0
  27. {beancount_cli-0.2.12 → beancount_cli-0.2.14}/src/beancount_cli/commands/root.py +0 -0
  28. {beancount_cli-0.2.12 → beancount_cli-0.2.14}/src/beancount_cli/config.py +0 -0
  29. {beancount_cli-0.2.12 → beancount_cli-0.2.14}/src/beancount_cli/formatting.py +0 -0
  30. {beancount_cli-0.2.12 → beancount_cli-0.2.14}/src/beancount_cli/py.typed +0 -0
  31. {beancount_cli-0.2.12 → beancount_cli-0.2.14}/tests/conftest.py +0 -0
  32. {beancount_cli-0.2.12 → beancount_cli-0.2.14}/tests/smoke_test.py +0 -0
  33. {beancount_cli-0.2.12 → beancount_cli-0.2.14}/tests/test_bql.py +0 -0
  34. {beancount_cli-0.2.12 → beancount_cli-0.2.14}/tests/test_config.py +0 -0
  35. {beancount_cli-0.2.12 → beancount_cli-0.2.14}/tests/test_models.py +0 -0
@@ -25,3 +25,4 @@ build/
25
25
  .vscode/
26
26
  *.swp
27
27
  .DS_Store
28
+ /Ledger/
@@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.2.14] - 2026-06-02
9
+
10
+ ### Added
11
+ - `account pad-balance`: insert a `pad` + `balance` directive pair directly into the ledger.
12
+
13
+ ### Changed
14
+ - `--stdin` flag renamed to `--input` across all commands that read from standard input.
15
+
16
+ ## [0.2.13] - 2026-04-17
17
+
18
+ ### Added
19
+ - `price check`: detect anomalous pricing situations in addition to missing price gaps.
20
+ - `transaction list --fields`: limit JSON output to a selected comma-separated subset of fields.
21
+
22
+ ### Changed
23
+ - Upgraded `agentyper` to `0.1.12`.
24
+
25
+ ### Fixed
26
+ - `report audit`: sort entries chronologically from oldest to newest.
27
+ - `price fetch`: remove the non-functional `--held` behavior.
28
+
8
29
  ## [0.2.12] - 2026-04-08
9
30
 
10
31
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: beancount-cli
3
- Version: 0.2.12
3
+ Version: 0.2.14
4
4
  Summary: A CLI tool to manage Beancount ledgers
5
5
  Project-URL: Homepage, https://github.com/romamo/beancount-cli
6
6
  Project-URL: Repository, https://github.com/romamo/beancount-cli
@@ -20,12 +20,12 @@ Classifier: Programming Language :: Python :: 3.12
20
20
  Classifier: Programming Language :: Python :: 3.13
21
21
  Classifier: Topic :: Office/Business :: Financial :: Accounting
22
22
  Requires-Python: >=3.10
23
- Requires-Dist: agentyper==0.1.8
24
- Requires-Dist: beancount>=3.0.0
25
- Requires-Dist: beanprice>=2.1.0
23
+ Requires-Dist: agentyper==0.1.13
24
+ Requires-Dist: beancount==3.2.3
25
+ Requires-Dist: beanprice2>=2.1.0
26
26
  Requires-Dist: beanquery>=0.1.0
27
- Requires-Dist: pydantic-settings>=2.0.0
28
- Requires-Dist: pydantic>=2.0.0
27
+ Requires-Dist: pydantic-settings==2.14.1
28
+ Requires-Dist: pydantic==2.13.4
29
29
  Requires-Dist: python-dateutil>=2.8.2
30
30
  Description-Content-Type: text/markdown
31
31
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "beancount-cli"
3
- version = "0.2.12"
3
+ version = "0.2.14"
4
4
  description = "A CLI tool to manage Beancount ledgers"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -9,13 +9,13 @@ authors = [
9
9
  license = { text = "MIT" }
10
10
  requires-python = ">=3.10"
11
11
  dependencies = [
12
- "beancount>=3.0.0",
12
+ "beancount==3.2.3",
13
13
  "beanquery>=0.1.0",
14
- "pydantic>=2.0.0",
14
+ "pydantic==2.13.4",
15
15
  "python-dateutil>=2.8.2",
16
- "beanprice>=2.1.0",
17
- "pydantic-settings>=2.0.0",
18
- "agentyper==0.1.8",
16
+ "beanprice2>=2.1.0",
17
+ "pydantic-settings==2.14.1",
18
+ "agentyper==0.1.13",
19
19
  ]
20
20
  keywords = ["beancount", "cli", "accounting", "automation", "agent"]
21
21
  classifiers = [
@@ -62,8 +62,6 @@ packages = ["src/beancount_cli"]
62
62
  requires = ["hatchling"]
63
63
  build-backend = "hatchling.build"
64
64
 
65
- [tool.uv.sources]
66
- beanprice = { git = "https://github.com/romamo/beanprice" }
67
65
 
68
66
  [dependency-groups]
69
67
  dev = [
@@ -88,4 +86,3 @@ extend-immutable-calls = ["agentyper.Argument", "agentyper.Option", "typer.Argum
88
86
  [tool.ruff.lint.per-file-ignores]
89
87
  "tests/*" = ["E501"]
90
88
 
91
-
@@ -0,0 +1 @@
1
+ __version__ = "0.2.14"
@@ -8,6 +8,7 @@ from beancount_cli.models import (
8
8
  BalanceModel,
9
9
  CostModel,
10
10
  CurrencyCode,
11
+ PadBalanceModel,
11
12
  PostingModel,
12
13
  TransactionModel,
13
14
  )
@@ -110,3 +111,30 @@ def to_core_balance(model: BalanceModel) -> data.Balance:
110
111
  diff_amount=None,
111
112
  tolerance=None,
112
113
  )
114
+
115
+
116
+ def to_core_pad(model: PadBalanceModel) -> tuple[data.Pad, data.Balance]:
117
+ """Convert a PadBalanceModel into a (Pad, Balance) directive pair.
118
+
119
+ The Pad directive is dated one day before the Balance assertion by default,
120
+ which is the standard Beancount pattern to avoid the 'Unused Pad entry' error.
121
+ """
122
+ import datetime
123
+
124
+ pad_date = model.pad_date or (model.balance_date - datetime.timedelta(days=1))
125
+
126
+ pad = data.Pad(
127
+ meta=model.meta or {},
128
+ date=pad_date,
129
+ account=str(model.account),
130
+ source_account=str(model.pad_account),
131
+ )
132
+ balance = data.Balance(
133
+ meta={},
134
+ date=model.balance_date,
135
+ account=str(model.account),
136
+ amount=to_core_amount(model.amount),
137
+ diff_amount=None,
138
+ tolerance=None,
139
+ )
140
+ return pad, balance
@@ -0,0 +1,202 @@
1
+ import json
2
+ import sys
3
+ from datetime import date
4
+ from pathlib import Path
5
+
6
+ import agentyper as typer
7
+ from pydantic import TypeAdapter
8
+
9
+ from beancount_cli.commands.common import (
10
+ _is_table_format,
11
+ console,
12
+ get_ledger_file,
13
+ read_json_input,
14
+ )
15
+ from beancount_cli.models import AccountModel, BalanceModel, PadBalanceModel
16
+ from beancount_cli.services import AccountService
17
+
18
+ app = typer.Agentyper(help="Manage accounts.")
19
+
20
+
21
+ @app.command(name="list")
22
+ def account_list(
23
+ file: Path | None = typer.Option(
24
+ None, "--file", "-f", envvar="BEANCOUNT_FILE", help="Main beancount file"
25
+ ),
26
+ ):
27
+ """List all accounts."""
28
+ actual_file = get_ledger_file(file)
29
+ service = AccountService(actual_file)
30
+ accounts = service.list_accounts()
31
+
32
+ if _is_table_format():
33
+ data = [
34
+ {
35
+ "Account": acc.name,
36
+ "Open Date": str(acc.open_date),
37
+ "Currencies": ", ".join(acc.currencies),
38
+ }
39
+ for acc in accounts
40
+ ]
41
+ typer.output(data, title=f"Accounts ({len(accounts)})")
42
+ else:
43
+ typer.output(accounts, title=f"Accounts ({len(accounts)})")
44
+
45
+
46
+ @app.command(name="create")
47
+ def account_create(
48
+ file: Path | None = typer.Option(
49
+ None, "--file", "-f", envvar="BEANCOUNT_FILE", help="Main beancount file"
50
+ ),
51
+ name: str | None = typer.Option(None, "--name", "-n", help="Account name (e.g. Assets:Bank)"),
52
+ currency_opt: str | None = typer.Option(
53
+ None, "--currency", "-c", help="Currencies (comma-separated)"
54
+ ),
55
+ open_date: str | None = typer.Option(None, "--date", "-d", help="Open date (YYYY-MM-DD)"),
56
+ json_data: str | None = typer.Option(
57
+ None, "--input", "-i", help="JSON string data (or '-' to read from STDIN)"
58
+ ),
59
+ target: Path | None = typer.Option(None, "--target", help="Override target file to write to"),
60
+ ):
61
+ """Create a new account."""
62
+ actual_file = get_ledger_file(file)
63
+ service = AccountService(actual_file)
64
+
65
+ if json_data:
66
+ data_input = json.loads(read_json_input(json_data))
67
+ if isinstance(data_input, list):
68
+ ta = TypeAdapter(list[AccountModel])
69
+ models = ta.validate_python(data_input)
70
+ for m in models:
71
+ service.create_account(m, target_file=target)
72
+ console.print(f"[green]Account {m.name} created.[/green]")
73
+ else:
74
+ model = AccountModel(**data_input)
75
+ service.create_account(model, target_file=target)
76
+ console.print(f"[green]Account {model.name} created.[/green]")
77
+ else:
78
+ if not name:
79
+ console.print("[red]Error: --name is required if not using --input.[/red]")
80
+ sys.exit(typer.EXIT_VALIDATION)
81
+
82
+ d = date.today()
83
+ if open_date:
84
+ d = date.fromisoformat(open_date)
85
+
86
+ currencies = [c.strip() for c in currency_opt.split(",")] if currency_opt else []
87
+ model = AccountModel(name=name, open_date=d, currencies=currencies)
88
+ service.create_account(model, target_file=target)
89
+ console.print(f"[green]Account {name} created.[/green]")
90
+
91
+
92
+ @app.command(name="balance")
93
+ def account_balance(
94
+ file: Path | None = typer.Option(
95
+ None, "--file", "-f", envvar="BEANCOUNT_FILE", help="Main beancount file"
96
+ ),
97
+ json_data: str = typer.Option(
98
+ ..., "--input", "-i", help="JSON string data (or '-' to read from STDIN)"
99
+ ),
100
+ target: Path | None = typer.Option(None, "--target", help="Override target file to write to"),
101
+ ):
102
+ """Add a balance directive for an account."""
103
+ actual_file = get_ledger_file(file)
104
+ service = AccountService(actual_file)
105
+
106
+ data_input = json.loads(read_json_input(json_data))
107
+ model = BalanceModel(**data_input)
108
+ service.add_balance(model, target_file=target)
109
+ console.print(f"[green]Balance check for {model.account} added.[/green]")
110
+
111
+
112
+ @app.command(name="pad-balance")
113
+ def account_pad_balance(
114
+ file: Path | None = typer.Option(
115
+ None, "--file", "-f", envvar="BEANCOUNT_FILE", help="Main beancount file"
116
+ ),
117
+ account: str | None = typer.Option(
118
+ None, "--account", help="Account to adjust (e.g. Assets:BE:Wise:EUR)"
119
+ ),
120
+ amount: str | None = typer.Option(
121
+ None, "--amount", help="Target balance amount (e.g. 1777.00)"
122
+ ),
123
+ currency: str | None = typer.Option(
124
+ None, "--currency", "-c", help="Currency of the target balance (e.g. EUR)"
125
+ ),
126
+ pad_account: str | None = typer.Option(
127
+ None,
128
+ "--pad-account",
129
+ "-p",
130
+ help="Account to absorb the difference (e.g. Expenses:Other)",
131
+ ),
132
+ balance_date: str | None = typer.Option(
133
+ None,
134
+ "--date",
135
+ "-d",
136
+ help="Date of the balance assertion (YYYY-MM-DD). Defaults to today.",
137
+ ),
138
+ pad_date: str | None = typer.Option(
139
+ None,
140
+ "--pad-date",
141
+ help="Date of the pad directive (YYYY-MM-DD). Defaults to balance-date minus 1 day.",
142
+ ),
143
+ json_data: str | None = typer.Option(
144
+ None, "--input", "-i", help="JSON string data (or '-' to read from STDIN)"
145
+ ),
146
+ target: Path | None = typer.Option(None, "--target", help="Override target file to write to"),
147
+ ):
148
+ """Adjust an account balance using a Pad + Balance directive pair.
149
+
150
+ Beancount inserts a synthetic transaction to bring ACCOUNT to AMOUNT
151
+ on BALANCE-DATE. The adjustment is automatically booked to PAD-ACCOUNT.
152
+
153
+ Example:
154
+ uv run bean account pad-balance \\
155
+ --account Assets:BE:Wise:EUR \\
156
+ --amount 1777 --currency EUR \\
157
+ --pad-account Expenses:Other
158
+
159
+ JSON example (for agent pipelines):
160
+ echo '{"account": "Assets:BE:Wise:EUR", "amount": {"number": 1777, "currency": "EUR"}, \\
161
+ "pad_account": "Expenses:Other", "balance_date": "2026-06-02"}' \\
162
+ | uv run bean account pad-balance --input -
163
+ """
164
+ actual_file = get_ledger_file(file)
165
+ service = AccountService(actual_file)
166
+
167
+ if json_data:
168
+ data_input = json.loads(read_json_input(json_data))
169
+ model = PadBalanceModel(**data_input)
170
+ else:
171
+ missing = [
172
+ f
173
+ for f, v in [
174
+ ("--account", account),
175
+ ("--amount", amount),
176
+ ("--currency", currency),
177
+ ("--pad-account", pad_account),
178
+ ]
179
+ if not v
180
+ ]
181
+ if missing:
182
+ console.print(
183
+ f"[red]Error: {', '.join(missing)} required when not using --input.[/red]"
184
+ )
185
+ sys.exit(typer.EXIT_VALIDATION)
186
+
187
+ b_date = date.fromisoformat(balance_date) if balance_date else date.today()
188
+ p_date = date.fromisoformat(pad_date) if pad_date else None
189
+
190
+ model = PadBalanceModel(
191
+ balance_date=b_date,
192
+ account=account, # type: ignore[arg-type]
193
+ amount={"number": amount, "currency": currency},
194
+ pad_account=pad_account, # type: ignore[arg-type]
195
+ pad_date=p_date,
196
+ )
197
+
198
+ service.add_pad_balance(model, target_file=target)
199
+ console.print(
200
+ f"[green]Pad + Balance for {model.account} → {model.amount.number} "
201
+ f"{model.amount.currency} added.[/green]"
202
+ )
@@ -0,0 +1,193 @@
1
+ import json
2
+ import logging
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import agentyper as typer
7
+ from beancount.core import data
8
+ from beancount.parser import parser as bp_parser
9
+
10
+ from beancount_cli.commands.common import (
11
+ _is_table_format,
12
+ console,
13
+ error_console,
14
+ get_ledger_file,
15
+ read_json_input,
16
+ read_stdin,
17
+ )
18
+ from beancount_cli.models import CommodityModel
19
+ from beancount_cli.services import CommodityService
20
+
21
+ app = typer.Agentyper(help="Manage commodities.")
22
+
23
+
24
+ @app.command(name="list")
25
+ def commodity_list(
26
+ file: Path | None = typer.Option(
27
+ None, "--file", "-f", envvar="BEANCOUNT_FILE", help="Main beancount file"
28
+ ),
29
+ asset_class: str | None = typer.Option(
30
+ None, "--asset-class", "-c", help="Filter by asset-class meta (e.g. stock, Cash)"
31
+ ),
32
+ ):
33
+ """List all commodities."""
34
+ actual_file = get_ledger_file(file)
35
+ service = CommodityService(actual_file)
36
+ commodities = service.list_commodities(asset_class=asset_class)
37
+
38
+ if _is_table_format():
39
+ data = [
40
+ {
41
+ "Currency": c.currency,
42
+ "Date": str(c.date) if c.date else "",
43
+ "Name": c.meta.get("name", "") if c.meta else "",
44
+ }
45
+ for c in commodities
46
+ ]
47
+ typer.output(data, title=f"Commodities ({len(commodities)})")
48
+ else:
49
+ typer.output(commodities, title=f"Commodities ({len(commodities)})")
50
+
51
+
52
+ @app.command(name="check")
53
+ def commodity_check(
54
+ file: Path | None = typer.Option(
55
+ None, "--file", "-f", envvar="BEANCOUNT_FILE", help="Main beancount file"
56
+ ),
57
+ ):
58
+ """Check for currencies used in transactions but missing a commodity directive."""
59
+ actual_file = get_ledger_file(file)
60
+ service = CommodityService(actual_file)
61
+ undeclared = service.get_undeclared_commodities()
62
+
63
+ if _is_table_format():
64
+ if not undeclared:
65
+ console.print("[green]All used currencies are declared.[/green]")
66
+ else:
67
+ typer.output(undeclared, title=f"Undeclared Commodities ({len(undeclared)})")
68
+ else:
69
+ typer.output(undeclared, title="Undeclared Commodities")
70
+
71
+
72
+ @app.command(name="export")
73
+ def commodity_export(
74
+ file: Path | None = typer.Option(
75
+ None, "--file", "-f", envvar="BEANCOUNT_FILE", help="Main beancount file"
76
+ ),
77
+ asset_class: str | None = typer.Option(
78
+ None, "--asset-class", "-c", help="Filter by asset-class meta (e.g. stock, Cash)"
79
+ ),
80
+ output_file: Path | None = typer.Option(
81
+ None, "--output-file", help="Write output to file (default: commodities_file or stdout)"
82
+ ),
83
+ ):
84
+ """Export commodities as beancount directives."""
85
+ actual_file = get_ledger_file(file)
86
+ service = CommodityService(actual_file)
87
+ commodities = service.list_commodities(asset_class=asset_class)
88
+
89
+ content = "\n".join(service._format_commodity_block(c) for c in commodities)
90
+
91
+ dest = output_file or service.ledger_service.get_commodities_file()
92
+ if dest:
93
+ dest.write_text(content)
94
+ console.print(f"[green]Exported {len(commodities)} commodities →[/green] {dest}")
95
+ else:
96
+ sys.stdout.write(content)
97
+
98
+
99
+ @app.command(name="import", mutating=True)
100
+ def commodity_import(
101
+ file: Path | None = typer.Option(
102
+ None, "--file", "-f", envvar="BEANCOUNT_FILE", help="Main beancount file"
103
+ ),
104
+ input_file: Path | None = typer.Option(
105
+ None, "--input-file", help="Read beancount directives from file instead of stdin"
106
+ ),
107
+ output_file: Path | None = typer.Option(
108
+ None, "--output-file", help="Write to file (default: commodities_file from ledger config)"
109
+ ),
110
+ overwrite: bool = typer.Option(False, "--overwrite", help="Overwrite existing commodities"),
111
+ dry_run: bool = False,
112
+ ):
113
+ """Import commodity directives from stdin (or --input-file) into the commodities_file."""
114
+ stdin_text = Path(input_file).read_text() if input_file else read_stdin()
115
+ entries, errors, _ = bp_parser.parse_string(stdin_text)
116
+ if errors:
117
+ for e in errors:
118
+ console.print(f"[red]Parse error: {e.message}[/red]")
119
+ sys.exit(typer.EXIT_VALIDATION)
120
+
121
+ commodities = [
122
+ CommodityModel(currency=e.currency, date=e.date, meta=e.meta)
123
+ for e in entries
124
+ if isinstance(e, data.Commodity)
125
+ ]
126
+ if not commodities:
127
+ console.print("[yellow]No commodity directives found in stdin.[/yellow]")
128
+ return
129
+
130
+ actual_file = get_ledger_file(file)
131
+ service = CommodityService(actual_file)
132
+ dest = output_file or service.ledger_service.get_commodities_file()
133
+
134
+ results, commodities_file = service.import_commodities(
135
+ commodities, output_file=dest, overwrite=overwrite, dry_run=dry_run
136
+ )
137
+
138
+ stdout_mode = not dry_run and commodities_file is None
139
+ status_console = error_console if stdout_mode else console
140
+ verbose = logging.getLogger().isEnabledFor(logging.INFO)
141
+ prefix = "[dim](dry-run)[/dim] " if dry_run else ""
142
+ for r in results:
143
+ if not verbose and not dry_run and r.action == "skipped":
144
+ continue
145
+ color = {"added": "green", "overwritten": "yellow", "skipped": "dim"}.get(r.action, "white")
146
+ status_console.print(f"{prefix}[{color}]{r.action:12}[/{color}] {r.currency}")
147
+
148
+ if dry_run:
149
+ return
150
+
151
+ if commodities_file:
152
+ console.print(f"\n[green]Done →[/green] {commodities_file}")
153
+ else:
154
+ # No destination configured — stream added/overwritten entries to stdout
155
+ written = {r.currency for r in results if r.action != "skipped"}
156
+ for c in commodities:
157
+ if str(c.currency) in written:
158
+ sys.stdout.write(service._format_commodity_block(c))
159
+
160
+
161
+ @app.command(name="create")
162
+ def commodity_create(
163
+ currency: str | None = typer.Argument(None, help="Currency code (e.g. USD)"),
164
+ file: Path | None = typer.Option(
165
+ None, "--file", "-f", envvar="BEANCOUNT_FILE", help="Main beancount file"
166
+ ),
167
+ name: str | None = typer.Option(None, "--name", "-n", help="Full name"),
168
+ json_data: str | None = typer.Option(
169
+ None, "--input", "-i", help="JSON string data (or '-' to read from STDIN)"
170
+ ),
171
+ ):
172
+ """Create a new commodity."""
173
+ actual_file = get_ledger_file(file)
174
+ service = CommodityService(actual_file)
175
+
176
+ if json_data:
177
+ data_input = json.loads(read_json_input(json_data))
178
+ items = data_input if isinstance(data_input, list) else [data_input]
179
+ for item in items:
180
+ curr = item.get("currency")
181
+ comm_name = item.get("name")
182
+ if not curr:
183
+ console.print(f"[yellow]Skipping invalid commodity entry: {item}[/yellow]")
184
+ continue
185
+ meta = {k: v for k, v in item.items() if k not in ("currency", "name")}
186
+ service.create_commodity(curr, name=comm_name, meta=meta)
187
+ console.print(f"[green]Commodity {curr} created.[/green]")
188
+ else:
189
+ if not currency:
190
+ console.print("[red]Error: currency argument is required if not using --input.[/red]")
191
+ sys.exit(typer.EXIT_VALIDATION)
192
+ service.create_commodity(currency, name=name)
193
+ console.print(f"[green]Commodity {currency} created.[/green]")
@@ -15,6 +15,30 @@ console = Console(width=_width)
15
15
  error_console = Console(stderr=True, width=_width)
16
16
 
17
17
 
18
+ _STDIN_MAX_BYTES = 65_536
19
+
20
+
21
+ def read_stdin() -> str:
22
+ """Read text from stdin, guarding against TTY deadlock and pipe-buffer overflow (§50, §61)."""
23
+ import agentyper as typer
24
+
25
+ if sys.stdin.isatty():
26
+ error_console.print(
27
+ "[red]Error: stdin is a TTY — pipe input or use --input-file PATH.[/red]"
28
+ )
29
+ sys.exit(typer.EXIT_VALIDATION)
30
+
31
+ chunk = sys.stdin.buffer.read(_STDIN_MAX_BYTES + 1)
32
+ if len(chunk) > _STDIN_MAX_BYTES:
33
+ error_console.print(
34
+ f"[red]Error: stdin exceeds {_STDIN_MAX_BYTES // 1024} KB limit."
35
+ " Use --input-file PATH for large payloads.[/red]"
36
+ )
37
+ sys.exit(typer.EXIT_VALIDATION)
38
+
39
+ return chunk.decode()
40
+
41
+
18
42
  def read_json_input(json_data: str) -> str:
19
43
  """Read JSON from a string or from STDIN when json_data is '-'."""
20
44
  if json_data == "-":