plano/cli/planoai/launch_cmd.py

331 lines
10 KiB
Python

"""``planoai launch`` command group.
Launches CLI agents (Claude Code, Codex) or the Claude Desktop app against the
local Plano gateway. This replaces the old ``planoai cli-agent`` command.
"""
from __future__ import annotations
import json
import os
import sys
from typing import Optional
import rich_click as click
import yaml
from planoai import claude_desktop as _cd
from planoai.consts import NATIVE_PID_FILE, PLANO_DOCKER_NAME
from planoai.core import _resolve_cli_agent_endpoint, start_cli_agent
from planoai.docker_cli import docker_container_status
from planoai.defaults import DEFAULT_LLM_LISTENER_PORT
from planoai.utils import find_config_file, getLogger
log = getLogger(__name__)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _is_native_plano_running() -> bool:
if not os.path.exists(NATIVE_PID_FILE):
return False
try:
with open(NATIVE_PID_FILE, "r") as f:
pids = json.load(f)
except (OSError, json.JSONDecodeError):
return False
envoy_pid = pids.get("envoy_pid")
brightstaff_pid = pids.get("brightstaff_pid")
if not isinstance(envoy_pid, int) or not isinstance(brightstaff_pid, int):
return False
for pid in (envoy_pid, brightstaff_pid):
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
continue
return True
def _is_plano_running() -> bool:
if _is_native_plano_running():
return True
return docker_container_status(PLANO_DOCKER_NAME) == "running"
def _require_plano_running(console) -> None:
if _is_plano_running():
return
console.print("[red]✗[/red] Plano is not running.")
console.print(
"[dim]Start Plano first using 'planoai up <config.yaml>' "
"(native or --docker mode).[/dim]"
)
sys.exit(1)
def _start_plano_with_config(config_path: str, console) -> None:
"""Invoke `planoai up` against the given config and wait for it to be healthy.
Reuses the click ``up`` command's callback so we get the same validation,
env loading, and native runner behavior as a top-level invocation. ``up``
runs in detached/background mode by default and only returns once Plano is
healthy, so we can safely continue with the Claude Desktop config flow
after it returns.
"""
# Lazy import: ``planoai.main`` pulls in heavy modules (rich, native runner,
# etc.) and would create a circular import at module-load time.
from planoai.main import up
abs_path = os.path.abspath(config_path)
if not os.path.exists(abs_path):
console.print(f"[red]✗[/red] Config file not found: {abs_path}")
sys.exit(1)
console.print(
f"[dim]Starting Plano with config " f"[cyan]{abs_path}[/cyan]...[/dim]"
)
up.callback(
file=abs_path,
path=".",
foreground=False,
with_tracing=False,
tracing_port=4317,
docker=False,
verbose=False,
listener_port=DEFAULT_LLM_LISTENER_PORT,
)
def _base_url_from_config_file(config_path: str) -> Optional[str]:
try:
with open(config_path, "r") as f:
cfg = yaml.safe_load(f) or {}
except (OSError, yaml.YAMLError):
return None
_host, port = _resolve_cli_agent_endpoint(cfg)
return f"http://localhost:{port}"
def _resolve_plano_config(file: Optional[str], path: str, console) -> str:
plano_config_file = find_config_file(path, file)
if not os.path.exists(plano_config_file):
console.print(f"[red]✗[/red] Config file not found: {plano_config_file}")
sys.exit(1)
return plano_config_file
def _run_cli_agent(agent_type: str, file, path, settings) -> None:
from rich.console import Console
console = Console()
_require_plano_running(console)
plano_config_file = _resolve_plano_config(file, path, console)
try:
start_cli_agent(plano_config_file, agent_type, settings)
except SystemExit:
raise
except Exception as e:
click.echo(f"Error: {e}")
sys.exit(1)
# ---------------------------------------------------------------------------
# Group + subcommands
# ---------------------------------------------------------------------------
@click.group()
def launch():
"""Launch a CLI agent or desktop app against the local Plano gateway."""
@launch.command("claude-cli")
@click.argument("file", required=False)
@click.option(
"--path", default=".", help="Path to the directory containing plano_config.yaml"
)
@click.option(
"--settings",
default="{}",
help="Additional settings as JSON string for the CLI agent.",
)
def claude_cli(file, path, settings):
"""Launch the Claude Code CLI connected to Plano."""
_run_cli_agent("claude", file, path, settings)
@launch.command("codex")
@click.argument("file", required=False)
@click.option(
"--path", default=".", help="Path to the directory containing plano_config.yaml"
)
@click.option(
"--settings",
default="{}",
help="Additional settings as JSON string for the CLI agent.",
)
def codex(file, path, settings):
"""Launch the Codex CLI connected to Plano."""
_run_cli_agent("codex", file, path, settings)
@launch.command("claude-desktop")
@click.option(
"--config",
"config_path",
type=click.Path(dir_okay=False),
default=None,
help="Path to a Plano config; if Plano isn't already running, "
"`planoai up <config>` is invoked first so the gateway is ready before "
"Claude Desktop is configured.",
)
@click.option(
"--no-launch",
"no_launch",
is_flag=True,
default=False,
help="Configure Claude Desktop but do not (re)open the app afterwards.",
)
@click.option(
"--restore",
"restore_flag",
is_flag=True,
default=False,
help="Switch Claude Desktop back to its usual Anthropic Claude profile.",
)
@click.option(
"--yes",
"-y",
"yes_flag",
is_flag=True,
default=False,
help="Auto-approve restart prompts.",
)
@click.option(
"--base-url",
default=None,
help="Plano LLM listener URL (default: derived from --config or running Plano, falling back to http://localhost:12000).",
)
def claude_desktop_cmd(config_path, no_launch, restore_flag, yes_flag, base_url):
"""Configure Claude Desktop to use the local Plano gateway.
Mirrors `ollama launch claude-desktop`: rewrites Claude Desktop's profile
JSONs (with `.bak` backups) to switch into third-party gateway mode pointed
at Plano, then optionally restarts Claude Desktop so the change takes
effect. When `--config <path>` is supplied and Plano is not already
running, this command also starts Plano with that config first, so the
end-to-end flow is a single command.
"""
from rich.console import Console
console = Console()
err = _cd.supported()
if err is not None:
console.print(f"[red]✗[/red] {err}")
sys.exit(1)
if restore_flag:
if config_path is not None:
console.print(
"[yellow]⚠[/yellow] --config is ignored when --restore is set."
)
try:
_cd.restore()
except Exception as e:
console.print(f"[red]✗[/red] Failed to restore Claude Desktop: {e}")
sys.exit(1)
console.print(f"[green]✓[/green] {_cd.RESTORED_MESSAGE}")
if no_launch:
return
try:
_cd.launch_or_restart(
"Restart Claude Desktop to use the usual Claude profile?",
yes_flag,
)
except Exception as e:
console.print(f"[yellow]⚠[/yellow] Could not restart Claude Desktop: {e}")
return
# Auto-start Plano if --config was provided and nothing is running yet.
if config_path is not None:
abs_config = os.path.abspath(config_path)
if not os.path.exists(abs_config):
console.print(f"[red]✗[/red] Config file not found: {abs_config}")
sys.exit(1)
if _is_plano_running():
console.print(
"[dim]Plano already running; skipping startup. Using listener "
"from [cyan]"
f"{abs_config}[/cyan] for the gateway URL.[/dim]"
)
else:
_start_plano_with_config(abs_config, console)
# Resolve base URL precedence: --base-url > --config file > running Plano > default.
resolved_url = (
base_url
or (
_base_url_from_config_file(os.path.abspath(config_path))
if config_path is not None
else None
)
or _resolve_base_url_from_running_plano()
or _cd.DEFAULT_BASE_URL
)
if not _is_plano_running():
console.print(
"[yellow]⚠[/yellow] Plano does not appear to be running. "
"Start it with [cyan]planoai up[/cyan] (or pass [cyan]--config "
"<path>[/cyan]) before using Claude Desktop."
)
console.print(
f"[dim]Configuring Claude Desktop to use Plano at "
f"[cyan]{resolved_url}[/cyan][/dim]"
)
try:
_cd.configure(resolved_url)
except Exception as e:
console.print(f"[red]✗[/red] Failed to configure Claude Desktop: {e}")
sys.exit(1)
console.print(f"[green]✓[/green] {_cd.SUCCESS_MESSAGE}")
console.print(f"[dim]{_cd.RESTORE_HINT}[/dim]")
if no_launch:
return
try:
_cd.launch_or_restart("Restart Claude Desktop to use Plano?", yes_flag)
except Exception as e:
console.print(f"[yellow]⚠[/yellow] Could not restart Claude Desktop: {e}")
def _resolve_base_url_from_running_plano() -> Optional[str]:
"""Return ``http://localhost:<port>`` for the active Plano LLM listener.
Best-effort: if no config can be located, return ``None`` so the caller
falls back to ``DEFAULT_BASE_URL``.
"""
try:
plano_config_file = find_config_file(".", None)
except Exception:
return None
if not plano_config_file or not os.path.exists(plano_config_file):
return None
try:
with open(plano_config_file, "r") as f:
cfg = yaml.safe_load(f) or {}
except (OSError, yaml.YAMLError):
return None
_host, port = _resolve_cli_agent_endpoint(cfg)
return f"http://localhost:{port}"