CascadingDev/src/cascadingdev/setup_project.py

460 lines
16 KiB
Python

#!/usr/bin/env python3
"""
setup_project.py — Installer-mode bootstrap for Cascading Development
Run this from the **installer bundle folder** (e.g., install/cascadingdev-<version>/), NOT inside the destination repo:
- Prompts (or use --target) for the destination repo path
- Copies essential files from installer → target (ramble.py, templates, hooks)
- Creates canonical structure, seeds rules/templates
- Initializes git and installs pre-commit hook
- Launches Ramble to capture the first feature request
Examples:
python setup_cascadingdev.py --target ~/dev/my-new-repo
python setup_cascadingdev.py --target /abs/path --no-ramble
"""
import json, re
import argparse
import datetime
import sys
import subprocess
import shutil
from pathlib import Path
# Bundle root (must contain assets/, ramble.py, VERSION)
INSTALL_ROOT = Path(__file__).resolve().parent
if not (INSTALL_ROOT / "assets").exists():
print("[-] This script must be run from the installer bundle directory (assets/ missing).")
print(
" Rebuild the bundle (e.g., `python tools/build_installer.py`) and run the copy in install/cascadingdev-*/.")
sys.exit(2)
# ---------- Helper Functions ----------
def say(msg: str) -> None:
print(msg, flush=True)
def ensure_dir(p: Path) -> None:
p.mkdir(parents=True, exist_ok=True)
def write_if_missing(path: Path, content: str) -> None:
ensure_dir(path.parent)
if not path.exists():
path.write_text(content, encoding="utf-8")
def copy_if_exists(src: Path, dst: Path) -> None:
if src.exists():
ensure_dir(dst.parent)
shutil.copy2(src, dst)
def copy_if_missing(src: Path, dst: Path) -> None:
ensure_dir(dst.parent)
if not dst.exists():
shutil.copy2(src, dst)
def run(cmd: list[str], cwd: Path | None = None) -> int:
proc = subprocess.Popen(cmd, cwd=cwd, stdout=sys.stdout, stderr=sys.stderr)
return proc.wait()
# --- Tiny template helpers ----------------------------------------------------
# Self-contained; no external dependencies
_META_RE = re.compile(r"<!--META\s*(\{.*?\})\s*-->", re.S)
def load_template_with_meta(path: Path) -> tuple[dict, str]:
"""
Returns (meta: dict, body_without_meta: str). If no META, ({} , full text).
META must be a single JSON object inside <!--META ... -->.
"""
if not path.exists():
return {}, ""
text = path.read_text(encoding="utf-8")
m = _META_RE.search(text)
if not m:
return {}, text
meta_json = m.group(1)
try:
meta = json.loads(meta_json)
except Exception:
meta = {}
body = _META_RE.sub("", text, count=1).lstrip()
return meta, body
def render_placeholders(body: str, values: dict) -> str:
"""
Simple {Token} replacement. Leaves unknown tokens as-is.
"""
# two-pass: {{Token}} then {Token}
out = body
for k, v in values.items():
out = out.replace("{{" + k + "}}", str(v))
try:
out = out.format_map({k: v for k, v in values.items()})
except Exception:
pass
return out
def meta_ramble_config(meta: dict) -> tuple[list[str], dict, dict, list[str]]:
"""
From template META, extract:
- fields: list of field names in order
- defaults: {field: default_value}
- criteria: {field: rule/description} (optional)
- hints: [str, ...] (optional)
"""
fields: list[str] = []
defaults: dict = {}
for spec in meta.get("ramble_fields", []):
name = spec.get("name")
if name:
fields.append(name)
if "default" in spec:
defaults[name] = spec["default"]
criteria = meta.get("criteria", {}) or {}
hints = meta.get("hints", []) or []
return fields, defaults, criteria, hints
def ensure_git_repo(target: Path):
"""Initialize a git repository if one doesn't exist at the target path."""
if not (target / ".git").exists():
# Initialize git repo with main branch
run(["git", "init", "-b", "main"], cwd=target)
# Seed .gitignore from template if present; otherwise fallback
tmpl_gitignore = INSTALL_ROOT / "assets" / "templates" / "root_gitignore"
if tmpl_gitignore.exists():
copy_if_missing(tmpl_gitignore, target / ".gitignore")
else:
write_if_missing(target / ".gitignore", "\n".join([
"__pycache__/", "*.py[cod]", "*.egg-info/", ".pytest_cache/",
".mypy_cache/", ".coverage", "htmlcov/", "node_modules/",
"dist/", "build/", ".env", ".env.*", "secrets/", ".DS_Store",
".git/ai-rules-*",
]) + "\n")
def install_precommit_hook(target: Path):
"""Install the pre-commit hook from installer assets to target git hooks."""
hook_src = INSTALL_ROOT / "assets" / "hooks" / "pre-commit"
hooks_dir = target / ".git" / "hooks"
hooks_dir.mkdir(parents=True, exist_ok=True)
hook_dst = hooks_dir / "pre-commit"
if not hook_src.exists():
say("[-] pre-commit hook source missing at assets/hooks/pre-commit in the installer bundle.")
return
# Copy hook content and make it executable
hook_dst.write_text(hook_src.read_text(encoding="utf-8"), encoding="utf-8")
hook_dst.chmod(0o755)
say(f"[+] Installed git hook → {hook_dst}")
def run_ramble_and_collect(target: Path, provider: str = "mock", claude_cmd: str = "claude"):
"""
Launch Ramble GUI to collect initial feature request details.
Falls back to terminal prompts if GUI fails or returns invalid JSON.
"""
# Find FR template + read META (for field names)
fr_tmpl = INSTALL_ROOT / "assets" / "templates" / "feature_request.md"
meta, _ = load_template_with_meta(fr_tmpl)
field_names, _defaults, criteria, hints = meta_ramble_config(meta)
# Fallback to your previous default fields if template lacks META
if not field_names:
field_names = ["Summary", "Title", "Intent", "ProblemItSolves", "BriefOverview"]
ramble = target / "ramble.py"
if not ramble.exists():
say("[-] ramble.py not found in target; skipping interactive FR capture.")
return None
# Build Ramble arguments dynamically from the template-defined fields
args = [
sys.executable, str(ramble),
"--provider", provider,
"--claude-cmd", claude_cmd,
"--prompt", "Describe your initial feature request for this repository",
"--fields", *field_names,
]
if criteria:
args += ["--criteria", json.dumps(criteria)]
if hints:
args += ["--hints", json.dumps(hints)]
say("[•] Launching Ramble (close the dialog with Submit to return JSON)…")
proc = subprocess.run(args, text=True, capture_output=True, cwd=str(target))
# Show any stderr output from Ramble
if proc.stderr and proc.stderr.strip():
say("[Ramble stderr]")
say(proc.stderr.strip())
# Try to parse JSON output from Ramble
out = (proc.stdout or "").strip()
if out:
try:
return json.loads(out)
except Exception as e:
say(f"[-] JSON parse failed: {e}")
# Terminal fallback - collect input manually if GUI fails
say("[!] Falling back to terminal prompts.")
def ask(label, default=""):
try:
v = input(f"{label}: ").strip()
return v or default
except EOFError:
return default
# Collect required fields via terminal input
fields = {
"Title": ask("Title (camelCase, <=24 chars)", "initialProjectDesign"),
"Intent": ask("Intent", ""),
"ProblemItSolves": ask("Problem it solves", ""),
"BriefOverview": ask("Brief overview", ""),
"Summary": ask("One- or two-sentence summary", ""),
}
return {"fields": fields, "summary": fields.get("Summary", "")}
def seed_process_and_rules(target: Path):
"""Seed machine-readable policies and stage rules by copying installer templates."""
# Seed process/policies.yml (machine-readable), per DESIGN.md Appendix A
process_dir = target / "process"
rules_dir = target / "Docs" / "features"
process_dir.mkdir(parents=True, exist_ok=True)
rules_dir.mkdir(parents=True, exist_ok=True)
# Locate templates in THIS installer bundle
t_root = INSTALL_ROOT / "assets" / "templates"
t_process = t_root / "process" / "policies.yml"
t_rules_root = t_root / "rules" / "root.ai-rules.yml"
t_rules_features = t_root / "rules" / "features.ai-rules.yml"
# Copy policies
if t_process.exists():
copy_if_missing(t_process, process_dir / "policies.yml")
# Copy rules files into expected locations
# Root rules (optional if you want a project-wide baseline)
if t_rules_root.exists():
copy_if_missing(t_rules_root, target / ".ai-rules.yml")
# Discussion/feature rules (cascade/override within Docs/features)
if t_rules_features.exists():
copy_if_missing(t_rules_features, rules_dir / ".ai-rules.yml")
def seed_initial_feature(target: Path, req_fields: dict | None):
today = datetime.date.today().isoformat()
feature_id = f"FR_{today}_initial-feature-request"
fr_dir = target / "Docs" / "features" / feature_id
disc_dir = fr_dir / "discussions"
disc_dir.mkdir(parents=True, exist_ok=True)
# Gather values from Ramble result (if any)
fields = (req_fields or {}).get("fields", {}) if req_fields else {}
# Load FR template + META
fr_tmpl = INSTALL_ROOT / "assets" / "templates" / "feature_request.md"
fr_meta, fr_body = load_template_with_meta(fr_tmpl)
field_names, defaults, _criteria, _hints = meta_ramble_config(fr_meta)
# Build values map with defaults → ramble fields → system tokens
values = {}
values.update(defaults) # template defaults
values.update(fields) # user-entered
values.update({ # system tokens
"FeatureId": feature_id,
"CreatedDate": today,
})
# If no template body, fall back to your old default
if not fr_body.strip():
title = values.get("Title", "initialProjectDesign")
intent = values.get("Intent", "")
problem = values.get("ProblemItSolves", "")
brief = values.get("BriefOverview", "")
summary = values.get("Summary", "")
fr_body = f"""# Feature Request: {title}
**Intent**: {intent}
**Motivation / Problem**: {problem}
**Brief Overview**: {brief}
**Summary**: {summary}
**Meta**: Created: {today}
"""
(fr_dir / "request.md").write_text(render_placeholders(fr_body, values), encoding="utf-8")
# --- feature.discussion.md ---
disc_tmpl = INSTALL_ROOT / "assets" / "templates" / "feature.discussion.md"
d_meta, d_body = load_template_with_meta(disc_tmpl)
# Always include the front-matter for rules, then template body (or fallback)
fm = f"""---\ntype: discussion\nstage: feature\nstatus: OPEN\nfeature_id: {feature_id}\ncreated: {today}\n---\n"""
if not d_body.strip():
d_body = (
"## Summary\n"
f"Initial discussion for {feature_id}. Append your comments below.\n\n"
"## Participation\n"
"- Maintainer: Kickoff. VOTE: READY\n"
)
(disc_dir / "feature.discussion.md").write_text(fm + render_placeholders(d_body, values), encoding="utf-8")
# --- feature.discussion.sum.md ---
sum_tmpl = INSTALL_ROOT / "assets" / "templates" / "feature.discussion.sum.md"
s_meta, s_body = load_template_with_meta(sum_tmpl)
if s_body.strip():
# use template
(disc_dir / "feature.discussion.sum.md").write_text(render_placeholders(s_body, values), encoding="utf-8")
else:
# your existing static content
(disc_dir / "feature.discussion.sum.md").write_text(
"""# Summary — Feature
<!-- SUMMARY:DECISIONS START -->
## Decisions (ADR-style)
- (none yet)
<!-- SUMMARY:DECISIONS END -->
<!-- SUMMARY:OPEN_QUESTIONS START -->
## Open Questions
- (none yet)
<!-- SUMMARY:OPEN_QUESTIONS END -->
<!-- SUMMARY:AWAITING START -->
## Awaiting Replies
- (none yet)
<!-- SUMMARY:AWAITING END -->
<!-- SUMMARY:ACTION_ITEMS START -->
## Action Items
- (none yet)
<!-- SUMMARY:ACTION_ITEMS END -->
<!-- SUMMARY:VOTES START -->
## Votes (latest per participant)
READY: 1 • CHANGES: 0 • REJECT: 0
- Maintainer
<!-- SUMMARY:VOTES END -->
<!-- SUMMARY:TIMELINE START -->
## Timeline (most recent first)
- {ts} Maintainer: Kickoff
<!-- SUMMARY:TIMELINE END -->
<!-- SUMMARY:LINKS START -->
## Links
- Design/Plan: ../design/design.md
<!-- SUMMARY:LINKS END -->
""".replace("{ts}", today), encoding="utf-8")
def copy_install_assets_to_target(target: Path):
"""Copy essential files from the installer to the target repository."""
# Runtime helpers into project root
copy_if_exists(INSTALL_ROOT / "ramble.py", target / "ramble.py")
copy_if_exists(INSTALL_ROOT / "create_feature.py", target / "create_feature.py")
# User guide into project root
copy_if_exists(INSTALL_ROOT / "assets" / "templates" / "USER_GUIDE.md",
target / "USER_GUIDE.md")
# Copy shipped templates (preferred source of truth)
tmpl_src = INSTALL_ROOT / "assets" / "templates"
if tmpl_src.exists():
shutil.copytree(tmpl_src, target / "process" / "templates", dirs_exist_ok=True)
# Place USER_GUIDE.md under process/ (clear separation from source templates)
ug_src = tmpl_src / "USER_GUIDE.md"
if ug_src.exists():
(target / "process").mkdir(parents=True, exist_ok=True)
shutil.copy2(ug_src, target / "process" / "USER_GUIDE.md")
# Hook is installed into .git/hooks by install_precommit_hook()
# Optionally copy any additional assets you drop under installer/automation, etc.
# Example: copy starter automation folder if provided in installer
if (INSTALL_ROOT / "automation").exists():
shutil.copytree(INSTALL_ROOT / "automation", target / "automation", dirs_exist_ok=True)
# Make workflow.py executable so pre-commit hook can run it
workflow_py = target / "automation" / "workflow.py"
if workflow_py.exists():
workflow_py.chmod(0o755)
def first_commit(target: Path):
"""Perform the initial git commit of all scaffolded files."""
try:
run(["git", "add", "-A"], cwd=target)
run(["git", "commit", "-m", "chore: bootstrap Cascading Development scaffolding"], cwd=target)
except Exception:
# Silently continue if commit fails (e.g., no git config)
pass
def main():
"""Main entry point for the Cascading Development setup script."""
# Parse command line arguments
ap = argparse.ArgumentParser()
ap.add_argument("--target", help="Destination path to create/use the repo")
ap.add_argument("--provider", choices=["mock", "claude"], default="mock", help="Ramble provider (default: mock)")
ap.add_argument("--no-ramble", action="store_true", help="Skip launching Ramble")
ap.add_argument("--claude-cmd", default="claude")
args = ap.parse_args()
# Get target directory from args or prompt user
target_str = args.target
if not target_str:
target_str = input("Destination repo path (will be created if missing): ").strip()
if not target_str:
say("No target specified. Aborting.")
sys.exit(2)
# Resolve and create target directory
target = Path(target_str).expanduser().resolve()
target.mkdir(parents=True, exist_ok=True)
say(f"[=] Installing Cascading Development into: {target}")
# Step 1: Copy assets from installer into target
copy_install_assets_to_target(target)
# Step 2: Create standard folder structure
for p in [
target / "Docs" / "features",
target / "Docs" / "discussions" / "reviews",
target / "Docs" / "diagrams" / "file_diagrams",
target / "scripts" / "hooks",
target / "src",
target / "tests",
target / "process",
]:
p.mkdir(parents=True, exist_ok=True)
# Step 3: Create rules/templates and basic process docs
seed_process_and_rules(target)
# Step 4: Initialize git & install pre-commit
ensure_git_repo(target)
install_precommit_hook(target)
# Step 5: Launch Ramble (if available and not disabled)
req = None
if not args.no_ramble:
req = run_ramble_and_collect(target, provider=args.provider, claude_cmd=args.claude_cmd)
# Step 6: Seed first feature based on Ramble output
seed_initial_feature(target, req)
# Step 7: Perform initial commit
first_commit(target)
# Completion message
say("[✓] Setup complete.")
say(f"Next steps:\n cd {target}\n git status")
if __name__ == "__main__":
main()