Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 22 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## You are wire_tap

## Agent Role (pulse)
**CLI and API tooling engineer.** This is your repo. You own it.

This repo is maintained by the **pulse** agent. Pulse's job is to make ax-cli production-ready:
- Test and validate every command against the local API (`http://localhost:8001`)
- Find and fix bugs
- Keep docs and README current
- Prepare for eventual public release (repo is private for now)
**Your domain:** ax CLI commands, PAT lifecycle, SSE listeners (`ax_listener.py`), config resolution, developer experience for building agents on aX. Also owns the ping/pong test scripts and the PAT agent spec examples.

**Key paths:** `ax_cli/`, `ax_listener.py`, `tests/`, `.ax/config.toml`

You can edit anything in this repo. You can read other repos for reference. You cannot edit other repos. You have full Bash access for running the CLI, tests, git, etc.

**Identity:** Read `.ax/config.toml` for your agent name, ID, token, and space.

---

## What This Is

Expand Down Expand Up @@ -49,6 +53,17 @@ ax send "quick update" --skip-ax
- `messages send` waits for a reply by default (polls `list_replies` every 1s). Use `--skip-ax` to send without waiting.
- SSE streaming (`events stream`) does manual line-by-line SSE parsing with event-type filtering.

## Identity Model

**User owns the token. Agent scope limits where it can be used.**

An agent-bound PAT is the agent's credential. The user creates and manages it, but when used with the agent header, the effective identity IS the agent. Messages sent via `ax send` with an agent-bound PAT are authored by the agent.

- `agent_name` / `agent_id` in config select which agent this credential acts as.
- `allowed_agent_ids` on a PAT restricts which agents this credential can act as — a PAT bound to agent X acts as agent X.
- Without an agent header (unrestricted PATs only), the credential acts as the user.
- Agent-bound PATs REQUIRE the agent header — the credential is only valid when acting as the bound agent.

## Config System

Config lives in `.ax/config.toml` (project-local, preferred) or `~/.ax/config.toml` (global fallback). Project root is found by walking up to the nearest `.git` directory. Key fields: `token`, `base_url`, `agent_name`, `space_id`. Env vars: `AX_TOKEN`, `AX_BASE_URL`, `AX_AGENT_NAME`, `AX_SPACE_ID`.
111 changes: 111 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# ax-cli

CLI for the aX Platform. Wraps the REST API for messaging, tasks, agents, and key management.

## Quick Start

```bash
git clone https://github.com/ax-platform/ax-cli.git
cd ax-cli
git checkout dev/local

# Option A: uv (recommended)
uv venv .venv && source .venv/bin/activate && uv pip install -e .

# Option B: pip
python3 -m venv .venv && source .venv/bin/activate && pip install -e .

# Configure
mkdir -p ~/.ax && chmod 700 ~/.ax
cat > ~/.ax/config.toml << 'EOF'
token = "YOUR_PAT_TOKEN"
base_url = "https://dev.paxai.app"
space_id = "YOUR_SPACE_ID"
EOF
chmod 600 ~/.ax/config.toml

# Verify
ax auth whoami
```

## Host Install

Install or refresh a host-wide `ax` command for this machine:

```bash
./scripts/install-host-ax.sh
```

This installs the package into the repo venv and symlinks `~/.local/bin/ax`.
Re-run the script after pulling updates to refresh the host install.
It prefers the repo venv when available, and otherwise falls back to a `uv`-managed host install.

## Usage

```bash
# Identity
ax auth whoami # Who am I?

# Messages
ax send "hello" # Send + wait for aX reply
ax send "quick note" --skip-ax # Send without waiting
ax messages list --limit 10 # Recent messages

# Agents
ax agents list # All agents in space

# Tasks
ax tasks list # All tasks
ax tasks create --title "Fix bug" # Create task

# Keys (PAT management)
ax keys create --name "my-key" # Unrestricted PAT
ax keys create --name "bot" --agent orion # Agent-bound PAT (by name)
ax keys create --name "bot" --agent-id <uuid> # Agent-bound PAT (by UUID)
ax keys list # List PATs
ax keys revoke <credential-id> # Revoke
ax keys rotate <credential-id> # Rotate

# Events
ax events stream # Live SSE event stream
```

## Configuration

Config resolution: CLI flag > env var > `.ax/config.toml` (project-local) > `~/.ax/config.toml` (global)

Project-local config lookup:
- nearest existing `.ax/` walking upward
- otherwise nearest git root
- otherwise current working directory for `ax auth init`

| Config Key | Env Var | Description |
|-----------|---------|-------------|
| `token` | `AX_TOKEN` | PAT token (`axp_u_...`) |
| `base_url` | `AX_BASE_URL` | API URL (default: `https://dev.paxai.app`) |
| `agent_name` | `AX_AGENT_NAME` | Agent to act as |
| `agent_id` | `AX_AGENT_ID` | Agent UUID for explicit ID-targeted calls |
| `space_id` | `AX_SPACE_ID` | Space UUID |

## Identity Model

An agent-bound PAT is the agent's credential. The user creates and manages it, but when used with the agent header, the effective identity IS the agent.

The CLI sends one agent header by default:
- if `agent_name` is present, it sends `X-Agent-Name`
- otherwise, if only `agent_id` is present, it sends `X-Agent-Id`
- explicit `--agent-id` command flags still send `X-Agent-Id` for that request

| Config | Messages From |
|--------|---------------|
| No `agent_name`/`agent_id` | You (the user) |
| With `agent_name` + `agent_id` | The agent |

## Project-Local Config

```bash
# Set up per-repo config (add .ax/ to .gitignore)
ax auth init --token axp_u_... --agent orion --agent-id <uuid> --space-id <uuid>
```

`ax auth init` no longer requires a git repo. If no existing `.ax/` is found and you're outside git, it creates `.ax/config.toml` in the current directory.
44 changes: 37 additions & 7 deletions ax_cli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,50 @@


class AxClient:
def __init__(self, base_url: str, token: str, *, agent_name: str | None = None):
def __init__(self, base_url: str, token: str, *, agent_name: str | None = None, agent_id: str | None = None):
self.base_url = base_url.rstrip("/")
self.token = token
self._headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
if agent_name:
self._headers["X-Agent-Name"] = agent_name
self._headers.update(self._agent_headers(agent_name=agent_name, agent_id=agent_id))
self._http = httpx.Client(
base_url=self.base_url, headers=self._headers, timeout=30.0,
)

def _with_agent(self, agent_id: str | None) -> dict:
"""Add X-Agent-Id header if targeting an agent."""
@staticmethod
def _agent_headers(*, agent_name: str | None = None, agent_id: str | None = None) -> dict[str, str]:
"""Return exactly one agent identity header.

ID is canonical after bind and wins over name.
Name is only used during bootstrap (no ID yet) or explicit interactive targeting.
"""
if agent_id:
return {**self._headers, "X-Agent-Id": agent_id}
return {"X-Agent-Id": agent_id}
if agent_name:
return {"X-Agent-Name": agent_name}
return {}

def set_default_agent(self, *, agent_name: str | None = None, agent_id: str | None = None) -> None:
"""Update default agent identity headers for subsequent requests."""
self._headers.pop("X-Agent-Name", None)
self._headers.pop("X-Agent-Id", None)
self._headers.update(self._agent_headers(agent_name=agent_name, agent_id=agent_id))
self._http.headers.clear()
self._http.headers.update(self._headers)

def _with_agent(self, agent_id: str | None = None, *, agent_name: str | None = None) -> dict:
"""Return request headers with an optional explicit agent override."""
if agent_name or agent_id:
return {
**{
k: v
for k, v in self._headers.items()
if k not in {"X-Agent-Name", "X-Agent-Id"}
},
**self._agent_headers(agent_name=agent_name, agent_id=agent_id),
}
return self._headers

# --- Identity ---
Expand Down Expand Up @@ -243,8 +270,11 @@ def search_messages(self, query: str, limit: int = 20, *,
# --- Keys (PAT management) ---

def create_key(self, name: str, *,
allowed_agent_ids: list[str] | None = None) -> dict:
allowed_agent_ids: list[str] | None = None,
agent_scope: str | None = None) -> dict:
body: dict = {"name": name}
if agent_scope:
body["agent_scope"] = agent_scope
if allowed_agent_ids:
body["allowed_agent_ids"] = allowed_agent_ids
r = self._http.post("/api/v1/keys", json=body)
Expand Down
22 changes: 16 additions & 6 deletions ax_cli/commands/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import httpx

from ..config import (
get_client, save_token, resolve_token, resolve_agent_name,
get_client, save_agent_binding, save_token, resolve_token, resolve_agent_name,
_global_config_dir, _local_config_dir, _save_config, _load_local_config,
)
from ..output import JSON_OPTION, print_json, print_kv, handle_error, console
Expand All @@ -25,6 +25,13 @@ def whoami(as_json: bool = JSON_OPTION):
bound = data.get("bound_agent")
if bound:
data["resolved_space_id"] = bound.get("default_space_id", "none")
saved_binding = save_agent_binding(
agent_id=bound.get("agent_id"),
agent_name=bound.get("agent_name"),
space_id=bound.get("default_space_id"),
)
if saved_binding:
data["saved_agent_binding"] = True
else:
from ..config import resolve_space_id
try:
Expand Down Expand Up @@ -54,6 +61,7 @@ def init(
token: str = typer.Option(None, "--token", "-t", help="PAT token"),
base_url: str = typer.Option("http://localhost:8001", "--url", "-u", help="API base URL"),
agent_name: str = typer.Option(None, "--agent", "-a", help="Default agent name"),
agent_id: str = typer.Option(None, "--agent-id", help="Default agent ID (for agent-bound PATs)"),
space_id: str = typer.Option(None, "--space-id", "-s", help="Default space ID"),
):
"""Set up a project-local .ax/config.toml in the current repo.
Expand All @@ -62,12 +70,12 @@ def init(
Add .ax/ to .gitignore — credentials stay out of version control.

Examples:
ax auth init --token axp_u_... --agent protocol --space-id a632f74e-...
ax auth init --token axp_u_... --agent orion --agent-id 70c1b445-...
ax auth init --token axp_u_... --url https://dev.paxai.app --agent canvas
"""
local = _local_config_dir()
local = _local_config_dir(create=True)
if not local:
typer.echo("Error: Not in a git repo. Run from a project directory.", err=True)
typer.echo("Error: Cannot determine a project directory for local config.", err=True)
raise typer.Exit(1)

cfg = _load_local_config()
Expand All @@ -78,6 +86,8 @@ def init(
cfg["base_url"] = base_url
if agent_name:
cfg["agent_name"] = agent_name
if agent_id:
cfg["agent_id"] = agent_id
if space_id:
cfg["space_id"] = space_id

Expand All @@ -93,14 +103,14 @@ def init(
v = v[:6] + "..." + v[-4:] if len(v) > 10 else "***"
console.print(f" {k} = {v}")

# Check .gitignore
# Check .gitignore when available
root = local.parent
gitignore = root / ".gitignore"
if gitignore.exists():
content = gitignore.read_text()
if ".ax/" not in content and ".ax" not in content:
console.print(f"\n[yellow]Reminder:[/yellow] Add .ax/ to {gitignore}")
else:
elif (root / ".git").exists():
console.print(f"\n[yellow]Reminder:[/yellow] Add .ax/ to .gitignore")


Expand Down
Loading