From d75f4e3635c724d48acb6479859e7ac3fcc9645f Mon Sep 17 00:00:00 2001 From: Khaliq Date: Mon, 22 Jun 2026 12:26:04 +0200 Subject: [PATCH] Fix multi-file packages dropping supporting files on format conversion When installing a multi-file package with `--as ` (e.g. `prpm install --as claude,codex`), the conversion path converted only the main file (SKILL.md) and then collapsed the entire file list down to that single file. Every supporting file (references/, agents/, helpers/, etc.) was silently dropped before the install loop ran, so the converted target (codex) received only SKILL.md while the native claude install kept the full tree. Preserve all files during conversion: convert only the main entry point and copy the remaining files verbatim, letting the existing multi-file install loop write the full directory tree to the target format's location (e.g. .agents/skills// for codex). Also guard the Cursor .mdc rename against progressive-disclosure skills so converted skills installed to .openskills/ keep their SKILL.md name and supporting files, matching the single-file install path. Co-Authored-By: Claude Opus 4.8 --- .../src/__tests__/install-multifile.test.ts | 58 ++++++++++++++++++- packages/cli/src/commands/install.ts | 36 +++++++++--- 2 files changed, 84 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/__tests__/install-multifile.test.ts b/packages/cli/src/__tests__/install-multifile.test.ts index a2055d44..4d4fec75 100644 --- a/packages/cli/src/__tests__/install-multifile.test.ts +++ b/packages/cli/src/__tests__/install-multifile.test.ts @@ -212,6 +212,53 @@ describe('install command - multi-file packages', () => { ); }); + it('should preserve supporting files when converting a multi-file skill to codex (--as codex)', async () => { + // Regression: converting a multi-file package used to collapse extractedFiles + // down to the converted main file, dropping references/, helpers/, etc. + const mockPackage = { + id: 'complex-skill', + name: 'complex-skill', + format: 'claude', subtype: 'skill', + tags: [], + total_downloads: 100, + verified: true, + latest_version: { + version: '1.0.0', + tarball_url: 'https://example.com/package.tar.gz', + }, + }; + + const tarGz = await createTarGz({ + 'SKILL.md': '---\nname: complex-skill\ndescription: A complex skill\n---\n\n# Main Skill File\n\nBody content here.', + 'helpers/utils.md': '# Utility Functions', + 'references/guide.md': '# Reference Guide', + }, { format: 'claude', subtype: 'skill', packageName: 'complex-skill' }); + + mockClient.getPackage.mockResolvedValue(mockPackage); + mockClient.downloadPackage.mockResolvedValue(tarGz); + + await handleInstall('complex-skill', { as: 'codex' }); + + // All three files must land in the codex skill directory + expect(saveFile).toHaveBeenCalledTimes(3); + + // Main file is converted (content regenerated) but still written as SKILL.md + expect(saveFile).toHaveBeenCalledWith( + '.agents/skills/complex-skill/SKILL.md', + expect.stringContaining('Body content here.') + ); + + // Supporting files are copied verbatim, preserving subdirectories + expect(saveFile).toHaveBeenCalledWith( + '.agents/skills/complex-skill/helpers/utils.md', + '# Utility Functions' + ); + expect(saveFile).toHaveBeenCalledWith( + '.agents/skills/complex-skill/references/guide.md', + '# Reference Guide' + ); + }); + it('should reject tampered tarballs with corrupted headers (security)', async () => { // Security: tampered archives should be rejected with strict mode enabled const mockPackage = { @@ -394,13 +441,18 @@ describe('install command - multi-file packages', () => { // Should succeed and convert only the main file (SKILL.md) await handleInstall('complex-skill', { as: 'cursor' }); - // Cursor doesn't have native skill support - uses progressive disclosure - // Should save to .openskills/ with AGENTS.md reference - expect(saveFile).toHaveBeenCalledTimes(1); + // Cursor doesn't have native skill support - uses progressive disclosure. + // The main file is converted and kept as SKILL.md; supporting files are + // preserved verbatim alongside it (not dropped). + expect(saveFile).toHaveBeenCalledTimes(2); expect(saveFile).toHaveBeenCalledWith( '.openskills/complex-skill/SKILL.md', expect.stringContaining('Main Skill') ); + expect(saveFile).toHaveBeenCalledWith( + '.openskills/complex-skill/helper.md', + '# Helper' + ); }); it('should use native skill location for OpenCode skill install', async () => { diff --git a/packages/cli/src/commands/install.ts b/packages/cli/src/commands/install.ts index ea443052..b31c1866 100644 --- a/packages/cli/src/commands/install.ts +++ b/packages/cli/src/commands/install.ts @@ -719,6 +719,10 @@ export async function handleInstall( // For single-file packages, use the only file // For multi-file packages, find the main file based on format/subtype conventions let sourceContent: string; + // Index of the main entry point within extractedFiles. Only this file is + // format-converted; for multi-file packages the remaining (supporting) + // files are copied verbatim so the full directory tree is preserved. + let mainFileIndex = 0; if (extractedFiles.length === 1) { sourceContent = extractedFiles[0].content; @@ -731,6 +735,7 @@ export async function handleInstall( `Expected files like SKILL.md, agent.md, or similar main entry points.` ); } + mainFileIndex = extractedFiles.indexOf(mainFile); console.log(` 📄 Using main file: ${mainFile.name}`); sourceContent = mainFile.content; } @@ -902,11 +907,25 @@ export async function handleInstall( throw new CLIError('Conversion failed: No content generated'); } - // Replace extracted content with converted content - extractedFiles = [{ - name: extractedFiles[0].name, - content: convertedContent - }]; + // Replace the main file's content with the converted content. + // For multi-file packages (e.g. skills with references/, agents/, etc.), + // preserve every other file so the full directory tree is installed — + // only the main entry point needs format conversion; supporting files are + // copied verbatim by the multi-file install loop below. Collapsing to a + // single file here would silently drop all supporting files in the target + // format (the cause of codex installs only receiving SKILL.md). + if (extractedFiles.length === 1) { + extractedFiles = [{ + name: extractedFiles[0].name, + content: convertedContent, + }]; + } else { + extractedFiles = extractedFiles.map((file, index) => + index === mainFileIndex + ? { ...file, content: convertedContent } + : file + ); + } console.log(` ✓ Converted from ${pkg.format} to ${format}`); } @@ -1476,8 +1495,11 @@ export async function handleInstall( // Multi-file package - create directory for package // For Claude skills, destDir already includes package name, so use it directly - // For Cursor rules converted from Claude skills, use flat structure - const isCursorConversion = (effectiveFormat === 'cursor' && pkg.format === 'claude' && pkg.subtype === 'skill'); + // For Cursor rules converted from Claude skills, use flat structure. + // Skip the Cursor .mdc rename when the skill is installed via progressive + // disclosure (.openskills/) — there it stays a SKILL.md, matching the + // single-file install path, and supporting files are preserved verbatim. + const isCursorConversion = (effectiveFormat === 'cursor' && pkg.format === 'claude' && pkg.subtype === 'skill') && !needsProgressiveDisclosureMulti; const nativeSubtypeConfig = getSubtypeConfig(effectiveFormat, effectiveSubtype); const usesNativePackageSubdirectory = !needsProgressiveDisclosureMulti && Boolean(nativeSubtypeConfig?.usesPackageSubdirectory); const packageDir = (effectiveFormat === 'claude' && effectiveSubtype === 'skill')