From 60e63096055839a7104aa8446bebe540999e5fe4 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Tue, 23 Jun 2026 18:42:17 -0400 Subject: [PATCH 01/14] chore(cli): tidy CLI docstrings and drop "agent-ready" jargon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `cli/__init__.py`: list the actual command surface — `init` (entry point), `create`, and `eval` - Replace the "agent-ready" terminology across the CLI with plain language ("initialize a project" / "set up for coding agents"), matching the docs. Rename the internal `_print_agent_ready` helper to `_print_ready` and change its message to "Your project is ready." --- src/pipecat/cli/__init__.py | 6 ++++-- src/pipecat/cli/commands/create.py | 4 ++-- src/pipecat/cli/commands/init.py | 31 +++++++++++++++--------------- src/pipecat/cli/main.py | 4 ++-- tests/cli/test_init_agent_ready.py | 10 +++++----- 5 files changed, 28 insertions(+), 27 deletions(-) diff --git a/src/pipecat/cli/__init__.py b/src/pipecat/cli/__init__.py index e1206106a50..44d113fdfee 100644 --- a/src/pipecat/cli/__init__.py +++ b/src/pipecat/cli/__init__.py @@ -7,8 +7,10 @@ """Pipecat CLI - command-line tools for building Pipecat AI voice agents. This package provides command-line tools for: -- Scaffolding new Pipecat projects with `pipecat create` -- Making a project agent-ready (AGENTS.md + CLAUDE.md) with `pipecat init` +- Initializing a new Pipecat project with `pipecat init` (the entry point: writes the + coding-agent guide, then helps you build with a coding agent or scaffold a bot) +- Scaffolding a project with `pipecat create` +- Running behavioral evals against a bot with `pipecat eval` And allows installing extensions like Pipecat Cloud: - Deploying to Pipecat Cloud with `pipecat cloud` diff --git a/src/pipecat/cli/commands/create.py b/src/pipecat/cli/commands/create.py index a9293a88b67..b4a58d4235a 100644 --- a/src/pipecat/cli/commands/create.py +++ b/src/pipecat/cli/commands/create.py @@ -169,7 +169,7 @@ def create_command( # command and routing the `quickstart` token preserves `pc create quickstart [-o ...]`. # # NOTE: this is the `pipecat create` scaffolder (formerly `pipecat init`). `pipecat init` - # is now a separate command that makes a project agent-ready (see commands/init.py). + # is now a separate command that initializes a new Pipecat project (see commands/init.py). if target == "quickstart": return quickstart_command(output_dir=output_dir) @@ -321,7 +321,7 @@ def scaffold_quickstart( Sets up a project with SmallWebRTC, Daily, Deepgram STT, OpenAI LLM, and Cartesia TTS — the fastest way to get a voice agent running. Shared by ``pipecat create quickstart`` (creates a ``pipecat-quickstart`` subfolder) and ``pipecat init - quickstart`` (scaffolds in-place into an already agent-ready directory, via + quickstart`` (scaffolds in-place into an already-initialized directory, via ``dest`` / ``in_place``). """ project_name = "pipecat-quickstart" diff --git a/src/pipecat/cli/commands/init.py b/src/pipecat/cli/commands/init.py index 7c5a517b73a..5dcaf3a7cc5 100644 --- a/src/pipecat/cli/commands/init.py +++ b/src/pipecat/cli/commands/init.py @@ -6,7 +6,7 @@ """``pipecat init`` — the single starting point for building a Pipecat app. -``pipecat init`` makes a project agent-ready, then routes you to a build method: +``pipecat init`` initializes a new Pipecat project, then routes you to a build method: - ``AGENTS.md`` — the coding-agent guide (read natively by most coding agents). - ``CLAUDE.md`` — a one-line ``@AGENTS.md`` import so Claude Code loads it too. @@ -21,7 +21,7 @@ hand off to a coding agent (which later scaffolds with ``pipecat create``), or scaffold a runnable bot right now (it runs the ``pipecat create`` wizard in-place in the same directory). ``pipecat init quickstart`` skips the question and scaffolds the canned -quickstart bot in-place — agent-ready *and* runnable in one step. +quickstart bot in-place — the coding-agent guide *and* a runnable bot in one step. ``pipecat create`` remains the scaffolder itself: coding agents and automation call it directly (non-interactively); ``init`` is the human entry point that wraps it. @@ -71,7 +71,7 @@ def _guide_footer() -> str: def _write_agent_guide(target_dir: Path, force: bool) -> None: """Write the core agent guide — AGENTS.md and CLAUDE.md — into a directory. - These make the project agent-ready and are wanted on *every* path (coding agent, + These set up the project for coding agents and are wanted on *every* path (coding agent, scaffold-now, quickstart), so they're written upfront. ``AGENTS.md`` is pipecat-owned and always (re)written, so re-running refreshes it after a Pipecat upgrade; ``CLAUDE.md`` is the developer's own entry point and is only overwritten with ``force``. @@ -142,7 +142,7 @@ def _is_interactive() -> bool: return sys.stdin.isatty() and sys.stdout.isatty() -def _print_agent_ready(target_dir: Path) -> None: +def _print_ready(target_dir: Path) -> None: """Print the guidance shown when the developer keeps the coding-agent path. Names the project directory so the developer knows where to open their session, @@ -152,7 +152,7 @@ def _print_agent_ready(target_dir: Path) -> None: """ where = "here" if target_dir.resolve() == Path.cwd() else f"in [bold]{target_dir}[/bold]" console.print( - f"\n[bold]Project is agent-ready.[/bold]\n\n" + f"\n[bold]Your project is ready.[/bold]\n\n" f"Read [bold]{_GETTING_STARTED_FILE}[/bold] for how to prompt your agent, then open a " f"coding session {where} to start building." ) @@ -170,7 +170,7 @@ def _route_build_method(target_dir: Path) -> None: already_scaffolded = (target_dir / "server").exists() if already_scaffolded or not _is_interactive(): _write_developer_guide(target_dir) - _print_agent_ready(target_dir) + _print_ready(target_dir) return import questionary @@ -189,7 +189,7 @@ def _route_build_method(target_dir: Path) -> None: # `ask()` returns None on Ctrl-C / EOF — fall through to the safe agent path. if choice != "scaffold": _write_developer_guide(target_dir) - _print_agent_ready(target_dir) + _print_ready(target_dir) return # Scaffold now: run the `create` wizard in-place. The scaffold lands in the same @@ -213,11 +213,11 @@ def _route_build_method(target_dir: Path) -> None: def _init_quickstart(force: bool) -> None: - """``pipecat init quickstart``: the canned quickstart, made agent-ready. + """``pipecat init quickstart``: the canned quickstart, with the coding-agent guide. Writes the agent guide into ``pipecat-quickstart/`` and scaffolds the quickstart - bot in-place there, so the learner's first project is both runnable and - agent-ready. The human counterpart to ``pipecat create quickstart`` (which omits + bot in-place there, so the learner's first project is both runnable and set up for + coding agents. The human counterpart to ``pipecat create quickstart`` (which omits the guide). Non-interactive — it's a fixed preset, so there's no build-method question. @@ -245,8 +245,7 @@ def init_command( ctx: typer.Context, target: str | None = typer.Argument( None, - help="Directory to make agent-ready (or 'quickstart' for the canned bot). " - "Created if missing.", + help="Directory to initialize (or 'quickstart' for the canned bot). Created if missing.", ), force: bool = typer.Option( False, @@ -254,7 +253,7 @@ def init_command( help=f"Also overwrite an existing {_CLAUDE_FILE} ({_AGENTS_FILE} is always refreshed).", ), ): - """Make a project agent-ready, then choose how to build. + """Initialize a new Pipecat project, then choose how to build. Writes AGENTS.md + CLAUDE.md + GETTING_STARTED.md, then (interactively) hands you off to a coding agent or scaffolds a runnable bot in-place with ``pipecat create``. @@ -263,11 +262,11 @@ def init_command( pipecat init # prompt for a directory, then choose how to build pipecat init my-bot # set up ./my-bot - pipecat init quickstart # agent-ready canned quickstart bot in ./pipecat-quickstart + pipecat init quickstart # canned quickstart bot in ./pipecat-quickstart pipecat init my-bot --force # overwrite existing files in ./my-bot pipecat init . # set up the current directory """ - # `pipecat init quickstart`: scaffold the canned bot in-place, made agent-ready. + # `pipecat init quickstart`: scaffold the canned bot in-place, with the coding-agent guide. if target == "quickstart": return _init_quickstart(force) @@ -278,7 +277,7 @@ def init_command( unexpected = " ".join(ctx.args) console.print( f"[red]Unexpected arguments:[/red] {unexpected}\n\n" - "`pipecat init` makes a project agent-ready (writes AGENTS.md, CLAUDE.md, " + "`pipecat init` initializes a new Pipecat project (writes AGENTS.md, CLAUDE.md, " "and GETTING_STARTED.md); it takes only an optional target directory and `--force`.\n" "To scaffold non-interactively, use [bold]`pipecat create`[/bold] — run " "`pipecat create --help`." diff --git a/src/pipecat/cli/main.py b/src/pipecat/cli/main.py index e05b5e6975f..5723c3ccd3f 100644 --- a/src/pipecat/cli/main.py +++ b/src/pipecat/cli/main.py @@ -75,12 +75,12 @@ def _build_app(): # positional target path followed by options (e.g. `pc create . --bot-type web`). app.command("create", help="Create a new Pipecat project")(create_command) - # `init` makes a project agent-ready (writes AGENTS.md + CLAUDE.md). ignore_unknown_options + # `init` initializes a new Pipecat project (writes AGENTS.md + CLAUDE.md). ignore_unknown_options # lets it catch legacy scaffolder flags (now `pipecat create`) and redirect with a clear # message instead of an opaque "no such option" error. app.command( "init", - help="Make a project agent-ready (writes AGENTS.md + CLAUDE.md)", + help="Initialize a new Pipecat project (writes AGENTS.md + CLAUDE.md)", context_settings={"ignore_unknown_options": True, "allow_extra_args": True}, )(init_command) diff --git a/tests/cli/test_init_agent_ready.py b/tests/cli/test_init_agent_ready.py index 023a9a579e3..7dd787a32c5 100644 --- a/tests/cli/test_init_agent_ready.py +++ b/tests/cli/test_init_agent_ready.py @@ -4,7 +4,7 @@ # SPDX-License-Identifier: BSD 2-Clause License # -"""Tests for `pipecat init` — making a project agent-ready (AGENTS.md + CLAUDE.md).""" +"""Tests for `pipecat init` — initializing a project (AGENTS.md + CLAUDE.md).""" from pathlib import Path @@ -109,14 +109,14 @@ def test_legacy_scaffolder_flags_redirect(self, tmp_path): # Redirect must not write a half-initialized project. assert not (tmp_path / "AGENTS.md").exists() - def test_quickstart_scaffolds_and_makes_agent_ready(self, tmp_path, monkeypatch): + def test_quickstart_scaffolds_and_writes_guide(self, tmp_path, monkeypatch): # `init quickstart` is the human front door for the canned bot: it scaffolds the # quickstart in-place AND drops the agent guide, all in ./pipecat-quickstart. monkeypatch.chdir(tmp_path) result = runner.invoke(app, ["init", "quickstart"]) assert result.exit_code == 0, result.output project = tmp_path / "pipecat-quickstart" - # Agent-ready files... + # Coding-agent guide files... assert (project / "AGENTS.md").exists() assert (project / "CLAUDE.md").read_text(encoding="utf-8").strip() == "@AGENTS.md" # ...plus a runnable bot in the same directory. @@ -173,7 +173,7 @@ def test_scaffold_branch_runs_create_in_place(self, tmp_path, monkeypatch, _sele # Scaffolding now ends in a built bot, so the from-scratch developer guide is # skipped — its place is taken by the scaffold's README (see test_quickstart_*). assert not (tmp_path / "GETTING_STARTED.md").exists() - # ...but the core agent-ready files are still written. + # ...but the core coding-agent guide files are still written. assert (tmp_path / "AGENTS.md").exists() assert (tmp_path / "CLAUDE.md").exists() @@ -186,7 +186,7 @@ def test_agent_branch_does_not_scaffold(self, tmp_path, monkeypatch, _select): ) result = runner.invoke(app, ["init", str(tmp_path)]) assert result.exit_code == 0, result.output - assert "agent-ready" in result.output.lower() + assert "your project is ready" in result.output.lower() assert not (tmp_path / "server").exists() # The coding-agent path gets the developer guide. assert (tmp_path / "GETTING_STARTED.md").exists() From 154ca7059f289525a0b5720059b1f76ba3f9445b Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Tue, 23 Jun 2026 20:18:03 -0400 Subject: [PATCH 02/14] docs(cli): streamline GETTING_STARTED.md intro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trim the orientation paragraph to what the human reader needs — this file is yours; AGENTS.md/CLAUDE.md are the agent's guide, picked up automatically. Drop the breakdown of what AGENTS.md teaches the agent. --- src/pipecat/cli/agent_templates/GETTING_STARTED.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/pipecat/cli/agent_templates/GETTING_STARTED.md b/src/pipecat/cli/agent_templates/GETTING_STARTED.md index f05456ea12f..51ad3a643d1 100644 --- a/src/pipecat/cli/agent_templates/GETTING_STARTED.md +++ b/src/pipecat/cli/agent_templates/GETTING_STARTED.md @@ -1,10 +1,8 @@ # Getting Started — Building Pipecat Bots with a Coding Agent **This file is for you**: how to drive your coding agent well. The other -files here are for the **agent**: AGENTS.md teaches it how to build Pipecat -apps — scaffold with `pipecat create`, check APIs against live sources -instead of stale training data, verify its own work with headless evals — -and CLAUDE.md loads it into Claude Code. +files here (`AGENTS.md`, `CLAUDE.md`) are the agent's guide, instructing +it how to write, run, and test Pipecat code. ## First: set up the Pipecat Context Hub From 4a39354cd356d979f308856001c3db8cef64a05c Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Wed, 24 Jun 2026 15:39:36 -0400 Subject: [PATCH 03/14] feat(cli)!: fold scaffolding into init, remove create command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pipecat init` is now the single entry point for building a Pipecat app. It still writes the coding-agent guide (AGENTS.md + CLAUDE.md) and now also scaffolds a runnable bot — interactively, or non-interactively from flags or a config file: pipecat init . --bot-type web -t daily --mode cascade \ --stt deepgram_stt --llm openai_llm --tts cartesia_tts Scaffolding is directory-first and in-place (project name derived from the target dir); create's --output/-o and --name-subfolder layout are gone. A missing target with scaffold flags defaults to the current directory so an automated run never hangs on a prompt. The scaffolder internals move to pipecat/cli/scaffold.py. `pipecat create` is removed; a hidden stub points users to `pipecat init`. --- README.md | 4 +- changelog/4881.removed.md | 12 + src/pipecat/cli/__init__.py | 4 +- src/pipecat/cli/agent_templates/AGENTS.md | 15 +- src/pipecat/cli/commands/__init__.py | 4 +- src/pipecat/cli/commands/create.py | 383 ------------------ src/pipecat/cli/commands/eval.py | 2 +- src/pipecat/cli/commands/init.py | 240 +++++++++-- src/pipecat/cli/main.py | 35 +- src/pipecat/cli/scaffold.py | 251 ++++++++++++ tests/cli/test_config_validator.py | 4 +- tests/cli/test_create_interactive.py | 62 --- tests/cli/test_init_agent_ready.py | 66 ++- ...eate_in_place.py => test_init_in_place.py} | 67 ++- tests/cli/test_init_interactive.py | 71 ++++ ...ommand.py => test_init_non_interactive.py} | 5 +- tests/cli/test_list_options.py | 18 +- tests/cli/test_project_generation.py | 2 +- tests/cli/test_quickstart.py | 21 +- 19 files changed, 684 insertions(+), 582 deletions(-) create mode 100644 changelog/4881.removed.md delete mode 100644 src/pipecat/cli/commands/create.py create mode 100644 src/pipecat/cli/scaffold.py delete mode 100644 tests/cli/test_create_interactive.py rename tests/cli/{test_create_in_place.py => test_init_in_place.py} (53%) create mode 100644 tests/cli/test_init_interactive.py rename tests/cli/{test_create_command.py => test_init_non_interactive.py} (97%) diff --git a/README.md b/README.md index 79928415be9..70de62b4c32 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ **Pipecat** is an open-source Python framework for building real-time voice and multimodal conversational agents. Build a single voice agent or a full multi-agent system where specialists hand off, fan out in parallel, and coordinate over a shared bus, locally or distributed across processes and machines. Orchestrate audio and video, AI services, transports, and conversation pipelines effortlessly, so you can focus on what makes your agents unique. -> Want to dive right in? Run `pipecat create quickstart` or follow the [quickstart guide](https://docs.pipecat.ai/getting-started/quickstart). +> Want to dive right in? Run `pipecat init quickstart` or follow the [quickstart guide](https://docs.pipecat.ai/getting-started/quickstart). ## 🚀 What You Can Build @@ -47,7 +47,7 @@ Want to build beautiful and engaging experiences? Checkout the [Voice UI Kit](ht ### 🛠️ Create and deploy projects -The [Pipecat CLI](https://docs.pipecat.ai/api-reference/cli/overview) ships with `pipecat-ai` — install it with `uv tool install "pipecat-ai[cli]"`. Scaffold a project in under a minute with `pipecat create`, or run `pipecat init` to make a project agent-ready so an AI coding assistant (Claude Code, Codex) builds it for you. Then use the CLI to monitor and deploy your agent to production. +The [Pipecat CLI](https://docs.pipecat.ai/api-reference/cli/overview) ships with `pipecat-ai` — install it with `uv tool install "pipecat-ai[cli]"`. Run `pipecat init` to start a project: it sets you up so an AI coding assistant (Claude Code, Codex) builds it for you, and can scaffold a runnable bot in under a minute. Then use the CLI to monitor and deploy your agent to production. ### 🔍 Debugging diff --git a/changelog/4881.removed.md b/changelog/4881.removed.md new file mode 100644 index 00000000000..10c93b82a16 --- /dev/null +++ b/changelog/4881.removed.md @@ -0,0 +1,12 @@ +- Removed the `pipecat create` command. Its scaffolding has been folded into `pipecat + init`, which is now the single entry point for building a Pipecat app. `init` still + writes the coding-agent guide (`AGENTS.md` + `CLAUDE.md`) and now also scaffolds a + runnable bot — interactively, or non-interactively from flags or a config file: + + pipecat init . --bot-type web -t daily --mode cascade \ + --stt deepgram_stt --llm openai_llm --tts cartesia_tts + + Run `pipecat init --help` or `pipecat init --list-options` for the available options. + `pipecat init quickstart` replaces `pipecat create quickstart`. Scaffolding is + directory-first and in-place (the project name is derived from the target directory); + `pipecat create`'s `--output/-o` and `--name`-subfolder layout are gone. diff --git a/src/pipecat/cli/__init__.py b/src/pipecat/cli/__init__.py index 44d113fdfee..07624e57a61 100644 --- a/src/pipecat/cli/__init__.py +++ b/src/pipecat/cli/__init__.py @@ -8,8 +8,8 @@ This package provides command-line tools for: - Initializing a new Pipecat project with `pipecat init` (the entry point: writes the - coding-agent guide, then helps you build with a coding agent or scaffold a bot) -- Scaffolding a project with `pipecat create` + coding-agent guide, then helps you build with a coding agent or scaffolds a runnable + bot — interactively or non-interactively from flags or a config file) - Running behavioral evals against a bot with `pipecat eval` And allows installing extensions like Pipecat Cloud: diff --git a/src/pipecat/cli/agent_templates/AGENTS.md b/src/pipecat/cli/agent_templates/AGENTS.md index 84c4e5dfb57..1b150e2ee7e 100644 --- a/src/pipecat/cli/agent_templates/AGENTS.md +++ b/src/pipecat/cli/agent_templates/AGENTS.md @@ -25,23 +25,24 @@ Always begin from the deterministic CLI scaffold. It gives you a known-good stru uv tool install "pipecat-ai[cli]" # provides the `pipecat` (alias `pc`) command ``` -> ⚠️ **Agents: scaffold non-interactively** — bare `pipecat create` opens an interactive wizard that **hangs an automated run**. Pass every choice as flags (or `--config`): +> ⚠️ **Agents: scaffold non-interactively** — bare `pipecat init` (no scaffold flags) opens an interactive wizard that **hangs an automated run**. Pass your choices as flags (or `--config`): ```bash -# Headless: --name (or --config) switches to non-interactive mode. +# Headless: any scaffold flag (--bot-type, a service, or --config) switches off the wizard. +# Run it in the project directory you're already in; `.` scaffolds in place, name from the dir. # The service values below are EXAMPLES — map the user's actual choices, don't copy these. -pipecat create --name mybot \ +pipecat init . \ --transport smallwebrtc --mode cascade \ --stt deepgram_stt --llm openai_llm --tts cartesia_tts \ --eval # eval transport + starter scenarios, for verification (§6) # • Don't hand-write flags from memory — discover them: -# pipecat create --help # available flags -# pipecat create --list-options # valid service/transport VALUES +# pipecat init --help # available flags +# pipecat init --list-options # valid service/transport VALUES # • --dry-run prints the resolved config as JSON; --config project.json drives it from a file. # • --transport is repeatable — pass each transport you want (production + a local-dev one, §2). # • --bot-type is inferred from --transport (telephony if any telephony transport, else web) — omit it. -# Humans (interactive wizard): `pipecat create quickstart` (defaults) or `pipecat create`. +# Humans: `pipecat init quickstart` (canned defaults) or bare `pipecat init` (interactive wizard). ``` **Choose *with* the user, not for them.** Map their requirements to the real options and confirm transport / services / mode / deployment (§7) before scaffolding — don't silently pick or guess. Mode affects testing speed — **cascade (STT→LLM→TTS)** gets the fast text-mode eval loop (§6); **realtime (speech-to-speech)** is tested in audio mode — but both run headless, so pick the mode the use case needs. @@ -197,7 +198,7 @@ A voice app can't be eyeballed like a web page — but you don't need a live cal > **Deep reference:** the **Pipecat Evals docs** are the authoritative spec — look them up via your Pipecat MCP (§3): **Overview**, **Writing Scenarios** (the schema + the two modality axes), **Using the Library** (the Python API), **Agent Self-Improvement** (the closed-loop workflow this section describes). For working examples, copy the **scaffolded starters in `server/evals/`** rather than writing YAML from scratch. The eval harness ships in the `pipecat-ai[evals]` extra (the `pipecat eval` command plus the local Kokoro/Moonshine speech models); scaffolding with `--eval` adds it, so run evals from the **bot's own environment**. -**Make your bot eval-able.** Scaffold with `pipecat create --eval` (headless) — pass it whenever you scaffold a bot you intend to test. The generated bot has the `eval` transport entry, eval dependencies in its env, and **runnable starter scenarios in `server/evals/`**: `starter_text.yaml` (the fast inner loop; cascade only) and `starter_audio.yaml` (the full round trip). They pass against the freshly scaffolded bot, so run them *first* to prove the loop, then edit them to match the bot you're building and copy them to grow the suite. For an **existing** bot, add the transport entry by hand (a one-time change; RTVI is already on by default for `PipelineWorker`, so that's the only edit): +**Make your bot eval-able.** Scaffold with `pipecat init . --eval` (headless) — pass it whenever you scaffold a bot you intend to test. The generated bot has the `eval` transport entry, eval dependencies in its env, and **runnable starter scenarios in `server/evals/`**: `starter_text.yaml` (the fast inner loop; cascade only) and `starter_audio.yaml` (the full round trip). They pass against the freshly scaffolded bot, so run them *first* to prove the loop, then edit them to match the bot you're building and copy them to grow the suite. For an **existing** bot, add the transport entry by hand (a one-time change; RTVI is already on by default for `PipelineWorker`, so that's the only edit): ```python from pipecat.evals.transport import EvalTransportParams diff --git a/src/pipecat/cli/commands/__init__.py b/src/pipecat/cli/commands/__init__.py index 9c515ea5dd9..b48678be58a 100644 --- a/src/pipecat/cli/commands/__init__.py +++ b/src/pipecat/cli/commands/__init__.py @@ -6,6 +6,6 @@ """CLI commands for Pipecat CLI.""" -from . import create, init +from . import init -__all__ = ["create", "init"] +__all__ = ["init"] diff --git a/src/pipecat/cli/commands/create.py b/src/pipecat/cli/commands/create.py deleted file mode 100644 index b4a58d4235a..00000000000 --- a/src/pipecat/cli/commands/create.py +++ /dev/null @@ -1,383 +0,0 @@ -# -# Copyright (c) 2025-2026, Daily -# -# SPDX-License-Identifier: BSD 2-Clause License -# - -"""Create command implementation for scaffolding new Pipecat projects.""" - -import json -from pathlib import Path - -import typer -from rich.console import Console - -from pipecat.cli.generators import ProjectGenerator -from pipecat.cli.prompts import ProjectConfig, ask_project_questions -from pipecat.cli.registry.service_metadata import ServiceRegistry - -console = Console() - - -def _list_options_callback(value: bool): - """Print available options as JSON and exit.""" - if not value: - return - - def values(defs): - return [s.value for s in defs] - - options = { - "bot_type": ["web", "telephony"], - "transports": { - "web": values(ServiceRegistry.WEBRTC_TRANSPORTS), - "telephony": values(ServiceRegistry.TELEPHONY_TRANSPORTS), - }, - "stt": values(ServiceRegistry.STT_SERVICES), - "llm": values(ServiceRegistry.LLM_SERVICES), - "tts": values(ServiceRegistry.TTS_SERVICES), - "realtime": values(ServiceRegistry.REALTIME_SERVICES), - "video": values(ServiceRegistry.VIDEO_SERVICES), - } - print(json.dumps(options, indent=2)) - raise typer.Exit(0) - - -def scaffold_interactive(dest: Path | None, derived_name: str | None, in_place: bool): - """Run the interactive wizard and generate a project. - - Shared by ``pipecat create`` (interactive mode) and ``pipecat init`` (when the - developer chooses to scaffold a runnable bot). ``dest`` is the resolved output - location: the exact target directory when ``in_place`` is True, otherwise the - parent the ```` subfolder is created under (``None`` = current directory). - """ - config_result = ask_project_questions(default_name=derived_name) - generator = ProjectGenerator(config_result) - project_path = generator.generate(dest, in_place=in_place) - generator.print_next_steps(project_path, in_place=in_place) - - -def create_command( - ctx: typer.Context, - target: str | None = typer.Argument( - None, - help="Target directory; use '.' to scaffold into the current directory " - "(no subfolder). Omit to create a subfolder.", - ), - output_dir: Path | None = typer.Option( - None, "--output", "-o", help="Output directory (defaults to current directory)" - ), - list_options: bool = typer.Option( - False, - "--list-options", - help="Print available service options as JSON and exit", - callback=_list_options_callback, - is_eager=True, - ), - # --- Non-interactive flags --- - name: str | None = typer.Option( - None, "--name", "-n", help="Project name (triggers non-interactive mode)" - ), - bot_type: str | None = typer.Option( - None, - "--bot-type", - "-b", - help="Bot type: 'web' or 'telephony' (inferred from --transport if omitted)", - ), - transport: list[str] | None = typer.Option( - None, "--transport", "-t", help="Transport (repeatable, e.g. -t daily -t smallwebrtc)" - ), - mode: str | None = typer.Option( - None, "--mode", "-m", help="Pipeline mode: 'cascade' or 'realtime'" - ), - stt: str | None = typer.Option(None, "--stt", help="STT service (cascade mode)"), - llm: str | None = typer.Option(None, "--llm", help="LLM service (cascade mode)"), - tts: str | None = typer.Option(None, "--tts", help="TTS service (cascade mode)"), - realtime: str | None = typer.Option( - None, "--realtime", help="Realtime service (realtime mode)" - ), - video: str | None = typer.Option(None, "--video", help="Video avatar service"), - client_framework: str | None = typer.Option( - None, "--client-framework", help="Client framework: 'react', 'vanilla', or 'none'" - ), - client_server: str | None = typer.Option( - None, "--client-server", help="Client dev server: 'vite' or 'nextjs'" - ), - daily_pstn_mode: str | None = typer.Option( - None, "--daily-pstn-mode", help="Daily PSTN mode: 'dial-in' or 'dial-out'" - ), - twilio_daily_sip_mode: str | None = typer.Option( - None, "--twilio-daily-sip-mode", help="Twilio+Daily SIP mode: 'dial-in' or 'dial-out'" - ), - recording: bool = typer.Option(False, "--recording/--no-recording", help="Enable recording"), - transcription: bool = typer.Option( - False, "--transcription/--no-transcription", help="Enable transcription" - ), - video_input: bool = typer.Option( - False, "--video-input/--no-video-input", help="Enable video input" - ), - video_output: bool = typer.Option( - False, "--video-output/--no-video-output", help="Enable video output" - ), - deploy_to_cloud: bool = typer.Option( - True, "--deploy-to-cloud/--no-deploy-to-cloud", help="Generate cloud deployment files" - ), - enable_krisp: bool = typer.Option( - False, "--enable-krisp/--no-enable-krisp", help="Enable Krisp noise cancellation" - ), - observability: bool = typer.Option( - False, "--observability/--no-observability", help="Enable observability" - ), - enable_eval: bool = typer.Option( - False, - "--eval/--no-eval", - help="Add an 'eval' transport so the bot is runnable with `-t eval` for " - "behavioral evals (see `pipecat eval`). Off by default.", - ), - config: Path | None = typer.Option( - None, "--config", "-c", help="JSON config file (triggers non-interactive mode)" - ), - dry_run: bool = typer.Option( - False, "--dry-run", help="Print resolved config as JSON without generating files" - ), -): - r"""Create a new Pipecat project. - - Creates a complete project structure with bot.py, dependencies, and configuration files. - - In interactive mode (default), uses a wizard to guide you through setup. - In non-interactive mode (when --name or --config is provided), all configuration - is taken from flags or a config file. - - Examples:: - - pc create # Interactive wizard - pc create . # Scaffold into the current dir - pc create --name my-bot --bot-type web \ - --transport daily --mode cascade \ - --stt deepgram_stt --llm openai_llm \ - --tts cartesia_tts # Non-interactive - pc create . --bot-type web -t daily -m cascade \ - --stt deepgram_stt --llm openai_llm \ - --tts cartesia_tts # In-place, name from dir - pc create --config project-config.json # From config file - pc create --name my-bot ... --dry-run # Preview config as JSON - """ - # `quickstart` is dispatched here rather than as a subcommand: a positional arg on a - # Typer *group* can't be followed by options (Click stops parsing at the first - # positional), which would break `pc create . --bot-type ...`. Keeping `create` a plain - # command and routing the `quickstart` token preserves `pc create quickstart [-o ...]`. - # - # NOTE: this is the `pipecat create` scaffolder (formerly `pipecat init`). `pipecat init` - # is now a separate command that initializes a new Pipecat project (see commands/init.py). - if target == "quickstart": - return quickstart_command(output_dir=output_dir) - - try: - # Resolve the in-place target. A positional path is the exact destination - # (vite/django style); -o keeps its legacy "parent dir + name subfolder" meaning, - # so passing both is ambiguous. - in_place = target is not None - dest: Path | None = None - derived_name: str | None = None - if in_place: - if output_dir is not None: - console.print( - "\n[red]Error: pass either a target directory or --output, not both.[/red]" - ) - raise typer.Exit(1) - dest = Path(target).resolve() - derived_name = dest.name or "pipecat-app" - - # In-place runs go non-interactive as soon as any non-interactive intent is - # present (the name can be derived from the directory). Scoped to in_place so - # no existing (no-positional) invocation changes behavior. - non_interactive = name is not None or config is not None - if in_place and (name is not None or bot_type is not None or config is not None): - non_interactive = True - - if non_interactive: - from pipecat.cli.config_validator import ( - ConfigValidationError, - config_to_json, - load_config_from_file, - validate_and_build_config, - ) - - # Load from config file if provided - if config is not None: - file_data = load_config_from_file(config) - - # Merge file values with CLI flags. An explicit CLI flag always wins; - # the file value only applies when the flag was omitted. - def from_cli(param): - """True only if the user typed this flag (vs. falling back to its default). - - Compares the parameter source by enum member name rather than identity: - Typer vendors its own copy of Click, so the ``ParameterSource`` returned - here is a different enum object than ``click.core.ParameterSource``. - """ - source = ctx.get_parameter_source(param) - return source is not None and source.name == "COMMANDLINE" - - def pick(value, param, *file_keys): - """Explicit CLI flag wins; else first file key present; else the flag's default.""" - if from_cli(param): - return value - for key in file_keys: - if key in file_data: - return file_data[key] - return value - - name = pick(name, "name", "name", "project_name") - bot_type = pick(bot_type, "bot_type", "bot_type") - transport = pick(transport, "transport", "transports", "transport") - mode = pick(mode, "mode", "mode") - stt = pick(stt, "stt", "stt", "stt_service") - llm = pick(llm, "llm", "llm", "llm_service") - tts = pick(tts, "tts", "tts", "tts_service") - realtime = pick(realtime, "realtime", "realtime", "realtime_service") - video = pick(video, "video", "video", "video_service") - client_framework = pick(client_framework, "client_framework", "client_framework") - client_server = pick(client_server, "client_server", "client_server") - daily_pstn_mode = pick(daily_pstn_mode, "daily_pstn_mode", "daily_pstn_mode") - twilio_daily_sip_mode = pick( - twilio_daily_sip_mode, "twilio_daily_sip_mode", "twilio_daily_sip_mode" - ) - recording = pick(recording, "recording", "recording") - transcription = pick(transcription, "transcription", "transcription") - video_input = pick(video_input, "video_input", "video_input") - video_output = pick(video_output, "video_output", "video_output") - deploy_to_cloud = pick(deploy_to_cloud, "deploy_to_cloud", "deploy_to_cloud") - enable_krisp = pick(enable_krisp, "enable_krisp", "enable_krisp") - observability = pick( - observability, "observability", "observability", "enable_observability" - ) - enable_eval = pick(enable_eval, "enable_eval", "enable_eval", "eval") - - try: - project_config = validate_and_build_config( - name=name or derived_name, - bot_type=bot_type, - transport=transport, - mode=mode, - stt=stt, - llm=llm, - tts=tts, - realtime=realtime, - video=video, - client_framework=client_framework, - client_server=client_server, - daily_pstn_mode=daily_pstn_mode, - twilio_daily_sip_mode=twilio_daily_sip_mode, - recording=recording, - transcription=transcription, - video_input=video_input, - video_output=video_output, - deploy_to_cloud=deploy_to_cloud, - enable_krisp=enable_krisp, - observability=observability, - enable_eval=enable_eval, - ) - except ConfigValidationError as e: - console.print(f"\n[red]{e}[/red]") - raise typer.Exit(1) - - if dry_run: - print(config_to_json(project_config)) - raise typer.Exit(0) - - # Generate project - generator = ProjectGenerator(project_config) - project_path = generator.generate( - dest if in_place else output_dir, non_interactive=True, in_place=in_place - ) - - # Show next steps - generator.print_next_steps(project_path, in_place=in_place) - - else: - # Interactive mode: ask questions, then scaffold. - scaffold_interactive(dest if in_place else output_dir, derived_name, in_place) - - except KeyboardInterrupt: - console.print("\n[yellow]Project creation cancelled.[/yellow]") - raise typer.Exit(1) - except typer.Exit: - raise - except FileExistsError as e: - console.print(f"\n[red]Error: {e}[/red]") - raise typer.Exit(1) - except Exception as e: - console.print(f"\n[red]Error creating project: {e}[/red]") - raise typer.Exit(1) - - -def scaffold_quickstart( - output_dir: Path | None = None, *, dest: Path | None = None, in_place: bool = False -): - """Generate the canned quickstart project (no questions). - - Sets up a project with SmallWebRTC, Daily, Deepgram STT, OpenAI LLM, and Cartesia - TTS — the fastest way to get a voice agent running. Shared by ``pipecat create - quickstart`` (creates a ``pipecat-quickstart`` subfolder) and ``pipecat init - quickstart`` (scaffolds in-place into an already-initialized directory, via - ``dest`` / ``in_place``). - """ - project_name = "pipecat-quickstart" - - console.print("[bold cyan]Let's create your Pipecat project![/bold cyan]\n") - - # Display all pre-selected defaults - console.print(f"[green]✔[/green] Project name: [cyan]{project_name}[/cyan]") - console.print("[green]✔[/green] Bot type: [cyan]Web/Mobile[/cyan]") - console.print("[green]✔[/green] Transport: [cyan]SmallWebRTC, Daily[/cyan]") - console.print("[green]✔[/green] Pipeline architecture: [cyan]Cascade (STT → LLM → TTS)[/cyan]") - console.print("[green]✔[/green] Speech-to-Text: [cyan]Deepgram[/cyan]") - console.print("[green]✔[/green] Language model: [cyan]OpenAI[/cyan]") - console.print("[green]✔[/green] Text-to-Speech: [cyan]Cartesia[/cyan]") - console.print("[green]✔[/green] Deploy to Pipecat Cloud: [cyan]Yes[/cyan]") - - # Build config with quickstart defaults - project_config = ProjectConfig( - project_name=project_name, - bot_type="web", - transports=["smallwebrtc", "daily"], - mode="cascade", - stt_service="deepgram_stt", - llm_service="openai_responses_llm", - tts_service="cartesia_tts", - deploy_to_cloud=True, - ) - - # Generate project - generator = ProjectGenerator(project_config) - project_path = generator.generate( - dest if in_place else output_dir, non_interactive=True, in_place=in_place - ) - - # Show next steps - generator.print_next_steps(project_path, in_place=in_place) - - -def quickstart_command(output_dir: Path | None = None): - """Create a new Pipecat project with quickstart defaults. - - Dispatched from ``create_command`` when the target is ``quickstart`` (e.g. - ``pc create quickstart [-o DIR]``). Wraps :func:`scaffold_quickstart` with the - standard create error handling. - """ - try: - scaffold_quickstart(output_dir=output_dir) - - except KeyboardInterrupt: - console.print("\n[yellow]Project creation cancelled.[/yellow]") - raise typer.Exit(1) - except typer.Exit: - raise - except FileExistsError as e: - console.print(f"\n[red]Error: {e}[/red]") - raise typer.Exit(1) - except Exception as e: - console.print(f"\n[red]Error creating project: {e}[/red]") - raise typer.Exit(1) diff --git a/src/pipecat/cli/commands/eval.py b/src/pipecat/cli/commands/eval.py index f93a02917ec..cdabc16d16e 100644 --- a/src/pipecat/cli/commands/eval.py +++ b/src/pipecat/cli/commands/eval.py @@ -77,7 +77,7 @@ def _format_detail(p: EvalTurnProgress) -> str: eval_app = typer.Typer( name="eval", - help="Run behavioral evals against a Pipecat bot.", + help="Run behavioral evals against a Pipecat bot", no_args_is_help=True, ) diff --git a/src/pipecat/cli/commands/init.py b/src/pipecat/cli/commands/init.py index 5dcaf3a7cc5..ac688cfbad8 100644 --- a/src/pipecat/cli/commands/init.py +++ b/src/pipecat/cli/commands/init.py @@ -17,14 +17,17 @@ path: it's onboarding for commissioning a build, so it doesn't fit a project that's already been scaffolded (where the README is the start-here). -After writing AGENTS.md + CLAUDE.md, interactive ``init`` asks how you want to build: -hand off to a coding agent (which later scaffolds with ``pipecat create``), or scaffold -a runnable bot right now (it runs the ``pipecat create`` wizard in-place in the same -directory). ``pipecat init quickstart`` skips the question and scaffolds the canned -quickstart bot in-place — the coding-agent guide *and* a runnable bot in one step. +``init`` is also the scaffolder. After writing AGENTS.md + CLAUDE.md it either: -``pipecat create`` remains the scaffolder itself: coding agents and automation call -it directly (non-interactively); ``init`` is the human entry point that wraps it. +- builds the project from flags or a config file when any scaffold option is given + (``pipecat init . --bot-type web -t daily …``) — the non-interactive path coding + agents and automation use; +- or, interactively, asks how you want to build: hand off to a coding agent, or scaffold + a runnable bot right now (the wizard, in-place in the same directory). + +``pipecat init quickstart`` skips the question and scaffolds the canned quickstart bot +in-place — the coding-agent guide *and* a runnable bot in one step. The scaffolding +itself lives in :mod:`pipecat.cli.scaffold`. Editing policy for the bundled guide: keep API specifics (signatures, imports, parameter names) out of AGENTS.md — it is a static snapshot, so anything that @@ -38,6 +41,7 @@ from rich.console import Console import pipecat.cli +from pipecat.cli.scaffold import list_options_callback, run_non_interactive_scaffold console = Console() @@ -50,7 +54,7 @@ _CLAUDE_FILE = "CLAUDE.md" _GETTING_STARTED_FILE = "GETTING_STARTED.md" -# Fixed destination for `pipecat init quickstart`, matching `pipecat create quickstart`. +# Fixed destination for `pipecat init quickstart`. _QUICKSTART_DIR = "pipecat-quickstart" @@ -147,8 +151,8 @@ def _print_ready(target_dir: Path) -> None: Names the project directory so the developer knows where to open their session, except when init targeted the current directory (``pipecat init .``), where "here" - reads better than "in .". The agent runs ``pipecat create`` itself (per AGENTS.md), - so it's left out of this human-facing message. + reads better than "in .". The agent runs ``pipecat init`` itself to scaffold (per + AGENTS.md), so that's left out of this human-facing message. """ where = "here" if target_dir.resolve() == Path.cwd() else f"in [bold]{target_dir}[/bold]" console.print( @@ -192,10 +196,10 @@ def _route_build_method(target_dir: Path) -> None: _print_ready(target_dir) return - # Scaffold now: run the `create` wizard in-place. The scaffold lands in the same - # directory that already holds AGENTS.md/CLAUDE.md (in-place mode preserves CLAUDE.md), - # and its README carries the start-here guidance — so no GETTING_STARTED.md here. - from pipecat.cli.commands.create import scaffold_interactive + # Scaffold now: run the wizard in-place. The scaffold lands in the same directory that + # already holds AGENTS.md/CLAUDE.md (in-place mode preserves CLAUDE.md), and its README + # carries the start-here guidance — so no GETTING_STARTED.md here. + from pipecat.cli.scaffold import scaffold_interactive try: scaffold_interactive(target_dir, target_dir.name, in_place=True) @@ -217,14 +221,13 @@ def _init_quickstart(force: bool) -> None: Writes the agent guide into ``pipecat-quickstart/`` and scaffolds the quickstart bot in-place there, so the learner's first project is both runnable and set up for - coding agents. The human counterpart to ``pipecat create quickstart`` (which omits - the guide). Non-interactive — it's a fixed preset, so there's no build-method + coding agents. Non-interactive — it's a fixed preset, so there's no build-method question. Skips ``GETTING_STARTED.md`` (the from-scratch developer onboarding): the bot already exists, so the scaffold's README is the start-here. """ - from pipecat.cli.commands.create import scaffold_quickstart + from pipecat.cli.scaffold import scaffold_quickstart target_dir = Path(_QUICKSTART_DIR) # AGENTS.md + CLAUDE.md only — _write_agent_guide never writes GETTING_STARTED.md. @@ -241,6 +244,17 @@ def _init_quickstart(force: bool) -> None: raise typer.Exit(1) +# Scaffold options that, when present, switch `init` to non-interactive scaffolding. +# A truthy/non-None value on any of these (or `--config`) means "build the project now" +# rather than just writing the guide and prompting. +def _scaffold_requested(*, config, name, bot_type, transport, mode, stt, llm, tts, realtime, video): + return ( + config is not None + or bool(transport) + or any(v is not None for v in (name, bot_type, mode, stt, llm, tts, realtime, video)) + ) + + def init_command( ctx: typer.Context, target: str | None = typer.Argument( @@ -252,38 +266,149 @@ def init_command( "--force", help=f"Also overwrite an existing {_CLAUDE_FILE} ({_AGENTS_FILE} is always refreshed).", ), + # --- Scaffold options (presence switches to non-interactive scaffolding) --- + name: str | None = typer.Option( + None, "--name", "-n", help="Project name (defaults to the target directory name)" + ), + bot_type: str | None = typer.Option( + None, + "--bot-type", + "-b", + help="Bot type: 'web' or 'telephony' (inferred from --transport if omitted)", + ), + transport: list[str] | None = typer.Option( + None, "--transport", "-t", help="Transport (repeatable, e.g. -t daily -t smallwebrtc)" + ), + mode: str | None = typer.Option( + None, "--mode", "-m", help="Pipeline mode: 'cascade' or 'realtime'" + ), + stt: str | None = typer.Option(None, "--stt", help="STT service (cascade mode)"), + llm: str | None = typer.Option(None, "--llm", help="LLM service (cascade mode)"), + tts: str | None = typer.Option(None, "--tts", help="TTS service (cascade mode)"), + realtime: str | None = typer.Option( + None, "--realtime", help="Realtime service (realtime mode)" + ), + video: str | None = typer.Option(None, "--video", help="Video avatar service"), + client_framework: str | None = typer.Option( + None, "--client-framework", help="Client framework: 'react', 'vanilla', or 'none'" + ), + client_server: str | None = typer.Option( + None, "--client-server", help="Client dev server: 'vite' or 'nextjs'" + ), + daily_pstn_mode: str | None = typer.Option( + None, "--daily-pstn-mode", help="Daily PSTN mode: 'dial-in' or 'dial-out'" + ), + twilio_daily_sip_mode: str | None = typer.Option( + None, "--twilio-daily-sip-mode", help="Twilio+Daily SIP mode: 'dial-in' or 'dial-out'" + ), + recording: bool = typer.Option(False, "--recording/--no-recording", help="Enable recording"), + transcription: bool = typer.Option( + False, "--transcription/--no-transcription", help="Enable transcription" + ), + video_input: bool = typer.Option( + False, "--video-input/--no-video-input", help="Enable video input" + ), + video_output: bool = typer.Option( + False, "--video-output/--no-video-output", help="Enable video output" + ), + deploy_to_cloud: bool = typer.Option( + True, "--deploy-to-cloud/--no-deploy-to-cloud", help="Generate cloud deployment files" + ), + enable_krisp: bool = typer.Option( + False, "--enable-krisp/--no-enable-krisp", help="Enable Krisp noise cancellation" + ), + observability: bool = typer.Option( + False, "--observability/--no-observability", help="Enable observability" + ), + enable_eval: bool = typer.Option( + False, + "--eval/--no-eval", + help="Add an 'eval' transport so the bot is runnable with `-t eval` for " + "behavioral evals (see `pipecat eval`). Off by default.", + ), + config: Path | None = typer.Option( + None, "--config", "-c", help="JSON config file (triggers non-interactive scaffolding)" + ), + dry_run: bool = typer.Option( + False, "--dry-run", help="Print resolved scaffold config as JSON without writing files" + ), + list_options: bool = typer.Option( + False, + "--list-options", + help="Print available service options as JSON and exit", + callback=list_options_callback, + is_eager=True, + ), ): - """Initialize a new Pipecat project, then choose how to build. + r"""Initialize a new Pipecat project — and optionally scaffold it. - Writes AGENTS.md + CLAUDE.md + GETTING_STARTED.md, then (interactively) hands you - off to a coding agent or scaffolds a runnable bot in-place with ``pipecat create``. + Writes the coding-agent guide (AGENTS.md + CLAUDE.md), then either scaffolds a runnable + bot or hands you off to a coding agent. Pass scaffold options (``--bot-type``, + ``--transport``, the service flags, or ``--config``) to build the project + non-interactively, in-place in the target directory; with no scaffold options, ``init`` + writes GETTING_STARTED.md and (interactively) asks how you want to build. Examples:: - pipecat init # prompt for a directory, then choose how to build - pipecat init my-bot # set up ./my-bot - pipecat init quickstart # canned quickstart bot in ./pipecat-quickstart - pipecat init my-bot --force # overwrite existing files in ./my-bot - pipecat init . # set up the current directory + pipecat init # prompt for a directory, then choose how to build + pipecat init my-bot # set up ./my-bot + pipecat init quickstart # canned quickstart bot in ./pipecat-quickstart + pipecat init . # set up the current directory + pipecat init . --bot-type web \ + --transport daily --mode cascade \ + --stt deepgram_stt --llm openai_llm \ + --tts cartesia_tts # scaffold in-place, non-interactively + pipecat init my-bot --config project-config.json # scaffold from a config file + pipecat init --list-options # print valid service/transport values as JSON """ # `pipecat init quickstart`: scaffold the canned bot in-place, with the coding-agent guide. if target == "quickstart": return _init_quickstart(force) - # The old scaffolder flags (`--name`, `--bot-type`, `--stt`, `-o`, …) belong to - # `pipecat create`. `ignore_unknown_options` (set at registration) drops them into - # ctx.args; redirect with a clear message instead of erroring opaquely. - if ctx.args: - unexpected = " ".join(ctx.args) - console.print( - f"[red]Unexpected arguments:[/red] {unexpected}\n\n" - "`pipecat init` initializes a new Pipecat project (writes AGENTS.md, CLAUDE.md, " - "and GETTING_STARTED.md); it takes only an optional target directory and `--force`.\n" - "To scaffold non-interactively, use [bold]`pipecat create`[/bold] — run " - "`pipecat create --help`." + scaffold_requested = _scaffold_requested( + config=config, + name=name, + bot_type=bot_type, + transport=transport, + mode=mode, + stt=stt, + llm=llm, + tts=tts, + realtime=realtime, + video=video, + ) + + if scaffold_requested: + return _scaffold_non_interactive( + ctx, + target=target, + force=force, + dry_run=dry_run, + config=config, + name=name, + bot_type=bot_type, + transport=transport, + mode=mode, + stt=stt, + llm=llm, + tts=tts, + realtime=realtime, + video=video, + client_framework=client_framework, + client_server=client_server, + daily_pstn_mode=daily_pstn_mode, + twilio_daily_sip_mode=twilio_daily_sip_mode, + recording=recording, + transcription=transcription, + video_input=video_input, + video_output=video_output, + deploy_to_cloud=deploy_to_cloud, + enable_krisp=enable_krisp, + observability=observability, + enable_eval=enable_eval, ) - raise typer.Exit(1) + # No scaffold options: write the guide and route to a build method. # No argument: prompt for the directory. `target` is just a path — `.` sets up the # current directory, any other value names a folder (created below if it doesn't exist). if target is None: @@ -297,3 +422,44 @@ def init_command( _write_agent_guide(target_dir, force) _route_build_method(target_dir) + + +def _scaffold_non_interactive(ctx: typer.Context, *, target, force, dry_run, config, **flags): + """Write the agent guide and scaffold the project in-place from flags/a config file. + + The in-place sibling of the wizard path (:func:`_route_build_method`): same directory, + no ``GETTING_STARTED.md`` (the scaffold's README is the start-here). With no positional + target we scaffold into the current directory rather than prompting, so an automated run + (a coding agent that omits the ``.``) never hangs. ``--dry-run`` previews the resolved + config and writes nothing. + """ + target_dir = Path(target or ".") + if target_dir.exists() and not target_dir.is_dir(): + console.print(f"[red]Error:[/red] {target_dir} exists and is not a directory.") + raise typer.Exit(1) + + # Don't write the guide on a dry run — it must leave the directory untouched. + if not dry_run: + _write_agent_guide(target_dir, force) + + try: + run_non_interactive_scaffold( + ctx, + dest=target_dir, + in_place=True, + derived_name=target_dir.resolve().name or "pipecat-app", + dry_run=dry_run, + config=config, + **flags, + ) + except KeyboardInterrupt: + console.print("\n[yellow]Project creation cancelled.[/yellow]") + raise typer.Exit(1) + except typer.Exit: + raise + except FileExistsError as e: + console.print(f"\n[red]Error: {e}[/red]") + raise typer.Exit(1) + except Exception as e: + console.print(f"\n[red]Error creating project: {e}[/red]") + raise typer.Exit(1) diff --git a/src/pipecat/cli/main.py b/src/pipecat/cli/main.py index 5723c3ccd3f..2c47ede09c7 100644 --- a/src/pipecat/cli/main.py +++ b/src/pipecat/cli/main.py @@ -60,7 +60,6 @@ def _build_app(): import typer from rich.console import Console - from pipecat.cli.commands.create import create_command from pipecat.cli.commands.eval import eval_app from pipecat.cli.commands.init import init_command @@ -71,18 +70,34 @@ def _build_app(): ) console = Console() - # `create` is a plain command (not a sub-Typer group) so it can take an optional - # positional target path followed by options (e.g. `pc create . --bot-type web`). - app.command("create", help="Create a new Pipecat project")(create_command) + # `init` is the single entry point for building a Pipecat app: it writes the + # coding-agent guide and can scaffold a runnable bot (interactively or from flags/a + # config file, e.g. `pipecat init . --bot-type web -t daily ...`). + app.command("init", help="Initialize a new Pipecat project")(init_command) + + # `pipecat create` was removed (folded into `init`). Keep a hidden stub so an old + # command or muscle-memory invocation gets a clear pointer instead of Click's bare + # "No such command". ignore_unknown_options swallows the old scaffolder flags. + def _removed_create(ctx: typer.Context): + print( + "`pipecat create` was removed. Use `pipecat init` instead. It scaffolds " + "a project from flags or a config file, e.g.\n\n" + " pipecat init . --bot-type web -t daily --stt deepgram_stt " + "--llm openai_llm --tts cartesia_tts\n\n" + "Run `pipecat init --help` or `pipecat init --list-options` for details.", + file=sys.stderr, + ) + raise typer.Exit(1) - # `init` initializes a new Pipecat project (writes AGENTS.md + CLAUDE.md). ignore_unknown_options - # lets it catch legacy scaffolder flags (now `pipecat create`) and redirect with a clear - # message instead of an opaque "no such option" error. app.command( - "init", - help="Initialize a new Pipecat project (writes AGENTS.md + CLAUDE.md)", + "create", + help=( + "The create command was removed. Use the init command to scaffold your " + "Pipecat project. Run `pipecat init --help` for details." + ), + hidden=True, context_settings={"ignore_unknown_options": True, "allow_extra_args": True}, - )(init_command) + )(_removed_create) # `eval` is a first-party sub-Typer group, built in (not a plugin extension). app.add_typer(eval_app, name="eval") diff --git a/src/pipecat/cli/scaffold.py b/src/pipecat/cli/scaffold.py new file mode 100644 index 00000000000..27ddef0602a --- /dev/null +++ b/src/pipecat/cli/scaffold.py @@ -0,0 +1,251 @@ +# +# Copyright (c) 2025-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Project scaffolding for ``pipecat init``. + +The scaffolder generates a runnable Pipecat project (``bot.py``, dependencies, config, +optional client). It has three entry points, all used by ``pipecat init``: + +- :func:`scaffold_interactive` — the wizard, run when the developer chooses "scaffold a + runnable bot now" on interactive ``init``. +- :func:`scaffold_quickstart` — the canned quickstart preset (``pipecat init quickstart``). +- :func:`run_non_interactive_scaffold` — flags/config-file driven, no prompts + (``pipecat init . --bot-type web …``); the path coding agents and automation use. + +These were formerly the body of the standalone ``pipecat create`` command, which has been +removed — ``pipecat init`` is now the single entry point. +""" + +import json +from pathlib import Path + +import typer +from rich.console import Console + +from pipecat.cli.generators import ProjectGenerator +from pipecat.cli.prompts import ProjectConfig, ask_project_questions +from pipecat.cli.registry.service_metadata import ServiceRegistry + +console = Console() + + +def list_options_callback(value: bool): + """Print available service options as JSON and exit (eager ``--list-options`` flag).""" + if not value: + return + + def values(defs): + return [s.value for s in defs] + + options = { + "bot_type": ["web", "telephony"], + "transports": { + "web": values(ServiceRegistry.WEBRTC_TRANSPORTS), + "telephony": values(ServiceRegistry.TELEPHONY_TRANSPORTS), + }, + "stt": values(ServiceRegistry.STT_SERVICES), + "llm": values(ServiceRegistry.LLM_SERVICES), + "tts": values(ServiceRegistry.TTS_SERVICES), + "realtime": values(ServiceRegistry.REALTIME_SERVICES), + "video": values(ServiceRegistry.VIDEO_SERVICES), + } + print(json.dumps(options, indent=2)) + raise typer.Exit(0) + + +def scaffold_interactive(dest: Path | None, derived_name: str | None, in_place: bool): + """Run the interactive wizard and generate a project. + + Used by ``pipecat init`` when the developer chooses to scaffold a runnable bot. + ``dest`` is the resolved output location: the exact target directory when ``in_place`` + is True, otherwise the parent the ```` subfolder is created under (``None`` = + current directory). + """ + config_result = ask_project_questions(default_name=derived_name) + generator = ProjectGenerator(config_result) + project_path = generator.generate(dest, in_place=in_place) + generator.print_next_steps(project_path, in_place=in_place) + + +def run_non_interactive_scaffold( + ctx: typer.Context, + *, + dest: Path | None, + in_place: bool, + derived_name: str | None, + dry_run: bool, + config: Path | None, + name: str | None, + bot_type: str | None, + transport: list[str] | None, + mode: str | None, + stt: str | None, + llm: str | None, + tts: str | None, + realtime: str | None, + video: str | None, + client_framework: str | None, + client_server: str | None, + daily_pstn_mode: str | None, + twilio_daily_sip_mode: str | None, + recording: bool, + transcription: bool, + video_input: bool, + video_output: bool, + deploy_to_cloud: bool, + enable_krisp: bool, + observability: bool, + enable_eval: bool, +): + """Build a project from CLI flags and/or a config file — no prompts. + + Merges a ``--config`` file (if given) with the CLI flags, validates the result, and + generates the project. An explicit CLI flag always wins; the file value applies only + when the flag was omitted. On ``dry_run`` the resolved config is printed as JSON and + nothing is written. + + ``ctx`` is the calling Typer command's context — used to tell which flags the user + actually typed (vs. their defaults). The parameter names here must match the option + names declared on that command. + """ + from pipecat.cli.config_validator import ( + ConfigValidationError, + config_to_json, + load_config_from_file, + validate_and_build_config, + ) + + if config is not None: + file_data = load_config_from_file(config) + + # Merge file values with CLI flags. An explicit CLI flag always wins; + # the file value only applies when the flag was omitted. + def from_cli(param): + """True only if the user typed this flag (vs. falling back to its default). + + Compares the parameter source by enum member name rather than identity: + Typer vendors its own copy of Click, so the ``ParameterSource`` returned + here is a different enum object than ``click.core.ParameterSource``. + """ + source = ctx.get_parameter_source(param) + return source is not None and source.name == "COMMANDLINE" + + def pick(value, param, *file_keys): + """Explicit CLI flag wins; else first file key present; else the flag's default.""" + if from_cli(param): + return value + for key in file_keys: + if key in file_data: + return file_data[key] + return value + + name = pick(name, "name", "name", "project_name") + bot_type = pick(bot_type, "bot_type", "bot_type") + transport = pick(transport, "transport", "transports", "transport") + mode = pick(mode, "mode", "mode") + stt = pick(stt, "stt", "stt", "stt_service") + llm = pick(llm, "llm", "llm", "llm_service") + tts = pick(tts, "tts", "tts", "tts_service") + realtime = pick(realtime, "realtime", "realtime", "realtime_service") + video = pick(video, "video", "video", "video_service") + client_framework = pick(client_framework, "client_framework", "client_framework") + client_server = pick(client_server, "client_server", "client_server") + daily_pstn_mode = pick(daily_pstn_mode, "daily_pstn_mode", "daily_pstn_mode") + twilio_daily_sip_mode = pick( + twilio_daily_sip_mode, "twilio_daily_sip_mode", "twilio_daily_sip_mode" + ) + recording = pick(recording, "recording", "recording") + transcription = pick(transcription, "transcription", "transcription") + video_input = pick(video_input, "video_input", "video_input") + video_output = pick(video_output, "video_output", "video_output") + deploy_to_cloud = pick(deploy_to_cloud, "deploy_to_cloud", "deploy_to_cloud") + enable_krisp = pick(enable_krisp, "enable_krisp", "enable_krisp") + observability = pick( + observability, "observability", "observability", "enable_observability" + ) + enable_eval = pick(enable_eval, "enable_eval", "enable_eval", "eval") + + try: + project_config = validate_and_build_config( + name=name or derived_name, + bot_type=bot_type, + transport=transport, + mode=mode, + stt=stt, + llm=llm, + tts=tts, + realtime=realtime, + video=video, + client_framework=client_framework, + client_server=client_server, + daily_pstn_mode=daily_pstn_mode, + twilio_daily_sip_mode=twilio_daily_sip_mode, + recording=recording, + transcription=transcription, + video_input=video_input, + video_output=video_output, + deploy_to_cloud=deploy_to_cloud, + enable_krisp=enable_krisp, + observability=observability, + enable_eval=enable_eval, + ) + except ConfigValidationError as e: + console.print(f"\n[red]{e}[/red]") + raise typer.Exit(1) + + if dry_run: + print(config_to_json(project_config)) + raise typer.Exit(0) + + generator = ProjectGenerator(project_config) + project_path = generator.generate(dest, non_interactive=True, in_place=in_place) + generator.print_next_steps(project_path, in_place=in_place) + + +def scaffold_quickstart( + output_dir: Path | None = None, *, dest: Path | None = None, in_place: bool = False +): + """Generate the canned quickstart project (no questions). + + Sets up a project with SmallWebRTC, Daily, Deepgram STT, OpenAI LLM, and Cartesia + TTS — the fastest way to get a voice agent running. Used by ``pipecat init + quickstart``, which scaffolds in-place into an already-initialized directory (via + ``dest`` / ``in_place``). + """ + project_name = "pipecat-quickstart" + + console.print("[bold cyan]Let's create your Pipecat project![/bold cyan]\n") + + # Display all pre-selected defaults + console.print(f"[green]✔[/green] Project name: [cyan]{project_name}[/cyan]") + console.print("[green]✔[/green] Bot type: [cyan]Web/Mobile[/cyan]") + console.print("[green]✔[/green] Transport: [cyan]SmallWebRTC, Daily[/cyan]") + console.print("[green]✔[/green] Pipeline architecture: [cyan]Cascade (STT → LLM → TTS)[/cyan]") + console.print("[green]✔[/green] Speech-to-Text: [cyan]Deepgram[/cyan]") + console.print("[green]✔[/green] Language model: [cyan]OpenAI[/cyan]") + console.print("[green]✔[/green] Text-to-Speech: [cyan]Cartesia[/cyan]") + console.print("[green]✔[/green] Deploy to Pipecat Cloud: [cyan]Yes[/cyan]") + + # Build config with quickstart defaults + project_config = ProjectConfig( + project_name=project_name, + bot_type="web", + transports=["smallwebrtc", "daily"], + mode="cascade", + stt_service="deepgram_stt", + llm_service="openai_responses_llm", + tts_service="cartesia_tts", + deploy_to_cloud=True, + ) + + # Generate project + generator = ProjectGenerator(project_config) + project_path = generator.generate( + dest if in_place else output_dir, non_interactive=True, in_place=in_place + ) + + # Show next steps + generator.print_next_steps(project_path, in_place=in_place) diff --git a/tests/cli/test_config_validator.py b/tests/cli/test_config_validator.py index 4972b2bf10d..182843b06f6 100644 --- a/tests/cli/test_config_validator.py +++ b/tests/cli/test_config_validator.py @@ -624,8 +624,8 @@ def test_load_invalid_json(self, tmp_path): def _parse_config_dict(file_data: dict) -> ProjectConfig: - """Simulate the merging logic from create_command: map config dict keys to - validate_and_build_config kwargs, exactly as the CLI does after loading JSON.""" + """Simulate the merging logic from run_non_interactive_scaffold: map config dict keys + to validate_and_build_config kwargs, exactly as the CLI does after loading JSON.""" return validate_and_build_config( name=file_data.get("name") or file_data.get("project_name"), bot_type=file_data.get("bot_type"), diff --git a/tests/cli/test_create_interactive.py b/tests/cli/test_create_interactive.py deleted file mode 100644 index 771ace839a7..00000000000 --- a/tests/cli/test_create_interactive.py +++ /dev/null @@ -1,62 +0,0 @@ -# -# Copyright (c) 2025-2026, Daily -# -# SPDX-License-Identifier: BSD 2-Clause License -# - -"""Tests for the interactive `pc create` path (the wizard). - -`create` with no `--name`/`--config` runs an interactive questionary wizard. These -tests stub only that wizard (`ask_project_questions`) and let the real -`scaffold_interactive` + `ProjectGenerator` run, so they exercise the interactive -dispatch and in-place handling end to end without needing a TTY. -""" - -from typer.testing import CliRunner - -import pipecat.cli.commands.create as create_mod -from pipecat.cli.main import app -from pipecat.cli.prompts import ProjectConfig - -runner = CliRunner() - - -def _config(name="demo-bot"): - """A minimal known-good web/cascade config, as the wizard would return.""" - return ProjectConfig( - project_name=name, - bot_type="web", - transports=["smallwebrtc"], - mode="cascade", - stt_service="deepgram_stt", - llm_service="openai_llm", - tts_service="cartesia_tts", - deploy_to_cloud=False, - ) - - -def test_interactive_generates_project(tmp_path, monkeypatch): - # No --name/--config → interactive: the wizard runs, then the project is generated. - monkeypatch.setattr(create_mod, "ask_project_questions", lambda default_name=None: _config()) - result = runner.invoke(app, ["create", "-o", str(tmp_path)]) - assert result.exit_code == 0, result.output - # Nested under the wizard-supplied name (no positional target). - assert (tmp_path / "demo-bot" / "server" / "bot.py").exists() - - -def test_interactive_in_place_uses_derived_name(tmp_path, monkeypatch): - # A positional target goes interactive in-place, deriving the wizard's default name - # from the directory and scaffolding directly into it (no subfolder). - captured = {} - - def fake_questions(default_name=None): - captured["default_name"] = default_name - return _config(name=default_name or "demo-bot") - - monkeypatch.setattr(create_mod, "ask_project_questions", fake_questions) - target = tmp_path / "my-bot" - result = runner.invoke(app, ["create", str(target)]) - assert result.exit_code == 0, result.output - assert captured["default_name"] == "my-bot" - assert (target / "server" / "bot.py").exists() - assert not (target / "my-bot").exists() diff --git a/tests/cli/test_init_agent_ready.py b/tests/cli/test_init_agent_ready.py index 7dd787a32c5..de6ebbc85f9 100644 --- a/tests/cli/test_init_agent_ready.py +++ b/tests/cli/test_init_agent_ready.py @@ -12,8 +12,8 @@ from typer.testing import CliRunner import pipecat.cli -import pipecat.cli.commands.create as create_mod import pipecat.cli.commands.init as init_mod +import pipecat.cli.scaffold as scaffold_mod from pipecat.cli.main import app runner = CliRunner() @@ -31,7 +31,7 @@ def test_writes_all_files(self, tmp_path): claude = tmp_path / "CLAUDE.md" getting_started = tmp_path / "GETTING_STARTED.md" assert agents.read_text(encoding="utf-8").strip() - assert "pipecat create" in agents.read_text(encoding="utf-8") + assert "pipecat init" in agents.read_text(encoding="utf-8") assert claude.read_text(encoding="utf-8").strip() == "@AGENTS.md" # Developer guidance: prompt-writing help with a copyable example prompt. gs_text = getting_started.read_text(encoding="utf-8") @@ -92,7 +92,7 @@ def test_rerun_refreshes_agents_keeps_claude(self, tmp_path): result = runner.invoke(app, ["init", str(tmp_path)]) assert result.exit_code == 0, result.output # AGENTS.md is pipecat-owned → refreshed; CLAUDE.md is the dev's → preserved. - assert "pipecat create" in (tmp_path / "AGENTS.md").read_text(encoding="utf-8") + assert "pipecat init" in (tmp_path / "AGENTS.md").read_text(encoding="utf-8") assert (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") == "# my own claude config" def test_force_overwrites_claude(self, tmp_path): @@ -102,12 +102,33 @@ def test_force_overwrites_claude(self, tmp_path): assert result.exit_code == 0, result.output assert (tmp_path / "CLAUDE.md").read_text(encoding="utf-8").strip() == "@AGENTS.md" - def test_legacy_scaffolder_flags_redirect(self, tmp_path): - result = runner.invoke(app, ["init", str(tmp_path), "--name", "x", "--bot-type", "web"]) - assert result.exit_code == 1 - assert "pipecat create" in result.output - # Redirect must not write a half-initialized project. - assert not (tmp_path / "AGENTS.md").exists() + def test_scaffold_flags_build_in_place(self, tmp_path): + # Scaffold flags now build the project in-place (no redirect to a separate command). + result = runner.invoke( + app, + [ + "init", + str(tmp_path), + "--bot-type", + "web", + "-t", + "daily", + "-m", + "cascade", + "--stt", + "deepgram_stt", + "--llm", + "openai_llm", + "--tts", + "cartesia_tts", + ], + ) + assert result.exit_code == 0, result.output + # init initialized for agent-led dev and scaffolded a runnable bot in the same dir. + assert (tmp_path / "AGENTS.md").exists() + assert (tmp_path / "server" / "bot.py").exists() + # The scaffold path skips the from-scratch developer guide. + assert not (tmp_path / "GETTING_STARTED.md").exists() def test_quickstart_scaffolds_and_writes_guide(self, tmp_path, monkeypatch): # `init quickstart` is the human front door for the canned bot: it scaffolds the @@ -158,11 +179,11 @@ def ask(self): monkeypatch.setattr(questionary, "select", lambda *a, **k: _Q()) return lambda value: chosen.__setitem__("value", value) - def test_scaffold_branch_runs_create_in_place(self, tmp_path, monkeypatch, _select): + def test_scaffold_branch_scaffolds_in_place(self, tmp_path, monkeypatch, _select): _select("scaffold") captured = {} monkeypatch.setattr( - create_mod, + scaffold_mod, "scaffold_interactive", lambda dest, name, in_place: captured.update(dest=dest, name=name, in_place=in_place), ) @@ -180,7 +201,7 @@ def test_scaffold_branch_runs_create_in_place(self, tmp_path, monkeypatch, _sele def test_agent_branch_does_not_scaffold(self, tmp_path, monkeypatch, _select): _select("agent") monkeypatch.setattr( - create_mod, + scaffold_mod, "scaffold_interactive", lambda *a, **k: pytest.fail("agent branch must not scaffold"), ) @@ -219,6 +240,21 @@ def test_no_prompt_without_a_tty(self, tmp_path, monkeypatch): assert not (tmp_path / "server").exists() +class TestRemovedCreateCommand: + """`pipecat create` was folded into `init`; a hidden stub points users to it.""" + + def test_create_is_hidden_from_help(self): + out = runner.invoke(app, ["--help"]).output + assert "create" not in out + assert "init" in out + + def test_create_errors_with_pointer_to_init(self): + # Old flags must not crash on parsing — the stub swallows them and prints guidance. + result = runner.invoke(app, ["create", "--name", "x", "--bot-type", "web"]) + assert result.exit_code == 1 + assert "pipecat init" in result.output + + class TestBundledGuide: """The guide must ship and be release-clean (catches packaging + content regressions).""" @@ -230,10 +266,10 @@ def test_bundled_files_exist(self): def test_agents_content_is_release_clean(self): text = (AGENT_TEMPLATES / "AGENTS.md").read_text(encoding="utf-8") assert text.strip() - assert "pipecat create" in text - # No local-checkout paths or stale command name should ever ship. + assert "pipecat init" in text + # No local-checkout paths or the removed command name should ever ship. assert "/Users/" not in text - assert "pipecat init" not in text + assert "pipecat create" not in text assert "TODO(release)" not in text def test_claude_is_agents_import(self): diff --git a/tests/cli/test_create_in_place.py b/tests/cli/test_init_in_place.py similarity index 53% rename from tests/cli/test_create_in_place.py rename to tests/cli/test_init_in_place.py index 2ed04451ba4..5d1e2ebcadd 100644 --- a/tests/cli/test_create_in_place.py +++ b/tests/cli/test_init_in_place.py @@ -4,7 +4,7 @@ # SPDX-License-Identifier: BSD 2-Clause License # -"""Tests for `pc init ` in-place scaffolding (e.g. `pc init .`).""" +"""Tests for `pipecat init ` in-place scaffolding (e.g. `pipecat init .`).""" from typer.testing import CliRunner @@ -30,8 +30,8 @@ def test_init_in_place_writes_into_target_dir(tmp_path): - """`pc init --name ...` scaffolds directly into , no subfolder.""" - result = runner.invoke(app, ["create", str(tmp_path), "--name", "demo", *SERVICE_FLAGS]) + """`pipecat init ...` scaffolds directly into , no subfolder.""" + result = runner.invoke(app, ["init", str(tmp_path), "--name", "demo", *SERVICE_FLAGS]) assert result.exit_code == 0, result.output assert (tmp_path / "server" / "bot.py").exists() @@ -40,11 +40,23 @@ def test_init_in_place_writes_into_target_dir(tmp_path): assert not (tmp_path / "demo").exists() +def test_init_in_place_also_writes_agent_guide(tmp_path): + """Non-interactive `init` still initializes for agent-led dev: AGENTS.md + CLAUDE.md.""" + result = runner.invoke(app, ["init", str(tmp_path), *SERVICE_FLAGS]) + assert result.exit_code == 0, result.output + + assert (tmp_path / "server" / "bot.py").exists() + assert (tmp_path / "AGENTS.md").exists() + assert (tmp_path / "CLAUDE.md").read_text(encoding="utf-8").strip() == "@AGENTS.md" + # The scaffold path skips GETTING_STARTED.md — the README is the start-here. + assert not (tmp_path / "GETTING_STARTED.md").exists() + + def test_init_in_place_derives_name_from_dir(tmp_path): """Without --name, the project name is derived from the target dir basename.""" target = tmp_path / "my-derived-bot" target.mkdir() - result = runner.invoke(app, ["create", str(target), *SERVICE_FLAGS]) + result = runner.invoke(app, ["init", str(target), *SERVICE_FLAGS]) assert result.exit_code == 0, result.output pyproject = (target / "server" / "pyproject.toml").read_text(encoding="utf-8") @@ -54,7 +66,7 @@ def test_init_in_place_derives_name_from_dir(tmp_path): def test_init_in_place_preserves_claude_md(tmp_path): """The agent-loop case: an existing CLAUDE.md is untouched.""" (tmp_path / "CLAUDE.md").write_text("# guidance", encoding="utf-8") - result = runner.invoke(app, ["create", str(tmp_path), "--name", "demo", *SERVICE_FLAGS]) + result = runner.invoke(app, ["init", str(tmp_path), "--name", "demo", *SERVICE_FLAGS]) assert result.exit_code == 0, result.output assert (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") == "# guidance" @@ -62,19 +74,24 @@ def test_init_in_place_preserves_claude_md(tmp_path): def test_init_in_place_over_agent_guide(tmp_path): - """The init→create handoff: scaffold in-place into a dir already holding the guide. + """The full agent loop: scaffold in-place into a dir already holding the guide. - Mirrors what interactive `pipecat init` leaves behind before its scaffold branch - runs `create` in-place. The guide files must survive, CLAUDE.md untouched. + Mirrors what `pipecat init` leaves behind before the agent re-runs it to scaffold. + AGENTS.md is pipecat-owned and gets refreshed; the developer's CLAUDE.md and any + GETTING_STARTED.md are left untouched. """ - (tmp_path / "AGENTS.md").write_text("# guide", encoding="utf-8") + (tmp_path / "AGENTS.md").write_text("# stale guide", encoding="utf-8") (tmp_path / "GETTING_STARTED.md").write_text("# dev", encoding="utf-8") (tmp_path / "CLAUDE.md").write_text("@AGENTS.md", encoding="utf-8") - result = runner.invoke(app, ["create", str(tmp_path), "--name", "demo", *SERVICE_FLAGS]) + result = runner.invoke(app, ["init", str(tmp_path), "--name", "demo", *SERVICE_FLAGS]) assert result.exit_code == 0, result.output assert (tmp_path / "server" / "bot.py").exists() - assert (tmp_path / "AGENTS.md").read_text(encoding="utf-8") == "# guide" + # AGENTS.md is refreshed from the bundled template (no longer the stale stub). + agents = (tmp_path / "AGENTS.md").read_text(encoding="utf-8") + assert agents != "# stale guide" + assert "pipecat init" in agents + # The developer's files survive. assert (tmp_path / "GETTING_STARTED.md").read_text(encoding="utf-8") == "# dev" assert (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") == "@AGENTS.md" @@ -82,32 +99,6 @@ def test_init_in_place_over_agent_guide(tmp_path): def test_init_in_place_aborts_on_existing_project(tmp_path): """Refuses to clobber a directory that already contains a project.""" (tmp_path / "server").mkdir() - result = runner.invoke(app, ["create", str(tmp_path), "--name", "demo", *SERVICE_FLAGS]) + result = runner.invoke(app, ["init", str(tmp_path), "--name", "demo", *SERVICE_FLAGS]) assert result.exit_code == 1 assert "already exists" in result.output - - -def test_init_target_and_output_are_mutually_exclusive(tmp_path): - """Passing both a positional target and -o is an error.""" - result = runner.invoke( - app, ["create", str(tmp_path), "-o", str(tmp_path), "--name", "demo", *SERVICE_FLAGS] - ) - assert result.exit_code == 1 - assert "not both" in result.output - - -def test_init_no_positional_still_nests(tmp_path): - """Non-breaking: without a positional target, the project nests under .""" - result = runner.invoke( - app, ["create", "--name", "nested-bot", "-o", str(tmp_path), *SERVICE_FLAGS] - ) - assert result.exit_code == 0, result.output - - assert (tmp_path / "nested-bot" / "server" / "bot.py").exists() - - -def test_init_quickstart_still_works(tmp_path): - """Non-breaking: `pc init quickstart -o DIR` still routes to quickstart.""" - result = runner.invoke(app, ["create", "quickstart", "-o", str(tmp_path)]) - assert result.exit_code == 0, result.output - assert (tmp_path / "pipecat-quickstart" / "server" / "bot.py").exists() diff --git a/tests/cli/test_init_interactive.py b/tests/cli/test_init_interactive.py new file mode 100644 index 00000000000..e51d6183533 --- /dev/null +++ b/tests/cli/test_init_interactive.py @@ -0,0 +1,71 @@ +# +# Copyright (c) 2025-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Tests for the interactive scaffold path of `pipecat init` (the wizard). + +When the developer chooses "Scaffold a runnable bot now", init runs the interactive +questionary wizard (`ask_project_questions`) and the real `scaffold_interactive` + +`ProjectGenerator`, in-place. These stub only the wizard and the build-method prompt, +exercising that path end to end without needing a TTY. +""" + +from typer.testing import CliRunner + +import pipecat.cli.commands.init as init_mod +import pipecat.cli.scaffold as scaffold_mod +from pipecat.cli.main import app +from pipecat.cli.prompts import ProjectConfig + +runner = CliRunner() + + +def _config(name="demo-bot"): + """A minimal known-good web/cascade config, as the wizard would return.""" + return ProjectConfig( + project_name=name, + bot_type="web", + transports=["smallwebrtc"], + mode="cascade", + stt_service="deepgram_stt", + llm_service="openai_llm", + tts_service="cartesia_tts", + deploy_to_cloud=False, + ) + + +def _force_scaffold_choice(monkeypatch): + """Force interactive init and make the build-method prompt return 'scaffold'.""" + import questionary + + monkeypatch.setattr(init_mod, "_is_interactive", lambda: True) + + class _Q: + def ask(self): + return "scaffold" + + monkeypatch.setattr(questionary, "select", lambda *a, **k: _Q()) + + +def test_interactive_scaffold_in_place_uses_derived_name(tmp_path, monkeypatch): + # The build-method prompt returns "scaffold", so init runs the wizard in-place, + # deriving the wizard's default name from the directory (no subfolder). + captured = {} + + def fake_questions(default_name=None): + captured["default_name"] = default_name + return _config(name=default_name or "demo-bot") + + monkeypatch.setattr(scaffold_mod, "ask_project_questions", fake_questions) + _force_scaffold_choice(monkeypatch) + + target = tmp_path / "my-bot" + result = runner.invoke(app, ["init", str(target)]) + assert result.exit_code == 0, result.output + assert captured["default_name"] == "my-bot" + assert (target / "server" / "bot.py").exists() + assert not (target / "my-bot").exists() + # init initializes for agent-led dev before scaffolding. + assert (target / "AGENTS.md").exists() diff --git a/tests/cli/test_create_command.py b/tests/cli/test_init_non_interactive.py similarity index 97% rename from tests/cli/test_create_command.py rename to tests/cli/test_init_non_interactive.py index 30594b6f73a..ce21079d10e 100644 --- a/tests/cli/test_create_command.py +++ b/tests/cli/test_init_non_interactive.py @@ -8,7 +8,8 @@ These drive the real Typer command via ``--dry-run``, which resolves the full config (file values merged with CLI flags) and prints it as JSON without -generating any files — exercising the merge logic in ``create_command`` end to end. +generating any files — exercising the merge logic in ``run_non_interactive_scaffold`` +end to end. """ import json @@ -40,7 +41,7 @@ def _write_config(tmp_path, **overrides): def _dry_run(args): """Invoke `init ... --dry-run` and return the parsed resolved config.""" - result = runner.invoke(app, ["create", *args, "--dry-run"]) + result = runner.invoke(app, ["init", *args, "--dry-run"]) assert result.exit_code == 0, result.stdout return json.loads(result.stdout) diff --git a/tests/cli/test_list_options.py b/tests/cli/test_list_options.py index 933815c032f..f5c2ddee690 100644 --- a/tests/cli/test_list_options.py +++ b/tests/cli/test_list_options.py @@ -20,16 +20,16 @@ class TestListOptions: """Tests for --list-options output.""" def test_exits_successfully(self): - result = runner.invoke(app, ["create", "--list-options"]) + result = runner.invoke(app, ["init", "--list-options"]) assert result.exit_code == 0 def test_returns_valid_json(self): - result = runner.invoke(app, ["create", "--list-options"]) + result = runner.invoke(app, ["init", "--list-options"]) data = json.loads(result.stdout) assert isinstance(data, dict) def test_top_level_keys(self): - result = runner.invoke(app, ["create", "--list-options"]) + result = runner.invoke(app, ["init", "--list-options"]) data = json.loads(result.stdout) assert set(data.keys()) == { "bot_type", @@ -42,19 +42,19 @@ def test_top_level_keys(self): } def test_bot_type_values(self): - result = runner.invoke(app, ["create", "--list-options"]) + result = runner.invoke(app, ["init", "--list-options"]) data = json.loads(result.stdout) assert data["bot_type"] == ["web", "telephony"] def test_transports_grouped_by_bot_type(self): - result = runner.invoke(app, ["create", "--list-options"]) + result = runner.invoke(app, ["init", "--list-options"]) data = json.loads(result.stdout) assert set(data["transports"].keys()) == {"web", "telephony"} assert isinstance(data["transports"]["web"], list) assert isinstance(data["transports"]["telephony"], list) def test_transports_match_registry(self): - result = runner.invoke(app, ["create", "--list-options"]) + result = runner.invoke(app, ["init", "--list-options"]) data = json.loads(result.stdout) assert data["transports"]["web"] == [s.value for s in ServiceRegistry.WEBRTC_TRANSPORTS] assert data["transports"]["telephony"] == [ @@ -62,7 +62,7 @@ def test_transports_match_registry(self): ] def test_services_match_registry(self): - result = runner.invoke(app, ["create", "--list-options"]) + result = runner.invoke(app, ["init", "--list-options"]) data = json.loads(result.stdout) assert data["stt"] == [s.value for s in ServiceRegistry.STT_SERVICES] assert data["llm"] == [s.value for s in ServiceRegistry.LLM_SERVICES] @@ -71,7 +71,7 @@ def test_services_match_registry(self): assert data["video"] == [s.value for s in ServiceRegistry.VIDEO_SERVICES] def test_all_lists_non_empty(self): - result = runner.invoke(app, ["create", "--list-options"]) + result = runner.invoke(app, ["init", "--list-options"]) data = json.loads(result.stdout) assert len(data["bot_type"]) > 0 assert len(data["transports"]["web"]) > 0 @@ -84,7 +84,7 @@ def test_all_lists_non_empty(self): def test_no_project_generated(self): """--list-options should exit before any project generation happens.""" - result = runner.invoke(app, ["create", "--list-options", "--name", "should-not-run"]) + result = runner.invoke(app, ["init", "--list-options", "--name", "should-not-run"]) assert result.exit_code == 0 data = json.loads(result.stdout) assert "bot_type" in data diff --git a/tests/cli/test_project_generation.py b/tests/cli/test_project_generation.py index d4ecd8826cb..93966b54815 100644 --- a/tests/cli/test_project_generation.py +++ b/tests/cli/test_project_generation.py @@ -671,7 +671,7 @@ def patched_read(self, *args, **kwargs): monkeypatch.setattr(Path, "write_text", patched_write) monkeypatch.setattr(Path, "read_text", patched_read) - # Mirror the quickstart_command config from commands/create.py. + # Mirror the scaffold_quickstart config from cli/scaffold.py. config = ProjectConfig( project_name="pipecat-quickstart", bot_type="web", diff --git a/tests/cli/test_quickstart.py b/tests/cli/test_quickstart.py index 3f2c2bcb13d..01fe2695b64 100644 --- a/tests/cli/test_quickstart.py +++ b/tests/cli/test_quickstart.py @@ -4,7 +4,7 @@ # SPDX-License-Identifier: BSD 2-Clause License # -"""Tests for the pc init quickstart command.""" +"""Tests for the `pipecat init quickstart` command.""" from typer.testing import CliRunner @@ -14,10 +14,11 @@ class TestQuickstart: - """Tests for the quickstart subcommand.""" + """Tests for the quickstart preset (scaffolds in-place into ./pipecat-quickstart).""" - def test_quickstart_generates_project(self, tmp_path): - result = runner.invoke(app, ["create", "quickstart", "-o", str(tmp_path)]) + def test_quickstart_generates_project(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = runner.invoke(app, ["init", "quickstart"]) assert result.exit_code == 0, result.output project_dir = tmp_path / "pipecat-quickstart" @@ -27,14 +28,16 @@ def test_quickstart_generates_project(self, tmp_path): assert (project_dir / "README.md").exists() assert (project_dir / "server" / "Dockerfile").exists() - def test_quickstart_fails_if_directory_exists(self, tmp_path): - (tmp_path / "pipecat-quickstart").mkdir() - result = runner.invoke(app, ["create", "quickstart", "-o", str(tmp_path)]) + def test_quickstart_fails_if_project_exists(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "pipecat-quickstart" / "server").mkdir(parents=True) + result = runner.invoke(app, ["init", "quickstart"]) assert result.exit_code == 1 assert "already exists" in result.output - def test_quickstart_output_contains_defaults(self, tmp_path): - result = runner.invoke(app, ["create", "quickstart", "-o", str(tmp_path)]) + def test_quickstart_output_contains_defaults(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = runner.invoke(app, ["init", "quickstart"]) assert result.exit_code == 0, result.output assert "SmallWebRTC" in result.output assert "Daily" in result.output From 60d9392d2fb4d131080d65072a50952407463a16 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Wed, 24 Jun 2026 15:45:02 -0400 Subject: [PATCH 04/14] chore(changelog): rename fragment to PR 4883 --- changelog/{4881.removed.md => 4883.removed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog/{4881.removed.md => 4883.removed.md} (100%) diff --git a/changelog/4881.removed.md b/changelog/4883.removed.md similarity index 100% rename from changelog/4881.removed.md rename to changelog/4883.removed.md From 4479aa8b19671342c3673098cbcf2c9b349c54d7 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Wed, 24 Jun 2026 16:13:21 -0400 Subject: [PATCH 05/14] feat(cli): polish pipecat init --help Group the scaffold flags into "Scaffold options" (the structural, value-taking choices) and "Scaffold features" (the optional boolean toggles) instead of one flat wall of ~24 options. Drop the help= override on the init command so its docstring (summary + examples) drives `pipecat init --help`; tighten that description and flatten the examples to single lines so they render cleanly. Sharpen the TARGET argument help to cover in-place scaffolding, `.`, and quickstart. --- src/pipecat/cli/commands/init.py | 141 ++++++++++++++++++++++--------- src/pipecat/cli/main.py | 5 +- 2 files changed, 106 insertions(+), 40 deletions(-) diff --git a/src/pipecat/cli/commands/init.py b/src/pipecat/cli/commands/init.py index ac688cfbad8..76b1b0a99fa 100644 --- a/src/pipecat/cli/commands/init.py +++ b/src/pipecat/cli/commands/init.py @@ -57,6 +57,11 @@ # Fixed destination for `pipecat init quickstart`. _QUICKSTART_DIR = "pipecat-quickstart" +# `--help` panels grouping the scaffold flags, so `pipecat init --help` reads as +# "what to build" vs. "optional features" instead of one flat wall of options. +_PANEL_SCAFFOLD = "Scaffold options" +_PANEL_FEATURES = "Scaffold features" + def _guide_footer() -> str: """Provenance stamp appended to pipecat-owned guide files. @@ -259,75 +264,140 @@ def init_command( ctx: typer.Context, target: str | None = typer.Argument( None, - help="Directory to initialize (or 'quickstart' for the canned bot). Created if missing.", + help="Project directory: initialized, and scaffolded in place when scaffold options " + "are given. Use '.' for the current directory; created if missing. Pass 'quickstart' " + "for the canned bot.", ), force: bool = typer.Option( False, "--force", help=f"Also overwrite an existing {_CLAUDE_FILE} ({_AGENTS_FILE} is always refreshed).", ), - # --- Scaffold options (presence switches to non-interactive scaffolding) --- name: str | None = typer.Option( - None, "--name", "-n", help="Project name (defaults to the target directory name)" + None, + "--name", + "-n", + help="Project name (defaults to the target directory name)", + rich_help_panel=_PANEL_SCAFFOLD, ), bot_type: str | None = typer.Option( None, "--bot-type", "-b", help="Bot type: 'web' or 'telephony' (inferred from --transport if omitted)", + rich_help_panel=_PANEL_SCAFFOLD, ), transport: list[str] | None = typer.Option( - None, "--transport", "-t", help="Transport (repeatable, e.g. -t daily -t smallwebrtc)" + None, + "--transport", + "-t", + help="Transport (repeatable, e.g. -t daily -t smallwebrtc)", + rich_help_panel=_PANEL_SCAFFOLD, ), mode: str | None = typer.Option( - None, "--mode", "-m", help="Pipeline mode: 'cascade' or 'realtime'" + None, + "--mode", + "-m", + help="Pipeline mode: 'cascade' or 'realtime'", + rich_help_panel=_PANEL_SCAFFOLD, + ), + stt: str | None = typer.Option( + None, "--stt", help="STT service (cascade mode)", rich_help_panel=_PANEL_SCAFFOLD + ), + llm: str | None = typer.Option( + None, "--llm", help="LLM service (cascade mode)", rich_help_panel=_PANEL_SCAFFOLD + ), + tts: str | None = typer.Option( + None, "--tts", help="TTS service (cascade mode)", rich_help_panel=_PANEL_SCAFFOLD ), - stt: str | None = typer.Option(None, "--stt", help="STT service (cascade mode)"), - llm: str | None = typer.Option(None, "--llm", help="LLM service (cascade mode)"), - tts: str | None = typer.Option(None, "--tts", help="TTS service (cascade mode)"), realtime: str | None = typer.Option( - None, "--realtime", help="Realtime service (realtime mode)" + None, + "--realtime", + help="Realtime service (realtime mode)", + rich_help_panel=_PANEL_SCAFFOLD, + ), + video: str | None = typer.Option( + None, "--video", help="Video avatar service", rich_help_panel=_PANEL_SCAFFOLD ), - video: str | None = typer.Option(None, "--video", help="Video avatar service"), client_framework: str | None = typer.Option( - None, "--client-framework", help="Client framework: 'react', 'vanilla', or 'none'" + None, + "--client-framework", + help="Client framework: 'react', 'vanilla', or 'none'", + rich_help_panel=_PANEL_SCAFFOLD, ), client_server: str | None = typer.Option( - None, "--client-server", help="Client dev server: 'vite' or 'nextjs'" + None, + "--client-server", + help="Client dev server: 'vite' or 'nextjs'", + rich_help_panel=_PANEL_SCAFFOLD, ), daily_pstn_mode: str | None = typer.Option( - None, "--daily-pstn-mode", help="Daily PSTN mode: 'dial-in' or 'dial-out'" + None, + "--daily-pstn-mode", + help="Daily PSTN mode: 'dial-in' or 'dial-out'", + rich_help_panel=_PANEL_SCAFFOLD, ), twilio_daily_sip_mode: str | None = typer.Option( - None, "--twilio-daily-sip-mode", help="Twilio+Daily SIP mode: 'dial-in' or 'dial-out'" + None, + "--twilio-daily-sip-mode", + help="Twilio+Daily SIP mode: 'dial-in' or 'dial-out'", + rich_help_panel=_PANEL_SCAFFOLD, + ), + config: Path | None = typer.Option( + None, + "--config", + "-c", + help="JSON config file (triggers non-interactive scaffolding)", + rich_help_panel=_PANEL_SCAFFOLD, + ), + recording: bool = typer.Option( + False, + "--recording/--no-recording", + help="Enable recording", + rich_help_panel=_PANEL_FEATURES, ), - recording: bool = typer.Option(False, "--recording/--no-recording", help="Enable recording"), transcription: bool = typer.Option( - False, "--transcription/--no-transcription", help="Enable transcription" + False, + "--transcription/--no-transcription", + help="Enable transcription", + rich_help_panel=_PANEL_FEATURES, ), video_input: bool = typer.Option( - False, "--video-input/--no-video-input", help="Enable video input" + False, + "--video-input/--no-video-input", + help="Enable video input", + rich_help_panel=_PANEL_FEATURES, ), video_output: bool = typer.Option( - False, "--video-output/--no-video-output", help="Enable video output" + False, + "--video-output/--no-video-output", + help="Enable video output", + rich_help_panel=_PANEL_FEATURES, ), deploy_to_cloud: bool = typer.Option( - True, "--deploy-to-cloud/--no-deploy-to-cloud", help="Generate cloud deployment files" + True, + "--deploy-to-cloud/--no-deploy-to-cloud", + help="Generate cloud deployment files", + rich_help_panel=_PANEL_FEATURES, ), enable_krisp: bool = typer.Option( - False, "--enable-krisp/--no-enable-krisp", help="Enable Krisp noise cancellation" + False, + "--enable-krisp/--no-enable-krisp", + help="Enable Krisp noise cancellation", + rich_help_panel=_PANEL_FEATURES, ), observability: bool = typer.Option( - False, "--observability/--no-observability", help="Enable observability" + False, + "--observability/--no-observability", + help="Enable observability", + rich_help_panel=_PANEL_FEATURES, ), enable_eval: bool = typer.Option( False, "--eval/--no-eval", help="Add an 'eval' transport so the bot is runnable with `-t eval` for " "behavioral evals (see `pipecat eval`). Off by default.", - ), - config: Path | None = typer.Option( - None, "--config", "-c", help="JSON config file (triggers non-interactive scaffolding)" + rich_help_panel=_PANEL_FEATURES, ), dry_run: bool = typer.Option( False, "--dry-run", help="Print resolved scaffold config as JSON without writing files" @@ -342,24 +412,19 @@ def init_command( ): r"""Initialize a new Pipecat project — and optionally scaffold it. - Writes the coding-agent guide (AGENTS.md + CLAUDE.md), then either scaffolds a runnable - bot or hands you off to a coding agent. Pass scaffold options (``--bot-type``, - ``--transport``, the service flags, or ``--config``) to build the project - non-interactively, in-place in the target directory; with no scaffold options, ``init`` - writes GETTING_STARTED.md and (interactively) asks how you want to build. + Writes the coding-agent guide (AGENTS.md, CLAUDE.md). Pass scaffold options (or + ``--config``) to also build a runnable bot in place; with none, ``init`` asks how you + want to build. Examples:: - pipecat init # prompt for a directory, then choose how to build - pipecat init my-bot # set up ./my-bot - pipecat init quickstart # canned quickstart bot in ./pipecat-quickstart - pipecat init . # set up the current directory - pipecat init . --bot-type web \ - --transport daily --mode cascade \ - --stt deepgram_stt --llm openai_llm \ - --tts cartesia_tts # scaffold in-place, non-interactively + pipecat init # prompt for a directory, then choose how to build + pipecat init my-bot # set up ./my-bot + pipecat init quickstart # canned quickstart bot in ./pipecat-quickstart + pipecat init . # set up the current directory + pipecat init . --bot-type web -t daily -m cascade --stt deepgram_stt --llm openai_llm --tts cartesia_tts pipecat init my-bot --config project-config.json # scaffold from a config file - pipecat init --list-options # print valid service/transport values as JSON + pipecat init --list-options # print valid service/transport values as JSON """ # `pipecat init quickstart`: scaffold the canned bot in-place, with the coding-agent guide. if target == "quickstart": diff --git a/src/pipecat/cli/main.py b/src/pipecat/cli/main.py index 2c47ede09c7..11e1c90b5f9 100644 --- a/src/pipecat/cli/main.py +++ b/src/pipecat/cli/main.py @@ -72,8 +72,9 @@ def _build_app(): # `init` is the single entry point for building a Pipecat app: it writes the # coding-agent guide and can scaffold a runnable bot (interactively or from flags/a - # config file, e.g. `pipecat init . --bot-type web -t daily ...`). - app.command("init", help="Initialize a new Pipecat project")(init_command) + # config file, e.g. `pipecat init . --bot-type web -t daily ...`). No `help=` here so + # the command's docstring (summary + examples) drives `pipecat init --help`. + app.command("init")(init_command) # `pipecat create` was removed (folded into `init`). Keep a hidden stub so an old # command or muscle-memory invocation gets a clear pointer instead of Click's bare From 4b5b8501fe6e9c4b879828fc2e0e622789e96d32 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Wed, 24 Jun 2026 16:28:42 -0400 Subject: [PATCH 06/14] fix(cli): validate scaffold config before writing; warn on quickstart flags Split run_non_interactive_scaffold into resolve_scaffold_config (merge + validate + --dry-run) and generate_scaffold (write), so `pipecat init` validates before writing the agent guide. An incomplete invocation like `pipecat init . --bot-type web` now fails atomically instead of dropping AGENTS.md/CLAUDE.md and then erroring. Also warn when scaffold flags are passed to `pipecat init quickstart` (a fixed preset) instead of silently ignoring them. --- src/pipecat/cli/commands/init.py | 53 +++++++++++++++++++++----- src/pipecat/cli/scaffold.py | 30 ++++++++++----- tests/cli/test_config_validator.py | 4 +- tests/cli/test_init_in_place.py | 15 ++++++++ tests/cli/test_init_non_interactive.py | 2 +- tests/cli/test_quickstart.py | 9 +++++ 6 files changed, 90 insertions(+), 23 deletions(-) diff --git a/src/pipecat/cli/commands/init.py b/src/pipecat/cli/commands/init.py index 76b1b0a99fa..ea023fc87b3 100644 --- a/src/pipecat/cli/commands/init.py +++ b/src/pipecat/cli/commands/init.py @@ -41,7 +41,11 @@ from rich.console import Console import pipecat.cli -from pipecat.cli.scaffold import list_options_callback, run_non_interactive_scaffold +from pipecat.cli.scaffold import ( + generate_scaffold, + list_options_callback, + resolve_scaffold_config, +) console = Console() @@ -260,6 +264,28 @@ def _scaffold_requested(*, config, name, bot_type, transport, mode, stt, llm, tt ) +# Every scaffold/feature option, by parameter name — used to detect flags the user +# explicitly typed (vs. defaulted) so `init quickstart` can flag the ones it ignores. +_SCAFFOLD_PARAM_NAMES = ( + "name", "bot_type", "transport", "mode", "stt", "llm", "tts", "realtime", "video", + "client_framework", "client_server", "daily_pstn_mode", "twilio_daily_sip_mode", "config", + "recording", "transcription", "video_input", "video_output", "deploy_to_cloud", + "enable_krisp", "observability", "enable_eval", +) # fmt: skip + + +def _has_explicit_scaffold_flags(ctx: typer.Context) -> bool: + """Whether the user typed any scaffold/feature flag on the command line. + + Uses the Typer context's parameter source so boolean toggles (which can't be told from + their default by value) are detected too. + """ + return any( + (src := ctx.get_parameter_source(p)) is not None and src.name == "COMMANDLINE" + for p in _SCAFFOLD_PARAM_NAMES + ) + + def init_command( ctx: typer.Context, target: str | None = typer.Argument( @@ -428,6 +454,13 @@ def init_command( """ # `pipecat init quickstart`: scaffold the canned bot in-place, with the coding-agent guide. if target == "quickstart": + # quickstart is a fixed preset — scaffold flags don't apply. Say so instead of + # silently ignoring them. + if _has_explicit_scaffold_flags(ctx): + console.print( + "[yellow]Note:[/yellow] `quickstart` is a fixed preset; " + "ignoring the scaffold options you passed." + ) return _init_quickstart(force) scaffold_requested = _scaffold_requested( @@ -495,28 +528,28 @@ def _scaffold_non_interactive(ctx: typer.Context, *, target, force, dry_run, con The in-place sibling of the wizard path (:func:`_route_build_method`): same directory, no ``GETTING_STARTED.md`` (the scaffold's README is the start-here). With no positional target we scaffold into the current directory rather than prompting, so an automated run - (a coding agent that omits the ``.``) never hangs. ``--dry-run`` previews the resolved - config and writes nothing. + (a coding agent that omits the ``.``) never hangs. + + The scaffold config is resolved and validated *before* the guide is written, so an + invalid invocation (or ``--dry-run``) fails without leaving a half-initialized directory. """ target_dir = Path(target or ".") if target_dir.exists() and not target_dir.is_dir(): console.print(f"[red]Error:[/red] {target_dir} exists and is not a directory.") raise typer.Exit(1) - # Don't write the guide on a dry run — it must leave the directory untouched. - if not dry_run: - _write_agent_guide(target_dir, force) - try: - run_non_interactive_scaffold( + # Validate first: this exits non-zero on a bad config, or zero on --dry-run, before + # anything is written. Only a fully valid, non-dry-run invocation reaches the writes. + project_config = resolve_scaffold_config( ctx, - dest=target_dir, - in_place=True, derived_name=target_dir.resolve().name or "pipecat-app", dry_run=dry_run, config=config, **flags, ) + _write_agent_guide(target_dir, force) + generate_scaffold(project_config, dest=target_dir, in_place=True) except KeyboardInterrupt: console.print("\n[yellow]Project creation cancelled.[/yellow]") raise typer.Exit(1) diff --git a/src/pipecat/cli/scaffold.py b/src/pipecat/cli/scaffold.py index 27ddef0602a..747a75e22dd 100644 --- a/src/pipecat/cli/scaffold.py +++ b/src/pipecat/cli/scaffold.py @@ -12,8 +12,9 @@ - :func:`scaffold_interactive` — the wizard, run when the developer chooses "scaffold a runnable bot now" on interactive ``init``. - :func:`scaffold_quickstart` — the canned quickstart preset (``pipecat init quickstart``). -- :func:`run_non_interactive_scaffold` — flags/config-file driven, no prompts - (``pipecat init . --bot-type web …``); the path coding agents and automation use. +- :func:`resolve_scaffold_config` + :func:`generate_scaffold` — flags/config-file driven, + no prompts (``pipecat init . --bot-type web …``); the path coding agents and automation + use. Split so the config is validated before any files are written. These were formerly the body of the standalone ``pipecat create`` command, which has been removed — ``pipecat init`` is now the single entry point. @@ -70,11 +71,9 @@ def scaffold_interactive(dest: Path | None, derived_name: str | None, in_place: generator.print_next_steps(project_path, in_place=in_place) -def run_non_interactive_scaffold( +def resolve_scaffold_config( ctx: typer.Context, *, - dest: Path | None, - in_place: bool, derived_name: str | None, dry_run: bool, config: Path | None, @@ -100,12 +99,13 @@ def run_non_interactive_scaffold( observability: bool, enable_eval: bool, ): - """Build a project from CLI flags and/or a config file — no prompts. + """Merge flags + config file into a validated ``ProjectConfig`` — no file writes. - Merges a ``--config`` file (if given) with the CLI flags, validates the result, and - generates the project. An explicit CLI flag always wins; the file value applies only - when the flag was omitted. On ``dry_run`` the resolved config is printed as JSON and - nothing is written. + Merges a ``--config`` file (if given) with the CLI flags and validates the result. An + explicit CLI flag always wins; the file value applies only when the flag was omitted. + Exits non-zero with a clear message on a validation error, and on ``dry_run`` prints + the resolved config as JSON and exits zero — so callers can validate *before* writing + anything (see :func:`generate_scaffold`). ``ctx`` is the calling Typer command's context — used to tell which flags the user actually typed (vs. their defaults). The parameter names here must match the option @@ -200,6 +200,16 @@ def pick(value, param, *file_keys): print(config_to_json(project_config)) raise typer.Exit(0) + return project_config + + +def generate_scaffold(project_config, *, dest: Path | None, in_place: bool): + """Generate a project from an already-validated config and print next steps. + + The write half of non-interactive scaffolding; pair it with + :func:`resolve_scaffold_config`, which validates first so a bad invocation fails + without touching the directory. + """ generator = ProjectGenerator(project_config) project_path = generator.generate(dest, non_interactive=True, in_place=in_place) generator.print_next_steps(project_path, in_place=in_place) diff --git a/tests/cli/test_config_validator.py b/tests/cli/test_config_validator.py index 182843b06f6..d1549fea8df 100644 --- a/tests/cli/test_config_validator.py +++ b/tests/cli/test_config_validator.py @@ -624,8 +624,8 @@ def test_load_invalid_json(self, tmp_path): def _parse_config_dict(file_data: dict) -> ProjectConfig: - """Simulate the merging logic from run_non_interactive_scaffold: map config dict keys - to validate_and_build_config kwargs, exactly as the CLI does after loading JSON.""" + """Simulate the merging logic from resolve_scaffold_config: map config dict keys to + validate_and_build_config kwargs, exactly as the CLI does after loading JSON.""" return validate_and_build_config( name=file_data.get("name") or file_data.get("project_name"), bot_type=file_data.get("bot_type"), diff --git a/tests/cli/test_init_in_place.py b/tests/cli/test_init_in_place.py index 5d1e2ebcadd..2a08bc92160 100644 --- a/tests/cli/test_init_in_place.py +++ b/tests/cli/test_init_in_place.py @@ -102,3 +102,18 @@ def test_init_in_place_aborts_on_existing_project(tmp_path): result = runner.invoke(app, ["init", str(tmp_path), "--name", "demo", *SERVICE_FLAGS]) assert result.exit_code == 1 assert "already exists" in result.output + + +def test_invalid_scaffold_flags_write_nothing(tmp_path): + """An incomplete scaffold invocation fails before writing — no half-initialized dir. + + The config is validated before the guide is written, so a bad invocation leaves the + directory untouched rather than dropping AGENTS.md/CLAUDE.md and then erroring. + """ + # --bot-type with no transport/mode/services is invalid. + result = runner.invoke(app, ["init", str(tmp_path), "--bot-type", "web"]) + assert result.exit_code == 1 + assert "validation failed" in result.output.lower() + assert not (tmp_path / "AGENTS.md").exists() + assert not (tmp_path / "CLAUDE.md").exists() + assert not (tmp_path / "server").exists() diff --git a/tests/cli/test_init_non_interactive.py b/tests/cli/test_init_non_interactive.py index ce21079d10e..83962138cca 100644 --- a/tests/cli/test_init_non_interactive.py +++ b/tests/cli/test_init_non_interactive.py @@ -8,7 +8,7 @@ These drive the real Typer command via ``--dry-run``, which resolves the full config (file values merged with CLI flags) and prints it as JSON without -generating any files — exercising the merge logic in ``run_non_interactive_scaffold`` +generating any files — exercising the merge logic in ``resolve_scaffold_config`` end to end. """ diff --git a/tests/cli/test_quickstart.py b/tests/cli/test_quickstart.py index 01fe2695b64..e5b6a7e962c 100644 --- a/tests/cli/test_quickstart.py +++ b/tests/cli/test_quickstart.py @@ -44,3 +44,12 @@ def test_quickstart_output_contains_defaults(self, tmp_path, monkeypatch): assert "Deepgram" in result.output assert "OpenAI" in result.output assert "Cartesia" in result.output + + def test_quickstart_warns_when_scaffold_flags_passed(self, tmp_path, monkeypatch): + # quickstart is a fixed preset; combining it with scaffold flags should warn rather + # than silently ignore them — but still scaffold the canned (web) bot. + monkeypatch.chdir(tmp_path) + result = runner.invoke(app, ["init", "quickstart", "--bot-type", "telephony"]) + assert result.exit_code == 0, result.output + assert "fixed preset" in result.output + assert (tmp_path / "pipecat-quickstart" / "server" / "bot.py").exists() From 6e79841b86d1f832c05a32a02ba91da46330565f Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Wed, 24 Jun 2026 17:02:34 -0400 Subject: [PATCH 07/14] docs(changelog): match house style for the create-removal entry --- changelog/4883.removed.md | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/changelog/4883.removed.md b/changelog/4883.removed.md index 10c93b82a16..a46c963e856 100644 --- a/changelog/4883.removed.md +++ b/changelog/4883.removed.md @@ -1,12 +1 @@ -- Removed the `pipecat create` command. Its scaffolding has been folded into `pipecat - init`, which is now the single entry point for building a Pipecat app. `init` still - writes the coding-agent guide (`AGENTS.md` + `CLAUDE.md`) and now also scaffolds a - runnable bot — interactively, or non-interactively from flags or a config file: - - pipecat init . --bot-type web -t daily --mode cascade \ - --stt deepgram_stt --llm openai_llm --tts cartesia_tts - - Run `pipecat init --help` or `pipecat init --list-options` for the available options. - `pipecat init quickstart` replaces `pipecat create quickstart`. Scaffolding is - directory-first and in-place (the project name is derived from the target directory); - `pipecat create`'s `--output/-o` and `--name`-subfolder layout are gone. +- ⚠️ Removed the `pipecat create` command; scaffolding now lives in `pipecat init`, the single entry point for starting a Pipecat app. Alongside the coding-agent guide (`AGENTS.md`, `CLAUDE.md`) it already wrote, `pipecat init` now also scaffolds a runnable bot — interactively, or non-interactively from flags or a config file (e.g. `pipecat init . --bot-type web -t daily --stt deepgram_stt --llm openai_llm --tts cartesia_tts`; run `pipecat init --list-options` for valid values). `pipecat init quickstart` replaces `pipecat create quickstart`. Scaffolding is now directory-first and in-place — the project name comes from the target directory, and `create`'s `--output/-o` and `--name`-subfolder layout are gone. From 01a3db821d462a25116384cdc7b067602e62a618 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Wed, 24 Jun 2026 17:05:32 -0400 Subject: [PATCH 08/14] docs(cli): drop human-facing note from vended AGENTS.md AGENTS.md is the coding-agent guide; the bare/quickstart interactive forms are for humans (covered in GETTING_STARTED.md) and the scaffold section's warning already tells the agent to avoid the interactive form. --- src/pipecat/cli/agent_templates/AGENTS.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/pipecat/cli/agent_templates/AGENTS.md b/src/pipecat/cli/agent_templates/AGENTS.md index 1b150e2ee7e..9e43c1f6178 100644 --- a/src/pipecat/cli/agent_templates/AGENTS.md +++ b/src/pipecat/cli/agent_templates/AGENTS.md @@ -41,8 +41,6 @@ pipecat init . \ # • --dry-run prints the resolved config as JSON; --config project.json drives it from a file. # • --transport is repeatable — pass each transport you want (production + a local-dev one, §2). # • --bot-type is inferred from --transport (telephony if any telephony transport, else web) — omit it. - -# Humans: `pipecat init quickstart` (canned defaults) or bare `pipecat init` (interactive wizard). ``` **Choose *with* the user, not for them.** Map their requirements to the real options and confirm transport / services / mode / deployment (§7) before scaffolding — don't silently pick or guess. Mode affects testing speed — **cascade (STT→LLM→TTS)** gets the fast text-mode eval loop (§6); **realtime (speech-to-speech)** is tested in audio mode — but both run headless, so pick the mode the use case needs. From 466eb45504d2040499260c75b70bfc158545b8c5 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Wed, 24 Jun 2026 17:11:05 -0400 Subject: [PATCH 09/14] docs(changelog): drop agent-ready jargon from init entry (4861) --- changelog/4861.changed.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog/4861.changed.md b/changelog/4861.changed.md index 2d5097ffb41..a3f0f23c7a5 100644 --- a/changelog/4861.changed.md +++ b/changelog/4861.changed.md @@ -1,4 +1,4 @@ -- `pipecat init` is now the starting point for building a Pipecat app. It makes your project agent-ready by writing `AGENTS.md` and `CLAUDE.md`, then helps you build: +- `pipecat init` is now the starting point for building a Pipecat app. It writes the coding-agent files `AGENTS.md` and `CLAUDE.md`, then helps you build: - Build with a coding agent (such as Claude Code or Codex). This also writes a `GETTING_STARTED.md` guide for building Pipecat apps with an AI coding assistant. - Scaffold a runnable bot immediately through an interactive setup wizard. - - Run `pipecat init quickstart` to scaffold the ready-to-run quickstart project, agent-ready in one step. + - Run `pipecat init quickstart` to scaffold the ready-to-run quickstart project, set up for coding agents in one step. From 39b6d1cf6a028b51260b3265d16fbf4bd54577dd Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Tue, 23 Jun 2026 21:48:15 -0400 Subject: [PATCH 10/14] feat(cli): preserve existing guide files on `pipecat init` re-run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the three guide files `pipecat init` writes (AGENTS.md, CLAUDE.md, GETTING_STARTED.md) follow one uniform rule instead of asymmetric ones. Previously AGENTS.md and GETTING_STARTED.md were always overwritten while CLAUDE.md was preserved — which silently destroyed a non-Claude user's hand-edited AGENTS.md on every re-run. Now: absent -> write; present -> keep it (never clobber silently); `--overwrite-guide` (renamed from `--force`, now covering all three files) to overwrite. A re-run reports what it did per file, and if a kept guide carries an older Pipecat's version stamp it prints a heads-up pointing at `--overwrite-guide` — so staleness is surfaced, not silent. - init.py: `_write_guide_file` helper (absent/keep/overwrite) + `_stamped_version` + `_print_refresh_summary`; footer text and flag/param renamed - tests: re-run now preserves; add stale-nudge and summary coverage --- src/pipecat/cli/commands/init.py | 155 ++++++++++++++++++++--------- tests/cli/test_init_agent_ready.py | 49 +++++++-- 2 files changed, 146 insertions(+), 58 deletions(-) diff --git a/src/pipecat/cli/commands/init.py b/src/pipecat/cli/commands/init.py index ea023fc87b3..32813d12f96 100644 --- a/src/pipecat/cli/commands/init.py +++ b/src/pipecat/cli/commands/init.py @@ -34,6 +34,7 @@ churns belongs in the live sources the guide's §3 points agents at. """ +import re import sys from pathlib import Path @@ -67,31 +68,96 @@ _PANEL_FEATURES = "Scaffold features" +# Matches the version recorded in a guide footer, e.g. "(pipecat-ai 0.1.2)". +_FOOTER_VERSION_RE = re.compile(r"pipecat-ai ([^)]+)\)") + + def _guide_footer() -> str: """Provenance stamp appended to pipecat-owned guide files. The written guide is a static snapshot that otherwise looks like hand-written project docs. The footer tells a later reader (human or agent) that it is generated and - refreshable, and which pipecat-ai wrote it — so a project whose pinned version has - moved on can spot a stale guide. + refreshable, and which pipecat-ai wrote it — so a re-run can spot a stale guide and + nudge the developer to refresh it. """ return ( f"\n\n" + "Run `pipecat init --overwrite-guide` to refresh. -->\n" ) -def _write_agent_guide(target_dir: Path, force: bool) -> None: +def _stamped_version(text: str) -> str | None: + """Return the pipecat-ai version recorded in a guide's footer, or None if unstamped.""" + match = _FOOTER_VERSION_RE.search(text) + return match.group(1) if match else None + + +def _write_guide_file(path: Path, content: str, *, stamped: bool, overwrite: bool) -> str: + """Write one guide file under the uniform preserve-by-default rule. + + Absent → write it. Present → keep it (never clobber silently) unless ``overwrite``. + ``stamped`` appends the provenance footer, so the file can be recognized and + version-checked on a later run; pass it for pipecat-owned guides (AGENTS.md, + GETTING_STARTED.md), not the trivial ``@AGENTS.md`` pointer in CLAUDE.md. + + Prints a per-file line and returns a status — ``"wrote"``, ``"overwrote"``, + ``"kept"``, or ``"kept-stale"`` (kept, but written by a different pipecat-ai) — that + the caller folds into a single refresh summary. Raises ``OSError`` on a write failure. + """ + body = content + _guide_footer() if stamped else content + + if not path.exists(): + path.write_text(body, encoding="utf-8") + console.print(f"[green]✔[/green] Wrote {path}") + return "wrote" + + if overwrite: + path.write_text(body, encoding="utf-8") + console.print(f"[green]✔[/green] Overwrote {path}") + return "overwrote" + + # Keep the existing file. Surface whether it's a stale pipecat guide (worth a refresh + # nudge) or one we don't own, so the developer knows why it was left alone. + stamped_ver = _stamped_version(path.read_text(encoding="utf-8")) + if stamped_ver is not None and stamped_ver != pipecat.__version__: + console.print(f"[yellow]•[/yellow] Kept {path} (written by pipecat-ai {stamped_ver})") + return "kept-stale" + if stamped and stamped_ver is None: + console.print(f"[yellow]•[/yellow] Kept {path} (your own file)") + return "kept" + console.print(f"[yellow]•[/yellow] Kept {path}") + return "kept" + + +def _print_refresh_summary(statuses: list[str]) -> None: + """Point the developer at --overwrite-guide when a re-run changed nothing. + + Keeps a preserve-everything run from reading as a silent no-op, and surfaces a stale + guide instead of letting it sit out of date quietly. + """ + if any(s == "kept-stale" for s in statuses): + console.print( + "[yellow]⚠[/yellow] Your guide was written by an older Pipecat. Run " + "[bold]`pipecat init --overwrite-guide`[/bold] to update it." + ) + elif statuses and all(s.startswith("kept") for s in statuses): + console.print( + "Guide files already in place. Run " + "[bold]`pipecat init --overwrite-guide`[/bold] to refresh them." + ) + + +def _write_agent_guide(target_dir: Path, overwrite: bool) -> None: """Write the core agent guide — AGENTS.md and CLAUDE.md — into a directory. - These set up the project for coding agents and are wanted on *every* path (coding agent, - scaffold-now, quickstart), so they're written upfront. ``AGENTS.md`` is pipecat-owned - and always (re)written, so re-running refreshes it after a Pipecat upgrade; ``CLAUDE.md`` - is the developer's own entry point and is only overwritten with ``force``. + Wanted on *every* path (coding agent, scaffold-now, quickstart), so they're written + upfront. Both follow the uniform rule (:func:`_write_guide_file`): an existing file is + kept unless ``overwrite``. AGENTS.md carries a provenance footer (so a re-run can spot + a stale guide); CLAUDE.md is the trivial ``@AGENTS.md`` pointer and is left unstamped. The developer guide (``GETTING_STARTED.md``) is written separately, by - :func:`_write_developer_guide`, only on the coding-agent path — see - :func:`_route_build_method`. Exits non-zero on a read/write error. + :func:`_write_developer_guide`, only on the coding-agent path. Exits non-zero on a + read/write error. """ try: agents_src = (_AGENT_TEMPLATES / _AGENTS_FILE).read_text(encoding="utf-8") @@ -102,36 +168,28 @@ def _write_agent_guide(target_dir: Path, force: bool) -> None: try: target_dir.mkdir(parents=True, exist_ok=True) - - agents_path = target_dir / _AGENTS_FILE - # AGENTS.md is pipecat-owned: always (re)write it so re-running refreshes the guide. - refreshed = agents_path.exists() - agents_path.write_text(agents_src + _guide_footer(), encoding="utf-8") - console.print(f"[green]✔[/green] {'Refreshed' if refreshed else 'Wrote'} {agents_path}") - - claude_path = target_dir / _CLAUDE_FILE - # CLAUDE.md is the developer's entry point: never clobber an existing one without --force. - if claude_path.exists() and not force: - console.print( - f"[yellow]•[/yellow] Kept existing {claude_path} (use --force to overwrite)." - ) - else: - claude_path.write_text(claude_src, encoding="utf-8") - console.print(f"[green]✔[/green] Wrote {claude_path}") + statuses = [ + _write_guide_file( + target_dir / _AGENTS_FILE, agents_src, stamped=True, overwrite=overwrite + ), + _write_guide_file( + target_dir / _CLAUDE_FILE, claude_src, stamped=False, overwrite=overwrite + ), + ] + _print_refresh_summary(statuses) except OSError as e: console.print(f"[red]Error writing files:[/red] {e}") raise typer.Exit(1) -def _write_developer_guide(target_dir: Path) -> None: +def _write_developer_guide(target_dir: Path, overwrite: bool) -> None: """Write GETTING_STARTED.md — from-scratch developer onboarding — into a directory. Written only on the coding-agent path, where the developer is about to commission a build. It does *not* fit a project that's just been scaffolded (``Scaffold a runnable bot now`` or ``pipecat init quickstart``): there the bot already exists and the - scaffold's README is the start-here (it carries the Context Hub setup, the one piece a - just-scaffolded user still needs). Always (re)written, like AGENTS.md; exits non-zero - on a read/write error. + scaffold's README is the start-here. Follows the same preserve-by-default rule as the + rest of the guide; exits non-zero on a read/write error. """ try: src = (_AGENT_TEMPLATES / _GETTING_STARTED_FILE).read_text(encoding="utf-8") @@ -141,10 +199,9 @@ def _write_developer_guide(target_dir: Path) -> None: try: target_dir.mkdir(parents=True, exist_ok=True) - path = target_dir / _GETTING_STARTED_FILE - refreshed = path.exists() - path.write_text(src + _guide_footer(), encoding="utf-8") - console.print(f"[green]✔[/green] {'Refreshed' if refreshed else 'Wrote'} {path}") + _write_guide_file( + target_dir / _GETTING_STARTED_FILE, src, stamped=True, overwrite=overwrite + ) except OSError as e: console.print(f"[red]Error writing files:[/red] {e}") raise typer.Exit(1) @@ -171,7 +228,7 @@ def _print_ready(target_dir: Path) -> None: ) -def _route_build_method(target_dir: Path) -> None: +def _route_build_method(target_dir: Path, overwrite: bool) -> None: """Ask whether to build with a coding agent or scaffold a runnable bot now. The coding-agent path adds the developer guide (GETTING_STARTED.md) and points the @@ -182,7 +239,7 @@ def _route_build_method(target_dir: Path) -> None: """ already_scaffolded = (target_dir / "server").exists() if already_scaffolded or not _is_interactive(): - _write_developer_guide(target_dir) + _write_developer_guide(target_dir, overwrite) _print_ready(target_dir) return @@ -201,7 +258,7 @@ def _route_build_method(target_dir: Path) -> None: # `ask()` returns None on Ctrl-C / EOF — fall through to the safe agent path. if choice != "scaffold": - _write_developer_guide(target_dir) + _write_developer_guide(target_dir, overwrite) _print_ready(target_dir) return @@ -225,7 +282,7 @@ def _route_build_method(target_dir: Path) -> None: raise typer.Exit(1) -def _init_quickstart(force: bool) -> None: +def _init_quickstart(overwrite: bool) -> None: """``pipecat init quickstart``: the canned quickstart, with the coding-agent guide. Writes the agent guide into ``pipecat-quickstart/`` and scaffolds the quickstart @@ -240,7 +297,7 @@ def _init_quickstart(force: bool) -> None: target_dir = Path(_QUICKSTART_DIR) # AGENTS.md + CLAUDE.md only — _write_agent_guide never writes GETTING_STARTED.md. - _write_agent_guide(target_dir, force) + _write_agent_guide(target_dir, overwrite) try: scaffold_quickstart(dest=target_dir, in_place=True) except typer.Exit: @@ -294,10 +351,11 @@ def init_command( "are given. Use '.' for the current directory; created if missing. Pass 'quickstart' " "for the canned bot.", ), - force: bool = typer.Option( + overwrite: bool = typer.Option( False, - "--force", - help=f"Also overwrite an existing {_CLAUDE_FILE} ({_AGENTS_FILE} is always refreshed).", + "--overwrite-guide", + help=f"Overwrite existing guide files ({_AGENTS_FILE}, {_CLAUDE_FILE}, " + f"{_GETTING_STARTED_FILE}). By default existing files are kept.", ), name: str | None = typer.Option( None, @@ -450,6 +508,7 @@ def init_command( pipecat init . # set up the current directory pipecat init . --bot-type web -t daily -m cascade --stt deepgram_stt --llm openai_llm --tts cartesia_tts pipecat init my-bot --config project-config.json # scaffold from a config file + pipecat init my-bot --overwrite-guide # refresh existing guide files pipecat init --list-options # print valid service/transport values as JSON """ # `pipecat init quickstart`: scaffold the canned bot in-place, with the coding-agent guide. @@ -461,7 +520,7 @@ def init_command( "[yellow]Note:[/yellow] `quickstart` is a fixed preset; " "ignoring the scaffold options you passed." ) - return _init_quickstart(force) + return _init_quickstart(overwrite) scaffold_requested = _scaffold_requested( config=config, @@ -480,7 +539,7 @@ def init_command( return _scaffold_non_interactive( ctx, target=target, - force=force, + overwrite=overwrite, dry_run=dry_run, config=config, name=name, @@ -518,11 +577,11 @@ def init_command( console.print(f"[red]Error:[/red] {target_dir} exists and is not a directory.") raise typer.Exit(1) - _write_agent_guide(target_dir, force) - _route_build_method(target_dir) + _write_agent_guide(target_dir, overwrite) + _route_build_method(target_dir, overwrite) -def _scaffold_non_interactive(ctx: typer.Context, *, target, force, dry_run, config, **flags): +def _scaffold_non_interactive(ctx: typer.Context, *, target, overwrite, dry_run, config, **flags): """Write the agent guide and scaffold the project in-place from flags/a config file. The in-place sibling of the wizard path (:func:`_route_build_method`): same directory, @@ -548,7 +607,7 @@ def _scaffold_non_interactive(ctx: typer.Context, *, target, force, dry_run, con config=config, **flags, ) - _write_agent_guide(target_dir, force) + _write_agent_guide(target_dir, overwrite) generate_scaffold(project_config, dest=target_dir, in_place=True) except KeyboardInterrupt: console.print("\n[yellow]Project creation cancelled.[/yellow]") diff --git a/tests/cli/test_init_agent_ready.py b/tests/cli/test_init_agent_ready.py index de6ebbc85f9..eb7e7541018 100644 --- a/tests/cli/test_init_agent_ready.py +++ b/tests/cli/test_init_agent_ready.py @@ -40,14 +40,19 @@ def test_writes_all_files(self, tmp_path): # The terminal output points the developer at it. assert "GETTING_STARTED.md" in result.output - def test_getting_started_is_refreshed_and_stamped(self, tmp_path): - """GETTING_STARTED.md is pipecat-owned: rewritten on re-run, version-stamped.""" + def test_getting_started_preserved_then_refreshed_with_flag(self, tmp_path): + """GETTING_STARTED.md is kept on a plain re-run; --overwrite-guide refreshes it.""" import pipecat runner.invoke(app, ["init", str(tmp_path)]) - (tmp_path / "GETTING_STARTED.md").write_text("stale", encoding="utf-8") + (tmp_path / "GETTING_STARTED.md").write_text("my notes", encoding="utf-8") + # Plain re-run preserves the developer's file. result = runner.invoke(app, ["init", str(tmp_path)]) assert result.exit_code == 0, result.output + assert (tmp_path / "GETTING_STARTED.md").read_text(encoding="utf-8") == "my notes" + # --overwrite-guide rewrites it from the template, version-stamped. + result = runner.invoke(app, ["init", str(tmp_path), "--overwrite-guide"]) + assert result.exit_code == 0, result.output text = (tmp_path / "GETTING_STARTED.md").read_text(encoding="utf-8") assert "first prompt" in text assert f"pipecat-ai {pipecat.__version__}" in text @@ -62,7 +67,7 @@ def test_agents_file_carries_version_stamp(self, tmp_path): text = (tmp_path / "AGENTS.md").read_text(encoding="utf-8") assert text.rstrip().endswith("-->") assert f"pipecat-ai {pipecat.__version__}" in text - assert "Re-run `pipecat init` to refresh" in text + assert "Run `pipecat init --overwrite-guide` to refresh" in text # The bundled template itself stays unstamped — the version is known only at write time. assert "Generated by" not in (AGENT_TEMPLATES / "AGENTS.md").read_text(encoding="utf-8") @@ -85,21 +90,33 @@ def test_creates_missing_target_dir(self, tmp_path): assert result.exit_code == 0, result.output assert (target / "AGENTS.md").exists() - def test_rerun_refreshes_agents_keeps_claude(self, tmp_path): + def test_rerun_preserves_existing_guides(self, tmp_path): runner.invoke(app, ["init", str(tmp_path)]) - (tmp_path / "AGENTS.md").write_text("stale", encoding="utf-8") + (tmp_path / "AGENTS.md").write_text("my agents", encoding="utf-8") (tmp_path / "CLAUDE.md").write_text("# my own claude config", encoding="utf-8") result = runner.invoke(app, ["init", str(tmp_path)]) assert result.exit_code == 0, result.output - # AGENTS.md is pipecat-owned → refreshed; CLAUDE.md is the dev's → preserved. - assert "pipecat init" in (tmp_path / "AGENTS.md").read_text(encoding="utf-8") + # Preserve-by-default: a plain re-run clobbers neither file. + assert (tmp_path / "AGENTS.md").read_text(encoding="utf-8") == "my agents" assert (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") == "# my own claude config" - def test_force_overwrites_claude(self, tmp_path): + def test_rerun_summary_when_all_kept(self, tmp_path): + runner.invoke(app, ["init", str(tmp_path)]) + # Second run: everything is present and current, so nothing is (re)written. + result = runner.invoke(app, ["init", str(tmp_path)]) + assert result.exit_code == 0, result.output + assert "Kept" in result.output + assert "already in place" in result.output + assert "overwrite-guide" in result.output + + def test_overwrite_guide_overwrites_all(self, tmp_path): runner.invoke(app, ["init", str(tmp_path)]) + (tmp_path / "AGENTS.md").write_text("stale agents", encoding="utf-8") (tmp_path / "CLAUDE.md").write_text("# my own claude config", encoding="utf-8") - result = runner.invoke(app, ["init", str(tmp_path), "--force"]) + result = runner.invoke(app, ["init", str(tmp_path), "--overwrite-guide"]) assert result.exit_code == 0, result.output + # Both are replaced with the current templates. + assert "pipecat init" in (tmp_path / "AGENTS.md").read_text(encoding="utf-8") assert (tmp_path / "CLAUDE.md").read_text(encoding="utf-8").strip() == "@AGENTS.md" def test_scaffold_flags_build_in_place(self, tmp_path): @@ -130,6 +147,18 @@ def test_scaffold_flags_build_in_place(self, tmp_path): # The scaffold path skips the from-scratch developer guide. assert not (tmp_path / "GETTING_STARTED.md").exists() + def test_stale_guide_nudges(self, tmp_path): + runner.invoke(app, ["init", str(tmp_path)]) + # Simulate a guide written by an older Pipecat via an old version stamp. + stale = "# old guide\n\n" + (tmp_path / "AGENTS.md").write_text(stale, encoding="utf-8") + result = runner.invoke(app, ["init", str(tmp_path)]) + assert result.exit_code == 0, result.output + # Kept as-is, but the developer is told it's stale and how to refresh. + assert (tmp_path / "AGENTS.md").read_text(encoding="utf-8") == stale + assert "0.0.1" in result.output + assert "overwrite-guide" in result.output + def test_quickstart_scaffolds_and_writes_guide(self, tmp_path, monkeypatch): # `init quickstart` is the human front door for the canned bot: it scaffolds the # quickstart in-place AND drops the agent guide, all in ./pipecat-quickstart. From adcb954c6fadee6f9c15f64b12a909518fc44a77 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Tue, 23 Jun 2026 21:48:48 -0400 Subject: [PATCH 11/14] Add changelog for init preserve-guides change --- changelog/4869.changed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/4869.changed.md diff --git a/changelog/4869.changed.md b/changelog/4869.changed.md new file mode 100644 index 00000000000..67a828d7a96 --- /dev/null +++ b/changelog/4869.changed.md @@ -0,0 +1 @@ +- `pipecat init` now keeps existing `AGENTS.md`, `CLAUDE.md`, and `GETTING_STARTED.md` files instead of overwriting them on re-run, and points out when a guide was written by an older Pipecat version. Pass the new `--overwrite-guide` flag (renamed from `--force`, now covering all three files) to refresh them. From 19d9a1c055fc218eb38dd0124aa19eda5291545f Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Tue, 23 Jun 2026 22:09:16 -0400 Subject: [PATCH 12/14] feat(cli): offer to refresh a stale guide on an interactive re-run When `pipecat init` is re-run in a terminal and finds a guide written by an older Pipecat, it now offers to refresh it ("Refresh the guide files now? [Y/n]") rather than only printing a nudge that's easy to miss before the build-method prompt. Re-running signals intent, so a one-keystroke refresh is the expected payoff. Non-interactive runs keep the printed `--overwrite-guide` nudge. --- changelog/4869.changed.md | 2 +- src/pipecat/cli/commands/init.py | 47 +++++++++++++++++++++++++----- tests/cli/test_init_agent_ready.py | 39 +++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 9 deletions(-) diff --git a/changelog/4869.changed.md b/changelog/4869.changed.md index 67a828d7a96..6018814728c 100644 --- a/changelog/4869.changed.md +++ b/changelog/4869.changed.md @@ -1 +1 @@ -- `pipecat init` now keeps existing `AGENTS.md`, `CLAUDE.md`, and `GETTING_STARTED.md` files instead of overwriting them on re-run, and points out when a guide was written by an older Pipecat version. Pass the new `--overwrite-guide` flag (renamed from `--force`, now covering all three files) to refresh them. +- ⚠️ `pipecat init` now keeps existing `AGENTS.md`, `CLAUDE.md`, and `GETTING_STARTED.md` files instead of overwriting them on re-run. When a guide was written by an older Pipecat version, an interactive run offers to refresh it; otherwise pass the new `--overwrite-guide` flag (renamed from `--force`, now covering all three files) to refresh them. diff --git a/src/pipecat/cli/commands/init.py b/src/pipecat/cli/commands/init.py index 32813d12f96..a04deec0407 100644 --- a/src/pipecat/cli/commands/init.py +++ b/src/pipecat/cli/commands/init.py @@ -147,7 +147,31 @@ def _print_refresh_summary(statuses: list[str]) -> None: ) -def _write_agent_guide(target_dir: Path, overwrite: bool) -> None: +def _offer_refresh(target_dir: Path) -> bool: + """Stale guide + interactive terminal: offer to refresh it now instead of just warning. + + Someone re-running ``init`` on a project whose guide predates their current Pipecat + almost always wants the new guide, so turn the passive nudge into a one-keystroke + action. Returns True if the guide was refreshed (the caller then refreshes the rest of + the guide too). Non-interactive callers don't reach here — they get the printed nudge. + """ + import questionary + + from pipecat.cli.prompts.questions import custom_style + + refresh = questionary.confirm( + "A guide file is from an older Pipecat. Refresh the guide files now?", + default=True, + style=custom_style, + ).ask() + # `ask()` returns None on Ctrl-C / EOF — treat anything but an explicit yes as "keep". + if refresh: + _write_agent_guide(target_dir, overwrite=True) + return True + return False + + +def _write_agent_guide(target_dir: Path, overwrite: bool) -> list[str]: """Write the core agent guide — AGENTS.md and CLAUDE.md — into a directory. Wanted on *every* path (coding agent, scaffold-now, quickstart), so they're written @@ -155,9 +179,10 @@ def _write_agent_guide(target_dir: Path, overwrite: bool) -> None: kept unless ``overwrite``. AGENTS.md carries a provenance footer (so a re-run can spot a stale guide); CLAUDE.md is the trivial ``@AGENTS.md`` pointer and is left unstamped. - The developer guide (``GETTING_STARTED.md``) is written separately, by - :func:`_write_developer_guide`, only on the coding-agent path. Exits non-zero on a - read/write error. + Returns the per-file statuses so the caller can follow up — a passive refresh nudge, + or an interactive offer to refresh. The developer guide (``GETTING_STARTED.md``) is + written separately, by :func:`_write_developer_guide`, only on the coding-agent path. + Exits non-zero on a read/write error. """ try: agents_src = (_AGENT_TEMPLATES / _AGENTS_FILE).read_text(encoding="utf-8") @@ -168,7 +193,7 @@ def _write_agent_guide(target_dir: Path, overwrite: bool) -> None: try: target_dir.mkdir(parents=True, exist_ok=True) - statuses = [ + return [ _write_guide_file( target_dir / _AGENTS_FILE, agents_src, stamped=True, overwrite=overwrite ), @@ -176,7 +201,6 @@ def _write_agent_guide(target_dir: Path, overwrite: bool) -> None: target_dir / _CLAUDE_FILE, claude_src, stamped=False, overwrite=overwrite ), ] - _print_refresh_summary(statuses) except OSError as e: console.print(f"[red]Error writing files:[/red] {e}") raise typer.Exit(1) @@ -297,7 +321,8 @@ def _init_quickstart(overwrite: bool) -> None: target_dir = Path(_QUICKSTART_DIR) # AGENTS.md + CLAUDE.md only — _write_agent_guide never writes GETTING_STARTED.md. - _write_agent_guide(target_dir, overwrite) + # Quickstart is a fixed, non-interactive preset, so a stale guide just gets the nudge. + _print_refresh_summary(_write_agent_guide(target_dir, overwrite)) try: scaffold_quickstart(dest=target_dir, in_place=True) except typer.Exit: @@ -577,7 +602,13 @@ def init_command( console.print(f"[red]Error:[/red] {target_dir} exists and is not a directory.") raise typer.Exit(1) - _write_agent_guide(target_dir, overwrite) + statuses = _write_agent_guide(target_dir, overwrite) + # A re-run on a guide from an older Pipecat: interactively offer to refresh it now + # (the expected payoff of re-running), otherwise fall back to the printed nudge. + if not overwrite and "kept-stale" in statuses and _is_interactive(): + overwrite = _offer_refresh(target_dir) + else: + _print_refresh_summary(statuses) _route_build_method(target_dir, overwrite) diff --git a/tests/cli/test_init_agent_ready.py b/tests/cli/test_init_agent_ready.py index eb7e7541018..c22c72f97b0 100644 --- a/tests/cli/test_init_agent_ready.py +++ b/tests/cli/test_init_agent_ready.py @@ -21,6 +21,16 @@ AGENT_TEMPLATES = Path(pipecat.cli.__file__).parent / "agent_templates" +class _StubAsk: + """Stand-in for a questionary prompt object: ``.ask()`` returns a preset value.""" + + def __init__(self, value): + self._value = value + + def ask(self): + return self._value + + class TestInitAgentReady: """Behavior of the `pipecat init` file-drop command.""" @@ -159,6 +169,35 @@ def test_stale_guide_nudges(self, tmp_path): assert "0.0.1" in result.output assert "overwrite-guide" in result.output + def test_stale_guide_offers_refresh_interactively(self, tmp_path, monkeypatch): + runner.invoke(app, ["init", str(tmp_path)]) + stale = "# old\n\n" + (tmp_path / "AGENTS.md").write_text(stale, encoding="utf-8") + # Force-interactive, confirm the refresh, then pick the default build method. + monkeypatch.setattr(init_mod, "_is_interactive", lambda: True) + import questionary + + monkeypatch.setattr(questionary, "confirm", lambda *a, **k: _StubAsk(True)) + monkeypatch.setattr(questionary, "select", lambda *a, **k: _StubAsk("agent")) + result = runner.invoke(app, ["init", str(tmp_path)]) + assert result.exit_code == 0, result.output + # Confirming overwrote the stale guide with the current template. + assert "pipecat init" in (tmp_path / "AGENTS.md").read_text(encoding="utf-8") + + def test_stale_guide_refresh_declined_keeps_file(self, tmp_path, monkeypatch): + runner.invoke(app, ["init", str(tmp_path)]) + stale = "# old\n\n" + (tmp_path / "AGENTS.md").write_text(stale, encoding="utf-8") + monkeypatch.setattr(init_mod, "_is_interactive", lambda: True) + import questionary + + monkeypatch.setattr(questionary, "confirm", lambda *a, **k: _StubAsk(False)) + monkeypatch.setattr(questionary, "select", lambda *a, **k: _StubAsk("agent")) + result = runner.invoke(app, ["init", str(tmp_path)]) + assert result.exit_code == 0, result.output + # Declining leaves the developer's file untouched. + assert (tmp_path / "AGENTS.md").read_text(encoding="utf-8") == stale + def test_quickstart_scaffolds_and_writes_guide(self, tmp_path, monkeypatch): # `init quickstart` is the human front door for the canned bot: it scaffolds the # quickstart in-place AND drops the agent guide, all in ./pipecat-quickstart. From 4ee9ea1d2e92d420d155a84e21cd62ff20652f90 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Wed, 24 Jun 2026 17:36:43 -0400 Subject: [PATCH 13/14] test(cli): keep in-place guide preserved under preserve-by-default Integrating preserve-by-default (this branch) with init's scaffold path (base): scaffolding over an existing guide now keeps it instead of refreshing it, so update the in-place test to assert preservation. --- tests/cli/test_init_in_place.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/tests/cli/test_init_in_place.py b/tests/cli/test_init_in_place.py index 2a08bc92160..f4922f99ffd 100644 --- a/tests/cli/test_init_in_place.py +++ b/tests/cli/test_init_in_place.py @@ -77,21 +77,18 @@ def test_init_in_place_over_agent_guide(tmp_path): """The full agent loop: scaffold in-place into a dir already holding the guide. Mirrors what `pipecat init` leaves behind before the agent re-runs it to scaffold. - AGENTS.md is pipecat-owned and gets refreshed; the developer's CLAUDE.md and any - GETTING_STARTED.md are left untouched. + Preserve-by-default: the existing guide files are all kept as-is, and the bot is + scaffolded alongside them (refresh the guide explicitly with --overwrite-guide). """ - (tmp_path / "AGENTS.md").write_text("# stale guide", encoding="utf-8") + (tmp_path / "AGENTS.md").write_text("# my guide", encoding="utf-8") (tmp_path / "GETTING_STARTED.md").write_text("# dev", encoding="utf-8") (tmp_path / "CLAUDE.md").write_text("@AGENTS.md", encoding="utf-8") result = runner.invoke(app, ["init", str(tmp_path), "--name", "demo", *SERVICE_FLAGS]) assert result.exit_code == 0, result.output assert (tmp_path / "server" / "bot.py").exists() - # AGENTS.md is refreshed from the bundled template (no longer the stale stub). - agents = (tmp_path / "AGENTS.md").read_text(encoding="utf-8") - assert agents != "# stale guide" - assert "pipecat init" in agents - # The developer's files survive. + # Existing guide files survive untouched. + assert (tmp_path / "AGENTS.md").read_text(encoding="utf-8") == "# my guide" assert (tmp_path / "GETTING_STARTED.md").read_text(encoding="utf-8") == "# dev" assert (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") == "@AGENTS.md" From e6a6d448837bce796772dc9da0ec07b20132b43b Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Thu, 25 Jun 2026 15:30:21 -0400 Subject: [PATCH 14/14] refactor(cli): rename overwrite param to overwrite_guide outside _write helpers --- src/pipecat/cli/commands/init.py | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/src/pipecat/cli/commands/init.py b/src/pipecat/cli/commands/init.py index a04deec0407..3aa43e9c809 100644 --- a/src/pipecat/cli/commands/init.py +++ b/src/pipecat/cli/commands/init.py @@ -252,7 +252,7 @@ def _print_ready(target_dir: Path) -> None: ) -def _route_build_method(target_dir: Path, overwrite: bool) -> None: +def _route_build_method(target_dir: Path, overwrite_guide: bool) -> None: """Ask whether to build with a coding agent or scaffold a runnable bot now. The coding-agent path adds the developer guide (GETTING_STARTED.md) and points the @@ -263,7 +263,7 @@ def _route_build_method(target_dir: Path, overwrite: bool) -> None: """ already_scaffolded = (target_dir / "server").exists() if already_scaffolded or not _is_interactive(): - _write_developer_guide(target_dir, overwrite) + _write_developer_guide(target_dir, overwrite_guide) _print_ready(target_dir) return @@ -282,7 +282,7 @@ def _route_build_method(target_dir: Path, overwrite: bool) -> None: # `ask()` returns None on Ctrl-C / EOF — fall through to the safe agent path. if choice != "scaffold": - _write_developer_guide(target_dir, overwrite) + _write_developer_guide(target_dir, overwrite_guide) _print_ready(target_dir) return @@ -306,7 +306,7 @@ def _route_build_method(target_dir: Path, overwrite: bool) -> None: raise typer.Exit(1) -def _init_quickstart(overwrite: bool) -> None: +def _init_quickstart(overwrite_guide: bool) -> None: """``pipecat init quickstart``: the canned quickstart, with the coding-agent guide. Writes the agent guide into ``pipecat-quickstart/`` and scaffolds the quickstart @@ -322,7 +322,7 @@ def _init_quickstart(overwrite: bool) -> None: target_dir = Path(_QUICKSTART_DIR) # AGENTS.md + CLAUDE.md only — _write_agent_guide never writes GETTING_STARTED.md. # Quickstart is a fixed, non-interactive preset, so a stale guide just gets the nudge. - _print_refresh_summary(_write_agent_guide(target_dir, overwrite)) + _print_refresh_summary(_write_agent_guide(target_dir, overwrite_guide)) try: scaffold_quickstart(dest=target_dir, in_place=True) except typer.Exit: @@ -376,7 +376,7 @@ def init_command( "are given. Use '.' for the current directory; created if missing. Pass 'quickstart' " "for the canned bot.", ), - overwrite: bool = typer.Option( + overwrite_guide: bool = typer.Option( False, "--overwrite-guide", help=f"Overwrite existing guide files ({_AGENTS_FILE}, {_CLAUDE_FILE}, " @@ -545,7 +545,7 @@ def init_command( "[yellow]Note:[/yellow] `quickstart` is a fixed preset; " "ignoring the scaffold options you passed." ) - return _init_quickstart(overwrite) + return _init_quickstart(overwrite_guide) scaffold_requested = _scaffold_requested( config=config, @@ -564,7 +564,7 @@ def init_command( return _scaffold_non_interactive( ctx, target=target, - overwrite=overwrite, + overwrite_guide=overwrite_guide, dry_run=dry_run, config=config, name=name, @@ -602,17 +602,19 @@ def init_command( console.print(f"[red]Error:[/red] {target_dir} exists and is not a directory.") raise typer.Exit(1) - statuses = _write_agent_guide(target_dir, overwrite) + statuses = _write_agent_guide(target_dir, overwrite_guide) # A re-run on a guide from an older Pipecat: interactively offer to refresh it now # (the expected payoff of re-running), otherwise fall back to the printed nudge. - if not overwrite and "kept-stale" in statuses and _is_interactive(): - overwrite = _offer_refresh(target_dir) + if not overwrite_guide and "kept-stale" in statuses and _is_interactive(): + overwrite_guide = _offer_refresh(target_dir) else: _print_refresh_summary(statuses) - _route_build_method(target_dir, overwrite) + _route_build_method(target_dir, overwrite_guide) -def _scaffold_non_interactive(ctx: typer.Context, *, target, overwrite, dry_run, config, **flags): +def _scaffold_non_interactive( + ctx: typer.Context, *, target, overwrite_guide, dry_run, config, **flags +): """Write the agent guide and scaffold the project in-place from flags/a config file. The in-place sibling of the wizard path (:func:`_route_build_method`): same directory, @@ -638,7 +640,7 @@ def _scaffold_non_interactive(ctx: typer.Context, *, target, overwrite, dry_run, config=config, **flags, ) - _write_agent_guide(target_dir, overwrite) + _write_agent_guide(target_dir, overwrite_guide) generate_scaffold(project_config, dest=target_dir, in_place=True) except KeyboardInterrupt: console.print("\n[yellow]Project creation cancelled.[/yellow]")