Skip to content
Closed
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
23 changes: 23 additions & 0 deletions src/components/ui/task-list/TaskList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,29 @@ describe("TaskList", () => {
expect(frame).toContain("│ three");
});

test("keeps a long title on one line without losing its glyph", () => {
const instance = render(<></>);
Object.defineProperty(instance.stdout, "columns", { configurable: true, value: 40 });
// Left to wrap, the title flex-shrinks the glyph column away entirely.
instance.rerender(
<TaskList
tasks={[
{
title: "Updating project spec file at '/a/very/long/absolute/path/agentcore.json'",
state: "done",
tail: [],
},
]}
/>,
);

const lines = (instance.lastFrame() ?? "").split("\n").filter((line) => line.trim());
expect(lines).toHaveLength(1);
expect(lines[0]).toStartWith("✓ Updating project spec file");
expect(lines[0]).toContain("…");
instance.unmount();
});

test("truncates tail lines to the terminal width", () => {
const instance = render(<></>);
Object.defineProperty(instance.stdout, "columns", { configurable: true, value: 24 });
Expand Down
8 changes: 6 additions & 2 deletions src/components/ui/task-list/TaskList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,19 +40,23 @@ export const TaskList: React.FC<TaskListProps> = ({
// `||`, not `??`: a pty can report 0 columns, which would truncate every
// tail line to nothing. Match Ink's own layout fallback of 80.
const columns = stdout?.columns || 80;
// One line per step: a title left to wrap flex-shrinks the 1-char glyph
// column to nothing, so the ✓/✕/spinner disappears. The glyph and its
// trailing space take 2 of the row's columns.
const title = (task: Task) => cliTruncate(task.title, Math.max(columns - 2, 3));

return (
<Box flexDirection="column">
{tasks.map((task, index) => (
<Box key={`${index}-${task.title}`} flexDirection="column">
{task.state === "running" ? (
<Spinner label={task.title} theme={theme} />
<Spinner label={title(task)} theme={theme} />
) : (
<Box>
<Text color={task.state === "done" ? theme.colors.success : theme.colors.error}>
{task.state === "done" ? "✓" : "✕"}
</Text>
<Text color={theme.colors.text}> {task.title}</Text>
<Text color={theme.colors.text}> {title(task)}</Text>
</Box>
)}
{task.state !== "done" &&
Expand Down
23 changes: 23 additions & 0 deletions src/core/project/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,29 @@ describe("FsProjectManager.create", () => {
]);
});

test("streams subprocess output as line-buffered output events", async () => {
await inTempDirectory();
const subject = new FsProjectManager({
logger: createSilentLogger(),
// The chunk boundary splits a line, so a chunk-per-event bridge would
// leak the fragments "vulnerabilit" / "ies".
runner: async (_command, { onOutput }) => {
onOutput?.("added 12 packages\nfound 0 vulnerabilit");
onOutput?.("ies\n");
},
checkTool: async () => {},
identity: new TestIdentityClient(),
});

const { events } = await runCreate(subject, {
name: "example",
scaffoldRuntimeInput: AGENT_PYTHON,
});

expect(events).toContainEqual({ type: "output", line: "added 12 packages" });
expect(events).toContainEqual({ type: "output", line: "found 0 vulnerabilities" });
});

test("skipInstall skips npm install and uv sync", async () => {
const directory = await inTempDirectory();
const { manager: subject, commands } = manager();
Expand Down
21 changes: 15 additions & 6 deletions src/handlers/project/create/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ import {
type HarnessModelProvider,
} from "../../../projectSchemas/harness";
import { InputValidationError } from "../../../errors";
import { parseJsonFlag } from "../../utils";
import { JsonRendererKey } from "../../../tui";
import { parseJsonFlag, renderJsonError } from "../../utils";
import { DEFAULT_HARNESS_MODEL } from "../add/harness";
import type { CoreBedrockAgentImporter } from "../../../core/project/bedrockAgentImport";
import { importScaffoldRuntimeInput, resolveImportBedrockAgentInput } from "../importBedrockAgent";
Expand Down Expand Up @@ -196,6 +197,7 @@ export const createCreateProjectHandler = (config: CreateProjectHandlerConfig) =
flag("skip-git", "skip initializing a git repository", z.boolean().default(false)),
],
handle: async (ctx, flags) => {
const jsonOutput = ctx.require(JsonKey);
const name = flags["name"];
if (name === undefined) {
throw new InputValidationError("required option '--name <name>' not specified");
Expand Down Expand Up @@ -305,13 +307,20 @@ export const createCreateProjectHandler = (config: CreateProjectHandlerConfig) =

// Same driver as build and deploy: a live step list in a TTY, and the previous plain
// line-per-step output when stderr is not a TTY or --json wants no ANSI on it.
await runWithProgress(config.projectManager.create(createInput), {
io: config.io,
interactive: ctx.require(JsonKey) ? false : undefined,
});
try {
await runWithProgress(config.projectManager.create(createInput), {
io: config.io,
interactive: jsonOutput ? false : undefined,
});
} catch (error) {
if (jsonOutput) renderJsonError(ctx, error);
throw error;
}

config.io.stderr.write(`Created project '${name}' in ./${name}\n`);
const message = `Created project '${name}' in ./${name}`;
config.io.stderr.write(`${message}\n`);
config.io.stderr.write(`To deploy it: cd ${name} && agentcore project deploy\n`);
if (jsonOutput) ctx.require(JsonRendererKey).renderJson({ message });
},
});

Expand Down
34 changes: 34 additions & 0 deletions src/handlers/project/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,40 @@ describe("project create", () => {
expect(io.stderr()).toContain("Syncing Python dependencies with uv");
expect(io.stderr()).toContain("Initializing git repository");
expect(io.stderr()).toContain("Created project 'MyAgent' in ./MyAgent");
expect(io.stdout()).toBe("");
});

test("renders the success message as JSON with --json", async () => {
await inTempDirectory();
const { io } = await run([
"create",
"--name",
"MyAgent",
"--skip-install",
"--skip-git",
"--json",
]);

expect(JSON.parse(io.stdout())).toEqual({ message: "Created project 'MyAgent' in ./MyAgent" });
});

test("renders a create failure as JSON without changing the thrown error", async () => {
await inTempDirectory();
await run(["create", "--name", "MyAgent", "--skip-install", "--skip-git"]);

// The run helper builds io after routing, so drive the failing route with
// its own io to read what the command wrote before rejecting.
const io = testIO();
const root = createRootHandler(new TestCoreClient(), {
io: io.io,
globalConfigAccessor: new TestGlobalConfigAccessor(),
logger: createSilentLogger(),
});
const args = ["create", "--name", "MyAgent", "--skip-install", "--skip-git", "--json"];

await expect(root.route(["node", "agentcore", "project", ...args])).rejects.toThrow(/MyAgent/);

expect(JSON.parse(io.stdout())).toEqual({ error: expect.stringContaining("MyAgent") });
});

test("--skip-install and --skip-git run no commands", async () => {
Expand Down
Loading