diff options
| author | Danilo M. <danix@danix.xyz> | 2026-06-30 10:34:35 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-06-30 10:34:35 +0200 |
| commit | 74b383a02714443318811ba5a286f5dbbbe09bad (patch) | |
| tree | b527fa978a661ca92ca6e86c41b85abcddd6b325 /docs/superpowers/plans | |
| parent | 9a2439a197b2456f3d6e8047254be7ba7a4122d3 (diff) | |
| download | firefly-cli-74b383a02714443318811ba5a286f5dbbbe09bad.tar.gz firefly-cli-74b383a02714443318811ba5a286f5dbbbe09bad.zip | |
Add firefly-cli implementation plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat (limited to 'docs/superpowers/plans')
| -rw-r--r-- | docs/superpowers/plans/2026-06-30-firefly-cli.md | 1387 |
1 files changed, 1387 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-06-30-firefly-cli.md b/docs/superpowers/plans/2026-06-30-firefly-cli.md new file mode 100644 index 0000000..2d5596c --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-firefly-cli.md @@ -0,0 +1,1387 @@ +# firefly-cli 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:** A Python-package CLI (`firefly`) that lets an LLM agent and the user record and query a Firefly III instance over its REST API. + +**Architecture:** A `firefly_cli` package with shared primitives (config, HTTP client, name resolver, output) and a self-registering command registry. Each command group is one module in `firefly_cli/commands/`; dropping a module in adds commands with no other edits. JSON output by default, `--human` for tables. + +**Tech Stack:** Python 3.11+ standard library only (`urllib.request`, `json`, `tomllib`, `argparse`, `unittest`). No third-party runtime deps. License GPLv2-only. + +--- + +## Reference facts (verified against the cloned firefly-iii codebase, read-only) + +- API base path: `/api/v1`. About endpoint: `GET /api/v1/about` returns `{"data":{"version":...,"api_version":...}}`. +- Auth: `Authorization: Bearer <PAT>`. Accept header `application/vnd.api+json`. +- Account types (enum string values): `Asset account`, `Expense account`, + `Revenue account`, `Liability credit account`, `Loan`, `Mortgage`, `Debt`, + `Cash account`, etc. The API `accounts` list accepts a `type` query filter + with short names: `asset`, `expense`, `revenue`, `liability`, etc. +- Account resource fields include: `id`, `name`, `type`, `current_balance`, + `currency_code`. (AccountTransformer, verified.) +- Transaction store body shape: + `{"transactions":[{ "type", "date", "amount", "description", + "source_id", "destination_id", "category_name", "tags": [...] }]}`. + Source/destination accept `_id` or `_name`; we send `_id` (resolved). Fields + verified in StoreRequest rules. +- Transaction reads use TransactionGroupTransformer (a group wraps one or more + splits). List/collection responses are Fractal-serialized: + `{"data":[...], "meta":{"pagination":{...}}, "links":{...}}`. + +**Executor note:** the exact list envelope and the precise `current_balance` +field name are confirmed live by the integration tests in Task 11. Until then, +unit tests use the documented shapes above. If a live shape differs, update the +unwrap logic and its unit test together. + +--- + +## File Structure + +- `firefly_cli/__init__.py` — version, package marker. +- `firefly_cli/config.py` — load (env over TOML file), template-write. +- `firefly_cli/client.py` — `Client` with `request()`, auth headers, error surfacing. +- `firefly_cli/errors.py` — exception types shared across modules. +- `firefly_cli/output.py` — `emit()` JSON/human, `unwrap()` envelope helper. +- `firefly_cli/resolver.py` — `Resolver` name→id with ambiguity errors. +- `firefly_cli/registry.py` — command registry (decorator + list). +- `firefly_cli/cli.py` — builds argparse from registry, `main()`, builds `Context`. +- `firefly_cli/context.py` — `Context` dataclass bundling config/client/resolver + flags. +- `firefly_cli/commands/__init__.py` — imports each command module so they register. +- `firefly_cli/commands/auth.py`, `account.py`, `transaction.py`, `category.py`, `tag.py`. +- `firefly_cli/__main__.py` — `python -m firefly_cli`. +- `tests/unit/test_*.py` — mocked, always run. +- `tests/integration/test_live.py` — gated by `FIREFLY_TEST_URL`/`FIREFLY_TEST_TOKEN`. +- `pyproject.toml`, `LICENSE`, `README.md`, `CLAUDE.md`. + +--- + +## Task 1: Project scaffold, license, CLAUDE.md + +**Files:** +- Create: `pyproject.toml`, `LICENSE`, `README.md`, `CLAUDE.md`, + `firefly_cli/__init__.py`, `firefly_cli/__main__.py`, `.gitignore` + +- [ ] **Step 1: Fetch GPLv2 license text** + +Run: +```bash +curl -fsSL https://www.gnu.org/licenses/old-licenses/gpl-2.0.txt -o LICENSE +head -2 LICENSE +``` +Expected: prints the GPLv2 preamble lines. If offline, executor must obtain the +official text before committing. + +- [ ] **Step 2: Write `pyproject.toml`** + +```toml +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "firefly-cli" +version = "0.1.0" +description = "CLI tool for agent interaction with Firefly III" +requires-python = ">=3.11" +license = { text = "GPL-2.0-only" } +authors = [{ name = "Danilo M.", email = "danix@danix.xyz" }] +dependencies = [] + +[project.scripts] +firefly = "firefly_cli.cli:main" + +[tool.setuptools.packages.find] +include = ["firefly_cli*"] +``` + +- [ ] **Step 3: Write `firefly_cli/__init__.py`** + +```python +# firefly-cli — CLI for Firefly III +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> +# Licensed under the GNU General Public License v2.0 only. + +__version__ = "0.1.0" +``` + +- [ ] **Step 4: Write `firefly_cli/__main__.py`** + +```python +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> GPL-2.0-only +from firefly_cli.cli import main + +if __name__ == "__main__": + raise SystemExit(main()) +``` + +- [ ] **Step 5: Write `.gitignore`** + +``` +__pycache__/ +*.pyc +*.egg-info/ +build/ +dist/ +.venv/ +``` + +- [ ] **Step 6: Write `README.md`** (sections: what it is, install + `pip install -e .`, config via `firefly auth set` or env vars, command list + placeholder pointing at `firefly --help`, License = GPLv2-only). + +- [ ] **Step 7: Write `CLAUDE.md`** — see Appendix A at the end of this plan for + full content. Copy it verbatim. + +- [ ] **Step 8: Commit** + +```bash +git add pyproject.toml LICENSE README.md CLAUDE.md firefly_cli/__init__.py firefly_cli/__main__.py .gitignore +git commit -S -m "chore: project scaffold, GPLv2 license, CLAUDE.md" +``` + +--- + +## Task 2: Errors module + +**Files:** +- Create: `firefly_cli/errors.py` +- Test: `tests/unit/test_errors.py` + +- [ ] **Step 1: Write the failing test** + +```python +import unittest +from firefly_cli.errors import FireflyError, ConfigError, ApiError, ResolutionError + +class TestErrors(unittest.TestCase): + def test_subclassing(self): + for cls in (ConfigError, ApiError, ResolutionError): + self.assertTrue(issubclass(cls, FireflyError)) + + def test_api_error_carries_status_and_body(self): + e = ApiError(422, {"message": "bad"}) + self.assertEqual(e.status, 422) + self.assertEqual(e.body, {"message": "bad"}) + self.assertIn("422", str(e)) + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run test, verify it fails** + +Run: `python -m unittest tests.unit.test_errors -v` +Expected: FAIL, `ModuleNotFoundError: firefly_cli.errors`. + +- [ ] **Step 3: Write `firefly_cli/errors.py`** + +```python +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> GPL-2.0-only + +class FireflyError(Exception): + """Base for all firefly-cli errors.""" + +class ConfigError(FireflyError): + """Missing or invalid configuration.""" + +class ResolutionError(FireflyError): + """A name could not be resolved to a single id.""" + +class ApiError(FireflyError): + """Firefly returned a non-2xx response.""" + def __init__(self, status, body): + self.status = status + self.body = body + msg = body.get("message") if isinstance(body, dict) else body + super().__init__(f"API error {status}: {msg}") +``` + +- [ ] **Step 4: Run test, verify it passes** + +Run: `python -m unittest tests.unit.test_errors -v` +Expected: PASS (add empty `tests/__init__.py`, `tests/unit/__init__.py` if needed). + +- [ ] **Step 5: Commit** + +```bash +git add firefly_cli/errors.py tests/ +git commit -S -m "feat: error types" +``` + +--- + +## Task 3: Config (env over TOML file, template write) + +**Files:** +- Create: `firefly_cli/config.py` +- Test: `tests/unit/test_config.py` + +- [ ] **Step 1: Write the failing test** + +```python +import os, tempfile, unittest +from pathlib import Path +from firefly_cli import config +from firefly_cli.errors import ConfigError + +class TestConfig(unittest.TestCase): + def setUp(self): + self.dir = tempfile.TemporaryDirectory() + self.path = Path(self.dir.name) / "config.toml" + def tearDown(self): + self.dir.cleanup() + for k in ("FIREFLY_URL", "FIREFLY_TOKEN"): + os.environ.pop(k, None) + + def test_write_then_read_roundtrip(self): + config.write("https://f.example/", "tok123", path=self.path) + cfg = config.load(path=self.path, env={}) + self.assertEqual(cfg["url"], "https://f.example") # trailing slash trimmed + self.assertEqual(cfg["token"], "tok123") + + def test_env_overrides_file(self): + config.write("https://file/", "filetok", path=self.path) + cfg = config.load(path=self.path, + env={"FIREFLY_URL": "https://env", "FIREFLY_TOKEN": "envtok"}) + self.assertEqual(cfg["url"], "https://env") + self.assertEqual(cfg["token"], "envtok") + + def test_missing_everything_raises_configerror(self): + with self.assertRaises(ConfigError): + config.load(path=self.path, env={}) + + def test_env_only_no_file(self): + cfg = config.load(path=self.path, + env={"FIREFLY_URL": "https://env/", "FIREFLY_TOKEN": "t"}) + self.assertEqual(cfg["url"], "https://env") +``` + +- [ ] **Step 2: Run test, verify it fails** + +Run: `python -m unittest tests.unit.test_config -v` +Expected: FAIL, no module/attr. + +- [ ] **Step 3: Write `firefly_cli/config.py`** + +```python +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> GPL-2.0-only +import os +import tomllib +from pathlib import Path +from firefly_cli.errors import ConfigError + +DEFAULT_PATH = Path(os.path.expanduser("~/.config/firefly-cli/config.toml")) + +def load(path=DEFAULT_PATH, env=None): + env = os.environ if env is None else env + url = env.get("FIREFLY_URL") + token = env.get("FIREFLY_TOKEN") + if not (url and token) and Path(path).exists(): + with open(path, "rb") as fh: + data = tomllib.load(fh) + url = url or data.get("url") + token = token or data.get("token") + if not url or not token: + raise ConfigError( + "No Firefly III config found. Run `firefly auth set` " + "or set FIREFLY_URL and FIREFLY_TOKEN." + ) + return {"url": url.rstrip("/"), "token": token} + +def write(url, token, path=DEFAULT_PATH): + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + # tomllib cannot write; template the 2-key file (deps stay at zero). + content = ( + f'url = "{url.rstrip("/")}"\n' + f'token = "{token}"\n' + ) + path.write_text(content) + path.chmod(0o600) + return path +``` + +- [ ] **Step 4: Run test, verify it passes** + +Run: `python -m unittest tests.unit.test_config -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add firefly_cli/config.py tests/unit/test_config.py +git commit -S -m "feat: config load/write with env override" +``` + +--- + +## Task 4: HTTP client + +**Files:** +- Create: `firefly_cli/client.py` +- Test: `tests/unit/test_client.py` + +- [ ] **Step 1: Write the failing test** + +```python +import json, unittest +from unittest.mock import patch, MagicMock +from io import BytesIO +import urllib.error +from firefly_cli.client import Client +from firefly_cli.errors import ApiError + +def fake_response(payload, status=200): + r = MagicMock() + r.read.return_value = json.dumps(payload).encode() + r.status = status + r.__enter__.return_value = r + r.__exit__.return_value = False + return r + +class TestClient(unittest.TestCase): + def setUp(self): + self.c = Client("https://f.example", "tok") + + @patch("firefly_cli.client.urllib.request.urlopen") + def test_get_builds_url_and_headers(self, urlopen): + urlopen.return_value = fake_response({"data": []}) + out = self.c.request("GET", "/api/v1/accounts", params={"type": "asset"}) + req = urlopen.call_args[0][0] + self.assertEqual(req.full_url, "https://f.example/api/v1/accounts?type=asset") + self.assertEqual(req.get_header("Authorization"), "Bearer tok") + self.assertEqual(req.get_header("Accept"), "application/vnd.api+json") + self.assertEqual(out, {"data": []}) + + @patch("firefly_cli.client.urllib.request.urlopen") + def test_post_sends_json_body(self, urlopen): + urlopen.return_value = fake_response({"data": {"id": "9"}}) + self.c.request("POST", "/api/v1/transactions", body={"x": 1}) + req = urlopen.call_args[0][0] + self.assertEqual(req.get_method(), "POST") + self.assertEqual(json.loads(req.data), {"x": 1}) + self.assertEqual(req.get_header("Content-type"), "application/json") + + @patch("firefly_cli.client.urllib.request.urlopen") + def test_http_error_becomes_apierror(self, urlopen): + body = json.dumps({"message": "boom"}).encode() + urlopen.side_effect = urllib.error.HTTPError( + "u", 422, "Unprocessable", {}, BytesIO(body)) + with self.assertRaises(ApiError) as ctx: + self.c.request("GET", "/api/v1/accounts") + self.assertEqual(ctx.exception.status, 422) + self.assertEqual(ctx.exception.body["message"], "boom") +``` + +- [ ] **Step 2: Run test, verify it fails** + +Run: `python -m unittest tests.unit.test_client -v` +Expected: FAIL, no module. + +- [ ] **Step 3: Write `firefly_cli/client.py`** + +```python +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> GPL-2.0-only +import json +import urllib.request +import urllib.parse +import urllib.error +from firefly_cli.errors import ApiError + +class Client: + def __init__(self, url, token, timeout=30): + self.url = url.rstrip("/") + self.token = token + self.timeout = timeout + + def request(self, method, path, params=None, body=None): + full = self.url + path + if params: + full += "?" + urllib.parse.urlencode(params) + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(full, data=data, method=method) + req.add_header("Authorization", f"Bearer {self.token}") + req.add_header("Accept", "application/vnd.api+json") + if data is not None: + req.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + raw = resp.read().decode() + return json.loads(raw) if raw else {} + except urllib.error.HTTPError as e: + raw = e.read().decode() + try: + parsed = json.loads(raw) + except ValueError: + parsed = {"message": raw or e.reason} + raise ApiError(e.code, parsed) from None +``` + +- [ ] **Step 4: Run test, verify it passes** + +Run: `python -m unittest tests.unit.test_client -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add firefly_cli/client.py tests/unit/test_client.py +git commit -S -m "feat: HTTP client with auth and error surfacing" +``` + +--- + +## Task 5: Output (emit + unwrap) + +**Files:** +- Create: `firefly_cli/output.py` +- Test: `tests/unit/test_output.py` + +- [ ] **Step 1: Write the failing test** + +```python +import io, json, unittest +from contextlib import redirect_stdout +from firefly_cli.output import unwrap, emit + +class TestOutput(unittest.TestCase): + def test_unwrap_list_returns_clean_objects(self): + resp = {"data": [ + {"id": "1", "type": "accounts", "attributes": {"name": "Checking"}}, + {"id": "2", "type": "accounts", "attributes": {"name": "Savings"}}, + ]} + self.assertEqual(unwrap(resp), + [{"id": "1", "name": "Checking"}, {"id": "2", "name": "Savings"}]) + + def test_unwrap_single_object(self): + resp = {"data": {"id": "5", "type": "accounts", + "attributes": {"name": "Wallet"}}} + self.assertEqual(unwrap(resp), {"id": "5", "name": "Wallet"}) + + def test_emit_json_default(self): + buf = io.StringIO() + with redirect_stdout(buf): + emit([{"id": "1", "name": "x"}], human=False) + self.assertEqual(json.loads(buf.getvalue()), [{"id": "1", "name": "x"}]) + + def test_emit_human_table_contains_values(self): + buf = io.StringIO() + with redirect_stdout(buf): + emit([{"id": "1", "name": "Checking"}], human=True) + out = buf.getvalue() + self.assertIn("Checking", out) + self.assertIn("id", out) +``` + +- [ ] **Step 2: Run test, verify it fails** + +Run: `python -m unittest tests.unit.test_output -v` +Expected: FAIL, no module. + +- [ ] **Step 3: Write `firefly_cli/output.py`** + +```python +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> GPL-2.0-only +import json +import sys + +def unwrap(resp): + """Flatten Firefly's JSON:API envelope to plain id+attributes objects.""" + def flat(item): + out = {"id": item.get("id")} + out.update(item.get("attributes", {})) + return out + data = resp.get("data", resp) + if isinstance(data, list): + return [flat(i) for i in data] + if isinstance(data, dict) and "attributes" in data: + return flat(data) + return data + +def emit(data, human=False, stream=None): + stream = stream or sys.stdout + if not human: + json.dump(data, stream, indent=2, default=str) + stream.write("\n") + return + rows = data if isinstance(data, list) else [data] + if not rows: + stream.write("(no results)\n") + return + cols = list(rows[0].keys()) + widths = {c: max(len(c), *(len(str(r.get(c, ""))) for r in rows)) for c in cols} + stream.write(" ".join(c.ljust(widths[c]) for c in cols) + "\n") + for r in rows: + stream.write(" ".join(str(r.get(c, "")).ljust(widths[c]) for c in cols) + "\n") + +def emit_error(message, stream=None): + stream = stream or sys.stderr + json.dump({"error": message}, stream) + stream.write("\n") +``` + +- [ ] **Step 4: Run test, verify it passes** + +Run: `python -m unittest tests.unit.test_output -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add firefly_cli/output.py tests/unit/test_output.py +git commit -S -m "feat: output emit and envelope unwrap" +``` + +--- + +## Task 6: Registry + Context + +**Files:** +- Create: `firefly_cli/registry.py`, `firefly_cli/context.py` +- Test: `tests/unit/test_registry.py` + +- [ ] **Step 1: Write the failing test** + +```python +import unittest +from firefly_cli import registry + +class TestRegistry(unittest.TestCase): + def setUp(self): + registry._COMMANDS.clear() + + def test_command_decorator_registers(self): + @registry.command("tx add", help="add a tx") + def handler(args, ctx): + return 0 + cmds = registry.all_commands() + self.assertEqual(len(cmds), 1) + self.assertEqual(cmds[0].name, "tx add") + self.assertIs(cmds[0].handler, handler) + + def test_add_arguments_callback_stored(self): + def args_cb(p): + p.add_argument("name") + @registry.command("account get", args=args_cb) + def handler(args, ctx): + return 0 + self.assertIs(registry.all_commands()[0].args, args_cb) +``` + +- [ ] **Step 2: Run test, verify it fails** + +Run: `python -m unittest tests.unit.test_registry -v` +Expected: FAIL, no module. + +- [ ] **Step 3: Write `firefly_cli/registry.py`** + +```python +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> GPL-2.0-only +from dataclasses import dataclass +from typing import Callable, Optional + +@dataclass +class Command: + name: str # e.g. "tx add" + handler: Callable # fn(args, ctx) -> int + help: str = "" + args: Optional[Callable] = None # fn(argparse_subparser) -> None + +_COMMANDS = [] + +def command(name, help="", args=None): + def deco(fn): + _COMMANDS.append(Command(name=name, handler=fn, help=help, args=args)) + return fn + return deco + +def all_commands(): + return list(_COMMANDS) +``` + +- [ ] **Step 4: Write `firefly_cli/context.py`** + +```python +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> GPL-2.0-only +from dataclasses import dataclass + +@dataclass +class Context: + client: object # firefly_cli.client.Client + resolver: object # firefly_cli.resolver.Resolver + human: bool = False +``` + +- [ ] **Step 5: Run test, verify it passes** + +Run: `python -m unittest tests.unit.test_registry -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add firefly_cli/registry.py firefly_cli/context.py tests/unit/test_registry.py +git commit -S -m "feat: command registry and context" +``` + +--- + +## Task 7: Resolver (name -> id) + +**Files:** +- Create: `firefly_cli/resolver.py` +- Test: `tests/unit/test_resolver.py` + +- [ ] **Step 1: Write the failing test** + +```python +import unittest +from unittest.mock import MagicMock +from firefly_cli.resolver import Resolver +from firefly_cli.errors import ResolutionError + +def client_returning(items): + c = MagicMock() + c.request.return_value = { + "data": [ + {"id": i["id"], "type": "accounts", "attributes": i["attrs"]} + for i in items + ] + } + return c + +class TestResolver(unittest.TestCase): + def test_resolves_unique_account_name(self): + c = client_returning([ + {"id": "3", "attrs": {"name": "Checking", "type": "asset"}}, + {"id": "4", "attrs": {"name": "Savings", "type": "asset"}}, + ]) + r = Resolver(c) + acc = r.account("checking") # case-insensitive + self.assertEqual(acc["id"], "3") + self.assertEqual(acc["type"], "asset") + + def test_no_match_raises_with_candidates(self): + c = client_returning([{"id": "3", "attrs": {"name": "Checking", "type": "asset"}}]) + r = Resolver(c) + with self.assertRaises(ResolutionError) as ctx: + r.account("Nope") + self.assertIn("Checking", str(ctx.exception)) + + def test_ambiguous_match_raises(self): + c = client_returning([ + {"id": "3", "attrs": {"name": "Cash", "type": "asset"}}, + {"id": "9", "attrs": {"name": "Cash", "type": "asset"}}, + ]) + r = Resolver(c) + with self.assertRaises(ResolutionError) as ctx: + r.account("Cash") + self.assertIn("3", str(ctx.exception)) + self.assertIn("9", str(ctx.exception)) +``` + +- [ ] **Step 2: Run test, verify it fails** + +Run: `python -m unittest tests.unit.test_resolver -v` +Expected: FAIL, no module. + +- [ ] **Step 3: Write `firefly_cli/resolver.py`** + +```python +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> GPL-2.0-only +from firefly_cli.errors import ResolutionError + +class Resolver: + def __init__(self, client): + self.client = client + + def _list(self, path): + resp = self.client.request("GET", path, params={"limit": 1000}) + out = [] + for item in resp.get("data", []): + attrs = item.get("attributes", {}) + out.append({"id": item.get("id"), **attrs}) + return out + + def _match(self, kind, items, name): + hits = [i for i in items if str(i.get("name", "")).lower() == name.lower()] + if len(hits) == 1: + return hits[0] + names = ", ".join(f'{i.get("name")}(id={i.get("id")})' for i in items) + if not hits: + raise ResolutionError( + f'No {kind} named "{name}". Available: {names or "(none)"}') + raise ResolutionError( + f'Ambiguous {kind} "{name}" matches ids ' + + ", ".join(i["id"] for i in hits)) + + def account(self, name): + return self._match("account", self._list("/api/v1/accounts"), name) + + def tag(self, name): + return self._match("tag", self._list("/api/v1/tags"), name) + + def category(self, name): + return self._match("category", self._list("/api/v1/categories"), name) +``` + +- [ ] **Step 4: Run test, verify it passes** + +Run: `python -m unittest tests.unit.test_resolver -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add firefly_cli/resolver.py tests/unit/test_resolver.py +git commit -S -m "feat: name-to-id resolver with loud ambiguity errors" +``` + +--- + +## Task 8: account + category + tag + auth commands + +**Files:** +- Create: `firefly_cli/commands/__init__.py`, `firefly_cli/commands/account.py`, + `firefly_cli/commands/category.py`, `firefly_cli/commands/tag.py`, + `firefly_cli/commands/auth.py` +- Test: `tests/unit/test_commands_account.py` + +- [ ] **Step 1: Write the failing test** + +```python +import unittest +from unittest.mock import MagicMock +from firefly_cli.commands import account as acct +from firefly_cli.context import Context + +def make_ctx(): + client = MagicMock() + resolver = MagicMock() + return Context(client=client, resolver=resolver, human=False), client, resolver + +class TestAccountCmd(unittest.TestCase): + def test_list_passes_type_filter(self): + ctx, client, _ = make_ctx() + client.request.return_value = {"data": []} + args = MagicMock(type="asset") + acct.cmd_list(args, ctx) + client.request.assert_called_once_with( + "GET", "/api/v1/accounts", params={"type": "asset"}) + + def test_balance_resolves_name_and_returns_balance(self): + ctx, client, resolver = make_ctx() + resolver.account.return_value = {"id": "3", "name": "Checking", + "current_balance": "100.00"} + args = MagicMock(account="Checking") + rc = acct.cmd_balance(args, ctx) + resolver.account.assert_called_once_with("Checking") + self.assertEqual(rc, 0) +``` + +- [ ] **Step 2: Run test, verify it fails** + +Run: `python -m unittest tests.unit.test_commands_account -v` +Expected: FAIL, no module. + +- [ ] **Step 3: Write `firefly_cli/commands/account.py`** + +```python +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> GPL-2.0-only +from firefly_cli import registry, output + +def _list_args(p): + p.add_argument("--type", help="filter: asset, expense, revenue, liability, ...") + +@registry.command("account list", help="list accounts", args=_list_args) +def cmd_list(args, ctx): + params = {"type": args.type} if getattr(args, "type", None) else None + resp = ctx.client.request("GET", "/api/v1/accounts", params=params) + output.emit(output.unwrap(resp), human=ctx.human) + return 0 + +def _name_arg(p): + p.add_argument("account", help="account name or id") + +@registry.command("account get", help="show one account", args=_name_arg) +def cmd_get(args, ctx): + acc = ctx.resolver.account(args.account) + output.emit(acc, human=ctx.human) + return 0 + +@registry.command("account balance", help="show account balance", args=_name_arg) +def cmd_balance(args, ctx): + acc = ctx.resolver.account(args.account) + output.emit({"id": acc["id"], "name": acc.get("name"), + "current_balance": acc.get("current_balance")}, human=ctx.human) + return 0 +``` + +- [ ] **Step 4: Write `firefly_cli/commands/category.py`** + +```python +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> GPL-2.0-only +from firefly_cli import registry, output + +@registry.command("category list", help="list categories") +def cmd_list(args, ctx): + resp = ctx.client.request("GET", "/api/v1/categories") + output.emit(output.unwrap(resp), human=ctx.human) + return 0 +``` + +- [ ] **Step 5: Write `firefly_cli/commands/tag.py`** + +```python +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> GPL-2.0-only +from firefly_cli import registry, output + +@registry.command("tag list", help="list tags") +def cmd_list(args, ctx): + resp = ctx.client.request("GET", "/api/v1/tags") + output.emit(output.unwrap(resp), human=ctx.human) + return 0 +``` + +- [ ] **Step 6: Write `firefly_cli/commands/auth.py`** + +```python +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> GPL-2.0-only +import getpass +from firefly_cli import registry, output, config + +def _set_args(p): + p.add_argument("--url", help="Firefly III base URL") + p.add_argument("--token", help="Personal Access Token") + +@registry.command("auth set", help="write url+token to config", args=_set_args) +def cmd_set(args, ctx): + url = args.url or input("Firefly III URL: ").strip() + token = args.token or getpass.getpass("Personal Access Token: ").strip() + path = config.write(url, token) + output.emit({"written": str(path)}, human=ctx.human) + return 0 + +@registry.command("auth test", help="verify connectivity and token") +def cmd_test(args, ctx): + resp = ctx.client.request("GET", "/api/v1/about") + output.emit(resp.get("data", resp), human=ctx.human) + return 0 +``` + +- [ ] **Step 7: Write `firefly_cli/commands/__init__.py`** (import to self-register) + +```python +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> GPL-2.0-only +# Importing each module runs its @registry.command decorators. +from firefly_cli.commands import auth, account, category, tag, transaction # noqa: F401 +``` + +Note: `transaction` is created in Task 9. Until then, temporarily drop +`transaction` from this import OR create Task 9 first. Executor: do Task 9 +before running the full CLI in Task 10; this unit test imports only `account`. + +- [ ] **Step 8: Run test, verify it passes** + +Run: `python -m unittest tests.unit.test_commands_account -v` +Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add firefly_cli/commands/ tests/unit/test_commands_account.py +git commit -S -m "feat: account, category, tag, auth commands" +``` + +--- + +## Task 9: transaction commands (add/list/get/search) + +**Files:** +- Create: `firefly_cli/commands/transaction.py` +- Test: `tests/unit/test_commands_transaction.py` + +- [ ] **Step 1: Write the failing test** + +```python +import unittest +from unittest.mock import MagicMock +from firefly_cli.commands import transaction as tx +from firefly_cli.context import Context + +def make_ctx(): + client = MagicMock() + resolver = MagicMock() + return Context(client=client, resolver=resolver, human=False), client, resolver + +class TestTxAdd(unittest.TestCase): + def test_infers_withdrawal_from_asset_to_expense(self): + ctx, client, resolver = make_ctx() + resolver.account.side_effect = lambda n: { + "Checking": {"id": "1", "name": "Checking", "type": "asset"}, + "Groceries": {"id": "2", "name": "Groceries", "type": "expense"}, + }[n] + client.request.return_value = {"data": {"id": "55", "type": "transactions", + "attributes": {}}} + args = MagicMock(amount="42.50", source="Checking", dest="Groceries", + desc="food", date="2026-06-30", category=None, + tags=None, type=None) + rc = tx.cmd_add(args, ctx) + self.assertEqual(rc, 0) + method, path = client.request.call_args[0][:2] + body = client.request.call_args[1]["body"] + split = body["transactions"][0] + self.assertEqual((method, path), ("POST", "/api/v1/transactions")) + self.assertEqual(split["type"], "withdrawal") + self.assertEqual(split["source_id"], "1") + self.assertEqual(split["destination_id"], "2") + self.assertEqual(split["amount"], "42.50") + + def test_infers_deposit_revenue_to_asset(self): + ctx, client, resolver = make_ctx() + resolver.account.side_effect = lambda n: { + "Salary": {"id": "7", "name": "Salary", "type": "revenue"}, + "Checking": {"id": "1", "name": "Checking", "type": "asset"}, + }[n] + client.request.return_value = {"data": {"id": "1", "attributes": {}}} + args = MagicMock(amount="1000", source="Salary", dest="Checking", + desc="pay", date=None, category=None, tags=None, type=None) + tx.cmd_add(args, ctx) + self.assertEqual(client.request.call_args[1]["body"]["transactions"][0]["type"], + "deposit") + + def test_explicit_type_overrides_inference(self): + ctx, client, resolver = make_ctx() + resolver.account.side_effect = lambda n: {"id": "1", "type": "asset", "name": n} + client.request.return_value = {"data": {"id": "1", "attributes": {}}} + args = MagicMock(amount="5", source="A", dest="B", desc=None, date=None, + category=None, tags="food,fun", type="transfer") + tx.cmd_add(args, ctx) + split = client.request.call_args[1]["body"]["transactions"][0] + self.assertEqual(split["type"], "transfer") + self.assertEqual(split["tags"], ["food", "fun"]) + +class TestTxList(unittest.TestCase): + def test_list_passes_date_params(self): + ctx, client, _ = make_ctx() + client.request.return_value = {"data": []} + args = MagicMock(since="2026-06-01", until="2026-06-30", + account=None, limit=10) + tx.cmd_list(args, ctx) + params = client.request.call_args[1]["params"] + self.assertEqual(params["start"], "2026-06-01") + self.assertEqual(params["end"], "2026-06-30") + self.assertEqual(params["limit"], 10) +``` + +- [ ] **Step 2: Run test, verify it fails** + +Run: `python -m unittest tests.unit.test_commands_transaction -v` +Expected: FAIL, no module. + +- [ ] **Step 3: Write `firefly_cli/commands/transaction.py`** + +```python +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> GPL-2.0-only +from firefly_cli import registry, output + +# Inference table keyed by (source_type, destination_type) -> firefly tx type. +def _infer_type(src_type, dst_type): + s, d = (src_type or "").lower(), (dst_type or "").lower() + if s == "asset" and d == "asset": + return "transfer" + if s in ("revenue",) and d == "asset": + return "deposit" + if s == "asset" and d in ("expense",): + return "withdrawal" + # Fallback: asset source -> withdrawal, asset dest -> deposit. + if s == "asset": + return "withdrawal" + if d == "asset": + return "deposit" + raise ValueError( + f"Cannot infer transaction type from {src_type!r}->{dst_type!r}; " + "pass --type withdrawal|deposit|transfer.") + +def _add_args(p): + p.add_argument("amount") + p.add_argument("--from", dest="source", required=True, help="source account") + p.add_argument("--to", dest="dest", required=True, help="destination account") + p.add_argument("--desc", default=None) + p.add_argument("--date", default=None, help="YYYY-MM-DD (default today)") + p.add_argument("--category", default=None) + p.add_argument("--tags", default=None, help="comma-separated") + p.add_argument("--type", default=None, + help="withdrawal|deposit|transfer (overrides inference)") + +@registry.command("tx add", help="record a transaction", args=_add_args) +def cmd_add(args, ctx): + src = ctx.resolver.account(args.source) + dst = ctx.resolver.account(args.dest) + ttype = args.type or _infer_type(src.get("type"), dst.get("type")) + from datetime import date as _date + split = { + "type": ttype, + "date": args.date or _date.today().isoformat(), + "amount": str(args.amount), + "description": args.desc or "", + "source_id": src["id"], + "destination_id": dst["id"], + } + if args.category: + split["category_name"] = ctx.resolver.category(args.category).get("name", args.category) + if args.tags: + split["tags"] = [t.strip() for t in args.tags.split(",") if t.strip()] + resp = ctx.client.request("POST", "/api/v1/transactions", + body={"transactions": [split]}) + output.emit(output.unwrap(resp), human=ctx.human) + return 0 + +def _list_args(p): + p.add_argument("--since", default=None, help="start date YYYY-MM-DD") + p.add_argument("--until", default=None, help="end date YYYY-MM-DD") + p.add_argument("--account", default=None, help="filter by account name") + p.add_argument("--limit", type=int, default=20) + +@registry.command("tx list", help="list transactions", args=_list_args) +def cmd_list(args, ctx): + if args.account: + acc = ctx.resolver.account(args.account) + path = f"/api/v1/accounts/{acc['id']}/transactions" + else: + path = "/api/v1/transactions" + params = {"limit": args.limit} + if args.since: + params["start"] = args.since + if args.until: + params["end"] = args.until + resp = ctx.client.request("GET", path, params=params) + output.emit(output.unwrap(resp), human=ctx.human) + return 0 + +def _id_arg(p): + p.add_argument("id") + +@registry.command("tx get", help="show one transaction", args=_id_arg) +def cmd_get(args, ctx): + resp = ctx.client.request("GET", f"/api/v1/transactions/{args.id}") + output.emit(output.unwrap(resp), human=ctx.human) + return 0 + +def _query_arg(p): + p.add_argument("query") + +@registry.command("tx search", help="search transactions", args=_query_arg) +def cmd_search(args, ctx): + resp = ctx.client.request("GET", "/api/v1/search/transactions", + params={"query": args.query}) + output.emit(output.unwrap(resp), human=ctx.human) + return 0 +``` + +- [ ] **Step 4: Run test, verify it passes** + +Run: `python -m unittest tests.unit.test_commands_transaction -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: transaction add/list/get/search with type inference" +``` + +--- + +## Task 10: CLI wiring (argparse from registry, error handling) + +**Files:** +- Create: `firefly_cli/cli.py` +- Test: `tests/unit/test_cli.py` + +- [ ] **Step 1: Write the failing test** + +```python +import unittest +from unittest.mock import patch, MagicMock +from firefly_cli import cli + +class TestCli(unittest.TestCase): + @patch("firefly_cli.cli.config.load") + @patch("firefly_cli.cli.Client") + def test_dispatches_account_list(self, Client, load): + load.return_value = {"url": "https://f", "token": "t"} + Client.return_value.request.return_value = {"data": []} + rc = cli.main(["account", "list"]) + self.assertEqual(rc, 0) + + @patch("firefly_cli.cli.config.load") + def test_config_error_returns_nonzero(self, load): + from firefly_cli.errors import ConfigError + load.side_effect = ConfigError("no config") + rc = cli.main(["account", "list"]) + self.assertEqual(rc, 1) + + def test_auth_set_does_not_require_config(self): + # auth set must run even with no config/client + with patch("firefly_cli.cli.config.write") as w: + w.return_value = "/tmp/x" + rc = cli.main(["auth", "set", "--url", "https://f", "--token", "t"]) + self.assertEqual(rc, 0) +``` + +- [ ] **Step 2: Run test, verify it fails** + +Run: `python -m unittest tests.unit.test_cli -v` +Expected: FAIL, no module. + +- [ ] **Step 3: Write `firefly_cli/cli.py`** + +```python +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> GPL-2.0-only +import argparse +import sys +from firefly_cli import config, registry, output +from firefly_cli.client import Client +from firefly_cli.resolver import Resolver +from firefly_cli.context import Context +from firefly_cli.errors import FireflyError +import firefly_cli.commands # noqa: F401 triggers registration + +# Commands that must work without a configured client. +_NO_CLIENT = {"auth set"} + +def _build_parser(): + parser = argparse.ArgumentParser(prog="firefly", + description="CLI for Firefly III") + parser.add_argument("--human", action="store_true", + help="human-readable tables instead of JSON") + parser.add_argument("--url", help="override base URL for this call") + parser.add_argument("--token", help="override token for this call") + sub = parser.add_subparsers(dest="_group", required=True) + + # Group "tx add" / "account list" into nested subparsers. + groups = {} + for cmd in registry.all_commands(): + group, _, leaf = cmd.name.partition(" ") + if group not in groups: + gp = sub.add_parser(group) + groups[group] = gp.add_subparsers(dest="_leaf", required=True) + lp = groups[group].add_parser(leaf, help=cmd.help) + if cmd.args: + cmd.args(lp) + lp.set_defaults(_handler=cmd.handler, _cmdname=cmd.name) + return parser + +def main(argv=None): + argv = sys.argv[1:] if argv is None else argv + parser = _build_parser() + args = parser.parse_args(argv) + try: + if args._cmdname in _NO_CLIENT: + ctx = Context(client=None, resolver=None, human=args.human) + else: + cfg = config.load() + url = args.url or cfg["url"] + token = args.token or cfg["token"] + client = Client(url, token) + ctx = Context(client=client, resolver=Resolver(client), + human=args.human) + return args._handler(args, ctx) + except FireflyError as e: + output.emit_error(str(e)) + return 1 + +if __name__ == "__main__": + raise SystemExit(main()) +``` + +- [ ] **Step 4: Run test, verify it passes** + +Run: `python -m unittest tests.unit.test_cli -v` +Expected: PASS. + +- [ ] **Step 5: Run the whole unit suite and the CLI help** + +Run: +```bash +python -m unittest discover -s tests/unit -v +python -m firefly_cli --help +python -m firefly_cli tx add --help +``` +Expected: all tests PASS; help shows groups and the `tx add` flags. + +- [ ] **Step 6: Commit** + +```bash +git add firefly_cli/cli.py tests/unit/test_cli.py +git commit -S -m "feat: CLI wiring from command registry" +``` + +--- + +## Task 11: Integration tests (gated, live test account, self-cleaning) + +**Files:** +- Create: `tests/integration/__init__.py`, `tests/integration/test_live.py` + +- [ ] **Step 1: Write the gated integration test** + +```python +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> GPL-2.0-only +import os, unittest +from firefly_cli.client import Client +from firefly_cli.resolver import Resolver +from firefly_cli.output import unwrap + +URL = os.environ.get("FIREFLY_TEST_URL") +TOKEN = os.environ.get("FIREFLY_TEST_TOKEN") + +@unittest.skipUnless(URL and TOKEN, + "set FIREFLY_TEST_URL and FIREFLY_TEST_TOKEN to run live tests") +class TestLive(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.client = Client(URL, TOKEN) + cls.resolver = Resolver(cls.client) + + def test_about_shape(self): + resp = self.client.request("GET", "/api/v1/about") + self.assertIn("version", resp["data"]) + + def test_accounts_envelope_and_balance_field(self): + resp = self.client.request("GET", "/api/v1/accounts", + params={"type": "asset", "limit": 5}) + self.assertIn("data", resp) + accs = unwrap(resp) + if accs: + # Confirm the balance field name assumed by the design. + self.assertIn("current_balance", accs[0], + "balance field name differs; update account balance cmd + unit test") + + def test_create_then_delete_transaction(self): + # Needs two asset accounts (or an asset + expense) on the TEST account. + accs = unwrap(self.client.request("GET", "/api/v1/accounts", + params={"type": "asset", "limit": 2})) + if len(accs) < 1: + self.skipTest("test account has no asset account") + exp = unwrap(self.client.request("GET", "/api/v1/accounts", + params={"type": "expense", "limit": 1})) + if not exp: + self.skipTest("test account has no expense account") + body = {"transactions": [{ + "type": "withdrawal", + "date": "2026-06-30", + "amount": "0.01", + "description": "firefly-cli integration test", + "source_id": accs[0]["id"], + "destination_id": exp[0]["id"], + }]} + created = self.client.request("POST", "/api/v1/transactions", body=body) + tx_id = created["data"]["id"] + try: + got = self.client.request("GET", f"/api/v1/transactions/{tx_id}") + self.assertEqual(got["data"]["id"], tx_id) + finally: + self.client.request("DELETE", f"/api/v1/transactions/{tx_id}") +``` + +- [ ] **Step 2: Run gated tests with no env (must skip)** + +Run: `python -m unittest discover -s tests/integration -v` +Expected: tests SKIPPED (no env vars set). + +- [ ] **Step 3: Run against the live test account** + +Ask the user to provide test-account creds, then: +```bash +FIREFLY_TEST_URL=<url> FIREFLY_TEST_TOKEN=<tok> \ + python -m unittest discover -s tests/integration -v +``` +Expected: PASS. If `current_balance` or the envelope differs, fix the relevant +production code + its unit test, then re-run. + +- [ ] **Step 4: Commit** + +```bash +git add tests/integration/ +git commit -S -m "test: gated live integration tests, self-cleaning" +``` + +--- + +## Task 12: Final pass, docs, push + +- [ ] **Step 1: Full suite + editable install smoke test** + +```bash +python -m unittest discover -s tests -v +pip install -e . +firefly --help +``` +Expected: tests PASS; `firefly` entry point works. + +- [ ] **Step 2: Update README** with the final verified command list and a + short "for agents" note (JSON default, exit codes, name resolution behavior). + +- [ ] **Step 3: Commit and push** + +```bash +git add README.md +git commit -S -m "docs: finalize README command reference" +git push +``` +Expected: pushed to `origin` (danix_git:firefly-cli). Verify signed: +`git log --format='%h %G? %s' | head` (want `G`). + +--- + +## Appendix A: CLAUDE.md content (copy verbatim in Task 1, Step 7) + +```markdown +# firefly-cli + +CLI tool letting an LLM agent (and the user) interact with a Firefly III +instance over its REST API. Python package, command `firefly`. + +## Reference, do not modify +The Firefly III source is cloned at `../GITHUB/firefly-iii/` for reference only +(API shapes, transformers, route definitions). NEVER write to it. + +## Architecture +- `firefly_cli/` package. Shared primitives: `config.py` (env over TOML file), + `client.py` (HTTP + auth + error surfacing), `resolver.py` (name->id), + `output.py` (JSON default, `--human` tables), `registry.py` + `context.py`. +- Commands live in `firefly_cli/commands/`, one module per group. Each command + registers via the `@registry.command("group leaf", ...)` decorator. +- `cli.py` builds argparse subparsers from the registry. `commands/__init__.py` + imports every command module so they self-register. + +## Adding a command (the expandability rule) +1. Add or edit a module in `firefly_cli/commands/`. +2. Decorate the handler: `@registry.command("budget status", help=..., args=fn)`. + `args` is `fn(subparser)` adding argparse arguments; handler is + `fn(args, ctx) -> int`. `ctx` has `.client`, `.resolver`, `.human`. +3. If it is a new module, add it to the import line in `commands/__init__.py`. +4. Write a unit test under `tests/unit/` (mock `ctx.client` / `ctx.resolver`). +No other files change. + +## Conventions +- Python 3.11+, standard library only. No third-party runtime deps. +- Output JSON by default (agent-first); `--human` for tables. +- Name args resolve to IDs; ambiguous or missing names are HARD errors listing + candidates. Never silently guess an account (real money). +- All errors are `firefly_cli.errors.FireflyError` subclasses; `cli.main` + catches them, prints `{"error": ...}` to stderr, returns exit code 1. + +## Testing +- `python -m unittest discover -s tests/unit` — mocked, always run. TDD. +- Integration tests in `tests/integration/` hit a LIVE TEST ACCOUNT, gated by + `FIREFLY_TEST_URL` / `FIREFLY_TEST_TOKEN`, skipped otherwise. Write tests + create-then-delete their own records. NEVER point these at real data. + +## License +GPLv2-only. Per-file header: `Copyright (C) 2026 Danilo M. <danix@danix.xyz>`. +``` +``` |
