diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..3547046
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,52 @@
+name: CI
+
+on:
+ push:
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ backend:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: backend
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+
+ - name: Install dependencies
+ run: pip install -r requirements.txt
+
+ - name: Pytest
+ env:
+ PYTHONPATH: .
+ run: pytest -q
+
+ extension:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: extension
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: npm
+ cache-dependency-path: extension/package-lock.json
+
+ - name: Install and compile
+ run: |
+ npm ci
+ npm run compile
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..af42c6e
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,98 @@
+name: Release
+
+on:
+ workflow_dispatch:
+ push:
+ tags:
+ - 'v*'
+
+permissions:
+ contents: write
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: npm
+ cache-dependency-path: extension/package-lock.json
+
+ - name: Install, compile, package
+ working-directory: extension
+ run: |
+ npm ci
+ npm run compile
+ npx --yes @vscode/vsce package
+
+ - name: Upload VSIX artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: creer-vsix
+ path: extension/*.vsix
+ if-no-files-found: error
+
+ github-release:
+ needs: [build]
+ runs-on: ubuntu-latest
+ if: startsWith(github.ref, 'refs/tags/')
+ steps:
+ - name: Download VSIX artifact
+ uses: actions/download-artifact@v4
+ with:
+ name: creer-vsix
+ path: dist
+
+ - name: Create GitHub Release
+ uses: softprops/action-gh-release@v2
+ with:
+ files: dist/*.vsix
+ generate_release_notes: true
+ fail_on_unmatched_files: true
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ publish:
+ needs: [build]
+ runs-on: ubuntu-latest
+ steps:
+ - name: Download VSIX artifact
+ uses: actions/download-artifact@v4
+ with:
+ name: creer-vsix
+ path: dist
+
+ - name: Publish (Marketplace / Open VSX when tokens present)
+ env:
+ VSCE_PAT: ${{ secrets.VSCE_PAT }}
+ OVSX_PAT: ${{ secrets.OVSX_PAT }}
+ run: |
+ set -euo pipefail
+ cd dist
+ shopt -s nullglob
+ vsix=(*.vsix)
+ if [ ${#vsix[@]} -eq 0 ]; then
+ echo "No .vsix artifact found"
+ exit 1
+ fi
+ echo "Packaged: ${vsix[*]}"
+
+ published=false
+ if [ -n "${VSCE_PAT:-}" ]; then
+ echo "Publishing to VS Marketplace…"
+ npx --yes @vscode/vsce publish --packagePath "${vsix[0]}" -p "$VSCE_PAT"
+ published=true
+ fi
+ if [ -n "${OVSX_PAT:-}" ]; then
+ echo "Publishing to Open VSX…"
+ npx --yes ovsx publish "${vsix[0]}" -p "$OVSX_PAT"
+ published=true
+ fi
+ if [ "$published" = false ]; then
+ echo "No publish tokens configured — artifact only"
+ fi
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..25ec95c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+.DS_Store
+*.log
+.env
+**/venv/
+**/__pycache__/
+**/node_modules/
+**/out/
+*.pyc
+.pytest_cache/
diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 0000000..db6c97a
--- /dev/null
+++ b/PLAN.md
@@ -0,0 +1,26 @@
+# Creer — Final Plan
+
+## Done
+
+- **v0.1** — FastAPI planner/generator + VS Code write-to-workspace (+ optional git)
+- **v0.2** — Preview, GitHub create/push, templates, overwrite protection, `/creer` chat
+- **v0.3** — Streaming, offline/local models, bake-ins, SecretStorage + GIT_ASKPASS
+- **v0.4** — Stream cancel, selectable license/CI bake-ins, quality gates
+- **v0.5** — Content diff preview before write, multi-root workspace targeting, installable JSON/YAML template packs
+- **v0.6** — Pack marketplace + remote URL install/delete, side-by-side conflict diffs, publish packaging
+- **v0.7** — Self-hosted pack registry (`/registry` + download), extension icon, Browse Pack Registry, release changelog
+- **v0.8** — Federated registry (`/registry/federated` + peers), Browse Federated Registry UI, GitHub Actions release + CI (artifact-first; signed publish when secrets exist)
+- **v0.9** — Peer status/probe UX (`creer.registryPeers`, Manage Registry Peers), federated browse enrichment, GitHub Release on tag + `RELEASE.md`
+
+## Optional next
+
+- Human: configure `VSCE_PAT` / `OVSX_PAT` repository secrets; tag `v0.9.0` (see [`RELEASE.md`](RELEASE.md))
+- More peer discovery / registry auth
+
+## Non-goals
+
+- Multi-agent orchestration
+- Memory graphs
+- Overengineered plugin frameworks
+
+Stay power-focused: idea → plan → files → workspace.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0237c04
--- /dev/null
+++ b/README.md
@@ -0,0 +1,88 @@
+# Creer
+
+AI-powered repo scaffolding inside your workspace.
+
+**Current version: 0.9.0**
+
+## Architecture
+
+```
+creer/
+├── backend/ # Python FastAPI AI engine (+ packs/ + registry)
+├── extension/ # VS Code extension (icon in media/)
+└── .github/ # CI + release workflows
+```
+
+## Quick start
+
+```bash
+# Backend
+cd backend && python -m venv venv && source venv/bin/activate
+pip install -r requirements.txt && cp .env.example .env
+uvicorn main:app --reload --port 8000
+
+# Extension
+cd extension && npm install && npm run compile
+# F5 → Creer: Create New Repo
+```
+
+## Registry & federation (v0.7–v0.9)
+
+Self-hosted pack catalog plus optional multi-host federation:
+
+| Method | Path | Description |
+|---|---|---|
+| `GET` | `/registry?q=&source=` | Searchable pack list |
+| `GET` | `/registry/federated?q=&source=&peers=` | Local + peer merge (extra peers CSV) |
+| `GET` | `/registry/peers` | Peer health + configured URLs |
+| `POST` | `/registry/peers/probe` | Probe one peer `{ url }` |
+| `GET` | `/registry/packs/{id}` | Pack metadata |
+| `GET` | `/registry/packs/{id}/download` | Portable JSON pack (installable URL) |
+| `GET` | `/marketplace` | Curated featured view |
+
+Extension settings: `creer.registryPeers` (comma-separated peer base URLs), `creer.showPeerStatus` (peer health in federated browse).
+
+Commands: **Creer: Browse Federated Registry**, **Creer: Manage Registry Peers**.
+
+Install from another Creer host:
+
+```bash
+curl -X POST http://localhost:8000/packs/install \
+ -H 'Content-Type: application/json' \
+ -d '{"url":"http://other-host:8000/registry/packs/fastapi-crud/download"}'
+```
+
+Set `CREER_PUBLIC_BASE_URL` for absolute download links in registry responses.
+Set `CREER_REGISTRY_PEERS` (comma-separated base URLs) for backend-configured federated discovery.
+Use `creer.registryPeers` in the extension for client-side extra peers when browsing.
+
+## Extension commands
+
+| Command | Title |
+|---|---|
+| `creer.createRepo` | Create New Repo |
+| `creer.createRepoFromChat` | Create from Chat Prompt |
+| `creer.browseMarketplace` | Browse Pack Marketplace |
+| `creer.browseRegistry` | Browse Pack Registry |
+| `creer.browseFederatedRegistry` | Browse Federated Registry |
+| `creer.manageRegistryPeers` | Manage Registry Peers |
+| `creer.installPackFromUrl` | Install Pack from URL |
+| `creer.setGitHubToken` / `clearGitHubToken` | SecretStorage token |
+
+## CI & publishing
+
+- **CI** (`.github/workflows/ci.yml`): pytest + extension compile on push/PR
+- **Release** (`.github/workflows/release.yml`): tag `v*` → package `.vsix`, create GitHub Release with attachment; publish to Marketplace / Open VSX only when `VSCE_PAT` / `OVSX_PAT` secrets are set
+
+See [`RELEASE.md`](RELEASE.md) and [`extension/PUBLISH.md`](extension/PUBLISH.md). Package locally:
+
+```bash
+cd extension && npm run compile && npm run package
+# → creer-0.9.0.vsix (includes media/icon.png)
+```
+
+Signed Marketplace / Open VSX publish requires your own `VSCE_PAT` / `OVSX_PAT` (never commit tokens).
+
+## License
+
+MIT
diff --git a/RELEASE.md b/RELEASE.md
new file mode 100644
index 0000000..fce98f3
--- /dev/null
+++ b/RELEASE.md
@@ -0,0 +1,56 @@
+# Releasing Creer v0.9.0
+
+Exact steps for a human maintainer to cut a tagged release with GitHub Release + optional Marketplace / Open VSX publish.
+
+## 1. Set repository secrets (once)
+
+In GitHub → **Settings → Secrets and variables → Actions**, add:
+
+| Secret | Purpose |
+|---|---|
+| `VSCE_PAT` | Azure DevOps PAT with Marketplace **Acquire** + **Publish** (publisher must match `extension/package.json` → `publisher`) |
+| `OVSX_PAT` | Open VSX access token from [open-vsx.org](https://open-vsx.org/) |
+
+Both are optional. If neither is set, the release workflow still builds the `.vsix`, uploads it as an artifact, and (on tag pushes) creates a **GitHub Release** with the `.vsix` attached. Marketplace / Open VSX publish is skipped with `No publish tokens configured — artifact only`.
+
+Never commit PATs. Prefer repo secrets over exporting tokens in shared shells.
+
+## 2. Bump & verify locally
+
+```bash
+# Confirm extension version is 0.9.0
+grep '"version"' extension/package.json
+
+cd extension
+npm ci
+npm run compile
+npm run package
+# → creer-0.9.0.vsix
+```
+
+Smoke-test: `code --install-extension creer-0.9.0.vsix` (or Cursor equivalent) against a running backend.
+
+## 3. Tag v0.9.0 and push
+
+From a clean `main` (or the release commit):
+
+```bash
+git tag -a v0.9.0 -m "Creer v0.9.0"
+git push origin v0.9.0
+```
+
+Tag pattern `v*` triggers [`.github/workflows/release.yml`](.github/workflows/release.yml).
+
+## 4. What the workflow does
+
+1. **build** — `npm ci` → `compile` → `vsce package` → upload `creer-vsix` artifact
+2. **github-release** (tag pushes only) — create a GitHub Release and attach the `.vsix` (`contents: write`)
+3. **publish** — if `VSCE_PAT` / `OVSX_PAT` secrets exist, publish to Marketplace / Open VSX; otherwise artifact-only
+
+You can also run the workflow via **Actions → Release → Run workflow** (`workflow_dispatch`) for a package/artifact without a tag (no GitHub Release job in that case).
+
+## 5. After release
+
+- Confirm the GitHub Release page lists `creer-0.9.0.vsix`
+- If secrets were set, confirm Marketplace / Open VSX listing updated to 0.9.0
+- See [`extension/PUBLISH.md`](extension/PUBLISH.md) for manual `vsce` / `ovsx` publish from a laptop
diff --git a/backend/.env.example b/backend/.env.example
new file mode 100644
index 0000000..999649a
--- /dev/null
+++ b/backend/.env.example
@@ -0,0 +1,27 @@
+# OpenAI API key (required unless CREER_OFFLINE=1 or using a local server that ignores it)
+OPENAI_API_KEY=sk-your-key-here
+
+# Optional OpenAI-compatible base URL for local/offline backends (Ollama, LM Studio, vLLM, etc.)
+# Example: http://127.0.0.1:11434/v1
+OPENAI_BASE_URL=
+
+# Model name (OpenAI or local-compatible)
+CREER_MODEL=gpt-4o-mini
+
+# Template-only / stub generation — never calls the LLM
+# Set to 1, true, or yes to enable
+CREER_OFFLINE=
+
+# Optional extra packs directory (JSON/YAML). Merged with backend/packs;
+# user packs override built-in packs on the same id.
+# When set, remote installs (POST /packs/install) write here.
+# When unset, installs go to backend/packs/installed/.
+# CREER_PACKS_DIR=/path/to/my-packs
+
+# Optional public base URL for absolute registry download links
+# Example: http://localhost:8000 or https://creer.example.com
+# CREER_PUBLIC_BASE_URL=http://localhost:8000
+
+# Comma-separated peer Creer registry base URLs (federated discovery, v0.8)
+# Example: http://127.0.0.1:8001,https://creer-packs.example.com
+# CREER_REGISTRY_PEERS=
diff --git a/backend/.gitignore b/backend/.gitignore
new file mode 100644
index 0000000..491ffd0
--- /dev/null
+++ b/backend/.gitignore
@@ -0,0 +1,8 @@
+.env
+venv/
+__pycache__/
+*.pyc
+.pytest_cache/
+.mypy_cache/
+packs/installed/*
+!packs/installed/.gitkeep
diff --git a/backend/app/__init__.py b/backend/app/__init__.py
new file mode 100644
index 0000000..1c9c4d8
--- /dev/null
+++ b/backend/app/__init__.py
@@ -0,0 +1 @@
+"""Creer backend package."""
diff --git a/backend/app/bakeins.py b/backend/app/bakeins.py
new file mode 100644
index 0000000..c4e1ae9
--- /dev/null
+++ b/backend/app/bakeins.py
@@ -0,0 +1,245 @@
+"""Open-source bake-ins merged into scaffolds (LICENSE, README, CI)."""
+
+from __future__ import annotations
+
+from typing import Any, Literal
+
+LicenseId = Literal["mit", "apache-2.0", "none"]
+CiId = Literal["auto", "python", "node", "none"]
+
+
+def list_bakein_options() -> dict[str, list[dict[str, str]]]:
+ return {
+ "licenses": [
+ {"id": "mit", "name": "MIT"},
+ {"id": "apache-2.0", "name": "Apache 2.0"},
+ {"id": "none", "name": "No license"},
+ ],
+ "ci": [
+ {"id": "auto", "name": "Auto-detect"},
+ {"id": "python", "name": "Python"},
+ {"id": "node", "name": "Node"},
+ {"id": "none", "name": "No CI"},
+ ],
+ }
+
+
+def _normalize_options(options: dict[str, Any] | None) -> dict[str, Any]:
+ opts = options or {}
+ license_id = opts.get("license") or "mit"
+ ci_id = opts.get("ci") or "auto"
+ include_readme = opts.get("include_readme")
+ if include_readme is None:
+ include_readme = True
+ if license_id not in ("mit", "apache-2.0", "none"):
+ license_id = "mit"
+ if ci_id not in ("auto", "python", "node", "none"):
+ ci_id = "auto"
+ return {
+ "license": license_id,
+ "ci": ci_id,
+ "include_readme": bool(include_readme),
+ }
+
+
+def _mit_license(project_name: str) -> str:
+ holder = project_name.strip() if project_name and project_name.strip() else "Creer Scaffold"
+ return f"""MIT License
+
+Copyright (c) 2026 {holder}
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+"""
+
+
+def _apache_license(project_name: str) -> str:
+ holder = project_name.strip() if project_name and project_name.strip() else "Creer Scaffold"
+ return f"""Apache License
+Version 2.0, January 2004
+http://www.apache.org/licenses/
+
+Copyright 2026 {holder}
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+"""
+
+
+def _default_readme(plan: dict) -> str:
+ name = plan.get("project_name") or "project"
+ stack = plan.get("stack") or ""
+ desc = plan.get("description") or f"Scaffolded by Creer for {name}."
+ lines = [
+ f"# {name}",
+ "",
+ desc,
+ "",
+ ]
+ if stack:
+ lines.extend([f"**Stack:** {stack}", ""])
+ lines.extend(
+ [
+ "## Getting started",
+ "",
+ "This project was generated with Creer.",
+ "Install dependencies and follow stack-specific docs in this repo.",
+ "",
+ ]
+ )
+ return "\n".join(lines)
+
+
+def _ci_python() -> str:
+ return """name: CI
+
+on:
+ push:
+ branches: [main, master]
+ pull_request:
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
+ if [ -f pyproject.toml ]; then pip install -e ".[dev]" || pip install -e .; fi
+ pip install pytest
+ - name: Run tests
+ run: pytest -q || echo "No tests yet — scaffold CI ok"
+"""
+
+
+def _ci_node() -> str:
+ return """name: CI
+
+on:
+ push:
+ branches: [main, master]
+ pull_request:
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+ cache: npm
+ - name: Install
+ run: npm ci || npm install
+ - name: Test
+ run: npm test --if-present
+"""
+
+
+def _ci_generic() -> str:
+ return """name: CI
+
+on:
+ push:
+ branches: [main, master]
+ pull_request:
+
+jobs:
+ scaffold:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Scaffold CI
+ run: echo "Creer scaffold CI — add stack-specific checks as the project grows"
+"""
+
+
+def _ci_workflow(plan: dict, ci_id: str) -> str | None:
+ if ci_id == "none":
+ return None
+ if ci_id == "python":
+ return _ci_python()
+ if ci_id == "node":
+ return _ci_node()
+
+ # auto
+ stack = (plan.get("stack") or "").lower()
+ paths = " ".join(plan.get("files") or []).lower()
+ blob = f"{stack} {paths}"
+
+ is_python = any(
+ tok in blob
+ for tok in ("python", "fastapi", "uvicorn", "pytest", "requirements.txt", "pyproject.toml")
+ )
+ is_node = any(
+ tok in blob for tok in ("node", "express", "next", "npm", "package.json", "react")
+ )
+
+ if is_python:
+ return _ci_python()
+ if is_node:
+ return _ci_node()
+ return _ci_generic()
+
+
+def apply_bakeins(
+ plan: dict,
+ files: dict[str, str],
+ options: dict[str, Any] | None = None,
+) -> dict[str, str]:
+ """
+ Merge open-source bake-ins into generated files.
+
+ - LICENSE per options.license (mit | apache-2.0 | none); never overwrite existing
+ - README.md created only if missing and include_readme
+ - .github/workflows/ci.yml per options.ci when missing
+ """
+ opts = _normalize_options(options)
+ out = dict(files)
+ project_name = plan.get("project_name") or "Creer Scaffold"
+
+ if opts["license"] != "none" and "LICENSE" not in out:
+ if opts["license"] == "apache-2.0":
+ out["LICENSE"] = _apache_license(project_name)
+ else:
+ out["LICENSE"] = _mit_license(project_name)
+
+ if opts["include_readme"] and "README.md" not in out:
+ out["README.md"] = _default_readme(plan)
+
+ ci_path = ".github/workflows/ci.yml"
+ if ci_path not in out:
+ ci_body = _ci_workflow(plan, opts["ci"])
+ if ci_body is not None:
+ out[ci_path] = ci_body
+
+ return out
diff --git a/backend/app/federation.py b/backend/app/federation.py
new file mode 100644
index 0000000..2ae88ef
--- /dev/null
+++ b/backend/app/federation.py
@@ -0,0 +1,325 @@
+"""Federated multi-host registry discovery — query peer Creer registries and merge."""
+
+from __future__ import annotations
+
+import time
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from typing import Any
+from urllib.parse import urljoin, urlparse
+
+import httpx
+
+from config import CREER_REGISTRY_PEERS
+from app.registry import list_registry
+
+FEDERATION_VERSION = "0.9.0"
+_MAX_PEERS = 8
+
+
+def parse_peers(raw: str | None = None) -> list[str]:
+ """
+ Normalize peer base URLs from CREER_REGISTRY_PEERS (or raw override).
+
+ Strips whitespace/trailing slash, dedupes (order preserved), keeps http/https only.
+ """
+ text = CREER_REGISTRY_PEERS if raw is None else raw
+ if not text:
+ return []
+
+ seen: set[str] = set()
+ out: list[str] = []
+ for part in text.split(","):
+ url = part.strip().rstrip("/")
+ if not url:
+ continue
+ parsed = urlparse(url)
+ if parsed.scheme not in ("http", "https"):
+ continue
+ if not parsed.netloc:
+ continue
+ if url in seen:
+ continue
+ seen.add(url)
+ out.append(url)
+ return out
+
+
+def resolve_peers(extra_peers: list[str] | None = None) -> list[str]:
+ """
+ Merge configured peers with optional ad-hoc extras.
+
+ Dedupes (configured first), http/https only, capped at _MAX_PEERS.
+ """
+ configured = parse_peers()
+ extras: list[str] = []
+ if extra_peers:
+ # Normalize each entry (allow raw URLs or comma-joined strings)
+ for raw in extra_peers:
+ if not raw:
+ continue
+ extras.extend(parse_peers(raw))
+
+ seen: set[str] = set()
+ out: list[str] = []
+ for url in configured + extras:
+ if url in seen:
+ continue
+ seen.add(url)
+ out.append(url)
+ if len(out) >= _MAX_PEERS:
+ break
+ return out
+
+
+def _absolutize_url(base_url: str, value: Any) -> Any:
+ if not isinstance(value, str) or not value:
+ return value
+ if value.startswith("http://") or value.startswith("https://"):
+ return value
+ # Relative path → absolute against peer base
+ return urljoin(base_url.rstrip("/") + "/", value.lstrip("/"))
+
+
+def _tag_peer_items(base_url: str, items: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ tagged: list[dict[str, Any]] = []
+ for raw in items:
+ if not isinstance(raw, dict):
+ continue
+ item = dict(raw)
+ item["peer"] = base_url
+ if "download_url" in item:
+ item["download_url"] = _absolutize_url(base_url, item.get("download_url"))
+ if "install_url" in item:
+ item["install_url"] = _absolutize_url(base_url, item.get("install_url"))
+ if "url" in item and isinstance(item.get("url"), str):
+ item["url"] = _absolutize_url(base_url, item["url"])
+ tagged.append(item)
+ return tagged
+
+
+def fetch_peer_registry(
+ base_url: str,
+ *,
+ q: str | None = None,
+ source: str | None = None,
+ timeout: float = 8.0,
+) -> list[dict[str, Any]]:
+ """
+ GET {base}/registry and return tagged items.
+
+ On any failure returns [] (never raises for federation callers).
+ """
+ items, _err = _fetch_peer_registry(base_url, q=q, source=source, timeout=timeout)
+ return items
+
+
+def _fetch_peer_registry(
+ base_url: str,
+ *,
+ q: str | None = None,
+ source: str | None = None,
+ timeout: float = 8.0,
+) -> tuple[list[dict[str, Any]], str | None]:
+ base = (base_url or "").strip().rstrip("/")
+ if not base:
+ return [], "empty base_url"
+
+ params: dict[str, str] = {}
+ if q:
+ params["q"] = q
+ if source:
+ params["source"] = source
+
+ url = f"{base}/registry"
+ try:
+ with httpx.Client(timeout=timeout, follow_redirects=True) as client:
+ resp = client.get(url, params=params or None)
+ resp.raise_for_status()
+ data = resp.json()
+ except Exception as exc: # noqa: BLE001 — federation must never crash
+ return [], str(exc)
+
+ if isinstance(data, dict):
+ raw_items = data.get("items") or []
+ elif isinstance(data, list):
+ raw_items = data
+ else:
+ return [], "unexpected registry response shape"
+
+ if not isinstance(raw_items, list):
+ return [], "unexpected registry items shape"
+
+ return _tag_peer_items(base, raw_items), None
+
+
+def probe_peer(base_url: str, timeout: float = 5.0) -> dict[str, Any]:
+ """
+ Probe a peer host via /health (preferred) and/or /registry for pack count.
+
+ Never raises — returns a status dict with ok/latency/count/version/error.
+ """
+ base = (base_url or "").strip().rstrip("/")
+ result: dict[str, Any] = {
+ "base_url": base,
+ "ok": False,
+ "latency_ms": None,
+ "count": None,
+ "version": None,
+ "error": None,
+ }
+ if not base:
+ result["error"] = "empty base_url"
+ return result
+
+ parsed = urlparse(base)
+ if parsed.scheme not in ("http", "https") or not parsed.netloc:
+ result["error"] = "url must use http or https with a host"
+ return result
+
+ started = time.perf_counter()
+ version: str | None = None
+ count: int | None = None
+ health_ok = False
+ last_error: str | None = None
+
+ try:
+ with httpx.Client(timeout=timeout, follow_redirects=True) as client:
+ # Prefer /health for liveness + version (+ registry_count when present)
+ try:
+ hresp = client.get(f"{base}/health")
+ hresp.raise_for_status()
+ hdata = hresp.json()
+ health_ok = True
+ if isinstance(hdata, dict):
+ ver = hdata.get("version")
+ if isinstance(ver, str):
+ version = ver
+ if "registry_count" in hdata and hdata["registry_count"] is not None:
+ try:
+ count = int(hdata["registry_count"])
+ except (TypeError, ValueError):
+ pass
+ except Exception as exc: # noqa: BLE001
+ last_error = str(exc)
+
+ # Use /registry for count (and version fallback) when needed
+ if count is None or version is None:
+ try:
+ rresp = client.get(f"{base}/registry")
+ rresp.raise_for_status()
+ rdata = rresp.json()
+ if isinstance(rdata, dict):
+ if version is None:
+ ver = rdata.get("version")
+ if isinstance(ver, str):
+ version = ver
+ if count is None:
+ raw_items = rdata.get("items") or []
+ count = len(raw_items) if isinstance(raw_items, list) else 0
+ elif isinstance(rdata, list) and count is None:
+ count = len(rdata)
+ except Exception as exc: # noqa: BLE001
+ if not health_ok:
+ last_error = str(exc)
+ elif last_error is None:
+ last_error = str(exc)
+
+ latency_ms = round((time.perf_counter() - started) * 1000, 1)
+ result["latency_ms"] = latency_ms
+
+ if health_ok or count is not None:
+ result["ok"] = True
+ result["count"] = count if count is not None else 0
+ result["version"] = version
+ result["error"] = None
+ return result
+
+ result["error"] = last_error or "unreachable"
+ return result
+ except Exception as exc: # noqa: BLE001
+ result["latency_ms"] = round((time.perf_counter() - started) * 1000, 1)
+ result["error"] = str(exc)
+ return result
+
+
+def list_peer_status() -> list[dict[str, Any]]:
+ """Probe all configured peers concurrently; preserve configured order."""
+ peers = parse_peers()[:_MAX_PEERS]
+ if not peers:
+ return []
+
+ by_url: dict[str, dict[str, Any]] = {}
+ workers = min(8, len(peers))
+ with ThreadPoolExecutor(max_workers=workers) as pool:
+ futures = {pool.submit(probe_peer, p): p for p in peers}
+ for fut in as_completed(futures):
+ peer = futures[fut]
+ by_url[peer] = fut.result()
+ return [by_url[p] for p in peers]
+
+
+def list_federated(
+ *,
+ q: str | None = None,
+ source: str | None = None,
+ include_local: bool = True,
+ extra_peers: list[str] | None = None,
+) -> dict[str, Any]:
+ """Merge local registry with peer registries (local ids win on collision)."""
+ local = list_registry(q=q, source=source or "all") if include_local else {
+ "version": FEDERATION_VERSION,
+ "base_url": None,
+ "items": [],
+ }
+
+ peers = resolve_peers(extra_peers)
+ peer_meta: list[dict[str, Any]] = []
+ peer_items_by_url: dict[str, list[dict[str, Any]]] = {}
+
+ def _one(peer: str) -> tuple[str, list[dict[str, Any]], str | None]:
+ items, err = _fetch_peer_registry(peer, q=q, source=source)
+ return peer, items, err
+
+ if peers:
+ workers = min(8, len(peers))
+ with ThreadPoolExecutor(max_workers=workers) as pool:
+ futures = {pool.submit(_one, p): p for p in peers}
+ for fut in as_completed(futures):
+ peer, items, err = fut.result()
+ peer_items_by_url[peer] = items
+ peer_meta.append(
+ {
+ "base_url": peer,
+ "ok": err is None,
+ "count": len(items) if err is None else 0,
+ "error": err,
+ }
+ )
+ # Stable peer order matching resolve_peers()
+ order = {p: i for i, p in enumerate(peers)}
+ peer_meta.sort(key=lambda m: order.get(m["base_url"], 0))
+
+ merged: list[dict[str, Any]] = []
+ seen_ids: set[str] = set()
+
+ for item in local.get("items") or []:
+ pid = item.get("id")
+ if isinstance(pid, str) and pid:
+ seen_ids.add(pid)
+ merged.append(item)
+
+ for peer in peers:
+ for item in peer_items_by_url.get(peer, []):
+ pid = item.get("id")
+ if isinstance(pid, str) and pid in seen_ids:
+ continue
+ if isinstance(pid, str) and pid:
+ seen_ids.add(pid)
+ merged.append(item)
+
+ return {
+ "version": FEDERATION_VERSION,
+ "local": local,
+ "peers": peer_meta,
+ "items": merged,
+ }
diff --git a/backend/app/generator.py b/backend/app/generator.py
new file mode 100644
index 0000000..7dc8c59
--- /dev/null
+++ b/backend/app/generator.py
@@ -0,0 +1,285 @@
+"""File content generation — LLM-driven or offline stubs."""
+
+from __future__ import annotations
+
+import json
+from collections.abc import Callable, Iterator
+from typing import Any
+
+from config import CREER_OFFLINE, MODEL, OPENAI_API_KEY, OPENAI_BASE_URL
+from app.llm import _get_client
+
+
+def _use_offline(plan: dict) -> bool:
+ """True when CREER_OFFLINE, or when no LLM endpoint is configured and a plan exists."""
+ if CREER_OFFLINE:
+ return True
+ # Local OpenAI-compatible servers (Ollama, etc.) may omit a real API key.
+ if OPENAI_BASE_URL:
+ return False
+ if not OPENAI_API_KEY and plan.get("files"):
+ return True
+ return False
+
+
+def _stub_content(file_path: str, plan: dict) -> str:
+ """Deterministic stub content for offline / template-only generation."""
+ name = plan.get("project_name") or "project"
+ stack = plan.get("stack") or ""
+ desc = plan.get("description") or f"Scaffolded project: {name}."
+ base = file_path.rsplit("/", 1)[-1]
+ base_lower = base.lower()
+ ext = base_lower.rsplit(".", 1)[-1] if "." in base_lower else ""
+
+ if base_lower == "readme.md":
+ lines = [f"# {name}", "", desc, ""]
+ if stack:
+ lines.extend([f"**Stack:** {stack}", ""])
+ lines.extend(
+ [
+ "## Status",
+ "",
+ "Generated in offline / template-only mode. Replace stubs with real implementation.",
+ "",
+ ]
+ )
+ return "\n".join(lines)
+
+ if base_lower == "requirements.txt":
+ if "fastapi" in stack.lower() or "uvicorn" in stack.lower():
+ return "fastapi>=0.115.0\nuvicorn[standard]>=0.32.0\npython-dotenv>=1.0.0\n"
+ return "# TODO: add dependencies\n"
+
+ if base_lower == "package.json":
+ pkg = {
+ "name": name,
+ "version": "0.1.0",
+ "private": True,
+ "scripts": {
+ "start": "node src/index.js",
+ "test": "echo \"No tests yet\" && exit 0",
+ },
+ "dependencies": {},
+ }
+ if "express" in stack.lower():
+ pkg["dependencies"]["express"] = "^4.21.0"
+ pkg["scripts"]["start"] = "node src/index.js"
+ if "next" in stack.lower():
+ pkg["dependencies"]["next"] = "^14.2.0"
+ pkg["dependencies"]["react"] = "^18.3.0"
+ pkg["dependencies"]["react-dom"] = "^18.3.0"
+ pkg["scripts"] = {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "test": "echo \"No tests yet\" && exit 0",
+ }
+ return json.dumps(pkg, indent=2) + "\n"
+
+ if base_lower == "pyproject.toml":
+ return f"""[project]
+name = "{name}"
+version = "0.1.0"
+description = "{desc.replace(chr(34), "'")}"
+requires-python = ">=3.10"
+dependencies = []
+
+[project.scripts]
+{name} = "src.cli:main"
+"""
+
+ if base_lower in (".gitignore",):
+ return (
+ "__pycache__/\n*.py[cod]\n.venv/\nvenv/\n.env\n"
+ "node_modules/\ndist/\nbuild/\n.DS_Store\n"
+ )
+
+ if base_lower in (".env.example",):
+ return "# Example environment variables\n# KEY=value\n"
+
+ if base_lower == "tsconfig.json":
+ return json.dumps(
+ {
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": True,
+ "skipLibCheck": True,
+ "strict": True,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "jsx": "preserve",
+ "noEmit": True,
+ "incremental": True,
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
+ "exclude": ["node_modules"],
+ },
+ indent=2,
+ ) + "\n"
+
+ if base_lower == "next.config.js":
+ return "/** @type {import('next').NextConfig} */\nconst nextConfig = {};\nmodule.exports = nextConfig;\n"
+
+ if ext in ("py",):
+ if base_lower == "main.py" and "fastapi" in stack.lower():
+ return (
+ '"""Application entrypoint."""\n\n'
+ "from fastapi import FastAPI\n\n"
+ f'app = FastAPI(title="{name}")\n\n\n'
+ '@app.get("/health")\n'
+ "def health():\n"
+ ' return {"status": "ok"}\n\n\n'
+ "# TODO: implement\n"
+ )
+ return f'"""{file_path}"""\n\n# TODO: implement\n'
+
+ if ext in ("js", "mjs", "cjs"):
+ return f"// {file_path}\n// TODO: implement\n"
+
+ if ext in ("ts", "tsx"):
+ return f"// {file_path}\n// TODO: implement\n"
+
+ if ext == "css":
+ return f"/* {file_path} */\n/* TODO: implement */\n"
+
+ if ext in ("html", "htm"):
+ return (
+ "\n"
+ '\n'
+ "
\n"
+ ' \n'
+ f" {name}\n"
+ ' \n'
+ "\n"
+ "\n"
+ f" {name}
\n"
+ " \n"
+ ' \n'
+ "\n"
+ "\n"
+ )
+
+ if ext in ("yml", "yaml"):
+ return f"# {file_path}\n# TODO: implement\n"
+
+ if ext == "json":
+ return "{}\n"
+
+ if ext == "toml":
+ return f"# {file_path}\n# TODO: implement\n"
+
+ # Default: comment-style stub
+ return f"# TODO: implement ({file_path})\n"
+
+
+def _strip_fences(content: str) -> str:
+ if content.startswith("```"):
+ lines = content.splitlines()
+ if lines and lines[0].startswith("```"):
+ lines = lines[1:]
+ if lines and lines[-1].strip() == "```":
+ lines = lines[:-1]
+ content = "\n".join(lines)
+ return content
+
+
+def _generate_one_llm(file_path: str, plan: dict) -> str:
+ prompt = f"""
+Generate the full content for file: {file_path}
+
+Project Stack: {plan.get("stack", "")}
+Project Name: {plan.get("project_name", "")}
+
+Follow best practices.
+Return ONLY the file content — no markdown fences, no explanation.
+"""
+ response = _get_client().chat.completions.create(
+ model=MODEL,
+ messages=[{"role": "user", "content": prompt}],
+ temperature=0.3,
+ )
+ content = response.choices[0].message.content or ""
+ return _strip_fences(content)
+
+
+def generate_files_iter(
+ plan: dict,
+ should_cancel: Callable[[], bool] | None = None,
+) -> Iterator[tuple[dict[str, Any], dict[str, str]]]:
+ """
+ Yield (event_dict, partial_files) progress while generating.
+
+ Events:
+ - file / generating
+ - file / done (with bytes)
+ - cancelled (when should_cancel returns True)
+ Does not yield start/done/error — caller owns those.
+ """
+ files_output: dict[str, str] = {}
+ file_list = list(plan["files"])
+ total = len(file_list)
+ offline = _use_offline(plan)
+
+ for index, file_path in enumerate(file_list, start=1):
+ if should_cancel and should_cancel():
+ yield (
+ {
+ "event": "cancelled",
+ "detail": "Cancelled by user",
+ },
+ dict(files_output),
+ )
+ return
+
+ yield (
+ {
+ "event": "file",
+ "index": index,
+ "total": total,
+ "path": file_path,
+ "status": "generating",
+ },
+ dict(files_output),
+ )
+
+ if should_cancel and should_cancel():
+ yield (
+ {
+ "event": "cancelled",
+ "detail": "Cancelled by user",
+ },
+ dict(files_output),
+ )
+ return
+
+ if offline:
+ content = _stub_content(file_path, plan)
+ else:
+ content = _generate_one_llm(file_path, plan)
+
+ files_output[file_path] = content
+ yield (
+ {
+ "event": "file",
+ "index": index,
+ "total": total,
+ "path": file_path,
+ "status": "done",
+ "bytes": len(content.encode("utf-8")),
+ },
+ dict(files_output),
+ )
+
+
+def generate_files(
+ plan: dict,
+ should_cancel: Callable[[], bool] | None = None,
+) -> dict[str, str]:
+ """Generate file contents one-by-one from a project plan."""
+ files_output: dict[str, str] = {}
+ for event, partial in generate_files_iter(plan, should_cancel=should_cancel):
+ files_output = partial
+ if event.get("event") == "cancelled":
+ raise ValueError(event.get("detail") or "Cancelled by user")
+ return files_output
diff --git a/backend/app/github.py b/backend/app/github.py
new file mode 100644
index 0000000..0dee3b2
--- /dev/null
+++ b/backend/app/github.py
@@ -0,0 +1,63 @@
+"""Optional GitHub REST helper for creating repositories."""
+
+from __future__ import annotations
+
+import httpx
+
+
+def create_github_repo(
+ token: str,
+ name: str,
+ private: bool = True,
+ description: str = "",
+) -> dict:
+ """
+ Create a GitHub repository for the authenticated user.
+
+ Returns dict with html_url, clone_url, and full_name.
+ Raises ValueError on auth/API failures.
+ """
+ if not token or not isinstance(token, str):
+ raise ValueError("GitHub token is required")
+ if not name or not isinstance(name, str):
+ raise ValueError("Repository name is required")
+
+ headers = {
+ "Authorization": f"Bearer {token}",
+ "Accept": "application/vnd.github+json",
+ "X-GitHub-Api-Version": "2022-11-28",
+ "User-Agent": "Creer/0.2",
+ }
+ payload = {
+ "name": name,
+ "private": private,
+ "description": description or "",
+ "auto_init": False,
+ }
+
+ try:
+ with httpx.Client(timeout=30.0) as client:
+ resp = client.post(
+ "https://api.github.com/user/repos",
+ headers=headers,
+ json=payload,
+ )
+ except httpx.HTTPError as exc:
+ raise ValueError(f"GitHub request failed: {exc}") from exc
+
+ if resp.status_code == 401:
+ raise ValueError("GitHub authentication failed — check your token")
+ if resp.status_code == 403:
+ raise ValueError("GitHub forbidden — token may lack repo scope")
+ if resp.status_code == 422:
+ detail = resp.json().get("message", resp.text) if resp.headers.get("content-type", "").startswith("application/json") else resp.text
+ raise ValueError(f"GitHub rejected repo creation: {detail}")
+ if resp.status_code >= 400:
+ raise ValueError(f"GitHub API error ({resp.status_code}): {resp.text[:300]}")
+
+ data = resp.json()
+ return {
+ "html_url": data.get("html_url", ""),
+ "clone_url": data.get("clone_url", ""),
+ "full_name": data.get("full_name", ""),
+ }
diff --git a/backend/app/jobs.py b/backend/app/jobs.py
new file mode 100644
index 0000000..07c569c
--- /dev/null
+++ b/backend/app/jobs.py
@@ -0,0 +1,76 @@
+"""In-memory generation job registry for stream cancellation."""
+
+from __future__ import annotations
+
+import threading
+import time
+import uuid
+from dataclasses import dataclass, field
+
+# Jobs older than this are eligible for cleanup.
+_TTL_SECONDS = 30 * 60
+
+
+@dataclass
+class _Job:
+ cancelled: bool = False
+ created_at: float = field(default_factory=time.time)
+
+
+_lock = threading.Lock()
+_jobs: dict[str, _Job] = {}
+
+
+def _purge_expired(now: float | None = None) -> None:
+ now = now if now is not None else time.time()
+ expired = [jid for jid, job in _jobs.items() if now - job.created_at > _TTL_SECONDS]
+ for jid in expired:
+ del _jobs[jid]
+
+
+def create_job(job_id: str | None = None) -> str:
+ """Register a new job (or ensure an existing id) and return its id.
+
+ If the id was already cancelled (cancel-before-start), keep it cancelled.
+ """
+ jid = (job_id or "").strip() or uuid.uuid4().hex
+ with _lock:
+ _purge_expired()
+ existing = _jobs.get(jid)
+ if existing is not None:
+ # Refresh TTL but preserve cancelled flag.
+ existing.created_at = time.time()
+ return jid
+ _jobs[jid] = _Job()
+ return jid
+
+
+def is_cancelled(job_id: str | None) -> bool:
+ if not job_id:
+ return False
+ with _lock:
+ job = _jobs.get(job_id)
+ return bool(job and job.cancelled)
+
+
+def cancel_job(job_id: str) -> bool:
+ """Mark a job cancelled. Returns True if the job existed (or was created as cancelled)."""
+ jid = (job_id or "").strip()
+ if not jid:
+ return False
+ with _lock:
+ _purge_expired()
+ job = _jobs.get(jid)
+ if job is None:
+ # Allow cancel-before-start races: register already-cancelled.
+ _jobs[jid] = _Job(cancelled=True)
+ return True
+ job.cancelled = True
+ return True
+
+
+def finish_job(job_id: str | None) -> None:
+ if not job_id:
+ return
+ with _lock:
+ _jobs.pop(job_id, None)
diff --git a/backend/app/llm.py b/backend/app/llm.py
new file mode 100644
index 0000000..e12a328
--- /dev/null
+++ b/backend/app/llm.py
@@ -0,0 +1,30 @@
+"""Shared OpenAI-compatible client for Creer."""
+
+from __future__ import annotations
+
+from openai import OpenAI
+
+from config import OPENAI_API_KEY, OPENAI_BASE_URL
+
+_client: OpenAI | None = None
+
+
+def _get_client() -> OpenAI:
+ """Lazy OpenAI client. Passes base_url when OPENAI_BASE_URL is set (Ollama, etc.)."""
+ global _client
+ if _client is None:
+ if not OPENAI_API_KEY and not OPENAI_BASE_URL:
+ raise ValueError(
+ "OPENAI_API_KEY is not set (or set OPENAI_BASE_URL for a local OpenAI-compatible server)"
+ )
+ kwargs: dict = {"api_key": OPENAI_API_KEY or "not-needed"}
+ if OPENAI_BASE_URL:
+ kwargs["base_url"] = OPENAI_BASE_URL
+ _client = OpenAI(**kwargs)
+ return _client
+
+
+def reset_client() -> None:
+ """Clear the cached client (useful in tests)."""
+ global _client
+ _client = None
diff --git a/backend/app/packs.py b/backend/app/packs.py
new file mode 100644
index 0000000..0285492
--- /dev/null
+++ b/backend/app/packs.py
@@ -0,0 +1,491 @@
+"""Installable template packs (JSON/YAML) for Creer v0.6 — including remote install."""
+
+from __future__ import annotations
+
+import json
+import os
+import re
+from pathlib import Path
+from urllib.parse import urlparse
+
+import httpx
+import yaml
+
+from app.templates import slugify
+from app.validator import _reject_unsafe_path
+
+# backend/packs — resolved relative to backend root (parent of app/)
+_BACKEND_ROOT = Path(__file__).resolve().parent.parent
+PACKS_DIR = _BACKEND_ROOT / "packs"
+INSTALLED_DIR = PACKS_DIR / "installed"
+
+_PACK_FILE_SUFFIXES = (".json", ".yaml", ".yml")
+_SAFE_ID = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$")
+
+MAX_DOWNLOAD_BYTES = 1_000_000
+FETCH_TIMEOUT_SECONDS = 30.0
+
+
+class PackConflictError(Exception):
+ """Raised when installing a pack id that already exists and overwrite is false."""
+
+
+class PackNotInstalledError(Exception):
+ """Raised when uninstall targets a pack that is not in the writable install dir."""
+
+
+def _extra_packs_dir() -> Path | None:
+ """Optional user packs directory via CREER_PACKS_DIR."""
+ raw = os.getenv("CREER_PACKS_DIR", "").strip()
+ if not raw:
+ return None
+ return Path(raw).expanduser().resolve()
+
+
+def writable_packs_dir() -> Path:
+ """
+ Directory where remotely installed packs are written.
+
+ Prefer CREER_PACKS_DIR when set; otherwise backend/packs/installed/.
+ """
+ extra = _extra_packs_dir()
+ if extra is not None:
+ return extra
+ return INSTALLED_DIR
+
+
+def validate_pack_dict(data: dict, *, source: str = "") -> dict:
+ """
+ Validate a pack mapping and return a normalized dict.
+
+ Required: id, name, non-empty files list with safe relative paths.
+ Optional: description, stack, version.
+ """
+ if not isinstance(data, dict):
+ raise ValueError(f"Pack must be a mapping/object: {source}")
+
+ pack_id = data.get("id")
+ name = data.get("name")
+ files = data.get("files")
+
+ if not isinstance(pack_id, str) or not pack_id.strip():
+ raise ValueError(f"Pack missing valid id: {source}")
+ pack_id = pack_id.strip()
+ if not _SAFE_ID.match(pack_id):
+ raise ValueError(f"Invalid pack id {pack_id!r} in {source}")
+
+ if not isinstance(name, str) or not name.strip():
+ raise ValueError(f"Pack missing valid name: {source}")
+
+ if not isinstance(files, list) or not files:
+ raise ValueError(f"Pack must include a non-empty files list: {source}")
+
+ validated_files: list[str] = []
+ seen: set[str] = set()
+ for entry in files:
+ if not isinstance(entry, str) or not entry.strip():
+ raise ValueError(f"Invalid file path in pack {source}: {entry!r}")
+ fpath = entry.strip().replace("\\", "/")
+ _reject_unsafe_path(fpath, label="pack file path")
+ if fpath in seen:
+ raise ValueError(f"Duplicate file path in pack {source}: {fpath!r}")
+ seen.add(fpath)
+ validated_files.append(fpath)
+
+ version = data.get("version", "1.0.0")
+ if version is not None and not isinstance(version, str):
+ raise ValueError(f"Pack version must be a string: {source}")
+
+ stack = data.get("stack", "")
+ if stack is not None and not isinstance(stack, str):
+ raise ValueError(f"Pack stack must be a string: {source}")
+
+ description = data.get("description", "")
+ if description is not None and not isinstance(description, str):
+ raise ValueError(f"Pack description must be a string: {source}")
+
+ return {
+ "id": pack_id,
+ "name": name.strip(),
+ "description": (description or "").strip(),
+ "stack": (stack or "").strip(),
+ "version": (version or "1.0.0").strip(),
+ "files": validated_files,
+ }
+
+
+def _detect_pack_format(
+ hint_filename: str | None = None,
+ content_type: str | None = None,
+ text: str | None = None,
+) -> str:
+ """Return 'json' or 'yaml' based on filename, Content-Type, and/or body sniff."""
+ if hint_filename:
+ lower = hint_filename.lower().split("?", 1)[0]
+ if lower.endswith(".json"):
+ return "json"
+ if lower.endswith(".yaml") or lower.endswith(".yml"):
+ return "yaml"
+
+ if content_type:
+ ct = content_type.split(";", 1)[0].strip().lower()
+ if ct in ("application/json", "text/json"):
+ return "json"
+ if ct in (
+ "application/yaml",
+ "application/x-yaml",
+ "text/yaml",
+ "text/x-yaml",
+ ):
+ return "yaml"
+
+ if text is not None:
+ stripped = text.lstrip()
+ if stripped.startswith("{") or stripped.startswith("["):
+ return "json"
+
+ # Default: try JSON first via caller; prefer yaml when ambiguous after sniff
+ return "yaml" if text is not None else "json"
+
+
+def parse_pack_content(
+ text_or_bytes: str | bytes,
+ hint_filename: str | None = None,
+ *,
+ content_type: str | None = None,
+) -> dict:
+ """Parse and validate pack content from text or bytes."""
+ if isinstance(text_or_bytes, bytes):
+ try:
+ text = text_or_bytes.decode("utf-8")
+ except UnicodeDecodeError as exc:
+ raise ValueError(f"Pack content is not valid UTF-8: {exc}") from exc
+ else:
+ text = text_or_bytes
+
+ source = hint_filename or ""
+ fmt = _detect_pack_format(hint_filename, content_type, text)
+
+ data: object
+ if fmt == "json":
+ try:
+ data = json.loads(text)
+ except json.JSONDecodeError as exc:
+ # Fallback: maybe mis-detected YAML
+ if hint_filename and hint_filename.lower().endswith(".json"):
+ raise ValueError(f"Invalid JSON in pack {source}: {exc}") from exc
+ try:
+ data = yaml.safe_load(text)
+ except yaml.YAMLError as yexc:
+ raise ValueError(f"Invalid pack content {source}: {exc}") from yexc
+ else:
+ try:
+ data = yaml.safe_load(text)
+ except yaml.YAMLError as exc:
+ raise ValueError(f"Invalid YAML in pack {source}: {exc}") from exc
+
+ if not isinstance(data, dict):
+ raise ValueError(f"Pack must be a mapping/object: {source}")
+
+ return validate_pack_dict(data, source=source)
+
+
+def parse_pack_bytes(
+ data: bytes,
+ hint_filename: str | None = None,
+ *,
+ content_type: str | None = None,
+) -> dict:
+ """Parse pack bytes (alias of parse_pack_content for bytes)."""
+ return parse_pack_content(data, hint_filename, content_type=content_type)
+
+
+def load_pack_file(path: Path | str) -> dict:
+ """
+ Load and validate a pack from a JSON or YAML file.
+
+ Required: id, name, non-empty files list with safe relative paths.
+ Optional: description, stack, version.
+ """
+ path = Path(path)
+ if not path.is_file():
+ raise ValueError(f"Pack file not found: {path}")
+
+ suffix = path.suffix.lower()
+ if suffix not in _PACK_FILE_SUFFIXES:
+ raise ValueError(f"Unsupported pack file type: {path.suffix!r}")
+
+ try:
+ text = path.read_text(encoding="utf-8")
+ except OSError as exc:
+ raise ValueError(f"Cannot read pack file {path}: {exc}") from exc
+
+ return parse_pack_content(text, hint_filename=path.name)
+
+
+def _load_packs_from_dir(directory: Path) -> dict[str, dict]:
+ """Load all valid pack files from a directory (non-recursive)."""
+ result: dict[str, dict] = {}
+ if not directory.is_dir():
+ return result
+
+ for path in sorted(directory.iterdir()):
+ if not path.is_file():
+ continue
+ if path.suffix.lower() not in _PACK_FILE_SUFFIXES:
+ continue
+ try:
+ pack = load_pack_file(path)
+ except ValueError:
+ # Skip invalid packs rather than failing the whole listing
+ continue
+ result[pack["id"]] = pack
+ return result
+
+
+def _all_packs() -> dict[str, dict]:
+ """
+ Merge built-in packs, installed packs, and optional CREER_PACKS_DIR.
+
+ Later sources override earlier ones on id collision:
+ built-in → installed/ → CREER_PACKS_DIR.
+ """
+ packs = _load_packs_from_dir(PACKS_DIR)
+ packs.update(_load_packs_from_dir(INSTALLED_DIR))
+ extra = _extra_packs_dir()
+ if extra is not None and extra.resolve() != INSTALLED_DIR.resolve():
+ packs.update(_load_packs_from_dir(extra))
+ return packs
+
+
+def list_packs() -> list[dict]:
+ """Return all available packs (built-in + installed + user), sorted by id."""
+ packs = _all_packs()
+ return [dict(packs[k]) for k in sorted(packs.keys())]
+
+
+def get_pack(pack_id: str) -> dict | None:
+ """Look up a pack by id."""
+ if not pack_id:
+ return None
+ pack = _all_packs().get(pack_id)
+ return dict(pack) if pack else None
+
+
+def install_pack(pack: dict, *, overwrite: bool = False) -> dict:
+ """
+ Write a validated pack dict as ``{id}.json`` into the writable packs dir.
+
+ Raises PackConflictError if the id already exists and overwrite is False.
+ """
+ validated = validate_pack_dict(pack, source="")
+ pack_id = validated["id"]
+
+ if get_pack(pack_id) is not None and not overwrite:
+ raise PackConflictError(f"Pack already exists: {pack_id!r}")
+
+ target_dir = writable_packs_dir()
+ target_dir.mkdir(parents=True, exist_ok=True)
+ target = target_dir / f"{pack_id}.json"
+ target.write_text(
+ json.dumps(validated, indent=2, ensure_ascii=False) + "\n",
+ encoding="utf-8",
+ )
+ return dict(validated)
+
+
+def install_pack_from_bytes(
+ data: bytes,
+ *,
+ hint_filename: str = "pack.json",
+ content_type: str | None = None,
+ overwrite: bool = False,
+) -> dict:
+ """Parse pack bytes and install into the writable packs dir."""
+ pack = parse_pack_bytes(data, hint_filename, content_type=content_type)
+ return install_pack(pack, overwrite=overwrite)
+
+
+def uninstall_pack(pack_id: str) -> bool:
+ """
+ Remove an installed pack from the writable dir only.
+
+ Returns True if a file was deleted.
+ Raises PackNotInstalledError if the pack is not present under the writable dir
+ (built-in shipped packs cannot be deleted this way).
+ """
+ if not pack_id or not _SAFE_ID.match(pack_id):
+ raise ValueError(f"Invalid pack id: {pack_id!r}")
+
+ target_dir = writable_packs_dir()
+ if not target_dir.is_dir():
+ raise PackNotInstalledError(f"Pack not installed (writable): {pack_id!r}")
+
+ candidates = [
+ target_dir / f"{pack_id}.json",
+ target_dir / f"{pack_id}.yaml",
+ target_dir / f"{pack_id}.yml",
+ ]
+ deleted = False
+ for path in candidates:
+ if path.is_file():
+ path.unlink()
+ deleted = True
+
+ if not deleted:
+ raise PackNotInstalledError(f"Pack not installed (writable): {pack_id!r}")
+ return True
+
+
+def validate_remote_pack_url(url: str) -> str:
+ """Allow only http/https URLs with a host. Returns the stripped URL."""
+ if not isinstance(url, str) or not url.strip():
+ raise ValueError("url is required")
+ url = url.strip()
+ parsed = urlparse(url)
+ if parsed.scheme not in ("http", "https"):
+ raise ValueError(
+ f"Only http/https URLs are allowed, got scheme {parsed.scheme!r}"
+ )
+ if not parsed.hostname:
+ raise ValueError("URL must include a host")
+ return url
+
+
+def _hint_filename_from_url(url: str) -> str:
+ path = urlparse(url).path or ""
+ name = Path(path).name
+ if name and any(name.lower().endswith(s) for s in _PACK_FILE_SUFFIXES):
+ return name
+ return "pack.json"
+
+
+def fetch_pack_bytes(url: str) -> tuple[bytes, str | None, str]:
+ """
+ Download pack content from a remote URL.
+
+ Returns (body, content_type, hint_filename).
+ Enforces http(s), ~30s timeout, and 1MB max size.
+ """
+ url = validate_remote_pack_url(url)
+ hint = _hint_filename_from_url(url)
+
+ try:
+ with httpx.Client(
+ timeout=FETCH_TIMEOUT_SECONDS,
+ follow_redirects=True,
+ ) as client:
+ with client.stream("GET", url) as resp:
+ if resp.status_code >= 400:
+ raise ValueError(
+ f"Failed to fetch pack ({resp.status_code}): {url}"
+ )
+ content_type = resp.headers.get("content-type")
+ chunks: list[bytes] = []
+ total = 0
+ for chunk in resp.iter_bytes():
+ if not chunk:
+ continue
+ total += len(chunk)
+ if total > MAX_DOWNLOAD_BYTES:
+ raise ValueError(
+ f"Pack download exceeds {MAX_DOWNLOAD_BYTES} byte limit"
+ )
+ chunks.append(chunk)
+ body = b"".join(chunks)
+ except httpx.HTTPError as exc:
+ raise ValueError(f"Failed to fetch pack URL: {exc}") from exc
+
+ if not body:
+ raise ValueError("Pack download was empty")
+
+ return body, content_type, hint
+
+
+def install_pack_from_url(url: str, *, overwrite: bool = False) -> dict:
+ """Fetch a remote pack URL, validate, and install."""
+ body, content_type, hint = fetch_pack_bytes(url)
+ return install_pack_from_bytes(
+ body,
+ hint_filename=hint,
+ content_type=content_type,
+ overwrite=overwrite,
+ )
+
+
+def marketplace_catalog() -> list[dict]:
+ """
+ Static curated marketplace catalog.
+
+ Bundled entries mirror the three shipped packs (offline/demo).
+ Remote entries are illustrative placeholders — do not require network in tests.
+ """
+ return [
+ {
+ "id": "fastapi-crud",
+ "name": "FastAPI CRUD",
+ "description": "FastAPI starter with models, CRUD routes, and uvicorn entrypoint.",
+ "source": "bundled",
+ "url": None,
+ },
+ {
+ "id": "python-lib",
+ "name": "Python Library",
+ "description": "Publishable Python library with pyproject, package layout, and tests.",
+ "source": "bundled",
+ "url": None,
+ },
+ {
+ "id": "express-ts",
+ "name": "Express TypeScript",
+ "description": "Express API in TypeScript with tsconfig and basic router.",
+ "source": "bundled",
+ "url": None,
+ },
+ {
+ "id": "demo-remote",
+ "name": "Demo Remote Pack",
+ "description": (
+ "Example remote pack URL (placeholder). Install via POST /packs/install "
+ "with this url — for demos/docs only; tests should mock the fetch."
+ ),
+ "url": (
+ "https://raw.githubusercontent.com/example/creer-packs/main/"
+ "demo-remote.json"
+ ),
+ "source": "remote",
+ },
+ {
+ "id": "hello-cli",
+ "name": "Hello CLI Pack",
+ "description": (
+ "Example remote CLI starter pack URL (placeholder for marketplace demos)."
+ ),
+ "url": (
+ "https://raw.githubusercontent.com/example/creer-packs/main/"
+ "hello-cli.yaml"
+ ),
+ "source": "remote",
+ },
+ ]
+
+
+def apply_pack(pack_id: str, idea: str) -> dict:
+ """
+ Build a plan from an installable pack.
+
+ Uses the pack's files and stack. Derives project_name by slugifying
+ the idea (deterministic — no API key required).
+ """
+ pack = get_pack(pack_id)
+ if pack is None:
+ raise ValueError(f"Unknown pack_id: {pack_id!r}")
+
+ project_name = slugify(idea)
+ return {
+ "project_name": project_name,
+ "stack": pack["stack"],
+ "files": list(pack["files"]),
+ "pack_id": pack["id"],
+ "description": pack.get("description", ""),
+ }
diff --git a/backend/app/planner.py b/backend/app/planner.py
new file mode 100644
index 0000000..8e225b6
--- /dev/null
+++ b/backend/app/planner.py
@@ -0,0 +1,154 @@
+"""Project planner — AI-driven or template-based."""
+
+from __future__ import annotations
+
+import json
+import re
+
+from config import CREER_OFFLINE, MODEL, OPENAI_API_KEY, OPENAI_BASE_URL
+from app.llm import _get_client
+from app.packs import apply_pack, get_pack
+from app.templates import apply_template, get_template, slugify
+
+
+def _llm_configured() -> bool:
+ """True when an OpenAI key or OpenAI-compatible base URL is available."""
+ return bool(OPENAI_API_KEY or OPENAI_BASE_URL)
+
+
+def _parse_json(content: str) -> dict:
+ """Parse model JSON, tolerating optional markdown fences."""
+ text = content.strip()
+ fence = re.search(r"```(?:json)?\s*([\s\S]*?)```", text)
+ if fence:
+ text = fence.group(1).strip()
+ return json.loads(text)
+
+
+def _ai_plan(idea: str) -> dict:
+ prompt = f"""
+You are a senior software architect.
+
+Convert the following idea into a clean project structure.
+
+Return ONLY valid JSON (no markdown):
+{{
+ "project_name": "...",
+ "stack": "...",
+ "files": ["path/file.py", ...]
+}}
+
+Rules:
+- project_name must be a valid folder name (lowercase, hyphens ok, no spaces)
+- files should be a focused, production-ready starter set (typically 5–15 files, max 40)
+- include README.md and a dependency manifest appropriate for the stack
+
+Idea: {idea}
+"""
+
+ response = _get_client().chat.completions.create(
+ model=MODEL,
+ messages=[{"role": "user", "content": prompt}],
+ temperature=0.2,
+ response_format={"type": "json_object"},
+ )
+
+ plan = _parse_json(response.choices[0].message.content)
+ if not isinstance(plan.get("files"), list) or not plan.get("project_name"):
+ raise ValueError("Planner returned an invalid plan structure")
+ return plan
+
+
+def _ai_name_project(idea: str, template: dict) -> str:
+ """Optionally ask the model for a short project name; fall back to slugify."""
+ try:
+ prompt = f"""
+Given this project idea and stack, return ONLY valid JSON:
+{{"project_name": "short-kebab-case-name"}}
+
+Rules:
+- lowercase, hyphens ok, no spaces
+- 2–40 characters preferred
+- must be a valid folder name
+
+Idea: {idea}
+Stack: {template.get("stack", "")}
+Template: {template.get("name", "")}
+"""
+ response = _get_client().chat.completions.create(
+ model=MODEL,
+ messages=[{"role": "user", "content": prompt}],
+ temperature=0.2,
+ response_format={"type": "json_object"},
+ )
+ data = _parse_json(response.choices[0].message.content)
+ name = data.get("project_name")
+ if isinstance(name, str) and name.strip():
+ return slugify(name)
+ except Exception:
+ pass
+ return slugify(idea)
+
+
+def plan_project(
+ idea: str,
+ template_id: str | None = None,
+ pack_id: str | None = None,
+) -> dict:
+ """
+ Build a project plan from an idea, optionally anchored to a pack or template.
+
+ pack_id and template_id are mutually exclusive — both set raises ValueError.
+
+ When pack_id or template_id is set:
+ - files and stack come from the pack/template
+ - project_name is derived deterministically via slugify (no API key required)
+ - if an LLM is configured and not offline, AI may refine project_name only
+
+ Without either: full AI planning (requires OPENAI_API_KEY or OPENAI_BASE_URL),
+ unless offline — offline without pack_id or template_id raises ValueError.
+ """
+ if pack_id and template_id:
+ raise ValueError("Provide pack_id or template_id, not both")
+
+ if CREER_OFFLINE and not pack_id and not template_id:
+ raise ValueError(
+ "Offline mode requires pack_id or template_id. Pass a pack_id "
+ "(see GET /packs) or template_id (see GET /templates) — AI planning "
+ "is disabled when CREER_OFFLINE is set."
+ )
+
+ if pack_id:
+ pack = get_pack(pack_id)
+ if pack is None:
+ raise ValueError(f"Unknown pack_id: {pack_id!r}")
+
+ plan = apply_pack(pack_id, idea)
+
+ if _llm_configured() and not CREER_OFFLINE:
+ plan["project_name"] = _ai_name_project(idea, pack)
+
+ return plan
+
+ if template_id:
+ tmpl = get_template(template_id)
+ if tmpl is None:
+ raise ValueError(f"Unknown template_id: {template_id!r}")
+
+ plan = apply_template(template_id, idea)
+
+ # Optionally refine name with AI when an LLM endpoint is available and not offline
+ if _llm_configured() and not CREER_OFFLINE:
+ plan["project_name"] = _ai_name_project(idea, tmpl)
+
+ return plan
+
+ if not _llm_configured() and not CREER_OFFLINE:
+ # No LLM endpoint and no pack/template — cannot plan with AI
+ raise ValueError(
+ "OPENAI_API_KEY (or OPENAI_BASE_URL) is not set. Provide a pack_id "
+ "or template_id for pack/template-only planning, or set "
+ "CREER_OFFLINE=1 with a pack_id or template_id."
+ )
+
+ return _ai_plan(idea)
diff --git a/backend/app/quality.py b/backend/app/quality.py
new file mode 100644
index 0000000..be6f534
--- /dev/null
+++ b/backend/app/quality.py
@@ -0,0 +1,102 @@
+"""Telemetry-free quality gates for generated project trees."""
+
+from __future__ import annotations
+
+from typing import Any
+
+MANIFEST_NAMES = {
+ "requirements.txt",
+ "pyproject.toml",
+ "package.json",
+ "Pipfile",
+ "go.mod",
+ "Cargo.toml",
+ "composer.json",
+}
+
+
+def run_quality_gates(
+ plan: dict,
+ files: dict[str, str],
+ *,
+ expect_license: bool = True,
+) -> list[dict[str, Any]]:
+ """
+ Return light static issues for a generated tree.
+
+ Severities: error | warning | info
+ """
+ issues: list[dict[str, Any]] = []
+
+ paths = list(files.keys())
+ seen: set[str] = set()
+ for path in paths:
+ if path in seen:
+ issues.append(
+ {
+ "code": "path_duplicate",
+ "severity": "error",
+ "path": path,
+ "message": f"Duplicate path in generated tree: {path}",
+ }
+ )
+ seen.add(path)
+
+ for path, content in files.items():
+ if not isinstance(content, str) or not content.strip():
+ issues.append(
+ {
+ "code": "empty_file",
+ "severity": "warning",
+ "path": path,
+ "message": f"File is empty or whitespace-only: {path}",
+ }
+ )
+
+ if "README.md" not in files and "README" not in files:
+ issues.append(
+ {
+ "code": "missing_readme",
+ "severity": "warning",
+ "message": "No README.md in generated tree",
+ }
+ )
+
+ if expect_license and "LICENSE" not in files:
+ issues.append(
+ {
+ "code": "missing_license",
+ "severity": "warning",
+ "message": "LICENSE missing after bake-ins",
+ }
+ )
+
+ lower_names = {p.split("/")[-1] for p in files}
+ if not (lower_names & MANIFEST_NAMES):
+ issues.append(
+ {
+ "code": "no_manifest",
+ "severity": "info",
+ "message": "No dependency manifest (requirements.txt / package.json / …)",
+ }
+ )
+
+ # Plan vs files: warn if plan listed paths that weren't generated
+ planned = plan.get("files") or []
+ if isinstance(planned, list):
+ missing = [p for p in planned if isinstance(p, str) and p not in files]
+ for path in missing[:20]:
+ issues.append(
+ {
+ "code": "missing_planned_file",
+ "severity": "warning",
+ "path": path,
+ "message": f"Planned file was not generated: {path}",
+ }
+ )
+
+ return issues
+
+
+def has_errors(issues: list[dict[str, Any]]) -> bool:
+ return any(i.get("severity") == "error" for i in issues)
diff --git a/backend/app/registry.py b/backend/app/registry.py
new file mode 100644
index 0000000..a09901b
--- /dev/null
+++ b/backend/app/registry.py
@@ -0,0 +1,166 @@
+"""Self-hosted pack registry — searchable catalog + downloadable pack JSON."""
+
+from __future__ import annotations
+
+import json
+from typing import Any
+
+from config import CREER_PUBLIC_BASE_URL
+from app.packs import (
+ INSTALLED_DIR,
+ PACKS_DIR,
+ _extra_packs_dir,
+ _load_packs_from_dir,
+ get_pack,
+ list_packs,
+ marketplace_catalog,
+)
+
+
+def _download_path(pack_id: str) -> str:
+ return f"/registry/packs/{pack_id}/download"
+
+
+def _absolute_or_relative(path: str) -> str:
+ base = (CREER_PUBLIC_BASE_URL or "").rstrip("/")
+ if base:
+ return f"{base}{path}"
+ return path
+
+
+def _packs_with_source() -> list[dict[str, Any]]:
+ """
+ Build pack list with source tags.
+
+ Precedence matches list_packs merge: built-in → installed → CREER_PACKS_DIR.
+ Final source reflects the winning file location.
+ """
+ sources: dict[str, str] = {}
+ packs: dict[str, dict] = {}
+
+ for pack_id, pack in _load_packs_from_dir(PACKS_DIR).items():
+ packs[pack_id] = pack
+ sources[pack_id] = "bundled"
+
+ for pack_id, pack in _load_packs_from_dir(INSTALLED_DIR).items():
+ packs[pack_id] = pack
+ sources[pack_id] = "installed"
+
+ extra = _extra_packs_dir()
+ if extra is not None and extra.resolve() != INSTALLED_DIR.resolve():
+ for pack_id, pack in _load_packs_from_dir(extra).items():
+ packs[pack_id] = pack
+ sources[pack_id] = "installed"
+
+ items: list[dict[str, Any]] = []
+ for pack_id in sorted(packs.keys()):
+ pack = dict(packs[pack_id])
+ download = _absolute_or_relative(_download_path(pack_id))
+ items.append(
+ {
+ "id": pack["id"],
+ "name": pack.get("name") or pack["id"],
+ "description": pack.get("description") or "",
+ "stack": pack.get("stack") or "",
+ "version": pack.get("version") or "1.0.0",
+ "source": sources.get(pack_id, "bundled"),
+ "files": list(pack.get("files") or []),
+ "download_url": download,
+ "install_url": download,
+ }
+ )
+ return items
+
+
+def list_registry(
+ *,
+ q: str | None = None,
+ source: str = "all",
+) -> dict[str, Any]:
+ """Searchable registry listing."""
+ source_filter = (source or "all").lower().strip()
+ if source_filter not in ("all", "bundled", "installed"):
+ source_filter = "all"
+
+ items = _packs_with_source()
+ if source_filter != "all":
+ items = [i for i in items if i["source"] == source_filter]
+
+ query = (q or "").strip().lower()
+ if query:
+ def matches(item: dict[str, Any]) -> bool:
+ blob = " ".join(
+ [
+ str(item.get("id") or ""),
+ str(item.get("name") or ""),
+ str(item.get("description") or ""),
+ str(item.get("stack") or ""),
+ ]
+ ).lower()
+ return query in blob
+
+ items = [i for i in items if matches(i)]
+
+ return {
+ "version": "0.9.0",
+ "base_url": CREER_PUBLIC_BASE_URL or None,
+ "items": items,
+ }
+
+
+def get_registry_pack(pack_id: str) -> dict[str, Any] | None:
+ for item in _packs_with_source():
+ if item["id"] == pack_id:
+ # Include full pack body fields
+ pack = get_pack(pack_id)
+ if pack is None:
+ return None
+ out = dict(item)
+ out["files"] = list(pack.get("files") or [])
+ out["stack"] = pack.get("stack") or out.get("stack") or ""
+ out["description"] = pack.get("description") or out.get("description") or ""
+ return out
+ return None
+
+
+def pack_download_bytes(pack_id: str) -> tuple[bytes, str] | None:
+ """Return (json_bytes, filename) for a pack, or None."""
+ pack = get_pack(pack_id)
+ if pack is None:
+ return None
+ # Portable JSON export
+ payload = {
+ "id": pack["id"],
+ "name": pack.get("name") or pack["id"],
+ "description": pack.get("description") or "",
+ "stack": pack.get("stack") or "",
+ "version": pack.get("version") or "1.0.0",
+ "files": list(pack.get("files") or []),
+ }
+ data = (json.dumps(payload, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
+ return data, f"{pack['id']}.json"
+
+
+def featured_marketplace() -> list[dict[str, Any]]:
+ """
+ Backward-compatible marketplace view.
+
+ Bundled catalog entries plus locally available packs as installable via registry download.
+ Remote placeholder URLs from the static catalog are preserved.
+ """
+ featured = marketplace_catalog()
+ # Enrich bundled items that exist locally with download/install URLs
+ local = {i["id"]: i for i in _packs_with_source()}
+ out: list[dict[str, Any]] = []
+ for item in featured:
+ entry = dict(item)
+ local_item = local.get(item["id"])
+ if local_item and not entry.get("url"):
+ entry["url"] = local_item["download_url"]
+ entry["download_url"] = local_item["download_url"]
+ out.append(entry)
+ return out
+
+
+def registry_count() -> int:
+ return len(list_packs())
diff --git a/backend/app/templates.py b/backend/app/templates.py
new file mode 100644
index 0000000..0475b3c
--- /dev/null
+++ b/backend/app/templates.py
@@ -0,0 +1,120 @@
+"""Curated starter templates for Creer v0.2."""
+
+from __future__ import annotations
+
+import re
+
+TEMPLATES: dict[str, dict] = {
+ "fastapi-minimal": {
+ "id": "fastapi-minimal",
+ "name": "FastAPI Minimal",
+ "description": "Minimal FastAPI API with uvicorn, health check, and env config.",
+ "stack": "FastAPI + Uvicorn",
+ "files": [
+ "main.py",
+ "requirements.txt",
+ "README.md",
+ ".env.example",
+ ".gitignore",
+ ],
+ },
+ "express-api": {
+ "id": "express-api",
+ "name": "Express API",
+ "description": "Node.js Express REST API with a basic router and scripts.",
+ "stack": "Node.js + Express",
+ "files": [
+ "package.json",
+ "src/index.js",
+ "src/routes/health.js",
+ "README.md",
+ ".gitignore",
+ ".env.example",
+ ],
+ },
+ "nextjs-app": {
+ "id": "nextjs-app",
+ "name": "Next.js App",
+ "description": "Next.js App Router starter with a home page and package manifest.",
+ "stack": "Next.js + React",
+ "files": [
+ "package.json",
+ "next.config.js",
+ "tsconfig.json",
+ "app/layout.tsx",
+ "app/page.tsx",
+ "app/globals.css",
+ "README.md",
+ ".gitignore",
+ ],
+ },
+ "python-cli": {
+ "id": "python-cli",
+ "name": "Python CLI",
+ "description": "Python command-line tool with argparse entrypoint and packaging basics.",
+ "stack": "Python CLI",
+ "files": [
+ "pyproject.toml",
+ "src/__init__.py",
+ "src/cli.py",
+ "src/__main__.py",
+ "README.md",
+ ".gitignore",
+ ],
+ },
+ "static-site": {
+ "id": "static-site",
+ "name": "Static Site",
+ "description": "Simple static HTML/CSS/JS site ready to open in a browser.",
+ "stack": "HTML + CSS + JavaScript",
+ "files": [
+ "index.html",
+ "styles.css",
+ "script.js",
+ "README.md",
+ ],
+ },
+}
+
+
+def slugify(text: str) -> str:
+ """Derive a filesystem-safe project name from free text."""
+ slug = text.strip().lower()
+ slug = re.sub(r"[^a-z0-9]+", "-", slug)
+ slug = slug.strip("-")
+ slug = re.sub(r"-{2,}", "-", slug)
+ if not slug:
+ slug = "project"
+ return slug[:64]
+
+
+def list_templates() -> list[dict]:
+ """Return all curated templates as a list of dicts."""
+ return [dict(t) for t in TEMPLATES.values()]
+
+
+def get_template(template_id: str) -> dict | None:
+ """Look up a template by id."""
+ tmpl = TEMPLATES.get(template_id)
+ return dict(tmpl) if tmpl else None
+
+
+def apply_template(template_id: str, idea: str) -> dict:
+ """
+ Build a plan from a curated template.
+
+ Uses the template's files and stack. Derives project_name by slugifying
+ the idea (deterministic — no API key required).
+ """
+ tmpl = get_template(template_id)
+ if tmpl is None:
+ raise ValueError(f"Unknown template_id: {template_id!r}")
+
+ project_name = slugify(idea)
+ return {
+ "project_name": project_name,
+ "stack": tmpl["stack"],
+ "files": list(tmpl["files"]),
+ "template_id": tmpl["id"],
+ "description": tmpl.get("description", ""),
+ }
diff --git a/backend/app/validator.py b/backend/app/validator.py
new file mode 100644
index 0000000..f5fcdae
--- /dev/null
+++ b/backend/app/validator.py
@@ -0,0 +1,109 @@
+"""Production-grade plan/file validation for Creer v0.2."""
+
+from __future__ import annotations
+
+import re
+
+# project_name: lowercase start, alphanumerics/hyphen/underscore/dot, no spaces
+SAFE_NAME = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$")
+
+# Relative paths only: no leading slash, no .. segments, safe chars
+SAFE_PATH = re.compile(r"^(?!/)(?!.*(?:^|/)\.\.(?:/|$))[a-zA-Z0-9._/-]+$")
+
+# Windows drive / UNC-ish prefixes
+WINDOWS_DRIVE = re.compile(r"^[a-zA-Z]:")
+
+# Plan file cap; generated set may also include bake-ins (LICENSE, CI, optional README).
+MAX_FILES = 45
+MAX_CONTENT_BYTES_PER_FILE = 200_000
+MAX_TOTAL_CONTENT_BYTES = 2_000_000
+
+
+def _reject_unsafe_path(path: str, *, label: str = "file path") -> None:
+ if not isinstance(path, str):
+ raise ValueError(f"Invalid {label}: must be a string, got {type(path).__name__}")
+
+ if "\x00" in path:
+ raise ValueError(f"Unsafe {label}: contains null byte: {path!r}")
+
+ if path.startswith("/") or path.startswith("\\"):
+ raise ValueError(f"Unsafe {label}: absolute paths are not allowed: {path!r}")
+
+ if WINDOWS_DRIVE.match(path) or path.startswith("\\\\"):
+ raise ValueError(f"Unsafe {label}: Windows drive/UNC paths are not allowed: {path!r}")
+
+ # Normalize separators for .. checks
+ normalized = path.replace("\\", "/")
+ parts = normalized.split("/")
+ if ".." in parts or any(p == ".." for p in parts):
+ raise ValueError(f"Unsafe {label}: path traversal ('..') is not allowed: {path!r}")
+
+ if not SAFE_PATH.match(path):
+ raise ValueError(f"Unsafe or invalid {label}: {path!r}")
+
+
+def validate_plan(plan: dict) -> None:
+ if not isinstance(plan, dict):
+ raise ValueError("Plan must be a dictionary")
+
+ name = plan.get("project_name")
+ files = plan.get("files")
+
+ if not isinstance(name, str) or not name:
+ raise ValueError(f"Invalid project_name: {name!r}")
+
+ if " " in name:
+ raise ValueError(f"Invalid project_name: spaces are not allowed: {name!r}")
+
+ if "\x00" in name:
+ raise ValueError(f"Invalid project_name: contains null byte: {name!r}")
+
+ if not SAFE_NAME.match(name):
+ raise ValueError(
+ f"Invalid project_name: must be 1–64 chars, start with alphanumeric, "
+ f"and contain only [a-zA-Z0-9._-]: {name!r}"
+ )
+
+ if not isinstance(files, list) or not files:
+ raise ValueError("Plan must include a non-empty files list")
+
+ if len(files) > MAX_FILES:
+ raise ValueError(f"Plan exceeds max file count ({MAX_FILES}): got {len(files)}")
+
+ seen: set[str] = set()
+ for path in files:
+ _reject_unsafe_path(path)
+ if path in seen:
+ raise ValueError(f"Duplicate file path in plan: {path!r}")
+ seen.add(path)
+
+
+def validate_files(files: dict) -> None:
+ if not isinstance(files, dict) or not files:
+ raise ValueError("Generated files must be a non-empty mapping")
+
+ if len(files) > MAX_FILES:
+ raise ValueError(f"Generated files exceed max file count ({MAX_FILES}): got {len(files)}")
+
+ total_bytes = 0
+ for path, content in files.items():
+ _reject_unsafe_path(path)
+
+ if not isinstance(content, str):
+ raise ValueError(f"File content for {path!r} must be a string")
+
+ if "\x00" in content:
+ raise ValueError(f"File content for {path!r} contains null bytes")
+
+ size = len(content.encode("utf-8"))
+ if size > MAX_CONTENT_BYTES_PER_FILE:
+ raise ValueError(
+ f"File {path!r} exceeds max content size "
+ f"({MAX_CONTENT_BYTES_PER_FILE} bytes): {size} bytes"
+ )
+ total_bytes += size
+
+ if total_bytes > MAX_TOTAL_CONTENT_BYTES:
+ raise ValueError(
+ f"Total content exceeds max ({MAX_TOTAL_CONTENT_BYTES} bytes): {total_bytes} bytes"
+ )
diff --git a/backend/config.py b/backend/config.py
new file mode 100644
index 0000000..0e5fc9f
--- /dev/null
+++ b/backend/config.py
@@ -0,0 +1,15 @@
+import os
+from dotenv import load_dotenv
+
+load_dotenv()
+
+OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
+OPENAI_BASE_URL = os.getenv("OPENAI_BASE_URL") # e.g. http://127.0.0.1:11434/v1 for Ollama
+MODEL = os.getenv("CREER_MODEL", "gpt-4o-mini")
+CREER_OFFLINE = os.getenv("CREER_OFFLINE", "").lower() in ("1", "true", "yes")
+# Optional extra packs directory (merged with backend/packs; user overrides on id collision)
+CREER_PACKS_DIR = os.getenv("CREER_PACKS_DIR", "").strip() or None
+# Optional public base URL for absolute registry download links
+CREER_PUBLIC_BASE_URL = os.getenv("CREER_PUBLIC_BASE_URL", "").strip() or None
+# Comma-separated peer Creer registry base URLs for federation (v0.8+)
+CREER_REGISTRY_PEERS = os.getenv("CREER_REGISTRY_PEERS", "").strip() or None
diff --git a/backend/main.py b/backend/main.py
new file mode 100644
index 0000000..54609c7
--- /dev/null
+++ b/backend/main.py
@@ -0,0 +1,512 @@
+"""Creer FastAPI application — plan, generate, stream, cancel, GitHub helpers."""
+
+from __future__ import annotations
+
+import json
+from collections.abc import Iterator
+from typing import Any, Literal
+from urllib.parse import urlparse
+
+from fastapi import FastAPI, Header, HTTPException, Query
+from fastapi.responses import Response, StreamingResponse
+from pydantic import BaseModel, Field
+
+from config import CREER_OFFLINE, CREER_PUBLIC_BASE_URL, MODEL, OPENAI_BASE_URL
+from app.bakeins import apply_bakeins, list_bakein_options
+from app.planner import plan_project
+from app.generator import generate_files, generate_files_iter
+from app.validator import validate_plan, validate_files
+from app.templates import list_templates, get_template
+from app.packs import (
+ PackConflictError,
+ PackNotInstalledError,
+ get_pack,
+ install_pack_from_url,
+ list_packs,
+ uninstall_pack,
+)
+from app.registry import (
+ featured_marketplace,
+ get_registry_pack,
+ list_registry,
+ pack_download_bytes,
+ registry_count,
+)
+from app.federation import list_federated, list_peer_status, parse_peers, probe_peer
+from app.github import create_github_repo
+from app.jobs import cancel_job, create_job, finish_job, is_cancelled
+from app.quality import has_errors, run_quality_gates
+
+VERSION = "0.9.0"
+
+app = FastAPI(title="Creer", version=VERSION)
+
+
+class PlanRequest(BaseModel):
+ idea: str = Field(..., min_length=3, max_length=4000)
+ template_id: str | None = None
+ pack_id: str | None = None
+
+
+class PlanBody(BaseModel):
+ project_name: str
+ stack: str | None = None
+ files: list[str]
+ template_id: str | None = None
+ pack_id: str | None = None
+ description: str | None = None
+
+
+class BakeinOptions(BaseModel):
+ license: Literal["mit", "apache-2.0", "none"] = "mit"
+ ci: Literal["auto", "python", "node", "none"] = "auto"
+ include_readme: bool = True
+
+
+class GenerateRequest(BaseModel):
+ idea: str = Field(..., min_length=3, max_length=4000)
+ template_id: str | None = None
+ pack_id: str | None = None
+ plan: PlanBody | None = None
+ job_id: str | None = None
+ bakeins: BakeinOptions | None = None
+
+
+class CancelRequest(BaseModel):
+ job_id: str = Field(..., min_length=1)
+
+
+class QualityRequest(BaseModel):
+ plan: PlanBody | None = None
+ files: dict[str, str]
+ bakeins: BakeinOptions | None = None
+
+
+class GitHubCreateRepoRequest(BaseModel):
+ name: str = Field(..., min_length=1, max_length=100)
+ private: bool = True
+ description: str = ""
+ token: str | None = None
+
+
+class PackInstallRequest(BaseModel):
+ url: str = Field(..., min_length=1, max_length=2000)
+ overwrite: bool = False
+
+
+class PeerProbeRequest(BaseModel):
+ url: str = Field(..., min_length=1, max_length=2000)
+
+
+def _check_pack_template_exclusive(
+ pack_id: str | None, template_id: str | None
+) -> None:
+ if pack_id and template_id:
+ raise ValueError("Provide pack_id or template_id, not both")
+
+
+def _resolve_plan(request: GenerateRequest) -> dict:
+ """Resolve a validated plan from GenerateRequest (shared by sync + stream)."""
+ _check_pack_template_exclusive(request.pack_id, request.template_id)
+
+ if request.plan is not None:
+ plan = request.plan.model_dump()
+ plan = {k: v for k, v in plan.items() if v is not None}
+ plan.setdefault("stack", "")
+ _check_pack_template_exclusive(plan.get("pack_id"), plan.get("template_id"))
+ if request.pack_id and "pack_id" not in plan:
+ plan["pack_id"] = request.pack_id
+ if request.template_id and "template_id" not in plan:
+ plan["template_id"] = request.template_id
+ # Re-check after merging top-level ids into plan
+ _check_pack_template_exclusive(plan.get("pack_id"), plan.get("template_id"))
+ else:
+ if request.pack_id and get_pack(request.pack_id) is None:
+ raise ValueError(f"Unknown pack_id: {request.pack_id!r}")
+ if request.template_id and get_template(request.template_id) is None:
+ raise ValueError(f"Unknown template_id: {request.template_id!r}")
+ plan = plan_project(
+ request.idea,
+ template_id=request.template_id,
+ pack_id=request.pack_id,
+ )
+
+ validate_plan(plan)
+ return plan
+
+
+def _bakein_dict(bakeins: BakeinOptions | None) -> dict[str, Any] | None:
+ return bakeins.model_dump() if bakeins is not None else None
+
+
+def _expect_license(bakeins: BakeinOptions | None) -> bool:
+ if bakeins is None:
+ return True
+ return bakeins.license != "none"
+
+
+def _sse(data: dict) -> str:
+ return f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
+
+
+@app.get("/health")
+def health():
+ return {
+ "status": "ok",
+ "version": VERSION,
+ "offline": CREER_OFFLINE,
+ "base_url_set": bool(OPENAI_BASE_URL),
+ "model": MODEL,
+ "packs_count": len(list_packs()),
+ "registry_count": registry_count(),
+ "public_base_url_set": bool(CREER_PUBLIC_BASE_URL),
+ "peers_configured": len(parse_peers()),
+ }
+
+
+@app.get("/templates")
+def templates():
+ return {"templates": list_templates()}
+
+
+@app.get("/packs")
+def packs():
+ return {"packs": list_packs()}
+
+
+@app.get("/registry")
+def registry(
+ q: str | None = Query(default=None, description="Search name/description/id/stack"),
+ source: str = Query(default="all", description="bundled | installed | all"),
+):
+ """Self-hosted searchable pack registry (local only)."""
+ return list_registry(q=q, source=source)
+
+
+@app.get("/registry/federated")
+def registry_federated(
+ q: str | None = Query(default=None, description="Search name/description/id/stack"),
+ source: str = Query(default="all", description="bundled | installed | all"),
+ peers: str | None = Query(
+ default=None,
+ description="Comma-separated extra peer base URLs for this request only",
+ ),
+):
+ """Federated registry: local packs plus peer Creer registries."""
+ extra = parse_peers(peers) if peers else None
+ return list_federated(
+ q=q, source=source, include_local=True, extra_peers=extra
+ )
+
+
+@app.get("/registry/peers")
+def registry_peers():
+ """Configured peer list plus live probe status for each peer."""
+ return {"peers": list_peer_status(), "configured": parse_peers()}
+
+
+@app.post("/registry/peers/probe")
+def registry_peers_probe(request: PeerProbeRequest):
+ """Ad-hoc probe of a single peer base URL."""
+ url = (request.url or "").strip().rstrip("/")
+ parsed = urlparse(url)
+ if parsed.scheme not in ("http", "https") or not parsed.netloc:
+ raise HTTPException(
+ status_code=400,
+ detail="url must use http or https with a host",
+ )
+ return probe_peer(url)
+
+
+@app.get("/registry/packs/{pack_id}")
+def registry_pack_detail(pack_id: str):
+ item = get_registry_pack(pack_id)
+ if item is None:
+ raise HTTPException(status_code=404, detail=f"Unknown pack_id: {pack_id!r}")
+ return item
+
+
+@app.get("/registry/packs/{pack_id}/download")
+def registry_pack_download(pack_id: str):
+ """Download pack as portable JSON (usable as POST /packs/install url)."""
+ result = pack_download_bytes(pack_id)
+ if result is None:
+ raise HTTPException(status_code=404, detail=f"Unknown pack_id: {pack_id!r}")
+ data, filename = result
+ return Response(
+ content=data,
+ media_type="application/json",
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+ )
+
+
+@app.get("/marketplace")
+def marketplace():
+ """Curated marketplace view (featured + local download URLs when available)."""
+ return {"items": featured_marketplace()}
+
+
+@app.post("/packs/install")
+def packs_install(request: PackInstallRequest):
+ """Fetch a remote pack URL (http/https), validate, and install locally."""
+ try:
+ pack = install_pack_from_url(request.url, overwrite=request.overwrite)
+ except PackConflictError as exc:
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ except Exception as exc:
+ raise HTTPException(
+ status_code=500, detail=f"Pack install failed: {exc}"
+ ) from exc
+ return {"installed": True, "pack": pack}
+
+
+@app.get("/packs/{pack_id}")
+def pack_detail(pack_id: str):
+ pack = get_pack(pack_id)
+ if pack is None:
+ raise HTTPException(status_code=404, detail=f"Unknown pack_id: {pack_id!r}")
+ return pack
+
+
+@app.delete("/packs/{pack_id}")
+def packs_delete(pack_id: str):
+ """Delete a pack from the writable installed dir only (not shipped examples)."""
+ try:
+ uninstall_pack(pack_id)
+ except PackNotInstalledError as exc:
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ return {"deleted": True, "pack_id": pack_id}
+
+
+@app.get("/bakeins")
+def bakeins():
+ return list_bakein_options()
+
+
+@app.post("/plan")
+def plan_only(request: PlanRequest):
+ """Return a project plan without generating file contents."""
+ try:
+ _check_pack_template_exclusive(request.pack_id, request.template_id)
+ if request.pack_id and get_pack(request.pack_id) is None:
+ raise ValueError(f"Unknown pack_id: {request.pack_id!r}")
+ if request.template_id and get_template(request.template_id) is None:
+ raise ValueError(f"Unknown template_id: {request.template_id!r}")
+ plan = plan_project(
+ request.idea,
+ template_id=request.template_id,
+ pack_id=request.pack_id,
+ )
+ validate_plan(plan)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ except Exception as exc:
+ raise HTTPException(status_code=500, detail=f"Planning failed: {exc}") from exc
+
+ result = {
+ "project_name": plan["project_name"],
+ "stack": plan.get("stack"),
+ "files": plan["files"],
+ }
+ if plan.get("pack_id"):
+ result["pack_id"] = plan["pack_id"]
+ if plan.get("template_id"):
+ result["template_id"] = plan["template_id"]
+ if plan.get("description"):
+ result["description"] = plan["description"]
+ return result
+
+
+@app.post("/generate")
+def generate_project(request: GenerateRequest):
+ job_id = create_job(request.job_id)
+ try:
+ if is_cancelled(job_id):
+ raise ValueError("Cancelled by user")
+ plan = _resolve_plan(request)
+ files = generate_files(plan, should_cancel=lambda: is_cancelled(job_id))
+ files = apply_bakeins(plan, files, _bakein_dict(request.bakeins))
+ validate_files(files)
+ quality = run_quality_gates(
+ plan, files, expect_license=_expect_license(request.bakeins)
+ )
+ if has_errors(quality):
+ detail = "; ".join(
+ f"{i.get('code')}: {i.get('message')}" for i in quality if i.get("severity") == "error"
+ )
+ raise ValueError(f"Quality gate failed: {detail}")
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ except Exception as exc:
+ raise HTTPException(status_code=500, detail=f"Generation failed: {exc}") from exc
+ finally:
+ finish_job(job_id)
+
+ result = {
+ "project_name": plan["project_name"],
+ "stack": plan.get("stack"),
+ "files": files,
+ "quality": quality,
+ "job_id": job_id,
+ }
+ if plan.get("pack_id"):
+ result["pack_id"] = plan["pack_id"]
+ if plan.get("template_id"):
+ result["template_id"] = plan["template_id"]
+ return result
+
+
+@app.post("/generate/stream")
+def generate_project_stream(request: GenerateRequest):
+ """Stream generation progress as Server-Sent Events (JSON data lines)."""
+ job_id = create_job(request.job_id)
+
+ def event_stream() -> Iterator[str]:
+ plan: dict | None = None
+ try:
+ if is_cancelled(job_id):
+ yield _sse(
+ {
+ "event": "cancelled",
+ "job_id": job_id,
+ "detail": "Cancelled by user",
+ }
+ )
+ return
+
+ plan = _resolve_plan(request)
+ file_list = list(plan["files"])
+ yield _sse(
+ {
+ "event": "start",
+ "job_id": job_id,
+ "project_name": plan["project_name"],
+ "total": len(file_list),
+ "stack": plan.get("stack") or "",
+ }
+ )
+
+ files: dict[str, str] = {}
+ cancelled = False
+ for event, partial in generate_files_iter(
+ plan, should_cancel=lambda: is_cancelled(job_id)
+ ):
+ files = partial
+ if event.get("event") == "cancelled":
+ cancelled = True
+ yield _sse(
+ {
+ "event": "cancelled",
+ "job_id": job_id,
+ "detail": event.get("detail") or "Cancelled by user",
+ }
+ )
+ break
+ yield _sse(event)
+
+ if cancelled:
+ return
+
+ files = apply_bakeins(plan, files, _bakein_dict(request.bakeins))
+ validate_files(files)
+ quality = run_quality_gates(
+ plan, files, expect_license=_expect_license(request.bakeins)
+ )
+ if has_errors(quality):
+ detail = "; ".join(
+ f"{i.get('code')}: {i.get('message')}"
+ for i in quality
+ if i.get("severity") == "error"
+ )
+ yield _sse({"event": "error", "detail": f"Quality gate failed: {detail}", "quality": quality})
+ return
+
+ done: dict = {
+ "event": "done",
+ "job_id": job_id,
+ "project_name": plan["project_name"],
+ "files": files,
+ "stack": plan.get("stack") or "",
+ "quality": quality,
+ }
+ if plan.get("pack_id"):
+ done["pack_id"] = plan["pack_id"]
+ if plan.get("template_id"):
+ done["template_id"] = plan["template_id"]
+ yield _sse(done)
+ except ValueError as exc:
+ yield _sse({"event": "error", "detail": str(exc)})
+ except Exception as exc:
+ yield _sse({"event": "error", "detail": f"Generation failed: {exc}"})
+ finally:
+ finish_job(job_id)
+
+ return StreamingResponse(
+ event_stream(),
+ media_type="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ "X-Accel-Buffering": "no",
+ },
+ )
+
+
+@app.post("/generate/cancel")
+def generate_cancel(request: CancelRequest):
+ ok = cancel_job(request.job_id)
+ if not ok:
+ raise HTTPException(status_code=400, detail="Invalid job_id")
+ return {"cancelled": True, "job_id": request.job_id}
+
+
+@app.post("/quality")
+def quality_check(request: QualityRequest):
+ plan = request.plan.model_dump() if request.plan is not None else {"files": list(request.files.keys())}
+ plan.setdefault("files", list(request.files.keys()))
+ quality = run_quality_gates(
+ plan,
+ request.files,
+ expect_license=_expect_license(request.bakeins),
+ )
+ return {"quality": quality, "ok": not has_errors(quality)}
+
+
+@app.post("/github/create-repo")
+def github_create_repo(
+ request: GitHubCreateRepoRequest,
+ authorization: str | None = Header(default=None),
+):
+ """Create a GitHub repo. Prefer Authorization: Bearer ; body.token also accepted."""
+ token = None
+ if authorization:
+ parts = authorization.split(" ", 1)
+ if len(parts) == 2 and parts[0].lower() == "bearer":
+ token = parts[1].strip()
+ else:
+ token = authorization.strip()
+ if not token:
+ token = request.token
+ if not token:
+ raise HTTPException(
+ status_code=401,
+ detail="GitHub token required via Authorization: Bearer or body.token",
+ )
+
+ try:
+ result = create_github_repo(
+ token=token,
+ name=request.name,
+ private=request.private,
+ description=request.description,
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ except Exception as exc:
+ raise HTTPException(status_code=500, detail=f"GitHub create failed: {exc}") from exc
+
+ return result
diff --git a/backend/packs/express-ts.yaml b/backend/packs/express-ts.yaml
new file mode 100644
index 0000000..45b257d
--- /dev/null
+++ b/backend/packs/express-ts.yaml
@@ -0,0 +1,13 @@
+id: express-ts
+name: Express TypeScript
+description: Express API in TypeScript with tsconfig and basic router.
+stack: Node.js + Express + TypeScript
+version: "1.0.0"
+files:
+ - package.json
+ - tsconfig.json
+ - src/index.ts
+ - src/routes/health.ts
+ - README.md
+ - .gitignore
+ - .env.example
diff --git a/backend/packs/fastapi-crud.json b/backend/packs/fastapi-crud.json
new file mode 100644
index 0000000..443adcf
--- /dev/null
+++ b/backend/packs/fastapi-crud.json
@@ -0,0 +1,17 @@
+{
+ "id": "fastapi-crud",
+ "name": "FastAPI CRUD",
+ "description": "FastAPI starter with models, CRUD routes, and uvicorn entrypoint.",
+ "stack": "FastAPI + Uvicorn",
+ "version": "1.0.0",
+ "files": [
+ "main.py",
+ "requirements.txt",
+ "README.md",
+ "app/__init__.py",
+ "app/models.py",
+ "app/routes.py",
+ ".env.example",
+ ".gitignore"
+ ]
+}
diff --git a/backend/packs/installed/.gitkeep b/backend/packs/installed/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/backend/packs/python-lib.json b/backend/packs/python-lib.json
new file mode 100644
index 0000000..e9fcd8e
--- /dev/null
+++ b/backend/packs/python-lib.json
@@ -0,0 +1,15 @@
+{
+ "id": "python-lib",
+ "name": "Python Library",
+ "description": "Publishable Python library with pyproject, package layout, and tests.",
+ "stack": "Python library",
+ "version": "1.0.0",
+ "files": [
+ "pyproject.toml",
+ "README.md",
+ "src/mylib/__init__.py",
+ "src/mylib/core.py",
+ "tests/test_core.py",
+ ".gitignore"
+ ]
+}
diff --git a/backend/requirements.txt b/backend/requirements.txt
new file mode 100644
index 0000000..4de1c63
--- /dev/null
+++ b/backend/requirements.txt
@@ -0,0 +1,8 @@
+fastapi>=0.115.0
+uvicorn[standard]>=0.32.0
+openai>=1.55.0
+python-dotenv>=1.0.0
+pydantic>=2.9.0
+httpx>=0.27.0
+PyYAML>=6.0.0
+pytest>=8.0.0
diff --git a/backend/tests/test_federation.py b/backend/tests/test_federation.py
new file mode 100644
index 0000000..171c111
--- /dev/null
+++ b/backend/tests/test_federation.py
@@ -0,0 +1,189 @@
+"""Tests for federated multi-host registry discovery (v0.8)."""
+
+from __future__ import annotations
+
+from fastapi.testclient import TestClient
+
+import main
+from app import federation as fed
+from app.federation import list_federated, parse_peers
+from main import VERSION
+
+
+def test_parse_peers_normalize_dedupe():
+ raw = (
+ " http://127.0.0.1:8001/,https://creer-packs.example.com,"
+ "http://127.0.0.1:8001,ftp://bad.example,not-a-url,,https://creer-packs.example.com/ "
+ )
+ peers = parse_peers(raw)
+ assert peers == [
+ "http://127.0.0.1:8001",
+ "https://creer-packs.example.com",
+ ]
+
+
+def test_parse_peers_from_config(monkeypatch):
+ monkeypatch.setattr(
+ fed,
+ "CREER_REGISTRY_PEERS",
+ "https://a.example/,https://b.example",
+ )
+ assert parse_peers() == ["https://a.example", "https://b.example"]
+
+
+def test_list_federated_mocked_peer(monkeypatch):
+ monkeypatch.setattr(
+ fed,
+ "CREER_REGISTRY_PEERS",
+ "http://peer.test:9000",
+ )
+
+ peer_item = {
+ "id": "peer-only-pack",
+ "name": "Peer Pack",
+ "description": "from peer",
+ "stack": "python",
+ "version": "1.0.0",
+ "source": "bundled",
+ "files": ["README.md"],
+ "download_url": "/registry/packs/peer-only-pack/download",
+ "install_url": "/registry/packs/peer-only-pack/download",
+ }
+
+ def fake_fetch(base_url, *, q=None, source=None, timeout=8.0):
+ assert base_url == "http://peer.test:9000"
+ return [
+ {
+ **peer_item,
+ "peer": base_url,
+ "download_url": f"{base_url}/registry/packs/peer-only-pack/download",
+ "install_url": f"{base_url}/registry/packs/peer-only-pack/download",
+ }
+ ], None
+
+ monkeypatch.setattr(fed, "_fetch_peer_registry", fake_fetch)
+
+ result = list_federated()
+ assert result["version"] == "0.9.0"
+ assert "items" in result["local"]
+ assert len(result["peers"]) == 1
+ assert result["peers"][0]["ok"] is True
+ assert result["peers"][0]["count"] == 1
+ assert result["peers"][0]["error"] is None
+
+ ids = [i["id"] for i in result["items"]]
+ assert "peer-only-pack" in ids
+ # Local packs come first
+ local_ids = {i["id"] for i in result["local"]["items"]}
+ assert ids[: len(local_ids)] == [i["id"] for i in result["local"]["items"]]
+
+ peer_entry = next(i for i in result["items"] if i["id"] == "peer-only-pack")
+ assert peer_entry["peer"] == "http://peer.test:9000"
+ assert peer_entry["download_url"].startswith("http://peer.test:9000/")
+
+
+def test_peer_failure_keeps_local(monkeypatch):
+ monkeypatch.setattr(
+ fed,
+ "CREER_REGISTRY_PEERS",
+ "http://dead.peer:9999",
+ )
+
+ def fake_fetch(base_url, *, q=None, source=None, timeout=8.0):
+ return [], "connection refused"
+
+ monkeypatch.setattr(fed, "_fetch_peer_registry", fake_fetch)
+
+ result = list_federated()
+ assert result["version"] == "0.9.0"
+ local_count = len(result["local"]["items"])
+ assert local_count >= 3
+ assert len(result["items"]) == local_count
+ assert result["peers"][0]["ok"] is False
+ assert result["peers"][0]["count"] == 0
+ assert "connection refused" in (result["peers"][0]["error"] or "")
+
+
+def test_dedupe_prefers_local(monkeypatch):
+ monkeypatch.setattr(
+ fed,
+ "CREER_REGISTRY_PEERS",
+ "http://peer.test:9000",
+ )
+
+ def fake_fetch(base_url, *, q=None, source=None, timeout=8.0):
+ # Overlap with bundled fastapi-crud id
+ return [
+ {
+ "id": "fastapi-crud",
+ "name": "Peer FastAPI",
+ "peer": base_url,
+ "download_url": f"{base_url}/registry/packs/fastapi-crud/download",
+ }
+ ], None
+
+ monkeypatch.setattr(fed, "_fetch_peer_registry", fake_fetch)
+ result = list_federated()
+ matches = [i for i in result["items"] if i["id"] == "fastapi-crud"]
+ assert len(matches) == 1
+ assert matches[0].get("peer") is None # local wins
+
+
+def test_health_0_9_and_federated_route(monkeypatch):
+ monkeypatch.setattr(fed, "CREER_REGISTRY_PEERS", "https://a.example,https://b.example")
+ # Also patch config import used if parse_peers reads module-level — already patched fed
+ c = TestClient(main.app)
+ h = c.get("/health").json()
+ assert h["version"] == "0.9.0"
+ assert VERSION == "0.9.0"
+ assert h["peers_configured"] == 2
+
+ # No live peers — empty mock via monkeypatch on fetch
+ monkeypatch.setattr(
+ fed,
+ "_fetch_peer_registry",
+ lambda *a, **k: ([], "offline"),
+ )
+ r = c.get("/registry/federated")
+ assert r.status_code == 200
+ body = r.json()
+ assert body["version"] == "0.9.0"
+ assert "local" in body
+ assert len(body["items"]) == len(body["local"]["items"])
+
+
+def test_fetch_peer_registry_absolutizes(monkeypatch):
+ class FakeResp:
+ def raise_for_status(self):
+ return None
+
+ def json(self):
+ return {
+ "items": [
+ {
+ "id": "remote-pack",
+ "download_url": "/registry/packs/remote-pack/download",
+ "install_url": "/registry/packs/remote-pack/download",
+ }
+ ]
+ }
+
+ class FakeClient:
+ def __init__(self, *a, **k):
+ pass
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *a):
+ return False
+
+ def get(self, url, params=None):
+ assert url == "http://peer.example/registry"
+ return FakeResp()
+
+ monkeypatch.setattr(fed.httpx, "Client", FakeClient)
+ items = fed.fetch_peer_registry("http://peer.example")
+ assert len(items) == 1
+ assert items[0]["peer"] == "http://peer.example"
+ assert items[0]["download_url"] == "http://peer.example/registry/packs/remote-pack/download"
diff --git a/backend/tests/test_federation_ux.py b/backend/tests/test_federation_ux.py
new file mode 100644
index 0000000..d7e66f6
--- /dev/null
+++ b/backend/tests/test_federation_ux.py
@@ -0,0 +1,292 @@
+"""Tests for federation UX — peer status, probe, ad-hoc peers (v0.9)."""
+
+from __future__ import annotations
+
+from fastapi.testclient import TestClient
+
+import main
+from app import federation as fed
+from app.federation import list_federated, list_peer_status, probe_peer, resolve_peers
+from main import VERSION
+
+
+def test_probe_peer_ok(monkeypatch):
+ calls: list[str] = []
+
+ class FakeResp:
+ def __init__(self, data):
+ self._data = data
+
+ def raise_for_status(self):
+ return None
+
+ def json(self):
+ return self._data
+
+ class FakeClient:
+ def __init__(self, *a, **k):
+ pass
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *a):
+ return False
+
+ def get(self, url, params=None):
+ calls.append(url)
+ if url.endswith("/health"):
+ return FakeResp(
+ {"status": "ok", "version": "0.8.0", "registry_count": 3}
+ )
+ raise AssertionError(f"unexpected url: {url}")
+
+ monkeypatch.setattr(fed.httpx, "Client", FakeClient)
+ result = probe_peer("http://peer.example:8001")
+ assert result["ok"] is True
+ assert result["base_url"] == "http://peer.example:8001"
+ assert result["version"] == "0.8.0"
+ assert result["count"] == 3
+ assert result["error"] is None
+ assert isinstance(result["latency_ms"], (int, float))
+ assert result["latency_ms"] >= 0
+ assert any(u.endswith("/health") for u in calls)
+
+
+def test_probe_peer_fail(monkeypatch):
+ class FakeClient:
+ def __init__(self, *a, **k):
+ pass
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *a):
+ return False
+
+ def get(self, url, params=None):
+ raise ConnectionError("connection refused")
+
+ monkeypatch.setattr(fed.httpx, "Client", FakeClient)
+ result = probe_peer("http://dead.peer:9999")
+ assert result["ok"] is False
+ assert result["base_url"] == "http://dead.peer:9999"
+ assert result["error"]
+ assert "connection refused" in result["error"]
+ assert result["count"] is None
+ assert isinstance(result["latency_ms"], (int, float))
+
+
+def test_probe_peer_registry_fallback(monkeypatch):
+ """When /health fails, /registry still yields ok + count."""
+
+ class FakeResp:
+ def __init__(self, data):
+ self._data = data
+
+ def raise_for_status(self):
+ return None
+
+ def json(self):
+ return self._data
+
+ class FakeClient:
+ def __init__(self, *a, **k):
+ pass
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *a):
+ return False
+
+ def get(self, url, params=None):
+ if url.endswith("/health"):
+ raise ConnectionError("no health")
+ if url.endswith("/registry"):
+ return FakeResp(
+ {
+ "version": "0.8.0",
+ "items": [{"id": "a"}, {"id": "b"}],
+ }
+ )
+ raise AssertionError(url)
+
+ monkeypatch.setattr(fed.httpx, "Client", FakeClient)
+ result = probe_peer("http://peer.example")
+ assert result["ok"] is True
+ assert result["count"] == 2
+ assert result["version"] == "0.8.0"
+ assert result["error"] is None
+
+
+def test_resolve_peers_merges_extra_dedupe_max():
+ # configured via monkeypatch below in other tests; unit-test via parse_peers path
+ peers = resolve_peers(None)
+ assert isinstance(peers, list)
+
+ # With extras only when no config
+ from app import federation as f
+
+ # Temporarily empty config
+ original = f.CREER_REGISTRY_PEERS
+ try:
+ f.CREER_REGISTRY_PEERS = "http://a.example,http://b.example"
+ merged = resolve_peers(
+ [
+ "http://b.example/",
+ "http://c.example",
+ "ftp://bad",
+ "http://d.example",
+ ]
+ )
+ assert merged == [
+ "http://a.example",
+ "http://b.example",
+ "http://c.example",
+ "http://d.example",
+ ]
+
+ f.CREER_REGISTRY_PEERS = ",".join(f"http://p{i}.example" for i in range(10))
+ capped = resolve_peers(["http://extra.example"])
+ assert len(capped) == 8
+ assert "http://extra.example" not in capped # configured fills the cap
+ finally:
+ f.CREER_REGISTRY_PEERS = original
+
+
+def test_list_peer_status_concurrent(monkeypatch):
+ monkeypatch.setattr(
+ fed,
+ "CREER_REGISTRY_PEERS",
+ "http://a.example,http://b.example",
+ )
+
+ def fake_probe(base_url, timeout=5.0):
+ return {
+ "base_url": base_url,
+ "ok": base_url.endswith("a.example"),
+ "latency_ms": 1.0,
+ "count": 1 if base_url.endswith("a.example") else None,
+ "version": "0.9.0" if base_url.endswith("a.example") else None,
+ "error": None if base_url.endswith("a.example") else "down",
+ }
+
+ monkeypatch.setattr(fed, "probe_peer", fake_probe)
+ statuses = list_peer_status()
+ assert len(statuses) == 2
+ assert statuses[0]["base_url"] == "http://a.example"
+ assert statuses[0]["ok"] is True
+ assert statuses[1]["base_url"] == "http://b.example"
+ assert statuses[1]["ok"] is False
+
+
+def test_registry_peers_route(monkeypatch):
+ monkeypatch.setattr(
+ fed,
+ "CREER_REGISTRY_PEERS",
+ "https://peer.example",
+ )
+ monkeypatch.setattr(
+ fed,
+ "probe_peer",
+ lambda base_url, timeout=5.0: {
+ "base_url": base_url,
+ "ok": True,
+ "latency_ms": 2.5,
+ "count": 4,
+ "version": "0.9.0",
+ "error": None,
+ },
+ )
+ c = TestClient(main.app)
+ r = c.get("/registry/peers")
+ assert r.status_code == 200
+ body = r.json()
+ assert body["configured"] == ["https://peer.example"]
+ assert len(body["peers"]) == 1
+ assert body["peers"][0]["ok"] is True
+ assert body["peers"][0]["count"] == 4
+
+
+def test_registry_peers_probe_validation():
+ c = TestClient(main.app)
+ bad = c.post("/registry/peers/probe", json={"url": "ftp://evil.example"})
+ assert bad.status_code == 400
+
+ bad2 = c.post("/registry/peers/probe", json={"url": "not-a-url"})
+ assert bad2.status_code == 400
+
+ bad3 = c.post("/registry/peers/probe", json={"url": "http://"})
+ assert bad3.status_code == 400
+
+
+def test_registry_peers_probe_ok(monkeypatch):
+ monkeypatch.setattr(
+ main,
+ "probe_peer",
+ lambda base_url, timeout=5.0: {
+ "base_url": base_url.rstrip("/"),
+ "ok": True,
+ "latency_ms": 10.0,
+ "count": 2,
+ "version": "0.9.0",
+ "error": None,
+ },
+ )
+ c = TestClient(main.app)
+ r = c.post("/registry/peers/probe", json={"url": "http://peer.test:9000/"})
+ assert r.status_code == 200
+ body = r.json()
+ assert body["ok"] is True
+ assert body["base_url"] == "http://peer.test:9000"
+ assert body["count"] == 2
+
+
+def test_federated_with_extra_peers_query(monkeypatch):
+ monkeypatch.setattr(fed, "CREER_REGISTRY_PEERS", "")
+
+ def fake_fetch(base_url, *, q=None, source=None, timeout=8.0):
+ assert base_url == "http://adhoc.peer:8002"
+ return [
+ {
+ "id": "adhoc-pack",
+ "name": "Adhoc",
+ "peer": base_url,
+ "download_url": f"{base_url}/registry/packs/adhoc-pack/download",
+ }
+ ], None
+
+ monkeypatch.setattr(fed, "_fetch_peer_registry", fake_fetch)
+ c = TestClient(main.app)
+ r = c.get("/registry/federated", params={"peers": "http://adhoc.peer:8002/"})
+ assert r.status_code == 200
+ body = r.json()
+ assert body["version"] == "0.9.0"
+ assert any(p["base_url"] == "http://adhoc.peer:8002" for p in body["peers"])
+ assert any(i["id"] == "adhoc-pack" for i in body["items"])
+
+
+def test_list_federated_extra_peers_arg(monkeypatch):
+ monkeypatch.setattr(fed, "CREER_REGISTRY_PEERS", "http://cfg.peer")
+
+ seen: list[str] = []
+
+ def fake_fetch(base_url, *, q=None, source=None, timeout=8.0):
+ seen.append(base_url)
+ return [], None
+
+ monkeypatch.setattr(fed, "_fetch_peer_registry", fake_fetch)
+ result = list_federated(extra_peers=["http://extra.peer/", "http://cfg.peer"])
+ assert result["version"] == "0.9.0"
+ assert seen == ["http://cfg.peer", "http://extra.peer"]
+
+
+def test_health_0_9(monkeypatch):
+ monkeypatch.setattr(fed, "CREER_REGISTRY_PEERS", "https://a.example,https://b.example")
+ c = TestClient(main.app)
+ h = c.get("/health").json()
+ assert h["version"] == "0.9.0"
+ assert VERSION == "0.9.0"
+ assert h["peers_configured"] == 2
+ assert h["status"] == "ok"
diff --git a/backend/tests/test_marketplace.py b/backend/tests/test_marketplace.py
new file mode 100644
index 0000000..5a70869
--- /dev/null
+++ b/backend/tests/test_marketplace.py
@@ -0,0 +1,205 @@
+"""Creer v0.6 marketplace / remote pack install tests (offline)."""
+
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+
+import pytest
+from fastapi.testclient import TestClient
+
+# Ensure offline before importing the app
+os.environ["CREER_OFFLINE"] = "1"
+# Isolate installs into a temp dir via fixture; clear CREER_PACKS_DIR by default
+os.environ.pop("CREER_PACKS_DIR", None)
+
+from app import packs as packs_mod
+from main import VERSION, app
+
+
+SAMPLE_PACK = {
+ "id": "marketplace-demo",
+ "name": "Marketplace Demo",
+ "description": "Installed via tests",
+ "stack": "demo",
+ "version": "1.0.0",
+ "files": ["README.md", "main.py"],
+}
+
+
+@pytest.fixture()
+def install_dir(tmp_path, monkeypatch):
+ """Point writable installs at a temp directory (not repo packs/)."""
+ target = tmp_path / "installed"
+ target.mkdir()
+ monkeypatch.setenv("CREER_PACKS_DIR", str(target))
+ # Also keep INSTALLED_DIR clean conceptually — writable_packs_dir uses env
+ yield target
+
+
+@pytest.fixture()
+def client(install_dir):
+ return TestClient(app)
+
+
+def test_health_version_0_9(client, monkeypatch):
+ monkeypatch.setattr("main.CREER_OFFLINE", True)
+ resp = client.get("/health")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["version"] == "0.9.0"
+ assert VERSION == "0.9.0"
+ assert data["offline"] is True
+ assert data["packs_count"] >= 3
+ assert "peers_configured" in data
+
+
+def test_marketplace_catalog(client):
+ resp = client.get("/marketplace")
+ assert resp.status_code == 200
+ items = resp.json()["items"]
+ assert len(items) >= 4
+ bundled = [i for i in items if i.get("source") == "bundled"]
+ remote = [i for i in items if i.get("source") == "remote"]
+ assert len(bundled) == 3
+ assert {b["id"] for b in bundled} == {"fastapi-crud", "python-lib", "express-ts"}
+ assert len(remote) >= 1
+ assert all("url" in r for r in remote)
+
+
+def test_install_pack_from_bytes(install_dir):
+ body = json.dumps(SAMPLE_PACK).encode("utf-8")
+ installed = packs_mod.install_pack_from_bytes(body, overwrite=False)
+ assert installed["id"] == "marketplace-demo"
+ assert (install_dir / "marketplace-demo.json").is_file()
+ ids = {p["id"] for p in packs_mod.list_packs()}
+ assert "marketplace-demo" in ids
+
+
+def test_install_endpoint_mocked_httpx(client, install_dir, monkeypatch):
+ body = json.dumps(SAMPLE_PACK).encode("utf-8")
+
+ class FakeResponse:
+ status_code = 200
+ headers = {"content-type": "application/json"}
+
+ def iter_bytes(self):
+ yield body
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *args):
+ return False
+
+ class FakeClient:
+ def __init__(self, *args, **kwargs):
+ pass
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *args):
+ return False
+
+ def stream(self, method, url):
+ assert method == "GET"
+ assert url.startswith("https://")
+ return FakeResponse()
+
+ monkeypatch.setattr(packs_mod.httpx, "Client", FakeClient)
+
+ resp = client.post(
+ "/packs/install",
+ json={"url": "https://example.com/packs/marketplace-demo.json", "overwrite": False},
+ )
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["installed"] is True
+ assert data["pack"]["id"] == "marketplace-demo"
+
+ packs = client.get("/packs").json()["packs"]
+ assert any(p["id"] == "marketplace-demo" for p in packs)
+
+
+def test_install_overwrite_false_conflict(client, install_dir, monkeypatch):
+ packs_mod.install_pack(SAMPLE_PACK, overwrite=False)
+ with pytest.raises(packs_mod.PackConflictError):
+ packs_mod.install_pack(SAMPLE_PACK, overwrite=False)
+
+ body = json.dumps(SAMPLE_PACK).encode("utf-8")
+
+ class FakeResponse:
+ status_code = 200
+ headers = {"content-type": "application/json"}
+
+ def iter_bytes(self):
+ yield body
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *args):
+ return False
+
+ class FakeClient:
+ def __init__(self, *args, **kwargs):
+ pass
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *args):
+ return False
+
+ def stream(self, method, url):
+ return FakeResponse()
+
+ monkeypatch.setattr(packs_mod.httpx, "Client", FakeClient)
+ resp = client.post(
+ "/packs/install",
+ json={
+ "url": "https://example.com/packs/marketplace-demo.json",
+ "overwrite": False,
+ },
+ )
+ assert resp.status_code == 409
+
+
+def test_delete_installed_only(client, install_dir):
+ packs_mod.install_pack(SAMPLE_PACK, overwrite=True)
+ assert any(p["id"] == "marketplace-demo" for p in packs_mod.list_packs())
+
+ resp = client.delete("/packs/marketplace-demo")
+ assert resp.status_code == 200
+ assert resp.json()["deleted"] is True
+ assert not (install_dir / "marketplace-demo.json").exists()
+ assert not any(p["id"] == "marketplace-demo" for p in packs_mod.list_packs())
+
+ # Built-in cannot be deleted via writable dir
+ resp = client.delete("/packs/fastapi-crud")
+ assert resp.status_code == 404
+ # Still listed (bundled)
+ assert any(p["id"] == "fastapi-crud" for p in client.get("/packs").json()["packs"])
+
+
+def test_reject_non_http_scheme():
+ with pytest.raises(ValueError, match="http/https"):
+ packs_mod.validate_remote_pack_url("file:///etc/passwd")
+ with pytest.raises(ValueError, match="http/https"):
+ packs_mod.validate_remote_pack_url("ftp://example.com/pack.json")
+
+
+def test_parse_yaml_pack():
+ text = """
+id: yaml-demo
+name: YAML Demo
+description: from yaml
+stack: yaml
+version: "1.0.0"
+files:
+ - README.md
+"""
+ pack = packs_mod.parse_pack_content(text, "pack.yaml")
+ assert pack["id"] == "yaml-demo"
diff --git a/backend/tests/test_registry.py b/backend/tests/test_registry.py
new file mode 100644
index 0000000..9b3cf64
--- /dev/null
+++ b/backend/tests/test_registry.py
@@ -0,0 +1,69 @@
+"""Tests for self-hosted pack registry (v0.7)."""
+
+from __future__ import annotations
+
+import json
+
+from fastapi.testclient import TestClient
+
+import main
+from app.packs import install_pack, uninstall_pack
+
+
+def test_health_registry_fields():
+ c = TestClient(main.app)
+ h = c.get("/health").json()
+ assert h["version"] == "0.9.0"
+ assert h["registry_count"] >= 3
+ assert "peers_configured" in h
+
+
+def test_registry_lists_and_search():
+ c = TestClient(main.app)
+ r = c.get("/registry")
+ assert r.status_code == 200
+ items = r.json()["items"]
+ assert len(items) >= 3
+ assert all("download_url" in i for i in items)
+
+ r = c.get("/registry", params={"q": "fastapi"})
+ assert r.status_code == 200
+ ids = [i["id"] for i in r.json()["items"]]
+ assert "fastapi-crud" in ids
+
+ r = c.get("/registry", params={"source": "bundled"})
+ assert r.status_code == 200
+ assert all(i["source"] == "bundled" for i in r.json()["items"])
+
+
+def test_registry_download_and_reinstall():
+ c = TestClient(main.app)
+ r = c.get("/registry/packs/python-lib/download")
+ assert r.status_code == 200
+ assert "attachment" in r.headers.get("content-disposition", "")
+ pack = r.json()
+ assert pack["id"] == "python-lib"
+ assert isinstance(pack["files"], list) and pack["files"]
+
+ # Install under a new id via bytes path (avoid conflicting with bundled id)
+ data = dict(pack)
+ data["id"] = "python-lib-copy"
+ data["name"] = "Python Lib Copy"
+ install_pack(data, overwrite=True)
+ try:
+ ids = [p["id"] for p in c.get("/packs").json()["packs"]]
+ assert "python-lib-copy" in ids
+ detail = c.get("/registry/packs/python-lib-copy")
+ assert detail.status_code == 200
+ assert detail.json()["source"] == "installed"
+ finally:
+ uninstall_pack("python-lib-copy")
+
+
+def test_marketplace_enriched():
+ c = TestClient(main.app)
+ items = c.get("/marketplace").json()["items"]
+ bundled = [i for i in items if i.get("source") == "bundled" and i["id"] == "fastapi-crud"]
+ assert bundled
+ # Local download URL attached when pack exists
+ assert bundled[0].get("url") or bundled[0].get("download_url")
diff --git a/extension/.gitignore b/extension/.gitignore
new file mode 100644
index 0000000..9fdd493
--- /dev/null
+++ b/extension/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+out/
+*.vsix
+.vscode-test/
+media/icon-1024.png
diff --git a/extension/.vscode/launch.json b/extension/.vscode/launch.json
new file mode 100644
index 0000000..a142310
--- /dev/null
+++ b/extension/.vscode/launch.json
@@ -0,0 +1,13 @@
+{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "Run Extension",
+ "type": "extensionHost",
+ "request": "launch",
+ "args": ["--extensionDevelopmentPath=${workspaceFolder}"],
+ "outFiles": ["${workspaceFolder}/out/**/*.js"],
+ "preLaunchTask": "npm: compile"
+ }
+ ]
+}
diff --git a/extension/.vscode/tasks.json b/extension/.vscode/tasks.json
new file mode 100644
index 0000000..c8e2ec3
--- /dev/null
+++ b/extension/.vscode/tasks.json
@@ -0,0 +1,11 @@
+{
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "type": "npm",
+ "script": "compile",
+ "group": "build",
+ "problemMatcher": ["$tsc"]
+ }
+ ]
+}
diff --git a/extension/.vscodeignore b/extension/.vscodeignore
new file mode 100644
index 0000000..14b22ea
--- /dev/null
+++ b/extension/.vscodeignore
@@ -0,0 +1,18 @@
+.vscode/**
+.vscode-test/**
+src/**
+tsconfig.json
+**/*.ts
+!**/*.d.ts
+**/*.map
+.gitignore
+.eslintignore
+.eslintrc*
+**/.DS_Store
+**/*.vsix
+node_modules/@types/**
+node_modules/typescript/**
+*.md
+!README.md
+!CHANGELOG.md
+media/icon-1024.png
diff --git a/extension/CHANGELOG.md b/extension/CHANGELOG.md
new file mode 100644
index 0000000..6bd19ed
--- /dev/null
+++ b/extension/CHANGELOG.md
@@ -0,0 +1,56 @@
+# Changelog
+
+## 0.9.0
+
+- Richer federation UX: `creer.registryPeers` + `creer.showPeerStatus` settings
+- API: `fetchPeerStatus`, `probePeer`; federated fetch passes `peers` query
+- **Creer: Browse Federated Registry** — peer health summary, local/`$(cloud)` labels
+- **Creer: Manage Registry Peers** — add/remove setting peers, probe all (soft-fail)
+- Release polish: GitHub Release on tag with `.vsix` attached; [`RELEASE.md`](../RELEASE.md)
+
+## 0.8.0
+
+- Federated multi-host registry: `GET /registry/federated`, `CREER_REGISTRY_PEERS`
+- Extension: **Creer: Browse Federated Registry** (search + install via absolute download/install URLs)
+- GitHub Actions release workflow (`.vsix` artifact; optional Marketplace / Open VSX publish when secrets exist)
+- Lightweight CI (backend pytest + extension compile)
+
+## 0.7.0
+
+- Self-hosted pack registry: `GET /registry`, pack detail + JSON download
+- Extension icon (`media/icon.png`) for Marketplace / Open VSX packaging
+- **Creer: Browse Pack Registry** command (search + install via download URL)
+- `CREER_PUBLIC_BASE_URL` for absolute registry links
+- Marketplace items enriched with local download URLs when available
+
+## 0.6.0
+
+- Pack marketplace + remote URL install/delete
+- Side-by-side conflict diffs (`creer-generated`)
+- Publish packaging (`PUBLISH.md`, vsce/ovsx scripts)
+
+## 0.5.0
+
+- Content diff preview before write
+- Multi-root workspace targeting
+- Installable JSON/YAML template packs
+
+## 0.4.0
+
+- Stream cancellation
+- Selectable license/CI bake-ins
+- Quality gates
+
+## 0.3.0
+
+- Streaming generation
+- Offline / local model backends
+- OSS bake-ins + token hardening
+
+## 0.2.0
+
+- Plan preview, templates, overwrite protection, GitHub push, `/creer` chat
+
+## 0.1.0
+
+- Initial FastAPI backend + VS Code scaffold command
diff --git a/extension/LICENSE b/extension/LICENSE
new file mode 100644
index 0000000..88cdccb
--- /dev/null
+++ b/extension/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Sanath S Patil
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/extension/PUBLISH.md b/extension/PUBLISH.md
new file mode 100644
index 0000000..53a5072
--- /dev/null
+++ b/extension/PUBLISH.md
@@ -0,0 +1,109 @@
+# Publishing Creer (VS Code / Open VSX)
+
+The extension is packaged from compiled `out/` JavaScript. **Do not commit secrets** (personal access tokens). Tokens stay in your environment only.
+
+## Prerequisites
+
+- Node.js 18+
+- Compiled extension: `cd extension && npm install && npm run compile`
+- Publisher identity:
+ - **VS Marketplace:** Azure DevOps PAT with Marketplace (Acquire / Publish) scopes, and a [publisher](https://marketplace.visualstudio.com/manage) matching `package.json` → `publisher`
+ - **Open VSX:** account + token from [open-vsx.org](https://open-vsx.org/)
+
+## Package a `.vsix` (no publish)
+
+```bash
+cd extension
+npm run compile
+npm run package
+# or: npx --yes @vscode/vsce package
+```
+
+This runs `vsce package`, respects `.vscodeignore`, and includes production dependencies (e.g. `axios`) plus `media/icon.png`. Output: `creer-0.9.0.vsix` (version from `package.json`).
+
+### Publisher signing (human step)
+
+vsce/ovsx publish with a PAT **signs the release to your publisher identity**. This repo is packaging-ready (icon, changelog, license, repository metadata). Actual signing/publish requires:
+
+1. Create publisher `creer` (or change `package.json` → `publisher`)
+2. Export `VSCE_PAT` / `OVSX_PAT` in your shell only
+3. Run `npx @vscode/vsce publish` or `npx ovsx publish`
+
+No tokens are stored in this repository.
+
+## GitHub Actions release
+
+Workflow: [`.github/workflows/release.yml`](../.github/workflows/release.yml)
+
+| Trigger | Behavior |
+|---|---|
+| Tag `v*` (e.g. `v0.9.0`) | Build + package `.vsix`, upload artifact, create GitHub Release with `.vsix` attached |
+| `workflow_dispatch` | Build + package + artifact (no GitHub Release); publish only if secrets set |
+
+**Publish job** downloads the artifact and:
+
+1. If repository secret `VSCE_PAT` is set → `npx @vscode/vsce publish --packagePath *.vsix -p "$VSCE_PAT"`
+2. If repository secret `OVSX_PAT` is set → `npx ovsx publish *.vsix -p "$OVSX_PAT"`
+3. If neither → logs `No publish tokens configured — artifact only` (signed publish is skipped)
+
+Configure secrets under **Settings → Secrets and variables → Actions** (never commit PATs). CI (`.github/workflows/ci.yml`) runs backend pytest + extension compile on push/PR and does not publish.
+
+Install locally for a smoke test:
+
+```bash
+code --install-extension creer-0.9.0.vsix
+# or Cursor: cursor --install-extension creer-0.9.0.vsix
+```
+
+## Publish to VS Marketplace
+
+1. Create a publisher if needed and ensure `package.json` `publisher` matches.
+2. Export a PAT (do not paste into git or chat logs):
+
+ ```bash
+ export VSCE_PAT=your_marketplace_pat
+ ```
+
+3. Publish:
+
+ ```bash
+ cd extension
+ npx --yes @vscode/vsce publish
+ # or: npm run publish:marketplace # prints the command reminder
+ ```
+
+Optional: `npx @vscode/vsce publish -p "$VSCE_PAT"`.
+
+## Publish to Open VSX
+
+1. Create an Open VSX token.
+2. Export it:
+
+ ```bash
+ export OVSX_PAT=your_open_vsx_token
+ ```
+
+3. Publish the same extension (package first if you want a local `.vsix`):
+
+ ```bash
+ cd extension
+ npx --yes ovsx publish
+ # with an existing vsix:
+ # npx --yes ovsx publish creer-0.9.0.vsix
+ ```
+
+`npm run publish:ovsx` only documents this flow (exits non-zero so CI does not publish by accident).
+
+## Checklist before publish
+
+- [ ] `version` bumped in `extension/package.json`
+- [ ] `npm run compile` succeeds
+- [ ] `license`, `repository`, `homepage`, `bugs` fields look correct
+- [ ] `.vscodeignore` excludes `src/`, maps, and tooling (ships `out/`)
+- [ ] Manual smoke: Create New Repo + Install Pack from URL against a running backend
+- [ ] No tokens in repo files or commit history
+
+## Notes
+
+- Icon: add `icon.png` and `"icon": "icon.png"` in `package.json` when you have artwork.
+- Prefer `npx @vscode/vsce` / `npx ovsx` so `@vscode/vsce` need not be a permanent `devDependency`.
diff --git a/extension/README.md b/extension/README.md
new file mode 100644
index 0000000..222decf
--- /dev/null
+++ b/extension/README.md
@@ -0,0 +1,18 @@
+# Creer (VS Code extension)
+
+AI-powered repo scaffolding inside your workspace.
+
+See the [repository README](https://github.com/seven0070/Creer#readme) for backend setup, commands, and settings.
+
+## Develop
+
+```bash
+npm install
+npm run compile
+```
+
+Press **F5** in VS Code/Cursor to launch an Extension Development Host (see `.vscode/launch.json`).
+
+## Publishing
+
+Package and publish steps (Marketplace / Open VSX) are documented in [PUBLISH.md](./PUBLISH.md). No publish tokens belong in this repo.
diff --git a/extension/media/icon.png b/extension/media/icon.png
new file mode 100644
index 0000000..d4fb02a
Binary files /dev/null and b/extension/media/icon.png differ
diff --git a/extension/package-lock.json b/extension/package-lock.json
new file mode 100644
index 0000000..af6747f
--- /dev/null
+++ b/extension/package-lock.json
@@ -0,0 +1,392 @@
+{
+ "name": "creer",
+ "version": "0.9.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "creer",
+ "version": "0.9.0",
+ "dependencies": {
+ "axios": "^1.7.9"
+ },
+ "devDependencies": {
+ "@types/node": "^20.17.0",
+ "@types/vscode": "^1.85.0",
+ "typescript": "^5.6.3"
+ },
+ "engines": {
+ "vscode": "^1.85.0"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "20.19.43",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
+ "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/vscode": {
+ "version": "1.125.0",
+ "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.125.0.tgz",
+ "integrity": "sha512-0icm/ZQAaism87P0ekHqi4/Ju9du+Tm0RUW+y7vqRsxY2cY0FNRX1nAnaW7nT6npPt2tfHiheZ55Zm9UhqonFA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/agent-base": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+ "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "license": "MIT"
+ },
+ "node_modules/axios": {
+ "version": "1.19.0",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz",
+ "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.16.0",
+ "form-data": "^4.0.6",
+ "https-proxy-agent": "^5.0.1",
+ "proxy-from-env": "^2.1.0"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/follow-redirects": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
+ "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
+ "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.4",
+ "mime-types": "^2.1.35"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+ "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "6",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/proxy-from-env": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
+ "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/extension/package.json b/extension/package.json
new file mode 100644
index 0000000..eebcb33
--- /dev/null
+++ b/extension/package.json
@@ -0,0 +1,175 @@
+{
+ "name": "creer",
+ "displayName": "Creer",
+ "description": "AI-powered repo scaffolding inside your workspace",
+ "version": "0.9.0",
+ "publisher": "creer",
+ "license": "MIT",
+ "icon": "media/icon.png",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/seven0070/Creer.git"
+ },
+ "homepage": "https://github.com/seven0070/Creer",
+ "bugs": {
+ "url": "https://github.com/seven0070/Creer/issues"
+ },
+ "engines": {
+ "vscode": "^1.85.0"
+ },
+ "categories": [
+ "Other",
+ "Snippets"
+ ],
+ "activationEvents": [],
+ "main": "./out/extension.js",
+ "contributes": {
+ "commands": [
+ {
+ "command": "creer.createRepo",
+ "title": "Creer: Create New Repo"
+ },
+ {
+ "command": "creer.createRepoFromChat",
+ "title": "Creer: Create from Chat Prompt"
+ },
+ {
+ "command": "creer.setGitHubToken",
+ "title": "Creer: Set GitHub Token"
+ },
+ {
+ "command": "creer.clearGitHubToken",
+ "title": "Creer: Clear GitHub Token"
+ },
+ {
+ "command": "creer.installPackFromUrl",
+ "title": "Creer: Install Pack from URL"
+ },
+ {
+ "command": "creer.browseMarketplace",
+ "title": "Creer: Browse Pack Marketplace"
+ },
+ {
+ "command": "creer.browseRegistry",
+ "title": "Creer: Browse Pack Registry"
+ },
+ {
+ "command": "creer.browseFederatedRegistry",
+ "title": "Creer: Browse Federated Registry"
+ },
+ {
+ "command": "creer.manageRegistryPeers",
+ "title": "Creer: Manage Registry Peers"
+ }
+ ],
+ "chatParticipants": [
+ {
+ "id": "creer.participant",
+ "fullName": "Creer",
+ "name": "creer",
+ "description": "Scaffold a repo with Creer",
+ "isSticky": false
+ }
+ ],
+ "configuration": {
+ "title": "Creer",
+ "properties": {
+ "creer.backendUrl": {
+ "type": "string",
+ "default": "http://localhost:8000",
+ "description": "Creer backend base URL"
+ },
+ "creer.initGit": {
+ "type": "boolean",
+ "default": true,
+ "description": "Initialize a git repo after scaffolding"
+ },
+ "creer.githubToken": {
+ "type": "string",
+ "default": "",
+ "description": "DEPRECATED: Prefer SecretStorage via 'Creer: Set GitHub Token'. Used only as a fallback when SecretStorage is empty.",
+ "deprecationMessage": "Use 'Creer: Set GitHub Token' (SecretStorage) instead of storing the token in settings."
+ },
+ "creer.githubPrivate": {
+ "type": "boolean",
+ "default": true,
+ "description": "Create GitHub repositories as private"
+ },
+ "creer.createGitHubRepo": {
+ "type": "boolean",
+ "default": false,
+ "description": "After scaffolding, create a GitHub remote repository"
+ },
+ "creer.previewBeforeWrite": {
+ "type": "boolean",
+ "default": true,
+ "description": "Show a plan preview and confirm before generating and writing files"
+ },
+ "creer.useStreaming": {
+ "type": "boolean",
+ "default": true,
+ "description": "Use SSE streaming (/generate/stream) with per-file progress; falls back to /generate on failure"
+ },
+ "creer.license": {
+ "type": "string",
+ "default": "mit",
+ "enum": ["mit", "apache-2.0", "none"],
+ "description": "License bake-in for generated projects"
+ },
+ "creer.ciPreset": {
+ "type": "string",
+ "default": "auto",
+ "enum": ["auto", "python", "node", "none"],
+ "description": "CI workflow bake-in preset for generated projects"
+ },
+ "creer.promptBakeins": {
+ "type": "boolean",
+ "default": true,
+ "description": "When true, QuickPick license and CI each run; when false, use creer.license and creer.ciPreset"
+ },
+ "creer.contentPreview": {
+ "type": "boolean",
+ "default": true,
+ "description": "After generate, before write, show a content preview/diff and confirm"
+ },
+ "creer.showConflictDiffs": {
+ "type": "boolean",
+ "default": true,
+ "description": "When conflicts exist, offer Review diffs (side-by-side vscode.diff). When false, only Overwrite all / Skip existing / Cancel"
+ },
+ "creer.defaultWorkspaceFolder": {
+ "type": "string",
+ "default": "",
+ "description": "Optional workspace folder name or path hint for multi-root workspaces; empty prompts when multiple folders are open"
+ },
+ "creer.registryPeers": {
+ "type": "string",
+ "default": "",
+ "description": "Comma-separated peer base URLs used when browsing the federated registry (passed as peers= query)"
+ },
+ "creer.showPeerStatus": {
+ "type": "boolean",
+ "default": true,
+ "description": "When browsing the federated registry, show peer health summary (ok/fail/latency)"
+ }
+ }
+ }
+ },
+ "scripts": {
+ "vscode:prepublish": "npm run compile",
+ "compile": "tsc -p ./",
+ "watch": "tsc -watch -p ./",
+ "lint": "tsc --noEmit -p ./",
+ "package": "npx --yes @vscode/vsce package",
+ "publish:ovsx": "echo \"Set OVSX_PAT then run: npx --yes ovsx publish\" && exit 1",
+ "publish:marketplace": "echo \"Set VSCE_PAT then run: npx --yes @vscode/vsce publish\" && exit 1"
+ },
+ "dependencies": {
+ "axios": "^1.7.9"
+ },
+ "devDependencies": {
+ "@types/node": "^20.17.0",
+ "@types/vscode": "^1.85.0",
+ "typescript": "^5.6.3"
+ }
+}
diff --git a/extension/src/api.ts b/extension/src/api.ts
new file mode 100644
index 0000000..7e62e8b
--- /dev/null
+++ b/extension/src/api.ts
@@ -0,0 +1,440 @@
+import axios, { AxiosError } from 'axios';
+import * as vscode from 'vscode';
+
+export interface Template {
+ id: string;
+ name: string;
+ description: string;
+ stack: string;
+ files: string[];
+}
+
+export interface Pack {
+ id: string;
+ name: string;
+ description: string;
+ stack: string;
+ version?: string;
+ files: string[];
+}
+
+export interface MarketplaceItem {
+ id: string;
+ name: string;
+ description: string;
+ /** e.g. bundled | remote | url host */
+ source: string;
+ url?: string;
+ download_url?: string;
+}
+
+export interface PlanResponse {
+ project_name: string;
+ stack?: string;
+ files: string[];
+ template_id?: string;
+ pack_id?: string;
+ description?: string;
+}
+
+export type LicenseBakein = 'mit' | 'apache-2.0' | 'none';
+export type CiBakein = 'auto' | 'python' | 'node' | 'none';
+
+export interface BakeinOptions {
+ license: LicenseBakein;
+ ci: CiBakein;
+ include_readme?: boolean;
+}
+
+export interface BakeinChoice {
+ id: string;
+ name: string;
+}
+
+export interface BakeinsResponse {
+ licenses: BakeinChoice[];
+ ci: BakeinChoice[];
+}
+
+export interface QualityIssue {
+ code: string;
+ severity: string;
+ message: string;
+ path?: string;
+}
+
+export interface GenerateResponse {
+ project_name: string;
+ stack?: string;
+ files: Record;
+ template_id?: string;
+ pack_id?: string;
+ quality?: QualityIssue[];
+}
+
+export interface GitHubCreateRepoResponse {
+ html_url: string;
+ clone_url: string;
+ full_name: string;
+}
+
+export interface PlanOptions {
+ templateId?: string;
+ packId?: string;
+}
+
+export interface GenerateOptions {
+ templateId?: string;
+ packId?: string;
+ plan?: PlanResponse;
+ jobId?: string;
+ bakeins?: BakeinOptions;
+}
+
+function getBackendUrl(): string {
+ const config = vscode.workspace.getConfiguration('creer');
+ return (config.get('backendUrl') || 'http://localhost:8000').replace(/\/$/, '');
+}
+
+export function formatAxiosError(err: unknown, fallback: string): string {
+ if (axios.isAxiosError(err)) {
+ const ax = err as AxiosError<{ detail?: string }>;
+ const detail = ax.response?.data?.detail;
+ if (typeof detail === 'string' && detail.trim()) {
+ return detail;
+ }
+ if (ax.message) {
+ return ax.message;
+ }
+ }
+ if (err instanceof Error) {
+ return err.message;
+ }
+ return fallback;
+}
+
+export async function fetchTemplates(): Promise {
+ const backendUrl = getBackendUrl();
+ const response = await axios.get<{ templates: Template[] }>(`${backendUrl}/templates`, {
+ timeout: 30_000,
+ });
+ return response.data.templates ?? [];
+}
+
+export async function fetchPacks(): Promise {
+ const backendUrl = getBackendUrl();
+ const response = await axios.get<{ packs: Pack[] }>(`${backendUrl}/packs`, {
+ timeout: 30_000,
+ });
+ return response.data.packs ?? [];
+}
+
+/**
+ * GET /marketplace → { items: MarketplaceItem[] }
+ * Callers should treat missing/404 endpoints as “unavailable”.
+ */
+export async function fetchMarketplace(): Promise {
+ const backendUrl = getBackendUrl();
+ const response = await axios.get<{ items: MarketplaceItem[] }>(
+ `${backendUrl}/marketplace`,
+ { timeout: 30_000 }
+ );
+ return response.data.items ?? [];
+}
+
+/**
+ * POST /packs/install → { url, overwrite? } → installed pack
+ * Backend returns `{ installed: true, pack: {...} }` (also tolerate a bare pack body).
+ */
+export async function installPack(
+ url: string,
+ overwrite?: boolean
+): Promise {
+ const backendUrl = getBackendUrl();
+ const body: { url: string; overwrite?: boolean } = { url };
+ if (overwrite !== undefined) {
+ body.overwrite = overwrite;
+ }
+ const response = await axios.post<{ installed?: boolean; pack?: Pack } & Pack>(
+ `${backendUrl}/packs/install`,
+ body,
+ { timeout: 120_000 }
+ );
+ const data = response.data;
+ if (data?.pack && typeof data.pack === 'object') {
+ return data.pack;
+ }
+ return data as Pack;
+}
+
+export interface RegistryItem {
+ id: string;
+ name: string;
+ description?: string;
+ stack?: string;
+ version?: string;
+ source?: string;
+ files?: string[];
+ download_url?: string;
+ install_url?: string;
+}
+
+export interface RegistryResponse {
+ version: string;
+ base_url?: string | null;
+ items: RegistryItem[];
+}
+
+/**
+ * GET /registry?q=&source= — searchable self-hosted pack registry.
+ */
+export async function fetchRegistry(options?: {
+ q?: string;
+ source?: 'all' | 'bundled' | 'installed';
+}): Promise {
+ const backendUrl = getBackendUrl();
+ const response = await axios.get(`${backendUrl}/registry`, {
+ timeout: 30_000,
+ params: {
+ q: options?.q || undefined,
+ source: options?.source || undefined,
+ },
+ });
+ return {
+ version: response.data.version,
+ base_url: response.data.base_url,
+ items: response.data.items ?? [],
+ };
+}
+
+/** Item from a federated registry merge; `peer` is set when sourced from a remote host. */
+export interface FederatedRegistryItem extends RegistryItem {
+ /** Peer base URL when the item came from a remote Creer registry. */
+ peer?: string;
+}
+
+export interface FederatedRegistryPeer {
+ base_url: string;
+ ok?: boolean;
+ count?: number;
+ error?: string | null;
+}
+
+export interface FederatedRegistryResponse {
+ version?: string;
+ /** Local registry snapshot. */
+ local: RegistryResponse | {
+ version?: string;
+ base_url?: string | null;
+ items?: RegistryItem[];
+ };
+ /** Configured peer hosts (reachable or not). */
+ peers: FederatedRegistryPeer[];
+ /** Merged catalog; peer-sourced rows may include `peer`. */
+ items: FederatedRegistryItem[];
+}
+
+/**
+ * GET /registry/federated?q=&source=&peers= — local + peer registry merge.
+ * `peers` is a comma-separated list of extra peer base URLs (from settings or callers).
+ */
+export async function fetchFederatedRegistry(options?: {
+ q?: string;
+ source?: string;
+ /** Extra peer base URLs (comma-separated string or array). */
+ peers?: string | string[];
+}): Promise {
+ const backendUrl = getBackendUrl();
+ let peersParam: string | undefined;
+ if (Array.isArray(options?.peers)) {
+ const joined = options.peers.map((p) => p.trim()).filter(Boolean).join(',');
+ peersParam = joined || undefined;
+ } else if (typeof options?.peers === 'string' && options.peers.trim()) {
+ peersParam = options.peers.trim();
+ }
+ const response = await axios.get(
+ `${backendUrl}/registry/federated`,
+ {
+ timeout: 60_000,
+ params: {
+ q: options?.q || undefined,
+ source: options?.source || undefined,
+ peers: peersParam,
+ },
+ }
+ );
+ return {
+ version: response.data.version,
+ local: response.data.local ?? { items: [] },
+ peers: response.data.peers ?? [],
+ items: response.data.items ?? [],
+ };
+}
+
+/** Live peer probe / status from GET /registry/peers or POST /registry/peers/probe. */
+export interface PeerStatus {
+ /** Peer base URL (preferred). */
+ url?: string;
+ /** Alternate field some backends may return. */
+ base_url?: string;
+ ok: boolean;
+ latency_ms?: number | null;
+ error?: string | null;
+ count?: number | null;
+}
+
+export interface PeerStatusListResponse {
+ peers: PeerStatus[];
+ /** Backend-configured peer URLs (CREER_REGISTRY_PEERS). */
+ configured: string[];
+}
+
+function normalizePeerStatus(raw: PeerStatus): PeerStatus {
+ return {
+ url: raw.url || raw.base_url || '',
+ base_url: raw.base_url || raw.url || '',
+ ok: Boolean(raw.ok),
+ latency_ms: raw.latency_ms ?? null,
+ error: raw.error ?? null,
+ count: raw.count ?? null,
+ };
+}
+
+/**
+ * GET /registry/peers → `{ peers: PeerStatus[], configured: string[] }`
+ */
+export async function fetchPeerStatus(): Promise {
+ const backendUrl = getBackendUrl();
+ const response = await axios.get(
+ `${backendUrl}/registry/peers`,
+ { timeout: 30_000 }
+ );
+ return {
+ peers: (response.data.peers ?? []).map(normalizePeerStatus),
+ configured: response.data.configured ?? [],
+ };
+}
+
+/**
+ * POST /registry/peers/probe `{ url }` → PeerStatus
+ */
+export async function probePeer(url: string): Promise {
+ const backendUrl = getBackendUrl();
+ const response = await axios.post(
+ `${backendUrl}/registry/peers/probe`,
+ { url },
+ { timeout: 30_000 }
+ );
+ return normalizePeerStatus(response.data);
+}
+
+/**
+ * DELETE /packs/{id} → delete installed pack
+ */
+export async function deletePack(id: string): Promise {
+ const backendUrl = getBackendUrl();
+ await axios.delete(`${backendUrl}/packs/${encodeURIComponent(id)}`, {
+ timeout: 30_000,
+ });
+}
+
+export async function fetchBakeins(): Promise {
+ const backendUrl = getBackendUrl();
+ const response = await axios.get(`${backendUrl}/bakeins`, {
+ timeout: 30_000,
+ });
+ return {
+ licenses: response.data.licenses ?? [],
+ ci: response.data.ci ?? [],
+ };
+}
+
+export async function postCancel(jobId: string): Promise<{ cancelled: true }> {
+ const backendUrl = getBackendUrl();
+ const response = await axios.post<{ cancelled: true }>(
+ `${backendUrl}/generate/cancel`,
+ { job_id: jobId },
+ { timeout: 30_000 }
+ );
+ return response.data;
+}
+
+export async function postPlan(idea: string, options?: PlanOptions): Promise {
+ const backendUrl = getBackendUrl();
+ const body: { idea: string; template_id?: string; pack_id?: string } = { idea };
+ if (options?.packId) {
+ body.pack_id = options.packId;
+ } else if (options?.templateId) {
+ body.template_id = options.templateId;
+ }
+ const response = await axios.post(`${backendUrl}/plan`, body, {
+ timeout: 300_000,
+ });
+ return response.data;
+}
+
+export async function postGenerate(
+ idea: string,
+ options?: GenerateOptions
+): Promise {
+ const backendUrl = getBackendUrl();
+ const body: {
+ idea: string;
+ template_id?: string;
+ pack_id?: string;
+ plan?: PlanResponse;
+ job_id?: string;
+ bakeins?: BakeinOptions;
+ } = { idea };
+ if (options?.packId) {
+ body.pack_id = options.packId;
+ } else if (options?.templateId) {
+ body.template_id = options.templateId;
+ }
+ if (options?.plan) {
+ body.plan = options.plan;
+ }
+ if (options?.jobId) {
+ body.job_id = options.jobId;
+ }
+ if (options?.bakeins) {
+ body.bakeins = options.bakeins;
+ }
+ const response = await axios.post(`${backendUrl}/generate`, body, {
+ timeout: 300_000,
+ });
+ return response.data;
+}
+
+/** Re-export streaming generate for callers that import from api. */
+export { streamGenerate, CancelledError, isCancellationError } from './streamGenerate';
+export type {
+ StreamProgressEvent,
+ StreamGenerateOptions,
+ StreamStartEvent,
+ StreamFileEvent,
+ StreamDoneEvent,
+ StreamErrorEvent,
+ StreamCancelledEvent,
+} from './streamGenerate';
+
+export async function createGitHubRepo(
+ token: string,
+ name: string,
+ options?: { private?: boolean; description?: string }
+): Promise {
+ const backendUrl = getBackendUrl();
+ const isPrivate = options?.private ?? true;
+ const description = options?.description ?? '';
+ const response = await axios.post(
+ `${backendUrl}/github/create-repo`,
+ { name, private: isPrivate, description },
+ {
+ timeout: 60_000,
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ }
+ );
+ return response.data;
+}
diff --git a/extension/src/conflictDiff.ts b/extension/src/conflictDiff.ts
new file mode 100644
index 0000000..1e1d42c
--- /dev/null
+++ b/extension/src/conflictDiff.ts
@@ -0,0 +1,196 @@
+import * as path from 'path';
+import * as vscode from 'vscode';
+import { resolveSafeProjectPath, type ConflictResolution } from './writeFiles';
+
+export const CREER_GENERATED_SCHEME = 'creer-generated';
+
+const MAX_DIFF_FILES = 15;
+
+/**
+ * Serves generated file contents for side-by-side `vscode.diff` against on-disk files.
+ * Content is keyed by the `path` query parameter (relative project path).
+ */
+export class CreerGeneratedContentProvider implements vscode.TextDocumentContentProvider {
+ private readonly contents = new Map();
+ private readonly _onDidChange = new vscode.EventEmitter();
+ readonly onDidChange = this._onDidChange.event;
+
+ setContent(relativePath: string, content: string): void {
+ const key = normalizeRelPath(relativePath);
+ this.contents.set(key, content);
+ this._onDidChange.fire(generatedUri(key));
+ }
+
+ setMany(files: Record, relativePaths: string[]): void {
+ for (const rel of relativePaths) {
+ this.setContent(rel, files[rel] ?? '');
+ }
+ }
+
+ clear(): void {
+ this.contents.clear();
+ }
+
+ provideTextDocumentContent(uri: vscode.Uri): string {
+ const key = pathFromUri(uri);
+ return this.contents.get(key) ?? '';
+ }
+}
+
+let sharedProvider: CreerGeneratedContentProvider | undefined;
+
+export function registerConflictDiffProvider(
+ context: vscode.ExtensionContext
+): CreerGeneratedContentProvider {
+ const provider = new CreerGeneratedContentProvider();
+ sharedProvider = provider;
+ context.subscriptions.push(
+ vscode.workspace.registerTextDocumentContentProvider(CREER_GENERATED_SCHEME, provider),
+ { dispose: () => {
+ if (sharedProvider === provider) {
+ sharedProvider = undefined;
+ }
+ provider.clear();
+ } }
+ );
+ return provider;
+}
+
+function getProvider(): CreerGeneratedContentProvider {
+ if (!sharedProvider) {
+ // Lazy fallback if activate forgot to register (tests / unexpected hosts)
+ sharedProvider = new CreerGeneratedContentProvider();
+ }
+ return sharedProvider;
+}
+
+function normalizeRelPath(relativePath: string): string {
+ return relativePath.replace(/\\/g, '/');
+}
+
+function pathFromUri(uri: vscode.Uri): string {
+ const q = new URLSearchParams(uri.query);
+ const fromQuery = q.get('path');
+ if (fromQuery) {
+ return normalizeRelPath(fromQuery);
+ }
+ return normalizeRelPath(uri.path.replace(/^\//, ''));
+}
+
+export function generatedUri(relativePath: string): vscode.Uri {
+ const normalized = normalizeRelPath(relativePath);
+ return vscode.Uri.from({
+ scheme: CREER_GENERATED_SCHEME,
+ path: '/' + normalized,
+ query: `path=${encodeURIComponent(normalized)}`,
+ });
+}
+
+/**
+ * Open side-by-side diffs: existing on-disk file (left) vs generated content (right).
+ * Caps at MAX_DIFF_FILES to avoid flooding the editor.
+ */
+export async function openConflictDiffs(
+ projectPath: string,
+ files: Record,
+ conflicts: string[],
+ provider?: CreerGeneratedContentProvider
+): Promise {
+ const p = provider ?? getProvider();
+ const toShow = conflicts.slice(0, MAX_DIFF_FILES);
+ p.setMany(files, toShow);
+
+ for (const rel of toShow) {
+ let existingUri: vscode.Uri;
+ try {
+ const fullPath = resolveSafeProjectPath(projectPath, rel);
+ existingUri = vscode.Uri.file(fullPath);
+ } catch {
+ continue;
+ }
+ const generated = generatedUri(rel);
+ const title = `${path.basename(rel)} (existing ↔ generated)`;
+ await vscode.commands.executeCommand('vscode.diff', existingUri, generated, title);
+ }
+
+ return toShow.length;
+}
+
+function conflictMessage(conflicts: string[]): string {
+ const preview = conflicts.slice(0, 10);
+ const extra = conflicts.length > 10 ? `\n…and ${conflicts.length - 10} more` : '';
+ const list = preview.map((p) => `• ${p}`).join('\n');
+ return `${conflicts.length} file(s) already exist under the project folder:\n${list}${extra}`;
+}
+
+async function promptOverwriteSkipCancel(
+ message: string,
+ detail?: string
+): Promise {
+ const choice = await vscode.window.showWarningMessage(
+ message,
+ { modal: true, detail },
+ 'Overwrite all',
+ 'Skip existing',
+ 'Cancel'
+ );
+ if (choice === 'Overwrite all') {
+ return 'overwrite';
+ }
+ if (choice === 'Skip existing') {
+ return 'skip';
+ }
+ return 'cancel';
+}
+
+/**
+ * Conflict resolution with optional side-by-side review via `creer-generated` diffs.
+ * When `creer.showConflictDiffs` is false, only Overwrite / Skip / Cancel are offered.
+ */
+export async function resolveConflictsWithDiffs(
+ projectPath: string,
+ files: Record,
+ conflicts: string[]
+): Promise {
+ if (conflicts.length === 0) {
+ return 'overwrite';
+ }
+
+ const config = vscode.workspace.getConfiguration('creer');
+ const showDiffs = config.get('showConflictDiffs') ?? true;
+ const message = conflictMessage(conflicts);
+
+ if (!showDiffs) {
+ return promptOverwriteSkipCancel(message);
+ }
+
+ const choice = await vscode.window.showWarningMessage(
+ message,
+ { modal: true },
+ 'Review diffs',
+ 'Overwrite all',
+ 'Skip existing',
+ 'Cancel'
+ );
+
+ if (choice === 'Overwrite all') {
+ return 'overwrite';
+ }
+ if (choice === 'Skip existing') {
+ return 'skip';
+ }
+ if (choice !== 'Review diffs') {
+ return 'cancel';
+ }
+
+ const opened = await openConflictDiffs(projectPath, files, conflicts);
+ const more =
+ conflicts.length > MAX_DIFF_FILES
+ ? ` (showing ${opened} of ${conflicts.length})`
+ : '';
+
+ return promptOverwriteSkipCancel(
+ `Reviewed ${opened} conflicted file(s)${more}. How should Creer proceed?`,
+ 'Overwrite all replaces existing files. Skip existing keeps them and writes only new paths.'
+ );
+}
diff --git a/extension/src/contentPreview.ts b/extension/src/contentPreview.ts
new file mode 100644
index 0000000..011741b
--- /dev/null
+++ b/extension/src/contentPreview.ts
@@ -0,0 +1,279 @@
+import * as fs from 'fs';
+import * as path from 'path';
+import * as vscode from 'vscode';
+
+const MAX_FILES_SHOWN = 30;
+const MAX_PREVIEW_LINES = 40;
+const MAX_DIFF_LINES = 60;
+
+function fenceLang(filePath: string): string {
+ const ext = path.extname(filePath).toLowerCase();
+ const map: Record = {
+ '.ts': 'typescript',
+ '.tsx': 'tsx',
+ '.js': 'javascript',
+ '.jsx': 'jsx',
+ '.py': 'python',
+ '.json': 'json',
+ '.md': 'markdown',
+ '.yml': 'yaml',
+ '.yaml': 'yaml',
+ '.toml': 'toml',
+ '.sh': 'bash',
+ '.css': 'css',
+ '.html': 'html',
+ '.rs': 'rust',
+ '.go': 'go',
+ '.java': 'java',
+ '.rb': 'ruby',
+ };
+ return map[ext] || '';
+}
+
+function byteSize(content: string): number {
+ return Buffer.byteLength(content, 'utf8');
+}
+
+function formatBytes(n: number): string {
+ if (n < 1024) {
+ return `${n} B`;
+ }
+ if (n < 1024 * 1024) {
+ return `${(n / 1024).toFixed(1)} KB`;
+ }
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
+}
+
+function truncateLines(text: string, maxLines: number): { text: string; truncated: boolean } {
+ const lines = text.split('\n');
+ if (lines.length <= maxLines) {
+ return { text, truncated: false };
+ }
+ return {
+ text: lines.slice(0, maxLines).join('\n'),
+ truncated: true,
+ };
+}
+
+/**
+ * Simple line-based unified diff (no external deps).
+ * Uses LCS on lines for moderate-sized inputs; truncates output.
+ */
+export function buildUnifiedDiff(
+ existing: string,
+ generated: string,
+ maxOutputLines = MAX_DIFF_LINES
+): string {
+ const a = existing.split('\n');
+ const b = generated.split('\n');
+
+ // Cap LCS input to keep it snappy on huge files
+ const CAP = 400;
+ const aCapped = a.length > CAP;
+ const bCapped = b.length > CAP;
+ const aLines = aCapped ? a.slice(0, CAP) : a;
+ const bLines = bCapped ? b.slice(0, CAP) : b;
+
+ const n = aLines.length;
+ const m = bLines.length;
+ const dp: number[][] = Array.from({ length: n + 1 }, () => Array(m + 1).fill(0));
+ for (let i = n - 1; i >= 0; i--) {
+ for (let j = m - 1; j >= 0; j--) {
+ if (aLines[i] === bLines[j]) {
+ dp[i][j] = dp[i + 1][j + 1] + 1;
+ } else {
+ dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]);
+ }
+ }
+ }
+
+ const ops: Array<{ kind: 'eq' | 'del' | 'add'; line: string }> = [];
+ let i = 0;
+ let j = 0;
+ while (i < n && j < m) {
+ if (aLines[i] === bLines[j]) {
+ ops.push({ kind: 'eq', line: aLines[i] });
+ i++;
+ j++;
+ } else if (dp[i + 1][j] >= dp[i][j + 1]) {
+ ops.push({ kind: 'del', line: aLines[i] });
+ i++;
+ } else {
+ ops.push({ kind: 'add', line: bLines[j] });
+ j++;
+ }
+ }
+ while (i < n) {
+ ops.push({ kind: 'del', line: aLines[i++] });
+ }
+ while (j < m) {
+ ops.push({ kind: 'add', line: bLines[j++] });
+ }
+
+ const out: string[] = ['--- existing', '+++ generated'];
+ let shown = 0;
+ let skippedEq = 0;
+
+ const flushSkipped = () => {
+ if (skippedEq > 0) {
+ out.push(` … ${skippedEq} unchanged line(s)`);
+ skippedEq = 0;
+ }
+ };
+
+ for (const op of ops) {
+ if (shown >= maxOutputLines) {
+ flushSkipped();
+ out.push(` … diff truncated (${ops.length - shown} more op(s))`);
+ break;
+ }
+ if (op.kind === 'eq') {
+ skippedEq++;
+ continue;
+ }
+ flushSkipped();
+ if (op.kind === 'del') {
+ out.push(`-${op.line}`);
+ } else {
+ out.push(`+${op.line}`);
+ }
+ shown++;
+ }
+ flushSkipped();
+
+ if (aCapped || bCapped) {
+ out.push(
+ ` … input capped for diff (existing ${a.length} lines, generated ${b.length} lines)`
+ );
+ }
+
+ return out.join('\n');
+}
+
+function buildNewFileSection(relPath: string, content: string): string {
+ const size = formatBytes(byteSize(content));
+ const lang = fenceLang(relPath);
+ const { text, truncated } = truncateLines(content, MAX_PREVIEW_LINES);
+ const note = truncated
+ ? `\n_Preview truncated to ${MAX_PREVIEW_LINES} lines (${content.split('\n').length} total)._\n`
+ : '';
+
+ return [
+ `### \`${relPath}\` — NEW · ${size}`,
+ '',
+ note,
+ '```' + lang,
+ text,
+ '```',
+ '',
+ ].join('\n');
+}
+
+function buildExistingFileSection(
+ relPath: string,
+ existing: string,
+ generated: string
+): string {
+ const size = formatBytes(byteSize(generated));
+ if (existing === generated) {
+ return [
+ `### \`${relPath}\` — UNCHANGED · ${size}`,
+ '',
+ '_Generated content matches the existing file._',
+ '',
+ ].join('\n');
+ }
+
+ const diff = buildUnifiedDiff(existing, generated);
+ return [
+ `### \`${relPath}\` — EXISTING (will overwrite) · ${size}`,
+ '',
+ '```diff',
+ diff,
+ '```',
+ '',
+ ].join('\n');
+}
+
+export function buildContentPreviewMarkdown(
+ projectName: string,
+ projectPath: string,
+ files: Record
+): string {
+ const paths = Object.keys(files).sort((a, b) => a.localeCompare(b));
+ const shown = paths.slice(0, MAX_FILES_SHOWN);
+ const omitted = paths.length - shown.length;
+
+ const sections: string[] = [
+ `# Creer content preview`,
+ '',
+ `**Project:** \`${projectName}\``,
+ '',
+ `**Path:** \`${projectPath}\``,
+ '',
+ `**Files:** ${paths.length}`,
+ '',
+ '---',
+ '',
+ ];
+
+ for (const relPath of shown) {
+ const generated = files[relPath] ?? '';
+ const fullPath = path.join(projectPath, relPath);
+ let existing: string | undefined;
+ try {
+ if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
+ existing = fs.readFileSync(fullPath, 'utf8');
+ }
+ } catch {
+ existing = undefined;
+ }
+
+ if (existing === undefined) {
+ sections.push(buildNewFileSection(relPath, generated));
+ } else {
+ sections.push(buildExistingFileSection(relPath, existing, generated));
+ }
+ }
+
+ if (omitted > 0) {
+ sections.push(`_…and ${omitted} more file(s) not shown in this preview._`, '');
+ }
+
+ sections.push('---', '', '_Confirm to write these files to disk._', '');
+ return sections.join('\n');
+}
+
+/**
+ * Open an untitled markdown preview of generated file contents and ask the user
+ * to confirm writing. Returns true if the user confirms.
+ * When `creer.contentPreview` is false, skips the UI and returns true.
+ */
+export async function showContentPreviewAndConfirm(
+ projectName: string,
+ projectPath: string,
+ files: Record
+): Promise {
+ const config = vscode.workspace.getConfiguration('creer');
+ const enabled = config.get('contentPreview') ?? true;
+ if (!enabled) {
+ return true;
+ }
+
+ const markdown = buildContentPreviewMarkdown(projectName, projectPath, files);
+ const doc = await vscode.workspace.openTextDocument({
+ content: markdown,
+ language: 'markdown',
+ });
+ await vscode.window.showTextDocument(doc, { preview: true, preserveFocus: false });
+
+ const fileCount = Object.keys(files).length;
+ const choice = await vscode.window.showInformationMessage(
+ `Creer content ready: ${projectName} (${fileCount} files). Write files to disk?`,
+ { modal: true },
+ 'Write files',
+ 'Cancel'
+ );
+
+ return choice === 'Write files';
+}
diff --git a/extension/src/extension.ts b/extension/src/extension.ts
new file mode 100644
index 0000000..406d206
--- /dev/null
+++ b/extension/src/extension.ts
@@ -0,0 +1,124 @@
+import * as vscode from 'vscode';
+import { registerConflictDiffProvider } from './conflictDiff';
+import {
+ browseMarketplaceCommand,
+ browseRegistryCommand,
+ installPackFromUrlCommand,
+} from './marketplace';
+import { browseFederatedRegistryCommand, manageRegistryPeersCommand } from './registry';
+import { runScaffoldFlow } from './scaffold';
+import { clearGitHubToken, setGitHubToken } from './secrets';
+
+export function activate(context: vscode.ExtensionContext) {
+ registerConflictDiffProvider(context);
+
+ const createRepo = vscode.commands.registerCommand('creer.createRepo', async () => {
+ await runScaffoldFlow({ context, fromChat: false });
+ });
+
+ const createRepoFromChat = vscode.commands.registerCommand(
+ 'creer.createRepoFromChat',
+ async (idea?: string) => {
+ const initial = typeof idea === 'string' ? idea : undefined;
+ await runScaffoldFlow({ context, idea: initial, fromChat: true });
+ }
+ );
+
+ const setToken = vscode.commands.registerCommand('creer.setGitHubToken', async () => {
+ const token = await vscode.window.showInputBox({
+ prompt: 'GitHub personal access token (repo scope) — stored in SecretStorage',
+ placeHolder: 'ghp_…',
+ password: true,
+ ignoreFocusOut: true,
+ });
+ const trimmed = token?.trim();
+ if (!trimmed) {
+ return;
+ }
+ await setGitHubToken(context, trimmed);
+ void vscode.window.showInformationMessage('Creer: GitHub token saved to SecretStorage.');
+ });
+
+ const clearToken = vscode.commands.registerCommand('creer.clearGitHubToken', async () => {
+ await clearGitHubToken(context);
+ void vscode.window.showInformationMessage('Creer: GitHub token cleared from SecretStorage.');
+ });
+
+ const installPackFromUrl = vscode.commands.registerCommand(
+ 'creer.installPackFromUrl',
+ () => installPackFromUrlCommand()
+ );
+
+ const browseMarketplace = vscode.commands.registerCommand(
+ 'creer.browseMarketplace',
+ () => browseMarketplaceCommand()
+ );
+
+ const browseRegistry = vscode.commands.registerCommand(
+ 'creer.browseRegistry',
+ () => browseRegistryCommand()
+ );
+
+ const browseFederatedRegistry = vscode.commands.registerCommand(
+ 'creer.browseFederatedRegistry',
+ () => browseFederatedRegistryCommand()
+ );
+
+ const manageRegistryPeers = vscode.commands.registerCommand(
+ 'creer.manageRegistryPeers',
+ () => manageRegistryPeersCommand()
+ );
+
+ context.subscriptions.push(
+ createRepo,
+ createRepoFromChat,
+ setToken,
+ clearToken,
+ installPackFromUrl,
+ browseMarketplace,
+ browseRegistry,
+ browseFederatedRegistry,
+ manageRegistryPeers
+ );
+ registerChatParticipant(context);
+}
+
+function registerChatParticipant(context: vscode.ExtensionContext): void {
+ // Runtime feature-detect: chat API may be missing on older VS Code hosts.
+ const create =
+ typeof vscode.chat?.createChatParticipant === 'function'
+ ? vscode.chat.createChatParticipant.bind(vscode.chat)
+ : undefined;
+
+ if (!create) {
+ return;
+ }
+
+ try {
+ const participant = create(
+ 'creer.participant',
+ async (request, _context, stream, _token) => {
+ const idea = (request.prompt || '').trim();
+ if (!idea) {
+ stream.markdown(
+ 'Provide an idea after `@creer`, for example: `@creer Build a FastAPI todo app`.\n\n' +
+ 'You can also run **Creer: Create from Chat Prompt** and type `/creer …`.'
+ );
+ return;
+ }
+
+ stream.markdown(
+ `Scaffolding with Creer: **${idea}**…\n\n` +
+ 'Follow the prompts to pick a template, preview the plan, and confirm.'
+ );
+ await runScaffoldFlow({ context, idea, fromChat: true });
+ }
+ );
+
+ context.subscriptions.push(participant);
+ } catch {
+ // Hosts without chat support — ignore registration failure.
+ }
+}
+
+export function deactivate() {}
diff --git a/extension/src/git.ts b/extension/src/git.ts
new file mode 100644
index 0000000..daf7643
--- /dev/null
+++ b/extension/src/git.ts
@@ -0,0 +1,138 @@
+import { execFile } from 'child_process';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { promisify } from 'util';
+
+const execFileAsync = promisify(execFile);
+
+async function git(
+ cwd: string,
+ args: string[],
+ env?: NodeJS.ProcessEnv
+): Promise {
+ await execFileAsync('git', args, {
+ cwd,
+ env: env ?? process.env,
+ });
+}
+
+export async function isGitRepo(projectPath: string): Promise {
+ return fs.existsSync(path.join(projectPath, '.git'));
+}
+
+export async function initGit(projectPath: string): Promise {
+ await git(projectPath, ['init']);
+ await git(projectPath, ['add', '.']);
+ try {
+ await git(projectPath, ['commit', '-m', 'Initial commit']);
+ } catch {
+ // Commit can fail if git user.name/email are unset — still leave the repo initialized.
+ }
+}
+
+export async function ensureGitRepo(projectPath: string): Promise {
+ if (!(await isGitRepo(projectPath))) {
+ await initGit(projectPath);
+ }
+}
+
+/**
+ * Write a temporary GIT_ASKPASS helper that reads the token from CREER_GITHUB_TOKEN.
+ * Never put the token in the remote URL or argv.
+ */
+function writeAskpassScript(): string {
+ const isWin = process.platform === 'win32';
+ const askpassPath = path.join(
+ os.tmpdir(),
+ `creer-askpass-${process.pid}-${Date.now()}${isWin ? '.cmd' : '.sh'}`
+ );
+
+ if (isWin) {
+ // Git asks with prompts containing "Username" / "Password".
+ const script = [
+ '@echo off',
+ 'setlocal EnableExtensions',
+ 'echo(%* | findstr /I "Username" >nul',
+ 'if not errorlevel 1 (',
+ ' echo x-access-token',
+ ') else (',
+ ' echo(%CREER_GITHUB_TOKEN%',
+ ')',
+ '',
+ ].join('\r\n');
+ fs.writeFileSync(askpassPath, script, { encoding: 'utf8' });
+ } else {
+ const script = [
+ '#!/bin/sh',
+ 'case "$1" in',
+ ' *[Uu]sername*) printf "%s" "x-access-token" ;;',
+ ' *) printf "%s" "$CREER_GITHUB_TOKEN" ;;',
+ 'esac',
+ '',
+ ].join('\n');
+ fs.writeFileSync(askpassPath, script, { encoding: 'utf8', mode: 0o700 });
+ }
+
+ return askpassPath;
+}
+
+export async function addRemoteAndPush(
+ projectPath: string,
+ cloneUrl: string,
+ token?: string
+): Promise {
+ try {
+ await git(projectPath, ['remote', 'remove', 'origin']);
+ } catch {
+ // No existing origin — fine.
+ }
+
+ // Always store a clean clone URL (no embedded credentials).
+ await git(projectPath, ['remote', 'add', 'origin', cloneUrl]);
+
+ // Ensure we have a branch name for -u push.
+ try {
+ await git(projectPath, ['rev-parse', '--verify', 'HEAD']);
+ } catch {
+ await git(projectPath, ['add', '.']);
+ try {
+ await git(projectPath, ['commit', '-m', 'Initial commit']);
+ } catch {
+ throw new Error('Cannot push: repository has no commits (set git user.name/email).');
+ }
+ }
+
+ let askpassPath: string | undefined;
+ try {
+ const pushEnv: NodeJS.ProcessEnv = { ...process.env };
+
+ if (token) {
+ askpassPath = writeAskpassScript();
+ pushEnv.GIT_ASKPASS = askpassPath;
+ pushEnv.GIT_TERMINAL_PROMPT = '0';
+ pushEnv.CREER_GITHUB_TOKEN = token;
+ // Prefer askpass over interactive prompts / GUI helpers for this child only.
+ pushEnv.SSH_ASKPASS = askpassPath;
+ pushEnv.SSH_ASKPASS_REQUIRE = 'never';
+ }
+
+ // Push to the clean clone URL — credentials come from askpass/env only.
+ try {
+ await git(projectPath, ['push', '-u', cloneUrl, 'HEAD'], pushEnv);
+ } catch {
+ await git(projectPath, ['push', '-u', cloneUrl, 'HEAD:main'], pushEnv);
+ }
+ } finally {
+ if (askpassPath) {
+ try {
+ fs.unlinkSync(askpassPath);
+ } catch {
+ // Best-effort cleanup.
+ }
+ }
+ }
+
+ // Keep origin pointing at the clean clone URL.
+ await git(projectPath, ['remote', 'set-url', 'origin', cloneUrl]);
+}
diff --git a/extension/src/marketplace.ts b/extension/src/marketplace.ts
new file mode 100644
index 0000000..a9a2167
--- /dev/null
+++ b/extension/src/marketplace.ts
@@ -0,0 +1,279 @@
+import * as vscode from 'vscode';
+import {
+ deletePack,
+ fetchMarketplace,
+ fetchRegistry,
+ formatAxiosError,
+ installPack,
+ type MarketplaceItem,
+ type RegistryItem,
+} from './api';
+
+export function resolveInstallUrl(url: string | undefined): string | undefined {
+ const trimmed = url?.trim();
+ if (!trimmed) {
+ return undefined;
+ }
+ if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
+ return trimmed;
+ }
+ // Relative registry paths → absolute against backend
+ const config = vscode.workspace.getConfiguration('creer');
+ const backendUrl = (config.get('backendUrl') || 'http://localhost:8000').replace(
+ /\/$/,
+ ''
+ );
+ if (trimmed.startsWith('/')) {
+ return `${backendUrl}${trimmed}`;
+ }
+ return `${backendUrl}/${trimmed}`;
+}
+
+/**
+ * Creer: Install Pack from URL — prompt for URL, POST /packs/install.
+ */
+export async function installPackFromUrlCommand(): Promise {
+ const url = await vscode.window.showInputBox({
+ prompt: 'Pack URL (JSON/YAML pack definition or registry download URL)',
+ placeHolder: 'http://localhost:8000/registry/packs/fastapi-crud/download',
+ ignoreFocusOut: true,
+ });
+ const trimmed = url?.trim();
+ if (!trimmed) {
+ return;
+ }
+
+ try {
+ const pack = await vscode.window.withProgress(
+ {
+ location: vscode.ProgressLocation.Notification,
+ title: 'Creer: installing pack…',
+ cancellable: false,
+ },
+ () => installPack(trimmed)
+ );
+ const name = pack.name || pack.id || 'pack';
+ void vscode.window.showInformationMessage(
+ `Creer: installed pack “${name}”${pack.id ? ` (${pack.id})` : ''}.`
+ );
+ } catch (err) {
+ const message = formatAxiosError(err, 'Failed to install pack');
+ void vscode.window.showErrorMessage(`Creer: could not install pack — ${message}`);
+ }
+}
+
+export function isBundledSource(source: string | undefined): boolean {
+ if (!source) {
+ return false;
+ }
+ const s = source.toLowerCase();
+ return s === 'bundled' || s === 'builtin' || s === 'built-in' || s === 'local';
+}
+
+export async function installFromResolvedUrl(
+ label: string,
+ url: string | undefined
+): Promise {
+ const resolved = resolveInstallUrl(url);
+ if (!resolved) {
+ void vscode.window.showInformationMessage(`Creer: “${label}” has no install URL.`);
+ return;
+ }
+
+ const action = await vscode.window.showInformationMessage(
+ `Install pack “${label}”?\n${resolved}`,
+ 'Install',
+ 'Cancel'
+ );
+ if (action !== 'Install') {
+ return;
+ }
+
+ try {
+ const pack = await vscode.window.withProgress(
+ {
+ location: vscode.ProgressLocation.Notification,
+ title: `Creer: installing ${label}…`,
+ cancellable: false,
+ },
+ () => installPack(resolved)
+ );
+ void vscode.window.showInformationMessage(
+ `Creer: installed pack “${pack.name || pack.id || label}”.`
+ );
+ } catch (err) {
+ const message = formatAxiosError(err, 'Failed to install pack');
+ void vscode.window.showErrorMessage(`Creer: could not install pack — ${message}`);
+ }
+}
+
+/**
+ * Creer: Browse Pack Marketplace — GET /marketplace, QuickPick, optional install.
+ */
+export async function browseMarketplaceCommand(): Promise {
+ let items: MarketplaceItem[];
+ try {
+ items = await vscode.window.withProgress(
+ {
+ location: vscode.ProgressLocation.Notification,
+ title: 'Creer: loading marketplace…',
+ cancellable: false,
+ },
+ () => fetchMarketplace()
+ );
+ } catch (err) {
+ const message = formatAxiosError(err, 'Marketplace unavailable');
+ void vscode.window.showWarningMessage(
+ `Creer: marketplace endpoint not available (${message}). ` +
+ 'Try “Creer: Browse Pack Registry” or “Install Pack from URL”.'
+ );
+ return;
+ }
+
+ if (items.length === 0) {
+ void vscode.window.showInformationMessage('Creer: marketplace has no items.');
+ return;
+ }
+
+ type PickItem = vscode.QuickPickItem & { market?: MarketplaceItem };
+
+ const picks: PickItem[] = items.map((item) => {
+ const bundled = isBundledSource(item.source);
+ const hasUrl = Boolean(item.url?.trim() || item.download_url?.trim());
+ let description = item.source || '';
+ if (bundled) {
+ description = description ? `${description} · bundled` : 'bundled';
+ } else if (hasUrl) {
+ description = description ? `${description} · remote` : 'remote';
+ }
+ return {
+ label: item.name || item.id,
+ description,
+ detail: item.description,
+ market: item,
+ };
+ });
+
+ const picked = await vscode.window.showQuickPick(picks, {
+ placeHolder: 'Select a marketplace pack',
+ ignoreFocusOut: true,
+ matchOnDescription: true,
+ matchOnDetail: true,
+ });
+
+ if (!picked?.market) {
+ return;
+ }
+
+ const item = picked.market;
+ if (isBundledSource(item.source) && !item.url && !item.download_url) {
+ void vscode.window.showInformationMessage(
+ `Creer: “${item.name || item.id}” is already available (bundled).`
+ );
+ return;
+ }
+
+ await installFromResolvedUrl(
+ item.name || item.id,
+ item.url || item.download_url
+ );
+}
+
+/**
+ * Creer: Browse Pack Registry — searchable self-hosted /registry catalog.
+ */
+export async function browseRegistryCommand(): Promise {
+ const q = await vscode.window.showInputBox({
+ prompt: 'Search registry (leave empty for all packs)',
+ placeHolder: 'fastapi, express, cli…',
+ ignoreFocusOut: true,
+ });
+ if (q === undefined) {
+ return;
+ }
+
+ let items: RegistryItem[];
+ try {
+ const registry = await vscode.window.withProgress(
+ {
+ location: vscode.ProgressLocation.Notification,
+ title: 'Creer: loading registry…',
+ cancellable: false,
+ },
+ () => fetchRegistry({ q: q.trim() || undefined })
+ );
+ items = registry.items;
+ } catch (err) {
+ const message = formatAxiosError(err, 'Registry unavailable');
+ void vscode.window.showWarningMessage(
+ `Creer: registry endpoint not available (${message}).`
+ );
+ return;
+ }
+
+ if (items.length === 0) {
+ void vscode.window.showInformationMessage('Creer: no registry packs matched.');
+ return;
+ }
+
+ type PickItem = vscode.QuickPickItem & { reg?: RegistryItem };
+ const picks: PickItem[] = items.map((item) => ({
+ label: item.name || item.id,
+ description: [item.source, item.stack, item.version].filter(Boolean).join(' · '),
+ detail: item.description,
+ reg: item,
+ }));
+
+ const picked = await vscode.window.showQuickPick(picks, {
+ placeHolder: 'Select a registry pack to install (or reinstall)',
+ ignoreFocusOut: true,
+ matchOnDescription: true,
+ matchOnDetail: true,
+ });
+
+ if (!picked?.reg) {
+ return;
+ }
+
+ const item = picked.reg;
+ if (isBundledSource(item.source)) {
+ const choice = await vscode.window.showInformationMessage(
+ `“${item.name || item.id}” is bundled. Install a local copy via registry download anyway?`,
+ 'Install copy',
+ 'Cancel'
+ );
+ if (choice !== 'Install copy') {
+ return;
+ }
+ }
+
+ await installFromResolvedUrl(
+ item.name || item.id,
+ item.install_url || item.download_url
+ );
+}
+
+/** Optional helper for hosts that expose delete UI later. */
+export async function deletePackCommand(packId?: string): Promise {
+ let id = packId?.trim();
+ if (!id) {
+ id = (
+ await vscode.window.showInputBox({
+ prompt: 'Pack id to delete (installed packs only)',
+ placeHolder: 'fastapi-crud',
+ ignoreFocusOut: true,
+ })
+ )?.trim();
+ }
+ if (!id) {
+ return;
+ }
+
+ try {
+ await deletePack(id);
+ void vscode.window.showInformationMessage(`Creer: deleted pack “${id}”.`);
+ } catch (err) {
+ const message = formatAxiosError(err, 'Failed to delete pack');
+ void vscode.window.showErrorMessage(`Creer: could not delete pack — ${message}`);
+ }
+}
diff --git a/extension/src/preview.ts b/extension/src/preview.ts
new file mode 100644
index 0000000..e98c1ea
--- /dev/null
+++ b/extension/src/preview.ts
@@ -0,0 +1,62 @@
+import * as vscode from 'vscode';
+import type { PlanResponse } from './api';
+
+function buildFileTreeMarkdown(files: string[]): string {
+ const sorted = [...files].sort((a, b) => a.localeCompare(b));
+ return sorted.map((f) => `- \`${f}\``).join('\n');
+}
+
+export function buildPlanPreviewMarkdown(plan: PlanResponse, idea: string): string {
+ const stack = plan.stack?.trim() || '(unspecified)';
+ let sourceLine = '\n**Template:** AI plan (no template)\n';
+ if (plan.pack_id) {
+ sourceLine = `\n**Pack:** \`${plan.pack_id}\`\n`;
+ } else if (plan.template_id) {
+ sourceLine = `\n**Template:** \`${plan.template_id}\`\n`;
+ }
+ const description = plan.description?.trim()
+ ? `\n**Description:** ${plan.description.trim()}\n`
+ : '';
+
+ return [
+ `# Creer plan preview`,
+ '',
+ `**Idea:** ${idea}`,
+ '',
+ `**Project:** \`${plan.project_name}\``,
+ '',
+ `**Stack:** ${stack}`,
+ sourceLine,
+ description,
+ `**Files (${plan.files.length}):**`,
+ '',
+ buildFileTreeMarkdown(plan.files),
+ '',
+ '---',
+ '',
+ '_Confirm generation to write these files into your workspace._',
+ '',
+ ].join('\n');
+}
+
+/**
+ * Open an untitled markdown preview document and ask the user to confirm generation.
+ * Returns true if the user confirms, false if they cancel.
+ */
+export async function showPlanPreviewAndConfirm(plan: PlanResponse, idea: string): Promise {
+ const markdown = buildPlanPreviewMarkdown(plan, idea);
+ const doc = await vscode.workspace.openTextDocument({
+ content: markdown,
+ language: 'markdown',
+ });
+ await vscode.window.showTextDocument(doc, { preview: true, preserveFocus: false });
+
+ const choice = await vscode.window.showInformationMessage(
+ `Creer plan ready: ${plan.project_name} (${plan.files.length} files). Generate & write files?`,
+ { modal: true },
+ 'Generate & write files',
+ 'Cancel'
+ );
+
+ return choice === 'Generate & write files';
+}
diff --git a/extension/src/registry.ts b/extension/src/registry.ts
new file mode 100644
index 0000000..dbd2916
--- /dev/null
+++ b/extension/src/registry.ts
@@ -0,0 +1,447 @@
+import * as vscode from 'vscode';
+import {
+ fetchFederatedRegistry,
+ fetchPeerStatus,
+ formatAxiosError,
+ probePeer,
+ type FederatedRegistryItem,
+ type PeerStatus,
+} from './api';
+import {
+ installFromResolvedUrl,
+ isBundledSource,
+} from './marketplace';
+
+function peerHostLabel(peer: string | undefined): string | undefined {
+ if (!peer?.trim()) {
+ return undefined;
+ }
+ try {
+ return new URL(peer).host || peer;
+ } catch {
+ return peer;
+ }
+}
+
+function peerUrlOf(status: PeerStatus): string {
+ return (status.url || status.base_url || '').trim();
+}
+
+function formatPeerHealth(status: PeerStatus): string {
+ const host = peerHostLabel(peerUrlOf(status)) || peerUrlOf(status) || 'peer';
+ if (status.ok) {
+ const latency =
+ typeof status.latency_ms === 'number' ? ` ${Math.round(status.latency_ms)}ms` : '';
+ return `$(check) ${host}${latency}`;
+ }
+ const err = status.error ? `: ${status.error}` : '';
+ return `$(error) ${host}${err}`;
+}
+
+function parseRegistryPeersSetting(): string[] {
+ const raw = vscode.workspace.getConfiguration('creer').get('registryPeers') || '';
+ return raw
+ .split(',')
+ .map((p) => p.trim().replace(/\/$/, ''))
+ .filter(Boolean);
+}
+
+async function saveRegistryPeersSetting(peers: string[]): Promise {
+ const value = peers.join(', ');
+ await vscode.workspace
+ .getConfiguration('creer')
+ .update('registryPeers', value, vscode.ConfigurationTarget.Global);
+}
+
+/**
+ * Creer: Browse Federated Registry — GET /registry/federated, QuickPick, install.
+ */
+export async function browseFederatedRegistryCommand(): Promise {
+ const config = vscode.workspace.getConfiguration('creer');
+ const showPeerStatus = config.get('showPeerStatus') !== false;
+ const registryPeers = config.get('registryPeers') || '';
+
+ const q = await vscode.window.showInputBox({
+ prompt: 'Search federated registry (leave empty for all packs)',
+ placeHolder: 'fastapi, express, peer host…',
+ ignoreFocusOut: true,
+ });
+ if (q === undefined) {
+ return;
+ }
+
+ let peerStatuses: PeerStatus[] | undefined;
+ if (showPeerStatus) {
+ try {
+ const statusResp = await fetchPeerStatus();
+ peerStatuses = statusResp.peers;
+ const ok = peerStatuses.filter((p) => p.ok).length;
+ const fail = peerStatuses.length - ok;
+ if (peerStatuses.length > 0) {
+ void vscode.window.showInformationMessage(
+ `Creer peers: ${ok} ok, ${fail} fail` +
+ (peerStatuses.some((p) => typeof p.latency_ms === 'number')
+ ? ` · ${peerStatuses
+ .filter((p) => p.ok && typeof p.latency_ms === 'number')
+ .map((p) => `${peerHostLabel(peerUrlOf(p))}:${Math.round(p.latency_ms!)}ms`)
+ .slice(0, 3)
+ .join(', ')}`
+ : '')
+ );
+ }
+ } catch {
+ // Soft-fail: peer status endpoints may be missing on older backends.
+ peerStatuses = undefined;
+ }
+ }
+
+ let items: FederatedRegistryItem[];
+ let peerCount = 0;
+ try {
+ const federated = await vscode.window.withProgress(
+ {
+ location: vscode.ProgressLocation.Notification,
+ title: 'Creer: loading federated registry…',
+ cancellable: false,
+ },
+ () =>
+ fetchFederatedRegistry({
+ q: q.trim() || undefined,
+ peers: registryPeers.trim() || undefined,
+ })
+ );
+ items = federated.items;
+ peerCount = federated.peers?.length ?? 0;
+ } catch (err) {
+ const message = formatAxiosError(err, 'Federated registry unavailable');
+ void vscode.window.showWarningMessage(
+ `Creer: federated registry endpoint not available (${message}). ` +
+ 'Try “Creer: Browse Pack Registry” or configure creer.registryPeers / CREER_REGISTRY_PEERS.'
+ );
+ return;
+ }
+
+ if (items.length === 0) {
+ void vscode.window.showInformationMessage(
+ peerCount > 0
+ ? 'Creer: no federated packs matched (peers configured).'
+ : 'Creer: no federated packs matched.'
+ );
+ return;
+ }
+
+ type PickItem = vscode.QuickPickItem & { fed?: FederatedRegistryItem };
+ const picks: PickItem[] = [];
+
+ if (showPeerStatus && peerStatuses && peerStatuses.length > 0) {
+ picks.push({
+ label: 'Peer health',
+ kind: vscode.QuickPickItemKind.Separator,
+ });
+ for (const status of peerStatuses) {
+ picks.push({
+ label: formatPeerHealth(status),
+ description: peerUrlOf(status),
+ detail: status.ok
+ ? typeof status.count === 'number'
+ ? `${status.count} packs`
+ : 'reachable'
+ : status.error || 'unreachable',
+ });
+ }
+ picks.push({
+ label: 'Packs',
+ kind: vscode.QuickPickItemKind.Separator,
+ });
+ }
+
+ const localItems = items.filter((i) => !i.peer);
+ const remoteItems = items.filter((i) => Boolean(i.peer));
+
+ const toPick = (item: FederatedRegistryItem): PickItem => {
+ const host = peerHostLabel(item.peer);
+ const parts: string[] = [];
+ if (host) {
+ parts.push(host);
+ } else {
+ parts.push('local');
+ }
+ if (item.source) {
+ parts.push(item.source);
+ }
+ if (item.stack) {
+ parts.push(item.stack);
+ }
+ if (item.version) {
+ parts.push(item.version);
+ }
+ return {
+ label: host
+ ? `$(cloud) ${item.name || item.id}`
+ : `$(home) ${item.name || item.id}`,
+ description: parts.join(' · '),
+ detail: item.description,
+ fed: item,
+ };
+ };
+
+ for (const item of localItems) {
+ picks.push(toPick(item));
+ }
+ for (const item of remoteItems) {
+ picks.push(toPick(item));
+ }
+
+ const picked = await vscode.window.showQuickPick(picks, {
+ placeHolder: 'Select a federated pack to install (peer host shown in description)',
+ ignoreFocusOut: true,
+ matchOnDescription: true,
+ matchOnDetail: true,
+ });
+
+ if (!picked?.fed) {
+ return;
+ }
+
+ const item = picked.fed;
+ if (isBundledSource(item.source) && !item.peer) {
+ const choice = await vscode.window.showInformationMessage(
+ `“${item.name || item.id}” is bundled locally. Install a copy via download URL anyway?`,
+ 'Install copy',
+ 'Cancel'
+ );
+ if (choice !== 'Install copy') {
+ return;
+ }
+ }
+
+ await installFromResolvedUrl(
+ item.name || item.id,
+ item.install_url || item.download_url
+ );
+}
+
+/**
+ * Creer: Manage Registry Peers — view/add/remove setting peers; probe via backend.
+ */
+export async function manageRegistryPeersCommand(): Promise {
+ const settingPeers = parseRegistryPeersSetting();
+
+ let configured: string[] = [];
+ let livePeers: PeerStatus[] = [];
+ let peersEndpointOk = true;
+ try {
+ const status = await fetchPeerStatus();
+ configured = status.configured ?? [];
+ livePeers = status.peers ?? [];
+ } catch {
+ peersEndpointOk = false;
+ }
+
+ const lines: string[] = [];
+ lines.push(
+ settingPeers.length
+ ? `Setting peers (${settingPeers.length}): ${settingPeers.join(', ')}`
+ : 'Setting peers: (none)'
+ );
+ if (peersEndpointOk) {
+ lines.push(
+ configured.length
+ ? `Backend configured (${configured.length}): ${configured.join(', ')}`
+ : 'Backend configured: (none)'
+ );
+ if (livePeers.length > 0) {
+ lines.push(
+ 'Status: ' +
+ livePeers
+ .map((p) => {
+ const host = peerHostLabel(peerUrlOf(p)) || peerUrlOf(p);
+ return p.ok
+ ? `${host} ok${typeof p.latency_ms === 'number' ? ` ${Math.round(p.latency_ms)}ms` : ''}`
+ : `${host} fail`;
+ })
+ .join('; ')
+ );
+ }
+ } else {
+ lines.push('Backend peer endpoints unavailable (soft-fail). You can still edit creer.registryPeers.');
+ }
+
+ type Action = 'add' | 'remove' | 'probe' | 'done';
+ type ActionPick = vscode.QuickPickItem & { action?: Action };
+
+ const actions: ActionPick[] = [
+ {
+ label: '$(info) Current peers',
+ description: 'Summary',
+ detail: lines.join('\n'),
+ },
+ {
+ label: '$(add) Add peer',
+ description: 'Probe URL then append to creer.registryPeers',
+ action: 'add',
+ },
+ {
+ label: '$(remove) Remove peer',
+ description: 'Remove from creer.registryPeers',
+ action: 'remove',
+ },
+ {
+ label: '$(debug-alt) Probe all',
+ description: peersEndpointOk
+ ? 'POST /registry/peers/probe for each setting peer'
+ : 'Requires backend probe endpoint',
+ action: 'probe',
+ },
+ ];
+
+ const picked = await vscode.window.showQuickPick(actions, {
+ placeHolder: 'Creer: Manage Registry Peers',
+ ignoreFocusOut: true,
+ matchOnDescription: true,
+ matchOnDetail: true,
+ });
+
+ if (!picked?.action) {
+ if (picked && !picked.action) {
+ void vscode.window.showInformationMessage(`Creer:\n${lines.join('\n')}`);
+ }
+ return;
+ }
+
+ if (picked.action === 'add') {
+ const url = await vscode.window.showInputBox({
+ prompt: 'Peer base URL (e.g. https://creer.example.com)',
+ placeHolder: 'http://localhost:8001',
+ ignoreFocusOut: true,
+ });
+ const trimmed = url?.trim().replace(/\/$/, '');
+ if (!trimmed) {
+ return;
+ }
+
+ let ok = true;
+ let probeError: string | undefined;
+ try {
+ const result = await vscode.window.withProgress(
+ {
+ location: vscode.ProgressLocation.Notification,
+ title: `Creer: probing ${trimmed}…`,
+ cancellable: false,
+ },
+ () => probePeer(trimmed)
+ );
+ ok = result.ok;
+ probeError = result.error || undefined;
+ if (ok) {
+ const latency =
+ typeof result.latency_ms === 'number'
+ ? ` (${Math.round(result.latency_ms)}ms)`
+ : '';
+ void vscode.window.showInformationMessage(
+ `Creer: peer reachable${latency}.`
+ );
+ }
+ } catch (err) {
+ // Soft-fail: if probe endpoint missing, still allow adding after confirm.
+ const message = formatAxiosError(err, 'Probe failed');
+ const choice = await vscode.window.showWarningMessage(
+ `Creer: could not probe peer (${message}). Add anyway?`,
+ 'Add',
+ 'Cancel'
+ );
+ if (choice !== 'Add') {
+ return;
+ }
+ ok = true;
+ }
+
+ if (!ok) {
+ void vscode.window.showErrorMessage(
+ `Creer: peer probe failed${probeError ? ` — ${probeError}` : ''}. Not added.`
+ );
+ return;
+ }
+
+ const next = parseRegistryPeersSetting();
+ if (!next.includes(trimmed)) {
+ next.push(trimmed);
+ await saveRegistryPeersSetting(next);
+ }
+ void vscode.window.showInformationMessage(
+ `Creer: added peer ${trimmed} to creer.registryPeers.`
+ );
+ return;
+ }
+
+ if (picked.action === 'remove') {
+ const current = parseRegistryPeersSetting();
+ if (current.length === 0) {
+ void vscode.window.showInformationMessage('Creer: no setting peers to remove.');
+ return;
+ }
+ const toRemove = await vscode.window.showQuickPick(
+ current.map((url) => ({ label: url, description: peerHostLabel(url) })),
+ {
+ placeHolder: 'Select peer to remove from creer.registryPeers',
+ ignoreFocusOut: true,
+ canPickMany: true,
+ }
+ );
+ if (!toRemove || toRemove.length === 0) {
+ return;
+ }
+ const removeSet = new Set(toRemove.map((p) => p.label));
+ const remaining = current.filter((u) => !removeSet.has(u));
+ await saveRegistryPeersSetting(remaining);
+ void vscode.window.showInformationMessage(
+ `Creer: removed ${toRemove.length} peer(s) from creer.registryPeers.`
+ );
+ return;
+ }
+
+ if (picked.action === 'probe') {
+ const current = parseRegistryPeersSetting();
+ const targets =
+ current.length > 0
+ ? current
+ : configured.length > 0
+ ? configured
+ : [];
+ if (targets.length === 0) {
+ void vscode.window.showInformationMessage(
+ 'Creer: no peers to probe. Add peers via this command or CREER_REGISTRY_PEERS.'
+ );
+ return;
+ }
+
+ const results: string[] = [];
+ await vscode.window.withProgress(
+ {
+ location: vscode.ProgressLocation.Notification,
+ title: 'Creer: probing peers…',
+ cancellable: false,
+ },
+ async (progress) => {
+ for (let i = 0; i < targets.length; i++) {
+ const url = targets[i];
+ progress.report({
+ message: `${i + 1}/${targets.length} ${peerHostLabel(url) || url}`,
+ });
+ try {
+ const status = await probePeer(url);
+ results.push(formatPeerHealth(status).replace(/\$\([^)]+\)\s*/g, ''));
+ } catch (err) {
+ results.push(
+ `${peerHostLabel(url) || url}: ${formatAxiosError(err, 'probe failed')}`
+ );
+ }
+ }
+ }
+ );
+
+ void vscode.window.showInformationMessage(
+ `Creer probe results:\n${results.join('\n')}`
+ );
+ }
+}
diff --git a/extension/src/scaffold.ts b/extension/src/scaffold.ts
new file mode 100644
index 0000000..013a938
--- /dev/null
+++ b/extension/src/scaffold.ts
@@ -0,0 +1,658 @@
+import * as path from 'path';
+import * as vscode from 'vscode';
+import {
+ createGitHubRepo,
+ fetchBakeins,
+ fetchPacks,
+ fetchTemplates,
+ formatAxiosError,
+ postGenerate,
+ postPlan,
+ type BakeinOptions,
+ type CiBakein,
+ type GenerateResponse,
+ type LicenseBakein,
+ type Pack,
+ type PlanResponse,
+ type QualityIssue,
+ type Template,
+} from './api';
+import { showContentPreviewAndConfirm } from './contentPreview';
+import { addRemoteAndPush, ensureGitRepo, initGit } from './git';
+import { showPlanPreviewAndConfirm } from './preview';
+import { resolveGitHubToken } from './secrets';
+import {
+ isCancellationError,
+ streamGenerate,
+ type StreamProgressEvent,
+} from './streamGenerate';
+import { resolveConflictsWithDiffs } from './conflictDiff';
+import { pickWorkspaceRoot } from './workspace';
+import {
+ assertSafeProjectName,
+ findConflicts,
+ writeProjectFiles,
+} from './writeFiles';
+
+export interface ScaffoldOptions {
+ /** Extension context (required for SecretStorage). */
+ context: vscode.ExtensionContext;
+ /** Pre-filled idea (e.g. from chat). If omitted, prompts the user. */
+ idea?: string;
+ /** Use chat-style InputBox placeholder (/creer …). */
+ fromChat?: boolean;
+}
+
+export type SourcePick =
+ | { kind: 'ai' }
+ | { kind: 'template'; id: string }
+ | { kind: 'pack'; id: string };
+
+const LICENSE_FALLBACK: Array<{ id: LicenseBakein; name: string }> = [
+ { id: 'mit', name: 'MIT' },
+ { id: 'apache-2.0', name: 'Apache-2.0' },
+ { id: 'none', name: 'None' },
+];
+
+const CI_FALLBACK: Array<{ id: CiBakein; name: string }> = [
+ { id: 'auto', name: 'Auto (detect from stack)' },
+ { id: 'python', name: 'Python' },
+ { id: 'node', name: 'Node' },
+ { id: 'none', name: 'None' },
+];
+
+function stripCreerPrefix(raw: string): string {
+ return raw.replace(/^\s*\/creer\b\s*/i, '').trim();
+}
+
+async function promptForIdea(fromChat: boolean, initial?: string): Promise {
+ if (initial?.trim()) {
+ const stripped = stripCreerPrefix(initial);
+ if (stripped) {
+ return stripped;
+ }
+ }
+
+ const value = await vscode.window.showInputBox({
+ prompt: fromChat
+ ? 'Enter a /creer prompt describing the project to scaffold'
+ : 'Describe the project you want to create',
+ placeHolder: fromChat
+ ? '/creer Build a FastAPI todo'
+ : 'Build a FastAPI todo app',
+ value: initial?.trim() || undefined,
+ ignoreFocusOut: true,
+ });
+
+ if (!value?.trim()) {
+ return undefined;
+ }
+ return stripCreerPrefix(value);
+}
+
+/**
+ * QuickPick: AI plan | built-in templates | packs.
+ * Returns null if cancelled.
+ */
+async function pickSource(
+ templates: Template[],
+ packs: Pack[]
+): Promise {
+ type Item = vscode.QuickPickItem & {
+ kindSelect?: SourcePick['kind'];
+ sourceId?: string;
+ };
+
+ const items: Item[] = [
+ {
+ label: 'AI plan (no template)',
+ description: 'Let Creer choose the stack and file layout',
+ kindSelect: 'ai',
+ },
+ ];
+
+ if (templates.length > 0) {
+ items.push({
+ label: 'Built-in templates',
+ kind: vscode.QuickPickItemKind.Separator,
+ });
+ for (const t of templates) {
+ items.push({
+ label: t.name,
+ description: t.stack,
+ detail: t.description,
+ kindSelect: 'template',
+ sourceId: t.id,
+ });
+ }
+ }
+
+ if (packs.length > 0) {
+ items.push({
+ label: 'Packs',
+ kind: vscode.QuickPickItemKind.Separator,
+ });
+ for (const p of packs) {
+ const versionNote = p.version ? ` v${p.version}` : '';
+ items.push({
+ label: `[pack] ${p.name}`,
+ description: `${p.stack}${versionNote}`,
+ detail: p.description,
+ kindSelect: 'pack',
+ sourceId: p.id,
+ });
+ }
+ }
+
+ const picked = await vscode.window.showQuickPick(items, {
+ placeHolder: 'Select AI plan, template, or pack',
+ ignoreFocusOut: true,
+ matchOnDescription: true,
+ matchOnDetail: true,
+ });
+
+ if (!picked || picked.kind === vscode.QuickPickItemKind.Separator) {
+ return null;
+ }
+
+ if (picked.kindSelect === 'template' && picked.sourceId) {
+ return { kind: 'template', id: picked.sourceId };
+ }
+ if (picked.kindSelect === 'pack' && picked.sourceId) {
+ return { kind: 'pack', id: picked.sourceId };
+ }
+ return { kind: 'ai' };
+}
+
+function isLicenseBakein(v: string): v is LicenseBakein {
+ return v === 'mit' || v === 'apache-2.0' || v === 'none';
+}
+
+function isCiBakein(v: string): v is CiBakein {
+ return v === 'auto' || v === 'python' || v === 'node' || v === 'none';
+}
+
+/**
+ * Resolve bake-in options from settings, optionally prompting via QuickPick
+ * when `creer.promptBakeins` is true.
+ * Returns null if the user cancels a prompt.
+ */
+async function resolveBakeins(): Promise {
+ const config = vscode.workspace.getConfiguration('creer');
+ const promptBakeins = config.get('promptBakeins') ?? true;
+ const settingsLicense = config.get('license') ?? 'mit';
+ const settingsCi = config.get('ciPreset') ?? 'auto';
+
+ const defaultLicense: LicenseBakein = isLicenseBakein(settingsLicense)
+ ? settingsLicense
+ : 'mit';
+ const defaultCi: CiBakein = isCiBakein(settingsCi) ? settingsCi : 'auto';
+
+ if (!promptBakeins) {
+ return { license: defaultLicense, ci: defaultCi };
+ }
+
+ let licenses: Array<{ id: string; name: string }> = LICENSE_FALLBACK.map((l) => ({
+ id: l.id,
+ name: l.name,
+ }));
+ let ciOptions: Array<{ id: string; name: string }> = CI_FALLBACK.map((c) => ({
+ id: c.id,
+ name: c.name,
+ }));
+
+ try {
+ const remote = await fetchBakeins();
+ if (remote.licenses.length > 0) {
+ licenses = remote.licenses;
+ }
+ if (remote.ci.length > 0) {
+ ciOptions = remote.ci;
+ }
+ } catch {
+ // Use built-in fallbacks when /bakeins is unavailable.
+ }
+
+ const licenseItems: Array = licenses.map(
+ (l) => ({
+ label: l.name,
+ description: l.id,
+ value: l.id,
+ })
+ );
+ const licensePick = await vscode.window.showQuickPick(licenseItems, {
+ placeHolder: `Select a license (default: ${defaultLicense})`,
+ ignoreFocusOut: true,
+ matchOnDescription: true,
+ });
+ if (!licensePick) {
+ return null;
+ }
+
+ const ciItems: Array = ciOptions.map((c) => ({
+ label: c.name,
+ description: c.id,
+ value: c.id,
+ }));
+ const ciPick = await vscode.window.showQuickPick(ciItems, {
+ placeHolder: `Select a CI preset (default: ${defaultCi})`,
+ ignoreFocusOut: true,
+ matchOnDescription: true,
+ });
+ if (!ciPick) {
+ return null;
+ }
+
+ const license = isLicenseBakein(licensePick.value) ? licensePick.value : defaultLicense;
+ const ci = isCiBakein(ciPick.value) ? ciPick.value : defaultCi;
+ return { license, ci };
+}
+
+async function maybeCreateGitHubRemote(
+ context: vscode.ExtensionContext,
+ projectPath: string,
+ projectName: string,
+ idea: string
+): Promise {
+ const config = vscode.workspace.getConfiguration('creer');
+ const autoCreate = config.get('createGitHubRepo') ?? false;
+ const isPrivate = config.get('githubPrivate') ?? true;
+
+ let shouldCreate = autoCreate;
+ if (!shouldCreate) {
+ const answer = await vscode.window.showInformationMessage(
+ 'Create GitHub repository?',
+ 'Yes',
+ 'No'
+ );
+ shouldCreate = answer === 'Yes';
+ }
+
+ if (!shouldCreate) {
+ return;
+ }
+
+ const token = await resolveGitHubToken(context);
+ if (!token) {
+ vscode.window.showWarningMessage('GitHub token required to create a repository. Skipped.');
+ return;
+ }
+
+ try {
+ const repo = await vscode.window.withProgress(
+ {
+ location: vscode.ProgressLocation.Notification,
+ title: 'Creer: creating GitHub repository…',
+ cancellable: false,
+ },
+ async () =>
+ createGitHubRepo(token, projectName, {
+ private: isPrivate,
+ description: idea.slice(0, 200),
+ })
+ );
+
+ await ensureGitRepo(projectPath);
+ await addRemoteAndPush(projectPath, repo.clone_url, token);
+
+ const open = await vscode.window.showInformationMessage(
+ `GitHub repo created: ${repo.html_url}`,
+ 'Open'
+ );
+ if (open === 'Open') {
+ await vscode.env.openExternal(vscode.Uri.parse(repo.html_url));
+ }
+ } catch (err) {
+ const message = formatAxiosError(err, 'GitHub create/push failed');
+ vscode.window.showWarningMessage(`Project created, but GitHub step failed: ${message}`);
+ }
+}
+
+function reportStreamProgress(
+ progress: vscode.Progress<{ message?: string; increment?: number }>,
+ ev: StreamProgressEvent
+): void {
+ if (ev.event === 'start') {
+ const jobNote = ev.job_id ? ` · job ${ev.job_id}` : '';
+ progress.report({
+ message: `Starting ${ev.project_name} (${ev.total} files)${jobNote}…`,
+ });
+ return;
+ }
+ if (ev.event === 'file') {
+ const status = ev.status === 'done' ? 'done' : 'generating';
+ progress.report({
+ message: `[${ev.index}/${ev.total}] ${ev.path} (${status})`,
+ });
+ }
+}
+
+function reportQualityIssues(quality: QualityIssue[] | undefined): void {
+ if (!quality || quality.length === 0) {
+ return;
+ }
+
+ const errors = quality.filter((q) => {
+ const s = (q.severity || '').toLowerCase();
+ return s === 'error' || s === 'critical' || s === 'fatal';
+ });
+ const warnings = quality.filter((q) => {
+ const s = (q.severity || '').toLowerCase();
+ return s === 'warning' || s === 'warn';
+ });
+ const other = quality.length - errors.length - warnings.length;
+
+ if (errors.length > 0) {
+ const sample = errors
+ .slice(0, 3)
+ .map((e) => (e.path ? `${e.code} (${e.path})` : e.code))
+ .join(', ');
+ const more = errors.length > 3 ? ` (+${errors.length - 3} more)` : '';
+ void vscode.window.showErrorMessage(
+ `Creer quality gates reported ${errors.length} error(s)` +
+ (warnings.length ? `, ${warnings.length} warning(s)` : '') +
+ `: ${sample}${more}`
+ );
+ return;
+ }
+
+ const parts: string[] = [];
+ if (warnings.length) {
+ parts.push(`${warnings.length} warning(s)`);
+ }
+ if (other > 0) {
+ parts.push(`${other} other issue(s)`);
+ }
+ void vscode.window.showWarningMessage(
+ `Creer quality gates: ${parts.join(', ') || `${quality.length} issue(s)`}.`
+ );
+}
+
+function sourceToIds(source: SourcePick): {
+ templateId?: string;
+ packId?: string;
+} {
+ if (source.kind === 'template') {
+ return { templateId: source.id };
+ }
+ if (source.kind === 'pack') {
+ return { packId: source.id };
+ }
+ return {};
+}
+
+async function generateWithOptionalStream(
+ idea: string,
+ plan: PlanResponse,
+ source: SourcePick,
+ useStreaming: boolean,
+ bakeins: BakeinOptions
+): Promise {
+ const { templateId, packId } = sourceToIds(source);
+ const resolvedTemplateId = plan.template_id ?? templateId;
+ const resolvedPackId = plan.pack_id ?? packId;
+
+ // pack_id and template_id are mutually exclusive
+ const genOpts = {
+ templateId: resolvedPackId ? undefined : resolvedTemplateId,
+ packId: resolvedPackId,
+ plan,
+ bakeins,
+ };
+
+ if (!useStreaming) {
+ return vscode.window.withProgress(
+ {
+ location: vscode.ProgressLocation.Notification,
+ title: 'Creer: generating project…',
+ cancellable: false,
+ },
+ () => postGenerate(idea, genOpts)
+ );
+ }
+
+ try {
+ return await vscode.window.withProgress(
+ {
+ location: vscode.ProgressLocation.Notification,
+ title: 'Creer: generating project…',
+ cancellable: true,
+ },
+ async (progress, cancellationToken) => {
+ const controller = new AbortController();
+ const sub = cancellationToken.onCancellationRequested(() => {
+ controller.abort();
+ });
+ try {
+ return await streamGenerate({
+ idea,
+ templateId: genOpts.templateId,
+ packId: genOpts.packId,
+ plan: genOpts.plan,
+ bakeins: genOpts.bakeins,
+ signal: controller.signal,
+ onProgress: (ev) => reportStreamProgress(progress, ev),
+ });
+ } finally {
+ sub.dispose();
+ }
+ }
+ );
+ } catch (err) {
+ if (isCancellationError(err)) {
+ throw err;
+ }
+ const message = formatAxiosError(err, 'Streaming generate failed');
+ vscode.window.showWarningMessage(
+ `Streaming failed (${message}); falling back to non-streaming generate.`
+ );
+ return vscode.window.withProgress(
+ {
+ location: vscode.ProgressLocation.Notification,
+ title: 'Creer: generating project (fallback)…',
+ cancellable: false,
+ },
+ () => postGenerate(idea, genOpts)
+ );
+ }
+}
+
+/**
+ * Shared scaffold flow used by createRepo, createRepoFromChat, and the chat participant.
+ */
+export async function runScaffoldFlow(options: ScaffoldOptions): Promise {
+ const { context } = options;
+ const fromChat = options.fromChat ?? false;
+ const idea = await promptForIdea(fromChat, options.idea);
+ if (!idea) {
+ return;
+ }
+
+ const rootPath = await pickWorkspaceRoot();
+ if (!rootPath) {
+ vscode.window.showErrorMessage('Open a workspace folder first.');
+ return;
+ }
+
+ const config = vscode.workspace.getConfiguration('creer');
+ const shouldInitGit = config.get('initGit') ?? true;
+ const previewBeforeWrite = config.get('previewBeforeWrite') ?? true;
+ const useStreaming = config.get('useStreaming') ?? true;
+
+ try {
+ // 1) Templates + packs
+ let templates: Template[] = [];
+ let packs: Pack[] = [];
+
+ try {
+ templates = await vscode.window.withProgress(
+ {
+ location: vscode.ProgressLocation.Notification,
+ title: 'Creer: loading templates…',
+ cancellable: false,
+ },
+ () => fetchTemplates()
+ );
+ } catch (err) {
+ const message = formatAxiosError(err, 'Failed to load templates');
+ vscode.window.showWarningMessage(
+ `Could not load templates (${message}). Continuing with AI plan only.`
+ );
+ }
+
+ try {
+ packs = await vscode.window.withProgress(
+ {
+ location: vscode.ProgressLocation.Notification,
+ title: 'Creer: loading packs…',
+ cancellable: false,
+ },
+ () => fetchPacks()
+ );
+ } catch {
+ // Backend /packs may not be ready yet — show templates only.
+ packs = [];
+ }
+
+ const source = await pickSource(templates, packs);
+ if (!source) {
+ return;
+ }
+ const { templateId, packId } = sourceToIds(source);
+
+ // 2) Plan
+ const plan: PlanResponse = await vscode.window.withProgress(
+ {
+ location: vscode.ProgressLocation.Notification,
+ title: 'Creer: planning project…',
+ cancellable: false,
+ },
+ () => postPlan(idea, { templateId, packId })
+ );
+
+ if (!plan.project_name || !Array.isArray(plan.files)) {
+ vscode.window.showErrorMessage('Creer backend returned an invalid plan.');
+ return;
+ }
+
+ if (templateId && !plan.template_id) {
+ plan.template_id = templateId;
+ }
+ if (packId && !plan.pack_id) {
+ plan.pack_id = packId;
+ }
+
+ // 3) Plan preview / confirm (tree) before generate
+ if (previewBeforeWrite) {
+ const confirmed = await showPlanPreviewAndConfirm(plan, idea);
+ if (!confirmed) {
+ return;
+ }
+ }
+
+ // 4) Bake-ins (license / CI)
+ const bakeins = await resolveBakeins();
+ if (!bakeins) {
+ return;
+ }
+
+ // 5) Generate (streaming with cancellable progress when enabled)
+ let generated: GenerateResponse;
+ try {
+ generated = await generateWithOptionalStream(
+ idea,
+ plan,
+ source,
+ useStreaming,
+ bakeins
+ );
+ } catch (err) {
+ if (isCancellationError(err)) {
+ void vscode.window.showInformationMessage('Creer generation cancelled.');
+ return;
+ }
+ throw err;
+ }
+
+ reportQualityIssues(generated.quality);
+
+ const projectName = generated.project_name || plan.project_name;
+ const files = generated.files;
+
+ if (!projectName || !files || typeof files !== 'object') {
+ vscode.window.showErrorMessage('Creer backend returned an invalid generate response.');
+ return;
+ }
+
+ try {
+ assertSafeProjectName(projectName);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ vscode.window.showErrorMessage(`Creer refused unsafe project name: ${message}`);
+ return;
+ }
+
+ const projectPath = path.join(rootPath, projectName);
+ // Ensure the resolved project folder stays under the workspace root
+ const resolvedRoot = path.resolve(rootPath);
+ const resolvedProject = path.resolve(projectPath);
+ const rootPrefix = resolvedRoot.endsWith(path.sep)
+ ? resolvedRoot
+ : resolvedRoot + path.sep;
+ if (resolvedProject !== resolvedRoot && !resolvedProject.startsWith(rootPrefix)) {
+ vscode.window.showErrorMessage(
+ `Creer refused project path outside workspace: ${projectName}`
+ );
+ return;
+ }
+
+ // 6) Content preview / diff before write
+ const contentConfirmed = await showContentPreviewAndConfirm(
+ projectName,
+ projectPath,
+ files
+ );
+ if (!contentConfirmed) {
+ return;
+ }
+
+ // 7) Conflict resolution (optional side-by-side diffs)
+ const conflicts = findConflicts(projectPath, files);
+ const resolution = await resolveConflictsWithDiffs(projectPath, files, conflicts);
+ if (resolution === 'cancel') {
+ return;
+ }
+
+ // 8) Write
+ const { written, skipped } = writeProjectFiles(projectPath, files, resolution);
+ if (written === 0 && skipped === 0) {
+ vscode.window.showWarningMessage('No files were written.');
+ return;
+ }
+
+ // 9) Git init
+ if (shouldInitGit) {
+ try {
+ await initGit(projectPath);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ vscode.window.showWarningMessage(`Project created, but git init failed: ${message}`);
+ }
+ }
+
+ // 10) GitHub remote (optional)
+ await maybeCreateGitHubRemote(context, projectPath, projectName, idea);
+
+ const skipNote = skipped > 0 ? ` (${skipped} existing skipped)` : '';
+ vscode.window.showInformationMessage(
+ `Project ${projectName} created at ${projectPath}${skipNote}.`
+ );
+ } catch (err) {
+ if (isCancellationError(err)) {
+ void vscode.window.showInformationMessage('Creer generation cancelled.');
+ return;
+ }
+ const message = formatAxiosError(err, 'Creer failed');
+ vscode.window.showErrorMessage(`Creer failed: ${message}`);
+ }
+}
diff --git a/extension/src/secrets.ts b/extension/src/secrets.ts
new file mode 100644
index 0000000..a1341fd
--- /dev/null
+++ b/extension/src/secrets.ts
@@ -0,0 +1,91 @@
+import * as vscode from 'vscode';
+
+/** SecretStorage key for the GitHub personal access token. */
+export const GITHUB_TOKEN_SECRET_KEY = 'creer.githubToken';
+
+/**
+ * Read GitHub token: SecretStorage first, then deprecated config fallback.
+ */
+export async function getGitHubToken(
+ context: vscode.ExtensionContext
+): Promise {
+ const secret = (await context.secrets.get(GITHUB_TOKEN_SECRET_KEY))?.trim();
+ if (secret) {
+ return secret;
+ }
+
+ // Migration path: plaintext setting (deprecated)
+ const fromConfig = (
+ vscode.workspace.getConfiguration('creer').get('githubToken') || ''
+ ).trim();
+ return fromConfig || undefined;
+}
+
+export async function setGitHubToken(
+ context: vscode.ExtensionContext,
+ token: string
+): Promise {
+ await context.secrets.store(GITHUB_TOKEN_SECRET_KEY, token.trim());
+}
+
+export async function clearGitHubToken(context: vscode.ExtensionContext): Promise {
+ await context.secrets.delete(GITHUB_TOKEN_SECRET_KEY);
+}
+
+/**
+ * Prompt for a token and optionally persist it in SecretStorage.
+ */
+export async function promptAndStoreGitHubToken(
+ context: vscode.ExtensionContext
+): Promise {
+ const token = await vscode.window.showInputBox({
+ prompt: 'GitHub personal access token (repo scope)',
+ placeHolder: 'ghp_…',
+ password: true,
+ ignoreFocusOut: true,
+ });
+
+ const trimmed = token?.trim();
+ if (!trimmed) {
+ return undefined;
+ }
+
+ const saveChoice = await vscode.window.showQuickPick(
+ [
+ {
+ label: 'Save to SecretStorage (recommended)',
+ description: 'Stored securely; preferred over settings.json',
+ id: 'save',
+ },
+ {
+ label: 'Use once (do not save)',
+ description: 'Token is only used for this operation',
+ id: 'once',
+ },
+ ],
+ {
+ placeHolder: 'Save GitHub token?',
+ ignoreFocusOut: true,
+ }
+ );
+
+ if (saveChoice?.id === 'save') {
+ await setGitHubToken(context, trimmed);
+ void vscode.window.showInformationMessage('Creer: GitHub token saved to SecretStorage.');
+ }
+
+ return trimmed;
+}
+
+/**
+ * Resolve token: SecretStorage → config fallback → prompt (offer save).
+ */
+export async function resolveGitHubToken(
+ context: vscode.ExtensionContext
+): Promise {
+ const existing = await getGitHubToken(context);
+ if (existing) {
+ return existing;
+ }
+ return promptAndStoreGitHubToken(context);
+}
diff --git a/extension/src/streamGenerate.ts b/extension/src/streamGenerate.ts
new file mode 100644
index 0000000..4517d8d
--- /dev/null
+++ b/extension/src/streamGenerate.ts
@@ -0,0 +1,382 @@
+import axios from 'axios';
+import * as http from 'http';
+import * as https from 'https';
+import { URL } from 'url';
+import * as vscode from 'vscode';
+import type { BakeinOptions, GenerateResponse, PlanResponse, QualityIssue } from './api';
+
+export type StreamStartEvent = {
+ event: 'start';
+ project_name: string;
+ total: number;
+ stack: string;
+ job_id?: string;
+};
+
+export type StreamFileEvent = {
+ event: 'file';
+ index: number;
+ total: number;
+ path: string;
+ status: 'generating' | 'done';
+ bytes?: number;
+};
+
+export type StreamDoneEvent = {
+ event: 'done';
+ project_name: string;
+ stack: string;
+ files: Record;
+ template_id?: string;
+ pack_id?: string;
+ quality?: QualityIssue[];
+};
+
+export type StreamErrorEvent = {
+ event: 'error';
+ detail: string;
+};
+
+export type StreamCancelledEvent = {
+ event: 'cancelled';
+ job_id: string;
+ detail: string;
+};
+
+export type StreamProgressEvent =
+ | StreamStartEvent
+ | StreamFileEvent
+ | StreamDoneEvent
+ | StreamErrorEvent
+ | StreamCancelledEvent;
+
+export interface StreamGenerateOptions {
+ idea: string;
+ templateId?: string;
+ packId?: string;
+ plan?: PlanResponse;
+ bakeins?: BakeinOptions;
+ jobId?: string;
+ signal?: AbortSignal;
+ onJobId?: (jobId: string) => void;
+ onProgress?: (event: StreamProgressEvent) => void;
+}
+
+/** Thrown when the user or AbortSignal cancels streaming generation. */
+export class CancelledError extends Error {
+ readonly jobId?: string;
+
+ constructor(message = 'Generation cancelled', jobId?: string) {
+ super(message);
+ this.name = 'AbortError';
+ this.jobId = jobId;
+ Object.setPrototypeOf(this, new.target.prototype);
+ }
+}
+
+export function isCancellationError(err: unknown): boolean {
+ if (!err || typeof err !== 'object') {
+ return false;
+ }
+ const e = err as { name?: string; code?: string };
+ return (
+ err instanceof CancelledError ||
+ e.name === 'AbortError' ||
+ e.name === 'CancelledError' ||
+ e.code === 'ERR_CANCELED'
+ );
+}
+
+function getBackendUrl(): string {
+ const config = vscode.workspace.getConfiguration('creer');
+ return (config.get('backendUrl') || 'http://localhost:8000').replace(/\/$/, '');
+}
+
+function parseSseChunk(
+ buffer: string,
+ onEvent: (data: StreamProgressEvent) => void
+): string {
+ // Normalize CRLF so event separators are always `\n\n` (proxies / Windows).
+ let remaining = buffer.replace(/\r\n/g, '\n');
+ for (;;) {
+ const sep = remaining.indexOf('\n\n');
+ if (sep === -1) {
+ break;
+ }
+ const rawEvent = remaining.slice(0, sep);
+ remaining = remaining.slice(sep + 2);
+
+ for (const line of rawEvent.split('\n')) {
+ const trimmed = line.trim();
+ if (!trimmed.startsWith('data:')) {
+ continue;
+ }
+ const payload = trimmed.slice(5).trim();
+ if (!payload || payload === '[DONE]') {
+ continue;
+ }
+ try {
+ const parsed = JSON.parse(payload) as StreamProgressEvent;
+ if (parsed && typeof parsed === 'object' && 'event' in parsed) {
+ onEvent(parsed);
+ }
+ } catch {
+ // Ignore malformed JSON fragments; wait for a complete event.
+ }
+ }
+ }
+ return remaining;
+}
+
+/**
+ * POST /generate/stream and parse SSE (`data: {...}\\n\\n`).
+ * Uses Node http/https so streaming works in the VS Code extension CommonJS host.
+ * Supports AbortSignal: posts /generate/cancel with job_id (when known) and destroys the request.
+ */
+export function streamGenerate(options: StreamGenerateOptions): Promise {
+ const backendUrl = getBackendUrl();
+ const url = new URL(`${backendUrl}/generate/stream`);
+ const body: {
+ idea: string;
+ template_id?: string;
+ pack_id?: string;
+ plan?: PlanResponse;
+ job_id?: string;
+ bakeins?: BakeinOptions;
+ } = { idea: options.idea };
+ if (options.packId) {
+ body.pack_id = options.packId;
+ } else if (options.templateId) {
+ body.template_id = options.templateId;
+ }
+ if (options.plan) {
+ body.plan = options.plan;
+ }
+ if (options.jobId) {
+ body.job_id = options.jobId;
+ }
+ if (options.bakeins) {
+ body.bakeins = options.bakeins;
+ }
+
+ const payload = JSON.stringify(body);
+ const lib = url.protocol === 'https:' ? https : http;
+
+ return new Promise((resolve, reject) => {
+ let settled = false;
+ let doneResult: GenerateResponse | undefined;
+ let buffer = '';
+ let jobId: string | undefined = options.jobId;
+ let cancelPosted = false;
+ let req: http.ClientRequest;
+
+ const fail = (err: Error) => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ cleanupAbort();
+ reject(err);
+ };
+
+ const succeed = (result: GenerateResponse) => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ cleanupAbort();
+ resolve(result);
+ };
+
+ const destroyRequest = () => {
+ try {
+ req.destroy();
+ } catch {
+ // ignore
+ }
+ };
+
+ const requestBackendCancel = () => {
+ if (cancelPosted || !jobId) {
+ return;
+ }
+ cancelPosted = true;
+ // Inline cancel call to avoid a circular import with api.ts.
+ void axios
+ .post(
+ `${backendUrl}/generate/cancel`,
+ { job_id: jobId },
+ { timeout: 30_000 }
+ )
+ .catch(() => {
+ // Best-effort; local abort still tears down the HTTP stream.
+ });
+ };
+
+ const abortNow = () => {
+ requestBackendCancel();
+ destroyRequest();
+ fail(new CancelledError('Generation cancelled', jobId));
+ };
+
+ const onAbort = () => {
+ abortNow();
+ };
+
+ const cleanupAbort = () => {
+ if (options.signal) {
+ options.signal.removeEventListener('abort', onAbort);
+ }
+ };
+
+ if (options.signal?.aborted) {
+ // No request yet — reject immediately (and cancel if we already have a job id).
+ requestBackendCancel();
+ fail(new CancelledError('Generation cancelled', jobId));
+ return;
+ }
+
+ const handleEvent = (ev: StreamProgressEvent) => {
+ options.onProgress?.(ev);
+
+ if (ev.event === 'start') {
+ if (ev.job_id) {
+ jobId = ev.job_id;
+ options.onJobId?.(ev.job_id);
+ // Race: user may have aborted between request start and start event.
+ if (options.signal?.aborted) {
+ abortNow();
+ }
+ }
+ return;
+ }
+
+ if (ev.event === 'cancelled') {
+ destroyRequest();
+ fail(
+ new CancelledError(
+ ev.detail || 'Generation cancelled',
+ ev.job_id || jobId
+ )
+ );
+ return;
+ }
+
+ if (ev.event === 'error') {
+ // Stop reading further events; destroy the socket so we do not hang.
+ destroyRequest();
+ fail(new Error(ev.detail || 'Streaming generation failed'));
+ return;
+ }
+
+ if (ev.event === 'done') {
+ doneResult = {
+ project_name: ev.project_name,
+ stack: ev.stack,
+ files: ev.files,
+ };
+ if (ev.template_id) {
+ doneResult.template_id = ev.template_id;
+ }
+ if (ev.pack_id) {
+ doneResult.pack_id = ev.pack_id;
+ }
+ if (ev.quality) {
+ doneResult.quality = ev.quality;
+ }
+ }
+ };
+
+ req = lib.request(
+ {
+ protocol: url.protocol,
+ hostname: url.hostname,
+ port: url.port || (url.protocol === 'https:' ? 443 : 80),
+ path: `${url.pathname}${url.search}`,
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'text/event-stream',
+ 'Content-Length': Buffer.byteLength(payload),
+ Connection: 'keep-alive',
+ },
+ timeout: 600_000,
+ },
+ (res) => {
+ const status = res.statusCode ?? 0;
+ if (status < 200 || status >= 300) {
+ const chunks: Buffer[] = [];
+ res.on('data', (c: Buffer) => chunks.push(c));
+ res.on('end', () => {
+ const text = Buffer.concat(chunks).toString('utf8');
+ let detail = text;
+ try {
+ const parsed = JSON.parse(text) as { detail?: string };
+ if (typeof parsed.detail === 'string') {
+ detail = parsed.detail;
+ }
+ } catch {
+ // keep raw text
+ }
+ fail(new Error(detail || `HTTP ${status} from /generate/stream`));
+ });
+ return;
+ }
+
+ res.setEncoding('utf8');
+ res.on('data', (chunk: string) => {
+ buffer = parseSseChunk(buffer + chunk, handleEvent);
+ });
+ res.on('end', () => {
+ if (buffer.trim()) {
+ parseSseChunk(buffer + '\n\n', handleEvent);
+ }
+ if (doneResult) {
+ succeed(doneResult);
+ } else if (!settled) {
+ fail(new Error('Stream ended without a done event'));
+ }
+ });
+ res.on('error', (err) => {
+ if (settled) {
+ return;
+ }
+ // Destroy after abort often surfaces as a socket error — treat as cancel.
+ if (options.signal?.aborted) {
+ fail(new CancelledError('Generation cancelled', jobId));
+ return;
+ }
+ fail(err instanceof Error ? err : new Error(String(err)));
+ });
+ }
+ );
+
+ req.on('timeout', () => {
+ req.destroy();
+ fail(new Error('Streaming generation timed out'));
+ });
+ req.on('error', (err) => {
+ if (settled) {
+ return;
+ }
+ if (options.signal?.aborted || isCancellationError(err)) {
+ fail(new CancelledError('Generation cancelled', jobId));
+ return;
+ }
+ // Node often emits Error with code after destroy(); ignore if we already aborted.
+ const code = (err as NodeJS.ErrnoException).code;
+ if (code === 'ECONNRESET' && cancelPosted) {
+ fail(new CancelledError('Generation cancelled', jobId));
+ return;
+ }
+ fail(err instanceof Error ? err : new Error(String(err)));
+ });
+
+ if (options.signal) {
+ options.signal.addEventListener('abort', onAbort, { once: true });
+ }
+
+ req.write(payload);
+ req.end();
+ });
+}
diff --git a/extension/src/workspace.ts b/extension/src/workspace.ts
new file mode 100644
index 0000000..618d399
--- /dev/null
+++ b/extension/src/workspace.ts
@@ -0,0 +1,44 @@
+import * as vscode from 'vscode';
+
+/**
+ * Resolve which workspace folder root to use for scaffolding.
+ * - Single folder → that folder's fsPath
+ * - Multiple → honor `creer.defaultWorkspaceFolder` if it matches name or fsPath;
+ * otherwise QuickPick by folder name / path
+ * Returns undefined if no folders are open or the user cancels the pick.
+ */
+export async function pickWorkspaceRoot(): Promise {
+ const folders = vscode.workspace.workspaceFolders;
+ if (!folders || folders.length === 0) {
+ return undefined;
+ }
+
+ if (folders.length === 1) {
+ return folders[0].uri.fsPath;
+ }
+
+ const config = vscode.workspace.getConfiguration('creer');
+ const hint = (config.get('defaultWorkspaceFolder') || '').trim();
+ if (hint) {
+ const matched = folders.find(
+ (f) => f.name === hint || f.uri.fsPath === hint || f.uri.fsPath.endsWith(hint)
+ );
+ if (matched) {
+ return matched.uri.fsPath;
+ }
+ }
+
+ const items: Array = folders.map((f) => ({
+ label: f.name,
+ description: f.uri.fsPath,
+ fsPath: f.uri.fsPath,
+ }));
+
+ const picked = await vscode.window.showQuickPick(items, {
+ placeHolder: 'Select a workspace folder for the new project',
+ ignoreFocusOut: true,
+ matchOnDescription: true,
+ });
+
+ return picked?.fsPath;
+}
diff --git a/extension/src/writeFiles.ts b/extension/src/writeFiles.ts
new file mode 100644
index 0000000..af5f6c9
--- /dev/null
+++ b/extension/src/writeFiles.ts
@@ -0,0 +1,139 @@
+import * as fs from 'fs';
+import * as path from 'path';
+import * as vscode from 'vscode';
+
+export type ConflictResolution = 'overwrite' | 'skip' | 'cancel';
+
+/** Safe project folder name: alphanumeric start, then [a-zA-Z0-9._-], max 64. */
+const SAFE_PROJECT_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/;
+
+/**
+ * Validate project_name before joining under the workspace root.
+ * Rejects path separators and `..` so the folder cannot escape the workspace.
+ */
+export function assertSafeProjectName(name: string): void {
+ if (typeof name !== 'string' || !name) {
+ throw new Error(`Invalid project name: ${JSON.stringify(name)}`);
+ }
+ if (name.includes('\0') || name.includes('/') || name.includes('\\')) {
+ throw new Error(`Unsafe project name: ${JSON.stringify(name)}`);
+ }
+ // Reject `.` / `..` as the whole name (path-like); substring `foo..bar` is allowed by backend SAFE_NAME
+ if (name === '.' || name === '..') {
+ throw new Error(`Unsafe project name: ${JSON.stringify(name)}`);
+ }
+ if (!SAFE_PROJECT_NAME.test(name)) {
+ throw new Error(
+ `Invalid project name (use 1–64 chars, alphanumeric start, [a-zA-Z0-9._-]): ${JSON.stringify(name)}`
+ );
+ }
+}
+
+/**
+ * Resolve a relative path under projectPath and ensure it cannot escape the project root.
+ * Rejects absolute paths, null bytes, and `..` traversal (defense in depth vs backend).
+ */
+export function resolveSafeProjectPath(projectPath: string, relativePath: string): string {
+ if (typeof relativePath !== 'string' || !relativePath) {
+ throw new Error(`Invalid file path: ${JSON.stringify(relativePath)}`);
+ }
+ if (relativePath.includes('\0')) {
+ throw new Error(`Unsafe file path (null byte): ${JSON.stringify(relativePath)}`);
+ }
+ if (path.isAbsolute(relativePath)) {
+ throw new Error(`Absolute file paths are not allowed: ${JSON.stringify(relativePath)}`);
+ }
+ // Windows drive / UNC when running on win32; also reject drive-like prefixes on any OS
+ if (/^[a-zA-Z]:/.test(relativePath) || relativePath.startsWith('\\\\')) {
+ throw new Error(`Windows drive/UNC paths are not allowed: ${JSON.stringify(relativePath)}`);
+ }
+
+ const normalizedSep = relativePath.replace(/\\/g, '/');
+ const segments = normalizedSep.split('/');
+ if (segments.some((s) => s === '..')) {
+ throw new Error(`Path traversal ('..') is not allowed: ${JSON.stringify(relativePath)}`);
+ }
+ if (segments.some((s) => s === '')) {
+ // Leading/trailing/double slashes → empty segment
+ throw new Error(`Invalid file path: ${JSON.stringify(relativePath)}`);
+ }
+
+ const root = path.resolve(projectPath);
+ const fullPath = path.resolve(root, ...segments);
+ const prefix = root.endsWith(path.sep) ? root : root + path.sep;
+ if (fullPath !== root && !fullPath.startsWith(prefix)) {
+ throw new Error(`Path escapes project root: ${JSON.stringify(relativePath)}`);
+ }
+ return fullPath;
+}
+
+export function findConflicts(
+ projectPath: string,
+ files: Record
+): string[] {
+ const conflicts: string[] = [];
+ for (const relativePath of Object.keys(files)) {
+ const fullPath = resolveSafeProjectPath(projectPath, relativePath);
+ if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
+ conflicts.push(relativePath);
+ }
+ }
+ return conflicts.sort((a, b) => a.localeCompare(b));
+}
+
+export async function resolveConflicts(conflicts: string[]): Promise {
+ if (conflicts.length === 0) {
+ return 'overwrite';
+ }
+
+ const preview = conflicts.slice(0, 10);
+ const extra = conflicts.length > 10 ? `\n…and ${conflicts.length - 10} more` : '';
+ const list = preview.map((p) => `• ${p}`).join('\n');
+
+ const choice = await vscode.window.showWarningMessage(
+ `${conflicts.length} file(s) already exist under the project folder:\n${list}${extra}`,
+ { modal: true },
+ 'Overwrite all',
+ 'Skip existing',
+ 'Cancel'
+ );
+
+ if (choice === 'Overwrite all') {
+ return 'overwrite';
+ }
+ if (choice === 'Skip existing') {
+ return 'skip';
+ }
+ return 'cancel';
+}
+
+export function writeProjectFiles(
+ projectPath: string,
+ files: Record,
+ resolution: ConflictResolution
+): { written: number; skipped: number } {
+ if (resolution === 'cancel') {
+ return { written: 0, skipped: 0 };
+ }
+
+ fs.mkdirSync(projectPath, { recursive: true });
+
+ let written = 0;
+ let skipped = 0;
+
+ for (const relativePath of Object.keys(files)) {
+ const fullPath = resolveSafeProjectPath(projectPath, relativePath);
+ const exists = fs.existsSync(fullPath) && fs.statSync(fullPath).isFile();
+
+ if (exists && resolution === 'skip') {
+ skipped += 1;
+ continue;
+ }
+
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
+ fs.writeFileSync(fullPath, files[relativePath], 'utf8');
+ written += 1;
+ }
+
+ return { written, skipped };
+}
diff --git a/extension/tsconfig.json b/extension/tsconfig.json
new file mode 100644
index 0000000..80604a6
--- /dev/null
+++ b/extension/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "module": "commonjs",
+ "target": "ES2022",
+ "lib": ["ES2022"],
+ "outDir": "out",
+ "rootDir": "src",
+ "sourceMap": true,
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true
+ },
+ "include": ["src"],
+ "exclude": ["node_modules", "out"]
+}