Skip to content

Commit 89fc5b7

Browse files
authored
fix: locating plugin json file (#37)
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
1 parent 9993a54 commit 89fc5b7

File tree

4 files changed

+238
-22
lines changed

4 files changed

+238
-22
lines changed

src/commands/plugin-install.ts

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,7 @@ import { loadTargetConfig, saveConfig } from '../helpers/aipm-config';
77
import { fileExists } from '../helpers/fs';
88
import { resolveMarketplacePath } from '../helpers/git';
99
import { defaultIO } from '../helpers/io';
10-
import {
11-
getMarketplaceType,
12-
getPluginSourcePath,
13-
isPluginInManifest,
14-
loadMarketplaceManifest,
15-
} from '../helpers/marketplace';
10+
import { getMarketplaceType, loadMarketplaceManifest, resolvePluginPath } from '../helpers/marketplace';
1611
import { validatePluginStructure } from '../helpers/plugin';
1712
import { formatSyncResult, syncPluginToCursor } from '../helpers/sync-strategy';
1813

@@ -65,25 +60,30 @@ export async function pluginInstall(options: unknown): Promise<void> {
6560
throw error;
6661
}
6762

68-
const manifest = !cmd.dryRun
69-
? await loadMarketplaceManifest(marketplacePath, getMarketplaceType(marketplaceName))
70-
: null;
63+
// In dry-run mode, skip validation for git/url marketplaces that haven't been cached yet.
64+
// Directory marketplaces can still be validated since they exist locally.
65+
const isDirectoryMarketplace = marketplace.source === 'directory';
66+
const shouldSkipValidation = cmd.dryRun && !isDirectoryMarketplace;
7167

72-
if (!isPluginInManifest(pluginName, manifest)) {
73-
const error = new Error(`Plugin '${pluginName}' not found in marketplace '${marketplaceName}'`);
74-
defaultIO.logError(error.message);
75-
throw error;
76-
}
68+
let manifest: Awaited<ReturnType<typeof loadMarketplaceManifest>> = null;
69+
let pluginPath: string | null = null;
7770

78-
const pluginPath = getPluginSourcePath(marketplacePath, pluginName, manifest);
71+
if (!shouldSkipValidation) {
72+
manifest = await loadMarketplaceManifest(marketplacePath, getMarketplaceType(marketplaceName));
7973

80-
if (!cmd.dryRun && !(await fileExists(pluginPath))) {
81-
const error = new Error(`Plugin '${pluginName}' not found in marketplace '${marketplaceName}'`);
82-
defaultIO.logError(error.message);
83-
throw error;
84-
}
74+
// Resolve plugin path: try manifest first, then search recursively if needed
75+
pluginPath = await resolvePluginPath(marketplacePath, pluginName, manifest);
76+
77+
if (!(await fileExists(pluginPath))) {
78+
const error = new Error(
79+
`Plugin '${pluginName}' not found in marketplace '${marketplaceName}'. ` +
80+
`Checked path: ${pluginPath}. ` +
81+
`If the plugin is in a nested directory, use the full path shown in search results.`,
82+
);
83+
defaultIO.logError(error.message);
84+
throw error;
85+
}
8586

86-
if (!cmd.dryRun) {
8787
try {
8888
await validatePluginStructure(pluginPath);
8989
} catch (error: unknown) {
@@ -115,6 +115,12 @@ export async function pluginInstall(options: unknown): Promise<void> {
115115
await saveConfig(cwd, updatedConfig, cmd.local);
116116
defaultIO.logSuccess(`Enabled plugin '${cmd.pluginId}' in ${configName}`);
117117

118+
// pluginPath is guaranteed to be set here since we skip validation only in dry-run mode,
119+
// and dry-run mode returns early above
120+
if (!pluginPath) {
121+
throw new Error(`Plugin path not resolved for '${pluginName}'`);
122+
}
123+
118124
const cursorDir = join(cwd, DIR_CURSOR);
119125
const syncResult = await syncPluginToCursor(pluginPath, marketplaceName, pluginName, cursorDir);
120126
const summary = formatSyncResult(syncResult);

src/errors.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export class PluginManifestNotFoundError extends Error {
1111
public readonly pluginPath: string,
1212
options?: ErrorOptions,
1313
) {
14-
super('Plugin manifest not found: .claude-plugin/plugin.json is required', options);
14+
super(`Plugin manifest not found: ${pluginPath}/.claude-plugin/plugin.json is required`, options);
1515
this.name = 'PluginManifestNotFoundError';
1616
}
1717
}

src/helpers/marketplace.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,3 +214,43 @@ export function getPluginSourcePath(
214214

215215
return join(marketplacePath, pluginName);
216216
}
217+
218+
/**
219+
* Resolves the plugin path from manifest or by searching recursively if no manifest exists.
220+
*
221+
* The marketplacePath is already resolved from installLocation in known_marketplaces.json,
222+
* so we have the authoritative marketplace root. The manifest's source field supports n-level
223+
* deep paths (e.g., "./available-plugins/code-review-ai"), so if the manifest exists and lists
224+
* the plugin, we try its source path first.
225+
*
226+
* If the manifest path doesn't exist (e.g., incorrect source path), we fall back to recursive
227+
* search to discover the plugin's actual location. Recursive search is also used when no manifest
228+
* exists (for auto-discovery).
229+
*/
230+
export async function resolvePluginPath(
231+
marketplacePath: string,
232+
pluginName: string,
233+
manifest: MarketplaceManifest | null,
234+
): Promise<string> {
235+
// If manifest exists and lists the plugin, try its source path first
236+
if (manifest && isPluginInManifest(pluginName, manifest)) {
237+
const manifestPath = getPluginSourcePath(marketplacePath, pluginName, manifest);
238+
// Verify the manifest path actually exists
239+
if (await fileExists(manifestPath)) {
240+
return manifestPath;
241+
}
242+
// Manifest path doesn't exist (incorrect source), fall through to recursive search
243+
}
244+
245+
// Search recursively when:
246+
// 1. No manifest exists (auto-discovery)
247+
// 2. Manifest exists but path doesn't exist (incorrect source path)
248+
const availablePlugins = await getAvailablePlugins(marketplacePath, null);
249+
const matchingPlugin = availablePlugins.find((p) => p === pluginName || p.split('/').pop() === pluginName);
250+
if (matchingPlugin) {
251+
return join(marketplacePath, matchingPlugin);
252+
}
253+
254+
// Fallback: return path based on plugin name or manifest source (even if it doesn't exist)
255+
return getPluginSourcePath(marketplacePath, pluginName, manifest);
256+
}

tests/commands/plugin-install.test.ts

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,4 +431,174 @@ describe('plugin-install', () => {
431431
expect(config.plugins['plugin-2@local']).toBeDefined();
432432
});
433433
});
434+
435+
describe('nested plugin directories', () => {
436+
test('should install plugin from nested directory when using simple name', async () => {
437+
const marketplaceDir = join(testDir, 'marketplace');
438+
await mkdir(marketplaceDir, { recursive: true });
439+
440+
// Create plugin in nested directory: available-plugins/code-review-ai
441+
const nestedPluginDir = join(marketplaceDir, 'available-plugins', 'code-review-ai');
442+
await mkdir(join(nestedPluginDir, '.claude-plugin'), { recursive: true });
443+
await writeFile(
444+
join(nestedPluginDir, '.claude-plugin', 'plugin.json'),
445+
JSON.stringify({
446+
name: 'code-review-ai',
447+
version: '1.2.0',
448+
author: 'Test Author',
449+
}),
450+
);
451+
452+
// Create a command file
453+
await mkdir(join(nestedPluginDir, 'commands'), { recursive: true });
454+
await writeFile(join(nestedPluginDir, 'commands', 'review.md'), '# Review command');
455+
456+
const pluginsPath = join(testDir, '.aipm', 'config.json');
457+
const aipmDir = join(testDir, '.aipm');
458+
await mkdir(aipmDir, { recursive: true });
459+
await writeFile(
460+
pluginsPath,
461+
JSON.stringify({
462+
marketplaces: {
463+
local: { source: 'directory', path: './marketplace' },
464+
},
465+
plugins: {},
466+
}),
467+
);
468+
469+
const options = {
470+
pluginId: 'code-review-ai@local',
471+
cwd: testDir,
472+
};
473+
474+
await pluginInstall(options);
475+
476+
// Verify plugin was installed
477+
const config = JSON.parse(await Bun.file(pluginsPath).text());
478+
expect(config.plugins['code-review-ai@local']).toBeDefined();
479+
expect(config.plugins['code-review-ai@local'].enabled).toBe(true);
480+
481+
// Verify files were synced to .cursor/
482+
const commandsPath = join(testDir, '.cursor', 'commands', 'aipm', 'local', 'code-review-ai', 'review.md');
483+
expect(await fileExists(commandsPath)).toBe(true);
484+
});
485+
486+
test('should install plugin from nested directory when manifest has incorrect source path', async () => {
487+
const marketplaceDir = join(testDir, 'marketplace');
488+
await mkdir(marketplaceDir, { recursive: true });
489+
490+
// Create marketplace manifest with incorrect source path
491+
await mkdir(join(marketplaceDir, '.claude-plugin'), { recursive: true });
492+
await writeFile(
493+
join(marketplaceDir, '.claude-plugin', 'marketplace.json'),
494+
JSON.stringify({
495+
name: 'test-marketplace',
496+
owner: { name: 'Test Owner' },
497+
plugins: [
498+
{
499+
name: 'code-review-ai',
500+
source: './wrong-path/code-review-ai', // Incorrect path
501+
},
502+
],
503+
}),
504+
);
505+
506+
// Create plugin in actual nested directory: available-plugins/code-review-ai
507+
const nestedPluginDir = join(marketplaceDir, 'available-plugins', 'code-review-ai');
508+
await mkdir(join(nestedPluginDir, '.claude-plugin'), { recursive: true });
509+
await writeFile(
510+
join(nestedPluginDir, '.claude-plugin', 'plugin.json'),
511+
JSON.stringify({
512+
name: 'code-review-ai',
513+
version: '1.2.0',
514+
author: 'Test Author',
515+
}),
516+
);
517+
518+
await mkdir(join(nestedPluginDir, 'commands'), { recursive: true });
519+
await writeFile(join(nestedPluginDir, 'commands', 'review.md'), '# Review command');
520+
521+
const pluginsPath = join(testDir, '.aipm', 'config.json');
522+
const aipmDir = join(testDir, '.aipm');
523+
await mkdir(aipmDir, { recursive: true });
524+
await writeFile(
525+
pluginsPath,
526+
JSON.stringify({
527+
marketplaces: {
528+
local: { source: 'directory', path: './marketplace' },
529+
},
530+
plugins: {},
531+
}),
532+
);
533+
534+
const options = {
535+
pluginId: 'code-review-ai@local',
536+
cwd: testDir,
537+
};
538+
539+
await pluginInstall(options);
540+
541+
// Verify plugin was installed despite incorrect manifest path
542+
const config = JSON.parse(await Bun.file(pluginsPath).text());
543+
expect(config.plugins['code-review-ai@local']).toBeDefined();
544+
expect(config.plugins['code-review-ai@local'].enabled).toBe(true);
545+
546+
// Verify files were synced from the correct nested path
547+
const commandsPath = join(testDir, '.cursor', 'commands', 'aipm', 'local', 'code-review-ai', 'review.md');
548+
expect(await fileExists(commandsPath)).toBe(true);
549+
});
550+
551+
test('should handle multiple nested plugins with same base name', async () => {
552+
const marketplaceDir = join(testDir, 'marketplace');
553+
await mkdir(marketplaceDir, { recursive: true });
554+
555+
// Create two plugins with same base name in different nested directories
556+
const plugin1Dir = join(marketplaceDir, 'category-a', 'my-plugin');
557+
await mkdir(join(plugin1Dir, '.claude-plugin'), { recursive: true });
558+
await writeFile(
559+
join(plugin1Dir, '.claude-plugin', 'plugin.json'),
560+
JSON.stringify({
561+
name: 'my-plugin',
562+
version: '1.0.0',
563+
author: 'Test Author',
564+
}),
565+
);
566+
567+
const plugin2Dir = join(marketplaceDir, 'category-b', 'my-plugin');
568+
await mkdir(join(plugin2Dir, '.claude-plugin'), { recursive: true });
569+
await writeFile(
570+
join(plugin2Dir, '.claude-plugin', 'plugin.json'),
571+
JSON.stringify({
572+
name: 'my-plugin',
573+
version: '2.0.0',
574+
author: 'Test Author',
575+
}),
576+
);
577+
578+
const pluginsPath = join(testDir, '.aipm', 'config.json');
579+
const aipmDir = join(testDir, '.aipm');
580+
await mkdir(aipmDir, { recursive: true });
581+
await writeFile(
582+
pluginsPath,
583+
JSON.stringify({
584+
marketplaces: {
585+
local: { source: 'directory', path: './marketplace' },
586+
},
587+
plugins: {},
588+
}),
589+
);
590+
591+
const options = {
592+
pluginId: 'my-plugin@local',
593+
cwd: testDir,
594+
};
595+
596+
// Should install the first matching plugin found
597+
await pluginInstall(options);
598+
599+
const config = JSON.parse(await Bun.file(pluginsPath).text());
600+
expect(config.plugins['my-plugin@local']).toBeDefined();
601+
expect(config.plugins['my-plugin@local'].enabled).toBe(true);
602+
});
603+
});
434604
});

0 commit comments

Comments
 (0)