agent-manager v0.2.0 : gestionnaire d'agents IA locaux
- CLI Rust (am) : list/install/uninstall/update/start/stop/restart/run/status/search/info/config/doctor/completion/export/import/self-update + shell interactif (Tab, historique) - 9 methodes d'installation (npm, pip, uv, cargo, go, bun, curl, binary, git) - catalogue de 72 agents, dependances, processus, securite (dry-run, checksums) - sondes de version paralleles + cache (am list ~25 ms a chaud) - installateurs one-liner : install.sh, install.ps1
This commit is contained in:
+273
@@ -0,0 +1,273 @@
|
||||
//! Console output helpers: colored logging, plain text tables, JSON output.
|
||||
|
||||
use colored::Colorize;
|
||||
use serde::Serialize;
|
||||
use std::io::{IsTerminal, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Name of the global log file inside the log directory.
|
||||
pub const LOG_FILE: &str = "agent-manager.log";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Logger
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Structured logger: writes every message to the timestamped log file and
|
||||
/// echoes the important ones to stderr (stdout stays reserved for data).
|
||||
#[derive(Debug)]
|
||||
pub struct Logger {
|
||||
log_dir: PathBuf,
|
||||
pub verbose: bool,
|
||||
pub quiet: bool,
|
||||
pub no_color: bool,
|
||||
}
|
||||
|
||||
impl Logger {
|
||||
pub fn new(log_dir: PathBuf, verbose: bool, quiet: bool, no_color: bool) -> Self {
|
||||
Self {
|
||||
log_dir,
|
||||
verbose,
|
||||
quiet,
|
||||
no_color,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn log_dir(&self) -> &PathBuf {
|
||||
&self.log_dir
|
||||
}
|
||||
|
||||
pub fn color_enabled(&self) -> bool {
|
||||
!self.no_color && std::io::stderr().is_terminal()
|
||||
}
|
||||
|
||||
fn write_log(&self, line: &str) {
|
||||
if std::fs::create_dir_all(&self.log_dir).is_err() {
|
||||
return;
|
||||
}
|
||||
let path = self.log_dir.join(LOG_FILE);
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
{
|
||||
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
let _ = writeln!(f, "[{ts}] {line}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Detailed message, shown only in verbose mode (always written to the log).
|
||||
pub fn verbose<S: AsRef<str>>(&self, msg: S) {
|
||||
let msg = msg.as_ref();
|
||||
self.write_log(msg);
|
||||
if self.verbose && !self.quiet {
|
||||
if self.color_enabled() {
|
||||
eprintln!("{}", msg.dimmed());
|
||||
} else {
|
||||
eprintln!("{msg}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Normal informational message.
|
||||
pub fn info<S: AsRef<str>>(&self, msg: S) {
|
||||
let msg = msg.as_ref();
|
||||
self.write_log(msg);
|
||||
if !self.quiet {
|
||||
eprintln!("{msg}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Success message (green).
|
||||
pub fn success<S: AsRef<str>>(&self, msg: S) {
|
||||
let msg = msg.as_ref();
|
||||
self.write_log(msg);
|
||||
if !self.quiet {
|
||||
if self.color_enabled() {
|
||||
eprintln!("{}", msg.green());
|
||||
} else {
|
||||
eprintln!("{msg}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Warning message (yellow).
|
||||
pub fn warn<S: AsRef<str>>(&self, msg: S) {
|
||||
let msg = msg.as_ref();
|
||||
self.write_log(msg);
|
||||
if !self.quiet {
|
||||
if self.color_enabled() {
|
||||
eprintln!("{}", msg.yellow());
|
||||
} else {
|
||||
eprintln!("{msg}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Error message (red); always shown, even in quiet mode.
|
||||
pub fn error<S: AsRef<str>>(&self, msg: S) {
|
||||
let msg = msg.as_ref();
|
||||
self.write_log(msg);
|
||||
if self.color_enabled() {
|
||||
eprintln!("{}", msg.red());
|
||||
} else {
|
||||
eprintln!("{msg}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Dry-run note (yellow prefix).
|
||||
pub fn dry<S: AsRef<str>>(&self, msg: S) {
|
||||
let msg = msg.as_ref();
|
||||
self.write_log(msg);
|
||||
if !self.quiet {
|
||||
if self.color_enabled() {
|
||||
eprintln!("{} {msg}", "[dry-run]".yellow().bold());
|
||||
} else {
|
||||
eprintln!("[dry-run] {msg}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Record an executed command (log file; echoed when verbose).
|
||||
pub fn cmd(&self, display: &str) {
|
||||
self.write_log(&format!("exec: {display}"));
|
||||
if self.verbose && !self.quiet {
|
||||
eprintln!(" {}", display.dimmed());
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a command result.
|
||||
pub fn result(&self, code: i32, stderr: &str) {
|
||||
if code != 0 {
|
||||
self.write_log(&format!("exit code {code}; stderr: {}", stderr.trim()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tables
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A minimal plain-text table. Cells must be pre-colored (widths are computed
|
||||
/// on ANSI-stripped text).
|
||||
pub struct Table {
|
||||
headers: Vec<String>,
|
||||
rows: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
const MAX_CELL: usize = 48;
|
||||
|
||||
impl Table {
|
||||
pub fn new(headers: Vec<&str>) -> Self {
|
||||
Self {
|
||||
headers: headers.into_iter().map(|h| h.to_string()).collect(),
|
||||
rows: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn row(&mut self, cells: Vec<String>) -> &mut Self {
|
||||
self.rows.push(cells);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn render(&self) -> String {
|
||||
if self.headers.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let cols = self.headers.len();
|
||||
let mut widths: Vec<usize> = self
|
||||
.headers
|
||||
.iter()
|
||||
.map(|h| visible_len(h).min(MAX_CELL))
|
||||
.collect();
|
||||
for row in &self.rows {
|
||||
for (i, cell) in row.iter().enumerate().take(cols) {
|
||||
widths[i] = widths[i].max(visible_len(cell).min(MAX_CELL));
|
||||
}
|
||||
}
|
||||
let mut out = String::new();
|
||||
let mut header_line = String::new();
|
||||
for (i, h) in self.headers.iter().enumerate() {
|
||||
if i > 0 {
|
||||
header_line.push_str(" ");
|
||||
}
|
||||
header_line.push_str(&pad(h, widths[i]));
|
||||
}
|
||||
let header_line = header_line.trim_end().to_string();
|
||||
out.push_str(&format!("{}
|
||||
", header_line.bold()));
|
||||
out.push_str(&"-".repeat(visible_len(&header_line)));
|
||||
out.push('\n');
|
||||
for row in &self.rows {
|
||||
let mut l = String::new();
|
||||
for i in 0..cols {
|
||||
if i > 0 {
|
||||
l.push_str(" ");
|
||||
}
|
||||
let cell = row.get(i).cloned().unwrap_or_default();
|
||||
l.push_str(&pad(&cell, widths[i]));
|
||||
}
|
||||
out.push_str(l.trim_end());
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Color a status cell for display in tables.
|
||||
pub fn status_style(status: &str, color: bool) -> String {
|
||||
let s = status.to_string();
|
||||
if !color {
|
||||
return s;
|
||||
}
|
||||
match status {
|
||||
"running" => s.green().bold().to_string(),
|
||||
"installed" => s.cyan().to_string(),
|
||||
"external" => s.yellow().to_string(),
|
||||
"not-installable" => s.red().to_string(),
|
||||
"not-installed" => s.dimmed().to_string(),
|
||||
"ok" => s.green().to_string(),
|
||||
"warn" => s.yellow().to_string(),
|
||||
"error" => s.red().bold().to_string(),
|
||||
_ => s,
|
||||
}
|
||||
}
|
||||
|
||||
/// Print a value as pretty JSON on stdout (for scripts).
|
||||
pub fn print_json<T: Serialize>(value: &T) {
|
||||
if serde_json::to_writer_pretty(std::io::stdout(), value).is_ok() {
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_ansi(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut chars = s.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '' {
|
||||
for n in chars.by_ref() {
|
||||
if n == 'm' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn visible_len(s: &str) -> usize {
|
||||
strip_ansi(s).chars().count()
|
||||
}
|
||||
|
||||
fn pad(s: &str, width: usize) -> String {
|
||||
let n = visible_len(s);
|
||||
if n > width {
|
||||
let plain = strip_ansi(s);
|
||||
let mut cut: String = plain.chars().take(width.saturating_sub(1)).collect();
|
||||
cut.push('…');
|
||||
cut
|
||||
} else {
|
||||
format!("{s}{}", " ".repeat(width - n))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user