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
10 changes: 10 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ async-trait = "0.1"
ort = { version = "=2.0.0-rc.10", features = ["load-dynamic", "ndarray"] }
ndarray = "0.16"
tokenizers = { version = "0.21", default-features = false, features = ["fancy-regex"] }
which = "8"
kiwi-rs = "0.1"
sha2 = "0.10"
tracing = "0.1"
Expand Down
1 change: 1 addition & 0 deletions crates/secall-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ async-trait.workspace = true
ort.workspace = true
ndarray.workspace = true
tokenizers.workspace = true
which.workspace = true
sha2.workspace = true
regex.workspace = true
futures-util.workspace = true
Expand Down
40 changes: 20 additions & 20 deletions crates/secall-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,27 +24,27 @@ pub async fn http_post_json(url: &str, body: &serde_json::Value) -> anyhow::Resu
Ok(())
}

/// 크로스플랫폼 명령어 존재 확인
pub fn command_exists(cmd: &str) -> bool {
#[cfg(target_os = "windows")]
let check = std::process::Command::new("where.exe")
.arg(cmd)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);

#[cfg(not(target_os = "windows"))]
let check = std::process::Command::new("which")
.arg(cmd)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
/// PATH (Windows 는 PATHEXT 포함) 에서 명령어의 실제 실행 파일 경로를 resolve 한다.
///
/// P87 (issue #92): Windows 에서 npm 으로 설치된 CLI (codex, claude 등) 는
/// `codex.cmd` 같은 배치 래퍼다. `std::process::Command::new("codex")` 는
/// PATHEXT 를 적용하지 않아 `.exe` 만 찾고 `.cmd` 는 "program not found" 로
/// 실패한다. `which` crate 는 PATHEXT 를 적용해 `.cmd`/`.bat`/`.exe` 를 모두
/// 탐색하므로 실제 경로를 얻을 수 있고, Rust 1.77+ 는 `.cmd`/`.bat` 확장자가
/// 포함된 경로를 `Command` 로 실행하면 cmd.exe 를 경유해 안전하게 실행한다.
///
/// resolve 실패 시 입력 문자열을 그대로 `PathBuf` 로 반환한다 (기존 동작 유지).
pub fn resolve_program(cmd: &str) -> std::path::PathBuf {
which::which(cmd).unwrap_or_else(|_| std::path::PathBuf::from(cmd))
}

check
/// 크로스플랫폼 명령어 존재 확인.
///
/// P87 (issue #92): 외부 `where.exe` / `which` 프로세스 호출 대신 `which` crate
/// 사용 — Windows PATHEXT (`.cmd` 등) 를 적용하고, spawn 시 쓰는 [`resolve_program`]
/// 과 동일한 탐색 규칙이라 "존재하지만 spawn 실패" 불일치를 제거한다.
pub fn command_exists(cmd: &str) -> bool {
which::which(cmd).is_ok()
}

#[cfg(test)]
Expand Down
2 changes: 1 addition & 1 deletion crates/secall-core/src/search/query_expand.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ pub fn expand_query(query: &str, db: Option<&Database>) -> Result<String> {
쿼리: {query}"
);

let output = std::process::Command::new("claude")
let output = std::process::Command::new(crate::resolve_program("claude"))
.args(["-p", &prompt, "--model", "claude-haiku-4-5-20251001"])
.output()?;

Expand Down
2 changes: 1 addition & 1 deletion crates/secall-core/src/wiki/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ impl WikiBackend for ClaudeBackend {
_ => "claude-sonnet-4-6",
};

let mut child = tokio::process::Command::new("claude")
let mut child = tokio::process::Command::new(crate::resolve_program("claude"))
.args(["-p", "--model", model_id])
.arg("--allowedTools")
.arg("mcp__secall__recall,mcp__secall__get,mcp__secall__status,mcp__secall__wiki_search,Read,Write,Edit,Glob,Grep")
Expand Down
2 changes: 1 addition & 1 deletion crates/secall-core/src/wiki/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ impl WikiBackend for CodexBackend {
let output_file = tempfile::NamedTempFile::new()?;
let output_path = output_file.path().to_path_buf();

let mut child = tokio::process::Command::new("codex")
let mut child = tokio::process::Command::new(crate::resolve_program("codex"))
.args([
"exec",
"--skip-git-repo-check",
Expand Down
5 changes: 4 additions & 1 deletion crates/secall-core/src/wiki/reviewers/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,17 @@ async fn run_review_cli(
anyhow::bail!("{bin} CLI not found in PATH");
}

// Gemini PR #96: PATH 탐색 (disk I/O) 은 루프 밖에서 1회만.
let program = crate::resolve_program(bin);

for strict in [false, true] {
let prompt = format!(
"{}\n\n{}",
load_review_system_prompt(kind),
super::build_user_prompt(page_content, source_summary, strict)
);

let mut child = tokio::process::Command::new(bin);
let mut child = tokio::process::Command::new(&program);
child
.args(args)
.stdin(Stdio::piped())
Expand Down
5 changes: 4 additions & 1 deletion crates/secall-core/src/wiki/reviewers/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ impl WikiReviewer for CodexReviewer {
anyhow::bail!("codex CLI not found in PATH");
}

// Gemini PR #96: PATH 탐색 (disk I/O) 은 루프 밖에서 1회만.
let program = crate::resolve_program("codex");

for strict in [false, true] {
let prompt = format!(
"{}\n\n{}",
Expand All @@ -29,7 +32,7 @@ impl WikiReviewer for CodexReviewer {
let output_file = tempfile::NamedTempFile::new()?;
let output_path = output_file.path().to_path_buf();

let mut child = tokio::process::Command::new("codex");
let mut child = tokio::process::Command::new(&program);
child
.args([
"exec",
Expand Down
8 changes: 8 additions & 0 deletions docs/plans/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,3 +166,11 @@ Plan document index. Register new plans here.
- [전체 계획서](p86-ollama-batch-fail-fast.md) — in_progress, 2026-05-19
- 단일 Task: `commands/wiki.rs` 의 backend 선택 직후 ollama/lmstudio 차단 + 가이드 메시지. silent 30분 wait 사고 차단.
- 관련: issue #88 (cakel).

---

### seCall P87 — Windows `.cmd` 래퍼 CLI spawn 실패 fix (issue #92)

- [전체 계획서](p87-windows-cmd-spawn.md) — in_progress, 2026-05-29
- 단일 Task: `which` crate 로 `resolve_program` 추가 — Windows PATHEXT 적용해 npm `.cmd` 래퍼 (codex/claude) 를 정상 spawn. spawn 5곳 + `command_exists` 통일.
- 관련: issue #92 (cakel).
90 changes: 90 additions & 0 deletions docs/plans/p87-windows-cmd-spawn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
---
type: plan
status: in_progress
updated_at: 2026-05-29
canonical: true
---

# P87 — Windows `.cmd` 래퍼 CLI spawn 실패 fix (issue #92)

## 배경

Issue #92 (cakel, Windows 10): `secall wiki update --backend codex` 실행 시:
```
Wiki update: all sessions (backend: codex)
Launching codex...
Error: program not found
```
`where codex` 는 `codex.cmd` + `codex` 를 찾고 `codex` 명령 자체도 동작하는데 secall 만 실패.

## 원인

- codex 는 npm 으로 설치되어 `C:\Users\User\AppData\Roaming\npm\codex.cmd` (배치 래퍼) 형태다.
- `std::process::Command::new("codex")` 는 Windows 에서 **PATHEXT 를 적용하지 않아** `codex.exe` 만 시도하고 `codex.cmd` 는 못 찾아 "program not found".
- 반면 `command_exists` 는 `where.exe codex` 외부 호출로 `.cmd` 를 찾으므로 **통과** → "존재하는데 spawn 실패" 불일치.

## 목표

- Windows 에서 npm `.cmd` 래퍼 CLI (codex / claude) 를 정상 spawn.
- macOS / Linux 회귀 없음.
- "존재 확인" 과 "실제 spawn" 의 탐색 규칙 일치.

## 비목표

- codex/claude 외 다른 외부 명령 (ollama, git) 의 동작 변경 없음 (영향 받지만 회귀 없음).

## 구현

### 1. `which` crate 도입

`Cargo.toml` workspace dep `which = "8"` + `secall-core/Cargo.toml` `which.workspace = true`.

`which` 는 Windows 에서 PATHEXT (`.CMD`/`.EXE`/`.BAT`) 를 적용해 실제 경로를 탐색한다.

### 2. `lib.rs` — `resolve_program` + `command_exists` 재구현

```rust
pub fn resolve_program(cmd: &str) -> std::path::PathBuf {
which::which(cmd).unwrap_or_else(|_| std::path::PathBuf::from(cmd))
}

pub fn command_exists(cmd: &str) -> bool {
which::which(cmd).is_ok()
}
```

`resolve_program` 이 `codex.cmd` 풀경로를 반환 → Rust 1.77+ 가 `.cmd`/`.bat` 확장자 경로를 `Command` 로 실행 시 cmd.exe 경유로 안전 실행 (인자 escaping 포함).

### 3. spawn 사이트 5곳 — `Command::new(resolve_program(X))`

| 파일 | 명령 |
|---|---|
| `wiki/codex.rs:30` | codex |
| `wiki/claude.rs:39` | claude |
| `wiki/reviewers/codex.rs:32` | codex |
| `wiki/reviewers/claude.rs` (`run_review_cli`) | bin (claude/codex) |
| `search/query_expand.rs:31` | claude |

## 변경 파일

- `Cargo.toml`, `crates/secall-core/Cargo.toml` — which dep
- `crates/secall-core/src/lib.rs` — resolve_program + command_exists
- `crates/secall-core/src/wiki/{codex,claude}.rs`
- `crates/secall-core/src/wiki/reviewers/{codex,claude}.rs`
- `crates/secall-core/src/search/query_expand.rs`
- `docs/plans/p87-windows-cmd-spawn.md` (신규) + `docs/plans/index.md`

## 검증

```bash
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test -p secall-core --lib # command_exists 회귀 (3건)
```

⚠️ **Windows 실제 spawn 검증 불가** (개발 환경 macOS). 머지 전 cakel (issue #92 보고자) 에게 Windows 빌드 검증 요청 필요. macOS/Linux 는 `which` 가 확장자 없는 실행파일 그대로 resolve → 회귀 없음 (위 테스트 통과 확인).

## 리스크

- `which` resolve 실패 시 입력 문자열 그대로 fallback → 기존 동작 유지 (최소 회귀).
- Rust 1.77+ 의 `.cmd` 자동 cmd.exe 경유 실행에 의존 — secall MSRV 1.75 보다 높음. 단 실제 빌드/CI 는 최신 toolchain (1.94) 사용. MSRV 표기는 별도 검토 (본 PR 범위 외 — 코드는 1.75 에서도 컴파일됨, .cmd 실행 동작만 1.77+).
Loading