feat(analog): add claw-analog minimal harness

Adds claw-analog minimal harness for lean, predictable tool execution.
This commit is contained in:
gismo212
2026-05-25 05:25:28 +03:00
committed by GitHub
parent a4efdc43d7
commit ae30bf4f04
7 changed files with 5199 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
[package]
name = "claw-analog"
version.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true
description = "Minimal agent harness: tool loop with explicit permissions and workspace jail."
[lib]
name = "claw_analog"
path = "src/lib.rs"
[[bin]]
name = "claw-analog"
path = "src/main.rs"
[dependencies]
api = { path = "../api" }
clap = { version = "4", features = ["derive"] }
clap_complete = "4"
globset = "0.4"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
runtime = { path = "../runtime" }
serde = { version = "1", features = ["derive"] }
serde_json.workspace = true
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
toml = "0.8"
walkdir = "2"
ignore = "0.4"
[dev-dependencies]
mock-anthropic-service = { path = "../mock-anthropic-service" }
tempfile = "3"

View File

@@ -0,0 +1,489 @@
//! `claw-analog agents` — run multiple specialized sub-agents sequentially.
use std::path::{Path, PathBuf};
use api::InputMessage;
use clap::{Parser, ValueEnum};
use claw_analog::{
enforce_non_interactive_permission_rules, load_analog_toml, resolve_analog_options,
resolve_analog_profile_path, resolve_rag_base_url, AnalogConfig, AnalogDoctorOverrides,
AnalogFileConfig, OutputFormat, PermissionMode, Preset, StreamOverride,
};
const DEF_MAX_READ: u64 = 256 * 1024;
const DEF_MAX_TURNS: u32 = 24;
const DEF_MAX_LIST: usize = 500;
const DEF_GREP_MAX: usize = 200;
const DEF_GLOB_PATHS: usize = 2000;
const DEF_GLOB_DEPTH: usize = 32;
const DEF_RAG_TIMEOUT_SECS: u64 = 30;
const DEF_RAG_TOP_K_MAX: u32 = 32;
const RAG_TOP_K_ABS_CAP: u32 = 256;
#[derive(Copy, Clone, Debug, ValueEnum)]
pub enum AgentsPresetArg {
Audit,
Explain,
Implement,
}
impl From<AgentsPresetArg> for Preset {
fn from(p: AgentsPresetArg) -> Self {
match p {
AgentsPresetArg::Audit => Preset::Audit,
AgentsPresetArg::Explain => Preset::Explain,
AgentsPresetArg::Implement => Preset::Implement,
}
}
}
#[derive(Copy, Clone, Debug, ValueEnum)]
pub enum AgentsPermissionArg {
ReadOnly,
WorkspaceWrite,
Prompt,
#[value(name = "danger-full-access")]
DangerFullAccess,
Allow,
}
impl From<AgentsPermissionArg> for PermissionMode {
fn from(p: AgentsPermissionArg) -> Self {
match p {
AgentsPermissionArg::ReadOnly => PermissionMode::ReadOnly,
AgentsPermissionArg::WorkspaceWrite => PermissionMode::WorkspaceWrite,
AgentsPermissionArg::Prompt => PermissionMode::Prompt,
AgentsPermissionArg::DangerFullAccess => PermissionMode::DangerFullAccess,
AgentsPermissionArg::Allow => PermissionMode::Allow,
}
}
}
#[derive(Debug, Clone)]
pub struct AgentSpec {
pub name: String,
pub preset: Preset,
pub permission: PermissionMode,
pub model: Option<String>,
pub prompt: Option<String>,
}
fn default_permission_for_preset(p: Preset) -> PermissionMode {
match p {
Preset::Audit | Preset::Explain => PermissionMode::ReadOnly,
Preset::Implement => PermissionMode::WorkspaceWrite,
Preset::None => PermissionMode::ReadOnly,
}
}
fn parse_agent_spec(s: &str) -> Result<AgentSpec, String> {
// Allowed forms:
// - "audit" | "explain" | "implement"
// - "name=audit,preset=audit,permission=read-only,model=...,prompt=..."
let raw = s.trim();
if raw.is_empty() {
return Err("empty --agent spec".to_string());
}
if !raw.contains('=') {
let preset = match raw.to_ascii_lowercase().as_str() {
"audit" => Preset::Audit,
"explain" => Preset::Explain,
"implement" | "fix" => Preset::Implement,
other => return Err(format!("unknown agent shorthand: {other}")),
};
return Ok(AgentSpec {
name: raw.to_string(),
preset,
permission: default_permission_for_preset(preset),
model: None,
prompt: None,
});
}
let mut name: Option<String> = None;
let mut preset: Option<Preset> = None;
let mut permission: Option<PermissionMode> = None;
let mut model: Option<String> = None;
let mut prompt: Option<String> = None;
for part in raw.split(',') {
let (k, v) = part
.split_once('=')
.ok_or_else(|| format!("invalid agent spec part {part:?} (expected k=v)"))?;
let k = k.trim().to_ascii_lowercase();
let v = v.trim();
if v.is_empty() {
continue;
}
match k.as_str() {
"name" => name = Some(v.to_string()),
"preset" => {
let p = match v.to_ascii_lowercase().as_str() {
"audit" => Preset::Audit,
"explain" => Preset::Explain,
"implement" | "fix" => Preset::Implement,
"none" => Preset::None,
other => return Err(format!("unknown preset {other:?}")),
};
preset = Some(p);
}
"permission" => {
let pm = match v.to_ascii_lowercase().replace('_', "-").as_str() {
"read-only" | "readonly" => PermissionMode::ReadOnly,
"workspace-write" | "write" => PermissionMode::WorkspaceWrite,
"prompt" => PermissionMode::Prompt,
"danger-full-access" | "danger" => PermissionMode::DangerFullAccess,
"allow" => PermissionMode::Allow,
other => return Err(format!("unknown permission {other:?}")),
};
permission = Some(pm);
}
"model" => model = Some(v.to_string()),
"prompt" => prompt = Some(v.to_string()),
other => return Err(format!("unknown agent spec key {other:?}")),
}
}
let preset = preset.unwrap_or(Preset::Audit);
let permission = permission.unwrap_or_else(|| default_permission_for_preset(preset));
let name = name.unwrap_or_else(|| preset.label().unwrap_or("agent").to_string());
Ok(AgentSpec {
name,
preset,
permission,
model,
prompt,
})
}
#[derive(Debug, Parser)]
pub struct AgentsCli {
/// Workspace root.
#[arg(short = 'w', long, default_value = ".", value_name = "DIR")]
pub workspace: PathBuf,
/// Config path (default: `<workspace>/.claw-analog.toml`).
#[arg(long, value_name = "PATH")]
pub config: Option<PathBuf>,
/// Base session path. If missing, it will be created from the base prompt.
#[arg(long, value_name = "PATH")]
pub base_session: PathBuf,
/// Base prompt. If omitted, reads from stdin.
#[arg(long)]
pub prompt: Option<String>,
/// Repeatable agent specs, e.g. `--agent audit` or `--agent name=fix,preset=implement,permission=workspace-write`.
#[arg(long, required = true)]
pub agent: Vec<String>,
/// If set, each agent writes its own session file next to base session.
#[arg(long, default_value_t = true)]
pub split_sessions: bool,
}
fn load_file_config(path: &Path) -> AnalogFileConfig {
if !path.is_file() {
return AnalogFileConfig::default();
}
load_analog_toml(path).unwrap_or_default()
}
fn config_path(args: &AgentsCli) -> PathBuf {
args.config
.clone()
.unwrap_or_else(|| args.workspace.join(".claw-analog.toml"))
}
fn derive_agent_session_path(base: &Path, agent_name: &str) -> PathBuf {
let base_s = base.to_string_lossy();
PathBuf::from(format!("{base_s}.agent-{agent_name}.json"))
}
fn read_stdin_prompt() -> Result<String, String> {
use std::io::Read;
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.map_err(|e| e.to_string())?;
let t = buf.trim();
if t.is_empty() {
return Err("empty prompt (pass --prompt or stdin)".to_string());
}
Ok(t.to_string())
}
fn ensure_base_session(base_session: &Path, workspace: &Path, prompt: &str) -> Result<(), String> {
if base_session.exists() {
return Ok(());
}
let ws_s = workspace.display().to_string();
let model = "base".to_string();
let messages = if prompt.trim().is_empty() {
Vec::new()
} else {
vec![InputMessage::user_text(prompt.to_string())]
};
claw_analog::session_save(base_session, &ws_s, &model, Preset::None, &messages)?;
Ok(())
}
pub fn run_agents(args: AgentsCli) -> Result<(), String> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| e.to_string())?;
rt.block_on(async { run_agents_async(args).await })
}
pub async fn run_agents_async(args: AgentsCli) -> Result<(), String> {
run_agents_inner(args, |cfg, out| {
Box::pin(async move {
claw_analog::run(cfg, out)
.await
.map_err(|e| e.to_string())?;
Ok(())
})
})
.await
}
type RunFuture<'a> = std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), String>> + 'a>>;
async fn run_agents_inner<F>(args: AgentsCli, mut run_one: F) -> Result<(), String>
where
for<'a> F: FnMut(AnalogConfig, &'a mut Vec<u8>) -> RunFuture<'a>,
{
let workspace = if args.workspace.is_absolute() {
args.workspace.clone()
} else {
std::env::current_dir()
.map_err(|e| e.to_string())?
.join(&args.workspace)
};
let cfg_path = config_path(&args);
let file_cfg = load_file_config(&cfg_path);
let base_prompt = match args.prompt.clone() {
Some(p) => p,
None => read_stdin_prompt()?,
};
ensure_base_session(&args.base_session, &workspace, base_prompt.as_str())?;
let mut specs = Vec::new();
for a in &args.agent {
specs.push(parse_agent_spec(a)?);
}
println!("claw-analog agents (sequential)\n");
println!(" workspace: {}", workspace.display());
println!(" base_session: {}", args.base_session.display());
println!(" agents: {}", specs.len());
println!();
for (i, spec) in specs.into_iter().enumerate() {
println!(
"== Agent {} / {}: {} ==",
i + 1,
args.agent.len(),
spec.name
);
println!(" preset: {}", spec.preset.label().unwrap_or("none"));
println!(" permission: {}", spec.permission.as_str());
if let Some(m) = &spec.model {
println!(" model: {m}");
}
enforce_non_interactive_permission_rules(spec.permission, false)?;
let agent_session = if args.split_sessions {
derive_agent_session_path(&args.base_session, spec.name.as_str())
} else {
args.base_session.clone()
};
if args.split_sessions {
std::fs::copy(&args.base_session, &agent_session).map_err(|e| e.to_string())?;
}
let overrides = AnalogDoctorOverrides {
model: spec.model.clone(),
permission: Some(spec.permission),
preset: Some(spec.preset),
output_format: Some(OutputFormat::Rich),
stream: StreamOverride::ForceOff,
..Default::default()
};
let resolved = resolve_analog_options(&file_cfg, &overrides);
let profile_path =
resolve_analog_profile_path(&workspace, None, file_cfg.profile.as_deref());
let profile_hint = if let Some(ref p) = profile_path {
claw_analog::load_profile_hint(p).unwrap_or(None)
} else {
None
};
let rag_base_url = resolve_rag_base_url(&file_cfg);
let agent_prompt = spec.prompt.unwrap_or_else(|| {
format!(
"Agent {}: run preset {}",
spec.name,
resolved.preset.label().unwrap_or("none")
)
});
let cfg = AnalogConfig {
model: resolved.model,
workspace: workspace.clone(),
permission_mode: resolved.permission_mode,
accept_danger_non_interactive: false,
use_stream: false,
output_format: resolved.output_format,
use_runtime_enforcer: resolved.use_runtime_enforcer,
max_read_bytes: file_cfg.max_read_bytes.unwrap_or(DEF_MAX_READ),
max_turns: file_cfg.max_turns.unwrap_or(DEF_MAX_TURNS),
max_list_entries: file_cfg.max_list_entries.unwrap_or(DEF_MAX_LIST),
grep_max_lines: file_cfg.grep_max_lines.unwrap_or(DEF_GREP_MAX),
glob_max_paths: file_cfg.glob_max_paths.unwrap_or(DEF_GLOB_PATHS),
glob_max_depth: file_cfg.glob_max_depth.unwrap_or(DEF_GLOB_DEPTH),
preset: resolved.preset,
language: file_cfg
.language
.as_deref()
.and_then(claw_analog::AnalogLanguage::from_toml_str)
.unwrap_or_default(),
session_path: Some(agent_session.clone()),
session_save_path: None,
profile_hint,
prompt: agent_prompt,
rag_base_url,
rag_http_timeout: std::time::Duration::from_secs(
file_cfg.rag_timeout_secs.unwrap_or(DEF_RAG_TIMEOUT_SECS),
),
rag_top_k_max: file_cfg
.rag_top_k_max
.unwrap_or(DEF_RAG_TOP_K_MAX)
.clamp(1, RAG_TOP_K_ABS_CAP),
};
let mut buf: Vec<u8> = Vec::new();
let run_res = run_one(cfg, &mut buf).await;
match run_res {
Ok(()) => {
let text = String::from_utf8_lossy(&buf);
let summary = tail_chars(text.as_ref(), 1600);
println!(" result: OK");
if args.split_sessions {
println!(" session: {}", agent_session.display());
}
println!(" summary_tail:\n{}\n", indent_lines(&summary, 4));
}
Err(e) => {
println!(" result: FAIL — {e}\n");
}
}
}
Ok(())
}
fn tail_chars(s: &str, n: usize) -> String {
let total = s.chars().count();
if total <= n {
return s.to_string();
}
s.chars().skip(total - n).collect()
}
fn indent_lines(s: &str, spaces: usize) -> String {
let pad = " ".repeat(spaces);
s.lines()
.map(|l| format!("{pad}{l}"))
.collect::<Vec<_>>()
.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, OnceLock};
fn mock_env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|e| e.into_inner())
}
#[test]
fn parses_agent_shorthand() {
let a = parse_agent_spec("audit").unwrap();
assert_eq!(a.preset, Preset::Audit);
assert_eq!(a.permission, PermissionMode::ReadOnly);
}
#[test]
fn parses_agent_kv() {
let a = parse_agent_spec("name=fix,preset=implement,permission=workspace-write").unwrap();
assert_eq!(a.name, "fix");
assert_eq!(a.preset, Preset::Implement);
assert_eq!(a.permission, PermissionMode::WorkspaceWrite);
}
#[test]
fn runs_two_agents_sequentially_with_stub_runner() {
let _g = mock_env_lock();
let dir = tempfile::tempdir().unwrap();
let workspace = dir.path().canonicalize().unwrap();
std::fs::write(workspace.join("fixture.txt"), "hello parity fixture\n").unwrap();
let base_session = workspace.join(".claw").join("agents-base.json");
std::fs::create_dir_all(base_session.parent().unwrap()).unwrap();
std::fs::write(
&base_session,
format!(
"{{\n \"version\": 1,\n \"workspace\": \"{}\",\n \"model\": \"base\",\n \"messages\": []\n}}\n",
workspace.display()
),
)
.unwrap();
let args = AgentsCli {
workspace: workspace.clone(),
config: None,
base_session: base_session.clone(),
prompt: Some(String::new()),
agent: vec![
"name=audit,preset=audit,permission=read-only,prompt=check 1".to_string(),
"name=explain,preset=explain,permission=read-only,prompt=check 2".to_string(),
],
split_sessions: true,
};
let called = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let called2 = called.clone();
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_all()
.build()
.expect("runtime");
rt.block_on(async {
run_agents_inner(args, move |_cfg, out| {
let called3 = called2.clone();
Box::pin(async move {
called3.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
out.extend_from_slice(b"stub ok");
Ok(())
})
})
.await
.expect("agents should run");
});
assert_eq!(called.load(std::sync::atomic::Ordering::Relaxed), 2);
assert!(derive_agent_session_path(&base_session, "audit").is_file());
assert!(derive_agent_session_path(&base_session, "explain").is_file());
}
}

View File

@@ -0,0 +1,144 @@
//! `claw-analog config validate` — parse TOML and profile without calling the API.
use std::path::PathBuf;
use clap::Parser;
use claw_analog::{
load_analog_toml, load_profile_hint, resolve_analog_options, resolve_analog_profile_path,
AnalogDoctorOverrides, AnalogFileConfig, AnalogLanguage, OutputFormat,
};
#[derive(Parser, Debug)]
pub struct ValidateCli {
#[arg(short = 'w', long, default_value = ".", value_name = "DIR")]
pub workspace: PathBuf,
#[arg(long, value_name = "PATH")]
pub config: Option<PathBuf>,
/// Require `<workspace>/.claw-analog.toml` (or `--config`) to exist and parse.
#[arg(long, default_value_t = false, action = clap::ArgAction::SetTrue)]
pub strict: bool,
#[arg(long, value_name = "PATH")]
pub profile: Option<PathBuf>,
}
pub fn run_validate(cli: ValidateCli) -> i32 {
let cfg_path = cli
.config
.clone()
.unwrap_or_else(|| cli.workspace.join(".claw-analog.toml"));
let file_cfg = if cfg_path.is_file() {
match load_analog_toml(&cfg_path) {
Ok(c) => {
println!("OK: {} parses", cfg_path.display());
c
}
Err(e) => {
eprintln!("ERROR: {}: {e}", cfg_path.display());
return 1;
}
}
} else if cli.strict {
eprintln!(
"ERROR: --strict: config file missing: {}",
cfg_path.display()
);
return 1;
} else {
println!(
"Note: {} absent — using empty TOML defaults for preview",
cfg_path.display()
);
AnalogFileConfig::default()
};
let prof_path = resolve_analog_profile_path(
&cli.workspace,
cli.profile.clone(),
file_cfg.profile.as_deref(),
);
let mut ok = true;
match &prof_path {
None => println!(
"Profile: (none — no CLI/TOML path and no default ~/.claw-analog/profile.toml)"
),
Some(p) => match load_profile_hint(p) {
Ok(Some(line)) => println!(
"OK: profile {} (line: {} chars)",
p.display(),
line.chars().count()
),
Ok(None) => println!("OK: profile {} (empty `line`)", p.display()),
Err(e) => {
eprintln!("ERROR: profile {}: {e}", p.display());
ok = false;
}
},
}
let lang = file_cfg
.language
.as_deref()
.and_then(AnalogLanguage::from_toml_str)
.unwrap_or_default();
let r = resolve_analog_options(&file_cfg, &AnalogDoctorOverrides::default());
println!("\nMerge preview (TOML + defaults only; main-run CLI flags not applied):");
println!(" language (TOML): {}", lang.as_str());
println!(" model: {}", r.model);
println!(" permission: {}", r.permission_mode.as_str());
println!(" preset: {}", r.preset.label().unwrap_or("none"));
println!(
" output_format: {}",
match r.output_format {
OutputFormat::Rich => "rich",
OutputFormat::Json => "json",
}
);
println!(" stream: {}", r.use_stream);
println!(
" runtime_enforcer: {}",
if r.use_runtime_enforcer { "on" } else { "off" }
);
println!(
" accept_danger_non_interactive: {}",
r.accept_danger_non_interactive
);
println!(" Provenance:");
for line in &r.provenance {
println!(" - {line}");
}
i32::from(!ok)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strict_fails_when_config_missing() {
let dir = tempfile::tempdir().unwrap();
let code = run_validate(ValidateCli {
workspace: dir.path().to_path_buf(),
config: None,
strict: true,
profile: None,
});
assert_eq!(code, 1);
}
#[test]
fn parses_when_config_present() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join(".claw-analog.toml");
std::fs::write(&p, r#"model = "sonnet""#).unwrap();
let code = run_validate(ValidateCli {
workspace: dir.path().to_path_buf(),
config: None,
strict: true,
profile: None,
});
assert_eq!(code, 0);
}
}

View File

@@ -0,0 +1,733 @@
//! `claw-analog doctor` — environment and Cargo sanity checks.
use std::net::{TcpStream, ToSocketAddrs};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use clap::ValueEnum;
use claw_analog::{
load_analog_toml, load_profile_hint, resolve_analog_options, AnalogDoctorOverrides,
AnalogFileConfig, OutputFormat, PermissionMode, Preset, StreamOverride, NDJSON_FORMAT_VERSION,
NDJSON_SCHEMA,
};
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
const ENV_CHECK: &[&str] = &[
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_BASE_URL",
"OPENAI_API_KEY",
"OPENAI_BASE_URL",
"XAI_API_KEY",
"RAG_BASE_URL",
];
#[derive(Copy, Clone, Debug, ValueEnum)]
pub enum DoctorPermissionArg {
ReadOnly,
WorkspaceWrite,
Prompt,
#[value(name = "danger-full-access")]
DangerFullAccess,
Allow,
}
impl From<DoctorPermissionArg> for PermissionMode {
fn from(p: DoctorPermissionArg) -> Self {
match p {
DoctorPermissionArg::ReadOnly => PermissionMode::ReadOnly,
DoctorPermissionArg::WorkspaceWrite => PermissionMode::WorkspaceWrite,
DoctorPermissionArg::Prompt => PermissionMode::Prompt,
DoctorPermissionArg::DangerFullAccess => PermissionMode::DangerFullAccess,
DoctorPermissionArg::Allow => PermissionMode::Allow,
}
}
}
#[derive(Copy, Clone, Debug, ValueEnum)]
pub enum DoctorOutputArg {
Rich,
Json,
}
impl From<DoctorOutputArg> for OutputFormat {
fn from(o: DoctorOutputArg) -> Self {
match o {
DoctorOutputArg::Rich => OutputFormat::Rich,
DoctorOutputArg::Json => OutputFormat::Json,
}
}
}
#[derive(Copy, Clone, Debug, ValueEnum)]
pub enum DoctorPresetCli {
None,
Audit,
Explain,
Implement,
}
impl From<DoctorPresetCli> for Preset {
fn from(p: DoctorPresetCli) -> Self {
match p {
DoctorPresetCli::None => Preset::None,
DoctorPresetCli::Audit => Preset::Audit,
DoctorPresetCli::Explain => Preset::Explain,
DoctorPresetCli::Implement => Preset::Implement,
}
}
}
#[derive(Debug, clap::Args)]
pub struct DoctorCli {
/// Workspace root (same as `claw-analog -w`; config defaults to `<workspace>/.claw-analog.toml`).
#[arg(short = 'w', long, default_value = ".", value_name = "DIR")]
pub workspace: PathBuf,
/// Config path (default: `<workspace>/.claw-analog.toml`).
#[arg(long, value_name = "PATH")]
pub config: Option<PathBuf>,
/// Override model (same precedence as main CLI).
#[arg(long)]
pub model: Option<String>,
#[arg(long, value_enum)]
pub permission: Option<DoctorPermissionArg>,
#[arg(long, value_enum)]
pub preset: Option<DoctorPresetCli>,
#[arg(long, value_enum)]
pub output_format: Option<DoctorOutputArg>,
#[arg(long, default_value_t = false, conflicts_with = "no_stream")]
pub stream: bool,
#[arg(long, default_value_t = false, conflicts_with = "stream")]
pub no_stream: bool,
/// Disable `runtime::PermissionEnforcer` (same as main CLI).
#[arg(
long = "no-runtime-enforcer",
default_value_t = false,
action = clap::ArgAction::SetTrue
)]
pub no_runtime_enforcer: bool,
#[arg(
long = "accept-danger-non-interactive",
default_value_t = false,
action = clap::ArgAction::SetTrue
)]
pub accept_danger_non_interactive: bool,
/// Profile TOML path (optional; if omitted, uses TOML `profile` or default `~/.claw-analog/profile.toml`).
#[arg(long, value_name = "PATH")]
pub profile: Option<PathBuf>,
/// TCP connect to host:port from `ANTHROPIC_BASE_URL` (or default API URL); not a full HTTP check.
#[arg(long, visible_alias = "mock")]
pub tcp_ping: bool,
/// Skip HTTPS/TLS + auth + quota header checks against configured providers.
#[arg(long, default_value_t = false)]
pub no_http_check: bool,
/// Also probe the embeddings endpoint for OpenAI-compatible providers (may incur minimal cost).
#[arg(long, default_value_t = false)]
pub embeddings_check: bool,
/// Skip compile check (`cargo check` / `build --release`).
#[arg(long)]
pub no_build: bool,
/// Run `cargo build --release -p claw-analog` (writes `target/release/…`, safe while `cargo run` holds `target/debug/…` on Windows).
#[arg(long, conflicts_with = "no_build")]
pub release_build: bool,
/// Directory containing the repo workspace `Cargo.toml` (default: search upward from cwd).
#[arg(long, value_name = "DIR")]
pub manifest_dir: Option<PathBuf>,
}
pub fn run_doctor(args: DoctorCli) -> i32 {
println!("claw-analog doctor — environment and build checks\n");
let workspace = args.workspace.clone();
let canon_ws = std::fs::canonicalize(&workspace).unwrap_or_else(|_| workspace.clone());
let cfg_path = args
.config
.clone()
.unwrap_or_else(|| workspace.join(".claw-analog.toml"));
let (file_cfg, cfg_note) = if cfg_path.is_file() {
match load_analog_toml(&cfg_path) {
Ok(c) => (c, "loaded"),
Err(e) => {
eprintln!(
"[claw-analog] doctor: failed to parse {}: {e} (using empty TOML defaults)",
cfg_path.display()
);
(AnalogFileConfig::default(), "parse error (defaults)")
}
}
} else {
(AnalogFileConfig::default(), "file missing (defaults only)")
};
let stream_ov = if args.no_stream {
StreamOverride::ForceOff
} else if args.stream {
StreamOverride::ForceOn
} else {
StreamOverride::FromFile
};
let overrides = AnalogDoctorOverrides {
model: args.model.clone(),
permission: args.permission.map(Into::into),
preset: args.preset.map(Into::into),
output_format: args.output_format.map(Into::into),
stream: stream_ov,
no_runtime_enforcer: args.no_runtime_enforcer,
accept_danger_non_interactive: args.accept_danger_non_interactive,
};
let resolved = resolve_analog_options(&file_cfg, &overrides);
println!("NDJSON contract (for `--output-format json` runs):");
println!(" schema: {NDJSON_SCHEMA}");
println!(" format_version: {NDJSON_FORMAT_VERSION}\n");
println!("Effective config (merge of `.claw-analog.toml` + flags below):");
println!(" workspace: {}", canon_ws.display());
println!(" config: {} ({cfg_note})", cfg_path.display());
println!(" model: {}", resolved.model);
println!(" permission: {}", resolved.permission_mode.as_str());
println!(" preset: {}", resolved.preset.label().unwrap_or("none"));
println!(
" output_format: {}",
match resolved.output_format {
OutputFormat::Rich => "rich",
OutputFormat::Json => "json",
}
);
println!(" stream: {}", resolved.use_stream);
println!(
" runtime_enforcer: {}",
if resolved.use_runtime_enforcer {
"on"
} else {
"off"
}
);
println!(
" accept_danger_non_interactive: {}",
resolved.accept_danger_non_interactive
);
println!(" Provenance (which side won src ← …):");
for line in &resolved.provenance {
println!(" - {line}");
}
println!();
let prof = resolve_profile_path_doctor(
args.profile.as_ref(),
file_cfg.profile.as_deref(),
&workspace,
);
print_profile_hint_section(&prof);
println!();
check_env();
println!();
let build_ok = if args.no_build {
println!("cargo: skipped (--no-build)");
true
} else if args.release_build {
run_cargo_release_build(args.manifest_dir.as_deref())
} else {
run_cargo_check(args.manifest_dir.as_deref())
};
println!();
if args.tcp_ping {
ping_print();
println!();
}
if !args.no_http_check {
http_checks_print(args.embeddings_check);
println!();
}
if build_ok {
0
} else {
1
}
}
fn home_dir() -> Option<PathBuf> {
#[cfg(windows)]
{
std::env::var_os("USERPROFILE").map(PathBuf::from)
}
#[cfg(not(windows))]
{
std::env::var_os("HOME").map(PathBuf::from)
}
}
fn expand_user_path(raw: &str) -> PathBuf {
if let Some(rest) = raw.strip_prefix("~/") {
home_dir()
.map(|h| h.join(rest))
.unwrap_or_else(|| PathBuf::from(raw))
} else {
PathBuf::from(raw)
}
}
fn resolve_profile_path_doctor(
cli: Option<&PathBuf>,
file: Option<&str>,
workspace: &Path,
) -> Option<PathBuf> {
if let Some(p) = cli {
return Some(if p.is_absolute() {
p.clone()
} else {
workspace.join(p)
});
}
if let Some(s) = file {
let p = expand_user_path(s.trim());
return Some(if p.is_absolute() {
p
} else {
workspace.join(p)
});
}
let def = home_dir()?.join(".claw-analog").join("profile.toml");
if def.is_file() {
Some(def)
} else {
None
}
}
fn print_profile_hint_section(path: &Option<PathBuf>) {
println!("Profile (system prompt snippet):");
match path {
None => println!(" (none — no --profile, no `profile` in TOML, default file absent)"),
Some(p) => {
print!(" path: {}", p.display());
match load_profile_hint(p) {
Ok(Some(h)) => println!(" — loaded, {} chars", h.chars().count()),
Ok(None) => println!(" — file ok, empty `line`"),
Err(e) => println!(" — error: {e}"),
}
}
}
}
fn mask_env_line(name: &str) {
match std::env::var(name) {
Ok(v) if !v.trim().is_empty() => {
println!(" {name}: set ({} chars)", v.chars().count());
}
Ok(_) => println!(" {name}: set but empty"),
Err(_) => println!(" {name}: unset"),
}
}
fn check_env() {
println!("Environment (values are not printed):");
for name in ENV_CHECK {
mask_env_line(name);
}
let anthro_ok = std::env::var("ANTHROPIC_API_KEY")
.map(|s| !s.trim().is_empty())
.unwrap_or(false)
|| std::env::var("ANTHROPIC_AUTH_TOKEN")
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
let openai_ok = std::env::var("OPENAI_API_KEY")
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
println!();
if anthro_ok {
println!("Anthropic credentials: OK (API key and/or auth token).");
} else {
println!("Anthropic credentials: not set — needed for default Claude/Anthropic models.");
}
if openai_ok {
println!("OpenAI API key: set — use `openai/...` model prefix for that provider.");
} else {
println!("OpenAI API key: unset — only relevant for `openai/` models.");
}
if !anthro_ok && !openai_ok {
println!("\nNote: neither Anthropic nor OpenAI keys are set; live runs will fail until you export credentials (see USAGE.md).");
}
}
/// Walk upward from `start` for a `Cargo.toml` that defines `[workspace]`.
pub fn discover_cargo_workspace(start: &Path) -> Option<PathBuf> {
let mut dir = start.to_path_buf();
for _ in 0..32 {
let manifest = dir.join("Cargo.toml");
if manifest.is_file() {
if let Ok(txt) = std::fs::read_to_string(&manifest) {
if txt.contains("[workspace]") {
return Some(dir);
}
}
}
dir = dir.parent()?.to_path_buf();
}
None
}
fn workspace_root_or_eprint(manifest_dir: Option<&Path>) -> Option<PathBuf> {
let start = manifest_dir
.map(Path::to_path_buf)
.or_else(|| std::env::current_dir().ok())
.unwrap_or_else(|| PathBuf::from("."));
discover_cargo_workspace(&start).or_else(|| {
eprintln!(
"cargo: could not find a [workspace] Cargo.toml above {}.\n Pass --manifest-dir pointing at the `rust` folder of claw-code.",
start.display()
);
None
})
}
/// `cargo check` does not replace `target/debug/claw-analog.exe`, so `cargo run … doctor` works on Windows.
fn run_cargo_check(manifest_dir: Option<&Path>) -> bool {
let Some(root) = workspace_root_or_eprint(manifest_dir) else {
return false;
};
println!("cargo check -p claw-analog (workspace {})", root.display());
println!(" (compile-only; avoids “access denied” replacing the running debug exe on Windows)");
let status = Command::new("cargo")
.args(["check", "-p", "claw-analog"])
.current_dir(&root)
.status();
match status {
Ok(s) if s.success() => {
println!("cargo check: OK");
true
}
Ok(s) => {
eprintln!("cargo check: failed ({s})");
false
}
Err(e) => {
eprintln!("cargo check: could not run `cargo` ({e}). Is Rust/Cargo on PATH?");
false
}
}
}
fn run_cargo_release_build(manifest_dir: Option<&Path>) -> bool {
let Some(root) = workspace_root_or_eprint(manifest_dir) else {
return false;
};
println!(
"cargo build --release -p claw-analog (workspace {})",
root.display()
);
println!(" (output in target/release/; does not overwrite a running target/debug/ binary)");
let status = Command::new("cargo")
.args(["build", "--release", "-p", "claw-analog"])
.current_dir(&root)
.status();
match status {
Ok(s) if s.success() => {
println!("cargo build --release: OK");
true
}
Ok(s) => {
eprintln!("cargo build --release: failed ({s})");
false
}
Err(e) => {
eprintln!("cargo build --release: could not run `cargo` ({e}). Is Rust/Cargo on PATH?");
false
}
}
}
fn default_anthropic_base() -> String {
std::env::var("ANTHROPIC_BASE_URL").unwrap_or_else(|_| "https://api.anthropic.com".into())
}
fn parse_host_port(url: &str) -> Result<(String, u16), String> {
let url = url.trim().trim_end_matches('/');
let (scheme, rest) = if let Some(r) = url.strip_prefix("https://") {
("https", r)
} else if let Some(r) = url.strip_prefix("http://") {
("http", r)
} else {
return Err("URL must start with http:// or https://".into());
};
let host_part = rest
.split('/')
.next()
.filter(|s| !s.is_empty())
.ok_or_else(|| "missing host".to_string())?;
if let Some((host, port_s)) = host_part.rsplit_once(':') {
if let Ok(p) = port_s.parse::<u16>() {
let host = host.trim_start_matches('[').trim_end_matches(']');
return Ok((host.to_string(), p));
}
}
let default_port = if scheme == "https" { 443 } else { 80 };
Ok((host_part.to_string(), default_port))
}
fn ping_print() {
let url = default_anthropic_base();
println!("TCP check for ANTHROPIC_BASE_URL (default if unset): {url}");
match parse_host_port(&url) {
Ok((host, port)) => match tcp_ping(&host, port) {
Ok(()) => println!(" reachability: OK ({host}:{port})"),
Err(e) => println!(" reachability: FAIL ({host}:{port}) — {e}"),
},
Err(e) => println!(" could not parse URL: {e}"),
}
println!(" (HTTP/TLS application data is not validated; this is connect() only.)");
}
fn tcp_ping(host: &str, port: u16) -> Result<(), String> {
let addr = (host, port)
.to_socket_addrs()
.map_err(|e| e.to_string())?
.next()
.ok_or_else(|| "no resolved addresses".to_string())?;
TcpStream::connect_timeout(&addr, Duration::from_secs(3)).map_err(|e| e.to_string())?;
Ok(())
}
fn http_checks_print(embeddings_check: bool) {
println!("HTTP/TLS checks (auth + TLS validation + quota headers when available):");
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build();
let Ok(rt) = rt else {
println!(" runtime: FAIL (could not build tokio runtime)");
return;
};
rt.block_on(async {
// OpenAI-compatible providers (OPENAI_BASE_URL, OPENAI_API_KEY)
if let Ok(key) = std::env::var("OPENAI_API_KEY") {
if !key.trim().is_empty() {
let base = std::env::var("OPENAI_BASE_URL")
.ok()
.unwrap_or_else(|| "https://api.openai.com/v1".to_string());
let url = openai_models_url(base.as_str());
let mut headers = HeaderMap::new();
if let Ok(v) = HeaderValue::from_str(format!("Bearer {}", key.trim()).as_str()) {
headers.insert(reqwest::header::AUTHORIZATION, v);
}
let _ = http_check_and_print("openai", url.as_str(), headers).await;
if embeddings_check {
let model = std::env::var("OPENAI_EMBEDDING_MODEL")
.ok()
.or_else(|| std::env::var("CLAW_RAG_EMBEDDING_MODEL").ok())
.unwrap_or_else(|| "text-embedding-3-small".to_string());
let eurl = openai_embeddings_url(base.as_str());
let mut eheaders = HeaderMap::new();
if let Ok(v) = HeaderValue::from_str(format!("Bearer {}", key.trim()).as_str())
{
eheaders.insert(reqwest::header::AUTHORIZATION, v);
}
let _ = openai_embeddings_probe(
"openai embeddings",
eurl.as_str(),
&model,
eheaders,
)
.await;
} else {
println!(" openai embeddings: skipped (pass --embeddings-check to enable)");
}
} else {
println!(" openai: skipped (OPENAI_API_KEY empty)");
}
} else {
println!(" openai: skipped (OPENAI_API_KEY unset)");
}
// Anthropic (ANTHROPIC_BASE_URL, ANTHROPIC_API_KEY/AUTH_TOKEN)
let a_key = std::env::var("ANTHROPIC_API_KEY").ok();
let a_tok = std::env::var("ANTHROPIC_AUTH_TOKEN").ok();
let a_base = std::env::var("ANTHROPIC_BASE_URL")
.ok()
.unwrap_or_else(|| "https://api.anthropic.com".to_string());
if a_key.as_deref().is_some_and(|s| !s.trim().is_empty())
|| a_tok.as_deref().is_some_and(|s| !s.trim().is_empty())
{
let url = anthropic_models_url(a_base.as_str());
let mut headers = HeaderMap::new();
headers.insert(
HeaderName::from_static("anthropic-version"),
HeaderValue::from_static("2023-06-01"),
);
if let Some(k) = a_key.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
if let Ok(v) = HeaderValue::from_str(k) {
headers.insert(HeaderName::from_static("x-api-key"), v);
}
} else if let Some(t) = a_tok.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
if let Ok(v) = HeaderValue::from_str(format!("Bearer {t}").as_str()) {
headers.insert(reqwest::header::AUTHORIZATION, v);
}
}
let _ = http_check_and_print("anthropic", url.as_str(), headers).await;
} else {
println!(" anthropic: skipped (no API key/token)");
}
// RAG service (RAG_BASE_URL) — just basic health + stats.
if let Ok(base) = std::env::var("RAG_BASE_URL") {
let base = base.trim().trim_end_matches('/');
if !base.is_empty() {
let headers = HeaderMap::new();
let _ =
http_check_and_print("rag health", &format!("{base}/health"), headers.clone())
.await;
let _ =
http_check_and_print("rag stats", &format!("{base}/v1/stats"), headers).await;
}
}
});
println!(" (TLS validation is performed by the HTTP client; certificate errors surface as request failures.)");
}
fn openai_models_url(base: &str) -> String {
let b = base.trim().trim_end_matches('/');
if b.ends_with("/v1") {
format!("{b}/models")
} else {
format!("{b}/v1/models")
}
}
fn openai_embeddings_url(base: &str) -> String {
let b = base.trim().trim_end_matches('/');
if b.ends_with("/v1") {
format!("{b}/embeddings")
} else {
format!("{b}/v1/embeddings")
}
}
fn anthropic_models_url(base: &str) -> String {
let b = base.trim().trim_end_matches('/');
format!("{b}/v1/models?limit=1")
}
async fn http_check_and_print(label: &str, url: &str, headers: HeaderMap) -> Result<(), ()> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(8))
.build();
let Ok(client) = client else {
println!(" {label}: FAIL (client build)");
return Err(());
};
let resp = client.get(url).headers(headers).send().await;
match resp {
Ok(r) => {
let status = r.status();
println!(" {label}: {status} ({url})");
print_quota_headers(r.headers());
Ok(())
}
Err(e) => {
let msg = e.to_string();
if msg.to_ascii_lowercase().contains("certificate")
|| msg.to_ascii_lowercase().contains("tls")
{
println!(" {label}: FAIL (TLS/cert) ({url}) — {msg}");
} else {
println!(" {label}: FAIL ({url}) — {msg}");
}
Err(())
}
}
}
fn print_quota_headers(headers: &HeaderMap) {
let mut out: Vec<(String, String)> = Vec::new();
for (k, v) in headers.iter() {
let name = k.as_str().to_ascii_lowercase();
if name.contains("ratelimit") || name.contains("quota") {
if let Ok(s) = v.to_str() {
out.push((k.as_str().to_string(), s.to_string()));
}
}
// OpenAI-compatible common headers:
if name.starts_with("x-ratelimit-") {
if let Ok(s) = v.to_str() {
out.push((k.as_str().to_string(), s.to_string()));
}
}
}
out.sort();
out.dedup();
for (k, v) in out {
println!(" {k}: {v}");
}
}
async fn openai_embeddings_probe(
label: &str,
url: &str,
model: &str,
headers: HeaderMap,
) -> Result<(), ()> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(12))
.build();
let Ok(client) = client else {
println!(" {label}: FAIL (client build)");
return Err(());
};
// Minimal request: one short string. We don't parse the embedding content.
let body = serde_json::json!({
"model": model,
"input": ["ping"]
});
let resp = client.post(url).headers(headers).json(&body).send().await;
match resp {
Ok(r) => {
let status = r.status();
println!(" {label}: {status} ({url}) model={model}");
print_quota_headers(r.headers());
if !status.is_success() {
let t = r.text().await.unwrap_or_default();
if !t.trim().is_empty() {
println!(" body: {}", t.chars().take(400).collect::<String>());
}
return Err(());
}
Ok(())
}
Err(e) => {
let msg = e.to_string();
if msg.to_ascii_lowercase().contains("certificate")
|| msg.to_ascii_lowercase().contains("tls")
{
println!(" {label}: FAIL (TLS/cert) ({url}) — {msg}");
} else {
println!(" {label}: FAIL ({url}) — {msg}");
}
Err(())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_base_url_host_port() {
assert_eq!(
parse_host_port("http://127.0.0.1:8080/v1").unwrap(),
("127.0.0.1".into(), 8080)
);
assert_eq!(
parse_host_port("https://api.anthropic.com").unwrap(),
("api.anthropic.com".into(), 443)
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,522 @@
//! Binary wrapper for `claw_analog::run` — see `how_to_run.md` in repo root.
mod agents;
mod config_cmd;
mod doctor;
use std::path::{Path, PathBuf};
use std::time::Duration;
use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
use clap_complete::{generate, Shell};
use claw_analog::{
load_analog_toml, load_profile_hint, permission_mode_from_toml_str, print_tools_dry_run,
resolve_analog_profile_path, resolve_rag_base_url, AnalogConfig, AnalogFileConfig,
AnalogLanguage, OutputFormat, PermissionMode, Preset, ANALOG_DEFAULT_MODEL,
};
#[derive(Copy, Clone, Debug, ValueEnum)]
enum PermissionArg {
ReadOnly,
WorkspaceWrite,
Prompt,
#[value(name = "danger-full-access")]
DangerFullAccess,
/// Same unrestricted posture as danger-full-access for this narrow tool set.
Allow,
}
#[derive(Copy, Clone, Debug, ValueEnum)]
enum OutputFormatArg {
Rich,
Json,
}
#[derive(Copy, Clone, Debug, ValueEnum)]
enum LangArg {
En,
Ru,
}
impl From<LangArg> for AnalogLanguage {
fn from(a: LangArg) -> Self {
match a {
LangArg::En => AnalogLanguage::En,
LangArg::Ru => AnalogLanguage::Ru,
}
}
}
#[derive(Copy, Clone, Debug, ValueEnum)]
enum PresetCli {
None,
/// Automatically infer a preset from the initial prompt.
Auto,
Audit,
Explain,
Implement,
}
impl From<PresetCli> for Preset {
fn from(p: PresetCli) -> Self {
match p {
PresetCli::None => Preset::None,
PresetCli::Auto => Preset::None,
PresetCli::Audit => Preset::Audit,
PresetCli::Explain => Preset::Explain,
PresetCli::Implement => Preset::Implement,
}
}
}
#[derive(Parser, Debug)]
#[command(
name = "claw-analog",
version,
about = "Lean tool-agent loop (read/list/grep/write) on claw-code `api` providers"
)]
#[command(args_conflicts_with_subcommands = true)]
struct RootCli {
#[command(subcommand)]
command: Option<Commands>,
#[command(flatten)]
run: RunCli,
}
#[derive(Subcommand, Debug)]
enum Commands {
/// Verify credentials, `cargo check -p claw-analog` (or `--release-build`), config merge preview, optional `--tcp-ping`.
Doctor(doctor::DoctorCli),
Config {
#[command(subcommand)]
command: ConfigSub,
},
/// Print shell completion script for this binary (redirect to a file or `source` it).
Complete(CompleteCli),
/// Run multiple specialized sub-agents sequentially (shared base session).
Agents(agents::AgentsCli),
}
#[derive(Subcommand, Debug)]
enum ConfigSub {
/// Parse `.claw-analog.toml` and profile; print a merge preview (no API calls).
Validate(config_cmd::ValidateCli),
}
#[derive(Parser, Debug)]
struct CompleteCli {
#[arg(value_enum)]
shell: ShellKind,
}
#[derive(Copy, Clone, Debug, ValueEnum)]
enum ShellKind {
Bash,
Zsh,
Fish,
#[value(name = "powershell", alias = "pwsh")]
Powershell,
}
#[derive(Parser, Debug)]
struct RunCli {
/// Config file (default: `<workspace>/.claw-analog.toml` if that path exists).
#[arg(long, value_name = "PATH")]
config: Option<PathBuf>,
#[arg(short, long)]
model: Option<String>,
#[arg(short = 'w', long, default_value = ".")]
workspace: PathBuf,
#[arg(long, value_enum)]
permission: Option<PermissionArg>,
#[arg(long, value_enum)]
preset: Option<PresetCli>,
/// Reply language hint for the assistant (`en` or `ru` in system prompt; not the API model id).
#[arg(long, value_enum)]
lang: Option<LangArg>,
/// Print effective tools for merged `permission` / enforcer, then exit (no prompt, no API).
#[arg(long, default_value_t = false, action = clap::ArgAction::SetTrue)]
print_tools: bool,
/// Persist message history for resume (JSON). See `how_to_run.md` for risks.
#[arg(long, value_name = "PATH")]
session: Option<PathBuf>,
/// Write session JSON to this path on each snapshot (export without `--session`, or an extra copy).
#[arg(long, value_name = "PATH")]
save_session: Option<PathBuf>,
/// Profile snippet TOML (`line = "..."`). Default: `~/.claw-analog/profile.toml` if it exists.
#[arg(long, value_name = "PATH")]
profile: Option<PathBuf>,
/// Stream assistant text to stdout as tokens arrive (uses `stream_message`).
#[arg(long, default_value_t = false, conflicts_with = "no_stream")]
stream: bool,
/// Turn streaming off (overrides `stream` in config).
#[arg(long, default_value_t = false, conflicts_with = "stream")]
no_stream: bool,
/// Newline-delimited JSON events on stdout (for agents / CI). Diagnostics stay on stderr.
#[arg(long, value_enum)]
output_format: Option<OutputFormatArg>,
/// Disable `runtime::PermissionEnforcer` (paths are still jailed; policy checks are weakened).
#[arg(long = "no-runtime-enforcer", default_value_t = false, action = clap::ArgAction::SetTrue)]
no_runtime_enforcer: bool,
/// Allow `danger-full-access` / `allow` when stdin is not a TTY (CI/automation; use with care).
#[arg(long = "accept-danger-non-interactive", default_value_t = false, action = clap::ArgAction::SetTrue)]
accept_danger_non_interactive: bool,
#[arg(long)]
max_read_bytes: Option<u64>,
#[arg(long)]
max_turns: Option<u32>,
#[arg(long)]
max_list_entries: Option<usize>,
#[arg(long)]
grep_max_lines: Option<usize>,
#[arg(long)]
glob_max_paths: Option<usize>,
#[arg(long)]
glob_max_depth: Option<usize>,
prompt: Option<String>,
}
const DEF_MAX_READ: u64 = 256 * 1024;
const DEF_MAX_TURNS: u32 = 24;
const DEF_MAX_LIST: usize = 500;
const DEF_GREP_MAX: usize = 200;
const DEF_GLOB_PATHS: usize = 2000;
const DEF_GLOB_DEPTH: usize = 32;
const DEF_RAG_TIMEOUT_SECS: u64 = 30;
const DEF_RAG_TOP_K_MAX: u32 = 32;
const RAG_TOP_K_ABS_CAP: u32 = 256;
fn config_file_path(cli: &RunCli) -> PathBuf {
cli.config
.clone()
.unwrap_or_else(|| cli.workspace.join(".claw-analog.toml"))
}
fn load_file_config(path: &Path) -> AnalogFileConfig {
if !path.is_file() {
return AnalogFileConfig::default();
}
match load_analog_toml(path) {
Ok(c) => c,
Err(e) => {
eprintln!(
"[claw-analog] warning: failed to read {}: {e}",
path.display()
);
AnalogFileConfig::default()
}
}
}
fn output_format_from_toml(s: &str) -> Option<OutputFormat> {
match s.to_ascii_lowercase().as_str() {
"json" => Some(OutputFormat::Json),
"rich" => Some(OutputFormat::Rich),
_ => None,
}
}
fn resolve_session_path(
cli: Option<PathBuf>,
file: Option<&str>,
workspace: &Path,
) -> Option<PathBuf> {
let p = cli.or_else(|| file.map(PathBuf::from))?;
Some(if p.is_absolute() {
p
} else {
workspace.join(p)
})
}
fn merge_language(cli: Option<LangArg>, file: Option<&str>) -> AnalogLanguage {
if let Some(l) = cli {
return l.into();
}
file.and_then(AnalogLanguage::from_toml_str)
.unwrap_or_default()
}
fn merge_preset(cli: Option<PresetCli>, file: Option<&str>, prompt: &str) -> Preset {
if let Some(p) = cli {
return match p {
PresetCli::Auto => claw_analog::infer_preset_from_prompt(prompt),
other => Preset::from(other),
};
}
if file.is_some_and(|s| s.trim().eq_ignore_ascii_case("auto")) {
return claw_analog::infer_preset_from_prompt(prompt);
}
if let Some(s) = file.and_then(Preset::from_toml_str) {
return s;
}
claw_analog::infer_preset_from_prompt(prompt)
}
fn merge_permission(
cli: Option<PermissionArg>,
file_perm: Option<String>,
preset: Preset,
) -> PermissionMode {
if let Some(p) = cli {
return match p {
PermissionArg::ReadOnly => PermissionMode::ReadOnly,
PermissionArg::WorkspaceWrite => PermissionMode::WorkspaceWrite,
PermissionArg::Prompt => PermissionMode::Prompt,
PermissionArg::DangerFullAccess => PermissionMode::DangerFullAccess,
PermissionArg::Allow => PermissionMode::Allow,
};
}
if let Some(s) = file_perm.as_deref().and_then(permission_mode_from_toml_str) {
return s;
}
match preset {
Preset::Implement => PermissionMode::WorkspaceWrite,
_ => PermissionMode::ReadOnly,
}
}
fn build_config(
cli: &RunCli,
file: &AnalogFileConfig,
prompt: String,
profile_hint: Option<String>,
session_path: Option<PathBuf>,
preset: Preset,
permission_mode: PermissionMode,
) -> AnalogConfig {
let model = cli
.model
.clone()
.or_else(|| file.model.clone())
.unwrap_or_else(|| ANALOG_DEFAULT_MODEL.into());
let output_format = cli
.output_format
.map(|o| match o {
OutputFormatArg::Rich => OutputFormat::Rich,
OutputFormatArg::Json => OutputFormat::Json,
})
.or_else(|| {
file.output_format
.as_deref()
.and_then(output_format_from_toml)
})
.unwrap_or(OutputFormat::Rich);
let use_stream = if cli.no_stream {
false
} else if cli.stream {
true
} else {
file.stream.unwrap_or(false)
};
let use_runtime_enforcer =
!cli.no_runtime_enforcer && !file.no_runtime_enforcer.unwrap_or(false);
let accept_danger_non_interactive =
cli.accept_danger_non_interactive || file.accept_danger_non_interactive.unwrap_or(false);
let max_read_bytes = cli
.max_read_bytes
.or(file.max_read_bytes)
.unwrap_or(DEF_MAX_READ);
let max_turns = cli.max_turns.or(file.max_turns).unwrap_or(DEF_MAX_TURNS);
let max_list_entries = cli
.max_list_entries
.or(file.max_list_entries)
.unwrap_or(DEF_MAX_LIST);
let grep_max_lines = cli
.grep_max_lines
.or(file.grep_max_lines)
.unwrap_or(DEF_GREP_MAX);
let glob_max_paths = cli
.glob_max_paths
.or(file.glob_max_paths)
.unwrap_or(DEF_GLOB_PATHS);
let glob_max_depth = cli
.glob_max_depth
.or(file.glob_max_depth)
.unwrap_or(DEF_GLOB_DEPTH);
let rag_base_url = resolve_rag_base_url(file);
let rag_http_timeout =
Duration::from_secs(file.rag_timeout_secs.unwrap_or(DEF_RAG_TIMEOUT_SECS).max(1));
let rag_top_k_max = file
.rag_top_k_max
.unwrap_or(DEF_RAG_TOP_K_MAX)
.clamp(1, RAG_TOP_K_ABS_CAP);
let session_save_path = cli.save_session.as_ref().map(|p| {
if p.is_absolute() {
p.clone()
} else {
cli.workspace.join(p)
}
});
let language = merge_language(cli.lang, file.language.as_deref());
AnalogConfig {
model,
workspace: cli.workspace.clone(),
permission_mode,
accept_danger_non_interactive,
use_stream,
output_format,
use_runtime_enforcer,
max_read_bytes,
max_turns,
max_list_entries,
grep_max_lines,
glob_max_paths,
glob_max_depth,
preset,
language,
session_path,
session_save_path,
profile_hint,
prompt,
rag_base_url,
rag_http_timeout,
rag_top_k_max,
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let root = RootCli::parse();
match root.command {
Some(Commands::Doctor(d)) => {
let code = doctor::run_doctor(d);
std::process::exit(code);
}
Some(Commands::Agents(a)) => {
let code = match agents::run_agents(a) {
Ok(()) => 0,
Err(e) => {
eprintln!("agents: {e}");
1
}
};
std::process::exit(code);
}
Some(Commands::Config { command }) => {
let code = match command {
ConfigSub::Validate(v) => config_cmd::run_validate(v),
};
std::process::exit(code);
}
Some(Commands::Complete(co)) => {
let shell = match co.shell {
ShellKind::Bash => Shell::Bash,
ShellKind::Zsh => Shell::Zsh,
ShellKind::Fish => Shell::Fish,
ShellKind::Powershell => Shell::PowerShell,
};
let mut cmd = RootCli::command();
generate(shell, &mut cmd, "claw-analog", &mut std::io::stdout());
return Ok(());
}
None => {}
}
let cli = root.run;
let cfg_path = config_file_path(&cli);
let file_cfg = load_file_config(&cfg_path);
if cli.print_tools {
let preset = merge_preset(
cli.preset,
file_cfg.preset.as_deref(),
&cli.prompt.clone().unwrap_or_default(),
);
let permission_mode = merge_permission(cli.permission, file_cfg.permission.clone(), preset);
let use_runtime_enforcer =
!cli.no_runtime_enforcer && !file_cfg.no_runtime_enforcer.unwrap_or(false);
let rag_url = resolve_rag_base_url(&file_cfg);
print_tools_dry_run(
permission_mode,
use_runtime_enforcer,
rag_url.as_deref(),
&mut std::io::stdout(),
)?;
return Ok(());
}
let pre_output_format = cli
.output_format
.map(|o| match o {
OutputFormatArg::Rich => OutputFormat::Rich,
OutputFormatArg::Json => OutputFormat::Json,
})
.or_else(|| {
file_cfg
.output_format
.as_deref()
.and_then(output_format_from_toml)
})
.unwrap_or(OutputFormat::Rich);
let prompt = if let Some(p) = cli.prompt.clone() {
p
} else {
use std::io::Read;
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
if buf.trim().is_empty() {
if matches!(pre_output_format, OutputFormat::Json) {
println!(
"{}",
serde_json::json!({"type": "error", "message": "empty prompt (pass as arg or stdin)"})
);
}
return Err("empty prompt (pass as arg or stdin)".into());
}
buf
};
let preset = merge_preset(cli.preset, file_cfg.preset.as_deref(), &prompt);
let permission_mode = merge_permission(cli.permission, file_cfg.permission.clone(), preset);
let session_path = resolve_session_path(
cli.session.clone(),
file_cfg.session.as_deref(),
&cli.workspace,
);
let profile_path = resolve_analog_profile_path(
&cli.workspace,
cli.profile.clone(),
file_cfg.profile.as_deref(),
);
let profile_hint = if let Some(ref p) = profile_path {
load_profile_hint(p)?
} else {
None
};
let config = build_config(
&cli,
&file_cfg,
prompt,
profile_hint,
session_path,
preset,
permission_mode,
);
let output_format = config.output_format;
let mut out = std::io::stdout();
if let Err(e) = claw_analog::run(config, &mut out).await {
if matches!(output_format, OutputFormat::Json) {
println!(
"{}",
serde_json::json!({"type": "error", "message": e.to_string()})
);
}
return Err(e);
}
Ok(())
}