diff --git a/src/components/RouterScreen.test.tsx b/src/components/RouterScreen.test.tsx index 44e1705bd..2de9f30f0 100644 --- a/src/components/RouterScreen.test.tsx +++ b/src/components/RouterScreen.test.tsx @@ -87,6 +87,9 @@ describe("navigation", () => { const r = renderScreen("/agentcore"); await waitForText(r.lastFrame, "❯ harness"); + await r.press("down"); + await waitForText(r.lastFrame, "❯ identity"); + await r.press("down"); await waitForText(r.lastFrame, "❯ runtime"); r.unmount(); diff --git a/src/core/identity.tsx b/src/core/identity.tsx new file mode 100644 index 000000000..6ef9e3b1e --- /dev/null +++ b/src/core/identity.tsx @@ -0,0 +1,67 @@ +import { + CreateApiKeyCredentialProviderCommand, + DeleteApiKeyCredentialProviderCommand, + GetApiKeyCredentialProviderCommand, + ListApiKeyCredentialProvidersCommand, + UpdateApiKeyCredentialProviderCommand, + type CreateApiKeyCredentialProviderResponse, + type DeleteApiKeyCredentialProviderResponse, + type GetApiKeyCredentialProviderResponse, + type ListApiKeyCredentialProvidersResponse, + type UpdateApiKeyCredentialProviderResponse, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import type { CoreIdentityClient } from "../handlers/identity/types"; +import type { AwsClients, CoreOptions } from "./types"; +import { toClientConfig } from "./utils"; + +export class IdentityClient implements CoreIdentityClient { + constructor(private readonly clients: AwsClients) {} + + async createApiKeyCredentialProvider( + name: string, + apiKey: string, + options: CoreOptions, + ): Promise { + return this.clients + .control(toClientConfig(options)) + .send(new CreateApiKeyCredentialProviderCommand({ name, apiKey })); + } + + async getApiKeyCredentialProvider( + name: string, + options: CoreOptions, + ): Promise { + return this.clients + .control(toClientConfig(options)) + .send(new GetApiKeyCredentialProviderCommand({ name })); + } + + async listApiKeyCredentialProviders( + nextToken: string | undefined, + maxResults: number | undefined, + options: CoreOptions, + ): Promise { + return this.clients + .control(toClientConfig(options)) + .send(new ListApiKeyCredentialProvidersCommand({ nextToken, maxResults })); + } + + async updateApiKeyCredentialProvider( + name: string, + apiKey: string, + options: CoreOptions, + ): Promise { + return this.clients + .control(toClientConfig(options)) + .send(new UpdateApiKeyCredentialProviderCommand({ name, apiKey })); + } + + async deleteApiKeyCredentialProvider( + name: string, + options: CoreOptions, + ): Promise { + return this.clients + .control(toClientConfig(options)) + .send(new DeleteApiKeyCredentialProviderCommand({ name })); + } +} diff --git a/src/core/index.tsx b/src/core/index.tsx index afddd3e3b..b08d01a49 100644 --- a/src/core/index.tsx +++ b/src/core/index.tsx @@ -2,6 +2,7 @@ import { BedrockAgentCoreControlClient } from "@aws-sdk/client-bedrock-agentcore import { BedrockAgentCoreClient } from "@aws-sdk/client-bedrock-agentcore"; import { IAMClient } from "@aws-sdk/client-iam"; import { HarnessClient } from "./harness"; +import { IdentityClient } from "./identity"; import { RuntimeClient } from "./runtime"; import type { AwsClients, @@ -45,6 +46,7 @@ export class CoreClient implements AwsClients { // Feature-scoped sub-clients. Access as e.g. `coreClient.harness.getHarness(...)`. readonly harness: HarnessClient = new HarnessClient(this); + readonly identity: IdentityClient = new IdentityClient(this); readonly runtime: RuntimeClient = new RuntimeClient(this); readonly projectManager: ProjectManager; diff --git a/src/handlers/runtime/help.tsx b/src/handlers/help.tsx similarity index 68% rename from src/handlers/runtime/help.tsx rename to src/handlers/help.tsx index 6a9755abc..4b12af384 100644 --- a/src/handlers/runtime/help.tsx +++ b/src/handlers/help.tsx @@ -1,5 +1,5 @@ -import { CommandKey, type DefaultHandle } from "../../router"; -import type { AppIO } from "../types"; +import { CommandKey, type DefaultHandle } from "../router"; +import type { AppIO } from "./types"; export function createHelpDefault(io: AppIO): DefaultHandle { return async (ctx) => { diff --git a/src/handlers/identity/__fixtures__/CreateApiKeyCredentialProviderCommand.14ae02cc9ee2a0c.json b/src/handlers/identity/__fixtures__/CreateApiKeyCredentialProviderCommand.14ae02cc9ee2a0c.json new file mode 100644 index 000000000..44eb5c572 --- /dev/null +++ b/src/handlers/identity/__fixtures__/CreateApiKeyCredentialProviderCommand.14ae02cc9ee2a0c.json @@ -0,0 +1,8 @@ +{ + "apiKeySecretArn": { + "secretArn": "arn:aws:secretsmanager:us-west-2:314146320088:secret:bedrock-agentcore-identity!default/apikey/agentcore-cli-identity-fixture-2-7fd24230-KEL3mK" + }, + "name": "agentcore-cli-identity-fixture-2", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:314146320088:token-vault/default/apikeycredentialprovider/agentcore-cli-identity-fixture-2", + "apiKeySecretSource": "MANAGED" +} \ No newline at end of file diff --git a/src/handlers/identity/__fixtures__/CreateApiKeyCredentialProviderCommand.c9fb3278285e2c1a.json b/src/handlers/identity/__fixtures__/CreateApiKeyCredentialProviderCommand.c9fb3278285e2c1a.json new file mode 100644 index 000000000..3e5d4439a --- /dev/null +++ b/src/handlers/identity/__fixtures__/CreateApiKeyCredentialProviderCommand.c9fb3278285e2c1a.json @@ -0,0 +1,8 @@ +{ + "apiKeySecretArn": { + "secretArn": "arn:aws:secretsmanager:us-west-2:314146320088:secret:bedrock-agentcore-identity!default/apikey/agentcore-cli-identity-fixture-d831a6a9-5GBOcy" + }, + "name": "agentcore-cli-identity-fixture", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:314146320088:token-vault/default/apikeycredentialprovider/agentcore-cli-identity-fixture", + "apiKeySecretSource": "MANAGED" +} \ No newline at end of file diff --git a/src/handlers/identity/__fixtures__/DeleteApiKeyCredentialProviderCommand.18249011246bc648.json b/src/handlers/identity/__fixtures__/DeleteApiKeyCredentialProviderCommand.18249011246bc648.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/src/handlers/identity/__fixtures__/DeleteApiKeyCredentialProviderCommand.18249011246bc648.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/handlers/identity/__fixtures__/DeleteApiKeyCredentialProviderCommand.b0c07380332f4673.json b/src/handlers/identity/__fixtures__/DeleteApiKeyCredentialProviderCommand.b0c07380332f4673.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/src/handlers/identity/__fixtures__/DeleteApiKeyCredentialProviderCommand.b0c07380332f4673.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/handlers/identity/__fixtures__/GetApiKeyCredentialProviderCommand.b0c07380332f4673.json b/src/handlers/identity/__fixtures__/GetApiKeyCredentialProviderCommand.b0c07380332f4673.json new file mode 100644 index 000000000..8be6b1f74 --- /dev/null +++ b/src/handlers/identity/__fixtures__/GetApiKeyCredentialProviderCommand.b0c07380332f4673.json @@ -0,0 +1,14 @@ +{ + "apiKeySecretArn": { + "secretArn": "arn:aws:secretsmanager:us-west-2:314146320088:secret:bedrock-agentcore-identity!default/apikey/agentcore-cli-identity-fixture-d831a6a9-5GBOcy" + }, + "name": "agentcore-cli-identity-fixture", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:314146320088:token-vault/default/apikeycredentialprovider/agentcore-cli-identity-fixture", + "createdTime": { + "$date": "2026-07-22T18:56:27.969Z" + }, + "lastUpdatedTime": { + "$date": "2026-07-22T18:56:27.969Z" + }, + "apiKeySecretSource": "MANAGED" +} \ No newline at end of file diff --git a/src/handlers/identity/__fixtures__/GetApiKeyCredentialProviderCommand.e134007136f7ab2.json b/src/handlers/identity/__fixtures__/GetApiKeyCredentialProviderCommand.e134007136f7ab2.json new file mode 100644 index 000000000..f596b4785 --- /dev/null +++ b/src/handlers/identity/__fixtures__/GetApiKeyCredentialProviderCommand.e134007136f7ab2.json @@ -0,0 +1,6 @@ +{ + "$error": { + "name": "ResourceNotFoundException", + "message": "ApiKeyCredentialProvider not found for missing-provider-000" + } +} \ No newline at end of file diff --git a/src/handlers/identity/__fixtures__/ListApiKeyCredentialProvidersCommand.23f97c9dcdd6350b.json b/src/handlers/identity/__fixtures__/ListApiKeyCredentialProvidersCommand.23f97c9dcdd6350b.json new file mode 100644 index 000000000..2a2e0bfe6 --- /dev/null +++ b/src/handlers/identity/__fixtures__/ListApiKeyCredentialProvidersCommand.23f97c9dcdd6350b.json @@ -0,0 +1,24 @@ +{ + "credentialProviders": [ + { + "name": "agentcore-cli-identity-fixture", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:314146320088:token-vault/default/apikeycredentialprovider/agentcore-cli-identity-fixture", + "createdTime": { + "$date": "2026-07-22T18:56:27.969Z" + }, + "lastUpdatedTime": { + "$date": "2026-07-22T18:56:27.969Z" + } + }, + { + "name": "agentcore-cli-identity-fixture-2", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:314146320088:token-vault/default/apikeycredentialprovider/agentcore-cli-identity-fixture-2", + "createdTime": { + "$date": "2026-07-22T18:56:28.265Z" + }, + "lastUpdatedTime": { + "$date": "2026-07-22T18:56:28.265Z" + } + } + ] +} \ No newline at end of file diff --git a/src/handlers/identity/__fixtures__/ListApiKeyCredentialProvidersCommand.7386ccbbc3fc09a4.json b/src/handlers/identity/__fixtures__/ListApiKeyCredentialProvidersCommand.7386ccbbc3fc09a4.json new file mode 100644 index 000000000..16ee9ca38 --- /dev/null +++ b/src/handlers/identity/__fixtures__/ListApiKeyCredentialProvidersCommand.7386ccbbc3fc09a4.json @@ -0,0 +1,14 @@ +{ + "credentialProviders": [ + { + "name": "agentcore-cli-identity-fixture-2", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:314146320088:token-vault/default/apikeycredentialprovider/agentcore-cli-identity-fixture-2", + "createdTime": { + "$date": "2026-07-22T18:56:28.265Z" + }, + "lastUpdatedTime": { + "$date": "2026-07-22T18:56:28.265Z" + } + } + ] +} \ No newline at end of file diff --git a/src/handlers/identity/__fixtures__/ListApiKeyCredentialProvidersCommand.7d2e22c637f6b633.json b/src/handlers/identity/__fixtures__/ListApiKeyCredentialProvidersCommand.7d2e22c637f6b633.json new file mode 100644 index 000000000..10fe7794f --- /dev/null +++ b/src/handlers/identity/__fixtures__/ListApiKeyCredentialProvidersCommand.7d2e22c637f6b633.json @@ -0,0 +1,15 @@ +{ + "credentialProviders": [ + { + "name": "agentcore-cli-identity-fixture", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:314146320088:token-vault/default/apikeycredentialprovider/agentcore-cli-identity-fixture", + "createdTime": { + "$date": "2026-07-22T18:56:27.969Z" + }, + "lastUpdatedTime": { + "$date": "2026-07-22T18:56:27.969Z" + } + } + ], + "nextToken": "eyJBY2NvdW50QW5kVHlwZUdzaVBrIjp7InZhbHVlU3RyIjoiMzE0MTQ2MzIwMDg4I0FQSV9LRVkiLCJhdHRyaWJ1dGVUeXBlIjoiUyJ9LCJTSyI6eyJ2YWx1ZVN0ciI6ImFnZW50Y29yZS1jbGktaWRlbnRpdHktZml4dHVyZSIsImF0dHJpYnV0ZVR5cGUiOiJTIn0sIlBLIjp7InZhbHVlU3RyIjoiQVBJX0tFWV9DUkVEX1BST1ZJREVSIzMxNDE0NjMyMDA4OCNkZWZhdWx0I2FnZW50Y29yZS1jbGktaWRlbnRpdHktZml4dHVyZSIsImF0dHJpYnV0ZVR5cGUiOiJTIn19" +} \ No newline at end of file diff --git a/src/handlers/identity/__fixtures__/UpdateApiKeyCredentialProviderCommand.68e4cd8243ce33f4.json b/src/handlers/identity/__fixtures__/UpdateApiKeyCredentialProviderCommand.68e4cd8243ce33f4.json new file mode 100644 index 000000000..8be6b1f74 --- /dev/null +++ b/src/handlers/identity/__fixtures__/UpdateApiKeyCredentialProviderCommand.68e4cd8243ce33f4.json @@ -0,0 +1,14 @@ +{ + "apiKeySecretArn": { + "secretArn": "arn:aws:secretsmanager:us-west-2:314146320088:secret:bedrock-agentcore-identity!default/apikey/agentcore-cli-identity-fixture-d831a6a9-5GBOcy" + }, + "name": "agentcore-cli-identity-fixture", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:314146320088:token-vault/default/apikeycredentialprovider/agentcore-cli-identity-fixture", + "createdTime": { + "$date": "2026-07-22T18:56:27.969Z" + }, + "lastUpdatedTime": { + "$date": "2026-07-22T18:56:27.969Z" + }, + "apiKeySecretSource": "MANAGED" +} \ No newline at end of file diff --git a/src/handlers/identity/__fixtures__/create-2.golden.json b/src/handlers/identity/__fixtures__/create-2.golden.json new file mode 100644 index 000000000..44eb5c572 --- /dev/null +++ b/src/handlers/identity/__fixtures__/create-2.golden.json @@ -0,0 +1,8 @@ +{ + "apiKeySecretArn": { + "secretArn": "arn:aws:secretsmanager:us-west-2:314146320088:secret:bedrock-agentcore-identity!default/apikey/agentcore-cli-identity-fixture-2-7fd24230-KEL3mK" + }, + "name": "agentcore-cli-identity-fixture-2", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:314146320088:token-vault/default/apikeycredentialprovider/agentcore-cli-identity-fixture-2", + "apiKeySecretSource": "MANAGED" +} \ No newline at end of file diff --git a/src/handlers/identity/__fixtures__/create.golden.json b/src/handlers/identity/__fixtures__/create.golden.json new file mode 100644 index 000000000..3e5d4439a --- /dev/null +++ b/src/handlers/identity/__fixtures__/create.golden.json @@ -0,0 +1,8 @@ +{ + "apiKeySecretArn": { + "secretArn": "arn:aws:secretsmanager:us-west-2:314146320088:secret:bedrock-agentcore-identity!default/apikey/agentcore-cli-identity-fixture-d831a6a9-5GBOcy" + }, + "name": "agentcore-cli-identity-fixture", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:314146320088:token-vault/default/apikeycredentialprovider/agentcore-cli-identity-fixture", + "apiKeySecretSource": "MANAGED" +} \ No newline at end of file diff --git a/src/handlers/identity/__fixtures__/delete-2.golden.json b/src/handlers/identity/__fixtures__/delete-2.golden.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/src/handlers/identity/__fixtures__/delete-2.golden.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/handlers/identity/__fixtures__/delete.golden.json b/src/handlers/identity/__fixtures__/delete.golden.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/src/handlers/identity/__fixtures__/delete.golden.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/handlers/identity/__fixtures__/get.golden.json b/src/handlers/identity/__fixtures__/get.golden.json new file mode 100644 index 000000000..73e014e53 --- /dev/null +++ b/src/handlers/identity/__fixtures__/get.golden.json @@ -0,0 +1,10 @@ +{ + "apiKeySecretArn": { + "secretArn": "arn:aws:secretsmanager:us-west-2:314146320088:secret:bedrock-agentcore-identity!default/apikey/agentcore-cli-identity-fixture-d831a6a9-5GBOcy" + }, + "name": "agentcore-cli-identity-fixture", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:314146320088:token-vault/default/apikeycredentialprovider/agentcore-cli-identity-fixture", + "createdTime": "2026-07-22T18:56:27.969Z", + "lastUpdatedTime": "2026-07-22T18:56:27.969Z", + "apiKeySecretSource": "MANAGED" +} \ No newline at end of file diff --git a/src/handlers/identity/__fixtures__/list-page-1.golden.json b/src/handlers/identity/__fixtures__/list-page-1.golden.json new file mode 100644 index 000000000..2cd91c658 --- /dev/null +++ b/src/handlers/identity/__fixtures__/list-page-1.golden.json @@ -0,0 +1,11 @@ +{ + "credentialProviders": [ + { + "name": "agentcore-cli-identity-fixture", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:314146320088:token-vault/default/apikeycredentialprovider/agentcore-cli-identity-fixture", + "createdTime": "2026-07-22T18:56:27.969Z", + "lastUpdatedTime": "2026-07-22T18:56:27.969Z" + } + ], + "nextToken": "eyJBY2NvdW50QW5kVHlwZUdzaVBrIjp7InZhbHVlU3RyIjoiMzE0MTQ2MzIwMDg4I0FQSV9LRVkiLCJhdHRyaWJ1dGVUeXBlIjoiUyJ9LCJTSyI6eyJ2YWx1ZVN0ciI6ImFnZW50Y29yZS1jbGktaWRlbnRpdHktZml4dHVyZSIsImF0dHJpYnV0ZVR5cGUiOiJTIn0sIlBLIjp7InZhbHVlU3RyIjoiQVBJX0tFWV9DUkVEX1BST1ZJREVSIzMxNDE0NjMyMDA4OCNkZWZhdWx0I2FnZW50Y29yZS1jbGktaWRlbnRpdHktZml4dHVyZSIsImF0dHJpYnV0ZVR5cGUiOiJTIn19" +} \ No newline at end of file diff --git a/src/handlers/identity/__fixtures__/list-page-2.golden.json b/src/handlers/identity/__fixtures__/list-page-2.golden.json new file mode 100644 index 000000000..f802106e6 --- /dev/null +++ b/src/handlers/identity/__fixtures__/list-page-2.golden.json @@ -0,0 +1,10 @@ +{ + "credentialProviders": [ + { + "name": "agentcore-cli-identity-fixture-2", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:314146320088:token-vault/default/apikeycredentialprovider/agentcore-cli-identity-fixture-2", + "createdTime": "2026-07-22T18:56:28.265Z", + "lastUpdatedTime": "2026-07-22T18:56:28.265Z" + } + ] +} \ No newline at end of file diff --git a/src/handlers/identity/__fixtures__/list.golden.json b/src/handlers/identity/__fixtures__/list.golden.json new file mode 100644 index 000000000..a060c65ea --- /dev/null +++ b/src/handlers/identity/__fixtures__/list.golden.json @@ -0,0 +1,16 @@ +{ + "credentialProviders": [ + { + "name": "agentcore-cli-identity-fixture", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:314146320088:token-vault/default/apikeycredentialprovider/agentcore-cli-identity-fixture", + "createdTime": "2026-07-22T18:56:27.969Z", + "lastUpdatedTime": "2026-07-22T18:56:27.969Z" + }, + { + "name": "agentcore-cli-identity-fixture-2", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:314146320088:token-vault/default/apikeycredentialprovider/agentcore-cli-identity-fixture-2", + "createdTime": "2026-07-22T18:56:28.265Z", + "lastUpdatedTime": "2026-07-22T18:56:28.265Z" + } + ] +} \ No newline at end of file diff --git a/src/handlers/identity/__fixtures__/update.golden.json b/src/handlers/identity/__fixtures__/update.golden.json new file mode 100644 index 000000000..73e014e53 --- /dev/null +++ b/src/handlers/identity/__fixtures__/update.golden.json @@ -0,0 +1,10 @@ +{ + "apiKeySecretArn": { + "secretArn": "arn:aws:secretsmanager:us-west-2:314146320088:secret:bedrock-agentcore-identity!default/apikey/agentcore-cli-identity-fixture-d831a6a9-5GBOcy" + }, + "name": "agentcore-cli-identity-fixture", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:314146320088:token-vault/default/apikeycredentialprovider/agentcore-cli-identity-fixture", + "createdTime": "2026-07-22T18:56:27.969Z", + "lastUpdatedTime": "2026-07-22T18:56:27.969Z", + "apiKeySecretSource": "MANAGED" +} \ No newline at end of file diff --git a/src/handlers/identity/api-key-credential-provider/create/index.tsx b/src/handlers/identity/api-key-credential-provider/create/index.tsx new file mode 100644 index 000000000..72b671a02 --- /dev/null +++ b/src/handlers/identity/api-key-credential-provider/create/index.tsx @@ -0,0 +1,33 @@ +import z from "zod"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +export const createCreateApiKeyCredentialProviderHandler = (core: Core) => + createHandler({ + name: "create", + description: "create an API key credential provider", + flags: [ + flag("name", "the name of the API key credential provider", z.string().optional()), + flag("api-key", "the API key value", z.string().optional()), + ], + handle: async (ctx, flags) => { + if (!flags.name) { + throw new TypeError("required option '--name ' not specified"); + } + if (!flags["api-key"]) { + throw new TypeError("required option '--api-key ' not specified"); + } + + ctx + .require(JsonRendererKey) + .renderJson( + await core.identity.createApiKeyCredentialProvider( + flags.name, + flags["api-key"], + coreOptsFromCtx(ctx), + ), + ); + }, + }); diff --git a/src/handlers/identity/api-key-credential-provider/delete/index.tsx b/src/handlers/identity/api-key-credential-provider/delete/index.tsx new file mode 100644 index 000000000..56a637756 --- /dev/null +++ b/src/handlers/identity/api-key-credential-provider/delete/index.tsx @@ -0,0 +1,23 @@ +import z from "zod"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +export const createDeleteApiKeyCredentialProviderHandler = (core: Core) => + createHandler({ + name: "delete", + description: "delete an API key credential provider", + flags: [flag("name", "the name of the API key credential provider", z.string().optional())], + handle: async (ctx, flags) => { + if (!flags.name) { + throw new TypeError("required option '--name ' not specified"); + } + + ctx + .require(JsonRendererKey) + .renderJson( + await core.identity.deleteApiKeyCredentialProvider(flags.name, coreOptsFromCtx(ctx)), + ); + }, + }); diff --git a/src/handlers/identity/api-key-credential-provider/get/index.tsx b/src/handlers/identity/api-key-credential-provider/get/index.tsx new file mode 100644 index 000000000..b988198a5 --- /dev/null +++ b/src/handlers/identity/api-key-credential-provider/get/index.tsx @@ -0,0 +1,23 @@ +import z from "zod"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +export const createGetApiKeyCredentialProviderHandler = (core: Core) => + createHandler({ + name: "get", + description: "get an API key credential provider", + flags: [flag("name", "the name of the API key credential provider", z.string().optional())], + handle: async (ctx, flags) => { + if (!flags.name) { + throw new TypeError("required option '--name ' not specified"); + } + + ctx + .require(JsonRendererKey) + .renderJson( + await core.identity.getApiKeyCredentialProvider(flags.name, coreOptsFromCtx(ctx)), + ); + }, + }); diff --git a/src/handlers/identity/api-key-credential-provider/index.tsx b/src/handlers/identity/api-key-credential-provider/index.tsx new file mode 100644 index 000000000..685acf94e --- /dev/null +++ b/src/handlers/identity/api-key-credential-provider/index.tsx @@ -0,0 +1,18 @@ +import { Router } from "../../../router"; +import type { AppIO, Core } from "../../types"; +import { createHelpDefault } from "../../help"; +import { createCreateApiKeyCredentialProviderHandler } from "./create"; +import { createDeleteApiKeyCredentialProviderHandler } from "./delete"; +import { createGetApiKeyCredentialProviderHandler } from "./get"; +import { createListApiKeyCredentialProvidersHandler } from "./list"; +import { createUpdateApiKeyCredentialProviderHandler } from "./update"; + +export function createApiKeyCredentialProviderHandler(core: Core, io: AppIO): Router { + return new Router("api-key-credential-provider", "manage API key credential providers") + .default(createHelpDefault(io)) + .handler(createCreateApiKeyCredentialProviderHandler(core)) + .handler(createGetApiKeyCredentialProviderHandler(core)) + .handler(createListApiKeyCredentialProvidersHandler(core)) + .handler(createUpdateApiKeyCredentialProviderHandler(core)) + .handler(createDeleteApiKeyCredentialProviderHandler(core)); +} diff --git a/src/handlers/identity/api-key-credential-provider/list/index.tsx b/src/handlers/identity/api-key-credential-provider/list/index.tsx new file mode 100644 index 000000000..995f11f30 --- /dev/null +++ b/src/handlers/identity/api-key-credential-provider/list/index.tsx @@ -0,0 +1,26 @@ +import z from "zod"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +export const createListApiKeyCredentialProvidersHandler = (core: Core) => + createHandler({ + name: "list", + description: "list API key credential providers", + flags: [ + flag("next-token", "pagination token returned by a previous request", z.string().optional()), + flag("max-results", "maximum number of items to return", z.number().optional()), + ], + handle: async (ctx, flags) => { + ctx + .require(JsonRendererKey) + .renderJson( + await core.identity.listApiKeyCredentialProviders( + flags["next-token"], + flags["max-results"], + coreOptsFromCtx(ctx), + ), + ); + }, + }); diff --git a/src/handlers/identity/api-key-credential-provider/update/index.tsx b/src/handlers/identity/api-key-credential-provider/update/index.tsx new file mode 100644 index 000000000..b081d5198 --- /dev/null +++ b/src/handlers/identity/api-key-credential-provider/update/index.tsx @@ -0,0 +1,33 @@ +import z from "zod"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +export const createUpdateApiKeyCredentialProviderHandler = (core: Core) => + createHandler({ + name: "update", + description: "update an API key credential provider", + flags: [ + flag("name", "the name of the API key credential provider", z.string().optional()), + flag("api-key", "the new API key value", z.string().optional()), + ], + handle: async (ctx, flags) => { + if (!flags.name) { + throw new TypeError("required option '--name ' not specified"); + } + if (!flags["api-key"]) { + throw new TypeError("required option '--api-key ' not specified"); + } + + ctx + .require(JsonRendererKey) + .renderJson( + await core.identity.updateApiKeyCredentialProvider( + flags.name, + flags["api-key"], + coreOptsFromCtx(ctx), + ), + ); + }, + }); diff --git a/src/handlers/identity/identity.test.tsx b/src/handlers/identity/identity.test.tsx new file mode 100644 index 000000000..8001a24ce --- /dev/null +++ b/src/handlers/identity/identity.test.tsx @@ -0,0 +1,216 @@ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { CoreClient } from "../../core"; +import { createSilentLogger, fixtureFactories, matchGolden, testIO } from "../../testing"; +import { createRootHandler } from "../index"; + +const REGION = "us-west-2"; +const FIXTURES = join(import.meta.dir, "__fixtures__"); + +// Record with AWS_PROFILE=YOUR_PROFILE RECORD=1 bun test src/handlers/identity/identity.test.tsx +// The test account must have two providers pre-created for pagination tests. +// The RECORD run creates them (create tests run first), tests pagination, then +// deletes them (delete tests run last). +const FIXTURE_PROVIDER_NAME = "agentcore-cli-identity-fixture"; +const FIXTURE_PROVIDER_NAME_2 = "agentcore-cli-identity-fixture-2"; +const MISSING_PROVIDER_NAME = "missing-provider-000"; + +function createFixtureCore(): CoreClient { + const { createControlClient, createDataClient, createIamClient } = fixtureFactories(FIXTURES); + return new CoreClient({ + createControlClient, + createDataClient, + createIamClient, + logger: createSilentLogger(), + }); +} + +async function run(args: string[]): Promise { + const io = testIO(); + const root = createRootHandler(createFixtureCore(), { + io: io.io, + logger: createSilentLogger(), + }); + + await root.route(["node", "agentcore", ...args, "--region", REGION]); + return io.stdout(); +} + +describe("identity command hierarchy", () => { + test("registers the identity command hierarchy", () => { + const root = createRootHandler(createFixtureCore(), { + io: testIO().io, + logger: createSilentLogger(), + }); + const identity = root.children().find((child) => child.name() === "identity"); + + expect(identity?.children().map((child) => child.name())).toEqual([ + "api-key-credential-provider", + ]); + expect( + identity + ?.children() + .find((child) => child.name() === "api-key-credential-provider") + ?.children() + .map((child) => child.name()), + ).toEqual(["create", "get", "list", "update", "delete"]); + }); + + test.each(["identity", "identity api-key-credential-provider"])( + "prints help for bare `%s` without an SDK call", + async (command) => { + const stdout = await run(command.split(" ")); + + expect(stdout).toContain(`Usage: agentcore ${command}`); + expect(stdout).toContain("Commands:"); + }, + ); +}); + +describe("api-key-credential-provider CRUDL", () => { + test("creates an API key credential provider", async () => { + const stdout = await run([ + "identity", + "api-key-credential-provider", + "create", + "--name", + FIXTURE_PROVIDER_NAME, + "--api-key", + "test-api-key-value", + ]); + + matchGolden(FIXTURES, "create.golden.json", stdout); + }); + + test("creates a second API key credential provider for pagination", async () => { + const stdout = await run([ + "identity", + "api-key-credential-provider", + "create", + "--name", + FIXTURE_PROVIDER_NAME_2, + "--api-key", + "test-api-key-value-2", + ]); + + matchGolden(FIXTURES, "create-2.golden.json", stdout); + }); + + test("gets an API key credential provider", async () => { + const stdout = await run([ + "identity", + "api-key-credential-provider", + "get", + "--name", + FIXTURE_PROVIDER_NAME, + ]); + + matchGolden(FIXTURES, "get.golden.json", stdout); + expect(JSON.parse(stdout).name).toBe(FIXTURE_PROVIDER_NAME); + }); + + test("lists API key credential providers", async () => { + const stdout = await run(["identity", "api-key-credential-provider", "list"]); + + matchGolden(FIXTURES, "list.golden.json", stdout); + expect(JSON.parse(stdout).credentialProviders).toBeArray(); + }); + + test("paginates API key credential provider list with --max-results and --next-token", async () => { + const firstPage = await run([ + "identity", + "api-key-credential-provider", + "list", + "--max-results", + "1", + ]); + matchGolden(FIXTURES, "list-page-1.golden.json", firstPage); + + const first = JSON.parse(firstPage); + expect(first.credentialProviders).toHaveLength(1); + expect(first.nextToken).toBeString(); + + const secondPage = await run([ + "identity", + "api-key-credential-provider", + "list", + "--max-results", + "1", + "--next-token", + first.nextToken, + ]); + matchGolden(FIXTURES, "list-page-2.golden.json", secondPage); + expect(JSON.parse(secondPage).credentialProviders).toHaveLength(1); + }); + + test("updates an API key credential provider", async () => { + const stdout = await run([ + "identity", + "api-key-credential-provider", + "update", + "--name", + FIXTURE_PROVIDER_NAME, + "--api-key", + "updated-api-key-value", + ]); + + matchGolden(FIXTURES, "update.golden.json", stdout); + expect(JSON.parse(stdout).name).toBe(FIXTURE_PROVIDER_NAME); + }); + + test("deletes the first API key credential provider", async () => { + const stdout = await run([ + "identity", + "api-key-credential-provider", + "delete", + "--name", + FIXTURE_PROVIDER_NAME, + ]); + + matchGolden(FIXTURES, "delete.golden.json", stdout); + }); + + test("deletes the second API key credential provider", async () => { + const stdout = await run([ + "identity", + "api-key-credential-provider", + "delete", + "--name", + FIXTURE_PROVIDER_NAME_2, + ]); + + matchGolden(FIXTURES, "delete-2.golden.json", stdout); + }); + + test.each([ + [ + "create --name only", + ["identity", "api-key-credential-provider", "create", "--name", "x"], + /--api-key/, + ], + [ + "create --api-key only", + ["identity", "api-key-credential-provider", "create", "--api-key", "x"], + /--name/, + ], + ["create bare", ["identity", "api-key-credential-provider", "create"], /--name/], + ["get bare", ["identity", "api-key-credential-provider", "get"], /--name/], + ["update bare", ["identity", "api-key-credential-provider", "update"], /--name/], + [ + "update --name only", + ["identity", "api-key-credential-provider", "update", "--name", "x"], + /--api-key/, + ], + ["delete bare", ["identity", "api-key-credential-provider", "delete"], /--name/], + ] as const)("rejects missing required flags for `%s`", async (_label, args, message) => { + expect(run([...args])).rejects.toThrow(message); + }); + + test("propagates ResourceNotFoundException from get", async () => { + await expect( + run(["identity", "api-key-credential-provider", "get", "--name", MISSING_PROVIDER_NAME]), + ).rejects.toMatchObject({ + name: "ResourceNotFoundException", + }); + }); +}); diff --git a/src/handlers/identity/index.tsx b/src/handlers/identity/index.tsx new file mode 100644 index 000000000..d52548260 --- /dev/null +++ b/src/handlers/identity/index.tsx @@ -0,0 +1,10 @@ +import { Router } from "../../router"; +import type { AppIO, Core } from "../types"; +import { createApiKeyCredentialProviderHandler } from "./api-key-credential-provider"; +import { createHelpDefault } from "../help"; + +export function createIdentityHandler(core: Core, io: AppIO): Router { + return new Router("identity", "manage AgentCore Identity resources") + .default(createHelpDefault(io)) + .handler(createApiKeyCredentialProviderHandler(core, io)); +} diff --git a/src/handlers/identity/types.tsx b/src/handlers/identity/types.tsx new file mode 100644 index 000000000..9a6d36cc0 --- /dev/null +++ b/src/handlers/identity/types.tsx @@ -0,0 +1,34 @@ +import type { + CreateApiKeyCredentialProviderResponse, + DeleteApiKeyCredentialProviderResponse, + GetApiKeyCredentialProviderResponse, + ListApiKeyCredentialProvidersResponse, + UpdateApiKeyCredentialProviderResponse, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import type { CoreOptions } from "../../core/types"; + +export interface CoreIdentityClient { + createApiKeyCredentialProvider( + name: string, + apiKey: string, + options: CoreOptions, + ): Promise; + getApiKeyCredentialProvider( + name: string, + options: CoreOptions, + ): Promise; + listApiKeyCredentialProviders( + nextToken: string | undefined, + maxResults: number | undefined, + options: CoreOptions, + ): Promise; + updateApiKeyCredentialProvider( + name: string, + apiKey: string, + options: CoreOptions, + ): Promise; + deleteApiKeyCredentialProvider( + name: string, + options: CoreOptions, + ): Promise; +} diff --git a/src/handlers/index.tsx b/src/handlers/index.tsx index a5d5dce94..047e2f619 100644 --- a/src/handlers/index.tsx +++ b/src/handlers/index.tsx @@ -1,5 +1,6 @@ import { Router } from "../router"; import { createHarnessHandler } from "./harness/index.tsx"; +import { createIdentityHandler } from "./identity/index.tsx"; import { createRuntimeHandler } from "./runtime/index.tsx"; import { DebugKey, EndpointKey, JsonKey, RegionKey } from "./keys.tsx"; import { createConfigHandler } from "./config/"; @@ -34,6 +35,7 @@ export function createRootHandler(core: Core, config: RootHandlerConfig): Router // Install sub handlers root.handler(createHarnessHandler(core, io)); + root.handler(createIdentityHandler(core, io)); root.handler(createRuntimeHandler(core, io)); root.handler(createConfigHandler(io)); root.handler(createProjectHandler({ projectManager: core.projectManager })); diff --git a/src/handlers/root.test.tsx b/src/handlers/root.test.tsx index b79eaec2f..e217ef582 100644 --- a/src/handlers/root.test.tsx +++ b/src/handlers/root.test.tsx @@ -11,6 +11,7 @@ describe("createRootHandler", () => { expect(root.name()).toBe("agentcore"); expect(root.children().map((c) => c.name())).toEqual([ "harness", + "identity", "runtime", "config", "project", diff --git a/src/handlers/runtime/endpoint/index.tsx b/src/handlers/runtime/endpoint/index.tsx index 23254220d..1af837ecd 100644 --- a/src/handlers/runtime/endpoint/index.tsx +++ b/src/handlers/runtime/endpoint/index.tsx @@ -1,6 +1,6 @@ import { Router } from "../../../router"; import type { AppIO, Core } from "../../types"; -import { createHelpDefault } from "../help"; +import { createHelpDefault } from "../../help"; import { createGetRuntimeEndpointHandler } from "./get"; import { createListRuntimeEndpointsHandler } from "./list"; diff --git a/src/handlers/runtime/index.tsx b/src/handlers/runtime/index.tsx index a33bb00cd..0f827148e 100644 --- a/src/handlers/runtime/index.tsx +++ b/src/handlers/runtime/index.tsx @@ -2,7 +2,7 @@ import { Router } from "../../router"; import type { AppIO, Core } from "../types"; import { createRuntimeEndpointHandler } from "./endpoint"; import { createGetRuntimeHandler } from "./get"; -import { createHelpDefault } from "./help"; +import { createHelpDefault } from "../help"; import { createListRuntimesHandler } from "./list"; import { createRuntimeVersionHandler } from "./version"; diff --git a/src/handlers/runtime/version/index.tsx b/src/handlers/runtime/version/index.tsx index 57e40d439..d54543a8c 100644 --- a/src/handlers/runtime/version/index.tsx +++ b/src/handlers/runtime/version/index.tsx @@ -1,6 +1,6 @@ import { Router } from "../../../router"; import type { AppIO, Core } from "../../types"; -import { createHelpDefault } from "../help"; +import { createHelpDefault } from "../../help"; import { createGetRuntimeVersionHandler } from "./get"; import { createListRuntimeVersionsHandler } from "./list"; diff --git a/src/handlers/types.tsx b/src/handlers/types.tsx index 58eecb093..8f8371d7a 100644 --- a/src/handlers/types.tsx +++ b/src/handlers/types.tsx @@ -1,10 +1,12 @@ import type { CoreHarnessClient } from "./harness/types.tsx"; +import type { CoreIdentityClient } from "./identity/types.tsx"; import type { CoreRuntimeClient } from "./runtime/types.tsx"; import type { Context } from "../router"; import type { ProjectManager } from "./project/types.ts"; export interface Core { harness: CoreHarnessClient; + identity: CoreIdentityClient; runtime: CoreRuntimeClient; projectManager: ProjectManager; } diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index f1559bda7..93a2c33aa 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -1,21 +1,26 @@ import type { + CreateApiKeyCredentialProviderResponse, CreateHarnessEndpointRequest, CreateHarnessEndpointResponse, CreateHarnessResponse, + DeleteApiKeyCredentialProviderResponse, DeleteHarnessEndpointRequest, DeleteHarnessEndpointResponse, DeleteHarnessRequest, DeleteHarnessResponse, + GetApiKeyCredentialProviderResponse, GetHarnessResponse, GetHarnessEndpointResponse, GetAgentRuntimeEndpointResponse, GetAgentRuntimeResponse, + ListApiKeyCredentialProvidersResponse, ListAgentRuntimeEndpointsResponse, ListAgentRuntimesResponse, ListAgentRuntimeVersionsResponse, ListHarnessesResponse, ListHarnessEndpointsResponse, ListHarnessVersionsResponse, + UpdateApiKeyCredentialProviderResponse, UpdateHarnessEndpointRequest, UpdateHarnessEndpointResponse, UpdateHarnessRequest, @@ -31,6 +36,7 @@ import type { } from "@aws-sdk/client-bedrock-agentcore"; import type { Core } from "../handlers/types"; import type { CoreHarnessClient, CreateHarnessInput } from "../handlers/harness/types"; +import type { CoreIdentityClient } from "../handlers/identity/types"; import type { CoreRuntimeClient } from "../handlers/runtime/types"; import type { CoreOptions } from "../core/types"; import type { ProjectManager } from "../handlers/project/types"; @@ -74,6 +80,13 @@ const DEFAULT_UPDATE_ENDPOINT_RESPONSE: UpdateHarnessEndpointResponse = {} as UpdateHarnessEndpointResponse; const DEFAULT_DELETE_ENDPOINT_RESPONSE: DeleteHarnessEndpointResponse = {} as DeleteHarnessEndpointResponse; +const DEFAULT_CREATE_API_KEY_RESPONSE = {} as CreateApiKeyCredentialProviderResponse; +const DEFAULT_GET_API_KEY_RESPONSE = {} as GetApiKeyCredentialProviderResponse; +const DEFAULT_LIST_API_KEYS_RESPONSE: ListApiKeyCredentialProvidersResponse = { + credentialProviders: [], +}; +const DEFAULT_UPDATE_API_KEY_RESPONSE = {} as UpdateApiKeyCredentialProviderResponse; +const DEFAULT_DELETE_API_KEY_RESPONSE = {} as DeleteApiKeyCredentialProviderResponse; const DEFAULT_GET_RUNTIME_RESPONSE = {} as GetAgentRuntimeResponse; const DEFAULT_GET_RUNTIME_ENDPOINT_RESPONSE = {} as GetAgentRuntimeEndpointResponse; const DEFAULT_LIST_RUNTIMES_RESPONSE: ListAgentRuntimesResponse = { agentRuntimes: [] }; @@ -474,9 +487,50 @@ type TestCoreClientOptions = { logger?: Logger; }; +class TestIdentityClient implements CoreIdentityClient { + async createApiKeyCredentialProvider( + _name: string, + _apiKey: string, + _options: CoreOptions, + ): Promise { + return DEFAULT_CREATE_API_KEY_RESPONSE; + } + + async getApiKeyCredentialProvider( + _name: string, + _options: CoreOptions, + ): Promise { + return DEFAULT_GET_API_KEY_RESPONSE; + } + + async listApiKeyCredentialProviders( + _nextToken: string | undefined, + _maxResults: number | undefined, + _options: CoreOptions, + ): Promise { + return DEFAULT_LIST_API_KEYS_RESPONSE; + } + + async updateApiKeyCredentialProvider( + _name: string, + _apiKey: string, + _options: CoreOptions, + ): Promise { + return DEFAULT_UPDATE_API_KEY_RESPONSE; + } + + async deleteApiKeyCredentialProvider( + _name: string, + _options: CoreOptions, + ): Promise { + return DEFAULT_DELETE_API_KEY_RESPONSE; + } +} + // TestCoreClient implements the Core contract with fully controllable sub-clients. export class TestCoreClient implements Core { readonly harness = new TestHarnessClient(); + readonly identity = new TestIdentityClient(); readonly runtime = new TestRuntimeClient(); readonly projectManager: ProjectManager;