mcp-app-studio 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,579 @@
1
+ // src/cli/index.ts
2
+ import fs2 from "fs";
3
+ import { createWriteStream } from "fs";
4
+ import { Readable } from "stream";
5
+ import { pipeline } from "stream/promises";
6
+ import path2 from "path";
7
+ import os from "os";
8
+ import { fileURLToPath } from "url";
9
+ import * as p from "@clack/prompts";
10
+ import pc from "picocolors";
11
+ import { extract } from "tar";
12
+
13
+ // src/cli/utils.ts
14
+ import fs from "fs";
15
+ import path from "path";
16
+ function isValidProjectPath(name) {
17
+ if (!name || name.trim() === "") {
18
+ return { valid: false, error: "Project name is required" };
19
+ }
20
+ const trimmed = name.trim();
21
+ if (path.isAbsolute(trimmed)) {
22
+ return {
23
+ valid: false,
24
+ error: "Absolute paths are not allowed. Use a relative path or project name."
25
+ };
26
+ }
27
+ if (trimmed.includes("..")) {
28
+ return {
29
+ valid: false,
30
+ error: "Path traversal (..) is not allowed. Use a simple project name."
31
+ };
32
+ }
33
+ const cwd = process.cwd();
34
+ const resolved = path.resolve(cwd, trimmed);
35
+ const relative = path.relative(cwd, resolved);
36
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
37
+ return {
38
+ valid: false,
39
+ error: "Project must be created within current directory."
40
+ };
41
+ }
42
+ return { valid: true };
43
+ }
44
+ function isValidPackageName(name) {
45
+ return /^(?:@[a-z0-9-*~][a-z0-9-*._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(
46
+ name
47
+ );
48
+ }
49
+ function toValidPackageName(name) {
50
+ return name.trim().toLowerCase().replace(/\s+/g, "-").replace(/^[._]/, "").replace(/[^a-z0-9-~]+/g, "-");
51
+ }
52
+ function isEmpty(dir) {
53
+ if (!fs.existsSync(dir)) return true;
54
+ const files = fs.readdirSync(dir);
55
+ return files.length === 0 || files.length === 1 && files[0] === ".git";
56
+ }
57
+ function emptyDir(dir) {
58
+ if (!fs.existsSync(dir)) return;
59
+ for (const file of fs.readdirSync(dir)) {
60
+ if (file === ".git") continue;
61
+ fs.rmSync(path.join(dir, file), { recursive: true, force: true });
62
+ }
63
+ }
64
+ function updatePackageJson(dir, name, description) {
65
+ const pkgPath = path.join(dir, "package.json");
66
+ if (!fs.existsSync(pkgPath)) return;
67
+ try {
68
+ const content = fs.readFileSync(pkgPath, "utf-8");
69
+ const pkg = JSON.parse(content);
70
+ pkg["name"] = name;
71
+ pkg["version"] = "0.1.0";
72
+ pkg["private"] = true;
73
+ if (description) {
74
+ pkg["description"] = description;
75
+ }
76
+ fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}
77
+ `);
78
+ } catch (error) {
79
+ throw new Error(
80
+ `Failed to update package.json: ${error instanceof Error ? error.message : String(error)}`
81
+ );
82
+ }
83
+ }
84
+ function detectPackageManager() {
85
+ const ua = process.env["npm_config_user_agent"] ?? "";
86
+ if (ua.includes("pnpm")) return "pnpm";
87
+ if (ua.includes("yarn")) return "yarn";
88
+ if (ua.includes("bun")) return "bun";
89
+ return "npm";
90
+ }
91
+
92
+ // src/cli/index.ts
93
+ var __dirname = path2.dirname(fileURLToPath(import.meta.url));
94
+ var TEMPLATE_REPO = "assistant-ui/mcp-app-studio-starter";
95
+ var TEMPLATE_BRANCH = "main";
96
+ var REQUIRED_NODE_VERSION = { major: 20, minor: 9, patch: 0 };
97
+ function parseNodeVersion(version) {
98
+ const [majorRaw, minorRaw, patchRaw] = version.split(".");
99
+ const major = Number.parseInt(majorRaw ?? "", 10);
100
+ const minor = Number.parseInt(minorRaw ?? "", 10);
101
+ const patch = Number.parseInt(patchRaw ?? "", 10);
102
+ if (![major, minor, patch].every(Number.isFinite)) return null;
103
+ return { major, minor, patch };
104
+ }
105
+ function isVersionAtLeast(current, required) {
106
+ if (current.major !== required.major) return current.major > required.major;
107
+ if (current.minor !== required.minor) return current.minor > required.minor;
108
+ return current.patch >= required.patch;
109
+ }
110
+ function ensureSupportedNodeVersion() {
111
+ const current = parseNodeVersion(process.versions.node);
112
+ if (!current) return;
113
+ if (!isVersionAtLeast(current, REQUIRED_NODE_VERSION)) {
114
+ console.error(
115
+ pc.red(
116
+ `mcp-app-studio requires Node.js >=${REQUIRED_NODE_VERSION.major}.${REQUIRED_NODE_VERSION.minor}.${REQUIRED_NODE_VERSION.patch} (detected ${process.versions.node}).`
117
+ )
118
+ );
119
+ console.error(pc.dim("Please upgrade Node.js (recommended: latest LTS)."));
120
+ process.exit(1);
121
+ }
122
+ }
123
+ function getVersion() {
124
+ try {
125
+ const pkgPath = path2.resolve(__dirname, "../package.json");
126
+ const pkg = JSON.parse(fs2.readFileSync(pkgPath, "utf-8"));
127
+ return pkg.version || "0.0.0";
128
+ } catch {
129
+ return "0.0.0";
130
+ }
131
+ }
132
+ function showHelp() {
133
+ console.log(`
134
+ mcp-app-studio v${getVersion()}
135
+
136
+ Create interactive apps for ChatGPT and MCP hosts (like Claude Desktop).
137
+
138
+ ${pc.bold("Requirements:")}
139
+ Node.js >=${REQUIRED_NODE_VERSION.major}.${REQUIRED_NODE_VERSION.minor}.${REQUIRED_NODE_VERSION.patch}
140
+
141
+ ${pc.bold("Usage:")}
142
+ npx mcp-app-studio [project-name] [options]
143
+
144
+ ${pc.bold("Options:")}
145
+ --help, -h Show this help message
146
+ --version, -v Show version number
147
+
148
+ ${pc.bold("Examples:")}
149
+ npx mcp-app-studio my-app
150
+ npx mcp-app-studio . ${pc.dim("# Use current directory")}
151
+ npx mcp-app-studio
152
+
153
+ ${pc.bold("Learn more:")}
154
+ Documentation: https://github.com/assistant-ui/mcp-app-studio
155
+ Examples: https://github.com/assistant-ui/mcp-app-studio-starter
156
+ `);
157
+ }
158
+ function quotePath(p2) {
159
+ if (!/[ $`"'\\&|;<>(){}[\]*?!#~]/.test(p2)) {
160
+ return p2;
161
+ }
162
+ return `'${p2.replace(/'/g, "'\\''")}'`;
163
+ }
164
+ var TEMPLATE_COMPONENTS = {
165
+ minimal: ["welcome"],
166
+ "poi-map": ["poi-map"]
167
+ };
168
+ var TEMPLATE_EXPORT_CONFIG = {
169
+ minimal: {
170
+ entryPoint: "lib/workbench/wrappers/welcome-card-sdk.tsx",
171
+ exportName: "WelcomeCardSDK"
172
+ },
173
+ "poi-map": {
174
+ entryPoint: "lib/workbench/wrappers/poi-map-sdk.tsx",
175
+ exportName: "POIMapSDK"
176
+ }
177
+ };
178
+ function generateComponentRegistry(components) {
179
+ const imports = [];
180
+ const entries = [];
181
+ if (components.includes("welcome")) {
182
+ imports.push('import { WelcomeCardSDK } from "./wrappers";');
183
+ entries.push(` {
184
+ id: "welcome",
185
+ label: "Welcome",
186
+ description: "A simple starter widget - the perfect starting point",
187
+ category: "cards",
188
+ component: WelcomeCardSDK,
189
+ defaultProps: {
190
+ title: "Welcome!",
191
+ message:
192
+ "This is your ChatGPT App. Edit this component to build something amazing.",
193
+ },
194
+ exportConfig: {
195
+ entryPoint: "lib/workbench/wrappers/welcome-card-sdk.tsx",
196
+ exportName: "WelcomeCardSDK",
197
+ },
198
+ }`);
199
+ }
200
+ if (components.includes("poi-map")) {
201
+ imports.push('import { POIMapSDK } from "./wrappers";');
202
+ entries.push(` {
203
+ id: "poi-map",
204
+ label: "POI Map",
205
+ description:
206
+ "Interactive map with points of interest - demonstrates display mode transitions, widget state, and tool calls",
207
+ category: "data",
208
+ component: POIMapSDK,
209
+ defaultProps: {
210
+ id: "workbench-poi-map",
211
+ title: "San Francisco Highlights",
212
+ pois: [
213
+ {
214
+ id: "1",
215
+ name: "Golden Gate Bridge",
216
+ category: "landmark",
217
+ lat: 37.8199,
218
+ lng: -122.4783,
219
+ description: "Iconic suspension bridge spanning the Golden Gate strait",
220
+ rating: 4.8,
221
+ imageUrl: "https://images.unsplash.com/photo-1449034446853-66c86144b0ad?w=400",
222
+ },
223
+ {
224
+ id: "2",
225
+ name: "Fisherman's Wharf",
226
+ category: "entertainment",
227
+ lat: 37.808,
228
+ lng: -122.4177,
229
+ description: "Historic waterfront with restaurants and attractions",
230
+ rating: 4.3,
231
+ },
232
+ ],
233
+ initialCenter: { lat: 37.7749, lng: -122.4194 },
234
+ initialZoom: 12,
235
+ },
236
+ exportConfig: {
237
+ entryPoint: "lib/workbench/wrappers/poi-map-sdk.tsx",
238
+ exportName: "POIMapSDK",
239
+ },
240
+ }`);
241
+ }
242
+ return `"use client";
243
+
244
+ import type { ComponentType } from "react";
245
+ ${imports.join("\n")}
246
+
247
+ export type ComponentCategory = "cards" | "lists" | "forms" | "data";
248
+
249
+ type AnyComponent = ComponentType<any>;
250
+
251
+ export interface WorkbenchComponentEntry {
252
+ id: string;
253
+ label: string;
254
+ description: string;
255
+ category: ComponentCategory;
256
+ component: AnyComponent;
257
+ defaultProps: Record<string, unknown>;
258
+ exportConfig: {
259
+ entryPoint: string;
260
+ exportName: string;
261
+ };
262
+ }
263
+
264
+ export const workbenchComponents: WorkbenchComponentEntry[] = [
265
+ ${entries.join(",\n")}
266
+ ];
267
+
268
+ export function getComponent(id: string): WorkbenchComponentEntry | undefined {
269
+ return workbenchComponents.find((c) => c.id === id);
270
+ }
271
+
272
+ export function getComponentIds(): string[] {
273
+ return workbenchComponents.map((c) => c.id);
274
+ }
275
+ `;
276
+ }
277
+ function generateWrappersIndex(components) {
278
+ const exports = [];
279
+ if (components.includes("welcome")) {
280
+ exports.push('export { WelcomeCardSDK } from "./welcome-card-sdk";');
281
+ }
282
+ if (components.includes("poi-map")) {
283
+ exports.push('export { POIMapSDK } from "./poi-map-sdk";');
284
+ }
285
+ return exports.length > 0 ? `${exports.join("\n")}
286
+ ` : "// No components\n";
287
+ }
288
+ function generateExamplesIndex(components) {
289
+ const exports = [];
290
+ if (components.includes("welcome")) {
291
+ exports.push('export * from "./welcome-card";');
292
+ }
293
+ if (components.includes("poi-map")) {
294
+ exports.push('export * from "./poi-map";');
295
+ }
296
+ return exports.length > 0 ? `${exports.join("\n")}
297
+ ` : "// No examples\n";
298
+ }
299
+ function updateExportScriptDefaults(targetDir, entryPoint, exportName) {
300
+ const exportScriptPath = path2.join(targetDir, "scripts/export.ts");
301
+ let content = fs2.readFileSync(exportScriptPath, "utf-8");
302
+ content = content.replace(
303
+ /entryPoint: "lib\/workbench\/wrappers\/[^"]+"/,
304
+ `entryPoint: "${entryPoint}"`
305
+ );
306
+ content = content.replace(
307
+ /exportName: "[^"]+",\n\s+name:/,
308
+ `exportName: "${exportName}",
309
+ name:`
310
+ );
311
+ content = content.replace(
312
+ /Widget entry point \(default: [^)]+\)/,
313
+ `Widget entry point (default: ${entryPoint})`
314
+ );
315
+ content = content.replace(
316
+ /Export name from entry file \(default: [^)]+\)/,
317
+ `Export name from entry file (default: ${exportName})`
318
+ );
319
+ fs2.writeFileSync(exportScriptPath, content);
320
+ }
321
+ function applyTemplate(targetDir, template) {
322
+ const components = TEMPLATE_COMPONENTS[template];
323
+ const registryPath = path2.join(
324
+ targetDir,
325
+ "lib/workbench/component-registry.tsx"
326
+ );
327
+ fs2.writeFileSync(registryPath, generateComponentRegistry(components));
328
+ const wrappersIndexPath = path2.join(
329
+ targetDir,
330
+ "lib/workbench/wrappers/index.ts"
331
+ );
332
+ fs2.writeFileSync(wrappersIndexPath, generateWrappersIndex(components));
333
+ const examplesIndexPath = path2.join(
334
+ targetDir,
335
+ "components/examples/index.ts"
336
+ );
337
+ fs2.writeFileSync(examplesIndexPath, generateExamplesIndex(components));
338
+ const examplesDir = path2.join(targetDir, "components/examples");
339
+ if (!components.includes("welcome")) {
340
+ fs2.rmSync(path2.join(examplesDir, "welcome-card"), {
341
+ recursive: true,
342
+ force: true
343
+ });
344
+ fs2.rmSync(
345
+ path2.join(targetDir, "lib/workbench/wrappers/welcome-card-sdk.tsx"),
346
+ { force: true }
347
+ );
348
+ }
349
+ if (!components.includes("poi-map")) {
350
+ fs2.rmSync(path2.join(examplesDir, "poi-map"), {
351
+ recursive: true,
352
+ force: true
353
+ });
354
+ fs2.rmSync(path2.join(targetDir, "lib/workbench/wrappers/poi-map-sdk.tsx"), {
355
+ force: true
356
+ });
357
+ }
358
+ const exportConfig = TEMPLATE_EXPORT_CONFIG[template];
359
+ updateExportScriptDefaults(
360
+ targetDir,
361
+ exportConfig.entryPoint,
362
+ exportConfig.exportName
363
+ );
364
+ }
365
+ async function downloadTemplate(targetDir) {
366
+ const tarballUrl = `https://github.com/${TEMPLATE_REPO}/archive/refs/heads/${TEMPLATE_BRANCH}.tar.gz`;
367
+ const tempDir = path2.join(os.tmpdir(), `mcp-app-studio-${Date.now()}`);
368
+ const tarballPath = path2.join(tempDir, "template.tar.gz");
369
+ try {
370
+ fs2.mkdirSync(tempDir, { recursive: true });
371
+ const response = await fetch(tarballUrl);
372
+ if (!response.ok) {
373
+ throw new Error(
374
+ `Failed to download template: ${response.status} ${response.statusText}`
375
+ );
376
+ }
377
+ const fileStream = createWriteStream(tarballPath);
378
+ const body = response.body;
379
+ if (!body) {
380
+ throw new Error("No response body received");
381
+ }
382
+ const nodeStream = Readable.fromWeb(
383
+ body
384
+ );
385
+ await pipeline(nodeStream, fileStream);
386
+ fs2.mkdirSync(targetDir, { recursive: true });
387
+ await extract({
388
+ file: tarballPath,
389
+ cwd: targetDir,
390
+ strip: 1
391
+ // Remove the top-level directory (e.g., mcp-app-studio-starter-main/)
392
+ });
393
+ } finally {
394
+ fs2.rmSync(tempDir, { recursive: true, force: true });
395
+ }
396
+ }
397
+ async function main() {
398
+ ensureSupportedNodeVersion();
399
+ const args = process.argv.slice(2);
400
+ if (args.includes("--help") || args.includes("-h")) {
401
+ showHelp();
402
+ process.exit(0);
403
+ }
404
+ if (args.includes("--version") || args.includes("-v")) {
405
+ console.log(getVersion());
406
+ process.exit(0);
407
+ }
408
+ const argProjectName = args.find((arg) => !arg.startsWith("-"));
409
+ p.intro(pc.bgCyan(pc.black(" mcp-app-studio ")));
410
+ if (argProjectName) {
411
+ const pathCheck = isValidProjectPath(argProjectName);
412
+ if (!pathCheck.valid) {
413
+ p.log.error(pathCheck.error ?? "Invalid project path");
414
+ process.exit(1);
415
+ }
416
+ }
417
+ const projectName = argProjectName ? argProjectName : await p.text({
418
+ message: "Project name:",
419
+ placeholder: "my-chatgpt-app",
420
+ validate: (value) => {
421
+ if (!value) return "Project name is required";
422
+ const pathCheck = isValidProjectPath(value);
423
+ if (!pathCheck.valid) return pathCheck.error;
424
+ if (!isValidPackageName(toValidPackageName(value))) {
425
+ return "Invalid project name";
426
+ }
427
+ return void 0;
428
+ }
429
+ });
430
+ if (p.isCancel(projectName)) {
431
+ p.cancel("Operation cancelled.");
432
+ process.exit(0);
433
+ }
434
+ const description = await p.text({
435
+ message: "App description:",
436
+ placeholder: "A ChatGPT app that helps users...",
437
+ initialValue: ""
438
+ });
439
+ if (p.isCancel(description)) {
440
+ p.cancel("Operation cancelled.");
441
+ process.exit(0);
442
+ }
443
+ const template = await p.select({
444
+ message: "Choose a starter template:",
445
+ options: [
446
+ {
447
+ value: "minimal",
448
+ label: "Minimal",
449
+ hint: "Simple welcome card - perfect starting point"
450
+ },
451
+ {
452
+ value: "poi-map",
453
+ label: "Locations App",
454
+ hint: "Interactive map demo with full SDK features"
455
+ }
456
+ ],
457
+ initialValue: "minimal"
458
+ });
459
+ if (p.isCancel(template)) {
460
+ p.cancel("Operation cancelled.");
461
+ process.exit(0);
462
+ }
463
+ const includeServer = await p.confirm({
464
+ message: "Include MCP server?",
465
+ initialValue: true
466
+ });
467
+ if (p.isCancel(includeServer)) {
468
+ p.cancel("Operation cancelled.");
469
+ process.exit(0);
470
+ }
471
+ const targetDir = path2.resolve(process.cwd(), projectName);
472
+ const packageName = projectName === "." ? toValidPackageName(path2.basename(targetDir)) : toValidPackageName(projectName);
473
+ const config = {
474
+ name: projectName,
475
+ packageName,
476
+ description: description || "",
477
+ template,
478
+ includeServer
479
+ };
480
+ if (!isEmpty(targetDir)) {
481
+ const overwrite = await p.confirm({
482
+ message: `Directory "${projectName}" is not empty. Remove existing files?`,
483
+ initialValue: false
484
+ });
485
+ if (p.isCancel(overwrite) || !overwrite) {
486
+ p.cancel("Operation cancelled.");
487
+ process.exit(0);
488
+ }
489
+ emptyDir(targetDir);
490
+ }
491
+ const s = p.spinner();
492
+ s.start("Downloading template...");
493
+ try {
494
+ await downloadTemplate(targetDir);
495
+ } catch (error) {
496
+ s.stop("Download failed");
497
+ p.log.error(
498
+ `Failed to download template: ${error instanceof Error ? error.message : String(error)}`
499
+ );
500
+ process.exit(1);
501
+ }
502
+ s.message("Creating project...");
503
+ updatePackageJson(targetDir, config.packageName, config.description);
504
+ s.message("Applying template...");
505
+ applyTemplate(targetDir, config.template);
506
+ if (!config.includeServer) {
507
+ fs2.rmSync(path2.join(targetDir, "server"), { recursive: true, force: true });
508
+ }
509
+ s.stop("Project created!");
510
+ const pm = detectPackageManager();
511
+ const installCmd = pm === "yarn" ? "yarn" : pm === "bun" ? "bun install" : `${pm} install`;
512
+ const runCmd = pm === "npm" ? "npm run" : pm === "bun" ? "bun run" : pm;
513
+ const devCmd = `${runCmd} dev`;
514
+ const quotedName = quotePath(projectName);
515
+ const nextSteps = [`cd ${quotedName}`, installCmd];
516
+ if (config.includeServer) {
517
+ nextSteps.push(`cd server && ${installCmd}`);
518
+ nextSteps.push("cd ..");
519
+ }
520
+ nextSteps.push(devCmd);
521
+ if (config.includeServer) {
522
+ nextSteps.push("");
523
+ nextSteps.push(pc.dim("# This starts both Next.js and MCP server"));
524
+ }
525
+ p.note(nextSteps.join("\n"), "Get started");
526
+ const structureGuide = [
527
+ `${pc.cyan("components/examples/")} ${pc.dim("\u2190 Your widget components")}`,
528
+ `${pc.cyan("lib/workbench/")} ${pc.dim("\u2190 SDK wrappers for workbench")}`
529
+ ];
530
+ if (config.includeServer) {
531
+ structureGuide.push(
532
+ `${pc.cyan("server/")} ${pc.dim("\u2190 MCP server for Claude Desktop")}`
533
+ );
534
+ }
535
+ p.note(structureGuide.join("\n"), "Project structure");
536
+ const keyCommands = [];
537
+ keyCommands.push(
538
+ `${pc.cyan(`${runCmd} dev`)} ${pc.dim("Start the development workbench")}`
539
+ );
540
+ keyCommands.push(
541
+ `${pc.cyan(`${runCmd} export`)} ${pc.dim("Build & export for ChatGPT")}`
542
+ );
543
+ if (config.includeServer) {
544
+ keyCommands.push(
545
+ `${pc.cyan(`cd server && ${runCmd} inspect`)} ${pc.dim("Test MCP server locally")}`
546
+ );
547
+ }
548
+ p.note(keyCommands.join("\n"), "Key commands");
549
+ p.log.message("");
550
+ p.log.step(pc.bold("Building for multiple platforms:"));
551
+ p.log.message(
552
+ ` ${pc.dim("\u2022")} Use ${pc.cyan("useFeature('widgetState')")} to check for ChatGPT features`
553
+ );
554
+ p.log.message(
555
+ ` ${pc.dim("\u2022")} Use ${pc.cyan("useFeature('modelContext')")} to check for MCP features`
556
+ );
557
+ p.log.message(
558
+ ` ${pc.dim("\u2022")} Call ${pc.cyan("enableDebugMode()")} in browser console to debug platform detection`
559
+ );
560
+ p.log.message("");
561
+ p.log.step(pc.bold("Learn more:"));
562
+ p.log.message(
563
+ ` ${pc.dim("\u2022")} SDK Docs: ${pc.cyan("https://github.com/assistant-ui/mcp-app-studio#sdk")}`
564
+ );
565
+ p.log.message(
566
+ ` ${pc.dim("\u2022")} Examples: ${pc.cyan("https://github.com/assistant-ui/mcp-app-studio-starter")}`
567
+ );
568
+ if (config.includeServer) {
569
+ p.log.message(
570
+ ` ${pc.dim("\u2022")} MCP Guide: ${pc.cyan("https://modelcontextprotocol.io/quickstart")}`
571
+ );
572
+ }
573
+ p.log.message("");
574
+ p.outro(pc.green("Happy building! \u{1F680}"));
575
+ }
576
+ main().catch((err) => {
577
+ console.error("Error:", err);
578
+ process.exit(1);
579
+ });