@spark-apps/quickpeek 1.2.3 → 1.2.4

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/mcp.mjs CHANGED
@@ -13,7 +13,8 @@
13
13
  // Deliberately NO delete tools: outputs are new files and the server never
14
14
  // removes anything a user recorded.
15
15
 
16
- import { readFileSync } from 'node:fs';
16
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
17
+ import { fileURLToPath } from 'node:url';
17
18
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
18
19
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
19
20
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
@@ -38,8 +39,16 @@ const protocolStdout = new Proxy(process.stdout, {
38
39
  },
39
40
  });
40
41
 
42
+ // icons: MCP's Implementation schema (SDK 1.29+). A client that renders it
43
+ // shows QuickPeek's own mark beside the server instead of a generic plug; one
44
+ // that does not simply ignores the field.
41
45
  const server = new Server(
42
- { name: 'quickpeek', version: pkg.version },
46
+ {
47
+ name: 'quickpeek',
48
+ version: pkg.version,
49
+ title: 'QuickPeek',
50
+ icons: [{ src: 'https://quickpeek.co/icons/icon-512x512.png', mimeType: 'image/png', sizes: ['512x512'] }],
51
+ },
43
52
  { capabilities: { tools: {} } }
44
53
  );
45
54
 
@@ -64,36 +73,100 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
64
73
  }
65
74
  });
66
75
 
76
+ /**
77
+ * Say so when the bundle is older than the sources it was built from.
78
+ *
79
+ * dist/mcp-tools.mjs is what actually answers every tool call, and nothing
80
+ * rebuilds it on the way in. A checkout whose src/ has moved on therefore
81
+ * serves an older QuickPeek than the code in front of you, and the difference
82
+ * shows up as missing output rather than an error - a stale bundle once
83
+ * produced demos with no intro card, no outro card and no length limit, and
84
+ * the three read as three separate regressions in features that were simply
85
+ * not compiled in yet.
86
+ *
87
+ * Warning only, and on stderr: a published install has no src/ to compare
88
+ * against, and no diagnostic is worth refusing to start over.
89
+ */
90
+ function warnIfBundleStale() {
91
+ try {
92
+ const srcDir = fileURLToPath(new URL('./src', import.meta.url));
93
+ const bundle = fileURLToPath(new URL('./dist/mcp-tools.mjs', import.meta.url));
94
+ const builtAt = statSync(bundle).mtimeMs;
95
+ let newest = 0;
96
+ const walk = (dir) => {
97
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
98
+ const full = `${dir}/${entry.name}`;
99
+ if (entry.isDirectory()) walk(full);
100
+ else if (entry.name.endsWith('.ts')) newest = Math.max(newest, statSync(full).mtimeMs);
101
+ }
102
+ };
103
+ walk(srcDir);
104
+ if (newest > builtAt) {
105
+ // Seconds under a minute: the usual case is "edited a file, restarted",
106
+ // and rounding that to "0 minute(s) older" reads as no gap at all.
107
+ const gapSecs = Math.round((newest - builtAt) / 1000);
108
+ const age = gapSecs < 60 ? `${gapSecs}s` : `${Math.round(gapSecs / 60)}min`;
109
+ console.error(
110
+ `quickpeek-mcp: dist/mcp-tools.mjs is ${age} older than src/ - ` +
111
+ `this server is running stale code. Run \`bun run build\` to rebuild it.`,
112
+ );
113
+ }
114
+ } catch {
115
+ // No src/ (a published install), or an unreadable tree: nothing to check.
116
+ }
117
+ }
118
+
67
119
  async function main() {
120
+ warnIfBundleStale();
121
+
68
122
  const transport = new StdioServerTransport(process.stdin, protocolStdout);
69
123
  await server.connect(transport);
70
124
 
71
125
  // Elicitation is optional in MCP. The capability is checked per call, not
72
126
  // here: the client only declares it in `initialize`, which arrives after
73
- // connect() resolves. null means "cannot prompt" and start_auth then falls
74
- // back to a follow-up finish_auth call.
75
- setElicit(async (message) => {
127
+ // connect() resolves. null means "cannot prompt" and the tools fall back
128
+ // to a two-call flow (status result now, follow-up call later).
129
+ setElicit(async (message, fields) => {
76
130
  if (!server.getClientCapabilities()?.elicitation) return null;
77
- const result = await server.elicitInput({
78
- message,
79
- requestedSchema: {
80
- type: 'object',
81
- properties: {
82
- signedIn: {
83
- type: 'boolean',
84
- title: 'Signed in?',
85
- description: 'Confirm once you have finished signing in, so the session can be saved.',
86
- },
131
+ const properties = {};
132
+ for (const f of fields) {
133
+ properties[f.name] =
134
+ f.type === 'enum'
135
+ ? {
136
+ type: 'string',
137
+ title: f.title,
138
+ ...(f.description ? { description: f.description } : {}),
139
+ enum: f.options,
140
+ ...(f.default ? { default: f.default } : {}),
141
+ }
142
+ : { type: 'boolean', title: f.title, ...(f.description ? { description: f.description } : {}) };
143
+ }
144
+ try {
145
+ const result = await server.elicitInput(
146
+ {
147
+ message,
148
+ requestedSchema: { type: 'object', properties, required: fields.map((f) => f.name) },
87
149
  },
88
- required: ['signedIn'],
89
- },
90
- });
91
- return result.action === 'accept' && result.content?.signedIn === true;
150
+ // The SDK's default request timeout is 60s. These prompts wait on a
151
+ // human signing into a website or reading a plan; give them 15 minutes.
152
+ { timeout: 15 * 60 * 1000 },
153
+ );
154
+ if (result.action === 'accept') return { action: 'accept', content: result.content ?? {} };
155
+ return { action: result.action === 'decline' ? 'decline' : 'cancel' };
156
+ } catch {
157
+ // A client may declare the capability yet choke on the form (or the
158
+ // request may time out unanswered). Treat it as "cannot prompt" so the
159
+ // tool degrades to the two-call flow instead of erroring the call.
160
+ return null;
161
+ }
92
162
  });
93
163
 
94
164
  // Read per call for the same reason as above: `initialize` has not arrived
95
- // yet. Lets tools skip opening a sign-in window no prompt could ever confirm.
96
- setCanElicit(() => !!server.getClientCapabilities()?.elicitation);
165
+ // yet. Lets tools skip opening a sign-in window no prompt could ever
166
+ // confirm. Checks `.form` specifically — elicitInput throws for url-only
167
+ // clients, and the SDK normalizes a legacy bare `elicitation: {}` to
168
+ // include form, so this stays true for older clients too.
169
+ setCanElicit(() => !!server.getClientCapabilities()?.elicitation?.form);
97
170
  }
98
171
 
99
172
  main().catch((e) => {
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@spark-apps/quickpeek",
3
- "version": "1.2.3",
3
+ "version": "1.2.4",
4
4
  "description": "Auto-generate demo videos for your web app with AI",
5
+ "mcpName": "co.quickpeek/quickpeek",
5
6
  "main": "dist/qp.js",
6
7
  "bin": {
7
8
  "quickpeek": "dist/qp.js",
@@ -28,6 +29,7 @@
28
29
  "scripts": {
29
30
  "dev": "npx tsx src/qp.ts",
30
31
  "build": "tsup",
32
+ "prepare": "tsup",
31
33
  "build:tsc": "tsc --noEmit",
32
34
  "lint": "biome check src",
33
35
  "lint:fix": "biome check --write src",
@@ -51,7 +53,7 @@
51
53
  "license": "MIT",
52
54
  "dependencies": {
53
55
  "@modelcontextprotocol/sdk": "^1.29.0",
54
- "@spark-apps/video-kit": "^0.1.0",
56
+ "@spark-apps/video-kit": "^0.1.1",
55
57
  "chalk": "^5.6.2",
56
58
  "dotenv": "^17.3.1",
57
59
  "figlet": "^1.11.0",
@@ -63,6 +65,7 @@
63
65
  "devDependencies": {
64
66
  "@biomejs/biome": "^2.4.8",
65
67
  "@types/node": "^20.19.37",
68
+ "next": "^15.5.14",
66
69
  "tsup": "^8.5.1",
67
70
  "tsx": "^4.21.0",
68
71
  "typescript": "^5.9.3",
@@ -1,335 +0,0 @@
1
- /**
2
- * Configuration types and defaults for QuickPeek
3
- */
4
- declare const VERSION = "1.2.3";
5
- declare const CONFIG_FILE = "quickpeek.config.json";
6
- type UserTier = 'free' | 'pro';
7
- interface Config {
8
- lang?: string;
9
- plan?: {
10
- maxSteps: number;
11
- };
12
- browser?: {
13
- extension?: string;
14
- };
15
- video: {
16
- width: number;
17
- height: number;
18
- format: string;
19
- preset: 'ultrafast' | 'superfast' | 'veryfast' | 'faster' | 'fast' | 'medium' | 'slow' | 'slower' | 'veryslow';
20
- quality: number;
21
- fps: number;
22
- cursor: boolean;
23
- highlight: false | 'outline' | 'full';
24
- fadeIn: number;
25
- fadeOut: number;
26
- transitions: 'none' | 'fade' | 'blend';
27
- contrast: number;
28
- tempo: number;
29
- stepDelay: number;
30
- outro?: string;
31
- zoom?: number;
32
- };
33
- audio?: {
34
- voice?: string;
35
- format: string;
36
- bitrate: string;
37
- };
38
- music?: {
39
- path: string;
40
- volume: number;
41
- };
42
- timing: {
43
- minStepDuration: number;
44
- };
45
- output: {
46
- dir: string;
47
- };
48
- }
49
- interface CaptionWordStyle {
50
- color: string;
51
- size: number;
52
- bold: boolean;
53
- }
54
- interface CaptionStyle {
55
- font: string;
56
- outline: string;
57
- outlineWidth: number;
58
- shadow: number;
59
- marginBottom: number;
60
- regular: CaptionWordStyle;
61
- highlight: CaptionWordStyle;
62
- }
63
- declare const DEFAULT_CONFIG: Config;
64
- declare const DEFAULT_CAPTIONS: CaptionStyle;
65
-
66
- /**
67
- * AI API client — Spark AI communication
68
- * Handles token management, rate limiting, and retries
69
- *
70
- * Two transports:
71
- * - Relay (default for the published CLI): quickpeek-web mints a short-lived
72
- * per-user token from a registered email and proxies the chat server-side.
73
- * No credential ships in the npm bundle.
74
- * - Direct (dev/server only): when SPARK_AI_KEY is present in the runtime
75
- * environment (never baked at build time), call Spark AI directly. This is
76
- * the path quickpeek-web itself uses server-side.
77
- */
78
-
79
- interface RateLimitInfo {
80
- used: number;
81
- limit: number;
82
- tier: UserTier;
83
- resetAt?: string;
84
- }
85
- interface AIResponse {
86
- success: true;
87
- content: string;
88
- limits: RateLimitInfo;
89
- }
90
- interface AIError {
91
- success: false;
92
- error: string;
93
- isRateLimit?: boolean;
94
- isAuthError?: boolean;
95
- retryAfterSeconds?: number;
96
- /** Upstream HTTP status, when the failure was an HTTP response. */
97
- status?: number;
98
- /** Host that produced the failure, so relay vs backend is distinguishable. */
99
- host?: string;
100
- /** Raw response body (truncated), for the error log. */
101
- detail?: string;
102
- }
103
- type AIResult = AIResponse | AIError;
104
- /** Called before each backoff so the CLI can explain a long pause. */
105
- type RetryNotice = (info: {
106
- attempt: number;
107
- status: number;
108
- delayMs: number;
109
- host: string;
110
- }) => void;
111
- interface ChatOptions {
112
- onRetry?: RetryNotice;
113
- /**
114
- * Backoff before each 5xx retry. Defaults to SERVER_BACKOFF_MS, which is
115
- * sized to fit inside a serverless function budget — callAI runs inside the
116
- * quickpeek-web routes as well as the CLI. Interactive callers should pass
117
- * CLI_BACKOFF_MS, which can afford to outwait the backend's closed window.
118
- */
119
- backoffMs?: number[];
120
- }
121
- /** Short enough to stay well inside a serverless maxDuration. */
122
- declare const SERVER_BACKOFF_MS: number[];
123
- /**
124
- * Long enough to outlast the AI backend's ~45-60s closed window (measured
125
- * 16 Aug 2026). Only safe where a human is waiting on a spinner.
126
- */
127
- declare const CLI_BACKOFF_MS: number[];
128
- /**
129
- * Direct Spark AI call using the runtime SPARK_AI_KEY (dev + server-side use).
130
- */
131
- declare function callAI(systemPrompt: string, userPrompt: string, tier?: UserTier, opts?: ChatOptions): Promise<AIResult>;
132
- /**
133
- * Relayed Spark AI call for the published CLI: authenticates with the user's
134
- * registered email via quickpeek-web, which holds the real key server-side
135
- * and enforces per-user daily limits.
136
- */
137
- declare function callAIViaRelay(systemPrompt: string, userPrompt: string, email: string, tier?: UserTier, opts?: ChatOptions): Promise<AIResult>;
138
- /**
139
- * Parse AI-generated plan JSON from response text
140
- */
141
- declare function parseAIPlanResponse(content: string): {
142
- success: true;
143
- plan: {
144
- title?: string;
145
- description?: string;
146
- steps: Array<{
147
- id?: number;
148
- action?: string;
149
- caption?: string;
150
- target?: string;
151
- value?: string;
152
- }>;
153
- };
154
- } | {
155
- success: false;
156
- error: string;
157
- };
158
- /**
159
- * Normalize a URL by adding https:// and stripping www prefix
160
- */
161
- declare function normalizeUrl(input: string): string;
162
-
163
- /**
164
- * Shared types for QuickPeek core
165
- */
166
- /** Action type suggested for an element based on its type */
167
- type SuggestedAction = 'click' | 'type' | 'upload' | 'drag' | 'select' | 'check';
168
- /** Element type discovered on a page */
169
- type ElementType = 'button' | 'input' | 'link' | 'text' | 'file' | 'select' | 'range' | 'checkbox';
170
- /** Discovered element from page crawl */
171
- interface ElementInfo {
172
- /** Element type */
173
- type: ElementType | string;
174
- /** Display text/label for the element */
175
- text: string;
176
- /** CSS selector to target the element */
177
- selector: string;
178
- /** Suggested action for this element */
179
- action?: SuggestedAction;
180
- /** Simplified target name (just the text, for AI use) */
181
- target?: string;
182
- }
183
- /** Highlight mode for UI elements */
184
- type HighlightMode = false | 'outline' | 'full';
185
- /** Step in a demo plan */
186
- interface Step {
187
- id: number | string;
188
- action: string;
189
- target?: string;
190
- value?: string;
191
- caption?: string;
192
- description?: string;
193
- duration?: number;
194
- zoom?: number | string;
195
- partial?: string;
196
- transition?: 'fade' | 'blend';
197
- highlight?: HighlightMode;
198
- mediaFile?: string;
199
- mediaType?: 'image' | 'video';
200
- }
201
- /** Demo plan structure */
202
- interface Plan {
203
- url: string;
204
- title: string;
205
- description: string;
206
- lang?: string;
207
- steps: Step[];
208
- }
209
-
210
- /**
211
- * Page crawling and interactive element extraction
212
- */
213
-
214
- /**
215
- * A selector matching one id.
216
- *
217
- * HTML ids are far more permissive than CSS identifiers: `description-(optional)`
218
- * is a perfectly legal id, but `#description-(optional)` is invalid CSS and
219
- * Playwright rejects it, so the step silently finds nothing. Anything that is
220
- * not identifier-safe gets the attribute form instead.
221
- */
222
- declare function idSelector(id: string): string;
223
- /**
224
- * Crawl a page and extract interactive elements with smart selectors
225
- * Results are cached in-memory for a few minutes to avoid duplicate browser launches
226
- * @param url - URL to crawl
227
- * @param task - Optional progress reporter (CLI uses ora spinners, web can omit)
228
- */
229
- declare function crawlPage(url: string, task?: (text: string) => {
230
- succeed: (text?: string) => void;
231
- }, storageStatePath?: string, viewport?: {
232
- width: number;
233
- height: number;
234
- }): Promise<{
235
- title: string;
236
- elements: ElementInfo[];
237
- scrollable: number;
238
- }>;
239
-
240
- /**
241
- * Utility functions for QuickPeek
242
- */
243
- /**
244
- * Get full language name from ISO code
245
- */
246
- declare function getLanguageName(lang?: string): string | null;
247
-
248
- /**
249
- * Convert rgba() or rgb() color string to hex
250
- */
251
- declare function rgbaToHex(color: string): string;
252
-
253
- /**
254
- * Parse partial field "start-end" into trim values
255
- * e.g., "0-3.5" -> { trimStart: 0, trimEnd: 3.5 }
256
- * e.g., "2-" -> { trimStart: 2, trimEnd: undefined }
257
- */
258
- declare function parsePartial(partial: string | undefined): {
259
- trimStart?: number;
260
- trimEnd?: number;
261
- };
262
- /**
263
- * Extract a message string from an unknown error value
264
- */
265
- declare function getErrorMessage(err: unknown): string;
266
- /**
267
- * Convert text to Title Case, keeping prepositions/articles lowercase
268
- * First word is always capitalized
269
- */
270
- declare function toTitleCase(text: string): string;
271
-
272
- /**
273
- * AI prompts for demo plan generation
274
- */
275
-
276
- declare function buildSystemPrompt(maxSteps: number): string;
277
- declare function buildUserPrompt(url: string, title: string, elements: ElementInfo[], langName: string | null, description: string | undefined, maxSteps: number, scrollable?: number): string;
278
-
279
- type Tier = 'free' | 'pro';
280
- declare const FEATURES: Record<string, string[]>;
281
- interface SparkSubscription {
282
- payment_type: 'subscription' | 'one_time';
283
- status: 'active' | 'trialing' | 'past_due' | 'canceled' | 'lifetime';
284
- is_lifetime: boolean;
285
- current_period_end: string;
286
- cancel_at_period_end: boolean;
287
- billing_interval: 'monthly' | 'yearly' | 'one_time';
288
- price_id: string;
289
- created_at: string;
290
- }
291
- interface SparkTrial {
292
- is_expired: boolean;
293
- days_remaining: number;
294
- uses_remaining: number;
295
- max_days: number;
296
- max_uses: number;
297
- }
298
- interface SparkStatus {
299
- subscription: SparkSubscription | null;
300
- verified: boolean;
301
- registered: boolean;
302
- usage_count: number;
303
- trial: SparkTrial | null;
304
- message?: string;
305
- }
306
- declare const getStatus: (email: string) => Promise<SparkStatus>;
307
- declare function getStatusCached(email: string): Promise<SparkStatus>;
308
- declare function getTier(d: SparkStatus): Tier;
309
- declare const isPaid: (d: SparkStatus) => boolean;
310
- declare const hasAccess: (d: SparkStatus, min: Tier) => boolean;
311
- declare const hasTrialRemaining: (d: SparkStatus) => boolean;
312
- declare const showPaywall: (d: SparkStatus) => boolean;
313
- declare const isCanceling: (d: SparkStatus) => boolean;
314
- /** Resolve tier directly from an email — the most common consumer call. Falls back to 'free' on error. */
315
- declare function getTierByEmail(email: string): Promise<Tier>;
316
- /** Check whether an email is verified. Falls back to false on error. */
317
- declare function isVerified(email: string): Promise<boolean>;
318
- declare function pricingUrl(opts?: {
319
- email?: string;
320
- returnUrl?: string;
321
- }): string;
322
-
323
- /**
324
- * Shared constants for page scraping and element filtering
325
- * Used by both CLI and web versions
326
- */
327
- declare const EXCLUDED_LINK_PATTERNS: string[];
328
- /** CSS selectors for interactive page elements */
329
- declare const INTERACTIVE_SELECTORS: string[];
330
- /** Check if link text or href matches an excluded pattern */
331
- declare function isExcludedLink(text: string, href?: string): boolean;
332
- /** Check if a link should be skipped during crawling */
333
- declare function shouldSkipLink(text: string, href: string, currentPath: string): boolean;
334
-
335
- export { toTitleCase as $, type AIError as A, hasAccess as B, type Config as C, DEFAULT_CAPTIONS as D, EXCLUDED_LINK_PATTERNS as E, FEATURES as F, hasTrialRemaining as G, type HighlightMode as H, INTERACTIVE_SELECTORS as I, idSelector as J, isCanceling as K, isExcludedLink as L, isPaid as M, isVerified as N, normalizeUrl as O, type Plan as P, parseAIPlanResponse as Q, type RateLimitInfo as R, SERVER_BACKOFF_MS as S, type Tier as T, type UserTier as U, VERSION as V, parsePartial as W, pricingUrl as X, rgbaToHex as Y, shouldSkipLink as Z, showPaywall as _, type AIResponse as a, getTier as a0, type AIResult as b, CLI_BACKOFF_MS as c, CONFIG_FILE as d, type CaptionStyle as e, type CaptionWordStyle as f, type ChatOptions as g, DEFAULT_CONFIG as h, type ElementInfo as i, type ElementType as j, type RetryNotice as k, type SparkStatus as l, type SparkSubscription as m, type SparkTrial as n, type Step as o, type SuggestedAction as p, buildSystemPrompt as q, buildUserPrompt as r, callAI as s, callAIViaRelay as t, crawlPage as u, getErrorMessage as v, getLanguageName as w, getStatus as x, getStatusCached as y, getTierByEmail as z };