Skip to content

fix(openclaw): support npm plugin installs in setup helper#2072

Merged
qin-ctx merged 1 commit into
volcengine:mainfrom
LinQiang391:main
May 15, 2026
Merged

fix(openclaw): support npm plugin installs in setup helper#2072
qin-ctx merged 1 commit into
volcengine:mainfrom
LinQiang391:main

Conversation

@LinQiang391
Copy link
Copy Markdown
Collaborator

Description

ov-install:默认 npm,安装 @openviking/openclaw-plugin@latest
ov-install --plugin-version=2026.5.8:npm exact version
ov-install --plugin-version=dev:npm dist-tag
ov-install --plugin-version=v0.3.16:自动识别为 GitHub tag
ov-install --plugin-version=main:自动识别为 GitHub ref

Related Issue

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Refactoring (no functional changes)
  • Performance improvement
  • Test update

Changes Made

Testing

  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I have tested this on the following platforms:
    • Linux
    • macOS
    • Windows

Checklist

  • My code follows the project's coding style
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • Any dependent changes have been merged and published

Screenshots (if applicable)

Additional Notes

@github-actions
Copy link
Copy Markdown

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🏅 Score: 85
🧪 No relevant tests
🔒 No security concerns identified
✅ No TODO sections
🔀 No multiple PR themes
⚡ Recommended focus areas for review

Temp directory cleanup may not run in all error paths

The npm package temp directory cleanup (cleanupNpmPackageTemp) is only called in deployPluginFromRemote's finally block. If an error occurs after ensureNpmPackageExtracted creates the temp directory but before deployPluginFromRemote runs, the temp directory will not be cleaned up.

async function ensureNpmPackageExtracted() {
  if (npmPackageExtractDir && existsSync(npmPackageExtractDir)) {
    return npmPackageExtractDir;
  }

  npmPackageTempDir = await mkdtemp(join(tmpdir(), "ov-plugin-npm-"));
  info(tr(
    `Downloading plugin package from npm: ${npmPackageSpec()}`,
    `Downloading plugin package from npm: ${npmPackageSpec()}`,
  ));

  const packResult = await runCapture("npm", [
    "pack",
    npmPackageSpec(),
    "--pack-destination",
    npmPackageTempDir,
    "--json",
    "--registry",
    NPM_REGISTRY,
  ], { shell: IS_WIN });

  if (packResult.code !== 0) {
    throw new Error(`npm pack failed for ${npmPackageSpec()}${packResult.err ? `: ${packResult.err}` : ""}`);
  }

  const parsed = parseNpmJsonOutput(packResult.out);
  const first = Array.isArray(parsed) ? parsed[0] : parsed;
  const filename = first?.filename || readdirSync(npmPackageTempDir).find((name) => name.endsWith(".tgz"));
  if (!filename) {
    throw new Error(`npm pack did not produce a tarball for ${npmPackageSpec()}`);
  }

  const tarballPath = join(npmPackageTempDir, filename);
  const extractRoot = join(npmPackageTempDir, "extract");
  await mkdir(extractRoot, { recursive: true });
  await run("tar", ["-xzf", tarballPath, "-C", extractRoot], { silent: true, shell: IS_WIN });

  const packageDir = join(extractRoot, "package");
  if (!existsSync(packageDir)) {
    throw new Error(`npm package ${npmPackageSpec()} did not contain the expected package directory`);
  }

  npmPackageExtractDir = packageDir;
  return npmPackageExtractDir;
}

function parseGitLsRemoteTags(output) {
  return String(output ?? "")
    .split(/\r?\n/)
    .map((line) => {
      const match = line.match(/refs\/tags\/(.+)$/);
      return match?.[1]?.trim() || "";
    })
    .filter(Boolean);
}

async function resolveDefaultPluginVersion() {
  if (PLUGIN_VERSION) {
    return;
  }

  if (pluginSource === "npm") {
    if (await resolveDefaultPluginVersionFromNpm()) {
      return;
    }
    warn(tr(
      "Falling back to GitHub tag resolution.",
      "Falling back to GitHub tag resolution.",
    ));
  }

  info(tr(
    `No plugin version specified; resolving latest tag from ${REPO}...`,
    `未指定插件版本,正在解析 ${REPO} 的最新 tag...`,
  ));

  const failures = [];
  const apiUrl = `https://api.github.com/repos/${REPO}/tags?per_page=100`;

  try {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), 10000);
    const response = await fetch(apiUrl, {
      headers: {
        Accept: "application/vnd.github+json",
        "User-Agent": "openviking-setup-helper",
        "X-GitHub-Api-Version": "2022-11-28",
      },
      signal: controller.signal,
    });
    clearTimeout(timeoutId);

    if (response.ok) {
      const payload = await response.json().catch(() => null);
      if (Array.isArray(payload)) {
        const latestTag = pickLatestPluginTag(payload.map((item) => item?.name || ""));
        if (latestTag) {
          PLUGIN_VERSION = latestTag;
          info(tr(
            `Resolved default plugin version to latest tag: ${PLUGIN_VERSION}`,
            `已将默认插件版本解析为最新 tag: ${PLUGIN_VERSION}`,
          ));
          return;
        }
      } else {
        failures.push("GitHub tags API returned an unexpected payload");
      }
    } else {
      failures.push(`GitHub tags API returned HTTP ${response.status}`);
    }
  } catch (error) {
    failures.push(`GitHub tags API failed: ${String(error)}`);
  }

  const gitRef = `https://github.com/${REPO}.git`;
  const gitResult = await runCapture("git", ["ls-remote", "--tags", "--refs", gitRef], {
    shell: IS_WIN,
  });
  if (gitResult.code === 0 && gitResult.out) {
    const latestTag = pickLatestPluginTag(parseGitLsRemoteTags(gitResult.out));
    if (latestTag) {
      PLUGIN_VERSION = latestTag;
      info(tr(
        `Resolved default plugin version via git tags: ${PLUGIN_VERSION}`,
        `已通过 git tag 解析默认插件版本: ${PLUGIN_VERSION}`,
      ));
      return;
    }
    failures.push("git ls-remote returned no usable tags");
  } else {
    failures.push(`git ls-remote failed${gitResult.err ? `: ${gitResult.err}` : ""}`);
  }

  err(tr(
    `Could not resolve the latest tag for ${REPO}.`,
    `无法解析 ${REPO} 的最新 tag。`,
  ));
  console.log(tr(
    "Please rerun with --plugin-version <tag>, or use --plugin-version main to track the branch head explicitly.",
    "请使用 --plugin-version <tag> 重新执行;如果需要显式跟踪分支头,请使用 --plugin-version main。",
  ));
  if (failures.length > 0) {
    warn(failures.join(" | "));
  }
  process.exit(1);
}

function applyManifestConfig(manifestData) {
  resolvedPluginId = manifestData.plugin?.id || "";
  resolvedPluginKind = manifestData.plugin?.kind || "";
  resolvedPluginSlot = manifestData.plugin?.slot || "";
  resolvedMinOpenclawVersion = manifestData.compatibility?.minOpenclawVersion || "";
  resolvedMinOpenvikingVersion = manifestData.compatibility?.minOpenvikingVersion || "";
  resolvedPluginReleaseId = manifestData.pluginVersion || manifestData.release?.id || "";
  const npmConfig = manifestData.npm && typeof manifestData.npm === "object"
    ? manifestData.npm
    : {};
  resolvedNpmOmitDev = npmConfig.omitDev !== false;
  resolvedNpmBuild = npmConfig.build === true || npmConfig.buildFromSource === true;
  resolvedNpmBuildMinOpenclawVersion =
    typeof npmConfig.buildMinOpenclawVersion === "string" && npmConfig.buildMinOpenclawVersion.trim()
      ? npmConfig.buildMinOpenclawVersion.trim()
      : DEFAULT_NPM_BUILD_MIN_OPENCLAW_VERSION;
  resolvedNpmBuildScript = typeof npmConfig.buildScript === "string" && npmConfig.buildScript.trim()
    ? npmConfig.buildScript.trim()
    : "build";
  resolvedNpmPruneAfterBuild = npmConfig.pruneAfterBuild !== false;
  resolvedFilesRequired = manifestData.files?.required || [];
  resolvedFilesOptional = manifestData.files?.optional || [];
}

function hasPrebuiltRuntimeOutputs(packageDir) {
  return existsSync(join(packageDir, "dist", "index.js"));
}

async function resolvePluginConfigFromNpm() {
  info(tr(
    `Resolving plugin configuration from npm package: ${npmPackageSpec()}`,
    `Resolving plugin configuration from npm package: ${npmPackageSpec()}`,
  ));

  const packageDir = await ensureNpmPackageExtracted();
  const manifestPath = join(packageDir, "install-manifest.json");
  const packageJsonPath = join(packageDir, "package.json");
  let manifestData = null;
  let packageJson = null;

  if (existsSync(packageJsonPath)) {
    try {
      packageJson = JSON.parse(await readFile(packageJsonPath, "utf8"));
      resolvedPluginReleaseId = packageJson.version || "";
    } catch {}
  }

  if (existsSync(manifestPath)) {
    try {
      manifestData = JSON.parse(await readFile(manifestPath, "utf8"));
      info(tr("Found manifest in npm package", "Found manifest in npm package"));
    } catch {}
  }

  resolvedPluginDir = ".";
  if (manifestData) {
    applyManifestConfig(manifestData);
  } else {
    const pkgName = packageJson?.name || "";
    const fallback = pkgName && pkgName !== DEFAULT_PLUGIN_NPM_PACKAGE ? FALLBACK_LEGACY : FALLBACK_CURRENT;
    resolvedPluginId = fallback.id;
    resolvedPluginKind = fallback.kind;
    resolvedPluginSlot = fallback.slot;
    resolvedFilesRequired = fallback.required;
    resolvedFilesOptional = fallback.optional;
    resolvedNpmOmitDev = true;
    resolvedNpmBuild = false;
    resolvedNpmBuildMinOpenclawVersion = DEFAULT_NPM_BUILD_MIN_OPENCLAW_VERSION;
    resolvedNpmBuildScript = "build";
    resolvedNpmPruneAfterBuild = true;
    resolvedMinOpenclawVersion = (packageJson?.engines?.openclaw || "").replace(/^>=?\s*/, "").trim()
      || fallback.minOpenclawVersion
      || "2026.3.7";
    resolvedMinOpenvikingVersion = "";
  }

  if (hasPrebuiltRuntimeOutputs(packageDir)) {
    resolvedNpmBuild = false;
    info(tr(
      "npm package contains prebuilt runtime output; skipping source build.",
      "npm package contains prebuilt runtime output; skipping source build.",
    ));
  }

  PLUGIN_DEST = join(OPENCLAW_DIR, "extensions", resolvedPluginId || "openviking");
  info(tr(`Plugin: ${resolvedPluginId} (${resolvedPluginKind})`, `Plugin: ${resolvedPluginId} (${resolvedPluginKind})`));
}

// Resolve plugin configuration from manifest or fallback
async function resolvePluginConfig() {
  if (pluginSource === "npm") {
    try {
      await resolvePluginConfigFromNpm();
      return;
    } catch (error) {
      warn(tr(
        `npm plugin resolution failed: ${error?.message || error}`,
        `npm plugin resolution failed: ${error?.message || error}`,
      ));
      warn(tr(
        "Falling back to GitHub plugin download.",
        "Falling back to GitHub plugin download.",
      ));
      pluginSource = "github";
    }
  }

  const ghRaw = `https://raw.githubusercontent.com/${REPO}/${PLUGIN_VERSION}`;

  info(tr(`Resolving plugin configuration for version: ${PLUGIN_VERSION}`, `正在解析插件配置,版本: ${PLUGIN_VERSION}`));

  resolvedNpmOmitDev = true;
  resolvedNpmBuild = false;
  resolvedNpmBuildMinOpenclawVersion = DEFAULT_NPM_BUILD_MIN_OPENCLAW_VERSION;
  resolvedNpmBuildScript = "build";
  resolvedNpmPruneAfterBuild = true;

  let pluginDir = "";
  let manifestData = null;

  // Try to detect plugin directory and download manifest
  const manifestCurrent = await tryFetch(`${ghRaw}/examples/openclaw-plugin/install-manifest.json`);
  if (manifestCurrent) {
    pluginDir = "openclaw-plugin";
    try {
      manifestData = JSON.parse(manifestCurrent);
    } catch {}
    info(tr("Found manifest in openclaw-plugin", "在 openclaw-plugin 中找到 manifest"));
  } else {
    const manifestLegacy = await tryFetch(`${ghRaw}/examples/openclaw-memory-plugin/install-manifest.json`);
    if (manifestLegacy) {
      pluginDir = "openclaw-memory-plugin";
      try {
        manifestData = JSON.parse(manifestLegacy);
      } catch {}
      info(tr("Found manifest in openclaw-memory-plugin", "在 openclaw-memory-plugin 中找到 manifest"));
    } else if (await testRemoteFile(`${ghRaw}/examples/openclaw-plugin/index.ts`)) {
      pluginDir = "openclaw-plugin";
      info(tr("No manifest found, using fallback for openclaw-plugin", "未找到 manifest,使用 openclaw-plugin 回退配置"));
    } else if (await testRemoteFile(`${ghRaw}/examples/openclaw-memory-plugin/index.ts`)) {
      pluginDir = "openclaw-memory-plugin";
      info(tr("No manifest found, using fallback for openclaw-memory-plugin", "未找到 manifest,使用 openclaw-memory-plugin 回退配置"));
    } else {
      err(tr(`Cannot find plugin directory for version: ${PLUGIN_VERSION}`, `无法找到版本 ${PLUGIN_VERSION} 的插件目录`));
      process.exit(1);
    }
  }

  resolvedPluginDir = pluginDir;
  resolvedPluginReleaseId = "";

  if (manifestData) {
    resolvedPluginId = manifestData.plugin?.id || "";
    resolvedPluginKind = manifestData.plugin?.kind || "";
    resolvedPluginSlot = manifestData.plugin?.slot || "";
    resolvedMinOpenclawVersion = manifestData.compatibility?.minOpenclawVersion || "";
    resolvedMinOpenvikingVersion = manifestData.compatibility?.minOpenvikingVersion || "";
    resolvedPluginReleaseId = manifestData.pluginVersion || manifestData.release?.id || "";
    const npmConfig = manifestData.npm && typeof manifestData.npm === "object"
      ? manifestData.npm
      : {};
    resolvedNpmOmitDev = npmConfig.omitDev !== false;
    resolvedNpmBuild = npmConfig.build === true || npmConfig.buildFromSource === true;
    resolvedNpmBuildMinOpenclawVersion =
      typeof npmConfig.buildMinOpenclawVersion === "string" && npmConfig.buildMinOpenclawVersion.trim()
        ? npmConfig.buildMinOpenclawVersion.trim()
        : DEFAULT_NPM_BUILD_MIN_OPENCLAW_VERSION;
    resolvedNpmBuildScript = typeof npmConfig.buildScript === "string" && npmConfig.buildScript.trim()
      ? npmConfig.buildScript.trim()
      : "build";
    resolvedNpmPruneAfterBuild = npmConfig.pruneAfterBuild !== false;
    resolvedFilesRequired = manifestData.files?.required || [];
    resolvedFilesOptional = manifestData.files?.optional || [];
  } else {
    // No manifest — determine plugin identity by package.json name
    let fallbackKey = pluginDir === "openclaw-memory-plugin" ? "legacy" : "current";
    let compatVer = "";

    const pkgJson = await tryFetch(`${ghRaw}/examples/${pluginDir}/package.json`);
    if (pkgJson) {
      try {
        const pkg = JSON.parse(pkgJson);
        const pkgName = pkg.name || "";
        resolvedPluginReleaseId = pkg.version || "";
        if (pkgName && pkgName !== "@openclaw/openviking") {
          fallbackKey = "legacy";
          info(tr(`Detected legacy plugin by package name: ${pkgName}`, `通过 package.json 名称检测到旧版插件: ${pkgName}`));
        } else if (pkgName) {
          fallbackKey = "current";
        }
        compatVer = (pkg.engines?.openclaw || "").replace(/^>=?\s*/, "").trim();
        if (compatVer) {
          info(tr(`Read minOpenclawVersion from package.json engines.openclaw: >=${compatVer}`, `从 package.json engines.openclaw 读取到最低版本: >=${compatVer}`));
        }
      } catch {}
    }

    const fallback = fallbackKey === "legacy" ? FALLBACK_LEGACY : FALLBACK_CURRENT;
    resolvedPluginDir = pluginDir;
    resolvedPluginId = fallback.id;
    resolvedPluginKind = fallback.kind;
    resolvedPluginSlot = fallback.slot;
    resolvedFilesRequired = fallback.required;
    resolvedFilesOptional = fallback.optional;
    resolvedNpmOmitDev = true;
    resolvedNpmBuild = false;
    resolvedNpmBuildMinOpenclawVersion = DEFAULT_NPM_BUILD_MIN_OPENCLAW_VERSION;
    resolvedNpmBuildScript = "build";
    resolvedNpmPruneAfterBuild = true;

    // If no compatVer from package.json, try main branch manifest
    if (!compatVer && PLUGIN_VERSION !== "main") {
      const mainRaw = `https://raw.githubusercontent.com/${REPO}/main`;
      const mainManifest = await tryFetch(`${mainRaw}/examples/openclaw-plugin/install-manifest.json`);
      if (mainManifest) {
        try {
          const m = JSON.parse(mainManifest);
          compatVer = m.compatibility?.minOpenclawVersion || "";
          if (compatVer) {
            info(tr(`Read minOpenclawVersion from main branch manifest: >=${compatVer}`, `从 main 分支 manifest 读取到最低版本: >=${compatVer}`));
          }
        } catch {}
      }
    }

    resolvedMinOpenclawVersion = compatVer || fallback.minOpenclawVersion || "2026.3.7";
    resolvedMinOpenvikingVersion = "";
  }

  // Set plugin destination
  PLUGIN_DEST = join(OPENCLAW_DIR, "extensions", resolvedPluginId);

  info(tr(`Plugin: ${resolvedPluginId} (${resolvedPluginKind})`, `插件: ${resolvedPluginId} (${resolvedPluginKind})`));
}

// Check OpenClaw version compatibility
async function checkOpenClawCompatibility() {
  if (process.env.SKIP_OPENCLAW === "1") {
    return;
  }

  const ocVersion = await detectOpenClawVersion();
  info(tr(`Detected OpenClaw version: ${ocVersion}`, `检测到 OpenClaw 版本: ${ocVersion}`));
  applyOpenClawBuildPolicy(ocVersion);

  // If no minimum version required, pass
  if (!resolvedMinOpenclawVersion) {
    return;
  }

  // If user explicitly requested an old version, pass
  if (isSemverLike(PLUGIN_VERSION) && !versionGte(PLUGIN_VERSION, "v0.2.8")) {
    return;
  }

  // Check compatibility
  if (!openClawPolicyVersionGte(ocVersion, resolvedMinOpenclawVersion)) {
    err(tr(
      `OpenClaw ${ocVersion} does not support this plugin (requires >= ${resolvedMinOpenclawVersion})`,
      `OpenClaw ${ocVersion} 不支持此插件(需要 >= ${resolvedMinOpenclawVersion})`
    ));
    console.log("");
    bold(tr("Please choose one of the following options:", "请选择以下方案之一:"));
    console.log("");
    console.log(`  ${tr("Option 1: Upgrade OpenClaw", "方案 1:升级 OpenClaw")}`);
    console.log(`    npm update -g openclaw --registry ${NPM_REGISTRY}`);
    console.log("");
    console.log(`  ${tr("Option 2: Install a legacy plugin release compatible with your current OpenClaw version", "方案 2:安装与当前 OpenClaw 版本兼容的旧版插件")}`);
    console.log(`    ${getLegacyInstallCommandHint()}`);
    console.log("");
    process.exit(1);
  }
}

function getOpenClawConfigPath() {
  return join(OPENCLAW_DIR, "openclaw.json");
}

function getOpenClawEnv() {
  if (OPENCLAW_DIR === DEFAULT_OPENCLAW_DIR) {
    return { ...process.env };
  }
  return { ...process.env, OPENCLAW_STATE_DIR: OPENCLAW_DIR };
}

async function readJsonFileIfExists(filePath) {
  if (!existsSync(filePath)) return null;
  const raw = await readFile(filePath, "utf8");
  return JSON.parse(raw);
}

function getInstallStatePathForPlugin(pluginId) {
  return join(OPENCLAW_DIR, "extensions", pluginId, ".ov-install-state.json");
}

async function printCurrentVersionInfo() {
  const state = await readJsonFileIfExists(getInstallStatePathForPlugin("openviking"));
  const pluginRequestedRef = state?.requestedRef || "";
  const pluginReleaseId = state?.releaseId || "";
  const pluginInstalledAt = state?.installedAt || "";

  console.log("");
  bold(tr("Installed versions", "当前已安装版本"));
  console.log("");
  console.log(`Target: ${OPENCLAW_DIR}`);
  console.log(`Plugin: ${pluginReleaseId || pluginRequestedRef || "not installed"}`);
  if (pluginRequestedRef && pluginReleaseId && pluginRequestedRef !== pluginReleaseId) {
    console.log(`Plugin requested ref: ${pluginRequestedRef}`);
  }
  console.log(tr("OpenViking server: not installed by this tool (use a remote URL in plugin config)", "OpenViking 服务端:本工具不安装;请在插件配置中填写远程服务地址"));
  if (pluginInstalledAt) {
    console.log(`Installed at: ${pluginInstalledAt}`);
  }
}

function getUpgradeAuditDir() {
  return join(OPENCLAW_DIR, ".openviking-upgrade-backup");
}

function getUpgradeAuditPath() {
  return join(getUpgradeAuditDir(), "last-upgrade.json");
}

function getOpenClawConfigBackupPath() {
  return join(getUpgradeAuditDir(), "openclaw.json.bak");
}

function getPluginVariantById(pluginId) {
  return PLUGIN_VARIANTS.find((variant) => variant.id === pluginId) || null;
}

function detectPluginPresence(config, variant) {
  const plugins = config?.plugins;
  const reasons = [];
  if (!plugins) {
    return { variant, present: false, reasons };
  }

  if (plugins.entries && Object.prototype.hasOwnProperty.call(plugins.entries, variant.id)) {
    reasons.push("entry");
  }
  if (plugins.slots?.[variant.slot] === variant.id) {
    reasons.push("slot");
  }
  if (Array.isArray(plugins.allow) && plugins.allow.includes(variant.id)) {
    reasons.push("allow");
  }
  if (
    Array.isArray(plugins.load?.paths)
    && plugins.load.paths.some((item) => typeof item === "string" && (item.includes(variant.id) || item.includes(variant.dir)))
  ) {
    reasons.push("loadPath");
  }
  if (existsSync(join(OPENCLAW_DIR, "extensions", variant.id))) {
    reasons.push("dir");
  }

  return { variant, present: reasons.length > 0, reasons };
}

async function detectInstalledPluginState() {
  const configPath = getOpenClawConfigPath();
  const config = await readJsonFileIfExists(configPath);
  const detections = [];
  for (const variant of PLUGIN_VARIANTS) {
    const detection = detectPluginPresence(config, variant);
    if (!detection.present) continue;
    detection.installState = await readJsonFileIfExists(getInstallStatePathForPlugin(variant.id));
    detections.push(detection);
  }

  let generation = "none";
  if (detections.length === 1) {
    generation = detections[0].variant.generation;
  } else if (detections.length > 1) {
    generation = "mixed";
  }

  return {
    config,
    configPath,
    detections,
    generation,
  };
}

function formatInstalledDetectionLabel(detection) {
  const requestedRef = detection.installState?.requestedRef;
  const releaseId = detection.installState?.releaseId;
  if (requestedRef) return `${detection.variant.id}@${requestedRef}`;
  if (releaseId) return `${detection.variant.id}#${releaseId}`;
  return `${detection.variant.id} (${detection.variant.generation}, exact version unknown)`;
}

function formatInstalledStateLabel(installedState) {
  if (!installedState?.detections?.length) {
    return "not-installed";
  }
  return installedState.detections.map(formatInstalledDetectionLabel).join(" + ");
}

function formatTargetVersionLabel() {
  const base = `${resolvedPluginId || "openviking"}@${PLUGIN_VERSION}`;
  if (resolvedPluginReleaseId && resolvedPluginReleaseId !== PLUGIN_VERSION) {
    return `${base} (${resolvedPluginReleaseId})`;
  }
  return base;
}

function extractRuntimeConfigFromPluginEntry(entryConfig) {
  if (!entryConfig || typeof entryConfig !== "object") return null;

  const runtime = {};
  if (typeof entryConfig.baseUrl === "string" && entryConfig.baseUrl.trim()) {
    runtime.baseUrl = entryConfig.baseUrl.trim();
  }
  if (typeof entryConfig.apiKey === "string" && entryConfig.apiKey.trim()) {
    runtime.apiKey = entryConfig.apiKey;
  }
  const prefix = entryConfig.agent_prefix || entryConfig.agentId;
  if (typeof prefix === "string" && prefix.trim()) {
    runtime.agent_prefix = prefix.trim();
  }
  if (typeof entryConfig.accountId === "string" && entryConfig.accountId.trim()) {
    runtime.accountId = entryConfig.accountId.trim();
  }
  if (typeof entryConfig.userId === "string" && entryConfig.userId.trim()) {
    runtime.userId = entryConfig.userId.trim();
  }
  return runtime;
}

async function backupOpenClawConfig(configPath) {
  await mkdir(getUpgradeAuditDir(), { recursive: true });
  const backupPath = getOpenClawConfigBackupPath();
  const configText = await readFile(configPath, "utf8");
  await writeFile(backupPath, configText, "utf8");
  return backupPath;
}

async function writeUpgradeAuditFile(data) {
  await mkdir(getUpgradeAuditDir(), { recursive: true });
  await writeFile(getUpgradeAuditPath(), `${JSON.stringify(data, null, 2)}\n`, "utf8");
}

async function writeInstallStateFile({ operation, fromVersion, configBackupPath, pluginBackups }) {
  const installStatePath = getInstallStatePathForPlugin(resolvedPluginId || "openviking");
  const state = {
    pluginId: resolvedPluginId || "openviking",
    generation: getPluginVariantById(resolvedPluginId || "openviking")?.generation || "unknown",
    requestedRef: PLUGIN_VERSION,
    releaseId: resolvedPluginReleaseId || "",
    operation,
    fromVersion: fromVersion || "",
    configBackupPath: configBackupPath || "",
    pluginBackups: pluginBackups || [],
    installedAt: new Date().toISOString(),
    repo: REPO,
  };
  await writeFile(installStatePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
}

async function moveDirWithFallback(sourceDir, destDir) {
  try {
    await rename(sourceDir, destDir);
  } catch {
    await cp(sourceDir, destDir, { recursive: true, force: true });
    await rm(sourceDir, { recursive: true, force: true });
  }
}

async function rollbackLastUpgradeOperation() {
  const auditPath = getUpgradeAuditPath();
  const audit = await readJsonFileIfExists(auditPath);
  if (!audit) {
    err(
      tr(
        `No rollback audit file found at ${auditPath}.`,
        `未找到回滚审计文件: ${auditPath}`,
      ),
    );
    process.exit(1);
  }

  if (audit.rolledBackAt) {
    warn(
      tr(
        `The last recorded upgrade was already rolled back at ${audit.rolledBackAt}.`,
        `最近一次升级已在 ${audit.rolledBackAt} 回滚。`,
      ),
    );
  }

  const configBackupPath = audit.configBackupPath || getOpenClawConfigBackupPath();
  if (!existsSync(configBackupPath)) {
    err(
      tr(
        `Rollback config backup is missing: ${configBackupPath}`,
        `回滚配置备份缺失: ${configBackupPath}`,
      ),
    );
    process.exit(1);
  }

  const pluginBackups = Array.isArray(audit.pluginBackups) ? audit.pluginBackups : [];
  if (pluginBackups.length === 0) {
    err(tr("Rollback audit file contains no plugin backups.", "回滚审计文件中没有插件备份信息。"));
    process.exit(1);
  }
  for (const pluginBackup of pluginBackups) {
    if (!pluginBackup?.pluginId || !pluginBackup?.backupDir || !existsSync(pluginBackup.backupDir)) {
      err(
        tr(
          `Rollback plugin backup is missing: ${pluginBackup?.backupDir || "<unknown>"}`,
          `回滚插件备份缺失: ${pluginBackup?.backupDir || "<unknown>"}`,
        ),
      );
      process.exit(1);
    }
  }

  info(tr(`Rolling back last upgrade: ${audit.fromVersion || "unknown"} <- ${audit.toVersion || "unknown"}`, `开始回滚最近一次升级: ${audit.fromVersion || "unknown"} <- ${audit.toVersion || "unknown"}`));
  await stopOpenClawGatewayForUpgrade();

  const configText = await readFile(configBackupPath, "utf8");
  await writeFile(getOpenClawConfigPath(), configText, "utf8");
  info(tr(`Restored openclaw.json from backup: ${configBackupPath}`, `已从备份恢复 openclaw.json: ${configBackupPath}`));

  const extensionsDir = join(OPENCLAW_DIR, "extensions");
  await mkdir(extensionsDir, { recursive: true });
  for (const variant of PLUGIN_VARIANTS) {
    const liveDir = join(extensionsDir, variant.id);
    if (existsSync(liveDir)) {
      await rm(liveDir, { recursive: true, force: true });
    }
  }

  for (const pluginBackup of pluginBackups) {
    if (!pluginBackup?.pluginId || !pluginBackup?.backupDir) continue;
    if (!existsSync(pluginBackup.backupDir)) {
      err(
        tr(
          `Rollback plugin backup is missing: ${pluginBackup.backupDir}`,
          `回滚插件备份缺失: ${pluginBackup.backupDir}`,
        ),
      );
      process.exit(1);
    }
    const destDir = join(extensionsDir, pluginBackup.pluginId);
    await moveDirWithFallback(pluginBackup.backupDir, destDir);
    info(tr(`Restored plugin directory: ${destDir}`, `已恢复插件目录: ${destDir}`));
  }

  audit.rolledBackAt = new Date().toISOString();
  audit.rollbackConfigPath = configBackupPath;
  await writeUpgradeAuditFile(audit);

  console.log("");
  bold(tr("Rollback complete!", "回滚完成!"));
  console.log("");
  info(tr(`Rollback audit file: ${auditPath}`, `回滚审计文件: ${auditPath}`));
  info(tr("Run `openclaw gateway` and `openclaw status` to verify the restored plugin state.", "请运行 `openclaw gateway` 和 `openclaw status` 验证恢复后的插件状态。"));
}

function prepareUpgradeRuntimeConfig(installedState) {
  const plugins = installedState.config?.plugins ?? {};
  const candidateOrder = installedState.detections
    .map((item) => item.variant)
    .sort((left, right) => (right.generation === "current" ? 1 : 0) - (left.generation === "current" ? 1 : 0));

  let runtime = null;
  for (const variant of candidateOrder) {
    const entryConfig = extractRuntimeConfigFromPluginEntry(plugins.entries?.[variant.id]?.config);
    if (entryConfig) {
      runtime = entryConfig;
      break;
    }
  }

  if (!runtime) {
    runtime = {};
  }

  delete runtime.mode;
  runtime.baseUrl = runtime.baseUrl || remoteBaseUrl;
  return runtime;
}

function removePluginConfig(config, variant) {
  const plugins = config?.plugins;
  if (!plugins) return false;

  let changed = false;

  if (Array.isArray(plugins.allow)) {
    const nextAllow = plugins.allow.filter((item) => item !== variant.id);
    changed = changed || nextAllow.length !== plugins.allow.length;
    plugins.allow = nextAllow;
  }

  if (Array.isArray(plugins.load?.paths)) {
    const nextPaths = plugins.load.paths.filter(
      (item) => typeof item !== "string" || (!item.includes(variant.id) && !item.includes(variant.dir)),
    );
    changed = changed || nextPaths.length !== plugins.load.paths.length;
    plugins.load.paths = nextPaths;
  }

  if (plugins.entries && Object.prototype.hasOwnProperty.call(plugins.entries, variant.id)) {
    delete plugins.entries[variant.id];
    changed = true;
  }

  if (plugins.slots?.[variant.slot] === variant.id) {
    plugins.slots[variant.slot] = variant.slotFallback;
    changed = true;
  }

  return changed;
}

async function prunePreviousUpgradeBackups(disabledDir, variant, keepDir) {
  if (!existsSync(disabledDir)) return;

  const prefix = `${variant.id}-upgrade-backup-`;
  const keepName = keepDir ? keepDir.split(/[\\/]/).pop() : "";
  const entries = readdirSync(disabledDir, { withFileTypes: true });
  for (const entry of entries) {
    if (!entry.isDirectory()) continue;
    if (!entry.name.startsWith(prefix)) continue;
    if (keepName && entry.name === keepName) continue;
    await rm(join(disabledDir, entry.name), { recursive: true, force: true });
  }
}

async function backupPluginDirectory(variant) {
  const pluginDir = join(OPENCLAW_DIR, "extensions", variant.id);
  if (!existsSync(pluginDir)) return null;

  const disabledDir = join(OPENCLAW_DIR, "disabled-extensions");
  const backupDir = join(disabledDir, `${variant.id}-upgrade-backup-${Date.now()}`);
  await mkdir(disabledDir, { recursive: true });
  try {
    await rename(pluginDir, backupDir);
  } catch {
    await cp(pluginDir, backupDir, { recursive: true, force: true });
    await rm(pluginDir, { recursive: true, force: true });
  }
  info(tr(`Backed up plugin directory: ${backupDir}`, `已备份插件目录: ${backupDir}`));
  await prunePreviousUpgradeBackups(disabledDir, variant, backupDir);
  return backupDir;
}

async function stopOpenClawGatewayForUpgrade() {
  const result = await runCapture("openclaw", ["gateway", "stop"], {
    env: getOpenClawEnv(),
    shell: IS_WIN,
  });
  if (result.code === 0) {
    info(tr("Stopped OpenClaw gateway before plugin upgrade", "升级插件前已停止 OpenClaw gateway"));
  } else {
    warn(tr("OpenClaw gateway may not be running; continuing", "OpenClaw gateway 可能未在运行,继续执行"));
  }
}

function shouldClaimTargetSlot(installedState) {
  const currentOwner = installedState.config?.plugins?.slots?.[resolvedPluginSlot];
  if (!currentOwner || currentOwner === "none" || currentOwner === "legacy" || currentOwner === resolvedPluginId) {
    return true;
  }
  const currentOwnerVariant = getPluginVariantById(currentOwner);
  if (currentOwnerVariant && installedState.detections.some((item) => item.variant.id === currentOwnerVariant.id)) {
    return true;
  }
  return false;
}

async function cleanupInstalledPluginConfig(installedState) {
  if (!installedState.config || !installedState.config.plugins) {
    warn(tr("openclaw.json has no plugins section; skipped targeted plugin cleanup", "openclaw.json 中没有 plugins 配置,已跳过定向插件清理"));
    return;
  }

  const nextConfig = structuredClone(installedState.config);
  let changed = false;
  for (const detection of installedState.detections) {
    changed = removePluginConfig(nextConfig, detection.variant) || changed;
  }

  if (!changed) {
    info(tr("No OpenViking plugin config changes were required", "无需修改 OpenViking 插件配置"));
    return;
  }

  await writeFile(installedState.configPath, `${JSON.stringify(nextConfig, null, 2)}\n`, "utf8");
  info(tr("Cleaned existing OpenViking plugin config only", "已仅清理 OpenViking 自身插件配置"));
}

async function prepareStrongPluginUpgrade() {
  const installedState = await detectInstalledPluginState();
  if (installedState.generation === "none") {
    err(
      tr(
        "Plugin upgrade mode requires an existing OpenViking plugin entry in openclaw.json.",
        "插件升级模式要求 openclaw.json 中已经存在 OpenViking 插件记录。",
      ),
    );
    process.exit(1);
  }

  installedUpgradeState = installedState;
  upgradeRuntimeConfig = prepareUpgradeRuntimeConfig(installedState);
  const fromVersion = formatInstalledStateLabel(installedState);
  const toVersion = formatTargetVersionLabel();
  info(
    tr(
      `Detected installed OpenViking plugin state: ${installedState.generation}`,
      `检测到已安装 OpenViking 插件状态: ${installedState.generation}`,
    ),
  );
  remoteBaseUrl = upgradeRuntimeConfig.baseUrl || remoteBaseUrl;
  remoteApiKey = upgradeRuntimeConfig.apiKey || "";
  remoteAgentPrefix = upgradeRuntimeConfig.agent_prefix || "";
  remoteAccountId = upgradeRuntimeConfig.accountId || "";
  remoteUserId = upgradeRuntimeConfig.userId || "";
  info(tr(`Upgrade runtime mode: ${selectedMode} (remote OpenViking server)`, `升级运行模式: ${selectedMode}(远程 OpenViking 服务)`));

  info(tr(`Upgrade path: ${fromVersion} -> ${toVersion}`, `升级路径: ${fromVersion} -> ${toVersion}`));

  await stopOpenClawGatewayForUpgrade();
  const configBackupPath = await backupOpenClawConfig(installedState.configPath);
  info(tr(`Backed up openclaw.json: ${configBackupPath}`, `已备份 openclaw.json: ${configBackupPath}`));
  const pluginBackups = [];
  for (const detection of installedState.detections) {
    const backupDir = await backupPluginDirectory(detection.variant);
    if (backupDir) {
      pluginBackups.push({ pluginId: detection.variant.id, backupDir });
    }
  }
  upgradeAudit = {
    operation: "upgrade",
    createdAt: new Date().toISOString(),
    fromVersion,
    toVersion,
    configBackupPath,
    pluginBackups,
    runtimeMode: selectedMode,
  };
  await writeUpgradeAuditFile(upgradeAudit);
  await cleanupInstalledPluginConfig(installedState);

  info(
    tr(
      "Upgrade will preserve existing plugin server connection settings where possible and re-apply minimal remote plugin config.",
      "升级将尽可能保留已有的插件服务端连接信息,并只回填最少的远程插件配置。",
    ),
  );
  info(tr(`Upgrade audit file: ${getUpgradeAuditPath()}`, `升级审计文件: ${getUpgradeAuditPath()}`));
}

async function downloadPluginFile(destDir, fileName, url, required, index, total) {
  const maxRetries = 3;
  const destPath = join(destDir, fileName);

  process.stdout.write(`  [${index}/${total}] ${fileName} `);

  let lastStatus = 0;
  let saw404 = false;

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch(url);
      lastStatus = response.status;
      if (response.ok) {
        const buffer = Buffer.from(await response.arrayBuffer());
        if (buffer.length === 0) {
          lastStatus = 0;
        } else {
          await mkdir(dirname(destPath), { recursive: true });
          await writeFile(destPath, buffer);
          console.log(" OK");
          return;
        }
      } else if (!required && response.status === 404) {
        saw404 = true;
        break;
      }
    } catch {
      lastStatus = 0;
    }

    if (attempt < maxRetries) {
      await new Promise((resolve) => setTimeout(resolve, 2000));
    }
  }

  if (saw404 || lastStatus === 404) {
    if (fileName === ".gitignore") {
      await mkdir(dirname(destPath), { recursive: true });
      await writeFile(destPath, "node_modules/\n", "utf8");
      console.log(" OK");
      return;
    }
    console.log(tr(" skip", " 跳过"));
    return;
  }

  if (!required) {
    console.log("");
    err(
      tr(
        `Optional file failed after ${maxRetries} retries (HTTP ${lastStatus || "network"}): ${url}`,
        `可选文件已重试 ${maxRetries} 次仍失败(HTTP ${lastStatus || "网络错误"}): ${url}`,
      ),
    );
    process.exit(1);
  }

  console.log("");
  err(tr(`Download failed after ${maxRetries} retries: ${url}`, `下载失败(已重试 ${maxRetries} 次): ${url}`));
  process.exit(1);
}

function runtimeOutputCandidatesForEntry(entry) {
  const normalized = String(entry || "").replace(/\\/g, "/").replace(/^\.\//, "");
  if (!normalized.endsWith(".ts")) {
    return [];
  }
  const withoutExt = normalized.slice(0, -3);
  return [
    `dist/${withoutExt}.js`,
    `dist/${withoutExt}.mjs`,
    `dist/${withoutExt}.cjs`,
    `${withoutExt}.js`,
    `${withoutExt}.mjs`,
    `${withoutExt}.cjs`,
  ];
}

async function assertBuiltRuntimeOutputs(destDir) {
  let pkg = null;
  try {
    pkg = JSON.parse(await readFile(join(destDir, "package.json"), "utf8"));
  } catch {
    return;
  }

  const entries = [];
  const extensions = pkg?.openclaw?.extensions;
  if (Array.isArray(extensions)) {
    for (const entry of extensions) {
      if (typeof entry === "string") entries.push(entry);
    }
  }
  if (typeof pkg?.openclaw?.setupEntry === "string") {
    entries.push(pkg.openclaw.setupEntry);
  }

  const missing = [];
  for (const entry of entries) {
    const candidates = runtimeOutputCandidatesForEntry(entry);
    if (candidates.length === 0) continue;
    const found = candidates.some((candidate) => existsSync(join(destDir, ...candidate.split("/"))));
    if (!found) {
      missing.push(`${entry} (expected one of: ${candidates.join(", ")})`);
    }
  }

  if (missing.length === 0) {
    return;
  }

  err(tr(
    `Plugin build did not create required runtime output:\n  - ${missing.join("\n  - ")}`,
    `插件构建未生成必需的运行时产物:\n  - ${missing.join("\n  - ")}`,
  ));
  process.exit(1);
}

async function installPluginNpmDependencies(destDir) {
  if (!resolvedNpmBuild) {
    info(tr("Installing plugin npm dependencies...", "正在安装插件 npm 依赖..."));
    const npmArgs = resolvedNpmOmitDev
      ? ["install", "--omit=dev", "--no-audit", "--no-fund", "--registry", NPM_REGISTRY]
      : ["install", "--no-audit", "--no-fund", "--registry", NPM_REGISTRY];
    await run("npm", npmArgs, { cwd: destDir, silent: false });
    return;
  }

  info(tr(
    "Installing plugin npm dependencies for source build...",
    "正在安装插件源码构建所需的 npm 依赖...",
  ));
  await run("npm", [
    "install",
    "--include=dev",
    "--no-audit",
    "--no-fund",
    "--registry",
    NPM_REGISTRY,
  ], { cwd: destDir, silent: false });

  info(tr(
    `Building plugin runtime output with npm run ${resolvedNpmBuildScript}...`,
    `正在执行 npm run ${resolvedNpmBuildScript} 构建插件运行时产物...`,
  ));
  await run("npm", ["run", resolvedNpmBuildScript], { cwd: destDir, silent: false });
  await assertBuiltRuntimeOutputs(destDir);

  if (resolvedNpmOmitDev && resolvedNpmPruneAfterBuild) {
    info(tr("Pruning plugin dev dependencies...", "正在裁剪插件开发依赖..."));
    await run("npm", [
      "prune",
      "--omit=dev",
      "--no-audit",
      "--no-fund",
    ], { cwd: destDir, silent: false });
  }
}

async function copyNpmPackageToDest(destDir) {
  const packageDir = await ensureNpmPackageExtracted();
  await mkdir(destDir, { recursive: true });
  const entries = readdirSync(packageDir, { withFileTypes: true });
  for (const entry of entries) {
    await cp(join(packageDir, entry.name), join(destDir, entry.name), { recursive: true, force: true });
  }
}

async function cleanupNpmPackageTemp() {
  if (!npmPackageTempDir) return;
  await rm(npmPackageTempDir, { recursive: true, force: true });
  npmPackageTempDir = "";
  npmPackageExtractDir = "";
}

async function downloadPlugin(destDir) {
  if (pluginSource === "npm") {
    await mkdir(destDir, { recursive: true });
    info(tr(
      `Installing plugin from npm package ${npmPackageSpec()}...`,
      `Installing plugin from npm package ${npmPackageSpec()}...`,
    ));
    await copyNpmPackageToDest(destDir);
    await installPluginNpmDependencies(destDir);
    info(tr(`Plugin deployed: ${PLUGIN_DEST}`, `Plugin deployed: ${PLUGIN_DEST}`));
    return;
  }

  const ghRaw = `https://raw.githubusercontent.com/${REPO}/${PLUGIN_VERSION}`;
  const pluginDir = resolvedPluginDir;
  const total = resolvedFilesRequired.length + resolvedFilesOptional.length;

  await mkdir(destDir, { recursive: true });

  info(tr(`Downloading plugin from ${REPO}@${PLUGIN_VERSION} (${total} files)...`, `正在从 ${REPO}@${PLUGIN_VERSION} 下载插件(共 ${total} 个文件)...`));

  let i = 0;
  // Download required files
  for (const name of resolvedFilesRequired) {
    if (!name) continue;
    i++;
    const url = `${ghRaw}/examples/${pluginDir}/${name}`;
    await downloadPluginFile(destDir, name, url, true, i, total);
  }

  // Download optional files
  for (const name of resolvedFilesOptional) {
    if (!name) continue;
    i++;
    const url = `${ghRaw}/examples/${pluginDir}/${name}`;
    await downloadPluginFile(destDir, name, url, false, i, total);
  }

  await installPluginNpmDependencies(destDir);
  info(tr(`Plugin deployed: ${PLUGIN_DEST}`, `插件部署完成: ${PLUGIN_DEST}`));
}

async function createPluginStagingDir() {
  const pluginId = resolvedPluginId || "openviking";
  const extensionsDir = join(OPENCLAW_DIR, "extensions");
  await mkdir(extensionsDir, { recursive: true });
  const stagingPrefix = `.${pluginId}.staging-`;
  try {
    const entries = readdirSync(extensionsDir, { withFileTypes: true });
    for (const entry of entries) {
      if (entry.isDirectory() && entry.name.startsWith(stagingPrefix)) {
        await rm(join(extensionsDir, entry.name), { recursive: true, force: true });
      }
    }
  } catch {}
  const stagingDir = join(extensionsDir, `${stagingPrefix}${process.pid}-${Date.now()}`);
  await mkdir(stagingDir, { recursive: true });
  return stagingDir;
}

async function finalizePluginDeployment(stagingDir) {
  await rm(PLUGIN_DEST, { recursive: true, force: true });
  try {
    await rename(stagingDir, PLUGIN_DEST);
  } catch {
    await cp(stagingDir, PLUGIN_DEST, { recursive: true, force: true });
    await rm(stagingDir, { recursive: true, force: true });
  }
  return info(tr(`Plugin deployed: ${PLUGIN_DEST}`, `插件部署完成: ${PLUGIN_DEST}`));
}

async function deployPluginFromRemote() {
  const stagingDir = await createPluginStagingDir();
  try {
    await downloadPlugin(stagingDir);
    await finalizePluginDeployment(stagingDir);
  } catch (error) {
    await rm(stagingDir, { recursive: true, force: true });
    throw error;
  } finally {
    await cleanupNpmPackageTemp();
  }

@github-actions
Copy link
Copy Markdown

PR Code Suggestions ✨

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Remove duplicate --github-repo argument handler

Remove the duplicate --github-repo argument parsing block. The original non-equals
--github-repo handler was missing the pluginSource update and is now redundant with
the second block. Combine them into a single correct block to ensure pluginSource is
set to "github" when using --github-repo without an explicit source.

examples/openclaw-plugin/setup-helper/install.js [203-221]

 if (arg.startsWith("--github-repo=")) {
   REPO = arg.slice("--github-repo=".length).trim();
   if (!pluginSourceExplicit) {
     pluginSource = "github";
   }
-  continue;
-}
-if (arg === "--github-repo") {
-  const repo = argv[i + 1]?.trim();
-  if (!repo) {
-    console.error("--github-repo requires a value (e.g. owner/repo)");
-    process.exit(1);
-  }
-  REPO = repo;
   continue;
 }
 if (arg === "--github-repo") {
   const repo = argv[i + 1]?.trim();
   if (!repo) {
     console.error("--github-repo requires a value (e.g. owner/repo)");
     process.exit(1);
   }
   REPO = repo;
   if (!pluginSourceExplicit) {
     pluginSource = "github";
   }
   i += 1;
   continue;
 }
Suggestion importance[1-10]: 8

__

Why: The PR contains a duplicate --github-repo argument parsing block: the first duplicate block fails to increment the argument index (i) or set pluginSource to "github", which would cause incorrect argument parsing and plugin source detection. This is a significant functional bug fix.

Medium

@qin-ctx qin-ctx merged commit 8ec1e21 into volcengine:main May 15, 2026
4 of 5 checks passed
@github-project-automation github-project-automation Bot moved this from Backlog to Done in OpenViking project May 15, 2026
r266-tech added a commit to r266-tech/OpenViking that referenced this pull request May 15, 2026
r266-tech added a commit to r266-tech/OpenViking that referenced this pull request May 15, 2026
MaojiaSheng pushed a commit that referenced this pull request May 15, 2026
…rsion auto-detect from #2072 (#2077)

* docs(openclaw): document plugin-source / plugin-package and plugin-version auto-detect from #2072 (en)

* docs(openclaw): document plugin-source / plugin-package and plugin-version auto-detect from #2072 (zh)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants