From b77ab299b78d95066837432bce1441d9c03e572d Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:57:48 +0000 Subject: [PATCH] feat(skills): support multiple marketplaces with CRUD and install metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `marketplaces` array to GlobalConfig (alongside legacy `marketplace` field) - Add `name` field to MarketplaceConfig for human-readable marketplace identity - Add `getAllMarketplaces()` helper that merges legacy single-marketplace config with the new array (backward-compatible migration on read) - `marketplace add` — new command that appends a marketplace to the array, clones it, and installs all plugins (replaces setup as the canonical command) - `marketplace setup` — kept as alias for backward compatibility - `marketplace list` — lists all registered marketplaces with their config - `marketplace remove ` — removes a marketplace from the config - `marketplace sync` — now prompts to select which marketplace when multiple are registered; supports `--marketplace ` to skip the prompt - Install metadata: `copyPluginSkills` writes `.pncli-installed.json` into the target skills directory, tracking which marketplace/plugin each skill came from - `skills uninstall ` — removes a skill by name using metadata, updates the tracking file, and reports what was tracked - All new logic covered by tests (30 tests in commands.test.ts, 303 total) Closes #240 Co-authored-by: Sunny Kolattukudy --- src/services/skills/commands.test.ts | 106 ++++++- src/services/skills/commands.ts | 406 +++++++++++++++++++++++++-- src/types/config.ts | 2 + 3 files changed, 496 insertions(+), 18 deletions(-) diff --git a/src/services/skills/commands.test.ts b/src/services/skills/commands.test.ts index 8734aee..f70b920 100644 --- a/src/services/skills/commands.test.ts +++ b/src/services/skills/commands.test.ts @@ -2,7 +2,8 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import fs from 'fs'; import path from 'path'; import os from 'os'; -import { resolvePluginChoices, resolveSkillsSrc, copyPluginSkills, injectTokenIntoUrl, repoNameFromUrl, defaultMarketplacePath } from './commands.js'; +import { resolvePluginChoices, resolveSkillsSrc, copyPluginSkills, injectTokenIntoUrl, repoNameFromUrl, defaultMarketplacePath, getAllMarketplaces, getInstalledMetaPath, readInstalledMeta } from './commands.js'; +import type { GlobalConfig } from '../../types/config.js'; type ReaddirResult = ReturnType; @@ -196,4 +197,107 @@ describe('copyPluginSkills', () => { expect(installed).toEqual([]); expect(failed).toEqual([]); }); + + it('writes install metadata when meta context is provided', () => { + vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined); + vi.spyOn(fs, 'readdirSync').mockImplementation((p) => { + const s = String(p); + if (s === '/market/plugins/sunny/skills') return ['skill-one'] as unknown as ReaddirResult; + return [] as unknown as ReaddirResult; + }); + vi.spyOn(fs, 'statSync').mockReturnValue({ isDirectory: () => true } as fs.Stats); + vi.spyOn(fs, 'rmSync').mockReturnValue(undefined); + vi.spyOn(fs, 'cpSync').mockReturnValue(undefined); + vi.spyOn(fs, 'existsSync').mockReturnValue(false); + const writeFileSpy = vi.spyOn(fs, 'writeFileSync').mockReturnValue(undefined); + + const { installed } = copyPluginSkills('/market/plugins/sunny/skills', targetDir, { + marketplace: 'my-market', + plugin: 'sunny', + installedFrom: 'https://github.com/owner/my-market.git', + }); + expect(installed).toEqual(['skill-one']); + + // The metadata write should have been called + expect(writeFileSpy).toHaveBeenCalled(); + const [writePath, writeContent] = writeFileSpy.mock.calls[0] as [string, string]; + expect(writePath).toContain('.pncli-installed.json'); + const parsed = JSON.parse(writeContent) as { version: number; skills: Record }; + expect(parsed.version).toBe(1); + expect(parsed.skills['skill-one'].marketplace).toBe('my-market'); + }); +}); + +// ── getAllMarketplaces ───────────────────────────────────────────────────────── + +describe('getAllMarketplaces', () => { + it('returns entries from the new marketplaces array', () => { + const config: GlobalConfig = { + marketplaces: [ + { name: 'alpha', repoUrl: 'https://github.com/org/alpha.git', localPath: '/home/user/.agents/marketplaces/alpha' }, + { name: 'beta', repoUrl: 'https://github.com/org/beta.git', localPath: '/home/user/.agents/marketplaces/beta' }, + ], + }; + const result = getAllMarketplaces(config); + expect(result).toHaveLength(2); + expect(result[0].name).toBe('alpha'); + expect(result[1].name).toBe('beta'); + }); + + it('migrates a legacy single marketplace field when not already in the array', () => { + const config: GlobalConfig = { + marketplace: { repoUrl: 'https://github.com/org/legacy.git', localPath: '/home/user/.agents/marketplaces/legacy' }, + }; + const result = getAllMarketplaces(config); + expect(result).toHaveLength(1); + expect(result[0].repoUrl).toBe('https://github.com/org/legacy.git'); + expect(result[0].name).toBe('legacy'); + }); + + it('does not duplicate a legacy marketplace already present in the array', () => { + const config: GlobalConfig = { + marketplace: { repoUrl: 'https://github.com/org/mkt.git', localPath: '/home/user/.agents/marketplaces/mkt' }, + marketplaces: [ + { name: 'mkt', repoUrl: 'https://github.com/org/mkt.git', localPath: '/home/user/.agents/marketplaces/mkt' }, + ], + }; + const result = getAllMarketplaces(config); + expect(result).toHaveLength(1); + }); + + it('returns empty array when no marketplaces are configured', () => { + const config: GlobalConfig = {}; + expect(getAllMarketplaces(config)).toEqual([]); + }); +}); + +// ── getInstalledMetaPath ────────────────────────────────────────────────────── + +describe('getInstalledMetaPath', () => { + it('returns the correct metadata path within the target directory', () => { + const targetDir = path.join(os.homedir(), '.agents', 'skills'); + expect(getInstalledMetaPath(targetDir)).toBe(path.join(targetDir, '.pncli-installed.json')); + }); +}); + +// ── readInstalledMeta ───────────────────────────────────────────────────────── + +describe('readInstalledMeta', () => { + it('returns an empty meta object when no file exists', () => { + vi.spyOn(fs, 'existsSync').mockReturnValue(false); + const meta = readInstalledMeta('/some/target'); + expect(meta).toEqual({ version: 1, skills: {} }); + }); + + it('parses an existing valid metadata file', () => { + vi.spyOn(fs, 'existsSync').mockReturnValue(true); + vi.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify({ + version: 1, + skills: { + 'my-skill': { marketplace: 'mkt', plugin: 'sunny', installedAt: '2026-06-30T00:00:00Z', installedFrom: 'https://github.com/org/mkt.git' }, + }, + })); + const meta = readInstalledMeta('/some/target'); + expect(meta.skills['my-skill'].marketplace).toBe('mkt'); + }); }); diff --git a/src/services/skills/commands.ts b/src/services/skills/commands.ts index 4968394..241645e 100644 --- a/src/services/skills/commands.ts +++ b/src/services/skills/commands.ts @@ -7,7 +7,7 @@ import { fileURLToPath } from 'url'; import { execFileSync } from 'child_process'; import select from '@inquirer/select'; import { writeGlobalConfig, getGlobalConfigPath, loadJsonFile } from '../../lib/config.js'; -import type { GlobalConfig } from '../../types/config.js'; +import type { GlobalConfig, MarketplaceConfig } from '../../types/config.js'; /** * Injects an HTTP access token into a git clone URL. @@ -67,6 +67,72 @@ function getBundledSkillsDir(): string { } } +/** + * Path to the skill install metadata file inside a given skills target directory. + */ +export function getInstalledMetaPath(targetDir: string): string { + return path.join(targetDir, '.pncli-installed.json'); +} + +/** + * Reads the installed-skills metadata from the target directory. + */ +export function readInstalledMeta(targetDir: string): InstalledMeta { + const metaPath = getInstalledMetaPath(targetDir); + const raw = loadJsonFile(metaPath); + if (raw && raw.version === 1 && raw.skills) return raw; + return { version: 1, skills: {} }; +} + +export interface InstalledSkillRecord { + marketplace: string; + plugin: string; + installedAt: string; + installedFrom: string; +} + +export interface InstalledMeta { + version: 1; + skills: Record; +} + +/** + * Returns all registered marketplaces from the global config, merging the + * legacy single `marketplace` field into the new `marketplaces` array. + */ +export function getAllMarketplaces(globalConfig: GlobalConfig): MarketplaceConfig[] { + const result: MarketplaceConfig[] = []; + // Include entries from the new array first + if (Array.isArray(globalConfig.marketplaces)) { + result.push(...globalConfig.marketplaces); + } + // Migrate the legacy single-marketplace field if it exists and isn't already in the array + if (globalConfig.marketplace?.repoUrl) { + const legacyUrl = globalConfig.marketplace.repoUrl; + const alreadyPresent = result.some(m => m.repoUrl === legacyUrl); + if (!alreadyPresent) { + result.push({ + name: globalConfig.marketplace.name ?? repoNameFromUrl(legacyUrl), + repoUrl: globalConfig.marketplace.repoUrl, + localPath: globalConfig.marketplace.localPath, + token: globalConfig.marketplace.token, + }); + } + } + return result; +} + +/** + * Saves an updated marketplaces array back to the global config, removing + * the legacy `marketplace` field to avoid double-entries on next read. + */ +function saveMarketplaces(configPath: string, existing: GlobalConfig, marketplaces: MarketplaceConfig[]): void { + const updated: GlobalConfig = { ...existing, marketplaces }; + // Remove the legacy single-marketplace key so it doesn't conflict + delete updated.marketplace; + writeGlobalConfig(updated, configPath); +} + export function registerSkillsCommands(program: Command): void { const skills = program.command('skills').description('Manage pncli Claude Code skills'); @@ -188,7 +254,10 @@ export function registerSkillsCommands(program: Command): void { return; } + const meta = readInstalledMeta(targetDir); + const skillDirs = fs.readdirSync(targetDir).filter(name => { + if (name.startsWith('.')) return false; const skillPath = path.join(targetDir, name, 'SKILL.md'); return fs.existsSync(skillPath); }); @@ -222,6 +291,7 @@ export function registerSkillsCommands(program: Command): void { services: metadata.services || data.services || '', providers: metadata.providers || data.providers || 'none', userInvocable: data['user-invocable'] === 'true', + installed: meta.skills[name] ?? null, }; }); @@ -231,21 +301,76 @@ export function registerSkillsCommands(program: Command): void { } }); - const marketplace = skills.command('marketplace').description('Manage a git-hosted skills marketplace'); + skills + .command('uninstall') + .description('Uninstall a skill installed from a marketplace') + .argument('', 'Skill name to uninstall (the directory name under your skills folder)') + .option('--agent ', 'Target agent host: github-copilot | claude-code', 'github-copilot') + .option('--claude', 'Shorthand for --agent claude-code') + .option('--target ', 'Override skills directory') + .action((name: string, opts: { agent?: string; claude?: boolean; target?: string }) => { + const start = Date.now(); + try { + let targetDir: string; + if (opts.target) { + targetDir = path.resolve(opts.target); + } else { + const agentName = opts.claude ? 'claude-code' : (opts.agent ?? 'github-copilot'); + const agentConfig = AGENT_PATHS[agentName]; + if (!agentConfig) { + throw new Error(`Unknown agent: "${agentName}". Use: ${Object.keys(AGENT_PATHS).join(' | ')}`); + } + targetDir = agentConfig.user; + } + + const resolvedTarget = path.resolve(targetDir); + const skillDir = path.resolve(targetDir, name); + if (!skillDir.startsWith(resolvedTarget + path.sep)) { + throw new Error(`Invalid skill name: "${name}"`); + } + + if (!fs.existsSync(skillDir)) { + throw new Error(`Skill "${name}" not found at ${skillDir}`); + } + + const meta = readInstalledMeta(targetDir); + const record = meta.skills[name]; + + fs.rmSync(skillDir, { recursive: true, force: true }); + + // Update metadata + delete meta.skills[name]; + const metaPath = getInstalledMetaPath(targetDir); + fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), 'utf8'); + + success({ + uninstalled: name, + target: targetDir, + wasTracked: !!record, + ...(record ? { installedFrom: record } : {}), + }, 'skills', 'uninstall', start); + } catch (err) { + fail(err, 'skills', 'uninstall', start); + } + }); + + const marketplace = skills.command('marketplace').description('Manage git-hosted skills marketplaces'); marketplace - .command('setup') - .description('Clone a skills marketplace repo, register it in global config, and install all plugins') + .command('add') + .description('Register a new marketplace, clone it, and install all its plugins') .argument('', 'Git clone URL of the marketplace repository') .argument('[localPath]', 'Local directory to clone into (default: ~/.agents/marketplaces/)') + .option('--name ', 'Human-readable name for this marketplace (default: derived from URL)') .option('--branch ', 'Branch to clone (default: remote HEAD)') .option('--token ', 'HTTP access token for authenticated clone and pull (GitHub PAT or Bitbucket token)') .option('--agent ', 'Target agent host for plugin install: github-copilot | claude-code (default: github-copilot)') .option('--claude', 'Shorthand for --agent claude-code') - .action(async (url: string, localPath: string | undefined, opts: { branch?: string; token?: string; agent?: string; claude?: boolean }) => { + .action(async (url: string, localPath: string | undefined, opts: { name?: string; branch?: string; token?: string; agent?: string; claude?: boolean }) => { const start = Date.now(); try { const resolvedPath = path.resolve(localPath ?? defaultMarketplacePath(url)); + const marketplaceName = opts.name ?? repoNameFromUrl(url); const hasGit = fs.existsSync(path.join(resolvedPath, '.git')); if (fs.existsSync(resolvedPath) && !hasGit && fs.readdirSync(resolvedPath).length > 0) { @@ -270,8 +395,22 @@ export function registerSkillsCommands(program: Command): void { const configPath = getGlobalConfigPath(); const existing: GlobalConfig = loadJsonFile(configPath) ?? {}; - existing.marketplace = { repoUrl: url, localPath: resolvedPath, ...(opts.token ? { token: opts.token } : {}) }; - writeGlobalConfig(existing, configPath); + const all = getAllMarketplaces(existing); + + // Update or add the marketplace entry + const idx = all.findIndex(m => m.name === marketplaceName || m.repoUrl === url); + const entry: MarketplaceConfig = { + name: marketplaceName, + repoUrl: url, + localPath: resolvedPath, + ...(opts.token ? { token: opts.token } : {}), + }; + if (idx !== -1) { + all[idx] = entry; + } else { + all.push(entry); + } + saveMarketplaces(configPath, existing, all); // Determine install target const agentName = opts.claude ? 'claude-code' : (opts.agent ?? 'github-copilot'); @@ -297,7 +436,11 @@ export function registerSkillsCommands(program: Command): void { warn(`No skills directory found for plugin "${pluginChoice.name}" — skipping.`); continue; } - const { installed, failed } = copyPluginSkills(skillsSrc, targetDir); + const { installed, failed } = copyPluginSkills(skillsSrc, targetDir, { + marketplace: marketplaceName, + plugin: pluginChoice.name, + installedFrom: url, + }); pluginResults[pluginChoice.name] = { installed, failed }; totalInstalled += installed.length; for (const skill of installed) { @@ -310,6 +453,116 @@ export function registerSkillsCommands(program: Command): void { } success({ + name: marketplaceName, + repoUrl: url, + localPath: resolvedPath, + branch: opts.branch ?? null, + tokenConfigured: !!opts.token, + plugins: pluginResults, + total: totalInstalled, + target: targetDir, + }, 'skills', 'marketplace-add', start); + } catch (err) { + fail(err, 'skills', 'marketplace-add', start); + } + }); + + // Keep `setup` as an alias for `add` (backward compatibility) + marketplace + .command('setup') + .description('Alias for `marketplace add` — clone a marketplace and install all its plugins') + .argument('', 'Git clone URL of the marketplace repository') + .argument('[localPath]', 'Local directory to clone into (default: ~/.agents/marketplaces/)') + .option('--name ', 'Human-readable name for this marketplace (default: derived from URL)') + .option('--branch ', 'Branch to clone (default: remote HEAD)') + .option('--token ', 'HTTP access token for authenticated clone and pull (GitHub PAT or Bitbucket token)') + .option('--agent ', 'Target agent host for plugin install: github-copilot | claude-code (default: github-copilot)') + .option('--claude', 'Shorthand for --agent claude-code') + .action(async (url: string, localPath: string | undefined, opts: { name?: string; branch?: string; token?: string; agent?: string; claude?: boolean }) => { + const start = Date.now(); + try { + const resolvedPath = path.resolve(localPath ?? defaultMarketplacePath(url)); + const marketplaceName = opts.name ?? repoNameFromUrl(url); + + const hasGit = fs.existsSync(path.join(resolvedPath, '.git')); + if (fs.existsSync(resolvedPath) && !hasGit && fs.readdirSync(resolvedPath).length > 0) { + throw new Error(`Directory already exists and is not a git repo: ${resolvedPath}`); + } + if (hasGit) { + warn(`Directory already contains a git repo at ${resolvedPath} — skipping clone, updating config and re-installing plugins.`); + } else { + const branchLabel = opts.branch ?? 'remote default'; + warn(`Cloning ${url} (branch: ${branchLabel}) → ${resolvedPath}...`); + const cloneUrl = opts.token ? injectTokenIntoUrl(url, opts.token) : url; + const cloneArgs = ['clone']; + if (opts.branch) cloneArgs.push('--branch', opts.branch); + cloneArgs.push(cloneUrl, resolvedPath); + try { + execFileSync('git', cloneArgs, { stdio: ['inherit', 'inherit', 'pipe'] }); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + throw new Error(msg.replace(/x-(?:token-auth|access-token):[^@]+@/g, 'x-token-auth:***@')); + } + } + + const configPath = getGlobalConfigPath(); + const existing: GlobalConfig = loadJsonFile(configPath) ?? {}; + const all = getAllMarketplaces(existing); + + const idx = all.findIndex(m => m.name === marketplaceName || m.repoUrl === url); + const entry: MarketplaceConfig = { + name: marketplaceName, + repoUrl: url, + localPath: resolvedPath, + ...(opts.token ? { token: opts.token } : {}), + }; + if (idx !== -1) { + all[idx] = entry; + } else { + all.push(entry); + } + saveMarketplaces(configPath, existing, all); + + const agentName = opts.claude ? 'claude-code' : (opts.agent ?? 'github-copilot'); + const agentConfig = AGENT_PATHS[agentName]; + if (!agentConfig) { + throw new Error(`Unknown agent: "${agentName}". Use: ${Object.keys(AGENT_PATHS).join(' | ')}`); + } + const targetDir = agentConfig.user; + + const pluginChoices = resolvePluginChoices(resolvedPath); + const pluginResults: Record = {}; + let totalInstalled = 0; + + if (pluginChoices.length === 0) { + warn('No plugins found in marketplace. Check the marketplace repository structure.'); + } else { + warn(`Installing ${pluginChoices.length} plugin(s) to ${targetDir}...`); + for (const pluginChoice of pluginChoices) { + const skillsSrc = resolveSkillsSrc(resolvedPath, pluginChoice.name); + if (!fs.existsSync(skillsSrc)) { + pluginResults[pluginChoice.name] = { installed: [], failed: [] }; + warn(`No skills directory found for plugin "${pluginChoice.name}" — skipping.`); + continue; + } + const { installed, failed } = copyPluginSkills(skillsSrc, targetDir, { + marketplace: marketplaceName, + plugin: pluginChoice.name, + installedFrom: url, + }); + pluginResults[pluginChoice.name] = { installed, failed }; + totalInstalled += installed.length; + for (const skill of installed) { + warn(` ${skill}: ${path.join(skillsSrc, skill)} → ${path.join(targetDir, skill)}`); + } + if (failed.length > 0) { + warn(`Skipped ${failed.length} skill(s) with invalid names in "${pluginChoice.name}": ${failed.join(', ')}`); + } + } + } + + success({ + name: marketplaceName, repoUrl: url, localPath: resolvedPath, branch: opts.branch ?? null, @@ -323,27 +576,114 @@ export function registerSkillsCommands(program: Command): void { } }); + marketplace + .command('list') + .description('List all registered marketplaces') + .action(() => { + const start = Date.now(); + try { + const configPath = getGlobalConfigPath(); + const globalConfig: GlobalConfig = loadJsonFile(configPath) ?? {}; + const all = getAllMarketplaces(globalConfig); + + success({ + marketplaces: all.map(m => ({ + name: m.name ?? repoNameFromUrl(m.repoUrl ?? ''), + repoUrl: m.repoUrl, + localPath: m.localPath, + tokenConfigured: !!m.token, + })), + total: all.length, + }, 'skills', 'marketplace-list', start); + } catch (err) { + fail(err, 'skills', 'marketplace-list', start); + } + }); + + marketplace + .command('remove') + .description('Remove a registered marketplace from the config (does not delete the local clone)') + .argument('', 'Name of the marketplace to remove') + .action((name: string) => { + const start = Date.now(); + try { + const configPath = getGlobalConfigPath(); + const existing: GlobalConfig = loadJsonFile(configPath) ?? {}; + const all = getAllMarketplaces(existing); + + const idx = all.findIndex(m => m.name === name || m.repoUrl === name); + if (idx === -1) { + throw new Error(`Marketplace "${name}" not found. Run: pncli skills marketplace list`); + } + + const removed = all.splice(idx, 1)[0]; + saveMarketplaces(configPath, existing, all); + + success({ + removed: { + name: removed.name ?? name, + repoUrl: removed.repoUrl, + localPath: removed.localPath, + }, + remaining: all.length, + }, 'skills', 'marketplace-remove', start); + } catch (err) { + fail(err, 'skills', 'marketplace-remove', start); + } + }); + marketplace .command('sync') .description('Pull latest marketplace content and install a plugin\'s skills') .argument('[plugin]', 'Plugin name to install, or "all" to install every plugin (skips interactive selection)') + .option('--marketplace ', 'Marketplace name to sync (skips interactive selection when multiple are registered)') .option('--agent ', 'Target agent host: github-copilot | claude-code (default: github-copilot)') .option('--claude', 'Shorthand for --agent claude-code') .option('--force', 'Force reinstall even if the marketplace repo has no new changes') - .action(async (plugin: string | undefined, opts: { agent?: string; claude?: boolean; force?: boolean }) => { + .action(async (plugin: string | undefined, opts: { marketplace?: string; agent?: string; claude?: boolean; force?: boolean }) => { const start = Date.now(); try { const configPath = getGlobalConfigPath(); const globalConfig: GlobalConfig = loadJsonFile(configPath) ?? {}; + const allMarketplaces = getAllMarketplaces(globalConfig); + + if (allMarketplaces.length === 0) { + throw new Error('No marketplaces configured. Run: pncli skills marketplace add '); + } + + // Select which marketplace to sync + let selectedMarketplace: MarketplaceConfig; + if (opts.marketplace) { + const found = allMarketplaces.find(m => m.name === opts.marketplace || m.repoUrl === opts.marketplace); + if (!found) { + throw new Error(`Marketplace "${opts.marketplace}" not found. Run: pncli skills marketplace list`); + } + selectedMarketplace = found; + } else if (allMarketplaces.length === 1) { + selectedMarketplace = allMarketplaces[0]; + } else { + const chosen = await select({ + message: 'Select a marketplace to sync:', + choices: allMarketplaces.map(m => ({ + value: m.name ?? repoNameFromUrl(m.repoUrl ?? ''), + name: `${m.name ?? repoNameFromUrl(m.repoUrl ?? '')} — ${m.repoUrl ?? ''}`, + })), + }); + const found = allMarketplaces.find(m => (m.name ?? repoNameFromUrl(m.repoUrl ?? '')) === chosen); + if (!found) throw new Error(`Marketplace "${chosen}" not found.`); + selectedMarketplace = found; + } + + const marketplacePath = selectedMarketplace.localPath; + const marketplaceName = selectedMarketplace.name ?? repoNameFromUrl(selectedMarketplace.repoUrl ?? ''); - const marketplacePath = globalConfig.marketplace?.localPath; if (!marketplacePath || !fs.existsSync(marketplacePath)) { - throw new Error('Marketplace not configured. Run: pncli skills marketplace setup '); + throw new Error(`Marketplace "${marketplaceName}" local path not found at ${marketplacePath ?? '(not set)'}. Run: pncli skills marketplace add `); } warn('Pulling latest marketplace content...'); - const repoUrl = globalConfig.marketplace?.repoUrl; - const token = globalConfig.marketplace?.token; + const repoUrl = selectedMarketplace.repoUrl; + const token = selectedMarketplace.token; const gitArgs = ['-C', marketplacePath]; if (repoUrl && token) { gitArgs.push('-c', `remote.origin.url=${injectTokenIntoUrl(repoUrl, token)}`); @@ -397,7 +737,7 @@ export function registerSkillsCommands(program: Command): void { if (selectedPlugin === 'all') { if (!marketplaceUpdated && !opts.force) { success({ - marketplace: marketplacePath, + marketplace: marketplaceName, marketplaceUpdated: false, updated: false, skipped: true, @@ -416,7 +756,11 @@ export function registerSkillsCommands(program: Command): void { warn(`No skills directory found for plugin "${pluginChoice.name}" — skipping.`); continue; } - const { installed, failed } = copyPluginSkills(skillsSrc, targetDir); + const { installed, failed } = copyPluginSkills(skillsSrc, targetDir, { + marketplace: marketplaceName, + plugin: pluginChoice.name, + installedFrom: repoUrl ?? '', + }); results[pluginChoice.name] = { installed, failed }; totalInstalled += installed.length; for (const skill of installed) { @@ -428,6 +772,7 @@ export function registerSkillsCommands(program: Command): void { } success({ + marketplace: marketplaceName, plugins: results, total: totalInstalled, target: targetDir, @@ -441,7 +786,11 @@ export function registerSkillsCommands(program: Command): void { throw new Error(`No skills directory found at ${skillsSrc}`); } - const { installed, failed } = copyPluginSkills(skillsSrc, targetDir); + const { installed, failed } = copyPluginSkills(skillsSrc, targetDir, { + marketplace: marketplaceName, + plugin: selectedPlugin, + installedFrom: repoUrl ?? '', + }); for (const skill of installed) { warn(` ${skill}: ${path.join(skillsSrc, skill)} → ${path.join(targetDir, skill)}`); } @@ -450,6 +799,7 @@ export function registerSkillsCommands(program: Command): void { } success({ + marketplace: marketplaceName, plugin: selectedPlugin, installed, failed, @@ -495,7 +845,13 @@ export function resolveSkillsSrc(marketplacePath: string, selectedPlugin: string return skillsSrc; } -export function copyPluginSkills(skillsSrc: string, targetDir: string): { installed: string[]; failed: string[] } { +interface InstallMeta { + marketplace: string; + plugin: string; + installedFrom: string; +} + +export function copyPluginSkills(skillsSrc: string, targetDir: string, meta?: InstallMeta): { installed: string[]; failed: string[] } { fs.mkdirSync(targetDir, { recursive: true }); const resolvedTarget = path.resolve(targetDir); @@ -517,5 +873,21 @@ export function copyPluginSkills(skillsSrc: string, targetDir: string): { instal installed.push(skillName); } + // Update install metadata if meta context was provided + if (meta && installed.length > 0) { + const installedMeta = readInstalledMeta(targetDir); + const now = new Date().toISOString(); + for (const skillName of installed) { + installedMeta.skills[skillName] = { + marketplace: meta.marketplace, + plugin: meta.plugin, + installedAt: now, + installedFrom: meta.installedFrom, + }; + } + const metaPath = getInstalledMetaPath(targetDir); + fs.writeFileSync(metaPath, JSON.stringify(installedMeta, null, 2), 'utf8'); + } + return { installed, failed }; } diff --git a/src/types/config.ts b/src/types/config.ts index bb869b9..78a6835 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -136,6 +136,7 @@ export interface OpenShiftConfig { } export interface MarketplaceConfig { + name?: string; repoUrl?: string; localPath?: string; token?: string; @@ -179,6 +180,7 @@ export interface GlobalConfig { sonatypeiq?: SonatypeIqConfig; openshift?: OpenShiftConfig; marketplace?: MarketplaceConfig; + marketplaces?: MarketplaceConfig[]; defaults?: Defaults; }