kanna-code 0.37.0 → 0.38.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.
@@ -1,3 +1,6 @@
1
+ import { readFile } from "node:fs/promises"
2
+ import os from "node:os"
3
+ import path from "node:path"
1
4
  import type { ServerWebSocket } from "bun"
2
5
  import { PROTOCOL_VERSION } from "../shared/types"
3
6
  import type { ClientEnvelope, ServerEnvelope, SubscriptionTopic } from "../shared/protocol"
@@ -16,9 +19,19 @@ import { writeStandaloneTranscriptExport } from "./standalone-export"
16
19
  import { TerminalManager } from "./terminal-manager"
17
20
  import type { UpdateManager } from "./update-manager"
18
21
  import { deriveChatSnapshot, deriveLocalProjectsSnapshot, deriveSidebarData } from "./read-models"
19
- import type { AppSettingsPatch, AppSettingsSnapshot, LlmProviderSnapshot, LlmProviderValidationResult } from "../shared/types"
22
+ import type {
23
+ AppSettingsPatch,
24
+ AppSettingsSnapshot,
25
+ InstalledSkillsSnapshot,
26
+ LlmProviderSnapshot,
27
+ LlmProviderValidationResult,
28
+ SkillInstallResult,
29
+ SkillSearchSnapshot,
30
+ SkillUninstallResult,
31
+ } from "../shared/types"
20
32
 
21
33
  const DEFAULT_CHAT_RECENT_LIMIT = 200
34
+ const SKILL_AGENT_ALIASES = ["universal", "claude-code"] as const
22
35
 
23
36
  function isSendToStartingProfilingEnabled() {
24
37
  return process.env.KANNA_PROFILE_SEND_TO_STARTING === "1"
@@ -149,6 +162,207 @@ function send(ws: ServerWebSocket<ClientState>, message: ServerEnvelope) {
149
162
  return payload.length
150
163
  }
151
164
 
165
+ export function assertSafeSkillSource(source: string) {
166
+ const normalized = source.trim()
167
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(normalized)) {
168
+ throw new Error("Skill source must be an owner/repo pair.")
169
+ }
170
+ return normalized
171
+ }
172
+
173
+ export function assertSafeSkillId(skillId: string) {
174
+ const normalized = skillId.trim()
175
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(normalized)) {
176
+ throw new Error("Skill id is invalid.")
177
+ }
178
+ return normalized
179
+ }
180
+
181
+ export function getGlobalSkillLockPath() {
182
+ const xdgStateHome = process.env.XDG_STATE_HOME?.trim()
183
+ if (xdgStateHome) {
184
+ return path.join(xdgStateHome, "skills", ".skill-lock.json")
185
+ }
186
+ return path.join(os.homedir(), ".agents", ".skill-lock.json")
187
+ }
188
+
189
+ function asString(value: unknown) {
190
+ return typeof value === "string" ? value : ""
191
+ }
192
+
193
+ export function parseInstalledSkillsLock(parsed: unknown, lockFilePath: string): InstalledSkillsSnapshot {
194
+ const skillsRecord = parsed
195
+ && typeof parsed === "object"
196
+ && "skills" in parsed
197
+ && parsed.skills
198
+ && typeof parsed.skills === "object"
199
+ && !Array.isArray(parsed.skills)
200
+ ? parsed.skills as Record<string, unknown>
201
+ : {}
202
+
203
+ const skills = Object.entries(skillsRecord)
204
+ .filter(([, entry]) => entry && typeof entry === "object" && !Array.isArray(entry))
205
+ .map(([name, entry]) => {
206
+ const record = entry as Record<string, unknown>
207
+ return {
208
+ name,
209
+ source: asString(record.source),
210
+ sourceType: asString(record.sourceType),
211
+ sourceUrl: asString(record.sourceUrl),
212
+ skillPath: asString(record.skillPath) || undefined,
213
+ installedAt: asString(record.installedAt),
214
+ updatedAt: asString(record.updatedAt),
215
+ pluginName: asString(record.pluginName) || undefined,
216
+ }
217
+ })
218
+ .sort((a, b) => a.name.localeCompare(b.name))
219
+
220
+ return {
221
+ lockFilePath,
222
+ skills,
223
+ }
224
+ }
225
+
226
+ export async function listInstalledSkills(lockFilePath = getGlobalSkillLockPath()): Promise<InstalledSkillsSnapshot> {
227
+ try {
228
+ return parseInstalledSkillsLock(JSON.parse(await readFile(lockFilePath, "utf8")), lockFilePath)
229
+ } catch {
230
+ return {
231
+ lockFilePath,
232
+ skills: [],
233
+ }
234
+ }
235
+ }
236
+
237
+ export async function searchSkills(query: string, limit = 100): Promise<SkillSearchSnapshot> {
238
+ const normalizedQuery = query.trim()
239
+ if (normalizedQuery.length < 2) {
240
+ return {
241
+ query: normalizedQuery,
242
+ searchType: "fuzzy",
243
+ skills: [],
244
+ count: 0,
245
+ duration_ms: 0,
246
+ }
247
+ }
248
+
249
+ const normalizedLimit = Math.max(1, Math.min(100, Math.trunc(limit)))
250
+ const url = new URL("https://skills.sh/api/search")
251
+ url.searchParams.set("q", normalizedQuery)
252
+ url.searchParams.set("limit", String(normalizedLimit))
253
+
254
+ const response = await fetch(url, {
255
+ signal: AbortSignal.timeout(10_000),
256
+ })
257
+ if (!response.ok) {
258
+ throw new Error(`Skills search failed with status ${response.status}.`)
259
+ }
260
+
261
+ const payload = await response.json() as Partial<SkillSearchSnapshot>
262
+ return {
263
+ query: typeof payload.query === "string" ? payload.query : normalizedQuery,
264
+ searchType: typeof payload.searchType === "string" ? payload.searchType : "fuzzy",
265
+ skills: Array.isArray(payload.skills)
266
+ ? payload.skills
267
+ .filter((skill) => (
268
+ skill
269
+ && typeof skill === "object"
270
+ && typeof skill.id === "string"
271
+ && typeof skill.skillId === "string"
272
+ && typeof skill.name === "string"
273
+ && typeof skill.source === "string"
274
+ ))
275
+ .map((skill) => ({
276
+ id: skill.id,
277
+ skillId: skill.skillId,
278
+ name: skill.name,
279
+ installs: typeof skill.installs === "number" ? skill.installs : 0,
280
+ source: skill.source,
281
+ }))
282
+ : [],
283
+ count: typeof payload.count === "number" ? payload.count : 0,
284
+ duration_ms: typeof payload.duration_ms === "number" ? payload.duration_ms : 0,
285
+ }
286
+ }
287
+
288
+ export function buildInstallSkillCommand(source: string, skillId: string) {
289
+ return [
290
+ process.platform === "win32" ? "npx.cmd" : "npx",
291
+ "skills",
292
+ "add",
293
+ assertSafeSkillSource(source),
294
+ "--skill",
295
+ assertSafeSkillId(skillId),
296
+ "--global",
297
+ "--agent",
298
+ ...SKILL_AGENT_ALIASES,
299
+ "--yes",
300
+ ]
301
+ }
302
+
303
+ export function buildUninstallSkillCommand(skillId: string) {
304
+ return [
305
+ process.platform === "win32" ? "npx.cmd" : "npx",
306
+ "skills",
307
+ "remove",
308
+ assertSafeSkillId(skillId),
309
+ "--global",
310
+ "--agent",
311
+ ...SKILL_AGENT_ALIASES,
312
+ "--yes",
313
+ ]
314
+ }
315
+
316
+ async function runSkillCommand(command: string[]) {
317
+ const cwd = os.homedir()
318
+ const subprocess = Bun.spawn(command, {
319
+ cwd,
320
+ stdout: "pipe",
321
+ stderr: "pipe",
322
+ env: {
323
+ ...process.env,
324
+ DISABLE_TELEMETRY: process.env.DISABLE_TELEMETRY ?? "1",
325
+ },
326
+ })
327
+
328
+ const [stdout, stderr, exitCode] = await Promise.all([
329
+ new Response(subprocess.stdout).text(),
330
+ new Response(subprocess.stderr).text(),
331
+ subprocess.exited,
332
+ ])
333
+
334
+ if (exitCode !== 0) {
335
+ throw new Error(stderr.trim() || stdout.trim() || `skills CLI exited with code ${exitCode}.`)
336
+ }
337
+
338
+ return { cwd, stdout, stderr }
339
+ }
340
+
341
+ export async function installSkill(source: string, skillId: string): Promise<SkillInstallResult> {
342
+ const command = buildInstallSkillCommand(source, skillId)
343
+ const { cwd, stdout, stderr } = await runSkillCommand(command)
344
+ return {
345
+ source: command[3],
346
+ skillId: command[5],
347
+ command,
348
+ cwd,
349
+ stdout,
350
+ stderr,
351
+ }
352
+ }
353
+
354
+ export async function uninstallSkill(skillId: string): Promise<SkillUninstallResult> {
355
+ const command = buildUninstallSkillCommand(skillId)
356
+ const { cwd, stdout, stderr } = await runSkillCommand(command)
357
+ return {
358
+ skillId: command[3],
359
+ command,
360
+ cwd,
361
+ stdout,
362
+ stderr,
363
+ }
364
+ }
365
+
152
366
  function ensureSnapshotSignatures(ws: ServerWebSocket<ClientState>) {
153
367
  if (!ws.data.snapshotSignatures) {
154
368
  ws.data.snapshotSignatures = new Map()
@@ -913,6 +1127,26 @@ export function createWsRouter({
913
1127
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
914
1128
  return
915
1129
  }
1130
+ case "skills.search": {
1131
+ const snapshot = await searchSkills(command.query, command.limit)
1132
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: snapshot })
1133
+ return
1134
+ }
1135
+ case "skills.install": {
1136
+ const result = await installSkill(command.source, command.skillId)
1137
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
1138
+ return
1139
+ }
1140
+ case "skills.uninstall": {
1141
+ const result = await uninstallSkill(command.skillId)
1142
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
1143
+ return
1144
+ }
1145
+ case "skills.listInstalled": {
1146
+ const result = await listInstalledSkills()
1147
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
1148
+ return
1149
+ }
916
1150
  case "project.open": {
917
1151
  await ensureProjectDirectory(command.localPath)
918
1152
  const normalizedPath = resolveLocalPath(command.localPath)
@@ -68,6 +68,10 @@ export type ClientCommand =
68
68
  | { type: "settings.writeAppSettings"; analyticsEnabled: boolean }
69
69
  | { type: "settings.writeAppSettingsPatch"; patch: AppSettingsPatch }
70
70
  | { type: "settings.readLlmProvider" }
71
+ | { type: "skills.search"; query: string; limit?: number }
72
+ | { type: "skills.install"; source: string; skillId: string }
73
+ | { type: "skills.uninstall"; skillId: string }
74
+ | { type: "skills.listInstalled" }
71
75
  | {
72
76
  type: "settings.writeLlmProvider"
73
77
  provider: LlmProviderSnapshot["provider"]
@@ -15,6 +15,55 @@ export type AttachmentKind = "image" | "file"
15
15
  export type StandaloneTranscriptAttachmentMode = "metadata" | "bundle"
16
16
  export type StandaloneTranscriptTheme = "light" | "dark"
17
17
 
18
+ export interface SkillSearchResult {
19
+ id: string
20
+ skillId: string
21
+ name: string
22
+ installs: number
23
+ source: string
24
+ }
25
+
26
+ export interface SkillSearchSnapshot {
27
+ query: string
28
+ searchType: string
29
+ skills: SkillSearchResult[]
30
+ count: number
31
+ duration_ms: number
32
+ }
33
+
34
+ export interface SkillInstallResult {
35
+ source: string
36
+ skillId: string
37
+ command: string[]
38
+ cwd: string
39
+ stdout: string
40
+ stderr: string
41
+ }
42
+
43
+ export interface SkillUninstallResult {
44
+ skillId: string
45
+ command: string[]
46
+ cwd: string
47
+ stdout: string
48
+ stderr: string
49
+ }
50
+
51
+ export interface InstalledSkillSummary {
52
+ name: string
53
+ source: string
54
+ sourceType: string
55
+ sourceUrl: string
56
+ skillPath?: string
57
+ installedAt: string
58
+ updatedAt: string
59
+ pluginName?: string
60
+ }
61
+
62
+ export interface InstalledSkillsSnapshot {
63
+ lockFilePath: string
64
+ skills: InstalledSkillSummary[]
65
+ }
66
+
18
67
  export interface ChatAttachment {
19
68
  id: string
20
69
  kind: AttachmentKind