mirror of
https://github.com/LerianStudio/ring
synced 2026-04-21 13:37:27 +00:00
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:
parent
c051c79939
commit
7612f45d97
6 changed files with 687 additions and 206 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -26,6 +26,9 @@ dist/
|
|||
# Ring infrastructure runtime files
|
||||
.ring/
|
||||
|
||||
# Ring build output (transformed files for symlink installs)
|
||||
.ring-build/
|
||||
|
||||
.codegraph/
|
||||
|
||||
node_modules/
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue