# Command-line tool

`gpuforge` is a small command-line tool for people and AI agents. It searches and rents machines, shows what they cost, opens SSH shells, manages your balance, and can call any API endpoint. It is a single Python file and needs only `python3`.

## Install

```bash
curl -fsSL https://api.gpuforge.io/v1/cli/install.sh | sh
```

This downloads the tool to `~/.local/bin/gpuforge` (set `GPUFORGE_BIN_DIR` to choose another folder) and tells you if that folder is not on your `PATH`. You can read the script first: `curl -fsSL https://api.gpuforge.io/v1/cli/install.sh`. The tool itself is served at `https://api.gpuforge.io/v1/cli/gpuforge` and contains no secrets.

## Sign in

```bash
gpuforge login
```

The tool opens a page on the console with a short code. Check the code matches and press **Approve** while signed in. The tool then receives its own API key and saves it in `~/.config/gpuforge/config.json`, readable only by you. Use `--no-browser` to print the link instead of opening it. A code expires after 10 minutes.

```bash
gpuforge whoami     # who is signed in
gpuforge logout     # revoke this computer's key and forget it
```

## Configuration

| Setting | Where |
|---|---|
| Config file | `~/.config/gpuforge/config.json` (or the path in `GPUFORGE_CONFIG`) |
| API key | Saved by `gpuforge login`, or the `GPUFORGE_TOKEN` environment variable (useful for agents and CI) |
| API address | Saved at login, or the `GPUFORGE_URL` environment variable |


## Commands

Add `--json` to any command for machine-readable output. Commands that cost money ask for confirmation first; add `--yes` (`-y`) to skip it once the user has agreed (an agent without a terminal must pass `--yes`, or the command is refused).

| Command | What it does | Costs money |
|---|---|---|
| `gpuforge login` / `logout` / `whoami` | Sign in, revoke this computer's key, show the account | No |
| `gpuforge search [options]` | Find machines that fit | No |
| `gpuforge caps` | List your capabilities | No |
| `gpuforge caps create spec.json` | Create a capability from a JSON file `{"name": ..., "spec": {...}}` (your `user_id` is filled in) | No |
| `gpuforge rent --cap CAP [OFFER_ID]` | Rent a machine for a capability: the offer you name, or the cheapest that fits | **Yes** |
| `gpuforge status [POD]` | Your machines and what they cost right now, or one machine in detail | No |
| `gpuforge ssh POD [COMMAND...]` | Open a shell on a machine, or run one command | No |
| `gpuforge stop POD` | Destroy a machine: billing stops and its disk is wiped | No |
| `gpuforge billing [--usage HOURS]` | Balance, spend, cost now, card, top-up settings; with `--usage`, cost per machine | No |
| `gpuforge topup AMOUNT` | Add money from the card saved on the console (at least $5; within your daily cap) | **Yes** |
| `gpuforge api METHOD PATH [JSON or @file.json]` | Call any endpoint, e.g. `gpuforge api GET /v1/catalog` | Depends |

### `search` options

| Option | Description |
|---|---|
| `--gpus N` | GPUs on one machine, 1–8, exact (default 1) |
| `--gpu NAME` | Card name contains, e.g. `h200`, `b200`, `4090` |
| `--family` | Oldest generation: `ada_lovelace`, `hopper`, `blackwell` |
| `--vram GB` | Minimum memory per GPU |
| `--ram GB` | Minimum system RAM |
| `--disk GB` | Disk (default 50) |
| `--max-price USD` | Most per hour for the whole machine |
| `--provider` | `vast`, `runpod`, `nebius` or `jarvis` |
| `--cap CAP` | Use a capability's requirements instead |
| `--all` | Also show machines where something cannot be confirmed before boot |
| `--limit N` | Rows to show (default 20) |

Offers expire 15 minutes after the search.

### `rent` options

| Option | Description |
|---|---|
| `--cap CAP` | The capability to run (required) |
| `OFFER_ID` | An offer from `search`; omit it for the cheapest machine that fits |
| `--name` | A name for the machine |
| `--max-price USD` | Refuse above this price per hour |
| `--no-ssh` | Do not add your public key. By default the first of `~/.ssh/id_ed25519.pub`, `id_ecdsa.pub`, `id_rsa.pub` is added, so `gpuforge ssh` works |

Before renting, the tool shows your balance and current spend; afterwards, how long the balance lasts at the new rate.

## Examples

```bash
# Five H200s on one machine, cheapest first
gpuforge search --gpus 5 --gpu h200

# Save a capability and rent the cheapest machine for it
gpuforge caps create my-trainer.json
gpuforge rent --cap cap_ab12cd34

# Watch it, open a shell, stop it
gpuforge status
gpuforge ssh my-trainer-3f9a1c
gpuforge ssh my-trainer-3f9a1c nvidia-smi
gpuforge stop my-trainer-3f9a1c

# Money
gpuforge billing --usage 24
gpuforge topup 20

# Anything else
gpuforge api GET /v1/catalog
gpuforge api POST /v1/jobs @job.json
```

`gpuforge ssh` waits up to 3 minutes for SSH to be ready and pins the machine's host key, so the connection is verified.

## Using the API from scripts and agents

Everything the CLI does is a plain HTTPS call; any language works.

- Send `Authorization: Bearer <key>` on every call and `Content-Type: application/json` on every body.
- Give each agent or project its own API key, so you can revoke it alone.
- An agent can read a short guide written for it at `GET https://api.gpuforge.io/v1/agent-guide` (plain text, no key needed), and the full reference at `GET https://api.gpuforge.io/v1/help`.
- Treat a machine as temporary: upload inputs after every start or wake, and download results at once.
- Poll with care: `GET /v1/jobs/{id}?wait=60` and `GET /v1/fleet/{pod_id}` every ~10 seconds are enough.
- Read `GET /v1/notifications?unread=true` and pass the messages on to the person who owns the account.

### A minimal Python job client

```python
import base64, json, os, urllib.request

BASE = os.environ["GPUFORGE_URL"]          # https://api.gpuforge.io
KEY = os.environ["GPUFORGE_TOKEN"]

def call(method, path, body=None):
    req = urllib.request.Request(
        BASE + path, method=method,
        data=json.dumps(body).encode() if body is not None else None,
        headers={"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=180) as r:
        return json.load(r)

job = call("POST", "/v1/jobs", {
    "program": os.environ["PROGRAM"],       # a program id from GET /v1/catalog
    "route": "/v1/generate",
    "input": {"prompt": "Full-color photograph of a red bicycle leaning on a brick wall, morning light."},
    "max_price_usd": 0.02,
})
while True:
    j = call("GET", f"/v1/jobs/{job['job_id']}?wait=60")
    if j["status"] in ("done", "failed"):
        break
if j["status"] == "done":
    open("bicycle.jpg", "wb").write(base64.b64decode(j["result"]["image"]))
    print("charged", j["price_usd"])
else:
    print("failed, not charged:", j["error"])
```
