pi-lean-dimension 0.5.0 → 0.6.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.
Files changed (104) hide show
  1. package/README.md +5 -3
  2. package/node_modules/pi-lean-host/AGENTS.md +13 -6
  3. package/node_modules/pi-lean-host/__tests__/api-learn-fetch-recipe.test.ts +282 -10
  4. package/node_modules/pi-lean-host/__tests__/api-learn-multi-file.test.ts +8 -16
  5. package/node_modules/pi-lean-host/__tests__/api-probe.test.ts +276 -279
  6. package/node_modules/pi-lean-host/__tests__/api-scaffold.test.ts +12 -33
  7. package/node_modules/pi-lean-host/__tests__/api-toggle.test.ts +14 -12
  8. package/node_modules/pi-lean-host/__tests__/bootstrap-command.test.ts +1 -7
  9. package/node_modules/pi-lean-host/__tests__/delete-command.test.ts +1 -12
  10. package/node_modules/pi-lean-host/__tests__/guide-catalog.test.ts +484 -0
  11. package/node_modules/pi-lean-host/__tests__/helpers.test.ts +0 -132
  12. package/node_modules/pi-lean-host/__tests__/oauth-command.test.ts +95 -0
  13. package/node_modules/pi-lean-host/__tests__/oauth-flow.test.ts +163 -0
  14. package/node_modules/pi-lean-host/__tests__/oauth-mint.test.ts +79 -6
  15. package/node_modules/pi-lean-host/__tests__/parse-api-guide.test.ts +4 -409
  16. package/node_modules/pi-lean-host/__tests__/response-spill.test.ts +0 -1
  17. package/node_modules/pi-lean-host/__tests__/secrets-command.test.ts +19 -35
  18. package/node_modules/pi-lean-host/__tests__/smoke.test.ts +1 -82
  19. package/node_modules/pi-lean-host/__tests__/ssrf-guard.test.ts +98 -0
  20. package/node_modules/pi-lean-host/__tests__/test-utils.ts +73 -0
  21. package/node_modules/pi-lean-host/__tests__/tools.test.ts +2 -518
  22. package/node_modules/pi-lean-host/__tests__/transport.test.ts +178 -31
  23. package/node_modules/pi-lean-host/__tests__/verify-command.test.ts +1 -8
  24. package/node_modules/pi-lean-host/__tests__/verify-stamp.test.ts +1 -4
  25. package/node_modules/pi-lean-host/api-guides/boe/local-helper.test.ts +9 -20
  26. package/node_modules/pi-lean-host/api-guides/dnb/error-envelope.test.ts +6 -18
  27. package/node_modules/pi-lean-host/api-guides/dnb/resumption-token.test.ts +6 -18
  28. package/node_modules/pi-lean-host/api-guides/frost-sensorthings/dotted-key.test.ts +6 -18
  29. package/node_modules/pi-lean-host/api-guides/github/static-key.test.ts +7 -25
  30. package/node_modules/pi-lean-host/api-guides/inaturalist/derived-id.test.ts +6 -18
  31. package/node_modules/pi-lean-host/api-guides/internet-archive/multi-recipe.test.ts +6 -26
  32. package/node_modules/pi-lean-host/api-guides/stripe/has-more.test.ts +6 -18
  33. package/node_modules/pi-lean-host/api-guides/telegram-bot/path-auth.test.ts +7 -25
  34. package/node_modules/pi-lean-host/api-guides/twitch/oauth2.test.ts +12 -47
  35. package/node_modules/pi-lean-host/api-guides/twitch-user/oauth-user.test.ts +11 -52
  36. package/node_modules/pi-lean-host/api-guides/usgs/transform.test.ts +10 -27
  37. package/node_modules/pi-lean-host/api-guides/wikidata-search/numeric-cursor.test.ts +6 -18
  38. package/node_modules/pi-lean-host/api-guides/wikimedia-action/token-bag.test.ts +9 -20
  39. package/node_modules/pi-lean-host/core/auth.ts +2 -1
  40. package/node_modules/pi-lean-host/core/helpers.ts +29 -35
  41. package/node_modules/pi-lean-host/core/oauth-command.ts +10 -8
  42. package/node_modules/pi-lean-host/core/oauth-flow.ts +20 -4
  43. package/node_modules/pi-lean-host/core/parse-api-guide.ts +3 -5
  44. package/node_modules/pi-lean-host/core/transport.ts +1 -1
  45. package/node_modules/pi-lean-host/package.json +1 -1
  46. package/node_modules/pi-lean-host/tools/api-guide.ts +18 -57
  47. package/node_modules/pi-lean-host/tools/api-learn.ts +20 -49
  48. package/node_modules/pi-lean-host/tools/api-probe.ts +17 -26
  49. package/node_modules/pi-lean-host/tools/api-scaffold.ts +20 -42
  50. package/node_modules/pi-lean-host/tools/utils.ts +69 -0
  51. package/node_modules/pi-lean-portal/AGENTS.md +4 -3
  52. package/node_modules/pi-lean-portal/README.md +9 -9
  53. package/node_modules/pi-lean-portal/__tests__/accessibility-tree.test.ts +0 -15
  54. package/node_modules/pi-lean-portal/__tests__/browser-install.test.ts +737 -0
  55. package/node_modules/pi-lean-portal/__tests__/browser-navigate.test.ts +33 -2
  56. package/node_modules/pi-lean-portal/__tests__/browser-status.test.ts +48 -25
  57. package/node_modules/pi-lean-portal/__tests__/browser-toggle-profile.test.ts +3 -9
  58. package/node_modules/pi-lean-portal/__tests__/browser-toggle.test.ts +3 -29
  59. package/node_modules/pi-lean-portal/__tests__/fetch-backend.test.ts +51 -0
  60. package/node_modules/pi-lean-portal/__tests__/helpers/__pycache__/mock-python-bridge.cpython-313-pytest-9.1.1.pyc +0 -0
  61. package/node_modules/pi-lean-portal/__tests__/helpers/__pycache__/mock-python-bridge.cpython-313.pyc +0 -0
  62. package/node_modules/pi-lean-portal/__tests__/helpers/mock-pi.ts +34 -0
  63. package/node_modules/pi-lean-portal/__tests__/helpers/mock-python-bridge.py +14 -24
  64. package/node_modules/pi-lean-portal/__tests__/plugin-registry.test.ts +0 -90
  65. package/node_modules/pi-lean-portal/__tests__/python-adapter.test.ts +32 -125
  66. package/node_modules/pi-lean-portal/__tests__/router-dispatch.test.ts +12 -170
  67. package/node_modules/pi-lean-portal/__tests__/router-session.test.ts +39 -151
  68. package/node_modules/pi-lean-portal/__tests__/web-guides.test.ts +1 -44
  69. package/node_modules/pi-lean-portal/backends/chromium/index.ts +1 -1
  70. package/node_modules/pi-lean-portal/backends/firefox/index.ts +1 -1
  71. package/node_modules/pi-lean-portal/backends/playwright-base/playwright-plugin.ts +1 -1
  72. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-312.pyc +0 -0
  73. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-313.pyc +0 -0
  74. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-312.pyc +0 -0
  75. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-313.pyc +0 -0
  76. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/playwright_base.py +43 -57
  77. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/transport.py +6 -21
  78. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_accessibility.cpython-313-pytest-9.1.1.pyc +0 -0
  79. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313-pytest-9.1.1.pyc +0 -0
  80. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313.pyc +0 -0
  81. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_transport.cpython-313-pytest-9.1.1.pyc +0 -0
  82. package/node_modules/pi-lean-portal/backends/python-base/tests/test_playwright_base_quirks.py +157 -73
  83. package/node_modules/pi-lean-portal/backends/python-base/tests/test_transport.py +15 -41
  84. package/node_modules/pi-lean-portal/browser-install.ts +454 -0
  85. package/node_modules/pi-lean-portal/browser-status.ts +21 -0
  86. package/node_modules/pi-lean-portal/browser-toggle.ts +13 -11
  87. package/node_modules/pi-lean-portal/core/fetch-backend.ts +2 -2
  88. package/node_modules/pi-lean-portal/core/router.ts +7 -32
  89. package/node_modules/pi-lean-portal/core/shared/session-manager.ts +1 -3
  90. package/node_modules/pi-lean-portal/index.ts +1 -18
  91. package/node_modules/pi-lean-portal/package.json +2 -1
  92. package/node_modules/pi-lean-portal/tools/browser-navigate.ts +6 -2
  93. package/node_modules/pi-lean-portal/tools/utils.ts +5 -6
  94. package/node_modules/pi-lean-search/AGENTS.md +5 -3
  95. package/node_modules/pi-lean-search/__tests__/web-search.test.ts +180 -127
  96. package/node_modules/pi-lean-search/index.ts +0 -3
  97. package/node_modules/pi-lean-search/package.json +1 -1
  98. package/node_modules/pi-lean-search/web-search-tool.ts +22 -13
  99. package/node_modules/yaml/browser/dist/compose/resolve-flow-scalar.js +19 -18
  100. package/node_modules/yaml/browser/dist/nodes/Alias.js +25 -23
  101. package/node_modules/yaml/dist/compose/resolve-flow-scalar.js +19 -18
  102. package/node_modules/yaml/dist/nodes/Alias.js +25 -23
  103. package/node_modules/yaml/package.json +1 -1
  104. package/package.json +4 -4
package/README.md CHANGED
@@ -15,9 +15,11 @@ SearXNG instance.
15
15
  ```bash
16
16
  pi install npm:pi-lean-portal
17
17
  pi install npm:pi-lean-host
18
- npx playwright install chromium firefox
19
18
  ```
20
19
 
20
+ then, inside pi, run **`/web install`** once to download the Chromium/Firefox
21
+ browser binaries.
22
+
21
23
  Once installed, the core browsing and API tools are enabled by default; the guide-authoring tools stay off until you opt in (see Commands
22
24
  below). To set a different default for **new** sessions, add a
23
25
  `toolsetDefaults` block to your Pi settings (`~/.pi/agent/settings.json` or
@@ -49,7 +51,7 @@ The Quick start above shows a common mix (portal + host). Install any combinatio
49
51
  | [`pi-lean-host`](https://github.com/coreyryanhanson/pi-lean-dimension/tree/main/packages/pi-lean-host) | Declarative API tools + `/api` command | none |
50
52
  | [`pi-lean-search`](https://github.com/coreyryanhanson/pi-lean-dimension/tree/main/packages/pi-lean-search) | `web-search` + `/searxng-status` | a SearXNG server[^2] |
51
53
 
52
- [^1]: **Browser binaries aren't downloaded during `npm install`.** The first `browser-navigate` call prompts you to install them if they're missing.
54
+ [^1]: **Browser binaries aren't downloaded during `npm install`.** Run `/web install` inside pi to fetch them (or `/web install chromium|firefox` for a single engine). If you'd rather download manually, run the bundled-CLI command `/web install` prints — a bare `npx playwright install chromium firefox` may resolve a different playwright copy and install revisions the backends don't match.
53
55
  [^2]: **SearXNG is only required by `pi-lean-search`.** The browser works immediately without it; `web-search` returns a clear setup message on first call. When you do run it, point the suite at your instance in Pi settings with `{ "searxng": { "url": "http://localhost:8888" } }`.
54
56
 
55
57
  ### Tools
@@ -93,7 +95,7 @@ The Quick start above shows a common mix (portal + host). Install any combinatio
93
95
 
94
96
  | Command | Owner | Description |
95
97
  |---|---|---|
96
- | `/web on\|off\|learn\|cookies\|profile\|status` | portal | Unified toggle and management |
98
+ | `/web on\|off\|learn\|install\|cookies\|profile\|status` | portal | Unified toggle, browser-binary install, and management |
97
99
  | `/searxng-status` | search | Test SearXNG connection and update status glyph |
98
100
  | `/api on\|off\|learn\|status\|helpers\|secrets\|verify\|delete\|oauth\|bootstrap` | host | Independent API tools toggle, guide verification, secrets, management, OAuth2 token mint/status, and agent-driven OAuth2 bootstrap |
99
101
 
@@ -378,7 +378,8 @@ speculatively.
378
378
  disambiguates 2+ slot domains) — local-only clear, no `revokeUrl`), `oauth-flow.ts` (headless paste-based
379
379
  auth-code + PKCE dance: PKCE pair gen, `buildAuthorizeUrl`,
380
380
  `parsePastedRedirect` (full redirect URL / bare code, state check,
381
- `?error=` surfacing), `mintAuthCodeToken` orchestration, the
381
+ `?error=` surfacing), Esc-at-paste recovery input (repaste / corrected-URI
382
+ restart / abort), `mintAuthCodeToken` orchestration, the
382
383
  `http://127.0.0.1/callback` redirect convention (RFC 8252 §7.3) —
383
384
  host-only, no portal import, no listener, no inbound network surface),
384
385
  `transport.ts` (shared fetch pipeline: UA, charset, gzip/deflate
@@ -416,11 +417,13 @@ speculatively.
416
417
  checklist → paste prompt → mint/stamp; cancel throws the two-call
417
418
  `init … --code` escape-hatch hint), `api-store.ts` (the learn-gated
418
419
  read-only both-stores inspection — see the tool bullet above;
419
- `TokenSlotMeta`/`collectDomainReport`/`collectUnscoped`), `api-probe.ts`, `utils.ts`, `index.ts`.
420
+ `TokenSlotMeta`/`collectDomainReport`/`collectUnscoped`), `api-probe.ts`, `utils.ts`.
420
421
  - `__tests__/` — framework structural tests (no network): `smoke`,
421
- `parse-api-guide`, `all-guides-parse` (every bundled `guide.md` parses
422
+ `parse-api-guide`, `guide-catalog` (projectToGuide/slug/loader/catalog/
423
+ frontmatter-stamp — split out of parse-api-guide), `all-guides-parse` (every bundled `guide.md` parses
422
424
  cleanly), `tools`, `api-learn-fetch-recipe` (fetch-recipe + entry-point
423
- split + N-guide disambiguation + file staging), `helpers`, `local-helpers`,
425
+ split + N-guide disambiguation + file staging + the api-learn write-path
426
+ tests moved out of tools), `helpers`, `local-helpers`,
424
427
  `api-toggle`, `api-scaffold`, `api-store` (bare orphan view, per-domain
425
428
  combined view, declared-slot gap, learn-gate refusal, redaction asserted
426
429
  against `details`, scope "(assumed)" fallback), `api-learn-multi-file` (multi-file staging,
@@ -429,7 +432,8 @@ speculatively.
429
432
  output-channel audit/SSRF/footer structural tests), `oauth`
430
433
  (client_credentials mint/cache/refresh/scrub), `oauth-flow` (auth-code +
431
434
  PKCE: paste-parse (full URL with state / bare code / `?error=`),
432
- headless `--code` completion, interactive inline prompt, --refresh), `query-secrets`
435
+ headless `--code` completion, interactive inline prompt, --refresh,
436
+ paste-restart recovery input + title-routed doubles), `query-secrets`
433
437
  (query-param-secret injection, output-channel redaction, api-probe inline
434
438
  auth / probe inline-auth `domain` override), `portal-projection`, `render-result`,
435
439
  `pinned-idioms` (load-bearing parsing idioms pinned as contract: XML `@_`
@@ -447,7 +451,10 @@ speculatively.
447
451
  429, so the unit test is the proof — plus the grant-based cache suite:
448
452
  no-grant-not-stored matrix, `no-cache` stickiness, 304 grant
449
453
  refresh/no-store delete, eviction-race (arrival-order independent),
450
- fresh-seeding, and the auth/fresh 304-arm gates),
454
+ fresh-seeding, and the auth/fresh 304-arm gates — and the fetchUrl
455
+ fallbackCharset/guardRedirects suites moved out of helpers),
456
+ `ssrf-guard` (ssrfGuard unit tests: IPv4-mapped-IPv6 bypass + baseline
457
+ blocks — pure function, no server or fixtures),
451
458
  `path-secrets` (secretPathRefs executor + resolve-op contract: token
452
459
  fill + agent-param drop, query isolation, URL/error redaction, 401
453
460
  scrub, fail-closed on missing ref),
@@ -15,6 +15,8 @@
15
15
  * - Missing `dir` → clear error, `guide.md` untouched.
16
16
  * - Inline `recipe` param is no longer a parameter (YAGNI removal).
17
17
  * - Path-traversal domain still rejected by `assertSafeDomain`.
18
+ * - Write-path behaviour (validation refusals, collision warnings,
19
+ * stamping, authoring manual) — moved here from tools.test.ts.
18
20
  * - TUI rendering — `renderCall` shows the 📝 icon for a `dir`-
19
21
  * bearing save call and 📖 for fetch; `renderResult` labels unchanged.
20
22
  *
@@ -130,6 +132,9 @@ describe("api-learn fetch-recipe", () => {
130
132
  expect(draft).toContain("<short>");
131
133
  expect(draft).toContain("<emoji>");
132
134
  expect(draft).not.toMatch(/apidatos|boe\.es|BOE|searchDiary|listConsolidada/);
135
+ // The prose-body (agent-instructions) ability is surfaced, not lost.
136
+ expect(draft).toContain("agent-instruction prose");
137
+ expect(draft).toContain("the closing ---");
133
138
  // Fail-closed: the as-is template cannot save (placeholder apiHost
134
139
  // is rejected by requireHttpUrl).
135
140
  expect(parseApiGuide(draft, { filename: "fresh.example" }).ok).toBe(false);
@@ -404,16 +409,6 @@ describe("api-learn save path (dir)", () => {
404
409
  expect(props.recipeFile).toBeUndefined();
405
410
  expect(props.dir).toBeDefined();
406
411
  });
407
-
408
- it("path-traversal domain still rejected by assertSafeDomain", async () => {
409
- const res = await callLearn("../../escape", stagedDirPath("../../escape"));
410
- const text = contentText(res);
411
- expect(text).toContain("Invalid domain");
412
- expect(res.details).toMatchObject({
413
- error: "invalid_domain",
414
- domain: "../../escape",
415
- });
416
- });
417
412
  });
418
413
 
419
414
  describe("api-learn TUI rendering", () => {
@@ -437,3 +432,280 @@ describe("api-learn TUI rendering", () => {
437
432
  expect((out as unknown as { text: string }).text).not.toContain("📝");
438
433
  });
439
434
  });
435
+
436
+ // ═════════════════════════════════════════════════════════════════
437
+ // api-learn — validate, write, no-half-write, example
438
+ // (moved from tools.test.ts; recipes use the dummy API host — no network)
439
+ // ═════════════════════════════════════════════════════════════════
440
+
441
+ /** An invalid recipe (missing leading / in path). */
442
+ const INVALID_RECIPE = `---
443
+ schemaVersion: 1
444
+ domains: [example.com]
445
+ apiHost: https://api.example.com
446
+ operations:
447
+ - name: get
448
+ via: restGet
449
+ path: things/{id}
450
+ ---
451
+ body
452
+ `;
453
+
454
+ describe("api-learn", () => {
455
+ it("prepends the authoring manual to template and fetch-recipe pulls", async () => {
456
+ // Template path ({domain, new: true}) — the manual travels with the
457
+ // staged draft.
458
+ const templateText = contentText(
459
+ await callLearn("example.com", undefined, { new: true }),
460
+ );
461
+ // Fetch-existing path ({domain}, no dir) — the manual travels
462
+ // with the staged raw recipe.
463
+ await saveRecipe("boe.es", recipe("boe.es", "BOE", "searchDiary"));
464
+ invalidateCache();
465
+ const fetchText = contentText(await callLearn("boe.es"));
466
+ for (const text of [templateText, fetchText]) {
467
+ expect(text).toContain("authoring manual");
468
+ // Field reference + defaults + semantics stay.
469
+ expect(text).toContain("Required fields");
470
+ expect(text).toContain("a LIST of operation mappings");
471
+ expect(text).toContain("Key defaults");
472
+ expect(text).toContain("Executor semantics");
473
+ expect(text).toContain("joinUrl` strips a leading `/");
474
+ expect(text).toContain("pagination.base` seeds the page param");
475
+ expect(text).toContain("Page-size resolution (offset-limit/page)");
476
+ expect(text).toContain("→ omit (server default applies)");
477
+ expect(text).toContain("optional: true` on a ref");
478
+ // Guide-prose (agent-instructions) ability is taught, not lost.
479
+ expect(text).toContain("Guide prose");
480
+ expect(text).toContain("Guide notes");
481
+ // Points at the template entry point; no recipe body.
482
+ expect(text).toContain("new: true");
483
+ expect(text).not.toContain("searchDiary");
484
+ expect(text).not.toContain("```yaml");
485
+ }
486
+ });
487
+
488
+ it("validates and writes a valid recipe", async () => {
489
+ const text = contentText(
490
+ await saveRecipe("boe.es", recipe("boe.es", "BOE", "searchDiary")),
491
+ );
492
+ expect(text).toContain("Guide saved");
493
+ expect(text).toContain("boe.es");
494
+ expect(text).toContain("searchDiary");
495
+ expect(text).toContain("api-fetch");
496
+
497
+ const filepath = join(tmpGuidesDir, "boe", "guide.md");
498
+ const content = readFileSync(filepath, "utf-8");
499
+ expect(content).toContain("apiHost:");
500
+ });
501
+
502
+ // Companion — save summary echoes the resolved auth mapping (names only,
503
+ // never values): wrong-shape is loud, right-shape-but-wrong-name
504
+ // is eyeballable at save.
505
+ it("save summary names the auth header→secret mapping, never values", async () => {
506
+ const recipeText = `---\nkind: api\ndomains: [authmap.example]\nshortName: AuthMap\napiHost: ${API}\nauth:\n kind: static-key\n secretRefs:\n Authorization:\n secret: apiKey\n prefix: "Bearer "\n X-Example-Pro-Key:\n secret: example_key\n prefix: ""\noperations:\n - name: get\n via: restGet\n path: /x\n accept: json\n---\n`;
507
+ const text = contentText(await saveRecipe("authmap.example", recipeText));
508
+ expect(text).toContain("Auth: static-key");
509
+ expect(text).toContain("Authorization ← secret apiKey (Bearer )");
510
+ expect(text).toContain("X-Example-Pro-Key ← secret example_key");
511
+ // Empty prefix (bare-key header) renders without an empty paren.
512
+ expect(text).not.toContain("example_key ()");
513
+ // Names only — never the store values.
514
+ expect(text).not.toContain("s3cr3t");
515
+ });
516
+
517
+ it("rejects an invalid recipe without writing", async () => {
518
+ const text = contentText(await saveRecipe("broken", INVALID_RECIPE));
519
+ expect(text).toContain("Validation error");
520
+ expect(text).toContain("operations[0].path");
521
+ expect(text).toContain("NOT saved");
522
+
523
+ const filepath = join(tmpGuidesDir, "broken", "guide.md");
524
+ expect(() => readFileSync(filepath, "utf-8")).toThrow();
525
+ });
526
+
527
+ // A validation failure names the failing field with expected/found and
528
+ // never writes. The manual-pointer tail is gone — the author already saw
529
+ // the manual on the pull that staged the draft.
530
+ it("reports validation failures with field/expected/found and does not save", async () => {
531
+ // Wrong-auth shape: name/secret fields instead of secretRefs/headerPrefixes.
532
+ const authText = contentText(
533
+ await saveRecipe(
534
+ "authbad.example",
535
+ `---\nschemaVersion: 1\ndomains: [authbad.example]\napiHost: https://api.example.com\nauth:\n kind: static-key\n name: X-EXAMPLE_PRO_API_KEY\n secret: api_key\noperations:\n - name: get\n via: restGet\n path: /things\n---\n`,
536
+ ),
537
+ );
538
+ expect(authText).toContain("auth.name");
539
+ expect(authText).toContain("NOT saved");
540
+
541
+ // Bad via.
542
+ const viaText = contentText(
543
+ await saveRecipe(
544
+ "viabad.example",
545
+ `---\nschemaVersion: 1\ndomains: [viabad.example]\napiHost: https://api.example.com\noperations:\n - name: get\n via: post\n path: /things\n---\n`,
546
+ ),
547
+ );
548
+ expect(viaText).toContain("operations[0].via");
549
+ expect(viaText).toContain("NOT saved");
550
+
551
+ // Unmapped field (frontmatter).
552
+ const fmText = contentText(await saveRecipe("fmbad.example", "just prose"));
553
+ expect(fmText).toContain("frontmatter");
554
+ expect(fmText).toContain("NOT saved");
555
+ });
556
+
557
+ it("rejects a description over 200 chars without writing", async () => {
558
+ // Strict-on-write: the parser accepts any length (lenient-on-read),
559
+ // but api-learn rejects >200 before writing.
560
+ const longDesc = "x".repeat(201);
561
+ const long = `---\nschemaVersion: 1\nkind: api\ndomains: [toolong.example]\ndescription: ${longDesc}\napiHost: ${API}\noperations:\n - name: get\n via: restGet\n path: /x\n accept: json\n---\n`;
562
+ const result = await saveRecipe("toolong.example", long);
563
+ const text = contentText(result);
564
+ expect(text).toContain("NOT saved");
565
+ expect(text).toContain("description");
566
+ expect(text).toContain("201");
567
+ expect(result.details).toMatchObject({ error: "description_too_long" });
568
+ expect(() =>
569
+ readFileSync(join(tmpGuidesDir, "toolong-example", "guide.md"), "utf-8"),
570
+ ).toThrow();
571
+ });
572
+
573
+ it("accepts a description at exactly 200 chars", async () => {
574
+ const desc = "x".repeat(200);
575
+ const boundary = `---\nschemaVersion: 1\nkind: api\ndomains: [boundary.example]\ndescription: ${desc}\napiHost: ${API}\noperations:\n - name: get\n via: restGet\n path: /x\n accept: json\n---\n`;
576
+ const text = contentText(await saveRecipe("boundary.example", boundary));
577
+ expect(text).toContain("Guide saved");
578
+ });
579
+
580
+ it("warns (does not reject) when domains collide with another guide", async () => {
581
+ // Two guides, same `domains:` key, different directories. Valid — that's
582
+ // the multi-recipe point. The write succeeds with a warning.
583
+ const first = `---\nschemaVersion: 1\nkind: api\ndomains: [collide.example]\norganization: collide.org\ndescription: First surface.\nshortName: First\napiHost: ${API}\noperations:\n - name: getFirst\n via: restGet\n path: /x\n accept: json\n---\n`;
584
+ const second = `---\nschemaVersion: 1\nkind: api\ndomains: [collide.example]\norganization: collide.org\ndescription: Second surface.\nshortName: Second\napiHost: ${API}\noperations:\n - name: getSecond\n via: restGet\n path: /x\n accept: json\n---\n`;
585
+ const firstText = contentText(await saveRecipe("collide-first", first));
586
+ expect(firstText).toContain("Guide saved");
587
+ expect(firstText).not.toContain("Multi-recipe");
588
+ invalidateCache();
589
+ const secondText = contentText(await saveRecipe("collide-second", second));
590
+ expect(secondText).toContain("Guide saved");
591
+ expect(secondText).toContain("Multi-recipe");
592
+ // The collision warning renders the slug (slug("Second") = "second"),
593
+ // not the `domain` arg "collide-second".
594
+ expect(secondText).toContain("writing to directory `second`");
595
+ expect(secondText).toContain("collide.example");
596
+ });
597
+
598
+ it("warns about a missing description when colliding", async () => {
599
+ // When the second guide collides and omits description:, api-learn
600
+ // recommends adding one (the primary disambiguation signal).
601
+ const first = `---\nschemaVersion: 1\nkind: api\ndomains: [nodesc.example]\norganization: nodesc.org\ndescription: First surface.\nshortName: First\napiHost: ${API}\noperations:\n - name: getFirst\n via: restGet\n path: /x\n accept: json\n---\n`;
602
+ const second = `---\nschemaVersion: 1\nkind: api\ndomains: [nodesc.example]\norganization: nodesc.org\nshortName: Second\napiHost: ${API}\noperations:\n - name: getSecond\n via: restGet\n path: /x\n accept: json\n---\n`;
603
+ await saveRecipe("nodesc-first", first);
604
+ invalidateCache();
605
+ const text = contentText(await saveRecipe("nodesc-second", second));
606
+ expect(text).toContain("Guide saved");
607
+ expect(text).toContain("Multi-recipe");
608
+ expect(text).toContain("description");
609
+ expect(text).toContain("recommended");
610
+ });
611
+
612
+ it("collision warning names /api delete as the recovery gesture", async () => {
613
+ // The agent has no delete tool — when an existing guide is wrong, the
614
+ // collision warning must point at the human-typed /api delete command,
615
+ // naming the colliding directory (the one to remove).
616
+ const first = `---\nschemaVersion: 1\nkind: api\ndomains: [recover.example]\norganization: recover.org\nshortName: First\napiHost: ${API}\noperations:\n - name: getFirst\n via: restGet\n path: /x\n accept: json\n---\n`;
617
+ const second = `---\nschemaVersion: 1\nkind: api\ndomains: [recover.example]\norganization: recover.org\nshortName: Second\napiHost: ${API}\noperations:\n - name: getSecond\n via: restGet\n path: /x\n accept: json\n---\n`;
618
+ await saveRecipe("recover-first", first);
619
+ invalidateCache();
620
+ const text = contentText(await saveRecipe("recover-second", second));
621
+ expect(text).toContain("Multi-recipe");
622
+ // The existing guide's dirName is slug(shortName) = "first".
623
+ expect(text).toContain("/api delete first");
624
+ expect(text).toContain("the agent has no delete tool");
625
+ });
626
+
627
+ it("does not warn when updating the same guide's own directory", async () => {
628
+ // Updating `foo.example` when `foo.example` already claims the domain is
629
+ // not a collision — same dirName. No warning.
630
+ const r1 = `---\nschemaVersion: 1\nkind: api\ndomains: [solo.example]\nshortName: Solo\napiHost: ${API}\noperations:\n - name: get\n via: restGet\n path: /x\n accept: json\n---\n`;
631
+ const r2 = r1.replace("name: get\n", "name: getMore\n");
632
+ await saveRecipe("solo.example", r1);
633
+ invalidateCache();
634
+ const text = contentText(await saveRecipe("solo.example", r2));
635
+ expect(text).toContain("Guide saved");
636
+ expect(text).not.toContain("Multi-recipe");
637
+ });
638
+
639
+ // The template is the docs-side discoverability: no hardcoded
640
+ // updated/verified dates (the tool stamps them when omitted) and a
641
+ // static-key auth block to crib from.
642
+ it("template has no hardcoded updated/verified dates", async () => {
643
+ const text = contentText(
644
+ await callLearn("example.com", undefined, { new: true }),
645
+ );
646
+ expect(text).toContain(stagedPath("example.com"));
647
+ const example = readFileSync(stagedPath("example.com"), "utf-8");
648
+ expect(example).not.toMatch(/^updated:/m);
649
+ expect(example).not.toMatch(/^verified:/m);
650
+ expect(example).toContain("stamped by the tool when omitted");
651
+ });
652
+
653
+ it("template documents the static-key auth block", async () => {
654
+ const text = contentText(
655
+ await callLearn("example.com", undefined, { new: true }),
656
+ );
657
+ expect(text).toContain(stagedPath("example.com"));
658
+ const example = readFileSync(stagedPath("example.com"), "utf-8");
659
+ expect(example).toContain("kind: static-key");
660
+ expect(example).toContain("secret: <secret-name>");
661
+ expect(example).toContain("secretRefs:");
662
+ expect(example).toContain('prefix: "Bearer "');
663
+ });
664
+
665
+ it("replaces an explicit divergent schemaVersion on save", async () => {
666
+ const stampReplace = `---\nkind: api\nschemaVersion: 5\ndomains: [stamp-replace.example]\nshortName: StampReplace\napiHost: ${API}\noperations:\n - name: get\n via: restGet\n path: /x\n accept: json\n---\n`;
667
+ await saveRecipe("stamp-replace.example", stampReplace);
668
+ const raw = readFileSync(
669
+ join(tmpGuidesDir, "stampreplace", "guide.md"),
670
+ "utf-8",
671
+ );
672
+ expect(raw).toMatch(/^schemaVersion: 1$/m);
673
+ expect(raw).not.toMatch(/^schemaVersion: 5$/m);
674
+ });
675
+
676
+ it("never touches a schemaVersion string in the prose body", async () => {
677
+ const stampProse = `---\nkind: api\ndomains: [stamp-prose.example]\nshortName: StampProse\napiHost: ${API}\noperations:\n - name: get\n via: restGet\n path: /x\n accept: json\n---\nThe schemaVersion: 5 in this prose must stay untouched.\n`;
678
+ await saveRecipe("stamp-prose.example", stampProse);
679
+ const raw = readFileSync(
680
+ join(tmpGuidesDir, "stampprose", "guide.md"),
681
+ "utf-8",
682
+ );
683
+ // Frontmatter got the stamp...
684
+ expect(raw).toMatch(/^schemaVersion: 1$/m);
685
+ // ...and the prose line is untouched (still schemaVersion: 5).
686
+ expect(raw).toContain(
687
+ "The schemaVersion: 5 in this prose must stay untouched.",
688
+ );
689
+ });
690
+
691
+ it("preserves comments and key order when stamping", async () => {
692
+ const stampOrder = `---\nkind: api\ndomains: [stamp-order.example]\n# a comment that must survive\nshortName: StampOrder\napiHost: ${API}\noperations:\n - name: get\n via: restGet\n path: /x\n accept: json\n---\n`;
693
+ await saveRecipe("stamp-order.example", stampOrder);
694
+ const raw = readFileSync(
695
+ join(tmpGuidesDir, "stamporder", "guide.md"),
696
+ "utf-8",
697
+ );
698
+ expect(raw).toContain("# a comment that must survive");
699
+ // Key order preserved; schemaVersion inserted after operations, before
700
+ // the closing --- (no YAML round-trip).
701
+ const idxDomains = raw.indexOf("domains:");
702
+ const idxShort = raw.indexOf("shortName:");
703
+ const idxApi = raw.indexOf("apiHost:");
704
+ const idxOps = raw.indexOf("operations:");
705
+ const idxSV = raw.indexOf("schemaVersion: 1");
706
+ expect(idxDomains).toBeLessThan(idxShort);
707
+ expect(idxShort).toBeLessThan(idxApi);
708
+ expect(idxApi).toBeLessThan(idxOps);
709
+ expect(idxOps).toBeLessThan(idxSV);
710
+ });
711
+ });
@@ -85,7 +85,7 @@ function dirFor(shortName: string): string {
85
85
  }
86
86
 
87
87
  /** Write guide.md (and optional siblings) directly into the guides dir. */
88
- function writeGuide(shortName: string, domain: string, src: string): void {
88
+ function writeGuide(shortName: string, src: string): void {
89
89
  const d = dirFor(shortName);
90
90
  mkdirSync(d, { recursive: true });
91
91
  writeFileSync(join(d, "guide.md"), src, "utf-8");
@@ -131,11 +131,7 @@ const SYNTAX_ERROR = `export default function(params, ctx) {
131
131
 
132
132
  describe("api-learn multi-file staging + save", () => {
133
133
  it("fetch-recipe stages all present siblings (guide.md + helper + verify.json)", async () => {
134
- writeGuide(
135
- "Sib",
136
- "sib.example",
137
- recipe("sib.example", "Sib", { helper: true }),
138
- );
134
+ writeGuide("Sib", recipe("sib.example", "Sib", { helper: true }));
139
135
  writeFileSync(join(dirFor("Sib"), "helper.mjs"), DEFAULT_EXPORT, "utf-8");
140
136
  writeFileSync(
141
137
  join(dirFor("Sib"), "verify.json"),
@@ -161,7 +157,7 @@ describe("api-learn multi-file staging + save", () => {
161
157
  });
162
158
 
163
159
  it("edge: no siblings → fetch stages only guide.md", async () => {
164
- writeGuide("Solo", "solo2.example", recipe("solo2.example", "Solo"));
160
+ writeGuide("Solo", recipe("solo2.example", "Solo"));
165
161
  const text = contentText(await callLearn({ domain: "solo2.example" }));
166
162
  expect(text).not.toContain("Siblings staged");
167
163
  // Fetch keys the staged dir by slug(shortName) = "solo".
@@ -188,7 +184,7 @@ describe("api-learn multi-file staging + save", () => {
188
184
 
189
185
  it("mirror-save present → overwrites the guides-dir counterpart", async () => {
190
186
  // Guides dir already has a helper with old content.
191
- writeGuide("Ov", "ov2.example", recipe("ov2.example", "Ov"));
187
+ writeGuide("Ov", recipe("ov2.example", "Ov"));
192
188
  writeFileSync(join(dirFor("Ov"), "helper.mjs"), "// old\n", "utf-8");
193
189
 
194
190
  const dir = stageRecipe("ov2.example", recipe("ov2.example", "Ov"));
@@ -201,7 +197,7 @@ describe("api-learn multi-file staging + save", () => {
201
197
 
202
198
  it("mirror-save absent → gate refuses without confirmDeletions; re-call confirms", async () => {
203
199
  // Guides dir has a helper; staged dir does not.
204
- writeGuide("Gate", "gate.example", recipe("gate.example", "Gate"));
200
+ writeGuide("Gate", recipe("gate.example", "Gate"));
205
201
  writeFileSync(join(dirFor("Gate"), "helper.mjs"), "// exists\n", "utf-8");
206
202
 
207
203
  const dir = stageRecipe("gate.example", recipe("gate.example", "Gate"));
@@ -230,7 +226,7 @@ describe("api-learn multi-file staging + save", () => {
230
226
  });
231
227
 
232
228
  it("deletion gate does NOT fire on the common path (all siblings staged)", async () => {
233
- writeGuide("Common", "common.example", recipe("common.example", "Common"));
229
+ writeGuide("Common", recipe("common.example", "Common"));
234
230
  writeFileSync(join(dirFor("Common"), "helper.mjs"), "// h\n", "utf-8");
235
231
 
236
232
  // fetch → stages all siblings.
@@ -436,11 +432,7 @@ describe("api-learn save-time helper validation", () => {
436
432
  describe("api-learn new:true over existing directories", () => {
437
433
  it("distinct shortName → own dir; existing guide + siblings untouched", async () => {
438
434
  // Existing guide "Old" (folder old/) with a helper.
439
- writeGuide(
440
- "Old",
441
- "newdistinct.example",
442
- recipe("newdistinct.example", "Old"),
443
- );
435
+ writeGuide("Old", recipe("newdistinct.example", "Old"));
444
436
  writeFileSync(join(dirFor("Old"), "helper.mjs"), "// keep\n", "utf-8");
445
437
 
446
438
  // Author a NEW guide (new:true → template) with a distinct shortName.
@@ -459,7 +451,7 @@ describe("api-learn new:true over existing directories", () => {
459
451
 
460
452
  it("same shortName → deletion gate refuses; confirmDeletions proceeds", async () => {
461
453
  // Existing guide "Same" (folder same/) with a helper.
462
- writeGuide("Same", "newsame.example", recipe("newsame.example", "Same"));
454
+ writeGuide("Same", recipe("newsame.example", "Same"));
463
455
  writeFileSync(join(dirFor("Same"), "helper.mjs"), "// doomed\n", "utf-8");
464
456
 
465
457
  // new:true template reuses shortName "Same" → self-keyed target is the