diff --git a/apps/cli/.rowboat/.gitignore b/apps/cli/.rowboat/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/apps/cli/.rowboat/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/apps/cli/package.json b/apps/cli/package.json index 165932a0..8a16c38f 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "build": "rm -rf dist && tsc" + "build": "rm -rf dist && tsc", + "copilot": "npm run build && node dist/x.js" }, "keywords": [], "author": "", diff --git a/apps/cli/src/application/assistant/README.md b/apps/cli/src/application/assistant/README.md new file mode 100644 index 00000000..ebcbf861 --- /dev/null +++ b/apps/cli/src/application/assistant/README.md @@ -0,0 +1,6 @@ +Rowboat Copilot (demo) + +- Entry point: `npm run copilot` (runs `src/x.ts` after building) +- Natural language interface to list/create/update/delete workflow JSON under `.rowboat/workflows` +- Uses existing zod schemas for validation; errors bubble up plainly for easy debugging +- Data folders ensured automatically: `.rowboat/workflows`, `.rowboat/agents`, `.rowboat/mcp` diff --git a/apps/cli/src/application/assistant/USAGE.md b/apps/cli/src/application/assistant/USAGE.md new file mode 100644 index 00000000..21a25779 --- /dev/null +++ b/apps/cli/src/application/assistant/USAGE.md @@ -0,0 +1,12 @@ +Quick start + +1. `cd rowboat-V2/apps/cli` +2. `export OPENAI_API_KEY=...` +3. `npm run copilot` + +Example prompts once running: +- `list my workflows` +- `show workflow example_workflow` +- `create a workflow demo that calls function get_date` +- `add an agent step default_assistant to demo` +- `delete the demo workflow` diff --git a/apps/cli/src/application/assistant/agents/service.ts b/apps/cli/src/application/assistant/agents/service.ts new file mode 100644 index 00000000..f18022c0 --- /dev/null +++ b/apps/cli/src/application/assistant/agents/service.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; +import { Agent } from "../../entities/agent.js"; +import { deleteJson, listJson, readJson, writeJson } from "../services/storage.js"; + +export type AgentId = string; + +export function listAgents(): AgentId[] { + return listJson("agents"); +} + +export function getAgent(id: AgentId): z.infer | undefined { + const raw = readJson("agents", id); + if (!raw) return undefined; + return Agent.parse(raw); +} + +export function upsertAgent( + id: AgentId, + value: Partial> +): z.infer { + const existing = readJson("agents", id) as Partial> | undefined; + const merged = { + name: id, + model: "openai:gpt-4o-mini", + description: "", + instructions: "", + ...(existing ?? {}), + ...value, + } satisfies Partial>; + const parsed = Agent.parse(merged); + writeJson("agents", id, parsed); + return parsed; +} + +export function deleteAgent(id: AgentId): boolean { + return deleteJson("agents", id); +} diff --git a/apps/cli/src/application/assistant/chat.ts b/apps/cli/src/application/assistant/chat.ts new file mode 100644 index 00000000..d6de810d --- /dev/null +++ b/apps/cli/src/application/assistant/chat.ts @@ -0,0 +1,105 @@ +import readline from "readline"; +import { z } from "zod"; +import { openai } from "@ai-sdk/openai"; +import { generateObject } from "ai"; +import { Workflow } from "../../application/entities/workflow.js"; +import { listWorkflows, getWorkflow, upsertWorkflow, deleteWorkflow } from "./workflows/service.js"; + +const ChatCommand = z.object({ + action: z.enum([ + "help", + "list_workflows", + "get_workflow", + "create_workflow", + "update_workflow", + "delete_workflow", + "unknown", + ]), + id: z.string().optional(), + updates: Workflow.partial().optional(), +}); + +type ChatCommandT = z.infer; + +const systemPrompt = ` +You are a CLI assistant that converts the user's natural language into a JSON command for managing workflows. + +Rules: +- Only output JSON matching the provided schema. No extra commentary. +- Choose the most appropriate action from: help, list_workflows, get_workflow, create_workflow, update_workflow, delete_workflow, unknown. +- For actions that need an id (get/update/delete/create), set "id" to the workflow identifier (e.g. "example_workflow"). +- For create/update, include only provided fields in "updates". If not provided, omit. +- Workflow shape reminder: { name: string, description: string, steps: Step[] } where Step is either { type: "function", id: string } or { type: "agent", id: string }. +- If the request is ambiguous, set action to "unknown". +`; + +async function interpret(input: string): Promise { + const { object } = await generateObject({ + model: openai("gpt-4.1"), + system: systemPrompt, + prompt: input, + schema: ChatCommand, + }); + return object; +} + +async function execute(cmd: ChatCommandT): Promise { + switch (cmd.action) { + case "help": + return { + usage: [ + "Examples:", + "- list workflows", + "- show workflow example_workflow", + "- create workflow demo with one step calling function get_date", + "- update workflow demo: add agent step default_assistant", + "- delete workflow demo", + ], + }; + case "list_workflows": + return { items: listWorkflows() }; + case "get_workflow": + if (!cmd.id) return { error: "id required" }; + return getWorkflow(cmd.id) ?? null; + case "create_workflow": + if (!cmd.id) return { error: "id required" }; + return upsertWorkflow(cmd.id, { ...(cmd.updates ?? {}) }); + case "update_workflow": + if (!cmd.id) return { error: "id required" }; + return upsertWorkflow(cmd.id, { ...(cmd.updates ?? {}) }); + case "delete_workflow": + if (!cmd.id) return { error: "id required" }; + return { deleted: deleteWorkflow(cmd.id) }; + case "unknown": + return { error: "Could not determine intent. Try again or ask for help." }; + } +} + +export async function startCopilot(): Promise { + if (!process.env.OPENAI_API_KEY) { + console.error("OPENAI_API_KEY is not set. Please export it to use chat."); + process.exitCode = 1; + return; + } + + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + console.log("Rowboat Copilot (type 'exit' to quit)"); + + const ask = () => rl.question("> ", async (line) => { + if (!line || line.trim().toLowerCase() === "exit") { + rl.close(); + return; + } + try { + const cmd = await interpret(line); + console.log("\n=== Parsed Command ===\n" + JSON.stringify(cmd, null, 2)); + const result = await execute(cmd); + console.log("\n=== Result ===\n" + JSON.stringify(result, null, 2) + "\n"); + } catch (err) { + console.error("Error:", (err as Error).message); + } + ask(); + }); + + ask(); +} diff --git a/apps/cli/src/application/assistant/mcp/service.ts b/apps/cli/src/application/assistant/mcp/service.ts new file mode 100644 index 00000000..c9d6da44 --- /dev/null +++ b/apps/cli/src/application/assistant/mcp/service.ts @@ -0,0 +1,24 @@ +import fs from "fs"; +import path from "path"; +import { z } from "zod"; +import { McpServerConfig } from "../../entities/mcp.js"; +import { ensureBaseDirs, getStoragePaths } from "../services/storage.js"; + +export function mcpConfigPath(): string { + const base = getStoragePaths(); + ensureBaseDirs(base); + return path.join(base.workDir, "mcp", "servers.json"); +} + +export function readMcpConfig(): z.infer { + const p = mcpConfigPath(); + if (!fs.existsSync(p)) return { mcpServers: [] }; + const raw = fs.readFileSync(p, "utf8"); + return McpServerConfig.parse(JSON.parse(raw)); +} + +export function writeMcpConfig(value: z.infer): void { + const p = mcpConfigPath(); + const parsed = McpServerConfig.parse(value); + fs.writeFileSync(p, JSON.stringify(parsed, null, 2) + "\n", "utf8"); +} diff --git a/apps/cli/src/application/assistant/services/storage.ts b/apps/cli/src/application/assistant/services/storage.ts new file mode 100644 index 00000000..ccf14c44 --- /dev/null +++ b/apps/cli/src/application/assistant/services/storage.ts @@ -0,0 +1,70 @@ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +export type DirKind = "workflows" | "agents" | "mcp"; + +export interface StoragePaths { + appRoot: string; + workDir: string; // .rowboat +} + +const defaultPaths: StoragePaths = (() => { + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const appRoot = path.resolve(__dirname, "../../../../"); + const workDir = path.join(appRoot, ".rowboat"); + return { appRoot, workDir }; +})(); + +export function getStoragePaths(): StoragePaths { + return defaultPaths; +} + +export function ensureBaseDirs(base: StoragePaths = defaultPaths) { + const ensure = (p: string) => { + if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); + }; + ensure(base.workDir); + ensure(path.join(base.workDir, "workflows")); + ensure(path.join(base.workDir, "agents")); + ensure(path.join(base.workDir, "mcp")); +} + +export function dirFor(kind: DirKind, base: StoragePaths = defaultPaths): string { + switch (kind) { + case "workflows": + return path.join(base.workDir, "workflows"); + case "agents": + return path.join(base.workDir, "agents"); + case "mcp": + return path.join(base.workDir, "mcp"); + } +} + +export function listJson(kind: DirKind, base: StoragePaths = defaultPaths): string[] { + const d = dirFor(kind, base); + if (!fs.existsSync(d)) return []; + return fs + .readdirSync(d) + .filter((f) => f.endsWith(".json")) + .map((f) => f.replace(/\.json$/, "")); +} + +export function readJson(kind: DirKind, id: string, base: StoragePaths = defaultPaths): T | undefined { + const p = path.join(dirFor(kind, base), `${id}.json`); + if (!fs.existsSync(p)) return undefined; + const raw = fs.readFileSync(p, "utf8"); + return JSON.parse(raw) as T; +} + +export function writeJson(kind: DirKind, id: string, value: unknown, base: StoragePaths = defaultPaths): void { + const p = path.join(dirFor(kind, base), `${id}.json`); + fs.writeFileSync(p, JSON.stringify(value, null, 2) + "\n", "utf8"); +} + +export function deleteJson(kind: DirKind, id: string, base: StoragePaths = defaultPaths): boolean { + const p = path.join(dirFor(kind, base), `${id}.json`); + if (!fs.existsSync(p)) return false; + fs.rmSync(p); + return true; +} diff --git a/apps/cli/src/application/assistant/workflows/service.ts b/apps/cli/src/application/assistant/workflows/service.ts new file mode 100644 index 00000000..d01d0796 --- /dev/null +++ b/apps/cli/src/application/assistant/workflows/service.ts @@ -0,0 +1,44 @@ +import { z } from "zod"; +import { Workflow } from "../../entities/workflow.js"; +import { deleteJson, listJson, readJson, writeJson } from "../services/storage.js"; + +export type WorkflowId = string; + +export function listWorkflows(): WorkflowId[] { + return listJson("workflows"); +} + +export function getWorkflow(id: WorkflowId): z.infer | undefined { + const raw = readJson("workflows", id); + if (!raw) return undefined; + return Workflow.parse(raw); +} + +export function upsertWorkflow( + id: WorkflowId, + value: Partial> +): z.infer { + const existing = readJson("workflows", id) as Partial> | undefined; + const now = new Date().toISOString(); + + const defaults: Partial> = { + name: id, + description: "", + steps: [], + createdAt: existing?.createdAt ?? now, + }; + const merged = { + ...defaults, + ...(existing ?? {}), + ...value, + updatedAt: now, + } satisfies Partial>; + + const parsed = Workflow.parse(merged); + writeJson("workflows", id, parsed); + return parsed; +} + +export function deleteWorkflow(id: WorkflowId): boolean { + return deleteJson("workflows", id); +} diff --git a/apps/cli/src/application/config/config.ts b/apps/cli/src/application/config/config.ts index 1f16f481..2692b300 100644 --- a/apps/cli/src/application/config/config.ts +++ b/apps/cli/src/application/config/config.ts @@ -1,16 +1,29 @@ import path from "path"; import fs from "fs"; +import { fileURLToPath } from "url"; import { McpServerConfig } from "../entities/mcp.js"; import { z } from "zod"; -export const WorkDir = "/Users/ramnique/work/rb/rowboat/apps/cli/.rowboat" +// Resolve app root relative to compiled file location (dist/...) +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const AppRoot = path.resolve(__dirname, "../../.."); +export const WorkDir = path.join(AppRoot, ".rowboat"); +function ensureDirs() { + const ensure = (p: string) => { if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); }; + ensure(WorkDir); + ensure(path.join(WorkDir, "workflows")); + ensure(path.join(WorkDir, "agents")); + ensure(path.join(WorkDir, "mcp")); +} function loadMcpServerConfig(): z.infer { - const configPath = path.join(WorkDir, "config", "mcp.json"); + ensureDirs(); + const configPath = path.join(WorkDir, "mcp", "servers.json"); + if (!fs.existsSync(configPath)) return { mcpServers: [] }; const config = fs.readFileSync(configPath, "utf8"); return McpServerConfig.parse(JSON.parse(config)); } const { mcpServers } = loadMcpServerConfig(); -export const McpServers = mcpServers; \ No newline at end of file +export const McpServers = mcpServers; diff --git a/apps/cli/src/x.ts b/apps/cli/src/x.ts new file mode 100644 index 00000000..cf823710 --- /dev/null +++ b/apps/cli/src/x.ts @@ -0,0 +1,9 @@ +import { ensureBaseDirs } from "./application/assistant/services/storage.js"; +import { startCopilot } from "./application/assistant/chat.js"; + +ensureBaseDirs(); + +startCopilot().catch((err) => { + console.error("Failed to run copilot:", err); + process.exitCode = 1; +});