Metadata-Version: 2.4
Name: muni
Version: 0.1.14
Summary: BioArena CLI — manage tools, jobs, spaces, and more
Author-email: BioArena <support@muni.bio>
License-Expression: MIT
Project-URL: Homepage, https://muni.bio
Project-URL: Repository, https://github.com/bioArena/muni-cli
Project-URL: Issues, https://github.com/bioArena/muni-cli/issues
Keywords: bioarena,muni,cli,bioinformatics,protein-design
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Science/Research
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.11
Description-Content-Type: text/markdown
Requires-Dist: realtime<3,>=2.28.0

# Muni CLI

Command-line interface for BioArena. Manage tools, GPU jobs, spaces, canvas nodes, and account balance from the terminal.

HTTP uses stdlib `urllib` only. One runtime dependency (`realtime`, which pulls in Pydantic v2 — used to parse typed responses from the SDK schema). Requires Python 3.11+.

## Install

```bash
# Recommended — isolated global install
pipx install .

# Or for development
pip install -e .
```

Both install the `muni` command and the `muni` Python package. `pipx` is preferred for regular use — it installs into its own virtualenv but makes the `muni` binary available globally on PATH.

## Quick Start

```bash
# 1. Log in (no config needed — defaults are built in)
# Default: opens a browser to muni.bio/cli/authorize, you click Authorize,
# CLI captures the callback on a localhost port, done. No password in terminal.
muni login

# On a headless / SSH box with no browser:
muni login --device
# Prints a short code; open muni.bio/device on any device and enter it.

# CI / scripted (legacy email+password):
muni login --email user@example.com --password mypassword

# 3. Explore tools
muni tools
muni tools --category protein-design
muni tool bioarena_rfd3 --inputs

# 4. Run a job
muni run bioarena_rfd3 pdb_id=1UBQ num_designs=5
muni run bioarena_ipsae --batch ipsae_batch.jsonl
cat ipsae_batch.jsonl | muni run bioarena_ipsae --batch - --wait

# 5. Check status and get results
muni status job_<uuid>
muni results job_<uuid>
muni results job_<uuid> --fields sequence,iptm
muni results job_<uuid> --sequences
muni files job_<uuid>
muni download-all job_<uuid> -o ./results/
muni wait job_<uuid> job_<another-uuid>

# Or submit and wait in one step:
muni run bioarena_rfd3 pdb_id=1UBQ num_designs=5 --follow

# 6. Work with canvas nodes in a space
muni space use <space_id>
muni nodes
muni nodes search "PXDesign"
muni node show <node_id>
muni node show <node_id> --json
```

---

## Job Identifiers

Every job the API creates has two IDs:

| ID | Format | Purpose |
|----|--------|---------|
| `job_id` | `job_<uuid>` | DB primary key — the stable, provider-agnostic identifier. Shown first in all muni output. **Prefer this.** |
| `call_id` | `bioarena-<tool>-job_<uuid>` (Anyscale) or `rw-<workflow-uuid>` (Rowan) | External tracker the compute provider knows the job as. Useful for correlating with provider dashboards or webhooks. |

All muni subcommands that take a job argument (`status`, `results`, `files`, `logs`, `download`, `download-all`, `wait`, `job summary`, `job query`) accept **either** form interchangeably — the API's `getJob()` branches on the `job_` prefix. `muni run` prints `Submitted: job_<uuid>` by default; pass `--json` to see both fields.

---

## Commands

### Configuration

| Command | Description |
|---------|-------------|
| `muni config` | Interactive setup for API URL, Supabase URL, and publishable key |
| `muni config show` | Show the active config profile, including the active space (masks long keys) |
| `muni config profiles` | List all configured profiles and show which is active |
| `muni config use <profile>` | Switch the default profile (creates it if it doesn't exist) |

### Authentication

The CLI uses OAuth 2.0 against the muni app. The default flow is authorization
code + PKCE with a localhost loopback — no password is ever typed in the
terminal. Supabase Auth sits underneath; refresh tokens are scoped to this
CLI installation and won't collide with web sessions.

| Command | Description |
|---------|-------------|
| `muni login` | Browser + PKCE flow (default). Opens `<app_url>/cli/authorize`, captures the callback on 127.0.0.1 |
| `muni login --device` | RFC 8628 device code. Prints a code you enter at `<app_url>/device` on any device |
| `muni login --no-browser` | Print the authorize URL instead of launching a browser |
| `muni login --client-name "My laptop"` | Label the session in Connected Devices |
| `muni login -e EMAIL -p PASS` | Legacy email/password flow (CI/scripted use) |
| `muni login --password-flow` | Force the legacy flow with interactive prompts |
| `muni logout` | Revoke the CLI refresh token server-side and clear local credentials |
| `muni whoami` | Show current user email, user ID, profile name, credit balance, and active space |

CLI sessions can be listed and revoked from the web at `/account` → Connected devices.

### Account

| Command | Description |
|---------|-------------|
| `muni balance` | Show credit balance |

### Spaces

Spaces are shared workspaces for organizing jobs and collaborating with other users.

| Command | Description |
|---------|-------------|
| `muni spaces` | List your spaces and mark the active one |
| `muni space create <name>` | Create a new space |
| `muni space delete <space_id>` | Delete a space (owner only) |
| `muni space members <space_id>` | List members with email, role, and user ID |
| `muni space invite <space_id> <email>` | Invite a user to a space |
| `muni space use <space_id-or-name>` | Set the active space for canvas node commands |

The active space is persisted in your profile config (`~/.muni/config.json`). Once set, all `muni nodes` and `muni node` commands default to it. You can override per-command with `--space-id`.

### Canvas Nodes

Canvas nodes are the visual elements on a space's canvas — structures, metrics, file trees, code, text, and more. Each node has a type, label, optional node script (tool configuration), and lifecycle state.

#### Listing & Searching

| Command | Description |
|---------|-------------|
| `muni nodes` | List all canvas nodes in the active space |
| `muni nodes --type structure` | Filter by node type (`structure`, `metric`, `filetree`, `text`, `code`, `json`, `markdown`, `image`, `molecule`, `note`, `results`) |
| `muni nodes -n 100` | Set result limit (default: 50) |
| `muni nodes --space-id <id>` | Override the active space for this command |
| `muni nodes search <query>` | Search nodes by keyword (matches label, type, tool name, PDB ID, original name, text, markdown, SMILES, and metric columns) |

`muni nodes` and `muni nodes search` now include canvas position, size, and connection count in both table and JSON output.

#### Inspecting

| Command | Description |
|---------|-------------|
| `muni node read <node_id>` | Read a node's full state (script, data, connections, Python code) |
| `muni node read <node_id> --json` | Full node payload as JSON |
| `muni node content <node_id>` | Stream the node's output content from storage |

#### Creating

| Command | Description |
|---------|-------------|
| `muni node create <title> --type <type>` | Create a new node (types include `metric`, `code`, `json`, `structure`, `chem`, `chart`, `text`, `image`, `job`, `group`) |
| `muni node create <title> --type code --python main.py` | Create a code node with main.py content |
| `muni node create <title> --type <type> --script script.ts` | Create with an inline script (compiled + executed for non-job nodes) |

#### Updating & Running

| Command | Description |
|---------|-------------|
| `muni node edit <node_id> --title "New Title"` | Rename a node |
| `muni node edit <node_id> --script script.ts` | Update a node's script |
| `muni node edit <node_id> --python main.py` | Update a code node's main.py |
| `muni node run <node_id>` | Compile + execute the node, cascading downstream |
| `muni node run <node_id> --wait` | Execute and wait for completion (default timeout: 60s) |
| `muni node run <node_id> --timeout 300` | Set wait timeout in seconds |
| `muni node move <node_id> <x> <y>` | Move a node to an absolute canvas position |
| `muni node select <page_id> <node_id>...` | Select one or more nodes on the canvas |
| `muni node focus <page_id> <node_id>` | Focus the canvas viewport on a node |
| `muni node connect <source_id> <target_id>` | Create a data edge between two nodes |

#### Deleting

| Command | Description |
|---------|-------------|
| `muni node remove <node_id>` | Soft-delete a node (recoverable; disables connections) |

All node mutations route through `/api/sdk`, which stamps the audit trail and broadcasts realtime updates — open canvases reflect changes without a refresh.

#### Metric nodes

| Command | Description |
|---------|-------------|
| `muni node metric-summary <node_id>` | Column names, detected types, and row count |
| `muni node metric-query <node_id> --columns col1,col2 --sort-by col1 --limit 50` | Sort / filter / paginate tabular data |
| `muni node metric-select <node_id> <action>` | Check / uncheck rows; actions: `get`, `select`, `deselect`, `clear` |

**Typical workflow** — explore a space from the terminal:
```bash
# Set your working space + page
muni space use 137df9fd-2634-4a99-bf7e-499799677d15
muni page use <page_id>

# See what's on the canvas
muni nodes
muni nodes --type metric

# Search for a specific run
muni nodes search "PXDesign"

# Inspect a metric node
muni node read <node_id> --json | jq '.currentData.preview'
muni node metric-query <node_id> --columns rank,af2_iptm,sequence --sort-by af2_iptm

# Create a new code node and run it
muni node create "Analysis" --type code --python analysis.py
muni node run <node_id> --wait
```

### Tool Discovery

| Command | Description |
|---------|-------------|
| `muni tools` | List all available tools |
| `muni tools -q <keyword>` | Search tools by keyword |
| `muni tools -c <category>` | Filter by category (e.g. `protein-design`, `peptide-science`, `structure-prediction`) |
| `muni tool <name>` | Full tool info: description, category, reference URL, and all inputs |
| `muni tool <name> --inputs` | Input parameters table with types, defaults, required markers, and descriptions |
| `muni tool <name> --examples` | Usage examples with sample `muni run` commands |

### Job Execution

| Command | Description |
|---------|-------------|
| `muni run <tool> [KEY=VALUE ...]` | Submit a job. Returns the `call_id` immediately |
| `muni run <tool> --params-file request.json` | Load parameters from a JSON file |
| `muni run bioarena_ipsae --batch batch.jsonl` | Submit many IPSAE jobs from a JSONL file |
| `cat batch.jsonl \| muni run bioarena_ipsae --batch -` | Submit a JSONL batch from stdin |
| `muni run bioarena_ipsae --batch batch.jsonl --submit-concurrency 10` | Set server-side batch submission concurrency |
| `muni run <tool> ... --wait` | Submit and poll until completion (prints compact completion hints) |
| `muni run <tool> ... --follow` | Submit and poll with richer live status output (provider, runtime) |
| `muni run <tool> ... --timeout 300` | Timeout in seconds for `--wait`/`--follow` (default: 1800) |
| `muni run <tool> ... --space-id S` | Associate job with a space |
| `muni run <tool> ... --title "My run"` | Set a human-readable job title |
| `muni run <tool> ... --dry-run` | Validate and print the resolved request payload without submitting |
| `muni run <tool> ... --no-validate` | Skip client-side schema validation before submitting |
| `muni run <tool> ... --log-lines 200` | Number of log tail lines to show after a failed `--follow` run (default: 120) |

When using `--wait` or `--follow`, the CLI polls job status with escalating backoff (2s, 2s, 5s, 5s, then 10s intervals). In human mode, completion stays compact: instead of dumping huge result payloads, `muni` prints the result shape and the next useful command such as `muni results <id> --fields ...`, `muni results <id> --sequences`, or `muni files <id>`. On failure with `--follow`, the last N log lines are automatically shown.

### Batch Submit

Batch submit is available in v1 for `bioarena_ipsae`.

Input format:
- JSONL only
- one JSON object per line
- each line must already contain the final tool arguments
- use `--batch -` to read JSONL from stdin

Examples:

```bash
# Submit 60 IPSAE jobs in one API request
muni run bioarena_ipsae --batch ipsae_batch.jsonl

# Submit from stdin
cat ipsae_batch.jsonl | muni run bioarena_ipsae --batch -

# Submit and wait for the successful items
muni run bioarena_ipsae --batch ipsae_batch.jsonl --wait --timeout 600
```

Behavior:
- Batch submission is best effort: valid items submit, invalid items are reported individually.
- `--wait` waits only on successfully submitted call IDs.
- exit code is non-zero if any batch item fails submission or any waited job fails/times out.
- `--follow` is not supported with `--batch`.
- shared Anyscale job queues are enabled automatically for batched IPSAE runs.

### Job Monitoring

| Command | Description |
|---------|-------------|
| `muni jobs` | List recent jobs (default: 20 most recent) |
| `muni jobs -s completed` | Filter by status (`submitted`, `running`, `completed`, `failed`, `cancelled`) |
| `muni jobs --space-id S` | Filter by space |
| `muni jobs -n 50` | Set result limit |
| `muni status <call_id>` | Check job status (includes type, provider, runtime, timestamps) |
| `muni wait <id1> <id2> ...` | Poll one or more jobs until they finish |
| `muni wait <id1> <id2> ... --json` | Return a compact per-job summary for scripting |
| `muni results <call_id>` | Get job results as JSON (prints "still running" for 202 responses) |
| `muni results <call_id> --fields sequence,iptm` | Project selected fields from large structured result sets |
| `muni results <call_id> --sequences` | Extract sequences from common protein-design result shapes |

`muni wait` and `muni run --wait` treat a job as finished when the job row reaches a terminal status (`completed`, `failed`, `cancelled`). In JSON mode, `wait` returns compact metadata such as `status`, `job_type`, `updated_at`, `completed_at`, and the detected result family, without embedding the full result payload.

### Logs

| Command | Description |
|---------|-------------|
| `muni logs <call_id>` | Fetch provider logs (default: last 200 lines) |
| `muni logs <call_id> --max-lines 500` | Fetch more log lines |
| `muni logs <call_id> --follow` | Continuously poll logs until job reaches a terminal state |
| `muni logs <call_id> --follow --interval 10` | Set polling interval in seconds (default: 5) |

### File Management

| Command | Description |
|---------|-------------|
| `muni files <call_id>` | List output files as a tree (with file sizes) |
| `muni files <call_id> --path designs` | Show only the `designs/` subtree |
| `muni download <call_id> <path>` | Download a single file (saves to current directory) |
| `muni download <call_id> <path> -o out.pdb` | Download with a custom output path |
| `muni download <call_id> <path> --stdout` | Write a single file directly to stdout |
| `muni download <call_id> <path> -o -` | Alias for `--stdout` |
| `muni download <call_id> designs` | Download an entire directory (auto-detected) |
| `muni download-all <call_id>` | Download all files to `./<call_id>/` |
| `muni download-all <call_id> -o ./results/` | Download all to a custom directory |
| `muni download-all <call_id> --path designs` | Download only the `designs/` folder |
| `muni download-all <call_id> --glob "*.pdb"` | Download only `.pdb` files |
| `muni download-all <call_id> -p designs -g "*.json"` | Combine: only `.json` files under `designs/` |

Binary files are base64-decoded on download. Text files are written as UTF-8.

Use `--stdout` only for single files. It is designed for piping into tools like `jq`, `head`, and `grep`, and cannot be combined with `--json`.

**Typical workflow** — inspect then download:
```bash
# See what the job produced
muni files job_<uuid>

# Download just the designs folder
muni download-all job_<uuid> --path designs -o ./my-designs/

# Or grab only PDB files from anywhere in the output
muni download-all job_<uuid> --glob "*.pdb" -o ./structures/

# Or stream one file directly into another tool
muni download job_<uuid> summary.json --stdout | jq '.'
```

---

## Global Flags

These flags work with any subcommand:

| Flag | Description |
|------|-------------|
| `--json` | Output machine-readable JSON instead of human-formatted tables |
| `--profile <name>` | Use a specific config/credentials profile for this command |
| `--api-url <url>` | Override the API URL for this command only |
| `--supabase-url <url>` | Override the Supabase URL for this command only |
| `--supabase-publishable-key <key>` | Override the Supabase publishable key for this command only |

Examples:
`muni config show --json`
`muni space members <space_id> --json`

---

## Parameter Syntax

Parameters use `KEY=VALUE` format on the command line. Values are auto-parsed:

```bash
# Strings
muni run bioarena_rfd3 pdb_id=1UBQ

# Numbers (auto-detected via JSON parsing)
muni run bioarena_rfd3 pdb_id=1UBQ num_designs=10

# Booleans
muni run some_tool flag=true

# JSON arrays
muni run bioarena_rfd3 hotspot_residues='["A32","A35"]'

# JSON objects
muni run some_tool config='{"key": "value"}'
```

### File References

Use `@` to load parameter values from files:

```bash
# Auto-detect: JSON files parsed as JSON, others read as text
muni run bioarena_rfd3 target_pdb=@./my_protein.pdb
muni run bioarena_protenix input_json=@./request.json

# Force JSON parsing
muni run bioarena_protenix input_json=@json:./request.json

# Force text (no JSON parsing)
muni run bioarena_rfd3 target_pdb=@text:./target.pdb
```

### Params File

Load all parameters from a JSON file (can be combined with KEY=VALUE args, which take precedence):

```bash
muni run bioarena_protenix --params-file ./request.json
muni run bioarena_protenix --params-file ./request.json num_designs=20  # override
```

### Client-Side Validation

By default, `muni run` fetches the tool's JSON Schema and validates parameters locally before submitting. This catches type mismatches, unknown parameters, and missing required fields. Disable with `--no-validate`.

`--validate` here is client-side schema validation only. It does not perform a remote execution preflight against staged files or backend job readiness.

### Result Shapes

Different tools store different result families in Supabase. Common shapes include:

- record arrays such as `rankings`, `top_designs`, `sequences`, `designs`, and some array-shaped `metrics`
- scorecard objects such as `best_metrics`, `metrics_summary`, `ranking`, object-shaped `metrics`, and object-shaped `summary`
- file-heavy manifests such as `pdb_files`, `summary_files`, `csv_files`, `json_files`, and related output lists

`muni results --fields` works best for structured record arrays and scorecard objects. `muni results --sequences` is a convenience extractor for common protein-design outputs. For file-heavy jobs, start with `muni files` and then `muni download` or `muni download-all`.

---

## Configuration

Config is stored in `~/.muni/config.json` with named profile support:

```json
{
  "current_profile": "default",
  "profiles": {
    "default": {
      "api_url": "https://api.bioarena.com",
      "supabase_url": "https://xxx.supabase.co",
      "supabase_publishable_key": "sb_publishable_..."
    },
    "local": {
      "api_url": "http://localhost:8000",
      "supabase_url": "https://xxx.supabase.co",
      "supabase_publishable_key": "sb_publishable_..."
    }
  }
}
```

Credentials are stored separately in `~/.muni/credentials.json` per profile. Both files are created with `0600` permissions.

### Environment Variables

Environment variables override config file values:

| Variable | Description |
|----------|-------------|
| `MUNI_API_URL` | BioArena Tools API URL |
| `MUNI_SUPABASE_URL` | Supabase project URL |
| `MUNI_SUPABASE_PUBLISHABLE_KEY` | Supabase publishable key |
| `MUNI_SPACE_ID` | Override the active space for canvas node commands |
| `MUNI_PROFILE` | Select a saved config/credentials profile |

### Token Management

Authentication tokens are automatically refreshed when within 60 seconds of expiry. If the refresh token is missing or invalid, you'll need to `muni login` again.

---

## Python SDK

Use `MuniClient` programmatically for scripting and automation. The preferred API is the namespaced sub-clients — they return Pydantic v2 models parsed from the TypeScript SDK's Zod schemas, so IDE autocomplete and validation work out of the box. The flat methods (`client.list_tools(...)`, `client.run(...)`, etc.) remain available as stable dict-returning wrappers.

```python
from muni import MuniClient

client = MuniClient()

# ── Sub-clients (preferred, typed) ────────────────────────────────

# Spaces
spaces = client.spaces.list()                      # list[SpacesListOutputItem]
print(spaces[0].name, spaces[0].id)
my_space = client.spaces.create("Experiments")     # SpacesCreateOutput

# Pages
pages = client.pages.list(my_space.id)             # list[PagesListOutputItem]
default = client.pages.get_default(my_space.id)    # PagesGetDefaultOutput
page_id = default.id

# Tools
tools = client.tools.list(category="protein-design")  # list[ToolsListOutputItem]
tool = client.tools.get("bioarena_rfd3")              # ToolsGetOutput (includes parameters, reference_url)

# Jobs
submit = client.jobs.submit(
    "bioarena_rfd3",
    {"pdb_id": "1UBQ", "num_designs": 5},
    title="RFD 1UBQ",
    space_id=my_space.id,
)                                                   # JobsSubmitOutput
results = client.jobs.wait(submit.call_id, timeout=600)

# Nodes (return dicts — augmented with runtime state post-create)
node = client.nodes.create(page_id, "Analysis", type="code",
                           python_code="print('hi')")
client.nodes.run(node["nodeId"])

# Connections
conn = client.connections.create(
    page_id=page_id,
    target_node_id=node["nodeId"],
    target_port="input",
    source_node_id="node_src",
    source_port="output",
)

# ── Flat wrappers (legacy; emit snake_case dicts) ─────────────────

tools_dict = client.list_tools(category="protein-design")        # {"tools": [...]}
job = client.run("bioarena_rfd3", {"pdb_id": "1UBQ", "num_designs": 5})
call_id = job["call_id"]

def on_status(data):
    print(f"Status: {data['status']}")

results = client.wait_for_job(call_id, timeout=600, on_status=on_status)
client.save_all_files(call_id, "./output/", glob_pattern="*.pdb")
```

### Sub-clients

| Namespace | Example methods |
|-----------|-----------------|
| `client.user` | `whoami()`, `balance()` |
| `client.spaces` | `list()`, `get()`, `create()`, `delete()`, `members()`, `invite()`, `resolve()` |
| `client.pages` | `list()`, `get()`, `get_default()`, `create()`, `delete()`, `reorder()` |
| `client.nodes` | `list()`, `get()`, `create()`, `update()`, `remove()`, `run()`, `save()`, `move()`, `select()`, `focus()`, `metric_summary()`, `metric_query()` |
| `client.node_files` | `list()`, `get()`, `create()`, `update()` (Python files for code nodes) |
| `client.node_data` | `list()`, `get()` (metric data) |
| `client.connections` | `list()`, `create()`, `remove()`, `enable()`, `disable()` |
| `client.tools` | `list()`, `get()`, `examples()` |
| `client.jobs` | `submit()`, `status()`, `wait()`, `results()`, `search()`, `cancel()`, `summary()`, `query()`, `logs()`, `batch()` |
| `client.files` | `list()`, `get()`, `download()` |
| `client.chat` / `client.plans` / `client.sandbox` / `client.scripts` | see `muni/clients/` |

Each sub-client routes through `POST /api/sdk` on the app server and (where wired) returns Pydantic models from `muni._generated.models`. Call `.model_dump(by_alias=True)` on any model to get a camelCase dict matching the TypeScript SDK's response shape.

### Flat MuniClient methods (legacy, stable)

| Method | Returns | Description |
|--------|---------|-------------|
| `list_tools(query, category)` | `dict` | List/search available tools (Tools API) |
| `get_tool(name)` | `dict` | Get tool schema (Tools API) |
| `get_examples(name)` | `dict` | Get usage examples (Tools API) |
| `run(tool, args, space_id, title)` | `dict` | Execute a tool, returns `{"call_id": "..."}` |
| `run_batch(tool, items, space_id, title_prefix, submit_concurrency)` | `dict` | Execute many items in one API request |
| `job_status(call_id)` | `dict` | Check job status |
| `job_results(call_id)` | `(int, dict)` | Get results (HTTP 202 = still running) |
| `job_logs(call_id, max_lines)` | `dict` | Fetch provider logs |
| `cancel_job(call_id)` | `(int, dict)` | Cancel a running job |
| `wait_for_job(call_id, timeout, on_status)` | `dict` | Poll until completion, raises on failure/timeout |
| `list_jobs(space_id, status, limit)` | `list[dict]` | List jobs from Supabase |
| `list_files(call_id)` | `dict` | List output files |
| `download_file(call_id, path)` | `(str, bool)` | Download file content (content, is_text) |
| `save_file(call_id, path, dest)` | `Path` | Download and write file to disk |
| `save_all_files(call_id, output_dir, *, path_prefix, glob_pattern)` | `list[Path]` | Bulk download, optionally filtered |
| `get_balance()` | `dict` | Credit balance (snake_case keys) |
| `list_spaces()` / `create_space(name)` / `delete_space(id)` | — | Space management (snake_case dicts) |
| `space_members(space_id)` / `invite_to_space(space_id, email)` | — | Membership |
| `resolve_space_id(id_or_name)` | `str` | Resolve a UUID or name to a space UUID |
| `list_pages(space_id)` / `get_page(page_id)` | — | Page lookup (PostgREST) |
| `search_nodes(page_id, *, node_type, query, limit)` | `list[dict]` | Search nodes on a page |
| `create_node(page_id, title, *, node_type, script, python_code, position_x, position_y)` | `dict` | Create a node (delegates to `client.nodes.create`) |
| `read_node(node_id)` / `edit_node(...)` / `remove_node(node_id)` | `dict` | Node management |
| `run_node(node_id, *, wait, timeout)` | `dict` | Execute a node, optionally polling for completion |
| `metric_summary(node_id)` / `metric_query(node_id, ...)` / `metric_select(node_id, action, ...)` | `dict` | Metric node helpers |

### Exceptions

```python
from muni import MuniError, AuthError, NotFoundError, JobFailedError, JobTimeoutError
from muni.exceptions import APIError, JobCancelledError

try:
    results = client.wait_for_job(call_id, timeout=300)
except JobFailedError as e:
    print(f"Job {e.call_id} failed: {e.error}")
except JobTimeoutError as e:
    print(f"Job {e.call_id} didn't finish in {e.timeout}s")
except JobCancelledError as e:
    print(f"Job {e.call_id} was cancelled")
except AuthError:
    print("Not logged in or token expired")
```

| Exception | Description |
|-----------|-------------|
| `MuniError` | Base class for all muni errors |
| `AuthError` | Login failed or no valid credentials |
| `NotFoundError` | Tool, job, or file not found |
| `APIError` | Unexpected HTTP error (has `.status_code`) |
| `JobFailedError` | Job completed with failed status (has `.call_id`, `.error`) |
| `JobCancelledError` | Job was cancelled (has `.call_id`) |
| `JobTimeoutError` | Timed out waiting for job (has `.call_id`, `.timeout`) |

---

## Architecture

### Derivation chain

The TypeScript SDK in `muni-app` is the canonical contract. The Python SDK is derived from it, and the CLI wraps the Python SDK:

```
TypeScript SDK (canonical)  →  Python SDK (derived)  →  CLI (wraps SDK)
   muni-app/src/lib/            muni/clients/*.py          muni/cli.py
   muni/sdk/client/             muni/_generated/models.py
```

Pydantic v2 models in `muni/_generated/models.py` are generated from the app's OpenAPI/Zod schemas — **do not edit by hand**. Regenerate via `pnpm codegen:python` in `muni-app`.

### Three backends

1. **App server `/api/sdk`** (canonical) — typed namespaced calls for nodes, pages, spaces, tools, jobs, connections, user, chat, plans, files, sandbox, scripts. Routed through `SDKClientBase._call()`.
2. **Tools API** (Elysia server, port 8000) — legacy path still used by `muni run`, `muni results`, `muni logs`, `muni download`, and the flat `list_tools`/`get_tool` wrappers.
3. **Supabase PostgREST** (direct) — auth, `muni jobs` listing, and a handful of direct reads/writes on `muni_nodes` / `muni_node_data` for metric query/select.

```
muni login    →  Supabase Auth API                    →  ~/.muni/credentials.json
muni run      →  Tools API (POST /tools/:name/execute)
muni status   →  Tools API (GET /jobs/:callId/status)
muni spaces   →  App server /api/sdk (spaces.list)
muni nodes    →  App server /api/sdk (nodes.list)
muni node ... →  App server /api/sdk (nodes.create/read/edit/run/...)
muni jobs     →  Supabase PostgREST (GET /rest/v1/jobs)
muni tools    →  Tools API (GET /tools)
```

All HTTP uses stdlib `urllib`. The only external dependency is `realtime` (used for push-based job-status updates in `muni wait`), which transitively pulls in Pydantic v2 for typed SDK responses.

## Project Structure

```
muni-cli/
├── muni/
│   ├── __init__.py       # MuniClient + exceptions re-export
│   ├── __main__.py       # python -m muni entry point
│   ├── cli.py            # argparse subcommands + KEY=VALUE parsing
│   ├── client.py         # MuniClient facade (legacy flat methods + sub-client wiring)
│   ├── clients/          # Namespaced sub-clients (nodes, spaces, pages, jobs, tools,
│   │   │                 #   user, connections, files, node_files, node_data, chat,
│   │   │                 #   plans, sandbox, scripts) — all route through /api/sdk
│   │   └── _base.py      # SDKClientBase: POST /api/sdk + optional Pydantic parsing
│   ├── _generated/       # Pydantic v2 models generated from the TS Zod schemas
│   │   └── models.py     # DO NOT EDIT — regenerate via `pnpm codegen:python`
│   ├── auth.py           # Email/password + PKCE login, logout, token refresh
│   ├── oauth.py          # OAuth 2.0 + PKCE + device-code flows
│   ├── realtime.py       # Supabase realtime subscriber for job-status updates
│   ├── api.py            # Tools API HTTP calls
│   ├── supabase.py       # PostgREST + Auth HTTP calls
│   ├── display.py        # Human-readable formatting (tables, trees, status)
│   ├── config.py         # ~/.muni/ file management + profile switching
│   ├── canvas.py         # Node connection geometry
│   ├── node_scripts.py   # Node script compile/validate helpers
│   ├── node_sources.py   # Node source DSL parser
│   └── exceptions.py     # MuniError hierarchy
├── pyproject.toml        # PEP 621 packaging (setuptools)
├── scripts/push          # Version-bumping push script
└── README.md
```

## Versioning

Version is defined in `muni/__init__.py` (`__version__`) and read dynamically by `pyproject.toml`. Check it with:

```bash
muni --version
```

### Auto-bump on push

Use `./scripts/push` instead of `git push` to automatically bump the patch version after a successful push:

```bash
./scripts/push origin master
```

This will:
1. Push your commits
2. Bump the patch version (e.g. `0.1.3` → `0.1.4`)
3. Commit and push the version bump

For major or minor bumps, edit `muni/__init__.py` manually before pushing.
