diff --git a/src/adapters/claude-code.ts b/src/adapters/claude-code.ts index 5e0fb49..0550b48 100644 --- a/src/adapters/claude-code.ts +++ b/src/adapters/claude-code.ts @@ -1,13 +1,76 @@ -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync, readFileSync, readdirSync, cpSync, mkdirSync } from 'node:fs'; import { join, resolve } from 'node:path'; import yaml from 'js-yaml'; import { loadAgentManifest, loadFileIfExists } from '../utils/loader.js'; import { loadAllSkillMetadata } from '../utils/skill-loader.js'; +/** + * Merge parent agent content into the current agent directory. + * Resolution rules per spec Section 15: + * - SOUL.md: child replaces parent entirely + * - RULES.md: child rules append to parent rules (union) + * - skills/, tools/: union with child shadowing parent on name collision + * - memory/: isolated per agent (not inherited) + */ +function mergeParentContent(agentDir: string, parentDir: string): { + mergedSoul: string | null; + mergedRules: string | null; +} { + const childSoul = loadFileIfExists(join(agentDir, 'SOUL.md')); + const parentSoul = loadFileIfExists(join(parentDir, 'SOUL.md')); + + const childRules = loadFileIfExists(join(agentDir, 'RULES.md')); + const parentRules = loadFileIfExists(join(parentDir, 'RULES.md')); + + // SOUL.md: child replaces parent entirely; fall back to parent if child has none + const mergedSoul = childSoul ?? parentSoul; + + // RULES.md: union — parent first, then child appended + let mergedRules: string | null = null; + if (parentRules && childRules) { + mergedRules = parentRules + '\n\n' + childRules; + } else { + mergedRules = childRules ?? parentRules; + } + + // skills/: copy parent skills that don't exist in child + const parentSkillsDir = join(parentDir, 'skills'); + const childSkillsDir = join(agentDir, 'skills'); + if (existsSync(parentSkillsDir)) { + mkdirSync(childSkillsDir, { recursive: true }); + const parentSkills = readdirSync(parentSkillsDir, { withFileTypes: true }); + for (const entry of parentSkills) { + if (!entry.isDirectory()) continue; + const childSkillPath = join(childSkillsDir, entry.name); + if (!existsSync(childSkillPath)) { + cpSync(join(parentSkillsDir, entry.name), childSkillPath, { recursive: true }); + } + } + } + + return { mergedSoul, mergedRules }; +} + export function exportToClaudeCode(dir: string): string { const agentDir = resolve(dir); const manifest = loadAgentManifest(agentDir); + // Check for installed parent agent (extends) + const parentDir = join(agentDir, '.gitagent', 'parent'); + const hasParent = existsSync(parentDir) && existsSync(join(parentDir, 'agent.yaml')); + + let soul: string | null; + let rules: string | null; + + if (hasParent) { + const merged = mergeParentContent(agentDir, parentDir); + soul = merged.mergedSoul; + rules = merged.mergedRules; + } else { + soul = loadFileIfExists(join(agentDir, 'SOUL.md')); + rules = loadFileIfExists(join(agentDir, 'RULES.md')); + } + // Build CLAUDE.md content const parts: string[] = []; @@ -15,13 +78,11 @@ export function exportToClaudeCode(dir: string): string { parts.push(`${manifest.description}\n`); // SOUL.md → identity section - const soul = loadFileIfExists(join(agentDir, 'SOUL.md')); if (soul) { parts.push(soul); } // RULES.md → constraints section - const rules = loadFileIfExists(join(agentDir, 'RULES.md')); if (rules) { parts.push(rules); } diff --git a/src/commands/export.ts b/src/commands/export.ts index b952d7e..4a63666 100644 --- a/src/commands/export.ts +++ b/src/commands/export.ts @@ -25,8 +25,7 @@ interface ExportOptions { export const exportCommand = new Command('export') .description('Export agent to other formats') -.requiredOption('-f, --format ', 'Export format (system-prompt, claude-code, openai, crewai, openclaw, nanobot, lyzr, github, copilot, opencode, cursor, gemini)') -.requiredOption('-f, --format ', 'Export format (system-prompt, claude-code, openai, crewai, openclaw, nanobot, lyzr, github, copilot, opencode, cursor, codex)') +.requiredOption('-f, --format ', 'Export format (system-prompt, claude-code, openai, crewai, openclaw, nanobot, lyzr, github, copilot, opencode, cursor, gemini, codex)') .option('-d, --dir ', 'Agent directory', '.') .option('-o, --output ', 'Output file path') .action(async (options: ExportOptions) => { @@ -75,12 +74,12 @@ export const exportCommand = new Command('export') case 'gemini': result = exportToGeminiString(dir); break; + case 'codex': + result = exportToCodexString(dir); + break; default: error(`Unknown format: ${options.format}`); - info('Supported formats: system-prompt, claude-code, openai, crewai, openclaw, nanobot, lyzr, github, copilot, opencode, cursor, gemini'); -case 'codex': - result = exportToCodexString(dir); - info('Supported formats: system-prompt, claude-code, openai, crewai, openclaw, nanobot, lyzr, github, copilot, opencode, cursor, codex'); + info('Supported formats: system-prompt, claude-code, openai, crewai, openclaw, nanobot, lyzr, github, copilot, opencode, cursor, gemini, codex'); process.exit(1); } diff --git a/src/commands/import.ts b/src/commands/import.ts index fa07732..51141ab 100644 --- a/src/commands/import.ts +++ b/src/commands/import.ts @@ -521,8 +521,7 @@ function parseSections(markdown: string): [string, string][] { export const importCommand = new Command('import') .description('Import from other agent formats') -.requiredOption('--from ', 'Source format (claude, cursor, crewai, opencode, gemini)') -.requiredOption('--from ', 'Source format (claude, cursor, crewai, opencode, codex)') +.requiredOption('--from ', 'Source format (claude, cursor, crewai, opencode, gemini, codex)') .argument('', 'Source file or directory path') .option('-d, --dir ', 'Target directory', '.') .action((sourcePath: string, options: ImportOptions) => { @@ -549,12 +548,12 @@ export const importCommand = new Command('import') case 'gemini': importFromGemini(sourcePath, targetDir); break; + case 'codex': + importFromCodex(sourcePath, targetDir); + break; default: error(`Unknown format: ${options.from}`); - info('Supported formats: claude, cursor, crewai, opencode, gemini'); -case 'codex': - importFromCodex(sourcePath, targetDir); - info('Supported formats: claude, cursor, crewai, opencode, codex'); + info('Supported formats: claude, cursor, crewai, opencode, gemini, codex'); process.exit(1); } diff --git a/src/commands/install.ts b/src/commands/install.ts index 34b8633..98491d1 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -1,5 +1,5 @@ import { Command } from 'commander'; -import { existsSync, mkdirSync } from 'node:fs'; +import { existsSync, mkdirSync, rmSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { execSync } from 'node:child_process'; import { loadAgentManifest } from '../utils/loader.js'; @@ -7,11 +7,37 @@ import { success, error, info, heading, divider, warn } from '../utils/format.js interface InstallOptions { dir: string; + force: boolean; +} + +function cloneGitRepo(source: string, targetDir: string, version?: string): void { + const versionFlag = version ? `--branch ${version.replace('^', '')}` : ''; + mkdirSync(join(targetDir, '..'), { recursive: true }); + execSync(`git clone --depth 1 ${versionFlag} "${source}" "${targetDir}" 2>&1`, { + stdio: 'pipe', + timeout: 60000, + }); +} + +function isGitSource(source: string): boolean { + return source.endsWith('.git') || source.includes('github.com') || source.includes('bitbucket.org') || source.includes('gitlab.com'); +} + +function removeIfExists(targetDir: string, force: boolean): boolean { + if (existsSync(targetDir)) { + if (!force) { + warn(`${targetDir} already exists, skipping (use --force to update)`); + return false; + } + rmSync(targetDir, { recursive: true, force: true }); + } + return true; } export const installCommand = new Command('install') - .description('Resolve and install agent dependencies') + .description('Resolve and install agent dependencies and extends') .option('-d, --dir ', 'Agent directory', '.') + .option('-f, --force', 'Force re-install (remove existing before install)', false) .action((options: InstallOptions) => { const dir = resolve(options.dir); @@ -25,64 +51,101 @@ export const installCommand = new Command('install') heading('Installing dependencies'); - if (!manifest.dependencies || manifest.dependencies.length === 0) { - info('No dependencies to install'); + const hasExtends = !!manifest.extends; + const hasDeps = manifest.dependencies && manifest.dependencies.length > 0; + + if (!hasExtends && !hasDeps) { + info('No dependencies or extends to install'); return; } const depsDir = join(dir, '.gitagent', 'deps'); mkdirSync(depsDir, { recursive: true }); - for (const dep of manifest.dependencies) { + // Handle extends — clone parent agent + if (hasExtends) { divider(); - info(`Installing ${dep.name} from ${dep.source}`); + const extendsSource = manifest.extends!; + info(`Installing parent agent from ${extendsSource}`); - const targetDir = dep.mount - ? join(dir, dep.mount) - : join(depsDir, dep.name); - - if (existsSync(targetDir)) { - warn(`${dep.name} already exists at ${targetDir}, skipping`); - continue; - } + const parentDir = join(dir, '.gitagent', 'parent'); - // Check if source is a local path - if (existsSync(resolve(dir, dep.source))) { - // Local dependency — symlink or copy - const sourcePath = resolve(dir, dep.source); + if (!removeIfExists(parentDir, options.force)) { + // skipped + } else if (existsSync(resolve(dir, extendsSource))) { + // Local extends + const sourcePath = resolve(dir, extendsSource); try { - mkdirSync(join(targetDir, '..'), { recursive: true }); - execSync(`cp -r "${sourcePath}" "${targetDir}"`, { stdio: 'pipe' }); - success(`Installed ${dep.name} (local)`); + mkdirSync(join(parentDir, '..'), { recursive: true }); + execSync(`cp -r "${sourcePath}" "${parentDir}"`, { stdio: 'pipe' }); + success('Installed parent agent (local)'); } catch (e) { - error(`Failed to install ${dep.name}: ${(e as Error).message}`); + error(`Failed to install parent agent: ${(e as Error).message}`); } - } else if (dep.source.includes('github.com') || dep.source.endsWith('.git')) { - // Git dependency + } else if (isGitSource(extendsSource)) { try { - const versionFlag = dep.version ? `--branch ${dep.version.replace('^', '')}` : ''; - mkdirSync(join(targetDir, '..'), { recursive: true }); - execSync(`git clone --depth 1 ${versionFlag} "${dep.source}" "${targetDir}" 2>&1`, { - stdio: 'pipe', - timeout: 60000, - }); - success(`Installed ${dep.name} (git)`); + cloneGitRepo(extendsSource, parentDir); + success('Installed parent agent (git)'); } catch (e) { - error(`Failed to clone ${dep.name}: ${(e as Error).message}`); + error(`Failed to clone parent agent: ${(e as Error).message}`); } } else { - warn(`Unknown source type for ${dep.name}: ${dep.source}`); + warn(`Unknown source type for extends: ${extendsSource}`); } - // Validate installed dependency - const depAgentYaml = join(targetDir, 'agent.yaml'); - if (existsSync(depAgentYaml)) { - success(`${dep.name} is a valid gitagent`); - } else { - warn(`${dep.name} does not contain agent.yaml — may not be a gitagent`); + // Validate parent + if (existsSync(join(parentDir, 'agent.yaml'))) { + success('Parent agent is a valid gitagent'); + } else if (existsSync(parentDir)) { + warn('Parent agent does not contain agent.yaml'); + } + } + + // Handle dependencies + if (hasDeps) { + for (const dep of manifest.dependencies!) { + divider(); + info(`Installing ${dep.name} from ${dep.source}`); + + const targetDir = dep.mount + ? join(dir, dep.mount) + : join(depsDir, dep.name); + + if (!removeIfExists(targetDir, options.force)) { + continue; + } + + // Check if source is a local path + if (existsSync(resolve(dir, dep.source))) { + const sourcePath = resolve(dir, dep.source); + try { + mkdirSync(join(targetDir, '..'), { recursive: true }); + execSync(`cp -r "${sourcePath}" "${targetDir}"`, { stdio: 'pipe' }); + success(`Installed ${dep.name} (local)`); + } catch (e) { + error(`Failed to install ${dep.name}: ${(e as Error).message}`); + } + } else if (isGitSource(dep.source)) { + try { + cloneGitRepo(dep.source, targetDir, dep.version); + success(`Installed ${dep.name} (git)`); + } catch (e) { + error(`Failed to clone ${dep.name}: ${(e as Error).message}`); + } + } else { + warn(`Unknown source type for ${dep.name}: ${dep.source}`); + } + + // Validate installed dependency + const depAgentYaml = join(targetDir, 'agent.yaml'); + if (existsSync(depAgentYaml)) { + success(`${dep.name} is a valid gitagent`); + } else { + warn(`${dep.name} does not contain agent.yaml — may not be a gitagent`); + } } } divider(); - success('Dependencies installed'); + success('Installation complete'); });