diff --git a/cli/src/integrationTest/kotlin/com/composables/cli/CliIntegrationTest.kt b/cli/src/integrationTest/kotlin/com/composables/cli/CliIntegrationTest.kt index 9389139..eee53cf 100644 --- a/cli/src/integrationTest/kotlin/com/composables/cli/CliIntegrationTest.kt +++ b/cli/src/integrationTest/kotlin/com/composables/cli/CliIntegrationTest.kt @@ -61,6 +61,7 @@ class CliIntegrationTest { assertThat(createResult.output) .contains("${projectGradleScript()} :desktopApp:hotRunJvm --auto") assertJvmReadme(projectDir) + assertGeneratedProjectHasGitignore(projectDir) val compileResult = runProcess( @@ -288,11 +289,13 @@ class CliIntegrationTest { val rootDir = createTempRoot("composables-cli-mcp-install-cursor") try { val launcher = installedLauncher() + File(rootDir, "settings.gradle.kts").writeText("""pluginManagement {}""") + val nestedDir = File(rootDir, "features/chat").apply { mkdirs() } val result = runProcess( command = listOf(launcher.absolutePath, "mcp", "install", "--client", "cursor"), - workingDir = rootDir, + workingDir = nestedDir, timeoutSeconds = 60, ) @@ -309,24 +312,45 @@ class CliIntegrationTest { } @Test - fun `cli mcp install writes firebender global config`() { + fun `cli mcp install fails outside a Gradle project for project clients`() { + val rootDir = createTempRoot("composables-cli-mcp-install-no-project") + 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(1) + assertThat(result.output).contains("Could not find a Gradle project root") + assertThat(result.output).contains("Run this command from the root of your project") + } finally { + rootDir.deleteRecursively() + } + } + + @Test + fun `cli mcp install writes firebender project config`() { val rootDir = createTempRoot("composables-cli-mcp-install-firebender") try { val launcher = installedLauncher() + File(rootDir, "settings.gradle.kts").writeText("""pluginManagement {}""") 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() + val content = File(rootDir, "firebender.json").readText() assertThat(content).contains(""""mcpServers"""") assertThat(content).contains(""""command": "composables"""") assertThat(content).contains(""""mcp"""") @@ -337,10 +361,11 @@ class CliIntegrationTest { } @Test - fun `cli mcp install writes zed global config`() { + fun `cli mcp install writes zed project config`() { val rootDir = createTempRoot("composables-cli-mcp-install-zed") try { val launcher = installedLauncher() + File(rootDir, "settings.gradle.kts").writeText("""pluginManagement {}""") val result = runProcess( @@ -353,16 +378,13 @@ class CliIntegrationTest { "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() + val content = File(rootDir, ".zed/settings.json").readText() assertThat(content).contains(""""context_servers"""") assertThat(content).contains(""""command": "composables"""") assertThat(content).contains(""""mcp"""") @@ -1046,6 +1068,16 @@ class CliIntegrationTest { .contains("fun main() = singleWindowApplication(title = \"Sample App\") { App() }") } + private fun assertGeneratedProjectHasGitignore(projectDir: File) { + val gitignore = File(projectDir, ".gitignore") + assertThat(gitignore.exists()).isTrue() + + val content = gitignore.readText() + assertThat(content).contains(".idea") + assertThat(content).contains(".gradle") + assertThat(content).contains("**/build/") + } + private fun runProcess( command: List, workingDir: File, diff --git a/cli/src/jvmMain/kotlin/Cli.kt b/cli/src/jvmMain/kotlin/Cli.kt index 7519d1a..08c3c8a 100644 --- a/cli/src/jvmMain/kotlin/Cli.kt +++ b/cli/src/jvmMain/kotlin/Cli.kt @@ -494,37 +494,65 @@ class McpInstall : CliktCommand("install") { } "antigravity" -> { - val result = installAntigravityMcpServer(overwrite = overwrite) + val result = + installAntigravityMcpServer( + projectRoot = findRequiredMcpProjectRoot(workingDir), + overwrite = overwrite, + ) echoInstallResult("Antigravity", result) } "claude" -> { - val result = installClaudeCodeMcpServer(projectRoot = workingDir, overwrite = overwrite) + val result = + installClaudeCodeMcpServer( + projectRoot = findRequiredMcpProjectRoot(workingDir), + overwrite = overwrite, + ) echoInstallResult("Claude Code", result) } "codex" -> { - val result = installCodexMcpServer(projectRoot = workingDir, overwrite = overwrite) + val result = + installCodexMcpServer( + projectRoot = findRequiredMcpProjectRoot(workingDir), + overwrite = overwrite, + ) echoInstallResult("Codex", result) } "cursor" -> { - val result = installCursorMcpServer(projectRoot = workingDir, overwrite = overwrite) + val result = + installCursorMcpServer( + projectRoot = findRequiredMcpProjectRoot(workingDir), + overwrite = overwrite, + ) echoInstallResult("Cursor", result) } "firebender" -> { - val result = installFirebenderMcpServer(overwrite = overwrite) + val result = + installFirebenderMcpServer( + projectRoot = findRequiredMcpProjectRoot(workingDir), + overwrite = overwrite, + ) echoInstallResult("Firebender", result) } "opencode" -> { - val result = installOpenCodeMcpServer(projectRoot = workingDir, overwrite = overwrite) + val result = + installOpenCodeMcpServer( + projectRoot = findRequiredMcpProjectRoot(workingDir), + overwrite = overwrite, + ) echoInstallResult("OpenCode", result) } "zed" -> { - val result = installZedMcpServer(overwrite = overwrite) + val result = + installZedMcpServer( + projectRoot = findRequiredMcpProjectRoot(workingDir), + overwrite = overwrite, + ) echoInstallResult("Zed", result) } @@ -549,6 +577,22 @@ class McpInstall : CliktCommand("install") { } } +internal fun findGradleProjectRoot(start: File): File? { + var current: File? = start.absoluteFile + while (current != null) { + if (File(current, "settings.gradle.kts").isFile || File(current, "settings.gradle").isFile) { + return current + } + current = current.parentFile + } + return null +} + +private fun findRequiredMcpProjectRoot(start: File): File = + findGradleProjectRoot(start) + ?: throw UsageError( + "Could not find a Gradle project root. Run this command from the root of your project.") + class AddModule : CliktCommand("module") { override fun help(context: Context): String = """ @@ -810,12 +854,12 @@ internal fun installAndroidStudioMcpServer( } internal fun installAntigravityMcpServer( - configFile: File = File(System.getProperty("user.home"), ".gemini/config/mcp_config.json"), + projectRoot: File, serverName: String = "composables", overwrite: Boolean = false, ): McpInstallResult = installJsonMcpServer( - configFile = configFile, + configFile = File(projectRoot, ".agents/mcp_config.json"), rootKey = "mcpServers", serverName = serverName, serverConfig = composablesStdioJsonConfig, @@ -868,12 +912,12 @@ internal fun installOpenCodeMcpServer( ) internal fun installFirebenderMcpServer( - configFile: File = File(System.getProperty("user.home"), ".firebender/firebender.json"), + projectRoot: File, serverName: String = "composables", overwrite: Boolean = false, ): McpInstallResult = installJsonMcpServer( - configFile = configFile, + configFile = File(projectRoot, "firebender.json"), rootKey = "mcpServers", serverName = serverName, serverConfig = composablesStdioJsonConfig, @@ -882,12 +926,12 @@ internal fun installFirebenderMcpServer( ) internal fun installZedMcpServer( - configFile: File = defaultZedSettingsFile(), + projectRoot: File, serverName: String = "composables", overwrite: Boolean = false, ): McpInstallResult = installJsonMcpServer( - configFile = configFile, + configFile = File(projectRoot, ".zed/settings.json"), rootKey = "context_servers", serverName = serverName, serverConfig = composablesStdioJsonConfig, @@ -895,18 +939,6 @@ internal fun installZedMcpServer( 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/jvmMain/resources/project/.gitignore b/cli/src/jvmMain/resources/project/.gitignore index 6f6db68..adfa9bf 100644 --- a/cli/src/jvmMain/resources/project/.gitignore +++ b/cli/src/jvmMain/resources/project/.gitignore @@ -1,17 +1,19 @@ *.iml +.kotlin .gradle -.kotlin/ -/local.properties -/.idea +**/build/ +xcuserdata +!src/**/build/ +local.properties +.idea .DS_Store -kotlin-js-store/ -build/ -/captures +captures .externalNativeBuild .cxx -iosApp/Podfile.lock -iosApp/Pods/* -iosApp/iosApp.xcworkspace/* -iosApp/iosApp.xcodeproj/* -!iosApp/iosApp.xcodeproj/project.pbxproj -shared/shared.podspec \ No newline at end of file +*.xcodeproj/* +!*.xcodeproj/project.pbxproj +!*.xcodeproj/xcshareddata/ +!*.xcodeproj/project.xcworkspace/ +!*.xcworkspace/contents.xcworkspacedata +**/xcshareddata/WorkspaceSettings.xcsettings +node_modules/ diff --git a/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt b/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt index 5ba12ac..6cb3485 100644 --- a/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt +++ b/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt @@ -35,6 +35,7 @@ class CliTest { val desktopBuildFile = File(projectDir, "desktopApp/build.gradle.kts") val rootBuildFile = File(projectDir, "build.gradle.kts") val readmeFile = File(projectDir, "README.md") + val gitignoreFile = File(projectDir, ".gitignore") val settingsFile = File(projectDir, "settings.gradle.kts") val appFile = File(projectDir, "shared/src/commonMain/kotlin/com/composables/demo/App.kt") val desktopMainFile = @@ -46,6 +47,7 @@ class CliTest { assertThat(desktopBuildFile.exists(), "Generated desktop module build file should exist") .isTrue() assertThat(readmeFile.exists(), "Generated README should exist").isTrue() + assertThat(gitignoreFile.exists(), "Generated .gitignore should exist").isTrue() assertThat(settingsFile.exists(), "Generated settings file should exist").isTrue() assertThat(appFile.exists(), "App source should be moved to the requested package").isTrue() assertThat(desktopMainFile.exists(), "Desktop launcher source should exist").isTrue() @@ -72,6 +74,7 @@ class CliTest { val desktopMainContent = desktopMainFile.readText() val rootBuildContent = rootBuildFile.readText() val readmeContent = readmeFile.readText() + val gitignoreContent = gitignoreFile.readText() val settingsContent = settingsFile.readText() val appContent = appFile.readText() @@ -101,6 +104,9 @@ class CliTest { assertThat(readmeContent).doesNotContain(":androidApp:installDebug") assertThat(readmeContent).doesNotContain("iosApp/iosApp.xcodeproj") assertThat(readmeContent).doesNotContain(":webApp:wasmJsBrowserDevelopmentRun") + assertThat(gitignoreContent).contains(".idea") + assertThat(gitignoreContent).contains(".gradle") + assertThat(gitignoreContent).contains("**/build/") assertThat(appContent).contains("package com.composables.demo") assertThat(appContent).contains("import androidx.compose.ui.tooling.preview.Preview") @@ -553,10 +559,10 @@ class CliTest { } @Test - fun `installAntigravityMcpServer writes shared mcp config`() { - withTempDir { targetDir -> - val configFile = File(targetDir, "mcp_config.json") - val result = installAntigravityMcpServer(configFile = configFile) + fun `installAntigravityMcpServer writes workspace mcp config`() { + withTempDir { projectRoot -> + val result = installAntigravityMcpServer(projectRoot = projectRoot) + val configFile = File(projectRoot, ".agents/mcp_config.json") val content = configFile.readText() assertThat(result is McpInstallResult.Installed).isTrue() @@ -609,10 +615,10 @@ class CliTest { } @Test - fun `installFirebenderMcpServer writes global firebender config`() { - withTempDir { targetDir -> - val configFile = File(targetDir, "firebender.json") - val result = installFirebenderMcpServer(configFile = configFile) + fun `installFirebenderMcpServer writes project firebender config`() { + withTempDir { projectRoot -> + val result = installFirebenderMcpServer(projectRoot = projectRoot) + val configFile = File(projectRoot, "firebender.json") val content = configFile.readText() assertThat(result is McpInstallResult.Installed).isTrue() @@ -625,10 +631,10 @@ class CliTest { } @Test - fun `installZedMcpServer writes global zed context server config`() { - withTempDir { targetDir -> - val configFile = File(targetDir, "settings.json") - val result = installZedMcpServer(configFile = configFile) + fun `installZedMcpServer writes project zed context server config`() { + withTempDir { projectRoot -> + val result = installZedMcpServer(projectRoot = projectRoot) + val configFile = File(projectRoot, ".zed/settings.json") val content = configFile.readText() assertThat(result is McpInstallResult.Installed).isTrue() @@ -713,6 +719,21 @@ class CliTest { } } + @Test + fun `findGradleProjectRoot walks up to settings file`() { + withTempDir { projectRoot -> + File(projectRoot, "settings.gradle.kts").writeText("""pluginManagement {}""") + val nested = File(projectRoot, "feature/chat/src").apply { mkdirs() } + + assertThat(findGradleProjectRoot(nested)).isEqualTo(projectRoot.absoluteFile) + } + } + + @Test + fun `findGradleProjectRoot returns null outside a Gradle project`() { + withTempDir { targetDir -> assertThat(findGradleProjectRoot(targetDir)).isNull() } + } + @Test fun `findAndroidStudioConfigDirectory selects latest mac config directory`() { withTempDir { homeDir -> @@ -739,60 +760,6 @@ 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 { diff --git a/settings.gradle.kts b/settings.gradle.kts index 0a7d6ac..101d3ca 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,3 +1,7 @@ +import org.apache.tools.ant.DirectoryScanner + +DirectoryScanner.removeDefaultExclude("**/.gitignore") + enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") rootProject.name = "composables-cli"