@swarmclawai/swarmclaw 0.7.7 → 0.8.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.
- package/README.md +12 -14
- package/next.config.ts +13 -2
- package/package.json +4 -2
- package/src/app/api/agents/[id]/thread/route.ts +9 -0
- package/src/app/api/agents/route.ts +4 -0
- package/src/app/api/agents/thread-route.test.ts +133 -0
- package/src/app/api/approvals/route.test.ts +148 -0
- package/src/app/api/canvas/[sessionId]/route.ts +3 -1
- package/src/app/api/chatrooms/[id]/chat/route.ts +4 -2
- package/src/app/api/chats/[id]/devserver/route.ts +48 -7
- package/src/app/api/chats/[id]/messages/route.ts +42 -18
- package/src/app/api/chats/[id]/route.ts +1 -1
- package/src/app/api/chats/[id]/stop/route.ts +5 -4
- package/src/app/api/chats/route.ts +23 -2
- package/src/app/api/clawhub/install/route.ts +28 -8
- package/src/app/api/connectors/[id]/route.ts +46 -3
- package/src/app/api/connectors/route.ts +12 -8
- package/src/app/api/external-agents/route.test.ts +165 -0
- package/src/app/api/gateways/[id]/health/route.ts +27 -12
- package/src/app/api/gateways/[id]/route.ts +2 -0
- package/src/app/api/gateways/health-route.test.ts +135 -0
- package/src/app/api/gateways/route.ts +2 -0
- package/src/app/api/mcp-servers/route.test.ts +130 -0
- package/src/app/api/openclaw/deploy/route.ts +38 -5
- package/src/app/api/plugins/install/route.ts +46 -6
- package/src/app/api/plugins/marketplace/route.ts +48 -15
- package/src/app/api/preview-server/route.ts +26 -11
- package/src/app/api/projects/[id]/route.ts +6 -2
- package/src/app/api/projects/route.ts +4 -3
- package/src/app/api/schedules/[id]/run/route.ts +4 -0
- package/src/app/api/schedules/route.test.ts +86 -0
- package/src/app/api/schedules/route.ts +6 -1
- package/src/app/api/secrets/[id]/route.ts +1 -0
- package/src/app/api/secrets/route.ts +2 -1
- package/src/app/api/settings/route.ts +2 -0
- package/src/app/api/setup/check-provider/route.test.ts +19 -0
- package/src/app/api/setup/check-provider/route.ts +40 -10
- package/src/app/api/skills/[id]/route.ts +12 -0
- package/src/app/api/skills/import/route.ts +14 -12
- package/src/app/api/skills/route.ts +13 -1
- package/src/app/api/tasks/[id]/route.ts +10 -1
- package/src/app/api/tasks/import/github/route.test.ts +65 -0
- package/src/app/api/tasks/import/github/route.ts +337 -0
- package/src/app/api/wallets/[id]/approve/route.ts +17 -3
- package/src/app/api/wallets/[id]/route.ts +79 -33
- package/src/app/api/wallets/[id]/send/route.ts +19 -33
- package/src/app/api/wallets/route.ts +78 -61
- package/src/app/api/webhooks/[id]/route.ts +33 -6
- package/src/app/api/webhooks/route.test.ts +272 -0
- package/src/cli/index.js +1 -0
- package/src/cli/spec.js +1 -0
- package/src/components/agents/agent-card.tsx +9 -2
- package/src/components/agents/agent-chat-list.tsx +18 -2
- package/src/components/agents/agent-list.tsx +1 -0
- package/src/components/agents/agent-sheet.tsx +257 -38
- package/src/components/agents/inspector-panel.tsx +41 -0
- package/src/components/canvas/canvas-panel.tsx +236 -65
- package/src/components/chat/chat-area.tsx +36 -19
- package/src/components/chat/chat-card.tsx +36 -13
- package/src/components/chat/chat-header.tsx +48 -16
- package/src/components/chat/chat-list.tsx +28 -4
- package/src/components/chat/checkpoint-timeline.tsx +50 -34
- package/src/components/chat/delegation-banner.test.ts +14 -1
- package/src/components/chat/delegation-banner.tsx +1 -1
- package/src/components/chat/message-bubble.tsx +208 -145
- package/src/components/chat/message-list.tsx +48 -19
- package/src/components/chatrooms/chatroom-message.tsx +2 -2
- package/src/components/chatrooms/chatroom-sheet.tsx +16 -2
- package/src/components/connectors/connector-health.tsx +1 -1
- package/src/components/connectors/connector-list.tsx +7 -2
- package/src/components/connectors/connector-sheet.tsx +337 -148
- package/src/components/gateways/gateway-sheet.tsx +2 -2
- package/src/components/layout/app-layout.tsx +40 -23
- package/src/components/mcp-servers/mcp-server-list.tsx +26 -5
- package/src/components/mcp-servers/mcp-server-sheet.tsx +19 -2
- package/src/components/openclaw/openclaw-deploy-panel.tsx +269 -21
- package/src/components/plugins/plugin-list.tsx +45 -9
- package/src/components/plugins/plugin-sheet.tsx +55 -7
- package/src/components/projects/project-detail.tsx +217 -0
- package/src/components/projects/project-sheet.tsx +176 -4
- package/src/components/providers/provider-list.tsx +2 -1
- package/src/components/providers/provider-sheet.tsx +21 -2
- package/src/components/schedules/schedule-card.tsx +25 -1
- package/src/components/schedules/schedule-sheet.tsx +44 -2
- package/src/components/secrets/secret-sheet.tsx +21 -2
- package/src/components/shared/agent-switch-dialog.tsx +12 -1
- package/src/components/shared/bottom-sheet.tsx +13 -3
- package/src/components/shared/command-palette.tsx +8 -1
- package/src/components/shared/confirm-dialog.tsx +19 -4
- package/src/components/shared/connector-platform-icon.test.ts +28 -0
- package/src/components/shared/connector-platform-icon.tsx +39 -6
- package/src/components/shared/settings/plugin-manager.tsx +29 -6
- package/src/components/shared/settings/section-capability-policy.tsx +45 -3
- package/src/components/shared/settings/section-voice.tsx +11 -3
- package/src/components/skills/skill-list.tsx +25 -0
- package/src/components/skills/skill-sheet.tsx +84 -12
- package/src/components/tasks/approvals-panel.tsx +289 -34
- package/src/components/tasks/task-board.tsx +410 -25
- package/src/components/tasks/task-card.tsx +66 -8
- package/src/components/tasks/task-sheet.tsx +16 -4
- package/src/components/ui/dialog.tsx +2 -2
- package/src/components/wallets/wallet-approval-dialog.tsx +4 -2
- package/src/components/wallets/wallet-panel.tsx +435 -90
- package/src/components/wallets/wallet-section.tsx +198 -48
- package/src/components/webhooks/webhook-sheet.tsx +22 -2
- package/src/lib/approval-display.ts +20 -0
- package/src/lib/canvas-content.ts +198 -0
- package/src/lib/chat-artifact-summary.ts +165 -0
- package/src/lib/chat-display.test.ts +91 -0
- package/src/lib/chat-display.ts +58 -0
- package/src/lib/chat-streaming-state.test.ts +47 -1
- package/src/lib/chat-streaming-state.ts +42 -0
- package/src/lib/ollama-model.ts +10 -0
- package/src/lib/openclaw-endpoint.test.ts +8 -0
- package/src/lib/openclaw-endpoint.ts +6 -1
- package/src/lib/plugin-install-cors.ts +46 -0
- package/src/lib/plugin-sources.test.ts +43 -0
- package/src/lib/plugin-sources.ts +77 -0
- package/src/lib/providers/ollama.ts +16 -6
- package/src/lib/providers/openclaw.test.ts +54 -0
- package/src/lib/providers/openclaw.ts +127 -11
- package/src/lib/schedule-dedupe-advanced.test.ts +1335 -0
- package/src/lib/schedule-dedupe.test.ts +66 -1
- package/src/lib/schedule-dedupe.ts +169 -12
- package/src/lib/schedule-origin.test.ts +20 -0
- package/src/lib/schedule-origin.ts +15 -0
- package/src/lib/server/__fixtures__/fake-mcp-stdio-server.mjs +27 -0
- package/src/lib/server/agent-availability.ts +16 -0
- package/src/lib/server/agent-runtime-config.ts +12 -4
- package/src/lib/server/agent-thread-session.test.ts +51 -0
- package/src/lib/server/agent-thread-session.ts +7 -0
- package/src/lib/server/approval-match.ts +205 -0
- package/src/lib/server/approvals-auto-approve.test.ts +538 -1
- package/src/lib/server/approvals.ts +214 -1
- package/src/lib/server/assistant-control.test.ts +29 -0
- package/src/lib/server/assistant-control.ts +23 -0
- package/src/lib/server/build-llm.test.ts +79 -0
- package/src/lib/server/build-llm.ts +14 -4
- package/src/lib/server/canvas-content.test.ts +32 -0
- package/src/lib/server/canvas-content.ts +6 -0
- package/src/lib/server/capability-router.test.ts +33 -0
- package/src/lib/server/capability-router.ts +80 -19
- package/src/lib/server/chat-execution-advanced.test.ts +651 -0
- package/src/lib/server/chat-execution-disabled.test.ts +94 -0
- package/src/lib/server/chat-execution-tool-events.test.ts +157 -0
- package/src/lib/server/chat-execution.ts +378 -73
- package/src/lib/server/clawhub-client.test.ts +14 -8
- package/src/lib/server/connectors/manager-reconnect.test.ts +47 -0
- package/src/lib/server/connectors/manager.test.ts +1147 -0
- package/src/lib/server/connectors/manager.ts +461 -137
- package/src/lib/server/connectors/pairing.ts +26 -5
- package/src/lib/server/connectors/types.ts +2 -0
- package/src/lib/server/connectors/whatsapp.test.ts +134 -0
- package/src/lib/server/connectors/whatsapp.ts +271 -47
- package/src/lib/server/context-manager.ts +6 -1
- package/src/lib/server/daemon-state.ts +84 -47
- package/src/lib/server/data-dir.test.ts +37 -0
- package/src/lib/server/data-dir.ts +20 -1
- package/src/lib/server/delegation-jobs-advanced.test.ts +513 -0
- package/src/lib/server/devserver-launch.test.ts +60 -0
- package/src/lib/server/devserver-launch.ts +85 -0
- package/src/lib/server/elevenlabs.test.ts +247 -1
- package/src/lib/server/elevenlabs.ts +147 -43
- package/src/lib/server/ethereum.ts +590 -0
- package/src/lib/server/eval/agent-regression-advanced.test.ts +302 -0
- package/src/lib/server/eval/agent-regression.test.ts +18 -1
- package/src/lib/server/eval/agent-regression.ts +383 -11
- package/src/lib/server/evm-swap.ts +475 -0
- package/src/lib/server/execution-log.ts +1 -0
- package/src/lib/server/heartbeat-service-timer.test.ts +173 -0
- package/src/lib/server/heartbeat-service.ts +20 -11
- package/src/lib/server/heartbeat-wake.test.ts +112 -0
- package/src/lib/server/heartbeat-wake.ts +338 -57
- package/src/lib/server/main-agent-loop-advanced.test.ts +538 -0
- package/src/lib/server/main-agent-loop.test.ts +260 -0
- package/src/lib/server/main-agent-loop.ts +559 -14
- package/src/lib/server/mcp-client.test.ts +16 -0
- package/src/lib/server/mcp-client.ts +25 -0
- package/src/lib/server/memory-integration.test.ts +719 -0
- package/src/lib/server/memory-policy.test.ts +43 -0
- package/src/lib/server/memory-policy.ts +132 -0
- package/src/lib/server/memory-tiers.test.ts +60 -0
- package/src/lib/server/memory-tiers.ts +16 -0
- package/src/lib/server/ollama-runtime.ts +58 -0
- package/src/lib/server/openclaw-deploy.test.ts +109 -1
- package/src/lib/server/openclaw-deploy.ts +557 -81
- package/src/lib/server/openclaw-gateway.test.ts +131 -0
- package/src/lib/server/openclaw-gateway.ts +10 -4
- package/src/lib/server/openclaw-health.test.ts +35 -0
- package/src/lib/server/openclaw-health.ts +215 -47
- package/src/lib/server/orchestrator-lg.ts +3 -2
- package/src/lib/server/orchestrator.ts +2 -0
- package/src/lib/server/plugins-advanced.test.ts +351 -0
- package/src/lib/server/plugins.ts +211 -6
- package/src/lib/server/project-context.ts +162 -0
- package/src/lib/server/project-utils.ts +150 -0
- package/src/lib/server/queue-advanced.test.ts +528 -0
- package/src/lib/server/queue-followups.test.ts +409 -2
- package/src/lib/server/queue-reconcile.test.ts +128 -0
- package/src/lib/server/queue.ts +527 -68
- package/src/lib/server/scheduler.ts +29 -1
- package/src/lib/server/session-note.test.ts +36 -0
- package/src/lib/server/session-note.ts +42 -0
- package/src/lib/server/session-run-manager.ts +83 -4
- package/src/lib/server/session-tools/canvas.ts +14 -12
- package/src/lib/server/session-tools/connector-inputs.test.ts +37 -0
- package/src/lib/server/session-tools/connector.test.ts +138 -0
- package/src/lib/server/session-tools/connector.ts +366 -54
- package/src/lib/server/session-tools/context.ts +17 -3
- package/src/lib/server/session-tools/crud.ts +484 -84
- package/src/lib/server/session-tools/delegate-fallback.test.ts +103 -0
- package/src/lib/server/session-tools/delegate-resume.test.ts +50 -0
- package/src/lib/server/session-tools/delegate.ts +102 -10
- package/src/lib/server/session-tools/discovery-approvals.test.ts +142 -0
- package/src/lib/server/session-tools/discovery.ts +80 -12
- package/src/lib/server/session-tools/file-normalize.test.ts +36 -0
- package/src/lib/server/session-tools/file.ts +43 -4
- package/src/lib/server/session-tools/human-loop.ts +35 -5
- package/src/lib/server/session-tools/index.ts +44 -9
- package/src/lib/server/session-tools/manage-connectors.test.ts +139 -0
- package/src/lib/server/session-tools/manage-schedules-advanced.test.ts +564 -0
- package/src/lib/server/session-tools/manage-schedules.test.ts +283 -0
- package/src/lib/server/session-tools/manage-tasks-advanced.test.ts +852 -0
- package/src/lib/server/session-tools/manage-tasks.test.ts +114 -0
- package/src/lib/server/session-tools/memory.test.ts +93 -0
- package/src/lib/server/session-tools/memory.ts +554 -75
- package/src/lib/server/session-tools/normalize-tool-args.ts +1 -1
- package/src/lib/server/session-tools/platform-access.test.ts +58 -0
- package/src/lib/server/session-tools/platform.ts +60 -19
- package/src/lib/server/session-tools/plugin-creator.ts +57 -1
- package/src/lib/server/session-tools/primitive-tools.test.ts +6 -0
- package/src/lib/server/session-tools/schedule.ts +6 -1
- package/src/lib/server/session-tools/shell-normalize.test.ts +25 -1
- package/src/lib/server/session-tools/shell.ts +22 -3
- package/src/lib/server/session-tools/wallet-tool.test.ts +254 -0
- package/src/lib/server/session-tools/wallet.ts +1374 -139
- package/src/lib/server/session-tools/web-inputs.test.ts +178 -0
- package/src/lib/server/session-tools/web.ts +621 -70
- package/src/lib/server/skill-discovery.ts +128 -0
- package/src/lib/server/skill-eligibility.test.ts +84 -0
- package/src/lib/server/skill-eligibility.ts +95 -0
- package/src/lib/server/skill-prompt-budget.test.ts +102 -0
- package/src/lib/server/skill-prompt-budget.ts +125 -0
- package/src/lib/server/skills-normalize.test.ts +54 -0
- package/src/lib/server/skills-normalize.ts +372 -26
- package/src/lib/server/solana.ts +214 -29
- package/src/lib/server/storage.ts +65 -36
- package/src/lib/server/stream-agent-chat.test.ts +437 -2
- package/src/lib/server/stream-agent-chat.ts +957 -79
- package/src/lib/server/system-events.ts +1 -1
- package/src/lib/server/tool-aliases.ts +2 -0
- package/src/lib/server/tool-capability-policy-advanced.test.ts +502 -0
- package/src/lib/server/tool-capability-policy.test.ts +24 -0
- package/src/lib/server/tool-capability-policy.ts +29 -1
- package/src/lib/server/tool-loop-detection.test.ts +105 -0
- package/src/lib/server/tool-loop-detection.ts +260 -0
- package/src/lib/server/tool-planning.test.ts +44 -0
- package/src/lib/server/tool-planning.ts +271 -0
- package/src/lib/server/wallet-execution.test.ts +198 -0
- package/src/lib/server/wallet-portfolio.test.ts +98 -0
- package/src/lib/server/wallet-portfolio.ts +724 -0
- package/src/lib/server/wallet-service.test.ts +57 -0
- package/src/lib/server/wallet-service.ts +213 -0
- package/src/lib/server/watch-jobs-advanced.test.ts +594 -0
- package/src/lib/server/watch-jobs.ts +17 -2
- package/src/lib/server/workspace-context.ts +111 -0
- package/src/lib/skill-save-payload.test.ts +39 -0
- package/src/lib/skill-save-payload.ts +37 -0
- package/src/lib/tasks.ts +28 -0
- package/src/lib/tool-definitions.ts +2 -1
- package/src/lib/tool-event-summary.test.ts +30 -0
- package/src/lib/tool-event-summary.ts +37 -0
- package/src/lib/validation/schemas.ts +1 -0
- package/src/lib/wallet-transactions.test.ts +75 -0
- package/src/lib/wallet-transactions.ts +43 -0
- package/src/lib/wallet.test.ts +17 -0
- package/src/lib/wallet.ts +183 -0
- package/src/proxy.test.ts +31 -0
- package/src/proxy.ts +34 -2
- package/src/stores/use-chat-store.ts +15 -1
- package/src/types/index.ts +249 -14
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import os from 'os'
|
|
4
|
+
import { DATA_DIR } from './data-dir'
|
|
5
|
+
import { normalizeSkillPayload, type NormalizedSkill } from './skills-normalize'
|
|
6
|
+
|
|
7
|
+
export interface DiscoveredSkill extends NormalizedSkill {
|
|
8
|
+
/** Which layer this skill was found in. */
|
|
9
|
+
source: 'bundled' | 'workspace' | 'project'
|
|
10
|
+
/** Absolute path to the SKILL.md file. */
|
|
11
|
+
sourcePath: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface DiscoveryCache {
|
|
15
|
+
skills: DiscoveredSkill[]
|
|
16
|
+
ids: string[]
|
|
17
|
+
timestamp: number
|
|
18
|
+
cacheKey: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const CACHE_TTL_MS = 5_000
|
|
22
|
+
|
|
23
|
+
let cache: DiscoveryCache | null = null
|
|
24
|
+
|
|
25
|
+
function buildCacheKey(cwd?: string): string {
|
|
26
|
+
return `${cwd || ''}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function scanLayer(
|
|
30
|
+
dir: string,
|
|
31
|
+
source: DiscoveredSkill['source'],
|
|
32
|
+
): DiscoveredSkill[] {
|
|
33
|
+
const results: DiscoveredSkill[] = []
|
|
34
|
+
let entries: string[]
|
|
35
|
+
try {
|
|
36
|
+
entries = fs.readdirSync(dir)
|
|
37
|
+
} catch {
|
|
38
|
+
return results
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
const skillDir = path.join(dir, entry)
|
|
43
|
+
let stat: fs.Stats
|
|
44
|
+
try {
|
|
45
|
+
stat = fs.statSync(skillDir)
|
|
46
|
+
} catch {
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
if (!stat.isDirectory()) continue
|
|
50
|
+
|
|
51
|
+
const skillFile = path.join(skillDir, 'SKILL.md')
|
|
52
|
+
let content: string
|
|
53
|
+
try {
|
|
54
|
+
content = fs.readFileSync(skillFile, 'utf-8')
|
|
55
|
+
} catch {
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const normalized = normalizeSkillPayload({
|
|
60
|
+
content,
|
|
61
|
+
filename: `${entry}.md`,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
results.push({
|
|
65
|
+
...normalized,
|
|
66
|
+
source,
|
|
67
|
+
sourcePath: skillFile,
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return results
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Discover skills from three layers:
|
|
76
|
+
* 1. Bundled: `data/skills/` (shipped with the app)
|
|
77
|
+
* 2. Workspace: `~/.swarmclaw/skills/` (user-installed)
|
|
78
|
+
* 3. Project: `<cwd>/skills/` (project-local)
|
|
79
|
+
*
|
|
80
|
+
* Results are cached with a 5-second TTL. Later layers override
|
|
81
|
+
* earlier ones when names collide (project > workspace > bundled).
|
|
82
|
+
*/
|
|
83
|
+
export function discoverSkills(opts?: { cwd?: string }): DiscoveredSkill[] {
|
|
84
|
+
const cwd = opts?.cwd
|
|
85
|
+
const cacheKey = buildCacheKey(cwd)
|
|
86
|
+
const now = Date.now()
|
|
87
|
+
|
|
88
|
+
if (cache && cache.cacheKey === cacheKey && now - cache.timestamp < CACHE_TTL_MS) {
|
|
89
|
+
return cache.skills
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Layer 1: Bundled skills
|
|
93
|
+
const bundledDir = path.join(DATA_DIR, 'skills')
|
|
94
|
+
const bundled = scanLayer(bundledDir, 'bundled')
|
|
95
|
+
|
|
96
|
+
// Layer 2: Workspace skills (~/.swarmclaw/skills/)
|
|
97
|
+
const workspaceDir = path.join(os.homedir(), '.swarmclaw', 'skills')
|
|
98
|
+
const workspace = scanLayer(workspaceDir, 'workspace')
|
|
99
|
+
|
|
100
|
+
// Layer 3: Project-local skills (<cwd>/skills/)
|
|
101
|
+
let project: DiscoveredSkill[] = []
|
|
102
|
+
if (cwd) {
|
|
103
|
+
const projectDir = path.join(cwd, 'skills')
|
|
104
|
+
project = scanLayer(projectDir, 'project')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Deduplicate: later layers win on name collision
|
|
108
|
+
const byName = new Map<string, DiscoveredSkill>()
|
|
109
|
+
for (const skill of [...bundled, ...workspace, ...project]) {
|
|
110
|
+
byName.set(skill.name.toLowerCase(), skill)
|
|
111
|
+
}
|
|
112
|
+
const skills = Array.from(byName.values())
|
|
113
|
+
const ids = skills.map((s) => s.name)
|
|
114
|
+
|
|
115
|
+
cache = { skills, ids, timestamp: now, cacheKey }
|
|
116
|
+
return skills
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Return the names of all currently discovered skills (uses cache if warm).
|
|
121
|
+
*/
|
|
122
|
+
export function getDiscoveredSkillIds(): string[] {
|
|
123
|
+
if (cache && Date.now() - cache.timestamp < CACHE_TTL_MS) {
|
|
124
|
+
return cache.ids
|
|
125
|
+
}
|
|
126
|
+
// Cold call without cwd — returns bundled + workspace only
|
|
127
|
+
return discoverSkills().map((s) => s.name)
|
|
128
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, it, beforeEach } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { evaluateRequirements, clearBinaryCache } from './skill-eligibility'
|
|
4
|
+
import type { SkillRequirements } from '@/types'
|
|
5
|
+
|
|
6
|
+
describe('evaluateRequirements', () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
clearBinaryCache()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('returns eligible when no requirements', () => {
|
|
12
|
+
const result = evaluateRequirements({})
|
|
13
|
+
assert.equal(result.eligible, true)
|
|
14
|
+
assert.equal(result.reasons.length, 0)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('passes when required binary exists (node)', () => {
|
|
18
|
+
const result = evaluateRequirements({ bins: ['node'] })
|
|
19
|
+
assert.equal(result.eligible, true)
|
|
20
|
+
assert.equal(result.missingBins.length, 0)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('fails when required binary is missing', () => {
|
|
24
|
+
const result = evaluateRequirements({ bins: ['nonexistent_binary_xyz_123'] })
|
|
25
|
+
assert.equal(result.eligible, false)
|
|
26
|
+
assert.deepEqual(result.missingBins, ['nonexistent_binary_xyz_123'])
|
|
27
|
+
assert.ok(result.reasons[0].includes('Missing binaries'))
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('handles anyBins groups — passes when at least one exists', () => {
|
|
31
|
+
const result = evaluateRequirements({
|
|
32
|
+
anyBins: [['node', 'nonexistent_abc']],
|
|
33
|
+
})
|
|
34
|
+
assert.equal(result.eligible, true)
|
|
35
|
+
assert.equal(result.missingAnyBins.length, 0)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('handles anyBins groups — fails when none exist', () => {
|
|
39
|
+
const result = evaluateRequirements({
|
|
40
|
+
anyBins: [['nonexistent_a', 'nonexistent_b']],
|
|
41
|
+
})
|
|
42
|
+
assert.equal(result.eligible, false)
|
|
43
|
+
assert.equal(result.missingAnyBins.length, 1)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('checks environment variables', () => {
|
|
47
|
+
const result = evaluateRequirements({ env: ['VERY_UNLIKELY_ENV_VAR_XYZ'] })
|
|
48
|
+
assert.equal(result.eligible, false)
|
|
49
|
+
assert.deepEqual(result.missingEnv, ['VERY_UNLIKELY_ENV_VAR_XYZ'])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('passes env check when env var is set', () => {
|
|
53
|
+
// PATH is always set
|
|
54
|
+
const result = evaluateRequirements({ env: ['PATH'] })
|
|
55
|
+
assert.equal(result.eligible, true)
|
|
56
|
+
assert.equal(result.missingEnv.length, 0)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('checks OS compatibility — passes on current platform', () => {
|
|
60
|
+
const result = evaluateRequirements({ os: [process.platform] })
|
|
61
|
+
assert.equal(result.eligible, true)
|
|
62
|
+
assert.equal(result.unsupportedOs, false)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('checks OS compatibility — fails on wrong platform', () => {
|
|
66
|
+
const result = evaluateRequirements({ os: ['nonexistent_os'] })
|
|
67
|
+
assert.equal(result.eligible, false)
|
|
68
|
+
assert.equal(result.unsupportedOs, true)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('combines multiple failing checks', () => {
|
|
72
|
+
const req: SkillRequirements = {
|
|
73
|
+
bins: ['nonexistent_bin_xyz'],
|
|
74
|
+
env: ['NONEXISTENT_ENV_ABC'],
|
|
75
|
+
os: ['nonexistent_os'],
|
|
76
|
+
}
|
|
77
|
+
const result = evaluateRequirements(req)
|
|
78
|
+
assert.equal(result.eligible, false)
|
|
79
|
+
assert.ok(result.reasons.length >= 3)
|
|
80
|
+
assert.ok(result.missingBins.length > 0)
|
|
81
|
+
assert.ok(result.missingEnv.length > 0)
|
|
82
|
+
assert.equal(result.unsupportedOs, true)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { execSync } from 'child_process'
|
|
2
|
+
import type { Skill, SkillRequirements } from '@/types'
|
|
3
|
+
|
|
4
|
+
export interface SkillEligibilityResult {
|
|
5
|
+
eligible: boolean
|
|
6
|
+
missingBins: string[]
|
|
7
|
+
missingAnyBins: string[][]
|
|
8
|
+
missingEnv: string[]
|
|
9
|
+
unsupportedOs: boolean
|
|
10
|
+
reasons: string[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const binaryCache = new Map<string, boolean>()
|
|
14
|
+
|
|
15
|
+
function hasBinary(name: string): boolean {
|
|
16
|
+
const cached = binaryCache.get(name)
|
|
17
|
+
if (cached !== undefined) return cached
|
|
18
|
+
try {
|
|
19
|
+
execSync(`which ${name}`, { stdio: 'ignore', timeout: 2000 })
|
|
20
|
+
binaryCache.set(name, true)
|
|
21
|
+
return true
|
|
22
|
+
} catch {
|
|
23
|
+
binaryCache.set(name, false)
|
|
24
|
+
return false
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Clear the binary cache (useful for tests or after installs). */
|
|
29
|
+
export function clearBinaryCache(): void {
|
|
30
|
+
binaryCache.clear()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function evaluateSkillEligibility(skill: Skill): SkillEligibilityResult {
|
|
34
|
+
const req = skill.skillRequirements
|
|
35
|
+
if (!req) return { eligible: true, missingBins: [], missingAnyBins: [], missingEnv: [], unsupportedOs: false, reasons: [] }
|
|
36
|
+
|
|
37
|
+
return evaluateRequirements(req)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function evaluateRequirements(req: SkillRequirements): SkillEligibilityResult {
|
|
41
|
+
const reasons: string[] = []
|
|
42
|
+
const missingBins: string[] = []
|
|
43
|
+
const missingAnyBins: string[][] = []
|
|
44
|
+
const missingEnv: string[] = []
|
|
45
|
+
let unsupportedOs = false
|
|
46
|
+
|
|
47
|
+
// Check required binaries
|
|
48
|
+
if (req.bins?.length) {
|
|
49
|
+
for (const bin of req.bins) {
|
|
50
|
+
if (!hasBinary(bin)) {
|
|
51
|
+
missingBins.push(bin)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (missingBins.length) {
|
|
55
|
+
reasons.push(`Missing binaries: ${missingBins.join(', ')}`)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Check anyBins groups (at least one from each group must exist)
|
|
60
|
+
if (req.anyBins?.length) {
|
|
61
|
+
for (const group of req.anyBins) {
|
|
62
|
+
if (!group.some(hasBinary)) {
|
|
63
|
+
missingAnyBins.push(group)
|
|
64
|
+
reasons.push(`None of [${group.join(', ')}] found`)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Check required environment variables
|
|
70
|
+
if (req.env?.length) {
|
|
71
|
+
for (const envVar of req.env) {
|
|
72
|
+
if (!process.env[envVar]) {
|
|
73
|
+
missingEnv.push(envVar)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (missingEnv.length) {
|
|
77
|
+
reasons.push(`Missing env vars: ${missingEnv.join(', ')}`)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Check OS compatibility
|
|
82
|
+
if (req.os?.length) {
|
|
83
|
+
if (!req.os.includes(process.platform)) {
|
|
84
|
+
unsupportedOs = true
|
|
85
|
+
reasons.push(`OS ${process.platform} not in [${req.os.join(', ')}]`)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const eligible = missingBins.length === 0
|
|
90
|
+
&& missingAnyBins.length === 0
|
|
91
|
+
&& missingEnv.length === 0
|
|
92
|
+
&& !unsupportedOs
|
|
93
|
+
|
|
94
|
+
return { eligible, missingBins, missingAnyBins, missingEnv, unsupportedOs, reasons }
|
|
95
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { budgetSkillsForPrompt, buildSkillPromptText, MAX_SKILLS_IN_PROMPT, MAX_SKILLS_PROMPT_CHARS } from './skill-prompt-budget'
|
|
4
|
+
import type { Skill } from '@/types'
|
|
5
|
+
|
|
6
|
+
function makeSkill(id: string, overrides: Partial<Skill> = {}): Skill {
|
|
7
|
+
return {
|
|
8
|
+
name: id,
|
|
9
|
+
filename: `${id}.md`,
|
|
10
|
+
content: overrides.content ?? `Instructions for ${id} skill.`,
|
|
11
|
+
createdAt: Date.now(),
|
|
12
|
+
updatedAt: Date.now(),
|
|
13
|
+
...overrides,
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('budgetSkillsForPrompt', () => {
|
|
18
|
+
it('includes agent-bound skills first', () => {
|
|
19
|
+
const skills: Record<string, Skill> = {
|
|
20
|
+
weather: makeSkill('weather'),
|
|
21
|
+
github: makeSkill('github'),
|
|
22
|
+
coding: makeSkill('coding'),
|
|
23
|
+
}
|
|
24
|
+
const result = budgetSkillsForPrompt(skills, ['weather', 'coding'])
|
|
25
|
+
const included = result.filter((r) => r.included)
|
|
26
|
+
assert.equal(included.length, 3) // weather, coding (agent-bound) + github (eligible)
|
|
27
|
+
assert.equal(included[0].skill.name, 'weather')
|
|
28
|
+
assert.equal(included[1].skill.name, 'coding')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('excludes ineligible skills (missing OS)', () => {
|
|
32
|
+
const skills: Record<string, Skill> = {
|
|
33
|
+
weather: makeSkill('weather', {
|
|
34
|
+
skillRequirements: { os: ['nonexistent_os'] },
|
|
35
|
+
}),
|
|
36
|
+
}
|
|
37
|
+
const result = budgetSkillsForPrompt(skills, ['weather'])
|
|
38
|
+
assert.equal(result[0].eligible, false)
|
|
39
|
+
assert.equal(result[0].included, false)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('respects MAX_SKILLS_IN_PROMPT count limit', () => {
|
|
43
|
+
const skills: Record<string, Skill> = {}
|
|
44
|
+
const ids: string[] = []
|
|
45
|
+
for (let i = 0; i < MAX_SKILLS_IN_PROMPT + 10; i++) {
|
|
46
|
+
const id = `skill-${String(i).padStart(4, '0')}`
|
|
47
|
+
skills[id] = makeSkill(id, { content: 'x' })
|
|
48
|
+
ids.push(id)
|
|
49
|
+
}
|
|
50
|
+
const result = budgetSkillsForPrompt(skills, ids)
|
|
51
|
+
const included = result.filter((r) => r.included)
|
|
52
|
+
assert.equal(included.length, MAX_SKILLS_IN_PROMPT)
|
|
53
|
+
const excluded = result.filter((r) => !r.included && r.reason === 'skill count limit reached')
|
|
54
|
+
assert.equal(excluded.length, 10)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('respects MAX_SKILLS_PROMPT_CHARS budget', () => {
|
|
58
|
+
// Create skills with large content that will exceed the budget
|
|
59
|
+
const bigContent = 'x'.repeat(10_000)
|
|
60
|
+
const skills: Record<string, Skill> = {
|
|
61
|
+
a: makeSkill('a', { content: bigContent }),
|
|
62
|
+
b: makeSkill('b', { content: bigContent }),
|
|
63
|
+
c: makeSkill('c', { content: bigContent }),
|
|
64
|
+
d: makeSkill('d', { content: bigContent }),
|
|
65
|
+
}
|
|
66
|
+
const result = budgetSkillsForPrompt(skills, ['a', 'b', 'c', 'd'])
|
|
67
|
+
const included = result.filter((r) => r.included)
|
|
68
|
+
// 10K * 3 = 30K, so at most 3 can fit
|
|
69
|
+
assert.ok(included.length <= 3)
|
|
70
|
+
const excluded = result.filter((r) => r.reason === 'character budget exceeded')
|
|
71
|
+
assert.ok(excluded.length >= 1)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('prioritizes always-on skills over regular skills', () => {
|
|
75
|
+
const skills: Record<string, Skill> = {
|
|
76
|
+
regular: makeSkill('regular'),
|
|
77
|
+
alwaysOn: makeSkill('alwaysOn', { always: true }),
|
|
78
|
+
}
|
|
79
|
+
const result = budgetSkillsForPrompt(skills, [])
|
|
80
|
+
const included = result.filter((r) => r.included)
|
|
81
|
+
// always-on should come before regular
|
|
82
|
+
const alwaysIdx = included.findIndex((r) => r.skill.name === 'alwaysOn')
|
|
83
|
+
const regularIdx = included.findIndex((r) => r.skill.name === 'regular')
|
|
84
|
+
assert.ok(alwaysIdx < regularIdx, 'always-on skill should be prioritized')
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('buildSkillPromptText', () => {
|
|
89
|
+
it('builds formatted prompt text', () => {
|
|
90
|
+
const skills: Record<string, Skill> = {
|
|
91
|
+
weather: makeSkill('weather', { content: 'Use wttr.in for weather queries.' }),
|
|
92
|
+
}
|
|
93
|
+
const text = buildSkillPromptText(skills, ['weather'])
|
|
94
|
+
assert.ok(text.includes('### weather'))
|
|
95
|
+
assert.ok(text.includes('Use wttr.in for weather queries.'))
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('returns empty string for no matching skills', () => {
|
|
99
|
+
const text = buildSkillPromptText({}, ['nonexistent'])
|
|
100
|
+
assert.equal(text, '')
|
|
101
|
+
})
|
|
102
|
+
})
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { Skill } from '@/types'
|
|
2
|
+
import { evaluateSkillEligibility } from './skill-eligibility'
|
|
3
|
+
|
|
4
|
+
/** Maximum number of skills injected into the system prompt. */
|
|
5
|
+
export const MAX_SKILLS_IN_PROMPT = 150
|
|
6
|
+
|
|
7
|
+
/** Maximum total characters of skill content in the system prompt. */
|
|
8
|
+
export const MAX_SKILLS_PROMPT_CHARS = 30_000
|
|
9
|
+
|
|
10
|
+
export interface BudgetedSkill {
|
|
11
|
+
skill: Skill
|
|
12
|
+
eligible: boolean
|
|
13
|
+
included: boolean
|
|
14
|
+
reason?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Filter and budget skills for prompt injection.
|
|
19
|
+
* Priority order:
|
|
20
|
+
* 1. Agent-bound skills (skillIds) — always first
|
|
21
|
+
* 2. `always: true` skills — global skills marked as always-on
|
|
22
|
+
* 3. Other eligible skills — sorted by name
|
|
23
|
+
*
|
|
24
|
+
* Skills are filtered by eligibility (requirements met) and then by budget
|
|
25
|
+
* (count and character limits).
|
|
26
|
+
*/
|
|
27
|
+
export function budgetSkillsForPrompt(
|
|
28
|
+
skills: Record<string, Skill>,
|
|
29
|
+
agentSkillIds: string[],
|
|
30
|
+
): BudgetedSkill[] {
|
|
31
|
+
const results: BudgetedSkill[] = []
|
|
32
|
+
const included: BudgetedSkill[] = []
|
|
33
|
+
let totalChars = 0
|
|
34
|
+
|
|
35
|
+
// Sort skills into priority buckets
|
|
36
|
+
const agentBound: Skill[] = []
|
|
37
|
+
const alwaysOn: Skill[] = []
|
|
38
|
+
const rest: Skill[] = []
|
|
39
|
+
|
|
40
|
+
for (const skillId of agentSkillIds) {
|
|
41
|
+
const skill = skills[skillId]
|
|
42
|
+
if (skill) agentBound.push(skill)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const skill of Object.values(skills)) {
|
|
46
|
+
if (agentSkillIds.includes(skill.name || '')) continue
|
|
47
|
+
if (skill.always) alwaysOn.push(skill)
|
|
48
|
+
else rest.push(skill)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Sort non-agent skills by name for deterministic ordering
|
|
52
|
+
alwaysOn.sort((a, b) => a.name.localeCompare(b.name))
|
|
53
|
+
rest.sort((a, b) => a.name.localeCompare(b.name))
|
|
54
|
+
|
|
55
|
+
const orderedSkills = [...agentBound, ...alwaysOn, ...rest]
|
|
56
|
+
|
|
57
|
+
for (const skill of orderedSkills) {
|
|
58
|
+
const eligibility = evaluateSkillEligibility(skill)
|
|
59
|
+
|
|
60
|
+
if (!eligibility.eligible) {
|
|
61
|
+
results.push({ skill, eligible: false, included: false, reason: eligibility.reasons[0] })
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const contentLen = (skill.content || '').length + (skill.name || '').length + 12 // "## Skill: " prefix
|
|
66
|
+
if (included.length >= MAX_SKILLS_IN_PROMPT) {
|
|
67
|
+
results.push({ skill, eligible: true, included: false, reason: 'skill count limit reached' })
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (totalChars + contentLen > MAX_SKILLS_PROMPT_CHARS) {
|
|
72
|
+
results.push({ skill, eligible: true, included: false, reason: 'character budget exceeded' })
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
totalChars += contentLen
|
|
77
|
+
const entry: BudgetedSkill = { skill, eligible: true, included: true }
|
|
78
|
+
results.push(entry)
|
|
79
|
+
included.push(entry)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return results
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Prescriptive skill adherence header.
|
|
87
|
+
* This tells the model exactly when and how to use skills — the key difference
|
|
88
|
+
* vs OpenClaw's superior skill following (1-2 tool calls vs 3-5).
|
|
89
|
+
*/
|
|
90
|
+
const SKILL_ADHERENCE_HEADER = `## Skills
|
|
91
|
+
|
|
92
|
+
Before responding, scan the skills listed below.
|
|
93
|
+
- If exactly one skill clearly applies to the user's request: follow its instructions directly.
|
|
94
|
+
- If multiple skills could apply: choose the most specific one, then follow it.
|
|
95
|
+
- If no skill applies: use your general tools (web search, shell, files, etc.) as normal.
|
|
96
|
+
|
|
97
|
+
When following a skill:
|
|
98
|
+
- Execute the skill's recommended commands or approach first, before falling back to generic tools.
|
|
99
|
+
- Prefer the skill's specific tool/command (e.g. \`curl wttr.in\` for weather) over generic web search.
|
|
100
|
+
- Aim for minimal tool calls — combine steps where possible.
|
|
101
|
+
- Respect rate limits: prefer fewer, larger operations over many small ones.
|
|
102
|
+
|
|
103
|
+
Available skills:`
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Build the prompt text for included skills, respecting budget limits.
|
|
107
|
+
* Returns the text to inject into the system prompt.
|
|
108
|
+
*/
|
|
109
|
+
export function buildSkillPromptText(
|
|
110
|
+
skills: Record<string, Skill>,
|
|
111
|
+
agentSkillIds: string[],
|
|
112
|
+
): string {
|
|
113
|
+
const budgeted = budgetSkillsForPrompt(skills, agentSkillIds)
|
|
114
|
+
const skillParts: string[] = []
|
|
115
|
+
|
|
116
|
+
for (const entry of budgeted) {
|
|
117
|
+
if (!entry.included) continue
|
|
118
|
+
if (!entry.skill.content) continue
|
|
119
|
+
skillParts.push(`### ${entry.skill.name}\n${entry.skill.content}`)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (skillParts.length === 0) return ''
|
|
123
|
+
|
|
124
|
+
return `${SKILL_ADHERENCE_HEADER}\n\n${skillParts.join('\n\n')}`
|
|
125
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { normalizeSkillPayload } from './skills-normalize'
|
|
4
|
+
|
|
5
|
+
test('normalizeSkillPayload parses openclaw frontmatter metadata', () => {
|
|
6
|
+
const normalized = normalizeSkillPayload({
|
|
7
|
+
content: `---
|
|
8
|
+
name: github-sync
|
|
9
|
+
description: Sync GitHub issues into tasks.
|
|
10
|
+
version: 1.2.3
|
|
11
|
+
metadata:
|
|
12
|
+
openclaw:
|
|
13
|
+
requires:
|
|
14
|
+
env:
|
|
15
|
+
- GITHUB_TOKEN
|
|
16
|
+
bins:
|
|
17
|
+
- curl
|
|
18
|
+
primaryEnv: GITHUB_TOKEN
|
|
19
|
+
homepage: https://example.com/github-sync
|
|
20
|
+
install:
|
|
21
|
+
- kind: brew
|
|
22
|
+
formula: gh
|
|
23
|
+
bins: [gh]
|
|
24
|
+
---
|
|
25
|
+
# Sync issues
|
|
26
|
+
|
|
27
|
+
Use the GitHub API.`,
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
assert.equal(normalized.name, 'github-sync')
|
|
31
|
+
assert.equal(normalized.description, 'Sync GitHub issues into tasks.')
|
|
32
|
+
assert.equal(normalized.version, '1.2.3')
|
|
33
|
+
assert.equal(normalized.primaryEnv, 'GITHUB_TOKEN')
|
|
34
|
+
assert.equal(normalized.homepage, 'https://example.com/github-sync')
|
|
35
|
+
assert.equal(normalized.sourceFormat, 'openclaw')
|
|
36
|
+
assert.match(normalized.content, /# Sync issues/)
|
|
37
|
+
assert.equal(normalized.skillRequirements?.env?.[0], 'GITHUB_TOKEN')
|
|
38
|
+
assert.equal(normalized.installOptions?.[0]?.kind, 'brew')
|
|
39
|
+
assert.equal(normalized.installOptions?.[0]?.bins?.[0], 'gh')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('normalizeSkillPayload flags undeclared env vars in skill content', () => {
|
|
43
|
+
const normalized = normalizeSkillPayload({
|
|
44
|
+
content: `---
|
|
45
|
+
name: env-check
|
|
46
|
+
description: Reads process env.
|
|
47
|
+
---
|
|
48
|
+
Run with \`process.env.GITHUB_TOKEN\` and \`process.env.OPENAI_API_KEY\`.`,
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
assert.equal(normalized.security?.level, 'high')
|
|
52
|
+
assert.ok(normalized.security?.missingDeclarations?.includes('GITHUB_TOKEN'))
|
|
53
|
+
assert.ok(normalized.security?.missingDeclarations?.includes('OPENAI_API_KEY'))
|
|
54
|
+
})
|