diff --git a/cli/src/integrationTest/kotlin/com/composables/cli/CliIntegrationTest.kt b/cli/src/integrationTest/kotlin/com/composables/cli/CliIntegrationTest.kt index 601638f..17c1777 100644 --- a/cli/src/integrationTest/kotlin/com/composables/cli/CliIntegrationTest.kt +++ b/cli/src/integrationTest/kotlin/com/composables/cli/CliIntegrationTest.kt @@ -281,6 +281,31 @@ class CliIntegrationTest { } } + @Test + fun `cli mcp install writes cursor config`() { + val rootDir = createTempRoot("composables-cli-mcp-install-cursor") + try { + val launcher = installedLauncher() + + val result = + runProcess( + command = listOf(launcher.absolutePath, "mcp", "install", "--client", "cursor"), + workingDir = rootDir, + timeoutSeconds = 60, + ) + + assertThat(result.finished).isTrue() + assertThat(result.exitCode).isEqualTo(0) + assertThat(result.output).contains("Installed Composables MCP for Cursor.") + val content = File(rootDir, ".cursor/mcp.json").readText() + assertThat(content).contains(""""command": "composables"""") + assertThat(content).contains(""""mcp"""") + assertThat(content).contains(""""start"""") + } finally { + rootDir.deleteRecursively() + } + } + @Test fun `cli mcp start exposes tools and creates project over stdio`() { val rootDir = createTempRoot("composables-cli-mcp-server") diff --git a/cli/src/jvmMain/kotlin/Cli.kt b/cli/src/jvmMain/kotlin/Cli.kt index 363ecd3..c1a6887 100644 --- a/cli/src/jvmMain/kotlin/Cli.kt +++ b/cli/src/jvmMain/kotlin/Cli.kt @@ -26,6 +26,7 @@ import java.util.jar.JarFile import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonObject @@ -454,6 +455,7 @@ class McpInstall : CliktCommand("install") { .flag(default = false) override fun run() { + val workingDir = File(System.getProperty("user.dir")) when (client.trim().lowercase()) { "android-studio" -> { val configDir = @@ -475,7 +477,48 @@ class McpInstall : CliktCommand("install") { } } - else -> throw UsageError("Unsupported client '$client'. Available clients: android-studio") + "antigravity" -> { + val result = installAntigravityMcpServer(overwrite = overwrite) + echoInstallResult("Antigravity", result) + } + + "claude" -> { + val result = installClaudeCodeMcpServer(projectRoot = workingDir, overwrite = overwrite) + echoInstallResult("Claude Code", result) + } + + "codex" -> { + val result = installCodexMcpServer(projectRoot = workingDir, overwrite = overwrite) + echoInstallResult("Codex", result) + } + + "cursor" -> { + val result = installCursorMcpServer(projectRoot = workingDir, overwrite = overwrite) + echoInstallResult("Cursor", result) + } + + "opencode" -> { + val result = installOpenCodeMcpServer(projectRoot = workingDir, overwrite = overwrite) + echoInstallResult("OpenCode", result) + } + + else -> + throw UsageError( + "Unsupported client '$client'. Available clients: android-studio, antigravity, claude, codex, cursor, opencode") + } + } + + private fun echoInstallResult(clientName: String, result: McpInstallResult) { + when (result) { + is McpInstallResult.AlreadyInstalled -> { + echo("Composables MCP is already installed for $clientName.") + echo("Using ${result.configFile.absolutePath}") + } + + is McpInstallResult.Installed -> { + echo("Installed Composables MCP for $clientName.") + echo("Updated ${result.configFile.absolutePath}") + } } } } @@ -654,6 +697,38 @@ internal sealed interface AndroidStudioMcpInstallResult { data class AlreadyInstalled(override val configFile: File) : AndroidStudioMcpInstallResult } +internal sealed interface McpInstallResult { + val configFile: File + + data class Installed(override val configFile: File) : McpInstallResult + + data class AlreadyInstalled(override val configFile: File) : McpInstallResult +} + +private val composablesStdioJsonConfig: JsonObject + get() = buildJsonObject { + put("command", "composables") + put( + "args", + buildJsonArray { + add(JsonPrimitive("mcp")) + add(JsonPrimitive("start")) + }) + } + +private val composablesOpenCodeJsonConfig: JsonObject + get() = buildJsonObject { + put("type", "local") + put( + "command", + buildJsonArray { + add(JsonPrimitive("composables")) + add(JsonPrimitive("mcp")) + add(JsonPrimitive("start")) + }) + put("enabled", JsonPrimitive(true)) + } + internal fun installAndroidStudioMcpServer( configDir: File, serverUrl: String, @@ -708,6 +783,156 @@ internal fun installAndroidStudioMcpServer( return AndroidStudioMcpInstallResult.Installed(mcpConfigFile) } +internal fun installAntigravityMcpServer( + configFile: File = File(System.getProperty("user.home"), ".gemini/config/mcp_config.json"), + serverName: String = "composables", + overwrite: Boolean = false, +): McpInstallResult = + installJsonMcpServer( + configFile = configFile, + rootKey = "mcpServers", + serverName = serverName, + serverConfig = composablesStdioJsonConfig, + clientName = "Antigravity", + overwrite = overwrite, + ) + +internal fun installClaudeCodeMcpServer( + projectRoot: File, + serverName: String = "composables", + overwrite: Boolean = false, +): McpInstallResult = + installJsonMcpServer( + configFile = File(projectRoot, ".mcp.json"), + rootKey = "mcpServers", + serverName = serverName, + serverConfig = composablesStdioJsonConfig, + clientName = "Claude Code", + overwrite = overwrite, + ) + +internal fun installCursorMcpServer( + projectRoot: File, + serverName: String = "composables", + overwrite: Boolean = false, +): McpInstallResult = + installJsonMcpServer( + configFile = File(projectRoot, ".cursor/mcp.json"), + rootKey = "mcpServers", + serverName = serverName, + serverConfig = composablesStdioJsonConfig, + clientName = "Cursor", + overwrite = overwrite, + ) + +internal fun installOpenCodeMcpServer( + projectRoot: File, + serverName: String = "composables", + overwrite: Boolean = false, +): McpInstallResult = + installJsonMcpServer( + configFile = File(projectRoot, "opencode.json"), + rootKey = "mcp", + serverName = serverName, + serverConfig = composablesOpenCodeJsonConfig, + clientName = "OpenCode", + overwrite = overwrite, + defaultRoot = + buildJsonObject { put("\$schema", JsonPrimitive("https://opencode.ai/config.json")) }, + ) + +private fun installJsonMcpServer( + configFile: File, + rootKey: String, + serverName: String, + serverConfig: JsonObject, + clientName: String, + overwrite: Boolean, + defaultRoot: JsonObject = JsonObject(emptyMap()), +): McpInstallResult { + ensureConfigParentDirectory(configFile) + val existingRoot = readJsonObjectOrEmpty(configFile) + val baseRoot = buildJsonObject { + defaultRoot.forEach { (key, value) -> put(key, value) } + existingRoot.forEach { (key, value) -> put(key, value) } + } + val existingServers = + readNestedJsonObject(baseRoot, rootKey, configFile) ?: JsonObject(emptyMap()) + val existingServer = existingServers[serverName]?.jsonObject + + if (existingServer == serverConfig) { + return McpInstallResult.AlreadyInstalled(configFile) + } + if (existingServer != null && !overwrite) { + throw UsageError( + "$clientName already has a '$serverName' MCP server configured. Use --overwrite to replace it.") + } + + val updatedServers = buildJsonObject { + existingServers.forEach { (key, value) -> put(key, value) } + put(serverName, serverConfig) + } + val updatedRoot = buildJsonObject { + baseRoot.forEach { (key, value) -> + if (key != rootKey) { + put(key, value) + } + } + put(rootKey, updatedServers) + } + + configFile.writeText(json.encodeToString(JsonObject.serializer(), updatedRoot) + "\n") + return McpInstallResult.Installed(configFile) +} + +internal fun installCodexMcpServer( + projectRoot: File, + serverName: String = "composables", + overwrite: Boolean = false, +): McpInstallResult { + val configFile = File(projectRoot, ".codex/config.toml") + ensureConfigParentDirectory(configFile) + val content = if (configFile.exists()) configFile.readText() else "" + val sectionHeader = "[mcp_servers.$serverName]" + val sectionRegex = Regex("""(?ms)^${Regex.escape(sectionHeader)}\s*\n.*?(?=^\[|\z)""") + val existingSection = sectionRegex.find(content)?.value?.trim() + val desiredSection = + """ + $sectionHeader + command = "composables" + args = ["mcp", "start"] + """ + .trimIndent() + + if (existingSection == desiredSection) { + return McpInstallResult.AlreadyInstalled(configFile) + } + if (existingSection != null && !overwrite) { + throw UsageError( + "Codex already has a '$serverName' MCP server configured. Use --overwrite to replace it.") + } + + val contentWithoutSection = sectionRegex.replace(content, "").trimEnd() + val updatedContent = + if (contentWithoutSection.isEmpty()) { + desiredSection + } else { + "$contentWithoutSection\n\n$desiredSection" + } + configFile.writeText("$updatedContent\n") + return McpInstallResult.Installed(configFile) +} + +private fun ensureConfigParentDirectory(configFile: File) { + val parent = configFile.parentFile ?: return + if (parent.exists() && !parent.isDirectory) { + throw UsageError("Config path ${parent.absolutePath} is not a directory.") + } + if (!parent.exists() && !parent.mkdirs()) { + throw UsageError("Failed to create config directory at ${parent.absolutePath}") + } +} + internal fun findAndroidStudioConfigDirectory(): File? { val override = System.getProperty("composables.androidStudio.configDir")?.trim().orEmpty() if (override.isNotEmpty()) { diff --git a/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt b/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt index d0f9c52..283392a 100644 --- a/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt +++ b/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt @@ -552,6 +552,136 @@ class CliTest { } } + @Test + fun `installAntigravityMcpServer writes shared mcp config`() { + withTempDir { targetDir -> + val configFile = File(targetDir, "mcp_config.json") + val result = installAntigravityMcpServer(configFile = configFile) + + val content = configFile.readText() + assertThat(result is McpInstallResult.Installed).isTrue() + assertThat(content).contains(""""mcpServers"""") + assertThat(content).contains(""""composables"""") + assertThat(content).contains(""""command": "composables"""") + assertThat(content).contains(""""mcp"""") + assertThat(content).contains(""""start"""") + } + } + + @Test + fun `installClaudeCodeMcpServer writes project mcp config`() { + withTempDir { projectRoot -> + val result = installClaudeCodeMcpServer(projectRoot = projectRoot) + val configFile = File(projectRoot, ".mcp.json") + + assertThat(result is McpInstallResult.Installed).isTrue() + assertThat(configFile).exists() + assertThat(configFile.readText()).contains(""""command": "composables"""") + } + } + + @Test + fun `installCursorMcpServer writes cursor mcp config`() { + withTempDir { projectRoot -> + val result = installCursorMcpServer(projectRoot = projectRoot) + val configFile = File(projectRoot, ".cursor/mcp.json") + + assertThat(result is McpInstallResult.Installed).isTrue() + assertThat(configFile).exists() + assertThat(configFile.readText()).contains(""""command": "composables"""") + } + } + + @Test + fun `installOpenCodeMcpServer writes opencode config`() { + withTempDir { projectRoot -> + val result = installOpenCodeMcpServer(projectRoot = projectRoot) + val configFile = File(projectRoot, "opencode.json") + val content = configFile.readText() + + assertThat(result is McpInstallResult.Installed).isTrue() + assertThat(content).contains(""""${'$'}schema": "https://opencode.ai/config.json"""") + assertThat(content).contains(""""mcp"""") + assertThat(content).contains(""""type": "local"""") + assertThat(content).contains(""""command": [""") + assertThat(content).contains(""""enabled": true""") + } + } + + @Test + fun `json mcp installers preserve existing servers and return already installed`() { + withTempDir { projectRoot -> + val configFile = + File(projectRoot, ".cursor/mcp.json").apply { + parentFile.mkdirs() + writeText( + """ + { + "mcpServers": { + "figma": { + "url": "https://mcp.figma.com/mcp" + } + } + } + """ + .trimIndent(), + ) + } + + val firstResult = installCursorMcpServer(projectRoot = projectRoot) + val secondResult = installCursorMcpServer(projectRoot = projectRoot) + val content = configFile.readText() + + assertThat(firstResult is McpInstallResult.Installed).isTrue() + assertThat(secondResult is McpInstallResult.AlreadyInstalled).isTrue() + assertThat(content).contains(""""figma"""") + assertThat(content).contains(""""composables"""") + } + } + + @Test + fun `installCodexMcpServer writes project toml config`() { + withTempDir { projectRoot -> + val result = installCodexMcpServer(projectRoot = projectRoot) + val configFile = File(projectRoot, ".codex/config.toml") + val content = configFile.readText() + + assertThat(result is McpInstallResult.Installed).isTrue() + assertThat(content).contains("[mcp_servers.composables]") + assertThat(content).contains("""command = "composables"""") + assertThat(content).contains("""args = ["mcp", "start"]""") + } + } + + @Test + fun `installCodexMcpServer preserves existing config and returns already installed`() { + withTempDir { projectRoot -> + val configFile = + File(projectRoot, ".codex/config.toml").apply { + parentFile.mkdirs() + writeText( + """ + model = "gpt-5.1-codex" + + [mcp_servers.other] + command = "other" + """ + .trimIndent() + "\n", + ) + } + + val firstResult = installCodexMcpServer(projectRoot = projectRoot) + val secondResult = installCodexMcpServer(projectRoot = projectRoot) + val content = configFile.readText() + + assertThat(firstResult is McpInstallResult.Installed).isTrue() + assertThat(secondResult is McpInstallResult.AlreadyInstalled).isTrue() + assertThat(content).contains("""model = "gpt-5.1-codex"""") + assertThat(content).contains("[mcp_servers.other]") + assertThat(content).contains("[mcp_servers.composables]") + } + } + @Test fun `findAndroidStudioConfigDirectory selects latest mac config directory`() { withTempDir { homeDir ->