sunpeak 0.16.5 → 0.16.7

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.
package/README.md CHANGED
@@ -43,8 +43,6 @@ pnpm add -g sunpeak
43
43
  sunpeak new
44
44
  ```
45
45
 
46
- To add `sunpeak` to an existing project, refer to the [documentation](https://sunpeak.ai/docs/add-to-existing-project).
47
-
48
46
  ## Overview
49
47
 
50
48
  `sunpeak` is an npm package that helps you build MCP Apps (interactive UI resources) while keeping your MCP server client-agnostic. Built on the [MCP Apps SDK](https://github.com/modelcontextprotocol/ext-apps) (`@modelcontextprotocol/ext-apps`). `sunpeak` consists of:
@@ -49,7 +49,8 @@ function resolveEsmEntry(require, packageName) {
49
49
  * Build all resources for a Sunpeak project
50
50
  * Runs in the context of a user's project directory
51
51
  */
52
- export async function build(projectRoot = process.cwd()) {
52
+ export async function build(projectRoot = process.cwd(), { quiet = false } = {}) {
53
+ const log = quiet ? () => {} : console.log.bind(console);
53
54
 
54
55
  // Check for package.json first
55
56
  const pkgJsonPath = path.join(projectRoot, 'package.json');
@@ -191,7 +192,7 @@ export async function build(projectRoot = process.cwd()) {
191
192
  process.exit(1);
192
193
  }
193
194
 
194
- console.log('Building all resources...\n');
195
+ log('Building all resources...\n');
195
196
 
196
197
  // Read and validate the template
197
198
  const template = readFileSync(templateFile, 'utf-8');
@@ -214,7 +215,7 @@ export async function build(projectRoot = process.cwd()) {
214
215
  // Build all resources (but don't copy yet)
215
216
  for (let i = 0; i < resourceFiles.length; i++) {
216
217
  const { componentName, componentFile, kebabName, entry, jsOutput, buildOutDir } = resourceFiles[i];
217
- console.log(`[${i + 1}/${resourceFiles.length}] Building ${kebabName}...`);
218
+ log(`[${i + 1}/${resourceFiles.length}] Building ${kebabName}...`);
218
219
 
219
220
  try {
220
221
  // Create build directory if it doesn't exist
@@ -234,6 +235,7 @@ export async function build(projectRoot = process.cwd()) {
234
235
  await viteBuild({
235
236
  mode: 'production',
236
237
  root: projectRoot,
238
+ ...(quiet && { logLevel: 'silent' }),
237
239
  plugins: [react(), tailwindcss(), inlineCssPlugin(buildOutDir)],
238
240
  define: {
239
241
  'process.env.NODE_ENV': JSON.stringify('production'),
@@ -277,7 +279,7 @@ export async function build(projectRoot = process.cwd()) {
277
279
  }
278
280
 
279
281
  // Now copy all files from build-output to dist/{resource}/
280
- console.log('\nCopying built files to dist/...');
282
+ log('\nCopying built files to dist/...');
281
283
  const timestamp = Date.now().toString(36);
282
284
 
283
285
  for (const { jsOutput, htmlOutput, buildOutDir, distOutDir, kebabName, componentFile, resourceDir } of resourceFiles) {
@@ -296,7 +298,7 @@ export async function build(projectRoot = process.cwd()) {
296
298
  // Generate URI using resource name and build timestamp
297
299
  meta.uri = `ui://${meta.name}-${timestamp}`;
298
300
  writeFileSync(destJson, JSON.stringify(meta, null, 2));
299
- console.log(`✓ Generated ${kebabName}/${kebabName}.json (uri: ${meta.uri})`);
301
+ log(`✓ Generated ${kebabName}/${kebabName}.json (uri: ${meta.uri})`);
300
302
 
301
303
  // Read built JS file and wrap in HTML shell
302
304
  const builtJsFile = path.join(buildOutDir, jsOutput);
@@ -318,13 +320,13 @@ ${jsContents}
318
320
  </body>
319
321
  </html>`;
320
322
  writeFileSync(destHtmlFile, html);
321
- console.log(`✓ Built ${kebabName}/${htmlOutput}`);
323
+ log(`✓ Built ${kebabName}/${htmlOutput}`);
322
324
  } else {
323
325
  console.error(`Built file not found: ${builtJsFile}`);
324
326
  if (existsSync(buildOutDir)) {
325
- console.log(` Files in ${buildOutDir}:`, readdirSync(buildOutDir));
327
+ log(` Files in ${buildOutDir}:`, readdirSync(buildOutDir));
326
328
  } else {
327
- console.log(` Build directory doesn't exist: ${buildOutDir}`);
329
+ log(` Build directory doesn't exist: ${buildOutDir}`);
328
330
  }
329
331
  process.exit(1);
330
332
  }
@@ -339,10 +341,10 @@ ${jsContents}
339
341
  rmSync(buildDir, { recursive: true });
340
342
  }
341
343
 
342
- console.log('\n✓ All resources built successfully!');
343
- console.log('\nBuilt resources:');
344
+ log('\n✓ All resources built successfully!');
345
+ log('\nBuilt resources:');
344
346
  for (const { kebabName } of resourceFiles) {
345
- console.log(` ${kebabName}`);
347
+ log(` ${kebabName}`);
346
348
  }
347
349
 
348
350
  // ========================================================================
@@ -360,7 +362,7 @@ ${jsContents}
360
362
  const hasServerEntry = existsSync(serverEntryPath);
361
363
 
362
364
  if (toolFiles.length > 0 || hasServerEntry) {
363
- console.log('\nCompiling server-side code...');
365
+ log('\nCompiling server-side code...');
364
366
 
365
367
  let esbuild;
366
368
  try {
@@ -407,7 +409,7 @@ ${jsContents}
407
409
  loader: { '.tsx': 'tsx', '.ts': 'ts' },
408
410
  logLevel: 'warning',
409
411
  });
410
- console.log(`✓ Compiled tools/${toolName}.js`);
412
+ log(`✓ Compiled tools/${toolName}.js`);
411
413
  } catch (err) {
412
414
  console.error(`Failed to compile tool ${toolName}:`, err.message);
413
415
  process.exit(1);
@@ -439,7 +441,7 @@ ${jsContents}
439
441
  loader: { '.tsx': 'tsx', '.ts': 'ts' },
440
442
  logLevel: 'warning',
441
443
  });
442
- console.log(`✓ Compiled server.js`);
444
+ log(`✓ Compiled server.js`);
443
445
  } catch (err) {
444
446
  console.error(`Failed to compile server entry:`, err.message);
445
447
  process.exit(1);
@@ -448,12 +450,13 @@ ${jsContents}
448
450
  }
449
451
  }
450
452
 
451
- console.log('\n✓ Build complete!');
453
+ log('\n✓ Build complete!');
452
454
  }
453
455
 
454
456
  // Allow running directly
455
457
  if (import.meta.url === `file://${process.argv[1]}`) {
456
- build().catch(error => {
458
+ const quiet = process.argv.includes('--quiet');
459
+ build(process.cwd(), { quiet }).catch(error => {
457
460
  console.error(error);
458
461
  process.exit(1);
459
462
  });
@@ -66,17 +66,17 @@ function startBuildWatcher(projectRoot, resourcesDir, mcpHandle) {
66
66
  let activeChild = null;
67
67
  const sunpeakBin = join(dirname(new URL(import.meta.url).pathname), '..', 'sunpeak.js');
68
68
 
69
- const runBuild = (reason) => {
69
+ const runBuild = () => {
70
70
  // Kill any in-progress build and start fresh
71
71
  if (activeChild) {
72
72
  activeChild.kill('SIGTERM');
73
73
  activeChild = null;
74
74
  }
75
75
 
76
- console.log(`[build] ${reason}`);
77
- const child = spawn(process.execPath, [sunpeakBin, 'build'], {
76
+ console.log(`[build] Building resources for the MCP server for non-ChatGPT hosts...`);
77
+ const child = spawn(process.execPath, [sunpeakBin, 'build', '--quiet'], {
78
78
  cwd: projectRoot,
79
- stdio: ['ignore', 'inherit', 'inherit'],
79
+ stdio: ['ignore', 'pipe', 'inherit'],
80
80
  env: { ...process.env, NODE_ENV: 'production' },
81
81
  });
82
82
  activeChild = child;
@@ -85,6 +85,7 @@ function startBuildWatcher(projectRoot, resourcesDir, mcpHandle) {
85
85
  if (child !== activeChild) return; // Superseded by a newer build
86
86
  activeChild = null;
87
87
  if (code === 0) {
88
+ console.log(`[build] Built resources for the MCP server for non-ChatGPT hosts.`);
88
89
  // Notify non-local sessions (Claude, etc.) that resources changed
89
90
  mcpHandle?.invalidateResources();
90
91
  } else if (code !== null) {
@@ -94,7 +95,7 @@ function startBuildWatcher(projectRoot, resourcesDir, mcpHandle) {
94
95
  };
95
96
 
96
97
  // Initial build
97
- runBuild('Initial production build for tunnel clients...');
98
+ runBuild();
98
99
 
99
100
  // Watch src/resources/ for changes using fs.watch (recursive supported on macOS/Windows)
100
101
  let debounceTimer = null;
@@ -108,7 +109,7 @@ function startBuildWatcher(projectRoot, resourcesDir, mcpHandle) {
108
109
 
109
110
  clearTimeout(debounceTimer);
110
111
  debounceTimer = setTimeout(() => {
111
- runBuild(`Rebuilding (${filename} changed)...`);
112
+ runBuild();
112
113
  }, 500);
113
114
  });
114
115
  console.log('[build] Watching src/resources/ for changes...');
@@ -2,26 +2,59 @@
2
2
  import { existsSync, mkdirSync, cpSync, readFileSync, writeFileSync, renameSync } from 'fs';
3
3
  import { join, dirname, basename } from 'path';
4
4
  import { fileURLToPath } from 'url';
5
- import { createInterface } from 'readline';
6
- import { execSync } from 'child_process';
5
+ import { execSync, exec } from 'child_process';
6
+ import { promisify } from 'util';
7
+
8
+ const execAsync = promisify(exec);
9
+ import * as clack from '@clack/prompts';
7
10
  import { discoverResources } from '../lib/patterns.mjs';
8
11
  import { detectPackageManager } from '../utils.mjs';
9
12
 
10
13
  const __dirname = dirname(fileURLToPath(import.meta.url));
11
14
 
12
15
  /**
13
- * Default prompt implementation using readline
14
- * @param {string} question
16
+ * Default prompt for project name using clack text input.
15
17
  * @returns {Promise<string>}
16
18
  */
17
- function defaultPrompt(question) {
18
- const rl = createInterface({ input: process.stdin, output: process.stdout });
19
- return new Promise((resolve) => {
20
- rl.question(question, (answer) => {
21
- rl.close();
22
- resolve(answer.trim());
23
- });
19
+ async function defaultPromptName() {
20
+ const value = await clack.text({
21
+ message: 'Project name',
22
+ placeholder: 'my-app',
23
+ defaultValue: 'my-app',
24
+ validate: (v) => {
25
+ if (v === 'template') return '"template" is a reserved name';
26
+ },
24
27
  });
28
+ if (clack.isCancel(value)) {
29
+ clack.cancel('Cancelled.');
30
+ process.exit(0);
31
+ }
32
+ return value;
33
+ }
34
+
35
+ /**
36
+ * Default resource selection using clack multiselect.
37
+ * @param {string[]} availableResources
38
+ * @returns {Promise<string[]>}
39
+ */
40
+ async function defaultSelectResources(availableResources) {
41
+ const selected = await clack.multiselect({
42
+ message: 'Resources (UIs) to include (space to toggle)',
43
+ options: (() => {
44
+ const maxLen = Math.max(...availableResources.map((r) => r.length));
45
+ return availableResources.map((r) => ({
46
+ value: r,
47
+ label: `${r.padEnd(maxLen)} (https://sunpeak.ai/docs/api-reference/resources/${r})`,
48
+ }));
49
+ })(),
50
+ initialValues: availableResources,
51
+ required: true,
52
+ });
53
+ if (clack.isCancel(selected)) {
54
+ clack.cancel('Cancelled.');
55
+ process.exit(0);
56
+ }
57
+ return selected;
25
58
  }
26
59
 
27
60
  /**
@@ -37,7 +70,12 @@ export const defaultDeps = {
37
70
  writeFileSync,
38
71
  renameSync,
39
72
  execSync,
40
- prompt: defaultPrompt,
73
+ execAsync,
74
+ promptName: defaultPromptName,
75
+ selectResources: defaultSelectResources,
76
+ intro: clack.intro,
77
+ outro: clack.outro,
78
+ spinner: clack.spinner,
41
79
  console,
42
80
  process,
43
81
  cwd: () => process.cwd(),
@@ -88,6 +126,8 @@ export function parseResourcesInput(input, validResources, deps = defaultDeps) {
88
126
  export async function init(projectName, resourcesArg, deps = defaultDeps) {
89
127
  const d = { ...defaultDeps, ...deps };
90
128
 
129
+ d.intro('☀️ sunpeak');
130
+
91
131
  // Discover available resources from template
92
132
  const availableResources = d.discoverResources();
93
133
  if (availableResources.length === 0) {
@@ -96,10 +136,7 @@ export async function init(projectName, resourcesArg, deps = defaultDeps) {
96
136
  }
97
137
 
98
138
  if (!projectName) {
99
- projectName = await d.prompt('☀️ 🏔️ Project name [my-app]: ');
100
- if (!projectName) {
101
- projectName = 'my-app';
102
- }
139
+ projectName = await d.promptName();
103
140
  }
104
141
 
105
142
  if (projectName === 'template') {
@@ -107,17 +144,13 @@ export async function init(projectName, resourcesArg, deps = defaultDeps) {
107
144
  d.process.exit(1);
108
145
  }
109
146
 
110
- // Use resources from args or ask for them
111
- let resourcesInput;
112
- if (resourcesArg) {
113
- resourcesInput = resourcesArg;
114
- d.console.log(`☀️ 🏔️ Resources: ${resourcesArg}`);
147
+ // Use resources from args or interactively select them
148
+ let selectedResources;
149
+ if (resourcesArg !== undefined) {
150
+ selectedResources = parseResourcesInput(resourcesArg, availableResources, d);
115
151
  } else {
116
- resourcesInput = await d.prompt(
117
- `☀️ 🏔️ Resources (UIs) to include [${availableResources.join(', ')}]: `
118
- );
152
+ selectedResources = await d.selectResources(availableResources);
119
153
  }
120
- const selectedResources = parseResourcesInput(resourcesInput, availableResources, d);
121
154
 
122
155
  const targetDir = join(d.cwd(), projectName);
123
156
 
@@ -126,13 +159,11 @@ export async function init(projectName, resourcesArg, deps = defaultDeps) {
126
159
  d.process.exit(1);
127
160
  }
128
161
 
129
- d.console.log(`☀️ 🏔️ Creating ${projectName}...`);
130
-
131
- d.mkdirSync(targetDir, { recursive: true });
132
-
133
162
  // Filter resource directories based on selection
134
163
  const excludedResources = availableResources.filter((r) => !selectedResources.includes(r));
135
164
 
165
+ d.mkdirSync(targetDir, { recursive: true });
166
+
136
167
  d.cpSync(d.templateDir, targetDir, {
137
168
  recursive: true,
138
169
  filter: (src) => {
@@ -223,32 +254,30 @@ export async function init(projectName, resourcesArg, deps = defaultDeps) {
223
254
 
224
255
  d.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
225
256
 
226
- // Detect package manager and run install
257
+ // Install dependencies with spinner
227
258
  const pm = d.detectPackageManager();
228
- d.console.log(`☀️ 🏔️ Installing dependencies with ${pm}...`);
259
+ const s = d.spinner();
260
+ s.start(`Installing dependencies with ${pm}...`);
229
261
 
230
262
  try {
231
- d.execSync(`${pm} install`, { cwd: targetDir, stdio: 'inherit' });
263
+ await d.execAsync(`${pm} install`, { cwd: targetDir });
264
+ s.stop(`Installed dependencies with ${pm}`);
232
265
  } catch {
233
- d.console.error(`\nInstall failed. You can try running "${pm} install" manually in the project directory.`);
266
+ s.stop(`Install failed. You can try running "${pm} install" manually.`);
234
267
  }
235
268
 
236
269
  const runCmd = pm === 'npm' ? 'npm run' : pm;
237
270
 
238
- d.console.log(`
239
- Done! To get started:
271
+ d.outro(`Done! To get started:
240
272
 
241
273
  cd ${projectName}
242
274
  sunpeak dev
243
275
 
244
- That's it! Your project commands:
276
+ Your project commands:
245
277
 
246
278
  sunpeak dev # Start dev server + MCP endpoint
247
279
  sunpeak build # Build for production
248
- ${runCmd} test # Run tests
249
-
250
- See README.md for more details.
251
- `);
280
+ ${runCmd} test # Run tests`);
252
281
  }
253
282
 
254
283
  // Allow running directly
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sunpeak",
3
- "version": "0.16.5",
3
+ "version": "0.16.7",
4
4
  "description": "Local-first MCP Apps framework. Quickstart, build, test, and ship your Claude or ChatGPT App!",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -93,11 +93,12 @@
93
93
  "react-dom": "^18.0.0 || ^19.0.0"
94
94
  },
95
95
  "dependencies": {
96
+ "@clack/prompts": "^1.1.0",
96
97
  "@modelcontextprotocol/ext-apps": "^1.0.1",
97
98
  "@modelcontextprotocol/sdk": "^1.25.2",
98
99
  "clsx": "^2.1.1",
99
- "tailwind-merge": "^3.4.0",
100
100
  "esbuild": "^0.27.0",
101
+ "tailwind-merge": "^3.4.0",
101
102
  "zod": "^3.25.76"
102
103
  },
103
104
  "devDependencies": {
@@ -354,7 +354,7 @@ function AlertBanner({ alert }: { alert: Alert }) {
354
354
  const config = alertTypeConfig[alert.type];
355
355
  return (
356
356
  <div
357
- className="flex items-start gap-2 p-3 rounded-lg"
357
+ className="flex items-center gap-2 p-3 rounded-lg"
358
358
  style={{
359
359
  backgroundColor: config.bg,
360
360
  borderWidth: 1,
@@ -362,12 +362,12 @@ function AlertBanner({ alert }: { alert: Alert }) {
362
362
  borderColor: config.border,
363
363
  }}
364
364
  >
365
- <span className="flex-shrink-0" style={{ color: config.text }}>
365
+ <span className="flex-shrink-0 text-base leading-none" style={{ color: config.text }}>
366
366
  {config.icon}
367
367
  </span>
368
- <p className="text-sm" style={{ color: config.text }}>
368
+ <span className="text-sm" style={{ color: config.text }}>
369
369
  {alert.message}
370
- </p>
370
+ </span>
371
371
  </div>
372
372
  );
373
373
  }