#[cfg(feature = "tui")]
mod tui;
use clap::{CommandFactory, Parser, Subcommand};
use owo_colors::OwoColorize;
use pawan::{agent::PawanAgent, config::PawanConfig, healing::Healer, PawanError, Result};
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "pawan")]
#[command(
author,
version,
about = "Pawan (पवन) - Self-healing, self-improving CLI coding agent"
)]
#[command(long_about = r#"
Pawan is a powerful CLI coding agent that can:
• Automatically fix compilation errors and warnings
• Execute complex coding tasks
• Generate documentation and commit messages
• Work on any Rust project including itself
Examples:
pawan # Interactive TUI mode
pawan heal # Auto-fix all issues
pawan task "add input validation to CreateAgentRequest"
pawan commit # Generate commit message
pawan improve docs # Generate missing documentation
"#)]
struct Cli {
#[arg(short, long, global = true)]
workspace: Option<PathBuf>,
#[arg(short, long, global = true)]
config: Option<PathBuf>,
#[arg(short, long, global = true)]
model: Option<String>,
#[arg(long, global = true)]
dry_run: bool,
#[arg(long, global = true)]
no_tui: bool,
#[arg(short, long, global = true)]
verbose: bool,
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
Heal {
#[arg(long)]
errors_only: bool,
#[arg(long)]
warnings_only: bool,
#[arg(long)]
tests_only: bool,
#[arg(long)]
commit: bool,
},
Task {
description: String,
},
#[command(alias = "ai-commit")]
Commit {
#[arg(short, long)]
all: bool,
#[arg(long)]
dry_run: bool,
#[arg(short = 'y', long)]
yes: bool,
},
Improve {
target: String,
#[arg(short, long)]
file: Option<PathBuf>,
},
Test {
#[arg(short, long)]
filter: Option<String>,
#[arg(long)]
fix: bool,
},
Review {
#[arg(long)]
staged: bool,
#[arg(short, long)]
file: Option<PathBuf>,
},
Explain {
query: String,
},
Status,
Chat {
#[arg(long)]
resume: Option<String>,
},
Init,
Doctor,
Bootstrap {
#[arg(long)]
skip_mise: bool,
#[arg(long)]
skip_native: bool,
#[arg(long)]
include_deagle: bool,
#[arg(long)]
force: bool,
#[arg(long)]
dry_run: bool,
},
Uninstall {
#[arg(long)]
purge_deagle: bool,
},
Bench,
Notify {
message: String,
#[arg(long, default_value = "whatsapp")]
channel: String,
},
Fmt {
#[arg(long)]
check: bool,
},
Tasks {
#[command(subcommand)]
action: TasksAction,
},
Sessions,
Completions {
#[arg(value_enum)]
shell: clap_complete::Shell,
},
Mcp {
#[command(subcommand)]
action: McpAction,
},
Config {
#[command(subcommand)]
action: ConfigAction,
},
Watch {
#[arg(short, long, default_value = "10")]
interval: u64,
#[arg(long)]
commit: bool,
#[arg(long)]
notify: bool,
},
Distill {
#[arg(short, long)]
session: Option<String>,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long)]
eval: bool,
#[arg(long)]
refine: bool,
#[arg(long)]
student_model: Option<String>,
},
Loop {
#[command(subcommand)]
action: LoopAction,
},
Pom {
#[arg(long)]
pom_url: Option<String>,
#[arg(long)]
complete: bool,
#[arg(long)]
force: bool,
},
Run {
prompt: Option<String>,
#[arg(short = 'f', long)]
file: Option<PathBuf>,
#[arg(short, long, default_value = "text")]
output: String,
#[arg(long, default_value = "300")]
timeout: u64,
#[arg(long)]
max_iterations: Option<usize>,
#[arg(long)]
max_retries: Option<usize>,
#[arg(long)]
save: bool,
#[arg(long)]
stream: bool,
},
}
#[derive(Subcommand)]
enum TasksAction {
List {
#[arg(long, default_value = "all")]
status: String,
#[arg(long)]
priority: Option<u8>,
#[arg(long)]
json: bool,
},
Ready {
#[arg(long)]
json: bool,
},
Create {
title: String,
#[arg(short, long, default_value = "2")]
priority: u8,
#[arg(short, long)]
desc: Option<String>,
},
Update {
id: String,
#[arg(long)]
status: Option<String>,
#[arg(long)]
priority: Option<u8>,
#[arg(long)]
title: Option<String>,
},
Close {
id: String,
#[arg(long)]
reason: Option<String>,
},
Dep {
#[command(subcommand)]
action: DepAction,
},
Decay {
#[arg(long, default_value = "30")]
max_age_days: u64,
},
}
#[derive(Subcommand)]
enum DepAction {
Add {
id: String,
blocks_id: String,
},
Rm {
id: String,
blocks_id: String,
},
}
#[derive(Subcommand)]
enum McpAction {
List,
Serve,
}
#[derive(Subcommand)]
enum ConfigAction {
Show,
Init,
}
#[derive(Subcommand)]
enum LoopAction {
Start {
#[arg(short, long)]
prompt: Option<String>,
#[arg(short, long, default_value = "180")]
interval: u64,
#[arg(long, default_value = "0")]
max_ticks: u64,
},
}
#[tokio::main]
async fn main() {
if let Err(e) = run().await {
eprintln!("{} {}", "Error:".red().bold(), e);
std::process::exit(1);
}
}
async fn run() -> Result<()> {
dotenvy::dotenv().ok();
if let Some(home) = dirs::home_dir() {
let config_env = home.join(".config/pawan/.env");
if config_env.exists() {
dotenvy::from_path_override(&config_env).ok();
}
}
let cli = Cli::parse();
let workspace = cli
.workspace
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
let mut config = PawanConfig::load(cli.config.as_ref())?;
config.apply_env_overrides();
let discovered = config.auto_discover_mcp_servers();
if !discovered.is_empty() && cli.verbose {
eprintln!("Auto-discovered MCP servers: {}", discovered.join(", "));
}
if let Some(model) = cli.model {
config.model = model;
}
if cli.dry_run {
config.dry_run = true;
}
if cli.verbose {
println!("{} {}", "Workspace:".cyan().bold(), workspace.display());
println!("{} {}", "Model:".cyan().bold(), config.model);
if config.dry_run {
println!("{}", "Dry-run mode enabled".yellow());
}
}
match cli.command {
None | Some(Commands::Chat { resume: None }) => {
run_interactive(config, workspace, cli.no_tui, None).await
}
Some(Commands::Chat { resume: Some(id) }) => {
run_interactive(config, workspace, cli.no_tui, Some(id)).await
}
Some(Commands::Init) => run_init(workspace).await,
Some(Commands::Doctor) => run_doctor(config, workspace).await,
Some(Commands::Bootstrap {
skip_mise,
skip_native,
include_deagle,
force,
dry_run,
}) => run_bootstrap(skip_mise, skip_native, include_deagle, force, dry_run).await,
Some(Commands::Uninstall { purge_deagle }) => run_uninstall(purge_deagle).await,
Some(Commands::Bench) => run_bench().await,
Some(Commands::Notify { message, channel }) => run_notify(&message, &channel).await,
Some(Commands::Fmt { check }) => run_fmt(workspace, check).await,
Some(Commands::Tasks { action }) => run_tasks(action).await,
Some(Commands::Sessions) => run_sessions().await,
Some(Commands::Completions { shell }) => {
clap_complete::generate(shell, &mut Cli::command(), "pawan", &mut std::io::stdout());
Ok(())
}
Some(Commands::Mcp { action }) => match action {
McpAction::List => run_mcp_list(config).await,
McpAction::Serve => {
pawan_mcp::server::serve(config).await?;
Ok(())
}
},
Some(Commands::Config { action }) => match action {
ConfigAction::Show => run_config_show(config),
ConfigAction::Init => run_config_init(),
},
Some(Commands::Heal {
errors_only,
warnings_only,
tests_only,
commit,
}) => {
run_heal(
config,
workspace,
errors_only,
warnings_only,
tests_only,
commit,
cli.verbose,
)
.await
}
Some(Commands::Task { description }) => {
run_task(config, workspace, &description, cli.verbose).await
}
Some(Commands::Commit { all, dry_run, yes }) => {
run_commit(config, workspace, all, dry_run, yes).await
}
Some(Commands::Improve { target, file }) => {
run_improve(config, workspace, &target, file, cli.verbose).await
}
Some(Commands::Test { filter, fix }) => run_test(config, workspace, filter, fix).await,
Some(Commands::Review { staged, file }) => {
run_review(config, workspace, staged, file).await
}
Some(Commands::Explain { query }) => run_explain(config, workspace, &query).await,
Some(Commands::Distill { session, output, eval, refine, student_model }) => {
run_distill(config, session, output, eval, refine, student_model).await
}
Some(Commands::Status) => run_status(config, workspace).await,
Some(Commands::Watch { interval, commit, notify }) => {
run_watch(config, workspace, interval, commit, notify).await
}
Some(Commands::Run {
prompt,
file,
output,
timeout,
max_iterations,
max_retries,
save,
stream,
}) => {
run_headless(
config,
workspace,
prompt,
file,
&output,
timeout,
max_iterations,
max_retries,
save,
stream,
cli.verbose,
)
.await
}
Some(Commands::Loop { action }) => run_loop(config, workspace, action, cli.verbose).await,
Some(Commands::Pom { pom_url, complete, force }) => {
run_pom(config, workspace, pom_url, complete, force, cli.verbose).await
}
}
}
async fn run_interactive(
config: PawanConfig,
workspace: PathBuf,
no_tui: bool,
resume_id: Option<String>,
) -> Result<()> {
let mut agent = PawanAgent::new(config.clone(), workspace);
#[cfg(feature = "mcp")]
setup_mcp_tools(&mut agent, &config).await;
if let Some(id) = resume_id {
agent.resume_session(&id)?;
if !no_tui {
eprintln!("Resumed session: {}", id);
}
}
#[cfg(feature = "tui")]
{
if no_tui {
crate::tui::run_simple(agent).await
} else {
crate::tui::run_tui(agent, config.tui).await
}
}
#[cfg(not(feature = "tui"))]
{
let _ = no_tui;
run_simple_cli(agent).await
}
}
async fn run_heal(
mut config: PawanConfig,
workspace: PathBuf,
errors_only: bool,
warnings_only: bool,
tests_only: bool,
commit: bool,
verbose: bool,
) -> Result<()> {
if errors_only {
config.healing.fix_warnings = false;
config.healing.fix_tests = false;
}
if warnings_only {
config.healing.fix_errors = false;
config.healing.fix_tests = false;
}
if tests_only {
config.healing.fix_errors = false;
config.healing.fix_warnings = false;
}
if commit {
config.healing.auto_commit = true;
}
println!("{}", "Pawan Self-Healing Mode".green().bold());
println!("{}", "═".repeat(40).dimmed());
let healer = Healer::new(workspace.clone(), config.healing.clone());
let (errors, warnings, failed_tests) = healer.count_issues().await?;
println!(
"\n{} {} errors, {} warnings, {} failed tests",
"Found:".cyan().bold(),
errors.to_string().red(),
warnings.to_string().yellow(),
failed_tests.to_string().red()
);
if errors == 0 && warnings == 0 && failed_tests == 0 {
println!("\n{}", "✓ Project is healthy!".green().bold());
return Ok(());
}
let mut agent = PawanAgent::new(config.clone(), workspace);
println!("\n{}", "Starting healing process...".cyan());
let response = agent.heal().await?;
println!("\n{}", "═".repeat(40).dimmed());
println!("{}", response.content);
if verbose && !response.tool_calls.is_empty() {
println!("\n{}", "Tool calls made:".dimmed());
for tc in &response.tool_calls {
let status_str = if tc.success { "✓" } else { "✗" };
if tc.success {
println!(
" {} {} ({}ms)",
status_str.green(),
tc.name.cyan(),
tc.duration_ms
);
} else {
println!(
" {} {} ({}ms)",
status_str.red(),
tc.name.cyan(),
tc.duration_ms
);
}
}
}
let (final_errors, final_warnings, final_tests) = healer.count_issues().await?;
println!("\n{}", "Final Status:".cyan().bold());
print!(" Errors: {} → ", errors.to_string().dimmed());
if final_errors < errors {
println!("{}", final_errors.to_string().green());
} else {
println!("{}", final_errors);
}
print!(" Warnings: {} → ", warnings.to_string().dimmed());
if final_warnings < warnings {
println!("{}", final_warnings.to_string().green());
} else {
println!("{}", final_warnings);
}
print!(" Failed Tests: {} → ", failed_tests.to_string().dimmed());
if final_tests < failed_tests {
println!("{}", final_tests.to_string().green());
} else {
println!("{}", final_tests);
}
Ok(())
}
async fn run_task(
config: PawanConfig,
workspace: PathBuf,
description: &str,
verbose: bool,
) -> Result<()> {
println!("{}", "Pawan Task Mode".green().bold());
println!("{}", "═".repeat(40).dimmed());
println!("{} {}", "Task:".cyan().bold(), description);
println!();
let config_ref = config.clone();
let mut agent = PawanAgent::new(config, workspace);
#[cfg(feature = "mcp")]
setup_mcp_tools(&mut agent, &config_ref).await;
let response = agent.task(description).await?;
println!("{}", response.content);
if verbose && !response.tool_calls.is_empty() {
println!("\n{}", "Tool calls made:".dimmed());
for tc in &response.tool_calls {
let status_str = if tc.success { "✓" } else { "✗" };
if tc.success {
println!(
" {} {} ({}ms)",
status_str.green(),
tc.name.cyan(),
tc.duration_ms
);
} else {
println!(
" {} {} ({}ms)",
status_str.red(),
tc.name.cyan(),
tc.duration_ms
);
}
}
}
Ok(())
}
async fn run_commit(
config: PawanConfig,
workspace: PathBuf,
stage_all: bool,
dry_run: bool,
auto_yes: bool,
) -> Result<()> {
use dialoguer::{Confirm, MultiSelect};
let status_output = std::process::Command::new("git")
.args(["status", "--porcelain"])
.current_dir(&workspace)
.output()
.map_err(PawanError::Io)?;
let status_text = String::from_utf8_lossy(&status_output.stdout);
if status_text.trim().is_empty() {
println!("{}", "Nothing to commit — working tree clean.".dimmed());
return Ok(());
}
let mut staged: Vec<String> = Vec::new();
let mut unstaged: Vec<String> = Vec::new();
let mut untracked: Vec<String> = Vec::new();
for line in status_text.lines() {
if line.len() < 4 {
continue;
}
let index_status = line.chars().next().unwrap_or(' ');
let worktree_status = line.chars().nth(1).unwrap_or(' ');
let file = line[3..].trim().to_string();
if index_status == '?' {
untracked.push(file);
} else {
if index_status != ' ' && index_status != '?' {
staged.push(file.clone());
}
if worktree_status != ' ' && worktree_status != '?' {
unstaged.push(file);
}
}
}
if !staged.is_empty() {
println!("{}", "Staged:".green().bold());
for f in &staged {
println!(" {} {}", "✓".green(), f);
}
}
if !unstaged.is_empty() {
println!("{}", "Unstaged:".yellow().bold());
for f in &unstaged {
println!(" {} {}", "~".yellow(), f);
}
}
if !untracked.is_empty() {
println!("{}", "Untracked:".red().bold());
for f in &untracked {
println!(" {} {}", "?".red(), f);
}
}
println!();
let needs_staging = !unstaged.is_empty() || !untracked.is_empty();
if needs_staging {
if stage_all {
println!("{}", "Staging all files...".cyan());
let output = std::process::Command::new("git")
.args(["add", "-A"])
.current_dir(&workspace)
.output()
.map_err(PawanError::Io)?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(PawanError::Git(format!("git add -A failed: {}", stderr)));
}
} else if staged.is_empty() {
let mut all_files: Vec<String> = Vec::new();
all_files.extend(unstaged.iter().map(|f| format!("~ {}", f)));
all_files.extend(untracked.iter().map(|f| format!("? {}", f)));
let selections = MultiSelect::new()
.with_prompt("Select files to stage (space to toggle, enter to confirm)")
.items(&all_files)
.defaults(&vec![true; all_files.len()])
.interact()
.unwrap_or_default();
if selections.is_empty() {
println!("{}", "No files selected. Aborting.".dimmed());
return Ok(());
}
let mut files_to_add: Vec<String> = Vec::new();
for idx in selections {
let raw = &all_files[idx];
files_to_add.push(raw[2..].to_string());
}
let file_refs: Vec<&str> = files_to_add.iter().map(|s| s.as_str()).collect();
let mut args = vec!["add", "--"];
args.extend(file_refs);
let output = std::process::Command::new("git")
.args(&args)
.current_dir(&workspace)
.output()
.map_err(PawanError::Io)?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(PawanError::Git(format!("git add failed: {}", stderr)));
}
println!(
"{}",
format!("Staged {} file(s).", files_to_add.len()).green()
);
}
}
println!("{}", "Generating commit message...".cyan());
let diff_output = std::process::Command::new("git")
.args(["diff", "--cached", "--stat"])
.current_dir(&workspace)
.output()
.map_err(PawanError::Io)?;
let diff_stat = String::from_utf8_lossy(&diff_output.stdout);
let diff_output = std::process::Command::new("git")
.args(["diff", "--cached"])
.current_dir(&workspace)
.output()
.map_err(PawanError::Io)?;
let diff_full = String::from_utf8_lossy(&diff_output.stdout);
if diff_full.trim().is_empty() && staged.is_empty() {
println!(
"{}",
"No staged changes to commit. Use -a to stage all.".dimmed()
);
return Ok(());
}
let diff_for_prompt = if diff_full.len() > 8000 {
format!("{}...\n\n[diff truncated, {} total bytes]", &diff_full[..8000], diff_full.len())
} else {
diff_full.to_string()
};
let prompt = format!(
r#"Generate a concise git commit message for the following changes.
Rules:
- Use conventional commits format (feat:, fix:, refactor:, chore:, docs:, test:)
- First line under 72 chars
- Add a blank line then a brief body (2-4 bullet points) if the changes are non-trivial
- Output ONLY the commit message, nothing else — no markdown fences, no explanation
Diff stat:
{diff_stat}
Full diff:
{diff_for_prompt}"#
);
let mut agent = PawanAgent::new(config, workspace.clone());
let response = agent.execute(&prompt).await?;
let message = response.content.trim().to_string();
let message = message
.strip_prefix("```")
.unwrap_or(&message)
.strip_suffix("```")
.unwrap_or(&message)
.trim()
.to_string();
println!("\n{}", "Diff preview:".cyan().bold());
println!("{}", "─".repeat(50).dimmed());
println!("{}", diff_stat.trim());
println!("{}", "─".repeat(50).dimmed());
let diff_lines: Vec<&str> = diff_full.lines().take(40).collect();
for line in &diff_lines {
println!("{}", line);
}
if diff_full.lines().count() > 40 {
println!("{}", format!("... [{} more lines]", diff_full.lines().count() - 40).dimmed());
}
println!("{}", "─".repeat(50).dimmed());
println!("\n{}", "Commit message:".green().bold());
println!("{}", "─".repeat(50).dimmed());
println!("{}", message);
println!("{}", "─".repeat(50).dimmed());
if dry_run {
println!("\n{}", "(dry run — not committing)".dimmed());
return Ok(());
}
let should_commit = auto_yes
|| Confirm::new()
.with_prompt("Commit with this message?")
.default(true)
.interact()
.unwrap_or(false);
if should_commit {
let output = std::process::Command::new("git")
.args(["commit", "-m", &message])
.current_dir(&workspace)
.output()
.map_err(PawanError::Io)?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
println!("{} {}", "✓".green(), "Committed!".green().bold());
if let Some(line) = stdout.lines().next() {
println!(" {}", line.dimmed());
}
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(PawanError::Git(format!("git commit failed: {}", stderr)));
}
} else {
println!("{}", "Aborted.".dimmed());
}
Ok(())
}
async fn run_test(
config: PawanConfig,
workspace: PathBuf,
filter: Option<String>,
auto_fix: bool,
) -> Result<()> {
let mut test_args = vec!["test", "--workspace"];
let filter_owned;
if let Some(ref f) = filter {
filter_owned = f.clone();
test_args.push("--");
test_args.push(&filter_owned);
}
println!(
"{} {}",
"Running".cyan(),
if let Some(ref f) = filter {
format!("tests matching '{}'...", f)
} else {
"all tests...".to_string()
}
);
let output = std::process::Command::new("cargo")
.args(&test_args)
.current_dir(&workspace)
.output()
.map_err(PawanError::Io)?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let combined = format!("{}\n{}", stdout, stderr);
let passed = combined.matches("test result: ok.").count();
let has_failures = combined.contains("FAILED") || combined.contains("failures:");
if !has_failures {
println!(
"{} {} {}",
"✓".green(),
"All tests passed!".green().bold(),
format!("({} suite(s))", passed).dimmed()
);
return Ok(());
}
let failure_lines: Vec<&str> = combined
.lines()
.filter(|l| {
l.contains("FAILED")
|| l.contains("panicked at")
|| l.contains("assertion")
|| l.contains("failures:")
|| l.contains("---- ")
})
.collect();
println!(
"\n{} {}",
"✗".red(),
"Test failures detected:".red().bold()
);
for line in &failure_lines {
println!(" {}", line);
}
if !auto_fix {
println!(
"\n{}",
"Run with --fix to auto-fix failures.".dimmed()
);
return Ok(());
}
println!("\n{}", "Analyzing and fixing failures...".cyan());
let test_output = if combined.len() > 8000 {
format!("{}...\n[truncated, {} bytes total]", &combined[..8000], combined.len())
} else {
combined.to_string()
};
let prompt = format!(
r#"The following test failures occurred in the project at {}:
```
{}
```
Please:
1. Analyze each failure to understand the root cause
2. Read the relevant source and test files
3. Fix each failure — prefer fixing the implementation over the test unless the test is clearly wrong
4. Run `cargo test` to verify your fixes work"#,
workspace.display(),
test_output
);
let mut agent = PawanAgent::new(config, workspace);
let on_token: pawan::agent::TokenCallback = Box::new(|token: &str| {
use std::io::Write;
print!("{}", token);
std::io::stdout().flush().ok();
});
let response = agent
.execute_with_callbacks(&prompt, Some(on_token), None, None)
.await?;
if !response.content.ends_with('\n') {
println!();
}
println!(
"\n{} {}",
"Done.".green(),
format!("({} iterations, {} tool calls)", response.iterations, response.tool_calls.len()).dimmed()
);
Ok(())
}
async fn run_explain(config: PawanConfig, workspace: PathBuf, query: &str) -> Result<()> {
println!("{} {}", "Explaining:".cyan(), query);
let prompt = if std::path::Path::new(query).exists() || query.contains('/') || query.contains('.') {
format!(
r#"Read the file at `{query}` and explain it concisely:
1. What it does (purpose)
2. Key types/functions and their roles
3. How it fits into the broader codebase
4. Any notable patterns, dependencies, or gotchas
Be concise — aim for 10-20 lines. Skip obvious things."#
)
} else {
format!(
"In the context of this codebase at {}, explain: {}\n\n\
If this is a function/type name, find it in the code first.\n\
Be concise — aim for 10-20 lines.",
workspace.display(),
query
)
};
let mut agent = PawanAgent::new(config, workspace);
let on_token: pawan::agent::TokenCallback = Box::new(|token: &str| {
use std::io::Write;
print!("{}", token);
std::io::stdout().flush().ok();
});
let response = agent
.execute_with_callbacks(&prompt, Some(on_token), None, None)
.await?;
if !response.content.ends_with('\n') {
println!();
}
Ok(())
}
async fn run_review(
config: PawanConfig,
workspace: PathBuf,
staged_only: bool,
file: Option<PathBuf>,
) -> Result<()> {
let mut diff_args: Vec<String> = if staged_only {
vec!["diff".into(), "--cached".into()]
} else {
vec!["diff".into(), "HEAD".into()]
};
if let Some(ref f) = file {
diff_args.push("--".into());
diff_args.push(f.to_string_lossy().into_owned());
}
let diff_args_ref: Vec<&str> = diff_args.iter().map(|s| s.as_str()).collect();
let diff_output = std::process::Command::new("git")
.args(&diff_args_ref)
.current_dir(&workspace)
.output()
.map_err(PawanError::Io)?;
let diff = String::from_utf8_lossy(&diff_output.stdout);
if diff.trim().is_empty() {
let fallback = std::process::Command::new("git")
.args(["diff"])
.current_dir(&workspace)
.output()
.map_err(PawanError::Io)?;
let fallback_diff = String::from_utf8_lossy(&fallback.stdout);
if fallback_diff.trim().is_empty() {
println!("{}", "No changes to review.".dimmed());
return Ok(());
}
return run_review_with_diff(config, workspace, &fallback_diff).await;
}
run_review_with_diff(config, workspace, &diff).await
}
async fn run_review_with_diff(
config: PawanConfig,
workspace: PathBuf,
diff: &str,
) -> Result<()> {
println!(
"{} {}",
"Reviewing".cyan(),
format!("({} lines of diff)...", diff.lines().count()).dimmed()
);
let diff_text = if diff.len() > 12000 {
format!(
"{}...\n\n[diff truncated, {} total bytes]",
&diff[..12000],
diff.len()
)
} else {
diff.to_string()
};
let prompt = format!(
r#"Review the following code changes. Be concise and actionable.
For each issue found, output:
- **Severity**: 🔴 critical / 🟡 warning / 🔵 suggestion
- **Location**: file and line
- **Issue**: what's wrong
- **Fix**: how to fix it
At the end, give an overall assessment: LGTM ✅, needs fixes 🔧, or needs rework ❌.
Focus on: bugs, security issues, performance, error handling, edge cases, code style.
Do NOT nitpick formatting or suggest adding comments.
```diff
{diff_text}
```"#
);
let mut agent = PawanAgent::new(config, workspace);
let on_token: pawan::agent::TokenCallback = Box::new(|token: &str| {
use std::io::Write;
print!("{}", token);
std::io::stdout().flush().ok();
});
let response = agent
.execute_with_callbacks(&prompt, Some(on_token), None, None)
.await?;
if !response.content.ends_with('\n') {
println!();
}
Ok(())
}
async fn run_improve(
config: PawanConfig,
workspace: PathBuf,
target: &str,
file: Option<PathBuf>,
verbose: bool,
) -> Result<()> {
let description = match target.to_lowercase().as_str() {
"docs" | "documentation" => {
if let Some(ref f) = file {
format!(
"Generate comprehensive documentation for all public items in {}. \
Add module-level docs, function docs, struct/enum docs with examples where helpful.",
f.display()
)
} else {
"Generate comprehensive documentation for all public items that are missing docs. \
Focus on module-level docs, function docs, and struct/enum docs."
.to_string()
}
}
"refactor" => {
if let Some(ref f) = file {
format!(
"Refactor {} to improve code quality. Look for: \
- Long functions that can be split \
- Code duplication that can be extracted \
- Complex conditionals that can be simplified \
- Better naming opportunities",
f.display()
)
} else {
"Analyze the codebase and suggest refactoring opportunities. \
Look for code duplication, overly complex functions, and naming improvements."
.to_string()
}
}
"tests" => {
if let Some(ref f) = file {
format!(
"Add comprehensive unit tests for {}. \
Cover edge cases, error conditions, and typical use cases.",
f.display()
)
} else {
"Identify areas with insufficient test coverage and add tests. \
Focus on critical business logic and edge cases."
.to_string()
}
}
"all" => "Improve the overall code quality: \
1. Fix any clippy warnings \
2. Add missing documentation \
3. Suggest and apply refactoring improvements \
4. Add missing tests for uncovered code"
.to_string(),
_ => {
return Err(PawanError::Config(format!(
"Unknown improvement target: {}. Use: docs, refactor, tests, or all",
target
)));
}
};
run_task(config, workspace, &description, verbose).await
}
async fn run_watch(
config: PawanConfig,
workspace: PathBuf,
interval_secs: u64,
auto_commit: bool,
notify: bool,
) -> Result<()> {
use std::io::Write;
println!(
"{}",
format!(
"Watching {} every {}s (Ctrl+C to stop)",
workspace.display(),
interval_secs
)
.cyan()
);
let mut last_status = true; let mut heal_count = 0u32;
loop {
let check = std::process::Command::new("cargo")
.args(["check", "--workspace", "--message-format=short"])
.current_dir(&workspace)
.output()
.map_err(PawanError::Io)?;
let elapsed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let secs = elapsed.as_secs() % 86400;
let now = format!("{:02}:{:02}:{:02}", secs / 3600, (secs % 3600) / 60, secs % 60);
if check.status.success() {
if !last_status {
println!("{} {} {}", format!("[{}]", now).dimmed(), "✓".green(), "All clear — project compiles.".green());
} else {
print!("{} {} {}\r", format!("[{}]", now).dimmed(), "✓".green(), "OK".dimmed());
std::io::stdout().flush().ok();
}
last_status = true;
} else {
let stderr = String::from_utf8_lossy(&check.stderr);
let error_count = stderr.lines().filter(|l| l.contains("error[")).count();
let warning_count = stderr.lines().filter(|l| l.contains("warning:")).count();
println!(
"\n{} {} {}",
format!("[{}]", now).dimmed(),
"✗".red(),
format!("{} error(s), {} warning(s) — healing...", error_count, warning_count).red()
);
last_status = false;
heal_count += 1;
if notify {
let _ = run_notify(
&format!("[pawan-watch] Build failed: {} error(s), {} warning(s) in {}",
error_count, warning_count, workspace.display()),
"whatsapp",
).await;
}
let mut agent = PawanAgent::new(config.clone(), workspace.clone());
let on_token: pawan::agent::TokenCallback = Box::new(|token: &str| {
use std::io::Write;
print!("{}", token);
std::io::stdout().flush().ok();
});
match agent
.execute_with_callbacks(
&format!(
"Fix the compilation errors in this project at {}. \
Run `cargo check` to see errors, then fix them one at a time. \
Verify each fix compiles before moving on.",
workspace.display()
),
Some(on_token),
None,
None,
)
.await
{
Ok(resp) => {
println!("\n{}", format!("Heal #{} complete ({} iterations, {} tool calls)", heal_count, resp.iterations, resp.tool_calls.len()).green());
if auto_commit && !resp.tool_calls.is_empty() {
let commit_output = std::process::Command::new("git")
.args(["add", "-A"])
.current_dir(&workspace)
.output()
.ok();
if commit_output.map(|o| o.status.success()).unwrap_or(false) {
let msg = format!("fix: auto-heal #{} by pawan watch", heal_count);
let _ = std::process::Command::new("git")
.args(["commit", "-m", &msg])
.current_dir(&workspace)
.output();
println!("{}", format!(" Auto-committed: {}", msg).dimmed());
}
}
}
Err(e) => {
println!("\n{}", format!("Heal failed: {}", e).red());
}
}
}
tokio::time::sleep(std::time::Duration::from_secs(interval_secs)).await;
}
}
async fn run_status(config: PawanConfig, workspace: PathBuf) -> Result<()> {
println!("{}", "Pawan Project Status".green().bold());
println!("{}", "═".repeat(40).dimmed());
let healer = Healer::new(workspace.clone(), config.healing);
println!("\n{}", "Checking compilation...".dimmed());
let diagnostics = healer.get_diagnostics().await?;
let errors: Vec<_> = diagnostics
.iter()
.filter(|d| d.kind == pawan::healing::DiagnosticKind::Error)
.collect();
let warnings: Vec<_> = diagnostics
.iter()
.filter(|d| d.kind == pawan::healing::DiagnosticKind::Warning)
.collect();
println!("{}", "Checking tests...".dimmed());
let failed_tests = healer.get_failed_tests().await?;
println!("\n{}", "Summary:".cyan().bold());
if errors.is_empty() {
println!(" {} No compilation errors", "✓".green());
} else {
println!(
" {} {} compilation error(s)",
"✗".red(),
errors.len().to_string().red().bold()
);
for err in errors.iter().take(5) {
println!(
" {} {}",
"→".dimmed(),
err.message.chars().take(60).collect::<String>()
);
}
if errors.len() > 5 {
println!(" {} ...and {} more", "→".dimmed(), errors.len() - 5);
}
}
if warnings.is_empty() {
println!(" {} No warnings", "✓".green());
} else {
println!(
" {} {} warning(s)",
"⚠".yellow(),
warnings.len().to_string().yellow().bold()
);
}
if failed_tests.is_empty() {
println!(" {} All tests passing", "✓".green());
} else {
println!(
" {} {} test(s) failing",
"✗".red(),
failed_tests.len().to_string().red().bold()
);
for test in failed_tests.iter().take(5) {
println!(" {} {}", "→".dimmed(), test.name);
}
if failed_tests.len() > 5 {
println!(
" {} ...and {} more",
"→".dimmed(),
failed_tests.len() - 5
);
}
}
println!();
if errors.is_empty() && warnings.is_empty() && failed_tests.is_empty() {
println!("{}", "✓ Project is healthy!".green().bold());
} else {
println!(
"{}",
"Run 'pawan heal' to automatically fix issues".yellow()
);
}
Ok(())
}
async fn run_doctor(config: PawanConfig, workspace: PathBuf) -> Result<()> {
use pawan::config::LlmProvider;
println!("{}", "Pawan Doctor".cyan().bold());
println!("{}\n", "─".repeat(40).dimmed());
let mut issues = 0u32;
print!(" Workspace: ");
if workspace.exists() {
println!("{} {}", "✓".green(), workspace.display());
} else {
println!("{} {} (not found)", "✗".red(), workspace.display());
issues += 1;
}
print!(" pawan.toml: ");
if workspace.join("pawan.toml").exists() {
println!("{}", "✓ found".green());
} else {
println!("{}", "- not found (using defaults)".dimmed());
}
print!(" PAWAN.md: ");
if workspace.join("PAWAN.md").exists() {
println!("{}", "✓ found".green());
} else {
println!("{}", "- not found (run `pawan init`)".dimmed());
}
print!(" .env: ");
if workspace.join(".env").exists() || std::path::Path::new(".env").exists() {
println!("{}", "✓ found".green());
} else {
println!("{}", "- not found".dimmed());
}
println!("\n{}", " API Keys:".bold());
match config.provider {
LlmProvider::Nvidia => {
print!(" NVIDIA_API_KEY: ");
if std::env::var("NVIDIA_API_KEY").is_ok() {
println!("{}", "✓ set".green());
} else {
println!("{}", "✗ NOT SET".red());
issues += 1;
}
}
LlmProvider::OpenAI => {
print!(" OPENAI_API_KEY: ");
if std::env::var("OPENAI_API_KEY").is_ok() {
println!("{}", "✓ set".green());
} else {
println!("{}", "✗ NOT SET".red());
issues += 1;
}
}
LlmProvider::Ollama => {
print!(" Ollama URL: ");
let url = std::env::var("OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".into());
println!("{}", url.cyan());
}
LlmProvider::Mlx => {
print!(" MLX URL: ");
let url = std::env::var("MLX_URL").unwrap_or_else(|_| "http://localhost:8080".into());
println!("{}", url.cyan());
}
}
println!("\n{}", " Model:".bold());
println!(" Provider: {}", format!("{:?}", config.provider).cyan());
println!(" Model: {}", config.model.cyan());
print!(" Connectivity: ");
let api_url = match config.provider {
LlmProvider::Nvidia => {
std::env::var("NVIDIA_API_URL")
.unwrap_or_else(|_| pawan::DEFAULT_NVIDIA_API_URL.to_string())
}
LlmProvider::OpenAI => {
std::env::var("OPENAI_API_URL")
.unwrap_or_else(|_| "https://api.openai.com/v1".to_string())
}
LlmProvider::Ollama => {
std::env::var("OLLAMA_URL")
.unwrap_or_else(|_| "http://localhost:11434".to_string())
}
LlmProvider::Mlx => {
std::env::var("MLX_URL")
.unwrap_or_else(|_| "http://localhost:8080".to_string())
}
};
let ping_url = if matches!(config.provider, LlmProvider::Ollama) {
api_url.clone()
} else {
format!("{}/models", api_url)
};
match std::process::Command::new("curl")
.args(["-sS", "--max-time", "5", "-o", "/dev/null", "-w", "%{http_code}", &ping_url])
.output()
{
Ok(output) if output.status.success() => {
let code = String::from_utf8_lossy(&output.stdout);
let code = code.trim();
if code == "200" || code == "401" {
println!("{} (reachable)", "✓".green());
} else {
println!("{} (HTTP {})", "⚠".yellow(), code);
}
}
Ok(_) => {
println!("{}", "✗ unreachable".red());
issues += 1;
}
Err(_) => {
println!("{}", "✗ curl not found".red());
issues += 1;
}
}
println!("\n{}", " Git:".bold());
print!(" Repository: ");
let git_check = std::process::Command::new("git")
.args(["rev-parse", "--is-inside-work-tree"])
.current_dir(&workspace)
.output();
match git_check {
Ok(output) if output.status.success() => println!("{}", "✓ inside git repo".green()),
_ => println!("{}", "- not a git repo".dimmed()),
}
println!("\n{}", " Tools:".bold());
let agent = PawanAgent::new(config.clone(), workspace);
let tool_count = agent.get_tool_definitions().len();
println!(" Registered: {} tools", format!("{}", tool_count).cyan());
if !config.mcp.is_empty() {
println!("\n{}", " MCP Servers:".bold());
for (name, entry) in &config.mcp {
let status = if entry.enabled { "enabled" } else { "disabled" };
println!(
" {}: {} ({})",
name,
entry.command.cyan(),
if entry.enabled {
status.green().to_string()
} else {
status.dimmed().to_string()
}
);
}
}
println!("\n{}", " Native Binaries:".bold());
let binaries = [
("rg", "ripgrep — fast code search"),
("fd", "fd — fast file finder"),
("ast-grep", "ast-grep — structural search/replace"),
("bat", "bat — syntax-highlighted file viewer"),
("delta", "delta — git diff viewer"),
];
let mut missing_count = 0;
for (bin, desc) in &binaries {
if which::which(bin).is_ok() {
println!(" {} {} {}", "✓".green(), bin, format!("({})", desc).dimmed());
} else {
println!(" {} {} {}", "-".dimmed(), bin, format!("({})", desc).dimmed());
missing_count += 1;
}
}
if missing_count > 0 {
println!(" {}", "Install missing: mise install <name>".dimmed());
}
println!("\n{}", "─".repeat(40).dimmed());
if issues == 0 {
println!("{}", " All checks passed! ✓".green().bold());
} else {
println!(
"{}",
format!(" {} issue(s) found.", issues).yellow().bold()
);
}
Ok(())
}
async fn run_bootstrap(
skip_mise: bool,
skip_native: bool,
include_deagle: bool,
force: bool,
dry_run: bool,
) -> Result<()> {
use pawan::bootstrap::{self, BootstrapOptions, BootstrapStepStatus};
if dry_run {
let missing = bootstrap::missing_deps();
if missing.is_empty() {
println!("{}", "All required dependencies present — nothing to install.".green());
println!(" {}", "deagle is embedded as a library — no separate binary needed.".dimmed());
} else {
println!("{}", "Missing dependencies:".yellow().bold());
for dep in &missing {
println!(" - {}", dep);
}
println!("\nRun `pawan bootstrap` (without --dry-run) to install.");
}
return Ok(());
}
println!("{}", "Bootstrapping pawan external dependencies...".cyan().bold());
let opts = BootstrapOptions {
skip_mise,
skip_native,
include_deagle,
force_reinstall: force,
};
let report = bootstrap::ensure_deps(opts);
for step in &report.steps {
let tag = match &step.status {
BootstrapStepStatus::AlreadyInstalled => format!("{}", "[ok]".green()),
BootstrapStepStatus::Installed => format!("{}", "[installed]".green().bold()),
BootstrapStepStatus::Skipped(r) => format!("{} ({})", "[skipped]".yellow(), r),
BootstrapStepStatus::Failed(e) => format!("{} {}", "[failed]".red().bold(), e),
};
println!(" {:12} {}", step.name, tag);
}
println!("\n{}", report.summary().bold());
if !report.all_ok() {
return Err(PawanError::Config(
"bootstrap failed — see errors above; rerun to retry or use --skip-* to bypass".into(),
));
}
Ok(())
}
async fn run_uninstall(purge_deagle: bool) -> Result<()> {
use pawan::bootstrap;
println!("{}", "Removing pawan bootstrap marker...".cyan());
bootstrap::uninstall(purge_deagle)?;
println!("{}", " marker removed".green());
if purge_deagle {
println!("{}", " deagle uninstalled via cargo".green());
} else {
println!(
" {}",
"mise and mise-managed tools NOT removed (shared with other programs)".dimmed()
);
println!(
" {}",
"to also remove deagle, rerun with --purge-deagle".dimmed()
);
}
Ok(())
}
async fn run_init(workspace: PathBuf) -> Result<()> {
let mut created = Vec::new();
let toml_path = workspace.join("pawan.toml");
if !toml_path.exists() {
let toml_content = r#"# Pawan configuration
# See: https://github.com/dirmacs/pawan
# LLM provider: nvidia, ollama, openai
provider = "nvidia"
# Model to use
model = "nvidia/llama-3.3-nemotron-super-49b-v1"
# Temperature (0.0 - 2.0)
temperature = 1.0
# Maximum tokens in response
max_tokens = 8192
# Maximum tool iterations per request
max_tool_iterations = 15
# [mcp.daedra]
# command = "daedra"
# args = ["serve", "--transport", "stdio", "--quiet"]
"#;
std::fs::write(&toml_path, toml_content).map_err(PawanError::Io)?;
created.push("pawan.toml");
}
let md_path = workspace.join("PAWAN.md");
if !md_path.exists() {
let project_name = if let Ok(cargo) =
std::fs::read_to_string(workspace.join("Cargo.toml"))
{
cargo
.lines()
.find(|l| l.starts_with("name"))
.and_then(|l| l.split('"').nth(1))
.unwrap_or("this project")
.to_string()
} else {
workspace
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "this project".to_string())
};
let md_content = format!(
r#"# {project_name}
## Project Context
<!-- Pawan reads this file to understand your project. -->
<!-- Add project-specific instructions, conventions, and context here. -->
## Architecture
<!-- Describe the high-level architecture -->
## Conventions
<!-- Code style, naming conventions, patterns to follow -->
## Key Files
<!-- List important files and what they do -->
"#
);
std::fs::write(&md_path, md_content).map_err(PawanError::Io)?;
created.push("PAWAN.md");
}
let pawan_dir = workspace.join(".pawan");
if !pawan_dir.exists() {
std::fs::create_dir_all(&pawan_dir).map_err(PawanError::Io)?;
created.push(".pawan/");
}
if created.is_empty() {
println!(
"{}",
"Pawan is already initialized in this directory.".dimmed()
);
} else {
println!("{}", "Pawan initialized!".green().bold());
for f in &created {
println!(" {} {}", "✓".green(), f);
}
println!(
"\n{}",
"Edit PAWAN.md to add project context for better AI assistance.".dimmed()
);
}
Ok(())
}
async fn run_tasks(action: TasksAction) -> Result<()> {
use pawan::tasks::{BeadId, BeadStatus, BeadStore};
let store = BeadStore::open()?;
match action {
TasksAction::List { status, priority, json } => {
let status_filter = match status.as_str() {
"all" => None,
s => Some(s),
};
let beads = store.list(status_filter, priority)?;
if json {
println!("{}", serde_json::to_string_pretty(&beads).unwrap_or_default());
return Ok(());
}
if beads.is_empty() {
println!("{}", "No beads found.".dimmed());
return Ok(());
}
println!("{}", "Beads".green().bold());
println!("{}", "═".repeat(70).dimmed());
println!(
" {:<12} {:<5} {:<12} {}",
"ID".cyan(), "Pri".cyan(), "Status".cyan(), "Title".cyan()
);
println!("{}", "─".repeat(70).dimmed());
for b in &beads {
let status_color = match b.status {
BeadStatus::Open => format!("{:<12}", "open").yellow().to_string(),
BeadStatus::InProgress => format!("{:<12}", "in_progress").blue().to_string(),
BeadStatus::Closed => format!("{:<12}", "closed").dimmed().to_string(),
};
println!(" {:<12} {:<5} {} {}", b.id.display(), b.priority, status_color, b.title);
}
println!("\n {} beads total", beads.len());
}
TasksAction::Ready { json } => {
let beads = store.ready()?;
if json {
println!("{}", serde_json::to_string_pretty(&beads).unwrap_or_default());
return Ok(());
}
if beads.is_empty() {
println!("{}", "No ready beads (all blocked or none open).".dimmed());
return Ok(());
}
println!("{} {} actionable beads:", "Ready:".green().bold(), beads.len());
for b in &beads {
println!(" {} [P{}] {}", b.id.display().cyan(), b.priority, b.title);
}
}
TasksAction::Create { title, priority, desc } => {
let bead = store.create(&title, desc.as_deref(), priority)?;
println!("{} {} — {}", "Created:".green().bold(), bead.id.display().cyan(), bead.title);
}
TasksAction::Update { id, status, priority, title } => {
let bid = BeadId::parse(&id);
let status = status.map(|s| s.parse::<BeadStatus>().unwrap_or(BeadStatus::Open));
store.update(&bid, title.as_deref(), status, priority)?;
println!("{} {}", "Updated:".green().bold(), bid.display().cyan());
}
TasksAction::Close { id, reason } => {
let bid = BeadId::parse(&id);
store.close(&bid, reason.as_deref())?;
println!("{} {}", "Closed:".green().bold(), bid.display().cyan());
}
TasksAction::Dep { action } => match action {
DepAction::Add { id, blocks_id } => {
let bid = BeadId::parse(&id);
let dep = BeadId::parse(&blocks_id);
store.dep_add(&bid, &dep)?;
println!("{} {} depends on {}", "Dep added:".green().bold(), bid.display().cyan(), dep.display().cyan());
}
DepAction::Rm { id, blocks_id } => {
let bid = BeadId::parse(&id);
let dep = BeadId::parse(&blocks_id);
store.dep_remove(&bid, &dep)?;
println!("{} {} no longer depends on {}", "Dep removed:".green().bold(), bid.display().cyan(), dep.display().cyan());
}
},
TasksAction::Decay { max_age_days } => {
let count = store.memory_decay(max_age_days)?;
if count == 0 {
println!("{}", "No beads to archive.".dimmed());
} else {
println!("{} {} beads archived (older than {} days)", "Decayed:".green().bold(), count, max_age_days);
}
}
}
Ok(())
}
async fn run_distill(
config: PawanConfig,
session_id: Option<String>,
output_dir: Option<PathBuf>,
eval: bool,
refine: bool,
student_model: Option<String>,
) -> Result<()> {
use pawan::agent::session::Session;
use pawan::agent::TokenUsage;
use pawan::skill_distillation;
let session = match session_id {
Some(id) => Session::load(&id)?,
None => {
let sessions = Session::list()?;
let latest = sessions.first().ok_or_else(|| {
PawanError::NotFound("No saved sessions found. Run a task first, then distill.".into())
})?;
eprintln!("Using latest session: {}", latest.id);
Session::load(&latest.id)?
}
};
if !skill_distillation::is_distillable(&session) {
eprintln!(
"Session {} has insufficient content for distillation (needs tool calls + messages).",
session.id
);
return Ok(());
}
let usage = TokenUsage {
prompt_tokens: session.total_tokens / 2,
completion_tokens: session.total_tokens / 2,
total_tokens: session.total_tokens,
..Default::default()
};
let output = output_dir
.or_else(|| skill_distillation::skills_dir().ok())
.unwrap_or_else(|| PathBuf::from("."));
eprintln!(
"Distilling session {} ({} messages, {} tool calls)...",
session.id,
session.messages.len(),
session.messages.iter().flat_map(|m| m.tool_calls.iter()).count()
);
let do_eval = eval || refine;
if refine {
match skill_distillation::distill_eval_refine_save(
&session,
&usage,
&config,
&output,
student_model.as_deref(),
)
.await
{
Ok((path, initial, final_score)) => {
println!("Skill distilled to: {}", path.display());
println!(
"Eval: initial score {:.2}, refined score {:.2} ({})",
initial,
final_score,
if final_score > initial {
"improved"
} else if final_score == initial {
"unchanged"
} else {
"regressed"
}
);
Ok(())
}
Err(e) => {
eprintln!("Distill-refine pipeline failed: {}", e);
Err(e)
}
}
} else if do_eval {
let skill = match skill_distillation::distill_session(&session, &usage, &config).await {
Ok(s) => s,
Err(e) => {
eprintln!("Distillation failed: {}", e);
return Err(e);
}
};
let student = student_model.as_deref().unwrap_or(&config.model);
eprintln!("Evaluating against student model: {}", student);
match skill_distillation::evaluate_skill(&skill, student, &config).await {
Ok(result) => {
println!("Overall score: {:.2} ({}/{} tests passed)",
result.overall_score,
result.test_results.iter().filter(|r| r.passed).count(),
result.test_results.len());
}
Err(e) => eprintln!("Evaluation failed: {}", e),
}
match skill_distillation::save_skill(&skill, &output) {
Ok(path) => {
println!("Skill distilled to: {}", path.display());
Ok(())
}
Err(e) => {
eprintln!("Save failed: {}", e);
Err(e)
}
}
} else {
match skill_distillation::distill_and_save(&session, &usage, &config, &output).await {
Ok(path) => {
println!("Skill distilled to: {}", path.display());
Ok(())
}
Err(e) => {
eprintln!("Distillation failed: {}", e);
Err(e)
}
}
}
}
async fn run_sessions() -> Result<()> {
use pawan::agent::session::Session;
let sessions = Session::list()?;
if sessions.is_empty() {
println!("{}", "No saved sessions.".dimmed());
return Ok(());
}
println!("{}", "Saved Sessions".green().bold());
println!("{}", "═".repeat(60).dimmed());
println!(
" {:<10} {:<30} {:<6} {}",
"ID".cyan(),
"Model".cyan(),
"Msgs".cyan(),
"Updated".cyan()
);
println!("{}", "─".repeat(60).dimmed());
for s in &sessions {
let model_short = if s.model.len() > 28 {
format!("...{}", &s.model[s.model.len() - 25..])
} else {
s.model.clone()
};
let updated = &s.updated_at[..19]; println!(
" {:<10} {:<30} {:<6} {}",
s.id, model_short, s.message_count, updated
);
}
println!("\n{}", "Resume with: pawan chat --resume <ID>".dimmed());
Ok(())
}
#[cfg(feature = "mcp")]
async fn setup_mcp_tools(agent: &mut PawanAgent, config: &PawanConfig) {
use pawan_mcp::{McpManager, McpServerConfig};
if config.mcp.is_empty() {
return;
}
let configs: Vec<McpServerConfig> = config
.mcp
.iter()
.map(|(name, entry)| McpServerConfig {
name: name.clone(),
command: entry.command.clone(),
args: entry.args.clone(),
env: entry.env.clone(),
enabled: entry.enabled,
})
.collect();
match McpManager::connect(&configs).await {
Ok(manager) => {
let count = manager.register_tools(agent.tools_mut());
if count > 0 {
eprintln!("Loaded {} MCP tools", count);
}
Box::leak(Box::new(manager));
}
Err(e) => {
eprintln!("Warning: MCP setup failed: {}", e);
}
}
}
fn run_config_show(config: PawanConfig) -> Result<()> {
use owo_colors::OwoColorize;
println!("{}", "Pawan Configuration (resolved)".cyan().bold());
println!("{}\n", "─".repeat(40).dimmed());
println!(" {} {}", "Provider:".bold(), format!("{:?}", config.provider).cyan());
println!(" {} {}", "Model:".bold(), config.model.cyan());
println!(" {} {}", "Temperature:".bold(), config.temperature);
println!(" {} {}", "Max tokens:".bold(), config.max_tokens);
println!(" {} {}", "Max iterations:".bold(), config.max_tool_iterations);
println!(" {} {}", "Thinking mode:".bold(), if config.use_thinking_mode() { "enabled".green().to_string() } else { "disabled".dimmed().to_string() });
if let Some(ref cloud) = config.cloud {
println!("\n{}", " Cloud fallback:".bold());
println!(" Model: {}", cloud.model.cyan());
}
if !config.fallback_models.is_empty() {
println!(" {} {}", "Fallbacks:".bold(), config.fallback_models.join(", "));
}
println!("\n{}", " Healing:".bold());
println!(" Errors: {}", if config.healing.fix_errors { "fix" } else { "skip" });
println!(" Warnings: {}", if config.healing.fix_warnings { "fix" } else { "skip" });
println!(" Tests: {}", if config.healing.fix_tests { "fix" } else { "skip" });
if !config.permissions.is_empty() {
println!("\n{}", " Permissions:".bold());
for (tool, perm) in &config.permissions {
println!(" {}: {:?}", tool, perm);
}
}
if !config.mcp.is_empty() {
println!("\n{}", " MCP servers:".bold());
for (name, entry) in &config.mcp {
let status = if entry.enabled { "enabled".green().to_string() } else { "disabled".dimmed().to_string() };
println!(" {}: {} ({})", name, entry.command.cyan(), status);
}
}
println!("\n{}", " Context files:".bold());
for path in &["PAWAN.md", "AGENTS.md", "CLAUDE.md", "SKILL.md", ".pawan/context.md"] {
if std::path::Path::new(path).exists() {
println!(" {} {}", "✓".green(), path);
}
}
println!("\n{}", "─".repeat(40).dimmed());
println!("{}", " Use `pawan config init` to generate pawan.toml".dimmed());
Ok(())
}
fn run_config_init() -> Result<()> {
let path = std::path::Path::new("pawan.toml");
if path.exists() {
return Err(PawanError::Config(
"pawan.toml already exists. Remove it first.".into(),
));
}
let template = r#"# Pawan configuration
# See: https://github.com/dirmacs/pawan
# LLM provider: nvidia, ollama, openai
# provider = "nvidia"
# Model to use (provider-specific ID)
model = "qwen/qwen3.5-122b-a10b"
# Generation parameters
temperature = 0.6
# top_p = 0.95
# max_tokens = 8192
# Self-healing settings
[healing]
fix_errors = true
fix_warnings = true
fix_tests = true
auto_commit = false
# MCP servers (uncomment to enable)
# [mcp.daedra]
# command = "daedra"
# args = ["serve", "--transport", "stdio", "--quiet"]
"#;
std::fs::write(path, template).map_err(PawanError::Io)?;
println!("{} Created pawan.toml", "✓".green());
Ok(())
}
async fn run_mcp_list(config: PawanConfig) -> Result<()> {
#[cfg(feature = "mcp")]
{
use pawan_mcp::{McpManager, McpServerConfig};
if config.mcp.is_empty() {
println!("{}", "No MCP servers configured in pawan.toml.".dimmed());
println!(
"\n{}",
"Add servers under [mcp.<name>] with command and args.".dimmed()
);
return Ok(());
}
let configs: Vec<McpServerConfig> = config
.mcp
.iter()
.map(|(name, entry)| McpServerConfig {
name: name.clone(),
command: entry.command.clone(),
args: entry.args.clone(),
env: entry.env.clone(),
enabled: entry.enabled,
})
.collect();
println!("{}", "Connecting to MCP servers...".dimmed());
let manager = McpManager::connect(&configs).await?;
println!("\n{}", "MCP Servers".green().bold());
println!("{}", "═".repeat(50).dimmed());
for (name, count) in manager.summary() {
println!(" {} {} ({} tools)", "●".green(), name.cyan(), count);
}
let mut registry = pawan::tools::ToolRegistry::new();
let total = manager.register_tools(&mut registry);
println!("\n{}", "Available Tools".green().bold());
println!("{}", "─".repeat(50).dimmed());
for name in registry.tool_names() {
println!(" {}", name);
}
println!("\n Total: {} MCP tools", total);
}
#[cfg(not(feature = "mcp"))]
{
let _ = config;
println!("MCP support not enabled. Build with --features mcp");
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn run_headless(
mut config: PawanConfig,
workspace: PathBuf,
prompt: Option<String>,
file: Option<PathBuf>,
output_format: &str,
timeout_secs: u64,
max_iterations: Option<usize>,
max_retries: Option<usize>,
save_session: bool,
stream: bool,
verbose: bool,
) -> Result<()> {
let prompt_text = match (prompt, file) {
(Some(p), _) => p,
(None, Some(f)) => std::fs::read_to_string(&f).map_err(|e| {
PawanError::Config(format!("Failed to read prompt file {}: {}", f.display(), e))
})?,
(None, None) => {
return Err(PawanError::Config(
"Either a prompt argument or --file is required for `run`".into(),
));
}
};
if let Some(max_iter) = max_iterations {
config.max_tool_iterations = max_iter;
}
if let Some(retries) = max_retries {
config.max_retries = retries;
}
let config_ref = config.clone();
let mut agent = PawanAgent::new(config, workspace);
#[cfg(feature = "mcp")]
setup_mcp_tools(&mut agent, &config_ref).await;
if let Err(e) = agent.preflight_check().await {
if output_format == "json" {
println!("{}", serde_json::json!({"error": e.to_string(), "success": false}));
} else {
eprintln!("\x1b[31mModel health check failed:\x1b[0m {}", e);
eprintln!("Check: is the model server running? Is the API URL correct?");
}
std::process::exit(1);
}
let is_json = output_format == "json";
let use_color = !is_json && atty::is(atty::Stream::Stderr);
if !is_json {
if use_color {
eprintln!("\x1b[1;36m┌─ pawan run\x1b[0m");
eprintln!("\x1b[1;36m│\x1b[0m \x1b[33mModel:\x1b[0m {}", agent.config().model);
let display_prompt: String = prompt_text.chars().take(80).collect();
eprintln!("\x1b[1;36m│\x1b[0m \x1b[33mPrompt:\x1b[0m {}", display_prompt);
eprintln!("\x1b[1;36m└─\x1b[0m");
} else {
eprintln!("── pawan run ──");
eprintln!("Model: {}", agent.config().model);
let display_prompt: String = prompt_text.chars().take(80).collect();
eprintln!("Prompt: {}", display_prompt);
eprintln!("───────────────");
}
}
let use_color_token = use_color;
let on_token: Option<pawan::agent::TokenCallback> = if stream && is_json {
Some(Box::new(|token: &str| {
use std::io::Write;
let event = serde_json::json!({"type": "token", "content": token});
println!("{}", serde_json::to_string(&event).unwrap_or_default());
std::io::stdout().flush().ok();
}))
} else if !is_json {
let suppressing = std::sync::Arc::new(std::sync::Mutex::new(false));
let buffer = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
Some(Box::new(move |token: &str| {
use std::io::Write;
let mut sup = suppressing.lock().unwrap_or_else(|e| e.into_inner());
let mut buf = buffer.lock().unwrap_or_else(|e| e.into_inner());
buf.push_str(token);
if buf.contains("[TOOL_CALLS]") || buf.contains("[TOOL_CALL]") {
*sup = true;
buf.clear();
return;
}
if *sup {
if token.contains('\n') {
*sup = false;
buf.clear();
}
return;
}
let clean = buf
.replace("<think>", "").replace("</think>", "")
.replace("<|im_start|>", "").replace("<|im_end|>", "");
if clean.len() < 50 {
let lower = clean.trim().to_lowercase();
if lower.starts_with("i'll ") || lower.starts_with("let me ") || lower.starts_with("i will ") {
return;
}
}
if !clean.is_empty() {
buf.clear();
if use_color_token {
print!("\x1b[37m{}\x1b[0m", clean);
} else {
print!("{}", clean);
}
std::io::stdout().flush().ok();
}
}))
} else {
None
};
let use_color_tool = use_color;
let on_tool_start: Option<pawan::agent::ToolStartCallback> = if is_json && stream {
Some(Box::new(|name: &str| {
let event = serde_json::json!({"type": "tool_start", "name": name});
println!("{}", serde_json::to_string(&event).unwrap_or_default());
}))
} else if !is_json {
Some(Box::new(move |name: &str| {
if use_color_tool {
eprint!("\x1b[1;35m ⚙ {}\x1b[0m", name);
} else {
eprint!(" > {}", name);
}
}))
} else {
None
};
let use_color_done = use_color;
let on_tool_done: Option<pawan::agent::ToolCallback> = if is_json && stream {
Some(Box::new(|tc: &pawan::agent::ToolCallRecord| {
let event = serde_json::json!({
"type": "tool_complete",
"name": tc.name,
"success": tc.success,
"duration_ms": tc.duration_ms,
});
println!("{}", serde_json::to_string(&event).unwrap_or_default());
}))
} else if !is_json {
Some(Box::new(move |tc: &pawan::agent::ToolCallRecord| {
if use_color_done {
if tc.success {
eprintln!(" \x1b[32m✓\x1b[0m \x1b[2m{}ms\x1b[0m", tc.duration_ms);
} else {
eprintln!(" \x1b[31m✗\x1b[0m \x1b[2m{}ms\x1b[0m", tc.duration_ms);
}
} else {
let icon = if tc.success { "ok" } else { "FAIL" };
eprintln!(" [{}] {}ms", icon, tc.duration_ms);
}
}))
} else {
None
};
let result = tokio::time::timeout(
std::time::Duration::from_secs(timeout_secs),
agent.execute_with_callbacks(&prompt_text, on_token, on_tool_done, on_tool_start),
)
.await;
let response = match result {
Ok(Ok(resp)) => resp,
Ok(Err(e)) => {
if output_format == "json" {
let err_json = serde_json::json!({
"success": false,
"error": e.to_string(),
});
println!("{}", serde_json::to_string_pretty(&err_json).unwrap());
} else {
eprintln!("{} {}", "Error:".red().bold(), e);
}
std::process::exit(1);
}
Err(_) => {
if output_format == "json" {
let err_json = serde_json::json!({
"success": false,
"error": format!("Timed out after {}s", timeout_secs),
});
println!("{}", serde_json::to_string_pretty(&err_json).unwrap());
} else {
eprintln!(
"{} Timed out after {}s",
"Error:".red().bold(),
timeout_secs
);
}
std::process::exit(2);
}
};
match output_format {
"json" => {
let tool_calls: Vec<serde_json::Value> = response
.tool_calls
.iter()
.map(|tc| {
serde_json::json!({
"name": tc.name,
"success": tc.success,
"duration_ms": tc.duration_ms,
})
})
.collect();
let clean_content = strip_thinking_tags(&response.content);
let output = serde_json::json!({
"success": true,
"content": clean_content,
"iterations": response.iterations,
"tool_calls": tool_calls,
"tool_call_count": response.tool_calls.len(),
"usage": {
"prompt_tokens": response.usage.prompt_tokens,
"completion_tokens": response.usage.completion_tokens,
"total_tokens": response.usage.total_tokens,
}
});
println!("{}", serde_json::to_string_pretty(&output).unwrap());
}
_ => {
let content = strip_thinking_tags(&response.content);
if !content.ends_with('\n') {
println!();
}
let use_color = atty::is(atty::Stream::Stderr);
let tc_count = response.tool_calls.len();
let success_count = response.tool_calls.iter().filter(|t| t.success).count();
let fail_count = tc_count - success_count;
let total_ms: u64 = response.tool_calls.iter().map(|t| t.duration_ms).sum();
if use_color {
eprintln!();
eprintln!("\x1b[1;36m┌─ summary\x1b[0m");
eprintln!("\x1b[1;36m│\x1b[0m \x1b[33mIterations:\x1b[0m {} \x1b[2m│\x1b[0m \x1b[33mTools:\x1b[0m \x1b[32m{} ok\x1b[0m{} \x1b[2m│\x1b[0m \x1b[33mTime:\x1b[0m {}ms",
response.iterations,
success_count,
if fail_count > 0 { format!(" \x1b[31m{} fail\x1b[0m", fail_count) } else { String::new() },
total_ms,
);
if response.usage.total_tokens > 0 {
let budget = if response.usage.reasoning_tokens > 0 {
format!(" \x1b[2m(think:{} act:{})\x1b[0m", response.usage.reasoning_tokens, response.usage.action_tokens)
} else { String::new() };
eprintln!("\x1b[1;36m│\x1b[0m \x1b[33mTokens:\x1b[0m {}{}",
response.usage.total_tokens, budget);
}
if !response.tool_calls.is_empty() && verbose {
eprintln!("\x1b[1;36m│\x1b[0m \x1b[33mTool log:\x1b[0m");
for tc in &response.tool_calls {
let icon = if tc.success { "\x1b[32m✓\x1b[0m" } else { "\x1b[31m✗\x1b[0m" };
eprintln!("\x1b[1;36m│\x1b[0m {} \x1b[1m{}\x1b[0m \x1b[2m{}ms\x1b[0m", icon, tc.name, tc.duration_ms);
}
}
eprintln!("\x1b[1;36m└─\x1b[0m");
} else {
eprintln!();
eprintln!("── summary ──");
eprintln!("Iterations: {} | Tools: {} ok{} | Time: {}ms",
response.iterations, success_count,
if fail_count > 0 { format!(", {} fail", fail_count) } else { String::new() },
total_ms);
if response.usage.total_tokens > 0 {
let budget = if response.usage.reasoning_tokens > 0 {
format!(" (think:{} act:{})", response.usage.reasoning_tokens, response.usage.action_tokens)
} else { String::new() };
eprintln!("Tokens: {}{}", response.usage.total_tokens, budget);
}
if !response.tool_calls.is_empty() && verbose {
for tc in &response.tool_calls {
let s = if tc.success { "ok" } else { "FAIL" };
eprintln!(" [{}] {} {}ms", s, tc.name, tc.duration_ms);
}
}
eprintln!("─────────────");
}
if response.tool_calls.is_empty() && response.content.trim().is_empty() {
if use_color {
eprintln!("\x1b[33m⚠ No tool calls were made and response is empty.\x1b[0m");
} else {
eprintln!("Warning: No tool calls were made and response is empty.");
}
}
if verbose {
}
}
}
if save_session {
match agent.save_session() {
Ok(id) => {
if output_format == "json" {
eprintln!("Session saved: {}", id);
} else {
eprintln!(
"Session saved: {} (resume with: pawan chat --resume {})",
id, id
);
}
if let Err(e) = agent.archive_to_eruka().await {
eprintln!("Warning: eruka archive_session failed: {}", e);
}
}
Err(e) => eprintln!("Warning: failed to save session: {}", e),
}
}
Ok(())
}
fn strip_thinking_tags(content: &str) -> String {
let mut result = content.to_string();
while let Some(start) = result.find("<think>") {
if let Some(end) = result.find("</think>") {
let end = end + "</think>".len();
result = format!("{}{}", &result[..start], &result[end..]);
} else {
result = result[..start].to_string();
break;
}
}
result.trim().to_string()
}
#[cfg(not(feature = "tui"))]
async fn run_simple_cli(mut agent: PawanAgent) -> Result<()> {
use std::io::{BufRead, Write};
println!("Pawan - Self-Healing CLI Coding Agent");
println!("Type 'quit' or 'exit' to quit, 'clear' to clear history");
println!("---");
let stdin = std::io::stdin();
let mut stdout = std::io::stdout();
loop {
print!("> ");
stdout.flush().ok();
let mut line = String::new();
stdin.lock().read_line(&mut line).ok();
let line = line.trim();
if line.is_empty() {
continue;
}
if line == "quit" || line == "exit" {
break;
}
if line == "clear" {
agent.clear_history();
println!("History cleared.");
continue;
}
println!("\nProcessing...\n");
match agent.execute(line).await {
Ok(response) => {
println!("{}\n", response.content);
if !response.tool_calls.is_empty() {
println!("Tool calls made:");
for tc in &response.tool_calls {
let status = if tc.success { "✓" } else { "✗" };
println!(" {} {} ({}ms)", status, tc.name, tc.duration_ms);
}
println!();
}
}
Err(e) => {
println!("Error: {}\n", e);
}
}
}
Ok(())
}
async fn run_fmt(workspace: PathBuf, check: bool) -> Result<()> {
use std::process::Command;
println!("{}", "Pawan Format".green().bold());
println!();
let fmt_args = if check {
vec!["fmt", "--all", "--", "--check"]
} else {
vec!["fmt", "--all"]
};
println!("{} cargo {}", "Running:".cyan(), fmt_args.join(" "));
let fmt_status = Command::new("cargo")
.args(&fmt_args)
.current_dir(&workspace)
.status()
.map_err(PawanError::Io)?;
if fmt_status.success() {
println!("{}", " cargo fmt: OK".green());
} else {
println!("{}", " cargo fmt: issues found".yellow());
if check {
return Ok(());
}
}
if !check {
println!("{} cargo clippy --fix", "Running:".cyan());
let clippy_status = Command::new("cargo")
.args(["clippy", "--fix", "--allow-dirty", "--allow-staged"])
.current_dir(&workspace)
.status()
.map_err(PawanError::Io)?;
if clippy_status.success() {
println!("{}", " cargo clippy --fix: OK".green());
} else {
println!("{}", " cargo clippy --fix: some issues remain".yellow());
}
}
println!();
println!("{}", "Format complete.".green().bold());
Ok(())
}
async fn run_bench() -> Result<()> {
println!("{}", "Pawan Bench".green().bold());
let nimakai = std::process::Command::new("which")
.arg("nimakai")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "nimakai".to_string());
if !std::path::Path::new(&nimakai).exists() {
println!("nimakai not found in PATH. Install: nimble install nimakai");
return Ok(());
}
let out = std::process::Command::new(nimakai).args(["bench", "--json"]).output();
match out {
Ok(o) => { println!("{}", String::from_utf8_lossy(&o.stdout)); }
Err(e) => { println!("Error: {}", e); }
}
Ok(())
}
async fn run_notify(message: &str, channel: &str) -> Result<()> {
let api_key = std::env::var("DOLTA_API_KEY").unwrap_or_default();
if api_key.is_empty() {
println!("{}", "DOLTA_API_KEY not set. Set it in .env or export it.".yellow());
return Ok(());
}
let relay_url = std::env::var("DOLTARES_RELAY_URL")
.unwrap_or_else(|_| "http://localhost:3100/api/deliver".to_string());
let body = serde_json::json!({
"channel": channel,
"to": "last",
"message": message,
});
let output = std::process::Command::new("curl")
.args([
"-s", "-X", "POST",
&relay_url,
"-H", "Content-Type: application/json",
"-H", &format!("Authorization: Bearer {}", api_key),
"-d", &serde_json::to_string(&body).unwrap_or_default(),
])
.output();
match output {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout);
if out.status.success() {
println!("{} {}", "Sent:".green(), stdout.trim());
} else {
println!("{} {}", "Failed:".red(), stdout.trim());
}
}
Err(e) => {
println!("{} {}", "Error:".red(), e);
}
}
Ok(())
}
async fn run_loop(
config: PawanConfig,
workspace: PathBuf,
action: LoopAction,
verbose: bool,
) -> Result<()> {
match action {
LoopAction::Start { prompt, interval, max_ticks } => {
run_loop_start(config, workspace, prompt, interval, max_ticks, verbose).await
}
}
}
async fn run_loop_start(
config: PawanConfig,
workspace: PathBuf,
prompt: Option<String>,
interval_secs: u64,
max_ticks: u64,
verbose: bool,
) -> Result<()> {
use tokio::signal;
let effective_prompt = prompt.unwrap_or_else(|| {
"Check the TaskList for the highest-priority pending task. \
Pick it, mark it in_progress, complete it fully, then mark it completed. \
If no pending tasks exist, report what you found and stop."
.to_string()
});
println!("{}", "Pawan Loop Mode".green().bold());
println!("{}", "═".repeat(40).dimmed());
println!(
"{} {}s {} {}",
"Interval:".cyan(),
interval_secs,
"Max ticks:".cyan(),
if max_ticks == 0 { "∞".to_string() } else { max_ticks.to_string() }
);
println!("{} {}", "Prompt:".cyan(), &effective_prompt[..effective_prompt.len().min(80)]);
println!("{}", "Press Ctrl+C to stop".dimmed());
println!();
let mut tick: u64 = 0;
loop {
tick += 1;
if max_ticks > 0 && tick > max_ticks {
println!("{}", "Max ticks reached — stopping.".dimmed());
break;
}
let elapsed = tick * interval_secs;
println!(
"{} {} {}",
format!("[tick {}]", tick).cyan().bold(),
format!("+{}s", elapsed).dimmed(),
"─".repeat(30).dimmed()
);
let config_ref = config.clone();
let mut agent = PawanAgent::new(config.clone(), workspace.clone());
#[cfg(feature = "mcp")]
setup_mcp_tools(&mut agent, &config_ref).await;
match agent.task(&effective_prompt).await {
Ok(response) => {
println!("{}", response.content);
if verbose && !response.tool_calls.is_empty() {
for tc in &response.tool_calls {
if tc.success {
println!(" {} {} ({}ms)", "✓".green(), tc.name.cyan(), tc.duration_ms);
} else {
println!(" {} {} ({}ms)", "✗".red(), tc.name.cyan(), tc.duration_ms);
}
}
}
}
Err(e) => {
eprintln!("{} {}", "Loop tick error:".red(), e);
}
}
println!();
let sleep = tokio::time::sleep(tokio::time::Duration::from_secs(interval_secs));
tokio::select! {
_ = sleep => {}
_ = signal::ctrl_c() => {
println!("{}", "\nLoop stopped by user.".dimmed());
break;
}
}
}
Ok(())
}
async fn fetch_pom_sprint(
client: &reqwest::Client,
url: &str,
) -> Result<Option<(String, i64, String, String)>> {
let body: serde_json::Value = client
.get(url)
.send()
.await
.map_err(|e| PawanError::Config(format!("POM unreachable: {e}")))?
.json()
.await
.map_err(|e| PawanError::Config(format!("POM response parse error: {e}")))?;
let first = body["data"].as_array().and_then(|a| a.first().cloned());
Ok(first.and_then(|s| {
let id = s["id"].as_str()?.to_string();
let number = s["number"].as_i64()?;
let title = s["title"].as_str()?.to_string();
let status = s["status"].as_str()?.to_string();
Some((id, number, title, status))
}))
}
async fn run_pom(
config: PawanConfig,
workspace: PathBuf,
pom_url_arg: Option<String>,
complete: bool,
force: bool,
verbose: bool,
) -> Result<()> {
let base = pom_url_arg
.or_else(|| std::env::var("POM_URL").ok())
.unwrap_or_else(|| "http://localhost:3002".to_string());
let base = base.trim_end_matches('/');
let client = reqwest::Client::new();
let sprint = {
let url = format!("{}/api/sprints?status=active&per_page=1", base);
match fetch_pom_sprint(&client, &url).await? {
Some(s) => s,
None => {
let url2 = format!("{}/api/sprints?status=pending&per_page=1", base);
fetch_pom_sprint(&client, &url2)
.await?
.ok_or_else(|| PawanError::Config("No active or pending sprint in POM".to_string()))?
}
}
};
let (sprint_id, sprint_number, sprint_title, sprint_status) = sprint;
println!("{}", "Pawan POM Mode".green().bold());
println!("{}", "═".repeat(40).dimmed());
println!(
"{} #{} — {} [{}]",
"Sprint:".cyan().bold(),
sprint_number,
sprint_title,
sprint_status.dimmed()
);
println!();
let markdown_resp = client
.get(format!("{}/api/current-sprint", base))
.send()
.await
.map_err(|e| PawanError::Config(format!("POM unreachable: {e}")))?;
if markdown_resp.status() == reqwest::StatusCode::NOT_FOUND {
return Err(PawanError::Config(
"POM reports no active or pending sprint".to_string(),
));
}
let sprint_markdown = markdown_resp
.text()
.await
.map_err(|e| PawanError::Config(format!("POM response error: {e}")))?;
if sprint_markdown.contains("No dissues assigned") {
if force {
println!("{}", "Warning: sprint has no dissues — proceeding anyway (--force)".yellow());
} else {
return Err(PawanError::Config(
"Sprint has no dissues assigned. Use --force to execute anyway.".to_string(),
));
}
}
if verbose {
println!("{}", "Sprint content:".dimmed());
println!("{}", sprint_markdown.dimmed());
println!();
}
let prompt = format!(
"Execute the following sprint. Complete every task listed, \
commit your work with meaningful messages, and leave the project in a clean state.\n\n\
{}",
sprint_markdown
);
let config_ref = config.clone();
let mut agent = PawanAgent::new(config, workspace);
#[cfg(feature = "mcp")]
setup_mcp_tools(&mut agent, &config_ref).await;
let response = agent
.task(&prompt)
.await
.map_err(|e| PawanError::Config(format!("Agent error: {e}")))?;
println!("{}", response.content);
if verbose && !response.tool_calls.is_empty() {
println!("\n{}", "Tool calls made:".dimmed());
for tc in &response.tool_calls {
if tc.success {
println!(" {} {} ({}ms)", "✓".green(), tc.name.cyan(), tc.duration_ms);
} else {
println!(" {} {} ({}ms)", "✗".red(), tc.name.cyan(), tc.duration_ms);
}
}
}
if complete {
println!("\n{}", "Marking sprint complete in POM…".dimmed());
let url = format!("{}/api/sprints/{}/complete", base, sprint_id);
let resp = client
.post(&url)
.send()
.await
.map_err(|e| PawanError::Config(format!("POM complete call failed: {e}")))?;
if resp.status().is_success() {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
println!("{}", "Sprint marked complete.".green().bold());
if let Some(next) = body.get("next").and_then(|n| n.as_object()) {
if let (Some(num), Some(title)) =
(next.get("number"), next.get("title"))
{
println!(
"{} #{} — {}",
"Next sprint:".cyan(),
num,
title.as_str().unwrap_or("")
);
}
}
} else {
eprintln!(
"{} Failed to mark sprint complete (HTTP {})",
"Warning:".yellow(),
resp.status()
);
}
}
println!();
println!(
" {} iterations {} tool calls",
response.iterations.to_string().cyan(),
response.tool_calls.len().to_string().cyan()
);
Ok(())
}