skillfish 1.0.5 → 1.0.6
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/dist/commands/add.js +28 -13
- package/dist/lib/github.d.ts +20 -4
- package/dist/lib/github.js +92 -67
- package/dist/lib/installer.d.ts +2 -0
- package/dist/lib/installer.js +34 -3
- package/package.json +1 -1
package/dist/commands/add.js
CHANGED
|
@@ -113,15 +113,16 @@ Examples:
|
|
|
113
113
|
exitWithError('Invalid repository format. Use: owner/repo', EXIT_CODES.INVALID_ARGS);
|
|
114
114
|
}
|
|
115
115
|
// 1. Discover or select skills
|
|
116
|
-
const
|
|
117
|
-
? [explicitPath]
|
|
116
|
+
const discoveryResult = explicitPath
|
|
117
|
+
? { paths: [explicitPath], branch: undefined }
|
|
118
118
|
: await discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput, skillNameArg);
|
|
119
|
-
if (!
|
|
119
|
+
if (!discoveryResult || discoveryResult.paths.length === 0) {
|
|
120
120
|
if (jsonMode) {
|
|
121
121
|
outputJsonAndExit(EXIT_CODES.NOT_FOUND);
|
|
122
122
|
}
|
|
123
123
|
process.exit(EXIT_CODES.NOT_FOUND);
|
|
124
124
|
}
|
|
125
|
+
const { paths: skillPaths, branch: discoveredBranch } = discoveryResult;
|
|
125
126
|
// 2. Determine install location (global vs project)
|
|
126
127
|
const baseDir = await selectInstallLocation(projectFlag, globalFlag, jsonMode);
|
|
127
128
|
// 3. Select agents to install to
|
|
@@ -179,6 +180,7 @@ Examples:
|
|
|
179
180
|
const result = await installSkill(owner, repo, skillPath, skillName, targetAgents, {
|
|
180
181
|
force,
|
|
181
182
|
baseDir,
|
|
183
|
+
branch: discoveredBranch,
|
|
182
184
|
});
|
|
183
185
|
if (spinner) {
|
|
184
186
|
if (result.failed) {
|
|
@@ -324,9 +326,9 @@ async function selectAgents(agents, isLocal, jsonMode) {
|
|
|
324
326
|
return agents.filter((a) => selected.includes(a.name));
|
|
325
327
|
}
|
|
326
328
|
async function discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput, targetSkillName) {
|
|
327
|
-
let
|
|
329
|
+
let skillDiscovery;
|
|
328
330
|
try {
|
|
329
|
-
|
|
331
|
+
skillDiscovery = await findAllSkillMdFiles(owner, repo);
|
|
330
332
|
}
|
|
331
333
|
catch (err) {
|
|
332
334
|
let errorMsg;
|
|
@@ -359,6 +361,7 @@ async function discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput,
|
|
|
359
361
|
}
|
|
360
362
|
process.exit(exitCode);
|
|
361
363
|
}
|
|
364
|
+
const { paths: skillPaths, branch } = skillDiscovery;
|
|
362
365
|
if (skillPaths.length === 0) {
|
|
363
366
|
const errorMsg = `No ${SKILL_FILENAME} found in repository`;
|
|
364
367
|
if (jsonMode) {
|
|
@@ -380,8 +383,8 @@ async function discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput,
|
|
|
380
383
|
const skills = await batchMap(skillPaths, async (sp) => {
|
|
381
384
|
const skillDir = sp === SKILL_FILENAME ? '.' : dirname(sp);
|
|
382
385
|
const folderName = sp === SKILL_FILENAME ? repo : basename(skillDir);
|
|
383
|
-
// Fetch raw content to parse frontmatter
|
|
384
|
-
const content = await fetchSkillMdContent(owner, repo, sp);
|
|
386
|
+
// Fetch raw content to parse frontmatter (use discovered branch)
|
|
387
|
+
const content = await fetchSkillMdContent(owner, repo, sp, branch);
|
|
385
388
|
const frontmatter = content ? parseFrontmatter(content) : {};
|
|
386
389
|
return {
|
|
387
390
|
path: sp,
|
|
@@ -397,15 +400,27 @@ async function discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput,
|
|
|
397
400
|
jsonOutput.skills_found = skills.map((s) => s.name);
|
|
398
401
|
// If a specific skill name was requested, find and return it
|
|
399
402
|
if (targetSkillName) {
|
|
400
|
-
|
|
401
|
-
const
|
|
403
|
+
// Normalize for flexible matching: lowercase, replace spaces/underscores with hyphens
|
|
404
|
+
const normalize = (s) => s.toLowerCase().replace(/[\s_]+/g, '-');
|
|
405
|
+
const normalizedTarget = normalize(targetSkillName);
|
|
406
|
+
// Try multiple matching strategies
|
|
407
|
+
const matchedSkill = skills.find((s) => {
|
|
408
|
+
const normalizedName = normalize(s.name);
|
|
409
|
+
const normalizedDir = normalize(s.dir);
|
|
410
|
+
const dirBasename = normalize(s.dir.split('/').pop() || '');
|
|
411
|
+
return (normalizedName === normalizedTarget || // Exact name match (normalized)
|
|
412
|
+
normalizedDir === normalizedTarget || // Exact dir match
|
|
413
|
+
dirBasename === normalizedTarget || // Directory basename match
|
|
414
|
+
s.name.toLowerCase() === targetSkillName.toLowerCase() // Original case-insensitive
|
|
415
|
+
);
|
|
416
|
+
});
|
|
402
417
|
if (matchedSkill) {
|
|
403
418
|
const displayName = toTitleCase(matchedSkill.name);
|
|
404
419
|
const desc = matchedSkill.description ? truncate(matchedSkill.description, 60) : '';
|
|
405
420
|
if (!jsonMode) {
|
|
406
421
|
p.log.info(`${pc.bold(displayName)}${desc ? pc.dim(` - ${desc}`) : ''}`);
|
|
407
422
|
}
|
|
408
|
-
return [matchedSkill.dir];
|
|
423
|
+
return { paths: [matchedSkill.dir], branch };
|
|
409
424
|
}
|
|
410
425
|
// Skill not found - show available skills
|
|
411
426
|
const errorMsg = `Skill "${targetSkillName}" not found in repository`;
|
|
@@ -431,7 +446,7 @@ async function discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput,
|
|
|
431
446
|
if (!jsonMode) {
|
|
432
447
|
p.log.info(`${pc.bold(displayName)}${desc ? pc.dim(` - ${desc}`) : ''}`);
|
|
433
448
|
}
|
|
434
|
-
return [skill.dir];
|
|
449
|
+
return { paths: [skill.dir], branch };
|
|
435
450
|
}
|
|
436
451
|
// Build options for selection with frontmatter metadata
|
|
437
452
|
// Title in label, description in hint (shows on focus)
|
|
@@ -447,7 +462,7 @@ async function discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput,
|
|
|
447
462
|
if (!jsonMode) {
|
|
448
463
|
console.log(`Installing all ${skills.length} skills`);
|
|
449
464
|
}
|
|
450
|
-
return skills.map((s) => s.dir);
|
|
465
|
+
return { paths: skills.map((s) => s.dir), branch };
|
|
451
466
|
}
|
|
452
467
|
// Otherwise, list skills and exit with guidance
|
|
453
468
|
if (jsonMode) {
|
|
@@ -475,7 +490,7 @@ async function discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput,
|
|
|
475
490
|
p.cancel('Cancelled');
|
|
476
491
|
process.exit(EXIT_CODES.SUCCESS);
|
|
477
492
|
}
|
|
478
|
-
return selected;
|
|
493
|
+
return { paths: selected, branch };
|
|
479
494
|
}
|
|
480
495
|
/**
|
|
481
496
|
* SECURITY: Show warning and ask for user confirmation before installing.
|
package/dist/lib/github.d.ts
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
* GitHub API functions for skill discovery and fetching.
|
|
3
3
|
*/
|
|
4
4
|
export declare const SKILL_FILENAME = "SKILL.md";
|
|
5
|
+
/**
|
|
6
|
+
* Result of skill discovery including branch information.
|
|
7
|
+
*/
|
|
8
|
+
export interface SkillDiscoveryResult {
|
|
9
|
+
paths: string[];
|
|
10
|
+
branch: string;
|
|
11
|
+
}
|
|
5
12
|
/**
|
|
6
13
|
* Thrown when GitHub API rate limit is exceeded.
|
|
7
14
|
*/
|
|
@@ -29,6 +36,15 @@ export declare class NetworkError extends Error {
|
|
|
29
36
|
export declare class GitHubApiError extends Error {
|
|
30
37
|
constructor(message: string);
|
|
31
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Fetch the default branch name for a repository.
|
|
41
|
+
* Uses the GitHub repos API which returns repository metadata including default_branch.
|
|
42
|
+
*
|
|
43
|
+
* @throws {RepoNotFoundError} When the repository is not found
|
|
44
|
+
* @throws {RateLimitError} When GitHub API rate limit is exceeded
|
|
45
|
+
* @throws {NetworkError} On network errors
|
|
46
|
+
*/
|
|
47
|
+
export declare function fetchDefaultBranch(owner: string, repo: string): Promise<string>;
|
|
32
48
|
/**
|
|
33
49
|
* Fetch with retry and exponential backoff.
|
|
34
50
|
* Retries on network errors and 5xx responses.
|
|
@@ -37,16 +53,16 @@ export declare function fetchWithRetry(url: string, options: RequestInit, maxRet
|
|
|
37
53
|
/**
|
|
38
54
|
* Fetch raw SKILL.md content from GitHub.
|
|
39
55
|
* Uses raw.githubusercontent.com which is not rate-limited like the API.
|
|
40
|
-
* Tries both main and master branches in parallel for better performance.
|
|
41
56
|
*/
|
|
42
|
-
export declare function fetchSkillMdContent(owner: string, repo: string, path: string): Promise<string | null>;
|
|
57
|
+
export declare function fetchSkillMdContent(owner: string, repo: string, path: string, branch: string): Promise<string | null>;
|
|
43
58
|
/**
|
|
44
59
|
* Find all SKILL.md files in a GitHub repository.
|
|
45
|
-
*
|
|
60
|
+
* Fetches the default branch, then searches for skills on that branch.
|
|
46
61
|
*
|
|
62
|
+
* @returns SkillDiscoveryResult with paths and the branch they were found on
|
|
47
63
|
* @throws {RateLimitError} When GitHub API rate limit is exceeded
|
|
48
64
|
* @throws {RepoNotFoundError} When the repository is not found
|
|
49
65
|
* @throws {NetworkError} On network errors (timeout, connection refused)
|
|
50
66
|
* @throws {GitHubApiError} When the API response format is unexpected
|
|
51
67
|
*/
|
|
52
|
-
export declare function findAllSkillMdFiles(owner: string, repo: string): Promise<
|
|
68
|
+
export declare function findAllSkillMdFiles(owner: string, repo: string): Promise<SkillDiscoveryResult>;
|
package/dist/lib/github.js
CHANGED
|
@@ -6,7 +6,6 @@ import { isGitTreeResponse, extractSkillPaths, sleep } from '../utils.js';
|
|
|
6
6
|
const API_TIMEOUT_MS = 10000;
|
|
7
7
|
const MAX_RETRIES = 3;
|
|
8
8
|
const RETRY_DELAYS_MS = [1000, 2000, 4000]; // Exponential backoff
|
|
9
|
-
const DEFAULT_BRANCHES = ['main', 'master'];
|
|
10
9
|
export const SKILL_FILENAME = 'SKILL.md';
|
|
11
10
|
// === Error Types ===
|
|
12
11
|
/**
|
|
@@ -51,7 +50,76 @@ export class GitHubApiError extends Error {
|
|
|
51
50
|
this.name = 'GitHubApiError';
|
|
52
51
|
}
|
|
53
52
|
}
|
|
53
|
+
// === Helper Functions ===
|
|
54
|
+
/**
|
|
55
|
+
* Check if a response indicates rate limiting and throw RateLimitError if so.
|
|
56
|
+
* @throws {RateLimitError} When rate limit is exceeded
|
|
57
|
+
*/
|
|
58
|
+
function checkRateLimit(res) {
|
|
59
|
+
if (res.status === 403) {
|
|
60
|
+
const remaining = res.headers.get('X-RateLimit-Remaining');
|
|
61
|
+
if (remaining === '0') {
|
|
62
|
+
const resetHeader = res.headers.get('X-RateLimit-Reset');
|
|
63
|
+
const resetTime = resetHeader ? new Date(parseInt(resetHeader) * 1000) : undefined;
|
|
64
|
+
throw new RateLimitError(resetTime);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Wrap unknown errors in appropriate typed errors.
|
|
70
|
+
* Re-throws known error types, wraps others in NetworkError.
|
|
71
|
+
* @throws {RateLimitError | RepoNotFoundError | GitHubApiError | NetworkError}
|
|
72
|
+
*/
|
|
73
|
+
function wrapApiError(err) {
|
|
74
|
+
// Re-throw known error types
|
|
75
|
+
if (err instanceof RateLimitError ||
|
|
76
|
+
err instanceof RepoNotFoundError ||
|
|
77
|
+
err instanceof GitHubApiError) {
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
// Handle timeout errors
|
|
81
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
82
|
+
throw new NetworkError('Request timed out. Check your network connection.');
|
|
83
|
+
}
|
|
84
|
+
// Wrap unknown errors as NetworkError
|
|
85
|
+
throw new NetworkError(`Network error: ${err instanceof Error ? err.message : 'unknown error'}`);
|
|
86
|
+
}
|
|
54
87
|
// === Functions ===
|
|
88
|
+
/**
|
|
89
|
+
* Fetch the default branch name for a repository.
|
|
90
|
+
* Uses the GitHub repos API which returns repository metadata including default_branch.
|
|
91
|
+
*
|
|
92
|
+
* @throws {RepoNotFoundError} When the repository is not found
|
|
93
|
+
* @throws {RateLimitError} When GitHub API rate limit is exceeded
|
|
94
|
+
* @throws {NetworkError} On network errors
|
|
95
|
+
*/
|
|
96
|
+
export async function fetchDefaultBranch(owner, repo) {
|
|
97
|
+
const headers = { 'User-Agent': 'skillfish' };
|
|
98
|
+
const url = `https://api.github.com/repos/${owner}/${repo}`;
|
|
99
|
+
const controller = new AbortController();
|
|
100
|
+
const timeoutId = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
|
101
|
+
try {
|
|
102
|
+
const res = await fetchWithRetry(url, { headers, signal: controller.signal });
|
|
103
|
+
checkRateLimit(res);
|
|
104
|
+
if (res.status === 404) {
|
|
105
|
+
throw new RepoNotFoundError(owner, repo);
|
|
106
|
+
}
|
|
107
|
+
if (!res.ok) {
|
|
108
|
+
throw new GitHubApiError(`GitHub API returned status ${res.status}`);
|
|
109
|
+
}
|
|
110
|
+
const data = await res.json();
|
|
111
|
+
if (typeof data.default_branch !== 'string' || !data.default_branch) {
|
|
112
|
+
throw new GitHubApiError('Repository metadata missing or invalid default_branch field');
|
|
113
|
+
}
|
|
114
|
+
return data.default_branch;
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
wrapApiError(err);
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
clearTimeout(timeoutId);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
55
123
|
/**
|
|
56
124
|
* Fetch with retry and exponential backoff.
|
|
57
125
|
* Retries on network errors and 5xx responses.
|
|
@@ -89,30 +157,25 @@ export async function fetchWithRetry(url, options, maxRetries = MAX_RETRIES) {
|
|
|
89
157
|
/**
|
|
90
158
|
* Fetch raw SKILL.md content from GitHub.
|
|
91
159
|
* Uses raw.githubusercontent.com which is not rate-limited like the API.
|
|
92
|
-
* Tries both main and master branches in parallel for better performance.
|
|
93
160
|
*/
|
|
94
|
-
export async function fetchSkillMdContent(owner, repo, path) {
|
|
161
|
+
export async function fetchSkillMdContent(owner, repo, path, branch) {
|
|
95
162
|
const headers = { 'User-Agent': 'skillfish' };
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const url = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`;
|
|
163
|
+
const url = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`;
|
|
164
|
+
try {
|
|
99
165
|
const res = await fetchWithRetry(url, { headers }, 2);
|
|
100
166
|
if (!res.ok)
|
|
101
|
-
|
|
167
|
+
return null;
|
|
102
168
|
return res.text();
|
|
103
|
-
}));
|
|
104
|
-
// Return first successful result
|
|
105
|
-
for (const result of results) {
|
|
106
|
-
if (result.status === 'fulfilled') {
|
|
107
|
-
return result.value;
|
|
108
|
-
}
|
|
109
169
|
}
|
|
110
|
-
|
|
170
|
+
catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
111
173
|
}
|
|
112
174
|
/**
|
|
113
175
|
* Find all SKILL.md files in a GitHub repository.
|
|
114
|
-
*
|
|
176
|
+
* Fetches the default branch, then searches for skills on that branch.
|
|
115
177
|
*
|
|
178
|
+
* @returns SkillDiscoveryResult with paths and the branch they were found on
|
|
116
179
|
* @throws {RateLimitError} When GitHub API rate limit is exceeded
|
|
117
180
|
* @throws {RepoNotFoundError} When the repository is not found
|
|
118
181
|
* @throws {NetworkError} On network errors (timeout, connection refused)
|
|
@@ -120,64 +183,26 @@ export async function fetchSkillMdContent(owner, repo, path) {
|
|
|
120
183
|
*/
|
|
121
184
|
export async function findAllSkillMdFiles(owner, repo) {
|
|
122
185
|
const headers = { 'User-Agent': 'skillfish' };
|
|
186
|
+
// Get the default branch
|
|
187
|
+
const branch = await fetchDefaultBranch(owner, repo);
|
|
123
188
|
const controller = new AbortController();
|
|
124
189
|
const timeoutId = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
|
125
190
|
try {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const resetHeader = res.headers.get('X-RateLimit-Reset');
|
|
136
|
-
const resetTime = resetHeader ? new Date(parseInt(resetHeader) * 1000) : undefined;
|
|
137
|
-
throw new RateLimitError(resetTime);
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
// 404 means branch doesn't exist, try next
|
|
141
|
-
if (res.status === 404) {
|
|
142
|
-
continue;
|
|
143
|
-
}
|
|
144
|
-
if (!res.ok) {
|
|
145
|
-
continue;
|
|
146
|
-
}
|
|
147
|
-
const rawData = await res.json();
|
|
148
|
-
if (!isGitTreeResponse(rawData)) {
|
|
149
|
-
throw new GitHubApiError('Unexpected response format from GitHub API.');
|
|
150
|
-
}
|
|
151
|
-
return extractSkillPaths(rawData, SKILL_FILENAME);
|
|
152
|
-
}
|
|
153
|
-
catch (err) {
|
|
154
|
-
// Re-throw typed errors
|
|
155
|
-
if (err instanceof RateLimitError ||
|
|
156
|
-
err instanceof GitHubApiError) {
|
|
157
|
-
throw err;
|
|
158
|
-
}
|
|
159
|
-
// If this is the last branch, let the error propagate
|
|
160
|
-
if (branch === DEFAULT_BRANCHES[DEFAULT_BRANCHES.length - 1]) {
|
|
161
|
-
throw err;
|
|
162
|
-
}
|
|
163
|
-
// Otherwise try next branch
|
|
164
|
-
continue;
|
|
165
|
-
}
|
|
191
|
+
const url = `https://api.github.com/repos/${owner}/${repo}/git/trees/${branch}?recursive=1`;
|
|
192
|
+
const res = await fetchWithRetry(url, { headers, signal: controller.signal });
|
|
193
|
+
checkRateLimit(res);
|
|
194
|
+
if (!res.ok) {
|
|
195
|
+
throw new GitHubApiError(`GitHub API returned status ${res.status}`);
|
|
196
|
+
}
|
|
197
|
+
const rawData = await res.json();
|
|
198
|
+
if (!isGitTreeResponse(rawData)) {
|
|
199
|
+
throw new GitHubApiError('Unexpected response format from GitHub API.');
|
|
166
200
|
}
|
|
167
|
-
|
|
168
|
-
|
|
201
|
+
const paths = extractSkillPaths(rawData, SKILL_FILENAME);
|
|
202
|
+
return { paths, branch };
|
|
169
203
|
}
|
|
170
204
|
catch (err) {
|
|
171
|
-
|
|
172
|
-
if (err instanceof RateLimitError ||
|
|
173
|
-
err instanceof RepoNotFoundError ||
|
|
174
|
-
err instanceof GitHubApiError) {
|
|
175
|
-
throw err;
|
|
176
|
-
}
|
|
177
|
-
if (err instanceof Error && err.name === 'AbortError') {
|
|
178
|
-
throw new NetworkError('Request timed out. Check your network connection.');
|
|
179
|
-
}
|
|
180
|
-
throw new NetworkError(`Network error: ${err instanceof Error ? err.message : 'unknown error'}`);
|
|
205
|
+
wrapApiError(err);
|
|
181
206
|
}
|
|
182
207
|
finally {
|
|
183
208
|
clearTimeout(timeoutId);
|
package/dist/lib/installer.d.ts
CHANGED
|
@@ -21,6 +21,8 @@ export interface InstallResult {
|
|
|
21
21
|
export interface InstallOptions {
|
|
22
22
|
force: boolean;
|
|
23
23
|
baseDir: string;
|
|
24
|
+
/** Branch to clone from. If not specified, degit will use default branch detection. */
|
|
25
|
+
branch?: string;
|
|
24
26
|
}
|
|
25
27
|
export interface CopyResult {
|
|
26
28
|
warnings: string[];
|
package/dist/lib/installer.js
CHANGED
|
@@ -8,6 +8,18 @@ import { join } from 'path';
|
|
|
8
8
|
import { randomUUID } from 'crypto';
|
|
9
9
|
import degit from 'degit';
|
|
10
10
|
import { SKILL_FILENAME } from './github.js';
|
|
11
|
+
/**
|
|
12
|
+
* Validates a branch name for safe use in degit paths.
|
|
13
|
+
* Git branch names can contain alphanumerics, dots, hyphens, underscores, and slashes.
|
|
14
|
+
* We explicitly reject '#' which is used as a delimiter in degit syntax.
|
|
15
|
+
*/
|
|
16
|
+
function isValidBranchName(branch) {
|
|
17
|
+
if (!branch || branch.length > 255)
|
|
18
|
+
return false;
|
|
19
|
+
// Allow alphanumerics, dots, hyphens, underscores, and slashes (for feature branches)
|
|
20
|
+
// Reject anything else, especially '#' which would break degit parsing
|
|
21
|
+
return /^[\w./-]+$/.test(branch) && !branch.includes('#');
|
|
22
|
+
}
|
|
11
23
|
// === Error Types ===
|
|
12
24
|
/**
|
|
13
25
|
* Thrown when SKILL.md is not found in downloaded content.
|
|
@@ -86,13 +98,22 @@ export async function installSkill(owner, repo, skillPath, skillName, agents, op
|
|
|
86
98
|
warnings: [],
|
|
87
99
|
failed: false,
|
|
88
100
|
};
|
|
89
|
-
const { force, baseDir } = options;
|
|
101
|
+
const { force, baseDir, branch } = options;
|
|
90
102
|
const tmpDir = join(homedir(), '.cache', 'skillfish', `${owner}-${repo}-${randomUUID()}`);
|
|
91
103
|
mkdirSync(tmpDir, { recursive: true, mode: 0o700 });
|
|
92
104
|
try {
|
|
93
105
|
// Download skill
|
|
106
|
+
// Build degit path: owner/repo[/subpath][#branch]
|
|
94
107
|
const downloadPath = skillPath === SKILL_FILENAME ? '' : skillPath;
|
|
95
|
-
|
|
108
|
+
let degitPath = downloadPath ? `${owner}/${repo}/${downloadPath}` : `${owner}/${repo}`;
|
|
109
|
+
// Append branch if specified (critical for repos with non-standard default branches like 'canary')
|
|
110
|
+
// Validate branch name to prevent injection attacks via malformed branch names
|
|
111
|
+
if (branch) {
|
|
112
|
+
if (!isValidBranchName(branch)) {
|
|
113
|
+
throw new Error(`Invalid branch name: ${branch}`);
|
|
114
|
+
}
|
|
115
|
+
degitPath = `${degitPath}#${branch}`;
|
|
116
|
+
}
|
|
96
117
|
const emitter = degit(degitPath, { cache: false, force: true });
|
|
97
118
|
await emitter.clone(tmpDir);
|
|
98
119
|
// Validate download
|
|
@@ -132,7 +153,17 @@ export async function installSkill(owner, repo, skillPath, skillName, agents, op
|
|
|
132
153
|
result.failureReason = err.message;
|
|
133
154
|
}
|
|
134
155
|
else {
|
|
135
|
-
|
|
156
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
157
|
+
// Provide more helpful error messages for common degit failures
|
|
158
|
+
if (errMsg.includes('could not find commit hash for HEAD')) {
|
|
159
|
+
result.failureReason = `Could not clone repository. The branch or path may not exist, or there may be a network issue. Tried: ${owner}/${repo}${skillPath !== SKILL_FILENAME ? `/${skillPath}` : ''}`;
|
|
160
|
+
}
|
|
161
|
+
else if (errMsg.includes('404')) {
|
|
162
|
+
result.failureReason = `Repository or path not found: ${owner}/${repo}${skillPath !== SKILL_FILENAME ? `/${skillPath}` : ''}`;
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
result.failureReason = errMsg;
|
|
166
|
+
}
|
|
136
167
|
}
|
|
137
168
|
}
|
|
138
169
|
finally {
|