# 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. <danix@danix.xyz>  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. <danix@danix.xyz>  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 `<ref>` 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 <ref>`. 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 "<test-asset-a>" --to "<test-expense>" --budget "CLI Smoke Budget" --desc "budget smoke"
$FF tx delete <that-tx-id> --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.
