diff --git a/CLAUDE.md b/CLAUDE.md index 87b828f..1ba9e4e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,9 +10,14 @@ Always use `uv run` to execute Python scripts and CLI commands (e.g., `uv run oc ## CLI Commands -- `ocaptain sail ` - 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 ` - View aggregated logs - `ocaptain tasks ` - Show task list @@ -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 # List sprites sprite exec -o -s # Run command on sprite ``` +**exe.dev:** +```bash +ssh .exe.xyz # SSH via exe.dev proxy +# Or via Tailscale (after ship joins tailnet): +ssh exedev@ -p 2222 +``` + ## Architecture - **Local Storage**: Voyages stored at `~/voyages//` diff --git a/README.md b/README.md index a4eb77a..5b54d3b 100644 --- a/README.md +++ b/README.md @@ -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 @@ -175,17 +181,23 @@ flowchart TB ## CLI Reference -### `ocaptain sail ` +### `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]` diff --git a/src/ocaptain/cli.py b/src/ocaptain/cli.py index 65bbb1d..5cb70cb 100644 --- a/src/ocaptain/cli.py +++ b/src/ocaptain/cli.py @@ -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}") @@ -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) @@ -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]") @@ -84,7 +94,7 @@ 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 @@ -92,7 +102,7 @@ def sail( with console.status(f"Launching voyage with {ships} ships..."): voyage = voyage_mod.sail( prompt, - repo, + plan_repo, ships, tokens, spec_content=spec_content, @@ -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)"), diff --git a/src/ocaptain/tmux.py b/src/ocaptain/tmux.py index 9352264..2b6b9fb 100644 --- a/src/ocaptain/tmux.py +++ b/src/ocaptain/tmux.py @@ -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) @@ -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, + ) diff --git a/src/ocaptain/voyage.py b/src/ocaptain/voyage.py index 30b7cb0..3b4a0f4 100644 --- a/src/ocaptain/voyage.py +++ b/src/ocaptain/voyage.py @@ -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