mirror of
https://github.com/katanemo/plano.git
synced 2026-06-17 15:25:17 +02:00
add init command
This commit is contained in:
parent
754cb7a091
commit
cde1d0f87f
7 changed files with 806 additions and 0 deletions
|
|
@ -1,5 +1,8 @@
|
|||
import os
|
||||
|
||||
# Brand color - Plano purple
|
||||
PLANO_COLOR = "#969FF4"
|
||||
|
||||
SERVICE_NAME_ARCHGW = "plano"
|
||||
PLANO_DOCKER_NAME = "plano"
|
||||
PLANO_DOCKER_IMAGE = os.getenv("PLANO_DOCKER_IMAGE", "katanemo/plano:0.4.2")
|
||||
|
|
|
|||
701
cli/planoai/init_cmd.py
Normal file
701
cli/planoai/init_cmd.py
Normal file
|
|
@ -0,0 +1,701 @@
|
|||
import re
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import rich_click as click
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
|
||||
from planoai.consts import PLANO_COLOR
|
||||
from planoai.utils import get_llm_provider_access_keys, find_repo_root
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Template:
|
||||
"""
|
||||
A Plano config template.
|
||||
|
||||
- id: stable identifier used by --template
|
||||
- title/description: UI strings
|
||||
- yaml_text: embedded template contents (works in PyPI installs)
|
||||
- repo_path: optional path to a real demos/.../config.yaml when running in-repo
|
||||
"""
|
||||
|
||||
id: str
|
||||
title: str
|
||||
description: str
|
||||
yaml_text: str | None = None
|
||||
repo_path: str | None = None
|
||||
|
||||
|
||||
BUILTIN_TEMPLATES: list[Template] = [
|
||||
Template(
|
||||
id="samples_python/weather_forecast",
|
||||
title="samples_python/weather_forecast",
|
||||
description="prompt targets + multiple LLMs (OpenAI/Groq/Anthropic)",
|
||||
yaml_text="""version: v0.1.0
|
||||
|
||||
listeners:
|
||||
ingress_traffic:
|
||||
address: 0.0.0.0
|
||||
port: 10000
|
||||
message_format: openai
|
||||
timeout: 30s
|
||||
|
||||
egress_traffic:
|
||||
address: 0.0.0.0
|
||||
port: 12000
|
||||
message_format: openai
|
||||
timeout: 30s
|
||||
|
||||
endpoints:
|
||||
weather_forecast_service:
|
||||
endpoint: host.docker.internal:18083
|
||||
connect_timeout: 0.005s
|
||||
|
||||
overrides:
|
||||
prompt_target_intent_matching_threshold: 0.6
|
||||
|
||||
llm_providers:
|
||||
- access_key: $GROQ_API_KEY
|
||||
model: groq/llama-3.2-3b-preview
|
||||
|
||||
- access_key: $OPENAI_API_KEY
|
||||
model: openai/gpt-4o
|
||||
default: true
|
||||
|
||||
- access_key: $OPENAI_API_KEY
|
||||
model: openai/gpt-4o-mini
|
||||
|
||||
- access_key: $ANTHROPIC_API_KEY
|
||||
model: anthropic/claude-sonnet-4-20250514
|
||||
|
||||
system_prompt: |
|
||||
You are a helpful assistant.
|
||||
|
||||
prompt_targets:
|
||||
- name: get_current_weather
|
||||
description: Get current weather at a location.
|
||||
parameters:
|
||||
- name: location
|
||||
description: The location to get the weather for
|
||||
required: true
|
||||
type: string
|
||||
format: City, State
|
||||
- name: days
|
||||
description: the number of days for the request
|
||||
required: true
|
||||
type: int
|
||||
endpoint:
|
||||
name: weather_forecast_service
|
||||
path: /weather
|
||||
http_method: POST
|
||||
|
||||
- name: default_target
|
||||
default: true
|
||||
description: This is the default target for all unmatched prompts.
|
||||
endpoint:
|
||||
name: weather_forecast_service
|
||||
path: /default_target
|
||||
http_method: POST
|
||||
system_prompt: |
|
||||
You are a helpful assistant! Summarize the user's request and provide a helpful response.
|
||||
auto_llm_dispatch_on_response: false
|
||||
|
||||
tracing:
|
||||
random_sampling: 100
|
||||
trace_arch_internal: true
|
||||
""",
|
||||
),
|
||||
Template(
|
||||
id="samples_python/stock_quote",
|
||||
title="samples_python/stock_quote",
|
||||
description="external API headers ($TWELVEDATA_API_KEY) + prompt targets",
|
||||
yaml_text="""version: v0.1.0
|
||||
|
||||
listeners:
|
||||
ingress_traffic:
|
||||
address: 0.0.0.0
|
||||
port: 10000
|
||||
message_format: openai
|
||||
timeout: 30s
|
||||
|
||||
llm_providers:
|
||||
- access_key: $OPENAI_API_KEY
|
||||
model: openai/gpt-4o
|
||||
|
||||
endpoints:
|
||||
twelvedata_api:
|
||||
endpoint: api.twelvedata.com
|
||||
protocol: https
|
||||
|
||||
system_prompt: |
|
||||
You are a helpful assistant.
|
||||
|
||||
prompt_targets:
|
||||
- name: stock_quote
|
||||
description: get current stock exchange rate for a given symbol
|
||||
parameters:
|
||||
- name: symbol
|
||||
description: Stock symbol
|
||||
required: true
|
||||
type: str
|
||||
endpoint:
|
||||
name: twelvedata_api
|
||||
path: /quote
|
||||
http_headers:
|
||||
Authorization: "apikey $TWELVEDATA_API_KEY"
|
||||
system_prompt: |
|
||||
You are a helpful stock exchange assistant. Parse the JSON and present it in a human-readable format. Be concise.
|
||||
|
||||
- name: stock_quote_time_series
|
||||
description: get historical stock exchange rate for a given symbol
|
||||
parameters:
|
||||
- name: symbol
|
||||
description: Stock symbol
|
||||
required: true
|
||||
type: str
|
||||
- name: interval
|
||||
description: Time interval
|
||||
default: 1day
|
||||
enum:
|
||||
- 1h
|
||||
- 1day
|
||||
type: str
|
||||
endpoint:
|
||||
name: twelvedata_api
|
||||
path: /time_series
|
||||
http_headers:
|
||||
Authorization: "apikey $TWELVEDATA_API_KEY"
|
||||
system_prompt: |
|
||||
You are a helpful stock exchange assistant. Parse the JSON and present it in a human-readable format. Be concise.
|
||||
|
||||
tracing:
|
||||
random_sampling: 100
|
||||
trace_arch_internal: true
|
||||
""",
|
||||
),
|
||||
Template(
|
||||
id="use_cases/claude_code_router",
|
||||
title="use_cases/claude_code_router",
|
||||
description="multi-model routing preferences + model_aliases (good for CLI agents)",
|
||||
yaml_text="""version: v0.1
|
||||
|
||||
listeners:
|
||||
egress_traffic:
|
||||
address: 0.0.0.0
|
||||
port: 12000
|
||||
message_format: openai
|
||||
timeout: 30s
|
||||
|
||||
llm_providers:
|
||||
- model: openai/gpt-5-2025-08-07
|
||||
access_key: $OPENAI_API_KEY
|
||||
routing_preferences:
|
||||
- name: code generation
|
||||
description: generating new code snippets, functions, or boilerplate based on user prompts or requirements
|
||||
|
||||
- model: openai/gpt-4.1-2025-04-14
|
||||
access_key: $OPENAI_API_KEY
|
||||
routing_preferences:
|
||||
- name: code understanding
|
||||
description: understand and explain existing code snippets, functions, or libraries
|
||||
|
||||
- model: anthropic/claude-sonnet-4-5
|
||||
default: true
|
||||
access_key: $ANTHROPIC_API_KEY
|
||||
|
||||
- model: anthropic/claude-haiku-4-5
|
||||
access_key: $ANTHROPIC_API_KEY
|
||||
|
||||
- model: ollama/llama3.1
|
||||
base_url: http://host.docker.internal:11434
|
||||
|
||||
model_aliases:
|
||||
arch.claude.code.small.fast:
|
||||
target: claude-haiku-4-5
|
||||
|
||||
tracing:
|
||||
random_sampling: 100
|
||||
""",
|
||||
),
|
||||
Template(
|
||||
id="use_cases/ollama",
|
||||
title="use_cases/ollama",
|
||||
description="local LLM via base_url (OpenAI-compatible provider_interface)",
|
||||
yaml_text="""version: v0.1.0
|
||||
|
||||
listeners:
|
||||
egress_traffic:
|
||||
address: 0.0.0.0
|
||||
port: 12000
|
||||
message_format: openai
|
||||
timeout: 30s
|
||||
|
||||
llm_providers:
|
||||
- model: my_llm_provider/llama3.2
|
||||
provider_interface: openai
|
||||
base_url: http://host.docker.internal:11434
|
||||
default: true
|
||||
|
||||
system_prompt: |
|
||||
You are a helpful assistant.
|
||||
|
||||
tracing:
|
||||
random_sampling: 100
|
||||
trace_arch_internal: true
|
||||
""",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _discover_repo_demo_templates(repo_root: str | None) -> dict[str, str]:
|
||||
"""
|
||||
Returns mapping from template id -> absolute config.yaml path for repo demos.
|
||||
This is best-effort and should be fast; built-in templates remain the default.
|
||||
"""
|
||||
if not repo_root:
|
||||
return {}
|
||||
demos_dir = Path(repo_root) / "demos"
|
||||
if not demos_dir.exists():
|
||||
return {}
|
||||
|
||||
result: dict[str, str] = {}
|
||||
# keep it bounded: just walk demos and match config.yaml (small tree)
|
||||
for cfg in demos_dir.rglob("config.yaml"):
|
||||
try:
|
||||
rel = cfg.relative_to(demos_dir).as_posix()
|
||||
except Exception:
|
||||
continue
|
||||
template_id = rel.removesuffix("/config.yaml")
|
||||
result[template_id] = str(cfg)
|
||||
return result
|
||||
|
||||
|
||||
def _get_templates() -> list[Template]:
|
||||
repo_root = find_repo_root()
|
||||
repo_templates = _discover_repo_demo_templates(repo_root)
|
||||
templates: list[Template] = []
|
||||
for t in BUILTIN_TEMPLATES:
|
||||
repo_path = repo_templates.get(t.id)
|
||||
templates.append(
|
||||
Template(
|
||||
id=t.id,
|
||||
title=t.title,
|
||||
description=t.description,
|
||||
yaml_text=t.yaml_text,
|
||||
repo_path=repo_path,
|
||||
)
|
||||
)
|
||||
|
||||
# Add any extra demo configs not represented by built-ins (no embedded yaml).
|
||||
builtin_ids = {t.id for t in templates}
|
||||
for template_id, path in sorted(repo_templates.items()):
|
||||
if template_id in builtin_ids:
|
||||
continue
|
||||
templates.append(
|
||||
Template(
|
||||
id=template_id,
|
||||
title=template_id,
|
||||
description="(repo demo)",
|
||||
yaml_text=None,
|
||||
repo_path=path,
|
||||
)
|
||||
)
|
||||
return templates
|
||||
|
||||
|
||||
def _resolve_template(template_id_or_path: str | None) -> Template | None:
|
||||
if not template_id_or_path:
|
||||
return None
|
||||
|
||||
# 1) explicit path
|
||||
p = Path(template_id_or_path)
|
||||
if p.exists() and p.is_file():
|
||||
return Template(
|
||||
id=str(p),
|
||||
title=str(p),
|
||||
description="(file)",
|
||||
yaml_text=None,
|
||||
repo_path=str(p.resolve()),
|
||||
)
|
||||
|
||||
# 2) known id
|
||||
templates = _get_templates()
|
||||
for t in templates:
|
||||
if t.id == template_id_or_path:
|
||||
return t
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _ensure_parent_dir(path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _write_clean_config(path: Path, force: bool) -> None:
|
||||
_ensure_parent_dir(path)
|
||||
if path.exists() and not force:
|
||||
raise FileExistsError(str(path))
|
||||
# user asked for NOTHING in it: empty file, with just a newline for POSIX friendliness
|
||||
path.write_text("\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _write_template_config(path: Path, template: Template, force: bool) -> str:
|
||||
_ensure_parent_dir(path)
|
||||
if path.exists() and not force:
|
||||
raise FileExistsError(str(path))
|
||||
|
||||
if template.repo_path:
|
||||
src = Path(template.repo_path)
|
||||
text = src.read_text(encoding="utf-8")
|
||||
path.write_text(text, encoding="utf-8")
|
||||
return f"repo:{template.repo_path}"
|
||||
|
||||
if template.yaml_text is None:
|
||||
raise ValueError(f"Template '{template.id}' is not available in this install.")
|
||||
|
||||
path.write_text(template.yaml_text, encoding="utf-8")
|
||||
return "builtin"
|
||||
|
||||
|
||||
_ENV_VAR_PATTERN = re.compile(r"\$\{?([A-Z_][A-Z0-9_]*)\}?")
|
||||
|
||||
|
||||
def _extract_env_vars(config_path: Path) -> list[str]:
|
||||
"""
|
||||
Extract env vars referenced by the config so we can offer .env placeholders.
|
||||
Uses existing logic (headers/model providers/etc) plus a regex fallback.
|
||||
"""
|
||||
keys: set[str] = set()
|
||||
try:
|
||||
extracted = get_llm_provider_access_keys(str(config_path))
|
||||
for item in extracted:
|
||||
if not item:
|
||||
continue
|
||||
if item.startswith("$"):
|
||||
keys.add(item[1:])
|
||||
else:
|
||||
# some cases may return raw vars
|
||||
keys.add(item)
|
||||
except Exception:
|
||||
# best-effort; still run regex scan
|
||||
pass
|
||||
|
||||
try:
|
||||
text = config_path.read_text(encoding="utf-8")
|
||||
for m in _ENV_VAR_PATTERN.findall(text):
|
||||
keys.add(m)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Filter obvious false positives if any ever appear
|
||||
keys.discard("HOST")
|
||||
keys.discard("PORT")
|
||||
return sorted(keys)
|
||||
|
||||
|
||||
def _read_env_file_keys(env_path: Path) -> set[str]:
|
||||
if not env_path.exists():
|
||||
return set()
|
||||
keys: set[str] = set()
|
||||
for line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
s = line.strip()
|
||||
if not s or s.startswith("#") or "=" not in s:
|
||||
continue
|
||||
k = s.split("=", 1)[0].strip()
|
||||
if k:
|
||||
keys.add(k)
|
||||
return keys
|
||||
|
||||
|
||||
def _upsert_env_placeholders(env_path: Path, keys: list[str]) -> list[str]:
|
||||
"""
|
||||
Create or append missing keys with blank values. Returns the keys actually added.
|
||||
"""
|
||||
_ensure_parent_dir(env_path)
|
||||
existing = _read_env_file_keys(env_path)
|
||||
missing = [k for k in keys if k not in existing]
|
||||
if not missing:
|
||||
return []
|
||||
|
||||
header = ""
|
||||
if env_path.exists():
|
||||
header = "\n# Added by `planoai init`\n"
|
||||
|
||||
addition = header + "\n".join([f"{k}=" for k in missing]) + "\n"
|
||||
with env_path.open("a", encoding="utf-8") as f:
|
||||
f.write(addition)
|
||||
return missing
|
||||
|
||||
|
||||
def _questionary_style():
|
||||
# prompt_toolkit style string format
|
||||
from prompt_toolkit.styles import Style
|
||||
|
||||
return Style.from_dict(
|
||||
{
|
||||
"qmark": f"fg:{PLANO_COLOR} bold",
|
||||
"question": "bold",
|
||||
"answer": f"fg:{PLANO_COLOR} bold",
|
||||
"pointer": f"fg:{PLANO_COLOR} bold",
|
||||
"highlighted": f"fg:{PLANO_COLOR} bold",
|
||||
"selected": f"fg:{PLANO_COLOR}",
|
||||
"instruction": "fg:#888888",
|
||||
"text": "",
|
||||
"disabled": "fg:#666666",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _force_truecolor_for_prompt_toolkit() -> None:
|
||||
"""
|
||||
Ensure prompt_toolkit uses truecolor so our brand hex (#969FF4) renders correctly.
|
||||
Without this, some terminals or environments downgrade to 8-bit and the color
|
||||
can look like a generic blue.
|
||||
"""
|
||||
# Only set if user hasn't explicitly chosen a depth.
|
||||
os.environ.setdefault("PROMPT_TOOLKIT_COLOR_DEPTH", "DEPTH_24_BIT")
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--template",
|
||||
"template_id_or_path",
|
||||
default=None,
|
||||
help="Create config.yaml from a template id (e.g. use_cases/claude_code_router) or a path to a YAML file.",
|
||||
)
|
||||
@click.option(
|
||||
"--clean",
|
||||
is_flag=True,
|
||||
help="Create an empty config.yaml with no contents.",
|
||||
)
|
||||
@click.option(
|
||||
"--output",
|
||||
"-o",
|
||||
"output_path",
|
||||
default="config.yaml",
|
||||
show_default=True,
|
||||
help="Where to write the generated config.",
|
||||
)
|
||||
@click.option(
|
||||
"--force",
|
||||
is_flag=True,
|
||||
help="Overwrite existing config file if it already exists.",
|
||||
)
|
||||
@click.option(
|
||||
"--no-env",
|
||||
is_flag=True,
|
||||
help="Do not create/update a .env file.",
|
||||
)
|
||||
@click.option(
|
||||
"--yes",
|
||||
"-y",
|
||||
is_flag=True,
|
||||
help="Skip interactive prompts and accept defaults (will NOT overwrite without --force).",
|
||||
)
|
||||
@click.option(
|
||||
"--list-templates",
|
||||
is_flag=True,
|
||||
help="List available template ids and exit.",
|
||||
)
|
||||
@click.pass_context
|
||||
def init(
|
||||
ctx, template_id_or_path, clean, output_path, force, no_env, yes, list_templates
|
||||
):
|
||||
"""Initialize a Plano config quickly (arrow-key interactive wizard by default)."""
|
||||
import sys
|
||||
|
||||
console = Console()
|
||||
|
||||
if clean and template_id_or_path:
|
||||
raise click.UsageError("Use either --clean or --template, not both.")
|
||||
|
||||
templates = _get_templates()
|
||||
|
||||
if list_templates:
|
||||
console.print(f"[bold {PLANO_COLOR}]Available templates[/bold {PLANO_COLOR}]\n")
|
||||
for t in templates:
|
||||
origin = (
|
||||
"repo" if t.repo_path else "builtin" if t.yaml_text else "repo-only"
|
||||
)
|
||||
console.print(
|
||||
f" [bold]{t.id}[/bold] [dim]({origin})[/dim] - {t.description}"
|
||||
)
|
||||
return
|
||||
|
||||
out_path = Path(output_path).expanduser()
|
||||
|
||||
# Non-interactive fast paths
|
||||
if yes or clean or template_id_or_path:
|
||||
if clean:
|
||||
try:
|
||||
_write_clean_config(out_path, force=force)
|
||||
except FileExistsError:
|
||||
raise click.ClickException(
|
||||
f"Refusing to overwrite existing file: {out_path} (use --force)"
|
||||
)
|
||||
console.print(f"[green]✓[/green] Wrote [bold]{out_path}[/bold]")
|
||||
return
|
||||
|
||||
if template_id_or_path:
|
||||
template = _resolve_template(template_id_or_path)
|
||||
if not template:
|
||||
raise click.ClickException(
|
||||
f"Unknown template: {template_id_or_path}\n"
|
||||
f"Run: planoai init --list-templates"
|
||||
)
|
||||
try:
|
||||
origin = _write_template_config(out_path, template, force=force)
|
||||
except FileExistsError:
|
||||
raise click.ClickException(
|
||||
f"Refusing to overwrite existing file: {out_path} (use --force)"
|
||||
)
|
||||
console.print(
|
||||
f"[green]✓[/green] Wrote [bold]{out_path}[/bold] [dim]({template.id}, {origin})[/dim]"
|
||||
)
|
||||
|
||||
if no_env:
|
||||
return
|
||||
|
||||
env_vars = _extract_env_vars(out_path)
|
||||
if env_vars:
|
||||
env_path = out_path.parent / ".env"
|
||||
added = _upsert_env_placeholders(env_path, env_vars)
|
||||
if added:
|
||||
console.print(
|
||||
f"[green]✓[/green] Updated [bold]{env_path}[/bold] [dim](added: {', '.join(added)})[/dim]"
|
||||
)
|
||||
else:
|
||||
console.print(f"[dim]✓ .env already contains required keys[/dim]")
|
||||
return
|
||||
|
||||
# yes without clean/template means: do nothing useful
|
||||
raise click.UsageError(
|
||||
"Non-interactive mode requires --template or --clean (or omit --yes for the interactive wizard)."
|
||||
)
|
||||
|
||||
# Interactive wizard
|
||||
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
||||
raise click.ClickException(
|
||||
"Interactive mode requires a TTY.\n"
|
||||
"Use one of:\n"
|
||||
" planoai init --template <id>\n"
|
||||
" planoai init --clean\n"
|
||||
" planoai init --list-templates"
|
||||
)
|
||||
|
||||
_force_truecolor_for_prompt_toolkit()
|
||||
|
||||
# Lazy import so non-interactive users don't pay the import/compat cost
|
||||
import questionary
|
||||
from questionary import Choice
|
||||
|
||||
# Step 1: mode
|
||||
mode = questionary.select(
|
||||
"Welcome to Plano! Pick a starting point:",
|
||||
choices=[
|
||||
Choice("Start from a demo template (recommended)", value="template"),
|
||||
Choice("Create a clean config.yaml (empty)", value="clean"),
|
||||
Choice("Cancel", value="cancel"),
|
||||
],
|
||||
style=_questionary_style(),
|
||||
pointer="❯",
|
||||
).ask()
|
||||
|
||||
if mode in (None, "cancel"):
|
||||
console.print("[dim]Cancelled.[/dim]")
|
||||
return
|
||||
|
||||
# Step 2: output path (default: config.yaml)
|
||||
out_answer = questionary.text(
|
||||
"Where should I write the config?",
|
||||
default=str(out_path),
|
||||
style=_questionary_style(),
|
||||
).ask()
|
||||
if not out_answer:
|
||||
console.print("[dim]Cancelled.[/dim]")
|
||||
return
|
||||
out_path = Path(out_answer).expanduser()
|
||||
|
||||
if out_path.exists() and not force:
|
||||
overwrite = questionary.confirm(
|
||||
f"{out_path} already exists. Overwrite?",
|
||||
default=False,
|
||||
style=_questionary_style(),
|
||||
).ask()
|
||||
if not overwrite:
|
||||
console.print("[dim]Cancelled.[/dim]")
|
||||
return
|
||||
force = True
|
||||
|
||||
if mode == "clean":
|
||||
_write_clean_config(out_path, force=True)
|
||||
console.print(f"[green]✓[/green] Wrote [bold]{out_path}[/bold]")
|
||||
return
|
||||
|
||||
# Step 3: choose template (curated at top, plus any repo-only demos)
|
||||
# Keep the list compact and readable.
|
||||
template_choices: list[Choice] = []
|
||||
for t in templates:
|
||||
label = f"{t.title} — {t.description}"
|
||||
template_choices.append(Choice(label, value=t))
|
||||
|
||||
template = questionary.select(
|
||||
"Choose a template",
|
||||
choices=template_choices,
|
||||
style=_questionary_style(),
|
||||
pointer="❯",
|
||||
use_indicator=True,
|
||||
).ask()
|
||||
if not template:
|
||||
console.print("[dim]Cancelled.[/dim]")
|
||||
return
|
||||
|
||||
origin = _write_template_config(out_path, template, force=True)
|
||||
console.print(
|
||||
f"[green]✓[/green] Wrote [bold]{out_path}[/bold] [dim]({template.id}, {origin})[/dim]"
|
||||
)
|
||||
|
||||
# Step 4: .env placeholders (recommended, fast)
|
||||
if not no_env:
|
||||
env_vars = _extract_env_vars(out_path)
|
||||
if env_vars:
|
||||
env_path = out_path.parent / ".env"
|
||||
do_env = questionary.confirm(
|
||||
"Create/update a .env file with placeholders for required keys?",
|
||||
default=True,
|
||||
style=_questionary_style(),
|
||||
).ask()
|
||||
if do_env:
|
||||
added = _upsert_env_placeholders(env_path, env_vars)
|
||||
if added:
|
||||
console.print(
|
||||
f"[green]✓[/green] Updated [bold]{env_path}[/bold] [dim](added: {', '.join(added)})[/dim]"
|
||||
)
|
||||
else:
|
||||
console.print(f"[dim]✓ .env already contains required keys[/dim]")
|
||||
|
||||
# Step 5: next step shortcuts (validate/up/done)
|
||||
next_step = questionary.select(
|
||||
"Next step",
|
||||
choices=[
|
||||
Choice(f"Run: planoai validate {out_path}", value="validate"),
|
||||
Choice(f"Run: planoai up {out_path}", value="up"),
|
||||
Choice("Done", value="done"),
|
||||
],
|
||||
default="validate",
|
||||
style=_questionary_style(),
|
||||
pointer="❯",
|
||||
).ask()
|
||||
|
||||
if next_step == "validate":
|
||||
# Reuse existing click command implementation
|
||||
from planoai.main import validate as validate_cmd
|
||||
|
||||
ctx.invoke(validate_cmd, config_file=str(out_path), path=".", quiet=False)
|
||||
elif next_step == "up":
|
||||
from planoai.main import up as up_cmd
|
||||
|
||||
ctx.invoke(up_cmd, file=str(out_path), path=".", foreground=False)
|
||||
|
|
@ -93,6 +93,7 @@ from planoai.core import (
|
|||
stop_docker_container,
|
||||
start_cli_agent,
|
||||
)
|
||||
from planoai.init_cmd import init as init_cmd
|
||||
from planoai.consts import (
|
||||
PLANO_DOCKER_IMAGE,
|
||||
PLANO_DOCKER_NAME,
|
||||
|
|
@ -879,6 +880,7 @@ main.add_command(logs)
|
|||
main.add_command(cli_agent)
|
||||
main.add_command(generate_prompt_targets)
|
||||
main.add_command(validate)
|
||||
main.add_command(init_cmd, name="init")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ dependencies = [
|
|||
"click>=8.1.7,<9.0.0",
|
||||
"jinja2>=3.1.4,<4.0.0",
|
||||
"jsonschema>=4.23.0,<5.0.0",
|
||||
"questionary>=2.1.1,<3.0.0",
|
||||
"pyyaml>=6.0.2,<7.0.0",
|
||||
"requests>=2.31.0,<3.0.0",
|
||||
"rich>=14.2.0",
|
||||
|
|
|
|||
59
cli/test/test_init.py
Normal file
59
cli/test/test_init.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
from click.testing import CliRunner
|
||||
|
||||
from planoai.init_cmd import init
|
||||
|
||||
|
||||
def test_init_clean_writes_empty_config(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(init, ["--clean"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
config_path = tmp_path / "config.yaml"
|
||||
assert config_path.exists()
|
||||
assert config_path.read_text(encoding="utf-8") == "\n"
|
||||
|
||||
|
||||
def test_init_template_builtin_writes_config_and_env(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
init, ["--template", "use_cases/claude_code_router", "--yes"]
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
config_path = tmp_path / "config.yaml"
|
||||
assert config_path.exists()
|
||||
config_text = config_path.read_text(encoding="utf-8")
|
||||
assert "llm_providers:" in config_text
|
||||
|
||||
env_path = tmp_path / ".env"
|
||||
assert env_path.exists()
|
||||
env_text = env_path.read_text(encoding="utf-8")
|
||||
assert "OPENAI_API_KEY=" in env_text
|
||||
assert "ANTHROPIC_API_KEY=" in env_text
|
||||
|
||||
|
||||
def test_init_refuses_overwrite_without_force(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "config.yaml").write_text("hello", encoding="utf-8")
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(init, ["--clean"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "Refusing to overwrite" in result.output
|
||||
|
||||
|
||||
def test_init_force_overwrites(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "config.yaml").write_text("hello", encoding="utf-8")
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(init, ["--clean", "--force"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert (tmp_path / "config.yaml").read_text(encoding="utf-8") == "\n"
|
||||
35
cli/uv.lock
generated
35
cli/uv.lock
generated
|
|
@ -271,6 +271,7 @@ dependencies = [
|
|||
{ name = "jinja2" },
|
||||
{ name = "jsonschema" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "questionary" },
|
||||
{ name = "requests" },
|
||||
{ name = "rich" },
|
||||
{ name = "rich-click" },
|
||||
|
|
@ -293,6 +294,7 @@ requires-dist = [
|
|||
{ name = "jsonschema", specifier = ">=4.23.0,<5.0.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.4.1,<9.0.0" },
|
||||
{ name = "pyyaml", specifier = ">=6.0.2,<7.0.0" },
|
||||
{ name = "questionary", specifier = ">=2.1.1,<3.0.0" },
|
||||
{ name = "requests", specifier = ">=2.31.0,<3.0.0" },
|
||||
{ name = "rich", specifier = ">=14.2.0" },
|
||||
{ name = "rich-click", specifier = ">=1.9.5" },
|
||||
|
|
@ -311,6 +313,18 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prompt-toolkit"
|
||||
version = "3.0.52"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "wcwidth" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
|
|
@ -382,6 +396,18 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "questionary"
|
||||
version = "2.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "prompt-toolkit" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "referencing"
|
||||
version = "0.36.2"
|
||||
|
|
@ -630,3 +656,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599
|
|||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wcwidth"
|
||||
version = "0.2.14"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293, upload-time = "2025-09-22T16:29:53.023Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" },
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue