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.
@@ -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 both owner/repo and owner/repo/plugin/skill
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 === 2) {
92
- [owner, repo] = parts;
93
- }
94
- else if (parts.length === 4) {
95
- const [o, r, plugin, skill] = parts;
96
- owner = o;
97
- repo = r;
98
- // Security: validate plugin and skill names
99
- if (!isValidName(plugin) || !isValidName(skill)) {
100
- exitWithError('Invalid plugin or skill name. Use only alphanumeric characters, dots, hyphens, and underscores.', EXIT_CODES.INVALID_ARGS);
101
- }
102
- explicitPath = explicitPath || `plugins/${plugin}/skills/${skill}`;
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: ${plugin}/${skill}`);
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 skillPaths = explicitPath
116
- ? [explicitPath]
117
- : await discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput);
118
- if (!skillPaths || skillPaths.length === 0) {
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
- // Construct github value to match skills.github column format: owner/repo/path/to/skill
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 skillPaths;
328
+ async function discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput, targetSkillName) {
329
+ let skillDiscovery;
330
330
  try {
331
- skillPaths = await findAllSkillMdFiles(owner, repo);
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. Use --path or --all to specify which one(s).');
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. Use --path or --all to specify which one(s) (non-interactive mode).');
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.
@@ -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
- * Uses sequential branch checking to conserve API rate limit (60/hr unauthenticated).
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<string[]>;
68
+ export declare function findAllSkillMdFiles(owner: string, repo: string): Promise<SkillDiscoveryResult>;
@@ -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
- // Try both branches in parallel
97
- const results = await Promise.allSettled(DEFAULT_BRANCHES.map(async (branch) => {
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
- throw new Error(`HTTP ${res.status}`);
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
- return null;
170
+ catch {
171
+ return null;
172
+ }
111
173
  }
112
174
  /**
113
175
  * Find all SKILL.md files in a GitHub repository.
114
- * Uses sequential branch checking to conserve API rate limit (60/hr unauthenticated).
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
- // Try each branch sequentially to conserve rate limit
127
- for (const branch of DEFAULT_BRANCHES) {
128
- const url = `https://api.github.com/repos/${owner}/${repo}/git/trees/${branch}?recursive=1`;
129
- try {
130
- const res = await fetchWithRetry(url, { headers, signal: controller.signal });
131
- // Check for rate limiting
132
- if (res.status === 403) {
133
- const remaining = res.headers.get('X-RateLimit-Remaining');
134
- if (remaining === '0') {
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
- // No branch found
168
- throw new RepoNotFoundError(owner, repo);
201
+ const paths = extractSkillPaths(rawData, SKILL_FILENAME);
202
+ return { paths, branch };
169
203
  }
170
204
  catch (err) {
171
- // Re-throw typed errors
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);
@@ -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[];
@@ -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
- const degitPath = downloadPath ? `${owner}/${repo}/${downloadPath}` : `${owner}/${repo}`;
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
- result.failureReason = err instanceof Error ? err.message : String(err);
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 {
@@ -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 github Full GitHub path (e.g., owner/repo/path/to/skill)
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(github: string): Promise<void>;
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 = 2000;
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 github Full GitHub path (e.g., owner/repo/path/to/skill)
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(github) {
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 (!github || github.length > 500) {
18
+ if (!owner || !repo || !skillName) {
17
19
  return Promise.resolve();
18
20
  }
19
- // Race between the fetch and a timeout to ensure we don't block the CLI
20
- const fetchPromise = fetch(TELEMETRY_URL, {
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: JSON.stringify({ github }),
24
- }).then(() => { }).catch(() => { });
25
- const timeoutPromise = new Promise((resolve) => {
26
- setTimeout(resolve, TELEMETRY_TIMEOUT);
27
- });
28
- return Promise.race([fetchPromise, timeoutPromise]);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillfish",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "Install AI agent skills from GitHub with a single command",
5
5
  "type": "module",
6
6
  "bin": {