Skip to content
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,26 @@
# [1.1.0-alpha.20](https://github.com/apimatic/apimatic-cli/compare/v1.1.0-alpha.19...v1.1.0-alpha.20) (2025-08-11)


### Bug Fixes

* remove simple-git dependency ([#151](https://github.com/apimatic/apimatic-cli/issues/151)) ([04d4669](https://github.com/apimatic/apimatic-cli/commit/04d466994722c116422d7e0d281a308c5c7dc355))

# [1.1.0-alpha.19](https://github.com/apimatic/apimatic-cli/compare/v1.1.0-alpha.18...v1.1.0-alpha.19) (2025-08-06)


### Bug Fixes

* add missing content header to telemetry api call ([#139](https://github.com/apimatic/apimatic-cli/issues/139)) ([f2b0e64](https://github.com/apimatic/apimatic-cli/commit/f2b0e64826d987ca844b285d8862ebf25e0fe286))
* remove ignore flag from portal serve ([#140](https://github.com/apimatic/apimatic-cli/issues/140)) ([336f5b7](https://github.com/apimatic/apimatic-cli/commit/336f5b750997dd6d1a8b5a1da4f85137283bb3ad))
* removed existing spec in quickstart when providing spec explicitly ([#147](https://github.com/apimatic/apimatic-cli/issues/147)) ([126318f](https://github.com/apimatic/apimatic-cli/commit/126318f0c497fc105a51ede3febe07731c09b41f))
* resolve bug in unarchive method ([#144](https://github.com/apimatic/apimatic-cli/issues/144)) ([e70b7d2](https://github.com/apimatic/apimatic-cli/commit/e70b7d2c03069297a1c0fba1cf7529a74eadd2bd))


### Features

* add better error messaging for sdk generation failures ([bac2b62](https://github.com/apimatic/apimatic-cli/commit/bac2b623a2a3efd2a78c711c731cb6c2764913cd))
* add responses for generate-via-file ([#143](https://github.com/apimatic/apimatic-cli/issues/143)) ([386be66](https://github.com/apimatic/apimatic-cli/commit/386be66c7f24df2b0fe9a9456bf23c998f9c00c3))

# [1.1.0-alpha.18](https://github.com/apimatic/apimatic-cli/compare/v1.1.0-alpha.17...v1.1.0-alpha.18) (2025-08-03)


Expand Down
64 changes: 51 additions & 13 deletions src/actions/portal/copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,17 @@ import { DirectoryPath } from "../../types/file/directoryPath.js";
import { ActionResult } from "../action-result.js";
import { SubscriptionInfo } from "../../types/api/account.js";
import { BuildContext } from "../../types/build-context.js";
import { withDirPath } from "../../infrastructure/tmp-extensions.js";
import { FilePath } from "../../types/file/filePath.js";
import { FileName } from "../../types/file/fileName.js";
import { FileService } from "../../infrastructure/file-service.js";
import { LauncherService } from "../../infrastructure/launcher-service.js";
import { taskLog } from "@clack/prompts";

export class CopilotAction {
private readonly apiService = new ApiService();
private readonly fileService = new FileService();
private readonly launcherService = new LauncherService();
private readonly prompts = new PortalCopilotPrompts();
private readonly configDir: DirectoryPath;
private readonly authKey: string | null;
Expand All @@ -16,7 +24,7 @@ export class CopilotAction {
this.authKey = authKey;
}

public async execute(buildDirectory: DirectoryPath, enable: boolean): Promise<ActionResult> {
public async execute(buildDirectory: DirectoryPath, force: boolean, enable: boolean): Promise<ActionResult> {
const buildContext = new BuildContext(buildDirectory);

if (!(await buildContext.validate())) {
Expand All @@ -25,21 +33,23 @@ export class CopilotAction {

const buildJson = await buildContext.getBuildFileContents();

if (buildJson.apiCopilotConfig != null && !(await this.prompts.confirmOverwrite()))
if (!force && buildJson.apiCopilotConfig != null && !(await this.prompts.confirmOverwrite()))
return ActionResult.error("Exiting without making any change.");

const response = await this.apiService.getAccountInfo(this.configDir, this.authKey);
const response = await this.prompts.spinnerAccountInfo(
() => this.apiService.getAccountInfo(this.configDir, this.authKey));

if (response.isErr()) {
return ActionResult.error(response._unsafeUnwrapErr());
}
const apiCopilotKey = await this.selectCopilotKey(response._unsafeUnwrap());
if (apiCopilotKey === null) {
return ActionResult.error("No copilot key found for the current subscription. Please contact support at support@apimatic.io.");

const apiCopilotKey = await this.selectCopilotKey(response._unsafeUnwrap(), force);
if (apiCopilotKey instanceof Error) {
return ActionResult.error(apiCopilotKey.message);
}

const welcomeMessage = await this.prompts.getWelcomeMessage();
if (welcomeMessage === undefined)
return ActionResult.error("Exiting without making any change.");
const welcomeMessage = await this.getWelcomeMessage();
if (welcomeMessage === undefined) return ActionResult.error("Exiting without making any change.");

buildJson.apiCopilotConfig = {
isEnabled: enable,
Expand All @@ -49,19 +59,47 @@ export class CopilotAction {

await buildContext.updateBuildFileContents(buildJson);

this.prompts.copilotConfigured(buildJson.apiCopilotConfig);
this.prompts.copilotConfigured(enable, apiCopilotKey);


return ActionResult.success();
}

private async selectCopilotKey(subscription: SubscriptionInfo | undefined): Promise<string | null> {
private async selectCopilotKey(subscription: SubscriptionInfo | undefined, force: boolean): Promise<string | Error> {
if (
subscription === undefined ||
subscription.ApiCopilotKeys === undefined ||
subscription.ApiCopilotKeys.length === 0
) {
return null;
return new Error("No copilot key found for the current subscription. Please contact support at support@apimatic.io.");
}

return await this.prompts.selectCopilotKey(subscription.ApiCopilotKeys);
if (subscription.ApiCopilotKeys.length === 1) {
if (force || (await this.prompts.confirmSingleKeyUsage(subscription.ApiCopilotKeys[0])))
return subscription.ApiCopilotKeys[0];
return new Error("Operation cancelled. No API Copilot key was selected.");
}

const key = await this.prompts.selectCopilotKey(subscription.ApiCopilotKeys);
if (key === null) return new Error("Operation cancelled. No API Copilot key was selected.");
await this.prompts.displayApiCopilotKeyUsageWarning();

return key;
}

private async getWelcomeMessage(): Promise<string> {
return await withDirPath(async (tempDir) => {
const tempFile = new FilePath(tempDir, new FileName("welcome-message.md"));
const defaultContent = "Hi there! I'm your API Integration Assistant, here to help you learn and integrate with this API.\n" +
"\n" +
"Ask me anything about this API or try one of these example prompts:\n" +
"\n" +
"`- What authentication methods does this API support?`\n" +
"`- [Enter another prompt here]`";
await this.fileService.writeContents(tempFile, defaultContent);
await this.launcherService.openInEditor(tempFile);
const welcomeMessage = await this.fileService.getContents(tempFile);
return welcomeMessage.replace(/\r\n|\r/g, "\n");
});
}
}
5 changes: 3 additions & 2 deletions src/commands/portal/copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export default class PortalCopilotEnable extends Command {
default: false,
description: "marks the API Copilot as disabled in the configuration"
}),
...FlagsProvider.force,
...FlagsProvider.authKey
};

Expand All @@ -30,13 +31,13 @@ export default class PortalCopilotEnable extends Command {

async run(): Promise<void> {
const {
flags: { input, "auth-key": authKey, disable}
flags: { input, "auth-key": authKey, disable, force}
} = await this.parse(PortalCopilotEnable);

const workingDirectory = new DirectoryPath(input ?? DEFAULT_WORKING_DIRECTORY);
const buildDirectory = input ? new DirectoryPath(input, "src") : workingDirectory.join("src");
const copilotConfigAction = new CopilotAction(new DirectoryPath(this.config.configDir), authKey);
const result = await copilotConfigAction.execute(buildDirectory, !disable);
const result = await copilotConfigAction.execute(buildDirectory, force, !disable);
result.map((message) => this.prompts.logError(message));
}
}
7 changes: 3 additions & 4 deletions src/infrastructure/file-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export class FileService {
const files = await fsExtra.readdir(dir.toString());
return files.length === 0;
} catch (error) {
return error instanceof Error && 'code' in error && error.code === "ENOENT";
return error instanceof Error && "code" in error && error.code === "ENOENT";
}
}

Expand Down Expand Up @@ -66,7 +66,7 @@ export class FileService {
}

public async getContents(filePath: FilePath): Promise<string> {
return await fsExtra.readFile(filePath.toString(), 'utf-8');
return await fsExtra.readFile(filePath.toString(), "utf-8");
}

public async writeFile(filePath: FilePath, stream: NodeJS.ReadableStream) {
Expand All @@ -75,7 +75,7 @@ export class FileService {
}

public async writeContents(filePath: FilePath, contents: string) {
await fsExtra.writeFile(filePath.toString(), contents, 'utf-8');
await fsExtra.writeFile(filePath.toString(), contents, "utf-8");
}

public async writeBuffer(filePath: FilePath, buffer: Buffer) {
Expand All @@ -85,7 +85,6 @@ export class FileService {
public async copy(source: FilePath, destination: FilePath) {
await fsExtra.copyFile(source.toString(), destination.toString());
}

}

const streamPipeline = promisify(pipeline);
18 changes: 18 additions & 0 deletions src/infrastructure/launcher-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { FilePath } from "../types/file/filePath.js";
import { execa } from "execa";

export class LauncherService {

public async openInEditor(filePath: FilePath): Promise<void> {
try {
await execa("code", ["--wait", filePath.toString()]);
} catch {
// TODO: check for fallback (start)
if (process.platform === "win32") {
await execa("cmd", ["/c", "start", "/wait", "notepad", filePath.toString()], { stdio: "ignore" });
} else if (process.platform === "darwin") {
await execa("vim", [filePath.toString()], { stdio: "inherit"});
}
}
}
}
65 changes: 46 additions & 19 deletions src/prompts/portal/copilot.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { select, cancel, isCancel, outro, confirm, log, text, } from "@clack/prompts";
import { CopilotConfig } from "../../types/build/build.js";
import { confirm, isCancel, log, outro, select, spinner } from "@clack/prompts";
import { getMessageInCyanColor } from "../../utils/utils.js";
import { Result } from "neverthrow";
import { SubscriptionInfo } from "../../types/api/account.js";
import { ServiceError } from "../../infrastructure/api-utils.js";

export class PortalCopilotPrompts {
public async selectCopilotKey(keys: string[]): Promise<string> {
public async displayApiCopilotKeyUsageWarning() {
log.warn('API Copilot can only be active on one Portal at a time. Configuring it on this Portal will disable it on any previously configured Portal.')
}
public async selectCopilotKey(keys: string[]): Promise<string | null> {
const selectedKey = await select({
message: "Select the ID for the API Copilot you would like to add to this API Portal:",
maxItems: 10,
Expand All @@ -14,30 +19,30 @@ export class PortalCopilotPrompts {
});

if (isCancel(selectedKey)) {
cancel("Operation cancelled.");
return process.exit(0);
return null;
}

return selectedKey;
}

public copilotConfigured(apiCopilotConfig: CopilotConfig) {
public copilotConfigured(status: boolean, copilotId: string): void {
outro(
`API Copilot configured successfully!

Copilot ID: ${getMessageInCyanColor(apiCopilotConfig.key)}
Welcome Message: ${getMessageInCyanColor(apiCopilotConfig.welcomeMessage)}
Status: ${getMessageInCyanColor(apiCopilotConfig.isEnabled ? "Enabled" : "Disabled")}
Copilot ID: ${getMessageInCyanColor(copilotId)}
Status: ${getMessageInCyanColor(status ? "Enabled" : "Disabled")}

Configuration saved to: APIMATIC-BUILD.json

Run 'apimatic portal:serve' to preview your API Portal and try out the API Copilot.`
The API Copilot will index your content when you next run \`apimatic portal:generate\` or \`apimatic portal:serve\`. Indexing takes up to 10 minutes depending on your API size.
Copy link
Copy Markdown
Contributor

@saeedjamshaid saeedjamshaid Aug 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Change this text to : "API Copilot will index your content the next time you run apimatic portal:generate or apimatic portal:serve. This process can take up to 10 minutes, depending on your API’s size"


To see your copilot: If your portal is already running, refresh the page. Otherwise, run \`apimatic portal:serve\`, select any programming language in the Portal and look for the chat icon in the bottom-right corner.`
);
}

public async confirmOverwrite(): Promise<boolean> {
const shouldOverwrite = await confirm({
message: "API Copilot configuration already exists. Do you want to overwrite?",
message: "API Copilot is already configured for this Portal, do you want to overwrite it?",
initialValue: false
});

Expand All @@ -48,20 +53,42 @@ Run 'apimatic portal:serve' to preview your API Portal and try out the API Copil
return shouldOverwrite;
}

logError(error: string): void {
public logError(error: string): void {
log.error(error);
}

async getWelcomeMessage(): Promise<string | undefined> {
const welcomeMessage = await text({
message: "Enter a welcome message for your API Copilot:",
placeholder: "Enter your welcome message here..."
public async spinnerAccountInfo(fn: () => Promise<Result<SubscriptionInfo, ServiceError>>) {
return this.withSpinner(
"Retrieving your subscription info",
"Retrieved subscription info",
"Error retrieving subscription info",
fn
);
}

private async withSpinner<T, E>(intro: string, success: string, failure: string, fn: () => Promise<Result<T, E>>) {
const s = spinner();
s.start(intro);
const result = await fn();
result.match(
() => s.stop(success, 0),
() => s.stop(failure, 1)
);
return result;
}

public async confirmSingleKeyUsage(apiCopilotKey: string) {
const confirmKeyUsage = await confirm({
message:
"API Copilot can only be active on one Portal at a time. Configuring it on this Portal will disable it on any previously configured Portal.\n" +
`Do you want to use this key: '${apiCopilotKey}'?`,
initialValue: true
});

if (isCancel(welcomeMessage)) {
return undefined;
if (isCancel(confirmKeyUsage)) {
return false;
}

return welcomeMessage;
return confirmKeyUsage;
}
}
4 changes: 2 additions & 2 deletions src/types/file/fileName.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
export class FileName {
private readonly name: string;

constructor(path: string) {
this.name = path;
constructor(name: string) {
this.name = name;
}

public toString(): string {
Expand Down