skillfish 1.0.15 → 1.0.17

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
@@ -47,6 +47,7 @@ Learn more at [agentskills.io](https://agentskills.io).
47
47
  | Command | Description |
48
48
  |---------|-------------|
49
49
  | `skillfish add <owner/repo>` | Install skills |
50
+ | `skillfish init` | Create a new skill |
50
51
  | `skillfish list` | View installed skills |
51
52
  | `skillfish remove [name]` | Remove skills |
52
53
  | `skillfish update` | Update installed skills |
@@ -56,8 +57,10 @@ All commands support `--json` for automation.
56
57
  ## Examples
57
58
 
58
59
  ```bash
59
- skillfish add user/my-skill # Install a skill
60
+ skillfish add owner/repo # Install from a repository
60
61
  skillfish add owner/repo --all # Install all skills from repo
62
+ skillfish init # Create a new skill (interactive)
63
+ skillfish init --name my-skill # Create with a specified name
61
64
  skillfish list # See what's installed
62
65
  skillfish update # Update all skills
63
66
  skillfish remove old-skill # Remove a skill
@@ -114,6 +117,23 @@ skillfish add owner/repo --project # Project only (./)
114
117
  skillfish add owner/repo --global # Global only (~/)
115
118
  ```
116
119
 
120
+ ### init
121
+
122
+ Create a new skill template with `SKILL.md` and optional directories.
123
+
124
+ ```bash
125
+ skillfish init # Interactive skill creation
126
+ skillfish init --name my-skill # Specify skill name
127
+ skillfish init --name my-skill --description "Does a thing" # Non-interactive
128
+ skillfish init --project # Create in current project (./)
129
+ skillfish init --global # Create in home directory (~/)
130
+ skillfish init --name my-skill --yes # Skip all prompts
131
+ skillfish init --author "your-name" # Set author metadata
132
+ skillfish init --license MIT # Set license
133
+ ```
134
+
135
+ Interactive mode prompts for name, description, optional metadata (author, license), optional directories (`scripts/`, `references/`, `assets/`), install location, and target agents.
136
+
117
137
  ### list
118
138
 
119
139
  View installed skills.
@@ -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, fetchTreeSha, SKILL_FILENAME, RateLimitError, RepoNotFoundError, NetworkError, GitHubApiError, } from '../lib/github.js';
12
+ import { findAllSkillMdFiles, fetchSkillMdContent, fetchDefaultBranch, fetchTreeSha, getSkillSha, 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 ===
@@ -127,11 +127,13 @@ Examples:
127
127
  // If we can't fetch SHA, install without manifest tracking
128
128
  sha = undefined;
129
129
  }
130
- discoveryResult = { paths: [explicitPath], branch, sha };
130
+ // For explicit paths, we don't have the tree (would require extra API call)
131
+ // The sha will be root tree SHA - acceptable for explicit path installs
132
+ discoveryResult = { paths: [explicitPath], branch, sha, tree: [] };
131
133
  }
132
134
  catch {
133
135
  // If we can't fetch the branch, let degit try its own detection
134
- discoveryResult = { paths: [explicitPath], branch: undefined, sha: undefined };
136
+ discoveryResult = { paths: [explicitPath], branch: undefined, sha: undefined, tree: [] };
135
137
  }
136
138
  }
137
139
  else {
@@ -143,7 +145,7 @@ Examples:
143
145
  }
144
146
  process.exit(EXIT_CODES.NOT_FOUND);
145
147
  }
146
- const { paths: skillPaths, branch: discoveredBranch, sha: discoveredSha } = discoveryResult;
148
+ const { paths: skillPaths, branch: discoveredBranch, sha: discoveredSha, tree: discoveredTree, } = discoveryResult;
147
149
  // 2. Determine install location (global vs project)
148
150
  const baseDir = await selectInstallLocation(projectFlag, globalFlag, jsonMode);
149
151
  // 3. Select agents to install to
@@ -198,11 +200,15 @@ Examples:
198
200
  spinner = p.spinner();
199
201
  spinner.start(`Downloading ${skillName}...`);
200
202
  }
203
+ // Get directory-specific SHA for better update tracking
204
+ // skillPath is either 'SKILL.md' or a directory like 'skills/foo'
205
+ const skillMdPath = skillPath === SKILL_FILENAME ? SKILL_FILENAME : `${skillPath}/${SKILL_FILENAME}`;
206
+ const skillSha = getSkillSha(discoveredTree, skillMdPath) ?? discoveredSha;
201
207
  const result = await installSkill(owner, repo, skillPath, skillName, targetAgents, {
202
208
  force,
203
209
  baseDir,
204
210
  branch: discoveredBranch,
205
- sha: discoveredSha,
211
+ sha: skillSha,
206
212
  });
207
213
  if (spinner) {
208
214
  if (result.failed) {
@@ -383,7 +389,7 @@ async function discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput,
383
389
  }
384
390
  process.exit(exitCode);
385
391
  }
386
- const { paths: skillPaths, branch, sha } = skillDiscovery;
392
+ const { paths: skillPaths, branch, sha, tree } = skillDiscovery;
387
393
  if (skillPaths.length === 0) {
388
394
  const errorMsg = `No ${SKILL_FILENAME} found in repository`;
389
395
  if (jsonMode) {
@@ -442,7 +448,7 @@ async function discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput,
442
448
  if (!jsonMode) {
443
449
  p.log.info(`${pc.bold(displayName)}${desc ? pc.dim(` - ${desc}`) : ''}`);
444
450
  }
445
- return { paths: [matchedSkill.dir], branch, sha };
451
+ return { paths: [matchedSkill.dir], branch, sha, tree };
446
452
  }
447
453
  // Skill not found - show available skills
448
454
  const errorMsg = `Skill "${targetSkillName}" not found in repository`;
@@ -468,7 +474,7 @@ async function discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput,
468
474
  if (!jsonMode) {
469
475
  p.log.info(`${pc.bold(displayName)}${desc ? pc.dim(` - ${desc}`) : ''}`);
470
476
  }
471
- return { paths: [skill.dir], branch, sha };
477
+ return { paths: [skill.dir], branch, sha, tree };
472
478
  }
473
479
  // Build options for selection with frontmatter metadata
474
480
  // No hints - @clack/prompts has rendering issues with long option lists
@@ -483,7 +489,7 @@ async function discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput,
483
489
  if (!jsonMode) {
484
490
  console.log(`Installing all ${skills.length} skills`);
485
491
  }
486
- return { paths: skills.map((s) => s.dir), branch, sha };
492
+ return { paths: skills.map((s) => s.dir), branch, sha, tree };
487
493
  }
488
494
  // Otherwise, list skills and exit with guidance
489
495
  if (jsonMode) {
@@ -511,7 +517,7 @@ async function discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput,
511
517
  p.cancel('Cancelled');
512
518
  process.exit(EXIT_CODES.SUCCESS);
513
519
  }
514
- return { paths: selected, branch, sha };
520
+ return { paths: selected, branch, sha, tree };
515
521
  }
516
522
  /**
517
523
  * SECURITY: Show warning and ask for user confirmation before installing.
@@ -0,0 +1,5 @@
1
+ /**
2
+ * `skillfish init` command - Generate a template skill.
3
+ */
4
+ import { Command } from 'commander';
5
+ export declare const initCommand: Command;
@@ -0,0 +1,499 @@
1
+ /**
2
+ * `skillfish init` command - Generate a template skill.
3
+ */
4
+ import { Command } from 'commander';
5
+ import { homedir } from 'os';
6
+ import { join } from 'path';
7
+ import { existsSync, mkdirSync, writeFileSync } from 'fs';
8
+ import * as p from '@clack/prompts';
9
+ import pc from 'picocolors';
10
+ import { getDetectedAgents, getAgentSkillDir } from '../lib/agents.js';
11
+ import { SKILL_FILENAME } from '../lib/github.js';
12
+ import { EXIT_CODES } from '../lib/constants.js';
13
+ import { isInputTTY, isTTY } from '../utils.js';
14
+ // === Validation ===
15
+ /**
16
+ * Validates skill name format.
17
+ * Must be lowercase, use hyphens or underscores, no spaces, no consecutive hyphens.
18
+ */
19
+ function isValidSkillName(name) {
20
+ // Must start and end with alphanumeric, contain only lowercase, numbers, hyphens, underscores
21
+ if (!/^[a-z0-9][a-z0-9_-]*[a-z0-9]$|^[a-z0-9]$/.test(name)) {
22
+ return false;
23
+ }
24
+ // No consecutive hyphens or underscores
25
+ if (/[-_]{2,}/.test(name)) {
26
+ return false;
27
+ }
28
+ // Reasonable length
29
+ if (name.length < 1 || name.length > 64) {
30
+ return false;
31
+ }
32
+ return true;
33
+ }
34
+ /**
35
+ * Convert user input to valid skill name format.
36
+ */
37
+ function normalizeSkillName(input) {
38
+ return input
39
+ .toLowerCase()
40
+ .trim()
41
+ .replace(/\s+/g, '-') // spaces to hyphens
42
+ .replace(/[^a-z0-9_-]/g, '') // remove invalid chars
43
+ .replace(/[-_]{2,}/g, '-'); // collapse multiple separators
44
+ }
45
+ const OPTIONAL_DIRS = [
46
+ { value: 'scripts', label: 'scripts/', hint: 'Executable code (Python, Bash, JS)' },
47
+ { value: 'references', label: 'references/', hint: 'Additional documentation' },
48
+ { value: 'assets', label: 'assets/', hint: 'Templates, images, data files' },
49
+ ];
50
+ /**
51
+ * Quote a YAML value if it contains characters that could break parsing.
52
+ * Wraps in double quotes and escapes internal double quotes/backslashes.
53
+ */
54
+ function yamlQuote(value) {
55
+ // If the value contains any YAML-special characters, quote it
56
+ if (/[:#[\]{}&*!|>'"%@`\n\r\\,]/.test(value) || value.startsWith('-') || value.startsWith('?')) {
57
+ const escaped = value
58
+ .replace(/\\/g, '\\\\')
59
+ .replace(/"/g, '\\"')
60
+ .replace(/\n/g, '\\n')
61
+ .replace(/\r/g, '\\r');
62
+ return `"${escaped}"`;
63
+ }
64
+ return value;
65
+ }
66
+ /**
67
+ * Generate SKILL.md content with frontmatter and template.
68
+ */
69
+ function generateSkillMd(meta, dirs) {
70
+ const frontmatterLines = [
71
+ '---',
72
+ `name: ${yamlQuote(meta.name)}`,
73
+ `description: ${yamlQuote(meta.description)}`,
74
+ ];
75
+ if (meta.license) {
76
+ frontmatterLines.push(`license: ${yamlQuote(meta.license)}`);
77
+ }
78
+ if (meta.author || meta.version) {
79
+ frontmatterLines.push('metadata:');
80
+ if (meta.author) {
81
+ frontmatterLines.push(` author: ${yamlQuote(meta.author)}`);
82
+ }
83
+ if (meta.version) {
84
+ frontmatterLines.push(` version: ${yamlQuote(meta.version)}`);
85
+ }
86
+ }
87
+ frontmatterLines.push('---');
88
+ const sections = [
89
+ `${frontmatterLines.join('\n')}`,
90
+ `# ${meta.name}`,
91
+ meta.description,
92
+ `## Instructions\n\n<!-- Add your skill instructions here. This is what the AI agent will read and follow. -->`,
93
+ `## Examples\n\n<!-- Provide examples of how this skill should be used. -->`,
94
+ ];
95
+ if (dirs.includes('references')) {
96
+ sections.push(`## References\n\n<!-- Reference additional docs from the references/ directory. -->\n<!-- Example: See [detailed guide](references/REFERENCE.md) for more info. -->`);
97
+ }
98
+ else {
99
+ sections.push(`## References\n\n<!-- Link to documentation, APIs, or other resources the agent might need. -->`);
100
+ }
101
+ if (dirs.includes('scripts')) {
102
+ sections.push(`## Scripts\n\n<!-- Executable code is available in the scripts/ directory. -->\n<!-- Example: Run scripts/setup.sh to configure the environment. -->`);
103
+ }
104
+ return sections.join('\n\n') + '\n';
105
+ }
106
+ // === Command Definition ===
107
+ export const initCommand = new Command('init')
108
+ .description('Create a new skill template')
109
+ .option('--name <name>', 'Skill name (lowercase, hyphens)')
110
+ .option('--description <desc>', 'Skill description')
111
+ .option('--author <author>', 'Skill author')
112
+ .option('--version <version>', 'Skill version (default: 1.0.0)')
113
+ .option('--license <license>', 'Skill license (e.g., MIT, Apache-2.0)')
114
+ .option('-y, --yes', 'Skip confirmation prompts')
115
+ .option('--project', 'Create in current project (./.claude)')
116
+ .option('--global', 'Create in home directory (~/.claude)')
117
+ .helpOption('-h, --help', 'Display help for command')
118
+ .addHelpText('after', `
119
+ Examples:
120
+ $ skillfish init Interactive skill creation
121
+ $ skillfish init --name my-skill Create skill with specified name
122
+ $ skillfish init --project Create in current project
123
+ $ skillfish init --name my-skill --yes Non-interactive creation`)
124
+ .action(async (options, command) => {
125
+ const jsonMode = command.parent?.opts().json ?? false;
126
+ const version = command.parent?.opts().version ?? '0.0.0';
127
+ // JSON output state
128
+ const jsonOutput = {
129
+ success: true,
130
+ errors: [],
131
+ created: [],
132
+ skipped: [],
133
+ };
134
+ function addError(message) {
135
+ jsonOutput.errors.push(message);
136
+ jsonOutput.success = false;
137
+ }
138
+ function outputJsonAndExit(exitCode) {
139
+ jsonOutput.exit_code = exitCode;
140
+ console.log(JSON.stringify(jsonOutput, null, 2));
141
+ process.exit(exitCode);
142
+ }
143
+ function exitWithError(message, exitCode, useClackLog = false) {
144
+ if (jsonMode) {
145
+ addError(message);
146
+ outputJsonAndExit(exitCode);
147
+ }
148
+ if (useClackLog) {
149
+ p.log.error(message);
150
+ }
151
+ else {
152
+ console.error(message);
153
+ }
154
+ process.exit(exitCode);
155
+ }
156
+ // Show banner and intro (TTY only, not in JSON mode)
157
+ if (isTTY() && !jsonMode) {
158
+ console.log();
159
+ console.log(pc.cyan(' ≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋'));
160
+ console.log(` ${pc.cyan('><>')} ${pc.bold('SKILL FISH')} ${pc.cyan('><>')}`);
161
+ console.log(pc.cyan(' ≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋'));
162
+ console.log();
163
+ p.intro(`${pc.bgCyan(pc.black(' skillfish '))} ${pc.dim(`v${version}`)} ${pc.dim('· Create a skill')}`);
164
+ }
165
+ const skipPrompts = options.yes ?? false;
166
+ const projectFlag = options.project ?? false;
167
+ const globalFlag = options.global ?? false;
168
+ if (projectFlag && globalFlag) {
169
+ exitWithError('Cannot use both --project and --global. Choose one.', EXIT_CODES.INVALID_ARGS);
170
+ }
171
+ // 1. Get skill name
172
+ let skillName;
173
+ if (options.name) {
174
+ // Validate provided name
175
+ const normalized = normalizeSkillName(options.name);
176
+ if (!isValidSkillName(normalized)) {
177
+ exitWithError('Invalid skill name. Use lowercase letters, numbers, and hyphens (e.g., my-skill).', EXIT_CODES.INVALID_ARGS);
178
+ }
179
+ skillName = normalized;
180
+ }
181
+ else if (!isInputTTY() || jsonMode) {
182
+ exitWithError('Skill name is required. Use --name <name> in non-interactive mode.', EXIT_CODES.INVALID_ARGS);
183
+ }
184
+ else {
185
+ // Interactive prompt for skill name
186
+ const nameInput = await p.text({
187
+ message: 'Skill name',
188
+ placeholder: 'my-skill',
189
+ validate: (value) => {
190
+ const normalized = normalizeSkillName(value);
191
+ if (!normalized) {
192
+ return 'Skill name is required';
193
+ }
194
+ if (!isValidSkillName(normalized)) {
195
+ return 'Use lowercase letters, numbers, and hyphens (e.g., my-skill)';
196
+ }
197
+ },
198
+ });
199
+ if (p.isCancel(nameInput)) {
200
+ p.cancel('Cancelled');
201
+ process.exit(EXIT_CODES.SUCCESS);
202
+ }
203
+ skillName = normalizeSkillName(nameInput);
204
+ }
205
+ // 2. Get skill description
206
+ let skillDescription;
207
+ if (options.description) {
208
+ skillDescription = options.description;
209
+ }
210
+ else if (!isInputTTY() || jsonMode) {
211
+ exitWithError('Skill description is required. Use --description <desc> in non-interactive mode.', EXIT_CODES.INVALID_ARGS);
212
+ }
213
+ else {
214
+ const descInput = await p.text({
215
+ message: 'Description',
216
+ placeholder: 'What does this skill do?',
217
+ validate: (value) => {
218
+ if (!value.trim()) {
219
+ return 'Description is required';
220
+ }
221
+ },
222
+ });
223
+ if (p.isCancel(descInput)) {
224
+ p.cancel('Cancelled');
225
+ process.exit(EXIT_CODES.SUCCESS);
226
+ }
227
+ skillDescription = descInput.trim();
228
+ }
229
+ // 3. Optional metadata (author, version, license) - only prompt interactively
230
+ let author = options.author;
231
+ let skillVersion = options.version || '1.0.0';
232
+ let license = options.license;
233
+ if (isInputTTY() && !jsonMode && !skipPrompts) {
234
+ const addMetadata = await p.confirm({
235
+ message: 'Add optional metadata (author, license)?',
236
+ initialValue: false,
237
+ });
238
+ if (p.isCancel(addMetadata)) {
239
+ p.cancel('Cancelled');
240
+ process.exit(EXIT_CODES.SUCCESS);
241
+ }
242
+ if (addMetadata) {
243
+ const authorInput = await p.text({
244
+ message: 'Author',
245
+ placeholder: 'your-name or your-org (optional)',
246
+ });
247
+ if (p.isCancel(authorInput)) {
248
+ p.cancel('Cancelled');
249
+ process.exit(EXIT_CODES.SUCCESS);
250
+ }
251
+ author = authorInput.trim() || undefined;
252
+ const licenseInput = await p.select({
253
+ message: 'License',
254
+ options: [
255
+ { value: '', label: 'None' },
256
+ { value: 'MIT', label: 'MIT' },
257
+ { value: 'Apache-2.0', label: 'Apache-2.0' },
258
+ { value: 'BSD-3-Clause', label: 'BSD-3-Clause' },
259
+ { value: 'GPL-3.0', label: 'GPL-3.0' },
260
+ { value: 'AGPL-3.0', label: 'AGPL-3.0' },
261
+ { value: 'Unlicense', label: 'Unlicense' },
262
+ ],
263
+ });
264
+ if (p.isCancel(licenseInput)) {
265
+ p.cancel('Cancelled');
266
+ process.exit(EXIT_CODES.SUCCESS);
267
+ }
268
+ license = licenseInput || undefined;
269
+ }
270
+ }
271
+ // 4. Select optional directories
272
+ let optionalDirs = [];
273
+ if (isInputTTY() && !jsonMode && !skipPrompts) {
274
+ const addDirs = await p.confirm({
275
+ message: 'Include optional directories (scripts/, references/, assets/)?',
276
+ initialValue: false,
277
+ });
278
+ if (p.isCancel(addDirs)) {
279
+ p.cancel('Cancelled');
280
+ process.exit(EXIT_CODES.SUCCESS);
281
+ }
282
+ if (addDirs) {
283
+ const selected = await p.multiselect({
284
+ message: 'Select directories to include',
285
+ options: OPTIONAL_DIRS.map((d) => ({
286
+ value: d.value,
287
+ label: d.label,
288
+ hint: d.hint,
289
+ })),
290
+ required: false,
291
+ });
292
+ if (p.isCancel(selected)) {
293
+ p.cancel('Cancelled');
294
+ process.exit(EXIT_CODES.SUCCESS);
295
+ }
296
+ optionalDirs = selected;
297
+ }
298
+ }
299
+ // 5. Determine install location (global vs project)
300
+ const baseDir = await selectInstallLocation(projectFlag, globalFlag, jsonMode);
301
+ // 6. Select agents to create skill for
302
+ const detected = getDetectedAgents();
303
+ if (detected.length === 0) {
304
+ const errorMsg = 'No agents detected. Install Claude Code, Cursor, or another supported agent first.';
305
+ if (jsonMode) {
306
+ addError(errorMsg);
307
+ outputJsonAndExit(EXIT_CODES.GENERAL_ERROR);
308
+ }
309
+ p.log.error(errorMsg);
310
+ p.outro(pc.dim('https://skill.fish/agents'));
311
+ process.exit(EXIT_CODES.GENERAL_ERROR);
312
+ }
313
+ let targetAgents;
314
+ if (!isInputTTY() || jsonMode || skipPrompts) {
315
+ // Non-TTY or JSON mode or --yes: use all detected agents
316
+ if (!jsonMode) {
317
+ console.log(`Creating for ${detected.length} agent(s): ${detected.map((a) => a.name).join(', ')}`);
318
+ }
319
+ targetAgents = detected;
320
+ }
321
+ else {
322
+ // Interactive: let user choose from detected agents
323
+ const isLocal = baseDir !== homedir();
324
+ targetAgents = await selectAgents(detected, isLocal, jsonMode);
325
+ }
326
+ // 7. Create the skill
327
+ const skillMeta = {
328
+ name: skillName,
329
+ description: skillDescription,
330
+ author,
331
+ version: skillVersion,
332
+ license,
333
+ };
334
+ const skillContent = generateSkillMd(skillMeta, optionalDirs);
335
+ const isLocal = baseDir !== homedir();
336
+ const pathPrefix = isLocal ? '.' : '~';
337
+ if (!jsonMode) {
338
+ p.log.step(`Creating ${pc.bold(skillName)}`);
339
+ }
340
+ let created = 0;
341
+ let skipped = 0;
342
+ for (const agent of targetAgents) {
343
+ const skillDir = join(getAgentSkillDir(agent, baseDir), skillName);
344
+ const skillFilePath = join(skillDir, SKILL_FILENAME);
345
+ // Check if skill already exists
346
+ if (existsSync(skillFilePath)) {
347
+ if (!jsonMode) {
348
+ console.log(` ${pc.yellow('●')} ${agent.name} ${pc.dim('(already exists)')}`);
349
+ }
350
+ jsonOutput.skipped.push({
351
+ skill: skillName,
352
+ agent: agent.name,
353
+ reason: 'Already exists',
354
+ });
355
+ skipped++;
356
+ continue;
357
+ }
358
+ // Create skill directory and SKILL.md
359
+ try {
360
+ mkdirSync(skillDir, { recursive: true, mode: 0o700 });
361
+ writeFileSync(skillFilePath, skillContent, { mode: 0o600 });
362
+ }
363
+ catch (err) {
364
+ const errMsg = err instanceof Error ? err.message : String(err);
365
+ if (!jsonMode) {
366
+ console.log(` ${pc.red('✗')} ${agent.name} ${pc.dim(`(${errMsg})`)}`);
367
+ }
368
+ addError(`Failed to create skill for ${agent.name}: ${errMsg}`);
369
+ continue;
370
+ }
371
+ // Create optional directories (non-fatal — SKILL.md is already written)
372
+ for (const dir of optionalDirs) {
373
+ try {
374
+ mkdirSync(join(skillDir, dir), { recursive: true, mode: 0o700 });
375
+ }
376
+ catch (err) {
377
+ const errMsg = err instanceof Error ? err.message : String(err);
378
+ if (!jsonMode) {
379
+ console.log(` ${pc.yellow('!')} ${agent.name}: failed to create ${dir}/ ${pc.dim(`(${errMsg})`)}`);
380
+ }
381
+ jsonOutput.errors.push(`Failed to create ${dir}/ for ${agent.name}: ${errMsg}`);
382
+ }
383
+ }
384
+ const displayPath = `${pathPrefix}/${agent.dir}/${skillName}`;
385
+ if (!jsonMode) {
386
+ console.log(` ${pc.green('✓')} ${agent.name} ${pc.dim(`→ ${displayPath}`)}`);
387
+ }
388
+ jsonOutput.created.push({
389
+ skill: skillName,
390
+ agent: agent.name,
391
+ path: skillDir,
392
+ });
393
+ created++;
394
+ }
395
+ // Summary
396
+ if (jsonMode) {
397
+ outputJsonAndExit(jsonOutput.success ? EXIT_CODES.SUCCESS : EXIT_CODES.GENERAL_ERROR);
398
+ }
399
+ console.log();
400
+ if (created > 0) {
401
+ p.log.success(`Created ${pc.bold(skillName)}`);
402
+ console.log();
403
+ console.log(pc.dim(' Next steps:'));
404
+ console.log(pc.dim(` 1. Edit ${SKILL_FILENAME} to add your instructions`));
405
+ if (optionalDirs.length > 0) {
406
+ console.log(pc.dim(` 2. Add files to ${optionalDirs.map((d) => `${d}/`).join(', ')}`));
407
+ console.log(pc.dim(' 3. Test with your AI agent'));
408
+ console.log(pc.dim(' 4. Share on skill.fish or GitHub'));
409
+ }
410
+ else {
411
+ console.log(pc.dim(' 2. Test with your AI agent'));
412
+ console.log(pc.dim(' 3. Share on skill.fish or GitHub'));
413
+ }
414
+ console.log();
415
+ p.outro(pc.green(`Done! Created ${created} skill${created === 1 ? '' : 's'}`));
416
+ }
417
+ else if (skipped > 0) {
418
+ p.outro(pc.yellow(`Skill "${skillName}" already exists for all selected agents`));
419
+ }
420
+ else {
421
+ p.outro(pc.yellow('No skills created'));
422
+ }
423
+ process.exit(created > 0 || skipped > 0 ? EXIT_CODES.SUCCESS : EXIT_CODES.GENERAL_ERROR);
424
+ });
425
+ // === Helper Functions ===
426
+ async function selectInstallLocation(projectFlag, globalFlag, jsonMode) {
427
+ // If flag specified, use it
428
+ if (projectFlag) {
429
+ if (!jsonMode) {
430
+ p.log.info(`Location: ${pc.cyan('Project')} ${pc.dim('(current directory)')}`);
431
+ }
432
+ return process.cwd();
433
+ }
434
+ if (globalFlag) {
435
+ if (!jsonMode) {
436
+ p.log.info(`Location: ${pc.cyan('Global')} ${pc.dim('(home directory)')}`);
437
+ }
438
+ return homedir();
439
+ }
440
+ // Non-TTY or JSON mode defaults to project (more common for init)
441
+ if (!isInputTTY() || jsonMode) {
442
+ return process.cwd();
443
+ }
444
+ // Interactive selection
445
+ const location = await p.select({
446
+ message: 'Install location',
447
+ options: [
448
+ {
449
+ value: 'project',
450
+ label: 'Project',
451
+ hint: 'For this project only (recommended)',
452
+ },
453
+ {
454
+ value: 'global',
455
+ label: 'Global',
456
+ hint: 'Available in all projects',
457
+ },
458
+ ],
459
+ });
460
+ if (p.isCancel(location)) {
461
+ p.cancel('Cancelled');
462
+ process.exit(EXIT_CODES.SUCCESS);
463
+ }
464
+ return location === 'project' ? process.cwd() : homedir();
465
+ }
466
+ async function selectAgents(agents, isLocal, jsonMode) {
467
+ const pathPrefix = isLocal ? '.' : '~';
468
+ // Show detected agents
469
+ if (!jsonMode) {
470
+ p.log.info(`Detected ${pc.cyan(agents.length.toString())} agent${agents.length === 1 ? '' : 's'}: ${agents.map((a) => a.name).join(', ')}`);
471
+ }
472
+ const installAll = await p.confirm({
473
+ message: 'Create for all detected agents?',
474
+ initialValue: true,
475
+ });
476
+ if (p.isCancel(installAll)) {
477
+ p.cancel('Cancelled');
478
+ process.exit(EXIT_CODES.SUCCESS);
479
+ }
480
+ if (installAll) {
481
+ return agents;
482
+ }
483
+ // User wants to choose specific agents
484
+ const options = agents.map((a) => ({
485
+ value: a.name,
486
+ label: a.name,
487
+ hint: `${pathPrefix}/${a.dir}`,
488
+ }));
489
+ const selected = await p.multiselect({
490
+ message: 'Select agents',
491
+ options,
492
+ required: true,
493
+ });
494
+ if (p.isCancel(selected)) {
495
+ p.cancel('Cancelled');
496
+ process.exit(EXIT_CODES.SUCCESS);
497
+ }
498
+ return agents.filter((a) => selected.includes(a.name));
499
+ }
@@ -9,7 +9,7 @@ import pc from 'picocolors';
9
9
  import { getDetectedAgents, getAgentSkillDir } from '../lib/agents.js';
10
10
  import { listInstalledSkillsInDir, installSkill } from '../lib/installer.js';
11
11
  import { readManifest } from '../lib/manifest.js';
12
- import { fetchTreeSha, RateLimitError, RepoNotFoundError, NetworkError, GitHubApiError, } from '../lib/github.js';
12
+ import { fetchRecursiveTree, getSkillSha, RateLimitError, RepoNotFoundError, NetworkError, GitHubApiError, } from '../lib/github.js';
13
13
  import { EXIT_CODES } from '../lib/constants.js';
14
14
  import { isInputTTY, isTTY } from '../utils.js';
15
15
  // === Command Definition ===
@@ -260,14 +260,15 @@ function collectTrackedSkills(agents) {
260
260
  }
261
261
  /**
262
262
  * Check which tracked skills have updates available.
263
- * Caches tree SHA lookups to avoid duplicate API calls for skills from the same repo.
263
+ * Caches recursive tree lookups to avoid duplicate API calls for skills from the same repo.
264
+ * Uses directory-level SHA comparison for accurate change detection.
264
265
  */
265
266
  async function checkForUpdates(skills) {
266
267
  const outdated = [];
267
268
  const errors = [];
268
269
  let rateLimitHit = false;
269
- // Cache tree SHA lookups by owner/repo/branch to avoid duplicate API calls
270
- const shaCache = new Map();
270
+ // Cache full tree lookups by owner/repo/branch to avoid duplicate API calls
271
+ const treeCache = new Map();
271
272
  const errorCache = new Map();
272
273
  for (const skill of skills) {
273
274
  const cacheKey = `${skill.manifest.owner}/${skill.manifest.repo}/${skill.manifest.branch}`;
@@ -288,12 +289,13 @@ async function checkForUpdates(skills) {
288
289
  }
289
290
  continue;
290
291
  }
291
- // Check if we already have a cached SHA for this repo
292
- let remoteSha = shaCache.get(cacheKey);
293
- if (!remoteSha) {
292
+ // Check if we already have a cached tree for this repo
293
+ let cached = treeCache.get(cacheKey);
294
+ if (!cached) {
294
295
  try {
295
- remoteSha = await fetchTreeSha(skill.manifest.owner, skill.manifest.repo, skill.manifest.branch);
296
- shaCache.set(cacheKey, remoteSha);
296
+ const { sha, tree } = await fetchRecursiveTree(skill.manifest.owner, skill.manifest.repo, skill.manifest.branch);
297
+ cached = { rootSha: sha, tree };
298
+ treeCache.set(cacheKey, cached);
297
299
  }
298
300
  catch (err) {
299
301
  if (err instanceof RateLimitError) {
@@ -319,6 +321,9 @@ async function checkForUpdates(skills) {
319
321
  continue;
320
322
  }
321
323
  }
324
+ // Get directory-specific SHA for accurate change detection
325
+ // Falls back to root SHA for backward compatibility with old manifests
326
+ const remoteSha = getSkillSha(cached.tree, skill.manifest.path) ?? cached.rootSha;
322
327
  if (skill.manifest.sha !== remoteSha) {
323
328
  outdated.push({ ...skill, remoteSha });
324
329
  }
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ import { fileURLToPath } from 'url';
10
10
  import { dirname, join } from 'path';
11
11
  import updateNotifier from 'update-notifier';
12
12
  import { addCommand } from './commands/add.js';
13
+ import { initCommand } from './commands/init.js';
13
14
  import { listCommand } from './commands/list.js';
14
15
  import { removeCommand } from './commands/remove.js';
15
16
  import { updateCommand } from './commands/update.js';
@@ -37,6 +38,7 @@ const program = new Command()
37
38
  Examples:
38
39
  $ skillfish add owner/repo Install skills from a repository
39
40
  $ skillfish add owner/repo/plugin/skill Install a specific skill
41
+ $ skillfish init Create a new skill template
40
42
  $ skillfish list Show installed skills
41
43
  $ skillfish remove my-skill Remove a skill
42
44
 
@@ -47,6 +49,7 @@ program.hook('preAction', (thisCommand) => {
47
49
  });
48
50
  // Add subcommands
49
51
  program.addCommand(addCommand);
52
+ program.addCommand(initCommand);
50
53
  program.addCommand(listCommand);
51
54
  program.addCommand(removeCommand);
52
55
  program.addCommand(updateCommand);
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * GitHub API functions for skill discovery and fetching.
3
3
  */
4
+ import { type GitTreeItem } from '../utils.js';
4
5
  export declare const SKILL_FILENAME = "SKILL.md";
5
6
  /**
6
7
  * Result of skill discovery including branch and SHA information.
@@ -8,9 +9,20 @@ export declare const SKILL_FILENAME = "SKILL.md";
8
9
  export interface SkillDiscoveryResult {
9
10
  paths: string[];
10
11
  branch: string;
11
- /** Tree SHA from git/trees response - changes when any file in repo changes */
12
+ /** Root tree SHA from git/trees response - changes when any file in repo changes */
12
13
  sha: string;
14
+ /** Raw tree items for directory-level SHA lookups */
15
+ tree: GitTreeItem[];
13
16
  }
17
+ /**
18
+ * Get the SHA for a skill path from a git tree.
19
+ * - For subdirectory skills: returns the directory's tree SHA
20
+ * - For root-level skills: returns the SKILL.md blob SHA
21
+ *
22
+ * This enables directory-level change detection instead of repo-level,
23
+ * reducing false "outdated" notifications when unrelated files change.
24
+ */
25
+ export declare function getSkillSha(tree: GitTreeItem[], skillPath: string): string | undefined;
14
26
  /**
15
27
  * Thrown when GitHub API rate limit is exceeded.
16
28
  */
@@ -67,6 +79,19 @@ export declare function fetchSkillMdContent(owner: string, repo: string, path: s
67
79
  * @throws {GitHubApiError} When the API response format is unexpected
68
80
  */
69
81
  export declare function fetchTreeSha(owner: string, repo: string, branch: string): Promise<string>;
82
+ /**
83
+ * Fetch the recursive tree for a repository branch.
84
+ * Returns both the root SHA and the full tree for directory-level SHA lookups.
85
+ *
86
+ * @throws {RepoNotFoundError} When the repository is not found
87
+ * @throws {RateLimitError} When GitHub API rate limit is exceeded
88
+ * @throws {NetworkError} On network errors
89
+ * @throws {GitHubApiError} When the API response format is unexpected
90
+ */
91
+ export declare function fetchRecursiveTree(owner: string, repo: string, branch: string): Promise<{
92
+ sha: string;
93
+ tree: GitTreeItem[];
94
+ }>;
70
95
  /**
71
96
  * Find all SKILL.md files in a GitHub repository.
72
97
  * Fetches the default branch, then searches for skills on that branch.
@@ -7,6 +7,26 @@ const API_TIMEOUT_MS = 10000;
7
7
  const MAX_RETRIES = 3;
8
8
  const RETRY_DELAYS_MS = [1000, 2000, 4000]; // Exponential backoff
9
9
  export const SKILL_FILENAME = 'SKILL.md';
10
+ // === Helper Functions ===
11
+ /**
12
+ * Get the SHA for a skill path from a git tree.
13
+ * - For subdirectory skills: returns the directory's tree SHA
14
+ * - For root-level skills: returns the SKILL.md blob SHA
15
+ *
16
+ * This enables directory-level change detection instead of repo-level,
17
+ * reducing false "outdated" notifications when unrelated files change.
18
+ */
19
+ export function getSkillSha(tree, skillPath) {
20
+ // Root-level skill - use the blob SHA of SKILL.md itself
21
+ if (skillPath === SKILL_FILENAME) {
22
+ const blob = tree.find((item) => item.path === SKILL_FILENAME && item.type === 'blob');
23
+ return blob?.sha;
24
+ }
25
+ // Subdirectory skill - use the directory's tree SHA
26
+ const dirPath = skillPath.replace(/\/SKILL\.md$/, '');
27
+ const dir = tree.find((item) => item.path === dirPath && item.type === 'tree');
28
+ return dir?.sha;
29
+ }
10
30
  // === Error Types ===
11
31
  /**
12
32
  * Thrown when GitHub API rate limit is exceeded.
@@ -208,25 +228,25 @@ export async function fetchTreeSha(owner, repo, branch) {
208
228
  }
209
229
  }
210
230
  /**
211
- * Find all SKILL.md files in a GitHub repository.
212
- * Fetches the default branch, then searches for skills on that branch.
231
+ * Fetch the recursive tree for a repository branch.
232
+ * Returns both the root SHA and the full tree for directory-level SHA lookups.
213
233
  *
214
- * @returns SkillDiscoveryResult with paths and the branch they were found on
215
- * @throws {RateLimitError} When GitHub API rate limit is exceeded
216
234
  * @throws {RepoNotFoundError} When the repository is not found
217
- * @throws {NetworkError} On network errors (timeout, connection refused)
235
+ * @throws {RateLimitError} When GitHub API rate limit is exceeded
236
+ * @throws {NetworkError} On network errors
218
237
  * @throws {GitHubApiError} When the API response format is unexpected
219
238
  */
220
- export async function findAllSkillMdFiles(owner, repo) {
239
+ export async function fetchRecursiveTree(owner, repo, branch) {
221
240
  const headers = { 'User-Agent': 'skillfish' };
222
- // Get the default branch
223
- const branch = await fetchDefaultBranch(owner, repo);
224
241
  const controller = new AbortController();
225
242
  const timeoutId = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
226
243
  try {
227
244
  const url = `https://api.github.com/repos/${owner}/${repo}/git/trees/${branch}?recursive=1`;
228
245
  const res = await fetchWithRetry(url, { headers, signal: controller.signal });
229
246
  checkRateLimit(res);
247
+ if (res.status === 404) {
248
+ throw new RepoNotFoundError(owner, repo);
249
+ }
230
250
  if (!res.ok) {
231
251
  throw new GitHubApiError(`GitHub API returned status ${res.status}`);
232
252
  }
@@ -234,9 +254,12 @@ export async function findAllSkillMdFiles(owner, repo) {
234
254
  if (!isGitTreeResponse(rawData)) {
235
255
  throw new GitHubApiError('Unexpected response format from GitHub API.');
236
256
  }
237
- const paths = extractSkillPaths(rawData, SKILL_FILENAME);
238
- const sha = rawData.sha ?? '';
239
- return { paths, branch, sha };
257
+ const sha = rawData.sha;
258
+ if (typeof sha !== 'string' || !/^[a-f0-9]{40}$/.test(sha)) {
259
+ throw new GitHubApiError('Invalid or missing sha field in tree response');
260
+ }
261
+ const tree = rawData.tree ?? [];
262
+ return { sha, tree };
240
263
  }
241
264
  catch (err) {
242
265
  wrapApiError(err);
@@ -245,3 +268,22 @@ export async function findAllSkillMdFiles(owner, repo) {
245
268
  clearTimeout(timeoutId);
246
269
  }
247
270
  }
271
+ /**
272
+ * Find all SKILL.md files in a GitHub repository.
273
+ * Fetches the default branch, then searches for skills on that branch.
274
+ *
275
+ * @returns SkillDiscoveryResult with paths and the branch they were found on
276
+ * @throws {RateLimitError} When GitHub API rate limit is exceeded
277
+ * @throws {RepoNotFoundError} When the repository is not found
278
+ * @throws {NetworkError} On network errors (timeout, connection refused)
279
+ * @throws {GitHubApiError} When the API response format is unexpected
280
+ */
281
+ export async function findAllSkillMdFiles(owner, repo) {
282
+ // Get the default branch
283
+ const branch = await fetchDefaultBranch(owner, repo);
284
+ // Fetch the recursive tree (reuses fetchRecursiveTree to avoid duplication)
285
+ const { sha, tree } = await fetchRecursiveTree(owner, repo, branch);
286
+ // Extract SKILL.md paths from the tree
287
+ const paths = extractSkillPaths({ tree }, SKILL_FILENAME);
288
+ return { paths, branch, sha, tree };
289
+ }
package/dist/utils.d.ts CHANGED
@@ -133,6 +133,21 @@ export interface UpdateJsonOutput extends BaseJsonOutput {
133
133
  outdated: OutdatedSkill[];
134
134
  updated: InstalledSkill[];
135
135
  }
136
+ /**
137
+ * JSON output for the `init` command.
138
+ */
139
+ export interface InitJsonOutput extends BaseJsonOutput {
140
+ created: {
141
+ skill: string;
142
+ agent: string;
143
+ path: string;
144
+ }[];
145
+ skipped: {
146
+ skill: string;
147
+ agent: string;
148
+ reason: string;
149
+ }[];
150
+ }
136
151
  /** @deprecated Use AddJsonOutput instead */
137
152
  export type JsonOutput = AddJsonOutput;
138
153
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillfish",
3
- "version": "1.0.15",
3
+ "version": "1.0.17",
4
4
  "description": "All in one Skill manager for AI coding agents. Install, update, and sync Skills across Claude Code, Cursor, Copilot + more.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -76,7 +76,7 @@
76
76
  },
77
77
  "devDependencies": {
78
78
  "@eslint/js": "^9.39.2",
79
- "@types/node": "^20.0.0",
79
+ "@types/node": "^25.0.10",
80
80
  "@types/update-notifier": "^6.0.8",
81
81
  "eslint": "^9.39.2",
82
82
  "eslint-config-prettier": "^10.1.8",