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
774 changes: 774 additions & 0 deletions docs/command-mode-technical-deep-dive.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/optimization-backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,4 @@
| ~~X-02~~ | 流式输出无法中断 | PR #3 cancel stream + ESC | 2024 |
| ~~U-07~~ | 命令执行卡片展示顺序错误:执行结果在下、AI 响应在上,用户无法第一时间看到回复 | 引入 bridgeNotifiedStart 延迟 notifyStart,工具启用时气泡在 ExecutionCard 之后创建 | 2025-04-15 |
| ~~U-08~~ | 命令执行完成后卡片不自动折叠,占据大量空间;缺少展开/折叠按钮 | ExecutionCard 完成后默认折叠,提供点击展开 | 2025-04-15 |
| ~~A-05~~ | `CommandExecutionService` 硬编码 `sh -c`,仅支持 Unix;路径校验和默认白名单也是 Unix-only | 引入 `ShellPlatform` sealed class(Unix/Windows),封装 ProcessBuilder、路径校验、工具定义、白名单;ChatService 和 Settings 均委托给 `ShellPlatform.current()` | 2026-04-15 |
608 changes: 608 additions & 0 deletions docs/superpowers/plans/2026-04-15-cross-platform-command.md

Large diffs are not rendered by default.

182 changes: 182 additions & 0 deletions docs/superpowers/specs/2026-04-15-cross-platform-command-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
# Cross-Platform Command Execution Design

**Date:** 2026-04-15
**Scope:** `execution/`, `ChatService.kt`, `settings/PluginSettings.kt`, `settings/SettingsFormState.kt`

---

## 背景

`CommandExecutionService.kt:21,71` 硬编码 `ProcessBuilder("sh", "-c", command)`,仅支持 Unix/macOS。目标是支持 Unix/macOS + Windows,并建立统一抽象,后续跨平台扩展点全部走该抽象。

参考:Claude Code(TypeScript)采用双工具方案——`BashTool`(Unix)和 `PowerShellTool`(Windows),各自独立的工具定义、权限校验、系统提示。本设计遵循同一思路。

---

## 平台差异盘点

### 确认的差异点(全在 `CommandExecutionService.kt`)

| 行号 | 问题 | Unix | Windows |
|------|------|------|---------|
| `:21` `:71` | shell 调用方式 | `sh -c <cmd>` | `powershell -NoProfile -Command <cmd>` |
| `:136` | `extractBaseCommand` 路径分隔符 | `substringAfterLast('/')` | 需同时处理 `\` 和 `.exe` 后缀 |
| `:147–151` | `hasPathsOutsideWorkspace` 绝对路径检测 | `startsWith("/")`, `~/` 展开 | `C:\...` 或 `\\UNC`,无 `~/` |
| 默认白名单 | `SettingsState` / `SettingsFormState` | `ls`, `cat`, `grep`, `find`, `pwd` | `Get-ChildItem`, `Get-Content`, `Select-String` 等 |

### 排除的差异点

- `GenerateCommitMessageAction.kt:196` `ProcessBuilder("git", "diff", ...)` — 直接调可执行文件,不走 shell,**跨平台无问题**
- `SessionStore` — 使用 IntelliJ `PathManager` API,**已跨平台**
- `ApiKeyStore` — 使用 IntelliJ `PasswordSafe` API,**已跨平台**
- 网络层、UI 层 — 无平台相关代码

---

## 架构设计

### 核心抽象:`ShellPlatform`

```
┌─────────────────────────────────────────────────┐
│ ChatService │
│ runCommandToolDefinition() │
│ → ShellPlatform.current().toolDefinition() │
│ │
│ buildBaseSystemPrompt() │
│ → 追加 ShellPlatform.current().shellHint() │
└──────────────┬──────────────────────────────────┘
┌──────────────▼──────────────────────────────────┐
│ ShellPlatform (sealed class) │
│ ├── Unix → sh -c, BashTool 工具定义 │
│ └── Windows → powershell -Command, PS 工具定义 │
└──────────────┬──────────────────────────────────┘
┌──────────────▼──────────────────────────────────┐
│ CommandExecutionService │
│ → ShellPlatform.current().buildProcess(cmd, dir)│
│ → ShellPlatform.current().extractBaseCommand() │
│ → ShellPlatform.current().hasPathsOutside...() │
└─────────────────────────────────────────────────┘
```

### 新增文件

**`src/main/kotlin/com/github/codeplangui/execution/ShellPlatform.kt`**

```kotlin
sealed class ShellPlatform {
abstract fun buildProcess(command: String, workDir: File): ProcessBuilder
abstract fun hasPathsOutsideWorkspace(command: String, basePath: String): Boolean
abstract fun extractBaseCommand(command: String): String
abstract fun toolDefinition(): ToolDefinition
abstract fun shellHint(): String
abstract fun defaultWhitelist(): MutableList<String>

object Unix : ShellPlatform() {
override fun buildProcess(command: String, workDir: File) =
ProcessBuilder("sh", "-c", command).directory(workDir)

override fun extractBaseCommand(command: String): String {
val base = command.trimStart().split(" ", "|", ";", ">", "<", "&").first().trim()
return base.substringAfterLast('/')
}

override fun hasPathsOutsideWorkspace(command: String, basePath: String): Boolean {
val home = System.getProperty("user.home") ?: ""
return command.split("\\s+".toRegex()).any { token ->
if (token.startsWith('-')) return@any false
val expanded = if (token.startsWith("~/")) home + token.drop(1) else token
if (!expanded.startsWith('/')) return@any false
!expanded.startsWith(basePath)
}
}

override fun toolDefinition(): ToolDefinition = /* name="run_command", bash 描述 */

override fun shellHint() = "" // Unix 是默认,无需额外提示

override fun defaultWhitelist() = mutableListOf(
"cargo", "gradle", "mvn", "npm", "yarn", "pnpm",
"git", "ls", "cat", "grep", "find", "echo", "pwd"
)
}

object Windows : ShellPlatform() {
override fun buildProcess(command: String, workDir: File) =
ProcessBuilder("powershell", "-NoProfile", "-Command", command).directory(workDir)

override fun extractBaseCommand(command: String): String {
val base = command.trimStart().split(" ", "|", ";", ">", "<", "&", "&&").first().trim()
return base.substringAfterLast('\\').substringAfterLast('/').removeSuffix(".exe")
}

override fun hasPathsOutsideWorkspace(command: String, basePath: String): Boolean {
val normalizedBase = basePath.replace('/', '\\').trimEnd('\\')
return command.split("\\s+".toRegex()).any { token ->
if (token.startsWith('-')) return@any false
val normalized = token.replace('/', '\\')
val isAbsolute = normalized.matches(Regex("[A-Za-z]:\\\\.*")) || normalized.startsWith("\\\\")
if (!isAbsolute) return@any false
!normalized.startsWith(normalizedBase)
}
}

override fun toolDefinition(): ToolDefinition = /* name="run_powershell", PowerShell 描述 */

override fun shellHint() =
"\n当前运行在 Windows 环境,请使用 PowerShell 语法调用 run_powershell 工具。"

override fun defaultWhitelist() = mutableListOf(
"cargo", "gradle", "mvn", "npm", "yarn", "pnpm",
"git",
"Get-ChildItem", "Get-Content", "Select-String",
"Get-Location", "Write-Output", "Where-Object"
)
}

companion object {
fun current(): ShellPlatform =
if (System.getProperty("os.name").lowercase().contains("win")) Windows else Unix
}
}
```

### 修改文件清单

| 文件 | 改动 |
|------|------|
| `execution/CommandExecutionService.kt:21` | `ShellPlatform.current().buildProcess(command, File(basePath)).redirectErrorStream(false).start()` |
| `execution/CommandExecutionService.kt:71` | 同上 |
| `execution/CommandExecutionService.kt:136` | `ShellPlatform.current().extractBaseCommand(command)` |
| `execution/CommandExecutionService.kt:147–151` | `ShellPlatform.current().hasPathsOutsideWorkspace(command, basePath)` |
| `ChatService.kt` `runCommandToolDefinition()` | `ShellPlatform.current().toolDefinition()` |
| `ChatService.kt` `buildBaseSystemPrompt()` | 追加 `ShellPlatform.current().shellHint()` |
| `settings/SettingsState.kt` `commandWhitelist` 默认值 | `ShellPlatform.current().defaultWhitelist()` |
| `settings/SettingsFormState.kt` `commandWhitelist` 默认值 | `ShellPlatform.current().defaultWhitelist()` |

---

## 错误处理

- `ShellPlatform.current()` 纯计算,不抛异常
- `buildProcess()` 只组装 `ProcessBuilder`,不启动进程
- 所有执行失败路径由现有 `ExecutionResult` sealed class 覆盖,无新增错误路径

---

## 测试策略

- `ShellPlatform.Unix` / `ShellPlatform.Windows` 是 `object`,可在任意平台显式调用测试
- `extractBaseCommand` 和 `hasPathsOutsideWorkspace` 两个实现各自写纯函数单元测试
- `buildProcess` 不单独测试(仅参数组装)
- 现有 `executeAsyncWithStream` 集成测试只在 Unix CI 跑,Windows 执行路径靠 `ShellPlatform.Windows` 单元测试覆盖

---

## 不在本次范围内

- WSL / Git Bash 检测与 fallback
- Windows 上的 sandbox / 安全沙箱
- 命令语法翻译(Unix → PowerShell 自动转换)
40 changes: 7 additions & 33 deletions src/main/kotlin/com/github/codeplangui/ChatService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import com.github.codeplangui.api.OkHttpSseClient
import com.github.codeplangui.api.ToolCallAccumulator
import com.github.codeplangui.api.ToolCallDelta
import com.github.codeplangui.api.ToolDefinition
import com.github.codeplangui.api.FunctionDefinition
import com.github.codeplangui.execution.CommandExecutionService
import com.github.codeplangui.execution.ExecutionResult
import com.github.codeplangui.execution.ShellPlatform
import com.github.codeplangui.model.ChatSession
import com.github.codeplangui.model.Message
import com.github.codeplangui.model.MessageRole
Expand All @@ -30,9 +30,6 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
Expand Down Expand Up @@ -229,32 +226,8 @@ class ChatService(private val project: Project) : Disposable {
publishStatus()
}

private fun runCommandToolDefinition(): ToolDefinition = ToolDefinition(
type = "function",
function = FunctionDefinition(
name = "run_command",
description = "Execute a shell command in the project root directory. " +
"Only use when the user asks you to run something or when you need to " +
"inspect state to answer accurately.",
parameters = buildJsonObject {
put("type", "object")
put("properties", buildJsonObject {
put("command", buildJsonObject {
put("type", "string")
put("description", "The shell command to execute")
})
put("description", buildJsonObject {
put("type", "string")
put("description", "One-line explanation of why you are running this command")
})
})
put("required", buildJsonArray {
add(JsonPrimitive("command"))
add(JsonPrimitive("description"))
})
}
)
)
private fun runCommandToolDefinition(): ToolDefinition =
ShellPlatform.current().toolDefinition()

private fun handleToolCallChunk(delta: ToolCallDelta) {
toolCallAccumulator.append(delta)
Expand Down Expand Up @@ -511,7 +484,7 @@ $selection
PreparedToolCall(
index = accumulated.index,
id = toolCallId,
functionName = accumulated.functionName ?: "run_command",
functionName = accumulated.functionName ?: ShellPlatform.current().toolName(),
argumentsJson = argsJson,
command = command,
description = description
Expand Down Expand Up @@ -749,10 +722,11 @@ internal fun buildSelectionContextLabel(fileName: String?, lineCount: Int): Stri

internal fun buildBaseSystemPrompt(commandExecutionEnabled: Boolean = false): String =
if (commandExecutionEnabled) {
val platform = ShellPlatform.current()
"""
你是一个代码助手。请简洁准确地回答用户问题。
你拥有 run_command 工具,可以在用户项目根目录执行 shell 命令
当用户请求运行命令、查看文件、执行构建或测试时,主动调用该工具获取真实结果后再作答。
你拥有 ${platform.toolName()} 工具,可以在用户项目根目录执行命令
当用户请求运行命令、查看文件、执行构建或测试时,主动调用该工具获取真实结果后再作答。${platform.shellHint()}
""".trimIndent()
} else {
"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,7 @@ class CommandExecutionService(private val project: Project) {
?: return@withContext ExecutionResult.Blocked(command, "Project path unavailable")
val startMs = System.currentTimeMillis()

val process = ProcessBuilder("sh", "-c", command)
.directory(File(basePath))
val process = ShellPlatform.current().buildProcess(command, File(basePath))
.redirectErrorStream(false)
.start()

Expand Down Expand Up @@ -68,8 +67,7 @@ class CommandExecutionService(private val project: Project) {
?: return@withContext ExecutionResult.Blocked(command, "Project path unavailable")
val startMs = System.currentTimeMillis()

val process = ProcessBuilder("sh", "-c", command)
.directory(File(basePath))
val process = ShellPlatform.current().buildProcess(command, File(basePath))
.redirectErrorStream(false)
.start()

Expand Down Expand Up @@ -131,28 +129,17 @@ class CommandExecutionService(private val project: Project) {
}

companion object {
fun extractBaseCommand(command: String): String {
val stripped = command.trimStart()
val base = stripped.split(" ", "|", ";", ">", "<", "&").first().trim()
return base.substringAfterLast('/')
}
fun extractBaseCommand(command: String): String =
ShellPlatform.current().extractBaseCommand(command)

fun isWhitelisted(command: String, whitelist: List<String>): Boolean {
if (whitelist.isEmpty()) return false
val base = extractBaseCommand(command)
return whitelist.any { it == base }
}

fun hasPathsOutsideWorkspace(command: String, basePath: String): Boolean {
val tokens = command.split("\\s+".toRegex())
val home = System.getProperty("user.home") ?: ""
return tokens.any { token ->
if (token.startsWith('-')) return@any false
val expanded = if (token.startsWith("~/")) home + token.drop(1) else token
if (!expanded.startsWith('/')) return@any false
!expanded.startsWith(basePath)
}
}
fun hasPathsOutsideWorkspace(command: String, basePath: String): Boolean =
ShellPlatform.current().hasPathsOutsideWorkspace(command, basePath)

fun truncateOutput(text: String, maxChars: Int): String =
if (text.length <= maxChars) text else text.take(maxChars)
Expand Down
Loading
Loading