diff --git a/packages/AGENTS.md b/packages/AGENTS.md new file mode 100644 index 00000000..1bb74260 --- /dev/null +++ b/packages/AGENTS.md @@ -0,0 +1,70 @@ + + + + +When users ask you to perform tasks, check if any of the available skills below can help complete the task more effectively. Skills provide specialized capabilities and domain knowledge. + +How to use skills (loaded into main context): +- Use the from the skill entry below +- Invoke: Bash("cat ") +- The skill content will load into your current context +- Example: Bash("cat .openskills/backend-architect/SKILL.md") + +Usage notes: +- Skills share your context window +- Do not invoke a skill that is already loaded in your context + + + + + +claude-skill +test-kiro skill +.openskills/claude-skill/SKILL.md + + + +complex-skill +complex-skill skill +.openskills/complex-skill/SKILL.md + + + + + + + +Agents are specialized AI assistants that run in independent contexts for complex multi-step tasks. + +How to use agents (spawned with independent context): +- The from the agent entry contains the agent definition file +- The agent definition will be automatically loaded into the subagent's context +- Invoke: Task(subagent_type="", prompt="task description") +- The agent runs in a separate context and returns results +- Example: Task(subagent_type="code-reviewer", prompt="Review the authentication code in auth.ts") + +Usage notes: +- Agents have independent context windows +- Each agent invocation is stateless +- Agents are spawned as subprocesses via the Task tool +- The agent's AGENT.md file is loaded into the subagent's context automatically + + + + + +windsurf-agent +windsurf-agent agent +.openagents/windsurf-agent/AGENT.md + + + +claude-agent +claude-agent agent +.openagents/claude-agent/AGENT.md + + + + + + diff --git a/packages/cli/src/__tests__/install-file-locations.test.ts b/packages/cli/src/__tests__/install-file-locations.test.ts index 87c25a18..336530f0 100644 --- a/packages/cli/src/__tests__/install-file-locations.test.ts +++ b/packages/cli/src/__tests__/install-file-locations.test.ts @@ -7,7 +7,7 @@ import { vi, describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, t 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 { saveFile, getDestinationDir, createSymlink } from '../core/filesystem'; import { readLockfile, writeLockfile, addToLockfile, createLockfile, setPackageIntegrity, parseLockfileKey } from '../core/lockfile'; import { gzipSync } from 'zlib'; import * as fs from 'fs/promises'; @@ -41,6 +41,7 @@ vi.mock('../core/filesystem', async () => { ...actual, saveFile: vi.fn(actual.saveFile), ensureDirectoryExists: vi.fn(actual.ensureDirectoryExists), + createSymlink: vi.fn().mockResolvedValue({ symlinked: true, path: '' }), }; }); @@ -394,23 +395,79 @@ description: Run headless orchestration { from: 'user' }, ); + // First format (claude) uses saveFile 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), + // Second format (codex) uses symlink (claude and codex are symlink-compatible for skills) + expect(createSymlink).toHaveBeenCalledWith( + expect.stringContaining('.claude/skills/running-headless-orchestrator'), + expect.stringContaining('.agents/skills/running-headless-orchestrator'), + true, ); expect(addToLockfile).toHaveBeenCalledWith( expect.any(Object), '@agent-relay/running-headless-orchestrator', expect.objectContaining({ format: 'claude', global: true }), ); + // Note: addToLockfile is NOT called for symlinked format (codex) since it reuses claude's installation + }); + + it('creates symlinks for claude,codex skill installs (symlink-compatible formats)', async () => { + // Mock homedir for global install and reset symlink mock + vi.spyOn(os, 'homedir').mockReturnValue(testDir); + vi.mocked(createSymlink).mockResolvedValue({ symlinked: true, path: '' }); + + const mockPackage = { + id: '@test/multi-format-skill', + name: '@test/multi-format-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', + }, + }; + + mockClient.getPackage.mockResolvedValue(mockPackage); + mockClient.downloadPackage.mockResolvedValue(gzipSync(`--- +name: multi-format-skill +description: Test multi-format skill +--- + +# Multi-Format Skill Test +`)); + + const cmd = createInstallCommand(); + cmd.exitOverride(); + + await cmd.parseAsync( + ['@test/multi-format-skill', '--as', 'claude,codex', '--global'], + { from: 'user' }, + ); + + // First format (claude) saves files normally + expect(saveFile).toHaveBeenCalledWith( + path.join(testDir, '.claude', 'skills', 'multi-format-skill', 'SKILL.md'), + expect.any(String), + ); + + // Codex uses symlinks to claude installation (claude and codex are symlink-compatible for skills) + expect(createSymlink).toHaveBeenCalledWith( + expect.stringContaining('.claude/skills/multi-format-skill'), + expect.stringContaining('.agents/skills/multi-format-skill'), + true, + ); + + // Only claude installation adds to lockfile expect(addToLockfile).toHaveBeenCalledWith( expect.any(Object), - '@agent-relay/running-headless-orchestrator', - expect.objectContaining({ format: 'codex', global: true }), + '@test/multi-format-skill', + expect.objectContaining({ format: 'claude', global: true }), ); }); }); diff --git a/packages/cli/src/commands/install.ts b/packages/cli/src/commands/install.ts index ea443052..b88d4e10 100644 --- a/packages/cli/src/commands/install.ts +++ b/packages/cli/src/commands/install.ts @@ -6,7 +6,7 @@ import { Command } from 'commander'; import chalk from 'chalk'; import { getRegistryClient } from '@pr-pm/registry-client'; import { getConfig } from '../core/user-config'; -import { saveFile, getDestinationDir, stripAuthorNamespace, autoDetectFormat, fileExists, getManifestFilename } from '../core/filesystem'; +import { saveFile, getDestinationDir, stripAuthorNamespace, autoDetectFormat, fileExists, getManifestFilename, createSymlink, ensureDirectoryExists } from '../core/filesystem'; import { addPackage } from '../core/lockfile'; import { telemetry } from '../core/telemetry'; import { Package, Format, Subtype, FORMATS, FORMAT_NATIVE_SUBTYPES } from '../types'; @@ -81,6 +81,160 @@ import { type HookMappingStrategy, } from '@pr-pm/converters'; +/** + * Track installed paths for multi-format symlink support + */ +interface InstallResult { + format: Format; + subtype: Subtype; + installedPaths: string[]; // All files/directories installed + destPath: string; // Main destination path (for display) +} + +/** + * Define symlink-compatible format groups. + * Formats in the same group with matching subtypes can share files via symlinks. + * The first format listed in each group is the "canonical" one (files are written there, + * others symlink to it). + */ +const SYMLINK_COMPATIBLE_GROUPS: Record = { + 'skill': [ + ['claude', 'codex', 'amp'], // Claude, Codex, and Amp all use similar skill structures + ], + 'agent': [], // Different agent formats (claude uses .md, codex uses .toml) + 'slash-command': [], + 'rule': [], + 'prompt': [], + 'collection': [], + 'chatmode': [], + 'tool': [], + 'hook': [], + 'workflow': [], + 'template': [], + 'plugin': [], + 'extension': [], + 'server': [], + 'snippet': [], +}; + +/** + * Check if two formats are symlink-compatible for a given subtype + */ +function areFormatsSymlinkCompatible(format1: Format, format2: Format, subtype: Subtype): boolean { + const groups = SYMLINK_COMPATIBLE_GROUPS[subtype] || []; + for (const group of groups) { + if (group.includes(format1) && group.includes(format2)) { + return true; + } + } + return false; +} + +/** + * Get the canonical format from a symlink-compatible group + */ +function getCanonicalFormat(formats: Format[], subtype: Subtype): Format | undefined { + const groups = SYMLINK_COMPATIBLE_GROUPS[subtype] || []; + for (const group of groups) { + // Return the first format from the group that's in our list + for (const canonicalFormat of group) { + if (formats.includes(canonicalFormat)) { + return canonicalFormat; + } + } + } + return undefined; +} + +/** + * Create a symlink-based installation for a format that's compatible with an existing installation. + * Returns the symlink path if successful, null if failed. + */ +async function createSymlinkInstall( + packageSpec: string, + sourceInstall: InstallResult, + targetFormat: Format, + subtype: Subtype, + options: { + manifestFile?: string; + noAppend?: boolean; + eager?: boolean; + } +): Promise { + try { + // Get the source and target directories + // Parse package spec properly: @scope/name@version or name@version + let packageId: string; + if (packageSpec.startsWith('@')) { + // Scoped package: @scope/name or @scope/name@version + const match = packageSpec.match(/^(@[^/]+\/[^@]+)(?:@.+)?$/); + packageId = match ? match[1] : packageSpec; + } else { + // Unscoped: name or name@version + packageId = packageSpec.split('@')[0]; + } + const packageName = stripAuthorNamespace(packageId); + + // Determine target directory based on format and subtype + const targetDir = getDestinationDir(targetFormat, subtype, packageName); + + // For skills, the source is the skill directory (e.g., .claude/skills/my-skill) + // and we symlink the skill directory to the target format's skill directory + if (subtype === 'skill' && sourceInstall.installedPaths.length > 0) { + // Find the skill directory (parent of SKILL.md) + const skillFile = sourceInstall.installedPaths.find(p => p.includes('SKILL.md')); + if (skillFile) { + const sourceDir = path.dirname(skillFile); + const fullSourceDir = path.resolve(sourceDir); + + // targetDir is the full path including package name (e.g., .agents/skills/my-skill) + // We need to ensure the parent directory exists and create symlink to the skill dir + const targetParentDir = path.dirname(targetDir); + await ensureDirectoryExists(targetParentDir); + + const fullTargetDir = path.resolve(targetDir); + + // Create symlink for the skill directory (targetDir -> sourceDir) + // e.g., .agents/skills/my-skill -> .claude/skills/my-skill + const result = await createSymlink(fullSourceDir, fullTargetDir, true); + + if (result.symlinked) { + console.log(` ✓ Created symlink: ${targetDir} -> ${sourceDir}`); + } else { + console.log(` ✓ Copied files to: ${targetDir} (symlinks not supported)`); + } + + // Handle progressive disclosure manifest if needed + if (!options.noAppend) { + const nativeSubtypes = FORMAT_NATIVE_SUBTYPES[targetFormat]; + const hasNativeSupport = nativeSubtypes?.includes(subtype) ?? false; + if (!hasNativeSupport) { + const manifestPath = options.manifestFile || getManifestFilename(targetFormat); + const manifestEntry: SkillManifestEntry = { + name: packageName, + description: `${packageName} skill`, + skillPath: targetDir, + mainFile: 'SKILL.md', + resourceType: 'skill', + eager: options.eager, + }; + await addSkillToManifest(manifestEntry, manifestPath); + console.log(` ✓ Added skill to ${manifestPath} manifest`); + } + } + + return targetDir; + } + } + + // For other subtypes or if skill directory not found, fall back to null (full install) + return null; + } catch (error) { + console.error(` ⚠️ Symlink creation failed: ${error instanceof Error ? error.message : error}`); + return null; + } +} + /** * Get icon for package format and subtype */ @@ -375,8 +529,9 @@ export async function handleInstall( name_slug: string; version?: string; }; + symlinkFrom?: InstallResult; // If provided, create symlinks to this installation instead of writing files } -): Promise { +): Promise { const startTime = Date.now(); let success = false; let error: string | undefined; @@ -921,10 +1076,11 @@ export async function handleInstall( locationOverride = undefined; } - // Track where files were saved for user feedback + // Track where files were saved for user feedback and symlink support let destPath = ''; // Will be set based on format/subtype before saving let destDir = ''; // Destination directory (needed for progressive disclosure) let fileCount = 0; + const allInstalledPaths: string[] = []; // Track all installed files for symlink support let hookMetadata: { events: string[]; hookId: string } | undefined = undefined; let pluginMetadata: { files: string[]; mcpServers?: Record; mcpGlobal?: boolean; mcpEditor?: MCPEditor } | undefined = undefined; let snippetMetadata: { targetPath: string; config: SnippetConfig } | undefined = undefined; @@ -1046,6 +1202,7 @@ export async function handleInstall( destPath = `${claudeRoot}/`; fileCount = installedFiles.length; + allInstalledPaths.push(...installedFiles); } // Special handling for MCP server packages (install server configs to .mcp.json or editor config) // Match when: native MCP format, OR source is MCP and --as targets an MCP editor (e.g., --as codex) @@ -1171,6 +1328,7 @@ export async function handleInstall( destPath = options.global ? path.join(os.homedir(), '.claude', 'CLAUDE.md') : 'CLAUDE.md'; await saveFile(destPath, mainFile); + allInstalledPaths.push(destPath); fileCount = 1; } // Check if this is a multi-file package @@ -1431,6 +1589,7 @@ export async function handleInstall( } await saveFile(destPath, mainFile); + allInstalledPaths.push(destPath); fileCount = 1; } else { // Multi-file package handling @@ -1590,6 +1749,7 @@ export async function handleInstall( const filePath = `${packageDir}/${fileName}`; await saveFile(filePath, fileContent); + allInstalledPaths.push(filePath); fileCount++; } @@ -1774,6 +1934,14 @@ export async function handleInstall( console.log(`\n💡 This package has been downloaded ${newDownloadCount.toLocaleString()} times`); success = true; + + // Return install result for symlink support in multi-format installs + return { + format: effectiveFormat as Format, + subtype: effectiveSubtype as Subtype, + installedPaths: allInstalledPaths, + destPath, + }; } catch (err) { error = err instanceof Error ? err.message : String(err); throw new CLIError(`\n❌ Installation failed: ${error}\n\n💡 Tips:\n - Check package name: prpm search \n - Get package info: prpm info `, 1); @@ -2223,11 +2391,18 @@ export function createInstallCommand(): Command { // Multi-target install: loop over each format and install independently. // Each iteration re-runs the full install pipeline (download, convert, write), // so one target failing doesn't stop the others. + // + // Symlink optimization: For symlink-compatible format pairs (e.g., claude/codex skills), + // we install the first format normally and create symlinks for subsequent formats. console.log(`📦 Installing ${packageSpec} to ${asTokens.length} targets: ${asTokens.join(', ')}\n`); let successCount = 0; const failures: { target: string; error: string }[] = []; const isCollectionSpec = packageSpec.startsWith('collections/'); + // Track first successful installation for each subtype (for symlink support) + const installedBySubtype: Map = new Map(); + const resolvedSubtype = (options.subtype as Subtype) || 'skill'; // Default to skill if not specified + for (const token of asTokens) { const isMCPEditorOnly = !validFormats.includes(token as Format) && MCP_EDITORS.includes(token as MCPEditor); const convertTo = isMCPEditorOnly ? undefined : (token as Format); @@ -2235,7 +2410,27 @@ export function createInstallCommand(): Command { console.log(chalk.cyan(`\n━━ [${token}] ━━`)); try { - await handleInstall(packageSpec, { + // Check if we can use symlinks instead of full installation + const existingInstall = installedBySubtype.get(resolvedSubtype); + if (convertTo && existingInstall && areFormatsSymlinkCompatible(existingInstall.format, convertTo, resolvedSubtype)) { + // Use symlinks instead of full installation + const symlinkResult = await createSymlinkInstall( + packageSpec, + existingInstall.result, + convertTo, + resolvedSubtype, + options + ); + if (symlinkResult) { + console.log(` 🔗 Symlinked from ${existingInstall.format} installation`); + successCount++; + continue; + } + // Fall through to full install if symlink failed + console.log(` ⚠️ Symlink failed, performing full install...`); + } + + const result = await handleInstall(packageSpec, { version: options.version, as: convertTo, subtype: options.subtype as Subtype | undefined, @@ -2250,6 +2445,12 @@ export function createInstallCommand(): Command { global: options.global, editor: (options.editor as MCPEditor | undefined) || mcpEditor, }); + + // Track successful installation for potential symlink targets + if (result && convertTo && !installedBySubtype.has(resolvedSubtype)) { + installedBySubtype.set(resolvedSubtype, { format: convertTo, result }); + } + successCount++; } catch (err) { // Collection installs throw CLIError with exitCode 0 on success — treat those as success. diff --git a/packages/cli/src/core/filesystem.ts b/packages/cli/src/core/filesystem.ts index d75e5c70..809ddd28 100644 --- a/packages/cli/src/core/filesystem.ts +++ b/packages/cli/src/core/filesystem.ts @@ -4,6 +4,7 @@ import { promises as fs } from 'fs'; import path from 'path'; +import os from 'os'; import { Format, Subtype } from '../types'; import { getDestinationDirectory, getSubtypeConfig, getSubtypes, getDefaultSubtype } from '@pr-pm/converters'; @@ -68,6 +69,87 @@ export async function saveFile(filePath: string, content: string): Promise } } +/** + * Create a symbolic link from source to target + * Falls back to copying if symlinks are not supported (Windows without admin) + * + * @param target - The path the symlink points to (the existing file) + * @param linkPath - The path where the symlink will be created + * @param forceRelative - If true, converts target to relative path from linkPath's directory + */ +export async function createSymlink( + target: string, + linkPath: string, + forceRelative: boolean = true +): Promise<{ symlinked: boolean; path: string }> { + try { + // Ensure parent directory exists + const linkDir = path.dirname(linkPath); + await ensureDirectoryExists(linkDir); + + // Convert to relative path if requested (better for portability) + let symlinkTarget = target; + if (forceRelative) { + symlinkTarget = path.relative(linkDir, target); + } + + // Remove existing file/symlink if present + try { + await fs.unlink(linkPath); + } catch { + // File doesn't exist, that's fine + } + + // Create symlink (use 'junction' on Windows for directories, 'file' for files) + const targetStat = await fs.stat(target); + const type = os.platform() === 'win32' && targetStat.isDirectory() ? 'junction' : undefined; + await fs.symlink(symlinkTarget, linkPath, type); + + return { symlinked: true, path: linkPath }; + } catch (error) { + // Symlinks may not be supported (Windows without developer mode) + // Fall back to copying + const err = error as NodeJS.ErrnoException; + if (err.code === 'EPERM' || err.code === 'ENOTSUP') { + await copyFileOrDirectory(target, linkPath); + return { symlinked: false, path: linkPath }; + } + throw new Error(`Failed to create symlink ${linkPath} -> ${target}: ${error}`); + } +} + +/** + * Copy a file or directory recursively + */ +async function copyFileOrDirectory(source: string, dest: string): Promise { + const stat = await fs.stat(source); + + if (stat.isDirectory()) { + await fs.mkdir(dest, { recursive: true }); + const entries = await fs.readdir(source, { withFileTypes: true }); + for (const entry of entries) { + const srcPath = path.join(source, entry.name); + const destPath = path.join(dest, entry.name); + await copyFileOrDirectory(srcPath, destPath); + } + } else { + await ensureDirectoryExists(path.dirname(dest)); + await fs.copyFile(source, dest); + } +} + +/** + * Check if a path is a symbolic link + */ +export async function isSymlink(filePath: string): Promise { + try { + const stat = await fs.lstat(filePath); + return stat.isSymbolicLink(); + } catch { + return false; + } +} + /** * Delete a file */