From 2bd5a81ecf2a8e246eeb29d4afba6865c901288c Mon Sep 17 00:00:00 2001 From: Padma Komarina Date: Tue, 30 Jun 2026 17:00:51 -0400 Subject: [PATCH 1/3] feat(export): surface export notes inline in the CLI and TUI Export notes (manual follow-up items like "browser tool needs a Container build" or "verify the path skill exists in the image") were only written to app//EXPORT_NOTES.md and referenced by a single passive line, so they were easy to miss. Return the notes from handleExportHarness and render them prominently on success: - CLI: a yellow "N export note(s) requiring manual follow-up" block listing each note's category + message, with a pointer to EXPORT_NOTES.md; a "no manual follow-up required" line when there are none. - TUI: the same notes shown on the export success screen above the next-steps menu. - --json: notes are included as a structured `notes[]` array (each {category, message}) via serializeResult, so scripts/CI can consume them. EXPORT_NOTES.md is still written unchanged. Verified live on both surfaces (a browser-on-CodeZip export shows the note; a plain harness shows none) and via --json. --- src/cli/commands/export/harness-action.ts | 4 ++- src/cli/commands/export/index.ts | 24 +++++++++++++-- .../tui/screens/export/ExportHarnessFlow.tsx | 30 +++++++++++++++++-- 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/src/cli/commands/export/harness-action.ts b/src/cli/commands/export/harness-action.ts index 89aa105c6..1bd18eaa0 100644 --- a/src/cli/commands/export/harness-action.ts +++ b/src/cli/commands/export/harness-action.ts @@ -35,7 +35,8 @@ export async function handleExportHarness( options: ExportHarnessOptions, progress?: ExportHarnessProgress ): Promise< - { success: true; agentName: string; agentPath: string; notesPath: string } | { success: false; error: Error } + | { success: true; agentName: string; agentPath: string; notesPath: string; notes: ExportNote[] } + | { success: false; error: Error } > { const log = (msg: string) => progress?.onProgress?.(msg); @@ -280,6 +281,7 @@ export async function handleExportHarness( agentName: targetAgentName, agentPath: agentDir, notesPath: join(agentDir, EXPORT_NOTES_FILENAME), + notes: context.exportNotes, }; } ); diff --git a/src/cli/commands/export/index.ts b/src/cli/commands/export/index.ts index ea7568eb6..495aa23b4 100644 --- a/src/cli/commands/export/index.ts +++ b/src/cli/commands/export/index.ts @@ -4,7 +4,7 @@ import { renderTUI } from '../../tui/render'; import { handleExportHarness } from './harness-action'; import type { Command } from '@commander-js/extra-typings'; -const { green, red, cyan, dim, reset } = ANSI; +const { green, red, cyan, dim, yellow, reset } = ANSI; export function registerExport(program: Command): void { const exportCmd = program @@ -75,8 +75,28 @@ export function registerExport(program: Command): void { console.log(`${dim}Generated:${reset}`); console.log(` app/${targetAgentName}/ Python agent (Strands)`); console.log(` agentcore/agentcore.json updated`); - console.log(` EXPORT_NOTES.md review for manual follow-up items`); console.log(''); + + // Surface any manual follow-up notes inline so they aren't missed (also written to + // app//EXPORT_NOTES.md). Each note is a category + a (possibly multi-line) message. + if (result.notes.length > 0) { + const label = result.notes.length === 1 ? 'note' : 'notes'; + console.log(`${yellow}⚠ ${result.notes.length} export ${label} requiring manual follow-up:${reset}`); + console.log(''); + for (const note of result.notes) { + console.log(` ${yellow}• ${note.category}${reset}`); + for (const line of note.message.split('\n')) { + console.log(` ${dim}${line}${reset}`); + } + console.log(''); + } + console.log(`${dim}These notes are also saved to app/${targetAgentName}/EXPORT_NOTES.md${reset}`); + console.log(''); + } else { + console.log(`${dim}No manual follow-up required. (Details: app/${targetAgentName}/EXPORT_NOTES.md)${reset}`); + console.log(''); + } + console.log('Next steps:'); console.log(''); console.log(` ${cyan}agentcore deploy${reset} ${dim}Deploy the new runtime agent${reset}`); diff --git a/src/cli/tui/screens/export/ExportHarnessFlow.tsx b/src/cli/tui/screens/export/ExportHarnessFlow.tsx index 75d29ab53..e8a68777b 100644 --- a/src/cli/tui/screens/export/ExportHarnessFlow.tsx +++ b/src/cli/tui/screens/export/ExportHarnessFlow.tsx @@ -1,3 +1,4 @@ +import type { ExportNote } from '../../../commands/export/types'; import { ErrorPrompt, GradientText, NextSteps, Screen, StepProgress } from '../../components'; import type { NextStep, Step } from '../../components'; import { ExportHarnessScreen } from './ExportHarnessScreen'; @@ -10,7 +11,7 @@ type FlowState = | { name: 'wizard'; harnessNames: string[]; existingAgentNames: string[]; containerOnlyHarnesses: Set } | { name: 'no-harnesses' } | { name: 'exporting'; steps: Step[] } - | { name: 'success'; agentName: string; notesPath: string } + | { name: 'success'; agentName: string; notesPath: string; notes: ExportNote[] } | { name: 'error'; message: string }; interface ExportHarnessFlowProps { @@ -112,7 +113,7 @@ export function ExportHarnessFlow({ isInteractive = true, onExit, onBack, onDepl return; } - setFlow({ name: 'success', agentName: result.agentName, notesPath: result.notesPath }); + setFlow({ name: 'success', agentName: result.agentName, notesPath: result.notesPath, notes: result.notes }); } catch (err) { const { getErrorMessage } = await import('../../../errors'); setFlow({ name: 'error', message: getErrorMessage(err) }); @@ -178,8 +179,31 @@ export function ExportHarnessFlow({ isInteractive = true, onExit, onBack, onDepl ✓ Exported harness → runtime agent {flow.agentName} Generated: app/{flow.agentName}/ · agentcore/agentcore.json updated - Review export notes: {flow.notesPath} + + {/* Surface manual follow-up notes inline so they aren't missed (also in EXPORT_NOTES.md). */} + {flow.notes.length > 0 ? ( + + + ⚠ {flow.notes.length} export {flow.notes.length === 1 ? 'note' : 'notes'} requiring manual follow-up: + + {flow.notes.map((note, i) => ( + + • {note.category} + {note.message.split('\n').map((line, j) => ( + + {' '} + {line} + + ))} + + ))} + Also saved to {flow.notesPath} + + ) : ( + No manual follow-up required. (Details: {flow.notesPath}) + )} + {isInteractive && ( )} From f2e094d00ac4754e146b3a82eeb65a851d1bd647 Mon Sep 17 00:00:00 2001 From: Padma Komarina Date: Wed, 1 Jul 2026 10:01:34 -0400 Subject: [PATCH 2/3] test(export): extract formatExportNotes as a tested pure function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the PR review note that the CLI/TUI note rendering had no unit coverage (only manual verification). Extract the note-formatting into a pure `formatExportNotes(notes, notesFileHint)` helper that returns tone-tagged lines (`warn`/`dim`), and have both the CLI (tone → ANSI) and the TUI export success screen (tone → Ink color) render from it. This makes the output testable without spawning the command and keeps the two surfaces' wording in sync (also removes the earlier duplicated rendering logic). Add format-export-notes.test.ts covering the no-notes line, singular/plural header, category + multi-line message expansion, and the EXPORT_NOTES.md hint. --- .../__tests__/format-export-notes.test.ts | 46 +++++++++++++++++++ src/cli/commands/export/index.ts | 23 +++------- src/cli/commands/export/types.ts | 31 +++++++++++++ .../tui/screens/export/ExportHarnessFlow.tsx | 30 ++++-------- 4 files changed, 92 insertions(+), 38 deletions(-) create mode 100644 src/cli/commands/export/__tests__/format-export-notes.test.ts diff --git a/src/cli/commands/export/__tests__/format-export-notes.test.ts b/src/cli/commands/export/__tests__/format-export-notes.test.ts new file mode 100644 index 000000000..f751e3932 --- /dev/null +++ b/src/cli/commands/export/__tests__/format-export-notes.test.ts @@ -0,0 +1,46 @@ +import type { ExportNote } from '../types'; +import { formatExportNotes } from '../types'; +import { describe, expect, it } from 'vitest'; + +const HINT = 'app/MyAgent/EXPORT_NOTES.md'; + +describe('formatExportNotes', () => { + it('returns a single dim "no follow-up" line when there are no notes', () => { + const lines = formatExportNotes([], HINT); + expect(lines).toEqual([{ text: `No manual follow-up required. (Details: ${HINT})`, tone: 'dim' }]); + }); + + it('renders a warning header (singular) + category + message + file hint for one note', () => { + const notes: ExportNote[] = [ + { category: 'Browser tool requires Container build', message: 'Re-export with --build Container.' }, + ]; + const lines = formatExportNotes(notes, HINT); + + // Header is a single "note" (not "notes") and carries the warn tone. + expect(lines[0]).toEqual({ text: '⚠ 1 export note requiring manual follow-up:', tone: 'warn' }); + // Category is warn-toned and bulleted. + expect(lines).toContainEqual({ text: ' • Browser tool requires Container build', tone: 'warn' }); + // Message line is dim and indented. + expect(lines).toContainEqual({ text: ' Re-export with --build Container.', tone: 'dim' }); + // Trailing pointer to the on-disk copy. + expect(lines.at(-1)).toEqual({ text: `These notes are also saved to ${HINT}`, tone: 'dim' }); + }); + + it('pluralizes the header and lists every note when there are multiple', () => { + const notes: ExportNote[] = [ + { category: 'First', message: 'a' }, + { category: 'Second', message: 'b' }, + ]; + const lines = formatExportNotes(notes, HINT); + expect(lines[0]?.text).toBe('⚠ 2 export notes requiring manual follow-up:'); + expect(lines).toContainEqual({ text: ' • First', tone: 'warn' }); + expect(lines).toContainEqual({ text: ' • Second', tone: 'warn' }); + }); + + it('splits a multi-line message into one dim line per line (preserving order)', () => { + const notes: ExportNote[] = [{ category: 'C', message: 'line one\nline two\nline three' }]; + const lines = formatExportNotes(notes, HINT); + const messageLines = lines.filter(l => l.tone === 'dim' && l.text.startsWith(' ')); + expect(messageLines.map(l => l.text)).toEqual([' line one', ' line two', ' line three']); + }); +}); diff --git a/src/cli/commands/export/index.ts b/src/cli/commands/export/index.ts index 495aa23b4..73833955b 100644 --- a/src/cli/commands/export/index.ts +++ b/src/cli/commands/export/index.ts @@ -2,6 +2,7 @@ import { serializeResult } from '../../../lib/result'; import { ANSI, COMMAND_DESCRIPTIONS } from '../../constants'; import { renderTUI } from '../../tui/render'; import { handleExportHarness } from './harness-action'; +import { formatExportNotes } from './types'; import type { Command } from '@commander-js/extra-typings'; const { green, red, cyan, dim, yellow, reset } = ANSI; @@ -78,24 +79,12 @@ export function registerExport(program: Command): void { console.log(''); // Surface any manual follow-up notes inline so they aren't missed (also written to - // app//EXPORT_NOTES.md). Each note is a category + a (possibly multi-line) message. - if (result.notes.length > 0) { - const label = result.notes.length === 1 ? 'note' : 'notes'; - console.log(`${yellow}⚠ ${result.notes.length} export ${label} requiring manual follow-up:${reset}`); - console.log(''); - for (const note of result.notes) { - console.log(` ${yellow}• ${note.category}${reset}`); - for (const line of note.message.split('\n')) { - console.log(` ${dim}${line}${reset}`); - } - console.log(''); - } - console.log(`${dim}These notes are also saved to app/${targetAgentName}/EXPORT_NOTES.md${reset}`); - console.log(''); - } else { - console.log(`${dim}No manual follow-up required. (Details: app/${targetAgentName}/EXPORT_NOTES.md)${reset}`); - console.log(''); + // app//EXPORT_NOTES.md). Shared formatter keeps CLI + TUI wording in sync. + for (const line of formatExportNotes(result.notes, `app/${targetAgentName}/EXPORT_NOTES.md`)) { + const color = line.tone === 'warn' ? yellow : dim; + console.log(`${color}${line.text}${reset}`); } + console.log(''); console.log('Next steps:'); console.log(''); diff --git a/src/cli/commands/export/types.ts b/src/cli/commands/export/types.ts index 9b150f986..56eea41b1 100644 --- a/src/cli/commands/export/types.ts +++ b/src/cli/commands/export/types.ts @@ -61,6 +61,37 @@ export interface ExportNote { message: string; } +/** A single rendered output line + a tone the caller maps to its own styling (ANSI, Ink, plain). */ +export interface ExportNoteLine { + text: string; + tone: 'warn' | 'dim'; +} + +/** + * Format export notes into styled lines for display on the export success path (CLI + TUI). Pure and + * side-effect-free so it can be unit-tested and shared, keeping the two surfaces' wording in sync. + * Returns a warning block listing each note's category + (multi-line) message when notes exist, or a + * single "no follow-up required" line otherwise. `notesFileHint` is the path shown for EXPORT_NOTES.md. + */ +export function formatExportNotes(notes: ExportNote[], notesFileHint: string): ExportNoteLine[] { + if (notes.length === 0) { + return [{ text: `No manual follow-up required. (Details: ${notesFileHint})`, tone: 'dim' }]; + } + + const label = notes.length === 1 ? 'note' : 'notes'; + const lines: ExportNoteLine[] = [ + { text: `⚠ ${notes.length} export ${label} requiring manual follow-up:`, tone: 'warn' }, + ]; + for (const note of notes) { + lines.push({ text: ` • ${note.category}`, tone: 'warn' }); + for (const messageLine of note.message.split('\n')) { + lines.push({ text: ` ${messageLine}`, tone: 'dim' }); + } + } + lines.push({ text: `These notes are also saved to ${notesFileHint}`, tone: 'dim' }); + return lines; +} + // ============================================================================ // Mapping output // ============================================================================ diff --git a/src/cli/tui/screens/export/ExportHarnessFlow.tsx b/src/cli/tui/screens/export/ExportHarnessFlow.tsx index e8a68777b..88af5663f 100644 --- a/src/cli/tui/screens/export/ExportHarnessFlow.tsx +++ b/src/cli/tui/screens/export/ExportHarnessFlow.tsx @@ -1,3 +1,4 @@ +import { formatExportNotes } from '../../../commands/export/types'; import type { ExportNote } from '../../../commands/export/types'; import { ErrorPrompt, GradientText, NextSteps, Screen, StepProgress } from '../../components'; import type { NextStep, Step } from '../../components'; @@ -181,28 +182,15 @@ export function ExportHarnessFlow({ isInteractive = true, onExit, onBack, onDepl Generated: app/{flow.agentName}/ · agentcore/agentcore.json updated - {/* Surface manual follow-up notes inline so they aren't missed (also in EXPORT_NOTES.md). */} - {flow.notes.length > 0 ? ( - - - ⚠ {flow.notes.length} export {flow.notes.length === 1 ? 'note' : 'notes'} requiring manual follow-up: + {/* Surface manual follow-up notes inline so they aren't missed (also in EXPORT_NOTES.md). + Shared formatter keeps the wording in sync with the CLI. */} + + {formatExportNotes(flow.notes, flow.notesPath).map((line, i) => ( + + {line.text} - {flow.notes.map((note, i) => ( - - • {note.category} - {note.message.split('\n').map((line, j) => ( - - {' '} - {line} - - ))} - - ))} - Also saved to {flow.notesPath} - - ) : ( - No manual follow-up required. (Details: {flow.notesPath}) - )} + ))} + {isInteractive && ( From 4177aa4fdf446837493f42e5f2755abc452f8712 Mon Sep 17 00:00:00 2001 From: Padma Komarina Date: Wed, 1 Jul 2026 10:18:52 -0400 Subject: [PATCH 3/3] test(export): assert notes are emitted in the --json output The CLI `--json` branch emits `JSON.stringify(serializeResult(result))`, so the export success result's `notes` array is a public contract for scripted consumers. Add cases asserting serializeResult preserves the notes array, that it survives a JSON round-trip as structured { category, message } objects, and that an empty notes list serializes to `[]` (not undefined). --- .../__tests__/format-export-notes.test.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/cli/commands/export/__tests__/format-export-notes.test.ts b/src/cli/commands/export/__tests__/format-export-notes.test.ts index f751e3932..5a002e295 100644 --- a/src/cli/commands/export/__tests__/format-export-notes.test.ts +++ b/src/cli/commands/export/__tests__/format-export-notes.test.ts @@ -1,9 +1,23 @@ +import { serializeResult } from '../../../../lib/result'; import type { ExportNote } from '../types'; import { formatExportNotes } from '../types'; import { describe, expect, it } from 'vitest'; const HINT = 'app/MyAgent/EXPORT_NOTES.md'; +/** The export-harness success result the CLI `--json` branch serializes. `success: true as const` + + * the index signature satisfy serializeResult's `{ success: true } & Record`. */ +function makeExportSuccess(notes: ExportNote[]): { + success: true; + agentName: string; + agentPath: string; + notesPath: string; + notes: ExportNote[]; + [k: string]: unknown; +} { + return { success: true, agentName: 'MyAgent', agentPath: '/tmp/app/MyAgent', notesPath: HINT, notes }; +} + describe('formatExportNotes', () => { it('returns a single dim "no follow-up" line when there are no notes', () => { const lines = formatExportNotes([], HINT); @@ -44,3 +58,37 @@ describe('formatExportNotes', () => { expect(messageLines.map(l => l.text)).toEqual([' line one', ' line two', ' line three']); }); }); + +// The CLI `--json` branch emits `JSON.stringify(serializeResult(result))`, so the export success +// result's `notes` array is a public contract for scripted consumers. Assert it survives that path +// as structured { category, message } objects. +describe('export --json notes contract', () => { + const notes: ExportNote[] = [ + { category: 'Browser tool requires Container build', message: 'Re-export with --build Container.' }, + { category: 'Path skill not found locally', message: 'Ensure /opt/skills/x exists in the image.' }, + ]; + + it('serializeResult preserves the notes array (with category + message)', () => { + const serialized = serializeResult(makeExportSuccess(notes)) as { notes: ExportNote[] }; + expect(serialized.notes).toEqual(notes); + }); + + it('notes survive a JSON round-trip as structured objects (the emitted --json payload)', () => { + const emitted = JSON.parse(JSON.stringify(serializeResult(makeExportSuccess(notes)))) as { + success: boolean; + notes: ExportNote[]; + }; + expect(emitted.success).toBe(true); + expect(Array.isArray(emitted.notes)).toBe(true); + expect(emitted.notes).toHaveLength(2); + expect(emitted.notes[0]).toEqual({ + category: 'Browser tool requires Container build', + message: 'Re-export with --build Container.', + }); + }); + + it('emits an empty notes array (not undefined) when there are no notes', () => { + const emitted = JSON.parse(JSON.stringify(serializeResult(makeExportSuccess([])))) as { notes: ExportNote[] }; + expect(emitted.notes).toEqual([]); + }); +});