Skill: lextoumbourou/pocketsmith-skill (version v1.0.0)

--- name: pocketsmith description: Manage PocketSmith transactions, categories, and financial data via the API metadata: {"openclaw": {"category": "finance", "requires": {"env": ["POCKETSMITH_DEVELOPER_KEY"]}, "optional_env": ["POCKETSMITH_ALLOW_WRITES"]}} --- # PocketSmith Skill Manage PocketSmith transactions and categories. Supports listing, searching, creating, updating, and deleting financial data. ## Prerequisites Set these environment variables: - `POCKETSMITH_DEVELOPER_KEY` - Your PocketSmith developer key (from Settings > Security > Manage Developer Keys) - `POCKETSMITH_ALLOW_WRITES` - Set to `true` to enable create, update, and delete operations (disabled by default for safety) ## Commands All commands should be run from the skill directory using `uv run pocketsmith`. ### Authentication ```bash # Check authentication status and get user info pocketsmith auth status # Get current user details pocketsmith me ``` ### Transactions ```bash # Get a single transaction pocketsmith transactions get <transaction_id> # List transactions for a user pocketsmith transactions list-by-user <user_id> pocketsmith transactions list-by-user <user_id> --start-date 2024-01-01 --end-date 2024-12-31 pocketsmith transactions list-by-user <user_id> --search "coffee" pocketsmith transactions list-by-user <user_id> --uncategorised pocketsmith transactions list-by-user <user_id> --needs-review pocketsmith transactions list-by-user <user_id> --type debit # List transactions by account pocketsmith transactions list-by-account <account_id> # List transactions by category pocketsmith transactions list-by-category <category_ids> # comma-separated # List transactions by transaction account pocketsmith transactions list-by-transaction-account <transaction_account_id> # Create a transaction (requires POCKETSMITH_ALLOW_WRITES=true) pocketsmith transactions create <transaction_account_id> --payee "Store Name" --amount -50.00 --date 2024-01-15 pocketsmith transactions create <transaction_account_id> --payee "Salary" --amount 5000.00 --date 2024-01-01 --category-id 123 # Update a transaction (requires POCKETSMITH_ALLOW_WRITES=true) pocketsmith transactions update <transaction_id> --category-id 456 pocketsmith transactions update <transaction_id> --payee "New Payee" --note "Updated note" pocketsmith transactions update <transaction_id> --labels "groceries,essential" # Delete a transaction (requires POCKETSMITH_ALLOW_WRITES=true) pocketsmith transactions delete <transaction_id> ``` ### Categories ```bash # Get a single category pocketsmith categories get <category_id> # List all categories for a user pocketsmith categories list <user_id> # Create a category (requires POCKETSMITH_ALLOW_WRITES=true) pocketsmith categories create <user_id> --title "New Category" pocketsmith categories create <user_id> --title "Subcategory" --parent-id 123 pocketsmith categories create <user_id> --title "Bills" --colour "#ff0000" --is-bill true # Update a category (requires POCKETSMITH_ALLOW_WRITES=true) pocketsmith categories update <category_id> --title "Renamed Category" pocketsmith categories update <category_id> --parent-id 456 pocketsmith categories update <category_id> --no-parent # Make top-level pocketsmith categories update <category_id> --colour "#00ff00" # Delete a category (requires POCKETSMITH_ALLOW_WRITES=true) pocketsmith categories delete <category_id> ``` ### Labels ```bash # List all labels for a user pocketsmith labels list <user_id> ``` ### Budget ```bash # List budget for a user (per-category budget analysis) pocketsmith budget list <user_id> pocketsmith budget list <user_id> --roll-up true # Get budget summary for a user pocketsmith budget summary <user_id> --period months --interval 1 --start-date 2024-01-01 --end-date 2024-12-31 # Get trend analysis (requires category and scenario IDs) pocketsmith budget trend <user_id> --period months --interval 1 --start-date 2024-01-01 --end-date 2024-12-31 --categories "123,456" --scenarios "1,2" # Refresh forecast cache (requires POCKETSMITH_ALLOW_WRITES=true) pocketsmith budget refresh <user_id> ``` ## Transaction Filter Options When listing transactions, these filters are available: - `--start-date` - Filter from date (YYYY-MM-DD) - `--end-date` - Filter to date (YYYY-MM-DD) - `--updated-since` - Only transactions updated after this datetime - `--uncategorised` - Only uncategorised transactions - `--type` - Filter by type: `debit` or `credit` - `--needs-review` - Only transactions needing review - `--search` - Search term for payee/memo - `--page` - Page number for pagination ## Category Options When creating/updating categories: - `--title` - Category name - `--colour` - CSS hex colour (e.g., `#ff0000`) - `--parent-id` - Parent category ID for subcategories - `--no-parent` - Make category top-level (update only) - `--is-transfer` - Mark as transfer category (true/false) - `--is-bill` - Mark as bill category (true/false) - `--roll-up` - Roll up to parent category (true/false) - `--refund-behaviour` - `debit_only` or `credit_only` ## Output Format All commands output JSON. Example transaction: ```json { "id": 1234567, "payee": "Coffee Shop", "amount": -5.50, "date": "2024-01-15", "category": { "id": 123, "title": "Eating Out" }, "transaction_account": { "id": 456, "name": "Checking Account" } } ``` ## Date Format All dates use `YYYY-MM-DD` format (e.g., `2024-01-15`). ## Write Protection Write operations (create, update, delete) are **disabled by default** for safety. To enable them: ```bash export POCKETSMITH_ALLOW_WRITES=true ``` Without this, write commands will fail with: ```json {"error": "Write operations are disabled by default. Set POCKETSMITH_ALLOW_WRITES=true to enable create, update, and delete operations.", "hint": "export POCKETSMITH_ALLOW_WRITES=true"} ``` ## Common Workflows ### Search and Categorize Transactions ```bash # Find uncategorised transactions pocketsmith transactions list-by-user 123456 --uncategorised # Search for specific transactions pocketsmith transactions list-by-user 123456 --search "Netflix" # Categorize a transaction pocketsmith transactions update 789012 --category-id 456 ``` ### Organize Categories ```bash # List existing categories pocketsmith categories list 123456 # Create a new subcategory pocketsmith categories create 123456 --title "Streaming" --parent-id 789 # Move a category under a different parent pocketsmith categories update 101112 --parent-id 789 ``` ### Review Transactions ```bash # Find transactions needing review pocketsmith transactions list-by-user 123456 --needs-review # Mark as reviewed by updating pocketsmith transactions update 789012 --needs-review false ```

path: pyproject.toml
content: [project]
name = "pocketsmith-cli"
version = "1.0.0"
description = "CLI tool for accessing PocketSmith financial data via the API"
requires-python = ">=3.10"
dependencies = [
    "httpx>=0.27.0",
]

[project.optional-dependencies]
dev = [
    "pytest>=8.0.0",
    "pytest-httpx>=0.30.0",
]

[project.scripts]
pocketsmith = "pocketsmith.cli:main"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/pocketsmith"]


path: README.md
content: # PocketSmith Skill

> Manage PocketSmith transactions, categories, and financial data via the API.

**Repository:** https://github.com/lextoumbourou/pocketsmith-skill

## Features

- **Transactions** - List, view, create, update, and delete transactions
- **Categories** - Full CRUD for spending categories
- **User Info** - View authenticated user details
- **Write Protection** - Safe by default, write operations require explicit opt-in

## Installation

### Claude Code

Claude Code can automatically discover and use this skill. Install it as a personal skill (available across all projects) or a project skill (specific to one project).

**Personal skill** (recommended):

```bash
# Clone into your personal skills directory
mkdir -p ~/.claude/skills
cd ~/.claude/skills
git clone https://github.com/lextoumbourou/pocketsmith-skill.git pocketsmith

# Install dependencies
cd pocketsmith
uv sync
```

**Project skill** (for a specific project):

```bash
# Clone into your project's skills directory
mkdir -p .claude/skills
cd .claude/skills
git clone https://github.com/lextoumbourou/pocketsmith-skill.git pocketsmith

# Install dependencies
cd pocketsmith
uv sync
```

Once installed, Claude Code will automatically load the skill when relevant, or you can invoke it directly with `/pocketsmith`.

### OpenClaw

Install as a managed skill (available to all agents) or workspace skill (specific to one workspace).

**Managed skill** (recommended):

```bash
# Clone into your managed skills directory
mkdir -p ~/.openclaw/skills
cd ~/.openclaw/skills
git clone https://github.com/lextoumbourou/pocketsmith-skill.git pocketsmith

# Install dependencies
cd pocketsmith
uv sync
```

**Workspace skill** (for a specific workspace):

```bash
# Clone into your workspace's skills directory
mkdir -p ./skills
cd ./skills
git clone https://github.com/lextoumbourou/pocketsmith-skill.git pocketsmith

# Install dependencies
cd pocketsmith
uv sync
```

### Verify Installation

```bash
cd ~/.claude/skills/pocketsmith  # or respective directory for your setup
uv run pocketsmith --help
```

## Setup

### 1. Get PocketSmith Developer Key

1. Log in to [PocketSmith](https://my.pocketsmith.com/)
2. Go to **Settings** > **Security** > **Manage Developer Keys**
3. Create a new developer key and copy it

### 2. Set Environment Variables

**For Claude Code** (`~/.claude/settings.json`):

```json
{
  "env": {
    "POCKETSMITH_DEVELOPER_KEY": "your_developer_key"
  }
}
```

**For OpenClaw** (`~/.openclaw/openclaw.json`):

```json
{
  "skills": {
    "entries": {
      "pocketsmith": {
        "enabled": true,
        "env": {
          "POCKETSMITH_DEVELOPER_KEY": "your_developer_key"
        }
      }
    }
  }
}
```

**For shell usage** (`~/.bashrc` or `~/.zshrc`):

```bash
export POCKETSMITH_DEVELOPER_KEY="your_developer_key"
```

### 3. Enable Write Operations (Optional)

Write operations (create, update, delete) are disabled by default for safety. To enable:

**For Claude Code** (`~/.claude/settings.json`):

```json
{
  "env": {
    "POCKETSMITH_DEVELOPER_KEY": "your_developer_key",
    "POCKETSMITH_ALLOW_WRITES": "true"
  }
}
```

**For OpenClaw** (`~/.openclaw/openclaw.json`):

```json
{
  "skills": {
    "entries": {
      "pocketsmith": {
        "enabled": true,
        "env": {
          "POCKETSMITH_DEVELOPER_KEY": "your_developer_key",
          "POCKETSMITH_ALLOW_WRITES": "true"
        }
      }
    }
  }
}
```

**For shell**:

```bash
export POCKETSMITH_ALLOW_WRITES=true
```

### 4. Verify Authentication

```bash
uv run pocketsmith auth status
```

## Usage

### CLI Commands

```bash
# Get current user
uv run pocketsmith me

# List transactions for a user
uv run pocketsmith transactions list-by-user 123456

# Search transactions
uv run pocketsmith transactions list-by-user 123456 --search "coffee" --start-date 2024-01-01

# Get a specific transaction
uv run pocketsmith transactions get 987654

# Update a transaction (requires POCKETSMITH_ALLOW_WRITES=true)
uv run pocketsmith transactions update 987654 --category-id 28637787

# Create a transaction
uv run pocketsmith transactions create 456789 --payee "Coffee Shop" --amount -5.50 --date 2024-01-15

# List categories
uv run pocketsmith categories list 123456

# Create a category
uv run pocketsmith categories create 123456 --title "Subscriptions" --parent-id 28601039

# Get help
uv run pocketsmith --help
uv run pocketsmith transactions --help
uv run pocketsmith categories --help
```

### In Claude Code / OpenClaw

Once the skill is installed, Claude will automatically use it when relevant. Just ask naturally:

- "Show me my PocketSmith transactions from last month"
- "Find all transactions containing 'Netflix'"
- "Categorize transaction 123456 as Subscriptions"
- "Create a new category called 'Side Projects' under Entertainment"
- "List all my spending categories"

You can also invoke the skill directly with `/pocketsmith` to see available commands.

## API Reference

See [IMPLEMENTATION_NOTES.md](./IMPLEMENTATION_NOTES.md) for implementation details and endpoint coverage.

## Output

All commands output JSON to stdout. Errors are written to stderr.

## Development

```bash
# Install dev dependencies
uv sync --extra dev

# Run tests
uv run pytest tests/ -v

# Run directly
uv run pocketsmith me
```

## Troubleshooting

| Error | Solution |
|-------|----------|
| `POCKETSMITH_DEVELOPER_KEY environment variable is required` | Set `POCKETSMITH_DEVELOPER_KEY` |
| `Write operations are disabled` | Set `POCKETSMITH_ALLOW_WRITES=true` |
| `401 Unauthorized` | Check your developer key is valid |
| `404 Not Found` | Check the resource ID exists |

## License

MIT


path: IMPLEMENTATION_NOTES.md
content: # PocketSmith Skill Implementation Notes

## Overview

CLI tool for accessing PocketSmith financial data via the API. Based on the sharesight-skill architecture.

**API Base URL:** `https://api.pocketsmith.com/v2`
**Auth Method:** `X-Developer-Key` header (simpler than OAuth)
**Docs:** https://developers.pocketsmith.com/reference

## Environment Variables

| Variable | Required | Description |
|----------|----------|-------------|
| `POCKETSMITH_DEVELOPER_KEY` | Yes | API key from PocketSmith Settings > Security |
| `POCKETSMITH_ALLOW_WRITES` | No | Set to `true` to enable create/update/delete |

## Implementation Progress

### Completed

- [x] **Core Infrastructure**
  - `auth.py` - Developer key management
  - `client.py` - HTTP client with `X-Developer-Key` header
  - `api.py` - API wrapper
  - `cli.py` - argparse CLI with write protection

- [x] **User**
  - `GET /me` - Get authenticated user

- [x] **Transactions**
  - `GET /transactions/{id}` - Get single transaction
  - `PUT /transactions/{id}` - Update transaction
  - `DELETE /transactions/{id}` - Delete transaction
  - `GET /users/{id}/transactions` - List by user
  - `GET /accounts/{id}/transactions` - List by account
  - `GET /categories/{id}/transactions` - List by categories
  - `GET /transaction_accounts/{id}/transactions` - List by transaction account
  - `POST /transaction_accounts/{id}/transactions` - Create transaction

- [x] **Categories**
  - `GET /categories/{id}` - Get category
  - `GET /users/{id}/categories` - List categories
  - `POST /users/{id}/categories` - Create category
  - `PUT /categories/{id}` - Update category
  - `DELETE /categories/{id}` - Delete category

- [x] **Labels**
  - `GET /users/{id}/labels` - List labels

- [x] **Budgeting**
  - `GET /users/{id}/budget` - List budget (per-category analysis)
  - `GET /users/{id}/budget_summary` - Get budget summary
  - `GET /users/{id}/trend_analysis` - Get trend analysis
  - `DELETE /users/{id}/forecast_cache` - Refresh forecast cache

### To Do

- [ ] **Category Rules**
  - `GET /users/{id}/category_rules` - List category rules
  - `GET /category_rules/{id}` - Get category rule
  - `POST /categories/{id}/category_rules` - Create category rule
  - `PUT /category_rules/{id}` - Update category rule
  - `DELETE /category_rules/{id}` - Delete category rule

- [ ] **Events**
  - `GET /users/{id}/events` - List events
  - `GET /events/{id}` - Get event
  - `POST /users/{id}/events` - Create event
  - `PUT /events/{id}` - Update event
  - `DELETE /events/{id}` - Delete event

- [ ] **Institutions**
  - `GET /users/{id}/institutions` - List institutions
  - `GET /institutions/{id}` - Get institution
  - `POST /users/{id}/institutions` - Create institution
  - `PUT /institutions/{id}` - Update institution
  - `DELETE /institutions/{id}` - Delete institution

- [ ] **Accounts**
  - `GET /users/{id}/accounts` - List accounts
  - `GET /accounts/{id}` - Get account
  - `POST /users/{id}/accounts` - Create account
  - `PUT /accounts/{id}` - Update account
  - `DELETE /accounts/{id}` - Delete account

- [ ] **Transaction Accounts**
  - `GET /users/{id}/transaction_accounts` - List transaction accounts
  - `GET /transaction_accounts/{id}` - Get transaction account
  - `PUT /transaction_accounts/{id}` - Update transaction account

- [ ] **Attachments**
  - `GET /users/{id}/attachments` - List attachments
  - `GET /attachments/{id}` - Get attachment
  - `POST /transactions/{id}/attachments` - Create attachment
  - `PUT /attachments/{id}` - Update attachment
  - `DELETE /attachments/{id}` - Delete attachment

## Architecture

```
src/pocketsmith/
├── __init__.py      # Package version
├── __main__.py      # Entry point for python -m pocketsmith
├── auth.py          # Developer key management
├── client.py        # HTTP client with auth headers
├── api.py           # High-level API methods
└── cli.py           # argparse CLI interface
```

### Write Protection

Write operations (create, update, delete) are disabled by default. The `@requires_write_permission` decorator checks for `POCKETSMITH_ALLOW_WRITES=true` before executing.

```python
@requires_write_permission
def cmd_transactions_create(args):
    ...
```

### CLI Command Pattern

Commands follow the pattern: `pocketsmith <resource> <action> [args]`

```bash
pocketsmith transactions get 123
pocketsmith transactions list-by-user 456 --start-date 2024-01-01
pocketsmith transactions create 789 --payee "Store" --amount -50.00 --date 2024-01-15
```

## Testing

```bash
# Install
uv sync

# Run CLI
uv run pocketsmith --help
uv run pocketsmith transactions --help
uv run pocketsmith categories --help

# Check auth
uv run pocketsmith auth status

# Test write protection (should fail without POCKETSMITH_ALLOW_WRITES=true)
uv run pocketsmith transactions create 123 --payee "Test" --amount -10 --date 2024-01-01
uv run pocketsmith categories create 123 --title "Test Category"
```


path: src/pocketsmith/auth.py
content: """Authentication for PocketSmith API using Developer Key."""

import os


def get_developer_key() -> str:
    """Get the PocketSmith developer key from environment.

    Returns:
        The developer key string.

    Raises:
        ValueError: If POCKETSMITH_DEVELOPER_KEY is not set.
    """
    key = os.environ.get("POCKETSMITH_DEVELOPER_KEY")
    if not key:
        raise ValueError(
            "POCKETSMITH_DEVELOPER_KEY environment variable is required. "
            "Get your key from PocketSmith Settings > Security > Manage Developer Keys."
        )
    return key


def is_authenticated() -> bool:
    """Check if a developer key is available.

    Returns:
        True if POCKETSMITH_DEVELOPER_KEY is set, False otherwise.
    """
    return bool(os.environ.get("POCKETSMITH_DEVELOPER_KEY"))


path: src/pocketsmith/client.py
content: """HTTP client for PocketSmith API with automatic authentication."""

from typing import Any, Optional

import httpx

from .auth import get_developer_key

BASE_URL = "https://api.pocketsmith.com/v2"


class PocketSmithClient:
    """HTTP client for the PocketSmith API."""

    def __init__(self):
        self._client: Optional[httpx.Client] = None

    def _get_client(self) -> httpx.Client:
        """Get or create the HTTP client."""
        if self._client is None:
            self._client = httpx.Client(
                base_url=BASE_URL,
                timeout=30.0,
            )
        return self._client

    def _get_headers(self) -> dict[str, str]:
        """Get request headers with developer key."""
        key = get_developer_key()
        return {
            "X-Developer-Key": key,
            "Content-Type": "application/json",
            "Accept": "application/json",
        }

    def _request(
        self,
        method: str,
        path: str,
        params: Optional[dict[str, Any]] = None,
        json_data: Optional[dict[str, Any]] = None,
    ) -> dict[str, Any] | list[Any] | None:
        """Make an authenticated request to the API.

        Args:
            method: HTTP method (GET, POST, PUT, DELETE)
            path: API path (e.g., "/transactions/123")
            params: Query parameters
            json_data: JSON body data

        Returns:
            JSON response data (dict, list, or None for 204 responses)

        Raises:
            APIError: If the API returns an error status code
        """
        client = self._get_client()
        headers = self._get_headers()

        response = client.request(
            method=method,
            url=path,
            params=params,
            json=json_data,
            headers=headers,
        )

        if response.status_code >= 400:
            error_detail = response.text
            try:
                error_json = response.json()
                if "error" in error_json:
                    error_detail = error_json["error"]
                elif "message" in error_json:
                    error_detail = error_json["message"]
            except Exception:
                pass
            raise APIError(response.status_code, error_detail)

        # Handle 204 No Content (e.g., successful DELETE)
        if response.status_code == 204:
            return None

        return response.json()

    def get(self, path: str, params: Optional[dict[str, Any]] = None) -> dict[str, Any] | list[Any]:
        """Make a GET request."""
        result = self._request("GET", path, params=params)
        return result if result is not None else {}

    def post(
        self, path: str, json_data: Optional[dict[str, Any]] = None, params: Optional[dict[str, Any]] = None
    ) -> dict[str, Any]:
        """Make a POST request."""
        result = self._request("POST", path, params=params, json_data=json_data)
        return result if result is not None else {}

    def put(
        self, path: str, json_data: Optional[dict[str, Any]] = None, params: Optional[dict[str, Any]] = None
    ) -> dict[str, Any]:
        """Make a PUT request."""
        result = self._request("PUT", path, params=params, json_data=json_data)
        return result if result is not None else {}

    def delete(self, path: str, params: Optional[dict[str, Any]] = None) -> None:
        """Make a DELETE request."""
        self._request("DELETE", path, params=params)

    def close(self) -> None:
        """Close the HTTP client."""
        if self._client:
            self._client.close()
            self._client = None

    def __enter__(self) -> "PocketSmithClient":
        return self

    def __exit__(self, *args) -> None:
        self.close()


class APIError(Exception):
    """API error with status code and message."""

    def __init__(self, status_code: int, message: str):
        self.status_code = status_code
        self.message = message
        super().__init__(f"API Error {status_code}: {message}")


path: src/pocketsmith/__init__.py
content: """PocketSmith CLI - Command-line interface for PocketSmith API."""

__version__ = "1.0.0"


path: src/pocketsmith/api.py
content: """High-level API wrapper for PocketSmith."""

from typing import Any, Optional

from .client import PocketSmithClient


class PocketSmithAPI:
    """High-level API for interacting with PocketSmith."""

    def __init__(self):
        self._client = PocketSmithClient()

    def close(self) -> None:
        """Close the API client."""
        self._client.close()

    def __enter__(self) -> "PocketSmithAPI":
        return self

    def __exit__(self, *args) -> None:
        self.close()

    # -------------------------------------------------------------------------
    # User endpoints
    # -------------------------------------------------------------------------

    def get_me(self) -> dict[str, Any]:
        """Get the current authenticated user.

        Returns:
            User object with id, login, name, email, etc.
        """
        return self._client.get("/me")

    # -------------------------------------------------------------------------
    # Transaction endpoints
    # -------------------------------------------------------------------------

    def get_transaction(self, transaction_id: int) -> dict[str, Any]:
        """Get a single transaction by ID.

        Args:
            transaction_id: The transaction ID.

        Returns:
            Transaction object.
        """
        return self._client.get(f"/transactions/{transaction_id}")

    def update_transaction(
        self,
        transaction_id: int,
        *,
        memo: Optional[str] = None,
        cheque_number: Optional[str] = None,
        payee: Optional[str] = None,
        amount: Optional[float] = None,
        date: Optional[str] = None,
        is_transfer: Optional[bool] = None,
        category_id: Optional[int] = None,
        note: Optional[str] = None,
        needs_review: Optional[bool] = None,
        labels: Optional[list[str]] = None,
    ) -> dict[str, Any]:
        """Update a transaction.

        Args:
            transaction_id: The transaction ID.
            memo: Bank memo/description.
            cheque_number: Cheque number.
            payee: Payee name.
            amount: Signed amount (negative for debits).
            date: Transaction date (YYYY-MM-DD).
            is_transfer: Whether this is a transfer.
            category_id: Category ID to assign.
            note: User note.
            needs_review: Whether transaction needs review.
            labels: List of labels to assign.

        Returns:
            Updated transaction object.
        """
        data: dict[str, Any] = {}
        if memo is not None:
            data["memo"] = memo
        if cheque_number is not None:
            data["cheque_number"] = cheque_number
        if payee is not None:
            data["payee"] = payee
        if amount is not None:
            data["amount"] = amount
        if date is not None:
            data["date"] = date
        if is_transfer is not None:
            data["is_transfer"] = is_transfer
        if category_id is not None:
            data["category_id"] = category_id
        if note is not None:
            data["note"] = note
        if needs_review is not None:
            data["needs_review"] = needs_review
        if labels is not None:
            data["labels"] = labels

        return self._client.put(f"/transactions/{transaction_id}", json_data=data)

    def delete_transaction(self, transaction_id: int) -> None:
        """Delete a transaction.

        Args:
            transaction_id: The transaction ID.
        """
        self._client.delete(f"/transactions/{transaction_id}")

    def list_user_transactions(
        self,
        user_id: int,
        *,
        start_date: Optional[str] = None,
        end_date: Optional[str] = None,
        updated_since: Optional[str] = None,
        uncategorised: Optional[bool] = None,
        type: Optional[str] = None,
        needs_review: Optional[bool] = None,
        search: Optional[str] = None,
        page: Optional[int] = None,
    ) -> list[dict[str, Any]]:
        """List transactions for a user.

        Args:
            user_id: The user ID.
            start_date: Start date filter (YYYY-MM-DD).
            end_date: End date filter (YYYY-MM-DD).
            updated_since: Only transactions updated since this datetime.
            uncategorised: Filter to uncategorised transactions only.
            type: Filter by type ('debit' or 'credit').
            needs_review: Filter to transactions needing review.
            search: Search term for payee/memo.
            page: Page number for pagination.

        Returns:
            List of transaction objects.
        """
        params: dict[str, Any] = {}
        if start_date is not None:
            params["start_date"] = start_date
        if end_date is not None:
            params["end_date"] = end_date
        if updated_since is not None:
            params["updated_since"] = updated_since
        if uncategorised is not None:
            params["uncategorised"] = 1 if uncategorised else 0
        if type is not None:
            params["type"] = type
        if needs_review is not None:
            params["needs_review"] = 1 if needs_review else 0
        if search is not None:
            params["search"] = search
        if page is not None:
            params["page"] = page

        return self._client.get(f"/users/{user_id}/transactions", params=params or None)

    def list_account_transactions(
        self,
        account_id: int,
        *,
        start_date: Optional[str] = None,
        end_date: Optional[str] = None,
        updated_since: Optional[str] = None,
        uncategorised: Optional[bool] = None,
        type: Optional[str] = None,
        needs_review: Optional[bool] = None,
        search: Optional[str] = None,
        page: Optional[int] = None,
    ) -> list[dict[str, Any]]:
        """List transactions for an account.

        Args:
            account_id: The account ID.
            start_date: Start date filter (YYYY-MM-DD).
            end_date: End date filter (YYYY-MM-DD).
            updated_since: Only transactions updated since this datetime.
            uncategorised: Filter to uncategorised transactions only.
            type: Filter by type ('debit' or 'credit').
            needs_review: Filter to transactions needing review.
            search: Search term for payee/memo.
            page: Page number for pagination.

        Returns:
            List of transaction objects.
        """
        params: dict[str, Any] = {}
        if start_date is not None:
            params["start_date"] = start_date
        if end_date is not None:
            params["end_date"] = end_date
        if updated_since is not None:
            params["updated_since"] = updated_since
        if uncategorised is not None:
            params["uncategorised"] = 1 if uncategorised else 0
        if type is not None:
            params["type"] = type
        if needs_review is not None:
            params["needs_review"] = 1 if needs_review else 0
        if search is not None:
            params["search"] = search
        if page is not None:
            params["page"] = page

        return self._client.get(f"/accounts/{account_id}/transactions", params=params or None)

    def list_category_transactions(
        self,
        category_ids: str,
        *,
        start_date: Optional[str] = None,
        end_date: Optional[str] = None,
        updated_since: Optional[str] = None,
        uncategorised: Optional[bool] = None,
        type: Optional[str] = None,
        needs_review: Optional[bool] = None,
        search: Optional[str] = None,
        page: Optional[int] = None,
    ) -> list[dict[str, Any]]:
        """List transactions for categories.

        Args:
            category_ids: Comma-separated category IDs.
            start_date: Start date filter (YYYY-MM-DD).
            end_date: End date filter (YYYY-MM-DD).
            updated_since: Only transactions updated since this datetime.
            uncategorised: Filter to uncategorised transactions only.
            type: Filter by type ('debit' or 'credit').
            needs_review: Filter to transactions needing review.
            search: Search term for payee/memo.
            page: Page number for pagination.

        Returns:
            List of transaction objects.
        """
        params: dict[str, Any] = {}
        if start_date is not None:
            params["start_date"] = start_date
        if end_date is not None:
            params["end_date"] = end_date
        if updated_since is not None:
            params["updated_since"] = updated_since
        if uncategorised is not None:
            params["uncategorised"] = 1 if uncategorised else 0
        if type is not None:
            params["type"] = type
        if needs_review is not None:
            params["needs_review"] = 1 if needs_review else 0
        if search is not None:
            params["search"] = search
        if page is not None:
            params["page"] = page

        return self._client.get(f"/categories/{category_ids}/transactions", params=params or None)

    def list_transaction_account_transactions(
        self,
        transaction_account_id: int,
        *,
        start_date: Optional[str] = None,
        end_date: Optional[str] = None,
        updated_since: Optional[str] = None,
        uncategorised: Optional[bool] = None,
        type: Optional[str] = None,
        needs_review: Optional[bool] = None,
        search: Optional[str] = None,
        page: Optional[int] = None,
    ) -> list[dict[str, Any]]:
        """List transactions for a transaction account.

        Args:
            transaction_account_id: The transaction account ID.
            start_date: Start date filter (YYYY-MM-DD).
            end_date: End date filter (YYYY-MM-DD).
            updated_since: Only transactions updated since this datetime.
            uncategorised: Filter to uncategorised transactions only.
            type: Filter by type ('debit' or 'credit').
            needs_review: Filter to transactions needing review.
            search: Search term for payee/memo.
            page: Page number for pagination.

        Returns:
            List of transaction objects.
        """
        params: dict[str, Any] = {}
        if start_date is not None:
            params["start_date"] = start_date
        if end_date is not None:
            params["end_date"] = end_date
        if updated_since is not None:
            params["updated_since"] = updated_since
        if uncategorised is not None:
            params["uncategorised"] = 1 if uncategorised else 0
        if type is not None:
            params["type"] = type
        if needs_review is not None:
            params["needs_review"] = 1 if needs_review else 0
        if search is not None:
            params["search"] = search
        if page is not None:
            params["page"] = page

        return self._client.get(
            f"/transaction_accounts/{transaction_account_id}/transactions", params=params or None
        )

    def create_transaction(
        self,
        transaction_account_id: int,
        *,
        payee: str,
        amount: float,
        date: str,
        is_transfer: Optional[bool] = None,
        labels: Optional[list[str]] = None,
        category_id: Optional[int] = None,
        note: Optional[str] = None,
        memo: Optional[str] = None,
        cheque_number: Optional[str] = None,
        needs_review: Optional[bool] = None,
    ) -> dict[str, Any]:
        """Create a transaction in a transaction account.

        Args:
            transaction_account_id: The transaction account ID.
            payee: Payee name (required).
            amount: Signed amount - negative for debits (required).
            date: Transaction date YYYY-MM-DD (required).
            is_transfer: Whether this is a transfer.
            labels: List of labels.
            category_id: Category ID.
            note: User note.
            memo: Bank memo.
            cheque_number: Cheque number.
            needs_review: Whether transaction needs review.

        Returns:
            Created transaction object.
        """
        data: dict[str, Any] = {
            "payee": payee,
            "amount": amount,
            "date": date,
        }
        if is_transfer is not None:
            data["is_transfer"] = is_transfer
        if labels is not None:
            data["labels"] = labels
        if category_id is not None:
            data["category_id"] = category_id
        if note is not None:
            data["note"] = note
        if memo is not None:
            data["memo"] = memo
        if cheque_number is not None:
            data["cheque_number"] = cheque_number
        if needs_review is not None:
            data["needs_review"] = needs_review

        return self._client.post(
            f"/transaction_accounts/{transaction_account_id}/transactions", json_data=data
        )

    # -------------------------------------------------------------------------
    # Category endpoints
    # -------------------------------------------------------------------------

    def get_category(self, category_id: int) -> dict[str, Any]:
        """Get a single category by ID.

        Args:
            category_id: The category ID.

        Returns:
            Category object with id, title, colour, children, parent_id, etc.
        """
        return self._client.get(f"/categories/{category_id}")

    def update_category(
        self,
        category_id: int,
        *,
        title: Optional[str] = None,
        colour: Optional[str] = None,
        parent_id: Optional[int] | type[...] = ...,
        is_transfer: Optional[bool] = None,
        is_bill: Optional[bool] = None,
        roll_up: Optional[bool] = None,
        refund_behaviour: Optional[str] = None,
    ) -> dict[str, Any]:
        """Update a category.

        Args:
            category_id: The category ID.
            title: Category title.
            colour: CSS hex colour (e.g., '#ff0000').
            parent_id: Parent category ID. Use None to make top-level, ... to leave unchanged.
            is_transfer: Whether this is a transfer category.
            is_bill: Whether this is a bill category.
            roll_up: Whether to roll up to parent category.
            refund_behaviour: Refund behaviour ('debit_only', 'credit_only', or null).

        Returns:
            Updated category object.
        """
        data: dict[str, Any] = {}
        if title is not None:
            data["title"] = title
        if colour is not None:
            data["colour"] = colour
        if parent_id is not ...:
            data["parent_id"] = parent_id  # Can be int or None (for top-level)
        if is_transfer is not None:
            data["is_transfer"] = is_transfer
        if is_bill is not None:
            data["is_bill"] = is_bill
        if roll_up is not None:
            data["roll_up"] = roll_up
        if refund_behaviour is not None:
            data["refund_behaviour"] = refund_behaviour

        return self._client.put(f"/categories/{category_id}", json_data=data)

    def delete_category(self, category_id: int) -> None:
        """Delete a category.

        Note: This deletes all budgets within the category and uncategorizes
        all transactions assigned to it.

        Args:
            category_id: The category ID.
        """
        self._client.delete(f"/categories/{category_id}")

    def list_categories(self, user_id: int) -> list[dict[str, Any]]:
        """List all categories for a user.

        Args:
            user_id: The user ID.

        Returns:
            List of category objects.
        """
        return self._client.get(f"/users/{user_id}/categories")

    def create_category(
        self,
        user_id: int,
        *,
        title: str,
        colour: Optional[str] = None,
        parent_id: Optional[int] = None,
        is_transfer: Optional[bool] = None,
        is_bill: Optional[bool] = None,
        roll_up: Optional[bool] = None,
        refund_behaviour: Optional[str] = None,
    ) -> dict[str, Any]:
        """Create a category for a user.

        Args:
            user_id: The user ID.
            title: Category title (required).
            colour: CSS hex colour (e.g., '#ff0000').
            parent_id: Parent category ID (null for top-level).
            is_transfer: Whether this is a transfer category.
            is_bill: Whether this is a bill category.
            roll_up: Whether to roll up to parent category.
            refund_behaviour: Refund behaviour ('debit_only', 'credit_only', or null).

        Returns:
            Created category object.
        """
        data: dict[str, Any] = {"title": title}
        if colour is not None:
            data["colour"] = colour
        if parent_id is not None:
            data["parent_id"] = parent_id
        if is_transfer is not None:
            data["is_transfer"] = is_transfer
        if is_bill is not None:
            data["is_bill"] = is_bill
        if roll_up is not None:
            data["roll_up"] = roll_up
        if refund_behaviour is not None:
            data["refund_behaviour"] = refund_behaviour

        return self._client.post(f"/users/{user_id}/categories", json_data=data)

    # -------------------------------------------------------------------------
    # Labels endpoints
    # -------------------------------------------------------------------------

    def list_labels(self, user_id: int) -> list[str]:
        """List all labels for a user.

        Args:
            user_id: The user ID.

        Returns:
            List of label strings.
        """
        return self._client.get(f"/users/{user_id}/labels")

    # -------------------------------------------------------------------------
    # Budget endpoints
    # -------------------------------------------------------------------------

    def list_budget(
        self,
        user_id: int,
        *,
        roll_up: Optional[bool] = None,
    ) -> list[dict[str, Any]]:
        """List budget for a user.

        Returns the user's budget consisting of one or more budget analysis
        packages, one per category.

        Args:
            user_id: The user ID.
            roll_up: Whether to roll up parent category budgets into children.

        Returns:
            List of budget analysis package objects.
        """
        params: dict[str, Any] = {}
        if roll_up is not None:
            params["roll_up"] = 1 if roll_up else 0

        return self._client.get(f"/users/{user_id}/budget", params=params or None)

    def get_budget_summary(
        self,
        user_id: int,
        *,
        period: str,
        interval: int,
        start_date: str,
        end_date: str,
    ) -> list[dict[str, Any]]:
        """Get budget summary for a user.

        Retrieves comprehensive expense/income analysis across all categories
        for specified periods.

        Args:
            user_id: The user ID.
            period: Analysis interval - 'weeks', 'months', 'years', or 'event'.
            interval: Period multiplier.
            start_date: Analysis start date (YYYY-MM-DD).
            end_date: Analysis end date (YYYY-MM-DD).

        Returns:
            List of budget analysis package objects.
        """
        params: dict[str, Any] = {
            "period": period,
            "interval": interval,
            "start_date": start_date,
            "end_date": end_date,
        }

        return self._client.get(f"/users/{user_id}/budget_summary", params=params)

    def get_trend_analysis(
        self,
        user_id: int,
        *,
        period: str,
        interval: int,
        start_date: str,
        end_date: str,
        categories: str,
        scenarios: str,
    ) -> list[dict[str, Any]]:
        """Get trend analysis for a user.

        Produces income/expense budget analysis across multiple categories
        and scenarios.

        Args:
            user_id: The user ID.
            period: Analysis interval - 'weeks', 'months', 'years', or 'event'.
            interval: Period multiplier.
            start_date: Analysis start date (YYYY-MM-DD).
            end_date: Analysis end date (YYYY-MM-DD).
            categories: Comma-separated category IDs.
            scenarios: Comma-separated scenario IDs.

        Returns:
            List of budget analysis package objects.
        """
        params: dict[str, Any] = {
            "period": period,
            "interval": interval,
            "start_date": start_date,
            "end_date": end_date,
            "categories": categories,
            "scenarios": scenarios,
        }

        return self._client.get(f"/users/{user_id}/trend_analysis", params=params)

    def delete_forecast_cache(self, user_id: int) -> None:
        """Delete and refresh the forecast cache for a user.

        This recalculates and refreshes the user's cached forecast.

        Args:
            user_id: The user ID.
        """
        self._client.delete(f"/users/{user_id}/forecast_cache")


path: src/pocketsmith/cli.py
content: """CLI interface for PocketSmith API using argparse."""

import argparse
import functools
import json
import os
import sys
from typing import Any, Callable

from . import __version__
from .api import PocketSmithAPI
from .auth import is_authenticated
from .client import APIError


class WritesDisabledError(Exception):
    """Raised when write operations are attempted without POCKETSMITH_ALLOW_WRITES=true."""

    def __init__(self):
        super().__init__(
            "Write operations are disabled by default. "
            "Set POCKETSMITH_ALLOW_WRITES=true to enable create, update, and delete operations."
        )


def requires_write_permission(func: Callable) -> Callable:
    """Decorator that checks if write operations are allowed before executing."""
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        if os.environ.get("POCKETSMITH_ALLOW_WRITES", "").lower() != "true":
            raise WritesDisabledError()
        return func(*args, **kwargs)
    return wrapper


def print_json(data: Any) -> None:
    """Print data as formatted JSON."""
    print(json.dumps(data, indent=2))


# -----------------------------------------------------------------------------
# Auth commands
# -----------------------------------------------------------------------------


def cmd_auth_status(args: argparse.Namespace) -> int:
    """Check authentication status."""
    authenticated = is_authenticated()
    if authenticated:
        with PocketSmithAPI() as api:
            try:
                user = api.get_me()
                print(json.dumps({
                    "authenticated": True,
                    "user_id": user.get("id"),
                    "login": user.get("login"),
                    "name": user.get("name"),
                }))
                return 0
            except APIError:
                print(json.dumps({"authenticated": False, "error": "Invalid developer key"}))
                return 1
    print(json.dumps({"authenticated": False}))
    return 1


# -----------------------------------------------------------------------------
# User commands
# -----------------------------------------------------------------------------


def cmd_me(args: argparse.Namespace) -> int:
    """Get current user info."""
    with PocketSmithAPI() as api:
        result = api.get_me()
        print_json(result)
    return 0


# -----------------------------------------------------------------------------
# Transaction commands
# -----------------------------------------------------------------------------


def cmd_transactions_get(args: argparse.Namespace) -> int:
    """Get a single transaction."""
    with PocketSmithAPI() as api:
        result = api.get_transaction(args.id)
        print_json(result)
    return 0


@requires_write_permission
def cmd_transactions_update(args: argparse.Namespace) -> int:
    """Update a transaction."""
    labels = None
    if args.labels is not None:
        labels = [l.strip() for l in args.labels.split(",") if l.strip()]

    with PocketSmithAPI() as api:
        result = api.update_transaction(
            args.id,
            memo=args.memo,
            cheque_number=args.cheque_number,
            payee=args.payee,
            amount=args.amount,
            date=args.date,
            is_transfer=args.is_transfer,
            category_id=args.category_id,
            note=args.note,
            needs_review=args.needs_review,
            labels=labels,
        )
        print_json(result)
    return 0


@requires_write_permission
def cmd_transactions_delete(args: argparse.Namespace) -> int:
    """Delete a transaction."""
    with PocketSmithAPI() as api:
        api.delete_transaction(args.id)
        print(json.dumps({"status": "success", "message": f"Transaction {args.id} deleted"}))
    return 0


def cmd_transactions_list_user(args: argparse.Namespace) -> int:
    """List transactions for a user."""
    with PocketSmithAPI() as api:
        result = api.list_user_transactions(
            args.user_id,
            start_date=args.start_date,
            end_date=args.end_date,
            updated_since=args.updated_since,
            uncategorised=args.uncategorised,
            type=args.type,
            needs_review=args.needs_review,
            search=args.search,
            page=args.page,
        )
        print_json(result)
    return 0


def cmd_transactions_list_account(args: argparse.Namespace) -> int:
    """List transactions for an account."""
    with PocketSmithAPI() as api:
        result = api.list_account_transactions(
            args.account_id,
            start_date=args.start_date,
            end_date=args.end_date,
            updated_since=args.updated_since,
            uncategorised=args.uncategorised,
            type=args.type,
            needs_review=args.needs_review,
            search=args.search,
            page=args.page,
        )
        print_json(result)
    return 0


def cmd_transactions_list_category(args: argparse.Namespace) -> int:
    """List transactions for categories."""
    with PocketSmithAPI() as api:
        result = api.list_category_transactions(
            args.category_ids,
            start_date=args.start_date,
            end_date=args.end_date,
            updated_since=args.updated_since,
            uncategorised=args.uncategorised,
            type=args.type,
            needs_review=args.needs_review,
            search=args.search,
            page=args.page,
        )
        print_json(result)
    return 0


def cmd_transactions_list_transaction_account(args: argparse.Namespace) -> int:
    """List transactions for a transaction account."""
    with PocketSmithAPI() as api:
        result = api.list_transaction_account_transactions(
            args.transaction_account_id,
            start_date=args.start_date,
            end_date=args.end_date,
            updated_since=args.updated_since,
            uncategorised=args.uncategorised,
            type=args.type,
            needs_review=args.needs_review,
            search=args.search,
            page=args.page,
        )
        print_json(result)
    return 0


@requires_write_permission
def cmd_transactions_create(args: argparse.Namespace) -> int:
    """Create a transaction."""
    labels = None
    if args.labels is not None:
        labels = [l.strip() for l in args.labels.split(",") if l.strip()]

    with PocketSmithAPI() as api:
        result = api.create_transaction(
            args.transaction_account_id,
            payee=args.payee,
            amount=args.amount,
            date=args.date,
            is_transfer=args.is_transfer,
            labels=labels,
            category_id=args.category_id,
            note=args.note,
            memo=args.memo,
            cheque_number=args.cheque_number,
            needs_review=args.needs_review,
        )
        print_json(result)
    return 0


# -----------------------------------------------------------------------------
# Category commands
# -----------------------------------------------------------------------------


def cmd_categories_get(args: argparse.Namespace) -> int:
    """Get a single category."""
    with PocketSmithAPI() as api:
        result = api.get_category(args.id)
        print_json(result)
    return 0


def cmd_categories_list(args: argparse.Namespace) -> int:
    """List categories for a user."""
    with PocketSmithAPI() as api:
        result = api.list_categories(args.user_id)
        print_json(result)
    return 0


@requires_write_permission
def cmd_categories_create(args: argparse.Namespace) -> int:
    """Create a category."""
    with PocketSmithAPI() as api:
        result = api.create_category(
            args.user_id,
            title=args.title,
            colour=args.colour,
            parent_id=args.parent_id,
            is_transfer=args.is_transfer,
            is_bill=args.is_bill,
            roll_up=args.roll_up,
            refund_behaviour=args.refund_behaviour,
        )
        print_json(result)
    return 0


@requires_write_permission
def cmd_categories_update(args: argparse.Namespace) -> int:
    """Update a category."""
    # Handle parent_id: ... means not provided, None means top-level, int means set parent
    parent_id_value: int | None | type[...] = ...
    if args.no_parent:
        parent_id_value = None  # Make top-level
    elif args.parent_id is not None:
        parent_id_value = args.parent_id

    with PocketSmithAPI() as api:
        result = api.update_category(
            args.id,
            title=args.title,
            colour=args.colour,
            parent_id=parent_id_value,
            is_transfer=args.is_transfer,
            is_bill=args.is_bill,
            roll_up=args.roll_up,
            refund_behaviour=args.refund_behaviour,
        )
        print_json(result)
    return 0


@requires_write_permission
def cmd_categories_delete(args: argparse.Namespace) -> int:
    """Delete a category."""
    with PocketSmithAPI() as api:
        api.delete_category(args.id)
        print(json.dumps({"status": "success", "message": f"Category {args.id} deleted"}))
    return 0


# -----------------------------------------------------------------------------
# Labels commands
# -----------------------------------------------------------------------------


def cmd_labels_list(args: argparse.Namespace) -> int:
    """List labels for a user."""
    with PocketSmithAPI() as api:
        result = api.list_labels(args.user_id)
        print_json(result)
    return 0


# -----------------------------------------------------------------------------
# Budget commands
# -----------------------------------------------------------------------------


def cmd_budget_list(args: argparse.Namespace) -> int:
    """List budget for a user."""
    with PocketSmithAPI() as api:
        result = api.list_budget(args.user_id, roll_up=args.roll_up)
        print_json(result)
    return 0


def cmd_budget_summary(args: argparse.Namespace) -> int:
    """Get budget summary for a user."""
    with PocketSmithAPI() as api:
        result = api.get_budget_summary(
            args.user_id,
            period=args.period,
            interval=args.interval,
            start_date=args.start_date,
            end_date=args.end_date,
        )
        print_json(result)
    return 0


def cmd_budget_trend(args: argparse.Namespace) -> int:
    """Get trend analysis for a user."""
    with PocketSmithAPI() as api:
        result = api.get_trend_analysis(
            args.user_id,
            period=args.period,
            interval=args.interval,
            start_date=args.start_date,
            end_date=args.end_date,
            categories=args.categories,
            scenarios=args.scenarios,
        )
        print_json(result)
    return 0


@requires_write_permission
def cmd_budget_refresh(args: argparse.Namespace) -> int:
    """Refresh forecast cache for a user."""
    with PocketSmithAPI() as api:
        api.delete_forecast_cache(args.user_id)
        print(json.dumps({"status": "success", "message": f"Forecast cache refreshed for user {args.user_id}"}))
    return 0


# -----------------------------------------------------------------------------
# Parser
# -----------------------------------------------------------------------------


def add_transaction_filter_args(parser: argparse.ArgumentParser) -> None:
    """Add common transaction filter arguments to a parser."""
    parser.add_argument("--start-date", help="Start date filter (YYYY-MM-DD)")
    parser.add_argument("--end-date", help="End date filter (YYYY-MM-DD)")
    parser.add_argument("--updated-since", help="Only transactions updated since this datetime")
    parser.add_argument("--uncategorised", action="store_true", help="Only uncategorised transactions")
    parser.add_argument("--type", choices=["debit", "credit"], help="Filter by transaction type")
    parser.add_argument("--needs-review", action="store_true", help="Only transactions needing review")
    parser.add_argument("--search", help="Search term for payee/memo")
    parser.add_argument("--page", type=int, help="Page number for pagination")


def create_parser() -> argparse.ArgumentParser:
    """Create the argument parser."""
    parser = argparse.ArgumentParser(
        prog="pocketsmith",
        description="CLI for accessing PocketSmith financial data via the API",
    )
    parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")

    subparsers = parser.add_subparsers(dest="command", help="Available commands")

    # -------------------------------------------------------------------------
    # Auth commands
    # -------------------------------------------------------------------------
    auth_parser = subparsers.add_parser("auth", help="Authentication commands")
    auth_subparsers = auth_parser.add_subparsers(dest="auth_command")
    auth_subparsers.add_parser("status", help="Check authentication status")

    # -------------------------------------------------------------------------
    # Me command
    # -------------------------------------------------------------------------
    subparsers.add_parser("me", help="Get current user info")

    # -------------------------------------------------------------------------
    # Transaction commands
    # -------------------------------------------------------------------------
    tx_parser = subparsers.add_parser("transactions", help="Transaction commands")
    tx_subparsers = tx_parser.add_subparsers(dest="transactions_command")

    # Get single transaction
    tx_get = tx_subparsers.add_parser("get", help="Get a single transaction")
    tx_get.add_argument("id", type=int, help="Transaction ID")

    # Update transaction
    tx_update = tx_subparsers.add_parser("update", help="Update a transaction")
    tx_update.add_argument("id", type=int, help="Transaction ID")
    tx_update.add_argument("--memo", help="Bank memo/description")
    tx_update.add_argument("--cheque-number", help="Cheque number")
    tx_update.add_argument("--payee", help="Payee name")
    tx_update.add_argument("--amount", type=float, help="Signed amount (negative for debits)")
    tx_update.add_argument("--date", help="Transaction date (YYYY-MM-DD)")
    tx_update.add_argument("--is-transfer", type=lambda x: x.lower() == "true", help="Is transfer (true/false)")
    tx_update.add_argument("--category-id", type=int, help="Category ID")
    tx_update.add_argument("--note", help="User note")
    tx_update.add_argument("--needs-review", type=lambda x: x.lower() == "true", help="Needs review (true/false)")
    tx_update.add_argument("--labels", help="Comma-separated labels")

    # Delete transaction
    tx_delete = tx_subparsers.add_parser("delete", help="Delete a transaction")
    tx_delete.add_argument("id", type=int, help="Transaction ID")

    # List transactions by user
    tx_list_user = tx_subparsers.add_parser("list-by-user", help="List transactions for a user")
    tx_list_user.add_argument("user_id", type=int, help="User ID")
    add_transaction_filter_args(tx_list_user)

    # List transactions by account
    tx_list_account = tx_subparsers.add_parser("list-by-account", help="List transactions for an account")
    tx_list_account.add_argument("account_id", type=int, help="Account ID")
    add_transaction_filter_args(tx_list_account)

    # List transactions by category
    tx_list_category = tx_subparsers.add_parser("list-by-category", help="List transactions for categories")
    tx_list_category.add_argument("category_ids", help="Comma-separated category IDs")
    add_transaction_filter_args(tx_list_category)

    # List transactions by transaction account
    tx_list_ta = tx_subparsers.add_parser("list-by-transaction-account", help="List transactions for a transaction account")
    tx_list_ta.add_argument("transaction_account_id", type=int, help="Transaction account ID")
    add_transaction_filter_args(tx_list_ta)

    # Create transaction
    tx_create = tx_subparsers.add_parser("create", help="Create a transaction")
    tx_create.add_argument("transaction_account_id", type=int, help="Transaction account ID")
    tx_create.add_argument("--payee", required=True, help="Payee name")
    tx_create.add_argument("--amount", type=float, required=True, help="Signed amount (negative for debits)")
    tx_create.add_argument("--date", required=True, help="Transaction date (YYYY-MM-DD)")
    tx_create.add_argument("--is-transfer", type=lambda x: x.lower() == "true", help="Is transfer (true/false)")
    tx_create.add_argument("--labels", help="Comma-separated labels")
    tx_create.add_argument("--category-id", type=int, help="Category ID")
    tx_create.add_argument("--note", help="User note")
    tx_create.add_argument("--memo", help="Bank memo")
    tx_create.add_argument("--cheque-number", help="Cheque number")
    tx_create.add_argument("--needs-review", type=lambda x: x.lower() == "true", help="Needs review (true/false)")

    # -------------------------------------------------------------------------
    # Category commands
    # -------------------------------------------------------------------------
    cat_parser = subparsers.add_parser("categories", help="Category commands")
    cat_subparsers = cat_parser.add_subparsers(dest="categories_command")

    # Get single category
    cat_get = cat_subparsers.add_parser("get", help="Get a single category")
    cat_get.add_argument("id", type=int, help="Category ID")

    # List categories
    cat_list = cat_subparsers.add_parser("list", help="List categories for a user")
    cat_list.add_argument("user_id", type=int, help="User ID")

    # Create category
    cat_create = cat_subparsers.add_parser("create", help="Create a category")
    cat_create.add_argument("user_id", type=int, help="User ID")
    cat_create.add_argument("--title", required=True, help="Category title")
    cat_create.add_argument("--colour", help="CSS hex colour (e.g., '#ff0000')")
    cat_create.add_argument("--parent-id", type=int, help="Parent category ID")
    cat_create.add_argument("--is-transfer", type=lambda x: x.lower() == "true", help="Is transfer category (true/false)")
    cat_create.add_argument("--is-bill", type=lambda x: x.lower() == "true", help="Is bill category (true/false)")
    cat_create.add_argument("--roll-up", type=lambda x: x.lower() == "true", help="Roll up to parent (true/false)")
    cat_create.add_argument("--refund-behaviour", choices=["debit_only", "credit_only"], help="Refund behaviour")

    # Update category
    cat_update = cat_subparsers.add_parser("update", help="Update a category")
    cat_update.add_argument("id", type=int, help="Category ID")
    cat_update.add_argument("--title", help="Category title")
    cat_update.add_argument("--colour", help="CSS hex colour (e.g., '#ff0000')")
    cat_update.add_argument("--parent-id", type=int, help="Parent category ID")
    cat_update.add_argument("--no-parent", action="store_true", help="Make this a top-level category")
    cat_update.add_argument("--is-transfer", type=lambda x: x.lower() == "true", help="Is transfer category (true/false)")
    cat_update.add_argument("--is-bill", type=lambda x: x.lower() == "true", help="Is bill category (true/false)")
    cat_update.add_argument("--roll-up", type=lambda x: x.lower() == "true", help="Roll up to parent (true/false)")
    cat_update.add_argument("--refund-behaviour", choices=["debit_only", "credit_only"], help="Refund behaviour")

    # Delete category
    cat_delete = cat_subparsers.add_parser("delete", help="Delete a category")
    cat_delete.add_argument("id", type=int, help="Category ID")

    # -------------------------------------------------------------------------
    # Labels commands
    # -------------------------------------------------------------------------
    labels_parser = subparsers.add_parser("labels", help="Labels commands")
    labels_subparsers = labels_parser.add_subparsers(dest="labels_command")

    # List labels
    labels_list = labels_subparsers.add_parser("list", help="List labels for a user")
    labels_list.add_argument("user_id", type=int, help="User ID")

    # -------------------------------------------------------------------------
    # Budget commands
    # -------------------------------------------------------------------------
    budget_parser = subparsers.add_parser("budget", help="Budget commands")
    budget_subparsers = budget_parser.add_subparsers(dest="budget_command")

    # List budget
    budget_list = budget_subparsers.add_parser("list", help="List budget for a user")
    budget_list.add_argument("user_id", type=int, help="User ID")
    budget_list.add_argument("--roll-up", type=lambda x: x.lower() == "true", help="Roll up parent categories (true/false)")

    # Budget summary
    budget_summary = budget_subparsers.add_parser("summary", help="Get budget summary for a user")
    budget_summary.add_argument("user_id", type=int, help="User ID")
    budget_summary.add_argument("--period", required=True, choices=["weeks", "months", "years", "event"], help="Analysis interval")
    budget_summary.add_argument("--interval", required=True, type=int, help="Period multiplier")
    budget_summary.add_argument("--start-date", required=True, help="Start date (YYYY-MM-DD)")
    budget_summary.add_argument("--end-date", required=True, help="End date (YYYY-MM-DD)")

    # Trend analysis
    budget_trend = budget_subparsers.add_parser("trend", help="Get trend analysis for a user")
    budget_trend.add_argument("user_id", type=int, help="User ID")
    budget_trend.add_argument("--period", required=True, choices=["weeks", "months", "years", "event"], help="Analysis interval")
    budget_trend.add_argument("--interval", required=True, type=int, help="Period multiplier")
    budget_trend.add_argument("--start-date", required=True, help="Start date (YYYY-MM-DD)")
    budget_trend.add_argument("--end-date", required=True, help="End date (YYYY-MM-DD)")
    budget_trend.add_argument("--categories", required=True, help="Comma-separated category IDs")
    budget_trend.add_argument("--scenarios", required=True, help="Comma-separated scenario IDs")

    # Refresh forecast cache
    budget_refresh = budget_subparsers.add_parser("refresh", help="Refresh forecast cache for a user")
    budget_refresh.add_argument("user_id", type=int, help="User ID")

    return parser


def main() -> int:
    """Main entry point for the CLI."""
    parser = create_parser()
    args = parser.parse_args()

    if not args.command:
        parser.print_help()
        return 0

    try:
        # Auth commands
        if args.command == "auth":
            if args.auth_command == "status" or args.auth_command is None:
                return cmd_auth_status(args)

        # Me command
        elif args.command == "me":
            return cmd_me(args)

        # Transaction commands
        elif args.command == "transactions":
            if args.transactions_command == "get":
                return cmd_transactions_get(args)
            elif args.transactions_command == "update":
                return cmd_transactions_update(args)
            elif args.transactions_command == "delete":
                return cmd_transactions_delete(args)
            elif args.transactions_command == "list-by-user":
                return cmd_transactions_list_user(args)
            elif args.transactions_command == "list-by-account":
                return cmd_transactions_list_account(args)
            elif args.transactions_command == "list-by-category":
                return cmd_transactions_list_category(args)
            elif args.transactions_command == "list-by-transaction-account":
                return cmd_transactions_list_transaction_account(args)
            elif args.transactions_command == "create":
                return cmd_transactions_create(args)
            else:
                parser.parse_args(["transactions", "--help"])
                return 0

        # Category commands
        elif args.command == "categories":
            if args.categories_command == "get":
                return cmd_categories_get(args)
            elif args.categories_command == "list":
                return cmd_categories_list(args)
            elif args.categories_command == "create":
                return cmd_categories_create(args)
            elif args.categories_command == "update":
                return cmd_categories_update(args)
            elif args.categories_command == "delete":
                return cmd_categories_delete(args)
            else:
                parser.parse_args(["categories", "--help"])
                return 0

        # Labels commands
        elif args.command == "labels":
            if args.labels_command == "list" or args.labels_command is None:
                return cmd_labels_list(args)
            else:
                parser.parse_args(["labels", "--help"])
                return 0

        # Budget commands
        elif args.command == "budget":
            if args.budget_command == "list":
                return cmd_budget_list(args)
            elif args.budget_command == "summary":
                return cmd_budget_summary(args)
            elif args.budget_command == "trend":
                return cmd_budget_trend(args)
            elif args.budget_command == "refresh":
                return cmd_budget_refresh(args)
            else:
                parser.parse_args(["budget", "--help"])
                return 0

        parser.print_help()
        return 0

    except WritesDisabledError as e:
        print(json.dumps({"error": str(e), "hint": "export POCKETSMITH_ALLOW_WRITES=true"}), file=sys.stderr)
        return 1
    except APIError as e:
        print(json.dumps({"error": e.message, "status_code": e.status_code}), file=sys.stderr)
        return 1
    except ValueError as e:
        print(json.dumps({"error": str(e)}), file=sys.stderr)
        return 1
    except Exception as e:
        print(json.dumps({"error": str(e)}), file=sys.stderr)
        return 1


if __name__ == "__main__":
    sys.exit(main())


path: src/pocketsmith/__main__.py
content: """Entry point for running as a module: python -m pocketsmith"""

from .cli import main

if __name__ == "__main__":
    raise SystemExit(main())


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)