Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down
11 changes: 9 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 4 additions & 0 deletions scripts/copy-migrations.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { cpSync } from 'node:fs'

cpSync('drizzle', 'dist/server/drizzle', { recursive: true })
console.log('Copied drizzle/ → dist/server/drizzle/')
20 changes: 19 additions & 1 deletion src/server/app.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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) => {
Expand All @@ -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
}
154 changes: 154 additions & 0 deletions src/server/cli.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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<string, unknown>
const server = cfg['server'] as Record<string, unknown> | undefined
const p = server?.['port']
return typeof p === 'number' ? p : null
Comment on lines +61 to +64
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

While the surrounding try...catch block prevents a crash, this logic for parsing the configuration file can be made more robust. The current implementation uses type assertions that may not hold true (e.g., if the config file content is null), relying on an exception to be caught. A safer approach is to use optional chaining to access nested properties, which gracefully handles null or undefined values at any point in the chain.

Suggested change
const cfg = JSON.parse(raw) as Record<string, unknown>
const server = cfg['server'] as Record<string, unknown> | undefined
const p = server?.['port']
return typeof p === 'number' ? p : null
const config = JSON.parse(raw);
const server = (config as any)?.server;
const p = (server as any)?.port;
return typeof p === 'number' ? p : null;

} catch {
return null
}
}

async function resolveOpencodeUrl(cliFlag?: string): Promise<string> {
// 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)
})
Loading