diff --git a/cli/src/integrationTest/kotlin/com/composables/cli/CliIntegrationTest.kt b/cli/src/integrationTest/kotlin/com/composables/cli/CliIntegrationTest.kt index 17c1777..9389139 100644 --- a/cli/src/integrationTest/kotlin/com/composables/cli/CliIntegrationTest.kt +++ b/cli/src/integrationTest/kotlin/com/composables/cli/CliIntegrationTest.kt @@ -276,6 +276,8 @@ class CliIntegrationTest { assertThat(result.exitCode).isEqualTo(0) assertThat(result.output).contains("--client") assertThat(result.output).contains("--overwrite") + assertThat(result.output).contains("firebender") + assertThat(result.output).contains("zed") } finally { rootDir.deleteRecursively() } @@ -306,6 +308,70 @@ class CliIntegrationTest { } } + @Test + fun `cli mcp install writes firebender global config`() { + val rootDir = createTempRoot("composables-cli-mcp-install-firebender") + try { + val launcher = installedLauncher() + + val result = + runProcess( + command = listOf(launcher.absolutePath, "mcp", "install", "--client", "firebender"), + workingDir = rootDir, + extraEnvironment = + mapOf("JAVA_TOOL_OPTIONS" to "-Duser.home=${rootDir.absolutePath}"), + timeoutSeconds = 60, + ) + + assertThat(result.finished).isTrue() + assertThat(result.exitCode).isEqualTo(0) + assertThat(result.output).contains("Installed Composables MCP for Firebender.") + val content = File(rootDir, ".firebender/firebender.json").readText() + assertThat(content).contains(""""mcpServers"""") + assertThat(content).contains(""""command": "composables"""") + assertThat(content).contains(""""mcp"""") + assertThat(content).contains(""""start"""") + } finally { + rootDir.deleteRecursively() + } + } + + @Test + fun `cli mcp install writes zed global config`() { + val rootDir = createTempRoot("composables-cli-mcp-install-zed") + try { + val launcher = installedLauncher() + + val result = + runProcess( + command = + listOf( + launcher.absolutePath, + "mcp", + "install", + "--client", + "zed", + ), + workingDir = rootDir, + extraEnvironment = + mapOf( + "JAVA_TOOL_OPTIONS" to "-Duser.home=${rootDir.absolutePath} -Dos.name=Linux"), + timeoutSeconds = 60, + ) + + assertThat(result.finished).isTrue() + assertThat(result.exitCode).isEqualTo(0) + assertThat(result.output).contains("Installed Composables MCP for Zed.") + val content = File(rootDir, ".config/zed/settings.json").readText() + assertThat(content).contains(""""context_servers"""") + 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") @@ -984,12 +1050,14 @@ class CliIntegrationTest { command: List, workingDir: File, stdin: String = "", + extraEnvironment: Map = emptyMap(), timeoutSeconds: Long, ): ProcessResult { val process = ProcessBuilder(platformCommand(command)) .directory(workingDir) .redirectErrorStream(true) + .apply { environment().putAll(extraEnvironment) } .start() val output = StringBuilder() diff --git a/cli/src/jvmMain/kotlin/Cli.kt b/cli/src/jvmMain/kotlin/Cli.kt index c1a6887..7519d1a 100644 --- a/cli/src/jvmMain/kotlin/Cli.kt +++ b/cli/src/jvmMain/kotlin/Cli.kt @@ -442,6 +442,18 @@ class Mcp : CliktCommand("mcp") { } } +private val supportedMcpInstallClients = + listOf( + "android-studio", + "antigravity", + "claude", + "codex", + "cursor", + "firebender", + "opencode", + "zed", + ) + class McpInstall : CliktCommand("install") { override fun help(context: Context): String = """ @@ -449,7 +461,11 @@ class McpInstall : CliktCommand("install") { """ .trimIndent() - private val client by option("--client", help = "Client to install for").required() + private val client by + option( + "--client", + help = "Client to install for: ${supportedMcpInstallClients.joinToString()}") + .required() private val overwrite by option("--overwrite", help = "Overwrite an existing conflicting Composables MCP server entry") .flag(default = false) @@ -497,14 +513,24 @@ class McpInstall : CliktCommand("install") { echoInstallResult("Cursor", result) } + "firebender" -> { + val result = installFirebenderMcpServer(overwrite = overwrite) + echoInstallResult("Firebender", result) + } + "opencode" -> { val result = installOpenCodeMcpServer(projectRoot = workingDir, overwrite = overwrite) echoInstallResult("OpenCode", result) } + "zed" -> { + val result = installZedMcpServer(overwrite = overwrite) + echoInstallResult("Zed", result) + } + else -> throw UsageError( - "Unsupported client '$client'. Available clients: android-studio, antigravity, claude, codex, cursor, opencode") + "Unsupported client '$client'. Available clients: ${supportedMcpInstallClients.joinToString()}") } } @@ -841,6 +867,46 @@ internal fun installOpenCodeMcpServer( buildJsonObject { put("\$schema", JsonPrimitive("https://opencode.ai/config.json")) }, ) +internal fun installFirebenderMcpServer( + configFile: File = File(System.getProperty("user.home"), ".firebender/firebender.json"), + serverName: String = "composables", + overwrite: Boolean = false, +): McpInstallResult = + installJsonMcpServer( + configFile = configFile, + rootKey = "mcpServers", + serverName = serverName, + serverConfig = composablesStdioJsonConfig, + clientName = "Firebender", + overwrite = overwrite, + ) + +internal fun installZedMcpServer( + configFile: File = defaultZedSettingsFile(), + serverName: String = "composables", + overwrite: Boolean = false, +): McpInstallResult = + installJsonMcpServer( + configFile = configFile, + rootKey = "context_servers", + serverName = serverName, + serverConfig = composablesStdioJsonConfig, + clientName = "Zed", + overwrite = overwrite, + ) + +internal fun defaultZedSettingsFile(appDataPath: String? = System.getenv("APPDATA")): File { + val homeDir = File(System.getProperty("user.home")) + return when { + isWindows() -> { + val appData = appDataPath?.takeIf { it.isNotBlank() }?.let(::File) + File(appData ?: File(homeDir, "AppData/Roaming"), "Zed/settings.json") + } + isMacOs() -> File(homeDir, ".zed/settings.json") + else -> File(homeDir, ".config/zed/settings.json") + } +} + private fun installJsonMcpServer( configFile: File, rootKey: String, diff --git a/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt b/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt index 283392a..5ba12ac 100644 --- a/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt +++ b/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt @@ -608,6 +608,37 @@ class CliTest { } } + @Test + fun `installFirebenderMcpServer writes global firebender config`() { + withTempDir { targetDir -> + val configFile = File(targetDir, "firebender.json") + val result = installFirebenderMcpServer(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 `installZedMcpServer writes global zed context server config`() { + withTempDir { targetDir -> + val configFile = File(targetDir, "settings.json") + val result = installZedMcpServer(configFile = configFile) + val content = configFile.readText() + + assertThat(result is McpInstallResult.Installed).isTrue() + assertThat(content).contains(""""context_servers"""") + assertThat(content).contains(""""composables"""") + assertThat(content).contains(""""command": "composables"""") + assertThat(content).contains(""""args": [""") + } + } + @Test fun `json mcp installers preserve existing servers and return already installed`() { withTempDir { projectRoot -> @@ -708,6 +739,60 @@ class CliTest { } } + @Test + fun `installZedMcpServer uses mac global settings path`() { + withTempDir { homeDir -> + withOsName("Mac OS X") { + withUserHome(homeDir.absolutePath) { + val result = installZedMcpServer() + + assertThat(result.configFile).isEqualTo(File(homeDir, ".zed/settings.json")) + assertThat(result.configFile).exists() + } + } + } + } + + @Test + fun `installZedMcpServer uses linux global settings path`() { + withTempDir { homeDir -> + withOsName("Linux") { + withUserHome(homeDir.absolutePath) { + val result = installZedMcpServer() + + assertThat(result.configFile).isEqualTo(File(homeDir, ".config/zed/settings.json")) + assertThat(result.configFile).exists() + } + } + } + } + + @Test + fun `defaultZedSettingsFile uses windows appdata path`() { + withTempDir { homeDir -> + withOsName("Windows 11") { + withUserHome(homeDir.absolutePath) { + val appDataDir = File(homeDir, "AppData/Roaming") + + assertThat(defaultZedSettingsFile(appDataPath = appDataDir.absolutePath)) + .isEqualTo(File(appDataDir, "Zed/settings.json")) + } + } + } + } + + @Test + fun `defaultZedSettingsFile uses windows home fallback when appdata is missing`() { + withTempDir { homeDir -> + withOsName("Windows 11") { + withUserHome(homeDir.absolutePath) { + assertThat(defaultZedSettingsFile(appDataPath = null)) + .isEqualTo(File(homeDir, "AppData/Roaming/Zed/settings.json")) + } + } + } + } + private fun withTempDir(block: (File) -> Unit) { val dir = Files.createTempDirectory("composables-cli-test").toFile() try {