Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .ai/plans/apimatic-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ Rejected:
published yet" is expressed — the state local plugin generation turns into a
bundled SDK.
`codegenVersion` stays, inside `publishing`, because the mismatch check
in `PluginConfigPresent.assertNoCodegenVersionMismatch` depends on it.
in `PluginConfig.assertNoCodegenVersionMismatch` depends on it.
`packageConfiguration` and `source.repositoryType` from the design sketch are
not modelled in this release. A hand-written file carrying `packageConfiguration`
keeps it, because unknown fields in these blocks are preserved. A hand-written
Expand Down Expand Up @@ -233,7 +233,7 @@ Changed:
of the `plugin` and `languages` blocks over `ApimaticConfigContext` on the
same directory: `getPluginConfigState()`, `upsertMetadata()`,
`upsertLanguage()` keep their signatures and results, and the JSON-level
checks it duplicated are gone. `PluginConfigPresent` is unchanged; it is
checks it duplicated are gone. `PluginConfig` is unchanged; it is
constructed from `{ ...plugin, languages }`, which is the shape it already
reads. `present` keeps meaning "the document parsed with nothing wrong in the
plugin's blocks", never "the `plugin` block is there": a document without one
Expand All @@ -244,7 +244,7 @@ Changed:
project that publishes SDKs before it builds a plugin.
- `PluginConfigData` splits into `PluginIdentityData` (the `plugin` block, with
the index signature) and the existing `PluginLanguages`; `PluginConfigData`
is the two together, which `PluginConfigPresent` judges. `PLUGIN_ID_PATTERN`
is the two together, which `PluginConfig` judges. `PLUGIN_ID_PATTERN`
moves to `types/plugin/plugin-config.ts` so the document can use it without
reaching into the context.
- `PortalSourceContext` keeps its constructor and reads the document through
Expand Down
17 changes: 9 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,11 @@ Documentation portals are now built on your machine from a `src/` directory, and
(header links) and `ai` (the page actions). Running `apimatic quickstart` scaffolds the block
with every default spelled out, and the file's `$schema` lets your editor complete and check it.
- A portal also needs the project's SDK languages, at least one, in the same file's
`languages` block, for example `"languages": { "typescript": {} }`. `sdk publish` adds to it,
and the context plugin reads it too, so name only the languages you ship. The same file
carries the plugin's identity in `plugin`; `src/plugin-config.json` is no longer read, so run
`plugin generate` and `sdk publish` again after upgrading and delete the old file.
`languages` block, for example `"languages": { "typescript": {} }`. `plugin generate` and
`sdk publish` both write to it, and the context plugin reads it too, so name only the languages
you ship. The same file carries the plugin's identity in `plugin`; `src/plugin-config.json` is
no longer read, so run `plugin generate` and `sdk publish` again after upgrading and delete the
old file.
- Put OpenAPI documents in `src/spec/`, Markdown pages in `src/content/` and images and other
files in `src/static/`.
- Page order comes from a `nav.json` beside your pages, listing them by file name, and a
Expand All @@ -52,7 +53,7 @@ $ npm install -g @apimatic/cli
$ apimatic COMMAND
running command...
$ apimatic (--version)
@apimatic/cli/1.3.1 win32-x64 node-v23.4.0
@apimatic/cli/1.3.1 win32-x64 node-v24.19.0
$ apimatic --help [COMMAND]
USAGE
$ apimatic COMMAND
Expand Down Expand Up @@ -252,7 +253,7 @@ _See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/6.2.46

## `apimatic plugin generate`

Generate a context plugin for your published SDKs.
Generate a context plugin for your SDKs.

```
USAGE
Expand All @@ -266,10 +267,10 @@ FLAGS
-k, --auth-key=<value> override current authentication state with an authentication key.

DESCRIPTION
Generate a context plugin for your published SDKs.
Generate a context plugin for your SDKs.

Generate a context plugin that teaches an AI coding assistant how to use your SDKs. Requires an input directory
containing a `src` directory with an `apimatic.json`.
containing a `src` directory with your API specification — `apimatic.json` is created if it is not there.

EXAMPLES
apimatic plugin generate
Expand Down
118 changes: 74 additions & 44 deletions src/actions/plugin/generate.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import { err } from 'neverthrow';
import { ServiceError } from '../../infrastructure/service-error.js';
import { withDirPath } from '../../infrastructure/tmp-extensions.js';
import { PluginService } from '../../infrastructure/services/plugin-service.js';
import { PublishingApiService } from '../../infrastructure/services/publishing-api-service.js';
import { PluginGeneratePrompts } from '../../prompts/plugin/generate.js';
import { BuildContext } from '../../types/build-context.js';
import { CommandMetadata } from '../../types/common/command-metadata.js';
import { DirectoryPath } from '../../types/file/directoryPath.js';
import { PluginConfigContext } from '../../types/plugin-config-context.js';
import { PluginConfigContext, PluginConfigWriteFailure } from '../../types/plugin-config-context.js';
import { PluginContext } from '../../types/plugin-context.js';
import { PublishingProfiles } from '../../types/publish/publishing-profiles.js';
import { TempContext } from '../../types/temp-context.js';
import { ActionResult } from '../action-result.js';
import { PluginRecordMetadataAction } from './record-metadata.js';

export class PluginGenerateAction {
private readonly prompts: PluginGeneratePrompts = new PluginGeneratePrompts();
private readonly pluginService: PluginService = new PluginService();
private readonly publishingApiService: PublishingApiService = new PublishingApiService();
private readonly configDir: DirectoryPath;
private readonly commandMetadata: CommandMetadata;
private readonly authKey: string | null;
Expand All @@ -33,8 +38,7 @@ export class PluginGenerateAction {
return ActionResult.failed();
}

const buildContext = new BuildContext(buildDirectory);
if (!(await buildContext.exists())) {
if (!(await new BuildContext(buildDirectory).exists())) {
this.prompts.srcDirectoryDoesNotExist(buildDirectory);
return ActionResult.failed();
}
Expand All @@ -46,64 +50,90 @@ export class PluginGenerateAction {
}

const configContext = new PluginConfigContext(buildDirectory);
let configState = await configContext.getPluginConfigState();
const configState = await configContext.getPluginConfigState();
if (configState.state === 'unreadable') {
this.prompts.pluginConfigUnreadable(configState.reason, configState.path);
return ActionResult.failed();
}

if (configState.state === 'missing' || !configState.hasMetadata()) {
const metadataResult = await new PluginRecordMetadataAction(
this.configDir,
this.commandMetadata,
this.authKey
).execute(buildDirectory);
if (metadataResult.isCancelled()) {
this.prompts.metadataCancelled(metadataResult.getMessage());
return ActionResult.cancelled();
}
if (!metadataResult.isSuccess()) {
return metadataResult.discardValue();
}
configState = metadataResult.getValue();
const identified =
configState.state === 'present' && configState.hasMetadata()
? ActionResult.success(configState)
: await new PluginRecordMetadataAction(this.configDir, this.commandMetadata, this.authKey).execute(
buildDirectory
);
if (!identified.isSuccess()) {
return identified.discardValue();
}

if (!configState.hasPublishedSdks()) {
this.prompts.noPublishedSdks();
this.prompts.nextStepsPublishSdks();
return ActionResult.success();
const config = identified.getValue();
const selection = await this.prompts.selectLanguages(config);
if (!selection?.length) {
this.prompts.noLanguagesSelected();
return ActionResult.cancelled();
}

// `src/` is zipped as it sits on disk, so a byte-order mark the reader above looked past
// would travel to a service that reads the file with its own parser. Done here rather than
// on the read: a run that stops short of the zip has no reason to rewrite the file.
const prepared = await configContext.removeByteOrderMark();
if (prepared.isErr()) {
this.prompts.configNotPrepared(prepared.error, buildDirectory);
this.prompts.languagesNotIncluded(config.unsupportedLanguages());

// Asked before the config is written, so a declined run leaves no language claimed in it.
const couldPublishInstead =
selection.some((language) => !config.publishedLanguages().includes(language)) &&
(
await this.prompts.checkPublishingProfiles(
this.publishingApiService.getPublishingProfiles(this.configDir, this.commandMetadata.shell, this.authKey)
)
)
.andThen(PublishingProfiles.create)
.map((profiles) => profiles.getActiveProfiles().length > 0)
.unwrapOr(false);
if (couldPublishInstead && !(await this.prompts.confirmLocalPlugin())) {
this.prompts.localPluginCancelled();
return ActionResult.cancelled();
}

const recorded = await configContext.recordLanguages(selection);
if (recorded.isErr()) {
this.prompts.configNotPrepared(recorded.error, buildDirectory);
return ActionResult.failed();
}

return await withDirPath(async (tempDirectory) => {
const tempContext = new TempContext(tempDirectory);
const buildZipPath = await tempContext.zip(buildDirectory);
const generated = await withDirPath(async (tempDirectory) => {
const staged = await configContext.stageUpload(tempDirectory, selection);
if (staged.isErr()) {
return err(staged.error);
}

const tempContext = new TempContext(tempDirectory);
const response = await this.prompts.generatePlugin(
this.pluginService.generatePlugin(buildZipPath, this.configDir, this.commandMetadata, this.authKey)
this.pluginService.generatePlugin(
await tempContext.zip(staged.value),
this.configDir,
this.commandMetadata,
this.authKey
)
);

if (response.isErr()) {
this.prompts.pluginGenerationError(response.error.errorMessage);
return ActionResult.failed();
}

const tempPluginZipPath = await tempContext.save(response.value);
await pluginContext.save(tempPluginZipPath);
return await response.asyncMap(async (stream) => pluginContext.save(await tempContext.save(stream)));
});
if (generated.isErr()) {
this.reportGenerationProblem(generated.error, buildDirectory);
return ActionResult.failed();
}

this.prompts.pluginGenerated(pluginDirectory);
this.prompts.tryPluginLocally(pluginDirectory);
this.prompts.nextStepsPublishPlugin();
this.prompts.installPluginLocally(pluginDirectory);
if (couldPublishInstead) {
this.prompts.previewOnly();
}

return ActionResult.success();
});
return ActionResult.success();
};

/** A staging fault and a generation fault land here alike; only the wording differs. */
private readonly reportGenerationProblem = (
problem: ServiceError | PluginConfigWriteFailure,
buildDirectory: DirectoryPath
) =>
typeof problem === 'string'
? this.prompts.configNotPrepared(problem, buildDirectory)
: this.prompts.pluginGenerationError(problem.errorMessage);
}
7 changes: 3 additions & 4 deletions src/actions/plugin/record-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { SubscriptionInfo } from '../../types/api/account.js';
import { CommandMetadata } from '../../types/common/command-metadata.js';
import { DirectoryPath } from '../../types/file/directoryPath.js';
import { PluginAuthor, PluginMetadata } from '../../types/plugin/plugin-config.js';
import { PluginConfigContext, PluginConfigPresent } from '../../types/plugin-config-context.js';
import { PluginConfig, PluginConfigContext } from '../../types/plugin-config-context.js';
import { ActionResult } from '../action-result.js';

/**
Expand Down Expand Up @@ -34,11 +34,10 @@ export class PluginRecordMetadataAction {
this.authKey = authKey;
}

public readonly execute = async (
buildDirectory: DirectoryPath
): Promise<ActionResult<PluginConfigPresent>> => {
public readonly execute = async (buildDirectory: DirectoryPath): Promise<ActionResult<PluginConfig>> => {
const input = await this.prompts.inputPluginMetadata(DEFAULT_METADATA);
if ('cancelled' in input) {
this.prompts.metadataCancelled(input.cancelled);
return ActionResult.cancelled(input.cancelled);
}

Expand Down
30 changes: 8 additions & 22 deletions src/actions/plugin/record-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { PluginConfigContext } from '../../types/plugin-config-context.js';
import { PublishType } from '../../types/publish-api/publishing-profile-item.js';
import { PublishingProfile } from '../../types/publish/publishing-profile.js';
import { SemVersion } from '../../types/publish/version.js';
import { CodeGenerationVersion, Language } from '../../types/sdk/generate.js';
import { Language } from '../../types/sdk/generate.js';
import { ActionResult } from '../action-result.js';

/**
Expand All @@ -20,23 +20,19 @@ export class PluginRecordSdkAction {
language: Language,
publishingProfile: PublishingProfile,
publishTypes: PublishType[],
packageVersion: SemVersion,
codegenVersion: CodeGenerationVersion,
isInteractive: boolean
packageVersion: SemVersion
): Promise<ActionResult> => {
// The entry has to describe what this run published, not what the profile happens to enable.
// A package-only run must not claim a repository it never pushed to, nor a source-only run a
// package that was never released.
// The entry describes what this run published, not what the profile enables: a package-only
// run claims no repository, and a source-only run records no released version. The package
// configuration is written either way — it says how the package is set up, not that one
// exists, and the service reads the package's name out of it.
const entry = buildLanguageEntry(
language,
publishTypes.includes(PublishType.SourceCodePublishing)
? publishingProfile.getGitConfigurationForLanguage(language)
: undefined,
publishTypes.includes(PublishType.PackagePublishing)
? publishingProfile.getPackageConfigurationDataForLanguage(language)
: undefined,
packageVersion,
codegenVersion
publishingProfile.getPackageConfigurationDataForLanguage(language),
publishTypes.includes(PublishType.PackagePublishing) ? packageVersion : undefined
);

const pluginConfigContext = new PluginConfigContext(buildDirectory);
Expand All @@ -48,16 +44,6 @@ export class PluginRecordSdkAction {

const configExisted = configState.state === 'present';

if (configExisted) {
const result = configState.assertNoCodegenVersionMismatch(codegenVersion, language, entry);
if (result.isErr()) {
this.prompts.codegenVersionMismatch(language, result.error.actual, result.error.expected);
if (isInteractive && !(await this.prompts.confirmCodegenVersionOverwrite())) {
return ActionResult.cancelled();
}
}
}

if (!entry.publishing?.source && (!configExisted || configState.hasNoSourceRepository(language))) {
this.prompts.noSourceRepository(language);
}
Expand Down
4 changes: 1 addition & 3 deletions src/actions/sdk/publish/interactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,7 @@ export class SdkPublishInteractiveAction {
language,
publishingProfile,
publishTypes,
version,
codegenOption.codeGenerationVersion(),
true
version
);
}

Expand Down
4 changes: 1 addition & 3 deletions src/actions/sdk/publish/non-interactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,7 @@ export class SdkPublishNonInteractiveAction {
language,
publishingProfile,
publishTypes,
semVersion,
codegenOption.codeGenerationVersion(),
false
semVersion
);
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/commands/plugin/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ import { FlagsProvider } from '../../types/flags-provider.js';
import { format, intro, outro } from '../../prompts/format.js';

export default class PluginGenerate extends Command {
static readonly summary = 'Generate a context plugin for your published SDKs.';
static readonly summary = 'Generate a context plugin for your SDKs.';

static readonly description =
'Generate a context plugin that teaches an AI coding assistant how to use your SDKs. Requires an input directory containing a `src` directory with an `apimatic.json`.';
'Generate a context plugin that teaches an AI coding assistant how to use your SDKs. Requires an input directory containing a `src` directory with your API specification — `apimatic.json` is created if it is not there.';

static readonly cmdTxt = format.cmd('apimatic', 'plugin', 'generate');

Expand Down
7 changes: 4 additions & 3 deletions src/infrastructure/services/publishing-api-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,18 @@ export class PublishingApiService {

public async getPublishingProfiles(
configDir: DirectoryPath,
shell: string
shell: string,
authKey: string | null = null
): Promise<Result<PublishingProfileItem[], ServiceError>> {
const authInfo: AuthInfo | null = await getAuthInfo(configDir.toString());
// `auth logout` blanks config.json rather than deleting it, so a logged-out user
// still has a non-null AuthInfo with an empty key — check the key, not the object.
if (!authInfo?.authKey) {
const token = authKey || authInfo?.authKey;
if (!token) {
return err(ServiceError.unauthorizedWithHint(null));
}

try {
const token = authInfo.authKey;
const response = await this.axiosInstance(shell, token).get(`/publishing-profile/user`);

if (response.status === 200) {
Expand Down
Loading
Loading