sac-cli 0.1.0

Terminal-based AI coding agent — fork of NAC with extended backend support and context management
Documentation
use std::path::PathBuf;

use serde_json::Value;

use crate::tools::{require_str, ToolResult, ToolRuntime};

const SANDBOX_READ_SCRIPT: &str = r#"
from pathlib import Path
import sys

orig = sys.argv[1]
path = Path(sys.argv[2])
offset = int(sys.argv[3])
limit = int(sys.argv[4])

if not path.exists():
    print(f"File not found: {orig}")
    sys.exit(2)

raw = path.read_bytes()
check_len = min(len(raw), 8192)
if b'\0' in raw[:check_len]:
    print(f"Binary file, cannot read as text: {orig}")
    sys.exit(2)

text = raw.decode('utf-8', errors='replace')
lines = text.splitlines()
total_lines = len(lines)
selected = lines[offset:offset + limit]

output = ''.join(f"{offset + idx + 1:4}| {line}\n" for idx, line in enumerate(selected))
if len(output) > 30000:
    output = output[:30000] + f"\n... (truncated, {total_lines} total lines)"
elif offset + len(selected) < total_lines:
    output += f"\n... (showing lines {offset + 1}-{offset + len(selected)} of {total_lines})"

sys.stdout.write(output)
"#;

pub async fn execute(args: Value, runtime: &ToolRuntime) -> ToolResult {
    let path = match require_str(&args, "path") {
        Ok(value) => value,
        Err(error) => return error,
    };
    let offset = args.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
    let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(2000) as usize;

    if let Some(sandbox) = &runtime.sandbox {
        let guest_path = match sandbox.resolve_path(&path) {
            Ok(path) => path,
            Err(error) => {
                return ToolResult {
                    content: error.to_string(),
                    is_error: true,
                }
            }
        };

        let args = vec![
            "-c".to_string(),
            SANDBOX_READ_SCRIPT.to_string(),
            path.clone(),
            guest_path.display().to_string(),
            offset.to_string(),
            limit.to_string(),
        ];

        return match sandbox.exec("python3", &args, None).await {
            Ok(output) => sandbox_output(output),
            Err(error) => ToolResult {
                content: format!("Error reading {} in sandbox: {}", path, error),
                is_error: true,
            },
        };
    }

    let path = PathBuf::from(path);
    if !path.exists() {
        return ToolResult {
            content: format!("File not found: {}", path.display()),
            is_error: true,
        };
    }

    let raw = match tokio::fs::read(&path).await {
        Ok(b) => b,
        Err(e) => {
            return ToolResult {
                content: format!("Error reading {}: {}", path.display(), e),
                is_error: true,
            };
        }
    };

    let check_len = raw.len().min(8192);
    if raw[..check_len].contains(&0u8) {
        return ToolResult {
            content: format!("Binary file, cannot read as text: {}", path.display()),
            is_error: true,
        };
    }

    let text = String::from_utf8_lossy(&raw).into_owned();
    let lines: Vec<&str> = text.lines().collect();
    let total_lines = lines.len();
    let selected: Vec<&str> = lines.iter().skip(offset).take(limit).copied().collect();

    let mut output = String::new();
    for (idx, line) in selected.iter().enumerate() {
        output.push_str(&format!("{:4}| {}\n", offset + idx + 1, line));
    }

    if output.len() > 30_000 {
        output.truncate(30_000);
        output.push_str(&format!("\n... (truncated, {} total lines)", total_lines));
    } else if offset + selected.len() < total_lines {
        output.push_str(&format!(
            "\n... (showing lines {}-{} of {})",
            offset + 1,
            offset + selected.len(),
            total_lines
        ));
    }

    ToolResult {
        content: output,
        is_error: false,
    }
}

fn sandbox_output(output: std::process::Output) -> ToolResult {
    let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
    let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
    if output.status.success() {
        ToolResult {
            content: stdout,
            is_error: false,
        }
    } else {
        let content = if !stdout.trim().is_empty() {
            stdout
        } else {
            stderr
        };
        ToolResult {
            content: content.trim().to_string(),
            is_error: true,
        }
    }
}

#[cfg(test)]
mod tests {
    use serde_json::json;
    use std::collections::HashSet;
    use std::sync::Arc;

    use tokio::sync::Mutex;

    use super::*;
    use crate::events::EventSink;

    fn local_runtime() -> ToolRuntime {
        ToolRuntime {
            store_path: PathBuf::new(),
            session_id: None,
            worker_executable: None,
            active_threads: Arc::new(Mutex::new(HashSet::new())),
            event_sink: EventSink::none(),
            sandbox: None,
            mcp: None,
            skills: None,
            activated_skills: Arc::new(Mutex::new(HashSet::new())),
            terminal_manager: crate::terminal::TerminalManager::new(),
            thread_timeout_secs: crate::tools::thread::DEFAULT_THREAD_TIMEOUT_SECS,
        }
    }

    #[tokio::test]
    async fn test_read_missing_file() {
        let result = execute(
            json!({ "path": "/nonexistent/file_xyz_12345.txt" }),
            &local_runtime(),
        )
        .await;
        assert!(result.is_error);
        assert!(
            result.content.contains("not found") || result.content.contains("not exist"),
            "Got: {}",
            result.content
        );
    }

    #[tokio::test]
    async fn test_read_existing_file() {
        let result = execute(json!({ "path": "Cargo.toml" }), &local_runtime()).await;
        assert!(!result.is_error, "Got error: {}", result.content);
        assert!(
            result.content.contains("[workspace]") || result.content.contains("[package]"),
            "Got: {}",
            result.content
        );
    }
}