skillfish 1.0.7 → 1.0.9

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.
@@ -107,7 +107,7 @@ export async function fetchDefaultBranch(owner, repo) {
107
107
  if (!res.ok) {
108
108
  throw new GitHubApiError(`GitHub API returned status ${res.status}`);
109
109
  }
110
- const data = await res.json();
110
+ const data = (await res.json());
111
111
  if (typeof data.default_branch !== 'string' || !data.default_branch) {
112
112
  throw new GitHubApiError('Repository metadata missing or invalid default_branch field');
113
113
  }
@@ -171,6 +171,42 @@ export async function fetchSkillMdContent(owner, repo, path, branch) {
171
171
  return null;
172
172
  }
173
173
  }
174
+ /**
175
+ * Fetch the tree SHA for a repository branch.
176
+ * Used for update checks - compares stored SHA with current SHA.
177
+ *
178
+ * @throws {RepoNotFoundError} When the repository is not found
179
+ * @throws {RateLimitError} When GitHub API rate limit is exceeded
180
+ * @throws {NetworkError} On network errors
181
+ * @throws {GitHubApiError} When the API response format is unexpected
182
+ */
183
+ export async function fetchTreeSha(owner, repo, branch) {
184
+ const headers = { 'User-Agent': 'skillfish' };
185
+ const controller = new AbortController();
186
+ const timeoutId = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
187
+ try {
188
+ const url = `https://api.github.com/repos/${owner}/${repo}/git/trees/${branch}`;
189
+ const res = await fetchWithRetry(url, { headers, signal: controller.signal });
190
+ checkRateLimit(res);
191
+ if (res.status === 404) {
192
+ throw new RepoNotFoundError(owner, repo);
193
+ }
194
+ if (!res.ok) {
195
+ throw new GitHubApiError(`GitHub API returned status ${res.status}`);
196
+ }
197
+ const data = (await res.json());
198
+ if (typeof data.sha !== 'string' || !/^[a-f0-9]{40}$/.test(data.sha)) {
199
+ throw new GitHubApiError('Invalid or missing sha field in tree response');
200
+ }
201
+ return data.sha;
202
+ }
203
+ catch (err) {
204
+ wrapApiError(err);
205
+ }
206
+ finally {
207
+ clearTimeout(timeoutId);
208
+ }
209
+ }
174
210
  /**
175
211
  * Find all SKILL.md files in a GitHub repository.
176
212
  * Fetches the default branch, then searches for skills on that branch.
@@ -199,7 +235,8 @@ export async function findAllSkillMdFiles(owner, repo) {
199
235
  throw new GitHubApiError('Unexpected response format from GitHub API.');
200
236
  }
201
237
  const paths = extractSkillPaths(rawData, SKILL_FILENAME);
202
- return { paths, branch };
238
+ const sha = rawData.sha ?? '';
239
+ return { paths, branch, sha };
203
240
  }
204
241
  catch (err) {
205
242
  wrapApiError(err);
@@ -4,16 +4,16 @@
4
4
  */
5
5
  import type { Agent } from './agents.js';
6
6
  export interface InstallResult {
7
- installed: Array<{
7
+ installed: {
8
8
  skill: string;
9
9
  agent: string;
10
10
  path: string;
11
- }>;
12
- skipped: Array<{
11
+ }[];
12
+ skipped: {
13
13
  skill: string;
14
14
  agent: string;
15
15
  reason: string;
16
- }>;
16
+ }[];
17
17
  warnings: string[];
18
18
  failed: boolean;
19
19
  failureReason?: string;
@@ -21,8 +21,10 @@ 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. */
24
+ /** Branch to clone from. If not specified, giget will use the repository's default branch. */
25
25
  branch?: string;
26
+ /** Tree SHA for manifest tracking. If provided, .skillfish.json will be written. */
27
+ sha?: string;
26
28
  }
27
29
  export interface CopyResult {
28
30
  warnings: string[];
@@ -2,22 +2,23 @@
2
2
  * Skill installation logic.
3
3
  * Handles downloading, validating, and installing skills to agent directories.
4
4
  */
5
- import { existsSync, mkdirSync, cpSync, rmSync, lstatSync, readdirSync, } from 'fs';
5
+ import { existsSync, mkdirSync, cpSync, rmSync, lstatSync, readdirSync } from 'fs';
6
6
  import { homedir } from 'os';
7
7
  import { join } from 'path';
8
8
  import { randomUUID } from 'crypto';
9
- import degit from 'degit';
9
+ import { downloadTemplate } from 'giget';
10
10
  import { SKILL_FILENAME } from './github.js';
11
+ import { writeManifest, MANIFEST_VERSION } from './manifest.js';
11
12
  /**
12
- * Validates a branch name for safe use in degit paths.
13
+ * Validates a branch name for safe use in giget source strings.
13
14
  * 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
+ * We explicitly reject '#' which is used as a delimiter in giget syntax.
15
16
  */
16
17
  function isValidBranchName(branch) {
17
18
  if (!branch || branch.length > 255)
18
19
  return false;
19
20
  // Allow alphanumerics, dots, hyphens, underscores, and slashes (for feature branches)
20
- // Reject anything else, especially '#' which would break degit parsing
21
+ // Reject anything else, especially '#' which would break giget parsing
21
22
  return /^[\w./-]+$/.test(branch) && !branch.includes('#');
22
23
  }
23
24
  // === Error Types ===
@@ -98,24 +99,28 @@ export async function installSkill(owner, repo, skillPath, skillName, agents, op
98
99
  warnings: [],
99
100
  failed: false,
100
101
  };
101
- const { force, baseDir, branch } = options;
102
+ const { force, baseDir, branch, sha } = options;
102
103
  const tmpDir = join(homedir(), '.cache', 'skillfish', `${owner}-${repo}-${randomUUID()}`);
103
104
  mkdirSync(tmpDir, { recursive: true, mode: 0o700 });
104
105
  try {
105
- // Download skill
106
- // Build degit path: owner/repo[/subpath][#branch]
106
+ // Download skill using giget (tarball-based, works reliably on all repo sizes)
107
+ // Build giget source: github:owner/repo[/subpath][#branch]
107
108
  const downloadPath = skillPath === SKILL_FILENAME ? '' : skillPath;
108
- let degitPath = downloadPath ? `${owner}/${repo}/${downloadPath}` : `${owner}/${repo}`;
109
+ let source = downloadPath
110
+ ? `github:${owner}/${repo}/${downloadPath}`
111
+ : `github:${owner}/${repo}`;
109
112
  // Append branch if specified (critical for repos with non-standard default branches like 'canary')
110
113
  // Validate branch name to prevent injection attacks via malformed branch names
111
114
  if (branch) {
112
115
  if (!isValidBranchName(branch)) {
113
116
  throw new Error(`Invalid branch name: ${branch}`);
114
117
  }
115
- degitPath = `${degitPath}#${branch}`;
118
+ source = `${source}#${branch}`;
116
119
  }
117
- const emitter = degit(degitPath, { cache: false, force: true });
118
- await emitter.clone(tmpDir);
120
+ await downloadTemplate(source, {
121
+ dir: tmpDir,
122
+ forceClean: true,
123
+ });
119
124
  // Validate download
120
125
  const skillMdPath = join(tmpDir, SKILL_FILENAME);
121
126
  if (!existsSync(skillMdPath)) {
@@ -140,6 +145,18 @@ export async function installSkill(owner, repo, skillPath, skillName, agents, op
140
145
  // Use safe copy to skip symlinks (security: prevents symlink attacks)
141
146
  const copyResult = safeCopyDir(tmpDir, destDir);
142
147
  result.warnings.push(...copyResult.warnings.map((w) => `${skillName}: ${w}`));
148
+ // Write manifest for tracking if SHA is provided
149
+ if (sha && branch) {
150
+ const manifest = {
151
+ version: MANIFEST_VERSION,
152
+ owner,
153
+ repo,
154
+ path: skillPath === SKILL_FILENAME ? '.' : skillPath,
155
+ branch,
156
+ sha,
157
+ };
158
+ writeManifest(destDir, manifest);
159
+ }
143
160
  result.installed.push({
144
161
  skill: skillName,
145
162
  agent: agent.name,
@@ -154,13 +171,8 @@ export async function installSkill(owner, repo, skillPath, skillName, agents, op
154
171
  }
155
172
  else {
156
173
  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')) {
174
+ // Provide more helpful error messages for common failures
175
+ if (errMsg.includes('404') || errMsg.includes('Not Found')) {
164
176
  result.failureReason = `Repository or path not found: ${owner}/${repo}${skillPath !== SKILL_FILENAME ? `/${skillPath}` : ''}`;
165
177
  }
166
178
  else {
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Manifest handling for skill tracking.
3
+ * Each installed skill has a .skillfish.json file that tracks its origin.
4
+ */
5
+ export declare const MANIFEST_FILENAME = ".skillfish.json";
6
+ export declare const MANIFEST_VERSION = 1;
7
+ /**
8
+ * Manifest schema for tracking installed skills.
9
+ * Stored in .skillfish.json within each skill directory.
10
+ */
11
+ export interface SkillManifest {
12
+ /** Schema version for future migrations */
13
+ version: 1;
14
+ /** GitHub repository owner */
15
+ owner: string;
16
+ /** GitHub repository name */
17
+ repo: string;
18
+ /** Path within repo (e.g., "skills/my-skill" or ".") */
19
+ path: string;
20
+ /** Branch at install time */
21
+ branch: string;
22
+ /** Tree SHA at install time (from git/trees response) */
23
+ sha: string;
24
+ }
25
+ /**
26
+ * Read manifest from a skill directory.
27
+ *
28
+ * @param skillDir - Path to the skill directory
29
+ * @returns Parsed manifest or null if not found/invalid
30
+ */
31
+ export declare function readManifest(skillDir: string): SkillManifest | null;
32
+ /**
33
+ * Write manifest to a skill directory.
34
+ *
35
+ * @param skillDir - Path to the skill directory
36
+ * @param manifest - Manifest data to write
37
+ */
38
+ export declare function writeManifest(skillDir: string, manifest: SkillManifest): void;
39
+ /**
40
+ * Check if a skill directory has a manifest.
41
+ *
42
+ * @param skillDir - Path to the skill directory
43
+ * @returns true if manifest exists
44
+ */
45
+ export declare function hasManifest(skillDir: string): boolean;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Manifest handling for skill tracking.
3
+ * Each installed skill has a .skillfish.json file that tracks its origin.
4
+ */
5
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
6
+ import { join } from 'path';
7
+ import { isValidName } from './constants.js';
8
+ import { isValidPath } from '../utils.js';
9
+ // === Constants ===
10
+ export const MANIFEST_FILENAME = '.skillfish.json';
11
+ export const MANIFEST_VERSION = 1;
12
+ /** Git SHA format: 40 hexadecimal characters */
13
+ const SHA_PATTERN = /^[a-f0-9]{40}$/;
14
+ /** Git branch name pattern (alphanumerics, dots, hyphens, underscores, slashes) */
15
+ const BRANCH_PATTERN = /^[\w./-]+$/;
16
+ // === Functions ===
17
+ /**
18
+ * Read manifest from a skill directory.
19
+ *
20
+ * @param skillDir - Path to the skill directory
21
+ * @returns Parsed manifest or null if not found/invalid
22
+ */
23
+ export function readManifest(skillDir) {
24
+ const manifestPath = join(skillDir, MANIFEST_FILENAME);
25
+ if (!existsSync(manifestPath)) {
26
+ return null;
27
+ }
28
+ try {
29
+ const content = readFileSync(manifestPath, 'utf-8');
30
+ const data = JSON.parse(content);
31
+ // Validate manifest structure
32
+ if (!isValidManifest(data)) {
33
+ return null;
34
+ }
35
+ return data;
36
+ }
37
+ catch {
38
+ // JSON parse error or file read error
39
+ return null;
40
+ }
41
+ }
42
+ /**
43
+ * Write manifest to a skill directory.
44
+ *
45
+ * @param skillDir - Path to the skill directory
46
+ * @param manifest - Manifest data to write
47
+ */
48
+ export function writeManifest(skillDir, manifest) {
49
+ const manifestPath = join(skillDir, MANIFEST_FILENAME);
50
+ const content = JSON.stringify(manifest, null, 2);
51
+ writeFileSync(manifestPath, content, 'utf-8');
52
+ }
53
+ /**
54
+ * Check if a skill directory has a manifest.
55
+ *
56
+ * @param skillDir - Path to the skill directory
57
+ * @returns true if manifest exists
58
+ */
59
+ export function hasManifest(skillDir) {
60
+ return existsSync(join(skillDir, MANIFEST_FILENAME));
61
+ }
62
+ /**
63
+ * Type guard to validate manifest structure and content.
64
+ * Validates both field types and content to prevent tampered manifests
65
+ * from causing unintended API requests or file operations.
66
+ */
67
+ function isValidManifest(data) {
68
+ if (typeof data !== 'object' || data === null) {
69
+ return false;
70
+ }
71
+ const obj = data;
72
+ // Check types first
73
+ if (obj.version !== MANIFEST_VERSION ||
74
+ typeof obj.owner !== 'string' ||
75
+ typeof obj.repo !== 'string' ||
76
+ typeof obj.path !== 'string' ||
77
+ typeof obj.branch !== 'string' ||
78
+ typeof obj.sha !== 'string') {
79
+ return false;
80
+ }
81
+ // Validate content to prevent tampered manifests
82
+ const { owner, repo, path, branch, sha } = obj;
83
+ // Owner and repo must be valid GitHub names
84
+ if (!isValidName(owner) || !isValidName(repo)) {
85
+ return false;
86
+ }
87
+ // Path must be safe (no traversal) - "." is valid for root
88
+ if (path !== '.' && !isValidPath(path)) {
89
+ return false;
90
+ }
91
+ // Branch must match git branch pattern
92
+ if (!BRANCH_PATTERN.test(branch) || branch.length > 255) {
93
+ return false;
94
+ }
95
+ // SHA must be valid git SHA format (40 hex chars)
96
+ if (!SHA_PATTERN.test(sha)) {
97
+ return false;
98
+ }
99
+ return true;
100
+ }
package/dist/utils.d.ts CHANGED
@@ -10,23 +10,23 @@ export declare function isValidPath(pathStr: string): boolean;
10
10
  /**
11
11
  * Type for GitHub tree API item.
12
12
  */
13
- export type GitTreeItem = {
13
+ export interface GitTreeItem {
14
14
  path: string;
15
15
  type: string;
16
16
  mode?: string;
17
17
  sha?: string;
18
18
  size?: number;
19
19
  url?: string;
20
- };
20
+ }
21
21
  /**
22
22
  * Type for GitHub tree API response.
23
23
  */
24
- export type GitTreeResponse = {
24
+ export interface GitTreeResponse {
25
25
  tree?: GitTreeItem[];
26
26
  sha?: string;
27
27
  url?: string;
28
28
  truncated?: boolean;
29
- };
29
+ }
30
30
  /**
31
31
  * Type guard for GitHub tree API response.
32
32
  * Validates the response structure at runtime.
@@ -94,11 +94,11 @@ export interface BaseJsonOutput {
94
94
  */
95
95
  export interface AddJsonOutput extends BaseJsonOutput {
96
96
  installed: InstalledSkill[];
97
- skipped: Array<{
97
+ skipped: {
98
98
  skill: string;
99
99
  agent: string;
100
100
  reason: string;
101
- }>;
101
+ }[];
102
102
  skills_found?: string[];
103
103
  }
104
104
  /**
@@ -114,6 +114,25 @@ export interface ListJsonOutput extends BaseJsonOutput {
114
114
  export interface RemoveJsonOutput extends BaseJsonOutput {
115
115
  removed: InstalledSkill[];
116
116
  }
117
+ /**
118
+ * Outdated skill information for update command.
119
+ */
120
+ export interface OutdatedSkill {
121
+ skill: string;
122
+ agent: string;
123
+ path: string;
124
+ location: 'global' | 'project';
125
+ localSha: string;
126
+ remoteSha: string;
127
+ source: string;
128
+ }
129
+ /**
130
+ * JSON output for the `update` command.
131
+ */
132
+ export interface UpdateJsonOutput extends BaseJsonOutput {
133
+ outdated: OutdatedSkill[];
134
+ updated: InstalledSkill[];
135
+ }
117
136
  /** @deprecated Use AddJsonOutput instead */
118
137
  export type JsonOutput = AddJsonOutput;
119
138
  /**
package/dist/utils.js CHANGED
@@ -86,9 +86,7 @@ export function deriveSkillName(skillPath, repoName) {
86
86
  * "my_cool_skill" → "My Cool Skill"
87
87
  */
88
88
  export function toTitleCase(str) {
89
- return str
90
- .replace(/[-_]/g, ' ')
91
- .replace(/\b\w/g, char => char.toUpperCase());
89
+ return str.replace(/[-_]/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase());
92
90
  }
93
91
  /**
94
92
  * Truncate text to a maximum length, adding ellipsis if needed.
@@ -105,14 +103,14 @@ export function extractSkillPaths(data, skillFilename = 'SKILL.md') {
105
103
  if (!data.tree)
106
104
  return [];
107
105
  return data.tree
108
- .filter(item => item.type === 'blob' && item.path.endsWith(skillFilename))
109
- .map(item => item.path);
106
+ .filter((item) => item.type === 'blob' && item.path.endsWith(skillFilename))
107
+ .map((item) => item.path);
110
108
  }
111
109
  /**
112
110
  * Sleep for a specified duration.
113
111
  */
114
112
  export function sleep(ms) {
115
- return new Promise(resolve => setTimeout(resolve, ms));
113
+ return new Promise((resolve) => setTimeout(resolve, ms));
116
114
  }
117
115
  /**
118
116
  * Process items with bounded concurrency.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillfish",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
4
4
  "description": "Install AI agent skills from GitHub with a single command",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,9 +24,15 @@
24
24
  "scripts": {
25
25
  "build": "tsc",
26
26
  "dev": "tsc --watch",
27
- "prepublishOnly": "npm run build",
27
+ "prepublishOnly": "npm run lint && npm test && npm run build",
28
28
  "test": "vitest run",
29
- "test:watch": "vitest"
29
+ "test:watch": "vitest",
30
+ "lint": "eslint .",
31
+ "lint:fix": "eslint . --fix",
32
+ "format": "prettier --write .",
33
+ "format:check": "prettier --check .",
34
+ "typecheck": "tsc --noEmit",
35
+ "prepare": "husky"
30
36
  },
31
37
  "keywords": [
32
38
  "cli",
@@ -54,15 +60,38 @@
54
60
  "author": "Graeme Knox",
55
61
  "license": "AGPL-3.0",
56
62
  "homepage": "https://skill.fish",
63
+ "funding": {
64
+ "type": "github",
65
+ "url": "https://github.com/sponsors/knoxgraeme"
66
+ },
67
+ "publishConfig": {
68
+ "access": "public"
69
+ },
57
70
  "dependencies": {
58
71
  "@clack/prompts": "^0.11.0",
59
72
  "commander": "^14.0.2",
60
- "degit": "^2.8.4",
73
+ "giget": "^3.1.1",
61
74
  "picocolors": "^1.1.1"
62
75
  },
63
76
  "devDependencies": {
77
+ "@eslint/js": "^9.39.2",
64
78
  "@types/node": "^20.0.0",
79
+ "eslint": "^9.39.2",
80
+ "eslint-config-prettier": "^10.1.8",
81
+ "husky": "^9.1.7",
82
+ "lint-staged": "^16.2.7",
83
+ "prettier": "^3.8.1",
65
84
  "typescript": "^5.4.0",
85
+ "typescript-eslint": "^8.53.1",
66
86
  "vitest": "^4.0.17"
87
+ },
88
+ "lint-staged": {
89
+ "*.ts": [
90
+ "eslint --fix",
91
+ "prettier --write"
92
+ ],
93
+ "*.{json,yml,yaml}": [
94
+ "prettier --write"
95
+ ]
67
96
  }
68
97
  }