skillfish 1.0.8 → 1.0.10

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/README.md CHANGED
@@ -1,4 +1,14 @@
1
- # skillfish
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/knoxgraeme/skillfish/main/assets/logo.png" alt="skillfish" width="600">
3
+ </p>
4
+
5
+ <p align="center">
6
+ <a href="https://npmjs.com/package/skillfish"><img src="https://img.shields.io/npm/v/skillfish" alt="npm"></a>
7
+ <a href="https://npmjs.com/package/skillfish"><img src="https://img.shields.io/npm/dm/skillfish" alt="downloads"></a>
8
+ <a href="LICENSE"><img src="https://img.shields.io/npm/l/skillfish" alt="license"></a>
9
+ <a href="package.json"><img src="https://img.shields.io/node/v/skillfish" alt="node"></a>
10
+ <a href="https://github.com/knoxgraeme/skillfish/actions"><img src="https://github.com/knoxgraeme/skillfish/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
11
+ </p>
2
12
 
3
13
  Install AI agent skills from GitHub with a single command.
4
14
 
@@ -101,9 +111,9 @@ When a repo contains multiple skills, you'll get an interactive multi-select men
101
111
 
102
112
  ```
103
113
  ◆ Select skills to install
104
- │ ◻ Frontend Design - Create distinctive, production-grade frontend interfaces
105
- │ ◻ Agent Browser - Browser automation using Vercel's agent-browser CLI
106
- │ ◻ Git Worktree - Manage Git worktrees for isolated parallel development
114
+ │ ◻ my-skill - A helpful skill for your AI agent
115
+ │ ◻ another-skill - Another useful capability
116
+ │ ◻ third-skill - Yet another skill option
107
117
  │ ...
108
118
 
109
119
  ```
@@ -117,7 +127,7 @@ Use `--all` to install all skills non-interactively (useful for automation).
117
127
  npx skillfish add user/my-skill
118
128
 
119
129
  # Install using full path from GitHub
120
- npx skillfish add EveryInc/compound-engineering-plugin/compound-engineering/frontend-design
130
+ npx skillfish add owner/repo/path/to/skill
121
131
 
122
132
  # Install from a plugin repo with explicit path
123
133
  npx skillfish add org/plugin-repo --path plugins/my-plugin/skills/skill-name
@@ -183,10 +193,22 @@ skillfish add owner/repo --json
183
193
  # Output includes: "exit_code": 0 (or error code)
184
194
  ```
185
195
 
196
+ ## Security
197
+
198
+ **Security Note:** Skills are markdown files that provide instructions to AI agents. Always review skills before installing. skillfish does not vet third-party skills.
199
+
200
+ To report security vulnerabilities, please email security@skill.fish. See [SECURITY.md](SECURITY.md) for details.
201
+
186
202
  ## Contributing
187
203
 
188
- Contributions welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
204
+ Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
205
+
206
+ Please read our [Code of Conduct](CODE_OF_CONDUCT.md) before participating.
207
+
208
+ ## Changelog
209
+
210
+ See [CHANGELOG.md](CHANGELOG.md) for release history.
189
211
 
190
212
  ## License
191
213
 
192
- AGPL-3.0
214
+ [AGPL-3.0](LICENSE) - Graeme Knox
@@ -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, fetchDefaultBranch, SKILL_FILENAME, RateLimitError, RepoNotFoundError, NetworkError, GitHubApiError, } from '../lib/github.js';
12
+ import { findAllSkillMdFiles, fetchSkillMdContent, fetchDefaultBranch, fetchTreeSha, 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 ===
@@ -115,14 +115,23 @@ Examples:
115
115
  // 1. Discover or select skills
116
116
  let discoveryResult;
117
117
  if (explicitPath) {
118
- // For explicit paths, we still need to fetch the default branch for degit
118
+ // For explicit paths, we still need to fetch the default branch and SHA for tracking
119
119
  try {
120
120
  const branch = await fetchDefaultBranch(owner, repo);
121
- discoveryResult = { paths: [explicitPath], branch };
121
+ // Fetch tree SHA for manifest tracking
122
+ let sha;
123
+ try {
124
+ sha = await fetchTreeSha(owner, repo, branch);
125
+ }
126
+ catch {
127
+ // If we can't fetch SHA, install without manifest tracking
128
+ sha = undefined;
129
+ }
130
+ discoveryResult = { paths: [explicitPath], branch, sha };
122
131
  }
123
- catch (err) {
132
+ catch {
124
133
  // If we can't fetch the branch, let degit try its own detection
125
- discoveryResult = { paths: [explicitPath], branch: undefined };
134
+ discoveryResult = { paths: [explicitPath], branch: undefined, sha: undefined };
126
135
  }
127
136
  }
128
137
  else {
@@ -134,7 +143,7 @@ Examples:
134
143
  }
135
144
  process.exit(EXIT_CODES.NOT_FOUND);
136
145
  }
137
- const { paths: skillPaths, branch: discoveredBranch } = discoveryResult;
146
+ const { paths: skillPaths, branch: discoveredBranch, sha: discoveredSha } = discoveryResult;
138
147
  // 2. Determine install location (global vs project)
139
148
  const baseDir = await selectInstallLocation(projectFlag, globalFlag, jsonMode);
140
149
  // 3. Select agents to install to
@@ -193,6 +202,7 @@ Examples:
193
202
  force,
194
203
  baseDir,
195
204
  branch: discoveredBranch,
205
+ sha: discoveredSha,
196
206
  });
197
207
  if (spinner) {
198
208
  if (result.failed) {
@@ -373,7 +383,7 @@ async function discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput,
373
383
  }
374
384
  process.exit(exitCode);
375
385
  }
376
- const { paths: skillPaths, branch } = skillDiscovery;
386
+ const { paths: skillPaths, branch, sha } = skillDiscovery;
377
387
  if (skillPaths.length === 0) {
378
388
  const errorMsg = `No ${SKILL_FILENAME} found in repository`;
379
389
  if (jsonMode) {
@@ -432,7 +442,7 @@ async function discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput,
432
442
  if (!jsonMode) {
433
443
  p.log.info(`${pc.bold(displayName)}${desc ? pc.dim(` - ${desc}`) : ''}`);
434
444
  }
435
- return { paths: [matchedSkill.dir], branch };
445
+ return { paths: [matchedSkill.dir], branch, sha };
436
446
  }
437
447
  // Skill not found - show available skills
438
448
  const errorMsg = `Skill "${targetSkillName}" not found in repository`;
@@ -458,7 +468,7 @@ async function discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput,
458
468
  if (!jsonMode) {
459
469
  p.log.info(`${pc.bold(displayName)}${desc ? pc.dim(` - ${desc}`) : ''}`);
460
470
  }
461
- return { paths: [skill.dir], branch };
471
+ return { paths: [skill.dir], branch, sha };
462
472
  }
463
473
  // Build options for selection with frontmatter metadata
464
474
  // Title in label, description in hint (shows on focus)
@@ -474,7 +484,7 @@ async function discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput,
474
484
  if (!jsonMode) {
475
485
  console.log(`Installing all ${skills.length} skills`);
476
486
  }
477
- return { paths: skills.map((s) => s.dir), branch };
487
+ return { paths: skills.map((s) => s.dir), branch, sha };
478
488
  }
479
489
  // Otherwise, list skills and exit with guidance
480
490
  if (jsonMode) {
@@ -502,7 +512,7 @@ async function discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput,
502
512
  p.cancel('Cancelled');
503
513
  process.exit(EXIT_CODES.SUCCESS);
504
514
  }
505
- return { paths: selected, branch };
515
+ return { paths: selected, branch, sha };
506
516
  }
507
517
  /**
508
518
  * SECURITY: Show warning and ask for user confirmation before installing.
@@ -78,7 +78,12 @@ Examples:
78
78
  const skillPath = join(globalDir, skill);
79
79
  if (!seenPaths.has(skillPath)) {
80
80
  seenPaths.add(skillPath);
81
- const item = { agent: agent.name, skill, path: skillPath, location: 'global' };
81
+ const item = {
82
+ agent: agent.name,
83
+ skill,
84
+ path: skillPath,
85
+ location: 'global',
86
+ };
82
87
  installed.push(item);
83
88
  globalSkills.push(item);
84
89
  }
@@ -92,7 +97,12 @@ Examples:
92
97
  // Skip if already seen (avoids duplicates when cwd is under home)
93
98
  if (!seenPaths.has(skillPath)) {
94
99
  seenPaths.add(skillPath);
95
- const item = { agent: agent.name, skill, path: skillPath, location: 'project' };
100
+ const item = {
101
+ agent: agent.name,
102
+ skill,
103
+ path: skillPath,
104
+ location: 'project',
105
+ };
96
106
  installed.push(item);
97
107
  projectSkills.push(item);
98
108
  }
@@ -136,7 +146,10 @@ Examples:
136
146
  if (hasBothLocations && isInputTTY()) {
137
147
  const locationOptions = [
138
148
  { value: 'global', label: `Global (~/) ${pc.dim(`(${globalSkills.length})`)}` },
139
- { value: 'project', label: `Project (./) ${pc.dim(`(${projectSkills.length})`)}` },
149
+ {
150
+ value: 'project',
151
+ label: `Project (./) ${pc.dim(`(${projectSkills.length})`)}`,
152
+ },
140
153
  ];
141
154
  const selectedLocation = await p.select({
142
155
  message: 'Select location',
@@ -208,7 +221,10 @@ Examples:
208
221
  if (hasBothLocations) {
209
222
  const locationOptions = [
210
223
  { value: 'global', label: `Global (~/) ${pc.dim(`(${globalSkills.length})`)}` },
211
- { value: 'project', label: `Project (./) ${pc.dim(`(${projectSkills.length})`)}` },
224
+ {
225
+ value: 'project',
226
+ label: `Project (./) ${pc.dim(`(${projectSkills.length})`)}`,
227
+ },
212
228
  ];
213
229
  const selectedLocation = await p.select({
214
230
  message: 'Select location',
@@ -82,16 +82,14 @@ Examples:
82
82
  // Detect agents
83
83
  const detected = getDetectedAgents();
84
84
  if (detected.length === 0) {
85
- exitWithError('No agents detected. Install Claude Code, Cursor, or another supported agent first.', EXIT_CODES.GENERAL_ERROR, true // useClackLog
86
- );
85
+ exitWithError('No agents detected. Install Claude Code, Cursor, or another supported agent first.', EXIT_CODES.GENERAL_ERROR, true);
87
86
  }
88
87
  // Filter to target agent if specified
89
88
  let targetAgents = detected;
90
89
  if (targetAgentName) {
91
90
  const found = detected.filter((a) => a.name.toLowerCase() === targetAgentName.toLowerCase());
92
91
  if (found.length === 0) {
93
- exitWithError(`Agent "${targetAgentName}" not found. Detected agents: ${detected.map((a) => a.name).join(', ')}`, EXIT_CODES.NOT_FOUND, true // useClackLog
94
- );
92
+ exitWithError(`Agent "${targetAgentName}" not found. Detected agents: ${detected.map((a) => a.name).join(', ')}`, EXIT_CODES.NOT_FOUND, true);
95
93
  }
96
94
  targetAgents = found;
97
95
  }
@@ -220,7 +218,10 @@ Examples:
220
218
  if (hasBothLocations) {
221
219
  const locationOptions = [
222
220
  { value: 'global', label: `Global (~/) ${pc.dim(`(${globalSkills.length})`)}` },
223
- { value: 'project', label: `Project (./) ${pc.dim(`(${projectSkills.length})`)}` },
221
+ {
222
+ value: 'project',
223
+ label: `Project (./) ${pc.dim(`(${projectSkills.length})`)}`,
224
+ },
224
225
  ];
225
226
  const selectedLocation = await p.select({
226
227
  message: 'Select location',
@@ -0,0 +1,5 @@
1
+ /**
2
+ * `skillfish update` command - Check for and apply updates to installed skills.
3
+ */
4
+ import { Command } from 'commander';
5
+ export declare const updateCommand: Command;
@@ -0,0 +1,327 @@
1
+ /**
2
+ * `skillfish update` command - Check for and apply updates to installed skills.
3
+ */
4
+ import { Command } from 'commander';
5
+ import { homedir } from 'os';
6
+ import { join } from 'path';
7
+ import * as p from '@clack/prompts';
8
+ import pc from 'picocolors';
9
+ import { getDetectedAgents, getAgentSkillDir } from '../lib/agents.js';
10
+ import { listInstalledSkillsInDir, installSkill } from '../lib/installer.js';
11
+ import { readManifest } from '../lib/manifest.js';
12
+ import { fetchTreeSha, RateLimitError, RepoNotFoundError, NetworkError, GitHubApiError, } from '../lib/github.js';
13
+ import { EXIT_CODES } from '../lib/constants.js';
14
+ import { isInputTTY, isTTY } from '../utils.js';
15
+ // === Command Definition ===
16
+ export const updateCommand = new Command('update')
17
+ .description('Check for and apply updates to installed skills')
18
+ .option('-y, --yes', 'Update all outdated skills without prompting')
19
+ .helpOption('-h, --help', 'Display help for command')
20
+ .addHelpText('after', `
21
+ Examples:
22
+ $ skillfish update Check for updates interactively
23
+ $ skillfish update --yes Update all outdated skills
24
+ $ skillfish update --json Check for updates (JSON output)
25
+ $ skillfish update --yes --json Update all outdated skills (JSON output)`)
26
+ .action(async (options, command) => {
27
+ const jsonMode = command.parent?.opts().json ?? false;
28
+ const autoUpdate = options.yes ?? false;
29
+ const version = command.parent?.opts().version ?? '0.0.0';
30
+ // JSON output state
31
+ const jsonOutput = {
32
+ success: true,
33
+ exit_code: EXIT_CODES.SUCCESS,
34
+ errors: [],
35
+ outdated: [],
36
+ updated: [],
37
+ };
38
+ function addError(message) {
39
+ jsonOutput.errors.push(message);
40
+ jsonOutput.success = false;
41
+ }
42
+ function outputJsonAndExit(exitCode) {
43
+ jsonOutput.exit_code = exitCode;
44
+ console.log(JSON.stringify(jsonOutput, null, 2));
45
+ process.exit(exitCode);
46
+ }
47
+ function exitWithError(message, exitCode) {
48
+ if (jsonMode) {
49
+ addError(message);
50
+ outputJsonAndExit(exitCode);
51
+ }
52
+ p.log.error(message);
53
+ process.exit(exitCode);
54
+ }
55
+ // Show banner (TTY only, not in JSON mode)
56
+ if (isTTY() && !jsonMode) {
57
+ console.log();
58
+ console.log(pc.cyan(' ≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋'));
59
+ console.log(` ${pc.cyan('><>')} ${pc.bold('SKILL FISH')} ${pc.cyan('><>')}`);
60
+ console.log(pc.cyan(' ≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋'));
61
+ console.log();
62
+ p.intro(`${pc.bgCyan(pc.black(' skillfish '))} ${pc.dim(`v${version}`)}`);
63
+ }
64
+ // Detect agents
65
+ const detected = getDetectedAgents();
66
+ if (detected.length === 0) {
67
+ exitWithError('No agents detected. Install Claude Code, Cursor, or another supported agent first.', EXIT_CODES.GENERAL_ERROR);
68
+ }
69
+ // Show checking spinner
70
+ let checkSpinner = null;
71
+ if (!jsonMode) {
72
+ checkSpinner = p.spinner();
73
+ checkSpinner.start('Checking for updates...');
74
+ }
75
+ // Collect all tracked skills (skills with manifests)
76
+ const trackedSkills = collectTrackedSkills(detected);
77
+ if (trackedSkills.length === 0) {
78
+ if (checkSpinner) {
79
+ checkSpinner.stop(pc.yellow('No tracked skills found'));
80
+ }
81
+ if (jsonMode) {
82
+ outputJsonAndExit(EXIT_CODES.SUCCESS);
83
+ }
84
+ console.log();
85
+ p.log.info(pc.dim("Skills installed before this version don't have tracking info."));
86
+ p.log.info(pc.dim('Reinstall skills with `skillfish add` to enable updates.'));
87
+ p.outro(pc.dim('Done'));
88
+ process.exit(EXIT_CODES.SUCCESS);
89
+ }
90
+ // Check for updates
91
+ const { outdated, errors, rateLimitHit } = await checkForUpdates(trackedSkills);
92
+ if (checkSpinner) {
93
+ if (rateLimitHit) {
94
+ checkSpinner.stop(pc.yellow('Rate limit reached'));
95
+ }
96
+ else if (outdated.length > 0) {
97
+ checkSpinner.stop(`Found ${pc.cyan(outdated.length.toString())} outdated skill${outdated.length === 1 ? '' : 's'}`);
98
+ }
99
+ else {
100
+ checkSpinner.stop(pc.green('All skills are up to date'));
101
+ }
102
+ }
103
+ // Add any errors to output
104
+ for (const error of errors) {
105
+ addError(error);
106
+ }
107
+ // Build outdated skills for JSON output
108
+ jsonOutput.outdated = outdated.map((s) => ({
109
+ skill: s.skill,
110
+ agent: s.agent.name,
111
+ path: s.path,
112
+ location: s.location,
113
+ localSha: s.manifest.sha,
114
+ remoteSha: s.remoteSha,
115
+ source: `${s.manifest.owner}/${s.manifest.repo}`,
116
+ }));
117
+ // If rate limit hit, report and exit
118
+ if (rateLimitHit) {
119
+ if (jsonMode) {
120
+ outputJsonAndExit(EXIT_CODES.NETWORK_ERROR);
121
+ }
122
+ p.log.warn('GitHub API rate limit exceeded. Try again later.');
123
+ process.exit(EXIT_CODES.NETWORK_ERROR);
124
+ }
125
+ // No updates available
126
+ if (outdated.length === 0) {
127
+ if (jsonMode) {
128
+ outputJsonAndExit(EXIT_CODES.SUCCESS);
129
+ }
130
+ p.outro(pc.green('All skills are up to date'));
131
+ process.exit(EXIT_CODES.SUCCESS);
132
+ }
133
+ // Display outdated skills (TTY mode)
134
+ if (!jsonMode) {
135
+ console.log();
136
+ for (const skill of outdated) {
137
+ const locationLabel = skill.location === 'global' ? pc.dim('global') : pc.dim('project');
138
+ const shortLocal = skill.manifest.sha.substring(0, 7);
139
+ const shortRemote = skill.remoteSha.substring(0, 7);
140
+ console.log(` ${pc.yellow('•')} ${pc.bold(skill.skill)} (${skill.agent.name}, ${locationLabel})`);
141
+ console.log(` ${pc.dim(shortLocal)} → ${pc.cyan(shortRemote)}`);
142
+ }
143
+ console.log();
144
+ }
145
+ // If --json without --yes: check mode only - don't apply updates
146
+ if (jsonMode && !autoUpdate) {
147
+ outputJsonAndExit(EXIT_CODES.SUCCESS);
148
+ }
149
+ // Prompt for confirmation (unless --yes)
150
+ if (!autoUpdate && isInputTTY() && !jsonMode) {
151
+ const proceed = await p.confirm({
152
+ message: `Update all ${outdated.length} skill${outdated.length === 1 ? '' : 's'}?`,
153
+ initialValue: true,
154
+ });
155
+ if (p.isCancel(proceed) || !proceed) {
156
+ p.cancel('Cancelled');
157
+ process.exit(EXIT_CODES.SUCCESS);
158
+ }
159
+ }
160
+ // Apply updates
161
+ let updatedCount = 0;
162
+ let failedCount = 0;
163
+ for (let i = 0; i < outdated.length; i++) {
164
+ const skill = outdated[i];
165
+ const progress = `[${i + 1}/${outdated.length}]`;
166
+ let updateSpinner = null;
167
+ if (!jsonMode) {
168
+ updateSpinner = p.spinner();
169
+ updateSpinner.start(`${progress} Updating ${skill.skill}...`);
170
+ }
171
+ const result = await installSkill(skill.manifest.owner, skill.manifest.repo, skill.manifest.path, skill.skill, [skill.agent], {
172
+ force: true,
173
+ baseDir: skill.location === 'global' ? homedir() : process.cwd(),
174
+ branch: skill.manifest.branch,
175
+ sha: skill.remoteSha,
176
+ });
177
+ if (result.failed) {
178
+ failedCount++;
179
+ if (updateSpinner) {
180
+ updateSpinner.stop(pc.red(`${progress} ${skill.skill} failed`));
181
+ }
182
+ addError(`Failed to update ${skill.skill}: ${result.failureReason}`);
183
+ }
184
+ else {
185
+ updatedCount++;
186
+ if (updateSpinner) {
187
+ updateSpinner.stop(pc.green(`${progress} ${skill.skill} updated`));
188
+ }
189
+ jsonOutput.updated.push({
190
+ skill: skill.skill,
191
+ agent: skill.agent.name,
192
+ path: skill.path,
193
+ location: skill.location,
194
+ });
195
+ }
196
+ }
197
+ // Summary
198
+ if (jsonMode) {
199
+ const exitCode = failedCount > 0 ? EXIT_CODES.GENERAL_ERROR : EXIT_CODES.SUCCESS;
200
+ outputJsonAndExit(exitCode);
201
+ }
202
+ console.log();
203
+ if (failedCount > 0) {
204
+ p.outro(pc.yellow(`Updated ${updatedCount} of ${outdated.length} skill${outdated.length === 1 ? '' : 's'}`));
205
+ process.exit(EXIT_CODES.GENERAL_ERROR);
206
+ }
207
+ else {
208
+ p.outro(pc.green(`Updated ${updatedCount} skill${updatedCount === 1 ? '' : 's'} successfully`));
209
+ process.exit(EXIT_CODES.SUCCESS);
210
+ }
211
+ });
212
+ // === Helper Functions ===
213
+ /**
214
+ * Collect all installed skills that have manifests (tracked skills).
215
+ */
216
+ function collectTrackedSkills(agents) {
217
+ const tracked = [];
218
+ const seenPaths = new Set();
219
+ for (const agent of agents) {
220
+ // Check global skills
221
+ const globalDir = getAgentSkillDir(agent, homedir());
222
+ const globalSkills = listInstalledSkillsInDir(globalDir);
223
+ for (const skill of globalSkills) {
224
+ const skillPath = join(globalDir, skill);
225
+ if (seenPaths.has(skillPath))
226
+ continue;
227
+ seenPaths.add(skillPath);
228
+ const manifest = readManifest(skillPath);
229
+ if (manifest) {
230
+ tracked.push({
231
+ skill,
232
+ agent,
233
+ path: skillPath,
234
+ location: 'global',
235
+ manifest,
236
+ });
237
+ }
238
+ }
239
+ // Check project skills
240
+ const projectDir = getAgentSkillDir(agent, process.cwd());
241
+ const projectSkills = listInstalledSkillsInDir(projectDir);
242
+ for (const skill of projectSkills) {
243
+ const skillPath = join(projectDir, skill);
244
+ if (seenPaths.has(skillPath))
245
+ continue;
246
+ seenPaths.add(skillPath);
247
+ const manifest = readManifest(skillPath);
248
+ if (manifest) {
249
+ tracked.push({
250
+ skill,
251
+ agent,
252
+ path: skillPath,
253
+ location: 'project',
254
+ manifest,
255
+ });
256
+ }
257
+ }
258
+ }
259
+ return tracked;
260
+ }
261
+ /**
262
+ * Check which tracked skills have updates available.
263
+ * Caches tree SHA lookups to avoid duplicate API calls for skills from the same repo.
264
+ */
265
+ async function checkForUpdates(skills) {
266
+ const outdated = [];
267
+ const errors = [];
268
+ let rateLimitHit = false;
269
+ // Cache tree SHA lookups by owner/repo/branch to avoid duplicate API calls
270
+ const shaCache = new Map();
271
+ const errorCache = new Map();
272
+ for (const skill of skills) {
273
+ const cacheKey = `${skill.manifest.owner}/${skill.manifest.repo}/${skill.manifest.branch}`;
274
+ // Check if we already have a cached error for this repo
275
+ const cachedError = errorCache.get(cacheKey);
276
+ if (cachedError) {
277
+ if (cachedError instanceof RepoNotFoundError) {
278
+ errors.push(`${skill.skill}: Repository not found (${skill.manifest.owner}/${skill.manifest.repo})`);
279
+ }
280
+ else if (cachedError instanceof NetworkError) {
281
+ errors.push(`${skill.skill}: ${cachedError.message}`);
282
+ }
283
+ else if (cachedError instanceof GitHubApiError) {
284
+ errors.push(`${skill.skill}: ${cachedError.message}`);
285
+ }
286
+ else {
287
+ errors.push(`${skill.skill}: ${cachedError.message}`);
288
+ }
289
+ continue;
290
+ }
291
+ // Check if we already have a cached SHA for this repo
292
+ let remoteSha = shaCache.get(cacheKey);
293
+ if (!remoteSha) {
294
+ try {
295
+ remoteSha = await fetchTreeSha(skill.manifest.owner, skill.manifest.repo, skill.manifest.branch);
296
+ shaCache.set(cacheKey, remoteSha);
297
+ }
298
+ catch (err) {
299
+ if (err instanceof RateLimitError) {
300
+ rateLimitHit = true;
301
+ break; // Stop checking on rate limit
302
+ }
303
+ // Cache the error for other skills from the same repo
304
+ if (err instanceof Error) {
305
+ errorCache.set(cacheKey, err);
306
+ }
307
+ if (err instanceof RepoNotFoundError) {
308
+ errors.push(`${skill.skill}: Repository not found (${skill.manifest.owner}/${skill.manifest.repo})`);
309
+ }
310
+ else if (err instanceof NetworkError) {
311
+ errors.push(`${skill.skill}: ${err.message}`);
312
+ }
313
+ else if (err instanceof GitHubApiError) {
314
+ errors.push(`${skill.skill}: ${err.message}`);
315
+ }
316
+ else {
317
+ errors.push(`${skill.skill}: ${err instanceof Error ? err.message : 'Unknown error'}`);
318
+ }
319
+ continue;
320
+ }
321
+ }
322
+ if (skill.manifest.sha !== remoteSha) {
323
+ outdated.push({ ...skill, remoteSha });
324
+ }
325
+ }
326
+ return { outdated, errors, rateLimitHit };
327
+ }
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ import { dirname, join } from 'path';
11
11
  import { addCommand } from './commands/add.js';
12
12
  import { listCommand } from './commands/list.js';
13
13
  import { removeCommand } from './commands/remove.js';
14
+ import { updateCommand } from './commands/update.js';
14
15
  // Read version from package.json (single source of truth)
15
16
  const __dirname = dirname(fileURLToPath(import.meta.url));
16
17
  const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
@@ -45,6 +46,7 @@ program.hook('preAction', (thisCommand) => {
45
46
  program.addCommand(addCommand);
46
47
  program.addCommand(listCommand);
47
48
  program.addCommand(removeCommand);
49
+ program.addCommand(updateCommand);
48
50
  // Handle --json flag for help output
49
51
  program.on('option:json', () => {
50
52
  // JSON mode is handled by commands
@@ -6,12 +6,12 @@
6
6
  * Agent configuration - data-driven for easier maintenance.
7
7
  * Detection checks home directory (agent installed globally) and cwd (local project).
8
8
  */
9
- export type AgentConfig = {
9
+ export interface AgentConfig {
10
10
  readonly name: string;
11
11
  readonly dir: string;
12
12
  readonly homePaths: readonly string[];
13
13
  readonly cwdPaths: readonly string[];
14
- };
14
+ }
15
15
  export declare const AGENT_CONFIGS: readonly AgentConfig[];
16
16
  /**
17
17
  * Check if an agent is detected on the system.
@@ -21,11 +21,11 @@ export declare function detectAgent(config: AgentConfig, baseDir?: string): bool
21
21
  /**
22
22
  * Runtime agent type with detect function.
23
23
  */
24
- export type Agent = {
24
+ export interface Agent {
25
25
  readonly name: string;
26
26
  readonly dir: string;
27
27
  readonly detect: () => boolean;
28
- };
28
+ }
29
29
  /**
30
30
  * Build AGENTS array from config (preserves existing API).
31
31
  */
@@ -3,11 +3,13 @@
3
3
  */
4
4
  export declare const SKILL_FILENAME = "SKILL.md";
5
5
  /**
6
- * Result of skill discovery including branch information.
6
+ * Result of skill discovery including branch and SHA information.
7
7
  */
8
8
  export interface SkillDiscoveryResult {
9
9
  paths: string[];
10
10
  branch: string;
11
+ /** Tree SHA from git/trees response - changes when any file in repo changes */
12
+ sha: string;
11
13
  }
12
14
  /**
13
15
  * Thrown when GitHub API rate limit is exceeded.
@@ -55,6 +57,16 @@ export declare function fetchWithRetry(url: string, options: RequestInit, maxRet
55
57
  * Uses raw.githubusercontent.com which is not rate-limited like the API.
56
58
  */
57
59
  export declare function fetchSkillMdContent(owner: string, repo: string, path: string, branch: string): Promise<string | null>;
60
+ /**
61
+ * Fetch the tree SHA for a repository branch.
62
+ * Used for update checks - compares stored SHA with current SHA.
63
+ *
64
+ * @throws {RepoNotFoundError} When the repository is not found
65
+ * @throws {RateLimitError} When GitHub API rate limit is exceeded
66
+ * @throws {NetworkError} On network errors
67
+ * @throws {GitHubApiError} When the API response format is unexpected
68
+ */
69
+ export declare function fetchTreeSha(owner: string, repo: string, branch: string): Promise<string>;
58
70
  /**
59
71
  * Find all SKILL.md files in a GitHub repository.
60
72
  * Fetches the default branch, then searches for skills on that branch.