Skip to content
Open
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
67 changes: 64 additions & 3 deletions src/adapters/claude-code.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,88 @@
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[] = [];

parts.push(`# ${manifest.name}`);
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);
}
Expand Down
11 changes: 5 additions & 6 deletions src/commands/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,7 @@ interface ExportOptions {

export const exportCommand = new Command('export')
.description('Export agent to other formats')
.requiredOption('-f, --format <format>', 'Export format (system-prompt, claude-code, openai, crewai, openclaw, nanobot, lyzr, github, copilot, opencode, cursor, gemini)')
.requiredOption('-f, --format <format>', 'Export format (system-prompt, claude-code, openai, crewai, openclaw, nanobot, lyzr, github, copilot, opencode, cursor, codex)')
.requiredOption('-f, --format <format>', 'Export format (system-prompt, claude-code, openai, crewai, openclaw, nanobot, lyzr, github, copilot, opencode, cursor, gemini, codex)')
.option('-d, --dir <dir>', 'Agent directory', '.')
.option('-o, --output <output>', 'Output file path')
.action(async (options: ExportOptions) => {
Expand Down Expand Up @@ -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);
}

Expand Down
11 changes: 5 additions & 6 deletions src/commands/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,8 +521,7 @@ function parseSections(markdown: string): [string, string][] {

export const importCommand = new Command('import')
.description('Import from other agent formats')
.requiredOption('--from <format>', 'Source format (claude, cursor, crewai, opencode, gemini)')
.requiredOption('--from <format>', 'Source format (claude, cursor, crewai, opencode, codex)')
.requiredOption('--from <format>', 'Source format (claude, cursor, crewai, opencode, gemini, codex)')
.argument('<path>', 'Source file or directory path')
.option('-d, --dir <dir>', 'Target directory', '.')
.action((sourcePath: string, options: ImportOptions) => {
Expand All @@ -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);
}

Expand Down
143 changes: 103 additions & 40 deletions src/commands/install.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,43 @@
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';
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 <dir>', 'Agent directory', '.')
.option('-f, --force', 'Force re-install (remove existing before install)', false)
.action((options: InstallOptions) => {
const dir = resolve(options.dir);

Expand All @@ -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');
});