Skip to content
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
11 changes: 6 additions & 5 deletions docusaurus/docs/features/cli-runners.md
Original file line number Diff line number Diff line change
@@ -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).

Expand All @@ -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
Expand All @@ -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.

Expand All @@ -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. |
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/com/devoxx/genie/model/spec/CliToolConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public enum CliType {
CLAUDE("Claude"),
CODEX("Codex"),
GEMINI("Gemini"),
KIMI("Kimi"),
CUSTOM("Custom");

private final String displayName;
Expand All @@ -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();
};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> buildProcessCommand(@NotNull CliToolConfig config,
@NotNull String prompt,
@Nullable String mcpConfigPath) {
List<String> 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";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> 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<String> command = cliCommand.buildProcessCommand(testConfig, testPrompt, null);

ProcessBuilder pb = new ProcessBuilder(command);
pb.redirectErrorStream(false);
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> 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<String> 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");
}
}
Loading