Skip to content
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ A CLI tool for managing AI coding assistant plugins across multiple clients (Cla

## Plans

Design documents and implementation plans are stored in `.claude/plans/`.
Design documents and implementation plans are stored in `.claude/plans/`. These are temporary working documents - once implementation is complete, delete the plan and update official docs with any user-facing behavior.

## Git Workflow

Expand Down
30 changes: 30 additions & 0 deletions docs/src/content/docs/guides/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,36 @@ my-plugin/
└── AGENTS.md
```

## Duplicate Skill Handling

When multiple plugins define skills with the same folder name, AllAgents automatically resolves naming conflicts:

| Conflict Type | Resolution | Example |
|---------------|------------|---------|
| Same skill folder, different plugins | `{plugin}_{skill}` | `plugin-a_coding-standards` |
| Same skill folder AND plugin name | `{org}_{plugin}_{skill}` (GitHub) | `acme_my-plugin_coding-standards` |
| Same skill folder AND plugin name | `{hash}_{plugin}_{skill}` (local) | `a1b2c3_my-plugin_coding-standards` |

Skills with unique folder names keep their original names unchanged.

**Example:**

```yaml
# workspace.yaml
plugins:
- ./plugin-a # has skills/coding-standards/
- ./plugin-b # has skills/coding-standards/
- ./plugin-c # has skills/testing/
```

After sync:
```
.claude/skills/
├── plugin-a_coding-standards/ # renamed (conflict)
├── plugin-b_coding-standards/ # renamed (conflict)
└── testing/ # unchanged (unique)
```

## Plugin Spec Format

Plugins use the `plugin@marketplace` format:
Expand Down
31 changes: 29 additions & 2 deletions src/core/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { mkdir, readdir, stat } from 'node:fs/promises';
import { mkdir, readdir, stat, readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { basename, dirname, join, resolve } from 'node:path';
import { execa } from 'execa';
import {
parseGitHubUrl,
getPluginCachePath,
validatePluginSource,
} from '../utils/plugin-path.js';
import { PluginManifestSchema } from '../models/plugin-config.js';

/**
* Information about a cached plugin
Expand Down Expand Up @@ -291,3 +292,29 @@ export async function updateCachedPlugins(

return results;
}

/**
* Get the plugin name from plugin.json or fallback to directory name
* @param pluginPath - Resolved path to the plugin directory
* @returns The plugin name
*/
export async function getPluginName(pluginPath: string): Promise<string> {
const manifestPath = join(pluginPath, 'plugin.json');

if (existsSync(manifestPath)) {
try {
const content = await readFile(manifestPath, 'utf-8');
const manifest = JSON.parse(content);
const result = PluginManifestSchema.safeParse(manifest);

if (result.success && result.data.name) {
return result.data.name;
}
} catch {
// Fall through to directory name fallback
}
}

// Fallback to directory name
return basename(pluginPath);
}
121 changes: 115 additions & 6 deletions src/core/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,19 @@ import {
parseGitHubUrl,
parseFileSource,
} from '../utils/plugin-path.js';
import { fetchPlugin } from './plugin.js';
import { copyPluginToWorkspace, copyWorkspaceFiles, type CopyResult } from './transform.js';
import { fetchPlugin, getPluginName } from './plugin.js';
import {
copyPluginToWorkspace,
copyWorkspaceFiles,
collectPluginSkills,
type CopyResult,
} from './transform.js';
import { CLIENT_MAPPINGS } from '../models/client-mapping.js';
import {
resolveSkillNames,
getSkillKey,
type SkillEntry,
} from '../utils/skill-name-resolver.js';
import {
isPluginSpec,
resolvePluginSpec,
Expand Down Expand Up @@ -658,13 +668,15 @@ async function validateAllPlugins(
* @param workspacePath - Path to workspace directory
* @param clients - List of clients to sync for
* @param dryRun - Simulate without making changes
* @param skillNameMap - Optional map of skill folder names to resolved names
* @returns Plugin sync result
*/
async function copyValidatedPlugin(
validatedPlugin: ValidatedPlugin,
workspacePath: string,
clients: string[],
dryRun: boolean,
skillNameMap?: Map<string, string>,
): Promise<PluginSyncResult> {
const copyResults: CopyResult[] = [];

Expand All @@ -674,7 +686,7 @@ async function copyValidatedPlugin(
validatedPlugin.resolved,
workspacePath,
client as ClientType,
{ dryRun },
{ dryRun, ...(skillNameMap && { skillNameMap }) },
);
copyResults.push(...results);
}
Expand All @@ -689,6 +701,88 @@ async function copyValidatedPlugin(
};
}

/**
* Collected skill information with plugin context for name resolution
*/
interface CollectedSkillEntry {
/** Skill folder name */
folderName: string;
/** Plugin name (from plugin.json or directory name) */
pluginName: string;
/** Plugin source reference */
pluginSource: string;
/** Resolved plugin path */
pluginPath: string;
}

/**
* Collect all skills from all validated plugins
* This is the first pass of two-pass name resolution
* @param validatedPlugins - Array of validated plugins with resolved paths
* @returns Array of collected skill entries
*/
async function collectAllSkills(
validatedPlugins: ValidatedPlugin[],
): Promise<CollectedSkillEntry[]> {
const allSkills: CollectedSkillEntry[] = [];

for (const plugin of validatedPlugins) {
const pluginName = await getPluginName(plugin.resolved);
const skills = await collectPluginSkills(plugin.resolved, plugin.plugin);

for (const skill of skills) {
allSkills.push({
folderName: skill.folderName,
pluginName,
pluginSource: plugin.plugin,
pluginPath: plugin.resolved,
});
}
}

return allSkills;
}

/**
* Build skill name maps for each plugin based on resolved names
* @param allSkills - Collected skills from all plugins
* @returns Map from plugin path to skill name map (folder name -> resolved name)
*/
function buildPluginSkillNameMaps(
allSkills: CollectedSkillEntry[],
): Map<string, Map<string, string>> {
// Convert to SkillEntry format for resolver
const skillEntries: SkillEntry[] = allSkills.map((skill) => ({
folderName: skill.folderName,
pluginName: skill.pluginName,
pluginSource: skill.pluginSource,
}));

// Resolve names using the skill name resolver
const resolution = resolveSkillNames(skillEntries);

// Build per-plugin maps
const pluginMaps = new Map<string, Map<string, string>>();

for (let i = 0; i < allSkills.length; i++) {
const skill = allSkills[i];
const entry = skillEntries[i];
if (!skill || !entry) continue;
const resolvedName = resolution.nameMap.get(getSkillKey(entry));

if (resolvedName) {
let pluginMap = pluginMaps.get(skill.pluginPath);
if (!pluginMap) {
pluginMap = new Map<string, string>();
pluginMaps.set(skill.pluginPath, pluginMap);
}
pluginMap.set(skill.folderName, resolvedName);
}
}

return pluginMaps;
}

/**
* Sync all plugins to workspace for all configured clients
*
Expand Down Expand Up @@ -816,11 +910,26 @@ export async function syncWorkspace(
await selectivePurgeWorkspace(workspacePath, previousState, config.clients);
}

// Step 3b: Two-pass skill name resolution
// Pass 1: Collect all skills from all plugins
const allSkills = await collectAllSkills(validatedPlugins);

// Build per-plugin skill name maps (handles conflicts automatically)
const pluginSkillMaps = buildPluginSkillNameMaps(allSkills);

// Step 4: Copy fresh from all validated plugins
// Pass 2: Copy skills using resolved names
const pluginResults = await Promise.all(
validatedPlugins.map((validatedPlugin) =>
copyValidatedPlugin(validatedPlugin, workspacePath, config.clients, dryRun),
),
validatedPlugins.map((validatedPlugin) => {
const skillNameMap = pluginSkillMaps.get(validatedPlugin.resolved);
return copyValidatedPlugin(
validatedPlugin,
workspacePath,
config.clients,
dryRun,
skillNameMap,
);
}),
);

// Step 5: Copy workspace files if configured
Expand Down
Loading