Improve Claude Design asset discovery and product runtime support

This commit is contained in:
musistudio
2026-08-07 11:51:19 +08:00
parent 860f41b187
commit 1f5cc499f1
9 changed files with 864 additions and 56 deletions
+25 -9
View File
@@ -2871,34 +2871,50 @@ function migratedClaudeShipPluginConfig(source: GatewayPluginConfig): GatewayPlu
}
export function claudeDesignRuntimePluginConfig(): GatewayPluginConfig | undefined {
return claudeProductRuntimePluginConfig(CLAUDE_DESIGN_PLUGIN_ID);
}
export function claudeShipRuntimePluginConfig(): GatewayPluginConfig | undefined {
return claudeProductRuntimePluginConfig(CLAUDE_SHIP_PLUGIN_ID);
}
function claudeProductRuntimePluginConfig(pluginId: string): GatewayPluginConfig | undefined {
if (!isDesktopAppRuntime()) {
return undefined;
}
const modulePath = resolveBundledOrExternalizedPluginModule(CLAUDE_DESIGN_PLUGIN_ID, undefined);
const modulePath = resolveBundledOrExternalizedPluginModule(pluginId, undefined);
if (!modulePath) {
return undefined;
}
return {
apps: knownGatewayPluginDefaultApps(CLAUDE_DESIGN_PLUGIN_ID),
apps: knownGatewayPluginDefaultApps(pluginId),
enabled: true,
id: CLAUDE_DESIGN_PLUGIN_ID,
id: pluginId,
module: modulePath,
permissions: knownGatewayPluginDefaultPermissions(CLAUDE_DESIGN_PLUGIN_ID),
surfaces: knownGatewayPluginDefaultSurfaces(CLAUDE_DESIGN_PLUGIN_ID)
permissions: knownGatewayPluginDefaultPermissions(pluginId),
surfaces: knownGatewayPluginDefaultSurfaces(pluginId)
};
}
export function withClaudeDesignRuntimePluginConfig(config: AppConfig): AppConfig {
const existingIndex = config.plugins.findIndex((plugin) => plugin.enabled !== false && plugin.id === CLAUDE_DESIGN_PLUGIN_ID);
return withClaudeProductRuntimePluginConfig(config, CLAUDE_DESIGN_PLUGIN_ID, "Claude Design");
}
export function withClaudeShipRuntimePluginConfig(config: AppConfig): AppConfig {
return withClaudeProductRuntimePluginConfig(config, CLAUDE_SHIP_PLUGIN_ID, "Claude Ship");
}
function withClaudeProductRuntimePluginConfig(config: AppConfig, pluginId: string, productName: string): AppConfig {
const existingIndex = config.plugins.findIndex((plugin) => plugin.enabled !== false && plugin.id === pluginId);
if (existingIndex >= 0 && config.plugins[existingIndex]?.module?.trim()) {
return config;
}
if (!isDesktopAppRuntime()) {
throw new Error("Claude Design is only available in CCR Desktop.");
throw new Error(`${productName} is only available in CCR Desktop.`);
}
const plugin = claudeDesignRuntimePluginConfig();
const plugin = claudeProductRuntimePluginConfig(pluginId);
if (!plugin) {
throw new Error("Claude Design runtime module was not found. Rebuild app assets so the bundled Claude Design plugin is copied into the Electron dist.");
throw new Error(`${productName} runtime module was not found. Rebuild app assets so the bundled ${productName} plugin is copied into the Electron dist.`);
}
if (existingIndex >= 0) {
const existing = config.plugins[existingIndex];
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { claudeDesignRuntimePluginConfig, migrateKnownGatewayPluginConfigsForTest, withClaudeDesignRuntimePluginConfig } from "@ccr/core/config/config.ts";
import { claudeDesignRuntimePluginConfig, claudeShipRuntimePluginConfig, migrateKnownGatewayPluginConfigsForTest, withClaudeDesignRuntimePluginConfig } from "@ccr/core/config/config.ts";
import { CCR_DESKTOP_APP_ENV } from "@ccr/core/runtime/desktop-app.ts";
test("legacy combined Claude Design plugin config migrates to split Design and Ship plugins", () => {
@@ -103,9 +103,13 @@ test("Claude Design runtime plugin config resolves from the bundled plugin in CC
process.env[CCR_DESKTOP_APP_ENV] = "1";
const plugin = claudeDesignRuntimePluginConfig();
const shipPlugin = claudeShipRuntimePluginConfig();
assert.equal(plugin?.id, "claude-design");
assert.equal(plugin?.module, bundledPluginModule("claude-design"));
assert.deepEqual(plugin?.apps?.map((app) => app.id), ["claude-design"]);
assert.equal(shipPlugin?.id, "claude-ship");
assert.equal(shipPlugin?.module, bundledPluginModule("claude-ship"));
assert.deepEqual(shipPlugin?.apps?.map((app) => app.id), ["claude-ship"]);
const config = { plugins: [] };
const runtimeConfig = withClaudeDesignRuntimePluginConfig(config);
@@ -12,7 +12,13 @@ Claude Design and Claude Ship are separate plugins. Install `plugins/claude-ship
By default the Claude Design window loads `https://claude-design.ccrdesk.top/design` from the frontend embedded in the plugin code. Static files under `/design/*` are served by the Claude Design frontend host, while CCR still intercepts Design API paths such as `/v1/design`, `/design/v1/design`, Omelette RPC, bootstrap, and privacy consent probes.
Browser-saved Claude Design HTML and Claude app `ion-dist` assets are no longer auto-detected. The plugin uses the Cloudflare Pages frontend by default; for local development fixtures, set `assetDir` explicitly.
Browser-saved Claude Design HTML and Claude app `ion-dist` assets are no longer auto-detected. The plugin uses the Cloudflare Pages frontend by default; for local development fixtures, set `frontendUrl`/`frontendAssetsOrigin` and `assetDir` explicitly. `assetDir` can point at any of these extracted roots:
- `/path/to/claude-design-assets/public`
- `/path/to/claude-design-assets/public/design`
- `/path/to/claude-design-assets/public/design/assets`
When `assetDir` contains a usable `design/index.html` or `index.html`, the plugin serves that saved HTML as the Design shell and does not mix in cached or remote-discovered entry bundles.
When the packaged app owns the `ccr://` protocol handler, the window can also be opened with:
@@ -104,6 +104,7 @@ const BOOTSTRAP_ROUTE_PATHS = ["/_bootstrap", "/api/bootstrap", "/edge-api/boots
const TOKENIZED_PREVIEW_ROUTE_PATHS = ["/_t", "/design/_t"];
const DESIGN_ONLINE_REQUIRED_ROUTE_PATHS = [CCR_RESOURCE_RUNTIME_PATH, "/design", CLAUDE_APP_LEGACY_DESIGN_PATH, OMELETTE_RPC_PATH_PREFIX, "/design/v1/design", "/v1/design", ...PRIVACY_CONSENT_ROUTE_PATHS, ...TOKENIZED_PREVIEW_ROUTE_PATHS, ...BOOTSTRAP_ROUTE_PATHS];
const DESIGN_LOCAL_REQUIRED_ROUTE_PATHS = [CCR_RESOURCE_RUNTIME_PATH, OMELETTE_RPC_PATH_PREFIX, "/design/v1/design", "/v1/design", ...PRIVACY_CONSENT_ROUTE_PATHS, ...TOKENIZED_PREVIEW_ROUTE_PATHS, ...CLAUDE_APP_SPA_ROUTE_PATHS, ...BOOTSTRAP_ROUTE_PATHS, ...AUTH_ESCAPE_ROUTE_PATHS, ...AUTH_API_ROUTE_PATHS];
const PARKED_MESSAGE_STATE_NOT_FOUND = 1;
const ORGANIZATION_ROUTE_PATHS = ["/api/organizations", "/organizations"];
const SHIP_API_ROUTE_PATHS = ["/api/billing/promotion/claude-ship", ...ORGANIZATION_ROUTE_PATHS];
const SHIP_ONLINE_REQUIRED_ROUTE_PATHS = [CCR_RESOURCE_RUNTIME_PATH, "/v1/code", "/v1/sessions", ...SHIP_API_ROUTE_PATHS, ...PRIVACY_CONSENT_ROUTE_PATHS, ...BOOTSTRAP_ROUTE_PATHS, ...AUTH_ESCAPE_ROUTE_PATHS, ...AUTH_API_ROUTE_PATHS];
@@ -513,6 +514,15 @@ const DEFAULT_ME = {
module.exports = createClaudeProductPlugin("design");
module.exports.createClaudeProductPlugin = createClaudeProductPlugin;
if (process.env.CCR_CLAUDE_DESIGN_PLUGIN_TEST_EXPORTS === "1") {
module.exports.__test = {
discoverLocalDesignIndexAssets,
injectDesignMeIntoHtml,
mergeDesignShellMe,
readLocalAsset,
routeMockRequest
};
}
function createClaudeProductPlugin(productName = "design") {
const product = CLAUDE_PLUGIN_PRODUCTS[productName] || CLAUDE_PLUGIN_PRODUCTS.design;
@@ -940,6 +950,10 @@ async function routeMockRequest(runtime, method, url, request, requestBody) {
return serveAsset(runtime, path, request);
}
if (method === "GET" && path.startsWith("/design/design-systems/")) {
return serveAsset(runtime, path, request);
}
if (method === "GET" && isClaudeAppStaticRoutePath(path)) {
const claudeShipAsset = serveClaudeShipAsset(runtime, path);
if (claudeShipAsset) {
@@ -2229,6 +2243,7 @@ async function handleOmeletteConnectRpc(runtime, method, path, request, requestB
const rpcName = path.split("/").pop();
const isConnectProtoRequest = headerIncludes(request.headers["content-type"], "application/connect+proto");
const isJsonRpcRequest = headerIncludes(request.headers["content-type"], "application/json") && !isConnectProtoRequest;
const rpcBody = rpcName === "Chat" || isConnectProtoRequest
? decodeConnectEnvelope(requestBody)
: requestBody;
@@ -2341,6 +2356,16 @@ async function handleOmeletteConnectRpc(runtime, method, path, request, requestB
return protoResponse(encodeListUserSkillsResponse(runtime));
case "GetUsageStatus":
return protoResponse(encodeUsageStatusResponse(runtime));
case "GetPrepaidBalance":
if (isJsonRpcRequest) {
return jsonResponse(200, prepaidBalancePayload(runtime));
}
return protoResponse(encodePrepaidBalanceResponse(runtime));
case "GetProjectPresence":
if (isJsonRpcRequest) {
return jsonResponse(200, projectPresencePayload(runtime));
}
return protoResponse(encodeProjectPresenceResponse(runtime, rpcBody));
case "UpdateOrgSettings":
updateOrgSettings(runtime, rpcBody);
return protoResponse(Buffer.alloc(0));
@@ -2357,6 +2382,16 @@ async function handleOmeletteConnectRpc(runtime, method, path, request, requestB
return protoResponse(encodeTokenResponse());
case "CountTokens":
return protoResponse(await countGatewayTokens(runtime, rpcBody));
case "GetParkedMessage":
if (isJsonRpcRequest) {
return jsonResponse(200, parkedMessagePayload());
}
return protoResponse(encodeParkedMessageResponse());
case "CancelChat":
if (isJsonRpcRequest) {
return jsonResponse(200, {});
}
return protoResponse(Buffer.alloc(0));
case "Chat":
return await chatWithGateway(runtime, rpcBody);
case "TrackEvent":
@@ -2411,7 +2446,11 @@ async function handleOmeletteConnectRpc(runtime, method, path, request, requestB
case "ExecuteExperienceAction":
case "LintFiles":
case "FigmaGetStatus":
case "GoogleGetStatus":
case "GithubGetStatus":
if (isJsonRpcRequest) {
return jsonResponse(200, integrationStatusPayload(rpcName));
}
return protoResponse(encodeIntegrationStatusResponse(rpcName));
case "McpListConnected":
case "McpListConnectors":
@@ -2972,19 +3011,23 @@ async function resolveDesignIndexAssets(runtime, request, options = {}) {
return current;
}
const fromRequests = discoverRequestedDesignIndexAssets(runtime.store);
const fromLocal = discoverLocalDesignIndexAssets(runtime.assetDir);
if (fromLocal) {
return updateDesignIndexAssets(runtime, fromLocal, fromLocal.source || "local", { checkedAt: now });
}
const fromRequests = discoverRequestedDesignIndexAssets(runtime.store);
const fromCache = discoverCachedDesignIndexAssets(runtime.store);
const fromRemote = await discoverRemoteDesignIndexAssets(runtime, request);
const fromRemoteCache = await cacheRemoteDesignIndexAssets(runtime, fromRemote, request);
const fallback = mergeDesignIndexAssetPartials(fromLocal, fromCache) || {};
const fallback = mergeDesignIndexAssetPartials(fromCache) || {};
const seeded = {
html: isUsableDesignShellHtml(fromLocal?.html) ? fromLocal.html : current.html,
html: current.html,
scriptPath: fallback.scriptPath || current.scriptPath,
source: fallback.source || current.source || "current",
stylePath: fallback.stylePath || current.stylePath
};
const discovered = mergeDesignIndexAssets(seeded, fromRequests, fromRemoteCache || fromRemote, fromLocal);
const discovered = mergeDesignIndexAssets(seeded, fromRequests, fromRemoteCache || fromRemote);
const safeDiscovered = selectUsableDesignIndexAssets(runtime, discovered, fallback, current);
return updateDesignIndexAssets(runtime, safeDiscovered, safeDiscovered.source || "discovered", { checkedAt: now });
}
@@ -3377,32 +3420,34 @@ function discoverLocalDesignIndexAssets(assetDir) {
const assetRoot = pathModule.resolve(expandHomePath(assetDir));
const scripts = [];
const styles = [];
for (const entry of listLocalAssetFiles(assetRoot)) {
const requestPath = `/design/assets/${entry.relativePath}`;
if (!isDesignIndexScriptPath(requestPath) && !isDesignIndexStylePath(requestPath)) {
continue;
for (const root of localDesignAssetRoots(assetRoot)) {
for (const entry of listLocalAssetFiles(root)) {
const requestPath = `/design/assets/${entry.relativePath}`;
if (!isDesignIndexScriptPath(requestPath) && !isDesignIndexStylePath(requestPath)) {
continue;
}
if (isDesignIndexStylePath(requestPath)) {
styles.push({ mtimeMs: entry.stat.mtimeMs, path: requestPath, size: entry.stat.size });
continue;
}
let body;
try {
body = fs.readFileSync(entry.file);
} catch {
continue;
}
scripts.push({
mtimeMs: entry.stat.mtimeMs,
path: requestPath,
score: designEntryScriptScore(requestPath, body, entry.stat),
size: entry.stat.size
});
}
if (isDesignIndexStylePath(requestPath)) {
styles.push({ mtimeMs: entry.stat.mtimeMs, path: requestPath, size: entry.stat.size });
continue;
}
let body;
try {
body = fs.readFileSync(entry.file);
} catch {
continue;
}
scripts.push({
mtimeMs: entry.stat.mtimeMs,
path: requestPath,
score: designEntryScriptScore(requestPath, body, entry.stat),
size: entry.stat.size
});
}
scripts.sort((a, b) => b.score - a.score || b.mtimeMs - a.mtimeMs || b.size - a.size);
styles.sort((a, b) => b.mtimeMs - a.mtimeMs || b.size - a.size);
const assets = {
html: "",
html: readLocalDesignIndexHtml(assetRoot),
scriptPath: scripts[0]?.path,
source: "local",
stylePath: styles[0]?.path
@@ -3410,6 +3455,66 @@ function discoverLocalDesignIndexAssets(assetDir) {
return assets.html || assets.scriptPath || assets.stylePath ? assets : undefined;
}
function localDesignAssetRoots(assetRoot) {
return uniqueExistingDirectories([
assetRoot,
pathModule.join(assetRoot, "assets"),
pathModule.join(assetRoot, "design", "assets")
]);
}
function readLocalDesignIndexHtml(assetRoot) {
for (const file of uniquePaths(localDesignIndexHtmlCandidates(assetRoot))) {
try {
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
continue;
}
const html = fs.readFileSync(file, "utf8");
if (isUsableDesignShellHtml(html)) {
return html;
}
} catch {
continue;
}
}
return "";
}
function localDesignIndexHtmlCandidates(assetRoot) {
const candidates = [
pathModule.join(assetRoot, "index.html"),
pathModule.join(assetRoot, "design", "index.html")
];
if (pathModule.basename(assetRoot) === "assets") {
candidates.push(pathModule.join(pathModule.dirname(assetRoot), "index.html"));
}
return candidates;
}
function uniqueExistingDirectories(paths) {
return uniquePaths(paths).filter((directory) => {
try {
return fs.existsSync(directory) && fs.statSync(directory).isDirectory();
} catch {
return false;
}
});
}
function uniquePaths(paths) {
const seen = new Set();
const result = [];
for (const file of paths) {
const normalized = pathModule.resolve(file);
if (seen.has(normalized)) {
continue;
}
seen.add(normalized);
result.push(normalized);
}
return result;
}
function localAssetDirExists(assetDir) {
if (!assetDir) {
return false;
@@ -9564,10 +9669,63 @@ function encodeUsageStatusResponse() {
return Buffer.alloc(0);
}
function encodePrepaidBalanceResponse() {
return Buffer.alloc(0);
}
function encodeProjectPresenceResponse() {
return Buffer.concat([
protoInt32(1, 1)
]);
}
function encodeParkedMessageResponse() {
return protoEnum(1, PARKED_MESSAGE_STATE_NOT_FOUND);
}
function encodeIntegrationStatusResponse() {
return Buffer.alloc(0);
}
function projectPresencePayload(runtime) {
return {
accounts: [
{
accountUuid: runtime.me.accountUuid,
displayName: runtime.me.displayName,
email: runtime.me.email
}
],
totalCount: 1
};
}
function prepaidBalancePayload() {
return {
balance: 0,
currency: "USD",
prepaidBalance: 0
};
}
function parkedMessagePayload() {
return {
doneEvent: "",
remainingMs: 0,
state: "NOT_FOUND",
turnEpoch: "0"
};
}
function integrationStatusPayload(rpcName) {
const name = String(rpcName || "").replace(/GetStatus$/, "").toLowerCase() || "integration";
return {
connected: false,
integration: name,
status: "disconnected"
};
}
function encodeModelPreset(preset) {
return Buffer.concat([
protoString(1, preset.id),
@@ -9704,6 +9862,11 @@ async function handleDesignRestApi(runtime, method, path, request, requestBody)
}
}
const projectEventsMatch = restPath.match(/^\/v1\/design\/projects\/([^/]+)\/events$/);
if (method === "GET" && projectEventsMatch) {
return designProjectEventsResponse(decodeURIComponent(projectEventsMatch[1]));
}
if ((method === "GET" || method === "POST") && restPath === "/v1/design/files") {
return handleDesignRestFiles(runtime, method, requestBody);
}
@@ -9767,6 +9930,9 @@ async function handleDesignRestApi(runtime, method, path, request, requestBody)
const filePath = sanitizeProjectFilePath(decodeURIComponent(serveMatch[2]));
const row = getProjectFileRow(runtime, projectId, filePath);
if (!row) {
if (filePath === DEFAULT_PROJECT_FILE_PATH) {
return servePendingDesignPreviewResponse(runtime, projectId, filePath);
}
return jsonResponse(404, { error: { message: `File not found: ${filePath}` } });
}
if (method === "HEAD") {
@@ -9948,6 +10114,62 @@ function isDesignRestApiRoutePath(path) {
/^\/design\/_t\/[^/]+\/v1\/design\//.test(path);
}
function designProjectEventsResponse(projectId) {
return eventStreamResponse(200, async (response) => {
await writeResponseChunk(response, Buffer.from("retry: 30000\n\n", "utf8"));
await writeResponseChunk(response, Buffer.from(`event: presence\ndata: ${JSON.stringify({ accounts: [], projectId, totalCount: 0 })}\n\n`, "utf8"));
await new Promise((resolve) => {
let closed = false;
let timer;
const finish = () => {
if (closed) {
return;
}
closed = true;
if (timer) {
clearInterval(timer);
}
resolve();
};
response.on("close", finish);
response.on("error", finish);
timer = setInterval(() => {
if (!closed) {
response.write(": keep-alive\n\n");
}
}, 25_000);
setTimeout(finish, 5 * 60 * 1000);
});
}, {
"connection": "keep-alive",
"x-accel-buffering": "no"
});
}
function servePendingDesignPreviewResponse(runtime, projectId, filePath) {
const previewVersion = projectPreviewVersion(runtime, projectId);
const html = injectOmelettePreviewScripts(renderClaudeDesignPendingPreviewHtml(), previewVersion, previewPollUrl(projectId, filePath));
return textResponse(200, html, {
"cache-control": "no-store",
"content-type": "text/html; charset=utf-8",
etag: `"ccr-preview-${previewVersion}"`,
"x-ccr-preview-version": previewVersion
});
}
function renderClaudeDesignPendingPreviewHtml() {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Claude Design preview pending</title>
<style>html,body{margin:0;min-height:100%;background:transparent;color:transparent}</style>
</head>
<body></body>
</html>`;
}
function serveProjectFileResponse(runtime, projectId, row, filePath) {
const body = Buffer.from(row.body_base64 || "", "base64");
const contentType = row.content_type || guessContentType(filePath);
@@ -13885,17 +14107,61 @@ function readLocalAsset(assetDir, requestPath) {
function localAssetRelativePathCandidates(localRoot, requestPath) {
const normalizedPath = normalizePath(requestPath);
const relativePath = normalizedPath
.replace(/^\/ship\//, "")
.replace(/^\/design\/assets\//, "")
.replace(/^\/assets\//, "")
.replace(/^\/design\//, "");
if (normalizedPath.startsWith("/design/assets/") && relativePath && !relativePath.includes("/")) {
return [relativePath, `v1/${relativePath}`];
if (normalizedPath.startsWith("/design/assets/")) {
return localAssetPathCandidatesForAssetPath(normalizedPath.slice("/design/assets/".length), "design");
}
if (normalizedPath.startsWith("/ship/assets/")) {
return localAssetPathCandidatesForAssetPath(normalizedPath.slice("/ship/assets/".length), "ship");
}
if (normalizedPath.startsWith("/assets/")) {
const assetPath = normalizedPath.slice("/assets/".length);
return uniqueRelativePathCandidates([
...localAssetPathCandidatesForAssetPath(assetPath, "design"),
...localAssetPathCandidatesForAssetPath(assetPath, "ship")
]);
}
if (normalizedPath.startsWith("/ship/")) {
const shipPath = normalizedPath.slice("/ship/".length);
return uniqueRelativePathCandidates([
`ship/${shipPath}`,
shipPath
]);
}
if (normalizedPath.startsWith("/design/")) {
const designPath = normalizedPath.slice("/design/".length);
return uniqueRelativePathCandidates([
`design/${designPath}`,
designPath
]);
}
const relativePath = normalizedPath.replace(/^\//, "");
return [relativePath];
}
function localAssetPathCandidatesForAssetPath(assetPath, product) {
const normalizedAssetPath = String(assetPath || "").replace(/^\/+/, "");
if (!normalizedAssetPath) {
return [];
}
const candidates = [
`${product}/assets/${normalizedAssetPath}`,
`assets/${normalizedAssetPath}`,
normalizedAssetPath
];
if (!normalizedAssetPath.includes("/")) {
candidates.push(
`${product}/assets/v1/${normalizedAssetPath}`,
"assets/v1/" + normalizedAssetPath,
"v1/" + normalizedAssetPath
);
}
return uniqueRelativePathCandidates(candidates);
}
function uniqueRelativePathCandidates(candidates) {
return Array.from(new Set(candidates.filter(Boolean)));
}
function isClaudeAppStaticRoutePath(path) {
return path === "/favicon.ico" ||
path === "/manifest.json" ||
@@ -14679,8 +14945,9 @@ function renderModulePreloadLinks(paths, lowPriority = false) {
function injectDesignMeIntoHtml(html, me) {
let nextHtml = html;
const meJsonScript = designMeJsonScript(me);
const meJsonPattern = /<script\b(?=[^>]*\bid=["']omelette-me["'])[^>]*>[\s\S]*?<\/script>/i;
const designMe = mergeDesignShellMe(readDesignMePayloadFromHtml(nextHtml), me);
const meJsonScript = designMeJsonScript(designMe);
if (meJsonPattern.test(nextHtml)) {
nextHtml = nextHtml.replace(meJsonPattern, meJsonScript);
}
@@ -14697,10 +14964,10 @@ function injectDesignMeIntoHtml(html, me) {
snippets.push(meJsonScript);
}
if (!nextHtml.includes("ccr-claude-design-model-reset")) {
snippets.push(designModelPreferenceResetScript(me));
snippets.push(designModelPreferenceResetScript(designMe));
}
if (!nextHtml.includes("__OMELETTE_ME__")) {
snippets.push(designMeGlobalScript(me));
snippets.push(designMeGlobalScript(designMe));
}
if (earlySnippets.length) {
nextHtml = injectHtmlAfterHeadOpen(nextHtml, earlySnippets.join("\n "));
@@ -14711,6 +14978,67 @@ function injectDesignMeIntoHtml(html, me) {
return injectHtmlAfterBodyOpen(nextHtml, snippets.join("\n "));
}
function readDesignMePayloadFromHtml(html) {
const match = /<script\b(?=[^>]*\bid=["']omelette-me["'])[^>]*>([\s\S]*?)<\/script>/i.exec(String(html || ""));
if (!match) {
return undefined;
}
const text = decodeHtmlJsonText(match[1] || "").trim();
const value = parseMaybeJson(text, undefined);
return isRecord(value) ? value : undefined;
}
function mergeDesignShellMe(shellMe, runtimeMe) {
const merged = isRecord(runtimeMe) ? { ...runtimeMe } : {};
if (!isRecord(shellMe)) {
return merged;
}
if (typeof shellMe.hasProjects === "boolean" && merged.hasProjects === undefined) {
merged.hasProjects = shellMe.hasProjects;
}
const shellGrowthbookPayload = stringValue(shellMe.growthbookPayload);
const runtimeGrowthbookPayload = stringValue(merged.growthbookPayload);
if (shellGrowthbookPayload) {
merged.growthbookPayload = mergeGrowthbookPayloads(shellGrowthbookPayload, runtimeGrowthbookPayload);
}
return merged;
}
function mergeGrowthbookPayloads(shellPayload, runtimePayload) {
const shell = parseMaybeJson(shellPayload, {});
const runtime = parseMaybeJson(runtimePayload, {});
if (!isRecord(shell)) {
return stringValue(runtimePayload) || "{}";
}
if (!isRecord(runtime)) {
return JSON.stringify(shell);
}
return JSON.stringify({
...shell,
...runtime,
features: {
...(isRecord(shell.features) ? shell.features : {}),
...(isRecord(runtime.features) ? runtime.features : {})
}
});
}
function decodeHtmlJsonText(value) {
return String(value || "")
.replace(/&quot;/g, "\"")
.replace(/&#34;/g, "\"")
.replace(/&#x22;/gi, "\"")
.replace(/&amp;/g, "&")
.replace(/&#38;/g, "&")
.replace(/&#x26;/gi, "&")
.replace(/&lt;/g, "<")
.replace(/&#60;/g, "<")
.replace(/&#x3c;/gi, "<")
.replace(/&gt;/g, ">")
.replace(/&#62;/g, ">")
.replace(/&#x3e;/gi, ">");
}
function injectClaudeShipEntrypointIntoHtml(html) {
let nextHtml = String(html || "");
if (!nextHtml.includes("ccr-claude-ship-entrypoint")) {
@@ -16,8 +16,13 @@ export type ClaudeDesignWindowCdpOptions = {
type ClaudeDesignPluginStatus = {
backend?: unknown;
frontendAssetsHost?: unknown;
frontendAssetsOrigin?: unknown;
frontendUrl?: unknown;
proxy?: {
fallbackHosts?: unknown;
frontendAssetsHost?: unknown;
frontendAssetsOrigin?: unknown;
host?: unknown;
paths?: unknown;
};
@@ -89,6 +94,11 @@ export function claudeDesignCdpOptionsFromStatus(status: ClaudeDesignPluginStatu
const hosts = normalizeHostList([
status.proxy?.host,
status.frontendUrl,
status.frontendAssetsHost,
status.frontendAssetsOrigin,
status.proxy?.frontendAssetsHost,
status.proxy?.frontendAssetsOrigin,
...(Array.isArray(status.proxy?.fallbackHosts) ? status.proxy.fallbackHosts : [])
]);
if (!hosts.length) {
+125 -6
View File
@@ -1,9 +1,46 @@
import { withClaudeDesignRuntimePluginConfig } from "@ccr/core/config/config";
import { CLAUDE_DESIGN_PLUGIN_ID, knownGatewayPluginDefaultApps, type AppConfig, type GatewayPluginAppConfig } from "@ccr/core/contracts/app";
import { withClaudeDesignRuntimePluginConfig, withClaudeShipRuntimePluginConfig } from "@ccr/core/config/config";
import { CLAUDE_DESIGN_PLUGIN_ID, CLAUDE_SHIP_PLUGIN_ID, knownGatewayPluginDefaultApps, type AppConfig, type GatewayPluginAppConfig, type GatewayPluginConfig } from "@ccr/core/contracts/app";
const DEFAULT_CLAUDE_DESIGN_FRONTEND_URL = "https://claude-design.ccrdesk.top/design";
const DESIGN_FRONTEND_URL_ENV_KEYS = ["CCR_CLAUDE_DESIGN_FRONTEND_URL", "CCR_CLAUDE_DESIGN_WEB_URL"];
const DESIGN_FRONTEND_ASSETS_ORIGIN_ENV_KEYS = ["CCR_CLAUDE_DESIGN_FRONTEND_ORIGIN", "CCR_CLAUDE_DESIGN_ASSETS_ORIGIN"];
const SHIP_FRONTEND_URL_ENV_KEYS = ["CCR_CLAUDE_SHIP_FRONTEND_URL", "CCR_CLAUDE_SHIP_WEB_URL"];
const SHIP_FRONTEND_ASSETS_ORIGIN_ENV_KEYS = ["CCR_CLAUDE_SHIP_FRONTEND_ORIGIN", "CCR_CLAUDE_SHIP_ASSETS_ORIGIN", "CCR_CLAUDE_DESIGN_ASSETS_ORIGIN"];
type ClaudeProductAppOpenConfig = {
appPath: string;
assetsOriginConfigKeys: string[];
assetsOriginEnvKeys: string[];
pluginId: string;
urlConfigKeys: string[];
urlEnvKeys: string[];
};
const CLAUDE_PRODUCT_APP_OPEN_CONFIGS: Record<string, ClaudeProductAppOpenConfig> = {
[CLAUDE_DESIGN_PLUGIN_ID]: {
appPath: "/design",
assetsOriginConfigKeys: ["designFrontendAssetsOrigin", "designFrontendOrigin", "designAssetsOrigin", "frontendAssetsOrigin", "frontendOrigin", "assetsOrigin", "staticAssetsOrigin"],
assetsOriginEnvKeys: DESIGN_FRONTEND_ASSETS_ORIGIN_ENV_KEYS,
pluginId: CLAUDE_DESIGN_PLUGIN_ID,
urlConfigKeys: ["designFrontendUrl", "designWebUrl", "frontendUrl", "webUrl"],
urlEnvKeys: DESIGN_FRONTEND_URL_ENV_KEYS
},
[CLAUDE_SHIP_PLUGIN_ID]: {
appPath: "/claude-ship",
assetsOriginConfigKeys: ["shipFrontendAssetsOrigin", "shipFrontendOrigin", "shipAssetsOrigin", "frontendAssetsOrigin", "frontendOrigin", "assetsOrigin", "staticAssetsOrigin"],
assetsOriginEnvKeys: SHIP_FRONTEND_ASSETS_ORIGIN_ENV_KEYS,
pluginId: CLAUDE_SHIP_PLUGIN_ID,
urlConfigKeys: ["shipFrontendUrl", "shipWebUrl", "frontendUrl", "webUrl"],
urlEnvKeys: SHIP_FRONTEND_URL_ENV_KEYS
}
};
export function pluginAppUrlForOpen(config: AppConfig, pluginId: string, appUrl: string): string {
const frontendOverride = claudeProductFrontendUrlForOpen(config, pluginId);
if (frontendOverride) {
return frontendOverride;
}
export function pluginAppUrlForOpen(_config: AppConfig, pluginId: string, appUrl: string): string {
if (pluginId !== CLAUDE_DESIGN_PLUGIN_ID) {
return appUrl;
}
@@ -26,9 +63,91 @@ export function builtInPluginAppForOpen(pluginId: string, appId?: string): Gatew
}
export function configForPluginAppOpen(config: AppConfig, pluginId: string): AppConfig {
return pluginId === CLAUDE_DESIGN_PLUGIN_ID
? withClaudeDesignRuntimePluginConfig(config)
: config;
if (pluginId === CLAUDE_DESIGN_PLUGIN_ID) {
return withClaudeDesignRuntimePluginConfig(config);
}
if (pluginId === CLAUDE_SHIP_PLUGIN_ID) {
return withClaudeShipRuntimePluginConfig(config);
}
return config;
}
function claudeProductFrontendUrlForOpen(config: AppConfig, pluginId: string): string {
const product = CLAUDE_PRODUCT_APP_OPEN_CONFIGS[pluginId];
if (!product) {
return "";
}
const pluginOptions = pluginConfigOptions(config, product.pluginId);
const configuredUrl = normalizeHttpUrl(
firstConfigValue(pluginOptions, product.urlConfigKeys) ||
firstEnvValue(product.urlEnvKeys)
);
if (configuredUrl) {
return configuredUrl;
}
const configuredOrigin = normalizeHttpOrigin(
firstConfigValue(pluginOptions, product.assetsOriginConfigKeys) ||
firstEnvValue(product.assetsOriginEnvKeys)
);
return configuredOrigin ? new URL(product.appPath, configuredOrigin).toString() : "";
}
function pluginConfigOptions(config: AppConfig, pluginId: string): Record<string, unknown> | undefined {
const plugin = config.plugins.find((candidate: GatewayPluginConfig) => (
candidate.enabled !== false &&
candidate.id === pluginId
));
return isRecord(plugin?.config) ? plugin.config : undefined;
}
function firstConfigValue(config: Record<string, unknown> | undefined, keys: string[]): string {
if (!config) {
return "";
}
for (const key of keys) {
const value = stringValue(config[key]);
if (value) {
return value;
}
}
return "";
}
function firstEnvValue(keys: string[]): string {
for (const key of keys) {
const value = stringValue(process.env[key]);
if (value) {
return value;
}
}
return "";
}
function normalizeHttpUrl(value: string): string {
if (!value) {
return "";
}
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : "";
} catch {
return "";
}
}
function normalizeHttpOrigin(value: string): string {
const normalized = normalizeHttpUrl(value);
return normalized ? new URL(normalized).origin : "";
}
function stringValue(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function isLegacyClaudeDesignUrl(value: string): boolean {
@@ -0,0 +1,231 @@
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
const previousTestExports = process.env.CCR_CLAUDE_DESIGN_PLUGIN_TEST_EXPORTS;
process.env.CCR_CLAUDE_DESIGN_PLUGIN_TEST_EXPORTS = "1";
const claudeDesignPlugin = require(path.resolve(
process.cwd(),
"packages",
"electron",
"bundled-plugins",
"claude-design",
"index.cjs"
));
if (previousTestExports === undefined) {
delete process.env.CCR_CLAUDE_DESIGN_PLUGIN_TEST_EXPORTS;
} else {
process.env.CCR_CLAUDE_DESIGN_PLUGIN_TEST_EXPORTS = previousTestExports;
}
test("Claude Design local asset discovery supports public, design, and assets roots", () => {
const fixtureRoot = mkdtempSync(path.join(os.tmpdir(), "ccr-claude-design-assets-"));
try {
const publicRoot = path.join(fixtureRoot, "public");
const designRoot = path.join(publicRoot, "design");
const assetsRoot = path.join(designRoot, "assets");
const v1Root = path.join(assetsRoot, "v1");
const designSystemsRoot = path.join(designRoot, "design-systems");
mkdirSync(v1Root, { recursive: true });
mkdirSync(designSystemsRoot, { recursive: true });
writeFileSync(
path.join(v1Root, "index-BLOCAL123.js"),
"console.log('__OMELETTE_ME__ /v1/design createRoot ProjectPage');",
"utf8"
);
writeFileSync(path.join(v1Root, "index-CLOCAL123.css"), "body{color:#111}", "utf8");
writeFileSync(
path.join(designRoot, "index.html"),
[
"<!doctype html>",
"<html>",
"<head>",
"<script>globalThis.__OMELETTE_ME__={}</script>",
'<script type="module" src="/design/assets/v1/index-BLOCAL123.js"></script>',
'<link rel="stylesheet" href="/design/assets/v1/index-CLOCAL123.css">',
"</head>",
'<body><div id="root"></div></body>',
"</html>"
].join(""),
"utf8"
);
writeFileSync(path.join(designSystemsRoot, "manifest.json"), JSON.stringify({ systems: [] }), "utf8");
for (const assetDir of [publicRoot, designRoot, assetsRoot]) {
const discovered = claudeDesignPlugin.__test.discoverLocalDesignIndexAssets(assetDir);
assert.equal(discovered?.source, "local");
assert.equal(discovered?.scriptPath, "/design/assets/v1/index-BLOCAL123.js");
assert.equal(discovered?.stylePath, "/design/assets/v1/index-CLOCAL123.css");
assert.match(discovered?.html || "", /index-BLOCAL123\.js/);
const localScript = claudeDesignPlugin.__test.readLocalAsset(assetDir, "/design/assets/v1/index-BLOCAL123.js");
assert.equal(localScript?.contentType, "application/javascript; charset=utf-8");
assert.match(localScript?.body.toString("utf8") || "", /ProjectPage/);
}
for (const assetDir of [publicRoot, designRoot]) {
const manifest = claudeDesignPlugin.__test.readLocalAsset(assetDir, "/design/design-systems/manifest.json");
assert.equal(manifest?.contentType, "application/json; charset=utf-8");
assert.deepEqual(JSON.parse(manifest?.body.toString("utf8") || ""), { systems: [] });
}
} finally {
rmSync(fixtureRoot, { force: true, recursive: true });
}
});
test("Claude Design local shell keeps official UI flags while replacing account identity", () => {
const shellMe = {
accountUuid: "real-account-uuid",
organizationUuid: "real-org-uuid",
email: "real-user@example.com",
displayName: "Real User",
growthbookPayload: JSON.stringify({
features: {
"3252999076": {
defaultValue: "template_tray"
},
"custom-shell-flag": {
defaultValue: true
}
},
hashing_algorithm: "djb2"
}),
hasProjects: true,
modelPresets: [
{
id: "remote-only-model",
label: "Remote Only"
}
]
};
const runtimeMe = {
accountUuid: "00000000-0000-4000-8000-000000000001",
organizationUuid: "00000000-0000-4000-8000-000000000002",
email: "claude-code-router@example.local",
displayName: "Claude Code Router",
growthbookPayload: "{}",
defaultModelId: "glm-5.2",
modelPresets: [
{
id: "glm-5.2",
label: "GLM 5.2"
}
]
};
const html = [
"<!doctype html>",
"<html>",
"<head></head>",
"<body>",
`<script type="application/json" id="omelette-me">${JSON.stringify(shellMe)}</script>`,
'<div id="root"></div>',
"</body>",
"</html>"
].join("");
const rendered = claudeDesignPlugin.__test.injectDesignMeIntoHtml(html, runtimeMe);
const match = /<script\b(?=[^>]*\bid=["']omelette-me["'])[^>]*>([\s\S]*?)<\/script>/i.exec(rendered);
assert.ok(match, "expected rendered shell to include omelette-me JSON");
const injectedMe = JSON.parse(match[1]);
const injectedGrowthbook = JSON.parse(injectedMe.growthbookPayload);
assert.equal(injectedMe.accountUuid, runtimeMe.accountUuid);
assert.equal(injectedMe.organizationUuid, runtimeMe.organizationUuid);
assert.equal(injectedMe.email, runtimeMe.email);
assert.equal(injectedMe.displayName, runtimeMe.displayName);
assert.equal(injectedMe.hasProjects, true);
assert.deepEqual(injectedMe.modelPresets, runtimeMe.modelPresets);
assert.equal(injectedGrowthbook.features["3252999076"].defaultValue, "template_tray");
assert.equal(injectedGrowthbook.features["custom-shell-flag"].defaultValue, true);
assert.equal(injectedGrowthbook.hashing_algorithm, "djb2");
assert.equal(rendered.includes("real-user@example.com"), false);
assert.equal(rendered.includes("real-account-uuid"), false);
});
test("Claude Design mock covers local frontend project runtime routes", async () => {
const runtime = fakeClaudeDesignRuntime();
const jsonHeaders = {
"connect-protocol-version": "1",
"content-type": "application/json"
};
for (const rpcName of ["GetProjectPresence", "GetPrepaidBalance", "GoogleGetStatus", "GetParkedMessage", "CancelChat"]) {
const response = await claudeDesignPlugin.__test.routeMockRequest(
runtime,
"POST",
new URL(`http://127.0.0.1/design/anthropic.omelette.api.v1alpha.OmeletteService/${rpcName}`),
{ headers: jsonHeaders },
Buffer.from(JSON.stringify({ projectId: "project-local-test" }), "utf8")
);
assert.equal(response.status, 200);
assert.match(response.headers["content-type"], /application\/json/);
}
const parkedMessageProto = await claudeDesignPlugin.__test.routeMockRequest(
runtime,
"POST",
new URL("http://127.0.0.1/design/anthropic.omelette.api.v1alpha.OmeletteService/GetParkedMessage"),
{ headers: { "content-type": "application/proto" } },
Buffer.alloc(0)
);
assert.equal(parkedMessageProto.status, 200);
assert.deepEqual([...parkedMessageProto.body], [0x08, 0x01]);
const preview = await claudeDesignPlugin.__test.routeMockRequest(
runtime,
"GET",
new URL("http://127.0.0.1/_t/local-preview-token/v1/design/projects/project-local-test/serve/index.html"),
{ headers: {} },
Buffer.alloc(0)
);
assert.equal(preview.status, 200);
assert.match(preview.headers["content-type"], /text\/html/);
assert.match(String(preview.body), /Claude Design preview pending/);
const events = await claudeDesignPlugin.__test.routeMockRequest(
runtime,
"GET",
new URL("http://127.0.0.1/design/v1/design/projects/project-local-test/events?tab=tab-local-test"),
{ headers: { accept: "text/event-stream" } },
Buffer.alloc(0)
);
assert.equal(events.status, 200);
assert.match(events.headers["content-type"], /text\/event-stream/);
assert.equal(typeof events.stream, "function");
});
function fakeClaudeDesignRuntime() {
return {
assetDir: "",
frontendUrl: "http://127.0.0.1:6173/design",
me: {
accountUuid: "00000000-0000-4000-8000-000000000001",
displayName: "Claude Code Router",
email: "claude-code-router@example.local",
organizationUuid: "00000000-0000-4000-8000-000000000002"
},
pluginId: "claude-design",
store: {
database: {
prepare() {
return {
bind() {
return this;
},
free() {},
getAsObject() {
return {};
},
step() {
return false;
}
};
},
run() {}
},
persist() {}
}
};
}
@@ -23,12 +23,13 @@ test("Claude Design window CDP options are derived from plugin status", () => {
fallbackHosts: ["claude.com", "www.anthropic.com"],
host: "claude.ai",
paths: ["/design", "/v1/design", "/api"]
}
},
frontendUrl: "http://127.0.0.1:6173/design"
});
assert.deepEqual(options, {
backendUrl: "http://127.0.0.1:45678/",
hosts: ["claude.ai", "claude.com", "www.anthropic.com"],
hosts: ["claude.ai", "127.0.0.1:6173", "claude.com", "www.anthropic.com"],
paths: ["/design", "/v1/design", "/api"]
});
});
@@ -55,6 +56,13 @@ test("Claude Design window CDP rewrites matching Claude requests to the local ba
),
"http://127.0.0.1:45678/_t/541cb539-11c0-4789-9d87-bfa6c87153aa/v1/design/projects/8964ff52-6e42-4b71-9247-1b7c18baee70/serve/index.html?srcmap=1"
);
assert.equal(
claudeDesignRedirectUrlForRequest("http://127.0.0.1:6173/design/v1/design/projects/project-1/events?tab=tab-1", {
...options,
hosts: [...options.hosts, "127.0.0.1:6173"]
}),
"http://127.0.0.1:45678/design/v1/design/projects/project-1/events?tab=tab-1"
);
assert.equal(
claudeDesignRedirectUrlForRequest("https://claude.ai/", options),
"http://127.0.0.1:45678/"
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { existsSync } from "node:fs";
import path from "node:path";
import test from "node:test";
import { CLAUDE_DESIGN_PLUGIN_ID } from "@ccr/core/contracts/app.ts";
import { CLAUDE_DESIGN_PLUGIN_ID, CLAUDE_SHIP_PLUGIN_ID } from "@ccr/core/contracts/app.ts";
import { CCR_DESKTOP_APP_ENV } from "@ccr/core/runtime/desktop-app.ts";
import { builtInPluginAppForOpen, configForPluginAppOpen, isLegacyClaudeDesignUrl, pluginAppUrlForOpen } from "@ccr/electron/main/plugin-app-url.ts";
@@ -56,6 +56,74 @@ test("Claude Design app opening keeps current and non-Design app URLs unchanged"
);
});
test("Claude Design app opening uses configured local frontend URL", () => {
const config = {
plugins: [{
config: {
frontendUrl: "http://127.0.0.1:6173/design"
},
enabled: true,
id: CLAUDE_DESIGN_PLUGIN_ID
}]
} as any;
assert.equal(
pluginAppUrlForOpen(
config,
CLAUDE_DESIGN_PLUGIN_ID,
"https://claude-design.ccrdesk.top/design"
),
"http://127.0.0.1:6173/design"
);
});
test("Claude Design app opening derives local frontend URL from configured assets origin", () => {
const config = {
plugins: [{
config: {
frontendAssetsOrigin: "http://127.0.0.1:6173/"
},
enabled: true,
id: CLAUDE_DESIGN_PLUGIN_ID
}]
} as any;
assert.equal(
pluginAppUrlForOpen(
config,
CLAUDE_DESIGN_PLUGIN_ID,
"https://claude-design.ccrdesk.top/design"
),
"http://127.0.0.1:6173/design"
);
});
test("Claude Design app opening uses local frontend URL from environment", () => {
withEnv("CCR_CLAUDE_DESIGN_FRONTEND_URL", "http://127.0.0.1:6173/design", () => {
assert.equal(
pluginAppUrlForOpen(
configWithIgnoredSavedDesignHtml,
CLAUDE_DESIGN_PLUGIN_ID,
"https://claude-design.ccrdesk.top/design"
),
"http://127.0.0.1:6173/design"
);
});
});
test("Claude Ship app opening derives local frontend URL from environment", () => {
withEnv("CCR_CLAUDE_SHIP_FRONTEND_ORIGIN", "http://127.0.0.1:6173", () => {
assert.equal(
pluginAppUrlForOpen(
{ plugins: [] } as any,
CLAUDE_SHIP_PLUGIN_ID,
"https://claude.ai/claude-ship"
),
"http://127.0.0.1:6173/claude-ship"
);
});
});
test("Claude Design resolves to the built-in app without installed plugin config", () => {
const pluginApp = builtInPluginAppForOpen(CLAUDE_DESIGN_PLUGIN_ID);
@@ -70,12 +138,16 @@ test("Claude Design app opening injects the runtime plugin config", () => {
withDesktopRuntime(() => {
const config = { plugins: [] } as any;
const runtimeConfig = configForPluginAppOpen(config, CLAUDE_DESIGN_PLUGIN_ID);
const shipConfig = configForPluginAppOpen(config, CLAUDE_SHIP_PLUGIN_ID);
assert.equal(config.plugins.length, 0);
assert.equal(runtimeConfig.plugins.length, 1);
assert.equal(runtimeConfig.plugins[0].id, CLAUDE_DESIGN_PLUGIN_ID);
assert.equal(runtimeConfig.plugins[0].module, bundledPluginModule("claude-design"));
assert.equal(configForPluginAppOpen(config, "claude-ship"), config);
assert.equal(shipConfig.plugins.length, 1);
assert.equal(shipConfig.plugins[0].id, CLAUDE_SHIP_PLUGIN_ID);
assert.equal(shipConfig.plugins[0].module, bundledPluginModule("claude-ship"));
assert.equal(configForPluginAppOpen(config, "unknown-plugin"), config);
});
});
@@ -118,3 +190,17 @@ function withDesktopRuntime(run: () => void): void {
}
}
}
function withEnv(name: string, value: string, run: () => void): void {
const previousValue = process.env[name];
try {
process.env[name] = value;
run();
} finally {
if (previousValue === undefined) {
delete process.env[name];
} else {
process.env[name] = previousValue;
}
}
}