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
@@ -788,7 +788,7 @@ async function wizardFields(
788
788
  GRANT_ITEMS,
789
789
  );
790
790
  if (grant === undefined) {
791
- await cancelled();
791
+ cancelled();
792
792
  return undefined;
793
793
  }
794
794
  let redirectUri: string | undefined;
@@ -805,11 +805,12 @@ async function wizardFields(
805
805
  )
806
806
  )?.trim();
807
807
  if (raw === undefined) {
808
- await cancelled();
808
+ cancelled();
809
809
  return undefined;
810
810
  }
811
811
  if (raw !== "") redirectUri = raw;
812
812
  }
813
+ // ast-grep-ignore: hardcoded-url
813
814
  const tokenUrl = (
814
815
  await ctx.ui.input(
815
816
  `Token endpoint URL for '${storeDomain}'`,
@@ -817,7 +818,7 @@ async function wizardFields(
817
818
  )
818
819
  )?.trim();
819
820
  if (!tokenUrl) {
820
- await cancelled();
821
+ cancelled();
821
822
  return undefined;
822
823
  }
823
824
  // Store-name rule: client-id/secret are picked from provisioned secrets —
@@ -836,7 +837,7 @@ async function wizardFields(
836
837
  names,
837
838
  );
838
839
  if (clientId === undefined) {
839
- await cancelled();
840
+ cancelled();
840
841
  return undefined;
841
842
  }
842
843
  const clientSecret = await ctx.ui.select(
@@ -844,7 +845,7 @@ async function wizardFields(
844
845
  grant === "client_credentials" ? names : [OMIT_SECRET, ...names],
845
846
  );
846
847
  if (clientSecret === undefined) {
847
- await cancelled();
848
+ cancelled();
848
849
  return undefined;
849
850
  }
850
851
  // A public PKCE client (secret omitted) sends no client credentials —
@@ -860,7 +861,7 @@ async function wizardFields(
860
861
  AUTH_METHOD_ITEMS,
861
862
  )) as OAuth2TokenEndpointAuthMethod | undefined);
862
863
  if (tokenEndpointAuthMethod === undefined) {
863
- await cancelled();
864
+ cancelled();
864
865
  return undefined;
865
866
  }
866
867
  if (grant === "client_credentials") {
@@ -874,6 +875,7 @@ async function wizardFields(
874
875
  },
875
876
  };
876
877
  }
878
+ // ast-grep-ignore: hardcoded-url
877
879
  const authorizeUrl = (
878
880
  await ctx.ui.input(
879
881
  `Authorization endpoint URL for '${storeDomain}'`,
@@ -881,7 +883,7 @@ async function wizardFields(
881
883
  )
882
884
  )?.trim();
883
885
  if (!authorizeUrl) {
884
- await cancelled();
886
+ cancelled();
885
887
  return undefined;
886
888
  }
887
889
  const scopesRaw = await ctx.ui.input(
@@ -889,7 +891,7 @@ async function wizardFields(
889
891
  "e.g. read,profile — empty to skip",
890
892
  );
891
893
  if (scopesRaw === undefined) {
892
- await cancelled();
894
+ cancelled();
893
895
  return undefined;
894
896
  }
895
897
  const scopes = scopesRaw
@@ -316,20 +316,36 @@ async function startAuthCodeFlow(
316
316
  // wrap) so they never have to hunt for it in the scrollback above.
317
317
  // Retry loop: a bad paste (typo, state mismatch, exchange hiccup) just
318
318
  // re-prompts — the pending flow (verifier + state) is unchanged, so a
319
- // retry is always safe. Escape/cancel exits to the --code nudge below.
319
+ // retry is always safe. Escape opens the recovery input below.
320
320
  for (;;) {
321
321
  const pasted = await ctx.ui.input(
322
322
  `Open this URL in YOUR browser and authorize, then paste the redirect URL (or just the code) for '${storeDomain}':\n` +
323
323
  printableAuthorizeUrl(authorizeUrl),
324
324
  "paste the address-bar URL after consenting",
325
325
  );
326
- // Cancelled → fall through to the --code nudge (pending flow survives).
327
- if (pasted === undefined) break;
326
+ if (pasted === undefined) {
327
+ // Esc at the paste: one recovery input instead of a hard cancel.
328
+ // Empty Enter (or a re-typed identical URI — a prefilling client
329
+ // submits the default on plain Enter) → paste again; the pending flow
330
+ // was never touched, so resuming is free and any code already issued
331
+ // against this authorize URL stays valid. A different typed URI →
332
+ // restart the flow with it (fresh PKCE/state/authorize URL, wizard
333
+ // answers intact since the mint call never returned). Esc → the
334
+ // --code nudge, pending survives.
335
+ const recovery = await ctx.ui.input(
336
+ `Redirect URI — Enter to keep ${redirectUri} and try the paste again, or type a corrected URI (Esc to abort)`,
337
+ redirectUri,
338
+ );
339
+ if (recovery === undefined) break;
340
+ const typed = recovery.trim();
341
+ if (typed === "" || typed === redirectUri) continue;
342
+ return startAuthCodeFlow(auth, storeDomain, ctx, typed);
343
+ }
328
344
  try {
329
345
  return await completePastedCode(auth, storeDomain, pasted);
330
346
  } catch (err) {
331
347
  ctx.ui.notify(
332
- `🔑 ${err instanceof Error ? err.message : String(err)} — paste again, or escape to cancel.`,
348
+ `🔑 ${err instanceof Error ? err.message : String(err)} — paste again, or escape for recovery options.`,
333
349
  "warning",
334
350
  );
335
351
  }
@@ -780,9 +780,8 @@ function validateResponseShape(
780
780
 
781
781
  /**
782
782
  * Validate a `Record<string, string>` auth sub-field (headers, secretRefs,
783
- * secretQueryRefs, headerPrefixes). Returns the parsed record, or a ParseError
784
- * when `raw` is absent/null/non-object/an array, or a value fails `valueOk`
785
- * (default: any string; `headerPrefixes` passes a non-empty check).
783
+ * secretQueryRefs). Returns the parsed record, or a ParseError when `raw` is
784
+ * absent/null/non-object/an array or any value is not a string.
786
785
  */
787
786
  function parseStringRecord(
788
787
  raw: unknown,
@@ -790,13 +789,12 @@ function parseStringRecord(
790
789
  fm: string,
791
790
  field: string,
792
791
  expect: string,
793
- valueOk: (v: unknown) => boolean = (v) => typeof v === "string",
794
792
  ): Record<string, string> | ParseApiGuideResult {
795
793
  if (
796
794
  raw === null ||
797
795
  typeof raw !== "object" ||
798
796
  Array.isArray(raw) ||
799
- Object.values(raw).some((v) => !valueOk(v))
797
+ Object.values(raw).some((v) => typeof v !== "string")
800
798
  ) {
801
799
  return fail(file, field, expect, describeFound(raw), {
802
800
  snippet: snippetFor(fm, "auth"),
@@ -231,7 +231,7 @@ function cacheKey(url: string, opts?: FetchOptions): string {
231
231
  * normalized by this). Defined here so the transport layer — the one place
232
232
  * that holds a raw request URL and may embed it in an error message — can
233
233
  * self-redact instead of relying on callers to remember. `helpers.ts`
234
- * re-exports it for the capture points it owns (result.url, urls[],
234
+ * uses it for the capture points it owns (result.url, urls[],
235
235
  * HelperError.url).
236
236
  */
237
237
  export function redactSecretParams(
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lean-host",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Read-only REST API client for Pi. Your agent authors and calls YAML recipes handling the tedious parts: pagination, OAuth2, rate limits, backoff. Credentials live in a local store, injected in code, never in agent context. Churn-free research + retrieval",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -13,18 +13,18 @@ import {
13
13
  } from "@earendil-works/pi-coding-agent";
14
14
  import { Type } from "@earendil-works/pi-ai";
15
15
  import { Text } from "@earendil-works/pi-tui";
16
- import { appendFooter, contentText } from "./utils.js";
16
+ import {
17
+ appendFooter,
18
+ disambiguationMenuResult,
19
+ shortNameErrorResult,
20
+ } from "./utils.js";
17
21
  import {
18
22
  loadAllGuides,
19
23
  findGuidesByDomain,
20
24
  getCatalogText,
21
25
  } from "../core/guide-store.js";
22
26
  import { TODAY } from "../core/parse-api-guide.js";
23
- import {
24
- formatGuideListings,
25
- selectGuideByShortName,
26
- shortNameErrorText,
27
- } from "../core/guide-catalog.js";
27
+ import { selectGuideByShortName } from "../core/guide-catalog.js";
28
28
  import { authStatusLine, canonicalStoreDomain } from "../core/auth.js";
29
29
  import { INLINE_LIMIT } from "../core/response-spill.js";
30
30
  import type { ApiGuide } from "../core/api-guide-types.js";
@@ -90,28 +90,12 @@ export const apiGuideTool = defineTool({
90
90
  if (guideSelector) {
91
91
  const sel = selectGuideByShortName(matches, guideSelector);
92
92
  if (!sel.ok) {
93
- return {
94
- content: [
95
- {
96
- type: "text",
97
- text: shortNameErrorText(
98
- sel,
99
- domain,
100
- guideSelector,
101
- `Call api-guide({domain: "${domain}"}) to see the menu.`,
102
- ),
103
- },
104
- ],
105
- details:
106
- sel.reason === "no_match"
107
- ? { error: "no_guide_by_shortname", domain, guide: guideSelector }
108
- : {
109
- error: "ambiguous_shortname",
110
- domain,
111
- guide: guideSelector,
112
- directories: sel.directories,
113
- },
114
- };
93
+ return shortNameErrorResult(
94
+ sel,
95
+ domain,
96
+ guideSelector,
97
+ `Call api-guide({domain: "${domain}"}) to see the menu.`,
98
+ );
115
99
  }
116
100
  return renderGuideDetail(sel.guide, domain);
117
101
  }
@@ -121,7 +105,12 @@ export const apiGuideTool = defineTool({
121
105
  }
122
106
 
123
107
  // Multiple guides for one domain → disambiguation menu.
124
- return renderDisambiguationMenu(domain, matches);
108
+ return disambiguationMenuResult(
109
+ domain,
110
+ matches,
111
+ (shortName) =>
112
+ `Call api-guide({domain: "${domain}", guide: "${shortName}"}) for details.`,
113
+ );
125
114
  },
126
115
 
127
116
  renderCall(args, theme, _context) {
@@ -292,31 +281,3 @@ function renderGuideDetail(
292
281
  },
293
282
  };
294
283
  }
295
-
296
- /**
297
- * Disambiguation menu for a domain claimed by more than one guide. Lists
298
- * each guide's shortName (+ description when present) and a truncated op-name
299
- * summary; the org header shows only when all matches share one organization.
300
- */
301
- function renderDisambiguationMenu(
302
- domain: string,
303
- matches: { guide: ApiGuide; dirName: string }[],
304
- ): AgentToolResult<unknown> {
305
- const orgs = new Set(
306
- matches.map((m) => m.guide.organization).filter((o): o is string => !!o),
307
- );
308
- const orgName = [...orgs][0];
309
- const orgPart =
310
- orgs.size === 1 && orgName ? ` (organization: ${orgName})` : "";
311
- const lines: string[] = [];
312
- lines.push(`${matches.length} API guides for '${domain}'${orgPart}:`);
313
- lines.push(formatGuideListings(matches));
314
- const example = matches[0]!.guide.shortName;
315
- lines.push(
316
- `Call api-guide({domain: "${domain}", guide: "${example}"}) for details.`,
317
- );
318
- return {
319
- content: [{ type: "text", text: lines.join("\n") }],
320
- details: { domain, disambiguation: matches.length },
321
- };
322
- }
@@ -27,7 +27,12 @@ import {
27
27
  } from "@earendil-works/pi-coding-agent";
28
28
  import { Type } from "@earendil-works/pi-ai";
29
29
  import { Text } from "@earendil-works/pi-tui";
30
- import { appendFooter, contentText } from "./utils.js";
30
+ import {
31
+ appendFooter,
32
+ contentText,
33
+ disambiguationMenuResult,
34
+ shortNameErrorResult,
35
+ } from "./utils.js";
31
36
  import {
32
37
  existsSync,
33
38
  mkdirSync,
@@ -43,11 +48,7 @@ import {
43
48
  parseApiGuide,
44
49
  stampFrontmatterField,
45
50
  } from "../core/parse-api-guide.js";
46
- import {
47
- formatGuideListings,
48
- selectGuideByShortName,
49
- shortNameErrorText,
50
- } from "../core/guide-catalog.js";
51
+ import { selectGuideByShortName } from "../core/guide-catalog.js";
51
52
  import {
52
53
  GUIDE_SCHEMA_VERSION,
53
54
  type ApiGuide,
@@ -415,25 +416,6 @@ function stageFetchedRecipe(
415
416
  };
416
417
  }
417
418
 
418
- /** Disambiguation menu for the N-guide fetch-recipe case (mirrors
419
- * api-guide's menu). */
420
- function renderFetchMenu(
421
- domain: string,
422
- matches: { guide: ApiGuide; dirName: string }[],
423
- ): AgentToolResult<unknown> {
424
- const lines: string[] = [];
425
- lines.push(`${matches.length} API guides for '${domain}':`);
426
- lines.push(formatGuideListings(matches));
427
- const example = matches[0]!.guide.shortName;
428
- lines.push(
429
- `Call api-learn({domain: "${domain}", guide: "${example}"}) to fetch one guide's recipe.`,
430
- );
431
- return {
432
- content: [{ type: "text", text: lines.join("\n") }],
433
- details: { mode: "menu", domain, disambiguation: matches.length },
434
- };
435
- }
436
-
437
419
  // ═══════════════════════════════════════════════════════════════════
438
420
  // Tool definition
439
421
  // ═══════════════════════════════════════════════════════════════════
@@ -541,34 +523,23 @@ export const apiLearnTool = defineTool({
541
523
  const { guide, dirName } = matches[0]!;
542
524
  return stageFetchedRecipe(domain, guide, dirName);
543
525
  }
544
- // N guides → disambiguation by shortName (mirrors api-guide).
526
+ // N guides → disambiguation by shortName (shared menu helper).
545
527
  if (!guideSelector) {
546
- return renderFetchMenu(domain, matches);
528
+ return disambiguationMenuResult(
529
+ domain,
530
+ matches,
531
+ (shortName) =>
532
+ `Call api-learn({domain: "${domain}", guide: "${shortName}"}) to fetch one guide's recipe.`,
533
+ );
547
534
  }
548
535
  const sel = selectGuideByShortName(matches, guideSelector);
549
536
  if (!sel.ok) {
550
- return {
551
- content: [
552
- {
553
- type: "text",
554
- text: shortNameErrorText(
555
- sel,
556
- domain,
557
- guideSelector,
558
- `Call api-learn({domain: "${domain}"}) to see the menu.`,
559
- ),
560
- },
561
- ],
562
- details:
563
- sel.reason === "no_match"
564
- ? { error: "no_guide_by_shortname", domain, guide: guideSelector }
565
- : {
566
- error: "ambiguous_shortname",
567
- domain,
568
- guide: guideSelector,
569
- directories: sel.directories,
570
- },
571
- };
537
+ return shortNameErrorResult(
538
+ sel,
539
+ domain,
540
+ guideSelector,
541
+ `Call api-learn({domain: "${domain}"}) to see the menu.`,
542
+ );
572
543
  }
573
544
  return stageFetchedRecipe(domain, sel.guide, sel.dirName);
574
545
  }
@@ -93,8 +93,6 @@ export interface ProbeResult {
93
93
  }
94
94
 
95
95
  export interface ProbeOptions {
96
- /** Accept header (default application/json). */
97
- accept?: string;
98
96
  /** On 404, walk the apiHost version backward (vN→v1). Default true. */
99
97
  walkVersions?: boolean;
100
98
  /**
@@ -563,7 +561,6 @@ export async function probe(
563
561
  opts: ProbeOptions = {},
564
562
  ctx?: ExtensionContext,
565
563
  ): Promise<ProbeResult> {
566
- const accept = opts.accept ?? "application/json";
567
564
  const walkVersions = opts.walkVersions ?? true;
568
565
  const domain =
569
566
  opts.domain ?? resolveProvisionedParentDomain(hostnameOf(apiHost));
@@ -575,7 +572,6 @@ export async function probe(
575
572
  apiHost,
576
573
  path,
577
574
  params,
578
- accept,
579
575
  authCtx,
580
576
  domain,
581
577
  versionPrefixOf(apiHost),
@@ -593,7 +589,7 @@ export async function probe(
593
589
  return base;
594
590
  }
595
591
  const hit = await walkBackward(
596
- { apiHost, path, params, accept, authCtx, domain },
592
+ { apiHost, path, params, authCtx, domain },
597
593
  Number(stated),
598
594
  );
599
595
  return hit ?? base;
@@ -615,7 +611,6 @@ async function walkBackward(
615
611
  apiHost: string;
616
612
  path: string;
617
613
  params: Record<string, unknown>;
618
- accept: string;
619
614
  authCtx: ProbeAuthCtx;
620
615
  domain: string;
621
616
  },
@@ -628,7 +623,6 @@ async function walkBackward(
628
623
  withVersion(ctx.apiHost, k),
629
624
  ctx.path,
630
625
  ctx.params,
631
- ctx.accept,
632
626
  ctx.authCtx,
633
627
  ctx.domain,
634
628
  `/v${k}`,
@@ -667,7 +661,6 @@ async function fetchOne(
667
661
  apiHost: string,
668
662
  path: string,
669
663
  params: Record<string, unknown>,
670
- accept: string,
671
664
  authCtx: ProbeAuthCtx,
672
665
  domain: string,
673
666
  prefix = "",
@@ -694,7 +687,7 @@ async function fetchOne(
694
687
  const url = redactUrl(rawUrl);
695
688
  const hasQuerySecret = Object.keys(authCtx.queryParams).length > 0;
696
689
  const res = await fetchUrl(rawUrl, {
697
- headers: { accept, ...authCtx.headers },
690
+ headers: { accept: "application/json", ...authCtx.headers },
698
691
  fresh: true,
699
692
  ...(authCtx.hasAuthBlock
700
693
  ? {
@@ -787,8 +780,7 @@ async function fetchOne(
787
780
  shape: null,
788
781
  draft: "",
789
782
  raw,
790
- note:
791
- "non-JSON body (set opts.accept for XML/HTML, or use a different path)",
783
+ note: "non-JSON body (use a different path)",
792
784
  };
793
785
  }
794
786
 
@@ -839,21 +831,20 @@ function buildUrl(
839
831
  const substituted = fillPathTemplate(path, fillParams);
840
832
  const qs = new URLSearchParams(
841
833
  Object.fromEntries(
842
- Object.entries(queryParams)
843
- .filter(([k, v]) => v !== undefined && !pathTokens.has(k))
844
- .map(([k, v]) => {
845
- // Probe has no guide schema to declare listStyle against, so
846
- // non-scalar values are a loud error — never the silent
847
- // String(v) wire form (String(["a","b"]) → "a,b" is
848
- // accidentally comma-correct but misdescribes repeat/bracket
849
- // APIs; [object Object] is wrong everywhere).
850
- if (Array.isArray(v) || (typeof v === "object" && v !== null)) {
851
- throw new Error(
852
- `Param "${k}" is ${Array.isArray(v) ? "an array" : "an object"} — api-probe sends scalar params only: pre-join list values as a string, or JSON.stringify DSL params`,
853
- );
854
- }
855
- return [k, String(v)] as [string, string];
856
- }),
834
+ Object.entries(queryParams).flatMap(([k, v]): [string, string][] => {
835
+ if (v === undefined || pathTokens.has(k)) return [];
836
+ // Probe has no guide schema to declare listStyle against, so
837
+ // non-scalar values are a loud error — never the silent
838
+ // String(v) wire form (String(["a","b"]) → "a,b" is
839
+ // accidentally comma-correct but misdescribes repeat/bracket
840
+ // APIs; [object Object] is wrong everywhere).
841
+ if (Array.isArray(v) || (typeof v === "object" && v !== null)) {
842
+ throw new Error(
843
+ `Param "${k}" is ${Array.isArray(v) ? "an array" : "an object"} — api-probe sends scalar params only: pre-join list values as a string, or JSON.stringify DSL params`,
844
+ );
845
+ }
846
+ return [[k, String(v)]];
847
+ }),
857
848
  ),
858
849
  ).toString();
859
850
  return joinUrl(apiHost, substituted, qs);
@@ -27,14 +27,15 @@
27
27
  import { defineTool } from "@earendil-works/pi-coding-agent";
28
28
  import { Type } from "@earendil-works/pi-ai";
29
29
  import { Text } from "@earendil-works/pi-tui";
30
- import { appendFooter, contentText } from "./utils.js";
30
+ import {
31
+ appendFooter,
32
+ contentText,
33
+ disambiguationMenuResult,
34
+ shortNameErrorResult,
35
+ } from "./utils.js";
31
36
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
32
37
  import { join } from "node:path";
33
- import {
34
- formatGuideListings,
35
- selectGuideByShortName,
36
- shortNameErrorText,
37
- } from "../core/guide-catalog.js";
38
+ import { selectGuideByShortName } from "../core/guide-catalog.js";
38
39
  import type { ApiGuide } from "../core/api-guide-types.js";
39
40
  import { findGuidesByDomain, getUserGuidesDir } from "../core/guide-store.js";
40
41
  import { assertSafeDomain, slug } from "../core/path-template.js";
@@ -235,45 +236,22 @@ export const apiScaffoldTool = defineTool({
235
236
  } else if (guideSelector) {
236
237
  const sel = selectGuideByShortName(matches, guideSelector);
237
238
  if (!sel.ok) {
238
- return {
239
- content: [
240
- {
241
- type: "text",
242
- text: shortNameErrorText(
243
- sel,
244
- domain,
245
- guideSelector,
246
- `Call api-scaffold({domain: "${domain}", verify: true}) to see the menu.`,
247
- ),
248
- },
249
- ],
250
- details:
251
- sel.reason === "no_match"
252
- ? { error: "no_guide_by_shortname", domain, guide: guideSelector }
253
- : {
254
- error: "ambiguous_shortname",
255
- domain,
256
- guide: guideSelector,
257
- directories: sel.directories,
258
- },
259
- };
239
+ return shortNameErrorResult(
240
+ sel,
241
+ domain,
242
+ guideSelector,
243
+ `Call api-scaffold({domain: "${domain}", verify: true}) to see the menu.`,
244
+ );
260
245
  }
261
246
  selected = sel;
262
247
  } else {
263
- // N guides, no selector → disambiguation menu (mirrors api-learn).
264
- return {
265
- content: [
266
- {
267
- type: "text",
268
- text: [
269
- `${matches.length} API guides for '${domain}':`,
270
- formatGuideListings(matches),
271
- `Call api-scaffold({domain: "${domain}", guide: "${matches[0]!.guide.shortName}", verify: true}) to scaffold one.`,
272
- ].join("\n"),
273
- },
274
- ],
275
- details: { mode: "menu", domain, disambiguation: matches.length },
276
- };
248
+ // N guides, no selector → disambiguation menu.
249
+ return disambiguationMenuResult(
250
+ domain,
251
+ matches,
252
+ (shortName) =>
253
+ `Call api-scaffold({domain: "${domain}", guide: "${shortName}", verify: true}) to scaffold one.`,
254
+ );
277
255
  }
278
256
 
279
257
  const { guide, dirName } = selected;
@@ -2,6 +2,12 @@ import type {
2
2
  AgentToolResult,
3
3
  ThemeColor,
4
4
  } from "@earendil-works/pi-coding-agent";
5
+ import {
6
+ formatGuideListings,
7
+ selectGuideByShortName,
8
+ shortNameErrorText,
9
+ } from "../core/guide-catalog.js";
10
+ import type { ApiGuide } from "../core/api-guide-types.js";
5
11
 
6
12
  /**
7
13
  * Extract the text of the first text content block of a tool result.
@@ -15,6 +21,69 @@ export function contentText(
15
21
  return c && c.type === "text" ? c.text : fallback;
16
22
  }
17
23
 
24
+ /**
25
+ * Tool-channel error result for a failed `selectGuideByShortName` — the
26
+ * shared envelope (error text + structured details) behind api-guide,
27
+ * api-learn, and api-scaffold's `{domain, guide}` resolution. The trailing
28
+ * "how to see the menu" hint differs per tool, so callers pass it.
29
+ */
30
+ export function shortNameErrorResult(
31
+ sel: Extract<ReturnType<typeof selectGuideByShortName>, { ok: false }>,
32
+ domain: string,
33
+ selector: string,
34
+ callToAction: string,
35
+ ): AgentToolResult<unknown> {
36
+ return {
37
+ content: [
38
+ {
39
+ type: "text",
40
+ text: shortNameErrorText(sel, domain, selector, callToAction),
41
+ },
42
+ ],
43
+ details:
44
+ sel.reason === "no_match"
45
+ ? { error: "no_guide_by_shortname", domain, guide: selector }
46
+ : {
47
+ error: "ambiguous_shortname",
48
+ domain,
49
+ guide: selector,
50
+ directories: sel.directories,
51
+ },
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Tool-channel disambiguation menu for a domain claimed by N guides:
57
+ * count header (+ shared `organization:` when all matches share one),
58
+ * per-guide listings, and a per-tool call-to-action footer built from
59
+ * the first match's shortName.
60
+ */
61
+ export function disambiguationMenuResult(
62
+ domain: string,
63
+ matches: { guide: ApiGuide }[],
64
+ callToAction: (shortName: string) => string,
65
+ ): AgentToolResult<unknown> {
66
+ const orgs = new Set(
67
+ matches.map((m) => m.guide.organization).filter((o): o is string => !!o),
68
+ );
69
+ const orgName = [...orgs][0];
70
+ const orgPart =
71
+ orgs.size === 1 && orgName ? ` (organization: ${orgName})` : "";
72
+ return {
73
+ content: [
74
+ {
75
+ type: "text",
76
+ text: [
77
+ `${matches.length} API guides for '${domain}'${orgPart}:`,
78
+ formatGuideListings(matches),
79
+ callToAction(matches[0]!.guide.shortName),
80
+ ].join("\n"),
81
+ },
82
+ ],
83
+ details: { mode: "menu", domain, disambiguation: matches.length },
84
+ };
85
+ }
86
+
18
87
  /**
19
88
  * Append a dim-styled content preview to an in-progress result string,
20
89
  * with a "more chars" suffix when the content exceeds the given limit.