Skip to content
This repository was archived by the owner on Jul 5, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -984,12 +1050,14 @@ class CliIntegrationTest {
command: List<String>,
workingDir: File,
stdin: String = "",
extraEnvironment: Map<String, String> = emptyMap(),
timeoutSeconds: Long,
): ProcessResult {
val process =
ProcessBuilder(platformCommand(command))
.directory(workingDir)
.redirectErrorStream(true)
.apply { environment().putAll(extraEnvironment) }
.start()

val output = StringBuilder()
Expand Down
70 changes: 68 additions & 2 deletions cli/src/jvmMain/kotlin/Cli.kt
Original file line number Diff line number Diff line change
Expand Up @@ -442,14 +442,30 @@ 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 =
"""
Installs Composables MCP for supported clients.
"""
.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)
Expand Down Expand Up @@ -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()}")
}
}

Expand Down Expand Up @@ -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,
Expand Down
85 changes: 85 additions & 0 deletions cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand Down Expand Up @@ -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 {
Expand Down
Loading