Skip to content

feat(agent-bff): serve the OpenAPI document in a browser through Redoc - #1829

Open
nbouliol wants to merge 12 commits into
feature/prd-687-verify-a-generated-client-calls-list-count-form-and-executefrom
feature/prd-965-serve-the-bff-openapi-document-in-a-browser-through-redoc
Open

feat(agent-bff): serve the OpenAPI document in a browser through Redoc#1829
nbouliol wants to merge 12 commits into
feature/prd-687-verify-a-generated-client-calls-list-count-form-and-executefrom
feature/prd-965-serve-the-bff-openapi-document-in-a-browser-through-redoc

Conversation

@nbouliol

@nbouliol nbouliol commented Aug 14, 2026

Copy link
Copy Markdown
Member

Stacked on #1828 — base it there, review this diff on its own, and retarget to main once #1828 lands.

What

GET /docs renders the BFF's dynamic OpenAPI document in a browser, with Redoc served by the BFF itself. GET /docs/redoc.standalone.js serves the viewer bundle.

Why it is shaped this way

BFF auth is header-only — Authorization: Bearer <bff_access> or X-Forest-Bff-Key (src/auth/auth-mode-middleware.ts), no cookie anywhere in the package. resolveAuthMode throws unauthorized() when neither header is present (src/auth/auth-mode.ts:24-30), and that middleware covers every /agent/* path. A browser navigating to a page sends neither header, and Redoc cannot attach one to its own spec fetch. So:

  • both routes sit outside /agent, next to the existing public /oauth/* routes;
  • the page is an empty shell — no schema in it, nothing to leak;
  • it prompts for a BFF API key, fetches /agent/openapi.json with that header itself, and passes the parsed object to Redoc.init.

The document therefore stays exactly as unreachable as before: test/cli-core.test.ts asserts /agent/openapi.json still answers 401 while /docs answers 200, which is the interaction the mount invariant of #1827 cannot see on its own.

The key is held in memory only — passed as an argument, input cleared, never written to localStorage or sessionStorage, never put in a URL.

Bundle provenance

redoc is a devDependency; its redoc.standalone.js is copied into dist at build time (build:copy, the pattern forest-cloud already uses for its templates). Consequences:

  • no BFF consumer installs redoc's dependency tree;
  • files: dist/**/*.js already covers the copied asset — no packaging change needed;
  • running from src (tests, build:watch) there is nothing to copy to, so the route falls back to resolving the devDependency. Both paths are covered by the tests.

Not a CDN, deliberately: the page holds a credential in memory, and a third-party script in that page could read it. Pinning an SRI hash would mitigate that at the cost of a manual hash bump per version.

Disabled state

Same flag as the document, BFF_OPENAPI_ENABLED. When it is off both routes fall through to 404 rather than throw: /docs is outside the agent-scoped error middleware (src/cli-core.ts wraps createErrorMiddleware in agentScoped), so a thrown openapiDisabled() would surface as a bare Koa 500 instead of the BFF error contract. A 404 also keeps a disabled deployment from advertising a page it does not serve.

Structural guarantee

src/docs/ imports nothing from src/openapi/ — the document path is passed in from cli-core. The existing openapi-mount-invariant.test.ts enforces this mechanically: any such import would show up in openapiImportsOutsideTheOpenapiDir() and fail. That is a stronger statement than the substring assertion on the page body, and both are in place.

Tests

10 route tests plus 3 full-chain tests: page and bundle public, page carries no schema, page never cached, bundle served as a script, document still 401, both routes 404 when disabled, non-docs paths and writes passed through. Package suite: 71 suites / 1106 tests green, yarn build copies the bundle into dist/docs/.

Not in scope

No "try it out" console, no write path from the page, and no OAuth login flow in the page — the BFF's OAuth is a registered-client authorization server, so the page would have to be registered as a client first.

Fixes PRD-965

🤖 Generated with Claude Code

Note

Serve OpenAPI docs viewer at /docs via Redoc in agent-bff

  • Adds a public Redoc HTML viewer at /docs and serves the Redoc standalone bundle at /docs/redoc.standalone.js, both outside the agent-scoped auth chain when openapiEnabled is true.
  • The HTML page prompts for a BFF API key, fetches the gated OpenAPI document with the X-Forest-Bff-Key header, and never persists the key.
  • The Redoc bundle is resolved at boot from a file copied next to the compiled module, falling back to require.resolve('redoc/bundles/redoc.standalone.js'); contents are read once and cached in memory.
  • Build script now compiles TypeScript then copies the Redoc bundle into dist/docs.
  • Behavioral Change: when openapiEnabled is false or the bundle is missing, /docs requests fall through to next() instead of returning a dedicated response, resulting in 404s upstream. The OpenAPI document at /agent/openapi.json remains credential-gated.

Changes since #1829 opened

  • Integrated Redoc viewer to render OpenAPI documentation with a Forest-branded theme, inline code samples, and secure API key input [a21b213]
  • Added collection-based tagging to OpenAPI operations to organize documentation by resource [a21b213]
  • Gated documentation serving on agent middleware presence and added error handling for unreadable Redoc bundles [a21b213]
  • Updated branding from 'Forest Admin' to 'Forest' in product descriptions and OpenAPI document title [a21b213]
  • Implemented shell-safe, Ruby-safe, and JSON-safe quoting utilities and refactored code sample generators to properly escape special characters [8b02367]
  • Added test coverage for new quoting and escaping behavior in code samples across shell, JavaScript, and Ruby [8b02367]

Macroscope summarized 6518b88.

@linear-code

linear-code Bot commented Aug 14, 2026

Copy link
Copy Markdown

PRD-965

@qltysh

qltysh Bot commented Aug 14, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

This PR will not change total coverage.

Modified Files with Diff Coverage (6)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
packages/agent-bff/src/openapi/openapi-document.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/cli-core.ts100.0%
New file Coverage rating: A
packages/agent-bff/src/docs/docs-theme.ts100.0%
New file Coverage rating: A
packages/agent-bff/src/docs/docs-page.ts100.0%
New file Coverage rating: A
packages/agent-bff/src/docs/docs-samples.ts100.0%
New file Coverage rating: A
packages/agent-bff/src/docs/docs-routes.ts100.0%
Total100.0%
🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

Comment thread packages/agent-bff/src/docs/docs-routes.ts
<body>
<form id="unlock">
<label for="key">BFF API key</label>
<input id="key" name="key" type="password" autocomplete="off" spellcheck="false" />

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The form has no method and no action, so with inline scripts blocked the preventDefault at line 99 never runs and the browser navigates to /docs?key=<secret>, putting the key in browser history, BFF access logs and any proxy in between: let's drop name="key" since the script reads the input by id, and add method="post" as a belt.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 749ce16 — differently: the <form> is gone entirely rather than neutralised.

method="post" still leaves a form that submits. The POST would land on 404 (READ_METHODS only holds GET/HEAD), so no key in a URL, but the form element stays and that is what Chrome reads as a login in your next comment. With a plain <div id="unlock">, a <button type="button"> and an explicit keydown/Enter listener there is no default action to prevent: without the inline script the button does nothing at all instead of navigating. name="key" is gone with it.

<body>
<form id="unlock">
<label for="key">BFF API key</label>
<input id="key" name="key" type="password" autocomplete="off" spellcheck="false" />

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A password input, a form hidden on success and a cleared value are exactly Chrome's successful-login heuristic, which ignores autocomplete="off" on password fields and offers to save the key, so the "key is never persisted" claim in the header comment breaks as soon as the user accepts: let's use autocomplete="new-password", or state that limit in the header comment instead of claiming the key is never persisted.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 749ce16, via the form removal rather than autocomplete.

new-password would not have helped — it is the signup/change-password marker, so Chrome still offers to save, and additionally suggests a generated password on a field that is not one. What actually drives the heuristic is the form submit, and there is no form any more.

The header comment now states the reasoning instead of just the claim.

}

form.style.display = 'none';
Redoc.init(result.body, { hideDownloadButton: true }, document.getElementById('redoc'));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redoc 2.x renders markdown descriptions as HTML without sanitizing unless untrustedSpec is set, and DOMPurify already ships inside redoc's tree, so the option costs nothing on a page that holds a credential in memory: let's pass untrustedSpec: true to Redoc.init.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 749ce16untrustedSpec: true on Redoc.init. The descriptions come from the agent's own schema, which is customer-authored, so it is the right default here regardless of cost.

form.style.display = 'none';
Redoc.init(result.body, { hideDownloadButton: true }, document.getElementById('redoc'));
})
.catch(function (fetchError) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The catch is chained after the second then, so a throw from Redoc.init at line 91, including the ReferenceError when the bundle script did not load, is reported as "Could not reach /agent/openapi.json" and points the reader at the wrong thing: let's check typeof Redoc === 'undefined' before init with its own message, or catch around the init separately.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 749ce16. The init moved out of the fetch chain into a render() with a typeof Redoc === 'undefined' guard (its own message, naming the bundle path) and its own try/catch around Redoc.init. The chain's .catch now only ever reports a fetch failure, which is what it says.

Comment thread packages/agent-bff/src/cli-core.ts Outdated
...oauthMiddlewares,
// Outside the agent-scoped chain on purpose: the viewer is a public page, the document it fetches
// is not.
createDocsRoutes({ enabled: config.openapiEnabled, documentPath: OPENAPI_PATH, logger }),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without FOREST_AUTH_SECRET, buildAgentMiddlewares returns [] so no OpenAPI route is mounted, yet the docs routes are gated on config.openapiEnabled alone, so /docs serves a page whose fetch can only ever get a bare Koa 404: the exact case docs-routes.ts:51-52 says the 404 fall-through exists to avoid. Let's gate it like the error middleware two lines above, with enabled: config.openapiEnabled && agentMiddlewares.length > 0.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 749ce16enabled: config.openapiEnabled && agentMiddlewares.length > 0, exactly as the error middleware two lines above.

Covered in test/cli-core.test.ts: with FOREST_AUTH_SECRET unset, /docs and /agent/openapi.json both answer 404.

});
});

describe('when the viewer bundle is requested without credentials', () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The page's no-store is asserted at lines 52-56 but the bundle's public, max-age=3600 is not, and it is the only cache header in the package that diverges from no-store: let's assert expect(response.headers['cache-control']).toBe('public, max-age=3600') in the bundle describe.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 749ce16expect(response.headers['cache-control']).toBe('public, max-age=3600') in the bundle describe.

@nbouliol
nbouliol force-pushed the feature/prd-687-verify-a-generated-client-calls-list-count-form-and-execute branch from c01807b to e242dc6 Compare August 19, 2026 10:03
@qltysh

qltysh Bot commented Aug 20, 2026

Copy link
Copy Markdown

1 new issue

Tool Category Rule Count
qlty Structure Function with high complexity (count = 12): createDocsRoutes 1

@nbouliol
nbouliol force-pushed the feature/prd-965-serve-the-bff-openapi-document-in-a-browser-through-redoc branch from 749ce16 to 6ce3f42 Compare August 20, 2026 13:41
Comment thread packages/agent-bff/src/docs/docs-page.ts
@nbouliol
nbouliol force-pushed the feature/prd-965-serve-the-bff-openapi-document-in-a-browser-through-redoc branch from 59f68d3 to 475bec7 Compare August 20, 2026 14:28
Comment thread packages/agent-bff/src/docs/docs-page.ts Outdated
Comment thread packages/agent-bff/src/docs/docs-samples.ts Outdated
@nbouliol
nbouliol force-pushed the feature/prd-687-verify-a-generated-client-calls-list-count-form-and-execute branch from e242dc6 to 90b37ef Compare August 21, 2026 10:08
nbouliol and others added 2 commits August 21, 2026 12:09
Adds GET /docs and GET /docs/redoc.standalone.js, both public and both outside
the agent chain: /agent/* answers 401 to a request with no credential, and a
browser sends none when it navigates.

The page carries no schema. It asks for a BFF API key, fetches the gated
document with it, and hands the parsed object to Redoc, so the document stays
unreachable unauthenticated. The key is never persisted.

The bundle is self-hosted rather than loaded from a CDN: the page holds a
credential in memory, and a third-party script in that page could read it. redoc
is a devDependency whose bundle is copied into dist at build time, so no
consumer of the BFF installs its dependency tree.

Fixes PRD-965

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bundle lookup becomes a seam, so the state a broken build leaves behind — the
viewer disabled with a warning naming the missing file, both routes on 404 — is
asserted rather than assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nbouliol
nbouliol force-pushed the feature/prd-965-serve-the-bff-openapi-document-in-a-browser-through-redoc branch from 8091dad to 6518b88 Compare August 21, 2026 10:10
nbouliol and others added 9 commits August 21, 2026 12:18
The prompt was a `<form>` with no `action`, so anything that kept the inline
script from running — a CSP on the deployment is enough — turned the submit
into a navigation to `/docs?key=<the key>`: the credential in the browser
history, in the BFF access log and in every proxy on the way. A form submit is
also what Chrome reads as a login, and it offers to save the key whatever
`autocomplete` says, which broke the "never persisted" claim in the header
comment. `autocomplete="new-password"` would not have helped: it is the
signup marker, and Chrome still offers to save.

So there is no form at all. The prompt is a div, the button is a plain button,
and Enter on the input is wired explicitly — the only thing the form gave.
Without the script the button now does nothing instead of leaking.

Also on that page: `untrustedSpec` on `Redoc.init`, since the descriptions in
the document come from the agent's own schema and Redoc renders their markdown
as HTML unsanitized otherwise; and the init moved out of the fetch chain, so a
missing bundle no longer reports itself as "could not reach the document".

Two mount problems around it:

- `/docs` was gated on `openapiEnabled` alone, so an install with no
  `FOREST_AUTH_SECRET` — no agent chain, no document mounted — served a page
  whose fetch could only ever reach a bare Koa 404. Gated on the edge being
  mounted too, like the error middleware above it.
- `readFileSync` on the bundle ran outside any error handling, so a file that
  resolved at boot and became unreadable answered a bare 500 on a path no
  error middleware covers. It falls through now, like a missing bundle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The viewer was stock Redoc on a bare shell. It now carries the palette the
frontend defines in `app/styles/common/palette.css`: the lime ramp as the
accent, slate as the neutrals, dark chrome on the sidebar and the right panel
the way the product's own chrome reads.

The palette is copied into `docs-theme.ts` rather than shared — this package
depends on nothing in the frontend, and a viewer trailing a shade behind a
redesign is not a defect.

Lime 500 is the brand colour and it carries 1.96:1 against white, so it is
never text here: it is a fill, with slate 1000 on it (9.18:1). Lime 700 is the
lightest shade usable as text on white (4.54:1) and takes the links and the
accents; the dark chrome takes lime 400 (11.5:1 on slate 1000).

Inter and Source Code Pro lead the font stacks but are NOT fetched. A page
holding an API key in memory must not talk to a font CDN, for the same reason
the Redoc bundle is served from here instead of from unpkg. A machine without
them gets the system UI font, which is the price. A test now asserts the page
carries no `https?://` at all, so that reasoning is mechanical rather than
stated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`info.title` is the heading Redoc renders and the name that lands in any client
generated from the document, so it is API metadata rather than page chrome —
kept in its own commit for that reason. No test asserted the old value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The frontend's `public/img/logo.svg` verbatim, minus its XML prolog — the mark
itself rather than a redrawing of it, so it cannot drift in geometry, and a
diff against the source asset stays trivial. 410 bytes, 558 once encoded.

Inline as a data URI rather than a served file: this page must request nothing
off-origin, and an icon file is a request like any other. It also needs no
route of its own and no bundle to exist, unlike everything else the page pulls.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two submissions in quick succession — a mistyped key corrected straight away —
resolve in whatever order the network gives them. Nothing checked which one was
still current, so an abandoned attempt answering late applied its result over
the live one: a stale 401 painting an error box over a rendered document, or a
stale document rendering over the one the reader actually asked for. Both
`then` and `catch` now drop completions that are not the latest attempt.

A counter rather than an AbortController: aborting fires the same `catch` that
would then need filtering anyway, so the check is the whole fix and the abort
only saves a request already in flight.

The page script had no executable test — asserting a guard by substring proves
nothing about ordering. It now runs in a `vm` against a stub DOM and a fetch
whose responses are resolved by hand, which is what lets the three race cases
be driven at all. Verified to bite: with the two checks removed, those three
fail and the other three pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ment

A 200 whose body is not JSON — a gateway page, a truncated response — built the
`unreadable_response` placeholder and then kept `ok: response.ok`, so the
success branch handed that placeholder to `Redoc.init` as if it were a spec.
The message describing the real problem was already there and could never be
shown. The parse failure now sets `ok: false`, which is the only status that
matches what happened.

Also fixes the flake I introduced with the previous commit's harness: `flush()`
awaited a single `process.nextTick`, and the nextTick queue runs BEFORE the
microtask queue, so one tick does not settle a three-hop fetch chain. Proven
rather than guessed — a bare probe shows a 3-deep chain unsettled after
nextTick and settled after setImmediate, which is what it uses now. Three full
suite runs clean since.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The unfolded operations carried no tags, so a viewer had no structure to group
by and rendered one flat list of everything. On a 16-collection schema that is
82 entries in a row; a real one runs into the hundreds, and the reader cannot
find a collection in it.

Each operation now carries its collection as its tag, and the document declares
the tag list. Relation and action operations take their PARENT collection
rather than the foreign one, so a group answers "what can I do with this
collection", which is the question the reader arrives with. The tag list is
declared rather than left to first appearance: it fixes the grouping order, and
it hands a consumer the collection list without parsing paths for it. The
generic document declares none — one operation per shape has nothing to group.

`tag` is required on `OperationOptions` rather than optional, so the compiler
pins every call site and a future operation cannot be added untagged by
omission.

Measured on a real 16-collection schema: 82 operations, 16 declared tags, no
untagged operation, no undeclared tag, and `redocly lint` still clean. Two of
the tests are those last invariants rather than examples, which is what stops
the flat list from creeping back one operation at a time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The docs page now decorates the fetched document with `x-codeSamples`, which
Redoc renders as one tab per language in the right panel.

In the page rather than in the document, deliberately. Three samples per
operation weigh ~73 KB on a 16-collection schema and several hundred KB on a
large one, which every consumer of `/agent/openapi.json` would pay for an
extension only a viewer reads — where this costs one function whatever the
operation count. It also lets a sample carry the REAL origin: the document
declares `servers: [{ url: '/' }]`, so a sample built into it could only hold a
placeholder host, while the page knows where it is served from and emits a
command that runs as pasted.

The generator reads the document rather than assuming the routes. The auth
header comes from the security scheme the operation names — apiKey in header
takes its name, http bearer becomes `Authorization: Bearer` — and the body
carries exactly the properties the request schema makes required, flattening
`allOf`. That yields `parentId` for a relation, `recordIds` for an action and an
empty body for a list without those three families being written down anywhere.

The timezone header is always emitted: `resolveTimezone` throws
`missing_timezone` when the header, the body field and the deployment default
are all absent, so a sample without it is a 400 — which is exactly what a
hand-written snippet forgets.

The key is never inlined. Each language reads it from the environment, so a
copied sample cannot carry a credential into a shell history.

Two guards the tests forced out. `withSamples` swallows its own failures: the
decoration first sat inside `Redoc.init`'s try, so a generator bug would have
surfaced as "Redoc could not render the document" and sent the reader looking in
the wrong place; a shape the generator cannot walk now costs the snippets, never
the page. And placeholder resolution is depth-bounded, because a filter is a
condition tree and a schema can reference itself.

Verified beyond "renders something": the 17 tests assert the exact curl, node
and ruby sources, that no sample carries the key the reader typed, and that a
self-referential schema still renders. Against the real 82-operation document,
all 82 get three samples with no missing auth or timezone header, and every
generated node and ruby sample parses (`ruby -c`, 82/82 each).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sample URLs were `origin + path`, so on the GENERIC document — every path a
template — all three languages pointed at a literal `/agent/v1/{collection}/list`.
That document is reachable whenever the deployment cannot unfold: no AGENT_URL,
or no read-model configuration.

Each path parameter the operation declares now becomes the same `<name>`
placeholder the bodies already use, resolving a `$ref`'d parameter too. One
notation across a snippet reads as "replace this", where `{collection}` could be
mistaken for syntax the API expects.

Deliberately NOT a "usable sample value": the generic document is served
precisely because the deployment cannot enumerate its collections, so no real
name exists to substitute, and an invented one would read as runnable and answer
404. The unfolded document is untouched by this — its segments are already the
real names, URL-encoded — which a test pins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread packages/agent-bff/src/docs/docs-samples.ts Outdated
Comment thread packages/agent-bff/src/docs/docs-samples.ts Outdated
Two defects in the generated samples, both in how values were interpolated.

The curl secret header was single-quoted, so the shell never expanded it and the
request carried the literal `$BFF_KEY` — a sample that looks runnable and earns a
401. It is double-quoted now, with the fixed text around the variable escaped for
that context; every other header keeps single quotes, where expansion would be
wrong.

And an apostrophe in a name broke all three languages. `encodeURIComponent` does
NOT encode `'`, so a collection called `John's orders` reaches the samples with
its apostrophe intact, and a field name or enum value can carry one into a body.
Interpolation now goes through the quoting of the target language: POSIX
close-reopen (`'\''`) for shell, `JSON.stringify` for every JavaScript literal,
backslash escaping for Ruby single quotes, and `#{` neutralised in the Ruby body
since a JSON literal is double-quoted and Ruby interpolates there.

Verified against real parsers rather than by eye: 164 samples per language — the
82-operation document plus a copy whose collection and field names carry
apostrophes — all pass `bash -n`, JavaScript parsing and `ruby -c`. Running one
through a stub `curl` shows the key expanded to its real value and the URL
arriving as a single argument, apostrophe included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants