#!/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 # The root directory of the installer package (contains scripts/, assets/, etc.) INSTALL_ROOT = Path(__file__).resolve().parent.parent # ---------- Helper Functions ---------- def sh(cmd, check=True, cwd=None): """Run a shell command and return the completed process.""" return subprocess.run(cmd, check=check, text=True, capture_output=True, cwd=cwd) def say(msg): """Print a message with immediate flush.""" print(msg, flush=True) def write_if_missing(path: Path, content: str): """Write content to a file only if it doesn't already exist.""" 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): """Copy a file from source to destination if the source exists.""" if src.exists(): dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(str(src), str(dst)) 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 sh(["git", "init", "-b", "main"], cwd=str(target)) # Create basic .gitignore file 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): """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 scripts/hooks/pre-commit in the installer.") 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. """ ramble = target / "ramble.py" if not ramble.exists(): say("[-] ramble.py not found in target; skipping interactive FR capture.") return None # Build Ramble command arguments 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)) # 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): """Create the standard folder structure, templates, and configuration files.""" # Create basic process documentation files 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") # Create machine-readable policies configuration 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 """) # Create template files for different document types 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") # Create AI rules configuration for general markdown files 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 """) # Create AI rules specific to feature discussions 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): """Create the initial feature request and associated discussion files.""" 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) # Create feature request content, using Ramble data if available 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: # Fallback to template content if no Ramble data body = (target / "process" / "templates" / "feature_request.md").read_text(encoding="utf-8") (fr_dir / "request.md").write_text(body, encoding="utf-8") # Create initial discussion file (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") # Create companion summary file with structured sections (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.""" # 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): """Perform the initial git commit of all scaffolded files.""" try: sh(["git", "add", "-A"], cwd=str(target)) sh(["git", "commit", "-m", "chore: bootstrap Cascading Development scaffolding"], cwd=str(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()