api-emulator 0.5.2 → 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,14 +6,13 @@ 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
- return {
11
- label: mod.label,
12
- endpoints: mod.endpoints,
13
- initConfig: mod.initConfig,
14
- contract: mod.contract,
15
- ...mod.manifest
16
- };
15
+ return mod.manifest ?? {};
17
16
  }
18
17
  function validatePluginManifest(manifest, pluginName) {
19
18
  if (manifest.name && manifest.name !== pluginName) {
@@ -31,20 +30,21 @@ function validatePluginManifest(manifest, pluginName) {
31
30
  async function loadExternalPluginModule(specifier) {
32
31
  const modulePath = specifier.startsWith(".") || isAbsolute(specifier) ? resolve(specifier) : specifier;
33
32
  const mod = await import(modulePath);
34
- const plugin = mod.plugin ?? mod.default;
35
- 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") {
36
35
  throw new Error(`Plugin "${specifier}" must export a ServicePlugin (as "plugin" or default export)`);
37
36
  }
38
- const name = plugin.name;
37
+ const name = plugin2.name;
39
38
  const manifest = validatePluginManifest(readPluginManifest(mod), name);
40
39
  return {
41
40
  name,
42
41
  label: manifest.label,
43
42
  endpoints: manifest.endpoints,
43
+ fidelity: formatPluginFidelity(manifest.fidelity),
44
44
  manifest,
45
45
  async load() {
46
46
  return {
47
- plugin,
47
+ plugin: plugin2,
48
48
  seedFromConfig: mod.seedFromConfig
49
49
  };
50
50
  },
@@ -124,6 +124,7 @@ import pc from "picocolors";
124
124
 
125
125
  // src/portless.ts
126
126
  import { execSync, spawnSync } from "child_process";
127
+ import { Socket } from "net";
127
128
  import { createInterface } from "readline";
128
129
  function isInteractive() {
129
130
  return Boolean(process.stdin.isTTY) && !process.env.CI;
@@ -133,32 +134,60 @@ function hasPortless() {
133
134
  return result.status === 0;
134
135
  }
135
136
  function promptYesNo(question) {
136
- return new Promise((resolve7) => {
137
+ return new Promise((resolve9) => {
137
138
  const rl = createInterface({ input: process.stdin, output: process.stdout });
138
139
  rl.question(question, (answer) => {
139
140
  rl.close();
140
141
  const normalized = answer.trim().toLowerCase();
141
- resolve7(normalized === "" || normalized === "y" || normalized === "yes");
142
+ resolve9(normalized === "" || normalized === "y" || normalized === "yes");
142
143
  });
143
144
  });
144
145
  }
145
- function isProxyRunning() {
146
- const result = spawnSync("portless", ["list"], { stdio: "ignore" });
147
- 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;
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
+ }
148
176
  }
177
+ var portlessInstallCommand = "bun add --global portless";
149
178
  async function ensurePortless() {
150
179
  if (!hasPortless()) {
151
180
  if (!isInteractive()) {
152
- console.error("portless is required but not installed. Run: npm i -g portless");
181
+ console.error(`portless is required but not installed. Run: ${portlessInstallCommand}`);
153
182
  process.exit(1);
154
183
  }
155
- 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] `);
156
185
  if (!yes) {
157
186
  console.error("Cannot continue without portless.");
158
187
  process.exit(1);
159
188
  }
160
189
  try {
161
- execSync("npm i -g portless", { stdio: "inherit" });
190
+ execSync(portlessInstallCommand, { stdio: "inherit" });
162
191
  } catch {
163
192
  console.error("Failed to install portless.");
164
193
  process.exit(1);
@@ -168,7 +197,7 @@ async function ensurePortless() {
168
197
  process.exit(1);
169
198
  }
170
199
  }
171
- if (!isProxyRunning()) {
200
+ if (!await isProxyRunning()) {
172
201
  console.error("portless proxy is not running. Start it with: portless proxy start");
173
202
  process.exit(1);
174
203
  }
@@ -197,7 +226,7 @@ function removeAliases(aliases) {
197
226
  }
198
227
  }
199
228
  function portlessBaseUrl(serviceName) {
200
- return `https://${serviceName}.api-emulator.localhost`;
229
+ return portlessUrl(`${serviceName}.api-emulator`) ?? `https://${serviceName}.api-emulator.localhost`;
201
230
  }
202
231
 
203
232
  // src/base-url.ts
@@ -208,13 +237,13 @@ function resolveBaseUrl(opts) {
208
237
  if (opts.baseUrl) {
209
238
  return opts.baseUrl.replace(/\{service\}/g, opts.service);
210
239
  }
211
- const envBaseUrl = process.env.API_EMULATOR_BASE_URL ?? process.env.EMULATE_BASE_URL;
240
+ const envBaseUrl = process.env.API_EMULATOR_BASE_URL;
212
241
  if (envBaseUrl) {
213
242
  return envBaseUrl.replace(/\{service\}/g, opts.service);
214
243
  }
215
- const portlessUrl = process.env.PORTLESS_URL;
216
- if (portlessUrl) {
217
- 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);
218
247
  }
219
248
  return `http://localhost:${opts.port}`;
220
249
  }
@@ -285,18 +314,36 @@ function createServiceRuntime(options) {
285
314
  seed();
286
315
  },
287
316
  close() {
288
- return new Promise((resolve7, reject) => {
317
+ return new Promise((resolve9, reject) => {
289
318
  httpServer.close((err) => {
290
319
  if (err) reject(err);
291
- else resolve7();
320
+ else resolve9();
292
321
  });
293
322
  });
294
323
  }
295
324
  };
296
325
  }
297
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
+
298
345
  // src/commands/start.ts
299
- var pkg = { version: "0.5.2" };
346
+ var pkg = { version: "0.7.0" };
300
347
  function loadSeedConfig(seedPath) {
301
348
  if (seedPath) {
302
349
  const fullPath = resolve3(seedPath);
@@ -313,17 +360,7 @@ function loadSeedConfig(seedPath) {
313
360
  process.exit(1);
314
361
  }
315
362
  }
316
- const autoFiles = [
317
- "api-emulator.config.yaml",
318
- "api-emulator.config.yml",
319
- "api-emulator.config.json",
320
- "emulate.config.yaml",
321
- "emulate.config.yml",
322
- "emulate.config.json",
323
- "service-emulator.config.yaml",
324
- "service-emulator.config.yml",
325
- "service-emulator.config.json"
326
- ];
363
+ const autoFiles = ["api-emulator.config.yaml", "api-emulator.config.yml", "api-emulator.config.json"];
327
364
  for (const file of autoFiles) {
328
365
  const fullPath = resolve3(file);
329
366
  if (existsSync2(fullPath)) {
@@ -394,17 +431,17 @@ async function startCommand(options) {
394
431
  if (options.portless) {
395
432
  portlessAliases.push({ name: `${svc}.api-emulator`, port });
396
433
  }
397
- const seedBaseUrl = typeof svcSeedConfig?.baseUrl === "string" && svcSeedConfig.baseUrl.length > 0 ? svcSeedConfig.baseUrl : void 0;
398
- const effectiveBaseUrl = options.portless ? portlessBaseUrl(svc) : options.baseUrl;
399
- const baseUrl = resolveBaseUrl({ service: svc, port, baseUrl: effectiveBaseUrl, seedBaseUrl });
400
- prepared.push({ svc, pluginModule, loadedPlugin, svcSeedConfig, port, baseUrl });
434
+ prepared.push({ svc, pluginModule, loadedPlugin, svcSeedConfig, port });
401
435
  }
402
436
  if (portlessAliases.length > 0) {
403
437
  registerAliases(portlessAliases);
404
438
  }
405
439
  const serviceUrls = [];
406
440
  const runningServices = [];
407
- 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 });
408
445
  serviceUrls.push({ name: svc, url: baseUrl });
409
446
  const running = createServiceRuntime({
410
447
  service: svc,
@@ -418,6 +455,11 @@ async function startCommand(options) {
418
455
  runningServices.push(running);
419
456
  }
420
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
+ });
421
463
  const shutdown = () => {
422
464
  console.log(`
423
465
  ${pc.dim("Shutting down...")}`);
@@ -462,13 +504,143 @@ function printBanner(services, tokens, configSource) {
462
504
  }
463
505
 
464
506
  // src/commands/init.ts
465
- import { writeFileSync as writeFileSync2, existsSync as existsSync3 } from "fs";
466
- 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";
467
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
+ }
468
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;
469
641
  const filename = "api-emulator.config.yaml";
470
- const fullPath = resolve4(filename);
471
- if (existsSync3(fullPath)) {
642
+ const fullPath = resolve5(filename);
643
+ if (existsSync4(fullPath)) {
472
644
  console.error(`Config file already exists: ${filename}`);
473
645
  process.exit(1);
474
646
  }
@@ -490,7 +662,7 @@ async function initCommand(options) {
490
662
  config = { ...DEFAULT_TOKENS, ...pluginModule.initConfig };
491
663
  }
492
664
  const content = yamlStringify(config);
493
- writeFileSync2(fullPath, content, "utf-8");
665
+ writeFileSync3(fullPath, content, "utf-8");
494
666
  console.log(`Created ${filename}`);
495
667
  console.log(`
496
668
  Run 'npx -p api-emulator api' to start the emulator.`);
@@ -504,18 +676,19 @@ async function listCommand(options = {}) {
504
676
  for (const pluginModule of Object.values(pluginModules)) {
505
677
  console.log(` ${pluginModule.name.padEnd(10)}${pluginModule.label}`);
506
678
  console.log(` Endpoints: ${pluginModule.endpoints}`);
679
+ console.log(` Fidelity: ${pluginModule.fidelity}`);
507
680
  console.log();
508
681
  }
509
682
  }
510
683
 
511
684
  // src/commands/install.ts
512
685
  import { spawnSync as spawnSync2 } from "child_process";
513
- import { existsSync as existsSync5 } from "fs";
514
- import { resolve as resolve6 } from "path";
686
+ import { existsSync as existsSync6 } from "fs";
687
+ import { resolve as resolve7 } from "path";
515
688
 
516
689
  // src/plugin-source-registry.ts
517
- import { existsSync as existsSync4, readdirSync, readFileSync as readFileSync3, statSync } from "fs";
518
- 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";
519
692
  var PLUGIN_SOURCES = {
520
693
  cloudflare: {
521
694
  name: "cloudflare",
@@ -533,17 +706,17 @@ var PACKAGE_ENTRY_DIRS = ["api-emulator", "trading-emulator"];
533
706
  function candidateCatalogRoots(cwd = process.cwd()) {
534
707
  const envRoots = process.env.API_EMULATOR_PLUGIN_CATALOGS?.split(",").map((value) => value.trim()).filter(Boolean) ?? [];
535
708
  const roots = [...envRoots];
536
- let current = resolve5(cwd);
709
+ let current = resolve6(cwd);
537
710
  for (; ; ) {
538
711
  for (const dir of DEFAULT_CATALOG_DIRS) {
539
712
  roots.push(join(current, dir));
540
- roots.push(join(dirname(current), dir));
713
+ roots.push(join(dirname2(current), dir));
541
714
  }
542
- const parent = dirname(current);
715
+ const parent = dirname2(current);
543
716
  if (parent === current) break;
544
717
  current = parent;
545
718
  }
546
- return [...new Set(roots.map((root) => resolve5(root)))];
719
+ return [...new Set(roots.map((root) => resolve6(root)))];
547
720
  }
548
721
  function sourceIdForRoot(root) {
549
722
  const base = root.split(/[\\/]/).pop() ?? "local";
@@ -552,29 +725,29 @@ function sourceIdForRoot(root) {
552
725
  return base;
553
726
  }
554
727
  function packageEntrySpecifier(packageRoot, pkg3) {
555
- if (typeof pkg3.exports === "string") return resolve5(packageRoot, pkg3.exports);
728
+ if (typeof pkg3.exports === "string") return resolve6(packageRoot, pkg3.exports);
556
729
  if (typeof pkg3.exports === "object" && pkg3.exports !== null && "." in pkg3.exports && typeof pkg3.exports["."] === "string") {
557
- return resolve5(packageRoot, pkg3.exports["."]);
730
+ return resolve6(packageRoot, pkg3.exports["."]);
558
731
  }
559
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") {
560
- return resolve5(packageRoot, pkg3.exports["."].import);
733
+ return resolve6(packageRoot, pkg3.exports["."].import);
561
734
  }
562
- if (typeof pkg3.main === "string") return resolve5(packageRoot, pkg3.main);
735
+ if (typeof pkg3.main === "string") return resolve6(packageRoot, pkg3.main);
563
736
  return packageRoot;
564
737
  }
565
738
  function discoverManifest(root, sourceId) {
566
739
  const sources = [];
567
740
  for (const manifestFile of CATALOG_MANIFEST_FILES) {
568
741
  const manifestPath = join(root, manifestFile);
569
- if (!existsSync4(manifestPath)) continue;
570
- const manifest = JSON.parse(readFileSync3(manifestPath, "utf-8"));
742
+ if (!existsSync5(manifestPath)) continue;
743
+ const manifest = JSON.parse(readFileSync4(manifestPath, "utf-8"));
571
744
  for (const [name, entry] of Object.entries(manifest.plugins ?? {})) {
572
745
  sources.push({
573
746
  name,
574
747
  sourceId,
575
748
  kind: entry.kind ?? (entry.packageName ? "package" : "file"),
576
749
  packageName: entry.packageName,
577
- specifier: entry.specifier.startsWith(".") ? resolve5(root, entry.specifier) : entry.specifier,
750
+ specifier: entry.specifier.startsWith(".") ? resolve6(root, entry.specifier) : entry.specifier,
578
751
  description: entry.description ?? `${name} plugin from ${sourceId} catalog`
579
752
  });
580
753
  }
@@ -582,31 +755,18 @@ function discoverManifest(root, sourceId) {
582
755
  return sources;
583
756
  }
584
757
  function discoverCatalog(root) {
585
- if (!existsSync4(root) || !statSync(root).isDirectory()) return [];
758
+ if (!existsSync5(root) || !statSync(root).isDirectory()) return [];
586
759
  const sourceId = sourceIdForRoot(root);
587
760
  const sources = discoverManifest(root, sourceId);
588
761
  for (const entry of readdirSync(root, { withFileTypes: true })) {
589
762
  if (!entry.isDirectory() || !entry.name.startsWith("@")) continue;
590
763
  const name = entry.name.slice(1);
591
764
  const pluginRoot = join(root, entry.name);
592
- for (const entryFile of PLUGIN_ENTRY_FILES) {
593
- const specifier = join(pluginRoot, entryFile);
594
- if (existsSync4(specifier)) {
595
- sources.push({
596
- name,
597
- sourceId,
598
- kind: "file",
599
- specifier,
600
- description: `${name} plugin from ${sourceId} catalog`
601
- });
602
- break;
603
- }
604
- }
605
765
  for (const entryDir of PACKAGE_ENTRY_DIRS) {
606
766
  const packageRoot = join(pluginRoot, entryDir);
607
767
  const packageJsonPath = join(packageRoot, "package.json");
608
- if (!existsSync4(packageJsonPath)) continue;
609
- const pkg3 = JSON.parse(readFileSync3(packageJsonPath, "utf-8"));
768
+ if (!existsSync5(packageJsonPath)) continue;
769
+ const pkg3 = JSON.parse(readFileSync4(packageJsonPath, "utf-8"));
610
770
  sources.push({
611
771
  name,
612
772
  sourceId,
@@ -618,6 +778,19 @@ function discoverCatalog(root) {
618
778
  });
619
779
  break;
620
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
+ }
621
794
  }
622
795
  return sources;
623
796
  }
@@ -636,9 +809,9 @@ function resolvePluginSource(name) {
636
809
 
637
810
  // src/commands/install.ts
638
811
  function detectPackageManager() {
639
- if (existsSync5(resolve6("bun.lock")) || existsSync5(resolve6("bun.lockb"))) return "bun";
640
- if (existsSync5(resolve6("pnpm-lock.yaml"))) return "pnpm";
641
- 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";
642
815
  return "npm";
643
816
  }
644
817
  function installPackage(packageManager, packageName) {
@@ -676,12 +849,188 @@ async function installCommand(name, options = {}) {
676
849
  console.log(`Recorded plugin in ${PLUGIN_LOCK_FILE}`);
677
850
  }
678
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
+
679
994
  // src/index.ts
680
- var pkg2 = { version: "0.5.2" };
681
- var defaultPort = process.env.API_EMULATOR_PORT ?? process.env.EMULATE_PORT ?? process.env.PORT ?? "4000";
995
+ var pkg2 = { version: "0.7.0" };
996
+ var defaultPort = process.env.API_EMULATOR_PORT ?? process.env.PORT ?? "4000";
682
997
  var program = new Command();
683
- program.name("api").description("Local API emulators you can run, share, and extend with plugins").version(pkg2.version);
684
- 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) => {
685
1034
  const port = parseInt(opts.port, 10);
686
1035
  if (Number.isNaN(port) || port < 1 || port > 65535) {
687
1036
  console.error(`Invalid port: ${opts.port}`);
@@ -693,24 +1042,62 @@ program.command("start", { isDefault: true }).description("Start the emulator se
693
1042
  seed: opts.seed,
694
1043
  baseUrl: opts.baseUrl,
695
1044
  portless: opts.portless,
696
- plugin: opts.plugin
1045
+ plugin: opts.plugin,
1046
+ notify: wantsStartNotify(opts)
697
1047
  });
698
1048
  });
699
- 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) => {
700
- 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
+ });
701
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);
702
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) => {
703
1081
  await listCommand({ plugin: opts.plugin });
704
1082
  });
705
- 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) => {
706
- try {
707
- 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, {
708
1088
  packageManager: opts.packageManager === false ? false : opts.packageManager
709
1089
  });
710
- } catch (err) {
711
- console.error(err instanceof Error ? err.message : err);
712
- process.exit(1);
713
- }
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
+ });
714
1101
  });
715
1102
  program.parse();
716
1103
  //# sourceMappingURL=index.js.map