@cuongtran001/kanna 1.2.0 → 1.3.1

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 (196) hide show
  1. package/dist/client/assets/{arc-BLk1CZKB.js → arc-DXtN3EWJ.js} +1 -1
  2. package/dist/client/assets/{architectureDiagram-3BPJPVTR-B01ZQYte.js → architectureDiagram-3BPJPVTR-CIGqW1z-.js} +1 -1
  3. package/dist/client/assets/{blockDiagram-GPEHLZMM-diL6a3cq.js → blockDiagram-GPEHLZMM-BafNS_H6.js} +1 -1
  4. package/dist/client/assets/{c4Diagram-AAUBKEIU-BY1verL8.js → c4Diagram-AAUBKEIU-YdAgRSis.js} +1 -1
  5. package/dist/client/assets/channel-BEYTrUAm.js +1 -0
  6. package/dist/client/assets/{chunk-2J33WTMH-psdZuajw.js → chunk-2J33WTMH-eiP5y5G-.js} +1 -1
  7. package/dist/client/assets/{chunk-4BX2VUAB-CrwQVBc5.js → chunk-4BX2VUAB-CvLGzPMF.js} +1 -1
  8. package/dist/client/assets/{chunk-55IACEB6-9-aj4jKO.js → chunk-55IACEB6-Y-S9Fb8A.js} +1 -1
  9. package/dist/client/assets/{chunk-727SXJPM-1FNK9_nB.js → chunk-727SXJPM-6sJH1RI9.js} +1 -1
  10. package/dist/client/assets/{chunk-AQP2D5EJ-DFhvd_Ek.js → chunk-AQP2D5EJ-hV1r_IXO.js} +1 -1
  11. package/dist/client/assets/{chunk-FMBD7UC4-B7MLrDa7.js → chunk-FMBD7UC4-CC3L28S5.js} +1 -1
  12. package/dist/client/assets/{chunk-ND2GUHAM-D4Df05QE.js → chunk-ND2GUHAM-BZ3czhdH.js} +1 -1
  13. package/dist/client/assets/{chunk-QZHKN3VN-Iq17Dznm.js → chunk-QZHKN3VN-Bjyd6Yr-.js} +1 -1
  14. package/dist/client/assets/classDiagram-4FO5ZUOK-CvnF5Ud6.js +1 -0
  15. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-CvnF5Ud6.js +1 -0
  16. package/dist/client/assets/{cose-bilkent-S5V4N54A-BmaZhtXy.js → cose-bilkent-S5V4N54A-CfxdDghE.js} +1 -1
  17. package/dist/client/assets/{dagre-BM42HDAG-BCmpfnfP.js → dagre-BM42HDAG-B8CddsbB.js} +1 -1
  18. package/dist/client/assets/{diagram-2AECGRRQ-BIEcKowS.js → diagram-2AECGRRQ-F0yS0fVB.js} +1 -1
  19. package/dist/client/assets/{diagram-5GNKFQAL-CRBABBfv.js → diagram-5GNKFQAL-cW0i0qlh.js} +1 -1
  20. package/dist/client/assets/{diagram-KO2AKTUF-BRxUk16N.js → diagram-KO2AKTUF-BzAKMeJT.js} +1 -1
  21. package/dist/client/assets/{diagram-LMA3HP47-DPBs4xl1.js → diagram-LMA3HP47-qdC4XqIl.js} +1 -1
  22. package/dist/client/assets/{diagram-OG6HWLK6-BE9rgVv8.js → diagram-OG6HWLK6-CGcpi6Dh.js} +1 -1
  23. package/dist/client/assets/{erDiagram-TEJ5UH35-D3vgA1xJ.js → erDiagram-TEJ5UH35-CxNTDicM.js} +1 -1
  24. package/dist/client/assets/{flowDiagram-I6XJVG4X-Cgu-8XXC.js → flowDiagram-I6XJVG4X-qkGa5TqY.js} +1 -1
  25. package/dist/client/assets/{ganttDiagram-6RSMTGT7-CgjXVyTR.js → ganttDiagram-6RSMTGT7-D9mVPoQi.js} +1 -1
  26. package/dist/client/assets/{gitGraphDiagram-PVQCEYII-zD5D8B-i.js → gitGraphDiagram-PVQCEYII-DA0grAXe.js} +1 -1
  27. package/dist/client/assets/{index-DBTdhMbJ.js → index-BqUY1Bm4.js} +1 -1
  28. package/dist/client/assets/index-CLsOWXff.css +32 -0
  29. package/dist/client/assets/{index-zQLLeAfl.js → index-TU1k6ov6.js} +253 -243
  30. package/dist/client/assets/{infoDiagram-5YYISTIA-BaFHqT4u.js → infoDiagram-5YYISTIA-CGljtLs3.js} +1 -1
  31. package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-2yAC1br2.js → ishikawaDiagram-YF4QCWOH-CtC0xrmG.js} +1 -1
  32. package/dist/client/assets/{journeyDiagram-JHISSGLW-DacQ-ND5.js → journeyDiagram-JHISSGLW-URLnVZeN.js} +1 -1
  33. package/dist/client/assets/{kanban-definition-UN3LZRKU-Qlbwm3z4.js → kanban-definition-UN3LZRKU-C2Q6aqQh.js} +1 -1
  34. package/dist/client/assets/{linear-DeafqpgO.js → linear-ByKHtsIx.js} +1 -1
  35. package/dist/client/assets/{mermaid.core-KYiCPJDj.js → mermaid.core-Dl_f2KLc.js} +4 -4
  36. package/dist/client/assets/{mindmap-definition-RKZ34NQL-B1V-T6d3.js → mindmap-definition-RKZ34NQL-CaJ9IDLI.js} +1 -1
  37. package/dist/client/assets/{pieDiagram-4H26LBE5-DOF9OFRu.js → pieDiagram-4H26LBE5-mmYaf2Oa.js} +1 -1
  38. package/dist/client/assets/{quadrantDiagram-W4KKPZXB-lH1mB7ko.js → quadrantDiagram-W4KKPZXB-B_PO6OCC.js} +1 -1
  39. package/dist/client/assets/{requirementDiagram-4Y6WPE33-BaTsJ_sS.js → requirementDiagram-4Y6WPE33-M4huwJt1.js} +1 -1
  40. package/dist/client/assets/{sankeyDiagram-5OEKKPKP-Pfxib5iI.js → sankeyDiagram-5OEKKPKP-B0hb1sWh.js} +1 -1
  41. package/dist/client/assets/{sequenceDiagram-3UESZ5HK-BQMEKkBC.js → sequenceDiagram-3UESZ5HK-BAqSptnA.js} +1 -1
  42. package/dist/client/assets/{stateDiagram-AJRCARHV-CuldNnzp.js → stateDiagram-AJRCARHV-DTNkvPi5.js} +1 -1
  43. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-CVN3oGTF.js +1 -0
  44. package/dist/client/assets/{timeline-definition-PNZ67QCA-DP7u0YAg.js → timeline-definition-PNZ67QCA-MgAjHz8Q.js} +1 -1
  45. package/dist/client/assets/{vennDiagram-CIIHVFJN-QlrjpNeQ.js → vennDiagram-CIIHVFJN-DmKoO51W.js} +1 -1
  46. package/dist/client/assets/{wardley-L42UT6IY-BqdJ_sAM.js → wardley-L42UT6IY-CWcyHnYs.js} +1 -1
  47. package/dist/client/assets/{wardleyDiagram-YWT4CUSO-Wi3kaQ5X.js → wardleyDiagram-YWT4CUSO-BGIGmFP9.js} +1 -1
  48. package/dist/client/assets/{xychartDiagram-2RQKCTM6-D-KXMyCG.js → xychartDiagram-2RQKCTM6-D20YIvEl.js} +1 -1
  49. package/dist/client/index.html +2 -2
  50. package/package.json +1 -1
  51. package/src/server/agent-coordinator-types.ts +158 -0
  52. package/src/server/agent-coordinator.ts +919 -0
  53. package/src/server/agent-deps-builders.ts +425 -0
  54. package/src/server/agent.oauth-rotation.test.ts +101 -0
  55. package/src/server/agent.test.ts +24 -8
  56. package/src/server/agent.ts +35 -4963
  57. package/src/server/auto-continue/read-model.test.ts +2 -2
  58. package/src/server/auto-continue/read-model.ts +4 -1
  59. package/src/server/claude-autocontinue-commands.test.ts +494 -0
  60. package/src/server/claude-autocontinue-commands.ts +256 -0
  61. package/src/server/claude-cancel-handler.test.ts +472 -0
  62. package/src/server/claude-cancel-handler.ts +241 -0
  63. package/src/server/claude-chat-management.test.ts +395 -0
  64. package/src/server/claude-chat-management.ts +251 -0
  65. package/src/server/claude-harness-stream.test.ts +273 -0
  66. package/src/server/claude-harness-stream.ts +158 -0
  67. package/src/server/claude-loop-orch-commands.test.ts +579 -0
  68. package/src/server/claude-loop-orch-commands.ts +466 -0
  69. package/src/server/claude-message-normalizer.test.ts +213 -0
  70. package/src/server/claude-message-normalizer.ts +436 -0
  71. package/src/server/claude-prompt-helpers.test.ts +248 -0
  72. package/src/server/claude-prompt-helpers.ts +139 -0
  73. package/src/server/claude-sdk-queue.test.ts +169 -0
  74. package/src/server/claude-sdk-queue.ts +82 -0
  75. package/src/server/claude-send-command.test.ts +414 -0
  76. package/src/server/claude-send-command.ts +420 -0
  77. package/src/server/claude-session-config-helpers.test.ts +279 -0
  78. package/src/server/claude-session-config-helpers.ts +151 -0
  79. package/src/server/claude-session-config.test.ts +250 -0
  80. package/src/server/claude-session-config.ts +159 -0
  81. package/src/server/claude-session-error-handler.test.ts +498 -0
  82. package/src/server/claude-session-error-handler.ts +361 -0
  83. package/src/server/claude-session-lifecycle.test.ts +669 -0
  84. package/src/server/claude-session-lifecycle.ts +283 -0
  85. package/src/server/claude-session-rebuild.test.ts +224 -0
  86. package/src/server/claude-session-rebuild.ts +101 -0
  87. package/src/server/claude-session-runner.test.ts +523 -0
  88. package/src/server/claude-session-runner.ts +521 -0
  89. package/src/server/claude-session-spawner.test.ts +401 -0
  90. package/src/server/claude-session-spawner.ts +401 -0
  91. package/src/server/claude-session-start.test.ts +205 -0
  92. package/src/server/claude-session-start.ts +277 -0
  93. package/src/server/claude-session-state-queries.test.ts +344 -0
  94. package/src/server/claude-session-state-queries.ts +160 -0
  95. package/src/server/claude-session-state.ts +104 -0
  96. package/src/server/claude-slash-commands.test.ts +277 -0
  97. package/src/server/claude-slash-commands.ts +234 -0
  98. package/src/server/claude-spawn-helpers.test.ts +221 -0
  99. package/src/server/claude-spawn-helpers.ts +179 -0
  100. package/src/server/claude-steer-log.test.ts +243 -0
  101. package/src/server/claude-steer-log.ts +58 -0
  102. package/src/server/claude-subagent-tool-response.test.ts +213 -0
  103. package/src/server/claude-subagent-tool-response.ts +165 -0
  104. package/src/server/claude-subagent-wiring.test.ts +263 -0
  105. package/src/server/claude-subagent-wiring.ts +324 -0
  106. package/src/server/claude-tool-respond.test.ts +321 -0
  107. package/src/server/claude-tool-respond.ts +142 -0
  108. package/src/server/claude-turn-runner.test.ts +310 -0
  109. package/src/server/claude-turn-runner.ts +228 -0
  110. package/src/server/claude-turn-starter.test.ts +266 -0
  111. package/src/server/claude-turn-starter.ts +566 -0
  112. package/src/server/claude-usage-math.test.ts +169 -0
  113. package/src/server/claude-usage-math.ts +132 -0
  114. package/src/server/diff-store-branch-ops.adapter.test.ts +332 -0
  115. package/src/server/diff-store-branch-ops.adapter.ts +602 -0
  116. package/src/server/diff-store-commit-ops.adapter.test.ts +358 -0
  117. package/src/server/diff-store-commit-ops.adapter.ts +258 -0
  118. package/src/server/diff-store-errors.ts +85 -0
  119. package/src/server/diff-store-file-ops.adapter.ts +212 -0
  120. package/src/server/diff-store-git-branch.adapter.ts +686 -0
  121. package/src/server/diff-store-io.adapter.ts +27 -0
  122. package/src/server/diff-store-parse.test.ts +212 -0
  123. package/src/server/diff-store-parse.ts +122 -0
  124. package/src/server/diff-store-state.ts +82 -0
  125. package/src/server/diff-store.ts +76 -1854
  126. package/src/server/event-store-apply.test.ts +280 -0
  127. package/src/server/event-store-apply.ts +132 -0
  128. package/src/server/event-store-chat-lifecycle.test.ts +408 -0
  129. package/src/server/event-store-chat-lifecycle.ts +441 -0
  130. package/src/server/event-store-entity-write.ts +356 -0
  131. package/src/server/event-store-helpers.test.ts +230 -0
  132. package/src/server/event-store-helpers.ts +235 -0
  133. package/src/server/event-store-init.ts +276 -0
  134. package/src/server/event-store-messages.adapter.test.ts +365 -0
  135. package/src/server/event-store-messages.adapter.ts +213 -0
  136. package/src/server/event-store-orch-subscription.ts +124 -0
  137. package/src/server/event-store-orch.test.ts +462 -0
  138. package/src/server/event-store-orch.ts +312 -0
  139. package/src/server/event-store-peripheral-events.adapter.test.ts +254 -0
  140. package/src/server/event-store-peripheral-events.adapter.ts +105 -0
  141. package/src/server/event-store-snapshot.test.ts +597 -0
  142. package/src/server/event-store-snapshot.ts +658 -0
  143. package/src/server/event-store-subagent.test.ts +408 -0
  144. package/src/server/event-store-subagent.ts +198 -0
  145. package/src/server/event-store-tool-requests.test.ts +210 -0
  146. package/src/server/event-store-tool-requests.ts +131 -0
  147. package/src/server/event-store-transcript-write.adapter.ts +285 -0
  148. package/src/server/event-store-write-ops.test.ts +554 -0
  149. package/src/server/event-store-write-ops.ts +529 -0
  150. package/src/server/event-store.test.ts +35 -0
  151. package/src/server/event-store.ts +251 -2284
  152. package/src/server/events.ts +3 -0
  153. package/src/server/harness-types.ts +15 -1
  154. package/src/server/oauth-errors.ts +19 -0
  155. package/src/server/read-models.ts +26 -1
  156. package/src/server/subagent-orchestrator.ts +3 -0
  157. package/src/server/ws-router-agent-ctrl.test.ts +187 -0
  158. package/src/server/ws-router-agent-ctrl.ts +137 -0
  159. package/src/server/ws-router-broadcast.ts +488 -0
  160. package/src/server/ws-router-chat.test.ts +461 -0
  161. package/src/server/ws-router-chat.ts +279 -0
  162. package/src/server/ws-router-defaults.test.ts +283 -0
  163. package/src/server/ws-router-defaults.ts +434 -0
  164. package/src/server/ws-router-diff.test.ts +167 -0
  165. package/src/server/ws-router-diff.ts +250 -0
  166. package/src/server/ws-router-envelope.ts +329 -0
  167. package/src/server/ws-router-misc.test.ts +422 -0
  168. package/src/server/ws-router-misc.ts +265 -0
  169. package/src/server/ws-router-orch.test.ts +190 -0
  170. package/src/server/ws-router-orch.ts +93 -0
  171. package/src/server/ws-router-project.test.ts +403 -0
  172. package/src/server/ws-router-project.ts +256 -0
  173. package/src/server/ws-router-push.test.ts +193 -0
  174. package/src/server/ws-router-push.ts +120 -0
  175. package/src/server/ws-router-settings.test.ts +173 -0
  176. package/src/server/ws-router-settings.ts +416 -0
  177. package/src/server/ws-router-skills.test.ts +194 -0
  178. package/src/server/ws-router-skills.ts +204 -0
  179. package/src/server/ws-router-utils.ts +224 -0
  180. package/src/server/ws-router.ts +339 -2254
  181. package/src/shared/app-settings-types.ts +365 -0
  182. package/src/shared/core-types.ts +27 -0
  183. package/src/shared/git-diff-types.ts +165 -0
  184. package/src/shared/loop-progress.test.ts +106 -0
  185. package/src/shared/loop-progress.ts +88 -0
  186. package/src/shared/mcp-types.ts +105 -0
  187. package/src/shared/provider-model-types.ts +437 -0
  188. package/src/shared/subagent-types.ts +198 -0
  189. package/src/shared/tool-call-types.ts +314 -0
  190. package/src/shared/transcript-types.ts +270 -0
  191. package/src/shared/types.ts +27 -1668
  192. package/dist/client/assets/channel-BMkhjiLI.js +0 -1
  193. package/dist/client/assets/classDiagram-4FO5ZUOK-C5Kg1XOD.js +0 -1
  194. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-C5Kg1XOD.js +0 -1
  195. package/dist/client/assets/index-H6C-f2Vp.css +0 -32
  196. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-Ca2U11af.js +0 -1
@@ -0,0 +1,194 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises"
3
+ import { tmpdir } from "node:os"
4
+ import path from "node:path"
5
+ import {
6
+ assertSafeSkillId,
7
+ assertSafeSkillSource,
8
+ buildInstallSkillCommand,
9
+ buildUninstallSkillCommand,
10
+ getGlobalSkillLockPath,
11
+ listInstalledSkills,
12
+ parseInstalledSkillsLock,
13
+ } from "./ws-router-skills"
14
+
15
+ describe("ws-router-skills", () => {
16
+ describe("assertSafeSkillSource", () => {
17
+ test("trims and accepts valid owner/repo pairs", () => {
18
+ expect(assertSafeSkillSource("owner/repo")).toBe("owner/repo")
19
+ expect(assertSafeSkillSource(" owner/repo ")).toBe("owner/repo")
20
+ expect(assertSafeSkillSource("Org-Name/my.skill_1")).toBe("Org-Name/my.skill_1")
21
+ })
22
+
23
+ test("rejects invalid sources", () => {
24
+ expect(() => assertSafeSkillSource("https://github.com/owner/repo")).toThrow("owner/repo")
25
+ expect(() => assertSafeSkillSource("just-a-name")).toThrow("owner/repo")
26
+ expect(() => assertSafeSkillSource("")).toThrow()
27
+ expect(() => assertSafeSkillSource("owner/repo/extra")).toThrow()
28
+ })
29
+ })
30
+
31
+ describe("assertSafeSkillId", () => {
32
+ test("trims and accepts valid skill ids", () => {
33
+ expect(assertSafeSkillId("my-skill")).toBe("my-skill")
34
+ expect(assertSafeSkillId(" my-skill_1 ")).toBe("my-skill_1")
35
+ expect(assertSafeSkillId("A")).toBe("A")
36
+ })
37
+
38
+ test("rejects invalid skill ids", () => {
39
+ expect(() => assertSafeSkillId("../nope")).toThrow("Skill id is invalid.")
40
+ expect(() => assertSafeSkillId("")).toThrow()
41
+ expect(() => assertSafeSkillId("-bad-start")).toThrow()
42
+ })
43
+ })
44
+
45
+ describe("getGlobalSkillLockPath", () => {
46
+ test("returns XDG_STATE_HOME path when env var is set", () => {
47
+ const orig = process.env.XDG_STATE_HOME
48
+ try {
49
+ process.env.XDG_STATE_HOME = "/custom/state"
50
+ const lockPath = getGlobalSkillLockPath()
51
+ expect(lockPath).toContain("/custom/state")
52
+ expect(lockPath).toContain(".skill-lock.json")
53
+ } finally {
54
+ if (orig === undefined) {
55
+ delete process.env.XDG_STATE_HOME
56
+ } else {
57
+ process.env.XDG_STATE_HOME = orig
58
+ }
59
+ }
60
+ })
61
+
62
+ test("falls back to ~/.agents/.skill-lock.json", () => {
63
+ const orig = process.env.XDG_STATE_HOME
64
+ try {
65
+ delete process.env.XDG_STATE_HOME
66
+ const lockPath = getGlobalSkillLockPath()
67
+ expect(lockPath).toContain(".agents")
68
+ expect(lockPath).toContain(".skill-lock.json")
69
+ } finally {
70
+ if (orig !== undefined) {
71
+ process.env.XDG_STATE_HOME = orig
72
+ }
73
+ }
74
+ })
75
+ })
76
+
77
+ describe("parseInstalledSkillsLock", () => {
78
+ test("parses installed global skills from a lock payload", () => {
79
+ const snapshot = parseInstalledSkillsLock({
80
+ version: 1,
81
+ skills: {
82
+ zeta: {
83
+ source: "owner/zeta",
84
+ sourceType: "github",
85
+ sourceUrl: "https://github.com/owner/zeta",
86
+ skillPath: "skills/zeta/SKILL.md",
87
+ installedAt: "2026-05-01T01:00:00.000Z",
88
+ updatedAt: "2026-05-01T02:00:00.000Z",
89
+ pluginName: "zeta-plugin",
90
+ },
91
+ alpha: {
92
+ source: "owner/alpha",
93
+ sourceType: "github",
94
+ },
95
+ ignored: "not an object",
96
+ },
97
+ }, "/tmp/.skill-lock.json")
98
+
99
+ expect(snapshot.lockFilePath).toBe("/tmp/.skill-lock.json")
100
+ // Skills are sorted alphabetically
101
+ expect(snapshot.skills.map((s) => s.name)).toEqual(["alpha", "zeta"])
102
+ expect(snapshot.skills[0]).toMatchObject({
103
+ name: "alpha",
104
+ source: "owner/alpha",
105
+ sourceType: "github",
106
+ sourceUrl: "",
107
+ installedAt: "",
108
+ updatedAt: "",
109
+ })
110
+ expect(snapshot.skills[1]).toMatchObject({
111
+ name: "zeta",
112
+ source: "owner/zeta",
113
+ skillPath: "skills/zeta/SKILL.md",
114
+ pluginName: "zeta-plugin",
115
+ })
116
+ })
117
+
118
+ test("returns empty skills list for invalid/null input", () => {
119
+ expect(parseInstalledSkillsLock(null, "/path").skills).toEqual([])
120
+ expect(parseInstalledSkillsLock("string", "/path").skills).toEqual([])
121
+ expect(parseInstalledSkillsLock({}, "/path").skills).toEqual([])
122
+ expect(parseInstalledSkillsLock({ skills: [] }, "/path").skills).toEqual([])
123
+ })
124
+ })
125
+
126
+ describe("listInstalledSkills", () => {
127
+ test("returns empty snapshot when lock file is missing", async () => {
128
+ const dir = await mkdtemp(path.join(tmpdir(), "kanna-skills-"))
129
+ try {
130
+ const missingPath = path.join(dir, "missing.json")
131
+ const result = await listInstalledSkills(missingPath)
132
+ expect(result).toEqual({ lockFilePath: missingPath, skills: [] })
133
+ } finally {
134
+ await rm(dir, { recursive: true, force: true })
135
+ }
136
+ })
137
+
138
+ test("returns empty snapshot when lock file is invalid JSON", async () => {
139
+ const dir = await mkdtemp(path.join(tmpdir(), "kanna-skills-"))
140
+ try {
141
+ const invalidPath = path.join(dir, ".skill-lock.json")
142
+ await writeFile(invalidPath, "{", "utf8")
143
+ const result = await listInstalledSkills(invalidPath)
144
+ expect(result).toEqual({ lockFilePath: invalidPath, skills: [] })
145
+ } finally {
146
+ await rm(dir, { recursive: true, force: true })
147
+ }
148
+ })
149
+ })
150
+
151
+ describe("buildInstallSkillCommand", () => {
152
+ test("builds global install command for universal and claude-code aliases", () => {
153
+ const cmd = buildInstallSkillCommand("owner/repo", "my-skill")
154
+ // First element is the binary (platform-dependent), skip it
155
+ expect(cmd.slice(1)).toEqual([
156
+ "skills",
157
+ "add",
158
+ "owner/repo",
159
+ "--skill",
160
+ "my-skill",
161
+ "--global",
162
+ "--agent",
163
+ "universal",
164
+ "claude-code",
165
+ "--yes",
166
+ ])
167
+ })
168
+
169
+ test("throws on invalid source or skillId", () => {
170
+ expect(() => buildInstallSkillCommand("https://github.com/o/r", "skill")).toThrow()
171
+ expect(() => buildInstallSkillCommand("owner/repo", "../bad")).toThrow()
172
+ })
173
+ })
174
+
175
+ describe("buildUninstallSkillCommand", () => {
176
+ test("builds global uninstall command for universal and claude-code aliases", () => {
177
+ const cmd = buildUninstallSkillCommand("my-skill")
178
+ expect(cmd.slice(1)).toEqual([
179
+ "skills",
180
+ "remove",
181
+ "my-skill",
182
+ "--global",
183
+ "--agent",
184
+ "universal",
185
+ "claude-code",
186
+ "--yes",
187
+ ])
188
+ })
189
+
190
+ test("throws on invalid skillId", () => {
191
+ expect(() => buildUninstallSkillCommand("../bad")).toThrow()
192
+ })
193
+ })
194
+ })
@@ -0,0 +1,204 @@
1
+ /**
2
+ * ws-router-skills.ts
3
+ *
4
+ * Pure skill-management utilities extracted from ws-router.ts.
5
+ * No closure dependencies from createWsRouter — safe to test in isolation.
6
+ *
7
+ * Covers: assertSafeSkill*, getGlobalSkillLockPath, parseInstalledSkillsLock,
8
+ * listInstalledSkills, searchSkills, buildInstall/UninstallSkillCommand,
9
+ * installSkill, uninstallSkill.
10
+ */
11
+ import os from "node:os"
12
+ import path from "node:path"
13
+ import type { AnyValue } from "../shared/errors"
14
+ import { isRecord } from "../shared/errors"
15
+ import { readTextFileOrThrow, spawnCommandCapture } from "./ws-router-io.adapter"
16
+ import type { InstalledSkillsSnapshot, SkillInstallResult, SkillSearchSnapshot, SkillUninstallResult } from "../shared/types"
17
+
18
+ const SKILL_AGENT_ALIASES = ["universal", "claude-code"] as const
19
+
20
+ export function assertSafeSkillSource(source: string) {
21
+ const normalized = source.trim()
22
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(normalized)) {
23
+ throw new Error("Skill source must be an owner/repo pair.")
24
+ }
25
+ return normalized
26
+ }
27
+
28
+ export function assertSafeSkillId(skillId: string) {
29
+ const normalized = skillId.trim()
30
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(normalized)) {
31
+ throw new Error("Skill id is invalid.")
32
+ }
33
+ return normalized
34
+ }
35
+
36
+ export function getGlobalSkillLockPath() {
37
+ const xdgStateHome = process.env.XDG_STATE_HOME?.trim()
38
+ if (xdgStateHome) {
39
+ return path.join(xdgStateHome, "skills", ".skill-lock.json")
40
+ }
41
+ return path.join(os.homedir(), ".agents", ".skill-lock.json")
42
+ }
43
+
44
+ function asString(value: AnyValue) {
45
+ return typeof value === "string" ? value : ""
46
+ }
47
+
48
+ export function parseInstalledSkillsLock(parsed: AnyValue, lockFilePath: string): InstalledSkillsSnapshot {
49
+ const skillsRaw = isRecord(parsed) && isRecord(parsed.skills) && !Array.isArray(parsed.skills)
50
+ ? parsed.skills
51
+ : null
52
+ const skillsRecord: Record<string, AnyValue> = skillsRaw ?? {}
53
+
54
+ const skills = Object.entries(skillsRecord)
55
+ .filter(([, entry]) => entry && typeof entry === "object" && !Array.isArray(entry))
56
+ .map(([name, entry]) => {
57
+ const record: Record<string, AnyValue> = isRecord(entry) ? entry : {}
58
+ return {
59
+ name,
60
+ source: asString(record.source),
61
+ sourceType: asString(record.sourceType),
62
+ sourceUrl: asString(record.sourceUrl),
63
+ skillPath: asString(record.skillPath) || undefined,
64
+ installedAt: asString(record.installedAt),
65
+ updatedAt: asString(record.updatedAt),
66
+ pluginName: asString(record.pluginName) || undefined,
67
+ }
68
+ })
69
+ .sort((a, b) => a.name.localeCompare(b.name))
70
+
71
+ return {
72
+ lockFilePath,
73
+ skills,
74
+ }
75
+ }
76
+
77
+ export async function listInstalledSkills(lockFilePath = getGlobalSkillLockPath()): Promise<InstalledSkillsSnapshot> {
78
+ try {
79
+ return parseInstalledSkillsLock(JSON.parse(await readTextFileOrThrow(lockFilePath)), lockFilePath)
80
+ } catch {
81
+ return {
82
+ lockFilePath,
83
+ skills: [],
84
+ }
85
+ }
86
+ }
87
+
88
+ export async function searchSkills(query: string, limit = 100): Promise<SkillSearchSnapshot> {
89
+ const normalizedQuery = query.trim()
90
+ if (normalizedQuery.length < 2) {
91
+ return {
92
+ query: normalizedQuery,
93
+ searchType: "fuzzy",
94
+ skills: [],
95
+ count: 0,
96
+ duration_ms: 0,
97
+ }
98
+ }
99
+
100
+ const normalizedLimit = Math.max(1, Math.min(100, Math.trunc(limit)))
101
+ const url = new URL("https://skills.sh/api/search")
102
+ url.searchParams.set("q", normalizedQuery)
103
+ url.searchParams.set("limit", String(normalizedLimit))
104
+
105
+ const response = await fetch(url, {
106
+ signal: AbortSignal.timeout(10_000),
107
+ })
108
+ if (!response.ok) {
109
+ throw new Error(`Skills search failed with status ${response.status}.`)
110
+ }
111
+
112
+ const payload: Partial<SkillSearchSnapshot> = await response.json()
113
+ return {
114
+ query: typeof payload.query === "string" ? payload.query : normalizedQuery,
115
+ searchType: typeof payload.searchType === "string" ? payload.searchType : "fuzzy",
116
+ skills: Array.isArray(payload.skills)
117
+ ? payload.skills
118
+ .filter((skill) => (
119
+ skill
120
+ && typeof skill === "object"
121
+ && typeof skill.id === "string"
122
+ && typeof skill.skillId === "string"
123
+ && typeof skill.name === "string"
124
+ && typeof skill.source === "string"
125
+ ))
126
+ .map((skill) => ({
127
+ id: skill.id,
128
+ skillId: skill.skillId,
129
+ name: skill.name,
130
+ installs: typeof skill.installs === "number" ? skill.installs : 0,
131
+ source: skill.source,
132
+ }))
133
+ : [],
134
+ count: typeof payload.count === "number" ? payload.count : 0,
135
+ duration_ms: typeof payload.duration_ms === "number" ? payload.duration_ms : 0,
136
+ }
137
+ }
138
+
139
+ export function buildInstallSkillCommand(source: string, skillId: string) {
140
+ return [
141
+ process.platform === "win32" ? "npx.cmd" : "npx",
142
+ "skills",
143
+ "add",
144
+ assertSafeSkillSource(source),
145
+ "--skill",
146
+ assertSafeSkillId(skillId),
147
+ "--global",
148
+ "--agent",
149
+ ...SKILL_AGENT_ALIASES,
150
+ "--yes",
151
+ ]
152
+ }
153
+
154
+ export function buildUninstallSkillCommand(skillId: string) {
155
+ return [
156
+ process.platform === "win32" ? "npx.cmd" : "npx",
157
+ "skills",
158
+ "remove",
159
+ assertSafeSkillId(skillId),
160
+ "--global",
161
+ "--agent",
162
+ ...SKILL_AGENT_ALIASES,
163
+ "--yes",
164
+ ]
165
+ }
166
+
167
+ async function runSkillCommand(command: string[]) {
168
+ const cwd = os.homedir()
169
+ const { stdout, stderr, exitCode } = await spawnCommandCapture(command, cwd, {
170
+ ...process.env,
171
+ DISABLE_TELEMETRY: process.env.DISABLE_TELEMETRY ?? "1",
172
+ })
173
+
174
+ if (exitCode !== 0) {
175
+ throw new Error(stderr.trim() || stdout.trim() || `skills CLI exited with code ${exitCode}.`)
176
+ }
177
+
178
+ return { cwd, stdout, stderr }
179
+ }
180
+
181
+ export async function installSkill(source: string, skillId: string): Promise<SkillInstallResult> {
182
+ const command = buildInstallSkillCommand(source, skillId)
183
+ const { cwd, stdout, stderr } = await runSkillCommand(command)
184
+ return {
185
+ source: command[3],
186
+ skillId: command[5],
187
+ command,
188
+ cwd,
189
+ stdout,
190
+ stderr,
191
+ }
192
+ }
193
+
194
+ export async function uninstallSkill(skillId: string): Promise<SkillUninstallResult> {
195
+ const command = buildUninstallSkillCommand(skillId)
196
+ const { cwd, stdout, stderr } = await runSkillCommand(command)
197
+ return {
198
+ skillId: command[3],
199
+ command,
200
+ cwd,
201
+ stdout,
202
+ stderr,
203
+ }
204
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Pure utilities for ws-router — no closure over createWsRouter state.
3
+ * Extracted from ws-router.ts to reduce its size.
4
+ */
5
+ import type { ServerWebSocket } from "bun"
6
+ import { log } from "../shared/log"
7
+ import type { SidebarData } from "../shared/types"
8
+ import { PROTOCOL_VERSION } from "../shared/types"
9
+ import type { ServerEnvelope, SubscriptionTopic } from "../shared/protocol"
10
+ import type { EventStore } from "./event-store"
11
+
12
+ // ── Shared interfaces ──────────────────────────────────────────────────────────
13
+
14
+ export interface ClientState {
15
+ subscriptions: Map<string, SubscriptionTopic>
16
+ snapshotSignatures: Map<string, string>
17
+ protectedDraftChatIds?: Set<string>
18
+ pushDeviceId?: string | null
19
+ originHost?: string
20
+ }
21
+
22
+ export interface SnapshotBroadcastFilter {
23
+ includeSidebar?: boolean
24
+ includeLocalProjects?: boolean
25
+ includeUpdate?: boolean
26
+ includeKeybindings?: boolean
27
+ includeAppSettings?: boolean
28
+ includePushConfig?: boolean
29
+ chatIds?: Set<string>
30
+ projectIds?: Set<string>
31
+ terminalIds?: Set<string>
32
+ }
33
+
34
+ export interface SnapshotComputationCache {
35
+ sidebar?: {
36
+ data: SidebarData
37
+ signature: string
38
+ }
39
+ }
40
+
41
+ // ── Profiling helpers ─────────────────────────────────────────────────────────
42
+
43
+ export function isSendToStartingProfilingEnabled(): boolean {
44
+ return process.env.KANNA_PROFILE_SEND_TO_STARTING === "1"
45
+ }
46
+
47
+ export function logSendToStartingProfile(
48
+ traceId: string | null | undefined,
49
+ startedAt: number | null | undefined,
50
+ stage: string,
51
+ details?: Record<string, unknown>
52
+ ): void {
53
+ if (!traceId || startedAt === undefined || startedAt === null || !isSendToStartingProfilingEnabled()) {
54
+ return
55
+ }
56
+
57
+ log.info("[kanna/send->starting][server]", JSON.stringify({
58
+ traceId,
59
+ stage,
60
+ elapsedMs: Number((performance.now() - startedAt).toFixed(1)),
61
+ ...details,
62
+ }))
63
+ }
64
+
65
+ // ── Subscription counting ─────────────────────────────────────────────────────
66
+
67
+ export function countSubscriptionsByTopic(ws: ServerWebSocket<ClientState>): {
68
+ total: number
69
+ sidebar: number
70
+ chat: number
71
+ projectGit: number
72
+ localProjects: number
73
+ update: number
74
+ keybindings: number
75
+ appSettings: number
76
+ terminal: number
77
+ } {
78
+ let sidebar = 0
79
+ let chat = 0
80
+ let projectGit = 0
81
+ let localProjects = 0
82
+ let update = 0
83
+ let keybindings = 0
84
+ let appSettings = 0
85
+ let terminal = 0
86
+
87
+ for (const topic of ws.data.subscriptions.values()) {
88
+ switch (topic.type) {
89
+ case "sidebar":
90
+ sidebar += 1
91
+ break
92
+ case "chat":
93
+ chat += 1
94
+ break
95
+ case "project-git":
96
+ projectGit += 1
97
+ break
98
+ case "local-projects":
99
+ localProjects += 1
100
+ break
101
+ case "update":
102
+ update += 1
103
+ break
104
+ case "keybindings":
105
+ keybindings += 1
106
+ break
107
+ case "app-settings":
108
+ appSettings += 1
109
+ break
110
+ case "terminal":
111
+ terminal += 1
112
+ break
113
+ }
114
+ }
115
+
116
+ return {
117
+ total: ws.data.subscriptions.size,
118
+ sidebar,
119
+ chat,
120
+ projectGit,
121
+ localProjects,
122
+ update,
123
+ keybindings,
124
+ appSettings,
125
+ terminal,
126
+ }
127
+ }
128
+
129
+ // ── Store helpers ─────────────────────────────────────────────────────────────
130
+
131
+ export function getSidebarProjectOrder(store: EventStore): string[] {
132
+ return typeof store.getSidebarProjectOrder === "function"
133
+ ? store.getSidebarProjectOrder()
134
+ : []
135
+ }
136
+
137
+ // ── Stale-state detection ─────────────────────────────────────────────────────
138
+
139
+ const BENIGN_STALE_STATE_MESSAGES = [
140
+ /^Chat not found$/,
141
+ /^Queued message not found$/,
142
+ /^File is no longer changed: /,
143
+ /^Project not found$/,
144
+ ] as const
145
+
146
+ export function isBenignStaleStateMessage(message: string): boolean {
147
+ return BENIGN_STALE_STATE_MESSAGES.some((pattern) => pattern.test(message))
148
+ }
149
+
150
+ // ── WebSocket send helper ─────────────────────────────────────────────────────
151
+
152
+ export function send(ws: ServerWebSocket<ClientState>, message: ServerEnvelope): number {
153
+ const payload = JSON.stringify(message)
154
+ ws.send(payload)
155
+ return payload.length
156
+ }
157
+
158
+ export function ensureSnapshotSignatures(
159
+ ws: ServerWebSocket<ClientState>
160
+ ): Map<string, string> {
161
+ if (!ws.data.snapshotSignatures) {
162
+ ws.data.snapshotSignatures = new Map()
163
+ }
164
+
165
+ return ws.data.snapshotSignatures
166
+ }
167
+
168
+ // ── Topic / envelope helpers ──────────────────────────────────────────────────
169
+
170
+ export function shouldIncludeTopic(
171
+ topic: SubscriptionTopic,
172
+ filter?: SnapshotBroadcastFilter
173
+ ): boolean {
174
+ if (!filter) {
175
+ return true
176
+ }
177
+
178
+ if (topic.type === "sidebar") {
179
+ return Boolean(filter.includeSidebar)
180
+ }
181
+ if (topic.type === "local-projects") {
182
+ return Boolean(filter.includeLocalProjects)
183
+ }
184
+ if (topic.type === "update") {
185
+ return Boolean(filter.includeUpdate)
186
+ }
187
+ if (topic.type === "keybindings") {
188
+ return Boolean(filter.includeKeybindings)
189
+ }
190
+ if (topic.type === "app-settings") {
191
+ return Boolean(filter.includeAppSettings)
192
+ }
193
+ if (topic.type === "push-config") {
194
+ return Boolean(filter.includePushConfig)
195
+ }
196
+ if (topic.type === "chat") {
197
+ return filter.chatIds?.has(topic.chatId) ?? false
198
+ }
199
+ if (topic.type === "project-git") {
200
+ return filter.projectIds?.has(topic.projectId) ?? false
201
+ }
202
+ if (topic.type === "terminal") {
203
+ return filter.terminalIds?.has(topic.terminalId) ?? false
204
+ }
205
+
206
+ return true
207
+ }
208
+
209
+ // timings.derivedAtMs = Date.now() on every call, making every snapshot unique
210
+ // and defeating signature-based dedup. Strip timings from the signature so that
211
+ // idle/finished chats are only sent once instead of on every broadcastSnapshots call.
212
+ export function getStableChatSnapshotSignature(
213
+ snapshot: Extract<ServerEnvelope, { type: "snapshot" }>["snapshot"]
214
+ ): string {
215
+ if (snapshot.type === "chat" && snapshot.data?.runtime) {
216
+ const { timings: _t, ...stableRuntime } = snapshot.data.runtime
217
+ return JSON.stringify({ type: snapshot.type, data: { ...snapshot.data, runtime: stableRuntime } })
218
+ }
219
+ return JSON.stringify(snapshot)
220
+ }
221
+
222
+ // Re-export PROTOCOL_VERSION so callers that import from utils don't need a
223
+ // second import just for the protocol version.
224
+ export { PROTOCOL_VERSION }