diff --git a/packages/opencode/.opencode/skills/altimate-setup/SKILL.md b/packages/opencode/.opencode/skills/altimate-setup/SKILL.md new file mode 100644 index 0000000000..cadd94e0b2 --- /dev/null +++ b/packages/opencode/.opencode/skills/altimate-setup/SKILL.md @@ -0,0 +1,31 @@ +--- +name: altimate-setup +description: Configure Altimate platform credentials for datamate and API access +--- + +# Altimate Setup + +Guide the user through configuring their Altimate platform credentials. + +## Steps + +1. **Check existing config**: Read `~/.altimate/altimate.json`. If it exists and is valid, show the current config (mask the API key) and ask if they want to update it. + +2. **Gather credentials**: Ask the user for: + - **Altimate URL** (default: `https://api.myaltimate.com`) + - **Instance name** (their tenant/org name, e.g. `megatenant`) + - **API key** (from Altimate platform settings) + - **MCP server URL** (optional, default: `https://mcpserver.getaltimate.com/sse`) + +3. **Write config**: Create `~/.altimate/` directory if needed, then write `~/.altimate/altimate.json`: + ```json + { + "altimateUrl": "", + "altimateInstanceName": "", + "altimateApiKey": "", + "mcpServerUrl": "" + } + ``` + Then set permissions to owner-only: `chmod 600 ~/.altimate/altimate.json` + +4. **Validate**: Call the `datamate_manager` tool with `operation: "list"` to verify the credentials work. Report success or failure to the user. diff --git a/packages/opencode/src/altimate/api/client.ts b/packages/opencode/src/altimate/api/client.ts new file mode 100644 index 0000000000..70346d2e39 --- /dev/null +++ b/packages/opencode/src/altimate/api/client.ts @@ -0,0 +1,178 @@ +import z from "zod" +import path from "path" +import { Global } from "../../global" +import { Filesystem } from "../../util/filesystem" + +const DEFAULT_MCP_URL = "https://mcpserver.getaltimate.com/sse" + +const AltimateCredentials = z.object({ + altimateUrl: z.string(), + altimateInstanceName: z.string(), + altimateApiKey: z.string(), + mcpServerUrl: z.string().optional(), +}) +type AltimateCredentials = z.infer + +const DatamateSummary = z.object({ + id: z.coerce.string(), + name: z.string(), + description: z.string().nullable().optional(), + integrations: z + .array( + z.object({ + id: z.coerce.string(), + tools: z.array(z.object({ key: z.string() })).optional(), + }), + ) + .nullable() + .optional(), + memory_enabled: z.boolean().optional(), + privacy: z.string().optional(), +}) + +const IntegrationSummary = z.object({ + id: z.coerce.string(), + name: z.string().optional(), + description: z.string().nullable().optional(), + tools: z + .array( + z.object({ + key: z.string(), + name: z.string().optional(), + enable_all: z.array(z.string()).optional(), + }), + ) + .optional(), +}) + +export namespace AltimateApi { + export function credentialsPath(): string { + return path.join(Global.Path.home, ".altimate", "altimate.json") + } + + export async function isConfigured(): Promise { + return Filesystem.exists(credentialsPath()) + } + + export async function getCredentials(): Promise { + const p = credentialsPath() + if (!(await Filesystem.exists(p))) { + throw new Error(`Altimate credentials not found at ${p}`) + } + const raw = JSON.parse(await Filesystem.readText(p)) + return AltimateCredentials.parse(raw) + } + + async function request(creds: AltimateCredentials, method: string, endpoint: string, body?: unknown) { + const url = `${creds.altimateUrl}${endpoint}` + const res = await fetch(url, { + method, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${creds.altimateApiKey}`, + "x-tenant": creds.altimateInstanceName, + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }) + if (!res.ok) { + throw new Error(`API ${method} ${endpoint} failed with status ${res.status}`) + } + return res.json() + } + + export async function listDatamates() { + const creds = await getCredentials() + const data = await request(creds, "GET", "/datamates/") + const list = Array.isArray(data) ? data : (data.datamates ?? data.data ?? []) + return list.map((d: unknown) => DatamateSummary.parse(d)) as z.infer[] + } + + export async function getDatamate(id: string) { + const creds = await getCredentials() + try { + const data = await request(creds, "GET", `/datamates/${id}/summary`) + const raw = data.datamate ?? data + return DatamateSummary.parse(raw) + } catch (e) { + // Fallback to list if single-item endpoint is unavailable (404) + if (e instanceof Error && e.message.includes("status 404")) { + const all = await listDatamates() + const found = all.find((d) => d.id === id) + if (!found) { + throw new Error(`Datamate with ID ${id} not found`) + } + return found + } + throw e + } + } + + export async function createDatamate(payload: { + name: string + description?: string + integrations?: Array<{ id: string; tools: Array<{ key: string }> }> + memory_enabled?: boolean + privacy?: string + }) { + const creds = await getCredentials() + const data = await request(creds, "POST", "/datamates/", payload) + // Backend returns { id: number } for create + const id = String(data.id ?? data.datamate?.id) + return { id, name: payload.name } + } + + export async function updateDatamate( + id: string, + payload: { + name?: string + description?: string + integrations?: Array<{ id: string; tools: Array<{ key: string }> }> + memory_enabled?: boolean + privacy?: string + }, + ) { + const creds = await getCredentials() + const data = await request(creds, "PATCH", `/datamates/${id}`, payload) + const raw = data.datamate ?? data + return DatamateSummary.parse(raw) + } + + export async function deleteDatamate(id: string) { + const creds = await getCredentials() + await request(creds, "DELETE", `/datamates/${id}`) + } + + export async function listIntegrations() { + const creds = await getCredentials() + const data = await request(creds, "GET", "/datamate_integrations/") + const list = Array.isArray(data) ? data : (data.integrations ?? data.data ?? []) + return list.map((d: unknown) => IntegrationSummary.parse(d)) as z.infer[] + } + + /** Resolve integration IDs to full integration objects with all tools enabled (matching frontend behavior). */ + export async function resolveIntegrations( + integrationIds: string[], + ): Promise }>> { + const allIntegrations = await listIntegrations() + return integrationIds.map((id) => { + const def = allIntegrations.find((i) => i.id === id) + const tools = + def?.tools?.flatMap((t) => (t.enable_all ?? [t.key]).map((k) => ({ key: k }))) ?? [] + return { id, tools } + }) + } + + export function buildMcpConfig(creds: AltimateCredentials, datamateId: string) { + return { + type: "remote" as const, + url: creds.mcpServerUrl ?? DEFAULT_MCP_URL, + oauth: false as const, + headers: { + Authorization: `Bearer ${creds.altimateApiKey}`, + "x-datamate-id": String(datamateId), + "x-tenant": creds.altimateInstanceName, + "x-altimate-url": creds.altimateUrl, + }, + } + } +} diff --git a/packages/opencode/src/altimate/tools/datamate.ts b/packages/opencode/src/altimate/tools/datamate.ts new file mode 100644 index 0000000000..e15e5af450 --- /dev/null +++ b/packages/opencode/src/altimate/tools/datamate.ts @@ -0,0 +1,468 @@ +import z from "zod" +import { Tool } from "../../tool/tool" +import { AltimateApi } from "../api/client" +import { MCP } from "../../mcp" +import { + addMcpToConfig, + removeMcpFromConfig, + listMcpInConfig, + resolveConfigPath, + findAllConfigPaths, +} from "../../mcp/config" +import { Instance } from "../../project/instance" +import { Global } from "../../global" + +/** Project root for config resolution — falls back to cwd when no git repo is detected. */ +function projectRoot() { + const wt = Instance.worktree + return wt === "/" ? Instance.directory : wt +} + +export function slugify(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") +} + +export const DatamateManagerTool = Tool.define("datamate_manager", { + description: + "Manage Altimate Datamates — AI teammates with integrations (Snowflake, Jira, dbt, etc). " + + "Operations: 'list' shows available datamates from the Altimate API, " + + "'list-integrations' shows available integrations and their tools/capabilities, " + + "'add' connects one as an MCP server and saves to config, " + + "'create' creates a new datamate then connects it (use 'list-integrations' first to find integration IDs), " + + "'edit' updates a datamate's config on the API, " + + "'delete' permanently removes a datamate from the API, " + + "'status' shows active datamate MCP servers in this session, " + + "'remove' disconnects a datamate MCP server and removes it from config, " + + "'list-config' shows all datamate entries saved in config files (project and global). " + + "Config files: project config is at /altimate-code.json, " + + "global config is at ~/.config/altimate-code/altimate-code.json. " + + "Datamate server names are prefixed with 'datamate-'. " + + "Do NOT use glob/grep/read to find config files — use 'list-config' instead.", + parameters: z.object({ + operation: z.enum(["list", "list-integrations", "add", "create", "edit", "delete", "status", "remove", "list-config"]), + datamate_id: z.string().optional().describe("Datamate ID (required for 'add', 'edit', 'delete')"), + name: z.string().optional().describe("Server name override for 'add', or name for 'create'/'edit'"), + description: z.string().optional().describe("Description (for 'create'/'edit')"), + integration_ids: z.array(z.string()).optional().describe("Integration IDs (for 'create'/'edit')"), + memory_enabled: z.boolean().optional().describe("Enable memory (for 'create'/'edit')"), + privacy: z.string().optional().describe("Privacy setting: 'private' or 'public' (for 'create'/'edit')"), + scope: z + .enum(["project", "global"]) + .optional() + .describe( + "Where to save/remove MCP config: 'project' (altimate-code.json in project root) or 'global' (~/.config/altimate-code/altimate-code.json). Ask the user which they prefer. Defaults to 'project'.", + ), + server_name: z + .string() + .optional() + .describe("Server name to remove (for 'remove'). Use 'list-config' or 'status' to find names."), + }), + async execute(args): Promise<{ title: string; metadata: Record; output: string }> { + if (args.operation !== "status" && args.operation !== "list-config") { + const configured = await AltimateApi.isConfigured() + if (!configured) { + return { + title: "Datamate: not configured", + metadata: {}, + output: + "Altimate credentials not found at ~/.altimate/altimate.json.\n\nUse the /altimate-setup skill to configure your credentials.", + } + } + } + + switch (args.operation) { + case "list": + return handleList() + case "list-integrations": + return handleListIntegrations() + case "add": + return handleAdd(args) + case "create": + return handleCreate(args) + case "edit": + return handleEdit(args) + case "delete": + return handleDelete(args) + case "status": + return handleStatus() + case "remove": + return handleRemove(args) + case "list-config": + return handleListConfig() + } + }, +}) + +async function handleList() { + try { + const datamates = await AltimateApi.listDatamates() + if (datamates.length === 0) { + return { + title: "Datamates: none found", + metadata: { count: 0 }, + output: "No datamates found. Use operation 'create' to create one.", + } + } + const lines = ["ID | Name | Description | Integrations | Privacy", "---|------|-------------|--------------|--------"] + for (const d of datamates) { + const integrations = d.integrations?.map((i: { id: string }) => i.id).join(", ") ?? "none" + lines.push(`${d.id} | ${d.name} | ${d.description ?? "-"} | ${integrations} | ${d.privacy ?? "-"}`) + } + return { + title: `Datamates: ${datamates.length} found`, + metadata: { count: datamates.length }, + output: lines.join("\n"), + } + } catch (e) { + return { + title: "Datamates: ERROR", + metadata: {}, + output: `Failed to list datamates: ${e instanceof Error ? e.message : String(e)}`, + } + } +} + +async function handleListIntegrations() { + try { + const integrations = await AltimateApi.listIntegrations() + if (integrations.length === 0) { + return { + title: "Integrations: none found", + metadata: { count: 0 }, + output: "No integrations available.", + } + } + const lines = ["ID | Name | Tools", "---|------|------"] + for (const i of integrations) { + const tools = i.tools?.map((t) => t.key).join(", ") ?? "none" + lines.push(`${i.id} | ${i.name} | ${tools}`) + } + return { + title: `Integrations: ${integrations.length} available`, + metadata: { count: integrations.length }, + output: lines.join("\n"), + } + } catch (e) { + return { + title: "Integrations: ERROR", + metadata: {}, + output: `Failed to list integrations: ${e instanceof Error ? e.message : String(e)}`, + } + } +} + +async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "project" | "global" }) { + if (!args.datamate_id) { + return { + title: "Datamate add: FAILED", + metadata: {}, + output: "Missing required parameter 'datamate_id'. Use 'list' first to see available datamates.", + } + } + try { + const creds = await AltimateApi.getCredentials() + const datamate = await AltimateApi.getDatamate(args.datamate_id) + const serverName = args.name ?? `datamate-${slugify(datamate.name)}` + const mcpConfig = AltimateApi.buildMcpConfig(creds, args.datamate_id) + + // Always save to config first so it persists for future sessions + const isGlobal = args.scope === "global" + const configPath = await resolveConfigPath(isGlobal ? Global.Path.config : projectRoot(), isGlobal) + await addMcpToConfig(serverName, mcpConfig, configPath) + + await MCP.add(serverName, mcpConfig) + + // Check connection status + const allStatus = await MCP.status() + const serverStatus = allStatus[serverName] + const connected = serverStatus?.status === "connected" + + if (!connected) { + return { + title: `Datamate '${datamate.name}': saved (connection pending)`, + metadata: { serverName, datamateId: args.datamate_id, configPath, status: serverStatus }, + output: `Saved datamate '${datamate.name}' (ID: ${args.datamate_id}) as MCP server '${serverName}' to ${configPath}.\n\nConnection status: ${serverStatus?.status ?? "unknown"}${serverStatus && "error" in serverStatus ? ` — ${serverStatus.error}` : ""}.\nIt will auto-connect on next session start.`, + } + } + + // Get tool count from the newly connected server + const mcpTools = await MCP.tools() + const toolCount = Object.keys(mcpTools).filter((k) => + k.startsWith(serverName.replace(/[^a-zA-Z0-9_-]/g, "_")), + ).length + + return { + title: `Datamate '${datamate.name}': connected as '${serverName}'`, + metadata: { serverName, datamateId: args.datamate_id, toolCount, configPath }, + output: `Connected datamate '${datamate.name}' (ID: ${args.datamate_id}) as MCP server '${serverName}'.\n\n${toolCount} tools are now available from this datamate. They will be usable in the next message.\n\nConfiguration saved to ${configPath} for future sessions.`, + } + } catch (e) { + return { + title: "Datamate add: ERROR", + metadata: {}, + output: `Failed to add datamate: ${e instanceof Error ? e.message : String(e)}`, + } + } +} + +async function handleCreate(args: { + name?: string + description?: string + integration_ids?: string[] + memory_enabled?: boolean + privacy?: string + scope?: "project" | "global" +}) { + if (!args.name) { + return { + title: "Datamate create: FAILED", + metadata: {}, + output: "Missing required parameter 'name'.", + } + } + try { + const integrations = args.integration_ids + ? await AltimateApi.resolveIntegrations(args.integration_ids) + : undefined + const created = await AltimateApi.createDatamate({ + name: args.name, + description: args.description, + integrations, + memory_enabled: args.memory_enabled ?? true, + privacy: args.privacy, + }) + return handleAdd({ datamate_id: created.id, name: `datamate-${slugify(args.name)}`, scope: args.scope }) + } catch (e) { + return { + title: "Datamate create: ERROR", + metadata: {}, + output: `Failed to create datamate: ${e instanceof Error ? e.message : String(e)}`, + } + } +} + +async function handleEdit(args: { + datamate_id?: string + name?: string + description?: string + integration_ids?: string[] + memory_enabled?: boolean + privacy?: string +}) { + if (!args.datamate_id) { + return { + title: "Datamate edit: FAILED", + metadata: {}, + output: "Missing required parameter 'datamate_id'. Use 'list' first to see available datamates.", + } + } + try { + const integrations = args.integration_ids + ? await AltimateApi.resolveIntegrations(args.integration_ids) + : undefined + const updated = await AltimateApi.updateDatamate(args.datamate_id, { + name: args.name, + description: args.description, + integrations, + memory_enabled: args.memory_enabled, + privacy: args.privacy, + }) + return { + title: `Datamate '${updated.name}': updated`, + metadata: { datamateId: args.datamate_id }, + output: `Updated datamate '${updated.name}' (ID: ${args.datamate_id}).\n\nIf this datamate is connected as an MCP server, use 'remove' then 'add' to refresh the connection with the new config.`, + } + } catch (e) { + return { + title: "Datamate edit: ERROR", + metadata: {}, + output: `Failed to edit datamate: ${e instanceof Error ? e.message : String(e)}`, + } + } +} + +async function handleDelete(args: { datamate_id?: string }) { + if (!args.datamate_id) { + return { + title: "Datamate delete: FAILED", + metadata: {}, + output: "Missing required parameter 'datamate_id'. Use 'list' first to see available datamates.", + } + } + try { + const datamate = await AltimateApi.getDatamate(args.datamate_id) + await AltimateApi.deleteDatamate(args.datamate_id) + + // Disconnect the specific MCP server for this datamate (not all datamate- servers) + const serverName = `datamate-${slugify(datamate.name)}` + const allStatus = await MCP.status() + const disconnected: string[] = [] + if (serverName in allStatus) { + try { + await MCP.remove(serverName) + disconnected.push(serverName) + } catch { + // Log but don't fail the delete operation + } + } + + // Remove from all config files + const configPaths = await findAllConfigPaths(projectRoot(), Global.Path.config) + const removedFrom: string[] = [] + for (const configPath of configPaths) { + if (await removeMcpFromConfig(serverName, configPath)) { + removedFrom.push(configPath) + } + } + + const parts = [`Deleted datamate '${datamate.name}' (ID: ${args.datamate_id}).`] + if (disconnected.length > 0) { + parts.push(`Disconnected servers: ${disconnected.join(", ")}.`) + } + if (removedFrom.length > 0) { + parts.push(`Removed from config: ${removedFrom.join(", ")}.`) + } else { + parts.push("No config entries found to remove.") + } + + return { + title: `Datamate '${datamate.name}': deleted`, + metadata: { datamateId: args.datamate_id, disconnected, removedFrom }, + output: parts.join("\n"), + } + } catch (e) { + return { + title: "Datamate delete: ERROR", + metadata: {}, + output: `Failed to delete datamate: ${e instanceof Error ? e.message : String(e)}`, + } + } +} + +async function handleStatus() { + try { + const allStatus = await MCP.status() + const datamateEntries = Object.entries(allStatus).filter(([name]) => name.startsWith("datamate-")) + if (datamateEntries.length === 0) { + return { + title: "Datamate servers: none active", + metadata: { count: 0 }, + output: + "No datamate MCP servers active in this session.\n\nUse 'list' then 'add' to connect a datamate, or 'list-config' to see saved configs.", + } + } + const lines = ["Server | Status", "-------|-------"] + for (const [name, s] of datamateEntries) { + lines.push(`${name} | ${s.status}`) + } + return { + title: `Datamate servers: ${datamateEntries.length} active`, + metadata: { count: datamateEntries.length }, + output: lines.join("\n"), + } + } catch (e) { + return { + title: "Datamate status: ERROR", + metadata: {}, + output: `Failed to get status: ${e instanceof Error ? e.message : String(e)}`, + } + } +} + +async function handleRemove(args: { server_name?: string; scope?: "project" | "global" }) { + if (!args.server_name) { + return { + title: "Datamate remove: FAILED", + metadata: {}, + output: + "Missing required parameter 'server_name'. Use 'status' to see active servers or 'list-config' to see saved configs.", + } + } + try { + // Fully remove from runtime state (disconnect + purge from MCP list) + await MCP.remove(args.server_name).catch(() => {}) + + // Remove from config files — when no scope specified, try both to avoid orphaned entries + const removed: string[] = [] + const scope = args.scope + if (!scope || scope === "global") { + const globalPath = await resolveConfigPath(Global.Path.config, true) + if (await removeMcpFromConfig(args.server_name, globalPath)) { + removed.push(globalPath) + } + } + if (!scope || scope === "project") { + const projectPath = await resolveConfigPath(projectRoot()) + if (await removeMcpFromConfig(args.server_name, projectPath)) { + removed.push(projectPath) + } + } + + const configMsg = + removed.length > 0 + ? `\n\nRemoved from config: ${removed.join(", ")}` + : "\n\nNo config entries found to remove." + + return { + title: `Datamate '${args.server_name}': removed`, + metadata: { removedFromConfigs: removed }, + output: `Disconnected and removed MCP server '${args.server_name}'.${configMsg}`, + } + } catch (e) { + return { + title: "Datamate remove: ERROR", + metadata: {}, + output: `Failed to remove: ${e instanceof Error ? e.message : String(e)}`, + } + } +} + +async function handleListConfig() { + try { + const configPaths = await findAllConfigPaths(projectRoot(), Global.Path.config) + if (configPaths.length === 0) { + return { + title: "Datamate config: no config files found", + metadata: {}, + output: `No config files found.\n\nProject config would be at: ${projectRoot()}/altimate-code.json\nGlobal config would be at: ${Global.Path.config}/altimate-code.json`, + } + } + + const lines: string[] = [] + let totalDatamates = 0 + + for (const configPath of configPaths) { + const mcpNames = await listMcpInConfig(configPath) + const datamateNames = mcpNames.filter((name) => name.startsWith("datamate-")) + const otherNames = mcpNames.filter((name) => !name.startsWith("datamate-")) + + lines.push(`**${configPath}**`) + if (datamateNames.length > 0) { + lines.push(` Datamate servers: ${datamateNames.join(", ")}`) + totalDatamates += datamateNames.length + } + if (otherNames.length > 0) { + lines.push(` Other MCP servers: ${otherNames.join(", ")}`) + } + if (mcpNames.length === 0) { + lines.push(" No MCP entries") + } + lines.push("") + } + + return { + title: `Datamate config: ${totalDatamates} datamate(s) across ${configPaths.length} file(s)`, + metadata: { configPaths, totalDatamates }, + output: lines.join("\n"), + } + } catch (e) { + return { + title: "Datamate config: ERROR", + metadata: {}, + output: `Failed to read configs: ${e instanceof Error ? e.message : String(e)}`, + } + } +} diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index 1c4bebcf11..ca9a87a320 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -12,9 +12,8 @@ import { Instance } from "../../project/instance" import { Installation } from "../../installation" import path from "path" import { Global } from "../../global" -import { modify, applyEdits } from "jsonc-parser" -import { Filesystem } from "../../util/filesystem" import { Bus } from "../../bus" +import { resolveConfigPath, addMcpToConfig, removeMcpFromConfig } from "../../mcp/config" function getAuthStatusIcon(status: MCP.AuthStatus): string { switch (status) { @@ -381,60 +380,6 @@ export const McpLogoutCommand = cmd({ }, }) -async function resolveConfigPath(baseDir: string, global = false) { - // Check for existing config files (prefer .jsonc over .json, check .opencode/ subdirectory too) - const candidates = [path.join(baseDir, "altimate-code.json"), path.join(baseDir, "altimate-code.jsonc")] - - if (!global) { - candidates.push(path.join(baseDir, ".opencode", "altimate-code.json"), path.join(baseDir, ".opencode", "altimate-code.jsonc")) - } - - for (const candidate of candidates) { - if (await Filesystem.exists(candidate)) { - return candidate - } - } - - // Default to altimate-code.json if none exist - return candidates[0] -} - -async function removeMcpFromConfig(name: string, configPath: string) { - if (!(await Filesystem.exists(configPath))) { - return false - } - - const text = await Filesystem.readText(configPath) - const edits = modify(text, ["mcp", name], undefined, { - formattingOptions: { tabSize: 2, insertSpaces: true }, - }) - - if (edits.length === 0) { - return false - } - - const result = applyEdits(text, edits) - await Filesystem.write(configPath, result) - return true -} - -async function addMcpToConfig(name: string, mcpConfig: Config.Mcp, configPath: string) { - let text = "{}" - if (await Filesystem.exists(configPath)) { - text = await Filesystem.readText(configPath) - } - - // Use jsonc-parser to modify while preserving comments - const edits = modify(text, ["mcp", name], mcpConfig, { - formattingOptions: { tabSize: 2, insertSpaces: true }, - }) - const result = applyEdits(text, edits) - - await Filesystem.write(configPath, result) - - return configPath -} - export const McpAddCommand = cmd({ command: "add", describe: "add an MCP server", @@ -510,7 +455,7 @@ export const McpAddCommand = cmd({ return } - // Interactive mode: existing behavior + // Interactive mode UI.empty() prompts.intro("Add MCP server") @@ -694,7 +639,6 @@ export const McpRemoveCommand = cmd({ if (removed) { console.log(`MCP server "${args.name}" removed from ${configPath}`) } else if (Instance.project.vcs === "git" && !args.global) { - // Try global scope as fallback only when in a git repo const globalPath = await resolveConfigPath(Global.Path.config, true) const removedGlobal = await removeMcpFromConfig(args.name, globalPath) if (removedGlobal) { @@ -704,7 +648,6 @@ export const McpRemoveCommand = cmd({ process.exit(1) } } else if (args.global && Instance.project.vcs === "git") { - // Try local scope as fallback when --global was explicit and we're in a git repo const localPath = await resolveConfigPath(Instance.worktree, false) const removedLocal = await removeMcpFromConfig(args.name, localPath) if (removedLocal) { diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 997f3fa9c4..78a6e24b40 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -336,6 +336,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ break } + case "mcp.tools.changed": { + sdk.client.mcp.status().then((x) => setStore("mcp", reconcile(x.data!))).catch(() => {}) + break + } + case "vcs.branch.updated": { setStore("vcs", { branch: event.properties.branch }) break diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 7b96af9903..90c260b7d3 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -145,7 +145,9 @@ export namespace Config { // altimate_change start - support both .altimate-code and .opencode config dirs if (dir.endsWith(".altimate-code") || dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) { // altimate_change end - for (const file of ["opencode.jsonc", "opencode.json"]) { + // altimate_change start - support altimate-code.json config filename + for (const file of ["altimate-code.json", "opencode.jsonc", "opencode.json"]) { + // altimate_change end log.debug(`loading config from ${path.join(dir, file)}`) result = mergeConfigConcatArrays(result, await loadFile(path.join(dir, file))) // to satisfy the type checker @@ -185,7 +187,9 @@ export namespace Config { // which would fail on system directories requiring elevated permissions // This way it only loads config file and not skills/plugins/commands if (existsSync(managedDir)) { - for (const file of ["opencode.jsonc", "opencode.json"]) { + // altimate_change start - support altimate-code.json config filename + for (const file of ["altimate-code.json", "opencode.jsonc", "opencode.json"]) { + // altimate_change end result = mergeConfigConcatArrays(result, await loadFile(path.join(managedDir, file))) } } @@ -1184,6 +1188,9 @@ export namespace Config { mergeDeep(await loadFile(path.join(Global.Path.config, "config.json"))), mergeDeep(await loadFile(path.join(Global.Path.config, "opencode.json"))), mergeDeep(await loadFile(path.join(Global.Path.config, "opencode.jsonc"))), + // altimate_change start - support altimate-code.json config filename + mergeDeep(await loadFile(path.join(Global.Path.config, "altimate-code.json"))), + // altimate_change end ) const legacy = path.join(Global.Path.config, "config") @@ -1297,9 +1304,11 @@ export namespace Config { } function globalConfigFile() { - const candidates = ["opencode.jsonc", "opencode.json", "config.json"].map((file) => + // altimate_change start - support altimate-code.json config filename + const candidates = ["altimate-code.json", "opencode.jsonc", "opencode.json", "config.json"].map((file) => path.join(Global.Path.config, file), ) + // altimate_change end for (const file of candidates) { if (existsSync(file)) return file } diff --git a/packages/opencode/src/mcp/config.ts b/packages/opencode/src/mcp/config.ts new file mode 100644 index 0000000000..b8d3b063b5 --- /dev/null +++ b/packages/opencode/src/mcp/config.ts @@ -0,0 +1,99 @@ +import path from "path" +import { modify, applyEdits, parseTree, findNodeAtLocation } from "jsonc-parser" +import { Filesystem } from "../util/filesystem" +import type { Config } from "../config/config" + +const CONFIG_FILENAMES = ["altimate-code.json", "opencode.json", "opencode.jsonc"] + +export async function resolveConfigPath(baseDir: string, global = false) { + const candidates: string[] = [] + + if (!global) { + // Check subdirectory configs first — that's where existing project configs typically live + candidates.push( + ...CONFIG_FILENAMES.map((f) => path.join(baseDir, ".altimate-code", f)), + ...CONFIG_FILENAMES.map((f) => path.join(baseDir, ".opencode", f)), + ) + } + + // Then check root-level configs + candidates.push(...CONFIG_FILENAMES.map((f) => path.join(baseDir, f))) + + for (const candidate of candidates) { + if (await Filesystem.exists(candidate)) { + return candidate + } + } + + return candidates[0] +} + +export async function addMcpToConfig(name: string, mcpConfig: Config.Mcp, configPath: string) { + let text = "{}" + if (await Filesystem.exists(configPath)) { + text = await Filesystem.readText(configPath) + } + + const edits = modify(text, ["mcp", name], mcpConfig, { + formattingOptions: { tabSize: 2, insertSpaces: true }, + }) + const result = applyEdits(text, edits) + + await Filesystem.write(configPath, result) + + return configPath +} + +export async function removeMcpFromConfig(name: string, configPath: string): Promise { + if (!(await Filesystem.exists(configPath))) return false + + const text = await Filesystem.readText(configPath) + const tree = parseTree(text) + if (!tree) return false + + const node = findNodeAtLocation(tree, ["mcp", name]) + if (!node) return false + + const edits = modify(text, ["mcp", name], undefined, { + formattingOptions: { tabSize: 2, insertSpaces: true }, + }) + const result = applyEdits(text, edits) + await Filesystem.write(configPath, result) + return true +} + +export async function listMcpInConfig(configPath: string): Promise { + if (!(await Filesystem.exists(configPath))) return [] + + const text = await Filesystem.readText(configPath) + const tree = parseTree(text) + if (!tree) return [] + + const mcpNode = findNodeAtLocation(tree, ["mcp"]) + if (!mcpNode || mcpNode.type !== "object" || !mcpNode.children) return [] + + return mcpNode.children + .filter((child) => child.type === "property" && child.children?.[0]) + .map((child) => child.children![0].value as string) +} + +/** Find all config files that exist (project + global) */ +export async function findAllConfigPaths(projectDir: string, globalDir: string): Promise { + const paths: string[] = [] + for (const dir of [projectDir, globalDir]) { + for (const name of CONFIG_FILENAMES) { + const p = path.join(dir, name) + if (await Filesystem.exists(p)) paths.push(p) + } + // Also check .altimate-code and .opencode subdirectories for project + if (dir === projectDir) { + for (const subdir of [".altimate-code", ".opencode"]) { + for (const name of CONFIG_FILENAMES) { + const p = path.join(dir, subdir, name) + if (await Filesystem.exists(p)) paths.push(p) + } + } + } + } + return paths +} diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index e1d7600c14..2a24aa6c73 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -276,12 +276,14 @@ export namespace MCP { error: "unknown error", } s.status[name] = status + Bus.publish(ToolsChanged, { server: name }) return { status, } } if (!result.mcpClient) { s.status[name] = result.status + Bus.publish(ToolsChanged, { server: name }) return { status: s.status, } @@ -297,6 +299,8 @@ export namespace MCP { s.status[name] = result.status if (result.transport) s.transports[name] = result.transport + Bus.publish(ToolsChanged, { server: name }) + return { status: s.status, } @@ -595,6 +599,13 @@ export namespace MCP { result[key] = s.status[key] ?? { status: "disabled" } } + // Include dynamically added servers not yet in cached config + for (const [key, st] of Object.entries(s.status)) { + if (!(key in result)) { + result[key] = st + } + } + return result } @@ -664,6 +675,14 @@ export namespace MCP { s.status[name] = { status: "disabled" } } + /** Fully remove a dynamically-added MCP server — disconnects, and purges from runtime state. */ + export async function remove(name: string) { + await disconnect(name) + const s = await state() + delete s.status[name] + Bus.publish(ToolsChanged, { server: name }) + } + export async function tools() { const result: Record = {} const s = await state() diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 3f93520678..684ff4376e 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -99,6 +99,7 @@ import { AltimateCoreIntrospectionSqlTool } from "../altimate/tools/altimate-cor import { AltimateCoreParseDbtTool } from "../altimate/tools/altimate-core-parse-dbt" import { AltimateCoreIsSafeTool } from "../altimate/tools/altimate-core-is-safe" import { ProjectScanTool } from "../altimate/tools/project-scan" +import { DatamateManagerTool } from "../altimate/tools/datamate" // altimate_change end export namespace ToolRegistry { @@ -261,6 +262,7 @@ export namespace ToolRegistry { AltimateCoreParseDbtTool, AltimateCoreIsSafeTool, ProjectScanTool, + DatamateManagerTool, // altimate_change end ...custom, ] diff --git a/packages/opencode/test/altimate/datamate.test.ts b/packages/opencode/test/altimate/datamate.test.ts new file mode 100644 index 0000000000..74614efe43 --- /dev/null +++ b/packages/opencode/test/altimate/datamate.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test" +import path from "path" +import os from "os" +import fsp from "fs/promises" + +import { AltimateApi } from "../../src/altimate/api/client" +import { slugify } from "../../src/altimate/tools/datamate" + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const tmpRoot = path.join(os.tmpdir(), "datamate-test-" + process.pid + "-" + Math.random().toString(36).slice(2)) + +// --------------------------------------------------------------------------- +// buildMcpConfig +// --------------------------------------------------------------------------- + +describe("buildMcpConfig", () => { + const creds = { + altimateUrl: "https://api.getaltimate.com", + altimateInstanceName: "megatenant", + altimateApiKey: "test-api-key-123", + } + + test("returns correct shape with 4 headers", () => { + const config = AltimateApi.buildMcpConfig(creds, "42") + expect(config.type).toBe("remote") + expect(config.headers).toBeDefined() + expect(Object.keys(config.headers)).toHaveLength(4) + expect(config.headers["Authorization"]).toBe("Bearer test-api-key-123") + expect(config.headers["x-datamate-id"]).toBe("42") + expect(config.headers["x-tenant"]).toBe("megatenant") + expect(config.headers["x-altimate-url"]).toBe("https://api.getaltimate.com") + }) + + test("uses default MCP URL when mcpServerUrl not set", () => { + const config = AltimateApi.buildMcpConfig(creds, "1") + expect(config.url).toBe("https://mcpserver.getaltimate.com/sse") + }) + + test("uses override MCP URL when mcpServerUrl set", () => { + const credsWithUrl = { ...creds, mcpServerUrl: "https://custom.example.com/sse" } + const config = AltimateApi.buildMcpConfig(credsWithUrl, "1") + expect(config.url).toBe("https://custom.example.com/sse") + }) + + test("sets oauth to false", () => { + const config = AltimateApi.buildMcpConfig(creds, "1") + expect(config.oauth).toBe(false) + }) + + test("coerces datamate ID to string", () => { + const config = AltimateApi.buildMcpConfig(creds, "123") + expect(config.headers["x-datamate-id"]).toBe("123") + expect(typeof config.headers["x-datamate-id"]).toBe("string") + }) +}) + +// --------------------------------------------------------------------------- +// credentialsPath +// --------------------------------------------------------------------------- + +describe("credentialsPath", () => { + test("returns path under home directory", () => { + const p = AltimateApi.credentialsPath() + expect(p).toContain(".altimate") + expect(p).toContain("altimate.json") + expect(p.endsWith(path.join(".altimate", "altimate.json"))).toBe(true) + }) +}) + +// --------------------------------------------------------------------------- +// getCredentials +// --------------------------------------------------------------------------- + +describe("getCredentials", () => { + const testHome = path.join(tmpRoot, "creds-test") + + beforeEach(async () => { + process.env.OPENCODE_TEST_HOME = testHome + await fsp.mkdir(testHome, { recursive: true }) + }) + + afterEach(async () => { + delete process.env.OPENCODE_TEST_HOME + await fsp.rm(testHome, { recursive: true, force: true }).catch(() => {}) + }) + + test("throws when file missing", async () => { + await expect(AltimateApi.getCredentials()).rejects.toThrow("credentials not found") + }) + + test("parses valid file", async () => { + const altDir = path.join(testHome, ".altimate") + await fsp.mkdir(altDir, { recursive: true }) + await fsp.writeFile( + path.join(altDir, "altimate.json"), + JSON.stringify({ + altimateUrl: "https://api.test.com", + altimateInstanceName: "testco", + altimateApiKey: "key123", + }), + ) + const creds = await AltimateApi.getCredentials() + expect(creds.altimateUrl).toBe("https://api.test.com") + expect(creds.altimateInstanceName).toBe("testco") + expect(creds.altimateApiKey).toBe("key123") + }) + + test("throws on malformed JSON", async () => { + const altDir = path.join(testHome, ".altimate") + await fsp.mkdir(altDir, { recursive: true }) + await fsp.writeFile(path.join(altDir, "altimate.json"), "not json") + await expect(AltimateApi.getCredentials()).rejects.toThrow() + }) + + test("throws on missing required fields", async () => { + const altDir = path.join(testHome, ".altimate") + await fsp.mkdir(altDir, { recursive: true }) + await fsp.writeFile( + path.join(altDir, "altimate.json"), + JSON.stringify({ altimateUrl: "https://api.test.com" }), + ) + await expect(AltimateApi.getCredentials()).rejects.toThrow() + }) +}) + +// --------------------------------------------------------------------------- +// slugify +// --------------------------------------------------------------------------- + +describe("slugify", () => { + test("converts spaces and special chars to hyphens", () => { + expect(slugify("My SQL Expert!")).toBe("my-sql-expert") + }) + + test("lowercases", () => { + expect(slugify("TestName")).toBe("testname") + }) + + test("strips leading/trailing hyphens", () => { + expect(slugify("--hello--")).toBe("hello") + }) + + test("collapses multiple special chars", () => { + expect(slugify("a b...c")).toBe("a-b-c") + }) +}) + +// --------------------------------------------------------------------------- +// Cleanup +// --------------------------------------------------------------------------- + +afterEach(async () => { + await fsp.rm(tmpRoot, { recursive: true, force: true }).catch(() => {}) +})