Skip to content

Commit 795fa2e

Browse files
committed
feat: implement extends resolution, --force flag, and parent merge in export (#55)
- `gitagent install` now resolves `extends` field — clones parent agent to .gitagent/parent/ - `--force` flag removes and re-installs existing deps/parent - `gitagent export -f claude-code` merges parent content: - SOUL.md: child replaces parent - RULES.md: parent + child appended (union) - skills/: parent skills copied where child doesn't shadow - Git source detection supports GitHub, Bitbucket, and GitLab - Security: uses execFileSync (not execSync) and cpSync (not cp -r shell)
1 parent 0ef6b91 commit 795fa2e

File tree

2 files changed

+172
-44
lines changed

2 files changed

+172
-44
lines changed

src/adapters/claude-code.ts

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,88 @@
1-
import { existsSync, readFileSync } from 'node:fs';
1+
import { existsSync, readFileSync, readdirSync, cpSync, mkdirSync } from 'node:fs';
22
import { join, resolve } from 'node:path';
33
import yaml from 'js-yaml';
44
import { loadAgentManifest, loadFileIfExists } from '../utils/loader.js';
55
import { loadAllSkillMetadata } from '../utils/skill-loader.js';
66

7+
/**
8+
* Merge parent agent content into the current agent directory.
9+
* Resolution rules per spec Section 15:
10+
* - SOUL.md: child replaces parent entirely
11+
* - RULES.md: child rules append to parent rules (union)
12+
* - skills/, tools/: union with child shadowing parent on name collision
13+
* - memory/: isolated per agent (not inherited)
14+
*/
15+
function mergeParentContent(agentDir: string, parentDir: string): {
16+
mergedSoul: string | null;
17+
mergedRules: string | null;
18+
} {
19+
const childSoul = loadFileIfExists(join(agentDir, 'SOUL.md'));
20+
const parentSoul = loadFileIfExists(join(parentDir, 'SOUL.md'));
21+
22+
const childRules = loadFileIfExists(join(agentDir, 'RULES.md'));
23+
const parentRules = loadFileIfExists(join(parentDir, 'RULES.md'));
24+
25+
// SOUL.md: child replaces parent entirely; fall back to parent if child has none
26+
const mergedSoul = childSoul ?? parentSoul;
27+
28+
// RULES.md: union — parent first, then child appended
29+
let mergedRules: string | null = null;
30+
if (parentRules && childRules) {
31+
mergedRules = parentRules + '\n\n' + childRules;
32+
} else {
33+
mergedRules = childRules ?? parentRules;
34+
}
35+
36+
// skills/: copy parent skills that don't exist in child
37+
const parentSkillsDir = join(parentDir, 'skills');
38+
const childSkillsDir = join(agentDir, 'skills');
39+
if (existsSync(parentSkillsDir)) {
40+
mkdirSync(childSkillsDir, { recursive: true });
41+
const parentSkills = readdirSync(parentSkillsDir, { withFileTypes: true });
42+
for (const entry of parentSkills) {
43+
if (!entry.isDirectory()) continue;
44+
const childSkillPath = join(childSkillsDir, entry.name);
45+
if (!existsSync(childSkillPath)) {
46+
cpSync(join(parentSkillsDir, entry.name), childSkillPath, { recursive: true });
47+
}
48+
}
49+
}
50+
51+
return { mergedSoul, mergedRules };
52+
}
53+
754
export function exportToClaudeCode(dir: string): string {
855
const agentDir = resolve(dir);
956
const manifest = loadAgentManifest(agentDir);
1057

58+
// Check for installed parent agent (extends)
59+
const parentDir = join(agentDir, '.gitagent', 'parent');
60+
const hasParent = existsSync(parentDir) && existsSync(join(parentDir, 'agent.yaml'));
61+
62+
let soul: string | null;
63+
let rules: string | null;
64+
65+
if (hasParent) {
66+
const merged = mergeParentContent(agentDir, parentDir);
67+
soul = merged.mergedSoul;
68+
rules = merged.mergedRules;
69+
} else {
70+
soul = loadFileIfExists(join(agentDir, 'SOUL.md'));
71+
rules = loadFileIfExists(join(agentDir, 'RULES.md'));
72+
}
73+
1174
// Build CLAUDE.md content
1275
const parts: string[] = [];
1376

1477
parts.push(`# ${manifest.name}`);
1578
parts.push(`${manifest.description}\n`);
1679

1780
// SOUL.md → identity section
18-
const soul = loadFileIfExists(join(agentDir, 'SOUL.md'));
1981
if (soul) {
2082
parts.push(soul);
2183
}
2284

2385
// RULES.md → constraints section
24-
const rules = loadFileIfExists(join(agentDir, 'RULES.md'));
2586
if (rules) {
2687
parts.push(rules);
2788
}

src/commands/install.ts

Lines changed: 108 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,47 @@
11
import { Command } from 'commander';
2-
import { existsSync, mkdirSync } from 'node:fs';
2+
import { existsSync, mkdirSync, rmSync, cpSync } from 'node:fs';
33
import { join, resolve } from 'node:path';
4-
import { execSync } from 'node:child_process';
4+
import { execFileSync } from 'node:child_process';
55
import { loadAgentManifest } from '../utils/loader.js';
66
import { success, error, info, heading, divider, warn } from '../utils/format.js';
77

88
interface InstallOptions {
99
dir: string;
10+
force: boolean;
11+
}
12+
13+
function cloneGitRepo(source: string, targetDir: string, version?: string): void {
14+
const args = ['clone', '--depth', '1'];
15+
if (version) {
16+
args.push('--branch', version.replace('^', ''));
17+
}
18+
args.push(source, targetDir);
19+
mkdirSync(join(targetDir, '..'), { recursive: true });
20+
execFileSync('git', args, {
21+
stdio: 'pipe',
22+
timeout: 60000,
23+
});
24+
}
25+
26+
function isGitSource(source: string): boolean {
27+
return source.endsWith('.git') || source.includes('github.com') || source.includes('bitbucket.org') || source.includes('gitlab.com');
28+
}
29+
30+
function removeIfExists(targetDir: string, force: boolean): boolean {
31+
if (existsSync(targetDir)) {
32+
if (!force) {
33+
warn(`${targetDir} already exists, skipping (use --force to update)`);
34+
return false;
35+
}
36+
rmSync(targetDir, { recursive: true, force: true });
37+
}
38+
return true;
1039
}
1140

1241
export const installCommand = new Command('install')
13-
.description('Resolve and install agent dependencies')
42+
.description('Resolve and install agent dependencies and extends')
1443
.option('-d, --dir <dir>', 'Agent directory', '.')
44+
.option('-f, --force', 'Force re-install (remove existing before install)', false)
1545
.action((options: InstallOptions) => {
1646
const dir = resolve(options.dir);
1747

@@ -25,64 +55,101 @@ export const installCommand = new Command('install')
2555

2656
heading('Installing dependencies');
2757

28-
if (!manifest.dependencies || manifest.dependencies.length === 0) {
29-
info('No dependencies to install');
58+
const hasExtends = !!manifest.extends;
59+
const hasDeps = manifest.dependencies && manifest.dependencies.length > 0;
60+
61+
if (!hasExtends && !hasDeps) {
62+
info('No dependencies or extends to install');
3063
return;
3164
}
3265

3366
const depsDir = join(dir, '.gitagent', 'deps');
3467
mkdirSync(depsDir, { recursive: true });
3568

36-
for (const dep of manifest.dependencies) {
69+
// Handle extends — clone parent agent
70+
if (hasExtends) {
3771
divider();
38-
info(`Installing ${dep.name} from ${dep.source}`);
72+
const extendsSource = manifest.extends!;
73+
info(`Installing parent agent from ${extendsSource}`);
3974

40-
const targetDir = dep.mount
41-
? join(dir, dep.mount)
42-
: join(depsDir, dep.name);
43-
44-
if (existsSync(targetDir)) {
45-
warn(`${dep.name} already exists at ${targetDir}, skipping`);
46-
continue;
47-
}
75+
const parentDir = join(dir, '.gitagent', 'parent');
4876

49-
// Check if source is a local path
50-
if (existsSync(resolve(dir, dep.source))) {
51-
// Local dependency — symlink or copy
52-
const sourcePath = resolve(dir, dep.source);
77+
if (!removeIfExists(parentDir, options.force)) {
78+
// skipped
79+
} else if (existsSync(resolve(dir, extendsSource))) {
80+
// Local extends
81+
const sourcePath = resolve(dir, extendsSource);
5382
try {
54-
mkdirSync(join(targetDir, '..'), { recursive: true });
55-
execSync(`cp -r "${sourcePath}" "${targetDir}"`, { stdio: 'pipe' });
56-
success(`Installed ${dep.name} (local)`);
83+
mkdirSync(join(parentDir, '..'), { recursive: true });
84+
cpSync(sourcePath, parentDir, { recursive: true });
85+
success('Installed parent agent (local)');
5786
} catch (e) {
58-
error(`Failed to install ${dep.name}: ${(e as Error).message}`);
87+
error(`Failed to install parent agent: ${(e as Error).message}`);
5988
}
60-
} else if (dep.source.includes('github.com') || dep.source.endsWith('.git')) {
61-
// Git dependency
89+
} else if (isGitSource(extendsSource)) {
6290
try {
63-
const versionFlag = dep.version ? `--branch ${dep.version.replace('^', '')}` : '';
64-
mkdirSync(join(targetDir, '..'), { recursive: true });
65-
execSync(`git clone --depth 1 ${versionFlag} "${dep.source}" "${targetDir}" 2>&1`, {
66-
stdio: 'pipe',
67-
timeout: 60000,
68-
});
69-
success(`Installed ${dep.name} (git)`);
91+
cloneGitRepo(extendsSource, parentDir);
92+
success('Installed parent agent (git)');
7093
} catch (e) {
71-
error(`Failed to clone ${dep.name}: ${(e as Error).message}`);
94+
error(`Failed to clone parent agent: ${(e as Error).message}`);
7295
}
7396
} else {
74-
warn(`Unknown source type for ${dep.name}: ${dep.source}`);
97+
warn(`Unknown source type for extends: ${extendsSource}`);
7598
}
7699

77-
// Validate installed dependency
78-
const depAgentYaml = join(targetDir, 'agent.yaml');
79-
if (existsSync(depAgentYaml)) {
80-
success(`${dep.name} is a valid gitagent`);
81-
} else {
82-
warn(`${dep.name} does not contain agent.yaml — may not be a gitagent`);
100+
// Validate parent
101+
if (existsSync(join(parentDir, 'agent.yaml'))) {
102+
success('Parent agent is a valid gitagent');
103+
} else if (existsSync(parentDir)) {
104+
warn('Parent agent does not contain agent.yaml');
105+
}
106+
}
107+
108+
// Handle dependencies
109+
if (hasDeps) {
110+
for (const dep of manifest.dependencies!) {
111+
divider();
112+
info(`Installing ${dep.name} from ${dep.source}`);
113+
114+
const targetDir = dep.mount
115+
? join(dir, dep.mount)
116+
: join(depsDir, dep.name);
117+
118+
if (!removeIfExists(targetDir, options.force)) {
119+
continue;
120+
}
121+
122+
// Check if source is a local path
123+
if (existsSync(resolve(dir, dep.source))) {
124+
const sourcePath = resolve(dir, dep.source);
125+
try {
126+
mkdirSync(join(targetDir, '..'), { recursive: true });
127+
cpSync(sourcePath, targetDir, { recursive: true });
128+
success(`Installed ${dep.name} (local)`);
129+
} catch (e) {
130+
error(`Failed to install ${dep.name}: ${(e as Error).message}`);
131+
}
132+
} else if (isGitSource(dep.source)) {
133+
try {
134+
cloneGitRepo(dep.source, targetDir, dep.version);
135+
success(`Installed ${dep.name} (git)`);
136+
} catch (e) {
137+
error(`Failed to clone ${dep.name}: ${(e as Error).message}`);
138+
}
139+
} else {
140+
warn(`Unknown source type for ${dep.name}: ${dep.source}`);
141+
}
142+
143+
// Validate installed dependency
144+
const depAgentYaml = join(targetDir, 'agent.yaml');
145+
if (existsSync(depAgentYaml)) {
146+
success(`${dep.name} is a valid gitagent`);
147+
} else {
148+
warn(`${dep.name} does not contain agent.yaml — may not be a gitagent`);
149+
}
83150
}
84151
}
85152

86153
divider();
87-
success('Dependencies installed');
154+
success('Installation complete');
88155
});

0 commit comments

Comments
 (0)