@extension.dev/mcp 4.6.0 → 4.8.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,17 +2,16 @@ 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";
10
+ import { execFile, execFileSync } from "node:child_process";
11
11
  import { filterKeysForThisBrowser } from "browser-extension-manifest-fields";
12
12
  import ws from "ws";
13
13
  import node_http from "node:http";
14
14
  import { extensionInstall, extensionUninstall, getManagedBrowsersCacheRoot } from "extension-install";
15
- import { execFile } from "node:child_process";
16
15
  import { promisify } from "node:util";
17
16
  var add_feature_namespaceObject = {};
18
17
  __webpack_require__.r(add_feature_namespaceObject);
@@ -35,7 +34,6 @@ __webpack_require__.d(create_namespaceObject, {
35
34
  var deploy_namespaceObject = {};
36
35
  __webpack_require__.r(deploy_namespaceObject);
37
36
  __webpack_require__.d(deploy_namespaceObject, {
38
- buildDeployArgs: ()=>buildDeployArgs,
39
37
  handler: ()=>deploy_handler,
40
38
  schema: ()=>deploy_schema
41
39
  });
@@ -201,7 +199,33 @@ __webpack_require__.d(whoami_namespaceObject, {
201
199
  handler: ()=>whoami_handler,
202
200
  schema: ()=>whoami_schema
203
201
  });
204
- var package_namespaceObject = JSON.parse('{"rE":"4.5.0","El":{"OP":"^4.0.11"}}');
202
+ var package_namespaceObject = JSON.parse('{"rE":"4.7.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
+ }
205
229
  const create_schema = {
206
230
  name: "extension_create",
207
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.",
@@ -219,7 +243,7 @@ const create_schema = {
219
243
  template: {
220
244
  type: "string",
221
245
  default: "typescript",
222
- 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."
223
247
  },
224
248
  install: {
225
249
  type: "boolean",
@@ -235,30 +259,47 @@ const create_schema = {
235
259
  async function create_handler(args) {
236
260
  const start = Date.now();
237
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);
238
268
  try {
239
269
  const result = await extensionCreate(projectInput, {
240
270
  template: args.template ?? "typescript",
241
271
  install: args.install ?? true,
242
272
  logger: {
243
- log: ()=>{},
244
- error: ()=>{}
273
+ log: capture("log"),
274
+ error: capture("error")
245
275
  }
246
276
  });
277
+ const packageManager = result.depsInstalled ? detectPackageManager(result.projectPath) : "npm";
278
+ const runDev = `${packageManager} run dev`;
247
279
  return JSON.stringify({
248
280
  projectPath: result.projectPath,
249
281
  projectName: result.projectName,
250
282
  template: result.template,
251
283
  depsInstalled: result.depsInstalled,
284
+ packageManager: result.depsInstalled ? packageManager : null,
252
285
  duration: Date.now() - start,
253
- nextSteps: [
286
+ nextSteps: result.depsInstalled ? [
254
287
  `cd ${result.projectPath}`,
288
+ runDev
289
+ ] : [
290
+ `cd ${result.projectPath}`,
291
+ "npm install",
255
292
  "npm run dev"
256
- ]
293
+ ],
294
+ ...result.depsInstalled ? {} : {
295
+ warnings: logTail()
296
+ }
257
297
  });
258
298
  } catch (err) {
259
299
  return JSON.stringify({
260
300
  error: err instanceof Error ? err.message : String(err),
261
- duration: Date.now() - start
301
+ duration: Date.now() - start,
302
+ log: logTail()
262
303
  });
263
304
  }
264
305
  }
@@ -299,8 +340,49 @@ async function listTemplates(filters) {
299
340
  if (filters?.tags?.length) templates = templates.filter((t)=>filters.tags.some((tag)=>t.tags?.includes(tag) || t.aiRecommendFor?.includes(tag)));
300
341
  if (filters?.featured) templates = templates.filter((t)=>t.featured);
301
342
  if (filters?.query) {
302
- const q = filters.query.toLowerCase();
303
- 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);
304
386
  }
305
387
  return templates;
306
388
  }
@@ -310,7 +392,7 @@ async function getTemplateBySlug(slug) {
310
392
  }
311
393
  const list_templates_schema = {
312
394
  name: "extension_list_templates",
313
- 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.",
314
396
  inputSchema: {
315
397
  type: "object",
316
398
  properties: {
@@ -319,13 +401,10 @@ const list_templates_schema = {
319
401
  enum: [
320
402
  "content",
321
403
  "sidebar",
322
- "action",
323
404
  "newtab",
324
- "devtools",
325
- "options",
326
405
  "background"
327
406
  ],
328
- 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."
329
408
  },
330
409
  framework: {
331
410
  type: "string",
@@ -336,7 +415,7 @@ const list_templates_schema = {
336
415
  "preact",
337
416
  ""
338
417
  ],
339
- 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."
340
419
  },
341
420
  tags: {
342
421
  type: "array",
@@ -351,7 +430,7 @@ const list_templates_schema = {
351
430
  },
352
431
  query: {
353
432
  type: "string",
354
- 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."
355
434
  }
356
435
  }
357
436
  }
@@ -375,112 +454,6 @@ async function list_templates_handler(args) {
375
454
  templates: results
376
455
  });
377
456
  }
378
- const build_schema = {
379
- name: "extension_build",
380
- description: "Build a browser extension for production. Outputs to dist/<browser>/. Optionally creates .zip for store submission.",
381
- inputSchema: {
382
- type: "object",
383
- properties: {
384
- projectPath: {
385
- type: "string",
386
- description: "Path to the extension project root"
387
- },
388
- browser: {
389
- type: "string",
390
- enum: [
391
- "chrome",
392
- "chromium",
393
- "edge",
394
- "brave",
395
- "opera",
396
- "vivaldi",
397
- "yandex",
398
- "firefox",
399
- "waterfox",
400
- "librewolf",
401
- "safari",
402
- "chromium-based",
403
- "gecko-based",
404
- "firefox-based",
405
- "webkit-based"
406
- ],
407
- default: "chrome",
408
- description: "Target browser"
409
- },
410
- zip: {
411
- type: "boolean",
412
- default: false,
413
- description: "Create a .zip file for store distribution"
414
- },
415
- zipSource: {
416
- type: "boolean",
417
- default: false,
418
- description: "Include source code zip (required by some stores)"
419
- },
420
- zipFilename: {
421
- type: "string",
422
- description: "Custom .zip file name (defaults to name and version)"
423
- },
424
- polyfill: {
425
- type: "boolean",
426
- default: false,
427
- description: "Apply cross-browser polyfill"
428
- },
429
- silent: {
430
- type: "boolean",
431
- default: false,
432
- description: "Suppress build output"
433
- },
434
- mode: {
435
- type: "string",
436
- enum: [
437
- "development",
438
- "production",
439
- "none"
440
- ],
441
- default: "production",
442
- description: "Bundler mode override (also sets NODE_ENV)"
443
- }
444
- },
445
- required: [
446
- "projectPath"
447
- ]
448
- }
449
- };
450
- async function build_handler(args) {
451
- const start = Date.now();
452
- try {
453
- const summary = await extensionBuild(args.projectPath, {
454
- browser: args.browser ?? "chrome",
455
- zip: args.zip ?? false,
456
- zipSource: args.zipSource ?? false,
457
- ...args.zipFilename ? {
458
- zipFilename: args.zipFilename
459
- } : {},
460
- ...void 0 !== args.polyfill ? {
461
- polyfill: args.polyfill
462
- } : {},
463
- ...void 0 !== args.silent ? {
464
- silent: args.silent
465
- } : {},
466
- ...args.mode ? {
467
- mode: args.mode
468
- } : {},
469
- exitOnError: false
470
- });
471
- return JSON.stringify({
472
- success: true,
473
- ...summary,
474
- duration: Date.now() - start
475
- });
476
- } catch (err) {
477
- return JSON.stringify({
478
- error: err instanceof Error ? err.message : String(err),
479
- duration: Date.now() - start,
480
- hint: "Check that the project has a valid src/manifest.json and all dependencies are installed."
481
- });
482
- }
483
- }
484
457
  const PINNED_CLI_VERSION = String(package_namespaceObject.El.OP ?? "latest").replace(/^[\^~]/, "");
485
458
  function pinnedCliVersion() {
486
459
  const override = String(process.env.EXTENSION_MCP_CLI_VERSION || "").trim();
@@ -562,6 +535,126 @@ function spawnExtensionCli(args, options) {
562
535
  child.unref();
563
536
  return child;
564
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
+ }
565
658
  const process_manager_sessions = new Map();
566
659
  function sessionKey(projectPath, browser) {
567
660
  return `${node_path.resolve(projectPath)}::${browser}`;
@@ -676,7 +769,7 @@ const dev_schema = {
676
769
  allowEval: {
677
770
  type: "boolean",
678
771
  default: false,
679
- description: "Additionally enable extension_eval (runs code in a context; writes a 0600 session token)"
772
+ description: "Enable extension_eval (runs code in a context; writes a 0600 session token). Implies allowControl, so a single allowEval: true also unlocks storage/reload/open/dom_inspect — you do not need to pass both."
680
773
  }
681
774
  },
682
775
  required: [
@@ -686,6 +779,7 @@ const dev_schema = {
686
779
  };
687
780
  async function dev_handler(args) {
688
781
  const browser = args.browser ?? "chrome";
782
+ const allowControl = Boolean(args.allowControl || args.allowEval);
689
783
  const cliArgs = [
690
784
  "dev",
691
785
  args.projectPath,
@@ -696,7 +790,7 @@ async function dev_handler(args) {
696
790
  if (args.noBrowser) cliArgs.push("--no-browser");
697
791
  if (false === args.polyfill) cliArgs.push("--polyfill", "false");
698
792
  cliArgs.push(...launchFlagArgs(args));
699
- if (args.allowControl) cliArgs.push("--allow-control");
793
+ if (allowControl) cliArgs.push("--allow-control");
700
794
  if (args.allowEval) cliArgs.push("--allow-eval");
701
795
  const child = spawnExtensionCli(cliArgs, {
702
796
  projectDir: args.projectPath
@@ -719,16 +813,34 @@ async function dev_handler(args) {
719
813
  await new Promise((resolve)=>setTimeout(resolve, 3000));
720
814
  child.stdout?.off("data", collector);
721
815
  child.stderr?.off("data", collector);
816
+ const controlVerbs = "storage, reload, open, dom_inspect";
817
+ const capabilities = {
818
+ allowControl,
819
+ allowEval: Boolean(args.allowEval),
820
+ unlocked: allowControl ? args.allowEval ? `${controlVerbs}, eval` : controlVerbs : "none (read-only: logs, source_inspect, wait, doctor)"
821
+ };
722
822
  return JSON.stringify({
723
823
  pid,
724
824
  browser,
725
825
  port: args.port ?? 8080,
726
826
  projectPath: args.projectPath,
727
827
  status: "started",
728
- 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.",
729
- earlyOutput: earlyOutput.slice(0, 500)
828
+ capabilities,
829
+ hint: "Use extension_wait to check when the extension is fully loaded, then extension_source_inspect to inspect the live state. " + (allowControl ? `Control channel is ON: extension_${controlVerbs.split(", ").join("/extension_")}${args.allowEval ? "/extension_eval" : ""} will work against this session.` : "Control channel is OFF: extension_storage/reload/open/dom_inspect need allowControl: true, and extension_eval needs allowEval: true (which also implies allowControl). Restart extension_dev with the flag you need.") + " When you are done, call extension_stop to shut down the dev server and browser.",
830
+ earlyOutput: denoiseEarlyOutput(earlyOutput).slice(0, 500)
730
831
  });
731
832
  }
833
+ function denoiseEarlyOutput(raw) {
834
+ const NOISE = [
835
+ /^npm warn Unknown project config/i,
836
+ /This will stop working in the next major version of npm/i,
837
+ /^npm warn config/i,
838
+ /V8: .*Invalid asm\.js/i,
839
+ /^\(node:\d+\) V8:/i,
840
+ /Use `node --trace-warnings/i
841
+ ];
842
+ return raw.split("\n").filter((line)=>!NOISE.some((re)=>re.test(line.trim()))).join("\n").replace(/\n{2,}/g, "\n").trimStart();
843
+ }
732
844
  const start_schema = {
733
845
  name: "extension_start",
734
846
  description: "Build the extension for production and immediately preview it in a browser. Combines build + preview in one step. No hot reload.",
@@ -899,6 +1011,66 @@ async function preview_handler(args) {
899
1011
  hint: "Call extension_stop when you are done to close the preview browser."
900
1012
  });
901
1013
  }
1014
+ function contractSightings(projectPath) {
1015
+ const root = node_path.resolve(projectPath, "dist", "extension-js");
1016
+ let dirs;
1017
+ try {
1018
+ dirs = node_fs.readdirSync(root);
1019
+ } catch {
1020
+ return [];
1021
+ }
1022
+ const sightings = [];
1023
+ for (const dir of dirs){
1024
+ const readyPath = node_path.join(root, dir, "ready.json");
1025
+ try {
1026
+ const stat = node_fs.statSync(readyPath);
1027
+ const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
1028
+ if (contract?.status !== "ready") continue;
1029
+ sightings.push({
1030
+ browser: dir,
1031
+ mtimeMs: stat.mtimeMs,
1032
+ pid: "number" == typeof contract.pid ? contract.pid : void 0
1033
+ });
1034
+ } catch {}
1035
+ }
1036
+ return sightings;
1037
+ }
1038
+ function pidAlive(pid) {
1039
+ try {
1040
+ process.kill(pid, 0);
1041
+ return true;
1042
+ } catch {
1043
+ return false;
1044
+ }
1045
+ }
1046
+ function knownSessionBrowsers(projectPath) {
1047
+ const resolved = node_path.resolve(projectPath);
1048
+ const browsers = [];
1049
+ for (const session of listSessions())if (node_path.resolve(session.projectPath) === resolved) browsers.push(session.browser);
1050
+ for (const sighting of contractSightings(projectPath))if (void 0 === sighting.pid || pidAlive(sighting.pid)) browsers.push(sighting.browser);
1051
+ return Array.from(new Set(browsers));
1052
+ }
1053
+ function resolveSessionBrowser(projectPath, explicit, fallback = "chromium") {
1054
+ if (explicit) return {
1055
+ browser: explicit,
1056
+ source: "explicit"
1057
+ };
1058
+ const resolved = node_path.resolve(projectPath);
1059
+ const mine = listSessions().filter((s)=>node_path.resolve(s.projectPath) === resolved);
1060
+ if (mine.length > 0) return {
1061
+ browser: mine[mine.length - 1].browser,
1062
+ source: "session"
1063
+ };
1064
+ const sightings = contractSightings(projectPath).filter((s)=>void 0 === s.pid || pidAlive(s.pid)).sort((a, b)=>b.mtimeMs - a.mtimeMs);
1065
+ if (sightings.length > 0) return {
1066
+ browser: sightings[0].browser,
1067
+ source: "contract"
1068
+ };
1069
+ return {
1070
+ browser: fallback,
1071
+ source: "fallback"
1072
+ };
1073
+ }
902
1074
  const stop_schema = {
903
1075
  name: "extension_stop",
904
1076
  description: "Stop a running dev, start, or preview session: terminates the dev server and the browser it launched. Counterpart to extension_dev/extension_start. Call it when you are done verifying so sessions do not accumulate.",
@@ -911,8 +1083,7 @@ const stop_schema = {
911
1083
  },
912
1084
  browser: {
913
1085
  type: "string",
914
- default: "chrome",
915
- description: "Browser of the session to stop (matches the browser passed to extension_dev/extension_start)"
1086
+ description: "Browser of the session to stop (matches the browser passed to extension_dev/extension_start). Defaults to the one live session for this project when omitted, instead of assuming chrome."
916
1087
  },
917
1088
  all: {
918
1089
  type: "boolean",
@@ -923,6 +1094,27 @@ const stop_schema = {
923
1094
  required: []
924
1095
  }
925
1096
  };
1097
+ function profileProcessPids(projectPath, browser) {
1098
+ const marker = node_path.resolve(projectPath, "dist", `extension-profile-${browser}`);
1099
+ try {
1100
+ const out = execFileSync("pgrep", [
1101
+ "-f",
1102
+ marker
1103
+ ], {
1104
+ encoding: "utf8"
1105
+ });
1106
+ return out.split("\n").map((s)=>parseInt(s.trim(), 10)).filter((n)=>Number.isInteger(n) && n > 0 && n !== process.pid);
1107
+ } catch {
1108
+ return [];
1109
+ }
1110
+ }
1111
+ function reapProfileProcesses(projectPath, browser) {
1112
+ const pids = profileProcessPids(projectPath, browser);
1113
+ for (const pid of pids)try {
1114
+ process.kill(pid, "SIGKILL");
1115
+ } catch {}
1116
+ return pids;
1117
+ }
926
1118
  function isAlive(pid) {
927
1119
  try {
928
1120
  process.kill(pid, 0);
@@ -959,13 +1151,17 @@ function pidFromReadyContract(projectPath, browser) {
959
1151
  async function stopOne(projectPath, browser) {
960
1152
  const session = getSession(projectPath, browser);
961
1153
  const pid = session?.pid ?? pidFromReadyContract(projectPath, browser);
962
- if (null == pid) return {
963
- projectPath,
964
- browser,
965
- pid: null,
966
- stopped: false,
967
- detail: "No known session for this project/browser (nothing registered in this server and no ready.json contract found)."
968
- };
1154
+ if (null == pid) {
1155
+ const reaped = reapProfileProcesses(projectPath, browser);
1156
+ return {
1157
+ projectPath,
1158
+ browser,
1159
+ pid: null,
1160
+ stopped: 0 !== reaped.length,
1161
+ reaped,
1162
+ detail: 0 === reaped.length ? "No known session for this project/browser (nothing registered in this server and no ready.json contract found)." : `No dev pid on record, but reaped ${reaped.length} orphaned browser process(es) from the profile dir.`
1163
+ };
1164
+ }
969
1165
  let detail;
970
1166
  if (isAlive(pid)) {
971
1167
  signal(pid, "SIGTERM");
@@ -976,17 +1172,23 @@ async function stopOne(projectPath, browser) {
976
1172
  }
977
1173
  detail = isAlive(pid) ? "Sent SIGTERM and SIGKILL but the process still reports alive; it may be exiting." : "Terminated.";
978
1174
  } else detail = "Process was already gone; cleaned up session records.";
1175
+ const reaped = reapProfileProcesses(projectPath, browser);
979
1176
  removeSession(projectPath, browser);
980
1177
  try {
981
1178
  node_fs.rmSync(readyJsonPath(projectPath, browser), {
982
1179
  force: true
983
1180
  });
984
1181
  } catch {}
1182
+ const survivors = profileProcessPids(projectPath, browser);
1183
+ const stopped = !isAlive(pid) && 0 === survivors.length;
1184
+ if (survivors.length) detail += ` Warning: ${survivors.length} browser process(es) still alive after reap (pids ${survivors.join(", ")}).`;
1185
+ else if (reaped.length) detail += ` Reaped ${reaped.length} browser process(es).`;
985
1186
  return {
986
1187
  projectPath,
987
1188
  browser,
988
1189
  pid,
989
- stopped: !isAlive(pid),
1190
+ stopped,
1191
+ reaped,
990
1192
  detail
991
1193
  };
992
1194
  }
@@ -1006,7 +1208,8 @@ async function stop_handler(args) {
1006
1208
  if (!args.projectPath) return JSON.stringify({
1007
1209
  error: "projectPath is required unless all=true. Pass the same projectPath used with extension_dev/extension_start."
1008
1210
  });
1009
- const outcome = await stopOne(args.projectPath, args.browser ?? "chrome");
1211
+ const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
1212
+ const outcome = await stopOne(args.projectPath, browser);
1010
1213
  return JSON.stringify(outcome);
1011
1214
  }
1012
1215
  const RAW_BASE = "https://raw.githubusercontent.com/extension-js/examples/main/examples";
@@ -1018,7 +1221,7 @@ const get_template_source_schema = {
1018
1221
  properties: {
1019
1222
  slug: {
1020
1223
  type: "string",
1021
- description: "Template slug (e.g. 'sidebar-claude', 'content-react')"
1224
+ description: "Template slug (e.g. 'ai-claude', 'content-react')"
1022
1225
  },
1023
1226
  files: {
1024
1227
  type: "array",
@@ -1101,6 +1304,91 @@ function isChromiumFamily(browser) {
1101
1304
  function isGeckoFamily(browser) {
1102
1305
  return GECKO_FAMILY.has(browser);
1103
1306
  }
1307
+ const KNOWN_PERMISSIONS = new Set([
1308
+ "activeTab",
1309
+ "alarms",
1310
+ "background",
1311
+ "bookmarks",
1312
+ "browsingData",
1313
+ "certificateProvider",
1314
+ "clipboardRead",
1315
+ "clipboardWrite",
1316
+ "contentSettings",
1317
+ "contextMenus",
1318
+ "cookies",
1319
+ "debugger",
1320
+ "declarativeContent",
1321
+ "declarativeNetRequest",
1322
+ "declarativeNetRequestWithHostAccess",
1323
+ "declarativeNetRequestFeedback",
1324
+ "desktopCapture",
1325
+ "dns",
1326
+ "documentScan",
1327
+ "downloads",
1328
+ "downloads.open",
1329
+ "downloads.ui",
1330
+ "enterprise.deviceAttributes",
1331
+ "enterprise.hardwarePlatform",
1332
+ "enterprise.networkingAttributes",
1333
+ "enterprise.platformKeys",
1334
+ "favicon",
1335
+ "fileBrowserHandler",
1336
+ "fileSystemProvider",
1337
+ "fontSettings",
1338
+ "gcm",
1339
+ "geolocation",
1340
+ "history",
1341
+ "identity",
1342
+ "identity.email",
1343
+ "idle",
1344
+ "loginState",
1345
+ "management",
1346
+ "nativeMessaging",
1347
+ "notifications",
1348
+ "offscreen",
1349
+ "pageCapture",
1350
+ "power",
1351
+ "printerProvider",
1352
+ "printing",
1353
+ "printingMetrics",
1354
+ "privacy",
1355
+ "processes",
1356
+ "proxy",
1357
+ "readingList",
1358
+ "runtime",
1359
+ "scripting",
1360
+ "search",
1361
+ "sessions",
1362
+ "sidePanel",
1363
+ "storage",
1364
+ "system.cpu",
1365
+ "system.display",
1366
+ "system.memory",
1367
+ "system.storage",
1368
+ "tabCapture",
1369
+ "tabGroups",
1370
+ "tabs",
1371
+ "topSites",
1372
+ "tts",
1373
+ "ttsEngine",
1374
+ "unlimitedStorage",
1375
+ "vpnProvider",
1376
+ "wallpaper",
1377
+ "webAuthenticationProxy",
1378
+ "webNavigation",
1379
+ "webRequest",
1380
+ "webRequestBlocking",
1381
+ "webRequestAuthProvider",
1382
+ "browserSettings",
1383
+ "captivePortal",
1384
+ "contextualIdentities",
1385
+ "dns",
1386
+ "menus",
1387
+ "menus.overrideContext",
1388
+ "pkcs11",
1389
+ "theme",
1390
+ "webRequestFilterResponse"
1391
+ ]);
1104
1392
  const manifest_validate_schema = {
1105
1393
  name: "extension_manifest_validate",
1106
1394
  description: "Validate a manifest.json file for correctness across browsers. Reports missing fields, invalid permissions, and cross-browser compatibility issues.",
@@ -1109,7 +1397,11 @@ const manifest_validate_schema = {
1109
1397
  properties: {
1110
1398
  manifestPath: {
1111
1399
  type: "string",
1112
- description: "Path to manifest.json"
1400
+ description: "Path to manifest.json. Or pass projectPath and the manifest is located for you."
1401
+ },
1402
+ projectPath: {
1403
+ type: "string",
1404
+ description: "Path to the extension project root; manifest.json is resolved from it (root or src/). Accepted in place of manifestPath."
1113
1405
  },
1114
1406
  browsers: {
1115
1407
  type: "array",
@@ -1123,11 +1415,64 @@ const manifest_validate_schema = {
1123
1415
  description: "Browsers to validate against"
1124
1416
  }
1125
1417
  },
1126
- required: [
1127
- "manifestPath"
1128
- ]
1418
+ required: []
1129
1419
  }
1130
1420
  };
1421
+ function collectPathRefs(m) {
1422
+ const refs = [];
1423
+ const push = (v)=>{
1424
+ if ("string" == typeof v) refs.push(v);
1425
+ };
1426
+ const action = m.action || m.browser_action;
1427
+ if (action) {
1428
+ push(action.default_popup);
1429
+ if ("string" == typeof action.default_icon) push(action.default_icon);
1430
+ else if (action.default_icon) Object.values(action.default_icon).forEach(push);
1431
+ }
1432
+ const bg = m.background;
1433
+ if (bg) {
1434
+ push(bg.service_worker);
1435
+ push(bg.page);
1436
+ if (Array.isArray(bg.scripts)) bg.scripts.forEach(push);
1437
+ }
1438
+ if (m.icons) Object.values(m.icons).forEach(push);
1439
+ const cs = m.content_scripts;
1440
+ if (Array.isArray(cs)) for (const c of cs){
1441
+ if (Array.isArray(c.js)) c.js.forEach(push);
1442
+ if (Array.isArray(c.css)) c.css.forEach(push);
1443
+ }
1444
+ push(m.options_page);
1445
+ const oui = m.options_ui;
1446
+ if (oui) push(oui.page);
1447
+ const sp = m.side_panel;
1448
+ if (sp) push(sp.default_path);
1449
+ const sa = m.sidebar_action;
1450
+ if (sa) push(sa.default_panel);
1451
+ const cuo = m.chrome_url_overrides;
1452
+ if (cuo) Object.values(cuo).forEach(push);
1453
+ return refs;
1454
+ }
1455
+ function fileResolvesSomewhere(ref, roots) {
1456
+ if (!ref || ref.includes("*") || /^(https?:|data:)/i.test(ref)) return true;
1457
+ const clean = ref.replace(/^\.?\//, "");
1458
+ return roots.some((root)=>{
1459
+ try {
1460
+ return node_fs.existsSync(node_path.resolve(root, clean));
1461
+ } catch {
1462
+ return false;
1463
+ }
1464
+ });
1465
+ }
1466
+ function findManifest(projectPath) {
1467
+ for (const rel of [
1468
+ "manifest.json",
1469
+ node_path.join("src", "manifest.json")
1470
+ ]){
1471
+ const candidate = node_path.resolve(projectPath, rel);
1472
+ if (node_fs.existsSync(candidate)) return candidate;
1473
+ }
1474
+ return null;
1475
+ }
1131
1476
  async function manifest_validate_handler(args) {
1132
1477
  const browsers = args.browsers ?? [
1133
1478
  "chrome",
@@ -1140,9 +1485,20 @@ async function manifest_validate_handler(args) {
1140
1485
  browserSupport: {},
1141
1486
  similarTemplates: []
1142
1487
  };
1488
+ const manifestPath = args.manifestPath ?? (args.projectPath ? findManifest(args.projectPath) : null);
1489
+ if (!manifestPath) return JSON.stringify({
1490
+ valid: false,
1491
+ errors: [
1492
+ args.projectPath ? `No manifest.json found under ${args.projectPath} (looked in the root and src/).` : "Pass manifestPath (path to manifest.json) or projectPath (project root)."
1493
+ ],
1494
+ warnings: [],
1495
+ browserSupport: {},
1496
+ similarTemplates: []
1497
+ });
1498
+ const manifestDir = node_path.dirname(node_path.resolve(manifestPath));
1143
1499
  let manifest;
1144
1500
  try {
1145
- const raw = node_fs.readFileSync(node_path.resolve(args.manifestPath), "utf8");
1501
+ const raw = node_fs.readFileSync(node_path.resolve(manifestPath), "utf8");
1146
1502
  manifest = JSON.parse(raw);
1147
1503
  } catch (err) {
1148
1504
  return JSON.stringify({
@@ -1158,7 +1514,22 @@ async function manifest_validate_handler(args) {
1158
1514
  if (!manifest.name) result.errors.push("Missing required field: name");
1159
1515
  if (!manifest.version) result.warnings.push("Missing field: version (required for store submission)");
1160
1516
  const chromiumManifest = filterKeysForThisBrowser(manifest, "chrome");
1517
+ const roots = [
1518
+ manifestDir,
1519
+ node_path.join(manifestDir, "src"),
1520
+ ..."src" === node_path.basename(manifestDir) ? [
1521
+ node_path.dirname(manifestDir)
1522
+ ] : []
1523
+ ];
1524
+ for (const ref of new Set(collectPathRefs(chromiumManifest)))if (!fileResolvesSomewhere(ref, roots)) result.warnings.push(`Referenced file "${ref}" was not found near the manifest — this is the kind of dangling reference extension_build fails on. Verify with extension_build.`);
1161
1525
  if (!chromiumManifest.manifest_version) result.errors.push('Missing manifest_version. Use "chromium:manifest_version": 3 and "firefox:manifest_version": 2 for cross-browser support.');
1526
+ const declaredPerms = [
1527
+ ...chromiumManifest.permissions ?? [],
1528
+ ...chromiumManifest.optional_permissions ?? []
1529
+ ].filter((p)=>"string" == typeof p);
1530
+ for (const perm of declaredPerms)if (!(perm.includes("://") || perm.includes("*")) && "<all_urls>" !== perm) {
1531
+ if (!KNOWN_PERMISSIONS.has(perm)) result.warnings.push(`Unrecognized permission "${perm}" — check for a typo (host/match patterns belong in host_permissions, not permissions).`);
1532
+ }
1162
1533
  for (const browser of browsers){
1163
1534
  const isChromium = isChromiumFamily(browser);
1164
1535
  const isFirefox = isGeckoFamily(browser);
@@ -1196,9 +1567,15 @@ async function manifest_validate_handler(args) {
1196
1567
  if (chromiumManifest.action || manifest["firefox:browser_action"]) surfaces.push("action");
1197
1568
  if (chromiumManifest.chrome_url_overrides?.newtab) surfaces.push("newtab");
1198
1569
  if (chromiumManifest.background) surfaces.push("background");
1199
- if (surfaces.length) try {
1570
+ const distinctive = surfaces.filter((s)=>"background" !== s);
1571
+ const matchOn = distinctive.length ? distinctive : surfaces;
1572
+ if (matchOn.length) try {
1200
1573
  const templates = await listTemplates();
1201
- result.similarTemplates = templates.filter((t)=>t.surfaces.some((s)=>surfaces.includes(s))).slice(0, 5).map((t)=>({
1574
+ result.similarTemplates = templates.map((t)=>({
1575
+ slug: t.slug,
1576
+ surfaces: t.surfaces,
1577
+ score: t.surfaces.filter((s)=>matchOn.includes(s)).length
1578
+ })).filter((t)=>t.score > 0).sort((a, b)=>b.score - a.score).slice(0, 5).map((t)=>({
1202
1579
  slug: t.slug,
1203
1580
  surfaces: t.surfaces
1204
1581
  }));
@@ -1320,12 +1697,58 @@ async function inspect_handler(args) {
1320
1697
  byType[f.type].count++;
1321
1698
  byType[f.type].size += f.size;
1322
1699
  }
1700
+ const sourcemapSize = byType.sourcemap?.size ?? 0;
1701
+ const buildType = sourcemapSize > 0 ? "development" : "production";
1702
+ const shippableSize = totalSize - sourcemapSize;
1703
+ const sizeByPath = new Map(files.map((f)=>[
1704
+ f.path,
1705
+ f.size
1706
+ ]));
1707
+ const entrypoints = [];
1708
+ const addEntry = (role, ref)=>{
1709
+ if ("string" != typeof ref) return;
1710
+ const size = sizeByPath.get(ref.replace(/^\.?\//, ""));
1711
+ entrypoints.push({
1712
+ role,
1713
+ path: ref,
1714
+ present: void 0 !== size,
1715
+ ...void 0 !== size ? {
1716
+ sizeFormatted: formatBytes(size)
1717
+ } : {}
1718
+ });
1719
+ };
1720
+ const bg = manifest.background;
1721
+ if (bg?.service_worker) addEntry("background.service_worker", bg.service_worker);
1722
+ if (Array.isArray(bg?.scripts)) bg.scripts.forEach((s)=>addEntry("background.scripts", s));
1723
+ const actionField = manifest.action || manifest.browser_action;
1724
+ if (actionField?.default_popup) addEntry("action.default_popup", actionField.default_popup);
1725
+ const contentScripts = manifest.content_scripts;
1726
+ if (Array.isArray(contentScripts)) contentScripts.forEach((c, i)=>{
1727
+ if (Array.isArray(c.js)) c.js.forEach((j)=>addEntry(`content_scripts[${i}].js`, j));
1728
+ if (Array.isArray(c.css)) c.css.forEach((s)=>addEntry(`content_scripts[${i}].css`, s));
1729
+ });
1730
+ const PROMO_RE = /(screenshot|promo|marquee|tile|banner|preview)[-_.]?\d*\.(png|jpe?g|webp|gif)$/i;
1731
+ const sizeWarnings = [];
1732
+ for (const f of files)if ("sourcemap" !== f.type) {
1733
+ if (PROMO_RE.test(f.path)) sizeWarnings.push(`${f.path} (${formatBytes(f.size)}) looks like a store-listing promo image shipped inside the extension package — move it out of the bundled sources so it does not inflate the store zip.`);
1734
+ else if ("image" === f.type && !f.path.includes("icon") && f.size > 51200 && shippableSize > 0 && f.size / shippableSize > 0.25) sizeWarnings.push(`${f.path} (${formatBytes(f.size)}) is ${Math.round(f.size / shippableSize * 100)}% of the shipped bundle — unusually large for a shipped asset.`);
1735
+ }
1323
1736
  const result = {
1324
1737
  browser,
1325
1738
  distPath,
1739
+ entrypoints,
1740
+ ...sizeWarnings.length ? {
1741
+ sizeWarnings
1742
+ } : {},
1743
+ buildType,
1326
1744
  totalSize,
1327
1745
  totalSizeFormatted: formatBytes(totalSize),
1746
+ shippableSize,
1747
+ shippableSizeFormatted: formatBytes(shippableSize),
1328
1748
  fileCount: files.length,
1749
+ ..."development" === buildType ? {
1750
+ note: `This dist contains ${formatBytes(sourcemapSize)} of sourcemaps and looks like a dev build; run extension_build for production sizes. shippableSize excludes sourcemaps.`
1751
+ } : {},
1329
1752
  manifest: {
1330
1753
  name: manifest.name,
1331
1754
  version: manifest.version,
@@ -1356,6 +1779,7 @@ async function inspect_handler(args) {
1356
1779
  hasManifest: node_fs.existsSync(manifestPath),
1357
1780
  hasIcons: files.some((f)=>"image" === f.type && f.path.includes("icon")),
1358
1781
  noSourceMaps: !files.some((f)=>"sourcemap" === f.type),
1782
+ noPromoAssets: !files.some((f)=>PROMO_RE.test(f.path)),
1359
1783
  under10MB: totalSize < 10485760
1360
1784
  }
1361
1785
  };
@@ -1770,13 +2194,16 @@ class CDPClient extends CDPConnection {
1770
2194
  }
1771
2195
  async function resolveCdpPort(projectPath, browser, options) {
1772
2196
  const waitMs = options?.waitMs ?? 20000;
2197
+ const graceMs = options?.graceMs ?? 2500;
1773
2198
  const readyPath = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
1774
2199
  const deadline = Date.now() + waitMs;
1775
2200
  let contractSeen = false;
2201
+ let contractSeenAt = null;
1776
2202
  for(;;){
1777
2203
  try {
1778
2204
  const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
1779
2205
  contractSeen = true;
2206
+ if (null == contractSeenAt) contractSeenAt = Date.now();
1780
2207
  if ("number" == typeof contract.cdpPort) return {
1781
2208
  port: contract.cdpPort,
1782
2209
  source: "contract"
@@ -1784,7 +2211,8 @@ async function resolveCdpPort(projectPath, browser, options) {
1784
2211
  } catch {
1785
2212
  if (!contractSeen) break;
1786
2213
  }
1787
- if (Date.now() >= deadline) break;
2214
+ const effectiveDeadline = null != contractSeenAt ? Math.min(deadline, contractSeenAt + graceMs) : deadline;
2215
+ if (Date.now() >= effectiveDeadline) break;
1788
2216
  await new Promise((resolve)=>setTimeout(resolve, 500));
1789
2217
  }
1790
2218
  if (!contractSeen && await isCdpEndpoint(9222)) return {
@@ -1812,66 +2240,6 @@ function isCdpEndpoint(port) {
1812
2240
  });
1813
2241
  });
1814
2242
  }
1815
- function contractSightings(projectPath) {
1816
- const root = node_path.resolve(projectPath, "dist", "extension-js");
1817
- let dirs;
1818
- try {
1819
- dirs = node_fs.readdirSync(root);
1820
- } catch {
1821
- return [];
1822
- }
1823
- const sightings = [];
1824
- for (const dir of dirs){
1825
- const readyPath = node_path.join(root, dir, "ready.json");
1826
- try {
1827
- const stat = node_fs.statSync(readyPath);
1828
- const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
1829
- if (contract?.status !== "ready") continue;
1830
- sightings.push({
1831
- browser: dir,
1832
- mtimeMs: stat.mtimeMs,
1833
- pid: "number" == typeof contract.pid ? contract.pid : void 0
1834
- });
1835
- } catch {}
1836
- }
1837
- return sightings;
1838
- }
1839
- function pidAlive(pid) {
1840
- try {
1841
- process.kill(pid, 0);
1842
- return true;
1843
- } catch {
1844
- return false;
1845
- }
1846
- }
1847
- function knownSessionBrowsers(projectPath) {
1848
- const resolved = node_path.resolve(projectPath);
1849
- const browsers = [];
1850
- for (const session of listSessions())if (node_path.resolve(session.projectPath) === resolved) browsers.push(session.browser);
1851
- for (const sighting of contractSightings(projectPath))if (void 0 === sighting.pid || pidAlive(sighting.pid)) browsers.push(sighting.browser);
1852
- return Array.from(new Set(browsers));
1853
- }
1854
- function resolveSessionBrowser(projectPath, explicit, fallback = "chromium") {
1855
- if (explicit) return {
1856
- browser: explicit,
1857
- source: "explicit"
1858
- };
1859
- const resolved = node_path.resolve(projectPath);
1860
- const mine = listSessions().filter((s)=>node_path.resolve(s.projectPath) === resolved);
1861
- if (mine.length > 0) return {
1862
- browser: mine[mine.length - 1].browser,
1863
- source: "session"
1864
- };
1865
- const sightings = contractSightings(projectPath).filter((s)=>void 0 === s.pid || pidAlive(s.pid)).sort((a, b)=>b.mtimeMs - a.mtimeMs);
1866
- if (sightings.length > 0) return {
1867
- browser: sightings[0].browser,
1868
- source: "contract"
1869
- };
1870
- return {
1871
- browser: fallback,
1872
- source: "fallback"
1873
- };
1874
- }
1875
2243
  const source_inspect_schema = {
1876
2244
  name: "extension_source_inspect",
1877
2245
  description: "Inspect a running extension's live state via Chrome DevTools Protocol: full HTML (with shadow DOM), DOM structure, content script injection, console messages, and CSS selector queries. Requires an active dev or start session.",
@@ -1942,8 +2310,8 @@ async function source_inspect_handler(args) {
1942
2310
  ]);
1943
2311
  const maxBytes = args.maxBytes ?? 262144;
1944
2312
  if (!isChromiumFamily(browser)) return JSON.stringify({
1945
- error: `Source inspection for ${browser} uses RDP (Remote Debug Protocol). Currently only Chromium CDP is supported.`,
1946
- hint: 'Pass browser: "chrome" (against a Chromium-family dev session).'
2313
+ error: `Source inspection reads the live DOM over Chrome DevTools Protocol, which ${browser} (Gecko) does not expose. This is a capability limit, not a missing session.`,
2314
+ hint: `For the ${browser} session, read runtime state with extension_logs, or extension_eval (context: "content"/"page") which works against Firefox over the control channel. To get CDP DOM inspection, run a Chromium-family dev session (extension_dev with browser: "chrome") in parallel.`
1947
2315
  });
1948
2316
  const resolved = await resolveCdpPort(args.projectPath, browser);
1949
2317
  if (!resolved) return JSON.stringify({
@@ -1954,16 +2322,26 @@ async function source_inspect_handler(args) {
1954
2322
  const cdp = new CDPClient();
1955
2323
  try {
1956
2324
  const allTargets = await CDPClient.discoverTargets(cdpPort);
1957
- const pageTargets = allTargets.filter((t)=>"page" === t.type && !t.url.startsWith("chrome://") && !t.url.startsWith("devtools://"));
1958
- if (0 === pageTargets.length) return JSON.stringify({
1959
- cdpPort,
1960
- browser,
1961
- warning: "No inspectable page targets found. The extension may not have opened a page yet.",
1962
- allTargets: allTargets.map((t)=>({
1963
- type: t.type,
1964
- url: t.url?.slice(0, 100)
1965
- }))
1966
- });
2325
+ const OVERRIDE_PAGES = [
2326
+ "chrome://newtab/",
2327
+ "chrome://new-tab-page/",
2328
+ "chrome://bookmarks/",
2329
+ "chrome://history/"
2330
+ ];
2331
+ const isOverridePage = (url)=>OVERRIDE_PAGES.some((p)=>url.startsWith(p));
2332
+ const pageTargets = allTargets.filter((t)=>"page" === t.type && !t.url.startsWith("devtools://") && (!t.url.startsWith("chrome://") || isOverridePage(t.url)));
2333
+ if (0 === pageTargets.length) {
2334
+ const chromeOnly = allTargets.some((t)=>"page" === t.type && t.url.startsWith("chrome://"));
2335
+ return JSON.stringify({
2336
+ cdpPort,
2337
+ browser,
2338
+ 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.",
2339
+ allTargets: allTargets.map((t)=>({
2340
+ type: t.type,
2341
+ url: t.url?.slice(0, 100)
2342
+ }))
2343
+ });
2344
+ }
1967
2345
  const target = args.url ? pageTargets.find((t)=>t.url.includes(args.url)) ?? pageTargets[0] : pageTargets[0];
1968
2346
  const browserWsUrl = await CDPClient.discoverBrowserWsUrl(cdpPort);
1969
2347
  await cdp.connect(browserWsUrl);
@@ -2459,7 +2837,7 @@ function commonFlags(args) {
2459
2837
  }
2460
2838
  const eval_schema = {
2461
2839
  name: "extension_eval",
2462
- 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`.",
2840
+ 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`.",
2463
2841
  inputSchema: {
2464
2842
  type: "object",
2465
2843
  properties: {
@@ -2487,11 +2865,11 @@ const eval_schema = {
2487
2865
  },
2488
2866
  url: {
2489
2867
  type: "string",
2490
- description: "For content/page: document(s) to target"
2868
+ 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."
2491
2869
  },
2492
2870
  tab: {
2493
2871
  type: "number",
2494
- description: "For content/page: a specific tab id"
2872
+ description: "Numeric chrome.tabs id. Required for context content/page/popup on Chromium (the `url` arg does not substitute for it)."
2495
2873
  },
2496
2874
  browser: {
2497
2875
  type: "string",
@@ -2703,7 +3081,21 @@ async function open_handler(args) {
2703
3081
  if ("command" === args.surface && args.name) cli.push("--name", args.name);
2704
3082
  cli.push("--browser", browser);
2705
3083
  if (null != args.timeout) cli.push("--timeout", String(args.timeout));
2706
- return runActVerb(cli, args.projectPath, args.timeout);
3084
+ const raw = await runActVerb(cli, args.projectPath, args.timeout);
3085
+ const headless = /^(1|true)$/i.test(process.env.EXTENSION_HEADLESS ?? "");
3086
+ if (headless && [
3087
+ "popup",
3088
+ "action",
3089
+ "sidebar"
3090
+ ].includes(args.surface)) try {
3091
+ const parsed = JSON.parse(raw);
3092
+ const msg = String(parsed?.error?.message ?? "");
3093
+ if (parsed?.ok === false && !parsed.hint && /active browser window|no active|headless/i.test(msg)) {
3094
+ 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.";
3095
+ return JSON.stringify(parsed);
3096
+ }
3097
+ } catch {}
3098
+ return raw;
2707
3099
  }
2708
3100
  const dom_inspect_schema = {
2709
3101
  name: "extension_dom_inspect",
@@ -2780,8 +3172,9 @@ async function dom_inspect_handler(args) {
2780
3172
  ok: false,
2781
3173
  error: {
2782
3174
  name: "BadRequest",
2783
- message: "content/page inspect requires a tab id"
2784
- }
3175
+ message: "content/page inspect requires a numeric tab id (chrome.tabs id, not a CDP target id)."
3176
+ },
3177
+ 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)."
2785
3178
  });
2786
3179
  const cli = [
2787
3180
  "inspect",
@@ -2813,13 +3206,17 @@ function readCredentials() {
2813
3206
  if (1 !== data.version) return null;
2814
3207
  const token = String(data.token || "").trim();
2815
3208
  if (!token) return null;
3209
+ const provider = "extensiondev" === data.provider || "github" === data.provider ? data.provider : void 0;
2816
3210
  return {
2817
3211
  version: 1,
2818
3212
  token,
2819
3213
  workspaceSlug: String(data.workspaceSlug || ""),
2820
3214
  projectSlug: String(data.projectSlug || ""),
2821
3215
  expiresAt: Number(data.expiresAt || 0),
2822
- api: String(data.api || "")
3216
+ api: String(data.api || ""),
3217
+ ...provider ? {
3218
+ provider
3219
+ } : {}
2823
3220
  };
2824
3221
  } catch {
2825
3222
  return null;
@@ -2891,8 +3288,12 @@ function safeApiBase(raw) {
2891
3288
  async function fetchLoginConfig(apiBase, fetchImpl = fetch) {
2892
3289
  const override = String(process.env.EXTENSION_DEV_GITHUB_CLIENT_ID || "").trim();
2893
3290
  if (override) return {
3291
+ provider: "github",
2894
3292
  clientId: override,
2895
- scope: "read:user"
3293
+ scope: "read:user",
3294
+ deviceCodeUrl: "/api/cli/device/code",
3295
+ deviceTokenUrl: "/api/cli/device/token",
3296
+ verificationUri: "https://github.com/login/device"
2896
3297
  };
2897
3298
  const res = await fetchImpl(`${apiBase}/api/cli/login/config`, {
2898
3299
  headers: {
@@ -2901,12 +3302,32 @@ async function fetchLoginConfig(apiBase, fetchImpl = fetch) {
2901
3302
  });
2902
3303
  if (!res.ok) throw new Error(`Could not fetch login config from ${apiBase} (${res.status}).`);
2903
3304
  const data = await res.json().catch(()=>({}));
3305
+ const provider = "extensiondev" === data.provider ? "extensiondev" : "github";
2904
3306
  const clientId = String(data.githubClientId || "").trim();
2905
- if (!clientId) throw new Error("Login is not configured on the server (no GitHub client id). Set EXTENSION_DEV_GITHUB_CLIENT_ID to override.");
3307
+ 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.");
2906
3308
  return {
3309
+ provider,
2907
3310
  clientId,
2908
- scope: String(data.scope || "read:user")
3311
+ scope: String(data.scope || "read:user"),
3312
+ deviceCodeUrl: String(data.deviceCodeUrl || "/api/cli/device/code"),
3313
+ deviceTokenUrl: String(data.deviceTokenUrl || "/api/cli/device/token"),
3314
+ verificationUri: String(data.verificationUri || "https://github.com/login/device")
3315
+ };
3316
+ }
3317
+ function persistTokenResponse(args) {
3318
+ const token = String(args.data.token || "").trim();
3319
+ if (!token) throw new Error("Login returned no token.");
3320
+ const creds = {
3321
+ version: 1,
3322
+ token,
3323
+ workspaceSlug: String(args.data.workspaceSlug || ""),
3324
+ projectSlug: String(args.data.projectSlug || ""),
3325
+ expiresAt: Number(args.data.expiresAt || 0),
3326
+ api: args.apiBase,
3327
+ provider: args.provider
2909
3328
  };
3329
+ writeCredentials(creds);
3330
+ return creds;
2910
3331
  }
2911
3332
  async function exchangeAndPersist(args) {
2912
3333
  const doFetch = args.fetchImpl ?? fetch;
@@ -2931,18 +3352,11 @@ async function exchangeAndPersist(args) {
2931
3352
  };
2932
3353
  }
2933
3354
  if (!res.ok) throw new Error(`Login exchange failed (${res.status}): ${data.message || "unknown error"}`);
2934
- const token = String(data.token || "").trim();
2935
- if (!token) throw new Error("Login exchange returned no token.");
2936
- const creds = {
2937
- version: 1,
2938
- token,
2939
- workspaceSlug: String(data.workspaceSlug || ""),
2940
- projectSlug: String(data.projectSlug || ""),
2941
- expiresAt: Number(data.expiresAt || 0),
2942
- api: args.apiBase
2943
- };
2944
- writeCredentials(creds);
2945
- return creds;
3355
+ return persistTokenResponse({
3356
+ apiBase: args.apiBase,
3357
+ data,
3358
+ provider: "github"
3359
+ });
2946
3360
  }
2947
3361
  function resolveToken() {
2948
3362
  const fromEnv = String(process.env.EXTENSION_DEV_TOKEN || "").trim();
@@ -3014,53 +3428,60 @@ async function publish(options = {}) {
3014
3428
  }
3015
3429
  const publish_schema = {
3016
3430
  name: "extension_publish",
3017
- 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.",
3431
+ 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.",
3018
3432
  inputSchema: {
3019
3433
  type: "object",
3020
3434
  properties: {
3021
- projectPath: {
3022
- type: "string",
3023
- description: "Path to the extension project root"
3024
- },
3025
3435
  ttlHours: {
3026
3436
  type: "number",
3027
- description: "Share-link lifetime in hours (1168, default 24)"
3437
+ description: "Private-project share-link lifetime in hours, 1-168 (default 24). Ignored for public projects."
3028
3438
  },
3029
3439
  buildSha: {
3030
3440
  type: "string",
3031
- description: "Pin the share URL to a specific build sha"
3441
+ 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."
3032
3442
  },
3033
3443
  api: {
3034
3444
  type: "string",
3035
3445
  description: "Platform base URL (defaults to https://www.extension.dev or EXTENSION_DEV_API_URL)"
3036
3446
  }
3037
3447
  },
3038
- required: [
3039
- "projectPath"
3040
- ]
3448
+ required: []
3041
3449
  }
3042
3450
  };
3043
- async function publish_handler(args) {
3044
- const token = resolveToken();
3045
- if (!token) return JSON.stringify({
3451
+ function fail(name, message) {
3452
+ return JSON.stringify({
3046
3453
  ok: false,
3047
3454
  error: {
3048
- name: "PublishAuthError",
3049
- message: "No token. Run extension_login, or set EXTENSION_DEV_TOKEN (create one in the extension.dev dashboard)."
3455
+ name,
3456
+ message
3050
3457
  }
3051
3458
  });
3459
+ }
3460
+ async function publish_handler(args) {
3461
+ const token = resolveToken();
3462
+ if (!token) return fail("PublishAuthError", "No token. Run extension_login, or set EXTENSION_DEV_TOKEN (create one in the extension.dev dashboard).");
3463
+ if (null != args.ttlHours) {
3464
+ const t = Number(args.ttlHours);
3465
+ if (!Number.isInteger(t) || t < 1 || t > 168) return fail("PublishBadRequest", "ttlHours must be an integer between 1 and 168.");
3466
+ }
3467
+ if (null != args.buildSha && "" !== args.buildSha) {
3468
+ if (!/^[0-9a-f]{7,40}$/i.test(args.buildSha)) return fail("PublishBadRequest", "buildSha must be a 7-40 character hex git sha.");
3469
+ }
3052
3470
  const result = await publish({
3053
3471
  ttlHours: args.ttlHours,
3054
3472
  buildSha: args.buildSha,
3055
3473
  api: args.api,
3056
3474
  token
3057
3475
  });
3058
- return result.ok ? JSON.stringify(result.data) : JSON.stringify(result);
3476
+ if (!result.ok) return JSON.stringify(result);
3477
+ const data = result.data;
3478
+ 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.";
3479
+ return JSON.stringify(data);
3059
3480
  }
3060
3481
  const release_promote_DEFAULT_API = "https://www.extension.dev";
3061
3482
  const release_promote_schema = {
3062
3483
  name: "extension_release_promote",
3063
- 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).",
3484
+ 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).",
3064
3485
  inputSchema: {
3065
3486
  type: "object",
3066
3487
  properties: {
@@ -3102,7 +3523,7 @@ const release_promote_schema = {
3102
3523
  ]
3103
3524
  }
3104
3525
  };
3105
- function fail(name, message) {
3526
+ function release_promote_fail(name, message) {
3106
3527
  return JSON.stringify({
3107
3528
  ok: false,
3108
3529
  error: {
@@ -3113,12 +3534,12 @@ function fail(name, message) {
3113
3534
  }
3114
3535
  async function release_promote_handler(args) {
3115
3536
  const token = resolveToken();
3116
- 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.");
3537
+ 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.");
3117
3538
  const buildId = String(args.buildId || "").trim();
3118
3539
  const channel = String(args.channel || "").trim();
3119
- if (!buildId || !channel) return fail("ReleaseInputError", "buildId and channel are required.");
3540
+ if (!buildId || !channel) return release_promote_fail("ReleaseInputError", "buildId and channel are required.");
3120
3541
  const apiCheck = safeApiBase(String(args.api || process.env.EXTENSION_DEV_API_URL || release_promote_DEFAULT_API));
3121
- if (!apiCheck.ok) return fail("ReleaseConfigError", apiCheck.message);
3542
+ if (!apiCheck.ok) return release_promote_fail("ReleaseConfigError", apiCheck.message);
3122
3543
  const url = `${apiCheck.base}/api/cli/release/promote`;
3123
3544
  const body = {
3124
3545
  buildId,
@@ -3139,7 +3560,7 @@ async function release_promote_handler(args) {
3139
3560
  body: JSON.stringify(body)
3140
3561
  });
3141
3562
  } catch (err) {
3142
- return fail("ReleaseNetworkError", `Could not reach ${url}: ${err?.message || err}`);
3563
+ return release_promote_fail("ReleaseNetworkError", `Could not reach ${url}: ${err?.message || err}`);
3143
3564
  }
3144
3565
  const text = await res.text();
3145
3566
  let data;
@@ -3150,175 +3571,117 @@ async function release_promote_handler(args) {
3150
3571
  message: text
3151
3572
  };
3152
3573
  }
3153
- if (!res.ok) return fail("ReleaseError", `promote failed (${res.status}): ${data?.message || text || "unknown error"}`);
3574
+ if (!res.ok) return release_promote_fail("ReleaseError", `promote failed (${res.status}): ${data?.message || text || "unknown error"}`);
3154
3575
  return JSON.stringify(data);
3155
3576
  }
3156
- const DEFAULT_DEPLOY_VERSION = "1.2.1";
3157
- function pinnedDeployVersion() {
3158
- return String(process.env.EXTENSION_DEPLOY_VERSION || "").trim() || DEFAULT_DEPLOY_VERSION;
3159
- }
3160
- function buildDeployArgs(args) {
3161
- const argv = [];
3162
- const pushValue = (flag, value)=>{
3163
- if (void 0 === value || "" === value) return;
3164
- argv.push(flag, String(value));
3165
- };
3166
- if (false !== args.dryRun) argv.push("--dry-run");
3167
- pushValue("--chrome-zip", args.chromeZip);
3168
- pushValue("--chrome-extension-id", args.chromeExtensionId);
3169
- pushValue("--chrome-publisher-id", args.chromePublisherId);
3170
- if (args.stagedPublish) argv.push("--chrome-staged-publish");
3171
- pushValue("--chrome-deploy-percentage", args.chromeDeployPercentage);
3172
- if (args.chromeSkipSubmitReview) argv.push("--chrome-skip-submit-review");
3173
- pushValue("--firefox-zip", args.firefoxZip);
3174
- pushValue("--firefox-sources-zip", args.firefoxSourcesZip);
3175
- pushValue("--firefox-extension-id", args.firefoxExtensionId);
3176
- pushValue("--firefox-channel", args.firefoxChannel);
3177
- pushValue("--edge-zip", args.edgeZip);
3178
- pushValue("--edge-product-id", args.edgeProductId);
3179
- if (args.edgeSkipSubmitReview) argv.push("--edge-skip-submit-review");
3180
- pushValue("--output-json", args.outputJson);
3181
- return argv;
3182
- }
3577
+ const deploy_DEFAULT_API = "https://www.extension.dev";
3183
3578
  const deploy_schema = {
3184
3579
  name: "extension_deploy",
3185
- description: "Submit a built extension to the Chrome Web Store, Firefox AMO, and/or Edge Add-ons by driving the standalone @extension.dev/deploy CLI. Provide the built .zip path(s); the target stores are inferred from which zips you pass. DEFAULTS TO A DRY RUN (verifies auth and inputs, uploads nothing) - pass dryRun:false to actually submit, which is irreversible and enters store review. Store CREDENTIALS are NOT arguments: set them in the environment or a .env.submit file in projectPath (CHROME_CLIENT_ID/CHROME_CLIENT_SECRET/CHROME_REFRESH_TOKEN or CHROME_SERVICE_ACCOUNT_JSON; FIREFOX_JWT_ISSUER/FIREFOX_JWT_SECRET; EDGE_CLIENT_ID/EDGE_API_KEY). Runs `npx @extension.dev/deploy`.",
3580
+ 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.",
3186
3581
  inputSchema: {
3187
3582
  type: "object",
3188
3583
  properties: {
3189
- projectPath: {
3190
- type: "string",
3191
- description: "Working directory. Relative zip paths and a .env.submit credential file are resolved from here."
3192
- },
3193
- chromeZip: {
3194
- type: "string",
3195
- description: "Path to the built Chrome extension .zip"
3196
- },
3197
- chromeExtensionId: {
3198
- type: "string",
3199
- description: "Chrome extension ID (or set CHROME_EXTENSION_ID)"
3200
- },
3201
- chromePublisherId: {
3202
- type: "string",
3203
- description: "Chrome publisher UUID (or set CHROME_PUBLISHER_ID)"
3204
- },
3205
- firefoxZip: {
3206
- type: "string",
3207
- description: "Path to the built Firefox extension .zip"
3208
- },
3209
- firefoxSourcesZip: {
3210
- type: "string",
3211
- description: "Path to the Firefox sources .zip (optional)"
3212
- },
3213
- firefoxExtensionId: {
3214
- type: "string",
3215
- description: "Firefox add-on GUID or email-style ID"
3584
+ browsers: {
3585
+ type: "array",
3586
+ items: {
3587
+ type: "string",
3588
+ enum: [
3589
+ "chrome",
3590
+ "firefox",
3591
+ "edge",
3592
+ "safari"
3593
+ ]
3594
+ },
3595
+ description: "Stores to submit to."
3216
3596
  },
3217
- firefoxChannel: {
3597
+ buildSha: {
3218
3598
  type: "string",
3219
- enum: [
3220
- "listed",
3221
- "unlisted"
3222
- ],
3223
- description: "Firefox channel (unlisted is required for a first submission)"
3599
+ description: "The built commit SHA to submit. It must have a completed build in the project's build index; an unknown sha is rejected."
3224
3600
  },
3225
- edgeZip: {
3601
+ channel: {
3226
3602
  type: "string",
3227
- description: "Path to the built Edge extension .zip"
3603
+ description: "Release channel to submit from (default stable)."
3228
3604
  },
3229
- edgeProductId: {
3605
+ version: {
3230
3606
  type: "string",
3231
- description: "Edge Partner Center product ID (or set EDGE_PRODUCT_ID)"
3607
+ description: "Version label for the submission record (optional)."
3232
3608
  },
3233
3609
  dryRun: {
3234
3610
  type: "boolean",
3235
3611
  default: true,
3236
- description: "Verify auth and inputs without uploading or publishing. Pass false to actually submit (irreversible)."
3237
- },
3238
- stagedPublish: {
3239
- type: "boolean",
3240
- default: false,
3241
- description: "Chrome: publish as STAGED_PUBLISH so the rollout can be raised review-free later."
3242
- },
3243
- chromeDeployPercentage: {
3244
- type: "number",
3245
- description: "Chrome staged rollout percentage (1-100)."
3246
- },
3247
- chromeSkipSubmitReview: {
3248
- type: "boolean",
3249
- default: false,
3250
- description: "Chrome: upload only, skip the publish/submit-for-review step."
3251
- },
3252
- edgeSkipSubmitReview: {
3253
- type: "boolean",
3254
- default: false,
3255
- description: "Edge: upload only, skip the publish/submit-for-review step."
3612
+ description: "Preflight only (verify auth, project, build, and store workflow). Pass false to actually dispatch the submission (irreversible, enters store review)."
3256
3613
  },
3257
- outputJson: {
3614
+ api: {
3258
3615
  type: "string",
3259
- description: "Write the machine-readable DeployResult JSON to this path."
3616
+ description: "Platform base URL (defaults to https://www.extension.dev or EXTENSION_DEV_API_URL)"
3260
3617
  }
3261
3618
  },
3262
3619
  required: [
3263
- "projectPath"
3620
+ "browsers",
3621
+ "buildSha"
3264
3622
  ]
3265
3623
  }
3266
3624
  };
3267
- async function deploy_handler(args) {
3268
- if (!args.chromeZip && !args.firefoxZip && !args.edgeZip) return JSON.stringify({
3625
+ function deploy_fail(name, message) {
3626
+ return JSON.stringify({
3269
3627
  ok: false,
3270
3628
  error: {
3271
- name: "DeployInputError",
3272
- message: "No store targets. Provide at least one of chromeZip, firefoxZip, or edgeZip."
3629
+ name,
3630
+ message
3273
3631
  }
3274
3632
  });
3275
- const version = pinnedDeployVersion();
3276
- const argv = [
3277
- "--yes",
3278
- `@extension.dev/deploy@${version}`,
3279
- ...buildDeployArgs(args)
3280
- ];
3281
- return new Promise((resolve)=>{
3282
- const child = cross_spawn("npx", argv, {
3283
- cwd: args.projectPath,
3284
- stdio: [
3285
- "ignore",
3286
- "pipe",
3287
- "pipe"
3288
- ],
3289
- env: {
3290
- ...process.env,
3291
- FORCE_COLOR: "0",
3292
- NO_COLOR: "1"
3293
- }
3294
- });
3295
- let stdout = "";
3296
- let stderr = "";
3297
- child.stdout?.on("data", (d)=>stdout += d.toString());
3298
- child.stderr?.on("data", (d)=>stderr += d.toString());
3299
- child.on("close", (code)=>{
3300
- resolve(JSON.stringify({
3301
- ok: 0 === code,
3302
- dryRun: false !== args.dryRun,
3303
- code,
3304
- stdout: stdout.trim(),
3305
- stderr: stderr.trim()
3306
- }));
3307
- });
3308
- child.on("error", (err)=>{
3309
- resolve(JSON.stringify({
3310
- ok: false,
3311
- error: {
3312
- name: "DeploySpawnError",
3313
- message: `Could not run extension-deploy: ${err instanceof Error ? err.message : String(err)}`
3314
- }
3315
- }));
3633
+ }
3634
+ async function deploy_handler(args) {
3635
+ const token = resolveToken();
3636
+ 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).");
3637
+ const browsers = (Array.isArray(args.browsers) ? args.browsers : []).map((b)=>String(b).trim().toLowerCase()).filter(Boolean);
3638
+ if (0 === browsers.length) return deploy_fail("DeployInputError", 'browsers is required (e.g. ["chrome","firefox","edge"]).');
3639
+ const buildSha = String(args.buildSha || "").trim();
3640
+ if (!buildSha) return deploy_fail("DeployInputError", "buildSha is required (the built commit to submit).");
3641
+ const apiCheck = safeApiBase(String(args.api || process.env.EXTENSION_DEV_API_URL || deploy_DEFAULT_API));
3642
+ if (!apiCheck.ok) return deploy_fail("DeployConfigError", apiCheck.message);
3643
+ const url = `${apiCheck.base}/api/cli/stores/submit`;
3644
+ const dryRun = false !== args.dryRun;
3645
+ const body = {
3646
+ browsers,
3647
+ buildSha,
3648
+ dryRun
3649
+ };
3650
+ if (args.channel) body.channel = String(args.channel).trim();
3651
+ if (args.version) body.version = String(args.version).trim();
3652
+ let res;
3653
+ try {
3654
+ res = await fetch(url, {
3655
+ method: "POST",
3656
+ headers: {
3657
+ authorization: `Bearer ${token}`,
3658
+ "content-type": "application/json"
3659
+ },
3660
+ body: JSON.stringify(body)
3316
3661
  });
3662
+ } catch (err) {
3663
+ return deploy_fail("DeployNetworkError", `Could not reach ${url}: ${err?.message || err}`);
3664
+ }
3665
+ const text = await res.text();
3666
+ let data;
3667
+ try {
3668
+ data = JSON.parse(text);
3669
+ } catch {
3670
+ data = {
3671
+ message: text
3672
+ };
3673
+ }
3674
+ if (!res.ok) return deploy_fail("DeployError", `submit failed (${res.status}): ${data?.message || text || "unknown error"}`);
3675
+ return JSON.stringify({
3676
+ mode: "platform",
3677
+ dryRun,
3678
+ ...data
3317
3679
  });
3318
3680
  }
3681
+ const SAFE_CEILING_MS = 50000;
3319
3682
  const wait_schema = {
3320
3683
  name: "extension_wait",
3321
- description: "Wait for a running dev or start session to be ready. Polls the ready.json contract file and returns structured status.",
3684
+ 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).",
3322
3685
  inputSchema: {
3323
3686
  type: "object",
3324
3687
  properties: {
@@ -3332,8 +3695,8 @@ const wait_schema = {
3332
3695
  },
3333
3696
  timeout: {
3334
3697
  type: "number",
3335
- default: 60000,
3336
- description: "Timeout in milliseconds"
3698
+ default: 45000,
3699
+ 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."
3337
3700
  }
3338
3701
  },
3339
3702
  required: [
@@ -3343,7 +3706,9 @@ const wait_schema = {
3343
3706
  };
3344
3707
  async function wait_handler(args) {
3345
3708
  const { browser } = resolveSessionBrowser(args.projectPath, args.browser, "chrome");
3346
- const timeout = args.timeout ?? 60000;
3709
+ const requested = args.timeout ?? 45000;
3710
+ const timeout = Math.min(Math.max(requested, 1000), SAFE_CEILING_MS);
3711
+ const clamped = requested > SAFE_CEILING_MS;
3347
3712
  const readyPath = node_path.resolve(args.projectPath, "dist", "extension-js", browser, "ready.json");
3348
3713
  const start = Date.now();
3349
3714
  const pollInterval = 1000;
@@ -3376,9 +3741,11 @@ async function wait_handler(args) {
3376
3741
  }
3377
3742
  return JSON.stringify({
3378
3743
  status: "timeout",
3379
- message: `Extension did not become ready within ${timeout}ms`,
3744
+ message: `Extension not ready after ${timeout}ms this call`,
3380
3745
  readyPath,
3381
- hint: "The dev session may still be building. Try increasing the timeout, or check if the dev process is still running."
3746
+ waitDuration: Date.now() - start,
3747
+ clamped: clamped ? `requested ${requested}ms was clamped to ${SAFE_CEILING_MS}ms to stay under the MCP client request timeout` : void 0,
3748
+ 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."
3382
3749
  });
3383
3750
  }
3384
3751
  const add_feature_schema = {
@@ -3548,7 +3915,23 @@ async function add_feature_handler(args) {
3548
3915
  });
3549
3916
  const template = await getTemplateBySlug(templateSlug);
3550
3917
  const referenceFiles = template?.keyFiles ?? template?.files ?? [];
3551
- const featureDir = "content-script" === args.feature ? "content" : args.feature;
3918
+ const FEATURE_DIR = {
3919
+ "content-script": "content",
3920
+ popup: "action"
3921
+ };
3922
+ const featureDir = FEATURE_DIR[args.feature] ?? args.feature;
3923
+ const scriptExt = "react" === framework || "preact" === framework ? "tsx" : "ts";
3924
+ const componentExt = "vue" === framework ? "vue" : "svelte" === framework ? "svelte" : "tsx";
3925
+ const COMPONENT_BASE = {
3926
+ content: "Content",
3927
+ sidebar: "Sidebar",
3928
+ action: "Action",
3929
+ newtab: "NewTab",
3930
+ options: "Options",
3931
+ devtools: "DevTools",
3932
+ background: "Background"
3933
+ };
3934
+ const componentBase = COMPONENT_BASE[featureDir] ?? featureDir.charAt(0).toUpperCase() + featureDir.slice(1);
3552
3935
  const filesToCreate = [];
3553
3936
  const manifestUpdates = MANIFEST_ADDITIONS[args.feature] ?? {};
3554
3937
  if ([
@@ -3562,24 +3945,42 @@ async function add_feature_handler(args) {
3562
3945
  path: `src/${featureDir}/index.html`,
3563
3946
  hint: "HTML entry point"
3564
3947
  }, {
3565
- path: `src/${featureDir}/scripts.${"vanilla" === framework ? "ts" : "tsx"}`,
3948
+ path: `src/${featureDir}/scripts.${scriptExt}`,
3566
3949
  hint: "vanilla" === framework ? "Script entry point" : `${framework} mount point`
3567
3950
  }, {
3568
3951
  path: `src/${featureDir}/styles.css`,
3569
3952
  hint: "Stylesheet"
3570
3953
  });
3571
- if ("vanilla" !== framework) filesToCreate.push({
3572
- path: `src/${featureDir}/${featureDir.charAt(0).toUpperCase() + featureDir.slice(1)}App.tsx`,
3573
- hint: `Main ${framework} component`
3954
+ if ("vanilla" !== framework) {
3955
+ filesToCreate.push({
3956
+ path: `src/${featureDir}/${componentBase}App.${componentExt}`,
3957
+ hint: `Main ${framework} component`
3958
+ });
3959
+ if ("vue" === framework) filesToCreate.push({
3960
+ path: `src/${featureDir}/shims-vue.d.ts`,
3961
+ hint: "Vue SFC type shim (lets TS import .vue components)"
3962
+ });
3963
+ }
3964
+ }
3965
+ if ("content-script" === args.feature) {
3966
+ filesToCreate.push({
3967
+ path: "src/content/scripts.ts",
3968
+ hint: "vanilla" === framework ? "Content script entry point" : `${framework} content-script mount point`
3969
+ }, {
3970
+ path: "src/content/styles.css",
3971
+ hint: "Content script styles"
3574
3972
  });
3973
+ if ("vanilla" !== framework) {
3974
+ filesToCreate.push({
3975
+ path: `src/content/ContentApp.${componentExt}`,
3976
+ hint: `Main ${framework} component mounted by the content script`
3977
+ });
3978
+ if ("vue" === framework) filesToCreate.push({
3979
+ path: "src/content/shims-vue.d.ts",
3980
+ hint: "Vue SFC type shim (lets TS import .vue components)"
3981
+ });
3982
+ }
3575
3983
  }
3576
- if ("content-script" === args.feature) filesToCreate.push({
3577
- path: "src/content/scripts.ts",
3578
- hint: "Content script entry point"
3579
- }, {
3580
- path: "src/content/styles.css",
3581
- hint: "Content script styles"
3582
- });
3583
3984
  if ("background" === args.feature) filesToCreate.push({
3584
3985
  path: "src/background.ts",
3585
3986
  hint: "Background service worker / script"
@@ -3682,9 +4083,102 @@ async function pollForToken(args) {
3682
4083
  reason: "pending"
3683
4084
  };
3684
4085
  }
4086
+ async function requestDeviceCode(args) {
4087
+ const doFetch = args.fetchImpl ?? fetch;
4088
+ const res = await doFetch(`${args.apiBase}${args.path}`, {
4089
+ method: "POST",
4090
+ headers: {
4091
+ "content-type": "application/json",
4092
+ accept: "application/json"
4093
+ },
4094
+ body: JSON.stringify({
4095
+ project: args.project,
4096
+ clientName: args.clientName ?? "extension-mcp"
4097
+ })
4098
+ });
4099
+ const text = await res.text();
4100
+ let data;
4101
+ try {
4102
+ data = JSON.parse(text);
4103
+ } catch {
4104
+ data = {
4105
+ message: text
4106
+ };
4107
+ }
4108
+ if (!res.ok) throw new Error(`Device code request failed (${res.status}): ${data.message || "unknown error"}`);
4109
+ const deviceCode = String(data.device_code || "").trim();
4110
+ const userCode = String(data.user_code || "").trim();
4111
+ if (!deviceCode || !userCode) throw new Error("Device code response missing device_code/user_code.");
4112
+ return {
4113
+ deviceCode,
4114
+ userCode,
4115
+ verificationUri: String(data.verification_uri || "https://extension.dev/device"),
4116
+ verificationUriComplete: String(data.verification_uri_complete || data.verification_uri || ""),
4117
+ interval: Number(data.interval || 5),
4118
+ expiresIn: Number(data.expires_in || 900)
4119
+ };
4120
+ }
4121
+ async function pollDeviceToken(args) {
4122
+ const doFetch = args.fetchImpl ?? fetch;
4123
+ const deadline = Date.now() + args.budgetMs;
4124
+ let interval = Math.max(1, args.interval);
4125
+ for(;;){
4126
+ const res = await doFetch(`${args.apiBase}${args.path}`, {
4127
+ method: "POST",
4128
+ headers: {
4129
+ "content-type": "application/json",
4130
+ accept: "application/json"
4131
+ },
4132
+ body: JSON.stringify({
4133
+ device_code: args.deviceCode,
4134
+ project: args.project
4135
+ })
4136
+ });
4137
+ const text = await res.text();
4138
+ let data;
4139
+ try {
4140
+ data = JSON.parse(text);
4141
+ } catch {
4142
+ data = {
4143
+ message: text
4144
+ };
4145
+ }
4146
+ if (res.ok && data.token) {
4147
+ const creds = persistTokenResponse({
4148
+ apiBase: args.apiBase,
4149
+ data,
4150
+ provider: "extensiondev"
4151
+ });
4152
+ return {
4153
+ ok: true,
4154
+ creds
4155
+ };
4156
+ }
4157
+ const error = String(data.error || "");
4158
+ if ("access_denied" === error) return {
4159
+ ok: false,
4160
+ reason: "denied"
4161
+ };
4162
+ if ("expired_token" === error) return {
4163
+ ok: false,
4164
+ reason: "expired"
4165
+ };
4166
+ if ("slow_down" === error) interval += 5;
4167
+ else if (error && "authorization_pending" !== error) return {
4168
+ ok: false,
4169
+ reason: "error",
4170
+ message: String(data.message || error)
4171
+ };
4172
+ if (Date.now() + 1000 * interval >= deadline) return {
4173
+ ok: false,
4174
+ reason: "pending"
4175
+ };
4176
+ await new Promise((r)=>setTimeout(r, 1000 * interval));
4177
+ }
4178
+ }
3685
4179
  const login_schema = {
3686
4180
  name: "extension_login",
3687
- 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.",
4181
+ 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.",
3688
4182
  inputSchema: {
3689
4183
  type: "object",
3690
4184
  properties: {
@@ -3747,6 +4241,52 @@ async function login_handler(args) {
3747
4241
  } catch (err) {
3748
4242
  return login_fail("LoginConfigError", err?.message || "Could not load login config.");
3749
4243
  }
4244
+ if ("extensiondev" === config.provider) {
4245
+ if (args.deviceCode) {
4246
+ const poll = await pollDeviceToken({
4247
+ apiBase,
4248
+ path: config.deviceTokenUrl,
4249
+ project,
4250
+ deviceCode: String(args.deviceCode),
4251
+ interval: 5,
4252
+ budgetMs: RESUME_BUDGET_MS
4253
+ });
4254
+ if (poll.ok) return success(poll.creds);
4255
+ if ("expired" === poll.reason) return login_fail("LoginExpired", "The device code expired. Run extension_login again to restart.");
4256
+ if ("denied" === poll.reason) return login_fail("LoginDenied", "Authorization was denied at extension.dev/device.");
4257
+ if ("error" === poll.reason) return login_fail("LoginError", poll.message || "Device login failed.");
4258
+ return login_pending({
4259
+ deviceCode: String(args.deviceCode),
4260
+ userCode: "(see the previous response)",
4261
+ verificationUri: config.verificationUri
4262
+ });
4263
+ }
4264
+ let start;
4265
+ try {
4266
+ start = await requestDeviceCode({
4267
+ apiBase,
4268
+ path: config.deviceCodeUrl,
4269
+ project
4270
+ });
4271
+ } catch (err) {
4272
+ return login_fail("LoginStartError", err?.message || "Could not start the device flow.");
4273
+ }
4274
+ const poll = await pollDeviceToken({
4275
+ apiBase,
4276
+ path: config.deviceTokenUrl,
4277
+ project,
4278
+ deviceCode: start.deviceCode,
4279
+ interval: start.interval,
4280
+ budgetMs: FIRST_CALL_BUDGET_MS
4281
+ });
4282
+ if (poll.ok) return success(poll.creds);
4283
+ if ("denied" === poll.reason) return login_fail("LoginDenied", "Authorization was denied at extension.dev/device.");
4284
+ return login_pending({
4285
+ deviceCode: start.deviceCode,
4286
+ userCode: start.userCode,
4287
+ verificationUri: start.verificationUri
4288
+ });
4289
+ }
3750
4290
  if (args.deviceCode) {
3751
4291
  const poll = await pollForToken({
3752
4292
  clientId: config.clientId,
@@ -3825,6 +4365,7 @@ async function whoami_handler() {
3825
4365
  workspaceSlug: creds.workspaceSlug,
3826
4366
  projectSlug: creds.projectSlug,
3827
4367
  api: creds.api,
4368
+ provider: creds.provider ?? "github",
3828
4369
  expiresAt: creds.expiresAt ? new Date(1000 * creds.expiresAt).toISOString() : null,
3829
4370
  expiresInSeconds: creds.expiresAt ? creds.expiresAt - now : null,
3830
4371
  expired,
@@ -3849,7 +4390,7 @@ async function logout_handler() {
3849
4390
  }
3850
4391
  const install_browser_schema = {
3851
4392
  name: "extension_install_browser",
3852
- description: "Install a managed browser binary for extension testing. Useful in CI, Docker, or fresh environments where browsers are not pre-installed.",
4393
+ 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.",
3853
4394
  inputSchema: {
3854
4395
  type: "object",
3855
4396
  properties: {
@@ -4323,37 +4864,80 @@ async function detect_browsers_handler(args) {
4323
4864
  hint: missing.length ? `Missing browser(s): ${missing.map((d)=>d.browser).join(", ")}.${missing.some((d)=>MANAGED_INSTALLABLE.has(d.browser)) ? ` Use extension_install_browser to install ${missing.filter((d)=>MANAGED_INSTALLABLE.has(d.browser)).map((d)=>d.browser).join(", ")}.` : ""}` : "All requested browsers are available."
4324
4865
  });
4325
4866
  }
4867
+ function doctor_readReadyContract(projectPath, browser) {
4868
+ try {
4869
+ const raw = node_fs.readFileSync(node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json"), "utf8");
4870
+ return JSON.parse(raw);
4871
+ } catch {
4872
+ return null;
4873
+ }
4874
+ }
4326
4875
  const doctor_schema = {
4327
4876
  name: "extension_doctor",
4328
- 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`.",
4877
+ 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.",
4329
4878
  inputSchema: {
4330
4879
  type: "object",
4331
4880
  properties: {
4332
4881
  projectPath: {
4333
4882
  type: "string",
4334
- description: "Path to the extension project root"
4883
+ description: "Path to the extension project root. Omit for a pre-flight environment check with no project."
4335
4884
  },
4336
4885
  browser: {
4337
4886
  type: "string",
4338
4887
  description: "Browser session to diagnose. Defaults to the active dev session's browser for this project."
4339
4888
  }
4340
- },
4341
- required: [
4342
- "projectPath"
4343
- ]
4889
+ }
4344
4890
  }
4345
4891
  };
4892
+ async function environmentPreflight() {
4893
+ const checks = [];
4894
+ const nodeMajor = Number(process.versions.node.split(".")[0]);
4895
+ checks.push({
4896
+ check: "node",
4897
+ status: nodeMajor >= 20 ? "pass" : "fail",
4898
+ detail: `Node ${process.versions.node} on ${process.platform}/${process.arch}`,
4899
+ remediation: nodeMajor >= 20 ? void 0 : "Extension.js needs Node >= 20.18."
4900
+ });
4901
+ const { code, stdout, stderr } = await runExtensionCli([
4902
+ "--version"
4903
+ ], {
4904
+ timeoutMs: 60000
4905
+ });
4906
+ const cliVersion = stdout.trim() || stderr.trim();
4907
+ checks.push({
4908
+ check: "extension-cli",
4909
+ status: 0 === code ? "pass" : "fail",
4910
+ detail: 0 === code ? `extension CLI resolvable (${cliVersion})` : "extension CLI could not be resolved",
4911
+ remediation: 0 === code ? void 0 : "Install locally (npm i -D extension) or rely on npx; check network access to the npm registry."
4912
+ });
4913
+ const cacheFile = node_path.join(node_os.homedir(), ".cache", "extension-js", "templates-meta.json");
4914
+ const cacheExists = node_fs.existsSync(cacheFile);
4915
+ checks.push({
4916
+ check: "template-cache",
4917
+ status: cacheExists ? "pass" : "warn",
4918
+ detail: cacheExists ? `Template catalog cached at ${cacheFile}` : "Template catalog not cached yet (first extension_list_templates will fetch it)"
4919
+ });
4920
+ const healthy = checks.every((c)=>"fail" !== c.status);
4921
+ return JSON.stringify({
4922
+ mode: "environment",
4923
+ healthy,
4924
+ checks,
4925
+ hint: "Pass projectPath to diagnose a live dev session end-to-end."
4926
+ });
4927
+ }
4346
4928
  async function doctor_handler(args) {
4347
- const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
4929
+ if (!args.projectPath) return environmentPreflight();
4930
+ const projectPath = args.projectPath;
4931
+ const { browser } = resolveSessionBrowser(projectPath, args.browser);
4348
4932
  const { code, stdout, stderr } = await runExtensionCli([
4349
4933
  "doctor",
4350
- args.projectPath,
4934
+ projectPath,
4351
4935
  "--browser",
4352
4936
  browser,
4353
4937
  "--output",
4354
4938
  "json"
4355
4939
  ], {
4356
- cwd: args.projectPath
4940
+ cwd: projectPath
4357
4941
  });
4358
4942
  const out = stdout.trim();
4359
4943
  try {
@@ -4363,9 +4947,21 @@ async function doctor_handler(args) {
4363
4947
  if ("string" == typeof check.detail) check.detail = toMcpSpeak(check.detail);
4364
4948
  if ("string" == typeof check.remediation) check.remediation = toMcpSpeak(check.remediation);
4365
4949
  }
4950
+ let healthy = 0 === code;
4951
+ const contract = doctor_readReadyContract(projectPath, browser);
4952
+ if (contract?.status === "error") {
4953
+ healthy = false;
4954
+ const detail = contract.errors && contract.errors.length ? contract.errors.join("; ") : contract.message || "The dev session recorded status: error in ready.json.";
4955
+ checks.push({
4956
+ check: "runtime-errors",
4957
+ status: "fail",
4958
+ detail: toMcpSpeak(detail),
4959
+ remediation: "The build or extension load failed. Fix the reported error, let the dev server recompile, then re-run doctor."
4960
+ });
4961
+ }
4366
4962
  return JSON.stringify({
4367
4963
  browser,
4368
- healthy: 0 === code,
4964
+ healthy,
4369
4965
  checks
4370
4966
  });
4371
4967
  } catch {
@@ -4412,6 +5008,55 @@ function checkPrimitive(path, value, schema, issues) {
4412
5008
  });
4413
5009
  }
4414
5010
  }
5011
+ const ARG_ALIASES = {
5012
+ projectPath: [
5013
+ "path",
5014
+ "dir",
5015
+ "projectDir",
5016
+ "cwd"
5017
+ ],
5018
+ projectName: [
5019
+ "name"
5020
+ ],
5021
+ parentDir: [
5022
+ "parent",
5023
+ "into"
5024
+ ],
5025
+ slug: [
5026
+ "template",
5027
+ "templateSlug"
5028
+ ],
5029
+ expression: [
5030
+ "code",
5031
+ "js",
5032
+ "script"
5033
+ ],
5034
+ manifestPath: [
5035
+ "manifest"
5036
+ ],
5037
+ surface: [
5038
+ "view"
5039
+ ]
5040
+ };
5041
+ function normalizeArgAliases(inputSchema, args) {
5042
+ const schema = inputSchema;
5043
+ const props = schema.properties ?? {};
5044
+ const out = {
5045
+ ...args
5046
+ };
5047
+ for (const [canonical, aliases] of Object.entries(ARG_ALIASES))if (canonical in props) {
5048
+ if (void 0 === out[canonical]) {
5049
+ for (const alias of aliases)if (!(alias in props)) {
5050
+ if (void 0 !== out[alias]) {
5051
+ out[canonical] = out[alias];
5052
+ delete out[alias];
5053
+ break;
5054
+ }
5055
+ }
5056
+ }
5057
+ }
5058
+ return out;
5059
+ }
4415
5060
  function validateToolInput(inputSchema, args) {
4416
5061
  const schema = inputSchema;
4417
5062
  const issues = [];
@@ -4510,7 +5155,8 @@ async function startServer() {
4510
5155
  ],
4511
5156
  isError: true
4512
5157
  };
4513
- const issues = validateToolInput(tool.schema.inputSchema, args ?? {});
5158
+ const normalizedArgs = normalizeArgAliases(tool.schema.inputSchema, args ?? {});
5159
+ const issues = validateToolInput(tool.schema.inputSchema, normalizedArgs);
4514
5160
  if (issues.length) return {
4515
5161
  content: [
4516
5162
  {
@@ -4521,7 +5167,7 @@ async function startServer() {
4521
5167
  isError: true
4522
5168
  };
4523
5169
  try {
4524
- const result = await tool.handler(args ?? {});
5170
+ const result = await tool.handler(normalizedArgs);
4525
5171
  return {
4526
5172
  content: [
4527
5173
  {
@@ -4599,6 +5245,31 @@ async function runCli(cmd, args) {
4599
5245
  const apiBase = resolveApiBase(flag("api"));
4600
5246
  try {
4601
5247
  const config = await fetchLoginConfig(apiBase);
5248
+ if ("extensiondev" === config.provider) {
5249
+ const start = await requestDeviceCode({
5250
+ apiBase,
5251
+ path: config.deviceCodeUrl,
5252
+ project
5253
+ });
5254
+ log("");
5255
+ log(` Open ${start.verificationUri} and enter code: ${start.userCode}`);
5256
+ log("");
5257
+ log(" Waiting for authorization...");
5258
+ const poll = await pollDeviceToken({
5259
+ apiBase,
5260
+ path: config.deviceTokenUrl,
5261
+ project,
5262
+ deviceCode: start.deviceCode,
5263
+ interval: start.interval,
5264
+ budgetMs: 1000 * start.expiresIn
5265
+ });
5266
+ if (!poll.ok) {
5267
+ 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.");
5268
+ return 1;
5269
+ }
5270
+ log(`Logged in to ${poll.creds.workspaceSlug}/${poll.creds.projectSlug}.`);
5271
+ return 0;
5272
+ }
4602
5273
  const start = await startDeviceCode({
4603
5274
  clientId: config.clientId,
4604
5275
  scope: config.scope