@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.
@@ -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
+ }
@@ -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.
@@ -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.89",
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/cli": "^0.1.18",
27
- "@botcord/protocol-core": "^0.2.13"
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: "gemini",
153
- displayName: "Gemini",
154
- binary: "gemini",
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 agent workspace/runtime-global skills and maps UI source buckets", () => {
42
+ it("scopes scans to the selected runtime and maps UI source buckets", () => {
41
43
  const agentId = "ag_skilltest";
42
- writeSkill(path.join(agentWorkspaceDir(agentId), ".claude", "skills"), "workspace-skill", "Workspace skill");
43
- writeSkill(path.join(agentCodexHomeDir(agentId), "skills"), "codex-skill", "Codex skill");
44
- writeSkill(path.join(tmpDir, ".codex", "skills"), "global-skill", "Global skill");
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 scanned = scanSoftSkills(agentId);
47
- expect(scanned.map((s) => s.name).sort()).toEqual([
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-skill",
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.skills).toHaveLength(3);
56
- expect(snapshot.skills.find((s) => s.name === "workspace-skill")?.source)
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 === "global-skill")?.source)
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");