@botcord/daemon 0.2.89 → 0.2.90
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/cloud-daemon.js +18 -4
- package/dist/daemon.d.ts +2 -3
- package/dist/daemon.js +21 -6
- package/dist/provision.d.ts +1 -0
- package/dist/provision.js +83 -7
- package/dist/runtime-models.js +46 -0
- package/dist/skill-index.d.ts +14 -2
- package/dist/skill-index.js +81 -34
- package/dist/skill-installer.d.ts +61 -0
- package/dist/skill-installer.js +340 -0
- package/dist/system-context.d.ts +6 -0
- package/dist/system-context.js +1 -1
- package/package.json +3 -3
- package/src/__tests__/runtime-discovery.test.ts +6 -3
- package/src/__tests__/runtime-models.test.ts +53 -0
- package/src/__tests__/skill-index.test.ts +89 -13
- package/src/__tests__/skill-installer.test.ts +224 -0
- package/src/cloud-daemon.ts +17 -4
- package/src/daemon.ts +23 -8
- package/src/provision.ts +90 -12
- package/src/runtime-models.ts +47 -0
- package/src/skill-index.ts +103 -47
- package/src/skill-installer.ts +472 -0
- package/src/system-context.ts +7 -1
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { agentCodexHomeDir, agentWorkspaceDir, } from "./agent-workspace.js";
|
|
7
|
+
import { collectAgentSkillSnapshot, } from "./skill-index.js";
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
const SAFE_SKILL_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
|
|
10
|
+
const MAX_INLINE_FILE_BYTES = 256 * 1024;
|
|
11
|
+
const TRUSTED_VERCEL_PACKAGE_SPECS = new Set([
|
|
12
|
+
"https://github.com/vercel-labs/skills",
|
|
13
|
+
"github:vercel-labs/skills",
|
|
14
|
+
"vercel-labs/skills",
|
|
15
|
+
]);
|
|
16
|
+
export function normalizeSkillManifest(input) {
|
|
17
|
+
const rawName = input.name ?? input.id;
|
|
18
|
+
if (!rawName)
|
|
19
|
+
throw new Error("skill manifest requires name or id");
|
|
20
|
+
const name = assertSafeSkillName(rawName);
|
|
21
|
+
const description = sanitizeInline(input.description ?? "");
|
|
22
|
+
const skillMd = input.skillMd ?? input.markdown ?? renderSkillMarkdown(name, description);
|
|
23
|
+
if (!skillMd.trim())
|
|
24
|
+
throw new Error(`skill ${name} has empty SKILL.md content`);
|
|
25
|
+
return {
|
|
26
|
+
name,
|
|
27
|
+
...(description ? { description } : {}),
|
|
28
|
+
skillMd,
|
|
29
|
+
files: input.files ?? [],
|
|
30
|
+
...(input.targetRuntimes ? { targetRuntimes: normalizeTargets(input.targetRuntimes) } : {}),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export function installAgentSkillManifest(agentId, manifest, opts = {}) {
|
|
34
|
+
const normalized = normalizeSkillManifest(manifest);
|
|
35
|
+
const installed = [
|
|
36
|
+
installNormalizedSkill(agentId, normalized, opts),
|
|
37
|
+
];
|
|
38
|
+
return {
|
|
39
|
+
agentId,
|
|
40
|
+
installed,
|
|
41
|
+
snapshot: collectAgentSkillSnapshot(agentId, { runtime: opts.runtime }),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export function installBotLearnArchiveManifest(agentId, archive, opts = {}) {
|
|
45
|
+
const skills = archive.skills && archive.skills.length > 0
|
|
46
|
+
? archive.skills
|
|
47
|
+
: [archive];
|
|
48
|
+
const installed = skills.map((skill) => installNormalizedSkill(agentId, normalizeSkillManifest({
|
|
49
|
+
...skill,
|
|
50
|
+
targetRuntimes: skill.targetRuntimes ?? archive.targetRuntimes,
|
|
51
|
+
}), opts));
|
|
52
|
+
return {
|
|
53
|
+
agentId,
|
|
54
|
+
installed,
|
|
55
|
+
snapshot: collectAgentSkillSnapshot(agentId, { runtime: opts.runtime }),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
export async function installVercelSkillsForAgent(opts) {
|
|
59
|
+
const packageSpec = normalizeTrustedVercelPackageSpec(opts.packageSpec);
|
|
60
|
+
const workspace = agentWorkspaceDir(opts.agentId);
|
|
61
|
+
const tempHome = mkdtempSync(path.join(tmpdir(), "botcord-skills-"));
|
|
62
|
+
const targets = targetsForRuntime(opts.runtime);
|
|
63
|
+
const executor = opts.executor ?? defaultVercelSkillsExecutor;
|
|
64
|
+
const args = buildVercelSkillsArgs(packageSpec, opts.skills, targets);
|
|
65
|
+
try {
|
|
66
|
+
await executor("npx", args, {
|
|
67
|
+
cwd: workspace,
|
|
68
|
+
env: {
|
|
69
|
+
...process.env,
|
|
70
|
+
HOME: tempHome,
|
|
71
|
+
USERPROFILE: tempHome,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
const installed = importVercelInstalledSkills(opts.agentId, tempHome, targets);
|
|
75
|
+
return {
|
|
76
|
+
agentId: opts.agentId,
|
|
77
|
+
installed,
|
|
78
|
+
snapshot: collectAgentSkillSnapshot(opts.agentId, { runtime: opts.runtime }),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
rmSync(tempHome, { recursive: true, force: true });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export function buildVercelSkillsArgs(packageSpec, skills, targets) {
|
|
86
|
+
const normalizedPackageSpec = normalizeTrustedVercelPackageSpec(packageSpec);
|
|
87
|
+
const args = ["--yes", "skills", "add", normalizedPackageSpec, "--global", "--copy", "--yes"];
|
|
88
|
+
for (const skill of skills ?? []) {
|
|
89
|
+
if (!skill.trim())
|
|
90
|
+
continue;
|
|
91
|
+
args.push("--skill", skill);
|
|
92
|
+
}
|
|
93
|
+
for (const target of targets) {
|
|
94
|
+
args.push("--agent", target === "codex" ? "codex" : "claude-code");
|
|
95
|
+
}
|
|
96
|
+
return args;
|
|
97
|
+
}
|
|
98
|
+
function installNormalizedSkill(agentId, manifest, opts) {
|
|
99
|
+
const targets = manifest.targetRuntimes ?? targetsForRuntime(opts.runtime);
|
|
100
|
+
validateSkillFiles(manifest.files, opts.sourceRoot);
|
|
101
|
+
const paths = [];
|
|
102
|
+
for (const target of targets) {
|
|
103
|
+
const skillDir = path.join(skillRootForTarget(agentId, target), manifest.name);
|
|
104
|
+
writeSkillDir(skillDir, manifest, opts.sourceRoot);
|
|
105
|
+
paths.push(skillDir);
|
|
106
|
+
}
|
|
107
|
+
return { name: manifest.name, targets, paths };
|
|
108
|
+
}
|
|
109
|
+
function writeSkillDir(skillDir, manifest, sourceRoot) {
|
|
110
|
+
const parent = path.dirname(skillDir);
|
|
111
|
+
mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
112
|
+
const tempDir = mkdtempSync(path.join(parent, `.${path.basename(skillDir)}-tmp-`));
|
|
113
|
+
try {
|
|
114
|
+
writeFileSync(path.join(tempDir, "SKILL.md"), manifest.skillMd, { mode: 0o600 });
|
|
115
|
+
for (const file of manifest.files) {
|
|
116
|
+
const relativePath = assertSafeRelativePath(file.path);
|
|
117
|
+
const dest = path.join(tempDir, relativePath);
|
|
118
|
+
mkdirSync(path.dirname(dest), { recursive: true, mode: 0o700 });
|
|
119
|
+
if (file.content !== undefined) {
|
|
120
|
+
writeFileSync(dest, file.content, { mode: 0o600 });
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
copySafeSourcePath(sourceRoot, file.sourcePath, dest);
|
|
124
|
+
}
|
|
125
|
+
rmSync(skillDir, { recursive: true, force: true });
|
|
126
|
+
renameSync(tempDir, skillDir);
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
130
|
+
throw err;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function importVercelInstalledSkills(agentId, tempHome, targets) {
|
|
134
|
+
const byName = new Map();
|
|
135
|
+
for (const target of targets) {
|
|
136
|
+
const sourceRoot = target === "codex"
|
|
137
|
+
? path.join(tempHome, ".codex", "skills")
|
|
138
|
+
: path.join(tempHome, ".claude", "skills");
|
|
139
|
+
if (!existsSync(sourceRoot))
|
|
140
|
+
continue;
|
|
141
|
+
for (const sourceSkillDir of findSkillDirs(sourceRoot)) {
|
|
142
|
+
const name = assertSafeSkillName(readSkillName(sourceSkillDir));
|
|
143
|
+
const dest = path.join(skillRootForTarget(agentId, target), name);
|
|
144
|
+
copySafeSkillDir(sourceSkillDir, dest);
|
|
145
|
+
const existing = byName.get(name);
|
|
146
|
+
if (existing) {
|
|
147
|
+
existing.targets.push(target);
|
|
148
|
+
existing.paths.push(dest);
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
byName.set(name, { name, targets: [target], paths: [dest] });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name));
|
|
156
|
+
}
|
|
157
|
+
function findSkillDirs(root) {
|
|
158
|
+
const out = [];
|
|
159
|
+
const visit = (dir, depth) => {
|
|
160
|
+
if (depth > 3)
|
|
161
|
+
return;
|
|
162
|
+
const skillMd = path.join(dir, "SKILL.md");
|
|
163
|
+
if (existsSync(skillMd) && lstatSync(skillMd).isFile()) {
|
|
164
|
+
out.push(dir);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
let children;
|
|
168
|
+
try {
|
|
169
|
+
children = readdirSync(dir).sort((a, b) => a.localeCompare(b));
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
for (const child of children) {
|
|
175
|
+
const childPath = path.join(dir, child);
|
|
176
|
+
try {
|
|
177
|
+
const childStat = lstatSync(childPath);
|
|
178
|
+
if (childStat.isSymbolicLink())
|
|
179
|
+
continue;
|
|
180
|
+
if (childStat.isDirectory())
|
|
181
|
+
visit(childPath, depth + 1);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
/* skip unreadable entries */
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
visit(root, 0);
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
function readSkillName(skillDir) {
|
|
192
|
+
const fallback = path.basename(skillDir);
|
|
193
|
+
let raw = "";
|
|
194
|
+
try {
|
|
195
|
+
raw = readFileSync(path.join(skillDir, "SKILL.md"), "utf8").slice(0, 8192);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return fallback;
|
|
199
|
+
}
|
|
200
|
+
const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
201
|
+
const name = fm?.[1]?.match(/^name:\s*(.+?)\s*$/m)?.[1];
|
|
202
|
+
return name ? unquote(name).trim() : fallback;
|
|
203
|
+
}
|
|
204
|
+
function targetsForRuntime(runtime) {
|
|
205
|
+
if (runtime === "codex")
|
|
206
|
+
return ["codex"];
|
|
207
|
+
if (runtime === "claude-code")
|
|
208
|
+
return ["claude-code"];
|
|
209
|
+
return ["claude-code", "codex"];
|
|
210
|
+
}
|
|
211
|
+
function normalizeTargets(targets) {
|
|
212
|
+
const out = [];
|
|
213
|
+
for (const target of targets) {
|
|
214
|
+
if (target !== "claude-code" && target !== "codex") {
|
|
215
|
+
throw new Error(`unsupported skill target: ${String(target)}`);
|
|
216
|
+
}
|
|
217
|
+
if (!out.includes(target))
|
|
218
|
+
out.push(target);
|
|
219
|
+
}
|
|
220
|
+
if (out.length === 0)
|
|
221
|
+
throw new Error("at least one target runtime is required");
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
function normalizeTrustedVercelPackageSpec(packageSpec) {
|
|
225
|
+
const cleaned = packageSpec.trim();
|
|
226
|
+
if (!cleaned)
|
|
227
|
+
throw new Error("packageSpec is required");
|
|
228
|
+
if (!TRUSTED_VERCEL_PACKAGE_SPECS.has(cleaned)) {
|
|
229
|
+
throw new Error(`unsupported vercel skills packageSpec: ${cleaned}`);
|
|
230
|
+
}
|
|
231
|
+
return cleaned;
|
|
232
|
+
}
|
|
233
|
+
function validateSkillFiles(files, sourceRoot) {
|
|
234
|
+
for (const file of files) {
|
|
235
|
+
assertSafeRelativePath(file.path);
|
|
236
|
+
if (file.content !== undefined) {
|
|
237
|
+
if (Buffer.byteLength(file.content, "utf8") > MAX_INLINE_FILE_BYTES) {
|
|
238
|
+
throw new Error(`skill file too large: ${file.path}`);
|
|
239
|
+
}
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (!sourceRoot || !file.sourcePath) {
|
|
243
|
+
throw new Error(`skill file ${file.path} requires content or sourcePath with sourceRoot`);
|
|
244
|
+
}
|
|
245
|
+
resolveSafeSourcePath(sourceRoot, file.sourcePath);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
function resolveSafeSourcePath(sourceRoot, relativeSourcePath) {
|
|
249
|
+
const safeRelativePath = assertSafeRelativePath(relativeSourcePath);
|
|
250
|
+
const rootReal = realpathSync(sourceRoot);
|
|
251
|
+
const sourcePath = path.resolve(sourceRoot, safeRelativePath);
|
|
252
|
+
if (lstatSync(sourcePath).isSymbolicLink()) {
|
|
253
|
+
throw new Error(`unsafe source path symlink: ${relativeSourcePath}`);
|
|
254
|
+
}
|
|
255
|
+
const sourceReal = realpathSync(sourcePath);
|
|
256
|
+
if (sourceReal !== rootReal && !sourceReal.startsWith(`${rootReal}${path.sep}`)) {
|
|
257
|
+
throw new Error(`unsafe source path: ${relativeSourcePath}`);
|
|
258
|
+
}
|
|
259
|
+
return sourceReal;
|
|
260
|
+
}
|
|
261
|
+
function copySafeSourcePath(sourceRoot, relativeSourcePath, dest) {
|
|
262
|
+
const source = resolveSafeSourcePath(sourceRoot, relativeSourcePath);
|
|
263
|
+
copyNoSymlinks(source, dest);
|
|
264
|
+
}
|
|
265
|
+
function copySafeSkillDir(sourceSkillDir, dest) {
|
|
266
|
+
const parent = path.dirname(dest);
|
|
267
|
+
mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
268
|
+
const tempDir = mkdtempSync(path.join(parent, `.${path.basename(dest)}-tmp-`));
|
|
269
|
+
try {
|
|
270
|
+
copyNoSymlinks(sourceSkillDir, tempDir);
|
|
271
|
+
rmSync(dest, { recursive: true, force: true });
|
|
272
|
+
renameSync(tempDir, dest);
|
|
273
|
+
}
|
|
274
|
+
catch (err) {
|
|
275
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
276
|
+
throw err;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
function copyNoSymlinks(source, dest) {
|
|
280
|
+
const sourceStat = lstatSync(source);
|
|
281
|
+
if (sourceStat.isSymbolicLink()) {
|
|
282
|
+
throw new Error(`skill import rejects symlink: ${source}`);
|
|
283
|
+
}
|
|
284
|
+
if (sourceStat.isDirectory()) {
|
|
285
|
+
mkdirSync(dest, { recursive: true, mode: 0o700 });
|
|
286
|
+
for (const child of readdirSync(source)) {
|
|
287
|
+
copyNoSymlinks(path.join(source, child), path.join(dest, child));
|
|
288
|
+
}
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (!sourceStat.isFile()) {
|
|
292
|
+
throw new Error(`skill import supports only files and directories: ${source}`);
|
|
293
|
+
}
|
|
294
|
+
cpSync(source, dest, { force: true, dereference: false });
|
|
295
|
+
}
|
|
296
|
+
function skillRootForTarget(agentId, target) {
|
|
297
|
+
return target === "codex"
|
|
298
|
+
? path.join(agentCodexHomeDir(agentId), "skills")
|
|
299
|
+
: path.join(agentWorkspaceDir(agentId), ".claude", "skills");
|
|
300
|
+
}
|
|
301
|
+
function assertSafeSkillName(value) {
|
|
302
|
+
const name = value.trim();
|
|
303
|
+
if (!SAFE_SKILL_NAME.test(name) || name === "." || name === "..") {
|
|
304
|
+
throw new Error(`unsafe skill name: ${JSON.stringify(value)}`);
|
|
305
|
+
}
|
|
306
|
+
return name;
|
|
307
|
+
}
|
|
308
|
+
function assertSafeRelativePath(value) {
|
|
309
|
+
const normalized = path.normalize(value);
|
|
310
|
+
if (!value ||
|
|
311
|
+
path.isAbsolute(value) ||
|
|
312
|
+
normalized === "." ||
|
|
313
|
+
normalized.startsWith("..") ||
|
|
314
|
+
normalized.split(path.sep).includes("..")) {
|
|
315
|
+
throw new Error(`unsafe skill file path: ${JSON.stringify(value)}`);
|
|
316
|
+
}
|
|
317
|
+
return normalized;
|
|
318
|
+
}
|
|
319
|
+
function renderSkillMarkdown(name, description) {
|
|
320
|
+
const desc = description ? `description: "${description.replace(/"/g, '\\"')}"\n` : "";
|
|
321
|
+
return `---\nname: ${name}\n${desc}---\n\n# ${name}\n`;
|
|
322
|
+
}
|
|
323
|
+
function sanitizeInline(value) {
|
|
324
|
+
return value.replace(/\s+/g, " ").trim();
|
|
325
|
+
}
|
|
326
|
+
function unquote(value) {
|
|
327
|
+
const trimmed = value.trim();
|
|
328
|
+
if ((trimmed.startsWith("\"") && trimmed.endsWith("\"")) ||
|
|
329
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
330
|
+
return trimmed.slice(1, -1);
|
|
331
|
+
}
|
|
332
|
+
return trimmed;
|
|
333
|
+
}
|
|
334
|
+
async function defaultVercelSkillsExecutor(command, args, options) {
|
|
335
|
+
await execFileAsync(command, args, {
|
|
336
|
+
...options,
|
|
337
|
+
encoding: "utf8",
|
|
338
|
+
maxBuffer: 1024 * 1024,
|
|
339
|
+
});
|
|
340
|
+
}
|
package/dist/system-context.d.ts
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
*/
|
|
27
27
|
import type { GatewayInboundMessage } from "./gateway/index.js";
|
|
28
28
|
import type { ActivityTracker } from "./activity-tracker.js";
|
|
29
|
+
import type { SkillIndexOptions } from "./skill-index.js";
|
|
29
30
|
/**
|
|
30
31
|
* Async per-turn room-context builder (see `room-context.ts`). Returns the
|
|
31
32
|
* rendered `[BotCord Room Context]` block, or `null` when there is nothing
|
|
@@ -59,6 +60,11 @@ export interface SystemContextDeps {
|
|
|
59
60
|
* dirs each turn. Return null to suppress the block.
|
|
60
61
|
*/
|
|
61
62
|
skillIndexBuilder?: (message: GatewayInboundMessage) => string | null;
|
|
63
|
+
/**
|
|
64
|
+
* Runtime/profile options for the default soft skill scanner. Kept lazy so
|
|
65
|
+
* hot-provisioned runtime changes are visible without rebuilding this closure.
|
|
66
|
+
*/
|
|
67
|
+
skillIndexOptions?: (message: GatewayInboundMessage) => SkillIndexOptions;
|
|
62
68
|
}
|
|
63
69
|
/**
|
|
64
70
|
* Build a {@link SystemContextBuilder} for the gateway dispatcher.
|
package/dist/system-context.js
CHANGED
|
@@ -112,7 +112,7 @@ export function createDaemonSystemContextBuilder(deps) {
|
|
|
112
112
|
try {
|
|
113
113
|
if (deps.skillIndexBuilder)
|
|
114
114
|
return deps.skillIndexBuilder(message);
|
|
115
|
-
return buildSoftSkillIndexPrompt(deps.agentId);
|
|
115
|
+
return buildSoftSkillIndexPrompt(deps.agentId, deps.skillIndexOptions?.(message) ?? {});
|
|
116
116
|
}
|
|
117
117
|
catch (err) {
|
|
118
118
|
log.warn("system-context: skill index build failed — skipping skill block", {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@botcord/daemon",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.90",
|
|
4
4
|
"description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"@larksuiteoapi/node-sdk": "^1.63.1",
|
|
25
25
|
"ws": "^8.20.1",
|
|
26
|
-
"@botcord/
|
|
27
|
-
"@botcord/
|
|
26
|
+
"@botcord/protocol-core": "^0.2.13",
|
|
27
|
+
"@botcord/cli": "^0.1.18"
|
|
28
28
|
},
|
|
29
29
|
"overrides": {
|
|
30
30
|
"axios": "^1.15.2"
|
|
@@ -147,11 +147,14 @@ describe("collectRuntimeSnapshot", () => {
|
|
|
147
147
|
});
|
|
148
148
|
|
|
149
149
|
it("omits optional fields rather than emitting explicit undefineds", () => {
|
|
150
|
+
// Use a synthetic runtime id with no catalog strategy so the snapshot
|
|
151
|
+
// doesn't pick up a `models` field. Switching to "gemini" here would
|
|
152
|
+
// attach the built-in gemini model list.
|
|
150
153
|
setRuntimes([
|
|
151
154
|
{
|
|
152
|
-
id: "
|
|
153
|
-
displayName: "
|
|
154
|
-
binary: "
|
|
155
|
+
id: "unknown-runtime",
|
|
156
|
+
displayName: "Unknown",
|
|
157
|
+
binary: "unknown",
|
|
155
158
|
supportsRun: true,
|
|
156
159
|
result: { available: true },
|
|
157
160
|
},
|
|
@@ -380,4 +380,57 @@ describe("runtime model discovery parsers", () => {
|
|
|
380
380
|
},
|
|
381
381
|
]);
|
|
382
382
|
});
|
|
383
|
+
|
|
384
|
+
it("returns the built-in Gemini catalog and caches it under gemini.json", () => {
|
|
385
|
+
const tmp = mkdtempSync(path.join(tmpdir(), "daemon-gemini-catalog-"));
|
|
386
|
+
const prevHome = process.env.HOME;
|
|
387
|
+
const prevCacheDir = process.env.BOTCORD_RUNTIME_CATALOG_CACHE_DIR;
|
|
388
|
+
try {
|
|
389
|
+
const home = path.join(tmp, "home");
|
|
390
|
+
const cacheDir = path.join(tmp, "catalog-cache");
|
|
391
|
+
mkdirSync(home, { recursive: true });
|
|
392
|
+
process.env.HOME = home;
|
|
393
|
+
process.env.BOTCORD_RUNTIME_CATALOG_CACHE_DIR = cacheDir;
|
|
394
|
+
|
|
395
|
+
const catalog = discoverRuntimeModelCatalog({
|
|
396
|
+
id: "gemini",
|
|
397
|
+
displayName: "Gemini CLI",
|
|
398
|
+
binary: "gemini",
|
|
399
|
+
supportsRun: true,
|
|
400
|
+
result: { available: true, path: path.join(tmp, "missing-gemini") },
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
const ids = catalog.models?.map((m) => m.id) ?? [];
|
|
404
|
+
// `auto` is the default — wizard should pre-select it.
|
|
405
|
+
expect(catalog.models?.find((m) => m.isDefault)?.id).toBe("auto");
|
|
406
|
+
// Spot-check the documented family is present.
|
|
407
|
+
expect(ids).toEqual(
|
|
408
|
+
expect.arrayContaining([
|
|
409
|
+
"auto",
|
|
410
|
+
"pro",
|
|
411
|
+
"flash",
|
|
412
|
+
"flash-lite",
|
|
413
|
+
"gemini-2.5-pro",
|
|
414
|
+
"gemini-2.5-flash",
|
|
415
|
+
"gemini-3-pro-preview",
|
|
416
|
+
"gemini-3.1-pro-preview",
|
|
417
|
+
]),
|
|
418
|
+
);
|
|
419
|
+
// No per-turn parameters yet (thinkingLevel / thinkingBudget aren't
|
|
420
|
+
// exposed as CLI flags; we can't safely route them through `extraArgs`).
|
|
421
|
+
expect(catalog.parameters ?? []).toEqual([]);
|
|
422
|
+
|
|
423
|
+
// Cache persisted so subsequent calls don't re-touch settings.json.
|
|
424
|
+
expect(readdirSync(cacheDir)).toEqual(["gemini.json"]);
|
|
425
|
+
const payload = JSON.parse(readFileSync(path.join(cacheDir, "gemini.json"), "utf8"));
|
|
426
|
+
expect(payload.runtimeId).toBe("gemini");
|
|
427
|
+
expect(payload.catalog.models.map((m: { id: string }) => m.id)).toContain("gemini-2.5-flash");
|
|
428
|
+
} finally {
|
|
429
|
+
if (prevHome === undefined) delete process.env.HOME;
|
|
430
|
+
else process.env.HOME = prevHome;
|
|
431
|
+
if (prevCacheDir === undefined) delete process.env.BOTCORD_RUNTIME_CATALOG_CACHE_DIR;
|
|
432
|
+
else process.env.BOTCORD_RUNTIME_CATALOG_CACHE_DIR = prevCacheDir;
|
|
433
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
434
|
+
}
|
|
435
|
+
});
|
|
383
436
|
});
|
|
@@ -4,8 +4,10 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import {
|
|
6
6
|
agentCodexHomeDir,
|
|
7
|
+
agentHermesHomeDir,
|
|
7
8
|
agentWorkspaceDir,
|
|
8
9
|
} from "../agent-workspace.js";
|
|
10
|
+
import { hermesProfileHomeDir } from "../gateway/runtimes/hermes-agent.js";
|
|
9
11
|
import {
|
|
10
12
|
buildSoftSkillIndexPrompt,
|
|
11
13
|
collectAgentSkillSnapshot,
|
|
@@ -37,27 +39,40 @@ afterEach(() => {
|
|
|
37
39
|
});
|
|
38
40
|
|
|
39
41
|
describe("skill snapshots", () => {
|
|
40
|
-
it("scans
|
|
42
|
+
it("scopes scans to the selected runtime and maps UI source buckets", () => {
|
|
41
43
|
const agentId = "ag_skilltest";
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
writeSkill(
|
|
44
|
+
const claudePath = path.join(agentWorkspaceDir(agentId), ".claude", "skills");
|
|
45
|
+
const codexPath = path.join(agentCodexHomeDir(agentId), "skills");
|
|
46
|
+
writeSkill(claudePath, "claude-skill", "Claude skill");
|
|
47
|
+
writeSkill(codexPath, "codex-skill", "Codex skill");
|
|
48
|
+
writeSkill(path.join(tmpDir, ".claude", "skills"), "global-claude", "Global Claude");
|
|
49
|
+
writeSkill(path.join(tmpDir, ".codex", "skills"), "global-codex", "Global Codex");
|
|
45
50
|
|
|
46
|
-
const
|
|
47
|
-
expect(
|
|
51
|
+
const claudeScanned = scanSoftSkills(agentId, { runtime: "claude-code" });
|
|
52
|
+
expect(claudeScanned.map((s) => s.name).sort()).toEqual([
|
|
53
|
+
"claude-skill",
|
|
54
|
+
"global-claude",
|
|
55
|
+
]);
|
|
56
|
+
expect(claudeScanned.every((s) => s.runtime === "claude-code")).toBe(true);
|
|
57
|
+
|
|
58
|
+
const codexScanned = scanSoftSkills(agentId, { runtime: "codex" });
|
|
59
|
+
expect(codexScanned.map((s) => s.name).sort()).toEqual([
|
|
48
60
|
"codex-skill",
|
|
49
|
-
"global-
|
|
50
|
-
"workspace-skill",
|
|
61
|
+
"global-codex",
|
|
51
62
|
]);
|
|
63
|
+
expect(codexScanned.every((s) => s.runtime === "codex")).toBe(true);
|
|
52
64
|
|
|
53
|
-
const snapshot = collectAgentSkillSnapshot(agentId);
|
|
65
|
+
const snapshot = collectAgentSkillSnapshot(agentId, { runtime: "codex" });
|
|
54
66
|
expect(snapshot.agentId).toBe(agentId);
|
|
55
|
-
expect(snapshot.
|
|
56
|
-
expect(snapshot.skills.
|
|
57
|
-
.toBe("workspace");
|
|
67
|
+
expect(snapshot.runtime).toBe("codex");
|
|
68
|
+
expect(snapshot.skills).toHaveLength(2);
|
|
58
69
|
expect(snapshot.skills.find((s) => s.name === "codex-skill")?.source)
|
|
59
70
|
.toBe("workspace");
|
|
60
|
-
expect(snapshot.skills.find((s) => s.name === "
|
|
71
|
+
expect(snapshot.skills.find((s) => s.name === "codex-skill")?.sourceDetail)
|
|
72
|
+
.toBe("agent-codex");
|
|
73
|
+
expect(snapshot.skills.find((s) => s.name === "codex-skill")?.path)
|
|
74
|
+
.toBe(path.join(codexPath, "codex-skill", "SKILL.md"));
|
|
75
|
+
expect(snapshot.skills.find((s) => s.name === "global-codex")?.source)
|
|
61
76
|
.toBe("runtime-global");
|
|
62
77
|
expect(snapshot.probedAt).toBeGreaterThan(0);
|
|
63
78
|
});
|
|
@@ -101,6 +116,67 @@ describe("skill snapshots", () => {
|
|
|
101
116
|
.toBe("runtime-global");
|
|
102
117
|
});
|
|
103
118
|
|
|
119
|
+
it("scans Hermes home/profile skills without mixing Claude or Codex dirs", () => {
|
|
120
|
+
const agentId = "ag_hermes_skills";
|
|
121
|
+
writeSkill(path.join(agentWorkspaceDir(agentId), ".claude", "skills"), "claude-only", "Claude only");
|
|
122
|
+
writeSkill(path.join(agentCodexHomeDir(agentId), "skills"), "codex-only", "Codex only");
|
|
123
|
+
writeSkill(path.join(agentHermesHomeDir(agentId), "skills"), "hermes-only", "Hermes only");
|
|
124
|
+
|
|
125
|
+
const isolated = scanSoftSkills(agentId, { runtime: "hermes-agent" });
|
|
126
|
+
expect(isolated.map((s) => s.name)).toEqual(["hermes-only"]);
|
|
127
|
+
expect(isolated[0]).toMatchObject({
|
|
128
|
+
source: "agent-hermes",
|
|
129
|
+
runtime: "hermes-agent",
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const profileAgentId = "ag_hermes_profile";
|
|
133
|
+
writeSkill(
|
|
134
|
+
path.join(hermesProfileHomeDir("writer"), "skills"),
|
|
135
|
+
"profile-skill",
|
|
136
|
+
"Hermes profile skill",
|
|
137
|
+
);
|
|
138
|
+
const profile = collectAgentSkillSnapshot(profileAgentId, {
|
|
139
|
+
runtime: "hermes-agent",
|
|
140
|
+
hermesProfile: "writer",
|
|
141
|
+
});
|
|
142
|
+
expect(profile.skills).toHaveLength(1);
|
|
143
|
+
expect(profile.skills[0]).toMatchObject({
|
|
144
|
+
name: "profile-skill",
|
|
145
|
+
source: "workspace",
|
|
146
|
+
sourceDetail: "agent-hermes-profile",
|
|
147
|
+
runtime: "hermes-agent",
|
|
148
|
+
profile: "writer",
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("keeps same-device workspace skills scoped by agent id", () => {
|
|
153
|
+
writeSkill(
|
|
154
|
+
path.join(agentWorkspaceDir("ag_workspace_a"), ".claude", "skills"),
|
|
155
|
+
"agent-local",
|
|
156
|
+
"Skill for agent A",
|
|
157
|
+
);
|
|
158
|
+
writeSkill(
|
|
159
|
+
path.join(agentWorkspaceDir("ag_workspace_b"), ".claude", "skills"),
|
|
160
|
+
"agent-local",
|
|
161
|
+
"Skill for agent B",
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
expect(scanSoftSkills("ag_workspace_a", { runtime: "claude-code" })).toEqual([
|
|
165
|
+
expect.objectContaining({
|
|
166
|
+
name: "agent-local",
|
|
167
|
+
description: "Skill for agent A",
|
|
168
|
+
source: "agent-claude",
|
|
169
|
+
}),
|
|
170
|
+
]);
|
|
171
|
+
expect(scanSoftSkills("ag_workspace_b", { runtime: "claude-code" })).toEqual([
|
|
172
|
+
expect.objectContaining({
|
|
173
|
+
name: "agent-local",
|
|
174
|
+
description: "Skill for agent B",
|
|
175
|
+
source: "agent-claude",
|
|
176
|
+
}),
|
|
177
|
+
]);
|
|
178
|
+
});
|
|
179
|
+
|
|
104
180
|
it("returns complete snapshots while keeping the prompt soft index capped", () => {
|
|
105
181
|
const agentId = "ag_manyskills";
|
|
106
182
|
const workspaceSkills = path.join(agentWorkspaceDir(agentId), ".claude", "skills");
|