kcg-code 0.0.0-stage → 0.2.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 (307) hide show
  1. package/README.md +230 -2
  2. package/bin/kcgcode.ts +13 -0
  3. package/bun-env.d.ts +19 -0
  4. package/kcg-code.config.example.json +3 -0
  5. package/package.json +84 -4
  6. package/public/logo.svg +1 -0
  7. package/public/manifest.json +18 -0
  8. package/public/sw.js +49 -0
  9. package/src/App.tsx +39 -0
  10. package/src/__tests__/manifest.test.ts +61 -0
  11. package/src/build-config.ts +9 -0
  12. package/src/cli/__tests__/daemon.test.ts +84 -0
  13. package/src/cli/__tests__/dashboard.test.ts +224 -0
  14. package/src/cli/__tests__/keep-awake.test.ts +44 -0
  15. package/src/cli/__tests__/port.test.ts +86 -0
  16. package/src/cli/args.ts +186 -0
  17. package/src/cli/attach.ts +154 -0
  18. package/src/cli/commands.ts +327 -0
  19. package/src/cli/daemon.ts +275 -0
  20. package/src/cli/dashboard.tsx +1060 -0
  21. package/src/cli/index.ts +47 -0
  22. package/src/cli/keep-awake.ts +148 -0
  23. package/src/cli/port.ts +92 -0
  24. package/src/cli/theme.ts +100 -0
  25. package/src/components/auth/AuthGate.tsx +182 -0
  26. package/src/components/auth/AvatarEditor.tsx +236 -0
  27. package/src/components/auth/LockScreen.tsx +714 -0
  28. package/src/components/auth/ProfileAvatar.tsx +60 -0
  29. package/src/components/auth/WelcomeScreen.tsx +672 -0
  30. package/src/components/auth/__tests__/WelcomeScreen.test.tsx +42 -0
  31. package/src/components/auth/avatar-icons.tsx +114 -0
  32. package/src/components/layout/AppShell.tsx +147 -0
  33. package/src/components/layout/AppSidebar.tsx +419 -0
  34. package/src/components/layout/SearchDialog.tsx +189 -0
  35. package/src/components/layout/SidebarAccount.tsx +111 -0
  36. package/src/components/layout/SidebarSessionMenu.tsx +163 -0
  37. package/src/components/layout/ThemeToggle.tsx +67 -0
  38. package/src/components/projects/FolderBrowser.tsx +142 -0
  39. package/src/components/projects/HomeChat.tsx +287 -0
  40. package/src/components/projects/HomeRecentSessions.tsx +90 -0
  41. package/src/components/projects/NewProjectDialog.tsx +249 -0
  42. package/src/components/projects/ProjectInstructions.tsx +218 -0
  43. package/src/components/projects/ProjectRow.tsx +58 -0
  44. package/src/components/projects/ProjectSidePanel.tsx +514 -0
  45. package/src/components/sessions/AgentLogo.tsx +42 -0
  46. package/src/components/sessions/AgentModeList.tsx +265 -0
  47. package/src/components/sessions/AgentPicker.tsx +132 -0
  48. package/src/components/sessions/AssistantTurn.tsx +145 -0
  49. package/src/components/sessions/AttachmentTile.tsx +161 -0
  50. package/src/components/sessions/CodeBlock.tsx +107 -0
  51. package/src/components/sessions/ComposerActionSheet.tsx +96 -0
  52. package/src/components/sessions/ConfirmDeleteProjectDialog.tsx +80 -0
  53. package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +92 -0
  54. package/src/components/sessions/ContextPanel.tsx +661 -0
  55. package/src/components/sessions/DropPlate.tsx +165 -0
  56. package/src/components/sessions/FileDropOverlay.tsx +232 -0
  57. package/src/components/sessions/FileMentionDropdown.tsx +47 -0
  58. package/src/components/sessions/ImageLightbox.tsx +173 -0
  59. package/src/components/sessions/MarkdownContent.tsx +187 -0
  60. package/src/components/sessions/Mascot.tsx +47 -0
  61. package/src/components/sessions/MessageActions.tsx +60 -0
  62. package/src/components/sessions/MessageRail.tsx +121 -0
  63. package/src/components/sessions/ModelPicker.tsx +247 -0
  64. package/src/components/sessions/ModelSearchList.tsx +231 -0
  65. package/src/components/sessions/NewSessionDialog.tsx +172 -0
  66. package/src/components/sessions/PendingAttachments.tsx +127 -0
  67. package/src/components/sessions/PromptCard.tsx +1085 -0
  68. package/src/components/sessions/PromptPanel.tsx +112 -0
  69. package/src/components/sessions/SessionComposer.tsx +641 -0
  70. package/src/components/sessions/SessionHeader.tsx +176 -0
  71. package/src/components/sessions/SessionList.tsx +503 -0
  72. package/src/components/sessions/SessionRow.tsx +237 -0
  73. package/src/components/sessions/SessionTimeline.tsx +275 -0
  74. package/src/components/sessions/SessionTimelineSkeleton.tsx +83 -0
  75. package/src/components/sessions/SessionView.tsx +240 -0
  76. package/src/components/sessions/ThoughtProcessBlock.tsx +199 -0
  77. package/src/components/sessions/UserMessage.tsx +177 -0
  78. package/src/components/sessions/__tests__/ContextPanel.test.tsx +256 -0
  79. package/src/components/sessions/__tests__/MarkdownContent.test.tsx +89 -0
  80. package/src/components/sessions/__tests__/MessageRail.test.tsx +40 -0
  81. package/src/components/sessions/__tests__/PermissionCard.test.tsx +79 -0
  82. package/src/components/sessions/__tests__/PromptCard.test.ts +166 -0
  83. package/src/components/sessions/__tests__/QuestionCard.test.tsx +166 -0
  84. package/src/components/sessions/__tests__/SessionTimelineSkeleton.test.tsx +32 -0
  85. package/src/components/sessions/__tests__/SessionView.test.ts +386 -0
  86. package/src/components/settings/AboutGroup.tsx +170 -0
  87. package/src/components/settings/AccountGroup.tsx +122 -0
  88. package/src/components/settings/DevicesGroup.tsx +151 -0
  89. package/src/components/settings/LhrRemoteAccess.tsx +257 -0
  90. package/src/components/settings/PasskeysGroup.tsx +551 -0
  91. package/src/components/settings/RemoteAccessGroup.tsx +1201 -0
  92. package/src/components/settings/SecurityGroup.tsx +566 -0
  93. package/src/components/settings/WorkspaceGroup.tsx +370 -0
  94. package/src/components/settings/__tests__/AboutGroup.test.tsx +47 -0
  95. package/src/components/settings/__tests__/PasskeysGroup.test.tsx +109 -0
  96. package/src/components/settings/__tests__/WorkspaceGroup.test.tsx +49 -0
  97. package/src/components/settings/prefs.tsx +333 -0
  98. package/src/components/skills/InstallNotifications.tsx +326 -0
  99. package/src/components/skills/InstallSkillDialog.tsx +390 -0
  100. package/src/components/skills/SkillCards.tsx +254 -0
  101. package/src/components/skills/SkillsProjectPicker.tsx +178 -0
  102. package/src/components/ui/adw-dialog.tsx +114 -0
  103. package/src/components/ui/adw-toggle-group.tsx +75 -0
  104. package/src/components/ui/alert-dialog.tsx +179 -0
  105. package/src/components/ui/alert.tsx +60 -0
  106. package/src/components/ui/badge.tsx +46 -0
  107. package/src/components/ui/bubble.tsx +125 -0
  108. package/src/components/ui/button.tsx +62 -0
  109. package/src/components/ui/card.tsx +75 -0
  110. package/src/components/ui/collapsible.tsx +19 -0
  111. package/src/components/ui/dialog.tsx +142 -0
  112. package/src/components/ui/dropdown-menu.tsx +225 -0
  113. package/src/components/ui/empty.tsx +94 -0
  114. package/src/components/ui/field.tsx +234 -0
  115. package/src/components/ui/input-group.tsx +161 -0
  116. package/src/components/ui/input.tsx +21 -0
  117. package/src/components/ui/label.tsx +19 -0
  118. package/src/components/ui/message-scroller.tsx +131 -0
  119. package/src/components/ui/message.tsx +85 -0
  120. package/src/components/ui/popover.tsx +46 -0
  121. package/src/components/ui/search-entry.tsx +70 -0
  122. package/src/components/ui/select.tsx +172 -0
  123. package/src/components/ui/separator.tsx +28 -0
  124. package/src/components/ui/sheet.tsx +138 -0
  125. package/src/components/ui/sidebar.tsx +699 -0
  126. package/src/components/ui/skeleton.tsx +15 -0
  127. package/src/components/ui/sonner.tsx +45 -0
  128. package/src/components/ui/spinner.tsx +16 -0
  129. package/src/components/ui/switch.tsx +32 -0
  130. package/src/components/ui/textarea.tsx +18 -0
  131. package/src/components/ui/tooltip.tsx +51 -0
  132. package/src/config.ts +114 -0
  133. package/src/db.ts +146 -0
  134. package/src/frontend.tsx +51 -0
  135. package/src/hooks/__tests__/useMention.test.ts +79 -0
  136. package/src/hooks/__tests__/useResyncOnReconnect.test.ts +128 -0
  137. package/src/hooks/__tests__/useRouter.test.ts +17 -0
  138. package/src/hooks/__tests__/useSmoothText.react.test.ts +194 -0
  139. package/src/hooks/__tests__/useSmoothText.test.ts +61 -0
  140. package/src/hooks/__tests__/useTheme.test.ts +118 -0
  141. package/src/hooks/use-mobile.ts +16 -0
  142. package/src/hooks/useAgentPicker.ts +101 -0
  143. package/src/hooks/useMediaQuery.ts +49 -0
  144. package/src/hooks/useMention.ts +236 -0
  145. package/src/hooks/useProjects.ts +88 -0
  146. package/src/hooks/useResyncOnReconnect.ts +28 -0
  147. package/src/hooks/useRouter.ts +92 -0
  148. package/src/hooks/useSessionChat.ts +427 -0
  149. package/src/hooks/useSessionContext.ts +106 -0
  150. package/src/hooks/useSessionList.ts +327 -0
  151. package/src/hooks/useSidebarData.ts +108 -0
  152. package/src/hooks/useSmoothText.ts +122 -0
  153. package/src/hooks/useTheme.ts +164 -0
  154. package/src/hooks/useWebSocket.ts +196 -0
  155. package/src/index.css +27 -0
  156. package/src/index.html +14 -0
  157. package/src/index.ts +49 -0
  158. package/src/lib/__tests__/attachments.test.ts +58 -0
  159. package/src/lib/__tests__/avatar-presets.test.ts +25 -0
  160. package/src/lib/__tests__/collapsible.test.ts +101 -0
  161. package/src/lib/__tests__/composer.test.ts +118 -0
  162. package/src/lib/__tests__/highlight.test.ts +42 -0
  163. package/src/lib/__tests__/home-chat.test.ts +64 -0
  164. package/src/lib/__tests__/lock-screen.test.ts +64 -0
  165. package/src/lib/__tests__/model-picker.test.ts +205 -0
  166. package/src/lib/__tests__/onboarding.test.ts +41 -0
  167. package/src/lib/__tests__/permission-info.test.ts +61 -0
  168. package/src/lib/__tests__/project-extensions.test.ts +83 -0
  169. package/src/lib/__tests__/project-overview.test.ts +188 -0
  170. package/src/lib/__tests__/references.test.ts +121 -0
  171. package/src/lib/__tests__/routes.test.ts +134 -0
  172. package/src/lib/__tests__/session-selection.test.ts +41 -0
  173. package/src/lib/__tests__/session-summary.test.ts +115 -0
  174. package/src/lib/__tests__/session-title.test.ts +58 -0
  175. package/src/lib/__tests__/sidebar-groups.test.ts +100 -0
  176. package/src/lib/__tests__/skill-installs.test.ts +135 -0
  177. package/src/lib/__tests__/skills-catalog.test.ts +124 -0
  178. package/src/lib/__tests__/tone.test.ts +37 -0
  179. package/src/lib/__tests__/turns-delta.test.ts +55 -0
  180. package/src/lib/agent-types.ts +49 -0
  181. package/src/lib/api.ts +306 -0
  182. package/src/lib/attachments.ts +203 -0
  183. package/src/lib/auth.ts +92 -0
  184. package/src/lib/avatar-presets.ts +31 -0
  185. package/src/lib/collapsible.ts +75 -0
  186. package/src/lib/composer.ts +89 -0
  187. package/src/lib/data-events.ts +17 -0
  188. package/src/lib/headlines.ts +30 -0
  189. package/src/lib/highlight.ts +186 -0
  190. package/src/lib/layout.ts +20 -0
  191. package/src/lib/lock-screen.ts +54 -0
  192. package/src/lib/model-picker.ts +133 -0
  193. package/src/lib/onboarding.ts +38 -0
  194. package/src/lib/passkeys.ts +113 -0
  195. package/src/lib/pending-prompt.ts +53 -0
  196. package/src/lib/permission-info.ts +101 -0
  197. package/src/lib/project-extensions.ts +136 -0
  198. package/src/lib/project-overview.ts +123 -0
  199. package/src/lib/prompts.ts +87 -0
  200. package/src/lib/references.ts +106 -0
  201. package/src/lib/routes.ts +124 -0
  202. package/src/lib/session-handoff.ts +25 -0
  203. package/src/lib/session-selection.ts +40 -0
  204. package/src/lib/session-status.ts +45 -0
  205. package/src/lib/session-summary.ts +68 -0
  206. package/src/lib/session-title.ts +63 -0
  207. package/src/lib/sidebar-groups.ts +78 -0
  208. package/src/lib/skill-installs.ts +362 -0
  209. package/src/lib/skills-catalog.ts +138 -0
  210. package/src/lib/time.ts +22 -0
  211. package/src/lib/tone.ts +80 -0
  212. package/src/lib/turns.ts +320 -0
  213. package/src/lib/utils.ts +6 -0
  214. package/src/logo.svg +31 -0
  215. package/src/pages/projects/ProjectDetailPage.tsx +131 -0
  216. package/src/pages/projects/ProjectsPage.tsx +97 -0
  217. package/src/pages/sessions/SessionPage.tsx +122 -0
  218. package/src/pages/settings/SettingsPage.tsx +366 -0
  219. package/src/pages/settings/__tests__/SettingsPage.test.ts +16 -0
  220. package/src/pages/skills/SkillsPage.tsx +745 -0
  221. package/src/paths.ts +121 -0
  222. package/src/runtime.ts +24 -0
  223. package/src/server/__tests__/app.test.ts +1044 -0
  224. package/src/server/__tests__/auth.test.ts +370 -0
  225. package/src/server/__tests__/config.test.ts +101 -0
  226. package/src/server/__tests__/db.test.ts +485 -0
  227. package/src/server/__tests__/host.test.ts +14 -0
  228. package/src/server/__tests__/tunnel-config.test.ts +38 -0
  229. package/src/server/app.ts +427 -0
  230. package/src/server/db/auth.ts +311 -0
  231. package/src/server/db/messages.ts +50 -0
  232. package/src/server/db/projects.ts +106 -0
  233. package/src/server/db/prompts.ts +78 -0
  234. package/src/server/db/rows.ts +150 -0
  235. package/src/server/db/schema.ts +221 -0
  236. package/src/server/db/sessions.ts +239 -0
  237. package/src/server/db/tunnel.ts +124 -0
  238. package/src/server/host.ts +12 -0
  239. package/src/server/result.ts +15 -0
  240. package/src/server/routes/auth-guard.ts +156 -0
  241. package/src/server/routes/auth.routes.ts +287 -0
  242. package/src/server/routes/helpers.ts +165 -0
  243. package/src/server/routes/meta.routes.ts +24 -0
  244. package/src/server/routes/passkeys.routes.ts +161 -0
  245. package/src/server/routes/projects.routes.ts +167 -0
  246. package/src/server/routes/sessions.routes.ts +197 -0
  247. package/src/server/routes/skills.routes.ts +136 -0
  248. package/src/server/routes/tunnel.routes.ts +132 -0
  249. package/src/server/routes/types.ts +35 -0
  250. package/src/server/routes/uploads.routes.ts +54 -0
  251. package/src/server/routes/workspace.routes.ts +127 -0
  252. package/src/server/services/__tests__/app-info.test.ts +18 -0
  253. package/src/server/services/__tests__/attachments.test.ts +159 -0
  254. package/src/server/services/__tests__/auth-passkeys.test.ts +358 -0
  255. package/src/server/services/__tests__/auth.test.ts +341 -0
  256. package/src/server/services/__tests__/claude-code.test.ts +609 -0
  257. package/src/server/services/__tests__/claude-mcp.test.ts +90 -0
  258. package/src/server/services/__tests__/claude-skills.test.ts +63 -0
  259. package/src/server/services/__tests__/frpc-installer.test.ts +83 -0
  260. package/src/server/services/__tests__/git-info.test.ts +74 -0
  261. package/src/server/services/__tests__/opencode.test.ts +320 -0
  262. package/src/server/services/__tests__/project-manager.test.ts +190 -0
  263. package/src/server/services/__tests__/sandbox.test.ts +141 -0
  264. package/src/server/services/__tests__/session-manager.test.ts +3509 -0
  265. package/src/server/services/__tests__/skill-install-jobs.test.ts +245 -0
  266. package/src/server/services/__tests__/skills-registry.test.ts +365 -0
  267. package/src/server/services/__tests__/tunnel-lhr.test.ts +234 -0
  268. package/src/server/services/__tests__/tunnel.test.ts +367 -0
  269. package/src/server/services/__tests__/websocket-gateway.test.ts +677 -0
  270. package/src/server/services/__tests__/workspace.test.ts +249 -0
  271. package/src/server/services/app-info.ts +29 -0
  272. package/src/server/services/attachments.ts +193 -0
  273. package/src/server/services/auth-passkeys.ts +459 -0
  274. package/src/server/services/auth.ts +633 -0
  275. package/src/server/services/claude-code-client.ts +1093 -0
  276. package/src/server/services/claude-code-translate.ts +370 -0
  277. package/src/server/services/claude-mcp.ts +122 -0
  278. package/src/server/services/claude-skills.ts +102 -0
  279. package/src/server/services/frpc-installer.ts +183 -0
  280. package/src/server/services/git-info.ts +87 -0
  281. package/src/server/services/opencode-client.ts +714 -0
  282. package/src/server/services/opencode-server.ts +319 -0
  283. package/src/server/services/opencode-sse.ts +82 -0
  284. package/src/server/services/project-manager.ts +147 -0
  285. package/src/server/services/sandbox.ts +83 -0
  286. package/src/server/services/session-events.ts +256 -0
  287. package/src/server/services/session-manager.ts +1439 -0
  288. package/src/server/services/skill-install-jobs.ts +275 -0
  289. package/src/server/services/skills-registry.ts +480 -0
  290. package/src/server/services/tunnel-api.ts +207 -0
  291. package/src/server/services/tunnel-config.ts +45 -0
  292. package/src/server/services/tunnel-lhr.ts +387 -0
  293. package/src/server/services/tunnel.ts +633 -0
  294. package/src/server/services/turn-stream.ts +339 -0
  295. package/src/server/services/websocket-gateway.ts +374 -0
  296. package/src/server/services/workspace.ts +223 -0
  297. package/src/server/services/ws-transport.ts +121 -0
  298. package/src/types/context.ts +69 -0
  299. package/src/types/index.ts +13 -0
  300. package/src/types/message.ts +58 -0
  301. package/src/types/project.ts +16 -0
  302. package/src/types/prompt.ts +118 -0
  303. package/src/types/session.ts +52 -0
  304. package/src/types/turns.ts +47 -0
  305. package/src/ws-protocol.ts +93 -0
  306. package/styles/globals.css +311 -0
  307. package/tsconfig.json +37 -0
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Unit test Skill_Install_Jobs — registry & refresh di-mock.
3
+ */
4
+ import { describe, expect, test } from "bun:test";
5
+ import type { Result } from "../../result";
6
+ import { createSkillInstallJobs, FINISHED_TTL_MS, MAX_LOG_LINES } from "../skill-install-jobs";
7
+ import type { InstallResult, SkillsRegistry } from "../skills-registry";
8
+
9
+ interface Harness {
10
+ jobs: ReturnType<typeof createSkillInstallJobs>;
11
+ setTime: (t: number) => void;
12
+ release: (res: Result<InstallResult>) => void;
13
+ emit: (line: string) => void;
14
+ refreshCalls: string[];
15
+ /** Agent yang diteruskan ke `registry.install` per panggilan. */
16
+ installAgents: (readonly string[] | undefined)[];
17
+ }
18
+
19
+ /**
20
+ * Registry palsu: instalasi "menggantung" sampai `release` dipanggil — atau
21
+ * sampai signal dibatalkan (meniru proses CLI yang dibunuh).
22
+ */
23
+ function harness(refreshed = true): Harness {
24
+ let t = 1000;
25
+ let emitFn: (line: string) => void = () => {};
26
+ let releaseFn: (res: Result<InstallResult>) => void = () => {};
27
+ const refreshCalls: string[] = [];
28
+ const installAgents: (readonly string[] | undefined)[] = [];
29
+ const registry: SkillsRegistry = {
30
+ search: async () => ({ ok: true, data: [] }),
31
+ audit: async () => ({ ok: true, data: { audits: null, overall: null } }),
32
+ install: (_path, _source, _skill, onLine, signal, agentTypes) =>
33
+ new Promise((resolve) => {
34
+ installAgents.push(agentTypes);
35
+ emitFn = (l) => onLine?.(l);
36
+ releaseFn = resolve;
37
+ signal?.addEventListener("abort", () =>
38
+ resolve({ ok: false, error: "SKILL_INSTALL_CANCELLED" }),
39
+ );
40
+ }),
41
+ };
42
+ const jobs = createSkillInstallJobs({
43
+ registry,
44
+ refreshSkills: async (projectId, agentType) => {
45
+ refreshCalls.push(agentType === "opencode" ? projectId : `${projectId}:${agentType}`);
46
+ return { ok: true, data: { refreshed } };
47
+ },
48
+ validate: (source, skill) => source === "a/b" && !skill.startsWith("-"),
49
+ now: () => t,
50
+ });
51
+ return {
52
+ jobs,
53
+ setTime: (v) => {
54
+ t = v;
55
+ },
56
+ release: (r) => releaseFn(r),
57
+ emit: (l) => emitFn(l),
58
+ refreshCalls,
59
+ installAgents,
60
+ };
61
+ }
62
+
63
+ const input = {
64
+ projectId: "p1",
65
+ projectName: "Proj",
66
+ projectPath: "/proj",
67
+ source: "a/b",
68
+ skillId: "c",
69
+ };
70
+
71
+ describe("createSkillInstallJobs", () => {
72
+ test("agentTypes diteruskan ke instalasi & refresh tiap agent (default opencode)", async () => {
73
+ const h = harness();
74
+ const a = h.jobs.start({ ...input, agentTypes: ["opencode", "claude-code"] });
75
+ expect(a.ok && a.data.agentTypes).toEqual(["opencode", "claude-code"]);
76
+ h.release({ ok: true, data: { name: "c", path: null } });
77
+ if (a.ok) await h.jobs.settled(a.data.id);
78
+ const b = h.jobs.start(input);
79
+ expect(b.ok && b.data.agentTypes).toEqual(["opencode"]);
80
+ h.release({ ok: true, data: { name: "c", path: null } });
81
+ if (b.ok) await h.jobs.settled(b.data.id);
82
+ expect(h.installAgents).toEqual([["opencode", "claude-code"], ["opencode"]]);
83
+ expect(h.refreshCalls).toEqual(["p1", "p1:claude-code", "p1"]);
84
+ });
85
+
86
+ test("job berjalan, log bertahap via from, sukses + refresh", async () => {
87
+ const h = harness();
88
+ const started = h.jobs.start(input);
89
+ expect(started.ok).toBe(true);
90
+ if (!started.ok) return;
91
+ const id = started.data.id;
92
+ expect(started.data.status).toBe("running");
93
+
94
+ h.emit("line 1");
95
+ h.emit("line 2");
96
+ const first = h.jobs.get(id, 0);
97
+ expect(first?.lines).toEqual(["line 1", "line 2"]);
98
+ expect(first?.job.lineCount).toBe(2);
99
+
100
+ h.emit("line 3");
101
+ const next = h.jobs.get(id, 2);
102
+ expect(next?.from).toBe(2);
103
+ expect(next?.lines).toEqual(["line 3"]);
104
+
105
+ h.release({ ok: true, data: { name: "c", path: "/proj/.agents/skills/c" } });
106
+ await h.jobs.settled(id);
107
+ const done = h.jobs.get(id, 3);
108
+ expect(done?.job.status).toBe("succeeded");
109
+ expect(done?.job.refreshed).toBe(true);
110
+ expect(done?.job.installedPath).toBe("/proj/.agents/skills/c");
111
+ expect(done?.job.finishedAt).not.toBeNull();
112
+ expect(done?.lines.at(-1)).toBe("✓ Installed c.");
113
+ expect(h.refreshCalls).toEqual(["p1"]);
114
+ });
115
+
116
+ test("gagal -> status failed + kode error, tanpa refresh", async () => {
117
+ const h = harness();
118
+ const started = h.jobs.start(input);
119
+ if (!started.ok) throw new Error("start gagal");
120
+ h.release({ ok: false, error: "SKILL_INSTALL_FAILED" });
121
+ await h.jobs.settled(started.data.id);
122
+ const log = h.jobs.get(started.data.id);
123
+ expect(log?.job.status).toBe("failed");
124
+ expect(log?.job.error).toBe("SKILL_INSTALL_FAILED");
125
+ expect(log?.lines.at(-1)).toBe("✗ Installation failed.");
126
+ expect(h.refreshCalls).toHaveLength(0);
127
+ });
128
+
129
+ test("refresh dilewati (chat aktif) -> refreshed false + pesan", async () => {
130
+ const h = harness(false);
131
+ const started = h.jobs.start(input);
132
+ if (!started.ok) throw new Error("start gagal");
133
+ h.release({ ok: true, data: { name: "c", path: null } });
134
+ await h.jobs.settled(started.data.id);
135
+ const log = h.jobs.get(started.data.id);
136
+ expect(log?.job.refreshed).toBe(false);
137
+ expect(log?.lines.at(-1)).toContain("reloads skills automatically when it finishes");
138
+ });
139
+
140
+ test("markRefreshed: job sukses tertunda di Project itu jadi aktif + dicatat", async () => {
141
+ const h = harness(false);
142
+ const started = h.jobs.start(input);
143
+ if (!started.ok) throw new Error("start gagal");
144
+ h.release({ ok: true, data: { name: "c", path: null } });
145
+ await h.jobs.settled(started.data.id);
146
+
147
+ h.jobs.markRefreshed("p-lain");
148
+ expect(h.jobs.get(started.data.id)?.job.refreshed).toBe(false);
149
+
150
+ h.jobs.markRefreshed("p1");
151
+ const log = h.jobs.get(started.data.id);
152
+ expect(log?.job.refreshed).toBe(true);
153
+ expect(log?.lines.at(-1)).toBe("✓ Chat finished — c is now active.");
154
+ // Idempoten: tidak menambah baris lagi.
155
+ h.jobs.markRefreshed("p1");
156
+ expect(h.jobs.get(started.data.id)?.job.lineCount).toBe(log?.job.lineCount);
157
+ });
158
+
159
+ test("validasi & satu job berjalan per Project", async () => {
160
+ const h = harness();
161
+ expect(h.jobs.start({ ...input, skillId: "--global" })).toEqual({
162
+ ok: false,
163
+ error: "INVALID_SKILL_ID",
164
+ });
165
+ const first = h.jobs.start(input);
166
+ expect(first.ok).toBe(true);
167
+ expect(h.jobs.start({ ...input, skillId: "d" })).toEqual({
168
+ ok: false,
169
+ error: "SKILL_INSTALL_IN_PROGRESS",
170
+ });
171
+ // Project lain tetap boleh.
172
+ expect(h.jobs.start({ ...input, projectId: "p2" }).ok).toBe(true);
173
+ expect(
174
+ h.jobs
175
+ .list()
176
+ .map((j) => j.projectId)
177
+ .sort(),
178
+ ).toEqual(["p1", "p2"]);
179
+ });
180
+
181
+ test("log dibatasi MAX_LOG_LINES; from lama -> truncated", () => {
182
+ const h = harness();
183
+ const started = h.jobs.start(input);
184
+ if (!started.ok) throw new Error("start gagal");
185
+ for (let i = 0; i < MAX_LOG_LINES + 10; i++) h.emit(`l${i}`);
186
+ const log = h.jobs.get(started.data.id, 0);
187
+ expect(log?.truncated).toBe(true);
188
+ expect(log?.from).toBe(10);
189
+ expect(log?.lines).toHaveLength(MAX_LOG_LINES);
190
+ expect(log?.lines[0]).toBe("l10");
191
+ expect(log?.job.lineCount).toBe(MAX_LOG_LINES + 10);
192
+ });
193
+
194
+ test("job selesai dibuang setelah TTL; id tak dikenal -> null", async () => {
195
+ const h = harness();
196
+ const started = h.jobs.start(input);
197
+ if (!started.ok) throw new Error("start gagal");
198
+ h.release({ ok: true, data: { name: "c", path: null } });
199
+ await h.jobs.settled(started.data.id);
200
+ expect(h.jobs.list()).toHaveLength(1);
201
+ h.setTime(1000 + FINISHED_TTL_MS + 1);
202
+ expect(h.jobs.list()).toHaveLength(0);
203
+ expect(h.jobs.get(started.data.id)).toBeNull();
204
+ expect(h.jobs.get("nope")).toBeNull();
205
+ });
206
+
207
+ test("cancel: job berjalan -> cancelled, tanpa refresh; kedua kali -> false", async () => {
208
+ const h = harness();
209
+ const started = h.jobs.start(input);
210
+ if (!started.ok) throw new Error("start gagal");
211
+ const id = started.data.id;
212
+ expect(h.jobs.cancel(id)).toBe(true);
213
+ expect(h.jobs.cancel(id)).toBe(false);
214
+ await h.jobs.settled(id);
215
+ const log = h.jobs.get(id);
216
+ expect(log?.job.status).toBe("cancelled");
217
+ expect(log?.job.error).toBe("SKILL_INSTALL_CANCELLED");
218
+ expect(log?.lines).toContain("Cancelling…");
219
+ expect(log?.lines.at(-1)).toBe("✗ Installation cancelled.");
220
+ expect(h.refreshCalls).toHaveLength(0);
221
+ // Project bebas lagi setelah dibatalkan.
222
+ expect(h.jobs.start(input).ok).toBe(true);
223
+ expect(h.jobs.cancel("nope")).toBe(false);
224
+ });
225
+
226
+ test("cancelAll: membatalkan semua job berjalan dan menunggu selesai", async () => {
227
+ const h = harness();
228
+ const a = h.jobs.start(input);
229
+ const b = h.jobs.start({ ...input, projectId: "p2" });
230
+ if (!a.ok || !b.ok) throw new Error("start gagal");
231
+ await h.jobs.cancelAll();
232
+ expect(h.jobs.get(a.data.id)?.job.status).toBe("cancelled");
233
+ expect(h.jobs.get(b.data.id)?.job.status).toBe("cancelled");
234
+ });
235
+
236
+ test("lastOutputAt diperbarui tiap baris", () => {
237
+ const h = harness();
238
+ const started = h.jobs.start(input);
239
+ if (!started.ok) throw new Error("start gagal");
240
+ expect(started.data.lastOutputAt).toBe(1000);
241
+ h.setTime(5000);
242
+ h.emit("x");
243
+ expect(h.jobs.get(started.data.id)?.job.lastOutputAt).toBe(5000);
244
+ });
245
+ });
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Unit test Skills_Registry (skills.sh + CLI `skills`) — fetch & spawn di-mock,
3
+ * tanpa jaringan maupun proses nyata.
4
+ */
5
+ import { describe, expect, test } from "bun:test";
6
+ import {
7
+ buildInstallArgs,
8
+ cleanOutputLine,
9
+ createSkillsRegistry,
10
+ defaultSpawn,
11
+ installEnv,
12
+ isValidSkillId,
13
+ isValidSource,
14
+ normalizeQuery,
15
+ parseAuditResponse,
16
+ parseInstallOutput,
17
+ parseSearchResponse,
18
+ SKILLS_CLI_VERSION,
19
+ type SpawnFn,
20
+ } from "../skills-registry";
21
+
22
+ function jsonResponse(body: unknown, status = 200): Response {
23
+ return new Response(JSON.stringify(body), {
24
+ status,
25
+ headers: { "content-type": "application/json" },
26
+ });
27
+ }
28
+
29
+ /** Fetch mock yang mencatat URL dan membalas lewat `handler`. */
30
+ function mockFetch(handler: (url: string) => Response) {
31
+ const urls: string[] = [];
32
+ const fn = (async (input: RequestInfo | URL) => {
33
+ const url = String(input);
34
+ urls.push(url);
35
+ return handler(url);
36
+ }) as typeof fetch;
37
+ return { fn, urls };
38
+ }
39
+
40
+ describe("validasi", () => {
41
+ test("source GitHub owner/repo & domain well-known diterima", () => {
42
+ expect(isValidSource("vercel-labs/agent-skills")).toBe(true);
43
+ expect(isValidSource("anthropics/skills")).toBe(true);
44
+ expect(isValidSource("mintlify.com")).toBe(true);
45
+ });
46
+
47
+ test("source berbahaya / tak valid ditolak", () => {
48
+ for (const bad of [
49
+ "",
50
+ "owner",
51
+ "owner/repo/extra",
52
+ "../etc/passwd",
53
+ "owner/..",
54
+ "-rf/repo",
55
+ "owner/repo;rm -rf /",
56
+ "owner/repo --global",
57
+ "https://github.com/a/b",
58
+ "owner/re po",
59
+ ]) {
60
+ expect(isValidSource(bad)).toBe(false);
61
+ }
62
+ });
63
+
64
+ test("skillId aman diterima; opsi CLI / traversal ditolak", () => {
65
+ expect(isValidSkillId("find-skills")).toBe(true);
66
+ expect(isValidSkillId("vercel_react.v2")).toBe(true);
67
+ for (const bad of ["", "-g", "--global", ".hidden", "a/b", "a..b", "a b", "*"]) {
68
+ expect(isValidSkillId(bad)).toBe(false);
69
+ }
70
+ });
71
+
72
+ test("normalizeQuery: trim, rapatkan spasi, batas 2-100 karakter", () => {
73
+ expect(normalizeQuery(" react native ")).toBe("react native");
74
+ expect(normalizeQuery("a")).toBeNull();
75
+ expect(normalizeQuery(" ")).toBeNull();
76
+ expect(normalizeQuery("x".repeat(101))).toBeNull();
77
+ });
78
+ });
79
+
80
+ describe("parsing", () => {
81
+ test("parseSearchResponse: map, buang tak valid & duplikat, urut installs", () => {
82
+ const out = parseSearchResponse({
83
+ skills: [
84
+ { id: "a/b/low", source: "a/b", skillId: "low", name: "Low", installs: 5 },
85
+ { id: "a/b/high", source: "a/b", skillId: "high", name: "High", installs: 900 },
86
+ { id: "a/b/high", source: "a/b", skillId: "high", name: "High", installs: 900 },
87
+ { source: "bad source", skillId: "x", installs: 1 },
88
+ { source: "a/b", skillId: "--global", installs: 1 },
89
+ { source: "a/b", skillId: "noname", installs: -3 },
90
+ "junk",
91
+ ],
92
+ });
93
+ expect(out.map((s) => s.skillId)).toEqual(["high", "low", "noname"]);
94
+ expect(out[0]).toEqual({
95
+ id: "a/b/high",
96
+ name: "High",
97
+ source: "a/b",
98
+ skillId: "high",
99
+ installs: 900,
100
+ url: "https://skills.sh/a/b/high",
101
+ });
102
+ expect(out[2]?.name).toBe("noname");
103
+ expect(out[2]?.installs).toBe(0);
104
+ expect(parseSearchResponse(null)).toEqual([]);
105
+ expect(parseSearchResponse({ skills: "nope" })).toEqual([]);
106
+ });
107
+
108
+ test("parseAuditResponse: overall = status terburuk; status asing -> warn", () => {
109
+ const res = parseAuditResponse({
110
+ audits: [
111
+ { provider: "Socket", status: "pass", summary: "No alerts" },
112
+ { provider: "Snyk", status: "warn", summary: "Med", riskLevel: "MEDIUM" },
113
+ { provider: "Odd", status: "weird" },
114
+ { status: "fail" },
115
+ ],
116
+ });
117
+ expect(res.overall).toBe("warn");
118
+ expect(res.audits?.map((a) => [a.provider, a.status])).toEqual([
119
+ ["Socket", "pass"],
120
+ ["Snyk", "warn"],
121
+ ["Odd", "warn"],
122
+ ]);
123
+ expect(
124
+ parseAuditResponse({
125
+ audits: [
126
+ { provider: "X", status: "fail" },
127
+ { provider: "Y", status: "pass" },
128
+ ],
129
+ }).overall,
130
+ ).toBe("fail");
131
+ expect(parseAuditResponse({ audits: [] })).toEqual({ audits: null, overall: null });
132
+ });
133
+
134
+ test("buildInstallArgs: array argumen dengan versi CLI terpin", () => {
135
+ expect(buildInstallArgs("a/b", "c")).toEqual([
136
+ "bunx",
137
+ `skills@${SKILLS_CLI_VERSION}`,
138
+ "add",
139
+ "a/b",
140
+ "--skill",
141
+ "c",
142
+ "--agent",
143
+ "opencode",
144
+ "-y",
145
+ "--json",
146
+ ]);
147
+ });
148
+
149
+ test("buildInstallArgs: beberapa agent setelah satu --agent", () => {
150
+ const args = buildInstallArgs("a/b", "c", ["opencode", "claude-code"]);
151
+ const i = args.indexOf("--agent");
152
+ expect(args.slice(i, i + 4)).toEqual(["--agent", "opencode", "claude-code", "-y"]);
153
+ });
154
+
155
+ test("parseInstallOutput: JSON setelah teks lain; status harus installed/updated", () => {
156
+ const ok = parseInstallOutput(
157
+ 'log line\n[{"name":"c","status":"installed","path":"/p/.agents/skills/c"}]',
158
+ "c",
159
+ );
160
+ expect(ok).toEqual({ ok: true, data: { name: "c", path: "/p/.agents/skills/c" } });
161
+ expect(parseInstallOutput("no json", "c").ok).toBe(false);
162
+ expect(parseInstallOutput("[not json", "c").ok).toBe(false);
163
+ expect(parseInstallOutput('[{"name":"c","status":"failed"}]', "c").ok).toBe(false);
164
+ expect(parseInstallOutput("[]", "c").ok).toBe(false);
165
+ });
166
+ });
167
+
168
+ describe("cleanOutputLine", () => {
169
+ test("buang ANSI, OSC, dan karakter kontrol; trim kanan", () => {
170
+ expect(cleanOutputLine("\u001b[1;32mok\u001b[0m ")).toBe("ok");
171
+ expect(cleanOutputLine("\u001b]8;;https://x\u0007link\u001b]8;;\u0007")).toBe("link");
172
+ expect(cleanOutputLine("a\u0008b\tc")).toBe("ab\tc");
173
+ expect(cleanOutputLine("\u001b[?25l◇ Done")).toBe("◇ Done");
174
+ });
175
+ });
176
+
177
+ describe("createSkillsRegistry", () => {
178
+ test("search memanggil /api/search, hasil di-cache 60 detik", async () => {
179
+ let t = 0;
180
+ const f = mockFetch(() =>
181
+ jsonResponse({ skills: [{ source: "a/b", skillId: "c", name: "c", installs: 1 }] }),
182
+ );
183
+ const reg = createSkillsRegistry({ fetch: f.fn, now: () => t });
184
+
185
+ const r1 = await reg.search(" React ");
186
+ expect(r1.ok && r1.data[0]?.id).toBe("a/b/c");
187
+ expect(f.urls[0]).toBe("https://skills.sh/api/search?q=React&limit=50");
188
+
189
+ await reg.search("react");
190
+ expect(f.urls).toHaveLength(1);
191
+
192
+ t = 61_000;
193
+ await reg.search("react");
194
+ expect(f.urls).toHaveLength(2);
195
+ });
196
+
197
+ test("search: query pendek ditolak tanpa fetch; 429 & error dipetakan", async () => {
198
+ const f = mockFetch(() => jsonResponse({ error: "x" }, 429));
199
+ const reg = createSkillsRegistry({ fetch: f.fn });
200
+ expect(await reg.search("a")).toEqual({ ok: false, error: "INVALID_SKILL_QUERY" });
201
+ expect(f.urls).toHaveLength(0);
202
+ expect(await reg.search("react")).toEqual({ ok: false, error: "SKILLS_RATE_LIMITED" });
203
+
204
+ const broken = createSkillsRegistry({
205
+ fetch: (async () => {
206
+ throw new Error("offline");
207
+ }) as unknown as typeof fetch,
208
+ });
209
+ const res = await broken.search("react");
210
+ expect(!res.ok && res.error.startsWith("SKILLS_SEARCH_FAILED")).toBe(true);
211
+ });
212
+
213
+ test("audit: URL v1, 404 -> tanpa audit, id tak valid ditolak", async () => {
214
+ const f = mockFetch((url) =>
215
+ url.endsWith("/none")
216
+ ? jsonResponse({ error: "not_found" }, 404)
217
+ : jsonResponse({ audits: [{ provider: "Socket", status: "pass", summary: "ok" }] }),
218
+ );
219
+ const reg = createSkillsRegistry({ fetch: f.fn });
220
+
221
+ const ok = await reg.audit("a/b", "c");
222
+ expect(ok.ok && ok.data.overall).toBe("pass");
223
+ expect(f.urls[0]).toBe("https://skills.sh/api/v1/skills/audit/a/b/c");
224
+
225
+ const none = await reg.audit("a/b", "none");
226
+ expect(none).toEqual({ ok: true, data: { audits: null, overall: null } });
227
+
228
+ expect(await reg.audit("../x", "c")).toEqual({ ok: false, error: "INVALID_SKILL_ID" });
229
+ expect(f.urls).toHaveLength(2);
230
+ });
231
+
232
+ test("install: spawn di direktori Project dengan argumen tervalidasi", async () => {
233
+ const calls: { args: string[]; cwd: string }[] = [];
234
+ const spawn: SpawnFn = async (args, cwd) => {
235
+ calls.push({ args, cwd });
236
+ return {
237
+ exitCode: 0,
238
+ stdout: '[{"name":"c","status":"installed","path":"/proj/.agents/skills/c"}]',
239
+ stderr: "",
240
+ };
241
+ };
242
+ const reg = createSkillsRegistry({ spawn });
243
+ const res = await reg.install("/proj", "a/b", "c");
244
+ expect(res).toEqual({ ok: true, data: { name: "c", path: "/proj/.agents/skills/c" } });
245
+ expect(calls).toEqual([{ args: buildInstallArgs("a/b", "c"), cwd: "/proj" }]);
246
+
247
+ expect(await reg.install("/proj", "a/b", "--global")).toEqual({
248
+ ok: false,
249
+ error: "INVALID_SKILL_ID",
250
+ });
251
+ expect(calls).toHaveLength(1);
252
+ });
253
+
254
+ test("install: onLine menerima perintah + output CLI yang sudah dibersihkan", async () => {
255
+ const spawn: SpawnFn = async (_args, _cwd, _t, onLine) => {
256
+ onLine?.("\u001b[32m◇ Installed\u001b[0m");
257
+ onLine?.("");
258
+ onLine?.("");
259
+ onLine?.("done");
260
+ return { exitCode: 0, stdout: '[{"name":"c","status":"installed"}]', stderr: "" };
261
+ };
262
+ const lines: string[] = [];
263
+ const reg = createSkillsRegistry({ spawn });
264
+ await reg.install("/proj", "a/b", "c", (l) => lines.push(l));
265
+ expect(lines).toEqual([
266
+ `$ ${buildInstallArgs("a/b", "c").join(" ")}`,
267
+ "◇ Installed",
268
+ "",
269
+ "done",
270
+ ]);
271
+ });
272
+
273
+ test("install: exit != 0 -> gagal; instalasi paralel di Project sama ditolak", async () => {
274
+ let release: () => void = () => {};
275
+ const gate = new Promise<void>((r) => {
276
+ release = r;
277
+ });
278
+ let exitCode = 0;
279
+ const spawn: SpawnFn = async () => {
280
+ await gate;
281
+ return { exitCode, stdout: '[{"name":"c","status":"installed"}]', stderr: "boom" };
282
+ };
283
+ const reg = createSkillsRegistry({ spawn });
284
+
285
+ const first = reg.install("/proj", "a/b", "c");
286
+ expect(await reg.install("/proj", "a/b", "d")).toEqual({
287
+ ok: false,
288
+ error: "SKILL_INSTALL_IN_PROGRESS",
289
+ });
290
+ release();
291
+ expect((await first).ok).toBe(true);
292
+
293
+ exitCode = 1;
294
+ expect(await reg.install("/proj", "a/b", "c")).toEqual({
295
+ ok: false,
296
+ error: "SKILL_INSTALL_FAILED",
297
+ });
298
+ });
299
+
300
+ test("install: signal dibatalkan -> SKILL_INSTALL_CANCELLED; exit null -> TIMEOUT", async () => {
301
+ const lines: string[] = [];
302
+ const ctrl = new AbortController();
303
+ const spawnAbort: SpawnFn = async (_a, _c, _t, _l, signal) => {
304
+ ctrl.abort();
305
+ expect(signal?.aborted).toBe(true);
306
+ return { exitCode: null, stdout: "", stderr: "" };
307
+ };
308
+ const reg = createSkillsRegistry({ spawn: spawnAbort });
309
+ expect(await reg.install("/p", "a/b", "c", (l) => lines.push(l), ctrl.signal)).toEqual({
310
+ ok: false,
311
+ error: "SKILL_INSTALL_CANCELLED",
312
+ });
313
+ expect(lines.at(-1)).toBe("Cancelled.");
314
+
315
+ const spawnTimeout: SpawnFn = async () => ({ exitCode: null, stdout: "", stderr: "" });
316
+ const reg2 = createSkillsRegistry({ spawn: spawnTimeout });
317
+ expect(await reg2.install("/p", "a/b", "c")).toEqual({
318
+ ok: false,
319
+ error: "SKILL_INSTALL_TIMEOUT",
320
+ });
321
+ });
322
+ });
323
+
324
+ describe("installEnv", () => {
325
+ test("mematikan telemetry & prompt git; env lain dipertahankan", () => {
326
+ const env = installEnv({ PATH: "/bin", HOME: "/h", DROP: undefined });
327
+ expect(env.PATH).toBe("/bin");
328
+ expect(env.HOME).toBe("/h");
329
+ expect("DROP" in env).toBe(false);
330
+ expect(env.DISABLE_TELEMETRY).toBe("1");
331
+ expect(env.GIT_TERMINAL_PROMPT).toBe("0");
332
+ expect(env.GIT_HTTP_LOW_SPEED_TIME).toBe("30");
333
+ });
334
+ });
335
+
336
+ describe("defaultSpawn (proses nyata)", () => {
337
+ // Regresi: proses cucu (git) yang memegang pipe membuat instalasi menggantung
338
+ // walau proses langsung sudah dibunuh. Kill process group harus mengakhirinya.
339
+ const hang = ["sh", "-c", "sh -c 'sleep 30' & echo started >&2; wait"];
340
+
341
+ test("pembatalan membunuh seluruh process group, termasuk cucu", async () => {
342
+ const ctrl = new AbortController();
343
+ const lines: string[] = [];
344
+ const t0 = Date.now();
345
+ setTimeout(() => ctrl.abort(), 200);
346
+ const res = await defaultSpawn(hang, "/tmp", 60_000, (l) => lines.push(l), ctrl.signal);
347
+ expect(Date.now() - t0).toBeLessThan(5_000);
348
+ expect(res.exitCode).toBeNull();
349
+ expect(lines).toContain("started");
350
+ });
351
+
352
+ test("timeout membunuh seluruh process group", async () => {
353
+ const t0 = Date.now();
354
+ const res = await defaultSpawn(hang, "/tmp", 300);
355
+ expect(Date.now() - t0).toBeLessThan(5_000);
356
+ expect(res.exitCode).toBeNull();
357
+ });
358
+
359
+ test("proses normal: exit code & stdout diteruskan", async () => {
360
+ const res = await defaultSpawn(["sh", "-c", "echo out; echo err >&2; exit 3"], "/tmp", 5_000);
361
+ expect(res.exitCode).toBe(3);
362
+ expect(res.stdout).toBe("out\n");
363
+ expect(res.stderr).toBe("err\n");
364
+ });
365
+ });