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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 35 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ agentcore # interactive TUI
├── gateway # inspect AgentCore Gateways
│ ├── get # get a Gateway by id
│ ├── list # list Gateways (server-side paginated)
│ ├── invoke # send a headless request through a Gateway
│ ├── invoke # invoke a Gateway headlessly or in a persistent console
│ ├── target
│ │ ├── get # get a Target under a Gateway
│ │ └── list # list Targets under a Gateway
Expand Down Expand Up @@ -161,6 +161,7 @@ agentcore memory record list --memory <memoryId> --namespace <namespace> --max-r
agentcore gateway get --id <gatewayId>
agentcore gateway list --max-results 20
agentcore gateway invoke --id <gatewayId> --payload file://request.json
agentcore gateway invoke --id <gatewayId> # open the persistent JSON console
agentcore gateway target get --gateway-id <gatewayId> --target-id <targetId>
agentcore gateway target list --gateway-id <gatewayId> --max-results 20
agentcore gateway connector get --gateway-id <gatewayId> --id <targetId>
Expand Down Expand Up @@ -216,9 +217,10 @@ Source-aware values: any field flag documented as such accepts the value inline,

### Invoke a Gateway

Gateway Invoke is a headless, project-independent HTTP request command. It gets
the Gateway by ID, uses the returned HTTPS origin, selects authentication from
the Gateway's authorizer, and preserves the request and response bodies.
Gateway Invoke is a project-independent HTTP request command with headless and
interactive modes. It gets the Gateway by ID, uses the returned HTTPS origin,
selects authentication from the Gateway's authorizer, and preserves the request
and response bodies.

```bash
# MCP Gateway: use the exact gatewayUrl returned by GetGateway.
Expand Down Expand Up @@ -268,9 +270,35 @@ stderr in raw and file modes. Redirects are returned without being followed.
Non-2xx response bodies use the selected output mode before the command exits
with a failure status.

Gateway Invoke V1 has no TUI, required request-type selector, tool/model
discovery command, or protocol-specific payload builder. Callers provide the
Gateway-relative route and protocol payload directly.
Without `--payload`, Gateway Invoke opens a persistent POST JSON console. Bare
invoke opens the Gateway picker, while `--id` opens the selected Gateway
directly. `--path`, `--session-id`, MCP session flags, `--header`, and
`--bearer-token` seed the console. Interactive bearer tokens may be inline or
`file://` sources, but not stdin. Explicit headless-only flags such as
`--method`, `--accept`, `--content-type`, `--output-file`, or `--json` keep the
command headless.

The console generates and displays a Runtime session ID, adopts returned Runtime
and MCP sessions, and streams textual responses as they arrive. An empty path
uses the exact `gatewayUrl`; `Ctrl+P` edits the raw Gateway-relative path and
`Ctrl+T` switches Gateways. Switching Gateways clears request context, while
changing paths preserves the draft and Gateway authentication but starts fresh
sessions.

| Shortcut | Action |
| ------------- | -------------------------------------------- |
| `Enter` | Send the JSON request |
| `Shift+Enter` | Insert a newline |
| `Ctrl+P` | Edit the Gateway-relative path |
| `Ctrl+T` | Change Gateway |
| `Ctrl+V` | Toggle raw and pretty completed JSON |
| `Esc` | Interrupt an active request or navigate back |
| `↑`/`↓` | Scroll response history |

Gateway Invoke V1 has no request-type selector, target/path discovery,
tool/model discovery command, authentication editor, or protocol-specific
payload builder. Callers provide the Gateway-relative route and protocol payload
directly. GET and DELETE remain available through headless invoke.

### Invoke a Runtime

Expand Down
4 changes: 3 additions & 1 deletion src/components/GatewayPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export interface GatewayPickerProps extends ScreenProps {
breadcrumb: string[];
description?: string;
onSelect: (gatewayId: string) => void;
onEscape?: () => void;
}

export function GatewayPicker({
Expand All @@ -51,6 +52,7 @@ export function GatewayPicker({
breadcrumb,
description,
onSelect,
onEscape,
}: GatewayPickerProps) {
const opts = coreOptsFromCtx(ctx);
const navigate = useNavigate();
Expand All @@ -71,7 +73,7 @@ export function GatewayPicker({
columns={gatewayColumns}
getValue={(row) => row.gatewayId}
onSelect={onSelect}
onBack={() => navigate("/" + breadcrumb.slice(0, -1).join("/"))}
onBack={onEscape ?? (() => navigate("/" + breadcrumb.slice(0, -1).join("/")))}
loadingMessage="Loading Gateways…"
errorMessage={(error) => `Error: ${error.message}`}
emptyMessage="No Gateways found in this Region."
Expand Down
126 changes: 126 additions & 0 deletions src/components/MultilineInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { useState } from "react";
import cliTruncate from "cli-truncate";
import { Box, Text, useInput, useWindowSize } from "ink";
import { darkTheme } from "./ui/_core.js";

const theme = darkTheme;
const PREVIEW_LINES = 4;

export interface MultilineInputProps {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
placeholder?: string;
submitDisabled?: boolean;
focus?: boolean;
}

function Cursor({ character }: { character: string }) {
return (
<Text color={theme.colors.focus} inverse>
{character}
</Text>
);
}

export function MultilineInput({
value,
onChange,
onSubmit,
placeholder = "Enter text",
submitDisabled = false,
focus = true,
}: MultilineInputProps) {
const { columns } = useWindowSize();
const [rawCursor, setRawCursor] = useState(value.length);
const cursor = Math.min(rawCursor, value.length);

useInput(
(input, key) => {
if (key.leftArrow) {
setRawCursor(Math.max(0, cursor - 1));
return;
}
if (key.rightArrow) {
setRawCursor(Math.min(value.length, cursor + 1));
return;
}
if (key.upArrow || key.downArrow) return;

if (key.backspace || key.delete) {
if (cursor === 0) return;
onChange(value.slice(0, cursor - 1) + value.slice(cursor));
setRawCursor(cursor - 1);
return;
}

if (key.return) {
if (key.shift || key.meta) {
onChange(value.slice(0, cursor) + "\n" + value.slice(cursor));
setRawCursor(cursor + 1);
} else if (!submitDisabled) {
onSubmit();
}
return;
}
if (key.ctrl || key.meta || key.escape || input === "") return;

const next = input.replace(/\r/g, "\n");
onChange(value.slice(0, cursor) + next + value.slice(cursor));
setRawCursor(cursor + next.length);
},
{ isActive: focus },
);

if (value === "") {
return (
<Box>
<Cursor character={placeholder[0] ?? " "} />
<Text color={theme.colors.muted}>{placeholder.slice(1)}</Text>
</Box>
);
}

const lines = value.split("\n");
const beforeCursor = value.slice(0, cursor);
const cursorLine = beforeCursor.split("\n").length - 1;
const lastNewline = beforeCursor.lastIndexOf("\n");
const cursorColumn = cursor - lastNewline - 1;
const start = Math.max(0, cursorLine - PREVIEW_LINES + 1);
const visible = lines.slice(start, start + PREVIEW_LINES);

return (
<Box flexDirection="column">
{visible.map((line, index) => {
const lineIndex = start + index;
const prefix = index === 0 && start > 0 ? "… " : "";
if (lineIndex !== cursorLine) {
return (
<Box key={lineIndex} width={columns}>
<Text wrap="truncate-end">{cliTruncate(`${prefix}${line || " "}`, columns)}</Text>
</Box>
);
}

const horizontalMarker = cursorColumn >= columns - prefix.length ? "… " : "";
const available = Math.max(1, columns - prefix.length - horizontalMarker.length);
const offset = Math.max(0, cursorColumn - available + 1);
const before = line.slice(offset, cursorColumn);
const at = line[cursorColumn] ?? " ";
const after = line.slice(
cursorColumn + 1,
cursorColumn + 1 + Math.max(0, available - before.length - 1),
);
return (
<Box key={lineIndex} width={columns}>
<Text color={theme.colors.border}>{prefix}</Text>
<Text color={theme.colors.border}>{horizontalMarker}</Text>
{before ? <Text>{before}</Text> : null}
<Cursor character={at} />
{after ? <Text>{after}</Text> : null}
</Box>
);
})}
</Box>
);
}
9 changes: 9 additions & 0 deletions src/components/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ import { GatewayConnectorGetScreen } from "../handlers/gateway/connector/get/scr
import { GatewayRuleScreen } from "../handlers/gateway/rule/screen.tsx";
import { GatewayRuleListScreen } from "../handlers/gateway/rule/list/screen.tsx";
import { GatewayRuleGetScreen } from "../handlers/gateway/rule/get/screen.tsx";
import { GatewayInvokeScreen } from "../handlers/gateway/invoke/screen.tsx";
import { RootScreen, HelpScreen } from "../handlers/screen.tsx";
import type { Context } from "../router";

Expand Down Expand Up @@ -359,6 +360,14 @@ export function Root({ path, ctx, core, queryClient }: RootProps) {
path="agentcore/gateway/get/:gatewayId/json"
element={<GatewayGetJsonScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/gateway/invoke"
element={<GatewayInvokeScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/gateway/invoke/:gatewayId"
element={<GatewayInvokeScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/gateway/target"
element={<GatewayTargetScreen ctx={ctx} core={core} />}
Expand Down
2 changes: 1 addition & 1 deletion src/handlers/gateway/gateway.screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ describe("Gateway menu and list", () => {

await waitForText(screen.lastFrame, "inspect AgentCore Gateways");
const frame = screen.lastFrame()!;
for (const command of ["get", "list", "target", "connector", "rule"]) {
for (const command of ["get", "list", "invoke", "target", "connector", "rule"]) {
expect(frame).toContain(command);
}
expect(frame).not.toMatch(/\bcreate\b/);
Expand Down
2 changes: 1 addition & 1 deletion src/handlers/gateway/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export function createGatewayHandler(core: Core, io: AppIO): Router {
return new Router("gateway", "inspect AgentCore Gateways")
.use(withTuiOnEmptyFlagsAndArgs(core, io))
.default(renderTui(core, io))
.supportedTuiCommands("get", "list", "target", "connector", "rule")
.supportedTuiCommands("get", "list", "invoke", "target", "connector", "rule")
.handler(createCreateGatewayHandler(core, io))
.handler(createUpdateGatewayHandler(core, io))
.handler(createGetGatewayHandler(core))
Expand Down
55 changes: 54 additions & 1 deletion src/handlers/gateway/invoke/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import {
GatewayInvokeInterruptedError,
GatewayInvokeResponseError,
InputValidationError,
InvalidEnvironmentError,
} from "../../../errors";
import type { AppIO } from "../../../io";
import { ExitCode } from "../../../runnable";
import { createHandler, flag } from "../../../router";
import { createHandler, flag, PathKey } from "../../../router";
import { renderTuiAt } from "../../../tui";
import { JsonKey } from "../../keys";
import type { Core } from "../../types";
import { coreOptsFromCtx } from "../../utils";
Expand All @@ -16,8 +18,10 @@ import {
normalizeGatewayInvokeRequest,
parseGatewayInvokeHeaders,
resolveGatewayInvokeSources,
resolveGatewayInvokeTuiBearerToken,
} from "./request";
import { writeGatewayInvokeResponse } from "./response";
import { GatewayInvokeLaunchContextKey } from "./launchContext";

export const createInvokeGatewayHandler = (core: Core, io: AppIO) =>
createHandler({
Expand Down Expand Up @@ -58,6 +62,55 @@ export const createInvokeGatewayHandler = (core: Core, io: AppIO) =>
}

const jsonOutput = ctx.require(JsonKey);
if (flags.payload === undefined) {
const hasHeadlessOnlyFlag = Object.entries(flags).some(
([name, value]) =>
![
"id",
"path",
"payload",
"header",
"bearer-token",
"session-id",
"mcp-session-id",
"mcp-protocol-version",
].includes(name) && value !== undefined,
);
if (!jsonOutput && !hasHeadlessOnlyFlag) {
const applicationHeaders = parseGatewayInvokeHeaders(flags.header);
const bearerToken = await resolveGatewayInvokeTuiBearerToken(
flags["bearer-token"],
io.stdin,
);
const launchContext = {
gatewayId: flags.id,
path: flags.path,
runtimeSessionId: flags["session-id"],
mcpSessionId: flags["mcp-session-id"],
mcpProtocolVersion: flags["mcp-protocol-version"],
applicationHeaders,
bearerToken,
};
try {
await renderTuiAt(
`${ctx.require(PathKey)}/${encodeURIComponent(flags.id)}`,
ctx.withValue(GatewayInvokeLaunchContextKey, launchContext),
core,
io,
);
} catch (error) {
if (error instanceof InvalidEnvironmentError) {
throw new InputValidationError(error.message, {
cause: error,
exitCode: ExitCode.USAGE,
});
}
throw error;
}
return;
}
}

if (jsonOutput && flags["output-file"] !== undefined) {
throw new InputValidationError("--json cannot be used with --output-file");
}
Expand Down
Loading
Loading