diff --git a/apps/cli/bin/app.js b/apps/cli/bin/app.js index 17a8c443..5a271571 100755 --- a/apps/cli/bin/app.js +++ b/apps/cli/bin/app.js @@ -1,8 +1,7 @@ #!/usr/bin/env node import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; -import { app, modelConfig, updateState } from '../dist/app.js'; -import { importExample, listAvailableExamples } from '../dist/application/examples/import-example.js'; +import { app, modelConfig, updateState, importExample, listExamples } from '../dist/app.js'; yargs(hideBin(process.argv)) @@ -26,41 +25,9 @@ yargs(hideBin(process.argv)) type: "boolean", description: "Do not interact with the user", default: false, - }) - .option("sync-example", { - type: "string", - description: "Import an example workflow by name (use 'all' for every example) before running", }), async (argv) => { let agent = argv.agent ?? "copilot"; - if (argv["sync-example"]) { - const requested = String(argv["sync-example"]).trim(); - const isAll = requested.toLowerCase() === "all"; - try { - const examplesToImport = isAll ? await listAvailableExamples() : [requested]; - if (examplesToImport.length === 0) { - console.error("No packaged examples are available to import."); - process.exit(1); - } - for (const exampleName of examplesToImport) { - const imported = await importExample(exampleName); - const agentList = imported.importedAgents.join(", "); - console.error(`Imported example '${exampleName}' with agents: ${agentList}`); - console.error(`Primary agent: ${imported.entryAgent}`); - if (imported.addedServers.length > 0) { - console.error(`Configured new MCP servers: ${imported.addedServers.join(", ")}`); - } - if (imported.skippedServers.length > 0) { - console.error(`Skipped existing MCP servers (already configured): ${imported.skippedServers.join(", ")}`); - } - } - } catch (error) { - console.error(error?.message ?? error); - process.exit(1); - } - console.error("Examples imported. Re-run rowboatx without --sync-example (or with --agent ) when you're ready to chat."); - return; - } await app({ agent, runId: argv.run_id, @@ -69,6 +36,69 @@ yargs(hideBin(process.argv)) }); } ) + .command( + "sync-example ", + "Import an example workflow by name", + (y) => y.positional("example", { + type: "string", + description: "The example to import", + }), + async (argv) => { + const exampleName = String(argv.example).trim(); + try { + const imported = await importExample(exampleName); + + // Build output message + const output = [ + `✓ Imported example '${exampleName}'`, + ` Agents: ${imported.importedAgents.join(", ")}`, + ` Primary: ${imported.entryAgent}`, + ]; + + if (imported.addedServers.length > 0) { + output.push(` MCP servers added: ${imported.addedServers.join(", ")}`); + } + if (imported.skippedServers.length > 0) { + output.push(` MCP servers skipped (already configured): ${imported.skippedServers.join(", ")}`); + } + + console.log(output.join("\n")); + + if (imported.postInstallInstructions) { + console.log("\n" + "=".repeat(60)); + console.log("POST-INSTALL INSTRUCTIONS"); + console.log("=".repeat(60)); + console.log(imported.postInstallInstructions); + console.log("=".repeat(60) + "\n"); + } + + console.log(`\nRun: rowboatx --agent ${imported.entryAgent}`); + } catch (error) { + console.error("Error:", error?.message ?? error); + process.exit(1); + } + } + ) + .command( + "list-example", + "List all available example workflows", + (y) => y, + async () => { + try { + const examples = await listExamples(); + if (examples.length === 0) { + console.error("No packaged examples are available to list."); + return; + } + for (const example of examples) { + console.log(example); + } + } catch (error) { + console.error(error?.message ?? error); + process.exit(1); + } + } + ) .command( "model-config", "Select model", diff --git a/apps/cli/examples/twitter-podcast.json b/apps/cli/examples/twitter-podcast.json index d008fcb6..28420c98 100644 --- a/apps/cli/examples/twitter-podcast.json +++ b/apps/cli/examples/twitter-podcast.json @@ -1,5 +1,6 @@ { "id": "twitter-podcast", + "post-import-instructions": "This example workflow generates a narrated podcast episode from recent AI-related tweets using multiple agents.", "description": "Generates a narrated podcast episode from recent AI-related tweets using multiple agents.", "entryAgent": "tweet-podcast", "agents": [ diff --git a/apps/cli/src/app.ts b/apps/cli/src/app.ts index 7f384365..0cc1d490 100644 --- a/apps/cli/src/app.ts +++ b/apps/cli/src/app.ts @@ -2,13 +2,22 @@ import { AgentState, streamAgent } from "./application/lib/agent.js"; import { StreamRenderer } from "./application/lib/stream-renderer.js"; import { stdin as input, stdout as output } from "node:process"; import fs from "fs"; +import { promises as fsp } from "fs"; import path from "path"; +import { fileURLToPath } from "url"; import { WorkDir, getModelConfig, updateModelConfig } from "./application/config/config.js"; import { RunEvent } from "./application/entities/run-events.js"; import { createInterface, Interface } from "node:readline/promises"; import { ToolCallPart } from "./application/entities/message.js"; +import { Agent } from "./application/entities/agent.js"; +import { McpServerConfig, McpServerDefinition } from "./application/entities/mcp.js"; import { z } from "zod"; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const PackageRoot = path.resolve(__dirname, ".."); +const ExamplesDir = path.join(PackageRoot, "examples"); + export async function updateState(agent: string, runId: string) { const state = new AgentState(agent, runId); // If running in a TTY, read run events from stdin line-by-line @@ -386,4 +395,126 @@ function renderCurrentModel(provider: string, flavor: string, model: string) { console.log(`- provider: ${provider}${flavor ? ` (${flavor})` : ""}`); console.log(`- model: ${model}`); console.log(""); -} \ No newline at end of file +} + +const ExampleSchema = z.object({ + id: z.string().min(1), + "post-install-instructions": z.string().optional(), + description: z.string().optional(), + entryAgent: z.string().optional(), + agents: z.array(Agent).min(1), + mcpServers: z.record(z.string(), McpServerDefinition).optional(), +}).refine( + (data) => !data.entryAgent || data.agents.some((agent) => agent.name === data.entryAgent), + { + message: "entryAgent must reference one of the defined agents", + path: ["entryAgent"], + }, +); + +async function readExampleFile(exampleName: string): Promise { + const examplePath = path.join(ExamplesDir, `${exampleName}.json`); + try { + return await fsp.readFile(examplePath, "utf8"); + } catch (error: any) { + if (error?.code === "ENOENT") { + const availableExamples = await listAvailableExamples(); + const listMessage = availableExamples.length + ? `Available examples: ${availableExamples.join(", ")}` + : "No packaged examples were found."; + throw new Error(`Unknown example '${exampleName}'. ${listMessage}`); + } + // Re-throw other errors (permission issues, etc.) + throw error; + } +} + +async function listAvailableExamples(): Promise { + try { + const entries = await fsp.readdir(ExamplesDir); + return entries + .filter((entry) => entry.endsWith(".json")) + .map((entry) => entry.replace(/\.json$/, "")) + .sort(); + } catch { + return []; + } +} + +async function writeAgents(agents: z.infer[]) { + await fsp.mkdir(path.join(WorkDir, "agents"), { recursive: true }); + await Promise.all( + agents.map(async (agent) => { + const agentPath = path.join(WorkDir, "agents", `${agent.name}.json`); + await fsp.writeFile(agentPath, JSON.stringify(agent, null, 2), "utf8"); + }), + ); +} + +async function mergeMcpServers(servers: Record>) { + const result = { added: [] as string[], skipped: [] as string[] }; + + // Early return if no servers to process + if (!servers || Object.keys(servers).length === 0) { + return result; + } + + const configPath = path.join(WorkDir, "config", "mcp.json"); + + // Read existing config + let currentConfig: z.infer = { mcpServers: {} }; + try { + const contents = await fsp.readFile(configPath, "utf8"); + currentConfig = McpServerConfig.parse(JSON.parse(contents)); + } catch (error: any) { + if (error?.code !== "ENOENT") { + throw new Error(`Unable to read MCP config: ${error.message ?? error}`); + } + // File doesn't exist yet, use empty config + } + + // Merge servers + for (const [name, definition] of Object.entries(servers)) { + if (currentConfig.mcpServers[name]) { + result.skipped.push(name); + } else { + currentConfig.mcpServers[name] = definition; + result.added.push(name); + } + } + + // Only write if we added new servers + if (result.added.length > 0) { + await fsp.mkdir(path.dirname(configPath), { recursive: true }); + await fsp.writeFile(configPath, JSON.stringify(currentConfig, null, 2), "utf8"); + } + + return result; +} + +export async function importExample(exampleName: string) { + const raw = await readExampleFile(exampleName); + const parsed = ExampleSchema.parse(JSON.parse(raw)); + const entryAgentName = parsed.entryAgent ?? parsed.agents[0]?.name; + if (!entryAgentName) { + throw new Error(`Example '${exampleName}' does not define any agents to run.`); + } + const postInstallInstructions = parsed["post-install-instructions"]; + await writeAgents(parsed.agents); + let serverMerge = { added: [] as string[], skipped: [] as string[] }; + if (parsed.mcpServers) { + serverMerge = await mergeMcpServers(parsed.mcpServers); + } + return { + id: parsed.id, + entryAgent: entryAgentName, + importedAgents: parsed.agents.map((agent) => agent.name), + addedServers: serverMerge.added, + skippedServers: serverMerge.skipped, + postInstallInstructions, + }; +} + +export async function listExamples() { + return listAvailableExamples(); +} diff --git a/apps/cli/src/application/examples/import-example.ts b/apps/cli/src/application/examples/import-example.ts deleted file mode 100644 index 31f7fefc..00000000 --- a/apps/cli/src/application/examples/import-example.ts +++ /dev/null @@ -1,115 +0,0 @@ -import path from "path"; -import { fileURLToPath } from "url"; -import { promises as fs } from "fs"; -import { z } from "zod"; -import { Agent } from "../entities/agent.js"; -import { WorkDir } from "../config/config.js"; -import { McpServerConfig, McpServerDefinition } from "../entities/mcp.js"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const PackageRoot = path.resolve(__dirname, "../../.."); -const ExamplesDir = path.join(PackageRoot, "examples"); - -const ExampleSchema = z.object({ - id: z.string().min(1), - description: z.string().optional(), - entryAgent: z.string().optional(), - agents: z.array(Agent).min(1), - mcpServers: z.record(z.string(), McpServerDefinition).optional(), -}).refine( - (data) => !data.entryAgent || data.agents.some((agent) => agent.name === data.entryAgent), - { - message: "entryAgent must reference one of the defined agents", - path: ["entryAgent"], - }, -); - -async function readExampleFile(exampleName: string): Promise { - const examplePath = path.join(ExamplesDir, `${exampleName}.json`); - try { - await fs.access(examplePath); - return await fs.readFile(examplePath, "utf8"); - } catch (error) { - const availableExamples = await listAvailableExamples(); - const listMessage = availableExamples.length - ? `Available examples: ${availableExamples.join(", ")}` - : "No packaged examples were found."; - throw new Error(`Unknown example '${exampleName}'. ${listMessage}`); - } -} - -export async function listAvailableExamples(): Promise { - try { - const entries = await fs.readdir(ExamplesDir); - return entries - .filter((entry) => entry.endsWith(".json")) - .map((entry) => entry.replace(/\.json$/, "")) - .sort(); - } catch { - return []; - } -} - -async function writeAgents(agents: z.infer[]) { - await fs.mkdir(path.join(WorkDir, "agents"), { recursive: true }); - await Promise.all( - agents.map(async (agent) => { - const agentPath = path.join(WorkDir, "agents", `${agent.name}.json`); - await fs.writeFile(agentPath, JSON.stringify(agent, null, 2), "utf8"); - }), - ); -} - -async function mergeMcpServers(servers: Record>) { - const result = { added: [] as string[], skipped: [] as string[] }; - if (!servers || Object.keys(servers).length === 0) { - return result; - } - const configPath = path.join(WorkDir, "config", "mcp.json"); - let currentConfig: z.infer = { mcpServers: {} }; - try { - const contents = await fs.readFile(configPath, "utf8"); - currentConfig = McpServerConfig.parse(JSON.parse(contents)); - } catch (error: any) { - if (error?.code !== "ENOENT") { - throw new Error(`Unable to read MCP config: ${error.message ?? error}`); - } - } - let modified = false; - for (const [name, definition] of Object.entries(servers)) { - if (currentConfig.mcpServers[name]) { - result.skipped.push(name); - continue; - } - currentConfig.mcpServers[name] = definition; - result.added.push(name); - modified = true; - } - await fs.mkdir(path.dirname(configPath), { recursive: true }); - if (modified) { - await fs.writeFile(configPath, JSON.stringify(currentConfig, null, 2), "utf8"); - } - return result; -} - -export async function importExample(exampleName: string) { - const raw = await readExampleFile(exampleName); - const parsed = ExampleSchema.parse(JSON.parse(raw)); - const entryAgentName = parsed.entryAgent ?? parsed.agents[0]?.name; - if (!entryAgentName) { - throw new Error(`Example '${exampleName}' does not define any agents to run.`); - } - await writeAgents(parsed.agents); - let serverMerge = { added: [] as string[], skipped: [] as string[] }; - if (parsed.mcpServers) { - serverMerge = await mergeMcpServers(parsed.mcpServers); - } - return { - id: parsed.id, - entryAgent: entryAgentName, - importedAgents: parsed.agents.map((agent) => agent.name), - addedServers: serverMerge.added, - skippedServers: serverMerge.skipped, - }; -}