feat(installer): add symlink install mode for faster developer iteration

feat(opencode): inline shared content to make installed components portable
This commit is contained in:
Fred Amaral 2026-03-08 14:42:25 -03:00
parent c051c79939
commit 7612f45d97
No known key found for this signature in database
GPG key ID: ADFE56C96F4AC56A
6 changed files with 687 additions and 206 deletions

3
.gitignore vendored
View file

@ -26,6 +26,9 @@ dist/
# Ring infrastructure runtime files
.ring/
# Ring build output (transformed files for symlink installs)
.ring-build/
.codegraph/
node_modules/

View file

@ -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 ""

View file

@ -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:

View file

@ -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"<!-- inlined: {file_name} -->")
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"<!-- inlined: {file_name} -->")
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

View file

@ -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: <source>/.ring-build/<platform>/
Symlinks: <config>/{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/<platform>/ 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/<platform>/, 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)

View file

@ -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/<platform>/
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: