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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
},
"lint-staged": {
"packages/cli/src/**/*.ts": [
"bash -c 'cd packages/cli && npx tsc --noEmit'"
"bash -c 'cd packages/cli && npx tsc -p tsconfig.typecheck.json'"
],
"packages/registry/src/**/*.ts": [
"bash -c 'cd packages/registry && npx tsc --noEmit'"
Expand All @@ -58,7 +58,7 @@
"bash -c 'cd packages/registry-client && npx tsc --noEmit'"
],
"packages/converters/src/**/*.ts": [
"bash -c 'cd packages/converters && npx tsc --noEmit'"
"bash -c 'cd packages/converters && npx tsc -p tsconfig.typecheck.json'"
],
"packages/types/src/**/*.ts": [
"bash -c 'cd packages/types && npx tsc --noEmit'"
Expand Down
118 changes: 118 additions & 0 deletions packages/cli/src/__tests__/install-format-conversion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,89 @@ description: Test for windsurf conversion
);
});

it('should preserve skill frontmatter when converting Claude skills to Codex', async () => {
const mockPackage = {
id: '@agent-relay/writing-agent-relay-workflows',
name: '@agent-relay/writing-agent-relay-workflows',
format: 'claude',
subtype: 'skill',
tags: [],
total_downloads: 11,
verified: true,
latest_version: {
version: '1.0.3',
tarball_url: 'https://example.com/package.tar.gz',
},
};

const claudeContent = `---
name: writing-agent-relay-workflows
description: Use when building multi-agent workflows with the relay broker-sdk.
---

# Writing Agent Relay Workflows

Use when building multi-agent workflows with the relay broker-sdk.

## Instructions

Build the workflow with explicit step dependencies.`;

mockClient.getPackage.mockResolvedValue(mockPackage);
mockClient.downloadPackage.mockResolvedValue(gzipSync(claudeContent));

await handleInstall('@agent-relay/writing-agent-relay-workflows', { as: 'codex' });

expect(saveFile).toHaveBeenCalledWith(
'.agents/skills/writing-agent-relay-workflows/SKILL.md',
expect.any(String)
);

const savedContent = (saveFile as Mock).mock.calls[0][1];
expect(savedContent).toContain('---');
expect(savedContent).toContain('name: writing-agent-relay-workflows');
expect(savedContent).toContain('description: Use when building multi-agent workflows with the relay broker-sdk.');
expect(savedContent).not.toContain('# Writing Agent Relay Workflows\n\nUse when building multi-agent workflows with the relay broker-sdk.\n');
});

it('should apply --tools override when converting a Claude skill to Codex', async () => {
const mockPackage = {
id: '@agent-relay/writing-agent-relay-workflows',
name: '@agent-relay/writing-agent-relay-workflows',
format: 'claude',
subtype: 'skill',
tags: [],
total_downloads: 11,
verified: true,
latest_version: {
version: '1.0.3',
tarball_url: 'https://example.com/package.tar.gz',
},
};

const claudeContent = `---
name: writing-agent-relay-workflows
description: Use when building multi-agent workflows with the relay broker-sdk.
tools: Read, Write
---

# Writing Agent Relay Workflows

Build the workflow with explicit step dependencies.`;

mockClient.getPackage.mockResolvedValue(mockPackage);
mockClient.downloadPackage.mockResolvedValue(gzipSync(claudeContent));

await handleInstall('@agent-relay/writing-agent-relay-workflows', {
as: 'codex',
tools: 'Read WebSearch Bash(git:*)',
});

const savedContent = (saveFile as Mock).mock.calls[0][1];
expect(savedContent).toContain('allowed-tools: Read WebSearch Bash(git:*)');
expect(savedContent).not.toContain('allowed-tools: Read Write');
});

it('should convert to Continue format using --as', async () => {
const mockPackage = {
id: 'claude-agent',
Expand Down Expand Up @@ -346,6 +429,41 @@ description: Test for copilot
);
});

it('should apply --tools override when installing Claude content', async () => {
const mockPackage = {
id: 'claude-skill',
name: 'claude-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 claudeContent = `---
name: claude-skill
description: Native Claude skill
tools: Read, Write
---

# Claude Skill

Content`;

mockClient.getPackage.mockResolvedValue(mockPackage);
mockClient.downloadPackage.mockResolvedValue(gzipSync(claudeContent));

await handleInstall('claude-skill', { tools: 'Read, Grep, Bash' });

const savedContent = (saveFile as Mock).mock.calls[0][1];
expect(savedContent).toContain('tools: Read, Grep, Bash');
expect(savedContent).not.toContain('tools: Read, Write');
});

it('should install Cursor rule in native format', async () => {
const mockPackage = {
id: 'cursor-rule',
Expand Down
95 changes: 88 additions & 7 deletions packages/cli/src/commands/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,56 @@ function getPackageIcon(format: Format, subtype: Subtype): string {
return subtypeIcons[subtype] || formatIcons[format] || '📦';
}

function hasConfigValues(config: { tools?: string; model?: string }): boolean {
return Boolean(config.tools || config.model);
}

function normalizeAllowedTools(tools: string): string {
return tools
.split(/[,\s]+/)
.map(tool => tool.trim())
.filter(Boolean)
.join(' ');
}

/** Normalize tools input to Claude's comma-separated format.
* Preserves parenthesized arguments like Bash(git add:*) */
function normalizeToolsForClaude(tools: string): string {
// If already comma-separated, just clean up
if (tools.includes(',')) {
return tools.split(',').map(t => t.trim()).filter(Boolean).join(', ');
Comment thread
khaliqgant marked this conversation as resolved.
}
// Match tool tokens: word optionally followed by parenthesized args
const parsed = tools.match(/[^\s,()]+(?:\([^)]*\))?/g) || [];
return parsed.map(t => t.trim()).filter(Boolean).join(', ');
}

function applyAgentSkillsTools(content: string, tools: string): string {
if (!content.startsWith('---\n')) {
return content;
}

const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
if (!match) {
return content;
}

const [, frontmatterText, body] = match;
const lines = frontmatterText.split('\n');
const normalizedTools = normalizeAllowedTools(tools);

const allowedToolsIndex = lines.findIndex(line => line.startsWith('allowed-tools:'));
if (allowedToolsIndex >= 0) {
lines[allowedToolsIndex] = `allowed-tools: ${normalizedTools}`;
} else {
const descriptionIndex = lines.findIndex(line => line.startsWith('description:'));
const insertAt = descriptionIndex >= 0 ? descriptionIndex + 1 : lines.length;
lines.splice(insertAt, 0, `allowed-tools: ${normalizedTools}`);
}

return `---\n${lines.join('\n')}\n---\n${body}`;
}

/**
* Get human-readable label for package format and subtype
*/
Expand Down Expand Up @@ -276,6 +326,7 @@ export async function handleInstall(
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)
tools?: string; // Override tool list for supported Claude/Codex outputs
fromCollection?: {
scope?: string;
name_slug: string;
Expand Down Expand Up @@ -657,10 +708,10 @@ export async function handleInstall(
try {
switch (sourceFormat) {
case 'cursor':
canonicalPkg = fromCursor(sourceContent, metadata);
canonicalPkg = fromCursor(sourceContent, metadata, pkg.subtype);
break;
case 'claude':
canonicalPkg = fromClaude(sourceContent, metadata);
canonicalPkg = fromClaude(sourceContent, metadata, 'claude', pkg.subtype);
break;
case 'windsurf':
canonicalPkg = fromWindsurf(sourceContent, metadata);
Expand Down Expand Up @@ -695,6 +746,10 @@ export async function handleInstall(
// Convert from canonical to target format
let convertedContent: string;
const targetFormat = format?.toLowerCase();
const effectiveClaudeConfig = {
...config.claude,
...(options.tools ? { tools: normalizeToolsForClaude(options.tools) } : {}),
};
Comment thread
khaliqgant marked this conversation as resolved.

try {
switch (targetFormat) {
Expand All @@ -712,7 +767,9 @@ export async function handleInstall(
break;
case 'claude':
case 'claude.md':
const claudeResult = toClaude(canonicalPkg);
const claudeResult = toClaude(canonicalPkg, hasConfigValues(effectiveClaudeConfig)
? { claudeConfig: effectiveClaudeConfig }
: {});
convertedContent = claudeResult.content;
break;
case 'continue':
Expand Down Expand Up @@ -775,7 +832,9 @@ export async function handleInstall(
break;
case 'codex':
// Codex uses AGENTS.md with section-based slash commands
convertedContent = toCodex(canonicalPkg).content;
convertedContent = toCodex(canonicalPkg, options.tools
? { codexConfig: { allowedTools: options.tools } }
: {}).content;
break;
case 'generic':
convertedContent = toCursor(canonicalPkg).content;
Expand Down Expand Up @@ -1215,12 +1274,22 @@ export async function handleInstall(

// Apply Claude config if downloading in Claude format
if (format === 'claude' && hasClaudeHeader(mainFile)) {
if (config.claude) {
const effectiveClaudeConfig = {
...config.claude,
...(options.tools ? { tools: normalizeToolsForClaude(options.tools) } : {}),
};
Comment thread
khaliqgant marked this conversation as resolved.

if (hasConfigValues(effectiveClaudeConfig)) {
console.log(` ⚙️ Applying Claude agent config...`);
mainFile = applyClaudeConfig(mainFile, config.claude);
mainFile = applyClaudeConfig(mainFile, effectiveClaudeConfig);
}
}

if (effectiveFormat === 'codex' && effectiveSubtype === 'skill' && options.tools) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
console.log(` ⚙️ Applying Codex skill tools override...`);
mainFile = applyAgentSkillsTools(mainFile, options.tools);
}

// Special handling for Claude hooks - merge into settings.json
if (effectiveFormat === 'claude' && effectiveSubtype === 'hook') {
// Ensure destPath is set for hooks (should be set earlier, but TypeScript can't verify)
Expand Down Expand Up @@ -1431,6 +1500,13 @@ export async function handleInstall(
}
}

// Apply Codex tools override to the main skill file (SKILL.md) in multi-file packages
if (effectiveFormat === 'codex' && effectiveSubtype === 'skill' && options.tools &&
(fileName === 'SKILL.md' || fileName.endsWith('/SKILL.md'))) {
console.log(` ⚙️ Applying Codex skill tools override (multi-file)...`);
fileContent = applyAgentSkillsTools(fileContent, options.tools);
}

const filePath = `${packageDir}/${fileName}`;
await saveFile(filePath, fileContent);
fileCount++;
Expand Down Expand Up @@ -1950,9 +2026,10 @@ export function createInstallCommand(): Command {
.option('--manifest-file <filename>', 'Custom manifest filename for progressive disclosure')
.option('--eager', 'Force skill/agent to always activate (not on-demand)')
.option('--lazy', 'Use default on-demand activation (overrides package eager setting)')
.option('--tools <tools>', 'Override Claude/Codex tool list for this install (comma- or space-separated)')
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
.option('--global', 'Install MCP servers to global config (e.g., ~/.claude/settings.json, ~/.codex/config.toml, ~/.cursor/mcp.json, ~/.kiro/settings/mcp.json)')
.option('--editor <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; global?: boolean; editor?: string }) => {
.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)
const rawAs = (options.format || options.as) as string | undefined;
const validFormats = FORMATS;
Expand Down Expand Up @@ -1984,6 +2061,9 @@ export function createInstallCommand(): Command {

// If no package specified, install from lockfile
if (!packageSpec) {
if (options.tools) {
console.warn('⚠️ --tools is ignored when installing from prpm.lock (no package specified)');
}
await installFromLockfile({
as: convertTo,
subtype: options.subtype as Subtype | undefined,
Expand All @@ -2008,6 +2088,7 @@ export function createInstallCommand(): Command {
manifestFile: options.manifestFile,
hookMapping: options.hookMapping as HookMappingStrategy | undefined,
eager,
tools: options.tools,
global: options.global,
editor: mcpEditor as MCPEditor | undefined,
});
Expand Down
28 changes: 28 additions & 0 deletions packages/cli/tsconfig.typecheck.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"rootDir": "..",
"module": "esnext",
"baseUrl": ".",
"paths": {
"@pr-pm/types": ["../types/src/index.ts"],
"@pr-pm/converters": ["../converters/src/index.ts"],
"@pr-pm/registry-client": ["../registry-client/src/index.ts"]
}
},
"include": [
"src/**/*",
"../types/src/**/*",
"../converters/src/**/*",
"../registry-client/src/**/*"
],
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts",
"../converters/src/**/*.test.ts",
"../registry-client/src/**/*.test.ts"
]
}
Loading
Loading