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.
@@ -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;
@@ -23,6 +23,8 @@ export interface InstallOptions {
23
23
  baseDir: string;
24
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,12 +2,13 @@
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
9
  import { downloadTemplate } from 'giget';
10
10
  import { SKILL_FILENAME } from './github.js';
11
+ import { writeManifest, MANIFEST_VERSION } from './manifest.js';
11
12
  /**
12
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.
@@ -98,7 +99,7 @@ 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 {
@@ -144,6 +145,18 @@ export async function installSkill(owner, repo, skillPath, skillName, agents, op
144
145
  // Use safe copy to skip symlinks (security: prevents symlink attacks)
145
146
  const copyResult = safeCopyDir(tmpDir, destDir);
146
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
+ }
147
160
  result.installed.push({
148
161
  skill: skillName,
149
162
  agent: agent.name,
@@ -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.8",
3
+ "version": "1.0.10",
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,6 +60,13 @@
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",
@@ -61,8 +74,24 @@
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
  }