Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 71 additions & 4 deletions apps/web/src/components/UseEverywhereModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ import { useT } from '../i18n';
import { modalOverlay, modalContent } from '../motion';
import type { Dict } from '../i18n/types';
import {
agentGuideSnippetUsesMcpInstallInfo,
buildAgentGuideMarkdown,
renderAgentGuideSnippetBody,
type AgentGuideMcpInstallInfo,
type AgentGuideOptions,
} from './use-everywhere/agent-guide';
import {
Expand Down Expand Up @@ -147,14 +150,43 @@ export function UseEverywhereGuidePanel({
const [activeId, setActiveId] = useState<GuideSection['id']>('overview');
const [guideCopy, setGuideCopy] = useState<CopyState>('idle');
const [snippetCopy, setSnippetCopy] = useState<{ key: string; state: CopyState } | null>(null);
const [mcpInstallInfo, setMcpInstallInfo] = useState<AgentGuideMcpInstallInfo | null>(null);
const mcpInstallInfoRequestRef = useRef<Promise<AgentGuideMcpInstallInfo | null> | null>(null);
const guideSections = useMemo(() => localizeGuideSections(t), [t]);

function loadMcpInstallInfo(): Promise<AgentGuideMcpInstallInfo | null> {
if (!mcpInstallInfoRequestRef.current) {
mcpInstallInfoRequestRef.current = fetch('/api/mcp/install-info')
.then(async (res) => {
if (!res.ok) throw new Error(`daemon ${res.status}`);
const data = (await res.json()) as unknown;
if (isAgentGuideMcpInstallInfo(data)) return data;
return null;
})
.catch(() => null);
}
return mcpInstallInfoRequestRef.current;
}

useEffect(() => {
Comment thread
YOMXXX marked this conversation as resolved.
let cancelled = false;
loadMcpInstallInfo()
.then((data) => {
if (cancelled) return;
setMcpInstallInfo(data);
});
return () => {
cancelled = true;
};
}, []);

const guideOptions: AgentGuideOptions = useMemo(() => {
const opts: AgentGuideOptions = {};
if (daemonUrl) opts.daemonUrl = daemonUrl;
if (versionHint) opts.versionHint = versionHint;
if (mcpInstallInfo) opts.mcpInstallInfo = mcpInstallInfo;
return opts;
}, [daemonUrl, versionHint]);
}, [daemonUrl, mcpInstallInfo, versionHint]);

const fullGuide = useMemo(
() => buildAgentGuideMarkdown(guideOptions),
Expand All @@ -177,7 +209,14 @@ export function UseEverywhereGuidePanel({
}, [activeId, guideSections]);

async function onCopyGuide() {
const state = await copyText(fullGuide);
const installInfo = mcpInstallInfo ?? await loadMcpInstallInfo();
Comment thread
YOMXXX marked this conversation as resolved.
if (installInfo && installInfo !== mcpInstallInfo) {
setMcpInstallInfo(installInfo);
}
const guide = installInfo
? buildAgentGuideMarkdown({ ...guideOptions, mcpInstallInfo: installInfo })
: fullGuide;
const state = await copyText(guide);
setGuideCopy(state);
if (state !== 'idle') {
window.setTimeout(() => setGuideCopy('idle'), COPY_RESET_MS);
Expand All @@ -190,7 +229,17 @@ export function UseEverywhereGuidePanel({
area: 'use_everywhere_tab',
element: 'copy',
});
const text = applyDaemonUrl(snippet.body, daemonUrl);
let installInfo = mcpInstallInfo;
if (agentGuideSnippetUsesMcpInstallInfo(snippet) && !installInfo) {
installInfo = await loadMcpInstallInfo();
if (installInfo && installInfo !== mcpInstallInfo) {
setMcpInstallInfo(installInfo);
}
}
const text = renderAgentGuideSnippetBody(snippet, {
daemonUrl,
mcpInstallInfo: installInfo,
});
const state = await copyText(text);
setSnippetCopy({ key, state });
if (state !== 'idle') {
Expand Down Expand Up @@ -230,6 +279,7 @@ export function UseEverywhereGuidePanel({
<SectionView
section={activeSection}
daemonUrl={daemonUrl}
mcpInstallInfo={mcpInstallInfo}
snippetCopy={snippetCopy}
onCopySnippet={onCopySnippet}
/>
Expand Down Expand Up @@ -283,6 +333,16 @@ export function UseEverywhereGuidePanel({
);
}

function isAgentGuideMcpInstallInfo(data: unknown): data is AgentGuideMcpInstallInfo {
if (!data || typeof data !== 'object') return false;
const candidate = data as Partial<AgentGuideMcpInstallInfo>;
return (
typeof candidate.command === 'string' &&
Array.isArray(candidate.args) &&
candidate.args.every((arg) => typeof arg === 'string')
);
}

async function copyText(text: string): Promise<CopyState> {
if (typeof navigator === 'undefined' || !navigator.clipboard) {
return 'failed';
Expand All @@ -298,13 +358,15 @@ async function copyText(text: string): Promise<CopyState> {
interface SectionViewProps {
section: GuideSection;
daemonUrl: string | undefined;
mcpInstallInfo: AgentGuideMcpInstallInfo | null;
snippetCopy: { key: string; state: CopyState } | null;
onCopySnippet: (key: string, snippet: CodeSnippet) => void;
}

function SectionView({
section,
daemonUrl,
mcpInstallInfo,
snippetCopy,
onCopySnippet,
}: SectionViewProps) {
Expand Down Expand Up @@ -356,7 +418,12 @@ function SectionView({
className="use-everywhere-snippet__pre"
data-language={snippet.language}
>
<code>{applyDaemonUrl(snippet.body, daemonUrl)}</code>
<code>
{renderAgentGuideSnippetBody(snippet, {
daemonUrl,
mcpInstallInfo,
})}
</code>
</pre>
</div>
);
Expand Down
125 changes: 115 additions & 10 deletions apps/web/src/components/use-everywhere/agent-guide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ import { GUIDE_SECTIONS, type CodeSnippet, type GuideSection } from './sections'
export interface AgentGuideOptions {
/** Live daemon URL detected at modal-open time. Defaults to the documented port. */
daemonUrl?: string;
/**
* Launch spec returned by `/api/mcp/install-info`. When present, MCP
* snippets use this absolute command/args/env tuple instead of assuming
* an `od` binary exists on PATH.
*/
mcpInstallInfo?: AgentGuideMcpInstallInfo | null;
/**
* Optional `od` binary path / hint. When provided we mention it in the
* setup checklist so the agent knows whether to run `od …` directly or
Expand All @@ -24,10 +30,22 @@ export interface AgentGuideOptions {
versionHint?: string;
}

export interface AgentGuideMcpInstallInfo {
command: string;
args: string[];
env?: Record<string, string>;
}

export interface AgentGuideSnippetRenderOptions {
daemonUrl: string | undefined;
mcpInstallInfo: AgentGuideMcpInstallInfo | null;
}

const DEFAULT_DAEMON_URL = 'http://127.0.0.1:7456';

export function buildAgentGuideMarkdown(options: AgentGuideOptions = {}): string {
const daemonUrl = (options.daemonUrl ?? DEFAULT_DAEMON_URL).replace(/\/$/, '');
const installInfo = normalizeMcpInstallInfo(options.mcpInstallInfo);
const lines: string[] = [];

lines.push('# Open Design — agent setup guide');
Expand Down Expand Up @@ -56,12 +74,23 @@ export function buildAgentGuideMarkdown(options: AgentGuideOptions = {}): string
lines.push('');
lines.push(' If it 404s or times out, ask the user to run `pnpm tools-dev` (dev) or open the Open Design app (packaged).');
lines.push('');
lines.push('2. Detect available agent CLIs and confirm `od` is on PATH:');
if (installInfo) {
lines.push('2. Use this daemon-reported MCP server config. Do not replace it with a bare `od` command:');
lines.push('');
lines.push(' ```json');
lines.push(indent(buildMcpServerConfigSnippet(installInfo), ' '));
lines.push(' ```');
lines.push('');
lines.push(` This config came from \`${daemonUrl}/api/mcp/install-info\` and preserves the absolute command, args, and env needed by packaged installs.`);
} else {
lines.push('2. Detect available agent CLIs and confirm `od` is on PATH:');
lines.push('');
lines.push(' ```bash');
lines.push(' od doctor');
lines.push(' od status --json');
lines.push(' ```');
}
lines.push('');
lines.push(' ```bash');
lines.push(' od doctor');
lines.push(' od status --json');
lines.push(' ```');
if (options.cliHint) {
lines.push('');
lines.push(` The user reported \`od\` at: \`${options.cliHint}\``);
Expand All @@ -88,7 +117,7 @@ export function buildAgentGuideMarkdown(options: AgentGuideOptions = {}): string
lines.push('');

for (const section of GUIDE_SECTIONS) {
lines.push(...renderSection(section, daemonUrl));
lines.push(...renderSection(section, daemonUrl, installInfo));
}

lines.push('## Reference URLs');
Expand All @@ -109,7 +138,31 @@ export function buildAgentGuideMarkdown(options: AgentGuideOptions = {}): string
return lines.join('\n');
}

function renderSection(section: GuideSection, daemonUrl: string): string[] {
export function renderAgentGuideSnippetBody(
snippet: CodeSnippet,
options: AgentGuideSnippetRenderOptions,
): string {
const daemonUrl = (options.daemonUrl ?? DEFAULT_DAEMON_URL).replace(/\/$/, '');
return renderSnippetBody(
snippet,
daemonUrl,
normalizeMcpInstallInfo(options.mcpInstallInfo),
);
}

export function agentGuideSnippetUsesMcpInstallInfo(snippet: CodeSnippet): boolean {
return (
snippet.language === 'json' &&
snippet.body.includes('"mcpServers"') &&
snippet.body.includes('"command": "od"')
);
}

function renderSection(
section: GuideSection,
daemonUrl: string,
installInfo: AgentGuideMcpInstallInfo | null,
): string[] {
const lines: string[] = [];
lines.push(`## ${substituteDaemonUrl(section.heading, daemonUrl)}`);
lines.push('');
Expand All @@ -122,7 +175,7 @@ function renderSection(section: GuideSection, daemonUrl: string): string[] {
lines.push('');
}
for (const snippet of section.snippets) {
lines.push(...renderSnippet(snippet, daemonUrl));
lines.push(...renderSnippet(snippet, daemonUrl, installInfo));
}
if (section.footer) {
lines.push(`> ${substituteDaemonUrl(section.footer, daemonUrl)}`);
Expand All @@ -131,17 +184,69 @@ function renderSection(section: GuideSection, daemonUrl: string): string[] {
return lines;
}

function renderSnippet(snippet: CodeSnippet, daemonUrl: string): string[] {
function renderSnippet(
snippet: CodeSnippet,
daemonUrl: string,
installInfo: AgentGuideMcpInstallInfo | null,
): string[] {
const lines: string[] = [];
lines.push(`### ${substituteDaemonUrl(snippet.label, daemonUrl)}`);
lines.push('');
lines.push('```' + snippet.language);
lines.push(substituteDaemonUrl(snippet.body, daemonUrl));
lines.push(renderSnippetBody(snippet, daemonUrl, installInfo));
lines.push('```');
lines.push('');
return lines;
}

function renderSnippetBody(
snippet: CodeSnippet,
daemonUrl: string,
installInfo: AgentGuideMcpInstallInfo | null,
): string {
if (installInfo && agentGuideSnippetUsesMcpInstallInfo(snippet)) {
return buildMcpServerConfigSnippet(installInfo);
}
return substituteDaemonUrl(snippet.body, daemonUrl);
}

function substituteDaemonUrl(body: string, daemonUrl: string): string {
return body.replace(/http:\/\/127\.0\.0\.1:7456/g, daemonUrl);
}

function buildMcpServerConfigSnippet(info: AgentGuideMcpInstallInfo): string {
const env = info.env && Object.keys(info.env).length > 0 ? info.env : undefined;
return JSON.stringify(
{
mcpServers: {
'open-design': {
command: info.command,
args: info.args,
...(env ? { env } : {}),
},
},
},
null,
2,
);
}

function normalizeMcpInstallInfo(
info: AgentGuideMcpInstallInfo | null | undefined,
): AgentGuideMcpInstallInfo | null {
if (!info || typeof info.command !== 'string' || info.command.length === 0) return null;
if (!Array.isArray(info.args) || !info.args.every((arg) => typeof arg === 'string')) return null;
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(info.env ?? {})) {
if (typeof value === 'string') env[key] = value;
}
return {
command: info.command,
args: info.args,
...(Object.keys(env).length > 0 ? { env } : {}),
};
}

function indent(body: string, prefix: string): string {
return body.split('\n').map((line) => `${prefix}${line}`).join('\n');
}
47 changes: 47 additions & 0 deletions apps/web/tests/components/use-everywhere-agent-guide.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,51 @@ describe('buildAgentGuideMarkdown', () => {
expect(md).toContain('- Daemon: `http://example.test:5555`');
expect(md).toContain('- MCP install info: `http://example.test:5555/api/mcp/install-info`');
});

it('uses daemon install-info for the MCP config instead of assuming od is on PATH', () => {
const md = buildAgentGuideMarkdown({
daemonUrl: 'http://127.0.0.1:7456',
mcpInstallInfo: {
command: 'C:\\Program Files\\Open Design\\Open Design.exe',
args: [
'C:\\Program Files\\Open Design\\resources\\app\\apps\\daemon\\dist\\cli.js',
'mcp',
],
env: {
ELECTRON_RUN_AS_NODE: '1',
OD_DATA_DIR: 'C:\\Users\\Ada\\AppData\\Roaming\\Open Design',
},
},
});

expect(md).toContain('"command": "C:\\\\Program Files\\\\Open Design\\\\Open Design.exe"');
expect(md).toContain(
'"C:\\\\Program Files\\\\Open Design\\\\resources\\\\app\\\\apps\\\\daemon\\\\dist\\\\cli.js"',
);
expect(md).toContain('"ELECTRON_RUN_AS_NODE": "1"');
expect(md).toContain('"OD_DATA_DIR": "C:\\\\Users\\\\Ada\\\\AppData\\\\Roaming\\\\Open Design"');
expect(md).not.toContain('"command": "od"');
});

it('does not rewrite CLI snippets with POSIX env prefixes for Windows packaged installs', () => {
const md = buildAgentGuideMarkdown({
daemonUrl: 'http://127.0.0.1:7456',
mcpInstallInfo: {
command: 'C:\\Program Files\\Open Design\\Open Design.exe',
args: [
'C:\\Program Files\\Open Design\\resources\\app\\apps\\daemon\\dist\\cli.js',
'mcp',
],
env: {
ELECTRON_RUN_AS_NODE: '1',
OD_DATA_DIR: 'C:\\Users\\Ada\\AppData\\Roaming\\Open Design',
},
},
});

expect(md).toContain('"command": "C:\\\\Program Files\\\\Open Design\\\\Open Design.exe"');
expect(md).toContain('"ELECTRON_RUN_AS_NODE": "1"');
expect(md).toContain('od skills list --json');
expect(md).not.toMatch(/^\s*ELECTRON_RUN_AS_NODE=1\s+OD_DATA_DIR=/m);
});
});
Loading
Loading