From 0f9772e47f6e4ba4b4a823357ec5ccd0e27248e5 Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Mon, 2 Feb 2026 09:12:12 -0800 Subject: [PATCH 1/4] feat: hoist the sails without a chart Add support for empty sail - launching a single ship without a plan directory. Useful for quick interactive Claude sessions. Usage: ocaptain sail # bare ship, no repo ocaptain sail --repo o/r # ship with repo cloned - Make plan argument optional in sail command - Add --repo/-r flag for empty sail with a repository - Add sail_empty() function in voyage.py - Add launch_interactive_ship() in tmux.py --- src/ocaptain/cli.py | 64 +++++++++++++++++++++++++++++--- src/ocaptain/tmux.py | 48 ++++++++++++++++++++++++ src/ocaptain/voyage.py | 83 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 189 insertions(+), 6 deletions(-) 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..d7e70ff 100644 --- a/src/ocaptain/voyage.py +++ b/src/ocaptain/voyage.py @@ -266,6 +266,89 @@ 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. Clone repository locally (if repo provided) + 3. Bootstrap single ship VM with Tailscale + 4. Start Mutagen sync (if repo provided) + 5. Launch Claude interactively in tmux + """ + from .config import CONFIG + from .local_storage import setup_local_voyage + from .mutagen import create_sync + 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. Clone repository locally (if provided) + has_workspace = False + if repo: + import subprocess # nosec: B404 + + subprocess.run( # nosec: B603, B607 + ["gh", "repo", "clone", repo, str(voyage_dir / "workspace")], + check=True, + ) + subprocess.run( # nosec: B603, B607 + ["git", "-C", str(voyage_dir / "workspace"), "checkout", "-b", voyage.branch], + check=True, + ) + has_workspace = True + + # 3. Write voyage.json locally + (voyage_dir / "voyage.json").write_text(voyage.to_json()) + + # 4. Bootstrap single ship + ship_vm, ship_ts_ip = bootstrap_ship(voyage, 0, tokens, telemetry) + + # 5. Start Mutagen sync (if repo provided) + if has_workspace: + remote_user = _get_remote_user(ship_vm) + remote_home = _get_remote_home(ship_vm) + session_name = f"{voyage.id}-ship-0" + + create_sync( + local_path=voyage_dir / "workspace", + remote_user=remote_user, + remote_host=ship_ts_ip, + remote_path=f"{remote_home}/voyage/workspace", + session_name=f"{session_name}-workspace", + extra_ignores=[".claude"], + ) + + # 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 From be691f07fe419ff5bd5f4dc02f7f4351a57f9520 Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Mon, 2 Feb 2026 09:27:19 -0800 Subject: [PATCH 2/4] fix: clone repo on ship rather than sync via mutagen Mutagen ignores .git by design. For empty sail, clone the repo directly on the ship so git history is preserved. --- src/ocaptain/voyage.py | 52 ++++++++++++++---------------------------- 1 file changed, 17 insertions(+), 35 deletions(-) diff --git a/src/ocaptain/voyage.py b/src/ocaptain/voyage.py index d7e70ff..7084e25 100644 --- a/src/ocaptain/voyage.py +++ b/src/ocaptain/voyage.py @@ -274,14 +274,12 @@ def sail_empty( """Launch a single-ship voyage without a plan. 1. Set up local voyage directory - 2. Clone repository locally (if repo provided) - 3. Bootstrap single ship VM with Tailscale - 4. Start Mutagen sync (if repo provided) - 5. Launch Claude interactively in tmux + 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 .mutagen import create_sync from .ship import bootstrap_ship from .tmux import launch_interactive_ship @@ -301,43 +299,27 @@ def sail_empty( # 1. Set up local voyage directory voyage_dir = setup_local_voyage(voyage.id, voyage.task_list_id) - # 2. Clone repository locally (if provided) - has_workspace = False - if repo: - import subprocess # nosec: B404 - - subprocess.run( # nosec: B603, B607 - ["gh", "repo", "clone", repo, str(voyage_dir / "workspace")], - check=True, - ) - subprocess.run( # nosec: B603, B607 - ["git", "-C", str(voyage_dir / "workspace"), "checkout", "-b", voyage.branch], - check=True, - ) - has_workspace = True - - # 3. Write voyage.json locally + # 2. Write voyage.json locally (voyage_dir / "voyage.json").write_text(voyage.to_json()) - # 4. Bootstrap single ship + # 3. Bootstrap single ship ship_vm, ship_ts_ip = bootstrap_ship(voyage, 0, tokens, telemetry) - # 5. Start Mutagen sync (if repo provided) - if has_workspace: - remote_user = _get_remote_user(ship_vm) + # 4. Clone repository on ship (if provided) + has_workspace = False + if repo: + provider = get_provider() remote_home = _get_remote_home(ship_vm) - session_name = f"{voyage.id}-ship-0" - create_sync( - local_path=voyage_dir / "workspace", - remote_user=remote_user, - remote_host=ship_ts_ip, - remote_path=f"{remote_home}/voyage/workspace", - session_name=f"{session_name}-workspace", - extra_ignores=[".claude"], - ) + 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 + # 5. Launch Claude interactively oauth_token = tokens.get("CLAUDE_CODE_OAUTH_TOKEN") if not oauth_token: raise ValueError( From 571ab217ee1fa145bdb67ae5230085223ce5bdf5 Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Mon, 2 Feb 2026 09:30:01 -0800 Subject: [PATCH 3/4] docs: chart the course for plan-less sailing Update README and CLAUDE.md to document empty sail mode: - ocaptain sail (bare ship) - ocaptain sail --repo owner/repo (ship with repo) --- CLAUDE.md | 9 +++++++-- README.md | 18 +++++++++++++++--- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 87b828f..8413ec1 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 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]` From 1914c93763685ef880cba9c24349083371ff656f Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Mon, 2 Feb 2026 09:40:15 -0800 Subject: [PATCH 4/4] fix: copy stop hook to ship for empty sail The settings.json references on-stop.sh but empty sail wasn't copying it. Also document exe.dev ships in CLAUDE.md. --- CLAUDE.md | 12 ++++++++++-- src/ocaptain/voyage.py | 19 ++++++++++++++++--- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8413ec1..1ba9e4e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,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/src/ocaptain/voyage.py b/src/ocaptain/voyage.py index 7084e25..3b4a0f4 100644 --- a/src/ocaptain/voyage.py +++ b/src/ocaptain/voyage.py @@ -305,10 +305,23 @@ def sail_empty( # 3. Bootstrap single ship ship_vm, ship_ts_ip = bootstrap_ship(voyage, 0, tokens, telemetry) - # 4. Clone repository on ship (if provided) + # 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: - provider = get_provider() remote_home = _get_remote_home(ship_vm) with get_connection(ship_vm, provider) as c: @@ -319,7 +332,7 @@ def sail_empty( ) has_workspace = True - # 5. Launch Claude interactively + # 6. Launch Claude interactively oauth_token = tokens.get("CLAUDE_CODE_OAUTH_TOKEN") if not oauth_token: raise ValueError(