skillfish 1.0.4 → 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 +80 -38
- 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/dist/telemetry.d.ts +4 -2
- package/dist/telemetry.js +18 -13
- package/package.json +1 -1
package/dist/commands/add.js
CHANGED
|
@@ -16,6 +16,7 @@ import { EXIT_CODES, isValidName } from '../lib/constants.js';
|
|
|
16
16
|
export const addCommand = new Command('add')
|
|
17
17
|
.description('Install a skill from a GitHub repository')
|
|
18
18
|
.argument('<repo>', 'GitHub repository (owner/repo or owner/repo/plugin/skill)')
|
|
19
|
+
.argument('[skill-name]', 'Install a specific skill by name (from SKILL.md frontmatter)')
|
|
19
20
|
.option('--force', 'Overwrite existing skills without prompting')
|
|
20
21
|
.option('-y, --yes', 'Skip all confirmation prompts')
|
|
21
22
|
.option('--all', 'Install all skills found in the repository')
|
|
@@ -26,11 +27,12 @@ export const addCommand = new Command('add')
|
|
|
26
27
|
.addHelpText('after', `
|
|
27
28
|
Examples:
|
|
28
29
|
$ skillfish add owner/repo Install from a repository
|
|
30
|
+
$ skillfish add owner/repo my-skill Install skill by name
|
|
29
31
|
$ skillfish add owner/repo --all Install all skills in repo
|
|
30
|
-
$ skillfish add owner/repo/plugin/skill Install a specific skill
|
|
32
|
+
$ skillfish add owner/repo/plugin/skill Install a specific skill by path
|
|
31
33
|
$ skillfish add owner/repo --path path/to Install skill at specific path
|
|
32
34
|
$ skillfish add owner/repo --project Install to current project only`)
|
|
33
|
-
.action(async (repoArg, options, command) => {
|
|
35
|
+
.action(async (repoArg, skillNameArg, options, command) => {
|
|
34
36
|
const jsonMode = command.parent?.opts().json ?? false;
|
|
35
37
|
const jsonOutput = createJsonOutput();
|
|
36
38
|
const version = command.parent?.opts().version ?? '0.0.0';
|
|
@@ -84,43 +86,43 @@ Examples:
|
|
|
84
86
|
exitWithError('Invalid --path value. Path must be relative and contain only safe characters.', EXIT_CODES.INVALID_ARGS);
|
|
85
87
|
}
|
|
86
88
|
}
|
|
87
|
-
// Parse repo format - supports
|
|
89
|
+
// Parse repo format - supports owner/repo and owner/repo/path/to/skill
|
|
88
90
|
const parts = repoArg.split('/');
|
|
89
91
|
let owner;
|
|
90
92
|
let repo;
|
|
91
|
-
if (parts.length
|
|
92
|
-
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
// Security: validate
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
93
|
+
if (parts.length < 2) {
|
|
94
|
+
exitWithError('Invalid format. Use: owner/repo or owner/repo/path/to/skill', EXIT_CODES.INVALID_ARGS);
|
|
95
|
+
}
|
|
96
|
+
[owner, repo] = parts;
|
|
97
|
+
// If path components exist after owner/repo, use them as the skill path
|
|
98
|
+
if (parts.length > 2) {
|
|
99
|
+
const pathParts = parts.slice(2);
|
|
100
|
+
// Security: validate each path component
|
|
101
|
+
for (const part of pathParts) {
|
|
102
|
+
if (!isValidName(part)) {
|
|
103
|
+
exitWithError('Invalid path component. Use only alphanumeric characters, dots, hyphens, and underscores.', EXIT_CODES.INVALID_ARGS);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
explicitPath = explicitPath || pathParts.join('/');
|
|
103
107
|
if (!jsonMode) {
|
|
104
|
-
console.log(`Installing skill from: ${
|
|
108
|
+
console.log(`Installing skill from: ${explicitPath}`);
|
|
105
109
|
}
|
|
106
110
|
}
|
|
107
|
-
else {
|
|
108
|
-
exitWithError('Invalid format. Use: owner/repo or owner/repo/plugin/skill', EXIT_CODES.INVALID_ARGS);
|
|
109
|
-
}
|
|
110
111
|
// Validate owner/repo (security: prevent injection)
|
|
111
112
|
if (!owner || !repo || !isValidName(owner) || !isValidName(repo)) {
|
|
112
113
|
exitWithError('Invalid repository format. Use: owner/repo', EXIT_CODES.INVALID_ARGS);
|
|
113
114
|
}
|
|
114
115
|
// 1. Discover or select skills
|
|
115
|
-
const
|
|
116
|
-
? [explicitPath]
|
|
117
|
-
: await discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput);
|
|
118
|
-
if (!
|
|
116
|
+
const discoveryResult = explicitPath
|
|
117
|
+
? { paths: [explicitPath], branch: undefined }
|
|
118
|
+
: await discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput, skillNameArg);
|
|
119
|
+
if (!discoveryResult || discoveryResult.paths.length === 0) {
|
|
119
120
|
if (jsonMode) {
|
|
120
121
|
outputJsonAndExit(EXIT_CODES.NOT_FOUND);
|
|
121
122
|
}
|
|
122
123
|
process.exit(EXIT_CODES.NOT_FOUND);
|
|
123
124
|
}
|
|
125
|
+
const { paths: skillPaths, branch: discoveredBranch } = discoveryResult;
|
|
124
126
|
// 2. Determine install location (global vs project)
|
|
125
127
|
const baseDir = await selectInstallLocation(projectFlag, globalFlag, jsonMode);
|
|
126
128
|
// 3. Select agents to install to
|
|
@@ -178,6 +180,7 @@ Examples:
|
|
|
178
180
|
const result = await installSkill(owner, repo, skillPath, skillName, targetAgents, {
|
|
179
181
|
force,
|
|
180
182
|
baseDir,
|
|
183
|
+
branch: discoveredBranch,
|
|
181
184
|
});
|
|
182
185
|
if (spinner) {
|
|
183
186
|
if (result.failed) {
|
|
@@ -224,10 +227,7 @@ Examples:
|
|
|
224
227
|
totalSkipped += result.skipped.length;
|
|
225
228
|
// Track successful installs (telemetry with timeout)
|
|
226
229
|
if (result.installed.length > 0) {
|
|
227
|
-
|
|
228
|
-
const skillDir = skillPath.replace(/\/?SKILL\.md$/i, '').replace(/^\.?\/?/, '');
|
|
229
|
-
const github = skillDir ? `${owner}/${repo}/${skillDir}` : `${owner}/${repo}`;
|
|
230
|
-
telemetryPromises.push(trackInstall(github));
|
|
230
|
+
telemetryPromises.push(trackInstall(owner, repo, skillName));
|
|
231
231
|
}
|
|
232
232
|
}
|
|
233
233
|
// Wait for telemetry to complete (with timeout built into trackInstall)
|
|
@@ -325,10 +325,10 @@ async function selectAgents(agents, isLocal, jsonMode) {
|
|
|
325
325
|
}
|
|
326
326
|
return agents.filter((a) => selected.includes(a.name));
|
|
327
327
|
}
|
|
328
|
-
async function discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput) {
|
|
329
|
-
let
|
|
328
|
+
async function discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput, targetSkillName) {
|
|
329
|
+
let skillDiscovery;
|
|
330
330
|
try {
|
|
331
|
-
|
|
331
|
+
skillDiscovery = await findAllSkillMdFiles(owner, repo);
|
|
332
332
|
}
|
|
333
333
|
catch (err) {
|
|
334
334
|
let errorMsg;
|
|
@@ -361,6 +361,7 @@ async function discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput)
|
|
|
361
361
|
}
|
|
362
362
|
process.exit(exitCode);
|
|
363
363
|
}
|
|
364
|
+
const { paths: skillPaths, branch } = skillDiscovery;
|
|
364
365
|
if (skillPaths.length === 0) {
|
|
365
366
|
const errorMsg = `No ${SKILL_FILENAME} found in repository`;
|
|
366
367
|
if (jsonMode) {
|
|
@@ -382,8 +383,8 @@ async function discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput)
|
|
|
382
383
|
const skills = await batchMap(skillPaths, async (sp) => {
|
|
383
384
|
const skillDir = sp === SKILL_FILENAME ? '.' : dirname(sp);
|
|
384
385
|
const folderName = sp === SKILL_FILENAME ? repo : basename(skillDir);
|
|
385
|
-
// Fetch raw content to parse frontmatter
|
|
386
|
-
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);
|
|
387
388
|
const frontmatter = content ? parseFrontmatter(content) : {};
|
|
388
389
|
return {
|
|
389
390
|
path: sp,
|
|
@@ -397,6 +398,47 @@ async function discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput)
|
|
|
397
398
|
}
|
|
398
399
|
// Store found skills in JSON output
|
|
399
400
|
jsonOutput.skills_found = skills.map((s) => s.name);
|
|
401
|
+
// If a specific skill name was requested, find and return it
|
|
402
|
+
if (targetSkillName) {
|
|
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
|
+
});
|
|
417
|
+
if (matchedSkill) {
|
|
418
|
+
const displayName = toTitleCase(matchedSkill.name);
|
|
419
|
+
const desc = matchedSkill.description ? truncate(matchedSkill.description, 60) : '';
|
|
420
|
+
if (!jsonMode) {
|
|
421
|
+
p.log.info(`${pc.bold(displayName)}${desc ? pc.dim(` - ${desc}`) : ''}`);
|
|
422
|
+
}
|
|
423
|
+
return { paths: [matchedSkill.dir], branch };
|
|
424
|
+
}
|
|
425
|
+
// Skill not found - show available skills
|
|
426
|
+
const errorMsg = `Skill "${targetSkillName}" not found in repository`;
|
|
427
|
+
if (jsonMode) {
|
|
428
|
+
jsonOutput.errors.push(errorMsg);
|
|
429
|
+
jsonOutput.success = false;
|
|
430
|
+
}
|
|
431
|
+
else {
|
|
432
|
+
p.log.error(errorMsg);
|
|
433
|
+
console.log(`\nAvailable skills in ${owner}/${repo}:`);
|
|
434
|
+
for (const skill of skills) {
|
|
435
|
+
const displayName = toTitleCase(skill.name);
|
|
436
|
+
const desc = skill.description ? pc.dim(` - ${truncate(skill.description, 80)}`) : '';
|
|
437
|
+
console.log(` - ${pc.cyan(skill.name)} ${displayName}${desc}`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
400
442
|
if (skills.length === 1) {
|
|
401
443
|
const skill = skills[0];
|
|
402
444
|
const displayName = toTitleCase(skill.name);
|
|
@@ -404,7 +446,7 @@ async function discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput)
|
|
|
404
446
|
if (!jsonMode) {
|
|
405
447
|
p.log.info(`${pc.bold(displayName)}${desc ? pc.dim(` - ${desc}`) : ''}`);
|
|
406
448
|
}
|
|
407
|
-
return [skill.dir];
|
|
449
|
+
return { paths: [skill.dir], branch };
|
|
408
450
|
}
|
|
409
451
|
// Build options for selection with frontmatter metadata
|
|
410
452
|
// Title in label, description in hint (shows on focus)
|
|
@@ -420,11 +462,11 @@ async function discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput)
|
|
|
420
462
|
if (!jsonMode) {
|
|
421
463
|
console.log(`Installing all ${skills.length} skills`);
|
|
422
464
|
}
|
|
423
|
-
return skills.map((s) => s.dir);
|
|
465
|
+
return { paths: skills.map((s) => s.dir), branch };
|
|
424
466
|
}
|
|
425
467
|
// Otherwise, list skills and exit with guidance
|
|
426
468
|
if (jsonMode) {
|
|
427
|
-
jsonOutput.errors.push('Multiple skills found.
|
|
469
|
+
jsonOutput.errors.push('Multiple skills found. Specify skill name, use --path, or --all.');
|
|
428
470
|
jsonOutput.success = false;
|
|
429
471
|
}
|
|
430
472
|
else {
|
|
@@ -432,9 +474,9 @@ async function discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput)
|
|
|
432
474
|
for (const skill of skills) {
|
|
433
475
|
const displayName = toTitleCase(skill.name);
|
|
434
476
|
const desc = skill.description ? pc.dim(` - ${truncate(skill.description, 80)}`) : '';
|
|
435
|
-
console.log(` - ${displayName}${desc}`);
|
|
477
|
+
console.log(` - ${pc.cyan(skill.name)} ${displayName}${desc}`);
|
|
436
478
|
}
|
|
437
|
-
console.error('\nMultiple skills found.
|
|
479
|
+
console.error('\nMultiple skills found. Specify skill name, use --path, or --all (non-interactive mode).');
|
|
438
480
|
}
|
|
439
481
|
return null;
|
|
440
482
|
}
|
|
@@ -448,7 +490,7 @@ async function discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput)
|
|
|
448
490
|
p.cancel('Cancelled');
|
|
449
491
|
process.exit(EXIT_CODES.SUCCESS);
|
|
450
492
|
}
|
|
451
|
-
return selected;
|
|
493
|
+
return { paths: selected, branch };
|
|
452
494
|
}
|
|
453
495
|
/**
|
|
454
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 {
|
package/dist/telemetry.d.ts
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
* Track a skill install. Returns a promise that resolves when the request
|
|
3
3
|
* completes (or times out). Never rejects.
|
|
4
4
|
*
|
|
5
|
-
* @param
|
|
5
|
+
* @param owner GitHub repository owner
|
|
6
|
+
* @param repo GitHub repository name
|
|
7
|
+
* @param skillName Name of the skill being installed
|
|
6
8
|
* @returns Promise that resolves when telemetry is sent (or times out)
|
|
7
9
|
*/
|
|
8
|
-
export declare function trackInstall(
|
|
10
|
+
export declare function trackInstall(owner: string, repo: string, skillName: string): Promise<void>;
|
package/dist/telemetry.js
CHANGED
|
@@ -1,34 +1,39 @@
|
|
|
1
1
|
const TELEMETRY_URL = 'https://mcpmarket.com/api/telemetry';
|
|
2
2
|
/** Timeout for telemetry requests (ms) */
|
|
3
|
-
const TELEMETRY_TIMEOUT =
|
|
3
|
+
const TELEMETRY_TIMEOUT = 5000;
|
|
4
4
|
/**
|
|
5
5
|
* Track a skill install. Returns a promise that resolves when the request
|
|
6
6
|
* completes (or times out). Never rejects.
|
|
7
7
|
*
|
|
8
|
-
* @param
|
|
8
|
+
* @param owner GitHub repository owner
|
|
9
|
+
* @param repo GitHub repository name
|
|
10
|
+
* @param skillName Name of the skill being installed
|
|
9
11
|
* @returns Promise that resolves when telemetry is sent (or times out)
|
|
10
12
|
*/
|
|
11
|
-
export function trackInstall(
|
|
13
|
+
export function trackInstall(owner, repo, skillName) {
|
|
12
14
|
try {
|
|
13
15
|
if (process.env.DO_NOT_TRACK === '1' || process.env.CI === 'true') {
|
|
14
16
|
return Promise.resolve();
|
|
15
17
|
}
|
|
16
|
-
if (!
|
|
18
|
+
if (!owner || !repo || !skillName) {
|
|
17
19
|
return Promise.resolve();
|
|
18
20
|
}
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
+
// Use AbortController to properly timeout the request
|
|
22
|
+
// This ensures the fetch actually completes (or aborts) before we return
|
|
23
|
+
const controller = new AbortController();
|
|
24
|
+
const timeoutId = setTimeout(() => controller.abort(), TELEMETRY_TIMEOUT);
|
|
25
|
+
const body = JSON.stringify({ owner, repo, skillName });
|
|
26
|
+
return fetch(TELEMETRY_URL, {
|
|
21
27
|
method: 'POST',
|
|
22
28
|
headers: { 'Content-Type': 'application/json' },
|
|
23
|
-
body
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
body,
|
|
30
|
+
signal: controller.signal,
|
|
31
|
+
})
|
|
32
|
+
.then(() => { })
|
|
33
|
+
.catch(() => { })
|
|
34
|
+
.finally(() => clearTimeout(timeoutId));
|
|
29
35
|
}
|
|
30
36
|
catch {
|
|
31
|
-
// Telemetry should never throw
|
|
32
37
|
return Promise.resolve();
|
|
33
38
|
}
|
|
34
39
|
}
|