Skip to content
Merged
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
21 changes: 17 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,14 @@ Always use `uv run` to execute Python scripts and CLI commands (e.g., `uv run oc

## CLI Commands

- `ocaptain sail <plan-dir>` - Launch a voyage from a plan directory
- `--ships, -n` - Override ship count
- `ocaptain sail [plan-dir]` - Launch a voyage from a plan directory, or a single interactive ship
- `--repo, -r` - Repository to clone (empty sail only)
- `--ships, -n` - Override ship count (ignored for empty sail)
- `--no-telemetry` - Disable OTLP telemetry
- Examples:
- `ocaptain sail ./plans/my-plan` - Multi-ship fleet with plan
- `ocaptain sail` - Single interactive ship, no repo
- `ocaptain sail --repo owner/repo` - Single ship with repo cloned
- `ocaptain status [voyage_id]` - Show voyage status
- `ocaptain logs <voyage_id>` - View aggregated logs
- `ocaptain tasks <voyage_id>` - Show task list
Expand All @@ -26,15 +31,23 @@ Always use `uv run` to execute Python scripts and CLI commands (e.g., `uv run oc
- `ocaptain telemetry-start` - Start OTLP collector
- `ocaptain telemetry-stop` - Stop OTLP collector

### sprites.dev Commands
### VM Provider Commands

Ships run on sprites.dev. Use `sprite` CLI for debugging:
Ships run on sprites.dev (fly.io) or exe.dev. Use the appropriate CLI for debugging:

**sprites.dev:**
```bash
sprite list -o <org> # List sprites
sprite exec -o <org> -s <name> # Run command on sprite
```

**exe.dev:**
```bash
ssh <ship-name>.exe.xyz # SSH via exe.dev proxy
# Or via Tailscale (after ship joins tailnet):
ssh exedev@<tailscale-ip> -p 2222
```

## Architecture

- **Local Storage**: Voyages stored at `~/voyages/<voyage-id>/`
Expand Down
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ Provisions a fleet of VMs on sprites.dev (fly.io) or exe.dev, each running an au
You (local) → ocaptain sail → sprites.dev VMs → Ships claim tasks → Code syncs back
```

Or launch a single ship for interactive use—no plan required:

```
You (local) → ocaptain sail --repo owner/repo → Single VM → Claude ready for commands
```

## Why?

- Deploy parallel, autonomous Claude Code instances with one command
Expand Down Expand Up @@ -175,17 +181,23 @@ flowchart TB

## CLI Reference

### `ocaptain sail <plan>`
### `ocaptain sail [plan]`

Launch a new voyage from a plan directory.
Launch a new voyage from a plan directory, or a single interactive ship without a plan.

```bash
# With a plan (multi-ship fleet)
ocaptain sail ./plans/add-auth --ships 5

# Without a plan (single interactive ship)
ocaptain sail # Bare ship, no repo
ocaptain sail --repo owner/repo # Ship with repo cloned
```

| Option | Description |
|--------|-------------|
| `--ships, -n` | Override recommended ship count |
| `--repo, -r` | Repository to clone (empty sail only) |
| `--ships, -n` | Override recommended ship count (ignored for empty sail) |
| `--no-telemetry` | Disable OTLP telemetry collection |

### `ocaptain status [voyage_id]`
Expand Down
64 changes: 58 additions & 6 deletions src/ocaptain/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,17 @@

@app.command()
def sail(
plan: str = typer.Argument(..., help="Path to voyage plan directory"),
plan: str = typer.Argument(None, help="Path to voyage plan directory"),
repo: str = typer.Option(None, "--repo", "-r", help="Repository to clone (empty sail only)"),
ships: int = typer.Option(None, "--ships", "-n", help="Override recommended ship count"),
no_telemetry: bool = typer.Option(False, "--no-telemetry", help="Disable OTLP telemetry"),
) -> None:
"""Launch a new voyage from a plan directory."""
"""Launch a new voyage from a plan directory, or an empty ship for interactive use."""
# Empty sail mode: no plan provided
if plan is None:
_sail_empty(repo=repo, no_telemetry=no_telemetry)
return

plan_dir = Path(plan)
if not plan_dir.is_dir():
console.print(f"[red]Error:[/red] Plan directory not found: {plan}")
Expand All @@ -45,13 +51,17 @@ def sail(
console.print(f" - {error}")
raise typer.Exit(1)

# Warn if --repo was provided with a plan (ignored)
if repo:
console.print("[yellow]Warning:[/yellow] --repo is ignored when a plan is provided")

# Load voyage.json
voyage_json = json.loads((plan_dir / "voyage.json").read_text())
tasks_dir = plan_dir / "tasks"
task_count = len(list(tasks_dir.glob("*.json")))

# Get values from voyage.json
repo = voyage_json["repo"]
plan_repo = voyage_json["repo"]
if not ships:
ships = voyage_json.get("recommended_ships", 3)

Expand All @@ -69,7 +79,7 @@ def sail(
telemetry = not no_telemetry and CONFIG.telemetry_enabled

console.print(f"[dim]Plan loaded: {plan_dir.name}[/dim]")
console.print(f"[dim] Repo: {repo}[/dim]")
console.print(f"[dim] Repo: {plan_repo}[/dim]")
console.print(f"[dim] Tasks: {task_count} (pre-created)[/dim]")
console.print(f"[dim] Ships: {ships}[/dim]")
console.print(f"[dim] Telemetry: {'enabled' if telemetry else 'disabled'}[/dim]")
Expand All @@ -84,15 +94,15 @@ def sail(
# Validate repository access before provisioning VMs
with console.status("Validating repository access..."):
try:
validate_repo_access(repo, tokens.get("GH_TOKEN"))
validate_repo_access(plan_repo, tokens.get("GH_TOKEN"))
except ValueError as e:
console.print(f"[red]Error:[/red] {e}")
raise typer.Exit(1) from None

with console.status(f"Launching voyage with {ships} ships..."):
voyage = voyage_mod.sail(
prompt,
repo,
plan_repo,
ships,
tokens,
spec_content=spec_content,
Expand All @@ -110,6 +120,48 @@ def sail(
console.print(f" [dim]ocaptain status {voyage.id}[/dim]")


def _sail_empty(repo: str | None, no_telemetry: bool) -> None:
"""Launch a single ship for interactive use (no plan)."""
telemetry = not no_telemetry and CONFIG.telemetry_enabled

if repo:
console.print(f"[dim]Launching empty sail with repo: {repo}[/dim]")
else:
console.print("[dim]Launching empty sail (no repo)[/dim]")
console.print(f"[dim] Telemetry: {'enabled' if telemetry else 'disabled'}[/dim]")

# Load and validate tokens
try:
tokens = load_tokens()
except ValueError as e:
console.print(f"[red]Error:[/red] {e}")
raise typer.Exit(1) from None

# Validate repository access if repo provided
if repo:
with console.status("Validating repository access..."):
try:
validate_repo_access(repo, tokens.get("GH_TOKEN"))
except ValueError as e:
console.print(f"[red]Error:[/red] {e}")
raise typer.Exit(1) from None

with console.status("Provisioning ship..."):
voyage = voyage_mod.sail_empty(
repo=repo,
tokens=tokens,
telemetry=telemetry,
)

console.print(f"\n[green]✓[/green] Voyage [bold]{voyage.id}[/bold] launched")
if repo:
console.print(f" Repo: {voyage.repo}")
console.print(f" Branch: {voyage.branch}")
console.print(" Ships: 1")
console.print("\nClaude awaits your command. Attach with:")
console.print(f" [dim]ocaptain shell {voyage.id}[/dim]")


@app.command()
def status(
voyage_id: str | None = typer.Argument(None, help="Voyage ID (optional if only one active)"),
Expand Down
48 changes: 48 additions & 0 deletions src/ocaptain/tmux.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,19 @@ def _build_claude_command(ship_id: str, voyage: Voyage, oauth_token: str) -> str
)


def _build_interactive_claude_command(ship_id: str, oauth_token: str, has_workspace: bool) -> str:
"""Build command for interactive Claude session (no plan)."""
cd_cmd = "cd ~/voyage/workspace && " if has_workspace else ""
return (
f"set -o pipefail && "
f"{cd_cmd}"
f"export LANG=C.utf8 LC_CTYPE=C.utf8 LC_ALL=C.utf8 && "
f"export CLAUDE_CODE_OAUTH_TOKEN={shlex.quote(oauth_token)} && "
f"claude --dangerously-skip-permissions ; "
f"exec bash"
)


def start_claude_on_ship(ship: VM, ship_id: str, voyage: Voyage, oauth_token: str) -> None:
"""Start Claude in a tmux session on the ship (runs autonomously)."""
claude_cmd = _build_claude_command(ship_id, voyage, oauth_token)
Expand Down Expand Up @@ -147,3 +160,38 @@ def launch_fleet(
start_claude_on_sprite(ship, ship_id, voyage, oauth_token)
else:
start_claude_on_ship(ship, ship_id, voyage, oauth_token)


def launch_interactive_ship(
ship: VM,
ship_id: str,
oauth_token: str,
has_workspace: bool,
) -> None:
"""Start Claude interactively on a single ship (no plan).

Claude runs inside tmux on the ship, surviving laptop disconnection.
Use `ocaptain shell` to attach and interact.
"""
claude_cmd = _build_interactive_claude_command(ship_id, oauth_token, has_workspace)
tmux_cmd = f"tmux new-session -d -s claude {shlex.quote(claude_cmd)}"

if is_sprite_vm(ship):
org = _get_sprites_org()
subprocess.run( # nosec B603 B607
["sprite", "exec", "-o", org, "-s", ship.name, "bash", "-c", tmux_cmd],
check=True,
)
else:
subprocess.run( # nosec B603 B607
[
"ssh",
"-o",
"StrictHostKeyChecking=no",
"-o",
"UserKnownHostsFile=/dev/null",
ship.ssh_dest,
tmux_cmd,
],
check=True,
)
78 changes: 78 additions & 0 deletions src/ocaptain/voyage.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,84 @@ def sail(
return voyage


def sail_empty(
repo: str | None = None,
tokens: dict[str, str] | None = None,
telemetry: bool = True,
) -> Voyage:
"""Launch a single-ship voyage without a plan.

1. Set up local voyage directory
2. Bootstrap single ship VM with Tailscale
3. Clone repository on ship (if repo provided)
4. Launch Claude interactively in tmux
"""
from .config import CONFIG
from .local_storage import setup_local_voyage
from .ship import bootstrap_ship
from .tmux import launch_interactive_ship

if tokens is None:
tokens = {}

# Create voyage with placeholder values for empty sail
prompt = "Interactive session"
voyage = Voyage.create(prompt, repo or "local", ships=1)

# Verify tailscale is ready
if not CONFIG.tailscale.ip:
raise RuntimeError("Tailscale IP not detected. Is Tailscale running?")
if not CONFIG.tailscale.oauth_secret:
raise RuntimeError("OCAPTAIN_TAILSCALE_OAUTH_SECRET not set")

# 1. Set up local voyage directory
voyage_dir = setup_local_voyage(voyage.id, voyage.task_list_id)

# 2. Write voyage.json locally
(voyage_dir / "voyage.json").write_text(voyage.to_json())

# 3. Bootstrap single ship
ship_vm, ship_ts_ip = bootstrap_ship(voyage, 0, tokens, telemetry)

# 4. Write and copy stop hook to ship
hook_content = render_stop_hook()
(voyage_dir / "on-stop.sh").write_text(hook_content)
(voyage_dir / "on-stop.sh").chmod(0o755)

provider = get_provider()
_copy_file_to_ship(
voyage_dir / "on-stop.sh",
f"{_get_remote_home(ship_vm)}/.ocaptain/hooks/on-stop.sh",
ship_vm,
ship_ts_ip,
provider,
)

# 5. Clone repository on ship (if provided)
has_workspace = False
if repo:
remote_home = _get_remote_home(ship_vm)

with get_connection(ship_vm, provider) as c:
c.run(f"gh repo clone {repo} {remote_home}/voyage/workspace", hide=True)
c.run(
f"git -C {remote_home}/voyage/workspace checkout -b {voyage.branch}",
hide=True,
)
has_workspace = True

# 6. Launch Claude interactively
oauth_token = tokens.get("CLAUDE_CODE_OAUTH_TOKEN")
if not oauth_token:
raise ValueError(
"CLAUDE_CODE_OAUTH_TOKEN not provided - cannot launch without authentication"
)

launch_interactive_ship(ship_vm, "ship-0", oauth_token, has_workspace)

return voyage


def load_voyage(voyage_id: str) -> Voyage:
"""Load voyage from local storage."""
from .local_storage import get_voyage_dir
Expand Down