mirror of
https://github.com/LerianStudio/ring
synced 2026-04-21 13:37:27 +00:00
feat(installer): introduce python-based multi-platform installer
refactor(installer): introduce adapter and transformer architecture fix(security): harden installer against path traversal and symlinks docs: add comprehensive guides for multi-platform support and migration fix(beads): improve stop hook logic to check for open issues
This commit is contained in:
parent
e10e3a4d03
commit
e3ece79ccf
46 changed files with 11809 additions and 57 deletions
63
CLAUDE.md
63
CLAUDE.md
|
|
@ -23,17 +23,62 @@ The architecture uses markdown-based skill definitions with YAML frontmatter, au
|
|||
|
||||
## Installation
|
||||
|
||||
Ring supports multiple AI platforms. The installer auto-detects installed platforms and transforms content appropriately.
|
||||
|
||||
### Supported Platforms
|
||||
|
||||
| Platform | Install Path | Format | Components |
|
||||
|----------|-------------|--------|------------|
|
||||
| Claude Code | `~/.claude/` | Native | skills, agents, commands, hooks |
|
||||
| Factory AI | `~/.factory/` | Transformed | skills, droids, commands |
|
||||
| Cursor | `~/.cursor/` | Transformed | .cursorrules, workflows |
|
||||
| Cline | `~/.cline/` | Transformed | prompts |
|
||||
|
||||
### Quick Install
|
||||
|
||||
```bash
|
||||
# Quick install via script (Linux/macOS/Git Bash)
|
||||
# Interactive installer (Linux/macOS/Git Bash)
|
||||
curl -fsSL https://raw.githubusercontent.com/lerianstudio/ring/main/install-ring.sh | bash
|
||||
|
||||
# Quick install via script (Windows PowerShell)
|
||||
# Interactive installer (Windows PowerShell)
|
||||
irm https://raw.githubusercontent.com/lerianstudio/ring/main/install-ring.ps1 | iex
|
||||
|
||||
# Or manual clone (marketplace with multiple plugins)
|
||||
# Or clone and run locally
|
||||
git clone https://github.com/lerianstudio/ring.git ~/ring
|
||||
cd ~/ring
|
||||
./installer/install-ring.sh
|
||||
```
|
||||
|
||||
### Platform-Specific Installation
|
||||
|
||||
```bash
|
||||
# Install to specific platform
|
||||
./installer/install-ring.sh install --platforms claude
|
||||
./installer/install-ring.sh install --platforms factory
|
||||
./installer/install-ring.sh install --platforms cursor
|
||||
./installer/install-ring.sh install --platforms cline
|
||||
|
||||
# Install to multiple platforms
|
||||
./installer/install-ring.sh install --platforms claude,cursor,cline
|
||||
|
||||
# Auto-detect and install all
|
||||
./installer/install-ring.sh install --platforms auto
|
||||
```
|
||||
|
||||
### Installer Commands
|
||||
|
||||
```bash
|
||||
./installer/install-ring.sh install # Install to platforms
|
||||
./installer/install-ring.sh update # Update existing installation
|
||||
./installer/install-ring.sh sync # Sync only changed files
|
||||
./installer/install-ring.sh list # List installed platforms
|
||||
./installer/install-ring.sh check # Check for updates
|
||||
./installer/install-ring.sh uninstall # Remove from platforms
|
||||
./installer/install-ring.sh detect # Detect available platforms
|
||||
```
|
||||
|
||||
See `docs/platforms/` for platform-specific guides.
|
||||
|
||||
## Architecture
|
||||
|
||||
**Monorepo Structure** - Marketplace with multiple plugin collections:
|
||||
|
|
@ -138,6 +183,18 @@ ring/ # Monorepo root
|
|||
│ ├── hooks.json # Hook configuration
|
||||
│ ├── session-start.sh # Context injection
|
||||
│ └── stop-hook.sh # Issue tracking integration
|
||||
├── installer/ # Multi-platform installer
|
||||
│ ├── ring_installer/ # Python package
|
||||
│ │ ├── adapters/ # Platform adapters (claude, factory, cursor, cline)
|
||||
│ │ ├── transformers/ # Content transformers
|
||||
│ │ ├── utils/ # Utilities (fs, version, platform_detect)
|
||||
│ │ └── core.py # Installation logic
|
||||
│ ├── tests/ # Test suite
|
||||
│ ├── install-ring.sh # Bash entry point
|
||||
│ └── install-ring.ps1 # PowerShell entry point
|
||||
├── docs/
|
||||
│ ├── plans/ # Implementation plans
|
||||
│ └── platforms/ # Platform-specific guides
|
||||
├── ops-team/ # Team-specific skills (reserved)
|
||||
└── pmm-team/ # Team-specific skills (reserved)
|
||||
```
|
||||
|
|
|
|||
122
README.md
122
README.md
|
|
@ -53,39 +53,111 @@ Ring solves this by:
|
|||
|
||||
*Plugin versions are managed in `.claude-plugin/marketplace.json`*
|
||||
|
||||
## 🖥️ Supported Platforms
|
||||
|
||||
Ring works across multiple AI development platforms:
|
||||
|
||||
| Platform | Format | Status | Features |
|
||||
|----------|--------|--------|----------|
|
||||
| **Claude Code** | Native | ✅ Source of truth | Skills, agents, commands, hooks |
|
||||
| **Factory AI** | Transformed | ✅ Supported | Droids, commands, skills |
|
||||
| **Cursor** | Transformed | ✅ Supported | Rules, workflows |
|
||||
| **Cline** | Transformed | ✅ Supported | Prompts |
|
||||
|
||||
**Transformation Notes:**
|
||||
- Claude Code receives Ring content in its native format
|
||||
- Factory AI: `agents` → `droids` terminology
|
||||
- Cursor: Skills/agents → `.cursorrules` and workflows
|
||||
- Cline: All content → structured prompts
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Installation as Claude Code Plugin
|
||||
### Multi-Platform Installation (Recommended)
|
||||
|
||||
1. **Quick Install Script** (Easiest)
|
||||
The Ring installer automatically detects installed platforms and transforms content appropriately.
|
||||
|
||||
**Linux/macOS/Git Bash:**
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/lerianstudio/ring/main/install-ring.sh | bash
|
||||
```
|
||||
**Linux/macOS/Git Bash:**
|
||||
```bash
|
||||
# Interactive installer (auto-detects platforms)
|
||||
curl -fsSL https://raw.githubusercontent.com/lerianstudio/ring/main/install-ring.sh | bash
|
||||
|
||||
**Windows PowerShell:**
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/lerianstudio/ring/main/install-ring.ps1 | iex
|
||||
```
|
||||
# Or clone and run locally
|
||||
git clone https://github.com/lerianstudio/ring.git ~/ring
|
||||
cd ~/ring
|
||||
./installer/install-ring.sh
|
||||
```
|
||||
|
||||
2. **Install from the Claude Code Plugin Marketplace** (Recommended)
|
||||
- Open Claude Code
|
||||
- Go to Settings → Plugins
|
||||
- Search for "ring"
|
||||
- Click Install
|
||||
**Windows PowerShell:**
|
||||
```powershell
|
||||
# Interactive installer (auto-detects platforms)
|
||||
irm https://raw.githubusercontent.com/lerianstudio/ring/main/install-ring.ps1 | iex
|
||||
|
||||
3. **Manual Installation**
|
||||
```bash
|
||||
# Clone the marketplace repository
|
||||
git clone https://github.com/lerianstudio/ring.git ~/ring
|
||||
# Or clone and run locally
|
||||
git clone https://github.com/lerianstudio/ring.git $HOME\ring
|
||||
cd $HOME\ring
|
||||
.\installer\install-ring.ps1
|
||||
```
|
||||
|
||||
# Install Python dependencies (optional, but recommended)
|
||||
cd ~/ring
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
**Note:** Python dependencies are optional. The hooks will use fallback parsers if PyYAML is unavailable.
|
||||
### Direct Platform Installation
|
||||
|
||||
Install to specific platforms without the interactive menu:
|
||||
|
||||
```bash
|
||||
# Install to Claude Code only (native format)
|
||||
./installer/install-ring.sh install --platforms claude
|
||||
|
||||
# Install to Factory AI only (droids format)
|
||||
./installer/install-ring.sh install --platforms factory
|
||||
|
||||
# Install to multiple platforms
|
||||
./installer/install-ring.sh install --platforms claude,cursor,cline
|
||||
|
||||
# Install to all detected platforms
|
||||
./installer/install-ring.sh install --platforms auto
|
||||
|
||||
# Dry run (preview changes without installing)
|
||||
./installer/install-ring.sh install --platforms auto --dry-run
|
||||
```
|
||||
|
||||
### Installer Commands
|
||||
|
||||
```bash
|
||||
# List installed platforms and versions
|
||||
./installer/install-ring.sh list
|
||||
|
||||
# Update existing installation
|
||||
./installer/install-ring.sh update
|
||||
|
||||
# Check for available updates
|
||||
./installer/install-ring.sh check
|
||||
|
||||
# Sync (update only changed files)
|
||||
./installer/install-ring.sh sync
|
||||
|
||||
# Uninstall from specific platform
|
||||
./installer/install-ring.sh uninstall --platforms cursor
|
||||
|
||||
# Detect available platforms
|
||||
./installer/install-ring.sh detect
|
||||
```
|
||||
|
||||
### Claude Code Plugin Marketplace
|
||||
|
||||
For Claude Code users, you can also install from the marketplace:
|
||||
- Open Claude Code
|
||||
- Go to Settings → Plugins
|
||||
- Search for "ring"
|
||||
- Click Install
|
||||
|
||||
### Manual Installation (Claude Code only)
|
||||
|
||||
```bash
|
||||
# Clone the marketplace repository
|
||||
git clone https://github.com/lerianstudio/ring.git ~/ring
|
||||
|
||||
# Skills auto-load at session start via hooks
|
||||
# No additional configuration needed for Claude Code
|
||||
```
|
||||
|
||||
### First Session
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,22 @@ EOF
|
|||
exit 0
|
||||
fi
|
||||
|
||||
# Check if checkpoint has been completed
|
||||
# Strategy: If there are open issues OR no uncommitted changes with markers, approve exit
|
||||
OPEN_ISSUES=$(bd status 2>/dev/null | grep -E "Total Issues:|Open:" | head -2 | tail -1 | awk '{print $2}' || echo "0")
|
||||
RECENT_MARKERS=$(git diff --name-only HEAD~5 2>/dev/null | head -20 | xargs grep -c -E "(TODO|FIXME|HACK|XXX):" 2>/dev/null | awk '{sum+=$1} END {print sum}' || echo "0")
|
||||
|
||||
# If we have open issues capturing work, or no markers found, allow exit
|
||||
if [[ "$OPEN_ISSUES" -gt 0 ]] || [[ "$RECENT_MARKERS" -eq 0 ]]; then
|
||||
cat <<'EOF'
|
||||
{
|
||||
"decision": "approve",
|
||||
"reason": "beads checkpoint complete - work captured in issues or no markers found"
|
||||
}
|
||||
EOF
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Beads is active - inject prompt to capture leftover work
|
||||
prompt='**BEADS END-OF-SESSION CHECKPOINT**
|
||||
|
||||
|
|
|
|||
|
|
@ -159,6 +159,50 @@ bd show ISSUE-ID --json # Single issue details
|
|||
| Search | `bd search "query"` |
|
||||
| Overview | `bd status` |
|
||||
|
||||
## End-of-Session Checkpoint (Stop Hook)
|
||||
|
||||
The beads plugin includes a Stop hook that automatically runs when you attempt to end a session. This hook ensures no work is lost by prompting you to capture leftover work as beads issues.
|
||||
|
||||
**How the checkpoint works:**
|
||||
|
||||
1. **Hook triggers** when you try to exit the session
|
||||
2. **Checks completion criteria:**
|
||||
- Are there open beads issues? (work captured)
|
||||
- Are there TODO/FIXME markers in recent changes? (work not captured)
|
||||
3. **Decision:**
|
||||
- **Approve exit** if work is captured OR no markers found
|
||||
- **Block exit** if markers exist but no issues created (prompts you to capture work)
|
||||
|
||||
**The checkpoint prompts you to:**
|
||||
- Search for TODO/FIXME/HACK/XXX markers in modified files
|
||||
- Check TodoWrite list for incomplete items
|
||||
- Review code review findings not yet addressed
|
||||
- Create beads issues for each piece of leftover work
|
||||
- Update in-progress issues if leaving work incomplete
|
||||
|
||||
**Example checkpoint flow:**
|
||||
```bash
|
||||
# You try to exit - hook blocks with checkpoint prompt
|
||||
# You run the checkpoint steps:
|
||||
git diff --name-only HEAD~5 | xargs grep -E "(TODO|FIXME):"
|
||||
# Found 3 TODOs
|
||||
|
||||
# Create issues for each:
|
||||
bd create "TODO: Refactor auth module" -t task -p 2
|
||||
bd create "FIXME: Handle edge case in parser" -t bug -p 1
|
||||
bd create "TODO: Add integration tests" -t task -p 3
|
||||
|
||||
# Check status
|
||||
bd status # Shows 3 new open issues
|
||||
|
||||
# Try to exit again - hook sees open issues, approves exit
|
||||
```
|
||||
|
||||
**When the hook approves exit:**
|
||||
- Open beads issues exist (indicating work was captured)
|
||||
- No TODO/FIXME markers in recent git changes
|
||||
- Beads not installed or not initialized
|
||||
|
||||
## Remember
|
||||
|
||||
- **`bd ready`** is your starting point - it shows what's actually actionable
|
||||
|
|
|
|||
279
docs/platforms/MIGRATION.md
Normal file
279
docs/platforms/MIGRATION.md
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
# Migration Guide
|
||||
|
||||
This guide helps you transition from a single-platform Ring installation to the multi-platform installer.
|
||||
|
||||
## Who Needs This Guide?
|
||||
|
||||
- Users with existing Ring installation in `~/.claude/`
|
||||
- Users switching from manual git clone to the installer
|
||||
- Users wanting to add Ring to additional platforms (Factory AI, Cursor, Cline)
|
||||
|
||||
## Pre-Migration Checklist
|
||||
|
||||
1. **Identify current installation:**
|
||||
```bash
|
||||
# Check if Ring is installed
|
||||
ls -la ~/.claude/plugins/ring/ 2>/dev/null || echo "No Claude Code installation"
|
||||
ls -la ~/.factory/ 2>/dev/null || echo "No Factory AI installation"
|
||||
ls -la ~/.cursor/.cursorrules 2>/dev/null || echo "No Cursor installation"
|
||||
ls -la ~/.cline/prompts/ 2>/dev/null || echo "No Cline installation"
|
||||
```
|
||||
|
||||
2. **Check Ring version:**
|
||||
```bash
|
||||
cat ~/.claude/plugins/ring/.claude-plugin/marketplace.json 2>/dev/null | grep version
|
||||
```
|
||||
|
||||
3. **Backup customizations (if any):**
|
||||
```bash
|
||||
# If you've customized any skills or agents
|
||||
cp -r ~/.claude/plugins/ring/custom-backup ~/ring-custom-backup
|
||||
```
|
||||
|
||||
## Migration Scenarios
|
||||
|
||||
### Scenario 1: Git Clone to Installer (Same Platform)
|
||||
|
||||
**Current state:** Ring cloned directly to `~/.claude/` or symlinked
|
||||
|
||||
**Steps:**
|
||||
1. Remove the old installation:
|
||||
```bash
|
||||
rm -rf ~/.claude/plugins/ring
|
||||
```
|
||||
|
||||
2. Clone Ring to a central location:
|
||||
```bash
|
||||
git clone https://github.com/lerianstudio/ring.git ~/ring
|
||||
```
|
||||
|
||||
3. Run the installer:
|
||||
```bash
|
||||
cd ~/ring
|
||||
./installer/install-ring.sh install --platforms claude
|
||||
```
|
||||
|
||||
4. Verify installation:
|
||||
```bash
|
||||
./installer/install-ring.sh list
|
||||
```
|
||||
|
||||
### Scenario 2: Claude Code to Multi-Platform
|
||||
|
||||
**Current state:** Ring only in Claude Code
|
||||
|
||||
**Steps:**
|
||||
1. Keep existing Claude Code installation (or reinstall)
|
||||
2. Add additional platforms:
|
||||
```bash
|
||||
cd ~/ring
|
||||
|
||||
# Add Factory AI
|
||||
./installer/install-ring.sh install --platforms factory
|
||||
|
||||
# Add Cursor
|
||||
./installer/install-ring.sh install --platforms cursor
|
||||
|
||||
# Add Cline
|
||||
./installer/install-ring.sh install --platforms cline
|
||||
```
|
||||
|
||||
3. Or install all at once:
|
||||
```bash
|
||||
./installer/install-ring.sh install --platforms auto
|
||||
```
|
||||
|
||||
### Scenario 3: Fresh Multi-Platform Install
|
||||
|
||||
**Current state:** No Ring installation
|
||||
|
||||
**Steps:**
|
||||
1. Clone Ring:
|
||||
```bash
|
||||
git clone https://github.com/lerianstudio/ring.git ~/ring
|
||||
cd ~/ring
|
||||
```
|
||||
|
||||
2. Run interactive installer:
|
||||
```bash
|
||||
./installer/install-ring.sh
|
||||
```
|
||||
|
||||
3. Select platforms from the menu (or choose "auto-detect")
|
||||
|
||||
### Scenario 4: Upgrading Existing Multi-Platform
|
||||
|
||||
**Current state:** Ring installed via installer to one or more platforms
|
||||
|
||||
**Steps:**
|
||||
1. Check for updates:
|
||||
```bash
|
||||
cd ~/ring
|
||||
git pull origin main
|
||||
./installer/install-ring.sh check
|
||||
```
|
||||
|
||||
2. Update all platforms:
|
||||
```bash
|
||||
./installer/install-ring.sh update
|
||||
```
|
||||
|
||||
3. Or sync only changed files:
|
||||
```bash
|
||||
./installer/install-ring.sh sync
|
||||
```
|
||||
|
||||
## Post-Migration Verification
|
||||
|
||||
### Claude Code
|
||||
```bash
|
||||
# Verify files exist
|
||||
ls ~/.claude/plugins/ring/default/skills/
|
||||
|
||||
# Start a new Claude Code session and check for:
|
||||
# "Ring skills loaded" message at session start
|
||||
```
|
||||
|
||||
### Factory AI
|
||||
```bash
|
||||
# Verify droids exist
|
||||
ls ~/.factory/droids/
|
||||
|
||||
# Start Factory AI and verify droid availability
|
||||
```
|
||||
|
||||
### Cursor
|
||||
```bash
|
||||
# Verify rules file
|
||||
cat ~/.cursor/.cursorrules | head -20
|
||||
|
||||
# Verify workflows
|
||||
ls ~/.cursor/workflows/
|
||||
|
||||
# Restart Cursor to load rules
|
||||
```
|
||||
|
||||
### Cline
|
||||
```bash
|
||||
# Verify prompts
|
||||
ls ~/.cline/prompts/skills/
|
||||
ls ~/.cline/prompts/workflows/
|
||||
|
||||
# Test by referencing a prompt in Cline
|
||||
```
|
||||
|
||||
## Handling Custom Content
|
||||
|
||||
### Preserving Custom Skills
|
||||
|
||||
If you've created custom skills:
|
||||
|
||||
1. **Before migration:** Copy to backup
|
||||
```bash
|
||||
cp -r ~/.claude/plugins/ring/default/skills/my-custom-skill ~/my-custom-skill-backup
|
||||
```
|
||||
|
||||
2. **After migration:** Restore
|
||||
```bash
|
||||
cp -r ~/my-custom-skill-backup ~/.claude/plugins/ring/default/skills/my-custom-skill
|
||||
```
|
||||
|
||||
3. **Re-run installer** to propagate to other platforms:
|
||||
```bash
|
||||
./installer/install-ring.sh sync
|
||||
```
|
||||
|
||||
### Custom Hooks
|
||||
|
||||
Custom hooks in `~/.claude/plugins/ring/*/hooks/` will be preserved during updates. The installer tracks file hashes and only updates Ring's original files.
|
||||
|
||||
### Platform-Specific Customizations
|
||||
|
||||
Custom content added to platform directories (e.g., `~/.cursor/workflows/my-workflow.md`) is not managed by Ring and will be preserved.
|
||||
|
||||
## Rollback Procedure
|
||||
|
||||
If something goes wrong:
|
||||
|
||||
### Full Rollback (Remove Ring)
|
||||
```bash
|
||||
# Uninstall from all platforms
|
||||
./installer/install-ring.sh uninstall --platforms claude,factory,cursor,cline
|
||||
|
||||
# Or manually remove
|
||||
rm -rf ~/.claude/plugins/ring
|
||||
rm -rf ~/.factory/droids ~/.factory/skills ~/.factory/commands ~/.factory/.ring-manifest.json
|
||||
rm -f ~/.cursor/.cursorrules && rm -rf ~/.cursor/workflows ~/.cursor/.ring-manifest.json
|
||||
rm -rf ~/.cline/prompts ~/.cline/.ring-manifest.json
|
||||
```
|
||||
|
||||
### Partial Rollback (Specific Platform)
|
||||
```bash
|
||||
./installer/install-ring.sh uninstall --platforms cursor
|
||||
```
|
||||
|
||||
### Restore from Git
|
||||
```bash
|
||||
# If installer state is corrupted, reinstall from scratch
|
||||
rm -rf ~/ring
|
||||
git clone https://github.com/lerianstudio/ring.git ~/ring
|
||||
cd ~/ring
|
||||
./installer/install-ring.sh install --platforms auto --force
|
||||
```
|
||||
|
||||
## Troubleshooting Migration Issues
|
||||
|
||||
### "Manifest not found" Error
|
||||
The installer uses `.ring-manifest.json` to track installations. If missing:
|
||||
```bash
|
||||
# Force reinstall
|
||||
./installer/install-ring.sh install --platforms <platform> --force
|
||||
```
|
||||
|
||||
### "Version mismatch" Warning
|
||||
```bash
|
||||
# Update local Ring repository
|
||||
cd ~/ring
|
||||
git pull origin main
|
||||
|
||||
# Then update installations
|
||||
./installer/install-ring.sh update
|
||||
```
|
||||
|
||||
### Hooks Not Running
|
||||
After migration, restart Claude Code/Factory AI to reload hooks.
|
||||
|
||||
### Cursor Rules Not Loading
|
||||
1. Verify file exists: `cat ~/.cursor/.cursorrules`
|
||||
2. Restart Cursor
|
||||
3. Check Cursor settings for rules enablement
|
||||
|
||||
### Permission Denied
|
||||
```bash
|
||||
# Fix permissions
|
||||
chmod +x ~/ring/installer/install-ring.sh
|
||||
chmod +x ~/ring/installer/install-ring.ps1
|
||||
```
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: Can I keep my manual installation alongside the installer?**
|
||||
A: Not recommended. The installer tracks state via manifest files. Manual changes may conflict.
|
||||
|
||||
**Q: Does migration delete my customizations?**
|
||||
A: No. The installer only manages Ring's files. Custom content is preserved.
|
||||
|
||||
**Q: Can I migrate one platform at a time?**
|
||||
A: Yes. Install platforms individually: `--platforms claude`, then later `--platforms cursor`, etc.
|
||||
|
||||
**Q: What if I use multiple Ring versions?**
|
||||
A: Not supported. All platforms install from the same Ring source (`~/ring`).
|
||||
|
||||
**Q: Can I install Ring to a custom location?**
|
||||
A: Claude Code plugin path is fixed. For other platforms, custom paths may be supported in future versions.
|
||||
|
||||
## Getting Help
|
||||
|
||||
- **GitHub Issues:** https://github.com/lerianstudio/ring/issues
|
||||
- **Installation logs:** Run with `--verbose` flag
|
||||
- **Dry run:** Test changes with `--dry-run` before applying
|
||||
169
docs/platforms/claude-code.md
Normal file
169
docs/platforms/claude-code.md
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
# Ring for Claude Code
|
||||
|
||||
Claude Code is the **native platform** for Ring. All skills, agents, commands, and hooks are designed primarily for Claude Code and require no transformation.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Interactive installer
|
||||
./installer/install-ring.sh
|
||||
|
||||
# Direct install
|
||||
./installer/install-ring.sh install --platforms claude
|
||||
|
||||
# Via curl (remote)
|
||||
curl -fsSL https://raw.githubusercontent.com/lerianstudio/ring/main/install-ring.sh | bash
|
||||
```
|
||||
|
||||
## Installation Path
|
||||
|
||||
Ring installs to: `~/.claude/`
|
||||
|
||||
```
|
||||
~/.claude/
|
||||
├── plugins/
|
||||
│ └── ring/ # Ring marketplace
|
||||
│ ├── default/ # Core plugin
|
||||
│ │ ├── skills/ # 20 core skills
|
||||
│ │ ├── agents/ # 5 specialized agents
|
||||
│ │ ├── commands/ # 6 slash commands
|
||||
│ │ └── hooks/ # Session lifecycle hooks
|
||||
│ ├── dev-team/ # Developer agents plugin
|
||||
│ ├── pm-team/ # Product planning plugin
|
||||
│ ├── finops-team/ # FinOps plugin
|
||||
│ ├── tw-team/ # Technical writing plugin
|
||||
│ ├── ralph-wiggum/ # Iterative loops plugin
|
||||
│ └── beads/ # Issue tracking plugin
|
||||
└── .ring-manifest.json # Installation tracking
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### Skills (46 total)
|
||||
|
||||
Skills are markdown files with YAML frontmatter that define workflows, processes, and best practices. They auto-load at session start.
|
||||
|
||||
**Invocation:**
|
||||
```
|
||||
Skill tool: "ring:test-driven-development"
|
||||
Skill tool: "ring:systematic-debugging"
|
||||
```
|
||||
|
||||
**Structure:**
|
||||
```yaml
|
||||
---
|
||||
name: skill-name
|
||||
description: |
|
||||
What this skill does
|
||||
trigger: |
|
||||
- When to use this skill
|
||||
skip_when: |
|
||||
- When NOT to use
|
||||
---
|
||||
|
||||
# Skill Content
|
||||
...
|
||||
```
|
||||
|
||||
### Agents (20 total)
|
||||
|
||||
Agents are specialized AI personas with defined expertise and output schemas.
|
||||
|
||||
**Invocation:**
|
||||
```
|
||||
Task tool with subagent_type="ring-default:code-reviewer"
|
||||
Task tool with subagent_type="ring-dev-team:backend-engineer-golang"
|
||||
```
|
||||
|
||||
**Categories:**
|
||||
- **Review agents**: code-reviewer, business-logic-reviewer, security-reviewer
|
||||
- **Planning agents**: write-plan, codebase-explorer
|
||||
- **Developer agents**: 10 language/role specialists
|
||||
- **FinOps agents**: finops-analyzer, finops-automation
|
||||
- **TW agents**: functional-writer, api-writer, docs-reviewer
|
||||
|
||||
### Commands (14 total)
|
||||
|
||||
Slash commands provide quick access to common workflows.
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
/ring-default:codereview # Parallel 3-reviewer dispatch
|
||||
/ring-default:brainstorm # Socratic design refinement
|
||||
/ring-pm-team:pre-dev-full # 8-gate planning workflow
|
||||
/ralph-wiggum:ralph-loop # Iterative AI loops
|
||||
```
|
||||
|
||||
### Hooks
|
||||
|
||||
Hooks run automatically at session events:
|
||||
- **SessionStart**: Load skills reference, inject context
|
||||
- **UserPromptSubmit**: Remind about CLAUDE.md
|
||||
- **Stop**: Ralph Wiggum loop continuation
|
||||
|
||||
## Session Lifecycle
|
||||
|
||||
1. **Session Start**: Ring hooks load, skill reference generated
|
||||
2. **Context Injection**: Plugin introductions (using-ring, using-dev-team, etc.)
|
||||
3. **Skill Discovery**: Check applicable skills before any task
|
||||
4. **Workflow Execution**: Use skills, agents, commands as needed
|
||||
5. **Verification**: Evidence-based completion
|
||||
|
||||
## Configuration
|
||||
|
||||
No additional configuration required. Ring auto-discovers plugins from `~/.claude/plugins/ring/`.
|
||||
|
||||
### Optional: Plugin Selection
|
||||
|
||||
To install specific plugins only:
|
||||
|
||||
```bash
|
||||
./installer/install-ring.sh install --platforms claude --plugins default,dev-team
|
||||
```
|
||||
|
||||
### Exclude Plugins
|
||||
|
||||
```bash
|
||||
./installer/install-ring.sh install --platforms claude --exclude finops-team,pm-team
|
||||
```
|
||||
|
||||
## Updating
|
||||
|
||||
```bash
|
||||
# Check for updates
|
||||
./installer/install-ring.sh check
|
||||
|
||||
# Update all plugins
|
||||
./installer/install-ring.sh update
|
||||
|
||||
# Sync only changed files
|
||||
./installer/install-ring.sh sync
|
||||
```
|
||||
|
||||
## Uninstalling
|
||||
|
||||
```bash
|
||||
./installer/install-ring.sh uninstall --platforms claude
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Skills not loading
|
||||
1. Check hooks are enabled: Settings → Hooks
|
||||
2. Verify installation: `./installer/install-ring.sh list`
|
||||
3. Check hook output: Look for "Ring skills loaded" at session start
|
||||
|
||||
### Agent not found
|
||||
1. Ensure plugin is installed that contains the agent
|
||||
2. Use fully qualified name: `ring-dev-team:backend-engineer-golang`
|
||||
|
||||
### Commands not available
|
||||
1. Commands require their parent plugin
|
||||
2. Check `/help` for available commands
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always check skills first**: Before any task, verify if a skill applies
|
||||
2. **Use parallel reviews**: Dispatch all 3 reviewers simultaneously
|
||||
3. **Follow TDD**: Test → Fail → Implement → Pass → Refactor
|
||||
4. **Evidence before claims**: Run verification, show output
|
||||
268
docs/platforms/cline.md
Normal file
268
docs/platforms/cline.md
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
# Ring for Cline
|
||||
|
||||
Cline uses **prompts** for workflow guidance. Ring transforms skills, agents, and commands into structured prompts during installation.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Interactive installer
|
||||
./installer/install-ring.sh
|
||||
|
||||
# Direct install
|
||||
./installer/install-ring.sh install --platforms cline
|
||||
|
||||
# Multiple platforms
|
||||
./installer/install-ring.sh install --platforms claude,cline
|
||||
```
|
||||
|
||||
## Installation Path
|
||||
|
||||
Ring installs to: `~/.cline/`
|
||||
|
||||
```
|
||||
~/.cline/
|
||||
├── prompts/ # All content as prompts
|
||||
│ ├── skills/ # Skill prompts
|
||||
│ │ ├── test-driven-development.md
|
||||
│ │ ├── systematic-debugging.md
|
||||
│ │ └── ...
|
||||
│ ├── workflows/ # Agent-derived workflows
|
||||
│ │ ├── code-review.md
|
||||
│ │ ├── backend-development.md
|
||||
│ │ └── ...
|
||||
│ └── commands/ # Command prompts
|
||||
│ ├── codereview.md
|
||||
│ ├── brainstorm.md
|
||||
│ └── ...
|
||||
└── .ring-manifest.json # Installation tracking
|
||||
```
|
||||
|
||||
## Transformations
|
||||
|
||||
### Skills → Skill Prompts
|
||||
|
||||
Skills become prompt files optimized for Cline's prompt system:
|
||||
|
||||
**Claude Code (SKILL.md with frontmatter):**
|
||||
```yaml
|
||||
---
|
||||
name: test-driven-development
|
||||
description: RED-GREEN-REFACTOR cycle
|
||||
trigger:
|
||||
- When writing new code
|
||||
- When fixing bugs
|
||||
---
|
||||
|
||||
# Test-Driven Development
|
||||
...detailed content...
|
||||
```
|
||||
|
||||
**Cline (prompt file):**
|
||||
```markdown
|
||||
# Test-Driven Development
|
||||
|
||||
## When to Use
|
||||
- When writing new code
|
||||
- When fixing bugs
|
||||
|
||||
## Process
|
||||
|
||||
### RED Phase
|
||||
1. Write a test that describes expected behavior
|
||||
2. Run the test - it MUST fail
|
||||
3. If test passes, your test is wrong
|
||||
|
||||
### GREEN Phase
|
||||
1. Write minimal code to pass the test
|
||||
2. Run the test - it MUST pass
|
||||
3. No extra code, no "future-proofing"
|
||||
|
||||
### REFACTOR Phase
|
||||
1. Clean up while tests stay green
|
||||
2. Extract methods, rename variables
|
||||
3. Run tests after each change
|
||||
|
||||
## Checklist
|
||||
- [ ] Test written before code
|
||||
- [ ] Test failed initially
|
||||
- [ ] Minimal code written
|
||||
- [ ] Test passes
|
||||
- [ ] Code refactored (optional)
|
||||
```
|
||||
|
||||
### Agents → Workflow Prompts
|
||||
|
||||
Agent definitions become workflow guidance prompts:
|
||||
|
||||
| Agent | Prompt |
|
||||
|-------|--------|
|
||||
| `code-reviewer` | `prompts/workflows/code-review.md` |
|
||||
| `backend-engineer-golang` | `prompts/workflows/go-backend.md` |
|
||||
| `security-reviewer` | `prompts/workflows/security-review.md` |
|
||||
|
||||
**Example workflow prompt:**
|
||||
```markdown
|
||||
# Code Review Workflow
|
||||
|
||||
## Role
|
||||
You are a code reviewer focusing on architecture, design patterns, and code quality.
|
||||
|
||||
## Review Checklist
|
||||
1. Architecture alignment
|
||||
2. Design pattern usage
|
||||
3. Code organization
|
||||
4. Error handling
|
||||
5. Test coverage
|
||||
|
||||
## Output Format
|
||||
## VERDICT: [PASS|FAIL|NEEDS_DISCUSSION]
|
||||
|
||||
## Summary
|
||||
[2-3 sentences]
|
||||
|
||||
## Issues Found
|
||||
### Critical
|
||||
- [issue]
|
||||
|
||||
### High
|
||||
- [issue]
|
||||
|
||||
## What Was Done Well
|
||||
- [positive point]
|
||||
```
|
||||
|
||||
### Commands → Command Prompts
|
||||
|
||||
Slash commands become prompt files you can reference:
|
||||
|
||||
```markdown
|
||||
# /codereview Command Prompt
|
||||
|
||||
## Usage
|
||||
Reference this prompt to initiate a code review.
|
||||
|
||||
## Process
|
||||
1. Identify files to review
|
||||
2. Apply code-review workflow
|
||||
3. Apply business-logic-review workflow
|
||||
4. Apply security-review workflow
|
||||
5. Aggregate findings
|
||||
6. Address by severity
|
||||
```
|
||||
|
||||
## Usage in Cline
|
||||
|
||||
### Loading Prompts
|
||||
|
||||
Reference prompts when starting tasks:
|
||||
|
||||
```
|
||||
"Load the test-driven-development prompt for this feature"
|
||||
"Follow the prompts/workflows/code-review.md workflow"
|
||||
```
|
||||
|
||||
### Prompt Discovery
|
||||
|
||||
List available prompts:
|
||||
```bash
|
||||
ls ~/.cline/prompts/skills/
|
||||
ls ~/.cline/prompts/workflows/
|
||||
ls ~/.cline/prompts/commands/
|
||||
```
|
||||
|
||||
### Combining Prompts
|
||||
|
||||
Use multiple prompts together:
|
||||
```
|
||||
"For this feature:
|
||||
1. Use test-driven-development prompt
|
||||
2. Use verification-before-completion prompt
|
||||
3. Follow go-backend workflow"
|
||||
```
|
||||
|
||||
## Feature Mapping
|
||||
|
||||
| Ring Feature | Cline Equivalent |
|
||||
|--------------|------------------|
|
||||
| Skills | Skill prompts |
|
||||
| Agents | Workflow prompts |
|
||||
| Commands | Command prompts |
|
||||
| Hooks | Not supported |
|
||||
|
||||
**Limitations:**
|
||||
- No automatic prompt loading
|
||||
- No hook execution
|
||||
- Manual prompt reference required
|
||||
|
||||
## Prompt Categories
|
||||
|
||||
### Skill Prompts (46)
|
||||
Development methodologies and best practices:
|
||||
- `test-driven-development.md` - TDD workflow
|
||||
- `systematic-debugging.md` - 4-phase debugging
|
||||
- `verification-before-completion.md` - Evidence requirements
|
||||
- `brainstorming.md` - Design refinement
|
||||
- ...and 42 more
|
||||
|
||||
### Workflow Prompts (20)
|
||||
Role-based workflows from agents:
|
||||
- `code-review.md` - Quality review
|
||||
- `security-review.md` - Security analysis
|
||||
- `go-backend.md` - Go development
|
||||
- `typescript-backend.md` - TypeScript development
|
||||
- ...and 16 more
|
||||
|
||||
### Command Prompts (14)
|
||||
Quick-reference command guides:
|
||||
- `codereview.md` - Review orchestration
|
||||
- `brainstorm.md` - Design sessions
|
||||
- `pre-dev-full.md` - Planning workflow
|
||||
- ...and 11 more
|
||||
|
||||
## Updating
|
||||
|
||||
```bash
|
||||
# Check for updates
|
||||
./installer/install-ring.sh check
|
||||
|
||||
# Update Cline installation
|
||||
./installer/install-ring.sh update --platforms cline
|
||||
|
||||
# Force regenerate all prompts
|
||||
./installer/install-ring.sh install --platforms cline --force
|
||||
```
|
||||
|
||||
## Uninstalling
|
||||
|
||||
```bash
|
||||
./installer/install-ring.sh uninstall --platforms cline
|
||||
```
|
||||
|
||||
This removes:
|
||||
- `prompts/` directory
|
||||
- `.ring-manifest.json`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Prompts not found
|
||||
1. Verify installation: `./installer/install-ring.sh list`
|
||||
2. Check directory: `ls ~/.cline/prompts/`
|
||||
3. Re-run installer if missing
|
||||
|
||||
### Outdated prompts
|
||||
```bash
|
||||
./installer/install-ring.sh sync --platforms cline
|
||||
```
|
||||
|
||||
### Prompt format issues
|
||||
If prompts aren't working well:
|
||||
1. Check Cline's prompt documentation
|
||||
2. Prompts may need manual adjustment for your Cline version
|
||||
|
||||
## Best Practices for Cline
|
||||
|
||||
1. **Reference prompts explicitly**: Start messages with prompt references
|
||||
2. **Combine related prompts**: Use skill + workflow prompts together
|
||||
3. **Create shortcuts**: Save common prompt combinations
|
||||
4. **Update regularly**: Sync to get latest prompt improvements
|
||||
5. **Customize if needed**: Prompts can be edited for your workflow
|
||||
207
docs/platforms/cursor.md
Normal file
207
docs/platforms/cursor.md
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
# Ring for Cursor
|
||||
|
||||
Cursor uses **rules** and **workflows** instead of skills and agents. Ring transforms content to Cursor's format during installation.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Interactive installer
|
||||
./installer/install-ring.sh
|
||||
|
||||
# Direct install
|
||||
./installer/install-ring.sh install --platforms cursor
|
||||
|
||||
# Multiple platforms
|
||||
./installer/install-ring.sh install --platforms claude,cursor
|
||||
```
|
||||
|
||||
## Installation Path
|
||||
|
||||
Ring installs to: `~/.cursor/`
|
||||
|
||||
```
|
||||
~/.cursor/
|
||||
├── .cursorrules # Generated rules file
|
||||
├── workflows/ # Transformed agents/commands
|
||||
│ ├── code-review.md
|
||||
│ ├── tdd-workflow.md
|
||||
│ ├── debugging-workflow.md
|
||||
│ └── ... (consolidated workflows)
|
||||
└── .ring-manifest.json # Installation tracking
|
||||
```
|
||||
|
||||
## Transformations
|
||||
|
||||
### Skills → Rules
|
||||
|
||||
Ring consolidates skills into Cursor's `.cursorrules` format:
|
||||
|
||||
**Claude Code (multiple skill files):**
|
||||
```
|
||||
skills/
|
||||
├── test-driven-development/SKILL.md
|
||||
├── systematic-debugging/SKILL.md
|
||||
└── verification-before-completion/SKILL.md
|
||||
```
|
||||
|
||||
**Cursor (single rules file):**
|
||||
```
|
||||
.cursorrules
|
||||
├── # Test-Driven Development
|
||||
│ RED → GREEN → REFACTOR cycle...
|
||||
├── # Systematic Debugging
|
||||
│ 4-phase root cause analysis...
|
||||
└── # Verification Before Completion
|
||||
Evidence before claims...
|
||||
```
|
||||
|
||||
### Agents → Workflows
|
||||
|
||||
Agents become workflow definitions:
|
||||
|
||||
| Claude Code | Cursor |
|
||||
|-------------|--------|
|
||||
| `agents/code-reviewer.md` | `workflows/code-review.md` |
|
||||
| `agents/write-plan.md` | `workflows/implementation-planning.md` |
|
||||
| `subagent_type="..."` | Follow workflow guide |
|
||||
|
||||
### Commands → Workflow Triggers
|
||||
|
||||
Slash commands become workflow entry points documented in the workflows directory.
|
||||
|
||||
## The .cursorrules File
|
||||
|
||||
Ring generates a comprehensive `.cursorrules` file containing:
|
||||
|
||||
1. **Core Principles** - Using-ring mandatory discovery
|
||||
2. **Development Rules** - TDD, debugging, verification
|
||||
3. **Code Quality Rules** - Anti-patterns, best practices
|
||||
4. **Workflow References** - Links to workflow files
|
||||
|
||||
**Example .cursorrules excerpt:**
|
||||
```markdown
|
||||
# Ring Skills for Cursor
|
||||
|
||||
## Mandatory: Skill Discovery
|
||||
Before any task, check if a skill applies.
|
||||
If a skill applies, you MUST use it.
|
||||
|
||||
## Test-Driven Development
|
||||
1. Write test first (RED)
|
||||
2. Run test - must fail
|
||||
3. Write minimal code (GREEN)
|
||||
4. Run test - must pass
|
||||
5. Refactor if needed
|
||||
|
||||
## Systematic Debugging
|
||||
Phase 1: Investigate (gather ALL evidence)
|
||||
Phase 2: Analyze patterns
|
||||
Phase 3: Test hypothesis (one at a time)
|
||||
Phase 4: Implement fix (with test)
|
||||
|
||||
...
|
||||
```
|
||||
|
||||
## Workflows Directory
|
||||
|
||||
Each workflow is a standalone guide:
|
||||
|
||||
**workflows/code-review.md:**
|
||||
```markdown
|
||||
# Code Review Workflow
|
||||
|
||||
## Overview
|
||||
Parallel 3-reviewer code review process.
|
||||
|
||||
## Reviewers
|
||||
1. Foundation Review - Architecture, patterns, quality
|
||||
2. Correctness Review - Business logic, requirements, edge cases
|
||||
3. Safety Review - Security, OWASP, authentication
|
||||
|
||||
## Process
|
||||
1. Identify files to review
|
||||
2. Run all 3 reviews in parallel
|
||||
3. Aggregate findings by severity
|
||||
4. Address Critical/High issues first
|
||||
5. Re-review after fixes
|
||||
|
||||
## Severity Guide
|
||||
- CRITICAL: Security vulnerabilities, data loss risk
|
||||
- HIGH: Business logic errors, missing requirements
|
||||
- MEDIUM: Code quality, maintainability
|
||||
- LOW: Style, minor improvements
|
||||
```
|
||||
|
||||
## Usage in Cursor
|
||||
|
||||
### Following Rules
|
||||
|
||||
Cursor automatically loads `.cursorrules`. The AI will:
|
||||
1. Check applicable rules before tasks
|
||||
2. Follow TDD when writing code
|
||||
3. Use debugging phases for issues
|
||||
4. Require verification before completion
|
||||
|
||||
### Using Workflows
|
||||
|
||||
Reference workflows when needed:
|
||||
```
|
||||
"Follow the code-review workflow for this PR"
|
||||
"Use the debugging-workflow to investigate this issue"
|
||||
```
|
||||
|
||||
## Feature Mapping
|
||||
|
||||
| Ring Feature | Cursor Equivalent |
|
||||
|--------------|-------------------|
|
||||
| Skills | Rules in .cursorrules |
|
||||
| Agents | Workflow guides |
|
||||
| Commands | Workflow entry points |
|
||||
| Hooks | Not supported |
|
||||
|
||||
**Limitations:**
|
||||
- No automatic hook execution
|
||||
- No dynamic skill loading
|
||||
- Workflows are guidance, not enforced
|
||||
|
||||
## Updating
|
||||
|
||||
```bash
|
||||
# Check for updates
|
||||
./installer/install-ring.sh check
|
||||
|
||||
# Update Cursor installation
|
||||
./installer/install-ring.sh update --platforms cursor
|
||||
|
||||
# Regenerate rules file
|
||||
./installer/install-ring.sh install --platforms cursor --force
|
||||
```
|
||||
|
||||
## Uninstalling
|
||||
|
||||
```bash
|
||||
./installer/install-ring.sh uninstall --platforms cursor
|
||||
```
|
||||
|
||||
This removes:
|
||||
- `.cursorrules` file
|
||||
- `workflows/` directory
|
||||
- `.ring-manifest.json`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Rules not loading
|
||||
1. Verify `.cursorrules` exists in `~/.cursor/`
|
||||
2. Restart Cursor to reload rules
|
||||
3. Check Cursor settings for rules enablement
|
||||
|
||||
### Workflow not found
|
||||
1. Check `~/.cursor/workflows/` directory
|
||||
2. Re-run installer: `./installer/install-ring.sh install --platforms cursor`
|
||||
|
||||
## Best Practices for Cursor
|
||||
|
||||
1. **Reference workflows explicitly**: "Use the TDD workflow"
|
||||
2. **Remind about rules**: If AI skips rules, reference them
|
||||
3. **Combine with manual checks**: Rules are guidance, verify compliance
|
||||
4. **Update regularly**: Re-run installer to get latest skills
|
||||
150
docs/platforms/factory-ai.md
Normal file
150
docs/platforms/factory-ai.md
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
# Ring for Factory AI
|
||||
|
||||
Factory AI uses **droids** instead of agents. Ring transforms agent definitions to droid format during installation.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Interactive installer
|
||||
./installer/install-ring.sh
|
||||
|
||||
# Direct install
|
||||
./installer/install-ring.sh install --platforms factory
|
||||
|
||||
# Multiple platforms
|
||||
./installer/install-ring.sh install --platforms claude,factory
|
||||
```
|
||||
|
||||
## Installation Path
|
||||
|
||||
Ring installs to: `~/.factory/`
|
||||
|
||||
```
|
||||
~/.factory/
|
||||
├── droids/ # Transformed from agents/
|
||||
│ ├── code-reviewer.md
|
||||
│ ├── business-logic-reviewer.md
|
||||
│ ├── security-reviewer.md
|
||||
│ ├── backend-engineer-golang.md
|
||||
│ └── ... (20 droids)
|
||||
├── commands/ # Slash commands
|
||||
├── skills/ # Skills (unchanged)
|
||||
└── .ring-manifest.json # Installation tracking
|
||||
```
|
||||
|
||||
## Transformations
|
||||
|
||||
### Agents → Droids
|
||||
|
||||
Ring automatically transforms:
|
||||
|
||||
| Claude Code | Factory AI |
|
||||
|-------------|------------|
|
||||
| `agents/` | `droids/` |
|
||||
| `subagent_type` | `droid_type` |
|
||||
| "agent" references | "droid" references |
|
||||
|
||||
**Example transformation:**
|
||||
|
||||
Claude Code (original):
|
||||
```markdown
|
||||
---
|
||||
name: code-reviewer
|
||||
description: Foundation review agent
|
||||
---
|
||||
Use this agent for code quality review...
|
||||
```
|
||||
|
||||
Factory AI (transformed):
|
||||
```markdown
|
||||
---
|
||||
name: code-reviewer
|
||||
description: Foundation review droid
|
||||
---
|
||||
Use this droid for code quality review...
|
||||
```
|
||||
|
||||
### Skills (Unchanged)
|
||||
|
||||
Skills transfer directly without transformation. The workflow logic, triggers, and content remain identical.
|
||||
|
||||
### Commands (Minor adjustments)
|
||||
|
||||
Commands receive terminology updates:
|
||||
- "dispatch agent" → "dispatch droid"
|
||||
- "subagent" → "subdroid"
|
||||
|
||||
## Usage in Factory AI
|
||||
|
||||
### Invoking Droids
|
||||
|
||||
```
|
||||
Task tool with droid_type="ring-default:code-reviewer"
|
||||
Task tool with droid_type="ring-dev-team:backend-engineer-golang"
|
||||
```
|
||||
|
||||
### Using Skills
|
||||
|
||||
```
|
||||
Skill tool: "ring:test-driven-development"
|
||||
Skill tool: "ring:systematic-debugging"
|
||||
```
|
||||
|
||||
### Running Commands
|
||||
|
||||
Commands work the same as Claude Code:
|
||||
```
|
||||
/ring-default:codereview
|
||||
/ring-default:brainstorm
|
||||
```
|
||||
|
||||
## Feature Parity
|
||||
|
||||
| Feature | Claude Code | Factory AI |
|
||||
|---------|-------------|------------|
|
||||
| Skills | 46 | 46 |
|
||||
| Agents/Droids | 20 | 20 |
|
||||
| Commands | 14 | 14 |
|
||||
| Hooks | Full | Limited |
|
||||
| Auto-discovery | Yes | Yes |
|
||||
|
||||
**Note**: Factory AI hook support may vary. Check Factory AI documentation for hook compatibility.
|
||||
|
||||
## Updating
|
||||
|
||||
```bash
|
||||
# Check for updates
|
||||
./installer/install-ring.sh check
|
||||
|
||||
# Update Factory AI installation
|
||||
./installer/install-ring.sh update --platforms factory
|
||||
```
|
||||
|
||||
## Uninstalling
|
||||
|
||||
```bash
|
||||
./installer/install-ring.sh uninstall --platforms factory
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Droid not found
|
||||
1. Verify installation: `./installer/install-ring.sh list`
|
||||
2. Check droids directory: `ls ~/.factory/droids/`
|
||||
3. Use fully qualified name: `ring-dev-team:backend-engineer-golang`
|
||||
|
||||
### Terminology mismatch
|
||||
If you see "agent" where "droid" is expected, re-run the installer:
|
||||
```bash
|
||||
./installer/install-ring.sh install --platforms factory --force
|
||||
```
|
||||
|
||||
## Dual Installation
|
||||
|
||||
You can install Ring to both Claude Code and Factory AI:
|
||||
|
||||
```bash
|
||||
./installer/install-ring.sh install --platforms claude,factory
|
||||
```
|
||||
|
||||
Each platform maintains its own manifest and installation. Updates are platform-independent.
|
||||
297
installer/SECURITY_FIXES.md
Normal file
297
installer/SECURITY_FIXES.md
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
# Security Fixes Applied to Ring Multi-Platform Installer
|
||||
|
||||
This document summarizes the security validations added to the Ring installer codebase.
|
||||
|
||||
## Date: 2025-11-27
|
||||
|
||||
## Overview
|
||||
|
||||
Five medium-severity security issues have been addressed across the installer codebase to prevent path traversal attacks, symlink vulnerabilities, command injection, and race conditions.
|
||||
|
||||
## Fixes Applied
|
||||
|
||||
### 1. Path Traversal Validation (Medium Priority)
|
||||
|
||||
**Location:** `installer/ring_installer/core.py` - `InstallTarget.__post_init__()`
|
||||
|
||||
**Issue:** Custom install paths were not validated, potentially allowing installation outside safe directories.
|
||||
|
||||
**Fix:** Added validation to ensure custom install paths are within expected boundaries:
|
||||
- Paths must be under the user's home directory, OR
|
||||
- Paths must be under `/opt` or `/usr/local` (standard system locations)
|
||||
- Paths are resolved to absolute paths to prevent traversal attacks
|
||||
|
||||
**Code Changes:**
|
||||
```python
|
||||
def __post_init__(self):
|
||||
if self.path is not None:
|
||||
self.path = Path(self.path).expanduser().resolve()
|
||||
# Validate path is within expected boundaries
|
||||
home = Path.home()
|
||||
try:
|
||||
self.path.relative_to(home)
|
||||
except ValueError:
|
||||
# Path not under home - check if it's a reasonable location
|
||||
allowed = [Path("/opt"), Path("/usr/local")]
|
||||
if not any(
|
||||
self.path.is_relative_to(p) for p in allowed if p.exists()
|
||||
):
|
||||
raise ValueError(
|
||||
f"Install path must be under home directory or /opt, /usr/local: {self.path}"
|
||||
)
|
||||
```
|
||||
|
||||
**Impact:** Prevents installation to arbitrary system locations like `/etc`, `/var`, or other sensitive directories.
|
||||
|
||||
---
|
||||
|
||||
### 2. Symlink Validation (Medium Priority)
|
||||
|
||||
**Location:** `installer/ring_installer/utils/fs.py` - `copy_with_transform()` and `atomic_write()`
|
||||
|
||||
**Issue:** Functions could write to symlink targets, potentially overwriting critical system files.
|
||||
|
||||
**Fix:** Added symlink checks before writing to target files:
|
||||
|
||||
**In `copy_with_transform()`:**
|
||||
```python
|
||||
# Check if target exists and is a symlink
|
||||
if target.exists() and target.is_symlink():
|
||||
raise ValueError(f"Refusing to write to symlink: {target}")
|
||||
```
|
||||
|
||||
**In `atomic_write()`:**
|
||||
```python
|
||||
# Check if target exists and is a symlink
|
||||
if path.exists() and path.is_symlink():
|
||||
raise ValueError(f"Refusing to write to symlink: {path}")
|
||||
```
|
||||
|
||||
**Impact:** Prevents symlink attacks where an attacker could trick the installer into overwriting system files via symlinks.
|
||||
|
||||
---
|
||||
|
||||
### 3. Use Resolved Binary Path in Subprocess (Medium Priority)
|
||||
|
||||
**Location:** `installer/ring_installer/utils/platform_detect.py` - `_detect_claude()` and `_detect_factory()`
|
||||
|
||||
**Issue:** Using command strings instead of resolved paths in subprocess calls could be exploited via PATH manipulation.
|
||||
|
||||
**Fix:** Changed subprocess calls to use the resolved binary path returned by `shutil.which()`:
|
||||
|
||||
**Before:**
|
||||
```python
|
||||
binary = shutil.which("claude")
|
||||
if binary:
|
||||
result = subprocess.run(["claude", "--version"], ...)
|
||||
```
|
||||
|
||||
**After:**
|
||||
```python
|
||||
binary = shutil.which("claude")
|
||||
if binary:
|
||||
result = subprocess.run([binary, "--version"], ...) # Use resolved path
|
||||
```
|
||||
|
||||
**Applied to:**
|
||||
- `_detect_claude()` (line 151-163)
|
||||
- `_detect_factory()` (line 193-212)
|
||||
|
||||
**Impact:** Ensures the correct binary is executed even if PATH is manipulated, preventing command injection through malicious binaries in the PATH.
|
||||
|
||||
---
|
||||
|
||||
### 4. Atomic Write Race Condition (Medium Priority)
|
||||
|
||||
**Location:** `installer/ring_installer/utils/fs.py` - `atomic_write()`
|
||||
|
||||
**Issue:** Predictable temporary file names created a race condition where an attacker could replace the temp file before it's renamed.
|
||||
|
||||
**Fix:** Used Python's `tempfile` module for secure random temp filenames:
|
||||
|
||||
**Before:**
|
||||
```python
|
||||
temp_path = path.parent / f".{path.name}.tmp"
|
||||
```
|
||||
|
||||
**After:**
|
||||
```python
|
||||
import tempfile
|
||||
|
||||
fd, temp_path_str = tempfile.mkstemp(
|
||||
dir=path.parent,
|
||||
prefix=f".{path.name}.",
|
||||
suffix=".tmp"
|
||||
)
|
||||
temp_path = Path(temp_path_str)
|
||||
```
|
||||
|
||||
**Additional Improvements:**
|
||||
- Use `os.fdopen(fd, ...)` to write directly to the secure file descriptor
|
||||
- Improved error handling to clean up temp file on exceptions
|
||||
- Added symlink check before writing
|
||||
|
||||
**Impact:** Eliminates TOCTOU (time-of-check-time-of-use) race condition by using unpredictable temporary filenames.
|
||||
|
||||
---
|
||||
|
||||
### 5. Factory Terminology Replacement (Medium Priority)
|
||||
|
||||
**Location:** `installer/ring_installer/adapters/factory.py` - `_replace_agent_references()`
|
||||
|
||||
**Issue:** Overly broad replacement was changing "agent" to "droid" in unrelated contexts like "user agent", code blocks, and URLs.
|
||||
|
||||
**Fix:** Implemented selective replacement with protected regions:
|
||||
|
||||
**Features:**
|
||||
- Protects code blocks (fenced and inline) from replacement
|
||||
- Protects URLs from replacement
|
||||
- Excludes "user agent" and similar terms from replacement
|
||||
- Only replaces Ring-specific agent references (e.g., `ring:*-agent`, `subagent_type`)
|
||||
- Uses negative lookbehind/lookahead patterns to avoid false positives
|
||||
|
||||
**Code Structure:**
|
||||
1. Identify protected regions (code blocks, URLs)
|
||||
2. Replace Ring-specific contexts (tool references, subagent)
|
||||
3. Replace general "agent" terminology with exclusions
|
||||
4. Skip replacements in protected regions
|
||||
|
||||
**Impact:** Prevents corruption of code samples, URLs, and technical terms during Factory AI transformation.
|
||||
|
||||
---
|
||||
|
||||
## Testing Recommendations
|
||||
|
||||
The following tests should be added or updated:
|
||||
|
||||
### Path Traversal Tests
|
||||
```python
|
||||
def test_rejects_path_outside_home(self):
|
||||
"""InstallTarget should reject paths outside home directory."""
|
||||
with pytest.raises(ValueError) as exc:
|
||||
InstallTarget(platform="claude", path="/etc/passwd")
|
||||
assert "Install path must be under home directory" in str(exc.value)
|
||||
|
||||
def test_accepts_path_in_opt(self):
|
||||
"""InstallTarget should accept paths under /opt."""
|
||||
target = InstallTarget(platform="claude", path="/opt/ring")
|
||||
assert target.path == Path("/opt/ring").resolve()
|
||||
```
|
||||
|
||||
### Symlink Tests
|
||||
```python
|
||||
def test_copy_with_transform_rejects_symlink(self, tmp_path):
|
||||
"""copy_with_transform() should reject writing to symlink."""
|
||||
source = tmp_path / "source.txt"
|
||||
source.write_text("content")
|
||||
target = tmp_path / "link.txt"
|
||||
real_target = tmp_path / "real.txt"
|
||||
real_target.write_text("original")
|
||||
target.symlink_to(real_target)
|
||||
|
||||
with pytest.raises(ValueError) as exc:
|
||||
copy_with_transform(source, target)
|
||||
assert "symlink" in str(exc.value).lower()
|
||||
|
||||
def test_atomic_write_rejects_symlink(self, tmp_path):
|
||||
"""atomic_write() should reject writing to symlink."""
|
||||
target = tmp_path / "link.txt"
|
||||
real_target = tmp_path / "real.txt"
|
||||
real_target.write_text("original")
|
||||
target.symlink_to(real_target)
|
||||
|
||||
with pytest.raises(ValueError) as exc:
|
||||
atomic_write(target, "new content")
|
||||
assert "symlink" in str(exc.value).lower()
|
||||
```
|
||||
|
||||
### Factory Replacement Tests
|
||||
```python
|
||||
def test_preserves_user_agent(self):
|
||||
"""_replace_agent_references() should not replace 'user agent'."""
|
||||
text = "The user agent string contains browser info."
|
||||
result = adapter._replace_agent_references(text)
|
||||
assert "user agent" in result
|
||||
assert "user droid" not in result
|
||||
|
||||
def test_preserves_code_blocks(self):
|
||||
"""_replace_agent_references() should not replace inside code blocks."""
|
||||
text = "```python\nagent = Agent()\n```"
|
||||
result = adapter._replace_agent_references(text)
|
||||
assert "agent = Agent()" in result
|
||||
|
||||
def test_replaces_ring_contexts(self):
|
||||
"""_replace_agent_references() should replace Ring-specific contexts."""
|
||||
text = 'Use "ring:test-agent" for testing'
|
||||
result = adapter._replace_agent_references(text)
|
||||
assert "ring:test-droid" in result
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
These changes maintain backward compatibility:
|
||||
- Valid installation paths continue to work unchanged
|
||||
- Error messages clearly indicate why a path/operation was rejected
|
||||
- No changes to public API signatures
|
||||
- Factory AI transformations preserve semantic meaning
|
||||
|
||||
---
|
||||
|
||||
## Security Posture Improvements
|
||||
|
||||
| Vulnerability Type | Before | After |
|
||||
|-------------------|--------|-------|
|
||||
| Path Traversal | ❌ Unrestricted | ✅ Validated boundaries |
|
||||
| Symlink Attacks | ❌ Unprotected | ✅ Explicit checks |
|
||||
| Command Injection | ⚠️ Weak protection | ✅ Resolved paths |
|
||||
| Race Conditions | ⚠️ Predictable temps | ✅ Secure random |
|
||||
| Code Corruption | ⚠️ Over-replacement | ✅ Context-aware |
|
||||
|
||||
---
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. `installer/ring_installer/core.py`
|
||||
- Added path validation to `InstallTarget.__post_init__()`
|
||||
|
||||
2. `installer/ring_installer/utils/fs.py`
|
||||
- Added symlink check to `copy_with_transform()`
|
||||
- Rewrote `atomic_write()` with `tempfile` module and symlink check
|
||||
|
||||
3. `installer/ring_installer/utils/platform_detect.py`
|
||||
- Updated `_detect_claude()` to use resolved binary path
|
||||
- Updated `_detect_factory()` to use resolved binary path
|
||||
|
||||
4. `installer/ring_installer/adapters/factory.py`
|
||||
- Rewrote `_replace_agent_references()` with protected regions and selective replacement
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations
|
||||
|
||||
1. **Additional Validation:**
|
||||
- Consider adding disk space checks before installation
|
||||
- Validate that target paths are writable before starting installation
|
||||
|
||||
2. **Security Hardening:**
|
||||
- Consider adding file integrity verification (checksums)
|
||||
- Add optional GPG signature verification for source files
|
||||
|
||||
3. **Audit Logging:**
|
||||
- Log security-related rejections for monitoring
|
||||
- Track installation attempts to sensitive directories
|
||||
|
||||
4. **Documentation:**
|
||||
- Update user documentation to clarify allowed installation paths
|
||||
- Add security guidelines for administrators
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- OWASP Path Traversal: https://owasp.org/www-community/attacks/Path_Traversal
|
||||
- CWE-59 Improper Link Resolution: https://cwe.mitre.org/data/definitions/59.html
|
||||
- CWE-367 TOCTOU Race Condition: https://cwe.mitre.org/data/definitions/367.html
|
||||
- Python tempfile Security: https://docs.python.org/3/library/tempfile.html#tempfile.mkstemp
|
||||
142
installer/install-ring.ps1
Normal file
142
installer/install-ring.ps1
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
# Ring Multi-Platform Installer (PowerShell)
|
||||
# Installs Ring skills to Claude Code, Factory AI, Cursor, and/or Cline
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$RingRoot = Split-Path -Parent $ScriptDir
|
||||
|
||||
Write-Host "================================================" -ForegroundColor Cyan
|
||||
Write-Host "Ring Multi-Platform Installer" -ForegroundColor Cyan
|
||||
Write-Host "================================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# Detect Python
|
||||
function Find-Python {
|
||||
$pythonCmds = @("python3", "python", "py -3")
|
||||
foreach ($cmd in $pythonCmds) {
|
||||
try {
|
||||
$parts = $cmd -split " "
|
||||
$exe = $parts[0]
|
||||
$args = if ($parts.Length -gt 1) { $parts[1..($parts.Length-1)] } else { @() }
|
||||
|
||||
$version = & $exe @args --version 2>&1
|
||||
if ($version -match "Python 3") {
|
||||
return $cmd
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
$PythonCmd = Find-Python
|
||||
|
||||
if (-not $PythonCmd) {
|
||||
Write-Host "Error: Python 3 is required but not found." -ForegroundColor Red
|
||||
Write-Host ""
|
||||
Write-Host "Install Python 3:"
|
||||
Write-Host " Windows: https://python.org/downloads/"
|
||||
Write-Host " Or: winget install Python.Python.3.12"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$parts = $PythonCmd -split " "
|
||||
$pythonExe = $parts[0]
|
||||
$pythonArgs = if ($parts.Length -gt 1) { $parts[1..($parts.Length-1)] } else { @() }
|
||||
|
||||
$version = & $pythonExe @pythonArgs --version 2>&1
|
||||
Write-Host "Found Python: $version" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
# Check if running with arguments (non-interactive mode)
|
||||
if ($args.Count -gt 0) {
|
||||
Set-Location $RingRoot
|
||||
& $pythonExe @pythonArgs -m installer.ring_installer @args
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
# Interactive mode - platform selection
|
||||
Write-Host "Select platforms to install Ring:"
|
||||
Write-Host ""
|
||||
Write-Host " 1) Claude Code (recommended, native format)" -ForegroundColor Blue
|
||||
Write-Host " 2) Factory AI (droids, transformed)" -ForegroundColor Blue
|
||||
Write-Host " 3) Cursor (rules/workflows, transformed)" -ForegroundColor Blue
|
||||
Write-Host " 4) Cline (prompts, transformed)" -ForegroundColor Blue
|
||||
Write-Host " 5) All detected platforms" -ForegroundColor Blue
|
||||
Write-Host " 6) Auto-detect and install" -ForegroundColor Blue
|
||||
Write-Host ""
|
||||
|
||||
$choices = Read-Host "Enter choice(s) separated by comma (e.g., 1,2) [default: 6]"
|
||||
|
||||
# Default to auto-detect
|
||||
if ([string]::IsNullOrWhiteSpace($choices)) {
|
||||
$choices = "6"
|
||||
}
|
||||
|
||||
# Parse choices
|
||||
$platforms = @()
|
||||
if ($choices -match "1") { $platforms += "claude" }
|
||||
if ($choices -match "2") { $platforms += "factory" }
|
||||
if ($choices -match "3") { $platforms += "cursor" }
|
||||
if ($choices -match "4") { $platforms += "cline" }
|
||||
if ($choices -match "5") { $platforms = @("claude", "factory", "cursor", "cline") }
|
||||
if ($choices -match "6") { $platforms = @("auto") }
|
||||
|
||||
if ($platforms.Count -eq 0) {
|
||||
Write-Host "No valid platforms selected." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
$platformString = $platforms -join ","
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Installing to: $platformString" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
# Additional options
|
||||
$verbose = Read-Host "Enable verbose output? (y/N)"
|
||||
$dryRun = Read-Host "Perform dry-run first? (y/N)"
|
||||
|
||||
$extraArgs = @()
|
||||
if ($verbose -match "^[Yy]$") {
|
||||
$extraArgs += "--verbose"
|
||||
}
|
||||
|
||||
# Run dry-run if requested
|
||||
if ($dryRun -match "^[Yy]$") {
|
||||
Write-Host ""
|
||||
Write-Host "=== Dry Run ===" -ForegroundColor Yellow
|
||||
Set-Location $RingRoot
|
||||
& $pythonExe @pythonArgs -m installer.ring_installer install --platforms $platformString --dry-run @extraArgs
|
||||
Write-Host ""
|
||||
$proceed = Read-Host "Proceed with actual installation? (Y/n)"
|
||||
if ($proceed -match "^[Nn]$") {
|
||||
Write-Host "Installation cancelled."
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
|
||||
# Run actual installation
|
||||
Write-Host ""
|
||||
Write-Host "=== Installing ===" -ForegroundColor Green
|
||||
Set-Location $RingRoot
|
||||
& $pythonExe @pythonArgs -m installer.ring_installer install --platforms $platformString @extraArgs
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "================================================" -ForegroundColor Green
|
||||
Write-Host "Installation Complete!" -ForegroundColor Green
|
||||
Write-Host "================================================" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "Next steps:"
|
||||
Write-Host " 1. Restart your AI tool or start a new session"
|
||||
Write-Host " 2. Skills will auto-load (Claude Code) or be available as configured"
|
||||
Write-Host ""
|
||||
Write-Host "Commands:"
|
||||
Write-Host " .\installer\install-ring.ps1 # Interactive install"
|
||||
Write-Host " .\installer\install-ring.ps1 --platforms claude # Direct install"
|
||||
Write-Host " .\installer\install-ring.ps1 update # Update installation"
|
||||
Write-Host " .\installer\install-ring.ps1 list # List installed"
|
||||
Write-Host ""
|
||||
153
installer/install-ring.sh
Executable file
153
installer/install-ring.sh
Executable file
|
|
@ -0,0 +1,153 @@
|
|||
#!/bin/bash
|
||||
# Ring Multi-Platform Installer
|
||||
# Installs Ring skills to Claude Code, Factory AI, Cursor, and/or Cline
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
RING_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
echo "================================================"
|
||||
echo "Ring Multi-Platform Installer"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
|
||||
# Colors (if terminal supports them)
|
||||
if [[ -t 1 ]] && command -v tput &>/dev/null; then
|
||||
GREEN=$(tput setaf 2)
|
||||
YELLOW=$(tput setaf 3)
|
||||
BLUE=$(tput setaf 4)
|
||||
RED=$(tput setaf 1)
|
||||
RESET=$(tput sgr0)
|
||||
else
|
||||
GREEN="" YELLOW="" BLUE="" RED="" RESET=""
|
||||
fi
|
||||
|
||||
# Detect Python
|
||||
detect_python() {
|
||||
if command -v python3 &>/dev/null; then
|
||||
echo "python3"
|
||||
elif command -v python &>/dev/null; then
|
||||
# Verify it's Python 3
|
||||
if python --version 2>&1 | grep -q "Python 3"; then
|
||||
echo "python"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
PYTHON_CMD=$(detect_python)
|
||||
|
||||
if [ -z "$PYTHON_CMD" ]; then
|
||||
echo "${RED}Error: Python 3 is required but not found.${RESET}"
|
||||
echo ""
|
||||
echo "Install Python 3:"
|
||||
echo " macOS: brew install python3"
|
||||
echo " Ubuntu: sudo apt install python3"
|
||||
echo " Windows: https://python.org/downloads/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "${GREEN}Found Python:${RESET} $($PYTHON_CMD --version)"
|
||||
echo ""
|
||||
|
||||
# Check if running with arguments (non-interactive mode)
|
||||
if [ $# -gt 0 ]; then
|
||||
# Direct passthrough to Python module
|
||||
cd "$RING_ROOT"
|
||||
exec $PYTHON_CMD -m installer.ring_installer "$@"
|
||||
fi
|
||||
|
||||
# Interactive mode - platform selection
|
||||
echo "Select platforms to install Ring:"
|
||||
echo ""
|
||||
echo " ${BLUE}1)${RESET} Claude Code (recommended, native format)"
|
||||
echo " ${BLUE}2)${RESET} Factory AI (droids, transformed)"
|
||||
echo " ${BLUE}3)${RESET} Cursor (rules/workflows, transformed)"
|
||||
echo " ${BLUE}4)${RESET} Cline (prompts, transformed)"
|
||||
echo " ${BLUE}5)${RESET} All detected platforms"
|
||||
echo " ${BLUE}6)${RESET} Auto-detect and install"
|
||||
echo ""
|
||||
|
||||
read -p "Enter choice(s) separated by comma (e.g., 1,2) [default: 6]: " choices
|
||||
|
||||
# Default to auto-detect
|
||||
if [ -z "$choices" ]; then
|
||||
choices="6"
|
||||
fi
|
||||
|
||||
# Parse choices
|
||||
PLATFORMS=""
|
||||
case "$choices" in
|
||||
*1*) PLATFORMS="${PLATFORMS}claude," ;;
|
||||
esac
|
||||
case "$choices" in
|
||||
*2*) PLATFORMS="${PLATFORMS}factory," ;;
|
||||
esac
|
||||
case "$choices" in
|
||||
*3*) PLATFORMS="${PLATFORMS}cursor," ;;
|
||||
esac
|
||||
case "$choices" in
|
||||
*4*) PLATFORMS="${PLATFORMS}cline," ;;
|
||||
esac
|
||||
case "$choices" in
|
||||
*5*) PLATFORMS="claude,factory,cursor,cline" ;;
|
||||
esac
|
||||
case "$choices" in
|
||||
*6*) PLATFORMS="auto" ;;
|
||||
esac
|
||||
|
||||
# Remove trailing comma
|
||||
PLATFORMS="${PLATFORMS%,}"
|
||||
|
||||
if [ -z "$PLATFORMS" ]; then
|
||||
echo "${RED}No valid platforms selected.${RESET}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Installing to: ${GREEN}${PLATFORMS}${RESET}"
|
||||
echo ""
|
||||
|
||||
# Additional options
|
||||
read -p "Enable verbose output? (y/N): " verbose
|
||||
read -p "Perform dry-run first? (y/N): " dry_run
|
||||
|
||||
EXTRA_ARGS=()
|
||||
if [[ "$verbose" =~ ^[Yy]$ ]]; then
|
||||
EXTRA_ARGS+=("--verbose")
|
||||
fi
|
||||
|
||||
# Run dry-run if requested
|
||||
if [[ "$dry_run" =~ ^[Yy]$ ]]; then
|
||||
echo ""
|
||||
echo "${YELLOW}=== Dry Run ===${RESET}"
|
||||
cd "$RING_ROOT"
|
||||
$PYTHON_CMD -m installer.ring_installer install --platforms "$PLATFORMS" --dry-run "${EXTRA_ARGS[@]}"
|
||||
echo ""
|
||||
read -p "Proceed with actual installation? (Y/n): " proceed
|
||||
if [[ "$proceed" =~ ^[Nn]$ ]]; then
|
||||
echo "Installation cancelled."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Run actual installation
|
||||
echo ""
|
||||
echo "${GREEN}=== Installing ===${RESET}"
|
||||
cd "$RING_ROOT"
|
||||
$PYTHON_CMD -m installer.ring_installer install --platforms "$PLATFORMS" "${EXTRA_ARGS[@]}"
|
||||
|
||||
echo ""
|
||||
echo "${GREEN}================================================${RESET}"
|
||||
echo "${GREEN}Installation Complete!${RESET}"
|
||||
echo "${GREEN}================================================${RESET}"
|
||||
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"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " ./installer/install-ring.sh # Interactive install"
|
||||
echo " ./installer/install-ring.sh --platforms claude # Direct install"
|
||||
echo " ./installer/install-ring.sh update # Update installation"
|
||||
echo " ./installer/install-ring.sh list # List installed"
|
||||
echo ""
|
||||
83
installer/pyproject.toml
Normal file
83
installer/pyproject.toml
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
[build-system]
|
||||
requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "ring-installer"
|
||||
version = "0.1.0"
|
||||
description = "Multi-platform installer for Ring AI skills library"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
authors = [
|
||||
{name = "Lerian Studio", email = "contact@lerian.studio"}
|
||||
]
|
||||
maintainers = [
|
||||
{name = "Fred Amaral", email = "fred@fredamaral.com.br"}
|
||||
]
|
||||
keywords = [
|
||||
"ai",
|
||||
"skills",
|
||||
"installer",
|
||||
"claude",
|
||||
"cursor",
|
||||
"cline",
|
||||
"factory"
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Environment :: Console",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Software Development :: Build Tools",
|
||||
"Topic :: Utilities",
|
||||
]
|
||||
requires-python = ">=3.8"
|
||||
dependencies = []
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-cov>=4.0.0",
|
||||
"mypy>=1.0.0",
|
||||
"ruff>=0.1.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/lerianstudio/ring"
|
||||
Documentation = "https://github.com/lerianstudio/ring#readme"
|
||||
Repository = "https://github.com/lerianstudio/ring"
|
||||
Issues = "https://github.com/lerianstudio/ring/issues"
|
||||
|
||||
[project.scripts]
|
||||
ring-installer = "ring_installer.__main__:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["ring_installer*"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.8"
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
disallow_untyped_defs = true
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py38"
|
||||
line-length = 100
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W", "I", "N", "UP", "B", "C4"]
|
||||
ignore = ["E501"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py"]
|
||||
python_functions = ["test_*"]
|
||||
addopts = "-v --tb=short"
|
||||
8
installer/requirements.txt
Normal file
8
installer/requirements.txt
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# Ring Multi-Platform Installer Requirements
|
||||
# Core installer uses stdlib only - no external dependencies required
|
||||
|
||||
# Optional: For development/testing
|
||||
# pytest>=7.0.0
|
||||
# pytest-cov>=4.0.0
|
||||
# mypy>=1.0.0
|
||||
# ruff>=0.1.0
|
||||
|
|
@ -13,10 +13,17 @@ from ring_installer.core import (
|
|||
InstallTarget,
|
||||
InstallOptions,
|
||||
InstallResult,
|
||||
UpdateCheckResult,
|
||||
SyncResult,
|
||||
install,
|
||||
update,
|
||||
uninstall,
|
||||
load_manifest,
|
||||
check_updates,
|
||||
update_with_diff,
|
||||
sync_platforms,
|
||||
uninstall_with_manifest,
|
||||
list_installed,
|
||||
)
|
||||
from ring_installer.adapters import (
|
||||
PlatformAdapter,
|
||||
|
|
@ -37,10 +44,17 @@ __all__ = [
|
|||
"InstallTarget",
|
||||
"InstallOptions",
|
||||
"InstallResult",
|
||||
"UpdateCheckResult",
|
||||
"SyncResult",
|
||||
"install",
|
||||
"update",
|
||||
"uninstall",
|
||||
"load_manifest",
|
||||
"check_updates",
|
||||
"update_with_diff",
|
||||
"sync_platforms",
|
||||
"uninstall_with_manifest",
|
||||
"list_installed",
|
||||
# Adapters
|
||||
"PlatformAdapter",
|
||||
"ClaudeAdapter",
|
||||
|
|
|
|||
701
installer/ring_installer/__main__.py
Normal file
701
installer/ring_installer/__main__.py
Normal file
|
|
@ -0,0 +1,701 @@
|
|||
"""
|
||||
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 uninstall [--platforms PLATFORMS] [--dry-run] [--force]
|
||||
python -m ring_installer list [--platform PLATFORM]
|
||||
python -m ring_installer detect
|
||||
|
||||
Examples:
|
||||
# Install to all detected platforms
|
||||
python -m ring_installer install
|
||||
|
||||
# Install to specific platforms
|
||||
python -m ring_installer install --platforms claude,cursor
|
||||
|
||||
# Dry run to see what would be done
|
||||
python -m ring_installer install --dry-run --verbose
|
||||
|
||||
# Update existing installation
|
||||
python -m ring_installer update
|
||||
|
||||
# List installed components
|
||||
python -m ring_installer list --platform claude
|
||||
|
||||
# Detect installed platforms
|
||||
python -m ring_installer detect
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from ring_installer import __version__
|
||||
from ring_installer.adapters import SUPPORTED_PLATFORMS, list_platforms
|
||||
from ring_installer.core import (
|
||||
InstallTarget,
|
||||
InstallOptions,
|
||||
InstallResult,
|
||||
InstallStatus,
|
||||
UpdateCheckResult,
|
||||
SyncResult,
|
||||
install,
|
||||
update,
|
||||
uninstall,
|
||||
list_installed,
|
||||
check_updates,
|
||||
update_with_diff,
|
||||
sync_platforms,
|
||||
uninstall_with_manifest,
|
||||
)
|
||||
from ring_installer.utils.platform_detect import (
|
||||
detect_installed_platforms,
|
||||
print_detection_report,
|
||||
get_platform_info,
|
||||
)
|
||||
|
||||
|
||||
def print_version() -> None:
|
||||
"""Print version information."""
|
||||
print(f"Ring Installer v{__version__}")
|
||||
|
||||
|
||||
def print_result(result: InstallResult, verbose: bool = False) -> None:
|
||||
"""Print installation result summary."""
|
||||
status_symbols = {
|
||||
InstallStatus.SUCCESS: "[OK]",
|
||||
InstallStatus.PARTIAL: "[PARTIAL]",
|
||||
InstallStatus.FAILED: "[FAILED]",
|
||||
InstallStatus.SKIPPED: "[SKIPPED]",
|
||||
}
|
||||
|
||||
print(f"\n{status_symbols.get(result.status, '[?]')} Installation {result.status.value}")
|
||||
print(f" Targets: {', '.join(result.targets)}")
|
||||
print(f" Installed: {result.components_installed}")
|
||||
print(f" Skipped: {result.components_skipped}")
|
||||
print(f" Failed: {result.components_failed}")
|
||||
|
||||
if result.errors:
|
||||
print("\nErrors:")
|
||||
for error in result.errors:
|
||||
print(f" - {error}")
|
||||
|
||||
if verbose and result.warnings:
|
||||
print("\nWarnings:")
|
||||
for warning in result.warnings:
|
||||
print(f" - {warning}")
|
||||
|
||||
if verbose and result.details:
|
||||
print("\nDetails:")
|
||||
for detail in result.details:
|
||||
status_sym = {
|
||||
InstallStatus.SUCCESS: "+",
|
||||
InstallStatus.FAILED: "x",
|
||||
InstallStatus.SKIPPED: "-",
|
||||
}.get(detail.status, "?")
|
||||
print(f" [{status_sym}] {detail.source_path.name} -> {detail.target_path}")
|
||||
|
||||
|
||||
def progress_callback(message: str, current: int, total: int) -> None:
|
||||
"""Display progress information."""
|
||||
percent = (current / total * 100) if total > 0 else 0
|
||||
print(f"\r[{percent:5.1f}%] {message[:60]:<60}", end="", flush=True)
|
||||
|
||||
|
||||
def find_ring_source() -> Optional[Path]:
|
||||
"""
|
||||
Find the Ring source directory.
|
||||
|
||||
Looks in these locations:
|
||||
1. Current directory (if it's a Ring repo)
|
||||
2. Parent directory (if running from installer/)
|
||||
3. ~/ring
|
||||
4. ~/.ring
|
||||
"""
|
||||
candidates = [
|
||||
Path.cwd(),
|
||||
Path.cwd().parent,
|
||||
Path.home() / "ring",
|
||||
Path.home() / ".ring",
|
||||
]
|
||||
|
||||
for candidate in candidates:
|
||||
# Check for Ring markers
|
||||
if (candidate / ".claude-plugin").exists():
|
||||
return candidate
|
||||
if (candidate / "default" / "skills").exists():
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def parse_platforms(platforms_str: Optional[str]) -> List[str]:
|
||||
"""Parse comma-separated platform string."""
|
||||
if not platforms_str:
|
||||
return []
|
||||
return [p.strip().lower() for p in platforms_str.split(",") if p.strip()]
|
||||
|
||||
|
||||
def validate_platforms(platforms: List[str]) -> List[str]:
|
||||
"""Validate platform identifiers and return valid ones."""
|
||||
valid = []
|
||||
invalid = []
|
||||
|
||||
for platform in platforms:
|
||||
if platform in SUPPORTED_PLATFORMS:
|
||||
valid.append(platform)
|
||||
else:
|
||||
invalid.append(platform)
|
||||
|
||||
if invalid:
|
||||
print(f"Warning: Ignoring unsupported platforms: {', '.join(invalid)}")
|
||||
print(f"Supported platforms: {', '.join(SUPPORTED_PLATFORMS)}")
|
||||
|
||||
return valid
|
||||
|
||||
|
||||
def cmd_install(args: argparse.Namespace) -> int:
|
||||
"""Handle install command."""
|
||||
# Find Ring source
|
||||
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.")
|
||||
print("Please specify with --source or run from Ring directory.")
|
||||
return 1
|
||||
|
||||
print(f"Ring source: {source_path}")
|
||||
|
||||
# Determine target platforms
|
||||
if args.platforms:
|
||||
platforms = validate_platforms(parse_platforms(args.platforms))
|
||||
if not platforms:
|
||||
return 1
|
||||
else:
|
||||
# Auto-detect installed platforms
|
||||
detected = detect_installed_platforms()
|
||||
if detected:
|
||||
platforms = [p.platform_id for p in detected]
|
||||
print(f"Auto-detected platforms: {', '.join(platforms)}")
|
||||
else:
|
||||
# Default to claude if nothing detected
|
||||
platforms = ["claude"]
|
||||
print("No platforms detected, defaulting to: claude")
|
||||
|
||||
# Build targets
|
||||
targets = [InstallTarget(platform=p) for p in platforms]
|
||||
|
||||
# Build options
|
||||
options = InstallOptions(
|
||||
dry_run=args.dry_run,
|
||||
force=args.force,
|
||||
backup=not args.no_backup,
|
||||
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,
|
||||
)
|
||||
|
||||
if args.dry_run:
|
||||
print("\n[DRY RUN] No changes will be made.\n")
|
||||
|
||||
# Run installation
|
||||
callback = progress_callback if not args.quiet and not args.verbose else None
|
||||
result = install(source_path, targets, options, callback)
|
||||
|
||||
if callback:
|
||||
print() # New line after progress
|
||||
|
||||
print_result(result, args.verbose)
|
||||
|
||||
return 0 if result.status in [InstallStatus.SUCCESS, InstallStatus.SKIPPED] else 1
|
||||
|
||||
|
||||
def cmd_update(args: argparse.Namespace) -> int:
|
||||
"""Handle update command."""
|
||||
# Find Ring source
|
||||
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
|
||||
|
||||
print(f"Ring source: {source_path}")
|
||||
|
||||
# Determine target platforms
|
||||
if args.platforms:
|
||||
platforms = validate_platforms(parse_platforms(args.platforms))
|
||||
if not platforms:
|
||||
return 1
|
||||
else:
|
||||
detected = detect_installed_platforms()
|
||||
if detected:
|
||||
platforms = [p.platform_id for p in detected]
|
||||
else:
|
||||
platforms = ["claude"]
|
||||
|
||||
print(f"Updating platforms: {', '.join(platforms)}")
|
||||
|
||||
# Build targets
|
||||
targets = [InstallTarget(platform=p) for p in platforms]
|
||||
|
||||
# Build options
|
||||
options = InstallOptions(
|
||||
dry_run=args.dry_run,
|
||||
force=True,
|
||||
backup=not args.no_backup,
|
||||
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,
|
||||
)
|
||||
|
||||
if args.dry_run:
|
||||
print("\n[DRY RUN] No changes will be made.\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):
|
||||
result = update_with_diff(source_path, targets, options, callback)
|
||||
else:
|
||||
result = update(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
|
||||
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
|
||||
|
||||
print(f"Ring source: {source_path}")
|
||||
|
||||
# Determine target platforms
|
||||
if args.platforms:
|
||||
platforms = validate_platforms(parse_platforms(args.platforms))
|
||||
if not platforms:
|
||||
return 1
|
||||
else:
|
||||
detected = detect_installed_platforms()
|
||||
if detected:
|
||||
platforms = [p.platform_id for p in detected]
|
||||
else:
|
||||
platforms = ["claude"]
|
||||
|
||||
# Build targets
|
||||
targets = [InstallTarget(platform=p) for p in platforms]
|
||||
|
||||
print(f"\nChecking updates for: {', '.join(platforms)}\n")
|
||||
|
||||
# Check for updates
|
||||
results = check_updates(source_path, targets)
|
||||
|
||||
# Display results
|
||||
any_updates = False
|
||||
for platform, result in results.items():
|
||||
print(f"{platform.title()}:")
|
||||
print(f" Installed: {result.installed_version or 'Not installed'}")
|
||||
print(f" Available: {result.available_version or 'Unknown'}")
|
||||
|
||||
if result.update_available:
|
||||
any_updates = True
|
||||
print(" Status: UPDATE AVAILABLE")
|
||||
elif result.installed_version and result.available_version:
|
||||
print(" Status: Up to date")
|
||||
else:
|
||||
print(" Status: Unknown")
|
||||
|
||||
if result.has_changes:
|
||||
print(f" Changed files: {len(result.changed_files)}")
|
||||
print(f" New files: {len(result.new_files)}")
|
||||
print(f" Removed files: {len(result.removed_files)}")
|
||||
|
||||
print()
|
||||
|
||||
if any_updates:
|
||||
print("Run 'ring-installer update' to apply updates.")
|
||||
return 0
|
||||
else:
|
||||
print("All platforms are up to date.")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_sync(args: argparse.Namespace) -> int:
|
||||
"""Handle sync command - sync components across platforms."""
|
||||
# Find Ring source
|
||||
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
|
||||
|
||||
print(f"Ring source: {source_path}")
|
||||
|
||||
# Determine target platforms
|
||||
if args.platforms:
|
||||
platforms = validate_platforms(parse_platforms(args.platforms))
|
||||
if not platforms:
|
||||
return 1
|
||||
else:
|
||||
detected = detect_installed_platforms()
|
||||
if detected:
|
||||
platforms = [p.platform_id for p in detected]
|
||||
else:
|
||||
print("No platforms detected. Specify with --platforms.")
|
||||
return 1
|
||||
|
||||
if len(platforms) < 2:
|
||||
print("Sync requires at least 2 platforms.")
|
||||
print("Specify platforms with --platforms (e.g., --platforms claude,cursor)")
|
||||
return 1
|
||||
|
||||
print(f"Syncing platforms: {', '.join(platforms)}")
|
||||
|
||||
# Build targets
|
||||
targets = [InstallTarget(platform=p) for p in platforms]
|
||||
|
||||
# Build options
|
||||
options = InstallOptions(
|
||||
dry_run=args.dry_run,
|
||||
force=True,
|
||||
backup=not args.no_backup,
|
||||
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,
|
||||
)
|
||||
|
||||
if args.dry_run:
|
||||
print("\n[DRY RUN] No changes will be made.\n")
|
||||
|
||||
callback = progress_callback if not args.quiet and not args.verbose else None
|
||||
|
||||
# Sync platforms
|
||||
sync_result = sync_platforms(source_path, targets, options, callback)
|
||||
|
||||
if callback:
|
||||
print()
|
||||
|
||||
# Display results
|
||||
print("\nSync Results:")
|
||||
print("-" * 40)
|
||||
|
||||
if sync_result.drift_detected:
|
||||
print("\nDrift detected between platforms:")
|
||||
for platform, details in sync_result.drift_details.items():
|
||||
print(f" {platform}:")
|
||||
for detail in details:
|
||||
print(f" - {detail}")
|
||||
|
||||
print(f"\nPlatforms synced: {', '.join(sync_result.platforms_synced) or 'None'}")
|
||||
if sync_result.platforms_skipped:
|
||||
print(f"Platforms skipped: {', '.join(sync_result.platforms_skipped)}")
|
||||
|
||||
# Show per-platform results
|
||||
if args.verbose:
|
||||
for platform, result in sync_result.install_results.items():
|
||||
print(f"\n{platform}:")
|
||||
print_result(result, verbose=True)
|
||||
|
||||
success = len(sync_result.platforms_skipped) == 0
|
||||
return 0 if success else 1
|
||||
|
||||
|
||||
def cmd_uninstall(args: argparse.Namespace) -> int:
|
||||
"""Handle uninstall command."""
|
||||
# Determine target platforms
|
||||
if args.platforms:
|
||||
platforms = validate_platforms(parse_platforms(args.platforms))
|
||||
if not platforms:
|
||||
return 1
|
||||
else:
|
||||
detected = detect_installed_platforms()
|
||||
if detected:
|
||||
platforms = [p.platform_id for p in detected]
|
||||
else:
|
||||
print("No platforms detected. Specify with --platforms.")
|
||||
return 1
|
||||
|
||||
print(f"Uninstalling from: {', '.join(platforms)}")
|
||||
|
||||
if not args.force:
|
||||
confirm = input("Are you sure? This will remove all Ring components. [y/N] ")
|
||||
if confirm.lower() != "y":
|
||||
print("Aborted.")
|
||||
return 0
|
||||
|
||||
targets = [InstallTarget(platform=p) for p in platforms]
|
||||
|
||||
options = InstallOptions(
|
||||
dry_run=args.dry_run,
|
||||
force=args.force,
|
||||
backup=not args.no_backup,
|
||||
verbose=args.verbose,
|
||||
)
|
||||
|
||||
if args.dry_run:
|
||||
print("\n[DRY RUN] No changes will be made.\n")
|
||||
|
||||
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):
|
||||
result = uninstall_with_manifest(targets, options, callback)
|
||||
else:
|
||||
result = uninstall(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_list(args: argparse.Namespace) -> int:
|
||||
"""Handle list command."""
|
||||
if args.platform:
|
||||
platforms = [args.platform]
|
||||
else:
|
||||
detected = detect_installed_platforms()
|
||||
if detected:
|
||||
platforms = [p.platform_id for p in detected]
|
||||
else:
|
||||
print("No platforms detected. Specify with --platform.")
|
||||
return 1
|
||||
|
||||
for platform in platforms:
|
||||
print(f"\n{platform.title()} - Installed Components:")
|
||||
print("-" * 40)
|
||||
|
||||
try:
|
||||
installed = list_installed(platform)
|
||||
total = 0
|
||||
|
||||
for component_type, components in installed.items():
|
||||
if components:
|
||||
print(f"\n {component_type.title()}:")
|
||||
for component in components:
|
||||
print(f" - {component}")
|
||||
total += len(components)
|
||||
|
||||
if total == 0:
|
||||
print(" (no components installed)")
|
||||
else:
|
||||
print(f"\n Total: {total} components")
|
||||
|
||||
except Exception as e:
|
||||
print(f" Error: {e}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
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,
|
||||
})
|
||||
print(json.dumps(data, indent=2))
|
||||
else:
|
||||
print_detection_report()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_platforms(args: argparse.Namespace) -> int:
|
||||
"""Handle platforms command - list all supported platforms."""
|
||||
print("Supported Platforms:")
|
||||
print("-" * 50)
|
||||
|
||||
for platform_info in list_platforms():
|
||||
native = "(native)" if platform_info["native_format"] else ""
|
||||
print(f"\n {platform_info['name']} ({platform_info['id']}) {native}")
|
||||
print(f" Components: {', '.join(platform_info['components'])}")
|
||||
|
||||
terminology = platform_info["terminology"]
|
||||
if any(k != v for k, v in terminology.items()):
|
||||
print(" Terminology:")
|
||||
for ring_term, platform_term in terminology.items():
|
||||
if ring_term != platform_term:
|
||||
print(f" {ring_term} -> {platform_term}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Main entry point for the CLI."""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="ring-installer",
|
||||
description="Ring multi-platform AI agent skill installer",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
%(prog)s install Install to all detected platforms
|
||||
%(prog)s install --platforms claude Install to Claude Code only
|
||||
%(prog)s install --dry-run Show what would be installed
|
||||
%(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__}"
|
||||
)
|
||||
|
||||
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(
|
||||
"--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",
|
||||
action="store_true",
|
||||
help="Show what would be done without making changes"
|
||||
)
|
||||
install_parser.add_argument(
|
||||
"--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"
|
||||
)
|
||||
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"
|
||||
)
|
||||
|
||||
# Update command
|
||||
update_parser = subparsers.add_parser("update", help="Update Ring components")
|
||||
update_parser.add_argument("--source", "-s", help="Path to Ring source directory")
|
||||
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("--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("--smart", action="store_true", help="Only update changed files")
|
||||
|
||||
# Check command
|
||||
check_parser = subparsers.add_parser("check", help="Check for available updates")
|
||||
check_parser.add_argument("--source", "-s", help="Path to Ring source directory")
|
||||
check_parser.add_argument("--platforms", "-p", help="Comma-separated list of target platforms")
|
||||
|
||||
# Sync command
|
||||
sync_parser = subparsers.add_parser("sync", help="Sync Ring components across platforms")
|
||||
sync_parser.add_argument("--source", "-s", help="Path to Ring source directory")
|
||||
sync_parser.add_argument("--platforms", "-p", help="Comma-separated list of platforms to sync")
|
||||
sync_parser.add_argument("--plugins", help="Comma-separated list of plugins to sync")
|
||||
sync_parser.add_argument("--exclude", help="Comma-separated list of plugins to exclude")
|
||||
sync_parser.add_argument("--dry-run", "-n", action="store_true", help="Show what would be done")
|
||||
sync_parser.add_argument("--no-backup", action="store_true", help="Don't create backups")
|
||||
sync_parser.add_argument("--verbose", "-v", action="store_true", help="Show detailed output")
|
||||
sync_parser.add_argument("--quiet", "-q", action="store_true", help="Suppress progress output")
|
||||
|
||||
# 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("--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")
|
||||
|
||||
# List command
|
||||
list_parser = subparsers.add_parser("list", help="List installed components")
|
||||
list_parser.add_argument("--platform", help="Platform to list components for")
|
||||
|
||||
# Detect command
|
||||
detect_parser = subparsers.add_parser("detect", help="Detect installed platforms")
|
||||
detect_parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
|
||||
# Platforms command
|
||||
platforms_parser = subparsers.add_parser("platforms", help="List supported platforms")
|
||||
|
||||
# Parse arguments
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
return 0
|
||||
|
||||
# Dispatch to command handler
|
||||
commands = {
|
||||
"install": cmd_install,
|
||||
"update": cmd_update,
|
||||
"check": cmd_check,
|
||||
"sync": cmd_sync,
|
||||
"uninstall": cmd_uninstall,
|
||||
"list": cmd_list,
|
||||
"detect": cmd_detect,
|
||||
"platforms": cmd_platforms,
|
||||
}
|
||||
|
||||
handler = commands.get(args.command)
|
||||
if handler:
|
||||
try:
|
||||
return handler(args)
|
||||
except KeyboardInterrupt:
|
||||
print("\nAborted.")
|
||||
return 130
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
if hasattr(args, "verbose") and args.verbose:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
else:
|
||||
parser.print_help()
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
BIN
installer/ring_installer/__pycache__/core.cpython-314.pyc
Normal file
BIN
installer/ring_installer/__pycache__/core.cpython-314.pyc
Normal file
Binary file not shown.
|
|
@ -13,6 +13,33 @@ Supported Platforms:
|
|||
|
||||
from typing import Dict, Type, Optional
|
||||
|
||||
|
||||
class PlatformID:
|
||||
"""
|
||||
Platform identifier constants.
|
||||
|
||||
Use these instead of magic strings when referencing platforms.
|
||||
|
||||
Example:
|
||||
if platform == PlatformID.CLAUDE:
|
||||
# handle Claude Code
|
||||
"""
|
||||
CLAUDE = "claude"
|
||||
FACTORY = "factory"
|
||||
CURSOR = "cursor"
|
||||
CLINE = "cline"
|
||||
|
||||
@classmethod
|
||||
def all(cls) -> list:
|
||||
"""Return all platform identifiers."""
|
||||
return [cls.CLAUDE, cls.FACTORY, cls.CURSOR, cls.CLINE]
|
||||
|
||||
@classmethod
|
||||
def is_valid(cls, platform: str) -> bool:
|
||||
"""Check if a platform identifier is valid."""
|
||||
return platform.lower() in cls.all()
|
||||
|
||||
|
||||
from ring_installer.adapters.base import PlatformAdapter
|
||||
from ring_installer.adapters.claude import ClaudeAdapter
|
||||
from ring_installer.adapters.factory import FactoryAdapter
|
||||
|
|
@ -21,10 +48,10 @@ from ring_installer.adapters.cline import ClineAdapter
|
|||
|
||||
# Registry of supported platforms and their adapters
|
||||
ADAPTER_REGISTRY: Dict[str, Type[PlatformAdapter]] = {
|
||||
"claude": ClaudeAdapter,
|
||||
"factory": FactoryAdapter,
|
||||
"cursor": CursorAdapter,
|
||||
"cline": ClineAdapter,
|
||||
PlatformID.CLAUDE: ClaudeAdapter,
|
||||
PlatformID.FACTORY: FactoryAdapter,
|
||||
PlatformID.CURSOR: CursorAdapter,
|
||||
PlatformID.CLINE: ClineAdapter,
|
||||
}
|
||||
|
||||
# List of supported platform identifiers
|
||||
|
|
@ -101,6 +128,8 @@ def list_platforms() -> list[dict]:
|
|||
|
||||
|
||||
__all__ = [
|
||||
# Platform identifiers
|
||||
"PlatformID",
|
||||
# Base class
|
||||
"PlatformAdapter",
|
||||
# Concrete adapters
|
||||
|
|
|
|||
|
|
@ -262,33 +262,76 @@ class FactoryAdapter(PlatformAdapter):
|
|||
"""
|
||||
Replace agent references with droid references.
|
||||
|
||||
This function performs selective replacement to avoid replacing
|
||||
"agent" in unrelated contexts like "user agent", URLs, or code blocks.
|
||||
|
||||
Args:
|
||||
text: Text containing agent references
|
||||
|
||||
Returns:
|
||||
Text with droid references
|
||||
"""
|
||||
# Pattern replacements for various contexts
|
||||
replacements = [
|
||||
# Exact matches (case sensitive)
|
||||
(r'\bagent\b', 'droid'),
|
||||
(r'\bAgent\b', 'Droid'),
|
||||
(r'\bAGENT\b', 'DROID'),
|
||||
(r'\bagents\b', 'droids'),
|
||||
(r'\bAgents\b', 'Droids'),
|
||||
(r'\bAGENTS\b', 'DROIDS'),
|
||||
# Compound terms
|
||||
(r'\bsubagent\b', 'subdroid'),
|
||||
(r'\bSubagent\b', 'Subdroid'),
|
||||
(r'\bsubagent_type\b', 'subdroid_type'),
|
||||
# First, protect code blocks and URLs from replacement
|
||||
protected_regions = []
|
||||
|
||||
# Protect code blocks (fenced)
|
||||
code_block_pattern = r'```[\s\S]*?```'
|
||||
for match in re.finditer(code_block_pattern, text):
|
||||
protected_regions.append((match.start(), match.end()))
|
||||
|
||||
# Protect inline code
|
||||
inline_code_pattern = r'`[^`]+`'
|
||||
for match in re.finditer(inline_code_pattern, text):
|
||||
protected_regions.append((match.start(), match.end()))
|
||||
|
||||
# Protect URLs
|
||||
url_pattern = r'https?://[^\s)]+|www\.[^\s)]+'
|
||||
for match in re.finditer(url_pattern, text):
|
||||
protected_regions.append((match.start(), match.end()))
|
||||
|
||||
def is_protected(pos: int) -> bool:
|
||||
"""Check if a position is within a protected region."""
|
||||
return any(start <= pos < end for start, end in protected_regions)
|
||||
|
||||
# Ring-specific context patterns
|
||||
ring_contexts = [
|
||||
# Tool references
|
||||
(r'"ring:([^"]*)-agent"', r'"ring:\1-droid"'),
|
||||
(r"'ring:([^']*)-agent'", r"'ring:\1-droid'"),
|
||||
# Subagent references
|
||||
(r'\bsubagent_type\b', 'subdroid_type'),
|
||||
(r'\bsubagent\b', 'subdroid'),
|
||||
(r'\bSubagent\b', 'Subdroid'),
|
||||
]
|
||||
|
||||
result = text
|
||||
for pattern, replacement in replacements:
|
||||
result = re.sub(pattern, replacement, result)
|
||||
for pattern, replacement in ring_contexts:
|
||||
# Replace only in non-protected regions
|
||||
matches = list(re.finditer(pattern, result))
|
||||
offset = 0
|
||||
for match in matches:
|
||||
if not is_protected(match.start() + offset):
|
||||
result = result[:match.start() + offset] + re.sub(pattern, replacement, match.group()) + result[match.end() + offset:]
|
||||
offset += len(re.sub(pattern, replacement, match.group())) - len(match.group())
|
||||
|
||||
# General agent terminology (with exclusions)
|
||||
general_replacements = [
|
||||
# Skip "user agent" and similar patterns
|
||||
(r'\b(?<!user\s)(?<!User\s)(?<!USER\s)agent\b(?!\s+string)(?!\s+header)', 'droid'),
|
||||
(r'\b(?<!user\s)(?<!User\s)(?<!USER\s)Agent\b(?!\s+string)(?!\s+header)', 'Droid'),
|
||||
(r'\bAGENT\b(?!\s+STRING)(?!\s+HEADER)', 'DROID'),
|
||||
(r'\b(?<!user\s)(?<!User\s)(?<!USER\s)agents\b(?!\s+strings)(?!\s+headers)', 'droids'),
|
||||
(r'\b(?<!user\s)(?<!User\s)(?<!USER\s)Agents\b(?!\s+strings)(?!\s+headers)', 'Droids'),
|
||||
(r'\bAGENTS\b(?!\s+STRINGS)(?!\s+HEADERS)', 'DROIDS'),
|
||||
]
|
||||
|
||||
for pattern, replacement in general_replacements:
|
||||
matches = list(re.finditer(pattern, result))
|
||||
offset = 0
|
||||
for match in matches:
|
||||
if not is_protected(match.start() + offset):
|
||||
result = result[:match.start() + offset] + replacement + result[match.end() + offset:]
|
||||
offset += len(replacement) - len(match.group())
|
||||
|
||||
return result
|
||||
|
||||
|
|
|
|||
|
|
@ -47,7 +47,20 @@ class InstallTarget:
|
|||
f"Supported: {', '.join(SUPPORTED_PLATFORMS)}"
|
||||
)
|
||||
if self.path is not None:
|
||||
self.path = Path(self.path).expanduser()
|
||||
self.path = Path(self.path).expanduser().resolve()
|
||||
# Validate path is within expected boundaries
|
||||
home = Path.home()
|
||||
try:
|
||||
self.path.relative_to(home)
|
||||
except ValueError:
|
||||
# Path not under home - check if it's a reasonable location
|
||||
allowed = [Path("/opt"), Path("/usr/local")]
|
||||
if not any(
|
||||
self.path.is_relative_to(p) for p in allowed if p.exists()
|
||||
):
|
||||
raise ValueError(
|
||||
f"Install path must be under home directory or /opt, /usr/local: {self.path}"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -92,6 +105,7 @@ class InstallResult:
|
|||
components_installed: Count of successfully installed components
|
||||
components_failed: Count of failed component installations
|
||||
components_skipped: Count of skipped components
|
||||
components_removed: Count of removed components (for uninstall operations)
|
||||
errors: List of error messages
|
||||
warnings: List of warning messages
|
||||
details: Detailed results per component
|
||||
|
|
@ -102,6 +116,7 @@ class InstallResult:
|
|||
components_installed: int = 0
|
||||
components_failed: int = 0
|
||||
components_skipped: int = 0
|
||||
components_removed: int = 0
|
||||
errors: List[str] = field(default_factory=list)
|
||||
warnings: List[str] = field(default_factory=list)
|
||||
details: List[ComponentResult] = field(default_factory=list)
|
||||
|
|
@ -139,11 +154,22 @@ class InstallResult:
|
|||
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"
|
||||
))
|
||||
|
||||
def finalize(self) -> None:
|
||||
"""Set the overall status based on component results."""
|
||||
if self.components_failed == 0 and self.components_installed > 0:
|
||||
total_success = self.components_installed + self.components_removed
|
||||
if self.components_failed == 0 and total_success > 0:
|
||||
self.status = InstallStatus.SUCCESS
|
||||
elif self.components_installed > 0:
|
||||
elif total_success > 0:
|
||||
self.status = InstallStatus.PARTIAL
|
||||
elif self.components_skipped > 0 and self.components_failed == 0:
|
||||
self.status = InstallStatus.SKIPPED
|
||||
|
|
@ -206,9 +232,17 @@ def discover_ring_components(ring_path: Path, plugin_names: Optional[List[str]]
|
|||
with open(marketplace_path) as f:
|
||||
marketplace = json.load(f)
|
||||
|
||||
# Validate marketplace has plugins
|
||||
if not marketplace.get("plugins"):
|
||||
import warnings
|
||||
warnings.warn(f"marketplace.json contains no plugins at {marketplace_path}")
|
||||
return {}
|
||||
|
||||
# Process each plugin in marketplace
|
||||
for plugin in marketplace.get("plugins", []):
|
||||
plugin_name = plugin.get("name", "").replace("ring-", "")
|
||||
name = plugin.get("name", "")
|
||||
# Only strip "ring-" prefix, not from anywhere in the string
|
||||
plugin_name = name[5:] if name.startswith("ring-") else name
|
||||
source = plugin.get("source", "")
|
||||
|
||||
# Check filters
|
||||
|
|
@ -549,3 +583,464 @@ def list_installed(platform: str) -> Dict[str, List[str]]:
|
|||
installed[component_type].append(file.name)
|
||||
|
||||
return installed
|
||||
|
||||
|
||||
@dataclass
|
||||
class UpdateCheckResult:
|
||||
"""
|
||||
Result of checking for updates.
|
||||
|
||||
Attributes:
|
||||
platform: Platform identifier
|
||||
installed_version: Currently installed version
|
||||
available_version: Available version in source
|
||||
update_available: Whether an update is available
|
||||
changed_files: Files that have changed
|
||||
new_files: New files to be added
|
||||
removed_files: Files to be removed
|
||||
"""
|
||||
platform: str
|
||||
installed_version: Optional[str]
|
||||
available_version: Optional[str]
|
||||
update_available: bool
|
||||
changed_files: List[str] = field(default_factory=list)
|
||||
new_files: List[str] = field(default_factory=list)
|
||||
removed_files: List[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def has_changes(self) -> bool:
|
||||
"""Check if there are any changes."""
|
||||
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]:
|
||||
"""
|
||||
Check for available updates on target platforms.
|
||||
|
||||
Args:
|
||||
source_path: Path to Ring source
|
||||
targets: List of platforms to check
|
||||
|
||||
Returns:
|
||||
Dictionary mapping platform names to UpdateCheckResult
|
||||
"""
|
||||
from ring_installer.utils.version import (
|
||||
check_for_updates,
|
||||
get_ring_version,
|
||||
)
|
||||
|
||||
results: Dict[str, UpdateCheckResult] = {}
|
||||
manifest = load_manifest()
|
||||
|
||||
for target in targets:
|
||||
adapter = get_adapter(target.platform, manifest.get("platforms", {}).get(target.platform))
|
||||
install_path = target.path or adapter.get_install_path()
|
||||
|
||||
update_info = check_for_updates(source_path, install_path, target.platform)
|
||||
|
||||
results[target.platform] = UpdateCheckResult(
|
||||
platform=target.platform,
|
||||
installed_version=update_info.installed_version,
|
||||
available_version=update_info.available_version,
|
||||
update_available=update_info.update_available,
|
||||
changed_files=update_info.changed_files,
|
||||
new_files=update_info.new_files,
|
||||
removed_files=update_info.removed_files,
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def update_with_diff(
|
||||
source_path: Path,
|
||||
targets: List[InstallTarget],
|
||||
options: Optional[InstallOptions] = None,
|
||||
progress_callback: Optional[Callable[[str, int, int], None]] = None
|
||||
) -> InstallResult:
|
||||
"""
|
||||
Update Ring components, only updating changed files.
|
||||
|
||||
This is a smarter update that compares installed files with source
|
||||
and only updates files that have changed.
|
||||
|
||||
Args:
|
||||
source_path: Path to the Ring repository/installation
|
||||
targets: List of installation targets
|
||||
options: Installation options
|
||||
progress_callback: Optional callback for progress updates
|
||||
|
||||
Returns:
|
||||
InstallResult with details about what was updated
|
||||
"""
|
||||
from ring_installer.utils.fs import (
|
||||
copy_with_transform,
|
||||
backup_existing,
|
||||
ensure_directory,
|
||||
get_file_hash,
|
||||
)
|
||||
from ring_installer.utils.version import (
|
||||
get_ring_version,
|
||||
get_manifest_path,
|
||||
InstallManifest,
|
||||
save_install_manifest,
|
||||
)
|
||||
|
||||
options = options or InstallOptions()
|
||||
result = InstallResult(status=InstallStatus.SUCCESS, targets=[t.platform for t in targets])
|
||||
|
||||
manifest = load_manifest()
|
||||
source_version = get_ring_version(source_path) or "0.0.0"
|
||||
|
||||
# Discover components
|
||||
components = discover_ring_components(
|
||||
source_path,
|
||||
plugin_names=options.plugin_names,
|
||||
exclude_plugins=options.exclude_plugins
|
||||
)
|
||||
|
||||
# Process each target
|
||||
for target in targets:
|
||||
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()
|
||||
|
||||
# Load existing manifest
|
||||
existing_manifest = InstallManifest.load(get_manifest_path(install_path))
|
||||
existing_files = existing_manifest.files if existing_manifest else {}
|
||||
|
||||
# Track installed files for new manifest
|
||||
installed_files: Dict[str, str] = {}
|
||||
installed_plugins: List[str] = []
|
||||
|
||||
# Process each plugin
|
||||
for plugin_name, plugin_components in components.items():
|
||||
installed_plugins.append(plugin_name)
|
||||
|
||||
# Process each component type
|
||||
for component_type, files in plugin_components.items():
|
||||
if component_type not in component_mapping:
|
||||
continue
|
||||
|
||||
if target.components and component_type not in target.components:
|
||||
continue
|
||||
|
||||
target_config = component_mapping[component_type]
|
||||
target_dir = install_path / target_config["target_dir"]
|
||||
|
||||
if len(components) > 1:
|
||||
target_dir = target_dir / plugin_name
|
||||
|
||||
if not options.dry_run:
|
||||
ensure_directory(target_dir)
|
||||
|
||||
for source_file in files:
|
||||
if progress_callback:
|
||||
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())
|
||||
)
|
||||
|
||||
# Determine target path
|
||||
if component_type == "skills":
|
||||
skill_name = source_file.parent.name
|
||||
target_file = target_dir / skill_name / source_file.name
|
||||
else:
|
||||
target_filename = adapter.get_target_filename(
|
||||
source_file.name,
|
||||
component_type.rstrip("s")
|
||||
)
|
||||
target_file = target_dir / target_filename
|
||||
|
||||
# Compute source hash
|
||||
try:
|
||||
source_hash = get_file_hash(source_file)
|
||||
except Exception as e:
|
||||
result.add_failure(source_file, target_file, f"Hash error: {e}")
|
||||
continue
|
||||
|
||||
# Check if update needed by comparing source hash with manifest
|
||||
relative_path = str(target_file.relative_to(install_path))
|
||||
target_exists = target_file.exists()
|
||||
|
||||
# Get stored source hash from manifest (backward compatible)
|
||||
stored_source_hash = existing_files.get(relative_path, "")
|
||||
|
||||
if target_exists and stored_source_hash:
|
||||
# Compare current source hash with stored source hash
|
||||
if source_hash == stored_source_hash:
|
||||
# Source file unchanged since last install, skip
|
||||
installed_files[relative_path] = source_hash
|
||||
result.add_skip(source_file, target_file, "No changes")
|
||||
continue
|
||||
elif target_exists and not stored_source_hash:
|
||||
# Old manifest without source hashes - always update for safety
|
||||
# This ensures first run after upgrade updates everything
|
||||
pass
|
||||
|
||||
# File needs update
|
||||
try:
|
||||
with open(source_file, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
except Exception as e:
|
||||
result.add_failure(source_file, target_file, f"Read error: {e}")
|
||||
continue
|
||||
|
||||
# Transform content
|
||||
try:
|
||||
metadata = {
|
||||
"name": source_file.stem,
|
||||
"source_path": str(source_file),
|
||||
"plugin": plugin_name
|
||||
}
|
||||
|
||||
if component_type == "agents":
|
||||
transformed = adapter.transform_agent(content, metadata)
|
||||
elif component_type == "commands":
|
||||
transformed = adapter.transform_command(content, metadata)
|
||||
elif component_type == "skills":
|
||||
transformed = adapter.transform_skill(content, metadata)
|
||||
elif component_type == "hooks":
|
||||
transformed = adapter.transform_hook(content, metadata)
|
||||
else:
|
||||
transformed = content
|
||||
except Exception as e:
|
||||
result.add_failure(source_file, target_file, f"Transform error: {e}")
|
||||
continue
|
||||
|
||||
# Write file
|
||||
if options.dry_run:
|
||||
if options.verbose:
|
||||
action = "update" if target_exists else "install"
|
||||
result.warnings.append(
|
||||
f"[DRY RUN] Would {action} {source_file} -> {target_file}"
|
||||
)
|
||||
result.add_success(source_file, target_file)
|
||||
else:
|
||||
try:
|
||||
backup_path = None
|
||||
if target_exists and options.backup:
|
||||
backup_path = backup_existing(target_file)
|
||||
|
||||
copy_with_transform(
|
||||
source_file,
|
||||
target_file,
|
||||
transform_func=lambda _: transformed
|
||||
)
|
||||
result.add_success(source_file, target_file, backup_path)
|
||||
except Exception as e:
|
||||
result.add_failure(source_file, target_file, f"Write error: {e}")
|
||||
continue
|
||||
|
||||
installed_files[relative_path] = source_hash
|
||||
|
||||
# Save updated manifest
|
||||
if not options.dry_run:
|
||||
save_install_manifest(
|
||||
install_path=install_path,
|
||||
source_path=source_path,
|
||||
platform=target.platform,
|
||||
version=source_version,
|
||||
plugins=installed_plugins,
|
||||
installed_files=installed_files
|
||||
)
|
||||
|
||||
result.finalize()
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncResult:
|
||||
"""
|
||||
Result of syncing platforms.
|
||||
|
||||
Attributes:
|
||||
platforms_synced: List of platforms that were synced
|
||||
platforms_skipped: List of platforms that were skipped
|
||||
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
|
||||
drift_details: Dict[str, List[str]] = field(default_factory=dict)
|
||||
install_results: Dict[str, InstallResult] = field(default_factory=dict)
|
||||
|
||||
|
||||
def sync_platforms(
|
||||
source_path: Path,
|
||||
targets: List[InstallTarget],
|
||||
options: Optional[InstallOptions] = None,
|
||||
progress_callback: Optional[Callable[[str, int, int], None]] = None
|
||||
) -> SyncResult:
|
||||
"""
|
||||
Sync Ring components across multiple platforms.
|
||||
|
||||
Ensures all target platforms have consistent Ring installations
|
||||
by detecting drift and re-applying transformations.
|
||||
|
||||
Args:
|
||||
source_path: Path to Ring source
|
||||
targets: List of platforms to sync
|
||||
options: Sync options
|
||||
progress_callback: Optional progress callback
|
||||
|
||||
Returns:
|
||||
SyncResult with details about the sync operation
|
||||
"""
|
||||
from ring_installer.utils.version import (
|
||||
get_installed_version,
|
||||
get_ring_version,
|
||||
)
|
||||
|
||||
options = options or InstallOptions()
|
||||
sync_result = SyncResult()
|
||||
|
||||
manifest = load_manifest()
|
||||
source_version = get_ring_version(source_path) or "0.0.0"
|
||||
|
||||
# Check each platform for drift
|
||||
platform_versions: Dict[str, Optional[str]] = {}
|
||||
|
||||
for target in targets:
|
||||
adapter = get_adapter(target.platform, manifest.get("platforms", {}).get(target.platform))
|
||||
install_path = target.path or adapter.get_install_path()
|
||||
|
||||
installed_version = get_installed_version(install_path, target.platform)
|
||||
platform_versions[target.platform] = installed_version
|
||||
|
||||
# Check for drift from source
|
||||
if installed_version != source_version:
|
||||
sync_result.drift_detected = True
|
||||
sync_result.drift_details[target.platform] = [
|
||||
f"Version mismatch: installed={installed_version}, source={source_version}"
|
||||
]
|
||||
|
||||
# Sync each platform
|
||||
for target in targets:
|
||||
if progress_callback:
|
||||
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
|
||||
)
|
||||
|
||||
sync_result.install_results[target.platform] = install_result
|
||||
|
||||
if install_result.status in [InstallStatus.SUCCESS, InstallStatus.SKIPPED]:
|
||||
sync_result.platforms_synced.append(target.platform)
|
||||
else:
|
||||
sync_result.platforms_skipped.append(target.platform)
|
||||
|
||||
return sync_result
|
||||
|
||||
|
||||
def uninstall_with_manifest(
|
||||
targets: List[InstallTarget],
|
||||
options: Optional[InstallOptions] = None,
|
||||
progress_callback: Optional[Callable[[str, int, int], None]] = None
|
||||
) -> InstallResult:
|
||||
"""
|
||||
Remove Ring components using the install manifest for precision.
|
||||
|
||||
Only removes files that were installed by Ring, preserving
|
||||
user modifications outside the manifest.
|
||||
|
||||
Args:
|
||||
targets: List of platforms to uninstall from
|
||||
options: Uninstall options
|
||||
progress_callback: Optional progress callback
|
||||
|
||||
Returns:
|
||||
InstallResult with details about what was removed
|
||||
"""
|
||||
from ring_installer.utils.fs import safe_remove
|
||||
from ring_installer.utils.version import get_manifest_path, InstallManifest
|
||||
|
||||
options = options or InstallOptions()
|
||||
result = InstallResult(status=InstallStatus.SUCCESS, targets=[t.platform for t in targets])
|
||||
|
||||
manifest = load_manifest()
|
||||
|
||||
for target in targets:
|
||||
adapter = get_adapter(target.platform, manifest.get("platforms", {}).get(target.platform))
|
||||
install_path = target.path or adapter.get_install_path()
|
||||
|
||||
# Load install manifest
|
||||
install_manifest = InstallManifest.load(get_manifest_path(install_path))
|
||||
|
||||
if not install_manifest:
|
||||
result.warnings.append(
|
||||
f"No install manifest found for {target.platform}, "
|
||||
"falling back to directory removal"
|
||||
)
|
||||
# Fall back to regular uninstall
|
||||
component_mapping = adapter.get_component_mapping()
|
||||
for component_type, config in component_mapping.items():
|
||||
target_dir = install_path / config["target_dir"]
|
||||
if target_dir.exists():
|
||||
if options.dry_run:
|
||||
if options.verbose:
|
||||
result.warnings.append(f"[DRY RUN] Would remove {target_dir}")
|
||||
else:
|
||||
try:
|
||||
if options.backup:
|
||||
from ring_installer.utils.fs import backup_existing
|
||||
backup_existing(target_dir)
|
||||
shutil.rmtree(target_dir)
|
||||
result.components_installed += 1
|
||||
except Exception as e:
|
||||
result.errors.append(f"Failed to remove {target_dir}: {e}")
|
||||
result.components_failed += 1
|
||||
continue
|
||||
|
||||
# Remove files from manifest
|
||||
for file_path in install_manifest.files.keys():
|
||||
full_path = install_path / file_path
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(
|
||||
f"Removing {file_path}",
|
||||
list(install_manifest.files.keys()).index(file_path),
|
||||
len(install_manifest.files)
|
||||
)
|
||||
|
||||
if options.dry_run:
|
||||
if options.verbose:
|
||||
result.warnings.append(f"[DRY RUN] Would remove {full_path}")
|
||||
result.components_installed += 1
|
||||
continue
|
||||
|
||||
if not full_path.exists():
|
||||
result.warnings.append(f"File already removed: {full_path}")
|
||||
continue
|
||||
|
||||
try:
|
||||
if options.backup:
|
||||
from ring_installer.utils.fs import backup_existing
|
||||
backup_existing(full_path)
|
||||
|
||||
safe_remove(full_path)
|
||||
result.components_installed += 1
|
||||
except Exception as e:
|
||||
result.errors.append(f"Failed to remove {full_path}: {e}")
|
||||
result.components_failed += 1
|
||||
|
||||
# Remove install manifest
|
||||
if not options.dry_run:
|
||||
manifest_path = get_manifest_path(install_path)
|
||||
safe_remove(manifest_path, missing_ok=True)
|
||||
|
||||
result.finalize()
|
||||
return result
|
||||
|
|
|
|||
360
installer/ring_installer/transformers/__init__.py
Normal file
360
installer/ring_installer/transformers/__init__.py
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
"""
|
||||
Content transformers for Ring multi-platform installer.
|
||||
|
||||
This module provides transformers for converting Ring components
|
||||
(skills, agents, commands, hooks) to platform-specific formats.
|
||||
|
||||
Supported Platforms:
|
||||
- Claude Code: Native Ring format (passthrough)
|
||||
- Factory AI: Agents -> Droids transformation
|
||||
- Cursor: Skills -> Rules, Agents/Commands -> Workflows
|
||||
- Cline: All components -> Prompts
|
||||
"""
|
||||
|
||||
from typing import Optional, Type, Dict
|
||||
|
||||
from ring_installer.transformers.base import (
|
||||
BaseTransformer,
|
||||
TransformContext,
|
||||
TransformResult,
|
||||
TransformerPipeline,
|
||||
PassthroughTransformer,
|
||||
TerminologyTransformer,
|
||||
FrontmatterTransformer,
|
||||
)
|
||||
from ring_installer.transformers.skill import (
|
||||
SkillTransformer,
|
||||
SkillTransformerFactory,
|
||||
)
|
||||
from ring_installer.transformers.agent import (
|
||||
AgentTransformer,
|
||||
AgentTransformerFactory,
|
||||
)
|
||||
from ring_installer.transformers.command import (
|
||||
CommandTransformer,
|
||||
CommandTransformerFactory,
|
||||
)
|
||||
from ring_installer.transformers.hooks import (
|
||||
HookTransformer,
|
||||
HookTransformerFactory,
|
||||
generate_hooks_json,
|
||||
parse_hooks_json,
|
||||
)
|
||||
from ring_installer.transformers.cursor_rules import (
|
||||
CursorRulesGenerator,
|
||||
CursorRulesTransformer,
|
||||
generate_cursorrules_from_skills,
|
||||
write_cursorrules,
|
||||
)
|
||||
from ring_installer.transformers.cline_prompts import (
|
||||
ClinePromptsGenerator,
|
||||
ClinePromptsTransformer,
|
||||
generate_cline_prompt,
|
||||
generate_prompts_index,
|
||||
write_cline_prompts,
|
||||
)
|
||||
|
||||
|
||||
# Component type to transformer factory mapping
|
||||
TRANSFORMER_FACTORIES: Dict[str, Dict[str, Type]] = {
|
||||
"skill": {
|
||||
"claude": SkillTransformer,
|
||||
"factory": SkillTransformer,
|
||||
"cursor": SkillTransformer,
|
||||
"cline": SkillTransformer,
|
||||
},
|
||||
"agent": {
|
||||
"claude": AgentTransformer,
|
||||
"factory": AgentTransformer,
|
||||
"cursor": AgentTransformer,
|
||||
"cline": AgentTransformer,
|
||||
},
|
||||
"command": {
|
||||
"claude": CommandTransformer,
|
||||
"factory": CommandTransformer,
|
||||
"cursor": CommandTransformer,
|
||||
"cline": CommandTransformer,
|
||||
},
|
||||
"hook": {
|
||||
"claude": HookTransformer,
|
||||
"factory": HookTransformer,
|
||||
"cursor": HookTransformer,
|
||||
"cline": HookTransformer,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_transformer(
|
||||
platform: str,
|
||||
component_type: str
|
||||
) -> BaseTransformer:
|
||||
"""
|
||||
Get a transformer for the specified platform and component type.
|
||||
|
||||
Args:
|
||||
platform: Target platform (claude, factory, cursor, cline)
|
||||
component_type: Component type (skill, agent, command, hook)
|
||||
|
||||
Returns:
|
||||
Configured transformer instance
|
||||
|
||||
Raises:
|
||||
ValueError: If platform or component type is not supported
|
||||
"""
|
||||
platform = platform.lower()
|
||||
component_type = component_type.lower().rstrip("s") # skills -> skill
|
||||
|
||||
if component_type not in TRANSFORMER_FACTORIES:
|
||||
raise ValueError(
|
||||
f"Unsupported component type: '{component_type}'. "
|
||||
f"Supported: {', '.join(TRANSFORMER_FACTORIES.keys())}"
|
||||
)
|
||||
|
||||
platform_transformers = TRANSFORMER_FACTORIES[component_type]
|
||||
|
||||
if platform not in platform_transformers:
|
||||
raise ValueError(
|
||||
f"Unsupported platform: '{platform}'. "
|
||||
f"Supported: {', '.join(platform_transformers.keys())}"
|
||||
)
|
||||
|
||||
# Use the appropriate factory
|
||||
if component_type == "skill":
|
||||
return SkillTransformerFactory.create(platform)
|
||||
elif component_type == "agent":
|
||||
return AgentTransformerFactory.create(platform)
|
||||
elif component_type == "command":
|
||||
return CommandTransformerFactory.create(platform)
|
||||
elif component_type == "hook":
|
||||
return HookTransformerFactory.create(platform)
|
||||
|
||||
# Fallback
|
||||
transformer_class = platform_transformers[platform]
|
||||
return transformer_class(platform=platform)
|
||||
|
||||
|
||||
def transform_content(
|
||||
content: str,
|
||||
platform: str,
|
||||
component_type: str,
|
||||
metadata: Optional[Dict] = None,
|
||||
source_path: str = ""
|
||||
) -> TransformResult:
|
||||
"""
|
||||
Transform content for a specific platform.
|
||||
|
||||
This is a convenience function that creates the appropriate
|
||||
transformer and context, then performs the transformation.
|
||||
|
||||
Args:
|
||||
content: Source content to transform
|
||||
platform: Target platform
|
||||
component_type: Type of component
|
||||
metadata: Optional metadata dict
|
||||
source_path: Optional source file path
|
||||
|
||||
Returns:
|
||||
TransformResult with transformed content
|
||||
"""
|
||||
transformer = get_transformer(platform, component_type)
|
||||
|
||||
context = TransformContext(
|
||||
platform=platform,
|
||||
component_type=component_type,
|
||||
source_path=source_path,
|
||||
metadata=metadata or {}
|
||||
)
|
||||
|
||||
return transformer.transform(content, context)
|
||||
|
||||
|
||||
def create_pipeline(
|
||||
platform: str,
|
||||
component_types: Optional[list] = None
|
||||
) -> TransformerPipeline:
|
||||
"""
|
||||
Create a transformation pipeline for a platform.
|
||||
|
||||
Args:
|
||||
platform: Target platform
|
||||
component_types: Optional list of component types to include
|
||||
|
||||
Returns:
|
||||
Configured TransformerPipeline
|
||||
"""
|
||||
pipeline = TransformerPipeline()
|
||||
|
||||
types_to_include = component_types or ["skill", "agent", "command"]
|
||||
|
||||
for component_type in types_to_include:
|
||||
try:
|
||||
transformer = get_transformer(platform, component_type)
|
||||
pipeline.add(transformer)
|
||||
except ValueError:
|
||||
pass # Skip unsupported combinations
|
||||
|
||||
return pipeline
|
||||
|
||||
|
||||
# Platform-specific generator functions
|
||||
def generate_cursor_output(
|
||||
skills: list,
|
||||
agents: list = None,
|
||||
commands: list = None,
|
||||
include_metadata: bool = True
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
Generate all Cursor output files.
|
||||
|
||||
Args:
|
||||
skills: List of skill content dicts
|
||||
agents: Optional list of agent content dicts
|
||||
commands: Optional list of command content dicts
|
||||
include_metadata: Include source metadata
|
||||
|
||||
Returns:
|
||||
Dict mapping output filenames to content
|
||||
"""
|
||||
agents = agents or []
|
||||
commands = commands or []
|
||||
output = {}
|
||||
|
||||
# Generate .cursorrules from skills
|
||||
if skills:
|
||||
output[".cursorrules"] = generate_cursorrules_from_skills(
|
||||
skills, include_metadata
|
||||
)
|
||||
|
||||
# Generate workflows from agents and commands
|
||||
for agent in agents:
|
||||
transformer = get_transformer("cursor", "agent")
|
||||
context = TransformContext(
|
||||
platform="cursor",
|
||||
component_type="agent",
|
||||
source_path=agent.get("source", ""),
|
||||
metadata={"name": agent.get("name", "unknown")}
|
||||
)
|
||||
result = transformer.transform(agent.get("content", ""), context)
|
||||
if result.success:
|
||||
filename = f"workflows/{agent.get('name', 'unknown')}.md"
|
||||
output[filename] = result.content
|
||||
|
||||
for command in commands:
|
||||
transformer = get_transformer("cursor", "command")
|
||||
context = TransformContext(
|
||||
platform="cursor",
|
||||
component_type="command",
|
||||
source_path=command.get("source", ""),
|
||||
metadata={"name": command.get("name", "unknown")}
|
||||
)
|
||||
result = transformer.transform(command.get("content", ""), context)
|
||||
if result.success:
|
||||
filename = f"workflows/{command.get('name', 'unknown')}.md"
|
||||
output[filename] = result.content
|
||||
|
||||
return output
|
||||
|
||||
|
||||
def generate_cline_output(
|
||||
skills: list = None,
|
||||
agents: list = None,
|
||||
commands: list = None
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
Generate all Cline output files.
|
||||
|
||||
Args:
|
||||
skills: Optional list of skill content dicts
|
||||
agents: Optional list of agent content dicts
|
||||
commands: Optional list of command content dicts
|
||||
|
||||
Returns:
|
||||
Dict mapping output filenames to content
|
||||
"""
|
||||
skills = skills or []
|
||||
agents = agents or []
|
||||
commands = commands or []
|
||||
output = {}
|
||||
|
||||
generator = ClinePromptsGenerator()
|
||||
|
||||
# Process skills
|
||||
for skill in skills:
|
||||
generator.add_component(
|
||||
skill.get("content", ""),
|
||||
"skill",
|
||||
skill.get("name", "unknown"),
|
||||
skill.get("source")
|
||||
)
|
||||
|
||||
# Process agents
|
||||
for agent in agents:
|
||||
generator.add_component(
|
||||
agent.get("content", ""),
|
||||
"agent",
|
||||
agent.get("name", "unknown"),
|
||||
agent.get("source")
|
||||
)
|
||||
|
||||
# Process commands
|
||||
for command in commands:
|
||||
generator.add_component(
|
||||
command.get("content", ""),
|
||||
"command",
|
||||
command.get("name", "unknown"),
|
||||
command.get("source")
|
||||
)
|
||||
|
||||
# Generate all prompt files
|
||||
for prompt_data in generator.prompts:
|
||||
prompt_content = generator.generate_prompt(prompt_data)
|
||||
prompt_type = prompt_data.get("type", "skill")
|
||||
name = prompt_data.get("name", "unknown")
|
||||
filename = f"prompts/{prompt_type}s/{name}.md"
|
||||
output[filename] = prompt_content
|
||||
|
||||
# Generate index
|
||||
output["prompts/index.md"] = generator.generate_index()
|
||||
|
||||
return output
|
||||
|
||||
|
||||
__all__ = [
|
||||
# Base classes
|
||||
"BaseTransformer",
|
||||
"TransformContext",
|
||||
"TransformResult",
|
||||
"TransformerPipeline",
|
||||
"PassthroughTransformer",
|
||||
"TerminologyTransformer",
|
||||
"FrontmatterTransformer",
|
||||
# Component transformers
|
||||
"SkillTransformer",
|
||||
"SkillTransformerFactory",
|
||||
"AgentTransformer",
|
||||
"AgentTransformerFactory",
|
||||
"CommandTransformer",
|
||||
"CommandTransformerFactory",
|
||||
"HookTransformer",
|
||||
"HookTransformerFactory",
|
||||
# Cursor-specific
|
||||
"CursorRulesGenerator",
|
||||
"CursorRulesTransformer",
|
||||
"generate_cursorrules_from_skills",
|
||||
"write_cursorrules",
|
||||
# Cline-specific
|
||||
"ClinePromptsGenerator",
|
||||
"ClinePromptsTransformer",
|
||||
"generate_cline_prompt",
|
||||
"generate_prompts_index",
|
||||
"write_cline_prompts",
|
||||
# Factory functions
|
||||
"get_transformer",
|
||||
"transform_content",
|
||||
"create_pipeline",
|
||||
"generate_cursor_output",
|
||||
"generate_cline_output",
|
||||
# Hook utilities
|
||||
"generate_hooks_json",
|
||||
"parse_hooks_json",
|
||||
]
|
||||
327
installer/ring_installer/transformers/agent.py
Normal file
327
installer/ring_installer/transformers/agent.py
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
"""
|
||||
Agent content transformer.
|
||||
|
||||
Transforms Ring agent markdown files to platform-specific formats.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ring_installer.transformers.base import (
|
||||
BaseTransformer,
|
||||
TransformContext,
|
||||
TransformResult,
|
||||
)
|
||||
|
||||
|
||||
class AgentTransformer(BaseTransformer):
|
||||
"""
|
||||
Transformer for Ring agent files.
|
||||
|
||||
Handles transformation of agent definitions including:
|
||||
- Claude: passthrough (native format)
|
||||
- Factory: agent -> droid, update references
|
||||
- Cursor: convert to workflow definition
|
||||
- Cline: convert to prompt template
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
platform: str,
|
||||
terminology: Optional[Dict[str, str]] = None
|
||||
):
|
||||
"""
|
||||
Initialize the agent transformer.
|
||||
|
||||
Args:
|
||||
platform: Target platform identifier
|
||||
terminology: Platform-specific terminology mapping
|
||||
"""
|
||||
super().__init__()
|
||||
self.platform = platform
|
||||
self.terminology = terminology or {}
|
||||
|
||||
def transform(self, content: str, context: TransformContext) -> TransformResult:
|
||||
"""
|
||||
Transform agent content for the target platform.
|
||||
|
||||
Args:
|
||||
content: Original agent content
|
||||
context: Transformation context
|
||||
|
||||
Returns:
|
||||
TransformResult with transformed content
|
||||
"""
|
||||
errors = self.validate(content, context)
|
||||
if errors:
|
||||
return TransformResult(content=content, success=False, errors=errors)
|
||||
|
||||
frontmatter, body = self.extract_frontmatter(content)
|
||||
|
||||
# Transform based on platform
|
||||
if self.platform == "claude":
|
||||
return self._transform_claude(frontmatter, body, context)
|
||||
elif self.platform == "factory":
|
||||
return self._transform_factory(frontmatter, body, context)
|
||||
elif self.platform == "cursor":
|
||||
return self._transform_cursor(frontmatter, body, context)
|
||||
elif self.platform == "cline":
|
||||
return self._transform_cline(frontmatter, body, context)
|
||||
else:
|
||||
return TransformResult(content=content, success=True)
|
||||
|
||||
def _transform_claude(
|
||||
self,
|
||||
frontmatter: Dict[str, Any],
|
||||
body: str,
|
||||
context: TransformContext
|
||||
) -> TransformResult:
|
||||
"""Transform agent for Claude Code (passthrough)."""
|
||||
if frontmatter:
|
||||
content = self.create_frontmatter(frontmatter) + "\n" + body
|
||||
else:
|
||||
content = body
|
||||
|
||||
return TransformResult(content=content, success=True)
|
||||
|
||||
def _transform_factory(
|
||||
self,
|
||||
frontmatter: Dict[str, Any],
|
||||
body: str,
|
||||
context: TransformContext
|
||||
) -> TransformResult:
|
||||
"""
|
||||
Transform agent to Factory AI droid format.
|
||||
|
||||
Converts terminology and structure for Factory compatibility.
|
||||
"""
|
||||
# Transform frontmatter
|
||||
transformed_fm = self._transform_factory_frontmatter(frontmatter)
|
||||
|
||||
# Transform body
|
||||
transformed_body = self._transform_factory_body(body)
|
||||
|
||||
if transformed_fm:
|
||||
content = self.create_frontmatter(transformed_fm) + "\n" + transformed_body
|
||||
else:
|
||||
content = transformed_body
|
||||
|
||||
return TransformResult(content=content, success=True)
|
||||
|
||||
def _transform_cursor(
|
||||
self,
|
||||
frontmatter: Dict[str, Any],
|
||||
body: str,
|
||||
context: TransformContext
|
||||
) -> TransformResult:
|
||||
"""
|
||||
Transform agent to Cursor workflow format.
|
||||
|
||||
Converts agent definition to workflow structure suitable
|
||||
for Cursor's multi-step operations.
|
||||
"""
|
||||
parts: List[str] = []
|
||||
|
||||
# Extract metadata
|
||||
name = frontmatter.get("name", context.metadata.get("name", "Untitled Workflow"))
|
||||
description = frontmatter.get("description", "")
|
||||
model = frontmatter.get("model", "")
|
||||
|
||||
# Build workflow header
|
||||
parts.append(f"# {self.to_title_case(name)} Workflow")
|
||||
parts.append("")
|
||||
|
||||
if description:
|
||||
clean_desc = self.clean_yaml_string(description)
|
||||
parts.append(f"**Purpose:** {clean_desc}")
|
||||
parts.append("")
|
||||
|
||||
if model:
|
||||
parts.append(f"**Recommended Model:** {model}")
|
||||
parts.append("")
|
||||
|
||||
# Output requirements from schema
|
||||
output_schema = frontmatter.get("output_schema", {})
|
||||
if output_schema:
|
||||
parts.append("## Output Requirements")
|
||||
parts.append("")
|
||||
required_sections = output_schema.get("required_sections", [])
|
||||
for section in required_sections:
|
||||
section_name = section.get("name", "")
|
||||
if section_name:
|
||||
required = " (required)" if section.get("required", True) else ""
|
||||
parts.append(f"- {section_name}{required}")
|
||||
parts.append("")
|
||||
|
||||
# Transform and add the body
|
||||
parts.append("## Workflow Steps")
|
||||
parts.append("")
|
||||
parts.append(self.transform_body_for_cursor(body))
|
||||
|
||||
return TransformResult(content="\n".join(parts), success=True)
|
||||
|
||||
def _transform_cline(
|
||||
self,
|
||||
frontmatter: Dict[str, Any],
|
||||
body: str,
|
||||
context: TransformContext
|
||||
) -> TransformResult:
|
||||
"""
|
||||
Transform agent to Cline prompt format.
|
||||
|
||||
Converts agent definition to prompt template with
|
||||
role definition and capabilities.
|
||||
"""
|
||||
parts: List[str] = []
|
||||
|
||||
# Extract metadata
|
||||
name = frontmatter.get("name", context.metadata.get("name", "Untitled Agent"))
|
||||
description = frontmatter.get("description", "")
|
||||
model = frontmatter.get("model", "")
|
||||
|
||||
# HTML comments for metadata
|
||||
parts.append(f"<!-- Prompt: {name} -->")
|
||||
parts.append("<!-- Type: agent -->")
|
||||
if model:
|
||||
parts.append(f"<!-- Recommended Model: {model} -->")
|
||||
if context.source_path:
|
||||
parts.append(f"<!-- Source: {context.source_path} -->")
|
||||
parts.append("")
|
||||
|
||||
# Title with role indicator
|
||||
parts.append(f"# {self.to_title_case(name)} Agent")
|
||||
parts.append("")
|
||||
|
||||
# Role description
|
||||
if description:
|
||||
clean_desc = self.clean_yaml_string(description)
|
||||
parts.append("## Role")
|
||||
parts.append("")
|
||||
parts.append(clean_desc)
|
||||
parts.append("")
|
||||
|
||||
# Model recommendation
|
||||
if model:
|
||||
parts.append(f"**Recommended Model:** `{model}`")
|
||||
parts.append("")
|
||||
|
||||
# Output requirements
|
||||
output_schema = frontmatter.get("output_schema", {})
|
||||
if output_schema:
|
||||
parts.append("## Expected Output Format")
|
||||
parts.append("")
|
||||
output_format = output_schema.get("format", "markdown")
|
||||
parts.append(f"Format: {output_format}")
|
||||
parts.append("")
|
||||
required_sections = output_schema.get("required_sections", [])
|
||||
if required_sections:
|
||||
parts.append("Required sections:")
|
||||
for section in required_sections:
|
||||
section_name = section.get("name", "")
|
||||
if section_name:
|
||||
parts.append(f"- {section_name}")
|
||||
parts.append("")
|
||||
|
||||
# Behavior and capabilities
|
||||
parts.append("## Behavior")
|
||||
parts.append("")
|
||||
parts.append(self.transform_body_for_cline(body))
|
||||
|
||||
return TransformResult(content="\n".join(parts), success=True)
|
||||
|
||||
def _transform_factory_frontmatter(
|
||||
self,
|
||||
frontmatter: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""Transform agent frontmatter to droid frontmatter."""
|
||||
result = dict(frontmatter)
|
||||
|
||||
# Rename agent-related fields
|
||||
if "agent" in result:
|
||||
result["droid"] = result.pop("agent")
|
||||
|
||||
if "agents" in result:
|
||||
result["droids"] = result.pop("agents")
|
||||
|
||||
if "subagent_type" in result:
|
||||
result["subdroid_type"] = result.pop("subagent_type")
|
||||
|
||||
# Add Factory-specific type
|
||||
if "type" not in result:
|
||||
result["type"] = "droid"
|
||||
|
||||
# Transform string values
|
||||
for key, value in list(result.items()):
|
||||
if isinstance(value, str):
|
||||
result[key] = self._replace_agent_references(value)
|
||||
elif isinstance(value, list):
|
||||
result[key] = [
|
||||
self._replace_agent_references(v) if isinstance(v, str) else v
|
||||
for v in value
|
||||
]
|
||||
|
||||
return result
|
||||
|
||||
def _transform_factory_body(self, body: str) -> str:
|
||||
"""Transform agent body to droid format."""
|
||||
result = self._replace_agent_references(body)
|
||||
|
||||
# Replace section headers
|
||||
replacements = [
|
||||
("# Agent ", "# Droid "),
|
||||
("## Agent ", "## Droid "),
|
||||
("### Agent ", "### Droid "),
|
||||
]
|
||||
|
||||
for old, new in replacements:
|
||||
result = result.replace(old, new)
|
||||
|
||||
return result
|
||||
|
||||
def _replace_agent_references(self, text: str) -> str:
|
||||
"""Replace agent references with droid references."""
|
||||
replacements = [
|
||||
(r'\bagent\b', 'droid'),
|
||||
(r'\bAgent\b', 'Droid'),
|
||||
(r'\bAGENT\b', 'DROID'),
|
||||
(r'\bagents\b', 'droids'),
|
||||
(r'\bAgents\b', 'Droids'),
|
||||
(r'\bAGENTS\b', 'DROIDS'),
|
||||
(r'\bsubagent\b', 'subdroid'),
|
||||
(r'\bSubagent\b', 'Subdroid'),
|
||||
(r'\bsubagent_type\b', 'subdroid_type'),
|
||||
(r'"ring:([^"]*)-agent"', r'"ring:\1-droid"'),
|
||||
(r"'ring:([^']*)-agent'", r"'ring:\1-droid'"),
|
||||
]
|
||||
|
||||
result = text
|
||||
for pattern, replacement in replacements:
|
||||
result = re.sub(pattern, replacement, result)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
|
||||
class AgentTransformerFactory:
|
||||
"""Factory for creating platform-specific agent transformers."""
|
||||
|
||||
PLATFORM_TERMINOLOGY = {
|
||||
"claude": {"agent": "agent"},
|
||||
"factory": {"agent": "droid"},
|
||||
"cursor": {"agent": "workflow"},
|
||||
"cline": {"agent": "prompt"},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def create(cls, platform: str) -> AgentTransformer:
|
||||
"""
|
||||
Create an agent transformer for the specified platform.
|
||||
|
||||
Args:
|
||||
platform: Target platform identifier
|
||||
|
||||
Returns:
|
||||
Configured AgentTransformer
|
||||
"""
|
||||
terminology = cls.PLATFORM_TERMINOLOGY.get(platform, {})
|
||||
return AgentTransformer(platform=platform, terminology=terminology)
|
||||
434
installer/ring_installer/transformers/base.py
Normal file
434
installer/ring_installer/transformers/base.py
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
"""
|
||||
Base transformer classes and interfaces.
|
||||
|
||||
Provides the foundation for content transformation across platforms.
|
||||
Transformers follow a pipeline pattern for composability.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List, Optional, Protocol, Tuple
|
||||
import re
|
||||
|
||||
# YAML import with error handling at module level
|
||||
try:
|
||||
import yaml
|
||||
YAML_AVAILABLE = True
|
||||
except ImportError:
|
||||
YAML_AVAILABLE = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class TransformContext:
|
||||
"""
|
||||
Context passed through transformation pipeline.
|
||||
|
||||
Attributes:
|
||||
platform: Target platform identifier
|
||||
component_type: Type of component (skill, agent, command, hook)
|
||||
source_path: Original file path
|
||||
metadata: Additional metadata from discovery
|
||||
options: Platform-specific options
|
||||
"""
|
||||
platform: str
|
||||
component_type: str
|
||||
source_path: str = ""
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
options: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TransformResult:
|
||||
"""
|
||||
Result of a transformation operation.
|
||||
|
||||
Attributes:
|
||||
content: Transformed content
|
||||
success: Whether transformation succeeded
|
||||
errors: List of error messages
|
||||
warnings: List of warning messages
|
||||
metadata: Additional output metadata
|
||||
"""
|
||||
content: str
|
||||
success: bool = True
|
||||
errors: List[str] = field(default_factory=list)
|
||||
warnings: List[str] = field(default_factory=list)
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class Transformer(Protocol):
|
||||
"""Protocol for transformer implementations."""
|
||||
|
||||
def transform(self, content: str, context: TransformContext) -> TransformResult:
|
||||
"""Transform content according to context."""
|
||||
...
|
||||
|
||||
|
||||
class BaseTransformer(ABC):
|
||||
"""
|
||||
Abstract base class for content transformers.
|
||||
|
||||
Transformers handle conversion of Ring component formats
|
||||
to platform-specific formats.
|
||||
"""
|
||||
|
||||
# Class constants for platform-specific replacements
|
||||
CURSOR_REPLACEMENTS = [
|
||||
("subagent", "sub-workflow"),
|
||||
("Subagent", "Sub-workflow"),
|
||||
("Task tool", "workflow step"),
|
||||
("Skill tool", "rule reference"),
|
||||
]
|
||||
|
||||
CLINE_REPLACEMENTS = [
|
||||
("Task tool", "sub-prompt"),
|
||||
("Skill tool", "prompt reference"),
|
||||
("subagent", "sub-prompt"),
|
||||
("Subagent", "Sub-prompt"),
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the transformer."""
|
||||
self._yaml_imported = False
|
||||
|
||||
@abstractmethod
|
||||
def transform(self, content: str, context: TransformContext) -> TransformResult:
|
||||
"""
|
||||
Transform content for the target platform.
|
||||
|
||||
Args:
|
||||
content: Source content to transform
|
||||
context: Transformation context
|
||||
|
||||
Returns:
|
||||
TransformResult with transformed content
|
||||
"""
|
||||
pass
|
||||
|
||||
def validate(self, content: str, context: TransformContext) -> List[str]:
|
||||
"""
|
||||
Validate content before transformation.
|
||||
|
||||
Args:
|
||||
content: Content to validate
|
||||
context: Transformation context
|
||||
|
||||
Returns:
|
||||
List of validation error messages (empty if valid)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
if not content.strip():
|
||||
errors.append(f"Empty {context.component_type} content")
|
||||
|
||||
return errors
|
||||
|
||||
def extract_frontmatter(self, content: str) -> Tuple[Dict[str, Any], str]:
|
||||
"""
|
||||
Extract YAML frontmatter from markdown content.
|
||||
|
||||
Args:
|
||||
content: Markdown content with optional YAML frontmatter
|
||||
|
||||
Returns:
|
||||
Tuple of (frontmatter dict, body content)
|
||||
"""
|
||||
frontmatter: Dict[str, Any] = {}
|
||||
body = content
|
||||
|
||||
if content.startswith("---"):
|
||||
end_marker = content.find("---", 3)
|
||||
if end_marker != -1:
|
||||
yaml_content = content[3:end_marker].strip()
|
||||
try:
|
||||
if YAML_AVAILABLE:
|
||||
frontmatter = yaml.safe_load(yaml_content) or {}
|
||||
except Exception:
|
||||
pass
|
||||
body = content[end_marker + 3:].strip()
|
||||
|
||||
return frontmatter, body
|
||||
|
||||
def create_frontmatter(self, data: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Create YAML frontmatter string from dictionary.
|
||||
|
||||
Args:
|
||||
data: Dictionary to convert to YAML frontmatter
|
||||
|
||||
Returns:
|
||||
YAML frontmatter string with --- delimiters
|
||||
"""
|
||||
if not data:
|
||||
return ""
|
||||
|
||||
if not YAML_AVAILABLE:
|
||||
return ""
|
||||
|
||||
yaml_str = yaml.dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
||||
return f"---\n{yaml_str}---\n"
|
||||
|
||||
def clean_yaml_string(self, text: str) -> str:
|
||||
"""
|
||||
Clean up YAML multi-line string markers.
|
||||
|
||||
Args:
|
||||
text: YAML string value
|
||||
|
||||
Returns:
|
||||
Cleaned string
|
||||
"""
|
||||
if not isinstance(text, str):
|
||||
return str(text) if text else ""
|
||||
# Remove | and > markers
|
||||
text = re.sub(r'^[|>]\s*', '', text)
|
||||
return text.strip()
|
||||
|
||||
def to_title_case(self, text: str) -> str:
|
||||
"""
|
||||
Convert text to title case, handling kebab-case and snake_case.
|
||||
|
||||
Args:
|
||||
text: Input text
|
||||
|
||||
Returns:
|
||||
Title-cased text
|
||||
"""
|
||||
# Replace separators with spaces
|
||||
text = text.replace("-", " ").replace("_", " ")
|
||||
return text.title()
|
||||
|
||||
def transform_body_for_cursor(self, body: str) -> str:
|
||||
"""
|
||||
Transform body content for Cursor compatibility.
|
||||
|
||||
Args:
|
||||
body: Original body content
|
||||
|
||||
Returns:
|
||||
Transformed content with Cursor-specific terminology
|
||||
"""
|
||||
result = body
|
||||
|
||||
# Apply Cursor-specific replacements
|
||||
for old, new in self.CURSOR_REPLACEMENTS:
|
||||
result = result.replace(old, new)
|
||||
|
||||
# Transform ring: references
|
||||
result = re.sub(
|
||||
r'`ring:([^`]+)`',
|
||||
lambda m: f"**{self.to_title_case(m.group(1))}**",
|
||||
result
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def transform_body_for_cline(self, body: str) -> str:
|
||||
"""
|
||||
Transform body content for Cline compatibility.
|
||||
|
||||
Args:
|
||||
body: Original body content
|
||||
|
||||
Returns:
|
||||
Transformed content with Cline-specific terminology
|
||||
"""
|
||||
result = body
|
||||
|
||||
# Apply Cline-specific replacements
|
||||
for old, new in self.CLINE_REPLACEMENTS:
|
||||
result = result.replace(old, new)
|
||||
|
||||
# Transform ring: references to @ format
|
||||
result = re.sub(
|
||||
r'`ring:([^`]+)`',
|
||||
lambda m: f"@{m.group(1).lower().replace('_', '-')}",
|
||||
result
|
||||
)
|
||||
|
||||
result = re.sub(
|
||||
r'"ring:([^"]+)"',
|
||||
lambda m: f'"@{m.group(1).lower().replace("_", "-")}"',
|
||||
result
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def add_list_items(self, parts: List[str], text: str) -> None:
|
||||
"""
|
||||
Add list items from YAML list or multi-line string.
|
||||
|
||||
Args:
|
||||
parts: List to append items to
|
||||
text: Text containing list items
|
||||
"""
|
||||
clean_text = self.clean_yaml_string(text)
|
||||
for line in clean_text.split("\n"):
|
||||
line = line.strip()
|
||||
if line:
|
||||
if line.startswith("-"):
|
||||
parts.append(line)
|
||||
else:
|
||||
parts.append(f"- {line}")
|
||||
|
||||
|
||||
class TransformerPipeline:
|
||||
"""
|
||||
Pipeline for composing multiple transformers.
|
||||
|
||||
Allows transformers to be chained together, with each
|
||||
transformer receiving the output of the previous one.
|
||||
"""
|
||||
|
||||
def __init__(self, transformers: Optional[List[BaseTransformer]] = None):
|
||||
"""
|
||||
Initialize the pipeline.
|
||||
|
||||
Args:
|
||||
transformers: Optional list of transformers to add
|
||||
"""
|
||||
self._transformers: List[BaseTransformer] = transformers or []
|
||||
|
||||
def add(self, transformer: BaseTransformer) -> "TransformerPipeline":
|
||||
"""
|
||||
Add a transformer to the pipeline.
|
||||
|
||||
Args:
|
||||
transformer: Transformer to add
|
||||
|
||||
Returns:
|
||||
Self for chaining
|
||||
"""
|
||||
self._transformers.append(transformer)
|
||||
return self
|
||||
|
||||
def transform(self, content: str, context: TransformContext) -> TransformResult:
|
||||
"""
|
||||
Run content through all transformers in the pipeline.
|
||||
|
||||
Args:
|
||||
content: Source content
|
||||
context: Transformation context
|
||||
|
||||
Returns:
|
||||
Final TransformResult
|
||||
"""
|
||||
current_content = content
|
||||
all_errors: List[str] = []
|
||||
all_warnings: List[str] = []
|
||||
combined_metadata: Dict[str, Any] = {}
|
||||
|
||||
for transformer in self._transformers:
|
||||
result = transformer.transform(current_content, context)
|
||||
|
||||
all_errors.extend(result.errors)
|
||||
all_warnings.extend(result.warnings)
|
||||
combined_metadata.update(result.metadata)
|
||||
|
||||
if not result.success:
|
||||
return TransformResult(
|
||||
content=current_content,
|
||||
success=False,
|
||||
errors=all_errors,
|
||||
warnings=all_warnings,
|
||||
metadata=combined_metadata
|
||||
)
|
||||
|
||||
current_content = result.content
|
||||
|
||||
return TransformResult(
|
||||
content=current_content,
|
||||
success=True,
|
||||
errors=all_errors,
|
||||
warnings=all_warnings,
|
||||
metadata=combined_metadata
|
||||
)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._transformers)
|
||||
|
||||
|
||||
class PassthroughTransformer(BaseTransformer):
|
||||
"""Transformer that returns content unchanged."""
|
||||
|
||||
def transform(self, content: str, context: TransformContext) -> TransformResult:
|
||||
"""Return content unchanged."""
|
||||
return TransformResult(content=content, success=True)
|
||||
|
||||
|
||||
class TerminologyTransformer(BaseTransformer):
|
||||
"""
|
||||
Transformer that replaces terminology based on platform conventions.
|
||||
"""
|
||||
|
||||
def __init__(self, terminology_map: Dict[str, str]):
|
||||
"""
|
||||
Initialize with terminology mapping.
|
||||
|
||||
Args:
|
||||
terminology_map: Mapping from Ring terms to platform terms
|
||||
"""
|
||||
super().__init__()
|
||||
self.terminology_map = terminology_map
|
||||
|
||||
def transform(self, content: str, context: TransformContext) -> TransformResult:
|
||||
"""Replace terminology in content."""
|
||||
result = content
|
||||
|
||||
for ring_term, platform_term in self.terminology_map.items():
|
||||
if ring_term != platform_term:
|
||||
# Case-sensitive replacements
|
||||
result = re.sub(rf'\b{ring_term}\b', platform_term, result)
|
||||
result = re.sub(rf'\b{ring_term.title()}\b', platform_term.title(), result)
|
||||
result = re.sub(rf'\b{ring_term.upper()}\b', platform_term.upper(), result)
|
||||
|
||||
return TransformResult(content=result, success=True)
|
||||
|
||||
|
||||
class FrontmatterTransformer(BaseTransformer):
|
||||
"""
|
||||
Transformer that modifies YAML frontmatter fields.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
field_mapping: Optional[Dict[str, str]] = None,
|
||||
remove_fields: Optional[List[str]] = None,
|
||||
add_fields: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
"""
|
||||
Initialize with frontmatter modifications.
|
||||
|
||||
Args:
|
||||
field_mapping: Mapping to rename fields
|
||||
remove_fields: Fields to remove
|
||||
add_fields: Fields to add
|
||||
"""
|
||||
super().__init__()
|
||||
self.field_mapping = field_mapping or {}
|
||||
self.remove_fields = remove_fields or []
|
||||
self.add_fields = add_fields or {}
|
||||
|
||||
def transform(self, content: str, context: TransformContext) -> TransformResult:
|
||||
"""Modify frontmatter according to configuration."""
|
||||
frontmatter, body = self.extract_frontmatter(content)
|
||||
|
||||
if not frontmatter:
|
||||
return TransformResult(content=content, success=True)
|
||||
|
||||
# Apply field mapping
|
||||
for old_name, new_name in self.field_mapping.items():
|
||||
if old_name in frontmatter:
|
||||
frontmatter[new_name] = frontmatter.pop(old_name)
|
||||
|
||||
# Remove fields
|
||||
for field in self.remove_fields:
|
||||
frontmatter.pop(field, None)
|
||||
|
||||
# Add fields
|
||||
frontmatter.update(self.add_fields)
|
||||
|
||||
# Rebuild content
|
||||
new_content = self.create_frontmatter(frontmatter) + "\n" + body
|
||||
|
||||
return TransformResult(content=new_content, success=True)
|
||||
476
installer/ring_installer/transformers/cline_prompts.py
Normal file
476
installer/ring_installer/transformers/cline_prompts.py
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
"""
|
||||
Cline prompts generator.
|
||||
|
||||
Generates companion prompts for Cline from Ring skills and agents.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ring_installer.transformers.base import (
|
||||
BaseTransformer,
|
||||
TransformContext,
|
||||
TransformResult,
|
||||
)
|
||||
|
||||
|
||||
class ClinePromptsGenerator(BaseTransformer):
|
||||
"""
|
||||
Generator for Cline prompt files.
|
||||
|
||||
Creates prompt templates from Ring skills, agents, and commands
|
||||
formatted for use with Cline's prompt system.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the generator."""
|
||||
super().__init__()
|
||||
self.prompts: List[Dict[str, Any]] = []
|
||||
|
||||
def transform(self, content: str, context: TransformContext) -> TransformResult:
|
||||
"""Not used for generator - use add_component and generate_prompt instead."""
|
||||
raise NotImplementedError("Use add_component() and generate_prompt() methods")
|
||||
|
||||
def add_component(
|
||||
self,
|
||||
content: str,
|
||||
component_type: str,
|
||||
name: str,
|
||||
source_path: Optional[str] = None
|
||||
) -> None:
|
||||
"""
|
||||
Add a component to be converted to a prompt.
|
||||
|
||||
Args:
|
||||
content: Component content (markdown with frontmatter)
|
||||
component_type: Type of component (skill, agent, command)
|
||||
name: Component name
|
||||
source_path: Original file path
|
||||
"""
|
||||
frontmatter, body = self.extract_frontmatter(content)
|
||||
|
||||
prompt = {
|
||||
"name": frontmatter.get("name", name),
|
||||
"type": component_type,
|
||||
"description": frontmatter.get("description", ""),
|
||||
"model": frontmatter.get("model", ""),
|
||||
"frontmatter": frontmatter,
|
||||
"content": body,
|
||||
"source": source_path,
|
||||
}
|
||||
|
||||
self.prompts.append(prompt)
|
||||
|
||||
def generate_prompt(self, prompt: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Generate a single prompt file content.
|
||||
|
||||
Args:
|
||||
prompt: Prompt data dictionary
|
||||
|
||||
Returns:
|
||||
Formatted prompt content
|
||||
"""
|
||||
component_type = prompt.get("type", "skill")
|
||||
|
||||
if component_type == "agent":
|
||||
return self._generate_agent_prompt(prompt)
|
||||
elif component_type == "command":
|
||||
return self._generate_command_prompt(prompt)
|
||||
else:
|
||||
return self._generate_skill_prompt(prompt)
|
||||
|
||||
def generate_index(self) -> str:
|
||||
"""
|
||||
Generate an index file listing all prompts.
|
||||
|
||||
Returns:
|
||||
Markdown index content
|
||||
"""
|
||||
lines = [
|
||||
"# Ring Prompts for Cline",
|
||||
"",
|
||||
"This directory contains Ring skills, agents, and commands",
|
||||
"converted to Cline prompts.",
|
||||
"",
|
||||
]
|
||||
|
||||
# Group by type
|
||||
by_type: Dict[str, List[Dict[str, Any]]] = {
|
||||
"agent": [],
|
||||
"command": [],
|
||||
"skill": []
|
||||
}
|
||||
|
||||
for prompt in self.prompts:
|
||||
prompt_type = prompt.get("type", "skill")
|
||||
if prompt_type in by_type:
|
||||
by_type[prompt_type].append(prompt)
|
||||
|
||||
# Add sections
|
||||
for prompt_type, prompts in by_type.items():
|
||||
if prompts:
|
||||
lines.append(f"## {prompt_type.title()}s")
|
||||
lines.append("")
|
||||
for prompt in prompts:
|
||||
name = prompt.get("name", "")
|
||||
desc = prompt.get("description", "")
|
||||
clean_desc = self.clean_yaml_string(desc)[:80]
|
||||
lines.append(f"- **{self.to_title_case(name)}** - {clean_desc}")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _generate_skill_prompt(self, prompt: Dict[str, Any]) -> str:
|
||||
"""Generate a skill-based prompt."""
|
||||
parts: List[str] = []
|
||||
|
||||
name = prompt.get("name", "Untitled")
|
||||
description = prompt.get("description", "")
|
||||
frontmatter = prompt.get("frontmatter", {})
|
||||
content = prompt.get("content", "")
|
||||
|
||||
# Metadata comments
|
||||
parts.append(f"<!-- Prompt: {name} -->")
|
||||
parts.append("<!-- Type: skill -->")
|
||||
if prompt.get("source"):
|
||||
parts.append(f"<!-- Source: {prompt['source']} -->")
|
||||
parts.append("")
|
||||
|
||||
# Title
|
||||
parts.append(f"# {self.to_title_case(name)}")
|
||||
parts.append("")
|
||||
|
||||
# Description
|
||||
if description:
|
||||
clean_desc = self.clean_yaml_string(description)
|
||||
parts.append(f"> {clean_desc}")
|
||||
parts.append("")
|
||||
|
||||
# Trigger conditions
|
||||
trigger = frontmatter.get("trigger", "")
|
||||
if trigger:
|
||||
parts.append("## Use This Prompt When")
|
||||
parts.append("")
|
||||
self.add_list_items(parts, trigger)
|
||||
parts.append("")
|
||||
|
||||
# Skip conditions
|
||||
skip_when = frontmatter.get("skip_when", "")
|
||||
if skip_when:
|
||||
parts.append("## Do Not Use When")
|
||||
parts.append("")
|
||||
self.add_list_items(parts, skip_when)
|
||||
parts.append("")
|
||||
|
||||
# Related
|
||||
related = frontmatter.get("related", {})
|
||||
if related:
|
||||
similar = related.get("similar", [])
|
||||
complementary = related.get("complementary", [])
|
||||
if similar or complementary:
|
||||
parts.append("## Related Prompts")
|
||||
parts.append("")
|
||||
if similar:
|
||||
parts.append("**Similar:** " + ", ".join(similar))
|
||||
if complementary:
|
||||
parts.append("**Works well with:** " + ", ".join(complementary))
|
||||
parts.append("")
|
||||
|
||||
# Instructions
|
||||
parts.append("## Instructions")
|
||||
parts.append("")
|
||||
parts.append(self._transform_content(content))
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
def _generate_agent_prompt(self, prompt: Dict[str, Any]) -> str:
|
||||
"""Generate an agent-based prompt."""
|
||||
parts: List[str] = []
|
||||
|
||||
name = prompt.get("name", "Untitled Agent")
|
||||
description = prompt.get("description", "")
|
||||
model = prompt.get("model", "")
|
||||
frontmatter = prompt.get("frontmatter", {})
|
||||
content = prompt.get("content", "")
|
||||
|
||||
# Metadata comments
|
||||
parts.append(f"<!-- Prompt: {name} -->")
|
||||
parts.append("<!-- Type: agent -->")
|
||||
if model:
|
||||
parts.append(f"<!-- Recommended Model: {model} -->")
|
||||
if prompt.get("source"):
|
||||
parts.append(f"<!-- Source: {prompt['source']} -->")
|
||||
parts.append("")
|
||||
|
||||
# Title
|
||||
parts.append(f"# {self.to_title_case(name)} Agent")
|
||||
parts.append("")
|
||||
|
||||
# Role description
|
||||
if description:
|
||||
clean_desc = self.clean_yaml_string(description)
|
||||
parts.append("## Role")
|
||||
parts.append("")
|
||||
parts.append(clean_desc)
|
||||
parts.append("")
|
||||
|
||||
# Model recommendation
|
||||
if model:
|
||||
parts.append(f"**Recommended Model:** `{model}`")
|
||||
parts.append("")
|
||||
|
||||
# Output requirements
|
||||
output_schema = frontmatter.get("output_schema", {})
|
||||
if output_schema:
|
||||
parts.append("## Expected Output Format")
|
||||
parts.append("")
|
||||
output_format = output_schema.get("format", "markdown")
|
||||
parts.append(f"Format: {output_format}")
|
||||
parts.append("")
|
||||
required_sections = output_schema.get("required_sections", [])
|
||||
if required_sections:
|
||||
parts.append("Required sections:")
|
||||
for section in required_sections:
|
||||
section_name = section.get("name", "")
|
||||
if section_name:
|
||||
parts.append(f"- {section_name}")
|
||||
parts.append("")
|
||||
|
||||
# Behavior
|
||||
parts.append("## Behavior")
|
||||
parts.append("")
|
||||
parts.append(self._transform_content(content))
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
def _generate_command_prompt(self, prompt: Dict[str, Any]) -> str:
|
||||
"""Generate a command-based prompt."""
|
||||
parts: List[str] = []
|
||||
|
||||
name = prompt.get("name", "Untitled Command")
|
||||
description = prompt.get("description", "")
|
||||
frontmatter = prompt.get("frontmatter", {})
|
||||
content = prompt.get("content", "")
|
||||
|
||||
# Metadata comments
|
||||
parts.append(f"<!-- Prompt: {name} -->")
|
||||
parts.append("<!-- Type: command -->")
|
||||
if prompt.get("source"):
|
||||
parts.append(f"<!-- Source: {prompt['source']} -->")
|
||||
parts.append("")
|
||||
|
||||
# Title
|
||||
parts.append(f"# {self.to_title_case(name)}")
|
||||
parts.append("")
|
||||
|
||||
# Description
|
||||
if description:
|
||||
clean_desc = self.clean_yaml_string(description)
|
||||
parts.append(f"> {clean_desc}")
|
||||
parts.append("")
|
||||
|
||||
# Parameters
|
||||
args = frontmatter.get("args", [])
|
||||
if args:
|
||||
parts.append("## Parameters")
|
||||
parts.append("")
|
||||
for arg in args:
|
||||
arg_name = arg.get("name", "")
|
||||
arg_desc = arg.get("description", "")
|
||||
required = arg.get("required", False)
|
||||
default = arg.get("default", "")
|
||||
|
||||
param_line = f"- **{arg_name}**"
|
||||
param_line += " (required)" if required else " (optional)"
|
||||
if arg_desc:
|
||||
param_line += f": {arg_desc}"
|
||||
if default:
|
||||
param_line += f" [default: {default}]"
|
||||
parts.append(param_line)
|
||||
parts.append("")
|
||||
|
||||
# Steps
|
||||
parts.append("## Steps")
|
||||
parts.append("")
|
||||
parts.append(self._transform_content(content))
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
def _transform_content(self, content: str) -> str:
|
||||
"""Transform content for Cline compatibility."""
|
||||
# Use base class method
|
||||
return self.transform_body_for_cline(content)
|
||||
|
||||
|
||||
class ClinePromptsTransformer(BaseTransformer):
|
||||
"""
|
||||
Transformer that generates Cline prompts from Ring components.
|
||||
|
||||
Works with the pipeline pattern, generating individual prompt files.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the transformer."""
|
||||
super().__init__()
|
||||
self.generator = ClinePromptsGenerator()
|
||||
|
||||
def transform(self, content: str, context: TransformContext) -> TransformResult:
|
||||
"""
|
||||
Transform a component to a Cline prompt.
|
||||
|
||||
Args:
|
||||
content: Component content
|
||||
context: Transformation context
|
||||
|
||||
Returns:
|
||||
TransformResult with the prompt content
|
||||
"""
|
||||
name = context.metadata.get("name", "unknown")
|
||||
component_type = context.component_type
|
||||
source = context.source_path
|
||||
|
||||
# Add to generator for index
|
||||
self.generator.add_component(content, component_type, name, source)
|
||||
|
||||
# Generate the prompt
|
||||
prompt_data = {
|
||||
"name": name,
|
||||
"type": component_type,
|
||||
"source": source,
|
||||
}
|
||||
|
||||
# Extract frontmatter
|
||||
frontmatter, body = self.extract_frontmatter(content)
|
||||
prompt_data["description"] = frontmatter.get("description", "")
|
||||
prompt_data["model"] = frontmatter.get("model", "")
|
||||
prompt_data["frontmatter"] = frontmatter
|
||||
prompt_data["content"] = body
|
||||
|
||||
prompt_content = self.generator.generate_prompt(prompt_data)
|
||||
|
||||
return TransformResult(
|
||||
content=prompt_content,
|
||||
success=True,
|
||||
metadata={"prompt_name": name, "prompt_type": component_type}
|
||||
)
|
||||
|
||||
def generate_index(self) -> str:
|
||||
"""
|
||||
Generate the prompts index file.
|
||||
|
||||
Returns:
|
||||
Index file content
|
||||
"""
|
||||
return self.generator.generate_index()
|
||||
|
||||
|
||||
def generate_cline_prompt(
|
||||
content: str,
|
||||
component_type: str,
|
||||
name: str,
|
||||
source_path: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
Generate a single Cline prompt from a Ring component.
|
||||
|
||||
Args:
|
||||
content: Component content
|
||||
component_type: Type (skill, agent, command)
|
||||
name: Component name
|
||||
source_path: Optional source path
|
||||
|
||||
Returns:
|
||||
Formatted prompt content
|
||||
"""
|
||||
generator = ClinePromptsGenerator()
|
||||
generator.add_component(content, component_type, name, source_path)
|
||||
|
||||
if generator.prompts:
|
||||
return generator.generate_prompt(generator.prompts[0])
|
||||
return ""
|
||||
|
||||
|
||||
def generate_prompts_index(
|
||||
prompts: List[Dict[str, str]]
|
||||
) -> str:
|
||||
"""
|
||||
Generate an index file for multiple prompts.
|
||||
|
||||
Args:
|
||||
prompts: List of prompt info dicts with name, type, description
|
||||
|
||||
Returns:
|
||||
Index file content
|
||||
"""
|
||||
generator = ClinePromptsGenerator()
|
||||
|
||||
for prompt in prompts:
|
||||
generator.add_component(
|
||||
content=prompt.get("content", ""),
|
||||
component_type=prompt.get("type", "skill"),
|
||||
name=prompt.get("name", "unknown"),
|
||||
source_path=prompt.get("source")
|
||||
)
|
||||
|
||||
return generator.generate_index()
|
||||
|
||||
|
||||
def write_cline_prompts(
|
||||
output_dir: Path,
|
||||
components: List[Dict[str, str]],
|
||||
generate_index_file: bool = True
|
||||
) -> List[Path]:
|
||||
"""
|
||||
Write Cline prompt files from Ring components.
|
||||
|
||||
Args:
|
||||
output_dir: Directory to write prompts
|
||||
components: List of component dicts with content, type, name
|
||||
generate_index_file: Whether to create an index.md
|
||||
|
||||
Returns:
|
||||
List of paths to written files
|
||||
"""
|
||||
output_dir = Path(output_dir).expanduser()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
written_files: List[Path] = []
|
||||
generator = ClinePromptsGenerator()
|
||||
|
||||
for component in components:
|
||||
content = component.get("content", "")
|
||||
component_type = component.get("type", "skill")
|
||||
name = component.get("name", "unknown")
|
||||
source = component.get("source")
|
||||
|
||||
generator.add_component(content, component_type, name, source)
|
||||
|
||||
# Generate and write the prompt
|
||||
prompt_data = generator.prompts[-1]
|
||||
prompt_content = generator.generate_prompt(prompt_data)
|
||||
|
||||
# Write to appropriate subdirectory
|
||||
subdir = output_dir / f"{component_type}s"
|
||||
subdir.mkdir(exist_ok=True)
|
||||
|
||||
filename = f"{name.replace(' ', '-').lower()}.md"
|
||||
file_path = subdir / filename
|
||||
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(prompt_content)
|
||||
|
||||
written_files.append(file_path)
|
||||
|
||||
# Generate index
|
||||
if generate_index_file:
|
||||
index_content = generator.generate_index()
|
||||
index_path = output_dir / "index.md"
|
||||
|
||||
with open(index_path, "w", encoding="utf-8") as f:
|
||||
f.write(index_content)
|
||||
|
||||
written_files.append(index_path)
|
||||
|
||||
return written_files
|
||||
293
installer/ring_installer/transformers/command.py
Normal file
293
installer/ring_installer/transformers/command.py
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
"""
|
||||
Command content transformer.
|
||||
|
||||
Transforms Ring slash command files to platform-specific formats.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ring_installer.transformers.base import (
|
||||
BaseTransformer,
|
||||
TransformContext,
|
||||
TransformResult,
|
||||
)
|
||||
|
||||
|
||||
class CommandTransformer(BaseTransformer):
|
||||
"""
|
||||
Transformer for Ring command files.
|
||||
|
||||
Handles transformation of slash command definitions:
|
||||
- Claude: passthrough (native format)
|
||||
- Factory: minimal terminology changes
|
||||
- Cursor: convert to workflow (commands don't exist)
|
||||
- Cline: convert to action prompt (commands don't exist)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
platform: str,
|
||||
terminology: Optional[Dict[str, str]] = None
|
||||
):
|
||||
"""
|
||||
Initialize the command transformer.
|
||||
|
||||
Args:
|
||||
platform: Target platform identifier
|
||||
terminology: Platform-specific terminology mapping
|
||||
"""
|
||||
super().__init__()
|
||||
self.platform = platform
|
||||
self.terminology = terminology or {}
|
||||
|
||||
def transform(self, content: str, context: TransformContext) -> TransformResult:
|
||||
"""
|
||||
Transform command content for the target platform.
|
||||
|
||||
Args:
|
||||
content: Original command content
|
||||
context: Transformation context
|
||||
|
||||
Returns:
|
||||
TransformResult with transformed content
|
||||
"""
|
||||
errors = self.validate(content, context)
|
||||
if errors:
|
||||
return TransformResult(content=content, success=False, errors=errors)
|
||||
|
||||
frontmatter, body = self.extract_frontmatter(content)
|
||||
|
||||
# Transform based on platform
|
||||
if self.platform == "claude":
|
||||
return self._transform_claude(frontmatter, body, context)
|
||||
elif self.platform == "factory":
|
||||
return self._transform_factory(frontmatter, body, context)
|
||||
elif self.platform == "cursor":
|
||||
return self._transform_cursor(frontmatter, body, context)
|
||||
elif self.platform == "cline":
|
||||
return self._transform_cline(frontmatter, body, context)
|
||||
else:
|
||||
return TransformResult(content=content, success=True)
|
||||
|
||||
def _transform_claude(
|
||||
self,
|
||||
frontmatter: Dict[str, Any],
|
||||
body: str,
|
||||
context: TransformContext
|
||||
) -> TransformResult:
|
||||
"""Transform command for Claude Code (passthrough)."""
|
||||
if frontmatter:
|
||||
content = self.create_frontmatter(frontmatter) + "\n" + body
|
||||
else:
|
||||
content = body
|
||||
|
||||
return TransformResult(content=content, success=True)
|
||||
|
||||
def _transform_factory(
|
||||
self,
|
||||
frontmatter: Dict[str, Any],
|
||||
body: str,
|
||||
context: TransformContext
|
||||
) -> TransformResult:
|
||||
"""
|
||||
Transform command for Factory AI.
|
||||
|
||||
Factory supports commands but with droid terminology.
|
||||
"""
|
||||
transformed_fm = self._transform_factory_frontmatter(frontmatter)
|
||||
transformed_body = self._replace_agent_references(body)
|
||||
|
||||
if transformed_fm:
|
||||
content = self.create_frontmatter(transformed_fm) + "\n" + transformed_body
|
||||
else:
|
||||
content = transformed_body
|
||||
|
||||
return TransformResult(content=content, success=True)
|
||||
|
||||
def _transform_cursor(
|
||||
self,
|
||||
frontmatter: Dict[str, Any],
|
||||
body: str,
|
||||
context: TransformContext
|
||||
) -> TransformResult:
|
||||
"""
|
||||
Transform command to Cursor workflow format.
|
||||
|
||||
Cursor doesn't have slash commands, so we convert to workflows.
|
||||
"""
|
||||
parts: List[str] = []
|
||||
|
||||
# Extract metadata
|
||||
name = frontmatter.get("name", context.metadata.get("name", "Untitled Command"))
|
||||
description = frontmatter.get("description", "")
|
||||
|
||||
# Build workflow header
|
||||
parts.append(f"# {self.to_title_case(name)}")
|
||||
parts.append("")
|
||||
|
||||
if description:
|
||||
clean_desc = self.clean_yaml_string(description)
|
||||
parts.append(clean_desc)
|
||||
parts.append("")
|
||||
|
||||
# Arguments -> Parameters section
|
||||
args = frontmatter.get("args", [])
|
||||
if args:
|
||||
parts.append("## Parameters")
|
||||
parts.append("")
|
||||
for arg in args:
|
||||
arg_name = arg.get("name", "")
|
||||
arg_desc = arg.get("description", "")
|
||||
required = "required" if arg.get("required", False) else "optional"
|
||||
default = arg.get("default", "")
|
||||
|
||||
param_line = f"- **{arg_name}** ({required})"
|
||||
if arg_desc:
|
||||
param_line += f": {arg_desc}"
|
||||
if default:
|
||||
param_line += f" [default: {default}]"
|
||||
parts.append(param_line)
|
||||
parts.append("")
|
||||
|
||||
# Add the body content
|
||||
parts.append("## Instructions")
|
||||
parts.append("")
|
||||
# Command transformer needs to remove /ring: prefix for Cursor
|
||||
transformed_body = self.transform_body_for_cursor(body)
|
||||
transformed_body = transformed_body.replace("/ring:", "/")
|
||||
parts.append(transformed_body)
|
||||
|
||||
return TransformResult(content="\n".join(parts), success=True)
|
||||
|
||||
def _transform_cline(
|
||||
self,
|
||||
frontmatter: Dict[str, Any],
|
||||
body: str,
|
||||
context: TransformContext
|
||||
) -> TransformResult:
|
||||
"""
|
||||
Transform command to Cline action prompt format.
|
||||
|
||||
Cline doesn't have slash commands, so we convert to prompts.
|
||||
"""
|
||||
parts: List[str] = []
|
||||
|
||||
# Extract metadata
|
||||
name = frontmatter.get("name", context.metadata.get("name", "Untitled Command"))
|
||||
description = frontmatter.get("description", "")
|
||||
|
||||
# HTML comments for metadata
|
||||
parts.append(f"<!-- Prompt: {name} -->")
|
||||
parts.append("<!-- Type: command -->")
|
||||
if context.source_path:
|
||||
parts.append(f"<!-- Source: {context.source_path} -->")
|
||||
parts.append("")
|
||||
|
||||
# Title
|
||||
parts.append(f"# {self.to_title_case(name)}")
|
||||
parts.append("")
|
||||
|
||||
# Description
|
||||
if description:
|
||||
clean_desc = self.clean_yaml_string(description)
|
||||
parts.append(f"> {clean_desc}")
|
||||
parts.append("")
|
||||
|
||||
# Arguments/parameters
|
||||
args = frontmatter.get("args", [])
|
||||
if args:
|
||||
parts.append("## Parameters")
|
||||
parts.append("")
|
||||
for arg in args:
|
||||
arg_name = arg.get("name", "")
|
||||
arg_desc = arg.get("description", "")
|
||||
required = arg.get("required", False)
|
||||
default = arg.get("default", "")
|
||||
|
||||
param_line = f"- **{arg_name}**"
|
||||
if required:
|
||||
param_line += " (required)"
|
||||
else:
|
||||
param_line += " (optional)"
|
||||
if arg_desc:
|
||||
param_line += f": {arg_desc}"
|
||||
if default:
|
||||
param_line += f" [default: {default}]"
|
||||
parts.append(param_line)
|
||||
parts.append("")
|
||||
|
||||
# Instructions
|
||||
parts.append("## Steps")
|
||||
parts.append("")
|
||||
# Command transformer needs to replace /ring: with @ for Cline
|
||||
transformed_body = self.transform_body_for_cline(body)
|
||||
transformed_body = transformed_body.replace("/ring:", "@")
|
||||
parts.append(transformed_body)
|
||||
|
||||
return TransformResult(content="\n".join(parts), success=True)
|
||||
|
||||
def _transform_factory_frontmatter(
|
||||
self,
|
||||
frontmatter: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""Transform command frontmatter for Factory."""
|
||||
result = dict(frontmatter)
|
||||
|
||||
# Transform string values containing agent references
|
||||
for key, value in list(result.items()):
|
||||
if isinstance(value, str):
|
||||
result[key] = self._replace_agent_references(value)
|
||||
elif isinstance(value, list):
|
||||
result[key] = [
|
||||
self._replace_agent_references(v) if isinstance(v, str) else v
|
||||
for v in value
|
||||
]
|
||||
|
||||
return result
|
||||
|
||||
def _replace_agent_references(self, text: str) -> str:
|
||||
"""Replace agent references with droid references."""
|
||||
replacements = [
|
||||
(r'\bagent\b', 'droid'),
|
||||
(r'\bAgent\b', 'Droid'),
|
||||
(r'\bAGENT\b', 'DROID'),
|
||||
(r'\bagents\b', 'droids'),
|
||||
(r'\bAgents\b', 'Droids'),
|
||||
(r'\bAGENTS\b', 'DROIDS'),
|
||||
(r'\bsubagent\b', 'subdroid'),
|
||||
(r'\bSubagent\b', 'Subdroid'),
|
||||
(r'"ring:([^"]*)-agent"', r'"ring:\1-droid"'),
|
||||
]
|
||||
|
||||
result = text
|
||||
for pattern, replacement in replacements:
|
||||
result = re.sub(pattern, replacement, result)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
|
||||
class CommandTransformerFactory:
|
||||
"""Factory for creating platform-specific command transformers."""
|
||||
|
||||
PLATFORM_TERMINOLOGY = {
|
||||
"claude": {"command": "command"},
|
||||
"factory": {"command": "command"},
|
||||
"cursor": {"command": "workflow"},
|
||||
"cline": {"command": "prompt"},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def create(cls, platform: str) -> CommandTransformer:
|
||||
"""
|
||||
Create a command transformer for the specified platform.
|
||||
|
||||
Args:
|
||||
platform: Target platform identifier
|
||||
|
||||
Returns:
|
||||
Configured CommandTransformer
|
||||
"""
|
||||
terminology = cls.PLATFORM_TERMINOLOGY.get(platform, {})
|
||||
return CommandTransformer(platform=platform, terminology=terminology)
|
||||
319
installer/ring_installer/transformers/cursor_rules.py
Normal file
319
installer/ring_installer/transformers/cursor_rules.py
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
"""
|
||||
Cursor rules generator.
|
||||
|
||||
Generates .cursorrules files from Ring skills and components.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ring_installer.transformers.base import (
|
||||
BaseTransformer,
|
||||
TransformContext,
|
||||
TransformResult,
|
||||
)
|
||||
|
||||
|
||||
class CursorRulesGenerator(BaseTransformer):
|
||||
"""
|
||||
Generator for Cursor .cursorrules files.
|
||||
|
||||
Creates consolidated rules files from multiple Ring skills,
|
||||
formatted according to Cursor's conventions.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the generator."""
|
||||
super().__init__()
|
||||
self.rules: List[Dict[str, Any]] = []
|
||||
|
||||
def transform(self, content: str, context: TransformContext) -> TransformResult:
|
||||
"""Not used for generator - use add_skill and generate instead."""
|
||||
raise NotImplementedError("Use add_skill() and generate() methods")
|
||||
|
||||
def add_skill(
|
||||
self,
|
||||
content: str,
|
||||
name: str,
|
||||
source_path: Optional[str] = None
|
||||
) -> None:
|
||||
"""
|
||||
Add a skill to be included in the rules file.
|
||||
|
||||
Args:
|
||||
content: Skill content (markdown with frontmatter)
|
||||
name: Skill name
|
||||
source_path: Original file path
|
||||
"""
|
||||
frontmatter, body = self.extract_frontmatter(content)
|
||||
|
||||
rule = {
|
||||
"name": frontmatter.get("name", name),
|
||||
"description": frontmatter.get("description", ""),
|
||||
"trigger": frontmatter.get("trigger", ""),
|
||||
"skip_when": frontmatter.get("skip_when", ""),
|
||||
"content": body,
|
||||
"source": source_path,
|
||||
}
|
||||
|
||||
self.rules.append(rule)
|
||||
|
||||
def generate(self, include_metadata: bool = True) -> str:
|
||||
"""
|
||||
Generate the consolidated .cursorrules file.
|
||||
|
||||
Args:
|
||||
include_metadata: Whether to include source metadata comments
|
||||
|
||||
Returns:
|
||||
Complete .cursorrules file content
|
||||
"""
|
||||
parts: List[str] = []
|
||||
|
||||
# Header
|
||||
parts.append("# Cursor Rules")
|
||||
parts.append("")
|
||||
parts.append("These rules are generated from Ring skills.")
|
||||
parts.append("Edit with caution - changes may be overwritten on update.")
|
||||
parts.append("")
|
||||
parts.append("---")
|
||||
parts.append("")
|
||||
|
||||
# Generate each rule
|
||||
for rule in self.rules:
|
||||
parts.append(self._format_rule(rule, include_metadata))
|
||||
parts.append("")
|
||||
parts.append("---")
|
||||
parts.append("")
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
def _format_rule(self, rule: Dict[str, Any], include_metadata: bool) -> str:
|
||||
"""Format a single rule entry."""
|
||||
parts: List[str] = []
|
||||
|
||||
name = rule["name"]
|
||||
description = rule.get("description", "")
|
||||
trigger = rule.get("trigger", "")
|
||||
skip_when = rule.get("skip_when", "")
|
||||
content = rule.get("content", "")
|
||||
|
||||
# Rule header
|
||||
parts.append(f"## {self.to_title_case(name)}")
|
||||
parts.append("")
|
||||
|
||||
# Source comment
|
||||
if include_metadata and rule.get("source"):
|
||||
parts.append(f"<!-- Source: {rule['source']} -->")
|
||||
parts.append("")
|
||||
|
||||
# Description
|
||||
if description:
|
||||
clean_desc = self.clean_yaml_string(description)
|
||||
parts.append(clean_desc)
|
||||
parts.append("")
|
||||
|
||||
# Trigger conditions
|
||||
if trigger:
|
||||
parts.append("### When to Apply")
|
||||
parts.append("")
|
||||
self.add_list_items(parts, trigger)
|
||||
parts.append("")
|
||||
|
||||
# Skip conditions
|
||||
if skip_when:
|
||||
parts.append("### Skip When")
|
||||
parts.append("")
|
||||
self.add_list_items(parts, skip_when)
|
||||
parts.append("")
|
||||
|
||||
# Main content
|
||||
if content:
|
||||
parts.append("### Instructions")
|
||||
parts.append("")
|
||||
parts.append(self._transform_content(content))
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
def _transform_content(self, content: str) -> str:
|
||||
"""Transform content for Cursor compatibility."""
|
||||
# Use base class method with additional ring: prefix removal
|
||||
result = self.transform_body_for_cursor(content)
|
||||
result = result.replace("ring:", "") # Remove ring: prefix
|
||||
return result
|
||||
|
||||
|
||||
class CursorRulesTransformer(BaseTransformer):
|
||||
"""
|
||||
Transformer that generates .cursorrules content from individual skills.
|
||||
|
||||
This transformer is designed to work with the pipeline pattern,
|
||||
accumulating rules from multiple skills.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the transformer."""
|
||||
super().__init__()
|
||||
self.generator = CursorRulesGenerator()
|
||||
|
||||
def transform(self, content: str, context: TransformContext) -> TransformResult:
|
||||
"""
|
||||
Transform a skill to a .cursorrules entry.
|
||||
|
||||
This accumulates rules; call generate() to get final output.
|
||||
|
||||
Args:
|
||||
content: Skill content
|
||||
context: Transformation context
|
||||
|
||||
Returns:
|
||||
TransformResult with the transformed rule entry
|
||||
"""
|
||||
name = context.metadata.get("name", "unknown")
|
||||
source = context.source_path
|
||||
|
||||
# Add to generator
|
||||
self.generator.add_skill(content, name, source)
|
||||
|
||||
# Return individual transformed rule
|
||||
frontmatter, body = self.extract_frontmatter(content)
|
||||
rule_content = self._format_single_rule(frontmatter, body, name)
|
||||
|
||||
return TransformResult(
|
||||
content=rule_content,
|
||||
success=True,
|
||||
metadata={"rule_name": name}
|
||||
)
|
||||
|
||||
def generate_combined(self, include_metadata: bool = True) -> str:
|
||||
"""
|
||||
Generate the combined .cursorrules file.
|
||||
|
||||
Args:
|
||||
include_metadata: Whether to include source comments
|
||||
|
||||
Returns:
|
||||
Complete .cursorrules content
|
||||
"""
|
||||
return self.generator.generate(include_metadata)
|
||||
|
||||
def _format_single_rule(
|
||||
self,
|
||||
frontmatter: Dict[str, Any],
|
||||
body: str,
|
||||
name: str
|
||||
) -> str:
|
||||
"""Format a single rule entry."""
|
||||
parts: List[str] = []
|
||||
|
||||
# Header
|
||||
rule_name = frontmatter.get("name", name)
|
||||
parts.append(f"## {self.to_title_case(rule_name)}")
|
||||
parts.append("")
|
||||
|
||||
# Description
|
||||
description = frontmatter.get("description", "")
|
||||
if description:
|
||||
clean_desc = self.clean_yaml_string(description)
|
||||
parts.append(clean_desc)
|
||||
parts.append("")
|
||||
|
||||
# Trigger
|
||||
trigger = frontmatter.get("trigger", "")
|
||||
if trigger:
|
||||
parts.append("### When to Apply")
|
||||
parts.append("")
|
||||
self._add_list_items(parts, trigger)
|
||||
parts.append("")
|
||||
|
||||
# Skip
|
||||
skip_when = frontmatter.get("skip_when", "")
|
||||
if skip_when:
|
||||
parts.append("### Skip When")
|
||||
parts.append("")
|
||||
self._add_list_items(parts, skip_when)
|
||||
parts.append("")
|
||||
|
||||
# Content
|
||||
parts.append("### Instructions")
|
||||
parts.append("")
|
||||
parts.append(self._transform_body(body))
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
def _transform_body(self, body: str) -> str:
|
||||
"""Transform body for Cursor."""
|
||||
result = body
|
||||
|
||||
replacements = [
|
||||
("subagent", "sub-workflow"),
|
||||
("Subagent", "Sub-workflow"),
|
||||
("Task tool", "workflow"),
|
||||
("Skill tool", "rule"),
|
||||
]
|
||||
|
||||
for old, new in replacements:
|
||||
result = result.replace(old, new)
|
||||
|
||||
result = re.sub(
|
||||
r'`ring:([^`]+)`',
|
||||
lambda m: f"**{self.to_title_case(m.group(1))}**",
|
||||
result
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
|
||||
def generate_cursorrules_from_skills(
|
||||
skills: List[Dict[str, str]],
|
||||
include_metadata: bool = True
|
||||
) -> str:
|
||||
"""
|
||||
Generate a .cursorrules file from a list of skills.
|
||||
|
||||
Args:
|
||||
skills: List of dicts with 'content', 'name', and optionally 'source'
|
||||
include_metadata: Whether to include source comments
|
||||
|
||||
Returns:
|
||||
Complete .cursorrules file content
|
||||
"""
|
||||
generator = CursorRulesGenerator()
|
||||
|
||||
for skill in skills:
|
||||
generator.add_skill(
|
||||
content=skill.get("content", ""),
|
||||
name=skill.get("name", "unknown"),
|
||||
source_path=skill.get("source")
|
||||
)
|
||||
|
||||
return generator.generate(include_metadata)
|
||||
|
||||
|
||||
def write_cursorrules(
|
||||
output_path: Path,
|
||||
skills: List[Dict[str, str]],
|
||||
include_metadata: bool = True
|
||||
) -> Path:
|
||||
"""
|
||||
Write a .cursorrules file from skills.
|
||||
|
||||
Args:
|
||||
output_path: Path to write the file
|
||||
skills: List of skill dicts
|
||||
include_metadata: Whether to include source comments
|
||||
|
||||
Returns:
|
||||
Path to the written file
|
||||
"""
|
||||
content = generate_cursorrules_from_skills(skills, include_metadata)
|
||||
|
||||
output_path = Path(output_path).expanduser()
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
return output_path
|
||||
373
installer/ring_installer/transformers/hooks.py
Normal file
373
installer/ring_installer/transformers/hooks.py
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
"""
|
||||
Hook content transformer.
|
||||
|
||||
Generates and transforms hook configurations for different platforms.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ring_installer.transformers.base import (
|
||||
BaseTransformer,
|
||||
TransformContext,
|
||||
TransformResult,
|
||||
)
|
||||
|
||||
|
||||
class HookTransformer(BaseTransformer):
|
||||
"""
|
||||
Transformer for Ring hook files.
|
||||
|
||||
Handles transformation of hook configurations:
|
||||
- Claude: passthrough (native hooks.json format)
|
||||
- Factory: convert to .factory/hooks format
|
||||
- Cursor: best-effort conversion or skip
|
||||
- Cline: best-effort conversion or skip
|
||||
"""
|
||||
|
||||
def __init__(self, platform: str):
|
||||
"""
|
||||
Initialize the hook transformer.
|
||||
|
||||
Args:
|
||||
platform: Target platform identifier
|
||||
"""
|
||||
super().__init__()
|
||||
self.platform = platform
|
||||
|
||||
def transform(self, content: str, context: TransformContext) -> TransformResult:
|
||||
"""
|
||||
Transform hook content for the target platform.
|
||||
|
||||
Args:
|
||||
content: Original hook content
|
||||
context: Transformation context
|
||||
|
||||
Returns:
|
||||
TransformResult with transformed content
|
||||
"""
|
||||
# Determine file type from source path
|
||||
source_path = context.source_path or ""
|
||||
is_json = source_path.endswith(".json")
|
||||
is_script = source_path.endswith(".sh") or source_path.endswith(".py")
|
||||
|
||||
if self.platform == "claude":
|
||||
return self._transform_claude(content, context, is_json)
|
||||
elif self.platform == "factory":
|
||||
return self._transform_factory(content, context, is_json, is_script)
|
||||
elif self.platform == "cursor":
|
||||
return self._transform_cursor(content, context, is_json, is_script)
|
||||
elif self.platform == "cline":
|
||||
return self._transform_cline(content, context, is_json, is_script)
|
||||
else:
|
||||
return TransformResult(content=content, success=True)
|
||||
|
||||
def _transform_claude(
|
||||
self,
|
||||
content: str,
|
||||
context: TransformContext,
|
||||
is_json: bool
|
||||
) -> TransformResult:
|
||||
"""Transform hook for Claude Code (passthrough)."""
|
||||
return TransformResult(content=content, success=True)
|
||||
|
||||
def _transform_factory(
|
||||
self,
|
||||
content: str,
|
||||
context: TransformContext,
|
||||
is_json: bool,
|
||||
is_script: bool
|
||||
) -> TransformResult:
|
||||
"""
|
||||
Transform hook for Factory AI.
|
||||
|
||||
Factory uses a similar hooks concept but with different format.
|
||||
"""
|
||||
if is_json:
|
||||
return self._transform_factory_hooks_json(content, context)
|
||||
elif is_script:
|
||||
return self._transform_factory_script(content, context)
|
||||
|
||||
return TransformResult(content=content, success=True)
|
||||
|
||||
def _transform_factory_hooks_json(
|
||||
self,
|
||||
content: str,
|
||||
context: TransformContext
|
||||
) -> TransformResult:
|
||||
"""Transform hooks.json to Factory format."""
|
||||
try:
|
||||
hooks_config = json.loads(content)
|
||||
except json.JSONDecodeError as e:
|
||||
return TransformResult(
|
||||
content=content,
|
||||
success=False,
|
||||
errors=[f"Invalid JSON: {e}"]
|
||||
)
|
||||
|
||||
# Transform hook configuration
|
||||
transformed = self._convert_claude_hooks_to_factory(hooks_config)
|
||||
|
||||
# Serialize back to JSON
|
||||
output = json.dumps(transformed, indent=2)
|
||||
return TransformResult(content=output, success=True)
|
||||
|
||||
def _convert_claude_hooks_to_factory(
|
||||
self,
|
||||
hooks_config: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""Convert Claude hooks config to Factory format."""
|
||||
result: Dict[str, Any] = {
|
||||
"version": "1.0",
|
||||
"triggers": []
|
||||
}
|
||||
|
||||
hooks = hooks_config.get("hooks", [])
|
||||
for hook in hooks:
|
||||
event = hook.get("event", "")
|
||||
command = hook.get("command", "")
|
||||
|
||||
# Map event names
|
||||
event_mapping = {
|
||||
"SessionStart": "session_start",
|
||||
"SessionEnd": "session_end",
|
||||
"PreToolUse": "pre_tool",
|
||||
"PostToolUse": "post_tool",
|
||||
"Stop": "stop",
|
||||
"UserPromptSubmit": "prompt_submit",
|
||||
}
|
||||
|
||||
factory_event = event_mapping.get(event, event.lower())
|
||||
|
||||
trigger = {
|
||||
"event": factory_event,
|
||||
"action": command,
|
||||
}
|
||||
|
||||
# Copy conditions if present
|
||||
if "match_files" in hook:
|
||||
trigger["match_files"] = hook["match_files"]
|
||||
if "match_tools" in hook:
|
||||
trigger["match_tools"] = hook["match_tools"]
|
||||
|
||||
result["triggers"].append(trigger)
|
||||
|
||||
return result
|
||||
|
||||
def _transform_factory_script(
|
||||
self,
|
||||
content: str,
|
||||
context: TransformContext
|
||||
) -> TransformResult:
|
||||
"""Transform hook script for Factory."""
|
||||
# Replace terminology in scripts
|
||||
result = content
|
||||
|
||||
replacements = [
|
||||
("agent", "droid"),
|
||||
("AGENT", "DROID"),
|
||||
("Agent", "Droid"),
|
||||
]
|
||||
|
||||
for old, new in replacements:
|
||||
result = result.replace(old, new)
|
||||
|
||||
return TransformResult(content=result, success=True)
|
||||
|
||||
def _transform_cursor(
|
||||
self,
|
||||
content: str,
|
||||
context: TransformContext,
|
||||
is_json: bool,
|
||||
is_script: bool
|
||||
) -> TransformResult:
|
||||
"""
|
||||
Transform hook for Cursor.
|
||||
|
||||
Cursor has limited hook support - generate automation rules
|
||||
or skip with warning.
|
||||
"""
|
||||
warnings: List[str] = []
|
||||
|
||||
if is_json:
|
||||
# Try to convert to Cursor automation format
|
||||
try:
|
||||
hooks_config = json.loads(content)
|
||||
transformed = self._convert_to_cursor_automation(hooks_config)
|
||||
if transformed:
|
||||
output = json.dumps(transformed, indent=2)
|
||||
return TransformResult(
|
||||
content=output,
|
||||
success=True,
|
||||
warnings=["Converted to Cursor automation format (limited support)"]
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
warnings.append("Cursor has limited hook support - some features may not work")
|
||||
return TransformResult(content=content, success=True, warnings=warnings)
|
||||
|
||||
elif is_script:
|
||||
# Scripts can't be directly used in Cursor
|
||||
warnings.append(
|
||||
f"Hook script '{context.source_path}' cannot be converted for Cursor - "
|
||||
"manual integration may be required"
|
||||
)
|
||||
return TransformResult(content=content, success=True, warnings=warnings)
|
||||
|
||||
return TransformResult(content=content, success=True)
|
||||
|
||||
def _convert_to_cursor_automation(
|
||||
self,
|
||||
hooks_config: Dict[str, Any]
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Convert hooks to Cursor automation format if possible."""
|
||||
# Cursor's automation is limited - return simplified format
|
||||
automations: List[Dict[str, Any]] = []
|
||||
|
||||
hooks = hooks_config.get("hooks", [])
|
||||
for hook in hooks:
|
||||
event = hook.get("event", "")
|
||||
|
||||
# Only SessionStart maps reasonably to Cursor
|
||||
if event == "SessionStart":
|
||||
automation = {
|
||||
"trigger": "project_open",
|
||||
"action": "run_rule",
|
||||
"rule": hook.get("command", "").replace("bash ", "")
|
||||
}
|
||||
automations.append(automation)
|
||||
|
||||
if automations:
|
||||
return {"automations": automations}
|
||||
return None
|
||||
|
||||
def _transform_cline(
|
||||
self,
|
||||
content: str,
|
||||
context: TransformContext,
|
||||
is_json: bool,
|
||||
is_script: bool
|
||||
) -> TransformResult:
|
||||
"""
|
||||
Transform hook for Cline.
|
||||
|
||||
Cline has very limited hook support via VS Code settings.
|
||||
Generate best-effort conversion with warnings.
|
||||
"""
|
||||
warnings: List[str] = []
|
||||
|
||||
if is_json:
|
||||
warnings.append(
|
||||
"Cline hooks are managed via VS Code extension settings - "
|
||||
"manual configuration may be required"
|
||||
)
|
||||
|
||||
try:
|
||||
hooks_config = json.loads(content)
|
||||
# Generate documentation comments about what hooks do
|
||||
docs = self._generate_cline_hook_docs(hooks_config)
|
||||
return TransformResult(
|
||||
content=docs,
|
||||
success=True,
|
||||
warnings=warnings,
|
||||
metadata={"original_config": hooks_config}
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
elif is_script:
|
||||
warnings.append(
|
||||
f"Hook script '{context.source_path}' cannot be used directly in Cline - "
|
||||
"consider converting to a prompt"
|
||||
)
|
||||
|
||||
return TransformResult(content=content, success=True, warnings=warnings)
|
||||
|
||||
def _generate_cline_hook_docs(self, hooks_config: Dict[str, Any]) -> str:
|
||||
"""Generate documentation about hooks for Cline users."""
|
||||
lines = [
|
||||
"# Hook Configuration Reference",
|
||||
"",
|
||||
"The following hooks are configured in Ring. Cline does not support",
|
||||
"automatic hooks, but you can manually trigger these behaviors.",
|
||||
"",
|
||||
]
|
||||
|
||||
hooks = hooks_config.get("hooks", [])
|
||||
for hook in hooks:
|
||||
event = hook.get("event", "Unknown")
|
||||
command = hook.get("command", "")
|
||||
|
||||
lines.append(f"## {event}")
|
||||
lines.append("")
|
||||
lines.append(f"**Original command:** `{command}`")
|
||||
lines.append("")
|
||||
|
||||
# Add guidance based on event type
|
||||
if event == "SessionStart":
|
||||
lines.append("**Cline equivalent:** Run this manually at session start")
|
||||
elif event == "PreToolUse":
|
||||
lines.append("**Cline equivalent:** Review before tool execution")
|
||||
elif event == "PostToolUse":
|
||||
lines.append("**Cline equivalent:** Run after tool completion")
|
||||
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class HookTransformerFactory:
|
||||
"""Factory for creating platform-specific hook transformers."""
|
||||
|
||||
@classmethod
|
||||
def create(cls, platform: str) -> HookTransformer:
|
||||
"""
|
||||
Create a hook transformer for the specified platform.
|
||||
|
||||
Args:
|
||||
platform: Target platform identifier
|
||||
|
||||
Returns:
|
||||
Configured HookTransformer
|
||||
"""
|
||||
return HookTransformer(platform=platform)
|
||||
|
||||
|
||||
def generate_hooks_json(
|
||||
hooks: List[Dict[str, Any]],
|
||||
platform: str = "claude"
|
||||
) -> str:
|
||||
"""
|
||||
Generate a hooks.json configuration file.
|
||||
|
||||
Args:
|
||||
hooks: List of hook configurations
|
||||
platform: Target platform
|
||||
|
||||
Returns:
|
||||
JSON string of hooks configuration
|
||||
"""
|
||||
config = {
|
||||
"hooks": hooks
|
||||
}
|
||||
|
||||
return json.dumps(config, indent=2)
|
||||
|
||||
|
||||
def parse_hooks_json(content: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Parse a hooks.json configuration file.
|
||||
|
||||
Args:
|
||||
content: JSON content
|
||||
|
||||
Returns:
|
||||
List of hook configurations
|
||||
"""
|
||||
try:
|
||||
config = json.loads(content)
|
||||
return config.get("hooks", [])
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
325
installer/ring_installer/transformers/skill.py
Normal file
325
installer/ring_installer/transformers/skill.py
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
"""
|
||||
Skill content transformer.
|
||||
|
||||
Transforms Ring SKILL.md files to platform-specific formats.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ring_installer.transformers.base import (
|
||||
BaseTransformer,
|
||||
TransformContext,
|
||||
TransformResult,
|
||||
)
|
||||
|
||||
|
||||
class SkillTransformer(BaseTransformer):
|
||||
"""
|
||||
Transformer for Ring skill files.
|
||||
|
||||
Handles transformation of YAML frontmatter and content body
|
||||
for different platform conventions.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
platform: str,
|
||||
terminology: Optional[Dict[str, str]] = None,
|
||||
preserve_frontmatter: bool = True
|
||||
):
|
||||
"""
|
||||
Initialize the skill transformer.
|
||||
|
||||
Args:
|
||||
platform: Target platform identifier
|
||||
terminology: Platform-specific terminology mapping
|
||||
preserve_frontmatter: Whether to keep frontmatter in output
|
||||
"""
|
||||
super().__init__()
|
||||
self.platform = platform
|
||||
self.terminology = terminology or {}
|
||||
self.preserve_frontmatter = preserve_frontmatter
|
||||
|
||||
def transform(self, content: str, context: TransformContext) -> TransformResult:
|
||||
"""
|
||||
Transform skill content for the target platform.
|
||||
|
||||
Args:
|
||||
content: Original skill content
|
||||
context: Transformation context
|
||||
|
||||
Returns:
|
||||
TransformResult with transformed content
|
||||
"""
|
||||
errors = self.validate(content, context)
|
||||
if errors:
|
||||
return TransformResult(content=content, success=False, errors=errors)
|
||||
|
||||
frontmatter, body = self.extract_frontmatter(content)
|
||||
|
||||
# Transform based on platform
|
||||
if self.platform == "claude":
|
||||
return self._transform_claude(frontmatter, body, context)
|
||||
elif self.platform == "factory":
|
||||
return self._transform_factory(frontmatter, body, context)
|
||||
elif self.platform == "cursor":
|
||||
return self._transform_cursor(frontmatter, body, context)
|
||||
elif self.platform == "cline":
|
||||
return self._transform_cline(frontmatter, body, context)
|
||||
else:
|
||||
# Default passthrough
|
||||
return TransformResult(content=content, success=True)
|
||||
|
||||
def _transform_claude(
|
||||
self,
|
||||
frontmatter: Dict[str, Any],
|
||||
body: str,
|
||||
context: TransformContext
|
||||
) -> TransformResult:
|
||||
"""Transform skill for Claude Code (passthrough)."""
|
||||
# Claude uses Ring format natively
|
||||
if frontmatter:
|
||||
content = self.create_frontmatter(frontmatter) + "\n" + body
|
||||
else:
|
||||
content = body
|
||||
|
||||
return TransformResult(content=content, success=True)
|
||||
|
||||
def _transform_factory(
|
||||
self,
|
||||
frontmatter: Dict[str, Any],
|
||||
body: str,
|
||||
context: TransformContext
|
||||
) -> TransformResult:
|
||||
"""Transform skill for Factory AI."""
|
||||
# Update terminology in frontmatter
|
||||
transformed_fm = self._transform_frontmatter_terminology(frontmatter)
|
||||
|
||||
# Update terminology in body
|
||||
transformed_body = self._transform_body_terminology(body)
|
||||
|
||||
if transformed_fm and self.preserve_frontmatter:
|
||||
content = self.create_frontmatter(transformed_fm) + "\n" + transformed_body
|
||||
else:
|
||||
content = transformed_body
|
||||
|
||||
return TransformResult(content=content, success=True)
|
||||
|
||||
def _transform_cursor(
|
||||
self,
|
||||
frontmatter: Dict[str, Any],
|
||||
body: str,
|
||||
context: TransformContext
|
||||
) -> TransformResult:
|
||||
"""Transform skill to Cursor rule format."""
|
||||
parts: List[str] = []
|
||||
|
||||
# Extract metadata
|
||||
name = frontmatter.get("name", context.metadata.get("name", "Untitled Rule"))
|
||||
description = frontmatter.get("description", "")
|
||||
|
||||
# Build rule structure
|
||||
parts.append(f"# {self.to_title_case(name)}")
|
||||
parts.append("")
|
||||
|
||||
if description:
|
||||
clean_desc = self.clean_yaml_string(description)
|
||||
parts.append(clean_desc)
|
||||
parts.append("")
|
||||
|
||||
# Trigger conditions -> "When to Apply"
|
||||
trigger = frontmatter.get("trigger", "")
|
||||
if trigger:
|
||||
parts.append("## When to Apply")
|
||||
parts.append("")
|
||||
self._add_list_section(parts, trigger)
|
||||
parts.append("")
|
||||
|
||||
# Skip conditions
|
||||
skip_when = frontmatter.get("skip_when", "")
|
||||
if skip_when:
|
||||
parts.append("## Skip When")
|
||||
parts.append("")
|
||||
self._add_list_section(parts, skip_when)
|
||||
parts.append("")
|
||||
|
||||
# Main instructions
|
||||
parts.append("## Instructions")
|
||||
parts.append("")
|
||||
parts.append(self.transform_body_for_cursor(body))
|
||||
|
||||
return TransformResult(content="\n".join(parts), success=True)
|
||||
|
||||
def _transform_cline(
|
||||
self,
|
||||
frontmatter: Dict[str, Any],
|
||||
body: str,
|
||||
context: TransformContext
|
||||
) -> TransformResult:
|
||||
"""Transform skill to Cline prompt format."""
|
||||
parts: List[str] = []
|
||||
|
||||
# Extract metadata
|
||||
name = frontmatter.get("name", context.metadata.get("name", "Untitled"))
|
||||
description = frontmatter.get("description", "")
|
||||
|
||||
# HTML comments for metadata
|
||||
parts.append(f"<!-- Prompt: {name} -->")
|
||||
parts.append("<!-- Type: skill -->")
|
||||
if context.source_path:
|
||||
parts.append(f"<!-- Source: {context.source_path} -->")
|
||||
parts.append("")
|
||||
|
||||
# Title
|
||||
parts.append(f"# {self.to_title_case(name)}")
|
||||
parts.append("")
|
||||
|
||||
# Description as blockquote
|
||||
if description:
|
||||
clean_desc = self.clean_yaml_string(description)
|
||||
parts.append(f"> {clean_desc}")
|
||||
parts.append("")
|
||||
|
||||
# Trigger -> "Use When"
|
||||
trigger = frontmatter.get("trigger", "")
|
||||
if trigger:
|
||||
parts.append("## Use This Prompt When")
|
||||
parts.append("")
|
||||
self._add_list_section(parts, trigger)
|
||||
parts.append("")
|
||||
|
||||
# Skip -> "Do Not Use When"
|
||||
skip_when = frontmatter.get("skip_when", "")
|
||||
if skip_when:
|
||||
parts.append("## Do Not Use When")
|
||||
parts.append("")
|
||||
self._add_list_section(parts, skip_when)
|
||||
parts.append("")
|
||||
|
||||
# Related prompts
|
||||
related = frontmatter.get("related", {})
|
||||
if related:
|
||||
similar = related.get("similar", [])
|
||||
complementary = related.get("complementary", [])
|
||||
if similar or complementary:
|
||||
parts.append("## Related Prompts")
|
||||
parts.append("")
|
||||
if similar:
|
||||
parts.append("**Similar:** " + ", ".join(similar))
|
||||
if complementary:
|
||||
parts.append("**Works well with:** " + ", ".join(complementary))
|
||||
parts.append("")
|
||||
|
||||
# Main instructions
|
||||
parts.append("## Instructions")
|
||||
parts.append("")
|
||||
parts.append(self.transform_body_for_cline(body))
|
||||
|
||||
return TransformResult(content="\n".join(parts), success=True)
|
||||
|
||||
def _transform_frontmatter_terminology(
|
||||
self,
|
||||
frontmatter: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""Apply terminology changes to frontmatter."""
|
||||
result = dict(frontmatter)
|
||||
|
||||
for old_term, new_term in self.terminology.items():
|
||||
if old_term == new_term:
|
||||
continue
|
||||
|
||||
# Rename keys
|
||||
if old_term in result:
|
||||
result[new_term] = result.pop(old_term)
|
||||
|
||||
# Rename plurals
|
||||
old_plural = f"{old_term}s"
|
||||
new_plural = f"{new_term}s"
|
||||
if old_plural in result:
|
||||
result[new_plural] = result.pop(old_plural)
|
||||
|
||||
# Transform string values
|
||||
for key, value in list(result.items()):
|
||||
if isinstance(value, str):
|
||||
result[key] = self._replace_term(value, old_term, new_term)
|
||||
elif isinstance(value, list):
|
||||
result[key] = [
|
||||
self._replace_term(v, old_term, new_term)
|
||||
if isinstance(v, str) else v
|
||||
for v in value
|
||||
]
|
||||
|
||||
return result
|
||||
|
||||
def _transform_body_terminology(self, body: str) -> str:
|
||||
"""Apply terminology changes to body content."""
|
||||
result = body
|
||||
|
||||
for old_term, new_term in self.terminology.items():
|
||||
if old_term != new_term:
|
||||
result = self._replace_term(result, old_term, new_term)
|
||||
|
||||
return result
|
||||
|
||||
def _replace_term(self, text: str, old_term: str, new_term: str) -> str:
|
||||
"""Replace a term with various case variants."""
|
||||
import re
|
||||
result = text
|
||||
# Lowercase
|
||||
result = re.sub(rf'\b{old_term}\b', new_term, result)
|
||||
# Title case
|
||||
result = re.sub(rf'\b{old_term.title()}\b', new_term.title(), result)
|
||||
# Uppercase
|
||||
result = re.sub(rf'\b{old_term.upper()}\b', new_term.upper(), result)
|
||||
return result
|
||||
|
||||
def _add_list_section(self, parts: List[str], text: str) -> None:
|
||||
"""Add list items from YAML list or multi-line string."""
|
||||
self.add_list_items(parts, text)
|
||||
|
||||
|
||||
class SkillTransformerFactory:
|
||||
"""Factory for creating platform-specific skill transformers."""
|
||||
|
||||
PLATFORM_TERMINOLOGY = {
|
||||
"claude": {
|
||||
"agent": "agent",
|
||||
"skill": "skill",
|
||||
"command": "command",
|
||||
},
|
||||
"factory": {
|
||||
"agent": "droid",
|
||||
"skill": "skill",
|
||||
"command": "command",
|
||||
},
|
||||
"cursor": {
|
||||
"agent": "workflow",
|
||||
"skill": "rule",
|
||||
"command": "workflow",
|
||||
},
|
||||
"cline": {
|
||||
"agent": "prompt",
|
||||
"skill": "prompt",
|
||||
"command": "prompt",
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def create(cls, platform: str) -> SkillTransformer:
|
||||
"""
|
||||
Create a skill transformer for the specified platform.
|
||||
|
||||
Args:
|
||||
platform: Target platform identifier
|
||||
|
||||
Returns:
|
||||
Configured SkillTransformer
|
||||
"""
|
||||
terminology = cls.PLATFORM_TERMINOLOGY.get(platform, {})
|
||||
preserve_frontmatter = platform in ("claude", "factory")
|
||||
|
||||
return SkillTransformer(
|
||||
platform=platform,
|
||||
terminology=terminology,
|
||||
preserve_frontmatter=preserve_frontmatter
|
||||
)
|
||||
|
|
@ -14,6 +14,16 @@ from ring_installer.utils.platform_detect import (
|
|||
get_platform_version,
|
||||
is_platform_installed,
|
||||
)
|
||||
from ring_installer.utils.version import (
|
||||
Version,
|
||||
InstallManifest,
|
||||
compare_versions,
|
||||
is_update_available,
|
||||
get_ring_version,
|
||||
get_installed_version,
|
||||
check_for_updates,
|
||||
save_install_manifest,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Filesystem utilities
|
||||
|
|
@ -26,4 +36,13 @@ __all__ = [
|
|||
"detect_installed_platforms",
|
||||
"get_platform_version",
|
||||
"is_platform_installed",
|
||||
# Version management
|
||||
"Version",
|
||||
"InstallManifest",
|
||||
"compare_versions",
|
||||
"is_update_available",
|
||||
"get_ring_version",
|
||||
"get_installed_version",
|
||||
"check_for_updates",
|
||||
"save_install_manifest",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -103,13 +103,18 @@ def copy_with_transform(
|
|||
Raises:
|
||||
FileNotFoundError: If source file doesn't exist
|
||||
PermissionError: If target cannot be written
|
||||
ValueError: If target is a symlink
|
||||
"""
|
||||
source = Path(source).expanduser()
|
||||
target = Path(target).expanduser()
|
||||
source = Path(source).expanduser().resolve()
|
||||
target = Path(target).expanduser().resolve()
|
||||
|
||||
if not source.exists():
|
||||
raise FileNotFoundError(f"Source file not found: {source}")
|
||||
|
||||
# Check if target exists and is a symlink
|
||||
if target.exists() and target.is_symlink():
|
||||
raise ValueError(f"Refusing to write to symlink: {target}")
|
||||
|
||||
# Ensure target directory exists
|
||||
ensure_directory(target.parent)
|
||||
|
||||
|
|
@ -304,20 +309,47 @@ def atomic_write(path: Path, content: Union[str, bytes], encoding: str = "utf-8"
|
|||
path: Target file path
|
||||
content: Content to write (str or bytes)
|
||||
encoding: Encoding for string content
|
||||
|
||||
Raises:
|
||||
ValueError: If target is a symlink
|
||||
"""
|
||||
path = Path(path).expanduser()
|
||||
temp_path = path.parent / f".{path.name}.tmp"
|
||||
import tempfile
|
||||
|
||||
path = Path(path).expanduser().resolve()
|
||||
|
||||
# Check if target exists and is a symlink
|
||||
if path.exists() and path.is_symlink():
|
||||
raise ValueError(f"Refusing to write to symlink: {path}")
|
||||
|
||||
# Ensure parent directory exists
|
||||
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"
|
||||
)
|
||||
temp_path = Path(temp_path_str)
|
||||
|
||||
try:
|
||||
if isinstance(content, bytes):
|
||||
with open(temp_path, "wb") as f:
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(content)
|
||||
else:
|
||||
with open(temp_path, "w", encoding=encoding) as f:
|
||||
with os.fdopen(fd, "w", encoding=encoding) as f:
|
||||
f.write(content)
|
||||
|
||||
# Set explicit permissions (rw-r--r--) before rename
|
||||
os.chmod(temp_path, 0o644)
|
||||
|
||||
# Atomic rename
|
||||
temp_path.replace(path)
|
||||
except Exception:
|
||||
# Clean up temp file on error
|
||||
if temp_path.exists():
|
||||
temp_path.unlink()
|
||||
raise
|
||||
finally:
|
||||
# Clean up temp file if rename failed
|
||||
if temp_path.exists():
|
||||
|
|
|
|||
460
installer/ring_installer/utils/platform_detect.py
Normal file
460
installer/ring_installer/utils/platform_detect.py
Normal file
|
|
@ -0,0 +1,460 @@
|
|||
"""
|
||||
Platform detection utilities for Ring installer.
|
||||
|
||||
Provides functions to detect which AI platforms are installed on the system
|
||||
and retrieve their version information.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
from ring_installer.adapters import SUPPORTED_PLATFORMS
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlatformInfo:
|
||||
"""Information about an installed platform."""
|
||||
platform_id: str
|
||||
name: str
|
||||
installed: bool
|
||||
partial: bool = False
|
||||
version: Optional[str] = None
|
||||
install_path: Optional[Path] = None
|
||||
config_path: Optional[Path] = None
|
||||
binary_path: Optional[Path] = None
|
||||
details: Dict[str, Any] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.details is None:
|
||||
self.details = {}
|
||||
|
||||
|
||||
def detect_installed_platforms() -> List[PlatformInfo]:
|
||||
"""
|
||||
Detect all supported platforms that are installed on the system.
|
||||
|
||||
Returns:
|
||||
List of PlatformInfo objects for each detected platform
|
||||
"""
|
||||
platforms = []
|
||||
|
||||
for platform_id in SUPPORTED_PLATFORMS:
|
||||
info = _detect_platform(platform_id)
|
||||
if info.installed:
|
||||
platforms.append(info)
|
||||
|
||||
return platforms
|
||||
|
||||
|
||||
def is_platform_installed(platform_id: str) -> bool:
|
||||
"""
|
||||
Check if a specific platform is installed.
|
||||
|
||||
Args:
|
||||
platform_id: Platform identifier (claude, factory, cursor, cline)
|
||||
|
||||
Returns:
|
||||
True if the platform is installed
|
||||
"""
|
||||
info = _detect_platform(platform_id)
|
||||
return info.installed
|
||||
|
||||
|
||||
def get_platform_version(platform_id: str) -> Optional[str]:
|
||||
"""
|
||||
Get the version of an installed platform.
|
||||
|
||||
Args:
|
||||
platform_id: Platform identifier
|
||||
|
||||
Returns:
|
||||
Version string, or None if not installed or version unavailable
|
||||
"""
|
||||
info = _detect_platform(platform_id)
|
||||
return info.version if info.installed else None
|
||||
|
||||
|
||||
def get_platform_info(platform_id: str) -> PlatformInfo:
|
||||
"""
|
||||
Get detailed information about a platform.
|
||||
|
||||
Args:
|
||||
platform_id: Platform identifier
|
||||
|
||||
Returns:
|
||||
PlatformInfo object with detection results
|
||||
"""
|
||||
return _detect_platform(platform_id)
|
||||
|
||||
|
||||
def _detect_platform(platform_id: str) -> PlatformInfo:
|
||||
"""
|
||||
Detect a specific platform.
|
||||
|
||||
Args:
|
||||
platform_id: Platform identifier
|
||||
|
||||
Returns:
|
||||
PlatformInfo with detection results
|
||||
"""
|
||||
detectors = {
|
||||
"claude": _detect_claude,
|
||||
"factory": _detect_factory,
|
||||
"cursor": _detect_cursor,
|
||||
"cline": _detect_cline,
|
||||
}
|
||||
|
||||
detector = detectors.get(platform_id.lower())
|
||||
if detector:
|
||||
return detector()
|
||||
|
||||
return PlatformInfo(
|
||||
platform_id=platform_id,
|
||||
name=platform_id.title(),
|
||||
installed=False
|
||||
)
|
||||
|
||||
|
||||
def _detect_claude() -> PlatformInfo:
|
||||
"""
|
||||
Detect Claude Code installation.
|
||||
|
||||
Checks for:
|
||||
- ~/.claude directory
|
||||
- claude binary in PATH
|
||||
"""
|
||||
info = PlatformInfo(
|
||||
platform_id="claude",
|
||||
name="Claude Code",
|
||||
installed=False
|
||||
)
|
||||
|
||||
# Check config directory
|
||||
config_path = Path.home() / ".claude"
|
||||
if config_path.exists():
|
||||
info.config_path = config_path
|
||||
info.install_path = config_path
|
||||
|
||||
# Check for binary
|
||||
binary = shutil.which("claude")
|
||||
if binary:
|
||||
info.binary_path = Path(binary)
|
||||
info.installed = True
|
||||
|
||||
# Get version using resolved binary path
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[binary, "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
version_match = re.search(r"(\d+\.\d+\.\d+)", result.stdout)
|
||||
if version_match:
|
||||
info.version = version_match.group(1)
|
||||
except (subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError):
|
||||
pass
|
||||
|
||||
# Even without binary, config directory indicates partial install
|
||||
if not info.installed and config_path.exists():
|
||||
info.installed = False # Don't mark as installed
|
||||
info.partial = True # Mark as partial
|
||||
info.details["note"] = "Config directory exists but binary not found"
|
||||
|
||||
return info
|
||||
|
||||
|
||||
def _detect_factory() -> PlatformInfo:
|
||||
"""
|
||||
Detect Factory AI installation.
|
||||
|
||||
Checks for:
|
||||
- ~/.factory directory
|
||||
- factory binary in PATH
|
||||
"""
|
||||
info = PlatformInfo(
|
||||
platform_id="factory",
|
||||
name="Factory AI",
|
||||
installed=False
|
||||
)
|
||||
|
||||
# Check config directory
|
||||
config_path = Path.home() / ".factory"
|
||||
if config_path.exists():
|
||||
info.config_path = config_path
|
||||
info.install_path = config_path
|
||||
|
||||
# Check for binary
|
||||
binary = shutil.which("factory")
|
||||
if binary:
|
||||
info.binary_path = Path(binary)
|
||||
info.installed = True
|
||||
|
||||
# Get version using resolved binary path
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[binary, "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
version_match = re.search(r"(\d+\.\d+\.\d+)", result.stdout)
|
||||
if version_match:
|
||||
info.version = version_match.group(1)
|
||||
except (subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError):
|
||||
pass
|
||||
|
||||
if not info.installed and config_path.exists():
|
||||
info.installed = False # Don't mark as installed
|
||||
info.partial = True # Mark as partial
|
||||
info.details["note"] = "Config directory exists but binary not found"
|
||||
|
||||
return info
|
||||
|
||||
|
||||
def _detect_cursor() -> PlatformInfo:
|
||||
"""
|
||||
Detect Cursor installation.
|
||||
|
||||
Checks for:
|
||||
- ~/.cursor directory
|
||||
- Cursor application in standard locations
|
||||
"""
|
||||
info = PlatformInfo(
|
||||
platform_id="cursor",
|
||||
name="Cursor",
|
||||
installed=False
|
||||
)
|
||||
|
||||
# Check config directory
|
||||
config_path = Path.home() / ".cursor"
|
||||
if config_path.exists():
|
||||
info.config_path = config_path
|
||||
info.install_path = config_path
|
||||
|
||||
# Check for application
|
||||
app_paths = _get_cursor_app_paths()
|
||||
for app_path in app_paths:
|
||||
if app_path.exists():
|
||||
info.binary_path = app_path
|
||||
info.installed = True
|
||||
|
||||
# Try to get version from package.json or similar
|
||||
version = _get_cursor_version(app_path)
|
||||
if version:
|
||||
info.version = version
|
||||
break
|
||||
|
||||
if not info.installed and config_path.exists():
|
||||
info.installed = False # Don't mark as installed
|
||||
info.partial = True # Mark as partial
|
||||
info.details["note"] = "Config directory exists but application not found"
|
||||
|
||||
return info
|
||||
|
||||
|
||||
def _detect_cline() -> PlatformInfo:
|
||||
"""
|
||||
Detect Cline installation.
|
||||
|
||||
Checks for:
|
||||
- ~/.cline directory
|
||||
- Cline VS Code extension
|
||||
"""
|
||||
info = PlatformInfo(
|
||||
platform_id="cline",
|
||||
name="Cline",
|
||||
installed=False
|
||||
)
|
||||
|
||||
# Check config directory
|
||||
config_path = Path.home() / ".cline"
|
||||
if config_path.exists():
|
||||
info.config_path = config_path
|
||||
info.install_path = config_path
|
||||
info.installed = True
|
||||
|
||||
# Check for VS Code extension
|
||||
extension_info = _find_vscode_extension("saoudrizwan.claude-dev")
|
||||
if extension_info:
|
||||
info.installed = True
|
||||
info.version = extension_info.get("version")
|
||||
info.details["extension_path"] = extension_info.get("path")
|
||||
info.details["extension_id"] = "saoudrizwan.claude-dev"
|
||||
|
||||
return info
|
||||
|
||||
|
||||
def _get_cursor_app_paths() -> List[Path]:
|
||||
"""Get potential Cursor application paths based on platform."""
|
||||
paths = []
|
||||
|
||||
if sys.platform == "darwin":
|
||||
paths.extend([
|
||||
Path("/Applications/Cursor.app"),
|
||||
Path.home() / "Applications/Cursor.app",
|
||||
])
|
||||
elif sys.platform == "win32":
|
||||
local_app_data = os.environ.get("LOCALAPPDATA", "")
|
||||
if local_app_data:
|
||||
paths.append(Path(local_app_data) / "Programs/cursor/Cursor.exe")
|
||||
paths.append(Path("C:/Program Files/Cursor/Cursor.exe"))
|
||||
else: # Linux
|
||||
paths.extend([
|
||||
Path("/usr/bin/cursor"),
|
||||
Path("/usr/local/bin/cursor"),
|
||||
Path.home() / ".local/bin/cursor",
|
||||
Path("/opt/Cursor/cursor"),
|
||||
])
|
||||
|
||||
return paths
|
||||
|
||||
|
||||
def _get_cursor_version(app_path: Path) -> Optional[str]:
|
||||
"""Try to extract Cursor version from application."""
|
||||
if sys.platform == "darwin":
|
||||
# Try Info.plist
|
||||
plist_path = app_path / "Contents/Info.plist"
|
||||
if plist_path.exists():
|
||||
try:
|
||||
import plistlib
|
||||
with open(plist_path, "rb") as f:
|
||||
plist = plistlib.load(f)
|
||||
return plist.get("CFBundleShortVersionString")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Try package.json in resources
|
||||
package_json = app_path / "Contents/Resources/app/package.json"
|
||||
if package_json.exists():
|
||||
try:
|
||||
with open(package_json) as f:
|
||||
data = json.load(f)
|
||||
return data.get("version")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
elif sys.platform == "win32":
|
||||
# Try version from executable (would need win32api)
|
||||
# Fall back to checking package.json if available
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _find_vscode_extension(extension_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Find a VS Code extension and get its information.
|
||||
|
||||
Args:
|
||||
extension_id: Extension identifier (publisher.name)
|
||||
|
||||
Returns:
|
||||
Dictionary with extension info, or None if not found
|
||||
"""
|
||||
extension_paths = _get_vscode_extension_paths()
|
||||
|
||||
for ext_dir in extension_paths:
|
||||
if not ext_dir.exists():
|
||||
continue
|
||||
|
||||
# Look for extension directory
|
||||
for entry in ext_dir.iterdir():
|
||||
if entry.is_dir() and entry.name.startswith(extension_id):
|
||||
# Found the extension
|
||||
package_json = entry / "package.json"
|
||||
if package_json.exists():
|
||||
try:
|
||||
with open(package_json) as f:
|
||||
data = json.load(f)
|
||||
return {
|
||||
"path": str(entry),
|
||||
"version": data.get("version"),
|
||||
"name": data.get("displayName", data.get("name")),
|
||||
}
|
||||
except Exception:
|
||||
return {"path": str(entry)}
|
||||
return {"path": str(entry)}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _get_vscode_extension_paths() -> List[Path]:
|
||||
"""Get VS Code extension directories based on platform."""
|
||||
paths = []
|
||||
|
||||
if sys.platform == "darwin":
|
||||
paths.extend([
|
||||
Path.home() / ".vscode/extensions",
|
||||
Path.home() / ".vscode-insiders/extensions",
|
||||
])
|
||||
elif sys.platform == "win32":
|
||||
user_profile = os.environ.get("USERPROFILE", "")
|
||||
if user_profile:
|
||||
paths.extend([
|
||||
Path(user_profile) / ".vscode/extensions",
|
||||
Path(user_profile) / ".vscode-insiders/extensions",
|
||||
])
|
||||
else: # Linux
|
||||
paths.extend([
|
||||
Path.home() / ".vscode/extensions",
|
||||
Path.home() / ".vscode-insiders/extensions",
|
||||
Path.home() / ".vscode-server/extensions",
|
||||
])
|
||||
|
||||
return paths
|
||||
|
||||
|
||||
def get_system_info() -> Dict[str, Any]:
|
||||
"""
|
||||
Get system information relevant to installation.
|
||||
|
||||
Returns:
|
||||
Dictionary with system details
|
||||
"""
|
||||
return {
|
||||
"platform": sys.platform,
|
||||
"python_version": sys.version,
|
||||
"home_directory": str(Path.home()),
|
||||
"current_directory": str(Path.cwd()),
|
||||
"path": os.environ.get("PATH", "").split(os.pathsep),
|
||||
}
|
||||
|
||||
|
||||
def print_detection_report() -> None:
|
||||
"""Print a human-readable report of detected platforms."""
|
||||
platforms = detect_installed_platforms()
|
||||
|
||||
print("Ring Installer - Platform Detection Report")
|
||||
print("=" * 50)
|
||||
|
||||
if not platforms:
|
||||
print("\nNo supported platforms detected.")
|
||||
print("\nSupported platforms:")
|
||||
for platform_id in SUPPORTED_PLATFORMS:
|
||||
print(f" - {platform_id}")
|
||||
return
|
||||
|
||||
print(f"\nDetected {len(platforms)} platform(s):\n")
|
||||
|
||||
for info in platforms:
|
||||
print(f" {info.name} ({info.platform_id})")
|
||||
if info.version:
|
||||
print(f" Version: {info.version}")
|
||||
if info.install_path:
|
||||
print(f" Install path: {info.install_path}")
|
||||
if info.binary_path:
|
||||
print(f" Binary: {info.binary_path}")
|
||||
if info.details:
|
||||
for key, value in info.details.items():
|
||||
print(f" {key}: {value}")
|
||||
print()
|
||||
461
installer/ring_installer/utils/version.py
Normal file
461
installer/ring_installer/utils/version.py
Normal file
|
|
@ -0,0 +1,461 @@
|
|||
"""
|
||||
Version management utilities for Ring installer.
|
||||
|
||||
Provides semver comparison, version detection, and update checking.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
@dataclass
|
||||
class Version:
|
||||
"""
|
||||
Semantic version representation.
|
||||
|
||||
Supports comparison operations and parsing from string.
|
||||
"""
|
||||
major: int
|
||||
minor: int
|
||||
patch: int
|
||||
prerelease: str = ""
|
||||
build: str = ""
|
||||
|
||||
@classmethod
|
||||
def parse(cls, version_str: str) -> "Version":
|
||||
"""
|
||||
Parse a version string into a Version object.
|
||||
|
||||
Args:
|
||||
version_str: Version string (e.g., "1.2.3", "1.2.3-beta.1", "1.2.3+build.123")
|
||||
|
||||
Returns:
|
||||
Version object
|
||||
|
||||
Raises:
|
||||
ValueError: If version string is invalid
|
||||
"""
|
||||
# Strip leading 'v' if present
|
||||
version_str = version_str.lstrip("v")
|
||||
|
||||
# Regex for semver: major.minor.patch[-prerelease][+build]
|
||||
pattern = r'^(\d+)\.(\d+)\.(\d+)(?:-([a-zA-Z0-9.-]+))?(?:\+([a-zA-Z0-9.-]+))?$'
|
||||
match = re.match(pattern, version_str)
|
||||
|
||||
if not match:
|
||||
raise ValueError(f"Invalid version string: '{version_str}'")
|
||||
|
||||
return cls(
|
||||
major=int(match.group(1)),
|
||||
minor=int(match.group(2)),
|
||||
patch=int(match.group(3)),
|
||||
prerelease=match.group(4) or "",
|
||||
build=match.group(5) or ""
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Convert to version string."""
|
||||
result = f"{self.major}.{self.minor}.{self.patch}"
|
||||
if self.prerelease:
|
||||
result += f"-{self.prerelease}"
|
||||
if self.build:
|
||||
result += f"+{self.build}"
|
||||
return result
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
if not isinstance(other, Version):
|
||||
return NotImplemented
|
||||
return (
|
||||
self.major == other.major and
|
||||
self.minor == other.minor and
|
||||
self.patch == other.patch and
|
||||
self.prerelease == other.prerelease
|
||||
)
|
||||
|
||||
def __lt__(self, other: "Version") -> bool:
|
||||
# Compare major.minor.patch
|
||||
if (self.major, self.minor, self.patch) != (other.major, other.minor, other.patch):
|
||||
return (self.major, self.minor, self.patch) < (other.major, other.minor, other.patch)
|
||||
|
||||
# Prerelease versions have lower precedence
|
||||
if self.prerelease and not other.prerelease:
|
||||
return True
|
||||
if not self.prerelease and other.prerelease:
|
||||
return False
|
||||
|
||||
# Compare prereleases
|
||||
return self._compare_prerelease(self.prerelease, other.prerelease) < 0
|
||||
|
||||
def __le__(self, other: "Version") -> bool:
|
||||
return self == other or self < other
|
||||
|
||||
def __gt__(self, other: "Version") -> bool:
|
||||
return not self <= other
|
||||
|
||||
def __ge__(self, other: "Version") -> bool:
|
||||
return not self < other
|
||||
|
||||
def _compare_prerelease(self, a: str, b: str) -> int:
|
||||
"""Compare prerelease identifiers."""
|
||||
if not a and not b:
|
||||
return 0
|
||||
if not a:
|
||||
return 1
|
||||
if not b:
|
||||
return -1
|
||||
|
||||
a_parts = a.split(".")
|
||||
b_parts = b.split(".")
|
||||
|
||||
for i in range(max(len(a_parts), len(b_parts))):
|
||||
a_part = a_parts[i] if i < len(a_parts) else ""
|
||||
b_part = b_parts[i] if i < len(b_parts) else ""
|
||||
|
||||
# Try numeric comparison
|
||||
try:
|
||||
a_num = int(a_part)
|
||||
b_num = int(b_part)
|
||||
if a_num != b_num:
|
||||
return a_num - b_num
|
||||
except ValueError:
|
||||
# Alphanumeric comparison
|
||||
if a_part != b_part:
|
||||
return -1 if a_part < b_part else 1
|
||||
|
||||
return 0
|
||||
|
||||
def is_prerelease(self) -> bool:
|
||||
"""Check if this is a prerelease version."""
|
||||
return bool(self.prerelease)
|
||||
|
||||
def bump_major(self) -> "Version":
|
||||
"""Return a new version with major incremented."""
|
||||
return Version(self.major + 1, 0, 0)
|
||||
|
||||
def bump_minor(self) -> "Version":
|
||||
"""Return a new version with minor incremented."""
|
||||
return Version(self.major, self.minor + 1, 0)
|
||||
|
||||
def bump_patch(self) -> "Version":
|
||||
"""Return a new version with patch incremented."""
|
||||
return Version(self.major, self.minor, self.patch + 1)
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstallManifest:
|
||||
"""
|
||||
Manifest tracking installed Ring components.
|
||||
|
||||
Stored in the installation directory to track versions and files.
|
||||
"""
|
||||
version: str
|
||||
installed_at: str
|
||||
source_path: str
|
||||
platform: str
|
||||
plugins: List[str]
|
||||
files: Dict[str, str] # path -> hash
|
||||
metadata: Dict[str, Any]
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
version: str,
|
||||
source_path: str,
|
||||
platform: str,
|
||||
plugins: Optional[List[str]] = None,
|
||||
files: Optional[Dict[str, str]] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> "InstallManifest":
|
||||
"""Create a new install manifest."""
|
||||
return cls(
|
||||
version=version,
|
||||
installed_at=datetime.now().isoformat(),
|
||||
source_path=source_path,
|
||||
platform=platform,
|
||||
plugins=plugins or [],
|
||||
files=files or {},
|
||||
metadata=metadata or {}
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
"version": self.version,
|
||||
"installed_at": self.installed_at,
|
||||
"source_path": self.source_path,
|
||||
"platform": self.platform,
|
||||
"plugins": self.plugins,
|
||||
"files": self.files,
|
||||
"metadata": self.metadata
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "InstallManifest":
|
||||
"""Create from dictionary."""
|
||||
return cls(
|
||||
version=data.get("version", "0.0.0"),
|
||||
installed_at=data.get("installed_at", ""),
|
||||
source_path=data.get("source_path", ""),
|
||||
platform=data.get("platform", ""),
|
||||
plugins=data.get("plugins", []),
|
||||
files=data.get("files", {}),
|
||||
metadata=data.get("metadata", {})
|
||||
)
|
||||
|
||||
def save(self, path: Path) -> None:
|
||||
"""Save manifest to file."""
|
||||
path = Path(path).expanduser()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(self.to_dict(), f, indent=2)
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Path) -> Optional["InstallManifest"]:
|
||||
"""Load manifest from file."""
|
||||
path = Path(path).expanduser()
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return cls.from_dict(data)
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
return None
|
||||
|
||||
|
||||
def compare_versions(v1: str, v2: str) -> int:
|
||||
"""
|
||||
Compare two version strings.
|
||||
|
||||
Args:
|
||||
v1: First version string
|
||||
v2: Second version string
|
||||
|
||||
Returns:
|
||||
-1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2
|
||||
"""
|
||||
ver1 = Version.parse(v1)
|
||||
ver2 = Version.parse(v2)
|
||||
|
||||
if ver1 < ver2:
|
||||
return -1
|
||||
elif ver1 > ver2:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def is_update_available(installed: str, available: str) -> bool:
|
||||
"""
|
||||
Check if an update is available.
|
||||
|
||||
Args:
|
||||
installed: Currently installed version
|
||||
available: Available version
|
||||
|
||||
Returns:
|
||||
True if available > installed
|
||||
"""
|
||||
return compare_versions(installed, available) < 0
|
||||
|
||||
|
||||
def get_ring_version(ring_path: Path) -> Optional[str]:
|
||||
"""
|
||||
Get the Ring version from a Ring installation.
|
||||
|
||||
Looks for version in:
|
||||
1. .claude-plugin/marketplace.json
|
||||
2. VERSION file
|
||||
3. package.json
|
||||
|
||||
Args:
|
||||
ring_path: Path to Ring installation
|
||||
|
||||
Returns:
|
||||
Version string or None if not found
|
||||
"""
|
||||
ring_path = Path(ring_path).expanduser()
|
||||
|
||||
# Check marketplace.json
|
||||
marketplace_path = ring_path / ".claude-plugin" / "marketplace.json"
|
||||
if marketplace_path.exists():
|
||||
try:
|
||||
with open(marketplace_path) as f:
|
||||
data = json.load(f)
|
||||
version = data.get("version")
|
||||
if version:
|
||||
return version
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass
|
||||
|
||||
# Check VERSION file
|
||||
version_file = ring_path / "VERSION"
|
||||
if version_file.exists():
|
||||
try:
|
||||
return version_file.read_text().strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Check package.json (if exists)
|
||||
package_json = ring_path / "package.json"
|
||||
if package_json.exists():
|
||||
try:
|
||||
with open(package_json) as f:
|
||||
data = json.load(f)
|
||||
return data.get("version")
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_installed_version(install_path: Path, platform: str) -> Optional[str]:
|
||||
"""
|
||||
Get the installed Ring version for a platform.
|
||||
|
||||
Args:
|
||||
install_path: Platform installation path
|
||||
platform: Platform identifier
|
||||
|
||||
Returns:
|
||||
Installed version or None
|
||||
"""
|
||||
install_path = Path(install_path).expanduser()
|
||||
manifest_path = install_path / ".ring-manifest.json"
|
||||
|
||||
manifest = InstallManifest.load(manifest_path)
|
||||
if manifest:
|
||||
return manifest.version
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_manifest_path(install_path: Path) -> Path:
|
||||
"""Get the path to the install manifest."""
|
||||
return Path(install_path).expanduser() / ".ring-manifest.json"
|
||||
|
||||
|
||||
@dataclass
|
||||
class UpdateInfo:
|
||||
"""Information about available updates."""
|
||||
installed_version: Optional[str]
|
||||
available_version: Optional[str]
|
||||
update_available: bool
|
||||
is_newer: bool
|
||||
is_downgrade: bool
|
||||
changed_files: List[str]
|
||||
new_files: List[str]
|
||||
removed_files: List[str]
|
||||
|
||||
@property
|
||||
def has_changes(self) -> bool:
|
||||
"""Check if there are any changes."""
|
||||
return bool(self.changed_files or self.new_files or self.removed_files)
|
||||
|
||||
|
||||
def check_for_updates(
|
||||
source_path: Path,
|
||||
install_path: Path,
|
||||
platform: str
|
||||
) -> UpdateInfo:
|
||||
"""
|
||||
Check for available updates.
|
||||
|
||||
Args:
|
||||
source_path: Path to Ring source
|
||||
install_path: Platform installation path
|
||||
platform: Platform identifier
|
||||
|
||||
Returns:
|
||||
UpdateInfo with details about available updates
|
||||
"""
|
||||
source_path = Path(source_path).expanduser()
|
||||
install_path = Path(install_path).expanduser()
|
||||
|
||||
# Get versions
|
||||
available = get_ring_version(source_path)
|
||||
installed = get_installed_version(install_path, platform)
|
||||
|
||||
# Determine update status
|
||||
update_available = False
|
||||
is_newer = False
|
||||
is_downgrade = False
|
||||
|
||||
if available and installed:
|
||||
cmp = compare_versions(installed, available)
|
||||
update_available = cmp < 0
|
||||
is_newer = cmp < 0
|
||||
is_downgrade = cmp > 0
|
||||
elif available and not installed:
|
||||
update_available = True
|
||||
is_newer = True
|
||||
|
||||
# Check for file changes
|
||||
manifest = InstallManifest.load(get_manifest_path(install_path))
|
||||
|
||||
changed_files: List[str] = []
|
||||
new_files: List[str] = []
|
||||
removed_files: List[str] = []
|
||||
|
||||
if manifest:
|
||||
# Compare installed files with source
|
||||
from ring_installer.utils.fs import get_file_hash
|
||||
|
||||
for file_path, file_hash in manifest.files.items():
|
||||
full_path = install_path / file_path
|
||||
if full_path.exists():
|
||||
current_hash = get_file_hash(full_path)
|
||||
if current_hash != file_hash:
|
||||
changed_files.append(file_path)
|
||||
else:
|
||||
removed_files.append(file_path)
|
||||
|
||||
return UpdateInfo(
|
||||
installed_version=installed,
|
||||
available_version=available,
|
||||
update_available=update_available,
|
||||
is_newer=is_newer,
|
||||
is_downgrade=is_downgrade,
|
||||
changed_files=changed_files,
|
||||
new_files=new_files,
|
||||
removed_files=removed_files
|
||||
)
|
||||
|
||||
|
||||
def save_install_manifest(
|
||||
install_path: Path,
|
||||
source_path: Path,
|
||||
platform: str,
|
||||
version: str,
|
||||
plugins: List[str],
|
||||
installed_files: Dict[str, str]
|
||||
) -> InstallManifest:
|
||||
"""
|
||||
Save an installation manifest.
|
||||
|
||||
Args:
|
||||
install_path: Platform installation path
|
||||
source_path: Ring source path
|
||||
platform: Platform identifier
|
||||
version: Installed version
|
||||
plugins: List of installed plugins
|
||||
installed_files: Dict of file paths to hashes
|
||||
|
||||
Returns:
|
||||
The created manifest
|
||||
"""
|
||||
manifest = InstallManifest.create(
|
||||
version=version,
|
||||
source_path=str(source_path),
|
||||
platform=platform,
|
||||
plugins=plugins,
|
||||
files=installed_files
|
||||
)
|
||||
|
||||
manifest_path = get_manifest_path(install_path)
|
||||
manifest.save(manifest_path)
|
||||
|
||||
return manifest
|
||||
BIN
installer/tests/__pycache__/conftest.cpython-314.pyc
Normal file
BIN
installer/tests/__pycache__/conftest.cpython-314.pyc
Normal file
Binary file not shown.
BIN
installer/tests/__pycache__/test_adapters.cpython-314.pyc
Normal file
BIN
installer/tests/__pycache__/test_adapters.cpython-314.pyc
Normal file
Binary file not shown.
BIN
installer/tests/__pycache__/test_core.cpython-314.pyc
Normal file
BIN
installer/tests/__pycache__/test_core.cpython-314.pyc
Normal file
Binary file not shown.
BIN
installer/tests/__pycache__/test_transformers.cpython-314.pyc
Normal file
BIN
installer/tests/__pycache__/test_transformers.cpython-314.pyc
Normal file
Binary file not shown.
BIN
installer/tests/__pycache__/test_utils.cpython-314.pyc
Normal file
BIN
installer/tests/__pycache__/test_utils.cpython-314.pyc
Normal file
Binary file not shown.
630
installer/tests/conftest.py
Normal file
630
installer/tests/conftest.py
Normal file
|
|
@ -0,0 +1,630 @@
|
|||
"""
|
||||
Pytest fixtures for Ring installer tests.
|
||||
|
||||
Provides shared fixtures for testing adapters, transformers, utilities, and core functions.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Generator
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Path Fixtures
|
||||
# ==============================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def fixtures_path() -> Path:
|
||||
"""
|
||||
Return the path to the test fixtures directory.
|
||||
|
||||
This fixture assumes the standard pytest test layout where fixtures
|
||||
are stored in a 'fixtures' subdirectory alongside the test files.
|
||||
|
||||
Expected structure:
|
||||
installer/tests/
|
||||
├── conftest.py <- This file
|
||||
├── fixtures/ <- Fixtures directory
|
||||
│ ├── skills/
|
||||
│ ├── agents/
|
||||
│ ├── commands/
|
||||
│ └── hooks/
|
||||
└── test_*.py
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If fixtures directory doesn't exist, with
|
||||
instructions for creating it.
|
||||
"""
|
||||
fixtures_dir = Path(__file__).parent / "fixtures"
|
||||
|
||||
if not fixtures_dir.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Test fixtures directory not found: {fixtures_dir}\n"
|
||||
f"Expected location: installer/tests/fixtures/\n"
|
||||
f"Run 'python -m pytest --collect-only' from installer/ to verify paths."
|
||||
)
|
||||
|
||||
return fixtures_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_ring_root(tmp_path: Path, fixtures_path: Path) -> Path:
|
||||
"""
|
||||
Create a temporary Ring source directory with test fixtures.
|
||||
|
||||
This fixture copies the test fixtures to a temporary directory,
|
||||
simulating a Ring installation for testing.
|
||||
|
||||
Returns:
|
||||
Path to temporary Ring root with test components.
|
||||
"""
|
||||
ring_root = tmp_path / "ring"
|
||||
ring_root.mkdir()
|
||||
|
||||
# Create marketplace.json
|
||||
marketplace_dir = ring_root / ".claude-plugin"
|
||||
marketplace_dir.mkdir()
|
||||
|
||||
marketplace_content = {
|
||||
"version": "1.2.3",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "ring-default",
|
||||
"description": "Core Ring plugin",
|
||||
"version": "1.0.0",
|
||||
"source": "./default"
|
||||
},
|
||||
{
|
||||
"name": "ring-test",
|
||||
"description": "Test plugin",
|
||||
"version": "0.1.0",
|
||||
"source": "./test-plugin"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with open(marketplace_dir / "marketplace.json", "w") as f:
|
||||
json.dump(marketplace_content, f, indent=2)
|
||||
|
||||
# Create default plugin structure
|
||||
default_plugin = ring_root / "default"
|
||||
default_plugin.mkdir()
|
||||
|
||||
# Copy fixtures to default plugin
|
||||
for component_type in ["skills", "agents", "commands", "hooks"]:
|
||||
src = fixtures_path / component_type
|
||||
if src.exists():
|
||||
dst = default_plugin / component_type
|
||||
if component_type == "skills":
|
||||
# Skills have subdirectories
|
||||
shutil.copytree(src, dst)
|
||||
else:
|
||||
dst.mkdir(parents=True, exist_ok=True)
|
||||
for file in src.iterdir():
|
||||
if file.is_file():
|
||||
shutil.copy2(file, dst / file.name)
|
||||
|
||||
# Create test-plugin with minimal structure
|
||||
test_plugin = ring_root / "test-plugin"
|
||||
test_plugin.mkdir()
|
||||
|
||||
return ring_root
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_install_dir(tmp_path: Path) -> Path:
|
||||
"""
|
||||
Create a temporary installation target directory.
|
||||
|
||||
Returns:
|
||||
Path to temporary installation directory.
|
||||
"""
|
||||
install_dir = tmp_path / "install"
|
||||
install_dir.mkdir()
|
||||
return install_dir
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Content Fixtures
|
||||
# ==============================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def sample_skill_content(fixtures_path: Path) -> str:
|
||||
"""
|
||||
Load sample skill content from fixtures.
|
||||
|
||||
Returns:
|
||||
Content of sample skill markdown file.
|
||||
"""
|
||||
skill_path = fixtures_path / "skills" / "sample-skill" / "SKILL.md"
|
||||
return skill_path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_agent_content(fixtures_path: Path) -> str:
|
||||
"""
|
||||
Load sample agent content from fixtures.
|
||||
|
||||
Returns:
|
||||
Content of sample agent markdown file.
|
||||
"""
|
||||
agent_path = fixtures_path / "agents" / "sample-agent.md"
|
||||
return agent_path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_command_content(fixtures_path: Path) -> str:
|
||||
"""
|
||||
Load sample command content from fixtures.
|
||||
|
||||
Returns:
|
||||
Content of sample command markdown file.
|
||||
"""
|
||||
command_path = fixtures_path / "commands" / "sample-command.md"
|
||||
return command_path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_hooks_content(fixtures_path: Path) -> str:
|
||||
"""
|
||||
Load sample hooks.json content from fixtures.
|
||||
|
||||
Returns:
|
||||
Content of sample hooks.json file.
|
||||
"""
|
||||
hooks_path = fixtures_path / "hooks" / "hooks.json"
|
||||
return hooks_path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_hooks_dict(fixtures_path: Path) -> Dict[str, Any]:
|
||||
"""
|
||||
Load sample hooks.json as a dictionary.
|
||||
|
||||
Returns:
|
||||
Parsed hooks.json content.
|
||||
"""
|
||||
hooks_path = fixtures_path / "hooks" / "hooks.json"
|
||||
with open(hooks_path) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Minimal Content Fixtures (for unit tests)
|
||||
# ==============================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def minimal_skill_content() -> str:
|
||||
"""
|
||||
Return minimal valid skill content for unit tests.
|
||||
|
||||
Returns:
|
||||
Minimal skill markdown with frontmatter.
|
||||
"""
|
||||
return """---
|
||||
name: minimal-skill
|
||||
description: A minimal skill for testing.
|
||||
---
|
||||
|
||||
# Minimal Skill
|
||||
|
||||
This is a minimal skill.
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def minimal_agent_content() -> str:
|
||||
"""
|
||||
Return minimal valid agent content for unit tests.
|
||||
|
||||
Returns:
|
||||
Minimal agent markdown with frontmatter.
|
||||
"""
|
||||
return """---
|
||||
name: minimal-agent
|
||||
description: A minimal agent for testing.
|
||||
model: claude-sonnet-4-20250514
|
||||
---
|
||||
|
||||
# Minimal Agent
|
||||
|
||||
This is a minimal agent.
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def minimal_command_content() -> str:
|
||||
"""
|
||||
Return minimal valid command content for unit tests.
|
||||
|
||||
Returns:
|
||||
Minimal command markdown with frontmatter.
|
||||
"""
|
||||
return """---
|
||||
name: minimal-command
|
||||
description: A minimal command for testing.
|
||||
args:
|
||||
- name: target
|
||||
required: true
|
||||
---
|
||||
|
||||
# Minimal Command
|
||||
|
||||
This is a minimal command.
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def content_without_frontmatter() -> str:
|
||||
"""
|
||||
Return content without YAML frontmatter.
|
||||
|
||||
Returns:
|
||||
Markdown content without frontmatter.
|
||||
"""
|
||||
return """# No Frontmatter Content
|
||||
|
||||
This content has no YAML frontmatter.
|
||||
|
||||
## Section
|
||||
|
||||
Some section content.
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def content_with_invalid_frontmatter() -> str:
|
||||
"""
|
||||
Return content with malformed YAML frontmatter.
|
||||
|
||||
Returns:
|
||||
Markdown with invalid frontmatter.
|
||||
"""
|
||||
return """---
|
||||
name: invalid
|
||||
description: [unclosed bracket
|
||||
---
|
||||
|
||||
# Invalid Frontmatter
|
||||
|
||||
The frontmatter YAML is malformed.
|
||||
"""
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Adapter Fixtures
|
||||
# ==============================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def mock_platform_adapter():
|
||||
"""
|
||||
Create a mock platform adapter for testing.
|
||||
|
||||
Returns:
|
||||
MagicMock configured as a PlatformAdapter.
|
||||
"""
|
||||
adapter = MagicMock()
|
||||
adapter.platform_id = "mock"
|
||||
adapter.platform_name = "Mock Platform"
|
||||
adapter.is_native_format.return_value = False
|
||||
|
||||
adapter.get_install_path.return_value = Path.home() / ".mock"
|
||||
adapter.get_component_mapping.return_value = {
|
||||
"agents": {"target_dir": "agents", "extension": ".md"},
|
||||
"commands": {"target_dir": "commands", "extension": ".md"},
|
||||
"skills": {"target_dir": "skills", "extension": ".md"},
|
||||
"hooks": {"target_dir": "hooks", "extension": ""},
|
||||
}
|
||||
adapter.get_terminology.return_value = {
|
||||
"agent": "mock-agent",
|
||||
"skill": "mock-skill",
|
||||
"command": "mock-command",
|
||||
"hook": "mock-hook",
|
||||
}
|
||||
|
||||
# Passthrough transforms by default
|
||||
adapter.transform_skill.side_effect = lambda c, m=None: c
|
||||
adapter.transform_agent.side_effect = lambda c, m=None: c
|
||||
adapter.transform_command.side_effect = lambda c, m=None: c
|
||||
adapter.transform_hook.side_effect = lambda c, m=None: c
|
||||
|
||||
adapter.get_target_filename.side_effect = lambda f, t: f
|
||||
|
||||
return adapter
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def claude_adapter_config() -> Dict[str, Any]:
|
||||
"""
|
||||
Return configuration for Claude adapter.
|
||||
|
||||
Returns:
|
||||
Claude adapter configuration dictionary.
|
||||
"""
|
||||
return {
|
||||
"install_path": "~/.claude",
|
||||
"native": True
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def factory_adapter_config() -> Dict[str, Any]:
|
||||
"""
|
||||
Return configuration for Factory adapter.
|
||||
|
||||
Returns:
|
||||
Factory adapter configuration dictionary.
|
||||
"""
|
||||
return {
|
||||
"install_path": "~/.factory",
|
||||
"native": False
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cursor_adapter_config() -> Dict[str, Any]:
|
||||
"""
|
||||
Return configuration for Cursor adapter.
|
||||
|
||||
Returns:
|
||||
Cursor adapter configuration dictionary.
|
||||
"""
|
||||
return {
|
||||
"install_path": "~/.cursor",
|
||||
"native": False
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cline_adapter_config() -> Dict[str, Any]:
|
||||
"""
|
||||
Return configuration for Cline adapter.
|
||||
|
||||
Returns:
|
||||
Cline adapter configuration dictionary.
|
||||
"""
|
||||
return {
|
||||
"install_path": "~/.cline",
|
||||
"native": False
|
||||
}
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Transformer Fixtures
|
||||
# ==============================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def transform_context():
|
||||
"""
|
||||
Create a factory function for TransformContext.
|
||||
|
||||
Returns:
|
||||
Function that creates TransformContext instances.
|
||||
"""
|
||||
from ring_installer.transformers.base import TransformContext
|
||||
|
||||
def _create_context(
|
||||
platform: str = "claude",
|
||||
component_type: str = "skill",
|
||||
source_path: str = "",
|
||||
metadata: Dict[str, Any] = None,
|
||||
options: Dict[str, Any] = None
|
||||
) -> TransformContext:
|
||||
return TransformContext(
|
||||
platform=platform,
|
||||
component_type=component_type,
|
||||
source_path=source_path,
|
||||
metadata=metadata or {},
|
||||
options=options or {}
|
||||
)
|
||||
|
||||
return _create_context
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Platform Detection Fixtures
|
||||
# ==============================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def mock_platform_detection():
|
||||
"""
|
||||
Mock platform detection functions.
|
||||
|
||||
Yields:
|
||||
Dictionary of mocked detection functions.
|
||||
"""
|
||||
with patch("ring_installer.utils.platform_detect._detect_claude") as mock_claude, \
|
||||
patch("ring_installer.utils.platform_detect._detect_factory") as mock_factory, \
|
||||
patch("ring_installer.utils.platform_detect._detect_cursor") as mock_cursor, \
|
||||
patch("ring_installer.utils.platform_detect._detect_cline") as mock_cline:
|
||||
|
||||
from ring_installer.utils.platform_detect import PlatformInfo
|
||||
|
||||
# Default: no platforms installed
|
||||
mock_claude.return_value = PlatformInfo(
|
||||
platform_id="claude",
|
||||
name="Claude Code",
|
||||
installed=False
|
||||
)
|
||||
mock_factory.return_value = PlatformInfo(
|
||||
platform_id="factory",
|
||||
name="Factory AI",
|
||||
installed=False
|
||||
)
|
||||
mock_cursor.return_value = PlatformInfo(
|
||||
platform_id="cursor",
|
||||
name="Cursor",
|
||||
installed=False
|
||||
)
|
||||
mock_cline.return_value = PlatformInfo(
|
||||
platform_id="cline",
|
||||
name="Cline",
|
||||
installed=False
|
||||
)
|
||||
|
||||
yield {
|
||||
"claude": mock_claude,
|
||||
"factory": mock_factory,
|
||||
"cursor": mock_cursor,
|
||||
"cline": mock_cline
|
||||
}
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Install Manifest Fixtures
|
||||
# ==============================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def sample_install_manifest() -> Dict[str, Any]:
|
||||
"""
|
||||
Return a sample installation manifest.
|
||||
|
||||
Returns:
|
||||
Dictionary representing an install manifest.
|
||||
"""
|
||||
return {
|
||||
"version": "1.0.0",
|
||||
"installed_at": "2024-01-15T10:30:00",
|
||||
"source_path": "/path/to/ring",
|
||||
"platform": "claude",
|
||||
"plugins": ["default", "test-plugin"],
|
||||
"files": {
|
||||
"agents/sample-agent.md": "abc123hash",
|
||||
"commands/sample-command.md": "def456hash",
|
||||
"skills/sample-skill/SKILL.md": "ghi789hash"
|
||||
},
|
||||
"metadata": {
|
||||
"installer_version": "0.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_manifest_file(tmp_path: Path):
|
||||
"""
|
||||
Factory fixture to create manifest files.
|
||||
|
||||
Returns:
|
||||
Function to create manifest files in tmp_path.
|
||||
"""
|
||||
def _create(manifest_data: Dict[str, Any], filename: str = ".ring-manifest.json") -> Path:
|
||||
manifest_path = tmp_path / filename
|
||||
with open(manifest_path, "w") as f:
|
||||
json.dump(manifest_data, f, indent=2)
|
||||
return manifest_path
|
||||
|
||||
return _create
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Version Fixtures
|
||||
# ==============================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def version_test_cases() -> list:
|
||||
"""
|
||||
Return version comparison test cases.
|
||||
|
||||
Returns:
|
||||
List of (v1, v2, expected_result) tuples.
|
||||
expected_result: -1 if v1 < v2, 0 if equal, 1 if v1 > v2
|
||||
"""
|
||||
return [
|
||||
# Basic comparisons
|
||||
("1.0.0", "1.0.0", 0),
|
||||
("1.0.0", "1.0.1", -1),
|
||||
("1.0.1", "1.0.0", 1),
|
||||
("1.0.0", "1.1.0", -1),
|
||||
("1.1.0", "1.0.0", 1),
|
||||
("1.0.0", "2.0.0", -1),
|
||||
("2.0.0", "1.0.0", 1),
|
||||
|
||||
# Prerelease versions
|
||||
("1.0.0-alpha", "1.0.0", -1),
|
||||
("1.0.0", "1.0.0-alpha", 1),
|
||||
("1.0.0-alpha", "1.0.0-beta", -1),
|
||||
("1.0.0-beta", "1.0.0-alpha", 1),
|
||||
("1.0.0-alpha.1", "1.0.0-alpha.2", -1),
|
||||
|
||||
# With v prefix
|
||||
("v1.0.0", "1.0.0", 0),
|
||||
("v1.0.0", "v1.0.1", -1),
|
||||
]
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Cleanup Fixtures
|
||||
# ==============================================================================
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def cleanup_temp_files(tmp_path: Path):
|
||||
"""
|
||||
Automatically clean up temporary files after each test.
|
||||
|
||||
This fixture runs after each test and ensures temp files are removed.
|
||||
"""
|
||||
yield
|
||||
# Cleanup happens automatically with tmp_path
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Helper Functions (not fixtures, but available in tests)
|
||||
# ==============================================================================
|
||||
|
||||
def assert_frontmatter_contains(content: str, expected_keys: list) -> None:
|
||||
"""
|
||||
Assert that content has frontmatter containing expected keys.
|
||||
|
||||
Args:
|
||||
content: Markdown content with frontmatter
|
||||
expected_keys: List of keys that should be present
|
||||
"""
|
||||
import yaml
|
||||
|
||||
assert content.startswith("---"), "Content should start with frontmatter"
|
||||
end = content.find("---", 3)
|
||||
assert end != -1, "Frontmatter should have closing delimiter"
|
||||
|
||||
yaml_content = content[3:end].strip()
|
||||
frontmatter = yaml.safe_load(yaml_content)
|
||||
|
||||
for key in expected_keys:
|
||||
assert key in frontmatter, f"Frontmatter should contain '{key}'"
|
||||
|
||||
|
||||
def assert_no_frontmatter(content: str) -> None:
|
||||
"""
|
||||
Assert that content does not have YAML frontmatter.
|
||||
|
||||
Args:
|
||||
content: Content to check
|
||||
"""
|
||||
assert not content.startswith("---"), "Content should not have frontmatter"
|
||||
|
||||
|
||||
def assert_contains_terminology(content: str, terms: list) -> None:
|
||||
"""
|
||||
Assert that content contains expected terminology.
|
||||
|
||||
Args:
|
||||
content: Content to check
|
||||
terms: List of terms that should be present
|
||||
"""
|
||||
for term in terms:
|
||||
assert term in content, f"Content should contain '{term}'"
|
||||
|
||||
|
||||
def assert_not_contains_terminology(content: str, terms: list) -> None:
|
||||
"""
|
||||
Assert that content does not contain specified terminology.
|
||||
|
||||
Args:
|
||||
content: Content to check
|
||||
terms: List of terms that should not be present
|
||||
"""
|
||||
for term in terms:
|
||||
assert term not in content, f"Content should not contain '{term}'"
|
||||
70
installer/tests/fixtures/agents/sample-agent.md
vendored
Normal file
70
installer/tests/fixtures/agents/sample-agent.md
vendored
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
---
|
||||
name: sample-agent
|
||||
description: |
|
||||
A sample agent for testing platform transformations.
|
||||
Demonstrates full agent definition structure.
|
||||
|
||||
model: claude-sonnet-4-20250514
|
||||
|
||||
output_schema:
|
||||
format: markdown
|
||||
required_sections:
|
||||
- name: Summary
|
||||
pattern: "^## Summary"
|
||||
required: true
|
||||
- name: Implementation
|
||||
pattern: "^## Implementation"
|
||||
required: true
|
||||
- name: Files Changed
|
||||
pattern: "^## Files Changed"
|
||||
required: true
|
||||
- name: Testing
|
||||
pattern: "^## Testing"
|
||||
required: false
|
||||
- name: Next Steps
|
||||
pattern: "^## Next Steps"
|
||||
required: true
|
||||
---
|
||||
|
||||
# Sample Agent
|
||||
|
||||
You are a sample agent for testing the Ring installer.
|
||||
|
||||
## Role
|
||||
|
||||
This agent demonstrates:
|
||||
- Agent to droid transformation for Factory AI
|
||||
- Agent to workflow transformation for Cursor
|
||||
- Agent to prompt transformation for Cline
|
||||
|
||||
## Capabilities
|
||||
|
||||
1. **Code Analysis**: Analyze code for quality issues
|
||||
2. **Documentation**: Generate documentation
|
||||
3. **Testing**: Create test cases
|
||||
|
||||
## Behavior
|
||||
|
||||
When invoked as a subagent via the Task tool:
|
||||
|
||||
1. Read the provided context
|
||||
2. Analyze the request
|
||||
3. Generate output following the schema
|
||||
|
||||
Use `ring:helper-skill` for additional context.
|
||||
|
||||
## Output Format
|
||||
|
||||
Always structure output as:
|
||||
|
||||
## Summary
|
||||
Brief overview of findings
|
||||
|
||||
## Implementation
|
||||
Detailed implementation steps
|
||||
|
||||
## Files Changed
|
||||
List of modified files
|
||||
|
||||
## Next Steps
|
||||
Recommended follow-up actions
|
||||
59
installer/tests/fixtures/commands/sample-command.md
vendored
Normal file
59
installer/tests/fixtures/commands/sample-command.md
vendored
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
---
|
||||
name: sample-command
|
||||
description: |
|
||||
A sample slash command for testing platform transformations.
|
||||
|
||||
args:
|
||||
- name: target
|
||||
description: The target file or directory to process
|
||||
required: true
|
||||
- name: format
|
||||
description: Output format (json, yaml, markdown)
|
||||
required: false
|
||||
default: markdown
|
||||
- name: verbose
|
||||
description: Enable verbose output
|
||||
required: false
|
||||
default: false
|
||||
---
|
||||
|
||||
# Sample Command
|
||||
|
||||
Execute this command to test the Ring installer.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/ring:sample-command [target] [--format=markdown] [--verbose]
|
||||
```
|
||||
|
||||
## Description
|
||||
|
||||
This command demonstrates:
|
||||
- Command to workflow transformation for Cursor
|
||||
- Command to prompt transformation for Cline
|
||||
- Argument parsing and validation
|
||||
|
||||
## Steps
|
||||
|
||||
1. Parse the provided arguments
|
||||
2. Validate the target path
|
||||
3. Process based on format option
|
||||
4. Output results
|
||||
|
||||
## Examples
|
||||
|
||||
Basic usage:
|
||||
```
|
||||
/ring:sample-command ./src
|
||||
```
|
||||
|
||||
With options:
|
||||
```
|
||||
/ring:sample-command ./src --format=json --verbose
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- See also: `ring:helper-agent`
|
||||
- Related skill: `ring:sample-skill`
|
||||
34
installer/tests/fixtures/hooks/hooks.json
vendored
Normal file
34
installer/tests/fixtures/hooks/hooks.json
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"hooks": [
|
||||
{
|
||||
"event": "SessionStart",
|
||||
"triggers": ["startup", "resume", "clear", "compact"],
|
||||
"type": "command",
|
||||
"command": "bash ${RING_PLUGIN_ROOT}/hooks/session-start.sh"
|
||||
},
|
||||
{
|
||||
"event": "UserPromptSubmit",
|
||||
"triggers": ["always"],
|
||||
"type": "command",
|
||||
"command": "bash ${RING_PLUGIN_ROOT}/hooks/prompt-submit.sh"
|
||||
},
|
||||
{
|
||||
"event": "PreToolUse",
|
||||
"triggers": ["Bash"],
|
||||
"type": "prompt",
|
||||
"prompt": "Verify the Bash command is safe to execute."
|
||||
},
|
||||
{
|
||||
"event": "PostToolUse",
|
||||
"triggers": ["Write", "Edit"],
|
||||
"type": "command",
|
||||
"command": "bash ${RING_PLUGIN_ROOT}/hooks/post-edit.sh"
|
||||
},
|
||||
{
|
||||
"event": "Stop",
|
||||
"triggers": ["always"],
|
||||
"type": "command",
|
||||
"command": "bash ${RING_PLUGIN_ROOT}/hooks/stop.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
56
installer/tests/fixtures/skills/sample-skill/SKILL.md
vendored
Normal file
56
installer/tests/fixtures/skills/sample-skill/SKILL.md
vendored
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
---
|
||||
name: sample-skill
|
||||
description: |
|
||||
A sample skill for testing the Ring multi-platform installer.
|
||||
This skill demonstrates the full frontmatter structure.
|
||||
|
||||
trigger: |
|
||||
- When testing transformer functionality
|
||||
- When validating platform-specific conversions
|
||||
- When running integration tests
|
||||
|
||||
skip_when: |
|
||||
- Production deployments
|
||||
- When using mock data instead
|
||||
|
||||
sequence:
|
||||
after: [prerequisite-skill]
|
||||
before: [following-skill]
|
||||
|
||||
related:
|
||||
similar: [other-sample-skill]
|
||||
complementary: [helper-skill, utility-skill]
|
||||
---
|
||||
|
||||
# Sample Skill
|
||||
|
||||
This is a sample skill used for testing the Ring installer.
|
||||
|
||||
## Purpose
|
||||
|
||||
This skill demonstrates:
|
||||
- YAML frontmatter parsing
|
||||
- Content body transformation
|
||||
- Platform-specific terminology changes
|
||||
|
||||
## Usage
|
||||
|
||||
Use the `ring:sample-skill` skill when you need to:
|
||||
|
||||
1. Test the installation process
|
||||
2. Validate transformer output
|
||||
3. Check platform compatibility
|
||||
|
||||
## Technical Details
|
||||
|
||||
The skill uses the Task tool to dispatch subagents:
|
||||
|
||||
```python
|
||||
# Example subagent dispatch
|
||||
Task.dispatch("ring:helper-agent", prompt="Process data")
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- Related skill: `ring:related-skill`
|
||||
- Agent reference: "ring:sample-agent"
|
||||
698
installer/tests/test_adapters.py
Normal file
698
installer/tests/test_adapters.py
Normal file
|
|
@ -0,0 +1,698 @@
|
|||
"""
|
||||
Tests for platform adapters.
|
||||
|
||||
Tests ClaudeAdapter, FactoryAdapter, CursorAdapter, ClineAdapter,
|
||||
and the get_adapter() factory function.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from ring_installer.adapters import (
|
||||
PlatformAdapter,
|
||||
ClaudeAdapter,
|
||||
FactoryAdapter,
|
||||
CursorAdapter,
|
||||
ClineAdapter,
|
||||
get_adapter,
|
||||
register_adapter,
|
||||
list_platforms,
|
||||
SUPPORTED_PLATFORMS,
|
||||
ADAPTER_REGISTRY,
|
||||
)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Tests for get_adapter() factory function
|
||||
# ==============================================================================
|
||||
|
||||
class TestGetAdapter:
|
||||
"""Tests for the get_adapter() factory function."""
|
||||
|
||||
def test_get_adapter_returns_claude_adapter(self):
|
||||
"""get_adapter('claude') should return ClaudeAdapter instance."""
|
||||
adapter = get_adapter("claude")
|
||||
assert isinstance(adapter, ClaudeAdapter)
|
||||
assert adapter.platform_id == "claude"
|
||||
|
||||
def test_get_adapter_returns_factory_adapter(self):
|
||||
"""get_adapter('factory') should return FactoryAdapter instance."""
|
||||
adapter = get_adapter("factory")
|
||||
assert isinstance(adapter, FactoryAdapter)
|
||||
assert adapter.platform_id == "factory"
|
||||
|
||||
def test_get_adapter_returns_cursor_adapter(self):
|
||||
"""get_adapter('cursor') should return CursorAdapter instance."""
|
||||
adapter = get_adapter("cursor")
|
||||
assert isinstance(adapter, CursorAdapter)
|
||||
assert adapter.platform_id == "cursor"
|
||||
|
||||
def test_get_adapter_returns_cline_adapter(self):
|
||||
"""get_adapter('cline') should return ClineAdapter instance."""
|
||||
adapter = get_adapter("cline")
|
||||
assert isinstance(adapter, ClineAdapter)
|
||||
assert adapter.platform_id == "cline"
|
||||
|
||||
def test_get_adapter_case_insensitive(self):
|
||||
"""get_adapter() should handle case-insensitive platform names."""
|
||||
assert isinstance(get_adapter("CLAUDE"), ClaudeAdapter)
|
||||
assert isinstance(get_adapter("Claude"), ClaudeAdapter)
|
||||
assert isinstance(get_adapter("FACTORY"), FactoryAdapter)
|
||||
assert isinstance(get_adapter("Cursor"), CursorAdapter)
|
||||
|
||||
def test_get_adapter_with_config(self):
|
||||
"""get_adapter() should accept optional configuration."""
|
||||
config = {"install_path": "/custom/path"}
|
||||
adapter = get_adapter("claude", config)
|
||||
assert adapter.config == config
|
||||
|
||||
def test_get_adapter_unsupported_platform_raises_error(self):
|
||||
"""get_adapter() should raise ValueError for unsupported platforms."""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
get_adapter("unsupported")
|
||||
|
||||
assert "Unsupported platform" in str(exc_info.value)
|
||||
assert "unsupported" in str(exc_info.value)
|
||||
|
||||
def test_supported_platforms_list(self):
|
||||
"""SUPPORTED_PLATFORMS should contain all expected platforms."""
|
||||
expected = {"claude", "factory", "cursor", "cline"}
|
||||
assert set(SUPPORTED_PLATFORMS) == expected
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Tests for register_adapter()
|
||||
# ==============================================================================
|
||||
|
||||
class TestRegisterAdapter:
|
||||
"""Tests for custom adapter registration."""
|
||||
|
||||
def test_register_custom_adapter(self):
|
||||
"""register_adapter() should add a custom adapter to the registry."""
|
||||
class CustomAdapter(PlatformAdapter):
|
||||
platform_id = "custom"
|
||||
platform_name = "Custom Platform"
|
||||
|
||||
def transform_skill(self, content, metadata=None):
|
||||
return content
|
||||
|
||||
def transform_agent(self, content, metadata=None):
|
||||
return content
|
||||
|
||||
def transform_command(self, content, metadata=None):
|
||||
return content
|
||||
|
||||
def get_install_path(self):
|
||||
return Path.home() / ".custom"
|
||||
|
||||
def get_component_mapping(self):
|
||||
return {"skills": {"target_dir": "skills", "extension": ".md"}}
|
||||
|
||||
register_adapter("custom", CustomAdapter)
|
||||
assert "custom" in ADAPTER_REGISTRY
|
||||
|
||||
adapter = get_adapter("custom")
|
||||
assert isinstance(adapter, CustomAdapter)
|
||||
|
||||
# Cleanup
|
||||
del ADAPTER_REGISTRY["custom"]
|
||||
|
||||
def test_register_adapter_requires_platform_adapter_subclass(self):
|
||||
"""register_adapter() should reject non-PlatformAdapter classes."""
|
||||
class NotAnAdapter:
|
||||
pass
|
||||
|
||||
with pytest.raises(TypeError) as exc_info:
|
||||
register_adapter("invalid", NotAnAdapter)
|
||||
|
||||
assert "must inherit from PlatformAdapter" in str(exc_info.value)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Tests for list_platforms()
|
||||
# ==============================================================================
|
||||
|
||||
class TestListPlatforms:
|
||||
"""Tests for the list_platforms() function."""
|
||||
|
||||
def test_list_platforms_returns_all_platforms(self):
|
||||
"""list_platforms() should return info for all supported platforms."""
|
||||
platforms = list_platforms()
|
||||
platform_ids = {p["id"] for p in platforms}
|
||||
|
||||
assert "claude" in platform_ids
|
||||
assert "factory" in platform_ids
|
||||
assert "cursor" in platform_ids
|
||||
assert "cline" in platform_ids
|
||||
|
||||
def test_list_platforms_includes_required_fields(self):
|
||||
"""list_platforms() should include required fields for each platform."""
|
||||
platforms = list_platforms()
|
||||
|
||||
for platform in platforms:
|
||||
assert "id" in platform
|
||||
assert "name" in platform
|
||||
assert "native_format" in platform
|
||||
assert "terminology" in platform
|
||||
assert "components" in platform
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Tests for ClaudeAdapter (passthrough)
|
||||
# ==============================================================================
|
||||
|
||||
class TestClaudeAdapter:
|
||||
"""Tests for ClaudeAdapter passthrough functionality."""
|
||||
|
||||
@pytest.fixture
|
||||
def adapter(self):
|
||||
"""Create a ClaudeAdapter instance."""
|
||||
return ClaudeAdapter()
|
||||
|
||||
def test_platform_id(self, adapter):
|
||||
"""ClaudeAdapter should have correct platform_id."""
|
||||
assert adapter.platform_id == "claude"
|
||||
assert adapter.platform_name == "Claude Code"
|
||||
|
||||
def test_is_native_format(self, adapter):
|
||||
"""ClaudeAdapter should report native format."""
|
||||
assert adapter.is_native_format() is True
|
||||
|
||||
def test_transform_skill_passthrough(self, adapter, sample_skill_content):
|
||||
"""transform_skill() should return content unchanged."""
|
||||
result = adapter.transform_skill(sample_skill_content)
|
||||
assert result == sample_skill_content
|
||||
|
||||
def test_transform_agent_passthrough(self, adapter, sample_agent_content):
|
||||
"""transform_agent() should return content unchanged."""
|
||||
result = adapter.transform_agent(sample_agent_content)
|
||||
assert result == sample_agent_content
|
||||
|
||||
def test_transform_command_passthrough(self, adapter, sample_command_content):
|
||||
"""transform_command() should return content unchanged."""
|
||||
result = adapter.transform_command(sample_command_content)
|
||||
assert result == sample_command_content
|
||||
|
||||
def test_transform_hook_passthrough(self, adapter, sample_hooks_content):
|
||||
"""transform_hook() should return content unchanged."""
|
||||
result = adapter.transform_hook(sample_hooks_content)
|
||||
assert result == sample_hooks_content
|
||||
|
||||
def test_get_install_path_default(self, adapter):
|
||||
"""get_install_path() should return ~/.claude by default."""
|
||||
path = adapter.get_install_path()
|
||||
assert path == Path.home() / ".claude"
|
||||
|
||||
def test_get_install_path_custom(self):
|
||||
"""get_install_path() should respect custom config."""
|
||||
adapter = ClaudeAdapter({"install_path": "/custom/path"})
|
||||
path = adapter.get_install_path()
|
||||
assert path == Path("/custom/path")
|
||||
|
||||
def test_get_component_mapping(self, adapter):
|
||||
"""get_component_mapping() should return Claude-specific mapping."""
|
||||
mapping = adapter.get_component_mapping()
|
||||
|
||||
assert "agents" in mapping
|
||||
assert "commands" in mapping
|
||||
assert "skills" in mapping
|
||||
assert "hooks" in mapping
|
||||
|
||||
assert mapping["agents"]["target_dir"] == "agents"
|
||||
assert mapping["agents"]["extension"] == ".md"
|
||||
|
||||
def test_get_terminology(self, adapter):
|
||||
"""get_terminology() should return identity mapping."""
|
||||
terminology = adapter.get_terminology()
|
||||
|
||||
assert terminology["agent"] == "agent"
|
||||
assert terminology["skill"] == "skill"
|
||||
assert terminology["command"] == "command"
|
||||
|
||||
def test_get_target_filename(self, adapter):
|
||||
"""get_target_filename() should preserve original filename."""
|
||||
result = adapter.get_target_filename("test-agent.md", "agent")
|
||||
assert result == "test-agent.md"
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Tests for FactoryAdapter (agent -> droid)
|
||||
# ==============================================================================
|
||||
|
||||
class TestFactoryAdapter:
|
||||
"""Tests for FactoryAdapter terminology transformation."""
|
||||
|
||||
@pytest.fixture
|
||||
def adapter(self):
|
||||
"""Create a FactoryAdapter instance."""
|
||||
return FactoryAdapter()
|
||||
|
||||
def test_platform_id(self, adapter):
|
||||
"""FactoryAdapter should have correct platform_id."""
|
||||
assert adapter.platform_id == "factory"
|
||||
assert adapter.platform_name == "Factory AI"
|
||||
|
||||
def test_is_not_native_format(self, adapter):
|
||||
"""FactoryAdapter should not report native format."""
|
||||
assert adapter.is_native_format() is False
|
||||
|
||||
def test_get_terminology(self, adapter):
|
||||
"""get_terminology() should return Factory-specific mapping."""
|
||||
terminology = adapter.get_terminology()
|
||||
|
||||
assert terminology["agent"] == "droid"
|
||||
assert terminology["skill"] == "skill"
|
||||
assert terminology["hook"] == "trigger"
|
||||
|
||||
def test_transform_skill_replaces_agent_references(self, adapter, sample_skill_content):
|
||||
"""transform_skill() should replace 'agent' with 'droid' in content."""
|
||||
result = adapter.transform_skill(sample_skill_content)
|
||||
|
||||
# Agent references in body should be replaced
|
||||
assert "droid" in result.lower() or "agent" not in result.lower()
|
||||
|
||||
def test_transform_agent_to_droid(self, adapter, sample_agent_content):
|
||||
"""transform_agent() should convert agent content to droid format."""
|
||||
result = adapter.transform_agent(sample_agent_content)
|
||||
|
||||
# Check terminology changes
|
||||
# The word "agent" in the content should be replaced with "droid"
|
||||
# (except in ring: references which use a different pattern)
|
||||
assert "Droid" in result or "droid" in result
|
||||
|
||||
def test_transform_agent_frontmatter(self, adapter, minimal_agent_content):
|
||||
"""transform_agent() should update frontmatter terminology."""
|
||||
content = """---
|
||||
name: test-agent
|
||||
subagent_type: helper
|
||||
---
|
||||
|
||||
# Test Agent
|
||||
"""
|
||||
result = adapter.transform_agent(content)
|
||||
|
||||
# subdroid_type should appear instead of subagent_type
|
||||
assert "subdroid_type" in result or "droid" in result.lower()
|
||||
|
||||
def test_get_component_mapping_droids(self, adapter):
|
||||
"""get_component_mapping() should map agents to droids directory."""
|
||||
mapping = adapter.get_component_mapping()
|
||||
|
||||
assert mapping["agents"]["target_dir"] == "droids"
|
||||
assert mapping["skills"]["target_dir"] == "skills"
|
||||
assert mapping["commands"]["target_dir"] == "commands"
|
||||
|
||||
def test_get_target_filename_renames_agent(self, adapter):
|
||||
"""get_target_filename() should rename *-agent.md to *-droid.md."""
|
||||
result = adapter.get_target_filename("code-agent.md", "agent")
|
||||
assert result == "code-droid.md"
|
||||
|
||||
result = adapter.get_target_filename("test_agent.md", "agent")
|
||||
assert result == "test_droid.md"
|
||||
|
||||
def test_get_target_filename_non_agent(self, adapter):
|
||||
"""get_target_filename() should not rename non-agent files."""
|
||||
result = adapter.get_target_filename("test-skill.md", "skill")
|
||||
assert result == "test-skill.md"
|
||||
|
||||
def test_replace_ring_references(self, adapter):
|
||||
"""FactoryAdapter should replace ring:*-agent references."""
|
||||
content = 'Use "ring:code-agent" for analysis.'
|
||||
result = adapter.transform_skill(content)
|
||||
|
||||
assert "ring:code-droid" in result or "droid" in result.lower()
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Tests for CursorAdapter (rules and workflows)
|
||||
# ==============================================================================
|
||||
|
||||
class TestCursorAdapter:
|
||||
"""Tests for CursorAdapter rule/workflow generation."""
|
||||
|
||||
@pytest.fixture
|
||||
def adapter(self):
|
||||
"""Create a CursorAdapter instance."""
|
||||
return CursorAdapter()
|
||||
|
||||
def test_platform_id(self, adapter):
|
||||
"""CursorAdapter should have correct platform_id."""
|
||||
assert adapter.platform_id == "cursor"
|
||||
assert adapter.platform_name == "Cursor"
|
||||
|
||||
def test_is_not_native_format(self, adapter):
|
||||
"""CursorAdapter should not report native format."""
|
||||
assert adapter.is_native_format() is False
|
||||
|
||||
def test_get_terminology(self, adapter):
|
||||
"""get_terminology() should return Cursor-specific mapping."""
|
||||
terminology = adapter.get_terminology()
|
||||
|
||||
assert terminology["agent"] == "workflow"
|
||||
assert terminology["skill"] == "rule"
|
||||
assert terminology["command"] == "workflow"
|
||||
|
||||
def test_transform_skill_to_rule(self, adapter, sample_skill_content):
|
||||
"""transform_skill() should convert skill to Cursor rule format."""
|
||||
result = adapter.transform_skill(sample_skill_content)
|
||||
|
||||
# Rule format should have title (from name)
|
||||
assert "# Sample Skill" in result or "# Sample-Skill" in result or "# sample" in result.lower()
|
||||
|
||||
# Should have "When to Apply" section from trigger
|
||||
assert "When to Apply" in result or "Instructions" in result
|
||||
|
||||
# Should not have YAML frontmatter
|
||||
assert not result.startswith("---")
|
||||
|
||||
def test_transform_skill_with_frontmatter_extraction(self, adapter, minimal_skill_content):
|
||||
"""transform_skill() should extract and use frontmatter data."""
|
||||
result = adapter.transform_skill(minimal_skill_content)
|
||||
|
||||
# Title should come from name field
|
||||
assert "Minimal Skill" in result or "minimal" in result.lower()
|
||||
|
||||
def test_transform_agent_to_workflow(self, adapter, sample_agent_content):
|
||||
"""transform_agent() should convert agent to workflow format."""
|
||||
result = adapter.transform_agent(sample_agent_content)
|
||||
|
||||
# Should have workflow header
|
||||
assert "Workflow" in result
|
||||
|
||||
# Should have workflow steps section
|
||||
assert "Workflow Steps" in result or "Steps" in result
|
||||
|
||||
# Should not have YAML frontmatter
|
||||
assert not result.startswith("---")
|
||||
|
||||
def test_transform_command_to_workflow(self, adapter, sample_command_content):
|
||||
"""transform_command() should convert command to workflow format."""
|
||||
result = adapter.transform_command(sample_command_content)
|
||||
|
||||
# Should have Parameters section (from args)
|
||||
assert "Parameters" in result
|
||||
|
||||
# Should have Instructions section
|
||||
assert "Instructions" in result
|
||||
|
||||
# Should not have YAML frontmatter
|
||||
assert not result.startswith("---")
|
||||
|
||||
def test_get_component_mapping(self, adapter):
|
||||
"""get_component_mapping() should map to Cursor directories."""
|
||||
mapping = adapter.get_component_mapping()
|
||||
|
||||
assert mapping["agents"]["target_dir"] == "workflows"
|
||||
assert mapping["commands"]["target_dir"] == "workflows"
|
||||
assert mapping["skills"]["target_dir"] == "rules"
|
||||
|
||||
def test_transform_replaces_ring_terminology(self, adapter):
|
||||
"""CursorAdapter should replace Ring-specific terminology."""
|
||||
content = "Use the Task tool to dispatch subagent."
|
||||
result = adapter.transform_skill(content)
|
||||
|
||||
# Ring terminology should be replaced
|
||||
assert "workflow step" in result.lower() or "sub-workflow" in result.lower()
|
||||
|
||||
def test_get_cursorrules_path_default(self, adapter):
|
||||
"""get_cursorrules_path() should return default path."""
|
||||
path = adapter.get_cursorrules_path()
|
||||
assert path == Path.home() / ".cursor" / ".cursorrules"
|
||||
|
||||
def test_get_cursorrules_path_with_project(self, adapter, tmp_path):
|
||||
"""get_cursorrules_path() should return project-specific path."""
|
||||
path = adapter.get_cursorrules_path(tmp_path)
|
||||
assert path == tmp_path / ".cursorrules"
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Tests for ClineAdapter (prompts)
|
||||
# ==============================================================================
|
||||
|
||||
class TestClineAdapter:
|
||||
"""Tests for ClineAdapter prompt generation."""
|
||||
|
||||
@pytest.fixture
|
||||
def adapter(self):
|
||||
"""Create a ClineAdapter instance."""
|
||||
return ClineAdapter()
|
||||
|
||||
def test_platform_id(self, adapter):
|
||||
"""ClineAdapter should have correct platform_id."""
|
||||
assert adapter.platform_id == "cline"
|
||||
assert adapter.platform_name == "Cline"
|
||||
|
||||
def test_is_not_native_format(self, adapter):
|
||||
"""ClineAdapter should not report native format."""
|
||||
assert adapter.is_native_format() is False
|
||||
|
||||
def test_get_terminology(self, adapter):
|
||||
"""get_terminology() should return Cline-specific mapping."""
|
||||
terminology = adapter.get_terminology()
|
||||
|
||||
assert terminology["agent"] == "prompt"
|
||||
assert terminology["skill"] == "prompt"
|
||||
assert terminology["command"] == "prompt"
|
||||
|
||||
def test_transform_skill_to_prompt(self, adapter, sample_skill_content):
|
||||
"""transform_skill() should convert skill to Cline prompt format."""
|
||||
result = adapter.transform_skill(sample_skill_content)
|
||||
|
||||
# Should have HTML comment metadata
|
||||
assert "<!-- Prompt:" in result
|
||||
assert "<!-- Type: skill -->" in result
|
||||
|
||||
# Should have title
|
||||
assert "#" in result
|
||||
|
||||
# Should have Instructions section
|
||||
assert "Instructions" in result
|
||||
|
||||
# Should not have YAML frontmatter
|
||||
assert not result.startswith("---")
|
||||
|
||||
def test_transform_skill_with_metadata(self, adapter, minimal_skill_content):
|
||||
"""transform_skill() should include metadata in comments."""
|
||||
metadata = {"source_path": "/path/to/skill.md"}
|
||||
result = adapter.transform_skill(minimal_skill_content, metadata)
|
||||
|
||||
# Should include source path comment
|
||||
assert "<!-- Source:" in result
|
||||
|
||||
def test_transform_agent_to_prompt(self, adapter, sample_agent_content):
|
||||
"""transform_agent() should convert agent to prompt format."""
|
||||
result = adapter.transform_agent(sample_agent_content)
|
||||
|
||||
# Should have prompt metadata
|
||||
assert "<!-- Prompt:" in result
|
||||
assert "<!-- Type: agent -->" in result
|
||||
|
||||
# Should have Role section
|
||||
assert "Role" in result or "Behavior" in result
|
||||
|
||||
# Should have model recommendation
|
||||
assert "Recommended Model" in result or "claude" in result.lower()
|
||||
|
||||
def test_transform_command_to_prompt(self, adapter, sample_command_content):
|
||||
"""transform_command() should convert command to prompt format."""
|
||||
result = adapter.transform_command(sample_command_content)
|
||||
|
||||
# Should have prompt metadata
|
||||
assert "<!-- Prompt:" in result
|
||||
assert "<!-- Type: command -->" in result
|
||||
|
||||
# Should have Parameters section
|
||||
assert "Parameters" in result
|
||||
|
||||
# Should have Steps section
|
||||
assert "Steps" in result
|
||||
|
||||
def test_get_component_mapping(self, adapter):
|
||||
"""get_component_mapping() should map to Cline prompt directories."""
|
||||
mapping = adapter.get_component_mapping()
|
||||
|
||||
assert mapping["agents"]["target_dir"] == "prompts/agents"
|
||||
assert mapping["commands"]["target_dir"] == "prompts/commands"
|
||||
assert mapping["skills"]["target_dir"] == "prompts/skills"
|
||||
|
||||
def test_transform_replaces_ring_references(self, adapter):
|
||||
"""ClineAdapter should convert ring: references to @ format."""
|
||||
content = "Use `ring:helper-skill` for context."
|
||||
result = adapter.transform_skill(content)
|
||||
|
||||
# ring: references should become @ references
|
||||
assert "@helper-skill" in result or "@" in result
|
||||
|
||||
def test_transform_replaces_ring_terminology(self, adapter):
|
||||
"""ClineAdapter should replace Ring-specific terminology."""
|
||||
content = "Use the Task tool to dispatch subagent."
|
||||
result = adapter.transform_skill(content)
|
||||
|
||||
# Ring terminology should be replaced
|
||||
assert "sub-prompt" in result.lower() or "prompt" in result.lower()
|
||||
|
||||
def test_generate_prompt_index(self, adapter):
|
||||
"""generate_prompt_index() should create an index of prompts."""
|
||||
prompts = [
|
||||
{"name": "skill-1", "type": "skills", "description": "First skill"},
|
||||
{"name": "agent-1", "type": "agents", "description": "First agent"},
|
||||
]
|
||||
|
||||
result = adapter.generate_prompt_index(prompts)
|
||||
|
||||
assert "Ring Prompts" in result
|
||||
assert "skill-1" in result.lower() or "Skill 1" in result
|
||||
assert "agent-1" in result.lower() or "Agent 1" in result
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Tests for PlatformAdapter Base Class
|
||||
# ==============================================================================
|
||||
|
||||
class TestPlatformAdapterBase:
|
||||
"""Tests for PlatformAdapter base class methods."""
|
||||
|
||||
@pytest.fixture
|
||||
def adapter(self):
|
||||
"""Create a ClaudeAdapter as a concrete implementation."""
|
||||
return ClaudeAdapter()
|
||||
|
||||
def test_validate_content_empty_fails(self, adapter):
|
||||
"""validate_content() should fail for empty content."""
|
||||
errors = adapter.validate_content("", "skill")
|
||||
assert len(errors) > 0
|
||||
assert "Empty" in errors[0]
|
||||
|
||||
def test_validate_content_whitespace_only_fails(self, adapter):
|
||||
"""validate_content() should fail for whitespace-only content."""
|
||||
errors = adapter.validate_content(" \n\t ", "skill")
|
||||
assert len(errors) > 0
|
||||
|
||||
def test_validate_content_invalid_frontmatter(self, adapter):
|
||||
"""validate_content() should detect invalid frontmatter."""
|
||||
content = """---
|
||||
name: test
|
||||
invalid frontmatter without closing
|
||||
"""
|
||||
errors = adapter.validate_content(content, "skill")
|
||||
assert len(errors) > 0
|
||||
|
||||
def test_extract_frontmatter_valid(self, adapter, minimal_skill_content):
|
||||
"""extract_frontmatter() should parse valid frontmatter."""
|
||||
frontmatter, body = adapter.extract_frontmatter(minimal_skill_content)
|
||||
|
||||
assert "name" in frontmatter
|
||||
assert frontmatter["name"] == "minimal-skill"
|
||||
assert "Minimal Skill" in body
|
||||
|
||||
def test_extract_frontmatter_no_frontmatter(self, adapter, content_without_frontmatter):
|
||||
"""extract_frontmatter() should handle content without frontmatter."""
|
||||
frontmatter, body = adapter.extract_frontmatter(content_without_frontmatter)
|
||||
|
||||
assert frontmatter == {}
|
||||
assert "No Frontmatter" in body
|
||||
|
||||
def test_create_frontmatter(self, adapter):
|
||||
"""create_frontmatter() should create valid YAML frontmatter."""
|
||||
data = {"name": "test", "description": "A test"}
|
||||
result = adapter.create_frontmatter(data)
|
||||
|
||||
assert result.startswith("---\n")
|
||||
assert result.endswith("---\n")
|
||||
assert "name: test" in result
|
||||
|
||||
def test_create_frontmatter_empty(self, adapter):
|
||||
"""create_frontmatter() should return empty string for empty dict."""
|
||||
result = adapter.create_frontmatter({})
|
||||
assert result == ""
|
||||
|
||||
def test_supports_component(self, adapter):
|
||||
"""supports_component() should check component mapping."""
|
||||
assert adapter.supports_component("agents") is True
|
||||
assert adapter.supports_component("skills") is True
|
||||
assert adapter.supports_component("unknown") is False
|
||||
|
||||
def test_replace_terminology(self, adapter):
|
||||
"""replace_terminology() should replace terms based on mapping."""
|
||||
# ClaudeAdapter has identity mapping, so use FactoryAdapter
|
||||
factory_adapter = FactoryAdapter()
|
||||
content = "The agent handles the task."
|
||||
result = factory_adapter.replace_terminology(content)
|
||||
|
||||
assert "droid" in result.lower()
|
||||
|
||||
def test_repr(self, adapter):
|
||||
"""__repr__() should return informative string."""
|
||||
repr_str = repr(adapter)
|
||||
assert "ClaudeAdapter" in repr_str
|
||||
assert "claude" in repr_str
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Parametrized Tests for All Adapters
|
||||
# ==============================================================================
|
||||
|
||||
@pytest.mark.parametrize("platform", SUPPORTED_PLATFORMS)
|
||||
class TestAllAdaptersCommon:
|
||||
"""Common tests that apply to all adapters."""
|
||||
|
||||
def test_adapter_has_required_attributes(self, platform):
|
||||
"""All adapters should have required attributes."""
|
||||
adapter = get_adapter(platform)
|
||||
|
||||
assert hasattr(adapter, "platform_id")
|
||||
assert hasattr(adapter, "platform_name")
|
||||
assert adapter.platform_id == platform
|
||||
|
||||
def test_adapter_has_required_methods(self, platform):
|
||||
"""All adapters should implement required methods."""
|
||||
adapter = get_adapter(platform)
|
||||
|
||||
assert callable(adapter.transform_skill)
|
||||
assert callable(adapter.transform_agent)
|
||||
assert callable(adapter.transform_command)
|
||||
assert callable(adapter.get_install_path)
|
||||
assert callable(adapter.get_component_mapping)
|
||||
|
||||
def test_transform_skill_returns_string(self, platform, minimal_skill_content):
|
||||
"""transform_skill() should return a string."""
|
||||
adapter = get_adapter(platform)
|
||||
result = adapter.transform_skill(minimal_skill_content)
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
||||
def test_transform_agent_returns_string(self, platform, minimal_agent_content):
|
||||
"""transform_agent() should return a string."""
|
||||
adapter = get_adapter(platform)
|
||||
result = adapter.transform_agent(minimal_agent_content)
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
||||
def test_transform_command_returns_string(self, platform, minimal_command_content):
|
||||
"""transform_command() should return a string."""
|
||||
adapter = get_adapter(platform)
|
||||
result = adapter.transform_command(minimal_command_content)
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
||||
def test_get_install_path_returns_path(self, platform):
|
||||
"""get_install_path() should return a Path object."""
|
||||
adapter = get_adapter(platform)
|
||||
path = adapter.get_install_path()
|
||||
assert isinstance(path, Path)
|
||||
|
||||
def test_get_component_mapping_returns_dict(self, platform):
|
||||
"""get_component_mapping() should return a dictionary."""
|
||||
adapter = get_adapter(platform)
|
||||
mapping = adapter.get_component_mapping()
|
||||
assert isinstance(mapping, dict)
|
||||
assert len(mapping) > 0
|
||||
|
||||
def test_get_terminology_returns_dict(self, platform):
|
||||
"""get_terminology() should return a dictionary."""
|
||||
adapter = get_adapter(platform)
|
||||
terminology = adapter.get_terminology()
|
||||
assert isinstance(terminology, dict)
|
||||
assert "agent" in terminology
|
||||
assert "skill" in terminology
|
||||
1298
installer/tests/test_core.py
Normal file
1298
installer/tests/test_core.py
Normal file
File diff suppressed because it is too large
Load diff
814
installer/tests/test_transformers.py
Normal file
814
installer/tests/test_transformers.py
Normal file
|
|
@ -0,0 +1,814 @@
|
|||
"""
|
||||
Tests for content transformers.
|
||||
|
||||
Tests SkillTransformer, AgentTransformer, CommandTransformer,
|
||||
transformer pipeline composition, and factory functions.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from typing import Dict, Any
|
||||
|
||||
from ring_installer.transformers import (
|
||||
BaseTransformer,
|
||||
TransformContext,
|
||||
TransformResult,
|
||||
TransformerPipeline,
|
||||
PassthroughTransformer,
|
||||
TerminologyTransformer,
|
||||
FrontmatterTransformer,
|
||||
SkillTransformer,
|
||||
SkillTransformerFactory,
|
||||
AgentTransformer,
|
||||
AgentTransformerFactory,
|
||||
CommandTransformer,
|
||||
CommandTransformerFactory,
|
||||
get_transformer,
|
||||
transform_content,
|
||||
create_pipeline,
|
||||
)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Tests for TransformContext
|
||||
# ==============================================================================
|
||||
|
||||
class TestTransformContext:
|
||||
"""Tests for TransformContext dataclass."""
|
||||
|
||||
def test_create_context_minimal(self):
|
||||
"""TransformContext should be creatable with minimal parameters."""
|
||||
context = TransformContext(
|
||||
platform="claude",
|
||||
component_type="skill"
|
||||
)
|
||||
|
||||
assert context.platform == "claude"
|
||||
assert context.component_type == "skill"
|
||||
assert context.source_path == ""
|
||||
assert context.metadata == {}
|
||||
assert context.options == {}
|
||||
|
||||
def test_create_context_full(self):
|
||||
"""TransformContext should accept all parameters."""
|
||||
context = TransformContext(
|
||||
platform="cursor",
|
||||
component_type="agent",
|
||||
source_path="/path/to/agent.md",
|
||||
metadata={"name": "test-agent"},
|
||||
options={"verbose": True}
|
||||
)
|
||||
|
||||
assert context.platform == "cursor"
|
||||
assert context.component_type == "agent"
|
||||
assert context.source_path == "/path/to/agent.md"
|
||||
assert context.metadata["name"] == "test-agent"
|
||||
assert context.options["verbose"] is True
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Tests for TransformResult
|
||||
# ==============================================================================
|
||||
|
||||
class TestTransformResult:
|
||||
"""Tests for TransformResult dataclass."""
|
||||
|
||||
def test_create_result_success(self):
|
||||
"""TransformResult should represent successful transformation."""
|
||||
result = TransformResult(
|
||||
content="transformed content",
|
||||
success=True
|
||||
)
|
||||
|
||||
assert result.content == "transformed content"
|
||||
assert result.success is True
|
||||
assert result.errors == []
|
||||
assert result.warnings == []
|
||||
|
||||
def test_create_result_failure(self):
|
||||
"""TransformResult should represent failed transformation."""
|
||||
result = TransformResult(
|
||||
content="original content",
|
||||
success=False,
|
||||
errors=["Error 1", "Error 2"]
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert len(result.errors) == 2
|
||||
assert "Error 1" in result.errors
|
||||
|
||||
def test_create_result_with_metadata(self):
|
||||
"""TransformResult should store transformation metadata."""
|
||||
result = TransformResult(
|
||||
content="content",
|
||||
success=True,
|
||||
metadata={"lines_changed": 5}
|
||||
)
|
||||
|
||||
assert result.metadata["lines_changed"] == 5
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Tests for PassthroughTransformer
|
||||
# ==============================================================================
|
||||
|
||||
class TestPassthroughTransformer:
|
||||
"""Tests for PassthroughTransformer."""
|
||||
|
||||
@pytest.fixture
|
||||
def transformer(self):
|
||||
"""Create a PassthroughTransformer."""
|
||||
return PassthroughTransformer()
|
||||
|
||||
@pytest.fixture
|
||||
def context(self, transform_context):
|
||||
"""Create a test context."""
|
||||
return transform_context(platform="claude", component_type="skill")
|
||||
|
||||
def test_passthrough_returns_unchanged_content(self, transformer, context):
|
||||
"""PassthroughTransformer should return content unchanged."""
|
||||
content = "Original content with **formatting**."
|
||||
result = transformer.transform(content, context)
|
||||
|
||||
assert result.content == content
|
||||
assert result.success is True
|
||||
|
||||
def test_passthrough_preserves_frontmatter(self, transformer, context, minimal_skill_content):
|
||||
"""PassthroughTransformer should preserve YAML frontmatter."""
|
||||
result = transformer.transform(minimal_skill_content, context)
|
||||
|
||||
assert result.content == minimal_skill_content
|
||||
assert result.content.startswith("---")
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Tests for TerminologyTransformer
|
||||
# ==============================================================================
|
||||
|
||||
class TestTerminologyTransformer:
|
||||
"""Tests for TerminologyTransformer."""
|
||||
|
||||
@pytest.fixture
|
||||
def factory_terminology(self):
|
||||
"""Return Factory AI terminology mapping."""
|
||||
return {
|
||||
"agent": "droid",
|
||||
"skill": "skill",
|
||||
"command": "command"
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def transformer(self, factory_terminology):
|
||||
"""Create a TerminologyTransformer with Factory terminology."""
|
||||
return TerminologyTransformer(factory_terminology)
|
||||
|
||||
@pytest.fixture
|
||||
def context(self, transform_context):
|
||||
"""Create a test context."""
|
||||
return transform_context(platform="factory", component_type="skill")
|
||||
|
||||
def test_replaces_lowercase_terms(self, transformer, context):
|
||||
"""TerminologyTransformer should replace lowercase terms."""
|
||||
content = "The agent handles requests."
|
||||
result = transformer.transform(content, context)
|
||||
|
||||
assert "droid" in result.content
|
||||
assert "agent" not in result.content
|
||||
|
||||
def test_replaces_titlecase_terms(self, transformer, context):
|
||||
"""TerminologyTransformer should replace title case terms."""
|
||||
content = "Agent Configuration"
|
||||
result = transformer.transform(content, context)
|
||||
|
||||
assert "Droid" in result.content
|
||||
|
||||
def test_replaces_uppercase_terms(self, transformer, context):
|
||||
"""TerminologyTransformer should replace uppercase terms."""
|
||||
content = "AGENT SETUP"
|
||||
result = transformer.transform(content, context)
|
||||
|
||||
assert "DROID" in result.content
|
||||
|
||||
def test_preserves_unchanged_terms(self, transformer, context):
|
||||
"""TerminologyTransformer should not change terms not in mapping."""
|
||||
content = "The skill handles commands."
|
||||
result = transformer.transform(content, context)
|
||||
|
||||
# skill and command should remain unchanged
|
||||
assert "skill" in result.content
|
||||
assert "commands" in result.content
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Tests for FrontmatterTransformer
|
||||
# ==============================================================================
|
||||
|
||||
class TestFrontmatterTransformer:
|
||||
"""Tests for FrontmatterTransformer."""
|
||||
|
||||
@pytest.fixture
|
||||
def context(self, transform_context):
|
||||
"""Create a test context."""
|
||||
return transform_context(platform="factory", component_type="agent")
|
||||
|
||||
def test_rename_fields(self, context):
|
||||
"""FrontmatterTransformer should rename fields."""
|
||||
transformer = FrontmatterTransformer(
|
||||
field_mapping={"agent": "droid", "subagent_type": "subdroid_type"}
|
||||
)
|
||||
|
||||
content = """---
|
||||
name: test
|
||||
agent: helper
|
||||
subagent_type: worker
|
||||
---
|
||||
|
||||
Body content
|
||||
"""
|
||||
result = transformer.transform(content, context)
|
||||
|
||||
assert "droid:" in result.content
|
||||
assert "subdroid_type:" in result.content
|
||||
assert "agent:" not in result.content
|
||||
assert "subagent_type:" not in result.content
|
||||
|
||||
def test_remove_fields(self, context):
|
||||
"""FrontmatterTransformer should remove specified fields."""
|
||||
transformer = FrontmatterTransformer(
|
||||
remove_fields=["internal_field", "debug"]
|
||||
)
|
||||
|
||||
content = """---
|
||||
name: test
|
||||
internal_field: value
|
||||
debug: true
|
||||
---
|
||||
|
||||
Body content
|
||||
"""
|
||||
result = transformer.transform(content, context)
|
||||
|
||||
assert "name:" in result.content
|
||||
assert "internal_field" not in result.content
|
||||
assert "debug" not in result.content
|
||||
|
||||
def test_add_fields(self, context):
|
||||
"""FrontmatterTransformer should add new fields."""
|
||||
transformer = FrontmatterTransformer(
|
||||
add_fields={"type": "droid", "platform": "factory"}
|
||||
)
|
||||
|
||||
content = """---
|
||||
name: test
|
||||
---
|
||||
|
||||
Body content
|
||||
"""
|
||||
result = transformer.transform(content, context)
|
||||
|
||||
assert "type:" in result.content
|
||||
assert "platform:" in result.content
|
||||
|
||||
def test_no_frontmatter_passthrough(self, context, content_without_frontmatter):
|
||||
"""FrontmatterTransformer should pass through content without frontmatter."""
|
||||
transformer = FrontmatterTransformer(
|
||||
field_mapping={"agent": "droid"}
|
||||
)
|
||||
|
||||
result = transformer.transform(content_without_frontmatter, context)
|
||||
|
||||
assert result.content == content_without_frontmatter
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Tests for TransformerPipeline
|
||||
# ==============================================================================
|
||||
|
||||
class TestTransformerPipeline:
|
||||
"""Tests for TransformerPipeline composition."""
|
||||
|
||||
@pytest.fixture
|
||||
def context(self, transform_context):
|
||||
"""Create a test context."""
|
||||
return transform_context(platform="factory", component_type="agent")
|
||||
|
||||
def test_empty_pipeline(self, context):
|
||||
"""Empty pipeline should return content unchanged."""
|
||||
pipeline = TransformerPipeline()
|
||||
|
||||
result = pipeline.transform("test content", context)
|
||||
|
||||
assert result.content == "test content"
|
||||
assert result.success is True
|
||||
|
||||
def test_single_transformer_pipeline(self, context):
|
||||
"""Pipeline with single transformer should work."""
|
||||
pipeline = TransformerPipeline([PassthroughTransformer()])
|
||||
|
||||
result = pipeline.transform("test content", context)
|
||||
|
||||
assert result.content == "test content"
|
||||
assert result.success is True
|
||||
|
||||
def test_chained_transformers(self, context):
|
||||
"""Pipeline should chain transformers in order."""
|
||||
terminology = {"agent": "droid"}
|
||||
frontmatter_mods = {"add_fields": {"platform": "factory"}}
|
||||
|
||||
pipeline = TransformerPipeline()
|
||||
pipeline.add(TerminologyTransformer(terminology))
|
||||
pipeline.add(FrontmatterTransformer(**frontmatter_mods))
|
||||
|
||||
content = """---
|
||||
name: test-agent
|
||||
---
|
||||
|
||||
The agent handles requests.
|
||||
"""
|
||||
result = pipeline.transform(content, context)
|
||||
|
||||
assert "droid" in result.content
|
||||
assert "platform:" in result.content
|
||||
|
||||
def test_pipeline_add_returns_self(self, context):
|
||||
"""Pipeline.add() should return self for chaining."""
|
||||
pipeline = TransformerPipeline()
|
||||
|
||||
result = pipeline.add(PassthroughTransformer())
|
||||
|
||||
assert result is pipeline
|
||||
|
||||
def test_pipeline_stops_on_failure(self, context):
|
||||
"""Pipeline should stop when a transformer fails."""
|
||||
|
||||
class FailingTransformer(BaseTransformer):
|
||||
def transform(self, content, context):
|
||||
return TransformResult(
|
||||
content=content,
|
||||
success=False,
|
||||
errors=["Intentional failure"]
|
||||
)
|
||||
|
||||
pipeline = TransformerPipeline([
|
||||
FailingTransformer(),
|
||||
PassthroughTransformer() # Should not be called
|
||||
])
|
||||
|
||||
result = pipeline.transform("test", context)
|
||||
|
||||
assert result.success is False
|
||||
assert "Intentional failure" in result.errors
|
||||
|
||||
def test_pipeline_aggregates_warnings(self, context):
|
||||
"""Pipeline should aggregate warnings from all transformers."""
|
||||
|
||||
class WarningTransformer(BaseTransformer):
|
||||
def __init__(self, warning):
|
||||
super().__init__()
|
||||
self.warning = warning
|
||||
|
||||
def transform(self, content, context):
|
||||
return TransformResult(
|
||||
content=content,
|
||||
success=True,
|
||||
warnings=[self.warning]
|
||||
)
|
||||
|
||||
pipeline = TransformerPipeline([
|
||||
WarningTransformer("Warning 1"),
|
||||
WarningTransformer("Warning 2")
|
||||
])
|
||||
|
||||
result = pipeline.transform("test", context)
|
||||
|
||||
assert result.success is True
|
||||
assert len(result.warnings) == 2
|
||||
|
||||
def test_pipeline_len(self):
|
||||
"""Pipeline should report correct length."""
|
||||
pipeline = TransformerPipeline()
|
||||
assert len(pipeline) == 0
|
||||
|
||||
pipeline.add(PassthroughTransformer())
|
||||
assert len(pipeline) == 1
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Tests for SkillTransformer
|
||||
# ==============================================================================
|
||||
|
||||
class TestSkillTransformer:
|
||||
"""Tests for SkillTransformer platform-specific transformations."""
|
||||
|
||||
def test_claude_passthrough(self, sample_skill_content, transform_context):
|
||||
"""Claude transformer should pass through skill content."""
|
||||
transformer = SkillTransformerFactory.create("claude")
|
||||
context = transform_context(platform="claude", component_type="skill")
|
||||
|
||||
result = transformer.transform(sample_skill_content, context)
|
||||
|
||||
assert result.success is True
|
||||
# Claude preserves frontmatter
|
||||
assert result.content.startswith("---")
|
||||
|
||||
def test_factory_terminology(self, sample_skill_content, transform_context):
|
||||
"""Factory transformer should replace agent terminology."""
|
||||
transformer = SkillTransformerFactory.create("factory")
|
||||
context = transform_context(platform="factory", component_type="skill")
|
||||
|
||||
result = transformer.transform(sample_skill_content, context)
|
||||
|
||||
assert result.success is True
|
||||
# Factory preserves frontmatter but changes terminology
|
||||
assert result.content.startswith("---")
|
||||
|
||||
def test_cursor_rule_format(self, sample_skill_content, transform_context):
|
||||
"""Cursor transformer should convert to rule format."""
|
||||
transformer = SkillTransformerFactory.create("cursor")
|
||||
context = transform_context(platform="cursor", component_type="skill")
|
||||
|
||||
result = transformer.transform(sample_skill_content, context)
|
||||
|
||||
assert result.success is True
|
||||
# Cursor removes frontmatter
|
||||
assert not result.content.startswith("---")
|
||||
# Should have rule structure
|
||||
assert "# " in result.content # Title
|
||||
assert "When to Apply" in result.content or "Instructions" in result.content
|
||||
|
||||
def test_cline_prompt_format(self, sample_skill_content, transform_context):
|
||||
"""Cline transformer should convert to prompt format."""
|
||||
transformer = SkillTransformerFactory.create("cline")
|
||||
context = transform_context(platform="cline", component_type="skill")
|
||||
|
||||
result = transformer.transform(sample_skill_content, context)
|
||||
|
||||
assert result.success is True
|
||||
# Cline removes frontmatter
|
||||
assert not result.content.startswith("---")
|
||||
# Should have prompt metadata
|
||||
assert "<!-- Prompt:" in result.content
|
||||
assert "<!-- Type: skill -->" in result.content
|
||||
|
||||
def test_empty_content_fails(self, transform_context):
|
||||
"""Transformer should fail on empty content."""
|
||||
transformer = SkillTransformerFactory.create("claude")
|
||||
context = transform_context(platform="claude", component_type="skill")
|
||||
|
||||
result = transformer.transform("", context)
|
||||
|
||||
assert result.success is False
|
||||
assert len(result.errors) > 0
|
||||
|
||||
def test_whitespace_content_fails(self, transform_context):
|
||||
"""Transformer should fail on whitespace-only content."""
|
||||
transformer = SkillTransformerFactory.create("claude")
|
||||
context = transform_context(platform="claude", component_type="skill")
|
||||
|
||||
result = transformer.transform(" \n\t ", context)
|
||||
|
||||
assert result.success is False
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Tests for AgentTransformer
|
||||
# ==============================================================================
|
||||
|
||||
class TestAgentTransformer:
|
||||
"""Tests for AgentTransformer platform-specific transformations."""
|
||||
|
||||
def test_claude_passthrough(self, sample_agent_content, transform_context):
|
||||
"""Claude transformer should pass through agent content."""
|
||||
transformer = AgentTransformerFactory.create("claude")
|
||||
context = transform_context(platform="claude", component_type="agent")
|
||||
|
||||
result = transformer.transform(sample_agent_content, context)
|
||||
|
||||
assert result.success is True
|
||||
assert result.content.startswith("---")
|
||||
|
||||
def test_factory_droid_conversion(self, sample_agent_content, transform_context):
|
||||
"""Factory transformer should convert agent to droid."""
|
||||
transformer = AgentTransformerFactory.create("factory")
|
||||
context = transform_context(platform="factory", component_type="agent")
|
||||
|
||||
result = transformer.transform(sample_agent_content, context)
|
||||
|
||||
assert result.success is True
|
||||
# Should contain droid terminology
|
||||
assert "droid" in result.content.lower() or "Droid" in result.content
|
||||
|
||||
def test_cursor_workflow_format(self, sample_agent_content, transform_context):
|
||||
"""Cursor transformer should convert agent to workflow."""
|
||||
transformer = AgentTransformerFactory.create("cursor")
|
||||
context = transform_context(platform="cursor", component_type="agent")
|
||||
|
||||
result = transformer.transform(sample_agent_content, context)
|
||||
|
||||
assert result.success is True
|
||||
assert not result.content.startswith("---")
|
||||
assert "Workflow" in result.content
|
||||
|
||||
def test_cline_prompt_format(self, sample_agent_content, transform_context):
|
||||
"""Cline transformer should convert agent to prompt."""
|
||||
transformer = AgentTransformerFactory.create("cline")
|
||||
context = transform_context(platform="cline", component_type="agent")
|
||||
|
||||
result = transformer.transform(sample_agent_content, context)
|
||||
|
||||
assert result.success is True
|
||||
assert "<!-- Prompt:" in result.content
|
||||
assert "<!-- Type: agent -->" in result.content
|
||||
assert "Role" in result.content or "Behavior" in result.content
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Tests for CommandTransformer
|
||||
# ==============================================================================
|
||||
|
||||
class TestCommandTransformer:
|
||||
"""Tests for CommandTransformer platform-specific transformations."""
|
||||
|
||||
def test_claude_passthrough(self, sample_command_content, transform_context):
|
||||
"""Claude transformer should pass through command content."""
|
||||
transformer = CommandTransformerFactory.create("claude")
|
||||
context = transform_context(platform="claude", component_type="command")
|
||||
|
||||
result = transformer.transform(sample_command_content, context)
|
||||
|
||||
assert result.success is True
|
||||
assert result.content.startswith("---")
|
||||
|
||||
def test_factory_preserves_command_with_droid_refs(self, sample_command_content, transform_context):
|
||||
"""Factory transformer should preserve command format with droid terminology."""
|
||||
transformer = CommandTransformerFactory.create("factory")
|
||||
context = transform_context(platform="factory", component_type="command")
|
||||
|
||||
result = transformer.transform(sample_command_content, context)
|
||||
|
||||
assert result.success is True
|
||||
|
||||
def test_cursor_workflow_format(self, sample_command_content, transform_context):
|
||||
"""Cursor transformer should convert command to workflow."""
|
||||
transformer = CommandTransformerFactory.create("cursor")
|
||||
context = transform_context(platform="cursor", component_type="command")
|
||||
|
||||
result = transformer.transform(sample_command_content, context)
|
||||
|
||||
assert result.success is True
|
||||
assert not result.content.startswith("---")
|
||||
assert "Parameters" in result.content
|
||||
assert "Instructions" in result.content
|
||||
|
||||
def test_cline_prompt_format(self, sample_command_content, transform_context):
|
||||
"""Cline transformer should convert command to action prompt."""
|
||||
transformer = CommandTransformerFactory.create("cline")
|
||||
context = transform_context(platform="cline", component_type="command")
|
||||
|
||||
result = transformer.transform(sample_command_content, context)
|
||||
|
||||
assert result.success is True
|
||||
assert "<!-- Prompt:" in result.content
|
||||
assert "<!-- Type: command -->" in result.content
|
||||
assert "Parameters" in result.content
|
||||
assert "Steps" in result.content
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Tests for get_transformer() Factory
|
||||
# ==============================================================================
|
||||
|
||||
class TestGetTransformer:
|
||||
"""Tests for get_transformer() factory function."""
|
||||
|
||||
@pytest.mark.parametrize("platform", ["claude", "factory", "cursor", "cline"])
|
||||
def test_get_skill_transformer(self, platform):
|
||||
"""get_transformer() should return skill transformer for each platform."""
|
||||
transformer = get_transformer(platform, "skill")
|
||||
assert isinstance(transformer, SkillTransformer)
|
||||
|
||||
@pytest.mark.parametrize("platform", ["claude", "factory", "cursor", "cline"])
|
||||
def test_get_agent_transformer(self, platform):
|
||||
"""get_transformer() should return agent transformer for each platform."""
|
||||
transformer = get_transformer(platform, "agent")
|
||||
assert isinstance(transformer, AgentTransformer)
|
||||
|
||||
@pytest.mark.parametrize("platform", ["claude", "factory", "cursor", "cline"])
|
||||
def test_get_command_transformer(self, platform):
|
||||
"""get_transformer() should return command transformer for each platform."""
|
||||
transformer = get_transformer(platform, "command")
|
||||
assert isinstance(transformer, CommandTransformer)
|
||||
|
||||
def test_get_transformer_handles_plural_component_type(self):
|
||||
"""get_transformer() should handle plural component types."""
|
||||
# 'skills' should work same as 'skill'
|
||||
transformer = get_transformer("claude", "skills")
|
||||
assert isinstance(transformer, SkillTransformer)
|
||||
|
||||
transformer = get_transformer("claude", "agents")
|
||||
assert isinstance(transformer, AgentTransformer)
|
||||
|
||||
def test_get_transformer_unsupported_platform_raises(self):
|
||||
"""get_transformer() should raise ValueError for unsupported platform."""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
get_transformer("unknown", "skill")
|
||||
|
||||
assert "Unsupported platform" in str(exc_info.value)
|
||||
|
||||
def test_get_transformer_unsupported_component_raises(self):
|
||||
"""get_transformer() should raise ValueError for unsupported component."""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
get_transformer("claude", "unknown")
|
||||
|
||||
assert "Unsupported component type" in str(exc_info.value)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Tests for transform_content() Convenience Function
|
||||
# ==============================================================================
|
||||
|
||||
class TestTransformContent:
|
||||
"""Tests for transform_content() convenience function."""
|
||||
|
||||
def test_transform_skill(self, sample_skill_content):
|
||||
"""transform_content() should transform skill content."""
|
||||
result = transform_content(
|
||||
content=sample_skill_content,
|
||||
platform="cursor",
|
||||
component_type="skill"
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert "# " in result.content # Has title
|
||||
|
||||
def test_transform_with_metadata(self, minimal_skill_content):
|
||||
"""transform_content() should pass metadata to transformer."""
|
||||
result = transform_content(
|
||||
content=minimal_skill_content,
|
||||
platform="cline",
|
||||
component_type="skill",
|
||||
metadata={"name": "custom-name"},
|
||||
source_path="/path/to/skill.md"
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert "<!-- Source:" in result.content
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Tests for create_pipeline() Function
|
||||
# ==============================================================================
|
||||
|
||||
class TestCreatePipeline:
|
||||
"""Tests for create_pipeline() function."""
|
||||
|
||||
def test_create_pipeline_default(self):
|
||||
"""create_pipeline() should create pipeline with default component types."""
|
||||
pipeline = create_pipeline("cursor")
|
||||
|
||||
# Default includes skill, agent, command
|
||||
assert len(pipeline) >= 1
|
||||
|
||||
def test_create_pipeline_specific_components(self):
|
||||
"""create_pipeline() should accept specific component types."""
|
||||
pipeline = create_pipeline("cursor", component_types=["skill"])
|
||||
|
||||
assert len(pipeline) == 1
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Parametrized Tests Across All Platforms
|
||||
# ==============================================================================
|
||||
|
||||
@pytest.mark.parametrize("platform", ["claude", "factory", "cursor", "cline"])
|
||||
class TestAllPlatformTransformers:
|
||||
"""Tests that apply to all platform transformers."""
|
||||
|
||||
def test_skill_transform_returns_result(self, platform, sample_skill_content, transform_context):
|
||||
"""All skill transformers should return TransformResult."""
|
||||
transformer = SkillTransformerFactory.create(platform)
|
||||
context = transform_context(platform=platform, component_type="skill")
|
||||
|
||||
result = transformer.transform(sample_skill_content, context)
|
||||
|
||||
assert isinstance(result, TransformResult)
|
||||
assert isinstance(result.content, str)
|
||||
assert len(result.content) > 0
|
||||
|
||||
def test_agent_transform_returns_result(self, platform, sample_agent_content, transform_context):
|
||||
"""All agent transformers should return TransformResult."""
|
||||
transformer = AgentTransformerFactory.create(platform)
|
||||
context = transform_context(platform=platform, component_type="agent")
|
||||
|
||||
result = transformer.transform(sample_agent_content, context)
|
||||
|
||||
assert isinstance(result, TransformResult)
|
||||
assert isinstance(result.content, str)
|
||||
|
||||
def test_command_transform_returns_result(self, platform, sample_command_content, transform_context):
|
||||
"""All command transformers should return TransformResult."""
|
||||
transformer = CommandTransformerFactory.create(platform)
|
||||
context = transform_context(platform=platform, component_type="command")
|
||||
|
||||
result = transformer.transform(sample_command_content, context)
|
||||
|
||||
assert isinstance(result, TransformResult)
|
||||
assert isinstance(result.content, str)
|
||||
|
||||
def test_preserves_essential_content(self, platform, sample_skill_content, transform_context):
|
||||
"""Transformers should preserve essential content from body."""
|
||||
transformer = SkillTransformerFactory.create(platform)
|
||||
context = transform_context(platform=platform, component_type="skill")
|
||||
|
||||
result = transformer.transform(sample_skill_content, context)
|
||||
|
||||
# Core documentation should be preserved (in some form)
|
||||
# The word "sample" should appear somewhere
|
||||
assert "sample" in result.content.lower() or "Sample" in result.content
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Edge Case Tests
|
||||
# ==============================================================================
|
||||
|
||||
class TestTransformerEdgeCases:
|
||||
"""Tests for edge cases and error handling."""
|
||||
|
||||
def test_content_without_frontmatter(self, content_without_frontmatter, transform_context):
|
||||
"""Transformers should handle content without frontmatter."""
|
||||
transformer = SkillTransformerFactory.create("cursor")
|
||||
context = transform_context(platform="cursor", component_type="skill")
|
||||
|
||||
result = transformer.transform(content_without_frontmatter, context)
|
||||
|
||||
assert result.success is True
|
||||
assert len(result.content) > 0
|
||||
|
||||
def test_malformed_frontmatter(self, content_with_invalid_frontmatter, transform_context):
|
||||
"""Transformers should handle malformed frontmatter gracefully."""
|
||||
transformer = SkillTransformerFactory.create("claude")
|
||||
context = transform_context(platform="claude", component_type="skill")
|
||||
|
||||
# Should not raise an exception
|
||||
result = transformer.transform(content_with_invalid_frontmatter, context)
|
||||
|
||||
# Result depends on implementation; it may pass or fail but shouldn't crash
|
||||
assert isinstance(result, TransformResult)
|
||||
|
||||
def test_unicode_content(self, transform_context):
|
||||
"""Transformers should handle unicode content."""
|
||||
content = """---
|
||||
name: unicode-test
|
||||
description: Test with unicode characters
|
||||
---
|
||||
|
||||
# Unicode Test
|
||||
|
||||
This content has unicode: , ,
|
||||
Japanese:
|
||||
Emoji:
|
||||
"""
|
||||
transformer = SkillTransformerFactory.create("cursor")
|
||||
context = transform_context(platform="cursor", component_type="skill")
|
||||
|
||||
result = transformer.transform(content, context)
|
||||
|
||||
assert result.success is True
|
||||
# Unicode should be preserved
|
||||
assert "" in result.content or "Unicode" in result.content
|
||||
|
||||
def test_very_long_content(self, transform_context):
|
||||
"""Transformers should handle very long content."""
|
||||
long_body = "This is a long line. " * 1000
|
||||
|
||||
content = f"""---
|
||||
name: long-content
|
||||
---
|
||||
|
||||
{long_body}
|
||||
"""
|
||||
transformer = SkillTransformerFactory.create("claude")
|
||||
context = transform_context(platform="claude", component_type="skill")
|
||||
|
||||
result = transformer.transform(content, context)
|
||||
|
||||
assert result.success is True
|
||||
assert len(result.content) > 10000
|
||||
|
||||
def test_special_yaml_characters(self, transform_context):
|
||||
"""Transformers should handle special YAML characters."""
|
||||
content = """---
|
||||
name: special-chars
|
||||
description: "Quotes: 'single' and \"double\""
|
||||
trigger: |
|
||||
- When using: colons
|
||||
- Or # hashes
|
||||
---
|
||||
|
||||
Content with special chars: {} [] : #
|
||||
"""
|
||||
transformer = SkillTransformerFactory.create("claude")
|
||||
context = transform_context(platform="claude", component_type="skill")
|
||||
|
||||
result = transformer.transform(content, context)
|
||||
|
||||
assert result.success is True
|
||||
987
installer/tests/test_utils.py
Normal file
987
installer/tests/test_utils.py
Normal file
|
|
@ -0,0 +1,987 @@
|
|||
"""
|
||||
Tests for utility modules.
|
||||
|
||||
Tests fs.py (filesystem utilities), platform_detect.py (platform detection),
|
||||
and version.py (semver comparison).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Tests for fs.py (Filesystem Utilities)
|
||||
# ==============================================================================
|
||||
|
||||
class TestEnsureDirectory:
|
||||
"""Tests for ensure_directory() function."""
|
||||
|
||||
def test_creates_new_directory(self, tmp_path):
|
||||
"""ensure_directory() should create a new directory."""
|
||||
from ring_installer.utils.fs import ensure_directory
|
||||
|
||||
new_dir = tmp_path / "new_directory"
|
||||
assert not new_dir.exists()
|
||||
|
||||
result = ensure_directory(new_dir)
|
||||
|
||||
assert new_dir.exists()
|
||||
assert new_dir.is_dir()
|
||||
assert result == new_dir
|
||||
|
||||
def test_creates_nested_directories(self, tmp_path):
|
||||
"""ensure_directory() should create nested directories."""
|
||||
from ring_installer.utils.fs import ensure_directory
|
||||
|
||||
nested_dir = tmp_path / "level1" / "level2" / "level3"
|
||||
|
||||
result = ensure_directory(nested_dir)
|
||||
|
||||
assert nested_dir.exists()
|
||||
assert result == nested_dir
|
||||
|
||||
def test_existing_directory_unchanged(self, tmp_path):
|
||||
"""ensure_directory() should not fail on existing directory."""
|
||||
from ring_installer.utils.fs import ensure_directory
|
||||
|
||||
existing = tmp_path / "existing"
|
||||
existing.mkdir()
|
||||
|
||||
result = ensure_directory(existing)
|
||||
|
||||
assert existing.exists()
|
||||
assert result == existing
|
||||
|
||||
def test_raises_if_file_exists(self, tmp_path):
|
||||
"""ensure_directory() should raise NotADirectoryError if path is a file."""
|
||||
from ring_installer.utils.fs import ensure_directory
|
||||
|
||||
file_path = tmp_path / "a_file"
|
||||
file_path.write_text("content")
|
||||
|
||||
with pytest.raises(NotADirectoryError):
|
||||
ensure_directory(file_path)
|
||||
|
||||
def test_expands_user_path(self, tmp_path):
|
||||
"""ensure_directory() should expand ~ in paths."""
|
||||
from ring_installer.utils.fs import ensure_directory
|
||||
|
||||
# Create a mock expanduser that returns tmp_path
|
||||
with patch.object(Path, 'expanduser', return_value=tmp_path / "expanded"):
|
||||
path = Path("~/test_dir")
|
||||
# The actual behavior depends on implementation
|
||||
# This test verifies expanduser is called
|
||||
|
||||
|
||||
class TestBackupExisting:
|
||||
"""Tests for backup_existing() function."""
|
||||
|
||||
def test_creates_backup_of_file(self, tmp_path):
|
||||
"""backup_existing() should create backup of existing file."""
|
||||
from ring_installer.utils.fs import backup_existing
|
||||
|
||||
original = tmp_path / "original.txt"
|
||||
original.write_text("original content")
|
||||
|
||||
backup_path = backup_existing(original)
|
||||
|
||||
assert backup_path is not None
|
||||
assert backup_path.exists()
|
||||
assert backup_path.read_text() == "original content"
|
||||
assert "backup" in backup_path.name
|
||||
|
||||
def test_creates_backup_of_directory(self, tmp_path):
|
||||
"""backup_existing() should create backup of existing directory."""
|
||||
from ring_installer.utils.fs import backup_existing
|
||||
|
||||
original_dir = tmp_path / "original_dir"
|
||||
original_dir.mkdir()
|
||||
(original_dir / "file.txt").write_text("content")
|
||||
|
||||
backup_path = backup_existing(original_dir)
|
||||
|
||||
assert backup_path is not None
|
||||
assert backup_path.exists()
|
||||
assert (backup_path / "file.txt").exists()
|
||||
|
||||
def test_returns_none_if_not_exists(self, tmp_path):
|
||||
"""backup_existing() should return None if path doesn't exist."""
|
||||
from ring_installer.utils.fs import backup_existing
|
||||
|
||||
nonexistent = tmp_path / "nonexistent"
|
||||
|
||||
result = backup_existing(nonexistent)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_uses_custom_backup_dir(self, tmp_path):
|
||||
"""backup_existing() should use custom backup directory."""
|
||||
from ring_installer.utils.fs import backup_existing
|
||||
|
||||
original = tmp_path / "original.txt"
|
||||
original.write_text("content")
|
||||
|
||||
backup_dir = tmp_path / "backups"
|
||||
backup_path = backup_existing(original, backup_dir=backup_dir)
|
||||
|
||||
assert backup_path.parent == backup_dir
|
||||
|
||||
def test_backup_name_includes_timestamp(self, tmp_path):
|
||||
"""backup_existing() should include timestamp in backup name."""
|
||||
from ring_installer.utils.fs import backup_existing
|
||||
|
||||
original = tmp_path / "original.txt"
|
||||
original.write_text("content")
|
||||
|
||||
backup_path = backup_existing(original)
|
||||
|
||||
# Backup name format: original.backup_YYYYMMDD_HHMMSS
|
||||
assert "backup_" in backup_path.name
|
||||
# Should have date-like pattern
|
||||
parts = backup_path.name.split("backup_")
|
||||
assert len(parts[1]) >= 15 # YYYYMMDD_HHMMSS
|
||||
|
||||
|
||||
class TestCopyWithTransform:
|
||||
"""Tests for copy_with_transform() function."""
|
||||
|
||||
def test_copies_file_without_transform(self, tmp_path):
|
||||
"""copy_with_transform() should copy file without transformation."""
|
||||
from ring_installer.utils.fs import copy_with_transform
|
||||
|
||||
source = tmp_path / "source.txt"
|
||||
source.write_text("source content")
|
||||
target = tmp_path / "target.txt"
|
||||
|
||||
result = copy_with_transform(source, target)
|
||||
|
||||
assert target.exists()
|
||||
assert target.read_text() == "source content"
|
||||
assert result == target
|
||||
|
||||
def test_applies_transformation(self, tmp_path):
|
||||
"""copy_with_transform() should apply transformation function."""
|
||||
from ring_installer.utils.fs import copy_with_transform
|
||||
|
||||
source = tmp_path / "source.txt"
|
||||
source.write_text("hello world")
|
||||
target = tmp_path / "target.txt"
|
||||
|
||||
result = copy_with_transform(
|
||||
source,
|
||||
target,
|
||||
transform_func=lambda c: c.upper()
|
||||
)
|
||||
|
||||
assert target.read_text() == "HELLO WORLD"
|
||||
|
||||
def test_creates_target_directory(self, tmp_path):
|
||||
"""copy_with_transform() should create target directory if needed."""
|
||||
from ring_installer.utils.fs import copy_with_transform
|
||||
|
||||
source = tmp_path / "source.txt"
|
||||
source.write_text("content")
|
||||
target = tmp_path / "nested" / "dir" / "target.txt"
|
||||
|
||||
copy_with_transform(source, target)
|
||||
|
||||
assert target.exists()
|
||||
|
||||
def test_raises_if_source_missing(self, tmp_path):
|
||||
"""copy_with_transform() should raise FileNotFoundError if source missing."""
|
||||
from ring_installer.utils.fs import copy_with_transform
|
||||
|
||||
source = tmp_path / "nonexistent.txt"
|
||||
target = tmp_path / "target.txt"
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
copy_with_transform(source, target)
|
||||
|
||||
|
||||
class TestSafeRemove:
|
||||
"""Tests for safe_remove() function."""
|
||||
|
||||
def test_removes_file(self, tmp_path):
|
||||
"""safe_remove() should remove a file."""
|
||||
from ring_installer.utils.fs import safe_remove
|
||||
|
||||
file_path = tmp_path / "to_remove.txt"
|
||||
file_path.write_text("content")
|
||||
|
||||
result = safe_remove(file_path)
|
||||
|
||||
assert result is True
|
||||
assert not file_path.exists()
|
||||
|
||||
def test_removes_directory(self, tmp_path):
|
||||
"""safe_remove() should remove a directory and its contents."""
|
||||
from ring_installer.utils.fs import safe_remove
|
||||
|
||||
dir_path = tmp_path / "to_remove"
|
||||
dir_path.mkdir()
|
||||
(dir_path / "file.txt").write_text("content")
|
||||
|
||||
result = safe_remove(dir_path)
|
||||
|
||||
assert result is True
|
||||
assert not dir_path.exists()
|
||||
|
||||
def test_missing_ok_true(self, tmp_path):
|
||||
"""safe_remove() should not raise if path missing and missing_ok=True."""
|
||||
from ring_installer.utils.fs import safe_remove
|
||||
|
||||
nonexistent = tmp_path / "nonexistent"
|
||||
|
||||
result = safe_remove(nonexistent, missing_ok=True)
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_missing_ok_false_raises(self, tmp_path):
|
||||
"""safe_remove() should raise if path missing and missing_ok=False."""
|
||||
from ring_installer.utils.fs import safe_remove
|
||||
|
||||
nonexistent = tmp_path / "nonexistent"
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
safe_remove(nonexistent, missing_ok=False)
|
||||
|
||||
|
||||
class TestGetFileHash:
|
||||
"""Tests for get_file_hash() function."""
|
||||
|
||||
def test_returns_sha256_hash(self, tmp_path):
|
||||
"""get_file_hash() should return SHA256 hash by default."""
|
||||
from ring_installer.utils.fs import get_file_hash
|
||||
|
||||
file_path = tmp_path / "test.txt"
|
||||
file_path.write_text("test content")
|
||||
|
||||
hash_value = get_file_hash(file_path)
|
||||
|
||||
assert len(hash_value) == 64 # SHA256 produces 64 hex chars
|
||||
assert all(c in "0123456789abcdef" for c in hash_value)
|
||||
|
||||
def test_same_content_same_hash(self, tmp_path):
|
||||
"""get_file_hash() should return same hash for same content."""
|
||||
from ring_installer.utils.fs import get_file_hash
|
||||
|
||||
file1 = tmp_path / "file1.txt"
|
||||
file2 = tmp_path / "file2.txt"
|
||||
file1.write_text("identical content")
|
||||
file2.write_text("identical content")
|
||||
|
||||
assert get_file_hash(file1) == get_file_hash(file2)
|
||||
|
||||
def test_different_content_different_hash(self, tmp_path):
|
||||
"""get_file_hash() should return different hash for different content."""
|
||||
from ring_installer.utils.fs import get_file_hash
|
||||
|
||||
file1 = tmp_path / "file1.txt"
|
||||
file2 = tmp_path / "file2.txt"
|
||||
file1.write_text("content A")
|
||||
file2.write_text("content B")
|
||||
|
||||
assert get_file_hash(file1) != get_file_hash(file2)
|
||||
|
||||
def test_supports_md5_algorithm(self, tmp_path):
|
||||
"""get_file_hash() should support MD5 algorithm."""
|
||||
from ring_installer.utils.fs import get_file_hash
|
||||
|
||||
file_path = tmp_path / "test.txt"
|
||||
file_path.write_text("test")
|
||||
|
||||
hash_value = get_file_hash(file_path, algorithm="md5")
|
||||
|
||||
assert len(hash_value) == 32 # MD5 produces 32 hex chars
|
||||
|
||||
def test_raises_if_file_missing(self, tmp_path):
|
||||
"""get_file_hash() should raise FileNotFoundError if file missing."""
|
||||
from ring_installer.utils.fs import get_file_hash
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
get_file_hash(tmp_path / "nonexistent.txt")
|
||||
|
||||
|
||||
class TestFilesAreIdentical:
|
||||
"""Tests for files_are_identical() function."""
|
||||
|
||||
def test_identical_files_returns_true(self, tmp_path):
|
||||
"""files_are_identical() should return True for identical files."""
|
||||
from ring_installer.utils.fs import files_are_identical
|
||||
|
||||
file1 = tmp_path / "file1.txt"
|
||||
file2 = tmp_path / "file2.txt"
|
||||
file1.write_text("same content")
|
||||
file2.write_text("same content")
|
||||
|
||||
assert files_are_identical(file1, file2) is True
|
||||
|
||||
def test_different_files_returns_false(self, tmp_path):
|
||||
"""files_are_identical() should return False for different files."""
|
||||
from ring_installer.utils.fs import files_are_identical
|
||||
|
||||
file1 = tmp_path / "file1.txt"
|
||||
file2 = tmp_path / "file2.txt"
|
||||
file1.write_text("content A")
|
||||
file2.write_text("content B")
|
||||
|
||||
assert files_are_identical(file1, file2) is False
|
||||
|
||||
def test_missing_file_returns_false(self, tmp_path):
|
||||
"""files_are_identical() should return False if either file missing."""
|
||||
from ring_installer.utils.fs import files_are_identical
|
||||
|
||||
file1 = tmp_path / "exists.txt"
|
||||
file1.write_text("content")
|
||||
file2 = tmp_path / "nonexistent.txt"
|
||||
|
||||
assert files_are_identical(file1, file2) is False
|
||||
assert files_are_identical(file2, file1) is False
|
||||
|
||||
|
||||
class TestListFilesRecursive:
|
||||
"""Tests for list_files_recursive() function."""
|
||||
|
||||
def test_lists_all_files(self, tmp_path):
|
||||
"""list_files_recursive() should list all files in directory."""
|
||||
from ring_installer.utils.fs import list_files_recursive
|
||||
|
||||
(tmp_path / "file1.txt").write_text("1")
|
||||
(tmp_path / "file2.md").write_text("2")
|
||||
nested = tmp_path / "nested"
|
||||
nested.mkdir()
|
||||
(nested / "file3.py").write_text("3")
|
||||
|
||||
files = list_files_recursive(tmp_path)
|
||||
|
||||
assert len(files) == 3
|
||||
|
||||
def test_filters_by_extension(self, tmp_path):
|
||||
"""list_files_recursive() should filter by extension."""
|
||||
from ring_installer.utils.fs import list_files_recursive
|
||||
|
||||
(tmp_path / "file1.txt").write_text("1")
|
||||
(tmp_path / "file2.md").write_text("2")
|
||||
(tmp_path / "file3.py").write_text("3")
|
||||
|
||||
files = list_files_recursive(tmp_path, extensions=[".md"])
|
||||
|
||||
assert len(files) == 1
|
||||
assert files[0].suffix == ".md"
|
||||
|
||||
def test_excludes_patterns(self, tmp_path):
|
||||
"""list_files_recursive() should exclude patterns."""
|
||||
from ring_installer.utils.fs import list_files_recursive
|
||||
|
||||
(tmp_path / "file.txt").write_text("1")
|
||||
cache_dir = tmp_path / "__pycache__"
|
||||
cache_dir.mkdir()
|
||||
(cache_dir / "cached.pyc").write_text("c")
|
||||
|
||||
files = list_files_recursive(tmp_path, exclude_patterns=["__pycache__"])
|
||||
|
||||
assert len(files) == 1
|
||||
assert all("__pycache__" not in str(f) for f in files)
|
||||
|
||||
|
||||
class TestAtomicWrite:
|
||||
"""Tests for atomic_write() function."""
|
||||
|
||||
def test_writes_string_content(self, tmp_path):
|
||||
"""atomic_write() should write string content."""
|
||||
from ring_installer.utils.fs import atomic_write
|
||||
|
||||
file_path = tmp_path / "output.txt"
|
||||
|
||||
atomic_write(file_path, "test content")
|
||||
|
||||
assert file_path.read_text() == "test content"
|
||||
|
||||
def test_writes_bytes_content(self, tmp_path):
|
||||
"""atomic_write() should write bytes content."""
|
||||
from ring_installer.utils.fs import atomic_write
|
||||
|
||||
file_path = tmp_path / "output.bin"
|
||||
|
||||
atomic_write(file_path, b"\x00\x01\x02")
|
||||
|
||||
assert file_path.read_bytes() == b"\x00\x01\x02"
|
||||
|
||||
def test_no_partial_writes(self, tmp_path):
|
||||
"""atomic_write() should not leave partial files on failure."""
|
||||
from ring_installer.utils.fs import atomic_write
|
||||
|
||||
file_path = tmp_path / "output.txt"
|
||||
|
||||
# Write initial content
|
||||
atomic_write(file_path, "initial")
|
||||
|
||||
# The atomic write should be all-or-nothing
|
||||
assert file_path.read_text() == "initial"
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Tests for platform_detect.py (Platform Detection)
|
||||
# ==============================================================================
|
||||
|
||||
class TestPlatformInfo:
|
||||
"""Tests for PlatformInfo dataclass."""
|
||||
|
||||
def test_create_platform_info(self):
|
||||
"""PlatformInfo should be creatable with basic attributes."""
|
||||
from ring_installer.utils.platform_detect import PlatformInfo
|
||||
|
||||
info = PlatformInfo(
|
||||
platform_id="test",
|
||||
name="Test Platform",
|
||||
installed=True
|
||||
)
|
||||
|
||||
assert info.platform_id == "test"
|
||||
assert info.name == "Test Platform"
|
||||
assert info.installed is True
|
||||
assert info.version is None
|
||||
assert info.details == {}
|
||||
|
||||
def test_platform_info_with_all_fields(self):
|
||||
"""PlatformInfo should accept all optional fields."""
|
||||
from ring_installer.utils.platform_detect import PlatformInfo
|
||||
|
||||
info = PlatformInfo(
|
||||
platform_id="test",
|
||||
name="Test Platform",
|
||||
installed=True,
|
||||
version="1.0.0",
|
||||
install_path=Path("/test"),
|
||||
config_path=Path("/test/config"),
|
||||
binary_path=Path("/usr/bin/test"),
|
||||
details={"extra": "info"}
|
||||
)
|
||||
|
||||
assert info.version == "1.0.0"
|
||||
assert info.install_path == Path("/test")
|
||||
assert info.details["extra"] == "info"
|
||||
|
||||
|
||||
class TestDetectInstalledPlatforms:
|
||||
"""Tests for detect_installed_platforms() function."""
|
||||
|
||||
def test_returns_list(self, mock_platform_detection):
|
||||
"""detect_installed_platforms() should return a list."""
|
||||
from ring_installer.utils.platform_detect import detect_installed_platforms
|
||||
|
||||
result = detect_installed_platforms()
|
||||
|
||||
assert isinstance(result, list)
|
||||
|
||||
def test_returns_only_installed(self, mock_platform_detection):
|
||||
"""detect_installed_platforms() should return only installed platforms."""
|
||||
from ring_installer.utils.platform_detect import PlatformInfo, detect_installed_platforms
|
||||
|
||||
# Set Claude as installed
|
||||
mock_platform_detection["claude"].return_value = PlatformInfo(
|
||||
platform_id="claude",
|
||||
name="Claude Code",
|
||||
installed=True,
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
result = detect_installed_platforms()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].platform_id == "claude"
|
||||
|
||||
|
||||
class TestIsPlatformInstalled:
|
||||
"""Tests for is_platform_installed() function."""
|
||||
|
||||
def test_returns_true_if_installed(self, mock_platform_detection):
|
||||
"""is_platform_installed() should return True for installed platform."""
|
||||
from ring_installer.utils.platform_detect import PlatformInfo, is_platform_installed
|
||||
|
||||
mock_platform_detection["claude"].return_value = PlatformInfo(
|
||||
platform_id="claude",
|
||||
name="Claude Code",
|
||||
installed=True
|
||||
)
|
||||
|
||||
assert is_platform_installed("claude") is True
|
||||
|
||||
def test_returns_false_if_not_installed(self, mock_platform_detection):
|
||||
"""is_platform_installed() should return False for uninstalled platform."""
|
||||
from ring_installer.utils.platform_detect import is_platform_installed
|
||||
|
||||
assert is_platform_installed("claude") is False
|
||||
|
||||
|
||||
class TestGetPlatformVersion:
|
||||
"""Tests for get_platform_version() function."""
|
||||
|
||||
def test_returns_version_if_installed(self, mock_platform_detection):
|
||||
"""get_platform_version() should return version for installed platform."""
|
||||
from ring_installer.utils.platform_detect import PlatformInfo, get_platform_version
|
||||
|
||||
mock_platform_detection["cursor"].return_value = PlatformInfo(
|
||||
platform_id="cursor",
|
||||
name="Cursor",
|
||||
installed=True,
|
||||
version="0.42.0"
|
||||
)
|
||||
|
||||
assert get_platform_version("cursor") == "0.42.0"
|
||||
|
||||
def test_returns_none_if_not_installed(self, mock_platform_detection):
|
||||
"""get_platform_version() should return None for uninstalled platform."""
|
||||
from ring_installer.utils.platform_detect import get_platform_version
|
||||
|
||||
assert get_platform_version("cursor") is None
|
||||
|
||||
|
||||
class TestGetSystemInfo:
|
||||
"""Tests for get_system_info() function."""
|
||||
|
||||
def test_returns_system_info(self):
|
||||
"""get_system_info() should return system information dict."""
|
||||
from ring_installer.utils.platform_detect import get_system_info
|
||||
|
||||
info = get_system_info()
|
||||
|
||||
assert "platform" in info
|
||||
assert info["platform"] == sys.platform
|
||||
assert "python_version" in info
|
||||
assert "home_directory" in info
|
||||
assert "path" in info
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Tests for version.py (Version Comparison)
|
||||
# ==============================================================================
|
||||
|
||||
class TestVersionParsing:
|
||||
"""Tests for Version class parsing."""
|
||||
|
||||
def test_parse_basic_version(self):
|
||||
"""Version.parse() should parse basic version string."""
|
||||
from ring_installer.utils.version import Version
|
||||
|
||||
v = Version.parse("1.2.3")
|
||||
|
||||
assert v.major == 1
|
||||
assert v.minor == 2
|
||||
assert v.patch == 3
|
||||
assert v.prerelease == ""
|
||||
assert v.build == ""
|
||||
|
||||
def test_parse_with_v_prefix(self):
|
||||
"""Version.parse() should handle 'v' prefix."""
|
||||
from ring_installer.utils.version import Version
|
||||
|
||||
v = Version.parse("v2.0.0")
|
||||
|
||||
assert v.major == 2
|
||||
assert v.minor == 0
|
||||
assert v.patch == 0
|
||||
|
||||
def test_parse_with_prerelease(self):
|
||||
"""Version.parse() should parse prerelease identifier."""
|
||||
from ring_installer.utils.version import Version
|
||||
|
||||
v = Version.parse("1.0.0-alpha.1")
|
||||
|
||||
assert v.major == 1
|
||||
assert v.prerelease == "alpha.1"
|
||||
|
||||
def test_parse_with_build(self):
|
||||
"""Version.parse() should parse build metadata."""
|
||||
from ring_installer.utils.version import Version
|
||||
|
||||
v = Version.parse("1.0.0+build.123")
|
||||
|
||||
assert v.build == "build.123"
|
||||
|
||||
def test_parse_full_version(self):
|
||||
"""Version.parse() should parse version with prerelease and build."""
|
||||
from ring_installer.utils.version import Version
|
||||
|
||||
v = Version.parse("1.0.0-beta.2+build.456")
|
||||
|
||||
assert v.prerelease == "beta.2"
|
||||
assert v.build == "build.456"
|
||||
|
||||
def test_parse_invalid_raises(self):
|
||||
"""Version.parse() should raise ValueError for invalid version."""
|
||||
from ring_installer.utils.version import Version
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
Version.parse("invalid")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
Version.parse("1.2") # Missing patch
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
Version.parse("1.2.3.4") # Too many parts
|
||||
|
||||
|
||||
class TestVersionComparison:
|
||||
"""Tests for Version comparison operations."""
|
||||
|
||||
def test_equal_versions(self):
|
||||
"""Equal versions should compare as equal."""
|
||||
from ring_installer.utils.version import Version
|
||||
|
||||
v1 = Version.parse("1.0.0")
|
||||
v2 = Version.parse("1.0.0")
|
||||
|
||||
assert v1 == v2
|
||||
|
||||
def test_major_comparison(self):
|
||||
"""Major version should be compared first."""
|
||||
from ring_installer.utils.version import Version
|
||||
|
||||
assert Version.parse("2.0.0") > Version.parse("1.9.9")
|
||||
assert Version.parse("1.0.0") < Version.parse("2.0.0")
|
||||
|
||||
def test_minor_comparison(self):
|
||||
"""Minor version should be compared when major is equal."""
|
||||
from ring_installer.utils.version import Version
|
||||
|
||||
assert Version.parse("1.2.0") > Version.parse("1.1.9")
|
||||
assert Version.parse("1.1.0") < Version.parse("1.2.0")
|
||||
|
||||
def test_patch_comparison(self):
|
||||
"""Patch version should be compared when major and minor are equal."""
|
||||
from ring_installer.utils.version import Version
|
||||
|
||||
assert Version.parse("1.0.2") > Version.parse("1.0.1")
|
||||
assert Version.parse("1.0.0") < Version.parse("1.0.1")
|
||||
|
||||
def test_prerelease_lower_than_release(self):
|
||||
"""Prerelease versions should be lower than release versions."""
|
||||
from ring_installer.utils.version import Version
|
||||
|
||||
assert Version.parse("1.0.0-alpha") < Version.parse("1.0.0")
|
||||
assert Version.parse("1.0.0") > Version.parse("1.0.0-rc.1")
|
||||
|
||||
def test_prerelease_comparison(self):
|
||||
"""Prerelease identifiers should be compared alphabetically."""
|
||||
from ring_installer.utils.version import Version
|
||||
|
||||
assert Version.parse("1.0.0-alpha") < Version.parse("1.0.0-beta")
|
||||
assert Version.parse("1.0.0-alpha.1") < Version.parse("1.0.0-alpha.2")
|
||||
|
||||
def test_comparison_operators(self):
|
||||
"""All comparison operators should work."""
|
||||
from ring_installer.utils.version import Version
|
||||
|
||||
v1 = Version.parse("1.0.0")
|
||||
v2 = Version.parse("1.0.1")
|
||||
|
||||
assert v1 < v2
|
||||
assert v1 <= v2
|
||||
assert v1 <= v1
|
||||
assert v2 > v1
|
||||
assert v2 >= v1
|
||||
assert v1 >= v1
|
||||
|
||||
|
||||
class TestVersionString:
|
||||
"""Tests for Version string representation."""
|
||||
|
||||
def test_str_basic(self):
|
||||
"""Version should convert to basic version string."""
|
||||
from ring_installer.utils.version import Version
|
||||
|
||||
v = Version(1, 2, 3)
|
||||
assert str(v) == "1.2.3"
|
||||
|
||||
def test_str_with_prerelease(self):
|
||||
"""Version should include prerelease in string."""
|
||||
from ring_installer.utils.version import Version
|
||||
|
||||
v = Version(1, 0, 0, prerelease="alpha")
|
||||
assert str(v) == "1.0.0-alpha"
|
||||
|
||||
def test_str_with_build(self):
|
||||
"""Version should include build metadata in string."""
|
||||
from ring_installer.utils.version import Version
|
||||
|
||||
v = Version(1, 0, 0, build="123")
|
||||
assert str(v) == "1.0.0+123"
|
||||
|
||||
|
||||
class TestVersionMethods:
|
||||
"""Tests for Version utility methods."""
|
||||
|
||||
def test_is_prerelease(self):
|
||||
"""is_prerelease() should correctly identify prerelease versions."""
|
||||
from ring_installer.utils.version import Version
|
||||
|
||||
assert Version.parse("1.0.0-alpha").is_prerelease() is True
|
||||
assert Version.parse("1.0.0").is_prerelease() is False
|
||||
|
||||
def test_bump_major(self):
|
||||
"""bump_major() should increment major version."""
|
||||
from ring_installer.utils.version import Version
|
||||
|
||||
v = Version.parse("1.2.3")
|
||||
bumped = v.bump_major()
|
||||
|
||||
assert str(bumped) == "2.0.0"
|
||||
|
||||
def test_bump_minor(self):
|
||||
"""bump_minor() should increment minor version."""
|
||||
from ring_installer.utils.version import Version
|
||||
|
||||
v = Version.parse("1.2.3")
|
||||
bumped = v.bump_minor()
|
||||
|
||||
assert str(bumped) == "1.3.0"
|
||||
|
||||
def test_bump_patch(self):
|
||||
"""bump_patch() should increment patch version."""
|
||||
from ring_installer.utils.version import Version
|
||||
|
||||
v = Version.parse("1.2.3")
|
||||
bumped = v.bump_patch()
|
||||
|
||||
assert str(bumped) == "1.2.4"
|
||||
|
||||
|
||||
class TestCompareVersions:
|
||||
"""Tests for compare_versions() function."""
|
||||
|
||||
def test_compare_versions(self, version_test_cases):
|
||||
"""compare_versions() should correctly compare versions."""
|
||||
from ring_installer.utils.version import compare_versions
|
||||
|
||||
for v1, v2, expected in version_test_cases:
|
||||
result = compare_versions(v1, v2)
|
||||
assert result == expected, f"compare_versions({v1}, {v2}) = {result}, expected {expected}"
|
||||
|
||||
|
||||
class TestIsUpdateAvailable:
|
||||
"""Tests for is_update_available() function."""
|
||||
|
||||
def test_update_available(self):
|
||||
"""is_update_available() should return True when update available."""
|
||||
from ring_installer.utils.version import is_update_available
|
||||
|
||||
assert is_update_available("1.0.0", "1.0.1") is True
|
||||
assert is_update_available("1.0.0", "2.0.0") is True
|
||||
|
||||
def test_no_update_available(self):
|
||||
"""is_update_available() should return False when up to date."""
|
||||
from ring_installer.utils.version import is_update_available
|
||||
|
||||
assert is_update_available("1.0.0", "1.0.0") is False
|
||||
assert is_update_available("2.0.0", "1.0.0") is False
|
||||
|
||||
|
||||
class TestInstallManifest:
|
||||
"""Tests for InstallManifest class."""
|
||||
|
||||
def test_create_manifest(self):
|
||||
"""InstallManifest.create() should create manifest with defaults."""
|
||||
from ring_installer.utils.version import InstallManifest
|
||||
|
||||
manifest = InstallManifest.create(
|
||||
version="1.0.0",
|
||||
source_path="/path/to/ring",
|
||||
platform="claude"
|
||||
)
|
||||
|
||||
assert manifest.version == "1.0.0"
|
||||
assert manifest.source_path == "/path/to/ring"
|
||||
assert manifest.platform == "claude"
|
||||
assert manifest.installed_at != "" # Should have timestamp
|
||||
|
||||
def test_to_dict(self):
|
||||
"""InstallManifest.to_dict() should convert to dictionary."""
|
||||
from ring_installer.utils.version import InstallManifest
|
||||
|
||||
manifest = InstallManifest.create(
|
||||
version="1.0.0",
|
||||
source_path="/path",
|
||||
platform="claude",
|
||||
plugins=["default"],
|
||||
files={"a.md": "hash"}
|
||||
)
|
||||
|
||||
data = manifest.to_dict()
|
||||
|
||||
assert data["version"] == "1.0.0"
|
||||
assert data["plugins"] == ["default"]
|
||||
assert data["files"]["a.md"] == "hash"
|
||||
|
||||
def test_from_dict(self):
|
||||
"""InstallManifest.from_dict() should create from dictionary."""
|
||||
from ring_installer.utils.version import InstallManifest
|
||||
|
||||
data = {
|
||||
"version": "2.0.0",
|
||||
"installed_at": "2024-01-01T00:00:00",
|
||||
"source_path": "/path",
|
||||
"platform": "cursor",
|
||||
"plugins": ["test"],
|
||||
"files": {},
|
||||
"metadata": {}
|
||||
}
|
||||
|
||||
manifest = InstallManifest.from_dict(data)
|
||||
|
||||
assert manifest.version == "2.0.0"
|
||||
assert manifest.platform == "cursor"
|
||||
|
||||
def test_save_and_load(self, tmp_path):
|
||||
"""InstallManifest should save and load from file."""
|
||||
from ring_installer.utils.version import InstallManifest
|
||||
|
||||
manifest = InstallManifest.create(
|
||||
version="1.0.0",
|
||||
source_path="/path",
|
||||
platform="claude",
|
||||
plugins=["default"],
|
||||
files={"test.md": "abc123"}
|
||||
)
|
||||
|
||||
manifest_path = tmp_path / ".ring-manifest.json"
|
||||
manifest.save(manifest_path)
|
||||
|
||||
loaded = InstallManifest.load(manifest_path)
|
||||
|
||||
assert loaded is not None
|
||||
assert loaded.version == "1.0.0"
|
||||
assert loaded.files["test.md"] == "abc123"
|
||||
|
||||
def test_load_nonexistent_returns_none(self, tmp_path):
|
||||
"""InstallManifest.load() should return None for missing file."""
|
||||
from ring_installer.utils.version import InstallManifest
|
||||
|
||||
result = InstallManifest.load(tmp_path / "nonexistent.json")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestGetRingVersion:
|
||||
"""Tests for get_ring_version() function."""
|
||||
|
||||
def test_from_marketplace_json(self, tmp_ring_root):
|
||||
"""get_ring_version() should read from marketplace.json."""
|
||||
from ring_installer.utils.version import get_ring_version
|
||||
|
||||
version = get_ring_version(tmp_ring_root)
|
||||
|
||||
assert version == "1.2.3"
|
||||
|
||||
def test_from_version_file(self, tmp_path):
|
||||
"""get_ring_version() should read from VERSION file."""
|
||||
from ring_installer.utils.version import get_ring_version
|
||||
|
||||
(tmp_path / "VERSION").write_text("2.0.0")
|
||||
|
||||
version = get_ring_version(tmp_path)
|
||||
|
||||
assert version == "2.0.0"
|
||||
|
||||
def test_returns_none_if_not_found(self, tmp_path):
|
||||
"""get_ring_version() should return None if no version found."""
|
||||
from ring_installer.utils.version import get_ring_version
|
||||
|
||||
version = get_ring_version(tmp_path)
|
||||
|
||||
assert version is None
|
||||
|
||||
|
||||
class TestGetManifestPath:
|
||||
"""Tests for get_manifest_path() function."""
|
||||
|
||||
def test_returns_manifest_path(self, tmp_path):
|
||||
"""get_manifest_path() should return .ring-manifest.json path."""
|
||||
from ring_installer.utils.version import get_manifest_path
|
||||
|
||||
path = get_manifest_path(tmp_path)
|
||||
|
||||
assert path == tmp_path / ".ring-manifest.json"
|
||||
|
||||
|
||||
class TestCheckForUpdates:
|
||||
"""Tests for check_for_updates() function."""
|
||||
|
||||
def test_detects_update_available(self, tmp_ring_root, tmp_install_dir):
|
||||
"""check_for_updates() should detect when update is available."""
|
||||
from ring_installer.utils.version import (
|
||||
check_for_updates,
|
||||
InstallManifest,
|
||||
get_manifest_path
|
||||
)
|
||||
|
||||
# Create old manifest in install dir
|
||||
old_manifest = InstallManifest.create(
|
||||
version="1.0.0", # Older than tmp_ring_root's 1.2.3
|
||||
source_path=str(tmp_ring_root),
|
||||
platform="claude"
|
||||
)
|
||||
old_manifest.save(get_manifest_path(tmp_install_dir))
|
||||
|
||||
result = check_for_updates(tmp_ring_root, tmp_install_dir, "claude")
|
||||
|
||||
assert result.update_available is True
|
||||
assert result.is_newer is True
|
||||
assert result.installed_version == "1.0.0"
|
||||
assert result.available_version == "1.2.3"
|
||||
|
||||
def test_no_update_when_same_version(self, tmp_ring_root, tmp_install_dir):
|
||||
"""check_for_updates() should detect no update when versions match."""
|
||||
from ring_installer.utils.version import (
|
||||
check_for_updates,
|
||||
InstallManifest,
|
||||
get_manifest_path
|
||||
)
|
||||
|
||||
# Create manifest with same version
|
||||
manifest = InstallManifest.create(
|
||||
version="1.2.3",
|
||||
source_path=str(tmp_ring_root),
|
||||
platform="claude"
|
||||
)
|
||||
manifest.save(get_manifest_path(tmp_install_dir))
|
||||
|
||||
result = check_for_updates(tmp_ring_root, tmp_install_dir, "claude")
|
||||
|
||||
assert result.update_available is False
|
||||
assert result.is_newer is False
|
||||
|
||||
|
||||
class TestSaveInstallManifest:
|
||||
"""Tests for save_install_manifest() function."""
|
||||
|
||||
def test_saves_manifest(self, tmp_path):
|
||||
"""save_install_manifest() should create manifest file."""
|
||||
from ring_installer.utils.version import (
|
||||
save_install_manifest,
|
||||
get_manifest_path,
|
||||
InstallManifest
|
||||
)
|
||||
|
||||
manifest = save_install_manifest(
|
||||
install_path=tmp_path,
|
||||
source_path=Path("/source"),
|
||||
platform="cursor",
|
||||
version="1.0.0",
|
||||
plugins=["default"],
|
||||
installed_files={"a.md": "hash1"}
|
||||
)
|
||||
|
||||
# Verify file was created
|
||||
manifest_path = get_manifest_path(tmp_path)
|
||||
assert manifest_path.exists()
|
||||
|
||||
# Verify content
|
||||
loaded = InstallManifest.load(manifest_path)
|
||||
assert loaded.version == "1.0.0"
|
||||
assert loaded.platform == "cursor"
|
||||
Loading…
Reference in a new issue