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.
- package/README.md +5 -3
- package/node_modules/pi-lean-host/AGENTS.md +13 -6
- package/node_modules/pi-lean-host/__tests__/api-learn-fetch-recipe.test.ts +282 -10
- package/node_modules/pi-lean-host/__tests__/api-learn-multi-file.test.ts +8 -16
- package/node_modules/pi-lean-host/__tests__/api-probe.test.ts +276 -279
- package/node_modules/pi-lean-host/__tests__/api-scaffold.test.ts +12 -33
- package/node_modules/pi-lean-host/__tests__/api-toggle.test.ts +14 -12
- package/node_modules/pi-lean-host/__tests__/bootstrap-command.test.ts +1 -7
- package/node_modules/pi-lean-host/__tests__/delete-command.test.ts +1 -12
- package/node_modules/pi-lean-host/__tests__/guide-catalog.test.ts +484 -0
- package/node_modules/pi-lean-host/__tests__/helpers.test.ts +0 -132
- package/node_modules/pi-lean-host/__tests__/oauth-command.test.ts +95 -0
- package/node_modules/pi-lean-host/__tests__/oauth-flow.test.ts +163 -0
- package/node_modules/pi-lean-host/__tests__/oauth-mint.test.ts +79 -6
- package/node_modules/pi-lean-host/__tests__/parse-api-guide.test.ts +4 -409
- package/node_modules/pi-lean-host/__tests__/response-spill.test.ts +0 -1
- package/node_modules/pi-lean-host/__tests__/secrets-command.test.ts +19 -35
- package/node_modules/pi-lean-host/__tests__/smoke.test.ts +1 -82
- package/node_modules/pi-lean-host/__tests__/ssrf-guard.test.ts +98 -0
- package/node_modules/pi-lean-host/__tests__/test-utils.ts +73 -0
- package/node_modules/pi-lean-host/__tests__/tools.test.ts +2 -518
- package/node_modules/pi-lean-host/__tests__/transport.test.ts +178 -31
- package/node_modules/pi-lean-host/__tests__/verify-command.test.ts +1 -8
- package/node_modules/pi-lean-host/__tests__/verify-stamp.test.ts +1 -4
- package/node_modules/pi-lean-host/api-guides/boe/local-helper.test.ts +9 -20
- package/node_modules/pi-lean-host/api-guides/dnb/error-envelope.test.ts +6 -18
- package/node_modules/pi-lean-host/api-guides/dnb/resumption-token.test.ts +6 -18
- package/node_modules/pi-lean-host/api-guides/frost-sensorthings/dotted-key.test.ts +6 -18
- package/node_modules/pi-lean-host/api-guides/github/static-key.test.ts +7 -25
- package/node_modules/pi-lean-host/api-guides/inaturalist/derived-id.test.ts +6 -18
- package/node_modules/pi-lean-host/api-guides/internet-archive/multi-recipe.test.ts +6 -26
- package/node_modules/pi-lean-host/api-guides/stripe/has-more.test.ts +6 -18
- package/node_modules/pi-lean-host/api-guides/telegram-bot/path-auth.test.ts +7 -25
- package/node_modules/pi-lean-host/api-guides/twitch/oauth2.test.ts +12 -47
- package/node_modules/pi-lean-host/api-guides/twitch-user/oauth-user.test.ts +11 -52
- package/node_modules/pi-lean-host/api-guides/usgs/transform.test.ts +10 -27
- package/node_modules/pi-lean-host/api-guides/wikidata-search/numeric-cursor.test.ts +6 -18
- package/node_modules/pi-lean-host/api-guides/wikimedia-action/token-bag.test.ts +9 -20
- package/node_modules/pi-lean-host/core/auth.ts +2 -1
- package/node_modules/pi-lean-host/core/helpers.ts +29 -35
- package/node_modules/pi-lean-host/core/oauth-command.ts +10 -8
- package/node_modules/pi-lean-host/core/oauth-flow.ts +20 -4
- package/node_modules/pi-lean-host/core/parse-api-guide.ts +3 -5
- package/node_modules/pi-lean-host/core/transport.ts +1 -1
- package/node_modules/pi-lean-host/package.json +1 -1
- package/node_modules/pi-lean-host/tools/api-guide.ts +18 -57
- package/node_modules/pi-lean-host/tools/api-learn.ts +20 -49
- package/node_modules/pi-lean-host/tools/api-probe.ts +17 -26
- package/node_modules/pi-lean-host/tools/api-scaffold.ts +20 -42
- package/node_modules/pi-lean-host/tools/utils.ts +69 -0
- package/node_modules/pi-lean-portal/AGENTS.md +4 -3
- package/node_modules/pi-lean-portal/README.md +9 -9
- package/node_modules/pi-lean-portal/__tests__/accessibility-tree.test.ts +0 -15
- package/node_modules/pi-lean-portal/__tests__/browser-install.test.ts +737 -0
- package/node_modules/pi-lean-portal/__tests__/browser-navigate.test.ts +33 -2
- package/node_modules/pi-lean-portal/__tests__/browser-status.test.ts +48 -25
- package/node_modules/pi-lean-portal/__tests__/browser-toggle-profile.test.ts +3 -9
- package/node_modules/pi-lean-portal/__tests__/browser-toggle.test.ts +3 -29
- package/node_modules/pi-lean-portal/__tests__/fetch-backend.test.ts +51 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/__pycache__/mock-python-bridge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/__pycache__/mock-python-bridge.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/mock-pi.ts +34 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/mock-python-bridge.py +14 -24
- package/node_modules/pi-lean-portal/__tests__/plugin-registry.test.ts +0 -90
- package/node_modules/pi-lean-portal/__tests__/python-adapter.test.ts +32 -125
- package/node_modules/pi-lean-portal/__tests__/router-dispatch.test.ts +12 -170
- package/node_modules/pi-lean-portal/__tests__/router-session.test.ts +39 -151
- package/node_modules/pi-lean-portal/__tests__/web-guides.test.ts +1 -44
- package/node_modules/pi-lean-portal/backends/chromium/index.ts +1 -1
- package/node_modules/pi-lean-portal/backends/firefox/index.ts +1 -1
- package/node_modules/pi-lean-portal/backends/playwright-base/playwright-plugin.ts +1 -1
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/playwright_base.py +43 -57
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/transport.py +6 -21
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_accessibility.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_transport.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/test_playwright_base_quirks.py +157 -73
- package/node_modules/pi-lean-portal/backends/python-base/tests/test_transport.py +15 -41
- package/node_modules/pi-lean-portal/browser-install.ts +454 -0
- package/node_modules/pi-lean-portal/browser-status.ts +21 -0
- package/node_modules/pi-lean-portal/browser-toggle.ts +13 -11
- package/node_modules/pi-lean-portal/core/fetch-backend.ts +2 -2
- package/node_modules/pi-lean-portal/core/router.ts +7 -32
- package/node_modules/pi-lean-portal/core/shared/session-manager.ts +1 -3
- package/node_modules/pi-lean-portal/index.ts +1 -18
- package/node_modules/pi-lean-portal/package.json +2 -1
- package/node_modules/pi-lean-portal/tools/browser-navigate.ts +6 -2
- package/node_modules/pi-lean-portal/tools/utils.ts +5 -6
- package/node_modules/pi-lean-search/AGENTS.md +5 -3
- package/node_modules/pi-lean-search/__tests__/web-search.test.ts +180 -127
- package/node_modules/pi-lean-search/index.ts +0 -3
- package/node_modules/pi-lean-search/package.json +1 -1
- package/node_modules/pi-lean-search/web-search-tool.ts +22 -13
- package/node_modules/yaml/browser/dist/compose/resolve-flow-scalar.js +19 -18
- package/node_modules/yaml/browser/dist/nodes/Alias.js +25 -23
- package/node_modules/yaml/dist/compose/resolve-flow-scalar.js +19 -18
- package/node_modules/yaml/dist/nodes/Alias.js +25 -23
- package/node_modules/yaml/package.json +1 -1
- 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`.**
|
|
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),
|
|
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
|
|
420
|
+
`TokenSlotMeta`/`collectDomainReport`/`collectUnscoped`), `api-probe.ts`, `utils.ts`.
|
|
420
421
|
- `__tests__/` — framework structural tests (no network): `smoke`,
|
|
421
|
-
`parse-api-guide`, `
|
|
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
|
|
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
|
|
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,
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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
|