agent-pool-mcp 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,281 @@
1
+ /**
2
+ * Skills management — 3-tier skill system for Gemini CLI agents.
3
+ *
4
+ * Tiers (lookup order):
5
+ * 1. Project: {cwd}/.gemini/skills/
6
+ * 2. User Global: ~/.gemini/skills/
7
+ * 3. Built-in: {server}/skills/ (read-only, shipped with agent-pool)
8
+ *
9
+ * @module agent-pool/tools/skills
10
+ */
11
+
12
+ import path from 'node:path';
13
+ import fs from 'node:fs';
14
+ import os from 'node:os';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
+
19
+ /** Built-in skills directory (read-only, ships with MCP server) */
20
+ const BUILTIN_SKILLS_DIR = path.resolve(__dirname, '..', '..', 'skills');
21
+
22
+ /** User-global skills directory */
23
+ const USER_GLOBAL_SKILLS_DIR = path.join(os.homedir(), '.gemini', 'skills');
24
+
25
+ /**
26
+ * Sanitize skill name to prevent path traversal.
27
+ * Rejects names containing path separators or traversal sequences.
28
+ *
29
+ * @param {string} name - Raw skill name
30
+ * @returns {string} Sanitized name
31
+ * @throws {Error} If name contains path traversal or is invalid
32
+ */
33
+ function sanitizeSkillName(name) {
34
+ if (!name || name.includes('/') || name.includes('\\') || name.includes('..')) {
35
+ throw new Error(`Invalid skill name: '${name}' (must not contain path separators or '..')`);
36
+ }
37
+ const sanitized = path.basename(name);
38
+ if (!sanitized || sanitized === '.' || sanitized === '..') {
39
+ throw new Error(`Invalid skill name: '${name}'`);
40
+ }
41
+ return sanitized;
42
+ }
43
+
44
+ /**
45
+ * Get the project-level skills directory.
46
+ *
47
+ * @param {string} cwd - Project root
48
+ * @returns {string}
49
+ */
50
+ function getProjectSkillsDir(cwd) {
51
+ return path.join(cwd, '.gemini', 'skills');
52
+ }
53
+
54
+ /**
55
+ * Parse YAML frontmatter from markdown content.
56
+ *
57
+ * @param {string} content - Markdown file content
58
+ * @returns {{name: string, description: string, body: string}}
59
+ */
60
+ function parseFrontmatter(content) {
61
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
62
+ if (!match) return { name: '', description: '', body: content };
63
+
64
+ const frontmatter = match[1];
65
+ const body = match[2].trim();
66
+ const result = { name: '', description: '', body };
67
+
68
+ for (const line of frontmatter.split('\n')) {
69
+ const [key, ...valueParts] = line.split(':');
70
+ const value = valueParts.join(':').trim();
71
+ if (key.trim() === 'name') result.name = value;
72
+ if (key.trim() === 'description') result.description = value;
73
+ }
74
+
75
+ return result;
76
+ }
77
+
78
+ /**
79
+ * Read skills from a directory.
80
+ *
81
+ * @param {string} dir - Skills directory
82
+ * @param {string} tier - Tier label: 'project', 'global', 'built-in'
83
+ * @returns {Array<{fileName: string, name: string, description: string, tier: string, filePath: string}>}
84
+ */
85
+ function readSkillsFromDir(dir, tier) {
86
+ if (!fs.existsSync(dir)) return [];
87
+
88
+ const files = fs.readdirSync(dir).filter((f) => f.endsWith('.md') && !f.startsWith('.'));
89
+ return files.map((fileName) => {
90
+ const filePath = path.join(dir, fileName);
91
+ const content = fs.readFileSync(filePath, 'utf-8');
92
+ const { name, description } = parseFrontmatter(content);
93
+ return {
94
+ fileName,
95
+ name: name || fileName.replace('.md', ''),
96
+ description: description || '(no description)',
97
+ tier,
98
+ filePath,
99
+ };
100
+ });
101
+ }
102
+
103
+ /**
104
+ * List skills from all 3 tiers. Project overrides global, global overrides built-in.
105
+ *
106
+ * @param {string} cwd - Project root
107
+ * @returns {Array<{fileName: string, name: string, description: string, tier: string, filePath: string}>}
108
+ */
109
+ export function listSkills(cwd) {
110
+ const builtIn = readSkillsFromDir(BUILTIN_SKILLS_DIR, 'built-in');
111
+ const userGlobal = readSkillsFromDir(USER_GLOBAL_SKILLS_DIR, 'global');
112
+ const project = readSkillsFromDir(getProjectSkillsDir(cwd), 'project');
113
+
114
+ // Merge: project overrides global overrides built-in (by fileName)
115
+ const merged = new Map();
116
+ for (const skill of builtIn) merged.set(skill.fileName, skill);
117
+ for (const skill of userGlobal) merged.set(skill.fileName, skill);
118
+ for (const skill of project) merged.set(skill.fileName, skill);
119
+
120
+ return [...merged.values()];
121
+ }
122
+
123
+ /**
124
+ * Find a skill by name across all tiers. Lookup: project → global → built-in.
125
+ *
126
+ * @param {string} cwd - Project root
127
+ * @param {string} skillName - Skill name (with or without .md)
128
+ * @returns {{filePath: string, content: string, tier: string} | null}
129
+ */
130
+ export function findSkill(cwd, skillName) {
131
+ const fileName = sanitizeSkillName(skillName.endsWith('.md') ? skillName : `${skillName}.md`);
132
+
133
+ const dirs = [
134
+ { dir: getProjectSkillsDir(cwd), tier: 'project' },
135
+ { dir: USER_GLOBAL_SKILLS_DIR, tier: 'global' },
136
+ { dir: BUILTIN_SKILLS_DIR, tier: 'built-in' },
137
+ ];
138
+
139
+ for (const { dir, tier } of dirs) {
140
+ const filePath = path.join(dir, fileName);
141
+ if (fs.existsSync(filePath)) {
142
+ return {
143
+ filePath,
144
+ content: fs.readFileSync(filePath, 'utf-8'),
145
+ tier,
146
+ };
147
+ }
148
+ }
149
+
150
+ return null;
151
+ }
152
+
153
+ /**
154
+ * Create or update a skill file.
155
+ *
156
+ * @param {string} cwd - Project root
157
+ * @param {string} skillName - Skill file name (without .md)
158
+ * @param {string} description - Short description
159
+ * @param {string} instructions - Markdown instructions body
160
+ * @param {string} [scope='project'] - 'project' or 'global'
161
+ * @returns {string} Path to created file
162
+ */
163
+ export function createSkill(cwd, skillName, description, instructions, scope = 'project') {
164
+ const targetDir = scope === 'global' ? USER_GLOBAL_SKILLS_DIR : getProjectSkillsDir(cwd);
165
+ fs.mkdirSync(targetDir, { recursive: true });
166
+
167
+ const fileName = sanitizeSkillName(skillName.endsWith('.md') ? skillName : `${skillName}.md`);
168
+ const filePath = path.join(targetDir, fileName);
169
+
170
+ const content = [
171
+ '---',
172
+ `name: ${skillName.replace('.md', '')}`,
173
+ `description: ${description}`,
174
+ '---',
175
+ '',
176
+ instructions,
177
+ '',
178
+ ].join('\n');
179
+
180
+ fs.writeFileSync(filePath, content, 'utf-8');
181
+ return filePath;
182
+ }
183
+
184
+ /**
185
+ * Delete a skill file.
186
+ *
187
+ * @param {string} cwd - Project root
188
+ * @param {string} skillName - Skill name (with or without .md)
189
+ * @param {string} [scope='project'] - 'project' or 'global'
190
+ * @returns {boolean} Whether the file was deleted
191
+ */
192
+ export function deleteSkill(cwd, skillName, scope = 'project') {
193
+ const targetDir = scope === 'global' ? USER_GLOBAL_SKILLS_DIR : getProjectSkillsDir(cwd);
194
+ const fileName = sanitizeSkillName(skillName.endsWith('.md') ? skillName : `${skillName}.md`);
195
+ const filePath = path.join(targetDir, fileName);
196
+
197
+ if (!fs.existsSync(filePath)) return false;
198
+ fs.unlinkSync(filePath);
199
+ return true;
200
+ }
201
+
202
+ /**
203
+ * Install a skill into a project by copying from global or built-in tier.
204
+ * Adds an origin comment to the frontmatter.
205
+ *
206
+ * @param {string} cwd - Project root
207
+ * @param {string} skillName - Skill name to install
208
+ * @returns {{installed: boolean, from: string, to: string, tier: string} | null}
209
+ */
210
+ export function installSkill(cwd, skillName) {
211
+ const fileName = sanitizeSkillName(skillName.endsWith('.md') ? skillName : `${skillName}.md`);
212
+
213
+ // Only search global and built-in (not project — that's where we're installing to)
214
+ const dirs = [
215
+ { dir: USER_GLOBAL_SKILLS_DIR, tier: 'global' },
216
+ { dir: BUILTIN_SKILLS_DIR, tier: 'built-in' },
217
+ ];
218
+
219
+ for (const { dir, tier } of dirs) {
220
+ const sourcePath = path.join(dir, fileName);
221
+ if (fs.existsSync(sourcePath)) {
222
+ const projectDir = getProjectSkillsDir(cwd);
223
+ fs.mkdirSync(projectDir, { recursive: true });
224
+
225
+ const destPath = path.join(projectDir, fileName);
226
+ let content = fs.readFileSync(sourcePath, 'utf-8');
227
+
228
+ // Add origin comment after frontmatter
229
+ const date = new Date().toISOString().split('T')[0];
230
+ const originComment = `<!-- Installed from ${tier}: ${sourcePath} on ${date} -->`;
231
+ content = content.replace(/^(---\n[\s\S]*?\n---\n)/, `$1\n${originComment}\n`);
232
+
233
+ fs.writeFileSync(destPath, content, 'utf-8');
234
+ return { installed: true, from: sourcePath, to: destPath, tier };
235
+ }
236
+ }
237
+
238
+ return null;
239
+ }
240
+
241
+ /**
242
+ * Provision a skill for a delegated task.
243
+ * Copies the skill from global/built-in into the project's .gemini/skills/
244
+ * so Gemini CLI can activate it natively via activate_skill tool.
245
+ *
246
+ * If the skill is already in the project tier, does nothing.
247
+ *
248
+ * @param {string} cwd - Project root
249
+ * @param {string} skillName - Skill name
250
+ * @returns {{name: string, provisioned: boolean, tier: string} | null}
251
+ */
252
+ export function provisionSkill(cwd, skillName) {
253
+ const fileName = sanitizeSkillName(skillName.endsWith('.md') ? skillName : `${skillName}.md`);
254
+ const canonicalName = skillName.replace('.md', '');
255
+
256
+ // Check if already in project
257
+ const projectPath = path.join(getProjectSkillsDir(cwd), fileName);
258
+ if (fs.existsSync(projectPath)) {
259
+ return { name: canonicalName, provisioned: false, tier: 'project' };
260
+ }
261
+
262
+ // Search global and built-in
263
+ const dirs = [
264
+ { dir: USER_GLOBAL_SKILLS_DIR, tier: 'global' },
265
+ { dir: BUILTIN_SKILLS_DIR, tier: 'built-in' },
266
+ ];
267
+
268
+ for (const { dir, tier } of dirs) {
269
+ const sourcePath = path.join(dir, fileName);
270
+ if (fs.existsSync(sourcePath)) {
271
+ const projectDir = getProjectSkillsDir(cwd);
272
+ fs.mkdirSync(projectDir, { recursive: true });
273
+
274
+ // Copy to project for native Gemini CLI discovery
275
+ fs.copyFileSync(sourcePath, projectPath);
276
+ return { name: canonicalName, provisioned: true, tier };
277
+ }
278
+ }
279
+
280
+ return null;
281
+ }