DSH Plugin Radar

Marks DeepSeek Harness plugins on GitHub and npm, and shows the install command that actually works — including the --profile flag DSH requires and cannot run without.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name              DSH Plugin Radar
// @name:zh-CN        DSH 插件雷达
// @namespace         https://dshmarketplace.dev/
// @version           1.0.0
// @description       Marks DeepSeek Harness plugins on GitHub and npm, and shows the install command that actually works — including the --profile flag DSH requires and cannot run without.
// @description:zh-CN 在 GitHub 和 npm 上标出 DeepSeek Harness 插件,并给出真正能跑的安装命令 —— 带上 DSH 必需的 --profile,少了它装不上。
// @author            DshMarketPlace
// @license           MIT
// @homepageURL       https://github.com/DshMarketPlace/dsh-plugin-radar
// @supportURL        https://github.com/DshMarketPlace/dsh-plugin-radar/issues
// @icon              https://dshmarketplace.dev/brand/icon-192.png
// @match             https://github.com/*
// @match             https://www.npmjs.com/package/*
// @connect           dshmarketplace.dev
// @run-at            document-idle
// @grant             GM_xmlhttpRequest
// @grant             GM_setClipboard
// @grant             GM_addStyle
// @grant             GM_getValue
// @grant             GM_setValue
// @grant             GM_deleteValue
// @grant             GM_listValues
// @grant             GM_registerMenuCommand
// ==/UserScript==

/*
 * Every listing comes from the public catalogue at dshmarketplace.dev. Nothing
 * about you is sent anywhere: the script reads an index of plugin names once
 * every six hours and does the matching in your browser.
 *
 * Source: https://github.com/DshMarketPlace/dsh-plugin-radar
 */

(() => {
  "use strict";

  const SITE = "https://dshmarketplace.dev";
  const INDEX_TTL = 6 * 60 * 60 * 1000;
  const DETAIL_TTL = 24 * 60 * 60 * 1000;
  const DETAIL_CAP = 150;

  // GitHub reserves these first path segments, so none of them can be an owner.
  // The catalogue lookup would reject them anyway; this just skips the work.
  const RESERVED = new Set([
    "topics", "search", "explore", "trending", "collections", "sponsors",
    "marketplace", "settings", "notifications", "codespaces", "orgs", "apps",
    "features", "pricing", "about", "login", "join", "new", "pulls", "issues",
    "dashboard", "account", "organizations", "site", "contact", "security",
  ]);

  const zh = /^zh/i.test(navigator.language || "");

  const T = zh
    ? {
        badge: "DSH 插件",
        copy: "复制",
        copied: "已复制",
        noInstall: "没有一行装得上的命令",
        subpathWhy:
          "插件在仓库子目录里。dsh plugin add 转发给 pnpm,pnpm 把 # 后面当成 git ref,所以 github:owner/repo#subpath 解析不了。照仓库 README 手动装。",
        noNpmWhy: "这个插件没发到 npm,也没有可用的一行安装命令。",
        page: "在 DSH Marketplace 上查看",
        search: "在 DSH Marketplace 上搜索",
        profile: "当前 profile",
        setProfile: "设置 profile 名",
        setProfilePrompt: "你的 DSH profile 名(默认 web):",
        refresh: "刷新目录缓存",
        refreshed: "缓存已清空,刷新页面即可重新拉取。",
        offline: "拉不到目录数据",
        chipTitle: (c) => `DeepSeek Harness 插件 · ${c}`,
      }
    : {
        badge: "DSH plugin",
        copy: "Copy",
        copied: "Copied",
        noInstall: "No one-line install",
        subpathWhy:
          "The plugin lives in a subdirectory. `dsh plugin add` forwards to pnpm, and pnpm reads everything after `#` as a git ref, so `github:owner/repo#subpath` cannot resolve. Install it by hand, following the repo's README.",
        noNpmWhy:
          "This plugin is not published to npm and has no working one-line install command.",
        page: "View on DSH Marketplace",
        search: "Search DSH Marketplace",
        profile: "Profile",
        setProfile: "Set profile name",
        setProfilePrompt: "Your DSH profile name (default: web):",
        refresh: "Refresh catalogue cache",
        refreshed: "Cache cleared. Reload the page to fetch it again.",
        offline: "Catalogue unavailable",
        chipTitle: (c) => `DeepSeek Harness plugin · ${c}`,
      };

  /* ------------------------------------------------------------------ store */

  const read = (key) => {
    try {
      return JSON.parse(GM_getValue(key, "null"));
    } catch {
      return null;
    }
  };

  const write = (key, value) => GM_setValue(key, JSON.stringify(value));

  /** Keeps the per-plugin cache from growing without bound. */
  function pruneDetails() {
    const keys = GM_listValues().filter((k) => k.startsWith("d:"));
    if (keys.length <= DETAIL_CAP) return;
    keys
      .map((k) => ({ k, at: read(k)?.at ?? 0 }))
      .sort((a, b) => a.at - b.at)
      .slice(0, keys.length - DETAIL_CAP)
      .forEach(({ k }) => GM_deleteValue(k));
  }

  const profile = () => GM_getValue("profile", "web");

  /**
   * The catalogue writes every command against the `web` profile, which is the
   * one a default DSH install creates. Anyone running another one needs the
   * name swapped, and swapping it here beats printing a command they have to
   * edit before it runs.
   */
  const withProfile = (cmd) =>
    cmd.replace(/--profile\s+\S+/, `--profile ${profile()}`);

  /* ----------------------------------------------------------------- network */

  function getJSON(url) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: "GET",
        url,
        headers: { accept: "application/json" },
        timeout: 15000,
        onload: (r) => {
          if (r.status < 200 || r.status >= 300) {
            reject(new Error(`HTTP ${r.status}`));
            return;
          }
          try {
            resolve(JSON.parse(r.responseText));
          } catch (e) {
            reject(e);
          }
        },
        onerror: () => reject(new Error("network")),
        ontimeout: () => reject(new Error("timeout")),
      });
    });
  }

  /* --------------------------------------------------------------- catalogue */

  function toMaps(payload) {
    const byRepo = new Map();
    const byNpm = new Map();

    for (const [fullName, category, install, path, npm] of payload.plugins) {
      const entry = { fullName, category, install, path, npm };
      const repo = fullName.split("#")[0].toLowerCase();
      const bucket = byRepo.get(repo) ?? [];
      bucket.push(entry);
      byRepo.set(repo, bucket);
      if (npm) byNpm.set(npm.toLowerCase(), entry);
    }

    return { byRepo, byNpm };
  }

  let catalogue = null;

  function loadCatalogue() {
    if (catalogue) return catalogue;

    catalogue = (async () => {
      const cached = read("index");
      if (cached && Date.now() - cached.at < INDEX_TTL) return toMaps(cached.v);

      try {
        const payload = await getJSON(`${SITE}/api/v1/index`);
        write("index", { at: Date.now(), v: payload });
        return toMaps(payload);
      } catch (err) {
        // A stale index still names the same plugins. Failing closed here would
        // make the script look broken every time the network hiccups.
        if (cached) return toMaps(cached.v);
        throw err;
      }
    })();

    return catalogue;
  }

  /** The full record for one listing — summary, risk flags, every install route. */
  async function detail(fullName) {
    const key = `d:${fullName}`;
    const cached = read(key);
    if (cached && Date.now() - cached.at < DETAIL_TTL) return cached.v;

    const res = await getJSON(
      `${SITE}/api/v1/plugins?q=${encodeURIComponent(fullName)}&limit=20`,
    );
    // `q` is a substring match, so a monorepo returns its siblings too.
    const hit = res.results.find((r) => r.fullName === fullName) ?? null;
    write(key, { at: Date.now(), v: hit });
    pruneDetails();
    return hit;
  }

  const linkFor = (entry) =>
    entry.path
      ? `${SITE}${entry.path}`
      : `${SITE}/plugins?q=${encodeURIComponent(entry.fullName)}`;

  /* ------------------------------------------------------------------ pieces */

  const el = (tag, props = {}, kids = []) => {
    const node = Object.assign(document.createElement(tag), props);
    for (const kid of kids) node.append(kid);
    return node;
  };

  function commandRow(cmd) {
    const full = withProfile(cmd);
    const button = el("button", { className: "dshr-copy", textContent: T.copy });

    button.addEventListener("click", (event) => {
      event.preventDefault();
      GM_setClipboard(full, "text");
      button.textContent = T.copied;
      button.classList.add("is-done");
      setTimeout(() => {
        button.textContent = T.copy;
        button.classList.remove("is-done");
      }, 1600);
    });

    return el("div", { className: "dshr-cmd" }, [
      el("code", { textContent: full }),
      button,
    ]);
  }

  function noteRow(text) {
    return el("p", { className: "dshr-note", textContent: text });
  }

  /* -------------------------------------------------------------- repo card */

  function card(entries) {
    const root = el("section", { className: "dshr-card" });
    if (!getComputedStyle(document.documentElement).getPropertyValue("--fgColor-default")) {
      root.dataset.dshrTheme = "auto";
    }

    const head = el("div", { className: "dshr-head" }, [
      el("a", {
        className: "dshr-brand",
        href: SITE,
        target: "_blank",
        rel: "noopener",
        textContent: T.badge,
      }),
    ]);
    if (entries[0].category) {
      head.append(el("span", { className: "dshr-cat", textContent: entries[0].category }));
    }
    root.append(head);

    for (const entry of entries) {
      const block = el("div", { className: "dshr-block" });

      if (entries.length > 1) {
        block.append(
          el("div", { className: "dshr-sub", textContent: entry.fullName.split("#")[1] ?? "." }),
        );
      }

      const body = el("p", { className: "dshr-summary" });
      block.append(body);

      block.append(
        entry.install
          ? commandRow(entry.install)
          : noteRow(entry.fullName.includes("#") ? T.subpathWhy : T.noNpmWhy),
      );

      const flags = el("div", { className: "dshr-flags" });
      block.append(flags);

      block.append(
        el("a", {
          className: "dshr-link",
          href: linkFor(entry),
          target: "_blank",
          rel: "noopener",
          textContent: entry.path ? T.page : T.search,
        }),
      );

      root.append(block);

      // Progressive enrichment: the card is complete without this, so a failed
      // or slow detail request costs nothing visible.
      detail(entry.fullName)
        .then((full) => {
          if (!full) return;
          const summary = (zh && full.summaryZh) || full.summary;
          if (summary) body.textContent = summary;
          for (const flag of full.riskFlags ?? []) {
            flags.append(el("span", { className: "dshr-flag", textContent: flag }));
          }
        })
        .catch(() => {});
    }

    return root;
  }

  function mountRepoCard(entries) {
    if (document.querySelector(".dshr-card")) return;

    const node = card(entries);
    const pane = document.querySelector('[class*="PageLayout-Pane-"]');

    if (pane) {
      node.classList.add("dshr-in-pane");
      pane.prepend(node);
      return;
    }

    const readme = document.querySelector("article.markdown-body");
    if (readme?.parentElement) {
      readme.parentElement.insertBefore(node, readme);
      return;
    }

    document.querySelector("#repository-container-header")?.after(node);
  }

  /* ------------------------------------------------------------------- chips */

  function chip(entry) {
    const link = el("a", {
      className: "dshr-chip",
      href: linkFor(entry),
      target: "_blank",
      rel: "noopener",
      textContent: "DSH",
      title: entry.install
        ? `${T.chipTitle(entry.category || "plugin")}\n${withProfile(entry.install)}`
        : T.chipTitle(entry.category || "plugin"),
    });
    link.addEventListener("click", (event) => event.stopPropagation());
    return link;
  }

  function markLinks(byRepo) {
    const here = repoFromPath(location.pathname);

    for (const anchor of document.querySelectorAll('a[href^="/"], a[href^="https://github.com/"]')) {
      if (anchor.dataset.dshr || anchor.closest(".dshr-card")) continue;

      let key;
      try {
        key = repoFromPath(new URL(anchor.href, location.origin).pathname);
      } catch {
        continue;
      }
      if (!key || key === here) continue;

      const entries = byRepo.get(key);
      if (!entries) continue;

      // A repository card links to itself four or five times — the avatar, the
      // wrapping article, the tab strip. Only the anchor that actually names
      // the repository gets a mark, or every card grows a row of them.
      const label = anchor.textContent.trim().toLowerCase();
      if (!label || !label.includes(key.split("/")[1])) continue;

      anchor.dataset.dshr = "1";
      anchor.after(chip(entries[0]));
    }
  }

  function repoFromPath(pathname) {
    const parts = pathname.split("/").filter(Boolean);
    if (parts.length !== 2) return null;
    if (RESERVED.has(parts[0].toLowerCase())) return null;
    return `${parts[0]}/${parts[1]}`.toLowerCase().replace(/\.git$/, "");
  }

  /* --------------------------------------------------------------------- npm */

  function mountNpmCard(entry) {
    if (document.querySelector(".dshr-card")) return;

    const node = card([entry]);
    const heading = [...document.querySelectorAll("h3")].find(
      (h) => h.textContent.trim() === "Install",
    );

    if (heading?.parentElement) {
      heading.parentElement.insertBefore(node, heading);
      return;
    }

    document.querySelector("#readme")?.prepend(node);
  }

  /* ------------------------------------------------------------------ driver */

  async function run() {
    let maps;
    try {
      maps = await loadCatalogue();
    } catch {
      return;
    }

    if (location.hostname === "www.npmjs.com") {
      const name = decodeURIComponent(location.pathname.replace(/^\/package\//, ""));
      const entry = maps.byNpm.get(name.toLowerCase());
      if (entry) mountNpmCard(entry);
      return;
    }

    const here = repoFromPath(location.pathname);
    const entries = here && maps.byRepo.get(here);
    if (entries) mountRepoCard(entries);

    markLinks(maps.byRepo);
  }

  let timer = null;
  let seen = location.href;

  function schedule() {
    clearTimeout(timer);
    timer = setTimeout(run, 250);
  }

  new MutationObserver(() => {
    if (location.href !== seen) {
      seen = location.href;
      document.querySelectorAll(".dshr-card, .dshr-chip").forEach((n) => n.remove());
      document.querySelectorAll("[data-dshr]").forEach((n) => delete n.dataset.dshr);
    }
    schedule();
  }).observe(document.documentElement, { childList: true, subtree: true });

  GM_registerMenuCommand(`${T.setProfile} (${T.profile}: ${profile()})`, () => {
    const next = prompt(T.setProfilePrompt, profile());
    if (next && /^[\w.-]+$/.test(next.trim())) {
      GM_setValue("profile", next.trim());
      location.reload();
    }
  });

  GM_registerMenuCommand(T.refresh, () => {
    GM_listValues()
      .filter((k) => k !== "profile")
      .forEach((k) => GM_deleteValue(k));
    alert(T.refreshed);
  });

  GM_addStyle(`
    .dshr-card {
      --dshr-copper: oklch(0.55 0.152 46);
      --dshr-fg: var(--fgColor-default, #1f2328);
      --dshr-muted: var(--fgColor-muted, #59636e);
      --dshr-bg: var(--bgColor-muted, #f6f8fa);
      --dshr-line: var(--borderColor-default, #d1d9e0);
      border: 1px solid var(--dshr-line);
      border-left: 3px solid var(--dshr-copper);
      background: var(--dshr-bg);
      color: var(--dshr-fg);
      padding: 12px 14px;
      margin: 0 0 16px;
      font-size: 13px;
      line-height: 1.5;
    }
    .dshr-card[data-dshr-theme="auto"] { color-scheme: light dark; }
    @media (prefers-color-scheme: dark) {
      .dshr-card[data-dshr-theme="auto"] {
        --dshr-copper: oklch(0.63 0.16 46);
        --dshr-fg: #e6edf3;
        --dshr-muted: #9198a1;
        --dshr-bg: #161b22;
        --dshr-line: #30363d;
      }
    }
    .dshr-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
    .dshr-brand {
      color: var(--dshr-copper) !important;
      font-weight: 600;
      letter-spacing: 0.02em;
      text-decoration: none;
    }
    .dshr-brand:hover { text-decoration: underline; }
    .dshr-cat {
      font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
      font-size: 11px;
      color: var(--dshr-muted);
      border: 1px solid var(--dshr-line);
      padding: 0 5px;
    }
    .dshr-block + .dshr-block { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--dshr-line); }
    .dshr-sub {
      font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
      font-size: 11px; color: var(--dshr-muted); margin-bottom: 6px;
    }
    .dshr-summary:empty { display: none; }
    .dshr-summary { margin: 0 0 8px; color: var(--dshr-fg); }
    .dshr-cmd { display: flex; align-items: stretch; border: 1px solid var(--dshr-line); }
    /* Wraps rather than scrolls: the sidebar is ~300px and a scrollbar across
       an install command reads as a rendering fault. The copy button is the
       intended way to take it, so a wrapped line costs nothing. */
    .dshr-cmd code {
      flex: 1; min-width: 0; white-space: pre-wrap; overflow-wrap: anywhere;
      padding: 6px 8px; background: none; border: 0; border-radius: 0;
      font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px;
      line-height: 1.45; color: var(--dshr-fg);
    }
    .dshr-copy {
      flex: none; align-self: flex-start; cursor: pointer;
      border: 0; border-left: 1px solid var(--dshr-line);
      background: none; color: var(--dshr-muted); font-size: 11px;
      padding: 7px 10px; white-space: nowrap;
    }
    .dshr-copy:hover { color: var(--dshr-copper); }
    .dshr-copy.is-done { color: var(--dshr-copper); }
    .dshr-note { margin: 0; color: var(--dshr-muted); font-size: 12px; }
    .dshr-flags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
    .dshr-flags:empty { display: none; }
    .dshr-flag {
      font-size: 11px; color: var(--dshr-muted);
      border: 1px dashed var(--dshr-line); padding: 0 5px;
    }
    .dshr-link {
      display: inline-block; margin-top: 10px; font-size: 12px;
      color: var(--dshr-copper) !important; text-decoration: none;
    }
    .dshr-link:hover { text-decoration: underline; }
    .dshr-link::after { content: " \\2192"; }
    .dshr-chip {
      display: inline-block; margin-left: 6px; vertical-align: middle;
      font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
      font-size: 10px; line-height: 1.6; letter-spacing: 0.04em;
      padding: 0 4px; text-decoration: none !important;
      color: oklch(0.55 0.152 46) !important;
      border: 1px solid currentColor;
    }
    .dshr-chip:hover { background: oklch(0.55 0.152 46); color: #fff !important; }
  `);

  run();
})();