Skip to content

feat(plugin): generate a context plugin without a published SDK - #351

Merged
sohail2721 merged 13 commits into
feat/local-plugin-generationfrom
feat/plugin-language-selection
Sep 24, 2026
Merged

sohail2721 merged 13 commits into
feat/local-plugin-generationfrom
feat/plugin-language-selection

Conversation

@sohail2721

Copy link
Copy Markdown
Contributor

Stacked on #350 — review that one first; this PR's diff is the second commit only, and its
base moves to dev when #350 merges.

plugin generate now asks which languages the plugin should include and builds one whether or not
an SDK has been published. Implements the standalone apimatic plugin generate screens from the
signed-off journey mockups.

Why

The command asked for the plugin's identity and then refused to build unless sdk publish had
already recorded a language — returning success() with no plugin, an exit code
indistinguishable from a real run to &&, to CI and to quickstart. So a new user had to hand over
GitHub credentials and create a publishing profile in a second web app before seeing the feature we
most want them to see. Removing that gate is the highest-leverage change in the onboarding journey.

What it does now

◆  Which languages should your plugin include?
│  ◼ C#          published — always included
│  ◼ TypeScript
│  ◻ Python
│
▲  You have a publishing profile set up.
│  We recommend publishing your SDK first for a better plugin experience.
│
◆  Do you still want to continue with a local plugin?
│  ● Yes / ○ No

A selected language that is not published is recorded as "typescript": {} — an entry with no
publishing block, which is what asks the service to generate that SDK and bundle it inside the
plugin. This is why #350 has to land first: an empty entry is only expressible, and only
accepted, under the publishing key.

Decisions worth naming

The gate is deleted, not rewritten. With every selectable language producible, there is no
"can we proceed?" question left. The only no-plugin exit is the user selecting nothing, and it
returns cancelled() — exit code 130, asserted, because the wrong exit code is the actual bug
being removed.

A published language is pre-checked and stays checked. Its entry records where the SDK actually
went, so clearing it would either leave that record describing something the plugin does not
mention, or delete it. Rather than a checkbox that silently does nothing when cleared, the option
is labelled published — always included and a cleared one is put back with a line saying why.

requestLanguages adds what is missing and never edits what is recorded. A published entry
comes through byte-identical, and a run that adds nothing does not rewrite the file at all — which
is what keeps the existing "carries no byte-order mark, and the rest of the file as written"
assertion true now that this path writes.

The publishing-profile recommendation fires on building something local, not on having a
profile.
If every selected language is already published there is nothing preview about the
result, so nothing is said. Declining cancels rather than starting sdk publish — a prompt that
launches a different, longer command on a No is one the user cannot predict. The lookup is
advisory: one that cannot answer is read as "no profile" rather than failing a generation the user
asked for (--auth-key does not reach getPublishingProfiles).

The preview caveat appears only where a profile exists. A user without one cannot act on
"publish for production", so for them the run ends at the install instructions rather than on a
caveat they cannot clear. This matches the mockups and settles apimatic/apimatic-io#2216's open
question 6 in favour of the issue text.

java, php and ruby are named rather than dropped in silence. They have no v4 renderer, so the
service leaves them out whatever the config says. Their entries are left untouched and the omission
is stated — otherwise a user who published a Java SDK gets a plugin quietly missing it.

Not in this PR

Before this can be used against dev

codegen-v2 #449 has to merge and deploy. Until it does, an empty entry is refused with
'publishing' must not be empty. and nothing here reaches a plugin. That is also the ordering
hazard from #2216 — a CLI that writes empty entries against a service that does not bundle gets
HTTP 200 and a plugin with no SDK in it, which it cannot detect — and it stays safe only while this
series is unreleased.

Testing

698 passing, 19 pending. tsc clean on src and test, eslint clean. The three failing
examples-parse assertions (Command portal generate has no examples) fail identically on a clean
dev.

New coverage: generating with nothing published and no SDK on disk (this one test is the feature
— it replaces the assertion that the gate short-circuits); selecting nothing cancels with 130;
a cleared published language is kept and reported; a published entry survives a selection
byte-identical; requestLanguages does not rewrite a file it adds nothing to; the recommendation
and preview note fire only with a profile and something local; a failed profile lookup still
generates; and an unsupported language is named with its entry intact.

🤖 Generated with Claude Code

`plugin generate` asked for nothing but the plugin's identity and then
refused to build unless `sdk publish` had already recorded a language —
returning `success()` with no plugin, an exit code indistinguishable from a
real run to `&&`, to CI and to quickstart. A new user therefore had to hand
over GitHub credentials and create a publishing profile before seeing the
feature we most want them to see.

The command now asks which languages the plugin should include and records
the ones that are not published as empty entries, which is what asks the
service to generate each SDK and bundle it inside the plugin. The gate is
deleted rather than rewritten: with every selectable language producible,
there is no "can we proceed?" question left, and the one no-plugin exit is
selecting nothing, which cancels with 130.

A published language is offered checked and stays checked. Its entry records
where the SDK actually went, so clearing it would either leave that record
describing something the plugin does not mention or delete it outright; the
option is labelled and the language is put back with a word rather than left
as a checkbox that does nothing. `requestLanguages` adds what is missing and
never edits what is recorded — a run that adds nothing does not rewrite the
file at all, which is what keeps the upload byte-identical.

Where a publishing profile exists and something local is about to be built,
the run says so once and asks a single yes/no, defaulting to yes: having a
profile means the user is able to publish, not that they want to right now.
Declining cancels rather than branching into another command. The preview
caveat is shown on that path only — a user without a profile cannot act on
"publish for production", so for them the run ends at the install
instructions. The lookup is advisory, so one that cannot answer is read as
no profile rather than failing a generation the user asked for.

java, php and ruby are named rather than dropped in silence: they have no v4
renderer, so the service leaves them out whatever the config says, and their
entries are left untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Muhammad Sohail and others added 8 commits September 24, 2026 09:52
A config that names no language came up with nothing checked, so the most
common run — a project that has only ever had a spec — needed three
keystrokes before Enter did anything, and an Enter pressed straight away
cancelled.

A config that names languages has already made the choice and still comes up
checked exactly as it is. One that names none has not chosen against
anything, so every language the plugin can carry is checked and a single
Enter gives the plugin covering all of them. Nothing is checked only when
there is nothing to check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The generated plugin ended on a boxed note listing each assistant's
documentation for loading an unpublished folder, and then a second box
telling the user to publish the plugin to GitHub — which read as a
contradiction beside the preview caveat that followed it.

Both go. The installer already knows how every editor loads a local plugin,
so naming it once is shorter than four links and is the only line that stays
right when an editor changes its procedure. Publishing the plugin is its own
command with its own instructions; a run that just produced a preview plugin
is not the place to recommend it.

What a successful run now says is the artifact path, the one command that
installs it, and — only where a publishing profile exists — that the plugin
is preview only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The run ended on a spinner that said only that it had succeeded, followed by
a line naming the directory. Both are the same fact, and the one the reader
wants is where the plugin is.

The spinner now carries it — `Plugin generated successfully — ./plugin`,
with the location dimmed, since it is the context rather than the message —
and the separate line goes.

`format.relativePath` renders it as a reader would type it from where they
are, because `DirectoryPath` resolves on construction and otherwise even the
directory the command was run in prints in full. A location outside the
working directory still prints absolute, which is the only form that is any
use there. The install command reads from the same helper so the two lines
name the plugin the same way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`plugin generate`'s summary read "Generate a context plugin for your
published SDKs" — the one sentence this feature disproves, and the first
thing `--help` and the README show. The description was stale too: it named
`apimatic.json` as a requirement, when the command creates one.

Both now say what the command does: you choose the languages, one whose SDK
is not published yet is generated and bundled inside the plugin, and what is
actually required is a `src` directory with the API specification.

The README's two copies are patched by hand rather than regenerated, because
`oclif readme` still rewrites the whole CRLF file (#346, #348).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`execute` had grown to eighteen branches, past what Sonar allows and past
what reads in one pass. The steps it runs are unchanged; each is now its
own method, so the body is the order they happen in.

`openConfig` reports through a Result because opening the config can end
the command three ways — unreadable, cancelled at the metadata prompts,
or failed there — and the caller ends on whichever came back rather than
deciding again. `confirmBundledSdks` answers whether the plugin is a
preview, with `undefined` for a user who stopped, matching how
`selectLanguages` above it already signals that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The muted location was built inside the sentence it sits in, two template
literals deep. It reads the same named once and used plainly.

`node:path` for the import, which is what the services in this repo
already write.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two steps were each called once and only from generate, and the
config one had to wrap its value in a Result to carry the result to end
on. Inlined, the early returns are the returns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sohail2721
sohail2721 force-pushed the feat/plugin-language-selection branch from f5aa0d4 to 82af79f Compare September 24, 2026 07:39
Muhammad Sohail and others added 4 commits September 24, 2026 12:50
`PluginConfigPresent` was named in #318 after `plugin-config.json`, which it
read literally. It reads the `plugin` and `languages` blocks of
`src/apimatic.json` now, and nothing in the CLI has opened that file since
the migration — but `let config: PluginConfigPresent` still read as a
statement about a file being on disk.

`PluginConfig` is what it holds, and pairs with `PluginConfigData` the way
`PluginMetadata` pairs with `PluginIdentityData`. `present` stays as the
discriminant, where it belongs: it is the third state in `PluginConfigState`,
and a comment on the class now says what it does and does not promise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`unsupportedLanguages` and `languageEntries` each opened by checking that
`languages` is an object. It always is: `configOf` writes `?? {}`,
`document.languages()` hands back an object or nothing at all, and a file
whose `languages` is not one is `unreadable` before a config is built.
`PluginConfig.create` has two callers and both go through `configOf`.

They would not have caught the one value that could arrive from a weaker
reader either — `typeof [] === 'object'`, so an array went straight through
into `Object.keys`. A note above the pair says where the guarantee is made.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`sdk quickstart` and `sdk publish` both hand a prompt the domain object,
let it build the options, say what it needs to say, and return the answer
or nothing; the action reads one value and branches. Plugin generation had
grown its own arrangement instead — a private action method computing the
option list, the pre-checked set and the put-back, with a prompt taking
three parallel arrays.

`selectLanguages(config)` now owns the choice, as `selectLanguage(profile)`
does next door, and the action is the four lines the others are.

Which languages come up checked is a question about the config, so it is
`PluginConfig.initialLanguages()` rather than a line in a prompt no test in
this repo can drive — `multiselect` is an ESM export sinon cannot replace,
which is why none of the prompt suites reach a clack call. The three tests
that asserted on the old arguments become three on that method, next to
`publishedLanguages` and `requestedLanguages` where they belong.

The action guards `!selection?.length`, so an empty answer ends the command
whether the prompt returned nothing or the user cleared every box.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`confirmBundledSdks` and `buildPlugin` each had one caller, and the first
paid for it with a tri-state return: `false` for a plain build, `true` for
a preview, `undefined` for a cancel the caller then had to turn back into
`ActionResult.cancelled()`. Inlined, `preview` is a plain boolean and the
cancel returns where it happens.

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

Copy link
Copy Markdown

@sohail2721
sohail2721 merged commit 6f2d176 into feat/local-plugin-generation Sep 24, 2026
2 checks passed
sohail2721 added a commit that referenced this pull request Sep 24, 2026
* fix(config): nest publishing under the language

`apimatic.json` shipped each language's `source`, `package` and
`codegenVersion` flat on the entry, but codegen-v2 binds them one level
down, under `publishing`. A flat entry therefore arrived with every field
skipped as an unknown key and the language read as carrying nothing: today
that fails validation with `'publishing' must not be empty.`, and once the
bundling change lands it reads as unpublished and gets an SDK generated and
bundled for a language that is already published.

The level is load-bearing beyond the wire. The entry is shared state — the
plugin's skills, the portal's SDK page and publishing all read it — so the
rest of it has to stay free for settings that are not about publishing, and
an entry with no `publishing` block is how "this language was asked for,
nothing is published yet" is expressed.

Preservation moves down with it: a run that publishes one half keeps the
other, and a language entry with no publishing record leaves the recorded
one alone. `languages.<lang>.publishing` joins the entry itself in the
shape checks, because `upsertLanguage` now spreads both levels.

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

* feat(plugin): generate a context plugin without a published SDK (#351)

* feat(plugin): generate without a published SDK

`plugin generate` asked for nothing but the plugin's identity and then
refused to build unless `sdk publish` had already recorded a language —
returning `success()` with no plugin, an exit code indistinguishable from a
real run to `&&`, to CI and to quickstart. A new user therefore had to hand
over GitHub credentials and create a publishing profile before seeing the
feature we most want them to see.

The command now asks which languages the plugin should include and records
the ones that are not published as empty entries, which is what asks the
service to generate each SDK and bundle it inside the plugin. The gate is
deleted rather than rewritten: with every selectable language producible,
there is no "can we proceed?" question left, and the one no-plugin exit is
selecting nothing, which cancels with 130.

A published language is offered checked and stays checked. Its entry records
where the SDK actually went, so clearing it would either leave that record
describing something the plugin does not mention or delete it outright; the
option is labelled and the language is put back with a word rather than left
as a checkbox that does nothing. `requestLanguages` adds what is missing and
never edits what is recorded — a run that adds nothing does not rewrite the
file at all, which is what keeps the upload byte-identical.

Where a publishing profile exists and something local is about to be built,
the run says so once and asks a single yes/no, defaulting to yes: having a
profile means the user is able to publish, not that they want to right now.
Declining cancels rather than branching into another command. The preview
caveat is shown on that path only — a user without a profile cannot act on
"publish for production", so for them the run ends at the install
instructions. The lookup is advisory, so one that cannot answer is read as
no profile rather than failing a generation the user asked for.

java, php and ruby are named rather than dropped in silence: they have no v4
renderer, so the service leaves them out whatever the config says, and their
entries are left untouched.

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

* fix(plugin): pre-check every language on a fresh config

A config that names no language came up with nothing checked, so the most
common run — a project that has only ever had a spec — needed three
keystrokes before Enter did anything, and an Enter pressed straight away
cancelled.

A config that names languages has already made the choice and still comes up
checked exactly as it is. One that names none has not chosen against
anything, so every language the plugin can carry is checked and a single
Enter gives the plugin covering all of them. Nothing is checked only when
there is nothing to check.

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

* refactor(plugin): name the installer instead of four editors

The generated plugin ended on a boxed note listing each assistant's
documentation for loading an unpublished folder, and then a second box
telling the user to publish the plugin to GitHub — which read as a
contradiction beside the preview caveat that followed it.

Both go. The installer already knows how every editor loads a local plugin,
so naming it once is shorter than four links and is the only line that stays
right when an editor changes its procedure. Publishing the plugin is its own
command with its own instructions; a run that just produced a preview plugin
is not the place to recommend it.

What a successful run now says is the artifact path, the one command that
installs it, and — only where a publishing profile exists — that the plugin
is preview only.

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

* refactor(plugin): put the path on the success line

The run ended on a spinner that said only that it had succeeded, followed by
a line naming the directory. Both are the same fact, and the one the reader
wants is where the plugin is.

The spinner now carries it — `Plugin generated successfully — ./plugin`,
with the location dimmed, since it is the context rather than the message —
and the separate line goes.

`format.relativePath` renders it as a reader would type it from where they
are, because `DirectoryPath` resolves on construction and otherwise even the
directory the command was run in prints in full. A location outside the
working directory still prints absolute, which is the only form that is any
use there. The install command reads from the same helper so the two lines
name the plugin the same way.

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

* docs(plugin): stop calling the SDKs published

`plugin generate`'s summary read "Generate a context plugin for your
published SDKs" — the one sentence this feature disproves, and the first
thing `--help` and the README show. The description was stale too: it named
`apimatic.json` as a requirement, when the command creates one.

Both now say what the command does: you choose the languages, one whose SDK
is not published yet is generated and bundled inside the plugin, and what is
actually required is a `src` directory with the API specification.

The README's two copies are patched by hand rather than regenerated, because
`oclif readme` still rewrites the whole CRLF file (#346, #348).

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

* refactor(plugin): split generate into the steps it runs

`execute` had grown to eighteen branches, past what Sonar allows and past
what reads in one pass. The steps it runs are unchanged; each is now its
own method, so the body is the order they happen in.

`openConfig` reports through a Result because opening the config can end
the command three ways — unreadable, cancelled at the metadata prompts,
or failed there — and the caller ends on whichever came back rather than
deciding again. `confirmBundledSdks` answers whether the plugin is a
preview, with `undefined` for a user who stopped, matching how
`selectLanguages` above it already signals that.

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

* style(plugin): unnest the success line and the path import

The muted location was built inside the sentence it sits in, two template
literals deep. It reads the same named once and used plainly.

`node:path` for the import, which is what the services in this repo
already write.

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

* refactor(plugin): read the directories and config in generate itself

The two steps were each called once and only from generate, and the
config one had to wrap its value in a Result to carry the result to end
on. Inlined, the early returns are the returns.

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

* style(plugin): unnest the install command line

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

* refactor(plugin): name the config after what it is, not the file it was

`PluginConfigPresent` was named in #318 after `plugin-config.json`, which it
read literally. It reads the `plugin` and `languages` blocks of
`src/apimatic.json` now, and nothing in the CLI has opened that file since
the migration — but `let config: PluginConfigPresent` still read as a
statement about a file being on disk.

`PluginConfig` is what it holds, and pairs with `PluginConfigData` the way
`PluginMetadata` pairs with `PluginIdentityData`. `present` stays as the
discriminant, where it belongs: it is the third state in `PluginConfigState`,
and a comment on the class now says what it does and does not promise.

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

* refactor(plugin): drop the language guards nothing can trip

`unsupportedLanguages` and `languageEntries` each opened by checking that
`languages` is an object. It always is: `configOf` writes `?? {}`,
`document.languages()` hands back an object or nothing at all, and a file
whose `languages` is not one is `unreadable` before a config is built.
`PluginConfig.create` has two callers and both go through `configOf`.

They would not have caught the one value that could arrive from a weaker
reader either — `typeof [] === 'object'`, so an array went straight through
into `Object.keys`. A note above the pair says where the guarantee is made.

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

* refactor(plugin): select languages the way the other commands do

`sdk quickstart` and `sdk publish` both hand a prompt the domain object,
let it build the options, say what it needs to say, and return the answer
or nothing; the action reads one value and branches. Plugin generation had
grown its own arrangement instead — a private action method computing the
option list, the pre-checked set and the put-back, with a prompt taking
three parallel arrays.

`selectLanguages(config)` now owns the choice, as `selectLanguage(profile)`
does next door, and the action is the four lines the others are.

Which languages come up checked is a question about the config, so it is
`PluginConfig.initialLanguages()` rather than a line in a prompt no test in
this repo can drive — `multiselect` is an ESM export sinon cannot replace,
which is why none of the prompt suites reach a clack call. The three tests
that asserted on the old arguments become three on that method, next to
`publishedLanguages` and `requestedLanguages` where they belong.

The action guards `!selection?.length`, so an empty answer ends the command
whether the prompt returned nothing or the user cleared every box.

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

* refactor(plugin): fold the single-caller helpers into execute

`confirmBundledSdks` and `buildPlugin` each had one caller, and the first
paid for it with a tri-state return: `false` for a plain build, `true` for
a preview, `undefined` for a cancel the caller then had to turn back into
`ActionResult.cancelled()`. Inlined, `preview` is a plain boolean and the
cancel returns where it happens.

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

---------

Co-authored-by: Muhammad Sohail <sohail.shahbaz@apimatic.io>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(plugin): split execute back into phases for the complexity gate

Inlining the helpers pushed execute past Sonar's cognitive complexity
limit (21 against 15 allowed), most of it from nested ifs, which count
double.

Extracted the three phases that cost the reader least: configWithMetadata,
which was the largest block and a genuine step of its own; the publishing
profile lookup, keeping the warn-and-confirm inline where it reads; and
buildPlugin, whose two ifs sit inside the withDirPath callback and so were
worth four points on their own.

The tri-state return does not come back: preview is still a plain boolean
and the cancel still returns where it happens.

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

* fix(plugin): make a cleared language mean something, and ask before writing

Review of #358 turned up three things the language selection got wrong.

A cleared checkbox did nothing. `requestLanguages` only ever added, so a
run that unchecked TypeScript left its entry in `apimatic.json`, and the
service reads the languages out of the zipped file rather than out of the
answers that produced it — the plugin still covered TypeScript. It now
records the selection as a set. Two kinds of entry are still never
dropped: a published one, because its entry records where the SDK
actually went, and a language a plugin cannot carry, which was never the
selection's to decide.

The config was written before the user was asked to confirm. Declining
"do you still want a local plugin?" printed "Exiting without generating a
plugin" and left the file claiming languages the user never got. The
write now happens after the confirm.

The two commands disagreed about what a publishing profile is. This one
counted rows; `sdk publish` counts profiles with an enabled language. A
user in between was told to publish first and then refused. It now asks
`PublishingProfiles.getActiveProfiles()`, the same question.

Also from the review: the profile lookup gets a spinner, since every
other caller has one and an advisory call that hangs silently reads as a
crash; the generation spinner no longer names the destination before the
save that creates it; and the "don't rewrite an unchanged file" rule
moves from `requestLanguages` into `ApimaticConfigContext.merge`, which
drops a redundant read and gives `upsertMetadata` and `upsertLanguage`
the same property.

Cleanups: `hasPublishedSdks` lost its last caller when this PR removed
the no-published-SDK gate; `recommendPublishingFirst` is the confirm's
own preamble rather than a call the action has to remember to make
first; and `languagesNotIncluded` labels its languages the way every
other line does.

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

* refactor(plugin): answer the review

**A cleared checkbox now means the language is left out**, published or
not, and clearing every box cancels. `selectLanguages` returns what was
checked and nothing else, which also takes the rule out of the prompts.

Excluding a published language without losing where its SDK went needed
the two files to stop being one. `recordLanguages` writes the user's file,
where a published entry survives a cleared checkbox because only
`sdk publish` can write that record again. `stageUpload` copies `src/` and
narrows `languages` in the copy to exactly the selection; the service reads
the copy. A consequence worth knowing: the byte-order mark is now stripped
from the copy, so the user's own file keeps whatever their editor put there.

**The three helpers are inlined by design, not by splitting** — a ternary
for the metadata step, one neverthrow chain for the profile lookup, and
`asyncMap` out of `withDirPath` so two nested branches become one outside.
`metadataCancelled` moved to the action that owns the prompt the user
walked away from. `preview` is now `couldPublishInstead`, which is what it
means.

`--auth-key` reaches the publishing profile lookup, which also closes the
same gap in `sdk publish`.

`PluginConfig.create` sorts `languages` once into what a plugin can carry
and what it cannot, so nothing re-filters or casts per call.
`relativeTo` moves onto `DirectoryPath`, where the path arithmetic belongs,
with a test. `requestedLanguages`, `recommendPublishingFirst` and `muted`
are gone; so is the guard in `merge` that never changed an outcome.

Every comment the review listed is one line of why or deleted, and the one
explaining why `hasMetadata` distrusts its own types is back.

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

* refactor(plugin)!: follow apimatic.json to its new shape

codegen-v2 `ee2672a` reworked the model this file is read through, and the
CLI was writing the old one. Three changes, each of which the service now
refuses or ignores.

**The package's name moved into `packageConfiguration`.** A release records
its version and nothing else; the service reads the name out of the
configuration — `packageId` for NuGet, `name` for npm and PyPI. So the
seven per-language package shapes collapse into one `{ version }`, and the
seven builders behind them into a single function: every language writes
the same thing now.

**`packageConfiguration` is what the CLI never wrote.** It is the profile's
own settings, written through as they stand, and the service requires it
beside any publishing block. A source-only publish gets one too: it says
how the package is configured, not that one was released. This closes the
question open since M2 about who supplies it — the service answered by
requiring it.

**`codegenVersion` left the schema.** It survives only on the legacy
`plugin-config.json` path, where the service still reads it; for
`apimatic.json` the service sets v4 itself. So it stops being written, and
`assertNoCodegenVersionMismatch` goes with the two prompts it drove — there
is no recorded version left to disagree with. An entry an older CLI wrote
keeps its `codegenVersion`, because the merge preserves what this version
does not model.

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

---------

Co-authored-by: Muhammad Sohail <sohail.shahbaz@apimatic.io>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
github-actions Bot pushed a commit that referenced this pull request Sep 28, 2026
# [2.0.0-beta.1](v1.5.0...v2.0.0-beta.1) (2026-09-28)

* build!: move to Node 24 and TypeScript 7 ([#347](#347)) ([2528d6e](2528d6e))
* feat(quickstart)!: one funnel, and the language question ([#363](#363)) ([c270135](c270135)), closes [#361](#361)
* feat(sdk)!: retire v3 generation and let a publish record itself ([#359](#359)) ([c48f91b](c48f91b)), closes [#358](#358) [#358](#358) [apimatic-io#2233](https://github.com/apimatic-io/issues/2233)

### Bug Fixes

* **config:** keep the package configuration a publish did not write ([#392](#392)) ([bc52735](bc52735))
* **config:** nest publishing under the language ([#350](#350)) ([fbacf74](fbacf74))
* **deps:** take fumadocs 16.15.15, which fixes bold text in a built portal ([#388](#388)) ([fa178ab](fa178ab))
* **plugin:** ask only for the plugin's identity when the languages are recorded ([#389](#389)) ([d721819](d721819)), closes [apimatic/apimatic-io#2258](https://github.com/apimatic/apimatic-io/issues/2258)
* **portal:** bundle the Geist fonts instead of loading them from Google Fonts ([#366](#366)) ([95f2f7e](95f2f7e))
* **portal:** drop the TypeScript definitions panel from operation pages ([#353](#353)) ([042eb89](042eb89))
* **portal:** give a run the temp directory the filesystem names, not an alias ([#377](#377)) ([d71d23f](d71d23f))
* **portal:** keep the encoding a page slug carries ([#396](#396)) ([0bec5f4](0bec5f4)), closes [apimatic/apimatic-io#2270](https://github.com/apimatic/apimatic-io/issues/2270)
* **portal:** keep the header's links on one line ([#398](#398)) ([40dc472](40dc472))
* **portal:** keep the portal name on one line in the header ([#395](#395)) ([f3d352c](f3d352c))
* **portal:** keep the preview starting until its first page answers ([#399](#399)) ([5359442](5359442))
* **portal:** name the operations a shared page comes from, in words a spec's author knows ([#387](#387)) ([01197a7](01197a7))
* **portal:** offer the page actions on API reference pages ([#382](#382)) ([22f4873](22f4873)), closes [apimatic/apimatic-io#2247](https://github.com/apimatic/apimatic-io/issues/2247) [#383](#383)
* **portal:** refuse tag and operationId names a URL or a Windows folder cannot hold ([#400](#400)) ([0ea01b2](0ea01b2)), closes [apimatic/apimatic-io#2262](https://github.com/apimatic/apimatic-io/issues/2262) [#396](#396)
* **portal:** render a request body in a media type Fumadocs has no adapter for ([#386](#386)) ([9f89ad3](9f89ad3))
* **portal:** render an operation whose response example has no value ([#373](#373)) ([1dc84ad](1dc84ad))
* **portal:** render split OpenAPI specifications correctly ([#354](#354)) ([d97cda6](d97cda6))
* **portal:** stop printing an operation's raw description under its title ([#383](#383)) ([25b9f6e](25b9f6e)), closes [apimatic/apimatic-io#2242](https://github.com/apimatic/apimatic-io/issues/2242)
* **quickstart:** download the sample spec from its new openapi.json path ([#401](#401)) ([ebc6743](ebc6743))
* the release test's high and medium findings ([#375](#375)) ([7fe41e1](7fe41e1))

### Features

* code samples support ([#352](#352)) ([7b82568](7b82568))
* **config:** replace portal.json and plugin-config.json with apimatic.json ([#348](#348)) ([506f67f](506f67f))
* local plugin generation ([#358](#358)) ([5ba70de](5ba70de)), closes [#351](#351) [#346](#346) [#348](#348) [#318](#318)
* **portal:** build the documentation portal locally with Fumadocs ([#343](#343)) ([073d09a](073d09a))
* **portal:** build the SDK and Context Plugin pages from real templates and the portal artifacts ([#369](#369)) ([0efd7b3](0efd7b3))
* **portal:** fetch the artifacts from /portal-artifacts ([#361](#361)) ([0e823f3](0e823f3))
* **portal:** generate the SDK and context plugin pages ([#360](#360)) ([338b14a](338b14a))
* **portal:** give the portal block its v2 shape, with branding and tabs ([#355](#355)) ([d0fb189](d0fb189))
* **portal:** lead each SDK card with its install command ([#376](#376)) ([119e9d6](119e9d6)), closes [#375](#375)
* **portal:** let the root nav.json decide the tabs, and fix the nav.json QA findings ([#371](#371)) ([ecf7666](ecf7666))
* **portal:** order the sidebar from nav.json ([#346](#346)) ([306e4f9](306e4f9))
* **portal:** point to api transform when src/spec has no OpenAPI 3.x document ([#364](#364)) ([eefc917](eefc917))
* **portal:** the SDK Overview design, on the SDKs, language and plugin pages ([#391](#391)) ([fd3fe34](fd3fe34))
* **quickstart:** suggest validating with an AI agent when a spec fails ([#385](#385)) ([36fd2bb](36fd2bb))

### Reverts

* **portal:** offer the page actions on API reference pages ([#382](#382)) ([#397](#397)) ([c087ef9](c087ef9)), closes [#388](#388) [apimatic/apimatic-io#2247](https://github.com/apimatic/apimatic-io/issues/2247)

### BREAKING CHANGES

* `apimatic sdk save-changes` is removed, along with
`--codegen-version`, `--stability`, `--track-changes` and `--skip-changes`
on `sdk generate` and `--codegen-version`, `--stability` and
`--update-plugin-config` on `sdk publish`. Java, Ruby, Go and PHP cannot be
generated until they reach v4.

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

* fix(sdk): keep --stability, which outlived the version it came with

Retiring v3 took `--stability` with it on the grounds that there was one
answer left. There is one answer *today*: v4 renders every language at beta
and they reach stable one at a time, so the level is a choice that survives
the version being fixed.

The flag is back on both commands, defaulting to stable as before, and the
level a language offers is a table rather than a constant — `stableLevels`
of one is answered without asking, and the interactive flow puts the
question back the moment a language has two. `sdk publish` names the level
in its summary only when the user chose it, read from oclif's parse
metadata, because `--stability stable` and the default are the same string
and the doc's block has no such row.

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

* fix(sdk): keep --codegen-version, with one version in it

Retiring v3 removed the flag along with the version, on the grounds that a
choice of one is not a choice. But the flag is how a caller says which
generator they want, and there will be a v5: removing it makes that move a
release that changes what people get, rather than a value they pass.

`CodeGenerationVersion` now holds `V4` alone, and stays an enum so the next
one is a line in it. The flag is back on both commands, accepting `v4`,
defaulting to `v4`, and refusing `v3` by name rather than generating
something else quietly. `sdk publish` names it in its telemetry again.

It is not written down. `codegenVersion` left `apimatic.json` when the
service took over deciding the generator, so the flag says what to generate
with and nothing records what was used.

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

* fix(config): drop codegenVersion from the published schema
* `apimatic quickstart` no longer offers a choice between a
portal and an SDK. It always builds a portal, and `apimatic sdk generate`
is the command for an SDK alone.

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

* feat(quickstart): ask which languages the portal covers

The one question quickstart asks besides the spec. Every language a plugin
can carry comes up checked, because a first portal covering everything it
can is what a single Enter should give, and the four it cannot are named in
the prompt rather than left out in silence.

The answer is written to `apimatic.json` through `recordLanguages` before
anything is built, because that file is what says which SDKs a portal
documents — every command after this one reads it rather than the answer
that produced it.

`languageLabel` moves to `types/sdk/generate.ts`, where both prompts that
name a language can reach it, and TypeScript is spelled the way its own
project spells it.

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

* feat(quickstart): name the plugin after the folder, without asking

The `plugin` block is what asks the service for a context plugin, so
quickstart has to write one — and the journey allows it no question about
plugins. The identity comes from the directory the user is standing in.

A folder name is not a plugin id. `Acme Payments` and `acme_payments` are
ordinary names and neither is the kebab-case `pluginId` has to be, so the
id is made from the name while the name itself is kept as typed — the id
goes to `gh repo create`, the name is what a reader sees. A directory whose
name survives none of that falls back, because writing an id the config
would then refuse is worse than not deriving one.

`0.1.0` for the version: every plugin starts somewhere.

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

* feat(quickstart): keep generated files out of the repository

Every command that builds a portal writes `src/static/sdk/`,
`src/static/plugin.zip` and an expanded `./plugin` into the project. None
of it belongs in a repository, and `portal serve` writes it too — so
without this a preview leaves a clean checkout dirty.

`/plugin/` is the one that matters. `plugin publish` runs `git init` inside
that directory and pushes it as its own repository; a parent tracking it
would nest one repository inside another.

Quickstart writes the entries because quickstart is what makes the project.
It appends what is missing and rewrites nothing, so a `.gitignore` the user
wrote survives and re-running in an adopted directory stacks no duplicates.

The paths are ignored here before anything on this branch produces them:
the placement lands with the `/portal-artifacts` call, and a user whose
first build dirties their tree has already been failed by then.

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

* fix(quickstart): end in the preview again, and say the coming languages under the answer

Two things, both about where a line belongs.

The rebase onto dev quietly took dev's ending for the wizard -- the one that
stops at the scaffold because "the wizard does not ask for the project's SDK
languages yet". It does, three lines above that comment. The handoff to
`portal serve`, and the `onAfterServe` the serve action took for it, had both
gone; nothing failed, because no test described the funnel's own ending. One
does now.

`Next Steps` loses its first two items with them. It told the user to name
their SDK languages and then start a preview; the wizard has just done both.
What is left is what to change now that the portal is on screen.

The coming-soon languages move out of the multiselect's message and under the
answer, in grey. Inside the message they rendered above the languages the user
had picked, in the green a submitted answer is drawn in, reading like a fourth
option rather than a footnote to the three.

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

* feat(portal): give quickstart the api transform hint, with the format it needs

Quickstart checks the spec itself before it writes the project, so the
transform hint portal generate and portal serve now give never reached it.
A Postman collection or a RAML file was told only that it was not an
OpenAPI document, with nothing to do about it.

Both now end on one sentence, which names --format=openapi3yaml: the
transform refuses to run without a format, so the command as printed
before would have failed.

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

* fix(quickstart): ignore only what is still generated into the project
* engines.node is now >=24.0.0. Node 22 is no longer supported.

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

* fix(sdk): build the validation service once configDir is assigned

`validationService` was initialised from `this.configDir` in a property
initialiser, but `configDir` is a constructor parameter property. The code only
worked because TypeScript's downlevel emit assigns parameter properties before
property initialisers; under the native class-field semantics that a modern
target selects, `this.configDir` is still undefined at that point and the
service would be constructed with no config directory.

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

* build: compile with TypeScript 7, keeping TypeScript 6 for linting

TypeScript 7 is the native compiler. On this repo it builds `src` in ~1.3s
against ~4.7s, and type-checks the suite in ~1.0s against ~4.8s. Emitted
output is unchanged apart from sourcemaps, temp-variable naming and
declaration ordering.

typescript-eslint cannot run on it: every published version, canary included,
declares `typescript >=4.8.4 <6.1.0` and throws at module init on TS 7, which
would take out `lint`, `posttest` and the pre-commit hook. So the two sit side
by side in Microsoft's documented arrangement — `tsc` is TypeScript 7 and
builds, bare `typescript` is the TypeScript 6 shim that only the linter loads.
Naming them this way rather than aliasing TypeScript 7 keeps the compiler each
script gets written down, and survives `pnpm update --latest`.

TypeScript 6 stopped sweeping node_modules/@types, so the test project names
the globals the suite needs; the source project resolves its types through
`node:` imports and needs no list.

`target` moves to es2024 now that the engine floor is Node 24, which also
selects native class-field semantics. `ts-node` goes: it drives the classic
compiler API, which TypeScript 7 no longer ships, and the suite runs on tsx.
`@types/mocha` was six years behind mocha and had to be named explicitly.

The `typescript.tsdk` pin is dropped. It is deprecated in favour of
`js/ts.tsdk.path`, inert unless a developer opts in by hand, and would now
point at a TypeScript 6 build within a patch of what VS Code already bundles.

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

* ci: test on Node 24 and 26, and release on 24

The engine floor is Node 24, so the 22 legs go. The second leg is 26 rather
than the floor alone: 26 becomes LTS on 2026-10-28, inside this major's life,
so it is what most developers will be running. The job count is unchanged.

Releasing on Node 24 drops the `npm install -g npm@latest` step, whose only
purpose was that Node 22 bundles npm 10.9.8 while OIDC trusted publishing
needs 11.5.1. Node 24 bundles 11.19.0. The constraint moves to a comment on
the matrix, since pinning that job back to 22 would silently break publishing.

`npm-tag-latest.yml` carried a `node: [ '14' ]` matrix that no step consumed;
the job never sets up Node at all.

The standalone pnpm in test.yml is kept rather than reverted to corepack. Its
original reason is gone, but it is a SHA-pinned executable that needs no Node
of its own, where corepack would resolve pnpm from the registry at run time and
need the three-step setup-node dance the other workflows use. The comment now
states the reason that actually holds.

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

* chore(vscode): repair the CLI debug configuration

`preLaunchTask` named a task the TypeScript extension provides, and that
provider is not registered when the extension stands down for TypeScript 7, so
the configuration would fail to start. The npm task provider supplies `build`
regardless, and still rebuilds before launching.

`program` pointed at `bin/run`, deleted in the ESM migration; it only launched
because Node appends `.js`. The forty commented-out argument lines carried
another machine's absolute paths.

`.gitignore` re-ignored `.vscode/launch.json` in its custom section, cancelling
the allowlist above it. Inert while the file is tracked, but it would have
refused a re-add.

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

* docs: record the two-compiler toolchain and how to remove it

The `typescript` dependency no longer being the compiler is surprising enough
to look like a mistake, so state why it is there, which command gets which
compiler, and the condition for deleting the arrangement.

Also corrects the build commands, which documented `tsc -b` although the
project has no references and the scripts call plain `tsc`.

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

* ci: remove unused action

* fix: broken test

* fix: mock fs broken in node 26

* ci: install pnpm standalone in the build and release workflows

Corepack is not distributed with Node 25 and newer, so the Node 26 leg of
check_build could not bootstrap pnpm. Also correct the release floor: npm
11.5.1, which trusted publishing needs, first ships in Node 24.5.0.

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

* fix(hooks): check the running Node against the declared engine range

Read engines.node from the manifest oclif has already parsed and compare
with semver, so package.json is the single source of the floor and a
minor-version floor is honoured. Test the hook's behaviour on both sides
of the range, and hold the CLI's range to the Node floors the portal
template's dependencies declare.

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

* docs: name the e2e test that depends on the two-compiler arrangement

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

* chore: untrack launch.json and drop leftover config

launch.json goes back to being a personal, ignored file. mocha.opts named
ts-node and has been unused since mocha 8. ESLint now skips Claude Code
worktrees under .claude.

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

* fix(portal): bind the prerender preview server to 127.0.0.1

The prerender pass fetched pages from a preview server on `localhost`.
On macOS it listened on `::1` only; a stalled connect under load fell
back to 127.0.0.1 and was refused, failing the portal build.

Co-Authored-By: Claude Opus 5.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.

1 participant