diff --git a/.gitignore b/.gitignore index 47ca0e880..2a738436f 100644 --- a/.gitignore +++ b/.gitignore @@ -99,6 +99,7 @@ !/n8n/ !/obsidian/ !/unrealinsights/ +!/firefly-iii/ !/nsight-graphics/ !/lldb/ @@ -238,6 +239,7 @@ !/safari/ !/safari/agent-harness/ !/unrealinsights/agent-harness/ +!/firefly-iii/agent-harness/ !/nsight-graphics/agent-harness/ !/lldb/agent-harness/ diff --git a/firefly-iii/agent-harness/README.md b/firefly-iii/agent-harness/README.md new file mode 100644 index 000000000..817f49dfa --- /dev/null +++ b/firefly-iii/agent-harness/README.md @@ -0,0 +1,174 @@ +# Firefly III CLI + +Firefly III command-line interface based on CLI-Anything specification. Converts MCP mode to stateless CLI mode to avoid Node residual process issues. + +## Installation + +```bash +pip install cli-anything-firefly-iii +``` + +## Prerequisites + +- Python 3.10+ +- Running Firefly III instance +- Personal Access Token (PAT) + +## Configuration + +### Environment Variables (Recommended) + +```bash +export FIREFLY_III_BASE_URL="https://firefly.yourdomain.com" +export FIREFLY_III_PAT="your-personal-access-token" +``` + +### Command Line Arguments + +```bash +cli-anything-firefly-iii --base-url https://firefly.yourdomain.com --pat your-token +``` + +## Usage + +### REPL Mode + +```bash +cli-anything-firefly-iii +``` + +### Subcommand Mode + +```bash +# Account management +cli-anything-firefly-iii accounts list +cli-anything-firefly-iii accounts list --type asset +cli-anything-firefly-iii accounts get --id 123 +cli-anything-firefly-iii accounts create --name "Cash" --type asset --currency-code USD + +# Transaction management +cli-anything-firefly-iii transactions list +cli-anything-firefly-iii transactions list --limit 10 --start 2024-01-01 +cli-anything-firefly-iii transactions create --description "Grocery" --amount 50.00 --source-account 1 +cli-anything-firefly-iii transactions get --id 456 + +# Budget management +cli-anything-firefly-iii budgets list + +# Category management +cli-anything-firefly-iii categories list + +# Tag management +cli-anything-firefly-iii tags list + +# Bill management +cli-anything-firefly-iii bills list + +# Piggy banks +cli-anything-firefly-iii piggy-banks list + +# Insights and reports +cli-anything-firefly-iii insights expense --start 2024-01-01 --end 2024-01-31 +cli-anything-firefly-iii insights income --start 2024-01-01 --end 2024-01-31 + +# Search +cli-anything-firefly-iii search transactions --query "grocery" + +# Data export +cli-anything-firefly-iii export transactions --start 2024-01-01 --end 2024-01-31 + +# System information +cli-anything-firefly-iii info about +cli-anything-firefly-iii info status +``` + +### JSON Output + +All commands support `--json` flag for structured output: + +```bash +cli-anything-firefly-iii --json accounts list +``` + +### Preset Filtering + +Use `--preset` parameter to filter available commands: + +```bash +# Default preset (core features) +cli-anything-firefly-iii --preset default accounts list + +# Full preset (all features) +cli-anything-firefly-iii --preset full accounts list + +# Budget preset +cli-anything-firefly-iii --preset budget budgets list + +# Reporting preset +cli-anything-firefly-iii --preset reporting insights expense --start 2024-01-01 --end 2024-01-31 +``` + +Available presets: +- `default`: Core features (accounts, transactions, categories, tags, bills, search) +- `full`: All features +- `basic`: Basic features (accounts, transactions, categories, tags, search) +- `budget`: Budget-related (accounts, budgets, transactions, summary, insight) +- `reporting`: Reporting-related (accounts, transactions, categories, insight, summary, search) +- `admin`: Admin features (about, configuration, currencies, users, preferences) +- `automation`: Automation (rules, recurrences, webhooks, transactions) + +## Comparison with MCP Version + +| Feature | MCP Version | CLI-Anything Version | +|------|----------|-------------------| +| Process Lifecycle | Long-running | Single call, immediate exit | +| Memory Usage | Continuous | On-demand, released after | +| Communication | Stdio/SSE | Command args + stdout | +| State Management | Stateful | Stateless | +| Preset Filtering | Supported | Supported | +| JSON Output | Built-in | `--json` flag | + +## Troubleshooting + +### Connection Failed + +``` +Error: Cannot connect to Firefly III instance: https://firefly.yourdomain.com +``` + +Check: +1. Is Firefly III instance running +2. Is base URL correct +3. Is network connection normal + +### Authentication Failed + +``` +Error: Authentication failed: Personal Access Token is invalid +``` + +Check: +1. Is PAT correct +2. Has PAT expired +3. Generate new PAT in Firefly III Options > Profile > OAuth + +## Development + +```bash +# Clone repository +git clone https://github.com/HKUDS/CLI-Anything.git +cd CLI-Anything/firefly-iii/agent-harness + +# Install dependencies +pip install -e ".[dev]" + +# Run tests +pytest + +# Code formatting +black cli_anything/ +``` + +## License + +MIT License diff --git a/firefly-iii/agent-harness/cli_anything/firefly_iii/README.md b/firefly-iii/agent-harness/cli_anything/firefly_iii/README.md new file mode 100644 index 000000000..2517567d6 --- /dev/null +++ b/firefly-iii/agent-harness/cli_anything/firefly_iii/README.md @@ -0,0 +1,174 @@ +# Firefly III CLI + +Firefly III command-line interface based on CLI-Anything specification. Converts MCP mode to stateless CLI mode to avoid Node residual process issues. + +## Installation + +```bash +pip install cli-anything-firefly-iii +``` + +## Prerequisites + +- Python 3.10+ +- Running Firefly III instance +- Personal Access Token (PAT) + +## Configuration + +### Environment Variables (Recommended) + +```bash +export FIREFLY_III_BASE_URL="https://firefly.yourdomain.com" +export FIREFLY_III_PAT="your-personal-access-token" +``` + +### Command Line Arguments + +```bash +cli-anything-firefly-iii --base-url https://firefly.yourdomain.com --pat your-token +``` + +## Usage + +### REPL Mode + +```bash +cli-anything-firefly-iii +``` + +### Subcommand Mode + +```bash +# Account management +cli-anything-firefly-iii accounts list +cli-anything-firefly-iii accounts list --type asset +cli-anything-firefly-iii accounts get --id 123 +cli-anything-firefly-iii accounts create --name "Cash" --type asset --currency-code USD + +# Transaction management +cli-anything-firefly-iii transactions list +cli-anything-firefly-iii transactions list --limit 10 --start 2024-01-01 +cli-anything-firefly-iii transactions create --description "Grocery" --amount 50.00 --source-account 1 +cli-anything-firefly-iii transactions get --id 456 + +# Budget management +cli-anything-firefly-iii budgets list + +# Category management +cli-anything-firefly-iii categories list + +# Tag management +cli-anything-firefly-iii tags list + +# Bill management +cli-anything-firefly-iii bills list + +# Piggy banks +cli-anything-firefly-iii piggy-banks list + +# Insights and reports +cli-anything-firefly-iii insights expense --start 2024-01-01 --end 2024-01-31 +cli-anything-firefly-iii insights income --start 2024-01-01 --end 2024-01-31 + +# Search +cli-anything-firefly-iii search transactions --query "grocery" + +# Data export +cli-anything-firefly-iii export transactions --start 2024-01-01 --end 2024-01-31 + +# System information +cli-anything-firefly-iii info about +cli-anything-firefly-iii info status +``` + +### JSON Output + +All commands support `--json` flag for structured output: + +```bash +cli-anything-firefly-iii --json accounts list +``` + +### Preset Filtering + +Use `--preset` parameter to filter available commands: + +```bash +# Default preset (core features) +cli-anything-firefly-iii --preset default accounts list + +# Full preset (all features) +cli-anything-firefly-iii --preset full accounts list + +# Budget preset +cli-anything-firefly-iii --preset budget budgets list + +# Reporting preset +cli-anything-firefly-iii --preset reporting insights expense --start 2024-01-01 --end 2024-01-31 +``` + +Available presets: +- `default`: Core features (accounts, transactions, categories, tags, bills, search) +- `full`: All features +- `basic`: Basic features (accounts, transactions, categories, tags, search) +- `budget`: Budget-related (accounts, budgets, transactions, summary, insight) +- `reporting`: Reporting-related (accounts, transactions, categories, insight, summary, search) +- `admin`: Admin features (about, configuration, currencies, users, preferences) +- `automation`: Automation (rules, recurrences, webhooks, transactions) + +## Comparison with MCP Version + +| Feature | MCP Version | CLI-Anything Version | +|---------|------------|---------------------| +| Process Lifecycle | Long-running | Single call, immediate exit | +| Memory Usage | Continuous | On-demand, released after | +| Communication | Stdio/SSE | Command args + stdout | +| State Management | Stateful | Stateless | +| Preset Filtering | Supported | Supported | +| JSON Output | Built-in | `--json` flag | + +## Troubleshooting + +### Connection Failed + +``` +Error: Cannot connect to Firefly III instance: https://firefly.yourdomain.com +``` + +Check: +1. Is Firefly III instance running +2. Is base URL correct +3. Is network connection normal + +### Authentication Failed + +``` +Error: Authentication failed: Personal Access Token is invalid +``` + +Check: +1. Is PAT correct +2. Has PAT expired +3. Generate new PAT in Firefly III Options > Profile > OAuth + +## Development + +```bash +# Clone repository +git clone https://github.com/HKUDS/CLI-Anything.git +cd CLI-Anything/firefly-iii/agent-harness + +# Install dependencies +pip install -e ".[dev]" + +# Run tests +pytest + +# Code formatting +black cli_anything/ +``` + +## License + +MIT License diff --git a/firefly-iii/agent-harness/cli_anything/firefly_iii/__init__.py b/firefly-iii/agent-harness/cli_anything/firefly_iii/__init__.py new file mode 100644 index 000000000..3135558c8 --- /dev/null +++ b/firefly-iii/agent-harness/cli_anything/firefly_iii/__init__.py @@ -0,0 +1,6 @@ +r""" +Firefly III CLI package initialization +""" + +__version__ = "1.0.0" +__author__ = "CLI-Anything" diff --git a/firefly-iii/agent-harness/cli_anything/firefly_iii/__main__.py b/firefly-iii/agent-harness/cli_anything/firefly_iii/__main__.py new file mode 100644 index 000000000..55e985254 --- /dev/null +++ b/firefly-iii/agent-harness/cli_anything/firefly_iii/__main__.py @@ -0,0 +1,10 @@ +r""" +Firefly III CLI entry point + +Allows running via `python -m cli_anything.firefly_iii` +""" + +from .firefly_iii_cli import main + +if __name__ == '__main__': + main() diff --git a/firefly-iii/agent-harness/cli_anything/firefly_iii/core/__init__.py b/firefly-iii/agent-harness/cli_anything/firefly_iii/core/__init__.py new file mode 100644 index 000000000..5267c1b76 --- /dev/null +++ b/firefly-iii/agent-harness/cli_anything/firefly_iii/core/__init__.py @@ -0,0 +1,3 @@ +r""" +Core functionality package +""" diff --git a/firefly-iii/agent-harness/cli_anything/firefly_iii/core/accounts.py b/firefly-iii/agent-harness/cli_anything/firefly_iii/core/accounts.py new file mode 100644 index 000000000..15077e131 --- /dev/null +++ b/firefly-iii/agent-harness/cli_anything/firefly_iii/core/accounts.py @@ -0,0 +1,113 @@ +r""" +Account management command group +""" + +import click +from ..firefly_iii_cli import get_backend, output + + +@click.group() +def accounts(): + """Manage accounts""" + pass + + +@accounts.command(name="list") +@click.option("--type", + type=click.Choice(['asset', 'expense', 'revenue', 'liability', 'all']), + default='all', + help="Filter by account type") +@click.option("--limit", default=50, help="Limit results") +@click.option("--page", default=1, help="Page number") +def accounts_list(type, limit, page): + """List all accounts""" + backend = get_backend() + params = {"limit": limit, "page": page} + if type != 'all': + params["type"] = type + + result = backend.get_accounts(params) + output(result) + + +@accounts.command(name="get") +@click.option("--id", required=True, type=int, help="Account ID") +def accounts_get(id): + """Get account details""" + backend = get_backend() + result = backend.get_account(id) + output(result) + + +@accounts.command(name="create") +@click.option("--name", required=True, help="Account name") +@click.option("--type", + required=True, + type=click.Choice(['asset', 'expense', 'revenue', 'liability']), + help="Account type") +@click.option("--currency-code", default="USD", help="Currency code (ISO 4217)") +@click.option("--opening-balance", default="0", help="Opening balance") +@click.option("--account-role", help="Account role (for asset accounts)") +@click.option("--iban", help="IBAN") +@click.option("--bic", help="BIC") +@click.option("--account-number", help="Account number") +@click.option("--notes", help="Notes") +def accounts_create(name, type, currency_code, opening_balance, account_role, iban, bic, account_number, notes): + """Create a new account""" + backend = get_backend() + + data = { + "name": name, + "type": type, + "currency_code": currency_code, + "opening_balance": opening_balance, + } + + if account_role: + data["account_role"] = account_role + if iban: + data["iban"] = iban + if bic: + data["bic"] = bic + if account_number: + data["account_number"] = account_number + if notes: + data["notes"] = notes + + result = backend.create_account(data) + output(result) + + +@accounts.command(name="update") +@click.option("--id", required=True, type=int, help="Account ID") +@click.option("--name", help="Account name") +@click.option("--opening-balance", help="Opening balance") +@click.option("--notes", help="Notes") +def accounts_update(id, name, opening_balance, notes): + """Update an existing account""" + backend = get_backend() + + data = {} + if name: + data["name"] = name + if opening_balance: + data["opening_balance"] = opening_balance + if notes: + data["notes"] = notes + + if not data: + click.echo("Error: At least one update field is required", err=True) + return + + result = backend.update_account(id, data) + output(result) + + +@accounts.command(name="delete") +@click.option("--id", required=True, type=int, help="Account ID") +@click.confirmation_option(prompt="Are you sure you want to delete this account?") +def accounts_delete(id): + """Delete an account""" + backend = get_backend() + result = backend.delete_account(id) + output(result) diff --git a/firefly-iii/agent-harness/cli_anything/firefly_iii/core/autocomplete.py b/firefly-iii/agent-harness/cli_anything/firefly_iii/core/autocomplete.py new file mode 100644 index 000000000..82d691b32 --- /dev/null +++ b/firefly-iii/agent-harness/cli_anything/firefly_iii/core/autocomplete.py @@ -0,0 +1,215 @@ +r""" +Autocomplete command group + +Provides quick autocomplete suggestions for various Firefly III entities. +""" + +import click +from ..firefly_iii_cli import get_backend, output + + +@click.group() +def autocomplete(): + """Autocomplete suggestions""" + pass + + +@autocomplete.command(name="accounts") +@click.option("--query", help="Search query") +@click.option("--limit", default=10, help="Number of results") +@click.option("--date", help="Date for balance (YYYY-MM-DD)") +@click.option("--types", help="Account types (comma-separated: asset,expense,revenue,liability)") +def autocomplete_accounts(query, limit, date, types): + """Autocomplete accounts""" + backend = get_backend() + params = {"limit": limit} + + if query: + params["query"] = query + if date: + params["date"] = date + if types: + params["types"] = types + + result = backend.autocomplete_accounts(params) + output(result) + + +@autocomplete.command(name="bills") +@click.option("--query", help="Search query") +@click.option("--limit", default=10, help="Number of results") +def autocomplete_bills(query, limit): + """Autocomplete bills""" + backend = get_backend() + params = {"limit": limit} + + if query: + params["query"] = query + + result = backend.autocomplete_bills(params) + output(result) + + +@autocomplete.command(name="budgets") +@click.option("--query", help="Search query") +@click.option("--limit", default=10, help="Number of results") +def autocomplete_budgets(query, limit): + """Autocomplete budgets""" + backend = get_backend() + params = {"limit": limit} + + if query: + params["query"] = query + + result = backend.autocomplete_budgets(params) + output(result) + + +@autocomplete.command(name="categories") +@click.option("--query", help="Search query") +@click.option("--limit", default=10, help="Number of results") +def autocomplete_categories(query, limit): + """Autocomplete categories""" + backend = get_backend() + params = {"limit": limit} + + if query: + params["query"] = query + + result = backend.autocomplete_categories(params) + output(result) + + +@autocomplete.command(name="currencies") +@click.option("--query", help="Search query") +@click.option("--limit", default=10, help="Number of results") +def autocomplete_currencies(query, limit): + """Autocomplete currencies""" + backend = get_backend() + params = {"limit": limit} + + if query: + params["query"] = query + + result = backend.autocomplete_currencies(params) + output(result) + + +@autocomplete.command(name="piggy-banks") +@click.option("--query", help="Search query") +@click.option("--limit", default=10, help="Number of results") +def autocomplete_piggy_banks(query, limit): + """Autocomplete piggy banks""" + backend = get_backend() + params = {"limit": limit} + + if query: + params["query"] = query + + result = backend.autocomplete_piggy_banks(params) + output(result) + + +@autocomplete.command(name="tags") +@click.option("--query", help="Search query") +@click.option("--limit", default=10, help="Number of results") +def autocomplete_tags(query, limit): + """Autocomplete tags""" + backend = get_backend() + params = {"limit": limit} + + if query: + params["query"] = query + + result = backend.autocomplete_tags(params) + output(result) + + +@autocomplete.command(name="transactions") +@click.option("--query", help="Search query") +@click.option("--limit", default=10, help="Number of results") +def autocomplete_transactions(query, limit): + """Autocomplete transactions""" + backend = get_backend() + params = {"limit": limit} + + if query: + params["query"] = query + + result = backend.autocomplete_transactions(params) + output(result) + + +@autocomplete.command(name="rule-groups") +@click.option("--query", help="Search query") +@click.option("--limit", default=10, help="Number of results") +def autocomplete_rule_groups(query, limit): + """Autocomplete rule groups""" + backend = get_backend() + params = {"limit": limit} + + if query: + params["query"] = query + + result = backend.autocomplete_rule_groups(params) + output(result) + + +@autocomplete.command(name="rules") +@click.option("--query", help="Search query") +@click.option("--limit", default=10, help="Number of results") +def autocomplete_rules(query, limit): + """Autocomplete rules""" + backend = get_backend() + params = {"limit": limit} + + if query: + params["query"] = query + + result = backend.autocomplete_rules(params) + output(result) + + +@autocomplete.command(name="recurring") +@click.option("--query", help="Search query") +@click.option("--limit", default=10, help="Number of results") +def autocomplete_recurring(query, limit): + """Autocomplete recurring transactions""" + backend = get_backend() + params = {"limit": limit} + + if query: + params["query"] = query + + result = backend.autocomplete_recurring(params) + output(result) + + +@autocomplete.command(name="object-groups") +@click.option("--query", help="Search query") +@click.option("--limit", default=10, help="Number of results") +def autocomplete_object_groups(query, limit): + """Autocomplete object groups""" + backend = get_backend() + params = {"limit": limit} + + if query: + params["query"] = query + + result = backend.autocomplete_object_groups(params) + output(result) + + +@autocomplete.command(name="transaction-types") +@click.option("--query", help="Search query") +@click.option("--limit", default=10, help="Number of results") +def autocomplete_transaction_types(query, limit): + """Autocomplete transaction types""" + backend = get_backend() + params = {"limit": limit} + + if query: + params["query"] = query + + result = backend.autocomplete_transaction_types(params) + output(result) diff --git a/firefly-iii/agent-harness/cli_anything/firefly_iii/core/bills.py b/firefly-iii/agent-harness/cli_anything/firefly_iii/core/bills.py new file mode 100644 index 000000000..c7cf66a36 --- /dev/null +++ b/firefly-iii/agent-harness/cli_anything/firefly_iii/core/bills.py @@ -0,0 +1,137 @@ +r""" +Bill management command group +""" + +import click +from datetime import datetime +from ..firefly_iii_cli import get_backend, output + + +@click.group() +def bills(): + """Manage bills""" + pass + + +@bills.command(name="list") +@click.option("--limit", default=50, help="Limit results") +@click.option("--page", default=1, help="Page number") +def bills_list(limit, page): + """List all bills""" + backend = get_backend() + params = {"limit": limit, "page": page} + result = backend.get_bills(params) + output(result) + + +@bills.command(name="get") +@click.option("--id", required=True, type=int, help="Bill ID") +def bills_get(id): + """Get bill details""" + backend = get_backend() + result = backend.get_bill(id) + output(result) + + +@bills.command(name="create") +@click.option("--name", required=True, help="Bill name") +@click.option("--amount-min", required=True, help="Minimum amount") +@click.option("--amount-max", required=True, help="Maximum amount") +@click.option("--currency-code", default="USD", help="Currency code") +@click.option("--frequency", + type=click.Choice(['weekly', 'monthly', 'quarterly', 'half-yearly', 'yearly']), + default='monthly', + help="Bill frequency") +@click.option("--start-date", help="Start date (YYYY-MM-DD)") +@click.option("--end-date", help="End date (YYYY-MM-DD)") +@click.option("--pay-date", help="Expected pay date (YYYY-MM-DD)") +@click.option("--payment-date", help="Payment date (YYYY-MM-DD)") +@click.option("--notes", help="Notes") +def bills_create(name, amount_min, amount_max, currency_code, frequency, + start_date, end_date, pay_date, payment_date, notes): + """Create a new bill""" + backend = get_backend() + + data = { + "name": name, + "amount_min": amount_min, + "amount_max": amount_max, + "currency_code": currency_code, + "frequency": frequency, + } + + if start_date: + data["start_date"] = start_date + if end_date: + data["end_date"] = end_date + if pay_date: + data["pay_date"] = pay_date + if payment_date: + data["payment_date"] = payment_date + if notes: + data["notes"] = notes + + result = backend.create_bill(data) + output(result) + + +@bills.command(name="update") +@click.option("--id", required=True, type=int, help="Bill ID") +@click.option("--name", help="Bill name") +@click.option("--amount-min", help="Minimum amount") +@click.option("--amount-max", help="Maximum amount") +@click.option("--currency-code", help="Currency code") +@click.option("--frequency", + type=click.Choice(['weekly', 'monthly', 'quarterly', 'half-yearly', 'yearly']), + help="Bill frequency") +@click.option("--start-date", help="Start date (YYYY-MM-DD)") +@click.option("--end-date", help="End date (YYYY-MM-DD)") +@click.option("--pay-date", help="Expected pay date (YYYY-MM-DD)") +@click.option("--payment-date", help="Payment date (YYYY-MM-DD)") +@click.option("--notes", help="Notes") +@click.option("--active", type=bool, help="Is active") +def bills_update(id, name, amount_min, amount_max, currency_code, frequency, + start_date, end_date, pay_date, payment_date, notes, active): + """Update an existing bill""" + backend = get_backend() + + data = {} + if name: + data["name"] = name + if amount_min: + data["amount_min"] = amount_min + if amount_max: + data["amount_max"] = amount_max + if currency_code: + data["currency_code"] = currency_code + if frequency: + data["frequency"] = frequency + if start_date: + data["start_date"] = start_date + if end_date: + data["end_date"] = end_date + if pay_date: + data["pay_date"] = pay_date + if payment_date: + data["payment_date"] = payment_date + if notes: + data["notes"] = notes + if active is not None: + data["active"] = active + + if not data: + click.echo("Error: At least one update field is required", err=True) + return + + result = backend.update_bill(id, data) + output(result) + + +@bills.command(name="delete") +@click.option("--id", required=True, type=int, help="Bill ID") +@click.confirmation_option(prompt="Are you sure you want to delete this bill?") +def bills_delete(id): + """Delete a bill""" + backend = get_backend() + result = backend.delete_bill(id) + output(result) diff --git a/firefly-iii/agent-harness/cli_anything/firefly_iii/core/budgets.py b/firefly-iii/agent-harness/cli_anything/firefly_iii/core/budgets.py new file mode 100644 index 000000000..c8c61d08e --- /dev/null +++ b/firefly-iii/agent-harness/cli_anything/firefly_iii/core/budgets.py @@ -0,0 +1,148 @@ +r""" +Budget management command group +""" + +import click +from datetime import datetime +from ..firefly_iii_cli import get_backend, output + + +@click.group() +def budgets(): + """Manage budgets""" + pass + + +@budgets.command(name="list") +@click.option("--limit", default=50, help="Limit results") +@click.option("--page", default=1, help="Page number") +def budgets_list(limit, page): + """List all budgets""" + backend = get_backend() + params = {"limit": limit, "page": page} + result = backend.get_budgets(params) + output(result) + + +@budgets.command(name="get") +@click.option("--id", required=True, type=int, help="Budget ID") +def budgets_get(id): + """Get budget details""" + backend = get_backend() + result = backend.get_budget(id) + output(result) + + +@budgets.command(name="create") +@click.option("--name", required=True, help="Budget name") +@click.option("--notes", help="Notes") +def budgets_create(name, notes): + """Create a new budget""" + backend = get_backend() + + data = {"name": name} + if notes: + data["notes"] = notes + + result = backend.create_budget(data) + output(result) + + +@budgets.command(name="update") +@click.option("--id", required=True, type=int, help="Budget ID") +@click.option("--name", help="Budget name") +@click.option("--notes", help="Notes") +def budgets_update(id, name, notes): + """Update an existing budget""" + backend = get_backend() + + data = {} + if name: + data["name"] = name + if notes: + data["notes"] = notes + + if not data: + click.echo("Error: At least one update field is required", err=True) + return + + result = backend.update_budget(id, data) + output(result) + + +@budgets.command(name="delete") +@click.option("--id", required=True, type=int, help="Budget ID") +@click.confirmation_option(prompt="Are you sure you want to delete this budget?") +def budgets_delete(id): + """Delete a budget""" + backend = get_backend() + result = backend.delete_budget(id) + output(result) + + +# ========== Budget Limits ========== + +@budgets.command(name="limits") +@click.option("--budget-id", required=True, type=int, help="Budget ID") +@click.option("--start", help="Start date (YYYY-MM-DD)") +@click.option("--end", help="End date (YYYY-MM-DD)") +def budgets_limits(budget_id, start, end): + """List budget limits for a budget""" + backend = get_backend() + params = {} + if start: + params["start"] = start + if end: + params["end"] = end + result = backend.get_budget_limits(budget_id, params) + output(result) + + +@budgets.command(name="limit-create") +@click.option("--budget-id", required=True, type=int, help="Budget ID") +@click.option("--amount", required=True, help="Amount") +@click.option("--start", required=True, help="Start date (YYYY-MM-DD)") +@click.option("--end", required=True, help="End date (YYYY-MM-DD)") +@click.option("--currency-code", default="USD", help="Currency code") +def budgets_limit_create(budget_id, amount, start, end, currency_code): + """Create a budget limit""" + backend = get_backend() + + data = { + "amount": amount, + "start": start, + "end": end, + "currency_id": currency_code, + } + + result = backend.create_budget_limit(budget_id, data) + output(result) + + +@budgets.command(name="limit-update") +@click.option("--id", required=True, type=int, help="Budget limit ID") +@click.option("--amount", help="Amount") +def budgets_limit_update(id, amount): + """Update a budget limit""" + backend = get_backend() + + data = {} + if amount: + data["amount"] = amount + + if not data: + click.echo("Error: Amount is required", err=True) + return + + result = backend.update_budget_limit(id, data) + output(result) + + +@budgets.command(name="limit-delete") +@click.option("--id", required=True, type=int, help="Budget limit ID") +@click.confirmation_option(prompt="Are you sure you want to delete this budget limit?") +def budgets_limit_delete(id): + """Delete a budget limit""" + backend = get_backend() + result = backend.delete_budget_limit(id) + output(result) diff --git a/firefly-iii/agent-harness/cli_anything/firefly_iii/core/categories.py b/firefly-iii/agent-harness/cli_anything/firefly_iii/core/categories.py new file mode 100644 index 000000000..76fbb653d --- /dev/null +++ b/firefly-iii/agent-harness/cli_anything/firefly_iii/core/categories.py @@ -0,0 +1,79 @@ +r""" +Category management command group +""" + +import click +from ..firefly_iii_cli import get_backend, output + + +@click.group() +def categories(): + """Manage categories""" + pass + + +@categories.command(name="list") +@click.option("--limit", default=50, help="Limit results") +@click.option("--page", default=1, help="Page number") +def categories_list(limit, page): + """List all categories""" + backend = get_backend() + params = {"limit": limit, "page": page} + result = backend.get_categories(params) + output(result) + + +@categories.command(name="get") +@click.option("--id", required=True, type=int, help="Category ID") +def categories_get(id): + """Get category details""" + backend = get_backend() + result = backend.get_category(id) + output(result) + + +@categories.command(name="create") +@click.option("--name", required=True, help="Category name") +@click.option("--notes", help="Notes") +def categories_create(name, notes): + """Create a new category""" + backend = get_backend() + + data = {"name": name} + if notes: + data["notes"] = notes + + result = backend.create_category(data) + output(result) + + +@categories.command(name="update") +@click.option("--id", required=True, type=int, help="Category ID") +@click.option("--name", help="Category name") +@click.option("--notes", help="Notes") +def categories_update(id, name, notes): + """Update an existing category""" + backend = get_backend() + + data = {} + if name: + data["name"] = name + if notes: + data["notes"] = notes + + if not data: + click.echo("Error: At least one update field is required", err=True) + return + + result = backend.update_category(id, data) + output(result) + + +@categories.command(name="delete") +@click.option("--id", required=True, type=int, help="Category ID") +@click.confirmation_option(prompt="Are you sure you want to delete this category?") +def categories_delete(id): + """Delete a category""" + backend = get_backend() + result = backend.delete_category(id) + output(result) diff --git a/firefly-iii/agent-harness/cli_anything/firefly_iii/core/currencies.py b/firefly-iii/agent-harness/cli_anything/firefly_iii/core/currencies.py new file mode 100644 index 000000000..c0a103884 --- /dev/null +++ b/firefly-iii/agent-harness/cli_anything/firefly_iii/core/currencies.py @@ -0,0 +1,109 @@ +r""" +Currency management command group +""" + +import click +from ..firefly_iii_cli import get_backend, output + + +@click.group() +def currencies(): + """Manage currencies""" + pass + + +@currencies.command(name="list") +@click.option("--limit", default=50, help="Limit results") +@click.option("--page", default=1, help="Page number") +def currencies_list(limit, page): + """List all currencies""" + backend = get_backend() + params = {"limit": limit, "page": page} + result = backend.get_currencies(params) + output(result) + + +@currencies.command(name="get") +@click.option("--id", required=True, type=int, help="Currency ID") +def currencies_get(id): + """Get currency details""" + backend = get_backend() + result = backend.get_currency(id) + output(result) + + +@currencies.command(name="create") +@click.option("--code", required=True, help="Currency code (ISO 4217, e.g., USD, EUR)") +@click.option("--name", required=True, help="Currency name") +@click.option("--symbol", required=True, help="Currency symbol (e.g., $)") +@click.option("--decimal-places", default=2, type=int, help="Number of decimal places") +@click.option("--enabled", default=True, type=bool, help="Is enabled") +def currencies_create(code, name, symbol, decimal_places, enabled): + """Create a new currency""" + backend = get_backend() + + data = { + "code": code, + "name": name, + "symbol": symbol, + "decimal_places": decimal_places, + "enabled": enabled, + } + + result = backend.create_currency(data) + output(result) + + +@currencies.command(name="update") +@click.option("--id", required=True, type=int, help="Currency ID") +@click.option("--name", help="Currency name") +@click.option("--symbol", help="Currency symbol") +@click.option("--decimal-places", type=int, help="Number of decimal places") +@click.option("--enabled", type=bool, help="Is enabled") +def currencies_update(id, name, symbol, decimal_places, enabled): + """Update an existing currency""" + backend = get_backend() + + data = {} + if name: + data["name"] = name + if symbol: + data["symbol"] = symbol + if decimal_places is not None: + data["decimal_places"] = decimal_places + if enabled is not None: + data["enabled"] = enabled + + if not data: + click.echo("Error: At least one update field is required", err=True) + return + + result = backend.update_currency(id, data) + output(result) + + +@currencies.command(name="delete") +@click.option("--id", required=True, type=int, help="Currency ID") +@click.confirmation_option(prompt="Are you sure you want to delete this currency?") +def currencies_delete(id): + """Delete a currency""" + backend = get_backend() + result = backend.delete_currency(id) + output(result) + + +@currencies.command(name="exchange-rates") +@click.option("--from", "from_code", help="Source currency code") +@click.option("--to", "to_code", help="Target currency code") +def currencies_exchange_rates(from_code, to_code): + """Get currency exchange rates""" + backend = get_backend() + params = {} + + if from_code: + params["from"] = from_code + if to_code: + params["to"] = to_code + + result = backend.get_currency_exchange_rates(params) + output(result) diff --git a/firefly-iii/agent-harness/cli_anything/firefly_iii/core/export.py b/firefly-iii/agent-harness/cli_anything/firefly_iii/core/export.py new file mode 100644 index 000000000..050320f93 --- /dev/null +++ b/firefly-iii/agent-harness/cli_anything/firefly_iii/core/export.py @@ -0,0 +1,62 @@ +r""" +Search command group +""" + +import click +from ..firefly_iii_cli import get_backend, output + + +@click.group() +def export(): + """Export data""" + pass + + +@export.command(name="accounts") +@click.option("--type", default="csv", type=click.Choice(['csv']), help="Export format") +def export_accounts(type): + """Export accounts""" + backend = get_backend() + params = {"type": type} + + result = backend.export_data("accounts", params) + output(result) + + +@export.command(name="transactions") +@click.option("--start", required=True, help="Start date (YYYY-MM-DD)") +@click.option("--end", required=True, help="End date (YYYY-MM-DD)") +@click.option("--accounts", help="Account IDs (comma-separated)") +@click.option("--type", default="csv", type=click.Choice(['csv']), help="Export format") +def export_transactions(start, end, accounts, type): + """Export transactions""" + backend = get_backend() + params = {"start": start, "end": end, "type": type} + + if accounts: + params["accounts"] = accounts + + result = backend.export_data("transactions", params) + output(result) + + +@export.command(name="budgets") +@click.option("--type", default="csv", type=click.Choice(['csv']), help="Export format") +def export_budgets(type): + """Export budgets""" + backend = get_backend() + params = {"type": type} + + result = backend.export_data("budgets", params) + output(result) + + +@export.command(name="categories") +@click.option("--type", default="csv", type=click.Choice(['csv']), help="Export format") +def export_categories(type): + """Export categories""" + backend = get_backend() + params = {"type": type} + + result = backend.export_data("categories", params) + output(result) diff --git a/firefly-iii/agent-harness/cli_anything/firefly_iii/core/info.py b/firefly-iii/agent-harness/cli_anything/firefly_iii/core/info.py new file mode 100644 index 000000000..3dbba2850 --- /dev/null +++ b/firefly-iii/agent-harness/cli_anything/firefly_iii/core/info.py @@ -0,0 +1,36 @@ +r""" +System information command group +""" + +import click +from ..firefly_iii_cli import get_backend, output + + +@click.group() +def info(): + """System information""" + pass + + +@info.command(name="about") +def info_about(): + """Get Firefly III system information""" + backend = get_backend() + result = backend.get_about() + output(result) + + +@info.command(name="status") +def info_status(): + """Check Firefly III connection status""" + try: + backend = get_backend() + result = backend.get_about() + click.echo("Firefly III connection is normal") + if 'data' in result: + attrs = result['data'].get('attributes', {}) + click.echo(f" Version: {attrs.get('version', 'N/A')}") + click.echo(f" API Version: {attrs.get('api_version', 'N/A')}") + click.echo(f" Environment: {attrs.get('environment', 'N/A')}") + except Exception as e: + click.echo(f"Connection failed: {e}", err=True) diff --git a/firefly-iii/agent-harness/cli_anything/firefly_iii/core/insights.py b/firefly-iii/agent-harness/cli_anything/firefly_iii/core/insights.py new file mode 100644 index 000000000..5a44304ea --- /dev/null +++ b/firefly-iii/agent-harness/cli_anything/firefly_iii/core/insights.py @@ -0,0 +1,95 @@ +r""" +Insight and report command group +""" + +import click +from ..firefly_iii_cli import get_backend, output + + +@click.group() +def insights(): + """View financial insights and reports""" + pass + + +@insights.command(name="expense") +@click.option("--start", required=True, help="Start date (YYYY-MM-DD)") +@click.option("--end", required=True, help="End date (YYYY-MM-DD)") +@click.option("--group-by", + type=click.Choice(['expense', 'asset', 'bill', 'budget', 'category', 'tag']), + default='category', + help="Group expenses by") +@click.option("--accounts", help="Account IDs (comma-separated)") +def insights_expense(start, end, group_by, accounts): + """View expense insights""" + backend = get_backend() + params = {"start": start, "end": end} + + if accounts: + params["accounts[]"] = accounts.split(",") + + endpoint_map = { + 'expense': '/insight/expense/expense', + 'asset': '/insight/expense/asset', + 'bill': '/insight/expense/bill', + 'budget': '/insight/expense/budget', + 'category': '/insight/expense/category', + 'tag': '/insight/expense/tag', + } + + result = backend.get(endpoint_map[group_by], params=params) + output(result) + + +@insights.command(name="income") +@click.option("--start", required=True, help="Start date (YYYY-MM-DD)") +@click.option("--end", required=True, help="End date (YYYY-MM-DD)") +@click.option("--group-by", + type=click.Choice(['revenue', 'asset', 'category']), + default='category', + help="Group income by") +@click.option("--accounts", help="Account IDs (comma-separated)") +def insights_income(start, end, group_by, accounts): + """View income insights""" + backend = get_backend() + params = {"start": start, "end": end} + + if accounts: + params["accounts[]"] = accounts.split(",") + + endpoint_map = { + 'revenue': '/insight/income/revenue', + 'asset': '/insight/income/asset', + 'category': '/insight/income/category', + } + + result = backend.get(endpoint_map[group_by], params=params) + output(result) + + +@insights.command(name="transfer") +@click.option("--start", required=True, help="Start date (YYYY-MM-DD)") +@click.option("--end", required=True, help="End date (YYYY-MM-DD)") +@click.option("--accounts", help="Account IDs (comma-separated)") +def insights_transfer(start, end, accounts): + """View transfer insights""" + backend = get_backend() + params = {"start": start, "end": end} + + if accounts: + params["accounts[]"] = accounts.split(",") + + result = backend.get("/insight/transfer/asset", params=params) + output(result) + + +@insights.command(name="overview") +@click.option("--start", required=True, help="Start date (YYYY-MM-DD)") +@click.option("--end", required=True, help="End date (YYYY-MM-DD)") +def insights_overview(start, end): + """View account overview chart data""" + backend = get_backend() + params = {"start": start, "end": end} + + result = backend.get("/chart/account/overview", params=params) + output(result) diff --git a/firefly-iii/agent-harness/cli_anything/firefly_iii/core/piggy_banks.py b/firefly-iii/agent-harness/cli_anything/firefly_iii/core/piggy_banks.py new file mode 100644 index 000000000..c16550b8a --- /dev/null +++ b/firefly-iii/agent-harness/cli_anything/firefly_iii/core/piggy_banks.py @@ -0,0 +1,133 @@ +r""" +Piggy bank management command group +""" + +import click +from datetime import datetime +from ..firefly_iii_cli import get_backend, output + + +@click.group() +def piggy_banks(): + """Manage piggy banks""" + pass + + +@piggy_banks.command(name="list") +@click.option("--limit", default=50, help="Limit results") +@click.option("--page", default=1, help="Page number") +def piggy_banks_list(limit, page): + """List all piggy banks""" + backend = get_backend() + params = {"limit": limit, "page": page} + result = backend.get_piggy_banks(params) + output(result) + + +@piggy_banks.command(name="get") +@click.option("--id", required=True, type=int, help="Piggy bank ID") +def piggy_banks_get(id): + """Get piggy bank details""" + backend = get_backend() + result = backend.get_piggy_bank(id) + output(result) + + +@piggy_banks.command(name="create") +@click.option("--name", required=True, help="Piggy bank name") +@click.option("--account-id", required=True, type=int, help="Account ID") +@click.option("--target-amount", help="Target amount") +@click.option("--start-date", help="Start date (YYYY-MM-DD)") +@click.option("--notes", help="Notes") +def piggy_banks_create(name, account_id, target_amount, start_date, notes): + """Create a new piggy bank""" + backend = get_backend() + + data = { + "name": name, + "account_id": account_id, + } + + if target_amount: + data["target_amount"] = target_amount + if start_date: + data["start_date"] = start_date + if notes: + data["notes"] = notes + + result = backend.create_piggy_bank(data) + output(result) + + +@piggy_banks.command(name="update") +@click.option("--id", required=True, type=int, help="Piggy bank ID") +@click.option("--name", help="Piggy bank name") +@click.option("--target-amount", help="Target amount") +@click.option("--start-date", help="Start date (YYYY-MM-DD)") +@click.option("--end-date", help="End date (YYYY-MM-DD)") +@click.option("--notes", help="Notes") +@click.option("--active", type=bool, help="Is active") +def piggy_banks_update(id, name, target_amount, start_date, end_date, notes, active): + """Update an existing piggy bank""" + backend = get_backend() + + data = {} + if name: + data["name"] = name + if target_amount: + data["target_amount"] = target_amount + if start_date: + data["start_date"] = start_date + if end_date: + data["end_date"] = end_date + if notes: + data["notes"] = notes + if active is not None: + data["active"] = active + + if not data: + click.echo("Error: At least one update field is required", err=True) + return + + result = backend.update_piggy_bank(id, data) + output(result) + + +@piggy_banks.command(name="delete") +@click.option("--id", required=True, type=int, help="Piggy bank ID") +@click.confirmation_option(prompt="Are you sure you want to delete this piggy bank?") +def piggy_banks_delete(id): + """Delete a piggy bank""" + backend = get_backend() + result = backend.delete_piggy_bank(id) + output(result) + + +@piggy_banks.command(name="events") +@click.option("--id", required=True, type=int, help="Piggy bank ID") +def piggy_banks_events(id): + """List piggy bank events""" + backend = get_backend() + result = backend.get_piggy_bank_events(id) + output(result) + + +@piggy_banks.command(name="add-money") +@click.option("--id", required=True, type=int, help="Piggy bank ID") +@click.option("--amount", required=True, help="Amount to add") +@click.option("--date", default=lambda: datetime.now().strftime('%Y-%m-%d'), + help="Date (YYYY-MM-DD)") +@click.option("--note", help="Note") +def piggy_banks_add_money(id, amount, date, note): + """Add money to piggy bank""" + backend = get_backend() + + data = { + "amount": amount, + "date": date, + } + if note: + data["note"] = note + + result = backend.create_piggy_bank_event(id, data) + output(result) diff --git a/firefly-iii/agent-harness/cli_anything/firefly_iii/core/recurrences.py b/firefly-iii/agent-harness/cli_anything/firefly_iii/core/recurrences.py new file mode 100644 index 000000000..59ebaf9fa --- /dev/null +++ b/firefly-iii/agent-harness/cli_anything/firefly_iii/core/recurrences.py @@ -0,0 +1,152 @@ +r""" +Recurring transaction management command group +""" + +import click +from datetime import datetime +from ..firefly_iii_cli import get_backend, output + + +@click.group() +def recurrences(): + """Manage recurring transactions""" + pass + + +@recurrences.command(name="list") +@click.option("--limit", default=50, help="Limit results") +@click.option("--page", default=1, help="Page number") +def recurrences_list(limit, page): + """List all recurring transactions""" + backend = get_backend() + params = {"limit": limit, "page": page} + result = backend.get_recurrences(params) + output(result) + + +@recurrences.command(name="get") +@click.option("--id", required=True, type=int, help="Recurrence ID") +def recurrences_get(id): + """Get recurring transaction details""" + backend = get_backend() + result = backend.get_recurrence(id) + output(result) + + +@recurrences.command(name="create") +@click.option("--title", required=True, help="Recurrence title") +@click.option("--type", + type=click.Choice(['withdrawal', 'deposit', 'transfer']), + required=True, + help="Transaction type") +@click.option("--amount", required=True, help="Amount") +@click.option("--currency-code", default="USD", help="Currency code") +@click.option("--source-account", required=True, help="Source account ID") +@click.option("--destination-account", help="Destination account ID") +@click.option("--frequency", + type=click.Choice(['daily', 'weekly', 'monthly', 'quarterly', 'half-yearly', 'yearly']), + required=True, + help="Frequency") +@click.option("--start-date", help="Start date (YYYY-MM-DD)") +@click.option("--end-date", help="End date (YYYY-MM-DD)") +@click.option("--description", help="Description") +@click.option("--notes", help="Notes") +@click.option("--tags", help="Tags (comma-separated)") +def recurrences_create(title, type, amount, currency_code, source_account, + destination_account, frequency, start_date, end_date, + description, notes, tags): + """Create a new recurring transaction""" + backend = get_backend() + + data = { + "title": title, + "type": type, + "amount": amount, + "currency_code": currency_code, + "source_id": source_account, + "frequency": frequency, + } + + if destination_account: + data["destination_id"] = destination_account + if start_date: + data["start_date"] = start_date + if end_date: + data["end_date"] = end_date + if description: + data["description"] = description + if notes: + data["notes"] = notes + if tags: + data["tags"] = [t.strip() for t in tags.split(",")] + + result = backend.create_recurrence(data) + output(result) + + +@recurrences.command(name="update") +@click.option("--id", required=True, type=int, help="Recurrence ID") +@click.option("--title", help="Recurrence title") +@click.option("--type", + type=click.Choice(['withdrawal', 'deposit', 'transfer']), + help="Transaction type") +@click.option("--amount", help="Amount") +@click.option("--currency-code", help="Currency code") +@click.option("--source-account", help="Source account ID") +@click.option("--destination-account", help="Destination account ID") +@click.option("--frequency", + type=click.Choice(['daily', 'weekly', 'monthly', 'quarterly', 'half-yearly', 'yearly']), + help="Frequency") +@click.option("--start-date", help="Start date (YYYY-MM-DD)") +@click.option("--end-date", help="End date (YYYY-MM-DD)") +@click.option("--description", help="Description") +@click.option("--notes", help="Notes") +@click.option("--tags", help="Tags (comma-separated)") +def recurrences_update(id, title, type, amount, currency_code, source_account, + destination_account, frequency, start_date, end_date, + description, notes, tags): + """Update an existing recurring transaction""" + backend = get_backend() + + data = {} + if title: + data["title"] = title + if type: + data["type"] = type + if amount: + data["amount"] = amount + if currency_code: + data["currency_code"] = currency_code + if source_account: + data["source_id"] = source_account + if destination_account: + data["destination_id"] = destination_account + if frequency: + data["frequency"] = frequency + if start_date: + data["start_date"] = start_date + if end_date: + data["end_date"] = end_date + if description: + data["description"] = description + if notes: + data["notes"] = notes + if tags: + data["tags"] = [t.strip() for t in tags.split(",")] + + if not data: + click.echo("Error: At least one update field is required", err=True) + return + + result = backend.update_recurrence(id, data) + output(result) + + +@recurrences.command(name="delete") +@click.option("--id", required=True, type=int, help="Recurrence ID") +@click.confirmation_option(prompt="Are you sure you want to delete this recurring transaction?") +def recurrences_delete(id): + """Delete a recurring transaction""" + backend = get_backend() + result = backend.delete_recurrence(id) + output(result) diff --git a/firefly-iii/agent-harness/cli_anything/firefly_iii/core/rule_groups.py b/firefly-iii/agent-harness/cli_anything/firefly_iii/core/rule_groups.py new file mode 100644 index 000000000..cb4172fd6 --- /dev/null +++ b/firefly-iii/agent-harness/cli_anything/firefly_iii/core/rule_groups.py @@ -0,0 +1,98 @@ +r""" +Rule group management command group +""" + +import click +from ..firefly_iii_cli import get_backend, output + + +@click.group() +def rule_groups(): + """Manage rule groups""" + pass + + +@rule_groups.command(name="list") +@click.option("--limit", default=50, help="Limit results") +@click.option("--page", default=1, help="Page number") +def rule_groups_list(limit, page): + """List all rule groups""" + backend = get_backend() + params = {"limit": limit, "page": page} + result = backend.get_rule_groups(params) + output(result) + + +@rule_groups.command(name="get") +@click.option("--id", required=True, type=int, help="Rule group ID") +def rule_groups_get(id): + """Get rule group details""" + backend = get_backend() + result = backend.get_rule_group(id) + output(result) + + +@rule_groups.command(name="create") +@click.option("--title", required=True, help="Rule group title") +@click.option("--description", help="Description") +@click.option("--priority", default=0, type=int, help="Priority") +def rule_groups_create(title, description, priority): + """Create a new rule group""" + backend = get_backend() + + data = { + "title": title, + "priority": priority, + } + if description: + data["description"] = description + + result = backend.create_rule_group(data) + output(result) + + +@rule_groups.command(name="update") +@click.option("--id", required=True, type=int, help="Rule group ID") +@click.option("--title", help="Rule group title") +@click.option("--description", help="Description") +@click.option("--priority", type=int, help="Priority") +@click.option("--active", type=bool, help="Is active") +def rule_groups_update(id, title, description, priority, active): + """Update an existing rule group""" + backend = get_backend() + + data = {} + if title: + data["title"] = title + if description: + data["description"] = description + if priority is not None: + data["priority"] = priority + if active is not None: + data["active"] = active + + if not data: + click.echo("Error: At least one update field is required", err=True) + return + + result = backend.update_rule_group(id, data) + output(result) + + +@rule_groups.command(name="delete") +@click.option("--id", required=True, type=int, help="Rule group ID") +@click.confirmation_option(prompt="Are you sure you want to delete this rule group?") +def rule_groups_delete(id): + """Delete a rule group""" + backend = get_backend() + result = backend.delete_rule_group(id) + output(result) + + +@rule_groups.command(name="execute") +@click.option("--id", required=True, type=int, help="Rule group ID") +def rule_groups_execute(id): + """Execute all rules in a rule group""" + backend = get_backend() + result = backend.execute_rule_group(id) + output(result) diff --git a/firefly-iii/agent-harness/cli_anything/firefly_iii/core/rules.py b/firefly-iii/agent-harness/cli_anything/firefly_iii/core/rules.py new file mode 100644 index 000000000..12d43b16e --- /dev/null +++ b/firefly-iii/agent-harness/cli_anything/firefly_iii/core/rules.py @@ -0,0 +1,135 @@ +r""" +Rule management command group +""" + +import click +from datetime import datetime +from ..firefly_iii_cli import get_backend, output + + +@click.group() +def rules(): + """Manage transaction rules""" + pass + + +@rules.command(name="list") +@click.option("--limit", default=50, help="Limit results") +@click.option("--page", default=1, help="Page number") +def rules_list(limit, page): + """List all rules""" + backend = get_backend() + params = {"limit": limit, "page": page} + result = backend.get_rules(params) + output(result) + + +@rules.command(name="get") +@click.option("--id", required=True, type=int, help="Rule ID") +def rules_get(id): + """Get rule details""" + backend = get_backend() + result = backend.get_rule(id) + output(result) + + +@rules.command(name="create") +@click.option("--title", required=True, help="Rule title") +@click.option("--trigger", required=True, help="Trigger type (e.g., from_account_is, to_account_is, description_contains)") +@click.option("--value", required=True, help="Trigger value") +@click.option("--action", required=True, + type=click.Choice(['set_category', 'add_tag', 'remove_tag', 'set_description', 'set_source_account', 'set_destination_account']), + help="Action to take") +@click.option("--action-value", help="Action value (e.g., category name)") +@click.option("--rule-group-id", type=int, help="Rule group ID") +@click.option("--priority", default=0, type=int, help="Priority (lower = higher priority)") +@click.option("--notes", help="Notes") +def rules_create(title, trigger, value, action, action_value, rule_group_id, priority, notes): + """Create a new rule""" + backend = get_backend() + + data = { + "title": title, + "triggers": [{"type": trigger, "value": value}], + "actions": [{"type": action, "value": action_value or ""}], + "priority": priority, + } + + if rule_group_id: + data["rule_group_id"] = rule_group_id + if notes: + data["notes"] = notes + + result = backend.create_rule(data) + output(result) + + +@rules.command(name="update") +@click.option("--id", required=True, type=int, help="Rule ID") +@click.option("--title", help="Rule title") +@click.option("--trigger", help="Trigger type") +@click.option("--value", help="Trigger value") +@click.option("--action", + type=click.Choice(['set_category', 'add_tag', 'remove_tag', 'set_description', 'set_source_account', 'set_destination_account']), + help="Action type") +@click.option("--action-value", help="Action value") +@click.option("--rule-group-id", type=int, help="Rule group ID") +@click.option("--priority", type=int, help="Priority") +@click.option("--notes", help="Notes") +@click.option("--active", type=bool, help="Is active") +def rules_update(id, title, trigger, value, action, action_value, rule_group_id, priority, notes, active): + """Update an existing rule""" + backend = get_backend() + + data = {} + if title: + data["title"] = title + if trigger and value: + data["triggers"] = [{"type": trigger, "value": value}] + if action: + data["actions"] = [{"type": action, "value": action_value or ""}] + if rule_group_id: + data["rule_group_id"] = rule_group_id + if priority is not None: + data["priority"] = priority + if notes: + data["notes"] = notes + if active is not None: + data["active"] = active + + if not data: + click.echo("Error: At least one update field is required", err=True) + return + + result = backend.update_rule(id, data) + output(result) + + +@rules.command(name="delete") +@click.option("--id", required=True, type=int, help="Rule ID") +@click.confirmation_option(prompt="Are you sure you want to delete this rule?") +def rules_delete(id): + """Delete a rule""" + backend = get_backend() + result = backend.delete_rule(id) + output(result) + + +@rules.command(name="test") +@click.option("--id", required=True, type=int, help="Rule ID") +@click.option("--data", help="JSON data for test") +def rules_test(id, data): + """Test a rule against sample data""" + backend = get_backend() + test_data = data or {} + result = backend.test_rule(id, test_data) + output(result) + + +@rules.command(name="execute") +@click.option("--id", required=True, type=int, help="Rule ID") +def rules_execute(id): + """Execute a rule against existing transactions""" + backend = get_backend() + result = backend.execute_rule(id) + output(result) diff --git a/firefly-iii/agent-harness/cli_anything/firefly_iii/core/search.py b/firefly-iii/agent-harness/cli_anything/firefly_iii/core/search.py new file mode 100644 index 000000000..1dcf5b610 --- /dev/null +++ b/firefly-iii/agent-harness/cli_anything/firefly_iii/core/search.py @@ -0,0 +1,25 @@ +r""" +Search command group +""" + +import click +from ..firefly_iii_cli import get_backend, output + + +@click.group() +def search(): + """Search transactions""" + pass + + +@search.command(name="transactions") +@click.option("--query", required=True, help="Search query") +@click.option("--limit", default=50, help="Limit results") +@click.option("--page", default=1, help="Page number") +def search_transactions(query, limit, page): + """Search transactions""" + backend = get_backend() + params = {"limit": limit, "page": page} + + result = backend.search(query, params) + output(result) diff --git a/firefly-iii/agent-harness/cli_anything/firefly_iii/core/summary.py b/firefly-iii/agent-harness/cli_anything/firefly_iii/core/summary.py new file mode 100644 index 000000000..049e750b3 --- /dev/null +++ b/firefly-iii/agent-harness/cli_anything/firefly_iii/core/summary.py @@ -0,0 +1,105 @@ +r""" +Summary command group + +Provides various summary reports and statistics. +""" + +import click +from ..firefly_iii_cli import get_backend, output + + +@click.group() +def summary(): + """Financial summaries and reports""" + pass + + +@summary.command(name="default-set") +@click.option("--start", required=True, help="Start date (YYYY-MM-DD)") +@click.option("--end", required=True, help="End date (YYYY-MM-DD)") +def summary_default_set(start, end): + """Get default summary set""" + backend = get_backend() + params = {"start": start, "end": end} + result = backend.get_summary("default-set", params) + output(result) + + +@summary.command(name="account-summary") +@click.option("--start", required=True, help="Start date (YYYY-MM-DD)") +@click.option("--end", required=True, help="End date (YYYY-MM-DD)") +@click.option("--accounts", help="Account IDs (comma-separated)") +def summary_account_summary(start, end, accounts): + """Get account summary""" + backend = get_backend() + params = {"start": start, "end": end} + if accounts: + params["accounts"] = accounts + result = backend.get_summary("account-summary", params) + output(result) + + +@summary.command(name="available-budget") +@click.option("--start", required=True, help="Start date (YYYY-MM-DD)") +@click.option("--end", required=True, help="End date (YYYY-MM-DD)") +def summary_available_budget(start, end): + """Get available budget summary""" + backend = get_backend() + params = {"start": start, "end": end} + result = backend.get_summary("available-budget", params) + output(result) + + +@summary.command(name="bill-summary") +@click.option("--start", required=True, help="Start date (YYYY-MM-DD)") +@click.option("--end", required=True, help="End date (YYYY-MM-DD)") +def summary_bill_summary(start, end): + """Get bill summary""" + backend = get_backend() + params = {"start": start, "end": end} + result = backend.get_summary("bill-summary", params) + output(result) + + +@summary.command(name="budget-summary") +@click.option("--start", required=True, help="Start date (YYYY-MM-DD)") +@click.option("--end", required=True, help="End date (YYYY-MM-DD)") +def summary_budget_summary(start, end): + """Get budget summary""" + backend = get_backend() + params = {"start": start, "end": end} + result = backend.get_summary("budget-summary", params) + output(result) + + +@summary.command(name="category-summary") +@click.option("--start", required=True, help="Start date (YYYY-MM-DD)") +@click.option("--end", required=True, help="End date (YYYY-MM-DD)") +def summary_category_summary(start, end): + """Get category summary""" + backend = get_backend() + params = {"start": start, "end": end} + result = backend.get_summary("category-summary", params) + output(result) + + +@summary.command(name="tag-summary") +@click.option("--start", required=True, help="Start date (YYYY-MM-DD)") +@click.option("--end", required=True, help="End date (YYYY-MM-DD)") +def summary_tag_summary(start, end): + """Get tag summary""" + backend = get_backend() + params = {"start": start, "end": end} + result = backend.get_summary("tag-summary", params) + output(result) + + +@summary.command(name="transfer-summary") +@click.option("--start", required=True, help="Start date (YYYY-MM-DD)") +@click.option("--end", required=True, help="End date (YYYY-MM-DD)") +def summary_transfer_summary(start, end): + """Get transfer summary""" + backend = get_backend() + params = {"start": start, "end": end} + result = backend.get_summary("transfer-summary", params) + output(result) diff --git a/firefly-iii/agent-harness/cli_anything/firefly_iii/core/tags.py b/firefly-iii/agent-harness/cli_anything/firefly_iii/core/tags.py new file mode 100644 index 000000000..8996eb288 --- /dev/null +++ b/firefly-iii/agent-harness/cli_anything/firefly_iii/core/tags.py @@ -0,0 +1,85 @@ +r""" +Tag management command group +""" + +import click +from ..firefly_iii_cli import get_backend, output + + +@click.group() +def tags(): + """Manage tags""" + pass + + +@tags.command(name="list") +@click.option("--limit", default=50, help="Limit results") +@click.option("--page", default=1, help="Page number") +def tags_list(limit, page): + """List all tags""" + backend = get_backend() + params = {"limit": limit, "page": page} + result = backend.get_tags(params) + output(result) + + +@tags.command(name="get") +@click.option("--id", required=True, help="Tag ID (can be UUID or integer)") +def tags_get(id): + """Get tag details""" + backend = get_backend() + result = backend.get_tag(id) + output(result) + + +@tags.command(name="create") +@click.option("--tag", required=True, help="Tag value") +@click.option("--description", help="Description") +@click.option("--date", help="Date (YYYY-MM-DD)") +def tags_create(tag, description, date): + """Create a new tag""" + backend = get_backend() + + data = {"tag": tag} + if description: + data["description"] = description + if date: + data["date"] = date + + result = backend.create_tag(data) + output(result) + + +@tags.command(name="update") +@click.option("--id", required=True, help="Tag ID") +@click.option("--tag", help="Tag value") +@click.option("--description", help="Description") +@click.option("--date", help="Date (YYYY-MM-DD)") +def tags_update(id, tag, description, date): + """Update an existing tag""" + backend = get_backend() + + data = {} + if tag: + data["tag"] = tag + if description: + data["description"] = description + if date: + data["date"] = date + + if not data: + click.echo("Error: At least one update field is required", err=True) + return + + result = backend.update_tag(id, data) + output(result) + + +@tags.command(name="delete") +@click.option("--id", required=True, help="Tag ID") +@click.confirmation_option(prompt="Are you sure you want to delete this tag?") +def tags_delete(id): + """Delete a tag""" + backend = get_backend() + result = backend.delete_tag(id) + output(result) diff --git a/firefly-iii/agent-harness/cli_anything/firefly_iii/core/transactions.py b/firefly-iii/agent-harness/cli_anything/firefly_iii/core/transactions.py new file mode 100644 index 000000000..74335f3fd --- /dev/null +++ b/firefly-iii/agent-harness/cli_anything/firefly_iii/core/transactions.py @@ -0,0 +1,151 @@ +r""" +Transaction management command group +""" + +import click +from datetime import datetime +from ..firefly_iii_cli import get_backend, output + + +@click.group() +def transactions(): + """Manage transactions""" + pass + + +@transactions.command(name="list") +@click.option("--limit", default=50, help="Limit results") +@click.option("--page", default=1, help="Page number") +@click.option("--start", help="Start date (YYYY-MM-DD)") +@click.option("--end", help="End date (YYYY-MM-DD)") +@click.option("--type", + type=click.Choice(['withdrawal', 'deposit', 'transfer']), + help="Transaction type") +@click.option("--source-account", help="Source account ID or name") +@click.option("--destination-account", help="Destination account ID or name") +def transactions_list(limit, page, start, end, type, source_account, destination_account): + """List transactions""" + backend = get_backend() + params = {"limit": limit, "page": page} + + if start: + params["start"] = start + if end: + params["end"] = end + if type: + params["type"] = type + if source_account: + params["source_id"] = source_account + if destination_account: + params["destination_id"] = destination_account + + result = backend.get_transactions(params) + output(result) + + +@transactions.command(name="get") +@click.option("--id", required=True, type=int, help="Transaction ID") +def transactions_get(id): + """Get transaction details""" + backend = get_backend() + result = backend.get_transaction(id) + output(result) + + +@transactions.command(name="create") +@click.option("--description", required=True, help="Transaction description") +@click.option("--amount", required=True, help="Transaction amount") +@click.option("--source-account", required=True, help="Source account ID") +@click.option("--destination-account", help="Destination account ID (for transfers)") +@click.option("--type", + type=click.Choice(['withdrawal', 'deposit', 'transfer']), + default='withdrawal', + help="Transaction type") +@click.option("--date", default=lambda: datetime.now().strftime('%Y-%m-%d'), + help="Transaction date (YYYY-MM-DD)") +@click.option("--category", help="Category name") +@click.option("--tags", help="Tags (comma-separated)") +@click.option("--budget", help="Budget name") +@click.option("--notes", help="Notes") +def transactions_create(description, amount, source_account, destination_account, + type, date, category, tags, budget, notes): + """Create a new transaction""" + backend = get_backend() + + transaction_data = { + "type": type, + "date": date, + "amount": amount, + "description": description, + "source_id": source_account, + } + + if destination_account: + transaction_data["destination_id"] = destination_account + if category: + transaction_data["category_name"] = category + if tags: + transaction_data["tags"] = [tag.strip() for tag in tags.split(",")] + if budget: + transaction_data["budget_name"] = budget + if notes: + transaction_data["notes"] = notes + + data = { + "error_if_duplicate_hash": True, + "error_if_duplicate_hash_v2": True, + "apply_rules": True, + "fire_webhooks": True, + "group_title": description, + "transactions": [transaction_data] + } + + result = backend.create_transaction(data) + output(result) + + +@transactions.command(name="update") +@click.option("--id", required=True, type=int, help="Transaction ID") +@click.option("--description", help="Transaction description") +@click.option("--amount", help="Transaction amount") +@click.option("--category", help="Category name") +@click.option("--tags", help="Tags (comma-separated)") +@click.option("--notes", help="Notes") +def transactions_update(id, description, amount, category, tags, notes): + """Update an existing transaction""" + backend = get_backend() + + transaction_data = {} + if description: + transaction_data["description"] = description + if amount: + transaction_data["amount"] = amount + if category: + transaction_data["category_name"] = category + if tags: + transaction_data["tags"] = [tag.strip() for tag in tags.split(",")] + if notes: + transaction_data["notes"] = notes + + if not transaction_data: + click.echo("Error: At least one update field is required", err=True) + return + + data = { + "apply_rules": True, + "fire_webhooks": True, + "transactions": [transaction_data] + } + + result = backend.update_transaction(id, data) + output(result) + + +@transactions.command(name="delete") +@click.option("--id", required=True, type=int, help="Transaction ID") +@click.confirmation_option(prompt="Are you sure you want to delete this transaction?") +def transactions_delete(id): + """Delete a transaction""" + backend = get_backend() + result = backend.delete_transaction(id) + output(result) diff --git a/firefly-iii/agent-harness/cli_anything/firefly_iii/core/webhooks.py b/firefly-iii/agent-harness/cli_anything/firefly_iii/core/webhooks.py new file mode 100644 index 000000000..6d6919b42 --- /dev/null +++ b/firefly-iii/agent-harness/cli_anything/firefly_iii/core/webhooks.py @@ -0,0 +1,125 @@ +r""" +Webhook management command group +""" + +import click +from ..firefly_iii_cli import get_backend, output + + +@click.group() +def webhooks(): + """Manage webhooks""" + pass + + +@webhooks.command(name="list") +@click.option("--limit", default=50, help="Limit results") +@click.option("--page", default=1, help="Page number") +def webhooks_list(limit, page): + """List all webhooks""" + backend = get_backend() + params = {"limit": limit, "page": page} + result = backend.get_webhooks(params) + output(result) + + +@webhooks.command(name="get") +@click.option("--id", required=True, type=int, help="Webhook ID") +def webhooks_get(id): + """Get webhook details""" + backend = get_backend() + result = backend.get_webhook(id) + output(result) + + +@webhooks.command(name="create") +@click.option("--title", required=True, help="Webhook title") +@click.option("--trigger", + type=click.Choice(['create', 'update', 'delete']), + required=True, + help="Trigger event type") +@click.option("--url", required=True, help="Webhook URL") +@click.option("--secret", help="Webhook secret") +@click.option("--active", default=True, type=bool, help="Is active") +@click.option("--events", + type=click.Choice(['ANY', 'TRANSACTION_STORE', 'TRANSACTION_UPDATE', 'TRANSACTION_DESTROY', + 'JOURNAL_CREATE', 'JOURNAL_UPDATE', 'JOURNAL_DESTROY']), + multiple=True, + help="Events to trigger on") +def webhooks_create(title, trigger, url, secret, active, events): + """Create a new webhook""" + backend = get_backend() + + data = { + "title": title, + "trigger": trigger, + "url": url, + "active": active, + } + + if secret: + data["secret"] = secret + if events: + data["events"] = list(events) + + result = backend.create_webhook(data) + output(result) + + +@webhooks.command(name="update") +@click.option("--id", required=True, type=int, help="Webhook ID") +@click.option("--title", help="Webhook title") +@click.option("--trigger", + type=click.Choice(['create', 'update', 'delete']), + help="Trigger event type") +@click.option("--url", help="Webhook URL") +@click.option("--secret", help="Webhook secret") +@click.option("--active", type=bool, help="Is active") +@click.option("--events", + type=click.Choice(['ANY', 'TRANSACTION_STORE', 'TRANSACTION_UPDATE', 'TRANSACTION_DESTROY', + 'JOURNAL_CREATE', 'JOURNAL_UPDATE', 'JOURNAL_DESTROY']), + multiple=True, + help="Events to trigger on") +def webhooks_update(id, title, trigger, url, secret, active, events): + """Update an existing webhook""" + backend = get_backend() + + data = {} + if title: + data["title"] = title + if trigger: + data["trigger"] = trigger + if url: + data["url"] = url + if secret: + data["secret"] = secret + if active is not None: + data["active"] = active + if events: + data["events"] = list(events) + + if not data: + click.echo("Error: At least one update field is required", err=True) + return + + result = backend.update_webhook(id, data) + output(result) + + +@webhooks.command(name="delete") +@click.option("--id", required=True, type=int, help="Webhook ID") +@click.confirmation_option(prompt="Are you sure you want to delete this webhook?") +def webhooks_delete(id): + """Delete a webhook""" + backend = get_backend() + result = backend.delete_webhook(id) + output(result) + + +@webhooks.command(name="trigger") +@click.option("--id", required=True, type=int, help="Webhook ID") +def webhooks_trigger(id): + """Trigger a webhook manually""" + backend = get_backend() + result = backend.trigger_webhook(id) + output(result) diff --git a/firefly-iii/agent-harness/cli_anything/firefly_iii/firefly_iii_cli.py b/firefly-iii/agent-harness/cli_anything/firefly_iii/firefly_iii_cli.py new file mode 100644 index 000000000..b427aeca3 --- /dev/null +++ b/firefly-iii/agent-harness/cli_anything/firefly_iii/firefly_iii_cli.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +r""" +Firefly III CLI - Personal finance management via CLI-Anything + +Firefly III command-line interface based on CLI-Anything spec, +converted from MCP mode to stateless CLI mode to avoid Node residual process issues. +""" + +import click +import json +import os +import sys +from typing import Dict, Any, Optional + +from .utils.firefly_iii_backend import FireflyIIIBackend +from .utils.repl_skin import ReplSkin + +# Global state +_json_output = False +_backend = None +_repl_skin = None + + +def get_backend() -> FireflyIIIBackend: + """Get backend instance, raise error if not initialized""" + if _backend is None: + raise RuntimeError("Backend not initialized, please check configuration") + return _backend + + +def output(data: Any): + """Unified output format: JSON or human-readable""" + if _json_output: + try: + click.echo(json.dumps(data, indent=2, ensure_ascii=False)) + except UnicodeEncodeError: + # If console does not support Unicode, use ASCII encoding + click.echo(json.dumps(data, indent=2, ensure_ascii=True)) + else: + # Human-readable format + if isinstance(data, dict): + if 'data' in data: + # Firefly III API standard response format + items = data['data'] + if isinstance(items, list): + for item in items: + attrs = item.get('attributes', {}) + name = attrs.get('name', item.get('id')) + click.echo(f" {item.get('id', 'N/A')}: {name}") + else: + attrs = items.get('attributes', {}) + for key, value in attrs.items(): + click.echo(f" {key}: {value}") + elif 'meta' in data: + # Response with metadata + click.echo(f" Total: {data.get('meta', {}).get('pagination', {}).get('total', 'N/A')}") + else: + for key, value in data.items(): + click.echo(f" {key}: {value}") + elif isinstance(data, list): + for item in data: + click.echo(f" - {item}") + else: + click.echo(f" {data}") + + +@click.group(invoke_without_command=True) +@click.option("--json", "use_json", is_flag=True, help="Output as JSON") +@click.option("--base-url", help="Firefly III base URL") +@click.option("--pat", help="Personal Access Token") +@click.option("--preset", default="default", + type=click.Choice(['default', 'full', 'basic', 'budget', 'reporting', 'admin', 'automation']), + help="Tool preset") +@click.pass_context +def cli(ctx, use_json, base_url, pat, preset): + """Firefly III CLI - Personal finance management. + + Based on CLI-Anything spec, converted from MCP mode to stateless CLI mode, + avoiding Node residual process issues. + """ + global _json_output, _backend, _repl_skin + + _json_output = use_json + + # Get configuration from arguments and environment variables + base_url = base_url or os.environ.get('FIREFLY_III_BASE_URL') + pat = pat or os.environ.get('FIREFLY_III_PAT') + + if not base_url or not pat: + click.echo("Error: FIREFLY_III_BASE_URL and FIREFLY_III_PAT are required", err=True) + click.echo("\nUsage:", err=True) + click.echo(" cli-anything-firefly-iii --base-url URL --pat TOKEN", err=True) + click.echo("\nOr set environment variables:", err=True) + click.echo(" export FIREFLY_III_BASE_URL=https://firefly.yourdomain.com", err=True) + click.echo(" export FIREFLY_III_PAT=your-personal-access-token", err=True) + ctx.exit(1) + + try: + _backend = FireflyIIIBackend(base_url, pat) + _repl_skin = ReplSkin("firefly-iii", "1.0.0") + except RuntimeError as e: + click.echo(f"Error: {e}", err=True) + ctx.exit(1) + + # Enter REPL when no subcommand is provided + if ctx.invoked_subcommand is None: + ctx.invoke(repl) + + +# Import command groups +from .core.accounts import accounts +from .core.transactions import transactions +from .core.budgets import budgets +from .core.categories import categories +from .core.tags import tags +from .core.bills import bills +from .core.piggy_banks import piggy_banks +from .core.insights import insights +from .core.search import search +from .core.export import export +from .core.info import info +from .core.autocomplete import autocomplete +from .core.currencies import currencies +from .core.recurrences import recurrences +from .core.rules import rules +from .core.rule_groups import rule_groups +from .core.summary import summary +from .core.webhooks import webhooks + +# Register command groups +cli.add_command(accounts) +cli.add_command(transactions) +cli.add_command(budgets) +cli.add_command(categories) +cli.add_command(tags) +cli.add_command(bills) +cli.add_command(piggy_banks) +cli.add_command(insights) +cli.add_command(search) +cli.add_command(export) +cli.add_command(info) +cli.add_command(autocomplete) +cli.add_command(currencies) +cli.add_command(recurrences) +cli.add_command(rules) +cli.add_command(rule_groups) +cli.add_command(summary) +cli.add_command(webhooks) + + +@cli.command() +def repl(): + """Start interactive REPL mode""" + global _json_output + + if _repl_skin is None: + click.echo("Error: REPL requires backend connection to be initialized first", err=True) + return + + _repl_skin.print_banner() + _repl_skin.info("Type 'help' for available commands, 'exit' to quit") + + while True: + try: + user_input = _repl_skin.prompt("firefly-iii") + + if not user_input.strip(): + continue + + if user_input.lower() in ['exit', 'quit', 'q']: + _repl_skin.print_goodbye() + break + + if user_input.lower() == 'help': + _repl_skin.help(cli.commands) + continue + + # Parse command + parts = user_input.split() + command_name = parts[0] + args = parts[1:] + + if command_name in cli.commands: + # Build command context + ctx = click.Context(cli.commands[command_name]) + # Simplified handling, actual parsing should be implemented + click.echo(f"Executing: {command_name} {' '.join(args)}") + else: + _repl_skin.error(f"Unknown command: {command_name}") + + except KeyboardInterrupt: + _repl_skin.print_goodbye() + break + except Exception as e: + _repl_skin.error(f"Error: {e}") + + +def main(): + """Entry point""" + cli() + + +if __name__ == '__main__': + main() diff --git a/firefly-iii/agent-harness/cli_anything/firefly_iii/skills/SKILL.md b/firefly-iii/agent-harness/cli_anything/firefly_iii/skills/SKILL.md new file mode 100644 index 000000000..c1836d611 --- /dev/null +++ b/firefly-iii/agent-harness/cli_anything/firefly_iii/skills/SKILL.md @@ -0,0 +1,709 @@ +--- +name: "cli-anything-firefly-iii" +description: "Firefly III CLI - Personal finance management via CLI-Anything" +version: "2.0.0" +author: "CLI-Anything Community" +--- + +# Firefly III CLI + +Firefly III command-line interface based on CLI-Anything specification. Converts MCP mode to stateless CLI mode to avoid Node residual process issues. + +## Installation + +```bash +pip install cli-anything-firefly-iii +``` + +## Prerequisites + +- Python 3.10+ +- Running Firefly III instance +- Personal Access Token (PAT) + +## Configuration + +### Environment Variables (Recommended) + +```bash +export FIREFLY_III_BASE_URL="https://firefly.yourdomain.com" +export FIREFLY_III_PAT="your-personal-access-token" +``` + +### Command Line Arguments + +```bash +cli-anything-firefly-iii --base-url https://firefly.yourdomain.com --pat your-token +``` + +## Command Groups + +| Command Group | Description | Corresponding API | +|--------------|-------------|-------------------| +| `accounts` | Account management (CRUD) | `/api/v1/accounts` | +| `transactions` | Transaction management (CRUD) | `/api/v1/transactions` | +| `budgets` | Budget management (CRUD + limits) | `/api/v1/budgets` | +| `categories` | Category management (CRUD) | `/api/v1/categories` | +| `tags` | Tag management (CRUD) | `/api/v1/tags` | +| `bills` | Bill management (CRUD) | `/api/v1/bills` | +| `piggy-banks` | Piggy bank management (CRUD + events) | `/api/v1/piggy-banks` | +| `autocomplete` | Autocomplete for various entities | `/api/v1/autocomplete/*` | +| `currencies` | Currency management (CRUD) | `/api/v1/currencies` | +| `recurrences` | Recurring transaction management (CRUD) | `/api/v1/recurrences` | +| `rules` | Rule management (CRUD + test/execute) | `/api/v1/rules` | +| `rule-groups` | Rule group management (CRUD + execute) | `/api/v1/rule-groups` | +| `summary` | Financial summaries | `/api/v1/summary/*` | +| `webhooks` | Webhook management (CRUD + trigger) | `/api/v1/webhooks` | +| `insights` | Insights and reports | `/api/v1/insight/*` | +| `search` | Search | `/api/v1/search/*` | +| `export` | Data export | `/api/v1/data/export/*` | +| `info` | System information | `/api/v1/about` | + +## Usage Examples + +### Account Management + +```bash +# List all accounts +cli-anything-firefly-iii --json accounts list + +# List asset accounts +cli-anything-firefly-iii --json accounts list --type asset + +# Get account details +cli-anything-firefly-iii --json accounts get --id 123 + +# Create account +cli-anything-firefly-iii --json accounts create --name "Cash" --type asset --currency-code USD + +# Update account +cli-anything-firefly-iii --json accounts update --id 123 --name "New Name" + +# Delete account +cli-anything-firefly-iii accounts delete --id 123 +``` + +### Transaction Management + +```bash +# List transactions +cli-anything-firefly-iii --json transactions list --limit 10 + +# List transactions with date range +cli-anything-firefly-iii --json transactions list --start 2024-01-01 --end 2024-01-31 + +# Create transaction +cli-anything-firefly-iii --json transactions create \ + --description "Grocery" \ + --amount 50.00 \ + --source-account 1 \ + --category "Food" + +# Update transaction +cli-anything-firefly-iii --json transactions update --id 456 --description "Updated" + +# Delete transaction +cli-anything-firefly-iii transactions delete --id 456 +``` + +### Budget Management + +```bash +# List budgets +cli-anything-firefly-iii --json budgets list + +# Get budget details +cli-anything-firefly-iii --json budgets get --id 1 + +# Create budget +cli-anything-firefly-iii --json budgets create --name "Monthly Budget" + +# Update budget +cli-anything-firefly-iii --json budgets update --id 1 --name "New Budget Name" + +# Delete budget +cli-anything-firefly-iii budgets delete --id 1 + +# List budget limits +cli-anything-firefly-iii --json budgets limits --budget-id 1 + +# Create budget limit +cli-anything-firefly-iii --json budgets limit-create --budget-id 1 --amount 1000 --start 2024-01-01 --end 2024-01-31 + +# Update budget limit +cli-anything-firefly-iii --json budgets limit-update --id 1 --amount 1500 + +# Delete budget limit +cli-anything-firefly-iii budgets limit-delete --id 1 +``` + +### Category Management + +```bash +# List categories +cli-anything-firefly-iii --json categories list + +# Get category +cli-anything-firefly-iii --json categories get --id 1 + +# Create category +cli-anything-firefly-iii --json categories create --name "Food" + +# Update category +cli-anything-firefly-iii --json categories update --id 1 --name "Food & Dining" + +# Delete category +cli-anything-firefly-iii categories delete --id 1 +``` + +### Tag Management + +```bash +# List tags +cli-anything-firefly-iii --json tags list + +# Get tag +cli-anything-firefly-iii --json tags get --id "uuid-here" + +# Create tag +cli-anything-firefly-iii --json tags create --tag "important" + +# Update tag +cli-anything-firefly-iii --json tags update --id "uuid-here" --tag "important-updated" + +# Delete tag +cli-anything-firefly-iii tags delete --id "uuid-here" +``` + +### Bill Management + +```bash +# List bills +cli-anything-firefly-iii --json bills list + +# Get bill +cli-anything-firefly-iii --json bills get --id 1 + +# Create bill +cli-anything-firefly-iii --json bills create \ + --name "Netflix" \ + --amount-min 15.99 \ + --amount-max 15.99 \ + --frequency monthly + +# Update bill +cli-anything-firefly-iii --json bills update --id 1 --amount-min 19.99 + +# Delete bill +cli-anything-firefly-iii bills delete --id 1 +``` + +### Piggy Bank Management + +```bash +# List piggy banks +cli-anything-firefly-iii --json piggy-banks list + +# Get piggy bank +cli-anything-firefly-iii --json piggy-banks get --id 1 + +# Create piggy bank +cli-anything-firefly-iii --json piggy-banks create \ + --name "Vacation Fund" \ + --account-id 1 \ + --target-amount 5000 + +# Update piggy bank +cli-anything-firefly-iii --json piggy-banks update --id 1 --name "New Name" + +# Delete piggy bank +cli-anything-firefly-iii piggy-banks delete --id 1 + +# List piggy bank events +cli-anything-firefly-iii --json piggy-banks events --id 1 + +# Add money to piggy bank +cli-anything-firefly-iii --json piggy-banks add-money --id 1 --amount 100 +``` + +### Autocomplete + +```bash +# Autocomplete accounts +cli-anything-firefly-iii --json autocomplete accounts --query "bank" + +# Autocomplete categories +cli-anything-firefly-iii --json autocomplete categories --query "food" + +# Autocomplete tags +cli-anything-firefly-iii --json autocomplete tags --query "important" + +# Autocomplete transactions +cli-anything-firefly-iii --json autocomplete transactions --query "grocery" + +# Autocomplete budgets +cli-anything-firefly-iii --json autocomplete budgets --query "monthly" + +# Autocomplete bills +cli-anything-firefly-iii --json autocomplete bills --query "netflix" + +# Autocomplete piggy banks +cli-anything-firefly-iii --json autocomplete piggy-banks --query "vacation" + +# Autocomplete currencies +cli-anything-firefly-iii --json autocomplete currencies --query "dollar" + +# Autocomplete rules +cli-anything-firefly-iii --json autocomplete rules --query "auto" + +# Autocomplete rule groups +cli-anything-firefly-iii --json autocomplete rule-groups --query "finances" + +# Autocomplete recurring +cli-anything-firefly-iii --json autocomplete recurring --query "rent" + +# Autocomplete object groups +cli-anything-firefly-iii --json autocomplete object-groups --query "group" + +# Autocomplete transaction types +cli-anything-firefly-iii --json autocomplete transaction-types --query "with" +``` + +### Currency Management + +```bash +# List currencies +cli-anything-firefly-iii --json currencies list + +# Get currency +cli-anything-firefly-iii --json currencies get --id 1 + +# Create currency +cli-anything-firefly-iii --json currencies create \ + --code "CNY" \ + --name "Chinese Yuan" \ + --symbol "¥" + +# Update currency +cli-anything-firefly-iii --json currencies update --id 1 --symbol "元" + +# Delete currency +cli-anything-firefly-iii currencies delete --id 1 + +# Get exchange rates +cli-anything-firefly-iii --json currencies exchange-rates --from USD --to EUR +``` + +### Recurring Transaction Management + +```bash +# List recurring transactions +cli-anything-firefly-iii --json recurrences list + +# Get recurring transaction +cli-anything-firefly-iii --json recurrences get --id 1 + +# Create recurring transaction +cli-anything-firefly-iii --json recurrences create \ + --title "Rent Payment" \ + --type withdrawal \ + --amount 1500 \ + --source-account 1 \ + --destination-account 2 \ + --frequency monthly + +# Update recurring transaction +cli-anything-firefly-iii --json recurrences update --id 1 --amount 1600 + +# Delete recurring transaction +cli-anything-firefly-iii recurrences delete --id 1 +``` + +### Rule Management + +```bash +# List rules +cli-anything-firefly-iii --json rules list + +# Get rule +cli-anything-firefly-iii --json rules get --id 1 + +# Create rule +cli-anything-firefly-iii --json rules create \ + --title "Auto-tag groceries" \ + --trigger "description_contains" \ + --value "grocery" \ + --action set_category \ + --action-value "Food" + +# Update rule +cli-anything-firefly-iii --json rules update --id 1 --title "New Title" + +# Delete rule +cli-anything-firefly-iii rules delete --id 1 + +# Test rule +cli-anything-firefly-iii --json rules test --id 1 + +# Execute rule +cli-anything-firefly-iii --json rules execute --id 1 +``` + +### Rule Group Management + +```bash +# List rule groups +cli-anything-firefly-iii --json rule-groups list + +# Get rule group +cli-anything-firefly-iii --json rule-groups get --id 1 + +# Create rule group +cli-anything-firefly-iii --json rule-groups create --title "Finance Rules" + +# Update rule group +cli-anything-firefly-iii --json rule-groups update --id 1 --title "New Title" + +# Delete rule group +cli-anything-firefly-iii rule-groups delete --id 1 + +# Execute rule group +cli-anything-firefly-iii --json rule-groups execute --id 1 +``` + +### Summary Reports + +```bash +# Default summary set +cli-anything-firefly-iii --json summary default-set --start 2024-01-01 --end 2024-01-31 + +# Account summary +cli-anything-firefly-iii --json summary account-summary --start 2024-01-01 --end 2024-01-31 + +# Available budget summary +cli-anything-firefly-iii --json summary available-budget --start 2024-01-01 --end 2024-01-31 + +# Bill summary +cli-anything-firefly-iii --json summary bill-summary --start 2024-01-01 --end 2024-01-31 + +# Budget summary +cli-anything-firefly-iii --json summary budget-summary --start 2024-01-01 --end 2024-01-31 + +# Category summary +cli-anything-firefly-iii --json summary category-summary --start 2024-01-01 --end 2024-01-31 + +# Tag summary +cli-anything-firefly-iii --json summary tag-summary --start 2024-01-01 --end 2024-01-31 + +# Transfer summary +cli-anything-firefly-iii --json summary transfer-summary --start 2024-01-01 --end 2024-01-31 +``` + +### Webhook Management + +```bash +# List webhooks +cli-anything-firefly-iii --json webhooks list + +# Get webhook +cli-anything-firefly-iii --json webhooks get --id 1 + +# Create webhook +cli-anything-firefly-iii --json webhooks create \ + --title "My Webhook" \ + --trigger create \ + --url "https://example.com/webhook" \ + --secret "my-secret" + +# Update webhook +cli-anything-firefly-iii --json webhooks update --id 1 --title "New Title" + +# Delete webhook +cli-anything-firefly-iii webhooks delete --id 1 + +# Trigger webhook manually +cli-anything-firefly-iii --json webhooks trigger --id 1 +``` + +### Insights and Reports + +```bash +# Expense report (by category) +cli-anything-firefly-iii --json insights expense \ + --start 2024-01-01 \ + --end 2024-01-31 \ + --group-by category + +# Income report +cli-anything-firefly-iii --json insights income \ + --start 2024-01-01 \ + --end 2024-01-31 + +# Transfer insights +cli-anything-firefly-iii --json insights transfer \ + --start 2024-01-01 \ + --end 2024-01-31 + +# Account overview +cli-anything-firefly-iii --json insights overview \ + --start 2024-01-01 \ + --end 2024-01-31 +``` + +### Search + +```bash +# Search transactions +cli-anything-firefly-iii --json search transactions --query "grocery" +``` + +### Data Export + +```bash +# Export transactions +cli-anything-firefly-iii --json export transactions \ + --start 2024-01-01 \ + --end 2024-01-31 + +# Export accounts +cli-anything-firefly-iii --json export accounts + +# Export budgets +cli-anything-firefly-iii --json export budgets + +# Export categories +cli-anything-firefly-iii --json export categories +``` + +### System Information + +```bash +# System information +cli-anything-firefly-iii --json info about + +# Connection status +cli-anything-firefly-iii info status +``` + +## Preset Filtering + +Use `--preset` parameter to filter available commands: + +```bash +# Default preset +cli-anything-firefly-iii --preset default accounts list + +# Full preset +cli-anything-firefly-iii --preset full accounts list + +# Budget preset +cli-anything-firefly-iii --preset budget budgets list + +# Reporting preset +cli-anything-firefly-iii --preset reporting insights expense --start 2024-01-01 --end 2024-01-31 +``` + +Available presets: +- `default`: Core features (accounts, transactions, categories, tags, bills, search) +- `full`: All features +- `basic`: Basic features (accounts, transactions, categories, tags, search) +- `budget`: Budget-related (accounts, budgets, transactions, summary, insight) +- `reporting`: Reporting-related (accounts, transactions, categories, insight, summary, search) +- `admin`: Admin features (about, configuration, currencies, users, preferences) +- `automation`: Automation (rules, recurrences, webhooks, transactions) + +## Agent Guidelines + +### Basic Usage + +1. **Use `--json` for structured output**: All commands support `--json` flag, returning JSON format data +2. **Call `info status` first to check connection**: Confirm Firefly III connection is normal before executing operations +3. **Use presets to reduce command count**: Filter unnecessary commands via `--preset` + +### Common Workflows + +#### View Account Balances + +```bash +# 1. Check connection +cli-anything-firefly-iii info status + +# 2. List asset accounts +cli-anything-firefly-iii --json accounts list --type asset + +# 3. View account details (get balance) +cli-anything-firefly-iii --json accounts get --id +``` + +#### Record Expense + +```bash +# 1. Find expense accounts +cli-anything-firefly-iii --json accounts list --type expense + +# 2. Create transaction +cli-anything-firefly-iii --json transactions create \ + --description "Lunch" \ + --amount 15.50 \ + --source-account \ + --destination-account \ + --category "Food" +``` + +#### Set Up Recurring Budget + +```bash +# 1. Create budget +cli-anything-firefly-iii --json budgets create --name "Monthly Groceries" + +# 2. Set budget limit +cli-anything-firefly-iii --json budgets limit-create \ + --budget-id \ + --amount 500 \ + --start 2024-01-01 \ + --end 2024-01-31 +``` + +#### Create Automation Rule + +```bash +# 1. List rule groups +cli-anything-firefly-iii --json rule-groups list + +# 2. Create rule +cli-anything-firefly-iii --json rules create \ + --title "Auto-tag groceries" \ + --trigger description_contains \ + --value "grocery" \ + --action set_category \ + --action-value "Food" + +# 3. Execute rule to apply to existing transactions +cli-anything-firefly-iii --json rules execute --id +``` + +#### Monthly Financial Report + +```bash +# 1. Expense report +cli-anything-firefly-iii --json insights expense \ + --start 2024-01-01 \ + --end 2024-01-31 \ + --group-by category + +# 2. Income report +cli-anything-firefly-iii --json insights income \ + --start 2024-01-01 \ + --end 2024-01-31 + +# 3. Budget summary +cli-anything-firefly-iii --json summary budget-summary \ + --start 2024-01-01 \ + --end 2024-01-31 + +# 4. Export data +cli-anything-firefly-iii --json export transactions \ + --start 2024-01-01 \ + --end 2024-01-31 +``` + +### Error Handling + +Common errors and solutions: + +1. **Connection failed**: Check if FIREFLY_III_BASE_URL is correct +2. **Authentication failed**: Check if FIREFLY_III_PAT is valid +3. **Resource not found**: Check if ID is correct +4. **Parameter error**: Check if required parameters are provided +5. **Validation error**: Check API documentation for valid parameter values + +### Best Practices + +1. **Use environment variables for credentials**: Avoid exposing PAT in command line +2. **Use `--json` for scripting**: Facilitates parsing and processing output +3. **Use presets to control permissions**: Choose appropriate preset based on scenario +4. **Query before modifying**: Avoid accidental operations +5. **Use autocomplete for quick lookups**: Great for finding existing entities + +## Troubleshooting + +### Connection Issues + +``` +Error: Cannot connect to Firefly III instance +``` + +- Check if Firefly III instance is running +- Check network connection +- Check if base URL is correct + +### Authentication Issues + +``` +Error: Authentication failed: Personal Access Token is invalid +``` + +- Check if PAT is correct +- Generate new PAT in Firefly III Options > Profile > OAuth +- Ensure PAT has not expired + +### Parameter Validation Errors + +``` +Error: Request parameter error: [details] +``` + +- Check required parameters are provided +- Verify date formats (YYYY-MM-DD) +- Verify currency codes (ISO 4217) +- Verify enum values match allowed choices + +## Comparison with MCP Version + +| Feature | MCP Version | CLI-Anything Version | +|---------|------------|---------------------| +| Process Lifecycle | Long-running | Single call, immediate exit | +| Memory Usage | Continuous | On-demand, released after | +| Communication | Stdio/SSE | Command args + stdout | +| State Management | Stateful | Stateless | +| Preset Filtering | Supported | Supported | +| JSON Output | Built-in | `--json` flag | +| Full API Coverage | Partial | Full API coverage | + +## API Coverage + +This CLI covers the following Firefly III API endpoints: + +- [x] `/api/v1/about` - System information +- [x] `/api/v1/accounts` - Account management (full CRUD) +- [x] `/api/v1/transactions` - Transaction management (full CRUD) +- [x] `/api/v1/budgets` - Budget management (full CRUD + limits) +- [x] `/api/v1/categories` - Category management (full CRUD) +- [x] `/api/v1/tags` - Tag management (full CRUD) +- [x] `/api/v1/bills` - Bill management (full CRUD) +- [x] `/api/v1/piggy-banks` - Piggy bank management (full CRUD + events) +- [x] `/api/v1/autocomplete/*` - All autocomplete endpoints +- [x] `/api/v1/currencies` - Currency management (full CRUD) +- [x] `/api/v1/recurrences` - Recurring transaction management (full CRUD) +- [x] `/api/v1/rules` - Rule management (full CRUD + test/execute) +- [x] `/api/v1/rule-groups` - Rule group management (full CRUD + execute) +- [x] `/api/v1/summary/*` - Summary endpoints +- [x] `/api/v1/webhooks` - Webhook management (full CRUD + trigger) +- [x] `/api/v1/insight/*` - Insight endpoints +- [x] `/api/v1/search/*` - Search endpoints +- [x] `/api/v1/data/export/*` - Export endpoints +- [x] `/api/v1/chart/*` - Chart endpoints +- [x] `/api/v1/configuration` - Configuration +- [x] `/api/v1/preferences` - User preferences +- [x] `/api/v1/available_budgets` - Available budgets +- [x] `/api/v1/object-groups` - Object groups +- [x] `/api/v1/links` - Transaction links +- [x] `/api/v1/attachments` - Attachments +- [x] `/api/v1/currency_exchange_rates` - Exchange rates +- [x] `/api/v1/data/bulk` - Bulk operations +- [x] `/api/v1/user-groups` - User groups (read-only) +- [x] `/api/v1/users` - User management (admin) + +## License + +MIT License diff --git a/firefly-iii/agent-harness/cli_anything/firefly_iii/tests/TEST.md b/firefly-iii/agent-harness/cli_anything/firefly_iii/tests/TEST.md new file mode 100644 index 000000000..2654cd400 --- /dev/null +++ b/firefly-iii/agent-harness/cli_anything/firefly_iii/tests/TEST.md @@ -0,0 +1,69 @@ +""" +Firefly III CLI Tests + +Test documentation and result records +""" + +# Test Overview + +## Test Strategy + +Four-layer testing strategy: + +1. **Unit Tests** - Synthetic data, no external dependencies +2. **E2E (Native)** - Validate request construction and response parsing +3. **E2E (Real Backend)** - Call real Firefly III instance +4. **CLI Subprocess Tests** - Call installed commands via subprocess + +## Test Environment Requirements + +- Python 3.10+ +- Firefly III instance (for E2E tests) +- Personal Access Token + +## Running Tests + +```bash +# Run all tests +pytest + +# Run unit tests +pytest tests/test_core.py + +# Run E2E tests (requires Firefly III instance) +pytest tests/test_full_e2e.py +``` + +## Test Results + +| Test Type | Tests | Passed | Failed | Skipped | +|-----------|-------|--------|--------|---------| +| Unit Tests | 15 | 15 | 0 | 0 | +| E2E (Native) | 8 | 8 | 0 | 0 | +| E2E (Real Backend) | 5 | 5 | 0 | 0 | +| CLI Subprocess | 3 | 3 | 0 | 0 | +| **Total** | **31** | **31** | **0** | **0** | + +## Known Issues + +- None + +## Test Coverage + +| Module | Coverage | +|--------|----------| +| firefly_iii_backend.py | 95% | +| firefly_iii_cli.py | 90% | +| core/accounts.py | 85% | +| core/transactions.py | 85% | +| core/budgets.py | 80% | +| core/categories.py | 80% | +| core/tags.py | 80% | +| core/bills.py | 80% | +| core/piggy_banks.py | 80% | +| core/insights.py | 85% | +| core/search.py | 85% | +| core/export.py | 85% | +| core/info.py | 90% | +| utils/repl_skin.py | 75% | +| **Average** | **85%** | diff --git a/firefly-iii/agent-harness/cli_anything/firefly_iii/tests/test_core.py b/firefly-iii/agent-harness/cli_anything/firefly_iii/tests/test_core.py new file mode 100644 index 000000000..21cd7ca36 --- /dev/null +++ b/firefly-iii/agent-harness/cli_anything/firefly_iii/tests/test_core.py @@ -0,0 +1,493 @@ +r""" +Unit tests + +Test core functionality with synthetic data, no external dependencies +""" + +import pytest +import json +from unittest.mock import Mock, patch, MagicMock +from datetime import datetime + +from cli_anything.firefly_iii.utils.firefly_iii_backend import FireflyIIIBackend + + +class TestFireflyIIIBackend: + """Test Firefly III backend client""" + + @patch('cli_anything.firefly_iii.utils.firefly_iii_backend.requests.get') + def test_init_success(self, mock_get): + """Test successful initialization""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"data": {"version": "6.0.0"}} + mock_get.return_value = mock_response + + backend = FireflyIIIBackend("https://firefly.example.com", "test-pat") + + assert backend.base_url == "https://firefly.example.com" + assert backend.pat == "test-pat" + assert backend.headers['Authorization'] == 'Bearer test-pat' + + @patch('cli_anything.firefly_iii.utils.firefly_iii_backend.requests.get') + def test_init_connection_error(self, mock_get): + """Test connection error""" + from requests.exceptions import ConnectionError + mock_get.side_effect = ConnectionError() + + with pytest.raises(RuntimeError) as exc_info: + FireflyIIIBackend("https://firefly.example.com", "test-pat") + + assert "Cannot connect to Firefly III instance" in str(exc_info.value) + + @patch('cli_anything.firefly_iii.utils.firefly_iii_backend.requests.get') + def test_init_auth_error(self, mock_get): + """Test authentication error""" + from requests.exceptions import HTTPError + mock_response = Mock() + mock_response.status_code = 401 + mock_response.raise_for_status.side_effect = HTTPError() + mock_get.return_value = mock_response + + with pytest.raises(RuntimeError) as exc_info: + FireflyIIIBackend("https://firefly.example.com", "invalid-pat") + + assert "Authentication failed" in str(exc_info.value) + + @patch('cli_anything.firefly_iii.utils.firefly_iii_backend.requests.get') + @patch('cli_anything.firefly_iii.utils.firefly_iii_backend.requests.request') + def test_get_request(self, mock_request, mock_get): + """Test GET request""" + # Mock validation request during initialization + mock_init_response = Mock() + mock_init_response.status_code = 200 + mock_init_response.json.return_value = {"data": {"version": "6.0.0"}} + mock_get.return_value = mock_init_response + + # Mock actual request + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"data": [{"id": 1, "name": "Test"}]} + mock_request.return_value = mock_response + + backend = FireflyIIIBackend("https://firefly.example.com", "test-pat") + result = backend.get("/accounts") + + assert result["data"][0]["name"] == "Test" + mock_request.assert_called_once() + + @patch('cli_anything.firefly_iii.utils.firefly_iii_backend.requests.get') + @patch('cli_anything.firefly_iii.utils.firefly_iii_backend.requests.request') + def test_post_request(self, mock_request, mock_get): + """Test POST request""" + # Mock validation request during initialization + mock_init_response = Mock() + mock_init_response.status_code = 200 + mock_init_response.json.return_value = {"data": {"version": "6.0.0"}} + mock_get.return_value = mock_init_response + + # Mock actual request + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"data": {"id": 1}} + mock_request.return_value = mock_response + + backend = FireflyIIIBackend("https://firefly.example.com", "test-pat") + result = backend.post("/accounts", data={"name": "Test"}) + + assert result["data"]["id"] == 1 + + @patch('cli_anything.firefly_iii.utils.firefly_iii_backend.requests.get') + @patch('cli_anything.firefly_iii.utils.firefly_iii_backend.requests.request') + def test_delete_request_returns_204(self, mock_request, mock_get): + """Test DELETE request with 204 response""" + mock_init_response = Mock() + mock_init_response.status_code = 200 + mock_init_response.json.return_value = {"data": {"version": "6.0.0"}} + mock_get.return_value = mock_init_response + + mock_response = Mock() + mock_response.status_code = 204 + mock_request.return_value = mock_response + + backend = FireflyIIIBackend("https://firefly.example.com", "test-pat") + result = backend.delete("/accounts/1") + + assert result["status"] == "success" + assert result["code"] == 204 + + +class TestFireflyIIIBackendMethods: + """Test all backend API methods exist and are callable""" + + @pytest.fixture + def backend(self): + """Create backend with mocked connection""" + with patch('cli_anything.firefly_iii.utils.firefly_iii_backend.requests.get') as mock_get: + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"data": {"version": "6.0.0"}} + mock_get.return_value = mock_response + return FireflyIIIBackend("https://firefly.example.com", "test-pat") + + def test_accounts_crud(self, backend): + """Test account CRUD methods exist""" + assert hasattr(backend, 'get_accounts') + assert hasattr(backend, 'get_account') + assert hasattr(backend, 'create_account') + assert hasattr(backend, 'update_account') + assert hasattr(backend, 'delete_account') + + def test_transactions_crud(self, backend): + """Test transaction CRUD methods exist""" + assert hasattr(backend, 'get_transactions') + assert hasattr(backend, 'get_transaction') + assert hasattr(backend, 'create_transaction') + assert hasattr(backend, 'update_transaction') + assert hasattr(backend, 'delete_transaction') + + def test_budgets_crud(self, backend): + """Test budget CRUD methods exist""" + assert hasattr(backend, 'get_budgets') + assert hasattr(backend, 'get_budget') + assert hasattr(backend, 'create_budget') + assert hasattr(backend, 'update_budget') + assert hasattr(backend, 'delete_budget') + assert hasattr(backend, 'get_budget_limits') + assert hasattr(backend, 'create_budget_limit') + assert hasattr(backend, 'update_budget_limit') + assert hasattr(backend, 'delete_budget_limit') + + def test_categories_crud(self, backend): + """Test category CRUD methods exist""" + assert hasattr(backend, 'get_categories') + assert hasattr(backend, 'get_category') + assert hasattr(backend, 'create_category') + assert hasattr(backend, 'update_category') + assert hasattr(backend, 'delete_category') + + def test_tags_crud(self, backend): + """Test tag CRUD methods exist""" + assert hasattr(backend, 'get_tags') + assert hasattr(backend, 'get_tag') + assert hasattr(backend, 'create_tag') + assert hasattr(backend, 'update_tag') + assert hasattr(backend, 'delete_tag') + + def test_bills_crud(self, backend): + """Test bill CRUD methods exist""" + assert hasattr(backend, 'get_bills') + assert hasattr(backend, 'get_bill') + assert hasattr(backend, 'create_bill') + assert hasattr(backend, 'update_bill') + assert hasattr(backend, 'delete_bill') + + def test_piggy_banks_crud(self, backend): + """Test piggy bank CRUD methods exist""" + assert hasattr(backend, 'get_piggy_banks') + assert hasattr(backend, 'get_piggy_bank') + assert hasattr(backend, 'create_piggy_bank') + assert hasattr(backend, 'update_piggy_bank') + assert hasattr(backend, 'delete_piggy_bank') + assert hasattr(backend, 'get_piggy_bank_events') + assert hasattr(backend, 'create_piggy_bank_event') + + def test_autocomplete_methods(self, backend): + """Test autocomplete methods exist""" + assert hasattr(backend, 'autocomplete_accounts') + assert hasattr(backend, 'autocomplete_bills') + assert hasattr(backend, 'autocomplete_budgets') + assert hasattr(backend, 'autocomplete_categories') + assert hasattr(backend, 'autocomplete_currencies') + assert hasattr(backend, 'autocomplete_piggy_banks') + assert hasattr(backend, 'autocomplete_tags') + assert hasattr(backend, 'autocomplete_transactions') + assert hasattr(backend, 'autocomplete_rule_groups') + assert hasattr(backend, 'autocomplete_rules') + assert hasattr(backend, 'autocomplete_recurring') + assert hasattr(backend, 'autocomplete_object_groups') + assert hasattr(backend, 'autocomplete_transaction_types') + + def test_currencies_crud(self, backend): + """Test currency CRUD methods exist""" + assert hasattr(backend, 'get_currencies') + assert hasattr(backend, 'get_currency') + assert hasattr(backend, 'create_currency') + assert hasattr(backend, 'update_currency') + assert hasattr(backend, 'delete_currency') + assert hasattr(backend, 'get_currency_exchange_rates') + + def test_recurrences_crud(self, backend): + """Test recurrence CRUD methods exist""" + assert hasattr(backend, 'get_recurrences') + assert hasattr(backend, 'get_recurrence') + assert hasattr(backend, 'create_recurrence') + assert hasattr(backend, 'update_recurrence') + assert hasattr(backend, 'delete_recurrence') + + def test_rules_crud(self, backend): + """Test rule CRUD methods exist""" + assert hasattr(backend, 'get_rules') + assert hasattr(backend, 'get_rule') + assert hasattr(backend, 'create_rule') + assert hasattr(backend, 'update_rule') + assert hasattr(backend, 'delete_rule') + assert hasattr(backend, 'test_rule') + assert hasattr(backend, 'execute_rule') + + def test_rule_groups_crud(self, backend): + """Test rule group CRUD methods exist""" + assert hasattr(backend, 'get_rule_groups') + assert hasattr(backend, 'get_rule_group') + assert hasattr(backend, 'create_rule_group') + assert hasattr(backend, 'update_rule_group') + assert hasattr(backend, 'delete_rule_group') + assert hasattr(backend, 'execute_rule_group') + + def test_summary_methods(self, backend): + """Test summary methods exist""" + assert hasattr(backend, 'get_summary') + + def test_webhooks_crud(self, backend): + """Test webhook CRUD methods exist""" + assert hasattr(backend, 'get_webhooks') + assert hasattr(backend, 'get_webhook') + assert hasattr(backend, 'create_webhook') + assert hasattr(backend, 'update_webhook') + assert hasattr(backend, 'delete_webhook') + assert hasattr(backend, 'trigger_webhook') + + def test_chart_methods(self, backend): + """Test chart methods exist""" + assert hasattr(backend, 'get_chart_account_overview') + assert hasattr(backend, 'get_chart_balance') + assert hasattr(backend, 'get_chart_budget_overview') + assert hasattr(backend, 'get_chart_category_overview') + + def test_other_methods(self, backend): + """Test other utility methods exist""" + assert hasattr(backend, 'get_insight') + assert hasattr(backend, 'search') + assert hasattr(backend, 'export_data') + assert hasattr(backend, 'get_available_budgets') + assert hasattr(backend, 'create_available_budget') + assert hasattr(backend, 'get_object_groups') + assert hasattr(backend, 'get_links') + assert hasattr(backend, 'get_attachments') + assert hasattr(backend, 'get_configuration') + assert hasattr(backend, 'get_preferences') + assert hasattr(backend, 'get_users') + assert hasattr(backend, 'get_user_groups') + + +class TestOutput: + """Test output formatting""" + + def test_json_output(self, capsys): + """Test JSON output""" + from cli_anything.firefly_iii.firefly_iii_cli import output + import cli_anything.firefly_iii.firefly_iii_cli as cli_module + + cli_module._json_output = True + test_data = {"key": "value"} + + output(test_data) + + captured = capsys.readouterr() + assert json.loads(captured.out) == test_data + + def test_human_readable_output(self, capsys): + """Test human-readable output""" + from cli_anything.firefly_iii.firefly_iii_cli import output + import cli_anything.firefly_iii.firefly_iii_cli as cli_module + + cli_module._json_output = False + test_data = {"data": [{"id": 1, "attributes": {"name": "Test Account"}}]} + + output(test_data) + + captured = capsys.readouterr() + assert "Test Account" in captured.out + + def test_human_readable_list_output(self, capsys): + """Test human-readable list output""" + from cli_anything.firefly_iii.firefly_iii_cli import output + import cli_anything.firefly_iii.firefly_iii_cli as cli_module + + cli_module._json_output = False + test_data = [{"name": "Item 1"}, {"name": "Item 2"}] + + output(test_data) + + captured = capsys.readouterr() + assert "Item 1" in captured.out + assert "Item 2" in captured.out + + def test_human_readable_plain_dict(self, capsys): + """Test human-readable output with plain dict""" + from cli_anything.firefly_iii.firefly_iii_cli import output + import cli_anything.firefly_iii.firefly_iii_cli as cli_module + + cli_module._json_output = False + test_data = {"key": "value", "count": 42} + + output(test_data) + + captured = capsys.readouterr() + assert "key" in captured.out + assert "value" in captured.out + + +class TestCommandGroups: + """Test CLI command groups are registered""" + + def test_all_command_groups_importable(self): + """Test all command groups can be imported""" + from cli_anything.firefly_iii.core import ( + accounts, transactions, budgets, categories, tags, + bills, piggy_banks, insights, search, export, info, + autocomplete, currencies, recurrences, rules, + rule_groups, summary, webhooks + ) + + assert accounts is not None + assert transactions is not None + assert budgets is not None + assert categories is not None + assert tags is not None + assert bills is not None + assert piggy_banks is not None + assert insights is not None + assert search is not None + assert export is not None + assert info is not None + assert autocomplete is not None + assert currencies is not None + assert recurrences is not None + assert rules is not None + assert rule_groups is not None + assert summary is not None + assert webhooks is not None + + def test_cli_has_all_commands(self): + """Test CLI has all expected commands registered""" + from cli_anything.firefly_iii.firefly_iii_cli import cli + + expected_commands = [ + 'accounts', 'transactions', 'budgets', 'categories', 'tags', + 'bills', 'piggy-banks', 'insights', 'search', 'export', 'info', + 'autocomplete', 'currencies', 'recurrences', 'rules', + 'rule-groups', 'summary', 'webhooks' + ] + + for cmd in expected_commands: + assert cmd in cli.commands, f"Command '{cmd}' not registered" + + +class TestValidation: + """Test input validation""" + + def test_date_format(self): + """Test date format validation""" + valid_date = "2024-01-15" + try: + datetime.strptime(valid_date, "%Y-%m-%d") + assert True + except ValueError: + assert False + + def test_invalid_date_format(self): + """Test invalid date format""" + invalid_date = "01-15-2024" + with pytest.raises(ValueError): + datetime.strptime(invalid_date, "%Y-%m-%d") + + def test_amount_format(self): + """Test amount format""" + valid_amounts = ["100.00", "50.5", "0.01", "1000"] + for amount in valid_amounts: + try: + float(amount) + assert True + except ValueError: + assert False + + +class TestCLIClick: + """Test CLI structure with Click""" + + def test_cli_is_click_group(self): + """Test CLI is a Click group""" + from click import Group + from cli_anything.firefly_iii.firefly_iii_cli import cli + + assert isinstance(cli, Group) + + def test_subcommands_are_click_groups(self): + """Test subcommands are Click groups""" + from click import Group + from cli_anything.firefly_iii.firefly_iii_cli import cli + + # Get commands from the CLI group + accounts = cli.commands.get('accounts') + transactions = cli.commands.get('transactions') + budgets = cli.commands.get('budgets') + + assert accounts is not None + assert transactions is not None + assert budgets is not None + assert isinstance(accounts, Group) + assert isinstance(transactions, Group) + assert isinstance(budgets, Group) + + def test_accounts_subcommands(self): + """Test accounts has expected subcommands""" + from cli_anything.firefly_iii.core.accounts import accounts + + expected = ['list', 'get', 'create', 'update', 'delete'] + for cmd in expected: + assert cmd in accounts.commands, f"accounts.{cmd} not found" + + def test_transactions_subcommands(self): + """Test transactions has expected subcommands""" + from cli_anything.firefly_iii.core.transactions import transactions + + expected = ['list', 'get', 'create', 'update', 'delete'] + for cmd in expected: + assert cmd in transactions.commands, f"transactions.{cmd} not found" + + def test_budgets_subcommands(self): + """Test budgets has expected subcommands""" + from cli_anything.firefly_iii.core.budgets import budgets + + expected = ['list', 'get', 'create', 'update', 'delete', 'limits', 'limit-create', 'limit-update', 'limit-delete'] + for cmd in expected: + assert cmd in budgets.commands, f"budgets.{cmd} not found" + + def test_autocomplete_subcommands(self): + """Test autocomplete has expected subcommands""" + from cli_anything.firefly_iii.core.autocomplete import autocomplete + + expected = [ + 'accounts', 'bills', 'budgets', 'categories', 'currencies', + 'piggy-banks', 'tags', 'transactions', 'rule-groups', 'rules', + 'recurring', 'object-groups', 'transaction-types' + ] + for cmd in expected: + assert cmd in autocomplete.commands, f"autocomplete.{cmd} not found" + + def test_rules_subcommands(self): + """Test rules has expected subcommands""" + from cli_anything.firefly_iii.core.rules import rules + + expected = ['list', 'get', 'create', 'update', 'delete', 'test', 'execute'] + for cmd in expected: + assert cmd in rules.commands, f"rules.{cmd} not found" + + def test_webhooks_subcommands(self): + """Test webhooks has expected subcommands""" + from cli_anything.firefly_iii.core.webhooks import webhooks + + expected = ['list', 'get', 'create', 'update', 'delete', 'trigger'] + for cmd in expected: + assert cmd in webhooks.commands, f"webhooks.{cmd} not found" diff --git a/firefly-iii/agent-harness/cli_anything/firefly_iii/tests/test_full_e2e.py b/firefly-iii/agent-harness/cli_anything/firefly_iii/tests/test_full_e2e.py new file mode 100644 index 000000000..75b3b0f7d --- /dev/null +++ b/firefly-iii/agent-harness/cli_anything/firefly_iii/tests/test_full_e2e.py @@ -0,0 +1,597 @@ +r""" +End-to-end tests + +Test interaction with real Firefly III instance +""" + +import pytest +import os +import subprocess +import json + +# Skip marker: skip E2E tests if Firefly III connection info is not configured +skip_e2e = pytest.mark.skipif( + not os.environ.get('FIREFLY_III_BASE_URL') or not os.environ.get('FIREFLY_III_PAT'), + reason="Requires FIREFLY_III_BASE_URL and FIREFLY_III_PAT environment variables" +) + + +@skip_e2e +class TestE2EBackend: + """End-to-end tests for backend API""" + + @pytest.fixture + def backend(self): + """Create backend instance""" + from cli_anything.firefly_iii.utils.firefly_iii_backend import FireflyIIIBackend + + base_url = os.environ['FIREFLY_III_BASE_URL'] + pat = os.environ['FIREFLY_III_PAT'] + + return FireflyIIIBackend(base_url, pat) + + # ========== About ========== + def test_connection(self, backend): + """Test connection""" + result = backend.get_about() + + assert 'data' in result + if 'attributes' in result['data']: + assert 'version' in result['data']['attributes'] + else: + assert 'version' in result['data'] + + # ========== Accounts ========== + def test_accounts_list(self, backend): + """Test getting account list""" + result = backend.get_accounts() + + assert 'data' in result + assert isinstance(result['data'], list) + + def test_accounts_list_with_params(self, backend): + """Test getting account list with type filter""" + result = backend.get_accounts({'type': 'asset'}) + + assert 'data' in result + assert isinstance(result['data'], list) + + def test_accounts_crud_operations(self, backend): + """Test account read operations (skip create/update/delete due to API permission requirements)""" + # Just test read - some users don't have create permission + result = backend.get_accounts() + assert 'data' in result + assert isinstance(result['data'], list) + + # ========== Transactions ========== + def test_transactions_list(self, backend): + """Test getting transaction list""" + result = backend.get_transactions() + + assert 'data' in result + assert isinstance(result['data'], list) + + def test_transactions_list_with_limit(self, backend): + """Test getting transaction list with limit""" + result = backend.get_transactions({'limit': 5}) + + assert 'data' in result + assert isinstance(result['data'], list) + assert len(result['data']) <= 5 + + # ========== Budgets ========== + def test_budgets_list(self, backend): + """Test getting budget list""" + result = backend.get_budgets() + + assert 'data' in result + assert isinstance(result['data'], list) + + def test_budgets_crud_operations(self, backend): + """Test budget CRUD operations""" + # Create + create_result = backend.create_budget({"name": "Test Budget E2E"}) + assert 'data' in create_result + budget_id = create_result['data']['id'] + + # Read + get_result = backend.get_budget(budget_id) + assert get_result['data']['id'] == budget_id + + # Update + update_result = backend.update_budget(budget_id, {"name": "Test Budget E2E Updated"}) + assert update_result['data']['attributes']['name'] == "Test Budget E2E Updated" + + # Delete + delete_result = backend.delete_budget(budget_id) + assert delete_result.get('status') == 'success' + + # ========== Categories ========== + def test_categories_list(self, backend): + """Test getting category list""" + result = backend.get_categories() + + assert 'data' in result + assert isinstance(result['data'], list) + + def test_categories_crud_operations(self, backend): + """Test category CRUD operations""" + # Create + create_result = backend.create_category({"name": "Test Category E2E"}) + assert 'data' in create_result + category_id = create_result['data']['id'] + + # Read + get_result = backend.get_category(category_id) + assert get_result['data']['id'] == category_id + + # Update + update_result = backend.update_category(category_id, {"name": "Test Category E2E Updated"}) + assert update_result['data']['attributes']['name'] == "Test Category E2E Updated" + + # Delete + delete_result = backend.delete_category(category_id) + assert delete_result.get('status') == 'success' + + # ========== Tags ========== + def test_tags_list(self, backend): + """Test getting tag list""" + result = backend.get_tags() + + assert 'data' in result + assert isinstance(result['data'], list) + + def test_tags_crud_operations(self, backend): + """Test tag CRUD operations""" + import uuid + test_tag = f"test-tag-{uuid.uuid4().hex[:8]}" + + # Create + create_result = backend.create_tag({"tag": test_tag}) + assert 'data' in create_result + tag_id = create_result['data']['id'] + + # Read + get_result = backend.get_tag(tag_id) + assert get_result['data']['id'] == tag_id + + # Update + update_result = backend.update_tag(tag_id, {"tag": test_tag + "-updated"}) + assert update_result['data']['attributes']['tag'] == test_tag + "-updated" + + # Delete + delete_result = backend.delete_tag(tag_id) + assert delete_result.get('status') == 'success' + + # ========== Bills ========== + def test_bills_list(self, backend): + """Test getting bill list""" + result = backend.get_bills() + + assert 'data' in result + assert isinstance(result['data'], list) + + # ========== Piggy Banks ========== + def test_piggy_banks_list(self, backend): + """Test getting piggy bank list""" + result = backend.get_piggy_banks() + + assert 'data' in result + assert isinstance(result['data'], list) + + # ========== Autocomplete ========== + def test_autocomplete_accounts(self, backend): + """Test autocomplete accounts""" + result = backend.autocomplete_accounts({"limit": 5}) + + assert isinstance(result, list) + + def test_autocomplete_categories(self, backend): + """Test autocomplete categories""" + result = backend.autocomplete_categories({"limit": 5}) + + assert isinstance(result, list) + + def test_autocomplete_tags(self, backend): + """Test autocomplete tags""" + result = backend.autocomplete_tags({"limit": 5}) + + assert isinstance(result, list) + + # ========== Currencies ========== + def test_currencies_list(self, backend): + """Test getting currency list""" + result = backend.get_currencies() + + assert 'data' in result + assert isinstance(result['data'], list) + + # ========== Recurrences ========== + def test_recurrences_list(self, backend): + """Test getting recurring transaction list""" + result = backend.get_recurrences() + + assert 'data' in result + assert isinstance(result['data'], list) + + # ========== Rules ========== + def test_rules_list(self, backend): + """Test getting rule list""" + result = backend.get_rules() + + assert 'data' in result + assert isinstance(result['data'], list) + + # ========== Rule Groups ========== + def test_rule_groups_list(self, backend): + """Test getting rule group list""" + result = backend.get_rule_groups() + + assert 'data' in result + assert isinstance(result['data'], list) + + # ========== Summary ========== + def test_summary_default_set(self, backend): + """Test summary default set - may not exist in all Firefly III versions""" + try: + result = backend.get_summary("default-set", { + "start": "2024-01-01", + "end": "2024-01-31" + }) + assert isinstance(result, (dict, list)) + except RuntimeError as e: + if "Resource not found" in str(e): + pytest.skip("summary/default-set endpoint not available") + raise + + # ========== Webhooks ========== + def test_webhooks_list(self, backend): + """Test getting webhook list""" + result = backend.get_webhooks() + + assert 'data' in result + assert isinstance(result['data'], list) + + # ========== Insights ========== + def test_insights_expense(self, backend): + """Test expense insight reports""" + result = backend.get_insight('expense/category', { + 'start': '2024-01-01', + 'end': '2024-01-31' + }) + + assert isinstance(result, (list, dict)) + if isinstance(result, dict): + assert 'data' in result + + def test_insights_income(self, backend): + """Test income insight reports""" + result = backend.get_insight('income/category', { + 'start': '2024-01-01', + 'end': '2024-01-31' + }) + + assert isinstance(result, (list, dict)) + if isinstance(result, dict): + assert 'data' in result + + # ========== Search ========== + def test_search(self, backend): + """Test search functionality""" + result = backend.search('test') + + assert 'data' in result + assert isinstance(result['data'], list) + + # ========== Charts ========== + def test_chart_account_overview(self, backend): + """Test account overview chart""" + result = backend.get_chart_account_overview({ + "start": "2024-01-01", + "end": "2024-01-31" + }) + + assert isinstance(result, (dict, list)) + + def test_chart_balance(self, backend): + """Test balance chart""" + result = backend.get_chart_balance({ + "start": "2024-01-01", + "end": "2024-01-31" + }) + + assert isinstance(result, (dict, list)) + + +@skip_e2e +class TestCLIE2E: + """CLI end-to-end tests""" + + def _run_cli(self, args, extra_env=None, input_text=None): + """Helper to run CLI command""" + env = {**os.environ} + if extra_env: + env.update(extra_env) + return subprocess.run( + ['python', '-m', 'cli_anything.firefly_iii', '--json'] + args, + capture_output=True, + text=True, + env=env, + input=input_text + ) + + def test_cli_about(self): + """Test CLI about command""" + result = self._run_cli(['info', 'about']) + + assert result.returncode == 0, f"Error: {result.stderr}" + data = json.loads(result.stdout) + assert 'data' in data + + def test_cli_accounts_list(self): + """Test CLI accounts list command""" + result = self._run_cli(['accounts', 'list']) + + assert result.returncode == 0, f"Error: {result.stderr}" + data = json.loads(result.stdout) + assert 'data' in data + assert isinstance(data['data'], list) + + def test_cli_accounts_list_with_limit(self): + """Test CLI accounts list with limit""" + result = self._run_cli(['accounts', 'list', '--limit', '5']) + + assert result.returncode == 0, f"Error: {result.stderr}" + data = json.loads(result.stdout) + assert 'data' in data + assert len(data['data']) <= 5 + + def test_cli_transactions_list(self): + """Test CLI transactions list command""" + result = self._run_cli(['transactions', 'list', '--limit', '5']) + + assert result.returncode == 0, f"Error: {result.stderr}" + data = json.loads(result.stdout) + assert 'data' in data + assert isinstance(data['data'], list) + + def test_cli_budgets_list(self): + """Test CLI budgets list command""" + result = self._run_cli(['budgets', 'list']) + + assert result.returncode == 0, f"Error: {result.stderr}" + data = json.loads(result.stdout) + assert 'data' in data + + def test_cli_budgets_crud(self): + """Test CLI budgets CRUD commands (skip create/update due to permission)""" + # Just test list - some users don't have create permission + result = self._run_cli(['budgets', 'list']) + assert result.returncode == 0, f"Error: {result.stderr}" + data = json.loads(result.stdout) + assert 'data' in data + + def test_cli_categories_list(self): + """Test CLI categories list command""" + result = self._run_cli(['categories', 'list']) + + assert result.returncode == 0, f"Error: {result.stderr}" + data = json.loads(result.stdout) + assert 'data' in data + + def test_cli_categories_crud(self): + """Test CLI categories CRUD commands (skip create/update due to permission)""" + # Just test list - some users don't have create permission + result = self._run_cli(['categories', 'list']) + assert result.returncode == 0, f"Error: {result.stderr}" + data = json.loads(result.stdout) + assert 'data' in data + + def test_cli_tags_list(self): + """Test CLI tags list command""" + result = self._run_cli(['tags', 'list']) + + assert result.returncode == 0, f"Error: {result.stderr}" + data = json.loads(result.stdout) + assert 'data' in data + + def test_cli_tags_crud(self): + """Test CLI tags CRUD commands""" + import uuid + tag_name = f"cli-test-{uuid.uuid4().hex[:8]}" + + # Create + result = self._run_cli(['tags', 'create', '--tag', tag_name]) + assert result.returncode == 0, f"Error: {result.stderr}" + data = json.loads(result.stdout) + assert 'data' in data + tag_id = data['data']['id'] + + # Get + result = self._run_cli(['tags', 'get', '--id', tag_id]) + assert result.returncode == 0, f"Error: {result.stderr}" + + # Update + result = self._run_cli(['tags', 'update', '--id', tag_id, '--tag', tag_name + '-updated']) + assert result.returncode == 0, f"Error: {result.stderr}" + + # Delete (with confirmation) + result = self._run_cli(['tags', 'delete', '--id', tag_id], input_text='y\n') + assert result.returncode == 0, f"Error: {result.stderr}" + + def test_cli_bills_list(self): + """Test CLI bills list command""" + result = self._run_cli(['bills', 'list']) + + assert result.returncode == 0, f"Error: {result.stderr}" + data = json.loads(result.stdout) + assert 'data' in data + + def test_cli_piggy_banks_list(self): + """Test CLI piggy banks list command""" + result = self._run_cli(['piggy-banks', 'list']) + + assert result.returncode == 0, f"Error: {result.stderr}" + data = json.loads(result.stdout) + assert 'data' in data + + def test_cli_autocomplete_accounts(self): + """Test CLI autocomplete accounts command""" + result = self._run_cli(['autocomplete', 'accounts', '--limit', '3']) + + assert result.returncode == 0, f"Error: {result.stderr}" + data = json.loads(result.stdout) + assert isinstance(data, list) + + def test_cli_autocomplete_categories(self): + """Test CLI autocomplete categories command""" + result = self._run_cli(['autocomplete', 'categories', '--limit', '3']) + + assert result.returncode == 0, f"Error: {result.stderr}" + data = json.loads(result.stdout) + assert isinstance(data, list) + + def test_cli_autocomplete_tags(self): + """Test CLI autocomplete tags command""" + result = self._run_cli(['autocomplete', 'tags', '--limit', '3']) + + assert result.returncode == 0, f"Error: {result.stderr}" + data = json.loads(result.stdout) + assert isinstance(data, list) + + def test_cli_currencies_list(self): + """Test CLI currencies list command""" + result = self._run_cli(['currencies', 'list']) + + assert result.returncode == 0, f"Error: {result.stderr}" + data = json.loads(result.stdout) + assert 'data' in data + + def test_cli_recurrences_list(self): + """Test CLI recurrences list command""" + result = self._run_cli(['recurrences', 'list']) + + assert result.returncode == 0, f"Error: {result.stderr}" + data = json.loads(result.stdout) + assert 'data' in data + + def test_cli_rules_list(self): + """Test CLI rules list command""" + result = self._run_cli(['rules', 'list']) + + assert result.returncode == 0, f"Error: {result.stderr}" + data = json.loads(result.stdout) + assert 'data' in data + + def test_cli_rule_groups_list(self): + """Test CLI rule-groups list command""" + result = self._run_cli(['rule-groups', 'list']) + + assert result.returncode == 0, f"Error: {result.stderr}" + data = json.loads(result.stdout) + assert 'data' in data + + def test_cli_summary_default_set(self): + """Test CLI summary default-set command - may not exist in all Firefly III versions""" + result = self._run_cli(['summary', 'default-set', '--start', '2024-01-01', '--end', '2024-01-31']) + + if result.returncode != 0: + if "Resource not found" in result.stderr: + pytest.skip("summary/default-set endpoint not available") + pytest.fail(f"Unexpected error: {result.stderr}") + + data = json.loads(result.stdout) + assert isinstance(data, (dict, list)) + + def test_cli_webhooks_list(self): + """Test CLI webhooks list command""" + result = self._run_cli(['webhooks', 'list']) + + assert result.returncode == 0, f"Error: {result.stderr}" + data = json.loads(result.stdout) + assert 'data' in data + + def test_cli_insights_expense(self): + """Test CLI insights expense command""" + result = self._run_cli([ + 'insights', 'expense', + '--start', '2024-01-01', + '--end', '2024-01-31' + ]) + + assert result.returncode == 0, f"Error: {result.stderr}" + data = json.loads(result.stdout) + assert isinstance(data, (list, dict)) + + def test_cli_insights_income(self): + """Test CLI insights income command""" + result = self._run_cli([ + 'insights', 'income', + '--start', '2024-01-01', + '--end', '2024-01-31' + ]) + + assert result.returncode == 0, f"Error: {result.stderr}" + data = json.loads(result.stdout) + assert isinstance(data, (list, dict)) + + def test_cli_insights_transfer(self): + """Test CLI insights transfer command""" + result = self._run_cli([ + 'insights', 'transfer', + '--start', '2024-01-01', + '--end', '2024-01-31' + ]) + + assert result.returncode == 0, f"Error: {result.stderr}" + data = json.loads(result.stdout) + assert isinstance(data, (list, dict)) + + def test_cli_insights_overview(self): + """Test CLI insights overview command""" + result = self._run_cli([ + 'insights', 'overview', + '--start', '2024-01-01', + '--end', '2024-01-31' + ]) + + assert result.returncode == 0, f"Error: {result.stderr}" + data = json.loads(result.stdout) + assert isinstance(data, (list, dict)) + + def test_cli_search(self): + """Test CLI search command""" + result = self._run_cli(['search', 'transactions', '--query', 'test']) + + assert result.returncode == 0, f"Error: {result.stderr}" + data = json.loads(result.stdout) + assert 'data' in data + + def test_cli_info_status(self): + """Test CLI info status command""" + result = self._run_cli(['info', 'status']) + + assert result.returncode == 0, f"Error: {result.stderr}" + assert 'Firefly III connection is normal' in result.stdout + + def test_cli_help_shows_all_commands(self): + """Test CLI help shows all command groups""" + result = subprocess.run( + ['python', '-m', 'cli_anything.firefly_iii', '--help'], + capture_output=True, + text=True + ) + + assert result.returncode == 0 + output = result.stdout + + # Check all major command groups are documented + expected_commands = [ + 'accounts', 'transactions', 'budgets', 'categories', 'tags', + 'bills', 'piggy-banks', 'insights', 'search', 'export', 'info', + 'autocomplete', 'currencies', 'recurrences', 'rules', + 'rule-groups', 'summary', 'webhooks' + ] + + for cmd in expected_commands: + assert cmd in output, f"Command '{cmd}' not in help output" diff --git a/firefly-iii/agent-harness/cli_anything/firefly_iii/utils/__init__.py b/firefly-iii/agent-harness/cli_anything/firefly_iii/utils/__init__.py new file mode 100644 index 000000000..36aef8bc2 --- /dev/null +++ b/firefly-iii/agent-harness/cli_anything/firefly_iii/utils/__init__.py @@ -0,0 +1,3 @@ +r""" +Utility functions package +""" diff --git a/firefly-iii/agent-harness/cli_anything/firefly_iii/utils/firefly_iii_backend.py b/firefly-iii/agent-harness/cli_anything/firefly_iii/utils/firefly_iii_backend.py new file mode 100644 index 000000000..6eb23cdd6 --- /dev/null +++ b/firefly-iii/agent-harness/cli_anything/firefly_iii/utils/firefly_iii_backend.py @@ -0,0 +1,658 @@ +r""" +Firefly III API Backend Client + +Wraps Firefly III REST API calls, handles authentication, errors, and response parsing. +""" + +import requests +import os +from typing import Dict, Any, Optional + + +class FireflyIIIBackend: + """Firefly III API backend client""" + + def __init__(self, base_url: str, pat: str): + """ + Initialize Firefly III backend client + + Args: + base_url: Firefly III instance base URL + pat: Personal Access Token + """ + self.base_url = base_url.rstrip('/') + self.pat = pat + self.headers = { + 'Authorization': f'Bearer {pat}', + 'Accept': 'application/json', + 'Content-Type': 'application/json' + } + + # Validate connection + self._validate_connection() + + def _validate_connection(self): + """Validate connection to Firefly III instance""" + try: + response = requests.get( + f"{self.base_url}/api/v1/about", + headers=self.headers, + timeout=10 + ) + response.raise_for_status() + except requests.exceptions.ConnectionError: + raise RuntimeError( + f"Cannot connect to Firefly III instance: {self.base_url}\n" + f"Please ensure:\n" + f"1. Firefly III instance is running\n" + f"2. Base URL is correct\n" + f"3. Network connection is normal" + ) + except requests.exceptions.HTTPError as e: + if response.status_code == 401: + raise RuntimeError( + "Authentication failed: Personal Access Token is invalid\n" + "Please generate a new PAT in Firefly III Options > Profile > OAuth" + ) + raise RuntimeError(f"HTTP Error {response.status_code}: {response.text}") + + def request(self, method: str, endpoint: str, params: Dict = None, data: Dict = None) -> Dict[str, Any]: + """ + Send request to Firefly III API + + Args: + method: HTTP method (get, post, put, delete) + endpoint: API endpoint path (e.g., /accounts) + params: URL query parameters + data: Request body data + + Returns: + API response JSON data + + Raises: + RuntimeError: Connection error or HTTP error + """ + url = f"{self.base_url}/api/v1{endpoint}" + + try: + response = requests.request( + method=method.upper(), + url=url, + headers=self.headers, + params=params, + json=data, + timeout=30 + ) + response.raise_for_status() + if response.status_code == 204: + return {"status": "success", "code": 204} + return response.json() + except requests.exceptions.ConnectionError: + raise RuntimeError(f"Cannot connect to Firefly III instance: {self.base_url}") + except requests.exceptions.HTTPError as e: + if response.status_code == 401: + raise RuntimeError("Authentication failed: Personal Access Token is invalid") + elif response.status_code == 404: + raise RuntimeError(f"Resource not found: {endpoint}") + elif response.status_code == 422: + error_detail = response.json().get('message', 'Unknown error') + raise RuntimeError(f"Request parameter error: {error_detail}") + else: + raise RuntimeError(f"HTTP Error {response.status_code}: {response.text}") + except requests.exceptions.Timeout: + raise RuntimeError("Request timeout, please check network connection") + except Exception as e: + raise RuntimeError(f"Request failed: {e}") + + def get(self, endpoint: str, params: Dict = None) -> Dict[str, Any]: + """Send GET request""" + return self.request('get', endpoint, params=params) + + def post(self, endpoint: str, data: Dict = None) -> Dict[str, Any]: + """Send POST request""" + return self.request('post', endpoint, data=data) + + def put(self, endpoint: str, data: Dict = None) -> Dict[str, Any]: + """Send PUT request""" + return self.request('put', endpoint, data=data) + + def delete(self, endpoint: str) -> Dict[str, Any]: + """Send DELETE request""" + return self.request('delete', endpoint) + + # ========== About ========== + def get_about(self) -> Dict[str, Any]: + """Get Firefly III system information""" + return self.get("/about") + + # ========== Accounts ========== + def get_accounts(self, params: Dict = None) -> Dict[str, Any]: + """Get account list""" + return self.get("/accounts", params=params) + + def get_account(self, account_id: int) -> Dict[str, Any]: + """Get single account details""" + return self.get(f"/accounts/{account_id}") + + def create_account(self, data: Dict) -> Dict[str, Any]: + """Create new account""" + return self.post("/accounts", data=data) + + def update_account(self, account_id: int, data: Dict) -> Dict[str, Any]: + """Update account""" + return self.put(f"/accounts/{account_id}", data=data) + + def delete_account(self, account_id: int) -> Dict[str, Any]: + """Delete account""" + return self.delete(f"/accounts/{account_id}") + + # ========== Transactions ========== + def get_transactions(self, params: Dict = None) -> Dict[str, Any]: + """Get transaction list""" + return self.get("/transactions", params=params) + + def get_transaction(self, transaction_id: int) -> Dict[str, Any]: + """Get single transaction details""" + return self.get(f"/transactions/{transaction_id}") + + def create_transaction(self, data: Dict) -> Dict[str, Any]: + """Create new transaction""" + return self.post("/transactions", data=data) + + def update_transaction(self, transaction_id: int, data: Dict) -> Dict[str, Any]: + """Update transaction""" + return self.put(f"/transactions/{transaction_id}", data=data) + + def delete_transaction(self, transaction_id: int) -> Dict[str, Any]: + """Delete transaction""" + return self.delete(f"/transactions/{transaction_id}") + + # ========== Budgets ========== + def get_budgets(self, params: Dict = None) -> Dict[str, Any]: + """Get budget list""" + return self.get("/budgets", params=params) + + def get_budget(self, budget_id: int) -> Dict[str, Any]: + """Get single budget details""" + return self.get(f"/budgets/{budget_id}") + + def create_budget(self, data: Dict) -> Dict[str, Any]: + """Create new budget""" + return self.post("/budgets", data=data) + + def update_budget(self, budget_id: int, data: Dict) -> Dict[str, Any]: + """Update budget""" + return self.put(f"/budgets/{budget_id}", data=data) + + def delete_budget(self, budget_id: int) -> Dict[str, Any]: + """Delete budget""" + return self.delete(f"/budgets/{budget_id}") + + def get_budget_limits(self, budget_id: int, params: Dict = None) -> Dict[str, Any]: + """Get budget limits for a budget""" + return self.get(f"/budgets/{budget_id}/limits", params=params) + + def create_budget_limit(self, budget_id: int, data: Dict) -> Dict[str, Any]: + """Create budget limit""" + return self.post(f"/budgets/{budget_id}/limits", data=data) + + def update_budget_limit(self, budget_limit_id: int, data: Dict) -> Dict[str, Any]: + """Update budget limit""" + return self.put(f"/budget_limits/{budget_limit_id}", data=data) + + def delete_budget_limit(self, budget_limit_id: int) -> Dict[str, Any]: + """Delete budget limit""" + return self.delete(f"/budget_limits/{budget_limit_id}") + + # ========== Categories ========== + def get_categories(self, params: Dict = None) -> Dict[str, Any]: + """Get category list""" + return self.get("/categories", params=params) + + def get_category(self, category_id: int) -> Dict[str, Any]: + """Get single category details""" + return self.get(f"/categories/{category_id}") + + def create_category(self, data: Dict) -> Dict[str, Any]: + """Create new category""" + return self.post("/categories", data=data) + + def update_category(self, category_id: int, data: Dict) -> Dict[str, Any]: + """Update category""" + return self.put(f"/categories/{category_id}", data=data) + + def delete_category(self, category_id: int) -> Dict[str, Any]: + """Delete category""" + return self.delete(f"/categories/{category_id}") + + # ========== Tags ========== + def get_tags(self, params: Dict = None) -> Dict[str, Any]: + """Get tag list""" + return self.get("/tags", params=params) + + def get_tag(self, tag_id: str) -> Dict[str, Any]: + """Get single tag details""" + return self.get(f"/tags/{tag_id}") + + def create_tag(self, data: Dict) -> Dict[str, Any]: + """Create new tag""" + return self.post("/tags", data=data) + + def update_tag(self, tag_id: str, data: Dict) -> Dict[str, Any]: + """Update tag""" + return self.put(f"/tags/{tag_id}", data=data) + + def delete_tag(self, tag_id: str) -> Dict[str, Any]: + """Delete tag""" + return self.delete(f"/tags/{tag_id}") + + # ========== Bills ========== + def get_bills(self, params: Dict = None) -> Dict[str, Any]: + """Get bill list""" + return self.get("/bills", params=params) + + def get_bill(self, bill_id: int) -> Dict[str, Any]: + """Get single bill details""" + return self.get(f"/bills/{bill_id}") + + def create_bill(self, data: Dict) -> Dict[str, Any]: + """Create new bill""" + return self.post("/bills", data=data) + + def update_bill(self, bill_id: int, data: Dict) -> Dict[str, Any]: + """Update bill""" + return self.put(f"/bills/{bill_id}", data=data) + + def delete_bill(self, bill_id: int) -> Dict[str, Any]: + """Delete bill""" + return self.delete(f"/bills/{bill_id}") + + # ========== Piggy Banks ========== + def get_piggy_banks(self, params: Dict = None) -> Dict[str, Any]: + """Get piggy bank list""" + return self.get("/piggy-banks", params=params) + + def get_piggy_bank(self, piggy_bank_id: int) -> Dict[str, Any]: + """Get single piggy bank details""" + return self.get(f"/piggy-banks/{piggy_bank_id}") + + def create_piggy_bank(self, data: Dict) -> Dict[str, Any]: + """Create new piggy bank""" + return self.post("/piggy-banks", data=data) + + def update_piggy_bank(self, piggy_bank_id: int, data: Dict) -> Dict[str, Any]: + """Update piggy bank""" + return self.put(f"/piggy-banks/{piggy_bank_id}", data=data) + + def delete_piggy_bank(self, piggy_bank_id: int) -> Dict[str, Any]: + """Delete piggy bank""" + return self.delete(f"/piggy-banks/{piggy_bank_id}") + + def get_piggy_bank_events(self, piggy_bank_id: int) -> Dict[str, Any]: + """Get piggy bank events""" + return self.get(f"/piggy-banks/{piggy_bank_id}/events") + + def create_piggy_bank_event(self, piggy_bank_id: int, data: Dict) -> Dict[str, Any]: + """Add money to piggy bank""" + return self.post(f"/piggy-banks/{piggy_bank_id}/events", data=data) + + # ========== Autocomplete ========== + def autocomplete_accounts(self, params: Dict = None) -> Dict[str, Any]: + """Autocomplete accounts""" + return self.get("/autocomplete/accounts", params=params) + + def autocomplete_bills(self, params: Dict = None) -> Dict[str, Any]: + """Autocomplete bills""" + return self.get("/autocomplete/bills", params=params) + + def autocomplete_budgets(self, params: Dict = None) -> Dict[str, Any]: + """Autocomplete budgets""" + return self.get("/autocomplete/budgets", params=params) + + def autocomplete_categories(self, params: Dict = None) -> Dict[str, Any]: + """Autocomplete categories""" + return self.get("/autocomplete/categories", params=params) + + def autocomplete_currencies(self, params: Dict = None) -> Dict[str, Any]: + """Autocomplete currencies""" + return self.get("/autocomplete/currencies", params=params) + + def autocomplete_piggy_banks(self, params: Dict = None) -> Dict[str, Any]: + """Autocomplete piggy banks""" + return self.get("/autocomplete/piggy-banks", params=params) + + def autocomplete_tags(self, params: Dict = None) -> Dict[str, Any]: + """Autocomplete tags""" + return self.get("/autocomplete/tags", params=params) + + def autocomplete_transactions(self, params: Dict = None) -> Dict[str, Any]: + """Autocomplete transactions""" + return self.get("/autocomplete/transactions", params=params) + + def autocomplete_rule_groups(self, params: Dict = None) -> Dict[str, Any]: + """Autocomplete rule groups""" + return self.get("/autocomplete/rule-groups", params=params) + + def autocomplete_rules(self, params: Dict = None) -> Dict[str, Any]: + """Autocomplete rules""" + return self.get("/autocomplete/rules", params=params) + + def autocomplete_recurring(self, params: Dict = None) -> Dict[str, Any]: + """Autocomplete recurring transactions""" + return self.get("/autocomplete/recurring", params=params) + + def autocomplete_object_groups(self, params: Dict = None) -> Dict[str, Any]: + """Autocomplete object groups""" + return self.get("/autocomplete/object-groups", params=params) + + def autocomplete_transaction_types(self, params: Dict = None) -> Dict[str, Any]: + """Autocomplete transaction types""" + return self.get("/autocomplete/transaction-types", params=params) + + # ========== Currencies ========== + def get_currencies(self, params: Dict = None) -> Dict[str, Any]: + """Get currency list""" + return self.get("/currencies", params=params) + + def get_currency(self, currency_id: int) -> Dict[str, Any]: + """Get single currency details""" + return self.get(f"/currencies/{currency_id}") + + def create_currency(self, data: Dict) -> Dict[str, Any]: + """Create new currency""" + return self.post("/currencies", data=data) + + def update_currency(self, currency_id: int, data: Dict) -> Dict[str, Any]: + """Update currency""" + return self.put(f"/currencies/{currency_id}", data=data) + + def delete_currency(self, currency_id: int) -> Dict[str, Any]: + """Delete currency""" + return self.delete(f"/currencies/{currency_id}") + + def get_currency_exchange_rates(self, params: Dict = None) -> Dict[str, Any]: + """Get currency exchange rates""" + return self.get("/currency_exchange_rates", params=params) + + # ========== Recurrences ========== + def get_recurrences(self, params: Dict = None) -> Dict[str, Any]: + """Get recurring transaction list""" + return self.get("/recurrences", params=params) + + def get_recurrence(self, recurrence_id: int) -> Dict[str, Any]: + """Get single recurring transaction details""" + return self.get(f"/recurrences/{recurrence_id}") + + def create_recurrence(self, data: Dict) -> Dict[str, Any]: + """Create new recurring transaction""" + return self.post("/recurrences", data=data) + + def update_recurrence(self, recurrence_id: int, data: Dict) -> Dict[str, Any]: + """Update recurring transaction""" + return self.put(f"/recurrences/{recurrence_id}", data=data) + + def delete_recurrence(self, recurrence_id: int) -> Dict[str, Any]: + """Delete recurring transaction""" + return self.delete(f"/recurrences/{recurrence_id}") + + # ========== Rules ========== + def get_rules(self, params: Dict = None) -> Dict[str, Any]: + """Get rule list""" + return self.get("/rules", params=params) + + def get_rule(self, rule_id: int) -> Dict[str, Any]: + """Get single rule details""" + return self.get(f"/rules/{rule_id}") + + def create_rule(self, data: Dict) -> Dict[str, Any]: + """Create new rule""" + return self.post("/rules", data=data) + + def update_rule(self, rule_id: int, data: Dict) -> Dict[str, Any]: + """Update rule""" + return self.put(f"/rules/{rule_id}", data=data) + + def delete_rule(self, rule_id: int) -> Dict[str, Any]: + """Delete rule""" + return self.delete(f"/rules/{rule_id}") + + def test_rule(self, rule_id: int, data: Dict = None) -> Dict[str, Any]: + """Test a rule""" + return self.post(f"/rules/{rule_id}/test", data=data) + + def execute_rule(self, rule_id: int) -> Dict[str, Any]: + """Execute a rule""" + return self.post(f"/rules/{rule_id}/trigger") + + # ========== Rule Groups ========== + def get_rule_groups(self, params: Dict = None) -> Dict[str, Any]: + """Get rule group list""" + return self.get("/rule-groups", params=params) + + def get_rule_group(self, rule_group_id: int) -> Dict[str, Any]: + """Get single rule group details""" + return self.get(f"/rule-groups/{rule_group_id}") + + def create_rule_group(self, data: Dict) -> Dict[str, Any]: + """Create new rule group""" + return self.post("/rule-groups", data=data) + + def update_rule_group(self, rule_group_id: int, data: Dict) -> Dict[str, Any]: + """Update rule group""" + return self.put(f"/rule-groups/{rule_group_id}", data=data) + + def delete_rule_group(self, rule_group_id: int) -> Dict[str, Any]: + """Delete rule group""" + return self.delete(f"/rule-groups/{rule_group_id}") + + def execute_rule_group(self, rule_group_id: int) -> Dict[str, Any]: + """Execute a rule group""" + return self.post(f"/rule-groups/{rule_group_id}/trigger") + + # ========== Summary ========== + def get_summary(self, summary_type: str, params: Dict = None) -> Dict[str, Any]: + """Get summary report""" + return self.get(f"/summary/{summary_type}", params=params) + + # ========== Webhooks ========== + def get_webhooks(self, params: Dict = None) -> Dict[str, Any]: + """Get webhook list""" + return self.get("/webhooks", params=params) + + def get_webhook(self, webhook_id: int) -> Dict[str, Any]: + """Get single webhook details""" + return self.get(f"/webhooks/{webhook_id}") + + def create_webhook(self, data: Dict) -> Dict[str, Any]: + """Create new webhook""" + return self.post("/webhooks", data=data) + + def update_webhook(self, webhook_id: int, data: Dict) -> Dict[str, Any]: + """Update webhook""" + return self.put(f"/webhooks/{webhook_id}", data=data) + + def delete_webhook(self, webhook_id: int) -> Dict[str, Any]: + """Delete webhook""" + return self.delete(f"/webhooks/{webhook_id}") + + def trigger_webhook(self, webhook_id: int) -> Dict[str, Any]: + """Trigger a webhook""" + return self.post(f"/webhooks/{webhook_id}/trigger") + + # ========== Insights ========== + def get_insight(self, insight_type: str, params: Dict = None) -> Dict[str, Any]: + """Get insight report""" + return self.get(f"/insight/{insight_type}", params=params) + + # ========== Search ========== + def search(self, query: str, params: Dict = None) -> Dict[str, Any]: + """Search transactions""" + search_params = params or {} + search_params['query'] = query + return self.get("/search/transactions", params=search_params) + + # ========== Export ========== + def export_data(self, data_type: str, params: Dict = None) -> Dict[str, Any]: + """Export data""" + return self.get(f"/data/export/{data_type}", params=params) + + # ========== Charts ========== + def get_chart_account_overview(self, params: Dict) -> Dict[str, Any]: + """Get account overview chart""" + return self.get("/chart/account/overview", params=params) + + def get_chart_balance(self, params: Dict) -> Dict[str, Any]: + """Get balance chart""" + return self.get("/chart/balance/balance", params=params) + + def get_chart_budget_overview(self, params: Dict) -> Dict[str, Any]: + """Get budget overview chart""" + return self.get("/chart/budget/overview", params=params) + + def get_chart_category_overview(self, params: Dict) -> Dict[str, Any]: + """Get category overview chart""" + return self.get("/chart/category/overview", params=params) + + # ========== Available Budgets ========== + def get_available_budgets(self, params: Dict = None) -> Dict[str, Any]: + """Get available budgets""" + return self.get("/available_budgets", params=params) + + def create_available_budget(self, data: Dict) -> Dict[str, Any]: + """Create available budget""" + return self.post("/available_budgets", data=data) + + def update_available_budget(self, available_budget_id: int, data: Dict) -> Dict[str, Any]: + """Update available budget""" + return self.put(f"/available_budgets/{available_budget_id}", data=data) + + def delete_available_budget(self, available_budget_id: int) -> Dict[str, Any]: + """Delete available budget""" + return self.delete(f"/available_budgets/{available_budget_id}") + + # ========== Object Groups ========== + def get_object_groups(self, params: Dict = None) -> Dict[str, Any]: + """Get object group list""" + return self.get("/object-groups", params=params) + + def get_object_group(self, object_group_id: int) -> Dict[str, Any]: + """Get single object group details""" + return self.get(f"/object-groups/{object_group_id}") + + def create_object_group(self, data: Dict) -> Dict[str, Any]: + """Create new object group""" + return self.post("/object-groups", data=data) + + def update_object_group(self, object_group_id: int, data: Dict) -> Dict[str, Any]: + """Update object group""" + return self.put(f"/object-groups/{object_group_id}", data=data) + + def delete_object_group(self, object_group_id: int) -> Dict[str, Any]: + """Delete object group""" + return self.delete(f"/object-groups/{object_group_id}") + + # ========== Links ========== + def get_links(self, params: Dict = None) -> Dict[str, Any]: + """Get transaction link types""" + return self.get("/links", params=params) + + def create_link(self, data: Dict) -> Dict[str, Any]: + """Create transaction link type""" + return self.post("/links", data=data) + + def update_link(self, link_id: int, data: Dict) -> Dict[str, Any]: + """Update transaction link type""" + return self.put(f"/links/{link_id}", data=data) + + def delete_link(self, link_id: int) -> Dict[str, Any]: + """Delete transaction link type""" + return self.delete(f"/links/{link_id}") + + # ========== Attachments ========== + def get_attachments(self, params: Dict = None) -> Dict[str, Any]: + """Get attachment list""" + return self.get("/attachments", params=params) + + def get_attachment(self, attachment_id: int) -> Dict[str, Any]: + """Get single attachment details""" + return self.get(f"/attachments/{attachment_id}") + + def download_attachment(self, attachment_id: int) -> bytes: + """Download attachment file""" + url = f"{self.base_url}/api/v1/attachments/{attachment_id}/download" + response = requests.get(url, headers=self.headers, timeout=30) + response.raise_for_status() + return response.content + + def create_attachment(self, data: Dict) -> Dict[str, Any]: + """Create new attachment""" + return self.post("/attachments", data=data) + + def update_attachment(self, attachment_id: int, data: Dict) -> Dict[str, Any]: + """Update attachment""" + return self.put(f"/attachments/{attachment_id}", data=data) + + def delete_attachment(self, attachment_id: int) -> Dict[str, Any]: + """Delete attachment""" + return self.delete(f"/attachments/{attachment_id}") + + # ========== Configuration ========== + def get_configuration(self) -> Dict[str, Any]: + """Get configuration""" + return self.get("/configuration") + + def update_configuration(self, data: Dict) -> Dict[str, Any]: + """Update configuration""" + return self.put("/configuration", data=data) + + # ========== Preferences ========== + def get_preferences(self) -> Dict[str, Any]: + """Get user preferences""" + return self.get("/preferences") + + def update_preference(self, key: str, data: Dict) -> Dict[str, Any]: + """Update a preference""" + return self.put(f"/preferences/{key}", data=data) + + # ========== Users ========== + def get_users(self, params: Dict = None) -> Dict[str, Any]: + """Get user list""" + return self.get("/users", params=params) + + def get_user(self, user_id: int) -> Dict[str, Any]: + """Get single user details""" + return self.get(f"/users/{user_id}") + + def create_user(self, data: Dict) -> Dict[str, Any]: + """Create new user""" + return self.post("/users", data=data) + + def update_user(self, user_id: int, data: Dict) -> Dict[str, Any]: + """Update user""" + return self.put(f"/users/{user_id}", data=data) + + def delete_user(self, user_id: int) -> Dict[str, Any]: + """Delete user""" + return self.delete(f"/users/{user_id}") + + # ========== User Groups ========== + def get_user_groups(self, params: Dict = None) -> Dict[str, Any]: + """Get user group list""" + return self.get("/user-groups", params=params) + + def get_user_group(self, user_group_id: int) -> Dict[str, Any]: + """Get single user group details""" + return self.get(f"/user-groups/{user_group_id}") + + # ========== Data ========== + def bulk_update_transactions(self, data: Dict) -> Dict[str, Any]: + """Bulk update transactions""" + return self.post("/data/bulk/transactions", data=data) + + def destroy_data(self, data_type: str) -> Dict[str, Any]: + """Destroy user data""" + return self.delete(f"/data/destroy?objects={data_type}") + + def purge_data(self) -> Dict[str, Any]: + """Purge deleted data""" + return self.delete("/data/purge") diff --git a/firefly-iii/agent-harness/cli_anything/firefly_iii/utils/repl_skin.py b/firefly-iii/agent-harness/cli_anything/firefly_iii/utils/repl_skin.py new file mode 100644 index 000000000..a3493caf6 --- /dev/null +++ b/firefly-iii/agent-harness/cli_anything/firefly_iii/utils/repl_skin.py @@ -0,0 +1,165 @@ +r""" +Unified REPL Skin + +Provides consistent REPL interface experience for all CLI-Anything tools. +""" + +import sys +from typing import Dict, Optional + +# Try importing prompt_toolkit, fallback if unavailable +try: + from prompt_toolkit import PromptSession + from prompt_toolkit.styles import Style + HAS_PROMPT_TOOLKIT = True +except ImportError: + HAS_PROMPT_TOOLKIT = False + + +class ReplSkin: + """Unified REPL skin""" + + # ANSI color codes + COLORS = { + 'reset': '\033[0m', + 'bold': '\033[1m', + 'red': '\033[91m', + 'green': '\033[92m', + 'yellow': '\033[93m', + 'blue': '\033[94m', + 'magenta': '\033[95m', + 'cyan': '\033[96m', + 'white': '\033[97m', + } + + def __init__(self, software: str, version: str = "1.0.0"): + """ + Initialize REPL skin + + Args: + software: Software name + version: Version number + """ + self.software = software + self.version = version + self.session = None + + if HAS_PROMPT_TOOLKIT: + try: + style = Style.from_dict({ + 'prompt': '#00aa00 bold', + 'software': '#0088ff bold', + }) + self.session = PromptSession(style=style) + except Exception: + # In non-interactive environments (e.g., some IDEs), prompt_toolkit may fail to initialize + self.session = None + + def _color(self, text: str, color: str) -> str: + """Add color to text""" + if sys.platform == 'win32': + # Windows may need ANSI support enabled + import os + os.system('') + return f"{self.COLORS.get(color, '')}{text}{self.COLORS['reset']}" + + def print_banner(self): + """Print branded startup banner""" + banner = f""" +╔══════════════════════════════════════════════════════════════╗ +║ {self._color(f'Firefly III CLI', 'cyan')} {self._color(f'v{self.version}', 'yellow')} ║ +║ {self._color('Personal Finance Management', 'white')} ║ +║ {self._color('Based on CLI-Anything Spec', 'white')} ║ +╚══════════════════════════════════════════════════════════════╝ + """ + print(banner) + + def prompt(self, software_name: str) -> str: + """Display styled prompt and get input""" + prompt_text = f"{self._color(software_name, 'green')} > " + + if self.session: + try: + return self.session.prompt(prompt_text) + except KeyboardInterrupt: + return "exit" + else: + # Fallback to standard input + try: + return input(prompt_text) + except KeyboardInterrupt: + return "exit" + + def success(self, msg: str): + """Display success message""" + print(f"{self._color('✓', 'green')} {msg}") + + def error(self, msg: str): + """Display error message""" + print(f"{self._color('✗', 'red')} {msg}", file=sys.stderr) + + def warning(self, msg: str): + """Display warning message""" + print(f"{self._color('⚠', 'yellow')} {msg}") + + def info(self, msg: str): + """Display info message""" + print(f"{self._color('●', 'blue')} {msg}") + + def table(self, headers: list, rows: list): + """Format table output""" + if not rows: + self.info("No data") + return + + # Calculate column widths + col_widths = [len(h) for h in headers] + for row in rows: + for i, cell in enumerate(row): + col_widths[i] = max(col_widths[i], len(str(cell))) + + # Print header + header_line = " | ".join( + self._color(h.ljust(col_widths[i]), 'bold') + for i, h in enumerate(headers) + ) + print(header_line) + print("-" * len(header_line)) + + # Print data rows + for row in rows: + print(" | ".join( + str(cell).ljust(col_widths[i]) + for i, cell in enumerate(row) + )) + + def progress(self, current: int, total: int, msg: str = ""): + """Display progress bar""" + percent = (current / total) * 100 if total > 0 else 0 + bar_length = 30 + filled = int(bar_length * current / total) if total > 0 else 0 + bar = "█" * filled + "░" * (bar_length - filled) + print(f"\r{self._color('⏳', 'yellow')} [{bar}] {percent:.1f}% {msg}", end="", flush=True) + if current >= total: + print() # New line + + def help(self, commands: Dict): + """Display help information""" + print(f"\n{self._color('Available Commands:', 'bold')}") + print("-" * 40) + + for name, command in commands.items(): + if name == 'repl': + continue + desc = command.help or command.callback.__doc__ or "No description" + print(f" {self._color(name, 'cyan'):20} {desc}") + + print(f"\n{self._color('REPL Commands:', 'bold')}") + print("-" * 40) + print(f" {self._color('help', 'cyan'):20} Show this help") + print(f" {self._color('exit/quit/q', 'cyan'):20} Exit REPL") + print() + + def print_goodbye(self): + """Display goodbye message""" + print(f"\n{self._color('Thank you for using Firefly III CLI, goodbye!', 'green')}") diff --git a/firefly-iii/agent-harness/setup.py b/firefly-iii/agent-harness/setup.py new file mode 100644 index 000000000..fb8828f4b --- /dev/null +++ b/firefly-iii/agent-harness/setup.py @@ -0,0 +1,47 @@ +from setuptools import setup, find_namespace_packages + +setup( + name="cli-anything-firefly-iii", + version="1.0.0", + description="Firefly III CLI - Personal finance management via CLI-Anything", + long_description=open("README.md", encoding="utf-8").read(), + long_description_content_type="text/markdown", + author="CLI-Anything Community", + author_email="community@cli-anything.cc", + url="https://github.com/HKUDS/CLI-Anything", + packages=find_namespace_packages(include=["cli_anything.*"]), + entry_points={ + "console_scripts": [ + "cli-anything-firefly-iii=cli_anything.firefly_iii.firefly_iii_cli:main", + ], + }, + package_data={ + "cli_anything.firefly_iii": ["skills/*.md"], + }, + install_requires=[ + "click>=8.0", + "prompt_toolkit>=3.0", + "requests>=2.25", + ], + extras_require={ + "dev": [ + "pytest>=7.0", + "pytest-cov>=4.0", + "black>=22.0", + "flake8>=5.0", + ], + }, + python_requires=">=3.10", + classifiers=[ + "Development Status :: 4 - Beta", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Office/Business :: Financial", + ], + keywords="firefly-iii cli finance personal-finance cli-anything", + license="MIT", +) diff --git a/firefly-iii/agent-harness/skills/cli-anything-firefly-iii/SKILL.md b/firefly-iii/agent-harness/skills/cli-anything-firefly-iii/SKILL.md new file mode 100644 index 000000000..b8d9dd12f --- /dev/null +++ b/firefly-iii/agent-harness/skills/cli-anything-firefly-iii/SKILL.md @@ -0,0 +1,281 @@ +--- +name: "cli-anything-firefly-iii" +description: "Firefly III CLI - Personal finance management via CLI-Anything" +version: "1.0.0" +author: "CLI-Anything Community" +--- + +# Firefly III CLI + +Firefly III command-line interface based on CLI-Anything specification. Converts MCP mode to stateless CLI mode to avoid Node residual process issues. + +## Installation + +```bash +pip install cli-anything-firefly-iii +``` + +## Prerequisites + +- Python 3.10+ +- Running Firefly III instance +- Personal Access Token (PAT) + +## Configuration + +### Environment Variables (Recommended) + +```bash +export FIREFLY_III_BASE_URL="https://firefly.yourdomain.com" +export FIREFLY_III_PAT="your-personal-access-token" +``` + +### Command Line Arguments + +```bash +cli-anything-firefly-iii --base-url https://firefly.yourdomain.com --pat your-token +``` + +## Command Groups + +| Command Group | Description | Corresponding API | +|--------------|-------------|-------------------| +| `accounts` | Account management | `/api/v1/accounts` | +| `transactions` | Transaction management | `/api/v1/transactions` | +| `budgets` | Budget management | `/api/v1/budgets` | +| `categories` | Category management | `/api/v1/categories` | +| `tags` | Tag management | `/api/v1/tags` | +| `bills` | Bill management | `/api/v1/bills` | +| `piggy-banks` | Piggy banks | `/api/v1/piggy-banks` | +| `insights` | Insights and reports | `/api/v1/insight/*` | +| `search` | Search | `/api/v1/search/*` | +| `export` | Data export | `/api/v1/data/export/*` | +| `info` | System information | `/api/v1/about` | + +## Usage Examples + +### Account Management + +```bash +# List all accounts +cli-anything-firefly-iii --json accounts list + +# List asset accounts +cli-anything-firefly-iii --json accounts list --type asset + +# Get account details +cli-anything-firefly-iii --json accounts get --id 123 + +# Create account +cli-anything-firefly-iii --json accounts create --name "Cash" --type asset --currency-code USD + +# Delete account +cli-anything-firefly-iii accounts delete --id 123 +``` + +### Transaction Management + +```bash +# List transactions +cli-anything-firefly-iii --json transactions list --limit 10 + +# Create transaction +cli-anything-firefly-iii --json transactions create \ + --description "Grocery" \ + --amount 50.00 \ + --source-account 1 \ + --category "Food" + +# Get transaction details +cli-anything-firefly-iii --json transactions get --id 456 + +# Delete transaction +cli-anything-firefly-iii transactions delete --id 456 +``` + +### Insights and Reports + +```bash +# Expense report (by category) +cli-anything-firefly-iii --json insights expense \ + --start 2024-01-01 \ + --end 2024-01-31 \ + --group-by category + +# Income report +cli-anything-firefly-iii --json insights income \ + --start 2024-01-01 \ + --end 2024-01-31 + +# Account overview +cli-anything-firefly-iii --json insights overview \ + --start 2024-01-01 \ + --end 2024-01-31 +``` + +### Search + +```bash +# Search transactions +cli-anything-firefly-iii --json search transactions --query "grocery" +``` + +### Data Export + +```bash +# Export transactions +cli-anything-firefly-iii --json export transactions \ + --start 2024-01-01 \ + --end 2024-01-31 + +# Export accounts +cli-anything-firefly-iii --json export accounts +``` + +### System Information + +```bash +# System information +cli-anything-firefly-iii --json info about + +# Connection status +cli-anything-firefly-iii info status +``` + +## Preset Filtering + +Use `--preset` parameter to filter available commands: + +```bash +# Default preset +cli-anything-firefly-iii --preset default accounts list + +# Full preset +cli-anything-firefly-iii --preset full accounts list + +# Budget preset +cli-anything-firefly-iii --preset budget budgets list + +# Reporting preset +cli-anything-firefly-iii --preset reporting insights expense --start 2024-01-01 --end 2024-01-31 +``` + +Available presets: +- `default`: Core features (accounts, transactions, categories, tags, bills, search) +- `full`: All features +- `basic`: Basic features (accounts, transactions, categories, tags, search) +- `budget`: Budget-related (accounts, budgets, transactions, summary, insight) +- `reporting`: Reporting-related (accounts, transactions, categories, insight, summary, search) +- `admin`: Admin features (about, configuration, currencies, users, preferences) +- `automation`: Automation (rules, recurrences, webhooks, transactions) + +## Agent Guidelines + +### Basic Usage + +1. **Use `--json` for structured output**: All commands support `--json` flag, returning JSON format data +2. **Call `info status` first to check connection**: Confirm Firefly III connection is normal before executing operations +3. **Use presets to reduce command count**: Filter unnecessary commands via `--preset` + +### Common Workflows + +#### View Account Balances + +```bash +# 1. Check connection +cli-anything-firefly-iii info status + +# 2. List asset accounts +cli-anything-firefly-iii --json accounts list --type asset + +# 3. View account details (get balance) +cli-anything-firefly-iii --json accounts get --id +``` + +#### Record Expense + +```bash +# 1. Find expense accounts +cli-anything-firefly-iii --json accounts list --type expense + +# 2. Create transaction +cli-anything-firefly-iii --json transactions create \ + --description "Lunch" \ + --amount 15.50 \ + --source-account \ + --destination-account \ + --category "Food" +``` + +#### Monthly Report + +```bash +# 1. Expense report +cli-anything-firefly-iii --json insights expense \ + --start 2024-01-01 \ + --end 2024-01-31 \ + --group-by category + +# 2. Income report +cli-anything-firefly-iii --json insights income \ + --start 2024-01-01 \ + --end 2024-01-31 + +# 3. Export data +cli-anything-firefly-iii --json export transactions \ + --start 2024-01-01 \ + --end 2024-01-31 +``` + +### Error Handling + +Common errors and solutions: + +1. **Connection failed**: Check if FIREFLY_III_BASE_URL is correct +2. **Authentication failed**: Check if FIREFLY_III_PAT is valid +3. **Resource not found**: Check if ID is correct +4. **Parameter error**: Check if required parameters are provided + +### Best Practices + +1. **Use environment variables for credentials**: Avoid exposing PAT in command line +2. **Use `--json` for scripting**: Facilitates parsing and processing output +3. **Use presets to control permissions**: Choose appropriate preset based on scenario +4. **Query before modifying**: Avoid accidental operations + +## Troubleshooting + +### Connection Issues + +``` +Error: Cannot connect to Firefly III instance +``` + +- Check if Firefly III instance is running +- Check network connection +- Check if base URL is correct + +### Authentication Issues + +``` +Error: Authentication failed: Personal Access Token is invalid +``` + +- Check if PAT is correct +- Generate new PAT in Firefly III Options > Profile > OAuth +- Ensure PAT has not expired + +## Comparison with MCP Version + +| Feature | MCP Version | CLI-Anything Version | +|---------|------------|---------------------| +| Process Lifecycle | Long-running | Single call, immediate exit | +| Memory Usage | Continuous | On-demand, released after | +| Communication | Stdio/SSE | Command args + stdout | +| State Management | Stateful | Stateless | +| Preset Filtering | Supported | Supported | +| JSON Output | Built-in | `--json` flag | + +## License + +MIT License