Skip to content

Commit 4ff303f

Browse files
authored
Merge pull request #88 from constructive-io/feat/skill-installer
feat(genomic): add SkillInstaller for fast skill installation via shallow clone
2 parents 81b3580 + a2d6f55 commit 4ff303f

3 files changed

Lines changed: 165 additions & 0 deletions

File tree

packages/genomic/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ export * from './git/git-cloner';
2020
export * from './git/types';
2121
export * from './scaffolder/template-scaffolder';
2222
export * from './scaffolder/types';
23+
export * from './skills/skill-installer';
24+
export * from './skills/types';
2325
export * from './template/templatizer';
2426
export * from './template/types';
2527
export * from './utils/npm-version-check';
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
4+
import { CacheManager } from '../cache/cache-manager';
5+
import { GitCloner } from '../git/git-cloner';
6+
import { BoilerplateSkill } from '../scaffolder/types';
7+
import { SkillInstallOptions, SkillInstallResult } from './types';
8+
9+
const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
10+
const SKILLS_DIR = path.join('.agents', 'skills');
11+
12+
/**
13+
* Installs skills from git repositories using shallow clones.
14+
*
15+
* Much faster than `npx skills add` because it:
16+
* - Clones with `--depth 1` (shallow)
17+
* - Caches source repos (subsequent installs are instant)
18+
* - Copies skill directories directly (no npm download)
19+
*
20+
* @example
21+
* ```typescript
22+
* const installer = new SkillInstaller({ toolName: 'pgpm' });
23+
* const result = installer.install(
24+
* [{ source: 'constructive-io/constructive', skills: ['pgpm'] }],
25+
* '/path/to/workspace'
26+
* );
27+
* ```
28+
*/
29+
export class SkillInstaller {
30+
private gitCloner: GitCloner;
31+
private cacheManager: CacheManager;
32+
33+
constructor(options?: SkillInstallOptions) {
34+
this.gitCloner = new GitCloner();
35+
this.cacheManager = new CacheManager({
36+
toolName: options?.toolName ?? 'genomic',
37+
ttl: options?.cacheTtlMs ?? DEFAULT_TTL_MS,
38+
baseDir: options?.cacheBaseDir,
39+
});
40+
}
41+
42+
/**
43+
* Install skills from their source repositories into a target directory.
44+
*
45+
* For each skill entry, clones the source repo (shallow, cached) and
46+
* copies `.agents/skills/<name>/` into `<targetDir>/.agents/skills/<name>/`.
47+
*
48+
* Non-fatal: failures are collected and returned, never thrown.
49+
*/
50+
install(skills: BoilerplateSkill[], targetDir: string): SkillInstallResult {
51+
const installed: string[] = [];
52+
const failed: SkillInstallResult['failed'] = [];
53+
54+
// Group skills by source to avoid cloning the same repo multiple times
55+
const bySource = new Map<string, string[]>();
56+
for (const entry of skills) {
57+
const existing = bySource.get(entry.source) ?? [];
58+
existing.push(...entry.skills);
59+
bySource.set(entry.source, existing);
60+
}
61+
62+
for (const [source, skillNames] of bySource) {
63+
let repoDir: string;
64+
try {
65+
repoDir = this.ensureRepo(source);
66+
} catch (err) {
67+
const msg = err instanceof Error ? err.message : String(err);
68+
for (const skill of skillNames) {
69+
failed.push({ skill, source, error: msg });
70+
}
71+
continue;
72+
}
73+
74+
for (const skillName of skillNames) {
75+
const sourcePath = path.join(repoDir, SKILLS_DIR, skillName);
76+
77+
if (!fs.existsSync(sourcePath)) {
78+
failed.push({
79+
skill: skillName,
80+
source,
81+
error: `Skill not found in source: ${SKILLS_DIR}/${skillName}`,
82+
});
83+
continue;
84+
}
85+
86+
try {
87+
const destPath = path.join(targetDir, SKILLS_DIR, skillName);
88+
fs.mkdirSync(path.dirname(destPath), { recursive: true });
89+
fs.cpSync(sourcePath, destPath, { recursive: true });
90+
installed.push(skillName);
91+
} catch (err) {
92+
const msg = err instanceof Error ? err.message : String(err);
93+
failed.push({ skill: skillName, source, error: msg });
94+
}
95+
}
96+
}
97+
98+
return { installed, failed };
99+
}
100+
101+
/**
102+
* Clone (or retrieve from cache) a skill source repository.
103+
*/
104+
private ensureRepo(source: string): string {
105+
const url = this.gitCloner.normalizeUrl(source);
106+
const cacheKey = this.cacheManager.createKey(url);
107+
108+
const expiredMetadata = this.cacheManager.checkExpiration(cacheKey);
109+
if (expiredMetadata) {
110+
this.cacheManager.clear(cacheKey);
111+
}
112+
113+
const cached = this.cacheManager.get(cacheKey);
114+
if (cached && !expiredMetadata) {
115+
return cached;
116+
}
117+
118+
const dest = path.join(this.cacheManager.getReposDir(), cacheKey);
119+
this.gitCloner.clone(url, dest, {
120+
depth: 1,
121+
singleBranch: true,
122+
});
123+
this.cacheManager.set(cacheKey, dest);
124+
return dest;
125+
}
126+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
export interface SkillInstallOptions {
2+
/**
3+
* Tool name for cache directory naming (e.g., 'pgpm' -> ~/.pgpm/cache).
4+
* Sharing the same toolName as your TemplateScaffolder means skills
5+
* and templates share the same cache directory.
6+
* @default 'genomic'
7+
*/
8+
toolName?: string;
9+
10+
/**
11+
* Cache TTL in milliseconds for cloned skill source repos.
12+
* @default 7 days
13+
*/
14+
cacheTtlMs?: number;
15+
16+
/**
17+
* Base directory for cache storage.
18+
* Useful for tests to avoid touching the real home directory.
19+
*/
20+
cacheBaseDir?: string;
21+
}
22+
23+
export interface SkillInstallResult {
24+
/** Successfully installed skill names */
25+
installed: string[];
26+
/** Skills that failed to install */
27+
failed: SkillInstallFailure[];
28+
}
29+
30+
export interface SkillInstallFailure {
31+
/** Skill name that failed */
32+
skill: string;
33+
/** Source repository */
34+
source: string;
35+
/** Error description */
36+
error: string;
37+
}

0 commit comments

Comments
 (0)