use clap::{Parser, Subcommand};
use harness_core::Skill;
use std::path::{Path, PathBuf};
#[derive(Parser, Debug)]
#[command(name = "harness", version, about = "Harness agent framework CLI")]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand, Debug)]
enum Cmd {
Skills {
#[command(subcommand)]
cmd: SkillsCmd,
},
New {
name: String,
#[arg(long)]
path: Option<PathBuf>,
#[arg(long)]
workspace: Option<PathBuf>,
#[arg(long)]
local: bool,
},
Trace {
file: PathBuf,
#[arg(long)]
summary: bool,
#[arg(long, short)]
verbose: bool,
},
Mcp {
#[command(subcommand)]
cmd: McpCmd,
},
Run {
prompt: String,
#[arg(long)]
workspace: Option<PathBuf>,
#[arg(long)]
model: Option<String>,
#[arg(long)]
base_url: Option<String>,
#[arg(long, default_value_t = 12)]
max_iters: u32,
#[arg(long)]
write: bool,
#[arg(long)]
shell: bool,
#[arg(long)]
progress: bool,
#[arg(long)]
json: bool,
},
Sched {
#[command(subcommand)]
cmd: SchedCmd,
},
Code {
#[arg(long)]
workspace: Option<PathBuf>,
#[arg(long)]
model: Option<String>,
#[arg(long)]
base_url: Option<String>,
#[arg(long, default_value_t = 30)]
max_iters: u32,
#[arg(long)]
yolo: bool,
},
}
#[derive(Subcommand, Debug)]
enum SchedCmd {
Add {
name: String,
schedule: String,
prompt: String,
#[arg(long, default_value = "stdout")]
channel: String,
#[arg(long)]
target: Option<String>,
#[arg(long)]
store: Option<PathBuf>,
},
List {
#[arg(long)]
store: Option<PathBuf>,
},
Rm {
id: String,
#[arg(long)]
store: Option<PathBuf>,
},
Enable {
id: String,
#[arg(long)]
store: Option<PathBuf>,
},
Disable {
id: String,
#[arg(long)]
store: Option<PathBuf>,
},
Run {
#[arg(long)]
store: Option<PathBuf>,
#[arg(long)]
workspace: Option<PathBuf>,
#[arg(long)]
model: Option<String>,
#[arg(long)]
base_url: Option<String>,
},
Serve {
#[arg(long)]
store: Option<PathBuf>,
#[arg(long)]
workspace: Option<PathBuf>,
#[arg(long)]
model: Option<String>,
#[arg(long)]
base_url: Option<String>,
#[arg(long, default_value_t = 60)]
tick: u64,
},
}
#[derive(Subcommand, Debug)]
enum McpCmd {
Serve {
#[arg(long)]
workspace: Option<PathBuf>,
#[arg(long)]
skills: Option<PathBuf>,
},
}
#[derive(Subcommand, Debug)]
enum SkillsCmd {
Validate {
path: PathBuf,
},
List {
dir: PathBuf,
},
Export {
target: PathBuf,
#[arg(long)]
from: Option<PathBuf>,
},
Lint {
dir: PathBuf,
},
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber_init();
let cli = Cli::parse();
match cli.cmd {
Cmd::Skills {
cmd: SkillsCmd::Validate { path },
} => match harness_skills::load_skill_dir(&path) {
Ok(s) => {
println!(
"✓ valid: {} — {}",
s.manifest().name,
s.manifest().description
);
Ok(())
}
Err(e) => {
eprintln!("✗ invalid: {e}");
std::process::exit(1);
}
},
Cmd::Skills {
cmd: SkillsCmd::List { dir },
} => {
let skills = harness_skills::scan_skills_root(&dir)?;
for s in &skills {
println!("{} — {}", s.manifest().name, s.manifest().description);
}
println!("\n{} skill(s)", skills.len());
Ok(())
}
Cmd::Skills {
cmd: SkillsCmd::Lint { dir },
} => {
let findings = harness_skills::lint_dir(&dir)?;
if findings.is_empty() {
println!("✓ no lint findings in {}", dir.display());
return Ok(());
}
let mut errors = 0;
let mut warnings = 0;
let mut infos = 0;
for f in &findings {
let tag = match f.severity {
harness_skills::LintSeverity::Error => {
errors += 1;
"ERROR"
}
harness_skills::LintSeverity::Warning => {
warnings += 1;
"WARN "
}
harness_skills::LintSeverity::Info => {
infos += 1;
"INFO "
}
};
println!("[{tag}] {}: {}", f.skill_name, f.message);
}
println!("\n{errors} error(s), {warnings} warning(s), {infos} info");
if errors > 0 {
std::process::exit(1);
}
return Ok(());
}
Cmd::Skills {
cmd: SkillsCmd::Export { target, from },
} => {
let mut registry = harness_skills::SkillRegistry::new().with_macro_skills()?;
if let Some(p) = from {
registry = registry.with_filesystem_root(&p)?;
}
let paths = harness_skills::export_registry(®istry, &target)?;
for p in &paths {
println!("✓ {}", p.display());
}
println!(
"\nexported {} skill(s) to {}",
paths.len(),
target.display()
);
Ok(())
}
Cmd::New {
name,
path,
workspace,
local,
} => scaffold_new_project(name, path, workspace, local),
Cmd::Trace {
file,
summary,
verbose,
} => print_session_trace(file, summary, verbose),
Cmd::Mcp {
cmd: McpCmd::Serve { workspace, skills },
} => run_mcp_server(workspace, skills).await,
Cmd::Run {
prompt,
workspace,
model,
base_url,
max_iters,
write,
shell,
progress,
json,
} => {
run_agent(RunOpts {
prompt,
workspace,
model,
base_url,
max_iters,
write,
shell,
progress,
json,
})
.await
}
Cmd::Sched { cmd } => run_sched(cmd).await,
Cmd::Code {
workspace,
model,
base_url,
max_iters,
yolo,
} => run_code(workspace, model, base_url, max_iters, yolo).await,
}
}
struct RunOpts {
prompt: String,
workspace: Option<PathBuf>,
model: Option<String>,
base_url: Option<String>,
max_iters: u32,
write: bool,
shell: bool,
progress: bool,
json: bool,
}
async fn run_agent(opts: RunOpts) -> anyhow::Result<()> {
use harness_core::Task;
use harness_loop::{AgentLoop, LiveProgressHook, Outcome};
use harness_models::OpenAiCompat;
use std::sync::Arc;
let (base_url, model_id, key) = resolve_endpoint(opts.model, opts.base_url)?;
let root = opts
.workspace
.unwrap_or_else(|| std::env::current_dir().unwrap());
let mut world = harness_context::default_world(root);
let model = OpenAiCompat::with_key(base_url, model_id, key);
let mut loop_ = AgentLoop::new(model)
.with_tool(Arc::new(harness_tools_fs::ReadFile))
.with_tool(Arc::new(harness_tools_fs::ListDir));
if opts.write {
loop_ = loop_
.with_tool(Arc::new(harness_tools_fs::WriteFile))
.with_tool(Arc::new(harness_tools_fs::EditFile));
}
if opts.shell {
loop_ = loop_.with_tool(Arc::new(harness_tools_shell::ShellRead));
}
if opts.progress {
loop_ = loop_.with_hook(Arc::new(LiveProgressHook::new()));
}
let task = Task {
description: opts.prompt,
source: None,
deadline: None,
};
let outcome = loop_
.run_with_max_iters(task, &mut world, opts.max_iters)
.await
.map_err(|e| anyhow::anyhow!("agent run failed: {e}"))?;
if opts.json {
let (kind, text, iters, tools, ti, to) = match &outcome {
Outcome::Done {
text,
iters,
tools_called,
usage,
..
} => (
"done",
text.clone(),
*iters,
*tools_called,
usage.input_tokens,
usage.output_tokens,
),
Outcome::BudgetExhausted {
last_text,
iters,
tools_called,
usage,
..
} => (
"budget_exhausted",
last_text.clone(),
*iters,
*tools_called,
usage.input_tokens,
usage.output_tokens,
),
};
println!(
"{}",
serde_json::json!({
"outcome": kind,
"text": text,
"iters": iters,
"tools_called": tools,
"input_tokens": ti,
"output_tokens": to,
})
);
} else {
match outcome {
Outcome::Done { text, .. } => {
println!("{}", text.unwrap_or_default().trim());
}
Outcome::BudgetExhausted {
last_text, iters, ..
} => {
eprintln!("(budget exhausted after {iters} iters)");
if let Some(t) = last_text {
println!("{}", t.trim());
}
}
}
}
Ok(())
}
struct ReplHook {
yolo: bool,
}
impl harness_core::Hook for ReplHook {
fn name(&self) -> &str {
"repl-ui"
}
fn matches(&self, ev: &harness_core::Event<'_>) -> bool {
matches!(
ev,
harness_core::Event::ModelTokenDelta { .. } | harness_core::Event::PreToolUse { .. }
)
}
fn fire(
&self,
ev: &harness_core::Event<'_>,
_w: &mut harness_core::World,
) -> harness_core::HookOutcome {
use harness_core::{Event, HookOutcome};
use std::io::Write;
match ev {
Event::ModelTokenDelta { text } => {
print!("{text}");
let _ = std::io::stdout().flush();
HookOutcome::Allow
}
Event::PreToolUse { action } => {
let risky = matches!(
action.tool.as_str(),
"write_file" | "edit_file" | "shell_exec"
);
eprintln!(
"\n \x1b[2m⚙ {}{}\x1b[0m",
action.tool,
fmt_tool_args(&action.tool, &action.args)
);
if self.yolo || !risky {
return HookOutcome::Allow;
}
eprint!(" apply this {}? [y/N] ", action.tool);
let _ = std::io::stderr().flush();
let mut line = String::new();
let ok = std::io::stdin().read_line(&mut line).is_ok()
&& matches!(line.trim(), "y" | "Y" | "yes");
if ok {
HookOutcome::Allow
} else {
eprintln!(" \x1b[33m✗ skipped\x1b[0m");
HookOutcome::Deny {
reason: format!(
"user declined the {} action; do not retry it — ask or try another approach",
action.tool
),
}
}
}
_ => HookOutcome::Allow,
}
}
}
fn fmt_tool_args(tool: &str, args: &serde_json::Value) -> String {
let s = |k: &str| args.get(k).and_then(|v| v.as_str()).unwrap_or("");
let trunc = |t: &str, n: usize| {
let t = t.replace('\n', "⏎");
if t.chars().count() > n {
format!("{}…", t.chars().take(n).collect::<String>())
} else {
t
}
};
match tool {
"write_file" => format!(" {} ({} bytes)", s("path"), s("content").len()),
"edit_file" => format!(
" {} {:?}→{:?}",
s("path"),
trunc(s("old_string"), 30),
trunc(s("new_string"), 30)
),
"shell_exec" | "shell_read" => format!(" $ {}", trunc(s("command"), 80)),
"read_file" | "list_dir" | "glob" => format!(" {}", s("path")),
"grep" => format!(" /{}/ {}", trunc(s("pattern"), 40), s("path")),
_ => format!(" {}", trunc(&args.to_string(), 80)),
}
}
async fn run_code(
workspace: Option<PathBuf>,
model: Option<String>,
base_url: Option<String>,
max_iters: u32,
yolo: bool,
) -> anyhow::Result<()> {
use harness_core::{Block, Task, Turn, TurnRole};
use harness_loop::{AgentLoop, Outcome};
use harness_models::OpenAiCompat;
use std::io::Write;
use std::sync::Arc;
let (base_url, model_id, key) = resolve_endpoint(model, base_url)?;
let root = workspace.unwrap_or_else(|| std::env::current_dir().unwrap());
let mut world = harness_context::default_world(root.clone());
let model = OpenAiCompat::with_key(base_url, model_id.clone(), key);
let loop_ = AgentLoop::new(model)
.with_streaming(true)
.with_tool(Arc::new(harness_tools_fs::ReadFile))
.with_tool(Arc::new(harness_tools_fs::ListDir))
.with_tool(Arc::new(harness_tools_fs::Grep))
.with_tool(Arc::new(harness_tools_fs::Glob))
.with_tool(Arc::new(harness_tools_fs::WriteFile))
.with_tool(Arc::new(harness_tools_fs::EditFile))
.with_tool(Arc::new(harness_tools_shell::ShellRead))
.with_tool(Arc::new(harness_tools_shell::ShellExec))
.with_hook(Arc::new(ReplHook { yolo }));
let mode = if yolo {
"\x1b[31mYOLO\x1b[0m (no approval)"
} else {
"\x1b[32mNORMAL\x1b[0m (approve writes)"
};
println!(
"\x1b[1mharness code\x1b[0m — {model_id} · {} · {mode}",
root.display()
);
println!("tools: read · write · edit · list · grep · glob · shell /reset · /exit\n");
let mut seed: Vec<Turn> = Vec::new();
let stdin = std::io::stdin();
loop {
print!("\x1b[1;36m› \x1b[0m");
std::io::stdout().flush().ok();
let mut line = String::new();
if stdin.read_line(&mut line)? == 0 {
break; }
let input = line.trim();
if input.is_empty() {
continue;
}
match input {
"/exit" | "/quit" | ":q" => break,
"/reset" => {
seed.clear();
println!("\x1b[2m(context cleared)\x1b[0m");
continue;
}
_ => {}
}
let task = Task {
description: input.to_string(),
source: None,
deadline: None,
};
let outcome = loop_
.run_with_seed_history(task, seed.clone(), &mut world, max_iters)
.await;
println!(); let reply = match outcome {
Ok(Outcome::Done { text, .. }) => text.unwrap_or_default(),
Ok(Outcome::BudgetExhausted {
last_text, iters, ..
}) => {
eprintln!("\x1b[33m(stopped at {iters}-iter budget)\x1b[0m");
last_text.unwrap_or_default()
}
Err(e) => {
eprintln!("\x1b[31merror:\x1b[0m {e}");
continue;
}
};
seed.push(Turn {
role: TurnRole::User,
blocks: vec![Block::Text(input.to_string())],
});
seed.push(Turn {
role: TurnRole::Assistant,
blocks: vec![Block::Text(reply)],
});
}
println!("bye.");
Ok(())
}
fn resolve_endpoint(
model: Option<String>,
base_url: Option<String>,
) -> anyhow::Result<(String, String, String)> {
let key = std::env::var("HARNESS_API_KEY")
.or_else(|_| std::env::var("DEEPSEEK_API_KEY"))
.map_err(|_| anyhow::anyhow!("set HARNESS_API_KEY (or DEEPSEEK_API_KEY)"))?;
let base_url = base_url
.or_else(|| std::env::var("HARNESS_BASE_URL").ok())
.unwrap_or_else(|| "https://api.deepseek.com".to_string());
let model_id = model
.or_else(|| std::env::var("HARNESS_MODEL").ok())
.unwrap_or_else(|| "deepseek-chat".to_string());
Ok((base_url, model_id, key))
}
fn sched_store_path(explicit: Option<PathBuf>) -> PathBuf {
if let Some(p) = explicit {
return p;
}
match std::env::var_os("HOME") {
Some(home) => PathBuf::from(home).join(".harness").join("jobs.json"),
None => PathBuf::from("harness-jobs.json"),
}
}
async fn run_sched(cmd: SchedCmd) -> anyhow::Result<()> {
use harness_daemon::Schedule;
use harness_scheduler::{FileJobStore, Job, JobStore};
match cmd {
SchedCmd::Add {
name,
schedule,
prompt,
channel,
target,
store,
} => {
let parsed = Schedule::parse(&schedule)
.map_err(|e| anyhow::anyhow!("bad schedule {schedule:?}: {e}"))?;
let store = FileJobStore::open(sched_store_path(store))?;
let now = chrono::Local::now();
let next = parsed.next_after(now).timestamp_millis();
let job = Job::new(name, schedule, prompt, channel, now.timestamp_millis())
.with_target(target)
.with_next_run(Some(next));
let id = job.id.clone();
store.add(&job).await?;
println!("✓ added job {id} (next run: {})", parsed.next_after(now));
Ok(())
}
SchedCmd::List { store } => {
let store = FileJobStore::open(sched_store_path(store))?;
let jobs = store.list().await?;
if jobs.is_empty() {
println!("(no scheduled jobs)");
return Ok(());
}
for j in &jobs {
let state = if j.enabled { "on " } else { "off" };
let next = j
.next_run_ms
.and_then(chrono::DateTime::from_timestamp_millis)
.map(|dt| {
dt.with_timezone(&chrono::Local)
.format("%Y-%m-%d %H:%M")
.to_string()
})
.unwrap_or_else(|| "due".into());
println!(
"[{state}] {} {} ({}) → {} next {}",
j.id, j.name, j.schedule, j.channel, next
);
}
println!("\n{} job(s)", jobs.len());
Ok(())
}
SchedCmd::Rm { id, store } => {
let store = FileJobStore::open(sched_store_path(store))?;
if store.remove(&id).await? {
println!("✓ removed {id}");
Ok(())
} else {
anyhow::bail!("no job with id {id}");
}
}
SchedCmd::Enable { id, store } => {
let store = FileJobStore::open(sched_store_path(store))?;
if store.set_enabled(&id, true).await? {
println!("✓ enabled {id}");
Ok(())
} else {
anyhow::bail!("no job with id {id}");
}
}
SchedCmd::Disable { id, store } => {
let store = FileJobStore::open(sched_store_path(store))?;
if store.set_enabled(&id, false).await? {
println!("✓ disabled {id}");
Ok(())
} else {
anyhow::bail!("no job with id {id}");
}
}
SchedCmd::Run {
store,
workspace,
model,
base_url,
} => {
let sched = build_scheduler(store, workspace, model, base_url)?;
let fired = sched.tick_once().await;
eprintln!("fired {fired} due job(s)");
Ok(())
}
SchedCmd::Serve {
store,
workspace,
model,
base_url,
tick,
} => {
let sched = build_scheduler(store, workspace, model, base_url)?
.with_tick(std::time::Duration::from_secs(tick));
eprintln!("scheduler serving; ticking every {tick}s (Ctrl-C to stop)");
loop {
let _ = sched.tick_once().await;
tokio::time::sleep(std::time::Duration::from_secs(tick)).await;
}
}
}
}
fn build_scheduler(
store: Option<PathBuf>,
workspace: Option<PathBuf>,
model: Option<String>,
base_url: Option<String>,
) -> anyhow::Result<harness_scheduler::Scheduler> {
use harness_models::OpenAiCompat;
use harness_scheduler::{EmailChannel, FileJobStore, JobStore, Scheduler, StdoutChannel};
use std::sync::Arc;
let (base_url, model_id, key) = resolve_endpoint(model, base_url)?;
let model: Arc<dyn harness_core::Model> =
Arc::new(OpenAiCompat::with_key(base_url, model_id, key));
let store: Arc<dyn JobStore> = Arc::new(FileJobStore::open(sched_store_path(store))?);
let root = workspace.unwrap_or_else(|| std::env::current_dir().unwrap());
let mut sched = Scheduler::new(store, model)
.with_repo_root(root)
.with_tool(Arc::new(harness_tools_fs::ReadFile))
.with_tool(Arc::new(harness_tools_fs::ListDir))
.with_tool(Arc::new(harness_tools_shell::ShellRead))
.with_channel(Arc::new(StdoutChannel::new()));
if let Some(email) = EmailChannel::from_env() {
sched = sched.with_channel(Arc::new(email));
}
Ok(sched)
}
async fn run_mcp_server(workspace: Option<PathBuf>, skills: Option<PathBuf>) -> anyhow::Result<()> {
use std::sync::Arc;
let root = workspace.unwrap_or_else(|| std::env::current_dir().unwrap());
let mut world = harness_context::default_world(root);
let mut server = harness_mcp::McpServer::new("harness-mcp", env!("CARGO_PKG_VERSION"))
.with_tools(vec![
Arc::new(harness_tools_fs::ReadFile),
Arc::new(harness_tools_fs::WriteFile),
Arc::new(harness_tools_fs::EditFile),
Arc::new(harness_tools_fs::ListDir),
Arc::new(harness_tools_shell::ShellRead),
]);
if let Some(skills_root) = skills {
let loaded = harness_skills::scan_skills_root(&skills_root)
.map_err(|e| anyhow::anyhow!("scan skills root {}: {e}", skills_root.display()))?;
let arc_skills: Vec<Arc<dyn harness_core::Skill>> = loaded
.into_iter()
.map(|s| Arc::new(s) as Arc<dyn harness_core::Skill>)
.collect();
server = server.with_skills(arc_skills);
}
server.serve_stdio(&mut world).await?;
Ok(())
}
fn print_session_trace(file: PathBuf, summary_only: bool, verbose: bool) -> anyhow::Result<()> {
let events = harness_loop::read_session(&file)
.map_err(|e| anyhow::anyhow!("read {}: {e}", file.display()))?;
let stats = harness_loop::SessionStats::from(&events);
if !summary_only {
for e in &events {
let formatted = if verbose {
harness_loop::format_event_verbose(e)
} else {
harness_loop::format_event_short(e)
};
println!("{formatted}");
}
println!();
}
println!("── summary ────────────────────────────────────────────────");
println!(" events: {}", stats.events);
println!(" model calls: {}", stats.model_calls);
println!(" tool calls: {}", stats.tool_calls);
println!(" compactions: {}", stats.stages_run);
println!(" iterations: {}", stats.iters);
println!(
" tokens: {} in / {} out",
stats.input_tokens, stats.output_tokens
);
println!(" duration: {} ms", stats.duration_ms);
Ok(())
}
fn scaffold_new_project(
name: String,
parent: Option<PathBuf>,
workspace: Option<PathBuf>,
local: bool,
) -> anyhow::Result<()> {
if name.is_empty() {
anyhow::bail!("project name must not be empty");
}
if name.starts_with(|c: char| c.is_ascii_digit()) {
anyhow::bail!("project name must not start with a digit");
}
if name.starts_with('-') || name.ends_with('-') {
anyhow::bail!("project name must not start or end with `-`");
}
for c in name.chars() {
if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') {
anyhow::bail!("project name contains invalid char `{c}` — use [a-z0-9_-]");
}
}
let root = parent.unwrap_or_else(|| std::env::current_dir().unwrap());
let dir = root.join(&name);
if dir.exists() {
anyhow::bail!("`{}` already exists", dir.display());
}
std::fs::create_dir_all(dir.join("src"))?;
let patch_root: Option<PathBuf> = match (workspace.as_ref(), local) {
(Some(p), _) => Some(canon_workspace_root(p)?),
(None, true) => Some(canon_workspace_root(&detect_local_workspace()?)?),
(None, false) => None,
};
let patch_section = if let Some(root) = &patch_root {
format!(
r#"
[patch.crates-io]
harness-rs = {{ path = "{root}/crates/harness" }}
harness-rs-core = {{ path = "{root}/crates/harness-core" }}
harness-rs-loop = {{ path = "{root}/crates/harness-loop" }}
harness-rs-models = {{ path = "{root}/crates/harness-models" }}
harness-rs-tools-fs = {{ path = "{root}/crates/harness-tools-fs" }}
harness-rs-tools-shell = {{ path = "{root}/crates/harness-tools-shell" }}
harness-rs-context = {{ path = "{root}/crates/harness-context" }}
harness-rs-skills = {{ path = "{root}/crates/harness-skills" }}
harness-rs-macros = {{ path = "{root}/crates/harness-macros" }}
harness-rs-hooks = {{ path = "{root}/crates/harness-hooks" }}
harness-rs-compactor = {{ path = "{root}/crates/harness-compactor" }}
harness-rs-blueprint = {{ path = "{root}/crates/harness-blueprint" }}
harness-rs-sandbox = {{ path = "{root}/crates/harness-sandbox" }}
harness-rs-sensors-rust = {{ path = "{root}/crates/harness-sensors-rust" }}
harness-rs-sensors-common = {{ path = "{root}/crates/harness-sensors-common" }}
harness-rs-templates = {{ path = "{root}/crates/harness-templates" }}
harness-rs-mcp = {{ path = "{root}/crates/harness-mcp" }}
"#,
root = root.display()
)
} else {
String::new()
};
let cargo_toml = format!(
r#"[package]
name = "{name}"
version = "0.1.0"
edition = "2024"
description = "An agent built with the harness framework."
[[bin]]
name = "{name}"
path = "src/main.rs"
[dependencies]
harness-rs = "0.0.4"
harness-rs-core = "0.0.4"
harness-rs-loop = "0.0.4"
harness-rs-models = "0.0.4"
harness-rs-tools-fs = "0.0.4"
harness-rs-context = "0.0.4"
tokio = {{ version = "1", features = ["macros", "rt-multi-thread"] }}
anyhow = "1"
serde_json = "1"
{patch_section}"#
);
std::fs::write(dir.join("Cargo.toml"), cargo_toml)?;
let main_rs = r###"//! Minimal harness-rs agent. Lists the current directory, then summarises.
//!
//! Run:
//! DEEPSEEK_API_KEY=sk-… cargo run # default DeepSeek
//! HARNESS_BASE_URL=https://… \
//! HARNESS_MODEL=gpt-5.4 \
//! HARNESS_API_KEY=sk-… cargo run # any OpenAI-compatible
//! HARNESS_PROGRESS=1 cargo run # live stderr trace
use harness::prelude::*;
use harness_context::default_world;
use harness_loop::{AgentLoop, LiveProgressHook};
use harness_models::OpenAiCompat;
use harness_tools_fs::{ListDir, ReadFile};
use std::sync::Arc;
/// Echo any text the user provides. Use when the user asks the agent to repeat something verbatim.
#[harness::skill(
name = "echo",
harness(kind = "computational", risk = "read-only"),
)]
async fn echo(_ctx: &mut Context, _w: &mut World) -> Result<(), harness::SkillError> {
Ok(())
}
/// Reverse a string. Demonstrates a #[tool] with explicit JSON schema.
#[harness::tool(
name = "reverse",
risk = "read-only",
schema = r#"{"type":"object","properties":{"text":{"type":"string"}},"required":["text"]}"#,
)]
async fn reverse(
args: serde_json::Value,
_w: &mut harness::World,
) -> Result<harness::ToolResult, harness::ToolError> {
let t = args["text"].as_str().unwrap_or("");
Ok(harness::ToolResult {
ok: true,
content: serde_json::json!({ "reversed": t.chars().rev().collect::<String>() }),
trace: None,
})
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Env-var-driven endpoint config (works with any OpenAI-compatible API).
let key = std::env::var("HARNESS_API_KEY")
.or_else(|_| std::env::var("DEEPSEEK_API_KEY"))
.map_err(|_| anyhow::anyhow!("set HARNESS_API_KEY or DEEPSEEK_API_KEY"))?;
let base_url = std::env::var("HARNESS_BASE_URL")
.unwrap_or_else(|_| "https://api.deepseek.com".to_string());
let model_id =
std::env::var("HARNESS_MODEL").unwrap_or_else(|_| "deepseek-v4-flash".into());
let model = OpenAiCompat::with_key(base_url, model_id, key);
let mut world = default_world(".");
let mut loop_ = AgentLoop::new(model)
.with_tool(Arc::new(ListDir))
.with_tool(Arc::new(ReadFile));
// Optional: live progress to stderr.
if std::env::var("HARNESS_PROGRESS").is_ok() {
loop_ = loop_.with_hook(Arc::new(LiveProgressHook::new()));
}
let task = Task {
description: "List the top-level files here, then describe what you find in one sentence.".into(),
source: None,
deadline: None,
};
let outcome = loop_.run_with_max_iters(task, &mut world, 6).await?;
println!("{outcome:?}");
Ok(())
}
"###;
std::fs::write(dir.join("src/main.rs"), main_rs)?;
println!("✓ created {}/", dir.display());
println!(" └─ Cargo.toml");
println!(" └─ src/main.rs (one #[skill], one #[tool], a minimal AgentLoop)");
if let Some(root) = &patch_root {
println!(" └─ [patch.crates-io] → {}", root.display());
} else {
println!();
println!("Note: the framework isn't on crates.io yet. To build this project");
println!("today, either re-run with `--local` (auto-detects the harness");
println!("workspace from the installed binary) or `--workspace <path>`,");
println!("or add a [patch.crates-io] section manually.");
}
println!();
println!("Next steps:");
println!(" cd {}", dir.display());
println!(" export DEEPSEEK_API_KEY=…");
println!(" cargo run");
Ok(())
}
fn canon_workspace_root(p: &Path) -> anyhow::Result<PathBuf> {
let c = p
.canonicalize()
.map_err(|e| anyhow::anyhow!("workspace `{}`: {e}", p.display()))?;
let core = c.join("crates/harness-core/Cargo.toml");
let facade = c.join("crates/harness/Cargo.toml");
if !core.exists() || !facade.exists() {
anyhow::bail!(
"{} does not look like a harness workspace (missing crates/harness-core or crates/harness)",
c.display()
);
}
Ok(c)
}
fn detect_local_workspace() -> anyhow::Result<PathBuf> {
let cli_manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
if let Some(crates) = cli_manifest.parent()
&& let Some(root) = crates.parent()
&& root.join("crates/harness-core/Cargo.toml").exists()
{
return Ok(root.to_path_buf());
}
let mut cur = std::env::current_dir()?;
loop {
if cur.join("crates/harness-core/Cargo.toml").exists() {
return Ok(cur);
}
if !cur.pop() {
anyhow::bail!(
"could not auto-detect a harness workspace; pass --workspace <path> explicitly"
);
}
}
}
fn tracing_subscriber_init() {
use tracing_subscriber::{EnvFilter, fmt};
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn"));
let _ = fmt()
.with_env_filter(filter)
.with_writer(std::io::stderr)
.try_init();
}