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
7 changes: 5 additions & 2 deletions .ai/skills/service.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ Services live at `src/infrastructure/services/` and are the only layer that make

- Instantiate controller per method call: `new {ControllerName}(client)` inside the method.
- `apiClientFactory.createApiClient(authHeader, shell)` provides the configured client.
- For async/polling SDK methods, don't hand-roll the poll loop — reuse `pollUntilCompleted()` in `portal-service.ts`, passing the poll interval as its first argument. Add a `static readonly` instance to `GenerationStatusEndpoint` for the new `{basePath}/{requestId}/status` endpoint and pass it to `ApiService.getGenerationStatus()`. Only supply a `ValidationErrorFormatter` when the endpoint needs custom validation wording (see `formatSdkValidationError`).
- For async/polling SDK methods, don't hand-roll the poll loop — reuse `pollUntilCompleted()` from `src/infrastructure/generation-status-poller.ts`, passing `{ pollIntervalMs, fetchStatus, timeout }`. Every pollable flow must pass a `timeout: { budgetMs, label }`; without one a stuck generation sits on the spinner forever. The budget alone is not a limit — it is only read between polls, so the `axiosInstance` behind `fetchStatus` also needs a `timeout`, or a poll that connects and never answers outlives the budget and hangs the CLI. The `label` opens the timeout message, so name the flow as the user knows it (`"Portal generation"`, `"SDK generation"`). Take the budget from a `generationTimeoutMs` constructor parameter so tests can shrink it. Only supply a `formatValidationError` when the endpoint needs custom validation wording (see `formatSdkValidationError`).
- Give each pollable endpoint its own private `get{Flow}GenerationStatus()` on the service that owns the flow, with the `{basePath}/{requestId}/status` path written out in it. Generation flows are independent and free to diverge, so the status fetch is deliberately not shared and takes no endpoint parameter, even where two flows currently read identically — resist factoring the bodies back together. A finished generation arrives as a `302` to the download location, not a `Completed` status body, so each method needs `maxRedirects: 0` and its own `302` mapping.
- An SDK-controller service that polls therefore also carries the axios-auth plumbing (`axiosInstance`, `apiBaseUrl`): the status endpoints are read over raw axios because a generated controller cannot surface the `302`. `portal-service.ts` is both variants at once for that reason.

### Axios-auth variant rules

Expand Down Expand Up @@ -72,7 +74,8 @@ Services live at `src/infrastructure/services/` and are the only layer that make
| Pattern | File |
|---|---|
| SDK controller + async polling | `src/infrastructure/services/portal-service.ts` |
| Pollable generation endpoints | `src/types/api/generation-status-endpoint.ts` |
| Per-endpoint generation status fetch | `src/infrastructure/services/portal-service.ts`, `src/infrastructure/services/plugin-service.ts` |
| Shared generation poll loop | `src/infrastructure/generation-status-poller.ts` |
| SDK controller + FormData | `src/infrastructure/services/validation-service.ts` |
| Raw axios with auth + axiosInstance | `src/infrastructure/services/api-service.ts` |
| Raw axios with different base URL | `src/infrastructure/services/auth-service.ts` |
Expand Down
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ USAGE
* [`apimatic auth status`](#apimatic-auth-status)
* [`apimatic autocomplete [SHELL]`](#apimatic-autocomplete-shell)
* [`apimatic help [COMMAND]`](#apimatic-help-command)
* [`apimatic plugin generate`](#apimatic-plugin-generate)
* [`apimatic portal copilot`](#apimatic-portal-copilot)
* [`apimatic portal generate`](#apimatic-portal-generate)
* [`apimatic portal recipe new`](#apimatic-portal-recipe-new)
Expand Down Expand Up @@ -222,6 +223,36 @@ DESCRIPTION

_See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/main/src/commands/help.ts)_

## `apimatic plugin generate`

Generate a Claude Code context plugin for your published SDKs.

```
USAGE
$ apimatic plugin generate [--zip] [-i <value>] [-d <value>] [-f] [-k <value>]

FLAGS
-d, --destination=<value> [default: <input>/plugin] path where the plugin will be generated.
-f, --force overwrite changes without asking for user consent.
-i, --input=<value> [default: ./] path to the parent directory containing the 'src' directory, which includes
API specifications and configuration files.
-k, --auth-key=<value> override current authentication state with an authentication key.
--zip download the generated plugin as a .zip archive

DESCRIPTION
Generate a Claude Code context plugin for your published 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 a `plugin-config.json`.

EXAMPLES
apimatic plugin generate

apimatic plugin generate --input="./" --destination="./plugin"
```

_See code: [src/commands/plugin/generate.ts](https://github.com/apimatic/apimatic-cli/blob/beta/src/commands/plugin/generate.ts)_

## `apimatic portal copilot`

Configure API Copilot for your API Documentation portal
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,9 @@
"auth": {
"description": "Login using your APIMatic credentials, or view your authentication status."
},
"plugin": {
"description": "Generate context plugins that teach AI coding assistants to use your SDKs."
},
"portal": {
"description": "Generate, download and serve an API Documentation portal for your APIs."
},
Expand Down
68 changes: 68 additions & 0 deletions src/actions/plugin/generate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { withDirPath } from '../../infrastructure/tmp-extensions.js';
import { PluginService } from '../../infrastructure/services/plugin-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 { PluginContext } from '../../types/plugin-context.js';
import { TempContext } from '../../types/temp-context.js';
import { ActionResult } from '../action-result.js';

export class PluginGenerateAction {
private readonly prompts: PluginGeneratePrompts = new PluginGeneratePrompts();
private readonly pluginService: PluginService = new PluginService();
private readonly configDir: DirectoryPath;
private readonly commandMetadata: CommandMetadata;
private readonly authKey: string | null;

constructor(configDir: DirectoryPath, commandMetadata: CommandMetadata, authKey: string | null = null) {
this.configDir = configDir;
this.commandMetadata = commandMetadata;
this.authKey = authKey;
}

public readonly execute = async (
buildDirectory: DirectoryPath,
pluginDirectory: DirectoryPath,
force: boolean,
zipPlugin: boolean
): Promise<ActionResult> => {
if (buildDirectory.isEqual(pluginDirectory)) {
this.prompts.directoryCannotBeSame(pluginDirectory);
return ActionResult.failed();
}

const buildContext = new BuildContext(buildDirectory);
if (!(await buildContext.validate())) {
this.prompts.srcDirectoryEmpty(buildDirectory);
return ActionResult.failed();
}

const pluginContext = new PluginContext(pluginDirectory);
if (!force && (await pluginContext.exists()) && !(await this.prompts.overwritePlugin(pluginDirectory))) {
this.prompts.pluginDirectoryNotEmpty();
return ActionResult.cancelled();
}

return await withDirPath(async (tempDirectory) => {
const tempContext = new TempContext(tempDirectory);
const buildZipPath = await tempContext.zip(buildDirectory);

const response = await this.prompts.generatePlugin(
this.pluginService.generatePlugin(buildZipPath, 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, zipPlugin);

this.prompts.pluginGenerated(pluginDirectory);

return ActionResult.success();
});
};
}
54 changes: 54 additions & 0 deletions src/commands/plugin/generate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Command, Flags } from '@oclif/core';
import { PluginGenerateAction } from '../../actions/plugin/generate.js';
import { CommandMetadata } from '../../types/common/command-metadata.js';
import { DirectoryPath } from '../../types/file/directoryPath.js';
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 Claude Code context plugin for your published 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 a `plugin-config.json`.';

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

static readonly examples = [
PluginGenerate.cmdTxt,
`${PluginGenerate.cmdTxt} ${format.flag('input', '"./"')} ${format.flag('destination', '"./plugin"')}`
];

static readonly flags = {
zip: Flags.boolean({
default: false,
description: 'download the generated plugin as a .zip archive'
}),
...FlagsProvider.input,
...FlagsProvider.destination('plugin', 'plugin'),
...FlagsProvider.force,
...FlagsProvider.authKey
};

async run(): Promise<void> {
const {
flags: { input, destination, force, zip: zipPlugin, 'auth-key': authKey }
} = await this.parse(PluginGenerate);

const workingDirectory = DirectoryPath.createInput(input);
const buildDirectory = workingDirectory.join('src');
const pluginDirectory = destination ? new DirectoryPath(destination) : workingDirectory.join('plugin');
const commandMetadata: CommandMetadata = {
commandName: PluginGenerate.id,
shell: this.config.shell
};

intro('Generate Context Plugin');
const action = new PluginGenerateAction(this.getConfigDir(), commandMetadata, authKey);
const result = await action.execute(buildDirectory, pluginDirectory, force, zipPlugin);
outro(result);
}

private readonly getConfigDir = () => {
return new DirectoryPath(this.config.configDir);
};
}
11 changes: 9 additions & 2 deletions src/config/axios-config.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import axios from "axios";

const fiftyMBsInBytes = 50 * 1024 * 1024;
const fiveMinutesInMilliseconds = 5 * 60 * 1000;
const fourMinutesInMilliseconds = 4 * 60 * 1000;

/**
* The single bound on any one API request, shared by the instance below and by services that
* build their own. A generation budget is only read between polls, so without this a request
* that connects and never answers would outlive it and hang the CLI.
*/
export const REQUEST_TIMEOUT_MS = fourMinutesInMilliseconds;

const axiosInstance = axios.create({
maxContentLength: fiftyMBsInBytes,
maxBodyLength: fiftyMBsInBytes,
timeout: fiveMinutesInMilliseconds
timeout: REQUEST_TIMEOUT_MS
});

export default axiosInstance;
92 changes: 92 additions & 0 deletions src/infrastructure/generation-status-poller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { Status } from '@apimatic/sdk';
import { err, ok, Result } from 'neverthrow';
import { ServiceError } from './service-error.js';

export const STATUS_POLL_INTERVAL_MS = 3000;

/**
* Set well clear of any plausible run: it exists to end a generation that is stuck, not to
* cap a slow one. No duration data exists for real runs, so this is the number to revisit
* if a legitimate generation ever reports a timeout.
*/
export const GENERATION_TIMEOUT_MS = 30 * 60 * 1000;

export interface GenerationStatus {
status: string;
errors?: Record<string, unknown>;
}

export type ValidationErrorFormatter = (errors: Record<string, string[]>) => string;

export interface GenerationPoll<T extends GenerationStatus> {
pollIntervalMs: number;
fetchStatus: () => Promise<Result<T, ServiceError>>;
/**
* Bounds a run whose status never reaches a terminal value. `label` opens the message.
* The budget is only read between polls, so `fetchStatus` must bound its own request —
* one that connects and never answers outlives any budget set here.
*/
timeout: { budgetMs: number; label: string };
/** Only when the endpoint needs custom validation wording. */
formatValidationError?: ValidationErrorFormatter;
}

export async function pollUntilCompleted<T extends GenerationStatus>({

Check failure on line 34 in src/infrastructure/generation-status-poller.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=apimatic_apimatic-cli&issues=AZ_6ql2YvMKEkdkiBlQZ&open=AZ_6ql2YvMKEkdkiBlQZ&pullRequest=314
pollIntervalMs,
fetchStatus,
timeout,
formatValidationError = formatValidationErrors
}: GenerationPoll<T>): Promise<Result<T, ServiceError>> {
const deadline = Date.now() + timeout.budgetMs;

for (;;) {
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));

const statusResult = await fetchStatus();
if (statusResult.isErr()) {
return err(statusResult.error);
}

const { status, errors } = statusResult.value;

if (status === Status.Completed) {
return ok(statusResult.value);
}
if (status === Status.Failed) {
return err(ServiceError.ServerError);
}
if (status === Status.ValidationError) {
const validationErrors = asMessages(errors);
return err(ServiceError.badRequest(formatValidationError(validationErrors), validationErrors));
}
if (status === Status.SubscriptionError) {
const message = Object.values(asMessages(errors)).flat()[0];
return err(ServiceError.forbidden('Access denied to resource.' + (message ? '\n- ' + message : '')));
}

// Every other status keeps the run alive rather than ending it: an endpoint reporting
// its own in-flight vocabulary is healthy, and the plugin orchestrator reports
// `Unknown` until it writes a custom status. The deadline is what stops a genuinely
// stuck run, and it is read after the status so a run that finished during the last
// wait still counts.
if (Date.now() >= deadline) {
return err(ServiceError.timeout(timedOutMessage(timeout.label, timeout.budgetMs)));
}
}
}

export const formatValidationErrors: ValidationErrorFormatter = (errors) => {
const messages = Object.values(errors).flat();
return 'One or more validation errors occurred.' + (messages.length ? '\n- ' + messages.join('\n- ') : '');
};

const timedOutMessage = (label: string, budgetMs: number): string => {
const minutes = Math.floor(budgetMs / 60_000);
if (minutes < 1) {
return `${label} timed out.`;
}
return `${label} timed out after ${minutes} ${minutes === 1 ? 'minute' : 'minutes'}.`;
};

const asMessages = (errors: Record<string, unknown> | undefined): Record<string, string[]> =>
(errors ?? {}) as Record<string, string[]>;
58 changes: 57 additions & 1 deletion src/infrastructure/service-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ export enum ServiceErrorCode {
InvalidResponse = "INVALID_RESPONSE",
UnAuthorized = "UNAUTHORIZED",
BadRequest = "BAD_REQUEST",
Forbidden = "FORBIDDEN"
Forbidden = "FORBIDDEN",
Timeout = "TIMEOUT"
}

export class ServiceError {
Expand All @@ -32,6 +33,9 @@ export class ServiceError {
static notFound(customMessage: string): ServiceError {
return new ServiceError(ServiceErrorCode.NotFound, customMessage, {});
}
static timeout(customMessage: string): ServiceError {
return new ServiceError(ServiceErrorCode.Timeout, customMessage, {});
}
static unauthorizedWithHint(apiMessage: string | null): ServiceError {
// Both remedies name the full `auth login` command: the key is supplied to
// that command, not to whichever one hit the 401 — most of them don't accept
Expand Down Expand Up @@ -97,6 +101,58 @@ function mapApiError(error: ApiError): ServiceError {
return ServiceError.ServerError;
}

interface ProblemDetailsBody {
title?: string;
detail?: string;
errors?: Record<string, string[]>;
}

/**
* `handleServiceError` reads a ProblemDetails body only off the SDK's typed errors, so on a raw
* axios call a 400 or 403 would collapse into a generic server error and lose its message.
* Unlike the SDK path this also falls back to `detail`, which io uses when it sends no `errors`
* map. Every other status is left to `mapTransportError`.
*/
function mapAxiosProblemDetails(error: unknown): ServiceError | undefined {
if (!axios.isAxiosError(error)) {
return undefined;
}

const body = error.response?.data as ProblemDetailsBody | undefined;
if (typeof body !== "object" || body === null) {
return undefined;
}

const errors = body.errors ?? {};
const firstMessage = Object.values(errors).flat()[0] ?? body.detail;
const title = body.title ?? "Request failed.";
const message = firstMessage ? `${title}\n- ${firstMessage}` : title;

if (error.response?.status === 400) {
return ServiceError.badRequest(message, errors);
}
if (error.response?.status === 403) {
return ServiceError.forbidden(message);
}
return undefined;
}

/**
* `handleServiceError` maps an axios 401 onto the bare `Unauthorized access.`, which leaves the
* user with nothing to act on — the SDK path names the remedy but the axios path does not.
*/
export function mapTransportError(error: unknown): ServiceError {
if (axios.isAxiosError(error) && error.response?.status === 401) {
return ServiceError.unauthorizedWithHint(null);
}
return handleServiceError(error);
}

/** For responses whose body is readable JSON; a streamed body can only be mapped by status. */
export function mapRequestError(error: unknown): ServiceError {
return mapAxiosProblemDetails(error) ?? mapTransportError(error);
}

export function handleServiceError(error: unknown): ServiceError {
if (error instanceof ApiError) {
// A `callAsStream` error body would otherwise hang the CLI. Order against
Expand Down
Loading
Loading