Skip to content

Commit 70151b8

Browse files
committed
feat(skills): flatten skill directories to avoid nesting limitations
Each skill now gets a completely flat directory name (e.g., aipm-marketplace-plugin-skill) with no nesting, addressing Claude Code's limitation with nested directories (issue #10238). Also fixed fallback for meta-plugins where skills are defined in plugin.json rather than marketplace manifest.
1 parent d7796cc commit 70151b8

File tree

7 files changed

+207
-94
lines changed

7 files changed

+207
-94
lines changed

bun.lock

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
"ci:all": "bun run format:check && bun run typecheck && bun run test",
2222
"test": "bun test",
2323
"test:coverage": "bun test --coverage",
24-
"typecheck": "tsc --noEmit",
24+
"typecheck": "tsgo --noEmit",
2525
"dev": "bun --watch src/cli.ts",
2626
"format": "prettier --write .",
2727
"format:check": "prettier --check .",
@@ -33,6 +33,7 @@
3333
"@straw-hat/prettier-config": "3.1.5",
3434
"@types/bun": "1.2.2",
3535
"@types/lodash.merge": "4.6.9",
36+
"@typescript/native-preview": "7.0.0-dev.20251014.1",
3637
"prettier": "3.6.2"
3738
},
3839
"peerDependencies": {

src/commands/plugin-install.ts

Lines changed: 30 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
1-
import merge from 'lodash.merge';
21
import { join } from 'node:path';
32
import { z } from 'zod';
4-
import { getConfigPath, getNotInitializedMessage, loadPluginsConfig } from '../config/loader';
5-
import { DIR_CURSOR, FILE_AIPM_CONFIG, FILE_AIPM_CONFIG_LOCAL } from '../constants';
3+
import { loadClaudeCodeMarketplaces } from '../config/loader';
4+
import { DIR_CURSOR } from '../constants';
65
import { getErrorMessage } from '../errors';
7-
import { loadTargetConfig, saveConfig } from '../helpers/aipm-config';
86
import { fileExists } from '../helpers/fs';
9-
import { resolveMarketplacePath } from '../helpers/git';
107
import { defaultIO } from '../helpers/io';
118
import { getMarketplaceType, loadMarketplaceManifest, resolvePluginPath } from '../helpers/marketplace';
129
import { isMetaPlugin, parsePluginId, validatePluginStructure } from '../helpers/plugin';
@@ -26,54 +23,42 @@ export async function pluginInstall(options: unknown): Promise<void> {
2623
const cwd = cmd.cwd || process.cwd();
2724

2825
try {
29-
const { config, sources } = await loadPluginsConfig(cwd);
30-
31-
if (!sources.project && !sources.local) {
32-
const error = new Error(getNotInitializedMessage());
33-
defaultIO.logError(error.message);
34-
throw error;
35-
}
36-
const configName = cmd.local ? getConfigPath(FILE_AIPM_CONFIG_LOCAL) : getConfigPath(FILE_AIPM_CONFIG);
37-
3826
const { pluginName, marketplaceName } = parsePluginId(cmd.pluginId);
3927

40-
const marketplace = config.marketplaces[marketplaceName];
28+
// Auto-discover Claude Code marketplaces
29+
const claudeMarketplaces = await loadClaudeCodeMarketplaces();
30+
31+
const marketplace = claudeMarketplaces[marketplaceName];
4132

4233
if (!marketplace) {
43-
const error = new Error(`Marketplace '${marketplaceName}' not found. Add it first with 'marketplace add'.`);
34+
const error = new Error(
35+
`Marketplace '${marketplaceName}' not found in Claude Code. ` +
36+
`Available marketplaces: ${Object.keys(claudeMarketplaces).join(', ')}`,
37+
);
4438
defaultIO.logError(error.message);
4539
throw error;
4640
}
4741

48-
const marketplacePath = await resolveMarketplacePath(marketplaceName, marketplace, cwd, {
49-
dryRun: cmd.dryRun,
50-
});
42+
const marketplacePath = marketplace.path;
5143

5244
if (!marketplacePath) {
53-
const error = new Error(`Marketplace '${marketplaceName}' has no path/url configured`);
45+
const error = new Error(`Marketplace '${marketplaceName}' has no path`);
5446
defaultIO.logError(error.message);
5547
throw error;
5648
}
5749

58-
// In dry-run mode, skip validation for git/url marketplaces that haven't been cached yet.
59-
// Directory marketplaces can still be validated since they exist locally.
60-
const isDirectoryMarketplace = marketplace.source === 'directory';
61-
const shouldSkipValidation = cmd.dryRun && !isDirectoryMarketplace;
62-
6350
let manifest: Awaited<ReturnType<typeof loadMarketplaceManifest>> = null;
6451
let pluginPath: string | null = null;
6552

66-
if (!shouldSkipValidation) {
53+
if (!cmd.dryRun) {
6754
manifest = await loadMarketplaceManifest(marketplacePath, getMarketplaceType(marketplaceName));
6855

6956
// Resolve plugin path: try manifest first, then search recursively if needed
7057
pluginPath = await resolvePluginPath(marketplacePath, pluginName, manifest);
7158

7259
if (!(await fileExists(pluginPath))) {
7360
const error = new Error(
74-
`Plugin '${pluginName}' not found in marketplace '${marketplaceName}'. ` +
75-
`Checked path: ${pluginPath}. ` +
76-
`If the plugin is in a nested directory, use the full path shown in search results.`,
61+
`Plugin '${pluginName}' not found in marketplace '${marketplaceName}'. ` + `Checked path: ${pluginPath}.`,
7762
);
7863
defaultIO.logError(error.message);
7964
throw error;
@@ -90,27 +75,11 @@ export async function pluginInstall(options: unknown): Promise<void> {
9075
}
9176
}
9277

93-
const isAlreadyEnabled = config.plugins[cmd.pluginId]?.enabled;
94-
95-
if (isAlreadyEnabled && !cmd.force) {
96-
defaultIO.logInfo(`Plugin '${cmd.pluginId}' is already installed`);
97-
return;
98-
}
99-
10078
if (cmd.dryRun) {
101-
defaultIO.logInfo(`[DRY RUN] Would enable plugin '${cmd.pluginId}' in ${configName}`);
102-
defaultIO.logInfo(`[DRY RUN] Would sync ${cmd.pluginId} to .cursor/`);
79+
defaultIO.logInfo(`[DRY RUN] Would sync ${cmd.pluginId} to .cursor/skills/`);
10380
return;
10481
}
10582

106-
const targetConfig = await loadTargetConfig(cwd, cmd.local);
107-
const updatedConfig = merge({}, targetConfig, {
108-
plugins: { [cmd.pluginId]: { enabled: true } },
109-
});
110-
111-
await saveConfig(cwd, updatedConfig, cmd.local);
112-
defaultIO.logSuccess(`Enabled plugin '${cmd.pluginId}' in ${configName}`);
113-
11483
// pluginPath is guaranteed to be set here since we skip validation only in dry-run mode,
11584
// and dry-run mode returns early above
11685
if (!pluginPath) {
@@ -120,10 +89,21 @@ export async function pluginInstall(options: unknown): Promise<void> {
12089
const cursorDir = join(cwd, DIR_CURSOR);
12190
const isMetaPluginCheck = isMetaPlugin(marketplacePath, pluginPath, manifest);
12291

123-
const syncResult =
124-
isMetaPluginCheck && manifest
125-
? await syncMetaPluginToCursor(marketplacePath, manifest, pluginName, marketplaceName, cursorDir)
126-
: await syncPluginToCursor(pluginPath, marketplaceName, pluginName, cursorDir);
92+
// Check if it's a meta-plugin with skills defined in the manifest
93+
let syncResult;
94+
if (isMetaPluginCheck && manifest) {
95+
const pluginEntry = manifest.plugins.find((p) => p.name === pluginName);
96+
if (pluginEntry && pluginEntry.skills && pluginEntry.skills.length > 0) {
97+
// True meta-plugin with skills in manifest
98+
syncResult = await syncMetaPluginToCursor(marketplacePath, manifest, pluginName, marketplaceName, cursorDir);
99+
} else {
100+
// Meta-plugin path but skills are in plugin.json (not in marketplace manifest)
101+
// Fall back to regular plugin sync
102+
syncResult = await syncPluginToCursor(pluginPath, marketplaceName, pluginName, cursorDir);
103+
}
104+
} else {
105+
syncResult = await syncPluginToCursor(pluginPath, marketplaceName, pluginName, cursorDir);
106+
}
127107

128108
const summary = formatSyncResult(syncResult);
129109

src/config/loader.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,3 +152,24 @@ export function getConfigPaths(baseDir: string) {
152152
gitignore: join(baseDir, FILE_GITIGNORE),
153153
};
154154
}
155+
156+
/**
157+
* Load Claude Code's marketplaces without requiring AIPM config
158+
*/
159+
export async function loadClaudeCodeMarketplaces(): Promise<Record<string, MarketplaceSource>> {
160+
const claudeMarketplaces: Record<string, MarketplaceSource> = {};
161+
162+
if (await isClaudeCodeInstalled()) {
163+
const claudeCodeMarketplaces = await readClaudeCodeMarketplaces();
164+
165+
for (const [marketplaceName, marketplaceConfig] of Object.entries(claudeCodeMarketplaces)) {
166+
const prefixedName = `claude/${marketplaceName}`;
167+
claudeMarketplaces[prefixedName] = convertClaudeMarketplaceToAIPM(
168+
marketplaceName,
169+
marketplaceConfig,
170+
) as MarketplaceSource;
171+
}
172+
}
173+
174+
return claudeMarketplaces;
175+
}

src/helpers/sync-strategy.ts

Lines changed: 60 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { cp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
2+
import type { Dirent } from 'node:fs';
23
import { basename, extname, join } from 'node:path';
34
import { DIR_AIPM_NAMESPACE, DIR_HOOKS, DIR_SKILLS, FILE_HOOKS_JSON } from '../constants';
45
import { DirectoryNotFoundError, isFileNotFoundError } from '../errors';
@@ -32,7 +33,7 @@ export type SyncResult = AipmPluginSyncResult | ClaudeCodePluginSyncResult;
3233
* - commands/*.md → .cursor/commands/aipm/marketplace-name/plugin-name/
3334
* - rules/*.mdc → .cursor/rules/aipm/marketplace-name/plugin-name/
3435
* - agents/*.md → .cursor/agents/aipm/marketplace-name/plugin-name/
35-
* - skills/*.md → .cursor/skills/aipm/marketplace-name/plugin-name/
36+
* - skills/ → .cursor/skills/aipm-marketplace-plugin/ (flattened dir name, see #10238)
3637
* - hooks/* → .cursor/hooks/aipm/marketplace-name/plugin-name/
3738
*/
3839
export async function syncPluginToCursor(
@@ -74,13 +75,30 @@ export async function syncPluginToCursor(
7475
);
7576
result.agentsCount = agentsResult;
7677

77-
// Sync skills/*.md to .cursor/skills/aipm/marketplace/plugin/
78-
const skillsResult = await syncDirectory(
79-
join(pluginPath, 'skills'),
80-
join(cursorDir, 'skills', DIR_AIPM_NAMESPACE, marketplaceName, pluginName),
81-
['.md'],
82-
);
83-
result.skillsCount = skillsResult;
78+
// Sync skills/ to .cursor/skills/aipm-marketplace-plugin-skill/ (flat directory names)
79+
// Each skill subdirectory gets its own flattened name to avoid nesting
80+
const skillsSourcePath = join(pluginPath, 'skills');
81+
82+
if (await fileExists(skillsSourcePath)) {
83+
let entries: Dirent[] = [];
84+
try {
85+
entries = await readdir(skillsSourcePath, { withFileTypes: true });
86+
} catch (error: unknown) {
87+
if (!isFileNotFoundError(error)) {
88+
throw new DirectoryNotFoundError(skillsSourcePath, { cause: error });
89+
}
90+
}
91+
92+
for (const entry of entries) {
93+
if (entry.isDirectory()) {
94+
const flattenedSkillName = getFlattenedSkillName(DIR_AIPM_NAMESPACE, marketplaceName, pluginName, entry.name);
95+
const skillSourcePath = join(skillsSourcePath, entry.name);
96+
const skillTargetPath = join(cursorDir, 'skills', flattenedSkillName);
97+
await cp(skillSourcePath, skillTargetPath, { recursive: true });
98+
result.skillsCount++;
99+
}
100+
}
101+
}
84102

85103
// Sync hooks - check for hooks.json and translate if needed
86104
const hooksResult = await syncHooks(pluginPath, marketplaceName, pluginName, cursorDir);
@@ -249,6 +267,27 @@ async function syncHooks(
249267
}
250268
}
251269

270+
/**
271+
* Generates a flattened skill name using delimiter
272+
* e.g., "aipm-marketplace-plugin" or "aipm-marketplace-plugin-skill-subskill"
273+
*
274+
* Claude Code does not support nested directories for skills.
275+
* See: https://github.com/anthropics/claude-code/issues/10238
276+
* All skills must be flat files in .cursor/skills/ directory.
277+
*/
278+
function getFlattenedSkillName(
279+
namespace: string,
280+
marketplaceName: string,
281+
pluginName: string,
282+
skillPath?: string,
283+
): string {
284+
const parts = [namespace, marketplaceName.replace(/\//g, '-'), pluginName];
285+
if (skillPath) {
286+
parts.push(skillPath.replace(/\//g, '-'));
287+
}
288+
return parts.join('-');
289+
}
290+
252291
/**
253292
* Syncs a directory, copying files with specified extensions
254293
* @param sourceDir Source directory
@@ -296,6 +335,10 @@ async function syncDirectory(sourceDir: string, targetDir: string, extensions: s
296335
* Syncs a Claude Code meta-plugin to Cursor directories.
297336
* Meta-plugins point to the marketplace root and define skills in the manifest.
298337
* We sync each skill directory listed in the manifest's skills array.
338+
*
339+
* Skill directory paths are flattened to avoid nested directories
340+
* (Claude Code does not support nested skill directories).
341+
* See: https://github.com/anthropics/claude-code/issues/10238
299342
*/
300343
export async function syncMetaPluginToCursor(
301344
marketplacePath: string,
@@ -315,11 +358,16 @@ export async function syncMetaPluginToCursor(
315358
const normalizedSkillPath = skillPath.replace(/^\.\//, '');
316359
const fullSkillPath = join(marketplacePath, normalizedSkillPath);
317360

318-
const marketplaceSegments = marketplaceName.split('/');
319-
const targetPath = join(cursorDir, DIR_SKILLS, DIR_AIPM_NAMESPACE, ...marketplaceSegments, normalizedSkillPath);
361+
// Strip 'skills/' prefix if present, then flatten the remaining path
362+
// e.g., 'skills/document-skills/xlsx' -> 'document-skills/xlsx' -> 'document-skills-xlsx'
363+
const skillNamePath = normalizedSkillPath.replace(/^skills\//, '');
364+
const flattenedSkillName = getFlattenedSkillName(DIR_AIPM_NAMESPACE, marketplaceName, pluginName, skillNamePath);
365+
const targetPath = join(cursorDir, DIR_SKILLS, flattenedSkillName);
320366

321-
const count = await syncDirectory(fullSkillPath, targetPath, ['.md']);
322-
skillsCount += count;
367+
if (await fileExists(fullSkillPath)) {
368+
await cp(fullSkillPath, targetPath, { recursive: true });
369+
skillsCount++;
370+
}
323371
}
324372

325373
return { type: 'claudecode', skillsCount };
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2+
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
3+
import { tmpdir } from 'node:os';
4+
import { join } from 'node:path';
5+
import { pluginInstall } from '../../src/commands/plugin-install';
6+
import { fileExists } from '../../src/helpers/fs';
7+
8+
describe('plugin-install (simplified - no config required)', () => {
9+
let testDir: string;
10+
11+
beforeEach(async () => {
12+
testDir = await mkdtemp(join(tmpdir(), 'aipm-simplified-'));
13+
await mkdir(join(testDir, '.cursor'), { recursive: true });
14+
});
15+
16+
afterEach(async () => {
17+
await rm(testDir, { recursive: true, force: true });
18+
});
19+
20+
test('should install a plugin from Claude Code without init', async () => {
21+
// No .aipm/ directory created, no config needed
22+
const options = {
23+
pluginId: 'document-skills@claude/anthropic-agent-skills',
24+
cwd: testDir,
25+
};
26+
27+
await pluginInstall(options);
28+
29+
// Verify skills were synced
30+
const skillsDir = join(testDir, '.cursor', 'skills');
31+
expect(await fileExists(skillsDir)).toBe(true);
32+
33+
// Verify flattened skill directories (each skill is flattened as a separate dir)
34+
const xlsxSkillDir = join(skillsDir, 'aipm-claude-anthropic-agent-skills-document-skills-xlsx');
35+
expect(await fileExists(xlsxSkillDir)).toBe(true);
36+
37+
// Verify internal structure is preserved within the skill
38+
const skillMd = join(xlsxSkillDir, 'SKILL.md');
39+
expect(await fileExists(skillMd)).toBe(true);
40+
});
41+
42+
test('should error if marketplace not found in Claude Code', async () => {
43+
const options = {
44+
pluginId: 'some-plugin@claude/nonexistent-marketplace',
45+
cwd: testDir,
46+
};
47+
48+
await expect(pluginInstall(options)).rejects.toThrow(
49+
"Marketplace 'claude/nonexistent-marketplace' not found in Claude Code",
50+
);
51+
});
52+
53+
test('should work from any directory without project setup', async () => {
54+
// Fresh temp directory with minimal structure
55+
const options = {
56+
pluginId: 'example-skills@claude/anthropic-agent-skills',
57+
cwd: testDir,
58+
};
59+
60+
// Should succeed without any config files or init
61+
await pluginInstall(options);
62+
63+
// Verify the plugin was installed
64+
const skillsDir = join(testDir, '.cursor', 'skills');
65+
expect(await fileExists(skillsDir)).toBe(true);
66+
});
67+
});

0 commit comments

Comments
 (0)