feat: add apimatic plugin generate, and share one bounded generation poll - #314
Merged
Conversation
Uploads the build directory, polls generation status until a terminal state, then downloads a Claude Code context plugin into `<input>/plugin` (or as `plugin.zip` with `--zip`). Unlike portal and SDK generation, this endpoint reports completion as a status body rather than a 302, and the run is bounded — a generation that never reaches a terminal status gives up after five minutes instead of polling forever. `PluginService` therefore keeps its own poll loop; deduping it against `portal-service.ts` is left to a follow-up. Deferred languages are reported as information, not failure. With `version` defaulting to v3 and C# the only generator emitting a plugin today, a partial result is the common case rather than the exception. Scoped to a `plugin-config.json` that already exists; creating one, and recording published SDKs into it from `sdk publish`, follow separately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MuHamza30
requested review from
Shield-Jaguar,
aliasghar98 and
saeedjamshaid
as code owners
August 10, 2026 11:02
MuHamza30
marked this pull request as draft
August 10, 2026 11:03
`handleServiceError` maps an axios 401 onto the bare "Unauthorized access.", so a key that expires mid-run left the user with nothing to act on. Route every call through `mapTransportError` so upload, poll and download all offer the remedy the poll already did, and assert the rendered message instead of a substring — the previous test passed on "Unauthorized" containing "auth". Drop the deferred-language reporting. codegen-v2 no longer returns `deferred`; it proxies v3 languages to codegen v3 instead, so the enum, the prompt and the result wrapper were dead against the current backend. Trim the command description to what this PR ships — creating `plugin-config.json` and recording SDKs into it land later — and mark `flags` readonly like the other statics. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add the `apimatic plugin generate` entry to the README command reference, which shipped without it. Written by hand rather than via `pnpm readme`: `oclif readme` matches its markers with `\n`, so against this repo's CRLF README it appends a second copy of the reference instead of replacing the existing one, doubling the file. Use the `getConfigDir()` helper that `.ai/skills/command.md` lists on its review checklist, and lower-case the `--zip` description to match every other flag. Correct two comments that described behaviour the code does not have: `mapProblemDetails` handles 400 and 403, not 404, and the poll loop is only bounded between polls. The loop's comment now also records why it does not reuse `pollUntilCompleted` from `portal-service.ts` as `service.md` asks, and that deduping waits on the regenerated SDK. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MuHamza30
marked this pull request as ready for review
August 11, 2026 06:50
`getError` hands back the server's list verbatim and an empty array is truthy, so a `ValidationError` carrying `pluginConfig` with no messages rendered a headed but empty list. Being an if/else, it also skipped the branch that prints the full message the service had already assembled from every key — so the one line telling the user what to fix was discarded. Guard on `?.length`, as `formatSdkValidationError` already does for `sdkMergeFailed`. Inline the routing that picks between those messages. No other action carries a private method, and `portal/generate.ts` does the same job — inspect a `ServiceError` for a key, choose a prompt — inside `execute`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ZipService.unArchive` throws on a payload that is not a zip, and on its own file-count and size guards — so the protection against a zip bomb surfaced as a raw stack trace rather than a message. Nothing between the context and oclif caught it, which `instructions.md:47` and `action.md:33` both forbid. Catch around the save and report it, the shape `portal/serve.ts:47` already uses for a throwing context. The reason is passed through so a user can tell an invalid archive from one that exceeded a limit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The orchestrator reports `Unknown` whenever it has not written a custom status yet, which includes the window right after a run starts, so treating it as terminal ended a generation that was about to succeed. Fall through to the next poll instead, as `portal-service.ts` does for any status it does not recognise, and let the deadline stop a genuinely stuck run. Reshape the loop to match that one while here: read the deadline after the status rather than before, so a run that finished during the last wait is not reported as a timeout, and derive the timeout message from the budget instead of hardcoding five minutes — the previous test asserted "after 5 minutes" against a 15ms budget. Move the axios ProblemDetails and 401 mappers into `service-error.ts`. `handleServiceError` reads bodies only off the SDK's typed errors, so every axios service loses 400 and 403 detail; the fix was reachable only from `plugin-service.ts` where it was written. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`axios.create` set no timeout, so a request that connected and then never answered never settled. The budget is only read once a status call returns, so it was never reached: the CLI sat on a spinner indefinitely. Removing the line added here does not merely fail the new test — the test process itself refuses to exit, which is the same event loop that keeps the command alive. `src/config/axios-config.ts` already held the value and was imported nowhere; it now exports it. The size caps stay unused: there is no client-side upload limit today and adding one is a separate decision. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A `ValidationError` can carry `pluginConfig` and `sdkRepos` together, but the routing treated them as alternatives, so the second was dropped along with its next-steps note — and `errorMessage`, which already held every message, was never printed either. The user paid a second upload and generation to learn what the first response had told us. Delete `ServiceError.serverError`: it was added for the `Unknown` branch and has had no caller since that branch went. Note this leaves a `Failed` status still discarding the reasons the backend attaches to it. Cover the default `--zip=false` path, which every ordinary run takes and no test exercised, and record download requests in the fake server so the test claiming to authenticate them can actually check. Rename the download-failure test to what it asserts — the error code, not the stream cleanup its name promised. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The action was reading `getError('pluginConfig')` and `getError('sdkRepos')` to
decide which message to show — knowing the shape of an error payload in order to
pick wording. Hand the whole `ServiceError` to one prompt, as `sdk generate`
does, and let the prompts class hold the variations. Portal branches in its
action only because its error is a union and it needs `instanceof` to tell a
`ServiceError` from a stream; ours is not.
Drop the guard around the save so it reads like portal's two lines, and with it
the `pluginSaveFailed` prompt that then had no caller. Note this restores the
raw stack trace a payload that is not a zip produces.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`pluginGenerationServiceError` inspected the error for `pluginConfig` and `sdkRepos` and picked wording per key. Any other key was reported by neither the key branches nor the fallback, which only ran when both were absent — so a response carrying a key the CLI did not know about lost those messages entirely, and the comment above the method claimed the opposite. The service already assembles one message from every key, so logging `errorMessage` reports all of them and no branch can suppress any. The per-key prompts and their tests go with it, and the surviving test asserts that a key the CLI does not recognise still reaches the user. `nextStepsPublishSdks` is kept but has no caller: it names the `apimatic sdk publish` remedy, which is the one thing the server's message cannot supply, and wants a trigger that does not reintroduce the branching. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The plugin status endpoint signals a finished run with a `302` to the download location, exactly as `/portal/v2` does — it never sends a `Completed` status body. `PluginService` waited for that body and mapped the redirect to `InvalidResponse`, so every successful generation ended on "An unexpected error occurred". The spec added in apimatic/apimatic-docs#829 documents the redirect for both endpoints, and `ApiService` already handled it for portal. `ApiService.getGenerationStatus()` took the endpoint as a parameter, so the four flows shared one status read and would have accumulated per-endpoint branches as they diverge. Each flow now owns its own: portal, SDK and V4 SDK move onto `PortalService`, which used `ApiService` for nothing else and carries the axios plumbing that comes with them. `ApiService` is back to account and telemetry, and `GenerationStatusEndpoint` is gone. The poll loop existed twice — portal's polled a stuck generation forever, plugin's carried a deadline that never reached the other three. One copy now lives in `src/infrastructure/generation-status-poller.ts`, takes a budget from every caller, and keeps the invariant the plugin copy established: the deadline is read after the status, so a run that finished during the last wait is not reported as a timeout, and an unrecognised status keeps polling. Timeouts are now one value each: a 3s poll interval and a 30 min generation budget beside the loop, and a single 4 min request timeout in the axios config. `PortalService` had no request timeout at all, so its budget could not fire on a poll that connected and never answered. Note the budget is still only read between polls, which leaves the effective ceiling at 30 min plus one request timeout rather than 30 min flat. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
apimatic plugin generate commandapimatic plugin generate, and share one bounded generation poll
`new PortalService(1, 5_000, 30)` said nothing about which number was the poll interval, which the generation budget and which the per-request bound. No production caller passed any of them either — all three parameters existed only so tests could shrink the pacing, which is the worst reason to have a positional seam. Both services now take a single optional `GenerationTimings`, defaulted by spreading over a `TIMING_DEFAULTS` object: one assignment instead of three fields, and `Partial<typeof TIMING_DEFAULTS>` derives the accepted options from the defaults so the two cannot drift. `TIMING_DEFAULTS` is declared per service rather than shared — two of its values belong to the poll loop and one to axios config, so a shared home would make the poller import transport config it does not use. The type is exported because `declaration: true` rejects a private name in a public constructor signature. Two cleanups while here: the `sdkMergeFailed` note moves onto `formatSdkValidationError`, which is what it describes rather than `createAuthorizationHeader` several methods above it, and the plugin action loses its `displayMessages` parameter, which no caller ever passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
saeedjamshaid
approved these changes
Aug 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Adds
apimatic plugin generate, and — because the plugin flow needed the same generationpolling the other three flows use — reworks that polling for portal and SDK generation too.
The second part changes behaviour in shipped commands, so it is called out separately below.
apimatic plugin generateUploads the build directory, polls generation status until a terminal state, then downloads
a Claude Code context plugin into
<input>/plugin(or asplugin.zipwith--zip).Scoped to a
plugin-config.jsonthat already exists; creating one, and recording publishedSDKs into it from
sdk publish, follow separately. The command does not yet verify the fileis present before uploading — the server reports it.
Changes to portal and SDK generation
302, not aCompletedstatus body. The plugin service originallywaited for the body and mapped the redirect to
InvalidResponse, so every successful runended on "An unexpected error occurred". apimatic/apimatic-docs#829 documents the redirect
for both endpoints, and
ApiServicealready handled it for portal.ApiService.getGenerationStatus(endpoint, …)tookthe endpoint as a parameter, so four flows shared one read and would have accumulated
per-endpoint branches as they diverge. Portal, SDK and V4 SDK move onto
PortalService,which used
ApiServicefor nothing else.GenerationStatusEndpointis deleted andApiServiceis back to account and telemetry.src/infrastructure/generation-status-poller.ts. It existed twice:portal's polled a stuck generation forever, plugin's carried a deadline the other three
never got. The surviving copy keeps the invariants the plugin one established — the
deadline is read after the status, so a run that finished during the last wait is not
reported as a timeout, and a status the loop does not recognise keeps the run alive.
request, each defined once.
PortalServicepreviously had no request timeout at all, soits budget could not fire on a poll that connected and never answered.
Known limits
timeout rather than 30 min flat.
which the request timeout does not reach.
Failedstatus still discards the reasons the backend attaches to it, reporting thegeneric "unexpected error" instead — even though the spec returns
errorswithFailedas well as with
ValidationError.naming the
auth loginremedy the way the plugin flow does.