skillfish 1.0.5 → 1.0.7
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 +42 -15
- 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 +36 -3
- package/package.json +1 -1
package/dist/commands/add.js
CHANGED
|
@@ -9,7 +9,7 @@ import pc from 'picocolors';
|
|
|
9
9
|
import { trackInstall } from '../telemetry.js';
|
|
10
10
|
import { isValidPath, parseFrontmatter, deriveSkillName, toTitleCase, truncate, batchMap, createJsonOutput, isInputTTY, isTTY, } from '../utils.js';
|
|
11
11
|
import { getDetectedAgents, AGENT_CONFIGS } from '../lib/agents.js';
|
|
12
|
-
import { findAllSkillMdFiles, fetchSkillMdContent, SKILL_FILENAME, RateLimitError, RepoNotFoundError, NetworkError, GitHubApiError, } from '../lib/github.js';
|
|
12
|
+
import { findAllSkillMdFiles, fetchSkillMdContent, fetchDefaultBranch, SKILL_FILENAME, RateLimitError, RepoNotFoundError, NetworkError, GitHubApiError, } from '../lib/github.js';
|
|
13
13
|
import { installSkill } from '../lib/installer.js';
|
|
14
14
|
import { EXIT_CODES, isValidName } from '../lib/constants.js';
|
|
15
15
|
// === Command Definition ===
|
|
@@ -113,15 +113,28 @@ Examples:
|
|
|
113
113
|
exitWithError('Invalid repository format. Use: owner/repo', EXIT_CODES.INVALID_ARGS);
|
|
114
114
|
}
|
|
115
115
|
// 1. Discover or select skills
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
116
|
+
let discoveryResult;
|
|
117
|
+
if (explicitPath) {
|
|
118
|
+
// For explicit paths, we still need to fetch the default branch for degit
|
|
119
|
+
try {
|
|
120
|
+
const branch = await fetchDefaultBranch(owner, repo);
|
|
121
|
+
discoveryResult = { paths: [explicitPath], branch };
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
// If we can't fetch the branch, let degit try its own detection
|
|
125
|
+
discoveryResult = { paths: [explicitPath], branch: undefined };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
discoveryResult = await discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput, skillNameArg);
|
|
130
|
+
}
|
|
131
|
+
if (!discoveryResult || discoveryResult.paths.length === 0) {
|
|
120
132
|
if (jsonMode) {
|
|
121
133
|
outputJsonAndExit(EXIT_CODES.NOT_FOUND);
|
|
122
134
|
}
|
|
123
135
|
process.exit(EXIT_CODES.NOT_FOUND);
|
|
124
136
|
}
|
|
137
|
+
const { paths: skillPaths, branch: discoveredBranch } = discoveryResult;
|
|
125
138
|
// 2. Determine install location (global vs project)
|
|
126
139
|
const baseDir = await selectInstallLocation(projectFlag, globalFlag, jsonMode);
|
|
127
140
|
// 3. Select agents to install to
|
|
@@ -179,6 +192,7 @@ Examples:
|
|
|
179
192
|
const result = await installSkill(owner, repo, skillPath, skillName, targetAgents, {
|
|
180
193
|
force,
|
|
181
194
|
baseDir,
|
|
195
|
+
branch: discoveredBranch,
|
|
182
196
|
});
|
|
183
197
|
if (spinner) {
|
|
184
198
|
if (result.failed) {
|
|
@@ -324,9 +338,9 @@ async function selectAgents(agents, isLocal, jsonMode) {
|
|
|
324
338
|
return agents.filter((a) => selected.includes(a.name));
|
|
325
339
|
}
|
|
326
340
|
async function discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput, targetSkillName) {
|
|
327
|
-
let
|
|
341
|
+
let skillDiscovery;
|
|
328
342
|
try {
|
|
329
|
-
|
|
343
|
+
skillDiscovery = await findAllSkillMdFiles(owner, repo);
|
|
330
344
|
}
|
|
331
345
|
catch (err) {
|
|
332
346
|
let errorMsg;
|
|
@@ -359,6 +373,7 @@ async function discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput,
|
|
|
359
373
|
}
|
|
360
374
|
process.exit(exitCode);
|
|
361
375
|
}
|
|
376
|
+
const { paths: skillPaths, branch } = skillDiscovery;
|
|
362
377
|
if (skillPaths.length === 0) {
|
|
363
378
|
const errorMsg = `No ${SKILL_FILENAME} found in repository`;
|
|
364
379
|
if (jsonMode) {
|
|
@@ -380,8 +395,8 @@ async function discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput,
|
|
|
380
395
|
const skills = await batchMap(skillPaths, async (sp) => {
|
|
381
396
|
const skillDir = sp === SKILL_FILENAME ? '.' : dirname(sp);
|
|
382
397
|
const folderName = sp === SKILL_FILENAME ? repo : basename(skillDir);
|
|
383
|
-
// Fetch raw content to parse frontmatter
|
|
384
|
-
const content = await fetchSkillMdContent(owner, repo, sp);
|
|
398
|
+
// Fetch raw content to parse frontmatter (use discovered branch)
|
|
399
|
+
const content = await fetchSkillMdContent(owner, repo, sp, branch);
|
|
385
400
|
const frontmatter = content ? parseFrontmatter(content) : {};
|
|
386
401
|
return {
|
|
387
402
|
path: sp,
|
|
@@ -397,15 +412,27 @@ async function discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput,
|
|
|
397
412
|
jsonOutput.skills_found = skills.map((s) => s.name);
|
|
398
413
|
// If a specific skill name was requested, find and return it
|
|
399
414
|
if (targetSkillName) {
|
|
400
|
-
|
|
401
|
-
const
|
|
415
|
+
// Normalize for flexible matching: lowercase, replace spaces/underscores with hyphens
|
|
416
|
+
const normalize = (s) => s.toLowerCase().replace(/[\s_]+/g, '-');
|
|
417
|
+
const normalizedTarget = normalize(targetSkillName);
|
|
418
|
+
// Try multiple matching strategies
|
|
419
|
+
const matchedSkill = skills.find((s) => {
|
|
420
|
+
const normalizedName = normalize(s.name);
|
|
421
|
+
const normalizedDir = normalize(s.dir);
|
|
422
|
+
const dirBasename = normalize(s.dir.split('/').pop() || '');
|
|
423
|
+
return (normalizedName === normalizedTarget || // Exact name match (normalized)
|
|
424
|
+
normalizedDir === normalizedTarget || // Exact dir match
|
|
425
|
+
dirBasename === normalizedTarget || // Directory basename match
|
|
426
|
+
s.name.toLowerCase() === targetSkillName.toLowerCase() // Original case-insensitive
|
|
427
|
+
);
|
|
428
|
+
});
|
|
402
429
|
if (matchedSkill) {
|
|
403
430
|
const displayName = toTitleCase(matchedSkill.name);
|
|
404
431
|
const desc = matchedSkill.description ? truncate(matchedSkill.description, 60) : '';
|
|
405
432
|
if (!jsonMode) {
|
|
406
433
|
p.log.info(`${pc.bold(displayName)}${desc ? pc.dim(` - ${desc}`) : ''}`);
|
|
407
434
|
}
|
|
408
|
-
return [matchedSkill.dir];
|
|
435
|
+
return { paths: [matchedSkill.dir], branch };
|
|
409
436
|
}
|
|
410
437
|
// Skill not found - show available skills
|
|
411
438
|
const errorMsg = `Skill "${targetSkillName}" not found in repository`;
|
|
@@ -431,7 +458,7 @@ async function discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput,
|
|
|
431
458
|
if (!jsonMode) {
|
|
432
459
|
p.log.info(`${pc.bold(displayName)}${desc ? pc.dim(` - ${desc}`) : ''}`);
|
|
433
460
|
}
|
|
434
|
-
return [skill.dir];
|
|
461
|
+
return { paths: [skill.dir], branch };
|
|
435
462
|
}
|
|
436
463
|
// Build options for selection with frontmatter metadata
|
|
437
464
|
// Title in label, description in hint (shows on focus)
|
|
@@ -447,7 +474,7 @@ async function discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput,
|
|
|
447
474
|
if (!jsonMode) {
|
|
448
475
|
console.log(`Installing all ${skills.length} skills`);
|
|
449
476
|
}
|
|
450
|
-
return skills.map((s) => s.dir);
|
|
477
|
+
return { paths: skills.map((s) => s.dir), branch };
|
|
451
478
|
}
|
|
452
479
|
// Otherwise, list skills and exit with guidance
|
|
453
480
|
if (jsonMode) {
|
|
@@ -475,7 +502,7 @@ async function discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput,
|
|
|
475
502
|
p.cancel('Cancelled');
|
|
476
503
|
process.exit(EXIT_CODES.SUCCESS);
|
|
477
504
|
}
|
|
478
|
-
return selected;
|
|
505
|
+
return { paths: selected, branch };
|
|
479
506
|
}
|
|
480
507
|
/**
|
|
481
508
|
* 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,19 @@ 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
|
+
// Match "could not find commit hash for HEAD" or "could not find commit hash for <branch>"
|
|
159
|
+
if (errMsg.includes('could not find commit hash')) {
|
|
160
|
+
const branchInfo = branch ? ` (branch: ${branch})` : '';
|
|
161
|
+
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}` : ''}${branchInfo}`;
|
|
162
|
+
}
|
|
163
|
+
else if (errMsg.includes('404')) {
|
|
164
|
+
result.failureReason = `Repository or path not found: ${owner}/${repo}${skillPath !== SKILL_FILENAME ? `/${skillPath}` : ''}`;
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
result.failureReason = errMsg;
|
|
168
|
+
}
|
|
136
169
|
}
|
|
137
170
|
}
|
|
138
171
|
finally {
|