Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1060.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Generated projects include their declared README, exact interpreter metadata, bounded Chirp dependencies with profile-specific extras, and wheel modules and namespaced assets that resolve outside the source checkout. Legacy starters use secure_stack, the demo account is restricted to development, and shell, SSE, and streaming starters render their intended content. Generated guidance explains reviewing and committing a uv lock before locked installs; automatic lock generation remains follow-up work.
81 changes: 74 additions & 7 deletions src/chirp/cli/_new.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
"""

import argparse
import json
import platform
import re
import sys
from pathlib import Path

Expand Down Expand Up @@ -77,6 +80,14 @@
V2_STYLE_CHIRPUI_CSS,
V2_TEST_APP_PY,
)
from chirp.cli.templates.scaffold import PROJECT_PATHS_PY, PROJECT_README
from chirp.cli.templates.shell import (
SHELL_APP_CHIRPUI_PY,
SHELL_ITEMS_PAGE_CHIRPUI_HTML,
SHELL_ITEMS_PAGE_CHIRPUI_PY,
SHELL_PAGE_CHIRPUI_HTML,
SHELL_PAGE_CHIRPUI_PY,
)


def _has_chirpui() -> bool:
Expand Down Expand Up @@ -118,6 +129,53 @@ def _write_scaffold_extras(project_dir: Path, name: str) -> None:
(mig / "README.md").write_text(MIGRATIONS_README, encoding="utf-8")


def _finish_project_metadata(project_dir: Path, args: argparse.Namespace) -> None:
"""Make each profile's flat source tree explicit to the build backend."""
(project_dir / "README.md").write_text(PROJECT_README.format(name=args.name), encoding="utf-8")
(project_dir / ".python-version").write_text(platform.python_version() + "\n", encoding="utf-8")
(project_dir / "project_paths.py").write_text(
PROJECT_PATHS_PY.format(
name=args.name, asset_dir="pages" if (project_dir / "pages").exists() else "templates"
),
encoding="utf-8",
)
metadata = project_dir / "pyproject.toml"
source = metadata.read_text(encoding="utf-8")
extras = ["sessions"]
if (project_dir / "models.py").exists():
extras.extend(["auth", "forms"])
if getattr(args, "ai", False):
extras.extend(["ai", "forms"])
if getattr(args, "stream", False):
extras.append("forms")
if getattr(args, "skill", False):
extras.append("skill")
if getattr(args, "with_chirpui", False):
extras.append("ui")
source = re.sub(r"bengal-chirp\[[^]]+\]", "bengal-chirp[" + ",".join(extras) + "]", source)
source += (
'\n[dependency-groups]\ndev = ["pytest>=8,<10", "pytest-asyncio>=1,<2", "httpx>=0.27,<1"]\n'
)
source += '\n[tool.pytest.ini_options]\nasyncio_mode = "auto"\n'
modules = sorted(path.stem for path in project_dir.glob("*.py"))
source += "\n[tool.setuptools]\npackages = []\npy-modules = " + json.dumps(modules) + "\n"
source += "\n[tool.setuptools.data-files]\n"
for directory in sorted(project_dir.rglob("*")):
if not directory.is_dir() or directory.parts[-1] == "tests":
continue
files = sorted(
str(path.relative_to(project_dir)) for path in directory.iterdir() if path.is_file()
)
if files:
source += (
json.dumps("share/" + args.name + "/" + str(directory.relative_to(project_dir)))
+ " = "
+ json.dumps(files)
+ "\n"
)
Comment on lines +163 to +175
metadata.write_text(source, encoding="utf-8")


def create_project(args: argparse.Namespace) -> None:
"""Generate a new chirp project directory.

Expand Down Expand Up @@ -163,6 +221,8 @@ def create_project(args: argparse.Namespace) -> None:
with_chirpui=getattr(args, "with_chirpui", False),
)

_finish_project_metadata(project_dir, args)

print(f"Created project '{args.name}'")
if getattr(args, "skill", False):
print()
Expand Down Expand Up @@ -262,25 +322,32 @@ def _create_shell(project_dir: Path, name: str, *, with_chirpui: bool) -> None:
pages_dir.mkdir(parents=True)
static_dir.mkdir(parents=True)

(project_dir / "app.py").write_text(SHELL_APP_PY)
(project_dir / "app.py").write_text(SHELL_APP_CHIRPUI_PY if use_chirpui else SHELL_APP_PY)
(pages_dir / "_context.py").write_text(SHELL_CONTEXT_PY)
(pages_dir / "_layout.html").write_text(
SHELL_LAYOUT_CHIRPUI_HTML if use_chirpui else SHELL_LAYOUT_HTML,
)
(pages_dir / "page.py").write_text(SHELL_PAGE_PY)
(pages_dir / "page.html").write_text(SHELL_PAGE_HTML)
(pages_dir / "page.py").write_text(SHELL_PAGE_CHIRPUI_PY if use_chirpui else SHELL_PAGE_PY)
(pages_dir / "page.html").write_text(
SHELL_PAGE_CHIRPUI_HTML if use_chirpui else SHELL_PAGE_HTML
)

items_dir = pages_dir / "items"
items_dir.mkdir()
(items_dir / "_layout.html").write_text(SHELL_ITEMS_LAYOUT_HTML)
(items_dir / "page.py").write_text(SHELL_ITEMS_PAGE_PY)
(items_dir / "page.html").write_text(SHELL_ITEMS_PAGE_HTML)
if not use_chirpui:
(items_dir / "_layout.html").write_text(SHELL_ITEMS_LAYOUT_HTML)
(items_dir / "page.py").write_text(
SHELL_ITEMS_PAGE_CHIRPUI_PY if use_chirpui else SHELL_ITEMS_PAGE_PY
)
(items_dir / "page.html").write_text(
SHELL_ITEMS_PAGE_CHIRPUI_HTML if use_chirpui else SHELL_ITEMS_PAGE_HTML
)

(project_dir / "theme.py").write_text(THEME_PY, encoding="utf-8")
_write_scaffold_extras(project_dir, name)
if use_chirpui:
(static_dir / "theme.css").write_text(THEME_CSS_STUB, encoding="utf-8")
else:
(project_dir / "theme.py").write_text(THEME_PY, encoding="utf-8")
_write_app_theme_assets(static_dir)


Expand Down
24 changes: 14 additions & 10 deletions src/chirp/cli/templates/ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@
\"\"\"AI chat scaffold — tools, SSE activity feed, secure stack.\"\"\"

import os
from pathlib import Path

from project_paths import ROOT

from chirp import App, AppConfig, EventStream, Fragment, Request, Template, secure_stack
from chirp.contracts import SSEContract, contract
from chirp.ai import AgentRun, InMemoryConversationStore, LLM

TEMPLATES_DIR = Path(__file__).parent / \"templates\"
TEMPLATES_DIR = ROOT / \"templates\"

app = App(AppConfig.from_env(template_dir=TEMPLATES_DIR, worker_mode=\"async\"))
app = App(AppConfig.from_env(htmx=True, csp_nonce_enabled=True, template_dir=TEMPLATES_DIR, worker_mode=\"async\"))
for middleware in secure_stack(app.config):
app.add_middleware(middleware)

Expand Down Expand Up @@ -57,6 +59,7 @@ async def post_chat(request: Request):


@app.route(\"/chat/stream\", referenced=True)
@contract(returns=SSEContract(event_types=frozenset({\"stream_token\"})))
def chat_stream():
async def generate():
global _pending_user
Expand All @@ -69,7 +72,7 @@ async def generate():

async for event in agent.stream(user_message):
if isinstance(event, TokenEvent):
yield Fragment(\"chat.html\", \"stream_token\", token=event.text)
yield Fragment(\"chat.html\", \"stream_token\", text_chunk=event.text)

return EventStream(generate())

Expand Down Expand Up @@ -97,28 +100,29 @@ async def generate():
<p><strong>{{ msg.role }}:</strong> {{ msg.content }}</p>
{% end %}
</div>
<form hx-post=\"/chat\" hx-target=\"#chat-input\" hx-swap=\"outerHTML\">
<form hx-post=\"/chat\" hx-target=\"#stream-region\" hx-select=\"unset\" hx-swap=\"innerHTML\">
{{ csrf_field() }}
<div id=\"chat-input\">
<input name=\"message\" placeholder=\"Ask anything...\" autocomplete=\"off\" />
<input name=\"message\" aria-label=\"Message\" placeholder=\"Ask anything...\" autocomplete=\"off\" />
<button type=\"submit\">Send</button>
</div>
</form>
<div id=\"stream-region\"></div>
{% end %}

{% block stream_start %}
<div id=\"stream-region\" hx-ext=\"sse\" sse-connect=\"/chat/stream\" sse-swap=\"stream_token\">
<div hx-ext=\"sse\" sse-connect=\"/chat/stream\" hx-target=\"this\" hx-disinherit=\"hx-target hx-swap\">
<p><strong>user:</strong> {{ user_content }}</p>
<p id=\"assistant-stream\"></p>
<p id=\"assistant-stream\" sse-swap=\"stream_token\" hx-target=\"this\" hx-swap=\"beforeend\"></p>
</div>
{% end %}

{% block stream_token %}
<p id=\"assistant-stream\">{{ token }}</p>
<span>{{ text_chunk }}</span>
{% end %}

{% block activity_row %}
<div class=\"activity\">{{ event.tool_name }}({{ event.arguments | format_json }})</div>
<div class=\"activity\">{{ event.tool_name }}({{ event.arguments | tojson }})</div>
{% end %}

{% block empty_response %}
Expand Down
24 changes: 8 additions & 16 deletions src/chirp/cli/templates/minimal.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
MINIMAL_APP_PY = """\
import os

from project_paths import ROOT

from chirp import secure_stack

from chirp import App, AppConfig, Request, Template
from chirp.middleware.csrf import CSRFConfig, CSRFMiddleware
from chirp.middleware.security_headers import SecurityHeadersMiddleware
from chirp.middleware.sessions import SessionConfig, SessionMiddleware

_DEFAULT_SECRET = "change-me-before-deploying"
_secret = os.environ.get("CHIRP_SECRET_KEY", _DEFAULT_SECRET)
Expand All @@ -23,7 +24,9 @@
)

config = AppConfig.from_env(
csp_nonce_enabled=True,
secret_key=_secret,
template_dir=ROOT / "templates",
env=_env,
debug=_debug,
)
Expand All @@ -36,19 +39,8 @@
)
raise RuntimeError(msg)

app.add_middleware(
SessionMiddleware(
SessionConfig(
secret_key=config.secret_key,
# secure defaults to "auto": Secure cookies in production/staging
# (resolved from AppConfig.env at freeze), off in local dev.
httponly=True,
samesite="lax",
)
)
)
app.add_middleware(CSRFMiddleware(CSRFConfig()))
app.add_middleware(SecurityHeadersMiddleware())
for middleware in secure_stack(config):
app.add_middleware(middleware)


@app.route("/")
Expand Down
57 changes: 55 additions & 2 deletions src/chirp/cli/templates/scaffold.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,9 @@
version = "0.1.0"
description = "Generated by chirp new"
readme = "README.md"
requires-python = ">=3.14"
requires-python = ">=3.14,<3.15"
dependencies = [
"bengal-chirp>=0.10.0",
"bengal-chirp[sessions]>=0.10.0,<0.11",
# SessionMiddleware's default CookieSessionStore signs cookies with
# itsdangerous; every scaffold wires the Session/CSRF/SecurityHeaders stack.
"itsdangerous>=2.2.0",
Expand Down Expand Up @@ -100,3 +100,56 @@
THEME_CSS_STUB = """/* Optional app-owned ChirpUI token overrides.
Loaded after /static/themes/app-theme-starter.css. See chirp-ui docs/APP-THEME.md. */
"""

PROJECT_README = """\
# {name}

Generated by `chirp new`. Use Python 3.14 (the generation interpreter's exact
version is recorded in `.python-version`).

## Install and lock

Run `uv lock` once, review and commit `uv.lock` together with `.python-version`
and `pyproject.toml`. Subsequent installs use `uv sync --locked`. Scaffolding
itself is offline and does not resolve or generate a dependency lock.
The Chirp dependency is bounded to the current minor release; review framework
release notes and regenerate the lock when changing that range.

## Develop and verify

```sh
uv sync --locked
uv run chirp dev app:app
uv run chirp check app:app
uv run python -m pytest
uv build
```

Run these commands from this project directory. Installed wheels resolve their
packaged route, template and static assets from the installation prefix. Profiles without a tests directory can omit the pytest command.

## Deploy

Set `CHIRP_ENV=production` and a strong random `CHIRP_SECRET_KEY`, configure
`CHIRP_ALLOWED_HOSTS`. After confirming HTTPS-only access, set
`CHIRP_STRICT_TRANSPORT_SECURITY=max-age=63072000; includeSubDomains`; this
pins browsers to HTTPS, so enable it deliberately. Then run `uv run chirp check app:app --deploy` before
`uv run python app.py`. The default scaffold's admin/password account exists
only in development: connect an application-owned user store before deployment.
The AI profile also requires provider credentials; the skill profile needs a
persistent `CHIRP_SKILL_PRIVATE_KEY` for stable signatures across restarts.

Dependency and security follow-up: [#856](https://github.com/lbliii/chirp/issues/856),
[#899](https://github.com/lbliii/chirp/issues/899), and
[#1060](https://github.com/lbliii/chirp/issues/1060).
"""

PROJECT_PATHS_PY = """\
\"\"\"Locate app assets in a checkout or an installed wheel.\"\"\"

import sys
from pathlib import Path

_source = Path(__file__).resolve().parent
ROOT = _source if (_source / "{asset_dir}").is_dir() else Path(sys.prefix) / "share" / "{name}"
"""
Loading
Loading