Skip to content

Commit 5cb218e

Browse files
committed
feat(import): add interactive TUI wizard for import command
Adds a multi-screen TUI flow for importing runtimes, memories, and starter toolkit configs, replacing the silent fall-through that previously occurred when selecting "import" in the TUI. Constraint: onProgress must be injectable so TUI can display step progress Rejected: Single text-input screen for all flows | each import type has different required fields Confidence: high Scope-risk: narrow
1 parent e467c75 commit 5cb218e

13 files changed

Lines changed: 449 additions & 6 deletions

src/cli/commands/import/import-memory.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,9 @@ function toMemorySpec(memory: MemoryDetail, localName: string): Memory {
8888
*/
8989
export async function handleImportMemory(options: ImportResourceOptions): Promise<ImportResourceResult> {
9090
const logger = new ExecLogger({ command: 'import-memory' });
91-
const onProgress = (message: string) => {
91+
const onProgress = options.onProgress ?? ((message: string) => {
9292
console.log(`${green}[done]${reset} ${message}`);
93-
};
93+
});
9494

9595
// Rollback state
9696
let configSnapshot: AgentCoreProjectSpec | undefined;

src/cli/commands/import/import-runtime.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,9 +103,9 @@ function toAgentEnvSpec(
103103
*/
104104
export async function handleImportRuntime(options: ImportResourceOptions): Promise<ImportResourceResult> {
105105
const logger = new ExecLogger({ command: 'import-runtime' });
106-
const onProgress = (message: string) => {
106+
const onProgress = options.onProgress ?? ((message: string) => {
107107
console.log(`${green}[done]${reset} ${message}`);
108-
};
108+
});
109109

110110
// Rollback state
111111
let configSnapshot: AgentCoreProjectSpec | undefined;

src/cli/commands/import/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,4 +107,5 @@ export interface ImportResourceOptions {
107107
name?: string;
108108
entrypoint?: string;
109109
yes?: boolean;
110+
onProgress?: (message: string) => void;
110111
}

src/cli/tui/App.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { LayoutProvider } from './context';
44
import { CLI_ONLY_EXAMPLES } from './copy';
55
import { MissingProjectMessage, WrongDirectoryMessage, getProjectRootMismatch, projectExists } from './guards';
66
import { AddFlow } from './screens/add/AddFlow';
7+
import { ImportFlow } from './screens/import';
78
import { CliOnlyScreen } from './screens/cli-only';
89
import { CreateScreen } from './screens/create';
910
import { DeployScreen } from './screens/deploy/DeployScreen';
@@ -45,6 +46,7 @@ type Route =
4546
| { name: 'validate' }
4647
| { name: 'package' }
4748
| { name: 'update' }
49+
| { name: 'import' }
4850
| { name: 'cli-only'; commandId: string };
4951

5052
// Commands that don't require being at the project root
@@ -112,6 +114,12 @@ function AppContent() {
112114
setRoute({ name: 'validate' });
113115
} else if (id === 'package') {
114116
setRoute({ name: 'package' });
117+
} else if (id === 'import') {
118+
if (!projectExists() && route.name === 'help') {
119+
setHelpNotice(<MissingProjectMessage inTui />);
120+
return;
121+
}
122+
setRoute({ name: 'import' });
115123
} else if (id === 'update') {
116124
setRoute({ name: 'update' });
117125
}
@@ -253,6 +261,10 @@ function AppContent() {
253261
return <PackageScreen isInteractive={true} onExit={() => setRoute({ name: 'help' })} />;
254262
}
255263

264+
if (route.name === 'import') {
265+
return <ImportFlow onBack={() => setRoute({ name: 'help' })} />;
266+
}
267+
256268
if (route.name === 'update') {
257269
return <UpdateScreen isInteractive={true} onExit={() => setRoute({ name: 'help' })} />;
258270
}

src/cli/tui/copy.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export const COMMAND_DESCRIPTIONS = {
4646
pause: 'Pause an online eval config. Supports --arn for configs outside the project.',
4747
resume: 'Resume a paused online eval config. Supports --arn for configs outside the project.',
4848
run: 'Run on-demand evaluation. Supports --agent-arn for agents outside the project.',
49-
import: 'Import resources from a Bedrock AgentCore Starter Toolkit project.',
49+
import: 'Import a runtime, memory, or starter toolkit into this project.',
5050
update: 'Check for and install CLI updates',
5151
validate: 'Validate agentcore/ config files.',
5252
} as const;
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { Panel } from '../../components/Panel';
2+
import { Screen } from '../../components/Screen';
3+
import { TextInput } from '../../components/TextInput';
4+
import { HELP_TEXT } from '../../constants';
5+
6+
const ARN_PATTERN = /^arn:aws:bedrock-agentcore:[^:]+:[^:]+:(runtime|memory)\/.+$/;
7+
8+
function validateArn(value: string): true | string {
9+
if (!ARN_PATTERN.test(value)) {
10+
return 'Invalid ARN format. Expected: arn:aws:bedrock-agentcore:<region>:<account>:<runtime|memory>/<id>';
11+
}
12+
return true;
13+
}
14+
15+
interface ArnInputScreenProps {
16+
resourceType: 'runtime' | 'memory';
17+
onSubmit: (arn: string) => void;
18+
onExit: () => void;
19+
}
20+
21+
export function ArnInputScreen({ resourceType, onSubmit, onExit }: ArnInputScreenProps) {
22+
const title = resourceType === 'runtime' ? 'Import Runtime' : 'Import Memory';
23+
const placeholder = `arn:aws:bedrock-agentcore:<region>:<account>:${resourceType}/<id>`;
24+
25+
return (
26+
<Screen title={title} onExit={onExit} helpText={HELP_TEXT.TEXT_INPUT}>
27+
<Panel>
28+
<TextInput
29+
prompt="Enter the resource ARN:"
30+
placeholder={placeholder}
31+
onSubmit={onSubmit}
32+
onCancel={onExit}
33+
customValidation={validateArn}
34+
/>
35+
</Panel>
36+
</Screen>
37+
);
38+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { Panel } from '../../components/Panel';
2+
import { PathInput } from '../../components/PathInput';
3+
import { Screen } from '../../components/Screen';
4+
import { Text } from 'ink';
5+
6+
interface CodePathScreenProps {
7+
onSubmit: (codePath: string) => void;
8+
onExit: () => void;
9+
}
10+
11+
export function CodePathScreen({ onSubmit, onExit }: CodePathScreenProps) {
12+
return (
13+
<Screen title="Agent Source Code" onExit={onExit} exitEnabled={false}>
14+
<Panel>
15+
<Text dimColor>Path to the directory containing your entrypoint file</Text>
16+
<PathInput
17+
pathType="directory"
18+
placeholder="app/my-agent/"
19+
onSubmit={onSubmit}
20+
onCancel={onExit}
21+
/>
22+
</Panel>
23+
</Screen>
24+
);
25+
}
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import type { ImportResult, ImportResourceResult } from '../../../commands/import/types';
2+
import { ErrorPrompt } from '../../components/PromptScreen';
3+
import { NextSteps, type NextStep } from '../../components/NextSteps';
4+
import { Screen } from '../../components/Screen';
5+
import { Panel } from '../../components/Panel';
6+
import { HELP_TEXT } from '../../constants';
7+
import { ArnInputScreen } from './ArnInputScreen';
8+
import { CodePathScreen } from './CodePathScreen';
9+
import { ImportProgressScreen } from './ImportProgressScreen';
10+
import { ImportSelectScreen, type ImportType } from './ImportSelectScreen';
11+
import { YamlPathScreen } from './YamlPathScreen';
12+
import { Box, Text } from 'ink';
13+
import React, { useState } from 'react';
14+
15+
type ImportFlowState =
16+
| { name: 'select-type' }
17+
| { name: 'arn-input'; resourceType: 'runtime' | 'memory' }
18+
| { name: 'code-path'; resourceType: 'runtime'; arn: string }
19+
| { name: 'yaml-path' }
20+
| {
21+
name: 'importing';
22+
importType: ImportType;
23+
arn?: string;
24+
code?: string;
25+
yamlPath?: string;
26+
}
27+
| {
28+
name: 'success';
29+
importType: ImportType;
30+
result: ImportResourceResult | ImportResult;
31+
}
32+
| { name: 'error'; message: string };
33+
34+
const IMPORT_NEXT_STEPS: NextStep[] = [
35+
{ command: 'deploy', label: 'Deploy the imported stack' },
36+
{ command: 'status', label: 'Verify resource status' },
37+
];
38+
39+
interface ImportFlowProps {
40+
onBack: () => void;
41+
}
42+
43+
export function ImportFlow({ onBack }: ImportFlowProps) {
44+
const [flow, setFlow] = useState<ImportFlowState>({ name: 'select-type' });
45+
46+
if (flow.name === 'select-type') {
47+
return (
48+
<ImportSelectScreen
49+
onSelect={(type) => {
50+
if (type === 'runtime' || type === 'memory') {
51+
setFlow({ name: 'arn-input', resourceType: type });
52+
} else {
53+
setFlow({ name: 'yaml-path' });
54+
}
55+
}}
56+
onExit={onBack}
57+
/>
58+
);
59+
}
60+
61+
if (flow.name === 'arn-input') {
62+
return (
63+
<ArnInputScreen
64+
resourceType={flow.resourceType}
65+
onSubmit={(arn) => {
66+
if (flow.resourceType === 'runtime') {
67+
setFlow({ name: 'code-path', resourceType: 'runtime', arn });
68+
} else {
69+
setFlow({
70+
name: 'importing',
71+
importType: 'memory',
72+
arn,
73+
});
74+
}
75+
}}
76+
onExit={() => setFlow({ name: 'select-type' })}
77+
/>
78+
);
79+
}
80+
81+
if (flow.name === 'code-path') {
82+
return (
83+
<CodePathScreen
84+
onSubmit={(codePath) => {
85+
setFlow({
86+
name: 'importing',
87+
importType: 'runtime',
88+
arn: flow.arn,
89+
code: codePath,
90+
});
91+
}}
92+
onExit={() => setFlow({ name: 'arn-input', resourceType: 'runtime' })}
93+
/>
94+
);
95+
}
96+
97+
if (flow.name === 'yaml-path') {
98+
return (
99+
<YamlPathScreen
100+
onSubmit={(yamlPath) => {
101+
setFlow({
102+
name: 'importing',
103+
importType: 'starter-toolkit',
104+
yamlPath,
105+
});
106+
}}
107+
onExit={() => setFlow({ name: 'select-type' })}
108+
/>
109+
);
110+
}
111+
112+
if (flow.name === 'importing') {
113+
return (
114+
<ImportProgressScreen
115+
importType={flow.importType}
116+
arn={flow.arn}
117+
code={flow.code}
118+
yamlPath={flow.yamlPath}
119+
onSuccess={(result) => {
120+
setFlow({ name: 'success', importType: flow.importType, result });
121+
}}
122+
onError={(message) => {
123+
setFlow({ name: 'error', message });
124+
}}
125+
onExit={onBack}
126+
/>
127+
);
128+
}
129+
130+
if (flow.name === 'success') {
131+
const result = flow.result;
132+
const isResource = 'resourceType' in result;
133+
134+
return (
135+
<Screen title="Import Complete" onExit={onBack} helpText={HELP_TEXT.BACK}>
136+
<Panel>
137+
<Box flexDirection="column">
138+
<Text color="green">Import successful!</Text>
139+
{isResource && (
140+
<Box flexDirection="column" marginTop={1}>
141+
<Text>
142+
<Text dimColor>Type: </Text>
143+
<Text>{(result as ImportResourceResult).resourceType}</Text>
144+
</Text>
145+
<Text>
146+
<Text dimColor>Name: </Text>
147+
<Text>{(result as ImportResourceResult).resourceName}</Text>
148+
</Text>
149+
{(result as ImportResourceResult).resourceId && (
150+
<Text>
151+
<Text dimColor>ID: </Text>
152+
<Text>{(result as ImportResourceResult).resourceId}</Text>
153+
</Text>
154+
)}
155+
</Box>
156+
)}
157+
{!isResource && (
158+
<Box flexDirection="column" marginTop={1}>
159+
{(result as ImportResult).importedAgents?.map((agent) => (
160+
<Text key={agent}>
161+
<Text dimColor>Agent: </Text>
162+
<Text>{agent}</Text>
163+
</Text>
164+
))}
165+
{(result as ImportResult).importedMemories?.map((mem) => (
166+
<Text key={mem}>
167+
<Text dimColor>Memory: </Text>
168+
<Text>{mem}</Text>
169+
</Text>
170+
))}
171+
</Box>
172+
)}
173+
</Box>
174+
</Panel>
175+
<NextSteps steps={IMPORT_NEXT_STEPS} isInteractive={true} onSelect={() => onBack()} onBack={onBack} />
176+
</Screen>
177+
);
178+
}
179+
180+
if (flow.name === 'error') {
181+
return (
182+
<ErrorPrompt
183+
message="Import failed"
184+
detail={flow.message}
185+
onBack={() => setFlow({ name: 'select-type' })}
186+
onExit={onBack}
187+
/>
188+
);
189+
}
190+
191+
return null;
192+
}

0 commit comments

Comments
 (0)