From d3495d570b3e7e77c5d3ffdcb5214278f765212d Mon Sep 17 00:00:00 2001 From: Khaliq Date: Fri, 24 Apr 2026 14:45:16 +0200 Subject: [PATCH 1/3] Fix global multi-format installs --- .../__tests__/install-file-locations.test.ts | 59 +++++++++- packages/cli/src/commands/install.ts | 102 ++++++++++++++---- packages/cli/src/commands/uninstall.ts | 11 +- packages/cli/src/commands/update.ts | 1 + packages/cli/src/core/lockfile.ts | 11 +- 5 files changed, 161 insertions(+), 23 deletions(-) diff --git a/packages/cli/src/__tests__/install-file-locations.test.ts b/packages/cli/src/__tests__/install-file-locations.test.ts index 0b60a026..26ecf59f 100644 --- a/packages/cli/src/__tests__/install-file-locations.test.ts +++ b/packages/cli/src/__tests__/install-file-locations.test.ts @@ -4,7 +4,7 @@ import { vi, describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, t * Tests that packages are installed to the correct directories based on type and format */ -import { handleInstall } from '../commands/install'; +import { handleInstall, createInstallCommand } from '../commands/install'; import { getRegistryClient } from '@pr-pm/registry-client'; import { getConfig } from '../core/user-config'; import { saveFile, getDestinationDir } from '../core/filesystem'; @@ -12,6 +12,7 @@ import { readLockfile, writeLockfile, addToLockfile, createLockfile, setPackageI import { gzipSync } from 'zlib'; import * as fs from 'fs/promises'; import * as path from 'path'; +import os from 'os'; import { promptYesNo } from '../core/prompts'; import { addSkillToManifest } from '../core/agents-md-progressive.js'; @@ -353,6 +354,62 @@ Follow TypeScript best practices. }); }); + describe('Global installs', () => { + it('installs multi-target Claude and Codex skills to user-level directories', async () => { + vi.spyOn(os, 'homedir').mockReturnValue(testDir); + + const mockPackage = { + id: '@agent-relay/running-headless-orchestrator', + name: '@agent-relay/running-headless-orchestrator', + format: 'claude', + subtype: 'skill', + tags: [], + total_downloads: 100, + verified: true, + latest_version: { + version: '1.0.0', + tarball_url: 'https://example.com/package.tar.gz', + }, + }; + + mockClient.getPackage.mockResolvedValue(mockPackage); + mockClient.downloadPackage.mockResolvedValue(gzipSync(`--- +name: running-headless-orchestrator +description: Run headless orchestration +--- + +# Running Headless Orchestrator +`)); + + const cmd = createInstallCommand(); + cmd.exitOverride(); + + await cmd.parseAsync( + ['@agent-relay/running-headless-orchestrator', '--as', 'claude,codex', '--global'], + { from: 'user' }, + ); + + expect(saveFile).toHaveBeenCalledWith( + path.join(testDir, '.claude', 'skills', 'running-headless-orchestrator', 'SKILL.md'), + expect.any(String), + ); + expect(saveFile).toHaveBeenCalledWith( + path.join(testDir, '.agents', 'skills', 'running-headless-orchestrator', 'SKILL.md'), + expect.any(String), + ); + expect(addToLockfile).toHaveBeenCalledWith( + expect.any(Object), + '@agent-relay/running-headless-orchestrator', + expect.objectContaining({ format: 'claude', global: true }), + ); + expect(addToLockfile).toHaveBeenCalledWith( + expect.any(Object), + '@agent-relay/running-headless-orchestrator', + expect.objectContaining({ format: 'codex', global: true }), + ); + }); + }); + describe('Format conversions with --as', () => { it('should install cursor package with --as claude to .claude/agents', async () => { const mockPackage = { diff --git a/packages/cli/src/commands/install.ts b/packages/cli/src/commands/install.ts index e2b796c5..a3bb1ba6 100644 --- a/packages/cli/src/commands/install.ts +++ b/packages/cli/src/commands/install.ts @@ -184,6 +184,47 @@ function applyAgentSkillsTools(content: string, tools: string): string { return `---\n${lines.join('\n')}\n---\n${body}`; } +function replaceLeadingDirectory(filePath: string, from: string, to: string): string { + const normalizedPath = filePath.startsWith('./') ? filePath.slice(2) : filePath; + + if (normalizedPath === from) { + return to; + } + + if (normalizedPath.startsWith(`${from}/`)) { + return path.join(to, normalizedPath.slice(from.length + 1)); + } + + return filePath; +} + +function getGlobalDestinationDir(format: Format, destDir: string): string { + const homeDir = os.homedir(); + + if (format === 'claude') { + return replaceLeadingDirectory(destDir, '.claude', path.join(homeDir, '.claude')); + } + + if (format === 'codex') { + if (destDir === '.' || destDir === './') { + return path.join(homeDir, '.codex'); + } + + const codexPath = replaceLeadingDirectory(destDir, '.codex', path.join(homeDir, '.codex')); + if (codexPath !== destDir) { + return codexPath; + } + + return replaceLeadingDirectory(destDir, '.agents', path.join(homeDir, '.agents')); + } + + return destDir; +} + +function getLockfileLocationKey(options: { global?: boolean }): string | undefined { + return options.global ? 'global' : undefined; +} + /** * Get human-readable label for package format and subtype */ @@ -324,7 +365,7 @@ export async function handleInstall( location?: string; noAppend?: boolean; // Skip manifest file update for skills manifestFile?: string; // Custom manifest filename (default: AGENTS.md) - global?: boolean; // Install MCP servers to global config + global?: boolean; // Install to user-level/global locations where supported editor?: MCPEditor; // Target editor for MCP server installation (claude, codex) hookMapping?: HookMappingStrategy; // Hook mapping strategy for cross-format hook conversion eager?: boolean; // Force skill/agent to always activate (not on-demand) @@ -387,7 +428,8 @@ export async function handleInstall( } // Get locked version for the specific format if available - const lockedVersion = getLockedVersion(lockfile, packageId, targetFormat); + const lockfileLocationKey = getLockfileLocationKey(options); + const lockedVersion = getLockedVersion(lockfile, packageId, targetFormat, lockfileLocationKey); // Determine version to install let version: string; @@ -420,7 +462,7 @@ export async function handleInstall( // If not found as snippet, check for non-snippet installation if (!installedPkg) { - const standardKey = getLockfileKey(packageId, targetFormat); + const standardKey = getLockfileKey(packageId, targetFormat, lockfileLocationKey); if (lockfile.packages[standardKey]) { installedPkg = lockfile.packages[standardKey]; matchedKey = standardKey; @@ -637,11 +679,11 @@ export async function handleInstall( // Verify integrity if we have a lockfile with integrity hash for this package // Only verify if the version matches - different versions will have different hashes - const lockfileKeyForVerification = getLockfileKey(packageId, targetFormat); + const lockfileKeyForVerification = getLockfileKey(packageId, targetFormat, lockfileLocationKey); const existingEntry = lockfile?.packages[lockfileKeyForVerification]; if (existingEntry?.integrity && existingEntry.version === actualVersion) { console.log(` 🔒 Verifying integrity...`); - const isValid = verifyPackageIntegrity(lockfile!, packageId, tarball, targetFormat); + const isValid = verifyPackageIntegrity(lockfile!, packageId, tarball, targetFormat, lockfileLocationKey); if (!isValid) { throw new CLIError( `❌ Integrity verification failed for ${packageId}\n\n` + @@ -892,6 +934,7 @@ export async function handleInstall( const isClaudePlugin = (pkg.format === 'claude' && pkg.subtype === 'plugin'); if (isClaudePlugin) { console.log(` 🔌 Installing Claude Plugin...`); + const claudeRoot = options.global ? path.join(os.homedir(), '.claude') : '.claude'; // Find and parse plugin.json const pluginJsonFile = extractedFiles.find(f => @@ -917,14 +960,15 @@ export async function handleInstall( f.name.startsWith('agents/') && f.name.endsWith('.md') ); if (agentFiles.length > 0) { - await fs.mkdir('.claude/agents', { recursive: true }); + const agentDir = path.join(claudeRoot, 'agents'); + await fs.mkdir(agentDir, { recursive: true }); for (const file of agentFiles) { const filename = path.basename(file.name); - const destFile = `.claude/agents/${filename}`; + const destFile = path.join(agentDir, filename); await saveFile(destFile, file.content); installedFiles.push(destFile); } - console.log(` ✓ Installed ${agentFiles.length} agents to .claude/agents/`); + console.log(` ✓ Installed ${agentFiles.length} agents to ${agentDir}/`); } // Install skills to .claude/skills/ @@ -935,13 +979,13 @@ export async function handleInstall( for (const file of skillFiles) { // Preserve skill directory structure (e.g., skills/my-skill/SKILL.md) const relativePath = file.name.replace(/^skills\//, ''); - const destFile = `.claude/skills/${relativePath}`; + const destFile = path.join(claudeRoot, 'skills', relativePath); const destFileDir = path.dirname(destFile); await fs.mkdir(destFileDir, { recursive: true }); await saveFile(destFile, file.content); installedFiles.push(destFile); } - console.log(` ✓ Installed ${skillFiles.length} skill files to .claude/skills/`); + console.log(` ✓ Installed ${skillFiles.length} skill files to ${path.join(claudeRoot, 'skills')}/`); } // Install commands to .claude/commands/ @@ -949,14 +993,15 @@ export async function handleInstall( f.name.startsWith('commands/') && f.name.endsWith('.md') ); if (commandFiles.length > 0) { - await fs.mkdir('.claude/commands', { recursive: true }); + const commandDir = path.join(claudeRoot, 'commands'); + await fs.mkdir(commandDir, { recursive: true }); for (const file of commandFiles) { const filename = path.basename(file.name); - const destFile = `.claude/commands/${filename}`; + const destFile = path.join(commandDir, filename); await saveFile(destFile, file.content); installedFiles.push(destFile); } - console.log(` ✓ Installed ${commandFiles.length} commands to .claude/commands/`); + console.log(` ✓ Installed ${commandFiles.length} commands to ${commandDir}/`); } // Merge MCP servers if present @@ -999,7 +1044,7 @@ export async function handleInstall( }; } - destPath = '.claude/'; + destPath = `${claudeRoot}/`; fileCount = installedFiles.length; } // Special handling for MCP server packages (install server configs to .mcp.json or editor config) @@ -1123,7 +1168,7 @@ export async function handleInstall( } let mainFile = extractedFiles[0].content; - destPath = 'CLAUDE.md'; + destPath = options.global ? path.join(os.homedir(), '.claude', 'CLAUDE.md') : 'CLAUDE.md'; await saveFile(destPath, mainFile); fileCount = 1; @@ -1131,6 +1176,13 @@ export async function handleInstall( // Check if this is a multi-file package else if (extractedFiles.length === 1) { destDir = getDestinationDir(effectiveFormat, effectiveSubtype, pkg.name); + if (options.global) { + const globalDestDir = getGlobalDestinationDir(effectiveFormat, destDir); + if (globalDestDir !== destDir) { + destDir = globalDestDir; + console.log(` 🌐 Installing globally to ${destDir}`); + } + } if (locationOverride && effectiveFormat === 'cursor') { const relativeDestDir = destDir.startsWith('./') ? destDir.slice(2) : destDir; @@ -1169,7 +1221,11 @@ export async function handleInstall( if (effectiveSubtype === 'skill') { // Skills go to .openskills/package-name/ directory destPath = `${destDir}/SKILL.md`; - console.log(` 📦 Installing skill to ${destDir}/ for progressive disclosure`); + if (effectiveFormat === 'codex') { + console.log(` 📦 Installing Codex skill to ${destDir}/`); + } else { + console.log(` 📦 Installing skill to ${destDir}/ for progressive disclosure`); + } } else if (effectiveSubtype === 'agent') { if (effectiveFormat === 'codex') { // Codex agent roles: project-local .codex/agents/.toml @@ -1404,6 +1460,14 @@ export async function handleInstall( destDir = getDestinationDir(effectiveFormat, effectiveSubtype, pkg.name); } + if (options.global) { + const globalDestDir = getGlobalDestinationDir(effectiveFormat, destDir); + if (globalDestDir !== destDir) { + destDir = globalDestDir; + console.log(` 🌐 Installing globally to ${destDir}`); + } + } + if (locationOverride && effectiveFormat === 'cursor') { const relativeDestDir = destDir.startsWith('./') ? destDir.slice(2) : destDir; destDir = path.join(locationOverride, relativeDestDir); @@ -1655,11 +1719,13 @@ export async function handleInstall( progressiveDisclosure: progressiveDisclosureMetadata, pluginMetadata, // Track plugin installation metadata for uninstall snippetMetadata, // Track snippet installation metadata for uninstall + global: options.global, }); // For snippets, include the target path in the key const snippetTargetPath = effectiveSubtype === 'snippet' ? snippetMetadata?.targetPath : undefined; - setPackageIntegrity(updatedLockfile, packageId, tarball, effectiveFormat, snippetTargetPath); + const integrityLocationKey = lockfileLocationKey || snippetTargetPath; + setPackageIntegrity(updatedLockfile, packageId, tarball, effectiveFormat, integrityLocationKey); await writeLockfile(updatedLockfile); // Update lockfile (already done above via addToLockfile + writeLockfile) @@ -2044,7 +2110,7 @@ export function createInstallCommand(): Command { .option('--eager', 'Force skill/agent to always activate (not on-demand)') .option('--lazy', 'Use default on-demand activation (overrides package eager setting)') .option('--tools ', 'Override Claude/Codex tool list for this install (comma- or space-separated)') - .option('--global', 'Install MCP servers to global config (e.g., ~/.claude/settings.json, ~/.codex/config.toml, ~/.cursor/mcp.json, ~/.kiro/settings/mcp.json)') + .option('--global', 'Install to user-level/global locations where supported (e.g., ~/.claude/skills, ~/.agents/skills, ~/.codex/agents, or global MCP config)') .option('--editor ', '[Deprecated: use --as] Target editor for MCP server installation') .action(async (packageSpec: string | undefined, options: { version?: string; as?: string; format?: string; subtype?: string; hookMapping?: string; frozenLockfile?: boolean; yes?: boolean; location?: string; noAppend?: boolean; manifestFile?: string; eager?: boolean; lazy?: boolean; tools?: string; global?: boolean; editor?: string }) => { // Support both --as and --format (format is alias for as) diff --git a/packages/cli/src/commands/uninstall.ts b/packages/cli/src/commands/uninstall.ts index 5c8531c1..135c3f81 100644 --- a/packages/cli/src/commands/uninstall.ts +++ b/packages/cli/src/commands/uninstall.ts @@ -296,8 +296,17 @@ export async function handleUninstall(name: string, options: { format?: string; // Specific format requested const requestedKey = getLockfileKey(name, requestedFormat); if (!lockfile.packages[requestedKey]) { + const matchingFormatKeys = matchingKeys.filter((key) => { + const parsed = parseLockfileKey(key); + const pkg = lockfile.packages[key]; + return parsed.format === requestedFormat || pkg.format === requestedFormat; + }); + + if (matchingFormatKeys.length > 0) { + keysToUninstall = matchingFormatKeys; + } // Check if package exists without format suffix - if (lockfile.packages[name] && lockfile.packages[name].format === requestedFormat) { + else if (lockfile.packages[name] && lockfile.packages[name].format === requestedFormat) { keysToUninstall = [name]; } else { throw new CLIError(`❌ Package "${name}" with format "${requestedFormat}" not found`, 1); diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index 7b3e610d..4553778f 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -90,6 +90,7 @@ export async function handleUpdate( const targetFormat = pkg.format || installedFormat; await handleInstall(`${packageId}@${latestVersion}`, { as: targetFormat, + global: pkg.global, }); updatedCount++; diff --git a/packages/cli/src/core/lockfile.ts b/packages/cli/src/core/lockfile.ts index 178f5ab6..cc6bed7d 100644 --- a/packages/cli/src/core/lockfile.ts +++ b/packages/cli/src/core/lockfile.ts @@ -25,6 +25,7 @@ export interface LockfilePackage { dependencies?: Record; format?: string; // Installed format subtype?: string; // Installed subtype + global?: boolean; // Whether package was installed to a user-level/global location sourceFormat?: string; // Original package format from registry sourceSubtype?: string; // Original subtype from registry installedPath?: string; // Path where the package was installed @@ -183,6 +184,7 @@ export function addToLockfile( sourceFormat?: string; sourceSubtype?: string; installedPath?: string; + global?: boolean; fromCollection?: { scope?: string; name_slug: string; @@ -221,7 +223,8 @@ export function addToLockfile( // Use format-specific key if format is provided (enables multiple formats per package) // For snippets, include target path in key to allow multiple installations to different files const snippetLocation = packageInfo.subtype === 'snippet' ? packageInfo.snippetMetadata?.targetPath : undefined; - const lockfileKey = getLockfileKey(packageId, packageInfo.format, snippetLocation); + const locationKey = packageInfo.global ? 'global' : snippetLocation; + const lockfileKey = getLockfileKey(packageId, packageInfo.format, locationKey); lockfile.packages[lockfileKey] = { version: packageInfo.version, @@ -230,6 +233,7 @@ export function addToLockfile( dependencies: packageInfo.dependencies, format: packageInfo.format, subtype: packageInfo.subtype, + global: packageInfo.global, sourceFormat: packageInfo.sourceFormat, sourceSubtype: packageInfo.sourceSubtype, installedPath: packageInfo.installedPath, @@ -291,7 +295,8 @@ export function verifyPackageIntegrity( export function getLockedVersion( lockfile: Lockfile | null, packageId: string, - format?: string + format?: string, + location?: string ): string | null { if (!lockfile) { return null; @@ -299,7 +304,7 @@ export function getLockedVersion( // If format specified, check specific key if (format) { - const lockfileKey = getLockfileKey(packageId, format); + const lockfileKey = getLockfileKey(packageId, format, location); return lockfile.packages[lockfileKey]?.version || null; } From d9d321e3668947b096aae6bd7290d084c82f3823 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Fri, 24 Apr 2026 15:32:57 +0200 Subject: [PATCH 2/3] Preserve global lockfile installs --- .../__tests__/install-from-lockfile.test.ts | 48 ++++++++++++++++ packages/cli/src/__tests__/update.test.ts | 55 ++++++++++++++++++- packages/cli/src/commands/install.ts | 10 +++- packages/cli/src/commands/update.ts | 29 ++++++++-- 4 files changed, 134 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/__tests__/install-from-lockfile.test.ts b/packages/cli/src/__tests__/install-from-lockfile.test.ts index 5bc4a1bf..5673406f 100644 --- a/packages/cli/src/__tests__/install-from-lockfile.test.ts +++ b/packages/cli/src/__tests__/install-from-lockfile.test.ts @@ -292,6 +292,54 @@ describe('install from lockfile', () => { expect(mockClient.getPackage).toHaveBeenCalledWith('@test/claude-skill'); expect(mockClient.downloadPackage).toHaveBeenCalled(); }); + + it('should preserve global installs from lockfile entries', async () => { + const lockfile: Lockfile = { + version: '1.0.0', + lockfileVersion: 1, + packages: { + '@test/global-skill#claude:global': { + version: '1.0.0', + resolved: 'https://registry.prpm.dev/packages/@test/global-skill/1.0.0/download', + integrity: '', + format: 'claude', + subtype: 'skill', + global: true, + }, + }, + generated: new Date().toISOString(), + }; + mockReadLockfile.mockResolvedValue(lockfile); + + mockClient.getPackage.mockResolvedValue({ + id: '@test/global-skill', + name: '@test/global-skill', + author: 'test', + version: '1.0.0', + format: 'claude', + subtype: 'skill', + files: ['SKILL.md'], + description: 'Global skill', + total_downloads: 0, + latest_version: { + version: '1.0.0', + tarball_url: 'https://registry.prpm.dev/packages/@test/global-skill/1.0.0/download', + }, + } as any); + mockClient.getPackageVersion.mockResolvedValue({ + version: '1.0.0', + tarball_url: 'https://registry.prpm.dev/packages/@test/global-skill/1.0.0/download', + } as any); + mockClient.downloadPackage.mockResolvedValue(gzipSync('# Global Skill')); + + await installFromLockfile({}); + + expect(mockAddToLockfile).toHaveBeenCalledWith( + expect.any(Object), + '@test/global-skill', + expect.objectContaining({ format: 'claude', global: true }), + ); + }); }); describe('multiple packages installation', () => { diff --git a/packages/cli/src/__tests__/update.test.ts b/packages/cli/src/__tests__/update.test.ts index 09f73bdd..57205d37 100644 --- a/packages/cli/src/__tests__/update.test.ts +++ b/packages/cli/src/__tests__/update.test.ts @@ -23,8 +23,9 @@ vi.mock('../core/lockfile', () => ({ readLockfile: vi.fn(), writeLockfile: vi.fn(), parseLockfileKey: vi.fn((key: string) => { - const parts = key.split('#'); - return { packageId: parts[0], format: parts[1] }; + const [packageId, rest] = key.split('#'); + const [format, location] = (rest || '').split(':'); + return { packageId, format: format || undefined, location }; }), })); vi.mock('../core/telemetry', () => ({ @@ -157,6 +158,56 @@ describe('update command', () => { { as: undefined } ); }); + + it('should preserve explicit global installs when updating', async () => { + (listPackages as Mock).mockResolvedValue([ + { + id: '@scope/global-skill#codex:global', + version: '1.0.0', + format: 'codex', + global: true, + }, + ]); + + mockClient.getPackage.mockResolvedValue({ + id: '@scope/global-skill', + latest_version: { version: '1.0.1' }, + }); + + await handleUpdate(); + + expect(handleInstall).toHaveBeenCalledWith( + '@scope/global-skill@1.0.1', + { as: 'codex', global: true } + ); + }); + + it('should preserve legacy global MCP editor metadata when updating', async () => { + (listPackages as Mock).mockResolvedValue([ + { + id: '@scope/mcp-server#mcp', + version: '1.0.0', + format: 'mcp', + pluginMetadata: { + files: [], + mcpGlobal: true, + mcpEditor: 'windsurf', + }, + }, + ]); + + mockClient.getPackage.mockResolvedValue({ + id: '@scope/mcp-server', + latest_version: { version: '1.0.1' }, + }); + + await handleUpdate(); + + expect(handleInstall).toHaveBeenCalledWith( + '@scope/mcp-server@1.0.1', + { as: 'mcp', global: true, editor: 'windsurf' } + ); + }); }); describe('version filtering', () => { diff --git a/packages/cli/src/commands/install.ts b/packages/cli/src/commands/install.ts index a3bb1ba6..8bbfd46e 100644 --- a/packages/cli/src/commands/install.ts +++ b/packages/cli/src/commands/install.ts @@ -1997,6 +1997,8 @@ export async function installFromLockfile(options: { frozenLockfile?: boolean; location?: string; hookMapping?: HookMappingStrategy; + global?: boolean; + editor?: MCPEditor; }): Promise { try { // Read lockfile @@ -2023,7 +2025,7 @@ export async function installFromLockfile(options: { const lockEntry = lockfile.packages[lockfileKey]; // Parse lockfile key to get package ID and format (outside try block for error handling) - const { packageId, format } = parseLockfileKey(lockfileKey); + const { packageId, format, location } = parseLockfileKey(lockfileKey); const displayName = format ? `${packageId} (${format})` : packageId; try { @@ -2047,6 +2049,8 @@ export async function installFromLockfile(options: { // Preserve manifest file from lockfile for progressive disclosure const manifestFile = lockEntry.progressiveDisclosure?.manifestPath; + const preservedGlobal = options.global ?? lockEntry.global ?? lockEntry.pluginMetadata?.mcpGlobal ?? (location === 'global' ? true : undefined); + const preservedEditor = options.editor ?? (lockEntry.pluginMetadata?.mcpEditor as MCPEditor | undefined); await handleInstall(packageSpec, { version: lockEntry.version, @@ -2058,6 +2062,8 @@ export async function installFromLockfile(options: { manifestFile, hookMapping: options.hookMapping, fromCollection: lockEntry.fromCollection, // Preserve collection metadata + global: preservedGlobal, + editor: preservedEditor, }); successCount++; @@ -2180,6 +2186,8 @@ export function createInstallCommand(): Command { frozenLockfile: options.frozenLockfile, location: options.location, hookMapping: options.hookMapping as HookMappingStrategy | undefined, + global: options.global, + editor: options.editor as MCPEditor | undefined, }); return; } diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index 4553778f..fbe6473d 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -9,6 +9,17 @@ import { listPackages, parseLockfileKey } from "../core/lockfile"; import { handleInstall } from "./install"; import { telemetry } from "../core/telemetry"; import { CLIError } from "../core/errors"; +import type { MCPEditor } from "../core/mcp"; + +type InstalledPackage = Awaited>[number]; + +function getPreservedGlobal(pkg: InstalledPackage, location?: string): boolean | undefined { + return pkg.global ?? pkg.pluginMetadata?.mcpGlobal ?? (location === "global" ? true : undefined); +} + +function getPreservedEditor(pkg: InstalledPackage): MCPEditor | undefined { + return pkg.pluginMetadata?.mcpEditor as MCPEditor | undefined; +} /** * Update packages to latest minor/patch versions @@ -51,7 +62,7 @@ export async function handleUpdate( for (const pkg of packagesToUpdate) { // Parse the lockfile key to get the actual package ID (without #format suffix) - const { packageId, format: installedFormat } = parseLockfileKey(pkg.id); + const { packageId, format: installedFormat, location } = parseLockfileKey(pkg.id); try { // Get package info from registry using the base package ID @@ -88,10 +99,18 @@ export async function handleUpdate( // Always pass the installed format to prevent auto-detection // Use pkg.format (entry data) with installedFormat (from key suffix) as fallback const targetFormat = pkg.format || installedFormat; - await handleInstall(`${packageId}@${latestVersion}`, { - as: targetFormat, - global: pkg.global, - }); + const installOptions: Parameters[1] = { as: targetFormat }; + const preservedGlobal = getPreservedGlobal(pkg, location); + const preservedEditor = getPreservedEditor(pkg); + + if (preservedGlobal !== undefined) { + installOptions.global = preservedGlobal; + } + if (preservedEditor) { + installOptions.editor = preservedEditor; + } + + await handleInstall(`${packageId}@${latestVersion}`, installOptions); updatedCount++; } catch (err) { From e1ed48c4453c17cdb604acb2e400a5da0f428847 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Fri, 24 Apr 2026 15:44:41 +0200 Subject: [PATCH 3/3] Preserve editor-only lockfile installs --- .../__tests__/install-file-locations.test.ts | 65 ++++++++++++++++++- packages/cli/src/commands/install.ts | 2 +- 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/__tests__/install-file-locations.test.ts b/packages/cli/src/__tests__/install-file-locations.test.ts index 26ecf59f..87c25a18 100644 --- a/packages/cli/src/__tests__/install-file-locations.test.ts +++ b/packages/cli/src/__tests__/install-file-locations.test.ts @@ -8,7 +8,7 @@ import { handleInstall, createInstallCommand } from '../commands/install'; import { getRegistryClient } from '@pr-pm/registry-client'; import { getConfig } from '../core/user-config'; import { saveFile, getDestinationDir } from '../core/filesystem'; -import { readLockfile, writeLockfile, addToLockfile, createLockfile, setPackageIntegrity } from '../core/lockfile'; +import { readLockfile, writeLockfile, addToLockfile, createLockfile, setPackageIntegrity, parseLockfileKey } from '../core/lockfile'; import { gzipSync } from 'zlib'; import * as fs from 'fs/promises'; import * as path from 'path'; @@ -80,7 +80,7 @@ describe('install command - file locations', () => { const dirs = [ '.claude', '.cursor', '.continue', '.windsurf', '.prompts', '.agents', '.github', '.kiro', '.gemini', '.opencode', '.factory', '.droid', - '.trae', '.zencoder', '.mcp', '.openskills', '.openagents', '.opencommands', + '.trae', '.vscode', '.zencoder', '.mcp', '.openskills', '.openagents', '.opencommands', 'AGENTS.md', 'GEMINI.md', 'CLAUDE.md', 'CONVENTIONS.md', 'replit.md', 'custom' ]; for (const dir of dirs) { @@ -97,6 +97,11 @@ describe('install command - file locations', () => { (addToLockfile as Mock).mockImplementation(() => {}); (createLockfile as Mock).mockReturnValue({ packages: {} }); (setPackageIntegrity as Mock).mockImplementation(() => {}); + (parseLockfileKey as Mock).mockImplementation((key: string) => { + const [packageId, formatAndLocation] = key.split('#'); + const [format, location] = (formatAndLocation || '').split(':'); + return { packageId, format: format || undefined, location }; + }); mockClient.trackDownload.mockResolvedValue(undefined); // Mock console methods @@ -1089,6 +1094,62 @@ description: Run headless orchestration // Should NOT have created/modified AGENTS.md expect(saveFile).not.toHaveBeenCalledWith('AGENTS.md', expect.any(String)); }); + + it('uses editor-only --as values when installing MCP packages from prpm.lock', async () => { + const lockfile = { + version: '1.0.0', + lockfileVersion: 1, + packages: { + '@test/mcp-lockfile#mcp': { + version: '1.0.0', + resolved: 'https://example.com/package.tar.gz', + integrity: '', + format: 'mcp', + subtype: 'server', + }, + }, + generated: new Date().toISOString(), + }; + const mockPackage = { + id: '@test/mcp-lockfile', + name: '@test/mcp-lockfile', + format: 'mcp', + subtype: 'server', + tags: ['mcp'], + total_downloads: 5, + verified: false, + latest_version: { + version: '1.0.0', + tarball_url: 'https://example.com/package.tar.gz', + }, + }; + + (readLockfile as Mock).mockResolvedValue(lockfile); + mockClient.getPackage.mockResolvedValue(mockPackage); + mockClient.getPackageVersion.mockResolvedValue({ + version: '1.0.0', + tarball_url: 'https://example.com/package.tar.gz', + }); + mockClient.downloadPackage.mockResolvedValue(await createMCPTarball(mcpServerJson)); + + const cmd = createInstallCommand(); + cmd.exitOverride(); + + await cmd.parseAsync(['--as', 'vscode'], { from: 'user' }); + + const vscodeConfig = JSON.parse(await fs.readFile(path.join(testDir, '.vscode', 'mcp.json'), 'utf-8')); + expect(vscodeConfig.servers['test-server']).toBeDefined(); + expect(addToLockfile).toHaveBeenCalledWith( + expect.any(Object), + '@test/mcp-lockfile', + expect.objectContaining({ + format: 'mcp', + pluginMetadata: expect.objectContaining({ + mcpEditor: 'vscode', + }), + }), + ); + }); }); describe('Lockfile metadata', () => { diff --git a/packages/cli/src/commands/install.ts b/packages/cli/src/commands/install.ts index 8bbfd46e..ea443052 100644 --- a/packages/cli/src/commands/install.ts +++ b/packages/cli/src/commands/install.ts @@ -2187,7 +2187,7 @@ export function createInstallCommand(): Command { location: options.location, hookMapping: options.hookMapping as HookMappingStrategy | undefined, global: options.global, - editor: options.editor as MCPEditor | undefined, + editor: (options.editor as MCPEditor | undefined) ?? (isMCPEditorOnly ? (singleAs as MCPEditor) : undefined), }); return; }