From c703100d7b0f872ee93dbc7455f805fcbcbc24d4 Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Fri, 31 Jul 2026 11:58:52 -0400 Subject: [PATCH 1/2] feat: implement TUI for identity --- README.md | 50 +++- src/components/CliOnlyScreen.tsx | 39 +++ src/components/PaginatedTablePicker.tsx | 4 +- src/components/Root.tsx | 85 ++++++ src/components/usePagedList.tsx | 8 +- .../apikey.screen.test.tsx | 253 ++++++++++++++++ .../create/screen.tsx | 12 + .../delete/screen.tsx | 12 + .../get/screen.tsx | 109 +++++++ .../api-key-credential-provider/index.tsx | 4 +- .../list/screen.tsx | 61 ++++ .../api-key-credential-provider/screen.tsx | 8 + .../update/screen.tsx | 12 + src/handlers/identity/identity.test.tsx | 36 ++- src/handlers/identity/index.tsx | 6 +- .../create/screen.tsx | 12 + .../delete/screen.tsx | 12 + .../oauth2-credential-provider/get/screen.tsx | 111 +++++++ .../oauth2-credential-provider/index.tsx | 4 +- .../list/screen.tsx | 64 ++++ .../oauth2.fixture.test.tsx | 2 +- .../oauth2.screen.test.tsx | 274 ++++++++++++++++++ .../oauth2.test.tsx | 29 +- .../oauth2-credential-provider/screen.tsx | 6 + .../update/screen.tsx | 12 + src/handlers/identity/screen.tsx | 6 + src/testing/TestCoreClient.tsx | 62 +++- 27 files changed, 1256 insertions(+), 37 deletions(-) create mode 100644 src/components/CliOnlyScreen.tsx create mode 100644 src/handlers/identity/api-key-credential-provider/apikey.screen.test.tsx create mode 100644 src/handlers/identity/api-key-credential-provider/create/screen.tsx create mode 100644 src/handlers/identity/api-key-credential-provider/delete/screen.tsx create mode 100644 src/handlers/identity/api-key-credential-provider/get/screen.tsx create mode 100644 src/handlers/identity/api-key-credential-provider/list/screen.tsx create mode 100644 src/handlers/identity/api-key-credential-provider/screen.tsx create mode 100644 src/handlers/identity/api-key-credential-provider/update/screen.tsx create mode 100644 src/handlers/identity/oauth2-credential-provider/create/screen.tsx create mode 100644 src/handlers/identity/oauth2-credential-provider/delete/screen.tsx create mode 100644 src/handlers/identity/oauth2-credential-provider/get/screen.tsx create mode 100644 src/handlers/identity/oauth2-credential-provider/list/screen.tsx create mode 100644 src/handlers/identity/oauth2-credential-provider/oauth2.screen.test.tsx create mode 100644 src/handlers/identity/oauth2-credential-provider/screen.tsx create mode 100644 src/handlers/identity/oauth2-credential-provider/update/screen.tsx create mode 100644 src/handlers/identity/screen.tsx diff --git a/README.md b/README.md index 1e32e50fe..99113fb0d 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,8 @@ It gives you two ways to work, from the same binary: - **A scriptable CLI** — every operation is a flag-driven subcommand that emits JSON (`--json`), so it can be used by codeing agents and can drop cleanly into scripts, CI, and automation. -- **An interactive TUI** — bare Harness, Runtime, and Memory branches and leaves open - their corresponding menus and selection flows. +- **An interactive TUI** — bare Harness, Runtime, Memory, and Identity branches + and leaves open their corresponding menus and selection flows. ```bash agentcore # launch the interactive TUI @@ -26,8 +26,8 @@ responses. `agentcore` wraps all of that behind one ergonomic tool. ## Command surface -Commands with operation flags run headlessly. Bare Harness, Runtime, and Memory branches -and leaves open their interactive flows. +Commands with operation flags run headlessly. Bare Harness, Runtime, Memory, and +Identity branches and leaves open their interactive flows. ``` agentcore # interactive TUI @@ -49,12 +49,18 @@ agentcore # interactive TUI │ ├── update │ └── delete ├── identity # manage AgentCore Identity resources -│ └── api-key-credential-provider -│ ├── create # create an API key credential provider -│ ├── get # get an API key credential provider -│ ├── list # list API key credential providers -│ ├── update # update an API key credential provider -│ └── delete # delete an API key credential provider +│ ├── api-key-credential-provider +│ │ ├── create # create an API key credential provider +│ │ ├── get # get an API key credential provider +│ │ ├── list # list API key credential providers +│ │ ├── update # update an API key credential provider +│ │ └── delete # delete an API key credential provider +│ └── oauth2-credential-provider +│ ├── create # create an OAuth2 credential provider +│ ├── get # get an OAuth2 credential provider +│ ├── list # list OAuth2 credential providers +│ ├── update # update an OAuth2 credential provider +│ └── delete # delete an OAuth2 credential provider ├── runtime # inspect deployed AgentCore Runtimes │ ├── get # fetch a Runtime by id │ ├── list # list Runtimes (server-side paginated) @@ -162,6 +168,17 @@ agentcore identity api-key-credential-provider list --max-results 10 agentcore identity api-key-credential-provider update --name my-provider --api-key agentcore identity api-key-credential-provider delete --name my-provider +# Manage OAuth2 credential providers (guided Custom OAuth2, or --provider-configuration for other vendors) +agentcore identity oauth2-credential-provider create \ + --name my-oauth-provider \ + --vendor CustomOauth2 \ + --client-id \ + --discovery-url https://issuer.example.com/.well-known/openid-configuration \ + --client-secret - +agentcore identity oauth2-credential-provider get --name my-oauth-provider +agentcore identity oauth2-credential-provider list --max-results 10 +agentcore identity oauth2-credential-provider delete --name my-oauth-provider + # Manage evaluators # Create an LLM-as-a-Judge evaluator with a rating-scale preset. agentcore eval evaluator llm-as-a-judge create \ @@ -311,6 +328,19 @@ agentcore memory event agentcore memory record ``` +The Identity TUI is read-only: bare `identity` branches and the `get`/`list` +leaves open interactive menus and detail views. Mutations (`create`, `update`, +`delete`) are intentionally CLI-only — selecting one in the TUI points you at the +equivalent CLI command rather than hosting a write flow. + +```bash +agentcore identity +agentcore identity api-key-credential-provider list +agentcore identity api-key-credential-provider get +agentcore identity oauth2-credential-provider list +agentcore identity oauth2-credential-provider get +``` + --- # Architecture & patterns diff --git a/src/components/CliOnlyScreen.tsx b/src/components/CliOnlyScreen.tsx new file mode 100644 index 000000000..099ed3c0a --- /dev/null +++ b/src/components/CliOnlyScreen.tsx @@ -0,0 +1,39 @@ +import { Box, Text, useInput } from "ink"; +import { useNavigate } from "react-router"; +import { Layout } from "./Layout"; +import { darkTheme } from "./ui/_core.js"; +import type { ScreenProps } from "../handlers/types"; + +export interface CliOnlyScreenProps extends ScreenProps { + breadcrumb: string[]; + // command is the CLI invocation shown to the user + // operation, e.g. "agentcore identity api-key-credential-provider create --help". + command: string; +} + +// CliOnlyScreen is the supported TUI experience for operations that are +// intentionally CLI-only +export function CliOnlyScreen({ breadcrumb, command }: CliOnlyScreenProps) { + const navigate = useNavigate(); + + useInput((_input, key) => { + if (key.escape) navigate(-1); + }); + + return ( + + + This operation is available via the CLI: + + {command} + + + + ); +} diff --git a/src/components/PaginatedTablePicker.tsx b/src/components/PaginatedTablePicker.tsx index 3149c65db..8b373f158 100644 --- a/src/components/PaginatedTablePicker.tsx +++ b/src/components/PaginatedTablePicker.tsx @@ -26,6 +26,7 @@ export interface PaginatedTablePickerProps string; emptyMessage: string; emptyPageMessage: string; + maxPageSize?: number; } export function PaginatedTablePicker>({ @@ -43,8 +44,9 @@ export function PaginatedTablePicker errorMessage, emptyMessage, emptyPageMessage, + maxPageSize, }: PaginatedTablePickerProps) { - const paging = usePagedList(); + const paging = usePagedList(maxPageSize); const list = useQuery({ queryKey: [...queryKey, paging.pageSize, paging.token], queryFn: () => loadPage(paging.token, paging.pageSize), diff --git a/src/components/Root.tsx b/src/components/Root.tsx index ad8f804c2..fcedbcd4d 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -48,6 +48,25 @@ import { import { MemoryEventScreen } from "../handlers/memory/event/screen.tsx"; import { MemoryEventGetScreen } from "../handlers/memory/event/get/screen.tsx"; import { MemoryEventListScreen } from "../handlers/memory/event/list/screen.tsx"; +import { IdentityScreen } from "../handlers/identity/screen.tsx"; +import { ApiKeyCredentialProviderScreen } from "../handlers/identity/api-key-credential-provider/screen.tsx"; +import { ApiKeyCredentialProviderListScreen } from "../handlers/identity/api-key-credential-provider/list/screen.tsx"; +import { + ApiKeyCredentialProviderGetScreen, + ApiKeyCredentialProviderGetJsonScreen, +} from "../handlers/identity/api-key-credential-provider/get/screen.tsx"; +import { ApiKeyCredentialProviderCreateScreen } from "../handlers/identity/api-key-credential-provider/create/screen.tsx"; +import { ApiKeyCredentialProviderUpdateScreen } from "../handlers/identity/api-key-credential-provider/update/screen.tsx"; +import { ApiKeyCredentialProviderDeleteScreen } from "../handlers/identity/api-key-credential-provider/delete/screen.tsx"; +import { Oauth2CredentialProviderScreen } from "../handlers/identity/oauth2-credential-provider/screen.tsx"; +import { Oauth2CredentialProviderListScreen } from "../handlers/identity/oauth2-credential-provider/list/screen.tsx"; +import { + Oauth2CredentialProviderGetScreen, + Oauth2CredentialProviderGetJsonScreen, +} from "../handlers/identity/oauth2-credential-provider/get/screen.tsx"; +import { Oauth2CredentialProviderCreateScreen } from "../handlers/identity/oauth2-credential-provider/create/screen.tsx"; +import { Oauth2CredentialProviderUpdateScreen } from "../handlers/identity/oauth2-credential-provider/update/screen.tsx"; +import { Oauth2CredentialProviderDeleteScreen } from "../handlers/identity/oauth2-credential-provider/delete/screen.tsx"; import { RootScreen, HelpScreen } from "../handlers/screen.tsx"; import type { Context } from "../router"; @@ -374,6 +393,72 @@ export function Root({ path, ctx, core, queryClient }: RootProps) { path="agentcore/memory/event/list/:memoryId/:actorId/:sessionId" element={} /> + } /> + } + /> + {/* Bare `get` (no name) has nothing to show — send the user to the list. */} + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> } /> diff --git a/src/components/usePagedList.tsx b/src/components/usePagedList.tsx index fa6ed7a25..1901b4482 100644 --- a/src/components/usePagedList.tsx +++ b/src/components/usePagedList.tsx @@ -37,9 +37,13 @@ function initialPagination(pageSize: number): PaginationState { // usePagedList holds the server-side pagination state shared by the picker // tables: a terminal-height-derived page size and the trail of nextTokens // leading to the current page, so ←/h can walk back through cached pages. -export function usePagedList(): PagedList { +// +// maxPageSize caps the terminal-derived page size for APIs that constrain +// maxResults (e.g. identity list operation capped at 20) +export function usePagedList(maxPageSize?: number): PagedList { const { rows } = useWindowSize(); - const pageSize = Math.max(3, rows - CHROME_ROWS); + const fitsTerminal = Math.max(3, rows - CHROME_ROWS); + const pageSize = maxPageSize ? Math.min(fitsTerminal, maxPageSize) : fitsTerminal; const [state, setState] = useState(() => initialPagination(pageSize)); const pageSizeChanged = state.pageSize !== pageSize; diff --git a/src/handlers/identity/api-key-credential-provider/apikey.screen.test.tsx b/src/handlers/identity/api-key-credential-provider/apikey.screen.test.tsx new file mode 100644 index 000000000..dcb77f2f9 --- /dev/null +++ b/src/handlers/identity/api-key-credential-provider/apikey.screen.test.tsx @@ -0,0 +1,253 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { + ApiKeyCredentialProviderItem, + GetApiKeyCredentialProviderResponse, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { QueryClient } from "@tanstack/react-query"; +import { + cleanupScreens, + renderScreen, + TestCoreClient, + tick, + waitFor, + waitForText, +} from "../../../testing"; + +afterEach(cleanupScreens); + +const endpointUrl = "https://identity.test"; + +function providerItem( + overrides: Partial = {}, +): ApiKeyCredentialProviderItem { + return { + name: "api-key-1", + credentialProviderArn: + "arn:aws:bedrock-agentcore:us-east-1:123456789012:token-vault/default/apikeycredentialprovider/api-key-1", + createdTime: new Date("2026-07-19T01:02:03.000Z"), + lastUpdatedTime: new Date("2026-07-20T12:34:56.000Z"), + ...overrides, + }; +} + +function getResponse( + overrides: Partial = {}, +): GetApiKeyCredentialProviderResponse { + return { + name: "api-key-1", + credentialProviderArn: + "arn:aws:bedrock-agentcore:us-east-1:123456789012:token-vault/default/apikeycredentialprovider/api-key-1", + apiKeySecretArn: { + secretArn: + "arn:aws:secretsmanager:us-east-1:123456789012:secret:bedrock-agentcore-identity!default/apikey/api-key-1", + }, + apiKeySecretSource: "MANAGED", + createdTime: new Date("2026-07-19T01:02:03.000Z"), + lastUpdatedTime: new Date("2026-07-20T12:34:56.000Z"), + ...overrides, + } as GetApiKeyCredentialProviderResponse; +} + +function coreWithProviders(providers: ApiKeyCredentialProviderItem[]): TestCoreClient { + const core = new TestCoreClient(); + core.identity.setListApiKeyResponse({ credentialProviders: providers }); + return core; +} + +describe("API key credential provider picker", () => { + test("renders provider name, created, and updated times", async () => { + const core = coreWithProviders([ + providerItem({ + name: "visible-provider", + createdTime: new Date("2026-07-18T00:00:00.000Z"), + lastUpdatedTime: new Date("2026-07-21T02:03:04.000Z"), + }), + ]); + const screen = renderScreen("/agentcore/identity/api-key-credential-provider/list", { core }); + + await waitForText(screen.lastFrame, "visible-provider"); + const frame = screen.lastFrame()!; + expect(frame).toContain("name"); + expect(frame).toContain("created UTC"); + expect(frame).toContain("updated UTC"); + expect(frame).toContain("2026-07-21 02:03"); + }); + + test("calls listApiKeyCredentialProviders with exact Core options", async () => { + const core = coreWithProviders([providerItem()]); + renderScreen("/agentcore/identity/api-key-credential-provider/list", { core, endpointUrl }); + + await waitFor(() => + core.identity.calls.some((call) => call.method === "listApiKeyCredentialProviders"), + ); + expect( + core.identity.calls.filter((call) => call.method === "listApiKeyCredentialProviders"), + ).toEqual([ + { + method: "listApiKeyCredentialProviders", + args: [undefined, expect.any(Number), { region: "us-east-1", endpointUrl }], + }, + ]); + }); + + test("caps maxResults at the service limit of 20 on a tall terminal", async () => { + const core = coreWithProviders([providerItem()]); + const screen = renderScreen("/agentcore/identity/api-key-credential-provider/list", { core }); + // Terminal taller than the 20-row service cap: page size must still clamp. + await screen.resize(120, 60); + + await waitFor(() => + core.identity.calls.some((call) => call.method === "listApiKeyCredentialProviders"), + ); + for (const call of core.identity.calls) { + if (call.method !== "listApiKeyCredentialProviders") continue; + expect(call.args[1] as number).toBeLessThanOrEqual(20); + } + }); + + test("shows first-page and later-page empty states", async () => { + const empty = renderScreen("/agentcore/identity/api-key-credential-provider/list"); + await waitForText(empty.lastFrame, "No API key credential providers found in this Region."); + empty.unmount(); + + const core = new TestCoreClient(); + core.identity.setListApiKeyResponse({ + credentialProviders: [providerItem({ name: "page-one" })], + nextToken: "page-2", + }); + core.identity.setListApiKeyResponse({ credentialProviders: [] }, "page-2"); + const paged = renderScreen("/agentcore/identity/api-key-credential-provider/list", { core }); + + await waitForText(paged.lastFrame, "page 1 · more →"); + await paged.write("l"); + await waitForText(paged.lastFrame, "No API key credential providers on this page."); + expect(paged.lastFrame()).not.toContain( + "No API key credential providers found in this Region.", + ); + }); + + test("bare get redirects to the picker", async () => { + const core = coreWithProviders([providerItem({ name: "redirected" })]); + const screen = renderScreen("/agentcore/identity/api-key-credential-provider/get", { core }); + + await waitForText(screen.lastFrame, "redirected"); + expect(core.identity.calls[0]?.method).toBe("listApiKeyCredentialProviders"); + }); + + test("selection opens the matching provider detail", async () => { + const name = "api key blue"; + const core = coreWithProviders([providerItem({ name })]); + core.identity.setGetApiKeyResponse(getResponse({ name })); + const screen = renderScreen("/agentcore/identity/api-key-credential-provider/list", { core }); + + await waitForText(screen.lastFrame, name); + await screen.press("return"); + await waitForText( + screen.lastFrame, + `agentcore → identity → api-key-credential-provider → get → ${name}`, + ); + await waitFor(() => + core.identity.calls.some( + (call) => call.method === "getApiKeyCredentialProvider" && call.args[0] === name, + ), + ); + }); +}); + +describe("API key credential provider detail", () => { + test("renders a resource summary with only the detail action", async () => { + const core = new TestCoreClient(); + core.identity.setGetApiKeyResponse(getResponse()); + const screen = renderScreen("/agentcore/identity/api-key-credential-provider/get/api-key-1", { + core, + endpointUrl, + }); + + await waitForText(screen.lastFrame, "show the full JSON definition"); + const frame = screen.lastFrame()!; + expect(frame).toContain("api-key-1"); + expect(frame).toMatch(/secretSource\s+MANAGED/); + expect(frame).toContain("arn:aws:bedrock-agentcore"); + // The only action is "detail"; mutations are CLI-only, never surfaced here. + expect(frame).toContain("❯ detail"); + expect( + core.identity.calls.find((call) => call.method === "getApiKeyCredentialProvider"), + ).toEqual({ + method: "getApiKeyCredentialProvider", + args: ["api-key-1", { region: "us-east-1", endpointUrl }], + }); + }); + + test("opens the complete provider JSON", async () => { + const core = new TestCoreClient(); + core.identity.setGetApiKeyResponse(getResponse()); + const screen = renderScreen("/agentcore/identity/api-key-credential-provider/get/api-key-1", { + core, + }); + + await waitForText(screen.lastFrame, "show the full JSON definition"); + await screen.press("return"); + await waitForText( + screen.lastFrame, + "agentcore → identity → api-key-credential-provider → get → api-key-1 → json", + ); + expect(screen.lastFrame()).toContain('"credentialProviderArn"'); + }); + + test("retries a failed detail query", async () => { + const core = new TestCoreClient(); + core.identity.setError(new Error("provider unavailable")); + const screen = renderScreen("/agentcore/identity/api-key-credential-provider/get/api-key-1", { + core, + }); + + await waitForText(screen.lastFrame, "provider unavailable"); + expect(screen.lastFrame()).toContain("[r] retry"); + + core.identity.setError(undefined); + core.identity.setGetApiKeyResponse(getResponse()); + await screen.write("r"); + await waitForText(screen.lastFrame, "show the full JSON definition"); + }); + + test("does not open cached detail after a background refresh fails", async () => { + const core = new TestCoreClient(); + core.identity.setGetApiKeyResponse(getResponse()); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity, staleTime: 0 } }, + }); + const screen = renderScreen("/agentcore/identity/api-key-credential-provider/get/api-key-1", { + core, + queryClient, + }); + + await waitForText(screen.lastFrame, "show the full JSON definition"); + core.identity.setError(new Error("background refresh failed")); + await queryClient.invalidateQueries({ + queryKey: ["api-key-credential-provider", "us-east-1", "api-key-1"], + }); + await waitForText(screen.lastFrame, "background refresh failed"); + + await screen.press("return"); + await tick(); + expect(screen.lastFrame()).toContain( + "agentcore → identity → api-key-credential-provider → get → api-key-1", + ); + expect(screen.lastFrame()).not.toContain("→ json"); + }); +}); + +describe("API key credential provider CLI-only mutations", () => { + test.each([ + ["create", "agentcore identity api-key-credential-provider create --help"], + ["update", "agentcore identity api-key-credential-provider update --help"], + ["delete", "agentcore identity api-key-credential-provider delete --help"], + ] as const)("`%s` points the user to the CLI without an SDK call", async (op, command) => { + const core = new TestCoreClient(); + const screen = renderScreen(`/agentcore/identity/api-key-credential-provider/${op}`, { core }); + + await waitForText(screen.lastFrame, "This operation is available via the CLI:"); + expect(screen.lastFrame()).toContain(command); + expect(core.identity.calls).toEqual([]); + }); +}); diff --git a/src/handlers/identity/api-key-credential-provider/create/screen.tsx b/src/handlers/identity/api-key-credential-provider/create/screen.tsx new file mode 100644 index 000000000..8b5d980af --- /dev/null +++ b/src/handlers/identity/api-key-credential-provider/create/screen.tsx @@ -0,0 +1,12 @@ +import { CliOnlyScreen } from "../../../../components/CliOnlyScreen"; +import type { ScreenProps } from "../../../types"; + +export function ApiKeyCredentialProviderCreateScreen(props: ScreenProps) { + return ( + + ); +} diff --git a/src/handlers/identity/api-key-credential-provider/delete/screen.tsx b/src/handlers/identity/api-key-credential-provider/delete/screen.tsx new file mode 100644 index 000000000..9e9c52057 --- /dev/null +++ b/src/handlers/identity/api-key-credential-provider/delete/screen.tsx @@ -0,0 +1,12 @@ +import { CliOnlyScreen } from "../../../../components/CliOnlyScreen"; +import type { ScreenProps } from "../../../types"; + +export function ApiKeyCredentialProviderDeleteScreen(props: ScreenProps) { + return ( + + ); +} diff --git a/src/handlers/identity/api-key-credential-provider/get/screen.tsx b/src/handlers/identity/api-key-credential-provider/get/screen.tsx new file mode 100644 index 000000000..41d76ec39 --- /dev/null +++ b/src/handlers/identity/api-key-credential-provider/get/screen.tsx @@ -0,0 +1,109 @@ +import { useQuery } from "@tanstack/react-query"; +import { Box, Text, useInput } from "ink"; +import { useNavigate, useParams } from "react-router"; +import { JsonDetail } from "../../../../components/JsonDetail"; +import { KeyValueTable } from "../../../../components/KeyValueTable.js"; +import { Layout } from "../../../../components/Layout"; +import { darkTheme } from "../../../../components/ui/_core.js"; +import { Divider } from "../../../../components/ui/divider/Divider.js"; +import { Spinner } from "../../../../components/ui/spinner"; +import type { ScreenProps } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +function useApiKeyProviderDetail({ ctx, core }: ScreenProps, name: string | undefined) { + const opts = coreOptsFromCtx(ctx); + return useQuery({ + queryKey: ["api-key-credential-provider", opts.region, name], + queryFn: () => core.identity.getApiKeyCredentialProvider(name!, opts), + enabled: name !== undefined, + }); +} + +export function ApiKeyCredentialProviderGetScreen(props: ScreenProps) { + const navigate = useNavigate(); + const { name } = useParams(); + const detail = useApiKeyProviderDetail(props, name); + + useInput((input, key) => { + if (key.escape) { + navigate(-1); + return; + } + if (input === "r" && detail.isError) { + void detail.refetch(); + return; + } + if (detail.isError || !detail.data) return; + if (key.return && name) { + navigate( + `/agentcore/identity/api-key-credential-provider/get/${encodeURIComponent(name)}/json`, + ); + } + }); + + return ( + + {detail.isPending ? ( + + ) : detail.isError ? ( + Error: {(detail.error as Error).message} + ) : ( + + + + + + + + + + + {"detail".padEnd(9)} + + show the full JSON definition + + + )} + + ); +} + +export function ApiKeyCredentialProviderGetJsonScreen(props: ScreenProps) { + const { name } = useParams(); + const detail = useApiKeyProviderDetail(props, name); + + return ( + void detail.refetch()} + /> + ); +} diff --git a/src/handlers/identity/api-key-credential-provider/index.tsx b/src/handlers/identity/api-key-credential-provider/index.tsx index 9d975d25e..f82c8a649 100644 --- a/src/handlers/identity/api-key-credential-provider/index.tsx +++ b/src/handlers/identity/api-key-credential-provider/index.tsx @@ -1,7 +1,7 @@ import { Router } from "../../../router"; +import { renderTui } from "../../../tui"; import type { AppIO } from "../../../io"; import type { Core } from "../../types"; -import { createHelpDefault } from "../../help"; import { createCreateApiKeyCredentialProviderHandler } from "./create"; import { createDeleteApiKeyCredentialProviderHandler } from "./delete"; import { createGetApiKeyCredentialProviderHandler } from "./get"; @@ -10,7 +10,7 @@ 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)) + .default(renderTui(core, io)) .handler(createCreateApiKeyCredentialProviderHandler(core, io)) .handler(createGetApiKeyCredentialProviderHandler(core)) .handler(createListApiKeyCredentialProvidersHandler(core)) diff --git a/src/handlers/identity/api-key-credential-provider/list/screen.tsx b/src/handlers/identity/api-key-credential-provider/list/screen.tsx new file mode 100644 index 000000000..d3db7ba41 --- /dev/null +++ b/src/handlers/identity/api-key-credential-provider/list/screen.tsx @@ -0,0 +1,61 @@ +import type { ApiKeyCredentialProviderItem } from "@aws-sdk/client-bedrock-agentcore-control"; +import { useNavigate } from "react-router"; +import { formatTimestamp } from "../../../../components/formatTimestamp"; +import { PaginatedTablePicker } from "../../../../components/PaginatedTablePicker"; +import type { DataTableColumn } from "../../../../components/ui/data-table"; +import type { ScreenProps } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +// identity list APIs cap maxResults at 20 +const MAX_PAGE_SIZE = 20; + +interface ApiKeyProviderRow extends Record { + name: string; + createdAt: string; + updatedAt: string; +} + +export const apiKeyProviderColumns = [ + { key: "name", header: "name", flex: true }, + { key: "createdAt", header: "created UTC", width: 16, render: formatTimestamp }, + { key: "updatedAt", header: "updated UTC", width: 16, render: formatTimestamp }, +] satisfies DataTableColumn[]; + +function toRow(provider: ApiKeyCredentialProviderItem): ApiKeyProviderRow { + return { + name: provider.name ?? "", + createdAt: provider.createdTime?.toISOString() ?? "-", + updatedAt: provider.lastUpdatedTime?.toISOString() ?? "-", + }; +} + +export function ApiKeyCredentialProviderListScreen({ ctx, core }: ScreenProps) { + const opts = coreOptsFromCtx(ctx); + const navigate = useNavigate(); + + return ( + { + const response = await core.identity.listApiKeyCredentialProviders(token, pageSize, opts); + return { + items: response.credentialProviders ?? [], + nextToken: response.nextToken, + }; + }} + toRow={toRow} + columns={apiKeyProviderColumns} + getValue={(row) => row.name} + onSelect={(name) => + navigate(`/agentcore/identity/api-key-credential-provider/get/${encodeURIComponent(name)}`) + } + onBack={() => navigate("/agentcore/identity/api-key-credential-provider")} + maxPageSize={MAX_PAGE_SIZE} + loadingMessage="Loading API key credential providers…" + errorMessage={(error) => `Error: ${error.message}`} + emptyMessage="No API key credential providers found in this Region." + emptyPageMessage="No API key credential providers on this page." + /> + ); +} diff --git a/src/handlers/identity/api-key-credential-provider/screen.tsx b/src/handlers/identity/api-key-credential-provider/screen.tsx new file mode 100644 index 000000000..50a31a17a --- /dev/null +++ b/src/handlers/identity/api-key-credential-provider/screen.tsx @@ -0,0 +1,8 @@ +import { RouterScreen } from "../../../components/RouterScreen"; +import type { ScreenProps } from "../../types"; + +export function ApiKeyCredentialProviderScreen(props: ScreenProps) { + return ( + + ); +} diff --git a/src/handlers/identity/api-key-credential-provider/update/screen.tsx b/src/handlers/identity/api-key-credential-provider/update/screen.tsx new file mode 100644 index 000000000..ae1d2889a --- /dev/null +++ b/src/handlers/identity/api-key-credential-provider/update/screen.tsx @@ -0,0 +1,12 @@ +import { CliOnlyScreen } from "../../../../components/CliOnlyScreen"; +import type { ScreenProps } from "../../../types"; + +export function ApiKeyCredentialProviderUpdateScreen(props: ScreenProps) { + return ( + + ); +} diff --git a/src/handlers/identity/identity.test.tsx b/src/handlers/identity/identity.test.tsx index eb81613f0..5349ef5cf 100644 --- a/src/handlers/identity/identity.test.tsx +++ b/src/handlers/identity/identity.test.tsx @@ -65,9 +65,9 @@ describe("identity command hierarchy", () => { }); test.each(["identity", "identity api-key-credential-provider"])( - "prints help for bare `%s` without an SDK call", + "prints help for `%s --json` without an SDK call", async (command) => { - const stdout = await run(command.split(" ")); + const stdout = await run([...command.split(" "), "--json"]); expect(stdout).toContain(`Usage: agentcore ${command}`); expect(stdout).toContain("Commands:"); @@ -75,6 +75,22 @@ describe("identity command hierarchy", () => { ); }); +describe("api-key-credential-provider TUI dispatch", () => { + test.each([ + ["identity", ["identity"]], + ["api-key-credential-provider", ["identity", "api-key-credential-provider"]], + ["create", ["identity", "api-key-credential-provider", "create"]], + ["get", ["identity", "api-key-credential-provider", "get"]], + ["list", ["identity", "api-key-credential-provider", "list"]], + ["update", ["identity", "api-key-credential-provider", "update"]], + ["delete", ["identity", "api-key-credential-provider", "delete"]], + ] as const)("opens the TUI for a bare `%s`", async (_label, args) => { + await expect(run([...args])).rejects.toThrow( + "interactive mode requires a TTY on stdin and stdout", + ); + }); +}); + describe("api-key-credential-provider CRUDL", () => { test("creates an API key credential provider", async () => { const stdout = await run([ @@ -118,7 +134,7 @@ describe("api-key-credential-provider CRUDL", () => { }); test("lists API key credential providers", async () => { - const stdout = await run(["identity", "api-key-credential-provider", "list"]); + const stdout = await run(["identity", "api-key-credential-provider", "list", "--json"]); matchGolden(FIXTURES, "list.golden.json", stdout); expect(JSON.parse(stdout).credentialProviders).toBeArray(); @@ -201,15 +217,21 @@ describe("api-key-credential-provider CRUDL", () => { ["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/], + [ + "get --json (no name)", + ["identity", "api-key-credential-provider", "get", "--json"], + /--name/, + ], [ "update --name only", ["identity", "api-key-credential-provider", "update", "--name", "x"], /--api-key.*--api-key-secret-reference/, ], - ["delete bare", ["identity", "api-key-credential-provider", "delete"], /--name/], + [ + "delete --json (no name)", + ["identity", "api-key-credential-provider", "delete", "--json"], + /--name/, + ], ] as const)("rejects missing required flags for `%s`", async (_label, args, message) => { expect(run([...args])).rejects.toThrow(message); }); diff --git a/src/handlers/identity/index.tsx b/src/handlers/identity/index.tsx index eed7d0ecd..64011df40 100644 --- a/src/handlers/identity/index.tsx +++ b/src/handlers/identity/index.tsx @@ -1,13 +1,15 @@ +import { withTuiOnEmptyFlagsAndArgs } from "../../middleware"; import { Router } from "../../router"; +import { renderTui } from "../../tui"; import type { AppIO } from "../../io"; import type { Core } from "../types"; import { createApiKeyCredentialProviderHandler } from "./api-key-credential-provider"; import { createOauth2CredentialProviderHandler } from "./oauth2-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)) + .use(withTuiOnEmptyFlagsAndArgs(core, io)) + .default(renderTui(core, io)) .handler(createApiKeyCredentialProviderHandler(core, io)) .handler(createOauth2CredentialProviderHandler(core, io)); } diff --git a/src/handlers/identity/oauth2-credential-provider/create/screen.tsx b/src/handlers/identity/oauth2-credential-provider/create/screen.tsx new file mode 100644 index 000000000..b85c1e474 --- /dev/null +++ b/src/handlers/identity/oauth2-credential-provider/create/screen.tsx @@ -0,0 +1,12 @@ +import { CliOnlyScreen } from "../../../../components/CliOnlyScreen"; +import type { ScreenProps } from "../../../types"; + +export function Oauth2CredentialProviderCreateScreen(props: ScreenProps) { + return ( + + ); +} diff --git a/src/handlers/identity/oauth2-credential-provider/delete/screen.tsx b/src/handlers/identity/oauth2-credential-provider/delete/screen.tsx new file mode 100644 index 000000000..81c5f466c --- /dev/null +++ b/src/handlers/identity/oauth2-credential-provider/delete/screen.tsx @@ -0,0 +1,12 @@ +import { CliOnlyScreen } from "../../../../components/CliOnlyScreen"; +import type { ScreenProps } from "../../../types"; + +export function Oauth2CredentialProviderDeleteScreen(props: ScreenProps) { + return ( + + ); +} diff --git a/src/handlers/identity/oauth2-credential-provider/get/screen.tsx b/src/handlers/identity/oauth2-credential-provider/get/screen.tsx new file mode 100644 index 000000000..3a583d412 --- /dev/null +++ b/src/handlers/identity/oauth2-credential-provider/get/screen.tsx @@ -0,0 +1,111 @@ +import { useQuery } from "@tanstack/react-query"; +import { Box, Text, useInput } from "ink"; +import { useNavigate, useParams } from "react-router"; +import { JsonDetail } from "../../../../components/JsonDetail"; +import { KeyValueTable } from "../../../../components/KeyValueTable.js"; +import { Layout } from "../../../../components/Layout"; +import { darkTheme } from "../../../../components/ui/_core.js"; +import { Divider } from "../../../../components/ui/divider/Divider.js"; +import { Spinner } from "../../../../components/ui/spinner"; +import type { ScreenProps } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +function useOauth2ProviderDetail({ ctx, core }: ScreenProps, name: string | undefined) { + const opts = coreOptsFromCtx(ctx); + return useQuery({ + queryKey: ["oauth2-credential-provider", opts.region, name], + queryFn: () => core.identity.getOauth2CredentialProvider(name!, opts), + enabled: name !== undefined, + }); +} + +export function Oauth2CredentialProviderGetScreen(props: ScreenProps) { + const navigate = useNavigate(); + const { name } = useParams(); + const detail = useOauth2ProviderDetail(props, name); + + useInput((input, key) => { + if (key.escape) { + navigate(-1); + return; + } + if (input === "r" && detail.isError) { + void detail.refetch(); + return; + } + if (detail.isError || !detail.data) return; + if (key.return && name) { + navigate( + `/agentcore/identity/oauth2-credential-provider/get/${encodeURIComponent(name)}/json`, + ); + } + }); + + return ( + + {detail.isPending ? ( + + ) : detail.isError ? ( + Error: {(detail.error as Error).message} + ) : ( + + + + + + + + + + + {"detail".padEnd(9)} + + show the full JSON definition + + + )} + + ); +} + +export function Oauth2CredentialProviderGetJsonScreen(props: ScreenProps) { + const { name } = useParams(); + const detail = useOauth2ProviderDetail(props, name); + + return ( + void detail.refetch()} + /> + ); +} diff --git a/src/handlers/identity/oauth2-credential-provider/index.tsx b/src/handlers/identity/oauth2-credential-provider/index.tsx index a312e2a63..3372cd7b1 100644 --- a/src/handlers/identity/oauth2-credential-provider/index.tsx +++ b/src/handlers/identity/oauth2-credential-provider/index.tsx @@ -1,7 +1,7 @@ import { Router } from "../../../router"; +import { renderTui } from "../../../tui"; import type { AppIO } from "../../../io"; import type { Core } from "../../types"; -import { createHelpDefault } from "../../help"; import { createCreateOauth2CredentialProviderHandler } from "./create"; import { createDeleteOauth2CredentialProviderHandler } from "./delete"; import { createGetOauth2CredentialProviderHandler } from "./get"; @@ -10,7 +10,7 @@ import { createUpdateOauth2CredentialProviderHandler } from "./update"; export function createOauth2CredentialProviderHandler(core: Core, io: AppIO): Router { return new Router("oauth2-credential-provider", "manage OAuth2 credential providers") - .default(createHelpDefault(io)) + .default(renderTui(core, io)) .handler(createCreateOauth2CredentialProviderHandler(core, io)) .handler(createGetOauth2CredentialProviderHandler(core)) .handler(createListOauth2CredentialProvidersHandler(core)) diff --git a/src/handlers/identity/oauth2-credential-provider/list/screen.tsx b/src/handlers/identity/oauth2-credential-provider/list/screen.tsx new file mode 100644 index 000000000..b99dd53f7 --- /dev/null +++ b/src/handlers/identity/oauth2-credential-provider/list/screen.tsx @@ -0,0 +1,64 @@ +import type { Oauth2CredentialProviderItem } from "@aws-sdk/client-bedrock-agentcore-control"; +import { useNavigate } from "react-router"; +import { formatTimestamp } from "../../../../components/formatTimestamp"; +import { PaginatedTablePicker } from "../../../../components/PaginatedTablePicker"; +import type { DataTableColumn } from "../../../../components/ui/data-table"; +import type { ScreenProps } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +// The Identity list APIs cap maxResults at 20 +const MAX_PAGE_SIZE = 20; + +interface Oauth2ProviderRow extends Record { + name: string; + vendor: string; + createdAt: string; + updatedAt: string; +} + +export const oauth2ProviderColumns = [ + { key: "name", header: "name", flex: true }, + { key: "vendor", header: "vendor", width: 18 }, + { key: "createdAt", header: "created UTC", width: 16, render: formatTimestamp }, + { key: "updatedAt", header: "updated UTC", width: 16, render: formatTimestamp }, +] satisfies DataTableColumn[]; + +function toRow(provider: Oauth2CredentialProviderItem): Oauth2ProviderRow { + return { + name: provider.name ?? "", + vendor: provider.credentialProviderVendor ?? "-", + createdAt: provider.createdTime?.toISOString() ?? "-", + updatedAt: provider.lastUpdatedTime?.toISOString() ?? "-", + }; +} + +export function Oauth2CredentialProviderListScreen({ ctx, core }: ScreenProps) { + const opts = coreOptsFromCtx(ctx); + const navigate = useNavigate(); + + return ( + { + const response = await core.identity.listOauth2CredentialProviders(token, pageSize, opts); + return { + items: response.credentialProviders ?? [], + nextToken: response.nextToken, + }; + }} + toRow={toRow} + columns={oauth2ProviderColumns} + getValue={(row) => row.name} + onSelect={(name) => + navigate(`/agentcore/identity/oauth2-credential-provider/get/${encodeURIComponent(name)}`) + } + onBack={() => navigate("/agentcore/identity/oauth2-credential-provider")} + maxPageSize={MAX_PAGE_SIZE} + loadingMessage="Loading OAuth2 credential providers…" + errorMessage={(error) => `Error: ${error.message}`} + emptyMessage="No OAuth2 credential providers found in this Region." + emptyPageMessage="No OAuth2 credential providers on this page." + /> + ); +} diff --git a/src/handlers/identity/oauth2-credential-provider/oauth2.fixture.test.tsx b/src/handlers/identity/oauth2-credential-provider/oauth2.fixture.test.tsx index 0dec53411..02fe61667 100644 --- a/src/handlers/identity/oauth2-credential-provider/oauth2.fixture.test.tsx +++ b/src/handlers/identity/oauth2-credential-provider/oauth2.fixture.test.tsx @@ -97,7 +97,7 @@ describe("oauth2-credential-provider CRUDL", () => { }); test("lists OAuth2 credential providers", async () => { - const stdout = await run(["identity", "oauth2-credential-provider", "list"]); + const stdout = await run(["identity", "oauth2-credential-provider", "list", "--json"]); matchGolden(FIXTURES, "list.golden.json", stdout); expect(JSON.parse(stdout).credentialProviders).toBeArray(); diff --git a/src/handlers/identity/oauth2-credential-provider/oauth2.screen.test.tsx b/src/handlers/identity/oauth2-credential-provider/oauth2.screen.test.tsx new file mode 100644 index 000000000..c6f856f12 --- /dev/null +++ b/src/handlers/identity/oauth2-credential-provider/oauth2.screen.test.tsx @@ -0,0 +1,274 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { + GetOauth2CredentialProviderResponse, + Oauth2CredentialProviderItem, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { QueryClient } from "@tanstack/react-query"; +import { + cleanupScreens, + renderScreen, + TestCoreClient, + tick, + waitFor, + waitForText, +} from "../../../testing"; + +afterEach(cleanupScreens); + +const endpointUrl = "https://identity.test"; + +function providerItem( + overrides: Partial = {}, +): Oauth2CredentialProviderItem { + return { + name: "oauth2-1", + credentialProviderVendor: "CustomOauth2", + credentialProviderArn: + "arn:aws:bedrock-agentcore:us-east-1:123456789012:token-vault/default/oauth2credentialprovider/oauth2-1", + createdTime: new Date("2026-07-19T01:02:03.000Z"), + lastUpdatedTime: new Date("2026-07-20T12:34:56.000Z"), + ...overrides, + }; +} + +function getResponse( + overrides: Partial = {}, +): GetOauth2CredentialProviderResponse { + return { + name: "oauth2-1", + credentialProviderVendor: "CustomOauth2", + credentialProviderArn: + "arn:aws:bedrock-agentcore:us-east-1:123456789012:token-vault/default/oauth2credentialprovider/oauth2-1", + clientSecretArn: { + secretArn: + "arn:aws:secretsmanager:us-east-1:123456789012:secret:bedrock-agentcore-identity!default/oauth2/oauth2-1", + }, + clientSecretSource: "MANAGED", + callbackUrl: "https://bedrock-agentcore.us-east-1.amazonaws.com/identities/oauth2/callback/abc", + createdTime: new Date("2026-07-19T01:02:03.000Z"), + lastUpdatedTime: new Date("2026-07-20T12:34:56.000Z"), + ...overrides, + } as GetOauth2CredentialProviderResponse; +} + +function coreWithProviders(providers: Oauth2CredentialProviderItem[]): TestCoreClient { + const core = new TestCoreClient(); + core.identity.setListOauth2Response({ credentialProviders: providers }); + return core; +} + +describe("OAuth2 credential provider picker", () => { + test("renders provider name, vendor, created, and updated times", async () => { + const core = coreWithProviders([ + providerItem({ + name: "visible-provider", + credentialProviderVendor: "GithubOauth2", + lastUpdatedTime: new Date("2026-07-21T02:03:04.000Z"), + }), + ]); + const screen = renderScreen("/agentcore/identity/oauth2-credential-provider/list", { core }); + + await waitForText(screen.lastFrame, "visible-provider"); + const frame = screen.lastFrame()!; + expect(frame).toContain("name"); + expect(frame).toContain("vendor"); + expect(frame).toContain("GithubOauth2"); + expect(frame).toContain("2026-07-21 02:03"); + }); + + test("calls listOauth2CredentialProviders with exact Core options", async () => { + const core = coreWithProviders([providerItem()]); + renderScreen("/agentcore/identity/oauth2-credential-provider/list", { core, endpointUrl }); + + await waitFor(() => + core.identity.calls.some((call) => call.method === "listOauth2CredentialProviders"), + ); + expect( + core.identity.calls.filter((call) => call.method === "listOauth2CredentialProviders"), + ).toEqual([ + { + method: "listOauth2CredentialProviders", + args: [undefined, expect.any(Number), { region: "us-east-1", endpointUrl }], + }, + ]); + }); + + test("caps maxResults at the service limit of 20 on a tall terminal", async () => { + const core = coreWithProviders([providerItem()]); + const screen = renderScreen("/agentcore/identity/oauth2-credential-provider/list", { core }); + // Terminal taller than the 20-row service cap: page size must still clamp. + await screen.resize(120, 60); + + await waitFor(() => + core.identity.calls.some((call) => call.method === "listOauth2CredentialProviders"), + ); + for (const call of core.identity.calls) { + if (call.method !== "listOauth2CredentialProviders") continue; + expect(call.args[1] as number).toBeLessThanOrEqual(20); + } + }); + + test("shows first-page and later-page empty states", async () => { + const empty = renderScreen("/agentcore/identity/oauth2-credential-provider/list"); + await waitForText(empty.lastFrame, "No OAuth2 credential providers found in this Region."); + empty.unmount(); + + const core = new TestCoreClient(); + core.identity.setListOauth2Response({ + credentialProviders: [providerItem({ name: "page-one" })], + nextToken: "page-2", + }); + core.identity.setListOauth2Response({ credentialProviders: [] }, "page-2"); + const paged = renderScreen("/agentcore/identity/oauth2-credential-provider/list", { core }); + + await waitForText(paged.lastFrame, "page 1 · more →"); + await paged.write("l"); + await waitForText(paged.lastFrame, "No OAuth2 credential providers on this page."); + expect(paged.lastFrame()).not.toContain("No OAuth2 credential providers found in this Region."); + }); + + test("bare get redirects to the picker", async () => { + const core = coreWithProviders([providerItem({ name: "redirected" })]); + const screen = renderScreen("/agentcore/identity/oauth2-credential-provider/get", { core }); + + await waitForText(screen.lastFrame, "redirected"); + expect(core.identity.calls[0]?.method).toBe("listOauth2CredentialProviders"); + }); + + test("selection opens the matching provider detail", async () => { + const name = "oauth2 blue"; + const core = coreWithProviders([providerItem({ name })]); + core.identity.setGetOauth2Response(getResponse({ name })); + const screen = renderScreen("/agentcore/identity/oauth2-credential-provider/list", { core }); + + await waitForText(screen.lastFrame, name); + await screen.press("return"); + await waitForText( + screen.lastFrame, + `agentcore → identity → oauth2-credential-provider → get → ${name}`, + ); + await waitFor(() => + core.identity.calls.some( + (call) => call.method === "getOauth2CredentialProvider" && call.args[0] === name, + ), + ); + }); +}); + +describe("OAuth2 credential provider detail", () => { + test("renders a resource summary with only the detail action", async () => { + const core = new TestCoreClient(); + core.identity.setGetOauth2Response(getResponse()); + const screen = renderScreen("/agentcore/identity/oauth2-credential-provider/get/oauth2-1", { + core, + endpointUrl, + }); + + await waitForText(screen.lastFrame, "show the full JSON definition"); + const frame = screen.lastFrame()!; + expect(frame).toContain("oauth2-1"); + expect(frame).toMatch(/vendor\s+CustomOauth2/); + expect(frame).toMatch(/secretSource\s+MANAGED/); + expect(frame).toContain("arn:aws:bedrock-agentcore"); + // The only action is "detail"; mutations are CLI-only, never surfaced here. + expect(frame).toContain("❯ detail"); + expect( + core.identity.calls.find((call) => call.method === "getOauth2CredentialProvider"), + ).toEqual({ + method: "getOauth2CredentialProvider", + args: ["oauth2-1", { region: "us-east-1", endpointUrl }], + }); + }); + + test("shows a callback URL only when the service provides one", async () => { + const withCallback = new TestCoreClient(); + withCallback.identity.setGetOauth2Response(getResponse()); + const shown = renderScreen("/agentcore/identity/oauth2-credential-provider/get/oauth2-1", { + core: withCallback, + }); + await waitForText(shown.lastFrame, "show the full JSON definition"); + expect(shown.lastFrame()).toContain("callbackUrl"); + shown.unmount(); + + const noCallback = new TestCoreClient(); + noCallback.identity.setGetOauth2Response(getResponse({ callbackUrl: undefined })); + const hidden = renderScreen("/agentcore/identity/oauth2-credential-provider/get/oauth2-1", { + core: noCallback, + }); + await waitForText(hidden.lastFrame, "show the full JSON definition"); + expect(hidden.lastFrame()).not.toContain("callbackUrl"); + }); + + test("opens the complete provider JSON", async () => { + const core = new TestCoreClient(); + core.identity.setGetOauth2Response(getResponse()); + const screen = renderScreen("/agentcore/identity/oauth2-credential-provider/get/oauth2-1", { + core, + }); + + await waitForText(screen.lastFrame, "show the full JSON definition"); + await screen.press("return"); + await waitForText( + screen.lastFrame, + "agentcore → identity → oauth2-credential-provider → get → oauth2-1 → json", + ); + expect(screen.lastFrame()).toContain('"credentialProviderVendor"'); + }); + + test("retries a failed detail query", async () => { + const core = new TestCoreClient(); + core.identity.setError(new Error("provider unavailable")); + const screen = renderScreen("/agentcore/identity/oauth2-credential-provider/get/oauth2-1", { + core, + }); + + await waitForText(screen.lastFrame, "provider unavailable"); + expect(screen.lastFrame()).toContain("[r] retry"); + + core.identity.setError(undefined); + core.identity.setGetOauth2Response(getResponse()); + await screen.write("r"); + await waitForText(screen.lastFrame, "show the full JSON definition"); + }); + + test("does not open cached detail after a background refresh fails", async () => { + const core = new TestCoreClient(); + core.identity.setGetOauth2Response(getResponse()); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity, staleTime: 0 } }, + }); + const screen = renderScreen("/agentcore/identity/oauth2-credential-provider/get/oauth2-1", { + core, + queryClient, + }); + + await waitForText(screen.lastFrame, "show the full JSON definition"); + core.identity.setError(new Error("background refresh failed")); + await queryClient.invalidateQueries({ + queryKey: ["oauth2-credential-provider", "us-east-1", "oauth2-1"], + }); + await waitForText(screen.lastFrame, "background refresh failed"); + + await screen.press("return"); + await tick(); + expect(screen.lastFrame()).toContain( + "agentcore → identity → oauth2-credential-provider → get → oauth2-1", + ); + expect(screen.lastFrame()).not.toContain("→ json"); + }); +}); + +describe("OAuth2 credential provider CLI-only mutations", () => { + test.each([ + ["create", "agentcore identity oauth2-credential-provider create --help"], + ["update", "agentcore identity oauth2-credential-provider update --help"], + ["delete", "agentcore identity oauth2-credential-provider delete --help"], + ] as const)("`%s` points the user to the CLI without an SDK call", async (op, command) => { + const core = new TestCoreClient(); + const screen = renderScreen(`/agentcore/identity/oauth2-credential-provider/${op}`, { core }); + + await waitForText(screen.lastFrame, "This operation is available via the CLI:"); + expect(screen.lastFrame()).toContain(command); + expect(core.identity.calls).toEqual([]); + }); +}); diff --git a/src/handlers/identity/oauth2-credential-provider/oauth2.test.tsx b/src/handlers/identity/oauth2-credential-provider/oauth2.test.tsx index a6b094674..90f8984eb 100644 --- a/src/handlers/identity/oauth2-credential-provider/oauth2.test.tsx +++ b/src/handlers/identity/oauth2-credential-provider/oauth2.test.tsx @@ -100,8 +100,8 @@ describe("oauth2-credential-provider command hierarchy", () => { ]); }); - test("prints help for bare `identity oauth2-credential-provider` without a Core call", async () => { - const { core, stdout } = await run(["identity", "oauth2-credential-provider"]); + test("prints help for `identity oauth2-credential-provider --json` without an SDK call", async () => { + const { core, stdout } = await run(["identity", "oauth2-credential-provider", "--json"]); expect(stdout).toContain("Usage: agentcore identity oauth2-credential-provider"); expect(stdout).toContain("Commands:"); @@ -109,6 +109,21 @@ describe("oauth2-credential-provider command hierarchy", () => { }); }); +describe("oauth2-credential-provider TUI dispatch", () => { + test.each([ + ["oauth2-credential-provider", ["identity", "oauth2-credential-provider"]], + ["create", ["identity", "oauth2-credential-provider", "create"]], + ["get", ["identity", "oauth2-credential-provider", "get"]], + ["list", ["identity", "oauth2-credential-provider", "list"]], + ["update", ["identity", "oauth2-credential-provider", "update"]], + ["delete", ["identity", "oauth2-credential-provider", "delete"]], + ] as const)("opens the TUI for a bare `%s`", async (_label, args) => { + await expect(run([...args])).rejects.toThrow( + "interactive mode requires a TTY on stdin and stdout", + ); + }); +}); + describe("oauth2-credential-provider flag validation", () => { test.each([ [ @@ -129,10 +144,12 @@ describe("oauth2-credential-provider flag validation", () => { ], /--client-secret.*--client-secret-reference/, ], - ["create bare", ["identity", "oauth2-credential-provider", "create"], /--name/], - ["get bare", ["identity", "oauth2-credential-provider", "get"], /--name/], - ["update bare", ["identity", "oauth2-credential-provider", "update"], /--name/], - ["delete bare", ["identity", "oauth2-credential-provider", "delete"], /--name/], + ["get --json (no name)", ["identity", "oauth2-credential-provider", "get", "--json"], /--name/], + [ + "delete --json (no name)", + ["identity", "oauth2-credential-provider", "delete", "--json"], + /--name/, + ], ] as const)("rejects missing required flags for `%s`", async (_label, args, message) => { expect(run([...args])).rejects.toThrow(message); }); diff --git a/src/handlers/identity/oauth2-credential-provider/screen.tsx b/src/handlers/identity/oauth2-credential-provider/screen.tsx new file mode 100644 index 000000000..8b3b84f16 --- /dev/null +++ b/src/handlers/identity/oauth2-credential-provider/screen.tsx @@ -0,0 +1,6 @@ +import { RouterScreen } from "../../../components/RouterScreen"; +import type { ScreenProps } from "../../types"; + +export function Oauth2CredentialProviderScreen(props: ScreenProps) { + return ; +} diff --git a/src/handlers/identity/oauth2-credential-provider/update/screen.tsx b/src/handlers/identity/oauth2-credential-provider/update/screen.tsx new file mode 100644 index 000000000..0f7c7dc12 --- /dev/null +++ b/src/handlers/identity/oauth2-credential-provider/update/screen.tsx @@ -0,0 +1,12 @@ +import { CliOnlyScreen } from "../../../../components/CliOnlyScreen"; +import type { ScreenProps } from "../../../types"; + +export function Oauth2CredentialProviderUpdateScreen(props: ScreenProps) { + return ( + + ); +} diff --git a/src/handlers/identity/screen.tsx b/src/handlers/identity/screen.tsx new file mode 100644 index 000000000..a5e36b67c --- /dev/null +++ b/src/handlers/identity/screen.tsx @@ -0,0 +1,6 @@ +import { RouterScreen } from "../../components/RouterScreen"; +import type { ScreenProps } from "../types"; + +export function IdentityScreen(props: ScreenProps) { + return ; +} diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index 5328fd5d3..5453a9aa6 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -920,12 +920,35 @@ type TestCoreClientOptions = { logger?: Logger; }; -class TestIdentityClient implements CoreIdentityClient { +export class TestIdentityClient implements CoreIdentityClient { readonly calls: RecordedCall[] = []; + private getApiKeyResponse: GetApiKeyCredentialProviderResponse = DEFAULT_GET_API_KEY_RESPONSE; + private listApiKeyResponses = new Map< + string | undefined, + ListApiKeyCredentialProvidersResponse + >(); private getOauth2Response: GetOauth2CredentialProviderResponse = DEFAULT_GET_OAUTH2_RESPONSE; + private listOauth2Responses = new Map< + string | undefined, + ListOauth2CredentialProvidersResponse + >(); private updateOauth2Response: UpdateOauth2CredentialProviderResponse = DEFAULT_UPDATE_OAUTH2_RESPONSE; + private error?: Error; + + setGetApiKeyResponse(response: GetApiKeyCredentialProviderResponse): this { + this.getApiKeyResponse = response; + return this; + } + + setListApiKeyResponse( + response: ListApiKeyCredentialProvidersResponse, + forNextToken?: string, + ): this { + this.listApiKeyResponses.set(forNextToken, response); + return this; + } setGetOauth2Response(response: GetOauth2CredentialProviderResponse): this { this.getOauth2Response = response; @@ -937,11 +960,25 @@ class TestIdentityClient implements CoreIdentityClient { return this; } + setListOauth2Response( + response: ListOauth2CredentialProvidersResponse, + forNextToken?: string, + ): this { + this.listOauth2Responses.set(forNextToken, response); + return this; + } + + setError(error: Error | undefined): this { + this.error = error; + return this; + } + async createApiKeyCredentialProvider( input: CreateApiKeyCredentialProviderInput, options: CoreOptions, ): Promise { this.calls.push({ method: "createApiKeyCredentialProvider", args: [input, options] }); + if (this.error) throw this.error; return DEFAULT_CREATE_API_KEY_RESPONSE; } @@ -950,7 +987,8 @@ class TestIdentityClient implements CoreIdentityClient { options: CoreOptions, ): Promise { this.calls.push({ method: "getApiKeyCredentialProvider", args: [name, options] }); - return DEFAULT_GET_API_KEY_RESPONSE; + if (this.error) throw this.error; + return this.getApiKeyResponse; } async listApiKeyCredentialProviders( @@ -962,7 +1000,12 @@ class TestIdentityClient implements CoreIdentityClient { method: "listApiKeyCredentialProviders", args: [nextToken, maxResults, options], }); - return DEFAULT_LIST_API_KEYS_RESPONSE; + if (this.error) throw this.error; + return ( + this.listApiKeyResponses.get(nextToken) ?? + this.listApiKeyResponses.get(undefined) ?? + DEFAULT_LIST_API_KEYS_RESPONSE + ); } async updateApiKeyCredentialProvider( @@ -970,6 +1013,7 @@ class TestIdentityClient implements CoreIdentityClient { options: CoreOptions, ): Promise { this.calls.push({ method: "updateApiKeyCredentialProvider", args: [input, options] }); + if (this.error) throw this.error; return DEFAULT_UPDATE_API_KEY_RESPONSE; } @@ -978,6 +1022,7 @@ class TestIdentityClient implements CoreIdentityClient { options: CoreOptions, ): Promise { this.calls.push({ method: "deleteApiKeyCredentialProvider", args: [name, options] }); + if (this.error) throw this.error; return DEFAULT_DELETE_API_KEY_RESPONSE; } @@ -986,6 +1031,7 @@ class TestIdentityClient implements CoreIdentityClient { options: CoreOptions, ): Promise { this.calls.push({ method: "createOauth2CredentialProvider", args: [input, options] }); + if (this.error) throw this.error; return DEFAULT_CREATE_OAUTH2_RESPONSE; } @@ -994,6 +1040,7 @@ class TestIdentityClient implements CoreIdentityClient { options: CoreOptions, ): Promise { this.calls.push({ method: "getOauth2CredentialProvider", args: [name, options] }); + if (this.error) throw this.error; return this.getOauth2Response; } @@ -1006,7 +1053,12 @@ class TestIdentityClient implements CoreIdentityClient { method: "listOauth2CredentialProviders", args: [nextToken, maxResults, options], }); - return DEFAULT_LIST_OAUTH2_RESPONSE; + if (this.error) throw this.error; + return ( + this.listOauth2Responses.get(nextToken) ?? + this.listOauth2Responses.get(undefined) ?? + DEFAULT_LIST_OAUTH2_RESPONSE + ); } async updateOauth2CredentialProvider( @@ -1014,6 +1066,7 @@ class TestIdentityClient implements CoreIdentityClient { options: CoreOptions, ): Promise { this.calls.push({ method: "updateOauth2CredentialProvider", args: [input, options] }); + if (this.error) throw this.error; return this.updateOauth2Response; } @@ -1022,6 +1075,7 @@ class TestIdentityClient implements CoreIdentityClient { options: CoreOptions, ): Promise { this.calls.push({ method: "deleteOauth2CredentialProvider", args: [name, options] }); + if (this.error) throw this.error; return DEFAULT_DELETE_OAUTH2_RESPONSE; } } From d9348dcdfdecb94fdbef712f01fe16a53df725e8 Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Thu, 6 Aug 2026 14:36:33 -0400 Subject: [PATCH 2/2] chore: use ResourceDetail screen and display failure reason in details --- README.md | 3 +- src/components/CliOnlyScreen.tsx | 39 -------- src/components/Root.tsx | 30 ------ .../apikey.screen.test.tsx | 28 +++--- .../create/screen.tsx | 12 --- .../delete/screen.tsx | 12 --- .../get/screen.tsx | 92 ++++++----------- .../api-key-credential-provider/screen.tsx | 8 +- .../update/screen.tsx | 12 --- .../create/screen.tsx | 12 --- .../delete/screen.tsx | 12 --- .../oauth2-credential-provider/get/screen.tsx | 98 +++++++------------ .../oauth2.screen.test.tsx | 46 ++++++--- .../oauth2-credential-provider/screen.tsx | 10 +- .../update/screen.tsx | 12 --- 15 files changed, 125 insertions(+), 301 deletions(-) delete mode 100644 src/components/CliOnlyScreen.tsx delete mode 100644 src/handlers/identity/api-key-credential-provider/create/screen.tsx delete mode 100644 src/handlers/identity/api-key-credential-provider/delete/screen.tsx delete mode 100644 src/handlers/identity/api-key-credential-provider/update/screen.tsx delete mode 100644 src/handlers/identity/oauth2-credential-provider/create/screen.tsx delete mode 100644 src/handlers/identity/oauth2-credential-provider/delete/screen.tsx delete mode 100644 src/handlers/identity/oauth2-credential-provider/update/screen.tsx diff --git a/README.md b/README.md index 99113fb0d..6314094eb 100644 --- a/README.md +++ b/README.md @@ -330,8 +330,7 @@ agentcore memory record The Identity TUI is read-only: bare `identity` branches and the `get`/`list` leaves open interactive menus and detail views. Mutations (`create`, `update`, -`delete`) are intentionally CLI-only — selecting one in the TUI points you at the -equivalent CLI command rather than hosting a write flow. +`delete`) remain available through the CLI and are omitted from the TUI menus. ```bash agentcore identity diff --git a/src/components/CliOnlyScreen.tsx b/src/components/CliOnlyScreen.tsx deleted file mode 100644 index 099ed3c0a..000000000 --- a/src/components/CliOnlyScreen.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { Box, Text, useInput } from "ink"; -import { useNavigate } from "react-router"; -import { Layout } from "./Layout"; -import { darkTheme } from "./ui/_core.js"; -import type { ScreenProps } from "../handlers/types"; - -export interface CliOnlyScreenProps extends ScreenProps { - breadcrumb: string[]; - // command is the CLI invocation shown to the user - // operation, e.g. "agentcore identity api-key-credential-provider create --help". - command: string; -} - -// CliOnlyScreen is the supported TUI experience for operations that are -// intentionally CLI-only -export function CliOnlyScreen({ breadcrumb, command }: CliOnlyScreenProps) { - const navigate = useNavigate(); - - useInput((_input, key) => { - if (key.escape) navigate(-1); - }); - - return ( - - - This operation is available via the CLI: - - {command} - - - - ); -} diff --git a/src/components/Root.tsx b/src/components/Root.tsx index fcedbcd4d..39eb95534 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -55,18 +55,12 @@ import { ApiKeyCredentialProviderGetScreen, ApiKeyCredentialProviderGetJsonScreen, } from "../handlers/identity/api-key-credential-provider/get/screen.tsx"; -import { ApiKeyCredentialProviderCreateScreen } from "../handlers/identity/api-key-credential-provider/create/screen.tsx"; -import { ApiKeyCredentialProviderUpdateScreen } from "../handlers/identity/api-key-credential-provider/update/screen.tsx"; -import { ApiKeyCredentialProviderDeleteScreen } from "../handlers/identity/api-key-credential-provider/delete/screen.tsx"; import { Oauth2CredentialProviderScreen } from "../handlers/identity/oauth2-credential-provider/screen.tsx"; import { Oauth2CredentialProviderListScreen } from "../handlers/identity/oauth2-credential-provider/list/screen.tsx"; import { Oauth2CredentialProviderGetScreen, Oauth2CredentialProviderGetJsonScreen, } from "../handlers/identity/oauth2-credential-provider/get/screen.tsx"; -import { Oauth2CredentialProviderCreateScreen } from "../handlers/identity/oauth2-credential-provider/create/screen.tsx"; -import { Oauth2CredentialProviderUpdateScreen } from "../handlers/identity/oauth2-credential-provider/update/screen.tsx"; -import { Oauth2CredentialProviderDeleteScreen } from "../handlers/identity/oauth2-credential-provider/delete/screen.tsx"; import { RootScreen, HelpScreen } from "../handlers/screen.tsx"; import type { Context } from "../router"; @@ -415,18 +409,6 @@ export function Root({ path, ctx, core, queryClient }: RootProps) { path="agentcore/identity/api-key-credential-provider/get/:name/json" element={} /> - } - /> - } - /> - } - /> } @@ -447,18 +429,6 @@ export function Root({ path, ctx, core, queryClient }: RootProps) { path="agentcore/identity/oauth2-credential-provider/get/:name/json" element={} /> - } - /> - } - /> - } - /> } /> diff --git a/src/handlers/identity/api-key-credential-provider/apikey.screen.test.tsx b/src/handlers/identity/api-key-credential-provider/apikey.screen.test.tsx index dcb77f2f9..13676a053 100644 --- a/src/handlers/identity/api-key-credential-provider/apikey.screen.test.tsx +++ b/src/handlers/identity/api-key-credential-provider/apikey.screen.test.tsx @@ -54,6 +54,19 @@ function coreWithProviders(providers: ApiKeyCredentialProviderItem[]): TestCoreC return core; } +describe("API key credential provider menu", () => { + test("offers only the read-only commands", async () => { + const screen = renderScreen("/agentcore/identity/api-key-credential-provider"); + + await waitForText(screen.lastFrame, "get an API key credential provider"); + const frame = screen.lastFrame()!; + expect(frame).toContain("list"); + expect(frame).not.toContain("create"); + expect(frame).not.toContain("update"); + expect(frame).not.toContain("delete"); + }); +}); + describe("API key credential provider picker", () => { test("renders provider name, created, and updated times", async () => { const core = coreWithProviders([ @@ -236,18 +249,3 @@ describe("API key credential provider detail", () => { expect(screen.lastFrame()).not.toContain("→ json"); }); }); - -describe("API key credential provider CLI-only mutations", () => { - test.each([ - ["create", "agentcore identity api-key-credential-provider create --help"], - ["update", "agentcore identity api-key-credential-provider update --help"], - ["delete", "agentcore identity api-key-credential-provider delete --help"], - ] as const)("`%s` points the user to the CLI without an SDK call", async (op, command) => { - const core = new TestCoreClient(); - const screen = renderScreen(`/agentcore/identity/api-key-credential-provider/${op}`, { core }); - - await waitForText(screen.lastFrame, "This operation is available via the CLI:"); - expect(screen.lastFrame()).toContain(command); - expect(core.identity.calls).toEqual([]); - }); -}); diff --git a/src/handlers/identity/api-key-credential-provider/create/screen.tsx b/src/handlers/identity/api-key-credential-provider/create/screen.tsx deleted file mode 100644 index 8b5d980af..000000000 --- a/src/handlers/identity/api-key-credential-provider/create/screen.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { CliOnlyScreen } from "../../../../components/CliOnlyScreen"; -import type { ScreenProps } from "../../../types"; - -export function ApiKeyCredentialProviderCreateScreen(props: ScreenProps) { - return ( - - ); -} diff --git a/src/handlers/identity/api-key-credential-provider/delete/screen.tsx b/src/handlers/identity/api-key-credential-provider/delete/screen.tsx deleted file mode 100644 index 9e9c52057..000000000 --- a/src/handlers/identity/api-key-credential-provider/delete/screen.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { CliOnlyScreen } from "../../../../components/CliOnlyScreen"; -import type { ScreenProps } from "../../../types"; - -export function ApiKeyCredentialProviderDeleteScreen(props: ScreenProps) { - return ( - - ); -} diff --git a/src/handlers/identity/api-key-credential-provider/get/screen.tsx b/src/handlers/identity/api-key-credential-provider/get/screen.tsx index 41d76ec39..277aff9fd 100644 --- a/src/handlers/identity/api-key-credential-provider/get/screen.tsx +++ b/src/handlers/identity/api-key-credential-provider/get/screen.tsx @@ -1,12 +1,7 @@ import { useQuery } from "@tanstack/react-query"; -import { Box, Text, useInput } from "ink"; import { useNavigate, useParams } from "react-router"; import { JsonDetail } from "../../../../components/JsonDetail"; -import { KeyValueTable } from "../../../../components/KeyValueTable.js"; -import { Layout } from "../../../../components/Layout"; -import { darkTheme } from "../../../../components/ui/_core.js"; -import { Divider } from "../../../../components/ui/divider/Divider.js"; -import { Spinner } from "../../../../components/ui/spinner"; +import { ResourceDetailScreen } from "../../../../components/ResourceDetailScreen"; import type { ScreenProps } from "../../../types"; import { coreOptsFromCtx } from "../../../utils"; @@ -24,64 +19,37 @@ export function ApiKeyCredentialProviderGetScreen(props: ScreenProps) { const { name } = useParams(); const detail = useApiKeyProviderDetail(props, name); - useInput((input, key) => { - if (key.escape) { - navigate(-1); - return; - } - if (input === "r" && detail.isError) { - void detail.refetch(); - return; - } - if (detail.isError || !detail.data) return; - if (key.return && name) { - navigate( - `/agentcore/identity/api-key-credential-provider/get/${encodeURIComponent(name)}/json`, - ); - } - }); - return ( - - {detail.isPending ? ( - - ) : detail.isError ? ( - Error: {(detail.error as Error).message} - ) : ( - - - - - - - - - - - {"detail".padEnd(9)} - - show the full JSON definition - - - )} - + isPending={detail.isPending} + error={detail.isError ? (detail.error as Error) : null} + items={{ + name: detail.data?.name ?? "", + secretSource: detail.data?.apiKeySecretSource ?? "-", + secretArn: detail.data?.apiKeySecretArn?.secretArn ?? "-", + createdAt: detail.data?.createdTime?.toISOString() ?? "-", + updatedAt: detail.data?.lastUpdatedTime?.toISOString() ?? "-", + arn: detail.data?.credentialProviderArn ?? "", + }} + actions={ + name && detail.data + ? [ + { + name: "detail", + description: "show the full JSON definition", + onSelect: () => + navigate( + `/agentcore/identity/api-key-credential-provider/get/${encodeURIComponent(name)}/json`, + ), + }, + ] + : [] + } + loadingLabel="Loading API key credential provider…" + onRetry={() => void detail.refetch()} + selectLabel="open detail" + /> ); } diff --git a/src/handlers/identity/api-key-credential-provider/screen.tsx b/src/handlers/identity/api-key-credential-provider/screen.tsx index 50a31a17a..806a39107 100644 --- a/src/handlers/identity/api-key-credential-provider/screen.tsx +++ b/src/handlers/identity/api-key-credential-provider/screen.tsx @@ -1,8 +1,14 @@ import { RouterScreen } from "../../../components/RouterScreen"; import type { ScreenProps } from "../../types"; +const OMIT = ["create", "update", "delete"]; + export function ApiKeyCredentialProviderScreen(props: ScreenProps) { return ( - + ); } diff --git a/src/handlers/identity/api-key-credential-provider/update/screen.tsx b/src/handlers/identity/api-key-credential-provider/update/screen.tsx deleted file mode 100644 index ae1d2889a..000000000 --- a/src/handlers/identity/api-key-credential-provider/update/screen.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { CliOnlyScreen } from "../../../../components/CliOnlyScreen"; -import type { ScreenProps } from "../../../types"; - -export function ApiKeyCredentialProviderUpdateScreen(props: ScreenProps) { - return ( - - ); -} diff --git a/src/handlers/identity/oauth2-credential-provider/create/screen.tsx b/src/handlers/identity/oauth2-credential-provider/create/screen.tsx deleted file mode 100644 index b85c1e474..000000000 --- a/src/handlers/identity/oauth2-credential-provider/create/screen.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { CliOnlyScreen } from "../../../../components/CliOnlyScreen"; -import type { ScreenProps } from "../../../types"; - -export function Oauth2CredentialProviderCreateScreen(props: ScreenProps) { - return ( - - ); -} diff --git a/src/handlers/identity/oauth2-credential-provider/delete/screen.tsx b/src/handlers/identity/oauth2-credential-provider/delete/screen.tsx deleted file mode 100644 index 81c5f466c..000000000 --- a/src/handlers/identity/oauth2-credential-provider/delete/screen.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { CliOnlyScreen } from "../../../../components/CliOnlyScreen"; -import type { ScreenProps } from "../../../types"; - -export function Oauth2CredentialProviderDeleteScreen(props: ScreenProps) { - return ( - - ); -} diff --git a/src/handlers/identity/oauth2-credential-provider/get/screen.tsx b/src/handlers/identity/oauth2-credential-provider/get/screen.tsx index 3a583d412..badeae83b 100644 --- a/src/handlers/identity/oauth2-credential-provider/get/screen.tsx +++ b/src/handlers/identity/oauth2-credential-provider/get/screen.tsx @@ -1,12 +1,7 @@ import { useQuery } from "@tanstack/react-query"; -import { Box, Text, useInput } from "ink"; import { useNavigate, useParams } from "react-router"; import { JsonDetail } from "../../../../components/JsonDetail"; -import { KeyValueTable } from "../../../../components/KeyValueTable.js"; -import { Layout } from "../../../../components/Layout"; -import { darkTheme } from "../../../../components/ui/_core.js"; -import { Divider } from "../../../../components/ui/divider/Divider.js"; -import { Spinner } from "../../../../components/ui/spinner"; +import { ResourceDetailScreen } from "../../../../components/ResourceDetailScreen"; import type { ScreenProps } from "../../../types"; import { coreOptsFromCtx } from "../../../utils"; @@ -24,66 +19,41 @@ export function Oauth2CredentialProviderGetScreen(props: ScreenProps) { const { name } = useParams(); const detail = useOauth2ProviderDetail(props, name); - useInput((input, key) => { - if (key.escape) { - navigate(-1); - return; - } - if (input === "r" && detail.isError) { - void detail.refetch(); - return; - } - if (detail.isError || !detail.data) return; - if (key.return && name) { - navigate( - `/agentcore/identity/oauth2-credential-provider/get/${encodeURIComponent(name)}/json`, - ); - } - }); - return ( - - {detail.isPending ? ( - - ) : detail.isError ? ( - Error: {(detail.error as Error).message} - ) : ( - - - - - - - - - - - {"detail".padEnd(9)} - - show the full JSON definition - - - )} - + isPending={detail.isPending} + error={detail.isError ? (detail.error as Error) : null} + items={{ + name: detail.data?.name ?? "", + vendor: detail.data?.credentialProviderVendor ?? "-", + status: detail.data?.status ?? "-", + ...(detail.data?.failureReason ? { failureReason: detail.data.failureReason } : {}), + secretSource: detail.data?.clientSecretSource ?? "-", + clientSecretArn: detail.data?.clientSecretArn?.secretArn ?? "-", + ...(detail.data?.callbackUrl ? { callbackUrl: detail.data.callbackUrl } : {}), + createdAt: detail.data?.createdTime?.toISOString() ?? "-", + updatedAt: detail.data?.lastUpdatedTime?.toISOString() ?? "-", + arn: detail.data?.credentialProviderArn ?? "", + }} + actions={ + name && detail.data + ? [ + { + name: "detail", + description: "show the full JSON definition", + onSelect: () => + navigate( + `/agentcore/identity/oauth2-credential-provider/get/${encodeURIComponent(name)}/json`, + ), + }, + ] + : [] + } + loadingLabel="Loading OAuth2 credential provider…" + onRetry={() => void detail.refetch()} + selectLabel="open detail" + /> ); } diff --git a/src/handlers/identity/oauth2-credential-provider/oauth2.screen.test.tsx b/src/handlers/identity/oauth2-credential-provider/oauth2.screen.test.tsx index c6f856f12..2bf9d4b05 100644 --- a/src/handlers/identity/oauth2-credential-provider/oauth2.screen.test.tsx +++ b/src/handlers/identity/oauth2-credential-provider/oauth2.screen.test.tsx @@ -57,6 +57,19 @@ function coreWithProviders(providers: Oauth2CredentialProviderItem[]): TestCoreC return core; } +describe("OAuth2 credential provider menu", () => { + test("offers only the read-only commands", async () => { + const screen = renderScreen("/agentcore/identity/oauth2-credential-provider"); + + await waitForText(screen.lastFrame, "get an OAuth2 credential provider"); + const frame = screen.lastFrame()!; + expect(frame).toContain("list"); + expect(frame).not.toContain("create"); + expect(frame).not.toContain("update"); + expect(frame).not.toContain("delete"); + }); +}); + describe("OAuth2 credential provider picker", () => { test("renders provider name, vendor, created, and updated times", async () => { const core = coreWithProviders([ @@ -199,6 +212,24 @@ describe("OAuth2 credential provider detail", () => { expect(hidden.lastFrame()).not.toContain("callbackUrl"); }); + test("shows status and failure reason for a failed provider", async () => { + const core = new TestCoreClient(); + core.identity.setGetOauth2Response( + getResponse({ + status: "CREATE_FAILED", + failureReason: "authorization server metadata could not be loaded", + }), + ); + const screen = renderScreen("/agentcore/identity/oauth2-credential-provider/get/oauth2-1", { + core, + }); + + await waitForText(screen.lastFrame, "authorization server metadata could not be loaded"); + const frame = screen.lastFrame()!; + expect(frame).toMatch(/status\s+CREATE_FAILED/); + expect(frame).toContain("failureReason"); + }); + test("opens the complete provider JSON", async () => { const core = new TestCoreClient(); core.identity.setGetOauth2Response(getResponse()); @@ -257,18 +288,3 @@ describe("OAuth2 credential provider detail", () => { expect(screen.lastFrame()).not.toContain("→ json"); }); }); - -describe("OAuth2 credential provider CLI-only mutations", () => { - test.each([ - ["create", "agentcore identity oauth2-credential-provider create --help"], - ["update", "agentcore identity oauth2-credential-provider update --help"], - ["delete", "agentcore identity oauth2-credential-provider delete --help"], - ] as const)("`%s` points the user to the CLI without an SDK call", async (op, command) => { - const core = new TestCoreClient(); - const screen = renderScreen(`/agentcore/identity/oauth2-credential-provider/${op}`, { core }); - - await waitForText(screen.lastFrame, "This operation is available via the CLI:"); - expect(screen.lastFrame()).toContain(command); - expect(core.identity.calls).toEqual([]); - }); -}); diff --git a/src/handlers/identity/oauth2-credential-provider/screen.tsx b/src/handlers/identity/oauth2-credential-provider/screen.tsx index 8b3b84f16..978532c4c 100644 --- a/src/handlers/identity/oauth2-credential-provider/screen.tsx +++ b/src/handlers/identity/oauth2-credential-provider/screen.tsx @@ -1,6 +1,14 @@ import { RouterScreen } from "../../../components/RouterScreen"; import type { ScreenProps } from "../../types"; +const OMIT = ["create", "update", "delete"]; + export function Oauth2CredentialProviderScreen(props: ScreenProps) { - return ; + return ( + + ); } diff --git a/src/handlers/identity/oauth2-credential-provider/update/screen.tsx b/src/handlers/identity/oauth2-credential-provider/update/screen.tsx deleted file mode 100644 index 0f7c7dc12..000000000 --- a/src/handlers/identity/oauth2-credential-provider/update/screen.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { CliOnlyScreen } from "../../../../components/CliOnlyScreen"; -import type { ScreenProps } from "../../../types"; - -export function Oauth2CredentialProviderUpdateScreen(props: ScreenProps) { - return ( - - ); -}