@mycadet/assistant 0.12.8 → 0.12.9

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.
Files changed (42) hide show
  1. package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/package.json +1 -1
  2. package/node_modules/@mycadet/ces-client/package.json +1 -1
  3. package/node_modules/@mycadet/ces-client/src/__tests__/ces-client.test.ts +14 -14
  4. package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/package.json +1 -1
  5. package/node_modules/@mycadet/service-contracts/package.json +1 -1
  6. package/openapi.yaml +2 -18
  7. package/package.json +2 -2
  8. package/src/__tests__/cloud-profile-guard.test.ts +21 -19
  9. package/src/__tests__/config-set-route.test.ts +12 -8
  10. package/src/__tests__/credential-health-service.test.ts +4 -2
  11. package/src/__tests__/get-skill-detail-audit.test.ts +134 -289
  12. package/src/__tests__/mcp-abort-signal.test.ts +0 -1
  13. package/src/__tests__/mcp-client-auth.test.ts +0 -1
  14. package/src/__tests__/mcp-config-secret-boundary.test.ts +44 -40
  15. package/src/__tests__/mcp-tool-annotations-risk.test.ts +0 -1
  16. package/src/__tests__/plugin-api-store-credential.test.ts +4 -1
  17. package/src/__tests__/skills-file-content-endpoint.test.ts +221 -535
  18. package/src/__tests__/skills-files-catalog-fallback.test.ts +258 -576
  19. package/src/api/events/show-cloud-login.ts +2 -2
  20. package/src/cli/commands/cloud/__tests__/callback-routes-list.test.ts +2 -2
  21. package/src/cli/commands/cloud/index.ts +1 -1
  22. package/src/cli/commands/config.ts +1 -1
  23. package/src/config/__tests__/substrate-twin-shadowing.test.ts +36 -26
  24. package/src/config/env-registry.ts +2 -9
  25. package/src/config/schemas/ingress.ts +30 -0
  26. package/src/config/schemas/llm.ts +0 -9
  27. package/src/config/seed-inference-profiles.ts +2 -2
  28. package/src/config/substrate-twin-shadowing.ts +12 -17
  29. package/src/config/sync-gated-profiles.ts +3 -4
  30. package/src/credential-execution/prompted-credential.ts +1 -10
  31. package/src/credential-health/credential-health-service.ts +8 -4
  32. package/src/daemon/credential-transcript-scrub.ts +11 -0
  33. package/src/plugins/defaults/memory/__tests__/memory-tier-boundary-guard.test.ts +5 -0
  34. package/src/providers/inference/backfill.ts +2 -2
  35. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +100 -57
  36. package/src/runtime/routes/cloud-routes.ts +5 -5
  37. package/src/runtime/routes/conversation-query-routes.ts +275 -397
  38. package/src/runtime/routes/secret-routes.ts +2 -17
  39. package/src/tools/credentials/store.ts +1 -10
  40. package/src/util/layout.ts +479 -0
  41. package/src/util/platform.ts +11 -467
  42. package/src/workspace/migrations/127-backfill-default-provider.ts +2 -2
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Tests for `getSkillFiles` provider chain fallback.
2
+ * Tests for `getSkillFiles` provider chain fallback — zero-leak.
3
3
  *
4
4
  * When a skill id isn't resolvable via `findSkillById` (i.e. not installed
5
5
  * locally, not bundled, not a cloud skill), `getSkillFiles` falls back to
@@ -11,286 +11,175 @@
11
11
  *
12
12
  * Coverage:
13
13
  * - Uninstalled catalog skill: returns `{ skill: catalog/cadet/available, files }` with `content: null` for every entry.
14
- * - Neither installed nor handled by any provider: returns 404.
14
+ * - Neither installed nor claimed by any provider: returns 404.
15
15
  * - Installed skill: preserves the disk-read behavior with inline `content`.
16
- * - Installed skill with missing directory: returns 404 without consulting providers.
17
16
  * - `catalogSkillToSlim` mapping: `metadata.cadet["display-name"]` wins over `cs.name`.
18
17
  * - skills.sh-shaped ID not in cadet catalog returns files from skills.sh provider.
19
18
  * - clawhub-shaped ID not in cadet catalog returns files from clawhub provider.
20
- * - ID that no provider handles returns 404.
19
+ * - ID that no provider canHandle returns 404.
21
20
  * - Provider priority: cadet provider is tried before skills.sh/clawhub.
21
+ *
22
+ * Zero-leak: the REAL catalog loader resolves skills seeded into the
23
+ * workspace skills dir, the REAL provider chain runs (cadet catalog +
24
+ * GitHub-API choreography over the requests-mock transport; the clawhub CLI
25
+ * subprocess spawned for real against a PATH-shimmed `npx` that records its
26
+ * argv), and the REAL `catalogSkillToSlim` mapping is asserted instead of a
27
+ * mock's echo. The former ghost-install test (resolver lists a skill whose
28
+ * directory is missing) is gone: through the real loader a resolved
29
+ * skill's directory always exists at resolution time — the handler's
30
+ * `existsSync` branch is defense-in-depth for mid-request unmount races
31
+ * that a deterministic test cannot reach.
22
32
  */
23
33
 
24
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
34
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
35
+ import { existsSync, readFileSync } from "node:fs";
25
36
  import { tmpdir } from "node:os";
26
37
  import { join } from "node:path";
27
- import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
38
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
39
+
40
+ import { setAllFlagsOnForTesting } from "./feature-flag-test-helpers.js";
41
+ import { setConfig } from "./helpers/set-config.js";
42
+ import { mockHttp } from "./requests-mock.js";
28
43
 
29
- import { setAllFlagsOnForTesting } from "../__tests__/feature-flag-test-helpers.js";
30
- import type { SkillSummary } from "../config/skills.js";
31
- import type { SlimSkillResponse } from "../daemon/message-types/skills.js";
32
- import type { SkillFileEntry } from "../skills/catalog-files.js";
33
- import type { SkillFileProvider } from "../skills/skill-file-provider.js";
44
+ // Real env: the real getCloudBaseUrl() reads config.cloud.baseUrl — point it
45
+ // at a host mockHttp matches by path substring, so the platform-facing
46
+ // provider fetches flow through the real env → URL → fetch chain.
47
+ setConfig("cloud", { baseUrl: "https://test-cloud.mycadet.ai" });
48
+
49
+ import {
50
+ _resetFileProvidersForTest,
51
+ getSkillFiles,
52
+ } from "../daemon/handlers/skills.js";
53
+ import { getCatalog, invalidateCatalogCache } from "../skills/catalog-cache.js";
54
+ import { getWorkspaceSkillsDir } from "../util/layout.js";
34
55
 
35
56
  // ---------------------------------------------------------------------------
36
- // Mock state — mutated by individual tests via reset helpers below
57
+ // clawhub CLI subprocess shim — real spawn, canned stdout
37
58
  // ---------------------------------------------------------------------------
38
59
 
39
- type ResolvedSkillEntry = {
40
- summary: SkillSummary;
41
- state: "enabled" | "disabled";
60
+ // clawhub-files drives the real `clawhub` CLI through `npx`. The shim is a
61
+ // real executable earlier on PATH that records its argv and emits canned
62
+ // JSON, so the spawn → stdout-parse contract runs for real.
63
+ const shimDir = join(tmpdir(), "skill-files-npx-shim");
64
+ const shimRecord = join(shimDir, "calls.log");
65
+ const shimScript = [
66
+ "#!/bin/sh",
67
+ 'if [ -n "${FAKE_NPX_RECORD:-}" ]; then',
68
+ ' printf \'%s\\n\' "$*" >> "$FAKE_NPX_RECORD"',
69
+ "fi",
70
+ "printf '%s' \"${FAKE_NPX_RESPONSE:-}\"",
71
+ "exit 0",
72
+ ].join("\n");
73
+
74
+ let originalPath: string | undefined;
75
+
76
+ const clawhubCalls = (): string[] => {
77
+ if (!existsSync(shimRecord)) {
78
+ return [];
79
+ }
80
+ return readFileSync(shimRecord, "utf-8")
81
+ .split("\n")
82
+ .filter((line) => line.trim().length > 0);
42
83
  };
43
84
 
44
- let mockResolvedStates: ResolvedSkillEntry[] = [];
45
-
46
- // Per-provider mock state
47
- let mockCadetProvider: SkillFileProvider;
48
- let mockSkillsshProvider: SkillFileProvider;
49
- let mockClawhubProvider: SkillFileProvider;
50
-
51
- // Track which providers were consulted
52
- const providerCalls: Array<{
53
- provider: string;
54
- method: string;
55
- skillId: string;
56
- }> = [];
57
-
58
- function makeNoopProvider(name: string): SkillFileProvider {
59
- return {
60
- canHandle(_skillId: string): boolean {
61
- return false;
62
- },
63
- async listFiles(skillId: string): Promise<SkillFileEntry[] | null> {
64
- providerCalls.push({ provider: name, method: "listFiles", skillId });
65
- return null;
66
- },
67
- async readFileContent(
68
- skillId: string,
69
- _path: string,
70
- ): Promise<SkillFileEntry | null> {
71
- providerCalls.push({
72
- provider: name,
73
- method: "readFileContent",
74
- skillId,
75
- });
76
- return null;
77
- },
78
- async toSlimSkill(skillId: string): Promise<SlimSkillResponse | null> {
79
- providerCalls.push({ provider: name, method: "toSlimSkill", skillId });
80
- return null;
81
- },
82
- };
85
+ function installNpxShim(inspectResponse: unknown): void {
86
+ mkdirSync(shimDir, { recursive: true });
87
+ writeFileSync(join(shimDir, "npx"), shimScript, { mode: 0o755 });
88
+ process.env.FAKE_NPX_RESPONSE = JSON.stringify(inspectResponse ?? {});
89
+ process.env.FAKE_NPX_RECORD = shimRecord;
90
+ originalPath = process.env.PATH;
91
+ process.env.PATH = `${shimDir}:${process.env.PATH ?? ""}`;
83
92
  }
84
93
 
85
94
  // ---------------------------------------------------------------------------
86
- // Module mocks — must be declared before importing the module under test
95
+ // Workspace skill seeding — the real loader resolves these
87
96
  // ---------------------------------------------------------------------------
88
97
 
89
- mock.module("../config/skills.js", () => ({
90
- loadSkillCatalog: () => [],
91
- }));
92
-
93
- mock.module("../config/skill-state.js", () => ({
94
- resolveSkillStates: () => mockResolvedStates,
95
- skillFlagKey: () => null,
96
- }));
97
-
98
- mock.module("../skills/install-meta.js", () => ({
99
- readInstallMeta: () => null,
100
- }));
101
-
102
- mock.module("../skills/catalog-cache.js", () => ({
103
- getCatalog: async () => [],
104
- }));
105
-
106
- // The provider factory functions are called once at module init and the
107
- // returned objects are captured in the `fileProviders` array. To allow
108
- // per-test mock reassignment, return proxy objects that delegate every
109
- // method call to the CURRENT value of the mutable mock variable.
110
- mock.module("../skills/catalog-files.js", () => ({
111
- catalogSkillToSlim: () => ({}),
112
- createCadetCatalogProvider: () => ({
113
- canHandle: (id: string) => mockCadetProvider.canHandle(id),
114
- listFiles: (id: string) => mockCadetProvider.listFiles(id),
115
- readFileContent: (id: string, p: string) =>
116
- mockCadetProvider.readFileContent(id, p),
117
- toSlimSkill: (id: string) => mockCadetProvider.toSlimSkill(id),
118
- }),
119
- hasHiddenOrSkippedSegment: () => false,
120
- readCatalogSkillFiles: async () => null,
121
- readCatalogSkillFileContent: async () => null,
122
- sanitizeRelativePath: (p: string) => p,
123
- SKIP_DIRS: new Set(["node_modules", "__pycache__", ".git"]),
124
- }));
125
-
126
- mock.module("../skills/skillssh-files.js", () => ({
127
- createSkillsShProvider: () => ({
128
- canHandle: (id: string) => mockSkillsshProvider.canHandle(id),
129
- listFiles: (id: string) => mockSkillsshProvider.listFiles(id),
130
- readFileContent: (id: string, p: string) =>
131
- mockSkillsshProvider.readFileContent(id, p),
132
- toSlimSkill: (id: string) => mockSkillsshProvider.toSlimSkill(id),
133
- }),
134
- }));
135
-
136
- mock.module("../skills/clawhub-files.js", () => ({
137
- createClawhubProvider: () => ({
138
- canHandle: (id: string) => mockClawhubProvider.canHandle(id),
139
- listFiles: (id: string) => mockClawhubProvider.listFiles(id),
140
- readFileContent: (id: string, p: string) =>
141
- mockClawhubProvider.readFileContent(id, p),
142
- toSlimSkill: (id: string) => mockClawhubProvider.toSlimSkill(id),
143
- }),
144
- }));
145
-
146
- mock.module("../skills/skill-file-provider.js", () => ({}));
147
-
148
- mock.module("../skills/catalog-install.js", () => ({
149
- installSkillLocally: async () => {},
150
- }));
151
-
152
- mock.module("../skills/catalog-search.js", () => ({
153
- filterByQuery: () => [],
154
- }));
155
-
156
- mock.module("../skills/clawhub.js", () => ({
157
- clawhubCheckUpdates: async () => [],
158
- clawhubInspect: async () => ({}),
159
- clawhubInstall: async () => ({ success: true }),
160
- clawhubSearch: async () => ({ skills: [] }),
161
- clawhubUpdate: async () => ({ success: true }),
162
- }));
163
-
164
- mock.module("../skills/skillssh-registry.js", () => ({
165
- installExternalSkill: async () => {},
166
- resolveSkillSource: () => ({ owner: "", repo: "", skillSlug: "" }),
167
- searchSkillsRegistry: async () => [],
168
- }));
169
-
170
- mock.module("../skills/cloud-store.js", () => ({
171
- createCloudSkill: () => ({ created: true }),
172
- deleteCloudSkill: () => ({ deleted: true }),
173
- validateCloudSkillId: () => null,
174
- }));
175
-
176
- mock.module("../plugins/defaults/memory/graph/capability-seed.js", () => ({
177
- deleteSkillCapabilityNode: () => {},
178
- seedSkillGraphNodes: () => {},
179
- seedUninstalledCatalogSkillMemories: async () => {},
180
- }));
181
-
182
- mock.module("../providers/provider-send-message.js", () => ({
183
- createTimeout: () => ({
184
- signal: AbortSignal.timeout(1000),
185
- cleanup: () => {},
186
- }),
187
- extractText: () => "",
188
- getConfiguredProvider: async () => null,
189
- userMessage: () => ({}),
190
- }));
191
-
192
- mock.module("../daemon/handlers/shared.js", () => ({
193
- CONFIG_RELOAD_DEBOUNCE_MS: 100,
194
- ensureSkillEntry: (_raw: Record<string, unknown>, _id: string) => ({
195
- enabled: false,
196
- }),
197
- log: {
198
- info: () => {},
199
- warn: () => {},
200
- error: () => {},
201
- debug: () => {},
202
- },
203
- }));
204
-
205
- // ---------------------------------------------------------------------------
206
- // Import after mocks
207
- // ---------------------------------------------------------------------------
208
-
209
- import {
210
- _resetFileProvidersForTest,
211
- getSkillFiles,
212
- } from "../daemon/handlers/skills.js";
98
+ const seededSkillDirs: string[] = [];
99
+
100
+ function seedWorkspaceSkill(
101
+ id: string,
102
+ files: Record<string, string | Buffer> = {},
103
+ ): string {
104
+ const dir = join(getWorkspaceSkillsDir(), id);
105
+ mkdirSync(dir, { recursive: true });
106
+ writeFileSync(
107
+ join(dir, "SKILL.md"),
108
+ `---\nname: ${id}\ndescription: seeded test skill\n---\n# ${id}\n`,
109
+ );
110
+ for (const [relPath, content] of Object.entries(files)) {
111
+ const abs = join(dir, relPath);
112
+ mkdirSync(join(abs, ".."), { recursive: true });
113
+ writeFileSync(abs, content);
114
+ }
115
+ seededSkillDirs.push(dir);
116
+ return dir;
117
+ }
213
118
 
214
- // ---------------------------------------------------------------------------
215
- // Helpers
216
- // ---------------------------------------------------------------------------
119
+ // A catalog listing the given skills — served for the platform catalog fetch
120
+ // that warms the real cache (`getCatalog()` mirrors the list path running
121
+ // first, exactly how production warms it before a fallback read).
122
+ function catalogBody(entries: unknown[]): { skills: unknown[] } {
123
+ return { skills: entries };
124
+ }
217
125
 
218
- function makeSummary(overrides: Partial<SkillSummary>): SkillSummary {
126
+ function catalogEntry(id: string, metadata?: unknown): unknown {
219
127
  return {
220
- id: overrides.id ?? "summary-id",
221
- name: overrides.name ?? "summary-id",
222
- displayName: overrides.displayName ?? "Summary",
223
- description: overrides.description ?? "",
224
- directoryPath: overrides.directoryPath ?? "/tmp/nonexistent-skill-dir",
225
- skillFilePath:
226
- overrides.skillFilePath ??
227
- join(overrides.directoryPath ?? "/tmp/nonexistent-skill-dir", "SKILL.md"),
228
- source: overrides.source ?? "workspace",
229
- bundled: overrides.bundled,
230
- icon: overrides.icon,
231
- emoji: overrides.emoji,
232
- toolManifest: overrides.toolManifest,
233
- includes: overrides.includes,
234
- featureFlag: overrides.featureFlag,
235
- activationHints: overrides.activationHints,
236
- avoidWhen: overrides.avoidWhen,
237
- inlineCommandExpansions: overrides.inlineCommandExpansions,
128
+ id,
129
+ name: id,
130
+ description: `${id} catalog skill`,
131
+ ...(metadata !== undefined ? { metadata } : {}),
238
132
  };
239
133
  }
240
134
 
135
+ beforeEach(() => {
136
+ setAllFlagsOnForTesting();
137
+ invalidateCatalogCache();
138
+ _resetFileProvidersForTest();
139
+ installNpxShim({});
140
+ });
141
+
142
+ afterEach(() => {
143
+ if (originalPath !== undefined) {
144
+ process.env.PATH = originalPath;
145
+ originalPath = undefined;
146
+ }
147
+ delete process.env.FAKE_NPX_RESPONSE;
148
+ delete process.env.FAKE_NPX_RECORD;
149
+ rmSync(shimDir, { recursive: true, force: true });
150
+ for (const dir of seededSkillDirs) {
151
+ rmSync(dir, { recursive: true, force: true });
152
+ }
153
+ seededSkillDirs.length = 0;
154
+ });
155
+
241
156
  // ---------------------------------------------------------------------------
242
157
  // Tests
243
158
  // ---------------------------------------------------------------------------
244
159
 
245
160
  describe("getSkillFiles — provider chain fallback", () => {
246
- beforeEach(() => {
247
- // Real flag resolution replaces the former blanket module mock.
248
- setAllFlagsOnForTesting();
249
-
250
- mockResolvedStates = [];
251
- providerCalls.length = 0;
252
- mockCadetProvider = makeNoopProvider("cadet");
253
- mockSkillsshProvider = makeNoopProvider("skillssh");
254
- mockClawhubProvider = makeNoopProvider("clawhub");
255
- // Force provider chain re-creation from the (mocked) factory functions
256
- _resetFileProvidersForTest();
257
- });
258
-
259
161
  test("returns catalog skill with files (content: null) when skill is uninstalled but present in cadet catalog", async () => {
260
- const mockFiles: SkillFileEntry[] = [
261
- {
262
- path: "SKILL.md",
263
- name: "SKILL.md",
264
- size: 42,
265
- mimeType: "",
266
- isBinary: false,
267
- content: null,
268
- },
269
- {
270
- path: "assets/logo.png",
271
- name: "logo.png",
272
- size: 1024,
273
- mimeType: "",
274
- isBinary: true,
275
- content: null,
162
+ const http = mockHttp();
163
+ http.on("GET", "/v1/skills/", {
164
+ status: 200,
165
+ body: catalogBody([
166
+ catalogEntry("acme-seo", {
167
+ cadet: { "display-name": "Acme SEO", category: "knowledge" },
168
+ }),
169
+ ]),
170
+ });
171
+ http.on("GET", "/v1/skills/acme-seo/files/", {
172
+ status: 200,
173
+ body: {
174
+ files: [
175
+ { path: "SKILL.md", name: "SKILL.md", size: 42 },
176
+ { path: "assets/logo.png", name: "logo.png", size: 1024 },
177
+ ],
276
178
  },
277
- ];
278
-
279
- mockCadetProvider = {
280
- canHandle: () => true,
281
- listFiles: async () => mockFiles,
282
- readFileContent: async () => null,
283
- toSlimSkill: async () => ({
284
- id: "acme-seo",
285
- name: "Acme SEO",
286
- description: "SEO helper",
287
- emoji: "\u{1F50D}",
288
- kind: "catalog",
289
- origin: "cadet",
290
- status: "available",
291
- category: "knowledge",
292
- }),
293
- };
179
+ });
180
+ // Warm the real catalog cache the same way production does: the list
181
+ // path runs before the fallback read.
182
+ await getCatalog();
294
183
 
295
184
  const result = await getSkillFiles("acme-seo");
296
185
 
@@ -299,16 +188,13 @@ describe("getSkillFiles — provider chain fallback", () => {
299
188
  return;
300
189
  }
301
190
 
302
- expect(result.skill).toEqual({
303
- id: "acme-seo",
304
- name: "Acme SEO",
305
- description: "SEO helper",
306
- emoji: "\u{1F50D}",
307
- kind: "catalog",
308
- origin: "cadet",
309
- status: "available",
310
- category: "knowledge",
311
- });
191
+ // The REAL catalogSkillToSlim mapping produced the slim response.
192
+ expect(result.skill.id).toBe("acme-seo");
193
+ expect(result.skill.name).toBe("Acme SEO");
194
+ expect(result.skill.kind).toBe("catalog");
195
+ expect(result.skill.origin).toBe("cadet");
196
+ expect(result.skill.status).toBe("available");
197
+ expect(result.skill.category).toBe("knowledge");
312
198
  expect(result.files).toHaveLength(2);
313
199
  for (const entry of result.files) {
314
200
  expect(entry.content).toBeNull();
@@ -322,26 +208,29 @@ describe("getSkillFiles — provider chain fallback", () => {
322
208
  );
323
209
  });
324
210
 
325
- test("returns 404 when skill is neither installed nor handled by any provider", async () => {
326
- mockResolvedStates = [];
327
-
328
- const result = await getSkillFiles("ghost-skill");
211
+ test("returns 404 when skill is neither installed nor claimed by any provider", async () => {
212
+ // An invalid clawhub slug with fewer than three segments is claimable by
213
+ // no provider: cadet needs the catalog cache, skills.sh needs ≥3 slash
214
+ // segments, clawhub needs a valid slug.
215
+ const result = await getSkillFiles("no such skill");
329
216
 
330
217
  expect("error" in result).toBe(true);
331
218
  if (!("error" in result)) {
332
219
  return;
333
220
  }
334
221
  expect(result.status).toBe(404);
335
- expect(result.error).toContain("ghost-skill");
336
222
  });
337
223
 
338
- test("returns 404 with 'files unavailable' when cadet provider canHandle returns true but listFiles returns null", async () => {
339
- mockCadetProvider = {
340
- canHandle: () => true,
341
- listFiles: async () => null,
342
- readFileContent: async () => null,
343
- toSlimSkill: async () => null,
344
- };
224
+ test("returns 404 with 'files unavailable' when cadet provider claims the skill but listFiles returns null", async () => {
225
+ const http = mockHttp();
226
+ http.on("GET", "/v1/skills/", {
227
+ status: 200,
228
+ body: catalogBody([catalogEntry("broken-skill")]),
229
+ });
230
+ http.on("GET", "/v1/skills/broken-skill/files/", {
231
+ status: 404,
232
+ });
233
+ await getCatalog();
345
234
 
346
235
  const result = await getSkillFiles("broken-skill");
347
236
 
@@ -355,128 +244,42 @@ describe("getSkillFiles — provider chain fallback", () => {
355
244
  });
356
245
 
357
246
  test("installed skill returns inline disk content (no provider fallback)", async () => {
358
- // Create a real temp directory for the installed-skill path to read.
359
- const workspaceRoot = mkdtempSync(
360
- join(tmpdir(), "cadet-skill-files-test-"),
361
- );
362
- const installedDir = join(workspaceRoot, "installed-skill");
363
- mkdirSync(installedDir, { recursive: true });
364
- writeFileSync(
365
- join(installedDir, "SKILL.md"),
366
- "# Installed\n\nBody of the installed skill.",
367
- );
368
- writeFileSync(
369
- join(installedDir, "notes.txt"),
370
- "Some notes about the skill.",
371
- );
372
-
373
- try {
374
- mockResolvedStates = [
375
- {
376
- summary: makeSummary({
377
- id: "installed-skill",
378
- name: "installed-skill",
379
- displayName: "Installed Skill",
380
- description: "A pre-installed skill",
381
- directoryPath: installedDir,
382
- source: "workspace",
383
- }),
384
- state: "enabled",
385
- },
386
- ];
387
-
388
- const result = await getSkillFiles("installed-skill");
389
-
390
- expect("error" in result).toBe(false);
391
- if ("error" in result) {
392
- return;
393
- }
394
-
395
- expect(result.files).toHaveLength(2);
396
- const skillMd = result.files.find((f) => f.path === "SKILL.md");
397
- const notes = result.files.find((f) => f.path === "notes.txt");
398
- expect(skillMd).toBeDefined();
399
- expect(notes).toBeDefined();
400
- expect(skillMd!.content).toBe(
401
- "# Installed\n\nBody of the installed skill.",
402
- );
403
- expect(notes!.content).toBe("Some notes about the skill.");
404
-
405
- // Sort-by-path behavior (localeCompare order) is preserved.
406
- expect(result.files.map((f) => f.path)).toEqual(
407
- [...result.files.map((f) => f.path)].sort((a, b) => a.localeCompare(b)),
408
- );
409
- } finally {
410
- rmSync(workspaceRoot, { recursive: true, force: true });
411
- }
412
- });
247
+ seedWorkspaceSkill("installed-skill", {
248
+ "notes.txt": "Some notes about the skill.",
249
+ });
413
250
 
414
- test("returns 404 without provider fallback when installed skill directory is missing on disk", async () => {
415
- mockResolvedStates = [
416
- {
417
- summary: makeSummary({
418
- id: "ghost-installed",
419
- name: "ghost-installed",
420
- displayName: "Ghost Installed",
421
- description: "Installed in resolver but directory is gone",
422
- directoryPath: "/tmp/definitely-does-not-exist-" + Date.now(),
423
- source: "workspace",
424
- }),
425
- state: "enabled",
426
- },
427
- ];
428
- // Even if a provider would handle this id, the handler must NOT
429
- // fall through — return 404 instead.
430
- mockCadetProvider = {
431
- canHandle: () => true,
432
- listFiles: async () => [
433
- {
434
- path: "SKILL.md",
435
- name: "SKILL.md",
436
- size: 10,
437
- mimeType: "",
438
- isBinary: false,
439
- content: null,
440
- },
441
- ],
442
- readFileContent: async () => null,
443
- toSlimSkill: async () => ({
444
- id: "ghost-installed",
445
- name: "Ghost",
446
- description: "",
447
- kind: "catalog",
448
- origin: "cadet",
449
- status: "available",
450
- category: "knowledge",
451
- }),
452
- };
453
-
454
- const result = await getSkillFiles("ghost-installed");
251
+ const result = await getSkillFiles("installed-skill");
455
252
 
456
- expect("error" in result).toBe(true);
457
- if (!("error" in result)) {
253
+ expect("error" in result).toBe(false);
254
+ if ("error" in result) {
458
255
  return;
459
256
  }
460
- expect(result.status).toBe(404);
461
- expect(result.error).toContain("ghost-installed");
462
- expect(result.error).toContain("directory missing");
257
+
258
+ expect(result.files).toHaveLength(2);
259
+ const skillMd = result.files.find((f) => f.path === "SKILL.md");
260
+ const notes = result.files.find((f) => f.path === "notes.txt");
261
+ expect(skillMd).toBeDefined();
262
+ expect(notes).toBeDefined();
263
+ expect(skillMd!.content).toContain("# installed-skill");
264
+ expect(notes!.content).toBe("Some notes about the skill.");
265
+
266
+ // Sort-by-path behavior (localeCompare order) is preserved.
267
+ expect(result.files.map((f) => f.path)).toEqual(
268
+ [...result.files.map((f) => f.path)].sort((a, b) => a.localeCompare(b)),
269
+ );
463
270
  });
464
271
 
465
272
  test("catalogSkillToSlim falls back to cs.name when metadata.cadet.display-name is absent", async () => {
466
- mockCadetProvider = {
467
- canHandle: () => true,
468
- listFiles: async () => [],
469
- readFileContent: async () => null,
470
- toSlimSkill: async () => ({
471
- id: "plain-skill",
472
- name: "plain-skill",
473
- description: "Minimal",
474
- kind: "catalog",
475
- origin: "cadet",
476
- status: "available",
477
- category: "knowledge",
478
- }),
479
- };
273
+ const http = mockHttp();
274
+ http.on("GET", "/v1/skills/", {
275
+ status: 200,
276
+ body: catalogBody([catalogEntry("plain-skill")]),
277
+ });
278
+ http.on("GET", "/v1/skills/plain-skill/files/", {
279
+ status: 200,
280
+ body: { files: [] },
281
+ });
282
+ await getCatalog();
480
283
 
481
284
  const result = await getSkillFiles("plain-skill");
482
285
 
@@ -484,27 +287,30 @@ describe("getSkillFiles — provider chain fallback", () => {
484
287
  if ("error" in result) {
485
288
  return;
486
289
  }
290
+ // Real mapping: no display-name metadata → the raw catalog name wins,
291
+ // and category falls back to "system".
487
292
  expect(result.skill.name).toBe("plain-skill");
293
+ expect(result.skill.category).toBe("system");
488
294
  expect(result.skill.kind).toBe("catalog");
489
295
  expect(result.skill.origin).toBe("cadet");
490
296
  expect(result.skill.status).toBe("available");
491
297
  });
492
298
 
493
299
  test("catalogSkillToSlim prefers metadata.cadet.display-name over cs.name", async () => {
494
- mockCadetProvider = {
495
- canHandle: () => true,
496
- listFiles: async () => [],
497
- readFileContent: async () => null,
498
- toSlimSkill: async () => ({
499
- id: "fancy-skill",
500
- name: "Pretty Fancy Name",
501
- description: "",
502
- kind: "catalog",
503
- origin: "cadet",
504
- status: "available",
505
- category: "knowledge",
506
- }),
507
- };
300
+ const http = mockHttp();
301
+ http.on("GET", "/v1/skills/", {
302
+ status: 200,
303
+ body: catalogBody([
304
+ catalogEntry("fancy-skill", {
305
+ cadet: { "display-name": "Pretty Fancy Name" },
306
+ }),
307
+ ]),
308
+ });
309
+ http.on("GET", "/v1/skills/fancy-skill/files/", {
310
+ status: 200,
311
+ body: { files: [] },
312
+ });
313
+ await getCatalog();
508
314
 
509
315
  const result = await getSkillFiles("fancy-skill");
510
316
 
@@ -516,43 +322,25 @@ describe("getSkillFiles — provider chain fallback", () => {
516
322
  });
517
323
 
518
324
  test("skills.sh-shaped ID not in cadet catalog returns files from skills.sh provider", async () => {
519
- // Cadet provider doesn't handle this ID
520
- mockCadetProvider = {
521
- canHandle: () => false,
522
- listFiles: async () => null,
523
- readFileContent: async () => null,
524
- toSlimSkill: async () => null,
525
- };
526
-
527
- const skillsshFiles: SkillFileEntry[] = [
325
+ const http = mockHttp();
326
+ // The real skills.sh chain: dir probe, then the recursive Contents API
327
+ // listing of that directory (same URL — one registration serves both).
328
+ http.on(
329
+ "GET",
330
+ "/repos/skillssh-owner/skillssh-repo/contents/skills/my-skill",
528
331
  {
529
- path: "SKILL.md",
530
- name: "SKILL.md",
531
- size: 100,
532
- mimeType: "",
533
- isBinary: false,
534
- content: null,
332
+ status: 200,
333
+ body: [
334
+ {
335
+ name: "SKILL.md",
336
+ path: "skills/my-skill/SKILL.md",
337
+ type: "file",
338
+ },
339
+ ],
535
340
  },
536
- ];
537
- mockSkillsshProvider = {
538
- canHandle: (id: string) => id.split("/").length >= 3,
539
- listFiles: async () => skillsshFiles,
540
- readFileContent: async () => null,
541
- toSlimSkill: async (id: string) => ({
542
- id,
543
- name: "my-skill",
544
- description: "",
545
- kind: "catalog",
546
- origin: "skillssh",
547
- status: "available",
548
- category: "knowledge",
549
- slug: id,
550
- sourceRepo: "owner/repo",
551
- installs: 0,
552
- }),
553
- };
554
-
555
- const result = await getSkillFiles("owner/repo/my-skill");
341
+ );
342
+
343
+ const result = await getSkillFiles("skillssh-owner/skillssh-repo/my-skill");
556
344
 
557
345
  expect("error" in result).toBe(false);
558
346
  if ("error" in result) {
@@ -561,61 +349,24 @@ describe("getSkillFiles — provider chain fallback", () => {
561
349
  expect(result.skill.origin).toBe("skillssh");
562
350
  expect(result.files).toHaveLength(1);
563
351
  expect(result.files[0].path).toBe("SKILL.md");
352
+ expect(result.files[0].content).toBeNull();
564
353
  });
565
354
 
566
355
  test("clawhub-shaped ID not in cadet catalog returns files from clawhub provider", async () => {
567
- // Cadet and skills.sh providers don't handle this ID
568
- mockCadetProvider = {
569
- canHandle: () => false,
570
- listFiles: async () => null,
571
- readFileContent: async () => null,
572
- toSlimSkill: async () => null,
573
- };
574
- mockSkillsshProvider = {
575
- canHandle: () => false,
576
- listFiles: async () => null,
577
- readFileContent: async () => null,
578
- toSlimSkill: async () => null,
579
- };
580
-
581
- const clawhubFiles: SkillFileEntry[] = [
582
- {
583
- path: "SKILL.md",
584
- name: "SKILL.md",
585
- size: 200,
586
- mimeType: "",
587
- isBinary: false,
588
- content: null,
589
- },
590
- {
591
- path: "lib/utils.js",
592
- name: "utils.js",
593
- size: 500,
594
- mimeType: "",
595
- isBinary: false,
596
- content: null,
597
- },
598
- ];
599
- mockClawhubProvider = {
600
- canHandle: () => true,
601
- listFiles: async () => clawhubFiles,
602
- readFileContent: async () => null,
603
- toSlimSkill: async () => ({
604
- id: "cool-tool",
605
- name: "Cool Tool",
606
- description: "A clawhub skill",
607
- kind: "catalog",
608
- origin: "clawhub",
609
- status: "available",
610
- category: "knowledge",
356
+ // Re-install the shim with a canned inspect payload including a files list.
357
+ installNpxShim({
358
+ skill: {
611
359
  slug: "cool-tool",
612
- author: "someone",
613
- stars: 5,
614
- installs: 10,
615
- reports: 0,
616
- version: "",
617
- }),
618
- };
360
+ displayName: "Cool Tool",
361
+ summary: "A clawhub skill",
362
+ },
363
+ owner: { handle: "someone" },
364
+ stats: { stars: 5, installs: 10 },
365
+ files: [
366
+ { path: "SKILL.md", size: 200 },
367
+ { path: "lib/utils.js", size: 500, contentType: "text/javascript" },
368
+ ],
369
+ });
619
370
 
620
371
  const result = await getSkillFiles("cool-tool");
621
372
 
@@ -623,17 +374,15 @@ describe("getSkillFiles — provider chain fallback", () => {
623
374
  if ("error" in result) {
624
375
  return;
625
376
  }
377
+ // The REAL provider mapped the CLI's inspect output.
626
378
  expect(result.skill.origin).toBe("clawhub");
379
+ expect(result.skill.name).toBe("Cool Tool");
627
380
  expect(result.files).toHaveLength(2);
381
+ expect(result.files[0].content).toBeNull();
628
382
  });
629
383
 
630
- test("ID that no provider handles returns 404", async () => {
631
- // All providers return canHandle=false
632
- mockCadetProvider = makeNoopProvider("cadet");
633
- mockSkillsshProvider = makeNoopProvider("skillssh");
634
- mockClawhubProvider = makeNoopProvider("clawhub");
635
-
636
- const result = await getSkillFiles("unknown-origin-skill");
384
+ test("ID that no provider canHandle returns 404", async () => {
385
+ const result = await getSkillFiles("no such skill anywhere");
637
386
 
638
387
  expect("error" in result).toBe(true);
639
388
  if (!("error" in result)) {
@@ -643,80 +392,22 @@ describe("getSkillFiles — provider chain fallback", () => {
643
392
  });
644
393
 
645
394
  test("provider priority — cadet provider is tried before skills.sh and clawhub", async () => {
646
- // All three providers claim to handle this ID, but cadet should win
647
- const cadetListFilesCalls: string[] = [];
648
- const skillsshListFilesCalls: string[] = [];
649
- const clawhubListFilesCalls: string[] = [];
650
-
651
- mockCadetProvider = {
652
- canHandle: () => true,
653
- listFiles: async (skillId: string) => {
654
- cadetListFilesCalls.push(skillId);
655
- return [
656
- {
657
- path: "SKILL.md",
658
- name: "SKILL.md",
659
- size: 10,
660
- mimeType: "",
661
- isBinary: false,
662
- content: null,
663
- },
664
- ];
665
- },
666
- readFileContent: async () => null,
667
- toSlimSkill: async () => ({
668
- id: "contested-skill",
669
- name: "Cadet Contested",
670
- description: "",
671
- kind: "catalog",
672
- origin: "cadet",
673
- status: "available",
674
- category: "knowledge",
675
- }),
676
- };
677
- mockSkillsshProvider = {
678
- canHandle: () => true,
679
- listFiles: async (skillId: string) => {
680
- skillsshListFilesCalls.push(skillId);
681
- return [];
682
- },
683
- readFileContent: async () => null,
684
- toSlimSkill: async () => ({
685
- id: "contested-skill",
686
- name: "Skillssh Contested",
687
- description: "",
688
- kind: "catalog",
689
- origin: "skillssh",
690
- status: "available",
691
- category: "knowledge",
692
- slug: "contested-skill",
693
- sourceRepo: "",
694
- installs: 0,
695
- }),
696
- };
697
- mockClawhubProvider = {
698
- canHandle: () => true,
699
- listFiles: async (skillId: string) => {
700
- clawhubListFilesCalls.push(skillId);
701
- return [];
395
+ const http = mockHttp();
396
+ http.on("GET", "/v1/skills/", {
397
+ status: 200,
398
+ body: catalogBody([
399
+ catalogEntry("contested-skill", {
400
+ cadet: { "display-name": "Cadet Contested" },
401
+ }),
402
+ ]),
403
+ });
404
+ http.on("GET", "/v1/skills/contested-skill/files/", {
405
+ status: 200,
406
+ body: {
407
+ files: [{ path: "SKILL.md", name: "SKILL.md", size: 10 }],
702
408
  },
703
- readFileContent: async () => null,
704
- toSlimSkill: async () => ({
705
- id: "contested-skill",
706
- name: "Clawhub Contested",
707
- description: "",
708
- kind: "catalog",
709
- origin: "clawhub",
710
- status: "available",
711
- category: "knowledge",
712
- slug: "contested-skill",
713
- author: "",
714
- stars: 0,
715
- installs: 0,
716
- reports: 0,
717
- version: "",
718
- }),
719
- };
409
+ });
410
+ await getCatalog();
720
411
 
721
412
  const result = await getSkillFiles("contested-skill");
722
413
 
@@ -727,17 +418,8 @@ describe("getSkillFiles — provider chain fallback", () => {
727
418
  // Cadet provider should have been used
728
419
  expect(result.skill.origin).toBe("cadet");
729
420
  expect(result.skill.name).toBe("Cadet Contested");
730
- // Cadet was called, but skills.sh and clawhub were NOT called
731
- expect(cadetListFilesCalls).toEqual(["contested-skill"]);
732
- expect(skillsshListFilesCalls).toEqual([]);
733
- expect(clawhubListFilesCalls).toEqual([]);
421
+ // The chain stops on the first match: the clawhub CLI never spawned even
422
+ // though its canHandle claims every valid slug.
423
+ expect(clawhubCalls()).toEqual([]);
734
424
  });
735
425
  });
736
-
737
- // ---------------------------------------------------------------------------
738
- // Cleanup: ensure we don't leak temp dirs if a test fails mid-way.
739
- // ---------------------------------------------------------------------------
740
-
741
- afterEach(() => {
742
- // Nothing to clean up outside test scope — temp dirs are cleaned per-test.
743
- });