* feat(shortcuts): make 'Open model selector' shortcut customizable Lets users remap the model selector shortcut (e.g. to Ctrl+M) via Settings > OpenChamber > Shortcuts, matching OpenCode's quick model-switch keybinding workflow. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * fix(agents): surface manual-restart needed when on external OpenCode Agent prompt/permission/settings edits are written to disk, but an external OpenCode server (skip-start or auto-detected on the default port) is not owned by OpenChamber and is only health-probed on config change, so it keeps serving its startup-cached config until restarted. The API previously claimed a successful reload, so the UI silently reverted the edit to the stale/default value on refresh. Now refreshOpenCodeAfterConfigChange reports whether a real reload happened; agent routes return requiresManualRestart for external mode; and the agents UI keeps the saved values and warns the user to restart their OpenCode server instead of showing a false success. Managed mode behavior is unchanged (process is restarted and reload is live). Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> --------- Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
505 lines
18 KiB
JavaScript
505 lines
18 KiB
JavaScript
export const registerConfigEntityRoutes = (app, dependencies) => {
|
|
const {
|
|
resolveProjectDirectory,
|
|
resolveOptionalProjectDirectory,
|
|
refreshOpenCodeAfterConfigChange,
|
|
clientReloadDelayMs,
|
|
getAgentSources,
|
|
getAgentConfig,
|
|
createAgent,
|
|
updateAgent,
|
|
deleteAgent,
|
|
getCommandSources,
|
|
createCommand,
|
|
updateCommand,
|
|
deleteCommand,
|
|
listMcpConfigs,
|
|
getMcpConfig,
|
|
createMcpConfig,
|
|
updateMcpConfig,
|
|
deleteMcpConfig,
|
|
listSnippets,
|
|
getSnippet,
|
|
createSnippet,
|
|
updateSnippet,
|
|
deleteSnippet,
|
|
expandSnippets,
|
|
} = dependencies;
|
|
|
|
// Build the response for a config mutation based on whether OpenCode actually
|
|
// reloaded the change. When connected to an external OpenCode server that
|
|
// OpenChamber cannot restart, the change is persisted to disk but the running
|
|
// server will not serve it until the user restarts that server. We must not
|
|
// report a clean "reloading" success in that case, otherwise the UI silently
|
|
// reverts the edit to the stale value on the next refresh.
|
|
const buildConfigMutationResponse = (refreshResult, { liveMessage, manualRestartMessage }) => {
|
|
if (refreshResult && refreshResult.external) {
|
|
return {
|
|
success: true,
|
|
requiresReload: false,
|
|
requiresManualRestart: true,
|
|
message: manualRestartMessage,
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
requiresReload: true,
|
|
message: liveMessage,
|
|
reloadDelayMs: clientReloadDelayMs,
|
|
};
|
|
};
|
|
|
|
const completeMcpMutation = async (res, action, name, applyChange) => {
|
|
applyChange();
|
|
|
|
try {
|
|
await refreshOpenCodeAfterConfigChange(`mcp ${action}`);
|
|
return res.json({
|
|
success: true,
|
|
requiresReload: true,
|
|
message: `MCP server "${name}" ${action}d. Reloading interface…`,
|
|
reloadDelayMs: clientReloadDelayMs,
|
|
});
|
|
} catch (error) {
|
|
console.error(`[API:MCP ${action}] Reload failed after config write:`, error);
|
|
return res.json({
|
|
success: true,
|
|
requiresReload: false,
|
|
reloadFailed: true,
|
|
message: `MCP server "${name}" ${action}d, but OpenCode reload failed.`,
|
|
warning: error.message || 'OpenCode reload failed after the MCP configuration changed',
|
|
});
|
|
}
|
|
};
|
|
|
|
app.get('/api/config/agents/:name', async (req, res) => {
|
|
try {
|
|
const agentName = req.params.name;
|
|
const { directory, error } = await resolveProjectDirectory(req);
|
|
if (!directory) {
|
|
return res.status(400).json({ error });
|
|
}
|
|
const sources = getAgentSources(agentName, directory);
|
|
|
|
const scope = sources.md.exists
|
|
? sources.md.scope
|
|
: (sources.json.exists ? sources.json.scope : null);
|
|
|
|
res.json({
|
|
name: agentName,
|
|
sources: sources,
|
|
scope,
|
|
isBuiltIn: !sources.md.exists && !sources.json.exists
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to get agent sources:', error);
|
|
res.status(500).json({ error: 'Failed to get agent configuration metadata' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/config/agents/:name/config', async (req, res) => {
|
|
try {
|
|
const agentName = req.params.name;
|
|
const { directory, error } = await resolveProjectDirectory(req);
|
|
if (!directory) {
|
|
return res.status(400).json({ error });
|
|
}
|
|
|
|
const configInfo = getAgentConfig(agentName, directory);
|
|
res.json(configInfo);
|
|
} catch (error) {
|
|
console.error('Failed to get agent config:', error);
|
|
res.status(500).json({ error: 'Failed to get agent configuration' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/config/agents/:name', async (req, res) => {
|
|
try {
|
|
const agentName = req.params.name;
|
|
const { scope, ...config } = req.body;
|
|
const { directory, error } = await resolveProjectDirectory(req);
|
|
if (!directory) {
|
|
return res.status(400).json({ error });
|
|
}
|
|
|
|
console.log('[Server] Creating agent:', agentName);
|
|
console.log('[Server] Config received:', JSON.stringify(config, null, 2));
|
|
console.log('[Server] Scope:', scope, 'Working directory:', directory);
|
|
|
|
createAgent(agentName, config, directory, scope);
|
|
const refreshResult = await refreshOpenCodeAfterConfigChange('agent creation', {
|
|
agentName
|
|
});
|
|
|
|
res.json(buildConfigMutationResponse(refreshResult, {
|
|
liveMessage: `Agent ${agentName} created successfully. Reloading interface…`,
|
|
manualRestartMessage: `Agent ${agentName} saved. Restart your connected OpenCode server to apply the change.`,
|
|
}));
|
|
} catch (error) {
|
|
console.error('Failed to create agent:', error);
|
|
res.status(500).json({ error: error.message || 'Failed to create agent' });
|
|
}
|
|
});
|
|
|
|
app.patch('/api/config/agents/:name', async (req, res) => {
|
|
try {
|
|
const agentName = req.params.name;
|
|
const updates = req.body;
|
|
const { directory, error } = await resolveProjectDirectory(req);
|
|
if (!directory) {
|
|
return res.status(400).json({ error });
|
|
}
|
|
|
|
console.log(`[Server] Updating agent: ${agentName}`);
|
|
console.log('[Server] Updates:', JSON.stringify(updates, null, 2));
|
|
console.log('[Server] Working directory:', directory);
|
|
|
|
updateAgent(agentName, updates, directory);
|
|
const refreshResult = await refreshOpenCodeAfterConfigChange('agent update');
|
|
|
|
console.log(`[Server] Agent ${agentName} updated successfully`);
|
|
|
|
res.json(buildConfigMutationResponse(refreshResult, {
|
|
liveMessage: `Agent ${agentName} updated successfully. Reloading interface…`,
|
|
manualRestartMessage: `Agent ${agentName} saved. Restart your connected OpenCode server to apply the change.`,
|
|
}));
|
|
} catch (error) {
|
|
console.error('[Server] Failed to update agent:', error);
|
|
console.error('[Server] Error stack:', error.stack);
|
|
res.status(500).json({ error: error.message || 'Failed to update agent' });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/config/agents/:name', async (req, res) => {
|
|
try {
|
|
const agentName = req.params.name;
|
|
const { directory, error } = await resolveProjectDirectory(req);
|
|
if (!directory) {
|
|
return res.status(400).json({ error });
|
|
}
|
|
|
|
const scope = req.body?.scope;
|
|
deleteAgent(agentName, directory, scope);
|
|
const refreshResult = await refreshOpenCodeAfterConfigChange('agent deletion');
|
|
|
|
res.json(buildConfigMutationResponse(refreshResult, {
|
|
liveMessage: `Agent ${agentName} deleted successfully. Reloading interface…`,
|
|
manualRestartMessage: `Agent ${agentName} deleted. Restart your connected OpenCode server to apply the change.`,
|
|
}));
|
|
} catch (error) {
|
|
console.error('Failed to delete agent:', error);
|
|
res.status(500).json({ error: error.message || 'Failed to delete agent' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/config/mcp', async (req, res) => {
|
|
try {
|
|
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
|
if (error) {
|
|
return res.status(400).json({ error });
|
|
}
|
|
const configs = listMcpConfigs(directory);
|
|
res.json(configs);
|
|
} catch (error) {
|
|
console.error('[API:GET /api/config/mcp] Failed:', error);
|
|
res.status(500).json({ error: error.message || 'Failed to list MCP configs' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/config/mcp/:name', async (req, res) => {
|
|
try {
|
|
const name = req.params.name;
|
|
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
|
if (error) {
|
|
return res.status(400).json({ error });
|
|
}
|
|
const config = getMcpConfig(name, directory);
|
|
if (!config) {
|
|
return res.status(404).json({ error: `MCP server "${name}" not found` });
|
|
}
|
|
res.json(config);
|
|
} catch (error) {
|
|
console.error('[API:GET /api/config/mcp/:name] Failed:', error);
|
|
res.status(500).json({ error: error.message || 'Failed to get MCP config' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/config/mcp/:name', async (req, res) => {
|
|
try {
|
|
const name = req.params.name;
|
|
const { scope, ...config } = req.body || {};
|
|
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
|
if (error) {
|
|
return res.status(400).json({ error });
|
|
}
|
|
console.log(`[API:POST /api/config/mcp] Creating MCP server: ${name}`);
|
|
|
|
await completeMcpMutation(res, 'create', name, () => {
|
|
createMcpConfig(name, config, directory, scope);
|
|
});
|
|
} catch (error) {
|
|
console.error('[API:POST /api/config/mcp/:name] Failed:', error);
|
|
res.status(500).json({ error: error.message || 'Failed to create MCP server' });
|
|
}
|
|
});
|
|
|
|
app.patch('/api/config/mcp/:name', async (req, res) => {
|
|
try {
|
|
const name = req.params.name;
|
|
const updates = req.body;
|
|
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
|
if (error) {
|
|
return res.status(400).json({ error });
|
|
}
|
|
console.log(`[API:PATCH /api/config/mcp] Updating MCP server: ${name}`);
|
|
|
|
await completeMcpMutation(res, 'update', name, () => {
|
|
updateMcpConfig(name, updates, directory);
|
|
});
|
|
} catch (error) {
|
|
console.error('[API:PATCH /api/config/mcp/:name] Failed:', error);
|
|
if (error?.message === `MCP server "${req.params.name}" not found`) {
|
|
return res.status(404).json({ error: error.message });
|
|
}
|
|
res.status(500).json({ error: error.message || 'Failed to update MCP server' });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/config/mcp/:name', async (req, res) => {
|
|
try {
|
|
const name = req.params.name;
|
|
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
|
if (error) {
|
|
return res.status(400).json({ error });
|
|
}
|
|
console.log(`[API:DELETE /api/config/mcp] Deleting MCP server: ${name}`);
|
|
|
|
await completeMcpMutation(res, 'delete', name, () => {
|
|
deleteMcpConfig(name, directory);
|
|
});
|
|
} catch (error) {
|
|
console.error('[API:DELETE /api/config/mcp/:name] Failed:', error);
|
|
res.status(500).json({ error: error.message || 'Failed to delete MCP server' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/config/commands/:name', async (req, res) => {
|
|
try {
|
|
const commandName = req.params.name;
|
|
const { directory, error } = await resolveProjectDirectory(req);
|
|
if (!directory) {
|
|
return res.status(400).json({ error });
|
|
}
|
|
const sources = getCommandSources(commandName, directory);
|
|
|
|
const scope = sources.md.exists
|
|
? sources.md.scope
|
|
: (sources.json.exists ? sources.json.scope : null);
|
|
|
|
res.json({
|
|
name: commandName,
|
|
sources: sources,
|
|
scope,
|
|
isBuiltIn: !sources.md.exists && !sources.json.exists
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to get command sources:', error);
|
|
res.status(500).json({ error: 'Failed to get command configuration metadata' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/config/commands/:name', async (req, res) => {
|
|
try {
|
|
const commandName = req.params.name;
|
|
const { scope, ...config } = req.body;
|
|
const { directory, error } = await resolveProjectDirectory(req);
|
|
if (!directory) {
|
|
return res.status(400).json({ error });
|
|
}
|
|
|
|
console.log('[Server] Creating command:', commandName);
|
|
console.log('[Server] Config received:', JSON.stringify(config, null, 2));
|
|
console.log('[Server] Scope:', scope, 'Working directory:', directory);
|
|
|
|
createCommand(commandName, config, directory, scope);
|
|
await refreshOpenCodeAfterConfigChange('command creation', {
|
|
commandName
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
requiresReload: true,
|
|
message: `Command ${commandName} created successfully. Reloading interface…`,
|
|
reloadDelayMs: clientReloadDelayMs,
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to create command:', error);
|
|
res.status(500).json({ error: error.message || 'Failed to create command' });
|
|
}
|
|
});
|
|
|
|
app.patch('/api/config/commands/:name', async (req, res) => {
|
|
try {
|
|
const commandName = req.params.name;
|
|
const updates = req.body;
|
|
const { directory, error } = await resolveProjectDirectory(req);
|
|
if (!directory) {
|
|
return res.status(400).json({ error });
|
|
}
|
|
|
|
console.log(`[Server] Updating command: ${commandName}`);
|
|
console.log('[Server] Updates:', JSON.stringify(updates, null, 2));
|
|
console.log('[Server] Working directory:', directory);
|
|
|
|
updateCommand(commandName, updates, directory);
|
|
await refreshOpenCodeAfterConfigChange('command update');
|
|
|
|
console.log(`[Server] Command ${commandName} updated successfully`);
|
|
|
|
res.json({
|
|
success: true,
|
|
requiresReload: true,
|
|
message: `Command ${commandName} updated successfully. Reloading interface…`,
|
|
reloadDelayMs: clientReloadDelayMs,
|
|
});
|
|
} catch (error) {
|
|
console.error('[Server] Failed to update command:', error);
|
|
console.error('[Server] Error stack:', error.stack);
|
|
res.status(500).json({ error: error.message || 'Failed to update command' });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/config/commands/:name', async (req, res) => {
|
|
try {
|
|
const commandName = req.params.name;
|
|
const { directory, error } = await resolveProjectDirectory(req);
|
|
if (!directory) {
|
|
return res.status(400).json({ error });
|
|
}
|
|
|
|
deleteCommand(commandName, directory);
|
|
await refreshOpenCodeAfterConfigChange('command deletion');
|
|
|
|
res.json({
|
|
success: true,
|
|
requiresReload: true,
|
|
message: `Command ${commandName} deleted successfully. Reloading interface…`,
|
|
reloadDelayMs: clientReloadDelayMs,
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to delete command:', error);
|
|
res.status(500).json({ error: error.message || 'Failed to delete command' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/config/snippets', async (req, res) => {
|
|
try {
|
|
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
|
if (error) {
|
|
return res.status(400).json({ error });
|
|
}
|
|
res.json(listSnippets(directory));
|
|
} catch (error) {
|
|
console.error('[API:GET /api/config/snippets] Failed:', error);
|
|
res.status(500).json({ error: error.message || 'Failed to list snippets' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/config/snippets/expand', async (req, res) => {
|
|
try {
|
|
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
|
if (error) {
|
|
return res.status(400).json({ error });
|
|
}
|
|
res.json({ text: expandSnippets(req.body?.text ?? '', directory) });
|
|
} catch (error) {
|
|
console.error('[API:POST /api/config/snippets/expand] Failed:', error);
|
|
res.status(500).json({ error: error.message || 'Failed to expand snippets' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/config/snippets/:name', async (req, res) => {
|
|
try {
|
|
const name = req.params.name;
|
|
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
|
if (error) {
|
|
return res.status(400).json({ error });
|
|
}
|
|
const snippet = getSnippet(name, directory);
|
|
if (!snippet) {
|
|
return res.status(404).json({ error: `Snippet "${name}" not found` });
|
|
}
|
|
res.json(snippet);
|
|
} catch (error) {
|
|
console.error('[API:GET /api/config/snippets/:name] Failed:', error);
|
|
if (error.message?.includes('Snippet name')) {
|
|
return res.status(400).json({ error: error.message });
|
|
}
|
|
res.status(500).json({ error: error.message || 'Failed to get snippet' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/config/snippets/:name', async (req, res) => {
|
|
try {
|
|
const name = req.params.name;
|
|
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
|
if (error) {
|
|
return res.status(400).json({ error });
|
|
}
|
|
const snippet = createSnippet(name, req.body || {}, directory, req.body?.scope || 'global');
|
|
res.json({ success: true, snippet });
|
|
} catch (error) {
|
|
console.error('[API:POST /api/config/snippets/:name] Failed:', error);
|
|
if (error.message?.includes('already exists')) {
|
|
return res.status(409).json({ error: error.message });
|
|
}
|
|
if (error.message?.includes('Snippet name') || error.message?.includes('Project directory')) {
|
|
return res.status(400).json({ error: error.message });
|
|
}
|
|
res.status(500).json({ error: error.message || 'Failed to create snippet' });
|
|
}
|
|
});
|
|
|
|
app.patch('/api/config/snippets/:name', async (req, res) => {
|
|
try {
|
|
const name = req.params.name;
|
|
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
|
if (error) {
|
|
return res.status(400).json({ error });
|
|
}
|
|
res.json({ success: true, snippet: updateSnippet(name, req.body || {}, directory) });
|
|
} catch (error) {
|
|
console.error('[API:PATCH /api/config/snippets/:name] Failed:', error);
|
|
if (error.message?.includes('not found')) {
|
|
return res.status(404).json({ error: error.message });
|
|
}
|
|
if (error.message?.includes('Snippet name')) {
|
|
return res.status(400).json({ error: error.message });
|
|
}
|
|
res.status(500).json({ error: error.message || 'Failed to update snippet' });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/config/snippets/:name', async (req, res) => {
|
|
try {
|
|
const name = req.params.name;
|
|
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
|
if (error) {
|
|
return res.status(400).json({ error });
|
|
}
|
|
deleteSnippet(name, directory);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('[API:DELETE /api/config/snippets/:name] Failed:', error);
|
|
if (error.message?.includes('not found')) {
|
|
return res.status(404).json({ error: error.message });
|
|
}
|
|
if (error.message?.includes('Snippet name')) {
|
|
return res.status(400).json({ error: error.message });
|
|
}
|
|
res.status(500).json({ error: error.message || 'Failed to delete snippet' });
|
|
}
|
|
});
|
|
};
|