935 lines
36 KiB
TypeScript
935 lines
36 KiB
TypeScript
|
|
// @ts-nocheck
|
||
|
|
// TypeScript is suppressed because @modelcontextprotocol/sdk@1.x expects
|
||
|
|
// Zod schemas for tool definitions, but we pass plain JSON Schema objects.
|
||
|
|
// The runtime contract is identical; there is no type-safety regression -
|
||
|
|
// the nocheck just avoids a blanket of incorrect Zod-vs-object type errors
|
||
|
|
// that would obscure real mistakes. Remove once the SDK adds a JSON Schema
|
||
|
|
// overload or we migrate to a Zod-based schema builder.
|
||
|
|
//
|
||
|
|
// `od mcp` - stdio MCP server that proxies read-only tool calls to the
|
||
|
|
// running daemon's HTTP API. Lets a coding agent in a *different* repo
|
||
|
|
// (Claude Code, Cursor, Zed) pull files from a local Open Design
|
||
|
|
// project without the export-zip-import dance.
|
||
|
|
//
|
||
|
|
// The server itself holds no state and never touches the filesystem;
|
||
|
|
// every tool resolves to a fetch() against `OD_DAEMON_URL`. Spawn the
|
||
|
|
// MCP server with no daemon running and tool calls return a clear
|
||
|
|
// "daemon not reachable" error - the server itself still launches so
|
||
|
|
// the client can list its tool schema.
|
||
|
|
|
||
|
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||
|
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||
|
|
import {
|
||
|
|
CallToolRequestSchema,
|
||
|
|
ListResourcesRequestSchema,
|
||
|
|
ListToolsRequestSchema,
|
||
|
|
ReadResourceRequestSchema,
|
||
|
|
} from '@modelcontextprotocol/sdk/types.js';
|
||
|
|
|
||
|
|
const SERVER_NAME = 'open-design';
|
||
|
|
const SERVER_VERSION = '0.2.0';
|
||
|
|
|
||
|
|
// Mimes whose body we surface as MCP `text` content. Everything else
|
||
|
|
// returns a clear error directing the caller at list_files for
|
||
|
|
// metadata, until phase 2 adds binary support.
|
||
|
|
const TEXTUAL_MIME_PATTERNS = [
|
||
|
|
/^text\//i,
|
||
|
|
/^application\/json\b/i,
|
||
|
|
/^application\/javascript\b/i,
|
||
|
|
/^application\/typescript\b/i,
|
||
|
|
/^application\/xml\b/i,
|
||
|
|
/^application\/x-(yaml|toml|httpd-php|sh)\b/i,
|
||
|
|
/\+json\b/i,
|
||
|
|
/\+xml\b/i,
|
||
|
|
/^image\/svg\+xml\b/i,
|
||
|
|
];
|
||
|
|
|
||
|
|
// Every tool here is a read against a local daemon owned by the
|
||
|
|
// current user, so they're all read-only, idempotent, and operate on
|
||
|
|
// a closed (project-scoped) namespace. Pull these into one constant
|
||
|
|
// so each tool def doesn't repeat them.
|
||
|
|
const READ_ANNOTATIONS = {
|
||
|
|
readOnlyHint: true,
|
||
|
|
idempotentHint: true,
|
||
|
|
openWorldHint: false,
|
||
|
|
};
|
||
|
|
|
||
|
|
// Description style: short, one purpose-line per tool. Active-context
|
||
|
|
// fallback is documented once in the server `instructions` block, so
|
||
|
|
// per-tool descriptions just say "project optional" and don't repeat
|
||
|
|
// the rationale - that saves ~150 tokens per tools/list response,
|
||
|
|
// shipped to the model on every session.
|
||
|
|
const PROJECT_ARG = {
|
||
|
|
type: 'string',
|
||
|
|
description: 'Project id (UUID) or name substring. Optional; defaults to the active project (expires after ~5 minutes of no Open Design activity).',
|
||
|
|
} as const;
|
||
|
|
|
||
|
|
const TOOL_DEFS = [
|
||
|
|
{
|
||
|
|
name: 'list_projects',
|
||
|
|
description: 'List every Open Design project on this daemon.',
|
||
|
|
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
||
|
|
annotations: { ...READ_ANNOTATIONS, title: 'List Open Design projects' },
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: 'get_active_context',
|
||
|
|
description:
|
||
|
|
'Project + file the user has open in Open Design right now. Returns {active:false, hint:"..."} when no project is active so the agent can ask the user to interact with Open Design (the active context expires ~5 minutes after the last user interaction). Most tools default to this when project is omitted, so you rarely need to call this directly.',
|
||
|
|
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
||
|
|
annotations: { ...READ_ANNOTATIONS, title: 'What is the user looking at?' },
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: 'get_artifact',
|
||
|
|
description:
|
||
|
|
'PREFER THIS over multiple get_file calls. Bundles the entry file plus every sibling it references (HTML <script>/<link>/<img>/srcset, JSX import/require, CSS url()/@import) up to depth 3, skipping CDN/data URLs. include="all" returns every file in the project; include="shallow" returns just the entry.',
|
||
|
|
inputSchema: {
|
||
|
|
type: 'object',
|
||
|
|
properties: {
|
||
|
|
project: PROJECT_ARG,
|
||
|
|
entry: {
|
||
|
|
type: 'string',
|
||
|
|
description:
|
||
|
|
"Entry file path relative to project root. Defaults to the active file or project's metadata.entryFile. Active-file fallback expires after ~5 minutes of no Open Design activity.",
|
||
|
|
},
|
||
|
|
include: {
|
||
|
|
type: 'string',
|
||
|
|
enum: ['auto', 'all', 'shallow'],
|
||
|
|
description: 'auto (default) | all | shallow',
|
||
|
|
},
|
||
|
|
maxBytes: {
|
||
|
|
type: 'number',
|
||
|
|
description:
|
||
|
|
'Soft cap on total text bytes (default 1_500_000). Also capped at 200 files. Excess files are dropped and truncated:true is set.',
|
||
|
|
},
|
||
|
|
},
|
||
|
|
additionalProperties: false,
|
||
|
|
},
|
||
|
|
annotations: { ...READ_ANNOTATIONS, title: 'Pull design bundle' },
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: 'get_project',
|
||
|
|
description:
|
||
|
|
'Single project metadata: name, active skill/design-system ids, entryFile, kind, timestamps.',
|
||
|
|
inputSchema: {
|
||
|
|
type: 'object',
|
||
|
|
properties: { project: PROJECT_ARG },
|
||
|
|
additionalProperties: false,
|
||
|
|
},
|
||
|
|
annotations: { ...READ_ANNOTATIONS, title: 'Get Open Design project' },
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: 'get_file',
|
||
|
|
description:
|
||
|
|
'Read one project file. Text mimes only (HTML, JSX, CSS, JSON, SVG, Markdown). Binary files return an error; use list_files for metadata. Returns up to `limit` lines starting at `offset` (defaults: offset=0, limit=2000), mirroring Claude Code\'s Read tool. For files longer than the slice, the response carries an `[od:file-window ...]` marker with totalLines so you can page by re-calling with the next offset. For multi-file designs prefer get_artifact.',
|
||
|
|
inputSchema: {
|
||
|
|
type: 'object',
|
||
|
|
properties: {
|
||
|
|
project: PROJECT_ARG,
|
||
|
|
path: {
|
||
|
|
type: 'string',
|
||
|
|
description:
|
||
|
|
'File path relative to project root, forward slashes. Optional; defaults to the active file when project is also omitted. Active-file fallback expires after ~5 minutes of no Open Design activity.',
|
||
|
|
},
|
||
|
|
offset: {
|
||
|
|
type: 'number',
|
||
|
|
description: '0-indexed starting line of the slice to return. Defaults to 0.',
|
||
|
|
},
|
||
|
|
limit: {
|
||
|
|
type: 'number',
|
||
|
|
description: 'Maximum number of lines to return. Defaults to 2000.',
|
||
|
|
},
|
||
|
|
},
|
||
|
|
additionalProperties: false,
|
||
|
|
},
|
||
|
|
annotations: { ...READ_ANNOTATIONS, title: 'Read project file' },
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: 'search_files',
|
||
|
|
description:
|
||
|
|
'Case-insensitive literal-substring search across textual files in a project. Returns up to max matches with file, 1-indexed line, and snippet.',
|
||
|
|
inputSchema: {
|
||
|
|
type: 'object',
|
||
|
|
properties: {
|
||
|
|
project: PROJECT_ARG,
|
||
|
|
query: {
|
||
|
|
type: 'string',
|
||
|
|
description: 'Literal substring (not a regex), case-insensitive.',
|
||
|
|
},
|
||
|
|
pattern: {
|
||
|
|
type: 'string',
|
||
|
|
description: 'Optional glob on file name, e.g. "*.jsx".',
|
||
|
|
},
|
||
|
|
max: {
|
||
|
|
type: 'number',
|
||
|
|
description: 'Cap on matches (default 200, hard cap 1000).',
|
||
|
|
},
|
||
|
|
},
|
||
|
|
required: ['query'],
|
||
|
|
additionalProperties: false,
|
||
|
|
},
|
||
|
|
annotations: { ...READ_ANNOTATIONS, title: 'Search project files' },
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: 'list_files',
|
||
|
|
description:
|
||
|
|
'Project file metadata: name, path, mime, kind, size, mtime, optional artifactManifest. Pass since=<unix-ms> to cheap-poll for changes.',
|
||
|
|
inputSchema: {
|
||
|
|
type: 'object',
|
||
|
|
properties: {
|
||
|
|
project: PROJECT_ARG,
|
||
|
|
since: {
|
||
|
|
type: 'number',
|
||
|
|
description: 'Unix-ms; only return files with mtime > since.',
|
||
|
|
},
|
||
|
|
},
|
||
|
|
additionalProperties: false,
|
||
|
|
},
|
||
|
|
annotations: { ...READ_ANNOTATIONS, title: 'List project files' },
|
||
|
|
},
|
||
|
|
// Catalog (skills, design systems) is intentionally NOT exposed as
|
||
|
|
// MCP tools. Skills are recipes that Open Design itself uses to
|
||
|
|
// generate artifacts; an external coding agent consuming Open
|
||
|
|
// Design's output can't run them. Design systems are reference material a
|
||
|
|
// user can opt into via the resource URIs (od://design-systems/...)
|
||
|
|
// when they actually want them, instead of paying tool-description
|
||
|
|
// tokens on every turn.
|
||
|
|
];
|
||
|
|
|
||
|
|
export async function runMcpStdio({ daemonUrl }) {
|
||
|
|
const baseUrl = String(daemonUrl).replace(/\/$/, '');
|
||
|
|
|
||
|
|
const server = new Server(
|
||
|
|
{ name: SERVER_NAME, version: SERVER_VERSION },
|
||
|
|
{
|
||
|
|
capabilities: { tools: {}, resources: {} },
|
||
|
|
instructions: [
|
||
|
|
'Open Design (OD) is a local-first design workspace. The user typically',
|
||
|
|
'has OD running on their machine; each project contains a rendered',
|
||
|
|
'artifact (HTML/JSX/CSS) plus its source files.',
|
||
|
|
'',
|
||
|
|
'Active context: get_artifact, get_project, get_file, search_files,',
|
||
|
|
'and list_files all accept project as OPTIONAL. When omitted, they',
|
||
|
|
'default to the project the user has open in OD right now; get_file',
|
||
|
|
'and get_artifact additionally default to the active file. So when',
|
||
|
|
'the user says "this file" / "the design I have open" / "find X",',
|
||
|
|
'just call the tool without project - no need to ask first. The',
|
||
|
|
'response carries usedActiveContext so you can confirm which',
|
||
|
|
'project/file you hit. Pass project explicitly to override.',
|
||
|
|
'',
|
||
|
|
'Pulling design context:',
|
||
|
|
' - get_artifact() - entry file PLUS every referenced sibling',
|
||
|
|
' (tokens CSS, JSX modules, imported assets) in one call.',
|
||
|
|
' PREFER THIS over multiple get_file calls when the user',
|
||
|
|
' wants to understand or extend a design.',
|
||
|
|
' - get_file(path) for a single known file. Returns up to 2000',
|
||
|
|
' lines starting at offset (default 0) and stamps a',
|
||
|
|
' [od:file-window ...] marker when the file is longer; page',
|
||
|
|
' by re-calling with the next offset.',
|
||
|
|
' - search_files(query) to find a class/component/copy string',
|
||
|
|
' without fetching every file.',
|
||
|
|
' - list_files for metadata only.',
|
||
|
|
' - list_projects to discover what is available on this daemon.',
|
||
|
|
' - get_active_context() if you want the active project/file',
|
||
|
|
' explicitly without making any other tool call.',
|
||
|
|
'',
|
||
|
|
'Project arguments accept either a UUID or a name substring',
|
||
|
|
'(e.g. "recaptr"); the server resolves the latter. When a project',
|
||
|
|
'is matched by slug or substring the response carries',
|
||
|
|
'resolvedProject:{id,name} so you can confirm which project was',
|
||
|
|
'resolved. Verify with the user if the match was unexpected.',
|
||
|
|
'',
|
||
|
|
'Reference material is exposed as MCP resources, not tools - read',
|
||
|
|
'od://design-systems/<id>/DESIGN.md when you need the brand spec',
|
||
|
|
'for a design (palette, typography, voice). Skills are similarly',
|
||
|
|
'available at od://skills/<id>/SKILL.md but are mostly relevant',
|
||
|
|
'when the user asks about how a particular artifact was generated.',
|
||
|
|
'',
|
||
|
|
'When extending an Open Design design in another codebase, pull',
|
||
|
|
'the full bundle once with get_artifact and work from those files',
|
||
|
|
'locally - do not fetch files one-by-one if you can avoid it.',
|
||
|
|
].join('\n'),
|
||
|
|
},
|
||
|
|
);
|
||
|
|
|
||
|
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||
|
|
tools: TOOL_DEFS,
|
||
|
|
}));
|
||
|
|
|
||
|
|
server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
||
|
|
const [skillsData, dsData] = await Promise.all([
|
||
|
|
getJson(`${baseUrl}/api/skills`).catch(() => ({ skills: [] })),
|
||
|
|
getJson(`${baseUrl}/api/design-systems`).catch(() => ({ designSystems: [] })),
|
||
|
|
]);
|
||
|
|
const resources = [
|
||
|
|
{
|
||
|
|
uri: 'od://focus/active',
|
||
|
|
name: 'Active Open Design context',
|
||
|
|
description: 'The project/file the user has open in Open Design right now.',
|
||
|
|
mimeType: 'application/json',
|
||
|
|
},
|
||
|
|
];
|
||
|
|
for (const s of skillsData?.skills || []) {
|
||
|
|
resources.push({
|
||
|
|
uri: `od://skills/${encodeURIComponent(s.id)}/SKILL.md`,
|
||
|
|
name: `Skill: ${s.name || s.id}`,
|
||
|
|
description: oneLine(s.description),
|
||
|
|
mimeType: 'text/markdown',
|
||
|
|
});
|
||
|
|
}
|
||
|
|
for (const d of dsData?.designSystems || []) {
|
||
|
|
resources.push({
|
||
|
|
uri: `od://design-systems/${encodeURIComponent(d.id)}/DESIGN.md`,
|
||
|
|
name: `Design system: ${d.title || d.name || d.id}`,
|
||
|
|
description: oneLine(d.summary),
|
||
|
|
mimeType: 'text/markdown',
|
||
|
|
});
|
||
|
|
}
|
||
|
|
return { resources };
|
||
|
|
});
|
||
|
|
|
||
|
|
server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
|
||
|
|
const uri = req.params?.uri;
|
||
|
|
if (uri === 'od://focus/active') {
|
||
|
|
const data = await getJson(`${baseUrl}/api/active`);
|
||
|
|
return {
|
||
|
|
contents: [
|
||
|
|
{
|
||
|
|
uri,
|
||
|
|
mimeType: 'application/json',
|
||
|
|
text: JSON.stringify(data, null, 2),
|
||
|
|
},
|
||
|
|
],
|
||
|
|
};
|
||
|
|
}
|
||
|
|
const m = String(uri || '').match(/^od:\/\/(skills|design-systems)\/([^/]+)\/(.+)$/);
|
||
|
|
if (!m) {
|
||
|
|
throw new Error(`unsupported resource URI: ${uri}`);
|
||
|
|
}
|
||
|
|
const [, kind, id] = m;
|
||
|
|
const route = kind === 'skills' ? 'skills' : 'design-systems';
|
||
|
|
const data = await getJson(
|
||
|
|
`${baseUrl}/api/${route}/${encodeURIComponent(decodeURIComponent(id))}`,
|
||
|
|
);
|
||
|
|
const text =
|
||
|
|
data?.skill?.body ??
|
||
|
|
data?.skill?.content ??
|
||
|
|
data?.designSystem?.body ??
|
||
|
|
data?.designSystem?.content ??
|
||
|
|
data?.body ??
|
||
|
|
data?.content ??
|
||
|
|
'';
|
||
|
|
return {
|
||
|
|
contents: [
|
||
|
|
{
|
||
|
|
uri,
|
||
|
|
mimeType: 'text/markdown',
|
||
|
|
text,
|
||
|
|
},
|
||
|
|
],
|
||
|
|
};
|
||
|
|
});
|
||
|
|
|
||
|
|
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
||
|
|
const name = req.params?.name;
|
||
|
|
const args = req.params?.arguments ?? {};
|
||
|
|
try {
|
||
|
|
switch (name) {
|
||
|
|
case 'list_projects':
|
||
|
|
return ok(await getJson(`${baseUrl}/api/projects`));
|
||
|
|
case 'get_active_context': {
|
||
|
|
const data = await getJson(`${baseUrl}/api/active`);
|
||
|
|
if (!data || data.active === false) {
|
||
|
|
return ok({
|
||
|
|
active: false,
|
||
|
|
hint: 'Open Design has no active project right now. The active context expires about 5 minutes after the last user interaction with Open Design, so the user may need to click into a project (or switch tabs inside one) to wake it up. Alternatively, pass project="<id-or-name>" to other tools to bypass active context entirely.',
|
||
|
|
});
|
||
|
|
}
|
||
|
|
return ok(data);
|
||
|
|
}
|
||
|
|
case 'get_project': {
|
||
|
|
const { id, resolved, active } = await resolveProjectArg(baseUrl, args.project);
|
||
|
|
const data = await getJson(`${baseUrl}/api/projects/${encodeURIComponent(id)}`);
|
||
|
|
const project = data?.project ?? data;
|
||
|
|
return ok(
|
||
|
|
withActiveEcho(
|
||
|
|
{
|
||
|
|
...project,
|
||
|
|
entryFile: project?.metadata?.entryFile ?? null,
|
||
|
|
kind: project?.metadata?.kind ?? null,
|
||
|
|
},
|
||
|
|
active,
|
||
|
|
resolved,
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
case 'list_files': {
|
||
|
|
const { id, resolved, active } = await resolveProjectArg(baseUrl, args.project);
|
||
|
|
const params = new URLSearchParams();
|
||
|
|
if (Number.isFinite(args.since)) params.set('since', String(args.since));
|
||
|
|
const qs = params.toString();
|
||
|
|
const url = `${baseUrl}/api/projects/${encodeURIComponent(id)}/files${qs ? `?${qs}` : ''}`;
|
||
|
|
return ok(withActiveEcho(await getJson(url), active, resolved));
|
||
|
|
}
|
||
|
|
case 'get_file': {
|
||
|
|
const { id, resolved, active } = await resolveProjectArg(baseUrl, args.project);
|
||
|
|
let path = typeof args.path === 'string' ? args.path : '';
|
||
|
|
// When both project and path are omitted, fall back to the
|
||
|
|
// active file. The agent saying "read this file" without
|
||
|
|
// specifying anything is the most natural call site.
|
||
|
|
if (!path && active && active.fileName) {
|
||
|
|
path = active.fileName;
|
||
|
|
}
|
||
|
|
requireString(path, 'path');
|
||
|
|
const offset = Number.isFinite(args.offset) ? Math.max(0, Math.floor(args.offset)) : 0;
|
||
|
|
const limit = Number.isFinite(args.limit) ? Math.max(1, Math.floor(args.limit)) : 2000;
|
||
|
|
return await getFile(baseUrl, id, path, active, resolved, offset, limit);
|
||
|
|
}
|
||
|
|
case 'get_artifact':
|
||
|
|
return await getArtifact(
|
||
|
|
baseUrl,
|
||
|
|
args.project,
|
||
|
|
args.entry,
|
||
|
|
args.include,
|
||
|
|
args.maxBytes,
|
||
|
|
);
|
||
|
|
case 'search_files': {
|
||
|
|
const { id, resolved, active } = await resolveProjectArg(baseUrl, args.project);
|
||
|
|
requireString(args.query, 'query');
|
||
|
|
const params = new URLSearchParams({ q: String(args.query) });
|
||
|
|
if (args.pattern) params.set('pattern', String(args.pattern));
|
||
|
|
if (args.max) params.set('max', String(args.max));
|
||
|
|
return ok(
|
||
|
|
withActiveEcho(
|
||
|
|
await getJson(
|
||
|
|
`${baseUrl}/api/projects/${encodeURIComponent(id)}/search?${params.toString()}`,
|
||
|
|
),
|
||
|
|
active,
|
||
|
|
resolved,
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
default:
|
||
|
|
return errorResult(`unknown tool: ${name}`);
|
||
|
|
}
|
||
|
|
} catch (err) {
|
||
|
|
return errorResult(formatError(err, baseUrl));
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
const transport = new StdioServerTransport();
|
||
|
|
await server.connect(transport);
|
||
|
|
|
||
|
|
// server.connect() only *starts* the transport; it resolves once the
|
||
|
|
// stdio reader is wired up, not when the stream closes. Hold the
|
||
|
|
// process open until the client disconnects (stdin EOF) so the cli.ts
|
||
|
|
// top-level `process.exit(0)` doesn't kill us mid-handshake.
|
||
|
|
await new Promise<void>((resolve) => {
|
||
|
|
const done = () => resolve();
|
||
|
|
transport.onclose = done;
|
||
|
|
process.stdin.once('end', done);
|
||
|
|
process.stdin.once('close', done);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function ok(payload) {
|
||
|
|
const text =
|
||
|
|
typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2);
|
||
|
|
return { content: [{ type: 'text', text }] };
|
||
|
|
}
|
||
|
|
|
||
|
|
function errorResult(message) {
|
||
|
|
return { isError: true, content: [{ type: 'text', text: message }] };
|
||
|
|
}
|
||
|
|
|
||
|
|
function requireString(v, name) {
|
||
|
|
if (typeof v !== 'string' || v.length === 0) {
|
||
|
|
throw new Error(`${name} is required (string).`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Resource description renderers in some MCP UIs collapse whitespace
|
||
|
|
// poorly; keep our descriptions on a single line so they don't break
|
||
|
|
// the catalog list layout.
|
||
|
|
function oneLine(s) {
|
||
|
|
if (typeof s !== 'string') return undefined;
|
||
|
|
return s.replace(/\s+/g, ' ').trim().slice(0, 200) || undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||
|
|
|
||
|
|
// Short-lived cache for the project list. A typical agent session
|
||
|
|
// makes several name-based lookups in quick succession; without this
|
||
|
|
// each one re-fetches /api/projects. The TTL is short so a project
|
||
|
|
// renamed in the Open Design UI shows up within a few seconds.
|
||
|
|
const PROJECT_LIST_TTL_MS = 5000;
|
||
|
|
let projectListCache = null;
|
||
|
|
|
||
|
|
async function fetchProjectList(baseUrl) {
|
||
|
|
const now = Date.now();
|
||
|
|
if (
|
||
|
|
projectListCache &&
|
||
|
|
projectListCache.baseUrl === baseUrl &&
|
||
|
|
now - projectListCache.t < PROJECT_LIST_TTL_MS
|
||
|
|
) {
|
||
|
|
return projectListCache.list;
|
||
|
|
}
|
||
|
|
const data = await getJson(`${baseUrl}/api/projects`);
|
||
|
|
const list = Array.isArray(data?.projects) ? data.projects : [];
|
||
|
|
projectListCache = { baseUrl, t: now, list };
|
||
|
|
return list;
|
||
|
|
}
|
||
|
|
|
||
|
|
// When the agent omits `project`, fall back to whatever the user has
|
||
|
|
// open in Open Design. Returns the resolved id plus, for echo-back to the
|
||
|
|
// caller, the active-context payload that was used. Throws a clear
|
||
|
|
// error when neither is available so the agent can prompt the user
|
||
|
|
// rather than guessing.
|
||
|
|
async function resolveProjectArg(baseUrl, arg) {
|
||
|
|
if (typeof arg === 'string' && arg.length > 0) {
|
||
|
|
const resolved = await resolveProjectId(baseUrl, arg);
|
||
|
|
return { id: resolved.id, resolved, active: null };
|
||
|
|
}
|
||
|
|
let active;
|
||
|
|
try {
|
||
|
|
active = await getJson(`${baseUrl}/api/active`);
|
||
|
|
} catch (err) {
|
||
|
|
throw new Error(
|
||
|
|
`project arg omitted and active context lookup failed: ${err && err.message ? err.message : err}. Pass project="<id-or-name>".`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if (!active || active.active === false || !active.projectId) {
|
||
|
|
throw new Error(
|
||
|
|
'project arg omitted and Open Design has no active project. The active context expires about 5 minutes after the last user interaction with Open Design - the user may need to click into a project to wake it up. Otherwise pass project="<id-or-name>".',
|
||
|
|
);
|
||
|
|
}
|
||
|
|
return { id: active.projectId, resolved: null, active };
|
||
|
|
}
|
||
|
|
|
||
|
|
async function resolveProjectId(baseUrl, arg) {
|
||
|
|
if (typeof arg !== 'string' || !arg) {
|
||
|
|
throw new Error('project is required (string).');
|
||
|
|
}
|
||
|
|
if (UUID_RE.test(arg)) return { id: arg, name: arg, source: 'uuid' as const };
|
||
|
|
|
||
|
|
const list = await fetchProjectList(baseUrl);
|
||
|
|
if (list.length === 0) {
|
||
|
|
throw new Error('no projects on this daemon');
|
||
|
|
}
|
||
|
|
|
||
|
|
const lower = arg.toLowerCase();
|
||
|
|
const norm = (s) =>
|
||
|
|
String(s || '')
|
||
|
|
.toLowerCase()
|
||
|
|
.replace(/\s*\(\d+\)\s*$/, '')
|
||
|
|
.replace(/[\s_-]+/g, '-');
|
||
|
|
const target = norm(arg);
|
||
|
|
|
||
|
|
const exact = list.filter((p) => String(p.name || '').toLowerCase() === lower);
|
||
|
|
if (exact.length === 1) return { id: exact[0].id, name: exact[0].name, source: 'exact' as const };
|
||
|
|
|
||
|
|
const slugged = list.filter((p) => norm(p.name) === target);
|
||
|
|
if (slugged.length === 1) return { id: slugged[0].id, name: slugged[0].name, source: 'slug' as const };
|
||
|
|
|
||
|
|
const subs = list.filter((p) =>
|
||
|
|
String(p.name || '').toLowerCase().includes(lower),
|
||
|
|
);
|
||
|
|
if (subs.length === 1) return { id: subs[0].id, name: subs[0].name, source: 'substring' as const };
|
||
|
|
if (subs.length > 1) {
|
||
|
|
const opts = subs.map((p) => `${p.name} (${p.id})`).join(', ');
|
||
|
|
throw new Error(
|
||
|
|
`multiple projects match "${arg}": ${opts}. Pass the UUID instead.`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
throw new Error(`no project matches "${arg}"`);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function getJson(url) {
|
||
|
|
const resp = await fetch(url);
|
||
|
|
if (!resp.ok) {
|
||
|
|
const body = await safeText(resp);
|
||
|
|
throw new Error(`daemon ${resp.status} on ${url}: ${body || resp.statusText}`);
|
||
|
|
}
|
||
|
|
return await resp.json();
|
||
|
|
}
|
||
|
|
|
||
|
|
async function getFile(baseUrl, project, relPath, active, resolved?, offset = 0, limit = 2000) {
|
||
|
|
const segments = String(relPath)
|
||
|
|
.split('/')
|
||
|
|
.filter((s) => s.length > 0)
|
||
|
|
.map(encodeURIComponent);
|
||
|
|
const url = `${baseUrl}/api/projects/${encodeURIComponent(project)}/raw/${segments.join('/')}`;
|
||
|
|
const resp = await fetch(url);
|
||
|
|
if (!resp.ok) {
|
||
|
|
const body = await safeText(resp);
|
||
|
|
return errorResult(
|
||
|
|
`daemon ${resp.status} on ${url}: ${body || resp.statusText}`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
const mime = (resp.headers.get('content-type') || 'application/octet-stream')
|
||
|
|
.split(';')[0]
|
||
|
|
.trim();
|
||
|
|
if (!isTextualMime(mime)) {
|
||
|
|
return errorResult(
|
||
|
|
`file at "${relPath}" has mime "${mime}"; binary content is not yet supported by od mcp. Use list_files to inspect its metadata.`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
const text = await resp.text();
|
||
|
|
const allLines = text.split('\n');
|
||
|
|
const totalLines = allLines.length;
|
||
|
|
const start = Math.min(offset, totalLines);
|
||
|
|
const slice = allLines.slice(start, start + limit);
|
||
|
|
const returnedLines = slice.length;
|
||
|
|
const truncated = start + returnedLines < totalLines;
|
||
|
|
|
||
|
|
const extra: string[] = [];
|
||
|
|
if (active) extra.push(formatActiveEchoLine(active, relPath));
|
||
|
|
if (resolved && (resolved.source === 'slug' || resolved.source === 'substring')) {
|
||
|
|
extra.push(`[od:resolved-project id="${resolved.id}" name="${resolved.name}" via="${resolved.source}"]`);
|
||
|
|
}
|
||
|
|
if (truncated || start > 0) {
|
||
|
|
const nextOffset = start + returnedLines;
|
||
|
|
const next = truncated ? `; call get_file again with offset=${nextOffset} to read more` : '';
|
||
|
|
extra.push(
|
||
|
|
`[od:file-window offset=${start} returnedLines=${returnedLines} totalLines=${totalLines}${next}]`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
content: [
|
||
|
|
...extra.map((t) => ({ type: 'text', text: t })),
|
||
|
|
{ type: 'text', text: slice.join('\n') },
|
||
|
|
],
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// Stamp `usedActiveContext` onto JSON tool responses when the
|
||
|
|
// project came from /api/active. Plain pass-through when the caller
|
||
|
|
// supplied project explicitly - keeps token overhead at zero for the
|
||
|
|
// explicit path.
|
||
|
|
function withActiveEcho(payload, active, resolved?) {
|
||
|
|
const result = active ? { ...payload, usedActiveContext: activeEchoPayload(active) } : payload;
|
||
|
|
if (resolved && (resolved.source === 'slug' || resolved.source === 'substring')) {
|
||
|
|
return { ...result, resolvedProject: { id: resolved.id, name: resolved.name } };
|
||
|
|
}
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
function activeEchoPayload(active) {
|
||
|
|
return {
|
||
|
|
projectId: active.projectId,
|
||
|
|
projectName: active.projectName ?? null,
|
||
|
|
fileName: active.fileName ?? null,
|
||
|
|
ageMs: active.ageMs ?? null,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatActiveEchoLine(active, resolvedPath) {
|
||
|
|
const proj = active.projectName || active.projectId;
|
||
|
|
const note = `[od:active-context project="${proj}" file="${resolvedPath}"]`;
|
||
|
|
return active.fileName === resolvedPath
|
||
|
|
? note
|
||
|
|
: `${note} (active file: ${active.fileName ?? 'none'})`;
|
||
|
|
}
|
||
|
|
|
||
|
|
const VALID_INCLUDE_MODES = new Set(['auto', 'all', 'shallow']);
|
||
|
|
const DEFAULT_MAX_BYTES = 1_500_000;
|
||
|
|
const MAX_FILES = 200;
|
||
|
|
|
||
|
|
// Tracks total textual content bytes accumulated; binary stubs don't
|
||
|
|
// count (their content is null). Once we cross the cap the caller
|
||
|
|
// stops fetching and stamps `truncated: true` on the bundle.
|
||
|
|
function totalTextBytes(files) {
|
||
|
|
let n = 0;
|
||
|
|
for (const f of files) {
|
||
|
|
if (!f.binary && typeof f.content === 'string') n += f.content.length;
|
||
|
|
}
|
||
|
|
return n;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function getArtifact(baseUrl, projectArg, entryArg, includeMode, maxBytesArg) {
|
||
|
|
const include = includeMode == null || includeMode === '' ? 'auto' : includeMode;
|
||
|
|
if (!VALID_INCLUDE_MODES.has(include)) {
|
||
|
|
return errorResult(
|
||
|
|
`invalid include "${includeMode}"; expected one of: auto, all, shallow`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
const maxBytes =
|
||
|
|
Number.isFinite(maxBytesArg) && maxBytesArg > 0 ? Number(maxBytesArg) : DEFAULT_MAX_BYTES;
|
||
|
|
|
||
|
|
const { id, active, resolved } = await resolveProjectArg(baseUrl, projectArg);
|
||
|
|
const data = await getJson(`${baseUrl}/api/projects/${encodeURIComponent(id)}`);
|
||
|
|
const project = data?.project ?? data;
|
||
|
|
// Active-file beats project default entry when project also came
|
||
|
|
// from active context - if the user is on landing.html and asks
|
||
|
|
// "bundle this", they mean landing.html, not whatever
|
||
|
|
// metadata.entryFile happens to be.
|
||
|
|
const explicitEntry = typeof entryArg === 'string' && entryArg.length > 0;
|
||
|
|
const entry = explicitEntry
|
||
|
|
? entryArg
|
||
|
|
: (active && active.fileName) || project?.metadata?.entryFile;
|
||
|
|
if (!entry) {
|
||
|
|
return errorResult(
|
||
|
|
`no entry file: pass entry="..." or set the project's metadata.entryFile`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (include === 'shallow') {
|
||
|
|
let file;
|
||
|
|
try {
|
||
|
|
file = await fetchProjectFile(baseUrl, id, entry);
|
||
|
|
} catch (err) {
|
||
|
|
return errorResult(err && err.message ? err.message : String(err));
|
||
|
|
}
|
||
|
|
return okBundle({ project, entry, files: [file], truncated: false, active, resolved });
|
||
|
|
}
|
||
|
|
|
||
|
|
if (include === 'all') {
|
||
|
|
const meta = await getJson(`${baseUrl}/api/projects/${encodeURIComponent(id)}/files`);
|
||
|
|
const allFiles = Array.isArray(meta?.files) ? meta.files : [];
|
||
|
|
const fetched = [];
|
||
|
|
let truncated = false;
|
||
|
|
for (const f of allFiles) {
|
||
|
|
if (fetched.length >= MAX_FILES || totalTextBytes(fetched) >= maxBytes) {
|
||
|
|
truncated = true;
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
const remaining = maxBytes - totalTextBytes(fetched);
|
||
|
|
fetched.push(await fetchProjectFile(baseUrl, id, f.name, remaining));
|
||
|
|
} catch (err) {
|
||
|
|
if (err instanceof BudgetExceededError) truncated = true;
|
||
|
|
// Skip files that fail to fetch; keep going.
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return okBundle({ project, entry, files: fetched, truncated, active, resolved });
|
||
|
|
}
|
||
|
|
|
||
|
|
// Auto mode: BFS from entry. The entry's own fetch must succeed -
|
||
|
|
// a 404 there almost always means the agent typo'd `entry:`, and
|
||
|
|
// returning an empty bundle would hide that.
|
||
|
|
let entryFile;
|
||
|
|
try {
|
||
|
|
entryFile = await fetchProjectFile(baseUrl, id, entry);
|
||
|
|
} catch (err) {
|
||
|
|
return errorResult(err && err.message ? err.message : String(err));
|
||
|
|
}
|
||
|
|
const MAX_DEPTH = 3;
|
||
|
|
const visited = new Set([entry]);
|
||
|
|
const fetched = [entryFile];
|
||
|
|
let truncated = false;
|
||
|
|
let frontier = [];
|
||
|
|
if (isTextualMime(entryFile.mime)) {
|
||
|
|
frontier = extractRelativeRefs(entryFile.content || '', entry, entryFile.mime).filter(
|
||
|
|
(r) => !visited.has(r),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
outer: for (let depth = 1; depth < MAX_DEPTH && frontier.length > 0; depth++) {
|
||
|
|
const next = [];
|
||
|
|
for (const refPath of frontier) {
|
||
|
|
if (visited.has(refPath)) continue;
|
||
|
|
visited.add(refPath);
|
||
|
|
if (fetched.length >= MAX_FILES || totalTextBytes(fetched) >= maxBytes) {
|
||
|
|
truncated = true;
|
||
|
|
break outer;
|
||
|
|
}
|
||
|
|
let file;
|
||
|
|
try {
|
||
|
|
const remaining = maxBytes - totalTextBytes(fetched);
|
||
|
|
file = await fetchProjectFile(baseUrl, id, refPath, remaining);
|
||
|
|
} catch (err) {
|
||
|
|
if (err instanceof BudgetExceededError) truncated = true;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
fetched.push(file);
|
||
|
|
if (!isTextualMime(file.mime)) continue;
|
||
|
|
const refs = extractRelativeRefs(file.content || '', refPath, file.mime);
|
||
|
|
for (const ref of refs) {
|
||
|
|
if (!visited.has(ref)) next.push(ref);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
frontier = next;
|
||
|
|
}
|
||
|
|
return okBundle({ project, entry, files: fetched, truncated, active, resolved });
|
||
|
|
}
|
||
|
|
|
||
|
|
// Thrown by fetchProjectFile when the server-advertised content-length exceeds
|
||
|
|
// the remaining byte budget. Distinguished from generic fetch errors (404,
|
||
|
|
// network) so callers can set truncated: true without treating it as a hard
|
||
|
|
// failure of the whole bundle.
|
||
|
|
class BudgetExceededError extends Error {}
|
||
|
|
|
||
|
|
async function fetchProjectFile(baseUrl, projectId, relPath, remainingBytes = Infinity) {
|
||
|
|
const segments = String(relPath)
|
||
|
|
.split('/')
|
||
|
|
.filter((s) => s.length > 0)
|
||
|
|
.map(encodeURIComponent);
|
||
|
|
const url = `${baseUrl}/api/projects/${encodeURIComponent(projectId)}/raw/${segments.join('/')}`;
|
||
|
|
const resp = await fetch(url);
|
||
|
|
if (!resp.ok) {
|
||
|
|
const body = await safeText(resp);
|
||
|
|
throw new Error(`daemon ${resp.status} on ${url}: ${body || resp.statusText}`);
|
||
|
|
}
|
||
|
|
const mime = (resp.headers.get('content-type') || 'application/octet-stream')
|
||
|
|
.split(';')[0]
|
||
|
|
.trim();
|
||
|
|
const headerSize = Number(resp.headers.get('content-length'));
|
||
|
|
const size = Number.isFinite(headerSize) && headerSize >= 0 ? headerSize : null;
|
||
|
|
if (!isTextualMime(mime)) {
|
||
|
|
return { name: relPath, mime, size, content: null, binary: true };
|
||
|
|
}
|
||
|
|
// If the server advertises a size that already exceeds our remaining
|
||
|
|
// budget, skip reading the body to avoid a large allocation.
|
||
|
|
if (size !== null && size > remainingBytes) {
|
||
|
|
throw new BudgetExceededError(`file ${relPath} (${size} bytes) exceeds remaining budget`);
|
||
|
|
}
|
||
|
|
const content = await resp.text();
|
||
|
|
return { name: relPath, mime, size: size ?? content.length, content, binary: false };
|
||
|
|
}
|
||
|
|
|
||
|
|
// Patterns common to HTML and CSS (also fine to run on plain markdown).
|
||
|
|
const HTML_REF_PATTERNS = [
|
||
|
|
/<script\b[^>]*\bsrc=["']([^"']+)["']/gi,
|
||
|
|
/<link\b[^>]*\bhref=["']([^"']+)["']/gi,
|
||
|
|
/<img\b[^>]*\bsrc=["']([^"']+)["']/gi,
|
||
|
|
/<source\b[^>]*\bsrc=["']([^"']+)["']/gi,
|
||
|
|
/<video\b[^>]*\bsrc=["']([^"']+)["']/gi,
|
||
|
|
/<audio\b[^>]*\bsrc=["']([^"']+)["']/gi,
|
||
|
|
/<iframe\b[^>]*\bsrc=["']([^"']+)["']/gi,
|
||
|
|
];
|
||
|
|
|
||
|
|
const CSS_REF_PATTERNS = [
|
||
|
|
/\burl\(\s*["']?([^"')]+)["']?\s*\)/gi,
|
||
|
|
/@import\s+(?:url\()?\s*["']([^"')]+)["']/gi,
|
||
|
|
];
|
||
|
|
|
||
|
|
// JS/TS only - running these on prose creates false positives on words
|
||
|
|
// like "imported from 'X'".
|
||
|
|
const JS_REF_PATTERNS = [
|
||
|
|
/\bimport\s+[^'"]*?['"]([^'"]+)['"]/g,
|
||
|
|
/\bfrom\s+['"]([^'"]+)['"]/g,
|
||
|
|
/\bimport\(\s*['"]([^'"]+)['"]\s*\)/g,
|
||
|
|
/\brequire\(\s*['"]([^'"]+)['"]\s*\)/g,
|
||
|
|
];
|
||
|
|
|
||
|
|
// `srcset` can list multiple comma-separated candidates.
|
||
|
|
const SRCSET_PATTERN = /\bsrcset=["']([^"']+)["']/gi;
|
||
|
|
|
||
|
|
function isJsLike(mime, fromPath) {
|
||
|
|
if (mime && /javascript|typescript/i.test(mime)) return true;
|
||
|
|
return /\.(?:m?jsx?|tsx?|cjs)$/i.test(fromPath);
|
||
|
|
}
|
||
|
|
|
||
|
|
function isCssLike(mime, fromPath) {
|
||
|
|
if (mime && /^text\/css\b/i.test(mime)) return true;
|
||
|
|
return /\.css$/i.test(fromPath);
|
||
|
|
}
|
||
|
|
|
||
|
|
function isHtmlLike(mime, fromPath) {
|
||
|
|
if (mime && /^text\/html\b/i.test(mime)) return true;
|
||
|
|
return /\.html?$/i.test(fromPath);
|
||
|
|
}
|
||
|
|
|
||
|
|
function extractRelativeRefs(text, fromPath, fromMime) {
|
||
|
|
if (!text) return [];
|
||
|
|
const refs = new Set();
|
||
|
|
const runPatterns = [];
|
||
|
|
if (isHtmlLike(fromMime, fromPath)) {
|
||
|
|
runPatterns.push(...HTML_REF_PATTERNS, ...CSS_REF_PATTERNS);
|
||
|
|
}
|
||
|
|
if (isCssLike(fromMime, fromPath)) {
|
||
|
|
runPatterns.push(...CSS_REF_PATTERNS);
|
||
|
|
}
|
||
|
|
if (isJsLike(fromMime, fromPath)) {
|
||
|
|
runPatterns.push(...JS_REF_PATTERNS);
|
||
|
|
}
|
||
|
|
// Fallback for unknown textual files: only the safest pattern,
|
||
|
|
// url() in case it's a CSS-in-something we don't recognize.
|
||
|
|
if (runPatterns.length === 0) {
|
||
|
|
runPatterns.push(...CSS_REF_PATTERNS);
|
||
|
|
}
|
||
|
|
|
||
|
|
const candidates = [];
|
||
|
|
for (const re of runPatterns) {
|
||
|
|
for (const m of text.matchAll(re)) {
|
||
|
|
const ref = (m[1] || '').trim();
|
||
|
|
if (ref) candidates.push(ref);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// Pull every candidate URL out of any srcset attributes in HTML.
|
||
|
|
if (isHtmlLike(fromMime, fromPath)) {
|
||
|
|
for (const m of text.matchAll(SRCSET_PATTERN)) {
|
||
|
|
const list = m[1] || '';
|
||
|
|
for (const part of list.split(',')) {
|
||
|
|
const url = part.trim().split(/\s+/)[0];
|
||
|
|
if (url) candidates.push(url);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
for (const raw of candidates) {
|
||
|
|
if (/^(?:https?:|\/\/|data:|mailto:|tel:|#)/i.test(raw)) continue;
|
||
|
|
const dir = fromPath.includes('/')
|
||
|
|
? fromPath.slice(0, fromPath.lastIndexOf('/') + 1)
|
||
|
|
: '';
|
||
|
|
const resolved = raw.startsWith('/') ? raw.slice(1) : dir + raw;
|
||
|
|
const stripped = resolved.replace(/[?#].*$/, '');
|
||
|
|
const segs = stripped.split('/').filter(Boolean);
|
||
|
|
const out: string[] = [];
|
||
|
|
let escaped = false;
|
||
|
|
for (const s of segs) {
|
||
|
|
if (s === '.') continue;
|
||
|
|
if (s === '..') {
|
||
|
|
if (out.length === 0) { escaped = true; break; }
|
||
|
|
out.pop();
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
out.push(s);
|
||
|
|
}
|
||
|
|
if (escaped || out.length === 0) continue;
|
||
|
|
refs.add(out.join('/'));
|
||
|
|
}
|
||
|
|
return [...refs];
|
||
|
|
}
|
||
|
|
|
||
|
|
function okBundle(bundle) {
|
||
|
|
const payload = {
|
||
|
|
entryFile: bundle.entry,
|
||
|
|
projectId: bundle.project?.id,
|
||
|
|
projectName: bundle.project?.name,
|
||
|
|
truncated: bundle.truncated === true,
|
||
|
|
files: bundle.files.map((f) => ({
|
||
|
|
name: f.name,
|
||
|
|
mime: f.mime,
|
||
|
|
size: f.size,
|
||
|
|
binary: f.binary === true,
|
||
|
|
content: f.binary ? null : f.content,
|
||
|
|
})),
|
||
|
|
manifest: bundle.project?.metadata ?? null,
|
||
|
|
};
|
||
|
|
return ok(withActiveEcho(payload, bundle.active, bundle.resolved));
|
||
|
|
}
|
||
|
|
|
||
|
|
function isTextualMime(mime) {
|
||
|
|
if (!mime) return false;
|
||
|
|
return TEXTUAL_MIME_PATTERNS.some((re) => re.test(mime));
|
||
|
|
}
|
||
|
|
|
||
|
|
async function safeText(resp) {
|
||
|
|
try {
|
||
|
|
return await resp.text();
|
||
|
|
} catch {
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatError(err, daemonUrl) {
|
||
|
|
const code = err && (err.cause?.code || err.code);
|
||
|
|
const msg = err && err.message ? err.message : String(err);
|
||
|
|
if (code === 'ECONNREFUSED' || code === 'ENOTFOUND') {
|
||
|
|
return `cannot reach the Open Design daemon at ${daemonUrl}. Is it running? Start it with \`pnpm tools-dev\`.`;
|
||
|
|
}
|
||
|
|
return msg;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Exported for unit tests only.
|
||
|
|
export { extractRelativeRefs, resolveProjectId, resolveProjectArg, withActiveEcho, fetchProjectFile, getArtifact, getFile };
|