# Budget Management Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add a `budget` command group (list/create/delete/enable/disable, limit list/set) plus `tx add --budget`, so the agent can manage Firefly III budgets and assign them to transactions from the CLI. **Architecture:** New `firefly_cli/commands/budget.py` module, self-registering via `@registry.command`. A new `resolver.budget()` resolves budget name-or-id to an object (mirrors `resolver.account`). A shared `_current_month()` helper supplies the default date range. `tx add` gets one optional `--budget` flag. Output stays JSON by default; `--human` gets a computed scalar `spent` for budgets. **Tech Stack:** Python 3.11+ stdlib only. Firefly III REST API v6.6.6. `unittest` (mocked) for unit tests. **Reference:** Spec at `docs/superpowers/specs/2026-07-03-budget-management-design.md`. --- ## File Structure - Create: `firefly_cli/commands/budget.py` — the whole `budget` group (7 handlers) + `_current_month()`. - Modify: `firefly_cli/resolver.py` — add `budget()` and `budget_by_id()`. - Modify: `firefly_cli/commands/transaction.py` — add `--budget` flag + `budget_id` on the split. - Modify: `firefly_cli/commands/__init__.py` — import `budget`. - Modify: `scripts/gen_completion.py` — `budget` in `GROUP_ORDER`, auto-budget enums in `FLAG_VALUES`. - Modify: `SKILL.md` — document the group. - Modify: `pyproject.toml`, `firefly_cli/__init__.py` — version bump. - Modify: `completions/firefly.bash` — regenerated, not hand-edited. - Create: `tests/unit/test_commands_budget.py`, `tests/unit/test_resolver.py` (append); extend `tests/unit/test_commands_transaction.py`. **Convention reminder (v0.3.7 bug):** the `@registry.command` decorator MUST sit immediately above its `cmd_*` def. Any helper goes ABOVE the decorated function, never between decorator and def. --- ## Task 1: resolver.budget() and budget_by_id() **Files:** - Modify: `firefly_cli/resolver.py:44` (after `category`) - Test: `tests/unit/test_resolver.py` (append) - [ ] **Step 1: Write the failing test** ```python # APPEND to the existing tests/unit/test_resolver.py. # unittest / MagicMock / Resolver / ResolutionError are already imported at the # top of that file — do NOT re-import; add only the helper and the class below. def _list_resp(budgets): return {"data": [{"id": b["id"], "attributes": {"name": b["name"]}} for b in budgets]} class TestResolverBudget(unittest.TestCase): def test_budget_by_name(self): client = MagicMock() client.request.return_value = _list_resp( [{"id": "3", "name": "Groceries"}, {"id": "4", "name": "Rent"}]) r = Resolver(client) self.assertEqual(r.budget("Groceries")["id"], "3") def test_budget_missing_raises_with_candidates(self): client = MagicMock() client.request.return_value = _list_resp([{"id": "3", "name": "Rent"}]) r = Resolver(client) with self.assertRaises(ResolutionError) as cm: r.budget("Nope") self.assertIn("Rent", str(cm.exception)) def test_budget_numeric_ref_goes_by_id(self): client = MagicMock() client.request.return_value = { "data": {"id": "7", "attributes": {"name": "Fun"}}} r = Resolver(client) got = r.budget("7") self.assertEqual(got["id"], "7") self.assertEqual(got["name"], "Fun") # Must hit the show endpoint, not list. client.request.assert_called_with("GET", "/api/v1/budgets/7") if __name__ == "__main__": unittest.main() ``` - [ ] **Step 2: Run test to verify it fails** Run: `python -m unittest tests.unit.test_resolver -v` Expected: FAIL, `AttributeError: 'Resolver' object has no attribute 'budget'` - [ ] **Step 3: Write minimal implementation** Add to `firefly_cli/resolver.py` after the `category` method (line 44): ```python def budget(self, name_or_id): # Budgets must pre-exist (Firefly does not auto-create them for a # transaction), so we resolve to an id. A numeric-looking ref goes # straight to the show endpoint (lets same-name-safe callers pin an id); # otherwise match by name against the list. if str(name_or_id).isdigit(): return self.budget_by_id(name_or_id) return self._match("budget", self._list("/api/v1/budgets"), name_or_id) def budget_by_id(self, budget_id): item = self.client.request("GET", f"/api/v1/budgets/{budget_id}")["data"] return {"id": item["id"], **item.get("attributes", {})} ``` - [ ] **Step 4: Run test to verify it passes** Run: `python -m unittest tests.unit.test_resolver -v` Expected: PASS (3 tests) - [ ] **Step 5: Commit** ```bash git add firefly_cli/resolver.py tests/unit/test_resolver.py git commit -S -m "feat(resolver): budget name/id resolution" ``` --- ## Task 2: budget.py module skeleton + `budget list` **Files:** - Create: `firefly_cli/commands/budget.py` - Modify: `firefly_cli/commands/__init__.py:3` - Test: `tests/unit/test_commands_budget.py` - [ ] **Step 1: Write the failing test** ```python # tests/unit/test_commands_budget.py # Copyright (C) 2026 Danilo M. GPL-2.0-only import unittest from unittest.mock import MagicMock from types import SimpleNamespace import firefly_cli.commands.budget as budget def _ctx(client=None, resolver=None): return SimpleNamespace(client=client or MagicMock(), resolver=resolver or MagicMock(), human=False) class TestBudgetList(unittest.TestCase): def test_list_defaults_to_current_month(self): client = MagicMock() client.request.return_value = {"data": []} args = SimpleNamespace(start=None, end=None) rc = budget.cmd_list(args, _ctx(client=client)) self.assertEqual(rc, 0) _, kwargs = client.request.call_args params = kwargs["params"] # current month: start is day 01, end is a valid last-of-month date self.assertTrue(params["start"].endswith("-01")) self.assertEqual(params["start"][:7], params["end"][:7]) def test_list_explicit_range(self): client = MagicMock() client.request.return_value = {"data": []} args = SimpleNamespace(start="2026-01-01", end="2026-01-31") budget.cmd_list(args, _ctx(client=client)) _, kwargs = client.request.call_args self.assertEqual(kwargs["params"], {"start": "2026-01-01", "end": "2026-01-31"}) if __name__ == "__main__": unittest.main() ``` - [ ] **Step 2: Run test to verify it fails** Run: `python -m unittest tests.unit.test_commands_budget -v` Expected: FAIL, `ModuleNotFoundError: No module named 'firefly_cli.commands.budget'` - [ ] **Step 3: Write minimal implementation** Create `firefly_cli/commands/budget.py`: ```python # Copyright (C) 2026 Danilo M. GPL-2.0-only import calendar from datetime import date from firefly_cli import registry, output def _current_month(): """(first, last) ISO dates for the current calendar month.""" today = date.today() last_day = calendar.monthrange(today.year, today.month)[1] first = today.replace(day=1).isoformat() last = today.replace(day=last_day).isoformat() return first, last def _spent_scalar(budget_obj): """Sum a budget's nested `spent` array into a single number (for --human). Firefly sends spent as a per-currency list of {sum: "-12.34", ...}; we sum the sums. JSON output is untouched; this only feeds the table view.""" total = 0.0 for entry in budget_obj.get("spent") or []: try: total += float(entry.get("sum", 0)) except (TypeError, ValueError): pass return f"{total:.2f}" def _list_args(p): p.add_argument("--start", default=None, help="YYYY-MM-DD (default: 1st of this month)") p.add_argument("--end", default=None, help="YYYY-MM-DD (default: last of this month)") @registry.command("budget list", help="list budgets with spent for a period (default: current month)", args=_list_args) def cmd_list(args, ctx): first, last = _current_month() params = {"start": args.start or first, "end": args.end or last} resp = ctx.client.request("GET", "/api/v1/budgets", params=params) rows = output.unwrap(resp) if ctx.human and isinstance(rows, list): # Replace the nested spent array with a scalar so the table shows it. rows = [{**r, "spent": _spent_scalar(r)} for r in rows] output.emit(rows, human=ctx.human) return 0 ``` Modify `firefly_cli/commands/__init__.py` line 3: ```python from firefly_cli.commands import auth, account, category, tag, transaction, budget # noqa: F401 ``` - [ ] **Step 4: Run test to verify it passes** Run: `python -m unittest tests.unit.test_commands_budget -v` Expected: PASS (2 tests) - [ ] **Step 5: Commit** ```bash git add firefly_cli/commands/budget.py firefly_cli/commands/__init__.py tests/unit/test_commands_budget.py git commit -S -m "feat(budget): budget list with current-month default" ``` --- ## Task 3: `budget create` **Files:** - Modify: `firefly_cli/commands/budget.py` (append) - Test: `tests/unit/test_commands_budget.py` (append) - [ ] **Step 1: Write the failing test** Append to `tests/unit/test_commands_budget.py`: ```python class TestBudgetCreate(unittest.TestCase): def test_create_minimal_active(self): client = MagicMock() client.request.return_value = {"data": {"id": "9", "attributes": {"name": "Fun"}}} args = SimpleNamespace(name="Fun", active=True, currency=None, auto_budget_amount=None, auto_budget_period=None, auto_budget_type=None) rc = budget.cmd_create(args, _ctx(client=client)) self.assertEqual(rc, 0) method, path = client.request.call_args[0][:2] body = client.request.call_args[1]["body"] self.assertEqual((method, path), ("POST", "/api/v1/budgets")) self.assertEqual(body["name"], "Fun") self.assertTrue(body["active"]) def test_create_inactive_with_auto_budget(self): client = MagicMock() client.request.return_value = {"data": {"id": "9", "attributes": {}}} args = SimpleNamespace(name="Cap", active=False, currency="EUR", auto_budget_amount="500", auto_budget_period="monthly", auto_budget_type="reset") budget.cmd_create(args, _ctx(client=client)) body = client.request.call_args[1]["body"] self.assertFalse(body["active"]) self.assertEqual(body["auto_budget_amount"], "500") self.assertEqual(body["auto_budget_type"], "reset") self.assertEqual(body["currency_code"], "EUR") ``` - [ ] **Step 2: Run test to verify it fails** Run: `python -m unittest tests.unit.test_commands_budget.TestBudgetCreate -v` Expected: FAIL, `AttributeError: module ... has no attribute 'cmd_create'` - [ ] **Step 3: Write minimal implementation** Append to `firefly_cli/commands/budget.py`: ```python def _create_args(p): p.add_argument("name", help="budget name (must be unique)") g = p.add_mutually_exclusive_group() g.add_argument("--active", dest="active", action="store_true", default=True, help="create active (default)") g.add_argument("--inactive", dest="active", action="store_false", help="create inactive") p.add_argument("--currency", default=None, help="auto-budget currency code, e.g. EUR") p.add_argument("--auto-budget-amount", dest="auto_budget_amount", default=None, help="recurring auto-budget amount") p.add_argument("--auto-budget-period", dest="auto_budget_period", default=None, help="daily|weekly|monthly|quarterly|half_year|yearly") p.add_argument("--auto-budget-type", dest="auto_budget_type", default=None, help="reset|rollover|adjusted|none") @registry.command("budget create", help="create a budget", args=_create_args) def cmd_create(args, ctx): body = {"name": args.name, "active": args.active} if args.auto_budget_amount is not None: body["auto_budget_amount"] = str(args.auto_budget_amount) if args.auto_budget_period is not None: body["auto_budget_period"] = args.auto_budget_period if args.auto_budget_type is not None: body["auto_budget_type"] = args.auto_budget_type if args.currency: body["currency_code"] = args.currency resp = ctx.client.request("POST", "/api/v1/budgets", body=body) output.emit(output.unwrap(resp), human=ctx.human) return 0 ``` - [ ] **Step 4: Run test to verify it passes** Run: `python -m unittest tests.unit.test_commands_budget.TestBudgetCreate -v` Expected: PASS (2 tests) - [ ] **Step 5: Commit** ```bash git add firefly_cli/commands/budget.py tests/unit/test_commands_budget.py git commit -S -m "feat(budget): budget create" ``` --- ## Task 4: `budget delete`, `budget enable`, `budget disable` **Files:** - Modify: `firefly_cli/commands/budget.py` (append) - Test: `tests/unit/test_commands_budget.py` (append) - [ ] **Step 1: Write the failing test** Append to `tests/unit/test_commands_budget.py`: ```python from firefly_cli.errors import FireflyError class TestBudgetLifecycle(unittest.TestCase): def _resolver(self): r = MagicMock() r.budget.return_value = {"id": "5", "name": "Rent"} return r def test_delete_requires_yes(self): args = SimpleNamespace(ref="Rent", yes=False) with self.assertRaises(FireflyError): budget.cmd_delete(args, _ctx(resolver=self._resolver())) def test_delete_with_yes_calls_delete(self): client = MagicMock() args = SimpleNamespace(ref="Rent", yes=True) rc = budget.cmd_delete(args, _ctx(client=client, resolver=self._resolver())) self.assertEqual(rc, 0) client.request.assert_called_with("DELETE", "/api/v1/budgets/5") def test_enable_sets_active_true(self): client = MagicMock() client.request.return_value = {"data": {"id": "5", "attributes": {}}} args = SimpleNamespace(ref="Rent") budget.cmd_enable(args, _ctx(client=client, resolver=self._resolver())) method, path = client.request.call_args[0][:2] body = client.request.call_args[1]["body"] self.assertEqual((method, path), ("PUT", "/api/v1/budgets/5")) self.assertTrue(body["active"]) def test_disable_sets_active_false(self): client = MagicMock() client.request.return_value = {"data": {"id": "5", "attributes": {}}} args = SimpleNamespace(ref="Rent") budget.cmd_disable(args, _ctx(client=client, resolver=self._resolver())) self.assertFalse(client.request.call_args[1]["body"]["active"]) ``` - [ ] **Step 2: Run test to verify it fails** Run: `python -m unittest tests.unit.test_commands_budget.TestBudgetLifecycle -v` Expected: FAIL, `AttributeError: ... has no attribute 'cmd_delete'` - [ ] **Step 3: Write minimal implementation** Append to `firefly_cli/commands/budget.py`. Add the import at the top of the file (next to the existing imports): ```python from firefly_cli.errors import FireflyError ``` Then append the handlers: ```python def _ref_arg(p): p.add_argument("ref", help="budget name or id") def _delete_args(p): _ref_arg(p) p.add_argument("--yes", action="store_true", help="confirm deletion (required)") @registry.command("budget delete", help="delete a budget by name or id (requires --yes)", args=_delete_args) def cmd_delete(args, ctx): if not args.yes: raise FireflyError("budget delete needs --yes to confirm.") b = ctx.resolver.budget(args.ref) ctx.client.request("DELETE", f"/api/v1/budgets/{b['id']}") output.emit({"deleted": b["id"], "name": b.get("name")}, human=ctx.human) return 0 def _set_active(ctx, ref, active): b = ctx.resolver.budget(ref) resp = ctx.client.request("PUT", f"/api/v1/budgets/{b['id']}", body={"active": active}) output.emit(output.unwrap(resp), human=ctx.human) return 0 @registry.command("budget enable", help="mark a budget active", args=_ref_arg) def cmd_enable(args, ctx): return _set_active(ctx, args.ref, True) @registry.command("budget disable", help="mark a budget inactive", args=_ref_arg) def cmd_disable(args, ctx): return _set_active(ctx, args.ref, False) ``` - [ ] **Step 4: Run test to verify it passes** Run: `python -m unittest tests.unit.test_commands_budget.TestBudgetLifecycle -v` Expected: PASS (4 tests) - [ ] **Step 5: Commit** ```bash git add firefly_cli/commands/budget.py tests/unit/test_commands_budget.py git commit -S -m "feat(budget): delete, enable, disable" ``` --- ## Task 5: `budget limit list` and `budget limit set` **Files:** - Modify: `firefly_cli/commands/budget.py` (append) - Test: `tests/unit/test_commands_budget.py` (append) - [ ] **Step 1: Write the failing test** Append to `tests/unit/test_commands_budget.py`: ```python class TestBudgetLimit(unittest.TestCase): def _resolver(self): r = MagicMock() r.budget.return_value = {"id": "5", "name": "Rent"} return r def test_limit_list(self): client = MagicMock() client.request.return_value = {"data": []} args = SimpleNamespace(ref="Rent") rc = budget.cmd_limit_list(args, _ctx(client=client, resolver=self._resolver())) self.assertEqual(rc, 0) client.request.assert_called_with("GET", "/api/v1/budgets/5/limits") def test_limit_set_default_month(self): client = MagicMock() client.request.return_value = {"data": {"id": "1", "attributes": {}}} args = SimpleNamespace(ref="Rent", amount="800", start=None, end=None, currency=None) budget.cmd_limit_set(args, _ctx(client=client, resolver=self._resolver())) method, path = client.request.call_args[0][:2] body = client.request.call_args[1]["body"] self.assertEqual((method, path), ("POST", "/api/v1/budgets/5/limits")) self.assertEqual(body["amount"], "800") self.assertTrue(body["start"].endswith("-01")) self.assertEqual(body["start"][:7], body["end"][:7]) def test_limit_set_explicit_range_and_currency(self): client = MagicMock() client.request.return_value = {"data": {"id": "1", "attributes": {}}} args = SimpleNamespace(ref="Rent", amount="800", start="2026-02-01", end="2026-02-28", currency="EUR") budget.cmd_limit_set(args, _ctx(client=client, resolver=self._resolver())) body = client.request.call_args[1]["body"] self.assertEqual(body["start"], "2026-02-01") self.assertEqual(body["end"], "2026-02-28") self.assertEqual(body["currency_code"], "EUR") ``` - [ ] **Step 2: Run test to verify it fails** Run: `python -m unittest tests.unit.test_commands_budget.TestBudgetLimit -v` Expected: FAIL, `AttributeError: ... has no attribute 'cmd_limit_list'` - [ ] **Step 3: Write minimal implementation** Append to `firefly_cli/commands/budget.py`: ```python @registry.command("budget limit list", help="list a budget's limits (name or id)", args=_ref_arg) def cmd_limit_list(args, ctx): b = ctx.resolver.budget(args.ref) resp = ctx.client.request("GET", f"/api/v1/budgets/{b['id']}/limits") output.emit(output.unwrap(resp), human=ctx.human) return 0 def _limit_set_args(p): _ref_arg(p) p.add_argument("--amount", required=True, help="limit amount (positive)") p.add_argument("--start", default=None, help="YYYY-MM-DD (default: 1st of this month)") p.add_argument("--end", default=None, help="YYYY-MM-DD (default: last of this month)") p.add_argument("--currency", default=None, help="currency code, e.g. EUR") @registry.command("budget limit set", help="set (create) a spending limit for a budget over a period", args=_limit_set_args) def cmd_limit_set(args, ctx): b = ctx.resolver.budget(args.ref) first, last = _current_month() body = {"amount": str(args.amount), "start": args.start or first, "end": args.end or last} if args.currency: body["currency_code"] = args.currency resp = ctx.client.request("POST", f"/api/v1/budgets/{b['id']}/limits", body=body) output.emit(output.unwrap(resp), human=ctx.human) return 0 ``` - [ ] **Step 4: Run test to verify it passes** Run: `python -m unittest tests.unit.test_commands_budget.TestBudgetLimit -v` Expected: PASS (3 tests) - [ ] **Step 5: Commit** ```bash git add firefly_cli/commands/budget.py tests/unit/test_commands_budget.py git commit -S -m "feat(budget): limit list and limit set" ``` --- ## Task 6: `tx add --budget` **Files:** - Modify: `firefly_cli/commands/transaction.py` (args block ~line 34, handler ~line 65) - Test: `tests/unit/test_commands_transaction.py` (append) - [ ] **Step 1: Write the failing test** Append a test to `tests/unit/test_commands_transaction.py`. (Match the file's existing ctx/args construction style; the assertion is what matters.) ```python class TestTxAddBudget(unittest.TestCase): def test_budget_ref_sets_budget_id_on_split(self): from types import SimpleNamespace from unittest.mock import MagicMock import firefly_cli.commands.transaction as tx client = MagicMock() client.request.return_value = {"data": {"id": "1", "attributes": {}}} resolver = MagicMock() resolver.account.side_effect = lambda n: {"id": "1", "name": n, "type": "asset"} resolver.budget.return_value = {"id": "12", "name": "Groceries"} ctx = SimpleNamespace(client=client, resolver=resolver, human=False) args = SimpleNamespace( amount="10", source="Checking", dest="Shop", source_id=None, dest_id=None, desc=None, date="2026-07-03", category=None, tags=None, type="withdrawal", dry_run=False, skip_dupes=False, budget="Groceries") rc = tx.cmd_add(args, ctx) self.assertEqual(rc, 0) body = client.request.call_args[1]["body"] self.assertEqual(body["transactions"][0]["budget_id"], "12") ``` - [ ] **Step 2: Run test to verify it fails** Run: `python -m unittest tests.unit.test_commands_transaction.TestTxAddBudget -v` Expected: FAIL, `AttributeError: 'types.SimpleNamespace' object has no attribute 'budget'` is NOT the failure we want — the handler must READ `args.budget`. It fails because the handler doesn't set `budget_id`. Actual expected fail: `KeyError: 'budget_id'` on the assertion. - [ ] **Step 3: Write minimal implementation** In `firefly_cli/commands/transaction.py`, add the flag in `_add_args` (after the `--tags` line, ~line 34): ```python p.add_argument("--budget", default=None, help="budget name or id to assign (must already exist)") ``` In `cmd_add`, after the `if args.tags:` block (~line 69) and before the `if ttype == "transfer":` block, add: ```python if args.budget: # Budgets must pre-exist; resolve name/id -> id (hard error on miss). split["budget_id"] = ctx.resolver.budget(args.budget)["id"] ``` - [ ] **Step 4: Run test to verify it passes** Run: `python -m unittest tests.unit.test_commands_transaction.TestTxAddBudget -v` Expected: PASS - [ ] **Step 5: Commit** ```bash git add firefly_cli/commands/transaction.py tests/unit/test_commands_transaction.py git commit -S -m "feat(tx): tx add --budget assigns a budget by name or id" ``` --- ## Task 7: handler-registration regression test **Files:** - Test: `tests/unit/test_commands_budget.py` (append) This guards the v0.3.7 misbinding class of bug: unit tests call `cmd_*` directly and bypass the registry, so a decorator bound to the wrong function is invisible to them. - [ ] **Step 1: Write the failing test (will pass once names line up)** Append to `tests/unit/test_commands_budget.py`: ```python from firefly_cli import registry import firefly_cli.commands # noqa: F401 ensure all modules registered class TestBudgetRegistration(unittest.TestCase): def test_each_budget_command_binds_to_its_handler(self): expected = { "budget list": budget.cmd_list, "budget create": budget.cmd_create, "budget delete": budget.cmd_delete, "budget enable": budget.cmd_enable, "budget disable": budget.cmd_disable, "budget limit list": budget.cmd_limit_list, "budget limit set": budget.cmd_limit_set, } by_name = {c.name: c.handler for c in registry.all_commands()} for name, fn in expected.items(): self.assertIn(name, by_name, f"{name} not registered") self.assertIs(by_name[name], fn, f"{name} bound to wrong handler") ``` Note: confirm the registry command object's handler attribute name. Check `firefly_cli/registry.py` — if the attribute is not `.handler`, use the actual name (e.g. `.func`). The v0.3.7 regression test in `tests/unit/` already does this; mirror it. - [ ] **Step 2: Run test** Run: `python -m unittest tests.unit.test_commands_budget.TestBudgetRegistration -v` Expected: PASS (if a decorator is misplaced, this FAILS — that is the point). - [ ] **Step 3: Commit** ```bash git add tests/unit/test_commands_budget.py git commit -S -m "test(budget): assert each command binds to its handler" ``` --- ## Task 8: completion + full unit suite **Files:** - Modify: `scripts/gen_completion.py:22` (GROUP_ORDER), `:27` (FLAG_VALUES) - Regenerate: `completions/firefly.bash` - [ ] **Step 1: Add budget to GROUP_ORDER and enum values** In `scripts/gen_completion.py`, line 22: ```python GROUP_ORDER = ["auth", "account", "category", "tag", "tx", "budget"] ``` Add to `FLAG_VALUES` (after the `tx edit` entry): ```python "budget create": { "--auto-budget-type": "reset rollover adjusted none", "--auto-budget-period": "daily weekly monthly quarterly half_year yearly", }, "budget limit set": {}, ``` - [ ] **Step 2: Regenerate completion** Run: `python scripts/gen_completion.py > completions/firefly.bash` Then verify it mentions budget: Run: `grep -c budget completions/firefly.bash` Expected: a count > 0. - [ ] **Step 3: Run the FULL unit suite** Run: `python -m unittest discover -s tests/unit` Expected: OK, all tests pass (prior count + the new budget/resolver/tx tests). - [ ] **Step 4: Smoke the CLI dispatch (registry path, not just direct calls)** Run: `python -m firefly_cli budget list --help` Expected: help text with `--start` and `--end`, exit 0. Run: `python -m firefly_cli budget limit set --help` Expected: help text with `--amount` (required), exit 0. - [ ] **Step 5: Commit** ```bash git add scripts/gen_completion.py completions/firefly.bash git commit -S -m "build(completion): budget group and auto-budget enum values" ``` --- ## Task 9: SKILL.md + version bump + tag **Files:** - Modify: `SKILL.md` - Modify: `pyproject.toml`, `firefly_cli/__init__.py` - [ ] **Step 1: Document the budget group in SKILL.md** Add a budget section covering: the seven commands, that a budget `` is a name OR id resolved by the CLI, that **budgets must pre-exist (not auto-created like categories)**, the current-month default period for `budget list` and `budget limit set`, and `tx add --budget `. Match the existing SKILL.md command-doc style (read a nearby section first). - [ ] **Step 2: Bump version** Determine the current version: Run: `grep version pyproject.toml | head -1` This is a new command group + one optional flag → contract-additive → **MINOR** bump (e.g. 0.3.8 → 0.4.0). Set the SAME new version in both `pyproject.toml` and `firefly_cli/__init__.py`. - [ ] **Step 3: Re-run full suite** Run: `python -m unittest discover -s tests/unit` Expected: OK. - [ ] **Step 4: Commit and tag** ```bash git add SKILL.md pyproject.toml firefly_cli/__init__.py git commit -S -m "feat: budget management group + tx add --budget (v0.4.0)" git tag -s v0.4.0 -m "budget management" git log --format='%h %G? %s' -3 ``` Expected: commits show `G` (good signature). Adjust the version string if the base was not 0.3.8. --- ## Task 10: live smoke test (mocked suite bypasses the registry) The mocked suite never hits Firefly and bypasses `client`. A live smoke against the test instance is REQUIRED after any command-module structure change (the v0.3.7 lesson). NEVER point at real data; create-then-delete own records. - [ ] **Step 1: Source test creds** ```bash set -a; . ~/.config/firefly-cli/test-creds.env; set +a FF="python -m firefly_cli --url $FIREFLY_TEST_URL --token $FIREFLY_TEST_TOKEN" ``` - [ ] **Step 2: Exercise the group (create → limit → assign → cleanup)** ```bash $FF budget create "CLI Smoke Budget" # note the id $FF budget list --human $FF budget limit set "CLI Smoke Budget" --amount 100 # default current month $FF budget limit list "CLI Smoke Budget" $FF budget disable "CLI Smoke Budget" $FF budget enable "CLI Smoke Budget" # assign to a throwaway tx between two existing test asset accounts, then delete it $FF tx add 1 --from "" --to "" --budget "CLI Smoke Budget" --desc "budget smoke" $FF tx delete --yes $FF budget delete "CLI Smoke Budget" --yes ``` Expected: each returns JSON (or a table for `--human`), exit 0; the bad paths (`budget delete` without `--yes`, an unknown budget ref) exit 1 with an `{"error": ...}`. Confirm the created budget is gone from `budget list` at the end. - [ ] **Step 3: Push** ```bash git push --follow-tags ``` Expected: reaches both remotes (danix_git gitolite and github). If the github push fails once with "agent refused operation" on the RSA key, retry directly. --- ## Self-Review Notes - **Spec coverage:** list ✓(T2), create ✓(T3), delete/enable/disable ✓(T4), limit list/set ✓(T5), tx add --budget ✓(T6), resolver.budget ✓(T1), current-month helper ✓(T2), output scalar spent ✓(T2), completion+enums ✓(T8), SKILL.md ✓(T9), version MINOR ✓(T9), live smoke ✓(T10). Out-of-scope items are not built. - **Output views:** the spec mentioned adding budget/limit views to `output.py` `_VIEWS`. Simplified: budgets reuse the existing `name` view plus a computed scalar `spent` (nested arrays don't tables well), and limits fall through the generic scalar-column table. No `_VIEWS` change needed. ponytail: add explicit views only if the generic table reads poorly in smoke. - **Registry handler attribute:** Task 7 depends on the command object's handler attribute name (`.handler` vs `.func`) — verify against `registry.py` / the existing v0.3.7 regression test before writing.