#!/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: \nstatus: OPEN\ncreated: \n---\n\n## Summary\n\n## Participation\n")
write_if_missing(tmpl_dir / "design_doc.md",
"# Design — \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
## 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."""
# 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()