@specbook/cli 0.3.0 → 0.4.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/agent-guide.md ADDED
@@ -0,0 +1,170 @@
1
+ # Specbook
2
+
3
+ > Specbook turns an OpenAPI spec into typed requests and flows that run as your API docs, as tests in CI, and as a monitor against production. Requests and flows are TypeScript files in the project's repo. The same files run in the terminal (`specbook test`), as one bundled `.mjs` file (`specbook bundle`), and in the browser (the docs UI, the QA portal and the "Try it" panel).
4
+
5
+ Use this file when you are asked to add or change requests, flows, checks or setup in a project that uses Specbook (`@specbook/cli`, `@specbook/core`). Needs Node 24.
6
+
7
+ Words used here: an **operation** is one endpoint of the spec. A **request** is a `.req.ts` file that calls one operation. A **flow** is a `.seq.ts` file that runs requests in order.
8
+
9
+ ## Set up a project
10
+
11
+ 1. `npm install --save-dev @specbook/cli @specbook/core`
12
+ 2. `npx specbook init` creates `.specbook/specbook.config.ts`, `.specbook/environments/` and the `#api/*` import in `package.json`.
13
+ 3. `npx specbook add <service> --spec <path or URL>` registers a spec and generates its types. A URL is downloaded to `openapi/` and recorded in `openapi/sources.json`. Set the service's address in `vars` (for example `petstoreUrl`) in the config; each environment can override it in `.specbook/environments/<env>.env`.
14
+ 4. `npx specbook check` validates specs, imports and environment files. `npx specbook generate` regenerates types; `test`, `dev` and `build` do it when they need to.
15
+
16
+ Never edit `.generated/`. Never put a secret in a file: name it in the config (`secrets: { API_KEY: {} }`) and read it as `{{secrets.API_KEY}}`; its value comes from an environment variable or a gitignored `<env>.secrets.env`.
17
+
18
+ ## Add a request
19
+
20
+ Put it at `requests/<service>/<name>.req.ts`. Import the operation from `#api/<service>` and export the request as the default:
21
+
22
+ ```ts
23
+ import { FindPetsByStatus } from '#api/petstore'
24
+
25
+ export default FindPetsByStatus.request({
26
+ name: 'Find pets by status',
27
+ tags: ['smoke', 'monitor'],
28
+ query: { status: '{{status}}' },
29
+ init: { status: 'available' },
30
+ capture: { pets: (res) => (res.status === 200 ? res.data : []) },
31
+ assert: (res, { assert }) => {
32
+ assert.is(res.status, 200, 'ok')
33
+ if (res.status === 200) assert.ok(res.data.length > 0, 'returns at least one pet')
34
+ },
35
+ })
36
+ ```
37
+
38
+ - Find the operation first. Its export name is the spec's `operationId` in PascalCase (`getPetById` becomes `GetPetById`). Read
39
+ `.generated/<service>/index.ts` (read only) or the spec to see them all; your editor completes `import { ... } from '#api/<service>'`.
40
+ - Fields: `name`, `tags`, `init` (default values), `path`, `query`, `headers`, `data` (the body), `capture`, `assert`, `safe`, `timeoutMs`.
41
+ - Give every `{{name}}` the request uses a default in `init`. Then the request also runs on its own, and its `vars` are typed.
42
+ - `{{name}}` is filled from `init`, a flow's inputs, values captured by earlier steps, and the config's `vars`.
43
+ - Path, query and body are typed from the spec. Let the compiler tell you what is missing.
44
+ - Add `tags: ['monitor']` to what should run against production.
45
+ - `safe: true` marks a POST that only reads (a search, a GraphQL query) so it is allowed in a `--safe` run.
46
+
47
+ ## Asserts
48
+
49
+ - A request with no `assert` passes when the response is 2xx (the check is listed as `status is 2xx`). An `assert` replaces that check, so state the status you expect, even a 404.
50
+ - The helper is uvu's `assert` (`ok`, `is`, `equal`, `match`, `type`, `instance`, `throws`, with `not.` versions). Give every call a short label as its last argument: `assert.is(res.status, 200, 'order placed')`. The label is what the report shows.
51
+ - Check `res.status` before reading `res.data`: `if (res.status === 200) { ... }`. Then `res.data` has the type the spec gives that response.
52
+ - The second argument also gives `vars` (everything in scope plus this step's captures) and `req` (the request as sent).
53
+ - Every check runs and is reported. A failed one shows expected against received.
54
+
55
+ ## Capture
56
+
57
+ `capture: { orderId: (res, vars) => (res.status === 200 ? res.data.id : undefined) }` keeps a value from the response for later steps. Return plain, JSON-serializable data. In a flow the value is `ctx.captured.orderId` and `{{orderId}}` in a later request.
58
+
59
+ ## Pass whole values, not just ids
60
+
61
+ A placeholder that is the whole string gives the value itself, with its type: an object, an array, a number, a boolean. Capture the thing
62
+ and send it on:
63
+
64
+ - `capture: { category: (res) => (res.status === 200 ? res.data.category : undefined) }`, then `data: { category: '{{category}}' }` sends
65
+ the object. An array of objects works the same way (`tags: '{{tags}}'`), and the whole body can be one placeholder: `data: '{{order}}'`.
66
+ - Inside a longer string the value becomes text, and an object or array becomes JSON: `name: 'copy of {{category.name}}'`.
67
+ - Reach into a value with dots and array positions: `{{pet.category.name}}`, `{{tags[0].id}}` (`{{tags.0.id}}` is the same), `{{grid[1][0]}}`.
68
+ `{{tags.length}}` is the count. A name or position that is not there, such as `{{tags[5].id}}`, fails the step with `Missing variable`,
69
+ and only a value's own fields are read (not `toString` or `constructor`). There are no expressions or filters: for anything computed,
70
+ do it in `capture`, which is a function, and capture the shaped result.
71
+ - Whole objects belong in the JSON or form body. Path, query and header values are text: an array in a query repeats the key, but an
72
+ object there is sent as "[object Object]". Use one of its fields (`{{pet.category.name}}`) or build the string in `capture`.
73
+ - A number stays a number. To send one as a string, capture `String(id)`.
74
+ - A name that does not exist fails the step with `Missing variable "{{name}}"`.
75
+
76
+ ## Build a flow
77
+
78
+ Put it at `sequences/<name>.seq.ts`, importing the requests it uses:
79
+
80
+ ```ts
81
+ import { c, sequence } from '@specbook/core'
82
+ import findPets from '../requests/petstore/find-pets.req'
83
+ import placeOrder from '../requests/petstore/place-order.req'
84
+
85
+ export default sequence({
86
+ id: 'adopt-pet',
87
+ title: 'Adopt a pet',
88
+ description: 'Find available pets, pick one, order it.',
89
+ tags: ['petstore', 'smoke'],
90
+ inputs: {
91
+ quantity: c.number({ default: 1, min: 1, description: 'How many to order.' }),
92
+ },
93
+ })
94
+ .step('Find available pets', findPets) // captures `pets`
95
+ .input('Choose a pet', (ctx) => ({
96
+ petId: c.select(ctx.captured.pets, {
97
+ label: (p) => `${p.name} (#${p.id})`,
98
+ value: (p) => p.id as number,
99
+ default: 0,
100
+ }),
101
+ }))
102
+ .step('Place order', placeOrder) // sends { petId, quantity }
103
+ ```
104
+
105
+ - `.step(name, request)`; the second argument may be `(ctx) => request`, where `ctx` has `inputs`, `captured`, `vars` and `env`.
106
+ - `request.with({ ... })` changes a request for one step (for example its `assert`).
107
+ - Also: `.input(name, ctx => controls)` asks a person for values, `.each(name, items, make)` repeats a request per item, `.check(name, fn)` asserts on the flow's state, `.use(otherFlow)` includes another flow.
108
+ - Controls are `c.number`, `c.text`, `c.select`, `c.multiselect`, `c.boolean`, `c.date`, `c.object`, `c.array`. A flow also runs unattended in CI and as a monitor, so every input and every `.input()` control needs a default that makes sense with nobody to ask.
109
+ - For generated data: `import { faker } from '@specbook/core/faker'` and use it in a default, for example `c.text({ default: () => faker.person.fullName() })`.
110
+ - Each step is also a request you can run alone.
111
+
112
+ ### Recipe: list, check, pick, act
113
+
114
+ For "fetch the products, assert there is at least one, pick one, add it to the cart with a quantity from the inputs":
115
+
116
+ 1. `requests/shop/list-products.req.ts`: the list operation, `assert` that `res.data.length > 0`, and capture the list:
117
+ `capture: { products: (res) => (res.status === 200 ? res.data : []) }`.
118
+ 2. `requests/shop/add-to-cart.req.ts`: the add operation with `init: { productId: 1, quantity: 1 }` and
119
+ `data: { productId: '{{productId}}', quantity: '{{quantity}}' }`.
120
+ 3. `sequences/add-to-cart.seq.ts`: `inputs: { quantity: c.number({ default: 1, min: 1 }) }`, then
121
+ `.step('List products', listProducts)`, `.input('Choose a product', (ctx) => ({ productId: c.select(ctx.captured.products, { label: (p) => p.name, value: (p) => p.id, default: 0 }) }))`,
122
+ then `.step('Add to cart', addToCart)`.
123
+ 4. Run it: `npx specbook test --grep "Add to cart"`, and `--input quantity=3` to try another value.
124
+
125
+ Capture is declared on the request that makes the response, and the flow reads it as `ctx.captured.<name>`. Pick the way to choose:
126
+ - To take the first item with nobody asked, capture it in the list request (`capture: { productId: (res) => (res.status === 200 ? res.data[0]?.id : undefined) }`)
127
+ and leave out `.input()`. The next request's `{{productId}}` is filled in.
128
+ - To let a person choose in the portal, use `.input()` with `c.select`, as above. Automation is not asked and takes the default, the first item.
129
+
130
+ ## Mocks
131
+
132
+ The `mock` environment (the default for `specbook test` and the UI) answers from the spec's examples with the request's values filled in, and needs no server. When a check needs data the example lacks, wrap the request in `withMock` from `@specbook/core`; the file's one export stays the default:
133
+
134
+ ```ts
135
+ export default withMock(GetOrderById.request({ /* ... */ }), (req, res) => {
136
+ if (res.status === 200) res.data.status = 'placed'
137
+ })
138
+ ```
139
+
140
+ ## Run it
141
+
142
+ - `npx specbook test` runs everything against mock data. `--env staging` runs against a real environment. `--tag monitor`, `--grep <re>`, `--list`, `--input name=value`, `--retries 1` (a pass on retry is reported as flaky), `--timeout <ms>`.
143
+ - Against production always pass `--safe`: a request that is not a GET, HEAD or OPTIONS is refused.
144
+ - Exit codes: 0 passed, 1 a flow failed or errored, 2 it could not run (missing secrets, a missing spec), 3 usage.
145
+ - `npx specbook dev` opens the UI; `npx specbook build --minimal` publishes a small portal of only the project's requests and flows for QA; `npx specbook bundle` writes one `.mjs` that runs with just node, for CI and ops.
146
+
147
+ ## Write isomorphic code
148
+
149
+ Requests and flows run in three places: node (`specbook test`), the single-file bundle (`specbook bundle`, run with just node) and the
150
+ browser (the docs UI, the QA portal and "Try it"). Everything they import must work in all three. A build and a green `specbook test`
151
+ do not prove it: a request that needs Node builds cleanly and passes in node, and only fails when someone opens it in the portal.
152
+
153
+ - Use what is already there: `fetch` (global), `@specbook/core` (`assert`, `faker` from `@specbook/core/faker`, the `c` controls) and
154
+ the language itself (`Date`, `Intl`, `URL`, `btoa`, `crypto.randomUUID()`). Prefer no new dependencies. A library such as lodash does
155
+ work in the browser, but it adds a lot of weight to the portal and the bundle for something a few lines of code do.
156
+ - Do not use `Buffer`: in the browser it is "Buffer is not defined". Use `btoa`, `Uint8Array`, `TextEncoder` and `TextDecoder`.
157
+ - Do not use Node built-ins (`fs`, `path`, `os`, `net`, `child_process`): in the browser the first call throws "externalized for browser
158
+ compatibility". For a file a request uploads, use the request's file `data`, which is embedded in the portal and the bundle.
159
+ - Do not read `process.env`. In the browser it is empty and silently `undefined`, so a request that reads a secret from it sends nothing.
160
+ Use `{{secrets.NAME}}` for secrets and the config's `vars` (`{{name}}`) for everything else.
161
+ - Do not use native addons, packages that `require()` a computed path, or packages that read their own files at run time.
162
+ - If a new package is unavoidable, it must be plain ESM JavaScript with no install scripts and no Node built-ins. Prove it three ways:
163
+ `npx specbook test`, `npx specbook bundle`, and `npx specbook build --minimal` followed by running the request in the portal.
164
+ - `specbook bundle --external <package>` is not a way around this. It leaves the import for node to resolve and a browser cannot, so a
165
+ request that needs it will not run in the portal. Do not use it for requests or flows. There is no node-only exception: if a request
166
+ cannot be written with `fetch` and plain code, tell the user instead of adding a Node-only dependency.
167
+
168
+ ## Notes beside the code (optional)
169
+
170
+ Markdown next to what it describes is shown in the UI: `requests/<service>/<Operation>/README.md` for an operation, `<name>.req.md` beside a request, `<name>.seq.md` beside a flow. A heading made of one code span (`` ## `petId` ``) attaches its text to that field.
package/dist/bin.js CHANGED
@@ -2143,8 +2143,10 @@ function installFaker(mod, config = {}, opts = {}) {
2143
2143
  }
2144
2144
 
2145
2145
  // ../core/src/template.ts
2146
- var WHOLE = /^\s*\{\{\s*([\w.$-]+)\s*\}\}\s*$/;
2147
- var EMBED = /\{\{\s*([\w.$-]+)\s*\}\}/g;
2146
+ var NAME = String.raw`[\w.$\[\]-]+`;
2147
+ var WHOLE = new RegExp(String.raw`^\s*\{\{\s*(${NAME})\s*\}\}\s*$`);
2148
+ var EMBED = new RegExp(String.raw`\{\{\s*(${NAME})\s*\}\}`, "g");
2149
+ var rootName = (name) => name.split(/[.[]/, 1)[0];
2148
2150
  var DEFERRED_NAMESPACES = ["secrets", "session"];
2149
2151
  var TemplateError = class extends Error {
2150
2152
  constructor(variable) {
@@ -2169,11 +2171,12 @@ function fromFaker(path) {
2169
2171
  }
2170
2172
  return { found: true, value: typeof cur === "function" ? cur.call(parent) : cur };
2171
2173
  }
2174
+ var steps = (path) => path.replace(/\[(\d+)\]/g, ".$1").split(".");
2172
2175
  function lookup(scope, path) {
2173
2176
  if (isFakerName(path)) return fromFaker(path);
2174
2177
  let cur = scope;
2175
- for (const part of path.split(".")) {
2176
- if (cur !== null && typeof cur === "object" && part in cur) {
2178
+ for (const part of steps(path)) {
2179
+ if (cur !== null && typeof cur === "object" && Object.hasOwn(cur, part)) {
2177
2180
  cur = cur[part];
2178
2181
  } else {
2179
2182
  return { found: false };
@@ -2862,7 +2865,7 @@ function createColors(enabled) {
2862
2865
  // ../core/src/reporters/list.ts
2863
2866
  var MARKS = { pass: "\u2713", fail: "\u2717", error: "!", skipped: "\u2013" };
2864
2867
  var formatDuration = (ms) => ms < 1e3 ? `${Math.round(ms)}ms` : ms < 6e4 ? `${(ms / 1e3).toFixed(2)}s` : `${Math.floor(ms / 6e4)}m ${Math.round(ms % 6e4 / 1e3)}s`;
2865
- var leaves = (steps) => steps.flatMap((s) => s.children?.length ? leaves(s.children) : [s]);
2868
+ var leaves = (steps3) => steps3.flatMap((s) => s.children?.length ? leaves(s.children) : [s]);
2866
2869
  function paint(status, c, text) {
2867
2870
  return status === "pass" ? c.green(text) : status === "skipped" ? c.dim(text) : status === "error" ? c.yellow(text) : c.red(text);
2868
2871
  }
@@ -2941,21 +2944,21 @@ function formatSummary(results, o = {}) {
2941
2944
  const c = o.colors ?? createColors(false);
2942
2945
  const t = reportTotals(results);
2943
2946
  const part = (n, word, paintFn) => n ? paintFn(`${n} ${word}`) : "";
2944
- const join18 = (...parts) => parts.filter(Boolean).join(c.dim(" | ")) || c.dim("none");
2947
+ const join19 = (...parts) => parts.filter(Boolean).join(c.dim(" | ")) || c.dim("none");
2945
2948
  const lines2 = [
2946
2949
  "",
2947
- `${c.bold(" Flows ")} ${join18(
2950
+ `${c.bold(" Flows ")} ${join19(
2948
2951
  part(t.flows.failed, "failed", c.red),
2949
2952
  part(t.flows.errored, "errored", c.yellow),
2950
2953
  part(t.flows.flaky, "flaky", c.yellow),
2951
2954
  part(t.flows.passed, "passed", c.green)
2952
2955
  )} ${c.dim(`(${results.length})`)}`,
2953
- `${c.bold(" Steps ")} ${join18(
2956
+ `${c.bold(" Steps ")} ${join19(
2954
2957
  part(t.steps.failed, "failed", c.red),
2955
2958
  part(t.steps.skipped, "skipped", c.dim),
2956
2959
  part(t.steps.passed, "passed", c.green)
2957
2960
  )}`,
2958
- `${c.bold(" Assertions ")} ${join18(
2961
+ `${c.bold(" Assertions ")} ${join19(
2959
2962
  part(t.assertions.failed, "failed", c.red),
2960
2963
  part(t.assertions.passed, "passed", c.green)
2961
2964
  )}`
@@ -3030,7 +3033,7 @@ var esc = (s) => String(s).replace(
3030
3033
  /[<>&"']|[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g,
3031
3034
  (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", '"': "&quot;", "'": "&apos;" })[c] ?? ""
3032
3035
  );
3033
- var leafSteps = (steps) => steps.flatMap((s) => s.children?.length ? leafSteps(s.children) : [s]);
3036
+ var leafSteps = (steps3) => steps3.flatMap((s) => s.children?.length ? leafSteps(s.children) : [s]);
3034
3037
  function toJUnit(results, o = {}) {
3035
3038
  const suites = results.map((r) => {
3036
3039
  const flat = leafSteps(r.steps);
@@ -3225,6 +3228,31 @@ function buildUrl(base, req) {
3225
3228
  return `${base.replace(/\/+$/, "")}${req.url}${q2 ? `?${q2}` : ""}`;
3226
3229
  }
3227
3230
  var DEFAULT_TIMEOUT_MS = 3e4;
3231
+ var WHY = {
3232
+ ECONNREFUSED: "connection refused",
3233
+ ENOTFOUND: "host not found",
3234
+ EAI_AGAIN: "host not found",
3235
+ ECONNRESET: "connection reset",
3236
+ ETIMEDOUT: "connection timed out",
3237
+ UND_ERR_CONNECT_TIMEOUT: "connection timed out",
3238
+ EHOSTUNREACH: "host unreachable",
3239
+ ENETUNREACH: "network unreachable"
3240
+ };
3241
+ function unreachable2(e, url) {
3242
+ const cause = e?.cause;
3243
+ if (!(e instanceof TypeError) || !cause) return e;
3244
+ const code = cause.code ?? cause.errors?.find((x2) => x2.code)?.code;
3245
+ const tls = code && /^CERT_|^ERR_TLS|SELF_SIGNED|UNABLE_TO_VERIFY/.test(code) ? "certificate problem" : "";
3246
+ const own = typeof cause.message === "string" && cause.message.length <= 120 ? cause.message : "";
3247
+ const why = code && WHY[code] || tls || own;
3248
+ if (!why) return e;
3249
+ let host = url;
3250
+ try {
3251
+ host = new URL(url).host;
3252
+ } catch {
3253
+ }
3254
+ return new Error(`Could not reach ${host}: ${why}.`, { cause: e });
3255
+ }
3228
3256
  function createFetchTransport(opts) {
3229
3257
  return async (req, signal) => {
3230
3258
  if (signal?.aborted) throw new Error("Stopped before the API answered.");
@@ -3243,8 +3271,9 @@ function createFetchTransport(opts) {
3243
3271
  ctl.abort();
3244
3272
  }, req.timeoutMs) : void 0;
3245
3273
  signal?.addEventListener("abort", () => ctl.abort());
3274
+ const url = buildUrl(opts.baseUrl(req.service), req);
3246
3275
  try {
3247
- const res = await f2(buildUrl(opts.baseUrl(req.service), req), {
3276
+ const res = await f2(url, {
3248
3277
  method: req.method,
3249
3278
  headers,
3250
3279
  body: body2,
@@ -3268,7 +3297,7 @@ function createFetchTransport(opts) {
3268
3297
  } catch (e) {
3269
3298
  if (timedOut) throw new Error(`Timed out after ${req.timeoutMs}ms: no answer from the API.`);
3270
3299
  if (signal?.aborted) throw new Error("Stopped before the API answered.");
3271
- throw e;
3300
+ throw unreachable2(e, url);
3272
3301
  } finally {
3273
3302
  if (timer) clearTimeout(timer);
3274
3303
  }
@@ -3318,7 +3347,7 @@ function usesOf(names, layers, from) {
3318
3347
  const out = [];
3319
3348
  for (const name of names) {
3320
3349
  if (isDeferred(name) || isFakerName(name)) continue;
3321
- const key = name.split(".")[0];
3350
+ const key = rootName(name);
3322
3351
  if (seen.has(key)) continue;
3323
3352
  seen.add(key);
3324
3353
  for (let i = layers.length - 1; i >= 0; i--) {
@@ -3587,7 +3616,7 @@ async function execute(rt2, def, name, flow, kind = "request", noAuth = false) {
3587
3616
  emit(rt2, { type: "step:end", name, status: result.status });
3588
3617
  return { result, response, authResults };
3589
3618
  }
3590
- function summarize(steps) {
3619
+ function summarize(steps3) {
3591
3620
  const t = { steps: 0, passed: 0, failed: 0, skipped: 0, assertions: { passed: 0, failed: 0 } };
3592
3621
  const visit = (s) => {
3593
3622
  if (s.children?.length) {
@@ -3600,21 +3629,21 @@ function summarize(steps) {
3600
3629
  else t.failed++;
3601
3630
  for (const a3 of s.assertions) a3.pass ? t.assertions.passed++ : t.assertions.failed++;
3602
3631
  };
3603
- steps.forEach(visit);
3632
+ steps3.forEach(visit);
3604
3633
  return t;
3605
3634
  }
3606
- function overall(steps) {
3635
+ function overall(steps3) {
3607
3636
  const all = [];
3608
3637
  const walk2 = (s) => {
3609
3638
  all.push(s);
3610
3639
  s.children?.forEach(walk2);
3611
3640
  };
3612
- steps.forEach(walk2);
3641
+ steps3.forEach(walk2);
3613
3642
  if (all.some((s) => s.status === "error")) return "error";
3614
3643
  if (all.some((s) => s.status === "fail")) return "fail";
3615
3644
  return "pass";
3616
3645
  }
3617
- function finish(rt2, kind, meta, steps, startedAt) {
3646
+ function finish(rt2, kind, meta, steps3, startedAt) {
3618
3647
  const result = {
3619
3648
  schemaVersion: 1,
3620
3649
  runId: rid(),
@@ -3626,11 +3655,11 @@ function finish(rt2, kind, meta, steps, startedAt) {
3626
3655
  seed: rt2.o.seed,
3627
3656
  usedDrafts: rt2.o.usedDrafts ?? false,
3628
3657
  tags: meta.tags ?? [],
3629
- status: overall(steps),
3658
+ status: overall(steps3),
3630
3659
  startedAt: new Date(startedAt).toISOString(),
3631
3660
  durationMs: (rt2.o.now?.() ?? now0()) - startedAt,
3632
- totals: summarize(steps),
3633
- steps
3661
+ totals: summarize(steps3),
3662
+ steps: steps3
3634
3663
  };
3635
3664
  const masked = maskDeep(result, rt2.mask);
3636
3665
  emit(rt2, { type: "done", result: masked });
@@ -3718,7 +3747,7 @@ async function runSequence(seq, o) {
3718
3747
  kind: "sequence",
3719
3748
  tags: seq.meta.tags
3720
3749
  });
3721
- const steps = [];
3750
+ const steps3 = [];
3722
3751
  const captured = {};
3723
3752
  const from = {};
3724
3753
  const remember = (values, step) => {
@@ -3729,7 +3758,7 @@ async function runSequence(seq, o) {
3729
3758
  try {
3730
3759
  inputs = resolveControls(seq.meta.inputs ?? {}, o.inputs);
3731
3760
  } catch (e) {
3732
- steps.push({
3761
+ steps3.push({
3733
3762
  name: "inputs",
3734
3763
  kind: "input",
3735
3764
  status: "error",
@@ -3738,7 +3767,7 @@ async function runSequence(seq, o) {
3738
3767
  error: e.message,
3739
3768
  durationMs: 0
3740
3769
  });
3741
- return finish(rt2, "sequence", seq.meta, steps, t0);
3770
+ return finish(rt2, "sequence", seq.meta, steps3, t0);
3742
3771
  }
3743
3772
  const vars = o.vars ?? {};
3744
3773
  const ctx = () => ({
@@ -3748,7 +3777,7 @@ async function runSequence(seq, o) {
3748
3777
  env: o.env
3749
3778
  });
3750
3779
  let failed = false;
3751
- const skip = (name, kind) => steps.push({ name, kind, status: "skipped", assertions: [], captured: {}, durationMs: 0 });
3780
+ const skip = (name, kind) => steps3.push({ name, kind, status: "skipped", assertions: [], captured: {}, durationMs: 0 });
3752
3781
  for (const node of seq.nodes) {
3753
3782
  if (failed && !seq.meta.continueOnFailure) {
3754
3783
  skip(node.name, node.kind === "step" ? "request" : node.kind);
@@ -3767,7 +3796,7 @@ async function runSequence(seq, o) {
3767
3796
  captured,
3768
3797
  from
3769
3798
  });
3770
- steps.push(...authResults, result);
3799
+ steps3.push(...authResults, result);
3771
3800
  remember(result.captured, node.name);
3772
3801
  if (result.status !== "pass") failed = true;
3773
3802
  } else if (node.kind === "each") {
@@ -3803,7 +3832,7 @@ async function runSequence(seq, o) {
3803
3832
  parent.captured = collected;
3804
3833
  parent.durationMs = (o.now?.() ?? now0()) - p0;
3805
3834
  remember(collected, node.name);
3806
- steps.push(parent);
3835
+ steps3.push(parent);
3807
3836
  if (parent.status !== "pass") failed = true;
3808
3837
  } else if (node.kind === "input") {
3809
3838
  const controls = node.controls(ctx());
@@ -3812,7 +3841,7 @@ async function runSequence(seq, o) {
3812
3841
  const given = o.onInput ? await o.onInput({ name: node.name, controls: described }) : o.inputs ?? {};
3813
3842
  const values = resolveControls(controls, given);
3814
3843
  inputs = { ...inputs, ...values };
3815
- steps.push({
3844
+ steps3.push({
3816
3845
  name: node.name,
3817
3846
  kind: "input",
3818
3847
  status: "pass",
@@ -3842,12 +3871,12 @@ async function runSequence(seq, o) {
3842
3871
  s.assertions = a3.results;
3843
3872
  if (s.status === "pass" && a3.results.some((r) => !r.pass)) s.status = "fail";
3844
3873
  emit(rt2, { type: "step:end", name: node.name, status: s.status });
3845
- steps.push(s);
3874
+ steps3.push(s);
3846
3875
  if (s.status !== "pass") failed = true;
3847
3876
  }
3848
3877
  } catch (e) {
3849
3878
  const isControl = e instanceof ControlError;
3850
- steps.push({
3879
+ steps3.push({
3851
3880
  name: node.name,
3852
3881
  kind: node.kind === "step" ? "request" : node.kind,
3853
3882
  status: "error",
@@ -3859,7 +3888,7 @@ async function runSequence(seq, o) {
3859
3888
  failed = true;
3860
3889
  }
3861
3890
  }
3862
- return finish(rt2, "sequence", seq.meta, steps, t0);
3891
+ return finish(rt2, "sequence", seq.meta, steps3, t0);
3863
3892
  }
3864
3893
 
3865
3894
  // ../core/src/runner/suite.ts
@@ -3919,7 +3948,7 @@ function headerLine(h2, colors2) {
3919
3948
  var retryLine = (colors2, title, attempt, allowed) => ` ${colors2.yellow("\u21BB")} retrying ${title} (attempt ${attempt} of ${allowed})
3920
3949
  `;
3921
3950
  function withoutLocations(results) {
3922
- const strip = (steps) => steps.map((s) => ({
3951
+ const strip = (steps3) => steps3.map((s) => ({
3923
3952
  ...s,
3924
3953
  assertions: s.assertions.map((a3) => ({ ...a3, location: void 0 })),
3925
3954
  ...s.children ? { children: strip(s.children) } : {}
@@ -5472,8 +5501,9 @@ var faker2 = new Proxy({}, {
5472
5501
  return current[key];
5473
5502
  }
5474
5503
  });
5475
- var WHOLE2 = /^\s*\{\{\s*([\w.$-]+)\s*\}\}\s*$/;
5476
- var EMBED2 = /\{\{\s*([\w.$-]+)\s*\}\}/g;
5504
+ var NAME2 = String.raw`[\w.$\[\]-]+`;
5505
+ var WHOLE2 = new RegExp(String.raw`^\s*\{\{\s*(${NAME2})\s*\}\}\s*$`);
5506
+ var EMBED2 = new RegExp(String.raw`\{\{\s*(${NAME2})\s*\}\}`, "g");
5477
5507
  var DEFERRED_NAMESPACES2 = ["secrets", "session"];
5478
5508
  var TemplateError2 = class extends Error {
5479
5509
  constructor(variable) {
@@ -5498,11 +5528,12 @@ function fromFaker2(path) {
5498
5528
  }
5499
5529
  return { found: true, value: typeof cur === "function" ? cur.call(parent) : cur };
5500
5530
  }
5531
+ var steps2 = (path) => path.replace(/\[(\d+)\]/g, ".$1").split(".");
5501
5532
  function lookup2(scope, path) {
5502
5533
  if (isFakerName2(path)) return fromFaker2(path);
5503
5534
  let cur = scope;
5504
- for (const part of path.split(".")) {
5505
- if (cur !== null && typeof cur === "object" && part in cur) {
5535
+ for (const part of steps2(path)) {
5536
+ if (cur !== null && typeof cur === "object" && Object.hasOwn(cur, part)) {
5506
5537
  cur = cur[part];
5507
5538
  } else {
5508
5539
  return { found: false };
@@ -6143,7 +6174,7 @@ async function writeEnvKey(file, key, value) {
6143
6174
  await writeFile3(file, setDotenvKey(current, key, value));
6144
6175
  }
6145
6176
  var IGNORE = ["**/node_modules/**", "**/.generated/**", "**/dist/**", "**/.git/**"];
6146
- var NAME = /\.example\.json$/;
6177
+ var NAME22 = /\.example\.json$/;
6147
6178
  async function scanExamples(root, outDir, operations) {
6148
6179
  const examples = {};
6149
6180
  const assets = {};
@@ -6161,7 +6192,7 @@ async function scanExamples(root, outDir, operations) {
6161
6192
  const [svc, , id, file] = relative22(gen, abs).split(sep22);
6162
6193
  if (!operations.has(`${svc}/${id}`)) continue;
6163
6194
  add(`${svc}/${id}`, {
6164
- name: file.replace(NAME, ""),
6195
+ name: file.replace(NAME22, ""),
6165
6196
  abs,
6166
6197
  source: "generated",
6167
6198
  dir: `${svc}/${id}@generated`
@@ -6175,7 +6206,7 @@ async function scanExamples(root, outDir, operations) {
6175
6206
  const key = `${svc}/${id}`;
6176
6207
  if (!svc || !id || !operations.has(key)) continue;
6177
6208
  add(key, {
6178
- name: file.replace(NAME, ""),
6209
+ name: file.replace(NAME22, ""),
6179
6210
  abs,
6180
6211
  source: "project",
6181
6212
  dir: `${svc}/${id}@project`
@@ -7757,13 +7788,16 @@ function transportFor(ctx, env, vars) {
7757
7788
  }
7758
7789
  });
7759
7790
  }
7760
- function originsFor(config, env, vars) {
7791
+ function originsFor(config, env, vars, basePath = false) {
7761
7792
  if (env === "mock") return {};
7762
7793
  const out = {};
7763
7794
  for (const service of Object.keys(config.services)) {
7764
7795
  try {
7765
7796
  const url = baseUrlFor(config, service, env, vars);
7766
- if (url) out[service] = new URL(url).origin;
7797
+ if (url) {
7798
+ const u = new URL(url);
7799
+ out[service] = basePath ? `${u.origin}${u.pathname.replace(/\/+$/, "")}` : u.origin;
7800
+ }
7767
7801
  } catch {
7768
7802
  }
7769
7803
  }
@@ -7844,6 +7878,7 @@ async function execute2(ctx, o, picked, specs, fileVars, secrets) {
7844
7878
  const vars = environmentScope(config, o.env, { ...fileVars, ...given });
7845
7879
  const transport = transportFor(ctx, o.env, vars);
7846
7880
  const origins = originsFor(config, o.env, vars);
7881
+ const addresses = originsFor(config, o.env, vars, true);
7847
7882
  const faker3 = await ctx.loadFaker();
7848
7883
  const git2 = { ...ctx.git ?? gitInfo(ctx.root) };
7849
7884
  const ci = detectCi(process.env);
@@ -7862,7 +7897,7 @@ async function execute2(ctx, o, picked, specs, fileVars, secrets) {
7862
7897
  colors: colors2,
7863
7898
  quiet: o.quiet,
7864
7899
  trace: o.trace,
7865
- origins,
7900
+ origins: addresses,
7866
7901
  cwd: ctx.root,
7867
7902
  readSource,
7868
7903
  rerun: (r) => `${ctx.command}${r.file && ctx.command === "specbook test" ? ` ${r.file}` : ""} --env ${o.env} --grep ${quote3(escapeRegExp(r.title))}${o.safe ? " --safe" : ""}`
@@ -7971,7 +8006,7 @@ async function execute2(ctx, o, picked, specs, fileVars, secrets) {
7971
8006
  toJUnit(clean, {
7972
8007
  timestamp: meta.startedAt,
7973
8008
  hostname: hostname(),
7974
- origins,
8009
+ origins: addresses,
7975
8010
  properties: { env: o.env, sha: git2.sha, branch: git2.branch, specbook: meta.specbook }
7976
8011
  })
7977
8012
  );
@@ -8279,7 +8314,7 @@ async function bundleCommand(project, loader, o) {
8279
8314
  ${e instanceof Error ? e.message : e}`
8280
8315
  );
8281
8316
  console.error(
8282
- "A package that cannot be compiled in (a native addon, a computed require) can stay an import: --external <package>."
8317
+ "A package that cannot be compiled in (a native addon, a computed require) can stay an import: --external <package>. A browser cannot load it, so replace it with fetch or plain code if the request must also run in the portal."
8283
8318
  );
8284
8319
  return 2;
8285
8320
  }
@@ -8432,9 +8467,9 @@ async function checkCommand(project, loader) {
8432
8467
  ...def.dataPatches
8433
8468
  ]);
8434
8469
  const init2 = new Set(Object.keys(def.input.init ?? {}));
8435
- const roots = new Set([...used2].map((n) => n.split(".")[0]));
8470
+ const roots = new Set([...used2].map(rootName));
8436
8471
  for (const name of used2) {
8437
- const root = name.split(".")[0];
8472
+ const root = rootName(name);
8438
8473
  if (name.startsWith("secrets.")) {
8439
8474
  if (!declared.has(name.slice(8)))
8440
8475
  warnings.push(`[${rel2}] secret "${name.slice(8)}" is not declared in config.secrets`);
@@ -8468,15 +8503,124 @@ async function generateCommand(project, loader, only) {
8468
8503
  }
8469
8504
 
8470
8505
  // src/commands/init.ts
8471
- import { existsSync as existsSync9 } from "fs";
8506
+ import { existsSync as existsSync10 } from "fs";
8507
+ import { mkdir as mkdir8, readFile as readFile16, writeFile as writeFile9 } from "fs/promises";
8508
+ import { join as join15 } from "path";
8509
+
8510
+ // src/commands/agents.ts
8511
+ import { existsSync as existsSync9, realpathSync } from "fs";
8472
8512
  import { mkdir as mkdir7, readFile as readFile15, writeFile as writeFile8 } from "fs/promises";
8473
8513
  import { join as join14 } from "path";
8474
- async function initCommand(cwd) {
8514
+ import { fileURLToPath as fileURLToPath2 } from "url";
8515
+ var FILES = ["AGENTS.md", "CLAUDE.md"];
8516
+ var START = "<!-- specbook:start";
8517
+ var END = "<!-- specbook:end -->";
8518
+ function agentBlock(eol = "\n") {
8519
+ return [
8520
+ `${START} (written by specbook init; \`specbook init --agents\` refreshes it) -->`,
8521
+ "## Specbook",
8522
+ "",
8523
+ "This project uses Specbook: requests (`requests/**/*.req.ts`) and flows (`sequences/**/*.seq.ts`) built from OpenAPI specs run as",
8524
+ "the docs, as tests and as monitors. Before adding or changing one, read `.specbook/agent-guide.md`. In short:",
8525
+ "",
8526
+ "- Import operations from `#api/<service>`. Never edit `.generated/`.",
8527
+ "- Label every `assert`, and use `capture` to pass values between steps.",
8528
+ "- Write isomorphic code: requests and flows also run in the browser, so no Node built-ins, `Buffer` or `process.env`.",
8529
+ "- Run `npx specbook check` and `npx specbook test` before you finish.",
8530
+ END
8531
+ ].join(eol);
8532
+ }
8533
+ function withBlock(text) {
8534
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
8535
+ const block = agentBlock(eol);
8536
+ const start = text.indexOf(START);
8537
+ if (start >= 0) {
8538
+ const end = text.indexOf(END, start);
8539
+ if (end < 0) return { text, status: "unterminated" };
8540
+ const next = text.slice(0, start) + block + text.slice(end + END.length);
8541
+ return { text: next, status: next === text ? "unchanged" : "updated" };
8542
+ }
8543
+ if (!text.trim()) return { text: `${block}${eol}`, status: "added" };
8544
+ const lead = text.endsWith("\n") ? "" : eol;
8545
+ return { text: `${text}${lead}${eol}${block}${eol}`, status: "added" };
8546
+ }
8547
+ var importsAgents = (text) => /^@AGENTS\.md\b/m.test(text);
8548
+ async function readGuide() {
8549
+ for (const rel2 of ["../agent-guide.md", "../../agent-guide.md"])
8550
+ try {
8551
+ return await readFile15(fileURLToPath2(new URL(rel2, import.meta.url)), "utf8");
8552
+ } catch {
8553
+ }
8554
+ throw new Error("agent-guide.md is missing from the @specbook/cli package");
8555
+ }
8556
+ async function agentsStep(cwd, agents) {
8557
+ if (agents === false) return;
8475
8558
  const dir = join14(cwd, ".specbook");
8476
8559
  await mkdir7(dir, { recursive: true });
8477
- const cfg = join14(dir, "specbook.config.ts");
8478
- if (!existsSync9(cfg)) {
8560
+ const guide = await readGuide();
8561
+ await writeFile8(
8562
+ join14(dir, "agent-guide.md"),
8563
+ `<!-- Written by specbook init v${cliVersion()}. Re-run \`specbook init\` to refresh it, and put project rules in AGENTS.md. -->
8564
+
8565
+ ${guide}`
8566
+ );
8567
+ console.log("\u2713 wrote .specbook/agent-guide.md");
8568
+ const seen = /* @__PURE__ */ new Set();
8569
+ const found = [];
8570
+ for (const f2 of FILES) {
8571
+ if (!existsSync9(join14(cwd, f2))) continue;
8572
+ const real = realpathSync(join14(cwd, f2));
8573
+ if (seen.has(real)) continue;
8574
+ seen.add(real);
8575
+ found.push(f2);
8576
+ }
8577
+ if (found.length === 0) {
8479
8578
  await writeFile8(
8579
+ join14(cwd, "AGENTS.md"),
8580
+ `# AGENTS.md
8581
+
8582
+ Instructions for AI coding agents working in this repository.
8583
+
8584
+ ${agentBlock()}
8585
+ `
8586
+ );
8587
+ console.log("\u2713 created AGENTS.md (read by Claude Code, Codex and other coding agents)");
8588
+ return;
8589
+ }
8590
+ if (!agents) {
8591
+ console.log(
8592
+ `${found.join(" and ")} already ${found.length > 1 ? "exist" : "exists"} and was not changed. To add Specbook's section to ${found.length > 1 ? "them" : "it"}: specbook init --agents`
8593
+ );
8594
+ return;
8595
+ }
8596
+ for (const file of found) {
8597
+ const path = join14(cwd, file);
8598
+ const text = await readFile15(path, "utf8");
8599
+ if (file === "CLAUDE.md" && found.includes("AGENTS.md") && importsAgents(text)) {
8600
+ console.log("CLAUDE.md imports AGENTS.md: its section covers Claude Code too");
8601
+ continue;
8602
+ }
8603
+ const r = withBlock(text);
8604
+ if (r.status === "unterminated") {
8605
+ console.log(`! ${file} has a "${START}" marker with no end marker; left unchanged`);
8606
+ continue;
8607
+ }
8608
+ if (r.status === "unchanged") {
8609
+ console.log(`${file} already has Specbook's section`);
8610
+ continue;
8611
+ }
8612
+ await writeFile8(path, r.text);
8613
+ console.log(`\u2713 ${r.status === "added" ? "added" : "updated"} Specbook's section in ${file}`);
8614
+ }
8615
+ }
8616
+
8617
+ // src/commands/init.ts
8618
+ async function initCommand(cwd, opts = {}) {
8619
+ const dir = join15(cwd, ".specbook");
8620
+ await mkdir8(dir, { recursive: true });
8621
+ const cfg = join15(dir, "specbook.config.ts");
8622
+ if (!existsSync10(cfg)) {
8623
+ await writeFile9(
8480
8624
  cfg,
8481
8625
  `import { defineConfig } from '@specbook/core'
8482
8626
 
@@ -8495,55 +8639,56 @@ export default defineConfig({
8495
8639
  console.log("\u2713 created .specbook/specbook.config.ts");
8496
8640
  }
8497
8641
  const envDir = await ensureEnvironmentsDir(cwd);
8498
- const example = join14(envDir, "staging.secrets.env.example");
8499
- if (!existsSync9(example)) {
8500
- await writeFile8(
8642
+ const example = join15(envDir, "staging.secrets.env.example");
8643
+ if (!existsSync10(example)) {
8644
+ await writeFile9(
8501
8645
  example,
8502
8646
  "# Copy to staging.secrets.env (gitignored) and fill in. One KEY=value per line.\n# YOUR_API_KEY=\n"
8503
8647
  );
8504
- await writeFile8(
8505
- join14(envDir, "staging.env"),
8648
+ await writeFile9(
8649
+ join15(envDir, "staging.env"),
8506
8650
  '# Non-secret variables for the "staging" environment (committed).\n# region=eu-west-1\n'
8507
8651
  );
8508
8652
  console.log("\u2713 created .specbook/environments/ (staging.env, staging.secrets.env.example)");
8509
8653
  }
8510
- const pkgPath = join14(cwd, "package.json");
8511
- const pkg = existsSync9(pkgPath) ? JSON.parse(await readFile15(pkgPath, "utf8")) : { name: "my-apis", private: true, type: "module" };
8654
+ const pkgPath = join15(cwd, "package.json");
8655
+ const pkg = existsSync10(pkgPath) ? JSON.parse(await readFile16(pkgPath, "utf8")) : { name: "my-apis", private: true, type: "module" };
8512
8656
  pkg.imports = { ...pkg.imports ?? {}, "#api/*": "./.generated/*/index.ts" };
8513
8657
  pkg.scripts = { ...pkg.scripts ?? {} };
8514
8658
  if (!pkg.scripts.prepare) pkg.scripts.prepare = "specbook generate";
8515
- await writeFile8(pkgPath, `${JSON.stringify(pkg, null, 2)}
8659
+ await writeFile9(pkgPath, `${JSON.stringify(pkg, null, 2)}
8516
8660
  `);
8517
8661
  console.log('\u2713 added the "#api/*" subpath import and a "prepare" script to package.json');
8518
- const gi = join14(cwd, ".gitignore");
8519
- const current = existsSync9(gi) ? await readFile15(gi, "utf8") : "";
8662
+ const gi = join15(cwd, ".gitignore");
8663
+ const current = existsSync10(gi) ? await readFile16(gi, "utf8") : "";
8520
8664
  const missing = [".generated", ".specbook/secrets.local.env"].filter(
8521
8665
  (entry) => !current.split("\n").some((l) => l.trim() === entry)
8522
8666
  );
8523
8667
  if (missing.length) {
8524
- await writeFile8(
8668
+ await writeFile9(
8525
8669
  gi,
8526
8670
  `${current}${current.endsWith("\n") || !current ? "" : "\n"}${missing.join("\n")}
8527
8671
  `
8528
8672
  );
8529
8673
  console.log(`\u2713 gitignored ${missing.join(", ")}`);
8530
8674
  }
8675
+ await agentsStep(cwd, opts.agents);
8531
8676
  }
8532
8677
 
8533
8678
  // src/commands/preview.ts
8534
8679
  import { spawn } from "child_process";
8535
- import { existsSync as existsSync10, readFileSync as readFileSync2 } from "fs";
8536
- import { mkdir as mkdir8, mkdtemp as mkdtemp2, rm as rm4, symlink, writeFile as writeFile10 } from "fs/promises";
8680
+ import { existsSync as existsSync11, readFileSync as readFileSync2 } from "fs";
8681
+ import { mkdir as mkdir9, mkdtemp as mkdtemp2, rm as rm4, symlink, writeFile as writeFile11 } from "fs/promises";
8537
8682
  import { createRequire as createRequire6 } from "module";
8538
8683
  import { tmpdir as tmpdir2 } from "os";
8539
- import { dirname as dirname8, extname, join as join16, resolve as resolve12 } from "path";
8684
+ import { dirname as dirname8, extname, join as join17, resolve as resolve12 } from "path";
8540
8685
  import SwaggerParser4 from "@apidevtools/swagger-parser";
8541
8686
 
8542
8687
  // src/commands/ui.ts
8543
- import { copyFile, cp, readFile as readFile16, stat, writeFile as writeFile9 } from "fs/promises";
8688
+ import { copyFile, cp, readFile as readFile17, stat, writeFile as writeFile10 } from "fs/promises";
8544
8689
  import { createRequire as createRequire5 } from "module";
8545
- import { basename as basename5, dirname as dirname7, join as join15, resolve as resolve11, sep as sep8 } from "path";
8546
- import { fileURLToPath as fileURLToPath2 } from "url";
8690
+ import { basename as basename5, dirname as dirname7, join as join16, resolve as resolve11, sep as sep8 } from "path";
8691
+ import { fileURLToPath as fileURLToPath3 } from "url";
8547
8692
  import { build, createServer } from "vite";
8548
8693
  function uiRoot() {
8549
8694
  const require2 = createRequire5(import.meta.url);
@@ -8562,10 +8707,10 @@ function enclosingNodeModules(file) {
8562
8707
  }
8563
8708
  function dependencyFolders(root) {
8564
8709
  const found = /* @__PURE__ */ new Set([
8565
- ...enclosingNodeModules(fileURLToPath2(import.meta.url)),
8566
- ...enclosingNodeModules(join15(root, "package.json"))
8710
+ ...enclosingNodeModules(fileURLToPath3(import.meta.url)),
8711
+ ...enclosingNodeModules(join16(root, "package.json"))
8567
8712
  ]);
8568
- for (const from of [join15(root, "package.json"), fileURLToPath2(import.meta.url)]) {
8713
+ for (const from of [join16(root, "package.json"), fileURLToPath3(import.meta.url)]) {
8569
8714
  const require2 = createRequire5(from);
8570
8715
  for (const name of ["vite", "prism-code-editor", "preact", "@preact/signals"]) {
8571
8716
  try {
@@ -8623,7 +8768,7 @@ async function devCommand(project, port = 5173) {
8623
8768
  }
8624
8769
  async function buildCommand(project, outDir, options = {}) {
8625
8770
  const out = resolve11(project.root, outDir);
8626
- const generated = join15(project.root, project.config.codegen?.outDir ?? ".generated");
8771
+ const generated = join16(project.root, project.config.codegen?.outDir ?? ".generated");
8627
8772
  const operations = options.minimal ? await usedOperationKeys(project, generated) : void 0;
8628
8773
  if (operations && operations.size === 0)
8629
8774
  throw new Error("Nothing to publish: no request or flow imports an operation (see --minimal)");
@@ -8645,25 +8790,25 @@ async function buildCommand(project, outDir, options = {}) {
8645
8790
  }
8646
8791
  }
8647
8792
  });
8648
- await copyMetadata(join15(generated, "specbook.json"), join15(out, "specbook.json"), operations);
8793
+ await copyMetadata(join16(generated, "specbook.json"), join16(out, "specbook.json"), operations);
8649
8794
  }
8650
8795
  async function copyMetadata(from, to, operations) {
8651
8796
  try {
8652
8797
  if (!operations) return await copyFile(from, to);
8653
- const meta = JSON.parse(await readFile16(from, "utf8"));
8798
+ const meta = JSON.parse(await readFile17(from, "utf8"));
8654
8799
  for (const [svc, s] of Object.entries(meta.services)) {
8655
8800
  s.operations = Object.fromEntries(
8656
8801
  Object.entries(s.operations).filter(([id]) => operations.has(`${svc}/${id}`))
8657
8802
  );
8658
8803
  if (Object.keys(s.operations).length === 0) delete meta.services[svc];
8659
8804
  }
8660
- await writeFile9(to, `${JSON.stringify(meta)}
8805
+ await writeFile10(to, `${JSON.stringify(meta)}
8661
8806
  `);
8662
8807
  } catch {
8663
8808
  }
8664
8809
  }
8665
8810
  async function copyViewer(project, outDir, viewerDir = resolve11(uiRoot(), "dist-viewer")) {
8666
- const has2 = await stat(join15(viewerDir, "index.html")).then(
8811
+ const has2 = await stat(join16(viewerDir, "index.html")).then(
8667
8812
  () => true,
8668
8813
  () => false
8669
8814
  );
@@ -8682,10 +8827,10 @@ var slug3 = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/
8682
8827
  async function link(root, name) {
8683
8828
  try {
8684
8829
  let dir = dirname8(createRequire6(import.meta.url).resolve(name));
8685
- while (!existsSync10(join16(dir, "package.json")) || JSON.parse(readFileSync2(join16(dir, "package.json"), "utf8")).name !== name)
8830
+ while (!existsSync11(join17(dir, "package.json")) || JSON.parse(readFileSync2(join17(dir, "package.json"), "utf8")).name !== name)
8686
8831
  dir = dirname8(dir);
8687
- const dest = join16(root, "node_modules", ...name.split("/"));
8688
- await mkdir8(dirname8(dest), { recursive: true });
8832
+ const dest = join17(root, "node_modules", ...name.split("/"));
8833
+ await mkdir9(dirname8(dest), { recursive: true });
8689
8834
  await symlink(dir, dest, "dir");
8690
8835
  } catch {
8691
8836
  }
@@ -8705,7 +8850,7 @@ async function previewCommand(source, o = {}) {
8705
8850
  const { servers } = openApiServers(api, source);
8706
8851
  const where = servers.length === 0 ? "" : servers.length === 1 && servers[0]?.name === "live" ? `, live calls go to ${servers[0].url}` : `
8707
8852
  Environments: ${servers.map((s) => `${s.name} \u2192 ${s.url}`).join(", ")}`;
8708
- const root = await mkdtemp2(join16(tmpdir2(), "specbook-preview-"));
8853
+ const root = await mkdtemp2(join17(tmpdir2(), "specbook-preview-"));
8709
8854
  const cleanup = () => rm4(root, { recursive: true, force: true }).catch(() => {
8710
8855
  });
8711
8856
  process.once("SIGINT", () => cleanup().then(() => process.exit(0)));
@@ -8715,9 +8860,9 @@ async function previewCommand(source, o = {}) {
8715
8860
  const res = await fetch(source);
8716
8861
  if (!res.ok) throw new Error(`Could not fetch ${source} (${res.status})`);
8717
8862
  const ext = /ya?ml/.test(extname(new URL(source).pathname)) ? ".yaml" : ".json";
8718
- specFile = join16(root, `openapi/api${ext}`);
8719
- await mkdir8(dirname8(specFile), { recursive: true });
8720
- await writeFile10(specFile, await res.text());
8863
+ specFile = join17(root, `openapi/api${ext}`);
8864
+ await mkdir9(dirname8(specFile), { recursive: true });
8865
+ await writeFile11(specFile, await res.text());
8721
8866
  }
8722
8867
  const {
8723
8868
  auth,
@@ -8750,14 +8895,14 @@ async function previewCommand(source, o = {}) {
8750
8895
  }
8751
8896
  }
8752
8897
  };
8753
- const configFile = join16(root, ".specbook/specbook.config.ts");
8754
- await mkdir8(dirname8(configFile), { recursive: true });
8755
- await writeFile10(configFile, `export default ${JSON.stringify(config, null, 2)}
8898
+ const configFile = join17(root, ".specbook/specbook.config.ts");
8899
+ await mkdir9(dirname8(configFile), { recursive: true });
8900
+ await writeFile11(configFile, `export default ${JSON.stringify(config, null, 2)}
8756
8901
  `);
8757
8902
  await link(root, "@specbook/core");
8758
8903
  await link(root, "@faker-js/faker");
8759
8904
  console.log(`Generating "${title}" \u2026`);
8760
- const out = await generateToDisk({ spec: specFile, service, outDir: join16(root, ".generated") });
8905
+ const out = await generateToDisk({ spec: specFile, service, outDir: join17(root, ".generated") });
8761
8906
  console.log(`\u2713 ${out.operations.length} operations${where}`);
8762
8907
  const project = { root, configFile, config };
8763
8908
  const server = await devCommand(project, o.port ?? 5173);
@@ -8826,8 +8971,8 @@ import { resolve as resolve15 } from "path";
8826
8971
 
8827
8972
  // src/examples.ts
8828
8973
  import { File } from "buffer";
8829
- import { readFile as readFile17 } from "fs/promises";
8830
- import { basename as basename6, dirname as dirname9, join as join17, resolve as resolve14 } from "path";
8974
+ import { readFile as readFile18 } from "fs/promises";
8975
+ import { basename as basename6, dirname as dirname9, join as join18, resolve as resolve14 } from "path";
8831
8976
  import fg9 from "fast-glob";
8832
8977
  var IGNORE3 = ["**/node_modules/**", "**/.generated/**", "**/dist/**", "**/.git/**"];
8833
8978
  function exampleLoadersFor(project) {
@@ -8835,9 +8980,9 @@ function exampleLoadersFor(project) {
8835
8980
  const example = async (op, name) => {
8836
8981
  const rel2 = `${op.service}/${op.id}/${name}.example.json`;
8837
8982
  const [custom] = await fg9(`**/${rel2}`, { cwd: project.root, absolute: true, ignore: IGNORE3 });
8838
- const file = custom ?? join17(outDir, op.service, "examples", op.id, `${name}.example.json`);
8983
+ const file = custom ?? join18(outDir, op.service, "examples", op.id, `${name}.example.json`);
8839
8984
  try {
8840
- const doc = JSON.parse(await readFile17(file, "utf8"));
8985
+ const doc = JSON.parse(await readFile18(file, "utf8"));
8841
8986
  return { ...doc, source: custom ? "project" : "generated", dir: dirname9(file) };
8842
8987
  } catch {
8843
8988
  return void 0;
@@ -8845,7 +8990,7 @@ function exampleLoadersFor(project) {
8845
8990
  };
8846
8991
  const read = async (path, dir) => {
8847
8992
  const abs = resolve14(dir ?? project.root, path);
8848
- return new File([await readFile17(abs)], basename6(abs));
8993
+ return new File([await readFile18(abs)], basename6(abs));
8849
8994
  };
8850
8995
  return { example, readFile: read };
8851
8996
  }
@@ -9012,7 +9157,9 @@ function testOptions(values, patterns) {
9012
9157
  // src/bin.ts
9013
9158
  var HELP = `specbook <command>
9014
9159
 
9015
- init scaffold config, #api/* import and the environments folder
9160
+ init scaffold config, #api/* import, the environments folder and an agent guide
9161
+ --agents add a Specbook section to an existing AGENTS.md or CLAUDE.md (never edited otherwise)
9162
+ --no-agents skip the agent guide and AGENTS.md
9016
9163
  add <service> --spec <p|url> register an OpenAPI spec in the config and generate it; an address is downloaded to
9017
9164
  openapi/ and recorded in openapi/sources.json
9018
9165
  add --refresh [service...] download the recorded addresses again, say what changed and regenerate it
@@ -9028,7 +9175,7 @@ var HELP = `specbook <command>
9028
9175
  ${TEST_HELP}
9029
9176
  bundle [--out <file>] compile the tests into one .mjs that runs with just node: for CI or ops, no install
9030
9177
  --tag <t> only the requests and flows with this tag (repeatable)
9031
- --external <pkg> leave a package as an import (it must be installed where the file runs; repeatable)
9178
+ --external <pkg> leave a package as an import (installed where the file runs; a browser cannot load it; repeatable)
9032
9179
  check validate specs, generated code drift, imported operations, placeholders and environment files
9033
9180
  secrets [--env <name>] show where each declared secret resolves from (never values)
9034
9181
  --secrets-file <path> include a dotenv file, as test would
@@ -9046,6 +9193,8 @@ async function main() {
9046
9193
  out: { type: "string" },
9047
9194
  external: { type: "string", multiple: true },
9048
9195
  "no-open": { type: "boolean" },
9196
+ agents: { type: "boolean" },
9197
+ "no-agents": { type: "boolean" },
9049
9198
  viewer: { type: "boolean" },
9050
9199
  minimal: { type: "boolean" },
9051
9200
  help: { type: "boolean", short: "h" }
@@ -9070,7 +9219,13 @@ async function main() {
9070
9219
  });
9071
9220
  }
9072
9221
  if (cmd === "init") {
9073
- await initCommand(cwd);
9222
+ if (values.agents && values["no-agents"]) {
9223
+ console.error("init: pass --agents or --no-agents, not both\n");
9224
+ return 3;
9225
+ }
9226
+ await initCommand(cwd, {
9227
+ agents: values["no-agents"] ? false : values.agents ? true : void 0
9228
+ });
9074
9229
  return 0;
9075
9230
  }
9076
9231
  if (!["generate", "test", "bundle", "dev", "build", "add", "check", "secrets"].includes(cmd)) {
@@ -164,13 +164,16 @@ function transportFor(ctx, env, vars) {
164
164
  }
165
165
  });
166
166
  }
167
- function originsFor(config, env, vars) {
167
+ function originsFor(config, env, vars, basePath = false) {
168
168
  if (env === "mock") return {};
169
169
  const out = {};
170
170
  for (const service of Object.keys(config.services)) {
171
171
  try {
172
172
  const url = baseUrlFor(config, service, env, vars);
173
- if (url) out[service] = new URL(url).origin;
173
+ if (url) {
174
+ const u = new URL(url);
175
+ out[service] = basePath ? `${u.origin}${u.pathname.replace(/\/+$/, "")}` : u.origin;
176
+ }
174
177
  } catch {
175
178
  }
176
179
  }
@@ -251,6 +254,7 @@ async function execute(ctx, o, picked, specs, fileVars, secrets) {
251
254
  const vars = environmentScope(config, o.env, { ...fileVars, ...given });
252
255
  const transport = transportFor(ctx, o.env, vars);
253
256
  const origins = originsFor(config, o.env, vars);
257
+ const addresses = originsFor(config, o.env, vars, true);
254
258
  const faker = await ctx.loadFaker();
255
259
  const git = { ...ctx.git ?? gitInfo(ctx.root) };
256
260
  const ci = detectCi(process.env);
@@ -269,7 +273,7 @@ async function execute(ctx, o, picked, specs, fileVars, secrets) {
269
273
  colors,
270
274
  quiet: o.quiet,
271
275
  trace: o.trace,
272
- origins,
276
+ origins: addresses,
273
277
  cwd: ctx.root,
274
278
  readSource,
275
279
  rerun: (r) => `${ctx.command}${r.file && ctx.command === "specbook test" ? ` ${r.file}` : ""} --env ${o.env} --grep ${quote(escapeRegExp(r.title))}${o.safe ? " --safe" : ""}`
@@ -378,7 +382,7 @@ async function execute(ctx, o, picked, specs, fileVars, secrets) {
378
382
  toJUnit(clean, {
379
383
  timestamp: meta.startedAt,
380
384
  hostname: hostname(),
381
- origins,
385
+ origins: addresses,
382
386
  properties: { env: o.env, sha: git.sha, branch: git.branch, specbook: meta.specbook }
383
387
  })
384
388
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specbook/cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Specbook command line: init, add, generate, dev, build, test, bundle, check, secrets and preview.",
5
5
  "keywords": [
6
6
  "specbook",
@@ -32,15 +32,16 @@
32
32
  },
33
33
  "files": [
34
34
  "dist",
35
+ "agent-guide.md",
35
36
  "!**/*.test.*"
36
37
  ],
37
38
  "dependencies": {
38
39
  "@apidevtools/swagger-parser": "10.1.1",
39
40
  "@faker-js/faker": "9.3.0",
40
- "@specbook/codegen": "0.3.0",
41
- "@specbook/core": "0.3.0",
42
- "@specbook/ui": "0.3.0",
43
- "@specbook/vite": "0.3.0",
41
+ "@specbook/codegen": "0.4.0",
42
+ "@specbook/core": "0.4.0",
43
+ "@specbook/ui": "0.4.0",
44
+ "@specbook/vite": "0.4.0",
44
45
  "fast-glob": "3.3.2",
45
46
  "rolldown": "1.2.13",
46
47
  "swagger2openapi": "7.0.8",