Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .claude/commands/allagents/workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,16 @@ Help the user manage their allagents workspace by running CLI commands and editi
| Subcommand | Usage | Description |
|------------|-------|-------------|
| `init` | `allagents workspace init <path>` | Create new workspace from template |
| `sync` | `allagents workspace sync [--force] [--dry-run]` | Sync plugins to all repositories |
| `sync` | `allagents workspace sync [--offline] [--dry-run] [--client <client>]` | Sync plugins to all repositories |
| `status` | `allagents workspace status` | Show plugin and client status |
| `add` | `allagents workspace add <plugin>` | Add a plugin (local path or GitHub URL) |
| `remove` | `allagents workspace remove <plugin>` | Remove a plugin from workspace |

## Sync Options

- `--force, -f` - Force re-fetch of remote plugins even if cached
- `--offline` - Use cached plugins without fetching latest from remote
- `--dry-run, -n` - Preview changes without making them
- `-c, --client <client>` - Sync only the specified client (e.g., opencode, claude)

## What to Do

Expand Down Expand Up @@ -56,5 +57,6 @@ clients:
## Tips

- Run `--dry-run` first to preview sync changes before applying
- Use `--force` to re-fetch cached GitHub plugins
- Use `--offline` to skip fetching and use cached plugins
- Use `--client <client>` to sync only a specific client
- Always run `status` after configuration changes to verify resolution
10 changes: 10 additions & 0 deletions docs/src/content/docs/guides/workspaces.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,16 @@ This means your personal commands, skills, or customizations in `.claude/command

AllAgents tracks synced files in `.allagents/sync-state.json`. This file is automatically created and updated on each sync.

### Syncing a Single Client

To sync only a specific client instead of all configured clients:

```bash
allagents workspace sync --client opencode
```

This is useful when you want to update files for one client without touching others. Files and sync state for non-targeted clients are preserved.

### Dry Run

Preview what would happen without making changes:
Expand Down
3 changes: 2 additions & 1 deletion docs/src/content/docs/reference/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ description: Complete reference for AllAgents CLI commands.

```bash
allagents workspace init <path> [--from <source>]
allagents workspace sync [--offline] [--dry-run]
allagents workspace sync [--offline] [--dry-run] [--client <client>]
allagents workspace status
allagents workspace plugin add <plugin@marketplace>
allagents workspace plugin remove <plugin>
Expand Down Expand Up @@ -36,6 +36,7 @@ Syncs plugins to the workspace using non-destructive sync. By default, remote pl
|------|-------------|
| `--offline` | Use cached plugins without fetching latest from remote |
| `--dry-run` | Preview changes without applying them |
| `-c, --client <client>` | Sync only the specified client (e.g., `opencode`, `claude`) |

**Non-destructive behavior:**
- First sync overlays files without deleting existing user files
Expand Down
12 changes: 10 additions & 2 deletions src/cli/commands/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,24 @@ workspaceCommand
.description('Sync plugins to workspace')
.option('--offline', 'Use cached plugins without fetching latest from remote')
.option('-n, --dry-run', 'Simulate sync without making changes')
.action(async (options: { offline?: boolean; dryRun?: boolean }) => {
.option('-c, --client <client>', 'Sync only the specified client (e.g., opencode, claude)')
.action(async (options: { offline?: boolean; dryRun?: boolean; client?: string }) => {
try {
const offline = options.offline ?? false;
const dryRun = options.dryRun ?? false;

if (dryRun) {
console.log('Dry run mode - no changes will be made\n');
}
if (options.client) {
console.log(`Syncing client: ${options.client}\n`);
}
console.log('Syncing workspace...\n');
const result = await syncWorkspace(process.cwd(), { offline, dryRun });
const result = await syncWorkspace(process.cwd(), {
offline,
dryRun,
...(options.client && { clients: [options.client] }),
});

// Early exit only for top-level errors (e.g., missing .allagents/workspace.yaml)
// Plugin-level errors are handled in the loop below
Expand Down
54 changes: 48 additions & 6 deletions src/core/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ export interface SyncOptions {
* instead of the target workspace. If not provided, defaults to workspacePath.
*/
workspaceSourceBase?: string;
/** Override which clients to sync. If provided, only these clients are synced instead of all configured clients. */
clients?: string[];
}

/**
Expand Down Expand Up @@ -220,6 +222,7 @@ export async function selectivePurgeWorkspace(
workspacePath: string,
state: SyncState | null,
clients: ClientType[],
options?: { partialSync?: boolean },
): Promise<PurgePaths[]> {
// First sync - no state, skip purge entirely (safe overlay)
if (!state) {
Expand All @@ -234,7 +237,11 @@ export async function selectivePurgeWorkspace(
// Include both current clients AND clients that were removed from config.
// Removed clients must be purged to avoid orphaned files on disk when a user
// removes a client from workspace.yaml (e.g., removes 'copilot' from clients list).
const clientsToProcess = [...new Set([...clients, ...previousClients])];
// However, during partial sync (--client flag), only purge the targeted clients
// to avoid removing files for clients that aren't being synced.
const clientsToProcess = options?.partialSync
? clients
: [...new Set([...clients, ...previousClients])];

for (const client of clientsToProcess) {
const previousFiles = getPreviouslySyncedFiles(state, client);
Expand Down Expand Up @@ -833,6 +840,29 @@ export async function syncWorkspace(
};
}

// Filter clients if override provided
const clients = options.clients
? config.clients.filter((c) => options.clients?.includes(c))
: config.clients;

// Validate requested clients are in config
if (options.clients) {
const invalidClients = options.clients.filter(
(c) => !config.clients.includes(c as ClientType),
);
if (invalidClients.length > 0) {
return {
success: false,
pluginResults: [],
totalCopied: 0,
totalFailed: 0,
totalSkipped: 0,
totalGenerated: 0,
error: `Client(s) not configured in workspace.yaml: ${invalidClients.join(', ')}\n Configured clients: ${config.clients.join(', ')}`,
};
}
}

// Step 1: Validate all plugins before any destructive action
const validatedPlugins = await validateAllPlugins(
config.plugins,
Expand Down Expand Up @@ -894,7 +924,7 @@ export async function syncWorkspace(
// 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
? clients
.map((client) => ({
client,
paths: getPreviouslySyncedFiles(previousState, client),
Expand All @@ -904,7 +934,9 @@ export async function syncWorkspace(

// Step 3: Selective purge - only remove files we previously synced (skip in dry-run mode)
if (!dryRun) {
await selectivePurgeWorkspace(workspacePath, previousState, config.clients);
await selectivePurgeWorkspace(workspacePath, previousState, clients, {
partialSync: !!options.clients,
});
}

// Step 3b: Two-pass skill name resolution
Expand All @@ -922,7 +954,7 @@ export async function syncWorkspace(
return copyValidatedPlugin(
validatedPlugin,
workspacePath,
config.clients,
clients,
dryRun,
skillNameMap,
);
Expand Down Expand Up @@ -989,7 +1021,7 @@ export async function syncWorkspace(
);

// If claude is a client and CLAUDE.md doesn't exist, copy AGENTS.md to CLAUDE.md
if (!dryRun && config.clients.includes('claude') && sourcePath) {
if (!dryRun && clients.includes('claude') && sourcePath) {
const claudePath = join(workspacePath, 'CLAUDE.md');
const agentsPath = join(workspacePath, 'AGENTS.md');
const claudeExistsInSource = existsSync(join(sourcePath, 'CLAUDE.md'));
Expand Down Expand Up @@ -1054,7 +1086,17 @@ export async function syncWorkspace(
];

// Group by client and save state
const syncedFiles = collectSyncedPaths(allCopyResults, workspacePath, config.clients);
const syncedFiles = collectSyncedPaths(allCopyResults, workspacePath, clients);

// When syncing a subset of clients, merge with existing state for non-targeted clients
if (options.clients && previousState) {
for (const [client, files] of Object.entries(previousState.files)) {
if (!clients.includes(client as ClientType)) {
syncedFiles[client as ClientType] = files;
}
}
}

await saveSyncState(workspacePath, syncedFiles);
}

Expand Down
87 changes: 87 additions & 0 deletions tests/unit/core/sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -896,4 +896,91 @@ clients:
expect(state2.files.copilot).toBeUndefined();
});
});

describe('syncWorkspace - client filtering', () => {
it('should fail when specified client is not in workspace config', async () => {
const configDir = join(testDir, '.allagents');
await mkdir(configDir, { recursive: true });
await writeFile(
join(configDir, 'workspace.yaml'),
`repositories: []\nplugins: []\nclients:\n - claude\n`,
);

const result = await syncWorkspace(testDir, { clients: ['opencode'] });

expect(result.success).toBe(false);
expect(result.error).toContain('opencode');
expect(result.error).toContain('not configured');
});

it('should sync only the specified client when clients option is provided', async () => {
// Setup plugin with a skill (skills require a subdirectory with SKILL.md)
const pluginDir = join(testDir, 'my-plugin');
await mkdir(join(pluginDir, 'skills', 'test-skill'), { recursive: true });
await writeFile(
join(pluginDir, 'skills', 'test-skill', 'SKILL.md'),
'---\nname: test-skill\ndescription: A test skill\n---\n# Test Skill\n',
);

// Setup workspace config with two clients
const configDir = join(testDir, '.allagents');
await mkdir(configDir, { recursive: true });
await writeFile(
join(configDir, 'workspace.yaml'),
`repositories: []\nplugins:\n - ./my-plugin\nclients:\n - claude\n - opencode\n`,
);

// Sync with only opencode
const result = await syncWorkspace(testDir, { clients: ['opencode'] });

expect(result.success).toBe(true);
// opencode files should exist
expect(existsSync(join(testDir, '.opencode', 'skills', 'test-skill', 'SKILL.md'))).toBe(true);
// claude files should NOT exist
expect(existsSync(join(testDir, '.claude', 'skills'))).toBe(false);
});

it('should preserve sync state for non-targeted clients during partial sync', async () => {
// Setup plugin with a skill (skills require a subdirectory with SKILL.md)
const pluginDir = join(testDir, 'my-plugin');
await mkdir(join(pluginDir, 'skills', 'test-skill'), { recursive: true });
await writeFile(
join(pluginDir, 'skills', 'test-skill', 'SKILL.md'),
'---\nname: test-skill\ndescription: A test skill\n---\n# Test Skill\n',
);

// Setup workspace config with two clients
const configDir = join(testDir, '.allagents');
await mkdir(configDir, { recursive: true });
await writeFile(
join(configDir, 'workspace.yaml'),
`repositories: []\nplugins:\n - ./my-plugin\nclients:\n - claude\n - opencode\n`,
);

// First: full sync (both clients)
const result1 = await syncWorkspace(testDir);
expect(result1.success).toBe(true);

// Verify both clients synced
expect(existsSync(join(testDir, '.claude', 'skills', 'test-skill', 'SKILL.md'))).toBe(true);
expect(existsSync(join(testDir, '.opencode', 'skills', 'test-skill', 'SKILL.md'))).toBe(true);

// Second: sync only opencode
const result2 = await syncWorkspace(testDir, { clients: ['opencode'] });
expect(result2.success).toBe(true);

// Claude files should still be on disk (not purged)
expect(existsSync(join(testDir, '.claude', 'skills', 'test-skill', 'SKILL.md'))).toBe(true);
// opencode files should still exist (re-synced)
expect(existsSync(join(testDir, '.opencode', 'skills', 'test-skill', 'SKILL.md'))).toBe(true);

// Verify sync state preserves both clients' files
const stateFile = join(testDir, '.allagents', 'sync-state.json');
const state = JSON.parse(await readFile(stateFile, 'utf-8'));
expect(state.files.claude).toBeDefined();
expect(state.files.claude.length).toBeGreaterThan(0);
expect(state.files.opencode).toBeDefined();
expect(state.files.opencode.length).toBeGreaterThan(0);
});
});
});