amalgm 0.1.129 → 0.1.130
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/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/agent-bundles/bundles.js +2 -1
- package/runtime/scripts/amalgm-mcp/agent-config/compiler/instructions.js +1 -2
- package/runtime/scripts/amalgm-mcp/agent-config/schema.js +1 -1
- package/runtime/scripts/amalgm-mcp/agent-config/store.js +5 -2
- package/runtime/scripts/amalgm-mcp/agents/rest.js +1 -1
- package/runtime/scripts/amalgm-mcp/agents/talk.js +2 -1
- package/runtime/scripts/amalgm-mcp/fs/search.js +67 -1
- package/runtime/scripts/amalgm-mcp/server/routes/agents.js +13 -0
- package/runtime/scripts/amalgm-mcp/skills/public.js +114 -0
- package/runtime/scripts/amalgm-mcp/skills/rest.js +47 -0
- package/runtime/scripts/amalgm-mcp/skills/roots.js +132 -0
- package/runtime/scripts/amalgm-mcp/skills/scanner.js +153 -0
- package/runtime/scripts/amalgm-mcp/skills/store.js +213 -0
- package/runtime/scripts/amalgm-mcp/state/db.js +18 -0
- package/runtime/scripts/amalgm-mcp/state/snapshot.js +3 -1
- package/runtime/scripts/amalgm-mcp/tests/agent-bundles.test.js +54 -0
- package/runtime/scripts/amalgm-mcp/tests/skills.test.js +184 -0
package/package.json
CHANGED
|
@@ -5,6 +5,7 @@ const crypto = require('crypto');
|
|
|
5
5
|
const { getAgentConfig, upsertAgentConfig, agentFieldsFromConfig } = require('../agent-config/store');
|
|
6
6
|
const { normalizeAgentConfig } = require('../agent-config/schema');
|
|
7
7
|
const { createAgent, getAllAgentsWithBuiltins, resolveAgent } = require('../agents/store');
|
|
8
|
+
const { hydrateConfigSkills } = require('../skills/store');
|
|
8
9
|
const { defaultToolboxService } = require('../toolbox/service');
|
|
9
10
|
|
|
10
11
|
const BUNDLE_KIND = 'amalgm.agent.bundle';
|
|
@@ -411,7 +412,7 @@ function collectAgentGraph(rootAgentId) {
|
|
|
411
412
|
seen.add(agentId);
|
|
412
413
|
const agent = resolveAgent(agentId);
|
|
413
414
|
if (!agent) throw new Error(`Agent not found: ${agentId}`);
|
|
414
|
-
const config = getAgentConfig(agent);
|
|
415
|
+
const config = hydrateConfigSkills(getAgentConfig(agent));
|
|
415
416
|
ordered.push({
|
|
416
417
|
sourceAgentId: agent.id,
|
|
417
418
|
agent: portableAgent(agent),
|
|
@@ -32,8 +32,7 @@ function compileSkillsSection(skills) {
|
|
|
32
32
|
const path = sourcePath(skill.source);
|
|
33
33
|
const details = [
|
|
34
34
|
path ? `Path: ${path}` : '',
|
|
35
|
-
skill.content
|
|
36
|
-
skill.content && !path ? skill.content.trim() : '',
|
|
35
|
+
skill.content ? skill.content.trim() : path ? 'Read the skill file when the task matches this skill.' : '',
|
|
37
36
|
];
|
|
38
37
|
lines.push(markdownListItem(title, details));
|
|
39
38
|
}
|
|
@@ -207,7 +207,7 @@ function agentFieldsFromConfig(config = {}) {
|
|
|
207
207
|
systemPrompt: normalized.instructions,
|
|
208
208
|
skills: normalized.skills
|
|
209
209
|
.filter((skill) => skill.enabled !== false)
|
|
210
|
-
.map((skill) => skill.
|
|
210
|
+
.map((skill) => skill.name || skill.id || skill.content)
|
|
211
211
|
.filter(Boolean),
|
|
212
212
|
loadout: normalized.loadout,
|
|
213
213
|
hooks: normalized.hooks,
|
|
@@ -7,6 +7,7 @@ const {
|
|
|
7
7
|
configFromAgent,
|
|
8
8
|
normalizeAgentConfig,
|
|
9
9
|
} = require('./schema');
|
|
10
|
+
const { hydrateConfigSkills } = require('../skills/store');
|
|
10
11
|
|
|
11
12
|
function nowIso() {
|
|
12
13
|
return new Date().toISOString();
|
|
@@ -119,9 +120,11 @@ function listAgentConfigs(db = openLocalDb()) {
|
|
|
119
120
|
.filter(Boolean);
|
|
120
121
|
}
|
|
121
122
|
|
|
122
|
-
function agentWithConfig(agent) {
|
|
123
|
+
function agentWithConfig(agent, options = {}) {
|
|
123
124
|
if (!agent) return null;
|
|
124
|
-
const config =
|
|
125
|
+
const config = options.hydrateSkills
|
|
126
|
+
? hydrateConfigSkills(getAgentConfig(agent))
|
|
127
|
+
: getAgentConfig(agent);
|
|
125
128
|
return {
|
|
126
129
|
...agent,
|
|
127
130
|
...agentFieldsFromConfig(config),
|
|
@@ -67,7 +67,7 @@ async function handleGet(body, sendJson) {
|
|
|
67
67
|
const { agent_id } = body;
|
|
68
68
|
const agent = resolveAgent(agent_id);
|
|
69
69
|
if (!agent) return sendJson(404, { error: `Agent not found: ${agent_id}` });
|
|
70
|
-
sendJson(200, { agent: agentWithConfig(agent) });
|
|
70
|
+
sendJson(200, { agent: agentWithConfig(agent, { hydrateSkills: true }) });
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
async function handleCreate(body, sendJson) {
|
|
@@ -41,6 +41,7 @@ const {
|
|
|
41
41
|
const { agentFieldsFromConfig, getAgentConfig } = require('../agent-config/store');
|
|
42
42
|
const { renderAgentInstructions } = require('../agent-config/renderers/instructions');
|
|
43
43
|
const { loadoutToolIds } = require('../agent-config/loadout');
|
|
44
|
+
const { hydrateConfigSkills } = require('../skills/store');
|
|
44
45
|
const credentialAdapter = require('../../credential-adapter');
|
|
45
46
|
const { buildLocalMcpServerConfigs } = require('../lib/mcp-resolver');
|
|
46
47
|
const {
|
|
@@ -599,7 +600,7 @@ async function handleTalkToAgent(args, context = {}) {
|
|
|
599
600
|
agent.baseHarnessId,
|
|
600
601
|
agent.authMethod || preferredAuthMethod,
|
|
601
602
|
);
|
|
602
|
-
const agentConfig = getAgentConfig(agent);
|
|
603
|
+
const agentConfig = hydrateConfigSkills(getAgentConfig(agent));
|
|
603
604
|
const agentFields = agentFieldsFromConfig(agentConfig);
|
|
604
605
|
const agentFiles = normalizeStringList(agent.files);
|
|
605
606
|
const agentSkills = normalizeStringList(agentFields.skills);
|
|
@@ -203,6 +203,64 @@ async function collectSkillEntries(root) {
|
|
|
203
203
|
return skills;
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
+
function pathStartsWithParent(filePath, parentPath) {
|
|
207
|
+
const normalizedFile = toPosix(path.resolve(filePath));
|
|
208
|
+
const normalizedParent = toPosix(path.resolve(parentPath));
|
|
209
|
+
return normalizedFile === normalizedParent || normalizedFile.startsWith(`${normalizedParent}/`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function includeRegisteredSkillForRoots(skill, roots) {
|
|
213
|
+
if (!skill || typeof skill !== 'object') return false;
|
|
214
|
+
if (!Array.isArray(roots) || roots.length === 0) return true;
|
|
215
|
+
const scope = String(skill.source?.scope || '');
|
|
216
|
+
if (scope === 'global') return true;
|
|
217
|
+
const skillPath = String(skill.source?.path || '');
|
|
218
|
+
const rootPath = String(skill.source?.rootPath || '');
|
|
219
|
+
return roots.some((root) => (
|
|
220
|
+
pathStartsWithParent(skillPath, root)
|
|
221
|
+
|| pathStartsWithParent(rootPath, root)
|
|
222
|
+
));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function rankedRegistrySkill(query, skill) {
|
|
226
|
+
const skillPath = String(skill?.source?.path || '');
|
|
227
|
+
if (!skillPath) return null;
|
|
228
|
+
const title = String(skill.name || '').trim() || pathBasename(skillPath.replace(/\/SKILL\.md$/i, ''));
|
|
229
|
+
const description = typeof skill.description === 'string' && skill.description.trim()
|
|
230
|
+
? skill.description.trim()
|
|
231
|
+
: undefined;
|
|
232
|
+
const score = Math.max(
|
|
233
|
+
fuzzyScorePath(query, skillPath),
|
|
234
|
+
fuzzyScore(query, title) + 8,
|
|
235
|
+
description ? fuzzyScore(query, description) : 0,
|
|
236
|
+
);
|
|
237
|
+
if (score <= 0) return null;
|
|
238
|
+
return {
|
|
239
|
+
id: `skill:${skillPath}`,
|
|
240
|
+
kind: 'skill',
|
|
241
|
+
title,
|
|
242
|
+
subtitle: skillPath,
|
|
243
|
+
path: skillPath,
|
|
244
|
+
isDirectory: false,
|
|
245
|
+
isSkill: true,
|
|
246
|
+
description,
|
|
247
|
+
score,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function collectRegisteredSkillItems(query, roots) {
|
|
252
|
+
try {
|
|
253
|
+
const { listSkills } = require('../skills/store');
|
|
254
|
+
const skills = listSkills({ source: 'fs:search' });
|
|
255
|
+
return skills
|
|
256
|
+
.filter((skill) => includeRegisteredSkillForRoots(skill, roots))
|
|
257
|
+
.map((skill) => rankedRegistrySkill(query, skill))
|
|
258
|
+
.filter(Boolean);
|
|
259
|
+
} catch {
|
|
260
|
+
return [];
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
206
264
|
function searchKindForEntry(entry) {
|
|
207
265
|
if (isSkillFile(entry.relativePath)) return 'skill';
|
|
208
266
|
return entry.isDirectory ? 'folder' : 'file';
|
|
@@ -330,6 +388,7 @@ function createFsSearchHandler({ resolveSafePath }) {
|
|
|
330
388
|
}
|
|
331
389
|
}
|
|
332
390
|
|
|
391
|
+
const registrySkillItems = collectRegisteredSkillItems(query, roots);
|
|
333
392
|
const kindOrder = { skill: 0, file: 1, folder: 2 };
|
|
334
393
|
const seenItems = new Set();
|
|
335
394
|
const items = [];
|
|
@@ -343,6 +402,13 @@ function createFsSearchHandler({ resolveSafePath }) {
|
|
|
343
402
|
}
|
|
344
403
|
}
|
|
345
404
|
|
|
405
|
+
for (const item of registrySkillItems) {
|
|
406
|
+
const key = `${item.kind}:${item.path}`;
|
|
407
|
+
if (seenItems.has(key)) continue;
|
|
408
|
+
seenItems.add(key);
|
|
409
|
+
items.push(item);
|
|
410
|
+
}
|
|
411
|
+
|
|
346
412
|
items.sort((left, right) => {
|
|
347
413
|
if (right.score !== left.score) return right.score - left.score;
|
|
348
414
|
if (kindOrder[left.kind] !== kindOrder[right.kind]) return kindOrder[left.kind] - kindOrder[right.kind];
|
|
@@ -367,7 +433,7 @@ function createFsSearchHandler({ resolveSafePath }) {
|
|
|
367
433
|
skippedDirs: 0,
|
|
368
434
|
errors: errors.length,
|
|
369
435
|
collectedEntries: 0,
|
|
370
|
-
skillEntries:
|
|
436
|
+
skillEntries: registrySkillItems.length,
|
|
371
437
|
timedOut: performance.now() > deadline,
|
|
372
438
|
truncated: false,
|
|
373
439
|
});
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const agentsRest = require('../../agents/rest');
|
|
4
4
|
const agentConfigRest = require('../../agent-config/rest');
|
|
5
5
|
const agentBundlesRest = require('../../agent-bundles/rest');
|
|
6
|
+
const skillsRest = require('../../skills/rest');
|
|
6
7
|
|
|
7
8
|
async function handleAgentRoutes(ctx) {
|
|
8
9
|
if (ctx.pathname === '/agent-config/get' && ctx.method === 'POST') {
|
|
@@ -25,6 +26,18 @@ async function handleAgentRoutes(ctx) {
|
|
|
25
26
|
agentBundlesRest.handleInstall(await ctx.readJsonBody(), ctx.sendJson);
|
|
26
27
|
return true;
|
|
27
28
|
}
|
|
29
|
+
if (ctx.pathname === '/skills/list' && ctx.method === 'GET') {
|
|
30
|
+
skillsRest.handleList(ctx.getQuery(), ctx.sendJson);
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
if (ctx.pathname === '/skills/refresh' && ctx.method === 'POST') {
|
|
34
|
+
skillsRest.handleRefresh(ctx.sendJson);
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
if (ctx.pathname === '/skills/public/install' && ctx.method === 'POST') {
|
|
38
|
+
skillsRest.handleInstallPublic(await ctx.readJsonBody(), ctx.sendJson);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
28
41
|
if (ctx.pathname === '/agents/list' && ctx.method === 'GET') {
|
|
29
42
|
agentsRest.handleList(ctx.sendJson);
|
|
30
43
|
return true;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execFile } = require('child_process');
|
|
4
|
+
const { promisify } = require('util');
|
|
5
|
+
const { syncInstalledSkills } = require('./store');
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
const MAX_BUFFER = 10 * 1024 * 1024;
|
|
9
|
+
|
|
10
|
+
function cleanString(value) {
|
|
11
|
+
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function npmCommand() {
|
|
15
|
+
return process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isSupportedInstallSource(value) {
|
|
19
|
+
const source = cleanString(value);
|
|
20
|
+
if (!source) return false;
|
|
21
|
+
if (/^https?:\/\/[^\s]+$/i.test(source)) return true;
|
|
22
|
+
return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(source);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function buildInstallSkillArgs({ installUrl, slug }) {
|
|
26
|
+
const source = cleanString(installUrl);
|
|
27
|
+
const skillSlug = cleanString(slug);
|
|
28
|
+
if (!isSupportedInstallSource(source)) {
|
|
29
|
+
throw new Error('installUrl must be an https URL or owner/repo reference');
|
|
30
|
+
}
|
|
31
|
+
if (!skillSlug) {
|
|
32
|
+
throw new Error('slug is required');
|
|
33
|
+
}
|
|
34
|
+
return [
|
|
35
|
+
'exec',
|
|
36
|
+
'--yes',
|
|
37
|
+
'--package=skills',
|
|
38
|
+
'--',
|
|
39
|
+
'skills',
|
|
40
|
+
'add',
|
|
41
|
+
source,
|
|
42
|
+
'--skill',
|
|
43
|
+
skillSlug,
|
|
44
|
+
'--agent',
|
|
45
|
+
'codex',
|
|
46
|
+
'--global',
|
|
47
|
+
'--yes',
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function matchesInstalledSlug(skill, slug) {
|
|
52
|
+
const normalizedSlug = cleanString(slug).toLowerCase();
|
|
53
|
+
if (!normalizedSlug || !skill || typeof skill !== 'object') return false;
|
|
54
|
+
const name = cleanString(skill.name).toLowerCase();
|
|
55
|
+
const directoryPath = cleanString(skill?.source?.directoryPath).toLowerCase();
|
|
56
|
+
const sourcePath = cleanString(skill?.source?.path).toLowerCase();
|
|
57
|
+
return name === normalizedSlug
|
|
58
|
+
|| directoryPath.endsWith(`/${normalizedSlug}`)
|
|
59
|
+
|| sourcePath.endsWith(`/${normalizedSlug}/skill.md`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function findInstalledSkillBySlug(skills, slug) {
|
|
63
|
+
return (Array.isArray(skills) ? skills : []).find((skill) => matchesInstalledSlug(skill, slug)) || null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function installPublicSkill({ installUrl, slug, homeDir, selectedCwd } = {}) {
|
|
67
|
+
const args = buildInstallSkillArgs({ installUrl, slug });
|
|
68
|
+
let installError = null;
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
await execFileAsync(npmCommand(), args, {
|
|
72
|
+
encoding: 'utf8',
|
|
73
|
+
maxBuffer: MAX_BUFFER,
|
|
74
|
+
shell: process.platform === 'win32',
|
|
75
|
+
windowsHide: true,
|
|
76
|
+
env: {
|
|
77
|
+
...process.env,
|
|
78
|
+
npm_config_audit: 'false',
|
|
79
|
+
npm_config_fund: 'false',
|
|
80
|
+
},
|
|
81
|
+
timeout: Number(process.env.AMALGM_SKILLS_INSTALL_TIMEOUT_MS || 300000),
|
|
82
|
+
});
|
|
83
|
+
} catch (error) {
|
|
84
|
+
installError = error;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const skills = syncInstalledSkills({
|
|
88
|
+
force: true,
|
|
89
|
+
includeContent: false,
|
|
90
|
+
...(cleanString(homeDir) ? { homeDir } : {}),
|
|
91
|
+
...(cleanString(selectedCwd) ? { selectedCwd } : {}),
|
|
92
|
+
source: 'skills:public-install',
|
|
93
|
+
});
|
|
94
|
+
const installedSkill = findInstalledSkillBySlug(skills, slug);
|
|
95
|
+
|
|
96
|
+
if (!installError) {
|
|
97
|
+
return { ok: true, alreadyInstalled: false, installedSkill, skills };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (installedSkill) {
|
|
101
|
+
return { ok: true, alreadyInstalled: true, installedSkill, skills };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const stderr = cleanString(installError.stderr);
|
|
105
|
+
const stdout = cleanString(installError.stdout);
|
|
106
|
+
const message = stderr || stdout || installError.message || 'Failed to install public skill';
|
|
107
|
+
throw new Error(message);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = {
|
|
111
|
+
buildInstallSkillArgs,
|
|
112
|
+
findInstalledSkillBySlug,
|
|
113
|
+
installPublicSkill,
|
|
114
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { listSkills, syncInstalledSkills } = require('./store');
|
|
4
|
+
const { installPublicSkill } = require('./public');
|
|
5
|
+
|
|
6
|
+
async function handleList(query, sendJson) {
|
|
7
|
+
try {
|
|
8
|
+
const refresh = String(query?.refresh || '').toLowerCase() === '1' || String(query?.refresh || '').toLowerCase() === 'true';
|
|
9
|
+
const includeContent = String(query?.include_content || '').toLowerCase() === '1'
|
|
10
|
+
|| String(query?.include_content || '').toLowerCase() === 'true';
|
|
11
|
+
const skills = refresh
|
|
12
|
+
? syncInstalledSkills({ force: true, includeContent, source: 'skills:list' })
|
|
13
|
+
: listSkills({ includeContent, source: 'skills:list' });
|
|
14
|
+
sendJson(200, { skills });
|
|
15
|
+
} catch (error) {
|
|
16
|
+
sendJson(500, { error: error.message || 'Failed to list skills' });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function handleRefresh(sendJson) {
|
|
21
|
+
try {
|
|
22
|
+
const skills = syncInstalledSkills({ force: true, source: 'skills:refresh' });
|
|
23
|
+
sendJson(200, { ok: true, skills });
|
|
24
|
+
} catch (error) {
|
|
25
|
+
sendJson(500, { error: error.message || 'Failed to refresh skills' });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function handleInstallPublic(body, sendJson) {
|
|
30
|
+
try {
|
|
31
|
+
const result = await installPublicSkill({
|
|
32
|
+
installUrl: body?.installUrl,
|
|
33
|
+
slug: body?.slug,
|
|
34
|
+
homeDir: body?.homeDir,
|
|
35
|
+
selectedCwd: body?.selectedCwd,
|
|
36
|
+
});
|
|
37
|
+
sendJson(200, result);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
sendJson(500, { error: error.message || 'Failed to install public skill' });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = {
|
|
44
|
+
handleInstallPublic,
|
|
45
|
+
handleList,
|
|
46
|
+
handleRefresh,
|
|
47
|
+
};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { readUserPreferences } = require('../lib/prefs');
|
|
7
|
+
|
|
8
|
+
function cleanString(value) {
|
|
9
|
+
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function normalizePath(value) {
|
|
13
|
+
const clean = cleanString(value);
|
|
14
|
+
if (!clean) return '';
|
|
15
|
+
return path.resolve(clean);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function uniquePaths(values) {
|
|
19
|
+
const seen = new Set();
|
|
20
|
+
const out = [];
|
|
21
|
+
for (const value of values) {
|
|
22
|
+
const normalized = normalizePath(value);
|
|
23
|
+
if (!normalized || seen.has(normalized)) continue;
|
|
24
|
+
seen.add(normalized);
|
|
25
|
+
out.push(normalized);
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function existingDirectory(dirPath) {
|
|
31
|
+
const normalized = normalizePath(dirPath);
|
|
32
|
+
if (!normalized) return '';
|
|
33
|
+
try {
|
|
34
|
+
const stat = fs.statSync(normalized);
|
|
35
|
+
return stat.isDirectory() ? normalized : '';
|
|
36
|
+
} catch {
|
|
37
|
+
return '';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function prefsSelectedCwd() {
|
|
42
|
+
try {
|
|
43
|
+
const prefs = readUserPreferences();
|
|
44
|
+
const cwd = prefs?.last_used?.cwd;
|
|
45
|
+
return normalizePath(cwd);
|
|
46
|
+
} catch {
|
|
47
|
+
return '';
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function walkProjectSkillRoots(selectedCwd) {
|
|
52
|
+
const roots = [];
|
|
53
|
+
let current = existingDirectory(selectedCwd);
|
|
54
|
+
while (current) {
|
|
55
|
+
const skillRoot = existingDirectory(path.join(current, '.agents', 'skills'));
|
|
56
|
+
if (skillRoot) roots.push(skillRoot);
|
|
57
|
+
const parent = path.dirname(current);
|
|
58
|
+
if (!parent || parent === current) break;
|
|
59
|
+
current = parent;
|
|
60
|
+
}
|
|
61
|
+
return roots;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function homeDir() {
|
|
65
|
+
return normalizePath(process.env.AMALGM_NATIVE_HOME || os.homedir());
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function buildCanonicalSkillRoots(options = {}) {
|
|
69
|
+
const selectedCwd = normalizePath(options.selectedCwd) || prefsSelectedCwd();
|
|
70
|
+
const home = normalizePath(options.homeDir) || homeDir();
|
|
71
|
+
const codeXHome = normalizePath(options.codexHome || process.env.CODEX_HOME);
|
|
72
|
+
const claudeConfigDir = normalizePath(options.claudeConfigDir || process.env.CLAUDE_CONFIG_DIR);
|
|
73
|
+
return uniquePaths([
|
|
74
|
+
...walkProjectSkillRoots(selectedCwd),
|
|
75
|
+
home ? path.join(home, '.agents', 'skills') : '',
|
|
76
|
+
codeXHome ? path.join(codeXHome, 'skills') : '',
|
|
77
|
+
home ? path.join(home, '.codex', 'skills') : '',
|
|
78
|
+
claudeConfigDir ? path.join(claudeConfigDir, 'skills') : '',
|
|
79
|
+
home ? path.join(home, '.claude', 'skills') : '',
|
|
80
|
+
home ? path.join(home, '.config', 'claude', 'skills') : '',
|
|
81
|
+
home ? path.join(home, '.config', 'opencode', 'skills') : '',
|
|
82
|
+
home ? path.join(home, '.opencode', 'skills') : '',
|
|
83
|
+
]).filter(existingDirectory);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function classifySkillRoot(rootPath, options = {}) {
|
|
87
|
+
const root = normalizePath(rootPath);
|
|
88
|
+
const selectedCwd = normalizePath(options.selectedCwd) || prefsSelectedCwd();
|
|
89
|
+
const home = normalizePath(options.homeDir) || homeDir();
|
|
90
|
+
const codeXHome = normalizePath(options.codexHome || process.env.CODEX_HOME);
|
|
91
|
+
const claudeConfigDir = normalizePath(options.claudeConfigDir || process.env.CLAUDE_CONFIG_DIR);
|
|
92
|
+
|
|
93
|
+
if (selectedCwd && root.startsWith(path.join(selectedCwd, '.agents', 'skills'))) {
|
|
94
|
+
return { provider: 'shared', scope: 'project', rootLabel: 'Project skills' };
|
|
95
|
+
}
|
|
96
|
+
if (root.endsWith(path.normalize(path.join('.agents', 'skills')))) {
|
|
97
|
+
return { provider: 'shared', scope: 'project', rootLabel: 'Project skills' };
|
|
98
|
+
}
|
|
99
|
+
if (home && root === normalizePath(path.join(home, '.agents', 'skills'))) {
|
|
100
|
+
return { provider: 'shared', scope: 'global', rootLabel: 'Shared global skills' };
|
|
101
|
+
}
|
|
102
|
+
if (
|
|
103
|
+
(codeXHome && root === normalizePath(path.join(codeXHome, 'skills')))
|
|
104
|
+
|| (home && root === normalizePath(path.join(home, '.codex', 'skills')))
|
|
105
|
+
) {
|
|
106
|
+
return { provider: 'codex', scope: 'global', rootLabel: 'Codex skills' };
|
|
107
|
+
}
|
|
108
|
+
if (
|
|
109
|
+
(claudeConfigDir && root === normalizePath(path.join(claudeConfigDir, 'skills')))
|
|
110
|
+
|| (home && root === normalizePath(path.join(home, '.claude', 'skills')))
|
|
111
|
+
|| (home && root === normalizePath(path.join(home, '.config', 'claude', 'skills')))
|
|
112
|
+
) {
|
|
113
|
+
return { provider: 'claude_code', scope: 'global', rootLabel: 'Claude Code skills' };
|
|
114
|
+
}
|
|
115
|
+
if (
|
|
116
|
+
(home && root === normalizePath(path.join(home, '.config', 'opencode', 'skills')))
|
|
117
|
+
|| (home && root === normalizePath(path.join(home, '.opencode', 'skills')))
|
|
118
|
+
) {
|
|
119
|
+
return { provider: 'opencode', scope: 'global', rootLabel: 'OpenCode skills' };
|
|
120
|
+
}
|
|
121
|
+
return { provider: 'shared', scope: 'global', rootLabel: 'Installed skills' };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = {
|
|
125
|
+
buildCanonicalSkillRoots,
|
|
126
|
+
classifySkillRoot,
|
|
127
|
+
homeDir,
|
|
128
|
+
normalizePath,
|
|
129
|
+
prefsSelectedCwd,
|
|
130
|
+
uniquePaths,
|
|
131
|
+
walkProjectSkillRoots,
|
|
132
|
+
};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { buildCanonicalSkillRoots, classifySkillRoot, normalizePath } = require('./roots');
|
|
7
|
+
|
|
8
|
+
const FRONTMATTER_RE = /^---\s*\n([\s\S]*?)\n---/;
|
|
9
|
+
|
|
10
|
+
function nowIso() {
|
|
11
|
+
return new Date().toISOString();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function cleanString(value) {
|
|
15
|
+
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function stableId(seed) {
|
|
19
|
+
return `skill-${crypto.createHash('sha256').update(seed).digest('hex').slice(0, 16)}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function trimTrailingSlash(value) {
|
|
23
|
+
if (value === path.sep) return value;
|
|
24
|
+
return value.replace(/[\\/]+$/, '');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseFrontmatter(markdown) {
|
|
28
|
+
const text = String(markdown || '');
|
|
29
|
+
const match = text.match(FRONTMATTER_RE);
|
|
30
|
+
if (!match) return {};
|
|
31
|
+
const fields = {};
|
|
32
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
33
|
+
const parts = line.match(/^\s*([A-Za-z0-9_-]+)\s*:\s*(.*?)\s*$/);
|
|
34
|
+
if (!parts) continue;
|
|
35
|
+
let value = parts[2].trim();
|
|
36
|
+
if (
|
|
37
|
+
(value.startsWith('"') && value.endsWith('"'))
|
|
38
|
+
|| (value.startsWith("'") && value.endsWith("'"))
|
|
39
|
+
) {
|
|
40
|
+
value = value.slice(1, -1);
|
|
41
|
+
}
|
|
42
|
+
fields[parts[1]] = value;
|
|
43
|
+
}
|
|
44
|
+
return fields;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function descriptionFromContent(content) {
|
|
48
|
+
const frontmatter = parseFrontmatter(content);
|
|
49
|
+
return cleanString(frontmatter.description);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function nameFromContent(content, fallback) {
|
|
53
|
+
const frontmatter = parseFrontmatter(content);
|
|
54
|
+
return cleanString(frontmatter.name) || fallback;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function fileDigest(content) {
|
|
58
|
+
return crypto.createHash('sha256').update(String(content || '')).digest('hex');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function scanSkillDir(rootPath, direntName, options = {}) {
|
|
62
|
+
const skillDir = path.join(rootPath, direntName);
|
|
63
|
+
const skillFile = path.join(skillDir, 'SKILL.md');
|
|
64
|
+
let fileStat;
|
|
65
|
+
try {
|
|
66
|
+
fileStat = fs.statSync(skillFile);
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
if (!fileStat.isFile()) return null;
|
|
71
|
+
|
|
72
|
+
const content = fs.readFileSync(skillFile, 'utf8');
|
|
73
|
+
const dirLstat = fs.lstatSync(skillDir);
|
|
74
|
+
const fileLstat = fs.lstatSync(skillFile);
|
|
75
|
+
const resolvedDirPath = trimTrailingSlash(fs.realpathSync(skillDir));
|
|
76
|
+
const resolvedPath = normalizePath(fs.realpathSync(skillFile));
|
|
77
|
+
const classification = classifySkillRoot(rootPath, options);
|
|
78
|
+
const name = nameFromContent(content, direntName);
|
|
79
|
+
const description = descriptionFromContent(content);
|
|
80
|
+
const statMtime = fileStat.mtime instanceof Date ? fileStat.mtime.toISOString() : nowIso();
|
|
81
|
+
const canonicalPath = normalizePath(skillFile);
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
id: stableId(canonicalPath),
|
|
85
|
+
name,
|
|
86
|
+
description,
|
|
87
|
+
content,
|
|
88
|
+
contentHash: fileDigest(content),
|
|
89
|
+
source: {
|
|
90
|
+
kind: 'installed',
|
|
91
|
+
provider: classification.provider,
|
|
92
|
+
scope: classification.scope,
|
|
93
|
+
rootLabel: classification.rootLabel,
|
|
94
|
+
path: canonicalPath,
|
|
95
|
+
directoryPath: trimTrailingSlash(normalizePath(skillDir)),
|
|
96
|
+
resolvedPath,
|
|
97
|
+
resolvedDirectoryPath: resolvedDirPath,
|
|
98
|
+
rootPath: normalizePath(rootPath),
|
|
99
|
+
viaSymlink: dirLstat.isSymbolicLink() || fileLstat.isSymbolicLink(),
|
|
100
|
+
},
|
|
101
|
+
updatedAt: statMtime,
|
|
102
|
+
discoveredAt: nowIso(),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isDirectoryEntry(rootPath, entry) {
|
|
107
|
+
if (entry.isDirectory()) return true;
|
|
108
|
+
if (!entry.isSymbolicLink()) return false;
|
|
109
|
+
try {
|
|
110
|
+
return fs.statSync(path.join(rootPath, entry.name)).isDirectory();
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function scanSkillRoot(rootPath, options = {}) {
|
|
117
|
+
let entries = [];
|
|
118
|
+
try {
|
|
119
|
+
entries = fs.readdirSync(rootPath, { withFileTypes: true });
|
|
120
|
+
} catch {
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
return entries
|
|
124
|
+
.filter((entry) => isDirectoryEntry(rootPath, entry) && !entry.name.startsWith('.'))
|
|
125
|
+
.map((entry) => scanSkillDir(rootPath, entry.name, options))
|
|
126
|
+
.filter(Boolean);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function scanInstalledSkills(options = {}) {
|
|
130
|
+
const roots = buildCanonicalSkillRoots(options);
|
|
131
|
+
const byPath = new Map();
|
|
132
|
+
for (const rootPath of roots) {
|
|
133
|
+
for (const skill of scanSkillRoot(rootPath, options)) {
|
|
134
|
+
const key = cleanString(skill?.source?.path);
|
|
135
|
+
if (!key || byPath.has(key)) continue;
|
|
136
|
+
byPath.set(key, skill);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
roots,
|
|
141
|
+
skills: Array.from(byPath.values()).sort((left, right) => (
|
|
142
|
+
String(left.name || '').localeCompare(String(right.name || ''))
|
|
143
|
+
|| String(left.source?.path || '').localeCompare(String(right.source?.path || ''))
|
|
144
|
+
)),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = {
|
|
149
|
+
descriptionFromContent,
|
|
150
|
+
nameFromContent,
|
|
151
|
+
parseFrontmatter,
|
|
152
|
+
scanInstalledSkills,
|
|
153
|
+
};
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { openLocalDb } = require('../state/db');
|
|
4
|
+
const { insertStateEvent, publishStateEvent } = require('../state/events');
|
|
5
|
+
const { scanInstalledSkills } = require('./scanner');
|
|
6
|
+
const { homeDir, normalizePath, prefsSelectedCwd } = require('./roots');
|
|
7
|
+
|
|
8
|
+
const SKILL_SYNC_STALE_MS = 5_000;
|
|
9
|
+
|
|
10
|
+
let lastSyncedAt = 0;
|
|
11
|
+
let lastSyncKey = '';
|
|
12
|
+
|
|
13
|
+
function cloneJson(value) {
|
|
14
|
+
return value == null ? value : JSON.parse(JSON.stringify(value));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function cleanString(value) {
|
|
18
|
+
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function includeContent(record, includeContent) {
|
|
22
|
+
if (includeContent) return cloneJson(record);
|
|
23
|
+
if (!record || typeof record !== 'object') return record;
|
|
24
|
+
const copy = cloneJson(record);
|
|
25
|
+
delete copy.content;
|
|
26
|
+
delete copy.contentHash;
|
|
27
|
+
return copy;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function rowToSkill(row, options = {}) {
|
|
31
|
+
if (!row) return null;
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(row.skill_json || '{}');
|
|
34
|
+
return includeContent(parsed, options.includeContent === true);
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function listSkillsFromDb(db = openLocalDb(), options = {}) {
|
|
41
|
+
return db.prepare('SELECT * FROM skills ORDER BY updated_at DESC, name ASC, path ASC')
|
|
42
|
+
.all()
|
|
43
|
+
.map((row) => rowToSkill(row, options))
|
|
44
|
+
.filter(Boolean);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getSkillById(id, db = openLocalDb(), options = {}) {
|
|
48
|
+
if (!id) return null;
|
|
49
|
+
const row = db.prepare('SELECT * FROM skills WHERE id = ?').get(id);
|
|
50
|
+
return rowToSkill(row, options);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getSkillByPath(filePath, db = openLocalDb(), options = {}) {
|
|
54
|
+
const pathValue = cleanString(filePath);
|
|
55
|
+
if (!pathValue) return null;
|
|
56
|
+
const row = db.prepare('SELECT * FROM skills WHERE path = ?').get(pathValue);
|
|
57
|
+
return rowToSkill(row, options);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function publicSkillList(skills) {
|
|
61
|
+
return (Array.isArray(skills) ? skills : [])
|
|
62
|
+
.map((skill) => includeContent(skill, false))
|
|
63
|
+
.filter(Boolean);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function syncKey(options = {}) {
|
|
67
|
+
return JSON.stringify({
|
|
68
|
+
selectedCwd: normalizePath(options.selectedCwd) || prefsSelectedCwd(),
|
|
69
|
+
homeDir: normalizePath(options.homeDir) || homeDir(),
|
|
70
|
+
codexHome: normalizePath(options.codexHome || process.env.CODEX_HOME),
|
|
71
|
+
claudeConfigDir: normalizePath(options.claudeConfigDir || process.env.CLAUDE_CONFIG_DIR),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function replaceSkillRows(db, scannedSkills) {
|
|
76
|
+
const currentRows = db.prepare('SELECT id, path, skill_json FROM skills').all();
|
|
77
|
+
const currentById = new Map(currentRows.map((row) => [row.id, row]));
|
|
78
|
+
const nextIds = new Set(scannedSkills.map((skill) => skill.id));
|
|
79
|
+
let changed = false;
|
|
80
|
+
|
|
81
|
+
for (const skill of scannedSkills) {
|
|
82
|
+
const existing = currentById.get(skill.id);
|
|
83
|
+
const serialized = JSON.stringify(skill);
|
|
84
|
+
if (!existing) {
|
|
85
|
+
db.prepare(`
|
|
86
|
+
INSERT INTO skills (id, name, path, provider, scope, root_path, updated_at, discovered_at, skill_json)
|
|
87
|
+
VALUES (@id, @name, @path, @provider, @scope, @root_path, @updated_at, @discovered_at, @skill_json)
|
|
88
|
+
`).run({
|
|
89
|
+
id: skill.id,
|
|
90
|
+
name: skill.name,
|
|
91
|
+
path: skill.source.path,
|
|
92
|
+
provider: skill.source.provider,
|
|
93
|
+
scope: skill.source.scope,
|
|
94
|
+
root_path: skill.source.rootPath,
|
|
95
|
+
updated_at: skill.updatedAt,
|
|
96
|
+
discovered_at: skill.discoveredAt,
|
|
97
|
+
skill_json: serialized,
|
|
98
|
+
});
|
|
99
|
+
changed = true;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (existing.skill_json === serialized) continue;
|
|
103
|
+
db.prepare(`
|
|
104
|
+
UPDATE skills
|
|
105
|
+
SET
|
|
106
|
+
name = @name,
|
|
107
|
+
path = @path,
|
|
108
|
+
provider = @provider,
|
|
109
|
+
scope = @scope,
|
|
110
|
+
root_path = @root_path,
|
|
111
|
+
updated_at = @updated_at,
|
|
112
|
+
discovered_at = @discovered_at,
|
|
113
|
+
skill_json = @skill_json
|
|
114
|
+
WHERE id = @id
|
|
115
|
+
`).run({
|
|
116
|
+
id: skill.id,
|
|
117
|
+
name: skill.name,
|
|
118
|
+
path: skill.source.path,
|
|
119
|
+
provider: skill.source.provider,
|
|
120
|
+
scope: skill.source.scope,
|
|
121
|
+
root_path: skill.source.rootPath,
|
|
122
|
+
updated_at: skill.updatedAt,
|
|
123
|
+
discovered_at: skill.discoveredAt,
|
|
124
|
+
skill_json: serialized,
|
|
125
|
+
});
|
|
126
|
+
changed = true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
for (const row of currentRows) {
|
|
130
|
+
if (nextIds.has(row.id)) continue;
|
|
131
|
+
db.prepare('DELETE FROM skills WHERE id = ?').run(row.id);
|
|
132
|
+
changed = true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return changed;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function syncInstalledSkills(options = {}) {
|
|
139
|
+
const now = Date.now();
|
|
140
|
+
const force = options.force === true;
|
|
141
|
+
const currentKey = syncKey(options);
|
|
142
|
+
if (!force && currentKey === lastSyncKey && now - lastSyncedAt < SKILL_SYNC_STALE_MS) {
|
|
143
|
+
return listSkillsFromDb(openLocalDb(), { includeContent: options.includeContent === true });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const db = openLocalDb();
|
|
147
|
+
const scanned = scanInstalledSkills(options);
|
|
148
|
+
let nextPublic = [];
|
|
149
|
+
let changed = false;
|
|
150
|
+
let event = null;
|
|
151
|
+
|
|
152
|
+
db.transaction(() => {
|
|
153
|
+
changed = replaceSkillRows(db, scanned.skills);
|
|
154
|
+
nextPublic = publicSkillList(listSkillsFromDb(db, { includeContent: false }));
|
|
155
|
+
if (!changed) return;
|
|
156
|
+
event = insertStateEvent(db, {
|
|
157
|
+
resource: 'skills',
|
|
158
|
+
op: 'replace',
|
|
159
|
+
value: nextPublic,
|
|
160
|
+
source: options.source || 'skills:sync',
|
|
161
|
+
});
|
|
162
|
+
})();
|
|
163
|
+
|
|
164
|
+
lastSyncedAt = now;
|
|
165
|
+
lastSyncKey = currentKey;
|
|
166
|
+
if (event) publishStateEvent(event);
|
|
167
|
+
return options.includeContent === true
|
|
168
|
+
? listSkillsFromDb(db, { includeContent: true })
|
|
169
|
+
: nextPublic;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function listSkills(options = {}) {
|
|
173
|
+
return syncInstalledSkills(options);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function skillContentForConfig(skill) {
|
|
177
|
+
const existingContent = cleanString(skill?.content);
|
|
178
|
+
if (existingContent) return existingContent;
|
|
179
|
+
|
|
180
|
+
const byId = getSkillById(cleanString(skill?.id), openLocalDb(), { includeContent: true });
|
|
181
|
+
if (byId?.content) return byId.content;
|
|
182
|
+
|
|
183
|
+
const sourcePath = cleanString(skill?.source?.path || skill?.path);
|
|
184
|
+
const byPath = getSkillByPath(sourcePath, openLocalDb(), { includeContent: true });
|
|
185
|
+
return cleanString(byPath?.content);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function hydrateSkill(skill) {
|
|
189
|
+
if (!skill || typeof skill !== 'object') return skill;
|
|
190
|
+
const content = skillContentForConfig(skill);
|
|
191
|
+
if (!content) return cloneJson(skill);
|
|
192
|
+
return {
|
|
193
|
+
...cloneJson(skill),
|
|
194
|
+
content,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function hydrateConfigSkills(config) {
|
|
199
|
+
if (!config || typeof config !== 'object') return config;
|
|
200
|
+
return {
|
|
201
|
+
...config,
|
|
202
|
+
skills: Array.isArray(config.skills) ? config.skills.map(hydrateSkill) : [],
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
module.exports = {
|
|
207
|
+
getSkillById,
|
|
208
|
+
getSkillByPath,
|
|
209
|
+
hydrateConfigSkills,
|
|
210
|
+
listSkills,
|
|
211
|
+
skillContentForConfig,
|
|
212
|
+
syncInstalledSkills,
|
|
213
|
+
};
|
|
@@ -319,6 +319,24 @@ function migrate(database = openLocalDb()) {
|
|
|
319
319
|
CREATE INDEX IF NOT EXISTS agent_configs_updated_idx
|
|
320
320
|
ON agent_configs(updated_at DESC);
|
|
321
321
|
|
|
322
|
+
CREATE TABLE IF NOT EXISTS skills (
|
|
323
|
+
id TEXT PRIMARY KEY,
|
|
324
|
+
name TEXT NOT NULL,
|
|
325
|
+
path TEXT NOT NULL UNIQUE,
|
|
326
|
+
provider TEXT NOT NULL,
|
|
327
|
+
scope TEXT NOT NULL,
|
|
328
|
+
root_path TEXT NOT NULL,
|
|
329
|
+
updated_at TEXT NOT NULL,
|
|
330
|
+
discovered_at TEXT NOT NULL,
|
|
331
|
+
skill_json TEXT NOT NULL
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
CREATE INDEX IF NOT EXISTS skills_updated_idx
|
|
335
|
+
ON skills(updated_at DESC);
|
|
336
|
+
|
|
337
|
+
CREATE INDEX IF NOT EXISTS skills_path_idx
|
|
338
|
+
ON skills(path);
|
|
339
|
+
|
|
322
340
|
CREATE TABLE IF NOT EXISTS project_context (
|
|
323
341
|
project_id TEXT PRIMARY KEY,
|
|
324
342
|
project_path TEXT NOT NULL,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const { currentSeq } = require('./events');
|
|
4
4
|
|
|
5
|
-
const DEFAULT_RESOURCES = ['automations', 'triggers', 'workflows', 'automation_runs', 'workflow_cell_runs', 'agents', 'agent_configs', 'apps', 'toolbox', 'tools', 'tool_actions', 'hooks', 'projects', 'workspaces', 'project_context', 'user_preferences', 'chat_payloads', 'mcp_connections', 'browser_profiles', 'browser_auth_bundles', 'browser_login_sessions'];
|
|
5
|
+
const DEFAULT_RESOURCES = ['automations', 'triggers', 'workflows', 'automation_runs', 'workflow_cell_runs', 'agents', 'agent_configs', 'skills', 'apps', 'toolbox', 'tools', 'tool_actions', 'hooks', 'projects', 'workspaces', 'project_context', 'user_preferences', 'chat_payloads', 'mcp_connections', 'browser_profiles', 'browser_auth_bundles', 'browser_login_sessions'];
|
|
6
6
|
|
|
7
7
|
function normalizeResources(resources) {
|
|
8
8
|
const values = resources ? String(resources).split(',') : DEFAULT_RESOURCES;
|
|
@@ -41,6 +41,8 @@ function readResource(resource, cache) {
|
|
|
41
41
|
}
|
|
42
42
|
case 'agent_configs':
|
|
43
43
|
return require('../agent-config/store').listAgentConfigs();
|
|
44
|
+
case 'skills':
|
|
45
|
+
return require('../skills/store').listSkills();
|
|
44
46
|
case 'apps':
|
|
45
47
|
return require('../apps/store').loadApps().apps;
|
|
46
48
|
case 'toolbox':
|
|
@@ -5,10 +5,13 @@ process.env.AMALGM_DIR = require('node:fs').mkdtempSync(
|
|
|
5
5
|
);
|
|
6
6
|
|
|
7
7
|
const assert = require('node:assert/strict');
|
|
8
|
+
const fs = require('node:fs');
|
|
9
|
+
const path = require('node:path');
|
|
8
10
|
const test = require('node:test');
|
|
9
11
|
|
|
10
12
|
const { createAgent, resolveAgent } = require('../agents/store');
|
|
11
13
|
const { upsertAgentConfig, getAgentConfig } = require('../agent-config/store');
|
|
14
|
+
const { syncInstalledSkills } = require('../skills/store');
|
|
12
15
|
const { defaultToolboxService } = require('../toolbox/service');
|
|
13
16
|
const { createAgentBundle, installAgentBundle } = require('../agent-bundles/bundles');
|
|
14
17
|
|
|
@@ -243,3 +246,54 @@ test('installing an agent bundle regenerates stale flat graph fields from canoni
|
|
|
243
246
|
assert.equal(result.ok, true);
|
|
244
247
|
assert.equal(Boolean(resolveAgent(result.rootAgentId)), true);
|
|
245
248
|
});
|
|
249
|
+
|
|
250
|
+
test('agent bundle snapshots installed skill text from the local registry', () => {
|
|
251
|
+
const projectRoot = path.join(process.env.AMALGM_DIR, 'bundle-skill-project');
|
|
252
|
+
const skillDir = path.join(projectRoot, '.agents', 'skills', 'portable-skill');
|
|
253
|
+
fs.mkdirSync(skillDir, { recursive: true });
|
|
254
|
+
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), [
|
|
255
|
+
'---',
|
|
256
|
+
'name: "portable-skill"',
|
|
257
|
+
'description: "Portable installed skill"',
|
|
258
|
+
'---',
|
|
259
|
+
'',
|
|
260
|
+
'# Portable skill',
|
|
261
|
+
'',
|
|
262
|
+
'Bring this guidance into shared bundles.',
|
|
263
|
+
'',
|
|
264
|
+
].join('\n'));
|
|
265
|
+
|
|
266
|
+
const [installedSkill] = syncInstalledSkills({
|
|
267
|
+
force: true,
|
|
268
|
+
selectedCwd: projectRoot,
|
|
269
|
+
homeDir: projectRoot,
|
|
270
|
+
source: 'agent-bundles:test',
|
|
271
|
+
}).filter((skill) => skill.name === 'portable-skill');
|
|
272
|
+
|
|
273
|
+
const root = createAgent({
|
|
274
|
+
id: 'bundle-installed-skill-root',
|
|
275
|
+
name: 'Bundle Installed Skill Root',
|
|
276
|
+
baseHarnessId: 'codex',
|
|
277
|
+
baseModelId: 'openai/gpt-5.5',
|
|
278
|
+
systemPrompt: 'Bundle installed skill root',
|
|
279
|
+
loadout: { toolIds: [] },
|
|
280
|
+
authMethod: 'amalgm',
|
|
281
|
+
});
|
|
282
|
+
upsertAgentConfig(root.id, {
|
|
283
|
+
instructions: 'Bundle installed skill root',
|
|
284
|
+
skills: [{
|
|
285
|
+
id: installedSkill.id,
|
|
286
|
+
name: installedSkill.name,
|
|
287
|
+
description: installedSkill.description,
|
|
288
|
+
source: installedSkill.source,
|
|
289
|
+
enabled: true,
|
|
290
|
+
}],
|
|
291
|
+
loadout: { toolIds: [] },
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const { bundle } = createAgentBundle(root.id, { bundleId: 'agent-bundle-installed-skill-test' });
|
|
295
|
+
|
|
296
|
+
assert.equal(bundle.skills.length, 1);
|
|
297
|
+
assert.equal(bundle.skills[0].name, 'portable-skill');
|
|
298
|
+
assert.match(bundle.skills[0].content, /Bring this guidance into shared bundles/);
|
|
299
|
+
});
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
process.env.AMALGM_DIR = require('node:fs').mkdtempSync(
|
|
4
|
+
require('node:path').join(require('node:os').tmpdir(), 'amalgm-skills-'),
|
|
5
|
+
);
|
|
6
|
+
|
|
7
|
+
const assert = require('node:assert/strict');
|
|
8
|
+
const fs = require('node:fs');
|
|
9
|
+
const os = require('node:os');
|
|
10
|
+
const path = require('node:path');
|
|
11
|
+
const test = require('node:test');
|
|
12
|
+
|
|
13
|
+
const { buildInstallSkillArgs, findInstalledSkillBySlug } = require('../skills/public');
|
|
14
|
+
const { scanInstalledSkills } = require('../skills/scanner');
|
|
15
|
+
const { hydrateConfigSkills, listSkills, syncInstalledSkills } = require('../skills/store');
|
|
16
|
+
|
|
17
|
+
function writeSkill(dirPath, description) {
|
|
18
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
19
|
+
fs.writeFileSync(path.join(dirPath, 'SKILL.md'), [
|
|
20
|
+
'---',
|
|
21
|
+
`name: "${path.basename(dirPath)}"`,
|
|
22
|
+
`description: "${description}"`,
|
|
23
|
+
'---',
|
|
24
|
+
'',
|
|
25
|
+
`# ${path.basename(dirPath)}`,
|
|
26
|
+
'',
|
|
27
|
+
`Use ${path.basename(dirPath)} when relevant.`,
|
|
28
|
+
'',
|
|
29
|
+
].join('\n'));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
test('scanInstalledSkills discovers canonical project/global roots and symlink metadata', () => {
|
|
33
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-skills-home-'));
|
|
34
|
+
const project = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-skills-project-'));
|
|
35
|
+
const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-skills-source-'));
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const globalSkillDir = path.join(home, '.codex', 'skills', 'global-review');
|
|
39
|
+
writeSkill(globalSkillDir, 'Global skill');
|
|
40
|
+
|
|
41
|
+
const realProjectSkillDir = path.join(sourceRoot, 'project-portability');
|
|
42
|
+
writeSkill(realProjectSkillDir, 'Project skill');
|
|
43
|
+
const projectSkillRoot = path.join(project, '.agents', 'skills');
|
|
44
|
+
fs.mkdirSync(projectSkillRoot, { recursive: true });
|
|
45
|
+
fs.symlinkSync(realProjectSkillDir, path.join(projectSkillRoot, 'project-portability'));
|
|
46
|
+
|
|
47
|
+
const result = scanInstalledSkills({
|
|
48
|
+
selectedCwd: project,
|
|
49
|
+
homeDir: home,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
assert.equal(result.roots.includes(path.join(project, '.agents', 'skills')), true);
|
|
53
|
+
assert.equal(result.roots.includes(path.join(home, '.codex', 'skills')), true);
|
|
54
|
+
assert.equal(result.skills.length, 2);
|
|
55
|
+
|
|
56
|
+
const projectSkill = result.skills.find((skill) => skill.name === 'project-portability');
|
|
57
|
+
assert.equal(projectSkill?.source.scope, 'project');
|
|
58
|
+
assert.equal(projectSkill?.source.viaSymlink, true);
|
|
59
|
+
assert.equal(projectSkill?.source.resolvedDirectoryPath, fs.realpathSync(realProjectSkillDir));
|
|
60
|
+
|
|
61
|
+
const globalSkill = result.skills.find((skill) => skill.name === 'global-review');
|
|
62
|
+
assert.equal(globalSkill?.source.provider, 'codex');
|
|
63
|
+
assert.equal(globalSkill?.source.scope, 'global');
|
|
64
|
+
} finally {
|
|
65
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
66
|
+
fs.rmSync(project, { recursive: true, force: true });
|
|
67
|
+
fs.rmSync(sourceRoot, { recursive: true, force: true });
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('hydrateConfigSkills loads installed skill content from the registry', () => {
|
|
72
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-skills-home-'));
|
|
73
|
+
const project = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-skills-project-'));
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const skillDir = path.join(project, '.agents', 'skills', 'agent-brief');
|
|
77
|
+
writeSkill(skillDir, 'Skill used for agent briefing');
|
|
78
|
+
|
|
79
|
+
const skills = syncInstalledSkills({
|
|
80
|
+
force: true,
|
|
81
|
+
selectedCwd: project,
|
|
82
|
+
homeDir: home,
|
|
83
|
+
source: 'skills:test',
|
|
84
|
+
});
|
|
85
|
+
const installed = skills.find((skill) => skill.name === 'agent-brief');
|
|
86
|
+
assert.ok(installed);
|
|
87
|
+
|
|
88
|
+
const hydrated = hydrateConfigSkills({
|
|
89
|
+
version: 1,
|
|
90
|
+
instructions: '',
|
|
91
|
+
skills: [{
|
|
92
|
+
id: installed.id,
|
|
93
|
+
name: installed.name,
|
|
94
|
+
source: installed.source,
|
|
95
|
+
enabled: true,
|
|
96
|
+
}],
|
|
97
|
+
loadout: { toolIds: [] },
|
|
98
|
+
hooks: [],
|
|
99
|
+
subagents: [],
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
assert.equal(typeof hydrated.skills[0].content, 'string');
|
|
103
|
+
assert.match(hydrated.skills[0].content, /Use agent-brief when relevant/);
|
|
104
|
+
} finally {
|
|
105
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
106
|
+
fs.rmSync(project, { recursive: true, force: true });
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('listSkills optionally includes stored skill content', () => {
|
|
111
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-skills-home-'));
|
|
112
|
+
const project = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-skills-project-'));
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const skillDir = path.join(project, '.agents', 'skills', 'portable-review');
|
|
116
|
+
writeSkill(skillDir, 'Portable review skill');
|
|
117
|
+
|
|
118
|
+
syncInstalledSkills({
|
|
119
|
+
force: true,
|
|
120
|
+
selectedCwd: project,
|
|
121
|
+
homeDir: home,
|
|
122
|
+
includeContent: true,
|
|
123
|
+
source: 'skills:test',
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const publicSkills = listSkills({
|
|
127
|
+
selectedCwd: project,
|
|
128
|
+
homeDir: home,
|
|
129
|
+
source: 'skills:test',
|
|
130
|
+
});
|
|
131
|
+
const privateSkills = listSkills({
|
|
132
|
+
selectedCwd: project,
|
|
133
|
+
homeDir: home,
|
|
134
|
+
includeContent: true,
|
|
135
|
+
source: 'skills:test',
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const publicSkill = publicSkills.find((skill) => skill.name === 'portable-review');
|
|
139
|
+
const privateSkill = privateSkills.find((skill) => skill.name === 'portable-review');
|
|
140
|
+
assert.equal(typeof publicSkill?.content, 'undefined');
|
|
141
|
+
assert.match(String(privateSkill?.content || ''), /Use portable-review when relevant/);
|
|
142
|
+
} finally {
|
|
143
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
144
|
+
fs.rmSync(project, { recursive: true, force: true });
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('buildInstallSkillArgs targets the global Codex skills install flow', () => {
|
|
149
|
+
assert.deepEqual(
|
|
150
|
+
buildInstallSkillArgs({
|
|
151
|
+
installUrl: 'https://github.com/vercel-labs/skills',
|
|
152
|
+
slug: 'find-skills',
|
|
153
|
+
}),
|
|
154
|
+
[
|
|
155
|
+
'exec',
|
|
156
|
+
'--yes',
|
|
157
|
+
'--package=skills',
|
|
158
|
+
'--',
|
|
159
|
+
'skills',
|
|
160
|
+
'add',
|
|
161
|
+
'https://github.com/vercel-labs/skills',
|
|
162
|
+
'--skill',
|
|
163
|
+
'find-skills',
|
|
164
|
+
'--agent',
|
|
165
|
+
'codex',
|
|
166
|
+
'--global',
|
|
167
|
+
'--yes',
|
|
168
|
+
],
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('findInstalledSkillBySlug matches installed public skills by name or directory', () => {
|
|
173
|
+
const installedSkill = {
|
|
174
|
+
id: 'skill:find-skills',
|
|
175
|
+
name: 'find-skills',
|
|
176
|
+
source: {
|
|
177
|
+
path: '/tmp/.agents/skills/find-skills/SKILL.md',
|
|
178
|
+
directoryPath: '/tmp/.agents/skills/find-skills',
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
assert.equal(findInstalledSkillBySlug([installedSkill], 'find-skills'), installedSkill);
|
|
183
|
+
assert.equal(findInstalledSkillBySlug([installedSkill], 'missing-skill'), null);
|
|
184
|
+
});
|