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
82 changes: 73 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "allagents",
"version": "0.8.2",
"version": "0.8.3",
"description": "CLI tool for managing multi-repo AI agent workspaces with plugin synchronization",
"type": "module",
"bin": {
Expand Down
55 changes: 40 additions & 15 deletions src/core/marketplace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
parseMarketplaceManifest,
resolvePluginSourcePath,
} from '../utils/marketplace-manifest-parser.js';
import { fetchPlugin } from './plugin.js';
import type { FetchResult } from './plugin.js';

/**
* Source types for marketplaces
Expand Down Expand Up @@ -575,7 +577,12 @@ export function parsePluginSpec(spec: string): {
*/
export async function resolvePluginSpec(
spec: string,
options: { subpath?: string; marketplaceNameOverride?: string } = {},
options: {
subpath?: string;
marketplaceNameOverride?: string;
marketplacePathOverride?: string;
fetchFn?: (url: string) => Promise<FetchResult>;
} = {},
): Promise<{ path: string; marketplace: string; plugin: string } | null> {
const parsed = parsePluginSpec(spec);
if (!parsed) {
Expand All @@ -584,34 +591,52 @@ export async function resolvePluginSpec(

// Use override name if provided (e.g., when manifest changed the marketplace name)
const marketplaceName = options.marketplaceNameOverride ?? parsed.marketplaceName;
const marketplace = await getMarketplace(marketplaceName);
if (!marketplace) {
return null;

// Determine marketplace path: use override or look up from registry
let marketplacePath: string | null = options.marketplacePathOverride ?? null;
if (!marketplacePath) {
const marketplace = await getMarketplace(marketplaceName);
if (!marketplace) {
return null;
}
marketplacePath = marketplace.path;
}

// Try manifest-based resolution first: look up plugin name in manifest entries
const manifestResult = await parseMarketplaceManifest(marketplace.path);
const manifestResult = await parseMarketplaceManifest(marketplacePath);
if (manifestResult.success) {
const pluginEntry = manifestResult.data.plugins.find(
(p) => p.name === parsed.plugin,
);
if (pluginEntry) {
const resolvedSource = typeof pluginEntry.source === 'string'
? resolve(marketplace.path, pluginEntry.source)
: pluginEntry.source.url;
if (typeof resolvedSource === 'string' && existsSync(resolvedSource)) {
return {
path: resolvedSource,
marketplace: marketplaceName,
plugin: parsed.plugin,
};
if (typeof pluginEntry.source === 'string') {
// Local path source - resolve relative to marketplace
const resolvedPath = resolve(marketplacePath, pluginEntry.source);
if (existsSync(resolvedPath)) {
return {
path: resolvedPath,
marketplace: marketplaceName,
plugin: parsed.plugin,
};
}
} else {
// URL source - fetch/clone the plugin
const fetchFn = options.fetchFn ?? fetchPlugin;
const fetchResult = await fetchFn(pluginEntry.source.url);
if (fetchResult.success && fetchResult.cachePath) {
return {
path: fetchResult.cachePath,
marketplace: marketplaceName,
plugin: parsed.plugin,
};
}
}
}
}

// Fall back to directory-based lookup
const subpath = options.subpath ?? parsed.subpath ?? 'plugins';
const pluginPath = join(marketplace.path, subpath, parsed.plugin);
const pluginPath = join(marketplacePath, subpath, parsed.plugin);

if (!existsSync(pluginPath)) {
return null;
Expand Down
68 changes: 68 additions & 0 deletions tests/unit/core/marketplace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
parsePluginSpec,
isPluginSpec,
getMarketplacePluginsFromManifest,
resolvePluginSpec,
} from '../../../src/core/marketplace.js';

describe('parsePluginSpec', () => {
Expand Down Expand Up @@ -160,6 +161,73 @@ describe('getMarketplacePluginsFromManifest', () => {
expect(result.plugins[0].source).toBe('https://github.com/org/repo.git');
});

it('should handle URL source plugins with resolved cache path', async () => {
// Create a fake cached plugin directory (simulating what fetchPlugin would produce)
const cachedPluginDir = join(testDir, 'cached-external');
mkdirSync(cachedPluginDir, { recursive: true });

const manifest = {
name: 'test',
description: 'Test',
plugins: [
{
name: 'external',
description: 'External plugin',
source: { source: 'url', url: 'https://github.com/org/repo.git' },
},
],
};
writeFileSync(
join(testDir, '.claude-plugin', 'marketplace.json'),
JSON.stringify(manifest),
);

// resolvePluginSpec should resolve URL-source plugins by fetching them
// We pass a mock fetchFn that returns the cached path
const result = await resolvePluginSpec('external@test-marketplace', {
marketplacePathOverride: testDir,
fetchFn: async () => ({
success: true,
action: 'fetched' as const,
cachePath: cachedPluginDir,
}),
});

expect(result).not.toBeNull();
expect(result!.path).toBe(cachedPluginDir);
expect(result!.plugin).toBe('external');
});

it('should return null for URL source plugins when fetch fails', async () => {
const manifest = {
name: 'test',
description: 'Test',
plugins: [
{
name: 'external',
description: 'External plugin',
source: { source: 'url', url: 'https://github.com/org/repo.git' },
},
],
};
writeFileSync(
join(testDir, '.claude-plugin', 'marketplace.json'),
JSON.stringify(manifest),
);

const result = await resolvePluginSpec('external@test-marketplace', {
marketplacePathOverride: testDir,
fetchFn: async () => ({
success: false,
action: 'skipped' as const,
cachePath: '',
error: 'Network error',
}),
});

expect(result).toBeNull();
});

it('should return plugins without warnings when manifest is missing description', async () => {
const manifest = {
name: 'test',
Expand Down