api-emulator 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -6,6 +6,11 @@ import { Command } from "commander";
6
6
  import { isAbsolute, resolve } from "path";
7
7
 
8
8
  // src/plugin-manifest.ts
9
+ function formatPluginFidelity(fidelity) {
10
+ if (!fidelity) return "unrated";
11
+ if (typeof fidelity === "string") return fidelity;
12
+ return fidelity.level;
13
+ }
9
14
  function readPluginManifest(mod) {
10
15
  return mod.manifest ?? {};
11
16
  }
@@ -25,20 +30,21 @@ function validatePluginManifest(manifest, pluginName) {
25
30
  async function loadExternalPluginModule(specifier) {
26
31
  const modulePath = specifier.startsWith(".") || isAbsolute(specifier) ? resolve(specifier) : specifier;
27
32
  const mod = await import(modulePath);
28
- const plugin = mod.plugin ?? mod.default;
29
- if (!plugin || typeof plugin.register !== "function" || typeof plugin.name !== "string") {
33
+ const plugin2 = mod.plugin ?? mod.default;
34
+ if (!plugin2 || typeof plugin2.register !== "function" || typeof plugin2.name !== "string") {
30
35
  throw new Error(`Plugin "${specifier}" must export a ServicePlugin (as "plugin" or default export)`);
31
36
  }
32
- const name = plugin.name;
37
+ const name = plugin2.name;
33
38
  const manifest = validatePluginManifest(readPluginManifest(mod), name);
34
39
  return {
35
40
  name,
36
41
  label: manifest.label,
37
42
  endpoints: manifest.endpoints,
43
+ fidelity: formatPluginFidelity(manifest.fidelity),
38
44
  manifest,
39
45
  async load() {
40
46
  return {
41
- plugin,
47
+ plugin: plugin2,
42
48
  seedFromConfig: mod.seedFromConfig
43
49
  };
44
50
  },
@@ -118,6 +124,7 @@ import pc from "picocolors";
118
124
 
119
125
  // src/portless.ts
120
126
  import { execSync, spawnSync } from "child_process";
127
+ import { Socket } from "net";
121
128
  import { createInterface } from "readline";
122
129
  function isInteractive() {
123
130
  return Boolean(process.stdin.isTTY) && !process.env.CI;
@@ -127,32 +134,60 @@ function hasPortless() {
127
134
  return result.status === 0;
128
135
  }
129
136
  function promptYesNo(question) {
130
- return new Promise((resolve7) => {
137
+ return new Promise((resolve9) => {
131
138
  const rl = createInterface({ input: process.stdin, output: process.stdout });
132
139
  rl.question(question, (answer) => {
133
140
  rl.close();
134
141
  const normalized = answer.trim().toLowerCase();
135
- resolve7(normalized === "" || normalized === "y" || normalized === "yes");
142
+ resolve9(normalized === "" || normalized === "y" || normalized === "yes");
136
143
  });
137
144
  });
138
145
  }
139
- function isProxyRunning() {
140
- const result = spawnSync("portless", ["list"], { stdio: "ignore" });
141
- return result.status === 0;
146
+ function portlessUrl(name) {
147
+ const result = spawnSync("portless", ["get", name], { encoding: "utf-8" });
148
+ if (result.status !== 0) return null;
149
+ const url = result.stdout.trim();
150
+ return url.length > 0 ? url : null;
142
151
  }
152
+ async function canConnect(port) {
153
+ return new Promise((resolve9) => {
154
+ const socket = new Socket();
155
+ const done = (ok) => {
156
+ socket.destroy();
157
+ resolve9(ok);
158
+ };
159
+ socket.setTimeout(500);
160
+ socket.once("connect", () => done(true));
161
+ socket.once("error", () => done(false));
162
+ socket.once("timeout", () => done(false));
163
+ socket.connect(port, "127.0.0.1");
164
+ });
165
+ }
166
+ async function isProxyRunning() {
167
+ const url = portlessUrl("api-emulator-probe");
168
+ if (!url) return false;
169
+ try {
170
+ const parsed = new URL(url);
171
+ const port = parsed.port ? Number(parsed.port) : parsed.protocol === "http:" ? 80 : 443;
172
+ return await canConnect(port);
173
+ } catch {
174
+ return false;
175
+ }
176
+ }
177
+ var portlessInstallCommand = "bun add --global portless";
143
178
  async function ensurePortless() {
144
179
  if (!hasPortless()) {
145
180
  if (!isInteractive()) {
146
- console.error("portless is required but not installed. Run: npm i -g portless");
181
+ console.error(`portless is required but not installed. Run: ${portlessInstallCommand}`);
147
182
  process.exit(1);
148
183
  }
149
- const yes = await promptYesNo("portless is not installed. Install it now? (npm i -g portless) [Y/n] ");
184
+ const yes = await promptYesNo(`portless is not installed. Install it now? (${portlessInstallCommand}) [Y/n] `);
150
185
  if (!yes) {
151
186
  console.error("Cannot continue without portless.");
152
187
  process.exit(1);
153
188
  }
154
189
  try {
155
- execSync("npm i -g portless", { stdio: "inherit" });
190
+ execSync(portlessInstallCommand, { stdio: "inherit" });
156
191
  } catch {
157
192
  console.error("Failed to install portless.");
158
193
  process.exit(1);
@@ -162,7 +197,7 @@ async function ensurePortless() {
162
197
  process.exit(1);
163
198
  }
164
199
  }
165
- if (!isProxyRunning()) {
200
+ if (!await isProxyRunning()) {
166
201
  console.error("portless proxy is not running. Start it with: portless proxy start");
167
202
  process.exit(1);
168
203
  }
@@ -191,7 +226,7 @@ function removeAliases(aliases) {
191
226
  }
192
227
  }
193
228
  function portlessBaseUrl(serviceName) {
194
- return `https://${serviceName}.api-emulator.localhost`;
229
+ return portlessUrl(`${serviceName}.api-emulator`) ?? `https://${serviceName}.api-emulator.localhost`;
195
230
  }
196
231
 
197
232
  // src/base-url.ts
@@ -206,9 +241,9 @@ function resolveBaseUrl(opts) {
206
241
  if (envBaseUrl) {
207
242
  return envBaseUrl.replace(/\{service\}/g, opts.service);
208
243
  }
209
- const portlessUrl = process.env.PORTLESS_URL;
210
- if (portlessUrl) {
211
- return portlessUrl.replace(/\{service\}/g, opts.service);
244
+ const portlessUrl2 = process.env.PORTLESS_URL;
245
+ if (portlessUrl2) {
246
+ return portlessUrl2.replace(/\{service\}/g, opts.service);
212
247
  }
213
248
  return `http://localhost:${opts.port}`;
214
249
  }
@@ -279,18 +314,36 @@ function createServiceRuntime(options) {
279
314
  seed();
280
315
  },
281
316
  close() {
282
- return new Promise((resolve7, reject) => {
317
+ return new Promise((resolve9, reject) => {
283
318
  httpServer.close((err) => {
284
319
  if (err) reject(err);
285
- else resolve7();
320
+ else resolve9();
286
321
  });
287
322
  });
288
323
  }
289
324
  };
290
325
  }
291
326
 
327
+ // src/cli-notifier.ts
328
+ import { spawn } from "child_process";
329
+ import { platform } from "os";
330
+ function escapeAppleScript(value) {
331
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
332
+ }
333
+ function appleScriptNotification({ title, message }) {
334
+ return `display notification "${escapeAppleScript(message)}" with title "${escapeAppleScript(title)}"`;
335
+ }
336
+ function notifyIfRequested(options) {
337
+ if (!options.enabled || platform() !== "darwin") return;
338
+ const child = spawn("/usr/bin/osascript", ["-e", appleScriptNotification(options)], {
339
+ detached: true,
340
+ stdio: "ignore"
341
+ });
342
+ child.unref();
343
+ }
344
+
292
345
  // src/commands/start.ts
293
- var pkg = { version: "0.6.0" };
346
+ var pkg = { version: "0.7.0" };
294
347
  function loadSeedConfig(seedPath) {
295
348
  if (seedPath) {
296
349
  const fullPath = resolve3(seedPath);
@@ -378,17 +431,17 @@ async function startCommand(options) {
378
431
  if (options.portless) {
379
432
  portlessAliases.push({ name: `${svc}.api-emulator`, port });
380
433
  }
381
- const seedBaseUrl = typeof svcSeedConfig?.baseUrl === "string" && svcSeedConfig.baseUrl.length > 0 ? svcSeedConfig.baseUrl : void 0;
382
- const effectiveBaseUrl = options.portless ? portlessBaseUrl(svc) : options.baseUrl;
383
- const baseUrl = resolveBaseUrl({ service: svc, port, baseUrl: effectiveBaseUrl, seedBaseUrl });
384
- prepared.push({ svc, pluginModule, loadedPlugin, svcSeedConfig, port, baseUrl });
434
+ prepared.push({ svc, pluginModule, loadedPlugin, svcSeedConfig, port });
385
435
  }
386
436
  if (portlessAliases.length > 0) {
387
437
  registerAliases(portlessAliases);
388
438
  }
389
439
  const serviceUrls = [];
390
440
  const runningServices = [];
391
- for (const { svc, pluginModule, loadedPlugin, svcSeedConfig, port, baseUrl } of prepared) {
441
+ for (const { svc, pluginModule, loadedPlugin, svcSeedConfig, port } of prepared) {
442
+ const seedBaseUrl = typeof svcSeedConfig?.baseUrl === "string" && svcSeedConfig.baseUrl.length > 0 ? svcSeedConfig.baseUrl : void 0;
443
+ const effectiveBaseUrl = options.portless ? portlessBaseUrl(svc) : options.baseUrl;
444
+ const baseUrl = resolveBaseUrl({ service: svc, port, baseUrl: effectiveBaseUrl, seedBaseUrl });
392
445
  serviceUrls.push({ name: svc, url: baseUrl });
393
446
  const running = createServiceRuntime({
394
447
  service: svc,
@@ -402,6 +455,11 @@ async function startCommand(options) {
402
455
  runningServices.push(running);
403
456
  }
404
457
  printBanner(serviceUrls, tokens, configSource);
458
+ notifyIfRequested({
459
+ enabled: options.notify,
460
+ title: "api-emulator",
461
+ message: `Server started with ${serviceUrls.length} service${serviceUrls.length === 1 ? "" : "s"}`
462
+ });
405
463
  const shutdown = () => {
406
464
  console.log(`
407
465
  ${pc.dim("Shutting down...")}`);
@@ -446,13 +504,143 @@ function printBanner(services, tokens, configSource) {
446
504
  }
447
505
 
448
506
  // src/commands/init.ts
449
- import { writeFileSync as writeFileSync2, existsSync as existsSync3 } from "fs";
450
- import { resolve as resolve4 } from "path";
507
+ import { writeFileSync as writeFileSync3, existsSync as existsSync4 } from "fs";
508
+ import { homedir } from "os";
509
+ import { resolve as resolve5 } from "path";
451
510
  import { stringify as yamlStringify } from "yaml";
511
+
512
+ // src/generated-manifest.ts
513
+ import { createHash } from "crypto";
514
+ import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
515
+ import { dirname, resolve as resolve4 } from "path";
516
+ var GENERATED_MANIFEST_FILE = ".api-emulator/manifest.json";
517
+ function checksum(content) {
518
+ return createHash("sha256").update(content).digest("hex");
519
+ }
520
+ function readGeneratedManifest(cwd = process.cwd()) {
521
+ const path = resolve4(cwd, GENERATED_MANIFEST_FILE);
522
+ if (!existsSync3(path)) return { version: 1, files: {} };
523
+ const parsed = JSON.parse(readFileSync3(path, "utf-8"));
524
+ if (parsed.version !== 1 || typeof parsed.files !== "object" || parsed.files === null) {
525
+ throw new Error(`Invalid ${GENERATED_MANIFEST_FILE}`);
526
+ }
527
+ return parsed;
528
+ }
529
+ function writeGeneratedManifest(manifest, cwd = process.cwd()) {
530
+ const path = resolve4(cwd, GENERATED_MANIFEST_FILE);
531
+ mkdirSync(dirname(path), { recursive: true });
532
+ const files = Object.fromEntries(Object.entries(manifest.files).sort(([a], [b]) => a.localeCompare(b)));
533
+ writeFileSync2(path, `${JSON.stringify({ version: 1, files }, null, 2)}
534
+ `, "utf-8");
535
+ }
536
+ function writeGeneratedFile(relativePath, content, options) {
537
+ const cwd = options.cwd ?? process.cwd();
538
+ const path = resolve4(cwd, relativePath);
539
+ const manifest = readGeneratedManifest(cwd);
540
+ const existing = manifest.files[relativePath];
541
+ if (existsSync3(path)) {
542
+ const current = readFileSync3(path, "utf-8");
543
+ if (current === content) {
544
+ manifest.files[relativePath] = { checksum: checksum(content), source: options.source };
545
+ writeGeneratedManifest(manifest, cwd);
546
+ return;
547
+ }
548
+ if (!options.yes && (!existing || existing.checksum !== checksum(current))) {
549
+ throw new Error(`Refusing to overwrite user-edited file: ${relativePath}. Re-run with --yes to overwrite.`);
550
+ }
551
+ }
552
+ mkdirSync(dirname(path), { recursive: true });
553
+ writeFileSync2(path, content, "utf-8");
554
+ manifest.files[relativePath] = { checksum: checksum(content), source: options.source };
555
+ writeGeneratedManifest(manifest, cwd);
556
+ }
557
+
558
+ // src/commands/init.ts
559
+ var AGENT_SKILL_DIRS = {
560
+ agents: ".agents/skills",
561
+ claude: ".claude/skills",
562
+ codex: ".codex/skills",
563
+ cursor: ".cursor/skills",
564
+ factory: ".agents/skills",
565
+ global: `${homedir()}/.agents/skills`,
566
+ "user-agents": `${homedir()}/.agents/skills`,
567
+ windsurf: ".windsurf/skills"
568
+ };
569
+ function parseAgentTargets(input, nonInteractive) {
570
+ const values = input?.split(",").map((value) => value.trim()).filter(Boolean) ?? [];
571
+ if (values.length > 0) return values;
572
+ return nonInteractive ? ["agents"] : ["agents"];
573
+ }
574
+ function pluginAuthoringSkill() {
575
+ return `---
576
+ name: api-emulator-plugin-authoring
577
+ description: Create, validate, and install api-emulator provider plugins.
578
+ ---
579
+
580
+ # API Emulator Plugin Authoring
581
+
582
+ Use this skill when adding or validating a provider plugin for api-emulator.
583
+
584
+ ## Workflow
585
+
586
+ 1. Create a provider clone scaffold with \`npx -p api-emulator api clone create <provider>\`.
587
+ 2. Add provider-shaped routes, deterministic seed state, and manifest fidelity metadata.
588
+ 3. Run \`npx -p api-emulator api validate-plugin <provider>\`.
589
+ 4. Run the relevant smoke or test command before calling the plugin done.
590
+
591
+ ## Safety
592
+
593
+ - Never call production APIs from smoke tests.
594
+ - Use dummy credentials and temporary CLI homes.
595
+ - Prefer documented base URL overrides before patching SDKs or CLIs.
596
+ `;
597
+ }
598
+ function runtimeSkill() {
599
+ return `---
600
+ name: api-emulator-runtime
601
+ description: Run api-emulator locally with configured provider plugins.
602
+ ---
603
+
604
+ # API Emulator Runtime
605
+
606
+ Use this skill when running local API replacements for development or tests.
607
+
608
+ ## Commands
609
+
610
+ - \`npx -p api-emulator api list\`
611
+ - \`npx -p api-emulator api init\`
612
+ - \`npx -p api-emulator api install <plugin>\`
613
+ - \`npx -p api-emulator api clone create <provider>\`
614
+ - \`npx -p api-emulator api --service <service>\`
615
+
616
+ Keep seed data deterministic and reset emulator state between tests.
617
+ `;
618
+ }
619
+ function installAgentSkills(options) {
620
+ for (const target of parseAgentTargets(options.agents, options.nonInteractive)) {
621
+ const dir = AGENT_SKILL_DIRS[target];
622
+ if (!dir) {
623
+ throw new Error(`Unknown agent target: ${target}. Available: ${Object.keys(AGENT_SKILL_DIRS).join(", ")}`);
624
+ }
625
+ writeGeneratedFile(`${dir}/api-emulator-plugin-authoring/SKILL.md`, pluginAuthoringSkill(), {
626
+ source: "api init",
627
+ yes: options.yes
628
+ });
629
+ writeGeneratedFile(`${dir}/api-emulator-runtime/SKILL.md`, runtimeSkill(), {
630
+ source: "api init",
631
+ yes: options.yes
632
+ });
633
+ }
634
+ }
452
635
  async function initCommand(options) {
636
+ if (options.agents || options.skillsOnly) {
637
+ installAgentSkills(options);
638
+ console.log("Installed api-emulator agent skills");
639
+ }
640
+ if (options.skillsOnly) return;
453
641
  const filename = "api-emulator.config.yaml";
454
- const fullPath = resolve4(filename);
455
- if (existsSync3(fullPath)) {
642
+ const fullPath = resolve5(filename);
643
+ if (existsSync4(fullPath)) {
456
644
  console.error(`Config file already exists: ${filename}`);
457
645
  process.exit(1);
458
646
  }
@@ -474,7 +662,7 @@ async function initCommand(options) {
474
662
  config = { ...DEFAULT_TOKENS, ...pluginModule.initConfig };
475
663
  }
476
664
  const content = yamlStringify(config);
477
- writeFileSync2(fullPath, content, "utf-8");
665
+ writeFileSync3(fullPath, content, "utf-8");
478
666
  console.log(`Created ${filename}`);
479
667
  console.log(`
480
668
  Run 'npx -p api-emulator api' to start the emulator.`);
@@ -488,18 +676,19 @@ async function listCommand(options = {}) {
488
676
  for (const pluginModule of Object.values(pluginModules)) {
489
677
  console.log(` ${pluginModule.name.padEnd(10)}${pluginModule.label}`);
490
678
  console.log(` Endpoints: ${pluginModule.endpoints}`);
679
+ console.log(` Fidelity: ${pluginModule.fidelity}`);
491
680
  console.log();
492
681
  }
493
682
  }
494
683
 
495
684
  // src/commands/install.ts
496
685
  import { spawnSync as spawnSync2 } from "child_process";
497
- import { existsSync as existsSync5 } from "fs";
498
- import { resolve as resolve6 } from "path";
686
+ import { existsSync as existsSync6 } from "fs";
687
+ import { resolve as resolve7 } from "path";
499
688
 
500
689
  // src/plugin-source-registry.ts
501
- import { existsSync as existsSync4, readdirSync, readFileSync as readFileSync3, statSync } from "fs";
502
- import { dirname, join, resolve as resolve5 } from "path";
690
+ import { existsSync as existsSync5, readdirSync, readFileSync as readFileSync4, statSync } from "fs";
691
+ import { dirname as dirname2, join, resolve as resolve6 } from "path";
503
692
  var PLUGIN_SOURCES = {
504
693
  cloudflare: {
505
694
  name: "cloudflare",
@@ -517,17 +706,17 @@ var PACKAGE_ENTRY_DIRS = ["api-emulator", "trading-emulator"];
517
706
  function candidateCatalogRoots(cwd = process.cwd()) {
518
707
  const envRoots = process.env.API_EMULATOR_PLUGIN_CATALOGS?.split(",").map((value) => value.trim()).filter(Boolean) ?? [];
519
708
  const roots = [...envRoots];
520
- let current = resolve5(cwd);
709
+ let current = resolve6(cwd);
521
710
  for (; ; ) {
522
711
  for (const dir of DEFAULT_CATALOG_DIRS) {
523
712
  roots.push(join(current, dir));
524
- roots.push(join(dirname(current), dir));
713
+ roots.push(join(dirname2(current), dir));
525
714
  }
526
- const parent = dirname(current);
715
+ const parent = dirname2(current);
527
716
  if (parent === current) break;
528
717
  current = parent;
529
718
  }
530
- return [...new Set(roots.map((root) => resolve5(root)))];
719
+ return [...new Set(roots.map((root) => resolve6(root)))];
531
720
  }
532
721
  function sourceIdForRoot(root) {
533
722
  const base = root.split(/[\\/]/).pop() ?? "local";
@@ -536,29 +725,29 @@ function sourceIdForRoot(root) {
536
725
  return base;
537
726
  }
538
727
  function packageEntrySpecifier(packageRoot, pkg3) {
539
- if (typeof pkg3.exports === "string") return resolve5(packageRoot, pkg3.exports);
728
+ if (typeof pkg3.exports === "string") return resolve6(packageRoot, pkg3.exports);
540
729
  if (typeof pkg3.exports === "object" && pkg3.exports !== null && "." in pkg3.exports && typeof pkg3.exports["."] === "string") {
541
- return resolve5(packageRoot, pkg3.exports["."]);
730
+ return resolve6(packageRoot, pkg3.exports["."]);
542
731
  }
543
732
  if (typeof pkg3.exports === "object" && pkg3.exports !== null && "." in pkg3.exports && typeof pkg3.exports["."] === "object" && pkg3.exports["."] !== null && "import" in pkg3.exports["."] && typeof pkg3.exports["."].import === "string") {
544
- return resolve5(packageRoot, pkg3.exports["."].import);
733
+ return resolve6(packageRoot, pkg3.exports["."].import);
545
734
  }
546
- if (typeof pkg3.main === "string") return resolve5(packageRoot, pkg3.main);
735
+ if (typeof pkg3.main === "string") return resolve6(packageRoot, pkg3.main);
547
736
  return packageRoot;
548
737
  }
549
738
  function discoverManifest(root, sourceId) {
550
739
  const sources = [];
551
740
  for (const manifestFile of CATALOG_MANIFEST_FILES) {
552
741
  const manifestPath = join(root, manifestFile);
553
- if (!existsSync4(manifestPath)) continue;
554
- const manifest = JSON.parse(readFileSync3(manifestPath, "utf-8"));
742
+ if (!existsSync5(manifestPath)) continue;
743
+ const manifest = JSON.parse(readFileSync4(manifestPath, "utf-8"));
555
744
  for (const [name, entry] of Object.entries(manifest.plugins ?? {})) {
556
745
  sources.push({
557
746
  name,
558
747
  sourceId,
559
748
  kind: entry.kind ?? (entry.packageName ? "package" : "file"),
560
749
  packageName: entry.packageName,
561
- specifier: entry.specifier.startsWith(".") ? resolve5(root, entry.specifier) : entry.specifier,
750
+ specifier: entry.specifier.startsWith(".") ? resolve6(root, entry.specifier) : entry.specifier,
562
751
  description: entry.description ?? `${name} plugin from ${sourceId} catalog`
563
752
  });
564
753
  }
@@ -566,31 +755,18 @@ function discoverManifest(root, sourceId) {
566
755
  return sources;
567
756
  }
568
757
  function discoverCatalog(root) {
569
- if (!existsSync4(root) || !statSync(root).isDirectory()) return [];
758
+ if (!existsSync5(root) || !statSync(root).isDirectory()) return [];
570
759
  const sourceId = sourceIdForRoot(root);
571
760
  const sources = discoverManifest(root, sourceId);
572
761
  for (const entry of readdirSync(root, { withFileTypes: true })) {
573
762
  if (!entry.isDirectory() || !entry.name.startsWith("@")) continue;
574
763
  const name = entry.name.slice(1);
575
764
  const pluginRoot = join(root, entry.name);
576
- for (const entryFile of PLUGIN_ENTRY_FILES) {
577
- const specifier = join(pluginRoot, entryFile);
578
- if (existsSync4(specifier)) {
579
- sources.push({
580
- name,
581
- sourceId,
582
- kind: "file",
583
- specifier,
584
- description: `${name} plugin from ${sourceId} catalog`
585
- });
586
- break;
587
- }
588
- }
589
765
  for (const entryDir of PACKAGE_ENTRY_DIRS) {
590
766
  const packageRoot = join(pluginRoot, entryDir);
591
767
  const packageJsonPath = join(packageRoot, "package.json");
592
- if (!existsSync4(packageJsonPath)) continue;
593
- const pkg3 = JSON.parse(readFileSync3(packageJsonPath, "utf-8"));
768
+ if (!existsSync5(packageJsonPath)) continue;
769
+ const pkg3 = JSON.parse(readFileSync4(packageJsonPath, "utf-8"));
594
770
  sources.push({
595
771
  name,
596
772
  sourceId,
@@ -602,6 +778,19 @@ function discoverCatalog(root) {
602
778
  });
603
779
  break;
604
780
  }
781
+ for (const entryFile of PLUGIN_ENTRY_FILES) {
782
+ const specifier = join(pluginRoot, entryFile);
783
+ if (existsSync5(specifier)) {
784
+ sources.push({
785
+ name,
786
+ sourceId,
787
+ kind: "file",
788
+ specifier,
789
+ description: `${name} plugin from ${sourceId} catalog`
790
+ });
791
+ break;
792
+ }
793
+ }
605
794
  }
606
795
  return sources;
607
796
  }
@@ -620,9 +809,9 @@ function resolvePluginSource(name) {
620
809
 
621
810
  // src/commands/install.ts
622
811
  function detectPackageManager() {
623
- if (existsSync5(resolve6("bun.lock")) || existsSync5(resolve6("bun.lockb"))) return "bun";
624
- if (existsSync5(resolve6("pnpm-lock.yaml"))) return "pnpm";
625
- if (existsSync5(resolve6("yarn.lock"))) return "yarn";
812
+ if (existsSync6(resolve7("bun.lock")) || existsSync6(resolve7("bun.lockb"))) return "bun";
813
+ if (existsSync6(resolve7("pnpm-lock.yaml"))) return "pnpm";
814
+ if (existsSync6(resolve7("yarn.lock"))) return "yarn";
626
815
  return "npm";
627
816
  }
628
817
  function installPackage(packageManager, packageName) {
@@ -660,12 +849,188 @@ async function installCommand(name, options = {}) {
660
849
  console.log(`Recorded plugin in ${PLUGIN_LOCK_FILE}`);
661
850
  }
662
851
 
852
+ // src/commands/validate-plugin.ts
853
+ import { existsSync as existsSync7, mkdtempSync, rmSync } from "fs";
854
+ import { tmpdir } from "os";
855
+ import { basename, extname, isAbsolute as isAbsolute2, join as join2, resolve as resolve8 } from "path";
856
+ import { spawnSync as spawnSync3 } from "child_process";
857
+ function resolveSource(input) {
858
+ const source = resolvePluginSource(input);
859
+ if (source) return source;
860
+ const specifier = input.startsWith(".") || isAbsolute2(input) ? resolve8(input) : input;
861
+ return {
862
+ name: basename(input).replace(/\.[^.]+$/, ""),
863
+ sourceId: "specifier",
864
+ kind: "file",
865
+ specifier,
866
+ description: `${input} plugin specifier`
867
+ };
868
+ }
869
+ function assertLocalFile(path) {
870
+ if ((path.startsWith(".") || isAbsolute2(path)) && !existsSync7(resolve8(path))) {
871
+ throw new Error(`Plugin entry does not exist: ${path}`);
872
+ }
873
+ }
874
+ function canBuild(specifier) {
875
+ return [".ts", ".tsx", ".js", ".mjs"].includes(extname(specifier));
876
+ }
877
+ function buildEntry(specifier) {
878
+ if (!canBuild(specifier)) return;
879
+ const outdir = mkdtempSync(join2(tmpdir(), "api-emulator-plugin-validate-"));
880
+ try {
881
+ const result = spawnSync3("bun", ["build", specifier, "--packages", "external", "--outdir", outdir], {
882
+ stdio: "pipe",
883
+ encoding: "utf-8"
884
+ });
885
+ if (result.error) {
886
+ if (result.error.code === "ENOENT") {
887
+ console.log(" build: skipped (bun not found)");
888
+ return;
889
+ }
890
+ throw result.error;
891
+ }
892
+ if (result.status !== 0) {
893
+ throw new Error((result.stderr || result.stdout || "Plugin build failed").trim());
894
+ }
895
+ console.log(" build: ok");
896
+ } finally {
897
+ rmSync(outdir, { recursive: true, force: true });
898
+ }
899
+ }
900
+ async function loadPlugin(specifier, expectedName) {
901
+ const module = await loadExternalPluginModule(specifier);
902
+ if (module.name !== expectedName) {
903
+ throw new Error(`Plugin exported name "${module.name}" does not match requested plugin "${expectedName}"`);
904
+ }
905
+ await module.load();
906
+ console.log(" load: ok");
907
+ }
908
+ async function validatePluginCommand(input, options = {}) {
909
+ const source = resolveSource(input);
910
+ console.log(`Validating ${source.name} from ${source.sourceId}`);
911
+ console.log(` specifier: ${source.specifier}`);
912
+ if (source.packageName) console.log(` package: ${source.packageName}`);
913
+ assertLocalFile(source.specifier);
914
+ console.log(" entry: ok");
915
+ if (!options.skipBuild) {
916
+ buildEntry(source.specifier);
917
+ }
918
+ if (options.skipLoad) {
919
+ console.log(" load: skipped");
920
+ } else {
921
+ await loadPlugin(source.specifier, source.name);
922
+ }
923
+ console.log(`Plugin ${source.name} is valid`);
924
+ }
925
+
926
+ // src/commands/plugin.ts
927
+ import { existsSync as existsSync8, readFileSync as readFileSync5 } from "fs";
928
+ import { join as join3 } from "path";
929
+ function normalizePluginName(input) {
930
+ const name = input.trim().replace(/^@/, "");
931
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) {
932
+ throw new Error("Plugin names must use lowercase letters, numbers, and hyphens");
933
+ }
934
+ return name;
935
+ }
936
+ function pluginEntry(name, fidelity) {
937
+ return `export const manifest = {
938
+ name: "${name}",
939
+ label: "${name} API emulator",
940
+ endpoints: "GET /health",
941
+ fidelity: {
942
+ level: "${fidelity}",
943
+ endpoints: ["GET /health"],
944
+ seedableResources: [],
945
+ smoke: "not-started",
946
+ },
947
+ initConfig: {
948
+ ${JSON.stringify(name)}: {},
949
+ },
950
+ };
951
+
952
+ export const plugin = {
953
+ name: "${name}",
954
+ register(app) {
955
+ app.get("/health", (c) => c.json({ ok: true, service: "${name}" }));
956
+ },
957
+ };
958
+ `;
959
+ }
960
+ function catalogContent(name, specifier, description) {
961
+ const catalogPath = "api-emulator.catalog.json";
962
+ const catalog = existsSync8(catalogPath) ? JSON.parse(readFileSync5(catalogPath, "utf-8")) : {};
963
+ catalog.plugins ??= {};
964
+ catalog.plugins[name] = {
965
+ kind: "file",
966
+ specifier,
967
+ description
968
+ };
969
+ return `${JSON.stringify(catalog, null, 2)}
970
+ `;
971
+ }
972
+ async function pluginCreateCommand(input, options = {}) {
973
+ const name = normalizePluginName(input);
974
+ const pluginDir = options.dir ?? `@${name}`;
975
+ const entryPath = join3(pluginDir, "api-emulator.mjs");
976
+ const catalogSpecifier = `./${entryPath}`;
977
+ writeGeneratedFile(entryPath, pluginEntry(name, options.fidelity ?? "stub"), {
978
+ source: "api clone create",
979
+ yes: options.yes
980
+ });
981
+ writeGeneratedFile(
982
+ "api-emulator.catalog.json",
983
+ catalogContent(name, catalogSpecifier, `${name} API emulator plugin`),
984
+ {
985
+ source: "api clone create",
986
+ yes: options.yes
987
+ }
988
+ );
989
+ console.log(`Created ${entryPath}`);
990
+ console.log(`Recorded ${name} in api-emulator.catalog.json`);
991
+ console.log(`Run 'npx -p api-emulator api validate-plugin ${name}' to check the clone.`);
992
+ }
993
+
663
994
  // src/index.ts
664
- var pkg2 = { version: "0.6.0" };
995
+ var pkg2 = { version: "0.7.0" };
665
996
  var defaultPort = process.env.API_EMULATOR_PORT ?? process.env.PORT ?? "4000";
666
997
  var program = new Command();
667
- program.name("api").description("Local API emulators you can run, share, and extend with plugins").version(pkg2.version);
668
- program.command("start", { isDefault: true }).description("Start the emulator server").option("-p, --port <port>", "Base port", defaultPort).option("-s, --service <services>", "Comma-separated services to enable").option("--seed <file>", "Path to seed config file").option("--base-url <url>", "Override advertised base URL (supports {service} template)").option("--portless", "Serve over HTTPS via portless (auto-registers aliases)").option("--plugin <plugins>", "Comma-separated external plugin paths or package names").action(async (opts) => {
998
+ program.name("api").description("Local API emulators you can run, share, and extend with plugins").version(pkg2.version).option("--notify", "Show a macOS notification for useful command milestones");
999
+ function notifyOption(command) {
1000
+ return command.option("--notify", "Show a macOS notification for useful command milestones");
1001
+ }
1002
+ function wantsNotify(command, opts) {
1003
+ return Boolean(opts.notify ?? command.optsWithGlobals().notify);
1004
+ }
1005
+ function wantsStartNotify(opts) {
1006
+ return opts.notify !== false;
1007
+ }
1008
+ function formatDuration(ms) {
1009
+ if (ms < 1e3) return `${ms}ms`;
1010
+ return `${(ms / 1e3).toFixed(1)}s`;
1011
+ }
1012
+ async function runCommandWithNotification(label, enabled, fn) {
1013
+ const startedAt = Date.now();
1014
+ try {
1015
+ await fn();
1016
+ notifyIfRequested({
1017
+ enabled,
1018
+ title: "api-emulator",
1019
+ message: `${label} finished in ${formatDuration(Date.now() - startedAt)}`
1020
+ });
1021
+ } catch (err) {
1022
+ notifyIfRequested({
1023
+ enabled,
1024
+ title: "api-emulator",
1025
+ message: `${label} failed after ${formatDuration(Date.now() - startedAt)}`
1026
+ });
1027
+ console.error(err instanceof Error ? err.message : err);
1028
+ process.exit(1);
1029
+ }
1030
+ }
1031
+ notifyOption(
1032
+ program.command("start", { isDefault: true }).description("Start the emulator server").option("-p, --port <port>", "Base port", defaultPort).option("-s, --service <services>", "Comma-separated services to enable").option("--seed <file>", "Path to seed config file").option("--base-url <url>", "Override advertised base URL (supports {service} template)").option("--portless", "Serve over HTTPS via portless (auto-registers aliases)").option("--plugin <plugins>", "Comma-separated external plugin paths or package names").option("--no-notify", "Disable the macOS notification when the emulator server is ready")
1033
+ ).action(async (opts) => {
669
1034
  const port = parseInt(opts.port, 10);
670
1035
  if (Number.isNaN(port) || port < 1 || port > 65535) {
671
1036
  console.error(`Invalid port: ${opts.port}`);
@@ -677,24 +1042,62 @@ program.command("start", { isDefault: true }).description("Start the emulator se
677
1042
  seed: opts.seed,
678
1043
  baseUrl: opts.baseUrl,
679
1044
  portless: opts.portless,
680
- plugin: opts.plugin
1045
+ plugin: opts.plugin,
1046
+ notify: wantsStartNotify(opts)
681
1047
  });
682
1048
  });
683
- program.command("init").description("Generate a starter config file").option("-s, --service <service>", "Service to generate config for", "all").option("--plugin <plugins>", "Comma-separated external plugin paths or package names").action(async (opts) => {
684
- await initCommand({ service: opts.service, plugin: opts.plugin });
1049
+ notifyOption(
1050
+ program.command("init").description("Generate a starter config file").option("-s, --service <service>", "Service to generate config for", "all").option("--plugin <plugins>", "Comma-separated external plugin paths or package names").option("--agents <targets>", "Install agent skills for agents,user-agents,claude,codex,cursor,windsurf").option("--skills-only", "Only install agent skills").option("--yes", "Overwrite generated files that changed").option("--non-interactive", "Use defaults suitable for agents and CI")
1051
+ ).action(async (opts, command) => {
1052
+ await runCommandWithNotification("init", wantsNotify(command, opts), async () => {
1053
+ await initCommand({
1054
+ service: opts.service,
1055
+ plugin: opts.plugin,
1056
+ agents: opts.agents,
1057
+ skillsOnly: opts.skillsOnly,
1058
+ yes: opts.yes,
1059
+ nonInteractive: opts.nonInteractive
1060
+ });
1061
+ });
685
1062
  });
1063
+ function addPluginCreateCommand(command) {
1064
+ notifyOption(
1065
+ command.command("create <name>").description("Scaffold a provider clone").option("--dir <dir>", "Directory to create the clone in").option("--fidelity <level>", "Initial fidelity level", "stub").option("--yes", "Overwrite generated files that changed")
1066
+ ).action(async (name, opts, actionCommand) => {
1067
+ await runCommandWithNotification("plugin create", wantsNotify(actionCommand, opts), async () => {
1068
+ await pluginCreateCommand(name, {
1069
+ dir: opts.dir,
1070
+ fidelity: opts.fidelity,
1071
+ yes: opts.yes
1072
+ });
1073
+ });
1074
+ });
1075
+ }
1076
+ var plugin = program.command("plugin").description("Create and manage provider plugins");
1077
+ addPluginCreateCommand(plugin);
1078
+ var clone = program.command("clone").description("Create and manage provider clones");
1079
+ addPluginCreateCommand(clone);
686
1080
  program.command("list").alias("list-services").description("List available services").option("--plugin <plugins>", "Comma-separated external plugin paths or package names").action(async (opts) => {
687
1081
  await listCommand({ plugin: opts.plugin });
688
1082
  });
689
- program.command("install <plugin>").description("Install a provider plugin by name").option("--package-manager <name>", "Package manager to use").option("--no-package-manager", "Only record the plugin in api-emulator.lock").action(async (plugin, opts) => {
690
- try {
691
- await installCommand(plugin, {
1083
+ notifyOption(
1084
+ program.command("install <plugin>").description("Install a provider plugin by name").option("--package-manager <name>", "Package manager to use").option("--no-package-manager", "Only record the plugin in api-emulator.lock")
1085
+ ).action(async (plugin2, opts, command) => {
1086
+ await runCommandWithNotification("install", wantsNotify(command, opts), async () => {
1087
+ await installCommand(plugin2, {
692
1088
  packageManager: opts.packageManager === false ? false : opts.packageManager
693
1089
  });
694
- } catch (err) {
695
- console.error(err instanceof Error ? err.message : err);
696
- process.exit(1);
697
- }
1090
+ });
1091
+ });
1092
+ notifyOption(
1093
+ program.command("validate-plugin <plugin>").description("Validate a provider plugin by name, path, or package").option("--skip-build", "Skip entrypoint build validation").option("--skip-load", "Skip runtime module loading validation")
1094
+ ).action(async (plugin2, opts, command) => {
1095
+ await runCommandWithNotification("validate-plugin", wantsNotify(command, opts), async () => {
1096
+ await validatePluginCommand(plugin2, {
1097
+ skipBuild: opts.skipBuild,
1098
+ skipLoad: opts.skipLoad
1099
+ });
1100
+ });
698
1101
  });
699
1102
  program.parse();
700
1103
  //# sourceMappingURL=index.js.map