@opengoat/core 2026.2.20 → 2026.2.23
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/dist/core/bootstrap/application/bootstrap.service.js +26 -7
- package/dist/core/bootstrap/application/bootstrap.service.js.map +1 -1
- package/dist/core/opengoat/application/opengoat.service.d.ts +16 -1
- package/dist/core/opengoat/application/opengoat.service.js +227 -47
- package/dist/core/opengoat/application/opengoat.service.js.map +1 -1
- package/dist/core/opengoat/index.d.ts +1 -1
- package/dist/core/skills/application/skill.service.d.ts +28 -2
- package/dist/core/skills/application/skill.service.js +433 -33
- package/dist/core/skills/application/skill.service.js.map +1 -1
- package/dist/core/skills/domain/skill.d.ts +19 -1
- package/dist/core/skills/domain/skill.js.map +1 -1
- package/dist/core/skills/index.d.ts +1 -1
- package/dist/core/skills/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import os from "node:os";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { BOARD_MANAGER_SKILL_ID } from "../../agents/domain/agent-manifest.js";
|
|
@@ -6,9 +7,11 @@ import { resolveSkillsConfig, } from "../domain/skill.js";
|
|
|
6
7
|
export class SkillService {
|
|
7
8
|
fileSystem;
|
|
8
9
|
pathPort;
|
|
10
|
+
commandRunner;
|
|
9
11
|
constructor(deps) {
|
|
10
12
|
this.fileSystem = deps.fileSystem;
|
|
11
13
|
this.pathPort = deps.pathPort;
|
|
14
|
+
this.commandRunner = deps.commandRunner;
|
|
12
15
|
}
|
|
13
16
|
async listSkills(paths, agentId = DEFAULT_AGENT_ID, runtimeConfig) {
|
|
14
17
|
const normalizedAgentId = normalizeAgentId(agentId) || DEFAULT_AGENT_ID;
|
|
@@ -17,7 +20,7 @@ export class SkillService {
|
|
|
17
20
|
if (!resolvedConfig.enabled) {
|
|
18
21
|
return [];
|
|
19
22
|
}
|
|
20
|
-
const discovered = await this.loadSkillsWithPrecedence(paths, resolvedConfig);
|
|
23
|
+
const discovered = await this.loadSkillsWithPrecedence(paths, normalizedAgentId, resolvedConfig);
|
|
21
24
|
const assigned = new Set(resolvedConfig.assigned);
|
|
22
25
|
const filtered = assigned.size > 0
|
|
23
26
|
? discovered.filter((skill) => assigned.has(skill.id))
|
|
@@ -64,7 +67,7 @@ export class SkillService {
|
|
|
64
67
|
"- If exactly one skill clearly applies, follow that skill.",
|
|
65
68
|
"- If multiple skills could apply, choose the most specific.",
|
|
66
69
|
"- If none apply, continue without using a skill.",
|
|
67
|
-
"Skill definitions
|
|
70
|
+
"Skill definitions may come from global and agent-specific stores.",
|
|
68
71
|
"",
|
|
69
72
|
];
|
|
70
73
|
if (selected.length === 0) {
|
|
@@ -95,75 +98,353 @@ export class SkillService {
|
|
|
95
98
|
skills: selected,
|
|
96
99
|
};
|
|
97
100
|
}
|
|
98
|
-
async installSkill(paths, request) {
|
|
101
|
+
async installSkill(paths, request, options = {}) {
|
|
102
|
+
const hasSourcePath = Boolean(request.sourcePath?.trim());
|
|
103
|
+
const hasSourceUrl = Boolean(request.sourceUrl?.trim());
|
|
104
|
+
if (hasSourcePath && hasSourceUrl) {
|
|
105
|
+
throw new Error("Use either sourcePath or sourceUrl, not both.");
|
|
106
|
+
}
|
|
99
107
|
const scope = request.scope === "global" ? "global" : "agent";
|
|
100
108
|
const normalizedAgentId = normalizeAgentId(request.agentId ?? DEFAULT_AGENT_ID) || DEFAULT_AGENT_ID;
|
|
101
|
-
const
|
|
109
|
+
const requestedSkillName = request.skillName?.trim() || request.sourceSkillName?.trim();
|
|
110
|
+
const skillId = normalizeAgentId(requestedSkillName ?? "");
|
|
102
111
|
if (!skillId) {
|
|
103
112
|
throw new Error("Skill name must contain at least one alphanumeric character.");
|
|
104
113
|
}
|
|
105
|
-
const
|
|
106
|
-
const
|
|
114
|
+
const globalSkillDir = this.pathPort.join(paths.skillsDir, skillId);
|
|
115
|
+
const globalSkillFile = this.pathPort.join(globalSkillDir, "SKILL.md");
|
|
116
|
+
const agentScopedSkillsBaseDir = this.pathPort.join(paths.skillsDir, AGENT_SCOPED_STORE_DIR, normalizedAgentId);
|
|
117
|
+
const agentScopedSkillDir = this.pathPort.join(agentScopedSkillsBaseDir, skillId);
|
|
118
|
+
const targetDir = scope === "global" ? globalSkillDir : agentScopedSkillDir;
|
|
107
119
|
const targetSkillFile = this.pathPort.join(targetDir, "SKILL.md");
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if (
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
120
|
+
let replaced = await this.fileSystem.exists(targetDir);
|
|
121
|
+
let workspaceInstallPaths = [];
|
|
122
|
+
if (hasSourcePath || hasSourceUrl) {
|
|
123
|
+
await this.fileSystem.ensureDir(scope === "global" ? paths.skillsDir : agentScopedSkillsBaseDir);
|
|
124
|
+
let resolvedSource;
|
|
125
|
+
try {
|
|
126
|
+
resolvedSource = hasSourcePath
|
|
127
|
+
? await this.resolveInstallSourceFromPath(request.sourcePath?.trim() ?? "")
|
|
128
|
+
: await this.resolveInstallSourceFromUrl(paths, request.sourceUrl?.trim() ?? "", request.sourceSkillName, skillId);
|
|
129
|
+
await this.fileSystem.removeDir(targetDir);
|
|
130
|
+
await this.fileSystem.copyDir(resolvedSource.sourceDir, targetDir);
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
if (resolvedSource?.cleanup) {
|
|
134
|
+
await resolvedSource.cleanup();
|
|
135
|
+
}
|
|
119
136
|
}
|
|
120
|
-
const sourceDir = sourceSkillFile === sourcePath ? path.dirname(sourcePath) : sourcePath;
|
|
121
|
-
await this.fileSystem.removeDir(targetDir);
|
|
122
|
-
await this.fileSystem.copyDir(sourceDir, targetDir);
|
|
123
137
|
if (scope === "agent") {
|
|
124
|
-
await this.
|
|
125
|
-
await this.reconcileRoleSkillsIfNeeded(paths, normalizedAgentId, skillId);
|
|
138
|
+
workspaceInstallPaths = await this.installSkillForAgent(paths, normalizedAgentId, skillId, options, targetDir);
|
|
126
139
|
}
|
|
127
140
|
return {
|
|
128
141
|
scope,
|
|
129
142
|
agentId: scope === "agent" ? normalizedAgentId : undefined,
|
|
143
|
+
assignedAgentIds: scope === "agent" ? [normalizedAgentId] : undefined,
|
|
130
144
|
skillId,
|
|
131
|
-
skillName:
|
|
132
|
-
source: "source-path",
|
|
145
|
+
skillName: requestedSkillName ?? skillId,
|
|
146
|
+
source: hasSourcePath ? "source-path" : "source-url",
|
|
133
147
|
installedPath: targetSkillFile,
|
|
148
|
+
workspaceInstallPaths,
|
|
134
149
|
replaced,
|
|
135
150
|
};
|
|
136
151
|
}
|
|
137
152
|
let source = "generated";
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
153
|
+
let installedPath = targetSkillFile;
|
|
154
|
+
let workspaceSourceDir = targetDir;
|
|
155
|
+
if (scope === "agent" && (await this.fileSystem.exists(globalSkillFile))) {
|
|
156
|
+
const hadAgentOverride = await this.fileSystem.exists(agentScopedSkillDir);
|
|
157
|
+
if (hadAgentOverride) {
|
|
158
|
+
await this.fileSystem.removeDir(agentScopedSkillDir);
|
|
159
|
+
}
|
|
160
|
+
replaced = hadAgentOverride;
|
|
141
161
|
source = "managed";
|
|
162
|
+
installedPath = globalSkillFile;
|
|
163
|
+
workspaceSourceDir = globalSkillDir;
|
|
142
164
|
}
|
|
143
165
|
else {
|
|
144
166
|
const description = request.description?.trim() ||
|
|
145
|
-
`Skill instructions for ${
|
|
167
|
+
`Skill instructions for ${requestedSkillName ?? skillId}.`;
|
|
146
168
|
const content = request.content?.trim() ||
|
|
147
169
|
renderSkillMarkdown({ skillId, description });
|
|
170
|
+
await this.fileSystem.ensureDir(scope === "global" ? paths.skillsDir : agentScopedSkillsBaseDir);
|
|
148
171
|
await this.fileSystem.ensureDir(targetDir);
|
|
149
172
|
await this.fileSystem.writeFile(targetSkillFile, ensureTrailingNewline(content));
|
|
150
173
|
source = "generated";
|
|
151
174
|
}
|
|
152
175
|
if (scope === "agent") {
|
|
153
|
-
await this.
|
|
154
|
-
await this.reconcileRoleSkillsIfNeeded(paths, normalizedAgentId, skillId);
|
|
176
|
+
workspaceInstallPaths = await this.installSkillForAgent(paths, normalizedAgentId, skillId, options, workspaceSourceDir);
|
|
155
177
|
}
|
|
156
178
|
return {
|
|
157
179
|
scope,
|
|
158
180
|
agentId: scope === "agent" ? normalizedAgentId : undefined,
|
|
181
|
+
assignedAgentIds: scope === "agent" ? [normalizedAgentId] : undefined,
|
|
159
182
|
skillId,
|
|
160
|
-
skillName:
|
|
183
|
+
skillName: requestedSkillName ?? skillId,
|
|
161
184
|
source,
|
|
162
|
-
installedPath
|
|
185
|
+
installedPath,
|
|
186
|
+
workspaceInstallPaths,
|
|
163
187
|
replaced,
|
|
164
188
|
};
|
|
165
189
|
}
|
|
166
|
-
async
|
|
190
|
+
async assignInstalledSkillToAgent(paths, agentId, skillId, options = {}) {
|
|
191
|
+
const normalizedAgentId = normalizeAgentId(agentId) || DEFAULT_AGENT_ID;
|
|
192
|
+
const normalizedSkillId = normalizeAgentId(skillId);
|
|
193
|
+
if (!normalizedSkillId) {
|
|
194
|
+
throw new Error("Skill id must contain at least one alphanumeric character.");
|
|
195
|
+
}
|
|
196
|
+
const sourceSkillFile = this.pathPort.join(paths.skillsDir, normalizedSkillId, "SKILL.md");
|
|
197
|
+
if (!(await this.fileSystem.exists(sourceSkillFile))) {
|
|
198
|
+
throw new Error(`Skill "${normalizedSkillId}" is not installed in global storage.`);
|
|
199
|
+
}
|
|
200
|
+
return this.installSkillForAgent(paths, normalizedAgentId, normalizedSkillId, options, this.pathPort.join(paths.skillsDir, normalizedSkillId));
|
|
201
|
+
}
|
|
202
|
+
async removeSkill(paths, request, options = {}) {
|
|
203
|
+
const scope = request.scope === "global" ? "global" : "agent";
|
|
204
|
+
const normalizedSkillId = normalizeAgentId(request.skillId ?? "");
|
|
205
|
+
if (!normalizedSkillId) {
|
|
206
|
+
throw new Error("Skill id must contain at least one alphanumeric character.");
|
|
207
|
+
}
|
|
208
|
+
if (scope === "global") {
|
|
209
|
+
const globalSkillDir = this.pathPort.join(paths.skillsDir, normalizedSkillId);
|
|
210
|
+
const removedFromGlobal = await this.fileSystem.exists(globalSkillDir);
|
|
211
|
+
await this.fileSystem.removeDir(globalSkillDir);
|
|
212
|
+
return {
|
|
213
|
+
scope: "global",
|
|
214
|
+
skillId: normalizedSkillId,
|
|
215
|
+
removedFromGlobal,
|
|
216
|
+
removedFromAgentIds: [],
|
|
217
|
+
removedWorkspacePaths: [],
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
const normalizedAgentId = normalizeAgentId(request.agentId ?? DEFAULT_AGENT_ID) || DEFAULT_AGENT_ID;
|
|
221
|
+
const removed = await this.removeSkillForAgent(paths, normalizedAgentId, normalizedSkillId, options);
|
|
222
|
+
return {
|
|
223
|
+
scope: "agent",
|
|
224
|
+
agentId: normalizedAgentId,
|
|
225
|
+
skillId: normalizedSkillId,
|
|
226
|
+
removedFromGlobal: false,
|
|
227
|
+
removedFromAgentIds: removed.removedFromConfig ||
|
|
228
|
+
removed.removedFromWorkspace ||
|
|
229
|
+
removed.removedFromAgentStore
|
|
230
|
+
? [normalizedAgentId]
|
|
231
|
+
: [],
|
|
232
|
+
removedWorkspacePaths: removed.removedWorkspacePaths,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
async removeAssignedSkillFromAgent(paths, agentId, skillId, options = {}) {
|
|
236
|
+
const normalizedAgentId = normalizeAgentId(agentId) || DEFAULT_AGENT_ID;
|
|
237
|
+
const normalizedSkillId = normalizeAgentId(skillId ?? "");
|
|
238
|
+
if (!normalizedSkillId) {
|
|
239
|
+
throw new Error("Skill id must contain at least one alphanumeric character.");
|
|
240
|
+
}
|
|
241
|
+
return this.removeSkillForAgent(paths, normalizedAgentId, normalizedSkillId, options);
|
|
242
|
+
}
|
|
243
|
+
async installSkillForAgent(paths, agentId, skillId, options, sourceSkillDir) {
|
|
244
|
+
await this.assignSkillToAgent(paths, agentId, skillId);
|
|
245
|
+
await this.reconcileRoleSkillsIfNeeded(paths, agentId, skillId);
|
|
246
|
+
return this.syncSkillToWorkspace(paths, agentId, skillId, options, sourceSkillDir);
|
|
247
|
+
}
|
|
248
|
+
async removeSkillForAgent(paths, agentId, skillId, options) {
|
|
249
|
+
const removedFromAgentStore = await this.removeAgentScopedSkill(paths, agentId, skillId);
|
|
250
|
+
const removedFromConfig = await this.unassignSkillFromAgent(paths, agentId, skillId);
|
|
251
|
+
const removedWorkspacePaths = await this.removeSkillFromWorkspace(paths, agentId, skillId, options);
|
|
252
|
+
return {
|
|
253
|
+
removedFromConfig,
|
|
254
|
+
removedFromAgentStore,
|
|
255
|
+
removedFromWorkspace: removedWorkspacePaths.length > 0,
|
|
256
|
+
removedWorkspacePaths,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
async syncSkillToWorkspace(paths, agentId, skillId, options, sourceSkillDir) {
|
|
260
|
+
const workspaceDir = options.workspaceDir?.trim() ||
|
|
261
|
+
this.pathPort.join(paths.workspacesDir, agentId);
|
|
262
|
+
const normalizedDirectories = normalizeWorkspaceSkillDirectories(options.workspaceSkillDirectories);
|
|
263
|
+
if (normalizedDirectories.length === 0) {
|
|
264
|
+
return [];
|
|
265
|
+
}
|
|
266
|
+
const resolvedSourceDir = sourceSkillDir ??
|
|
267
|
+
(await this.resolveInstalledSkillSourceDir(paths, agentId, skillId));
|
|
268
|
+
if (!resolvedSourceDir ||
|
|
269
|
+
!(await this.fileSystem.exists(resolvedSourceDir))) {
|
|
270
|
+
return [];
|
|
271
|
+
}
|
|
272
|
+
const installedPaths = [];
|
|
273
|
+
for (const relativeSkillsDir of normalizedDirectories) {
|
|
274
|
+
const skillsDir = this.pathPort.join(workspaceDir, relativeSkillsDir);
|
|
275
|
+
const targetSkillDir = this.pathPort.join(skillsDir, skillId);
|
|
276
|
+
await this.fileSystem.ensureDir(skillsDir);
|
|
277
|
+
await this.fileSystem.removeDir(targetSkillDir);
|
|
278
|
+
await this.fileSystem.copyDir(resolvedSourceDir, targetSkillDir);
|
|
279
|
+
installedPaths.push(this.pathPort.join(targetSkillDir, "SKILL.md"));
|
|
280
|
+
}
|
|
281
|
+
return installedPaths;
|
|
282
|
+
}
|
|
283
|
+
async removeSkillFromWorkspace(paths, agentId, skillId, options) {
|
|
284
|
+
const workspaceDir = options.workspaceDir?.trim() ||
|
|
285
|
+
this.pathPort.join(paths.workspacesDir, agentId);
|
|
286
|
+
const normalizedDirectories = normalizeWorkspaceSkillDirectories(options.workspaceSkillDirectories);
|
|
287
|
+
if (normalizedDirectories.length === 0) {
|
|
288
|
+
return [];
|
|
289
|
+
}
|
|
290
|
+
const removedPaths = [];
|
|
291
|
+
for (const relativeSkillsDir of normalizedDirectories) {
|
|
292
|
+
const skillsDir = this.pathPort.join(workspaceDir, relativeSkillsDir);
|
|
293
|
+
const targetSkillDir = this.pathPort.join(skillsDir, skillId);
|
|
294
|
+
if (!(await this.fileSystem.exists(targetSkillDir))) {
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
await this.fileSystem.removeDir(targetSkillDir);
|
|
298
|
+
removedPaths.push(this.pathPort.join(targetSkillDir, "SKILL.md"));
|
|
299
|
+
}
|
|
300
|
+
return removedPaths;
|
|
301
|
+
}
|
|
302
|
+
async resolveInstalledSkillSourceDir(paths, agentId, skillId) {
|
|
303
|
+
const agentScopedDir = this.pathPort.join(paths.skillsDir, AGENT_SCOPED_STORE_DIR, agentId, skillId);
|
|
304
|
+
if (await this.fileSystem.exists(agentScopedDir)) {
|
|
305
|
+
return agentScopedDir;
|
|
306
|
+
}
|
|
307
|
+
const globalDir = this.pathPort.join(paths.skillsDir, skillId);
|
|
308
|
+
if (await this.fileSystem.exists(globalDir)) {
|
|
309
|
+
return globalDir;
|
|
310
|
+
}
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|
|
313
|
+
async removeAgentScopedSkill(paths, agentId, skillId) {
|
|
314
|
+
const agentScopedDir = this.pathPort.join(paths.skillsDir, AGENT_SCOPED_STORE_DIR, agentId, skillId);
|
|
315
|
+
const exists = await this.fileSystem.exists(agentScopedDir);
|
|
316
|
+
if (!exists) {
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
await this.fileSystem.removeDir(agentScopedDir);
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
async resolveInstallSourceFromPath(sourcePathInput) {
|
|
323
|
+
const sourcePath = resolveUserPath(sourcePathInput);
|
|
324
|
+
const sourceSkillFile = sourcePath.toLowerCase().endsWith("/skill.md") ||
|
|
325
|
+
sourcePath.toLowerCase().endsWith("\\skill.md")
|
|
326
|
+
? sourcePath
|
|
327
|
+
: this.pathPort.join(sourcePath, "SKILL.md");
|
|
328
|
+
const sourceFileExists = await this.fileSystem.exists(sourceSkillFile);
|
|
329
|
+
if (!sourceFileExists) {
|
|
330
|
+
throw new Error(`Source skill not found: ${sourceSkillFile}`);
|
|
331
|
+
}
|
|
332
|
+
const sourceDir = sourceSkillFile === sourcePath ? path.dirname(sourcePath) : sourcePath;
|
|
333
|
+
return {
|
|
334
|
+
sourceDir,
|
|
335
|
+
source: "source-path",
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
async resolveInstallSourceFromUrl(paths, sourceUrl, sourceSkillName, fallbackSkillId) {
|
|
339
|
+
if (!this.commandRunner) {
|
|
340
|
+
throw new Error("Installing skills from URL requires a configured command runner.");
|
|
341
|
+
}
|
|
342
|
+
const resolvedUrl = sourceUrl.trim();
|
|
343
|
+
if (!resolvedUrl) {
|
|
344
|
+
throw new Error("sourceUrl cannot be empty.");
|
|
345
|
+
}
|
|
346
|
+
const tempRoot = this.pathPort.join(paths.homeDir, ".tmp", "skill-installs", randomUUID());
|
|
347
|
+
const cloneDir = this.pathPort.join(tempRoot, "repo");
|
|
348
|
+
await this.fileSystem.ensureDir(tempRoot);
|
|
349
|
+
const cloneSource = stripGitHubTreePath(resolvedUrl);
|
|
350
|
+
const cloneResult = await this.commandRunner.run({
|
|
351
|
+
command: "git",
|
|
352
|
+
args: ["clone", "--depth", "1", cloneSource, cloneDir],
|
|
353
|
+
cwd: paths.homeDir,
|
|
354
|
+
});
|
|
355
|
+
if (cloneResult.code !== 0) {
|
|
356
|
+
throw new Error(`Unable to clone skill source URL "${resolvedUrl}". ${cloneResult.stderr.trim() || cloneResult.stdout.trim() || ""}`.trim());
|
|
357
|
+
}
|
|
358
|
+
const pathHint = resolveGitHubTreePathHint(resolvedUrl);
|
|
359
|
+
const skillHint = sourceSkillName?.trim() || fallbackSkillId;
|
|
360
|
+
const resolvedSkillDir = await this.resolveRepositorySkillDirectory(cloneDir, skillHint, pathHint);
|
|
361
|
+
return {
|
|
362
|
+
sourceDir: resolvedSkillDir,
|
|
363
|
+
source: "source-url",
|
|
364
|
+
cleanup: async () => {
|
|
365
|
+
await this.fileSystem.removeDir(tempRoot);
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
async resolveRepositorySkillDirectory(repositoryDir, skillHint, pathHint) {
|
|
370
|
+
const normalizedSkillHint = normalizeAgentId(skillHint);
|
|
371
|
+
const normalizedPathHint = normalizeRelativeDirectory(pathHint);
|
|
372
|
+
if (normalizedPathHint) {
|
|
373
|
+
const hinted = this.pathPort.join(repositoryDir, normalizedPathHint);
|
|
374
|
+
const hintedSkillFile = this.pathPort.join(hinted, "SKILL.md");
|
|
375
|
+
if (await this.fileSystem.exists(hintedSkillFile)) {
|
|
376
|
+
return hinted;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (normalizedSkillHint) {
|
|
380
|
+
const candidateRelativePaths = [
|
|
381
|
+
normalizedSkillHint,
|
|
382
|
+
`skills/${normalizedSkillHint}`,
|
|
383
|
+
`.claude/skills/${normalizedSkillHint}`,
|
|
384
|
+
".claude-plugin/skills/" + normalizedSkillHint,
|
|
385
|
+
".agents/skills/" + normalizedSkillHint,
|
|
386
|
+
".agent/skills/" + normalizedSkillHint,
|
|
387
|
+
".cursor/skills/" + normalizedSkillHint,
|
|
388
|
+
".copilot/skills/" + normalizedSkillHint,
|
|
389
|
+
".opencode/skills/" + normalizedSkillHint,
|
|
390
|
+
".gemini/skills/" + normalizedSkillHint,
|
|
391
|
+
];
|
|
392
|
+
for (const relativeCandidate of candidateRelativePaths) {
|
|
393
|
+
const candidate = this.pathPort.join(repositoryDir, relativeCandidate);
|
|
394
|
+
const candidateSkillFile = this.pathPort.join(candidate, "SKILL.md");
|
|
395
|
+
if (await this.fileSystem.exists(candidateSkillFile)) {
|
|
396
|
+
return candidate;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
const discovered = await this.discoverSkillDirectories(repositoryDir, 0, 7);
|
|
401
|
+
if (normalizedSkillHint) {
|
|
402
|
+
const matched = discovered.find((directoryPath) => {
|
|
403
|
+
const relativePath = path
|
|
404
|
+
.relative(repositoryDir, directoryPath)
|
|
405
|
+
.replace(/\\/g, "/");
|
|
406
|
+
const directoryName = path.basename(directoryPath).trim();
|
|
407
|
+
return (normalizeAgentId(directoryName) === normalizedSkillHint ||
|
|
408
|
+
normalizeAgentId(relativePath) === normalizedSkillHint);
|
|
409
|
+
});
|
|
410
|
+
if (matched) {
|
|
411
|
+
return matched;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (discovered.length === 1) {
|
|
415
|
+
return discovered[0];
|
|
416
|
+
}
|
|
417
|
+
if (discovered.length === 0) {
|
|
418
|
+
throw new Error("No valid SKILL.md definitions were found in the source URL.");
|
|
419
|
+
}
|
|
420
|
+
const available = discovered
|
|
421
|
+
.slice(0, 10)
|
|
422
|
+
.map((directoryPath) => path.relative(repositoryDir, directoryPath))
|
|
423
|
+
.join(", ");
|
|
424
|
+
throw new Error(`Multiple skills were found in the source URL. Specify sourceSkillName. Available candidates: ${available}`);
|
|
425
|
+
}
|
|
426
|
+
async discoverSkillDirectories(rootDir, depth, maxDepth) {
|
|
427
|
+
if (depth > maxDepth) {
|
|
428
|
+
return [];
|
|
429
|
+
}
|
|
430
|
+
const skillFile = this.pathPort.join(rootDir, "SKILL.md");
|
|
431
|
+
const found = [];
|
|
432
|
+
if (await this.fileSystem.exists(skillFile)) {
|
|
433
|
+
found.push(rootDir);
|
|
434
|
+
}
|
|
435
|
+
const childDirectories = await this.fileSystem.listDirectories(rootDir);
|
|
436
|
+
for (const childDirectory of childDirectories) {
|
|
437
|
+
if (!childDirectory || childDirectory.startsWith(".git")) {
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
const childPath = this.pathPort.join(rootDir, childDirectory);
|
|
441
|
+
const nested = await this.discoverSkillDirectories(childPath, depth + 1, maxDepth);
|
|
442
|
+
found.push(...nested);
|
|
443
|
+
}
|
|
444
|
+
return dedupe(found).sort((left, right) => left.localeCompare(right));
|
|
445
|
+
}
|
|
446
|
+
async loadSkillsWithPrecedence(paths, agentId, config) {
|
|
447
|
+
const agentScopedDir = this.pathPort.join(paths.skillsDir, AGENT_SCOPED_STORE_DIR, agentId);
|
|
167
448
|
const extraDirs = config.load.extraDirs.map(resolveUserPath);
|
|
168
449
|
const sources = [
|
|
169
450
|
{
|
|
@@ -171,6 +452,11 @@ export class SkillService {
|
|
|
171
452
|
dir: paths.skillsDir,
|
|
172
453
|
enabled: config.includeManaged,
|
|
173
454
|
},
|
|
455
|
+
{
|
|
456
|
+
source: "managed",
|
|
457
|
+
dir: agentScopedDir,
|
|
458
|
+
enabled: config.includeManaged,
|
|
459
|
+
},
|
|
174
460
|
...extraDirs.map((dir) => ({
|
|
175
461
|
source: "extra",
|
|
176
462
|
dir,
|
|
@@ -275,6 +561,41 @@ export class SkillService {
|
|
|
275
561
|
parsed.runtime = runtimeRecord;
|
|
276
562
|
await this.fileSystem.writeFile(configPath, `${JSON.stringify(parsed, null, 2)}\n`);
|
|
277
563
|
}
|
|
564
|
+
async unassignSkillFromAgent(paths, agentId, skillId) {
|
|
565
|
+
const configPath = this.pathPort.join(paths.agentsDir, agentId, "config.json");
|
|
566
|
+
if (!(await this.fileSystem.exists(configPath))) {
|
|
567
|
+
return false;
|
|
568
|
+
}
|
|
569
|
+
const raw = await this.fileSystem.readFile(configPath);
|
|
570
|
+
const parsed = JSON.parse(raw);
|
|
571
|
+
const runtimeRecord = parsed.runtime &&
|
|
572
|
+
typeof parsed.runtime === "object" &&
|
|
573
|
+
!Array.isArray(parsed.runtime)
|
|
574
|
+
? parsed.runtime
|
|
575
|
+
: {};
|
|
576
|
+
const skillsRecord = runtimeRecord.skills &&
|
|
577
|
+
typeof runtimeRecord.skills === "object" &&
|
|
578
|
+
!Array.isArray(runtimeRecord.skills)
|
|
579
|
+
? runtimeRecord.skills
|
|
580
|
+
: {};
|
|
581
|
+
const assignedRaw = Array.isArray(skillsRecord.assigned)
|
|
582
|
+
? skillsRecord.assigned
|
|
583
|
+
: [];
|
|
584
|
+
const assigned = [
|
|
585
|
+
...new Set(assignedRaw
|
|
586
|
+
.map((value) => String(value).trim().toLowerCase())
|
|
587
|
+
.filter(Boolean)),
|
|
588
|
+
];
|
|
589
|
+
const nextAssigned = assigned.filter((entry) => entry !== skillId);
|
|
590
|
+
if (nextAssigned.length === assigned.length) {
|
|
591
|
+
return false;
|
|
592
|
+
}
|
|
593
|
+
skillsRecord.assigned = nextAssigned;
|
|
594
|
+
runtimeRecord.skills = skillsRecord;
|
|
595
|
+
parsed.runtime = runtimeRecord;
|
|
596
|
+
await this.fileSystem.writeFile(configPath, `${JSON.stringify(parsed, null, 2)}\n`);
|
|
597
|
+
return true;
|
|
598
|
+
}
|
|
278
599
|
async reconcileRoleSkillsIfNeeded(paths, agentId, skillId) {
|
|
279
600
|
const normalizedSkill = skillId.trim().toLowerCase();
|
|
280
601
|
if (!ROLE_SKILL_IDS.has(normalizedSkill)) {
|
|
@@ -330,6 +651,7 @@ const BOARD_INDIVIDUAL_SKILL_ID = "og-board-individual";
|
|
|
330
651
|
const BOARDS_SKILL_ID = "og-boards";
|
|
331
652
|
const LEGACY_BOARD_MANAGER_SKILL_ID = "board-manager";
|
|
332
653
|
const LEGACY_BOARD_INDIVIDUAL_SKILL_ID = "board-individual";
|
|
654
|
+
const AGENT_SCOPED_STORE_DIR = ".agent-scoped";
|
|
333
655
|
const ROLE_SKILL_IDS = new Set([
|
|
334
656
|
BOARDS_SKILL_ID,
|
|
335
657
|
BOARD_MANAGER_SKILL_ID,
|
|
@@ -459,6 +781,84 @@ function resolveUserPath(value) {
|
|
|
459
781
|
}
|
|
460
782
|
return path.resolve(value);
|
|
461
783
|
}
|
|
784
|
+
function normalizeWorkspaceSkillDirectories(input) {
|
|
785
|
+
if (!Array.isArray(input) || input.length === 0) {
|
|
786
|
+
return [];
|
|
787
|
+
}
|
|
788
|
+
const directories = [];
|
|
789
|
+
for (const candidate of input) {
|
|
790
|
+
const normalized = normalizeRelativeDirectory(candidate);
|
|
791
|
+
if (!normalized || directories.includes(normalized)) {
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
directories.push(normalized);
|
|
795
|
+
}
|
|
796
|
+
return directories;
|
|
797
|
+
}
|
|
798
|
+
function normalizeRelativeDirectory(value) {
|
|
799
|
+
if (!value) {
|
|
800
|
+
return null;
|
|
801
|
+
}
|
|
802
|
+
const normalized = value
|
|
803
|
+
.trim()
|
|
804
|
+
.replace(/\\/g, "/")
|
|
805
|
+
.replace(/^\/+/, "")
|
|
806
|
+
.replace(/\/+$/, "");
|
|
807
|
+
if (!normalized || normalized.startsWith("..")) {
|
|
808
|
+
return null;
|
|
809
|
+
}
|
|
810
|
+
const parts = normalized
|
|
811
|
+
.split("/")
|
|
812
|
+
.map((part) => part.trim())
|
|
813
|
+
.filter(Boolean);
|
|
814
|
+
if (parts.length === 0 || parts.includes("..")) {
|
|
815
|
+
return null;
|
|
816
|
+
}
|
|
817
|
+
return parts.join("/");
|
|
818
|
+
}
|
|
819
|
+
function stripGitHubTreePath(sourceUrl) {
|
|
820
|
+
try {
|
|
821
|
+
const parsed = new URL(sourceUrl);
|
|
822
|
+
if (parsed.hostname !== "github.com") {
|
|
823
|
+
return sourceUrl;
|
|
824
|
+
}
|
|
825
|
+
const segments = parsed.pathname.split("/").filter(Boolean);
|
|
826
|
+
if (segments.length >= 4 && segments[2] === "tree") {
|
|
827
|
+
const owner = segments[0];
|
|
828
|
+
const repo = segments[1]?.replace(/\.git$/i, "");
|
|
829
|
+
if (owner && repo) {
|
|
830
|
+
return `https://github.com/${owner}/${repo}.git`;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
return sourceUrl;
|
|
834
|
+
}
|
|
835
|
+
catch {
|
|
836
|
+
return sourceUrl;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
function resolveGitHubTreePathHint(sourceUrl) {
|
|
840
|
+
try {
|
|
841
|
+
const parsed = new URL(sourceUrl);
|
|
842
|
+
if (parsed.hostname !== "github.com") {
|
|
843
|
+
return undefined;
|
|
844
|
+
}
|
|
845
|
+
const segments = parsed.pathname.split("/").filter(Boolean);
|
|
846
|
+
if (segments.length <= 4 || segments[2] !== "tree") {
|
|
847
|
+
return undefined;
|
|
848
|
+
}
|
|
849
|
+
const relativeSegments = segments.slice(4);
|
|
850
|
+
if (relativeSegments.length === 0) {
|
|
851
|
+
return undefined;
|
|
852
|
+
}
|
|
853
|
+
return relativeSegments.join("/");
|
|
854
|
+
}
|
|
855
|
+
catch {
|
|
856
|
+
return undefined;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
function dedupe(values) {
|
|
860
|
+
return [...new Set(values)];
|
|
861
|
+
}
|
|
462
862
|
function escapeXml(value) {
|
|
463
863
|
return value
|
|
464
864
|
.replace(/&/g, "&")
|