skillfish 1.0.0

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.
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Skill installation logic.
3
+ * Handles downloading, validating, and installing skills to agent directories.
4
+ */
5
+ import { existsSync, mkdirSync, cpSync, rmSync, lstatSync, readdirSync, } from 'fs';
6
+ import { homedir } from 'os';
7
+ import { join } from 'path';
8
+ import { randomUUID } from 'crypto';
9
+ import degit from 'degit';
10
+ import { SKILL_FILENAME } from './github.js';
11
+ // === Error Types ===
12
+ /**
13
+ * Thrown when SKILL.md is not found in downloaded content.
14
+ */
15
+ export class SkillMdNotFoundError extends Error {
16
+ skillPath;
17
+ constructor(skillPath) {
18
+ super(`${SKILL_FILENAME} not found in downloaded content. Path may be incorrect.`);
19
+ this.skillPath = skillPath;
20
+ this.name = 'SkillMdNotFoundError';
21
+ }
22
+ }
23
+ // === Functions ===
24
+ /**
25
+ * Recursively copies a directory while skipping symlinks for security.
26
+ * This prevents symlink attacks where malicious repos could link to sensitive files.
27
+ *
28
+ * SECURITY: Uses double-check pattern to minimize TOCTOU race window.
29
+ * The second lstatSync check immediately before cpSync reduces (but doesn't
30
+ * eliminate) the window for a race condition attack.
31
+ *
32
+ * @returns CopyResult with any warnings generated during copy
33
+ */
34
+ export function safeCopyDir(src, dest) {
35
+ const warnings = [];
36
+ function copyRecursive(srcPath, destPath) {
37
+ mkdirSync(destPath, { recursive: true, mode: 0o700 });
38
+ const entries = readdirSync(srcPath, { withFileTypes: true });
39
+ for (const entry of entries) {
40
+ const entrySrc = join(srcPath, entry.name);
41
+ const entryDest = join(destPath, entry.name);
42
+ // First check: Skip symlinks for security
43
+ if (entry.isSymbolicLink()) {
44
+ warnings.push(`Skipped symlink: ${entry.name}`);
45
+ continue;
46
+ }
47
+ if (entry.isDirectory()) {
48
+ copyRecursive(entrySrc, entryDest);
49
+ }
50
+ else if (entry.isFile()) {
51
+ // SECURITY: Second check immediately before copy to minimize TOCTOU window
52
+ // This doesn't eliminate the race but significantly reduces the attack window
53
+ try {
54
+ const stat = lstatSync(entrySrc);
55
+ if (stat.isSymbolicLink()) {
56
+ warnings.push(`Skipped symlink (detected on copy): ${entry.name}`);
57
+ continue;
58
+ }
59
+ cpSync(entrySrc, entryDest);
60
+ }
61
+ catch (err) {
62
+ // File may have been removed/changed between readdir and copy
63
+ warnings.push(`Could not copy ${entry.name}: ${err instanceof Error ? err.message : 'unknown error'}`);
64
+ }
65
+ }
66
+ }
67
+ }
68
+ copyRecursive(src, dest);
69
+ return { warnings };
70
+ }
71
+ /**
72
+ * Download and install a skill to multiple agent directories.
73
+ *
74
+ * @param owner - GitHub repository owner
75
+ * @param repo - GitHub repository name
76
+ * @param skillPath - Path to skill within repository (or SKILL.md for root)
77
+ * @param skillName - Name to use for the installed skill directory
78
+ * @param agents - List of agents to install to
79
+ * @param options - Installation options (force, baseDir)
80
+ * @returns InstallResult with details of what was installed/skipped
81
+ */
82
+ export async function installSkill(owner, repo, skillPath, skillName, agents, options) {
83
+ const result = {
84
+ installed: [],
85
+ skipped: [],
86
+ warnings: [],
87
+ failed: false,
88
+ };
89
+ const { force, baseDir } = options;
90
+ const tmpDir = join(homedir(), '.cache', 'skillfish', `${owner}-${repo}-${randomUUID()}`);
91
+ mkdirSync(tmpDir, { recursive: true, mode: 0o700 });
92
+ try {
93
+ // Download skill
94
+ const downloadPath = skillPath === SKILL_FILENAME ? '' : skillPath;
95
+ const degitPath = downloadPath ? `${owner}/${repo}/${downloadPath}` : `${owner}/${repo}`;
96
+ const emitter = degit(degitPath, { cache: false, force: true });
97
+ await emitter.clone(tmpDir);
98
+ // Validate download
99
+ const skillMdPath = join(tmpDir, SKILL_FILENAME);
100
+ if (!existsSync(skillMdPath)) {
101
+ throw new SkillMdNotFoundError(skillPath);
102
+ }
103
+ // Copy to each agent directory
104
+ for (const agent of agents) {
105
+ const destDir = join(baseDir, agent.dir, skillName);
106
+ if (existsSync(destDir) && !force) {
107
+ result.skipped.push({
108
+ skill: skillName,
109
+ agent: agent.name,
110
+ reason: 'Already exists (use --force to overwrite)',
111
+ });
112
+ continue;
113
+ }
114
+ // Create parent directory and remove existing if force
115
+ mkdirSync(join(baseDir, agent.dir), { recursive: true, mode: 0o700 });
116
+ if (existsSync(destDir)) {
117
+ rmSync(destDir, { recursive: true });
118
+ }
119
+ // Use safe copy to skip symlinks (security: prevents symlink attacks)
120
+ const copyResult = safeCopyDir(tmpDir, destDir);
121
+ result.warnings.push(...copyResult.warnings.map((w) => `${skillName}: ${w}`));
122
+ result.installed.push({
123
+ skill: skillName,
124
+ agent: agent.name,
125
+ path: destDir,
126
+ });
127
+ }
128
+ }
129
+ catch (err) {
130
+ result.failed = true;
131
+ if (err instanceof SkillMdNotFoundError) {
132
+ result.failureReason = err.message;
133
+ }
134
+ else {
135
+ result.failureReason = err instanceof Error ? err.message : String(err);
136
+ }
137
+ }
138
+ finally {
139
+ rmSync(tmpDir, { recursive: true, force: true });
140
+ }
141
+ return result;
142
+ }
143
+ /**
144
+ * List installed skills for a given agent directory.
145
+ *
146
+ * @param skillDir - Path to the agent's skills directory
147
+ * @returns Array of skill names that have a valid SKILL.md
148
+ */
149
+ export function listInstalledSkillsInDir(skillDir) {
150
+ if (!existsSync(skillDir)) {
151
+ return [];
152
+ }
153
+ try {
154
+ return readdirSync(skillDir, { withFileTypes: true })
155
+ .filter((entry) => entry.isDirectory())
156
+ .filter((entry) => existsSync(join(skillDir, entry.name, SKILL_FILENAME)))
157
+ .map((entry) => entry.name);
158
+ }
159
+ catch {
160
+ // Directory might not be readable
161
+ return [];
162
+ }
163
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Track a skill install. Fire-and-forget - never blocks or throws.
3
+ *
4
+ * NOTE: Due to Node.js event loop behavior, this request may not complete
5
+ * if the CLI process exits immediately after calling. This is acceptable
6
+ * for directional metrics (like npm download counts).
7
+ *
8
+ * @param github Full GitHub path (e.g., owner/repo/path/to/skill)
9
+ */
10
+ export declare function trackInstall(github: string): void;
@@ -0,0 +1,27 @@
1
+ const TELEMETRY_URL = 'https://mcpmarket.com/api/telemetry';
2
+ /**
3
+ * Track a skill install. Fire-and-forget - never blocks or throws.
4
+ *
5
+ * NOTE: Due to Node.js event loop behavior, this request may not complete
6
+ * if the CLI process exits immediately after calling. This is acceptable
7
+ * for directional metrics (like npm download counts).
8
+ *
9
+ * @param github Full GitHub path (e.g., owner/repo/path/to/skill)
10
+ */
11
+ export function trackInstall(github) {
12
+ try {
13
+ if (process.env.DO_NOT_TRACK === '1' || process.env.CI === 'true')
14
+ return;
15
+ if (!github || github.length > 500)
16
+ return;
17
+ // POST with JSON body - fire and forget
18
+ fetch(TELEMETRY_URL, {
19
+ method: 'POST',
20
+ headers: { 'Content-Type': 'application/json' },
21
+ body: JSON.stringify({ github }),
22
+ }).catch(() => { });
23
+ }
24
+ catch {
25
+ // Telemetry should never throw
26
+ }
27
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Utility functions for skillfish CLI.
3
+ * These are pure functions extracted for testability.
4
+ */
5
+ /**
6
+ * Validates a path to prevent directory traversal attacks.
7
+ * Ensures path doesn't escape the intended directory.
8
+ */
9
+ export declare function isValidPath(pathStr: string): boolean;
10
+ /**
11
+ * Type for GitHub tree API item.
12
+ */
13
+ export type GitTreeItem = {
14
+ path: string;
15
+ type: string;
16
+ mode?: string;
17
+ sha?: string;
18
+ size?: number;
19
+ url?: string;
20
+ };
21
+ /**
22
+ * Type for GitHub tree API response.
23
+ */
24
+ export type GitTreeResponse = {
25
+ tree?: GitTreeItem[];
26
+ sha?: string;
27
+ url?: string;
28
+ truncated?: boolean;
29
+ };
30
+ /**
31
+ * Type guard for GitHub tree API response.
32
+ * Validates the response structure at runtime.
33
+ */
34
+ export declare function isGitTreeResponse(data: unknown): data is GitTreeResponse;
35
+ /**
36
+ * Parse YAML frontmatter from SKILL.md content.
37
+ * Extracts name and description fields with fallbacks.
38
+ */
39
+ export declare function parseFrontmatter(content: string): {
40
+ name?: string;
41
+ description?: string;
42
+ };
43
+ /**
44
+ * Derive skill name from path and repo name.
45
+ */
46
+ export declare function deriveSkillName(skillPath: string, repoName: string): string;
47
+ /**
48
+ * Convert kebab-case or snake_case to Title Case.
49
+ * "skill-lookup" → "Skill Lookup"
50
+ * "my_cool_skill" → "My Cool Skill"
51
+ */
52
+ export declare function toTitleCase(str: string): string;
53
+ /**
54
+ * Truncate text to a maximum length, adding ellipsis if needed.
55
+ */
56
+ export declare function truncate(text: string, maxLength: number): string;
57
+ /**
58
+ * Extract SKILL.md paths from validated GitHub tree response.
59
+ */
60
+ export declare function extractSkillPaths(data: GitTreeResponse, skillFilename?: string): string[];
61
+ /**
62
+ * Sleep for a specified duration.
63
+ */
64
+ export declare function sleep(ms: number): Promise<void>;
65
+ /**
66
+ * Process items with bounded concurrency.
67
+ * Prevents overwhelming resources with unbounded parallel requests.
68
+ *
69
+ * @param items - Items to process
70
+ * @param fn - Async function to apply to each item
71
+ * @param concurrency - Maximum concurrent operations (default: 10)
72
+ */
73
+ export declare function batchMap<T, R>(items: T[], fn: (item: T) => Promise<R>, concurrency?: number): Promise<R[]>;
74
+ /**
75
+ * Common installed skill structure used across commands.
76
+ */
77
+ export interface InstalledSkill {
78
+ skill: string;
79
+ agent: string;
80
+ path: string;
81
+ location?: 'global' | 'project';
82
+ }
83
+ /**
84
+ * Base JSON output with fields common to all commands.
85
+ * All command-specific types extend this for API consistency.
86
+ */
87
+ export interface BaseJsonOutput {
88
+ success: boolean;
89
+ exit_code?: number;
90
+ errors: string[];
91
+ }
92
+ /**
93
+ * JSON output for the `add` command.
94
+ */
95
+ export interface AddJsonOutput extends BaseJsonOutput {
96
+ installed: InstalledSkill[];
97
+ skipped: Array<{
98
+ skill: string;
99
+ agent: string;
100
+ reason: string;
101
+ }>;
102
+ skills_found?: string[];
103
+ }
104
+ /**
105
+ * JSON output for the `list` command.
106
+ */
107
+ export interface ListJsonOutput extends BaseJsonOutput {
108
+ installed: InstalledSkill[];
109
+ agents_detected: string[];
110
+ }
111
+ /**
112
+ * JSON output for the `remove` command.
113
+ */
114
+ export interface RemoveJsonOutput extends BaseJsonOutput {
115
+ removed: InstalledSkill[];
116
+ }
117
+ /** @deprecated Use AddJsonOutput instead */
118
+ export type JsonOutput = AddJsonOutput;
119
+ /**
120
+ * Create a fresh JSON output object for the add command.
121
+ */
122
+ export declare function createJsonOutput(): AddJsonOutput;
123
+ /**
124
+ * Check if stdout is a TTY (interactive terminal).
125
+ */
126
+ export declare function isTTY(): boolean;
127
+ /**
128
+ * Check if stdin is a TTY (interactive terminal).
129
+ */
130
+ export declare function isInputTTY(): boolean;
package/dist/utils.js ADDED
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Utility functions for skillfish CLI.
3
+ * These are pure functions extracted for testability.
4
+ */
5
+ import { normalize, isAbsolute, basename } from 'path';
6
+ /**
7
+ * Validates a path to prevent directory traversal attacks.
8
+ * Ensures path doesn't escape the intended directory.
9
+ */
10
+ export function isValidPath(pathStr) {
11
+ // Reject absolute paths
12
+ if (isAbsolute(pathStr))
13
+ return false;
14
+ // Normalize and check for directory traversal
15
+ const normalized = normalize(pathStr);
16
+ if (normalized.startsWith('..') || normalized.includes('/../'))
17
+ return false;
18
+ // Only allow alphanumeric, dots, hyphens, underscores, and forward slashes
19
+ if (!/^[\w./-]+$/.test(pathStr))
20
+ return false;
21
+ // Reject paths that could be problematic
22
+ if (pathStr.includes('//') || pathStr.startsWith('/'))
23
+ return false;
24
+ return true;
25
+ }
26
+ /**
27
+ * Type guard for GitHub tree API response.
28
+ * Validates the response structure at runtime.
29
+ */
30
+ export function isGitTreeResponse(data) {
31
+ if (typeof data !== 'object' || data === null)
32
+ return false;
33
+ const obj = data;
34
+ // tree is optional, but if present must be an array
35
+ if ('tree' in obj && obj.tree !== undefined) {
36
+ if (!Array.isArray(obj.tree))
37
+ return false;
38
+ // Validate each item has required fields
39
+ for (const item of obj.tree) {
40
+ if (typeof item !== 'object' || item === null)
41
+ return false;
42
+ const entry = item;
43
+ if (typeof entry.path !== 'string')
44
+ return false;
45
+ if (typeof entry.type !== 'string')
46
+ return false;
47
+ }
48
+ }
49
+ return true;
50
+ }
51
+ /**
52
+ * Parse YAML frontmatter from SKILL.md content.
53
+ * Extracts name and description fields with fallbacks.
54
+ */
55
+ export function parseFrontmatter(content) {
56
+ // Match frontmatter block: ---\n...\n---
57
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
58
+ if (!match)
59
+ return {};
60
+ const yaml = match[1];
61
+ // Extract name (handles quoted and unquoted values)
62
+ const nameMatch = yaml.match(/^name:\s*["']?(.+?)["']?\s*$/m);
63
+ const name = nameMatch?.[1]?.trim();
64
+ // Extract description (handles quoted and unquoted values)
65
+ const descMatch = yaml.match(/^description:\s*["']?(.+?)["']?\s*$/m);
66
+ const description = descMatch?.[1]?.trim();
67
+ return { name, description };
68
+ }
69
+ /**
70
+ * Derive skill name from path and repo name.
71
+ */
72
+ export function deriveSkillName(skillPath, repoName) {
73
+ if (skillPath === 'SKILL.md' || skillPath === './SKILL.md') {
74
+ return repoName;
75
+ }
76
+ const normalized = skillPath.replace(/\/SKILL\.md$/i, '');
77
+ const name = basename(normalized);
78
+ if (!/^[\w.-]+$/.test(name)) {
79
+ return repoName;
80
+ }
81
+ return name;
82
+ }
83
+ /**
84
+ * Convert kebab-case or snake_case to Title Case.
85
+ * "skill-lookup" → "Skill Lookup"
86
+ * "my_cool_skill" → "My Cool Skill"
87
+ */
88
+ export function toTitleCase(str) {
89
+ return str
90
+ .replace(/[-_]/g, ' ')
91
+ .replace(/\b\w/g, char => char.toUpperCase());
92
+ }
93
+ /**
94
+ * Truncate text to a maximum length, adding ellipsis if needed.
95
+ */
96
+ export function truncate(text, maxLength) {
97
+ if (text.length <= maxLength)
98
+ return text;
99
+ return text.slice(0, maxLength - 1).trim() + '…';
100
+ }
101
+ /**
102
+ * Extract SKILL.md paths from validated GitHub tree response.
103
+ */
104
+ export function extractSkillPaths(data, skillFilename = 'SKILL.md') {
105
+ if (!data.tree)
106
+ return [];
107
+ return data.tree
108
+ .filter(item => item.type === 'blob' && item.path.endsWith(skillFilename))
109
+ .map(item => item.path);
110
+ }
111
+ /**
112
+ * Sleep for a specified duration.
113
+ */
114
+ export function sleep(ms) {
115
+ return new Promise(resolve => setTimeout(resolve, ms));
116
+ }
117
+ /**
118
+ * Process items with bounded concurrency.
119
+ * Prevents overwhelming resources with unbounded parallel requests.
120
+ *
121
+ * @param items - Items to process
122
+ * @param fn - Async function to apply to each item
123
+ * @param concurrency - Maximum concurrent operations (default: 10)
124
+ */
125
+ export async function batchMap(items, fn, concurrency = 10) {
126
+ const results = [];
127
+ let index = 0;
128
+ async function worker() {
129
+ while (index < items.length) {
130
+ const currentIndex = index++;
131
+ results[currentIndex] = await fn(items[currentIndex]);
132
+ }
133
+ }
134
+ // Start workers up to concurrency limit
135
+ const workers = Array(Math.min(concurrency, items.length))
136
+ .fill(null)
137
+ .map(() => worker());
138
+ await Promise.all(workers);
139
+ return results;
140
+ }
141
+ /**
142
+ * Create a fresh JSON output object for the add command.
143
+ */
144
+ export function createJsonOutput() {
145
+ return {
146
+ success: true,
147
+ installed: [],
148
+ skipped: [],
149
+ errors: [],
150
+ };
151
+ }
152
+ /**
153
+ * Check if stdout is a TTY (interactive terminal).
154
+ */
155
+ export function isTTY() {
156
+ return process.stdout.isTTY === true;
157
+ }
158
+ /**
159
+ * Check if stdin is a TTY (interactive terminal).
160
+ */
161
+ export function isInputTTY() {
162
+ return process.stdin.isTTY === true;
163
+ }
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "skillfish",
3
+ "version": "1.0.0",
4
+ "description": "Install AI agent skills from GitHub with a single command",
5
+ "type": "module",
6
+ "bin": {
7
+ "skillfish": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "!dist/__tests__",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/nichochar/skillfish.git"
20
+ },
21
+ "bugs": {
22
+ "url": "https://github.com/nichochar/skillfish/issues"
23
+ },
24
+ "scripts": {
25
+ "build": "tsc",
26
+ "dev": "tsc --watch",
27
+ "prepublishOnly": "npm run build",
28
+ "test": "vitest run",
29
+ "test:watch": "vitest"
30
+ },
31
+ "keywords": [
32
+ "cli",
33
+ "skills",
34
+ "agent-skills",
35
+ "ai-agent",
36
+ "ai-tools",
37
+ "mcp",
38
+ "claude",
39
+ "claude-code",
40
+ "cursor",
41
+ "windsurf",
42
+ "codex",
43
+ "copilot",
44
+ "github-copilot",
45
+ "gemini-cli",
46
+ "opencode",
47
+ "goose",
48
+ "amp",
49
+ "roo",
50
+ "kiro",
51
+ "trae",
52
+ "cline"
53
+ ],
54
+ "author": "Graeme Knox",
55
+ "license": "AGPL-3.0",
56
+ "homepage": "https://skill.fish",
57
+ "dependencies": {
58
+ "@clack/prompts": "^0.11.0",
59
+ "commander": "^14.0.2",
60
+ "degit": "^2.8.4",
61
+ "picocolors": "^1.1.1"
62
+ },
63
+ "devDependencies": {
64
+ "@types/node": "^20.0.0",
65
+ "typescript": "^5.4.0",
66
+ "vitest": "^4.0.17"
67
+ }
68
+ }