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..b43ddbbba --- /dev/null +++ b/src/assets/templates/hello-world-python/README.md @@ -0,0 +1,60 @@ +# 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..eec07ca77 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,28 @@ 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 }); + }, + checkTool: async () => {}, // CI hosts don't have uv installed + }), + 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 +62,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,17 +85,92 @@ 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: (event) => messages.push(event.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"); + }, + checkTool: async () => {}, + }); + + 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 () => { 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 c4f14ece7..a834ea106 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, runProcess, type ProcessRunner } 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,8 @@ function enclosingProjectRoot(directory: string): string | undefined { type ProjectManagerConfig = { logger: Logger; source?: AssetSource; // Bun executable or dist/assets depending on runtime + runner?: ProcessRunner; // injectable so tests never spawn real processes + checkTool?: typeof requireTool; // injectable so tests don't depend on the host's PATH }; /** @@ -35,10 +39,14 @@ type ProjectManagerConfig = { export class FsProjectManager implements ProjectManager { private readonly logger: Logger; private readonly source: AssetSource; + 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 ?? runProcess; + this.checkTool = config.checkTool ?? requireTool; } public resolve(_input: ResolveProjectInput): Promise { @@ -54,9 +62,39 @@ 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?.({ 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) { + 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"))) { + 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) { + await this.checkTool("git", "Install git: https://git-scm.com/downloads"); + input.onProgress?.({ message: "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..d769b3ee3 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: (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/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..f1d767303 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -88,6 +88,18 @@ 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?: (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 new file mode 100644 index 000000000..0e40279e2 --- /dev/null +++ b/src/io/exec.test.ts @@ -0,0 +1,75 @@ +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 { + MissingToolError, + ProcessFailedError, + requireTool, + runProcess, + toolAvailable, +} from "./exec"; + +// 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 { + const path = join(scriptsDir, name); + await writeFile(path, source); + return path; +} + +afterAll(() => rm(scriptsDir, { recursive: true, force: true })); + +describe("toolAvailable", () => { + test("finds a tool that exists", async () => { + // node is guaranteed present: it's running this test suite's runtime deps. + expect(await toolAvailable("node")).toBe(true); + }); + + 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", async () => { + await expect(requireTool("node", "unused hint")).resolves.toBeUndefined(); + }); + + test("throws MissingToolError with the install hint", async () => { + await expect( + requireTool("definitely-not-a-real-tool-xyz", "Install it from https://example.com"), + ).rejects.toThrow( + new MissingToolError("definitely-not-a-real-tool-xyz", "Install it from https://example.com"), + ); + }); +}); + +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 runProcess(["node", succeeding], { + cwd: process.cwd(), + onOutput: (chunk) => chunks.push(chunk), + }); + + expect(chunks.join("")).toContain("hello"); + }); + + test("rejects with ProcessFailedError carrying output and exit code", async () => { + const failing = await script("fail.js", "console.error('boom'); process.exit(3)"); + const promise = runProcess(["node", failing], { cwd: process.cwd() }); + + await expect(promise).rejects.toBeInstanceOf(ProcessFailedError); + await expect(promise).rejects.toThrow(/exit code 3/); + await expect(promise).rejects.toThrow(/boom/); + }); + + test("rejects with ProcessFailedError when the executable cannot spawn", async () => { + await expect( + 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 new file mode 100644 index 000000000..ce7d8f549 --- /dev/null +++ b/src/io/exec.ts @@ -0,0 +1,89 @@ +// 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 { 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) { + 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 ProcessFailedError 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 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, 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, + probeArgs?: string[], +): Promise { + if (!(await toolAvailable(tool, probeArgs))) throw new MissingToolError(tool, installHint); +} + +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 subprocess to completion. Injectable so tests never spawn real processes. */ +export type ProcessRunner = (command: string[], options: RunProcessOptions) => Promise; + +/** + * Runs a subprocess, streaming combined stdout/stderr to `onOutput` while also + * capturing it; rejects with {@link ProcessFailedError} on a non-zero exit. + */ +export const runProcess: ProcessRunner = ([executable, ...args], { cwd, onOutput }) => { + return new Promise((resolve, reject) => { + const child = spawn(executable!, args, { + cwd, + stdio: ["ignore", "pipe", "pipe"], + shell: useShell, + }); + + 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 ProcessFailedError([executable!, ...args], cwd, null, String(error))); + }); + child.on("close", (exitCode) => { + if (exitCode === 0) resolve(); + else reject(new ProcessFailedError([executable!, ...args], cwd, exitCode, output)); + }); + }); +}; diff --git a/src/io/index.ts b/src/io/index.ts index a802d5fc7..f146280e6 100644 --- a/src/io/index.ts +++ b/src/io/index.ts @@ -1,4 +1,13 @@ export { atomicWrite } from "./atomicWrite"; +export { + MissingToolError, + ProcessFailedError, + requireTool, + runProcess, + toolAvailable, + type ProcessRunner, + type RunProcessOptions, +} from "./exec"; export { FsReadWriteJson } from "./json"; export { SourceResolver, type SourceResolverConfig } from "./source"; export type { AppIO, ReadWriteJson } from "./types"; diff --git a/src/middleware/withTuiOnEmptyFlagsAndArgs.test.tsx b/src/middleware/withTuiOnEmptyFlagsAndArgs.test.tsx new file mode 100644 index 000000000..241fbc7bf --- /dev/null +++ b/src/middleware/withTuiOnEmptyFlagsAndArgs.test.tsx @@ -0,0 +1,51 @@ +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"; + +// 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({ + 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.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); + }); +}); 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); diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index b80546f97..f92e35846 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -935,7 +935,17 @@ 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 }); + }, + checkTool: async () => {}, // CI hosts don't have uv installed + }); } }