diff --git a/docusaurus/docs/features/cli-runners.md b/docusaurus/docs/features/cli-runners.md index fb26a7a1..772e57db 100644 --- a/docusaurus/docs/features/cli-runners.md +++ b/docusaurus/docs/features/cli-runners.md @@ -1,14 +1,14 @@ --- sidebar_position: 5 title: CLI Runners -description: Execute spec tasks via external CLI tools like Claude Code, GitHub Copilot CLI, OpenAI Codex CLI, or Google Gemini CLI. DevoxxGenie manages the task lifecycle while your preferred tool does the implementation. -keywords: [devoxxgenie, cli runners, claude code, copilot, codex, gemini, cli tools, spec-driven development, sdd, agent loop] +description: Execute spec tasks via external CLI tools like Claude Code, GitHub Copilot CLI, OpenAI Codex CLI, Google Gemini CLI, or Kimi CLI. DevoxxGenie manages the task lifecycle while your preferred tool does the implementation. +keywords: [devoxxgenie, cli runners, claude code, copilot, codex, gemini, kimi, cli tools, spec-driven development, sdd, agent loop] image: /img/devoxxgenie-social-card.jpg --- # CLI Runners -Instead of using the built-in LLM provider, you can execute spec tasks via **external CLI tools** — such as Claude Code, GitHub Copilot CLI, OpenAI Codex CLI, or Google Gemini CLI. This lets you leverage the tool you already use and trust, while DevoxxGenie manages the task lifecycle. +Instead of using the built-in LLM provider, you can execute spec tasks via **external CLI tools** — such as Claude Code, GitHub Copilot CLI, OpenAI Codex CLI, Google Gemini CLI, or Kimi CLI. This lets you leverage the tool you already use and trust, while DevoxxGenie manages the task lifecycle. CLI Runners integrate with both [Spec-driven Development](spec-driven-development.md) (single task execution) and the [Agent Loop](sdd-agent-loop.md) (batch task execution). @@ -35,6 +35,7 @@ Each CLI tool is launched as an external process with your task prompt piped in. | **GitHub Copilot** | stdin | Auto-generated `--additional-mcp-config` (with `@` prefix) | `--allow-all` | | **OpenAI Codex** | Trailing argument | Not supported | `exec --model gpt-5.3-codex --full-auto` | | **Google Gemini** | stdin | Auto-generated `--mcp-config` | *(none)* | +| **Kimi** | `--prompt` flag | Auto-generated `--mcp-config-file` | `--yolo` | | **Custom** | stdin | Configurable | *(user-defined)* | :::note @@ -61,7 +62,7 @@ You can configure **multiple CLI tools** — for example, Claude for complex tas The **DevoxxGenie Specs** toolbar contains an execution mode dropdown: - **LLM Provider** — uses the built-in LLM agent (default) -- **CLI: Claude** / **CLI: Copilot** / etc. — uses the configured external CLI tool +- **CLI: Claude** / **CLI: Copilot** / **CLI: Kimi** / etc. — uses the configured external CLI tool The selection is persisted across IDE restarts. When you click **Run Selected** or **Run All To Do**, tasks are executed using whichever mode is selected. @@ -71,7 +72,7 @@ The selection is persisted across IDE restarts. When you click **Run Selected** | Field | Description | |-------|-------------| -| **Type** | Preset type (Claude, Copilot, Codex, Gemini, Custom). Selecting a type auto-fills the other fields. | +| **Type** | Preset type (Claude, Copilot, Codex, Gemini, Kimi, Custom). Selecting a type auto-fills the other fields. | | **Executable path** | Absolute path to the CLI binary (e.g., `/opt/homebrew/bin/claude`) | | **Extra args** | Command-line arguments passed to the CLI. These are split on whitespace — no shell quoting needed. | | **Env vars** | Optional environment variables as `KEY=VALUE, KEY2=VALUE2`. Useful for API keys not inherited from the shell. | diff --git a/src/main/java/com/devoxx/genie/model/spec/CliToolConfig.java b/src/main/java/com/devoxx/genie/model/spec/CliToolConfig.java index e51e90ee..6c86c1b7 100644 --- a/src/main/java/com/devoxx/genie/model/spec/CliToolConfig.java +++ b/src/main/java/com/devoxx/genie/model/spec/CliToolConfig.java @@ -34,6 +34,7 @@ public enum CliType { CLAUDE("Claude"), CODEX("Codex"), GEMINI("Gemini"), + KIMI("Kimi"), CUSTOM("Custom"); private final String displayName; @@ -49,6 +50,7 @@ public enum CliType { case CLAUDE -> new ClaudeCliCommand(); case CODEX -> new CodexCliCommand(); case GEMINI -> new GeminiCliCommand(); + case KIMI -> new KimiCliCommand(); case CUSTOM -> new CustomCliCommand(); }; } diff --git a/src/main/java/com/devoxx/genie/service/cli/command/KimiCliCommand.java b/src/main/java/com/devoxx/genie/service/cli/command/KimiCliCommand.java new file mode 100644 index 00000000..4ee7d79e --- /dev/null +++ b/src/main/java/com/devoxx/genie/service/cli/command/KimiCliCommand.java @@ -0,0 +1,58 @@ +package com.devoxx.genie.service.cli.command; + +import com.devoxx.genie.model.spec.CliToolConfig; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** + * Kimi CLI: prompt passed via --prompt flag. + * Example: kimi --yolo --mcp-config-file mcp.json --prompt "the prompt here" + */ +public class KimiCliCommand extends AbstractCliCommand { + + @Override + public @NotNull List buildProcessCommand(@NotNull CliToolConfig config, + @NotNull String prompt, + @Nullable String mcpConfigPath) { + List command = new ArrayList<>(config.buildCommand()); + appendMcpConfig(command, config, mcpConfigPath); + // Kimi takes the prompt via --prompt flag + command.add("--prompt"); + command.add(prompt); + return command; + } + + @Override + public void writePrompt(@NotNull Process process, @NotNull String prompt) throws IOException { + // Prompt passed as --prompt argument — keep stdin open to avoid BrokenPipeError + } + + @Override + public boolean onTaskCompleted(@NotNull Process process) { + // Kimi enters interactive mode after processing — kill it when done + if (process.isAlive()) { + process.destroyForcibly(); + return true; + } + return false; + } + + @Override + public @NotNull String defaultExecutablePath() { + return "/opt/homebrew/bin/kimi"; + } + + @Override + public @NotNull String defaultExtraArgs() { + return "--yolo"; + } + + @Override + public @NotNull String defaultMcpConfigFlag() { + return "--mcp-config-file"; + } +} diff --git a/src/main/java/com/devoxx/genie/ui/settings/spec/SpecSettingsComponent.java b/src/main/java/com/devoxx/genie/ui/settings/spec/SpecSettingsComponent.java index 3f812889..8e00dee4 100644 --- a/src/main/java/com/devoxx/genie/ui/settings/spec/SpecSettingsComponent.java +++ b/src/main/java/com/devoxx/genie/ui/settings/spec/SpecSettingsComponent.java @@ -601,10 +601,14 @@ private void runTest() { com.intellij.openapi.application.ApplicationManager.getApplication().executeOnPooledThread(() -> { try { - // Build the actual command using the configured flags and pipe a test prompt via stdin - // This verifies authentication, not just installation + // Use the CliCommand abstraction to build command and deliver prompt + // This ensures tool-specific behavior (e.g., --prompt flag for Kimi) CliToolConfig testConfig = getResult(); - java.util.List command = testConfig.buildCommand(); + String testPrompt = "Respond with only: OK"; + CliToolConfig.CliType cliType = testConfig.getType() != null + ? testConfig.getType() : CliToolConfig.CliType.CUSTOM; + com.devoxx.genie.service.cli.command.CliCommand cliCommand = cliType.createCommand(); + java.util.List command = cliCommand.buildProcessCommand(testConfig, testPrompt, null); ProcessBuilder pb = new ProcessBuilder(command); pb.redirectErrorStream(false); @@ -619,12 +623,8 @@ private void runTest() { Process process = pb.start(); - // Pipe test prompt via stdin - try (var writer = new java.io.OutputStreamWriter( - process.getOutputStream(), java.nio.charset.StandardCharsets.UTF_8)) { - writer.write("Respond with only: OK"); - writer.flush(); - } + // Delegate prompt delivery to the command + cliCommand.writePrompt(process, testPrompt); // Read stdout and stderr in parallel StringBuilder stdout = new StringBuilder(); diff --git a/src/test/java/com/devoxx/genie/service/cli/command/KimiCliCommandIT.java b/src/test/java/com/devoxx/genie/service/cli/command/KimiCliCommandIT.java new file mode 100644 index 00000000..5e48ff1e --- /dev/null +++ b/src/test/java/com/devoxx/genie/service/cli/command/KimiCliCommandIT.java @@ -0,0 +1,119 @@ +package com.devoxx.genie.service.cli.command; + +import com.devoxx.genie.model.spec.CliToolConfig; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIf; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration test that verifies Kimi CLI can be launched via ProcessBuilder + * with the --prompt flag. Requires Kimi installed at /Users/stephan/.local/bin/kimi. + */ +class KimiCliCommandIT { + + private static final String KIMI_PATH = "/Users/stephan/.local/bin/kimi"; + + static boolean kimiInstalled() { + return new java.io.File(KIMI_PATH).canExecute(); + } + + @Test + @EnabledIf("kimiInstalled") + void testBuildProcessCommand() { + KimiCliCommand command = new KimiCliCommand(); + CliToolConfig config = CliToolConfig.builder() + .type(CliToolConfig.CliType.KIMI) + .executablePath(KIMI_PATH) + .extraArgs(List.of("--yolo")) + .mcpConfigFlag("--mcp-config-file") + .build(); + + List cmd = command.buildProcessCommand(config, "say hello", "/tmp/mcp.json"); + + assertThat(cmd).containsExactly( + KIMI_PATH, + "--yolo", + "--mcp-config-file", "/tmp/mcp.json", + "--prompt", "say hello" + ); + } + + @Test + @EnabledIf("kimiInstalled") + void testKimiProcessStartsWithPromptFlag() throws Exception { + KimiCliCommand command = new KimiCliCommand(); + CliToolConfig config = CliToolConfig.builder() + .type(CliToolConfig.CliType.KIMI) + .executablePath(KIMI_PATH) + .extraArgs(List.of("--yolo")) + .mcpConfigFlag("") + .build(); + + List cmd = command.buildProcessCommand(config, "Respond with only: OK", null); + + ProcessBuilder pb = new ProcessBuilder(cmd); + pb.redirectErrorStream(false); + pb.environment().putAll(System.getenv()); + + Process process = pb.start(); + + // Don't close stdin — Kimi crashes with BrokenPipeError if stdin is closed + command.writePrompt(process, "Respond with only: OK"); + + // Read first few lines of stdout + StringBuilder stdout = new StringBuilder(); + Thread stdoutReader = new Thread(() -> { + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + String line; + int count = 0; + while ((line = reader.readLine()) != null && count < 20) { + stdout.append(line).append("\n"); + count++; + } + } catch (Exception ignored) {} + }); + + StringBuilder stderr = new StringBuilder(); + Thread stderrReader = new Thread(() -> { + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8))) { + String line; + int count = 0; + while ((line = reader.readLine()) != null && count < 20) { + stderr.append(line).append("\n"); + count++; + } + } catch (Exception ignored) {} + }); + + stdoutReader.setDaemon(true); + stderrReader.setDaemon(true); + stdoutReader.start(); + stderrReader.start(); + + boolean exited = process.waitFor(60, TimeUnit.SECONDS); + if (!exited) { + process.destroyForcibly(); + } + + stdoutReader.join(3000); + stderrReader.join(3000); + + System.out.println("=== STDOUT ==="); + System.out.println(stdout); + System.out.println("=== STDERR ==="); + System.out.println(stderr); + System.out.println("=== EXIT CODE: " + (exited ? process.exitValue() : "TIMEOUT") + " ==="); + + // The process should not crash with BrokenPipeError + assertThat(stderr.toString()).doesNotContain("BrokenPipeError"); + } +}