diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 6d8a64b7d02..3866df78396 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -668,6 +668,7 @@ export namespace ACP { }) const availableModels = entries.flatMap((provider) => { const models = Provider.sort(Object.values(provider.models)) + if (models.length === 0) return [] return models.map((model) => ({ modelId: `${provider.id}/${model.id}`, name: `${provider.name}/${model.name}`, diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 9b01eae9e9b..265e3594838 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -645,14 +645,26 @@ export namespace Provider { function mergeProvider(providerID: string, provider: Partial) { const existing = providers[providerID] if (existing) { - // @ts-expect-error - providers[providerID] = mergeDeep(existing, provider) + const merged = mergeDeep(existing, provider) as Info + // Validate after merge - if no models, remove the provider entirely + if (Object.keys(merged.models || {}).length === 0) { + log.warn("Provider has no models after merge, removing", { providerID }) + delete providers[providerID] + return + } + providers[providerID] = merged return } const match = database[providerID] if (!match) return - // @ts-expect-error - providers[providerID] = mergeDeep(match, provider) + + const merged = mergeDeep(match, provider) as Info + // Validate immediately after creation - if no models, don't add to providers + if (Object.keys(merged.models || {}).length === 0) { + log.warn("Provider has no models from database, skipping", { providerID }) + return + } + providers[providerID] = merged } // extend database from config diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index c7baec778c6..6bfaf572fe0 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -2657,100 +2657,1691 @@ export namespace Server { }, }, }), - validator("json", TuiEvent.ToastShow.properties), - async (c) => { - await Bus.publish(TuiEvent.ToastShow, c.req.valid("json")) - return c.json(true) + ), + async (c) => { + const sessionID = c.req.valid("param").sessionID + const updates = c.req.valid("json") + + const updatedSession = await Session.update(sessionID, (session) => { + if (updates.title !== undefined) { + session.title = updates.title + } + if (updates.time?.archived !== undefined) session.time.archived = updates.time.archived + }) + + return c.json(updatedSession) + }, + ) + .post( + "/session/:sessionID/init", + describeRoute({ + summary: "Initialize session", + description: + "Analyze the current application and create an AGENTS.md file with project-specific agent configurations.", + operationId: "session.init", + responses: { + 200: { + description: "200", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), }, - ) - .post( - "/tui/publish", - describeRoute({ - summary: "Publish TUI event", - description: "Publish a TUI event", - operationId: "tui.publish", - responses: { - 200: { - description: "Event published successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, + }), + validator( + "param", + z.object({ + sessionID: z.string().meta({ description: "Session ID" }), + }), + ), + validator("json", Session.initialize.schema.omit({ sessionID: true })), + async (c) => { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + await Session.initialize({ ...body, sessionID }) + return c.json(true) + }, + ) + .post( + "/session/:sessionID/fork", + describeRoute({ + summary: "Fork session", + description: "Create a new session by forking an existing session at a specific message point.", + operationId: "session.fork", + responses: { + 200: { + description: "200", + content: { + "application/json": { + schema: resolver(Session.Info), }, }, - ...errors(400), }, + }, + }), + validator( + "param", + z.object({ + sessionID: Session.fork.schema.shape.sessionID, }), - validator( - "json", - z.union( - Object.values(TuiEvent).map((def) => { - return z - .object({ - type: z.literal(def.type), - properties: def.properties, - }) - .meta({ - ref: "Event" + "." + def.type, - }) - }), + ), + validator("json", Session.fork.schema.omit({ sessionID: true })), + async (c) => { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + const result = await Session.fork({ ...body, sessionID }) + return c.json(result) + }, + ) + .post( + "/session/:sessionID/abort", + describeRoute({ + summary: "Abort session", + description: "Abort an active session and stop any ongoing AI processing or command execution.", + operationId: "session.abort", + responses: { + 200: { + description: "Aborted session", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: z.string(), + }), + ), + async (c) => { + SessionPrompt.cancel(c.req.valid("param").sessionID) + return c.json(true) + }, + ) + .post( + "/session/:sessionID/share", + describeRoute({ + summary: "Share session", + description: "Create a shareable link for a session, allowing others to view the conversation.", + operationId: "session.share", + responses: { + 200: { + description: "Successfully shared session", + content: { + "application/json": { + schema: resolver(Session.Info), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: z.string(), + }), + ), + async (c) => { + const sessionID = c.req.valid("param").sessionID + await Session.share(sessionID) + const session = await Session.get(sessionID) + return c.json(session) + }, + ) + .get( + "/session/:sessionID/diff", + describeRoute({ + summary: "Get message diff", + description: "Get the file changes (diff) that resulted from a specific user message in the session.", + operationId: "session.diff", + responses: { + 200: { + description: "Successfully retrieved diff", + content: { + "application/json": { + schema: resolver(Snapshot.FileDiff.array()), + }, + }, + }, + }, + }), + validator( + "param", + z.object({ + sessionID: SessionSummary.diff.schema.shape.sessionID, + }), + ), + validator( + "query", + z.object({ + messageID: SessionSummary.diff.schema.shape.messageID, + }), + ), + async (c) => { + const query = c.req.valid("query") + const params = c.req.valid("param") + const result = await SessionSummary.diff({ + sessionID: params.sessionID, + messageID: query.messageID, + }) + return c.json(result) + }, + ) + .delete( + "/session/:sessionID/share", + describeRoute({ + summary: "Unshare session", + description: "Remove the shareable link for a session, making it private again.", + operationId: "session.unshare", + responses: { + 200: { + description: "Successfully unshared session", + content: { + "application/json": { + schema: resolver(Session.Info), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: Session.unshare.schema, + }), + ), + async (c) => { + const sessionID = c.req.valid("param").sessionID + await Session.unshare(sessionID) + const session = await Session.get(sessionID) + return c.json(session) + }, + ) + .post( + "/session/:sessionID/summarize", + describeRoute({ + summary: "Summarize session", + description: "Generate a concise summary of the session using AI compaction to preserve key information.", + operationId: "session.summarize", + responses: { + 200: { + description: "Summarized session", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: z.string().meta({ description: "Session ID" }), + }), + ), + validator( + "json", + z.object({ + providerID: z.string(), + modelID: z.string(), + }), + ), + async (c) => { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + const msgs = await Session.messages({ sessionID }) + let currentAgent = await Agent.defaultAgent() + for (let i = msgs.length - 1; i >= 0; i--) { + const info = msgs[i].info + if (info.role === "user") { + currentAgent = info.agent || (await Agent.defaultAgent()) + break + } + } + await SessionCompaction.create({ + sessionID, + agent: currentAgent, + model: { + providerID: body.providerID, + modelID: body.modelID, + }, + auto: false, + }) + await SessionPrompt.loop(sessionID) + return c.json(true) + }, + ) + .get( + "/session/:sessionID/message", + describeRoute({ + summary: "Get session messages", + description: "Retrieve all messages in a session, including user prompts and AI responses.", + operationId: "session.messages", + responses: { + 200: { + description: "List of messages", + content: { + "application/json": { + schema: resolver(MessageV2.WithParts.array()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: z.string().meta({ description: "Session ID" }), + }), + ), + validator( + "query", + z.object({ + limit: z.coerce.number().optional(), + }), + ), + async (c) => { + const query = c.req.valid("query") + const messages = await Session.messages({ + sessionID: c.req.valid("param").sessionID, + limit: query.limit, + }) + return c.json(messages) + }, + ) + .get( + "/session/:sessionID/diff", + describeRoute({ + summary: "Get session diff", + description: "Get all file changes (diffs) made during this session.", + operationId: "session.diff", + responses: { + 200: { + description: "List of diffs", + content: { + "application/json": { + schema: resolver(Snapshot.FileDiff.array()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: z.string().meta({ description: "Session ID" }), + }), + ), + async (c) => { + const diff = await Session.diff(c.req.valid("param").sessionID) + return c.json(diff) + }, + ) + .get( + "/session/:sessionID/message/:messageID", + describeRoute({ + summary: "Get message", + description: "Retrieve a specific message from a session by its message ID.", + operationId: "session.message", + responses: { + 200: { + description: "Message", + content: { + "application/json": { + schema: resolver( + z.object({ + info: MessageV2.Info, + parts: MessageV2.Part.array(), + }), + ), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: z.string().meta({ description: "Session ID" }), + messageID: z.string().meta({ description: "Message ID" }), + }), + ), + async (c) => { + const params = c.req.valid("param") + const message = await MessageV2.get({ + sessionID: params.sessionID, + messageID: params.messageID, + }) + return c.json(message) + }, + ) + .delete( + "/session/:sessionID/message/:messageID/part/:partID", + describeRoute({ + description: "Delete a part from a message", + operationId: "part.delete", + responses: { + 200: { + description: "Successfully deleted part", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: z.string().meta({ description: "Session ID" }), + messageID: z.string().meta({ description: "Message ID" }), + partID: z.string().meta({ description: "Part ID" }), + }), + ), + async (c) => { + const params = c.req.valid("param") + await Session.removePart({ + sessionID: params.sessionID, + messageID: params.messageID, + partID: params.partID, + }) + return c.json(true) + }, + ) + .patch( + "/session/:sessionID/message/:messageID/part/:partID", + describeRoute({ + description: "Update a part in a message", + operationId: "part.update", + responses: { + 200: { + description: "Successfully updated part", + content: { + "application/json": { + schema: resolver(MessageV2.Part), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: z.string().meta({ description: "Session ID" }), + messageID: z.string().meta({ description: "Message ID" }), + partID: z.string().meta({ description: "Part ID" }), + }), + ), + validator("json", MessageV2.Part), + async (c) => { + const params = c.req.valid("param") + const body = c.req.valid("json") + if (body.id !== params.partID || body.messageID !== params.messageID || body.sessionID !== params.sessionID) { + throw new Error( + `Part mismatch: body.id='${body.id}' vs partID='${params.partID}', body.messageID='${body.messageID}' vs messageID='${params.messageID}', body.sessionID='${body.sessionID}' vs sessionID='${params.sessionID}'`, + ) + } + const part = await Session.updatePart(body) + return c.json(part) + }, + ) + .post( + "/session/:sessionID/message", + describeRoute({ + summary: "Send message", + description: "Create and send a new message to a session, streaming the AI response.", + operationId: "session.prompt", + responses: { + 200: { + description: "Created message", + content: { + "application/json": { + schema: resolver( + z.object({ + info: MessageV2.Assistant, + parts: MessageV2.Part.array(), + }), + ), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: z.string().meta({ description: "Session ID" }), + }), + ), + validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })), + async (c) => { + c.status(200) + c.header("Content-Type", "application/json") + return stream(c, async (stream) => { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + const msg = await SessionPrompt.prompt({ ...body, sessionID }) + stream.write(JSON.stringify(msg)) + }) + }, + ) + .post( + "/session/:sessionID/prompt_async", + describeRoute({ + summary: "Send async message", + description: + "Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.", + operationId: "session.prompt_async", + responses: { + 204: { + description: "Prompt accepted", + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: z.string().meta({ description: "Session ID" }), + }), + ), + validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })), + async (c) => { + c.status(204) + c.header("Content-Type", "application/json") + return stream(c, async () => { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + SessionPrompt.prompt({ ...body, sessionID }) + }) + }, + ) + .post( + "/session/:sessionID/command", + describeRoute({ + summary: "Send command", + description: "Send a new command to a session for execution by the AI assistant.", + operationId: "session.command", + responses: { + 200: { + description: "Created message", + content: { + "application/json": { + schema: resolver( + z.object({ + info: MessageV2.Assistant, + parts: MessageV2.Part.array(), + }), + ), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: z.string().meta({ description: "Session ID" }), + }), + ), + validator("json", SessionPrompt.CommandInput.omit({ sessionID: true })), + async (c) => { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + const msg = await SessionPrompt.command({ ...body, sessionID }) + return c.json(msg) + }, + ) + .post( + "/session/:sessionID/shell", + describeRoute({ + summary: "Run shell command", + description: "Execute a shell command within the session context and return the AI's response.", + operationId: "session.shell", + responses: { + 200: { + description: "Created message", + content: { + "application/json": { + schema: resolver(MessageV2.Assistant), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: z.string().meta({ description: "Session ID" }), + }), + ), + validator("json", SessionPrompt.ShellInput.omit({ sessionID: true })), + async (c) => { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + const msg = await SessionPrompt.shell({ ...body, sessionID }) + return c.json(msg) + }, + ) + .post( + "/session/:sessionID/revert", + describeRoute({ + summary: "Revert message", + description: "Revert a specific message in a session, undoing its effects and restoring the previous state.", + operationId: "session.revert", + responses: { + 200: { + description: "Updated session", + content: { + "application/json": { + schema: resolver(Session.Info), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: z.string(), + }), + ), + validator("json", SessionRevert.RevertInput.omit({ sessionID: true })), + async (c) => { + const sessionID = c.req.valid("param").sessionID + log.info("revert", c.req.valid("json")) + const session = await SessionRevert.revert({ + sessionID, + ...c.req.valid("json"), + }) + return c.json(session) + }, + ) + .post( + "/session/:sessionID/unrevert", + describeRoute({ + summary: "Restore reverted messages", + description: "Restore all previously reverted messages in a session.", + operationId: "session.unrevert", + responses: { + 200: { + description: "Updated session", + content: { + "application/json": { + schema: resolver(Session.Info), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: z.string(), + }), + ), + async (c) => { + const sessionID = c.req.valid("param").sessionID + const session = await SessionRevert.unrevert({ sessionID }) + return c.json(session) + }, + ) + .post( + "/session/:sessionID/permissions/:permissionID", + describeRoute({ + summary: "Respond to permission", + description: "Approve or deny a permission request from the AI assistant.", + operationId: "permission.respond", + responses: { + 200: { + description: "Permission processed successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: z.string(), + permissionID: z.string(), + }), + ), + validator("json", z.object({ response: Permission.Response })), + async (c) => { + const params = c.req.valid("param") + const sessionID = params.sessionID + const permissionID = params.permissionID + Permission.respond({ + sessionID, + permissionID, + response: c.req.valid("json").response, + }) + return c.json(true) + }, + ) + .get( + "/command", + describeRoute({ + summary: "List commands", + description: "Get a list of all available commands in the OpenCode system.", + operationId: "command.list", + responses: { + 200: { + description: "List of commands", + content: { + "application/json": { + schema: resolver(Command.Info.array()), + }, + }, + }, + }, + }), + async (c) => { + const commands = await Command.list() + return c.json(commands) + }, + ) + .get( + "/config/providers", + describeRoute({ + summary: "List config providers", + description: "Get a list of all configured AI providers and their default models.", + operationId: "config.providers", + responses: { + 200: { + description: "List of providers", + content: { + "application/json": { + schema: resolver( + z.object({ + providers: Provider.Info.array(), + default: z.record(z.string(), z.string()), + }), + ), + }, + }, + }, + }, + }), + async (c) => { + using _ = log.time("providers") + const providers = await Provider.list().then((x) => mapValues(x, (item) => item)) + return c.json({ + providers: Object.values(providers), + default: Object.fromEntries( + Object.entries(providers) + .map(([providerID, item]) => { + const sorted = Provider.sort(Object.values(item.models)) + if (sorted.length === 0) { + log.error("Provider has no models available", { providerID }) + return null + } + return [providerID, sorted[0].id] + }) + .filter((entry): entry is [string, string] => entry !== null), ), - ), - async (c) => { - const evt = c.req.valid("json") - await Bus.publish(Object.values(TuiEvent).find((def) => def.type === evt.type)!, evt.properties) - return c.json(true) + }) + }, + ) + .get( + "/provider", + describeRoute({ + summary: "List providers", + description: "Get a list of all available AI providers, including both available and connected ones.", + operationId: "provider.list", + responses: { + 200: { + description: "List of providers", + content: { + "application/json": { + schema: resolver( + z.object({ + all: ModelsDev.Provider.array(), + default: z.record(z.string(), z.string()), + connected: z.array(z.string()), + }), + ), + }, + }, + }, }, - ) - .post( - "/tui/select-session", - describeRoute({ - summary: "Select session", - description: "Navigate the TUI to display the specified session.", - operationId: "tui.selectSession", - responses: { - 200: { - description: "Session selected successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, + }), + async (c) => { + const config = await Config.get() + const disabled = new Set(config.disabled_providers ?? []) + const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined + + const allProviders = await ModelsDev.get() + const filteredProviders: Record = {} + for (const [key, value] of Object.entries(allProviders)) { + if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { + filteredProviders[key] = value + } + } + + const connected = await Provider.list() + const providers = Object.assign( + mapValues(filteredProviders, (x) => Provider.fromModelsDevProvider(x)), + connected, + ) + + return c.json({ + all: Object.values(providers), + default: Object.fromEntries( + Object.entries(providers) + .map(([providerID, item]) => { + const sorted = Provider.sort(Object.values(item.models)) + if (sorted.length === 0) { + log.error("Provider has no models available", { providerID }) + return null + } + return [providerID, sorted[0].id] + }) + .filter((entry): entry is [string, string] => entry !== null), + ), + connected: Object.keys(connected), + }) + }, + ) + .get( + "/provider/auth", + describeRoute({ + summary: "Get provider auth methods", + description: "Retrieve available authentication methods for all AI providers.", + operationId: "provider.auth", + responses: { + 200: { + description: "Provider auth methods", + content: { + "application/json": { + schema: resolver(z.record(z.string(), z.array(ProviderAuth.Method))), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await ProviderAuth.methods()) + }, + ) + .post( + "/provider/:providerID/oauth/authorize", + describeRoute({ + summary: "OAuth authorize", + description: "Initiate OAuth authorization for a specific AI provider to get an authorization URL.", + operationId: "provider.oauth.authorize", + responses: { + 200: { + description: "Authorization URL and method", + content: { + "application/json": { + schema: resolver(ProviderAuth.Authorization.optional()), }, }, - ...errors(400, 404), }, + ...errors(400), + }, + }), + validator( + "param", + z.object({ + providerID: z.string().meta({ description: "Provider ID" }), }), - validator("json", TuiEvent.SessionSelect.properties), - async (c) => { - const { sessionID } = c.req.valid("json") - await Session.get(sessionID) - await Bus.publish(TuiEvent.SessionSelect, { sessionID }) - return c.json(true) + ), + validator( + "json", + z.object({ + method: z.number().meta({ description: "Auth method index" }), + }), + ), + async (c) => { + const providerID = c.req.valid("param").providerID + const { method } = c.req.valid("json") + const result = await ProviderAuth.authorize({ + providerID, + method, + }) + return c.json(result) + }, + ) + .post( + "/provider/:providerID/oauth/callback", + describeRoute({ + summary: "OAuth callback", + description: "Handle the OAuth callback from a provider after user authorization.", + operationId: "provider.oauth.callback", + responses: { + 200: { + description: "OAuth callback processed successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), }, - ) - .route("/tui/control", TuiRoute) - .put( - "/auth/:providerID", - describeRoute({ - summary: "Set auth credentials", - description: "Set authentication credentials", - operationId: "auth.set", - responses: { - 200: { - description: "Successfully set authentication credentials", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, + }), + validator( + "param", + z.object({ + providerID: z.string().meta({ description: "Provider ID" }), + }), + ), + validator( + "json", + z.object({ + method: z.number().meta({ description: "Auth method index" }), + code: z.string().optional().meta({ description: "OAuth authorization code" }), + }), + ), + async (c) => { + const providerID = c.req.valid("param").providerID + const { method, code } = c.req.valid("json") + await ProviderAuth.callback({ + providerID, + method, + code, + }) + return c.json(true) + }, + ) + .get( + "/find", + describeRoute({ + summary: "Find text", + description: "Search for text patterns across files in the project using ripgrep.", + operationId: "find.text", + responses: { + 200: { + description: "Matches", + content: { + "application/json": { + schema: resolver(Ripgrep.Match.shape.data.array()), }, }, - ...errors(400), }, + }, + }), + validator( + "query", + z.object({ + pattern: z.string(), }), - validator( - "param", - z.object({ - providerID: z.string(), + ), + async (c) => { + const pattern = c.req.valid("query").pattern + const result = await Ripgrep.search({ + cwd: Instance.directory, + pattern, + limit: 10, + }) + return c.json(result) + }, + ) + .get( + "/find/file", + describeRoute({ + summary: "Find files", + description: "Search for files by name or pattern in the project directory.", + operationId: "find.files", + responses: { + 200: { + description: "File paths", + content: { + "application/json": { + schema: resolver(z.string().array()), + }, + }, + }, + }, + }), + validator( + "query", + z.object({ + query: z.string(), + dirs: z.enum(["true", "false"]).optional(), + }), + ), + async (c) => { + const query = c.req.valid("query").query + const dirs = c.req.valid("query").dirs + const results = await File.search({ + query, + limit: 10, + dirs: dirs !== "false", + }) + return c.json(results) + }, + ) + .get( + "/find/symbol", + describeRoute({ + summary: "Find symbols", + description: "Search for workspace symbols like functions, classes, and variables using LSP.", + operationId: "find.symbols", + responses: { + 200: { + description: "Symbols", + content: { + "application/json": { + schema: resolver(LSP.Symbol.array()), + }, + }, + }, + }, + }), + validator( + "query", + z.object({ + query: z.string(), + }), + ), + async (c) => { + /* + const query = c.req.valid("query").query + const result = await LSP.workspaceSymbol(query) + return c.json(result) + */ + return c.json([]) + }, + ) + .get( + "/file", + describeRoute({ + summary: "List files", + description: "List files and directories in a specified path.", + operationId: "file.list", + responses: { + 200: { + description: "Files and directories", + content: { + "application/json": { + schema: resolver(File.Node.array()), + }, + }, + }, + }, + }), + validator( + "query", + z.object({ + path: z.string(), + }), + ), + async (c) => { + const path = c.req.valid("query").path + const content = await File.list(path) + return c.json(content) + }, + ) + .get( + "/file/content", + describeRoute({ + summary: "Read file", + description: "Read the content of a specified file.", + operationId: "file.read", + responses: { + 200: { + description: "File content", + content: { + "application/json": { + schema: resolver(File.Content), + }, + }, + }, + }, + }), + validator( + "query", + z.object({ + path: z.string(), + }), + ), + async (c) => { + const path = c.req.valid("query").path + const content = await File.read(path) + return c.json(content) + }, + ) + .get( + "/file/status", + describeRoute({ + summary: "Get file status", + description: "Get the git status of all files in the project.", + operationId: "file.status", + responses: { + 200: { + description: "File status", + content: { + "application/json": { + schema: resolver(File.Info.array()), + }, + }, + }, + }, + }), + async (c) => { + const content = await File.status() + return c.json(content) + }, + ) + .post( + "/log", + describeRoute({ + summary: "Write log", + description: "Write a log entry to the server logs with specified level and metadata.", + operationId: "app.log", + responses: { + 200: { + description: "Log entry written successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "json", + z.object({ + service: z.string().meta({ description: "Service name for the log entry" }), + level: z.enum(["debug", "info", "error", "warn"]).meta({ description: "Log level" }), + message: z.string().meta({ description: "Log message" }), + extra: z + .record(z.string(), z.any()) + .optional() + .meta({ description: "Additional metadata for the log entry" }), + }), + ), + async (c) => { + const { service, level, message, extra } = c.req.valid("json") + const logger = Log.create({ service }) + + switch (level) { + case "debug": + logger.debug(message, extra) + break + case "info": + logger.info(message, extra) + break + case "error": + logger.error(message, extra) + break + case "warn": + logger.warn(message, extra) + break + } + + return c.json(true) + }, + ) + .get( + "/agent", + describeRoute({ + summary: "List agents", + description: "Get a list of all available AI agents in the OpenCode system.", + operationId: "app.agents", + responses: { + 200: { + description: "List of agents", + content: { + "application/json": { + schema: resolver(Agent.Info.array()), + }, + }, + }, + }, + }), + async (c) => { + const modes = await Agent.list() + return c.json(modes) + }, + ) + .get( + "/mcp", + describeRoute({ + summary: "Get MCP status", + description: "Get the status of all Model Context Protocol (MCP) servers.", + operationId: "mcp.status", + responses: { + 200: { + description: "MCP server status", + content: { + "application/json": { + schema: resolver(z.record(z.string(), MCP.Status)), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await MCP.status()) + }, + ) + .post( + "/mcp", + describeRoute({ + summary: "Add MCP server", + description: "Dynamically add a new Model Context Protocol (MCP) server to the system.", + operationId: "mcp.add", + responses: { + 200: { + description: "MCP server added successfully", + content: { + "application/json": { + schema: resolver(z.record(z.string(), MCP.Status)), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "json", + z.object({ + name: z.string(), + config: Config.Mcp, + }), + ), + async (c) => { + const { name, config } = c.req.valid("json") + const result = await MCP.add(name, config) + return c.json(result.status) + }, + ) + .post( + "/mcp/:name/auth", + describeRoute({ + summary: "Start MCP OAuth", + description: "Start OAuth authentication flow for a Model Context Protocol (MCP) server.", + operationId: "mcp.auth.start", + responses: { + 200: { + description: "OAuth flow started", + content: { + "application/json": { + schema: resolver( + z.object({ + authorizationUrl: z.string().describe("URL to open in browser for authorization"), + }), + ), + }, + }, + }, + ...errors(400, 404), + }, + }), + async (c) => { + const name = c.req.param("name") + const supportsOAuth = await MCP.supportsOAuth(name) + if (!supportsOAuth) { + return c.json({ error: `MCP server ${name} does not support OAuth` }, 400) + } + const result = await MCP.startAuth(name) + return c.json(result) + }, + ) + .post( + "/mcp/:name/auth/callback", + describeRoute({ + summary: "Complete MCP OAuth", + description: + "Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code.", + operationId: "mcp.auth.callback", + responses: { + 200: { + description: "OAuth authentication completed", + content: { + "application/json": { + schema: resolver(MCP.Status), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "json", + z.object({ + code: z.string().describe("Authorization code from OAuth callback"), + }), + ), + async (c) => { + const name = c.req.param("name") + const { code } = c.req.valid("json") + const status = await MCP.finishAuth(name, code) + return c.json(status) + }, + ) + .post( + "/mcp/:name/auth/authenticate", + describeRoute({ + summary: "Authenticate MCP OAuth", + description: "Start OAuth flow and wait for callback (opens browser)", + operationId: "mcp.auth.authenticate", + responses: { + 200: { + description: "OAuth authentication completed", + content: { + "application/json": { + schema: resolver(MCP.Status), + }, + }, + }, + ...errors(400, 404), + }, + }), + async (c) => { + const name = c.req.param("name") + const supportsOAuth = await MCP.supportsOAuth(name) + if (!supportsOAuth) { + return c.json({ error: `MCP server ${name} does not support OAuth` }, 400) + } + const status = await MCP.authenticate(name) + return c.json(status) + }, + ) + .delete( + "/mcp/:name/auth", + describeRoute({ + summary: "Remove MCP OAuth", + description: "Remove OAuth credentials for an MCP server", + operationId: "mcp.auth.remove", + responses: { + 200: { + description: "OAuth credentials removed", + content: { + "application/json": { + schema: resolver(z.object({ success: z.literal(true) })), + }, + }, + }, + ...errors(404), + }, + }), + async (c) => { + const name = c.req.param("name") + await MCP.removeAuth(name) + return c.json({ success: true as const }) + }, + ) + .post( + "/mcp/:name/connect", + describeRoute({ + description: "Connect an MCP server", + operationId: "mcp.connect", + responses: { + 200: { + description: "MCP server connected successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + validator("param", z.object({ name: z.string() })), + async (c) => { + const { name } = c.req.valid("param") + await MCP.connect(name) + return c.json(true) + }, + ) + .post( + "/mcp/:name/disconnect", + describeRoute({ + description: "Disconnect an MCP server", + operationId: "mcp.disconnect", + responses: { + 200: { + description: "MCP server disconnected successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + validator("param", z.object({ name: z.string() })), + async (c) => { + const { name } = c.req.valid("param") + await MCP.disconnect(name) + return c.json(true) + }, + ) + .get( + "/lsp", + describeRoute({ + summary: "Get LSP status", + description: "Get LSP server status", + operationId: "lsp.status", + responses: { + 200: { + description: "LSP server status", + content: { + "application/json": { + schema: resolver(LSP.Status.array()), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await LSP.status()) + }, + ) + .get( + "/formatter", + describeRoute({ + summary: "Get formatter status", + description: "Get formatter status", + operationId: "formatter.status", + responses: { + 200: { + description: "Formatter status", + content: { + "application/json": { + schema: resolver(Format.Status.array()), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await Format.status()) + }, + ) + .post( + "/tui/append-prompt", + describeRoute({ + summary: "Append TUI prompt", + description: "Append prompt to the TUI", + operationId: "tui.appendPrompt", + responses: { + 200: { + description: "Prompt processed successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", TuiEvent.PromptAppend.properties), + async (c) => { + await Bus.publish(TuiEvent.PromptAppend, c.req.valid("json")) + return c.json(true) + }, + ) + .post( + "/tui/open-help", + describeRoute({ + summary: "Open help dialog", + description: "Open the help dialog in the TUI to display user assistance information.", + operationId: "tui.openHelp", + responses: { + 200: { + description: "Help dialog opened successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + async (c) => { + // TODO: open dialog + return c.json(true) + }, + ) + .post( + "/tui/open-sessions", + describeRoute({ + summary: "Open sessions dialog", + description: "Open the session dialog", + operationId: "tui.openSessions", + responses: { + 200: { + description: "Session dialog opened successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + async (c) => { + await Bus.publish(TuiEvent.CommandExecute, { + command: "session.list", + }) + return c.json(true) + }, + ) + .post( + "/tui/open-themes", + describeRoute({ + summary: "Open themes dialog", + description: "Open the theme dialog", + operationId: "tui.openThemes", + responses: { + 200: { + description: "Theme dialog opened successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + async (c) => { + await Bus.publish(TuiEvent.CommandExecute, { + command: "session.list", + }) + return c.json(true) + }, + ) + .post( + "/tui/open-models", + describeRoute({ + summary: "Open models dialog", + description: "Open the model dialog", + operationId: "tui.openModels", + responses: { + 200: { + description: "Model dialog opened successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + async (c) => { + await Bus.publish(TuiEvent.CommandExecute, { + command: "model.list", + }) + return c.json(true) + }, + ) + .post( + "/tui/submit-prompt", + describeRoute({ + summary: "Submit TUI prompt", + description: "Submit the prompt", + operationId: "tui.submitPrompt", + responses: { + 200: { + description: "Prompt submitted successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + async (c) => { + await Bus.publish(TuiEvent.CommandExecute, { + command: "prompt.submit", + }) + return c.json(true) + }, + ) + .post( + "/tui/clear-prompt", + describeRoute({ + summary: "Clear TUI prompt", + description: "Clear the prompt", + operationId: "tui.clearPrompt", + responses: { + 200: { + description: "Prompt cleared successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + async (c) => { + await Bus.publish(TuiEvent.CommandExecute, { + command: "prompt.clear", + }) + return c.json(true) + }, + ) + .post( + "/tui/execute-command", + describeRoute({ + summary: "Execute TUI command", + description: "Execute a TUI command (e.g. agent_cycle)", + operationId: "tui.executeCommand", + responses: { + 200: { + description: "Command executed successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", z.object({ command: z.string() })), + async (c) => { + const command = c.req.valid("json").command + await Bus.publish(TuiEvent.CommandExecute, { + // @ts-expect-error + command: { + session_new: "session.new", + session_share: "session.share", + session_interrupt: "session.interrupt", + session_compact: "session.compact", + messages_page_up: "session.page.up", + messages_page_down: "session.page.down", + messages_half_page_up: "session.half.page.up", + messages_half_page_down: "session.half.page.down", + messages_first: "session.first", + messages_last: "session.last", + agent_cycle: "agent.cycle", + }[command], + }) + return c.json(true) + }, + ) + .post( + "/tui/show-toast", + describeRoute({ + summary: "Show TUI toast", + description: "Show a toast notification in the TUI", + operationId: "tui.showToast", + responses: { + 200: { + description: "Toast notification shown successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + validator("json", TuiEvent.ToastShow.properties), + async (c) => { + await Bus.publish(TuiEvent.ToastShow, c.req.valid("json")) + return c.json(true) + }, + ) + .post( + "/tui/publish", + describeRoute({ + summary: "Publish TUI event", + description: "Publish a TUI event", + operationId: "tui.publish", + responses: { + 200: { + description: "Event published successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "json", + z.union( + Object.values(TuiEvent).map((def) => { + return z + .object({ + type: z.literal(def.type), + properties: def.properties, + }) + .meta({ + ref: "Event" + "." + def.type, + }) }), ), validator("json", Auth.Info),