@rbbtsn0w/adg 0.1.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +308 -0
- package/bin/adg.ts +758 -0
- package/docs/agents-spec.md +132 -0
- package/docs/authoring.md +352 -0
- package/package.json +50 -0
- package/schemas/adg-plugin.schema.json +77 -0
- package/schemas/marketplace.schema.json +86 -0
- package/schemas/plugin-lock.schema.json +90 -0
- package/src/adapters/anthropic.ts +54 -0
- package/src/adapters/index.ts +24 -0
- package/src/adapters/openai.ts +37 -0
- package/src/adapters/reverse.ts +60 -0
- package/src/agents/claude.ts +124 -0
- package/src/agents/codex.ts +67 -0
- package/src/agents/index.ts +12 -0
- package/src/agents/registry.ts +30 -0
- package/src/agents/types.ts +47 -0
- package/src/commands/adapt.ts +36 -0
- package/src/commands/import.ts +69 -0
- package/src/commands/init.ts +146 -0
- package/src/commands/install.ts +411 -0
- package/src/commands/link.ts +61 -0
- package/src/commands/list.ts +28 -0
- package/src/commands/marketplace.ts +198 -0
- package/src/commands/migrate.ts +84 -0
- package/src/commands/multiselect-skills.ts +137 -0
- package/src/commands/remove.ts +136 -0
- package/src/commands/select-agents.ts +45 -0
- package/src/commands/select-components.ts +66 -0
- package/src/commands/select-plugins.ts +28 -0
- package/src/commands/select-scope.ts +21 -0
- package/src/commands/update.ts +85 -0
- package/src/commands/validate.ts +57 -0
- package/src/components.ts +90 -0
- package/src/deps.ts +64 -0
- package/src/fsutil.ts +38 -0
- package/src/hash.ts +61 -0
- package/src/lock.ts +57 -0
- package/src/manifest.ts +113 -0
- package/src/marketplace.ts +41 -0
- package/src/package.ts +74 -0
- package/src/paths.ts +129 -0
- package/src/semver.ts +67 -0
- package/src/skills.ts +88 -0
- package/src/sources.ts +159 -0
- package/src/types.ts +140 -0
- package/vendor/skills/LICENSE +29 -0
- package/vendor/skills/PROVENANCE.md +60 -0
- package/vendor/skills/ThirdPartyNoticeText.txt +117 -0
- package/vendor/skills/package.json +143 -0
- package/vendor/skills/src/add.ts +1999 -0
- package/vendor/skills/src/agents.ts +755 -0
- package/vendor/skills/src/blob.ts +567 -0
- package/vendor/skills/src/cli.ts +387 -0
- package/vendor/skills/src/constants.ts +3 -0
- package/vendor/skills/src/detect-agent.ts +62 -0
- package/vendor/skills/src/find.ts +357 -0
- package/vendor/skills/src/frontmatter.ts +16 -0
- package/vendor/skills/src/git-tree.ts +36 -0
- package/vendor/skills/src/git.ts +277 -0
- package/vendor/skills/src/install.ts +91 -0
- package/vendor/skills/src/installer.ts +1097 -0
- package/vendor/skills/src/list.ts +231 -0
- package/vendor/skills/src/local-lock.ts +182 -0
- package/vendor/skills/src/plugin-manifest.ts +183 -0
- package/vendor/skills/src/prompts/search-multiselect.ts +387 -0
- package/vendor/skills/src/providers/index.ts +14 -0
- package/vendor/skills/src/providers/registry.ts +51 -0
- package/vendor/skills/src/providers/types.ts +97 -0
- package/vendor/skills/src/providers/wellknown.ts +804 -0
- package/vendor/skills/src/remove.ts +323 -0
- package/vendor/skills/src/sanitize.ts +65 -0
- package/vendor/skills/src/self-cli.ts +20 -0
- package/vendor/skills/src/skill-lock.ts +329 -0
- package/vendor/skills/src/skills.ts +316 -0
- package/vendor/skills/src/source-parser.ts +438 -0
- package/vendor/skills/src/sync.ts +478 -0
- package/vendor/skills/src/telemetry.ts +186 -0
- package/vendor/skills/src/test-utils.ts +73 -0
- package/vendor/skills/src/types.ts +128 -0
- package/vendor/skills/src/update-source.ts +90 -0
- package/vendor/skills/src/update.ts +749 -0
- package/vendor/skills/src/use.ts +675 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import * as readline from 'readline';
|
|
2
|
+
import { runAdd, parseAddOptions } from './add.ts';
|
|
3
|
+
import { sanitizeMetadata } from './sanitize.ts';
|
|
4
|
+
import { track } from './telemetry.ts';
|
|
5
|
+
import { isRepoPrivate } from './source-parser.ts';
|
|
6
|
+
import { isRunningInAgent } from './detect-agent.ts';
|
|
7
|
+
|
|
8
|
+
const RESET = '\x1b[0m';
|
|
9
|
+
const BOLD = '\x1b[1m';
|
|
10
|
+
const DIM = '\x1b[38;5;102m';
|
|
11
|
+
const TEXT = '\x1b[38;5;145m';
|
|
12
|
+
const CYAN = '\x1b[36m';
|
|
13
|
+
const MAGENTA = '\x1b[35m';
|
|
14
|
+
const YELLOW = '\x1b[33m';
|
|
15
|
+
|
|
16
|
+
// API endpoint for skills search
|
|
17
|
+
const SEARCH_API_BASE = process.env.SKILLS_API_URL || 'https://skills.sh';
|
|
18
|
+
|
|
19
|
+
function formatInstalls(count: number): string {
|
|
20
|
+
if (!count || count <= 0) return '';
|
|
21
|
+
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, '')}M installs`;
|
|
22
|
+
if (count >= 1_000) return `${(count / 1_000).toFixed(1).replace(/\.0$/, '')}K installs`;
|
|
23
|
+
return `${count} install${count === 1 ? '' : 's'}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SearchSkill {
|
|
27
|
+
name: string;
|
|
28
|
+
slug: string;
|
|
29
|
+
source: string;
|
|
30
|
+
installs: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Search via API
|
|
34
|
+
export async function searchSkillsAPI(query: string): Promise<SearchSkill[]> {
|
|
35
|
+
try {
|
|
36
|
+
const url = `${SEARCH_API_BASE}/api/search?q=${encodeURIComponent(query)}&limit=10`;
|
|
37
|
+
const res = await fetch(url);
|
|
38
|
+
|
|
39
|
+
if (!res.ok) return [];
|
|
40
|
+
|
|
41
|
+
const data = (await res.json()) as {
|
|
42
|
+
skills: Array<{
|
|
43
|
+
id: string;
|
|
44
|
+
name: string;
|
|
45
|
+
installs: number;
|
|
46
|
+
source: string;
|
|
47
|
+
}>;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return data.skills
|
|
51
|
+
.map((skill) => ({
|
|
52
|
+
name: sanitizeMetadata(skill.name),
|
|
53
|
+
slug: sanitizeMetadata(skill.id),
|
|
54
|
+
source: sanitizeMetadata(skill.source || ''),
|
|
55
|
+
installs: skill.installs,
|
|
56
|
+
}))
|
|
57
|
+
.sort((a, b) => (b.installs || 0) - (a.installs || 0));
|
|
58
|
+
} catch {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ANSI escape codes for terminal control
|
|
64
|
+
const HIDE_CURSOR = '\x1b[?25l';
|
|
65
|
+
const SHOW_CURSOR = '\x1b[?25h';
|
|
66
|
+
const CLEAR_DOWN = '\x1b[J';
|
|
67
|
+
const MOVE_UP = (n: number) => `\x1b[${n}A`;
|
|
68
|
+
const MOVE_TO_COL = (n: number) => `\x1b[${n}G`;
|
|
69
|
+
|
|
70
|
+
// Custom fzf-style search prompt using raw readline
|
|
71
|
+
async function runSearchPrompt(initialQuery = ''): Promise<SearchSkill | null> {
|
|
72
|
+
let results: SearchSkill[] = [];
|
|
73
|
+
let selectedIndex = 0;
|
|
74
|
+
let query = initialQuery;
|
|
75
|
+
let loading = false;
|
|
76
|
+
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
77
|
+
let lastRenderedLines = 0;
|
|
78
|
+
|
|
79
|
+
// Enable raw mode for keypress events
|
|
80
|
+
if (process.stdin.isTTY) {
|
|
81
|
+
process.stdin.setRawMode(true);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Setup readline for keypress events but don't let it echo
|
|
85
|
+
readline.emitKeypressEvents(process.stdin);
|
|
86
|
+
|
|
87
|
+
// Resume stdin to start receiving events
|
|
88
|
+
process.stdin.resume();
|
|
89
|
+
|
|
90
|
+
// Hide cursor during selection
|
|
91
|
+
process.stdout.write(HIDE_CURSOR);
|
|
92
|
+
|
|
93
|
+
function render(): void {
|
|
94
|
+
// Move cursor up to overwrite previous render
|
|
95
|
+
if (lastRenderedLines > 0) {
|
|
96
|
+
process.stdout.write(MOVE_UP(lastRenderedLines) + MOVE_TO_COL(1));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Clear from cursor to end of screen (removes ghost trails)
|
|
100
|
+
process.stdout.write(CLEAR_DOWN);
|
|
101
|
+
|
|
102
|
+
const lines: string[] = [];
|
|
103
|
+
|
|
104
|
+
// Search input line with cursor
|
|
105
|
+
const cursor = `${BOLD}_${RESET}`;
|
|
106
|
+
lines.push(`${TEXT}Search skills:${RESET} ${query}${cursor}`);
|
|
107
|
+
lines.push('');
|
|
108
|
+
|
|
109
|
+
// Results - keep showing existing results while loading new ones
|
|
110
|
+
if (!query || query.length < 2) {
|
|
111
|
+
lines.push(`${DIM}Start typing to search (min 2 chars)${RESET}`);
|
|
112
|
+
} else if (results.length === 0 && loading) {
|
|
113
|
+
lines.push(`${DIM}Searching...${RESET}`);
|
|
114
|
+
} else if (results.length === 0) {
|
|
115
|
+
lines.push(`${DIM}No skills found${RESET}`);
|
|
116
|
+
} else {
|
|
117
|
+
const maxVisible = 8;
|
|
118
|
+
const visible = results.slice(0, maxVisible);
|
|
119
|
+
|
|
120
|
+
for (let i = 0; i < visible.length; i++) {
|
|
121
|
+
const skill = visible[i]!;
|
|
122
|
+
const isSelected = i === selectedIndex;
|
|
123
|
+
const arrow = isSelected ? `${BOLD}>${RESET}` : ' ';
|
|
124
|
+
const name = isSelected ? `${BOLD}${skill.name}${RESET}` : `${TEXT}${skill.name}${RESET}`;
|
|
125
|
+
const source = skill.source ? ` ${DIM}${skill.source}${RESET}` : '';
|
|
126
|
+
const installs = formatInstalls(skill.installs);
|
|
127
|
+
const installsBadge = installs ? ` ${CYAN}${installs}${RESET}` : '';
|
|
128
|
+
const loadingIndicator = loading && i === 0 ? ` ${DIM}...${RESET}` : '';
|
|
129
|
+
|
|
130
|
+
lines.push(` ${arrow} ${name}${source}${installsBadge}${loadingIndicator}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
lines.push('');
|
|
135
|
+
lines.push(`${DIM}up/down navigate | enter select | esc cancel${RESET}`);
|
|
136
|
+
|
|
137
|
+
// Write each line
|
|
138
|
+
for (const line of lines) {
|
|
139
|
+
process.stdout.write(line + '\n');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
lastRenderedLines = lines.length;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function triggerSearch(q: string): void {
|
|
146
|
+
// Always clear any pending debounce timer
|
|
147
|
+
if (debounceTimer) {
|
|
148
|
+
clearTimeout(debounceTimer);
|
|
149
|
+
debounceTimer = null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Always reset loading state when starting a new search
|
|
153
|
+
loading = false;
|
|
154
|
+
|
|
155
|
+
if (!q || q.length < 2) {
|
|
156
|
+
results = [];
|
|
157
|
+
selectedIndex = 0;
|
|
158
|
+
render();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Use API search for all queries (debounced)
|
|
163
|
+
loading = true;
|
|
164
|
+
render();
|
|
165
|
+
|
|
166
|
+
// Adaptive debounce: shorter queries = longer wait (user still typing)
|
|
167
|
+
// 2 chars: 250ms, 3 chars: 200ms, 4 chars: 150ms, 5+ chars: 150ms
|
|
168
|
+
const debounceMs = Math.max(150, 350 - q.length * 50);
|
|
169
|
+
|
|
170
|
+
debounceTimer = setTimeout(async () => {
|
|
171
|
+
try {
|
|
172
|
+
results = await searchSkillsAPI(q);
|
|
173
|
+
selectedIndex = 0;
|
|
174
|
+
} catch {
|
|
175
|
+
results = [];
|
|
176
|
+
} finally {
|
|
177
|
+
loading = false;
|
|
178
|
+
debounceTimer = null;
|
|
179
|
+
render();
|
|
180
|
+
}
|
|
181
|
+
}, debounceMs);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Trigger initial search if there's a query, then render
|
|
185
|
+
if (initialQuery) {
|
|
186
|
+
triggerSearch(initialQuery);
|
|
187
|
+
}
|
|
188
|
+
render();
|
|
189
|
+
|
|
190
|
+
return new Promise((resolve) => {
|
|
191
|
+
function cleanup(): void {
|
|
192
|
+
process.stdin.removeListener('keypress', handleKeypress);
|
|
193
|
+
if (process.stdin.isTTY) {
|
|
194
|
+
process.stdin.setRawMode(false);
|
|
195
|
+
}
|
|
196
|
+
process.stdout.write(SHOW_CURSOR);
|
|
197
|
+
// Pause stdin to fully release it for child processes
|
|
198
|
+
process.stdin.pause();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function handleKeypress(_ch: string | undefined, key: readline.Key): void {
|
|
202
|
+
if (!key) return;
|
|
203
|
+
|
|
204
|
+
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
|
|
205
|
+
// Cancel
|
|
206
|
+
cleanup();
|
|
207
|
+
resolve(null);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (key.name === 'return') {
|
|
212
|
+
// Submit
|
|
213
|
+
cleanup();
|
|
214
|
+
resolve(results[selectedIndex] || null);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (key.name === 'up') {
|
|
219
|
+
selectedIndex = Math.max(0, selectedIndex - 1);
|
|
220
|
+
render();
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (key.name === 'down') {
|
|
225
|
+
selectedIndex = Math.min(Math.max(0, results.length - 1), selectedIndex + 1);
|
|
226
|
+
render();
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (key.name === 'backspace') {
|
|
231
|
+
if (query.length > 0) {
|
|
232
|
+
query = query.slice(0, -1);
|
|
233
|
+
triggerSearch(query);
|
|
234
|
+
}
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Regular character input
|
|
239
|
+
if (key.sequence && !key.ctrl && !key.meta && key.sequence.length === 1) {
|
|
240
|
+
const char = key.sequence;
|
|
241
|
+
if (char >= ' ' && char <= '~') {
|
|
242
|
+
query += char;
|
|
243
|
+
triggerSearch(query);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
process.stdin.on('keypress', handleKeypress);
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Parse owner/repo from a package string (for the find command)
|
|
253
|
+
function getOwnerRepoFromString(pkg: string): { owner: string; repo: string } | null {
|
|
254
|
+
// Handle owner/repo or owner/repo@skill
|
|
255
|
+
const atIndex = pkg.lastIndexOf('@');
|
|
256
|
+
const repoPath = atIndex > 0 ? pkg.slice(0, atIndex) : pkg;
|
|
257
|
+
const match = repoPath.match(/^([^/]+)\/([^/]+)$/);
|
|
258
|
+
if (match) {
|
|
259
|
+
return { owner: match[1]!, repo: match[2]! };
|
|
260
|
+
}
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function isRepoPublic(owner: string, repo: string): Promise<boolean> {
|
|
265
|
+
const isPrivate = await isRepoPrivate(owner, repo);
|
|
266
|
+
// Return true only if we know it's public (isPrivate === false)
|
|
267
|
+
// Return false if private or unable to determine
|
|
268
|
+
return isPrivate === false;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export async function runFind(args: string[]): Promise<void> {
|
|
272
|
+
const query = args.join(' ');
|
|
273
|
+
const isNonInteractive = !process.stdin.isTTY;
|
|
274
|
+
const agentTip = `${DIM}Tip: if running in a coding agent, follow these steps:${RESET}
|
|
275
|
+
${DIM} 1) npx skills find [query]${RESET}
|
|
276
|
+
${DIM} 2) npx skills add <owner/repo@skill>${RESET}`;
|
|
277
|
+
|
|
278
|
+
// Non-interactive mode: just print results and exit
|
|
279
|
+
if (query) {
|
|
280
|
+
const results = await searchSkillsAPI(query);
|
|
281
|
+
|
|
282
|
+
// Track telemetry for non-interactive search
|
|
283
|
+
track({
|
|
284
|
+
event: 'find',
|
|
285
|
+
query,
|
|
286
|
+
resultCount: String(results.length),
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
if (results.length === 0) {
|
|
290
|
+
console.log(`${DIM}No skills found for "${query}"${RESET}`);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
console.log(`${DIM}Install with${RESET} npx skills add <owner/repo@skill>`);
|
|
295
|
+
console.log();
|
|
296
|
+
|
|
297
|
+
for (const skill of results.slice(0, 6)) {
|
|
298
|
+
const pkg = skill.source || skill.slug;
|
|
299
|
+
const installs = formatInstalls(skill.installs);
|
|
300
|
+
console.log(
|
|
301
|
+
`${TEXT}${pkg}@${skill.name}${RESET}${installs ? ` ${CYAN}${installs}${RESET}` : ''}`
|
|
302
|
+
);
|
|
303
|
+
console.log(`${DIM}└ https://skills.sh/${skill.slug}${RESET}`);
|
|
304
|
+
console.log();
|
|
305
|
+
}
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Skip interactive search when running inside an AI agent or non-TTY
|
|
310
|
+
if (isNonInteractive || (await isRunningInAgent())) {
|
|
311
|
+
console.log(agentTip);
|
|
312
|
+
console.log();
|
|
313
|
+
console.log(`${DIM}Usage: npx skills find <query>${RESET}`);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const selected = await runSearchPrompt();
|
|
318
|
+
|
|
319
|
+
// Track telemetry for interactive search
|
|
320
|
+
track({
|
|
321
|
+
event: 'find',
|
|
322
|
+
query: '',
|
|
323
|
+
resultCount: selected ? '1' : '0',
|
|
324
|
+
interactive: '1',
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
if (!selected) {
|
|
328
|
+
console.log(`${DIM}Search cancelled${RESET}`);
|
|
329
|
+
console.log();
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Use source (owner/repo) and skill name for installation
|
|
334
|
+
const pkg = selected.source || selected.slug;
|
|
335
|
+
const skillName = selected.name;
|
|
336
|
+
|
|
337
|
+
console.log();
|
|
338
|
+
console.log(`${TEXT}Installing ${BOLD}${skillName}${RESET} from ${DIM}${pkg}${RESET}...`);
|
|
339
|
+
console.log();
|
|
340
|
+
|
|
341
|
+
// Run add directly since we're in the same CLI
|
|
342
|
+
const { source, options } = parseAddOptions([pkg, '--skill', skillName]);
|
|
343
|
+
await runAdd(source, options);
|
|
344
|
+
|
|
345
|
+
console.log();
|
|
346
|
+
|
|
347
|
+
const info = getOwnerRepoFromString(pkg);
|
|
348
|
+
if (info && (await isRepoPublic(info.owner, info.repo))) {
|
|
349
|
+
console.log(
|
|
350
|
+
`${DIM}View the skill at${RESET} ${TEXT}https://skills.sh/${selected.slug}${RESET}`
|
|
351
|
+
);
|
|
352
|
+
} else {
|
|
353
|
+
console.log(`${DIM}Discover more skills at${RESET} ${TEXT}https://skills.sh${RESET}`);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
console.log();
|
|
357
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { parse as parseYaml } from 'yaml';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal frontmatter parser. Only supports YAML (the `---` delimiter).
|
|
5
|
+
* Does NOT support `---js` / `---javascript` to avoid eval()-based RCE
|
|
6
|
+
* that exists in gray-matter's built-in JS engine.
|
|
7
|
+
*/
|
|
8
|
+
export function parseFrontmatter(raw: string): {
|
|
9
|
+
data: Record<string, unknown>;
|
|
10
|
+
content: string;
|
|
11
|
+
} {
|
|
12
|
+
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
13
|
+
if (!match) return { data: {}, content: raw };
|
|
14
|
+
const data = (parseYaml(match[1]!) as Record<string, unknown>) ?? {};
|
|
15
|
+
return { data, content: match[2] ?? '' };
|
|
16
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* ADG-added file (see vendor/skills/PROVENANCE.md → Local patches).
|
|
8
|
+
*
|
|
9
|
+
* Return the git *tree object SHA* for a folder inside a freshly cloned repo —
|
|
10
|
+
* the SAME 40-hex value GitHub's Trees API returns and that
|
|
11
|
+
* `getSkillFolderHashFromTree` (blob.ts) compares against at update time.
|
|
12
|
+
*
|
|
13
|
+
* Used by `add.ts` so a github source that fell back to a `git clone` at install
|
|
14
|
+
* still records a tree SHA, not a sha256 content hash. Otherwise the install and
|
|
15
|
+
* update hashing schemes diverge and every update perpetually re-flags the skill
|
|
16
|
+
* (the bug that made a collection repo "fully update" on every run).
|
|
17
|
+
*
|
|
18
|
+
* `folder === ''` means the repo root. Returns null if git can't resolve it.
|
|
19
|
+
*
|
|
20
|
+
* Deliberately uses `child_process` rather than simple-git: this keeps the file
|
|
21
|
+
* free of simple-git's typings, which don't satisfy ADG's strict tsconfig when a
|
|
22
|
+
* test pulls the module into the typecheck graph.
|
|
23
|
+
*/
|
|
24
|
+
export async function gitTreeShaForFolder(
|
|
25
|
+
repoDir: string,
|
|
26
|
+
folder: string
|
|
27
|
+
): Promise<string | null> {
|
|
28
|
+
const spec = folder ? `HEAD:${folder}` : 'HEAD^{tree}';
|
|
29
|
+
try {
|
|
30
|
+
const { stdout } = await execFileAsync('git', ['-C', repoDir, 'rev-parse', spec]);
|
|
31
|
+
const sha = stdout.trim();
|
|
32
|
+
return /^[0-9a-f]{40}$/.test(sha) ? sha : null;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// ADG patch: named import. The default import is not callable under the root
|
|
2
|
+
// tsconfig (NodeNext + verbatimModuleSyntax, no esModuleInterop); simple-git
|
|
3
|
+
// exposes `simpleGit` as a named export. See vendor/skills/PROVENANCE.md.
|
|
4
|
+
import { simpleGit } from 'simple-git';
|
|
5
|
+
import { join, normalize, resolve, sep } from 'path';
|
|
6
|
+
import { mkdtemp, mkdir, rm } from 'fs/promises';
|
|
7
|
+
import { tmpdir } from 'os';
|
|
8
|
+
import { execFile } from 'child_process';
|
|
9
|
+
import { promisify } from 'util';
|
|
10
|
+
|
|
11
|
+
const DEFAULT_CLONE_TIMEOUT_MS = 300_000; // 5 minutes
|
|
12
|
+
const CLONE_TIMEOUT_MS = (() => {
|
|
13
|
+
const raw = process.env.SKILLS_CLONE_TIMEOUT_MS;
|
|
14
|
+
if (!raw) return DEFAULT_CLONE_TIMEOUT_MS;
|
|
15
|
+
const parsed = Number.parseInt(raw, 10);
|
|
16
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_CLONE_TIMEOUT_MS;
|
|
17
|
+
})();
|
|
18
|
+
const execFileAsync = promisify(execFile);
|
|
19
|
+
|
|
20
|
+
interface GitHubRepoInfo {
|
|
21
|
+
owner: string;
|
|
22
|
+
repo: string;
|
|
23
|
+
slug: string;
|
|
24
|
+
sshUrl: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class GitCloneError extends Error {
|
|
28
|
+
readonly url: string;
|
|
29
|
+
readonly isTimeout: boolean;
|
|
30
|
+
readonly isAuthError: boolean;
|
|
31
|
+
|
|
32
|
+
constructor(message: string, url: string, isTimeout = false, isAuthError = false) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = 'GitCloneError';
|
|
35
|
+
this.url = url;
|
|
36
|
+
this.isTimeout = isTimeout;
|
|
37
|
+
this.isAuthError = isAuthError;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function parseGitHubRepoUrl(url: string): GitHubRepoInfo | null {
|
|
42
|
+
const sshMatch = url.match(/^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/i);
|
|
43
|
+
if (sshMatch) {
|
|
44
|
+
const owner = sshMatch[1]!;
|
|
45
|
+
const repo = sshMatch[2]!;
|
|
46
|
+
return {
|
|
47
|
+
owner,
|
|
48
|
+
repo,
|
|
49
|
+
slug: `${owner}/${repo}`,
|
|
50
|
+
sshUrl: `git@github.com:${owner}/${repo}.git`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const parsed = new URL(url);
|
|
56
|
+
if (parsed.hostname !== 'github.com') return null;
|
|
57
|
+
|
|
58
|
+
const match = parsed.pathname.match(/^\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
|
|
59
|
+
if (!match) return null;
|
|
60
|
+
|
|
61
|
+
const owner = match[1]!;
|
|
62
|
+
const repo = match[2]!;
|
|
63
|
+
return {
|
|
64
|
+
owner,
|
|
65
|
+
repo,
|
|
66
|
+
slug: `${owner}/${repo}`,
|
|
67
|
+
sshUrl: `git@github.com:${owner}/${repo}.git`,
|
|
68
|
+
};
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function isGitHubHttpsCloneUrl(url: string): boolean {
|
|
75
|
+
try {
|
|
76
|
+
const parsed = new URL(url);
|
|
77
|
+
return parsed.protocol === 'https:' && parsed.hostname === 'github.com';
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function isGitHubSsoAuthError(message: string): boolean {
|
|
84
|
+
const lower = message.toLowerCase();
|
|
85
|
+
return (
|
|
86
|
+
lower.includes('saml sso') ||
|
|
87
|
+
lower.includes('enforced sso') ||
|
|
88
|
+
lower.includes('enabled or enforced saml') ||
|
|
89
|
+
lower.includes('re-authorize the oauth application')
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isAuthFailure(message: string): boolean {
|
|
94
|
+
return (
|
|
95
|
+
message.includes('Authentication failed') ||
|
|
96
|
+
message.includes('could not read Username') ||
|
|
97
|
+
message.includes('Permission denied') ||
|
|
98
|
+
message.includes('Repository not found') ||
|
|
99
|
+
message.includes('requested URL returned error: 403') ||
|
|
100
|
+
isGitHubSsoAuthError(message)
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function createGitClient(extraEnv?: NodeJS.ProcessEnv) {
|
|
105
|
+
return simpleGit({
|
|
106
|
+
timeout: { block: CLONE_TIMEOUT_MS },
|
|
107
|
+
// When git-lfs is NOT installed, GIT_LFS_SKIP_SMUDGE has no effect —
|
|
108
|
+
// git sees `filter=lfs` in .gitattributes, tries to run
|
|
109
|
+
// `git-lfs filter-process`, and aborts the checkout with:
|
|
110
|
+
// git-lfs filter-process: git-lfs: command not found
|
|
111
|
+
// fatal: the remote end hung up unexpectedly
|
|
112
|
+
// warning: Clone succeeded, but checkout failed.
|
|
113
|
+
// Overriding filter.lfs.* at the command level disables the filter
|
|
114
|
+
// entirely for this clone, so checkout succeeds regardless of whether
|
|
115
|
+
// git-lfs is installed. LFS-tracked files are left as ~130-byte
|
|
116
|
+
// pointer files, which the skills installer doesn't read anyway
|
|
117
|
+
// (skills are plain text — HTML/MD/JSON — never LFS-tracked).
|
|
118
|
+
//
|
|
119
|
+
// Reported downstream: heygen-com/hyperframes#407.
|
|
120
|
+
config: [
|
|
121
|
+
'filter.lfs.required=false',
|
|
122
|
+
'filter.lfs.smudge=',
|
|
123
|
+
'filter.lfs.clean=',
|
|
124
|
+
'filter.lfs.process=',
|
|
125
|
+
],
|
|
126
|
+
// ADG patch: simple-git >=3.36 blocks `filter.*.smudge/clean` configs (an RCE
|
|
127
|
+
// vector via a malicious filter command) unless explicitly allowed, failing
|
|
128
|
+
// every clone with "Configuring filter.smudge is not permitted without
|
|
129
|
+
// enabling allowUnsafeFilter". Here the filters are set to EMPTY — we are
|
|
130
|
+
// *disabling* the LFS filter, not running one — so opting in is safe and is
|
|
131
|
+
// exactly the intent of the config above. See vendor/skills/PROVENANCE.md.
|
|
132
|
+
unsafe: {
|
|
133
|
+
allowUnsafeFilter: true,
|
|
134
|
+
},
|
|
135
|
+
// ADG patch: env must be applied via `.env()`, not as a constructor option.
|
|
136
|
+
// simple-git's factory only reads baseDir/maxConcurrentProcesses/trimmed from
|
|
137
|
+
// the options object and silently drops `env`, so the GIT_TERMINAL_PROMPT /
|
|
138
|
+
// GIT_LFS_SKIP_SMUDGE / GIT_SSH_COMMAND overrides below never reached the
|
|
139
|
+
// spawned git before this. See vendor/skills/PROVENANCE.md.
|
|
140
|
+
}).env({
|
|
141
|
+
...process.env,
|
|
142
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
143
|
+
// When git-lfs IS installed, tell it not to download LFS content during
|
|
144
|
+
// checkout. See #952 for context and empirical impact.
|
|
145
|
+
GIT_LFS_SKIP_SMUDGE: '1',
|
|
146
|
+
...extraEnv,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function resetTempDir(dir: string): Promise<void> {
|
|
151
|
+
await rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
152
|
+
await mkdir(dir, { recursive: true });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function tryGhClone(repo: GitHubRepoInfo, tempDir: string, ref?: string): Promise<boolean> {
|
|
156
|
+
let cloneTarget = repo.slug;
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
const { stdout, stderr } = await execFileAsync('gh', ['auth', 'status', '-h', 'github.com'], {
|
|
160
|
+
timeout: 5000,
|
|
161
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
162
|
+
});
|
|
163
|
+
const statusOutput = `${stdout}${stderr}`;
|
|
164
|
+
if (/Git operations protocol:\s+ssh/i.test(statusOutput)) {
|
|
165
|
+
cloneTarget = repo.sshUrl;
|
|
166
|
+
}
|
|
167
|
+
} catch {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const gitFlags = ref ? ['--depth=1', '--branch', ref] : ['--depth=1'];
|
|
172
|
+
await execFileAsync('gh', ['repo', 'clone', cloneTarget, tempDir, '--', ...gitFlags], {
|
|
173
|
+
timeout: CLONE_TIMEOUT_MS,
|
|
174
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
175
|
+
});
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function buildGitHubAuthError(url: string, repo: GitHubRepoInfo | null, message: string): string {
|
|
180
|
+
if (repo && isGitHubSsoAuthError(message)) {
|
|
181
|
+
return (
|
|
182
|
+
`GitHub blocked HTTPS access to ${url} because the organization enforces SAML SSO.\n` +
|
|
183
|
+
` skills tried your existing git credentials and available fallbacks, but none succeeded.\n` +
|
|
184
|
+
` - Re-authorize your GitHub credentials/app for that org's SSO policy\n` +
|
|
185
|
+
` - Or rerun with SSH: npx skills add ${repo.sshUrl}\n` +
|
|
186
|
+
` - Verify access with: gh auth status -h github.com or ssh -T git@github.com`
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (repo) {
|
|
191
|
+
return (
|
|
192
|
+
`Authentication failed for ${url}.\n` +
|
|
193
|
+
` - For private repos, ensure you have access\n` +
|
|
194
|
+
` - Retry with SSH: npx skills add ${repo.sshUrl}\n` +
|
|
195
|
+
` - Check access with: gh auth status -h github.com or ssh -T git@github.com`
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return (
|
|
200
|
+
`Authentication failed for ${url}.\n` +
|
|
201
|
+
` - For private repos, ensure you have access\n` +
|
|
202
|
+
` - For SSH: Check your keys with 'ssh -T git@github.com'\n` +
|
|
203
|
+
` - For HTTPS: Run 'gh auth login' or configure git credentials`
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function cloneRepo(url: string, ref?: string): Promise<string> {
|
|
208
|
+
const tempDir = await mkdtemp(join(tmpdir(), 'skills-'));
|
|
209
|
+
const cloneOptions = ref ? ['--depth', '1', '--branch', ref] : ['--depth', '1'];
|
|
210
|
+
const repo = parseGitHubRepoUrl(url);
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
await createGitClient().clone(url, tempDir, cloneOptions);
|
|
214
|
+
return tempDir;
|
|
215
|
+
} catch (error) {
|
|
216
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
217
|
+
const isTimeout = errorMessage.includes('block timeout') || errorMessage.includes('timed out');
|
|
218
|
+
const isAuthError = isAuthFailure(errorMessage);
|
|
219
|
+
|
|
220
|
+
if (isTimeout) {
|
|
221
|
+
await rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
|
222
|
+
const seconds = Math.round(CLONE_TIMEOUT_MS / 1000);
|
|
223
|
+
throw new GitCloneError(
|
|
224
|
+
`Clone timed out after ${seconds}s. Common causes:\n` +
|
|
225
|
+
` - Large repository: raise the timeout with SKILLS_CLONE_TIMEOUT_MS=600000 (10m)\n` +
|
|
226
|
+
` - Slow network: retry, or clone manually and pass the local path to 'skills add'\n` +
|
|
227
|
+
` - Private repo without credentials: ensure auth is configured\n` +
|
|
228
|
+
` - For SSH: ssh-add -l (to check loaded keys)\n` +
|
|
229
|
+
` - For HTTPS: gh auth status (if using GitHub CLI)`,
|
|
230
|
+
url,
|
|
231
|
+
true,
|
|
232
|
+
false
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (isAuthError && repo && isGitHubHttpsCloneUrl(url)) {
|
|
237
|
+
try {
|
|
238
|
+
await resetTempDir(tempDir);
|
|
239
|
+
if (await tryGhClone(repo, tempDir, ref)) {
|
|
240
|
+
return tempDir;
|
|
241
|
+
}
|
|
242
|
+
} catch {
|
|
243
|
+
// Fall through to SSH retry.
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
await resetTempDir(tempDir);
|
|
248
|
+
await createGitClient({
|
|
249
|
+
GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND ?? 'ssh -o BatchMode=yes',
|
|
250
|
+
}).clone(repo.sshUrl, tempDir, cloneOptions);
|
|
251
|
+
return tempDir;
|
|
252
|
+
} catch {
|
|
253
|
+
// Fall through to the targeted auth error below.
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
await rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
|
258
|
+
|
|
259
|
+
if (isAuthError) {
|
|
260
|
+
throw new GitCloneError(buildGitHubAuthError(url, repo, errorMessage), url, false, true);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
throw new GitCloneError(`Failed to clone ${url}: ${errorMessage}`, url, false, false);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export async function cleanupTempDir(dir: string): Promise<void> {
|
|
268
|
+
// Validate that the directory path is within tmpdir to prevent deletion of arbitrary paths
|
|
269
|
+
const normalizedDir = normalize(resolve(dir));
|
|
270
|
+
const normalizedTmpDir = normalize(resolve(tmpdir()));
|
|
271
|
+
|
|
272
|
+
if (!normalizedDir.startsWith(normalizedTmpDir + sep) && normalizedDir !== normalizedTmpDir) {
|
|
273
|
+
throw new Error('Attempted to clean up directory outside of temp directory');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
await rm(dir, { recursive: true, force: true });
|
|
277
|
+
}
|