Skip to content
Draft
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
17 changes: 17 additions & 0 deletions bun.lock

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

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"ci:all": "bun run format:check && bun run typecheck && bun run test",
"test": "bun test",
"test:coverage": "bun test --coverage",
"typecheck": "tsc --noEmit",
"typecheck": "tsgo --noEmit",
"dev": "bun --watch src/cli.ts",
"format": "prettier --write .",
"format:check": "prettier --check .",
Expand All @@ -33,6 +33,7 @@
"@straw-hat/prettier-config": "3.1.5",
"@types/bun": "1.2.2",
"@types/lodash.merge": "4.6.9",
"@typescript/native-preview": "7.0.0-dev.20251014.1",
"prettier": "3.6.2"
},
"peerDependencies": {
Expand Down
140 changes: 92 additions & 48 deletions src/commands/plugin-install.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import merge from 'lodash.merge';
import { join } from 'node:path';
import { z } from 'zod';
import { getConfigPath, getNotInitializedMessage, loadPluginsConfig } from '../config/loader';
import { DIR_CURSOR, FILE_AIPM_CONFIG, FILE_AIPM_CONFIG_LOCAL } from '../constants';
import { loadClaudeCodeMarketplaces, loadPluginsConfig } from '../config/loader';
import { DIR_CURSOR } from '../constants';
import { getErrorMessage } from '../errors';
import { loadTargetConfig, saveConfig } from '../helpers/aipm-config';
import { isClaudeCodeInstalled } from '../helpers/claude-code-config';
import { fileExists } from '../helpers/fs';
import { resolveMarketplacePath } from '../helpers/git';
import { defaultIO } from '../helpers/io';
Expand All @@ -15,9 +16,7 @@
const PluginInstallOptionsSchema = z.object({
pluginId: z.string().min(1),
cwd: z.string().optional(),
local: z.boolean().optional(),
dryRun: z.boolean().optional(),
force: z.boolean().optional(),
});

export async function pluginInstall(options: unknown): Promise<void> {
Expand All @@ -26,54 +25,92 @@
const cwd = cmd.cwd || process.cwd();

try {
const { config, sources } = await loadPluginsConfig(cwd);
const { pluginName, marketplaceName } = parsePluginId(cmd.pluginId);

if (!sources.project && !sources.local) {
const error = new Error(getNotInitializedMessage());
defaultIO.logError(error.message);
throw error;
// Load AIPM config if available (optional for zero-config Claude Code mode)
let config: any = {};
let aipmInitialized = false;
try {
const loaded = await loadPluginsConfig(cwd);
config = loaded.config;
aipmInitialized = true;
} catch {
// AIPM not initialized - this is OK for Claude Code zero-config mode
config = { marketplaces: {}, plugins: {} };
}
const configName = cmd.local ? getConfigPath(FILE_AIPM_CONFIG_LOCAL) : getConfigPath(FILE_AIPM_CONFIG);

const { pluginName, marketplaceName } = parsePluginId(cmd.pluginId);
const aipmMarketplaces: Record<string, any> = config.marketplaces || {};

const marketplace = config.marketplaces[marketplaceName];
// Check if plugin is already installed
if (aipmInitialized && config.plugins[cmd.pluginId]) {
defaultIO.logInfo(`Plugin '${cmd.pluginId}' is already installed`);
return;
}

const claudeMarketplaces = await loadClaudeCodeMarketplaces();
const allMarketplaces = merge({}, claudeMarketplaces, aipmMarketplaces);
const marketplace = allMarketplaces[marketplaceName];

if (!marketplace) {
const error = new Error(`Marketplace '${marketplaceName}' not found. Add it first with 'marketplace add'.`);
const availableMarketplaces = Object.keys(allMarketplaces);

let errorMessage: string;
if (!availableMarketplaces.includes(marketplaceName) && marketplaceName.startsWith('claude/')) {
const claudeCodeInstalled = await isClaudeCodeInstalled();
if (!claudeCodeInstalled) {
errorMessage = `Claude Code is not installed. Install Claude Code to use Claude Code marketplaces like '${marketplaceName}'.`;
} else {
errorMessage =
`Marketplace '${marketplaceName}' not found in Claude Code. ` +
`Available marketplaces: ${availableMarketplaces.join(', ')}`;
}
} else if (availableMarketplaces.length === 0) {
errorMessage = `Marketplace '${marketplaceName}' not found. No marketplaces available.`;
} else {
errorMessage =
`Marketplace '${marketplaceName}' not found. ` +
`Available marketplaces: ${availableMarketplaces.join(', ')}`;
}

const error = new Error(errorMessage);

Check failure on line 75 in src/commands/plugin-install.ts

View workflow job for this annotation

GitHub Actions / Lint and Test

error: Claude Code is not installed. Install Claude Code to use Claude Code marketplaces like 'claude/anthropic-agent-skills'.

at pluginInstall (/home/runner/work/aipm/aipm/src/commands/plugin-install.ts:75:21) at async <anonymous> (/home/runner/work/aipm/aipm/tests/commands/plugin-install.test.ts:64:13)

Check failure on line 75 in src/commands/plugin-install.ts

View workflow job for this annotation

GitHub Actions / Lint and Test

error: Claude Code is not installed. Install Claude Code to use Claude Code marketplaces like 'claude/anthropic-agent-skills'.

at pluginInstall (/home/runner/work/aipm/aipm/src/commands/plugin-install.ts:75:21) at async <anonymous> (/home/runner/work/aipm/aipm/tests/commands/plugin-install.test.ts:29:13)
defaultIO.logError(error.message);
throw error;
}

const marketplacePath = await resolveMarketplacePath(marketplaceName, marketplace, cwd, {
dryRun: cmd.dryRun,
});
let marketplacePath: string | null = null;

// For git/url sources, resolve the path (clone/download if needed)
if (marketplace.source && marketplace.source !== 'directory') {
marketplacePath = await resolveMarketplacePath(marketplaceName, marketplace, cwd, {
dryRun: cmd.dryRun,
});
} else {
// For directory sources, use the path directly
marketplacePath = marketplace.path;
}

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

// In dry-run mode, skip validation for git/url marketplaces that haven't been cached yet.
// Directory marketplaces can still be validated since they exist locally.
const isDirectoryMarketplace = marketplace.source === 'directory';
const shouldSkipValidation = cmd.dryRun && !isDirectoryMarketplace;

let manifest: Awaited<ReturnType<typeof loadMarketplaceManifest>> = null;
let pluginPath: string | null = null;

if (!shouldSkipValidation) {
// Always validate for directory sources; skip expensive operations for git/url in dry-run
const isDirectorySource = !marketplace.source || marketplace.source === 'directory';
const shouldValidate = isDirectorySource || !cmd.dryRun;

if (shouldValidate) {
manifest = await loadMarketplaceManifest(marketplacePath, getMarketplaceType(marketplaceName));

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

if (!(await fileExists(pluginPath))) {
const error = new Error(
`Plugin '${pluginName}' not found in marketplace '${marketplaceName}'. ` +
`Checked path: ${pluginPath}. ` +
`If the plugin is in a nested directory, use the full path shown in search results.`,
`Plugin '${pluginName}' not found in marketplace '${marketplaceName}'. ` + `Checked path: ${pluginPath}.`,
);
defaultIO.logError(error.message);
throw error;
Expand All @@ -90,44 +127,51 @@
}
}

const isAlreadyEnabled = config.plugins[cmd.pluginId]?.enabled;

if (isAlreadyEnabled && !cmd.force) {
defaultIO.logInfo(`Plugin '${cmd.pluginId}' is already installed`);
return;
}

if (cmd.dryRun) {
defaultIO.logInfo(`[DRY RUN] Would enable plugin '${cmd.pluginId}' in ${configName}`);
defaultIO.logInfo(`[DRY RUN] Would sync ${cmd.pluginId} to .cursor/`);
defaultIO.logInfo(`[DRY RUN] Would sync ${cmd.pluginId} to .cursor/skills/`);
return;
}

const targetConfig = await loadTargetConfig(cwd, cmd.local);
const updatedConfig = merge({}, targetConfig, {
plugins: { [cmd.pluginId]: { enabled: true } },
});

await saveConfig(cwd, updatedConfig, cmd.local);
defaultIO.logSuccess(`Enabled plugin '${cmd.pluginId}' in ${configName}`);

// pluginPath is guaranteed to be set here since we skip validation only in dry-run mode,
// and dry-run mode returns early above
// For git/url sources in dry-run, we skip validation so pluginPath won't be set
// In this case, we can't perform the sync, so we already returned above
if (!pluginPath) {
throw new Error(`Plugin path not resolved for '${pluginName}'`);
}

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

const syncResult =
isMetaPluginCheck && manifest
? await syncMetaPluginToCursor(marketplacePath, manifest, pluginName, marketplaceName, cursorDir)
: await syncPluginToCursor(pluginPath, marketplaceName, pluginName, cursorDir);
// Check if it's a meta-plugin with skills defined in the manifest
let syncResult;
if (isMetaPluginCheck && manifest) {
const pluginEntry = manifest.plugins.find((p) => p.name === pluginName);
if (pluginEntry && pluginEntry.skills && pluginEntry.skills.length > 0) {
// True meta-plugin with skills in manifest
syncResult = await syncMetaPluginToCursor(marketplacePath, manifest, pluginName, marketplaceName, cursorDir);
} else {
// Meta-plugin path but skills are in plugin.json (not in marketplace manifest)
// Fall back to regular plugin sync
syncResult = await syncPluginToCursor(pluginPath, marketplaceName, pluginName, cursorDir);
}
} else {
syncResult = await syncPluginToCursor(pluginPath, marketplaceName, pluginName, cursorDir);
}

// Save plugin to AIPM config if initialized (enables sync and uninstall)
if (aipmInitialized) {
const targetConfig = await loadTargetConfig(cwd, false);
const updatedConfig = merge({}, targetConfig, {
plugins: { [cmd.pluginId]: { enabled: true } },
});
await saveConfig(cwd, updatedConfig, false);
}

const summary = formatSyncResult(syncResult);

defaultIO.logSuccess(`Installed ${cmd.pluginId}`);
if (aipmInitialized) {
defaultIO.logSuccess(`Added plugin '${cmd.pluginId}' to .aipm/config.json`);
}
console.log(`\n✨ Plugin '${cmd.pluginId}' installed successfully! (${summary})\n`);
} catch (error: unknown) {
const message = getErrorMessage(error);
Expand Down
Loading
Loading