skillfish 1.0.3 → 1.0.5

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,29 +86,28 @@ 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);
@@ -114,7 +115,7 @@ Examples:
114
115
  // 1. Discover or select skills
115
116
  const skillPaths = explicitPath
116
117
  ? [explicitPath]
117
- : await discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput);
118
+ : await discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput, skillNameArg);
118
119
  if (!skillPaths || skillPaths.length === 0) {
119
120
  if (jsonMode) {
120
121
  outputJsonAndExit(EXIT_CODES.NOT_FOUND);
@@ -224,10 +225,7 @@ Examples:
224
225
  totalSkipped += result.skipped.length;
225
226
  // Track successful installs (telemetry with timeout)
226
227
  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));
228
+ telemetryPromises.push(trackInstall(owner, repo, skillName));
231
229
  }
232
230
  }
233
231
  // Wait for telemetry to complete (with timeout built into trackInstall)
@@ -325,7 +323,7 @@ async function selectAgents(agents, isLocal, jsonMode) {
325
323
  }
326
324
  return agents.filter((a) => selected.includes(a.name));
327
325
  }
328
- async function discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput) {
326
+ async function discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput, targetSkillName) {
329
327
  let skillPaths;
330
328
  try {
331
329
  skillPaths = await findAllSkillMdFiles(owner, repo);
@@ -397,6 +395,35 @@ async function discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput)
397
395
  }
398
396
  // Store found skills in JSON output
399
397
  jsonOutput.skills_found = skills.map((s) => s.name);
398
+ // If a specific skill name was requested, find and return it
399
+ if (targetSkillName) {
400
+ const normalizedTarget = targetSkillName.toLowerCase();
401
+ const matchedSkill = skills.find((s) => s.name.toLowerCase() === normalizedTarget);
402
+ if (matchedSkill) {
403
+ const displayName = toTitleCase(matchedSkill.name);
404
+ const desc = matchedSkill.description ? truncate(matchedSkill.description, 60) : '';
405
+ if (!jsonMode) {
406
+ p.log.info(`${pc.bold(displayName)}${desc ? pc.dim(` - ${desc}`) : ''}`);
407
+ }
408
+ return [matchedSkill.dir];
409
+ }
410
+ // Skill not found - show available skills
411
+ const errorMsg = `Skill "${targetSkillName}" not found in repository`;
412
+ if (jsonMode) {
413
+ jsonOutput.errors.push(errorMsg);
414
+ jsonOutput.success = false;
415
+ }
416
+ else {
417
+ p.log.error(errorMsg);
418
+ console.log(`\nAvailable skills in ${owner}/${repo}:`);
419
+ for (const skill of skills) {
420
+ const displayName = toTitleCase(skill.name);
421
+ const desc = skill.description ? pc.dim(` - ${truncate(skill.description, 80)}`) : '';
422
+ console.log(` - ${pc.cyan(skill.name)} ${displayName}${desc}`);
423
+ }
424
+ }
425
+ return null;
426
+ }
400
427
  if (skills.length === 1) {
401
428
  const skill = skills[0];
402
429
  const displayName = toTitleCase(skill.name);
@@ -424,7 +451,7 @@ async function discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput)
424
451
  }
425
452
  // Otherwise, list skills and exit with guidance
426
453
  if (jsonMode) {
427
- jsonOutput.errors.push('Multiple skills found. Use --path or --all to specify which one(s).');
454
+ jsonOutput.errors.push('Multiple skills found. Specify skill name, use --path, or --all.');
428
455
  jsonOutput.success = false;
429
456
  }
430
457
  else {
@@ -432,9 +459,9 @@ async function discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput)
432
459
  for (const skill of skills) {
433
460
  const displayName = toTitleCase(skill.name);
434
461
  const desc = skill.description ? pc.dim(` - ${truncate(skill.description, 80)}`) : '';
435
- console.log(` - ${displayName}${desc}`);
462
+ console.log(` - ${pc.cyan(skill.name)} ${displayName}${desc}`);
436
463
  }
437
- console.error('\nMultiple skills found. Use --path or --all to specify which one(s) (non-interactive mode).');
464
+ console.error('\nMultiple skills found. Specify skill name, use --path, or --all (non-interactive mode).');
438
465
  }
439
466
  return null;
440
467
  }
@@ -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,10 +1,10 @@
1
1
  {
2
2
  "name": "skillfish",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "Install AI agent skills from GitHub with a single command",
5
5
  "type": "module",
6
6
  "bin": {
7
- "skillfish": "./dist/index.js"
7
+ "skillfish": "dist/index.js"
8
8
  },
9
9
  "files": [
10
10
  "dist",