- Cowork: ComboFormModal

- BaseUrlSelect: add cloud endpoint option, custom URL local state, always
  default to first option; new cliEndpointMatch helper; CLI tool cards refactor
- API: new /v1/audio/voices and /v1/models/info; /v1/models filters disabled
  models, drop unused timestamp
- initializeApp: guard tunnel/tailscale auto-resume to once-per-process
- geminiHelper: ensureObjectType for schemas with properties but no type
- skills: minor SKILL.md tweaks (chat/embeddings/image/stt/tts/web-*)
This commit is contained in:
decolua
2026-05-07 15:45:09 +07:00
parent 6344abcf8d
commit 5c62e73cc6
28 changed files with 1897 additions and 320 deletions
@@ -0,0 +1,104 @@
"use server";
import { NextResponse } from "next/server";
const REGISTRY_URL = "https://api.anthropic.com/mcp-registry/v0/servers";
const VISIBILITY = "commercial,gsuite,gsuite-google";
const PLUGINS_REPO = "anthropics/knowledge-work-plugins";
const GH_API = "https://api.github.com";
const GH_RAW = "https://raw.githubusercontent.com";
const CACHE_TTL_MS = 60 * 60 * 1000; // 1h
const G_KEY = "__9routerCoworkMcpRegistryCache";
function gcache() {
if (!globalThis[G_KEY]) globalThis[G_KEY] = { ts: 0, data: null };
return globalThis[G_KEY];
}
// Fetch full registry across pagination
async function fetchRegistry() {
const out = [];
let cursor = "";
for (let i = 0; i < 20; i++) {
const url = `${REGISTRY_URL}?limit=500&visibility=${VISIBILITY}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`;
const r = await fetch(url, { headers: { "accept": "application/json" } });
if (!r.ok) break;
const j = await r.json();
for (const item of j.servers || []) {
const s = item.server || {};
const remote = (s.remotes || [])[0];
if (!remote?.url) continue;
const transport = remote.type === "streamable-http" ? "http" : (remote.type === "sse" ? "sse" : "http");
out.push({
source: "registry",
name: s.name,
title: s.title || s.name,
description: s.description || "",
url: remote.url,
transport,
});
}
cursor = j.metadata?.nextCursor;
if (!cursor) break;
}
return out;
}
// Fetch plugins from anthropics/knowledge-work-plugins. Each plugin folder contains
// .claude-plugin/plugin.json with mcp_servers map.
async function fetchPlugins() {
const r = await fetch(`${GH_API}/repos/${PLUGINS_REPO}/contents/`, { headers: { "accept": "application/vnd.github.v3+json" } });
if (!r.ok) return [];
const items = await r.json();
const dirs = items.filter((i) => i.type === "dir" && !i.name.startsWith(".") && i.name !== "partner-built");
const out = [];
await Promise.all(dirs.map(async (d) => {
try {
const url = `${GH_RAW}/${PLUGINS_REPO}/main/${d.name}/.claude-plugin/plugin.json`;
const pr = await fetch(url);
if (!pr.ok) return;
const pj = await pr.json();
const servers = pj.mcp_servers || pj.mcpServers || {};
for (const [key, srv] of Object.entries(servers)) {
if (!srv?.url || typeof srv.url !== "string") continue;
if (!/^https?:\/\//i.test(srv.url)) continue;
const transport = /\/sse(\b|\/)/i.test(srv.url) ? "sse" : (srv.type === "sse" ? "sse" : "http");
out.push({
source: "plugins",
plugin: d.name,
name: `${d.name}-${key}`,
title: pj.name || d.name,
description: pj.description || "",
url: srv.url,
transport,
});
}
} catch { /* skip */ }
}));
return out;
}
export async function GET(request) {
const { searchParams } = new URL(request.url);
const force = searchParams.get("refresh") === "1";
const cache = gcache();
if (!force && cache.data && Date.now() - cache.ts < CACHE_TTL_MS) {
return NextResponse.json({ cached: true, ...cache.data });
}
try {
const [registry, plugins] = await Promise.all([fetchRegistry(), fetchPlugins()]);
// Deduplicate by url
const seen = new Set();
const merged = [...registry, ...plugins].filter((s) => {
if (seen.has(s.url)) return false;
seen.add(s.url);
return true;
});
const data = { servers: merged, counts: { registry: registry.length, plugins: plugins.length, total: merged.length } };
cache.ts = Date.now();
cache.data = data;
return NextResponse.json({ cached: false, ...data });
} catch (e) {
return NextResponse.json({ error: e.message, servers: [], counts: { total: 0 } }, { status: 500 });
}
}
+133 -9
View File
@@ -5,9 +5,115 @@ import fs from "fs/promises";
import path from "path";
import os from "os";
import crypto from "crypto";
import { COWORK_PLUGINS, buildManagedMcpServers } from "@/shared/constants/coworkPlugins";
const PROVIDER = "gateway";
// Plugin folder mount location.
// Claude Cowork 3p actually launches with --user-data-dir=Claude-3p, so plugins
// must live there (not the system /Library path which requires admin & isn't read in 3p).
const getOrgPluginsCandidates = () => {
if (os.platform() === "darwin") {
const home = os.homedir();
return [
path.join(home, "Library", "Application Support", "Claude-3p", "org-plugins"),
path.join(home, "Library", "Application Support", "Claude", "org-plugins"),
"/Library/Application Support/Claude/org-plugins",
];
}
if (os.platform() === "win32") {
const localApp = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
const programData = process.env.ProgramData || "C:\\ProgramData";
return [
path.join(localApp, "Claude-3p", "org-plugins"),
path.join(localApp, "Claude", "org-plugins"),
path.join(programData, "Claude", "org-plugins"),
];
}
return [path.join(os.homedir(), ".config", "Claude-3p", "org-plugins"), "/etc/Claude/org-plugins"];
};
// Pick first writable candidate for org-plugins
async function pickPluginsRoot() {
for (const dir of getOrgPluginsCandidates()) {
try {
await fs.mkdir(dir, { recursive: true });
// Probe write
const probe = path.join(dir, ".__9router_probe");
await fs.writeFile(probe, "ok");
await fs.unlink(probe);
return dir;
} catch { /* try next */ }
}
return null;
}
// Create plugin folder mount: org-plugins/<name>/claude-plugin/{plugin.json, version.json, .mcp.json}
async function writeOrgPluginsFolder(selectedPluginNames) {
const root = await pickPluginsRoot();
if (!root) return { error: "no_writable_plugins_dir", written: [] };
const set = new Set(selectedPluginNames || []);
const selectedPlugins = COWORK_PLUGINS.filter((p) => set.has(p.name));
// Remove previously-managed plugin subfolders (best-effort)
for (const p of COWORK_PLUGINS) {
try { await fs.rm(path.join(root, p.name), { recursive: true, force: true }); } catch { /* ignore */ }
}
const written = [];
for (const p of selectedPlugins) {
const pluginRoot = path.join(root, p.name);
const metaDir = path.join(pluginRoot, ".claude-plugin");
try {
await fs.mkdir(metaDir, { recursive: true });
const manifest = { name: p.name, version: "1.0.0", description: p.description || p.name, author: { name: "9router" } };
await fs.writeFile(path.join(metaDir, "plugin.json"), JSON.stringify(manifest, null, 2));
// .mcp.json at plugin root, schema: {mcpServers: {name: {type, url, oauth?}}}
const mcpServers = {};
for (const s of p.servers) {
const key = p.servers.length === 1 ? p.name : `${p.name}-${s.key}`;
mcpServers[key] = {
type: /\/sse(\b|\/)/i.test(s.url) ? "sse" : "http",
url: s.url,
};
}
await fs.writeFile(path.join(pluginRoot, ".mcp.json"), JSON.stringify({ mcpServers }, null, 2));
written.push(p.name);
} catch (e) {
return { error: e.code || e.message, written, root };
}
}
return { written, root };
}
// Set operonSkipMcpApprovals[serverName]=true in Claude-3p/config.json so user
// is not prompted for every tool call. Mirrors mcpToolAccessProvider.setSkipApprovals.
async function writeSkipApprovals(managedServers) {
const cfgPath = path.join(getWriteRoot(), "config.json");
let cfg = {};
try {
cfg = JSON.parse(await fs.readFile(cfgPath, "utf-8")) || {};
} catch (e) {
if (e.code !== "ENOENT") return { error: e.code };
}
// Reset previous managed entries (those we own == COWORK_PLUGINS server names)
const ownedNames = new Set();
for (const p of COWORK_PLUGINS) {
for (const s of p.servers) {
ownedNames.add(p.servers.length === 1 ? p.name : `${p.name}-${s.key}`);
}
}
const skip = (cfg.operonSkipMcpApprovals && typeof cfg.operonSkipMcpApprovals === "object") ? cfg.operonSkipMcpApprovals : {};
for (const k of Object.keys(skip)) {
if (ownedNames.has(k)) delete skip[k];
}
for (const srv of managedServers) {
if (srv?.name) skip[srv.name] = true;
}
cfg.operonSkipMcpApprovals = skip;
await fs.mkdir(getWriteRoot(), { recursive: true });
await fs.writeFile(cfgPath, JSON.stringify(cfg, null, 2));
return { written: Object.keys(skip).length };
}
// Candidate user-data roots — Cowork can run from either Claude-3p (3p mode) or Claude (1p mode w/ cowork features)
const getCandidateRoots = () => {
if (os.platform() === "darwin") {
@@ -107,8 +213,6 @@ const checkInstalled = async () => {
return false;
};
const isLocalhostUrl = (url) => /localhost|127\.0\.0\.1|0\.0\.0\.0/i.test(url || "");
const readJson = async (filePath) => {
try {
const content = await fs.readFile(filePath, "utf-8");
@@ -160,6 +264,12 @@ export async function GET() {
? config.inferenceModels.map((m) => (typeof m === "string" ? m : m?.name)).filter(Boolean)
: [];
// managedMcpServers stored as native array in configLibrary <uuid>.json
const managedMcpArr = Array.isArray(config?.managedMcpServers) ? config.managedMcpServers : [];
const selectedPlugins = COWORK_PLUGINS
.filter((p) => p.servers.some((s) => managedMcpArr.some((v) => v?.url === s.url)))
.map((p) => p.name);
const has9Router = !!(config?.inferenceProvider === PROVIDER && baseUrl);
return NextResponse.json({
@@ -172,7 +282,9 @@ export async function GET() {
baseUrl,
models,
provider: config?.inferenceProvider || null,
selectedPlugins,
},
availablePlugins: COWORK_PLUGINS.map((p) => ({ name: p.name, description: p.description })),
});
} catch (error) {
console.log("Error reading cowork settings:", error);
@@ -182,23 +294,20 @@ export async function GET() {
export async function POST(request) {
try {
const { baseUrl, apiKey, models } = await request.json();
const { baseUrl, apiKey, models, plugins } = await request.json();
if (!baseUrl || !apiKey) {
return NextResponse.json({ error: "baseUrl and apiKey are required" }, { status: 400 });
}
if (isLocalhostUrl(baseUrl)) {
return NextResponse.json({
error: "Claude Cowork sandbox cannot reach localhost. Enable Tunnel/Cloud Endpoint or use Tailscale/VPS.",
}, { status: 400 });
}
const modelsArray = Array.isArray(models) ? models.filter((m) => typeof m === "string" && m.trim()) : [];
if (modelsArray.length === 0) {
return NextResponse.json({ error: "At least one model is required" }, { status: 400 });
}
const pluginsArray = Array.isArray(plugins) ? plugins.filter((p) => typeof p === "string") : [];
const managedMcpServers = buildManagedMcpServers(pluginsArray);
const bootstrapped = await bootstrapDeploymentMode();
const meta = await ensureMeta();
const configPath = path.join(getWriteConfigDir(), `${meta.appliedId}.json`);
@@ -208,10 +317,21 @@ export async function POST(request) {
inferenceGatewayBaseUrl: baseUrl,
inferenceGatewayApiKey: apiKey,
inferenceModels: modelsArray.map((name) => ({ name })),
isLocalDevMcpEnabled: true,
isDesktopExtensionEnabled: true,
};
if (managedMcpServers.length > 0) {
newConfig.managedMcpServers = managedMcpServers;
}
await fs.writeFile(configPath, JSON.stringify(newConfig, null, 2));
// Plugin folder mount (best-effort, doesn't fail the request)
const pluginsResult = await writeOrgPluginsFolder(pluginsArray);
// Auto-skip approvals for managed servers
let skipResult = null;
try { skipResult = await writeSkipApprovals(managedMcpServers); } catch (e) { skipResult = { error: e.message }; }
return NextResponse.json({
success: true,
bootstrapped,
@@ -219,6 +339,8 @@ export async function POST(request) {
? "Cowork enabled (3p mode set). Quit & reopen Claude Desktop."
: "Cowork settings applied. Quit & reopen Claude Desktop.",
configPath,
plugins: pluginsResult,
skipApprovals: skipResult,
});
} catch (error) {
console.log("Error applying cowork settings:", error);
@@ -238,6 +360,8 @@ export async function DELETE() {
} catch (error) {
if (error.code !== "ENOENT") throw error;
}
await writeOrgPluginsFolder([]);
try { await writeSkipApprovals([]); } catch { /* ignore */ }
return NextResponse.json({ success: true, message: "Cowork config reset" });
} catch (error) {
console.log("Error resetting cowork settings:", error);