@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.
Files changed (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +308 -0
  3. package/bin/adg.ts +758 -0
  4. package/docs/agents-spec.md +132 -0
  5. package/docs/authoring.md +352 -0
  6. package/package.json +50 -0
  7. package/schemas/adg-plugin.schema.json +77 -0
  8. package/schemas/marketplace.schema.json +86 -0
  9. package/schemas/plugin-lock.schema.json +90 -0
  10. package/src/adapters/anthropic.ts +54 -0
  11. package/src/adapters/index.ts +24 -0
  12. package/src/adapters/openai.ts +37 -0
  13. package/src/adapters/reverse.ts +60 -0
  14. package/src/agents/claude.ts +124 -0
  15. package/src/agents/codex.ts +67 -0
  16. package/src/agents/index.ts +12 -0
  17. package/src/agents/registry.ts +30 -0
  18. package/src/agents/types.ts +47 -0
  19. package/src/commands/adapt.ts +36 -0
  20. package/src/commands/import.ts +69 -0
  21. package/src/commands/init.ts +146 -0
  22. package/src/commands/install.ts +411 -0
  23. package/src/commands/link.ts +61 -0
  24. package/src/commands/list.ts +28 -0
  25. package/src/commands/marketplace.ts +198 -0
  26. package/src/commands/migrate.ts +84 -0
  27. package/src/commands/multiselect-skills.ts +137 -0
  28. package/src/commands/remove.ts +136 -0
  29. package/src/commands/select-agents.ts +45 -0
  30. package/src/commands/select-components.ts +66 -0
  31. package/src/commands/select-plugins.ts +28 -0
  32. package/src/commands/select-scope.ts +21 -0
  33. package/src/commands/update.ts +85 -0
  34. package/src/commands/validate.ts +57 -0
  35. package/src/components.ts +90 -0
  36. package/src/deps.ts +64 -0
  37. package/src/fsutil.ts +38 -0
  38. package/src/hash.ts +61 -0
  39. package/src/lock.ts +57 -0
  40. package/src/manifest.ts +113 -0
  41. package/src/marketplace.ts +41 -0
  42. package/src/package.ts +74 -0
  43. package/src/paths.ts +129 -0
  44. package/src/semver.ts +67 -0
  45. package/src/skills.ts +88 -0
  46. package/src/sources.ts +159 -0
  47. package/src/types.ts +140 -0
  48. package/vendor/skills/LICENSE +29 -0
  49. package/vendor/skills/PROVENANCE.md +60 -0
  50. package/vendor/skills/ThirdPartyNoticeText.txt +117 -0
  51. package/vendor/skills/package.json +143 -0
  52. package/vendor/skills/src/add.ts +1999 -0
  53. package/vendor/skills/src/agents.ts +755 -0
  54. package/vendor/skills/src/blob.ts +567 -0
  55. package/vendor/skills/src/cli.ts +387 -0
  56. package/vendor/skills/src/constants.ts +3 -0
  57. package/vendor/skills/src/detect-agent.ts +62 -0
  58. package/vendor/skills/src/find.ts +357 -0
  59. package/vendor/skills/src/frontmatter.ts +16 -0
  60. package/vendor/skills/src/git-tree.ts +36 -0
  61. package/vendor/skills/src/git.ts +277 -0
  62. package/vendor/skills/src/install.ts +91 -0
  63. package/vendor/skills/src/installer.ts +1097 -0
  64. package/vendor/skills/src/list.ts +231 -0
  65. package/vendor/skills/src/local-lock.ts +182 -0
  66. package/vendor/skills/src/plugin-manifest.ts +183 -0
  67. package/vendor/skills/src/prompts/search-multiselect.ts +387 -0
  68. package/vendor/skills/src/providers/index.ts +14 -0
  69. package/vendor/skills/src/providers/registry.ts +51 -0
  70. package/vendor/skills/src/providers/types.ts +97 -0
  71. package/vendor/skills/src/providers/wellknown.ts +804 -0
  72. package/vendor/skills/src/remove.ts +323 -0
  73. package/vendor/skills/src/sanitize.ts +65 -0
  74. package/vendor/skills/src/self-cli.ts +20 -0
  75. package/vendor/skills/src/skill-lock.ts +329 -0
  76. package/vendor/skills/src/skills.ts +316 -0
  77. package/vendor/skills/src/source-parser.ts +438 -0
  78. package/vendor/skills/src/sync.ts +478 -0
  79. package/vendor/skills/src/telemetry.ts +186 -0
  80. package/vendor/skills/src/test-utils.ts +73 -0
  81. package/vendor/skills/src/types.ts +128 -0
  82. package/vendor/skills/src/update-source.ts +90 -0
  83. package/vendor/skills/src/update.ts +749 -0
  84. 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
+ }