diff --git a/src/core/core.test.ts b/src/core/core.test.ts index 8a3a2bba8..2c773710a 100644 --- a/src/core/core.test.ts +++ b/src/core/core.test.ts @@ -9,6 +9,7 @@ import { import { CoreClient } from "./index"; import type { ClientConfig } from "./types"; import { toClientConfig } from "./utils"; +import { createSilentLogger } from "../testing"; // A minimal stand-in for the SDK clients; CoreClient only stores and returns // them, so an opaque tagged object is enough to assert identity/caching. @@ -24,14 +25,15 @@ function fakeIam(config: ClientConfig): IAMClient { test("control() constructs a client once per config and caches it", () => { let built = 0; - const core = new CoreClient( - (config) => { + const core = new CoreClient({ + createControlClient: (config) => { built++; return fakeControl(config); }, - fakeData, - fakeIam, - ); + createDataClient: fakeData, + createIamClient: fakeIam, + logger: createSilentLogger(), + }); const a = core.control({ region: "us-east-1" }); const b = core.control({ region: "us-east-1" }); @@ -42,14 +44,15 @@ test("control() constructs a client once per config and caches it", () => { test("control() builds a distinct client per distinct config", () => { let built = 0; - const core = new CoreClient( - (config) => { + const core = new CoreClient({ + createControlClient: (config) => { built++; return fakeControl(config); }, - fakeData, - fakeIam, - ); + createDataClient: fakeData, + createIamClient: fakeIam, + logger: createSilentLogger(), + }); core.control({ region: "us-east-1" }); core.control({ region: "us-west-2" }); @@ -61,17 +64,18 @@ test("control() builds a distinct client per distinct config", () => { test("data() caches independently of control()", () => { let controlBuilt = 0; let dataBuilt = 0; - const core = new CoreClient( - (config) => { + const core = new CoreClient({ + createControlClient: (config) => { controlBuilt++; return fakeControl(config); }, - (config) => { + createDataClient: (config) => { dataBuilt++; return fakeData(config); }, - fakeIam, - ); + createIamClient: fakeIam, + logger: createSilentLogger(), + }); core.control({ region: "us-east-1" }); const d1 = core.data({ region: "us-east-1" }); @@ -83,7 +87,12 @@ test("data() caches independently of control()", () => { }); test("exposes a harness sub-client", () => { - const core = new CoreClient(fakeControl, fakeData, fakeIam); + const core = new CoreClient({ + createControlClient: fakeControl, + createDataClient: fakeData, + createIamClient: fakeIam, + logger: createSilentLogger(), + }); expect(core.harness).toBeDefined(); }); @@ -93,9 +102,9 @@ test("invokeHarness sends an InvokeHarnessCommand on the data client with the ab const sent: { command: unknown; options: unknown }[] = []; const configs: ClientConfig[] = []; const response = { stream: undefined }; - const core = new CoreClient( - fakeControl, - (config) => { + const core = new CoreClient({ + createControlClient: fakeControl, + createDataClient: (config) => { configs.push(config); return { config, @@ -106,8 +115,9 @@ test("invokeHarness sends an InvokeHarnessCommand on the data client with the ab }, } as unknown as BedrockAgentCoreClient; }, - fakeIam, - ); + createIamClient: fakeIam, + logger: createSilentLogger(), + }); const request = { harnessArn: "arn:aws:bedrock-agentcore:us-east-1:123:harness/h-1", @@ -138,16 +148,17 @@ test("invokeHarness stream iteration rejects promptly when aborted mid-stream", await new Promise(() => {}); }, }; - const core = new CoreClient( - fakeControl, - (config) => + const core = new CoreClient({ + createControlClient: fakeControl, + createDataClient: (config) => ({ config, kind: "data", send: async () => ({ stream: hangingStream }), }) as unknown as BedrockAgentCoreClient, - fakeIam, - ); + createIamClient: fakeIam, + logger: createSilentLogger(), + }); const controller = new AbortController(); const response = await core.harness.invokeHarness( @@ -166,9 +177,9 @@ test("invokeHarness stream iteration rejects promptly when aborted mid-stream", test("invokeAgentRuntimeCommand sends the command on the data client with the abort signal", async () => { const sent: { command: unknown; options: unknown }[] = []; - const core = new CoreClient( - fakeControl, - (config) => + const core = new CoreClient({ + createControlClient: fakeControl, + createDataClient: (config) => ({ config, kind: "data", @@ -177,8 +188,9 @@ test("invokeAgentRuntimeCommand sends the command on the data client with the ab return { statusCode: 200, stream: undefined }; }, }) as unknown as BedrockAgentCoreClient, - fakeIam, - ); + createIamClient: fakeIam, + logger: createSilentLogger(), + }); const request = { agentRuntimeArn: "arn:aws:bedrock-agentcore:us-east-1:123:harness/h-1", @@ -195,16 +207,17 @@ test("invokeAgentRuntimeCommand sends the command on the data client with the ab test("invokeHarness returns the stream untouched when no abort signal is given", async () => { const stream = (async function* () {})(); - const core = new CoreClient( - fakeControl, - (config) => + const core = new CoreClient({ + createControlClient: fakeControl, + createDataClient: (config) => ({ config, kind: "data", send: async () => ({ stream }), }) as unknown as BedrockAgentCoreClient, - fakeIam, - ); + createIamClient: fakeIam, + logger: createSilentLogger(), + }); const response = await core.harness.invokeHarness( { harnessArn: "arn", runtimeSessionId: "s".repeat(40), messages: [] }, diff --git a/src/core/index.tsx b/src/core/index.tsx index b28fa5323..afddd3e3b 100644 --- a/src/core/index.tsx +++ b/src/core/index.tsx @@ -10,6 +10,9 @@ import type { CreateDataClient, CreateIamClient, } from "./types"; +import type { Logger } from "../logging"; +import type { ProjectManager } from "../handlers/project/types"; +import { FsProjectManager } from "./project"; export type { AwsClients, @@ -19,6 +22,13 @@ export type { CreateIamClient, } from "./types"; +type CoreClientConfig = { + createControlClient: CreateControlClient; + createDataClient: CreateDataClient; + createIamClient: CreateIamClient; + logger: Logger; +}; + // CoreClient is the single entry point to the Bedrock AgentCore APIs. It owns the // underlying SDK clients (one per config, created on demand from the injected // factories) and exposes feature-scoped sub-clients such as `harness`, keeping the @@ -28,15 +38,27 @@ export class CoreClient implements AwsClients { private dataClients = new Map(); private iamClients = new Map(); + private readonly createControlClient: CreateControlClient; + private readonly createDataClient: CreateDataClient; + private readonly createIamClient: CreateIamClient; + private logger: Logger; + // Feature-scoped sub-clients. Access as e.g. `coreClient.harness.getHarness(...)`. readonly harness: HarnessClient = new HarnessClient(this); readonly runtime: RuntimeClient = new RuntimeClient(this); - constructor( - private readonly createControlClient: CreateControlClient, - private readonly createDataClient: CreateDataClient, - private readonly createIamClient: CreateIamClient, - ) {} + readonly projectManager: ProjectManager; + + constructor(config: CoreClientConfig) { + this.createControlClient = config.createControlClient; + this.createDataClient = config.createDataClient; + this.createIamClient = config.createIamClient; + this.logger = config.logger; + + this.projectManager = new FsProjectManager({ + logger: this.logger.child({ module: "projectManager" }), + }); + } // control returns the control-plane client for `config`, creating and caching it // on first use. diff --git a/src/core/project/index.tsx b/src/core/project/index.tsx new file mode 100644 index 000000000..dfc769f53 --- /dev/null +++ b/src/core/project/index.tsx @@ -0,0 +1 @@ +export { FsProjectManager } from "./manager"; diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx new file mode 100644 index 000000000..5e91ff693 --- /dev/null +++ b/src/core/project/manager.tsx @@ -0,0 +1,26 @@ +import type { + CreateProjectInput, + ResolveProjectInput, + Project, + ProjectManager, +} from "../../handlers/project/types"; +import type { Logger } from "../../logging"; + +type ProjectManagerConfig = { + logger: Logger; +}; + +/** + * An implementation of {@link ProjectManager} that relies on the local file system to manage projects. + */ +export class FsProjectManager implements ProjectManager { + constructor(_config: ProjectManagerConfig) {} + + public resolve(_input: ResolveProjectInput): Promise { + throw new Error(`ProjectManager.resolve is not implemented yet`); + } + + public create(_input: CreateProjectInput): Promise { + throw new Error(`ProjectManager.create is not implemented yet`); + } +} diff --git a/src/handlers/harness/harness.test.tsx b/src/handlers/harness/harness.test.tsx index 59a8a11a0..7f705d84e 100644 --- a/src/handlers/harness/harness.test.tsx +++ b/src/handlers/harness/harness.test.tsx @@ -30,7 +30,12 @@ const REGION = "us-west-2"; // and returns whatever the command wrote to stdout. async function run(args: string[]): Promise { const { createControlClient, createDataClient, createIamClient } = fixtureFactories(FIXTURES); - const core = new CoreClient(createControlClient, createDataClient, createIamClient); + const core = new CoreClient({ + createControlClient, + createDataClient, + createIamClient, + logger: createSilentLogger(), + }); const io = testIO(); const root = createRootHandler(core, { io: io.io, logger: createSilentLogger() }); await root.route(["node", "agentcore", ...args, "--region", REGION]); diff --git a/src/handlers/index.tsx b/src/handlers/index.tsx index 3707fc373..a5d5dce94 100644 --- a/src/handlers/index.tsx +++ b/src/handlers/index.tsx @@ -36,7 +36,7 @@ export function createRootHandler(core: Core, config: RootHandlerConfig): Router root.handler(createHarnessHandler(core, io)); root.handler(createRuntimeHandler(core, io)); root.handler(createConfigHandler(io)); - root.handler(createProjectHandler()); + root.handler(createProjectHandler({ projectManager: core.projectManager })); // 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 63e8c375c..aef7c9788 100644 --- a/src/handlers/project/create/index.ts +++ b/src/handlers/project/create/index.ts @@ -1,9 +1,12 @@ import z from "zod"; import { createHandler, flag } from "../../../router"; +import { PROJECT_TEMPLATES, type ProjectManager } from "../types"; -export const PROJECT_TEMPLATES = ["placeholder"] as const; +type CreateProjectHandlerConfig = { + projectManager: ProjectManager; +}; -export const createCreateProjectHandler = () => +export const createCreateProjectHandler = (config: CreateProjectHandlerConfig) => createHandler({ name: "create", description: "create a new AgentCore project", @@ -11,10 +14,12 @@ export const createCreateProjectHandler = () => flag( "template", "project template to scaffold from", - z.enum(PROJECT_TEMPLATES).default("placeholder"), + z.enum(PROJECT_TEMPLATES).default(PROJECT_TEMPLATES.BAREBONES), ), ], - handle: async () => { - throw new Error("`agentcore project create` is not implemented yet"); + handle: async (_ctx, flags) => { + await config.projectManager.create({ + template: flags.template, + }); }, }); diff --git a/src/handlers/project/index.ts b/src/handlers/project/index.ts index fdeb5b343..ef1d03b1d 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -1,10 +1,15 @@ import { Router } from "../../router"; import { createCreateProjectHandler } from "./create"; +import type { ProjectManager } from "./types"; -export function createProjectHandler(): Router { +type ProjectHandlerConfig = { + projectManager: ProjectManager; +}; + +export function createProjectHandler(config: ProjectHandlerConfig): Router { const project = new Router("project", "manage an AgentCore project"); - project.handler(createCreateProjectHandler()); + project.handler(createCreateProjectHandler({ projectManager: config.projectManager })); return project; } diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 6c2922920..301853b56 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -17,7 +17,7 @@ describe("project create", () => { }); test("accepts a known --template value", async () => { - await expect(run(["create", "--template", "placeholder"])).rejects.toThrow(/not implemented/); + await expect(run(["create", "--template", "barebones"])).rejects.toThrow(/not implemented/); }); test("rejects an unknown --template value", async () => { diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts new file mode 100644 index 000000000..1215cfd51 --- /dev/null +++ b/src/handlers/project/types.ts @@ -0,0 +1,31 @@ +/** Available project templates for scaffolding new AgentCore projects. */ +export const PROJECT_TEMPLATES = { + BAREBONES: "barebones", +} as const; + +export type ProjectTemplate = (typeof PROJECT_TEMPLATES)[keyof typeof PROJECT_TEMPLATES]; + +export type CreateProjectInput = { + /** The project template to scaffold from. */ + template: ProjectTemplate; +}; + +export type ResolveProjectInput = { + /** A path to search from when locating the project root. */ + filePath: string; +}; + +export type Project = { + name: string; +}; + +/** + * The primary interface for interacting with projects + */ +export interface ProjectManager { + /** Scaffold a new AgentCore project from the given template. */ + create(input: CreateProjectInput): Promise; + + /** Locate an existing AgentCore project. Returns undefined if no project can be found. */ + resolve(input: ResolveProjectInput): Promise; +} diff --git a/src/handlers/runtime/runtime.test.tsx b/src/handlers/runtime/runtime.test.tsx index 5685d8619..501aed620 100644 --- a/src/handlers/runtime/runtime.test.tsx +++ b/src/handlers/runtime/runtime.test.tsx @@ -16,7 +16,12 @@ const MISSING_RUNTIME_ID = "missing_runtime-0000000000"; function createFixtureCore(): CoreClient { const { createControlClient, createDataClient, createIamClient } = fixtureFactories(FIXTURES); - return new CoreClient(createControlClient, createDataClient, createIamClient); + return new CoreClient({ + createControlClient, + createDataClient, + createIamClient, + logger: createSilentLogger(), + }); } async function run(args: string[]): Promise { diff --git a/src/handlers/types.tsx b/src/handlers/types.tsx index 3e95a2b14..58eecb093 100644 --- a/src/handlers/types.tsx +++ b/src/handlers/types.tsx @@ -1,10 +1,12 @@ import type { CoreHarnessClient } from "./harness/types.tsx"; import type { CoreRuntimeClient } from "./runtime/types.tsx"; import type { Context } from "../router"; +import type { ProjectManager } from "./project/types.ts"; export interface Core { harness: CoreHarnessClient; runtime: CoreRuntimeClient; + projectManager: ProjectManager; } // AppIO is the set of standard streams the app reads from and writes to. It is diff --git a/src/index.ts b/src/index.ts index bb41c6042..a74c0161a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,7 +36,12 @@ process.exit( // Wrap the SDK clients in the CoreClient the handlers consume. Passing // factories (rather than instances) lets CoreClient build one client per // region on demand. - const coreClient = new CoreClient(createControlClient, createDataClient, createIamClient); + const coreClient = new CoreClient({ + createControlClient, + createDataClient, + createIamClient, + logger: rootLogger.child({ module: "core" }), + }); // Pass it to the root handler, along with the process's standard streams as // the app's io. CoreClient exposes feature sub-clients (e.g. `.harness`), so diff --git a/src/middleware/withProject.test.ts b/src/middleware/withProject.test.ts new file mode 100644 index 000000000..852e75bf3 --- /dev/null +++ b/src/middleware/withProject.test.ts @@ -0,0 +1,23 @@ +import { test, expect, describe } from "bun:test"; +import { Router, createHandler } from "../router"; +import { createSilentLogger } from "../testing"; +import { withProject } from "./withProject"; +import { FsProjectManager } from "../core/project"; + +describe("withProject", () => { + test("throws not implemented", async () => { + const projectManager = new FsProjectManager({ logger: createSilentLogger() }); + + const app = new Router("app", "test"); + app.use(withProject({ projectManager, cwd: "/some/path" })); + app.handler( + createHandler({ + name: "check", + description: "noop", + handle: async () => {}, + }), + ); + + await expect(app.route(["node", "app", "check"])).rejects.toThrow(/not implemented/); + }); +}); diff --git a/src/middleware/withProject.tsx b/src/middleware/withProject.tsx new file mode 100644 index 000000000..288d227f7 --- /dev/null +++ b/src/middleware/withProject.tsx @@ -0,0 +1,30 @@ +import type { Project, ProjectManager } from "../handlers/project/types"; +import { ProjectKey, type Middleware } from "../router"; + +interface WithProjectConfig { + projectManager: ProjectManager; + cwd: string; +} + +/** + * Middleware that locates the AgentCore project from the configured working + * directory and pins it on the context under {@link ProjectKey}. + * Throws if no project can be found. + * + * @param config - Contains the {@link ProjectManager} and the `cwd` to search from. + */ +export function withProject(config: WithProjectConfig): Middleware { + return (h) => ({ + name: () => h.name(), + description: () => h.description(), + flags: () => h.flags(), + arguments: () => h.arguments(), + children: () => h.children(), + handle: async (ctx, flags, args) => { + const project = await config.projectManager.resolve({ filePath: config.cwd }); + // TODO: swap this for a typed error. + if (!project) throw new Error(`Unable to find project at path ${config.cwd}`); + await h.handle(ctx.withValue(ProjectKey, project), flags, args); + }, + }); +} diff --git a/src/router/index.tsx b/src/router/index.tsx index 98f581fa8..fb8c31a88 100644 --- a/src/router/index.tsx +++ b/src/router/index.tsx @@ -4,6 +4,7 @@ export { CommandKey, PathKey, LoggerKey, + ProjectKey, type DefaultHandle, type DefaultHandlerProvider, isDefaultHandlerProvider, diff --git a/src/router/router.tsx b/src/router/router.tsx index 5aec62670..eab6778b2 100644 --- a/src/router/router.tsx +++ b/src/router/router.tsx @@ -6,6 +6,7 @@ import { parseArguments, toCommanderArgument } from "./args"; import { Command } from "commander"; import type { Logger } from "../logging"; +import type { Project } from "../handlers/project/types"; // CommandKey exposes the Commander Command for the executing leaf via context. export const CommandKey: ContextKey = contextKey("commander.command"); @@ -14,6 +15,8 @@ export const PathKey: ContextKey = contextKey("path"); export const LoggerKey = contextKey("logger"); +export const ProjectKey = contextKey("project"); + // DefaultHandle runs when a group is selected without a subcommand (e.g. // `agentcore` or `agentcore harness`). It reads group-level/global flags from the // context; own flags/arguments are not supported, so it receives empty objects. diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index c12f17aad..f1559bda7 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -33,6 +33,10 @@ import type { Core } from "../handlers/types"; import type { CoreHarnessClient, CreateHarnessInput } from "../handlers/harness/types"; import type { CoreRuntimeClient } from "../handlers/runtime/types"; import type { CoreOptions } from "../core/types"; +import type { ProjectManager } from "../handlers/project/types"; +import type { Logger } from "../logging"; +import { createSilentLogger } from "./logging"; +import { FsProjectManager } from "../core/project"; // TestCoreClient is a hand-controllable `Core` for tests. It implements the same // interface the real CoreClient satisfies, so it drops straight into @@ -466,9 +470,17 @@ class TestRuntimeClient implements CoreRuntimeClient { return DEFAULT_LIST_RUNTIME_ENDPOINTS_RESPONSE; } } +type TestCoreClientOptions = { + logger?: Logger; +}; // TestCoreClient implements the Core contract with fully controllable sub-clients. export class TestCoreClient implements Core { readonly harness = new TestHarnessClient(); readonly runtime = new TestRuntimeClient(); + readonly projectManager: ProjectManager; + + constructor(options?: TestCoreClientOptions) { + this.projectManager = new FsProjectManager({ logger: options?.logger ?? createSilentLogger() }); + } }