@zeyiy/openclaw-channel 0.3.6 → 0.3.8

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/dist/portal.js CHANGED
@@ -5,8 +5,14 @@
5
5
  * Handles requests from portal to manage local openclaw agents, files, and models.
6
6
  * Lifecycle is tied to the OpenIM account: starts/stops alongside the account.
7
7
  */
8
- import { readFile, writeFile, stat, mkdir } from "node:fs/promises";
9
- import { resolve, join, dirname } from "node:path";
8
+ import { readFile, writeFile, stat, mkdir, unlink, rm } from "node:fs/promises";
9
+ import { readdirSync, realpathSync, existsSync, readFileSync, statSync } from "node:fs";
10
+ import { resolve, join, dirname, basename } from "node:path";
11
+ import { execFile } from "node:child_process";
12
+ import { promisify } from "node:util";
13
+ import { tmpdir } from "node:os";
14
+ import { normalizeAgentId, resolveDefaultAgentId, resolveUserPath, resolveAgentWorkspaceDir, isPathSafe, resolveStateDir, resolveOpenClawConfigPath, hasBinarySync, resolveBundledSkillsDir, resolveCronStorePath, resolveClawHubBaseUrl, loadFullConfig, clearDiskConfigCache, isEnvSatisfied, } from "./paths";
15
+ import { fetchClawHub, downloadArchive, extractTarGz } from "./network";
10
16
  const bridges = new Map();
11
17
  const RECONNECT_BASE_MS = 2000;
12
18
  const RECONNECT_MAX_MS = 60000;
@@ -31,50 +37,6 @@ function portalLog(api, level, msg) {
31
37
  function getConfig(api) {
32
38
  return api.config ?? globalThis.__openimGatewayConfig ?? {};
33
39
  }
34
- function normalizeAgentId(value) {
35
- const trimmed = (value ?? "").trim();
36
- if (!trimmed)
37
- return "main";
38
- return trimmed.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+/, "").replace(/-+$/, "").slice(0, 64) || "main";
39
- }
40
- function resolveDefaultAgentId(cfg) {
41
- const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
42
- if (agents.length === 0)
43
- return "main";
44
- const defaults = agents.filter((a) => a?.default);
45
- const chosen = (defaults[0] ?? agents[0])?.id?.trim();
46
- return normalizeAgentId(chosen || "main");
47
- }
48
- /** Expand leading ~ to $HOME, then resolve to absolute path. */
49
- function resolveUserPath(p) {
50
- const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
51
- if (p.startsWith("~/") || p === "~") {
52
- return resolve(home, p.slice(2));
53
- }
54
- return resolve(p);
55
- }
56
- function resolveAgentWorkspaceDir(cfg, agentId) {
57
- const id = normalizeAgentId(agentId);
58
- const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
59
- const entry = agents.find((a) => a?.id && normalizeAgentId(a.id) === id);
60
- if (entry?.workspace?.trim())
61
- return resolveUserPath(entry.workspace.trim());
62
- const fallback = cfg.agents?.defaults?.workspace?.trim();
63
- const defaultId = resolveDefaultAgentId(cfg);
64
- const home = process.env.HOME ?? process.cwd();
65
- if (id === defaultId) {
66
- if (fallback)
67
- return resolveUserPath(fallback);
68
- return resolve(home, ".openclaw", "workspace");
69
- }
70
- if (fallback)
71
- return join(resolveUserPath(fallback), id);
72
- return resolve(home, ".openclaw", `workspace-${id}`);
73
- }
74
- function isPathSafe(workspaceRoot, targetPath) {
75
- const resolved = resolve(workspaceRoot, targetPath);
76
- return resolved.startsWith(workspaceRoot + "/") || resolved === workspaceRoot;
77
- }
78
40
  async function statFileSafely(filePath) {
79
41
  try {
80
42
  const s = await stat(filePath);
@@ -119,12 +81,13 @@ function handleModelsList(api, params) {
119
81
  }
120
82
  }
121
83
  }
122
- // Resolve active model for the requested agent
84
+ // Resolve active model for the requested agent (read from disk for latest state)
123
85
  const rawAgentId = String(params.agentId ?? "").trim();
124
86
  let activeModelId;
125
87
  if (rawAgentId) {
88
+ const fullCfg = loadFullConfig();
126
89
  const agentId = normalizeAgentId(rawAgentId);
127
- const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
90
+ const agents = Array.isArray(fullCfg.agents?.list) ? fullCfg.agents.list : [];
128
91
  const entry = agents.find((a) => a?.id && normalizeAgentId(a.id) === agentId);
129
92
  if (entry?.model) {
130
93
  // model can be a string like "deepminer/claude-sonnet-4-6" or an object { primary: "..." }
@@ -375,150 +338,999 @@ function handleBotAgentGet(api, accountId) {
375
338
  return { agentId: defaultId, ...(defaultEntry?.name ? { name: defaultEntry.name } : {}) };
376
339
  }
377
340
  /**
378
- * tools.catalog — return the runtime tool catalog from config.
379
- * Reads tools from agents' TOOLS.md references and registered plugin tools.
341
+ * tools.catalog — relay to gateway for the live runtime tool catalog.
342
+ *
343
+ * Gateway returns: { agentId, profiles, groups: [{ id, label, source, tools: [...] }] }
344
+ */
345
+ // ---------------------------------------------------------------------------
346
+ // tools.catalog: static core tool definitions (mirrors gateway's CORE_TOOL_DEFINITIONS)
347
+ // ---------------------------------------------------------------------------
348
+ const CORE_TOOL_SECTION_ORDER = [
349
+ { id: "fs", label: "Files" },
350
+ { id: "runtime", label: "Runtime" },
351
+ { id: "web", label: "Web" },
352
+ { id: "memory", label: "Memory" },
353
+ { id: "sessions", label: "Sessions" },
354
+ { id: "ui", label: "UI" },
355
+ { id: "messaging", label: "Messaging" },
356
+ { id: "automation", label: "Automation" },
357
+ { id: "nodes", label: "Nodes" },
358
+ { id: "agents", label: "Agents" },
359
+ { id: "media", label: "Media" },
360
+ ];
361
+ const CORE_TOOL_DEFINITIONS = [
362
+ { id: "read", label: "read", description: "Read file contents", sectionId: "fs", profiles: ["coding"] },
363
+ { id: "write", label: "write", description: "Create or overwrite files", sectionId: "fs", profiles: ["coding"] },
364
+ { id: "edit", label: "edit", description: "Make precise edits", sectionId: "fs", profiles: ["coding"] },
365
+ { id: "apply_patch", label: "apply_patch", description: "Patch files", sectionId: "fs", profiles: ["coding"] },
366
+ { id: "exec", label: "exec", description: "Run shell commands", sectionId: "runtime", profiles: ["coding"] },
367
+ { id: "process", label: "process", description: "Manage processes", sectionId: "runtime", profiles: ["coding"] },
368
+ { id: "code_execution", label: "code_execution", description: "Run sandboxed remote analysis", sectionId: "runtime", profiles: ["coding"] },
369
+ { id: "web_search", label: "web_search", description: "Search the web", sectionId: "web", profiles: ["coding"] },
370
+ { id: "web_fetch", label: "web_fetch", description: "Fetch web content", sectionId: "web", profiles: ["coding"] },
371
+ { id: "x_search", label: "x_search", description: "Search X posts", sectionId: "web", profiles: ["coding"] },
372
+ { id: "memory_search", label: "memory_search", description: "Semantic search", sectionId: "memory", profiles: ["coding"] },
373
+ { id: "memory_get", label: "memory_get", description: "Read memory files", sectionId: "memory", profiles: ["coding"] },
374
+ { id: "sessions_list", label: "sessions_list", description: "List sessions", sectionId: "sessions", profiles: ["coding", "messaging"] },
375
+ { id: "sessions_history", label: "sessions_history", description: "View session history", sectionId: "sessions", profiles: ["coding", "messaging"] },
376
+ { id: "sessions_send", label: "sessions_send", description: "Send to session", sectionId: "sessions", profiles: ["coding", "messaging"] },
377
+ { id: "sessions_spawn", label: "sessions_spawn", description: "Spawn new session", sectionId: "sessions", profiles: ["coding"] },
378
+ { id: "sessions_yield", label: "sessions_yield", description: "End turn to receive sub-agent results", sectionId: "sessions", profiles: ["coding"] },
379
+ { id: "subagents", label: "subagents", description: "Manage sub-agents", sectionId: "sessions", profiles: ["coding"] },
380
+ { id: "session_status", label: "session_status", description: "Session status", sectionId: "sessions", profiles: ["minimal", "coding", "messaging"] },
381
+ { id: "browser", label: "browser", description: "Control web browser", sectionId: "ui", profiles: [] },
382
+ { id: "canvas", label: "canvas", description: "Control canvases", sectionId: "ui", profiles: [] },
383
+ { id: "message", label: "message", description: "Send messages", sectionId: "messaging", profiles: ["messaging"] },
384
+ { id: "cron", label: "cron", description: "Schedule cron jobs", sectionId: "automation", profiles: ["coding"] },
385
+ { id: "gateway", label: "gateway", description: "Gateway control", sectionId: "automation", profiles: [] },
386
+ { id: "nodes", label: "nodes", description: "Nodes + devices", sectionId: "nodes", profiles: [] },
387
+ { id: "agents_list", label: "agents_list", description: "List agents", sectionId: "agents", profiles: [] },
388
+ { id: "update_plan", label: "update_plan", description: "Update plan", sectionId: "agents", profiles: ["coding"] },
389
+ { id: "image", label: "image", description: "Image understanding", sectionId: "media", profiles: ["coding"] },
390
+ { id: "image_generate", label: "image_generate", description: "Image generation", sectionId: "media", profiles: ["coding"] },
391
+ { id: "music_generate", label: "music_generate", description: "Music generation", sectionId: "media", profiles: ["coding"] },
392
+ { id: "video_generate", label: "video_generate", description: "Video generation", sectionId: "media", profiles: ["coding"] },
393
+ { id: "tts", label: "tts", description: "Text-to-speech conversion", sectionId: "media", profiles: [] },
394
+ ];
395
+ const TOOL_CATALOG_PROFILES = [
396
+ { id: "minimal", label: "Minimal" },
397
+ { id: "coding", label: "Coding" },
398
+ { id: "messaging", label: "Messaging" },
399
+ { id: "full", label: "Full" },
400
+ ];
401
+ /**
402
+ * tools.catalog — mirrors gateway's buildToolsCatalogResult.
403
+ *
404
+ * Core groups: hardcoded static CORE_TOOL_DEFINITIONS (identical to gateway source).
405
+ * Plugin groups: read from config.plugins.entries (enabled plugins).
406
+ * Note: individual plugin tool names require loading plugin factories (runtime-only),
407
+ * so plugin groups are listed without their tools array — matching what config can provide.
380
408
  */
381
- function handleToolsCatalog(api) {
409
+ function handleToolsCatalog(api, params) {
382
410
  const cfg = getConfig(api);
383
- const tools = [];
384
- // Core tools from config.tools section
385
- const configTools = cfg.tools;
386
- if (configTools && typeof configTools === "object") {
387
- for (const [name, toolCfg] of Object.entries(configTools)) {
388
- if (!name || name.startsWith("_"))
411
+ const rawAgentId = String(params.agentId ?? "").trim();
412
+ const agentId = rawAgentId ? normalizeAgentId(rawAgentId) : resolveDefaultAgentId(cfg);
413
+ // Build core groups (identical to gateway's buildCoreGroups)
414
+ const coreGroups = CORE_TOOL_SECTION_ORDER.map((section) => ({
415
+ id: section.id,
416
+ label: section.label,
417
+ source: "core",
418
+ tools: CORE_TOOL_DEFINITIONS
419
+ .filter((tool) => tool.sectionId === section.id)
420
+ .map((tool) => ({
421
+ id: tool.id,
422
+ label: tool.label,
423
+ description: tool.description,
424
+ source: "core",
425
+ defaultProfiles: tool.profiles,
426
+ })),
427
+ })).filter((section) => section.tools.length > 0);
428
+ // Build plugin groups from config.plugins.entries (best-effort, no factory loading)
429
+ const pluginEntries = cfg.plugins?.entries;
430
+ const pluginGroups = [];
431
+ if (pluginEntries && typeof pluginEntries === "object") {
432
+ for (const [pluginId, entry] of Object.entries(pluginEntries)) {
433
+ // Skip explicitly disabled plugins
434
+ if (entry?.enabled === false)
389
435
  continue;
390
- tools.push({
391
- name,
392
- description: toolCfg?.description ?? "",
393
- source: "core",
394
- optional: Boolean(toolCfg?.optional),
436
+ pluginGroups.push({
437
+ id: `plugin:${pluginId}`,
438
+ label: pluginId,
439
+ source: "plugin",
440
+ pluginId,
441
+ tools: [],
395
442
  });
396
443
  }
444
+ pluginGroups.sort((a, b) => a.label.localeCompare(b.label));
397
445
  }
398
- // Plugin-registered tools (from channel registrations)
399
- const plugins = cfg.plugins;
400
- if (plugins && typeof plugins === "object") {
401
- for (const [pluginId, pluginCfg] of Object.entries(plugins)) {
402
- if (!pluginId)
403
- continue;
404
- const pluginTools = pluginCfg?.tools;
405
- if (Array.isArray(pluginTools)) {
406
- for (const t of pluginTools) {
407
- const tName = typeof t === "string" ? t : t?.name;
408
- if (!tName)
409
- continue;
410
- tools.push({
411
- name: String(tName),
412
- description: typeof t === "object" ? t?.description ?? "" : "",
413
- source: "plugin",
414
- pluginId,
415
- optional: typeof t === "object" ? Boolean(t?.optional) : false,
416
- });
446
+ return {
447
+ agentId,
448
+ profiles: TOOL_CATALOG_PROFILES,
449
+ groups: [...coreGroups, ...pluginGroups],
450
+ };
451
+ }
452
+ // ---------------------------------------------------------------------------
453
+ // Skills: file-based helpers (mirrors openclaw gateway internals)
454
+ // ---------------------------------------------------------------------------
455
+ /**
456
+ * Relaxed JSON5 parser: removes trailing commas before } or ].
457
+ * Used for SKILL.md metadata blocks which use JSON5 syntax (trailing commas,
458
+ * multi-line objects) but do NOT contain // or /* comments in practice.
459
+ *
460
+ * NOTE: We intentionally do NOT strip // comments here because that regex
461
+ * would incorrectly match URLs like "https://..." inside string values.
462
+ */
463
+ function parseJson5Relaxed(text) {
464
+ // Remove trailing commas before } or ] (the main JSON5 feature in SKILL.md)
465
+ const s = text.replace(/,(\s*[}\]])/g, "$1");
466
+ return JSON.parse(s);
467
+ }
468
+ /**
469
+ * Extract the raw YAML block between --- delimiters.
470
+ * Mirrors gateway's extractFrontmatterBlock.
471
+ */
472
+ function extractFrontmatterBlock(content) {
473
+ const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
474
+ if (!normalized.startsWith("---"))
475
+ return undefined;
476
+ const endIndex = normalized.indexOf("\n---", 3);
477
+ if (endIndex === -1)
478
+ return undefined;
479
+ return normalized.slice(4, endIndex);
480
+ }
481
+ /**
482
+ * Parse SKILL.md frontmatter to plain string key-value pairs.
483
+ * Handles both single-line and multi-line (indented) values.
484
+ * Mirrors gateway's parseLineFrontmatter + lineFrontmatterToPlain.
485
+ */
486
+ function parseSkillFrontmatter(content) {
487
+ const block = extractFrontmatterBlock(content);
488
+ if (!block)
489
+ return {};
490
+ const result = {};
491
+ const lines = block.split("\n");
492
+ let i = 0;
493
+ while (i < lines.length) {
494
+ const match = lines[i].match(/^([\w-]+):\s*(.*)$/);
495
+ if (!match) {
496
+ i++;
497
+ continue;
498
+ }
499
+ const key = match[1];
500
+ const inlineValue = match[2].trim();
501
+ // Handle YAML multi-line indicators (| or >) and empty inline values
502
+ const isMultilineIndicator = inlineValue === "|" || inlineValue === ">" || inlineValue === "|-" || inlineValue === ">-";
503
+ if ((!inlineValue || isMultilineIndicator) && i + 1 < lines.length) {
504
+ const nextLine = lines[i + 1];
505
+ if (nextLine.startsWith(" ") || nextLine.startsWith("\t")) {
506
+ // Multi-line: collect all indented lines
507
+ const valueLines = [];
508
+ let j = i + 1;
509
+ while (j < lines.length && (lines[j].startsWith(" ") || lines[j].startsWith("\t") || lines[j] === "")) {
510
+ valueLines.push(lines[j]);
511
+ j++;
417
512
  }
513
+ const value = valueLines.join("\n").trim();
514
+ if (value)
515
+ result[key] = value;
516
+ i = j;
517
+ continue;
418
518
  }
419
519
  }
520
+ if (inlineValue && !isMultilineIndicator) {
521
+ result[key] = inlineValue.replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1");
522
+ }
523
+ i++;
420
524
  }
421
- // Also include OpenIM channel tools registered by this plugin
422
- const openimTools = ["openim_send_text", "openim_send_image", "openim_send_file", "openim_send_video"];
423
- for (const name of openimTools) {
424
- tools.push({ name, description: `OpenIM: ${name.replace("openim_", "")}`, source: "plugin", pluginId: "openim" });
425
- }
426
- return { tools };
525
+ return result;
427
526
  }
428
527
  /**
429
- * skills.status return the visible skill list for an agent.
430
- * Reads installed plugins/skills from config and reports their status.
528
+ * Extract openclaw manifest from parsed frontmatter.
529
+ * The metadata field is a JSON5 object with key "openclaw".
530
+ * Mirrors gateway's resolveOpenClawManifestBlock + resolveOpenClawMetadata.
431
531
  */
432
- function handleSkillsStatus(api, params) {
433
- const cfg = getConfig(api);
434
- const skills = [];
435
- // Plugins as skills
436
- const plugins = cfg.plugins;
437
- if (plugins && typeof plugins === "object") {
438
- for (const [pluginId, pluginCfg] of Object.entries(plugins)) {
439
- if (!pluginId)
532
+ function resolveOpenClawManifest(frontmatter) {
533
+ const metadataRaw = frontmatter.metadata;
534
+ if (!metadataRaw)
535
+ return undefined;
536
+ let manifest;
537
+ try {
538
+ const parsed = parseJson5Relaxed(metadataRaw);
539
+ if (!parsed || typeof parsed !== "object")
540
+ return undefined;
541
+ // Try known openclaw manifest keys
542
+ manifest = parsed.openclaw ?? parsed["openclaw-agent"] ?? parsed["claude-code"];
543
+ if (!manifest && typeof parsed === "object") {
544
+ // If no known wrapper key, check if the parsed object itself looks like a manifest
545
+ const keys = Object.keys(parsed);
546
+ if (keys.some(k => ["requires", "install", "always", "emoji"].includes(k)))
547
+ manifest = parsed;
548
+ }
549
+ if (!manifest || typeof manifest !== "object")
550
+ return undefined;
551
+ }
552
+ catch {
553
+ return undefined;
554
+ }
555
+ const normalizeStrList = (v) => {
556
+ if (!v)
557
+ return [];
558
+ if (Array.isArray(v))
559
+ return v.map(String).filter(Boolean);
560
+ if (typeof v === "string" && v.trim())
561
+ return [v.trim()];
562
+ return [];
563
+ };
564
+ const requiresRaw = manifest.requires;
565
+ const requires = {};
566
+ if (requiresRaw && typeof requiresRaw === "object") {
567
+ requires.bins = normalizeStrList(requiresRaw.bins);
568
+ requires.anyBins = normalizeStrList(requiresRaw.anyBins);
569
+ requires.env = normalizeStrList(requiresRaw.env);
570
+ requires.config = normalizeStrList(requiresRaw.config);
571
+ }
572
+ const install = [];
573
+ if (Array.isArray(manifest.install)) {
574
+ for (const spec of manifest.install) {
575
+ if (!spec || typeof spec !== "object")
440
576
  continue;
441
- const enabled = pluginCfg?.enabled !== false;
442
- skills.push({
443
- name: pluginId,
444
- description: pluginCfg?.description ?? "",
445
- installed: true,
446
- enabled,
447
- configCheck: enabled ? "ok" : "missing",
577
+ install.push({
578
+ id: typeof spec.id === "string" ? spec.id : undefined,
579
+ kind: typeof spec.kind === "string" ? spec.kind : typeof spec.type === "string" ? spec.type : undefined,
580
+ bins: normalizeStrList(spec.bins),
581
+ label: typeof spec.label === "string" ? spec.label : undefined,
582
+ formula: typeof spec.formula === "string" ? spec.formula : undefined,
583
+ package: typeof spec.package === "string" ? spec.package : undefined,
584
+ module: typeof spec.module === "string" ? spec.module : undefined,
585
+ os: normalizeStrList(spec.os),
448
586
  });
449
587
  }
450
588
  }
451
- // Channels as skills
452
- const channels = cfg.channels;
453
- if (channels && typeof channels === "object") {
454
- for (const [chanId, chanCfg] of Object.entries(channels)) {
455
- if (!chanId)
589
+ return {
590
+ always: typeof manifest.always === "boolean" ? manifest.always : undefined,
591
+ emoji: typeof manifest.emoji === "string" ? manifest.emoji : undefined,
592
+ homepage: typeof manifest.homepage === "string" ? manifest.homepage : undefined,
593
+ skillKey: typeof manifest.skillKey === "string" ? manifest.skillKey : undefined,
594
+ primaryEnv: typeof manifest.primaryEnv === "string" ? manifest.primaryEnv : undefined,
595
+ os: normalizeStrList(manifest.os),
596
+ requires: Object.keys(requires).length > 0 ? requires : undefined,
597
+ install: install.length > 0 ? install : undefined,
598
+ };
599
+ }
600
+ /**
601
+ * Load all skills from a directory. Each skill lives in a subdirectory with a SKILL.md file.
602
+ * Mirrors gateway's loadSkillsFromDirSafe.
603
+ */
604
+ function loadSkillsFromDir(dir, source) {
605
+ let rootReal;
606
+ try {
607
+ rootReal = realpathSync(resolve(dir));
608
+ }
609
+ catch {
610
+ return [];
611
+ }
612
+ let subdirs;
613
+ try {
614
+ subdirs = readdirSync(dir, { withFileTypes: true })
615
+ .filter(e => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules")
616
+ .map(e => join(dir, e.name))
617
+ .sort();
618
+ }
619
+ catch {
620
+ return [];
621
+ }
622
+ const entries = [];
623
+ for (const skillDir of subdirs) {
624
+ const skillMdPath = join(skillDir, "SKILL.md");
625
+ if (!existsSync(skillMdPath))
626
+ continue;
627
+ let content;
628
+ try {
629
+ content = readFileSync(skillMdPath, "utf-8");
630
+ }
631
+ catch {
632
+ continue;
633
+ }
634
+ const frontmatter = parseSkillFrontmatter(content);
635
+ const name = (frontmatter.name ?? "").trim() || basename(skillDir);
636
+ const description = (frontmatter.description ?? "").trim();
637
+ if (!name || !description)
638
+ continue;
639
+ entries.push({
640
+ name,
641
+ description,
642
+ filePath: resolve(skillMdPath),
643
+ baseDir: resolve(skillDir),
644
+ source,
645
+ frontmatter,
646
+ metadata: resolveOpenClawManifest(frontmatter),
647
+ });
648
+ }
649
+ return entries;
650
+ }
651
+ /**
652
+ * Resolve skill directories registered by enabled plugins.
653
+ * Each plugin's openclaw.plugin.json may declare "skills": ["./skills"]
654
+ * relative to the plugin's root directory.
655
+ *
656
+ * Only plugins explicitly listed in config.plugins.entries with enabled !== false
657
+ * are considered. Unlisted plugins are NOT loaded.
658
+ */
659
+ function resolvePluginSkillDirs(cfg) {
660
+ const dirs = [];
661
+ const pluginEntries = cfg?.plugins?.entries;
662
+ if (!pluginEntries || typeof pluginEntries !== "object")
663
+ return dirs;
664
+ // Collect explicitly enabled plugin IDs
665
+ const enabledPluginIds = new Set();
666
+ for (const [pluginId, entry] of Object.entries(pluginEntries)) {
667
+ if (entry?.enabled !== false)
668
+ enabledPluginIds.add(pluginId);
669
+ }
670
+ if (enabledPluginIds.size === 0)
671
+ return dirs;
672
+ /** Read skills paths from a plugin manifest file */
673
+ const readManifestSkills = (manifestPath) => {
674
+ try {
675
+ if (!existsSync(manifestPath))
676
+ return [];
677
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
678
+ if (!Array.isArray(manifest?.skills))
679
+ return [];
680
+ const result = [];
681
+ for (const s of manifest.skills) {
682
+ const rel = String(s ?? "").trim();
683
+ if (!rel)
684
+ continue;
685
+ const candidate = resolve(dirname(manifestPath), rel);
686
+ if (existsSync(candidate))
687
+ result.push(candidate);
688
+ }
689
+ return result;
690
+ }
691
+ catch {
692
+ return [];
693
+ }
694
+ };
695
+ // 1. User-installed plugins (plugins.installs)
696
+ const pluginInstalls = cfg?.plugins?.installs;
697
+ if (pluginInstalls && typeof pluginInstalls === "object") {
698
+ for (const [pluginId, install] of Object.entries(pluginInstalls)) {
699
+ if (!enabledPluginIds.has(pluginId))
456
700
  continue;
457
- skills.push({
458
- name: `channel:${chanId}`,
459
- description: `Channel: ${chanId}`,
460
- installed: true,
461
- enabled: chanCfg?.enabled !== false,
462
- configCheck: "ok",
463
- });
701
+ const installPath = String(install?.installPath ?? "").trim();
702
+ if (!installPath)
703
+ continue;
704
+ dirs.push(...readManifestSkills(join(resolveUserPath(installPath), "openclaw.plugin.json")));
464
705
  }
465
706
  }
466
- return { skills };
707
+ // 2. Bundled extensions (only for enabled plugins)
708
+ const bundledDir = resolveBundledSkillsDir();
709
+ if (bundledDir) {
710
+ const extensionsDir = resolve(bundledDir, "..", "dist", "extensions");
711
+ try {
712
+ if (existsSync(extensionsDir)) {
713
+ for (const pluginId of enabledPluginIds) {
714
+ const extManifest = join(extensionsDir, pluginId, "openclaw.plugin.json");
715
+ dirs.push(...readManifestSkills(extManifest));
716
+ }
717
+ }
718
+ }
719
+ catch { /* skip */ }
720
+ }
721
+ return dirs;
467
722
  }
468
723
  /**
469
- * skills.search search ClawHub for discoverable skills.
470
- * Currently returns an empty list (ClawHub integration not yet implemented locally).
724
+ * Load all skill entries visible in a workspace.
725
+ * Sources (later overrides earlier):
726
+ * extra (config extraDirs + plugin skills) → bundled → installed → workspace
471
727
  */
472
- function handleSkillsSearch(_api, params) {
473
- // ClawHub discovery requires network access to the registry — placeholder for now
474
- return { skills: [] };
728
+ function loadAllSkillEntries(workspaceDir, cfg) {
729
+ const stateDir = resolveStateDir();
730
+ const merged = new Map();
731
+ const addAll = (entries) => { for (const e of entries)
732
+ merged.set(e.name, e); };
733
+ // 1. Extra dirs: config extraDirs + plugin-registered skill dirs
734
+ const extraDirs = [];
735
+ if (Array.isArray(cfg?.skills?.load?.extraDirs)) {
736
+ for (const d of cfg.skills.load.extraDirs) {
737
+ const s = String(d ?? "").trim();
738
+ if (s)
739
+ extraDirs.push(resolveUserPath(s));
740
+ }
741
+ }
742
+ const pluginSkillDirs = resolvePluginSkillDirs(cfg);
743
+ for (const dir of [...extraDirs, ...pluginSkillDirs])
744
+ addAll(loadSkillsFromDir(dir, "extra"));
745
+ // 2. Bundled (openclaw package built-in)
746
+ const bundledDir = resolveBundledSkillsDir();
747
+ if (bundledDir)
748
+ addAll(loadSkillsFromDir(bundledDir, "builtin"));
749
+ // 3. Installed (~/.openclaw/skills/)
750
+ addAll(loadSkillsFromDir(join(stateDir, "skills"), "installed"));
751
+ // 4. Workspace skills ({workspace}/skills/) — highest priority
752
+ addAll(loadSkillsFromDir(join(resolve(workspaceDir), "skills"), "workspace"));
753
+ return Array.from(merged.values());
475
754
  }
476
755
  /**
477
- * skills.detail get detail for a specific skill from ClawHub.
478
- * Placeholder: returns basic info from local config if the skill is installed.
756
+ * Build runtime status for a single skill entry.
479
757
  */
480
- function handleSkillsDetail(api, params) {
481
- const name = String(params.name ?? "").trim();
482
- if (!name)
483
- throw { code: 400, message: "name is required" };
758
+ function buildSkillStatus(entry, cfg) {
759
+ // Read skills config from disk to ensure we have skills.entries
760
+ const fullCfg = loadFullConfig();
761
+ const skillKey = entry.metadata?.skillKey ?? entry.name;
762
+ const skillCfg = fullCfg?.skills?.entries?.[skillKey] ?? {};
763
+ const disabled = skillCfg.enabled === false;
764
+ const always = entry.metadata?.always === true;
765
+ const bundled = entry.source === "builtin";
766
+ const requiresBins = entry.metadata?.requires?.bins ?? [];
767
+ const requiresAnyBins = entry.metadata?.requires?.anyBins ?? [];
768
+ const requiresEnv = entry.metadata?.requires?.env ?? [];
769
+ const requiresConfig = entry.metadata?.requires?.config ?? [];
770
+ const missingBins = always ? [] : requiresBins.filter(b => !hasBinarySync(b));
771
+ const missingAnyBins = always ? [] :
772
+ (requiresAnyBins.length > 0 && !requiresAnyBins.some(b => hasBinarySync(b)) ? requiresAnyBins : []);
773
+ const missingEnv = always ? [] : requiresEnv.filter(e => !isEnvSatisfied(e, skillCfg, entry.metadata?.primaryEnv));
774
+ const isConfigPathSatisfied = (configPath) => {
775
+ // configPath like "channels.discord.token" → check fullCfg.channels.discord.token
776
+ const parts = configPath.split(".");
777
+ let current = fullCfg;
778
+ for (const part of parts) {
779
+ if (!current || typeof current !== "object")
780
+ return false;
781
+ current = current[part];
782
+ }
783
+ return current !== undefined && current !== null && current !== "";
784
+ };
785
+ const configChecks = requiresConfig.map(p => ({ path: p, satisfied: isConfigPathSatisfied(p) }));
786
+ const missingConfig = always ? [] : configChecks.filter(c => !c.satisfied).map(c => c.path);
787
+ const requirementsSatisfied = missingBins.length === 0 && missingAnyBins.length === 0 &&
788
+ missingEnv.length === 0 && missingConfig.length === 0;
789
+ return {
790
+ name: entry.name,
791
+ description: entry.description,
792
+ source: entry.source,
793
+ bundled,
794
+ filePath: entry.filePath,
795
+ baseDir: entry.baseDir,
796
+ skillKey,
797
+ ...(entry.metadata?.primaryEnv ? { primaryEnv: entry.metadata.primaryEnv } : {}),
798
+ ...(entry.metadata?.emoji ? { emoji: entry.metadata.emoji } : {}),
799
+ ...(entry.metadata?.homepage ? { homepage: entry.metadata.homepage } : {}),
800
+ always,
801
+ disabled,
802
+ blockedByAllowlist: false, // simplified: bundled allowlist check omitted
803
+ eligible: !disabled && requirementsSatisfied,
804
+ requirements: { bins: requiresBins, anyBins: requiresAnyBins, env: requiresEnv, config: requiresConfig, os: entry.metadata?.os ?? [] },
805
+ missing: { bins: missingBins, anyBins: missingAnyBins, env: missingEnv, config: missingConfig, os: [] },
806
+ configChecks,
807
+ install: (entry.metadata?.install ?? []).map((spec, idx) => ({
808
+ id: spec.id ?? `${spec.kind ?? "install"}-${idx}`,
809
+ kind: spec.kind ?? "",
810
+ label: spec.label ?? `Install (${spec.kind ?? "unknown"})`,
811
+ bins: spec.bins ?? [],
812
+ })),
813
+ };
814
+ }
815
+ // ---------------------------------------------------------------------------
816
+ // Skills method handlers (file-based, no gateway relay)
817
+ // ---------------------------------------------------------------------------
818
+ /**
819
+ * skills.status — read skill status from disk, grouped by source category.
820
+ *
821
+ * Categories:
822
+ * extra — skills.load.extraDirs → "Extra Skills"
823
+ * builtin — openclaw 安装包内置 → "Built-in Skills"
824
+ * installed — ~/.openclaw/skills/ → "Installed Skills"
825
+ * workspace — {workspace}/skills/ → "Workspace Skills"
826
+ */
827
+ async function handleSkillsStatus(api, params) {
484
828
  const cfg = getConfig(api);
485
- const plugins = cfg.plugins;
486
- if (plugins && typeof plugins === "object" && name in plugins) {
487
- const p = plugins[name];
488
- return {
489
- skill: {
490
- name,
491
- description: p?.description ?? "",
492
- author: p?.author ?? undefined,
493
- version: p?.version ?? undefined,
494
- source: "local",
495
- },
496
- };
829
+ const rawAgentId = String(params.agentId ?? "").trim();
830
+ const agentId = rawAgentId ? normalizeAgentId(rawAgentId) : resolveDefaultAgentId(cfg);
831
+ const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
832
+ const entries = loadAllSkillEntries(workspaceDir, cfg);
833
+ const categoryLabels = {
834
+ extra: "Extra Skills",
835
+ builtin: "Built-in Skills",
836
+ installed: "Installed Skills",
837
+ workspace: "Workspace Skills",
838
+ };
839
+ const grouped = {};
840
+ for (const e of entries) {
841
+ const cat = e.source;
842
+ if (!grouped[cat])
843
+ grouped[cat] = [];
844
+ grouped[cat].push(buildSkillStatus(e, cfg));
497
845
  }
498
- return { skill: null };
846
+ const categories = Object.entries(grouped).map(([source, skills]) => ({
847
+ source,
848
+ label: categoryLabels[source] ?? source,
849
+ skills,
850
+ }));
851
+ portalLog(api, "info", `skills.status: agentId=${agentId} workspace=${workspaceDir} count=${entries.length}`);
852
+ return { agentId, workspaceDir, categories };
499
853
  }
500
854
  /**
501
- * cron.list return configured cron jobs.
855
+ * Resolve agent-level skill filter (whitelist).
856
+ * Returns undefined if no filter is configured (all skills enabled for this agent).
857
+ * Returns a Set of allowed skill names if a filter is configured.
502
858
  */
503
- function handleCronList(api) {
859
+ function resolveAgentSkillFilter(cfg, agentId) {
860
+ const normalizedId = normalizeAgentId(agentId);
861
+ const agentEntry = (cfg.agents?.list ?? []).find((e) => normalizeAgentId(e?.id ?? "") === normalizedId);
862
+ // Agent-level skills field takes precedence
863
+ if (agentEntry && agentEntry.skills !== undefined) {
864
+ const list = Array.isArray(agentEntry.skills) ? agentEntry.skills : [];
865
+ return new Set(list.map((s) => String(s ?? "").trim()).filter(Boolean));
866
+ }
867
+ // Fallback to defaults
868
+ const defaultSkills = cfg.agents?.defaults?.skills;
869
+ if (defaultSkills !== undefined) {
870
+ const list = Array.isArray(defaultSkills) ? defaultSkills : [];
871
+ return new Set(list.map((s) => String(s ?? "").trim()).filter(Boolean));
872
+ }
873
+ // No filter — all skills enabled for this agent
874
+ return undefined;
875
+ }
876
+ /**
877
+ * agent.skills.status — same as skills.status but with an additional
878
+ * `agent_disabled` field per skill indicating whether the skill is disabled
879
+ * for this specific agent (via agent-level skill filter).
880
+ */
881
+ async function handleAgentSkillsStatus(api, params) {
882
+ const rawAgentId = String(params.agentId ?? "").trim();
883
+ if (!rawAgentId)
884
+ throw { code: 400, message: "agentId is required" };
504
885
  const cfg = getConfig(api);
505
- const jobs = [];
506
- const cronConfig = cfg.cron ?? cfg.automation?.cron;
507
- if (Array.isArray(cronConfig)) {
508
- for (const entry of cronConfig) {
509
- if (!entry || typeof entry !== "object")
510
- continue;
511
- jobs.push({
512
- id: String(entry.id ?? entry.name ?? `cron-${jobs.length}`),
513
- name: entry.name ?? entry.id ?? undefined,
514
- schedule: String(entry.schedule ?? entry.cron ?? ""),
515
- command: entry.command ?? entry.text ?? entry.message ?? undefined,
516
- enabled: entry.enabled !== false,
517
- lastRun: entry.lastRun ?? undefined,
518
- nextRun: entry.nextRun ?? undefined,
519
- });
886
+ const fullCfg = loadFullConfig();
887
+ const agentId = normalizeAgentId(rawAgentId);
888
+ const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
889
+ const entries = loadAllSkillEntries(workspaceDir, cfg);
890
+ const skillFilter = resolveAgentSkillFilter(fullCfg, agentId);
891
+ const categoryLabels = {
892
+ extra: "Extra Skills",
893
+ builtin: "Built-in Skills",
894
+ installed: "Installed Skills",
895
+ workspace: "Workspace Skills",
896
+ };
897
+ const grouped = {};
898
+ for (const e of entries) {
899
+ const cat = e.source;
900
+ if (!grouped[cat])
901
+ grouped[cat] = [];
902
+ const status = buildSkillStatus(e, cfg);
903
+ // agent_disabled: true if a skill filter exists and this skill is NOT in the whitelist
904
+ const agentDisabled = skillFilter !== undefined && !skillFilter.has(e.name);
905
+ grouped[cat].push({ ...status, agent_disabled: agentDisabled });
906
+ }
907
+ const categories = Object.entries(grouped).map(([source, skills]) => ({
908
+ source,
909
+ label: categoryLabels[source] ?? source,
910
+ skills,
911
+ }));
912
+ portalLog(api, "info", `agent.skills.status: agentId=${agentId} workspace=${workspaceDir} count=${entries.length} filter=${skillFilter ? `[${skillFilter.size}]` : "none"}`);
913
+ return { agentId, workspaceDir, categories };
914
+ }
915
+ /**
916
+ * skills.search — search ClawHub registry directly.
917
+ *
918
+ * GET https://clawhub.ai/api/v1/search?q={query}&limit={limit}
919
+ * Mirrors gateway's searchSkillsFromClawHub.
920
+ */
921
+ async function handleSkillsSearch(api, params) {
922
+ const query = String(params.query ?? "").trim();
923
+ if (!query)
924
+ return { results: [] };
925
+ try {
926
+ const searchParams = { q: query };
927
+ if (params.limit)
928
+ searchParams.limit = String(params.limit);
929
+ const result = await fetchClawHub(resolveClawHubBaseUrl(), "/api/v1/search", searchParams);
930
+ const results = result?.results ?? [];
931
+ portalLog(api, "info", `skills.search: query="${query}" got ${results.length} results`);
932
+ return { results };
933
+ }
934
+ catch (err) {
935
+ portalLog(api, "warn", `skills.search: ClawHub error: ${err?.message ?? String(err)}`);
936
+ return { results: [] };
937
+ }
938
+ }
939
+ /**
940
+ * skills.install — install a skill into an agent's workspace.
941
+ *
942
+ * Two modes:
943
+ * source=clawhub (default): download from ClawHub by slug
944
+ * source=url: download a zip from the given URL
945
+ *
946
+ * Both extract into {workspace}/skills/{skillKey}/
947
+ */
948
+ async function handleSkillsInstall(api, params) {
949
+ const source = String(params.source ?? "clawhub").trim();
950
+ const cfg = getConfig(api);
951
+ const rawAgentId = String(params.agentId ?? "").trim();
952
+ const agentId = rawAgentId ? normalizeAgentId(rawAgentId) : resolveDefaultAgentId(cfg);
953
+ const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
954
+ const execFileAsync = promisify(execFile);
955
+ if (source === "url") {
956
+ // --- URL install: download zip and extract ---
957
+ const url = String(params.url ?? "").trim();
958
+ if (!url)
959
+ throw { code: 400, message: "url is required for source=url" };
960
+ const skillKey = String(params.skillKey ?? params.name ?? "").trim();
961
+ if (!skillKey) {
962
+ throw { code: 400, message: "skillKey (or name) is required — the directory name under skills/" };
963
+ }
964
+ // Download
965
+ let archiveBytes;
966
+ try {
967
+ archiveBytes = await downloadArchive(url);
520
968
  }
969
+ catch (err) {
970
+ throw { code: 503, message: `skills.install: download failed: ${err.message}` };
971
+ }
972
+ const targetDir = join(resolve(workspaceDir), "skills", skillKey);
973
+ const isZip = url.endsWith(".zip") || archiveBytes[0] === 0x50 && archiveBytes[1] === 0x4b; // PK magic
974
+ const tmpFile = join(tmpdir(), `openclaw-skill-${skillKey}-${Date.now()}${isZip ? ".zip" : ".tar.gz"}`);
975
+ try {
976
+ await writeFile(tmpFile, archiveBytes);
977
+ // Clean existing dir and recreate
978
+ await rm(targetDir, { recursive: true, force: true });
979
+ await mkdir(targetDir, { recursive: true });
980
+ if (isZip) {
981
+ await execFileAsync("unzip", ["-o", tmpFile, "-d", targetDir]);
982
+ // If zip contains a single root directory, move contents up
983
+ try {
984
+ const items = readdirSync(targetDir);
985
+ if (items.length === 1) {
986
+ const innerDir = join(targetDir, items[0]);
987
+ const innerStat = statSync(innerDir);
988
+ if (innerStat.isDirectory()) {
989
+ const innerItems = readdirSync(innerDir);
990
+ for (const item of innerItems) {
991
+ await execFileAsync("mv", [join(innerDir, item), targetDir]);
992
+ }
993
+ await execFileAsync("rm", ["-rf", innerDir]);
994
+ }
995
+ }
996
+ }
997
+ catch { /* best effort flatten */ }
998
+ }
999
+ else {
1000
+ await extractTarGz(tmpFile, targetDir);
1001
+ }
1002
+ }
1003
+ catch (err) {
1004
+ throw { code: 500, message: `skills.install: extraction failed: ${err.message}` };
1005
+ }
1006
+ finally {
1007
+ try {
1008
+ await unlink(tmpFile);
1009
+ }
1010
+ catch { /* cleanup */ }
1011
+ }
1012
+ portalLog(api, "info", `skills.install: source=url agentId=${agentId} skillKey="${skillKey}" targetDir="${targetDir}"`);
1013
+ return { ok: true, message: `Installed ${skillKey} from URL`, agentId, skillKey, targetDir };
521
1014
  }
1015
+ if (source === "clawhub") {
1016
+ // --- ClawHub install: download by slug ---
1017
+ const slug = String(params.slug ?? params.name ?? "").trim();
1018
+ if (!slug)
1019
+ throw { code: 400, message: "slug (or name) is required for source=clawhub" };
1020
+ let detail;
1021
+ try {
1022
+ detail = await fetchClawHub(resolveClawHubBaseUrl(), `/api/v1/skills/${encodeURIComponent(slug)}`);
1023
+ }
1024
+ catch (err) {
1025
+ throw { code: 503, message: `skills.install: ClawHub fetch failed: ${err.message}` };
1026
+ }
1027
+ if (!detail?.skill)
1028
+ throw { code: 404, message: `Skill "${slug}" not found on ClawHub` };
1029
+ const version = String(params.version ?? detail.latestVersion?.version ?? "").trim();
1030
+ if (!version)
1031
+ throw { code: 400, message: `Skill "${slug}" has no installable version` };
1032
+ let archiveBytes;
1033
+ try {
1034
+ const searchParams = { version };
1035
+ const archiveUrl = resolveClawHubBaseUrl()
1036
+ + `/api/v1/packages/${encodeURIComponent(slug)}/download?` + new URLSearchParams(searchParams);
1037
+ archiveBytes = await downloadArchive(archiveUrl, 60000);
1038
+ }
1039
+ catch (err) {
1040
+ throw { code: 503, message: `skills.install: download failed: ${err.message}` };
1041
+ }
1042
+ const targetDir = join(resolve(workspaceDir), "skills", slug);
1043
+ const tmpArchive = join(tmpdir(), `openclaw-skill-${slug}-${Date.now()}.tar.gz`);
1044
+ try {
1045
+ await writeFile(tmpArchive, archiveBytes);
1046
+ // Clean existing dir and recreate
1047
+ await rm(targetDir, { recursive: true, force: true });
1048
+ await mkdir(targetDir, { recursive: true });
1049
+ await extractTarGz(tmpArchive, targetDir);
1050
+ }
1051
+ catch (err) {
1052
+ throw { code: 500, message: `skills.install: extraction failed: ${err.message}` };
1053
+ }
1054
+ finally {
1055
+ try {
1056
+ await unlink(tmpArchive);
1057
+ }
1058
+ catch { /* cleanup */ }
1059
+ }
1060
+ portalLog(api, "info", `skills.install: source=clawhub agentId=${agentId} slug="${slug}" version="${version}" targetDir="${targetDir}"`);
1061
+ return { ok: true, message: `Installed ${slug}@${version}`, agentId, slug, version, targetDir };
1062
+ }
1063
+ throw { code: 400, message: `unsupported source: "${source}". Use "clawhub" or "url"` };
1064
+ }
1065
+ /**
1066
+ * skills.set — set a skill's enabled/disabled state globally.
1067
+ *
1068
+ * Only modifies the `enabled` field in skills.entries[skillKey],
1069
+ * preserving all other fields (env, apiKey, etc.).
1070
+ * If enabled is true, the `enabled` key is removed (default is enabled).
1071
+ */
1072
+ async function handleSkillsSet(api, params) {
1073
+ const skillKey = String(params.skillKey ?? params.name ?? "").trim();
1074
+ if (!skillKey)
1075
+ throw { code: 400, message: "skillKey (or name) is required" };
1076
+ if (typeof params.enabled !== "boolean") {
1077
+ throw { code: 400, message: "enabled (boolean) is required" };
1078
+ }
1079
+ const enabled = params.enabled;
1080
+ const configPath = resolveOpenClawConfigPath();
1081
+ // Read existing config
1082
+ let cfg = {};
1083
+ try {
1084
+ const raw = await readFile(configPath, "utf-8");
1085
+ cfg = JSON.parse(raw);
1086
+ }
1087
+ catch (err) {
1088
+ if (err.code !== "ENOENT") {
1089
+ throw { code: 500, message: `skills.set: failed to read config: ${err.message}` };
1090
+ }
1091
+ }
1092
+ // Ensure skills.entries structure exists
1093
+ if (!cfg.skills || typeof cfg.skills !== "object")
1094
+ cfg.skills = {};
1095
+ if (!cfg.skills.entries || typeof cfg.skills.entries !== "object")
1096
+ cfg.skills.entries = {};
1097
+ // Get or create the entry, preserving existing fields
1098
+ const current = cfg.skills.entries[skillKey] && typeof cfg.skills.entries[skillKey] === "object"
1099
+ ? { ...cfg.skills.entries[skillKey] }
1100
+ : {};
1101
+ if (enabled) {
1102
+ // Enabled is the default — remove the key to keep config clean
1103
+ delete current.enabled;
1104
+ // If entry is now empty, remove it entirely
1105
+ if (Object.keys(current).length === 0) {
1106
+ delete cfg.skills.entries[skillKey];
1107
+ }
1108
+ else {
1109
+ cfg.skills.entries[skillKey] = current;
1110
+ }
1111
+ }
1112
+ else {
1113
+ current.enabled = false;
1114
+ cfg.skills.entries[skillKey] = current;
1115
+ }
1116
+ // Write back
1117
+ try {
1118
+ await mkdir(dirname(configPath), { recursive: true });
1119
+ await writeFile(configPath, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
1120
+ }
1121
+ catch (err) {
1122
+ throw { code: 500, message: `skills.set: failed to write config: ${err.message}` };
1123
+ }
1124
+ // Invalidate disk config cache
1125
+ clearDiskConfigCache();
1126
+ portalLog(api, "info", `skills.set: skillKey=${skillKey} enabled=${enabled}`);
1127
+ return { ok: true, skillKey, enabled };
1128
+ }
1129
+ /**
1130
+ * agent.skills.set — enable or disable a skill for a specific agent.
1131
+ *
1132
+ * Agent skill config is a whitelist array on agents.list[].skills:
1133
+ * - undefined (not configured) → all skills enabled
1134
+ * - [] (empty array) → all skills disabled
1135
+ * - ["a", "b"] → only a and b enabled
1136
+ *
1137
+ * When disabling the first skill (no whitelist exists yet), we populate
1138
+ * the whitelist with ALL currently available skill names minus the target.
1139
+ * When enabling, we add the skill to the existing whitelist.
1140
+ * When disabling, we remove it from the whitelist.
1141
+ */
1142
+ async function handleAgentSkillsSet(api, params) {
1143
+ const rawAgentId = String(params.agentId ?? "").trim();
1144
+ const skillName = String(params.skillKey ?? params.name ?? "").trim();
1145
+ if (!rawAgentId)
1146
+ throw { code: 400, message: "agentId is required" };
1147
+ if (!skillName)
1148
+ throw { code: 400, message: "skillKey (or name) is required" };
1149
+ if (typeof params.enabled !== "boolean")
1150
+ throw { code: 400, message: "enabled (boolean) is required" };
1151
+ const enabled = params.enabled;
1152
+ const agentId = normalizeAgentId(rawAgentId);
1153
+ const configPath = resolveOpenClawConfigPath();
1154
+ // Read config from disk
1155
+ let cfg = {};
1156
+ try {
1157
+ const raw = await readFile(configPath, "utf-8");
1158
+ cfg = JSON.parse(raw);
1159
+ }
1160
+ catch (err) {
1161
+ if (err.code !== "ENOENT") {
1162
+ throw { code: 500, message: `agent.skills.set: failed to read config: ${err.message}` };
1163
+ }
1164
+ }
1165
+ // Find the agent entry in agents.list
1166
+ if (!cfg.agents)
1167
+ cfg.agents = {};
1168
+ if (!Array.isArray(cfg.agents.list))
1169
+ cfg.agents.list = [];
1170
+ let agentEntry = cfg.agents.list.find((a) => a?.id && normalizeAgentId(a.id) === agentId);
1171
+ if (!agentEntry) {
1172
+ throw { code: 400, message: `agent "${agentId}" not found in config` };
1173
+ }
1174
+ const hasWhitelist = Array.isArray(agentEntry.skills);
1175
+ if (enabled) {
1176
+ // Enable the skill
1177
+ if (!hasWhitelist) {
1178
+ // No whitelist → all skills already enabled, nothing to do
1179
+ portalLog(api, "info", `agent.skills.set: agentId=${agentId} skill=${skillName} already enabled (no whitelist)`);
1180
+ return { ok: true, agentId, skillKey: skillName, enabled: true, skills: undefined };
1181
+ }
1182
+ // Add to whitelist if not present
1183
+ const currentList = agentEntry.skills;
1184
+ if (!currentList.includes(skillName)) {
1185
+ currentList.push(skillName);
1186
+ }
1187
+ }
1188
+ else {
1189
+ // Disable the skill
1190
+ if (!hasWhitelist) {
1191
+ // No whitelist yet → populate with all skill names minus the target
1192
+ const runtimeCfg = getConfig(api);
1193
+ const workspaceDir = resolveAgentWorkspaceDir(runtimeCfg, agentId);
1194
+ const allEntries = loadAllSkillEntries(workspaceDir, runtimeCfg);
1195
+ const allNames = allEntries.map(e => e.name);
1196
+ agentEntry.skills = allNames.filter(n => n !== skillName);
1197
+ }
1198
+ else {
1199
+ // Remove from existing whitelist
1200
+ agentEntry.skills = agentEntry.skills.filter((s) => s !== skillName);
1201
+ }
1202
+ }
1203
+ // Write back config
1204
+ try {
1205
+ await mkdir(dirname(configPath), { recursive: true });
1206
+ await writeFile(configPath, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
1207
+ }
1208
+ catch (err) {
1209
+ throw { code: 500, message: `agent.skills.set: failed to write config: ${err.message}` };
1210
+ }
1211
+ clearDiskConfigCache();
1212
+ const resultSkills = agentEntry.skills ?? undefined;
1213
+ portalLog(api, "info", `agent.skills.set: agentId=${agentId} skill=${skillName} enabled=${enabled} skills=${JSON.stringify(resultSkills)}`);
1214
+ return { ok: true, agentId, skillKey: skillName, enabled, skills: resultSkills };
1215
+ }
1216
+ /**
1217
+ * agent.model.set — switch the model for a specific agent.
1218
+ *
1219
+ * Writes the model string (e.g. "deepminer/claude-sonnet-4-6") to
1220
+ * agents.list[agentId].model in openclaw.json.
1221
+ */
1222
+ async function handleAgentModelSet(api, params) {
1223
+ const rawAgentId = String(params.agentId ?? "").trim();
1224
+ const model = String(params.model ?? "").trim();
1225
+ if (!rawAgentId)
1226
+ throw { code: 400, message: "agentId is required" };
1227
+ if (!model)
1228
+ throw { code: 400, message: "model is required" };
1229
+ const agentId = normalizeAgentId(rawAgentId);
1230
+ const configPath = resolveOpenClawConfigPath();
1231
+ let cfg = {};
1232
+ try {
1233
+ const raw = await readFile(configPath, "utf-8");
1234
+ cfg = JSON.parse(raw);
1235
+ }
1236
+ catch (err) {
1237
+ if (err.code !== "ENOENT") {
1238
+ throw { code: 500, message: `agent.model.set: failed to read config: ${err.message}` };
1239
+ }
1240
+ }
1241
+ if (!cfg.agents)
1242
+ cfg.agents = {};
1243
+ if (!Array.isArray(cfg.agents.list))
1244
+ cfg.agents.list = [];
1245
+ const agentEntry = cfg.agents.list.find((a) => a?.id && normalizeAgentId(a.id) === agentId);
1246
+ if (!agentEntry) {
1247
+ throw { code: 400, message: `agent "${agentId}" not found in config` };
1248
+ }
1249
+ agentEntry.model = model;
1250
+ try {
1251
+ await mkdir(dirname(configPath), { recursive: true });
1252
+ await writeFile(configPath, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
1253
+ }
1254
+ catch (err) {
1255
+ throw { code: 500, message: `agent.model.set: failed to write config: ${err.message}` };
1256
+ }
1257
+ clearDiskConfigCache();
1258
+ portalLog(api, "info", `agent.model.set: agentId=${agentId} model=${model}`);
1259
+ return { ok: true, agentId, model };
1260
+ }
1261
+ /**
1262
+ * cron.list — read jobs directly from the persisted cron store (jobs.json).
1263
+ *
1264
+ * Supports filtering by: agentId, includeDisabled, query
1265
+ * Supports sorting: nextRunAtMs | updatedAtMs | name, asc | desc
1266
+ * Supports pagination: offset, limit
1267
+ */
1268
+ async function handleCronList(api, params) {
1269
+ const storePath = resolveCronStorePath(api);
1270
+ let raw;
1271
+ try {
1272
+ raw = await readFile(storePath, "utf-8");
1273
+ }
1274
+ catch (err) {
1275
+ if (err?.code === "ENOENT") {
1276
+ portalLog(api, "info", `cron.list: store not found at ${storePath}`);
1277
+ return { jobs: [] };
1278
+ }
1279
+ throw { code: 500, message: `cron.list: failed to read store: ${err.message}` };
1280
+ }
1281
+ let store;
1282
+ try {
1283
+ store = JSON.parse(raw);
1284
+ }
1285
+ catch {
1286
+ throw { code: 500, message: "cron.list: invalid JSON in cron store" };
1287
+ }
1288
+ let jobs = Array.isArray(store.jobs) ? store.jobs : [];
1289
+ // Filter: agentId
1290
+ const agentId = String(params.agentId ?? "").trim();
1291
+ if (agentId) {
1292
+ jobs = jobs.filter((j) => j.agentId === agentId);
1293
+ }
1294
+ // Filter: enabled (default: exclude disabled)
1295
+ const includeDisabled = params.includeDisabled === true;
1296
+ if (!includeDisabled) {
1297
+ jobs = jobs.filter((j) => j.enabled !== false);
1298
+ }
1299
+ // Filter: name query
1300
+ const query = String(params.query ?? "").trim().toLowerCase();
1301
+ if (query) {
1302
+ jobs = jobs.filter((j) => j.name?.toLowerCase().includes(query));
1303
+ }
1304
+ // Sort
1305
+ const sortBy = String(params.sortBy ?? "nextRunAtMs");
1306
+ const sortDir = String(params.sortDir ?? "asc");
1307
+ jobs = [...jobs].sort((a, b) => {
1308
+ let av = 0;
1309
+ let bv = 0;
1310
+ if (sortBy === "updatedAtMs") {
1311
+ av = a.updatedAtMs ?? 0;
1312
+ bv = b.updatedAtMs ?? 0;
1313
+ }
1314
+ else if (sortBy === "name") {
1315
+ av = a.name ?? "";
1316
+ bv = b.name ?? "";
1317
+ }
1318
+ else {
1319
+ // default: nextRunAtMs
1320
+ av = a.state?.nextRunAtMs ?? 0;
1321
+ bv = b.state?.nextRunAtMs ?? 0;
1322
+ }
1323
+ const cmp = av < bv ? -1 : av > bv ? 1 : 0;
1324
+ return sortDir === "desc" ? -cmp : cmp;
1325
+ });
1326
+ // Pagination
1327
+ const offset = Math.max(0, Number(params.offset) || 0);
1328
+ const limit = Number(params.limit) || 0;
1329
+ if (offset > 0)
1330
+ jobs = jobs.slice(offset);
1331
+ if (limit > 0)
1332
+ jobs = jobs.slice(0, limit);
1333
+ portalLog(api, "info", `cron.list: storePath=${storePath} agentId=${agentId || "*"} count=${jobs.length}`);
522
1334
  return { jobs };
523
1335
  }
524
1336
  // ---------------------------------------------------------------------------
@@ -552,19 +1364,31 @@ async function handlePortalRequest(api, accountId, request) {
552
1364
  result = await handleAgentsCreate(api, params ?? {});
553
1365
  break;
554
1366
  case "tools.catalog":
555
- result = handleToolsCatalog(api);
1367
+ result = await handleToolsCatalog(api, params ?? {});
556
1368
  break;
557
1369
  case "skills.status":
558
- result = handleSkillsStatus(api, params ?? {});
1370
+ result = await handleSkillsStatus(api, params ?? {});
1371
+ break;
1372
+ case "agent.skills.status":
1373
+ result = await handleAgentSkillsStatus(api, params ?? {});
1374
+ break;
1375
+ case "agent.skills.set":
1376
+ result = await handleAgentSkillsSet(api, params ?? {});
1377
+ break;
1378
+ case "agent.model.set":
1379
+ result = await handleAgentModelSet(api, params ?? {});
559
1380
  break;
560
1381
  case "skills.search":
561
- result = handleSkillsSearch(api, params ?? {});
1382
+ result = await handleSkillsSearch(api, params ?? {});
1383
+ break;
1384
+ case "skills.install":
1385
+ result = await handleSkillsInstall(api, params ?? {});
562
1386
  break;
563
- case "skills.detail":
564
- result = handleSkillsDetail(api, params ?? {});
1387
+ case "skills.set":
1388
+ result = await handleSkillsSet(api, params ?? {});
565
1389
  break;
566
1390
  case "cron.list":
567
- result = handleCronList(api);
1391
+ result = await handleCronList(api, params ?? {});
568
1392
  break;
569
1393
  case "ping":
570
1394
  result = { pong: true };