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.
@@ -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
- const skillPaths = explicitPath
117
- ? [explicitPath]
118
- : await discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput, skillNameArg);
119
- if (!skillPaths || skillPaths.length === 0) {
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 skillPaths;
341
+ let skillDiscovery;
328
342
  try {
329
- skillPaths = await findAllSkillMdFiles(owner, repo);
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
- const normalizedTarget = targetSkillName.toLowerCase();
401
- const matchedSkill = skills.find((s) => s.name.toLowerCase() === normalizedTarget);
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.
@@ -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,19 @@ 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
+ // 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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillfish",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "Install AI agent skills from GitHub with a single command",
5
5
  "type": "module",
6
6
  "bin": {