diff --git a/.agentic/tasks/01KTYGNV45PJ7WEVQA1MC0NVDQ.json b/.agentic/tasks/01KTYGNV45PJ7WEVQA1MC0NVDQ.json new file mode 100644 index 0000000..a07f82d --- /dev/null +++ b/.agentic/tasks/01KTYGNV45PJ7WEVQA1MC0NVDQ.json @@ -0,0 +1,18 @@ +{ + "description": "tnezdev/agentic#107: CLI support runtime package discovery and delegation", + "status": "done", + "tags": [], + "id": "01KTYGNV45PJ7WEVQA1MC0NVDQ", + "annotations": [ + { + "text": "status: ready → in_progress", + "timestamp": "2026-06-12T18:14:34.120Z" + }, + { + "text": "status: in_progress → done", + "timestamp": "2026-06-12T18:25:07.419Z" + } + ], + "created_at": "2026-06-12T18:14:30.277Z", + "updated_at": "2026-06-12T18:25:07.419Z" +} \ No newline at end of file diff --git a/packages/agentic/AGENTS.md b/packages/agentic/AGENTS.md index 7252161..661bb02 100644 --- a/packages/agentic/AGENTS.md +++ b/packages/agentic/AGENTS.md @@ -93,7 +93,7 @@ Current command surface: - `agentic workflow list/show/run/status` - `agentic persona list/view/activate` - `agentic artifact create/read/write/edit/inspect/list/finalize` -- `agentic runtime list/add/init/run/status` — runtime package namespace only; package install/discovery/delegation belongs to the runtime lane +- `agentic runtime list/add/init/run/status` — runtime package discovery and delegation in core; runtime behavior belongs to runtime packages ### Skills on disk diff --git a/packages/agentic/package.json b/packages/agentic/package.json index 62888ab..5498b96 100644 --- a/packages/agentic/package.json +++ b/packages/agentic/package.json @@ -19,6 +19,10 @@ ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" + }, + "./runtime": { + "types": "./dist/runtime.d.ts", + "default": "./dist/runtime.js" } }, "bin": { diff --git a/packages/agentic/src/cli/commands/runtime.ts b/packages/agentic/src/cli/commands/runtime.ts index 3ad8178..8dd997a 100644 --- a/packages/agentic/src/cli/commands/runtime.ts +++ b/packages/agentic/src/cli/commands/runtime.ts @@ -1,10 +1,29 @@ +import { existsSync } from "node:fs" +import { mkdir, readFile, writeFile } from "node:fs/promises" +import { createRequire } from "node:module" +import { join } from "node:path" +import { pathToFileURL } from "node:url" +import { parseToml } from "../../config.js" +import { FilesystemArtifactAdapter } from "../../artifact/filesystem.js" +import { FilesystemWorkflowAdapter } from "../../workflow/filesystem.js" +import { FilesystemPersonaAdapter } from "../../personas/filesystem.js" +import { LayeredSource } from "../../sources/layered.js" +import { NestedFileSource } from "../../sources/nested-file.js" +import { resolveGlobalDir, resolveProjectDir } from "../../resolve-dir.js" import type { + AgenticRuntimePackage, RuntimeCommandName, RuntimeCommandOutput, + RuntimeCommandResult, + RuntimeConfig, + RuntimeInitArgs, RuntimeListOutput, RuntimeRef, + RuntimeRefStatus, + RuntimeRunArgs, + RuntimeStatusArgs, } from "../../types.js" -import type { Command } from "../context.js" +import type { Command, Ctx } from "../context.js" import { formatRuntimeAction, formatRuntimeHelp, @@ -13,14 +32,20 @@ import { import { output } from "../output.js" const PACKAGE_DISCOVERY_NOTE = - "Runtime packages are optional. This slice adds the CLI namespace only; package install, config writes, discovery, and delegation are intentionally deferred." + "Runtime packages are optional. Use `agentic runtime add ` to record a target; install guidance is printed when a package is missing." -const OFFICIAL_RUNTIMES: RuntimeRef[] = [ +type OfficialRuntime = Omit + +type ResolvedRuntime = OfficialRuntime & { + configured: boolean + runtime_config: Record +} + +const OFFICIAL_RUNTIMES: OfficialRuntime[] = [ { name: "local", package_name: "@tnezdev/agentic-runtime-local", description: "Run Agentic workspaces on the local machine.", - status: "known", capabilities: ["init", "run", "status"], install_command: "bun add -d @tnezdev/agentic-runtime-local", }, @@ -34,16 +59,31 @@ platform integration. Commands: runtime list List known runtime targets - runtime add Show package guidance for a runtime target + runtime add Record a runtime target and verify its package runtime init [name] Initialize a configured runtime target runtime run [target] Run a target with the default runtime - runtime status [name] Show runtime availability guidance` + runtime status [name] Show runtime status through the runtime package` + +class MissingRuntimePackageError extends Error {} +class InvalidRuntimePackageError extends Error {} + +type RuntimeDiscovery = + | { ok: true; manifest: AgenticRuntimePackage } + | { ok: false; reason: "missing" | "invalid"; message: string } + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} function knownRuntimeNames(): string { return OFFICIAL_RUNTIMES.map((runtime) => runtime.name).join(", ") } -function getRuntime(name: string): RuntimeRef { +function runtimeConfig(ctx: Ctx): RuntimeConfig { + return ctx.config.runtime ?? { targets: {} } +} + +function getOfficialRuntime(name: string): OfficialRuntime { const runtime = OFFICIAL_RUNTIMES.find((candidate) => candidate.name === name) if (runtime === undefined) { throw new Error( @@ -53,40 +93,342 @@ function getRuntime(name: string): RuntimeRef { return runtime } +function defaultRuntimeName(ctx: Ctx): string { + const config = runtimeConfig(ctx) + if (config.default !== undefined) return config.default + const configured = Object.keys(config.targets) + if (configured.length === 1) return configured[0]! + return "local" +} + +function resolveRuntime(ctx: Ctx, name: string): ResolvedRuntime { + const official = getOfficialRuntime(name) + const configured = runtimeConfig(ctx).targets[name] + return { + ...official, + package_name: configured?.package ?? official.package_name, + configured: configured !== undefined, + runtime_config: configured?.config ?? {}, + } +} + +function runtimeRef( + runtime: ResolvedRuntime, + status: RuntimeRefStatus, + manifest?: AgenticRuntimePackage | undefined, + error?: string | undefined, +): RuntimeRef { + return { + name: runtime.name, + package_name: manifest?.package_name ?? runtime.package_name, + description: manifest?.description ?? runtime.description, + status, + capabilities: manifest?.capabilities ?? runtime.capabilities, + install_command: runtime.install_command, + ...(error !== undefined ? { error } : {}), + } +} + function runtimeAction( command: RuntimeCommandName, runtime: RuntimeRef, status: RuntimeCommandOutput["status"], message: string, - target?: string | undefined, + options?: { + target?: string | undefined + next_steps?: string[] | undefined + result?: RuntimeCommandResult | undefined + }, ): RuntimeCommandOutput { - return { + const action: RuntimeCommandOutput = { command, runtime, - ...(target !== undefined ? { target } : {}), status, message, - next_steps: [ - `Runtime package: ${runtime.package_name}`, - `Install command once the package exists: ${runtime.install_command}`, - "Next runtime slice: package discovery, config writes, and command delegation.", - ], + next_steps: options?.next_steps ?? [], } + if (options?.target !== undefined) action.target = options.target + if (options?.result !== undefined) action.result = options.result + return action } -function unavailableError( - command: RuntimeCommandName, - runtime: RuntimeRef, - target?: string | undefined, -): Error { - const action = runtimeAction( - command, - runtime, - "needs_package", - `Cannot ${command} with runtime "${runtime.name}" yet because runtime package discovery/delegation is not implemented in core.`, - target, +function missingPackageSteps(runtime: ResolvedRuntime): string[] { + return [ + `Runtime package: ${runtime.package_name}`, + `Install command: ${runtime.install_command}`, + "Then rerun the runtime command.", + ] +} + +function missingPackageError(runtime: ResolvedRuntime): Error { + return new Error( + [ + `Runtime package for "${runtime.name}" is not installed.`, + ...missingPackageSteps(runtime), + ].join("\n"), + ) +} + +function workspaceConfigDir(baseDir: string): string { + const agenticDir = join(baseDir, ".agentic") + const sporesDir = join(baseDir, ".spores") + if (!existsSync(agenticDir) && existsSync(sporesDir)) return sporesDir + return agenticDir +} + +async function readTextIfExists(path: string): Promise { + try { + return await readFile(path, "utf-8") + } catch (err) { + if (isRecord(err) && err["code"] === "ENOENT") return "" + throw err + } +} + +function sectionFromDoc(value: unknown): Record { + return isRecord(value) ? (value as Record) : {} +} + +function tomlString(value: string): string { + return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` +} + +function sectionBlock( + name: string, + values: Record, + firstKey: string, +): string { + const keys = [ + ...(values[firstKey] !== undefined ? [firstKey] : []), + ...Object.keys(values).filter((key) => key !== firstKey).sort(), + ] + return [`[${name}]`, ...keys.map((key) => `${key} = ${tomlString(values[key]!)}`)].join( + "\n", + ) +} + +function removeSections(text: string, sections: Set): string { + const kept: string[] = [] + let skipping = false + + for (const line of text.split(/\r?\n/)) { + const sectionMatch = line.trim().match(/^\[([^\]]+)]$/) + if (sectionMatch) { + skipping = sections.has(sectionMatch[1]!) + } + if (!skipping) kept.push(line) + } + + return kept.join("\n").trimEnd() +} + +function upsertRuntimeConfig( + text: string, + name: string, + packageName: string, +): string { + const doc = parseToml(text) + const runtimeSection = { + ...sectionFromDoc(doc["runtime"]), + default: name, + } + const targetSection = { + ...sectionFromDoc(doc[`runtime.${name}`]), + package: packageName, + } + const base = removeSections(text, new Set(["runtime", `runtime.${name}`])) + return [ + base, + sectionBlock("runtime", runtimeSection, "default"), + sectionBlock(`runtime.${name}`, targetSection, "package"), + ] + .filter((part) => part.length > 0) + .join("\n\n") + "\n" +} + +async function writeRuntimeConfig( + baseDir: string, + name: string, + packageName: string, +): Promise { + const dir = workspaceConfigDir(baseDir) + const configPath = join(dir, "config.toml") + await mkdir(dir, { recursive: true }) + const current = await readTextIfExists(configPath) + await writeFile(configPath, upsertRuntimeConfig(current, name, packageName), "utf-8") + return configPath +} + +function resolveRuntimePackage(baseDir: string, packageName: string): string { + try { + const requireFromWorkspace = createRequire(join(baseDir, "package.json")) + return requireFromWorkspace.resolve(packageName) + } catch { + throw new MissingRuntimePackageError(`Runtime package "${packageName}" is not installed.`) + } +} + +function validateRuntimePackage( + value: unknown, + expected: ResolvedRuntime, +): AgenticRuntimePackage { + if (!isRecord(value)) { + throw new InvalidRuntimePackageError( + `Runtime package "${expected.package_name}" must export a runtime manifest object.`, + ) + } + if (value["kind"] !== "agentic-runtime") { + throw new InvalidRuntimePackageError( + `Runtime package "${expected.package_name}" has invalid kind; expected "agentic-runtime".`, + ) + } + if (value["api_version"] !== 1) { + throw new InvalidRuntimePackageError( + `Runtime package "${expected.package_name}" has unsupported api_version; expected 1.`, + ) + } + if (value["name"] !== expected.name) { + throw new InvalidRuntimePackageError( + `Runtime package "${expected.package_name}" declares name "${String(value["name"])}"; expected "${expected.name}".`, + ) + } + if (value["package_name"] !== expected.package_name) { + throw new InvalidRuntimePackageError( + `Runtime package "${expected.package_name}" declares package_name "${String(value["package_name"])}".`, + ) + } + if (!Array.isArray(value["capabilities"]) || !value["capabilities"].every((item) => typeof item === "string")) { + throw new InvalidRuntimePackageError( + `Runtime package "${expected.package_name}" must declare string capabilities.`, + ) + } + if (!isRecord(value["commands"])) { + throw new InvalidRuntimePackageError( + `Runtime package "${expected.package_name}" must declare a commands object.`, + ) + } + + const commands = value["commands"] + for (const command of ["init", "run", "status", "dev", "deploy"]) { + const handler = commands[command] + if (handler !== undefined && typeof handler !== "function") { + throw new InvalidRuntimePackageError( + `Runtime package "${expected.package_name}" command "${command}" must be a function.`, + ) + } + } + + return value as AgenticRuntimePackage +} + +async function loadRuntimePackage( + baseDir: string, + runtime: ResolvedRuntime, +): Promise { + const entry = resolveRuntimePackage(baseDir, runtime.package_name) + let imported: unknown + try { + imported = await import(pathToFileURL(entry).href) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + throw new InvalidRuntimePackageError( + `Could not load runtime package "${runtime.package_name}": ${message}`, + ) + } + + const moduleRecord = isRecord(imported) ? imported : {} + const manifest = moduleRecord["runtime"] ?? moduleRecord["default"] + return validateRuntimePackage(manifest, runtime) +} + +async function discoverRuntimePackage( + baseDir: string, + runtime: ResolvedRuntime, +): Promise { + try { + return { ok: true, manifest: await loadRuntimePackage(baseDir, runtime) } + } catch (err) { + if (err instanceof MissingRuntimePackageError) { + return { ok: false, reason: "missing", message: err.message } + } + if (err instanceof InvalidRuntimePackageError) { + return { ok: false, reason: "invalid", message: err.message } + } + throw err + } +} + +function runtimeSkillsSource(baseDir: string): LayeredSource { + return new LayeredSource([ + new NestedFileSource(resolveProjectDir(baseDir, "skills"), "skill.md"), + new NestedFileSource(resolveGlobalDir("skills"), "skill.md"), + ]) +} + +function runtimeContext(ctx: Ctx, runtime: ResolvedRuntime) { + return { + cwd: ctx.baseDir, + workspace_root: ctx.baseDir, + runtime_name: runtime.name, + runtime_package: runtime.package_name, + json: ctx.json, + env: process.env, + config: ctx.config, + runtime_config: runtime.runtime_config, + agentic: { + memory: ctx.adapter, + workflows: new FilesystemWorkflowAdapter(ctx.baseDir), + personas: new FilesystemPersonaAdapter(ctx.baseDir), + skills: runtimeSkillsSource(ctx.baseDir), + artifacts: new FilesystemArtifactAdapter(ctx.baseDir), + }, + } +} + +function supportedRuntimeCommands(manifest: AgenticRuntimePackage): string[] { + return Object.entries(manifest.commands) + .filter(([, handler]) => typeof handler === "function") + .map(([name]) => name) + .sort() +} + +async function loadRuntimeForDelegation( + ctx: Ctx, + runtime: ResolvedRuntime, +): Promise { + const discovery = await discoverRuntimePackage(ctx.baseDir, runtime) + if (!discovery.ok) { + if (discovery.reason === "missing") throw missingPackageError(runtime) + throw new Error(discovery.message) + } + return discovery.manifest +} + +async function delegateRuntimeCommand( + ctx: Ctx, + command: "init" | "run" | "status", + runtime: ResolvedRuntime, + args: RuntimeInitArgs | RuntimeRunArgs | RuntimeStatusArgs, +): Promise { + const manifest = await loadRuntimeForDelegation(ctx, runtime) + const handler = manifest.commands[command] + if (handler === undefined) { + const supported = supportedRuntimeCommands(manifest).join(", ") || "none" + throw new Error( + `Runtime "${runtime.name}" does not support command "${command}". Supported commands: ${supported}.`, + ) + } + + const result = await handler(runtimeContext(ctx, runtime), args as never) + const message = result?.summary ?? `Runtime "${runtime.name}" ${command} completed.` + output( + ctx, + runtimeAction(command, runtimeRef(runtime, "configured", manifest), "delegated", message, { + target: "target" in args ? args.target : undefined, + result: result ?? undefined, + }), + formatRuntimeAction, ) - return new Error([action.message, ...action.next_steps].join(" ")) } export const runtimeHelpCommand: Command = async (ctx) => { @@ -94,8 +436,27 @@ export const runtimeHelpCommand: Command = async (ctx) => { } export const runtimeListCommand: Command = async (ctx) => { + const runtimes: RuntimeRef[] = [] + for (const official of OFFICIAL_RUNTIMES) { + const runtime = resolveRuntime(ctx, official.name) + const discovery = await discoverRuntimePackage(ctx.baseDir, runtime) + if (discovery.ok) { + runtimes.push( + runtimeRef( + runtime, + runtime.configured ? "configured" : "installed", + discovery.manifest, + ), + ) + } else if (discovery.reason === "invalid") { + runtimes.push(runtimeRef(runtime, "invalid_manifest", undefined, discovery.message)) + } else { + runtimes.push(runtimeRef(runtime, runtime.configured ? "missing_package" : "available")) + } + } + const result: RuntimeListOutput = { - runtimes: OFFICIAL_RUNTIMES, + runtimes, note: PACKAGE_DISCOVERY_NOTE, } output(ctx, result, formatRuntimeList) @@ -105,33 +466,91 @@ export const runtimeAddCommand: Command = async (ctx, args) => { const name = args[0] if (name === undefined) throw new Error("Usage: agentic runtime add ") - const runtime = getRuntime(name) + const official = getOfficialRuntime(name) + const runtime: ResolvedRuntime = { + ...official, + configured: true, + runtime_config: runtimeConfig(ctx).targets[name]?.config ?? {}, + } + const discovery = await discoverRuntimePackage(ctx.baseDir, runtime) + + if (!discovery.ok && discovery.reason === "invalid") { + throw new Error(discovery.message) + } + + const configPath = await writeRuntimeConfig(ctx.baseDir, official.name, official.package_name) + + if (!discovery.ok) { + const result = runtimeAction( + "add", + runtimeRef(runtime, "missing_package"), + "needs_package", + `Recorded runtime target "${runtime.name}" in ${configPath}, but the package is not installed yet.`, + { next_steps: missingPackageSteps(runtime) }, + ) + output(ctx, result, formatRuntimeAction) + return + } + const result = runtimeAction( "add", - runtime, - "recognized", - `Runtime target "${runtime.name}" resolves to ${runtime.package_name}. This command does not mutate config until package discovery lands.`, + runtimeRef(runtime, "configured", discovery.manifest), + "added", + `Recorded runtime target "${runtime.name}" in ${configPath}.`, + { + result: { + summary: `Loaded ${discovery.manifest.package_name}.`, + data: { + config_path: configPath, + manifest: { + name: discovery.manifest.name, + package_name: discovery.manifest.package_name, + capabilities: discovery.manifest.capabilities, + }, + }, + }, + }, ) output(ctx, result, formatRuntimeAction) } -export const runtimeInitCommand: Command = async (_ctx, args) => { - const runtime = getRuntime(args[0] ?? "local") - throw unavailableError("init", runtime) +export const runtimeInitCommand: Command = async (ctx, args, flags) => { + const name = args[0] ?? defaultRuntimeName(ctx) + const runtime = resolveRuntime(ctx, name) + await delegateRuntimeCommand(ctx, "init", runtime, { + args: args[0] === undefined ? [] : args.slice(1), + flags, + }) } -export const runtimeRunCommand: Command = async (_ctx, args) => { - const runtime = getRuntime("local") - throw unavailableError("run", runtime, args[0]) +export const runtimeRunCommand: Command = async (ctx, args, flags) => { + const runtime = resolveRuntime(ctx, defaultRuntimeName(ctx)) + await delegateRuntimeCommand(ctx, "run", runtime, { + target: args[0], + args: args.slice(1), + flags, + }) } -export const runtimeStatusCommand: Command = async (ctx, args) => { - const runtime = getRuntime(args[0] ?? "local") - const result = runtimeAction( - "status", - runtime, - "needs_package", - `Runtime target "${runtime.name}" is known, but runtime package discovery/delegation is not implemented yet.`, - ) - output(ctx, result, formatRuntimeAction) +export const runtimeStatusCommand: Command = async (ctx, args, flags) => { + const runtime = resolveRuntime(ctx, args[0] ?? defaultRuntimeName(ctx)) + const discovery = await discoverRuntimePackage(ctx.baseDir, runtime) + if (!discovery.ok && discovery.reason === "missing") { + const configuredState = runtime.configured ? "configured" : "known" + const result = runtimeAction( + "status", + runtimeRef(runtime, "missing_package"), + "needs_package", + `Runtime target "${runtime.name}" is ${configuredState}, but the package is not installed yet.`, + { next_steps: missingPackageSteps(runtime) }, + ) + output(ctx, result, formatRuntimeAction) + return + } + if (!discovery.ok) throw new Error(discovery.message) + + await delegateRuntimeCommand(ctx, "status", runtime, { + args: args[0] === undefined ? [] : args.slice(1), + flags, + }) } diff --git a/packages/agentic/src/cli/format.ts b/packages/agentic/src/cli/format.ts index 0afc2c1..79ed3e2 100644 --- a/packages/agentic/src/cli/format.ts +++ b/packages/agentic/src/cli/format.ts @@ -637,11 +637,17 @@ export function formatRuntimeList(result: RuntimeListOutput): string { } export function formatRuntimeAction(result: RuntimeCommandOutput): string { - return [ + const lines = [ `${result.runtime.name}: ${result.status}`, result.message, ...(result.target !== undefined ? [`target: ${result.target}`] : []), - "next steps:", - ...result.next_steps.map((step) => ` - ${step}`), - ].join("\n") + ] + if (result.result?.data !== undefined) { + lines.push(JSON.stringify(result.result.data, null, 2)) + } + if (result.next_steps.length > 0) { + lines.push("next steps:") + lines.push(...result.next_steps.map((step) => ` - ${step}`)) + } + return lines.join("\n") } diff --git a/packages/agentic/src/cli/main.test.ts b/packages/agentic/src/cli/main.test.ts index 93e46af..c67c8e0 100644 --- a/packages/agentic/src/cli/main.test.ts +++ b/packages/agentic/src/cli/main.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test" -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" import { join } from "node:path" import { tmpdir } from "node:os" @@ -23,6 +23,46 @@ async function runJson(...args: string[]): Promise { return JSON.parse(stdout) } +async function writeRuntimePackage(baseDir: string, source?: string): Promise { + const pkgDir = join( + baseDir, + "node_modules", + "@tnezdev", + "agentic-runtime-local", + ) + await mkdir(pkgDir, { recursive: true }) + await writeFile( + join(pkgDir, "package.json"), + JSON.stringify({ type: "module", exports: "./index.js" }, null, 2), + ) + await writeFile( + join(pkgDir, "index.js"), + source ?? `export const runtime = { + kind: "agentic-runtime", + api_version: 1, + name: "local", + package_name: "@tnezdev/agentic-runtime-local", + description: "Test local runtime", + capabilities: ["init", "run", "status"], + commands: { + init: async (ctx, args) => ({ + summary: "initialized local runtime", + data: { runtime_config: ctx.runtime_config, args: args.args }, + }), + run: async (ctx, args) => ({ + summary: "ran local target", + data: { target: args.target, args: args.args, json: ctx.json }, + }), + status: async () => ({ + summary: "runtime ready", + data: { ready: true }, + }), + }, +} +`, + ) +} + describe("CLI", () => { let tmpDir: string let base: string[] @@ -57,15 +97,16 @@ describe("CLI", () => { it("routes runtime list", async () => { const result = (await runJson(...base, "runtime", "list")) as { - runtimes: Array<{ name: string; package_name: string }> + runtimes: Array<{ name: string; package_name: string; status: string }> note: string } expect(result.runtimes[0]!.name).toBe("local") expect(result.runtimes[0]!.package_name).toBe("@tnezdev/agentic-runtime-local") - expect(result.note).toContain("CLI namespace only") + expect(result.runtimes[0]!.status).toBe("available") + expect(result.note).toContain("Runtime packages are optional") }) - it("runtime add gives package guidance for local", async () => { + it("runtime add records local and gives package guidance when missing", async () => { const result = (await runJson(...base, "runtime", "add", "local")) as { command: string status: string @@ -73,9 +114,52 @@ describe("CLI", () => { next_steps: string[] } expect(result.command).toBe("add") - expect(result.status).toBe("recognized") + expect(result.status).toBe("needs_package") expect(result.runtime.package_name).toBe("@tnezdev/agentic-runtime-local") expect(result.next_steps.join("\n")).toContain("bun add -d @tnezdev/agentic-runtime-local") + + const config = await readFile(join(tmpDir, ".agentic", "config.toml"), "utf-8") + expect(config).toContain('[runtime]') + expect(config).toContain('default = "local"') + expect(config).toContain('[runtime.local]') + expect(config).toContain('package = "@tnezdev/agentic-runtime-local"') + }) + + it("runtime add verifies an installed runtime package", async () => { + await writeRuntimePackage(tmpDir) + + const result = (await runJson(...base, "runtime", "add", "local")) as { + status: string + runtime: { status: string } + result: { data: { manifest: { capabilities: string[] } } } + } + + expect(result.status).toBe("added") + expect(result.runtime.status).toBe("configured") + expect(result.result.data.manifest.capabilities).toContain("run") + }) + + it("runtime add rejects an invalid installed package without writing config", async () => { + await writeRuntimePackage( + tmpDir, + `export const runtime = { kind: "not-agentic", api_version: 1 } +`, + ) + + const { stdout, exitCode } = await run( + "--json", + ...base, + "runtime", + "add", + "local", + ) + + expect(exitCode).toBe(1) + const result = JSON.parse(stdout) as { error: string } + expect(result.error).toContain("invalid kind") + + const configExists = await Bun.file(join(tmpDir, ".agentic", "config.toml")).exists() + expect(configExists).toBe(false) }) it("runtime add rejects unknown runtime names", async () => { @@ -91,7 +175,7 @@ describe("CLI", () => { expect(result.error).toContain('Unknown runtime target "spaceship"') }) - it("runtime run fails with actionable package guidance before delegation exists", async () => { + it("runtime run fails with actionable package guidance when package is missing", async () => { const { stdout, exitCode } = await run( "--json", ...base, @@ -105,6 +189,44 @@ describe("CLI", () => { expect(result.error).toContain("bun add -d @tnezdev/agentic-runtime-local") }) + it("runtime run delegates to an installed runtime package", async () => { + await writeRuntimePackage(tmpDir) + await run(...base, "runtime", "add", "local") + + const result = (await runJson( + ...base, + "runtime", + "run", + "inbox-review", + "extra", + )) as { + status: string + result: { data: { target: string; args: string[]; json: boolean } } + } + + expect(result.status).toBe("delegated") + expect(result.result.data.target).toBe("inbox-review") + expect(result.result.data.args).toEqual(["extra"]) + expect(result.result.data.json).toBe(true) + }) + + it("runtime init passes opaque runtime config to the package", async () => { + await writeRuntimePackage(tmpDir) + await mkdir(join(tmpDir, ".agentic"), { recursive: true }) + await writeFile( + join(tmpDir, ".agentic", "config.toml"), + '[runtime]\ndefault = "local"\n\n[runtime.local]\npackage = "@tnezdev/agentic-runtime-local"\nharness = "pi"\n', + ) + + const result = (await runJson(...base, "runtime", "init")) as { + status: string + result: { data: { runtime_config: Record } } + } + + expect(result.status).toBe("delegated") + expect(result.result.data.runtime_config).toEqual({ harness: "pi" }) + }) + it("exits 1 on unknown command", async () => { const { exitCode, stderr } = await run(...base, "bogus") expect(exitCode).toBe(1) diff --git a/packages/agentic/src/config.test.ts b/packages/agentic/src/config.test.ts index 56e7a41..0a83c0b 100644 --- a/packages/agentic/src/config.test.ts +++ b/packages/agentic/src/config.test.ts @@ -17,6 +17,15 @@ describe("parseToml", () => { ) }) + it("parses dotted sections", () => { + const doc = parseToml( + '[runtime.local]\npackage = "@tnezdev/agentic-runtime-local"', + ) + expect((doc["runtime.local"] as Record)["package"]).toBe( + "@tnezdev/agentic-runtime-local", + ) + }) + it("ignores comments and blank lines", () => { const doc = parseToml('# comment\n\nadapter = "test"') expect(doc["adapter"]).toBe("test") @@ -62,6 +71,21 @@ describe("loadConfig", () => { expect(config.memory.dreamDepth).toBe(7) }) + it("loads runtime config and opaque runtime target keys", async () => { + await mkdir(join(tmpDir, ".agentic"), { recursive: true }) + await writeFile( + join(tmpDir, ".agentic", "config.toml"), + '[runtime]\ndefault = "local"\n\n[runtime.local]\npackage = "@tnezdev/agentic-runtime-local"\nharness = "pi"\n', + ) + + const config = await loadConfig(tmpDir) + expect(config.runtime!.default).toBe("local") + expect(config.runtime!.targets["local"]!.package).toBe( + "@tnezdev/agentic-runtime-local", + ) + expect(config.runtime!.targets["local"]!.config).toEqual({ harness: "pi" }) + }) + it(".agentic config wins over .spores config when both exist", async () => { await mkdir(join(tmpDir, ".agentic"), { recursive: true }) await writeFile( diff --git a/packages/agentic/src/config.ts b/packages/agentic/src/config.ts index c416861..c485ac7 100644 --- a/packages/agentic/src/config.ts +++ b/packages/agentic/src/config.ts @@ -1,7 +1,7 @@ import { readFile } from "node:fs/promises" import { join } from "node:path" import { homedir } from "node:os" -import type { MemoryTier, SporesConfig } from "./types.js" +import type { MemoryTier, RuntimeTargetConfig, SporesConfig } from "./types.js" const DEFAULTS: SporesConfig = { adapter: "filesystem", @@ -15,6 +15,9 @@ const DEFAULTS: SporesConfig = { runsDir: ".agentic/runs", }, wake: {}, + runtime: { + targets: {}, + }, } type TomlSection = Record @@ -28,7 +31,7 @@ function parseToml(text: string): TomlDoc { const line = raw.trim() if (line === "" || line.startsWith("#")) continue - const sectionMatch = line.match(/^\[(\w+)]$/) + const sectionMatch = line.match(/^\[([\w.]+)]$/) if (sectionMatch) { currentSection = sectionMatch[1]! if (result[currentSection] === undefined) { @@ -55,11 +58,24 @@ function parseToml(text: string): TomlDoc { } function applyToml(config: SporesConfig, doc: TomlDoc): SporesConfig { + const sourceRuntime = config.runtime ?? { targets: {} } + const runtimeTargets: Record = {} + for (const [name, target] of Object.entries(sourceRuntime.targets)) { + runtimeTargets[name] = { + package: target.package, + config: { ...target.config }, + } + } + const result = { ...config, memory: { ...config.memory }, workflow: { ...config.workflow }, wake: { ...config.wake }, + runtime: { + default: sourceRuntime.default, + targets: runtimeTargets, + }, } if (typeof doc["adapter"] === "string") { @@ -86,6 +102,29 @@ function applyToml(config: SporesConfig, doc: TomlDoc): SporesConfig { if (wake["template"] !== undefined) result.wake.template = wake["template"] } + const runtime = doc["runtime"] + if (typeof runtime === "object") { + if (runtime["default"] !== undefined) result.runtime.default = runtime["default"] + } + + for (const [sectionName, section] of Object.entries(doc)) { + if (!sectionName.startsWith("runtime.") || typeof section !== "object") { + continue + } + + const name = sectionName.slice("runtime.".length) + const runtimeConfig: Record = {} + for (const [key, value] of Object.entries(section)) { + if (key !== "package") runtimeConfig[key] = value + } + + const existing = result.runtime.targets[name] + result.runtime.targets[name] = { + package: section["package"] ?? existing?.package, + config: { ...(existing?.config ?? {}), ...runtimeConfig }, + } + } + return result } diff --git a/packages/agentic/src/index.ts b/packages/agentic/src/index.ts index 4b253a6..b1377a8 100644 --- a/packages/agentic/src/index.ts +++ b/packages/agentic/src/index.ts @@ -63,9 +63,24 @@ export type { CapabilityPolicy, CapabilityArtifacts, CapabilityDef, + AgenticRuntimeBindings, + AgenticRuntimePackage, RuntimeCapability, + RuntimeCommandFlags, + RuntimeCommandHandler, + RuntimeCommandMap, RuntimeCommandName, + RuntimeCommandResult, + RuntimeContext, + RuntimeDeployArgs, + RuntimeDevArgs, + RuntimeInitArgs, RuntimeRef, + RuntimeRefStatus, + RuntimeRunArgs, + RuntimeStatusArgs, + RuntimeConfig, + RuntimeTargetConfig, RuntimeListOutput, RuntimeCommandOutput, } from "./types.js" diff --git a/packages/agentic/src/runtime.ts b/packages/agentic/src/runtime.ts new file mode 100644 index 0000000..12692e3 --- /dev/null +++ b/packages/agentic/src/runtime.ts @@ -0,0 +1,15 @@ +export type { + AgenticRuntimeBindings, + AgenticRuntimePackage, + RuntimeCapability, + RuntimeCommandFlags, + RuntimeCommandHandler, + RuntimeCommandMap, + RuntimeCommandResult, + RuntimeContext, + RuntimeDeployArgs, + RuntimeDevArgs, + RuntimeInitArgs, + RuntimeRunArgs, + RuntimeStatusArgs, +} from "./types.js" diff --git a/packages/agentic/src/types.ts b/packages/agentic/src/types.ts index b1dc730..ce6d88e 100644 --- a/packages/agentic/src/types.ts +++ b/packages/agentic/src/types.ts @@ -1,3 +1,9 @@ +import type { ArtifactAdapter } from "./artifact/adapter.js" +import type { MemoryAdapter } from "./memory/adapter.js" +import type { PersonaAdapter } from "./personas/adapter.js" +import type { Source } from "./sources/source.js" +import type { WorkflowAdapter } from "./workflow/adapter.js" + export type MemoryTier = "L1" | "L2" | "L3" export type Memory = { @@ -42,6 +48,7 @@ export type SporesConfig = { wake: { template?: string | undefined // path to WAKE.md template (absolute or relative to baseDir) } + runtime?: RuntimeConfig | undefined } /** Preferred alias for {@link SporesConfig}. Use `AgenticConfig` in new code. */ @@ -368,10 +375,19 @@ export type DispatchHandlerHooks = { // Runtime package CLI types // // Core owns the runtime CLI front door and package seam. Runtime packages own -// harness/platform integration. These types describe the narrow placeholder -// surface used before package discovery/delegation lands. +// harness/platform integration. // --------------------------------------------------------------------------- +export type RuntimeTargetConfig = { + package?: string | undefined + config: Record +} + +export type RuntimeConfig = { + default?: string | undefined + targets: Record +} + export type RuntimeCapability = | "init" | "run" @@ -384,13 +400,97 @@ export type RuntimeCapability = export type RuntimeCommandName = "add" | "init" | "run" | "status" +export type RuntimeCommandFlags = Record + +export type RuntimeInitArgs = { + args: string[] + flags: RuntimeCommandFlags +} + +export type RuntimeRunArgs = { + target?: string | undefined + args: string[] + flags: RuntimeCommandFlags +} + +export type RuntimeStatusArgs = { + args: string[] + flags: RuntimeCommandFlags +} + +export type RuntimeDevArgs = { + args: string[] + flags: RuntimeCommandFlags +} + +export type RuntimeDeployArgs = { + args: string[] + flags: RuntimeCommandFlags +} + +export type RuntimeCommandResult = { + summary?: string | undefined + data?: unknown +} + +export type RuntimeCommandHandler = ( + ctx: RuntimeContext, + args: TArgs, +) => Promise + +export type RuntimeCommandMap = { + init?: RuntimeCommandHandler | undefined + run?: RuntimeCommandHandler | undefined + status?: RuntimeCommandHandler | undefined + dev?: RuntimeCommandHandler | undefined + deploy?: RuntimeCommandHandler | undefined +} + +export type AgenticRuntimeBindings = { + memory: MemoryAdapter + workflows: WorkflowAdapter + personas: PersonaAdapter + skills: Source + artifacts: ArtifactAdapter +} + +export type RuntimeContext = { + cwd: string + workspace_root: string + runtime_name: string + runtime_package: string + json: boolean + env: Record + config: AgenticConfig + runtime_config: Record + agentic: AgenticRuntimeBindings +} + +export type AgenticRuntimePackage = { + kind: "agentic-runtime" + api_version: 1 + name: string + package_name: string + description?: string | undefined + capabilities: RuntimeCapability[] + commands: RuntimeCommandMap +} + +export type RuntimeRefStatus = + | "available" + | "configured" + | "installed" + | "missing_package" + | "invalid_manifest" + export type RuntimeRef = { name: string package_name: string description: string - status: "known" + status: RuntimeRefStatus capabilities: RuntimeCapability[] install_command: string + error?: string | undefined } export type RuntimeListOutput = { @@ -402,9 +502,10 @@ export type RuntimeCommandOutput = { command: RuntimeCommandName runtime: RuntimeRef target?: string | undefined - status: "recognized" | "needs_package" + status: "added" | "delegated" | "needs_package" message: string next_steps: string[] + result?: RuntimeCommandResult | undefined } // ---------------------------------------------------------------------------