refactor: extract shared route handlers and modularize plan server (#214)

* refactor: extract shared route handlers and modularize plan server

Pull duplicated /api/image, /api/upload, /api/agents handlers and
handleServerReady into shared-handlers.ts. Move /api/doc and Obsidian
reference endpoints into reference-handlers.ts. Eliminates ~120 lines
of verbatim duplication across index.ts, review.ts, and annotate.ts.

Closes #210

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: deduplicate OpencodeClient type and fix stale module comment

Export OpencodeClient interface from shared-handlers.ts and import it
in index.ts and review.ts instead of redefining inline. Also corrects
the module doc comment about /api/agents usage scope.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michael Ramos
2026-03-03 23:17:16 -08:00
committed by GitHub
parent e5f3cfffbb
commit d9c2d46113
5 changed files with 340 additions and 394 deletions
+4 -54
View File
@@ -11,15 +11,14 @@
* PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote)
*/
import { mkdirSync } from "fs";
import { isRemoteSession, getServerPort } from "./remote";
import { openBrowser } from "./browser";
import { getRepoInfo } from "./repo";
import { validateImagePath, validateUploadExtension, UPLOAD_DIR } from "./image";
import { handleImage, handleUpload, handleServerReady } from "./shared-handlers";
// Re-export utilities
export { isRemoteSession, getServerPort } from "./remote";
export { openBrowser } from "./browser";
export { handleServerReady as handleAnnotateServerReady } from "./shared-handlers";
// --- Types ---
@@ -126,48 +125,12 @@ export async function startAnnotateServer(
// API: Serve images (local paths or temp uploads)
if (url.pathname === "/api/image") {
const imagePath = url.searchParams.get("path");
if (!imagePath) {
return new Response("Missing path parameter", { status: 400 });
}
const validation = validateImagePath(imagePath);
if (!validation.valid) {
return new Response(validation.error!, { status: 403 });
}
try {
const file = Bun.file(validation.resolved);
if (!(await file.exists())) {
return new Response("File not found", { status: 404 });
}
return new Response(file);
} catch {
return new Response("Failed to read file", { status: 500 });
}
return handleImage(req);
}
// API: Upload image -> save to temp -> return path
if (url.pathname === "/api/upload" && req.method === "POST") {
try {
const formData = await req.formData();
const file = formData.get("file") as File;
if (!file) {
return new Response("No file provided", { status: 400 });
}
const extResult = validateUploadExtension(file.name);
if (!extResult.valid) {
return Response.json({ error: extResult.error }, { status: 400 });
}
mkdirSync(UPLOAD_DIR, { recursive: true });
const tempPath = `${UPLOAD_DIR}/${crypto.randomUUID()}.${extResult.ext}`;
await Bun.write(tempPath, file);
return Response.json({ path: tempPath, originalName: file.name });
} catch (err) {
const message =
err instanceof Error ? err.message : "Upload failed";
return Response.json({ error: message }, { status: 500 });
}
return handleUpload(req);
}
// API: Submit annotation feedback
@@ -242,16 +205,3 @@ export async function startAnnotateServer(
stop: () => server.stop(),
};
}
/**
* Default behavior: open browser for local sessions
*/
export async function handleAnnotateServerReady(
url: string,
isRemote: boolean,
_port: number
): Promise<void> {
if (!isRemote) {
await openBrowser(url);
}
}
+12 -267
View File
@@ -9,14 +9,10 @@
* PLANNOTATOR_ORIGIN - Origin identifier ("claude-code" or "opencode")
*/
import { mkdirSync, existsSync, statSync } from "fs";
import { resolve } from "path";
import { isRemoteSession, getServerPort } from "./remote";
import { openBrowser } from "./browser";
import { validateImagePath, validateUploadExtension, UPLOAD_DIR } from "./image";
import { openEditorDiff } from "./ide";
import {
detectObsidianVaults,
saveToObsidian,
saveToBear,
type ObsidianConfig,
@@ -37,12 +33,16 @@ import {
} from "./storage";
import { getRepoInfo } from "./repo";
import { detectProjectName } from "./project";
import { handleImage, handleUpload, handleAgents, handleServerReady, type OpencodeClient } from "./shared-handlers";
import { handleDoc, handleObsidianVaults, handleObsidianFiles, handleObsidianDoc } from "./reference-handlers";
// Re-export utilities
export { isRemoteSession, getServerPort } from "./remote";
export { openBrowser } from "./browser";
export * from "./integrations";
export * from "./storage";
export { handleServerReady } from "./shared-handlers";
export { type VaultNode, buildFileTree } from "./reference-handlers";
// --- Types ---
@@ -64,11 +64,7 @@ export interface ServerOptions {
/** Called when server starts with the URL, remote status, and port */
onReady?: (url: string, isRemote: boolean, port: number) => void;
/** OpenCode client for querying available agents (OpenCode only) */
opencodeClient?: {
app: {
agents: (options?: object) => Promise<{ data?: Array<{ name: string; description?: string; mode: string; hidden?: boolean }> }>;
};
};
opencodeClient?: OpencodeClient;
}
export interface ServerResult {
@@ -203,120 +199,17 @@ export async function startPlannotatorServer(
// API: Serve a linked markdown document
if (url.pathname === "/api/doc" && req.method === "GET") {
const requestedPath = url.searchParams.get("path");
if (!requestedPath) {
return Response.json({ error: "Missing path parameter" }, { status: 400 });
}
const projectRoot = process.cwd();
// Restrict to markdown files only
if (!/\.mdx?$/i.test(requestedPath)) {
return Response.json({ error: "Only .md and .mdx files are supported" }, { status: 400 });
}
// Path resolution: 3 strategies in order
let resolvedPath: string | null = null;
if (requestedPath.startsWith("/")) {
// 1. Absolute path
resolvedPath = requestedPath;
} else {
// 2. Relative to project root
const fromRoot = resolve(projectRoot, requestedPath);
if (await Bun.file(fromRoot).exists()) {
resolvedPath = fromRoot;
}
// 3. Bare filename — search entire project for unique match
if (!resolvedPath && !requestedPath.includes("/")) {
const glob = new Bun.Glob(`**/${requestedPath}`);
const matches: string[] = [];
for await (const match of glob.scan({ cwd: projectRoot, onlyFiles: true })) {
if (match.includes("node_modules/") || match.includes(".git/")) continue;
if (match.split("/").pop() === requestedPath) {
matches.push(resolve(projectRoot, match));
}
}
if (matches.length === 1) {
resolvedPath = matches[0];
} else if (matches.length > 1) {
const relativePaths = matches.map((m) => m.replace(projectRoot + "/", ""));
return Response.json(
{ error: `Ambiguous filename '${requestedPath}': found ${matches.length} matches`, matches: relativePaths },
{ status: 400 }
);
}
}
}
if (!resolvedPath) {
return Response.json({ error: `File not found: ${requestedPath}` }, { status: 404 });
}
// Security: path must stay within projectRoot
const normalised = resolve(resolvedPath);
if (!normalised.startsWith(projectRoot + "/") && normalised !== projectRoot) {
return Response.json({ error: "Access denied: path is outside project root" }, { status: 403 });
}
const file = Bun.file(normalised);
if (!(await file.exists())) {
return Response.json({ error: `File not found: ${requestedPath}` }, { status: 404 });
}
try {
const markdown = await file.text();
return Response.json({ markdown, filepath: normalised });
} catch {
return Response.json({ error: "Failed to read file" }, { status: 500 });
}
return handleDoc(req);
}
// API: Serve images (local paths or temp uploads)
if (url.pathname === "/api/image") {
const imagePath = url.searchParams.get("path");
if (!imagePath) {
return new Response("Missing path parameter", { status: 400 });
}
const validation = validateImagePath(imagePath);
if (!validation.valid) {
return new Response(validation.error!, { status: 403 });
}
try {
const file = Bun.file(validation.resolved);
if (!(await file.exists())) {
return new Response("File not found", { status: 404 });
}
return new Response(file);
} catch {
return new Response("Failed to read file", { status: 500 });
}
return handleImage(req);
}
// API: Upload image -> save to temp -> return path
if (url.pathname === "/api/upload" && req.method === "POST") {
try {
const formData = await req.formData();
const file = formData.get("file") as File;
if (!file) {
return new Response("No file provided", { status: 400 });
}
const extResult = validateUploadExtension(file.name);
if (!extResult.valid) {
return Response.json({ error: extResult.error }, { status: 400 });
}
mkdirSync(UPLOAD_DIR, { recursive: true });
const tempPath = `${UPLOAD_DIR}/${crypto.randomUUID()}.${extResult.ext}`;
await Bun.write(tempPath, file);
return Response.json({ path: tempPath, originalName: file.name });
} catch (err) {
const message =
err instanceof Error ? err.message : "Upload failed";
return Response.json({ error: message }, { status: 500 });
}
return handleUpload(req);
}
// API: Open plan diff in VS Code
@@ -346,104 +239,22 @@ export async function startPlannotatorServer(
// API: Detect Obsidian vaults
if (url.pathname === "/api/obsidian/vaults") {
const vaults = detectObsidianVaults();
return Response.json({ vaults });
return handleObsidianVaults();
}
// API: List Obsidian vault files as a tree
if (url.pathname === "/api/reference/obsidian/files" && req.method === "GET") {
const vaultPath = url.searchParams.get("vaultPath");
if (!vaultPath) {
return Response.json({ error: "Missing vaultPath parameter" }, { status: 400 });
}
const resolvedVault = resolve(vaultPath);
if (!existsSync(resolvedVault) || !statSync(resolvedVault).isDirectory()) {
return Response.json({ error: "Invalid vault path" }, { status: 400 });
}
try {
const glob = new Bun.Glob("**/*.md");
const files: string[] = [];
for await (const match of glob.scan({ cwd: resolvedVault, onlyFiles: true })) {
if (match.includes(".obsidian/") || match.includes(".trash/")) continue;
files.push(match);
}
files.sort();
const tree = buildFileTree(files);
return Response.json({ tree });
} catch {
return Response.json({ error: "Failed to list vault files" }, { status: 500 });
}
return handleObsidianFiles(req);
}
// API: Read an Obsidian vault document
if (url.pathname === "/api/reference/obsidian/doc" && req.method === "GET") {
const vaultPath = url.searchParams.get("vaultPath");
const filePath = url.searchParams.get("path");
if (!vaultPath || !filePath) {
return Response.json({ error: "Missing vaultPath or path parameter" }, { status: 400 });
}
if (!/\.mdx?$/i.test(filePath)) {
return Response.json({ error: "Only markdown files are supported" }, { status: 400 });
}
const resolvedVault = resolve(vaultPath);
let resolvedFile = resolve(resolvedVault, filePath);
// If direct path doesn't exist and it's a bare filename, search the vault
if (!existsSync(resolvedFile) && !filePath.includes("/")) {
const glob = new Bun.Glob(`**/${filePath}`);
const matches: string[] = [];
for await (const match of glob.scan({ cwd: resolvedVault, onlyFiles: true })) {
if (match.includes(".obsidian/") || match.includes(".trash/")) continue;
matches.push(resolve(resolvedVault, match));
}
if (matches.length === 1) {
resolvedFile = matches[0];
} else if (matches.length > 1) {
const relativePaths = matches.map((m) => m.replace(resolvedVault + "/", ""));
return Response.json(
{ error: `Ambiguous filename '${filePath}': found ${matches.length} matches`, matches: relativePaths },
{ status: 400 }
);
}
}
// Security: must be within vault
if (!resolvedFile.startsWith(resolvedVault + "/")) {
return Response.json({ error: "Access denied: path is outside vault" }, { status: 403 });
}
try {
const file = Bun.file(resolvedFile);
if (!(await file.exists())) {
return Response.json({ error: `File not found: ${filePath}` }, { status: 404 });
}
const markdown = await file.text();
return Response.json({ markdown, filepath: resolvedFile });
} catch {
return Response.json({ error: "Failed to read file" }, { status: 500 });
}
return handleObsidianDoc(req);
}
// API: Get available agents (OpenCode only)
if (url.pathname === "/api/agents") {
if (!options.opencodeClient) {
return Response.json({ agents: [] });
}
try {
const result = await options.opencodeClient.app.agents({});
const agents = (result.data ?? [])
.filter((a) => a.mode === "primary" && !a.hidden)
.map((a) => ({ id: a.name, name: a.name, description: a.description }));
return Response.json({ agents });
} catch {
return Response.json({ agents: [], error: "Failed to fetch agents" });
}
return handleAgents(options.opencodeClient);
}
// API: Save to notes (decoupled from approve/deny)
@@ -637,69 +448,3 @@ export async function startPlannotatorServer(
stop: () => server.stop(),
};
}
/**
* Default behavior: open browser for local sessions
*/
export async function handleServerReady(
url: string,
isRemote: boolean,
_port: number
): Promise<void> {
if (!isRemote) {
await openBrowser(url);
}
}
// --- Vault file tree helpers ---
export interface VaultNode {
name: string;
path: string; // relative path within vault
type: "file" | "folder";
children?: VaultNode[];
}
/**
* Build a nested file tree from a sorted list of relative paths.
* Folders are sorted before files at each level.
*/
function buildFileTree(relativePaths: string[]): VaultNode[] {
const root: VaultNode[] = [];
for (const filePath of relativePaths) {
const parts = filePath.split("/");
let current = root;
let pathSoFar = "";
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
pathSoFar = pathSoFar ? `${pathSoFar}/${part}` : part;
const isFile = i === parts.length - 1;
let node = current.find((n) => n.name === part && n.type === (isFile ? "file" : "folder"));
if (!node) {
node = { name: part, path: pathSoFar, type: isFile ? "file" : "folder" };
if (!isFile) node.children = [];
current.push(node);
}
if (!isFile) {
current = node.children!;
}
}
}
// Sort: folders first (alphabetical), then files (alphabetical)
const sortNodes = (nodes: VaultNode[]) => {
nodes.sort((a, b) => {
if (a.type !== b.type) return a.type === "folder" ? -1 : 1;
return a.name.localeCompare(b.name);
});
for (const node of nodes) {
if (node.children) sortNodes(node.children);
}
};
sortNodes(root);
return root;
}
+224
View File
@@ -0,0 +1,224 @@
/**
* Reference/document route handlers for the plan server.
*
* Handles /api/doc, /api/obsidian/vaults, /api/reference/obsidian/files,
* and /api/reference/obsidian/doc. Extracted from index.ts for modularity.
*/
import { existsSync, statSync } from "fs";
import { resolve } from "path";
import { detectObsidianVaults } from "./integrations";
// --- Vault file tree helpers ---
export interface VaultNode {
name: string;
path: string; // relative path within vault
type: "file" | "folder";
children?: VaultNode[];
}
/**
* Build a nested file tree from a sorted list of relative paths.
* Folders are sorted before files at each level.
*/
export function buildFileTree(relativePaths: string[]): VaultNode[] {
const root: VaultNode[] = [];
for (const filePath of relativePaths) {
const parts = filePath.split("/");
let current = root;
let pathSoFar = "";
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
pathSoFar = pathSoFar ? `${pathSoFar}/${part}` : part;
const isFile = i === parts.length - 1;
let node = current.find((n) => n.name === part && n.type === (isFile ? "file" : "folder"));
if (!node) {
node = { name: part, path: pathSoFar, type: isFile ? "file" : "folder" };
if (!isFile) node.children = [];
current.push(node);
}
if (!isFile) {
current = node.children!;
}
}
}
// Sort: folders first (alphabetical), then files (alphabetical)
const sortNodes = (nodes: VaultNode[]) => {
nodes.sort((a, b) => {
if (a.type !== b.type) return a.type === "folder" ? -1 : 1;
return a.name.localeCompare(b.name);
});
for (const node of nodes) {
if (node.children) sortNodes(node.children);
}
};
sortNodes(root);
return root;
}
// --- Route handlers ---
/** Serve a linked markdown document. Resolves absolute, relative, or bare filename paths. */
export async function handleDoc(req: Request): Promise<Response> {
const url = new URL(req.url);
const requestedPath = url.searchParams.get("path");
if (!requestedPath) {
return Response.json({ error: "Missing path parameter" }, { status: 400 });
}
const projectRoot = process.cwd();
// Restrict to markdown files only
if (!/\.mdx?$/i.test(requestedPath)) {
return Response.json({ error: "Only .md and .mdx files are supported" }, { status: 400 });
}
// Path resolution: 3 strategies in order
let resolvedPath: string | null = null;
if (requestedPath.startsWith("/")) {
// 1. Absolute path
resolvedPath = requestedPath;
} else {
// 2. Relative to project root
const fromRoot = resolve(projectRoot, requestedPath);
if (await Bun.file(fromRoot).exists()) {
resolvedPath = fromRoot;
}
// 3. Bare filename — search entire project for unique match
if (!resolvedPath && !requestedPath.includes("/")) {
const glob = new Bun.Glob(`**/${requestedPath}`);
const matches: string[] = [];
for await (const match of glob.scan({ cwd: projectRoot, onlyFiles: true })) {
if (match.includes("node_modules/") || match.includes(".git/")) continue;
if (match.split("/").pop() === requestedPath) {
matches.push(resolve(projectRoot, match));
}
}
if (matches.length === 1) {
resolvedPath = matches[0];
} else if (matches.length > 1) {
const relativePaths = matches.map((m) => m.replace(projectRoot + "/", ""));
return Response.json(
{ error: `Ambiguous filename '${requestedPath}': found ${matches.length} matches`, matches: relativePaths },
{ status: 400 },
);
}
}
}
if (!resolvedPath) {
return Response.json({ error: `File not found: ${requestedPath}` }, { status: 404 });
}
// Security: path must stay within projectRoot
const normalised = resolve(resolvedPath);
if (!normalised.startsWith(projectRoot + "/") && normalised !== projectRoot) {
return Response.json({ error: "Access denied: path is outside project root" }, { status: 403 });
}
const file = Bun.file(normalised);
if (!(await file.exists())) {
return Response.json({ error: `File not found: ${requestedPath}` }, { status: 404 });
}
try {
const markdown = await file.text();
return Response.json({ markdown, filepath: normalised });
} catch {
return Response.json({ error: "Failed to read file" }, { status: 500 });
}
}
/** Detect available Obsidian vaults. */
export function handleObsidianVaults(): Response {
const vaults = detectObsidianVaults();
return Response.json({ vaults });
}
/** List Obsidian vault files as a nested tree. */
export async function handleObsidianFiles(req: Request): Promise<Response> {
const url = new URL(req.url);
const vaultPath = url.searchParams.get("vaultPath");
if (!vaultPath) {
return Response.json({ error: "Missing vaultPath parameter" }, { status: 400 });
}
const resolvedVault = resolve(vaultPath);
if (!existsSync(resolvedVault) || !statSync(resolvedVault).isDirectory()) {
return Response.json({ error: "Invalid vault path" }, { status: 400 });
}
try {
const glob = new Bun.Glob("**/*.md");
const files: string[] = [];
for await (const match of glob.scan({ cwd: resolvedVault, onlyFiles: true })) {
if (match.includes(".obsidian/") || match.includes(".trash/")) continue;
files.push(match);
}
files.sort();
const tree = buildFileTree(files);
return Response.json({ tree });
} catch {
return Response.json({ error: "Failed to list vault files" }, { status: 500 });
}
}
/** Read an Obsidian vault document. Supports direct path and bare filename search. */
export async function handleObsidianDoc(req: Request): Promise<Response> {
const url = new URL(req.url);
const vaultPath = url.searchParams.get("vaultPath");
const filePath = url.searchParams.get("path");
if (!vaultPath || !filePath) {
return Response.json({ error: "Missing vaultPath or path parameter" }, { status: 400 });
}
if (!/\.mdx?$/i.test(filePath)) {
return Response.json({ error: "Only markdown files are supported" }, { status: 400 });
}
const resolvedVault = resolve(vaultPath);
let resolvedFile = resolve(resolvedVault, filePath);
// If direct path doesn't exist and it's a bare filename, search the vault
if (!existsSync(resolvedFile) && !filePath.includes("/")) {
const glob = new Bun.Glob(`**/${filePath}`);
const matches: string[] = [];
for await (const match of glob.scan({ cwd: resolvedVault, onlyFiles: true })) {
if (match.includes(".obsidian/") || match.includes(".trash/")) continue;
matches.push(resolve(resolvedVault, match));
}
if (matches.length === 1) {
resolvedFile = matches[0];
} else if (matches.length > 1) {
const relativePaths = matches.map((m) => m.replace(resolvedVault + "/", ""));
return Response.json(
{ error: `Ambiguous filename '${filePath}': found ${matches.length} matches`, matches: relativePaths },
{ status: 400 },
);
}
}
// Security: must be within vault
if (!resolvedFile.startsWith(resolvedVault + "/")) {
return Response.json({ error: "Access denied: path is outside vault" }, { status: 403 });
}
try {
const file = Bun.file(resolvedFile);
if (!(await file.exists())) {
return Response.json({ error: `File not found: ${filePath}` }, { status: 404 });
}
const markdown = await file.text();
return Response.json({ markdown, filepath: resolvedFile });
} catch {
return Response.json({ error: "Failed to read file" }, { status: 500 });
}
}
+6 -73
View File
@@ -9,17 +9,16 @@
* PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote)
*/
import { mkdirSync } from "fs";
import { isRemoteSession, getServerPort } from "./remote";
import { openBrowser } from "./browser";
import { type DiffType, type GitContext, runGitDiff } from "./git";
import { getRepoInfo } from "./repo";
import { validateImagePath, validateUploadExtension, UPLOAD_DIR } from "./image";
import { handleImage, handleUpload, handleAgents, handleServerReady, type OpencodeClient } from "./shared-handlers";
// Re-export utilities
export { isRemoteSession, getServerPort } from "./remote";
export { openBrowser } from "./browser";
export { type DiffType, type DiffOption, type GitContext } from "./git";
export { handleServerReady as handleReviewServerReady } from "./shared-handlers";
// --- Types ---
@@ -45,11 +44,7 @@ export interface ReviewServerOptions {
/** Called when server starts with the URL, remote status, and port */
onReady?: (url: string, isRemote: boolean, port: number) => void;
/** OpenCode client for querying available agents (OpenCode only) */
opencodeClient?: {
app: {
agents: (options?: object) => Promise<{ data?: Array<{ name: string; description?: string; mode: string; hidden?: boolean }> }>;
};
};
opencodeClient?: OpencodeClient;
}
export interface ReviewServerResult {
@@ -177,66 +172,17 @@ export async function startReviewServer(
// API: Serve images (local paths or temp uploads)
if (url.pathname === "/api/image") {
const imagePath = url.searchParams.get("path");
if (!imagePath) {
return new Response("Missing path parameter", { status: 400 });
}
const validation = validateImagePath(imagePath);
if (!validation.valid) {
return new Response(validation.error!, { status: 403 });
}
try {
const file = Bun.file(validation.resolved);
if (!(await file.exists())) {
return new Response("File not found", { status: 404 });
}
return new Response(file);
} catch {
return new Response("Failed to read file", { status: 500 });
}
return handleImage(req);
}
// API: Upload image -> save to temp -> return path
if (url.pathname === "/api/upload" && req.method === "POST") {
try {
const formData = await req.formData();
const file = formData.get("file") as File;
if (!file) {
return new Response("No file provided", { status: 400 });
}
const extResult = validateUploadExtension(file.name);
if (!extResult.valid) {
return Response.json({ error: extResult.error }, { status: 400 });
}
mkdirSync(UPLOAD_DIR, { recursive: true });
const tempPath = `${UPLOAD_DIR}/${crypto.randomUUID()}.${extResult.ext}`;
await Bun.write(tempPath, file);
return Response.json({ path: tempPath, originalName: file.name });
} catch (err) {
const message =
err instanceof Error ? err.message : "Upload failed";
return Response.json({ error: message }, { status: 500 });
}
return handleUpload(req);
}
// API: Get available agents (OpenCode only)
if (url.pathname === "/api/agents") {
if (!options.opencodeClient) {
return Response.json({ agents: [] });
}
try {
const result = await options.opencodeClient.app.agents({});
const agents = (result.data ?? [])
.filter((a) => a.mode === "primary" && !a.hidden)
.map((a) => ({ id: a.name, name: a.name, description: a.description }));
return Response.json({ agents });
} catch {
return Response.json({ agents: [], error: "Failed to fetch agents" });
}
return handleAgents(options.opencodeClient);
}
// API: Submit review feedback
@@ -307,16 +253,3 @@ export async function startReviewServer(
stop: () => server.stop(),
};
}
/**
* Default behavior: open browser for local sessions
*/
export async function handleReviewServerReady(
url: string,
isRemote: boolean,
_port: number
): Promise<void> {
if (!isRemote) {
await openBrowser(url);
}
}
+94
View File
@@ -0,0 +1,94 @@
/**
* Shared route handlers used by plan, review, and annotate servers.
*
* Eliminates duplication of /api/image, /api/upload, and the server-ready
* handler across all three server files. Also shares /api/agents for plan + review.
*/
import { mkdirSync } from "fs";
import { openBrowser } from "./browser";
import { validateImagePath, validateUploadExtension, UPLOAD_DIR } from "./image";
/** Serve images from local paths or temp uploads. Used by all 3 servers. */
export async function handleImage(req: Request): Promise<Response> {
const url = new URL(req.url);
const imagePath = url.searchParams.get("path");
if (!imagePath) {
return new Response("Missing path parameter", { status: 400 });
}
const validation = validateImagePath(imagePath);
if (!validation.valid) {
return new Response(validation.error!, { status: 403 });
}
try {
const file = Bun.file(validation.resolved);
if (!(await file.exists())) {
return new Response("File not found", { status: 404 });
}
return new Response(file);
} catch {
return new Response("Failed to read file", { status: 500 });
}
}
/** Upload image to temp dir, return path. Used by all 3 servers. */
export async function handleUpload(req: Request): Promise<Response> {
try {
const formData = await req.formData();
const file = formData.get("file") as File;
if (!file) {
return new Response("No file provided", { status: 400 });
}
const extResult = validateUploadExtension(file.name);
if (!extResult.valid) {
return Response.json({ error: extResult.error }, { status: 400 });
}
mkdirSync(UPLOAD_DIR, { recursive: true });
const tempPath = `${UPLOAD_DIR}/${crypto.randomUUID()}.${extResult.ext}`;
await Bun.write(tempPath, file);
return Response.json({ path: tempPath, originalName: file.name });
} catch (err) {
const message = err instanceof Error ? err.message : "Upload failed";
return Response.json({ error: message }, { status: 500 });
}
}
/** OpenCode agent client interface (subset of OpenCode SDK) */
export interface OpencodeClient {
app: {
agents: (options?: object) => Promise<{
data?: Array<{ name: string; description?: string; mode: string; hidden?: boolean }>;
}>;
};
}
/** List available agents. Used by plan + review servers (OpenCode only). */
export async function handleAgents(opencodeClient?: OpencodeClient): Promise<Response> {
if (!opencodeClient) {
return Response.json({ agents: [] });
}
try {
const result = await opencodeClient.app.agents({});
const agents = (result.data ?? [])
.filter((a) => a.mode === "primary" && !a.hidden)
.map((a) => ({ id: a.name, name: a.name, description: a.description }));
return Response.json({ agents });
} catch {
return Response.json({ agents: [], error: "Failed to fetch agents" });
}
}
/** Open browser for local sessions. Used by all 3 servers. */
export async function handleServerReady(
url: string,
isRemote: boolean,
_port: number,
): Promise<void> {
if (!isRemote) {
await openBrowser(url);
}
}