@elizaos/plugin-agent-skills 1.0.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.
@@ -0,0 +1,628 @@
1
+ /**
2
+ * Skill Dependency Installation Service
3
+ *
4
+ * Handles installation of skill dependencies using various package managers:
5
+ * - brew (Homebrew, macOS)
6
+ * - apt (apt-get, Debian/Ubuntu Linux)
7
+ * - node (npm/pnpm/bun)
8
+ * - pip (Python pip/pip3)
9
+ * - cargo (Rust cargo)
10
+ *
11
+ * @module services/install
12
+ */
13
+
14
+ import type { IAgentRuntime } from "@elizaos/core";
15
+ import type {
16
+ OttoInstallOption,
17
+ InstallDependencyOptions,
18
+ InstallDependencyResult,
19
+ InstallProgressCallback,
20
+ SkillEligibility,
21
+ } from "../types";
22
+
23
+ // ============================================================
24
+ // CONSTANTS
25
+ // ============================================================
26
+
27
+ /** Default timeout for installation commands (5 minutes) */
28
+ const DEFAULT_TIMEOUT = 300_000;
29
+
30
+ /** Node package managers in preference order */
31
+ const NODE_MANAGERS = ["bun", "pnpm", "npm", "yarn"] as const;
32
+
33
+ // ============================================================
34
+ // PLATFORM UTILITIES
35
+ // ============================================================
36
+
37
+ /**
38
+ * Detect the current operating system.
39
+ */
40
+ function detectPlatform(): "darwin" | "linux" | "windows" | "unknown" {
41
+ if (typeof process === "undefined") return "unknown";
42
+ const platform = process.platform;
43
+ if (platform === "darwin") return "darwin";
44
+ if (platform === "linux") return "linux";
45
+ if (platform === "win32") return "windows";
46
+ return "unknown";
47
+ }
48
+
49
+ /**
50
+ * Check if a binary exists in PATH using which/where.
51
+ */
52
+ async function binaryExists(name: string): Promise<boolean> {
53
+ try {
54
+ const { execSync } = await import("node:child_process");
55
+ const platform = detectPlatform();
56
+ const command = platform === "windows" ? `where ${name}` : `which ${name}`;
57
+ execSync(command, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
58
+ return true;
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ // ============================================================
65
+ // PACKAGE MANAGER DETECTION
66
+ // ============================================================
67
+
68
+ /**
69
+ * Get the preferred Node.js package manager.
70
+ *
71
+ * Order of preference:
72
+ * 1. OTTO_NODE_MANAGER env var if set
73
+ * 2. bun (fastest)
74
+ * 3. pnpm (efficient)
75
+ * 4. npm (universal fallback)
76
+ * 5. yarn
77
+ */
78
+ export async function getPreferredNodeManager(): Promise<string | null> {
79
+ // Check for explicit preference
80
+ const preferred = process.env.OTTO_NODE_MANAGER;
81
+ if (preferred && (await binaryExists(preferred))) {
82
+ return preferred;
83
+ }
84
+
85
+ // Check in preference order
86
+ for (const manager of NODE_MANAGERS) {
87
+ if (await binaryExists(manager)) {
88
+ return manager;
89
+ }
90
+ }
91
+
92
+ return null;
93
+ }
94
+
95
+ /**
96
+ * Check if Homebrew is available (macOS).
97
+ */
98
+ export async function isHomebrewAvailable(): Promise<boolean> {
99
+ return detectPlatform() === "darwin" && (await binaryExists("brew"));
100
+ }
101
+
102
+ /**
103
+ * Check if apt-get is available (Debian/Ubuntu).
104
+ */
105
+ export async function isAptAvailable(): Promise<boolean> {
106
+ return detectPlatform() === "linux" && (await binaryExists("apt-get"));
107
+ }
108
+
109
+ /**
110
+ * Check if pip is available.
111
+ */
112
+ export async function isPipAvailable(): Promise<boolean> {
113
+ return (await binaryExists("pip3")) || (await binaryExists("pip"));
114
+ }
115
+
116
+ /**
117
+ * Check if cargo is available.
118
+ */
119
+ export async function isCargoAvailable(): Promise<boolean> {
120
+ return binaryExists("cargo");
121
+ }
122
+
123
+ // ============================================================
124
+ // COMMAND BUILDERS
125
+ // ============================================================
126
+
127
+ /**
128
+ * Build the installation command for a given option.
129
+ */
130
+ function buildInstallCommand(option: OttoInstallOption): string | null {
131
+ switch (option.kind) {
132
+ case "brew":
133
+ return `brew install ${option.formula || option.package}`;
134
+
135
+ case "apt":
136
+ return `sudo apt-get install -y ${option.package}`;
137
+
138
+ case "node": {
139
+ // Will be resolved at runtime to user's preferred manager
140
+ const pkg = option.package;
141
+ return `__NODE_MANAGER__ install -g ${pkg}`;
142
+ }
143
+
144
+ case "pip":
145
+ return `pip3 install ${option.package}`;
146
+
147
+ case "cargo":
148
+ return `cargo install ${option.package}`;
149
+
150
+ case "manual":
151
+ // Manual installation - return instructions
152
+ return null;
153
+
154
+ default:
155
+ return null;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Resolve the __NODE_MANAGER__ placeholder in commands.
161
+ */
162
+ async function resolveNodeManager(command: string): Promise<string> {
163
+ if (!command.includes("__NODE_MANAGER__")) {
164
+ return command;
165
+ }
166
+
167
+ const manager = await getPreferredNodeManager();
168
+ if (!manager) {
169
+ throw new Error("No Node.js package manager found (tried bun, pnpm, npm, yarn)");
170
+ }
171
+
172
+ return command.replace("__NODE_MANAGER__", manager);
173
+ }
174
+
175
+ // ============================================================
176
+ // INSTALLATION EXECUTION
177
+ // ============================================================
178
+
179
+ /**
180
+ * Execute an installation command.
181
+ */
182
+ async function executeInstall(
183
+ command: string,
184
+ options: {
185
+ timeout?: number;
186
+ onProgress?: InstallProgressCallback;
187
+ dryRun?: boolean;
188
+ } = {},
189
+ ): Promise<{ success: boolean; error?: string; duration: number }> {
190
+ const { timeout = DEFAULT_TIMEOUT, onProgress, dryRun } = options;
191
+ const startTime = Date.now();
192
+
193
+ if (dryRun) {
194
+ onProgress?.({
195
+ phase: "complete",
196
+ progress: 100,
197
+ message: `[DRY RUN] Would execute: ${command}`,
198
+ });
199
+ return { success: true, duration: 0 };
200
+ }
201
+
202
+ try {
203
+ const { spawn } = await import("node:child_process");
204
+
205
+ onProgress?.({
206
+ phase: "installing",
207
+ progress: 10,
208
+ message: `Executing: ${command}`,
209
+ });
210
+
211
+ return await new Promise((resolve) => {
212
+ const child = spawn("sh", ["-c", command], {
213
+ stdio: ["pipe", "pipe", "pipe"],
214
+ timeout,
215
+ });
216
+
217
+ let stdout = "";
218
+ let stderr = "";
219
+
220
+ child.stdout?.on("data", (data) => {
221
+ stdout += data.toString();
222
+ onProgress?.({
223
+ phase: "installing",
224
+ progress: 50,
225
+ message: data.toString().trim().slice(0, 200),
226
+ });
227
+ });
228
+
229
+ child.stderr?.on("data", (data) => {
230
+ stderr += data.toString();
231
+ });
232
+
233
+ child.on("close", (code) => {
234
+ const duration = Date.now() - startTime;
235
+
236
+ if (code === 0) {
237
+ onProgress?.({
238
+ phase: "complete",
239
+ progress: 100,
240
+ message: "Installation completed successfully",
241
+ });
242
+ resolve({ success: true, duration });
243
+ } else {
244
+ const error = stderr || stdout || `Process exited with code ${code}`;
245
+ onProgress?.({
246
+ phase: "error",
247
+ message: "Installation failed",
248
+ error,
249
+ });
250
+ resolve({ success: false, error, duration });
251
+ }
252
+ });
253
+
254
+ child.on("error", (err) => {
255
+ const duration = Date.now() - startTime;
256
+ const error = err.message;
257
+ onProgress?.({
258
+ phase: "error",
259
+ message: "Installation failed",
260
+ error,
261
+ });
262
+ resolve({ success: false, error, duration });
263
+ });
264
+ });
265
+ } catch (error) {
266
+ const duration = Date.now() - startTime;
267
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
268
+ onProgress?.({
269
+ phase: "error",
270
+ message: "Installation failed",
271
+ error: errorMessage,
272
+ });
273
+ return { success: false, error: errorMessage, duration };
274
+ }
275
+ }
276
+
277
+ // ============================================================
278
+ // MAIN INSTALLATION FUNCTION
279
+ // ============================================================
280
+
281
+ /**
282
+ * Install a skill dependency using the specified option.
283
+ *
284
+ * @param options - Installation options including the install option to use
285
+ * @returns Installation result
286
+ *
287
+ * @example
288
+ * ```ts
289
+ * const result = await installSkillDependency({
290
+ * option: { id: "brew", kind: "brew", formula: "jq" },
291
+ * onProgress: (event) => console.log(event.message),
292
+ * });
293
+ * ```
294
+ */
295
+ export async function installSkillDependency(
296
+ options: InstallDependencyOptions,
297
+ ): Promise<InstallDependencyResult> {
298
+ const { option, onProgress, dryRun, timeout } = options;
299
+
300
+ onProgress?.({
301
+ phase: "installing",
302
+ progress: 0,
303
+ message: `Preparing to install via ${option.kind}...`,
304
+ });
305
+
306
+ // Check if the installation kind is available
307
+ const platform = detectPlatform();
308
+
309
+ switch (option.kind) {
310
+ case "brew":
311
+ if (!(await isHomebrewAvailable())) {
312
+ return {
313
+ success: false,
314
+ option,
315
+ error: "Homebrew is not available (macOS only)",
316
+ };
317
+ }
318
+ break;
319
+
320
+ case "apt":
321
+ if (!(await isAptAvailable())) {
322
+ return {
323
+ success: false,
324
+ option,
325
+ error: "apt-get is not available (Debian/Ubuntu only)",
326
+ };
327
+ }
328
+ break;
329
+
330
+ case "node":
331
+ if (!(await getPreferredNodeManager())) {
332
+ return {
333
+ success: false,
334
+ option,
335
+ error: "No Node.js package manager found",
336
+ };
337
+ }
338
+ break;
339
+
340
+ case "pip":
341
+ if (!(await isPipAvailable())) {
342
+ return {
343
+ success: false,
344
+ option,
345
+ error: "pip/pip3 is not available",
346
+ };
347
+ }
348
+ break;
349
+
350
+ case "cargo":
351
+ if (!(await isCargoAvailable())) {
352
+ return {
353
+ success: false,
354
+ option,
355
+ error: "cargo is not available (Rust)",
356
+ };
357
+ }
358
+ break;
359
+
360
+ case "manual":
361
+ return {
362
+ success: false,
363
+ option,
364
+ error: `Manual installation required: ${option.label || "See skill documentation"}`,
365
+ };
366
+ }
367
+
368
+ // Build and execute the command
369
+ let command = buildInstallCommand(option);
370
+ if (!command) {
371
+ return {
372
+ success: false,
373
+ option,
374
+ error: `Cannot build command for install kind: ${option.kind}`,
375
+ };
376
+ }
377
+
378
+ // Resolve node manager placeholder
379
+ try {
380
+ command = await resolveNodeManager(command);
381
+ } catch (error) {
382
+ return {
383
+ success: false,
384
+ option,
385
+ error: error instanceof Error ? error.message : "Failed to resolve node manager",
386
+ };
387
+ }
388
+
389
+ const result = await executeInstall(command, {
390
+ timeout,
391
+ onProgress,
392
+ dryRun,
393
+ });
394
+
395
+ return {
396
+ ...result,
397
+ option,
398
+ command,
399
+ };
400
+ }
401
+
402
+ /**
403
+ * Find the best available install option for the current platform.
404
+ *
405
+ * @param options - Available install options
406
+ * @returns Best option for current platform, or null if none available
407
+ */
408
+ export async function findBestInstallOption(
409
+ options: OttoInstallOption[],
410
+ ): Promise<OttoInstallOption | null> {
411
+ const platform = detectPlatform();
412
+
413
+ // Platform preference order
414
+ const preferenceOrder: Array<OttoInstallOption["kind"]> = [];
415
+
416
+ if (platform === "darwin") {
417
+ preferenceOrder.push("brew", "node", "pip", "cargo");
418
+ } else if (platform === "linux") {
419
+ preferenceOrder.push("apt", "node", "pip", "cargo");
420
+ } else {
421
+ preferenceOrder.push("node", "pip", "cargo");
422
+ }
423
+
424
+ for (const kind of preferenceOrder) {
425
+ const option = options.find((o) => o.kind === kind);
426
+ if (option) {
427
+ // Verify the package manager is available
428
+ switch (kind) {
429
+ case "brew":
430
+ if (await isHomebrewAvailable()) return option;
431
+ break;
432
+ case "apt":
433
+ if (await isAptAvailable()) return option;
434
+ break;
435
+ case "node":
436
+ if (await getPreferredNodeManager()) return option;
437
+ break;
438
+ case "pip":
439
+ if (await isPipAvailable()) return option;
440
+ break;
441
+ case "cargo":
442
+ if (await isCargoAvailable()) return option;
443
+ break;
444
+ }
445
+ }
446
+ }
447
+
448
+ // Fall back to manual if available
449
+ const manual = options.find((o) => o.kind === "manual");
450
+ return manual || null;
451
+ }
452
+
453
+ /**
454
+ * Get installation options that are available on the current platform.
455
+ *
456
+ * @param options - All install options
457
+ * @returns Options available on current platform
458
+ */
459
+ export async function getAvailableInstallOptions(
460
+ options: OttoInstallOption[],
461
+ ): Promise<OttoInstallOption[]> {
462
+ const available: OttoInstallOption[] = [];
463
+
464
+ for (const option of options) {
465
+ switch (option.kind) {
466
+ case "brew":
467
+ if (await isHomebrewAvailable()) available.push(option);
468
+ break;
469
+ case "apt":
470
+ if (await isAptAvailable()) available.push(option);
471
+ break;
472
+ case "node":
473
+ if (await getPreferredNodeManager()) available.push(option);
474
+ break;
475
+ case "pip":
476
+ if (await isPipAvailable()) available.push(option);
477
+ break;
478
+ case "cargo":
479
+ if (await isCargoAvailable()) available.push(option);
480
+ break;
481
+ case "manual":
482
+ available.push(option);
483
+ break;
484
+ }
485
+ }
486
+
487
+ return available;
488
+ }
489
+
490
+ // ============================================================
491
+ // SKILL-LEVEL INSTALLATION
492
+ // ============================================================
493
+
494
+ /**
495
+ * Install all required dependencies for a skill.
496
+ *
497
+ * @param skill - The skill with metadata containing install options
498
+ * @param options - Installation options
499
+ * @returns Array of installation results
500
+ */
501
+ export async function installSkillDependencies(
502
+ skill: {
503
+ slug: string;
504
+ frontmatter: {
505
+ metadata?: {
506
+ otto?: { install?: OttoInstallOption[] };
507
+ };
508
+ };
509
+ },
510
+ options: {
511
+ onProgress?: InstallProgressCallback;
512
+ dryRun?: boolean;
513
+ } = {},
514
+ ): Promise<InstallDependencyResult[]> {
515
+ const metadata = skill.frontmatter.metadata?.otto;
516
+ const installOptions = metadata?.install || [];
517
+
518
+ if (installOptions.length === 0) {
519
+ return [];
520
+ }
521
+
522
+ const results: InstallDependencyResult[] = [];
523
+ const { onProgress, dryRun } = options;
524
+
525
+ // Group options by the binaries they provide
526
+ const binsByOption = new Map<string, OttoInstallOption[]>();
527
+
528
+ for (const option of installOptions) {
529
+ const bins = option.bins || [];
530
+ for (const bin of bins) {
531
+ if (!binsByOption.has(bin)) {
532
+ binsByOption.set(bin, []);
533
+ }
534
+ binsByOption.get(bin)!.push(option);
535
+ }
536
+ }
537
+
538
+ // For each required binary, find the best option and install
539
+ for (const [bin, opts] of binsByOption) {
540
+ // Check if already installed
541
+ if (await binaryExists(bin)) {
542
+ onProgress?.({
543
+ phase: "complete",
544
+ message: `${bin} is already installed`,
545
+ });
546
+ continue;
547
+ }
548
+
549
+ // Find best option
550
+ const bestOption = await findBestInstallOption(opts);
551
+ if (!bestOption) {
552
+ results.push({
553
+ success: false,
554
+ option: opts[0],
555
+ error: `No available installation method for ${bin}`,
556
+ });
557
+ continue;
558
+ }
559
+
560
+ // Install
561
+ const result = await installSkillDependency({
562
+ option: bestOption,
563
+ onProgress,
564
+ dryRun,
565
+ });
566
+
567
+ results.push(result);
568
+
569
+ // If failed, don't continue installing other deps
570
+ if (!result.success) {
571
+ break;
572
+ }
573
+ }
574
+
575
+ return results;
576
+ }
577
+
578
+ /**
579
+ * Get a summary of what would be installed for a skill.
580
+ */
581
+ export async function getInstallPlan(
582
+ skill: {
583
+ slug: string;
584
+ frontmatter: {
585
+ metadata?: {
586
+ otto?: { install?: OttoInstallOption[]; requires?: { bins?: string[] } };
587
+ };
588
+ };
589
+ },
590
+ ): Promise<{
591
+ requiredBins: string[];
592
+ missingBins: string[];
593
+ availableOptions: OttoInstallOption[];
594
+ recommendedOptions: OttoInstallOption[];
595
+ }> {
596
+ const metadata = skill.frontmatter.metadata?.otto;
597
+
598
+ const requiredBins = metadata?.requires?.bins || [];
599
+ const installOptions = metadata?.install || [];
600
+
601
+ // Check which bins are missing
602
+ const missingBins: string[] = [];
603
+ for (const bin of requiredBins) {
604
+ if (!(await binaryExists(bin))) {
605
+ missingBins.push(bin);
606
+ }
607
+ }
608
+
609
+ // Get available options
610
+ const availableOptions = await getAvailableInstallOptions(installOptions);
611
+
612
+ // Get recommended options (one per missing binary)
613
+ const recommendedOptions: OttoInstallOption[] = [];
614
+ for (const bin of missingBins) {
615
+ const opts = installOptions.filter((o) => o.bins?.includes(bin));
616
+ const best = await findBestInstallOption(opts);
617
+ if (best && !recommendedOptions.includes(best)) {
618
+ recommendedOptions.push(best);
619
+ }
620
+ }
621
+
622
+ return {
623
+ requiredBins,
624
+ missingBins,
625
+ availableOptions,
626
+ recommendedOptions,
627
+ };
628
+ }