#!/usr/bin/env python3 """ setup_project.py — Installer-mode bootstrap for Cascading Development Run this from your installation folder (NOT inside the destination repo): - Prompts (or use --target) for the destination repo path - Copies essential files from installer → target (DESIGN.md, ramble.py, hooks) - Creates canonical structure, seeds rules/templates - Initializes git and installs pre-commit hook - Launches Ramble to capture the first feature request Examples: python3 scripts/setup_project.py python3 scripts/setup_project.py --target ~/dev/my-new-repo python3 scripts/setup_project.py --target /abs/path --no-ramble """ import sys import json import shutil import argparse import subprocess import datetime from pathlib import Path INSTALL_ROOT = Path(__file__).resolve().parent.parent # installer root (contains this scripts/ dir) # ---------- helpers ---------- def sh(cmd, check=True, cwd=None): return subprocess.run(cmd, check=check, text=True, capture_output=True, cwd=cwd) def say(msg): print(msg, flush=True) def write_if_missing(path: Path, content: str): path.parent.mkdir(parents=True, exist_ok=True) if not path.exists(): path.write_text(content, encoding="utf-8") def copy_if_exists(src: Path, dst: Path): if src.exists(): dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(str(src), str(dst)) def ensure_git_repo(target: Path): if not (target / ".git").exists(): sh(["git", "init", "-b", "main"], cwd=str(target)) write_if_missing(target / ".gitignore", "\n".join([ ".env", ".env.*", "secrets/", ".git/ai-rules-*", "__pycache__/", "*.pyc", ".pytest_cache/", ".DS_Store", ]) + "\n") def install_precommit_hook(target: Path): 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 scripts/hooks/pre-commit in the installer.") return 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"): ramble = target / "ramble.py" if not ramble.exists(): say("[-] ramble.py not found in target; skipping interactive FR capture.") return None args = [ sys.executable, str(ramble), "--provider", provider, "--claude-cmd", claude_cmd, "--prompt", "Describe your initial feature request for this repository", "--fields", "Summary", "Title", "Intent", "ProblemItSolves", "BriefOverview", "--criteria", '{"Summary":"<= 2 sentences","Title":"camelCase, <= 24 chars"}' ] say("[•] Launching Ramble (close the dialog with Submit to return JSON)…") proc = subprocess.run(args, text=True, capture_output=True, cwd=str(target)) if proc.stderr and proc.stderr.strip(): say("[Ramble stderr]") say(proc.stderr.strip()) out = (proc.stdout or "").strip() if out: try: return json.loads(out) except Exception as e: say(f"[-] JSON parse failed: {e}") # Terminal fallback so setup can proceed without GUI deps say("[!] Falling back to terminal prompts.") def ask(label, default=""): try: v = input(f"{label}: ").strip() return v or default except EOFError: return default 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): write_if_missing(target / "process" / "design.md", "# Process & Architecture (Local Notes)\n\n(See DESIGN.md for full spec.)\n") write_if_missing(target / "process" / "policies.md", "# Policies (Human-readable)\n\nSee machine-readable config in policies.yml.\n") write_if_missing(target / "process" / "policies.yml", """version: 1 voting: values: [READY, CHANGES, REJECT] allow_agent_votes: true quorum: discussion: { ready: all, reject: all } implementation: { ready: 1_human, reject: all } review: { ready: 1_human, reject: all } eligibility: agents_allowed: true require_human_for: [implementation, review] etiquette: name_prefix_agents: "AI_" vote_line_regex: "^VOTE:\\s*(READY|CHANGES|REJECT)\\s*$" response_timeout_hours: 24 """) tmpl_dir = target / "process" / "templates" write_if_missing(tmpl_dir / "feature_request.md", "# Feature Request: \n\n**Intent**: …\n**Motivation / Problem**: …\n**Constraints / Non-Goals**: …\n**Rough Proposal**: …\n**Open Questions**: …\n") write_if_missing(tmpl_dir / "discussion.md", "---\ntype: discussion\nstage: <feature|design|implementation|testing|review>\nstatus: OPEN\ncreated: <YYYY-MM-DD>\n---\n\n## Summary\n\n## Participation\n") write_if_missing(tmpl_dir / "design_doc.md", "# Design — <FR id / Title>\n\n## Context & Goals\n## Non-Goals & Constraints\n## Options Considered\n## Decision & Rationale\n## Architecture Diagram(s)\n## Risks & Mitigations\n## Acceptance Criteria\n") write_if_missing(target / ".ai-rules.yml", """version: 1 file_associations: "*.md": "md-file" rules: md-file: description: "Normalize Markdown" instruction: | Keep markdown tidy (headings, lists, spacing). No content churn. settings: model: "local-mock" temperature: 0.1 """) write_if_missing(target / "Docs" / "features" / ".ai-rules.yml", """version: 1 file_associations: "feature.discussion.md": "feature_discussion" "feature.discussion.sum.md": "discussion_summary" rules: feature_discussion: outputs: summary_companion: path: "{dir}/discussions/feature.discussion.sum.md" output_type: "discussion_summary_writer" instruction: | Ensure the summary file exists and maintain only the bounded sections: DECISIONS, OPEN_QUESTIONS, AWAITING, ACTION_ITEMS, VOTES, TIMELINE, LINKS. discussion_summary: outputs: normalize: path: "{dir}/feature.discussion.sum.md" output_type: "discussion_summary_normalizer" instruction: | If missing, create summary with standard markers. Do not edit text outside markers. """) def seed_initial_feature(target: Path, req_fields: dict | None): today = datetime.date.today().isoformat() fr_dir = target / "Docs" / "features" / f"FR_{today}_initial-feature-request" disc_dir = fr_dir / "discussions" disc_dir.mkdir(parents=True, exist_ok=True) if req_fields: title = (req_fields.get("fields", {}) or {}).get("Title", "").strip() or "initialProjectDesign" intent = (req_fields.get("fields", {}) or {}).get("Intent", "").strip() or "—" problem = (req_fields.get("fields", {}) or {}).get("ProblemItSolves", "").strip() or "—" brief = (req_fields.get("fields", {}) or {}).get("BriefOverview", "").strip() or "—" summary = (req_fields.get("summary") or "").strip() body = f"""# Feature Request: {title} **Intent**: {intent} **Motivation / Problem**: {problem} **Brief Overview**: {brief} **Summary**: {summary} **Meta**: Created: {today} """ else: body = (target / "process" / "templates" / "feature_request.md").read_text(encoding="utf-8") (fr_dir / "request.md").write_text(body, encoding="utf-8") (disc_dir / "feature.discussion.md").write_text( f"""--- type: discussion stage: feature status: OPEN feature_id: FR_{today}_initial-feature-request created: {today} --- ## Summary Initial discussion for the first feature request. Append your comments below. ## Participation - Maintainer: Kickoff. VOTE: READY """, encoding="utf-8") (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 DESIGN.md and ramble.py from installer if present copy_if_exists(INSTALL_ROOT / "DESIGN.md", target / "DESIGN.md") copy_if_exists(INSTALL_ROOT / "ramble.py", target / "ramble.py") # 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) # Copy the hook (you already install it to .git/hooks via install_precommit_hook) # If you ever want the raw hook inside the user's repo too: # copy_if_exists(INSTALL_ROOT / "assets" / "hooks" / "pre-commit", target / "scripts" / "hooks" / "pre-commit") # 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) def first_commit(target: Path): try: sh(["git", "add", "-A"], cwd=str(target)) sh(["git", "commit", "-m", "chore: bootstrap Cascading Development scaffolding"], cwd=str(target)) except Exception: pass def main(): 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() 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) target = Path(target_str).expanduser().resolve() target.mkdir(parents=True, exist_ok=True) say(f"[=] Installing Cascading Development into: {target}") # Copy assets from installer into target copy_install_assets_to_target(target) # Ensure folder layout 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) # Create rules/templates and basic process docs seed_process_and_rules(target) # Initialize git & install pre-commit ensure_git_repo(target) install_precommit_hook(target) # Launch Ramble (if available) req = None if not args.no_ramble: req = run_ramble_and_collect(target, provider=args.provider, claude_cmd=args.claude_cmd) # Seed first feature based on Ramble output seed_initial_feature(target, req) # First commit first_commit(target) say("[✓] Setup complete.") say(f"Next steps:\n cd {target}\n git status") if __name__ == "__main__": main()