@hellcoder/companion 0.96.0

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 (242) hide show
  1. package/bin/cli.ts +168 -0
  2. package/bin/ctl.ts +528 -0
  3. package/bin/generate-token.ts +28 -0
  4. package/dist/apple-touch-icon.png +0 -0
  5. package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
  6. package/dist/assets/CronManager-EGwLJONv.js +1 -0
  7. package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
  8. package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
  9. package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
  10. package/dist/assets/Playground-BV3k0RbV.js +109 -0
  11. package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
  12. package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
  13. package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
  14. package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
  15. package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
  16. package/dist/assets/index-BhUa1e6X.css +1 -0
  17. package/dist/assets/index-DkqeP-R9.js +134 -0
  18. package/dist/assets/sw-register-BibwRdvC.js +1 -0
  19. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  20. package/dist/favicon.svg +8 -0
  21. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  22. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  23. package/dist/icon-192.png +0 -0
  24. package/dist/icon-512.png +0 -0
  25. package/dist/index.html +20 -0
  26. package/dist/logo-codex.svg +14 -0
  27. package/dist/logo-docker.svg +4 -0
  28. package/dist/logo.svg +14 -0
  29. package/dist/manifest.json +24 -0
  30. package/dist/sw.js +2 -0
  31. package/package.json +104 -0
  32. package/server/agent-cron-migrator.test.ts +610 -0
  33. package/server/agent-cron-migrator.ts +85 -0
  34. package/server/agent-executor.test.ts +1108 -0
  35. package/server/agent-executor.ts +346 -0
  36. package/server/agent-store.test.ts +588 -0
  37. package/server/agent-store.ts +185 -0
  38. package/server/agent-types.ts +138 -0
  39. package/server/ai-validation-settings.test.ts +128 -0
  40. package/server/ai-validation-settings.ts +35 -0
  41. package/server/ai-validator.test.ts +387 -0
  42. package/server/ai-validator.ts +271 -0
  43. package/server/auth-manager.test.ts +83 -0
  44. package/server/auth-manager.ts +150 -0
  45. package/server/auto-namer.test.ts +252 -0
  46. package/server/auto-namer.ts +78 -0
  47. package/server/backend-adapter.test.ts +38 -0
  48. package/server/backend-adapter.ts +54 -0
  49. package/server/cache-headers.test.ts +98 -0
  50. package/server/cache-headers.ts +61 -0
  51. package/server/claude-adapter.test.ts +1363 -0
  52. package/server/claude-adapter.ts +889 -0
  53. package/server/claude-container-auth.test.ts +44 -0
  54. package/server/claude-container-auth.ts +30 -0
  55. package/server/claude-protocol-contract.test.ts +71 -0
  56. package/server/claude-protocol-drift.test.ts +78 -0
  57. package/server/claude-session-discovery.test.ts +132 -0
  58. package/server/claude-session-discovery.ts +157 -0
  59. package/server/claude-session-history.test.ts +158 -0
  60. package/server/claude-session-history.ts +410 -0
  61. package/server/cli-launcher.test.ts +1343 -0
  62. package/server/cli-launcher.ts +1298 -0
  63. package/server/cli.test.ts +16 -0
  64. package/server/codex-adapter.test.ts +5545 -0
  65. package/server/codex-adapter.ts +3062 -0
  66. package/server/codex-container-auth.test.ts +50 -0
  67. package/server/codex-container-auth.ts +24 -0
  68. package/server/codex-home.test.ts +61 -0
  69. package/server/codex-home.ts +26 -0
  70. package/server/codex-protocol-contract.test.ts +96 -0
  71. package/server/codex-protocol-drift.test.ts +123 -0
  72. package/server/codex-ws-proxy.cjs +226 -0
  73. package/server/commands-discovery.test.ts +179 -0
  74. package/server/commands-discovery.ts +81 -0
  75. package/server/constants.ts +7 -0
  76. package/server/container-manager.test.ts +1211 -0
  77. package/server/container-manager.ts +1053 -0
  78. package/server/cron-scheduler.test.ts +957 -0
  79. package/server/cron-scheduler.ts +243 -0
  80. package/server/cron-store.test.ts +422 -0
  81. package/server/cron-store.ts +148 -0
  82. package/server/cron-types.ts +63 -0
  83. package/server/env-manager.test.ts +268 -0
  84. package/server/env-manager.ts +161 -0
  85. package/server/event-bus-types.ts +64 -0
  86. package/server/event-bus.test.ts +244 -0
  87. package/server/event-bus.ts +124 -0
  88. package/server/execution-store.test.ts +307 -0
  89. package/server/execution-store.ts +170 -0
  90. package/server/fs-utils.ts +15 -0
  91. package/server/git-utils.test.ts +938 -0
  92. package/server/git-utils.ts +421 -0
  93. package/server/github-pr.test.ts +498 -0
  94. package/server/github-pr.ts +379 -0
  95. package/server/image-pull-manager.test.ts +303 -0
  96. package/server/image-pull-manager.ts +279 -0
  97. package/server/index.ts +396 -0
  98. package/server/linear-agent-bridge.test.ts +1157 -0
  99. package/server/linear-agent-bridge.ts +629 -0
  100. package/server/linear-agent.test.ts +473 -0
  101. package/server/linear-agent.ts +479 -0
  102. package/server/linear-cache.test.ts +136 -0
  103. package/server/linear-cache.ts +113 -0
  104. package/server/linear-connections.test.ts +350 -0
  105. package/server/linear-connections.ts +231 -0
  106. package/server/linear-credential-migration.test.ts +337 -0
  107. package/server/linear-credential-migration.ts +63 -0
  108. package/server/linear-oauth-connections-migration.test.ts +268 -0
  109. package/server/linear-oauth-connections.test.ts +365 -0
  110. package/server/linear-oauth-connections.ts +294 -0
  111. package/server/linear-project-manager.test.ts +162 -0
  112. package/server/linear-project-manager.ts +111 -0
  113. package/server/linear-prompt-builder.test.ts +74 -0
  114. package/server/linear-prompt-builder.ts +61 -0
  115. package/server/linear-staging.test.ts +276 -0
  116. package/server/linear-staging.ts +142 -0
  117. package/server/logger.test.ts +393 -0
  118. package/server/logger.ts +259 -0
  119. package/server/metrics-collector.test.ts +413 -0
  120. package/server/metrics-collector.ts +350 -0
  121. package/server/metrics-types.ts +108 -0
  122. package/server/middleware/managed-auth.test.ts +264 -0
  123. package/server/middleware/managed-auth.ts +195 -0
  124. package/server/novnc-proxy.test.ts +333 -0
  125. package/server/novnc-proxy.ts +99 -0
  126. package/server/path-resolver.test.ts +552 -0
  127. package/server/path-resolver.ts +186 -0
  128. package/server/paths.test.ts +31 -0
  129. package/server/paths.ts +11 -0
  130. package/server/pr-poller.test.ts +191 -0
  131. package/server/pr-poller.ts +162 -0
  132. package/server/prompt-manager.test.ts +211 -0
  133. package/server/prompt-manager.ts +211 -0
  134. package/server/protocol/claude-upstream/README.md +19 -0
  135. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  136. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  137. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  138. package/server/protocol/codex-upstream/README.md +18 -0
  139. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  140. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  141. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  142. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  143. package/server/protocol-monitor.ts +50 -0
  144. package/server/recorder.test.ts +454 -0
  145. package/server/recorder.ts +374 -0
  146. package/server/recording-hub/compat-validator.test.ts +150 -0
  147. package/server/recording-hub/compat-validator.ts +284 -0
  148. package/server/recording-hub/diagnostics.test.ts +140 -0
  149. package/server/recording-hub/diagnostics.ts +299 -0
  150. package/server/recording-hub/hub-config.test.ts +44 -0
  151. package/server/recording-hub/hub-config.ts +19 -0
  152. package/server/recording-hub/hub-routes.test.ts +417 -0
  153. package/server/recording-hub/hub-routes.ts +236 -0
  154. package/server/recording-hub/hub-store.test.ts +262 -0
  155. package/server/recording-hub/hub-store.ts +265 -0
  156. package/server/recording-hub/replay-adapter.test.ts +294 -0
  157. package/server/recording-hub/replay-adapter.ts +207 -0
  158. package/server/relay-client.test.ts +337 -0
  159. package/server/relay-client.ts +320 -0
  160. package/server/replay.test.ts +200 -0
  161. package/server/replay.ts +78 -0
  162. package/server/routes/agent-routes.test.ts +1400 -0
  163. package/server/routes/agent-routes.ts +409 -0
  164. package/server/routes/cron-routes.test.ts +881 -0
  165. package/server/routes/cron-routes.ts +103 -0
  166. package/server/routes/env-routes.test.ts +383 -0
  167. package/server/routes/env-routes.ts +95 -0
  168. package/server/routes/fs-routes.test.ts +1198 -0
  169. package/server/routes/fs-routes.ts +605 -0
  170. package/server/routes/git-routes.test.ts +813 -0
  171. package/server/routes/git-routes.ts +97 -0
  172. package/server/routes/linear-agent-routes.test.ts +721 -0
  173. package/server/routes/linear-agent-routes.ts +304 -0
  174. package/server/routes/linear-connection-routes.test.ts +927 -0
  175. package/server/routes/linear-connection-routes.ts +244 -0
  176. package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
  177. package/server/routes/linear-oauth-connection-routes.ts +129 -0
  178. package/server/routes/linear-routes.test.ts +1510 -0
  179. package/server/routes/linear-routes.ts +953 -0
  180. package/server/routes/metrics-routes.test.ts +103 -0
  181. package/server/routes/metrics-routes.ts +13 -0
  182. package/server/routes/prompt-routes.ts +67 -0
  183. package/server/routes/sandbox-routes.test.ts +513 -0
  184. package/server/routes/sandbox-routes.ts +127 -0
  185. package/server/routes/settings-routes.ts +270 -0
  186. package/server/routes/skills-routes.test.ts +690 -0
  187. package/server/routes/skills-routes.ts +100 -0
  188. package/server/routes/system-routes.test.ts +637 -0
  189. package/server/routes/system-routes.ts +228 -0
  190. package/server/routes/tailscale-routes.test.ts +176 -0
  191. package/server/routes/tailscale-routes.ts +22 -0
  192. package/server/routes.test.ts +4655 -0
  193. package/server/routes.ts +1277 -0
  194. package/server/sandbox-manager.test.ts +378 -0
  195. package/server/sandbox-manager.ts +168 -0
  196. package/server/service.test.ts +1419 -0
  197. package/server/service.ts +718 -0
  198. package/server/session-creation-service.test.ts +661 -0
  199. package/server/session-creation-service.ts +473 -0
  200. package/server/session-git-info.ts +104 -0
  201. package/server/session-linear-issues.test.ts +118 -0
  202. package/server/session-linear-issues.ts +88 -0
  203. package/server/session-names.test.ts +94 -0
  204. package/server/session-names.ts +67 -0
  205. package/server/session-orchestrator.test.ts +1784 -0
  206. package/server/session-orchestrator.ts +973 -0
  207. package/server/session-state-machine.test.ts +606 -0
  208. package/server/session-state-machine.ts +207 -0
  209. package/server/session-store.test.ts +290 -0
  210. package/server/session-store.ts +146 -0
  211. package/server/session-types.ts +509 -0
  212. package/server/settings-manager.test.ts +275 -0
  213. package/server/settings-manager.ts +173 -0
  214. package/server/tailscale-manager.test.ts +553 -0
  215. package/server/tailscale-manager.ts +451 -0
  216. package/server/terminal-manager.ts +240 -0
  217. package/server/update-checker.test.ts +306 -0
  218. package/server/update-checker.ts +197 -0
  219. package/server/usage-limits.test.ts +536 -0
  220. package/server/usage-limits.ts +225 -0
  221. package/server/worktree-tracker.test.ts +243 -0
  222. package/server/worktree-tracker.ts +84 -0
  223. package/server/ws-auth.test.ts +59 -0
  224. package/server/ws-auth.ts +41 -0
  225. package/server/ws-bridge-browser-ingest.test.ts +272 -0
  226. package/server/ws-bridge-browser-ingest.ts +72 -0
  227. package/server/ws-bridge-browser.ts +112 -0
  228. package/server/ws-bridge-cli-ingest.test.ts +302 -0
  229. package/server/ws-bridge-cli-ingest.ts +81 -0
  230. package/server/ws-bridge-codex.test.ts +1837 -0
  231. package/server/ws-bridge-codex.ts +266 -0
  232. package/server/ws-bridge-controls.test.ts +124 -0
  233. package/server/ws-bridge-controls.ts +20 -0
  234. package/server/ws-bridge-persist.test.ts +296 -0
  235. package/server/ws-bridge-persist.ts +66 -0
  236. package/server/ws-bridge-publish.test.ts +234 -0
  237. package/server/ws-bridge-publish.ts +79 -0
  238. package/server/ws-bridge-replay.test.ts +44 -0
  239. package/server/ws-bridge-replay.ts +61 -0
  240. package/server/ws-bridge-types.ts +106 -0
  241. package/server/ws-bridge.test.ts +4777 -0
  242. package/server/ws-bridge.ts +1279 -0
@@ -0,0 +1,690 @@
1
+ import { vi, describe, it, expect, beforeEach } from "vitest";
2
+
3
+ // ─── Hoisted mocks ──────────────────────────────────────────────────────────
4
+ // All node:fs, node:fs/promises, and node:os functions are mocked before any
5
+ // module-level code runs. This is critical because SKILLS_DIR is computed at
6
+ // import time via homedir().
7
+
8
+ const mockExistsSync = vi.hoisted(() => vi.fn(() => false));
9
+ const mockReaddir = vi.hoisted(() => vi.fn(async () => []));
10
+ const mockReadFile = vi.hoisted(() => vi.fn(async () => ""));
11
+ const mockWriteFile = vi.hoisted(() => vi.fn(async (_path: string, _content: string) => {}));
12
+ const mockRm = vi.hoisted(() => vi.fn(async () => {}));
13
+ const mockMkdir = vi.hoisted(() => vi.fn(async () => {}));
14
+
15
+ vi.mock("node:fs", () => ({ existsSync: mockExistsSync }));
16
+ vi.mock("node:fs/promises", () => ({
17
+ readdir: mockReaddir,
18
+ readFile: mockReadFile,
19
+ writeFile: mockWriteFile,
20
+ rm: mockRm,
21
+ mkdir: mockMkdir,
22
+ }));
23
+ vi.mock("node:os", () => ({ homedir: () => "/mock-home" }));
24
+
25
+ import { Hono } from "hono";
26
+ import { registerSkillRoutes } from "./skills-routes.js";
27
+
28
+ // ─── Constants ──────────────────────────────────────────────────────────────
29
+ // SKILLS_DIR resolves to /mock-home/.claude/skills because homedir() is mocked.
30
+ const SKILLS_DIR = "/mock-home/.claude/skills";
31
+
32
+ // ─── Helpers ────────────────────────────────────────────────────────────────
33
+
34
+ /**
35
+ * Creates a fake directory entry for readdir({ withFileTypes: true }).
36
+ * Simulates a Dirent object with name and isDirectory method.
37
+ */
38
+ function makeDirent(name: string, isDir = true) {
39
+ return { name, isDirectory: () => isDir };
40
+ }
41
+
42
+ /**
43
+ * Builds a SKILL.md file content string with YAML front matter.
44
+ * Allows testing the front matter parser with various name/description values.
45
+ */
46
+ function makeSkillMd(name: string, description: string, body = "") {
47
+ return `---\nname: ${name}\ndescription: "${description}"\n---\n\n${body}`;
48
+ }
49
+
50
+ // ─── Test setup ─────────────────────────────────────────────────────────────
51
+
52
+ let app: Hono;
53
+
54
+ beforeEach(() => {
55
+ vi.clearAllMocks();
56
+ // Reset all mocks to their default return values
57
+ mockExistsSync.mockReturnValue(false);
58
+ mockReaddir.mockResolvedValue([]);
59
+ mockReadFile.mockResolvedValue("");
60
+ mockWriteFile.mockResolvedValue(undefined);
61
+ mockRm.mockResolvedValue(undefined);
62
+ mockMkdir.mockResolvedValue(undefined);
63
+
64
+ app = new Hono();
65
+ registerSkillRoutes(app);
66
+ });
67
+
68
+ // ─── GET /skills ────────────────────────────────────────────────────────────
69
+
70
+ describe("GET /skills", () => {
71
+ it("returns an empty array when SKILLS_DIR does not exist", async () => {
72
+ // existsSync returns false by default, so the directory doesn't exist
73
+ mockExistsSync.mockReturnValue(false);
74
+
75
+ const res = await app.request("/skills");
76
+
77
+ expect(res.status).toBe(200);
78
+ const json = await res.json();
79
+ expect(json).toEqual([]);
80
+ });
81
+
82
+ it("returns an empty array when SKILLS_DIR exists but is empty", async () => {
83
+ // The first existsSync call checks SKILLS_DIR itself
84
+ mockExistsSync.mockReturnValue(true);
85
+ mockReaddir.mockResolvedValue([]);
86
+
87
+ const res = await app.request("/skills");
88
+
89
+ expect(res.status).toBe(200);
90
+ const json = await res.json();
91
+ expect(json).toEqual([]);
92
+ });
93
+
94
+ it("skips non-directory entries in SKILLS_DIR", async () => {
95
+ // Files inside the skills directory should be ignored (only directories matter)
96
+ mockExistsSync.mockReturnValue(true);
97
+ mockReaddir.mockResolvedValue([
98
+ makeDirent("readme.txt", false),
99
+ makeDirent(".DS_Store", false),
100
+ ] as any);
101
+
102
+ const res = await app.request("/skills");
103
+
104
+ expect(res.status).toBe(200);
105
+ const json = await res.json();
106
+ expect(json).toEqual([]);
107
+ });
108
+
109
+ it("skips directories that have no SKILL.md file", async () => {
110
+ // A directory exists but its SKILL.md path check returns false
111
+ mockExistsSync
112
+ .mockReturnValueOnce(true) // SKILLS_DIR exists
113
+ .mockReturnValueOnce(false); // SKILL.md does not exist
114
+ mockReaddir.mockResolvedValue([makeDirent("orphan-dir")] as any);
115
+
116
+ const res = await app.request("/skills");
117
+
118
+ expect(res.status).toBe(200);
119
+ const json = await res.json();
120
+ expect(json).toEqual([]);
121
+ });
122
+
123
+ it("parses front matter and returns skill metadata for valid skills", async () => {
124
+ // Two valid skill directories with properly formatted SKILL.md files
125
+ mockExistsSync.mockReturnValue(true);
126
+ mockReaddir.mockResolvedValue([
127
+ makeDirent("my-skill"),
128
+ makeDirent("another-skill"),
129
+ ] as any);
130
+ mockReadFile
131
+ .mockResolvedValueOnce(makeSkillMd("My Skill", "Does cool things", "# Usage\nRun it."))
132
+ .mockResolvedValueOnce(makeSkillMd("Another Skill", "Also useful"));
133
+
134
+ const res = await app.request("/skills");
135
+
136
+ expect(res.status).toBe(200);
137
+ const json = await res.json();
138
+ expect(json).toHaveLength(2);
139
+ expect(json[0]).toEqual({
140
+ slug: "my-skill",
141
+ name: "My Skill",
142
+ description: "Does cool things",
143
+ path: `${SKILLS_DIR}/my-skill/SKILL.md`,
144
+ });
145
+ expect(json[1]).toEqual({
146
+ slug: "another-skill",
147
+ name: "Another Skill",
148
+ description: "Also useful",
149
+ path: `${SKILLS_DIR}/another-skill/SKILL.md`,
150
+ });
151
+ });
152
+
153
+ it("uses the directory name as fallback when front matter has no name field", async () => {
154
+ // Front matter exists but has no name: line — slug should be used as name
155
+ mockExistsSync.mockReturnValue(true);
156
+ mockReaddir.mockResolvedValue([makeDirent("fallback-skill")] as any);
157
+ mockReadFile.mockResolvedValue("---\ndescription: \"just a description\"\n---\n\nsome content");
158
+
159
+ const res = await app.request("/skills");
160
+
161
+ expect(res.status).toBe(200);
162
+ const json = await res.json();
163
+ expect(json).toHaveLength(1);
164
+ expect(json[0].name).toBe("fallback-skill");
165
+ expect(json[0].description).toBe("just a description");
166
+ });
167
+
168
+ it("handles SKILL.md files with no front matter (no --- delimiters)", async () => {
169
+ // Content without front matter — name falls back to directory name, description empty
170
+ mockExistsSync.mockReturnValue(true);
171
+ mockReaddir.mockResolvedValue([makeDirent("raw-skill")] as any);
172
+ mockReadFile.mockResolvedValue("# Raw Skill\n\nNo front matter here.");
173
+
174
+ const res = await app.request("/skills");
175
+
176
+ expect(res.status).toBe(200);
177
+ const json = await res.json();
178
+ expect(json).toHaveLength(1);
179
+ expect(json[0].name).toBe("raw-skill");
180
+ expect(json[0].description).toBe("");
181
+ });
182
+
183
+ it("strips quotes from name values in front matter", async () => {
184
+ // Name wrapped in double quotes should have them removed
185
+ mockExistsSync.mockReturnValue(true);
186
+ mockReaddir.mockResolvedValue([makeDirent("quoted-skill")] as any);
187
+ mockReadFile.mockResolvedValue('---\nname: "Quoted Name"\ndescription: "desc"\n---\n\ncontent');
188
+
189
+ const res = await app.request("/skills");
190
+
191
+ expect(res.status).toBe(200);
192
+ const json = await res.json();
193
+ expect(json[0].name).toBe("Quoted Name");
194
+ });
195
+
196
+ it("strips single quotes from name values in front matter", async () => {
197
+ // Name wrapped in single quotes should also be stripped
198
+ mockExistsSync.mockReturnValue(true);
199
+ mockReaddir.mockResolvedValue([makeDirent("sq-skill")] as any);
200
+ mockReadFile.mockResolvedValue("---\nname: 'Single Quoted'\ndescription: 'desc'\n---\n\ncontent");
201
+
202
+ const res = await app.request("/skills");
203
+
204
+ expect(res.status).toBe(200);
205
+ const json = await res.json();
206
+ expect(json[0].name).toBe("Single Quoted");
207
+ });
208
+
209
+ it("returns a mixed list filtering out non-directories and missing SKILL.md entries", async () => {
210
+ // Mix of valid directories, non-directories, and directories without SKILL.md
211
+ mockExistsSync
212
+ .mockReturnValueOnce(true) // SKILLS_DIR exists
213
+ .mockReturnValueOnce(true) // valid-skill/SKILL.md exists
214
+ .mockReturnValueOnce(false); // no-md-skill/SKILL.md does not exist
215
+ mockReaddir.mockResolvedValue([
216
+ makeDirent("valid-skill"),
217
+ makeDirent("just-a-file.txt", false),
218
+ makeDirent("no-md-skill"),
219
+ ] as any);
220
+ mockReadFile.mockResolvedValueOnce(makeSkillMd("Valid", "A valid skill"));
221
+
222
+ const res = await app.request("/skills");
223
+
224
+ expect(res.status).toBe(200);
225
+ const json = await res.json();
226
+ expect(json).toHaveLength(1);
227
+ expect(json[0].slug).toBe("valid-skill");
228
+ });
229
+
230
+ it("returns 500 when readdir throws an unexpected error", async () => {
231
+ // Simulates a filesystem error during directory listing
232
+ mockExistsSync.mockReturnValue(true);
233
+ mockReaddir.mockRejectedValue(new Error("Permission denied"));
234
+
235
+ const res = await app.request("/skills");
236
+
237
+ expect(res.status).toBe(500);
238
+ const json = await res.json();
239
+ expect(json.error).toContain("Permission denied");
240
+ });
241
+
242
+ it("returns 500 when readFile throws an unexpected error", async () => {
243
+ // Simulates a read error on an individual SKILL.md file
244
+ mockExistsSync.mockReturnValue(true);
245
+ mockReaddir.mockResolvedValue([makeDirent("broken-skill")] as any);
246
+ mockReadFile.mockRejectedValue(new Error("EACCES: permission denied"));
247
+
248
+ const res = await app.request("/skills");
249
+
250
+ expect(res.status).toBe(500);
251
+ const json = await res.json();
252
+ expect(json.error).toContain("EACCES");
253
+ });
254
+ });
255
+
256
+ // ─── GET /skills/:slug ──────────────────────────────────────────────────────
257
+
258
+ describe("GET /skills/:slug", () => {
259
+ it("returns the skill content when slug and SKILL.md are valid", async () => {
260
+ // Happy path: valid slug, file exists, content is returned
261
+ const content = makeSkillMd("My Skill", "A useful skill", "# Usage\nDo stuff.");
262
+ mockExistsSync.mockReturnValue(true);
263
+ mockReadFile.mockResolvedValue(content);
264
+
265
+ const res = await app.request("/skills/my-skill");
266
+
267
+ expect(res.status).toBe(200);
268
+ const json = await res.json();
269
+ expect(json).toEqual({
270
+ slug: "my-skill",
271
+ path: `${SKILLS_DIR}/my-skill/SKILL.md`,
272
+ content,
273
+ });
274
+ });
275
+
276
+ it("returns 404 when SKILL.md does not exist for the slug", async () => {
277
+ mockExistsSync.mockReturnValue(false);
278
+
279
+ const res = await app.request("/skills/nonexistent");
280
+
281
+ expect(res.status).toBe(404);
282
+ const json = await res.json();
283
+ expect(json.error).toBe("Skill not found");
284
+ });
285
+
286
+ it("returns 400 when slug contains '..'", async () => {
287
+ // Path traversal attempt with double dots
288
+ const res = await app.request("/skills/..%2F..%2Fetc");
289
+
290
+ expect(res.status).toBe(400);
291
+ const json = await res.json();
292
+ expect(json.error).toBe("Invalid slug");
293
+ });
294
+
295
+ it("returns 400 when slug contains a forward slash", async () => {
296
+ // The route parameter itself won't contain a literal '/' since Hono
297
+ // treats it as a path separator. However, URL-encoded '/' (%2F) in
298
+ // the slug param is tested here to verify the validation logic.
299
+ // We test the validation by calling the path directly with a known bad slug.
300
+ const res = await app.request("/skills/bad%2Fslug");
301
+
302
+ expect(res.status).toBe(400);
303
+ const json = await res.json();
304
+ expect(json.error).toBe("Invalid slug");
305
+ });
306
+
307
+ it("returns 400 when slug contains a backslash", async () => {
308
+ const res = await app.request("/skills/bad%5Cslug");
309
+
310
+ expect(res.status).toBe(400);
311
+ const json = await res.json();
312
+ expect(json.error).toBe("Invalid slug");
313
+ });
314
+ });
315
+
316
+ // ─── POST /skills ───────────────────────────────────────────────────────────
317
+
318
+ describe("POST /skills", () => {
319
+ it("creates a new skill with name, description, and content", async () => {
320
+ // Happy path: new skill that does not yet exist
321
+ mockExistsSync.mockReturnValue(false); // SKILL.md doesn't exist yet
322
+
323
+ const res = await app.request("/skills", {
324
+ method: "POST",
325
+ headers: { "Content-Type": "application/json" },
326
+ body: JSON.stringify({
327
+ name: "My New Skill",
328
+ description: "Does amazing things",
329
+ content: "# My New Skill\n\nHere are the instructions.",
330
+ }),
331
+ });
332
+
333
+ expect(res.status).toBe(200);
334
+ const json = await res.json();
335
+ expect(json.slug).toBe("my-new-skill");
336
+ expect(json.name).toBe("My New Skill");
337
+ expect(json.description).toBe("Does amazing things");
338
+ expect(json.path).toBe(`${SKILLS_DIR}/my-new-skill/SKILL.md`);
339
+
340
+ // Verify mkdir was called for both SKILLS_DIR and the skill directory
341
+ expect(mockMkdir).toHaveBeenCalledWith(SKILLS_DIR, { recursive: true });
342
+ expect(mockMkdir).toHaveBeenCalledWith(`${SKILLS_DIR}/my-new-skill`, { recursive: true });
343
+
344
+ // Verify writeFile was called with the expected markdown content
345
+ expect(mockWriteFile).toHaveBeenCalledTimes(1);
346
+ const writtenContent = mockWriteFile.mock.calls[0][1] as string;
347
+ expect(writtenContent).toContain("name: my-new-skill");
348
+ expect(writtenContent).toContain('"Does amazing things"');
349
+ expect(writtenContent).toContain("# My New Skill\n\nHere are the instructions.");
350
+ });
351
+
352
+ it("generates default content when content is not provided", async () => {
353
+ // Omitting content should produce a default "# Name" body
354
+ mockExistsSync.mockReturnValue(false);
355
+
356
+ const res = await app.request("/skills", {
357
+ method: "POST",
358
+ headers: { "Content-Type": "application/json" },
359
+ body: JSON.stringify({ name: "Bare Skill" }),
360
+ });
361
+
362
+ expect(res.status).toBe(200);
363
+ const json = await res.json();
364
+ expect(json.slug).toBe("bare-skill");
365
+
366
+ // Default content should include the skill name as a heading
367
+ const writtenContent = mockWriteFile.mock.calls[0][1] as string;
368
+ expect(writtenContent).toContain("# Bare Skill");
369
+ expect(writtenContent).toContain("Describe what this skill does");
370
+ });
371
+
372
+ it("generates a default description when description is not provided", async () => {
373
+ // Omitting description should fallback to "Skill: <name>"
374
+ mockExistsSync.mockReturnValue(false);
375
+
376
+ const res = await app.request("/skills", {
377
+ method: "POST",
378
+ headers: { "Content-Type": "application/json" },
379
+ body: JSON.stringify({ name: "No Desc" }),
380
+ });
381
+
382
+ expect(res.status).toBe(200);
383
+ const json = await res.json();
384
+ expect(json.description).toBe("Skill: No Desc");
385
+ });
386
+
387
+ it("returns 400 when name is missing", async () => {
388
+ const res = await app.request("/skills", {
389
+ method: "POST",
390
+ headers: { "Content-Type": "application/json" },
391
+ body: JSON.stringify({ description: "No name provided" }),
392
+ });
393
+
394
+ expect(res.status).toBe(400);
395
+ const json = await res.json();
396
+ expect(json.error).toBe("name is required");
397
+ });
398
+
399
+ it("returns 400 when name is not a string", async () => {
400
+ const res = await app.request("/skills", {
401
+ method: "POST",
402
+ headers: { "Content-Type": "application/json" },
403
+ body: JSON.stringify({ name: 12345 }),
404
+ });
405
+
406
+ expect(res.status).toBe(400);
407
+ const json = await res.json();
408
+ expect(json.error).toBe("name is required");
409
+ });
410
+
411
+ it("returns 400 when name is an empty string", async () => {
412
+ const res = await app.request("/skills", {
413
+ method: "POST",
414
+ headers: { "Content-Type": "application/json" },
415
+ body: JSON.stringify({ name: "" }),
416
+ });
417
+
418
+ expect(res.status).toBe(400);
419
+ const json = await res.json();
420
+ expect(json.error).toBe("name is required");
421
+ });
422
+
423
+ it("returns 400 when name produces an empty slug (all special characters)", async () => {
424
+ // A name like "!!!" would become an empty slug after sanitization
425
+ const res = await app.request("/skills", {
426
+ method: "POST",
427
+ headers: { "Content-Type": "application/json" },
428
+ body: JSON.stringify({ name: "!!!" }),
429
+ });
430
+
431
+ expect(res.status).toBe(400);
432
+ const json = await res.json();
433
+ expect(json.error).toBe("Invalid name");
434
+ });
435
+
436
+ it("returns 409 when a skill with the same slug already exists", async () => {
437
+ // existsSync returns true for the SKILL.md check, indicating conflict
438
+ mockExistsSync.mockReturnValue(true);
439
+
440
+ const res = await app.request("/skills", {
441
+ method: "POST",
442
+ headers: { "Content-Type": "application/json" },
443
+ body: JSON.stringify({ name: "Existing Skill" }),
444
+ });
445
+
446
+ expect(res.status).toBe(409);
447
+ const json = await res.json();
448
+ expect(json.error).toContain("already exists");
449
+ expect(json.error).toContain("existing-skill");
450
+ });
451
+
452
+ it("handles invalid JSON body gracefully", async () => {
453
+ // Malformed JSON should not crash the server — c.req.json().catch returns {}
454
+ const res = await app.request("/skills", {
455
+ method: "POST",
456
+ headers: { "Content-Type": "application/json" },
457
+ body: "not valid json",
458
+ });
459
+
460
+ expect(res.status).toBe(400);
461
+ const json = await res.json();
462
+ expect(json.error).toBe("name is required");
463
+ });
464
+
465
+ it("generates a correct slug from a name with special characters", async () => {
466
+ // Names with mixed case, spaces, and special chars should produce clean slugs
467
+ mockExistsSync.mockReturnValue(false);
468
+
469
+ const res = await app.request("/skills", {
470
+ method: "POST",
471
+ headers: { "Content-Type": "application/json" },
472
+ body: JSON.stringify({ name: " Hello World!!! @#$ Test " }),
473
+ });
474
+
475
+ expect(res.status).toBe(200);
476
+ const json = await res.json();
477
+ // The slug generation lowercases, replaces non-alphanum with hyphens,
478
+ // and trims leading/trailing hyphens
479
+ expect(json.slug).toBe("hello-world-test");
480
+ });
481
+
482
+ it("trims leading and trailing hyphens from the generated slug", async () => {
483
+ // A name with leading/trailing special chars should not produce leading/trailing hyphens
484
+ mockExistsSync.mockReturnValue(false);
485
+
486
+ const res = await app.request("/skills", {
487
+ method: "POST",
488
+ headers: { "Content-Type": "application/json" },
489
+ body: JSON.stringify({ name: "---trimmed---" }),
490
+ });
491
+
492
+ expect(res.status).toBe(200);
493
+ const json = await res.json();
494
+ expect(json.slug).toBe("trimmed");
495
+ });
496
+ });
497
+
498
+ // ─── PUT /skills/:slug ──────────────────────────────────────────────────────
499
+
500
+ describe("PUT /skills/:slug", () => {
501
+ it("updates the skill content when slug is valid and skill exists", async () => {
502
+ // Happy path: existing skill, valid content update
503
+ mockExistsSync.mockReturnValue(true);
504
+ const newContent = "---\nname: updated\n---\n\n# Updated content";
505
+
506
+ const res = await app.request("/skills/my-skill", {
507
+ method: "PUT",
508
+ headers: { "Content-Type": "application/json" },
509
+ body: JSON.stringify({ content: newContent }),
510
+ });
511
+
512
+ expect(res.status).toBe(200);
513
+ const json = await res.json();
514
+ expect(json).toEqual({
515
+ ok: true,
516
+ slug: "my-skill",
517
+ path: `${SKILLS_DIR}/my-skill/SKILL.md`,
518
+ });
519
+
520
+ // Verify writeFile was called with the new content
521
+ expect(mockWriteFile).toHaveBeenCalledWith(
522
+ `${SKILLS_DIR}/my-skill/SKILL.md`,
523
+ newContent,
524
+ "utf-8",
525
+ );
526
+ });
527
+
528
+ it("allows updating with empty string content", async () => {
529
+ // An empty string is still a valid content value (typeof is "string")
530
+ mockExistsSync.mockReturnValue(true);
531
+
532
+ const res = await app.request("/skills/my-skill", {
533
+ method: "PUT",
534
+ headers: { "Content-Type": "application/json" },
535
+ body: JSON.stringify({ content: "" }),
536
+ });
537
+
538
+ expect(res.status).toBe(200);
539
+ const json = await res.json();
540
+ expect(json.ok).toBe(true);
541
+ expect(mockWriteFile).toHaveBeenCalledWith(
542
+ `${SKILLS_DIR}/my-skill/SKILL.md`,
543
+ "",
544
+ "utf-8",
545
+ );
546
+ });
547
+
548
+ it("returns 404 when the skill does not exist", async () => {
549
+ mockExistsSync.mockReturnValue(false);
550
+
551
+ const res = await app.request("/skills/nonexistent", {
552
+ method: "PUT",
553
+ headers: { "Content-Type": "application/json" },
554
+ body: JSON.stringify({ content: "anything" }),
555
+ });
556
+
557
+ expect(res.status).toBe(404);
558
+ const json = await res.json();
559
+ expect(json.error).toBe("Skill not found");
560
+ });
561
+
562
+ it("returns 400 when content is missing from the body", async () => {
563
+ mockExistsSync.mockReturnValue(true);
564
+
565
+ const res = await app.request("/skills/my-skill", {
566
+ method: "PUT",
567
+ headers: { "Content-Type": "application/json" },
568
+ body: JSON.stringify({ name: "no content field" }),
569
+ });
570
+
571
+ expect(res.status).toBe(400);
572
+ const json = await res.json();
573
+ expect(json.error).toBe("content is required");
574
+ });
575
+
576
+ it("returns 400 when content is not a string", async () => {
577
+ mockExistsSync.mockReturnValue(true);
578
+
579
+ const res = await app.request("/skills/my-skill", {
580
+ method: "PUT",
581
+ headers: { "Content-Type": "application/json" },
582
+ body: JSON.stringify({ content: 42 }),
583
+ });
584
+
585
+ expect(res.status).toBe(400);
586
+ const json = await res.json();
587
+ expect(json.error).toBe("content is required");
588
+ });
589
+
590
+ it("returns 400 when slug contains '..'", async () => {
591
+ const res = await app.request("/skills/..%2Fevil", {
592
+ method: "PUT",
593
+ headers: { "Content-Type": "application/json" },
594
+ body: JSON.stringify({ content: "pwned" }),
595
+ });
596
+
597
+ expect(res.status).toBe(400);
598
+ const json = await res.json();
599
+ expect(json.error).toBe("Invalid slug");
600
+ });
601
+
602
+ it("returns 400 when slug contains a backslash", async () => {
603
+ const res = await app.request("/skills/back%5Cslash", {
604
+ method: "PUT",
605
+ headers: { "Content-Type": "application/json" },
606
+ body: JSON.stringify({ content: "test" }),
607
+ });
608
+
609
+ expect(res.status).toBe(400);
610
+ const json = await res.json();
611
+ expect(json.error).toBe("Invalid slug");
612
+ });
613
+
614
+ it("handles invalid JSON body gracefully", async () => {
615
+ // Malformed body falls through to the content check
616
+ mockExistsSync.mockReturnValue(true);
617
+
618
+ const res = await app.request("/skills/my-skill", {
619
+ method: "PUT",
620
+ headers: { "Content-Type": "application/json" },
621
+ body: "{{bad json",
622
+ });
623
+
624
+ expect(res.status).toBe(400);
625
+ const json = await res.json();
626
+ expect(json.error).toBe("content is required");
627
+ });
628
+ });
629
+
630
+ // ─── DELETE /skills/:slug ───────────────────────────────────────────────────
631
+
632
+ describe("DELETE /skills/:slug", () => {
633
+ it("deletes the skill directory when it exists", async () => {
634
+ // Happy path: directory exists, rm succeeds
635
+ mockExistsSync.mockReturnValue(true);
636
+
637
+ const res = await app.request("/skills/doomed-skill", { method: "DELETE" });
638
+
639
+ expect(res.status).toBe(200);
640
+ const json = await res.json();
641
+ expect(json).toEqual({ ok: true, slug: "doomed-skill" });
642
+
643
+ // Verify rm was called with recursive and force flags on the directory
644
+ expect(mockRm).toHaveBeenCalledWith(
645
+ `${SKILLS_DIR}/doomed-skill`,
646
+ { recursive: true, force: true },
647
+ );
648
+ });
649
+
650
+ it("returns 404 when the skill directory does not exist", async () => {
651
+ mockExistsSync.mockReturnValue(false);
652
+
653
+ const res = await app.request("/skills/ghost", { method: "DELETE" });
654
+
655
+ expect(res.status).toBe(404);
656
+ const json = await res.json();
657
+ expect(json.error).toBe("Skill not found");
658
+ });
659
+
660
+ it("returns 400 when slug contains '..'", async () => {
661
+ const res = await app.request("/skills/..%2F..%2Fetc", { method: "DELETE" });
662
+
663
+ expect(res.status).toBe(400);
664
+ const json = await res.json();
665
+ expect(json.error).toBe("Invalid slug");
666
+ });
667
+
668
+ it("returns 400 when slug contains a forward slash", async () => {
669
+ const res = await app.request("/skills/a%2Fb", { method: "DELETE" });
670
+
671
+ expect(res.status).toBe(400);
672
+ const json = await res.json();
673
+ expect(json.error).toBe("Invalid slug");
674
+ });
675
+
676
+ it("returns 400 when slug contains a backslash", async () => {
677
+ const res = await app.request("/skills/a%5Cb", { method: "DELETE" });
678
+
679
+ expect(res.status).toBe(400);
680
+ const json = await res.json();
681
+ expect(json.error).toBe("Invalid slug");
682
+ });
683
+
684
+ it("does not call rm when slug validation fails", async () => {
685
+ // Ensure that the filesystem is never touched for invalid slugs
686
+ await app.request("/skills/..%2Fetc%2Fpasswd", { method: "DELETE" });
687
+
688
+ expect(mockRm).not.toHaveBeenCalled();
689
+ });
690
+ });