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..0b64265
--- /dev/null
+++ b/PLAN.md
@@ -0,0 +1,38 @@
+# Creer — Final Plan
+
+v0.1 delivered the foundation: FastAPI planner/generator + VS Code command that writes a generated repo into the workspace (optional git init).
+
+## v0.2 — done
+
+1. **Preview before writing** — plan preview markdown + confirm before generate/write (`creer.previewBeforeWrite`).
+2. **GitHub repo creation** — `POST /github/create-repo` + extension remote add/push.
+3. **Curated templates** — `GET /templates` + template-anchored `/plan` & `/generate`.
+4. **Overwrite protection** — per-file conflict detection with overwrite / skip / cancel.
+5. **Chat command `/creer`** — `creer.createRepoFromChat` + `@creer` chat participant.
+
+## v0.3 — done
+
+1. **Streaming generation** — `POST /generate/stream` (SSE) + extension progress UI.
+2. **Local / offline backends** — `OPENAI_BASE_URL`, `CREER_OFFLINE`.
+3. **Open-source bake-ins** — LICENSE / README / CI via `bakeins.py`.
+4. **Hardening** — `GIT_ASKPASS` + SecretStorage for GitHub tokens.
+
+## v0.4 — done
+
+1. **Cancellation** — `job_id` on stream + `POST /generate/cancel`; extension AbortSignal + cancellable progress.
+2. **Selectable bake-ins** — license (`mit` / `apache-2.0` / `none`) and CI presets (`auto` / `python` / `node` / `none`); `GET /bakeins`.
+3. **Quality gates** — telemetry-free tree checks (`quality` on generate/done; `POST /quality`).
+
+## v0.5 (optional next)
+
+- Diff preview of generated file contents before write
+- Multi-root workspace targeting
+- Template packs as installable JSON/YAML
+
+## 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..7d92e59
--- /dev/null
+++ b/README.md
@@ -0,0 +1,111 @@
+# Creer
+
+AI-powered repo scaffolding inside your workspace.
+
+**Current version: 0.5.0**
+
+## Architecture
+
+```
+creer/
+├── backend/ # Python FastAPI AI engine (+ packs/)
+└── extension/ # VS Code extension
+```
+
+## Prerequisites
+
+- Python 3.10+
+- Node.js 18+
+- OpenAI API key **or** `OPENAI_BASE_URL` **or** `CREER_OFFLINE=1` (with template/pack)
+- VS Code / Cursor
+
+## Backend (v0.5)
+
+```bash
+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
+```
+
+### Environment
+
+| Variable | Description |
+|---|---|
+| `OPENAI_API_KEY` | OpenAI API key |
+| `OPENAI_BASE_URL` | OpenAI-compatible base URL (Ollama, etc.) |
+| `CREER_MODEL` | Model name (default `gpt-4o-mini`) |
+| `CREER_OFFLINE` | Template/pack-only stubs |
+| `CREER_PACKS_DIR` | Extra packs directory (overrides same ids) |
+
+### Endpoints
+
+| Method | Path | Description |
+|---|---|---|
+| `GET` | `/health` | Version `0.5.0`, packs_count |
+| `GET` | `/templates` | Built-in templates |
+| `GET` | `/packs` | Installable JSON/YAML packs |
+| `GET` | `/packs/{id}` | Single pack |
+| `GET` | `/bakeins` | License/CI options |
+| `POST` | `/plan` | Plan (`template_id` **or** `pack_id`) |
+| `POST` | `/generate` | Generate + bake-ins + quality |
+| `POST` | `/generate/stream` | SSE progress (`job_id`) |
+| `POST` | `/generate/cancel` | Cancel job |
+| `POST` | `/quality` | Dry-run gates |
+| `POST` | `/github/create-repo` | Create GitHub repo |
+
+### Packs
+
+Drop `.json` / `.yaml` files into `backend/packs/` (or `CREER_PACKS_DIR`):
+
+```json
+{
+ "id": "fastapi-crud",
+ "name": "FastAPI CRUD",
+ "description": "CRUD API starter",
+ "stack": "FastAPI + Uvicorn",
+ "version": "1.0.0",
+ "files": ["main.py", "requirements.txt", "README.md"]
+}
+```
+
+Shipped examples: `fastapi-crud`, `express-ts`, `python-lib`.
+
+## Extension (v0.5)
+
+```bash
+cd extension && npm install && npm run compile
+```
+
+Flow: idea → template/pack → bake-ins → plan preview → generate (cancellable) → **content diff preview** → write → optional git/GitHub.
+
+Multi-root: QuickPick workspace folder (or `creer.defaultWorkspaceFolder`).
+
+### Commands
+
+| Command | Title |
+|---|---|
+| `creer.createRepo` | Creer: Create New Repo |
+| `creer.createRepoFromChat` | Creer: Create from Chat Prompt |
+| `creer.setGitHubToken` | Creer: Set GitHub Token |
+| `creer.clearGitHubToken` | Creer: Clear GitHub Token |
+
+### Settings
+
+| Setting | Default | Description |
+|---|---|---|
+| `creer.backendUrl` | `http://localhost:8000` | Backend URL |
+| `creer.contentPreview` | `true` | Diff/content preview before write |
+| `creer.defaultWorkspaceFolder` | `""` | Multi-root folder name/path hint |
+| `creer.previewBeforeWrite` | `true` | Plan tree confirm before generate |
+| `creer.useStreaming` | `true` | SSE progress |
+| `creer.promptBakeins` | `true` | QuickPick license/CI |
+| `creer.license` / `creer.ciPreset` | `mit` / `auto` | Defaults when not prompting |
+| `creer.initGit` | `true` | git init + commit |
+| `creer.createGitHubRepo` | `false` | Create remote |
+| `creer.githubPrivate` | `true` | Private repos |
+
+## License
+
+MIT
diff --git a/backend/.env.example b/backend/.env.example
new file mode 100644
index 0000000..bb3c577
--- /dev/null
+++ b/backend/.env.example
@@ -0,0 +1,17 @@
+# 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.
+# CREER_PACKS_DIR=/path/to/my-packs
diff --git a/backend/.gitignore b/backend/.gitignore
new file mode 100644
index 0000000..7d49b78
--- /dev/null
+++ b/backend/.gitignore
@@ -0,0 +1,6 @@
+.env
+venv/
+__pycache__/
+*.pyc
+.pytest_cache/
+.mypy_cache/
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/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..f3a06c8
--- /dev/null
+++ b/backend/app/packs.py
@@ -0,0 +1,179 @@
+"""Installable template packs (JSON/YAML) for Creer v0.5."""
+
+from __future__ import annotations
+
+import json
+import os
+import re
+from pathlib import Path
+
+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"
+
+_PACK_FILE_SUFFIXES = (".json", ".yaml", ".yml")
+_SAFE_ID = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$")
+
+
+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 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()
+ try:
+ text = path.read_text(encoding="utf-8")
+ except OSError as exc:
+ raise ValueError(f"Cannot read pack file {path}: {exc}") from exc
+
+ if suffix == ".json":
+ try:
+ data = json.loads(text)
+ except json.JSONDecodeError as exc:
+ raise ValueError(f"Invalid JSON in pack {path}: {exc}") from exc
+ elif suffix in (".yaml", ".yml"):
+ try:
+ data = yaml.safe_load(text)
+ except yaml.YAMLError as exc:
+ raise ValueError(f"Invalid YAML in pack {path}: {exc}") from exc
+ else:
+ raise ValueError(f"Unsupported pack file type: {path.suffix!r}")
+
+ if not isinstance(data, dict):
+ raise ValueError(f"Pack must be a mapping/object: {path}")
+
+ 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: {path}")
+ pack_id = pack_id.strip()
+ if not _SAFE_ID.match(pack_id):
+ raise ValueError(f"Invalid pack id {pack_id!r} in {path}")
+
+ if not isinstance(name, str) or not name.strip():
+ raise ValueError(f"Pack missing valid name: {path}")
+
+ if not isinstance(files, list) or not files:
+ raise ValueError(f"Pack must include a non-empty files list: {path}")
+
+ 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 {path}: {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 {path}: {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: {path}")
+
+ stack = data.get("stack", "")
+ if stack is not None and not isinstance(stack, str):
+ raise ValueError(f"Pack stack must be a string: {path}")
+
+ description = data.get("description", "")
+ if description is not None and not isinstance(description, str):
+ raise ValueError(f"Pack description must be a string: {path}")
+
+ 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 _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 with optional CREER_PACKS_DIR.
+
+ User packs override built-in packs on id collision.
+ """
+ packs = _load_packs_from_dir(PACKS_DIR)
+ extra = _extra_packs_dir()
+ if extra is not None:
+ packs.update(_load_packs_from_dir(extra))
+ return packs
+
+
+def list_packs() -> list[dict]:
+ """Return all available packs (built-in + 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 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/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..7845039
--- /dev/null
+++ b/backend/config.py
@@ -0,0 +1,11 @@
+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
diff --git a/backend/main.py b/backend/main.py
new file mode 100644
index 0000000..8b98684
--- /dev/null
+++ b/backend/main.py
@@ -0,0 +1,384 @@
+"""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 fastapi import FastAPI, Header, HTTPException
+from fastapi.responses import StreamingResponse
+from pydantic import BaseModel, Field
+
+from config import CREER_OFFLINE, 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 list_packs, get_pack
+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.5.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
+
+
+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()),
+ }
+
+
+@app.get("/templates")
+def templates():
+ return {"templates": list_templates()}
+
+
+@app.get("/packs")
+def packs():
+ return {"packs": list_packs()}
+
+
+@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.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/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..8726dde
--- /dev/null
+++ b/backend/requirements.txt
@@ -0,0 +1,7 @@
+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
diff --git a/extension/.gitignore b/extension/.gitignore
new file mode 100644
index 0000000..e6b3087
--- /dev/null
+++ b/extension/.gitignore
@@ -0,0 +1,4 @@
+node_modules/
+out/
+*.vsix
+.vscode-test/
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/package-lock.json b/extension/package-lock.json
new file mode 100644
index 0000000..54caa3e
--- /dev/null
+++ b/extension/package-lock.json
@@ -0,0 +1,392 @@
+{
+ "name": "creer",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "creer",
+ "version": "0.1.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..0ffed8a
--- /dev/null
+++ b/extension/package.json
@@ -0,0 +1,127 @@
+{
+ "name": "creer",
+ "displayName": "Creer",
+ "description": "AI-powered repo scaffolding inside your workspace",
+ "version": "0.5.0",
+ "publisher": "creer",
+ "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"
+ }
+ ],
+ "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.defaultWorkspaceFolder": {
+ "type": "string",
+ "default": "",
+ "description": "Optional workspace folder name or path hint for multi-root workspaces; empty prompts when multiple folders are open"
+ }
+ }
+ }
+ },
+ "scripts": {
+ "vscode:prepublish": "npm run compile",
+ "compile": "tsc -p ./",
+ "watch": "tsc -watch -p ./",
+ "lint": "tsc --noEmit -p ./"
+ },
+ "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..71f8c93
--- /dev/null
+++ b/extension/src/api.ts
@@ -0,0 +1,221 @@
+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 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 ?? [];
+}
+
+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/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..abac31a
--- /dev/null
+++ b/extension/src/extension.ts
@@ -0,0 +1,80 @@
+import * as vscode from 'vscode';
+import { runScaffoldFlow } from './scaffold';
+import { clearGitHubToken, setGitHubToken } from './secrets';
+
+export function activate(context: vscode.ExtensionContext) {
+ 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.');
+ });
+
+ context.subscriptions.push(createRepo, createRepoFromChat, setToken, clearToken);
+ 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/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/scaffold.ts b/extension/src/scaffold.ts
new file mode 100644
index 0000000..f267d79
--- /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 { pickWorkspaceRoot } from './workspace';
+import {
+ assertSafeProjectName,
+ findConflicts,
+ resolveConflicts,
+ 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
+ const conflicts = findConflicts(projectPath, files);
+ const resolution = await resolveConflicts(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"]
+}