diff --git a/.ralph/.gitignore b/.ralph/.gitignore deleted file mode 100644 index 1659e93..0000000 --- a/.ralph/.gitignore +++ /dev/null @@ -1,13 +0,0 @@ -# Ralph execution artifacts - do not commit -logs/ -archive/ -progress.json -status.json -.claude_session_id - -# Keep templates and specs -!PROMPT.md -!@fix_plan.md -!@AGENT.md -!specs/ -.ralph_session_history diff --git a/.ralph/@AGENT.md b/.ralph/@AGENT.md deleted file mode 100644 index 85d47a3..0000000 --- a/.ralph/@AGENT.md +++ /dev/null @@ -1,182 +0,0 @@ -# Agent Build Instructions - -## Tech Stack -- **Runtime**: Bun (latest stable) -- **Language**: TypeScript (strict mode) -- **CLI Framework**: Commander.js -- **Testing**: Bun test + BATS for integration tests - -## Project Setup -```bash -# Install Bun (if not already installed) -curl -fsSL https://bun.sh/install | bash - -# Install dependencies -bun install - -# Build TypeScript -bun run build -``` - -## Running Tests -```bash -# Run all tests -bun test - -# Run unit tests only -bun test src/ - -# Run with coverage -bun test --coverage - -# Run integration tests (BATS) -bun run test:integration -# or -bats tests/integration/*.bats - -# Watch mode for development -bun test --watch -``` - -## Build Commands -```bash -# Development build -bun run build - -# Type checking -bun run typecheck - -# Lint code -bun run lint - -# Format code -bun run format -``` - -## Development Workflow -```bash -# Run CLI locally during development -bun run src/cli/index.ts workspace init ./test-workspace - -# Link for global usage (optional) -bun link -allagents workspace init ./test-workspace -``` - -## Key Learnings -- Bun is significantly faster than Node.js for test execution -- Use `bun build` for bundling CLI into single executable -- BATS tests should cover full CLI workflows end-to-end -- Keep unit tests fast; use integration tests for file I/O heavy operations - -## Feature Development Quality Standards - -**CRITICAL**: All new features MUST meet the following mandatory requirements before being considered complete. - -### Testing Requirements - -- **Minimum Coverage**: 85% code coverage ratio required for all new code -- **Test Pass Rate**: 100% - all tests must pass, no exceptions -- **Test Types Required**: - - Unit tests for all business logic and services - - Integration tests for API endpoints or main functionality - - End-to-end tests for critical user workflows -- **Coverage Validation**: Run coverage reports before marking features complete: - ```bash - # Examples by language/framework - npm run test:coverage - pytest --cov=src tests/ --cov-report=term-missing - cargo tarpaulin --out Html - ``` -- **Test Quality**: Tests must validate behavior, not just achieve coverage metrics -- **Test Documentation**: Complex test scenarios must include comments explaining the test strategy - -### Git Workflow Requirements - -Before moving to the next feature, ALL changes must be: - -1. **Committed with Clear Messages**: - ```bash - git add . - git commit -m "feat(module): descriptive message following conventional commits" - ``` - - Use conventional commit format: `feat:`, `fix:`, `docs:`, `test:`, `refactor:`, etc. - - Include scope when applicable: `feat(api):`, `fix(ui):`, `test(auth):` - - Write descriptive messages that explain WHAT changed and WHY - -2. **Pushed to Remote Repository**: - ```bash - git push origin - ``` - - Never leave completed features uncommitted - - Push regularly to maintain backup and enable collaboration - - Ensure CI/CD pipelines pass before considering feature complete - -3. **Branch Hygiene**: - - Work on feature branches, never directly on `main` - - Branch naming convention: `feature/`, `fix/`, `docs/` - - Create pull requests for all significant changes - -4. **Ralph Integration**: - - Update .ralph/@fix_plan.md with new tasks before starting work - - Mark items complete in .ralph/@fix_plan.md upon completion - - Update .ralph/PROMPT.md if development patterns change - - Test features work within Ralph's autonomous loop - -### Documentation Requirements - -**ALL implementation documentation MUST remain synchronized with the codebase**: - -1. **Code Documentation**: - - Language-appropriate documentation (JSDoc, docstrings, etc.) - - Update inline comments when implementation changes - - Remove outdated comments immediately - -2. **Implementation Documentation**: - - Update relevant sections in this AGENT.md file - - Keep build and test commands current - - Update configuration examples when defaults change - - Document breaking changes prominently - -3. **README Updates**: - - Keep feature lists current - - Update setup instructions when dependencies change - - Maintain accurate command examples - - Update version compatibility information - -4. **AGENT.md Maintenance**: - - Add new build patterns to relevant sections - - Update "Key Learnings" with new insights - - Keep command examples accurate and tested - - Document new testing patterns or quality gates - -### Feature Completion Checklist - -Before marking ANY feature as complete, verify: - -- [ ] All tests pass with appropriate framework command -- [ ] Code coverage meets 85% minimum threshold -- [ ] Coverage report reviewed for meaningful test quality -- [ ] Code formatted according to project standards -- [ ] Type checking passes (if applicable) -- [ ] All changes committed with conventional commit messages -- [ ] All commits pushed to remote repository -- [ ] .ralph/@fix_plan.md task marked as complete -- [ ] Implementation documentation updated -- [ ] Inline code comments updated or added -- [ ] .ralph/@AGENT.md updated (if new patterns introduced) -- [ ] Breaking changes documented -- [ ] Features tested within Ralph loop (if applicable) -- [ ] CI/CD pipeline passes - -### Rationale - -These standards ensure: -- **Quality**: High test coverage and pass rates prevent regressions -- **Traceability**: Git commits and .ralph/@fix_plan.md provide clear history of changes -- **Maintainability**: Current documentation reduces onboarding time and prevents knowledge loss -- **Collaboration**: Pushed changes enable team visibility and code review -- **Reliability**: Consistent quality gates maintain production stability -- **Automation**: Ralph integration ensures continuous development practices - -**Enforcement**: AI agents should automatically apply these standards to all feature development tasks without requiring explicit instruction for each task. diff --git a/.ralph/@fix_plan.md b/.ralph/@fix_plan.md deleted file mode 100644 index 1041135..0000000 --- a/.ralph/@fix_plan.md +++ /dev/null @@ -1,147 +0,0 @@ -# Ralph Fix Plan - -## Architecture Fix: Align with Claude Plugin Convention (HIGH PRIORITY) - -Based on `claude plugin --help`, the industry standard architecture is: - -### Claude's Architecture (Reference) -``` -~/.claude/plugins/ -├── known_marketplaces.json # Registered marketplaces -├── installed_plugins.json # Installed plugins with versions -├── marketplaces/ # Cloned marketplace repos -│ └── / -└── cache/ # Installed plugin copies - └── /// -``` - -**Key Concepts:** -- **Marketplace**: A source repo containing multiple plugins (GitHub or local directory) -- **Plugin**: Identified as `plugin@marketplace` (e.g., `context7@claude-plugins-official`) -- **Scope**: `user` (global), `project` (per-project), or `local` - -### Our Target Architecture -``` -~/.allagents/ -├── marketplaces.json # Registered marketplaces -├── marketplaces/ # Cloned marketplace repos -│ └── / -└── installed/ # Installed plugin copies (optional, for version tracking) -``` - -**For workspace.yaml:** -```yaml -plugins: - - context7@claude-plugins-official # plugin@marketplace format - - code-review@claude-plugins-official - - my-plugin@my-local-marketplace -``` - ---- - -## Phase A: Refactor CLI Commands to Match Claude Convention -- [x] Create `plugin marketplace` subcommand group - - [x] `allagents plugin marketplace list` - list registered marketplaces - - [x] `allagents plugin marketplace add ` - add from URL/path/GitHub - - [x] `allagents plugin marketplace remove ` - remove a marketplace - - [x] `allagents plugin marketplace update [name]` - update marketplace(s) -- [x] Refactor plugin commands - - [x] `allagents plugin list [marketplace]` - list available plugins - - [x] `allagents plugin validate ` - validate plugin/marketplace (stub - full impl deferred) -- [x] Refactor workspace plugin commands (was `workspace add/remove`) - - [x] `allagents workspace plugin add ` - add plugin to workspace.yaml - - [x] `allagents workspace plugin remove ` - remove plugin from workspace.yaml -- [x] Remove deprecated commands - - [x] Remove `plugin fetch` (replaced by `plugin marketplace add`) - - [x] Remove old `plugin update` (now `plugin marketplace update`) - - [x] Remove `workspace add` (now `workspace plugin add`) - - [x] Remove `workspace remove` (now `workspace plugin remove`) - -## Phase B: Update Data Model -- [x] Create `~/.allagents/marketplaces.json` for registry - ```json - { - "claude-plugins-official": { - "source": { "type": "github", "repo": "anthropics/claude-plugins-official" }, - "installLocation": "~/.allagents/marketplaces/claude-plugins-official", - "lastUpdated": "2026-01-23T..." - } - } - ``` -- [x] Update plugin resolution (implemented in `src/core/marketplace.ts` instead of plugin-path.ts) - - [x] Parse `plugin@marketplace` format (`isPluginSpec`, `resolvePluginSpec`) - - [x] Resolve marketplace by name from registry (`getMarketplace`) - - [x] Find plugin within marketplace directory (`resolvePluginSpec`) -- [x] Create marketplace registry functions in `src/core/marketplace.ts` - - [x] `addMarketplace(source)` - add to registry and clone/link - - [x] `removeMarketplace(name)` - remove from registry - - [x] `listMarketplaces()` - list from registry - - [x] `updateMarketplace(name?)` - git pull or re-sync - - [x] `getMarketplacePath(name)` - get local path - -## Phase C: Update Plugin Resolution -- [x] Create `src/core/marketplace.ts` functions (in marketplace.ts, not plugin.ts) - - [x] `listMarketplacePlugins(marketplace)` - enumerate plugins from marketplace - - [x] `resolvePluginSpec(spec)` - resolve `plugin@marketplace` to local path - - [x] `validatePlugin(path)` - validate plugin structure (exists as `validateSkill` in transform.ts) -- [x] Update workspace.yaml format - - [x] Support `plugin@marketplace` syntax - - [x] Support shorthand (assumes default marketplace if unambiguous) - N/A, use full spec - -## Phase D: Update Workspace Sync & Auto-Registration -- [x] Modify `src/core/sync.ts` - - [x] Parse plugin specs as `plugin@marketplace` - - [x] Ensure marketplace is registered/cloned - - [x] Resolve plugin path within marketplace - - [x] Copy plugin content to workspace -- [x] Implement auto-registration in workspace sync - - [x] Accept `plugin@marketplace` format - - [x] If marketplace unknown: - - [x] Known names (e.g., `claude-plugins-official`) → auto-register from well-known GitHub - - [x] Full spec (`plugin@owner/repo`) → auto-register GitHub marketplace - - [x] Otherwise → error with helpful message - - [x] Add marketplace to registry, then resolve plugin -- [x] Create well-known marketplaces config - - [x] `claude-plugins-official` → `anthropics/claude-plugins-official` - - [x] Extensible for future additions (WELL_KNOWN_MARKETPLACES constant) - -## Phase E: Update Tests (LOW PRIORITY - per testing guidelines) -- [ ] Update unit tests for new plugin path format (deferred) -- [ ] Add tests for marketplace registry functions (deferred) -- [ ] Add tests for `plugin@marketplace` resolution (deferred) - -NOTE: Core implementation is complete. Tests deferred per guidelines (~20% effort max). - -## Phase F: Update Documentation -- [x] Update README.md with new CLI structure -- [x] Update example workspace.yaml files with new format -- [x] Update `.ralph/PROMPT.md` with new architecture -- [x] Update `.ralph/specs/requirements.md` with new architecture -- [x] Add attribution to dotagents - ---- - -## Notes -- Follow `claude plugin marketplace list` subcommand convention exactly -- Use `plugin@marketplace` naming convention for plugins -- Marketplaces are registered sources, not just cached repos -- Local directory marketplaces don't need cloning, just registry entry -- GitHub marketplaces get cloned to `~/.allagents/marketplaces//` - -### Auto-Registration Behavior -When adding a plugin with unknown marketplace: -``` -# Known marketplace name → auto-registers from well-known GitHub repo -workspace plugin add code-review@claude-plugins-official -→ Auto-registers anthropics/claude-plugins-official - -# Fully qualified name → auto-registers the GitHub repo -workspace plugin add my-plugin@someuser/their-repo -→ Auto-registers someuser/their-repo as "their-repo" - -# Unknown short name → error -workspace plugin add my-plugin@unknown-marketplace -→ Error: Marketplace 'unknown-marketplace' not found. - Use fully qualified name: my-plugin@owner/repo - Or register first: allagents plugin marketplace add -``` diff --git a/.ralph/PROMPT.md b/.ralph/PROMPT.md deleted file mode 100644 index a3f9f15..0000000 --- a/.ralph/PROMPT.md +++ /dev/null @@ -1,176 +0,0 @@ -# Ralph Development Instructions - -## Context -You are Ralph, an autonomous AI development agent working on **allagents** - a CLI tool for managing multi-repo AI agent workspaces with plugin synchronization. - -## Project Overview -allagents is similar to [dotagents](https://github.com/iannuttall/dotagents) but with key differences: -- Multi-repo workspace support via `workspace.yaml` -- Remote plugin fetching from GitHub URLs -- File copying instead of symlinks (workspaces are git repos) -- Non-interactive CLI subcommands instead of TUI - -## Current Objectives -1. **FIX ARCHITECTURE**: Align with `claude plugin` convention - - Implement `plugin marketplace` subcommands (list/add/remove/update) - - Use `plugin@marketplace` naming convention - - Create marketplace registry at `~/.allagents/marketplaces.json` -2. Update workspace sync to resolve `plugin@marketplace` specs -3. Support 8 AI clients (Claude, Copilot, Codex, Cursor, OpenCode, Gemini, Factory, Ampcode) -4. Implement skill validation with YAML frontmatter parsing -5. Handle file transformations for different clients - -## Key Principles -- ONE task per loop - focus on the most important thing -- Search the codebase before assuming something isn't implemented -- Use subagents for expensive operations (file searching, analysis) -- Write comprehensive tests with clear documentation -- Update .ralph/@fix_plan.md with your learnings -- Commit working changes with descriptive messages - -## Testing Guidelines (CRITICAL) -- LIMIT testing to ~20% of your total effort per loop -- PRIORITIZE: Implementation > Documentation > Tests -- Only write tests for NEW functionality you implement -- Do NOT refactor existing tests unless broken -- Focus on CORE functionality first, comprehensive testing later -- Use `bats` for CLI integration tests - -## Technical Requirements - -### CLI Structure (Aligned with `claude plugin` convention) -``` -# Workspace commands -allagents workspace init # Create workspace from current template -allagents workspace sync # Sync plugins to workspace (fetch + copy) -allagents workspace status # Show sync status of plugins -allagents workspace plugin add # Add plugin to workspace.yaml -allagents workspace plugin remove # Remove plugin from workspace.yaml - -# Plugin marketplace commands (matches `claude plugin marketplace`) -allagents plugin marketplace list # List registered marketplaces -allagents plugin marketplace add # Add marketplace from URL/path/GitHub -allagents plugin marketplace remove # Remove a marketplace -allagents plugin marketplace update [name] # Update marketplace(s) from remote - -# Plugin commands -allagents plugin list [marketplace] # List plugins from marketplaces -allagents plugin validate # Validate plugin/marketplace structure -``` - -### Architecture (Aligned with Claude Code) - -**Reference: Claude's plugin structure** -``` -~/.claude/plugins/ -├── known_marketplaces.json # Registered marketplaces -├── installed_plugins.json # Installed plugins with versions -├── marketplaces/ # Cloned marketplace repos -│ └── / -└── cache/ # Installed plugin copies - └── /// -``` - -**Our structure:** -``` -~/.allagents/ -├── marketplaces.json # Registered marketplaces -└── marketplaces/ # Cloned marketplace repos - └── / -``` - -### Key Concepts -- **Marketplace**: A source repo containing multiple plugins (GitHub or local directory) - - GitHub: `anthropics/claude-plugins-official` → cloned to `~/.allagents/marketplaces/claude-plugins-official/` - - Local: `/path/to/my-plugins` → registered but not cloned -- **Plugin**: Identified as `plugin@marketplace` (e.g., `code-review@claude-plugins-official`) -- **Plugin path**: Plugins live in `plugins//` within a marketplace - -### workspace.yaml Plugin Format -```yaml -plugins: - - code-review@claude-plugins-official # plugin@marketplace syntax - - context7@claude-plugins-official - - my-skill@local-marketplace -``` - -### Plugin Resolution Flow -1. Parse `plugin@marketplace` (e.g., `code-review@claude-plugins-official`) -2. Look up marketplace in `~/.allagents/marketplaces.json` -3. If not found, attempt auto-registration: - - Known name (e.g., `claude-plugins-official`) → fetch from well-known GitHub repo - - Full spec (`plugin@owner/repo`) → fetch from GitHub `owner/repo` - - Unknown short name → error with helpful message -4. Resolve plugin path: `/plugins//` -5. Copy plugin content to workspace - -### Well-Known Marketplaces -These marketplace names auto-resolve to their GitHub repos: -- `claude-plugins-official` → `anthropics/claude-plugins-official` - -### Client Path Mappings -| Client | Commands Path | Skills Path | Agent File | Hooks | -|--------|---------------|-------------|------------|-------| -| Claude | `.claude/commands/` | `.claude/skills/` | `CLAUDE.md` | `.claude/hooks/` | -| Copilot | `.github/prompts/*.prompt.md` | `.github/skills/` | `AGENTS.md` | No | -| Codex | `.codex/prompts/` | `.codex/skills/` | `AGENTS.md` | No | -| Cursor | `.cursor/commands/` | `.cursor/skills/` | No | No | -| OpenCode | `.opencode/commands/` | `.opencode/skills/` | `AGENTS.md` | No | -| Gemini | `.gemini/commands/` | `.gemini/skills/` | `GEMINI.md` | No | -| Factory | `.factory/commands/` | `.factory/skills/` | `AGENTS.md` | `.factory/hooks/` | -| Ampcode | N/A | N/A | `AGENTS.md` | No | - -### Marketplace Registry -- Config file: `~/.allagents/marketplaces.json` -- GitHub marketplaces cloned to: `~/.allagents/marketplaces//` -- Local directory marketplaces: just registered, not cloned -- Fetched via `gh` CLI for GitHub sources -- Plugin content at: `/plugins//` - -### Skill Validation -Skills must have valid `SKILL.md` with YAML frontmatter: -- `name`: lowercase, alphanumeric + hyphens, max 64 chars (required) -- `description`: non-empty string (required) -- `allowed-tools`: array of tool names (optional) -- `model`: model identifier string (optional) - -## Success Criteria -1. `allagents plugin marketplace add/list/remove/update` manage marketplace registry -2. `allagents plugin list` shows plugins from registered marketplaces -3. `allagents workspace plugin add/remove` manage workspace.yaml with auto-registration -4. `allagents workspace init` creates workspace with correct structure -5. `allagents workspace sync` resolves `plugin@marketplace` specs and copies content -6. All 8 clients receive correctly transformed files -7. Agent files (CLAUDE.md, GEMINI.md, AGENTS.md) created with workspace rules appended -8. Skills validated before copying -9. Git commits created after sync with metadata - -## Current Task -Follow .ralph/@fix_plan.md and choose the most important item to implement next. -Use your judgment to prioritize what will have the biggest impact on project progress. - -## Status Reporting (CRITICAL - Ralph needs this!) - -**IMPORTANT**: At the end of your response, ALWAYS include this status block: - -``` ----RALPH_STATUS--- -STATUS: IN_PROGRESS | COMPLETE | BLOCKED -TASKS_COMPLETED_THIS_LOOP: -FILES_MODIFIED: -TESTS_STATUS: PASSING | FAILING | NOT_RUN -WORK_TYPE: IMPLEMENTATION | TESTING | DOCUMENTATION | REFACTORING -EXIT_SIGNAL: false | true -RECOMMENDATION: ----END_RALPH_STATUS--- -``` - -### When to set EXIT_SIGNAL: true -Set EXIT_SIGNAL to **true** when ALL of these conditions are met: -1. All items in @fix_plan.md are marked [x] -2. All tests are passing -3. No errors or warnings in the last execution -4. All requirements from specs/ are implemented -5. You have nothing meaningful left to implement - -Remember: Quality over speed. Build it right the first time. Know when you're done. diff --git a/.ralph/specs/requirements.md b/.ralph/specs/requirements.md deleted file mode 100644 index 1da338f..0000000 --- a/.ralph/specs/requirements.md +++ /dev/null @@ -1,529 +0,0 @@ -# Technical Requirements Specification - -## Tech Stack - -- **Runtime**: Bun (latest stable) -- **Language**: TypeScript (strict mode) -- **CLI Framework**: Commander.js or similar -- **Testing**: Bun test + BATS for integration tests -- **YAML Parser**: js-yaml -- **Git Operations**: simple-git or direct shell commands -- **GitHub API**: Octokit or gh CLI wrapper - -## Project Structure - -``` -allagents/ -├── src/ -│ ├── cli/ -│ │ ├── commands/ -│ │ │ ├── workspace.ts # workspace subcommands (init, sync, status, plugin) -│ │ │ └── plugin.ts # plugin subcommands (marketplace, list, validate) -│ │ └── index.ts # CLI entry point -│ ├── core/ -│ │ ├── workspace.ts # Workspace operations -│ │ ├── marketplace.ts # Marketplace registry operations -│ │ ├── plugin.ts # Plugin resolution and listing -│ │ ├── sync.ts # Sync logic -│ │ └── transform.ts # File transformations -│ ├── models/ -│ │ ├── workspace-config.ts # workspace.yaml types -│ │ ├── marketplace.ts # Marketplace registry types -│ │ ├── plugin-config.ts # plugin.json types -│ │ └── client-mapping.ts # Client path mappings -│ ├── validators/ -│ │ └── skill.ts # Skill YAML validation -│ └── utils/ -│ ├── file.ts # File operations -│ ├── git.ts # Git operations -│ ├── github.ts # GitHub fetching -│ └── plugin-spec.ts # Parse plugin@marketplace specs -│ └── templates/ -│ └── default/ -│ ├── AGENTS.md -│ └── workspace.yaml -├── examples/ -│ └── workspaces/ -│ └── multi-repo/ -│ └── workspace.yaml -├── tests/ -│ ├── unit/ -│ └── integration/ # BATS tests -├── package.json -├── tsconfig.json -└── bun.lockb -``` - -## Core Data Structures - -### WorkspaceConfig (workspace.yaml) - -```typescript -interface WorkspaceConfig { - repositories: Repository[]; - plugins: PluginSpec[]; // plugin@marketplace format - clients: ClientType[]; -} - -interface Repository { - path: string; // Relative or absolute path - owner: string; // GitHub owner - repo: string; // GitHub repo name - description: string; // Description -} - -// Plugin spec format: "plugin-name@marketplace-name" -// Examples: -// - "code-review@claude-plugins-official" -// - "my-plugin@someuser/their-repo" (fully qualified for unknown marketplaces) -type PluginSpec = string; - -type ClientType = - | 'claude' - | 'copilot' - | 'codex' - | 'cursor' - | 'opencode' - | 'gemini' - | 'factory' - | 'ampcode'; -``` - -### MarketplaceRegistry (~/.allagents/marketplaces.json) - -```typescript -interface MarketplaceRegistry { - [name: string]: MarketplaceEntry; -} - -interface MarketplaceEntry { - source: MarketplaceSource; - installLocation: string; // Absolute path to marketplace - lastUpdated: string; // ISO timestamp -} - -type MarketplaceSource = - | { type: 'github'; repo: string } // e.g., "anthropics/claude-plugins-official" - | { type: 'directory'; path: string }; // Local directory path - -// Well-known marketplaces (auto-resolve by name) -const WELL_KNOWN_MARKETPLACES: Record = { - 'claude-plugins-official': 'anthropics/claude-plugins-official', -}; -``` - -### PluginManifest (plugin.json) - -```typescript -interface PluginManifest { - name: string; - version: string; - description: string; - author?: string; - license?: string; -} -``` - -### SkillMetadata (SKILL.md frontmatter) - -```typescript -interface SkillMetadata { - name: string; // Required: lowercase, alphanumeric + hyphens, max 64 chars - description: string; // Required: non-empty string - 'allowed-tools'?: string[]; // Optional: array of tool names - model?: string; // Optional: model identifier -} -``` - -### ClientMapping - -```typescript -interface ClientMapping { - commandsPath: string; // e.g., '.claude/commands/' - commandsExt: string; // e.g., '.md' or '.prompt.md' - skillsPath: string; // e.g., '.claude/skills/' - agentFile: string; // e.g., 'CLAUDE.md' or 'AGENTS.md' - agentFileFallback?: string; // e.g., 'AGENTS.md' for CLAUDE.md - hooksPath?: string; // Optional: e.g., '.claude/hooks/' -} - -const CLIENT_MAPPINGS: Record = { - claude: { - commandsPath: '.claude/commands/', - commandsExt: '.md', - skillsPath: '.claude/skills/', - agentFile: 'CLAUDE.md', - agentFileFallback: 'AGENTS.md', - hooksPath: '.claude/hooks/', - }, - copilot: { - commandsPath: '.github/prompts/', - commandsExt: '.prompt.md', - skillsPath: '.github/skills/', - agentFile: 'AGENTS.md', - }, - // ... other clients -}; -``` - -## Command Specifications - -### `allagents workspace init ` - -**Purpose**: Create new workspace from template - -**Behavior**: -1. Validate path doesn't already exist -2. Create directory structure -3. Copy template files from `src/templates/default/` -4. Convert relative plugin paths to absolute paths in workspace.yaml -5. Initialize git repository -6. Create initial commit - -**Exit codes**: -- 0: Success -- 1: Path already exists -- 2: Template not found -- 3: Git initialization failed - -### `allagents workspace sync` - -**Purpose**: Sync plugins to workspace - -**Behavior**: -1. Read workspace.yaml from current directory -2. For each plugin spec (`plugin@marketplace`): - - Parse plugin name and marketplace name - - Look up marketplace in `~/.allagents/marketplaces.json` - - If marketplace not found → error (user must add it first or use auto-registration via `workspace plugin add`) - - Resolve plugin path: `/plugins//` -3. For each client in workspace.yaml: - - Determine target paths from CLIENT_MAPPINGS - - Copy commands with extension transform - - Copy skills with validation - - Copy hooks (if client supports) - - Create/update agent file with workspace rules appended -4. Create git commit with sync metadata - -**Exit codes**: -- 0: Success -- 1: workspace.yaml not found -- 2: Marketplace not found -- 3: Plugin not found in marketplace -- 4: Validation failed -- 5: File operation failed -- 6: Git commit failed - -**Flags**: -- `--force`: Overwrite local changes -- `--dry-run`: Preview changes without applying - -### `allagents workspace status` - -**Purpose**: Show sync status of plugins - -**Behavior**: -1. Read workspace.yaml -2. Check cache status for remote plugins -3. Check file timestamps for local plugins -4. Display table with plugin name, source, last synced, status - -### `allagents workspace plugin add ` - -**Purpose**: Add plugin to workspace.yaml with auto-registration - -**Behavior**: -1. Parse `plugin@marketplace` format -2. Check if marketplace is registered in `~/.allagents/marketplaces.json` -3. If not registered, attempt auto-registration: - - Known name (e.g., `claude-plugins-official`) → fetch from well-known GitHub repo - - Full spec (e.g., `plugin@owner/repo`) → fetch from GitHub `owner/repo` - - Unknown short name → error with helpful message -4. Verify plugin exists in marketplace: `/plugins//` -5. Add to plugins list in workspace.yaml -6. Optionally run sync - -**Exit codes**: -- 0: Success -- 1: Invalid plugin spec format -- 2: Marketplace not found and cannot auto-register -- 3: Plugin not found in marketplace -- 4: workspace.yaml not found - -### `allagents workspace plugin remove ` - -**Purpose**: Remove plugin from workspace.yaml - -**Behavior**: -1. Remove plugin from plugins list in workspace.yaml -2. Optionally clean up synced files - -### `allagents plugin marketplace list` - -**Purpose**: List registered marketplaces - -**Behavior**: -1. Read `~/.allagents/marketplaces.json` -2. Display table with marketplace name, source type, path, last updated - -### `allagents plugin marketplace add ` - -**Purpose**: Add marketplace from URL, path, or GitHub repo - -**Behavior**: -1. Determine source type: - - Local path → register as directory source - - GitHub repo (owner/repo) → clone to `~/.allagents/marketplaces//` -2. Validate marketplace structure (must contain `plugins/` directory) -3. Add entry to `~/.allagents/marketplaces.json` - -**Exit codes**: -- 0: Success -- 1: Invalid source -- 2: Clone/fetch failed -- 3: Invalid marketplace structure - -### `allagents plugin marketplace remove ` - -**Purpose**: Remove a registered marketplace - -**Behavior**: -1. Remove entry from `~/.allagents/marketplaces.json` -2. Optionally delete cloned directory (with confirmation) - -### `allagents plugin marketplace update [name]` - -**Purpose**: Update marketplace(s) from remote - -**Behavior**: -1. If name provided: update specific marketplace -2. If no name: update all GitHub-sourced marketplaces -3. Use `git pull` for GitHub sources -4. Update `lastUpdated` timestamp in registry - -### `allagents plugin list [marketplace]` - -**Purpose**: List available plugins from marketplaces - -**Behavior**: -1. If marketplace provided: list plugins from that marketplace -2. If no marketplace: list plugins from all registered marketplaces -3. Enumerate `plugins/*/` directories in each marketplace -4. Display table with plugin name, marketplace, description (from plugin.json) - -### `allagents plugin validate ` - -**Purpose**: Validate plugin or marketplace structure - -**Behavior**: -1. If path contains `plugins/`: validate as marketplace -2. Otherwise: validate as single plugin -3. Check required files (plugin.json, SKILL.md in skills) -4. Validate YAML frontmatter in skills -5. Report validation errors - -## File Transformation Rules - -### Command Files - -**Source**: `plugin/commands/*.md` - -**Transformations**: -- Claude: Copy as-is to `.claude/commands/*.md` -- Copilot: Rename to `.github/prompts/*.prompt.md` -- Codex: Copy to `.codex/prompts/*.md` -- Others: Copy to client-specific path with `.md` extension - -### Skill Directories - -**Source**: `plugin/skills//` - -**Transformations**: -1. Validate SKILL.md has valid YAML frontmatter -2. Validate required fields (name, description) -3. Copy entire skill directory to client-specific skills path -4. Preserve directory structure (references/, scripts/, assets/) - -**Validation Rules**: -- Name: lowercase, alphanumeric + hyphens, max 64 chars -- Description: non-empty string -- allowed-tools: array of strings (if present) -- model: string (if present) - -### Hooks - -**Source**: `plugin/hooks/*.md` - -**Transformations**: -- Only copy for Claude and Factory clients -- Copy to `.claude/hooks/` or `.factory/hooks/` - -### Agent Files - -**Source**: `plugin/CLAUDE.md`, `plugin/GEMINI.md`, `plugin/AGENTS.md` - -**Transformations**: -1. Determine which agent files to create based on clients list -2. Use source precedence: - - CLAUDE.md → CLAUDE.md (if exists), fallback to AGENTS.md - - GEMINI.md → GEMINI.md (if exists), fallback to AGENTS.md - - AGENTS.md → AGENTS.md -3. Append workspace rules section from template - -**Workspace Rules Template**: -```markdown - -# Workspace Rules - -## Rule: Workspace Discovery -TRIGGER: Any task -ACTION: Read `workspace.yaml` to get repository paths and project domains - -## Rule: Correct Repository Paths -TRIGGER: File operations (read, search, modify) -ACTION: Use repository paths from `workspace.yaml`, not assumptions - -``` - -## Validation Requirements - -### workspace.yaml Validation - -- Must be valid YAML -- repositories: array of Repository objects -- plugins: array of strings -- clients: array of valid ClientType strings - -### Skill Validation - -- SKILL.md must exist -- Must have YAML frontmatter (--- delimited) -- name field: required, lowercase, alphanumeric + hyphens, max 64 chars -- description field: required, non-empty string -- allowed-tools: optional array of strings -- model: optional string - -### Plugin Structure Validation - -- commands/ directory (optional) -- skills/ directory (optional) -- hooks/ directory (optional) -- At least one of commands/, skills/, or hooks/ must exist -- plugin.json must exist and be valid JSON - -## Error Handling - -### User-Facing Errors - -- Clear error messages with actionable guidance -- Exit codes that indicate error category -- Suggest fixes when possible - -### Examples - -``` -Error: workspace.yaml not found in current directory - Run 'allagents workspace init ' to create a new workspace - -Error: Invalid skill name 'MySkill' in plugin 'my-plugin' - Skill names must be lowercase with hyphens only (e.g., 'my-skill') - -Error: Failed to fetch plugin from GitHub - Check that you have 'gh' CLI installed and authenticated - Run: gh auth login -``` - -## Git Operations - -### Sync Commit Message Format - -``` -sync: Update workspace from plugins - -Synced plugins: -- plugin-name-1 (local) -- plugin-name-2 (github.com/owner/repo) - -Timestamp: 2026-01-22T10:30:00Z -``` - -### Initial Workspace Commit - -``` -init: Create workspace from template - -Created workspace at: /path/to/workspace -Template: default -``` - -## Performance Considerations - -- Marketplaces cached at `~/.allagents/marketplaces//` -- Registry stored at `~/.allagents/marketplaces.json` -- Only clone marketplace if not already registered -- Use `git pull` for updates (incremental) -- Use parallel operations where possible (file copying) -- Stream large files instead of loading into memory - -## Testing Requirements - -### Unit Tests (Bun test) - -- workspace.yaml parsing and validation -- Plugin spec parsing (`plugin@marketplace` format) -- Marketplace registry operations -- Skill YAML frontmatter validation -- File transformation logic -- Client mapping lookups -- Path resolution - -### Integration Tests (BATS) - -- Full workspace init flow -- Full workspace sync flow with `plugin@marketplace` specs -- Marketplace add/list/remove/update commands -- Auto-registration of unknown marketplaces -- Plugin list from marketplaces -- File transformations end-to-end -- Git commit creation -- Error handling scenarios - -### Test Coverage Target - -- Minimum 85% code coverage -- 100% test pass rate required -- Critical paths require integration tests - -## Dependencies - -```json -{ - "dependencies": { - "commander": "^11.x", - "js-yaml": "^4.x", - "simple-git": "^3.x", - "zod": "^3.x" - }, - "devDependencies": { - "@types/bun": "latest", - "@types/js-yaml": "^4.x", - "bats": "^1.x", - "typescript": "^5.x" - } -} -``` - -## Success Metrics - -1. `plugin marketplace add/list/remove/update` manage marketplace registry -2. `plugin list` shows available plugins from registered marketplaces -3. `workspace plugin add` supports auto-registration of unknown marketplaces -4. `workspace plugin add/remove` manage workspace.yaml plugins -5. Can create workspace from template -6. Can sync plugins using `plugin@marketplace` format -7. All 8 clients receive correctly transformed files -8. Skills are validated before copying -9. Agent files created with workspace rules appended -10. Git commits created after each sync -11. 85%+ test coverage -12. All integration tests passing diff --git a/README.md b/README.md index 6cd84c4..895cdd9 100644 --- a/README.md +++ b/README.md @@ -79,11 +79,16 @@ allagents workspace sync # Initialize a new workspace from template allagents workspace init -# Sync all plugins to workspace +# Sync all plugins to workspace (non-destructive) allagents workspace sync [options] - --force Force overwrite of local changes + --force Force re-fetch of remote plugins even if cached --dry-run Preview changes without applying +# Non-destructive sync: your files are safe +# - First sync overlays without deleting existing files +# - Subsequent syncs only remove files AllAgents previously synced +# - Tracked in .allagents/sync-state.json + # Show status of workspace and plugins allagents workspace status diff --git a/docs/plans/non-destructive-sync.md b/docs/plans/non-destructive-sync.md new file mode 100644 index 0000000..392c22a --- /dev/null +++ b/docs/plans/non-destructive-sync.md @@ -0,0 +1,139 @@ +# Plan: Non-Destructive Sync with State Tracking + +## Problem + +Currently `allagents sync` uses a destructive approach: +1. Purges ALL managed directories (`rm -rf .claude/commands/`, etc.) +2. Copies fresh from plugins + +This breaks existing workspaces with user-created files alongside plugin content. + +## Solution + +Track which files allagents syncs in a state file. Only purge files we previously created. + +**Model:** +| File type | Behavior | +|-----------|----------| +| Files in sync-state | Purge and recreate | +| User files (not in state) | Leave alone | +| Agent files (CLAUDE.md, AGENTS.md) | Merge: preserve user content, update WORKSPACE-RULES section | + +## Implementation + +### 1. Create State Model (`src/models/sync-state.ts`) - NEW + +```typescript +export interface SyncState { + version: 1; + lastSync: string; // ISO timestamp + files: Record; // per-client file list +} +``` + +### 2. Create State Utilities (`src/core/sync-state.ts`) - NEW + +- `getSyncStatePath(workspacePath)` → `.allagents/sync-state.json` +- `loadSyncState(workspacePath)` → `SyncState | null` +- `saveSyncState(workspacePath, files)` → writes state file +- `getPreviouslySyncedFiles(state, client)` → `string[]` + +### 3. Add Selective Purge (`src/core/sync.ts`) - MODIFY + +Add `selectivePurgeWorkspace()` function: +- Takes previous state +- If no state (first sync), skip purge entirely +- Otherwise, only delete files listed in state +- Use `unlink()` for files, `rmdir()` for empty directories +- Clean up empty parent directories after deletion + +### 4. Fix WORKSPACE-RULES Injection (`src/core/transform.ts`) - MODIFY + +Current code (line 463): `appendFile(targetPath, WORKSPACE_RULES)` - NOT idempotent + +Fix: Add `injectWorkspaceRules(filePath)` function: +- Read file content +- If markers exist (`...END -->`): replace between markers +- If no markers: append with markers +- Idempotent - safe to run multiple times + +Update `copyWorkspaceFiles()` to use new function instead of `appendFile()`. + +### 5. Track Files During Copy (`src/core/sync.ts`) - MODIFY + +Add `collectSyncedPaths()` function: +- Extract destination paths from `CopyResult[]` +- Group by client based on path prefix (`.claude/` → claude, `.github/` → copilot) +- Handle skill directories (track with trailing `/`) + +### 6. Update Main Sync Flow (`src/core/sync.ts:syncWorkspace`) - MODIFY + +``` +Before: + validate → purge ALL → copy → commit + +After: + validate → load state → selective purge → copy → save state → commit +``` + +Key changes at lines 419-424: +- Load previous state before purge +- Replace `purgeWorkspace()` with `selectivePurgeWorkspace()` +- After successful copy, call `saveSyncState()` + +### 7. Add Constant (`src/constants.ts`) - MODIFY + +```typescript +export const SYNC_STATE_FILE = 'sync-state.json'; +``` + +## Files to Modify + +| File | Change | +|------|--------| +| `src/models/sync-state.ts` | NEW - State schema with Zod | +| `src/core/sync-state.ts` | NEW - Load/save state utilities | +| `src/core/sync.ts` | Add selective purge, update sync flow | +| `src/core/transform.ts` | Fix idempotent WORKSPACE-RULES injection | +| `src/constants.ts` | Add SYNC_STATE_FILE constant | +| `tests/unit/core/sync-state.test.ts` | NEW - State utilities tests | +| `tests/unit/core/sync.test.ts` | Add non-destructive sync tests | + +## Edge Cases + +1. **First sync on existing workspace**: No state file → skip purge → overlay only +2. **Corrupted state file**: Treat as no state → safe behavior +3. **Skill directories**: Track with trailing `/` to distinguish from files +4. **Plugin removed from config**: Its files purged on next sync (in state) +5. **User adds file after sync**: Not in state → preserved + +## Verification + +1. **Unit tests**: Run `bun test tests/unit/core/sync-state.test.ts` +2. **Integration test**: + ```bash + # Create workspace with user file + mkdir -p test-ws/.claude/commands + echo "# User" > test-ws/.claude/commands/user.md + echo "# Plugin" > test-ws/.claude/commands/plugin.md + + # Create workspace.yaml with empty plugins + mkdir test-ws/.allagents + echo "repositories: []\nplugins: []\nclients: [claude]" > test-ws/.allagents/workspace.yaml + + # First sync - should preserve user.md + allagents workspace sync test-ws + ls test-ws/.claude/commands/ # user.md should exist + + # Check state file created + cat test-ws/.allagents/sync-state.json + ``` +3. **WORKSPACE-RULES idempotency**: + ```bash + # Run sync twice on same workspace + allagents workspace sync test-ws + allagents workspace sync test-ws + + # Check CLAUDE.md has exactly ONE WORKSPACE-RULES section + grep -c "WORKSPACE-RULES:START" test-ws/CLAUDE.md # Should be 1 + ``` diff --git a/docs/src/content/docs/guides/workspaces.mdx b/docs/src/content/docs/guides/workspaces.mdx index e3b403e..74ca51d 100644 --- a/docs/src/content/docs/guides/workspaces.mdx +++ b/docs/src/content/docs/guides/workspaces.mdx @@ -27,3 +27,28 @@ clients: - copilot - cursor ``` + +## Syncing Plugins + +```bash +allagents workspace sync +``` + +### Non-Destructive Sync + +AllAgents uses **non-destructive sync** to protect your files: + +- **First sync**: Overlays plugin files without deleting existing files +- **Subsequent syncs**: Only removes files that AllAgents previously synced + +This means your personal commands, skills, or customizations in `.claude/commands/` etc. are never deleted - only files that came from plugins are managed. + +AllAgents tracks synced files in `.allagents/sync-state.json`. This file is automatically created and updated on each sync. + +### Dry Run + +Preview what would happen without making changes: + +```bash +allagents workspace sync --dry-run +``` diff --git a/docs/src/content/docs/reference/cli.mdx b/docs/src/content/docs/reference/cli.mdx index 068ccf7..69f92a4 100644 --- a/docs/src/content/docs/reference/cli.mdx +++ b/docs/src/content/docs/reference/cli.mdx @@ -13,6 +13,22 @@ allagents workspace plugin add allagents workspace plugin remove ``` +### workspace sync + +Syncs plugins to the workspace using non-destructive sync: + +| Flag | Description | +|------|-------------| +| `--force`, `-f` | Force re-fetch of remote plugins even if cached | +| `--dry-run` | Preview changes without applying them | + +**Non-destructive behavior:** +- First sync overlays files without deleting existing user files +- Subsequent syncs only remove files previously synced by AllAgents +- User files (not from plugins) are never deleted + +Sync state is tracked in `.allagents/sync-state.json`. + ## Plugin Commands ```bash diff --git a/src/cli/commands/workspace.ts b/src/cli/commands/workspace.ts index 0b5b1d1..9f12edb 100644 --- a/src/cli/commands/workspace.ts +++ b/src/cli/commands/workspace.ts @@ -15,7 +15,7 @@ workspaceCommand .action(async (path: string | undefined, options: { from?: string }) => { try { const targetPath = path ?? '.'; - const result = await initWorkspace(targetPath, { from: options.from }); + const result = await initWorkspace(targetPath, options.from ? { from: options.from } : {}); // Print sync results if sync was performed if (result.syncResult) { diff --git a/src/constants.ts b/src/constants.ts index 7566fbc..1ac2927 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -3,6 +3,11 @@ */ export const CONFIG_DIR = '.allagents'; +/** + * Sync state filename (tracks which files were synced) + */ +export const SYNC_STATE_FILE = 'sync-state.json'; + /** * Workspace config filename */ diff --git a/src/core/sync-state.ts b/src/core/sync-state.ts new file mode 100644 index 0000000..e046151 --- /dev/null +++ b/src/core/sync-state.ts @@ -0,0 +1,83 @@ +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { CONFIG_DIR, SYNC_STATE_FILE } from '../constants.js'; +import { SyncStateSchema, type SyncState } from '../models/sync-state.js'; +import type { ClientType } from '../models/workspace-config.js'; + +/** + * Get the path to the sync state file + * @param workspacePath - Path to workspace directory + * @returns Path to .allagents/sync-state.json + */ +export function getSyncStatePath(workspacePath: string): string { + return join(workspacePath, CONFIG_DIR, SYNC_STATE_FILE); +} + +/** + * Load sync state from disk + * Returns null if file doesn't exist or is corrupted (safe behavior) + * @param workspacePath - Path to workspace directory + * @returns Parsed sync state or null + */ +export async function loadSyncState(workspacePath: string): Promise { + const statePath = getSyncStatePath(workspacePath); + + if (!existsSync(statePath)) { + return null; + } + + try { + const content = await readFile(statePath, 'utf-8'); + const parsed = JSON.parse(content); + const result = SyncStateSchema.safeParse(parsed); + + if (!result.success) { + // Corrupted state file - treat as no state (safe behavior) + return null; + } + + return result.data; + } catch { + // Read or parse error - treat as no state + return null; + } +} + +/** + * Save sync state to disk + * @param workspacePath - Path to workspace directory + * @param files - Per-client file lists that were synced + */ +export async function saveSyncState( + workspacePath: string, + files: Partial>, +): Promise { + const statePath = getSyncStatePath(workspacePath); + + const state: SyncState = { + version: 1, + lastSync: new Date().toISOString(), + files: files as Record, + }; + + await mkdir(dirname(statePath), { recursive: true }); + await writeFile(statePath, JSON.stringify(state, null, 2), 'utf-8'); +} + +/** + * Get files that were previously synced for a specific client + * @param state - Loaded sync state (or null) + * @param client - Client type to get files for + * @returns Array of file paths, empty if no state or no files for client + */ +export function getPreviouslySyncedFiles( + state: SyncState | null, + client: ClientType, +): string[] { + if (!state) { + return []; + } + + return state.files[client] ?? []; +} diff --git a/src/core/sync.ts b/src/core/sync.ts index 251a467..415c2e5 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -1,6 +1,6 @@ import { existsSync } from 'node:fs'; -import { rm } from 'node:fs/promises'; -import { join, resolve } from 'node:path'; +import { rm, unlink, rmdir } from 'node:fs/promises'; +import { join, resolve, dirname, relative } from 'node:path'; import { CONFIG_DIR, WORKSPACE_CONFIG_FILE } from '../constants.js'; import simpleGit from 'simple-git'; import { parseWorkspaceConfig } from '../utils/workspace-parser.js'; @@ -24,6 +24,12 @@ import { addMarketplace, getWellKnownMarketplaces, } from './marketplace.js'; +import { + loadSyncState, + saveSyncState, + getPreviouslySyncedFiles, +} from './sync-state.js'; +import type { SyncState } from '../models/sync-state.js'; /** * Result of a sync operation @@ -186,6 +192,150 @@ export function getPurgePaths( return result; } +/** + * Selectively purge only files that were previously synced + * Non-destructive: preserves user-created files + * @param workspacePath - Path to workspace directory + * @param state - Previous sync state (if null, skips purge entirely) + * @param clients - List of clients to purge files for + * @returns List of paths that were purged per client + */ +export async function selectivePurgeWorkspace( + workspacePath: string, + state: SyncState | null, + clients: ClientType[], +): Promise { + // First sync - no state, skip purge entirely (safe overlay) + if (!state) { + return []; + } + + const result: PurgePaths[] = []; + + for (const client of clients) { + const previousFiles = getPreviouslySyncedFiles(state, client); + const purgedPaths: string[] = []; + + // Delete each previously synced file + for (const filePath of previousFiles) { + const fullPath = join(workspacePath, filePath); + + if (!existsSync(fullPath)) { + continue; + } + + try { + // Check if it's a directory (skill directories end with /) + if (filePath.endsWith('/')) { + await rm(fullPath, { recursive: true, force: true }); + } else { + await unlink(fullPath); + } + purgedPaths.push(filePath); + + // Clean up empty parent directories + await cleanupEmptyParents(workspacePath, filePath); + } catch { + // Best effort - continue with other files + } + } + + if (purgedPaths.length > 0) { + result.push({ client, paths: purgedPaths }); + } + } + + return result; +} + +/** + * Clean up empty parent directories after file deletion + * Stops at workspace root + */ +async function cleanupEmptyParents(workspacePath: string, filePath: string): Promise { + let parentPath = dirname(filePath); + + while (parentPath && parentPath !== '.' && parentPath !== '/') { + const fullParentPath = join(workspacePath, parentPath); + + if (!existsSync(fullParentPath)) { + parentPath = dirname(parentPath); + continue; + } + + try { + // rmdir only works on empty directories - will throw if not empty + await rmdir(fullParentPath); + parentPath = dirname(parentPath); + } catch { + // Directory not empty or other error - stop climbing + break; + } + } +} + +/** + * Collect synced file paths from copy results, grouped by client + * @param copyResults - Array of copy results from plugins + * @param workspacePath - Path to workspace directory + * @param clients - List of clients to track + * @returns Per-client file lists + */ +export function collectSyncedPaths( + copyResults: CopyResult[], + workspacePath: string, + clients: ClientType[], +): Partial> { + const result: Partial> = {}; + + // Initialize arrays for each client + for (const client of clients) { + result[client] = []; + } + + for (const copyResult of copyResults) { + if (copyResult.action !== 'copied' && copyResult.action !== 'generated') { + continue; + } + + // Get relative path from workspace + const relativePath = relative(workspacePath, copyResult.destination); + + // Determine which client this file belongs to + for (const client of clients) { + const mapping = CLIENT_MAPPINGS[client]; + + // Check if this is a skill directory (copy results for skills point to the dir) + const isSkillDir = + mapping.skillsPath && + (relativePath === mapping.skillsPath.replace(/\/$/, '') || + relativePath.startsWith(mapping.skillsPath)); + + // Track skill directories with trailing / + if (isSkillDir && !relativePath.includes('/')) { + // This is the skill directory itself + result[client]?.push(`${relativePath}/`); + break; + } + + // Check if file belongs to this client's paths + if ( + (mapping.commandsPath && relativePath.startsWith(mapping.commandsPath)) || + (mapping.skillsPath && relativePath.startsWith(mapping.skillsPath)) || + (mapping.hooksPath && relativePath.startsWith(mapping.hooksPath)) || + (mapping.agentsPath && relativePath.startsWith(mapping.agentsPath)) || + relativePath === mapping.agentFile || + (mapping.agentFileFallback && relativePath === mapping.agentFileFallback) + ) { + result[client]?.push(relativePath); + break; + } + } + } + + return result; +} + /** * Validate a single plugin by resolving its path without copying * @param pluginSource - Plugin source @@ -415,12 +565,23 @@ export async function syncWorkspace( }; } - // Step 2: Get paths that will be purged (for dry-run reporting) - const purgedPaths = getPurgePaths(workspacePath, config.clients); - - // Step 3: Purge managed directories (skip in dry-run mode) + // Step 2: Load previous sync state for selective purge + const previousState = await loadSyncState(workspacePath); + + // Step 2b: Get paths that will be purged (for dry-run reporting) + // In non-destructive mode, only show files from state (or nothing on first sync) + const purgedPaths = previousState + ? config.clients + .map((client) => ({ + client, + paths: getPreviouslySyncedFiles(previousState, client), + })) + .filter((p) => p.paths.length > 0) + : []; + + // Step 3: Selective purge - only remove files we previously synced (skip in dry-run mode) if (!dryRun) { - await purgeWorkspace(workspacePath, config.clients); + await selectivePurgeWorkspace(workspacePath, previousState, config.clients); } // Step 4: Copy fresh from all validated plugins @@ -483,6 +644,19 @@ export async function syncWorkspace( const hasFailures = pluginResults.some((r) => !r.success) || totalFailed > 0; + // Step 6: Save sync state with all copied files (skip in dry-run mode) + if (!hasFailures && !dryRun) { + // Collect all copy results + const allCopyResults: CopyResult[] = [ + ...pluginResults.flatMap((r) => r.copyResults), + ...workspaceFileResults, + ]; + + // Group by client and save state + const syncedFiles = collectSyncedPaths(allCopyResults, workspacePath, config.clients); + await saveSyncState(workspacePath, syncedFiles); + } + // Create git commit if successful (skip in dry-run mode) if (!hasFailures && !dryRun) { try { diff --git a/src/core/transform.ts b/src/core/transform.ts index 8529bb0..1fdac26 100644 --- a/src/core/transform.ts +++ b/src/core/transform.ts @@ -1,4 +1,4 @@ -import { readFile, writeFile, mkdir, cp, readdir, appendFile } from 'node:fs/promises'; +import { readFile, writeFile, mkdir, cp, readdir } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { resolveGlobPatterns, isGlobPattern } from '../utils/glob-patterns.js'; @@ -7,6 +7,31 @@ import type { ClientType, WorkspaceFile } from '../models/workspace-config.js'; import { validateSkill } from '../validators/skill.js'; import { WORKSPACE_RULES } from '../constants.js'; +/** + * Inject WORKSPACE-RULES into a file idempotently + * If markers exist, replaces content between markers + * If no markers, appends with markers + * @param filePath - Path to the agent file (CLAUDE.md or AGENTS.md) + */ +export async function injectWorkspaceRules(filePath: string): Promise { + const content = await readFile(filePath, 'utf-8'); + const startMarker = ''; + const endMarker = ''; + + const startIndex = content.indexOf(startMarker); + const endIndex = content.indexOf(endMarker); + + if (startIndex !== -1 && endIndex !== -1 && endIndex > startIndex) { + // Markers exist - replace content between them (including markers) + const before = content.substring(0, startIndex); + const after = content.substring(endIndex + endMarker.length); + await writeFile(filePath, before + WORKSPACE_RULES.trim() + after, 'utf-8'); + } else { + // No markers - append + await writeFile(filePath, content + WORKSPACE_RULES, 'utf-8'); + } +} + /** * Result of a file copy operation */ @@ -450,23 +475,24 @@ export async function copyWorkspaceFiles( } } - // Append WORKSPACE-RULES to the appropriate agent file - if (copiedAgentFiles.length > 0 && !dryRun) { - // If both files exist, append to AGENTS.md; otherwise append to whichever one exists + // Inject WORKSPACE-RULES into the appropriate agent file (idempotent) + const firstAgentFile = copiedAgentFiles[0]; + if (firstAgentFile && !dryRun) { + // If both files exist, inject into AGENTS.md; otherwise inject into whichever one exists const targetFile = copiedAgentFiles.includes('AGENTS.md') ? 'AGENTS.md' - : copiedAgentFiles[0]; + : firstAgentFile; const targetPath = join(workspacePath, targetFile); try { - await appendFile(targetPath, WORKSPACE_RULES, 'utf-8'); + await injectWorkspaceRules(targetPath); } catch (error) { results.push({ source: 'WORKSPACE-RULES', destination: targetPath, action: 'failed', - error: error instanceof Error ? error.message : 'Failed to append WORKSPACE-RULES', + error: error instanceof Error ? error.message : 'Failed to inject WORKSPACE-RULES', }); } } diff --git a/src/core/workspace.ts b/src/core/workspace.ts index 7cf5370..4321bcf 100644 --- a/src/core/workspace.ts +++ b/src/core/workspace.ts @@ -30,7 +30,7 @@ export interface InitResult { * @throws Error if path already exists or initialization fails */ export async function initWorkspace( - targetPath: string = '.', + targetPath = '.', options: InitOptions = {}, ): Promise { const absoluteTarget = resolve(targetPath); diff --git a/src/models/sync-state.ts b/src/models/sync-state.ts new file mode 100644 index 0000000..4e564a6 --- /dev/null +++ b/src/models/sync-state.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; +import { ClientTypeSchema } from './workspace-config.js'; + +/** + * Sync state schema - tracks which files were synced per client + * Used for non-destructive sync (only purge files we previously created) + */ +export const SyncStateSchema = z.object({ + version: z.literal(1), + lastSync: z.string(), // ISO timestamp + files: z.record(ClientTypeSchema, z.array(z.string())), +}); + +export type SyncState = z.infer; diff --git a/tests/unit/core/sync-state.test.ts b/tests/unit/core/sync-state.test.ts new file mode 100644 index 0000000..e53da0e --- /dev/null +++ b/tests/unit/core/sync-state.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtemp, rm, mkdir, writeFile, readFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + getSyncStatePath, + loadSyncState, + saveSyncState, + getPreviouslySyncedFiles, +} from '../../../src/core/sync-state.js'; +import { CONFIG_DIR, SYNC_STATE_FILE } from '../../../src/constants.js'; + +describe('sync-state', () => { + let testDir: string; + + beforeEach(async () => { + testDir = await mkdtemp(join(tmpdir(), 'allagents-sync-state-test-')); + }); + + afterEach(async () => { + await rm(testDir, { recursive: true, force: true }); + }); + + describe('getSyncStatePath', () => { + it('should return correct path', () => { + const result = getSyncStatePath(testDir); + expect(result).toBe(join(testDir, CONFIG_DIR, SYNC_STATE_FILE)); + }); + }); + + describe('loadSyncState', () => { + it('should return null when file does not exist', async () => { + const result = await loadSyncState(testDir); + expect(result).toBeNull(); + }); + + it('should return null on corrupted JSON', async () => { + await mkdir(join(testDir, CONFIG_DIR), { recursive: true }); + await writeFile(join(testDir, CONFIG_DIR, SYNC_STATE_FILE), 'not json'); + + const result = await loadSyncState(testDir); + expect(result).toBeNull(); + }); + + it('should return null on invalid schema', async () => { + await mkdir(join(testDir, CONFIG_DIR), { recursive: true }); + await writeFile( + join(testDir, CONFIG_DIR, SYNC_STATE_FILE), + JSON.stringify({ version: 999, invalid: true }), + ); + + const result = await loadSyncState(testDir); + expect(result).toBeNull(); + }); + + it('should load valid state file', async () => { + await mkdir(join(testDir, CONFIG_DIR), { recursive: true }); + const state = { + version: 1, + lastSync: '2024-01-01T00:00:00.000Z', + files: { + claude: ['.claude/commands/test.md'], + }, + }; + await writeFile( + join(testDir, CONFIG_DIR, SYNC_STATE_FILE), + JSON.stringify(state), + ); + + const result = await loadSyncState(testDir); + expect(result).not.toBeNull(); + expect(result!.version).toBe(1); + expect(result!.files.claude).toEqual(['.claude/commands/test.md']); + }); + }); + + describe('saveSyncState', () => { + it('should create state file with correct structure', async () => { + await saveSyncState(testDir, { + claude: ['.claude/commands/cmd1.md', '.claude/commands/cmd2.md'], + }); + + const statePath = join(testDir, CONFIG_DIR, SYNC_STATE_FILE); + expect(existsSync(statePath)).toBe(true); + + const content = await readFile(statePath, 'utf-8'); + const state = JSON.parse(content); + + expect(state.version).toBe(1); + expect(state.lastSync).toBeDefined(); + expect(state.files.claude).toEqual([ + '.claude/commands/cmd1.md', + '.claude/commands/cmd2.md', + ]); + }); + + it('should create config directory if it does not exist', async () => { + expect(existsSync(join(testDir, CONFIG_DIR))).toBe(false); + + await saveSyncState(testDir, { claude: [] }); + + expect(existsSync(join(testDir, CONFIG_DIR))).toBe(true); + }); + }); + + describe('getPreviouslySyncedFiles', () => { + it('should return empty array when state is null', () => { + const result = getPreviouslySyncedFiles(null, 'claude'); + expect(result).toEqual([]); + }); + + it('should return empty array when client has no files', () => { + const state = { + version: 1 as const, + lastSync: '2024-01-01T00:00:00.000Z', + files: {}, + }; + + const result = getPreviouslySyncedFiles(state, 'claude'); + expect(result).toEqual([]); + }); + + it('should return files for specific client', () => { + const state = { + version: 1 as const, + lastSync: '2024-01-01T00:00:00.000Z', + files: { + claude: ['.claude/commands/cmd1.md', '.claude/commands/cmd2.md'], + copilot: ['.github/prompts/cmd1.prompt.md'], + }, + }; + + expect(getPreviouslySyncedFiles(state, 'claude')).toEqual([ + '.claude/commands/cmd1.md', + '.claude/commands/cmd2.md', + ]); + expect(getPreviouslySyncedFiles(state, 'copilot')).toEqual([ + '.github/prompts/cmd1.prompt.md', + ]); + }); + }); +}); diff --git a/tests/unit/core/sync.test.ts b/tests/unit/core/sync.test.ts index 42d41cb..234201c 100644 --- a/tests/unit/core/sync.test.ts +++ b/tests/unit/core/sync.test.ts @@ -257,11 +257,12 @@ clients: const result = await syncWorkspace(testDir, { dryRun: true }); expect(result.success).toBe(true); - // Should have purge paths in result + // Should have purge paths in result (now shows individual files from state) expect(result.purgedPaths).toBeDefined(); expect(result.purgedPaths!.length).toBeGreaterThan(0); expect(result.purgedPaths![0].client).toBe('claude'); - expect(result.purgedPaths![0].paths).toContain('.claude/commands/'); + // Non-destructive sync tracks individual files, not directories + expect(result.purgedPaths![0].paths).toContain('.claude/commands/my-command.md'); // Files should still exist (dry-run doesn't purge) expect(existsSync(join(testDir, '.claude', 'commands', 'my-command.md'))).toBe(true); @@ -402,4 +403,207 @@ clients: expect(readmeContent).not.toContain('WORKSPACE-RULES'); }); }); + + describe('syncWorkspace - non-destructive sync', () => { + it('should preserve user files on first sync (no previous state)', async () => { + // Setup: User has existing files before first sync + await mkdir(join(testDir, '.claude', 'commands'), { recursive: true }); + await writeFile(join(testDir, '.claude', 'commands', 'user-command.md'), '# User Command'); + + // Setup: Create a plugin with a different command + const pluginDir = join(testDir, 'my-plugin'); + await mkdir(join(pluginDir, 'commands'), { recursive: true }); + await writeFile(join(pluginDir, 'commands', 'plugin-command.md'), '# Plugin Command'); + + // Setup: Create workspace config + await mkdir(join(testDir, CONFIG_DIR), { recursive: true }); + await writeFile( + join(testDir, CONFIG_DIR, WORKSPACE_CONFIG_FILE), + ` +repositories: [] +plugins: + - ./my-plugin +clients: + - claude +`, + ); + + // First sync - should overlay without purging user files + const result = await syncWorkspace(testDir); + expect(result.success).toBe(true); + + // User file should be preserved + expect(existsSync(join(testDir, '.claude', 'commands', 'user-command.md'))).toBe(true); + const userContent = await readFile( + join(testDir, '.claude', 'commands', 'user-command.md'), + 'utf-8', + ); + expect(userContent).toBe('# User Command'); + + // Plugin file should be copied + expect(existsSync(join(testDir, '.claude', 'commands', 'plugin-command.md'))).toBe(true); + }); + + it('should only remove previously synced files on subsequent sync', async () => { + // Setup: Create two plugins + const plugin1Dir = join(testDir, 'plugin1'); + const plugin2Dir = join(testDir, 'plugin2'); + await mkdir(join(plugin1Dir, 'commands'), { recursive: true }); + await mkdir(join(plugin2Dir, 'commands'), { recursive: true }); + await writeFile(join(plugin1Dir, 'commands', 'cmd1.md'), '# Command 1'); + await writeFile(join(plugin2Dir, 'commands', 'cmd2.md'), '# Command 2'); + + // Setup: User has their own command + await mkdir(join(testDir, '.claude', 'commands'), { recursive: true }); + await writeFile(join(testDir, '.claude', 'commands', 'user.md'), '# User'); + + // Setup: Create workspace config with both plugins + await mkdir(join(testDir, CONFIG_DIR), { recursive: true }); + await writeFile( + join(testDir, CONFIG_DIR, WORKSPACE_CONFIG_FILE), + ` +repositories: [] +plugins: + - ./plugin1 + - ./plugin2 +clients: + - claude +`, + ); + + // First sync + const result1 = await syncWorkspace(testDir); + expect(result1.success).toBe(true); + expect(existsSync(join(testDir, '.claude', 'commands', 'cmd1.md'))).toBe(true); + expect(existsSync(join(testDir, '.claude', 'commands', 'cmd2.md'))).toBe(true); + expect(existsSync(join(testDir, '.claude', 'commands', 'user.md'))).toBe(true); + + // Now remove plugin2 from config + await writeFile( + join(testDir, CONFIG_DIR, WORKSPACE_CONFIG_FILE), + ` +repositories: [] +plugins: + - ./plugin1 +clients: + - claude +`, + ); + + // Second sync - should remove cmd2.md but keep user.md + const result2 = await syncWorkspace(testDir); + expect(result2.success).toBe(true); + expect(existsSync(join(testDir, '.claude', 'commands', 'cmd1.md'))).toBe(true); + expect(existsSync(join(testDir, '.claude', 'commands', 'cmd2.md'))).toBe(false); + expect(existsSync(join(testDir, '.claude', 'commands', 'user.md'))).toBe(true); + }); + + it('should preserve user files added after initial sync', async () => { + // Setup: Create a plugin + const pluginDir = join(testDir, 'my-plugin'); + await mkdir(join(pluginDir, 'commands'), { recursive: true }); + await writeFile(join(pluginDir, 'commands', 'plugin.md'), '# Plugin'); + + // Setup: Create workspace config + await mkdir(join(testDir, CONFIG_DIR), { recursive: true }); + await writeFile( + join(testDir, CONFIG_DIR, WORKSPACE_CONFIG_FILE), + ` +repositories: [] +plugins: + - ./my-plugin +clients: + - claude +`, + ); + + // First sync + const result1 = await syncWorkspace(testDir); + expect(result1.success).toBe(true); + + // User adds a file AFTER initial sync + await writeFile(join(testDir, '.claude', 'commands', 'user-added.md'), '# User Added'); + + // Second sync - user file should be preserved + const result2 = await syncWorkspace(testDir); + expect(result2.success).toBe(true); + expect(existsSync(join(testDir, '.claude', 'commands', 'user-added.md'))).toBe(true); + expect(existsSync(join(testDir, '.claude', 'commands', 'plugin.md'))).toBe(true); + }); + + it('should create state file after sync', async () => { + // Setup: Create a plugin + const pluginDir = join(testDir, 'my-plugin'); + await mkdir(join(pluginDir, 'commands'), { recursive: true }); + await writeFile(join(pluginDir, 'commands', 'cmd.md'), '# Command'); + + // Setup: Create workspace config + await mkdir(join(testDir, CONFIG_DIR), { recursive: true }); + await writeFile( + join(testDir, CONFIG_DIR, WORKSPACE_CONFIG_FILE), + ` +repositories: [] +plugins: + - ./my-plugin +clients: + - claude +`, + ); + + // Sync + await syncWorkspace(testDir); + + // State file should exist + const statePath = join(testDir, CONFIG_DIR, 'sync-state.json'); + expect(existsSync(statePath)).toBe(true); + + // State should contain synced files + const stateContent = await readFile(statePath, 'utf-8'); + const state = JSON.parse(stateContent); + expect(state.version).toBe(1); + expect(state.files.claude).toContain('.claude/commands/cmd.md'); + }); + }); + + describe('syncWorkspace - WORKSPACE-RULES idempotency', () => { + it('should have exactly one WORKSPACE-RULES section after multiple syncs', async () => { + // Setup: Create workspace source with CLAUDE.md + const sourceDir = join(testDir, 'workspace-source'); + await mkdir(sourceDir, { recursive: true }); + await writeFile(join(sourceDir, 'CLAUDE.md'), '# My Project\n'); + + // Setup: Create workspace config + await mkdir(join(testDir, CONFIG_DIR), { recursive: true }); + await writeFile( + join(testDir, CONFIG_DIR, WORKSPACE_CONFIG_FILE), + ` +workspace: + source: ./workspace-source + files: + - CLAUDE.md +repositories: [] +plugins: [] +clients: + - claude +`, + ); + + // First sync + await syncWorkspace(testDir); + + // Second sync + await syncWorkspace(testDir); + + // Third sync + await syncWorkspace(testDir); + + // Check CLAUDE.md has exactly ONE WORKSPACE-RULES section + const content = await readFile(join(testDir, 'CLAUDE.md'), 'utf-8'); + const startCount = (content.match(//g) || []).length; + const endCount = (content.match(//g) || []).length; + + expect(startCount).toBe(1); + expect(endCount).toBe(1); + }); + }); });