diff --git a/.claude/plans/2026-02-03-workspace-sync-both-scopes-design.md b/.claude/plans/2026-02-03-workspace-sync-both-scopes-design.md new file mode 100644 index 00000000..6692c386 --- /dev/null +++ b/.claude/plans/2026-02-03-workspace-sync-both-scopes-design.md @@ -0,0 +1,39 @@ +# Workspace Sync Both Scopes + +## Problem + +Users must run `workspace sync --scope user` separately to sync user-level plugins, creating friction. New users don't understand scopes and expect the command to "just work." + +## Design + +`workspace sync` syncs both user and project workspaces automatically with no `--scope` flag. + +### Behavior + +1. If `~/.allagents/workspace.yaml` exists, sync user workspace +2. If `.allagents/workspace.yaml` exists in cwd, sync project workspace +3. If both exist, sync both (user first, then project) +4. If neither exists, auto-create user config via `ensureUserWorkspace()` and print: + "No plugins configured. Run `allagents plugin install ` to get started." + +### CLI Changes + +- Remove `--scope` / `-s` option from `workspace sync` command +- No changes to other commands (`plugin install --scope user` etc. remain) + +### Output + +Label each sync phase. Only show phases that actually run. + +``` +Syncing user workspace... + ✓ synced 3 plugins +Syncing project workspace... + ✓ synced 2 plugins +``` + +## Key Files + +- `src/cli/commands/workspace.ts` — remove `--scope` from sync command, call both sync functions +- `src/core/sync.ts` — `syncWorkspace()` (project) and `syncUserWorkspace()` (user) +- `src/core/user-workspace.ts` — `ensureUserWorkspace()` for auto-creation diff --git a/docs/plans/2026-02-03-workspace-sync-both-scopes.md b/docs/plans/2026-02-03-workspace-sync-both-scopes.md new file mode 100644 index 00000000..9de15b08 --- /dev/null +++ b/docs/plans/2026-02-03-workspace-sync-both-scopes.md @@ -0,0 +1,495 @@ +# Workspace Sync Both Scopes Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Make `workspace sync` automatically sync both user and project workspaces without requiring `--scope`. + +**Architecture:** Remove the `--scope` flag from the sync command. The handler calls `syncUserWorkspace()` if `~/.allagents/workspace.yaml` exists, then calls `syncWorkspace()` if `.allagents/workspace.yaml` exists in cwd. If neither exists, auto-create user config and show guidance. + +**Tech Stack:** TypeScript, cmd-ts, bun:test + +--- + +### Task 1: Add `mergeSyncResults` helper + +**Files:** +- Modify: `src/core/sync.ts` (after line 57, after `SyncResult` interface) +- Test: `tests/unit/core/sync-merge.test.ts` + +**Step 1: Write the failing test** + +Create `tests/unit/core/sync-merge.test.ts`: + +```typescript +import { describe, expect, test } from 'bun:test'; +import { mergeSyncResults } from '../../src/core/sync.js'; +import type { SyncResult } from '../../src/core/sync.js'; + +describe('mergeSyncResults', () => { + test('merges two successful results', () => { + const a: SyncResult = { + success: true, + pluginResults: [{ plugin: 'a', resolved: '/a', success: true, copyResults: [] }], + totalCopied: 2, + totalFailed: 0, + totalSkipped: 0, + totalGenerated: 1, + }; + const b: SyncResult = { + success: true, + pluginResults: [{ plugin: 'b', resolved: '/b', success: true, copyResults: [] }], + totalCopied: 3, + totalFailed: 0, + totalSkipped: 1, + totalGenerated: 0, + }; + const merged = mergeSyncResults(a, b); + expect(merged.success).toBe(true); + expect(merged.pluginResults).toHaveLength(2); + expect(merged.totalCopied).toBe(5); + expect(merged.totalFailed).toBe(0); + expect(merged.totalSkipped).toBe(1); + expect(merged.totalGenerated).toBe(1); + }); + + test('merges when one has failures', () => { + const a: SyncResult = { + success: true, + pluginResults: [], + totalCopied: 1, + totalFailed: 0, + totalSkipped: 0, + totalGenerated: 0, + }; + const b: SyncResult = { + success: false, + pluginResults: [], + totalCopied: 0, + totalFailed: 1, + totalSkipped: 0, + totalGenerated: 0, + error: 'plugin failed', + }; + const merged = mergeSyncResults(a, b); + expect(merged.success).toBe(false); + expect(merged.totalCopied).toBe(1); + expect(merged.totalFailed).toBe(1); + }); + + test('merges warnings from both results', () => { + const a: SyncResult = { + success: true, + pluginResults: [], + totalCopied: 0, + totalFailed: 0, + totalSkipped: 0, + totalGenerated: 0, + warnings: ['warn1'], + }; + const b: SyncResult = { + success: true, + pluginResults: [], + totalCopied: 0, + totalFailed: 0, + totalSkipped: 0, + totalGenerated: 0, + warnings: ['warn2'], + }; + const merged = mergeSyncResults(a, b); + expect(merged.warnings).toEqual(['warn1', 'warn2']); + }); + + test('merges purgedPaths from both results', () => { + const a: SyncResult = { + success: true, + pluginResults: [], + totalCopied: 0, + totalFailed: 0, + totalSkipped: 0, + totalGenerated: 0, + purgedPaths: [{ client: 'claude', paths: ['/a'] }], + }; + const b: SyncResult = { + success: true, + pluginResults: [], + totalCopied: 0, + totalFailed: 0, + totalSkipped: 0, + totalGenerated: 0, + purgedPaths: [{ client: 'copilot', paths: ['/b'] }], + }; + const merged = mergeSyncResults(a, b); + expect(merged.purgedPaths).toEqual([ + { client: 'claude', paths: ['/a'] }, + { client: 'copilot', paths: ['/b'] }, + ]); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `bun test tests/unit/core/sync-merge.test.ts` +Expected: FAIL — `mergeSyncResults` is not exported + +**Step 3: Write minimal implementation** + +Add to `src/core/sync.ts` after the `SyncResult` interface (after line 57): + +```typescript +/** + * Merge two SyncResult objects into one combined result. + */ +export function mergeSyncResults(a: SyncResult, b: SyncResult): SyncResult { + const warnings = [...(a.warnings || []), ...(b.warnings || [])]; + const purgedPaths = [...(a.purgedPaths || []), ...(b.purgedPaths || [])]; + return { + success: a.success && b.success, + pluginResults: [...a.pluginResults, ...b.pluginResults], + totalCopied: a.totalCopied + b.totalCopied, + totalFailed: a.totalFailed + b.totalFailed, + totalSkipped: a.totalSkipped + b.totalSkipped, + totalGenerated: a.totalGenerated + b.totalGenerated, + ...(warnings.length > 0 && { warnings }), + ...(purgedPaths.length > 0 && { purgedPaths }), + }; +} +``` + +**Step 4: Run test to verify it passes** + +Run: `bun test tests/unit/core/sync-merge.test.ts` +Expected: PASS — all 4 tests pass + +**Step 5: Commit** + +```bash +git add src/core/sync.ts tests/unit/core/sync-merge.test.ts +git commit -m "feat(sync): add mergeSyncResults helper" +``` + +--- + +### Task 2: Rewrite sync command handler to sync both scopes + +**Files:** +- Modify: `src/cli/commands/workspace.ts:96-241` — rewrite `syncCmd` +- Modify: `src/core/user-workspace.ts` — import `ensureUserWorkspace` (already exported) + +**Step 1: Write the failing test** + +Create `tests/unit/cli/workspace-sync-both.test.ts`: + +```typescript +import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; +import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { initWorkspace } from '../../src/core/workspace.js'; +import { addUserPlugin } from '../../src/core/user-workspace.js'; +import { syncUserWorkspace, syncWorkspace } from '../../src/core/sync.js'; + +describe('workspace sync both scopes', () => { + let tempHome: string; + let tempProject: string; + let originalHome: string; + + beforeEach(async () => { + tempHome = await mkdtemp(join(tmpdir(), 'allagents-both-home-')); + tempProject = await mkdtemp(join(tmpdir(), 'allagents-both-proj-')); + originalHome = process.env.HOME || ''; + process.env.HOME = tempHome; + }); + + afterEach(async () => { + process.env.HOME = originalHome; + await rm(tempHome, { recursive: true, force: true }); + await rm(tempProject, { recursive: true, force: true }); + }); + + test('syncs user workspace when only user config exists', async () => { + // Create a local plugin + const pluginDir = join(tempHome, 'test-plugin'); + const skillDir = join(pluginDir, 'skills', 'test-skill'); + await mkdir(skillDir, { recursive: true }); + await writeFile(join(skillDir, 'SKILL.md'), '---\nname: test-skill\ndescription: A test\n---\nContent'); + await addUserPlugin(pluginDir); + + // Sync user — should work + const result = await syncUserWorkspace(); + expect(result.success).toBe(true); + expect(result.totalCopied).toBeGreaterThan(0); + expect(existsSync(join(tempHome, '.claude', 'skills', 'test-skill'))).toBe(true); + + // No project config — syncWorkspace should fail gracefully + const projResult = await syncWorkspace(tempProject); + expect(projResult.success).toBe(false); + }); + + test('syncs both when both configs exist', async () => { + // User plugin + const userPluginDir = join(tempHome, 'user-plugin'); + const userSkillDir = join(userPluginDir, 'skills', 'user-skill'); + await mkdir(userSkillDir, { recursive: true }); + await writeFile(join(userSkillDir, 'SKILL.md'), '---\nname: user-skill\ndescription: User skill\n---\nContent'); + await addUserPlugin(userPluginDir); + + // Project plugin + await initWorkspace(tempProject); + const projPluginDir = join(tempHome, 'proj-plugin'); + const projSkillDir = join(projPluginDir, 'skills', 'proj-skill'); + await mkdir(projSkillDir, { recursive: true }); + await writeFile(join(projSkillDir, 'SKILL.md'), '---\nname: proj-skill\ndescription: Project skill\n---\nContent'); + const { addPlugin } = await import('../../src/core/workspace-modify.js'); + await addPlugin(projPluginDir, tempProject); + + // Sync both + const userResult = await syncUserWorkspace(); + expect(userResult.success).toBe(true); + const projResult = await syncWorkspace(tempProject); + expect(projResult.success).toBe(true); + + // Both skills should exist in their respective locations + expect(existsSync(join(tempHome, '.claude', 'skills', 'user-skill'))).toBe(true); + expect(existsSync(join(tempProject, '.claude', 'skills', 'proj-skill'))).toBe(true); + }); +}); +``` + +**Step 2: Run test to verify it passes (baseline — these test the sync functions directly)** + +Run: `bun test tests/unit/cli/workspace-sync-both.test.ts` +Expected: PASS — confirms the underlying sync functions work correctly for both scopes + +**Step 3: Rewrite the sync command handler** + +Replace the `syncCmd` definition in `src/cli/commands/workspace.ts` (lines 96-241): + +```typescript +const syncCmd = command({ + name: 'sync', + description: buildDescription(syncMeta), + args: { + offline: flag({ long: 'offline', description: 'Use cached plugins without fetching latest from remote' }), + dryRun: flag({ long: 'dry-run', short: 'n', description: 'Simulate sync without making changes' }), + client: option({ type: optional(string), long: 'client', short: 'c', description: 'Sync only the specified client (e.g., opencode, claude)' }), + }, + handler: async ({ offline, dryRun, client }) => { + try { + if (!isJsonMode() && dryRun) { + console.log('Dry run mode - no changes will be made\n'); + } + if (!isJsonMode() && client) { + console.log(`Syncing client: ${client}\n`); + } + + const { getUserWorkspaceConfig, ensureUserWorkspace } = await import('../../core/user-workspace.js'); + const { mergeSyncResults } = await import('../../core/sync.js'); + + const userConfigExists = !!(await getUserWorkspaceConfig()); + const projectConfigPath = join(process.cwd(), '.allagents', 'workspace.yaml'); + const projectConfigExists = existsSync(projectConfigPath); + + // If neither config exists, auto-create user config and show guidance + if (!userConfigExists && !projectConfigExists) { + await ensureUserWorkspace(); + if (isJsonMode()) { + jsonOutput({ success: true, command: 'workspace sync', data: { message: 'No plugins configured' } }); + } else { + console.log('No plugins configured. Run `allagents plugin install ` to get started.'); + } + return; + } + + let combined: SyncResult | null = null; + + // Sync user workspace if config exists + if (userConfigExists) { + if (!isJsonMode()) { + console.log('Syncing user workspace...\n'); + } + const userResult = await syncUserWorkspace({ offline, dryRun }); + combined = userResult; + } + + // Sync project workspace if config exists + if (projectConfigExists) { + if (!isJsonMode()) { + console.log('Syncing project workspace...\n'); + } + const projectResult = await syncWorkspace(process.cwd(), { + offline, + dryRun, + ...(client && { clients: [client] }), + }); + combined = combined ? mergeSyncResults(combined, projectResult) : projectResult; + } + + const result = combined!; + + if (isJsonMode()) { + const syncData = buildSyncData(result); + const success = result.success && result.totalFailed === 0; + jsonOutput({ + success, + command: 'workspace sync', + data: syncData, + ...(!success && { error: 'Sync completed with failures' }), + }); + if (!success) { + process.exit(1); + } + return; + } + + // Show purge plan in dry-run mode + if (dryRun && result.purgedPaths && result.purgedPaths.length > 0) { + console.log('Would purge managed directories:'); + for (const purgePath of result.purgedPaths) { + console.log(` ${purgePath.client}:`); + for (const path of purgePath.paths) { + console.log(` - ${path}`); + } + } + console.log(''); + } + + // Print plugin results + for (const pluginResult of result.pluginResults) { + const status = pluginResult.success ? '\u2713' : '\u2717'; + console.log(`${status} Plugin: ${pluginResult.plugin}`); + + if (pluginResult.error) { + console.log(` Error: ${pluginResult.error}`); + } + + const copied = pluginResult.copyResults.filter((r) => r.action === 'copied').length; + const generated = pluginResult.copyResults.filter((r) => r.action === 'generated').length; + const failed = pluginResult.copyResults.filter((r) => r.action === 'failed').length; + + if (copied > 0) console.log(` Copied: ${copied} files`); + if (generated > 0) console.log(` Generated: ${generated} files`); + if (failed > 0) { + console.log(` Failed: ${failed} files`); + for (const failedResult of pluginResult.copyResults.filter((r) => r.action === 'failed')) { + console.log(` - ${failedResult.destination}: ${failedResult.error}`); + } + } + } + + // Show warnings + if (result.warnings && result.warnings.length > 0) { + console.log('\nWarnings:'); + for (const warning of result.warnings) { + console.log(` \u26A0 ${warning}`); + } + } + + // Print summary + console.log(`\nSync complete${dryRun ? ' (dry run)' : ''}:`); + console.log(` Total ${dryRun ? 'would copy' : 'copied'}: ${result.totalCopied}`); + if (result.totalGenerated > 0) console.log(` Total generated: ${result.totalGenerated}`); + if (result.totalFailed > 0) console.log(` Total failed: ${result.totalFailed}`); + if (result.totalSkipped > 0) console.log(` Total skipped: ${result.totalSkipped}`); + + if (!result.success || result.totalFailed > 0) { + process.exit(1); + } + } catch (error) { + if (error instanceof Error) { + if (isJsonMode()) { + jsonOutput({ success: false, command: 'workspace sync', error: error.message }); + process.exit(1); + } + console.error(`Error: ${error.message}`); + process.exit(1); + } + throw error; + } + }, +}); +``` + +Note: Add these imports at the top of the file: +```typescript +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import type { SyncResult } from '../../core/sync.js'; +``` + +**Step 4: Run all tests to verify nothing breaks** + +Run: `bun test` +Expected: All existing tests pass + +**Step 5: Commit** + +```bash +git add src/cli/commands/workspace.ts tests/unit/cli/workspace-sync-both.test.ts +git commit -m "feat(sync): sync both user and project workspaces automatically" +``` + +--- + +### Task 3: Update metadata and help text + +**Files:** +- Modify: `src/cli/metadata/workspace.ts:32-58` — remove `--scope` from `syncMeta` + +**Step 1: Update syncMeta** + +In `src/cli/metadata/workspace.ts`, remove the `--scope` example and option: + +- Remove `'allagents workspace sync --scope user'` from examples array +- Remove `{ flag: '--scope', short: '-s', type: 'string', description: '...' }` from options array + +**Step 2: Run help text tests** + +Run: `bun test tests/e2e/cli-help.test.ts tests/e2e/cli-enriched-help.test.ts tests/e2e/cli-agent-help.test.ts` +Expected: PASS (help tests should not hardcode --scope for sync) + +**Step 3: Commit** + +```bash +git add src/cli/metadata/workspace.ts +git commit -m "docs(sync): remove --scope from sync help text and metadata" +``` + +--- + +### Task 4: Update allagents:workspace skill if it references --scope sync + +**Files:** +- Check and modify: the allagents:workspace skill file (if it exists and references `--scope` for sync) + +**Step 1: Search for --scope references in skills** + +Search for `--scope` in any skill or command file that mentions `workspace sync`. + +**Step 2: Update references** + +Remove any `--scope user` references from `workspace sync` examples. Keep `--scope` references for `plugin install` unchanged. + +**Step 3: Commit** + +```bash +git add +git commit -m "docs(skill): update workspace skill to reflect sync both scopes" +``` + +--- + +### Task 5: Run full test suite and verify + +**Step 1: Run full test suite** + +Run: `bun test` +Expected: All 472+ tests pass, 0 failures + +**Step 2: Manual smoke test** + +Run: `bun run src/cli/index.ts workspace sync --help` +Expected: No `--scope` flag shown + +**Step 3: Final commit if any fixups needed** diff --git a/src/cli/commands/workspace.ts b/src/cli/commands/workspace.ts index 52c945ca..64a8dca5 100644 --- a/src/cli/commands/workspace.ts +++ b/src/cli/commands/workspace.ts @@ -1,8 +1,12 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; import { command, positional, option, flag, string, optional } from 'cmd-ts'; import { initWorkspace } from '../../core/workspace.js'; -import { syncWorkspace, syncUserWorkspace } from '../../core/sync.js'; +import { syncWorkspace, syncUserWorkspace, mergeSyncResults } from '../../core/sync.js'; +import type { SyncResult } from '../../core/sync.js'; import { getWorkspaceStatus } from '../../core/status.js'; import { pruneOrphanedPlugins } from '../../core/prune.js'; +import { getUserWorkspaceConfig, ensureUserWorkspace } from '../../core/user-workspace.js'; import { isJsonMode, jsonOutput } from '../json-output.js'; import { buildDescription, conciseSubcommands } from '../help.js'; import { initMeta, syncMeta, statusMeta, pruneMeta } from '../metadata/workspace.js'; @@ -10,7 +14,7 @@ import { initMeta, syncMeta, statusMeta, pruneMeta } from '../metadata/workspace /** * Build a JSON-friendly sync data object from a sync result. */ -function buildSyncData(result: Awaited>) { +function buildSyncData(result: SyncResult) { return { copied: result.totalCopied, generated: result.totalGenerated, @@ -100,39 +104,58 @@ const syncCmd = command({ offline: flag({ long: 'offline', description: 'Use cached plugins without fetching latest from remote' }), dryRun: flag({ long: 'dry-run', short: 'n', description: 'Simulate sync without making changes' }), client: option({ type: optional(string), long: 'client', short: 'c', description: 'Sync only the specified client (e.g., opencode, claude)' }), - scope: option({ type: optional(string), long: 'scope', short: 's', description: 'Sync scope: "project" (default) or "user"' }), }, - handler: async ({ offline, dryRun, client, scope }) => { + handler: async ({ offline, dryRun, client }) => { try { - const isUser = scope === 'user'; - if (!isJsonMode()) { - if (dryRun) { - console.log('Dry run mode - no changes will be made\n'); + if (!isJsonMode() && dryRun) { + console.log('Dry run mode - no changes will be made\n'); + } + if (!isJsonMode() && client) { + console.log(`Syncing client: ${client}\n`); + } + + const userConfigExists = !!(await getUserWorkspaceConfig()); + const projectConfigPath = join(process.cwd(), '.allagents', 'workspace.yaml'); + const projectConfigExists = existsSync(projectConfigPath); + + // If neither config exists, auto-create user config and show guidance + if (!userConfigExists && !projectConfigExists) { + await ensureUserWorkspace(); + if (isJsonMode()) { + jsonOutput({ success: true, command: 'workspace sync', data: { message: 'No plugins configured' } }); + } else { + console.log('No plugins configured. Run `allagents plugin install ` to get started.'); } - if (client) { - console.log(`Syncing client: ${client}\n`); + return; + } + + let combined: SyncResult | null = null; + + // Sync user workspace if config exists + if (userConfigExists) { + if (!isJsonMode()) { + console.log('Syncing user workspace...\n'); } - console.log(`Syncing ${isUser ? 'user' : ''} workspace...\n`); + const userResult = await syncUserWorkspace({ offline, dryRun }); + combined = userResult; } - const result = isUser - ? await syncUserWorkspace({ offline, dryRun }) - : await syncWorkspace(process.cwd(), { - offline, - dryRun, - ...(client && { clients: [client] }), - }); - - // Early exit only for top-level errors (e.g., missing .allagents/workspace.yaml) - // Plugin-level errors are handled in the loop below - if (!result.success && result.error) { - if (isJsonMode()) { - jsonOutput({ success: false, command: 'workspace sync', error: result.error }); - process.exit(1); + + // Sync project workspace if config exists + if (projectConfigExists) { + if (!isJsonMode()) { + console.log('Syncing project workspace...\n'); } - console.error(`Error: ${result.error}`); - process.exit(1); + const projectResult = await syncWorkspace(process.cwd(), { + offline, + dryRun, + ...(client && { clients: [client] }), + }); + combined = combined ? mergeSyncResults(combined, projectResult) : projectResult; } + // At this point, at least one config existed so combined is set + const result = combined as SyncResult; + if (isJsonMode()) { const syncData = buildSyncData(result); const success = result.success && result.totalFailed === 0; @@ -169,37 +192,21 @@ const syncCmd = command({ console.log(` Error: ${pluginResult.error}`); } - // Count by action - const copied = pluginResult.copyResults.filter( - (r) => r.action === 'copied', - ).length; - const generated = pluginResult.copyResults.filter( - (r) => r.action === 'generated', - ).length; - const failed = pluginResult.copyResults.filter( - (r) => r.action === 'failed', - ).length; - - if (copied > 0) { - console.log(` Copied: ${copied} files`); - } - if (generated > 0) { - console.log(` Generated: ${generated} files`); - } + const copied = pluginResult.copyResults.filter((r) => r.action === 'copied').length; + const generated = pluginResult.copyResults.filter((r) => r.action === 'generated').length; + const failed = pluginResult.copyResults.filter((r) => r.action === 'failed').length; + + if (copied > 0) console.log(` Copied: ${copied} files`); + if (generated > 0) console.log(` Generated: ${generated} files`); if (failed > 0) { console.log(` Failed: ${failed} files`); - // Show failure details - for (const failedResult of pluginResult.copyResults.filter( - (r) => r.action === 'failed', - )) { - console.log( - ` - ${failedResult.destination}: ${failedResult.error}`, - ); + for (const failedResult of pluginResult.copyResults.filter((r) => r.action === 'failed')) { + console.log(` - ${failedResult.destination}: ${failedResult.error}`); } } } - // Show warnings for skipped plugins + // Show warnings if (result.warnings && result.warnings.length > 0) { console.log('\nWarnings:'); for (const warning of result.warnings) { @@ -209,20 +216,11 @@ const syncCmd = command({ // Print summary console.log(`\nSync complete${dryRun ? ' (dry run)' : ''}:`); - console.log( - ` Total ${dryRun ? 'would copy' : 'copied'}: ${result.totalCopied}`, - ); - if (result.totalGenerated > 0) { - console.log(` Total generated: ${result.totalGenerated}`); - } - if (result.totalFailed > 0) { - console.log(` Total failed: ${result.totalFailed}`); - } - if (result.totalSkipped > 0) { - console.log(` Total skipped: ${result.totalSkipped}`); - } + console.log(` Total ${dryRun ? 'would copy' : 'copied'}: ${result.totalCopied}`); + if (result.totalGenerated > 0) console.log(` Total generated: ${result.totalGenerated}`); + if (result.totalFailed > 0) console.log(` Total failed: ${result.totalFailed}`); + if (result.totalSkipped > 0) console.log(` Total skipped: ${result.totalSkipped}`); - // Exit with error if any failures occurred (plugin-level or copy-level) if (!result.success || result.totalFailed > 0) { process.exit(1); } diff --git a/src/cli/metadata/workspace.ts b/src/cli/metadata/workspace.ts index 5904a502..08a16ae4 100644 --- a/src/cli/metadata/workspace.ts +++ b/src/cli/metadata/workspace.ts @@ -38,7 +38,6 @@ export const syncMeta: AgentCommandMeta = { 'allagents workspace sync --dry-run', 'allagents workspace sync --client claude', 'allagents workspace sync --offline', - 'allagents workspace sync --scope user', ], expectedOutput: 'Lists synced files with status per plugin. Exit 0 on success, exit 1 if any files failed.', @@ -46,7 +45,6 @@ export const syncMeta: AgentCommandMeta = { { flag: '--offline', type: 'boolean', description: 'Use cached plugins without fetching latest from remote' }, { flag: '--dry-run', short: '-n', type: 'boolean', description: 'Simulate sync without making changes' }, { flag: '--client', short: '-c', type: 'string', description: 'Sync only the specified client (e.g., opencode, claude)' }, - { flag: '--scope', short: '-s', type: 'string', description: 'Sync scope: "project" (default) or "user"' }, ], outputSchema: { copied: 'number', diff --git a/src/core/sync.ts b/src/core/sync.ts index d96db0ea..03c56702 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -56,6 +56,24 @@ export interface SyncResult { warnings?: string[]; } +/** + * Merge two SyncResult objects into one combined result. + */ +export function mergeSyncResults(a: SyncResult, b: SyncResult): SyncResult { + const warnings = [...(a.warnings || []), ...(b.warnings || [])]; + const purgedPaths = [...(a.purgedPaths || []), ...(b.purgedPaths || [])]; + return { + success: a.success && b.success, + pluginResults: [...a.pluginResults, ...b.pluginResults], + totalCopied: a.totalCopied + b.totalCopied, + totalFailed: a.totalFailed + b.totalFailed, + totalSkipped: a.totalSkipped + b.totalSkipped, + totalGenerated: a.totalGenerated + b.totalGenerated, + ...(warnings.length > 0 && { warnings }), + ...(purgedPaths.length > 0 && { purgedPaths }), + }; +} + /** * Result of syncing a single plugin */ diff --git a/tests/e2e/cli-agent-help.test.ts b/tests/e2e/cli-agent-help.test.ts index eb3b205b..9088d274 100644 --- a/tests/e2e/cli-agent-help.test.ts +++ b/tests/e2e/cli-agent-help.test.ts @@ -91,7 +91,7 @@ describe('allagents --agent-help (single command)', () => { const { stdout } = await runCli(['workspace', 'sync', '--agent-help']); const json = parseJson(stdout); expect(json.options).toBeInstanceOf(Array); - expect(json.options.length).toBe(4); + expect(json.options.length).toBe(3); const dryRun = json.options.find((o: any) => o.flag === '--dry-run'); expect(dryRun).toBeDefined(); @@ -109,9 +109,7 @@ describe('allagents --agent-help (single command)', () => { expect(offline.short).toBeUndefined(); const scope = json.options.find((o: any) => o.flag === '--scope'); - expect(scope).toBeDefined(); - expect(scope.type).toBe('string'); - expect(scope.short).toBe('-s'); + expect(scope).toBeUndefined(); }); it('plugin install --agent-help includes positionals', async () => { diff --git a/tests/e2e/cli-json-output.test.ts b/tests/e2e/cli-json-output.test.ts index 6a996277..7c72e360 100644 --- a/tests/e2e/cli-json-output.test.ts +++ b/tests/e2e/cli-json-output.test.ts @@ -1,12 +1,15 @@ -import { describe, it, expect } from 'bun:test'; +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; import { execa } from 'execa'; import { resolve } from 'node:path'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; const CLI = resolve(import.meta.dir, '../../dist/index.js'); -async function runCli(args: string[]) { +async function runCli(args: string[], env?: Record) { try { - const result = await execa('node', [CLI, ...args]); + const result = await execa('node', [CLI, ...args], env ? { env: { ...process.env, ...env } } : undefined); return { stdout: result.stdout, stderr: result.stderr, exitCode: 0 }; } catch (error: any) { return { stdout: error.stdout || '', stderr: error.stderr || '', exitCode: error.exitCode || 1 }; @@ -69,14 +72,22 @@ describe('CLI --json output envelope', () => { // ============================================================================= describe('CLI --json error cases', () => { - it('workspace sync --json in non-workspace dir returns error JSON with exit code 1', async () => { - const { stdout, exitCode } = await runCli(['workspace', 'sync', '--json']); - expect(exitCode).toBe(1); + let mockHome: string; + + beforeAll(async () => { + mockHome = await mkdtemp(join(tmpdir(), 'allagents-e2e-json-')); + }); + + afterAll(async () => { + await rm(mockHome, { recursive: true, force: true }); + }); + + it('workspace sync --json in non-workspace dir with no user config returns success JSON', async () => { + const { stdout, exitCode } = await runCli(['workspace', 'sync', '--json'], { HOME: mockHome }); + expect(exitCode).toBe(0); const json = parseJson(stdout); - expect(json.success).toBe(false); + expect(json.success).toBe(true); expect(json.command).toBe('workspace sync'); - expect(typeof json.error).toBe('string'); - expect(json.error.length).toBeGreaterThan(0); }); it('workspace status --json in non-workspace dir returns error JSON with exit code 1', async () => { @@ -152,10 +163,15 @@ describe('CLI output without --json is unchanged', () => { expect(() => JSON.parse(stdout)).toThrow(); }); - it('workspace sync without --json in non-workspace dir outputs human error', async () => { - const { stderr, exitCode } = await runCli(['workspace', 'sync']); - expect(exitCode).toBe(1); - expect(stderr).toContain('Error'); + it('workspace sync without --json in non-workspace dir outputs guidance', async () => { + const mockHome2 = await mkdtemp(join(tmpdir(), 'allagents-e2e-human-')); + try { + const { stdout, exitCode } = await runCli(['workspace', 'sync'], { HOME: mockHome2 }); + expect(exitCode).toBe(0); + expect(stdout).toContain('No plugins configured'); + } finally { + await rm(mockHome2, { recursive: true, force: true }); + } }); it('plugin marketplace list without --json outputs human text', async () => { diff --git a/tests/unit/cli/workspace-sync-both.test.ts b/tests/unit/cli/workspace-sync-both.test.ts new file mode 100644 index 00000000..845d1a36 --- /dev/null +++ b/tests/unit/cli/workspace-sync-both.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; +import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { initWorkspace } from '../../../src/core/workspace.js'; +import { addUserPlugin } from '../../../src/core/user-workspace.js'; +import { syncUserWorkspace, syncWorkspace } from '../../../src/core/sync.js'; + +describe('workspace sync both scopes', () => { + let tempHome: string; + let tempProject: string; + let originalHome: string; + + beforeEach(async () => { + tempHome = await mkdtemp(join(tmpdir(), 'allagents-both-home-')); + tempProject = await mkdtemp(join(tmpdir(), 'allagents-both-proj-')); + originalHome = process.env.HOME || ''; + process.env.HOME = tempHome; + }); + + afterEach(async () => { + process.env.HOME = originalHome; + await rm(tempHome, { recursive: true, force: true }); + await rm(tempProject, { recursive: true, force: true }); + }); + + test('syncs user workspace when only user config exists', async () => { + const pluginDir = join(tempHome, 'test-plugin'); + const skillDir = join(pluginDir, 'skills', 'test-skill'); + await mkdir(skillDir, { recursive: true }); + await writeFile(join(skillDir, 'SKILL.md'), '---\nname: test-skill\ndescription: A test\n---\nContent'); + await addUserPlugin(pluginDir); + + const result = await syncUserWorkspace(); + expect(result.success).toBe(true); + expect(result.totalCopied).toBeGreaterThan(0); + expect(existsSync(join(tempHome, '.claude', 'skills', 'test-skill'))).toBe(true); + + const projResult = await syncWorkspace(tempProject); + expect(projResult.success).toBe(false); + }); + + test('syncs both when both configs exist', async () => { + const userPluginDir = join(tempHome, 'user-plugin'); + const userSkillDir = join(userPluginDir, 'skills', 'user-skill'); + await mkdir(userSkillDir, { recursive: true }); + await writeFile(join(userSkillDir, 'SKILL.md'), '---\nname: user-skill\ndescription: User skill\n---\nContent'); + await addUserPlugin(userPluginDir); + + await initWorkspace(tempProject); + const projPluginDir = join(tempHome, 'proj-plugin'); + const projSkillDir = join(projPluginDir, 'skills', 'proj-skill'); + await mkdir(projSkillDir, { recursive: true }); + await writeFile(join(projSkillDir, 'SKILL.md'), '---\nname: proj-skill\ndescription: Project skill\n---\nContent'); + const { addPlugin } = await import('../../../src/core/workspace-modify.js'); + await addPlugin(projPluginDir, tempProject); + + const userResult = await syncUserWorkspace(); + expect(userResult.success).toBe(true); + const projResult = await syncWorkspace(tempProject); + expect(projResult.success).toBe(true); + + expect(existsSync(join(tempHome, '.claude', 'skills', 'user-skill'))).toBe(true); + expect(existsSync(join(tempProject, '.claude', 'skills', 'proj-skill'))).toBe(true); + }); +}); diff --git a/tests/unit/core/sync-merge.test.ts b/tests/unit/core/sync-merge.test.ts new file mode 100644 index 00000000..873c17fb --- /dev/null +++ b/tests/unit/core/sync-merge.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from 'bun:test'; +import { mergeSyncResults } from '../../../src/core/sync.js'; +import type { SyncResult } from '../../../src/core/sync.js'; + +describe('mergeSyncResults', () => { + test('merges two successful results', () => { + const a: SyncResult = { + success: true, + pluginResults: [{ plugin: 'a', resolved: '/a', success: true, copyResults: [] }], + totalCopied: 2, + totalFailed: 0, + totalSkipped: 0, + totalGenerated: 1, + }; + const b: SyncResult = { + success: true, + pluginResults: [{ plugin: 'b', resolved: '/b', success: true, copyResults: [] }], + totalCopied: 3, + totalFailed: 0, + totalSkipped: 1, + totalGenerated: 0, + }; + const merged = mergeSyncResults(a, b); + expect(merged.success).toBe(true); + expect(merged.pluginResults).toHaveLength(2); + expect(merged.totalCopied).toBe(5); + expect(merged.totalFailed).toBe(0); + expect(merged.totalSkipped).toBe(1); + expect(merged.totalGenerated).toBe(1); + }); + + test('merges when one has failures', () => { + const a: SyncResult = { + success: true, + pluginResults: [], + totalCopied: 1, + totalFailed: 0, + totalSkipped: 0, + totalGenerated: 0, + }; + const b: SyncResult = { + success: false, + pluginResults: [], + totalCopied: 0, + totalFailed: 1, + totalSkipped: 0, + totalGenerated: 0, + error: 'plugin failed', + }; + const merged = mergeSyncResults(a, b); + expect(merged.success).toBe(false); + expect(merged.totalCopied).toBe(1); + expect(merged.totalFailed).toBe(1); + }); + + test('merges warnings from both results', () => { + const a: SyncResult = { + success: true, + pluginResults: [], + totalCopied: 0, + totalFailed: 0, + totalSkipped: 0, + totalGenerated: 0, + warnings: ['warn1'], + }; + const b: SyncResult = { + success: true, + pluginResults: [], + totalCopied: 0, + totalFailed: 0, + totalSkipped: 0, + totalGenerated: 0, + warnings: ['warn2'], + }; + const merged = mergeSyncResults(a, b); + expect(merged.warnings).toEqual(['warn1', 'warn2']); + }); + + test('merges purgedPaths from both results', () => { + const a: SyncResult = { + success: true, + pluginResults: [], + totalCopied: 0, + totalFailed: 0, + totalSkipped: 0, + totalGenerated: 0, + purgedPaths: [{ client: 'claude', paths: ['/a'] }], + }; + const b: SyncResult = { + success: true, + pluginResults: [], + totalCopied: 0, + totalFailed: 0, + totalSkipped: 0, + totalGenerated: 0, + purgedPaths: [{ client: 'copilot', paths: ['/b'] }], + }; + const merged = mergeSyncResults(a, b); + expect(merged.purgedPaths).toEqual([ + { client: 'claude', paths: ['/a'] }, + { client: 'copilot', paths: ['/b'] }, + ]); + }); +});