use std::io::{IsTerminal, Read};
use std::process::ExitCode;
use attini::session_cmd;
use attini::tell_cli::{self, Continuation, DEFAULT_MAX_TURNS, RateLimit, TellConfig};
const EXIT_USAGE: u8 = 2;
const EXIT_RUNTIME: u8 = 1;
const DEFAULT_MODEL: &str = "deepseek-flash";
const SESSION_ENV: &str = "ATTINI_SESSION_NAME";
const MODEL_ENV: &str = "ATTINI_MODEL_NAME";
const MAX_TOKENS_ENV: &str = "ATTINI_MAX_TOKENS";
const TEMPERATURE_ENV: &str = "ATTINI_TEMPERATURE";
const SYSTEM_PROMPT_ENV: &str = "ATTINI_SYSTEM_PROMPT";
const COMMAND_TIMEOUT_ENV: &str = "ATTINI_COMMAND_TIMEOUT_SECONDS";
const MAX_STDIN_BYTES: usize = 1024 * 1024;
const DEFAULT_TURN_TOOL_CALL_LIMIT_STR: &str = "20";
const DEFAULT_TOOL_CALL_RATE_STR: &str = "60/60";
const DEFAULT_SESSION_TOOL_CALL_MAX_STR: &str = "5000";
fn main() -> ExitCode {
match run() {
Ok(RunOutcome::Ok) => ExitCode::SUCCESS,
Ok(RunOutcome::Exit(code)) => code,
Err(RunError::Usage(err)) => {
eprintln!("{err:?}");
ExitCode::from(EXIT_USAGE)
}
Err(RunError::Runtime(msg)) => {
eprintln!("attini: {msg}");
ExitCode::from(EXIT_RUNTIME)
}
}
}
enum RunOutcome {
Ok,
Exit(ExitCode),
}
#[derive(Debug, Clone, Copy)]
enum CommandOutcome {
NotHandled,
Done,
Exit(ExitCode),
Help,
}
enum RunError {
Usage(noargs::Error),
Runtime(String),
}
impl From<noargs::Error> for RunError {
fn from(err: noargs::Error) -> Self {
Self::Usage(err)
}
}
fn append_stdin_aux(prompt: String, stdin_text: &str) -> String {
format!("{prompt}\n\n--- stdin ---\n{stdin_text}\n--- end stdin ---")
}
fn read_stdin_auxiliary() -> Result<Option<String>, RunError> {
if std::io::stdin().is_terminal() {
eprintln!(
"note: --stdin: reading from the terminal; type your text and press EOF (Ctrl+D) to \
send, or Ctrl+C to cancel"
);
}
let mut buf = Vec::new();
std::io::stdin()
.read_to_end(&mut buf)
.map_err(|e| RunError::Runtime(format!("failed to read standard input: {e}")))?;
if buf.len() > MAX_STDIN_BYTES {
return Err(RunError::Runtime(format!(
"standard input exceeded {MAX_STDIN_BYTES} bytes; paste a smaller fragment"
)));
}
let s = String::from_utf8(buf)
.map_err(|e| RunError::Runtime(format!("standard input is not UTF-8: {e}")))?;
if s.is_empty() {
eprintln!("warning: --stdin produced empty input; continuing without it");
Ok(None)
} else {
Ok(Some(s))
}
}
fn check_unconsumed_args(args: &noargs::RawArgs) -> Result<(), RunError> {
if let Some((_, raw)) = args.remaining_args().next() {
return Err(RunError::Usage(noargs::Error::other(
args,
format!("unexpected argument '{raw}' found"),
)));
}
Ok(())
}
fn run() -> Result<RunOutcome, RunError> {
let mut args = noargs::raw_args();
args.metadata_mut().app_name = env!("CARGO_PKG_NAME");
args.metadata_mut().app_description = "DeepSeek-based coding agent prototype.";
if noargs::VERSION_FLAG.take(&mut args).is_present() {
println!("{} {}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
return Ok(RunOutcome::Ok);
}
noargs::HELP_FLAG.take_help(&mut args);
match try_run_tell(&mut args)? {
CommandOutcome::NotHandled => {}
CommandOutcome::Done => return Ok(RunOutcome::Ok),
CommandOutcome::Exit(exit) => return Ok(RunOutcome::Exit(exit)),
CommandOutcome::Help => {
if let Some(help) = args.finish()? {
print!("{help}");
}
return Ok(RunOutcome::Ok);
}
}
match try_run_approve(&mut args)? {
CommandOutcome::NotHandled => {}
CommandOutcome::Done => return Ok(RunOutcome::Ok),
CommandOutcome::Exit(exit) => return Ok(RunOutcome::Exit(exit)),
CommandOutcome::Help => {
if let Some(help) = args.finish()? {
print!("{help}");
}
return Ok(RunOutcome::Ok);
}
}
match try_run_ask(&mut args)? {
CommandOutcome::NotHandled => {}
CommandOutcome::Done => return Ok(RunOutcome::Ok),
CommandOutcome::Exit(_) => unreachable!("attini ask never exits"),
CommandOutcome::Help => {
if let Some(help) = args.finish()? {
print!("{help}");
}
return Ok(RunOutcome::Ok);
}
}
for outcome in [try_run_status(&mut args)?, try_run_logstats(&mut args)?] {
match outcome {
CommandOutcome::NotHandled => {}
CommandOutcome::Done => return Ok(RunOutcome::Ok),
CommandOutcome::Exit(exit) => return Ok(RunOutcome::Exit(exit)),
CommandOutcome::Help => {
if let Some(help) = args.finish()? {
print!("{help}");
}
return Ok(RunOutcome::Ok);
}
}
}
if let Some(help) = args.finish()? {
print!("{help}");
}
Ok(RunOutcome::Ok)
}
fn try_run_tell(args: &mut noargs::RawArgs) -> Result<CommandOutcome, RunError> {
if !noargs::cmd("tell")
.doc("Tell the agent what to do; it runs one turn against a persistent session")
.take(args)
.is_present()
{
return Ok(CommandOutcome::NotHandled);
}
let model: String = noargs::opt("model")
.ty("NAME")
.doc("Model name")
.default(DEFAULT_MODEL)
.env(MODEL_ENV)
.take(args)
.then(|o| o.value().parse())?;
let system: Option<String> = noargs::opt("system-prompt")
.ty("TEXT")
.doc("Optional system prompt prepended to the conversation")
.env(SYSTEM_PROMPT_ENV)
.take(args)
.present_and_then(|o| o.value().parse())?;
let session_name: String = noargs::opt("session")
.short('s')
.ty("NAME")
.doc("Session name; directory is .attini/<NAME>/")
.default("main")
.env(SESSION_ENV)
.take(args)
.then(|o| o.value().parse())?;
let turn_tool_call_limit: usize = noargs::opt("turn-tool-call-limit")
.ty("N")
.doc(
"Maximum tool calls admitted per model turn. Extras get a synthetic error \
result and the loop advances to the next turn.",
)
.default(DEFAULT_TURN_TOOL_CALL_LIMIT_STR)
.take(args)
.then(|o| o.value().parse())?;
let tool_call_rate_raw: String = noargs::opt("tool-call-rate")
.ty("CALLS/SECS|none")
.doc(
"Sliding-window rate cap on admitted tool calls, formatted as \
<calls>/<window_seconds>. Use `none` to disable.",
)
.default(DEFAULT_TOOL_CALL_RATE_STR)
.take(args)
.then(|o| o.value().parse())?;
let session_tool_call_max_raw: String = noargs::opt("session-tool-call-max")
.ty("N|none")
.doc(
"Invocation-scope backstop on admitted tool calls. Reaching it ends the \
invocation with reason=session_tool_call_exhausted. Use `none` to disable.",
)
.default(DEFAULT_SESSION_TOOL_CALL_MAX_STR)
.take(args)
.then(|o| o.value().parse())?;
let max_tokens: Option<u64> = noargs::opt("max-tokens")
.ty("N")
.doc("Maximum completion tokens per model call; `none` uses the model default")
.env(MAX_TOKENS_ENV)
.take(args)
.present_and_then(|o| o.value().parse::<u64>())?;
let temperature: Option<f64> = noargs::opt("temperature")
.short('t')
.ty("N")
.doc(
"Sampling temperature for model calls; 0 is deterministic. Default 0 for code editing.",
)
.env(TEMPERATURE_ENV)
.take(args)
.present_and_then(|o| o.value().parse::<f64>())?;
let command_timeout_seconds: Option<u64> = noargs::opt("command-timeout")
.ty("N")
.doc(
"Wall-clock cap in seconds on a single `command` tool call; the child is killed \
(SIGTERM, then SIGKILL) on expiry. Default 180. `0` disables the cap.",
)
.env(COMMAND_TIMEOUT_ENV)
.take(args)
.present_and_then(|o| o.value().parse::<u64>())?;
let use_stdin = noargs::flag("stdin")
.short('I')
.doc(
"Read standard input and append it to the prompt as auxiliary content \
(not a file). Reads until EOF (from a terminal, press Ctrl+D); caps at 1 MiB.",
)
.take(args)
.is_present();
let prompt: Option<String> = noargs::arg("[PROMPT]")
.doc("User prompt for this turn")
.example("List the files in src/")
.take(args)
.present_and_then(|a| a.value().parse())?;
if args.metadata().help_mode {
return Ok(CommandOutcome::Help);
}
check_unconsumed_args(args)?;
let tool_call_rate = parse_tool_call_rate(&tool_call_rate_raw)?;
let session_tool_call_max = parse_session_tool_call_max(&session_tool_call_max_raw)?;
let p = match prompt {
Some(p) => p,
None => {
return Err(RunError::Runtime(
"PROMPT is required (to approve a pending call, use `attini approve`)".to_string(),
));
}
};
let p = if use_stdin {
match read_stdin_auxiliary()? {
Some(text) => append_stdin_aux(p, &text),
None => p,
}
} else {
p
};
let cont = Continuation::Prompt(p);
let authorization = attini::sansio::permissions::Authorization::PerTool;
let workspace_root = std::env::current_dir()
.map_err(|e| RunError::Runtime(format!("failed to read current dir: {e}")))?;
let cfg = TellConfig {
session_name,
model,
max_tokens,
workspace_root,
system_prompt: system,
max_turns: DEFAULT_MAX_TURNS,
turn_tool_call_limit,
tool_call_rate,
session_tool_call_max,
authorization,
temperature,
grant_request: tell_cli::GrantRequest::None,
command_timeout_seconds,
};
match tell_cli::run(cfg, cont).map_err(|e| RunError::Runtime(e.to_string()))? {
tell_cli::TellOutcome::Exit(code) => Ok(CommandOutcome::Exit(code)),
}
}
fn try_run_approve(args: &mut noargs::RawArgs) -> Result<CommandOutcome, RunError> {
if !noargs::cmd("approve")
.doc("Resume a stopped session: approve its pending tool call(s), or continue at max_turns")
.take(args)
.is_present()
{
return Ok(CommandOutcome::NotHandled);
}
let session_name: String = noargs::opt("session")
.short('s')
.ty("NAME")
.doc("Session name; directory is .attini/<NAME>/")
.default("main")
.env(SESSION_ENV)
.take(args)
.then(|o| o.value().parse())?;
let model: String = noargs::opt("model")
.ty("NAME")
.doc("Model name")
.default(DEFAULT_MODEL)
.env(MODEL_ENV)
.take(args)
.then(|o| o.value().parse())?;
let grant: tell_cli::GrantRequest = match noargs::opt("grant")
.ty("SCOPE")
.doc(
"Also persist an auto-approve rule for the approved command: \
`oneshot` (approve only, persist nothing — the default), \
`session` (append the args-prefix to the session permissions.jsonl), or \
`workspace` (append to the workspace-wide permissions.jsonl).",
)
.take(args)
.present_and_then(|o| o.value().parse::<String>())?
{
Some(s) => match s.as_str() {
"oneshot" => tell_cli::GrantRequest::Oneshot,
"session" => tell_cli::GrantRequest::Session,
"workspace" => tell_cli::GrantRequest::Workspace,
other => {
return Err(RunError::Runtime(format!(
"--grant must be 'oneshot', 'session', or 'workspace', got '{other}'"
)));
}
},
None => tell_cli::GrantRequest::None,
};
let command_timeout_seconds: Option<u64> = noargs::opt("command-timeout")
.ty("N")
.doc(
"Wall-clock cap in seconds on a single `command` tool call; the child is killed \
(SIGTERM, then SIGKILL) on expiry. Default 180. `0` disables the cap.",
)
.env(COMMAND_TIMEOUT_ENV)
.take(args)
.present_and_then(|o| o.value().parse::<u64>())?;
if args.metadata().help_mode {
return Ok(CommandOutcome::Help);
}
check_unconsumed_args(args)?;
let workspace_root = std::env::current_dir()
.map_err(|e| RunError::Runtime(format!("failed to read current dir: {e}")))?;
let cfg = TellConfig {
session_name,
model,
max_tokens: None,
workspace_root,
system_prompt: None,
max_turns: DEFAULT_MAX_TURNS,
turn_tool_call_limit: DEFAULT_TURN_TOOL_CALL_LIMIT_STR
.parse()
.map_err(|e| RunError::Runtime(format!("bad default turn limit: {e}")))?,
tool_call_rate: parse_tool_call_rate(DEFAULT_TOOL_CALL_RATE_STR)?,
session_tool_call_max: parse_session_tool_call_max(DEFAULT_SESSION_TOOL_CALL_MAX_STR)?,
authorization: attini::sansio::permissions::Authorization::PerTool,
temperature: None,
grant_request: grant,
command_timeout_seconds,
};
match tell_cli::run(cfg, Continuation::Approve).map_err(|e| RunError::Runtime(e.to_string()))? {
tell_cli::TellOutcome::Exit(code) => Ok(CommandOutcome::Exit(code)),
}
}
fn try_run_ask(args: &mut noargs::RawArgs) -> Result<CommandOutcome, RunError> {
if !noargs::cmd("ask")
.doc("Ask the model about the current state of a session (read-only)")
.take(args)
.is_present()
{
return Ok(CommandOutcome::NotHandled);
}
let session_name: String = noargs::opt("session")
.short('s')
.ty("NAME")
.doc("Session name; directory is .attini/<NAME>/")
.default("main")
.env(SESSION_ENV)
.take(args)
.then(|o| o.value().parse())?;
let model: String = noargs::opt("model")
.ty("NAME")
.doc("Model name used for the summariser")
.default(DEFAULT_MODEL)
.env(MODEL_ENV)
.take(args)
.then(|o| o.value().parse())?;
let limit: Option<usize> = noargs::opt("limit")
.ty("N")
.doc("Only summarise the most recent N conversation records")
.take(args)
.present_and_then(|o| o.value().parse::<usize>())?;
let all = noargs::flag("all")
.doc("Summarise the entire conversation, ignoring the last summary cutoff")
.take(args)
.is_present();
let max_tokens: Option<u64> = noargs::opt("max-tokens")
.ty("N")
.doc("Maximum tokens for the summariser response")
.env(MAX_TOKENS_ENV)
.take(args)
.present_and_then(|o| o.value().parse::<u64>())?;
let question: Option<String> = noargs::arg("[QUESTION]")
.doc("Optional question to focus the model's answer on the current state")
.example("What is the model currently working on?")
.take(args)
.present_and_then(|a| a.value().parse())?;
if args.metadata().help_mode {
return Ok(CommandOutcome::Help);
}
check_unconsumed_args(args)?;
session_cmd::run_ask(
&session_name,
question.as_deref(),
&model,
limit,
all,
max_tokens,
)
.map_err(|e| RunError::Runtime(e.to_string()))?;
Ok(CommandOutcome::Done)
}
fn parse_tool_call_rate(raw: &str) -> Result<Option<RateLimit>, RunError> {
if raw == "none" {
return Ok(None);
}
let (calls_str, window_str) = raw.split_once('/').ok_or_else(|| {
RunError::Runtime(format!(
"--tool-call-rate must be <calls>/<window_seconds> or `none` (got {raw:?})"
))
})?;
let calls: usize = calls_str.parse().map_err(|e| {
RunError::Runtime(format!(
"--tool-call-rate calls part {calls_str:?} is not a non-negative integer: {e}"
))
})?;
let window_secs: u64 = window_str.parse().map_err(|e| {
RunError::Runtime(format!(
"--tool-call-rate window part {window_str:?} is not a non-negative integer: {e}"
))
})?;
if calls == 0 || window_secs == 0 {
return Err(RunError::Runtime(
"--tool-call-rate calls and window must both be positive (use `none` to disable)"
.to_string(),
));
}
Ok(Some(RateLimit {
calls,
window: std::time::Duration::from_secs(window_secs),
}))
}
fn parse_session_tool_call_max(raw: &str) -> Result<Option<usize>, RunError> {
if raw == "none" {
return Ok(None);
}
let n: usize = raw.parse().map_err(|e| {
RunError::Runtime(format!(
"--session-tool-call-max must be a non-negative integer or `none` (got {raw:?}): {e}"
))
})?;
if n == 0 {
return Err(RunError::Runtime(
"--session-tool-call-max must be positive (use `none` to disable)".to_string(),
));
}
Ok(Some(n))
}
fn try_run_status(args: &mut noargs::RawArgs) -> Result<CommandOutcome, RunError> {
if !noargs::cmd("status")
.doc("Show one session's current state (lock, summary, pending) and aggregate metrics.")
.take(args)
.is_present()
{
return Ok(CommandOutcome::NotHandled);
}
let name: String = noargs::opt("session")
.short('s')
.ty("NAME")
.doc("Session name; directory is .attini/<NAME>/")
.default("main")
.env(SESSION_ENV)
.take(args)
.then(|o| o.value().parse())?;
let json = noargs::flag("json")
.doc("Emit the whole overview as a JSON object")
.take(args)
.is_present();
if args.metadata().help_mode {
return Ok(CommandOutcome::Help);
}
session_cmd::run_status(&name, json).map_err(|e| RunError::Runtime(e.to_string()))?;
Ok(CommandOutcome::Done)
}
fn try_run_logstats(args: &mut noargs::RawArgs) -> Result<CommandOutcome, RunError> {
if !noargs::cmd("logstats")
.doc(
"Summarise one session's conversation log: record-kind histogram, \
assistant payload split, tool-result bytes by function, read \
targets, command programs/families, token-usage totals. \
Read-only; never acquires the session LOCK.",
)
.take(args)
.is_present()
{
return Ok(CommandOutcome::NotHandled);
}
let name: String = noargs::opt("session")
.short('s')
.ty("NAME")
.doc("Session name; directory is .attini/<NAME>/")
.default("main")
.env(SESSION_ENV)
.take(args)
.then(|o| o.value().parse())?;
let json = noargs::flag("json")
.doc("Emit the full analysis (not just top-10) as a JSON object")
.take(args)
.is_present();
if args.metadata().help_mode {
return Ok(CommandOutcome::Help);
}
session_cmd::run_logstats(&name, json).map_err(|e| RunError::Runtime(e.to_string()))?;
Ok(CommandOutcome::Done)
}
#[cfg(test)]
mod tests {
use super::*;
use attini::tell_cli::{
DEFAULT_SESSION_TOOL_CALL_MAX, DEFAULT_TOOL_CALL_RATE_CALLS,
DEFAULT_TOOL_CALL_RATE_WINDOW_SECS, DEFAULT_TURN_TOOL_CALL_LIMIT,
};
#[test]
fn default_string_constants_stay_in_sync() {
assert_eq!(
DEFAULT_TURN_TOOL_CALL_LIMIT_STR,
DEFAULT_TURN_TOOL_CALL_LIMIT.to_string()
);
assert_eq!(
DEFAULT_TOOL_CALL_RATE_STR,
format!(
"{}/{}",
DEFAULT_TOOL_CALL_RATE_CALLS, DEFAULT_TOOL_CALL_RATE_WINDOW_SECS
)
);
assert_eq!(
DEFAULT_SESSION_TOOL_CALL_MAX_STR,
DEFAULT_SESSION_TOOL_CALL_MAX.to_string()
);
}
#[test]
fn tool_call_rate_none_disables() {
match parse_tool_call_rate("none") {
Ok(None) => {}
other => panic!("expected Ok(None), got is_ok={}", other.is_ok()),
}
}
#[test]
fn tool_call_rate_valid_form_parses() {
match parse_tool_call_rate("30/15") {
Ok(Some(rl)) => {
assert_eq!(rl.calls, 30);
assert_eq!(rl.window, std::time::Duration::from_secs(15));
}
other => panic!("expected Ok(Some(...)), got is_ok={}", other.is_ok()),
}
}
#[test]
fn tool_call_rate_missing_slash_errors() {
assert!(parse_tool_call_rate("30").is_err());
}
#[test]
fn tool_call_rate_zero_parts_error() {
assert!(parse_tool_call_rate("0/60").is_err());
assert!(parse_tool_call_rate("60/0").is_err());
}
#[test]
fn tool_call_rate_non_integer_errors() {
assert!(parse_tool_call_rate("abc/60").is_err());
assert!(parse_tool_call_rate("60/xyz").is_err());
}
#[test]
fn session_tool_call_max_none_disables() {
match parse_session_tool_call_max("none") {
Ok(None) => {}
other => panic!("expected Ok(None), got is_ok={}", other.is_ok()),
}
}
#[test]
fn session_tool_call_max_positive_parses() {
match parse_session_tool_call_max("42") {
Ok(Some(n)) => assert_eq!(n, 42),
other => panic!("expected Ok(Some(42)), got is_ok={}", other.is_ok()),
}
}
#[test]
fn session_tool_call_max_zero_errors() {
assert!(parse_session_tool_call_max("0").is_err());
}
#[test]
fn session_tool_call_max_non_integer_errors() {
assert!(parse_session_tool_call_max("abc").is_err());
}
#[test]
fn unconsumed_args_catch_extra_positions() {
let mut args = noargs::RawArgs::new(
["attini", "tell", "hello", "world"]
.iter()
.map(|s| s.to_string()),
);
noargs::cmd("tell").take(&mut args);
noargs::arg("[PROMPT]").take(&mut args);
assert!(check_unconsumed_args(&args).is_err());
}
#[test]
fn unconsumed_args_ok_when_all_consumed() {
let mut args =
noargs::RawArgs::new(["attini", "tell", "hello"].iter().map(|s| s.to_string()));
noargs::cmd("tell").take(&mut args);
noargs::arg("[PROMPT]").take(&mut args);
assert!(check_unconsumed_args(&args).is_ok());
}
#[test]
fn append_stdin_aux_wraps_content() {
let out = append_stdin_aux("summarise".to_string(), "the quick brown fox");
assert_eq!(
out,
"summarise\n\n--- stdin ---\nthe quick brown fox\n--- end stdin ---"
);
}
}