From b752eea310d3e46fc9c7197abd7a5f9e6be2ef52 Mon Sep 17 00:00:00 2001 From: StatPan Date: Thu, 9 Apr 2026 20:37:03 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20npx=20agentree=20=E2=80=94=20CLI=20entr?= =?UTF-8?q?y=20point=20with=20opencode=20auto-detection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 26 ++++++ package.json | 11 ++- scripts/copy-migrations.mjs | 4 + src/server/app.ts | 20 ++++- src/server/cli.ts | 154 ++++++++++++++++++++++++++++++++++++ 5 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 scripts/copy-migrations.mjs create mode 100644 src/server/cli.ts diff --git a/README.md b/README.md index edb385c..dc53a65 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,32 @@ Agentree sits on top of opencode and adds a visual layer: see all your running s --- +## Quick start + +If you already have [opencode](https://opencode.ai) running: + +```bash +npx agentree +``` + +Agentree auto-detects opencode on `localhost:6543` or `localhost:4096`. Open http://localhost:3001 in your browser. + +**Options** + +| Flag | Default | Description | +|------|---------|-------------| +| `--port`, `-p` | `3001` | Port for Agentree | +| `--opencode-url` | auto-detect | opencode server URL | + +```bash +npx agentree --port 8080 --opencode-url http://localhost:6543 +``` + +**DB location:** `~/.agentree/agentree.db` +Override with `DB_PATH=./agentree.db npx agentree`. + +--- + ## How it works ``` diff --git a/package.json b/package.json index 0567e02..ddd581b 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,20 @@ { "name": "agentree", "version": "0.1.0", - "private": true, "type": "module", + "bin": { + "agentree": "./dist/server/cli.js" + }, + "files": [ + "dist/server/", + "dist/client/" + ], "scripts": { "dev": "concurrently \"pnpm run dev:client\" \"pnpm run dev:server\"", "dev:client": "vite", "dev:server": "dotenv -e .env.opencode -- tsx watch src/server/index.ts", - "build": "tsc -p tsconfig.json && vite build", + "build": "tsc -p tsconfig.json && vite build && node scripts/copy-migrations.mjs", + "prepublishOnly": "pnpm run build", "preview": "vite preview", "test": "vitest run", "test:opencode": "dotenv -e .env.opencode -- env AGENTREE_OPENCODE_INTEGRATION=1 AGENTREE_OPENCODE_LLM=1 vitest run --config vitest.opencode.config.ts", diff --git a/scripts/copy-migrations.mjs b/scripts/copy-migrations.mjs new file mode 100644 index 0000000..c046685 --- /dev/null +++ b/scripts/copy-migrations.mjs @@ -0,0 +1,4 @@ +import { cpSync } from 'node:fs' + +cpSync('drizzle', 'dist/server/drizzle', { recursive: true }) +console.log('Copied drizzle/ → dist/server/drizzle/') diff --git a/src/server/app.ts b/src/server/app.ts index 1708c1d..0eabdb5 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -1,5 +1,8 @@ import { Hono } from 'hono' import { cors } from 'hono/cors' +import { serveStatic } from '@hono/node-server/serve-static' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' import { treeRouter } from './routes/tree.js' import { sessionRouter } from './routes/session.js' import { canvasRouter } from './routes/canvas.js' @@ -9,7 +12,11 @@ import { relationRouter } from './routes/relation.js' import { projectRouter } from './routes/project.js' import { sseHandler, isOpencodeConnected } from './sse/broadcaster.js' -export function createApp() { +type AppOptions = { + staticDir?: string +} + +export function createApp(options: AppOptions = {}) { const app = new Hono() app.onError((err, c) => { @@ -36,5 +43,16 @@ export function createApp() { app.route('/', relationRouter) app.route('/', projectRouter) + // Static file serving for production CLI mode — must come after all /api routes + if (options.staticDir) { + app.use('*', serveStatic({ root: options.staticDir })) + // SPA fallback: serve index.html for any unmatched non-API path + app.use('*', async (c) => { + if (c.req.path.startsWith('/api')) return c.notFound() + const html = await readFile(join(options.staticDir!, 'index.html'), 'utf-8') + return c.html(html) + }) + } + return app } diff --git a/src/server/cli.ts b/src/server/cli.ts new file mode 100644 index 0000000..64fe5bf --- /dev/null +++ b/src/server/cli.ts @@ -0,0 +1,154 @@ +#!/usr/bin/env node +import { parseArgs } from 'node:util' +import { homedir } from 'node:os' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { mkdirSync, readFileSync, existsSync } from 'node:fs' +import { serve } from '@hono/node-server' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + +// ─── CLI args ───────────────────────────────────────────────────────────────── + +const { values } = parseArgs({ + args: process.argv.slice(2), + options: { + port: { type: 'string', short: 'p', default: '3001' }, + 'opencode-url': { type: 'string' }, + help: { type: 'boolean', short: 'h', default: false }, + }, + allowPositionals: false, +}) + +if (values.help) { + console.log(` + agentree [options] + + --port, -p Port to listen on (default: 3001) + --opencode-url opencode server URL (default: auto-detect) + --help, -h Show this help message + + Environment variables: + OPENCODE_API_URL opencode server URL (overrides auto-detect) + OPENCODE_SERVER_USERNAME / OPENCODE_SERVER_PASSWORD Basic auth credentials + DB_PATH SQLite database path (default: ~/.agentree/agentree.db) + PORT Port (overrides --port) + `) + process.exit(0) +} + +const port = Number(process.env.PORT ?? values.port ?? '3001') + +// ─── opencode detection ─────────────────────────────────────────────────────── + +async function healthCheck(url: string): Promise { + try { + const res = await fetch(`${url}/global/health`, { + signal: AbortSignal.timeout(2000), + }) + return res.ok + } catch { + return false + } +} + +function readOpencodeConfigPort(): number | null { + try { + const configPath = join(homedir(), '.config', 'opencode', 'opencode.json') + if (!existsSync(configPath)) return null + const raw = readFileSync(configPath, 'utf-8') + const cfg = JSON.parse(raw) as Record + const server = cfg['server'] as Record | undefined + const p = server?.['port'] + return typeof p === 'number' ? p : null + } catch { + return null + } +} + +async function resolveOpencodeUrl(cliFlag?: string): Promise { + // 1. Explicit CLI flag + if (cliFlag) return cliFlag + + // 2. Environment variable + if (process.env.OPENCODE_API_URL) return process.env.OPENCODE_API_URL + + // 3. Health check common ports + const candidates = ['http://localhost:6543', 'http://localhost:4096'] + for (const url of candidates) { + if (await healthCheck(url)) return url + } + + // 4. opencode config file + const configPort = readOpencodeConfigPort() + if (configPort) { + const url = `http://localhost:${configPort}` + if (await healthCheck(url)) return url + } + + // Not found + console.error(` + Could not find a running opencode instance. + Agentree requires opencode to be running. + + Start opencode, then re-run: + npx agentree + + Or point to a running instance: + npx agentree --opencode-url http://localhost:6543 + + See https://opencode.ai for installation instructions. +`) + process.exit(1) +} + +// ─── DB path ────────────────────────────────────────────────────────────────── + +function resolveDbPath(): string { + if (process.env.DB_PATH) return process.env.DB_PATH + const dir = join(homedir(), '.agentree') + mkdirSync(dir, { recursive: true }) + return join(dir, 'agentree.db') +} + +// ─── Main ───────────────────────────────────────────────────────────────────── + +async function main() { + const opencodeUrl = await resolveOpencodeUrl(values['opencode-url']) + const dbPath = resolveDbPath() + + // Set env vars before importing any module that reads them at load time + process.env.OPENCODE_API_URL = opencodeUrl + process.env.DB_PATH = dbPath + process.env.CORS_ORIGIN = `http://localhost:${port}` + + // Dynamic imports — must happen AFTER env vars are set + const { db } = await import('./db/index.js') + const { migrate } = await import('drizzle-orm/better-sqlite3/migrator') + const { createApp } = await import('./app.js') + const { startOpencodeListener } = await import('./sse/broadcaster.js') + + const migrationsFolder = join(__dirname, 'drizzle') + migrate(db, { migrationsFolder }) + + const staticDir = join(__dirname, '..', 'client') + const app = createApp({ staticDir }) + + serve({ fetch: app.fetch, port }, () => { + console.log(` + Agentree is running + + Local: http://localhost:${port} + opencode: ${opencodeUrl} + + Open http://localhost:${port} in your browser. +`) + startOpencodeListener().catch(console.error) + }) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +})