@swarmclawai/swarmclaw 0.9.3 → 0.9.4

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 (50) hide show
  1. package/README.md +12 -10
  2. package/bundled-skills/google-workspace/SKILL.md +2 -0
  3. package/package.json +1 -1
  4. package/src/app/api/chatrooms/[id]/chat/route.ts +1 -1
  5. package/src/app/api/clawhub/install/route.ts +2 -0
  6. package/src/app/api/skills/[id]/route.ts +4 -0
  7. package/src/app/api/skills/route.ts +4 -0
  8. package/src/components/agents/agent-sheet.tsx +5 -5
  9. package/src/lib/server/agents/agent-thread-session.test.ts +64 -0
  10. package/src/lib/server/agents/agent-thread-session.ts +1 -1
  11. package/src/lib/server/agents/main-agent-loop-advanced.test.ts +77 -0
  12. package/src/lib/server/agents/main-agent-loop.ts +259 -0
  13. package/src/lib/server/agents/orchestrator-lg.ts +12 -8
  14. package/src/lib/server/agents/orchestrator.ts +11 -7
  15. package/src/lib/server/chat-execution/chat-execution-advanced.test.ts +11 -10
  16. package/src/lib/server/chat-execution/chat-execution-session-sync.test.ts +116 -3
  17. package/src/lib/server/chat-execution/chat-execution.ts +74 -26
  18. package/src/lib/server/chat-execution/stream-agent-chat.test.ts +65 -30
  19. package/src/lib/server/chat-execution/stream-agent-chat.ts +69 -25
  20. package/src/lib/server/chatrooms/chatroom-helpers.test.ts +26 -0
  21. package/src/lib/server/chatrooms/chatroom-helpers.ts +11 -8
  22. package/src/lib/server/connectors/contact-boundaries.ts +101 -0
  23. package/src/lib/server/connectors/manager.test.ts +504 -73
  24. package/src/lib/server/connectors/manager.ts +40 -9
  25. package/src/lib/server/connectors/session-consolidation.ts +2 -0
  26. package/src/lib/server/connectors/session-kind.ts +7 -0
  27. package/src/lib/server/connectors/session.test.ts +104 -0
  28. package/src/lib/server/connectors/session.ts +5 -2
  29. package/src/lib/server/identity-continuity.test.ts +4 -3
  30. package/src/lib/server/identity-continuity.ts +8 -4
  31. package/src/lib/server/memory/session-archive-memory.ts +2 -1
  32. package/src/lib/server/session-reset-policy.test.ts +17 -3
  33. package/src/lib/server/session-reset-policy.ts +4 -2
  34. package/src/lib/server/session-tools/connector.ts +11 -10
  35. package/src/lib/server/session-tools/crud.ts +41 -7
  36. package/src/lib/server/session-tools/index.ts +2 -0
  37. package/src/lib/server/session-tools/manage-skills.test.ts +194 -0
  38. package/src/lib/server/session-tools/memory.ts +12 -23
  39. package/src/lib/server/session-tools/skill-runtime.test.ts +175 -0
  40. package/src/lib/server/session-tools/skill-runtime.ts +382 -0
  41. package/src/lib/server/session-tools/skills.ts +575 -0
  42. package/src/lib/server/skills/runtime-skill-resolver.test.ts +162 -0
  43. package/src/lib/server/skills/runtime-skill-resolver.ts +750 -0
  44. package/src/lib/server/skills/skill-discovery.ts +4 -0
  45. package/src/lib/server/skills/skills-normalize.test.ts +28 -0
  46. package/src/lib/server/skills/skills-normalize.ts +93 -1
  47. package/src/lib/server/storage.ts +1 -1
  48. package/src/lib/server/tasks/task-followups.test.ts +124 -0
  49. package/src/lib/server/tasks/task-followups.ts +88 -13
  50. package/src/types/index.ts +26 -2
@@ -0,0 +1,575 @@
1
+ import { genId } from '@/lib/id'
2
+ import type { ApprovalRequest, Agent, Skill } from '@/types'
3
+ import { dedup, errorMessage } from '@/lib/shared-utils'
4
+ import { requestApproval } from '@/lib/server/approvals'
5
+ import {
6
+ loadAgent,
7
+ loadApprovals,
8
+ loadSkills,
9
+ patchAgent,
10
+ saveSkills,
11
+ } from '@/lib/server/storage'
12
+ import { fetchSkillContent, searchClawHub } from '@/lib/server/skills/clawhub-client'
13
+ import { clearDiscoveredSkillsCache } from '@/lib/server/skills/skill-discovery'
14
+ import {
15
+ buildRuntimeSkillPromptBlocks,
16
+ findResolvedSkill,
17
+ recommendRuntimeSkillsForTask,
18
+ resolveRuntimeSkills,
19
+ type ResolvedRuntimeSkill,
20
+ } from '@/lib/server/skills/runtime-skill-resolver'
21
+ import { normalizeSkillPayload } from '@/lib/server/skills/skills-normalize'
22
+ import type { ToolBuildContext } from './context'
23
+
24
+ type SkillSelectorInput = {
25
+ id?: string
26
+ skillId?: string
27
+ name?: string
28
+ url?: string
29
+ content?: string
30
+ }
31
+
32
+ function normalizeKey(value: unknown): string {
33
+ return typeof value === 'string'
34
+ ? value.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')
35
+ : ''
36
+ }
37
+
38
+ function buildCrudPayload(
39
+ normalized: Record<string, unknown>,
40
+ action: string | undefined,
41
+ data: string | undefined,
42
+ ): Record<string, unknown> {
43
+ if (data) return JSON.parse(data)
44
+ if (action !== 'create' && action !== 'update') return {}
45
+ const entries = Object.entries(normalized).filter(([key]) =>
46
+ ![
47
+ 'action',
48
+ 'id',
49
+ 'skillId',
50
+ 'data',
51
+ 'query',
52
+ 'task',
53
+ 'url',
54
+ 'approvalId',
55
+ 'attach',
56
+ 'agentId',
57
+ 'targetAgentId',
58
+ 'input',
59
+ 'args',
60
+ 'arguments',
61
+ 'payload',
62
+ 'parameters',
63
+ ].includes(key),
64
+ )
65
+ return entries.length > 0 ? Object.fromEntries(entries) : {}
66
+ }
67
+
68
+ function summarizeSkill(skill: ResolvedRuntimeSkill): Record<string, unknown> {
69
+ return {
70
+ id: skill.id,
71
+ storageId: skill.storageId || null,
72
+ key: skill.key,
73
+ name: skill.name,
74
+ description: skill.description || '',
75
+ source: skill.source,
76
+ managed: skill.managed,
77
+ attached: skill.attached,
78
+ eligible: skill.eligible,
79
+ status: skill.status,
80
+ missing: skill.missing,
81
+ toolNames: skill.toolNames,
82
+ capabilities: skill.capabilities,
83
+ installOptions: skill.installOptions || [],
84
+ autoMatch: skill.autoMatch,
85
+ matchReasons: skill.matchReasons,
86
+ invocation: skill.invocation || null,
87
+ commandDispatch: skill.commandDispatch || null,
88
+ executionMode: skill.executionMode,
89
+ runnable: skill.runnable,
90
+ selected: skill.selected,
91
+ dispatchBlocker: skill.dispatchBlocker || null,
92
+ sourcePath: skill.sourcePath || null,
93
+ sourceUrl: skill.sourceUrl || null,
94
+ }
95
+ }
96
+
97
+ function resolveActiveAgent(bctx: ToolBuildContext): Agent | null {
98
+ const agentId = bctx.ctx?.agentId
99
+ if (!agentId) return null
100
+ return loadAgent(agentId) as Agent | null
101
+ }
102
+
103
+ function resolveTargetAgentId(
104
+ payload: Record<string, unknown>,
105
+ bctx: ToolBuildContext,
106
+ ): string | null {
107
+ const requested = typeof payload.agentId === 'string' && payload.agentId.trim()
108
+ ? payload.agentId.trim()
109
+ : typeof payload.targetAgentId === 'string' && payload.targetAgentId.trim()
110
+ ? payload.targetAgentId.trim()
111
+ : bctx.ctx?.agentId || null
112
+
113
+ if (!requested) return null
114
+ if (bctx.ctx?.platformAssignScope !== 'all' && requested !== bctx.ctx?.agentId) {
115
+ throw new Error(`You may only attach skills to your own agent (${bctx.ctx?.agentId || 'current agent'}) in this session.`)
116
+ }
117
+ const target = loadAgent(requested)
118
+ if (!target) throw new Error(`Agent "${requested}" not found.`)
119
+ return requested
120
+ }
121
+
122
+ function upsertStoredSkill(input: {
123
+ existingId?: string
124
+ body: Record<string, unknown>
125
+ }): Skill {
126
+ const skills = loadSkills()
127
+ const normalized = normalizeSkillPayload(input.body)
128
+ const now = Date.now()
129
+ const id = input.existingId || genId()
130
+ const previous = input.existingId ? skills[input.existingId] : null
131
+
132
+ const next: Skill = {
133
+ id,
134
+ name: normalized.name,
135
+ filename: normalized.filename || previous?.filename || `skill-${id}.md`,
136
+ content: normalized.content || '',
137
+ description: normalized.description || '',
138
+ sourceUrl: normalized.sourceUrl,
139
+ sourceFormat: normalized.sourceFormat,
140
+ author: normalized.author,
141
+ tags: normalized.tags,
142
+ version: normalized.version,
143
+ homepage: normalized.homepage,
144
+ primaryEnv: normalized.primaryEnv,
145
+ skillKey: normalized.skillKey,
146
+ toolNames: normalized.toolNames,
147
+ capabilities: normalized.capabilities,
148
+ always: normalized.always,
149
+ installOptions: normalized.installOptions,
150
+ skillRequirements: normalized.skillRequirements,
151
+ detectedEnvVars: normalized.detectedEnvVars,
152
+ security: normalized.security,
153
+ invocation: normalized.invocation,
154
+ commandDispatch: normalized.commandDispatch,
155
+ frontmatter: normalized.frontmatter,
156
+ scope: input.body.scope === 'agent' ? 'agent' : previous?.scope || 'global',
157
+ agentIds: Array.isArray(input.body.agentIds)
158
+ ? (input.body.agentIds as unknown[]).filter((value): value is string => typeof value === 'string')
159
+ : previous?.agentIds,
160
+ createdAt: previous?.createdAt || now,
161
+ updatedAt: now,
162
+ }
163
+
164
+ skills[id] = next
165
+ saveSkills(skills)
166
+ clearDiscoveredSkillsCache()
167
+ return next
168
+ }
169
+
170
+ function attachSkillToAgent(skillId: string, agentId: string): Agent {
171
+ const updated = patchAgent(agentId, (current) => {
172
+ if (!current) return current
173
+ const nextSkillIds = dedup([...(Array.isArray(current.skillIds) ? current.skillIds : []), skillId])
174
+ current.skillIds = nextSkillIds
175
+ current.updatedAt = Date.now()
176
+ return current
177
+ })
178
+ if (!updated) throw new Error(`Agent "${agentId}" not found.`)
179
+ return updated as Agent
180
+ }
181
+
182
+ function findStoredSkillBySelector(skills: Record<string, Skill>, selector: SkillSelectorInput): Skill | null {
183
+ const directId = selector.id || selector.skillId
184
+ if (directId && skills[directId]) return skills[directId]
185
+
186
+ const normalizedName = normalizeKey(selector.name)
187
+ if (!normalizedName) return null
188
+ return Object.values(skills).find((skill) =>
189
+ normalizeKey(skill.id) === normalizedName
190
+ || normalizeKey(skill.name) === normalizedName
191
+ || normalizeKey(skill.skillKey || '') === normalizedName,
192
+ ) || null
193
+ }
194
+
195
+ function buildSkillSnapshot(bctx: ToolBuildContext) {
196
+ clearDiscoveredSkillsCache()
197
+ const activeAgent = resolveActiveAgent(bctx)
198
+ const session = bctx.resolveCurrentSession?.()
199
+ return resolveRuntimeSkills({
200
+ cwd: bctx.cwd,
201
+ enabledPlugins: bctx.activePlugins,
202
+ agentSkillIds: activeAgent?.skillIds || [],
203
+ storedSkills: loadSkills(),
204
+ selectedSkillId: typeof session?.skillRuntimeState?.selectedSkillId === 'string'
205
+ ? session.skillRuntimeState.selectedSkillId
206
+ : null,
207
+ })
208
+ }
209
+
210
+ function findPendingInstallApproval(params: {
211
+ sessionId?: string | null
212
+ agentId?: string | null
213
+ question: string
214
+ prompt: string
215
+ }): ApprovalRequest | null {
216
+ return Object.values(loadApprovals()).find((approval) =>
217
+ approval.status === 'pending'
218
+ && approval.category === 'human_loop'
219
+ && (approval.sessionId || null) === (params.sessionId || null)
220
+ && (approval.agentId || null) === (params.agentId || null)
221
+ && approval.data?.question === params.question
222
+ && approval.data?.prompt === params.prompt,
223
+ ) || null
224
+ }
225
+
226
+ function ensureApprovedInstall(approvalId: string | null | undefined): ApprovalRequest {
227
+ const normalized = typeof approvalId === 'string' ? approvalId.trim() : ''
228
+ if (!normalized) {
229
+ throw new Error('This install requires approval. Call manage_skills install first to create the approval request, then retry with approvalId after approval.')
230
+ }
231
+ const approval = loadApprovals()[normalized]
232
+ if (!approval) throw new Error(`Approval "${normalized}" not found.`)
233
+ if (approval.status !== 'approved') {
234
+ throw new Error(`Approval "${normalized}" is not approved yet.`)
235
+ }
236
+ return approval
237
+ }
238
+
239
+ async function materializeResolvedSkill(skill: ResolvedRuntimeSkill): Promise<Skill> {
240
+ const skills = loadSkills()
241
+ const existing = skill.storageId ? skills[skill.storageId] : null
242
+ if (existing) return existing
243
+ const duplicate = Object.values(skills).find((entry) =>
244
+ normalizeKey(entry.skillKey || entry.name) === normalizeKey(skill.skillKey || skill.name),
245
+ )
246
+ if (duplicate) return duplicate
247
+ return upsertStoredSkill({
248
+ body: {
249
+ name: skill.name,
250
+ filename: skill.filename,
251
+ description: skill.description,
252
+ content: skill.content,
253
+ sourceUrl: skill.sourceUrl,
254
+ sourceFormat: skill.sourceFormat,
255
+ author: skill.author,
256
+ tags: skill.tags,
257
+ version: skill.version,
258
+ homepage: skill.homepage,
259
+ primaryEnv: skill.primaryEnv,
260
+ skillKey: skill.skillKey,
261
+ toolNames: skill.toolNames,
262
+ capabilities: skill.capabilities,
263
+ always: skill.always,
264
+ installOptions: skill.installOptions,
265
+ skillRequirements: skill.skillRequirements,
266
+ detectedEnvVars: skill.detectedEnvVars,
267
+ security: skill.security,
268
+ invocation: skill.invocation,
269
+ commandDispatch: skill.commandDispatch,
270
+ frontmatter: skill.frontmatter,
271
+ },
272
+ })
273
+ }
274
+
275
+ async function installRemoteSkill(params: {
276
+ name?: string
277
+ description?: string
278
+ url: string
279
+ author?: string
280
+ tags?: string[]
281
+ content?: string
282
+ }): Promise<Skill> {
283
+ const content = params.content || await fetchSkillContent(params.url)
284
+ const skills = loadSkills()
285
+ const normalizedBody = normalizeSkillPayload({
286
+ name: params.name,
287
+ description: params.description,
288
+ sourceUrl: params.url,
289
+ author: params.author,
290
+ tags: params.tags,
291
+ content,
292
+ })
293
+
294
+ const duplicate = Object.values(skills).find((skill) =>
295
+ normalizeKey(skill.skillKey || skill.name) === normalizeKey(normalizedBody.skillKey || normalizedBody.name),
296
+ )
297
+ if (duplicate) return duplicate
298
+
299
+ return upsertStoredSkill({
300
+ body: {
301
+ ...normalizedBody,
302
+ content,
303
+ },
304
+ })
305
+ }
306
+
307
+ function parseSearchLimit(raw: unknown, fallback = 8): number {
308
+ const parsed = typeof raw === 'number' ? raw : Number.parseInt(String(raw || ''), 10)
309
+ if (!Number.isFinite(parsed)) return fallback
310
+ return Math.max(1, Math.min(20, Math.trunc(parsed)))
311
+ }
312
+
313
+ export function buildManageSkillsDescription(): string {
314
+ return [
315
+ 'Manage reusable skills and runtime skill discovery.',
316
+ 'Supported actions: `list`, `get`, `create`, `update`, `delete`, `status`, `search_available`, `recommend_for_task`, `attach`, `install`.',
317
+ 'Use `status` to inspect local/runtime skills with eligibility and missing requirements.',
318
+ 'Use `recommend_for_task` when a task may benefit from a reusable workflow.',
319
+ 'Use `use_skill` for runtime selection, loading, and execution of an already-discovered skill.',
320
+ 'Use `install` only when you intentionally want to add a skill. Installation is explicit and approval-gated.',
321
+ 'Use this direct tool name exactly as shown (`manage_skills`).',
322
+ ].join('\n\n')
323
+ }
324
+
325
+ export async function executeManageSkillsAction(
326
+ rawArgs: Record<string, unknown>,
327
+ bctx: ToolBuildContext,
328
+ ): Promise<string> {
329
+ const normalized = rawArgs
330
+ const action = typeof normalized.action === 'string' ? normalized.action.trim().toLowerCase() : ''
331
+ const data = typeof normalized.data === 'string' ? normalized.data : undefined
332
+ const payload = buildCrudPayload(normalized, action, data)
333
+
334
+ try {
335
+ switch (action) {
336
+ case 'list': {
337
+ return JSON.stringify(Object.values(loadSkills()))
338
+ }
339
+ case 'get': {
340
+ const skills = loadSkills()
341
+ const selected = findStoredSkillBySelector(skills, {
342
+ id: typeof normalized.id === 'string' ? normalized.id : undefined,
343
+ skillId: typeof normalized.skillId === 'string' ? normalized.skillId : undefined,
344
+ name: typeof normalized.name === 'string' ? normalized.name : undefined,
345
+ })
346
+ if (!selected) return 'Not found.'
347
+ return JSON.stringify(selected)
348
+ }
349
+ case 'create': {
350
+ const created = upsertStoredSkill({ body: payload })
351
+ return JSON.stringify(created)
352
+ }
353
+ case 'update': {
354
+ const skillId = typeof normalized.id === 'string' ? normalized.id.trim() : ''
355
+ if (!skillId) return 'Error: "id" is required for update action.'
356
+ const existing = loadSkills()[skillId]
357
+ if (!existing) return `Not found: skills "${skillId}"`
358
+ const updated = upsertStoredSkill({
359
+ existingId: skillId,
360
+ body: { ...existing, ...payload },
361
+ })
362
+ return JSON.stringify(updated)
363
+ }
364
+ case 'delete': {
365
+ const skillId = typeof normalized.id === 'string' ? normalized.id.trim() : ''
366
+ if (!skillId) return 'Error: "id" is required for delete action.'
367
+ const skills = loadSkills()
368
+ if (!skills[skillId]) return `Not found: skills "${skillId}"`
369
+ delete skills[skillId]
370
+ saveSkills(skills)
371
+ return JSON.stringify({ deleted: skillId })
372
+ }
373
+ case 'status': {
374
+ const snapshot = buildSkillSnapshot(bctx)
375
+ const query = typeof normalized.query === 'string' ? normalized.query.trim() : ''
376
+ const ranked = query
377
+ ? recommendRuntimeSkillsForTask(snapshot.skills, query, bctx.activePlugins).map((entry) => entry.skill)
378
+ : snapshot.skills
379
+ const limit = parseSearchLimit(normalized.limit, 12)
380
+ return JSON.stringify(ranked.slice(0, limit).map(summarizeSkill))
381
+ }
382
+ case 'search_available': {
383
+ const snapshot = buildSkillSnapshot(bctx)
384
+ const query = typeof normalized.query === 'string' ? normalized.query.trim() : ''
385
+ const limit = parseSearchLimit(normalized.limit, 8)
386
+ const local = query
387
+ ? recommendRuntimeSkillsForTask(snapshot.skills, query, bctx.activePlugins).slice(0, limit)
388
+ : snapshot.skills.slice(0, limit).map((skill) => ({ skill, score: skill.score, reasons: skill.matchReasons }))
389
+ const marketplace = query ? await searchClawHub(query, 1, limit) : { skills: [], total: 0, page: 1 }
390
+ return JSON.stringify({
391
+ local: local.map((entry) => ({
392
+ ...summarizeSkill(entry.skill),
393
+ score: entry.score,
394
+ reasons: entry.reasons,
395
+ })),
396
+ marketplace: marketplace.skills,
397
+ })
398
+ }
399
+ case 'recommend_for_task': {
400
+ const task = typeof normalized.task === 'string' && normalized.task.trim()
401
+ ? normalized.task.trim()
402
+ : typeof normalized.query === 'string' ? normalized.query.trim() : ''
403
+ if (!task) return 'Error: "task" or "query" is required for recommend_for_task.'
404
+ const snapshot = buildSkillSnapshot(bctx)
405
+ const local = recommendRuntimeSkillsForTask(snapshot.skills, task, bctx.activePlugins).slice(0, 8)
406
+ const remote = local.length >= 3 ? { skills: [] } : await searchClawHub(task, 1, 5)
407
+ return JSON.stringify({
408
+ local: local.map((entry) => ({
409
+ ...summarizeSkill(entry.skill),
410
+ score: entry.score,
411
+ reasons: entry.reasons,
412
+ promptEligible: snapshot.promptSkills.some((skill) => skill.id === entry.skill.id),
413
+ })),
414
+ marketplace: remote.skills,
415
+ promptBlocks: buildRuntimeSkillPromptBlocks({
416
+ ...snapshot,
417
+ promptSkills: local.map((entry) => entry.skill).filter((skill) => skill.eligible),
418
+ availableSkills: snapshot.skills.filter((skill) => !local.some((entry) => entry.skill.id === skill.id)),
419
+ }),
420
+ })
421
+ }
422
+ case 'attach': {
423
+ const snapshot = buildSkillSnapshot(bctx)
424
+ const target = findResolvedSkill(snapshot.skills, String(normalized.id || normalized.skillId || normalized.name || ''))
425
+ if (!target) return 'Error: skill not found in local/runtime skills.'
426
+ const stored = await materializeResolvedSkill(target)
427
+ const agentId = resolveTargetAgentId(normalized, bctx)
428
+ if (!agentId) return 'Error: no target agent available for attach.'
429
+ attachSkillToAgent(stored.id, agentId)
430
+ return JSON.stringify({
431
+ ok: true,
432
+ agentId,
433
+ skillId: stored.id,
434
+ skillName: stored.name,
435
+ attached: true,
436
+ })
437
+ }
438
+ case 'install': {
439
+ const snapshot = buildSkillSnapshot(bctx)
440
+ const selector = String(normalized.id || normalized.skillId || normalized.name || '').trim()
441
+ const localTarget = selector ? findResolvedSkill(snapshot.skills, selector) : null
442
+ const attach = normalized.attach === true
443
+ const attachAgentId = attach ? resolveTargetAgentId(normalized, bctx) : null
444
+
445
+ if (localTarget) {
446
+ if (!localTarget.storageId) {
447
+ const question = `Install local skill "${localTarget.name}" into managed skills?`
448
+ const prompt = localTarget.sourcePath || localTarget.id
449
+ const pending = findPendingInstallApproval({
450
+ sessionId: bctx.ctx?.sessionId || null,
451
+ agentId: bctx.ctx?.agentId || null,
452
+ question,
453
+ prompt,
454
+ })
455
+ if (!normalized.approvalId) {
456
+ const approval = pending || requestApproval({
457
+ category: 'human_loop',
458
+ title: `Install skill "${localTarget.name}"`,
459
+ description: 'Approve copying this local skill into managed skills so it can be attached and reused durably.',
460
+ agentId: bctx.ctx?.agentId || null,
461
+ sessionId: bctx.ctx?.sessionId || null,
462
+ data: {
463
+ question,
464
+ prompt,
465
+ action: 'manage_skills.install',
466
+ skillName: localTarget.name,
467
+ runtimeSkillId: localTarget.id,
468
+ attachAgentId,
469
+ },
470
+ })
471
+ return JSON.stringify({
472
+ ok: false,
473
+ requiresApproval: true,
474
+ approval,
475
+ skill: summarizeSkill(localTarget),
476
+ })
477
+ }
478
+ ensureApprovedInstall(typeof normalized.approvalId === 'string' ? normalized.approvalId : null)
479
+ }
480
+
481
+ const stored = await materializeResolvedSkill(localTarget)
482
+ if (attach && attachAgentId) attachSkillToAgent(stored.id, attachAgentId)
483
+ return JSON.stringify({
484
+ ok: true,
485
+ installed: true,
486
+ source: localTarget.source,
487
+ deduplicated: Boolean(localTarget.storageId),
488
+ skill: stored,
489
+ attachedToAgentId: attachAgentId,
490
+ })
491
+ }
492
+
493
+ const url = typeof normalized.url === 'string' ? normalized.url.trim() : ''
494
+ let remoteTarget = {
495
+ name: typeof normalized.name === 'string' ? normalized.name.trim() : '',
496
+ description: typeof normalized.description === 'string' ? normalized.description.trim() : '',
497
+ url,
498
+ author: typeof normalized.author === 'string' ? normalized.author.trim() : '',
499
+ tags: Array.isArray(normalized.tags)
500
+ ? normalized.tags.filter((value): value is string => typeof value === 'string')
501
+ : [],
502
+ content: typeof normalized.content === 'string' ? normalized.content : undefined,
503
+ }
504
+
505
+ if (!remoteTarget.url) {
506
+ const query = remoteTarget.name || (typeof normalized.query === 'string' ? normalized.query.trim() : '')
507
+ if (!query) return 'Error: install requires a local skill selector, `url`, or a `name`/`query` to search.'
508
+ const marketplace = await searchClawHub(query, 1, 5)
509
+ const exact = marketplace.skills.find((skill) =>
510
+ normalizeKey(skill.name) === normalizeKey(query)
511
+ || normalizeKey(skill.id) === normalizeKey(query),
512
+ ) || marketplace.skills[0]
513
+ if (!exact) return `Error: no marketplace skill found for "${query}".`
514
+ remoteTarget = {
515
+ ...remoteTarget,
516
+ name: exact.name,
517
+ description: exact.description,
518
+ url: exact.url,
519
+ author: exact.author,
520
+ tags: exact.tags,
521
+ }
522
+ }
523
+
524
+ const question = `Install skill "${remoteTarget.name || remoteTarget.url}" from ${remoteTarget.url}?`
525
+ const prompt = remoteTarget.url
526
+ const pending = findPendingInstallApproval({
527
+ sessionId: bctx.ctx?.sessionId || null,
528
+ agentId: bctx.ctx?.agentId || null,
529
+ question,
530
+ prompt,
531
+ })
532
+
533
+ if (!normalized.approvalId) {
534
+ const approval = pending || requestApproval({
535
+ category: 'human_loop',
536
+ title: `Install skill "${remoteTarget.name || 'remote skill'}"`,
537
+ description: 'Approve adding this external skill to managed skills.',
538
+ agentId: bctx.ctx?.agentId || null,
539
+ sessionId: bctx.ctx?.sessionId || null,
540
+ data: {
541
+ question,
542
+ prompt,
543
+ action: 'manage_skills.install',
544
+ skillName: remoteTarget.name,
545
+ url: remoteTarget.url,
546
+ attachAgentId,
547
+ },
548
+ })
549
+ return JSON.stringify({
550
+ ok: false,
551
+ requiresApproval: true,
552
+ approval,
553
+ source: 'clawhub',
554
+ skill: remoteTarget,
555
+ })
556
+ }
557
+
558
+ ensureApprovedInstall(typeof normalized.approvalId === 'string' ? normalized.approvalId : null)
559
+ const installed = await installRemoteSkill(remoteTarget)
560
+ if (attach && attachAgentId) attachSkillToAgent(installed.id, attachAgentId)
561
+ return JSON.stringify({
562
+ ok: true,
563
+ installed: true,
564
+ source: 'clawhub',
565
+ skill: installed,
566
+ attachedToAgentId: attachAgentId,
567
+ })
568
+ }
569
+ default:
570
+ return `Error: Unknown action "${action}".`
571
+ }
572
+ } catch (err: unknown) {
573
+ return `Error: ${errorMessage(err)}`
574
+ }
575
+ }