diff --git a/src/globalConfig/accessor.tsx b/src/globalConfig/accessor.tsx new file mode 100644 index 000000000..0867f2524 --- /dev/null +++ b/src/globalConfig/accessor.tsx @@ -0,0 +1,61 @@ +import { type GlobalConfig, type GlobalConfigAccessor, type ReadWriteJson } from "./types"; +import type { Logger } from "../logging"; +import { globalConfigSchema } from "./schemas"; +import z from "zod"; + +type DefaultGlobalConfigAccessorConfig = { + logger: Logger; + json: ReadWriteJson; + filePath: string; +}; + +/** + * Implements {@link GlobalConfigAccessor} backed by the given json source. + * + * @param config - The logger and json datasource to be used by the config. . + * @returns A {@link GlobalConfigAccessor} instance. + */ +export class DefaultGlobalConfigAccessor implements GlobalConfigAccessor { + private cachedConfig: GlobalConfig | undefined; + private readonly json: ReadWriteJson; + private readonly filePath: string; + private readonly logger: Logger; + + constructor(config: DefaultGlobalConfigAccessorConfig) { + this.logger = config.logger; + this.json = config.json; + this.filePath = config.filePath; + this.logger.info(`creating global config accessor`); + } + + public async get(): Promise { + const logger = this.logger.child({ method: "get" }); + logger.debug("reading global config"); + + if (this.cachedConfig) return this.cachedConfig; + logger.debug("no config cached, reading from source"); + + try { + this.cachedConfig = await this.json.read(this.filePath, globalConfigSchema); + return this.cachedConfig; + } catch (e) { + if (isFileNotFoundError(e)) return {}; + throw e; + } + } + + public async set(newConfig: GlobalConfig): Promise { + this.logger.child({ newConfig, method: "set" }).debug("writing global config"); + + const validatedConfig = globalConfigSchema.safeParse(newConfig); + if (!validatedConfig.success) { + throw new TypeError(z.prettifyError(validatedConfig.error)); + } + this.cachedConfig = await this.json.write(this.filePath, validatedConfig.data); + return this.cachedConfig; + } +} + +function isFileNotFoundError(e: unknown): boolean { + return e instanceof Error && "code" in e && e.code === "ENOENT"; +} diff --git a/src/globalConfig/index.tsx b/src/globalConfig/index.tsx new file mode 100644 index 000000000..0f6a6a76a --- /dev/null +++ b/src/globalConfig/index.tsx @@ -0,0 +1,4 @@ +export { type GlobalConfigAccessor, type GlobalConfig, type ReadWriteJson } from "./types"; +export { globalConfigSchema, getGlobalConfigKeys, getGlobalConfigValueType } from "./schemas"; +export { DefaultGlobalConfigAccessor } from "./accessor"; +export { FsReadWriteJson } from "./json"; diff --git a/src/globalConfig/json.tsx b/src/globalConfig/json.tsx new file mode 100644 index 000000000..e02eb4b1e --- /dev/null +++ b/src/globalConfig/json.tsx @@ -0,0 +1,70 @@ +import z from "zod"; +import { readFile, writeFile } from "fs/promises"; +import type { Logger } from "../logging"; +import type { ReadWriteJson } from "./types"; + +type ReadWriteJsonConfig = { + logger: Logger; +}; + +// TODO: attach telemetry metadata to this error class. +class DeserializationError extends Error { + constructor(path: string, options?: { cause?: unknown }) { + super(`Failed to deserialize JSON at "${path}"`, options); + this.name = "DeserializationError"; + } +} + +/** + * Implements {@link ReadWriteJson} through node fs. + * + * @param config - logger + */ +export class FsReadWriteJson implements ReadWriteJson { + private readonly logger: Logger; + + constructor(config: ReadWriteJsonConfig) { + this.logger = config.logger; + } + + private async readJson(filePath: string): Promise { + const raw = await readFile(filePath, "utf8"); + + try { + return JSON.parse(raw); + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)); + this.logger + .child({ filePath, errorName: error.name, errorMessage: error.message }) + .error(`failed to parse json file`); + throw new DeserializationError(filePath, { cause: e }); + } + } + + public async read( + filePath: string, + schema: TSchema, + ): Promise> { + const data = await this.readJson(filePath); + const parseResult = schema.safeParse(data); + + if (!parseResult.success) { + this.logger + .child({ + filePath, + errorName: parseResult.error.name, + errorMessage: parseResult.error.message, + }) + .error(`failed to validate parsed json file`); + throw new DeserializationError(filePath, { cause: parseResult.error }); + } + + return parseResult.data; + } + + public async write(filePath: string, data: TData): Promise { + const contents = JSON.stringify(data, undefined, 2); + await writeFile(filePath, contents); + return data; + } +} diff --git a/src/globalConfig/schemas.tsx b/src/globalConfig/schemas.tsx new file mode 100644 index 000000000..6dce86a5d --- /dev/null +++ b/src/globalConfig/schemas.tsx @@ -0,0 +1,84 @@ +import z from "zod"; + +/** + * Schema for the global config file. {@link GlobalConfigAccessor} is built from this definition. + * All fields should be optional; missing fields should be overridden with reasonable defaults in the consuming code. + */ +export const globalConfigSchema = z.object({ + telemetry: z + .object({ + enabled: z.boolean().optional(), + endpoint: z.string().optional(), + audit: z.boolean().optional(), + }) + .optional(), + installationId: z.uuid().optional(), +}); + +/** + * Returns the internal def for a zod schema, which contains the `type` discriminant + * and structural fields like `innerType`, `shape`, `out`, etc. + * @see https://zod.dev/packages/core + */ +function getDef(schema: z.ZodType): { + type: string; + innerType?: z.ZodType; + out?: z.ZodType; + shape?: Record; +} { + return (schema as any)._zod.def; +} + +/** Peels wrapper types to reach the base schema. */ +function unwrapSchema(schema: z.ZodType): z.ZodType { + for (;;) { + const def = getDef(schema); + switch (def.type) { + case "optional": + case "nullable": + case "default": + case "readonly": + schema = def.innerType!; + break; + case "pipe": + // z.preprocess() returns a ZodPipe — take the output schema + schema = def.out!; + break; + default: + return schema; + } + } +} + +/** Recursively builds a map of dot-notation paths to their leaf schemas. */ +function buildSchemaMap(schema: z.ZodObject, prefix = ""): Map { + const map = new Map(); + + for (const key of schema.keyof().options) { + const fullPath = prefix ? `${prefix}.${key}` : key; + const value = schema.shape[key]; + map.set(fullPath, value); + + const inner = unwrapSchema(value); + if (getDef(inner).type === "object") { + for (const [nested, s] of buildSchemaMap(inner as z.ZodObject, fullPath)) { + map.set(nested, s); + } + } + } + + return map; +} + +const globalConfigSchemaMap = buildSchemaMap(globalConfigSchema); + +/** Returns all valid dot-notation config keys (e.g. `"telemetry.enabled"`). */ +export function getGlobalConfigKeys(): string[] { + return [...globalConfigSchemaMap.keys()]; +} + +/** Returns the base zod type name (e.g. `"boolean"`, `"string"`) for the schema at `path`, or `undefined` if the path is invalid. */ +export function getGlobalConfigValueType(path: string): z.ZodType["def"]["type"] | undefined { + const schema = globalConfigSchemaMap.get(path); + return schema ? unwrapSchema(schema).def.type : undefined; +} diff --git a/src/globalConfig/types.tsx b/src/globalConfig/types.tsx new file mode 100644 index 000000000..e3d10b499 --- /dev/null +++ b/src/globalConfig/types.tsx @@ -0,0 +1,20 @@ +import type z from "zod"; +import type { globalConfigSchema } from "./schemas"; + +/** Inferred shape of the global config from {@link globalConfigSchema}. */ +export type GlobalConfig = z.infer; + +/** Reads and writes global CLI configuration. */ +export interface GlobalConfigAccessor { + /** Returns the current global config. */ + get(): Promise; + /** Validates and persists a new config. Throws on invalid shape. */ + set(newConfig: GlobalConfig): Promise; +} + +export interface ReadWriteJson { + /** Reads data from the given file */ + read(filePath: string, schema: TSchema): Promise>; + /** Writes data to the given file */ + write(filePath: string, data: TData): Promise; +} diff --git a/src/handlers/config/config.test.tsx b/src/handlers/config/config.test.tsx index bdb79908c..9aa738f61 100644 --- a/src/handlers/config/config.test.tsx +++ b/src/handlers/config/config.test.tsx @@ -1,36 +1,165 @@ -import { test, expect, describe } from "bun:test"; +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; +import { join } from "node:path"; +import { mkdtemp, rm, writeFile, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { createRootHandler } from "../index"; import { createSilentLogger, TestCoreClient, testIO } from "../../testing"; +import { DefaultGlobalConfigAccessor, FsReadWriteJson } from "../../globalConfig"; -// End-to-end tests for the `config` command, driven through the real root -// handler and top-level route(). A TestCoreClient stands in for Core (config -// doesn't touch it, but createRootHandler requires one); an in-memory io -// captures the output. +describe("config", () => { + let tempDir: string; + let configPath: string; + + const validConfig = { + telemetry: { enabled: true, endpoint: "https://example.com" }, + installationId: "550e8400-e29b-41d4-a716-446655440000", + }; -async function run(args: string[]): Promise { - const io = testIO(); - const root = createRootHandler(new TestCoreClient(), { - io: io.io, - logger: createSilentLogger(), + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "agentcore-config-test-")); + configPath = join(tempDir, "config.json"); + await writeFile(configPath, JSON.stringify(validConfig)); }); - await root.route(["node", "agentcore", "config", ...args]); - return io.stdout(); -} -describe("config", () => { - test("prints the key and value it was given", async () => { - expect(await run(["telemetry.enabled", "true"])).toBe( - "updating config key=telemetry.enabled, value=true", + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + async function run(args: string[]): Promise { + const io = testIO(); + const logger = createSilentLogger(); + const globalConfigAccessor = new DefaultGlobalConfigAccessor({ + logger, + filePath: configPath, + json: new FsReadWriteJson({ + logger, + }), + }); + const root = createRootHandler(new TestCoreClient(), { + io: io.io, + logger, + globalConfigAccessor, + }); + await root.route(["node", "agentcore", "config", ...args]); + return io.stdout(); + } + + test("prints the full config when no args are passed", async () => { + const output = await run([]); + expect(JSON.parse(output)).toEqual(validConfig); + }); + + test("prints the value at a key when only a key is passed", async () => { + const output = await run(["telemetry.enabled"]); + expect(JSON.parse(output)).toBe(true); + }); + + test("prints a nested object when a branch key is passed", async () => { + const output = await run(["telemetry"]); + expect(JSON.parse(output)).toEqual(validConfig.telemetry); + }); + + test("sets a value when key and value are passed", async () => { + const initialReadValueOutput = await run(["telemetry.endpoint"]); + expect(JSON.parse(initialReadValueOutput)).toBe(validConfig.telemetry.endpoint); + + const newEndpoint = "http://example2.com"; + + const writeValueOutput = await run(["telemetry.endpoint", newEndpoint]); + expect(JSON.parse(writeValueOutput)).toBe(newEndpoint); + + const readValueOutput = await run(["telemetry.endpoint"]); + expect(JSON.parse(readValueOutput)).toBe(newEndpoint); + + // validate the new value persists on disk. + expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({ + ...validConfig, + telemetry: { ...validConfig.telemetry, endpoint: newEndpoint }, + }); + }); + + test("accepts json values for non-leaf nodes", async () => { + const initialTelemetrySettingsOutput = await run(["telemetry"]); + const initialTelemetrySettings = JSON.parse(initialTelemetrySettingsOutput); + + const newTelemetrySettings = { + ...initialTelemetrySettings, + enabled: !initialTelemetrySettings.enabled, + }; + const newTelemetrySettingsOutput = await run([ + "telemetry", + JSON.stringify(newTelemetrySettings), + ]); + expect(JSON.parse(newTelemetrySettingsOutput)).toMatchObject(newTelemetrySettings); + + const finalTelemetrySettingsOutput = await run(["telemetry"]); + expect(JSON.parse(finalTelemetrySettingsOutput)).toMatchObject(newTelemetrySettings); + }); + + test("throws on invalid key", async () => { + // TODO: swap to validation error. + await expect(run(["nonexistent.key"])).rejects.toThrow(TypeError); + }); + + test("does not throw on valid, but non-existent key", async () => { + const output = await run(["telemetry.audit"]); + expect(output).toBe(""); + }); + + test("throws on invalid value for key", async () => { + // TODO: swap to validation error + await expect(run(["telemetry.enabled", "banana"])).rejects.toThrow(TypeError); + }); + + test("coerces values based on schema", async () => { + const setEnabledOutput = await run(["telemetry.enabled", "false"]); + expect(JSON.parse(setEnabledOutput)).toBe(false); + const readEnabledOutput = await run(["telemetry.enabled"]); + expect(JSON.parse(readEnabledOutput)).toBe(false); + + const setEndpointOutput = await run(["telemetry.endpoint", "false"]); + expect(JSON.parse(setEndpointOutput)).toBe("false"); + const readEndpointOutput = await run(["telemetry.endpoint"]); + expect(JSON.parse(readEndpointOutput)).toBe("false"); + }); + + test("throws if any field fails validation", async () => { + await writeFile( + configPath, + JSON.stringify({ + telemetry: { enabled: "not-a-bool", endpoint: "https://good.com" }, + }), ); + await expect(run(["telemetry.endpoint"])).rejects.toThrow("Failed to deserialize"); }); - test("value is undefined when only a key is passed", async () => { - expect(await run(["telemetry.enabled"])).toBe( - "updating config key=telemetry.enabled, value=undefined", + test("ignores unsupported fields in the config file", async () => { + await writeFile( + configPath, + JSON.stringify({ + ...validConfig, + futureField: "unknown", + telemetry: { ...validConfig.telemetry, futureNested: 42 }, + }), ); + const output = await run([]); + expect(JSON.parse(output)).toEqual(validConfig); }); - test("both key and value are undefined when none are passed", async () => { - expect(await run([])).toBe("updating config key=undefined, value=undefined"); + test("throws clear error on invalid json", async () => { + await writeFile(configPath, "not { valid json"); + // TODO: assert on error type + await expect(run([])).rejects.toThrow("Failed to deserialize"); + }); + + test("creates default config if it does not exist", async () => { + await rm(configPath); + const newEndpoint = "http://new-endpoint.command"; + + const output = await run(["telemetry.endpoint", newEndpoint]); + expect(JSON.parse(output)).toBe(newEndpoint); + + const readOutput = await run(["telemetry.endpoint"]); + expect(JSON.parse(readOutput)).toBe(newEndpoint); }); }); diff --git a/src/handlers/config/handler.tsx b/src/handlers/config/handler.tsx index 3cdbeaebb..bcf120e70 100644 --- a/src/handlers/config/handler.tsx +++ b/src/handlers/config/handler.tsx @@ -1,15 +1,20 @@ import z from "zod"; -import { createHandler, argument } from "../../router"; -import type { AppIO } from "../types"; +import { createHandler, argument, GlobalConfigAccessorKey } from "../../router"; +import { JsonRendererKey } from "../../tui"; +import { + getGlobalConfigKeys, + getGlobalConfigValueType, + type GlobalConfig, +} from "../../globalConfig"; /* - * read/write global configuration values. ex. telemetry settings, log level, etc. + * read/write global configuration values. ex. telemetry settings, endpoint overrides, etc. * Ex. * `config [key] [value]` sets key to value. * `config [key]` prints the value with key. * `config` prints the full config. */ -export const createConfigHandler = (io: AppIO) => +export const createConfigHandler = () => createHandler({ name: "config", description: "read/write global config values", @@ -17,12 +22,83 @@ export const createConfigHandler = (io: AppIO) => argument( "key", "config key in JSON path notation (e.g. telemetry.enabled)", - z.string().optional(), + z.enum(getGlobalConfigKeys()).optional(), ), argument("value", "value to set for the key", z.string().optional()), ], - handle: async (_ctx, _flags, args) => { - // TODO: implment a real handler that update the global config via injected config accessor. - io.stdout.write(`updating config key=${args.key}, value=${args.value}\n`); + handle: async (ctx, _flags, args) => { + const globalConfigAccessor = ctx.require(GlobalConfigAccessorKey); + const jsonRenderer = ctx.require(JsonRendererKey); + + const globalConfig = await globalConfigAccessor.get(); + // print entire config when key is missing. + if (!args.key) { + jsonRenderer.renderJson(globalConfig); + return; + } + + // print entire value at key when value is missing. + if (!args.value) { + const scopedConfig = getAtPath(globalConfig, args.key); + if (scopedConfig !== undefined) jsonRenderer.renderJson(scopedConfig); + return; + } + const coercedValue = coerceValue(args.key, args.value); + + // update value at key with given value. + await globalConfigAccessor.set( + setAtPath(globalConfig, args.key, coercedValue) as GlobalConfig, + ); + + jsonRenderer.renderJson(coercedValue); }, }); + +/** Coerces a raw CLI string to the type expected by the config schema at `key`. Returns original value if unable to coerce */ +function coerceValue(key: string, value: string): unknown { + const valueType = getGlobalConfigValueType(key); + if (!valueType) { + return value; + } + switch (valueType) { + case "boolean": + case "object": + try { + return JSON.parse(value); + } catch { + return value; + } + default: + return value; + } +} + +/** Type guard that narrows `value` to a plain object record. */ +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Retrieves a nested value from `obj` using dot-notation (e.g. `"telemetry.enabled"`). */ +function getAtPath(obj: object, path: string): unknown { + return path.split(".").reduce((acc, key) => (isRecord(acc) ? acc[key] : undefined), obj); +} + +/** Returns a shallow-cloned object with `value` set at the given dot-notation `path`. */ +function setAtPath( + obj: Record, + path: string, + value: unknown, +): Record { + const keys = path.split("."); + if (keys.length === 0) return obj; + const head = keys[0]!; + if (keys.length === 1) return { ...obj, [head]: value }; + return { + ...obj, + [head]: setAtPath( + isRecord(obj[head]) ? (obj[head] as Record) : {}, + keys.slice(1).join("."), + value, + ), + }; +} diff --git a/src/handlers/harness/exec/exec.test.tsx b/src/handlers/harness/exec/exec.test.tsx index bb548a98d..96a56d4f0 100644 --- a/src/handlers/harness/exec/exec.test.tsx +++ b/src/handlers/harness/exec/exec.test.tsx @@ -6,6 +6,7 @@ import type { import type { GetHarnessResponse } from "@aws-sdk/client-bedrock-agentcore-control"; import { createRootHandler } from "../../index"; import { createSilentLogger, TestCoreClient, testIO } from "../../../testing"; +import { TestGlobalConfigAccessor } from "../../../testing/"; // Command-flow tests for `harness exec`, driven through the real root handler. // Like the invoke suite, these use a TestCoreClient because the command @@ -31,7 +32,11 @@ async function run(args: string[], configure?: (core: TestCoreClient) => void) { core.harness.setExecEvents(...EXEC_EVENTS); configure?.(core); const io = testIO(); - const root = createRootHandler(core, { io: io.io, logger: createSilentLogger() }); + const root = createRootHandler(core, { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); await root.route(["node", "agentcore", ...args, "--region", "us-west-2"]); return { core, stdout: io.stdout() }; } diff --git a/src/handlers/harness/harness.test.tsx b/src/handlers/harness/harness.test.tsx index 7f705d84e..cc56448d2 100644 --- a/src/handlers/harness/harness.test.tsx +++ b/src/handlers/harness/harness.test.tsx @@ -7,6 +7,7 @@ import { fixtureFactories, isRecording, matchGolden, + TestGlobalConfigAccessor, testIO, } from "../../testing"; @@ -37,7 +38,11 @@ async function run(args: string[]): Promise { logger: createSilentLogger(), }); const io = testIO(); - const root = createRootHandler(core, { io: io.io, logger: createSilentLogger() }); + const root = createRootHandler(core, { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); await root.route(["node", "agentcore", ...args, "--region", REGION]); return io.stdout(); } diff --git a/src/handlers/harness/invoke/invoke.test.tsx b/src/handlers/harness/invoke/invoke.test.tsx index 25015719a..2ff072b4d 100644 --- a/src/handlers/harness/invoke/invoke.test.tsx +++ b/src/handlers/harness/invoke/invoke.test.tsx @@ -5,7 +5,12 @@ import type { } from "@aws-sdk/client-bedrock-agentcore"; import type { GetHarnessResponse } from "@aws-sdk/client-bedrock-agentcore-control"; import { createRootHandler } from "../../index"; -import { createSilentLogger, TestCoreClient, testIO } from "../../../testing"; +import { + createSilentLogger, + TestCoreClient, + TestGlobalConfigAccessor, + testIO, +} from "../../../testing"; // Command-flow tests for `harness invoke`, driven through the real root handler // exactly as the CLI runs it. Unlike the get/list suites these use a @@ -41,7 +46,11 @@ async function run(args: string[], configure?: (core: TestCoreClient) => void) { core.harness.setInvokeEvents(...TURN_EVENTS); configure?.(core); const io = testIO(); - const root = createRootHandler(core, { io: io.io, logger: createSilentLogger() }); + const root = createRootHandler(core, { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); await root.route(["node", "agentcore", ...args, "--region", "us-west-2"]); return { core, stdout: io.stdout() }; } diff --git a/src/handlers/help.screen.test.tsx b/src/handlers/help.screen.test.tsx index 064c68adf..f2792e21b 100644 --- a/src/handlers/help.screen.test.tsx +++ b/src/handlers/help.screen.test.tsx @@ -4,7 +4,7 @@ import { render, cleanup } from "ink-testing-library"; import { ValueContext, compile, CommandKey } from "../router"; import { createRootHandler } from "./index"; import { HelpScreen } from "./screen"; -import { createSilentLogger, TestCoreClient, testIO } from "../testing"; +import { createSilentLogger, TestCoreClient, TestGlobalConfigAccessor, testIO } from "../testing"; afterEach(cleanup); @@ -16,7 +16,11 @@ afterEach(cleanup); describe("HelpScreen", () => { test("renders the command's help text", () => { const command = compile( - createRootHandler(new TestCoreClient(), { io: testIO().io, logger: createSilentLogger() }), + createRootHandler(new TestCoreClient(), { + io: testIO().io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }), ValueContext.EmptyContext(), ); const ctx = ValueContext.EmptyContext().withValue(CommandKey, command); diff --git a/src/handlers/index.tsx b/src/handlers/index.tsx index a5d5dce94..89d3ad22f 100644 --- a/src/handlers/index.tsx +++ b/src/handlers/index.tsx @@ -5,13 +5,15 @@ import { DebugKey, EndpointKey, JsonKey, RegionKey } from "./keys.tsx"; import { createConfigHandler } from "./config/"; import { createProjectHandler } from "./project/index.ts"; import { renderTui } from "../tui"; -import { withRegion, withJsonRenderer, withLogging } from "../middleware"; +import { withRegion, withJsonRenderer, withLogging, withGlobalConfigAccessor } from "../middleware"; import type { AppIO, Core } from "./types.tsx"; import type { Logger } from "../logging"; +import type { GlobalConfigAccessor } from "../globalConfig"; export interface RootHandlerConfig { io: AppIO; logger: Logger; + globalConfigAccessor: GlobalConfigAccessor; } export function createRootHandler(core: Core, config: RootHandlerConfig): Router { @@ -32,10 +34,13 @@ export function createRootHandler(core: Core, config: RootHandlerConfig): Router // Inject a logger into each handler. root.use(withLogging({ logger })); + // Pin the global config accessor on the context for any handler that needs it. + root.use(withGlobalConfigAccessor(config.globalConfigAccessor)); + // Install sub handlers root.handler(createHarnessHandler(core, io)); root.handler(createRuntimeHandler(core, io)); - root.handler(createConfigHandler(io)); + root.handler(createConfigHandler()); root.handler(createProjectHandler({ projectManager: core.projectManager })); // Invoking with no subcommand launches the interactive TUI. diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 301853b56..be7115d78 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -1,11 +1,17 @@ import { test, expect, describe } from "bun:test"; import { createRootHandler } from "../index"; -import { createSilentLogger, TestCoreClient, testIO } from "../../testing"; +import { + createSilentLogger, + TestCoreClient, + TestGlobalConfigAccessor, + testIO, +} from "../../testing"; async function run(args: string[]): Promise { const io = testIO(); const root = createRootHandler(new TestCoreClient(), { io: io.io, + globalConfigAccessor: new TestGlobalConfigAccessor(), logger: createSilentLogger(), }); await root.route(["node", "agentcore", "project", ...args]); diff --git a/src/handlers/root.test.tsx b/src/handlers/root.test.tsx index b79eaec2f..b0e10d403 100644 --- a/src/handlers/root.test.tsx +++ b/src/handlers/root.test.tsx @@ -1,12 +1,13 @@ import { test, expect, describe } from "bun:test"; import { createRootHandler } from "./index"; -import { createSilentLogger, TestCoreClient, testIO } from "../testing"; +import { createSilentLogger, TestCoreClient, TestGlobalConfigAccessor, testIO } from "../testing"; describe("createRootHandler", () => { test("builds the agentcore command tree with its subcommands", () => { const root = createRootHandler(new TestCoreClient(), { io: testIO().io, logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), }); expect(root.name()).toBe("agentcore"); expect(root.children().map((c) => c.name())).toEqual([ diff --git a/src/handlers/runtime/runtime.test.tsx b/src/handlers/runtime/runtime.test.tsx index 501aed620..6d748d971 100644 --- a/src/handlers/runtime/runtime.test.tsx +++ b/src/handlers/runtime/runtime.test.tsx @@ -3,6 +3,7 @@ import { join } from "node:path"; import { CoreClient } from "../../core"; import { createSilentLogger, fixtureFactories, matchGolden, testIO } from "../../testing"; import { createRootHandler } from "../index"; +import { TestGlobalConfigAccessor } from "../../testing/globalConfig"; const REGION = "us-west-2"; const FIXTURES = join(import.meta.dir, "__fixtures__"); @@ -29,6 +30,7 @@ async function run(args: string[]): Promise { const root = createRootHandler(createFixtureCore(), { io: io.io, logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), }); await root.route(["node", "agentcore", ...args, "--region", REGION]); @@ -40,6 +42,7 @@ describe("runtime command hierarchy", () => { const root = createRootHandler(createFixtureCore(), { io: testIO().io, logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), }); const runtime = root.children().find((child) => child.name() === "runtime"); diff --git a/src/index.ts b/src/index.ts index 2dd9108e3..1fcf5c657 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import { createControlClient, createDataClient, createIamClient } from "./core/f import { createRootHandler } from "./handlers"; import { createFileLogger, LOG_LEVEL } from "./logging"; import { runWithExitCode } from "./runnable"; +import { DefaultGlobalConfigAccessor, FsReadWriteJson } from "./globalConfig"; process.exit( await runWithExitCode(async (argv: string[]) => { @@ -21,7 +22,6 @@ process.exit( const rootLogger = createFileLogger({ filePath: join(homedir(), ".agentcore", "logs", "output"), - // TODO: allow overriding via global settings logLevel: LOG_LEVEL.DEBUG, bindings: { cliSessionId }, }); @@ -33,7 +33,18 @@ process.exit( }; try { - // Wrap the SDK clients in the CoreClient the handlers consume. Passing + const globalConfigAccessor = new DefaultGlobalConfigAccessor({ + logger: rootLogger.child({ module: "globalConfigAccessor" }), + filePath: join(homedir(), ".agentcore", "config.json"), + json: new FsReadWriteJson({ + logger: rootLogger.child({ module: "jsonDataSource" }), + }), + }); + + // TODO: get or create an installationId here that we can write into telemetry + + rootLogger.info(`running CLI`); + // factories (rather than instances) lets CoreClient build one client per // region on demand. const coreClient = new CoreClient({ @@ -49,6 +60,7 @@ process.exit( const rootHandler = createRootHandler(coreClient, { io, logger: rootLogger, + globalConfigAccessor, }); // Handle the request diff --git a/src/middleware/index.tsx b/src/middleware/index.tsx index e4f9a09f3..e77355898 100644 --- a/src/middleware/index.tsx +++ b/src/middleware/index.tsx @@ -2,3 +2,4 @@ export { withRegion } from "./withRegion"; export { withTuiOnEmptyFlagsAndArgs } from "./withTuiOnEmptyFlagsAndArgs"; export { withJsonRenderer } from "./withJsonRenderer"; export { withLogging } from "./withLogging"; +export { withGlobalConfigAccessor } from "./withGlobalConfigAccessor"; diff --git a/src/middleware/withGlobalConfigAccessor.tsx b/src/middleware/withGlobalConfigAccessor.tsx new file mode 100644 index 000000000..8acd2b056 --- /dev/null +++ b/src/middleware/withGlobalConfigAccessor.tsx @@ -0,0 +1,20 @@ +import { GlobalConfigAccessorKey, type Middleware } from "../router"; +import type { GlobalConfigAccessor } from "../globalConfig"; + +/** + * Middleware that pins a GlobalConfigAccessor on the context, making it + * available to every handler beneath the mount point via + * `ctx.require(GlobalConfigAccessorKey)`. + */ +export function withGlobalConfigAccessor(accessor: GlobalConfigAccessor): Middleware { + return (h) => ({ + name: () => h.name(), + description: () => h.description(), + flags: () => h.flags(), + arguments: () => h.arguments(), + children: () => h.children(), + handle: async (ctx, flags, args) => { + await h.handle(ctx.withValue(GlobalConfigAccessorKey, accessor), flags, args); + }, + }); +} diff --git a/src/middleware/withRegion.test.tsx b/src/middleware/withRegion.test.tsx index 93a15d8ac..1404f93ac 100644 --- a/src/middleware/withRegion.test.tsx +++ b/src/middleware/withRegion.test.tsx @@ -3,7 +3,7 @@ import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createRootHandler } from "../handlers"; -import { TestCoreClient, testIO } from "../testing"; +import { TestCoreClient, TestGlobalConfigAccessor, testIO } from "../testing"; import { createSilentLogger } from "../testing/"; // writeConfigFile writes an AWS shared-config file with the given contents to a @@ -24,7 +24,11 @@ function writeConfigFile(contents: string): string { // opening the TUI) and returns the region the handler passed to Core. async function resolvedRegion(args: string[]): Promise { const core = new TestCoreClient(); - const root = createRootHandler(core, { io: testIO().io, logger: createSilentLogger() }); + const root = createRootHandler(core, { + io: testIO().io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); await root.route(["node", "agentcore", "harness", "list", "--json", ...args]); const call = core.harness.calls.at(-1); const options = call?.args[2] as { region: string }; diff --git a/src/router/index.tsx b/src/router/index.tsx index fb8c31a88..3764b1020 100644 --- a/src/router/index.tsx +++ b/src/router/index.tsx @@ -4,6 +4,7 @@ export { CommandKey, PathKey, LoggerKey, + GlobalConfigAccessorKey, ProjectKey, type DefaultHandle, type DefaultHandlerProvider, diff --git a/src/router/router.tsx b/src/router/router.tsx index eab6778b2..5245b5f01 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 { GlobalConfigAccessor } from "../globalConfig"; import type { Project } from "../handlers/project/types"; // CommandKey exposes the Commander Command for the executing leaf via context. @@ -15,6 +16,8 @@ export const PathKey: ContextKey = contextKey("path"); export const LoggerKey = contextKey("logger"); +export const GlobalConfigAccessorKey: ContextKey = + contextKey("globalConfigAccessor"); export const ProjectKey = contextKey("project"); // DefaultHandle runs when a group is selected without a subcommand (e.g. diff --git a/src/testing/globalConfig.tsx b/src/testing/globalConfig.tsx new file mode 100644 index 000000000..5623b1ca1 --- /dev/null +++ b/src/testing/globalConfig.tsx @@ -0,0 +1,26 @@ +import type { GlobalConfig, GlobalConfigAccessor } from "../globalConfig"; +import { globalConfigSchema } from "../globalConfig"; + +/** + * In-memory GlobalConfigAccessor for tests. + */ +type TestGlobalConfigAccessorOptions = { + initialConfigData?: GlobalConfig; +}; + +export class TestGlobalConfigAccessor implements GlobalConfigAccessor { + private configData: GlobalConfig; + + constructor(options?: TestGlobalConfigAccessorOptions) { + this.configData = options?.initialConfigData ?? {}; + } + + public async get(): Promise { + return this.configData; + } + + public async set(newConfig: Record): Promise { + this.configData = globalConfigSchema.parse(newConfig); + return this.configData; + } +} diff --git a/src/testing/index.tsx b/src/testing/index.tsx index 30ee08187..c45cdc611 100644 --- a/src/testing/index.tsx +++ b/src/testing/index.tsx @@ -13,3 +13,4 @@ export { type RenderScreenResult, } from "./renderScreen"; export { createSilentLogger, assertLogsMatch, type LogQuery } from "./logging"; +export { TestGlobalConfigAccessor } from "./globalConfig"; diff --git a/src/testing/renderScreen.tsx b/src/testing/renderScreen.tsx index 18973cce1..b7ac3939d 100644 --- a/src/testing/renderScreen.tsx +++ b/src/testing/renderScreen.tsx @@ -1,4 +1,3 @@ -import React from "react"; import { render, cleanup } from "ink-testing-library"; import { QueryClient } from "@tanstack/react-query"; import { ValueContext, compile, CommandKey, type Context } from "../router"; @@ -10,6 +9,7 @@ import { TestCoreClient } from "./TestCoreClient"; import { testIO } from "./testIO"; import { tick, waitFor } from "./timing"; import { createSilentLogger } from "./logging"; +import { TestGlobalConfigAccessor } from "./globalConfig"; // TUI test harness. // @@ -29,7 +29,11 @@ import { createSilentLogger } from "./logging"; // keeps the command menus faithful to the production command structure. function baseContext(core: TestCoreClient): Context { const rootCommand = compile( - createRootHandler(core, { io: testIO().io, logger: createSilentLogger() }), + createRootHandler(core, { + io: testIO().io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }), ValueContext.EmptyContext(), ); diff --git a/src/tui/tui.test.tsx b/src/tui/tui.test.tsx index c018fb748..ca34a7e6b 100644 --- a/src/tui/tui.test.tsx +++ b/src/tui/tui.test.tsx @@ -1,7 +1,7 @@ import { test, expect, describe } from "bun:test"; import { createRootHandler } from "../handlers"; import { renderJson } from "./index"; -import { createSilentLogger, TestCoreClient, testIO } from "../testing"; +import { createSilentLogger, TestCoreClient, TestGlobalConfigAccessor, testIO } from "../testing"; describe("renderJson", () => { test("pretty-prints a value as indented JSON to the given writer", () => { @@ -21,6 +21,7 @@ describe("--json short-circuits the TUI", () => { const root = createRootHandler(new TestCoreClient(), { io: io.io, logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), }); await root.route(["node", "agentcore", ...args, "--json"]); return io.stdout();