Merge remote-tracking branch 'origin/hono' into hono-frontend

This commit is contained in:
tusharmagar 2025-12-18 11:16:17 +05:30
commit 869318e22b
22 changed files with 3976 additions and 151 deletions

View file

@ -1,7 +1,8 @@
#!/usr/bin/env node
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import { app, modelConfig, updateState, importExample, listExamples, exportWorkflow } from '../dist/app.js';
import { app, modelConfig, importExample, listExamples, exportWorkflow } from '../dist/app.js';
import { runTui } from '../dist/tui/index.js';
yargs(hideBin(process.argv))
@ -36,6 +37,20 @@ yargs(hideBin(process.argv))
});
}
)
.command(
"ui",
"Launch the interactive Rowboat dashboard",
(y) => y
.option("server-url", {
type: "string",
description: "Rowboat server base URL",
}),
(argv) => {
runTui({
serverUrl: argv.serverUrl,
});
}
)
.command(
"import",
"Import an example workflow (--example) or custom workflow from file (--file)",

File diff suppressed because it is too large Load diff

View file

@ -21,6 +21,7 @@
"description": "",
"devDependencies": {
"@types/node": "^24.9.1",
"@types/react": "^18.3.12",
"typescript": "^5.9.3"
},
"dependencies": {
@ -29,17 +30,28 @@
"@ai-sdk/openai": "^2.0.53",
"@ai-sdk/openai-compatible": "^1.0.27",
"@ai-sdk/provider": "^2.0.0",
"@google-cloud/local-auth": "^3.0.1",
"@hono/node-server": "^1.19.6",
"@hono/standard-validator": "^0.1.5",
"@modelcontextprotocol/sdk": "^1.20.2",
"@openrouter/ai-sdk-provider": "^1.2.6",
"ai": "^5.0.102",
"awilix": "^12.0.5",
"eventsource-parser": "^1.1.2",
"google-auth-library": "^10.5.0",
"googleapis": "^169.0.0",
"hono": "^4.10.7",
"hono-openapi": "^1.1.1",
"ink": "^5.1.0",
"ink-select-input": "^6.2.0",
"ink-spinner": "^5.0.0",
"ink-text-input": "^6.0.0",
"json-schema-to-zod": "^2.6.1",
"nanoid": "^5.1.6",
"node-html-markdown": "^2.0.0",
"ollama-ai-provider-v2": "^1.5.4",
"react": "^18.3.1",
"yaml": "^2.8.2",
"yargs": "^18.0.0",
"zod": "^4.1.12"
}

View file

@ -29,7 +29,7 @@ export const Agent = z.object({
name: z.string(),
provider: z.string().optional(),
model: z.string().optional(),
description: z.string(),
description: z.string().optional(),
instructions: z.string(),
tools: z.record(z.string(), ToolAttachment).optional(),
});

View file

@ -1,8 +1,12 @@
import { WorkDir } from "../config/config.js";
import fs from "fs/promises";
import { glob } from "node:fs/promises";
import path from "path";
import z from "zod";
import { Agent } from "./agents.js";
import { parse, stringify } from "yaml";
const UpdateAgentSchema = Agent.omit({ name: true });
export interface IAgentsRepo {
list(): Promise<z.infer<typeof Agent>[]>;
@ -13,33 +17,76 @@ export interface IAgentsRepo {
}
export class FSAgentsRepo implements IAgentsRepo {
private readonly agentsDir = path.join(WorkDir, "agents");
async list(): Promise<z.infer<typeof Agent>[]> {
const result: z.infer<typeof Agent>[] = [];
// list all json files in workdir/agents/
const agentsDir = path.join(WorkDir, "agents");
const files = await fs.readdir(agentsDir);
for (const file of files) {
const contents = await fs.readFile(path.join(agentsDir, file), "utf8");
result.push(Agent.parse(JSON.parse(contents)));
// list all md files in workdir/agents/
const matches = await Array.fromAsync(glob("**/*.md", { cwd: this.agentsDir }));
for (const file of matches) {
try {
const agent = await this.parseAgentMd(path.join(this.agentsDir, file));
result.push(agent);
} catch (error) {
console.error(`Error parsing agent ${file}: ${error instanceof Error ? error.message : String(error)}`);
continue;
}
}
return result;
}
private async parseAgentMd(filePath: string): Promise<z.infer<typeof Agent>> {
const raw = await fs.readFile(filePath, "utf8");
// strip the path prefix from the file name
// and the .md extension
const agentName = filePath
.replace(this.agentsDir + "/", "")
.replace(/\.md$/, "");
let agent: z.infer<typeof Agent> = {
name: agentName,
instructions: raw,
};
let content = raw;
// check for frontmatter markers at start
if (raw.startsWith("---")) {
const end = raw.indexOf("\n---", 3);
if (end !== -1) {
const fm = raw.slice(3, end).trim(); // YAML text
content = raw.slice(end + 4).trim(); // body after frontmatter
const yaml = parse(fm);
const parsed = Agent
.omit({ name: true, instructions: true })
.parse(yaml);
agent = {
...agent,
...parsed,
instructions: content,
};
}
}
return agent;
}
async fetch(id: string): Promise<z.infer<typeof Agent>> {
const contents = await fs.readFile(path.join(WorkDir, "agents", `${id}.json`), "utf8");
return Agent.parse(JSON.parse(contents));
return this.parseAgentMd(path.join(this.agentsDir, `${id}.md`));
}
async create(agent: z.infer<typeof Agent>): Promise<void> {
await fs.writeFile(path.join(WorkDir, "agents", `${agent.name}.json`), JSON.stringify(agent, null, 2));
await fs.writeFile(path.join(this.agentsDir, `${agent.name}.md`), agent.instructions);
}
async update(id: string, agent: z.infer<typeof Agent>): Promise<void> {
await fs.writeFile(path.join(WorkDir, "agents", `${id}.json`), JSON.stringify(agent, null, 2));
async update(id: string, agent: z.infer<typeof UpdateAgentSchema>): Promise<void> {
const { instructions, ...rest } = agent;
const contents = `---\n${stringify(rest)}\n---\n${instructions}`;
await fs.writeFile(path.join(this.agentsDir, `${id}.md`), contents);
}
async delete(id: string): Promise<void> {
await fs.unlink(path.join(WorkDir, "agents", `${id}.json`));
await fs.unlink(path.join(this.agentsDir, `${id}.md`));
}
}

View file

@ -21,6 +21,7 @@ import { IBus } from "../application/lib/bus.js";
import { IMessageQueue } from "../application/lib/message-queue.js";
import { IRunsRepo } from "../runs/repo.js";
import { IRunsLock } from "../runs/lock.js";
import { PrefixLogger } from "../shared/prefix-logger.js";
export interface IAgentRuntime {
trigger(runId: string): Promise<void>;
@ -63,6 +64,11 @@ export class AgentRuntime implements IAgentRuntime {
return;
}
try {
await this.bus.publish({
runId,
type: "run-processing-start",
subflow: [],
});
while (true) {
let eventCount = 0;
const run = await this.runsRepo.fetch(runId);
@ -94,6 +100,11 @@ export class AgentRuntime implements IAgentRuntime {
}
} finally {
await this.runsLock.release(runId);
await this.bus.publish({
runId,
type: "run-processing-end",
subflow: [],
});
}
}
}
@ -499,6 +510,8 @@ export async function* streamAgent({
messageQueue: IMessageQueue;
modelConfigRepo: IModelConfigRepo;
}): AsyncGenerator<z.infer<typeof RunEvent>, void, unknown> {
const logger = new PrefixLogger(`run-${runId}-${state.agentName}`);
async function* processEvent(event: z.infer<typeof RunEvent>): AsyncGenerator<z.infer<typeof RunEvent>, void, unknown> {
state.ingest(event);
yield event;
@ -518,61 +531,29 @@ export async function* streamAgent({
// set up provider + model
const provider = await getProvider(agent.provider);
const model = provider.languageModel(agent.model || modelConfig.defaults.model);
let loopCounter = 0;
console.log('here');
async function pendingMsgs() {
const pendingMsgs = [];
}
while (true) {
// console.error(`loop counter: ${loopCounter++}`)
// if last response is from assistant and text, get any pending msgs
const lastMessage = state.messages[state.messages.length - 1];
if (lastMessage
&& lastMessage.role === "assistant"
&& (typeof lastMessage.content === "string"
|| !lastMessage.content.some(part => part.type === "tool-call")
)
) {
let pending = 0;
while(true) {
const msg = await messageQueue.dequeue(runId);
if (!msg) {
break;
}
pending++;
yield *processEvent({
runId,
type: "message",
messageId: msg.messageId,
message: {
role: "user",
content: msg.message,
},
subflow: [],
});
}
// if no msgs found, return
if (!pending) {
return;
}
}
loopCounter++;
let loopLogger = logger.child(`iter-${loopCounter}`);
loopLogger.log('starting loop iteration');
// execute any pending tool calls
for (const toolCallId of Object.keys(state.pendingToolCalls)) {
const toolCall = state.toolCallIdMap[toolCallId];
let _logger = loopLogger.child(`tc-${toolCallId}-${toolCall.toolName}`);
_logger.log('processing');
// if ask-human, skip
if (toolCall.toolName === "ask-human") {
_logger.log('skipping, reason: ask-human');
continue;
}
// if tool has been denied, deny
if (state.deniedToolCallIds[toolCallId]) {
yield *processEvent({
_logger.log('returning denied tool message, reason: tool has been denied');
yield* processEvent({
runId,
messageId: await idGenerator.next(),
type: "message",
@ -587,13 +568,15 @@ export async function* streamAgent({
continue;
}
// if permission is pending on this tool call, allow execution
// if permission is pending on this tool call, skip execution
if (state.pendingToolPermissionRequests[toolCallId]) {
_logger.log('skipping, reason: permission is pending');
continue;
}
// execute approved tool
yield *processEvent({
_logger.log('executing tool');
yield* processEvent({
runId,
type: "tool-invocation",
toolCallId,
@ -611,7 +594,7 @@ export async function* streamAgent({
messageQueue,
modelConfigRepo,
})) {
yield *processEvent({
yield* processEvent({
...event,
subflow: [toolCallId, ...event.subflow],
});
@ -637,7 +620,7 @@ export async function* streamAgent({
result: result,
subflow: [],
});
yield *processEvent({
yield* processEvent({
runId,
messageId: await idGenerator.next(),
type: "message",
@ -647,26 +630,20 @@ export async function* streamAgent({
}
}
// if pending state, exit
// if waiting on user permission or ask-human, exit
if (state.getPendingAskHumans().length || state.getPendingPermissions().length) {
// console.error("pending asks or permissions, exiting (b.)")
loopLogger.log('exiting loop, reason: pending asks or permissions');
return;
}
// if current message state isn't runnable, exit
/*
if (state.messages.length === 0 || state.messages[state.messages.length - 1].role === "assistant") {
// console.error("current message state isn't runnable, exiting (c.)")
return;
}
*/
while(true) {
// get any queued user messages
while (true) {
const msg = await messageQueue.dequeue(runId);
if (!msg) {
break;
}
yield *processEvent({
loopLogger.log('dequeued user message', msg.messageId);
yield* processEvent({
runId,
type: "message",
messageId: msg.messageId,
@ -678,7 +655,20 @@ export async function* streamAgent({
});
}
// if last response is from assistant and text, exit
const lastMessage = state.messages[state.messages.length - 1];
if (lastMessage
&& lastMessage.role === "assistant"
&& (typeof lastMessage.content === "string"
|| !lastMessage.content.some(part => part.type === "tool-call")
)
) {
loopLogger.log('exiting loop, reason: last message is from assistant and text');
return;
}
// run one LLM turn.
loopLogger.log('running llm turn');
// stream agent response and build message
const messageBuilder = new StreamStepMessageBuilder();
for await (const event of streamLlm(
@ -687,8 +677,9 @@ export async function* streamAgent({
agent.instructions,
tools,
)) {
loopLogger.log('got llm-stream-event:', event.type)
messageBuilder.ingest(event);
yield *processEvent({
yield* processEvent({
runId,
type: "llm-stream-event",
event: event,
@ -698,7 +689,7 @@ export async function* streamAgent({
// build and emit final message from agent response
const message = messageBuilder.get();
yield *processEvent({
yield* processEvent({
runId,
messageId: await idGenerator.next(),
type: "message",
@ -712,7 +703,8 @@ export async function* streamAgent({
if (part.type === "tool-call") {
const underlyingTool = agent.tools![part.toolName];
if (underlyingTool.type === "builtin" && underlyingTool.name === "ask-human") {
yield *processEvent({
loopLogger.log('emitting ask-human-request, toolCallId:', part.toolCallId);
yield* processEvent({
runId,
type: "ask-human-request",
toolCallId: part.toolCallId,
@ -723,7 +715,8 @@ export async function* streamAgent({
if (underlyingTool.type === "builtin" && underlyingTool.name === "executeCommand") {
// if command is blocked, then seek permission
if (isBlocked(part.arguments.command)) {
yield *processEvent({
loopLogger.log('emitting tool-permission-request, toolCallId:', part.toolCallId);
yield* processEvent({
runId,
type: "tool-permission-request",
toolCall: part,
@ -732,14 +725,15 @@ export async function* streamAgent({
}
}
if (underlyingTool.type === "agent" && underlyingTool.name) {
yield *processEvent({
loopLogger.log('emitting spawn-subflow, toolCallId:', part.toolCallId);
yield* processEvent({
runId,
type: "spawn-subflow",
agentName: underlyingTool.name,
toolCallId: part.toolCallId,
subflow: [],
});
yield *processEvent({
yield* processEvent({
runId,
messageId: await idGenerator.next(),
type: "message",

View file

@ -9,8 +9,7 @@ import { RunEvent } from "./entities/run-events.js";
import { createInterface, Interface } from "node:readline/promises";
import { ToolCallPart } from "./entities/message.js";
import { Agent } from "./agents/agents.js";
import { McpServerConfig } from "./mcp/mcp.js";
import { McpServerDefinition } from "./mcp/mcp.js";
import { McpServerConfig, McpServerDefinition } from "./mcp/schema.js";
import { Example } from "./entities/example.js";
import { z } from "zod";
import { Flavor } from "./models/models.js";

View file

@ -7,7 +7,7 @@ import { resolveSkill, availableSkills } from "../assistant/skills/index.js";
import { executeTool, listServers, listTools } from "../../mcp/mcp.js";
import container from "../../di/container.js";
import { IMcpConfigRepo } from "../..//mcp/repo.js";
import { McpServerDefinition } from "../../mcp/mcp.js";
import { McpServerDefinition } from "../../mcp/schema.js";
const BuiltinToolsSchema = z.record(z.string(), z.object({
description: z.string(),

View file

@ -13,7 +13,6 @@ export class InMemoryBus implements IBus {
private subscribers: Map<string, ((event: z.infer<typeof RunEvent>) => Promise<void>)[]> = new Map();
async publish(event: z.infer<typeof RunEvent>): Promise<void> {
console.log(this.subscribers);
const pending: Promise<void>[] = [];
for (const subscriber of this.subscribers.get(event.runId) || []) {
pending.push(subscriber(event));
@ -21,7 +20,6 @@ export class InMemoryBus implements IBus {
for (const subscriber of this.subscribers.get('*') || []) {
pending.push(subscriber(event));
}
console.log(pending.length);
await Promise.all(pending);
}
@ -30,7 +28,6 @@ export class InMemoryBus implements IBus {
this.subscribers.set(runId, []);
}
this.subscribers.get(runId)!.push(handler);
console.log(this.subscribers);
return () => {
this.subscribers.get(runId)!.splice(this.subscribers.get(runId)!.indexOf(handler), 1);
};

View file

@ -1,6 +1,6 @@
import z from "zod"
import { Agent } from "../agents/agents.js"
import { McpServerDefinition } from "../mcp/mcp.js";
import { McpServerDefinition } from "../mcp/schema.js";
export const Example = z.object({
id: z.string(),
@ -9,4 +9,4 @@ export const Example = z.object({
entryAgent: z.string().optional(),
agents: z.array(Agent).optional(),
mcpServers: z.record(z.string(), McpServerDefinition).optional(),
});
});

View file

@ -8,6 +8,14 @@ const BaseRunEvent = z.object({
subflow: z.array(z.string()),
});
export const RunProcessingStartEvent = BaseRunEvent.extend({
type: z.literal("run-processing-start"),
});
export const RunProcessingEndEvent = BaseRunEvent.extend({
type: z.literal("run-processing-end"),
});
export const StartEvent = BaseRunEvent.extend({
type: z.literal("start"),
agentName: z.string(),
@ -73,6 +81,8 @@ export const RunErrorEvent = BaseRunEvent.extend({
});
export const RunEvent = z.union([
RunProcessingStartEvent,
RunProcessingEndEvent,
StartEvent,
SpawnSubFlowEvent,
LlmStreamEvent,

View file

@ -0,0 +1,286 @@
import fs from 'fs';
import path from 'path';
import { google } from 'googleapis';
import { authenticate } from '@google-cloud/local-auth';
import { OAuth2Client } from 'google-auth-library';
import { NodeHtmlMarkdown } from 'node-html-markdown'
// Configuration
const CREDENTIALS_PATH = path.join(process.cwd(), 'credentials.json');
const TOKEN_PATH = path.join(process.cwd(), 'token_calendar_notes.json'); // Changed to force re-auth with new scopes
const SYNC_INTERVAL_MS = 60 * 1000;
const SCOPES = [
'https://www.googleapis.com/auth/calendar.readonly',
'https://www.googleapis.com/auth/drive.readonly'
];
const nhm = new NodeHtmlMarkdown();
// --- Auth Functions ---
async function loadSavedCredentialsIfExist(): Promise<OAuth2Client | null> {
try {
if (!fs.existsSync(TOKEN_PATH)) return null;
const tokenContent = fs.readFileSync(TOKEN_PATH, 'utf-8');
const tokenData = JSON.parse(tokenContent);
const credsContent = fs.readFileSync(CREDENTIALS_PATH, 'utf-8');
const keys = JSON.parse(credsContent);
const key = keys.installed || keys.web;
const client = new google.auth.OAuth2(
key.client_id,
key.client_secret,
key.redirect_uris ? key.redirect_uris[0] : 'http://localhost'
);
client.setCredentials({
refresh_token: tokenData.refresh_token || tokenData.refreshToken,
access_token: tokenData.token || tokenData.access_token,
expiry_date: tokenData.expiry || tokenData.expiry_date,
scope: tokenData.scope
});
return client;
} catch (err) {
console.error("Error loading saved credentials:", err);
return null;
}
}
async function saveCredentials(client: OAuth2Client) {
const content = fs.readFileSync(CREDENTIALS_PATH, 'utf-8');
const keys = JSON.parse(content);
const key = keys.installed || keys.web;
const payload = JSON.stringify({
type: 'authorized_user',
client_id: key.client_id,
client_secret: key.client_secret,
refresh_token: client.credentials.refresh_token,
access_token: client.credentials.access_token,
expiry_date: client.credentials.expiry_date,
}, null, 2);
fs.writeFileSync(TOKEN_PATH, payload);
}
async function authorize(): Promise<OAuth2Client> {
let client = await loadSavedCredentialsIfExist();
if (client && client.credentials && client.credentials.expiry_date && client.credentials.expiry_date > Date.now()) {
console.log("Using existing valid token.");
return client;
}
if (client && client.credentials && (!client.credentials.expiry_date || client.credentials.expiry_date <= Date.now()) && client.credentials.refresh_token) {
console.log("Refreshing expired token...");
try {
await client.refreshAccessToken();
await saveCredentials(client);
return client;
} catch (e) {
console.error("Failed to refresh token:", e);
if (fs.existsSync(TOKEN_PATH)) fs.unlinkSync(TOKEN_PATH);
}
}
console.log("Performing new OAuth authentication...");
client = await authenticate({
scopes: SCOPES,
keyfilePath: CREDENTIALS_PATH,
}) as any;
if (client && client.credentials) {
await saveCredentials(client);
}
return client!;
}
// --- Helper Functions ---
function cleanFilename(name: string): string {
return name.replace(/[\\/*?:\"<>|]/g, "").replace(/\s+/g, "_").substring(0, 100).trim();
}
// --- Sync Logic ---
function cleanUpOldFiles(currentEventIds: Set<string>, syncDir: string) {
if (!fs.existsSync(syncDir)) return;
const files = fs.readdirSync(syncDir);
for (const filename of files) {
if (filename === 'sync_state.json') continue;
// We expect files like:
// {eventId}.json
// {eventId}_doc_{docId}.md
let eventId: string | null = null;
if (filename.endsWith('.json')) {
eventId = filename.replace('.json', '');
} else if (filename.endsWith('.md')) {
// Try to extract eventId from prefix
// Assuming eventId doesn't contain underscores usually, but if it does, this split might be fragile.
// Google Calendar IDs are usually alphanumeric.
// Let's rely on the delimiter we use: "_doc_"
const parts = filename.split('_doc_');
if (parts.length > 1) {
eventId = parts[0];
}
}
if (eventId && !currentEventIds.has(eventId)) {
try {
fs.unlinkSync(path.join(syncDir, filename));
console.log(`Removed old/out-of-window file: ${filename}`);
} catch (e) {
console.error(`Error deleting file ${filename}:`, e);
}
}
}
}
async function saveEvent(event: any, syncDir: string): Promise<boolean> {
const eventId = event.id;
if (!eventId) return false;
const filePath = path.join(syncDir, `${eventId}.json`);
try {
fs.writeFileSync(filePath, JSON.stringify(event, null, 2));
return true;
} catch (e) {
console.error(`Error saving event ${eventId}:`, e);
return false;
}
}
async function processAttachments(drive: any, event: any, syncDir: string) {
if (!event.attachments || event.attachments.length === 0) return;
const eventId = event.id;
const eventTitle = event.summary || 'Untitled';
const eventDate = event.start?.dateTime || event.start?.date || 'Unknown';
const organizer = event.organizer?.email || 'Unknown';
for (const att of event.attachments) {
// We only care about Google Docs
if (att.mimeType === 'application/vnd.google-apps.document') {
const fileId = att.fileId;
const safeTitle = cleanFilename(att.title);
// Unique filename linked to event
const filename = `${eventId}_doc_${safeTitle}.md`;
const filePath = path.join(syncDir, filename);
// Simple cache check: if file exists, skip.
// Ideally we check modifiedTime, but that requires an extra API call per file.
// Given the loop interval, we can just check existence to save quota.
// If user updates notes, they might want them re-synced.
// For now, let's just check existence. To be smarter, we'd need a state file or check API.
if (fs.existsSync(filePath)) continue;
try {
const res = await drive.files.export({
fileId: fileId,
mimeType: 'text/html'
});
const html = res.data;
const md = nhm.translate(html);
const frontmatter = [
`# ${att.title}`,
`**Event:** ${eventTitle}`,
`**Date:** ${eventDate}`,
`**Organizer:** ${organizer}`,
`**Link:** ${att.fileUrl}`,
`---`,
``
].join('\n');
fs.writeFileSync(filePath, frontmatter + md);
console.log(`Synced Note: ${att.title} for event ${eventTitle}`);
} catch (e) {
console.error(`Failed to download note ${att.title}:`, e);
}
}
}
}
async function syncCalendarWindow(auth: OAuth2Client, syncDir: string, lookbackDays: number) {
// Calculate window
const now = new Date();
const lookbackMs = lookbackDays * 24 * 60 * 60 * 1000;
const twoWeeksForwardMs = 14 * 24 * 60 * 60 * 1000;
const timeMin = new Date(now.getTime() - lookbackMs).toISOString();
const timeMax = new Date(now.getTime() + twoWeeksForwardMs).toISOString();
console.log(`Syncing calendar from ${timeMin} to ${timeMax} (lookback: ${lookbackDays} days)...`);
const calendar = google.calendar({ version: 'v3', auth });
const drive = google.drive({ version: 'v3', auth });
try {
const res = await calendar.events.list({
calendarId: 'primary',
timeMin: timeMin,
timeMax: timeMax,
singleEvents: true,
orderBy: 'startTime'
});
const events = res.data.items || [];
const currentEventIds = new Set<string>();
if (events.length === 0) {
console.log("No events found in this window.");
} else {
console.log(`Found ${events.length} events.`);
for (const event of events) {
if (event.id) {
await saveEvent(event, syncDir);
await processAttachments(drive, event, syncDir);
currentEventIds.add(event.id);
}
}
}
cleanUpOldFiles(currentEventIds, syncDir);
} catch (error) {
console.error("An error occurred during calendar sync:", error);
}
}
async function main() {
console.log("Starting Google Calendar & Notes Sync (TS)...");
const syncDirArg = process.argv[2];
const lookbackDaysArg = process.argv[3];
const SYNC_DIR = syncDirArg || 'synced_calendar_events';
const LOOKBACK_DAYS = lookbackDaysArg ? parseInt(lookbackDaysArg, 10) : 14;
if (isNaN(LOOKBACK_DAYS) || LOOKBACK_DAYS <= 0) {
console.error("Error: Lookback days must be a positive number.");
process.exit(1);
}
if (!fs.existsSync(SYNC_DIR)) {
fs.mkdirSync(SYNC_DIR, { recursive: true });
}
try {
const auth = await authorize();
console.log("Authorization successful.");
while (true) {
await syncCalendarWindow(auth, SYNC_DIR, LOOKBACK_DAYS);
console.log(`Sleeping for ${SYNC_INTERVAL_MS / 1000} seconds...`);
await new Promise(resolve => setTimeout(resolve, SYNC_INTERVAL_MS));
}
} catch (error) {
console.error("Fatal error in main loop:", error);
}
}
main().catch(console.error);

View file

@ -0,0 +1,368 @@
import fs from 'fs';
import path from 'path';
import { google } from 'googleapis';
import { authenticate } from '@google-cloud/local-auth';
import { NodeHtmlMarkdown } from 'node-html-markdown'
import { OAuth2Client } from 'google-auth-library';
// Configuration
const DEFAULT_SYNC_DIR = 'synced_emails_ts';
const CREDENTIALS_PATH = path.join(process.cwd(), 'credentials.json');
const TOKEN_PATH = path.join(process.cwd(), 'token_api.json'); // Reuse Python's token
const SYNC_INTERVAL_MS = 60 * 1000;
const SCOPES = ['https://www.googleapis.com/auth/gmail.readonly'];
const nhm = new NodeHtmlMarkdown();
// --- Auth Functions ---
async function loadSavedCredentialsIfExist(): Promise<OAuth2Client | null> {
try {
const tokenContent = fs.readFileSync(TOKEN_PATH, 'utf-8');
const tokenData = JSON.parse(tokenContent);
const credsContent = fs.readFileSync(CREDENTIALS_PATH, 'utf-8');
const keys = JSON.parse(credsContent);
const key = keys.installed || keys.web;
// Manually construct credentials for google.auth.fromJSON
const credentials = {
type: 'authorized_user',
client_id: key.client_id,
client_secret: key.client_secret,
refresh_token: tokenData.refresh_token || tokenData.refreshToken, // Handle both cases
access_token: tokenData.token || tokenData.access_token, // Handle both cases
expiry_date: tokenData.expiry || tokenData.expiry_date
};
return google.auth.fromJSON(credentials) as OAuth2Client;
} catch (err) {
console.error("Error loading saved credentials:", err);
return null;
}
}
async function saveCredentials(client: OAuth2Client) {
const content = fs.readFileSync(CREDENTIALS_PATH, 'utf-8');
const keys = JSON.parse(content);
const key = keys.installed || keys.web;
const payload = JSON.stringify({
type: 'authorized_user',
client_id: key.client_id,
client_secret: key.client_secret,
refresh_token: client.credentials.refresh_token,
access_token: client.credentials.access_token,
expiry_date: client.credentials.expiry_date,
}, null, 2);
fs.writeFileSync(TOKEN_PATH, payload);
}
async function authorize(): Promise<OAuth2Client> {
let client = await loadSavedCredentialsIfExist();
if (client && client.credentials && client.credentials.expiry_date && client.credentials.expiry_date > Date.now()) {
console.log("Using existing valid token.");
return client;
}
if (client && client.credentials && (!client.credentials.expiry_date || client.credentials.expiry_date <= Date.now()) && client.credentials.refresh_token) {
console.log("Refreshing expired token...");
try {
await client.refreshAccessToken();
await saveCredentials(client); // Save refreshed token
return client;
} catch (e) {
console.error("Failed to refresh token:", e);
// Fall through to full re-auth if refresh fails
fs.existsSync(TOKEN_PATH) && fs.unlinkSync(TOKEN_PATH);
}
}
console.log("Performing new OAuth authentication...");
client = await authenticate({
scopes: SCOPES,
keyfilePath: CREDENTIALS_PATH,
}) as any;
if (client && client.credentials) {
await saveCredentials(client);
}
return client!;
}
// --- Helper Functions ---
function cleanFilename(name: string): string {
return name.replace(/[\\/*?:":<>|]/g, "").substring(0, 100).trim();
}
function decodeBase64(data: string): string {
return Buffer.from(data, 'base64').toString('utf-8');
}
function getBody(payload: any): string {
let body = "";
if (payload.parts) {
for (const part of payload.parts) {
if (part.mimeType === 'text/plain' && part.body && part.body.data) {
const text = decodeBase64(part.body.data);
// Strip quoted lines
const cleanLines = text.split('\n').filter((line: string) => !line.trim().startsWith('>'));
body += cleanLines.join('\n');
} else if (part.mimeType === 'text/html' && part.body && part.body.data) {
const html = decodeBase64(part.body.data);
let md = nhm.translate(html);
// Simple quote stripping for MD
const cleanLines = md.split('\n').filter((line: string) => !line.trim().startsWith('>'));
body += cleanLines.join('\n');
} else if (part.parts) {
body += getBody(part);
}
}
} else if (payload.body && payload.body.data) {
const data = decodeBase64(payload.body.data);
if (payload.mimeType === 'text/html') {
let md = nhm.translate(data);
body += md.split('\n').filter((line: string) => !line.trim().startsWith('>')).join('\n');
} else {
body += data.split('\n').filter((line: string) => !line.trim().startsWith('>')).join('\n');
}
}
return body;
}
async function saveAttachment(gmail: any, userId: string, msgId: string, part: any, attachmentsDir: string): Promise<string | null> {
const filename = part.filename;
const attId = part.body?.attachmentId;
if (!filename || !attId) return null;
const safeName = `${msgId}_${cleanFilename(filename)}`;
const filePath = path.join(attachmentsDir, safeName);
if (fs.existsSync(filePath)) return safeName;
try {
const res = await gmail.users.messages.attachments.get({
userId,
messageId: msgId,
id: attId
});
const data = res.data.data;
if (data) {
fs.writeFileSync(filePath, Buffer.from(data, 'base64'));
console.log(`Saved attachment: ${safeName}`);
return safeName;
}
} catch (e) {
console.error(`Error saving attachment ${filename}:`, e);
}
return null;
}
// --- Sync Logic ---
async function processThread(auth: OAuth2Client, threadId: string, syncDir: string, attachmentsDir: string) {
const gmail = google.gmail({ version: 'v1', auth });
try {
const res = await gmail.users.threads.get({ userId: 'me', id: threadId });
const thread = res.data;
const messages = thread.messages;
if (!messages || messages.length === 0) return;
// Subject from first message
const firstHeader = messages[0].payload?.headers;
const subject = firstHeader?.find(h => h.name === 'Subject')?.value || '(No Subject)';
let mdContent = `# ${subject}\n\n`;
mdContent += `**Thread ID:** ${threadId}\n`;
mdContent += `**Message Count:** ${messages.length}\n\n---\n\n`;
for (const msg of messages) {
const msgId = msg.id!;
const headers = msg.payload?.headers || [];
const from = headers.find(h => h.name === 'From')?.value || 'Unknown';
const date = headers.find(h => h.name === 'Date')?.value || 'Unknown';
mdContent += `### From: ${from}\n`;
mdContent += `**Date:** ${date}\n\n`;
const body = getBody(msg.payload);
mdContent += `${body}\n\n`;
// Attachments
const parts: any[] = [];
const traverseParts = (pList: any[]) => {
for (const p of pList) {
parts.push(p);
if (p.parts) traverseParts(p.parts);
}
};
if (msg.payload?.parts) traverseParts(msg.payload.parts);
let attachmentsFound = false;
for (const part of parts) {
if (part.filename && part.body?.attachmentId) {
const savedName = await saveAttachment(gmail, 'me', msgId, part, attachmentsDir);
if (savedName) {
if (!attachmentsFound) {
mdContent += "**Attachments:**\n";
attachmentsFound = true;
}
mdContent += `- [${part.filename}](attachments/${savedName})\n`;
}
}
}
mdContent += "\n---\n\n";
}
fs.writeFileSync(path.join(syncDir, `${threadId}.md`), mdContent);
console.log(`Synced Thread: ${subject} (${threadId})`);
} catch (error) {
console.error(`Error processing thread ${threadId}:`, error);
}
}
function loadState(stateFile: string): { historyId?: string } {
if (fs.existsSync(stateFile)) {
return JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
}
return {};
}
function saveState(historyId: string, stateFile: string) {
fs.writeFileSync(stateFile, JSON.stringify({
historyId,
last_sync: new Date().toISOString()
}, null, 2));
}
async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: string, stateFile: string, lookbackDays: number) {
console.log(`Performing full sync of last ${lookbackDays} days...`);
const gmail = google.gmail({ version: 'v1', auth });
const pastDate = new Date();
pastDate.setDate(pastDate.getDate() - lookbackDays);
const dateQuery = pastDate.toISOString().split('T')[0].replace(/-/g, '/');
// Get History ID
const profile = await gmail.users.getProfile({ userId: 'me' });
const currentHistoryId = profile.data.historyId!;
let pageToken: string | undefined;
do {
const res: any = await gmail.users.threads.list({
userId: 'me',
q: `after:${dateQuery}`,
pageToken
});
const threads = res.data.threads;
if (threads) {
for (const thread of threads) {
await processThread(auth, thread.id!, syncDir, attachmentsDir);
}
}
pageToken = res.data.nextPageToken;
} while (pageToken);
saveState(currentHistoryId, stateFile);
console.log("Full sync complete.");
}
async function partialSync(auth: OAuth2Client, startHistoryId: string, syncDir: string, attachmentsDir: string, stateFile: string, lookbackDays: number) {
console.log(`Checking updates since historyId ${startHistoryId}...`);
const gmail = google.gmail({ version: 'v1', auth });
try {
const res = await gmail.users.history.list({
userId: 'me',
startHistoryId,
historyTypes: ['messageAdded']
});
const changes = res.data.history;
if (!changes || changes.length === 0) {
console.log("No new changes.");
const profile = await gmail.users.getProfile({ userId: 'me' });
saveState(profile.data.historyId!, stateFile);
return;
}
console.log(`Found ${changes.length} history records.`);
const threadIds = new Set<string>();
for (const record of changes) {
if (record.messagesAdded) {
for (const item of record.messagesAdded) {
if (item.message?.threadId) {
threadIds.add(item.message.threadId);
}
}
}
}
for (const tid of threadIds) {
await processThread(auth, tid, syncDir, attachmentsDir);
}
const profile = await gmail.users.getProfile({ userId: 'me' });
saveState(profile.data.historyId!, stateFile);
} catch (error: any) {
if (error.response?.status === 404) {
console.log("History ID expired. Falling back to full sync.");
await fullSync(auth, syncDir, attachmentsDir, stateFile, lookbackDays);
} else {
console.error("Error during partial sync:", error);
// If 401, remove token to force re-auth next run
if (error.response?.status === 401 && fs.existsSync(TOKEN_PATH)) {
console.log("401 Unauthorized. Deleting token to force re-authentication.");
fs.unlinkSync(TOKEN_PATH);
}
}
}
}
async function main() {
console.log("Starting Gmail Sync (TS)...");
const syncDirArg = process.argv[2];
const lookbackDaysArg = process.argv[3];
const SYNC_DIR = syncDirArg || DEFAULT_SYNC_DIR;
const LOOKBACK_DAYS = lookbackDaysArg ? parseInt(lookbackDaysArg, 10) : 7; // Default to 7 days
if (isNaN(LOOKBACK_DAYS) || LOOKBACK_DAYS <= 0) {
console.error("Error: Lookback days must be a positive number.");
process.exit(1);
}
const ATTACHMENTS_DIR = path.join(SYNC_DIR, 'attachments');
const STATE_FILE = path.join(SYNC_DIR, 'sync_state.json');
// Ensure directories exist
if (!fs.existsSync(SYNC_DIR)) fs.mkdirSync(SYNC_DIR, { recursive: true });
if (!fs.existsSync(ATTACHMENTS_DIR)) fs.mkdirSync(ATTACHMENTS_DIR, { recursive: true });
try {
const auth = await authorize();
console.log("Authorization successful.");
while (true) {
const state = loadState(STATE_FILE);
if (!state.historyId) {
console.log("No history ID found, starting full sync...");
await fullSync(auth, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS);
} else {
console.log("History ID found, starting partial sync...");
await partialSync(auth, state.historyId, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS);
}
console.log(`Sleeping for ${SYNC_INTERVAL_MS / 1000} seconds...`);
await new Promise(resolve => setTimeout(resolve, SYNC_INTERVAL_MS));
}
} catch (error) {
console.error("Fatal error in main loop:", error);
}
}
main().catch(console.error);

View file

@ -6,63 +6,12 @@ import z from "zod";
import { IMcpConfigRepo } from "./repo.js";
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
export const StdioMcpServerConfig = z.object({
type: z.literal("stdio").optional(),
command: z.string(),
args: z.array(z.string()).optional(),
env: z.record(z.string(), z.string()).optional(),
});
export const HttpMcpServerConfig = z.object({
type: z.literal("http").optional(),
url: z.string(),
headers: z.record(z.string(), z.string()).optional(),
});
export const McpServerDefinition = z.union([StdioMcpServerConfig, HttpMcpServerConfig]);
export const McpServerConfig = z.object({
mcpServers: z.record(z.string(), McpServerDefinition),
});
const connectionState = z.enum(["disconnected", "connected", "error"]);
export const McpServerList = z.object({
mcpServers: z.record(z.string(), z.object({
config: McpServerDefinition,
state: connectionState,
error: z.string().nullable(),
})),
});
/*
inputSchema: {
[x: string]: unknown;
type: "object";
properties?: Record<string, object> | undefined;
required?: string[] | undefined;
};
*/
export const Tool = z.object({
name: z.string(),
description: z.string().optional(),
inputSchema: z.object({
type: z.literal("object"),
properties: z.record(z.string(), z.any()).optional(),
required: z.array(z.string()).optional(),
}),
outputSchema: z.object({
type: z.literal("object"),
properties: z.record(z.string(), z.any()).optional(),
required: z.array(z.string()).optional(),
}).optional(),
})
export const ListToolsResponse = z.object({
tools: z.array(Tool),
nextCursor: z.string().optional(),
});
import {
connectionState,
ListToolsResponse,
McpServerDefinition,
McpServerList,
} from "./schema.js";
type mcpState = {
state: z.infer<typeof connectionState>,
@ -171,4 +120,4 @@ export async function executeTool(serverName: string, toolName: string, input: a
arguments: input,
});
return result;
}
}

View file

@ -1,6 +1,5 @@
import { WorkDir } from "../config/config.js";
import { McpServerConfig } from "./mcp.js";
import { McpServerDefinition } from "./mcp.js";
import { McpServerConfig, McpServerDefinition } from "./schema.js";
import fs from "fs/promises";
import path from "path";
import z from "zod";
@ -42,4 +41,4 @@ export class FSMcpConfigRepo implements IMcpConfigRepo {
delete conf.mcpServers[serverName];
await fs.writeFile(this.configPath, JSON.stringify(conf, null, 2));
}
}
}

View file

@ -0,0 +1,50 @@
import z from "zod";
export const StdioMcpServerConfig = z.object({
type: z.literal("stdio").optional(),
command: z.string(),
args: z.array(z.string()).optional(),
env: z.record(z.string(), z.string()).optional(),
});
export const HttpMcpServerConfig = z.object({
type: z.literal("http").optional(),
url: z.string(),
headers: z.record(z.string(), z.string()).optional(),
});
export const McpServerDefinition = z.union([StdioMcpServerConfig, HttpMcpServerConfig]);
export const McpServerConfig = z.object({
mcpServers: z.record(z.string(), McpServerDefinition),
});
export const connectionState = z.enum(["disconnected", "connected", "error"]);
export const McpServerList = z.object({
mcpServers: z.record(z.string(), z.object({
config: McpServerDefinition,
state: connectionState,
error: z.string().nullable(),
})),
});
export const Tool = z.object({
name: z.string(),
description: z.string().optional(),
inputSchema: z.object({
type: z.literal("object"),
properties: z.record(z.string(), z.any()).optional(),
required: z.array(z.string()).optional(),
}),
outputSchema: z.object({
type: z.literal("object"),
properties: z.record(z.string(), z.any()).optional(),
required: z.array(z.string()).optional(),
}).optional(),
});
export const ListToolsResponse = z.object({
tools: z.array(Tool),
nextCursor: z.string().optional(),
});

View file

@ -4,8 +4,8 @@ import { streamSSE } from 'hono/streaming'
import { describeRoute, validator, resolver, openAPIRouteHandler } from "hono-openapi"
import z from 'zod';
import container from './di/container.js';
import { executeTool, listServers, listTools, ListToolsResponse, McpServerList } from "./mcp/mcp.js";
import { McpServerDefinition } from "./mcp/mcp.js";
import { executeTool, listServers, listTools } from "./mcp/mcp.js";
import { ListToolsResponse, McpServerDefinition, McpServerList } from "./mcp/schema.js";
import { IMcpConfigRepo } from './mcp/repo.js';
import { IModelConfigRepo } from './models/repo.js';
import { ModelConfig, Provider } from "./models/models.js";
@ -14,6 +14,7 @@ import { Agent } from "./agents/agents.js";
import { AskHumanResponsePayload, authorizePermission, createMessage, createRun, replyToHumanInputRequest, Run, stop, ToolPermissionAuthorizePayload } from './runs/runs.js';
import { IRunsRepo, CreateRunOptions, ListRunsResponse } from './runs/repo.js';
import { IBus } from './application/lib/bus.js';
import { cors } from 'hono/cors';
let id = 0;
@ -620,7 +621,6 @@ const routes = new Hono()
unsub = await bus.subscribe('*', async (event) => {
if (aborted) return;
console.log('got ev', event);
await stream.writeSSE({
data: JSON.stringify(event),
event: "message",
@ -638,6 +638,7 @@ const routes = new Hono()
;
const app = new Hono()
.use("/*", cors())
.route("/", routes)
.get(
"/openapi.json",
@ -665,4 +666,4 @@ serve({
// PUT /skills/<id>
// DELETE /skills/<id>
// GET /sse
// GET /sse

View file

@ -0,0 +1,26 @@
// create a PrefixLogger class that wraps console.log with a prefix
// and allows chaining with a parent logger
export class PrefixLogger {
private prefix: string;
private parent: PrefixLogger | null;
constructor(prefix: string, parent: PrefixLogger | null = null) {
this.prefix = prefix;
this.parent = parent;
}
log(...args: any[]) {
const timestamp = new Date().toISOString();
const prefix = '[' + this.prefix + ']';
if (this.parent) {
this.parent.log(prefix, ...args);
} else {
console.log(timestamp, prefix, ...args);
}
}
child(childPrefix: string): PrefixLogger {
return new PrefixLogger(childPrefix, this);
}
}

190
apps/cli/src/tui/api.ts Normal file
View file

@ -0,0 +1,190 @@
import { createParser } from "eventsource-parser";
import { Agent } from "../agents/agents.js";
import { AskHumanResponsePayload, Run, ToolPermissionAuthorizePayload } from "../runs/runs.js";
import { ListRunsResponse } from "../runs/repo.js";
import { ModelConfig } from "../models/models.js";
import { RunEvent } from "../entities/run-events.js";
import z from "zod";
const HealthSchema = z.object({
status: z.literal("ok"),
});
const MessageResponse = z.object({
messageId: z.string(),
});
const SuccessSchema = z.object({
success: z.literal(true),
});
type RunEventType = z.infer<typeof RunEvent>;
export interface RowboatApiOptions {
baseUrl?: string;
}
export class RowboatApi {
readonly baseUrl: string;
constructor({ baseUrl }: RowboatApiOptions = {}) {
this.baseUrl = baseUrl ?? process.env.ROWBOATX_SERVER_URL ?? "http://127.0.0.1:3000";
}
private buildUrl(pathname: string): string {
return new URL(pathname, this.baseUrl).toString();
}
private async request<T>(pathname: string, init?: RequestInit): Promise<T> {
const headers: Record<string, string> = {
Accept: "application/json",
};
if (init?.headers instanceof Headers) {
init.headers.forEach((value, key) => {
headers[key] = value;
});
} else if (Array.isArray(init?.headers)) {
for (const [key, value] of init.headers) {
headers[key] = value;
}
} else if (init?.headers) {
Object.assign(headers, init.headers as Record<string, string>);
}
if (init?.body && !headers["Content-Type"]) {
headers["Content-Type"] = "application/json";
}
const response = await fetch(this.buildUrl(pathname), {
method: "GET",
...init,
headers,
});
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(`Request to ${pathname} failed (${response.status}): ${text || response.statusText}`);
}
if (response.status === 204) {
return undefined as T;
}
const text = await response.text();
if (!text) {
return undefined as T;
}
return JSON.parse(text) as T;
}
async getHealth(): Promise<z.infer<typeof HealthSchema>> {
const payload = await this.request("/health");
return HealthSchema.parse(payload);
}
async getModelConfig(): Promise<z.infer<typeof ModelConfig>> {
const payload = await this.request("/models");
return ModelConfig.parse(payload);
}
async listAgents(): Promise<z.infer<typeof Agent>[]> {
const payload = await this.request("/agents");
return Agent.array().parse(payload);
}
async listRuns(cursor?: string): Promise<z.infer<typeof ListRunsResponse>> {
const searchParams = new URLSearchParams();
if (cursor) {
searchParams.set("cursor", cursor);
}
const payload = await this.request(`/runs${searchParams.size ? `?${searchParams.toString()}` : ""}`);
return ListRunsResponse.parse(payload);
}
async getRun(runId: string): Promise<z.infer<typeof Run>> {
const payload = await this.request(`/runs/${encodeURIComponent(runId)}`);
return Run.parse(payload);
}
async createRun(agentId: string): Promise<z.infer<typeof Run>> {
const payload = await this.request("/runs/new", {
method: "POST",
body: JSON.stringify({ agentId }),
});
return Run.parse(payload);
}
async sendMessage(runId: string, message: string): Promise<z.infer<typeof MessageResponse>> {
const payload = await this.request(`/runs/${encodeURIComponent(runId)}/messages/new`, {
method: "POST",
body: JSON.stringify({ message }),
});
return MessageResponse.parse(payload);
}
async authorizeTool(runId: string, payload: z.infer<typeof ToolPermissionAuthorizePayload>): Promise<void> {
const response = await this.request(`/runs/${encodeURIComponent(runId)}/permissions/authorize`, {
method: "POST",
body: JSON.stringify(payload),
});
SuccessSchema.parse(response);
}
async replyToHuman(runId: string, requestId: string, payload: z.infer<typeof AskHumanResponsePayload>): Promise<void> {
const response = await this.request(`/runs/${encodeURIComponent(runId)}/human-input-requests/${encodeURIComponent(requestId)}/reply`, {
method: "POST",
body: JSON.stringify(payload),
});
SuccessSchema.parse(response);
}
async stopRun(runId: string): Promise<void> {
const response = await this.request(`/runs/${encodeURIComponent(runId)}/stop`, {
method: "POST",
});
SuccessSchema.parse(response);
}
async subscribeToEvents(onEvent: (event: RunEventType) => void, onError?: (error: Error) => void): Promise<() => void> {
const controller = new AbortController();
const response = await fetch(this.buildUrl("/stream"), {
method: "GET",
headers: {
Accept: "text/event-stream",
},
signal: controller.signal,
});
if (!response.ok || !response.body) {
throw new Error(`Failed to subscribe to event stream (${response.status})`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
const parser = createParser((event) => {
if (event.type !== "event" || !event.data) {
return;
}
try {
const parsed = RunEvent.parse(JSON.parse(event.data));
onEvent(parsed);
} catch (error) {
onError?.(error instanceof Error ? error : new Error(String(error)));
}
});
(async () => {
try {
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
parser.feed(decoder.decode(value, { stream: true }));
}
} catch (error) {
if (controller.signal.aborted) {
return;
}
onError?.(error instanceof Error ? error : new Error(String(error)));
}
})();
return () => {
controller.abort();
reader.cancel().catch(() => undefined);
};
}
}

View file

@ -0,0 +1,8 @@
import React from "react";
import { render } from "ink";
import { RowboatTui } from "./ui.js";
export function runTui({ serverUrl }: { serverUrl?: string }) {
const baseUrl = serverUrl ?? process.env.ROWBOATX_SERVER_URL ?? "http://127.0.0.1:3000";
render(<RowboatTui serverUrl={baseUrl} />);
}

1174
apps/cli/src/tui/ui.tsx Normal file

File diff suppressed because it is too large Load diff

View file

@ -11,6 +11,7 @@
"esModuleInterop": true,
"skipLibCheck": true,
"sourceMap": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./src/*"