Skip to content

feat: add apimatic plugin generate, and share one bounded generation poll - #314

Merged
MuHamza30 merged 12 commits into
devfrom
hamza/plugin-generate-happy-path
Aug 13, 2026
Merged

feat: add apimatic plugin generate, and share one bounded generation poll#314
MuHamza30 merged 12 commits into
devfrom
hamza/plugin-generate-happy-path

Conversation

@MuHamza30

@MuHamza30 MuHamza30 commented Aug 10, 2026

Copy link
Copy Markdown

Adds apimatic plugin generate, and — because the plugin flow needed the same generation
polling 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 generate

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).

Scoped to a plugin-config.json that already exists; creating one, and recording published
SDKs into it from sdk publish, follow separately. The command does not yet verify the file
is present before uploading — the server reports it.

Changes to portal and SDK generation

  • Completion is a 302, not a Completed status body. The plugin service originally
    waited for the body and mapped the redirect to InvalidResponse, so every successful run
    ended on "An unexpected error occurred". apimatic/apimatic-docs#829 documents the redirect
    for both endpoints, and ApiService already handled it for portal.
  • Each flow now owns its status call. ApiService.getGenerationStatus(endpoint, …) took
    the 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 ApiService for nothing else. GenerationStatusEndpoint is deleted and
    ApiService is back to account and telemetry.
  • One poll loop, in 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.
  • Every generation is bounded: 3s poll interval, 30 min generation budget, 4 min per
    request, each defined once. PortalService previously had no request timeout at all, so
    its budget could not fire on a poll that connected and never answered.

Known limits

  • The budget is read between polls, so the effective ceiling is 30 min plus one request
    timeout rather than 30 min flat.
  • Only the poll phase is bounded. Portal and SDK upload/download go through the SDK client,
    which the request timeout does not reach.
  • A single transient poll failure still ends the run with no retry, costing a re-upload.
  • A Failed status still discards the reasons the backend attaches to it, reporting the
    generic "unexpected error" instead — even though the spec returns errors with Failed
    as well as with ValidationError.
  • A 401 during portal or SDK polling still shows the bare "Unauthorized access." rather than
    naming the auth login remedy the way the plugin flow does.

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 MuHamza30 self-assigned this Aug 10, 2026
@MuHamza30
MuHamza30 marked this pull request as draft August 10, 2026 11:03
MuHamza30 and others added 2 commits August 11, 2026 11:01
`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
MuHamza30 marked this pull request as ready for review August 11, 2026 06:50
MuHamza30 and others added 8 commits August 11, 2026 13:29
`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>
@saeedjamshaid saeedjamshaid changed the title feat: add apimatic plugin generate command feat: add apimatic plugin generate, and share one bounded generation poll Aug 13, 2026
`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>
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
10.1% Duplication on New Code (required ≤ 3%)

See analysis details on SonarQube Cloud

@MuHamza30
MuHamza30 merged commit 8f00d76 into dev Aug 13, 2026
3 of 4 checks passed
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