mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
chore: commit local work (CLI + core + TUI)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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) }
|
||||
})
|
||||
};
|
||||
|
||||
|
||||
+738
-223
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user