chore: commit local work (CLI + core + TUI)

This commit is contained in:
James Peter
2025-08-29 14:20:24 +10:00
parent 75ed3479c3
commit eea7d98e2b
6 changed files with 986 additions and 247 deletions
+52
View File
@@ -3,6 +3,8 @@
import path from "path";
import { fileURLToPath } from "url";
import { realpathSync, accessSync, constants } from "fs";
import { spawnSync } from "child_process";
// __dirname equivalent in ESM
const __filename = fileURLToPath(import.meta.url);
@@ -10,6 +12,56 @@ const __dirname = path.dirname(__filename);
const { platform, arch } = process;
// If invoked as "code" and another "code" exists in PATH that is not
// this package's launcher, delegate to it (e.g., VS Code's CLI).
// This avoids hijacking users who already have VS Code installed.
const maybeDelegateToOtherCode = () => {
try {
const invoked = process.env._ || "";
const invokedBase = path.basename(invoked);
if (invokedBase !== "code") return false;
const ourScriptReal = realpathSync(process.argv[1]);
const which = spawnSync("bash", ["-lc", "which -a code 2>/dev/null"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
});
if (which.status !== 0 || !which.stdout) return false;
const candidates = which.stdout
.split(/\r?\n/)
.map((s) => s.trim())
.filter(Boolean);
for (const c of candidates) {
// Skip the path that was used to invoke us
if (c === invoked) continue;
try {
const real = realpathSync(c);
// Skip if it resolves to our own script (same file inside our package)
if (real === ourScriptReal) continue;
// Ensure it's executable; if so, exec and mirror exit code
accessSync(c, constants.X_OK);
const run = spawnSync(c, process.argv.slice(2), {
stdio: "inherit",
});
// If it executed (even with non-zero), we consider delegation done.
const code = run.status == null ? 1 : run.status;
process.exit(code);
} catch {
// Try next candidate
}
}
} catch {
// Fall through to our own binary
}
return false;
};
maybeDelegateToOtherCode();
const isWSL = () => {
if (platform !== "linux") return false;
try {
+46 -6
View File
@@ -445,9 +445,19 @@ fn apply_hunks_to_files(hunks: &[Hunk]) -> anyhow::Result<AffectedPaths> {
added.push(path.clone());
}
Hunk::DeleteFile { path } => {
std::fs::remove_file(path)
.with_context(|| format!("Failed to delete file {}", path.display()))?;
deleted.push(path.clone());
match std::fs::remove_file(path) {
Ok(()) => deleted.push(path.clone()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
// Treat deleting a non-existent file as success (idempotent delete).
deleted.push(path.clone());
}
Err(e) => {
return Err(anyhow::Error::new(e).context(format!(
"Failed to delete file {}",
path.display()
)));
}
}
}
Hunk::UpdateFile {
path,
@@ -466,8 +476,18 @@ fn apply_hunks_to_files(hunks: &[Hunk]) -> anyhow::Result<AffectedPaths> {
}
std::fs::write(dest, new_contents)
.with_context(|| format!("Failed to write file {}", dest.display()))?;
std::fs::remove_file(path)
.with_context(|| format!("Failed to remove original {}", path.display()))?;
match std::fs::remove_file(path) {
Ok(()) => (),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
// Original already gone; proceed.
}
Err(e) => {
return Err(anyhow::Error::new(e).context(format!(
"Failed to remove original {}",
path.display()
)));
}
}
modified.push(dest.clone());
} else {
std::fs::write(path, new_contents)
@@ -489,13 +509,33 @@ struct AppliedPatch {
new_contents: String,
}
/// Best-effort read with brief retries for transient NotFound during editor-style atomic renames.
fn read_to_string_with_retry(path: &Path) -> std::io::Result<String> {
use std::io::ErrorKind;
const MAX_ATTEMPTS: usize = 5;
let mut attempt = 0;
loop {
match std::fs::read_to_string(path) {
Ok(s) => return Ok(s),
Err(e) if e.kind() == ErrorKind::NotFound && attempt < MAX_ATTEMPTS => {
// Tiny backoff (10ms, 20ms, 40ms, 80ms, 160ms)
let delay_ms = 10u64 << attempt;
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
attempt += 1;
continue;
}
Err(e) => return Err(e),
}
}
}
/// Return *only* the new file contents (joined into a single `String`) after
/// applying the chunks to the file at `path`.
fn derive_new_contents_from_chunks(
path: &Path,
chunks: &[UpdateFileChunk],
) -> std::result::Result<AppliedPatch, ApplyPatchError> {
let original_contents = match std::fs::read_to_string(path) {
let original_contents = match read_to_string_with_retry(path) {
Ok(contents) => contents,
Err(err) => {
return Err(ApplyPatchError::IoError(IoError {
+2 -2
View File
@@ -39,7 +39,7 @@ agent_run {
"task": "Implement JWT middleware (RS256) with key rotation and unit/integration tests. Preserve existing OAuth flows. Provide README usage snippet.",
"context": "Service: services/api (Rust Axum). Secrets via env. CI: `cargo test --all`.",
"files": ["services/api", "services/api/src", "services/api/Cargo.toml"],
"model": ["claude","gemini","codex"],
"model": ["claude","gemini","code"],
"output": "Middleware + passing tests + README snippet",
"read_only": false // Allow changes - will launch every agent in a separate worktree
}
@@ -59,4 +59,4 @@ set -euo pipefail
./build-fast.sh
```
Markdown tables, quotes, callouts, task lists, strikethrough and inline code are also all supported.
Markdown tables, quotes, callouts, task lists, strikethrough and inline code are also all supported.
+69 -13
View File
@@ -542,6 +542,51 @@ async fn execute_model_with_permissions(
working_dir: Option<PathBuf>,
config: Option<AgentConfig>,
) -> Result<String, String> {
// Helper: cross‑platform check whether an executable is available in PATH.
fn command_exists(cmd: &str) -> bool {
// Absolute/relative path with separators: check directly.
if cmd.contains(std::path::MAIN_SEPARATOR) || cmd.contains('/') || cmd.contains('\\') {
return std::fs::metadata(cmd).is_ok();
}
let path = std::env::var_os("PATH");
if path.is_none() {
return false;
}
let pathext: Vec<String> = if cfg!(windows) {
// Default Windows PATHEXT set; also respect env if provided.
let env_ext = std::env::var("PATHEXT").unwrap_or(".COM;.EXE;.BAT;.CMD;.MSI;.PS1".to_string());
env_ext.split(';').filter_map(|e| {
let e = e.trim();
if e.is_empty() { None } else { Some(e.to_string()) }
}).collect()
} else {
Vec::new()
};
for dir in std::env::split_paths(&path.unwrap()) {
if dir.as_os_str().is_empty() {
continue;
}
let base = dir.join(cmd);
if cfg!(windows) {
// Try with PATHEXT variants
if std::fs::metadata(&base).is_ok() {
return true;
}
for ext in &pathext {
let candidate = base.with_extension(ext.trim_start_matches('.'));
if std::fs::metadata(&candidate).is_ok() {
return true;
}
}
} else if std::fs::metadata(&base).is_ok() {
return true;
}
}
false
}
// Use config command if provided, otherwise use model name
let command = if let Some(ref cfg) = config {
cfg.command.clone()
@@ -549,7 +594,19 @@ async fn execute_model_with_permissions(
model.to_lowercase()
};
let mut cmd = Command::new(command.clone());
// Special case: for the built‑in Codex agent, prefer invoking the currently
// running executable with the `exec` subcommand rather than relying on a
// `codex` binary to be present on PATH. This improves portability,
// especially on Windows where global shims may be missing.
let model_lower = model.to_lowercase();
let mut cmd = if (model_lower == "code" || model_lower == "codex") && config.is_none() {
match std::env::current_exe() {
Ok(path) => Command::new(path),
Err(e) => return Err(format!("Failed to resolve current executable: {}", e)),
}
} else {
Command::new(command.clone())
};
// Set working directory if provided
if let Some(dir) = working_dir {
@@ -572,12 +629,7 @@ async fn execute_model_with_permissions(
// Build command based on model and permissions
// Use command instead of model for matching if config provided
let model_lower = model.to_lowercase();
let model_name = if config.is_some() {
command.as_str()
} else {
model_lower.as_str()
};
let model_name = if config.is_some() { command.as_str() } else { model_lower.as_str() };
match model_name {
"claude" => {
@@ -599,7 +651,7 @@ async fn execute_model_with_permissions(
cmd.args(&["-m", "gemini-2.5-pro", "-y", "-p", prompt]);
}
}
"codex" => {
"codex" | "code" => {
if read_only {
cmd.args(&["-s", "read-only", "-a", "never", "exec", prompt]);
} else {
@@ -611,10 +663,14 @@ async fn execute_model_with_permissions(
}
}
let output = cmd
.output()
.await
.map_err(|e| format!("Failed to execute {}: {}", model, e))?;
// Proactively check for presence of external command before spawn when not
// using the current executable fallback. This avoids confusing OS errors
// like "program not found" and lets us surface a cleaner message.
if model_name != "codex" && model_name != "code" && !command_exists(&command) {
return Err(format!("Required agent '{}' is not installed or not in PATH", command));
}
let output = cmd.output().await.map_err(|e| format!("Failed to execute {}: {}", model, e))?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
@@ -639,7 +695,7 @@ pub fn create_run_agent_tool() -> OpenAiTool {
"model".to_string(),
JsonSchema::String {
description: Some(
"Model: 'claude', 'gemini', or 'codex' (or array of models for batch execution)"
"Model: 'claude', 'gemini', or 'code' (or array of models for batch execution)"
.to_string(),
),
},
+79 -3
View File
@@ -3415,9 +3415,55 @@ async fn handle_run_agent(
.into_iter()
.filter_map(|m| m.as_str().map(String::from))
.collect(),
_ => vec!["codex".to_string()], // Default model
_ => vec!["code".to_string()], // Default model
};
// Helper: derive the command to check for a given model/config pair.
fn resolve_command_for_check(model: &str, cfg: Option<&crate::config_types::AgentConfig>) -> (String, bool) {
if let Some(c) = cfg { return (c.command.clone(), false); }
let m = model.to_lowercase();
match m.as_str() {
// Built-in: always available via current_exe fallback.
"code" | "codex" => (m, true),
// External CLIs expected to be in PATH
"claude" => ("claude".to_string(), false),
"gemini" => ("gemini".to_string(), false),
_ => (m, false),
}
}
// Helper: PATH lookup to determine if a command exists.
fn command_exists(cmd: &str) -> bool {
if cmd.contains(std::path::MAIN_SEPARATOR) || cmd.contains('/') || cmd.contains('\\') {
return std::fs::metadata(cmd).is_ok();
}
let Some(path_os) = std::env::var_os("PATH") else { return false; };
let pathext: Vec<String> = if cfg!(windows) {
std::env::var("PATHEXT")
.unwrap_or(".COM;.EXE;.BAT;.CMD;.MSI;.PS1".to_string())
.split(';')
.filter(|e| !e.trim().is_empty())
.map(|s| s.to_string())
.collect()
} else {
Vec::new()
};
for dir in std::env::split_paths(&path_os) {
if dir.as_os_str().is_empty() { continue; }
let base = dir.join(cmd);
if cfg!(windows) {
if std::fs::metadata(&base).is_ok() { return true; }
for ext in &pathext {
let candidate = base.with_extension(ext.trim_start_matches('.'));
if std::fs::metadata(&candidate).is_ok() { return true; }
}
} else if std::fs::metadata(&base).is_ok() {
return true;
}
}
false
}
let batch_id = if models.len() > 1 {
Some(Uuid::new_v4().to_string())
} else {
@@ -3425,6 +3471,7 @@ async fn handle_run_agent(
};
let mut agent_ids = Vec::new();
let mut skipped: Vec<String> = Vec::new();
for model in models {
// Check if this model is configured and enabled
let agent_config = sess.agents.iter().find(|a| {
@@ -3437,6 +3484,12 @@ async fn handle_run_agent(
continue; // Skip disabled agents
}
let (cmd_to_check, is_builtin) = resolve_command_for_check(&model, Some(config));
if !is_builtin && !command_exists(&cmd_to_check) {
skipped.push(format!("{} (missing: {})", model, cmd_to_check));
continue;
}
// Override read_only if agent is configured as read-only
let read_only = config.read_only || params.read_only.unwrap_or(false);
@@ -3455,6 +3508,11 @@ async fn handle_run_agent(
agent_ids.push(agent_id);
} else {
// Use default configuration for unknown agents
let (cmd_to_check, is_builtin) = resolve_command_for_check(&model, None);
if !is_builtin && !command_exists(&cmd_to_check) {
skipped.push(format!("{} (missing: {})", model, cmd_to_check));
continue;
}
let agent_id = manager
.create_agent(
model,
@@ -3470,6 +3528,22 @@ async fn handle_run_agent(
}
}
// If nothing runnable remains, fall back to a single built‑in Codex agent.
if agent_ids.is_empty() {
let agent_id = manager
.create_agent(
"code".to_string(),
params.task.clone(),
params.context.clone(),
params.output.clone(),
params.files.clone().unwrap_or_default(),
params.read_only.unwrap_or(false),
None,
)
.await;
agent_ids.push(agent_id);
}
// Send agent status update event
drop(manager); // Release the write lock first
if agent_ids.len() > 0 {
@@ -3481,13 +3555,15 @@ async fn handle_run_agent(
"batch_id": batch_id,
"agent_ids": agent_ids,
"status": "started",
"message": format!("Started {} agents", agent_ids.len())
"message": format!("Started {} agents", agent_ids.len()),
"skipped": if skipped.is_empty() { None } else { Some(skipped) }
})
} else {
serde_json::json!({
"agent_id": agent_ids[0],
"status": "started",
"message": "Agent started successfully"
"message": "Agent started successfully",
"skipped": if skipped.is_empty() { None } else { Some(skipped) }
})
};
File diff suppressed because it is too large Load Diff