@zeyiy/openclaw-channel 0.3.5 → 0.3.7

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,12 @@
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 { accessSync, readdirSync, realpathSync, existsSync, readFileSync, statSync, constants as fsConstants } 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, homedir } from "node:os";
10
14
  const bridges = new Map();
11
15
  const RECONNECT_BASE_MS = 2000;
12
16
  const RECONNECT_MAX_MS = 60000;
@@ -119,12 +123,13 @@ function handleModelsList(api, params) {
119
123
  }
120
124
  }
121
125
  }
122
- // Resolve active model for the requested agent
126
+ // Resolve active model for the requested agent (read from disk for latest state)
123
127
  const rawAgentId = String(params.agentId ?? "").trim();
124
128
  let activeModelId;
125
129
  if (rawAgentId) {
130
+ const fullCfg = loadFullConfig();
126
131
  const agentId = normalizeAgentId(rawAgentId);
127
- const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
132
+ const agents = Array.isArray(fullCfg.agents?.list) ? fullCfg.agents.list : [];
128
133
  const entry = agents.find((a) => a?.id && normalizeAgentId(a.id) === agentId);
129
134
  if (entry?.model) {
130
135
  // model can be a string like "deepminer/claude-sonnet-4-6" or an object { primary: "..." }
@@ -374,6 +379,1255 @@ function handleBotAgentGet(api, accountId) {
374
379
  const defaultEntry = agents.find((a) => a?.id && normalizeAgentId(a.id) === defaultId);
375
380
  return { agentId: defaultId, ...(defaultEntry?.name ? { name: defaultEntry.name } : {}) };
376
381
  }
382
+ /**
383
+ * tools.catalog — relay to gateway for the live runtime tool catalog.
384
+ *
385
+ * Gateway returns: { agentId, profiles, groups: [{ id, label, source, tools: [...] }] }
386
+ */
387
+ // ---------------------------------------------------------------------------
388
+ // tools.catalog: static core tool definitions (mirrors gateway's CORE_TOOL_DEFINITIONS)
389
+ // ---------------------------------------------------------------------------
390
+ const CORE_TOOL_SECTION_ORDER = [
391
+ { id: "fs", label: "Files" },
392
+ { id: "runtime", label: "Runtime" },
393
+ { id: "web", label: "Web" },
394
+ { id: "memory", label: "Memory" },
395
+ { id: "sessions", label: "Sessions" },
396
+ { id: "ui", label: "UI" },
397
+ { id: "messaging", label: "Messaging" },
398
+ { id: "automation", label: "Automation" },
399
+ { id: "nodes", label: "Nodes" },
400
+ { id: "agents", label: "Agents" },
401
+ { id: "media", label: "Media" },
402
+ ];
403
+ const CORE_TOOL_DEFINITIONS = [
404
+ { id: "read", label: "read", description: "Read file contents", sectionId: "fs", profiles: ["coding"] },
405
+ { id: "write", label: "write", description: "Create or overwrite files", sectionId: "fs", profiles: ["coding"] },
406
+ { id: "edit", label: "edit", description: "Make precise edits", sectionId: "fs", profiles: ["coding"] },
407
+ { id: "apply_patch", label: "apply_patch", description: "Patch files", sectionId: "fs", profiles: ["coding"] },
408
+ { id: "exec", label: "exec", description: "Run shell commands", sectionId: "runtime", profiles: ["coding"] },
409
+ { id: "process", label: "process", description: "Manage processes", sectionId: "runtime", profiles: ["coding"] },
410
+ { id: "code_execution", label: "code_execution", description: "Run sandboxed remote analysis", sectionId: "runtime", profiles: ["coding"] },
411
+ { id: "web_search", label: "web_search", description: "Search the web", sectionId: "web", profiles: ["coding"] },
412
+ { id: "web_fetch", label: "web_fetch", description: "Fetch web content", sectionId: "web", profiles: ["coding"] },
413
+ { id: "x_search", label: "x_search", description: "Search X posts", sectionId: "web", profiles: ["coding"] },
414
+ { id: "memory_search", label: "memory_search", description: "Semantic search", sectionId: "memory", profiles: ["coding"] },
415
+ { id: "memory_get", label: "memory_get", description: "Read memory files", sectionId: "memory", profiles: ["coding"] },
416
+ { id: "sessions_list", label: "sessions_list", description: "List sessions", sectionId: "sessions", profiles: ["coding", "messaging"] },
417
+ { id: "sessions_history", label: "sessions_history", description: "View session history", sectionId: "sessions", profiles: ["coding", "messaging"] },
418
+ { id: "sessions_send", label: "sessions_send", description: "Send to session", sectionId: "sessions", profiles: ["coding", "messaging"] },
419
+ { id: "sessions_spawn", label: "sessions_spawn", description: "Spawn new session", sectionId: "sessions", profiles: ["coding"] },
420
+ { id: "sessions_yield", label: "sessions_yield", description: "End turn to receive sub-agent results", sectionId: "sessions", profiles: ["coding"] },
421
+ { id: "subagents", label: "subagents", description: "Manage sub-agents", sectionId: "sessions", profiles: ["coding"] },
422
+ { id: "session_status", label: "session_status", description: "Session status", sectionId: "sessions", profiles: ["minimal", "coding", "messaging"] },
423
+ { id: "browser", label: "browser", description: "Control web browser", sectionId: "ui", profiles: [] },
424
+ { id: "canvas", label: "canvas", description: "Control canvases", sectionId: "ui", profiles: [] },
425
+ { id: "message", label: "message", description: "Send messages", sectionId: "messaging", profiles: ["messaging"] },
426
+ { id: "cron", label: "cron", description: "Schedule cron jobs", sectionId: "automation", profiles: ["coding"] },
427
+ { id: "gateway", label: "gateway", description: "Gateway control", sectionId: "automation", profiles: [] },
428
+ { id: "nodes", label: "nodes", description: "Nodes + devices", sectionId: "nodes", profiles: [] },
429
+ { id: "agents_list", label: "agents_list", description: "List agents", sectionId: "agents", profiles: [] },
430
+ { id: "update_plan", label: "update_plan", description: "Update plan", sectionId: "agents", profiles: ["coding"] },
431
+ { id: "image", label: "image", description: "Image understanding", sectionId: "media", profiles: ["coding"] },
432
+ { id: "image_generate", label: "image_generate", description: "Image generation", sectionId: "media", profiles: ["coding"] },
433
+ { id: "music_generate", label: "music_generate", description: "Music generation", sectionId: "media", profiles: ["coding"] },
434
+ { id: "video_generate", label: "video_generate", description: "Video generation", sectionId: "media", profiles: ["coding"] },
435
+ { id: "tts", label: "tts", description: "Text-to-speech conversion", sectionId: "media", profiles: [] },
436
+ ];
437
+ const TOOL_CATALOG_PROFILES = [
438
+ { id: "minimal", label: "Minimal" },
439
+ { id: "coding", label: "Coding" },
440
+ { id: "messaging", label: "Messaging" },
441
+ { id: "full", label: "Full" },
442
+ ];
443
+ /**
444
+ * tools.catalog — mirrors gateway's buildToolsCatalogResult.
445
+ *
446
+ * Core groups: hardcoded static CORE_TOOL_DEFINITIONS (identical to gateway source).
447
+ * Plugin groups: read from config.plugins.entries (enabled plugins).
448
+ * Note: individual plugin tool names require loading plugin factories (runtime-only),
449
+ * so plugin groups are listed without their tools array — matching what config can provide.
450
+ */
451
+ function handleToolsCatalog(api, params) {
452
+ const cfg = getConfig(api);
453
+ const rawAgentId = String(params.agentId ?? "").trim();
454
+ const agentId = rawAgentId ? normalizeAgentId(rawAgentId) : resolveDefaultAgentId(cfg);
455
+ // Build core groups (identical to gateway's buildCoreGroups)
456
+ const coreGroups = CORE_TOOL_SECTION_ORDER.map((section) => ({
457
+ id: section.id,
458
+ label: section.label,
459
+ source: "core",
460
+ tools: CORE_TOOL_DEFINITIONS
461
+ .filter((tool) => tool.sectionId === section.id)
462
+ .map((tool) => ({
463
+ id: tool.id,
464
+ label: tool.label,
465
+ description: tool.description,
466
+ source: "core",
467
+ defaultProfiles: tool.profiles,
468
+ })),
469
+ })).filter((section) => section.tools.length > 0);
470
+ // Build plugin groups from config.plugins.entries (best-effort, no factory loading)
471
+ const pluginEntries = cfg.plugins?.entries;
472
+ const pluginGroups = [];
473
+ if (pluginEntries && typeof pluginEntries === "object") {
474
+ for (const [pluginId, entry] of Object.entries(pluginEntries)) {
475
+ // Skip explicitly disabled plugins
476
+ if (entry?.enabled === false)
477
+ continue;
478
+ pluginGroups.push({
479
+ id: `plugin:${pluginId}`,
480
+ label: pluginId,
481
+ source: "plugin",
482
+ pluginId,
483
+ tools: [],
484
+ });
485
+ }
486
+ pluginGroups.sort((a, b) => a.label.localeCompare(b.label));
487
+ }
488
+ return {
489
+ agentId,
490
+ profiles: TOOL_CATALOG_PROFILES,
491
+ groups: [...coreGroups, ...pluginGroups],
492
+ };
493
+ }
494
+ // ---------------------------------------------------------------------------
495
+ // Skills: file-based helpers (mirrors openclaw gateway internals)
496
+ // ---------------------------------------------------------------------------
497
+ /**
498
+ * Resolve the openclaw state directory root.
499
+ * Mirrors gateway's resolveStateDir: OPENCLAW_STATE_DIR > dirname(OPENCLAW_CONFIG_PATH) > ~/.openclaw
500
+ */
501
+ /** Resolve the effective home directory, matching gateway's resolveRequiredHomeDir priority:
502
+ * OPENCLAW_HOME → process.env.HOME → process.env.USERPROFILE → os.homedir() → cwd */
503
+ function resolveEffectiveHomeDir() {
504
+ const openclawHome = (process.env.OPENCLAW_HOME ?? "").trim();
505
+ if (openclawHome && openclawHome !== "undefined" && openclawHome !== "null") {
506
+ // Support ~/... prefix expansion using os.homedir()
507
+ if (openclawHome === "~" || openclawHome.startsWith("~/") || openclawHome.startsWith("~\\")) {
508
+ const osHome = process.env.HOME || process.env.USERPROFILE || homedir();
509
+ if (osHome)
510
+ return resolve(openclawHome.replace(/^~(?=$|[\\/])/, osHome));
511
+ }
512
+ return resolve(openclawHome);
513
+ }
514
+ const envHome = (process.env.HOME ?? "").trim();
515
+ if (envHome)
516
+ return resolve(envHome);
517
+ const userProfile = (process.env.USERPROFILE ?? "").trim();
518
+ if (userProfile)
519
+ return resolve(userProfile);
520
+ try {
521
+ return resolve(homedir());
522
+ }
523
+ catch { }
524
+ return resolve(process.cwd());
525
+ }
526
+ function resolveStateDir() {
527
+ const stateDir = (process.env.OPENCLAW_STATE_DIR ?? "").trim();
528
+ if (stateDir)
529
+ return resolve(stateDir.startsWith("~") ? stateDir.replace(/^~(?=$|[\\/])/, resolveEffectiveHomeDir()) : stateDir);
530
+ const configPath = (process.env.OPENCLAW_CONFIG_PATH ?? "").trim();
531
+ if (configPath)
532
+ return resolve(dirname(configPath.startsWith("~") ? configPath.replace(/^~(?=$|[\\/])/, resolveEffectiveHomeDir()) : configPath));
533
+ return join(resolveEffectiveHomeDir(), ".openclaw");
534
+ }
535
+ /**
536
+ * Resolve the openclaw config file path.
537
+ * Mirrors gateway's resolveCanonicalConfigPath: OPENCLAW_CONFIG_PATH > {stateDir}/openclaw.json
538
+ */
539
+ function resolveOpenClawConfigPath() {
540
+ const configPathOverride = (process.env.OPENCLAW_CONFIG_PATH ?? "").trim();
541
+ if (configPathOverride)
542
+ return resolveUserPath(configPathOverride);
543
+ return join(resolveStateDir(), "openclaw.json");
544
+ }
545
+ /** Check if a binary is executable on the system PATH. Mirrors gateway's hasBinary. */
546
+ const _binaryCache = new Map();
547
+ function hasBinarySync(bin) {
548
+ const cached = _binaryCache.get(bin);
549
+ if (cached !== undefined)
550
+ return cached;
551
+ const delimiter = process.platform === "win32" ? ";" : ":";
552
+ const parts = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
553
+ const extensions = process.platform === "win32"
554
+ ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")
555
+ : [""];
556
+ for (const part of parts) {
557
+ for (const ext of extensions) {
558
+ try {
559
+ accessSync(join(part, bin + ext), fsConstants.X_OK);
560
+ _binaryCache.set(bin, true);
561
+ return true;
562
+ }
563
+ catch { /* keep searching */ }
564
+ }
565
+ }
566
+ _binaryCache.set(bin, false);
567
+ return false;
568
+ }
569
+ /**
570
+ * Relaxed JSON5 parser: removes trailing commas before } or ].
571
+ * Used for SKILL.md metadata blocks which use JSON5 syntax (trailing commas,
572
+ * multi-line objects) but do NOT contain // or /* comments in practice.
573
+ *
574
+ * NOTE: We intentionally do NOT strip // comments here because that regex
575
+ * would incorrectly match URLs like "https://..." inside string values.
576
+ */
577
+ function parseJson5Relaxed(text) {
578
+ // Remove trailing commas before } or ] (the main JSON5 feature in SKILL.md)
579
+ const s = text.replace(/,(\s*[}\]])/g, "$1");
580
+ return JSON.parse(s);
581
+ }
582
+ /**
583
+ * Extract the raw YAML block between --- delimiters.
584
+ * Mirrors gateway's extractFrontmatterBlock.
585
+ */
586
+ function extractFrontmatterBlock(content) {
587
+ const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
588
+ if (!normalized.startsWith("---"))
589
+ return undefined;
590
+ const endIndex = normalized.indexOf("\n---", 3);
591
+ if (endIndex === -1)
592
+ return undefined;
593
+ return normalized.slice(4, endIndex);
594
+ }
595
+ /**
596
+ * Parse SKILL.md frontmatter to plain string key-value pairs.
597
+ * Handles both single-line and multi-line (indented) values.
598
+ * Mirrors gateway's parseLineFrontmatter + lineFrontmatterToPlain.
599
+ */
600
+ function parseSkillFrontmatter(content) {
601
+ const block = extractFrontmatterBlock(content);
602
+ if (!block)
603
+ return {};
604
+ const result = {};
605
+ const lines = block.split("\n");
606
+ let i = 0;
607
+ while (i < lines.length) {
608
+ const match = lines[i].match(/^([\w-]+):\s*(.*)$/);
609
+ if (!match) {
610
+ i++;
611
+ continue;
612
+ }
613
+ const key = match[1];
614
+ const inlineValue = match[2].trim();
615
+ // Handle YAML multi-line indicators (| or >) and empty inline values
616
+ const isMultilineIndicator = inlineValue === "|" || inlineValue === ">" || inlineValue === "|-" || inlineValue === ">-";
617
+ if ((!inlineValue || isMultilineIndicator) && i + 1 < lines.length) {
618
+ const nextLine = lines[i + 1];
619
+ if (nextLine.startsWith(" ") || nextLine.startsWith("\t")) {
620
+ // Multi-line: collect all indented lines
621
+ const valueLines = [];
622
+ let j = i + 1;
623
+ while (j < lines.length && (lines[j].startsWith(" ") || lines[j].startsWith("\t") || lines[j] === "")) {
624
+ valueLines.push(lines[j]);
625
+ j++;
626
+ }
627
+ const value = valueLines.join("\n").trim();
628
+ if (value)
629
+ result[key] = value;
630
+ i = j;
631
+ continue;
632
+ }
633
+ }
634
+ if (inlineValue && !isMultilineIndicator) {
635
+ result[key] = inlineValue.replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1");
636
+ }
637
+ i++;
638
+ }
639
+ return result;
640
+ }
641
+ /**
642
+ * Extract openclaw manifest from parsed frontmatter.
643
+ * The metadata field is a JSON5 object with key "openclaw".
644
+ * Mirrors gateway's resolveOpenClawManifestBlock + resolveOpenClawMetadata.
645
+ */
646
+ function resolveOpenClawManifest(frontmatter) {
647
+ const metadataRaw = frontmatter.metadata;
648
+ if (!metadataRaw)
649
+ return undefined;
650
+ let manifest;
651
+ try {
652
+ const parsed = parseJson5Relaxed(metadataRaw);
653
+ if (!parsed || typeof parsed !== "object")
654
+ return undefined;
655
+ // Try known openclaw manifest keys
656
+ manifest = parsed.openclaw ?? parsed["openclaw-agent"] ?? parsed["claude-code"];
657
+ if (!manifest && typeof parsed === "object") {
658
+ // If no known wrapper key, check if the parsed object itself looks like a manifest
659
+ const keys = Object.keys(parsed);
660
+ if (keys.some(k => ["requires", "install", "always", "emoji"].includes(k)))
661
+ manifest = parsed;
662
+ }
663
+ if (!manifest || typeof manifest !== "object")
664
+ return undefined;
665
+ }
666
+ catch {
667
+ return undefined;
668
+ }
669
+ const normalizeStrList = (v) => {
670
+ if (!v)
671
+ return [];
672
+ if (Array.isArray(v))
673
+ return v.map(String).filter(Boolean);
674
+ if (typeof v === "string" && v.trim())
675
+ return [v.trim()];
676
+ return [];
677
+ };
678
+ const requiresRaw = manifest.requires;
679
+ const requires = {};
680
+ if (requiresRaw && typeof requiresRaw === "object") {
681
+ requires.bins = normalizeStrList(requiresRaw.bins);
682
+ requires.anyBins = normalizeStrList(requiresRaw.anyBins);
683
+ requires.env = normalizeStrList(requiresRaw.env);
684
+ requires.config = normalizeStrList(requiresRaw.config);
685
+ }
686
+ const install = [];
687
+ if (Array.isArray(manifest.install)) {
688
+ for (const spec of manifest.install) {
689
+ if (!spec || typeof spec !== "object")
690
+ continue;
691
+ install.push({
692
+ id: typeof spec.id === "string" ? spec.id : undefined,
693
+ kind: typeof spec.kind === "string" ? spec.kind : typeof spec.type === "string" ? spec.type : undefined,
694
+ bins: normalizeStrList(spec.bins),
695
+ label: typeof spec.label === "string" ? spec.label : undefined,
696
+ formula: typeof spec.formula === "string" ? spec.formula : undefined,
697
+ package: typeof spec.package === "string" ? spec.package : undefined,
698
+ module: typeof spec.module === "string" ? spec.module : undefined,
699
+ os: normalizeStrList(spec.os),
700
+ });
701
+ }
702
+ }
703
+ return {
704
+ always: typeof manifest.always === "boolean" ? manifest.always : undefined,
705
+ emoji: typeof manifest.emoji === "string" ? manifest.emoji : undefined,
706
+ homepage: typeof manifest.homepage === "string" ? manifest.homepage : undefined,
707
+ skillKey: typeof manifest.skillKey === "string" ? manifest.skillKey : undefined,
708
+ primaryEnv: typeof manifest.primaryEnv === "string" ? manifest.primaryEnv : undefined,
709
+ os: normalizeStrList(manifest.os),
710
+ requires: Object.keys(requires).length > 0 ? requires : undefined,
711
+ install: install.length > 0 ? install : undefined,
712
+ };
713
+ }
714
+ /**
715
+ * Load all skills from a directory. Each skill lives in a subdirectory with a SKILL.md file.
716
+ * Mirrors gateway's loadSkillsFromDirSafe.
717
+ */
718
+ function loadSkillsFromDir(dir, source) {
719
+ let rootReal;
720
+ try {
721
+ rootReal = realpathSync(resolve(dir));
722
+ }
723
+ catch {
724
+ return [];
725
+ }
726
+ let subdirs;
727
+ try {
728
+ subdirs = readdirSync(dir, { withFileTypes: true })
729
+ .filter(e => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules")
730
+ .map(e => join(dir, e.name))
731
+ .sort();
732
+ }
733
+ catch {
734
+ return [];
735
+ }
736
+ const entries = [];
737
+ for (const skillDir of subdirs) {
738
+ const skillMdPath = join(skillDir, "SKILL.md");
739
+ if (!existsSync(skillMdPath))
740
+ continue;
741
+ let content;
742
+ try {
743
+ content = readFileSync(skillMdPath, "utf-8");
744
+ }
745
+ catch {
746
+ continue;
747
+ }
748
+ const frontmatter = parseSkillFrontmatter(content);
749
+ const name = (frontmatter.name ?? "").trim() || basename(skillDir);
750
+ const description = (frontmatter.description ?? "").trim();
751
+ if (!name || !description)
752
+ continue;
753
+ entries.push({
754
+ name,
755
+ description,
756
+ filePath: resolve(skillMdPath),
757
+ baseDir: resolve(skillDir),
758
+ source,
759
+ frontmatter,
760
+ metadata: resolveOpenClawManifest(frontmatter),
761
+ });
762
+ }
763
+ return entries;
764
+ }
765
+ /**
766
+ * Resolve the bundled openclaw skills directory.
767
+ * Searches multiple known installation paths (npm, pnpm, nvm, etc.)
768
+ */
769
+ function resolveBundledSkillsDir() {
770
+ const override = (process.env.OPENCLAW_BUNDLED_SKILLS_DIR ?? "").trim();
771
+ if (override)
772
+ return override;
773
+ const candidates = [];
774
+ // 1. Adjacent to node binary (nvm-style installs)
775
+ try {
776
+ candidates.push(join(dirname(process.execPath), "skills"));
777
+ }
778
+ catch { }
779
+ // 2. From argv[1] (openclaw.mjs inside gateway process)
780
+ try {
781
+ const argv1 = process.argv[1] ?? "";
782
+ if (argv1)
783
+ candidates.push(join(dirname(argv1), "skills"));
784
+ }
785
+ catch { }
786
+ // 3. Standard npm global: {execPath}/../lib/node_modules/openclaw/skills
787
+ try {
788
+ candidates.push(join(dirname(process.execPath), "..", "lib", "node_modules", "openclaw", "skills"));
789
+ }
790
+ catch { }
791
+ // 4. ~/.npm-global/lib/node_modules/openclaw/skills (common npm prefix)
792
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
793
+ if (home) {
794
+ candidates.push(join(home, ".npm-global", "lib", "node_modules", "openclaw", "skills"));
795
+ }
796
+ // 5. Resolve from `which openclaw` symlink → package root
797
+ try {
798
+ const openclawBin = join(home, ".npm-global", "bin", "openclaw");
799
+ if (existsSync(openclawBin)) {
800
+ const realPath = realpathSync(openclawBin);
801
+ // realPath = .../openclaw/openclaw.mjs → dirname = .../openclaw/
802
+ candidates.push(join(dirname(realPath), "skills"));
803
+ }
804
+ }
805
+ catch { }
806
+ // 6. pnpm global store (glob-style search for latest version)
807
+ if (home) {
808
+ try {
809
+ const pnpmBase = join(home, "Library", "pnpm", "global", "5", ".pnpm");
810
+ if (existsSync(pnpmBase)) {
811
+ const dirs = readdirSync(pnpmBase)
812
+ .filter(d => d.startsWith("openclaw@"))
813
+ .sort()
814
+ .reverse();
815
+ for (const d of dirs) {
816
+ candidates.push(join(pnpmBase, d, "node_modules", "openclaw", "skills"));
817
+ }
818
+ }
819
+ }
820
+ catch { }
821
+ }
822
+ for (const candidate of candidates) {
823
+ try {
824
+ if (existsSync(candidate))
825
+ return candidate;
826
+ }
827
+ catch { }
828
+ }
829
+ return undefined;
830
+ }
831
+ /**
832
+ * Resolve skill directories registered by enabled plugins.
833
+ * Each plugin's openclaw.plugin.json may declare "skills": ["./skills"]
834
+ * relative to the plugin's root directory.
835
+ *
836
+ * Only plugins explicitly listed in config.plugins.entries with enabled !== false
837
+ * are considered. Unlisted plugins are NOT loaded.
838
+ */
839
+ function resolvePluginSkillDirs(cfg) {
840
+ const dirs = [];
841
+ const pluginEntries = cfg?.plugins?.entries;
842
+ if (!pluginEntries || typeof pluginEntries !== "object")
843
+ return dirs;
844
+ // Collect explicitly enabled plugin IDs
845
+ const enabledPluginIds = new Set();
846
+ for (const [pluginId, entry] of Object.entries(pluginEntries)) {
847
+ if (entry?.enabled !== false)
848
+ enabledPluginIds.add(pluginId);
849
+ }
850
+ if (enabledPluginIds.size === 0)
851
+ return dirs;
852
+ /** Read skills paths from a plugin manifest file */
853
+ const readManifestSkills = (manifestPath) => {
854
+ try {
855
+ if (!existsSync(manifestPath))
856
+ return [];
857
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
858
+ if (!Array.isArray(manifest?.skills))
859
+ return [];
860
+ const result = [];
861
+ for (const s of manifest.skills) {
862
+ const rel = String(s ?? "").trim();
863
+ if (!rel)
864
+ continue;
865
+ const candidate = resolve(dirname(manifestPath), rel);
866
+ if (existsSync(candidate))
867
+ result.push(candidate);
868
+ }
869
+ return result;
870
+ }
871
+ catch {
872
+ return [];
873
+ }
874
+ };
875
+ // 1. User-installed plugins (plugins.installs)
876
+ const pluginInstalls = cfg?.plugins?.installs;
877
+ if (pluginInstalls && typeof pluginInstalls === "object") {
878
+ for (const [pluginId, install] of Object.entries(pluginInstalls)) {
879
+ if (!enabledPluginIds.has(pluginId))
880
+ continue;
881
+ const installPath = String(install?.installPath ?? "").trim();
882
+ if (!installPath)
883
+ continue;
884
+ dirs.push(...readManifestSkills(join(resolveUserPath(installPath), "openclaw.plugin.json")));
885
+ }
886
+ }
887
+ // 2. Bundled extensions (only for enabled plugins)
888
+ const bundledDir = resolveBundledSkillsDir();
889
+ if (bundledDir) {
890
+ const extensionsDir = resolve(bundledDir, "..", "dist", "extensions");
891
+ try {
892
+ if (existsSync(extensionsDir)) {
893
+ for (const pluginId of enabledPluginIds) {
894
+ const extManifest = join(extensionsDir, pluginId, "openclaw.plugin.json");
895
+ dirs.push(...readManifestSkills(extManifest));
896
+ }
897
+ }
898
+ }
899
+ catch { /* skip */ }
900
+ }
901
+ return dirs;
902
+ }
903
+ /**
904
+ * Load all skill entries visible in a workspace.
905
+ * Sources (later overrides earlier):
906
+ * extra (config extraDirs + plugin skills) → bundled → installed → workspace
907
+ */
908
+ function loadAllSkillEntries(workspaceDir, cfg) {
909
+ const stateDir = resolveStateDir();
910
+ const merged = new Map();
911
+ const addAll = (entries) => { for (const e of entries)
912
+ merged.set(e.name, e); };
913
+ // 1. Extra dirs: config extraDirs + plugin-registered skill dirs
914
+ const extraDirs = [];
915
+ if (Array.isArray(cfg?.skills?.load?.extraDirs)) {
916
+ for (const d of cfg.skills.load.extraDirs) {
917
+ const s = String(d ?? "").trim();
918
+ if (s)
919
+ extraDirs.push(resolveUserPath(s));
920
+ }
921
+ }
922
+ const pluginSkillDirs = resolvePluginSkillDirs(cfg);
923
+ for (const dir of [...extraDirs, ...pluginSkillDirs])
924
+ addAll(loadSkillsFromDir(dir, "extra"));
925
+ // 2. Bundled (openclaw package built-in)
926
+ const bundledDir = resolveBundledSkillsDir();
927
+ if (bundledDir)
928
+ addAll(loadSkillsFromDir(bundledDir, "builtin"));
929
+ // 3. Installed (~/.openclaw/skills/)
930
+ addAll(loadSkillsFromDir(join(stateDir, "skills"), "installed"));
931
+ // 4. Workspace skills ({workspace}/skills/) — highest priority
932
+ addAll(loadSkillsFromDir(join(resolve(workspaceDir), "skills"), "workspace"));
933
+ return Array.from(merged.values());
934
+ }
935
+ /**
936
+ * Load the full openclaw config from disk.
937
+ * api.config may not contain all sections (e.g. skills.entries),
938
+ * so we read openclaw.json directly for complete data.
939
+ */
940
+ let _diskConfigCache = null;
941
+ function loadFullConfig() {
942
+ const configPath = resolveOpenClawConfigPath();
943
+ try {
944
+ const st = statSync(configPath);
945
+ if (_diskConfigCache && _diskConfigCache.mtimeMs === Math.floor(st.mtimeMs)) {
946
+ return _diskConfigCache.cfg;
947
+ }
948
+ const raw = readFileSync(configPath, "utf-8");
949
+ const cfg = JSON.parse(raw);
950
+ _diskConfigCache = { cfg, mtimeMs: Math.floor(st.mtimeMs) };
951
+ return cfg;
952
+ }
953
+ catch {
954
+ return {};
955
+ }
956
+ }
957
+ /**
958
+ * Build runtime status for a single skill entry.
959
+ */
960
+ function buildSkillStatus(entry, cfg) {
961
+ // Read skills config from disk to ensure we have skills.entries
962
+ const fullCfg = loadFullConfig();
963
+ const skillKey = entry.metadata?.skillKey ?? entry.name;
964
+ const skillCfg = fullCfg?.skills?.entries?.[skillKey] ?? {};
965
+ const disabled = skillCfg.enabled === false;
966
+ const always = entry.metadata?.always === true;
967
+ const bundled = entry.source === "builtin";
968
+ const requiresBins = entry.metadata?.requires?.bins ?? [];
969
+ const requiresAnyBins = entry.metadata?.requires?.anyBins ?? [];
970
+ const requiresEnv = entry.metadata?.requires?.env ?? [];
971
+ const requiresConfig = entry.metadata?.requires?.config ?? [];
972
+ const isEnvSatisfied = (name) => Boolean(process.env[name] || skillCfg?.env?.[name] ||
973
+ (skillCfg?.apiKey && entry.metadata?.primaryEnv === name));
974
+ const missingBins = always ? [] : requiresBins.filter(b => !hasBinarySync(b));
975
+ const missingAnyBins = always ? [] :
976
+ (requiresAnyBins.length > 0 && !requiresAnyBins.some(b => hasBinarySync(b)) ? requiresAnyBins : []);
977
+ const missingEnv = always ? [] : requiresEnv.filter(e => !isEnvSatisfied(e));
978
+ const isConfigPathSatisfied = (configPath) => {
979
+ // configPath like "channels.discord.token" → check fullCfg.channels.discord.token
980
+ const parts = configPath.split(".");
981
+ let current = fullCfg;
982
+ for (const part of parts) {
983
+ if (!current || typeof current !== "object")
984
+ return false;
985
+ current = current[part];
986
+ }
987
+ return current !== undefined && current !== null && current !== "";
988
+ };
989
+ const configChecks = requiresConfig.map(p => ({ path: p, satisfied: isConfigPathSatisfied(p) }));
990
+ const missingConfig = always ? [] : configChecks.filter(c => !c.satisfied).map(c => c.path);
991
+ const requirementsSatisfied = missingBins.length === 0 && missingAnyBins.length === 0 &&
992
+ missingEnv.length === 0 && missingConfig.length === 0;
993
+ return {
994
+ name: entry.name,
995
+ description: entry.description,
996
+ source: entry.source,
997
+ bundled,
998
+ filePath: entry.filePath,
999
+ baseDir: entry.baseDir,
1000
+ skillKey,
1001
+ ...(entry.metadata?.primaryEnv ? { primaryEnv: entry.metadata.primaryEnv } : {}),
1002
+ ...(entry.metadata?.emoji ? { emoji: entry.metadata.emoji } : {}),
1003
+ ...(entry.metadata?.homepage ? { homepage: entry.metadata.homepage } : {}),
1004
+ always,
1005
+ disabled,
1006
+ blockedByAllowlist: false, // simplified: bundled allowlist check omitted
1007
+ eligible: !disabled && requirementsSatisfied,
1008
+ requirements: { bins: requiresBins, anyBins: requiresAnyBins, env: requiresEnv, config: requiresConfig, os: entry.metadata?.os ?? [] },
1009
+ missing: { bins: missingBins, anyBins: missingAnyBins, env: missingEnv, config: missingConfig, os: [] },
1010
+ configChecks,
1011
+ install: (entry.metadata?.install ?? []).map((spec, idx) => ({
1012
+ id: spec.id ?? `${spec.kind ?? "install"}-${idx}`,
1013
+ kind: spec.kind ?? "",
1014
+ label: spec.label ?? `Install (${spec.kind ?? "unknown"})`,
1015
+ bins: spec.bins ?? [],
1016
+ })),
1017
+ };
1018
+ }
1019
+ /**
1020
+ * Fetch from ClawHub API.
1021
+ * Base URL: OPENCLAW_CLAWHUB_URL or CLAWHUB_URL env, or https://clawhub.ai
1022
+ * Mirrors gateway's fetchJson (clawhub-t8tftw_j.js).
1023
+ */
1024
+ async function fetchClawHub(path, searchParams) {
1025
+ const baseUrl = ((process.env.OPENCLAW_CLAWHUB_URL ?? "").trim() ||
1026
+ (process.env.CLAWHUB_URL ?? "").trim() ||
1027
+ "https://clawhub.ai").replace(/\/+$/, "");
1028
+ let url = `${baseUrl}${path}`;
1029
+ if (searchParams && Object.keys(searchParams).length > 0) {
1030
+ url += `?${new URLSearchParams(searchParams).toString()}`;
1031
+ }
1032
+ const controller = new AbortController();
1033
+ const timer = setTimeout(() => controller.abort(), 30000);
1034
+ try {
1035
+ const response = await fetch(url, { signal: controller.signal });
1036
+ if (!response.ok) {
1037
+ throw new Error(`ClawHub ${path} failed (${response.status}): ${response.statusText}`);
1038
+ }
1039
+ return await response.json();
1040
+ }
1041
+ finally {
1042
+ clearTimeout(timer);
1043
+ }
1044
+ }
1045
+ // ---------------------------------------------------------------------------
1046
+ // Skills method handlers (file-based, no gateway relay)
1047
+ // ---------------------------------------------------------------------------
1048
+ /**
1049
+ * skills.status — read skill status from disk, grouped by source category.
1050
+ *
1051
+ * Categories:
1052
+ * extra — skills.load.extraDirs → "Extra Skills"
1053
+ * builtin — openclaw 安装包内置 → "Built-in Skills"
1054
+ * installed — ~/.openclaw/skills/ → "Installed Skills"
1055
+ * workspace — {workspace}/skills/ → "Workspace Skills"
1056
+ */
1057
+ async function handleSkillsStatus(api, params) {
1058
+ const cfg = getConfig(api);
1059
+ const rawAgentId = String(params.agentId ?? "").trim();
1060
+ const agentId = rawAgentId ? normalizeAgentId(rawAgentId) : resolveDefaultAgentId(cfg);
1061
+ const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
1062
+ const entries = loadAllSkillEntries(workspaceDir, cfg);
1063
+ const categoryLabels = {
1064
+ extra: "Extra Skills",
1065
+ builtin: "Built-in Skills",
1066
+ installed: "Installed Skills",
1067
+ workspace: "Workspace Skills",
1068
+ };
1069
+ const grouped = {};
1070
+ for (const e of entries) {
1071
+ const cat = e.source;
1072
+ if (!grouped[cat])
1073
+ grouped[cat] = [];
1074
+ grouped[cat].push(buildSkillStatus(e, cfg));
1075
+ }
1076
+ const categories = Object.entries(grouped).map(([source, skills]) => ({
1077
+ source,
1078
+ label: categoryLabels[source] ?? source,
1079
+ skills,
1080
+ }));
1081
+ portalLog(api, "info", `skills.status: agentId=${agentId} workspace=${workspaceDir} count=${entries.length}`);
1082
+ return { agentId, workspaceDir, categories };
1083
+ }
1084
+ /**
1085
+ * Resolve agent-level skill filter (whitelist).
1086
+ * Returns undefined if no filter is configured (all skills enabled for this agent).
1087
+ * Returns a Set of allowed skill names if a filter is configured.
1088
+ */
1089
+ function resolveAgentSkillFilter(cfg, agentId) {
1090
+ const normalizedId = normalizeAgentId(agentId);
1091
+ const agentEntry = (cfg.agents?.list ?? []).find((e) => normalizeAgentId(e?.id ?? "") === normalizedId);
1092
+ // Agent-level skills field takes precedence
1093
+ if (agentEntry && agentEntry.skills !== undefined) {
1094
+ const list = Array.isArray(agentEntry.skills) ? agentEntry.skills : [];
1095
+ return new Set(list.map((s) => String(s ?? "").trim()).filter(Boolean));
1096
+ }
1097
+ // Fallback to defaults
1098
+ const defaultSkills = cfg.agents?.defaults?.skills;
1099
+ if (defaultSkills !== undefined) {
1100
+ const list = Array.isArray(defaultSkills) ? defaultSkills : [];
1101
+ return new Set(list.map((s) => String(s ?? "").trim()).filter(Boolean));
1102
+ }
1103
+ // No filter — all skills enabled for this agent
1104
+ return undefined;
1105
+ }
1106
+ /**
1107
+ * agent.skills.status — same as skills.status but with an additional
1108
+ * `agent_disabled` field per skill indicating whether the skill is disabled
1109
+ * for this specific agent (via agent-level skill filter).
1110
+ */
1111
+ async function handleAgentSkillsStatus(api, params) {
1112
+ const rawAgentId = String(params.agentId ?? "").trim();
1113
+ if (!rawAgentId)
1114
+ throw { code: 400, message: "agentId is required" };
1115
+ const cfg = getConfig(api);
1116
+ const fullCfg = loadFullConfig();
1117
+ const agentId = normalizeAgentId(rawAgentId);
1118
+ const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
1119
+ const entries = loadAllSkillEntries(workspaceDir, cfg);
1120
+ const skillFilter = resolveAgentSkillFilter(fullCfg, agentId);
1121
+ const categoryLabels = {
1122
+ extra: "Extra Skills",
1123
+ builtin: "Built-in Skills",
1124
+ installed: "Installed Skills",
1125
+ workspace: "Workspace Skills",
1126
+ };
1127
+ const grouped = {};
1128
+ for (const e of entries) {
1129
+ const cat = e.source;
1130
+ if (!grouped[cat])
1131
+ grouped[cat] = [];
1132
+ const status = buildSkillStatus(e, cfg);
1133
+ // agent_disabled: true if a skill filter exists and this skill is NOT in the whitelist
1134
+ const agentDisabled = skillFilter !== undefined && !skillFilter.has(e.name);
1135
+ grouped[cat].push({ ...status, agent_disabled: agentDisabled });
1136
+ }
1137
+ const categories = Object.entries(grouped).map(([source, skills]) => ({
1138
+ source,
1139
+ label: categoryLabels[source] ?? source,
1140
+ skills,
1141
+ }));
1142
+ portalLog(api, "info", `agent.skills.status: agentId=${agentId} workspace=${workspaceDir} count=${entries.length} filter=${skillFilter ? `[${skillFilter.size}]` : "none"}`);
1143
+ return { agentId, workspaceDir, categories };
1144
+ }
1145
+ /**
1146
+ * skills.search — search ClawHub registry directly.
1147
+ *
1148
+ * GET https://clawhub.ai/api/v1/search?q={query}&limit={limit}
1149
+ * Mirrors gateway's searchSkillsFromClawHub.
1150
+ */
1151
+ async function handleSkillsSearch(api, params) {
1152
+ const query = String(params.query ?? "").trim();
1153
+ if (!query)
1154
+ return { results: [] };
1155
+ try {
1156
+ const searchParams = { q: query };
1157
+ if (params.limit)
1158
+ searchParams.limit = String(params.limit);
1159
+ const result = await fetchClawHub("/api/v1/search", searchParams);
1160
+ const results = result?.results ?? [];
1161
+ portalLog(api, "info", `skills.search: query="${query}" got ${results.length} results`);
1162
+ return { results };
1163
+ }
1164
+ catch (err) {
1165
+ portalLog(api, "warn", `skills.search: ClawHub error: ${err?.message ?? String(err)}`);
1166
+ return { results: [] };
1167
+ }
1168
+ }
1169
+ /**
1170
+ * Extract a tar.gz archive into targetDir, auto-detecting whether to strip
1171
+ * a single wrapping directory.
1172
+ */
1173
+ async function extractTarGz(archivePath, targetDir) {
1174
+ const execFileAsync = promisify(execFile);
1175
+ const { stdout } = await execFileAsync("tar", ["tzf", archivePath]);
1176
+ const entries = stdout.trim().split("\n").filter(Boolean);
1177
+ const topDirs = new Set(entries.map(e => e.split("/")[0]));
1178
+ const needsStrip = topDirs.size === 1 && entries.every(e => e.includes("/"));
1179
+ if (needsStrip) {
1180
+ await execFileAsync("tar", ["xzf", archivePath, "-C", targetDir, "--strip-components=1"]);
1181
+ }
1182
+ else {
1183
+ await execFileAsync("tar", ["xzf", archivePath, "-C", targetDir]);
1184
+ }
1185
+ }
1186
+ /**
1187
+ * skills.install — install a skill into an agent's workspace.
1188
+ *
1189
+ * Two modes:
1190
+ * source=clawhub (default): download from ClawHub by slug
1191
+ * source=url: download a zip from the given URL
1192
+ *
1193
+ * Both extract into {workspace}/skills/{skillKey}/
1194
+ */
1195
+ async function handleSkillsInstall(api, params) {
1196
+ const source = String(params.source ?? "clawhub").trim();
1197
+ const cfg = getConfig(api);
1198
+ const rawAgentId = String(params.agentId ?? "").trim();
1199
+ const agentId = rawAgentId ? normalizeAgentId(rawAgentId) : resolveDefaultAgentId(cfg);
1200
+ const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
1201
+ const execFileAsync = promisify(execFile);
1202
+ if (source === "url") {
1203
+ // --- URL install: download zip and extract ---
1204
+ const url = String(params.url ?? "").trim();
1205
+ if (!url)
1206
+ throw { code: 400, message: "url is required for source=url" };
1207
+ const skillKey = String(params.skillKey ?? params.name ?? "").trim();
1208
+ if (!skillKey) {
1209
+ throw { code: 400, message: "skillKey (or name) is required — the directory name under skills/" };
1210
+ }
1211
+ // Download
1212
+ let archiveBytes;
1213
+ try {
1214
+ const controller = new AbortController();
1215
+ const timer = setTimeout(() => controller.abort(), 120000);
1216
+ try {
1217
+ const response = await fetch(url, { signal: controller.signal });
1218
+ if (!response.ok)
1219
+ throw new Error(`download failed (${response.status})`);
1220
+ archiveBytes = new Uint8Array(await response.arrayBuffer());
1221
+ }
1222
+ finally {
1223
+ clearTimeout(timer);
1224
+ }
1225
+ }
1226
+ catch (err) {
1227
+ throw { code: 503, message: `skills.install: download failed: ${err.message}` };
1228
+ }
1229
+ const targetDir = join(resolve(workspaceDir), "skills", skillKey);
1230
+ const isZip = url.endsWith(".zip") || archiveBytes[0] === 0x50 && archiveBytes[1] === 0x4b; // PK magic
1231
+ const tmpFile = join(tmpdir(), `openclaw-skill-${skillKey}-${Date.now()}${isZip ? ".zip" : ".tar.gz"}`);
1232
+ try {
1233
+ await writeFile(tmpFile, archiveBytes);
1234
+ // Clean existing dir and recreate
1235
+ await rm(targetDir, { recursive: true, force: true });
1236
+ await mkdir(targetDir, { recursive: true });
1237
+ if (isZip) {
1238
+ await execFileAsync("unzip", ["-o", tmpFile, "-d", targetDir]);
1239
+ // If zip contains a single root directory, move contents up
1240
+ try {
1241
+ const items = readdirSync(targetDir);
1242
+ if (items.length === 1) {
1243
+ const innerDir = join(targetDir, items[0]);
1244
+ const innerStat = statSync(innerDir);
1245
+ if (innerStat.isDirectory()) {
1246
+ const innerItems = readdirSync(innerDir);
1247
+ for (const item of innerItems) {
1248
+ await execFileAsync("mv", [join(innerDir, item), targetDir]);
1249
+ }
1250
+ await execFileAsync("rm", ["-rf", innerDir]);
1251
+ }
1252
+ }
1253
+ }
1254
+ catch { /* best effort flatten */ }
1255
+ }
1256
+ else {
1257
+ await extractTarGz(tmpFile, targetDir);
1258
+ }
1259
+ }
1260
+ catch (err) {
1261
+ throw { code: 500, message: `skills.install: extraction failed: ${err.message}` };
1262
+ }
1263
+ finally {
1264
+ try {
1265
+ await unlink(tmpFile);
1266
+ }
1267
+ catch { /* cleanup */ }
1268
+ }
1269
+ portalLog(api, "info", `skills.install: source=url agentId=${agentId} skillKey="${skillKey}" targetDir="${targetDir}"`);
1270
+ return { ok: true, message: `Installed ${skillKey} from URL`, agentId, skillKey, targetDir };
1271
+ }
1272
+ if (source === "clawhub") {
1273
+ // --- ClawHub install: download by slug ---
1274
+ const slug = String(params.slug ?? params.name ?? "").trim();
1275
+ if (!slug)
1276
+ throw { code: 400, message: "slug (or name) is required for source=clawhub" };
1277
+ let detail;
1278
+ try {
1279
+ detail = await fetchClawHub(`/api/v1/skills/${encodeURIComponent(slug)}`);
1280
+ }
1281
+ catch (err) {
1282
+ throw { code: 503, message: `skills.install: ClawHub fetch failed: ${err.message}` };
1283
+ }
1284
+ if (!detail?.skill)
1285
+ throw { code: 404, message: `Skill "${slug}" not found on ClawHub` };
1286
+ const version = String(params.version ?? detail.latestVersion?.version ?? "").trim();
1287
+ if (!version)
1288
+ throw { code: 400, message: `Skill "${slug}" has no installable version` };
1289
+ let archiveBytes;
1290
+ try {
1291
+ const searchParams = { version };
1292
+ const archiveUrl = ((process.env.OPENCLAW_CLAWHUB_URL ?? process.env.CLAWHUB_URL ?? "https://clawhub.ai").replace(/\/+$/, "")
1293
+ + `/api/v1/packages/${encodeURIComponent(slug)}/download?` + new URLSearchParams(searchParams));
1294
+ const controller = new AbortController();
1295
+ const timer = setTimeout(() => controller.abort(), 60000);
1296
+ try {
1297
+ const response = await fetch(archiveUrl, { signal: controller.signal });
1298
+ if (!response.ok)
1299
+ throw new Error(`download failed (${response.status})`);
1300
+ archiveBytes = new Uint8Array(await response.arrayBuffer());
1301
+ }
1302
+ finally {
1303
+ clearTimeout(timer);
1304
+ }
1305
+ }
1306
+ catch (err) {
1307
+ throw { code: 503, message: `skills.install: download failed: ${err.message}` };
1308
+ }
1309
+ const targetDir = join(resolve(workspaceDir), "skills", slug);
1310
+ const tmpArchive = join(tmpdir(), `openclaw-skill-${slug}-${Date.now()}.tar.gz`);
1311
+ try {
1312
+ await writeFile(tmpArchive, archiveBytes);
1313
+ // Clean existing dir and recreate
1314
+ await rm(targetDir, { recursive: true, force: true });
1315
+ await mkdir(targetDir, { recursive: true });
1316
+ await extractTarGz(tmpArchive, targetDir);
1317
+ }
1318
+ catch (err) {
1319
+ throw { code: 500, message: `skills.install: extraction failed: ${err.message}` };
1320
+ }
1321
+ finally {
1322
+ try {
1323
+ await unlink(tmpArchive);
1324
+ }
1325
+ catch { /* cleanup */ }
1326
+ }
1327
+ portalLog(api, "info", `skills.install: source=clawhub agentId=${agentId} slug="${slug}" version="${version}" targetDir="${targetDir}"`);
1328
+ return { ok: true, message: `Installed ${slug}@${version}`, agentId, slug, version, targetDir };
1329
+ }
1330
+ throw { code: 400, message: `unsupported source: "${source}". Use "clawhub" or "url"` };
1331
+ }
1332
+ /**
1333
+ * skills.set — set a skill's enabled/disabled state globally.
1334
+ *
1335
+ * Only modifies the `enabled` field in skills.entries[skillKey],
1336
+ * preserving all other fields (env, apiKey, etc.).
1337
+ * If enabled is true, the `enabled` key is removed (default is enabled).
1338
+ */
1339
+ async function handleSkillsSet(api, params) {
1340
+ const skillKey = String(params.skillKey ?? params.name ?? "").trim();
1341
+ if (!skillKey)
1342
+ throw { code: 400, message: "skillKey (or name) is required" };
1343
+ if (typeof params.enabled !== "boolean") {
1344
+ throw { code: 400, message: "enabled (boolean) is required" };
1345
+ }
1346
+ const enabled = params.enabled;
1347
+ const configPath = resolveOpenClawConfigPath();
1348
+ // Read existing config
1349
+ let cfg = {};
1350
+ try {
1351
+ const raw = await readFile(configPath, "utf-8");
1352
+ cfg = JSON.parse(raw);
1353
+ }
1354
+ catch (err) {
1355
+ if (err.code !== "ENOENT") {
1356
+ throw { code: 500, message: `skills.set: failed to read config: ${err.message}` };
1357
+ }
1358
+ }
1359
+ // Ensure skills.entries structure exists
1360
+ if (!cfg.skills || typeof cfg.skills !== "object")
1361
+ cfg.skills = {};
1362
+ if (!cfg.skills.entries || typeof cfg.skills.entries !== "object")
1363
+ cfg.skills.entries = {};
1364
+ // Get or create the entry, preserving existing fields
1365
+ const current = cfg.skills.entries[skillKey] && typeof cfg.skills.entries[skillKey] === "object"
1366
+ ? { ...cfg.skills.entries[skillKey] }
1367
+ : {};
1368
+ if (enabled) {
1369
+ // Enabled is the default — remove the key to keep config clean
1370
+ delete current.enabled;
1371
+ // If entry is now empty, remove it entirely
1372
+ if (Object.keys(current).length === 0) {
1373
+ delete cfg.skills.entries[skillKey];
1374
+ }
1375
+ else {
1376
+ cfg.skills.entries[skillKey] = current;
1377
+ }
1378
+ }
1379
+ else {
1380
+ current.enabled = false;
1381
+ cfg.skills.entries[skillKey] = current;
1382
+ }
1383
+ // Write back
1384
+ try {
1385
+ await mkdir(dirname(configPath), { recursive: true });
1386
+ await writeFile(configPath, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
1387
+ }
1388
+ catch (err) {
1389
+ throw { code: 500, message: `skills.set: failed to write config: ${err.message}` };
1390
+ }
1391
+ // Invalidate disk config cache
1392
+ _diskConfigCache = null;
1393
+ portalLog(api, "info", `skills.set: skillKey=${skillKey} enabled=${enabled}`);
1394
+ return { ok: true, skillKey, enabled };
1395
+ }
1396
+ /**
1397
+ * agent.skills.set — enable or disable a skill for a specific agent.
1398
+ *
1399
+ * Agent skill config is a whitelist array on agents.list[].skills:
1400
+ * - undefined (not configured) → all skills enabled
1401
+ * - [] (empty array) → all skills disabled
1402
+ * - ["a", "b"] → only a and b enabled
1403
+ *
1404
+ * When disabling the first skill (no whitelist exists yet), we populate
1405
+ * the whitelist with ALL currently available skill names minus the target.
1406
+ * When enabling, we add the skill to the existing whitelist.
1407
+ * When disabling, we remove it from the whitelist.
1408
+ */
1409
+ async function handleAgentSkillsSet(api, params) {
1410
+ const rawAgentId = String(params.agentId ?? "").trim();
1411
+ const skillName = String(params.skillKey ?? params.name ?? "").trim();
1412
+ if (!rawAgentId)
1413
+ throw { code: 400, message: "agentId is required" };
1414
+ if (!skillName)
1415
+ throw { code: 400, message: "skillKey (or name) is required" };
1416
+ if (typeof params.enabled !== "boolean")
1417
+ throw { code: 400, message: "enabled (boolean) is required" };
1418
+ const enabled = params.enabled;
1419
+ const agentId = normalizeAgentId(rawAgentId);
1420
+ const configPath = resolveOpenClawConfigPath();
1421
+ // Read config from disk
1422
+ let cfg = {};
1423
+ try {
1424
+ const raw = await readFile(configPath, "utf-8");
1425
+ cfg = JSON.parse(raw);
1426
+ }
1427
+ catch (err) {
1428
+ if (err.code !== "ENOENT") {
1429
+ throw { code: 500, message: `agent.skills.set: failed to read config: ${err.message}` };
1430
+ }
1431
+ }
1432
+ // Find the agent entry in agents.list
1433
+ if (!cfg.agents)
1434
+ cfg.agents = {};
1435
+ if (!Array.isArray(cfg.agents.list))
1436
+ cfg.agents.list = [];
1437
+ let agentEntry = cfg.agents.list.find((a) => a?.id && normalizeAgentId(a.id) === agentId);
1438
+ if (!agentEntry) {
1439
+ throw { code: 400, message: `agent "${agentId}" not found in config` };
1440
+ }
1441
+ const hasWhitelist = Array.isArray(agentEntry.skills);
1442
+ if (enabled) {
1443
+ // Enable the skill
1444
+ if (!hasWhitelist) {
1445
+ // No whitelist → all skills already enabled, nothing to do
1446
+ portalLog(api, "info", `agent.skills.set: agentId=${agentId} skill=${skillName} already enabled (no whitelist)`);
1447
+ return { ok: true, agentId, skillKey: skillName, enabled: true, skills: undefined };
1448
+ }
1449
+ // Add to whitelist if not present
1450
+ const currentList = agentEntry.skills;
1451
+ if (!currentList.includes(skillName)) {
1452
+ currentList.push(skillName);
1453
+ }
1454
+ }
1455
+ else {
1456
+ // Disable the skill
1457
+ if (!hasWhitelist) {
1458
+ // No whitelist yet → populate with all skill names minus the target
1459
+ const runtimeCfg = getConfig(api);
1460
+ const workspaceDir = resolveAgentWorkspaceDir(runtimeCfg, agentId);
1461
+ const allEntries = loadAllSkillEntries(workspaceDir, runtimeCfg);
1462
+ const allNames = allEntries.map(e => e.name);
1463
+ agentEntry.skills = allNames.filter(n => n !== skillName);
1464
+ }
1465
+ else {
1466
+ // Remove from existing whitelist
1467
+ agentEntry.skills = agentEntry.skills.filter((s) => s !== skillName);
1468
+ }
1469
+ }
1470
+ // Write back config
1471
+ try {
1472
+ await mkdir(dirname(configPath), { recursive: true });
1473
+ await writeFile(configPath, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
1474
+ }
1475
+ catch (err) {
1476
+ throw { code: 500, message: `agent.skills.set: failed to write config: ${err.message}` };
1477
+ }
1478
+ _diskConfigCache = null;
1479
+ const resultSkills = agentEntry.skills ?? undefined;
1480
+ portalLog(api, "info", `agent.skills.set: agentId=${agentId} skill=${skillName} enabled=${enabled} skills=${JSON.stringify(resultSkills)}`);
1481
+ return { ok: true, agentId, skillKey: skillName, enabled, skills: resultSkills };
1482
+ }
1483
+ /**
1484
+ * agent.model.set — switch the model for a specific agent.
1485
+ *
1486
+ * Writes the model string (e.g. "deepminer/claude-sonnet-4-6") to
1487
+ * agents.list[agentId].model in openclaw.json.
1488
+ */
1489
+ async function handleAgentModelSet(api, params) {
1490
+ const rawAgentId = String(params.agentId ?? "").trim();
1491
+ const model = String(params.model ?? "").trim();
1492
+ if (!rawAgentId)
1493
+ throw { code: 400, message: "agentId is required" };
1494
+ if (!model)
1495
+ throw { code: 400, message: "model is required" };
1496
+ const agentId = normalizeAgentId(rawAgentId);
1497
+ const configPath = resolveOpenClawConfigPath();
1498
+ let cfg = {};
1499
+ try {
1500
+ const raw = await readFile(configPath, "utf-8");
1501
+ cfg = JSON.parse(raw);
1502
+ }
1503
+ catch (err) {
1504
+ if (err.code !== "ENOENT") {
1505
+ throw { code: 500, message: `agent.model.set: failed to read config: ${err.message}` };
1506
+ }
1507
+ }
1508
+ if (!cfg.agents)
1509
+ cfg.agents = {};
1510
+ if (!Array.isArray(cfg.agents.list))
1511
+ cfg.agents.list = [];
1512
+ const agentEntry = cfg.agents.list.find((a) => a?.id && normalizeAgentId(a.id) === agentId);
1513
+ if (!agentEntry) {
1514
+ throw { code: 400, message: `agent "${agentId}" not found in config` };
1515
+ }
1516
+ agentEntry.model = model;
1517
+ try {
1518
+ await mkdir(dirname(configPath), { recursive: true });
1519
+ await writeFile(configPath, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
1520
+ }
1521
+ catch (err) {
1522
+ throw { code: 500, message: `agent.model.set: failed to write config: ${err.message}` };
1523
+ }
1524
+ _diskConfigCache = null;
1525
+ portalLog(api, "info", `agent.model.set: agentId=${agentId} model=${model}`);
1526
+ return { ok: true, agentId, model };
1527
+ }
1528
+ /**
1529
+ * Resolve the path to the openclaw cron store file (jobs.json).
1530
+ *
1531
+ * Mirrors openclaw's own resolveCronStorePath / resolveConfigDir logic:
1532
+ * 1. OPENCLAW_STATE_DIR env var → {stateDir}/cron/jobs.json
1533
+ * 2. OPENCLAW_CONFIG_PATH env var → dirname(configPath)/cron/jobs.json
1534
+ * 3. cfg.cron?.store (from full openclaw config) → resolved path
1535
+ * 4. Default: ~/.openclaw/cron/jobs.json
1536
+ */
1537
+ function resolveCronStorePath(api) {
1538
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
1539
+ const expandHome = (p) => p.startsWith("~/") || p === "~" ? join(home, p.slice(2)) : p;
1540
+ // 1. OPENCLAW_STATE_DIR
1541
+ const stateDir = (process.env.OPENCLAW_STATE_DIR ?? "").trim();
1542
+ if (stateDir)
1543
+ return resolve(join(expandHome(stateDir), "cron", "jobs.json"));
1544
+ // 2. OPENCLAW_CONFIG_PATH
1545
+ const configPath = (process.env.OPENCLAW_CONFIG_PATH ?? "").trim();
1546
+ if (configPath)
1547
+ return resolve(join(dirname(expandHome(configPath)), "cron", "jobs.json"));
1548
+ // 3. cfg.cron?.store
1549
+ const cfg = getConfig(api);
1550
+ const cfgStore = String(cfg?.cron?.store ?? "").trim();
1551
+ if (cfgStore)
1552
+ return resolve(expandHome(cfgStore));
1553
+ // 4. Default
1554
+ return join(home, ".openclaw", "cron", "jobs.json");
1555
+ }
1556
+ /**
1557
+ * cron.list — read jobs directly from the persisted cron store (jobs.json).
1558
+ *
1559
+ * Supports filtering by: agentId, includeDisabled, query
1560
+ * Supports sorting: nextRunAtMs | updatedAtMs | name, asc | desc
1561
+ * Supports pagination: offset, limit
1562
+ */
1563
+ async function handleCronList(api, params) {
1564
+ const storePath = resolveCronStorePath(api);
1565
+ let raw;
1566
+ try {
1567
+ raw = await readFile(storePath, "utf-8");
1568
+ }
1569
+ catch (err) {
1570
+ if (err?.code === "ENOENT") {
1571
+ portalLog(api, "info", `cron.list: store not found at ${storePath}`);
1572
+ return { jobs: [] };
1573
+ }
1574
+ throw { code: 500, message: `cron.list: failed to read store: ${err.message}` };
1575
+ }
1576
+ let store;
1577
+ try {
1578
+ store = JSON.parse(raw);
1579
+ }
1580
+ catch {
1581
+ throw { code: 500, message: "cron.list: invalid JSON in cron store" };
1582
+ }
1583
+ let jobs = Array.isArray(store.jobs) ? store.jobs : [];
1584
+ // Filter: agentId
1585
+ const agentId = String(params.agentId ?? "").trim();
1586
+ if (agentId) {
1587
+ jobs = jobs.filter((j) => j.agentId === agentId);
1588
+ }
1589
+ // Filter: enabled (default: exclude disabled)
1590
+ const includeDisabled = params.includeDisabled === true;
1591
+ if (!includeDisabled) {
1592
+ jobs = jobs.filter((j) => j.enabled !== false);
1593
+ }
1594
+ // Filter: name query
1595
+ const query = String(params.query ?? "").trim().toLowerCase();
1596
+ if (query) {
1597
+ jobs = jobs.filter((j) => j.name?.toLowerCase().includes(query));
1598
+ }
1599
+ // Sort
1600
+ const sortBy = String(params.sortBy ?? "nextRunAtMs");
1601
+ const sortDir = String(params.sortDir ?? "asc");
1602
+ jobs = [...jobs].sort((a, b) => {
1603
+ let av = 0;
1604
+ let bv = 0;
1605
+ if (sortBy === "updatedAtMs") {
1606
+ av = a.updatedAtMs ?? 0;
1607
+ bv = b.updatedAtMs ?? 0;
1608
+ }
1609
+ else if (sortBy === "name") {
1610
+ av = a.name ?? "";
1611
+ bv = b.name ?? "";
1612
+ }
1613
+ else {
1614
+ // default: nextRunAtMs
1615
+ av = a.state?.nextRunAtMs ?? 0;
1616
+ bv = b.state?.nextRunAtMs ?? 0;
1617
+ }
1618
+ const cmp = av < bv ? -1 : av > bv ? 1 : 0;
1619
+ return sortDir === "desc" ? -cmp : cmp;
1620
+ });
1621
+ // Pagination
1622
+ const offset = Math.max(0, Number(params.offset) || 0);
1623
+ const limit = Number(params.limit) || 0;
1624
+ if (offset > 0)
1625
+ jobs = jobs.slice(offset);
1626
+ if (limit > 0)
1627
+ jobs = jobs.slice(0, limit);
1628
+ portalLog(api, "info", `cron.list: storePath=${storePath} agentId=${agentId || "*"} count=${jobs.length}`);
1629
+ return { jobs };
1630
+ }
377
1631
  // ---------------------------------------------------------------------------
378
1632
  // Request dispatch
379
1633
  // ---------------------------------------------------------------------------
@@ -404,6 +1658,33 @@ async function handlePortalRequest(api, accountId, request) {
404
1658
  case "agents.create":
405
1659
  result = await handleAgentsCreate(api, params ?? {});
406
1660
  break;
1661
+ case "tools.catalog":
1662
+ result = await handleToolsCatalog(api, params ?? {});
1663
+ break;
1664
+ case "skills.status":
1665
+ result = await handleSkillsStatus(api, params ?? {});
1666
+ break;
1667
+ case "agent.skills.status":
1668
+ result = await handleAgentSkillsStatus(api, params ?? {});
1669
+ break;
1670
+ case "agent.skills.set":
1671
+ result = await handleAgentSkillsSet(api, params ?? {});
1672
+ break;
1673
+ case "agent.model.set":
1674
+ result = await handleAgentModelSet(api, params ?? {});
1675
+ break;
1676
+ case "skills.search":
1677
+ result = await handleSkillsSearch(api, params ?? {});
1678
+ break;
1679
+ case "skills.install":
1680
+ result = await handleSkillsInstall(api, params ?? {});
1681
+ break;
1682
+ case "skills.set":
1683
+ result = await handleSkillsSet(api, params ?? {});
1684
+ break;
1685
+ case "cron.list":
1686
+ result = await handleCronList(api, params ?? {});
1687
+ break;
407
1688
  case "ping":
408
1689
  result = { pong: true };
409
1690
  break;