@extension.dev/mcp 4.5.0 → 4.7.0

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/dist/module.js CHANGED
@@ -2,11 +2,10 @@ import { __webpack_require__ } from "./rslib-runtime.js";
2
2
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
5
+ import node_fs, { existsSync, readdirSync, statSync } from "node:fs";
5
6
  import node_path, { join } from "node:path";
6
7
  import { extensionCreate } from "extension-create";
7
- import node_fs, { existsSync, readdirSync, statSync } from "node:fs";
8
8
  import node_os from "node:os";
9
- import { extensionBuild } from "extension-develop";
10
9
  import cross_spawn from "cross-spawn";
11
10
  import { filterKeysForThisBrowser } from "browser-extension-manifest-fields";
12
11
  import ws from "ws";
@@ -32,6 +31,12 @@ __webpack_require__.d(create_namespaceObject, {
32
31
  handler: ()=>create_handler,
33
32
  schema: ()=>create_schema
34
33
  });
34
+ var deploy_namespaceObject = {};
35
+ __webpack_require__.r(deploy_namespaceObject);
36
+ __webpack_require__.d(deploy_namespaceObject, {
37
+ handler: ()=>deploy_handler,
38
+ schema: ()=>deploy_schema
39
+ });
35
40
  var detect_browsers_namespaceObject = {};
36
41
  __webpack_require__.r(detect_browsers_namespaceObject);
37
42
  __webpack_require__.d(detect_browsers_namespaceObject, {
@@ -194,7 +199,33 @@ __webpack_require__.d(whoami_namespaceObject, {
194
199
  handler: ()=>whoami_handler,
195
200
  schema: ()=>whoami_schema
196
201
  });
197
- var package_namespaceObject = JSON.parse('{"rE":"4.5.0","El":{"OP":"^4.0.11"}}');
202
+ var package_namespaceObject = JSON.parse('{"rE":"4.6.0","El":{"OP":"^4.0.11"}}');
203
+ function detectPackageManager(projectPath) {
204
+ const byLockfile = [
205
+ [
206
+ "bun.lock",
207
+ "bun"
208
+ ],
209
+ [
210
+ "bun.lockb",
211
+ "bun"
212
+ ],
213
+ [
214
+ "pnpm-lock.yaml",
215
+ "pnpm"
216
+ ],
217
+ [
218
+ "yarn.lock",
219
+ "yarn"
220
+ ],
221
+ [
222
+ "package-lock.json",
223
+ "npm"
224
+ ]
225
+ ];
226
+ for (const [lockfile, pm] of byLockfile)if (node_fs.existsSync(node_path.join(projectPath, lockfile))) return pm;
227
+ return "npm";
228
+ }
198
229
  const create_schema = {
199
230
  name: "extension_create",
200
231
  description: "Create a new browser extension project from a template in the extension.dev template catalog. Use extension_list_templates to see available options.",
@@ -212,7 +243,7 @@ const create_schema = {
212
243
  template: {
213
244
  type: "string",
214
245
  default: "typescript",
215
- description: "Template slug from the extension.dev template catalog (e.g. 'react', 'sidebar-claude', 'content-vue'). Use extension_list_templates to discover options."
246
+ description: "Template slug from the extension.dev template catalog (e.g. 'react', 'ai-claude', 'content-vue'). Use extension_list_templates to discover options."
216
247
  },
217
248
  install: {
218
249
  type: "boolean",
@@ -228,30 +259,47 @@ const create_schema = {
228
259
  async function create_handler(args) {
229
260
  const start = Date.now();
230
261
  const projectInput = args.parentDir ? node_path.resolve(args.parentDir, args.projectName) : args.projectName;
262
+ const logLines = [];
263
+ const capture = (stream)=>(...parts)=>{
264
+ const line = parts.map((p)=>"string" == typeof p ? p : String(p)).join(" ").trim();
265
+ if (line) logLines.push("error" === stream ? `[error] ${line}` : line);
266
+ };
267
+ const logTail = (max = 20)=>logLines.slice(-max);
231
268
  try {
232
269
  const result = await extensionCreate(projectInput, {
233
270
  template: args.template ?? "typescript",
234
271
  install: args.install ?? true,
235
272
  logger: {
236
- log: ()=>{},
237
- error: ()=>{}
273
+ log: capture("log"),
274
+ error: capture("error")
238
275
  }
239
276
  });
277
+ const packageManager = result.depsInstalled ? detectPackageManager(result.projectPath) : "npm";
278
+ const runDev = `${packageManager} run dev`;
240
279
  return JSON.stringify({
241
280
  projectPath: result.projectPath,
242
281
  projectName: result.projectName,
243
282
  template: result.template,
244
283
  depsInstalled: result.depsInstalled,
284
+ packageManager: result.depsInstalled ? packageManager : null,
245
285
  duration: Date.now() - start,
246
- nextSteps: [
286
+ nextSteps: result.depsInstalled ? [
247
287
  `cd ${result.projectPath}`,
288
+ runDev
289
+ ] : [
290
+ `cd ${result.projectPath}`,
291
+ "npm install",
248
292
  "npm run dev"
249
- ]
293
+ ],
294
+ ...result.depsInstalled ? {} : {
295
+ warnings: logTail()
296
+ }
250
297
  });
251
298
  } catch (err) {
252
299
  return JSON.stringify({
253
300
  error: err instanceof Error ? err.message : String(err),
254
- duration: Date.now() - start
301
+ duration: Date.now() - start,
302
+ log: logTail()
255
303
  });
256
304
  }
257
305
  }
@@ -292,8 +340,49 @@ async function listTemplates(filters) {
292
340
  if (filters?.tags?.length) templates = templates.filter((t)=>filters.tags.some((tag)=>t.tags?.includes(tag) || t.aiRecommendFor?.includes(tag)));
293
341
  if (filters?.featured) templates = templates.filter((t)=>t.featured);
294
342
  if (filters?.query) {
295
- const q = filters.query.toLowerCase();
296
- templates = templates.filter((t)=>t.slug.includes(q) || t.description.toLowerCase().includes(q) || t.tags?.some((tag)=>tag.includes(q)) || t.useCases?.some((uc)=>uc.toLowerCase().includes(q)) || t.aiPromptExamples?.some((ex)=>ex.toLowerCase().includes(q)));
343
+ const phrase = filters.query.toLowerCase().trim();
344
+ const STOP = new Set([
345
+ "the",
346
+ "a",
347
+ "an",
348
+ "and",
349
+ "or",
350
+ "for",
351
+ "with",
352
+ "that",
353
+ "this",
354
+ "to",
355
+ "of",
356
+ "on",
357
+ "in",
358
+ "into",
359
+ "your",
360
+ "my",
361
+ "me",
362
+ "it",
363
+ "is"
364
+ ]);
365
+ const tokens = phrase.split(/\s+/).filter((tok)=>tok.length >= 2 && !STOP.has(tok));
366
+ const bodyOf = (t)=>[
367
+ t.description,
368
+ ...t.tags ?? [],
369
+ ...t.useCases ?? [],
370
+ ...t.aiPromptExamples ?? []
371
+ ].join(" ").toLowerCase();
372
+ const scored = templates.map((t)=>{
373
+ const slug = t.slug.toLowerCase();
374
+ const body = bodyOf(t);
375
+ const hay = `${slug} ${body}`;
376
+ let score = 0;
377
+ if (phrase && hay.includes(phrase)) score += 100;
378
+ for (const tok of tokens)if (slug.includes(tok)) score += 3;
379
+ else if (body.includes(tok)) score += 1;
380
+ return {
381
+ t,
382
+ score
383
+ };
384
+ }).filter((entry)=>entry.score > 0).sort((a, b)=>b.score - a.score);
385
+ templates = scored.map((entry)=>entry.t);
297
386
  }
298
387
  return templates;
299
388
  }
@@ -303,7 +392,7 @@ async function getTemplateBySlug(slug) {
303
392
  }
304
393
  const list_templates_schema = {
305
394
  name: "extension_list_templates",
306
- description: "List available extension templates from the extension.dev template catalog. Filter by surface, framework, or tags. Returns structured metadata from templates-meta.json.",
395
+ description: "List available extension templates from the extension.dev template catalog. Filter by surface, framework, or tags. Returns structured metadata from templates-meta.json. Note: 'framework' is the UI framework only (react/vue/svelte/preact/vanilla) - it is not the language. TypeScript and JavaScript templates live under slugs (e.g. 'typescript', 'content-typescript'); shadcn is a React variant ('sidebar-shadcn') and provider AIs are tagged 'ai' ('ai-chatgpt', 'ai-claude'). Reach those with query/tags/slug, not framework.",
307
396
  inputSchema: {
308
397
  type: "object",
309
398
  properties: {
@@ -312,13 +401,10 @@ const list_templates_schema = {
312
401
  enum: [
313
402
  "content",
314
403
  "sidebar",
315
- "action",
316
404
  "newtab",
317
- "devtools",
318
- "options",
319
405
  "background"
320
406
  ],
321
- description: "Filter by extension surface type"
407
+ description: "Filter by extension surface type. For a popup/action starter use the 'action' slug (query:'action'), not a surface filter."
322
408
  },
323
409
  framework: {
324
410
  type: "string",
@@ -329,7 +415,7 @@ const list_templates_schema = {
329
415
  "preact",
330
416
  ""
331
417
  ],
332
- description: "Filter by UI framework (empty string = vanilla JS)"
418
+ description: "Filter by UI framework only (empty string = vanilla JS). Not a language filter - for TypeScript/JavaScript use query or slug."
333
419
  },
334
420
  tags: {
335
421
  type: "array",
@@ -344,7 +430,7 @@ const list_templates_schema = {
344
430
  },
345
431
  query: {
346
432
  type: "string",
347
- description: "Free-text search across slug, description, tags, and useCases"
433
+ description: "Keyword search across slug, description, tags, and useCases. Ranks by how many query words match, so a natural phrase works; single keywords are fine too."
348
434
  }
349
435
  }
350
436
  }
@@ -368,112 +454,6 @@ async function list_templates_handler(args) {
368
454
  templates: results
369
455
  });
370
456
  }
371
- const build_schema = {
372
- name: "extension_build",
373
- description: "Build a browser extension for production. Outputs to dist/<browser>/. Optionally creates .zip for store submission.",
374
- inputSchema: {
375
- type: "object",
376
- properties: {
377
- projectPath: {
378
- type: "string",
379
- description: "Path to the extension project root"
380
- },
381
- browser: {
382
- type: "string",
383
- enum: [
384
- "chrome",
385
- "chromium",
386
- "edge",
387
- "brave",
388
- "opera",
389
- "vivaldi",
390
- "yandex",
391
- "firefox",
392
- "waterfox",
393
- "librewolf",
394
- "safari",
395
- "chromium-based",
396
- "gecko-based",
397
- "firefox-based",
398
- "webkit-based"
399
- ],
400
- default: "chrome",
401
- description: "Target browser"
402
- },
403
- zip: {
404
- type: "boolean",
405
- default: false,
406
- description: "Create a .zip file for store distribution"
407
- },
408
- zipSource: {
409
- type: "boolean",
410
- default: false,
411
- description: "Include source code zip (required by some stores)"
412
- },
413
- zipFilename: {
414
- type: "string",
415
- description: "Custom .zip file name (defaults to name and version)"
416
- },
417
- polyfill: {
418
- type: "boolean",
419
- default: false,
420
- description: "Apply cross-browser polyfill"
421
- },
422
- silent: {
423
- type: "boolean",
424
- default: false,
425
- description: "Suppress build output"
426
- },
427
- mode: {
428
- type: "string",
429
- enum: [
430
- "development",
431
- "production",
432
- "none"
433
- ],
434
- default: "production",
435
- description: "Bundler mode override (also sets NODE_ENV)"
436
- }
437
- },
438
- required: [
439
- "projectPath"
440
- ]
441
- }
442
- };
443
- async function build_handler(args) {
444
- const start = Date.now();
445
- try {
446
- const summary = await extensionBuild(args.projectPath, {
447
- browser: args.browser ?? "chrome",
448
- zip: args.zip ?? false,
449
- zipSource: args.zipSource ?? false,
450
- ...args.zipFilename ? {
451
- zipFilename: args.zipFilename
452
- } : {},
453
- ...void 0 !== args.polyfill ? {
454
- polyfill: args.polyfill
455
- } : {},
456
- ...void 0 !== args.silent ? {
457
- silent: args.silent
458
- } : {},
459
- ...args.mode ? {
460
- mode: args.mode
461
- } : {},
462
- exitOnError: false
463
- });
464
- return JSON.stringify({
465
- success: true,
466
- ...summary,
467
- duration: Date.now() - start
468
- });
469
- } catch (err) {
470
- return JSON.stringify({
471
- error: err instanceof Error ? err.message : String(err),
472
- duration: Date.now() - start,
473
- hint: "Check that the project has a valid src/manifest.json and all dependencies are installed."
474
- });
475
- }
476
- }
477
457
  const PINNED_CLI_VERSION = String(package_namespaceObject.El.OP ?? "latest").replace(/^[\^~]/, "");
478
458
  function pinnedCliVersion() {
479
459
  const override = String(process.env.EXTENSION_MCP_CLI_VERSION || "").trim();
@@ -555,6 +535,126 @@ function spawnExtensionCli(args, options) {
555
535
  child.unref();
556
536
  return child;
557
537
  }
538
+ const build_schema = {
539
+ name: "extension_build",
540
+ description: "Build a browser extension for production. Outputs to dist/<browser>/. Optionally creates .zip for store submission.",
541
+ inputSchema: {
542
+ type: "object",
543
+ properties: {
544
+ projectPath: {
545
+ type: "string",
546
+ description: "Path to the extension project root"
547
+ },
548
+ browser: {
549
+ type: "string",
550
+ enum: [
551
+ "chrome",
552
+ "chromium",
553
+ "edge",
554
+ "brave",
555
+ "opera",
556
+ "vivaldi",
557
+ "yandex",
558
+ "firefox",
559
+ "waterfox",
560
+ "librewolf",
561
+ "safari",
562
+ "chromium-based",
563
+ "gecko-based",
564
+ "firefox-based",
565
+ "webkit-based"
566
+ ],
567
+ default: "chrome",
568
+ description: "Target browser"
569
+ },
570
+ zip: {
571
+ type: "boolean",
572
+ default: false,
573
+ description: "Create a .zip file for store distribution"
574
+ },
575
+ zipSource: {
576
+ type: "boolean",
577
+ default: false,
578
+ description: "Include source code zip (required by some stores)"
579
+ },
580
+ zipFilename: {
581
+ type: "string",
582
+ description: "Custom .zip file name (defaults to name and version)"
583
+ },
584
+ polyfill: {
585
+ type: "boolean",
586
+ default: false,
587
+ description: "Apply cross-browser polyfill"
588
+ },
589
+ silent: {
590
+ type: "boolean",
591
+ default: false,
592
+ description: "Suppress build output"
593
+ },
594
+ mode: {
595
+ type: "string",
596
+ enum: [
597
+ "development",
598
+ "production",
599
+ "none"
600
+ ],
601
+ default: "production",
602
+ description: "Bundler mode override (also sets NODE_ENV)"
603
+ }
604
+ },
605
+ required: [
606
+ "projectPath"
607
+ ]
608
+ }
609
+ };
610
+ async function build_handler(args) {
611
+ const start = Date.now();
612
+ const browser = args.browser ?? "chrome";
613
+ const cliArgs = [
614
+ "build",
615
+ args.projectPath,
616
+ "--browser",
617
+ browser
618
+ ];
619
+ if (args.zip) cliArgs.push("--zip");
620
+ if (args.zipSource) cliArgs.push("--zip-source");
621
+ if (args.zipFilename) cliArgs.push("--zip-filename", args.zipFilename);
622
+ if (args.polyfill) cliArgs.push("--polyfill");
623
+ if (args.silent) cliArgs.push("--silent");
624
+ if (args.mode) cliArgs.push("--mode", args.mode);
625
+ const { code, stdout, stderr } = await runExtensionCli(cliArgs, {
626
+ cwd: args.projectPath,
627
+ timeoutMs: 180000
628
+ });
629
+ const duration = Date.now() - start;
630
+ const out = (stdout ?? "").trim();
631
+ const lastLines = (text, n)=>text.split("\n").slice(-n).join("\n");
632
+ if (0 === code) {
633
+ const size = out.match(/Size:\s*([\d.]+\s*[kKmMgG]?B)/)?.[1];
634
+ const status = out.match(/Build Status:\s*(\w+)/)?.[1];
635
+ return JSON.stringify({
636
+ success: true,
637
+ browser,
638
+ ...size ? {
639
+ size
640
+ } : {},
641
+ ...status ? {
642
+ status
643
+ } : {},
644
+ zip: args.zip ?? false,
645
+ duration,
646
+ output: lastLines(out, 12)
647
+ });
648
+ }
649
+ const message = stderr.trim() || out || `extension build exited with code ${code}`;
650
+ return JSON.stringify({
651
+ success: false,
652
+ browser,
653
+ error: message.slice(0, 1200),
654
+ duration,
655
+ hint: "Check that the project has a valid src/manifest.json and its dependencies are installed (extension_dev auto-installs; build does not)."
656
+ });
657
+ }
558
658
  const process_manager_sessions = new Map();
559
659
  function sessionKey(projectPath, browser) {
560
660
  return `${node_path.resolve(projectPath)}::${browser}`;
@@ -719,9 +819,20 @@ async function dev_handler(args) {
719
819
  projectPath: args.projectPath,
720
820
  status: "started",
721
821
  hint: "Use extension_wait to check when the extension is fully loaded, then extension_source_inspect to inspect the live state. When you are done, call extension_stop to shut down the dev server and browser.",
722
- earlyOutput: earlyOutput.slice(0, 500)
822
+ earlyOutput: denoiseEarlyOutput(earlyOutput).slice(0, 500)
723
823
  });
724
824
  }
825
+ function denoiseEarlyOutput(raw) {
826
+ const NOISE = [
827
+ /^npm warn Unknown project config/i,
828
+ /This will stop working in the next major version of npm/i,
829
+ /^npm warn config/i,
830
+ /V8: .*Invalid asm\.js/i,
831
+ /^\(node:\d+\) V8:/i,
832
+ /Use `node --trace-warnings/i
833
+ ];
834
+ return raw.split("\n").filter((line)=>!NOISE.some((re)=>re.test(line.trim()))).join("\n").replace(/\n{2,}/g, "\n").trimStart();
835
+ }
725
836
  const start_schema = {
726
837
  name: "extension_start",
727
838
  description: "Build the extension for production and immediately preview it in a browser. Combines build + preview in one step. No hot reload.",
@@ -1011,7 +1122,7 @@ const get_template_source_schema = {
1011
1122
  properties: {
1012
1123
  slug: {
1013
1124
  type: "string",
1014
- description: "Template slug (e.g. 'sidebar-claude', 'content-react')"
1125
+ description: "Template slug (e.g. 'ai-claude', 'content-react')"
1015
1126
  },
1016
1127
  files: {
1017
1128
  type: "array",
@@ -1094,6 +1205,91 @@ function isChromiumFamily(browser) {
1094
1205
  function isGeckoFamily(browser) {
1095
1206
  return GECKO_FAMILY.has(browser);
1096
1207
  }
1208
+ const KNOWN_PERMISSIONS = new Set([
1209
+ "activeTab",
1210
+ "alarms",
1211
+ "background",
1212
+ "bookmarks",
1213
+ "browsingData",
1214
+ "certificateProvider",
1215
+ "clipboardRead",
1216
+ "clipboardWrite",
1217
+ "contentSettings",
1218
+ "contextMenus",
1219
+ "cookies",
1220
+ "debugger",
1221
+ "declarativeContent",
1222
+ "declarativeNetRequest",
1223
+ "declarativeNetRequestWithHostAccess",
1224
+ "declarativeNetRequestFeedback",
1225
+ "desktopCapture",
1226
+ "dns",
1227
+ "documentScan",
1228
+ "downloads",
1229
+ "downloads.open",
1230
+ "downloads.ui",
1231
+ "enterprise.deviceAttributes",
1232
+ "enterprise.hardwarePlatform",
1233
+ "enterprise.networkingAttributes",
1234
+ "enterprise.platformKeys",
1235
+ "favicon",
1236
+ "fileBrowserHandler",
1237
+ "fileSystemProvider",
1238
+ "fontSettings",
1239
+ "gcm",
1240
+ "geolocation",
1241
+ "history",
1242
+ "identity",
1243
+ "identity.email",
1244
+ "idle",
1245
+ "loginState",
1246
+ "management",
1247
+ "nativeMessaging",
1248
+ "notifications",
1249
+ "offscreen",
1250
+ "pageCapture",
1251
+ "power",
1252
+ "printerProvider",
1253
+ "printing",
1254
+ "printingMetrics",
1255
+ "privacy",
1256
+ "processes",
1257
+ "proxy",
1258
+ "readingList",
1259
+ "runtime",
1260
+ "scripting",
1261
+ "search",
1262
+ "sessions",
1263
+ "sidePanel",
1264
+ "storage",
1265
+ "system.cpu",
1266
+ "system.display",
1267
+ "system.memory",
1268
+ "system.storage",
1269
+ "tabCapture",
1270
+ "tabGroups",
1271
+ "tabs",
1272
+ "topSites",
1273
+ "tts",
1274
+ "ttsEngine",
1275
+ "unlimitedStorage",
1276
+ "vpnProvider",
1277
+ "wallpaper",
1278
+ "webAuthenticationProxy",
1279
+ "webNavigation",
1280
+ "webRequest",
1281
+ "webRequestBlocking",
1282
+ "webRequestAuthProvider",
1283
+ "browserSettings",
1284
+ "captivePortal",
1285
+ "contextualIdentities",
1286
+ "dns",
1287
+ "menus",
1288
+ "menus.overrideContext",
1289
+ "pkcs11",
1290
+ "theme",
1291
+ "webRequestFilterResponse"
1292
+ ]);
1097
1293
  const manifest_validate_schema = {
1098
1294
  name: "extension_manifest_validate",
1099
1295
  description: "Validate a manifest.json file for correctness across browsers. Reports missing fields, invalid permissions, and cross-browser compatibility issues.",
@@ -1152,6 +1348,13 @@ async function manifest_validate_handler(args) {
1152
1348
  if (!manifest.version) result.warnings.push("Missing field: version (required for store submission)");
1153
1349
  const chromiumManifest = filterKeysForThisBrowser(manifest, "chrome");
1154
1350
  if (!chromiumManifest.manifest_version) result.errors.push('Missing manifest_version. Use "chromium:manifest_version": 3 and "firefox:manifest_version": 2 for cross-browser support.');
1351
+ const declaredPerms = [
1352
+ ...chromiumManifest.permissions ?? [],
1353
+ ...chromiumManifest.optional_permissions ?? []
1354
+ ].filter((p)=>"string" == typeof p);
1355
+ for (const perm of declaredPerms)if (!(perm.includes("://") || perm.includes("*")) && "<all_urls>" !== perm) {
1356
+ if (!KNOWN_PERMISSIONS.has(perm)) result.warnings.push(`Unrecognized permission "${perm}" — check for a typo (host/match patterns belong in host_permissions, not permissions).`);
1357
+ }
1155
1358
  for (const browser of browsers){
1156
1359
  const isChromium = isChromiumFamily(browser);
1157
1360
  const isFirefox = isGeckoFamily(browser);
@@ -1189,9 +1392,15 @@ async function manifest_validate_handler(args) {
1189
1392
  if (chromiumManifest.action || manifest["firefox:browser_action"]) surfaces.push("action");
1190
1393
  if (chromiumManifest.chrome_url_overrides?.newtab) surfaces.push("newtab");
1191
1394
  if (chromiumManifest.background) surfaces.push("background");
1192
- if (surfaces.length) try {
1395
+ const distinctive = surfaces.filter((s)=>"background" !== s);
1396
+ const matchOn = distinctive.length ? distinctive : surfaces;
1397
+ if (matchOn.length) try {
1193
1398
  const templates = await listTemplates();
1194
- result.similarTemplates = templates.filter((t)=>t.surfaces.some((s)=>surfaces.includes(s))).slice(0, 5).map((t)=>({
1399
+ result.similarTemplates = templates.map((t)=>({
1400
+ slug: t.slug,
1401
+ surfaces: t.surfaces,
1402
+ score: t.surfaces.filter((s)=>matchOn.includes(s)).length
1403
+ })).filter((t)=>t.score > 0).sort((a, b)=>b.score - a.score).slice(0, 5).map((t)=>({
1195
1404
  slug: t.slug,
1196
1405
  surfaces: t.surfaces
1197
1406
  }));
@@ -1313,12 +1522,21 @@ async function inspect_handler(args) {
1313
1522
  byType[f.type].count++;
1314
1523
  byType[f.type].size += f.size;
1315
1524
  }
1525
+ const sourcemapSize = byType.sourcemap?.size ?? 0;
1526
+ const buildType = sourcemapSize > 0 ? "development" : "production";
1527
+ const shippableSize = totalSize - sourcemapSize;
1316
1528
  const result = {
1317
1529
  browser,
1318
1530
  distPath,
1531
+ buildType,
1319
1532
  totalSize,
1320
1533
  totalSizeFormatted: formatBytes(totalSize),
1534
+ shippableSize,
1535
+ shippableSizeFormatted: formatBytes(shippableSize),
1321
1536
  fileCount: files.length,
1537
+ ..."development" === buildType ? {
1538
+ note: `This dist contains ${formatBytes(sourcemapSize)} of sourcemaps and looks like a dev build; run extension_build for production sizes. shippableSize excludes sourcemaps.`
1539
+ } : {},
1322
1540
  manifest: {
1323
1541
  name: manifest.name,
1324
1542
  version: manifest.version,
@@ -1763,13 +1981,16 @@ class CDPClient extends CDPConnection {
1763
1981
  }
1764
1982
  async function resolveCdpPort(projectPath, browser, options) {
1765
1983
  const waitMs = options?.waitMs ?? 20000;
1984
+ const graceMs = options?.graceMs ?? 2500;
1766
1985
  const readyPath = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
1767
1986
  const deadline = Date.now() + waitMs;
1768
1987
  let contractSeen = false;
1988
+ let contractSeenAt = null;
1769
1989
  for(;;){
1770
1990
  try {
1771
1991
  const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
1772
1992
  contractSeen = true;
1993
+ if (null == contractSeenAt) contractSeenAt = Date.now();
1773
1994
  if ("number" == typeof contract.cdpPort) return {
1774
1995
  port: contract.cdpPort,
1775
1996
  source: "contract"
@@ -1777,7 +1998,8 @@ async function resolveCdpPort(projectPath, browser, options) {
1777
1998
  } catch {
1778
1999
  if (!contractSeen) break;
1779
2000
  }
1780
- if (Date.now() >= deadline) break;
2001
+ const effectiveDeadline = null != contractSeenAt ? Math.min(deadline, contractSeenAt + graceMs) : deadline;
2002
+ if (Date.now() >= effectiveDeadline) break;
1781
2003
  await new Promise((resolve)=>setTimeout(resolve, 500));
1782
2004
  }
1783
2005
  if (!contractSeen && await isCdpEndpoint(9222)) return {
@@ -1947,16 +2169,26 @@ async function source_inspect_handler(args) {
1947
2169
  const cdp = new CDPClient();
1948
2170
  try {
1949
2171
  const allTargets = await CDPClient.discoverTargets(cdpPort);
1950
- const pageTargets = allTargets.filter((t)=>"page" === t.type && !t.url.startsWith("chrome://") && !t.url.startsWith("devtools://"));
1951
- if (0 === pageTargets.length) return JSON.stringify({
1952
- cdpPort,
1953
- browser,
1954
- warning: "No inspectable page targets found. The extension may not have opened a page yet.",
1955
- allTargets: allTargets.map((t)=>({
1956
- type: t.type,
1957
- url: t.url?.slice(0, 100)
1958
- }))
1959
- });
2172
+ const OVERRIDE_PAGES = [
2173
+ "chrome://newtab/",
2174
+ "chrome://new-tab-page/",
2175
+ "chrome://bookmarks/",
2176
+ "chrome://history/"
2177
+ ];
2178
+ const isOverridePage = (url)=>OVERRIDE_PAGES.some((p)=>url.startsWith(p));
2179
+ const pageTargets = allTargets.filter((t)=>"page" === t.type && !t.url.startsWith("devtools://") && (!t.url.startsWith("chrome://") || isOverridePage(t.url)));
2180
+ if (0 === pageTargets.length) {
2181
+ const chromeOnly = allTargets.some((t)=>"page" === t.type && t.url.startsWith("chrome://"));
2182
+ return JSON.stringify({
2183
+ cdpPort,
2184
+ browser,
2185
+ warning: chromeOnly ? "No inspectable page targets found. Only internal chrome:// pages are open; open the extension's surface (or pass a url to navigate a tab) first." : "No inspectable page targets found. The extension may not have opened a page yet.",
2186
+ allTargets: allTargets.map((t)=>({
2187
+ type: t.type,
2188
+ url: t.url?.slice(0, 100)
2189
+ }))
2190
+ });
2191
+ }
1960
2192
  const target = args.url ? pageTargets.find((t)=>t.url.includes(args.url)) ?? pageTargets[0] : pageTargets[0];
1961
2193
  const browserWsUrl = await CDPClient.discoverBrowserWsUrl(cdpPort);
1962
2194
  await cdp.connect(browserWsUrl);
@@ -2452,7 +2684,7 @@ function commonFlags(args) {
2452
2684
  }
2453
2685
  const eval_schema = {
2454
2686
  name: "extension_eval",
2455
- description: "Evaluate an expression in a running extension context (service worker, content script, popup, options, sidebar). Requires the dev session to be started with allowEval: true (extension_dev; writes a 0600 session token the CLI reads). Wraps `extension eval`.",
2687
+ description: "Evaluate an expression in a running extension context. Requires the dev session to be started with allowEval: true (extension_dev; writes a 0600 session token the CLI reads). Chromium caveats: eval in the MV3 background/service_worker is blocked by CSP (use an MV2/Firefox build for that context), and context content/page/popup require a numeric `tab` id (a chrome.tabs id) — the `url` arg alone does not target a tab. To read a content-script DOM without a tab id, prefer extension_source_inspect (it auto-selects the active page and can navigate by url). Wraps `extension eval`.",
2456
2688
  inputSchema: {
2457
2689
  type: "object",
2458
2690
  properties: {
@@ -2480,11 +2712,11 @@ const eval_schema = {
2480
2712
  },
2481
2713
  url: {
2482
2714
  type: "string",
2483
- description: "For content/page: document(s) to target"
2715
+ description: "For content/page: filters which matching document(s) to target, but does NOT by itself select a tab — a numeric `tab` id is still required on Chromium."
2484
2716
  },
2485
2717
  tab: {
2486
2718
  type: "number",
2487
- description: "For content/page: a specific tab id"
2719
+ description: "Numeric chrome.tabs id. Required for context content/page/popup on Chromium (the `url` arg does not substitute for it)."
2488
2720
  },
2489
2721
  browser: {
2490
2722
  type: "string",
@@ -2696,7 +2928,21 @@ async function open_handler(args) {
2696
2928
  if ("command" === args.surface && args.name) cli.push("--name", args.name);
2697
2929
  cli.push("--browser", browser);
2698
2930
  if (null != args.timeout) cli.push("--timeout", String(args.timeout));
2699
- return runActVerb(cli, args.projectPath, args.timeout);
2931
+ const raw = await runActVerb(cli, args.projectPath, args.timeout);
2932
+ const headless = /^(1|true)$/i.test(process.env.EXTENSION_HEADLESS ?? "");
2933
+ if (headless && [
2934
+ "popup",
2935
+ "action",
2936
+ "sidebar"
2937
+ ].includes(args.surface)) try {
2938
+ const parsed = JSON.parse(raw);
2939
+ const msg = String(parsed?.error?.message ?? "");
2940
+ if (parsed?.ok === false && !parsed.hint && /active browser window|no active|headless/i.test(msg)) {
2941
+ parsed.hint = "The dev browser is running headless (EXTENSION_HEADLESS), which has no visible window to attach a popup/sidebar to. Relaunch a headed dev session to open UI surfaces.";
2942
+ return JSON.stringify(parsed);
2943
+ }
2944
+ } catch {}
2945
+ return raw;
2700
2946
  }
2701
2947
  const dom_inspect_schema = {
2702
2948
  name: "extension_dom_inspect",
@@ -2773,8 +3019,9 @@ async function dom_inspect_handler(args) {
2773
3019
  ok: false,
2774
3020
  error: {
2775
3021
  name: "BadRequest",
2776
- message: "content/page inspect requires a tab id"
2777
- }
3022
+ message: "content/page inspect requires a numeric tab id (chrome.tabs id, not a CDP target id)."
3023
+ },
3024
+ hint: "To inspect a content script's DOM without hunting for a tab id, use extension_source_inspect (it auto-selects the active page and can navigate via its url arg). Use extension_dom_inspect with context popup/options/sidebar/devtools for an open surface (no tab needed)."
2778
3025
  });
2779
3026
  const cli = [
2780
3027
  "inspect",
@@ -2806,13 +3053,17 @@ function readCredentials() {
2806
3053
  if (1 !== data.version) return null;
2807
3054
  const token = String(data.token || "").trim();
2808
3055
  if (!token) return null;
3056
+ const provider = "extensiondev" === data.provider || "github" === data.provider ? data.provider : void 0;
2809
3057
  return {
2810
3058
  version: 1,
2811
3059
  token,
2812
3060
  workspaceSlug: String(data.workspaceSlug || ""),
2813
3061
  projectSlug: String(data.projectSlug || ""),
2814
3062
  expiresAt: Number(data.expiresAt || 0),
2815
- api: String(data.api || "")
3063
+ api: String(data.api || ""),
3064
+ ...provider ? {
3065
+ provider
3066
+ } : {}
2816
3067
  };
2817
3068
  } catch {
2818
3069
  return null;
@@ -2884,8 +3135,12 @@ function safeApiBase(raw) {
2884
3135
  async function fetchLoginConfig(apiBase, fetchImpl = fetch) {
2885
3136
  const override = String(process.env.EXTENSION_DEV_GITHUB_CLIENT_ID || "").trim();
2886
3137
  if (override) return {
3138
+ provider: "github",
2887
3139
  clientId: override,
2888
- scope: "read:user"
3140
+ scope: "read:user",
3141
+ deviceCodeUrl: "/api/cli/device/code",
3142
+ deviceTokenUrl: "/api/cli/device/token",
3143
+ verificationUri: "https://github.com/login/device"
2889
3144
  };
2890
3145
  const res = await fetchImpl(`${apiBase}/api/cli/login/config`, {
2891
3146
  headers: {
@@ -2894,12 +3149,32 @@ async function fetchLoginConfig(apiBase, fetchImpl = fetch) {
2894
3149
  });
2895
3150
  if (!res.ok) throw new Error(`Could not fetch login config from ${apiBase} (${res.status}).`);
2896
3151
  const data = await res.json().catch(()=>({}));
3152
+ const provider = "extensiondev" === data.provider ? "extensiondev" : "github";
2897
3153
  const clientId = String(data.githubClientId || "").trim();
2898
- if (!clientId) throw new Error("Login is not configured on the server (no GitHub client id). Set EXTENSION_DEV_GITHUB_CLIENT_ID to override.");
3154
+ if ("github" === provider && !clientId) throw new Error("Login is not configured on the server (no GitHub client id). Set EXTENSION_DEV_GITHUB_CLIENT_ID to override.");
2899
3155
  return {
3156
+ provider,
2900
3157
  clientId,
2901
- scope: String(data.scope || "read:user")
3158
+ scope: String(data.scope || "read:user"),
3159
+ deviceCodeUrl: String(data.deviceCodeUrl || "/api/cli/device/code"),
3160
+ deviceTokenUrl: String(data.deviceTokenUrl || "/api/cli/device/token"),
3161
+ verificationUri: String(data.verificationUri || "https://github.com/login/device")
3162
+ };
3163
+ }
3164
+ function persistTokenResponse(args) {
3165
+ const token = String(args.data.token || "").trim();
3166
+ if (!token) throw new Error("Login returned no token.");
3167
+ const creds = {
3168
+ version: 1,
3169
+ token,
3170
+ workspaceSlug: String(args.data.workspaceSlug || ""),
3171
+ projectSlug: String(args.data.projectSlug || ""),
3172
+ expiresAt: Number(args.data.expiresAt || 0),
3173
+ api: args.apiBase,
3174
+ provider: args.provider
2902
3175
  };
3176
+ writeCredentials(creds);
3177
+ return creds;
2903
3178
  }
2904
3179
  async function exchangeAndPersist(args) {
2905
3180
  const doFetch = args.fetchImpl ?? fetch;
@@ -2924,18 +3199,11 @@ async function exchangeAndPersist(args) {
2924
3199
  };
2925
3200
  }
2926
3201
  if (!res.ok) throw new Error(`Login exchange failed (${res.status}): ${data.message || "unknown error"}`);
2927
- const token = String(data.token || "").trim();
2928
- if (!token) throw new Error("Login exchange returned no token.");
2929
- const creds = {
2930
- version: 1,
2931
- token,
2932
- workspaceSlug: String(data.workspaceSlug || ""),
2933
- projectSlug: String(data.projectSlug || ""),
2934
- expiresAt: Number(data.expiresAt || 0),
2935
- api: args.apiBase
2936
- };
2937
- writeCredentials(creds);
2938
- return creds;
3202
+ return persistTokenResponse({
3203
+ apiBase: args.apiBase,
3204
+ data,
3205
+ provider: "github"
3206
+ });
2939
3207
  }
2940
3208
  function resolveToken() {
2941
3209
  const fromEnv = String(process.env.EXTENSION_DEV_TOKEN || "").trim();
@@ -3007,53 +3275,60 @@ async function publish(options = {}) {
3007
3275
  }
3008
3276
  const publish_schema = {
3009
3277
  name: "extension_publish",
3010
- description: "Publish a project to extension.dev and return a shareable URL. Auth-gated: requires EXTENSION_DEV_TOKEN (a workspace/project access token) in the environment. Posts to the platform's CLI publish endpoint directly. This is the only tool that talks to the hosted platform rather than the local browser.",
3278
+ description: "Publish the project your stored token is scoped to (from extension_login, or EXTENSION_DEV_TOKEN) to extension.dev and return its shareable URL. The publish target is the token's project -- there is no projectPath, the local files are not uploaded. For a PUBLIC project the URL is the canonical public page and ttlHours does not apply; for a PRIVATE project it is a fresh time-limited share link (?share=) whose lifetime is ttlHours. Posts to the platform's CLI publish endpoint. Besides extension_login this is the only tool that talks to the hosted platform.",
3011
3279
  inputSchema: {
3012
3280
  type: "object",
3013
3281
  properties: {
3014
- projectPath: {
3015
- type: "string",
3016
- description: "Path to the extension project root"
3017
- },
3018
3282
  ttlHours: {
3019
3283
  type: "number",
3020
- description: "Share-link lifetime in hours (1168, default 24)"
3284
+ description: "Private-project share-link lifetime in hours, 1-168 (default 24). Ignored for public projects."
3021
3285
  },
3022
3286
  buildSha: {
3023
3287
  type: "string",
3024
- description: "Pin the share URL to a specific build sha"
3288
+ description: "Pin the share URL to a specific build sha (7-40 hex chars). The platform verifies the build exists in the project's build index and rejects an unknown sha, so the returned URL always points at a real build."
3025
3289
  },
3026
3290
  api: {
3027
3291
  type: "string",
3028
3292
  description: "Platform base URL (defaults to https://www.extension.dev or EXTENSION_DEV_API_URL)"
3029
3293
  }
3030
3294
  },
3031
- required: [
3032
- "projectPath"
3033
- ]
3295
+ required: []
3034
3296
  }
3035
3297
  };
3036
- async function publish_handler(args) {
3037
- const token = resolveToken();
3038
- if (!token) return JSON.stringify({
3298
+ function fail(name, message) {
3299
+ return JSON.stringify({
3039
3300
  ok: false,
3040
3301
  error: {
3041
- name: "PublishAuthError",
3042
- message: "No token. Run extension_login, or set EXTENSION_DEV_TOKEN (create one in the extension.dev dashboard)."
3302
+ name,
3303
+ message
3043
3304
  }
3044
3305
  });
3306
+ }
3307
+ async function publish_handler(args) {
3308
+ const token = resolveToken();
3309
+ if (!token) return fail("PublishAuthError", "No token. Run extension_login, or set EXTENSION_DEV_TOKEN (create one in the extension.dev dashboard).");
3310
+ if (null != args.ttlHours) {
3311
+ const t = Number(args.ttlHours);
3312
+ if (!Number.isInteger(t) || t < 1 || t > 168) return fail("PublishBadRequest", "ttlHours must be an integer between 1 and 168.");
3313
+ }
3314
+ if (null != args.buildSha && "" !== args.buildSha) {
3315
+ if (!/^[0-9a-f]{7,40}$/i.test(args.buildSha)) return fail("PublishBadRequest", "buildSha must be a 7-40 character hex git sha.");
3316
+ }
3045
3317
  const result = await publish({
3046
3318
  ttlHours: args.ttlHours,
3047
3319
  buildSha: args.buildSha,
3048
3320
  api: args.api,
3049
3321
  token
3050
3322
  });
3051
- return result.ok ? JSON.stringify(result.data) : JSON.stringify(result);
3323
+ if (!result.ok) return JSON.stringify(result);
3324
+ const data = result.data;
3325
+ if (null != args.ttlHours && "public" === data.visibility) data.note = "ttlHours was ignored: this is a public project, whose share URL is its canonical public page.";
3326
+ return JSON.stringify(data);
3052
3327
  }
3053
3328
  const release_promote_DEFAULT_API = "https://www.extension.dev";
3054
3329
  const release_promote_schema = {
3055
3330
  name: "extension_release_promote",
3056
- description: "Promote a built extension to a release channel (e.g. stable, preview, beta) on extension.dev, headless. Auth-gated: requires a release token in EXTENSION_DEV_TOKEN (mint and revoke it in the dashboard under project settings -> Access tokens). Posts to the platform's CLI release endpoint; the project is identified by the token. Cutting a version-bump PR is not available headlessly (it writes to your source repo and needs an interactive login).",
3331
+ description: "Promote a built extension to a release channel (e.g. stable, preview, beta) on extension.dev, headless. Auth-gated: uses your stored login (extension_login) or a release token in EXTENSION_DEV_TOKEN (mint and revoke it in the dashboard under project settings -> Access tokens). Posts to the platform's CLI release endpoint; the project is identified by the token. Cutting a version-bump PR is not available headlessly (it writes to your source repo and needs an interactive login).",
3057
3332
  inputSchema: {
3058
3333
  type: "object",
3059
3334
  properties: {
@@ -3095,7 +3370,7 @@ const release_promote_schema = {
3095
3370
  ]
3096
3371
  }
3097
3372
  };
3098
- function fail(name, message) {
3373
+ function release_promote_fail(name, message) {
3099
3374
  return JSON.stringify({
3100
3375
  ok: false,
3101
3376
  error: {
@@ -3106,12 +3381,12 @@ function fail(name, message) {
3106
3381
  }
3107
3382
  async function release_promote_handler(args) {
3108
3383
  const token = resolveToken();
3109
- if (!token) return fail("ReleaseAuthError", "No token. Set EXTENSION_DEV_TOKEN to a release token (create one in the extension.dev dashboard under project settings -> Access tokens), or run extension_login.");
3384
+ if (!token) return release_promote_fail("ReleaseAuthError", "No token. Set EXTENSION_DEV_TOKEN to a release token (create one in the extension.dev dashboard under project settings -> Access tokens), or run extension_login.");
3110
3385
  const buildId = String(args.buildId || "").trim();
3111
3386
  const channel = String(args.channel || "").trim();
3112
- if (!buildId || !channel) return fail("ReleaseInputError", "buildId and channel are required.");
3387
+ if (!buildId || !channel) return release_promote_fail("ReleaseInputError", "buildId and channel are required.");
3113
3388
  const apiCheck = safeApiBase(String(args.api || process.env.EXTENSION_DEV_API_URL || release_promote_DEFAULT_API));
3114
- if (!apiCheck.ok) return fail("ReleaseConfigError", apiCheck.message);
3389
+ if (!apiCheck.ok) return release_promote_fail("ReleaseConfigError", apiCheck.message);
3115
3390
  const url = `${apiCheck.base}/api/cli/release/promote`;
3116
3391
  const body = {
3117
3392
  buildId,
@@ -3132,7 +3407,7 @@ async function release_promote_handler(args) {
3132
3407
  body: JSON.stringify(body)
3133
3408
  });
3134
3409
  } catch (err) {
3135
- return fail("ReleaseNetworkError", `Could not reach ${url}: ${err?.message || err}`);
3410
+ return release_promote_fail("ReleaseNetworkError", `Could not reach ${url}: ${err?.message || err}`);
3136
3411
  }
3137
3412
  const text = await res.text();
3138
3413
  let data;
@@ -3143,12 +3418,117 @@ async function release_promote_handler(args) {
3143
3418
  message: text
3144
3419
  };
3145
3420
  }
3146
- if (!res.ok) return fail("ReleaseError", `promote failed (${res.status}): ${data?.message || text || "unknown error"}`);
3421
+ if (!res.ok) return release_promote_fail("ReleaseError", `promote failed (${res.status}): ${data?.message || text || "unknown error"}`);
3147
3422
  return JSON.stringify(data);
3148
3423
  }
3424
+ const deploy_DEFAULT_API = "https://www.extension.dev";
3425
+ const deploy_schema = {
3426
+ name: "extension_deploy",
3427
+ description: "Submit a built extension to the Chrome Web Store, Firefox AMO, and/or Edge Add-ons THROUGH extension.dev, which holds your store credentials and dispatches the release from your project's mirror CI. DEFAULTS TO A DRY RUN (preflight: verifies auth, the project, that the build exists, and the store workflow - dispatches nothing); pass dryRun:false to actually submit, which is irreversible and enters store review. The target project is identified by your token (extension_login or a release token in EXTENSION_DEV_TOKEN); store credentials are never tool arguments and local files are not uploaded. Pass browsers + buildSha. Posts to the platform's CLI store-submission endpoint.",
3428
+ inputSchema: {
3429
+ type: "object",
3430
+ properties: {
3431
+ browsers: {
3432
+ type: "array",
3433
+ items: {
3434
+ type: "string",
3435
+ enum: [
3436
+ "chrome",
3437
+ "firefox",
3438
+ "edge",
3439
+ "safari"
3440
+ ]
3441
+ },
3442
+ description: "Stores to submit to."
3443
+ },
3444
+ buildSha: {
3445
+ type: "string",
3446
+ description: "The built commit SHA to submit. It must have a completed build in the project's build index; an unknown sha is rejected."
3447
+ },
3448
+ channel: {
3449
+ type: "string",
3450
+ description: "Release channel to submit from (default stable)."
3451
+ },
3452
+ version: {
3453
+ type: "string",
3454
+ description: "Version label for the submission record (optional)."
3455
+ },
3456
+ dryRun: {
3457
+ type: "boolean",
3458
+ default: true,
3459
+ description: "Preflight only (verify auth, project, build, and store workflow). Pass false to actually dispatch the submission (irreversible, enters store review)."
3460
+ },
3461
+ api: {
3462
+ type: "string",
3463
+ description: "Platform base URL (defaults to https://www.extension.dev or EXTENSION_DEV_API_URL)"
3464
+ }
3465
+ },
3466
+ required: [
3467
+ "browsers",
3468
+ "buildSha"
3469
+ ]
3470
+ }
3471
+ };
3472
+ function deploy_fail(name, message) {
3473
+ return JSON.stringify({
3474
+ ok: false,
3475
+ error: {
3476
+ name,
3477
+ message
3478
+ }
3479
+ });
3480
+ }
3481
+ async function deploy_handler(args) {
3482
+ const token = resolveToken();
3483
+ if (!token) return deploy_fail("DeployAuthError", "No token. Run extension_login, or set EXTENSION_DEV_TOKEN (create one in the extension.dev dashboard under project settings -> Access tokens).");
3484
+ const browsers = (Array.isArray(args.browsers) ? args.browsers : []).map((b)=>String(b).trim().toLowerCase()).filter(Boolean);
3485
+ if (0 === browsers.length) return deploy_fail("DeployInputError", 'browsers is required (e.g. ["chrome","firefox","edge"]).');
3486
+ const buildSha = String(args.buildSha || "").trim();
3487
+ if (!buildSha) return deploy_fail("DeployInputError", "buildSha is required (the built commit to submit).");
3488
+ const apiCheck = safeApiBase(String(args.api || process.env.EXTENSION_DEV_API_URL || deploy_DEFAULT_API));
3489
+ if (!apiCheck.ok) return deploy_fail("DeployConfigError", apiCheck.message);
3490
+ const url = `${apiCheck.base}/api/cli/stores/submit`;
3491
+ const dryRun = false !== args.dryRun;
3492
+ const body = {
3493
+ browsers,
3494
+ buildSha,
3495
+ dryRun
3496
+ };
3497
+ if (args.channel) body.channel = String(args.channel).trim();
3498
+ if (args.version) body.version = String(args.version).trim();
3499
+ let res;
3500
+ try {
3501
+ res = await fetch(url, {
3502
+ method: "POST",
3503
+ headers: {
3504
+ authorization: `Bearer ${token}`,
3505
+ "content-type": "application/json"
3506
+ },
3507
+ body: JSON.stringify(body)
3508
+ });
3509
+ } catch (err) {
3510
+ return deploy_fail("DeployNetworkError", `Could not reach ${url}: ${err?.message || err}`);
3511
+ }
3512
+ const text = await res.text();
3513
+ let data;
3514
+ try {
3515
+ data = JSON.parse(text);
3516
+ } catch {
3517
+ data = {
3518
+ message: text
3519
+ };
3520
+ }
3521
+ if (!res.ok) return deploy_fail("DeployError", `submit failed (${res.status}): ${data?.message || text || "unknown error"}`);
3522
+ return JSON.stringify({
3523
+ mode: "platform",
3524
+ dryRun,
3525
+ ...data
3526
+ });
3527
+ }
3528
+ const SAFE_CEILING_MS = 50000;
3149
3529
  const wait_schema = {
3150
3530
  name: "extension_wait",
3151
- description: "Wait for a running dev or start session to be ready. Polls the ready.json contract file and returns structured status.",
3531
+ description: "Wait for a running dev or start session to be ready. Polls the ready.json contract file and returns structured status. A single call is bounded to ~50s to stay under the MCP client request timeout; if it returns status:'timeout', call it again to keep waiting (polling resumes on the same contract).",
3152
3532
  inputSchema: {
3153
3533
  type: "object",
3154
3534
  properties: {
@@ -3162,8 +3542,8 @@ const wait_schema = {
3162
3542
  },
3163
3543
  timeout: {
3164
3544
  type: "number",
3165
- default: 60000,
3166
- description: "Timeout in milliseconds"
3545
+ default: 45000,
3546
+ description: "Requested wait in milliseconds. Clamped to ~50s per call so it never exceeds the MCP client request timeout; call again to keep waiting for slower builds."
3167
3547
  }
3168
3548
  },
3169
3549
  required: [
@@ -3173,7 +3553,9 @@ const wait_schema = {
3173
3553
  };
3174
3554
  async function wait_handler(args) {
3175
3555
  const { browser } = resolveSessionBrowser(args.projectPath, args.browser, "chrome");
3176
- const timeout = args.timeout ?? 60000;
3556
+ const requested = args.timeout ?? 45000;
3557
+ const timeout = Math.min(Math.max(requested, 1000), SAFE_CEILING_MS);
3558
+ const clamped = requested > SAFE_CEILING_MS;
3177
3559
  const readyPath = node_path.resolve(args.projectPath, "dist", "extension-js", browser, "ready.json");
3178
3560
  const start = Date.now();
3179
3561
  const pollInterval = 1000;
@@ -3206,9 +3588,11 @@ async function wait_handler(args) {
3206
3588
  }
3207
3589
  return JSON.stringify({
3208
3590
  status: "timeout",
3209
- message: `Extension did not become ready within ${timeout}ms`,
3591
+ message: `Extension not ready after ${timeout}ms this call`,
3210
3592
  readyPath,
3211
- hint: "The dev session may still be building. Try increasing the timeout, or check if the dev process is still running."
3593
+ waitDuration: Date.now() - start,
3594
+ clamped: clamped ? `requested ${requested}ms was clamped to ${SAFE_CEILING_MS}ms to stay under the MCP client request timeout` : void 0,
3595
+ hint: "Still building — call extension_wait again to keep waiting (it resumes polling the same contract). If it never readies, check the dev process with extension_doctor."
3212
3596
  });
3213
3597
  }
3214
3598
  const add_feature_schema = {
@@ -3378,7 +3762,23 @@ async function add_feature_handler(args) {
3378
3762
  });
3379
3763
  const template = await getTemplateBySlug(templateSlug);
3380
3764
  const referenceFiles = template?.keyFiles ?? template?.files ?? [];
3381
- const featureDir = "content-script" === args.feature ? "content" : args.feature;
3765
+ const FEATURE_DIR = {
3766
+ "content-script": "content",
3767
+ popup: "action"
3768
+ };
3769
+ const featureDir = FEATURE_DIR[args.feature] ?? args.feature;
3770
+ const scriptExt = "react" === framework || "preact" === framework ? "tsx" : "ts";
3771
+ const componentExt = "vue" === framework ? "vue" : "svelte" === framework ? "svelte" : "tsx";
3772
+ const COMPONENT_BASE = {
3773
+ content: "Content",
3774
+ sidebar: "Sidebar",
3775
+ action: "Action",
3776
+ newtab: "NewTab",
3777
+ options: "Options",
3778
+ devtools: "DevTools",
3779
+ background: "Background"
3780
+ };
3781
+ const componentBase = COMPONENT_BASE[featureDir] ?? featureDir.charAt(0).toUpperCase() + featureDir.slice(1);
3382
3782
  const filesToCreate = [];
3383
3783
  const manifestUpdates = MANIFEST_ADDITIONS[args.feature] ?? {};
3384
3784
  if ([
@@ -3392,24 +3792,42 @@ async function add_feature_handler(args) {
3392
3792
  path: `src/${featureDir}/index.html`,
3393
3793
  hint: "HTML entry point"
3394
3794
  }, {
3395
- path: `src/${featureDir}/scripts.${"vanilla" === framework ? "ts" : "tsx"}`,
3795
+ path: `src/${featureDir}/scripts.${scriptExt}`,
3396
3796
  hint: "vanilla" === framework ? "Script entry point" : `${framework} mount point`
3397
3797
  }, {
3398
3798
  path: `src/${featureDir}/styles.css`,
3399
3799
  hint: "Stylesheet"
3400
3800
  });
3401
- if ("vanilla" !== framework) filesToCreate.push({
3402
- path: `src/${featureDir}/${featureDir.charAt(0).toUpperCase() + featureDir.slice(1)}App.tsx`,
3403
- hint: `Main ${framework} component`
3801
+ if ("vanilla" !== framework) {
3802
+ filesToCreate.push({
3803
+ path: `src/${featureDir}/${componentBase}App.${componentExt}`,
3804
+ hint: `Main ${framework} component`
3805
+ });
3806
+ if ("vue" === framework) filesToCreate.push({
3807
+ path: `src/${featureDir}/shims-vue.d.ts`,
3808
+ hint: "Vue SFC type shim (lets TS import .vue components)"
3809
+ });
3810
+ }
3811
+ }
3812
+ if ("content-script" === args.feature) {
3813
+ filesToCreate.push({
3814
+ path: "src/content/scripts.ts",
3815
+ hint: "vanilla" === framework ? "Content script entry point" : `${framework} content-script mount point`
3816
+ }, {
3817
+ path: "src/content/styles.css",
3818
+ hint: "Content script styles"
3404
3819
  });
3820
+ if ("vanilla" !== framework) {
3821
+ filesToCreate.push({
3822
+ path: `src/content/ContentApp.${componentExt}`,
3823
+ hint: `Main ${framework} component mounted by the content script`
3824
+ });
3825
+ if ("vue" === framework) filesToCreate.push({
3826
+ path: "src/content/shims-vue.d.ts",
3827
+ hint: "Vue SFC type shim (lets TS import .vue components)"
3828
+ });
3829
+ }
3405
3830
  }
3406
- if ("content-script" === args.feature) filesToCreate.push({
3407
- path: "src/content/scripts.ts",
3408
- hint: "Content script entry point"
3409
- }, {
3410
- path: "src/content/styles.css",
3411
- hint: "Content script styles"
3412
- });
3413
3831
  if ("background" === args.feature) filesToCreate.push({
3414
3832
  path: "src/background.ts",
3415
3833
  hint: "Background service worker / script"
@@ -3512,9 +3930,102 @@ async function pollForToken(args) {
3512
3930
  reason: "pending"
3513
3931
  };
3514
3932
  }
3933
+ async function requestDeviceCode(args) {
3934
+ const doFetch = args.fetchImpl ?? fetch;
3935
+ const res = await doFetch(`${args.apiBase}${args.path}`, {
3936
+ method: "POST",
3937
+ headers: {
3938
+ "content-type": "application/json",
3939
+ accept: "application/json"
3940
+ },
3941
+ body: JSON.stringify({
3942
+ project: args.project,
3943
+ clientName: args.clientName ?? "extension-mcp"
3944
+ })
3945
+ });
3946
+ const text = await res.text();
3947
+ let data;
3948
+ try {
3949
+ data = JSON.parse(text);
3950
+ } catch {
3951
+ data = {
3952
+ message: text
3953
+ };
3954
+ }
3955
+ if (!res.ok) throw new Error(`Device code request failed (${res.status}): ${data.message || "unknown error"}`);
3956
+ const deviceCode = String(data.device_code || "").trim();
3957
+ const userCode = String(data.user_code || "").trim();
3958
+ if (!deviceCode || !userCode) throw new Error("Device code response missing device_code/user_code.");
3959
+ return {
3960
+ deviceCode,
3961
+ userCode,
3962
+ verificationUri: String(data.verification_uri || "https://extension.dev/device"),
3963
+ verificationUriComplete: String(data.verification_uri_complete || data.verification_uri || ""),
3964
+ interval: Number(data.interval || 5),
3965
+ expiresIn: Number(data.expires_in || 900)
3966
+ };
3967
+ }
3968
+ async function pollDeviceToken(args) {
3969
+ const doFetch = args.fetchImpl ?? fetch;
3970
+ const deadline = Date.now() + args.budgetMs;
3971
+ let interval = Math.max(1, args.interval);
3972
+ for(;;){
3973
+ const res = await doFetch(`${args.apiBase}${args.path}`, {
3974
+ method: "POST",
3975
+ headers: {
3976
+ "content-type": "application/json",
3977
+ accept: "application/json"
3978
+ },
3979
+ body: JSON.stringify({
3980
+ device_code: args.deviceCode,
3981
+ project: args.project
3982
+ })
3983
+ });
3984
+ const text = await res.text();
3985
+ let data;
3986
+ try {
3987
+ data = JSON.parse(text);
3988
+ } catch {
3989
+ data = {
3990
+ message: text
3991
+ };
3992
+ }
3993
+ if (res.ok && data.token) {
3994
+ const creds = persistTokenResponse({
3995
+ apiBase: args.apiBase,
3996
+ data,
3997
+ provider: "extensiondev"
3998
+ });
3999
+ return {
4000
+ ok: true,
4001
+ creds
4002
+ };
4003
+ }
4004
+ const error = String(data.error || "");
4005
+ if ("access_denied" === error) return {
4006
+ ok: false,
4007
+ reason: "denied"
4008
+ };
4009
+ if ("expired_token" === error) return {
4010
+ ok: false,
4011
+ reason: "expired"
4012
+ };
4013
+ if ("slow_down" === error) interval += 5;
4014
+ else if (error && "authorization_pending" !== error) return {
4015
+ ok: false,
4016
+ reason: "error",
4017
+ message: String(data.message || error)
4018
+ };
4019
+ if (Date.now() + 1000 * interval >= deadline) return {
4020
+ ok: false,
4021
+ reason: "pending"
4022
+ };
4023
+ await new Promise((r)=>setTimeout(r, 1000 * interval));
4024
+ }
4025
+ }
3515
4026
  const login_schema = {
3516
4027
  name: "extension_login",
3517
- description: "Authenticate to extension.dev via GitHub device-code and store a project-scoped access token locally so extension_publish can use it. Two-phase: call with `project` to get a code + URL for the user to authorize, then call again with the returned `deviceCode` to finish. Never returns the token. This is the only tool besides extension_publish that talks to the hosted platform.",
4028
+ description: "Authenticate to extension.dev and store a project-scoped access token locally so extension_publish can use it. Two-phase: call with `project` to get a code + URL for the user to authorize, then call again with the returned `deviceCode` to finish. The server picks the flow: the extension.dev-gated device flow (you authorize at extension.dev/device; GitHub federation happens server-side, no GitHub token on this machine) or, as a fallback, the legacy GitHub device flow. Never returns the token. This is the only tool besides extension_publish that talks to the hosted platform.",
3518
4029
  inputSchema: {
3519
4030
  type: "object",
3520
4031
  properties: {
@@ -3577,6 +4088,52 @@ async function login_handler(args) {
3577
4088
  } catch (err) {
3578
4089
  return login_fail("LoginConfigError", err?.message || "Could not load login config.");
3579
4090
  }
4091
+ if ("extensiondev" === config.provider) {
4092
+ if (args.deviceCode) {
4093
+ const poll = await pollDeviceToken({
4094
+ apiBase,
4095
+ path: config.deviceTokenUrl,
4096
+ project,
4097
+ deviceCode: String(args.deviceCode),
4098
+ interval: 5,
4099
+ budgetMs: RESUME_BUDGET_MS
4100
+ });
4101
+ if (poll.ok) return success(poll.creds);
4102
+ if ("expired" === poll.reason) return login_fail("LoginExpired", "The device code expired. Run extension_login again to restart.");
4103
+ if ("denied" === poll.reason) return login_fail("LoginDenied", "Authorization was denied at extension.dev/device.");
4104
+ if ("error" === poll.reason) return login_fail("LoginError", poll.message || "Device login failed.");
4105
+ return login_pending({
4106
+ deviceCode: String(args.deviceCode),
4107
+ userCode: "(see the previous response)",
4108
+ verificationUri: config.verificationUri
4109
+ });
4110
+ }
4111
+ let start;
4112
+ try {
4113
+ start = await requestDeviceCode({
4114
+ apiBase,
4115
+ path: config.deviceCodeUrl,
4116
+ project
4117
+ });
4118
+ } catch (err) {
4119
+ return login_fail("LoginStartError", err?.message || "Could not start the device flow.");
4120
+ }
4121
+ const poll = await pollDeviceToken({
4122
+ apiBase,
4123
+ path: config.deviceTokenUrl,
4124
+ project,
4125
+ deviceCode: start.deviceCode,
4126
+ interval: start.interval,
4127
+ budgetMs: FIRST_CALL_BUDGET_MS
4128
+ });
4129
+ if (poll.ok) return success(poll.creds);
4130
+ if ("denied" === poll.reason) return login_fail("LoginDenied", "Authorization was denied at extension.dev/device.");
4131
+ return login_pending({
4132
+ deviceCode: start.deviceCode,
4133
+ userCode: start.userCode,
4134
+ verificationUri: start.verificationUri
4135
+ });
4136
+ }
3580
4137
  if (args.deviceCode) {
3581
4138
  const poll = await pollForToken({
3582
4139
  clientId: config.clientId,
@@ -3655,6 +4212,7 @@ async function whoami_handler() {
3655
4212
  workspaceSlug: creds.workspaceSlug,
3656
4213
  projectSlug: creds.projectSlug,
3657
4214
  api: creds.api,
4215
+ provider: creds.provider ?? "github",
3658
4216
  expiresAt: creds.expiresAt ? new Date(1000 * creds.expiresAt).toISOString() : null,
3659
4217
  expiresInSeconds: creds.expiresAt ? creds.expiresAt - now : null,
3660
4218
  expired,
@@ -3679,7 +4237,7 @@ async function logout_handler() {
3679
4237
  }
3680
4238
  const install_browser_schema = {
3681
4239
  name: "extension_install_browser",
3682
- description: "Install a managed browser binary for extension testing. Useful in CI, Docker, or fresh environments where browsers are not pre-installed.",
4240
+ description: "Install a managed browser binary for extension testing. Useful in CI, Docker, or fresh environments where browsers are not pre-installed. This downloads ~580-625 MB in a single blocking call (30s+ on a fast link); on a slow network it can exceed a client's default request timeout, so allow a generous timeout when calling it.",
3683
4241
  inputSchema: {
3684
4242
  type: "object",
3685
4243
  properties: {
@@ -4155,35 +4713,70 @@ async function detect_browsers_handler(args) {
4155
4713
  }
4156
4714
  const doctor_schema = {
4157
4715
  name: "extension_doctor",
4158
- description: "Diagnose a dev session end-to-end: ready contract, dev-server process, control-port agreement, control channel, eval token, executor, and browser liveness. Returns one {check, status, detail, remediation?} entry per leg in dependency order — a 'skip' names the check that blocked it and is NOT a pass. Run this first when any act tool (storage/reload/eval/open) errors unexpectedly. Wraps `extension doctor`.",
4716
+ description: "Diagnose a dev session end-to-end: ready contract, dev-server process, control-port agreement, control channel, eval token, executor, and browser liveness. Returns one {check, status, detail, remediation?} entry per leg in dependency order — a 'skip' names the check that blocked it and is NOT a pass. Run this first when any act tool (storage/reload/eval/open) errors unexpectedly. Wraps `extension doctor`. Call with no projectPath for a pre-flight environment check (node, extension CLI, template cache) before any project exists.",
4159
4717
  inputSchema: {
4160
4718
  type: "object",
4161
4719
  properties: {
4162
4720
  projectPath: {
4163
4721
  type: "string",
4164
- description: "Path to the extension project root"
4722
+ description: "Path to the extension project root. Omit for a pre-flight environment check with no project."
4165
4723
  },
4166
4724
  browser: {
4167
4725
  type: "string",
4168
4726
  description: "Browser session to diagnose. Defaults to the active dev session's browser for this project."
4169
4727
  }
4170
- },
4171
- required: [
4172
- "projectPath"
4173
- ]
4728
+ }
4174
4729
  }
4175
4730
  };
4731
+ async function environmentPreflight() {
4732
+ const checks = [];
4733
+ const nodeMajor = Number(process.versions.node.split(".")[0]);
4734
+ checks.push({
4735
+ check: "node",
4736
+ status: nodeMajor >= 20 ? "pass" : "fail",
4737
+ detail: `Node ${process.versions.node} on ${process.platform}/${process.arch}`,
4738
+ remediation: nodeMajor >= 20 ? void 0 : "Extension.js needs Node >= 20.18."
4739
+ });
4740
+ const { code, stdout, stderr } = await runExtensionCli([
4741
+ "--version"
4742
+ ], {
4743
+ timeoutMs: 60000
4744
+ });
4745
+ const cliVersion = stdout.trim() || stderr.trim();
4746
+ checks.push({
4747
+ check: "extension-cli",
4748
+ status: 0 === code ? "pass" : "fail",
4749
+ detail: 0 === code ? `extension CLI resolvable (${cliVersion})` : "extension CLI could not be resolved",
4750
+ remediation: 0 === code ? void 0 : "Install locally (npm i -D extension) or rely on npx; check network access to the npm registry."
4751
+ });
4752
+ const cacheFile = node_path.join(node_os.homedir(), ".cache", "extension-js", "templates-meta.json");
4753
+ const cacheExists = node_fs.existsSync(cacheFile);
4754
+ checks.push({
4755
+ check: "template-cache",
4756
+ status: cacheExists ? "pass" : "warn",
4757
+ detail: cacheExists ? `Template catalog cached at ${cacheFile}` : "Template catalog not cached yet (first extension_list_templates will fetch it)"
4758
+ });
4759
+ const healthy = checks.every((c)=>"fail" !== c.status);
4760
+ return JSON.stringify({
4761
+ mode: "environment",
4762
+ healthy,
4763
+ checks,
4764
+ hint: "Pass projectPath to diagnose a live dev session end-to-end."
4765
+ });
4766
+ }
4176
4767
  async function doctor_handler(args) {
4177
- const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
4768
+ if (!args.projectPath) return environmentPreflight();
4769
+ const projectPath = args.projectPath;
4770
+ const { browser } = resolveSessionBrowser(projectPath, args.browser);
4178
4771
  const { code, stdout, stderr } = await runExtensionCli([
4179
4772
  "doctor",
4180
- args.projectPath,
4773
+ projectPath,
4181
4774
  "--browser",
4182
4775
  browser,
4183
4776
  "--output",
4184
4777
  "json"
4185
4778
  ], {
4186
- cwd: args.projectPath
4779
+ cwd: projectPath
4187
4780
  });
4188
4781
  const out = stdout.trim();
4189
4782
  try {
@@ -4295,6 +4888,7 @@ const tools = [
4295
4888
  dom_inspect_namespaceObject,
4296
4889
  tools_publish_namespaceObject,
4297
4890
  release_promote_namespaceObject,
4891
+ deploy_namespaceObject,
4298
4892
  wait_namespaceObject,
4299
4893
  add_feature_namespaceObject,
4300
4894
  login_namespaceObject,
@@ -4428,6 +5022,31 @@ async function runCli(cmd, args) {
4428
5022
  const apiBase = resolveApiBase(flag("api"));
4429
5023
  try {
4430
5024
  const config = await fetchLoginConfig(apiBase);
5025
+ if ("extensiondev" === config.provider) {
5026
+ const start = await requestDeviceCode({
5027
+ apiBase,
5028
+ path: config.deviceCodeUrl,
5029
+ project
5030
+ });
5031
+ log("");
5032
+ log(` Open ${start.verificationUri} and enter code: ${start.userCode}`);
5033
+ log("");
5034
+ log(" Waiting for authorization...");
5035
+ const poll = await pollDeviceToken({
5036
+ apiBase,
5037
+ path: config.deviceTokenUrl,
5038
+ project,
5039
+ deviceCode: start.deviceCode,
5040
+ interval: start.interval,
5041
+ budgetMs: 1000 * start.expiresIn
5042
+ });
5043
+ if (!poll.ok) {
5044
+ log("denied" === poll.reason ? "Authorization was denied at extension.dev/device." : "expired" === poll.reason ? "The device code expired. Run login again." : "Timed out waiting for authorization. Run login again.");
5045
+ return 1;
5046
+ }
5047
+ log(`Logged in to ${poll.creds.workspaceSlug}/${poll.creds.projectSlug}.`);
5048
+ return 0;
5049
+ }
4431
5050
  const start = await startDeviceCode({
4432
5051
  clientId: config.clientId,
4433
5052
  scope: config.scope