#!/usr/bin/env python3 """ setup_project.py — Installer-mode bootstrap for Cascading Development Run this from the **installer bundle folder** (e.g., install/cascadingdev-/), 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"", 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 . """ 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 ## Decisions (ADR-style) - (none yet) ## Open Questions - (none yet) ## Awaiting Replies - (none yet) ## Action Items - (none yet) ## Votes (latest per participant) READY: 1 • CHANGES: 0 • REJECT: 0 - Maintainer ## Timeline (most recent first) - {ts} Maintainer: Kickoff ## Links - Design/Plan: ../design/design.md """.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()