#![allow(
dead_code,
unused_variables,
unused_assignments,
clippy::collapsible_if,
clippy::collapsible_else_if,
clippy::manual_strip,
clippy::needless_borrows_for_generic_args
)]
use npcrs::error::Result;
use npcrs::kernel::Kernel;
use rustyline::completion::{Completer, Pair};
use rustyline::error::ReadlineError;
use rustyline::highlight::Highlighter;
use rustyline::hint::Hinter;
use rustyline::validate::Validator;
use rustyline::{Cmd, CompletionType, Config, Editor, EventHandler, Helper, KeyEvent, Modifiers};
use std::borrow::Cow;
const CYAN: &str = "\x1b[36m";
const PURPLE: &str = "\x1b[35m";
const DIM: &str = "\x1b[90m";
const GREEN: &str = "\x1b[32m";
const YELLOW: &str = "\x1b[33m";
const RED: &str = "\x1b[31m";
const BOLD: &str = "\x1b[1m";
const RESET: &str = "\x1b[0m";
struct NpcHelper {
npc_names: Vec<String>,
commands: Vec<String>,
}
impl NpcHelper {
fn new(npc_names: Vec<String>, jinx_names: Vec<String>) -> Self {
let mut commands = vec![
"/ps", "/stats", "/help", "/quit", "/exit", "/clear", "/agent", "/chat", "/cmd",
"/switch", "/kill", "/jinxes", "/set", "/history",
]
.into_iter()
.map(String::from)
.collect::<Vec<_>>();
for j in jinx_names {
commands.push(format!("/{}", j));
}
Self {
npc_names,
commands,
}
}
}
impl Completer for NpcHelper {
type Candidate = Pair;
fn complete(
&self,
line: &str,
pos: usize,
_ctx: &rustyline::Context<'_>,
) -> rustyline::Result<(usize, Vec<Pair>)> {
let word_start = line[..pos].rfind(' ').map(|i| i + 1).unwrap_or(0);
let word = &line[word_start..pos];
let mut matches = Vec::new();
if word.starts_with('@') {
let prefix = &word[1..];
for name in &self.npc_names {
if name.starts_with(prefix) {
matches.push(Pair {
display: format!("@{}", name),
replacement: format!("@{} ", name),
});
}
}
} else if word.starts_with('/') {
for cmd in &self.commands {
if cmd.starts_with(word) {
matches.push(Pair {
display: cmd.clone(),
replacement: format!("{} ", cmd),
});
}
}
}
Ok((word_start, matches))
}
}
impl Hinter for NpcHelper {
type Hint = String;
}
impl Highlighter for NpcHelper {
fn highlight_prompt<'b, 's: 'b, 'p: 'b>(
&'s self,
prompt: &'p str,
_default: bool,
) -> Cow<'b, str> {
Cow::Borrowed(prompt)
}
}
impl Validator for NpcHelper {}
impl Helper for NpcHelper {}
#[derive(Clone, PartialEq)]
enum Mode {
Agent,
Chat,
Cmd,
}
impl std::fmt::Display for Mode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Mode::Agent => write!(f, "agent"),
Mode::Chat => write!(f, "chat"),
Mode::Cmd => write!(f, "cmd"),
}
}
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env()
.add_directive("npcrs=warn".parse().unwrap()),
)
.with_target(false)
.without_time()
.init();
let _ = dotenvy::dotenv();
load_npcshrc();
let team_dir = find_team_dir();
let db_path = shellexpand::tilde("~/npcsh_history.db").to_string();
let mut kernel = Kernel::boot(&team_dir, &db_path)?;
print_welcome(&kernel);
let config = Config::builder()
.completion_type(CompletionType::List)
.build();
let npc_names: Vec<String> = kernel.ps().iter().map(|p| p.npc.name.clone()).collect();
let jinx_names: Vec<String> = kernel.jinx_names().into_iter().map(String::from).collect();
let helper = NpcHelper::new(npc_names, jinx_names);
let history_path = shellexpand::tilde("~/.npcsh_history").to_string();
let mut rl = Editor::with_config(config).unwrap();
rl.set_helper(Some(helper));
rl.bind_sequence(
KeyEvent::new('', Modifiers::empty()),
EventHandler::Simple(Cmd::Interrupt),
);
match rl.load_history(&history_path) {
Ok(_) => tracing::info!("Loaded history from {}", history_path),
Err(e) => tracing::info!("No existing history found or error loading: {}", e),
}
let mut current_pid: u32 = 0;
let mut mode = Mode::Agent;
let mut turn_count: u64 = 0;
loop {
let npc_name = kernel
.get_process(current_pid)
.map(|p| p.npc.name.as_str())
.unwrap_or("???");
let cwd = std::env::current_dir()
.map(|p| {
let s = p.display().to_string();
let home = shellexpand::tilde("~").to_string();
if let Some(rest) = s.strip_prefix(&home) {
format!("~{}", rest)
} else {
s
}
})
.unwrap_or_else(|_| "?".to_string());
let model = kernel
.get_process(current_pid)
.map(|p| p.npc.resolved_model())
.unwrap_or_else(|| "?".to_string());
let prompt = format!(
"{DIM}{cwd}{RESET} {CYAN}{BOLD}{npc_name}{RESET} {DIM}[{mode}|{model}]{RESET}\n{PURPLE}>{RESET} "
);
let input = match rl.readline(&prompt) {
Ok(line) => line,
Err(ReadlineError::Interrupted) => {
eprintln!("^C");
continue;
}
Err(ReadlineError::Eof) => break,
Err(e) => {
eprintln!("Error: {}", e);
break;
}
};
let input = input.trim().to_string();
if input.is_empty() {
continue;
}
if !input.trim().is_empty() {
let _ = rl.add_history_entry(&input);
}
let handled = match input.as_str() {
"exit" | "quit" | "/quit" | "/exit" => break,
"/ps" => {
for p in kernel.ps() {
let state_color = match p.state {
npcrs::process::ProcessState::Running => GREEN,
npcrs::process::ProcessState::Blocked => YELLOW,
npcrs::process::ProcessState::Dead => RED,
_ => DIM,
};
println!(
" {CYAN}@{:<12}{RESET} pid:{:<3} {state_color}{:?}{RESET} tokens:{}/{} cost:${:.4} turns:{}",
p.npc.name,
p.pid,
p.state,
p.usage.total_input_tokens,
p.usage.total_output_tokens,
p.usage.total_cost_usd,
p.usage.total_turns,
);
}
true
}
"/stats" => {
let s = kernel.stats();
println!(
"{BOLD}Kernel Stats{RESET}\n uptime: {}s\n processes: {} (run:{} blk:{} dead:{})\n tokens: {} (in+out)\n cost: ${:.4}\n jinxes: {}",
s.uptime_secs,
s.total_processes,
s.running,
s.blocked,
s.dead,
s.total_tokens,
s.total_cost_usd,
s.jinx_count,
);
true
}
"/help" => {
println!(
"{BOLD}npcsh-rs{RESET} — NPC OS Shell v{}\n",
env!("CARGO_PKG_VERSION")
);
println!("{BOLD}Modes:{RESET}");
println!(" {CYAN}/agent{RESET} Full agent mode (tools + bash + LLM)");
println!(" {CYAN}/chat{RESET} Chat-only mode (LLM, no tools)");
println!(" {CYAN}/cmd{RESET} Command mode (bash first, LLM fallback)");
println!();
println!("{BOLD}NPC Commands:{RESET}");
println!(" {CYAN}@npc{RESET} Switch to NPC process");
println!(" {CYAN}@npc command{RESET} Delegate command to NPC");
println!(" {CYAN}/switch <npc>{RESET} Switch to NPC process");
println!(" {CYAN}/kill{RESET} Kill current process");
println!();
println!("{BOLD}Info:{RESET}");
println!(" {CYAN}/ps{RESET} List processes");
println!(" {CYAN}/stats{RESET} Kernel stats");
println!(" {CYAN}/jinxes{RESET} List available tools");
println!(" {CYAN}/history{RESET} Show conversation history");
println!();
println!("{BOLD}Config:{RESET}");
println!(" {CYAN}/set key=val{RESET} Set model, provider, mode");
println!(" {CYAN}/clear{RESET} Clear conversation");
println!();
println!("{BOLD}Shell:{RESET}");
println!(" Any text is sent to the current NPC.");
println!(" In {CYAN}/cmd{RESET} mode, input runs as bash first.");
println!(" Tab completes @npcs and /commands.");
true
}
"/agent" => {
mode = Mode::Agent;
eprintln!("{GREEN}Switched to agent mode{RESET}");
true
}
"/chat" => {
mode = Mode::Chat;
eprintln!("{GREEN}Switched to chat mode{RESET}");
true
}
"/cmd" => {
mode = Mode::Cmd;
eprintln!("{GREEN}Switched to cmd mode{RESET}");
true
}
"/jinxes" => {
let names = kernel.jinx_names();
let mut sorted: Vec<&str> = names;
sorted.sort();
println!("{BOLD}Available jinxes ({}):{RESET}", sorted.len());
for chunk in sorted.chunks(6) {
println!(
" {}",
chunk
.iter()
.map(|n| format!("{CYAN}/{n}{RESET}"))
.collect::<Vec<_>>()
.join(" ")
);
}
true
}
"/clear" => {
if let Some(p) = kernel.get_process_mut(current_pid) {
p.messages.clear();
eprintln!("{GREEN}Conversation cleared{RESET}");
}
true
}
"/history" => {
if let Some(p) = kernel.get_process(current_pid) {
if p.messages.is_empty() {
println!("{DIM}(no messages){RESET}");
} else {
for m in &p.messages {
let role_color = match m.role.as_str() {
"user" => CYAN,
"assistant" => GREEN,
_ => DIM,
};
let content = m.content.as_deref().unwrap_or("");
let preview = if content.len() > 80 {
format!("{}...", &content[..80])
} else {
content.to_string()
};
println!(" {role_color}{:<10}{RESET} {}", m.role, preview);
}
}
}
true
}
"/kill" => {
if current_pid == 0 {
eprintln!("{RED}Cannot kill init (pid 0){RESET}");
} else {
let name = kernel.get_process(current_pid).map(|p| p.npc.name.clone());
kernel.kill(current_pid, 0).ok();
current_pid = 0;
eprintln!(
"{YELLOW}Killed @{} — switched to init{RESET}",
name.unwrap_or_default()
);
}
true
}
_ => false,
};
if handled {
continue;
}
if input.starts_with("/set ") {
let rest = input.strip_prefix("/set ").unwrap().trim();
handle_set_command(rest, &mut kernel, current_pid, &mut mode);
continue;
}
if input.starts_with('@') {
let parts: Vec<&str> = input[1..].splitn(2, ' ').collect();
let target = parts[0];
if let Some(command) = parts.get(1) {
eprintln!("{DIM}delegating to @{target}...{RESET}");
match kernel.delegate(current_pid, target, command).await {
Ok(output) => println!("{}", output),
Err(e) => eprintln!("{RED}Error: {e}{RESET}"),
}
} else {
if let Some(proc) = kernel.find_by_name(target) {
current_pid = proc.pid;
eprintln!("{GREEN}Switched to @{target} (pid:{current_pid}){RESET}");
} else {
eprintln!("{RED}NPC '{target}' not found.{RESET} Available:");
for p in kernel.ps() {
eprintln!(" {CYAN}@{}{RESET}", p.npc.name);
}
}
}
continue;
}
if input.starts_with('/') {
let parts: Vec<&str> = input[1..].splitn(2, ' ').collect();
let cmd_name = parts[0];
let args_str = parts.get(1).unwrap_or(&"");
if kernel.jinxes.contains_key(cmd_name) {
let mut args = std::collections::HashMap::new();
if !args_str.is_empty() {
let mut has_kv = false;
for part in args_str.split_whitespace() {
if let Some((k, v)) = part.split_once('=') {
args.insert(k.to_string(), v.to_string());
has_kv = true;
}
}
if !has_kv {
if let Some(first_input) = kernel.jinxes[cmd_name].inputs.first() {
args.insert(first_input.name.clone(), args_str.to_string());
}
}
}
match kernel.syscall(current_pid, cmd_name, &args).await {
Ok(output) => {
if !output.is_empty() {
println!("{}", output);
}
}
Err(e) => eprintln!("{RED}Error: {e}{RESET}"),
}
} else {
eprintln!("{RED}Unknown command: /{cmd_name}{RESET}");
}
continue;
}
if input.starts_with("cd ") || input == "cd" {
let target = input.strip_prefix("cd").unwrap().trim();
let target = if target.is_empty() {
shellexpand::tilde("~").to_string()
} else {
shellexpand::tilde(target).to_string()
};
let target = if std::path::Path::new(&target).is_relative() {
let cwd = std::env::current_dir().unwrap_or_default();
cwd.join(&target)
.canonicalize()
.unwrap_or_else(|_| cwd.join(&target))
.display()
.to_string()
} else {
target
};
match std::env::set_current_dir(&target) {
Ok(_) => eprintln!("{DIM}Changed to: {target}{RESET}"),
Err(e) => eprintln!("{RED}cd: {e}{RESET}"),
}
continue;
}
if is_terminal_editor(&input) {
run_interactive(&input);
continue;
}
if is_interactive(&input) {
run_interactive(&input);
continue;
}
turn_count += 1;
match mode {
Mode::Agent => {
if is_bash_command(&input) {
run_bash(&input).await;
} else {
match kernel.exec(current_pid, &input).await {
Ok(output) if !output.is_empty() => println!("{}", output),
Err(e) => eprintln!("{RED}Error: {e}{RESET}"),
_ => {}
}
}
}
Mode::Chat => match kernel.exec_chat(current_pid, &input).await {
Ok(output) if !output.is_empty() => println!("{}", output),
Err(e) => eprintln!("{RED}Error: {e}{RESET}"),
_ => {}
},
Mode::Cmd => {
if !run_bash(&input).await {
match kernel.exec(current_pid, &input).await {
Ok(output) if !output.is_empty() => println!("{}", output),
Err(e) => eprintln!("{RED}Error: {e}{RESET}"),
_ => {}
}
}
}
}
if let Some(p) = kernel.get_process(current_pid) {
if p.usage.total_turns > 0 {
eprintln!(
"{DIM}[tokens:{}/{} | turn:{} | cost:${:.4}]{RESET}",
p.usage.total_input_tokens,
p.usage.total_output_tokens,
p.usage.total_turns,
p.usage.total_cost_usd,
);
}
}
}
if let Err(e) = rl.save_history(&history_path) {
eprintln!("{DIM}Warning: Failed to save history: {e}{RESET}");
} else {
tracing::info!("History saved to {}", history_path);
}
let s = kernel.stats();
eprintln!(
"{DIM}uptime: {}s | tokens: {} | cost: ${:.4}{RESET}",
s.uptime_secs, s.total_tokens, s.total_cost_usd
);
Ok(())
}
fn handle_set_command(rest: &str, kernel: &mut Kernel, pid: u32, mode: &mut Mode) {
let parts: Vec<&str> = rest.splitn(2, '=').collect();
if parts.len() != 2 {
eprintln!("Usage: /set key=value");
eprintln!(" model=gpt-4o provider=openai mode=chat");
return;
}
let key = parts[0].trim();
let value = parts[1].trim();
match key {
"model" => {
if let Some(p) = kernel.get_process_mut(pid) {
p.npc.model = Some(value.to_string());
eprintln!("{GREEN}model = {value}{RESET}");
}
}
"provider" => {
if let Some(p) = kernel.get_process_mut(pid) {
p.npc.provider = Some(value.to_string());
eprintln!("{GREEN}provider = {value}{RESET}");
}
}
"mode" => match value {
"agent" => *mode = Mode::Agent,
"chat" => *mode = Mode::Chat,
"cmd" => *mode = Mode::Cmd,
_ => eprintln!("{RED}Unknown mode: {value}{RESET}"),
},
_ => eprintln!("{RED}Unknown setting: {key}{RESET}"),
}
}
fn print_welcome(kernel: &Kernel) {
let s = kernel.stats();
let blue = "\x1b[1;94m";
let rust = "\x1b[1;38;5;202m";
eprintln!();
eprintln!("{blue}___________________________________________{RESET}");
eprintln!();
eprintln!(" Welcome to {blue}npc{RESET}{rust}sh{RESET}!");
eprintln!("{blue} {RESET}{rust} _ \\{RESET}");
eprintln!("{blue} _ __ _ __ ___ {RESET}{rust} ___ | |___ \\{RESET}");
eprintln!("{blue}| '_ \\ | '_ \\ / __|{RESET}{rust} / __/ | |_ _| \\{RESET}");
eprintln!("{blue}| | | || |_) |( |__ {RESET}{rust} \\_ \\ | | | | //{RESET}");
eprintln!("{blue}|_| |_|| .__/ \\___/{RESET}{rust} |___/ |_| |_| //{RESET}");
eprintln!(" {blue}|🤖| {RESET}{rust} //{RESET}");
eprintln!(" {blue}|🤖|{RESET}");
eprintln!(" {blue}|🤖|{RESET}");
eprintln!("{rust}___________________________________________{RESET}");
eprintln!();
eprintln!(" {BOLD}npcsh{RESET} v{} (rust)", env!("CARGO_PKG_VERSION"));
eprintln!(
" {DIM}{} processes | {} jinxes | /help for commands{RESET}",
s.total_processes, s.jinx_count
);
eprintln!();
eprint!(" {BOLD}Agents:{RESET} ");
let names: Vec<String> = kernel
.ps()
.iter()
.map(|p| format!("{CYAN}@{}{RESET}", p.npc.name))
.collect();
eprintln!("{}", names.join(" "));
eprintln!(" {BOLD}Modes:{RESET} {CYAN}/agent{RESET} {CYAN}/chat{RESET} {CYAN}/cmd{RESET}");
eprintln!();
}
fn load_npcshrc() {
let rc_path = shellexpand::tilde("~/.npcshrc").to_string();
let path = std::path::Path::new(&rc_path);
if !path.exists() {
return;
}
if let Ok(content) = std::fs::read_to_string(path) {
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let line = line.strip_prefix("export ").unwrap_or(line);
if let Some((key, value)) = line.split_once('=') {
let key = key.trim();
let value = value.trim().trim_matches('"').trim_matches('\'');
if std::env::var(key).is_err() {
unsafe { std::env::set_var(key, value) };
}
}
}
}
}
const TERMINAL_EDITORS: &[&str] = &["vim", "nvim", "nano", "vi", "emacs", "less", "more", "man"];
const INTERACTIVE_COMMANDS: &[&str] = &[
"ipython",
"python",
"python3",
"node",
"irb",
"ghci",
"mysql",
"psql",
"sqlite3",
"redis-cli",
"mongo",
"ssh",
"telnet",
"ftp",
"sftp",
"top",
"htop",
"watch",
"r",
];
const SHELL_BUILTINS: &[&str] = &[
"cd", "pwd", "echo", "export", "source", "alias", "unalias", "history", "set", "unset", "read",
"eval", "exec", "exit", "return", "shift", "trap", "wait", "jobs", "fg", "bg", "kill",
"ulimit", "umask", "type", "hash", "true", "false",
];
fn is_bash_command(input: &str) -> bool {
let parts: Vec<&str> = input.split_whitespace().collect();
if parts.is_empty() {
return false;
}
let cmd = parts[0];
if SHELL_BUILTINS.contains(&cmd) {
return true;
}
if let Ok(output) = std::process::Command::new("which").arg(cmd).output() {
return output.status.success();
}
false
}
fn is_terminal_editor(input: &str) -> bool {
let cmd = input.split_whitespace().next().unwrap_or("");
TERMINAL_EDITORS.contains(&cmd)
}
fn is_interactive(input: &str) -> bool {
let cmd = input.split_whitespace().next().unwrap_or("");
INTERACTIVE_COMMANDS.contains(&cmd)
}
async fn run_bash(input: &str) -> bool {
match tokio::process::Command::new("bash")
.arg("-c")
.arg(input)
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.status()
.await
{
Ok(status) => status.success(),
Err(e) => {
eprintln!("{RED}bash: {e}{RESET}");
false
}
}
}
fn run_interactive(input: &str) {
let _ = std::process::Command::new("bash")
.arg("-c")
.arg(input)
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.status();
}
fn find_team_dir() -> String {
let args: Vec<String> = std::env::args().collect();
if let Some(pos) = args.iter().position(|a| a == "--team") {
if let Some(dir) = args.get(pos + 1) {
return dir.clone();
}
}
if std::path::Path::new("./npc_team").exists() {
return "./npc_team".to_string();
}
let global = shellexpand::tilde("~/.npcsh/npc_team").to_string();
if std::path::Path::new(&global).exists() {
return global;
}
".".to_string()
}