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
124 changes: 121 additions & 3 deletions packages/cli/src/__tests__/install-file-locations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ 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';
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';
import os from 'os';
import { promptYesNo } from '../core/prompts';
import { addSkillToManifest } from '../core/agents-md-progressive.js';

Expand Down Expand Up @@ -79,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) {
Expand All @@ -96,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
Expand Down Expand Up @@ -353,6 +359,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 = {
Expand Down Expand Up @@ -1032,6 +1094,62 @@ Follow TypeScript best practices.
// 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', () => {
Expand Down
48 changes: 48 additions & 0 deletions packages/cli/src/__tests__/install-from-lockfile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
55 changes: 53 additions & 2 deletions packages/cli/src/__tests__/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading
Loading