From 7612f45d97240de2241cdb3809bc04dec9b411b2 Mon Sep 17 00:00:00 2001 From: Fred Amaral Date: Sun, 8 Mar 2026 14:42:25 -0300 Subject: [PATCH] feat(installer): add symlink install mode for faster developer iteration feat(opencode): inline shared content to make installed components portable --- .gitignore | 3 + installer/install-ring.sh | 22 +- installer/ring_installer/__main__.py | 216 +++++++++++---- installer/ring_installer/adapters/opencode.py | 241 +++++++++++++--- installer/ring_installer/core.py | 262 +++++++++++------- installer/ring_installer/utils/fs.py | 149 +++++++++- 6 files changed, 687 insertions(+), 206 deletions(-) diff --git a/.gitignore b/.gitignore index ca3b82ee..0d71c7a1 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,9 @@ dist/ # Ring infrastructure runtime files .ring/ +# Ring build output (transformed files for symlink installs) +.ring-build/ + .codegraph/ node_modules/ diff --git a/installer/install-ring.sh b/installer/install-ring.sh index f3d86809..5a1ebd5a 100755 --- a/installer/install-ring.sh +++ b/installer/install-ring.sh @@ -79,9 +79,18 @@ if [ $# -gt 0 ]; then fi fi - # Direct passthrough to Python module + # Direct passthrough to Python module. + # If the first arg is a flag (--*), prepend "install" as the default subcommand. + # Known subcommands are passed through as-is. cd "$RING_ROOT" - exec "$PYTHON_CMD" -m installer.ring_installer "$@" + case "$1" in + install|update|rebuild|check|sync|uninstall|list|detect|platforms) + exec "$PYTHON_CMD" -m installer.ring_installer "$@" + ;; + *) + exec "$PYTHON_CMD" -m installer.ring_installer install "$@" + ;; + esac fi # Interactive mode - platform selection @@ -156,10 +165,14 @@ echo "Installing to: ${GREEN}${PLATFORMS}${RESET}" echo "" # Additional options +read -p "Use symlink mode? (builds in-repo, symlinks to config) (y/N): " use_link read -p "Enable verbose output? (y/N): " verbose read -p "Perform dry-run first? (y/N): " dry_run EXTRA_ARGS=() +if [[ "$use_link" =~ ^[Yy]$ ]]; then + EXTRA_ARGS+=("--link" "--force") +fi if [[ "$verbose" =~ ^[Yy]$ ]]; then EXTRA_ARGS+=("--verbose") fi @@ -192,10 +205,15 @@ echo "" echo "Next steps:" echo " 1. Restart your AI tool or start a new session" echo " 2. Skills will auto-load (Claude Code) or be available as configured" +if [[ "$use_link" =~ ^[Yy]$ ]]; then + echo " 3. After git pull, run: ./installer/install-ring.sh rebuild" +fi echo "" echo "Commands:" echo " ./installer/install-ring.sh # Interactive install" echo " ./installer/install-ring.sh --platforms claude # Direct install" +echo " ./installer/install-ring.sh --platforms opencode --link # Symlink install" echo " ./installer/install-ring.sh update # Update installation" +echo " ./installer/install-ring.sh rebuild # Rebuild after git pull" echo " ./installer/install-ring.sh list # List installed" echo "" diff --git a/installer/ring_installer/__main__.py b/installer/ring_installer/__main__.py index 17a6d954..c827a6e0 100644 --- a/installer/ring_installer/__main__.py +++ b/installer/ring_installer/__main__.py @@ -2,8 +2,9 @@ Ring Installer CLI - Multi-platform AI agent skill installer. Usage: - python -m ring_installer install [--platforms PLATFORMS] [--dry-run] [--force] [--verbose] - python -m ring_installer update [--platforms PLATFORMS] [--dry-run] [--verbose] + python -m ring_installer install [--platforms PLATFORMS] [--dry-run] [--force] [--verbose] [--link] + python -m ring_installer update [--platforms PLATFORMS] [--dry-run] [--verbose] [--link] + python -m ring_installer rebuild [--platforms PLATFORMS] [--verbose] python -m ring_installer uninstall [--platforms PLATFORMS] [--dry-run] [--force] python -m ring_installer list [--platform PLATFORM] python -m ring_installer detect @@ -15,6 +16,12 @@ Examples: # Install to specific platforms python -m ring_installer install --platforms claude,cursor + # Symlink install (builds in-repo, symlinks from config dir) + python -m ring_installer install --platforms opencode --link + + # Rebuild after git pull (re-transforms, symlinks still valid) + python -m ring_installer rebuild + # Dry run to see what would be done python -m ring_installer install --dry-run --verbose @@ -197,6 +204,7 @@ def cmd_install(args: argparse.Namespace) -> int: targets = [InstallTarget(platform=p) for p in platforms] # Build options + use_link = getattr(args, "link", False) options = InstallOptions( dry_run=args.dry_run, force=args.force, @@ -204,11 +212,17 @@ def cmd_install(args: argparse.Namespace) -> int: verbose=args.verbose, plugin_names=parse_platforms(args.plugins) if args.plugins else None, exclude_plugins=parse_platforms(args.exclude) if args.exclude else None, + link=use_link, ) if args.dry_run: print("\n[DRY RUN] No changes will be made.\n") + if use_link: + print( + f"[LINK MODE] Building to {source_path / '.ring-build'}, symlinking to config dirs.\n" + ) + # Run installation callback = progress_callback if not args.quiet and not args.verbose else None result = install(source_path, targets, options, callback) @@ -250,6 +264,7 @@ def cmd_update(args: argparse.Namespace) -> int: targets = [InstallTarget(platform=p) for p in platforms] # Build options + use_link = getattr(args, "link", False) options = InstallOptions( dry_run=args.dry_run, force=True, @@ -257,15 +272,19 @@ def cmd_update(args: argparse.Namespace) -> int: verbose=args.verbose, plugin_names=parse_platforms(args.plugins) if args.plugins else None, exclude_plugins=parse_platforms(args.exclude) if args.exclude else None, + link=use_link, ) if args.dry_run: print("\n[DRY RUN] No changes will be made.\n") + if use_link: + print(f"[LINK MODE] Rebuilding {source_path / '.ring-build'}\n") + callback = progress_callback if not args.quiet and not args.verbose else None # Use smart update if --smart flag is set - if getattr(args, 'smart', False): + if getattr(args, "smart", False): result = update_with_diff(source_path, targets, options, callback) else: result = update(source_path, targets, options, callback) @@ -278,6 +297,66 @@ def cmd_update(args: argparse.Namespace) -> int: return 0 if result.status in [InstallStatus.SUCCESS, InstallStatus.SKIPPED] else 1 +def cmd_rebuild(args: argparse.Namespace) -> int: + """ + Handle rebuild command — re-transform files in .ring-build/ for symlink installs. + + This is the fast path after `git pull`: re-runs transformations on source files + and writes updated content to the build directory. Symlinks already point there, + so the platform picks up changes immediately. + """ + source_path = Path(args.source).expanduser() if args.source else find_ring_source() + + if not source_path or not source_path.exists(): + print("Error: Could not find Ring source directory.") + return 1 + + build_root = source_path / ".ring-build" + if not build_root.exists(): + print("Error: No .ring-build/ directory found.") + print("Run 'install --link' first to set up symlink-based installation.") + return 1 + + print(f"Ring source: {source_path}") + + # Determine which platforms have existing builds + if args.platforms: + platforms = validate_platforms(parse_platforms(args.platforms)) + if not platforms: + return 1 + else: + # Auto-detect from existing .ring-build/ subdirectories + platforms = [ + d.name for d in build_root.iterdir() if d.is_dir() and d.name in SUPPORTED_PLATFORMS + ] + if not platforms: + print("Error: No platform builds found in .ring-build/") + return 1 + print(f"Rebuilding platforms: {', '.join(platforms)}") + + targets = [InstallTarget(platform=p) for p in platforms] + + # Rebuild = force update with link mode + options = InstallOptions( + force=True, + backup=False, + verbose=args.verbose, + plugin_names=parse_platforms(args.plugins) if args.plugins else None, + exclude_plugins=parse_platforms(args.exclude) if args.exclude else None, + link=True, + ) + + callback = progress_callback if not args.quiet and not args.verbose else None + result = install(source_path, targets, options, callback) + + if callback: + print() + + print_result(result, args.verbose) + + return 0 if result.status in [InstallStatus.SUCCESS, InstallStatus.SKIPPED] else 1 + + def cmd_check(args: argparse.Namespace) -> int: """Handle check command - check for available updates.""" # Find Ring source @@ -457,7 +536,7 @@ def cmd_uninstall(args: argparse.Namespace) -> int: callback = progress_callback if not args.quiet and not args.verbose else None # Use manifest-based uninstall if --precise flag is set - if getattr(args, 'precise', False): + if getattr(args, "precise", False): result = uninstall_with_manifest(targets, options, callback) else: result = uninstall(targets, options, callback) @@ -512,17 +591,20 @@ def cmd_detect(args: argparse.Namespace) -> int: """Handle detect command.""" if args.json: import json + platforms = detect_installed_platforms() data = [] for p in platforms: - data.append({ - "platform_id": p.platform_id, - "name": p.name, - "version": p.version, - "install_path": str(p.install_path) if p.install_path else None, - "binary_path": str(p.binary_path) if p.binary_path else None, - "details": p.details, - }) + data.append( + { + "platform_id": p.platform_id, + "name": p.name, + "version": p.version, + "install_path": str(p.install_path) if p.install_path else None, + "binary_path": str(p.binary_path) if p.binary_path else None, + "details": p.details, + } + ) print(json.dumps(data, indent=2)) else: print_detection_report() @@ -564,59 +646,43 @@ Examples: %(prog)s update Update existing installation %(prog)s list --platform claude List installed components %(prog)s detect Detect installed platforms - """ + """, ) - parser.add_argument( - "--version", "-V", - action="version", - version=f"%(prog)s {__version__}" - ) + parser.add_argument("--version", "-V", action="version", version=f"%(prog)s {__version__}") subparsers = parser.add_subparsers(dest="command", help="Available commands") # Install command install_parser = subparsers.add_parser("install", help="Install Ring components") + install_parser.add_argument("--source", "-s", help="Path to Ring source directory") install_parser.add_argument( - "--source", "-s", - help="Path to Ring source directory" + "--platforms", "-p", help="Comma-separated list of target platforms" ) + install_parser.add_argument("--plugins", help="Comma-separated list of plugins to install") + install_parser.add_argument("--exclude", help="Comma-separated list of plugins to exclude") install_parser.add_argument( - "--platforms", "-p", - help="Comma-separated list of target platforms" - ) - install_parser.add_argument( - "--plugins", - help="Comma-separated list of plugins to install" - ) - install_parser.add_argument( - "--exclude", - help="Comma-separated list of plugins to exclude" - ) - install_parser.add_argument( - "--dry-run", "-n", + "--dry-run", + "-n", action="store_true", - help="Show what would be done without making changes" + help="Show what would be done without making changes", ) install_parser.add_argument( - "--force", "-f", - action="store_true", - help="Overwrite existing files" + "--force", "-f", action="store_true", help="Overwrite existing files" ) install_parser.add_argument( - "--no-backup", - action="store_true", - help="Don't create backups before overwriting" + "--no-backup", action="store_true", help="Don't create backups before overwriting" + ) + install_parser.add_argument("--verbose", "-v", action="store_true", help="Show detailed output") + install_parser.add_argument( + "--quiet", "-q", action="store_true", help="Suppress progress output" ) install_parser.add_argument( - "--verbose", "-v", + "--link", + "-l", action="store_true", - help="Show detailed output" - ) - install_parser.add_argument( - "--quiet", "-q", - action="store_true", - help="Suppress progress output" + help="Build transformed files in-repo (.ring-build/) and symlink " + "from platform config dir. Enables instant updates via git pull + rebuild.", ) # Update command @@ -625,11 +691,37 @@ Examples: update_parser.add_argument("--platforms", "-p", help="Comma-separated list of target platforms") update_parser.add_argument("--plugins", help="Comma-separated list of plugins to update") update_parser.add_argument("--exclude", help="Comma-separated list of plugins to exclude") - update_parser.add_argument("--dry-run", "-n", action="store_true", help="Show what would be done") + update_parser.add_argument( + "--dry-run", "-n", action="store_true", help="Show what would be done" + ) update_parser.add_argument("--no-backup", action="store_true", help="Don't create backups") update_parser.add_argument("--verbose", "-v", action="store_true", help="Show detailed output") - update_parser.add_argument("--quiet", "-q", action="store_true", help="Suppress progress output") + update_parser.add_argument( + "--quiet", "-q", action="store_true", help="Suppress progress output" + ) update_parser.add_argument("--smart", action="store_true", help="Only update changed files") + update_parser.add_argument( + "--link", + "-l", + action="store_true", + help="Rebuild transformed files in-repo (.ring-build/) for symlink installs", + ) + + # Rebuild command - lightweight re-transform for symlink installs + rebuild_parser = subparsers.add_parser( + "rebuild", + help="Rebuild .ring-build/ transformed files (for symlink installs after git pull)", + ) + rebuild_parser.add_argument("--source", "-s", help="Path to Ring source directory") + rebuild_parser.add_argument( + "--platforms", "-p", help="Comma-separated list of target platforms" + ) + rebuild_parser.add_argument("--plugins", help="Comma-separated list of plugins to rebuild") + rebuild_parser.add_argument("--exclude", help="Comma-separated list of plugins to exclude") + rebuild_parser.add_argument("--verbose", "-v", action="store_true", help="Show detailed output") + rebuild_parser.add_argument( + "--quiet", "-q", action="store_true", help="Suppress progress output" + ) # Check command check_parser = subparsers.add_parser("check", help="Check for available updates") @@ -649,13 +741,25 @@ Examples: # Uninstall command uninstall_parser = subparsers.add_parser("uninstall", help="Remove Ring components") - uninstall_parser.add_argument("--platforms", "-p", help="Comma-separated list of target platforms") - uninstall_parser.add_argument("--dry-run", "-n", action="store_true", help="Show what would be done") - uninstall_parser.add_argument("--force", "-f", action="store_true", help="Don't prompt for confirmation") + uninstall_parser.add_argument( + "--platforms", "-p", help="Comma-separated list of target platforms" + ) + uninstall_parser.add_argument( + "--dry-run", "-n", action="store_true", help="Show what would be done" + ) + uninstall_parser.add_argument( + "--force", "-f", action="store_true", help="Don't prompt for confirmation" + ) uninstall_parser.add_argument("--no-backup", action="store_true", help="Don't create backups") - uninstall_parser.add_argument("--verbose", "-v", action="store_true", help="Show detailed output") - uninstall_parser.add_argument("--quiet", "-q", action="store_true", help="Suppress progress output") - uninstall_parser.add_argument("--precise", action="store_true", help="Use manifest for precise removal") + uninstall_parser.add_argument( + "--verbose", "-v", action="store_true", help="Show detailed output" + ) + uninstall_parser.add_argument( + "--quiet", "-q", action="store_true", help="Suppress progress output" + ) + uninstall_parser.add_argument( + "--precise", action="store_true", help="Use manifest for precise removal" + ) # List command list_parser = subparsers.add_parser("list", help="List installed components") @@ -679,6 +783,7 @@ Examples: commands = { "install": cmd_install, "update": cmd_update, + "rebuild": cmd_rebuild, "check": cmd_check, "sync": cmd_sync, "uninstall": cmd_uninstall, @@ -698,6 +803,7 @@ Examples: print(f"Error: {e}") if hasattr(args, "verbose") and args.verbose: import traceback + traceback.print_exc() return 1 else: diff --git a/installer/ring_installer/adapters/opencode.py b/installer/ring_installer/adapters/opencode.py index 61580736..a4d9ca7f 100644 --- a/installer/ring_installer/adapters/opencode.py +++ b/installer/ring_installer/adapters/opencode.py @@ -85,37 +85,37 @@ class OpenCodeAdapter(PlatformAdapter): # Reference: OpenCode skill schema - only these fields are recognized # All other fields (model, tools, version, etc.) are stripped during transformation _OPENCODE_SKILL_ALLOWED_FIELDS: List[str] = [ - "name", # Required: skill identifier - "description", # Optional: displayed in skill list - "license", # Optional: license identifier (e.g., "MIT") - "compatibility", # Optional: version constraints - "metadata", # Optional: arbitrary key-value metadata + "name", # Required: skill identifier + "description", # Optional: displayed in skill list + "license", # Optional: license identifier (e.g., "MIT") + "compatibility", # Optional: version constraints + "metadata", # Optional: arbitrary key-value metadata ] # OpenCode agent allowed frontmatter fields # Reference: OpenCode agent schema - defines agent behavior and capabilities _OPENCODE_AGENT_ALLOWED_FIELDS: List[str] = [ - "name", # Required: agent identifier - "description", # Optional: shown in agent selection - "mode", # Optional: "primary", "subagent", or "all" - "model", # Optional: "provider/model-id" format - "tools", # Optional: tool access configuration - "hidden", # Optional: hide from agent list - "subtask", # Optional: mark as subtask-only agent - "temperature", # Optional: response randomness (0.0-1.0) - "maxSteps", # Optional: max agentic iterations - "permission", # Optional: {edit: ask|allow|deny, bash: ask|allow|deny} + "name", # Required: agent identifier + "description", # Optional: shown in agent selection + "mode", # Optional: "primary", "subagent", or "all" + "model", # Optional: "provider/model-id" format + "tools", # Optional: tool access configuration + "hidden", # Optional: hide from agent list + "subtask", # Optional: mark as subtask-only agent + "temperature", # Optional: response randomness (0.0-1.0) + "maxSteps", # Optional: max agentic iterations + "permission", # Optional: {edit: ask|allow|deny, bash: ask|allow|deny} ] # OpenCode command allowed frontmatter fields # Reference: OpenCode command schema # Note: argument-hint is NOT supported - use $ARGUMENTS in template body _OPENCODE_COMMAND_ALLOWED_FIELDS: List[str] = [ - "name", # Optional: override filename-based name - "description", # Optional: shown in slash command suggestions - "model", # Optional: override model for this command - "subtask", # Optional: mark as subtask command - "agent", # Optional: which agent executes this command + "name", # Optional: override filename-based name + "description", # Optional: shown in slash command suggestions + "model", # Optional: override model for this command + "subtask", # Optional: mark as subtask command + "agent", # Optional: which agent executes this command ] # OpenCode permission values @@ -139,6 +139,7 @@ class OpenCodeAdapter(PlatformAdapter): Transformation includes: - Tool name normalization (capitalized -> lowercase) - Frontmatter filtering (only allowed fields kept) + - Inline resolution of shared-patterns and docs references OpenCode skill frontmatter only supports: name, description, license, compatibility, metadata. All other fields are stripped. @@ -157,6 +158,10 @@ class OpenCodeAdapter(PlatformAdapter): body = self._normalize_tool_references(body) + # Inline shared-patterns and docs references + source_path = metadata.get("source_path") if metadata else None + body = self._resolve_inline_references(body, source_path) + if frontmatter: return self.create_frontmatter(frontmatter) + "\n" + body return body @@ -218,6 +223,10 @@ class OpenCodeAdapter(PlatformAdapter): body = self._normalize_tool_references(body) body = self._strip_model_requirement_section(body) + # Inline shared-patterns and docs references + source_path = metadata.get("source_path") if metadata else None + body = self._resolve_inline_references(body, source_path) + if frontmatter: return self.create_frontmatter(frontmatter) + "\n" + body return body @@ -242,15 +251,17 @@ class OpenCodeAdapter(PlatformAdapter): # - The self-verification instructions # - The orchestrator requirement code block # - The trailing horizontal rule (---) separator - pattern = r'## ⚠️ Model Requirement[^\n]*\n.*?\n---\n' - result = re.sub(pattern, '', body, flags=re.DOTALL) + pattern = r"## ⚠️ Model Requirement[^\n]*\n.*?\n---\n" + result = re.sub(pattern, "", body, flags=re.DOTALL) # Clean up any resulting double blank lines - result = re.sub(r'\n{3,}', '\n\n', result) + result = re.sub(r"\n{3,}", "\n\n", result) - return result.strip() + '\n' + return result.strip() + "\n" - def transform_command(self, command_content: str, metadata: Optional[Dict[str, Any]] = None) -> str: + def transform_command( + self, command_content: str, metadata: Optional[Dict[str, Any]] = None + ) -> str: """ Transform a Ring command for OpenCode. @@ -278,11 +289,17 @@ class OpenCodeAdapter(PlatformAdapter): body = self._normalize_tool_references(body) + # Inline shared-patterns and docs references + source_path = metadata.get("source_path") if metadata else None + body = self._resolve_inline_references(body, source_path) + if frontmatter: return self.create_frontmatter(frontmatter) + "\n" + body return body - def transform_hook(self, hook_content: str, metadata: Optional[Dict[str, Any]] = None) -> Optional[str]: + def transform_hook( + self, hook_content: str, metadata: Optional[Dict[str, Any]] = None + ) -> Optional[str]: """ Transform a Ring hook for OpenCode. @@ -307,7 +324,7 @@ class OpenCodeAdapter(PlatformAdapter): "Hook '%s' cannot be installed: OpenCode uses plugin-based hooks " "(tool.execute.before, tool.execute.after, etc.) which are incompatible with " "Ring's file-based hooks. This hook will be skipped.", - hook_name + hook_name, ) return None @@ -350,16 +367,16 @@ class OpenCodeAdapter(PlatformAdapter): return { "agents": { "target_dir": "agent", # Singular in OpenCode - "extension": ".md" + "extension": ".md", }, "commands": { "target_dir": "command", # Singular in OpenCode - "extension": ".md" + "extension": ".md", }, "skills": { "target_dir": "skill", # Singular in OpenCode - "extension": ".md" - } + "extension": ".md", + }, # NOTE: hooks intentionally excluded - OpenCode uses plugin-based hooks } @@ -591,7 +608,7 @@ class OpenCodeAdapter(PlatformAdapter): logger.debug( "Dropped unsupported argument fields: %s. " "OpenCode doesn't support argument-hint. Use $ARGUMENTS in template body.", - dropped_args + dropped_args, ) # Filter to only allowed fields @@ -656,13 +673,165 @@ class OpenCodeAdapter(PlatformAdapter): result = text for claude_name, opencode_name in self._OPENCODE_TOOL_NAME_MAP.items(): result = re.sub( - rf'\b{claude_name}\b(?=\s+tool|\s+command)', + rf"\b{claude_name}\b(?=\s+tool|\s+command)", opencode_name, result, - flags=re.IGNORECASE + flags=re.IGNORECASE, ) return result + # --- Inline Reference Resolution --- + # Patterns for files that should be inlined when referenced via relative paths. + # These directories contain shared content (anti-rationalization tables, pressure + # resistance, standards, etc.) that agents/skills reference but that won't exist + # at the installed location due to directory hierarchy inversion. + _INLINE_PATH_PATTERNS = [ + "shared-patterns/", + "docs/standards/", + "docs/regulatory/", + "docs/infrastructure", + ] + + # Maximum file size to inline (256KB) — prevents accidental inclusion of huge files + _MAX_INLINE_SIZE = 256 * 1024 + + # Track already-inlined files per transform call to prevent infinite recursion + # when inlined files themselves reference other files. + _MAX_INLINE_DEPTH = 2 + + def _resolve_inline_references( + self, + body: str, + source_path: Optional[str], + depth: int = 0, + ) -> str: + """ + Resolve relative-path markdown references by inlining the referenced file content. + + Finds markdown links like [text](../shared-patterns/foo.md) where the target + is a shared-pattern or docs file, reads the source file from the Ring repo, + and replaces the link with the actual content. This makes each installed + component self-contained — no broken references at runtime. + + Only resolves references to paths matching _INLINE_PATH_PATTERNS. + Skips URLs (http/https), anchors (#), and already-inlined content. + + Args: + body: The markdown body content + source_path: Absolute path to the original source file in the Ring repo. + Used to resolve relative references. + depth: Current recursion depth (prevents infinite loops) + + Returns: + Body with resolvable references replaced by inlined content + """ + if not source_path or depth >= self._MAX_INLINE_DEPTH: + return body + + source_dir = Path(source_path).parent + + # Match markdown links: [text](path) + # Capture: full match, link text, link path + link_pattern = re.compile(r"\[([^\]]*)\]\(([^)]+)\)") + + def _should_inline(link_path: str) -> bool: + """Check if a link path should be inlined.""" + # Skip URLs, anchors, and non-file references + if link_path.startswith(("http://", "https://", "#", "mailto:")): + return False + # Only inline paths matching our patterns + return any(pat in link_path for pat in self._INLINE_PATH_PATTERNS) + + def _read_referenced_file(link_path: str) -> Optional[str]: + """Resolve and read a referenced file relative to the source.""" + try: + resolved = (source_dir / link_path).resolve() + # Safety: must exist and be a file + if not resolved.is_file(): + return None + # Safety: don't inline huge files + if resolved.stat().st_size > self._MAX_INLINE_SIZE: + logger.warning( + "Skipping inline for %s: file too large (%d bytes)", + link_path, + resolved.stat().st_size, + ) + return None + content = resolved.read_text(encoding="utf-8") + # Recursively resolve references in the inlined content + if depth + 1 < self._MAX_INLINE_DEPTH: + content = self._resolve_inline_references(content, str(resolved), depth + 1) + return content + except (OSError, UnicodeDecodeError) as e: + logger.debug("Could not inline %s: %s", link_path, e) + return None + + lines = body.split("\n") + result_lines: List[str] = [] + inlined_count = 0 + + for line in lines: + matches = list(link_pattern.finditer(line)) + if not matches: + result_lines.append(line) + continue + + # Check if any link on this line should be inlined + inlined_this_line = False + for match in matches: + link_text = match.group(1) + link_path = match.group(2) + + if not _should_inline(link_path): + continue + + content = _read_referenced_file(link_path) + if content is None: + continue + + # Determine replacement strategy based on line context + stripped = line.strip() + full_match = match.group(0) + + # Case 1: Line is ONLY the link (possibly with "See " prefix) + # e.g., "See [foo.md](../shared-patterns/foo.md)" + # Replace entire line with inlined content + is_standalone = ( + stripped == full_match + or stripped.startswith(("See ", "see ", "Refer to ", "Reference: ")) + and full_match in stripped + and len(stripped) - len(full_match) < 30 + ) + + if is_standalone: + # Replace the entire line with the file content + # Add a subtle marker for debugging + file_name = Path(link_path).name + result_lines.append(f"") + result_lines.append(content.rstrip()) + inlined_this_line = True + inlined_count += 1 + break # Only one inline per line + else: + # Case 2: Link is embedded in a larger sentence + # Append the content after the line + result_lines.append(line) + file_name = Path(link_path).name + result_lines.append("") + result_lines.append(f"") + result_lines.append(content.rstrip()) + inlined_this_line = True + inlined_count += 1 + break + + if not inlined_this_line: + result_lines.append(line) + + if inlined_count > 0: + logger.debug("Inlined %d reference(s) from %s", inlined_count, source_path) + + return "\n".join(result_lines) + def get_target_filename(self, source_filename: str, component_type: str) -> str: """ Get the target filename for a component in OpenCode. @@ -695,7 +864,7 @@ class OpenCodeAdapter(PlatformAdapter): self, hooks_config: Dict[str, Any], dry_run: bool = False, - install_path: Optional[Path] = None + install_path: Optional[Path] = None, ) -> bool: """ Merge hooks configuration into OpenCode's config file. @@ -732,6 +901,6 @@ class OpenCodeAdapter(PlatformAdapter): "OpenCode uses plugin-based hooks (tool.execute.before, etc.) " "which are incompatible with Ring's file-based hooks. " "Hooks will not be installed.", - hook_count + hook_count, ) return True diff --git a/installer/ring_installer/core.py b/installer/ring_installer/core.py index 5f4cc48b..919d1ab0 100644 --- a/installer/ring_installer/core.py +++ b/installer/ring_installer/core.py @@ -52,6 +52,7 @@ MAX_FILE_SIZE = 10 * 1024 * 1024 class InstallStatus(Enum): """Status of an installation operation.""" + SUCCESS = "success" PARTIAL = "partial" FAILED = "failed" @@ -69,6 +70,7 @@ class InstallTarget: components: List of component types to install (agents, commands, skills) If None, installs all components. """ + platform: str path: Optional[Path] = None components: Optional[List[str]] = None @@ -89,9 +91,7 @@ class InstallTarget: except ValueError: # Path not under home - check if it's a reasonable location allowed = [Path("/opt"), Path("/usr/local"), Path(tempfile.gettempdir()).resolve()] - if not any( - self.path.is_relative_to(p) for p in allowed if p.exists() - ): + if not any(self.path.is_relative_to(p) for p in allowed if p.exists()): warnings.warn( f"Install path is outside recommended locations: {self.path}", RuntimeWarning, @@ -112,7 +112,12 @@ class InstallOptions: plugin_names: List of plugin names to install (None = all) exclude_plugins: List of plugin names to exclude rollback_on_failure: If True, remove files written in a failed install pass + link: If True, build transformed files in-repo and create symlinks + instead of copying files to the platform config directory. + Build output: /.ring-build// + Symlinks: /{agent,command,skill} -> build dir """ + dry_run: bool = False force: bool = False backup: bool = True @@ -120,11 +125,13 @@ class InstallOptions: plugin_names: Optional[List[str]] = None exclude_plugins: Optional[List[str]] = None rollback_on_failure: bool = True + link: bool = False @dataclass(slots=True) class ComponentResult: """Result of installing a single component.""" + source_path: Path target_path: Path status: InstallStatus @@ -150,6 +157,7 @@ class InstallResult: details: Detailed results per component timestamp: When the installation was performed """ + status: InstallStatus targets: List[str] = field(default_factory=list) components_installed: int = 0 @@ -164,12 +172,14 @@ class InstallResult: def add_success(self, source: Path, target: Path, backup: Optional[Path] = None) -> None: """Record a successful component installation.""" self.components_installed += 1 - self.details.append(ComponentResult( - source_path=source, - target_path=target, - status=InstallStatus.SUCCESS, - backup_path=backup - )) + self.details.append( + ComponentResult( + source_path=source, + target_path=target, + status=InstallStatus.SUCCESS, + backup_path=backup, + ) + ) def add_failure( self, @@ -177,7 +187,7 @@ class InstallResult: target: Path, message: str, exc_info: Optional[BaseException] = None, - include_traceback: bool = True + include_traceback: bool = True, ) -> None: """Record a failed component installation. @@ -197,34 +207,40 @@ class InstallResult: traceback.format_exception(type(exc_info), exc_info, exc_info.__traceback__) ) - self.details.append(ComponentResult( - source_path=source, - target_path=target, - status=InstallStatus.FAILED, - message=message, - traceback_str=traceback_str - )) + self.details.append( + ComponentResult( + source_path=source, + target_path=target, + status=InstallStatus.FAILED, + message=message, + traceback_str=traceback_str, + ) + ) def add_skip(self, source: Path, target: Path, message: str) -> None: """Record a skipped component.""" self.components_skipped += 1 self.warnings.append(f"{source}: {message}") - self.details.append(ComponentResult( - source_path=source, - target_path=target, - status=InstallStatus.SKIPPED, - message=message - )) + self.details.append( + ComponentResult( + source_path=source, + target_path=target, + status=InstallStatus.SKIPPED, + message=message, + ) + ) def add_removal(self, target: Path, message: str = "") -> None: """Record a successful component removal.""" self.components_removed += 1 - self.details.append(ComponentResult( - source_path=Path(""), # No source for removals - target_path=target, - status=InstallStatus.SUCCESS, - message=message or "Removed" - )) + self.details.append( + ComponentResult( + source_path=Path(""), # No source for removals + target_path=target, + status=InstallStatus.SUCCESS, + message=message or "Removed", + ) + ) def finalize(self) -> None: """Set the overall status based on component results.""" @@ -240,9 +256,7 @@ class InstallResult: def _validate_marketplace_schema( - marketplace: Dict[str, Any], - marketplace_path: Path, - ring_path: Path + marketplace: Dict[str, Any], marketplace_path: Path, ring_path: Path ) -> None: """ Validate the marketplace.json schema. @@ -271,9 +285,7 @@ def _validate_marketplace_schema( for i, plugin in enumerate(plugins): if not isinstance(plugin, dict): - raise ValueError( - f"Invalid marketplace.json: plugin at index {i} must be an object" - ) + raise ValueError(f"Invalid marketplace.json: plugin at index {i} must be an object") # Required fields if "name" not in plugin: @@ -391,8 +403,11 @@ def _verify_marketplace_integrity(ring_path: Path) -> None: ) -def discover_ring_components(ring_path: Path, plugin_names: Optional[List[str]] = None, - exclude_plugins: Optional[List[str]] = None) -> Dict[str, Dict[str, List[Path]]]: +def discover_ring_components( + ring_path: Path, + plugin_names: Optional[List[str]] = None, + exclude_plugins: Optional[List[str]] = None, +) -> Dict[str, Dict[str, List[Path]]]: """ Discover Ring components (skills, agents, commands) from a Ring installation. @@ -470,12 +485,7 @@ def _discover_plugin_components(plugin_path: Path) -> Dict[str, List[Path]]: Returns: Dictionary mapping component types to file paths """ - result: Dict[str, List[Path]] = { - "agents": [], - "commands": [], - "skills": [], - "hooks": [] - } + result: Dict[str, List[Path]] = {"agents": [], "commands": [], "skills": [], "hooks": []} # Discover agents agents_dir = plugin_path / "agents" @@ -509,7 +519,7 @@ def install( source_path: Path, targets: List[InstallTarget], options: Optional[InstallOptions] = None, - progress_callback: Optional[Callable[[str, int, int], None]] = None + progress_callback: Optional[Callable[[str, int, int], None]] = None, ) -> InstallResult: """ Install Ring components to one or more target platforms. @@ -526,8 +536,11 @@ def install( """ from ring_installer.utils.fs import ( backup_existing, + clean_build_dir, copy_with_transform, + create_directory_symlink, ensure_directory, + get_build_dir, safe_remove, ) @@ -542,14 +555,13 @@ def install( # Discover components components = discover_ring_components( - source_path, - plugin_names=options.plugin_names, - exclude_plugins=options.exclude_plugins + source_path, plugin_names=options.plugin_names, exclude_plugins=options.exclude_plugins ) # Calculate total work total_components = sum( - len(files) for plugin_components in components.values() + len(files) + for plugin_components in components.values() for files in plugin_components.values() ) * len(targets) @@ -562,7 +574,22 @@ def install( adapter = get_adapter(target.platform, manifest.get("platforms", {}).get(target.platform)) install_path = target.path or adapter.get_install_path() component_mapping = adapter.get_component_mapping() - + + # --- Link mode setup --- + # When link=True, write transformed files to .ring-build// inside the + # Ring repo, then create directory symlinks from the platform config dir. + # This allows `git pull` + rebuild to instantly update all installed components. + build_dir: Optional[Path] = None + write_base = install_path # default: write directly to platform config + symlink_targets: Dict[str, Path] = {} # component_dir -> build_dir subpath + + if options.link: + build_dir = get_build_dir(source_path, target.platform) + write_base = build_dir + if not options.dry_run: + clean_build_dir(build_dir) + logger.info("Link mode: building to %s, will symlink from %s", build_dir, install_path) + # Collect hooks.json configs for platforms that need hooks merged into settings hooks_configs_to_merge: List[Dict[str, Any]] = [] @@ -579,7 +606,13 @@ def install( continue target_config = component_mapping[component_type] - target_dir = install_path / target_config["target_dir"] + target_dir = write_base / target_config["target_dir"] + + # Track component dirs for symlink creation in link mode + if options.link and build_dir is not None: + comp_dir_name = target_config["target_dir"] + if comp_dir_name not in symlink_targets: + symlink_targets[comp_dir_name] = build_dir / comp_dir_name # Check if platform requires flat structure for this component type requires_flat = adapter.requires_flat_components(component_type) @@ -602,7 +635,7 @@ def install( progress_callback( f"Installing {source_file.name} to {target.platform}", current, - total_components + total_components, ) # Determine target filename @@ -616,11 +649,13 @@ def install( target_file = target_dir / skill_name / source_file.name elif component_type == "hooks": # Check if platform needs hooks.json merged into settings instead of installed as file - if (hasattr(adapter, "should_skip_hook_file") and - adapter.should_skip_hook_file(source_file.name)): + if hasattr( + adapter, "should_skip_hook_file" + ) and adapter.should_skip_hook_file(source_file.name): # Collect hooks.json content for later merge into settings try: import json + hooks_content = source_file.read_text(encoding="utf-8") # Transform hook paths before merge if hasattr(adapter, "transform_hook"): @@ -632,7 +667,7 @@ def install( source_file, Path("settings.json"), f"Failed to parse hooks.json: {e}", - exc_info=e + exc_info=e, ) continue # Skip installing hooks.json as a file # Hooks can have multiple extensions (.json/.sh/.py). Preserve the original filename. @@ -642,22 +677,20 @@ def install( target_filename = adapter.get_flat_filename( source_file.name, component_type.rstrip("s"), # agents -> agent - plugin_name + plugin_name, ) target_file = target_dir / target_filename else: target_filename = adapter.get_target_filename( source_file.name, - component_type.rstrip("s") # agents -> agent + component_type.rstrip("s"), # agents -> agent ) target_file = target_dir / target_filename # Check if target exists if target_file.exists() and not options.force: result.add_skip( - source_file, - target_file, - "File exists (use --force to overwrite)" + source_file, target_file, "File exists (use --force to overwrite)" ) continue @@ -673,7 +706,7 @@ def install( result.add_failure( source_file, target_file, - f"File too large ({file_size} bytes, max {MAX_FILE_SIZE})" + f"File too large ({file_size} bytes, max {MAX_FILE_SIZE})", ) continue @@ -692,7 +725,7 @@ def install( metadata = { "name": metadata_name, "source_path": str(source_file), - "plugin": plugin_name + "plugin": plugin_name, } if component_type == "agents": @@ -708,13 +741,15 @@ def install( result.add_skip( source_file, target_file, - "Platform does not support file-based hooks" + "Platform does not support file-based hooks", ) continue else: transformed = content except Exception as e: - result.add_failure(source_file, target_file, f"Transform error: {e}", exc_info=e) + result.add_failure( + source_file, target_file, f"Transform error: {e}", exc_info=e + ) continue # Write transformed content @@ -734,7 +769,9 @@ def install( result.add_success(source_file, target_file, backup_path) installed_paths.append(target_file) except Exception as e: - result.add_failure(source_file, target_file, f"Write error: {e}", exc_info=e) + result.add_failure( + source_file, target_file, f"Write error: {e}", exc_info=e + ) # Merge collected hooks.json configs into settings for platforms that require it if hooks_configs_to_merge and hasattr(adapter, "merge_hooks_to_settings"): @@ -753,13 +790,17 @@ def install( if h.get("hooks") } for entry in entries: - cmd = entry.get("hooks", [{}])[0].get("command", "") if entry.get("hooks") else "" + cmd = ( + entry.get("hooks", [{}])[0].get("command", "") + if entry.get("hooks") + else "" + ) matcher = entry.get("matcher", "") key = (cmd, matcher) if cmd and key not in existing_hooks: merged_config["hooks"][event].append(entry) existing_hooks.add(key) - + if adapter.merge_hooks_to_settings(merged_config, options.dry_run, install_path): settings_path = install_path / "settings.json" if options.dry_run: @@ -769,6 +810,36 @@ def install( else: result.warnings.append("Failed to merge hooks into settings.json") + # --- Link mode: create directory symlinks --- + # After all files are written to .ring-build//, create symlinks + # from the platform config dir pointing to each component subdirectory. + if options.link and symlink_targets and build_dir is not None: + for comp_dir_name, build_subdir in symlink_targets.items(): + link_path = install_path / comp_dir_name + if options.dry_run: + result.warnings.append(f"[DRY RUN] Would symlink {link_path} -> {build_subdir}") + else: + try: + create_directory_symlink( + link_path=link_path, + target_path=build_subdir, + force=options.force, + backup=options.backup, + ) + result.warnings.append(f"Symlinked {link_path} -> {build_subdir}") + except FileExistsError as e: + result.warnings.append( + f"Symlink skipped ({comp_dir_name}): {e}. " + f"Use --force to replace existing directory." + ) + except Exception as e: + result.add_failure( + build_subdir, + link_path, + f"Symlink creation failed: {e}", + exc_info=e, + ) + # Roll back partial target installs when failures occur if not options.dry_run and options.rollback_on_failure: if result.components_failed > target_failures_before and installed_paths: @@ -789,7 +860,7 @@ def update( source_path: Path, targets: List[InstallTarget], options: Optional[InstallOptions] = None, - progress_callback: Optional[Callable[[str, int, int], None]] = None + progress_callback: Optional[Callable[[str, int, int], None]] = None, ) -> InstallResult: """ Update Ring components on target platforms. @@ -813,7 +884,7 @@ def update( def uninstall( targets: List[InstallTarget], options: Optional[InstallOptions] = None, - progress_callback: Optional[Callable[[str, int, int], None]] = None + progress_callback: Optional[Callable[[str, int, int], None]] = None, ) -> InstallResult: """ Remove Ring components from target platforms. @@ -832,7 +903,9 @@ def uninstall( platform_manifest = load_manifest() for target in targets: - adapter = get_adapter(target.platform, platform_manifest.get("platforms", {}).get(target.platform)) + adapter = get_adapter( + target.platform, platform_manifest.get("platforms", {}).get(target.platform) + ) install_path = target.path or adapter.get_install_path() component_mapping = adapter.get_component_mapping() @@ -852,6 +925,7 @@ def uninstall( # Create backup if requested if options.backup: from ring_installer.utils.fs import backup_existing + backup_existing(target_dir) # Remove directory @@ -914,6 +988,7 @@ class UpdateCheckResult: new_files: New files to be added removed_files: Files to be removed """ + platform: str installed_version: Optional[str] available_version: Optional[str] @@ -928,10 +1003,7 @@ class UpdateCheckResult: return bool(self.changed_files or self.new_files or self.removed_files) -def check_updates( - source_path: Path, - targets: List[InstallTarget] -) -> Dict[str, UpdateCheckResult]: +def check_updates(source_path: Path, targets: List[InstallTarget]) -> Dict[str, UpdateCheckResult]: """ Check for available updates on target platforms. @@ -968,7 +1040,7 @@ def update_with_diff( source_path: Path, targets: List[InstallTarget], options: Optional[InstallOptions] = None, - progress_callback: Optional[Callable[[str, int, int], None]] = None + progress_callback: Optional[Callable[[str, int, int], None]] = None, ) -> InstallResult: """ Update Ring components, only updating changed files. @@ -1006,9 +1078,7 @@ def update_with_diff( # Discover components components = discover_ring_components( - source_path, - plugin_names=options.plugin_names, - exclude_plugins=options.exclude_plugins + source_path, plugin_names=options.plugin_names, exclude_plugins=options.exclude_plugins ) # Process each target @@ -1057,7 +1127,7 @@ def update_with_diff( progress_callback( f"Checking {source_file.name}", result.components_installed + result.components_skipped, - sum(len(f) for pc in components.values() for f in pc.values()) + sum(len(f) for pc in components.values() for f in pc.values()), ) # Determine target path @@ -1073,15 +1143,12 @@ def update_with_diff( elif requires_flat and len(components) > 1: # Platform requires flat structure - use prefixed filename target_filename = adapter.get_flat_filename( - source_file.name, - component_type.rstrip("s"), - plugin_name + source_file.name, component_type.rstrip("s"), plugin_name ) target_file = target_dir / target_filename else: target_filename = adapter.get_target_filename( - source_file.name, - component_type.rstrip("s") + source_file.name, component_type.rstrip("s") ) target_file = target_dir / target_filename @@ -1139,7 +1206,7 @@ def update_with_diff( metadata = { "name": metadata_name, "source_path": str(source_file), - "plugin": plugin_name + "plugin": plugin_name, } if component_type == "agents": @@ -1155,7 +1222,7 @@ def update_with_diff( result.add_skip( source_file, target_file, - "Platform does not support file-based hooks" + "Platform does not support file-based hooks", ) continue else: @@ -1203,7 +1270,7 @@ def update_with_diff( platform=target.platform, version=source_version, plugins=installed_plugins, - installed_files=installed_files + installed_files=installed_files, ) result.finalize() @@ -1221,6 +1288,7 @@ class SyncResult: drift_detected: Whether drift was detected between platforms drift_details: Details about drift per platform """ + platforms_synced: List[str] = field(default_factory=list) platforms_skipped: List[str] = field(default_factory=list) drift_detected: bool = False @@ -1232,7 +1300,7 @@ def sync_platforms( source_path: Path, targets: List[InstallTarget], options: Optional[InstallOptions] = None, - progress_callback: Optional[Callable[[str, int, int], None]] = None + progress_callback: Optional[Callable[[str, int, int], None]] = None, ) -> SyncResult: """ Sync Ring components across multiple platforms. @@ -1275,19 +1343,10 @@ def sync_platforms( # Sync each platform for target in targets: if progress_callback: - progress_callback( - f"Syncing {target.platform}", - targets.index(target), - len(targets) - ) + progress_callback(f"Syncing {target.platform}", targets.index(target), len(targets)) # Use update_with_diff for smart syncing - install_result = update_with_diff( - source_path, - [target], - options, - progress_callback - ) + install_result = update_with_diff(source_path, [target], options, progress_callback) sync_result.install_results[target.platform] = install_result @@ -1302,7 +1361,7 @@ def sync_platforms( def uninstall_with_manifest( targets: List[InstallTarget], options: Optional[InstallOptions] = None, - progress_callback: Optional[Callable[[str, int, int], None]] = None + progress_callback: Optional[Callable[[str, int, int], None]] = None, ) -> InstallResult: """ Remove Ring components using the install manifest for precision. @@ -1319,6 +1378,7 @@ def uninstall_with_manifest( InstallResult with details about what was removed """ from ring_installer.utils.fs import backup_existing, safe_remove + options = options or InstallOptions() result = InstallResult(status=InstallStatus.SUCCESS, targets=[t.platform for t in targets]) @@ -1349,6 +1409,7 @@ def uninstall_with_manifest( try: if options.backup: from ring_installer.utils.fs import backup_existing + backup_existing(target_dir) shutil.rmtree(target_dir) result.add_removal(target_dir) @@ -1365,7 +1426,7 @@ def uninstall_with_manifest( progress_callback( f"Removing {file_path}", list(install_manifest.files.keys()).index(file_path), - len(install_manifest.files) + len(install_manifest.files), ) if options.dry_run: @@ -1381,6 +1442,7 @@ def uninstall_with_manifest( try: if options.backup: from ring_installer.utils.fs import backup_existing + backup_existing(full_path) safe_remove(full_path) diff --git a/installer/ring_installer/utils/fs.py b/installer/ring_installer/utils/fs.py index e4bfbdd2..63790dc6 100644 --- a/installer/ring_installer/utils/fs.py +++ b/installer/ring_installer/utils/fs.py @@ -92,7 +92,7 @@ def copy_with_transform( source: Path, target: Path, transform_func: Optional[Callable[[str], str]] = None, - encoding: str = "utf-8" + encoding: str = "utf-8", ) -> Path: """ Copy a file with optional content transformation. @@ -264,9 +264,7 @@ def get_directory_size(path: Path) -> int: def list_files_recursive( - path: Path, - extensions: Optional[list[str]] = None, - exclude_patterns: Optional[list[str]] = None + path: Path, extensions: Optional[list[str]] = None, exclude_patterns: Optional[list[str]] = None ) -> list[Path]: """ List all files in a directory recursively. @@ -342,11 +340,7 @@ def atomic_write(path: Path, content: Union[str, bytes], encoding: str = "utf-8" ensure_directory(path.parent) # Use tempfile for secure random filename - fd, temp_path_str = tempfile.mkstemp( - dir=path.parent, - prefix=f".{path.name}.", - suffix=".tmp" - ) + fd, temp_path_str = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp") temp_path = Path(temp_path_str) try: @@ -373,6 +367,121 @@ def atomic_write(path: Path, content: Union[str, bytes], encoding: str = "utf-8" temp_path.unlink() +def create_directory_symlink( + link_path: Path, + target_path: Path, + force: bool = False, + backup: bool = True, +) -> Path: + """ + Create a directory symlink, handling existing directories safely. + + If link_path already exists: + - As a symlink pointing to target_path: no-op (idempotent) + - As a symlink pointing elsewhere: remove and re-create (if force) + - As a regular directory: back up and replace (if force) + - As a file: raise ValueError + + Args: + link_path: Where the symlink should be created + target_path: The directory the symlink should point to (must exist) + force: If True, replace existing directories/symlinks + backup: If True, back up existing directories before replacing + + Returns: + The created symlink path + + Raises: + FileNotFoundError: If target_path doesn't exist + FileExistsError: If link_path exists and force is False + ValueError: If link_path is a regular file (not a directory or symlink) + """ + link_path = Path(link_path).expanduser() + target_path = Path(target_path).expanduser().resolve() + + if not target_path.exists(): + raise FileNotFoundError(f"Symlink target does not exist: {target_path}") + + if not target_path.is_dir(): + raise ValueError(f"Symlink target is not a directory: {target_path}") + + # Idempotent: already linked correctly + if link_path.is_symlink(): + existing_target = link_path.resolve() + if existing_target == target_path: + logger.debug("Symlink already correct: %s -> %s", link_path, target_path) + return link_path + + # Points elsewhere + if not force: + raise FileExistsError( + f"Symlink exists but points to {existing_target}, not {target_path}. " + f"Use --force to replace." + ) + link_path.unlink() + logger.info("Replaced stale symlink: %s (was -> %s)", link_path, existing_target) + + elif link_path.exists(): + if link_path.is_file(): + raise ValueError(f"Cannot create directory symlink: {link_path} is a regular file") + + if not force: + raise FileExistsError( + f"Directory exists at {link_path}. Use --force to replace with symlink." + ) + + # Back up the existing directory + if backup: + backup_path = backup_existing(link_path) + if backup_path: + logger.info("Backed up existing directory: %s -> %s", link_path, backup_path) + + # Remove the existing directory + shutil.rmtree(link_path) + logger.info("Removed existing directory: %s", link_path) + + # Ensure parent exists + ensure_directory(link_path.parent) + + # Create the symlink + link_path.symlink_to(target_path, target_is_directory=True) + logger.info("Created symlink: %s -> %s", link_path, target_path) + + return link_path + + +def get_build_dir(source_path: Path, platform: str) -> Path: + """ + Get the build output directory for a platform's symlink install. + + Build directories live inside the Ring repo at .ring-build// + and contain transformed files that are then symlinked from the platform's + config directory. + + Args: + source_path: Path to the Ring repository root + platform: Platform identifier (e.g., "opencode", "claude") + + Returns: + Path to the build directory (may not exist yet) + """ + return Path(source_path).expanduser().resolve() / ".ring-build" / platform + + +def clean_build_dir(build_dir: Path) -> None: + """ + Remove and recreate a build directory for a fresh build. + + Args: + build_dir: Path to the build directory + """ + build_dir = Path(build_dir).expanduser() + if build_dir.exists(): + shutil.rmtree(build_dir) + logger.debug("Cleaned build directory: %s", build_dir) + build_dir.mkdir(parents=True, exist_ok=True) + + def _is_binary_file(path: Path, sample_size: int = 8192) -> bool: """ Check if a file appears to be binary. @@ -386,10 +495,24 @@ def _is_binary_file(path: Path, sample_size: int = 8192) -> bool: """ # Known binary extensions binary_extensions = { - ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", - ".pdf", ".zip", ".tar", ".gz", ".bz2", - ".exe", ".dll", ".so", ".dylib", - ".pyc", ".pyo", ".class", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".bmp", + ".ico", + ".pdf", + ".zip", + ".tar", + ".gz", + ".bz2", + ".exe", + ".dll", + ".so", + ".dylib", + ".pyc", + ".pyo", + ".class", } if path.suffix.lower() in binary_extensions: