diff --git a/package.json b/package.json index 58731e7d..a9e36e08 100644 --- a/package.json +++ b/package.json @@ -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'" @@ -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'" diff --git a/packages/cli/src/__tests__/install-format-conversion.test.ts b/packages/cli/src/__tests__/install-format-conversion.test.ts index 2961a3f1..c048d96f 100644 --- a/packages/cli/src/__tests__/install-format-conversion.test.ts +++ b/packages/cli/src/__tests__/install-format-conversion.test.ts @@ -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', @@ -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', diff --git a/packages/cli/src/commands/install.ts b/packages/cli/src/commands/install.ts index a24506f0..738af6f4 100644 --- a/packages/cli/src/commands/install.ts +++ b/packages/cli/src/commands/install.ts @@ -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(', '); + } + // 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 */ @@ -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; @@ -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); @@ -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) } : {}), + }; try { switch (targetFormat) { @@ -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': @@ -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; @@ -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) } : {}), + }; + + 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) { + 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) @@ -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++; @@ -1950,9 +2026,10 @@ export function createInstallCommand(): Command { .option('--manifest-file ', '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 ', '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('--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; @@ -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, @@ -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, }); diff --git a/packages/cli/tsconfig.typecheck.json b/packages/cli/tsconfig.typecheck.json new file mode 100644 index 00000000..8ea08e34 --- /dev/null +++ b/packages/cli/tsconfig.typecheck.json @@ -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" + ] +} diff --git a/packages/converters/src/to-codex.ts b/packages/converters/src/to-codex.ts index 6a7ea70f..def95649 100644 --- a/packages/converters/src/to-codex.ts +++ b/packages/converters/src/to-codex.ts @@ -36,6 +36,8 @@ export interface CodexConfig { existingContent?: string; /** Force output to AGENTS.md format even for skills */ forceAgentsMd?: boolean; + /** Override allowed tools for Agent Skills outputs */ + allowedTools?: string; } /** @@ -61,7 +63,7 @@ export function toCodex( if (isSkill && !config.forceAgentsMd) { // Native SKILL.md format for skills - content = convertToSkillMd(pkg, warnings); + content = convertToSkillMd(pkg, warnings, config); } else if (isAgent) { // Native TOML format for agent roles (~/.codex/agents/.toml) content = convertToAgentRoleToml(pkg, warnings); @@ -194,7 +196,8 @@ function convertToAgentRoleToml( */ function convertToSkillMd( pkg: CanonicalPackage, - warnings: string[] + warnings: string[], + config?: CodexConfig ): string { const lines: string[] = []; @@ -246,6 +249,10 @@ function convertToSkillMd( frontmatter['allowed-tools'] = toolsSection.tools.join(' '); } + if (config?.allowedTools) { + frontmatter['allowed-tools'] = normalizeAllowedTools(config.allowedTools); + } + // Generate YAML frontmatter lines.push('---'); lines.push(yaml.dump(frontmatter, { indent: 2, lineWidth: -1 }).trim()); @@ -279,6 +286,14 @@ function truncateDescription(desc: string, maxLength: number): string { return desc.substring(0, maxLength - 3) + '...'; } +function normalizeAllowedTools(tools: string): string { + return tools + .split(/[,\s]+/) + .map(tool => tool.trim()) + .filter(Boolean) + .join(' '); +} + /** * Strip author namespace from package name (e.g., @prpm/self-improving → self-improving) */ diff --git a/packages/converters/tsconfig.typecheck.json b/packages/converters/tsconfig.typecheck.json new file mode 100644 index 00000000..ec868c29 --- /dev/null +++ b/packages/converters/tsconfig.typecheck.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "..", + "baseUrl": ".", + "paths": { + "@pr-pm/types": ["../types/src/index.ts"] + } + }, + "include": ["src/**/*", "src/**/*.json", "../types/src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"] +}