Skip to content

Commit 0dd3c7f

Browse files
committed
fix(web): use daemon MCP install info in agent guide
1 parent 23704a9 commit 0dd3c7f

4 files changed

Lines changed: 237 additions & 12 deletions

File tree

apps/web/src/components/UseEverywhereModal.tsx

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { modalOverlay, modalContent } from '../motion';
1818
import type { Dict } from '../i18n/types';
1919
import {
2020
buildAgentGuideMarkdown,
21+
type AgentGuideMcpInstallInfo,
2122
type AgentGuideOptions,
2223
} from './use-everywhere/agent-guide';
2324
import {
@@ -147,14 +148,41 @@ export function UseEverywhereGuidePanel({
147148
const [activeId, setActiveId] = useState<GuideSection['id']>('overview');
148149
const [guideCopy, setGuideCopy] = useState<CopyState>('idle');
149150
const [snippetCopy, setSnippetCopy] = useState<{ key: string; state: CopyState } | null>(null);
151+
const [mcpInstallInfo, setMcpInstallInfo] = useState<AgentGuideMcpInstallInfo | null>(null);
150152
const guideSections = useMemo(() => localizeGuideSections(t), [t]);
151153

154+
useEffect(() => {
155+
let cancelled = false;
156+
fetch('/api/mcp/install-info')
157+
.then(async (res) => {
158+
if (!res.ok) throw new Error(`daemon ${res.status}`);
159+
return (await res.json()) as AgentGuideMcpInstallInfo;
160+
})
161+
.then((data) => {
162+
if (cancelled) return;
163+
if (
164+
typeof data.command === 'string' &&
165+
Array.isArray(data.args) &&
166+
data.args.every((arg) => typeof arg === 'string')
167+
) {
168+
setMcpInstallInfo(data);
169+
}
170+
})
171+
.catch(() => {
172+
if (!cancelled) setMcpInstallInfo(null);
173+
});
174+
return () => {
175+
cancelled = true;
176+
};
177+
}, []);
178+
152179
const guideOptions: AgentGuideOptions = useMemo(() => {
153180
const opts: AgentGuideOptions = {};
154181
if (daemonUrl) opts.daemonUrl = daemonUrl;
155182
if (versionHint) opts.versionHint = versionHint;
183+
if (mcpInstallInfo) opts.mcpInstallInfo = mcpInstallInfo;
156184
return opts;
157-
}, [daemonUrl, versionHint]);
185+
}, [daemonUrl, mcpInstallInfo, versionHint]);
158186

159187
const fullGuide = useMemo(
160188
() => buildAgentGuideMarkdown(guideOptions),

apps/web/src/components/use-everywhere/agent-guide.ts

Lines changed: 121 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ import { GUIDE_SECTIONS, type CodeSnippet, type GuideSection } from './sections'
1414
export interface AgentGuideOptions {
1515
/** Live daemon URL detected at modal-open time. Defaults to the documented port. */
1616
daemonUrl?: string;
17+
/**
18+
* Launch spec returned by `/api/mcp/install-info`. When present, MCP
19+
* snippets use this absolute command/args/env tuple instead of assuming
20+
* an `od` binary exists on PATH.
21+
*/
22+
mcpInstallInfo?: AgentGuideMcpInstallInfo | null;
1723
/**
1824
* Optional `od` binary path / hint. When provided we mention it in the
1925
* setup checklist so the agent knows whether to run `od …` directly or
@@ -24,10 +30,18 @@ export interface AgentGuideOptions {
2430
versionHint?: string;
2531
}
2632

33+
export interface AgentGuideMcpInstallInfo {
34+
command: string;
35+
args: string[];
36+
env?: Record<string, string>;
37+
}
38+
2739
const DEFAULT_DAEMON_URL = 'http://127.0.0.1:7456';
2840

2941
export function buildAgentGuideMarkdown(options: AgentGuideOptions = {}): string {
3042
const daemonUrl = (options.daemonUrl ?? DEFAULT_DAEMON_URL).replace(/\/$/, '');
43+
const installInfo = normalizeMcpInstallInfo(options.mcpInstallInfo);
44+
const cliCommand = installInfo ? renderCliCommandPrefix(installInfo) : null;
3145
const lines: string[] = [];
3246

3347
lines.push('# Open Design — agent setup guide');
@@ -56,12 +70,23 @@ export function buildAgentGuideMarkdown(options: AgentGuideOptions = {}): string
5670
lines.push('');
5771
lines.push(' If it 404s or times out, ask the user to run `pnpm tools-dev` (dev) or open the Open Design app (packaged).');
5872
lines.push('');
59-
lines.push('2. Detect available agent CLIs and confirm `od` is on PATH:');
73+
if (installInfo) {
74+
lines.push('2. Use this daemon-reported MCP server config. Do not replace it with a bare `od` command:');
75+
lines.push('');
76+
lines.push(' ```json');
77+
lines.push(indent(buildMcpServerConfigSnippet(installInfo), ' '));
78+
lines.push(' ```');
79+
lines.push('');
80+
lines.push(` This config came from \`${daemonUrl}/api/mcp/install-info\` and preserves the absolute command, args, and env needed by packaged installs.`);
81+
} else {
82+
lines.push('2. Detect available agent CLIs and confirm `od` is on PATH:');
83+
lines.push('');
84+
lines.push(' ```bash');
85+
lines.push(' od doctor');
86+
lines.push(' od status --json');
87+
lines.push(' ```');
88+
}
6089
lines.push('');
61-
lines.push(' ```bash');
62-
lines.push(' od doctor');
63-
lines.push(' od status --json');
64-
lines.push(' ```');
6590
if (options.cliHint) {
6691
lines.push('');
6792
lines.push(` The user reported \`od\` at: \`${options.cliHint}\``);
@@ -83,12 +108,12 @@ export function buildAgentGuideMarkdown(options: AgentGuideOptions = {}): string
83108
lines.push('');
84109
lines.push(' ```bash');
85110
lines.push(` curl -s ${daemonUrl}/api/skills | jq '.skills | length'`);
86-
lines.push(' od skills list --json');
111+
lines.push(` ${cliCommand ?? 'od'} skills list --json`);
87112
lines.push(' ```');
88113
lines.push('');
89114

90115
for (const section of GUIDE_SECTIONS) {
91-
lines.push(...renderSection(section, daemonUrl));
116+
lines.push(...renderSection(section, daemonUrl, installInfo, cliCommand));
92117
}
93118

94119
lines.push('## Reference URLs');
@@ -109,7 +134,12 @@ export function buildAgentGuideMarkdown(options: AgentGuideOptions = {}): string
109134
return lines.join('\n');
110135
}
111136

112-
function renderSection(section: GuideSection, daemonUrl: string): string[] {
137+
function renderSection(
138+
section: GuideSection,
139+
daemonUrl: string,
140+
installInfo: AgentGuideMcpInstallInfo | null,
141+
cliCommand: string | null,
142+
): string[] {
113143
const lines: string[] = [];
114144
lines.push(`## ${substituteDaemonUrl(section.heading, daemonUrl)}`);
115145
lines.push('');
@@ -122,7 +152,7 @@ function renderSection(section: GuideSection, daemonUrl: string): string[] {
122152
lines.push('');
123153
}
124154
for (const snippet of section.snippets) {
125-
lines.push(...renderSnippet(snippet, daemonUrl));
155+
lines.push(...renderSnippet(snippet, daemonUrl, installInfo, cliCommand));
126156
}
127157
if (section.footer) {
128158
lines.push(`> ${substituteDaemonUrl(section.footer, daemonUrl)}`);
@@ -131,17 +161,97 @@ function renderSection(section: GuideSection, daemonUrl: string): string[] {
131161
return lines;
132162
}
133163

134-
function renderSnippet(snippet: CodeSnippet, daemonUrl: string): string[] {
164+
function renderSnippet(
165+
snippet: CodeSnippet,
166+
daemonUrl: string,
167+
installInfo: AgentGuideMcpInstallInfo | null,
168+
cliCommand: string | null,
169+
): string[] {
135170
const lines: string[] = [];
136171
lines.push(`### ${substituteDaemonUrl(snippet.label, daemonUrl)}`);
137172
lines.push('');
138173
lines.push('```' + snippet.language);
139-
lines.push(substituteDaemonUrl(snippet.body, daemonUrl));
174+
lines.push(renderSnippetBody(snippet, daemonUrl, installInfo, cliCommand));
140175
lines.push('```');
141176
lines.push('');
142177
return lines;
143178
}
144179

180+
function renderSnippetBody(
181+
snippet: CodeSnippet,
182+
daemonUrl: string,
183+
installInfo: AgentGuideMcpInstallInfo | null,
184+
cliCommand: string | null,
185+
): string {
186+
if (
187+
installInfo &&
188+
snippet.language === 'json' &&
189+
snippet.body.includes('"mcpServers"') &&
190+
snippet.body.includes('"command": "od"')
191+
) {
192+
return buildMcpServerConfigSnippet(installInfo);
193+
}
194+
const withUrl = substituteDaemonUrl(snippet.body, daemonUrl);
195+
if (!cliCommand) return withUrl;
196+
return withUrl
197+
.replace(/^od(?=\s)/gm, cliCommand)
198+
.replace(/\$\(od(?=\s)/g, `$(${cliCommand}`);
199+
}
200+
145201
function substituteDaemonUrl(body: string, daemonUrl: string): string {
146202
return body.replace(/http:\/\/127\.0\.0\.1:7456/g, daemonUrl);
147203
}
204+
205+
function buildMcpServerConfigSnippet(info: AgentGuideMcpInstallInfo): string {
206+
const env = info.env && Object.keys(info.env).length > 0 ? info.env : undefined;
207+
return JSON.stringify(
208+
{
209+
mcpServers: {
210+
'open-design': {
211+
command: info.command,
212+
args: info.args,
213+
...(env ? { env } : {}),
214+
},
215+
},
216+
},
217+
null,
218+
2,
219+
);
220+
}
221+
222+
function normalizeMcpInstallInfo(
223+
info: AgentGuideMcpInstallInfo | null | undefined,
224+
): AgentGuideMcpInstallInfo | null {
225+
if (!info || typeof info.command !== 'string' || info.command.length === 0) return null;
226+
if (!Array.isArray(info.args) || !info.args.every((arg) => typeof arg === 'string')) return null;
227+
const env: Record<string, string> = {};
228+
for (const [key, value] of Object.entries(info.env ?? {})) {
229+
if (typeof value === 'string') env[key] = value;
230+
}
231+
return {
232+
command: info.command,
233+
args: info.args,
234+
...(Object.keys(env).length > 0 ? { env } : {}),
235+
};
236+
}
237+
238+
function renderCliCommandPrefix(info: AgentGuideMcpInstallInfo): string {
239+
const mcpArgIndex = info.args.indexOf('mcp');
240+
const cliArgs = mcpArgIndex >= 0 ? info.args.slice(0, mcpArgIndex) : info.args;
241+
const envPrefix = Object.entries(info.env ?? {})
242+
.filter(([key]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
243+
.map(([key, value]) => `${key}=${shellQuote(value)}`);
244+
return [...envPrefix, info.command, ...cliArgs].map((part, index) => {
245+
if (index < envPrefix.length) return part;
246+
return shellQuote(part);
247+
}).join(' ');
248+
}
249+
250+
function shellQuote(value: string): string {
251+
if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value)) return value;
252+
return `"${value.replace(/(["$`])/g, '\\$1')}"`;
253+
}
254+
255+
function indent(body: string, prefix: string): string {
256+
return body.split('\n').map((line) => `${prefix}${line}`).join('\n');
257+
}

apps/web/tests/components/use-everywhere-agent-guide.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,4 +74,29 @@ describe('buildAgentGuideMarkdown', () => {
7474
expect(md).toContain('- Daemon: `http://example.test:5555`');
7575
expect(md).toContain('- MCP install info: `http://example.test:5555/api/mcp/install-info`');
7676
});
77+
78+
it('uses daemon install-info for the MCP config instead of assuming od is on PATH', () => {
79+
const md = buildAgentGuideMarkdown({
80+
daemonUrl: 'http://127.0.0.1:7456',
81+
mcpInstallInfo: {
82+
command: 'C:\\Program Files\\Open Design\\Open Design.exe',
83+
args: [
84+
'C:\\Program Files\\Open Design\\resources\\app\\apps\\daemon\\dist\\cli.js',
85+
'mcp',
86+
],
87+
env: {
88+
ELECTRON_RUN_AS_NODE: '1',
89+
OD_DATA_DIR: 'C:\\Users\\Ada\\AppData\\Roaming\\Open Design',
90+
},
91+
},
92+
});
93+
94+
expect(md).toContain('"command": "C:\\\\Program Files\\\\Open Design\\\\Open Design.exe"');
95+
expect(md).toContain(
96+
'"C:\\\\Program Files\\\\Open Design\\\\resources\\\\app\\\\apps\\\\daemon\\\\dist\\\\cli.js"',
97+
);
98+
expect(md).toContain('"ELECTRON_RUN_AS_NODE": "1"');
99+
expect(md).toContain('"OD_DATA_DIR": "C:\\\\Users\\\\Ada\\\\AppData\\\\Roaming\\\\Open Design"');
100+
expect(md).not.toContain('"command": "od"');
101+
});
77102
});
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// @vitest-environment jsdom
2+
3+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
4+
import { afterEach, describe, expect, it, vi } from 'vitest';
5+
6+
import { UseEverywhereGuidePanel } from '../../src/components/UseEverywhereModal';
7+
8+
const originalFetch = globalThis.fetch;
9+
const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
10+
11+
describe('UseEverywhereGuidePanel copy guide', () => {
12+
afterEach(() => {
13+
globalThis.fetch = originalFetch;
14+
if (originalClipboard) {
15+
Object.defineProperty(navigator, 'clipboard', originalClipboard);
16+
}
17+
vi.restoreAllMocks();
18+
});
19+
20+
it('copies the daemon install-info launch spec into the agent guide', async () => {
21+
const writeText = vi.fn().mockResolvedValue(undefined);
22+
Object.defineProperty(navigator, 'clipboard', {
23+
configurable: true,
24+
value: { writeText },
25+
});
26+
globalThis.fetch = vi.fn().mockResolvedValue({
27+
ok: true,
28+
json: async () => ({
29+
command: 'C:\\Program Files\\Open Design\\Open Design.exe',
30+
args: [
31+
'C:\\Program Files\\Open Design\\resources\\app\\apps\\daemon\\dist\\cli.js',
32+
'mcp',
33+
],
34+
env: {
35+
ELECTRON_RUN_AS_NODE: '1',
36+
OD_DATA_DIR: 'C:\\Users\\Ada\\AppData\\Roaming\\Open Design',
37+
},
38+
daemonUrl: 'http://127.0.0.1:7456',
39+
platform: 'win32',
40+
cliExists: true,
41+
nodeExists: true,
42+
buildHint: null,
43+
}),
44+
} satisfies Partial<Response>) as typeof fetch;
45+
46+
render(<UseEverywhereGuidePanel daemonUrl="http://127.0.0.1:7456" />);
47+
48+
await waitFor(() =>
49+
expect(globalThis.fetch).toHaveBeenCalledWith('/api/mcp/install-info'),
50+
);
51+
fireEvent.click(screen.getByTestId('use-everywhere-copy-guide'));
52+
53+
await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1));
54+
const copied = writeText.mock.calls[0]?.[0] as string;
55+
expect(copied).toContain('"command": "C:\\\\Program Files\\\\Open Design\\\\Open Design.exe"');
56+
expect(copied).toContain(
57+
'"C:\\\\Program Files\\\\Open Design\\\\resources\\\\app\\\\apps\\\\daemon\\\\dist\\\\cli.js"',
58+
);
59+
expect(copied).toContain('"ELECTRON_RUN_AS_NODE": "1"');
60+
expect(copied).not.toContain('"command": "od"');
61+
});
62+
});

0 commit comments

Comments
 (0)