@teamclaws/teamclaw 2026.3.21 → 2026.3.25

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.
@@ -0,0 +1,302 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import type { PluginLogger } from "../../api.js";
5
+ import { normalizeRecommendedSkills } from "../roles.js";
6
+ import { resolveDefaultOpenClawWorkspaceDir } from "../openclaw-workspace.js";
7
+ import type { TaskAssignmentPayload, TaskExecutionEventInput } from "../types.js";
8
+
9
+ type SkillCli = "openclaw" | "clawhub";
10
+
11
+ type CommandResult = {
12
+ ok: boolean;
13
+ code: number;
14
+ stdout: string;
15
+ stderr: string;
16
+ };
17
+
18
+ export type SkillInstallResult = {
19
+ installed: string[];
20
+ skipped: string[];
21
+ failed: Array<{ skill: string; error: string }>;
22
+ events: TaskExecutionEventInput[];
23
+ };
24
+
25
+ function truncateOutput(value: string, limit = 400): string {
26
+ const trimmed = value.trim();
27
+ if (!trimmed) {
28
+ return "";
29
+ }
30
+ if (trimmed.length <= limit) {
31
+ return trimmed;
32
+ }
33
+ return `${trimmed.slice(0, limit).trimEnd()}…`;
34
+ }
35
+
36
+ function normalizeKey(value: string): string {
37
+ return value.trim().toLowerCase();
38
+ }
39
+
40
+ function isSkillSlug(value: string): boolean {
41
+ return /^[a-z0-9][a-z0-9._/-]*$/i.test(value) && !/\s/.test(value);
42
+ }
43
+
44
+ async function pathExists(filePath: string): Promise<boolean> {
45
+ try {
46
+ await fs.access(filePath);
47
+ return true;
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ async function runCommand(
54
+ command: string,
55
+ args: string[],
56
+ cwd: string,
57
+ ): Promise<CommandResult> {
58
+ return await new Promise<CommandResult>((resolve) => {
59
+ const child = spawn(command, args, {
60
+ cwd,
61
+ env: process.env,
62
+ stdio: ["ignore", "pipe", "pipe"],
63
+ });
64
+
65
+ let stdout = "";
66
+ let stderr = "";
67
+ child.stdout.on("data", (chunk) => {
68
+ stdout += chunk.toString();
69
+ });
70
+ child.stderr.on("data", (chunk) => {
71
+ stderr += chunk.toString();
72
+ });
73
+
74
+ child.on("error", (error) => {
75
+ resolve({
76
+ ok: false,
77
+ code: -1,
78
+ stdout,
79
+ stderr: error.message,
80
+ });
81
+ });
82
+
83
+ child.on("close", (code) => {
84
+ resolve({
85
+ ok: code === 0,
86
+ code: code ?? -1,
87
+ stdout,
88
+ stderr,
89
+ });
90
+ });
91
+ });
92
+ }
93
+
94
+ async function detectSkillCli(workspaceDir: string): Promise<SkillCli | null> {
95
+ const openclaw = await runCommand("openclaw", ["skills", "list", "--json"], workspaceDir);
96
+ if (openclaw.ok) {
97
+ return "openclaw";
98
+ }
99
+
100
+ const clawhub = await runCommand("clawhub", ["list"], workspaceDir);
101
+ if (clawhub.ok) {
102
+ return "clawhub";
103
+ }
104
+
105
+ return null;
106
+ }
107
+
108
+ function resolveSlugFromOpenClawSearch(requested: string, output: string): {
109
+ slug?: string;
110
+ summary: string;
111
+ } {
112
+ try {
113
+ const parsed = JSON.parse(output) as { results?: Array<{ slug?: string; displayName?: string; version?: string }> };
114
+ const results = Array.isArray(parsed.results) ? parsed.results : [];
115
+ const summary = results.slice(0, 3).map((entry) => {
116
+ const slug = typeof entry.slug === "string" ? entry.slug : "";
117
+ const displayName = typeof entry.displayName === "string" ? entry.displayName : slug;
118
+ const version = typeof entry.version === "string" ? `@${entry.version}` : "";
119
+ return slug ? `${slug}${version} (${displayName})` : displayName;
120
+ }).filter(Boolean).join("; ");
121
+
122
+ const exact = results.find((entry) => {
123
+ const slug = typeof entry.slug === "string" ? entry.slug : "";
124
+ const displayName = typeof entry.displayName === "string" ? entry.displayName : "";
125
+ return normalizeKey(slug) === normalizeKey(requested) || normalizeKey(displayName) === normalizeKey(requested);
126
+ });
127
+
128
+ return {
129
+ slug: typeof exact?.slug === "string" ? exact.slug : undefined,
130
+ summary: summary || "no exact ClawHub search match recorded",
131
+ };
132
+ } catch {
133
+ return {
134
+ summary: truncateOutput(output) || "search returned no structured results",
135
+ };
136
+ }
137
+ }
138
+
139
+ function buildInstalledSkillPath(workspaceDir: string, skillSlug: string): string {
140
+ return path.join(workspaceDir, "skills", skillSlug);
141
+ }
142
+
143
+ export async function installRecommendedSkills(
144
+ assignment: TaskAssignmentPayload,
145
+ logger: PluginLogger,
146
+ ): Promise<SkillInstallResult> {
147
+ const recommendedSkills = normalizeRecommendedSkills(assignment.recommendedSkills ?? []);
148
+ const workspaceDir = resolveDefaultOpenClawWorkspaceDir();
149
+ const events: TaskExecutionEventInput[] = [];
150
+ const installed: string[] = [];
151
+ const skipped: string[] = [];
152
+ const failed: Array<{ skill: string; error: string }> = [];
153
+
154
+ if (recommendedSkills.length === 0) {
155
+ return { installed, skipped, failed, events };
156
+ }
157
+
158
+ await fs.mkdir(path.join(workspaceDir, "skills"), { recursive: true });
159
+
160
+ const cli = await detectSkillCli(workspaceDir);
161
+ if (!cli) {
162
+ const message = "No skill installer CLI is available (expected openclaw or clawhub in PATH).";
163
+ logger.warn(`Worker: ${message}`);
164
+ events.push({
165
+ type: "error",
166
+ phase: "skills_preflight_unavailable",
167
+ source: "worker",
168
+ status: "running",
169
+ message,
170
+ });
171
+ return {
172
+ installed,
173
+ skipped,
174
+ failed: recommendedSkills.map((skill) => ({ skill, error: message })),
175
+ events,
176
+ };
177
+ }
178
+
179
+ events.push({
180
+ type: "lifecycle",
181
+ phase: "skills_preflight_started",
182
+ source: "worker",
183
+ status: "running",
184
+ message: `Preparing ${recommendedSkills.length} recommended skill(s) via ${cli}.`,
185
+ });
186
+
187
+ for (const requestedSkill of recommendedSkills) {
188
+ let resolvedSlug = isSkillSlug(requestedSkill) ? requestedSkill : undefined;
189
+ const installedPath = resolvedSlug ? buildInstalledSkillPath(workspaceDir, resolvedSlug) : "";
190
+
191
+ if (installedPath && await pathExists(installedPath)) {
192
+ skipped.push(requestedSkill);
193
+ events.push({
194
+ type: "lifecycle",
195
+ phase: "skill_install_skipped",
196
+ source: "worker",
197
+ status: "running",
198
+ message: `Skill ${requestedSkill} is already present in the workspace.`,
199
+ });
200
+ continue;
201
+ }
202
+
203
+ if (cli === "openclaw") {
204
+ const searchResult = await runCommand(
205
+ "openclaw",
206
+ ["skills", "search", requestedSkill, "--limit", "5", "--json"],
207
+ workspaceDir,
208
+ );
209
+ if (searchResult.ok) {
210
+ const resolved = resolveSlugFromOpenClawSearch(requestedSkill, searchResult.stdout);
211
+ if (!resolvedSlug && resolved.slug) {
212
+ resolvedSlug = resolved.slug;
213
+ }
214
+ events.push({
215
+ type: "progress",
216
+ phase: "skill_search_completed",
217
+ source: "worker",
218
+ status: "running",
219
+ message: `Skill search for "${requestedSkill}": ${resolved.summary}.`,
220
+ });
221
+ } else {
222
+ events.push({
223
+ type: "error",
224
+ phase: "skill_search_failed",
225
+ source: "worker",
226
+ status: "running",
227
+ message: `Skill search failed for "${requestedSkill}": ${truncateOutput(searchResult.stderr || searchResult.stdout) || `exit ${searchResult.code}`}.`,
228
+ });
229
+ }
230
+ } else {
231
+ const searchResult = await runCommand(
232
+ "clawhub",
233
+ ["search", requestedSkill, "--limit", "5"],
234
+ workspaceDir,
235
+ );
236
+ const searchMessage = searchResult.ok
237
+ ? truncateOutput(searchResult.stdout) || "search completed"
238
+ : truncateOutput(searchResult.stderr || searchResult.stdout) || `exit ${searchResult.code}`;
239
+ events.push({
240
+ type: searchResult.ok ? "progress" : "error",
241
+ phase: searchResult.ok ? "skill_search_completed" : "skill_search_failed",
242
+ source: "worker",
243
+ status: "running",
244
+ message: `Skill search for "${requestedSkill}": ${searchMessage}.`,
245
+ });
246
+ }
247
+
248
+ if (!resolvedSlug) {
249
+ const message = `No installable skill slug was resolved for "${requestedSkill}".`;
250
+ failed.push({ skill: requestedSkill, error: message });
251
+ events.push({
252
+ type: "error",
253
+ phase: "skill_install_failed",
254
+ source: "worker",
255
+ status: "running",
256
+ message,
257
+ });
258
+ continue;
259
+ }
260
+
261
+ const installResult = cli === "openclaw"
262
+ ? await runCommand("openclaw", ["skills", "install", resolvedSlug], workspaceDir)
263
+ : await runCommand("clawhub", ["install", resolvedSlug, "--workdir", workspaceDir, "--dir", "skills"], workspaceDir);
264
+
265
+ if (!installResult.ok) {
266
+ const message = truncateOutput(installResult.stderr || installResult.stdout) || `exit ${installResult.code}`;
267
+ failed.push({ skill: requestedSkill, error: message });
268
+ events.push({
269
+ type: "error",
270
+ phase: "skill_install_failed",
271
+ source: "worker",
272
+ status: "running",
273
+ message: `Failed to install ${resolvedSlug}: ${message}.`,
274
+ });
275
+ continue;
276
+ }
277
+
278
+ installed.push(resolvedSlug);
279
+ events.push({
280
+ type: "lifecycle",
281
+ phase: "skill_install_completed",
282
+ source: "worker",
283
+ status: "running",
284
+ message: `Installed recommended skill ${resolvedSlug}.`,
285
+ });
286
+ }
287
+
288
+ events.push({
289
+ type: failed.length > 0 ? "error" : "lifecycle",
290
+ phase: failed.length > 0 ? "skills_preflight_partial" : "skills_preflight_completed",
291
+ source: "worker",
292
+ status: "running",
293
+ message: `Skill preflight finished: ${installed.length} installed, ${skipped.length} already present, ${failed.length} failed.`,
294
+ });
295
+
296
+ return {
297
+ installed,
298
+ skipped,
299
+ failed,
300
+ events,
301
+ };
302
+ }