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:
Fred Amaral 2025-11-27 18:20:53 -03:00
parent e10e3a4d03
commit e3ece79ccf
No known key found for this signature in database
GPG key ID: ADFE56C96F4AC56A
46 changed files with 11809 additions and 57 deletions

View file

@ -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
View file

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

View file

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

View file

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

View 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
View 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
View 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

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

View 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

View file

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

View 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())

View file

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

View file

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

View file

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

View 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",
]

View 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)

View 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)

View 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

View 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)

View 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

View 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 []

View 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
)

View file

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

View file

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

View 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()

View 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

Binary file not shown.

Binary file not shown.

Binary file not shown.

630
installer/tests/conftest.py Normal file
View 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}'"

View 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

View 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`

View 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"
}
]
}

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

View 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

File diff suppressed because it is too large Load diff

View 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

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