Files
openchamber/packages/web/server/lib/opencode/routes.js
T

855 lines
32 KiB
JavaScript

import express from 'express';
import { createProjectIdFromPath } from '../projects/project-id.js';
import fs from 'fs';
import path from 'path';
import {
buildDeferredRestartResponse,
} from './config-mutation-response.js';
import { getClaudeCliAuthStatus } from './claude-cli-auth.js';
import { OPENCODE_CONFIG_DIR } from './shared.js';
export const registerOpenCodeRoutes = (app, dependencies) => {
const {
crypto,
getOpenCodeResolutionSnapshot,
getOpenCodeUpgradeCapability,
formatSettingsResponse,
readSettingsFromDisk,
readSettingsFromDiskMigrated,
persistSettings,
sanitizeProjects,
validateDirectoryPath,
resolveProjectDirectory,
getProviderSources,
removeProviderConfig,
upsertProviderConfig,
refreshOpenCodeAfterConfigChange,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
fsPromises = fs.promises,
} = dependencies;
let authLibrary = null;
const pendingMcpAuthContextByState = new Map();
const PENDING_MCP_AUTH_TTL_MS = 30 * 60 * 1000;
const getAuthLibrary = async () => {
if (!authLibrary) {
authLibrary = await import('./auth.js');
}
return authLibrary;
};
const normalizePendingString = (value) => {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
return trimmed || null;
};
const escapeHtml = (value) => String(value)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
// Self-contained page for the OAuth return leg: the system browser has no UI
// session, so it cannot load the SPA behind the auth gate — everything it
// needs ships inline. `openchamber://focus/mcp-auth` raises the desktop app;
// the link stays visible because some browsers only follow custom-protocol
// URLs from a user gesture.
const renderMcpOAuthCallbackPage = ({ title, message, desktopReturn }) => `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)} — OpenChamber</title>
<style>
:root { color-scheme: light dark; }
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: Canvas; color: CanvasText; }
main { max-width: 34rem; padding: 2.5rem 2rem; text-align: center; }
h1 { font-size: 1.25rem; margin: 0 0 0.75rem; }
p { margin: 0; line-height: 1.5; opacity: 0.85; }
a.return { display: inline-block; margin-top: 1.5rem; padding: 0.5rem 1.25rem; border-radius: 0.5rem;
border: 1px solid color-mix(in srgb, CanvasText 25%, transparent); color: inherit; text-decoration: none; }
</style>
</head>
<body>
<main>
<h1>${escapeHtml(title)}</h1>
<p>${escapeHtml(message)}</p>
${desktopReturn ? `<a class="return" href="openchamber://focus/mcp-auth">Return to OpenChamber</a>
<script>window.location.href = 'openchamber://focus/mcp-auth';</script>` : ''}
</main>
</body>
</html>`;
const readOpenCodeCurrentVersion = async () => {
const healthResponse = await fetch(buildOpenCodeUrl('/global/health', ''), {
method: 'GET',
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
});
const health = await healthResponse.json().catch(() => null);
if (!healthResponse.ok) {
return { ok: false, status: healthResponse.status, error: health?.error || healthResponse.statusText };
}
const currentVersion = typeof health?.version === 'string' ? health.version.replace(/^v/, '') : null;
return { ok: true, currentVersion };
};
const parseVersionForComparison = (value) => {
const normalized = String(value || '').replace(/^v/, '').split('+')[0];
const prereleaseIndex = normalized.indexOf('-');
const core = prereleaseIndex >= 0 ? normalized.slice(0, prereleaseIndex) : normalized;
const parts = core.split('.').map((part) => {
const parsed = Number.parseInt(part || '0', 10);
return Number.isFinite(parsed) ? parsed : 0;
});
return { parts, prerelease: prereleaseIndex >= 0 };
};
const compareVersions = (left, right) => {
const a = parseVersionForComparison(left);
const b = parseVersionForComparison(right);
const length = Math.max(a.parts.length, b.parts.length);
for (let index = 0; index < length; index += 1) {
const diff = (a.parts[index] || 0) - (b.parts[index] || 0);
if (diff !== 0) return diff;
}
if (a.prerelease !== b.prerelease) return a.prerelease ? -1 : 1;
return 0;
};
const fetchLatestOpenCodeVersionFromGithub = async () => {
const response = await fetch('https://api.github.com/repos/anomalyco/opencode/releases/latest', {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
throw new Error(`OpenCode releases responded with ${response.status}`);
}
const payload = await response.json();
const tag = typeof payload?.tag_name === 'string' ? payload.tag_name.trim() : '';
return tag.replace(/^v/, '');
};
const fetchLatestOpenCodeVersionFromNpm = async () => {
const response = await fetch('https://registry.npmjs.org/opencode-ai/latest', {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
throw new Error(`OpenCode npm registry responded with ${response.status}`);
}
const payload = await response.json();
return typeof payload?.version === 'string' ? payload.version.trim().replace(/^v/, '') : '';
};
const fetchLatestOpenCodeVersion = async () => {
const results = await Promise.allSettled([
fetchLatestOpenCodeVersionFromNpm(),
fetchLatestOpenCodeVersionFromGithub(),
]);
const versions = results
.filter((result) => result.status === 'fulfilled' && result.value)
.map((result) => result.value);
if (versions.length === 0) {
const failure = results.find((result) => result.status === 'rejected');
throw failure?.reason instanceof Error ? failure.reason : new Error('Failed to resolve latest OpenCode version');
}
return versions.sort((left, right) => compareVersions(right, left))[0];
};
// OpenCode's `/global/upgrade` requires an explicit semver target and rejects
// a bodyless call, so "update to the latest" has to name the version. The
// release lookup is the same one the upgrade-status check already uses to
// decide there is anything to offer.
const resolveOpenCodeUpgradeTarget = async (requestedTarget) => {
if (typeof requestedTarget === 'string' && requestedTarget.trim().length > 0) {
return { resolved: true, target: requestedTarget.trim() };
}
try {
const latest = await fetchLatestOpenCodeVersion();
if (!latest) {
return { resolved: false, reason: 'The latest OpenCode version could not be determined.' };
}
return { resolved: true, target: latest };
} catch (error) {
return {
resolved: false,
reason: error instanceof Error ? error.message : 'The latest OpenCode version could not be determined.',
};
}
};
// OpenCode reports a rejected upgrade as `{ name, data: { message, kind } }`,
// which carries no `error` field. Reading only `error` left the user with the
// bare HTTP status text ("Bad Request") and nothing to act on.
const readOpenCodeUpgradeErrorMessage = (payload, response) => {
const candidates = [payload?.error, payload?.data?.message, payload?.message];
for (const candidate of candidates) {
if (typeof candidate === 'string' && candidate.trim().length > 0) {
return candidate.trim();
}
}
return response.statusText || 'Failed to upgrade OpenCode';
};
const pruneExpiredPendingMcpAuthContexts = () => {
const now = Date.now();
for (const [state, entry] of pendingMcpAuthContextByState.entries()) {
if (!entry || typeof entry.expiresAt !== 'number' || entry.expiresAt <= now) {
pendingMcpAuthContextByState.delete(state);
}
}
};
app.get('/api/config/settings', async (_req, res) => {
try {
const settings = await readSettingsFromDiskMigrated();
res.json(formatSettingsResponse(settings));
} catch (error) {
console.error('Failed to read settings:', error);
res.status(500).json({ error: 'Failed to read settings' });
}
});
app.get('/api/config/opencode-resolution', async (_req, res) => {
try {
const settings = await readSettingsFromDiskMigrated();
const resolution = await getOpenCodeResolutionSnapshot(settings);
res.json(resolution);
} catch (error) {
console.error('Failed to resolve OpenCode binary:', error);
res.status(500).json({ error: 'Failed to resolve OpenCode binary' });
}
});
let openCodeUpgradePromise = null;
app.post('/api/opencode/upgrade', async (req, res) => {
try {
const capability = getOpenCodeUpgradeCapability();
if (!capability.supported) {
return res.status(409).json({
success: false,
code: capability.reason === 'bundled'
? 'OPENCODE_UPGRADE_MANAGED_BY_OPENCHAMBER'
: 'OPENCODE_UPGRADE_UNSUPPORTED',
error: capability.reason === 'bundled'
? 'OpenCode is bundled with OpenChamber Desktop and updates with the app.'
: 'This OpenCode runtime cannot be upgraded by OpenChamber.',
});
}
if (openCodeUpgradePromise) {
return res.status(409).json({
success: false,
code: 'OPENCODE_UPGRADE_IN_PROGRESS',
error: 'An OpenCode upgrade is already in progress.',
});
}
const requestedTarget = req.body?.target;
// The target lookup reaches the network, so it runs inside the operation:
// the in-flight lock is taken synchronously above, and a second click
// cannot slip past while the release version is being resolved.
const upgradeOperation = (async () => {
const targetResolution = await resolveOpenCodeUpgradeTarget(requestedTarget);
if (!targetResolution.resolved) {
return {
status: 502,
body: {
success: false,
code: 'OPENCODE_UPGRADE_TARGET_UNRESOLVED',
error: `Could not determine which OpenCode version to install: ${targetResolution.reason}`,
},
};
}
const response = await fetch(buildOpenCodeUrl('/global/upgrade', ''), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...getOpenCodeAuthHeaders(),
},
body: JSON.stringify({ target: targetResolution.target }),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
return {
status: response.status,
body: {
success: false,
error: readOpenCodeUpgradeErrorMessage(payload, response),
},
};
}
try {
await refreshOpenCodeAfterConfigChange('OpenCode upgrade');
} catch (restartError) {
return {
status: 500,
body: {
success: false,
upgraded: true,
error: restartError instanceof Error
? `OpenCode upgraded, but restart failed: ${restartError.message}`
: 'OpenCode upgraded, but restart failed',
},
};
}
return {
status: 200,
body: { ...(payload ?? { success: true }), restarted: true },
};
})();
openCodeUpgradePromise = upgradeOperation;
try {
const result = await upgradeOperation;
return res.status(result.status).json(result.body);
} finally {
if (openCodeUpgradePromise === upgradeOperation) {
openCodeUpgradePromise = null;
}
}
} catch (error) {
console.error('Failed to upgrade OpenCode:', error);
return res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to upgrade OpenCode',
});
}
});
app.get('/api/opencode/upgrade-status', async (_req, res) => {
try {
const capability = getOpenCodeUpgradeCapability();
if (!capability.supported) {
const current = await readOpenCodeCurrentVersion().catch(() => ({ ok: false, currentVersion: null }));
return res.json({
available: false,
currentVersion: current.ok ? current.currentVersion : null,
latestVersion: null,
upgrade: capability,
});
}
const [healthResponse, latestVersion] = await Promise.all([
fetch(buildOpenCodeUrl('/global/health', ''), {
method: 'GET',
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
}),
fetchLatestOpenCodeVersion(),
]);
const health = await healthResponse.json().catch(() => null);
if (!healthResponse.ok) {
return res.status(healthResponse.status).json({
available: null,
error: health?.error || healthResponse.statusText || 'Failed to read OpenCode version',
});
}
const currentVersion = typeof health?.version === 'string' ? health.version.replace(/^v/, '') : null;
if (!currentVersion || !latestVersion) {
return res.json({ available: null, currentVersion, latestVersion: latestVersion || null });
}
const available = compareVersions(latestVersion, currentVersion) > 0;
return res.json({
available,
currentVersion,
latestVersion,
upgrade: capability,
});
} catch (error) {
return res.status(500).json({
available: null,
error: error instanceof Error ? error.message : 'Failed to check OpenCode upgrade status',
});
}
});
app.get('/api/opencode/health', async (_req, res) => {
try {
const healthResponse = await fetch(buildOpenCodeUrl('/global/health', ''), {
method: 'GET',
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
});
const health = await healthResponse.json().catch(() => null);
if (!healthResponse.ok) {
return res.status(healthResponse.status).json({
healthy: false,
error: health?.error || healthResponse.statusText || 'OpenCode health check failed',
});
}
return res.json({ healthy: health?.healthy === true });
} catch (error) {
return res.status(503).json({
healthy: false,
error: error instanceof Error ? error.message : 'OpenCode health check failed',
});
}
});
app.get('/api/opencode/version', async (_req, res) => {
try {
const healthResponse = await fetch(buildOpenCodeUrl('/global/health', ''), {
method: 'GET',
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
});
const health = await healthResponse.json().catch(() => null);
if (!healthResponse.ok) {
return res.status(healthResponse.status).json({
version: null,
error: health?.error || healthResponse.statusText || 'Failed to read OpenCode version',
});
}
const version = typeof health?.version === 'string' ? health.version.replace(/^v/, '') : null;
return res.json({ version });
} catch (error) {
return res.status(500).json({
version: null,
error: error instanceof Error ? error.message : 'Failed to read OpenCode version',
});
}
});
app.put('/api/config/settings', async (req, res) => {
try {
const updated = await persistSettings(req.body ?? {});
res.json(updated);
} catch (error) {
console.error('[API:PUT /api/config/settings] Failed to save settings:', error);
console.error('[API:PUT /api/config/settings] Error stack:', error.stack);
res.status(500).json({ error: 'Failed to save settings' });
}
});
// The body parser is per-route on this server; without it req.body is
// undefined here, the state read as absent, and the "parked" context was
// silently never stored — the callback then always failed as unknown.
app.post('/api/mcp/auth/pending', express.json({ limit: '16kb' }), async (req, res) => {
try {
pruneExpiredPendingMcpAuthContexts();
const state = normalizePendingString(req.body?.state);
if (!state) {
return res.json({ success: true, context: null });
}
const name = normalizePendingString(req.body?.name);
if (!name) {
return res.status(400).json({ error: 'MCP server name is required' });
}
const entry = {
name,
directory: normalizePendingString(req.body?.directory),
// Which surface started the flow. It belongs here rather than in the
// redirect URI: that URI is written into the server's config once and
// deliberately never rewritten, so anything encoded in it would be
// frozen at whatever runtime authorised first.
origin: normalizePendingString(req.body?.origin),
expiresAt: Date.now() + PENDING_MCP_AUTH_TTL_MS,
};
pendingMcpAuthContextByState.set(state, entry);
return res.json({
success: true,
context: {
name: entry.name,
directory: entry.directory,
origin: entry.origin,
},
});
} catch (error) {
console.error('Failed to store pending MCP auth context:', error);
return res.status(500).json({ error: error.message || 'Failed to store pending MCP auth context' });
}
});
app.get('/api/mcp/auth/pending', async (req, res) => {
try {
pruneExpiredPendingMcpAuthContexts();
const state = normalizePendingString(Array.isArray(req.query?.state) ? req.query.state[0] : req.query?.state);
if (!state) {
return res.json(null);
}
const pendingMcpAuthContext = pendingMcpAuthContextByState.get(state) ?? null;
if (!pendingMcpAuthContext) {
return res.status(404).json({ error: 'No pending MCP auth context' });
}
return res.json(pendingMcpAuthContext);
} catch (error) {
console.error('Failed to read pending MCP auth context:', error);
return res.status(500).json({ error: error.message || 'Failed to read pending MCP auth context' });
}
});
app.delete('/api/mcp/auth/pending', async (req, res) => {
try {
const state = normalizePendingString(Array.isArray(req.query?.state) ? req.query.state[0] : req.query?.state);
if (!state) {
return res.json({ success: true });
}
pendingMcpAuthContextByState.delete(state);
return res.json({ success: true });
} catch (error) {
console.error('Failed to clear pending MCP auth context:', error);
return res.status(500).json({ error: error.message || 'Failed to clear pending MCP auth context' });
}
});
// Browser return leg of the MCP OAuth flow, completed entirely server-side.
//
// The provider redirects the SYSTEM browser here, and that browser has no
// OpenChamber UI session — the SPA route this path used to land on sits
// behind the client-side auth gate, so the user saw a login page instead of
// a finished authorization. No session can be required on this path.
//
// Safe without auth because it acts only on a code+state pair whose `state`
// matches a context parked by an authenticated start call: `state` is the
// OAuth CSRF secret, generated per flow and known only to the initiating
// client and the provider. Without a match the code is NOT forwarded, so an
// unauthenticated caller cannot bind this server's MCP entry to a foreign
// account by fabricating a callback. The endpoint reads nothing and mutates
// nothing else.
app.get('/mcp/oauth/callback', async (req, res) => {
const queryValue = (key) => normalizePendingString(Array.isArray(req.query?.[key]) ? req.query[key][0] : req.query?.[key]);
const state = queryValue('state');
const code = queryValue('code');
const providerError = queryValue('error');
const providerErrorDescription = queryValue('error_description');
pruneExpiredPendingMcpAuthContexts();
const context = state ? pendingMcpAuthContextByState.get(state) ?? null : null;
const startedFromDesktop = context?.origin === 'desktop';
const finish = (status, { title, message }) => {
if (state) pendingMcpAuthContextByState.delete(state);
res.status(status).type('html').send(renderMcpOAuthCallbackPage({
title,
message,
// Browsers only follow custom-protocol links from a user gesture in
// some configurations, so the page both tries the jump and keeps a
// visible link as the fallback.
desktopReturn: startedFromDesktop,
}));
};
if (providerError) {
return finish(400, {
title: 'Authorization Failed',
message: providerErrorDescription || providerError,
});
}
if (!code) {
return finish(400, {
title: 'Authorization Failed',
message: 'The provider did not return an authorization code. Start authorization again from MCP Settings.',
});
}
if (!context?.name) {
return finish(400, {
title: 'Authorization Failed',
message: 'This authorization session has expired or is unknown to the running app. Return to OpenChamber and click Authorize again.',
});
}
try {
const callbackUrl = new URL(buildOpenCodeUrl(`/mcp/${encodeURIComponent(context.name)}/auth/callback`, ''));
if (context.directory) callbackUrl.searchParams.set('directory', context.directory);
const upstream = await fetch(callbackUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...getOpenCodeAuthHeaders() },
body: JSON.stringify({ code }),
});
if (!upstream.ok) {
const payload = await upstream.json().catch(() => null);
return finish(502, {
title: 'Authorization Failed',
message: payload?.error || payload?.message || `OpenCode rejected the authorization code (${upstream.status}). Start authorization again from MCP Settings.`,
});
}
return finish(200, {
title: 'Authorization Complete',
message: 'You can close this tab and return to OpenChamber.',
});
} catch (error) {
return finish(502, {
title: 'Authorization Failed',
message: error?.message || 'Failed to complete MCP authorization.',
});
}
});
app.get('/api/provider/:providerId/source', async (req, res) => {
try {
const { providerId } = req.params;
if (!providerId) {
return res.status(400).json({ error: 'Provider ID is required' });
}
const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
const queryDirectory = Array.isArray(req.query?.directory)
? req.query.directory[0]
: req.query?.directory;
const requestedDirectory = headerDirectory || queryDirectory || null;
let directory = null;
const resolved = await resolveProjectDirectory(req);
if (resolved.directory) {
directory = resolved.directory;
} else if (requestedDirectory) {
return res.status(400).json({ error: resolved.error });
}
const sources = getProviderSources(providerId, directory);
const { getProviderAuth } = await getAuthLibrary();
const auth = getProviderAuth(providerId);
sources.sources.auth.exists = providerId === 'claude-code'
? getClaudeCliAuthStatus().connected
: Boolean(auth);
return res.json({
providerId,
sources: sources.sources,
});
} catch (error) {
console.error('Failed to get provider sources:', error);
return res.status(500).json({ error: error.message || 'Failed to get provider sources' });
}
});
app.put('/api/provider', async (req, res) => {
try {
const providerID = typeof req.body?.providerID === 'string'
? req.body.providerID.trim()
: (typeof req.body?.providerId === 'string' ? req.body.providerId.trim() : '');
const config = req.body?.config;
const scope = typeof req.body?.scope === 'string' ? req.body.scope : 'user';
if (!providerID) {
return res.status(400).json({ error: 'Provider ID is required' });
}
if (!config || typeof config !== 'object' || Array.isArray(config)) {
return res.status(400).json({ error: 'Provider config is required' });
}
if (scope !== 'user' && scope !== 'project' && scope !== 'custom') {
return res.status(400).json({ error: 'Invalid scope' });
}
const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
const queryDirectory = Array.isArray(req.query?.directory)
? req.query.directory[0]
: req.query?.directory;
const requestedDirectory = headerDirectory || queryDirectory || null;
let directory = null;
if (scope === 'project' || requestedDirectory) {
const resolved = await resolveProjectDirectory(req);
if (!resolved.directory) {
return res.status(400).json({ error: resolved.error || 'Working directory is required' });
}
directory = resolved.directory;
} else {
const resolved = await resolveProjectDirectory(req);
if (resolved.directory) {
directory = resolved.directory;
}
}
const { getProviderAuth } = await getAuthLibrary();
const hasStoredAuth = Boolean(getProviderAuth(providerID));
const upsertResult = upsertProviderConfig(providerID, config, directory, scope, { hasStoredAuth });
return res.json({
...buildDeferredRestartResponse(
`Provider ${providerID} saved. Restart OpenCode to apply.`,
),
providerId: upsertResult.providerId,
path: upsertResult.path,
config: upsertResult.config,
});
} catch (error) {
const status = typeof error?.statusCode === 'number' ? error.statusCode : 500;
console.error('Failed to upsert provider config:', error);
return res.status(status).json({ error: error.message || 'Failed to save provider config' });
}
});
app.delete('/api/provider/:providerId/auth', async (req, res) => {
try {
const { providerId } = req.params;
if (!providerId) {
return res.status(400).json({ error: 'Provider ID is required' });
}
const scope = typeof req.query?.scope === 'string' ? req.query.scope : 'auth';
const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
const queryDirectory = Array.isArray(req.query?.directory)
? req.query.directory[0]
: req.query?.directory;
const requestedDirectory = headerDirectory || queryDirectory || null;
let directory = null;
if (scope === 'project' || requestedDirectory) {
const resolved = await resolveProjectDirectory(req);
if (!resolved.directory) {
return res.status(400).json({ error: resolved.error });
}
directory = resolved.directory;
} else {
const resolved = await resolveProjectDirectory(req);
if (resolved.directory) {
directory = resolved.directory;
}
}
let removed = false;
if (scope === 'auth') {
const { removeProviderAuth } = await getAuthLibrary();
removed = removeProviderAuth(providerId);
} else if (scope === 'user' || scope === 'project' || scope === 'custom') {
removed = removeProviderConfig(providerId, directory, scope);
} else if (scope === 'all') {
const { removeProviderAuth } = await getAuthLibrary();
const authRemoved = removeProviderAuth(providerId);
const userRemoved = removeProviderConfig(providerId, directory, 'user');
const projectRemoved = directory ? removeProviderConfig(providerId, directory, 'project') : false;
const customRemoved = removeProviderConfig(providerId, directory, 'custom');
removed = authRemoved || userRemoved || projectRemoved || customRemoved;
} else {
return res.status(400).json({ error: 'Invalid scope' });
}
if (removed) {
return res.json({
success: true,
removed,
...buildDeferredRestartResponse('Provider disconnected successfully. Restart OpenCode to apply.'),
});
}
return res.json({
success: true,
removed,
requiresReload: false,
message: 'Provider was not connected',
});
} catch (error) {
console.error('Failed to disconnect provider:', error);
return res.status(500).json({ error: error.message || 'Failed to disconnect provider' });
}
});
app.post('/api/opencode/directory', async (req, res) => {
try {
const requestedPath = typeof req.body?.path === 'string' ? req.body.path.trim() : '';
if (!requestedPath) {
return res.status(400).json({ error: 'Path is required' });
}
if (req.body?.create === true) {
await fsPromises.mkdir(path.resolve(requestedPath), { recursive: true });
}
const validated = await validateDirectoryPath(requestedPath);
if (!validated.ok) {
return res.status(400).json({ error: validated.error });
}
const resolvedPath = validated.directory;
const currentSettings = await readSettingsFromDisk();
const existingProjects = sanitizeProjects(currentSettings.projects) || [];
const existing = existingProjects.find((project) => project.path === resolvedPath) || null;
const nextProjects = existing
? existingProjects
: [
...existingProjects,
{
id: createProjectIdFromPath(resolvedPath),
path: resolvedPath,
addedAt: Date.now(),
lastOpenedAt: Date.now(),
},
];
const activeProjectId = existing ? existing.id : nextProjects[nextProjects.length - 1].id;
const updated = await persistSettings({
projects: nextProjects,
activeProjectId,
lastDirectory: resolvedPath,
});
return res.json({
success: true,
restarted: false,
path: resolvedPath,
settings: updated,
});
} catch (error) {
console.error('Failed to update OpenCode working directory:', error);
return res.status(500).json({ error: error.message || 'Failed to update working directory' });
}
});
// Behavior / Global AGENTS.md endpoints
const AGENTS_MD_PATH = path.join(OPENCODE_CONFIG_DIR, 'AGENTS.md');
const MAX_BEHAVIOR_PROMPT_SIZE = 1024 * 1024; // 1 MB
app.get('/api/behavior/agents-md', async (_req, res) => {
try {
try {
await fs.promises.access(AGENTS_MD_PATH);
} catch {
return res.json({ content: '', exists: false, path: AGENTS_MD_PATH });
}
const content = await fs.promises.readFile(AGENTS_MD_PATH, 'utf8');
return res.json({ content, exists: true, path: AGENTS_MD_PATH });
} catch (error) {
console.error('Failed to read AGENTS.md:', error);
return res.status(500).json({ error: 'Failed to read AGENTS.md' });
}
});
app.put('/api/behavior/agents-md', async (req, res) => {
try {
const content = typeof req.body?.content === 'string' ? req.body.content : '';
if (content.length > MAX_BEHAVIOR_PROMPT_SIZE) {
return res.status(413).json({ error: `Content exceeds maximum size of ${MAX_BEHAVIOR_PROMPT_SIZE} bytes` });
}
// Ensure parent directory exists
const parentDir = path.dirname(AGENTS_MD_PATH);
try {
await fs.promises.access(parentDir);
} catch {
await fs.promises.mkdir(parentDir, { recursive: true });
}
await fs.promises.writeFile(AGENTS_MD_PATH, content, 'utf8');
return res.json(buildDeferredRestartResponse(
'AGENTS.md saved. Restart OpenCode to apply.',
));
} catch (error) {
console.error('Failed to write AGENTS.md:', error);
return res.status(500).json({ error: error.message || 'Failed to write AGENTS.md' });
}
});
};