Skip to content

Commit f285018

Browse files
committed
fix(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. Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
1 parent d7796cc commit f285018

File tree

10 files changed

+365
-773
lines changed

10 files changed

+365
-773
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: 75 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import merge from 'lodash.merge';
22
import { join } from 'node:path';
33
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';
4+
import { loadClaudeCodeMarketplaces, loadPluginsConfig } from '../config/loader';
5+
import { DIR_CURSOR } from '../constants';
66
import { getErrorMessage } from '../errors';
7-
import { loadTargetConfig, saveConfig } from '../helpers/aipm-config';
7+
import { isClaudeCodeInstalled } from '../helpers/claude-code-config';
88
import { fileExists } from '../helpers/fs';
99
import { resolveMarketplacePath } from '../helpers/git';
1010
import { defaultIO } from '../helpers/io';
@@ -15,9 +15,7 @@ import { formatSyncResult, syncMetaPluginToCursor, syncPluginToCursor } from '..
1515
const PluginInstallOptionsSchema = z.object({
1616
pluginId: z.string().min(1),
1717
cwd: z.string().optional(),
18-
local: z.boolean().optional(),
1918
dryRun: z.boolean().optional(),
20-
force: z.boolean().optional(),
2119
});
2220

2321
export async function pluginInstall(options: unknown): Promise<void> {
@@ -26,54 +24,86 @@ export async function pluginInstall(options: unknown): Promise<void> {
2624
const cwd = cmd.cwd || process.cwd();
2725

2826
try {
29-
const { config, sources } = await loadPluginsConfig(cwd);
27+
const { pluginName, marketplaceName } = parsePluginId(cmd.pluginId);
3028

31-
if (!sources.project && !sources.local) {
32-
const error = new Error(getNotInitializedMessage());
33-
defaultIO.logError(error.message);
34-
throw error;
29+
// Load marketplaces from both AIPM config (optional) and Claude Code (auto-discovered)
30+
let aipmMarketplaces: Record<string, any> = {};
31+
try {
32+
const config = await loadPluginsConfig(cwd);
33+
aipmMarketplaces = config.config.marketplaces || {};
34+
} catch (error: unknown) {
35+
// AIPM config is optional - proceed without it
36+
// Log at info level so users know about config issues but can continue
37+
if (!(error instanceof Error && error.message.includes('not found'))) {
38+
const message = getErrorMessage(error);
39+
defaultIO.logInfo(`ℹ️ Config loading: ${message}`);
40+
}
3541
}
36-
const configName = cmd.local ? getConfigPath(FILE_AIPM_CONFIG_LOCAL) : getConfigPath(FILE_AIPM_CONFIG);
3742

38-
const { pluginName, marketplaceName } = parsePluginId(cmd.pluginId);
39-
40-
const marketplace = config.marketplaces[marketplaceName];
43+
const claudeMarketplaces = await loadClaudeCodeMarketplaces();
44+
const allMarketplaces = merge({}, claudeMarketplaces, aipmMarketplaces);
45+
const marketplace = allMarketplaces[marketplaceName];
4146

4247
if (!marketplace) {
43-
const error = new Error(`Marketplace '${marketplaceName}' not found. Add it first with 'marketplace add'.`);
48+
const availableMarketplaces = Object.keys(allMarketplaces);
49+
50+
let errorMessage: string;
51+
if (!availableMarketplaces.includes(marketplaceName) && marketplaceName.startsWith('claude/')) {
52+
const claudeCodeInstalled = await isClaudeCodeInstalled();
53+
if (!claudeCodeInstalled) {
54+
errorMessage = `Claude Code is not installed. Install Claude Code to use Claude Code marketplaces like '${marketplaceName}'.`;
55+
} else {
56+
errorMessage =
57+
`Marketplace '${marketplaceName}' not found in Claude Code. ` +
58+
`Available marketplaces: ${availableMarketplaces.join(', ')}`;
59+
}
60+
} else if (availableMarketplaces.length === 0) {
61+
errorMessage = `Marketplace '${marketplaceName}' not found. No marketplaces available.`;
62+
} else {
63+
errorMessage =
64+
`Marketplace '${marketplaceName}' not found. ` +
65+
`Available marketplaces: ${availableMarketplaces.join(', ')}`;
66+
}
67+
68+
const error = new Error(errorMessage);
4469
defaultIO.logError(error.message);
4570
throw error;
4671
}
4772

48-
const marketplacePath = await resolveMarketplacePath(marketplaceName, marketplace, cwd, {
49-
dryRun: cmd.dryRun,
50-
});
73+
let marketplacePath: string | null = null;
74+
75+
// For git/url sources, resolve the path (clone/download if needed)
76+
if (marketplace.source && marketplace.source !== 'directory') {
77+
marketplacePath = await resolveMarketplacePath(marketplaceName, marketplace, cwd, {
78+
dryRun: cmd.dryRun,
79+
});
80+
} else {
81+
// For directory sources, use the path directly
82+
marketplacePath = marketplace.path;
83+
}
5184

5285
if (!marketplacePath) {
5386
const error = new Error(`Marketplace '${marketplaceName}' has no path/url configured`);
5487
defaultIO.logError(error.message);
5588
throw error;
5689
}
5790

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-
6391
let manifest: Awaited<ReturnType<typeof loadMarketplaceManifest>> = null;
6492
let pluginPath: string | null = null;
6593

66-
if (!shouldSkipValidation) {
94+
// Always validate for directory sources; skip expensive operations for git/url in dry-run
95+
const isDirectorySource = !marketplace.source || marketplace.source === 'directory';
96+
const shouldValidate = isDirectorySource || !cmd.dryRun;
97+
98+
if (shouldValidate) {
6799
manifest = await loadMarketplaceManifest(marketplacePath, getMarketplaceType(marketplaceName));
68100

69101
// Resolve plugin path: try manifest first, then search recursively if needed
70102
pluginPath = await resolvePluginPath(marketplacePath, pluginName, manifest);
71103

72104
if (!(await fileExists(pluginPath))) {
73105
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.`,
106+
`Plugin '${pluginName}' not found in marketplace '${marketplaceName}'. ` + `Checked path: ${pluginPath}.`,
77107
);
78108
defaultIO.logError(error.message);
79109
throw error;
@@ -90,40 +120,35 @@ export async function pluginInstall(options: unknown): Promise<void> {
90120
}
91121
}
92122

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-
100123
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/`);
124+
defaultIO.logInfo(`[DRY RUN] Would sync ${cmd.pluginId} to .cursor/skills/`);
103125
return;
104126
}
105127

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-
114-
// pluginPath is guaranteed to be set here since we skip validation only in dry-run mode,
115-
// and dry-run mode returns early above
128+
// For git/url sources in dry-run, we skip validation so pluginPath won't be set
129+
// In this case, we can't perform the sync, so we already returned above
116130
if (!pluginPath) {
117131
throw new Error(`Plugin path not resolved for '${pluginName}'`);
118132
}
119133

120134
const cursorDir = join(cwd, DIR_CURSOR);
121135
const isMetaPluginCheck = isMetaPlugin(marketplacePath, pluginPath, manifest);
122136

123-
const syncResult =
124-
isMetaPluginCheck && manifest
125-
? await syncMetaPluginToCursor(marketplacePath, manifest, pluginName, marketplaceName, cursorDir)
126-
: await syncPluginToCursor(pluginPath, marketplaceName, pluginName, cursorDir);
137+
// Check if it's a meta-plugin with skills defined in the manifest
138+
let syncResult;
139+
if (isMetaPluginCheck && manifest) {
140+
const pluginEntry = manifest.plugins.find((p) => p.name === pluginName);
141+
if (pluginEntry && pluginEntry.skills && pluginEntry.skills.length > 0) {
142+
// True meta-plugin with skills in manifest
143+
syncResult = await syncMetaPluginToCursor(marketplacePath, manifest, pluginName, marketplaceName, cursorDir);
144+
} else {
145+
// Meta-plugin path but skills are in plugin.json (not in marketplace manifest)
146+
// Fall back to regular plugin sync
147+
syncResult = await syncPluginToCursor(pluginPath, marketplaceName, pluginName, cursorDir);
148+
}
149+
} else {
150+
syncResult = await syncPluginToCursor(pluginPath, marketplaceName, pluginName, cursorDir);
151+
}
127152

128153
const summary = formatSyncResult(syncResult);
129154

src/commands/plugin-uninstall.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { rm } from 'node:fs/promises';
1+
import { readdir, rm } from 'node:fs/promises';
22
import { join } from 'node:path';
33
import { z } from 'zod';
44
import { getConfigPath, getNotInitializedMessage, loadPluginsConfig } from '../config/loader';
@@ -147,6 +147,31 @@ export async function pluginUninstall(options: unknown): Promise<void> {
147147
}
148148
}
149149
}
150+
151+
// Remove flattened skill directories for this plugin
152+
// Pattern: .cursor/skills/aipm-<marketplace>-<plugin>[-subskill]
153+
const skillsDir = join(cwd, DIR_CURSOR, DIR_SKILLS);
154+
try {
155+
const entries = await readdir(skillsDir, { withFileTypes: true });
156+
const flattenedPrefix = `aipm-${marketplaceName.replace(/\//g, '-')}-${pluginName}`;
157+
for (const entry of entries) {
158+
if (entry.isDirectory() && entry.name.startsWith(flattenedPrefix)) {
159+
try {
160+
await rm(join(skillsDir, entry.name), { recursive: true });
161+
deletedCount++;
162+
} catch (error) {
163+
if (!isFileNotFoundError(error)) {
164+
throw error;
165+
}
166+
}
167+
}
168+
}
169+
} catch (error) {
170+
// Ignore errors reading skills directory
171+
if (!isFileNotFoundError(error)) {
172+
throw error;
173+
}
174+
}
150175
}
151176
} else {
152177
// Marketplace config missing - can still delete AIPM plugins, but not Claude Code meta-plugins

src/commands/sync.ts

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1-
import { rm, stat } from 'node:fs/promises';
1+
import { readdir, rm, stat } from 'node:fs/promises';
22
import { join } from 'node:path';
33
import { z } from 'zod';
44
import { getNotInitializedMessage, loadPluginsConfig } from '../config/loader';
5-
import { DIR_AIPM_NAMESPACE, DIR_HOOKS, DIR_MARKETPLACE, INTEGRATION_INCLUDE_ALL, PLUGIN_SUBDIRS } from '../constants';
5+
import {
6+
DIR_AIPM_NAMESPACE,
7+
DIR_HOOKS,
8+
DIR_MARKETPLACE,
9+
DIR_SKILLS,
10+
INTEGRATION_INCLUDE_ALL,
11+
PLUGIN_SUBDIRS,
12+
} from '../constants';
613
import { getErrorMessage } from '../errors';
714
import { ensureDir, fileExists } from '../helpers/fs';
815
import { resolveMarketplacePath } from '../helpers/git';
@@ -74,17 +81,6 @@ export async function sync(options: SyncOptions = {}): Promise<void> {
7481
// Collect hooks early to enable cleanup even if no plugins are enabled
7582
const collectedPluginHooks: CursorHooksConfig[] = [];
7683

77-
if (enabledPlugins.length === 0) {
78-
defaultIO.logInfo('No enabled plugins found');
79-
// Clean up hooks from disabled/uninstalled plugins
80-
if (!cmd.dryRun) {
81-
await mergeHooks(targetDir, collectedPluginHooks);
82-
}
83-
return;
84-
}
85-
86-
console.log(`\n🔄 Syncing ${enabledPlugins.length} enabled plugin(s)...\n`);
87-
8884
const targetSubdirs = getEnabledSubdirs(cursorIntegration?.include);
8985

9086
if (!cmd.dryRun) {
@@ -103,8 +99,42 @@ export async function sync(options: SyncOptions = {}): Promise<void> {
10399
}
104100
await ensureDir(aipmSubdirPath);
105101
}
102+
103+
// Clean up flattened skill directories and nested aipm structure for disabled plugins
104+
// Both flattened (aipm-*) and nested (aipm/marketplace/plugin) skill paths need cleanup
105+
// This must run even if no plugins are enabled to clean up skills from disabled plugins
106+
const skillsDir = join(targetDir, DIR_SKILLS);
107+
try {
108+
const entries = await readdir(skillsDir, { withFileTypes: true });
109+
for (const entry of entries) {
110+
if (entry.isDirectory()) {
111+
if (entry.name.startsWith('aipm-')) {
112+
// Remove flattened skill directories
113+
await rm(join(skillsDir, entry.name), { recursive: true, force: true });
114+
defaultIO.logInfo(`Cleaned up flattened skill: ${entry.name}`);
115+
} else if (entry.name === DIR_AIPM_NAMESPACE) {
116+
// Remove entire nested aipm structure which gets rebuilt
117+
await rm(join(skillsDir, entry.name), { recursive: true, force: true });
118+
defaultIO.logInfo(`Cleaned up nested skills structure`);
119+
}
120+
}
121+
}
122+
} catch {
123+
// Skills directory doesn't exist yet, which is fine
124+
}
125+
}
126+
127+
if (enabledPlugins.length === 0) {
128+
defaultIO.logInfo('No enabled plugins found');
129+
// Clean up hooks from disabled/uninstalled plugins
130+
if (!cmd.dryRun) {
131+
await mergeHooks(targetDir, collectedPluginHooks);
132+
}
133+
return;
106134
}
107135

136+
console.log(`\n🔄 Syncing ${enabledPlugins.length} enabled plugin(s)...\n`);
137+
108138
let installedCount = 0;
109139
let skippedCount = 0;
110140

src/config/loader.ts

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

0 commit comments

Comments
 (0)