diff --git a/.github/workflows/hook-tests.yml b/.github/workflows/hook-tests.yml index 168ac84c..8ce4d61f 100644 --- a/.github/workflows/hook-tests.yml +++ b/.github/workflows/hook-tests.yml @@ -17,6 +17,13 @@ jobs: with: node-version: '20' + - name: Setup Python and uv + uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install uv + run: pip install uv + - name: Install dependencies run: cd .claude/hooks && npm ci diff --git a/README.md b/README.md index b251981e..618ca4f8 100644 --- a/README.md +++ b/README.md @@ -976,6 +976,92 @@ This will: | Scripts | ~/.claude/scripts/ | | PostgreSQL | Docker container | +### Installation Mode: Copy vs Symlink + +Continuous Claude supports two installation modes for syncing components (hooks, skills, agents, rules) from the repo to `~/.claude/`: + +| Mode | Description | +|------|-------------| +| **Copy** (default) | Copies files to `~/.claude/` | +| **Symlink** | Creates symlinks to repo files | + +#### Copy Install (Default) + +Files are copied from the repository to `~/.claude/`. Changes in the repo do not automatically reflect in `~/.claude/` until you run the update script again. + +**Pros:** +- Isolated from repo changes (safer for users who don't modify the repo) +- No risk of accidental repo modifications affecting their setup +- Standard approach for end users + +**Cons:** +- Requires running update to get new features +- Can become out of sync if updates are skipped + +#### Symlink Install + +Files are symlinked from the repository location to `~/.claude/`. Changes in the repo immediately reflect in your active installation. + +**Pros:** +- Always up-to-date with the repo +- Best for contributors developing on the project +- Changes propagate immediately without running update + +**Cons:** +- Modifications to `~/.claude/` files affect the repo (use caution) +- Repo structure changes can break symlinks + +#### Selecting Installation Mode + +The wizard asks which mode to use during installation: + +``` +Installation Mode: + 1. Copy install (default - copies files to ~/.claude/) + 2. Symlink install (links to repo - best for contributors) +``` + +#### Switching Between Modes + +```bash +# Switch from copy to symlink +uv run python -m scripts.setup.update --mode symlink + +# Switch from symlink to copy +uv run python -m scripts.setup.update --mode copy +``` + +#### Verification + +```bash +# Check if files are symlinks or copies +ls -la ~/.claude/hooks/ | head -3 + +# Symlink mode shows: lrwxr-xr-x hooks -> /path/to/repo/.claude/hooks +# Copy mode shows: -rw-r--r-- hooks/ (regular directory) +``` + +#### For Contributors + +If you plan to contribute to Continuous Claude: + +```bash +# 1. Fork the repository +# 2. Clone your fork +git clone https://github.com/YOUR_USERNAME/Continuous-Claude-v3.git +cd Continuous-Claude-v3/opc + +# 3. Install with symlinks (select option 2 in wizard) +uv run python -m scripts.setup.wizard + +# 4. Create a feature branch +git checkout -b feat/new-feature + +# 5. Make changes - they automatically apply to ~/.claude/ +# 6. Test in Claude Code +# 7. Commit and push +``` + ### For Brownfield Projects After installation, start Claude and run: diff --git a/opc/scripts/setup/claude_integration.py b/opc/scripts/setup/claude_integration.py index 7e4c1e9f..8c75af13 100644 --- a/opc/scripts/setup/claude_integration.py +++ b/opc/scripts/setup/claude_integration.py @@ -722,3 +722,161 @@ def install_tldr_code(verbose: bool = False) -> tuple[bool, str]: return True, "TLDR-Code already available" else: return False, symlink_msg + + +def _copy_scripts(opc_source: Path, target_dir: Path) -> int: + """Copy scripts directory from OPC source to target directory. + + Copies scripts/core, scripts/mathlib, scripts/tldr, and root scripts + needed for skills/hooks. + + Args: + opc_source: Path to the project root .claude source directory + target_dir: Target .claude directory + + Returns: + Number of scripts copied + """ + scripts_copied = 0 + target_scripts_root = target_dir / "scripts" + target_scripts_root.mkdir(parents=True, exist_ok=True) + + # Copy scripts/core/ for memory/artifact support + opc_scripts_core = opc_source.parent / "opc" / "scripts" / "core" + target_scripts_core = target_dir / "scripts" / "core" + if opc_scripts_core.exists(): + target_scripts_core.parent.mkdir(parents=True, exist_ok=True) + if target_scripts_core.exists(): + shutil.rmtree(target_scripts_core) + shutil.copytree(opc_scripts_core, target_scripts_core) + scripts_copied += len(list(target_scripts_core.rglob("*.py"))) + + # Copy scripts/mathlib/ for math computation support + opc_scripts_math = opc_source.parent / "opc" / "scripts" / "mathlib" + target_scripts_math = target_dir / "scripts" / "mathlib" + if opc_scripts_math.exists(): + if target_scripts_math.exists(): + shutil.rmtree(target_scripts_math) + shutil.copytree(opc_scripts_math, target_scripts_math) + scripts_copied += len(list(target_scripts_math.rglob("*.py"))) + + # Copy scripts/tldr/ for TLDR hook integration + opc_scripts_tldr = opc_source.parent / "opc" / "scripts" / "tldr" + target_scripts_tldr = target_dir / "scripts" / "tldr" + if opc_scripts_tldr.exists(): + if target_scripts_tldr.exists(): + shutil.rmtree(target_scripts_tldr) + shutil.copytree(opc_scripts_tldr, target_scripts_tldr) + scripts_copied += len(list(target_scripts_tldr.rglob("*.py"))) + + # Copy individual root scripts used by skills/hooks + root_scripts = [ + "ast_grep_find.py", + "braintrust_analyze.py", + "qlty_check.py", + "research_implement_pipeline.py", + "test_research_pipeline.py", + "multi_tool_pipeline.py", + "recall_temporal_facts.py", + ] + opc_scripts_root = opc_source.parent / "opc" / "scripts" + for script_name in root_scripts: + src = opc_scripts_root / script_name + if src.exists(): + shutil.copy2(src, target_scripts_root / script_name) + scripts_copied += 1 + + return scripts_copied + + +def install_opc_integration_symlink( + target_dir: Path, + opc_source: Path, +) -> dict[str, Any]: + """Install OPC integration using symlinks for dynamic content. + + Creates symlinks for: rules, skills, hooks, agents + Copies (not symlinks): settings.json, servers, runtime, plugins, scripts + + Auto-backs up existing directories before symlinking. + + Args: + target_dir: Target .claude directory + opc_source: Source OPC .claude directory + + Returns: + dict with keys: success, symlinked_dirs, backed_up_dirs, error + """ + result = { + "success": False, + "symlinked_dirs": [], + "backed_up_dirs": [], + "copied_dirs": [], + "error": None, + } + + try: + target_dir.mkdir(parents=True, exist_ok=True) + backup_dir = target_dir.parent / ".claude.backup.symlink" + backup_dir.mkdir(parents=True, exist_ok=True) + + # Directories to symlink (use symlinks for dynamic content) + symlink_dirs = ["rules", "skills", "hooks", "agents"] + + # Directories to copy (use copies for static content) + copy_dirs = ["servers", "runtime", "plugins"] + + # Symlink directories + for dir_name in symlink_dirs: + source_path = opc_source / dir_name + target_path = target_dir / dir_name + + if not source_path.exists(): + continue + + # Backup existing directory before symlinking + if target_path.exists(): + if target_path.is_symlink(): + target_path.unlink() + elif target_path.is_dir(): + backup_path = backup_dir / dir_name + if backup_path.exists(): + shutil.rmtree(backup_path) + shutil.copytree(target_path, backup_path) + result["backed_up_dirs"].append(dir_name) + shutil.rmtree(target_path) + + # Create symlink + target_path.symlink_to(source_path) + result["symlinked_dirs"].append(dir_name) + + # Copy directories + for dir_name in copy_dirs: + source_path = opc_source / dir_name + target_path = target_dir / dir_name + + if not source_path.exists(): + continue + + if target_path.exists(): + shutil.rmtree(target_path) + shutil.copytree(source_path, target_path) + result["copied_dirs"].append(dir_name) + + # Copy settings.json (always copy, never symlink) + opc_settings_path = opc_source / "settings.json" + target_settings_path = target_dir / "settings.json" + if opc_settings_path.exists(): + shutil.copy2(opc_settings_path, target_settings_path) + + # Copy scripts (always copy, never symlink) + scripts_copied = _copy_scripts(opc_source, target_dir) + if scripts_copied > 0: + result["copied_dirs"].append("scripts") + + result["success"] = True + + except Exception as e: + result["error"] = str(e) + + return result diff --git a/opc/scripts/setup/wizard.py b/opc/scripts/setup/wizard.py index 29383819..50c95f9c 100644 --- a/opc/scripts/setup/wizard.py +++ b/opc/scripts/setup/wizard.py @@ -1932,6 +1932,7 @@ async def run_setup_wizard() -> None: get_global_claude_dir, get_opc_integration_source, install_opc_integration, + install_opc_integration_symlink, ) claude_dir = get_global_claude_dir() # Use global ~/.claude, not project-local @@ -1964,74 +1965,104 @@ async def run_setup_wizard() -> None: console.print("\n[bold]Installation Options:[/bold]") console.print(" 1. Full install (backup existing, install OPC, merge non-conflicting)") console.print(" 2. Fresh install (backup existing, install OPC only)") - console.print(" 3. Skip (keep existing configuration)") + console.print(" 3. Symlink install (rules/skills/hooks/agents as symlinks, auto-updates with OPC)") + console.print(" 4. Skip (keep existing configuration)") - choice = Prompt.ask("Choose option", choices=["1", "2", "3"], default="1") + choice = Prompt.ask("Choose option", choices=["1", "2", "3", "4"], default="1") - if choice in ("1", "2"): + if choice in ("1", "2", "3"): # Backup first backup_path = backup_claude_dir(claude_dir) if backup_path: console.print(f" [green]OK[/green] Backup created: {backup_path.name}") - # Install - merge = choice == "1" - result = install_opc_integration( - claude_dir, - opc_source, - merge_user_items=merge, - existing=existing if merge else None, - conflicts=conflicts if merge else None, - ) - - if result["success"]: - console.print(f" [green]OK[/green] Installed {result['installed_hooks']} hooks") - console.print(f" [green]OK[/green] Installed {result['installed_skills']} skills") - console.print(f" [green]OK[/green] Installed {result['installed_rules']} rules") - console.print(f" [green]OK[/green] Installed {result['installed_agents']} agents") - console.print(f" [green]OK[/green] Installed {result['installed_servers']} MCP servers") - if result["merged_items"]: - console.print( - f" [green]OK[/green] Merged {len(result['merged_items'])} custom items" - ) + if choice == "3": + # Symlink install + result = install_opc_integration_symlink(claude_dir, opc_source) + if result["success"]: + console.print(f" [green]OK[/green] Symlinked {len(result['symlinked_dirs'])} directories: {', '.join(result['symlinked_dirs'])}") + console.print(f" [green]OK[/green] Copied {len(result['copied_dirs'])} directories: {', '.join(result['copied_dirs'])}") + if result.get("backed_up_dirs"): + console.print(f" [dim]Backed up: {', '.join(result['backed_up_dirs'])}[/dim]") + else: + console.print(f" [red]ERROR[/red] {result.get('error', 'Unknown error')}") + else: + # Merge or Replace install + merge = choice == "1" + result = install_opc_integration( + claude_dir, + opc_source, + merge_user_items=merge, + existing=existing if merge else None, + conflicts=conflicts if merge else None, + ) - # Build TypeScript hooks - console.print(" Building TypeScript hooks...") - hooks_dir = claude_dir / "hooks" - build_success, build_msg = build_typescript_hooks(hooks_dir) - if build_success: - console.print(f" [green]OK[/green] {build_msg}") + if result["success"]: + console.print(f" [green]OK[/green] Installed {result['installed_hooks']} hooks") + console.print(f" [green]OK[/green] Installed {result['installed_skills']} skills") + console.print(f" [green]OK[/green] Installed {result['installed_rules']} rules") + console.print(f" [green]OK[/green] Installed {result['installed_agents']} agents") + console.print(f" [green]OK[/green] Installed {result['installed_servers']} MCP servers") + if result["merged_items"]: + console.print( + f" [green]OK[/green] Merged {len(result['merged_items'])} custom items" + ) else: - console.print(f" [yellow]WARN[/yellow] {build_msg}") - console.print(" [dim]You can build manually: cd ~/.claude/hooks && npm install && npm run build[/dim]") + console.print(f" [red]ERROR[/red] {result.get('error', 'Unknown error')}") + + # Build TypeScript hooks (common for all install methods) + console.print(" Building TypeScript hooks...") + hooks_dir = claude_dir / "hooks" + build_success, build_msg = build_typescript_hooks(hooks_dir) + if build_success: + console.print(f" [green]OK[/green] {build_msg}") else: - console.print(f" [red]ERROR[/red] {result.get('error', 'Unknown error')}") + console.print(f" [yellow]WARN[/yellow] {build_msg}") + console.print(" [dim]You can build manually: cd ~/.claude/hooks && npm install && npm run build[/dim]") else: console.print(" Skipped integration installation") else: # Clean install - if Confirm.ask("Install Claude Code integration (hooks, skills, rules)?", default=True): + console.print(" No existing Claude Code configuration found.") + console.print("\n[bold]Installation Options:[/bold]") + console.print(" 1. Copy install (static copy of all OPC files)") + console.print(" 2. Symlink install (rules/skills/hooks/agents as symlinks, auto-updates with OPC)") + + install_choice = Prompt.ask("Choose option", choices=["1", "2"], default="1") + + if Confirm.ask("\nInstall Claude Code integration?", default=True): opc_source = get_opc_integration_source() - result = install_opc_integration(claude_dir, opc_source) - - if result["success"]: - console.print(f" [green]OK[/green] Installed {result['installed_hooks']} hooks") - console.print(f" [green]OK[/green] Installed {result['installed_skills']} skills") - console.print(f" [green]OK[/green] Installed {result['installed_rules']} rules") - console.print(f" [green]OK[/green] Installed {result['installed_agents']} agents") - console.print(f" [green]OK[/green] Installed {result['installed_servers']} MCP servers") - - # Build TypeScript hooks - console.print(" Building TypeScript hooks...") - hooks_dir = claude_dir / "hooks" - build_success, build_msg = build_typescript_hooks(hooks_dir) - if build_success: - console.print(f" [green]OK[/green] {build_msg}") + + if install_choice == "2": + # Symlink install + result = install_opc_integration_symlink(claude_dir, opc_source) + if result["success"]: + console.print(f" [green]OK[/green] Symlinked {len(result['symlinked_dirs'])} directories: {', '.join(result['symlinked_dirs'])}") + console.print(f" [green]OK[/green] Copied {len(result['copied_dirs'])} directories: {', '.join(result['copied_dirs'])}") + else: + console.print(f" [red]ERROR[/red] {result.get('error', 'Unknown error')}") + else: + # Copy install + result = install_opc_integration(claude_dir, opc_source) + + if result["success"]: + console.print(f" [green]OK[/green] Installed {result['installed_hooks']} hooks") + console.print(f" [green]OK[/green] Installed {result['installed_skills']} skills") + console.print(f" [green]OK[/green] Installed {result['installed_rules']} rules") + console.print(f" [green]OK[/green] Installed {result['installed_agents']} agents") + console.print(f" [green]OK[/green] Installed {result['installed_servers']} MCP servers") else: - console.print(f" [yellow]WARN[/yellow] {build_msg}") - console.print(" [dim]You can build manually: cd ~/.claude/hooks && npm install && npm run build[/dim]") + console.print(f" [red]ERROR[/red] {result.get('error', 'Unknown error')}") + + # Build TypeScript hooks (common for both install methods) + console.print(" Building TypeScript hooks...") + hooks_dir = claude_dir / "hooks" + build_success, build_msg = build_typescript_hooks(hooks_dir) + if build_success: + console.print(f" [green]OK[/green] {build_msg}") else: - console.print(f" [red]ERROR[/red] {result.get('error', 'Unknown error')}") + console.print(f" [yellow]WARN[/yellow] {build_msg}") + console.print(" [dim]You can build manually: cd ~/.claude/hooks && npm install && npm run build[/dim]") # Step 9: Math Features (Optional) console.print("\n[bold]Step 9/12: Math Features (Optional)[/bold]")