From f47e14c2412d38e402ae3b330ea2b4a06ae35bcf Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Thu, 30 Jul 2026 11:22:31 -0400 Subject: [PATCH 1/8] feat(project): run npm install, uv sync, and git init after scaffolding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After writing the project tree, `project create` now installs CDK dependencies (npm install), syncs Python dependencies (uv sync, when the template ships a pyproject.toml), and initializes a git repository — skippable via --skip-install and --skip-git. - src/io/exec.ts: minimal subprocess runner + tool-on-PATH check with modeled errors (MissingToolError, CommandFailedError). Uses node:child_process instead of Bun.$ because the npm bundle targets Node, where Bun APIs are unavailable. - Progress steps and the success message stream to stderr through the injected AppIO; subprocess output streams to the file logger and is surfaced in the terminal only on failure, with a retry hint. - A failed step keeps the scaffolded files (crash-only): the error tells the user how to rerun the step manually. - Template gains a README (getting-started guide recommending Strands) wired through pyproject.toml — uv sync previously failed on hatchling metadata validation because the declared README did not exist. --- .../templates/hello-world-python/README.md | 57 ++++++++++ .../__snapshots__/manager.test.ts.snap | 1 + src/core/project/manager.test.ts | 103 ++++++++++++++++-- src/core/project/manager.tsx | 32 ++++++ src/handlers/index.tsx | 2 +- src/handlers/project/create/index.ts | 15 ++- src/handlers/project/index.ts | 6 +- src/handlers/project/project.test.ts | 37 ++++++- src/handlers/project/types.ts | 6 + src/io/exec.test.ts | 54 +++++++++ src/io/exec.ts | 86 +++++++++++++++ src/io/index.ts | 9 ++ src/testing/TestCoreClient.tsx | 11 +- 13 files changed, 405 insertions(+), 14 deletions(-) create mode 100644 src/assets/templates/hello-world-python/README.md create mode 100644 src/io/exec.test.ts create mode 100644 src/io/exec.ts diff --git a/src/assets/templates/hello-world-python/README.md b/src/assets/templates/hello-world-python/README.md new file mode 100644 index 000000000..07538a277 --- /dev/null +++ b/src/assets/templates/hello-world-python/README.md @@ -0,0 +1,57 @@ +# hello-world + +A minimal AgentCore Runtime agent built with the +[Strands Agents SDK](https://strandsagents.com) — our recommended framework +for building agents on AWS Bedrock AgentCore. + +## What's here + +- `main.py` — the agent. A `BedrockAgentCoreApp` wraps a Strands `Agent`; + the `@app.entrypoint` function receives each invocation payload and streams + the agent's response back to the caller. +- `pyproject.toml` — Python dependencies, managed with + [uv](https://docs.astral.sh/uv/). `agentcore project create` has already run + `uv sync` for you (unless you passed `--skip-install`), so `.venv/` is ready. + +## Run it locally + +```bash +uv run main.py +``` + +The app listens on http://localhost:8080. Invoke it: + +```bash +curl -X POST http://localhost:8080/invocations \ + -H "Content-Type: application/json" \ + -d '{"prompt": "Hello!"}' +``` + +## Build your agent + +Start in `main.py`: + +- Change the `system_prompt` to shape your agent's behavior. +- Give it tools — Strands ships ready-made ones and makes custom tools a + decorator away: + + ```python + from strands import Agent, tool + + @tool + def word_count(text: str) -> int: + """Count words in text.""" + return len(text.split()) + + agent = Agent(system_prompt="You are a helpful assistant.", tools=[word_count]) + ``` + +- Add dependencies with `uv add `. + +See the [Strands documentation](https://strandsagents.com/latest/documentation/docs/) +for multi-agent patterns, MCP tools, and model configuration. + +## Deploy + +Deploy from the project root with the AgentCore CLI; the CDK app under +`agentcore/cdk` provisions the Runtime that hosts this agent. diff --git a/src/core/project/__snapshots__/manager.test.ts.snap b/src/core/project/__snapshots__/manager.test.ts.snap index 87d602318..2b1f68db1 100644 --- a/src/core/project/__snapshots__/manager.test.ts.snap +++ b/src/core/project/__snapshots__/manager.test.ts.snap @@ -15,6 +15,7 @@ exports[`FsProjectManager.create scaffolds the expected file tree into a fresh d "agentcore/cdk/package.json", "agentcore/cdk/test/cdk.test.ts", "agentcore/cdk/tsconfig.json", + "app/hello-world/README.md", "app/hello-world/main.py", "app/hello-world/pyproject.toml", ] diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index e019c6377..f84f6cbea 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -14,7 +14,9 @@ async function inTempDirectory(): Promise { const directory = await mkdtemp(join(tmpdir(), "agentcore-manager-")); tempDirectories.push(directory); process.chdir(directory); - return directory; + // cwd is the realpath (macOS tmpdir lives behind a /var -> /private/var + // symlink), matching the paths the manager derives from process.cwd(). + return process.cwd(); } afterEach(async () => { @@ -24,14 +26,27 @@ afterEach(async () => { ); }); -function manager(): FsProjectManager { - return new FsProjectManager({ logger: createSilentLogger() }); +// A manager whose runner records commands instead of spawning them. +function manager(): { manager: FsProjectManager; commands: { command: string[]; cwd: string }[] } { + const commands: { command: string[]; cwd: string }[] = []; + return { + manager: new FsProjectManager({ + logger: createSilentLogger(), + runner: async (command, { cwd }) => { + commands.push({ command, cwd }); + }, + }), + commands, + }; } describe("FsProjectManager.create", () => { test("scaffolds the expected file tree into a fresh directory", async () => { const directory = await inTempDirectory(); - await manager().create({ name: "example", template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON }); + await manager().manager.create({ + name: "example", + template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON, + }); const projectRoot = join(directory, "example"); const manifest = (await readdir(projectRoot, { recursive: true, withFileTypes: true })) @@ -46,7 +61,10 @@ describe("FsProjectManager.create", () => { test("writes a deploy-ready agentcore.json registering the template agent", async () => { const directory = await inTempDirectory(); - await manager().create({ name: "example", template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON }); + await manager().manager.create({ + name: "example", + template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON, + }); const configDir = join(directory, "example", "agentcore"); const spec = await Bun.file(join(configDir, "agentcore.json")).json(); @@ -66,8 +84,79 @@ describe("FsProjectManager.create", () => { await inTempDirectory(); const input = { name: "example", template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON }; - await manager().create(input); - await expect(manager().create(input)).rejects.toBeInstanceOf(ProjectFileExistsError); + await manager().manager.create(input); + await expect(manager().manager.create(input)).rejects.toBeInstanceOf(ProjectFileExistsError); + }); + + test("runs npm install, uv sync, and git init after scaffolding", async () => { + const directory = await inTempDirectory(); + const { manager: subject, commands } = manager(); + await subject.create({ name: "example", template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON }); + + const projectRoot = join(directory, "example"); + expect(commands).toEqual([ + { command: ["npm", "install"], cwd: join(projectRoot, "agentcore", "cdk") }, + { command: ["uv", "sync"], cwd: join(projectRoot, "app", "hello-world") }, + { command: ["git", "init"], cwd: projectRoot }, + ]); + }); + + test("skipInstall skips npm install and uv sync", async () => { + const directory = await inTempDirectory(); + const { manager: subject, commands } = manager(); + await subject.create({ + name: "example", + template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON, + skipInstall: true, + }); + + expect(commands).toEqual([{ command: ["git", "init"], cwd: join(directory, "example") }]); + }); + + test("skipGit skips git init", async () => { + await inTempDirectory(); + const { manager: subject, commands } = manager(); + await subject.create({ + name: "example", + template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON, + skipGit: true, + }); + + expect(commands.map(({ command }) => command[0])).toEqual(["npm", "uv"]); + }); + + test("reports each step through onProgress", async () => { + await inTempDirectory(); + const messages: string[] = []; + await manager().manager.create({ + name: "example", + template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON, + onProgress: (message) => messages.push(message), + }); + + expect(messages).toEqual([ + "Scaffolding project files...", + "Installing CDK dependencies (npm install)...", + "Syncing Python dependencies (uv sync)...", + "Initializing git repository...", + ]); + }); + + test("a failed step propagates and leaves the scaffolded files in place", async () => { + const directory = await inTempDirectory(); + const failing = new FsProjectManager({ + logger: createSilentLogger(), + runner: async () => { + throw new Error("npm exploded"); + }, + }); + + await expect( + failing.create({ name: "example", template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON }), + ).rejects.toThrow("npm exploded"); + expect(await Bun.file(join(directory, "example", "agentcore", "agentcore.json")).exists()).toBe( + true, + ); }); test("refuses to create a project inside an existing project", async () => { diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index c4f14ece7..2fcc59e7b 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -8,8 +8,10 @@ import type { ProjectManager, } from "../../handlers/project/types"; import type { Logger } from "../../logging"; +import { requireTool, runCommand, type CommandRunner } from "../../io"; import { projectTree } from "./compose"; import { defaultSource, type AssetSource } from "./source"; +import { TEMPLATES } from "./templates"; import { writeTree } from "./tree"; /** Walks up from directory looking for the agentcore/agentcore.json project marker. */ @@ -27,6 +29,7 @@ function enclosingProjectRoot(directory: string): string | undefined { type ProjectManagerConfig = { logger: Logger; source?: AssetSource; // Bun executable or dist/assets depending on runtime + runner?: CommandRunner; // injectable so tests never spawn real processes }; /** @@ -35,10 +38,12 @@ type ProjectManagerConfig = { export class FsProjectManager implements ProjectManager { private readonly logger: Logger; private readonly source: AssetSource; + private readonly runner: CommandRunner; constructor(config: ProjectManagerConfig) { this.logger = config.logger; this.source = config.source ?? defaultSource(); + this.runner = config.runner ?? runCommand; } public resolve(_input: ResolveProjectInput): Promise { @@ -54,9 +59,36 @@ export class FsProjectManager implements ProjectManager { const destination = join(process.cwd(), input.name); this.logger.debug(`scaffolding project "${input.name}" from template "${input.template}"`); + input.onProgress?.("Scaffolding project files..."); const tree = await projectTree(input.name, input.template, this.source); await writeTree(tree, destination); + // A failed step leaves the scaffolded files in place; the error tells the + // user how to rerun the step by hand. + if (!input.skipInstall) { + requireTool("npm", "Install Node.js: https://nodejs.org/"); + input.onProgress?.("Installing CDK dependencies (npm install)..."); + await this.run(["npm", "install"], join(destination, "agentcore", "cdk")); + + const appDir = join(destination, "app", TEMPLATES[input.template].appDir); + if (existsSync(join(appDir, "pyproject.toml"))) { + requireTool("uv", "Install uv: https://docs.astral.sh/uv/getting-started/installation/"); + input.onProgress?.("Syncing Python dependencies (uv sync)..."); + await this.run(["uv", "sync"], appDir); + } + } + + if (!input.skipGit) { + requireTool("git", "Install git: https://git-scm.com/downloads"); + input.onProgress?.("Initializing git repository..."); + await this.run(["git", "init"], destination); + } + return { name: input.name }; } + + // Runs a command with its output streamed to the file logger. + private run(command: string[], cwd: string): Promise { + return this.runner(command, { cwd, onOutput: (chunk) => this.logger.debug(chunk) }); + } } diff --git a/src/handlers/index.tsx b/src/handlers/index.tsx index 39897fb6b..87def85c6 100644 --- a/src/handlers/index.tsx +++ b/src/handlers/index.tsx @@ -50,7 +50,7 @@ export function createRootHandler(core: Core, config: RootHandlerConfig): Router root.handler(createGatewayHandler(core, io)); root.handler(createEvalHandler(core, io)); root.handler(createConfigHandler()); - root.handler(createProjectHandler({ projectManager: core.projectManager })); + root.handler(createProjectHandler({ projectManager: core.projectManager, io })); // Invoking with no subcommand launches the interactive TUI. root.default(renderTui(core, io)); diff --git a/src/handlers/project/create/index.ts b/src/handlers/project/create/index.ts index 9967cdb7e..520062dbb 100644 --- a/src/handlers/project/create/index.ts +++ b/src/handlers/project/create/index.ts @@ -1,9 +1,11 @@ import z from "zod"; import { createHandler, flag } from "../../../router"; +import type { AppIO } from "../../../io"; import { PROJECT_TEMPLATES, ProjectNameSchema, type ProjectManager } from "../types"; type CreateProjectHandlerConfig = { projectManager: ProjectManager; + io: AppIO; }; export const createCreateProjectHandler = (config: CreateProjectHandlerConfig) => @@ -17,11 +19,22 @@ export const createCreateProjectHandler = (config: CreateProjectHandlerConfig) = "project template to scaffold from", z.enum(PROJECT_TEMPLATES).default(PROJECT_TEMPLATES.HELLO_WORLD_PYTHON), ), + flag( + "skip-install", + "skip installing dependencies (npm install, uv sync)", + z.boolean().default(false), + ), + flag("skip-git", "skip initializing a git repository", z.boolean().default(false)), ], handle: async (_ctx, flags) => { - await config.projectManager.create({ + // Progress and success go to stderr, keeping stdout for machine output. + const project = await config.projectManager.create({ name: flags["project-name"], template: flags["template"], + skipInstall: flags["skip-install"], + skipGit: flags["skip-git"], + onProgress: (message) => config.io.stderr.write(`${message}\n`), }); + config.io.stderr.write(`Created project '${project.name}' in ./${project.name}\n`); }, }); diff --git a/src/handlers/project/index.ts b/src/handlers/project/index.ts index 8dc35a487..6c681a32d 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -1,4 +1,5 @@ import { Router } from "../../router"; +import type { AppIO } from "../../io"; import { createCreateProjectHandler } from "./create"; import { createAddProjectHandler } from "./add"; import { createRemoveProjectHandler } from "./remove"; @@ -10,12 +11,15 @@ import type { ProjectManager } from "./types"; type ProjectHandlerConfig = { projectManager: ProjectManager; + io: AppIO; }; export function createProjectHandler(config: ProjectHandlerConfig): Router { const project = new Router("project", "manage an AgentCore project"); - project.handler(createCreateProjectHandler({ projectManager: config.projectManager })); + project.handler( + createCreateProjectHandler({ projectManager: config.projectManager, io: config.io }), + ); project.handler(createAddProjectHandler()); project.handler(createRemoveProjectHandler()); project.handler(createDevProjectHandler()); diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 7e1d13c0f..94b669535 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -10,14 +10,16 @@ import { testIO, } from "../../testing"; -async function run(args: string[]): Promise { +async function run(args: string[]) { const io = testIO(); - const root = createRootHandler(new TestCoreClient(), { + const core = new TestCoreClient(); + const root = createRootHandler(core, { io: io.io, globalConfigAccessor: new TestGlobalConfigAccessor(), logger: createSilentLogger(), }); await root.route(["node", "agentcore", "project", ...args]); + return { io, core }; } describe.each(["add", "remove", "dev", "deploy", "status", "build"])("project %s", (command) => { @@ -33,7 +35,9 @@ async function inTempDirectory(): Promise { const directory = await mkdtemp(join(tmpdir(), "agentcore-project-")); tempDirectories.push(directory); process.chdir(directory); - return directory; + // cwd is the realpath (macOS tmpdir lives behind a /var -> /private/var + // symlink), matching the paths the manager derives from process.cwd(). + return process.cwd(); } afterEach(async () => { @@ -64,6 +68,33 @@ describe("project create", () => { await expect(run(["create", "--project-name", "test"])).rejects.toThrow(/conflicts with/); }); + test("runs the post-scaffold steps and reports progress on stderr", async () => { + const directory = await inTempDirectory(); + const { io, core } = await run(["create", "--project-name", "MyAgent"]); + + const projectRoot = join(directory, "MyAgent"); + expect(core.projectCommands).toEqual([ + { command: ["npm", "install"], cwd: join(projectRoot, "agentcore", "cdk") }, + { command: ["uv", "sync"], cwd: join(projectRoot, "app", "hello-world") }, + { command: ["git", "init"], cwd: projectRoot }, + ]); + expect(io.stderr()).toContain("Scaffolding project files..."); + expect(io.stderr()).toContain("Created project 'MyAgent' in ./MyAgent"); + }); + + test("--skip-install and --skip-git run no commands", async () => { + await inTempDirectory(); + const { core } = await run([ + "create", + "--project-name", + "MyAgent", + "--skip-install", + "--skip-git", + ]); + + expect(core.projectCommands).toEqual([]); + }); + test("rejects an unknown --template value", async () => { await inTempDirectory(); await expect( diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index 458d562c4..aa2b5cd8d 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -88,6 +88,12 @@ export type CreateProjectInput = { name: string; /** The project template to scaffold from. */ template: ProjectTemplate; + /** Skip installing dependencies (npm install, uv sync). */ + skipInstall?: boolean; + /** Skip initializing a git repository. */ + skipGit?: boolean; + /** Called as each creation step begins; drives progress output. */ + onProgress?: (message: string) => void; }; export type ResolveProjectInput = { diff --git a/src/io/exec.test.ts b/src/io/exec.test.ts new file mode 100644 index 000000000..921e4460c --- /dev/null +++ b/src/io/exec.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test"; +import { CommandFailedError, MissingToolError, requireTool, runCommand, toolOnPath } from "./exec"; + +describe("toolOnPath", () => { + test("finds a tool that exists", () => { + // node is guaranteed present: it's running this test suite's runtime deps. + expect(toolOnPath("node")).toBe(true); + }); + + test("misses a tool that does not exist", () => { + expect(toolOnPath("definitely-not-a-real-tool-xyz")).toBe(false); + }); +}); + +describe("requireTool", () => { + test("passes for an available tool", () => { + expect(() => requireTool("node", "unused hint")).not.toThrow(); + }); + + test("throws MissingToolError with the install hint", () => { + expect(() => + requireTool("definitely-not-a-real-tool-xyz", "Install it from https://example.com"), + ).toThrow( + new MissingToolError("definitely-not-a-real-tool-xyz", "Install it from https://example.com"), + ); + }); +}); + +describe("runCommand", () => { + test("resolves on exit 0 and streams output to onOutput", async () => { + const chunks: string[] = []; + await runCommand(["node", "-e", "console.log('hello')"], { + cwd: process.cwd(), + onOutput: (chunk) => chunks.push(chunk), + }); + + expect(chunks.join("")).toContain("hello"); + }); + + test("rejects with CommandFailedError carrying output and exit code", async () => { + const command = ["node", "-e", "console.error('boom'); process.exit(3)"]; + const promise = runCommand(command, { cwd: process.cwd() }); + + await expect(promise).rejects.toBeInstanceOf(CommandFailedError); + await expect(promise).rejects.toThrow(/exit code 3/); + await expect(promise).rejects.toThrow(/boom/); + }); + + test("rejects with CommandFailedError when the executable cannot spawn", async () => { + await expect( + runCommand(["definitely-not-a-real-tool-xyz"], { cwd: process.cwd() }), + ).rejects.toBeInstanceOf(CommandFailedError); + }); +}); diff --git a/src/io/exec.ts b/src/io/exec.ts new file mode 100644 index 000000000..66a1db102 --- /dev/null +++ b/src/io/exec.ts @@ -0,0 +1,86 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { delimiter, join } from "node:path"; +import { AgentCoreCLIError, ERROR_SOURCE } from "../errors"; + +/** Error raised when a required executable is not found on PATH. */ +export class MissingToolError extends AgentCoreCLIError { + constructor(tool: string, installHint: string) { + super(`'${tool}' was not found on your PATH. ${installHint}`, { + source: ERROR_SOURCE.USER, + meta: { tool }, + }); + } +} + +/** Error raised when a subprocess exits non-zero, carrying its captured output. */ +export class CommandFailedError extends AgentCoreCLIError { + constructor(command: string[], cwd: string, exitCode: number | null, output: string) { + const rendered = command.join(" "); + super( + `'${rendered}' failed in ${cwd} (exit code ${exitCode ?? "unknown"}).\n\n` + + `${output.trim()}\n\n` + + `Fix the issue and run 'cd ${cwd} && ${rendered}' to retry.`, + { source: ERROR_SOURCE.USER, meta: { command, cwd, exitCode } }, + ); + } +} + +/** Returns true if `tool` resolves to an executable on PATH. */ +export function toolOnPath(tool: string): boolean { + // On Windows executables carry a PATHEXT suffix (npm -> npm.cmd); elsewhere + // the bare name is the file. + const extensions = + process.platform === "win32" ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";") : [""]; + return (process.env.PATH ?? "") + .split(delimiter) + .filter(Boolean) + .some((dir) => extensions.some((ext) => existsSync(join(dir, tool + ext)))); +} + +/** Throws {@link MissingToolError} unless `tool` is available on PATH. */ +export function requireTool(tool: string, installHint: string): void { + if (!toolOnPath(tool)) throw new MissingToolError(tool, installHint); +} + +export type RunCommandOptions = { + /** Working directory the command runs in. */ + cwd: string; + /** Receives each chunk of combined stdout/stderr as it streams (e.g. into a logger). */ + onOutput?: (chunk: string) => void; +}; + +/** Runs a command to completion. Injectable so tests never spawn real processes. */ +export type CommandRunner = (command: string[], options: RunCommandOptions) => Promise; + +/** + * Runs a command, streaming combined stdout/stderr to `onOutput` while also + * capturing it; rejects with {@link CommandFailedError} on a non-zero exit. + */ +export const runCommand: CommandRunner = ([executable, ...args], { cwd, onOutput }) => { + return new Promise((resolve, reject) => { + // shell on win32 so PATHEXT resolution (npm.cmd etc.) works. + const child = spawn(executable!, args, { + cwd, + stdio: ["ignore", "pipe", "pipe"], + shell: process.platform === "win32", + }); + + let output = ""; + const collect = (chunk: Buffer) => { + const text = chunk.toString(); + output += text; + onOutput?.(text); + }; + child.stdout.on("data", collect); + child.stderr.on("data", collect); + + child.on("error", (error) => { + reject(new CommandFailedError([executable!, ...args], cwd, null, String(error))); + }); + child.on("close", (exitCode) => { + if (exitCode === 0) resolve(); + else reject(new CommandFailedError([executable!, ...args], cwd, exitCode, output)); + }); + }); +}; diff --git a/src/io/index.ts b/src/io/index.ts index a802d5fc7..0cb7479c7 100644 --- a/src/io/index.ts +++ b/src/io/index.ts @@ -1,4 +1,13 @@ export { atomicWrite } from "./atomicWrite"; +export { + CommandFailedError, + MissingToolError, + requireTool, + runCommand, + toolOnPath, + type CommandRunner, + type RunCommandOptions, +} from "./exec"; export { FsReadWriteJson } from "./json"; export { SourceResolver, type SourceResolverConfig } from "./source"; export type { AppIO, ReadWriteJson } from "./types"; diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index b80546f97..c036fc812 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -935,7 +935,16 @@ export class TestCoreClient implements Core { readonly eval = new TestEvalClient(); readonly projectManager: ProjectManager; + // Commands the project manager would have run (npm install, git init, ...), + // recorded instead of spawned so tests stay fast and hermetic. + readonly projectCommands: { command: string[]; cwd: string }[] = []; + constructor(options?: TestCoreClientOptions) { - this.projectManager = new FsProjectManager({ logger: options?.logger ?? createSilentLogger() }); + this.projectManager = new FsProjectManager({ + logger: options?.logger ?? createSilentLogger(), + runner: async (command, { cwd }) => { + this.projectCommands.push({ command, cwd }); + }, + }); } } From e53c060e352511b8ec06446b9269a131aec17978 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Thu, 30 Jul 2026 11:25:54 -0400 Subject: [PATCH 2/8] docs(template): TODO to swap local-run instructions for agentcore dev/invoke --- src/assets/templates/hello-world-python/README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/assets/templates/hello-world-python/README.md b/src/assets/templates/hello-world-python/README.md index 07538a277..b43ddbbba 100644 --- a/src/assets/templates/hello-world-python/README.md +++ b/src/assets/templates/hello-world-python/README.md @@ -27,6 +27,9 @@ curl -X POST http://localhost:8080/invocations \ -d '{"prompt": "Hello!"}' ``` + + ## Build your agent Start in `main.py`: From 826787f6d70923824420881ff919f898254381e2 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Thu, 30 Jul 2026 11:30:27 -0400 Subject: [PATCH 3/8] test(project): inject a no-op tool check so tests don't depend on host PATH CI runners don't have uv installed; FsProjectManager's requireTool calls ran against the real PATH even with the fake command runner. checkTool is now injectable alongside runner, defaulting to the real requireTool. --- src/core/project/manager.test.ts | 2 ++ src/core/project/manager.tsx | 9 ++++++--- src/testing/TestCoreClient.tsx | 1 + 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index f84f6cbea..00b271394 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -35,6 +35,7 @@ function manager(): { manager: FsProjectManager; commands: { command: string[]; runner: async (command, { cwd }) => { commands.push({ command, cwd }); }, + checkTool: () => {}, // CI hosts don't have uv installed }), commands, }; @@ -149,6 +150,7 @@ describe("FsProjectManager.create", () => { runner: async () => { throw new Error("npm exploded"); }, + checkTool: () => {}, }); await expect( diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 2fcc59e7b..b5d400ac7 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -30,6 +30,7 @@ type ProjectManagerConfig = { logger: Logger; source?: AssetSource; // Bun executable or dist/assets depending on runtime runner?: CommandRunner; // injectable so tests never spawn real processes + checkTool?: typeof requireTool; // injectable so tests don't depend on the host's PATH }; /** @@ -39,11 +40,13 @@ export class FsProjectManager implements ProjectManager { private readonly logger: Logger; private readonly source: AssetSource; private readonly runner: CommandRunner; + private readonly checkTool: typeof requireTool; constructor(config: ProjectManagerConfig) { this.logger = config.logger; this.source = config.source ?? defaultSource(); this.runner = config.runner ?? runCommand; + this.checkTool = config.checkTool ?? requireTool; } public resolve(_input: ResolveProjectInput): Promise { @@ -66,20 +69,20 @@ export class FsProjectManager implements ProjectManager { // A failed step leaves the scaffolded files in place; the error tells the // user how to rerun the step by hand. if (!input.skipInstall) { - requireTool("npm", "Install Node.js: https://nodejs.org/"); + this.checkTool("npm", "Install Node.js: https://nodejs.org/"); input.onProgress?.("Installing CDK dependencies (npm install)..."); await this.run(["npm", "install"], join(destination, "agentcore", "cdk")); const appDir = join(destination, "app", TEMPLATES[input.template].appDir); if (existsSync(join(appDir, "pyproject.toml"))) { - requireTool("uv", "Install uv: https://docs.astral.sh/uv/getting-started/installation/"); + this.checkTool("uv", "Install uv: https://docs.astral.sh/uv/getting-started/installation/"); input.onProgress?.("Syncing Python dependencies (uv sync)..."); await this.run(["uv", "sync"], appDir); } } if (!input.skipGit) { - requireTool("git", "Install git: https://git-scm.com/downloads"); + this.checkTool("git", "Install git: https://git-scm.com/downloads"); input.onProgress?.("Initializing git repository..."); await this.run(["git", "init"], destination); } diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index c036fc812..2d6a13df3 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -945,6 +945,7 @@ export class TestCoreClient implements Core { runner: async (command, { cwd }) => { this.projectCommands.push({ command, cwd }); }, + checkTool: () => {}, // CI hosts don't have uv installed }); } } From da43186d9a4c60833071b07e379ed1147ead09e3 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Thu, 30 Jul 2026 12:58:08 -0400 Subject: [PATCH 4/8] test(io): run exec test scripts from files for win32 compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit runCommand spawns through cmd.exe on Windows (shell: true for PATHEXT resolution), which mangles the quoting of node -e one-liners — the 'boom; exit 3' script never ran, so the process exited 0 and the rejection assertion failed. Write the scripts to temp files instead. --- src/io/exec.test.ts | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/io/exec.test.ts b/src/io/exec.test.ts index 921e4460c..625c5d191 100644 --- a/src/io/exec.test.ts +++ b/src/io/exec.test.ts @@ -1,6 +1,20 @@ -import { describe, expect, test } from "bun:test"; +import { afterAll, describe, expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { CommandFailedError, MissingToolError, requireTool, runCommand, toolOnPath } from "./exec"; +// Scripts run from files rather than `node -e` one-liners: on win32 runCommand +// spawns through cmd.exe (for PATHEXT resolution), which mangles quoted args. +const scriptsDir = await mkdtemp(join(tmpdir(), "agentcore-exec-")); +async function script(name: string, source: string): Promise { + const path = join(scriptsDir, name); + await writeFile(path, source); + return path; +} + +afterAll(() => rm(scriptsDir, { recursive: true, force: true })); + describe("toolOnPath", () => { test("finds a tool that exists", () => { // node is guaranteed present: it's running this test suite's runtime deps. @@ -28,8 +42,9 @@ describe("requireTool", () => { describe("runCommand", () => { test("resolves on exit 0 and streams output to onOutput", async () => { + const succeeding = await script("succeed.js", "console.log('hello')"); const chunks: string[] = []; - await runCommand(["node", "-e", "console.log('hello')"], { + await runCommand(["node", succeeding], { cwd: process.cwd(), onOutput: (chunk) => chunks.push(chunk), }); @@ -38,8 +53,8 @@ describe("runCommand", () => { }); test("rejects with CommandFailedError carrying output and exit code", async () => { - const command = ["node", "-e", "console.error('boom'); process.exit(3)"]; - const promise = runCommand(command, { cwd: process.cwd() }); + const failing = await script("fail.js", "console.error('boom'); process.exit(3)"); + const promise = runCommand(["node", failing], { cwd: process.cwd() }); await expect(promise).rejects.toBeInstanceOf(CommandFailedError); await expect(promise).rejects.toThrow(/exit code 3/); From 837a8f1652a1ec3c434d938f523bd72213acef82 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Fri, 31 Jul 2026 12:51:31 -0400 Subject: [PATCH 5/8] refactor(io): address review feedback on exec utility and progress events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename CommandRunner/runCommand/CommandFailedError to ProcessRunner/runProcess/ProcessFailedError: 'command' collides with the CLI's own command vocabulary; 'process' mirrors child_process. - Replace the PATH-probing toolOnPath with toolAvailable, which runs ' --version' — simpler and more reliable than reimplementing PATH/PATHEXT resolution. - onProgress now emits a structured CreateProgressEvent instead of a bare string, leaving room for richer step info without a breaking change. --- src/core/project/manager.test.ts | 13 ++++--- src/core/project/manager.tsx | 25 +++++++------ src/handlers/project/create/index.ts | 2 +- src/handlers/project/types.ts | 8 ++++- src/io/exec.test.ts | 46 +++++++++++++----------- src/io/exec.ts | 53 ++++++++++++++-------------- src/io/index.ts | 10 +++--- src/testing/TestCoreClient.tsx | 2 +- 8 files changed, 88 insertions(+), 71 deletions(-) diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index 00b271394..eec07ca77 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -35,7 +35,7 @@ function manager(): { manager: FsProjectManager; commands: { command: string[]; runner: async (command, { cwd }) => { commands.push({ command, cwd }); }, - checkTool: () => {}, // CI hosts don't have uv installed + checkTool: async () => {}, // CI hosts don't have uv installed }), commands, }; @@ -132,7 +132,7 @@ describe("FsProjectManager.create", () => { await manager().manager.create({ name: "example", template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON, - onProgress: (message) => messages.push(message), + onProgress: (event) => messages.push(event.message), }); expect(messages).toEqual([ @@ -150,7 +150,7 @@ describe("FsProjectManager.create", () => { runner: async () => { throw new Error("npm exploded"); }, - checkTool: () => {}, + checkTool: async () => {}, }); await expect( @@ -163,11 +163,14 @@ describe("FsProjectManager.create", () => { test("refuses to create a project inside an existing project", async () => { const directory = await inTempDirectory(); - await manager().create({ name: "root", template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON }); + await manager().manager.create({ + name: "root", + template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON, + }); process.chdir(join(directory, "root")); await expect( - manager().create({ name: "child", template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON }), + manager().manager.create({ name: "child", template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON }), ).rejects.toBeInstanceOf(NestedProjectError); }); }); diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index b5d400ac7..a834ea106 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -8,7 +8,7 @@ import type { ProjectManager, } from "../../handlers/project/types"; import type { Logger } from "../../logging"; -import { requireTool, runCommand, type CommandRunner } from "../../io"; +import { requireTool, runProcess, type ProcessRunner } from "../../io"; import { projectTree } from "./compose"; import { defaultSource, type AssetSource } from "./source"; import { TEMPLATES } from "./templates"; @@ -29,7 +29,7 @@ function enclosingProjectRoot(directory: string): string | undefined { type ProjectManagerConfig = { logger: Logger; source?: AssetSource; // Bun executable or dist/assets depending on runtime - runner?: CommandRunner; // injectable so tests never spawn real processes + runner?: ProcessRunner; // injectable so tests never spawn real processes checkTool?: typeof requireTool; // injectable so tests don't depend on the host's PATH }; @@ -39,13 +39,13 @@ type ProjectManagerConfig = { export class FsProjectManager implements ProjectManager { private readonly logger: Logger; private readonly source: AssetSource; - private readonly runner: CommandRunner; + private readonly runner: ProcessRunner; private readonly checkTool: typeof requireTool; constructor(config: ProjectManagerConfig) { this.logger = config.logger; this.source = config.source ?? defaultSource(); - this.runner = config.runner ?? runCommand; + this.runner = config.runner ?? runProcess; this.checkTool = config.checkTool ?? requireTool; } @@ -62,28 +62,31 @@ export class FsProjectManager implements ProjectManager { const destination = join(process.cwd(), input.name); this.logger.debug(`scaffolding project "${input.name}" from template "${input.template}"`); - input.onProgress?.("Scaffolding project files..."); + input.onProgress?.({ message: "Scaffolding project files..." }); const tree = await projectTree(input.name, input.template, this.source); await writeTree(tree, destination); // A failed step leaves the scaffolded files in place; the error tells the // user how to rerun the step by hand. if (!input.skipInstall) { - this.checkTool("npm", "Install Node.js: https://nodejs.org/"); - input.onProgress?.("Installing CDK dependencies (npm install)..."); + await this.checkTool("npm", "Install Node.js: https://nodejs.org/"); + input.onProgress?.({ message: "Installing CDK dependencies (npm install)..." }); await this.run(["npm", "install"], join(destination, "agentcore", "cdk")); const appDir = join(destination, "app", TEMPLATES[input.template].appDir); if (existsSync(join(appDir, "pyproject.toml"))) { - this.checkTool("uv", "Install uv: https://docs.astral.sh/uv/getting-started/installation/"); - input.onProgress?.("Syncing Python dependencies (uv sync)..."); + await this.checkTool( + "uv", + "Install uv: https://docs.astral.sh/uv/getting-started/installation/", + ); + input.onProgress?.({ message: "Syncing Python dependencies (uv sync)..." }); await this.run(["uv", "sync"], appDir); } } if (!input.skipGit) { - this.checkTool("git", "Install git: https://git-scm.com/downloads"); - input.onProgress?.("Initializing git repository..."); + await this.checkTool("git", "Install git: https://git-scm.com/downloads"); + input.onProgress?.({ message: "Initializing git repository..." }); await this.run(["git", "init"], destination); } diff --git a/src/handlers/project/create/index.ts b/src/handlers/project/create/index.ts index 520062dbb..d769b3ee3 100644 --- a/src/handlers/project/create/index.ts +++ b/src/handlers/project/create/index.ts @@ -33,7 +33,7 @@ export const createCreateProjectHandler = (config: CreateProjectHandlerConfig) = template: flags["template"], skipInstall: flags["skip-install"], skipGit: flags["skip-git"], - onProgress: (message) => config.io.stderr.write(`${message}\n`), + onProgress: (event) => config.io.stderr.write(`${event.message}\n`), }); config.io.stderr.write(`Created project '${project.name}' in ./${project.name}\n`); }, diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index aa2b5cd8d..f1d767303 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -93,7 +93,13 @@ export type CreateProjectInput = { /** Skip initializing a git repository. */ skipGit?: boolean; /** Called as each creation step begins; drives progress output. */ - onProgress?: (message: string) => void; + onProgress?: (event: CreateProgressEvent) => void; +}; + +/** A progress update emitted as a creation step begins. */ +export type CreateProgressEvent = { + /** Human-readable description of the step. */ + message: string; }; export type ResolveProjectInput = { diff --git a/src/io/exec.test.ts b/src/io/exec.test.ts index 625c5d191..0e40279e2 100644 --- a/src/io/exec.test.ts +++ b/src/io/exec.test.ts @@ -2,9 +2,15 @@ import { afterAll, describe, expect, test } from "bun:test"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { CommandFailedError, MissingToolError, requireTool, runCommand, toolOnPath } from "./exec"; +import { + MissingToolError, + ProcessFailedError, + requireTool, + runProcess, + toolAvailable, +} from "./exec"; -// Scripts run from files rather than `node -e` one-liners: on win32 runCommand +// Scripts run from files rather than `node -e` one-liners: on win32 runProcess // spawns through cmd.exe (for PATHEXT resolution), which mangles quoted args. const scriptsDir = await mkdtemp(join(tmpdir(), "agentcore-exec-")); async function script(name: string, source: string): Promise { @@ -15,36 +21,36 @@ async function script(name: string, source: string): Promise { afterAll(() => rm(scriptsDir, { recursive: true, force: true })); -describe("toolOnPath", () => { - test("finds a tool that exists", () => { +describe("toolAvailable", () => { + test("finds a tool that exists", async () => { // node is guaranteed present: it's running this test suite's runtime deps. - expect(toolOnPath("node")).toBe(true); + expect(await toolAvailable("node")).toBe(true); }); - test("misses a tool that does not exist", () => { - expect(toolOnPath("definitely-not-a-real-tool-xyz")).toBe(false); + test("misses a tool that does not exist", async () => { + expect(await toolAvailable("definitely-not-a-real-tool-xyz")).toBe(false); }); }); describe("requireTool", () => { - test("passes for an available tool", () => { - expect(() => requireTool("node", "unused hint")).not.toThrow(); + test("passes for an available tool", async () => { + await expect(requireTool("node", "unused hint")).resolves.toBeUndefined(); }); - test("throws MissingToolError with the install hint", () => { - expect(() => + test("throws MissingToolError with the install hint", async () => { + await expect( requireTool("definitely-not-a-real-tool-xyz", "Install it from https://example.com"), - ).toThrow( + ).rejects.toThrow( new MissingToolError("definitely-not-a-real-tool-xyz", "Install it from https://example.com"), ); }); }); -describe("runCommand", () => { +describe("runProcess", () => { test("resolves on exit 0 and streams output to onOutput", async () => { const succeeding = await script("succeed.js", "console.log('hello')"); const chunks: string[] = []; - await runCommand(["node", succeeding], { + await runProcess(["node", succeeding], { cwd: process.cwd(), onOutput: (chunk) => chunks.push(chunk), }); @@ -52,18 +58,18 @@ describe("runCommand", () => { expect(chunks.join("")).toContain("hello"); }); - test("rejects with CommandFailedError carrying output and exit code", async () => { + test("rejects with ProcessFailedError carrying output and exit code", async () => { const failing = await script("fail.js", "console.error('boom'); process.exit(3)"); - const promise = runCommand(["node", failing], { cwd: process.cwd() }); + const promise = runProcess(["node", failing], { cwd: process.cwd() }); - await expect(promise).rejects.toBeInstanceOf(CommandFailedError); + await expect(promise).rejects.toBeInstanceOf(ProcessFailedError); await expect(promise).rejects.toThrow(/exit code 3/); await expect(promise).rejects.toThrow(/boom/); }); - test("rejects with CommandFailedError when the executable cannot spawn", async () => { + test("rejects with ProcessFailedError when the executable cannot spawn", async () => { await expect( - runCommand(["definitely-not-a-real-tool-xyz"], { cwd: process.cwd() }), - ).rejects.toBeInstanceOf(CommandFailedError); + runProcess(["definitely-not-a-real-tool-xyz"], { cwd: process.cwd() }), + ).rejects.toBeInstanceOf(ProcessFailedError); }); }); diff --git a/src/io/exec.ts b/src/io/exec.ts index 66a1db102..ceb90ddfa 100644 --- a/src/io/exec.ts +++ b/src/io/exec.ts @@ -1,8 +1,11 @@ +// Local subprocess execution. Uses node:child_process (not Bun.$/Bun.spawn) +// because the npm bundle targets Node — Bun APIs are unavailable there. import { spawn } from "node:child_process"; -import { existsSync } from "node:fs"; -import { delimiter, join } from "node:path"; import { AgentCoreCLIError, ERROR_SOURCE } from "../errors"; +// cmd.exe resolves PATHEXT executables (npm.cmd, uv.exe) that a bare spawn misses. +const useShell = process.platform === "win32"; + /** Error raised when a required executable is not found on PATH. */ export class MissingToolError extends AgentCoreCLIError { constructor(tool: string, installHint: string) { @@ -14,7 +17,7 @@ export class MissingToolError extends AgentCoreCLIError { } /** Error raised when a subprocess exits non-zero, carrying its captured output. */ -export class CommandFailedError extends AgentCoreCLIError { +export class ProcessFailedError extends AgentCoreCLIError { constructor(command: string[], cwd: string, exitCode: number | null, output: string) { const rendered = command.join(" "); super( @@ -26,44 +29,40 @@ export class CommandFailedError extends AgentCoreCLIError { } } -/** Returns true if `tool` resolves to an executable on PATH. */ -export function toolOnPath(tool: string): boolean { - // On Windows executables carry a PATHEXT suffix (npm -> npm.cmd); elsewhere - // the bare name is the file. - const extensions = - process.platform === "win32" ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";") : [""]; - return (process.env.PATH ?? "") - .split(delimiter) - .filter(Boolean) - .some((dir) => extensions.some((ext) => existsSync(join(dir, tool + ext)))); +/** Returns true if `tool --version` runs successfully — more reliable than probing PATH. */ +export function toolAvailable(tool: string): Promise { + return new Promise((resolve) => { + const child = spawn(tool, ["--version"], { stdio: "ignore", shell: useShell }); + child.on("error", () => resolve(false)); + child.on("close", (exitCode) => resolve(exitCode === 0)); + }); } -/** Throws {@link MissingToolError} unless `tool` is available on PATH. */ -export function requireTool(tool: string, installHint: string): void { - if (!toolOnPath(tool)) throw new MissingToolError(tool, installHint); +/** Throws {@link MissingToolError} unless `tool` is available. */ +export async function requireTool(tool: string, installHint: string): Promise { + if (!(await toolAvailable(tool))) throw new MissingToolError(tool, installHint); } -export type RunCommandOptions = { - /** Working directory the command runs in. */ +export type RunProcessOptions = { + /** Working directory the process runs in. */ cwd: string; /** Receives each chunk of combined stdout/stderr as it streams (e.g. into a logger). */ onOutput?: (chunk: string) => void; }; -/** Runs a command to completion. Injectable so tests never spawn real processes. */ -export type CommandRunner = (command: string[], options: RunCommandOptions) => Promise; +/** Runs a subprocess to completion. Injectable so tests never spawn real processes. */ +export type ProcessRunner = (command: string[], options: RunProcessOptions) => Promise; /** - * Runs a command, streaming combined stdout/stderr to `onOutput` while also - * capturing it; rejects with {@link CommandFailedError} on a non-zero exit. + * Runs a subprocess, streaming combined stdout/stderr to `onOutput` while also + * capturing it; rejects with {@link ProcessFailedError} on a non-zero exit. */ -export const runCommand: CommandRunner = ([executable, ...args], { cwd, onOutput }) => { +export const runProcess: ProcessRunner = ([executable, ...args], { cwd, onOutput }) => { return new Promise((resolve, reject) => { - // shell on win32 so PATHEXT resolution (npm.cmd etc.) works. const child = spawn(executable!, args, { cwd, stdio: ["ignore", "pipe", "pipe"], - shell: process.platform === "win32", + shell: useShell, }); let output = ""; @@ -76,11 +75,11 @@ export const runCommand: CommandRunner = ([executable, ...args], { cwd, onOutput child.stderr.on("data", collect); child.on("error", (error) => { - reject(new CommandFailedError([executable!, ...args], cwd, null, String(error))); + reject(new ProcessFailedError([executable!, ...args], cwd, null, String(error))); }); child.on("close", (exitCode) => { if (exitCode === 0) resolve(); - else reject(new CommandFailedError([executable!, ...args], cwd, exitCode, output)); + else reject(new ProcessFailedError([executable!, ...args], cwd, exitCode, output)); }); }); }; diff --git a/src/io/index.ts b/src/io/index.ts index 0cb7479c7..f146280e6 100644 --- a/src/io/index.ts +++ b/src/io/index.ts @@ -1,12 +1,12 @@ export { atomicWrite } from "./atomicWrite"; export { - CommandFailedError, MissingToolError, + ProcessFailedError, requireTool, - runCommand, - toolOnPath, - type CommandRunner, - type RunCommandOptions, + runProcess, + toolAvailable, + type ProcessRunner, + type RunProcessOptions, } from "./exec"; export { FsReadWriteJson } from "./json"; export { SourceResolver, type SourceResolverConfig } from "./source"; diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index 2d6a13df3..f92e35846 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -945,7 +945,7 @@ export class TestCoreClient implements Core { runner: async (command, { cwd }) => { this.projectCommands.push({ command, cwd }); }, - checkTool: () => {}, // CI hosts don't have uv installed + checkTool: async () => {}, // CI hosts don't have uv installed }); } } From a5ff802bbf6b6d28382d08547fdee064b6236ac3 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Fri, 31 Jul 2026 13:03:27 -0400 Subject: [PATCH 6/8] fix(middleware): count only cli-passed flags when deciding TUI-on-empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The emptiness check counted the parsed flags object, where schema and Commander boolean defaults arrive as defined values — a leaf with any defaulted flag (list --max-results, create --template/--skip-*) could never open the TUI on a bare invocation. Use Commander's getOptionValueSource to count only flags the user actually passed, so mounting withTuiOnEmptyFlagsAndArgs on the project router later works with the defaulted create flags. --- src/middleware/withTuiOnEmptyFlagsAndArgs.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/middleware/withTuiOnEmptyFlagsAndArgs.tsx b/src/middleware/withTuiOnEmptyFlagsAndArgs.tsx index 3b6735782..bf79e8002 100644 --- a/src/middleware/withTuiOnEmptyFlagsAndArgs.tsx +++ b/src/middleware/withTuiOnEmptyFlagsAndArgs.tsx @@ -1,8 +1,9 @@ +import { Option, type Command } from "commander"; import { renderTui } from "../tui"; import { JsonKey } from "../handlers/keys"; import type { AppIO } from "../io"; import type { Core } from "../handlers/types"; -import { type Middleware } from "../router"; +import { CommandKey, type Handler, type Middleware } from "../router"; // countPassedValues counts how many entries of an object hold a defined value. const countPassedValues = (obj: object) => @@ -14,6 +15,16 @@ const countPassedValues = (obj: object) => return acc; }, 0); +// countPassedFlags counts the leaf's own flags the user actually supplied on +// the command line. The parsed flags object can't be used for this: schema +// (and Commander boolean) defaults arrive there as defined values, which would +// make a leaf with defaulted flags look non-empty on a bare invocation. +const countPassedFlags = (h: Handler, command: Command) => + h.flags().filter((f) => { + const attribute = new Option(`--${f.name}`).attributeName(); + return command.getOptionValueSource(attribute) === "cli"; + }).length; + // withTuiOnEmptyFlagsAndArgs opens the interactive TUI when a leaf command is // invoked with no flags or arguments (and not in JSON mode); otherwise it // delegates to the wrapped handler. @@ -29,7 +40,7 @@ export function withTuiOnEmptyFlagsAndArgs(core: Core, io: AppIO): Middleware { handle: async (ctx, flags, args) => { if ( !ctx.require(JsonKey) && - countPassedValues(flags) === 0 && + countPassedFlags(h, ctx.require(CommandKey)) === 0 && countPassedValues(args) === 0 ) { await boundRenderTui(ctx, flags, args); From cf22addf498c7d21d672331b25312da4a54b9c33 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Fri, 31 Jul 2026 13:10:32 -0400 Subject: [PATCH 7/8] test(middleware): cover TUI-on-empty vs headless decision Routes a real command tree with fully-defaulted flags: bare invocation opens the TUI (TTY error under testIO), explicitly passed defaulted or value flags and --json run the handler. --- .../withTuiOnEmptyFlagsAndArgs.test.tsx | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 src/middleware/withTuiOnEmptyFlagsAndArgs.test.tsx diff --git a/src/middleware/withTuiOnEmptyFlagsAndArgs.test.tsx b/src/middleware/withTuiOnEmptyFlagsAndArgs.test.tsx new file mode 100644 index 000000000..a3458c2d5 --- /dev/null +++ b/src/middleware/withTuiOnEmptyFlagsAndArgs.test.tsx @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test"; +import z from "zod"; +import { withTuiOnEmptyFlagsAndArgs } from "./withTuiOnEmptyFlagsAndArgs"; +import { Router, createHandler, flag } from "../router"; +import { JsonKey } from "../handlers/keys"; +import { TestCoreClient, testIO } from "../testing"; + +// The middleware decides between the TUI and the wrapped handler from what the +// user actually typed. These tests route a real command tree so Commander's +// option-source tracking (cli vs default) is exercised end to end; the TUI +// branch is observable as the renderTui TTY error (testIO is not a TTY), the +// headless branch as the leaf handler running. +function route(args: string[]): { ran: () => boolean; routed: Promise } { + let handled = false; + const leaf = createHandler({ + name: "leaf", + description: "a leaf with only defaulted flags", + flags: [ + flag("template", "defaulted enum", z.enum(["a", "b"]).default("a")), + flag("skip-thing", "defaulted boolean", z.boolean().default(false)), + ], + handle: async () => { + handled = true; + }, + }); + + const root = new Router("agentcore", "test root") + .groupFlags(JsonKey) + .use(withTuiOnEmptyFlagsAndArgs(new TestCoreClient(), testIO().io)) + .handler(leaf); + + return { ran: () => handled, routed: root.route(["node", "agentcore", "leaf", ...args]) }; +} + +describe("withTuiOnEmptyFlagsAndArgs", () => { + test("opens the TUI on a bare invocation even when every flag has a default", async () => { + const { ran, routed } = route([]); + + await expect(routed).rejects.toThrow("interactive mode requires a TTY on stdin and stdout"); + expect(ran()).toBe(false); + }); + + test("runs the handler when a defaulted flag is passed explicitly", async () => { + const { ran, routed } = route(["--skip-thing"]); + + await routed; + expect(ran()).toBe(true); + }); + + test("runs the handler when a value flag is passed explicitly", async () => { + const { ran, routed } = route(["--template", "b"]); + + await routed; + expect(ran()).toBe(true); + }); + + test("runs the handler under --json instead of opening the TUI", async () => { + const { ran, routed } = route(["--json"]); + + await routed; + expect(ran()).toBe(true); + }); +}); From 06154ddd2319b7e86efad27b963f705a8f61d273 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Fri, 31 Jul 2026 16:21:36 -0400 Subject: [PATCH 8/8] refactor(io): parameterize tool probe args; tighten middleware tests - toolAvailable/requireTool take probeArgs (default --version) for tools like ssh that only support -V. - Collapse the three headless-branch middleware tests into test.each and drop the over-explaining harness comment. --- src/io/exec.ts | 14 +++++---- .../withTuiOnEmptyFlagsAndArgs.test.tsx | 30 ++++++------------- 2 files changed, 18 insertions(+), 26 deletions(-) diff --git a/src/io/exec.ts b/src/io/exec.ts index ceb90ddfa..ce7d8f549 100644 --- a/src/io/exec.ts +++ b/src/io/exec.ts @@ -29,18 +29,22 @@ export class ProcessFailedError extends AgentCoreCLIError { } } -/** Returns true if `tool --version` runs successfully — more reliable than probing PATH. */ -export function toolAvailable(tool: string): Promise { +/** Returns true if running `tool` with probeArgs (`--version` by default) exits 0. */ +export function toolAvailable(tool: string, probeArgs: string[] = ["--version"]): Promise { return new Promise((resolve) => { - const child = spawn(tool, ["--version"], { stdio: "ignore", shell: useShell }); + const child = spawn(tool, probeArgs, { stdio: "ignore", shell: useShell }); child.on("error", () => resolve(false)); child.on("close", (exitCode) => resolve(exitCode === 0)); }); } /** Throws {@link MissingToolError} unless `tool` is available. */ -export async function requireTool(tool: string, installHint: string): Promise { - if (!(await toolAvailable(tool))) throw new MissingToolError(tool, installHint); +export async function requireTool( + tool: string, + installHint: string, + probeArgs?: string[], +): Promise { + if (!(await toolAvailable(tool, probeArgs))) throw new MissingToolError(tool, installHint); } export type RunProcessOptions = { diff --git a/src/middleware/withTuiOnEmptyFlagsAndArgs.test.tsx b/src/middleware/withTuiOnEmptyFlagsAndArgs.test.tsx index a3458c2d5..241fbc7bf 100644 --- a/src/middleware/withTuiOnEmptyFlagsAndArgs.test.tsx +++ b/src/middleware/withTuiOnEmptyFlagsAndArgs.test.tsx @@ -5,11 +5,9 @@ import { Router, createHandler, flag } from "../router"; import { JsonKey } from "../handlers/keys"; import { TestCoreClient, testIO } from "../testing"; -// The middleware decides between the TUI and the wrapped handler from what the -// user actually typed. These tests route a real command tree so Commander's -// option-source tracking (cli vs default) is exercised end to end; the TUI -// branch is observable as the renderTui TTY error (testIO is not a TTY), the -// headless branch as the leaf handler running. +// route runs a command tree whose leaf flags all carry defaults. The branch the +// middleware takes is observable: the TUI attempt throws (testIO is not a TTY), +// the headless path runs the leaf handler. function route(args: string[]): { ran: () => boolean; routed: Promise } { let handled = false; const leaf = createHandler({ @@ -40,22 +38,12 @@ describe("withTuiOnEmptyFlagsAndArgs", () => { expect(ran()).toBe(false); }); - test("runs the handler when a defaulted flag is passed explicitly", async () => { - const { ran, routed } = route(["--skip-thing"]); - - await routed; - expect(ran()).toBe(true); - }); - - test("runs the handler when a value flag is passed explicitly", async () => { - const { ran, routed } = route(["--template", "b"]); - - await routed; - expect(ran()).toBe(true); - }); - - test("runs the handler under --json instead of opening the TUI", async () => { - const { ran, routed } = route(["--json"]); + test.each([ + ["a defaulted boolean flag", ["--skip-thing"]], + ["a defaulted value flag", ["--template", "b"]], + ["--json", ["--json"]], + ])("runs the handler when %s is passed explicitly", async (_label, args) => { + const { ran, routed } = route(args); await routed; expect(ran()).toBe(true);