v0.4.1 : Phase 1 complete (17/17) - dashboard TUI (#33), favoris/notes/tags (#39), profils d'environnement (#40), completions dynamiques + man pages (#41), packaging officiel + CI (#42), self-update Gitea, state v3, 212 tests
release / linux (push) Failing after 23s
release / windows (push) Canceled after 0s
release / macos (push) Canceled after 0s

This commit is contained in:
2026-08-17 10:50:13 -04:00
parent 4f4cd034b2
commit 56732d672b
93 changed files with 9412 additions and 1270 deletions
+37
View File
@@ -0,0 +1,37 @@
//! _agents (hidden): list agent names for shell completions (issue #41).
//! Prints one name per line; --installed restricts to what can actually be
//! launched: installed agents (managed + external), aliases and groups.
use super::*;
/// The completion pool: installed agents (managed + external), aliases and
/// groups when installed is true, the whole catalog otherwise.
pub fn agent_names(app: &App, installed: bool) -> Vec<String> {
let mut names: Vec<String> = Vec::new();
if installed {
let probe = crate::probe::detect_externals(app);
for a in app.catalog.agents() {
if app.state.get(&a.name).ok().flatten().is_some()
|| probe.path_of(&a.name).is_some()
{
names.push(a.name.clone());
}
}
names.extend(app.config.aliases.keys().cloned());
for g in app.config.groups.keys() {
names.push(format!("group:{g}"));
}
} else {
names.extend(app.catalog.agents().iter().map(|a| a.name.clone()));
}
names.sort();
names.dedup();
names
}
pub fn run(app: &App, installed: bool) -> Result<i32> {
for n in agent_names(app, installed) {
println!("{n}");
}
Ok(0)
}
+209
View File
@@ -0,0 +1,209 @@
//! favorite / unfavorite / note / tag / untag / tags: personal
//! annotations attached to agents (issue #39). Persisted in state.json
//! (additive since state v3) and traced in the event journal.
use super::*;
use crate::events::{Event, EventKind};
use crate::output::{boxed_table, print_json};
use crate::state::Annotation;
use anyhow::bail;
use serde::Serialize;
#[derive(Serialize)]
struct AnnotationJson {
agent: String,
favorite: bool,
note: Option<String>,
tags: Vec<String>,
}
/// Save the annotation block, emitting a journal event.
fn save_annotation(app: &App, def: &AgentDef, ann: &Annotation, detail: &str) -> Result<()> {
app.state.set_annotation(&def.name, ann)?;
app.emit(
&Event::now(EventKind::Annotate)
.with_agent(&def.name)
.with_reason(detail),
);
Ok(())
}
pub fn run_favorite(app: &App, agent: &str) -> Result<i32> {
let def = require_agent(app, agent)?;
let mut ann = app.state.annotation(&def.name)?;
if ann.favorite {
app.log.info(&format!("'{}' is already a favorite", def.name));
return Ok(0);
}
ann.favorite = true;
save_annotation(app, def, &ann, "favorite")?;
app.log.success(&format!("⭐ {} added to favorites", def.name));
Ok(0)
}
pub fn run_unfavorite(app: &App, agent: &str) -> Result<i32> {
let def = require_agent(app, agent)?;
let mut ann = app.state.annotation(&def.name)?;
if !ann.favorite {
app.log.info(&format!("'{}' is not a favorite", def.name));
return Ok(0);
}
ann.favorite = false;
save_annotation(app, def, &ann, "unfavorite")?;
app.log.success(&format!("★ {} removed from favorites", def.name));
Ok(0)
}
pub fn run_note(app: &App, agent: &str, text: &[String]) -> Result<i32> {
let def = require_agent(app, agent)?;
let mut ann = app.state.annotation(&def.name)?;
if text.is_empty() {
match &ann.note {
Some(n) => {
println!("{}", n);
Ok(0)
}
None => {
app.log.info(&format!("no note for '{}' — 'am note {} <text>' to set one", def.name, def.name));
Ok(0)
}
}
} else {
let note = text.join(" ");
ann.note = Some(note.clone());
save_annotation(app, def, &ann, "note")?;
app.log.success(&format!("note saved for '{}'", def.name));
Ok(0)
}
}
pub fn run_tag(app: &App, agent: &str, words: &[String]) -> Result<i32> {
if words.is_empty() {
bail!("usage: am tag <agent> <tag>... — at least one tag is required");
}
let def = require_agent(app, agent)?;
let mut ann = app.state.annotation(&def.name)?;
let mut added = Vec::new();
for w in words {
let tag = w.trim().trim_start_matches('#').to_lowercase();
if tag.is_empty() {
continue;
}
if !ann.tags.contains(&tag) {
ann.tags.push(tag.clone());
added.push(tag);
}
}
if added.is_empty() {
app.log.info(&format!("'{}' already carries those tags", def.name));
return Ok(0);
}
ann.tags.sort();
save_annotation(app, def, &ann, &format!("tags +{}", added.join(",")))?;
app.log.success(&format!(
"tagged '{}' with {}",
def.name,
added.join(", ")
));
Ok(0)
}
pub fn run_untag(app: &App, agent: &str, tag: &str) -> Result<i32> {
let def = require_agent(app, agent)?;
let mut ann = app.state.annotation(&def.name)?;
let needle = tag.trim().trim_start_matches('#').to_lowercase();
let before = ann.tags.len();
ann.tags.retain(|t| t != &needle);
if ann.tags.len() == before {
app.log.info(&format!("'{}' has no tag '{needle}'", def.name));
return Ok(0);
}
save_annotation(app, def, &ann, &format!("tags -{needle}"))?;
app.log.success(&format!("removed tag '{needle}' from '{}'", def.name));
Ok(0)
}
pub fn run_tags(app: &App, agent: Option<&str>) -> Result<i32> {
let all = app.state.annotations()?;
if let Some(name_or_alias) = agent {
let def = require_agent(app, name_or_alias)?;
let ann = app.state.annotation(&def.name)?;
if app.json() {
print_json(&AnnotationJson {
agent: def.name.clone(),
favorite: ann.favorite,
note: ann.note.clone(),
tags: ann.tags.clone(),
});
return Ok(0);
}
if ann.tags.is_empty() {
app.log.info(&format!("no personal tags for '{}'", def.name));
} else {
println!("{}", ann.tags.join(" · "));
}
return Ok(0);
}
// Global view: tag -> agents (sorted by tag).
let mut by_tag: BTreeMap<String, Vec<String>> = BTreeMap::new();
for (name, ann) in &all {
for t in &ann.tags {
by_tag.entry(t.clone()).or_default().push(name.clone());
}
}
if app.json() {
let rows: Vec<serde_json::Value> = by_tag
.iter()
.map(|(tag, agents)| {
serde_json::json!({ "tag": tag, "count": agents.len(), "agents": agents })
})
.collect();
print_json(&rows);
return Ok(0);
}
if by_tag.is_empty() {
app.log.info("no personal tags yet — 'am tag <agent> <word>' to add one");
return Ok(0);
}
let mut table_rows = Vec::new();
for (tag, agents) in &by_tag {
table_rows.push(vec![
format!("#{tag}"),
agents.len().to_string(),
agents.join(", "),
]);
}
print!(
"{}",
boxed_table(
vec!["TAG", "COUNT", "AGENTS"],
table_rows,
app.theme(),
app.color(),
)
);
Ok(0)
}
/// Is 'agent' a favorite? (used by list/status to print the star)
pub fn is_favorite(app: &App, agent: &str) -> bool {
app.state.annotation(agent).map(|a| a.favorite).unwrap_or(false)
}
/// Personal tags of 'agent' (used by search --tag and info).
pub fn tags_of(app: &App, agent: &str) -> Vec<String> {
app.state.annotation(agent).map(|a| a.tags).unwrap_or_default()
}
/// Agent names whose annotations match a personal tag (issue #39).
pub fn agents_with_tag(app: &App, tag: &str) -> Vec<String> {
let needle = tag.trim().trim_start_matches('#').to_lowercase();
app.state
.annotations()
.unwrap_or_default()
.into_iter()
.filter(|(_, a)| a.tags.iter().any(|t| t == &needle))
.map(|(name, _)| name)
.collect()
}
+142 -3
View File
@@ -1,13 +1,152 @@
//! completion: generate shell completion scripts (bash, zsh, fish,
//! powershell, elvish) via clap_complete.
//! powershell, elvish) via clap_complete. With --installed, a dynamic
//! layer is appended: Tab then queries 'am _agents --installed' at runtime,
//! so only installed agents, aliases and groups are proposed (issue #41).
use crate::cli::Cli;
use anyhow::Result;
use clap::CommandFactory;
use clap_complete::{generate, Shell};
use std::io::Write;
pub fn run(shell: Shell) -> Result<i32> {
pub fn run(shell: Shell, installed: bool) -> Result<i32> {
let mut cmd = Cli::command();
generate(shell, &mut cmd, "am", &mut std::io::stdout());
let mut buf: Vec<u8> = Vec::new();
generate(shell, &mut cmd, "am", &mut buf);
if installed {
let mut text = String::from_utf8_lossy(&buf).to_string();
match shell {
Shell::PowerShell => {
// Re-bind the base completer to a variable so the wrapper
// can delegate to it.
let marker = "Register-ArgumentCompleter -Native -CommandName 'am' -ScriptBlock {";
if let Some(pos) = text.find(marker) {
text.replace_range(pos..pos + marker.len(), "$amBase = {");
text.push('\n');
text.push_str(powershell_snippet());
}
}
_ => {
text.push('\n');
text.push_str(snippet(shell));
}
}
std::io::stdout().write_all(text.as_bytes())?;
} else {
std::io::stdout().write_all(&buf)?;
}
Ok(0)
}
pub fn snippet(shell: Shell) -> &'static str {
match shell {
Shell::Bash => bash_snippet(),
Shell::Zsh => zsh_snippet(),
Shell::Fish => fish_snippet(),
Shell::Elvish => elvish_snippet(),
Shell::PowerShell => powershell_snippet(),
_ => "",
}
}
pub fn bash_snippet() -> &'static str {
r#"# --- dynamic completion of installed agents, aliases and groups (--installed) ---
_am_installed() {
local cur cmd
cur="${COMP_WORDS[COMP_CWORD]}"
cmd="${COMP_WORDS[1]}"
case "${cmd}" in
start|stop|restart|run|info|logs|open|install|uninstall|update|watch|favorite|unfavorite|note|tag|untag|status|sessions|stats|log)
if [[ ${COMP_CWORD} -eq 2 ]]; then
COMPREPLY=( $(compgen -W "$(am _agents --installed 2>/dev/null)" -- "${cur}") )
return 0
fi
;;
esac
_am "$@"
}
complete -F _am_installed -o nosort -o bashdefault -o default am
"#
}
pub fn zsh_snippet() -> &'static str {
r#"# --- dynamic completion of installed agents, aliases and groups (--installed) ---
_am_installed() {
local -a agents
if [[ ${CURRENT} -eq 3 ]] && [[ ${words[2]} =~ ^(start|stop|restart|run|info|logs|open|install|uninstall|update|watch|favorite|unfavorite|note|tag|untag|status|sessions|stats|log)$ ]]; then
agents=("${(@f)$(am _agents --installed 2>/dev/null)}")
_describe -t agents 'agent' agents
return
fi
_am "$@"
}
compdef _am_installed am
"#
}
pub fn fish_snippet() -> &'static str {
r#"# --- dynamic completion of installed agents, aliases and groups (--installed) ---
complete -c am -n "__fish_seen_subcommand_from start stop restart run info logs open install uninstall update watch favorite unfavorite note tag untag status sessions stats log" -f -a "(am _agents --installed 2>/dev/null)"
"#
}
pub fn elvish_snippet() -> &'static str {
r#"# --- dynamic completion of installed agents, aliases and groups (--installed) ---
var am-base = $edit:completion:arg-completer[am]
set edit:completion:arg-completer[am] = {|@words|
if (and (>= (count $words) 2) (<= (count $words) 3) (has-value [start stop restart run info logs open install uninstall update watch favorite unfavorite note tag untag status sessions stats log] $words[1])) {
put $@(am _agents --installed)
} else {
$am-base $@words
}
}
"#
}
pub fn powershell_snippet() -> &'static str {
r#"
# --- dynamic completion of installed agents, aliases and groups (--installed) ---
Register-ArgumentCompleter -Native -CommandName 'am' -ScriptBlock {
param($wordToComplete, $commandAst, $cursorPosition)
$tokens = $commandAst.CommandElements | ForEach-Object { $_.Extent.Text }
$agentCommands = @('start','stop','restart','run','info','logs','open','install','uninstall','update','watch','favorite','unfavorite','note','tag','untag','status','sessions','stats','log')
if ($tokens.Count -ge 2 -and $agentCommands -contains $tokens[1]) {
(am _agents --installed 2>$null) | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
}
return
}
& $amBase $wordToComplete $commandAst $cursorPosition
}
"#
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dynamic_snippets_are_produced_for_every_shell() {
for shell in [
Shell::Bash,
Shell::Zsh,
Shell::Fish,
Shell::Elvish,
Shell::PowerShell,
] {
let text = snippet(shell);
assert!(text.contains("_agents --installed"), "{shell:?}");
assert!(text.contains("favorite"), "{shell:?}");
}
}
#[test]
fn base_script_has_no_dynamic_layer() {
let mut cmd = Cli::command();
let mut buf: Vec<u8> = Vec::new();
generate(Shell::Bash, &mut cmd, "am", &mut buf);
let base = String::from_utf8(buf).unwrap();
assert!(!base.contains("_am_installed"));
assert!(base.contains("complete -F _am "));
}
}
+379
View File
@@ -0,0 +1,379 @@
//! dashboard: live TUI over the whole fleet (issue #33) — five tabs
//! (overview, activity, stats, sessions, projects), 1 s refresh, keyboard
//! navigation, clean exit. When stdout is not a terminal (or --json), a
//! plain snapshot is printed instead of entering the TUI.
use super::*;
use crate::dashboard::{collect, ActivityRow, DashData, OverviewRow, SessionRow, StatsRow};
use crate::projects::ProjectSummary;
use crate::output::print_json;
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Tabs};
use ratatui::{DefaultTerminal, Frame};
use std::io::IsTerminal;
use std::time::Duration;
pub const TABS: [&str; 5] = ["1 overview", "2 activity", "3 stats", "4 sessions", "5 projects"];
struct Ui {
tab: usize,
scroll: [usize; 5],
}
pub fn run(app: &App) -> Result<i32> {
if app.json() {
print_json(&collect(app));
return Ok(0);
}
if !std::io::stdout().is_terminal() {
print_snapshot(app)?;
return Ok(0);
}
let mut terminal = ratatui::init();
let result = run_loop(app, &mut terminal);
ratatui::restore();
// Leave the prompt on a fresh line when launched from the REPL.
println!();
result
}
fn run_loop(app: &App, terminal: &mut DefaultTerminal) -> Result<i32> {
let mut ui = Ui { tab: 0, scroll: [0; 5] };
let mut data = collect(app);
loop {
terminal.draw(|f| draw(f, &data, &ui))?;
if !event::poll(Duration::from_secs(1))? {
// 1 s refresh: repaint with fresh data.
data = collect(app);
continue;
}
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
KeyCode::Char('q') | KeyCode::Esc => return Ok(0),
KeyCode::Char('h') | KeyCode::Left => ui.tab = ui.tab.saturating_sub(1),
KeyCode::Char('l') | KeyCode::Right | KeyCode::Tab => {
ui.tab = (ui.tab + 1) % TABS.len()
}
KeyCode::Char(c) if c.is_ascii_digit() && c != '0' => {
ui.tab = (c as usize - '1' as usize).min(TABS.len() - 1);
}
KeyCode::Char('k') | KeyCode::Up => {
ui.scroll[ui.tab] = ui.scroll[ui.tab].saturating_sub(1)
}
KeyCode::Char('j') | KeyCode::Down => ui.scroll[ui.tab] += 1,
KeyCode::PageUp => ui.scroll[ui.tab] = ui.scroll[ui.tab].saturating_sub(10),
KeyCode::PageDown => ui.scroll[ui.tab] += 10,
KeyCode::Home => ui.scroll[ui.tab] = 0,
KeyCode::Char('r') => data = collect(app),
_ => {}
},
Event::Resize(_, _) => data = collect(app),
_ => {}
}
}
}
fn draw(f: &mut Frame, data: &DashData, ui: &Ui) {
let [header, body] =
Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).areas(f.area());
let counts = &data.counts;
let title = Line::from(vec![
Span::styled(
" agent-manager ",
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(format!(
" {} installed · {} running · {} sessions{}",
counts.installed,
counts.running,
counts.sessions_count,
counts
.last_used
.as_deref()
.map(|t| format!(" · last used {t}"))
.unwrap_or_default(),
)),
]);
let tabs = Tabs::new(TABS)
.block(
Block::default()
.borders(Borders::ALL)
.title(title)
.title_alignment(Alignment::Left),
)
.select(ui.tab)
.highlight_style(
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
f.render_widget(tabs, header);
match ui.tab {
0 => draw_overview(f, data, ui, body),
1 => draw_activity(f, data, ui, body),
2 => draw_stats(f, data, ui, body),
3 => draw_sessions(f, data, ui, body),
_ => draw_projects(f, data, ui, body),
}
}
fn status_style(status: &str) -> Style {
match status {
"running" => Style::default().fg(Color::Green).add_modifier(Modifier::BOLD),
"installed" | "external" => Style::default().fg(Color::Cyan),
"failed" | "interrupted" => Style::default().fg(Color::Red),
"not-installed" | "not-installable" => Style::default().fg(Color::DarkGray),
_ => Style::default().fg(Color::Yellow),
}
}
fn block<'a>(title: &str, hint: &str, scroll: usize) -> Block<'a> {
Block::default()
.borders(Borders::ALL)
.title(format!(" {title} "))
.title_bottom(format!(" {hint} · scroll {scroll} "))
.title_alignment(Alignment::Left)
}
fn fmt_duration(secs: u64) -> String {
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
format!("{}m{:02}s", secs / 60, secs % 60)
} else if secs < 86400 {
format!("{}h{:02}m", secs / 3600, (secs % 3600) / 60)
} else {
format!("{}d{:02}h", secs / 86400, (secs % 86400) / 3600)
}
}
fn overview_line(r: &OverviewRow) -> Line<'static> {
let star = if r.favorite { "★ " } else { " " };
let status = r.status.as_str();
let version = r.version.as_deref().unwrap_or("—");
let uptime = r
.uptime_s
.map(|s| format!("up {}", fmt_duration(s)))
.unwrap_or_default();
let pid = r
.pid
.map(|p| format!("pid {p}"))
.unwrap_or_default();
Line::from(vec![
Span::styled(format!("{star}{:<22}", r.name), Style::default().add_modifier(Modifier::BOLD)),
Span::styled(format!("{:<16}", status), status_style(status)),
Span::raw(format!("{:<12} ", version)),
Span::raw(format!("{pid:<10} {uptime}")),
])
}
fn draw_overview(f: &mut Frame, data: &DashData, ui: &Ui, area: Rect) {
let mut lines: Vec<Line> = data
.overview
.iter()
.map(overview_line)
.collect();
if lines.is_empty() {
lines.push(Line::from("no agents in the catalog"));
}
let p = Paragraph::new(lines)
.block(block("overview", "j/k scroll · q quit", ui.scroll[0]))
.scroll((ui.scroll[0] as u16, 0));
f.render_widget(p, area);
}
fn activity_line(r: &ActivityRow) -> Line<'static> {
Line::from(vec![
Span::styled(format!("{:<21} ", r.ts), Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{:<10} ", r.kind),
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
),
Span::styled(format!("{:<22} ", r.agent.as_deref().unwrap_or("-")), Style::default().fg(Color::Yellow)),
Span::raw(r.detail.clone()),
])
}
fn draw_activity(f: &mut Frame, data: &DashData, ui: &Ui, area: Rect) {
let lines: Vec<Line> = data.activity.iter().map(activity_line).collect();
let p = Paragraph::new(if lines.is_empty() {
vec![Line::from("the event journal is empty — every am action will appear here")]
} else {
lines
})
.block(block("activity — event journal", "j/k scroll · q quit", ui.scroll[1]))
.scroll((ui.scroll[1] as u16, 0));
f.render_widget(p, area);
}
fn stats_line(r: &StatsRow) -> Line<'static> {
let rate = if r.launches > 0 {
format!("{:>6.0}% ok", 100.0 * (r.launches.saturating_sub(r.failures)) as f64 / r.launches as f64)
} else {
" —".to_string()
};
Line::from(vec![
Span::styled(format!("{:<22}", r.agent), Style::default().add_modifier(Modifier::BOLD)),
Span::raw(format!("{:>8} launches ", r.launches)),
Span::raw(format!("{:>8} failures ", r.failures)),
Span::raw(format!("{:>10} ", fmt_duration(r.total_duration_s))),
Span::styled(rate, status_style(if r.failures > 0 { "failed" } else { "running" })),
])
}
fn draw_stats(f: &mut Frame, data: &DashData, ui: &Ui, area: Rect) {
let lines: Vec<Line> = data.stats.iter().map(stats_line).collect();
let p = Paragraph::new(if lines.is_empty() {
vec![Line::from("no usage data yet — start an agent to fill the journal")]
} else {
lines
})
.block(block("stats — per agent", "j/k scroll · q quit", ui.scroll[2]))
.scroll((ui.scroll[2] as u16, 0));
f.render_widget(p, area);
}
fn session_line(r: &SessionRow) -> Line<'static> {
let dur = r
.duration_s
.map(fmt_duration)
.unwrap_or_else(|| "running…".to_string());
Line::from(vec![
Span::styled(format!("{:<18} ", r.id), Style::default().fg(Color::Yellow)),
Span::styled(format!("{:<11} ", r.status), status_style(&r.status)),
Span::styled(
format!("{:<22} ", r.agent.as_deref().unwrap_or("repl")),
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(format!(
"{:<26} ",
r.project.as_deref().unwrap_or("-")
)),
Span::styled(format!("{:<10} ", r.started_at), Style::default().fg(Color::DarkGray)),
Span::raw(dur),
])
}
fn draw_sessions(f: &mut Frame, data: &DashData, ui: &Ui, area: Rect) {
let lines: Vec<Line> = data.sessions.iter().map(session_line).collect();
let p = Paragraph::new(if lines.is_empty() {
vec![Line::from("no sessions recorded yet")]
} else {
lines
})
.block(block("sessions", "j/k scroll · q quit", ui.scroll[3]))
.scroll((ui.scroll[3] as u16, 0));
f.render_widget(p, area);
}
fn project_line(r: &ProjectSummary) -> Line<'static> {
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(Span::styled(
format!("{:<28} ", r.root),
Style::default().add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(
format!("{:<22} ", r.dominant.as_deref().unwrap_or("-")),
Style::default().fg(Color::Cyan),
));
spans.push(Span::raw(format!(
"{:>4} sessions {:>5} launches {:>10} ",
r.sessions, r.launches, fmt_duration(r.total_duration_s)
)));
spans.push(Span::styled(
r.last_activity.as_deref().unwrap_or("-").to_string(),
Style::default().fg(Color::DarkGray),
));
Line::from(spans)
}
fn draw_projects(f: &mut Frame, data: &DashData, ui: &Ui, area: Rect) {
let lines: Vec<Line> = data.projects.iter().map(project_line).collect();
let p = Paragraph::new(if lines.is_empty() {
vec![Line::from("no project activity recorded yet")]
} else {
lines
})
.block(block("projects", "j/k scroll · q quit", ui.scroll[4]))
.scroll((ui.scroll[4] as u16, 0));
f.render_widget(p, area);
}
/// Plain-text snapshot (piped stdout): same data, no TUI.
fn print_snapshot(app: &App) -> Result<i32> {
let data = collect(app);
let c = &data.counts;
println!(
"agent-manager dashboard — {} installed · {} running · {} sessions",
c.installed, c.running, c.sessions_count
);
println!();
println!("overview");
for r in &data.overview {
let star = if r.favorite { "★ " } else { " " };
let uptime = r
.uptime_s
.map(|s| format!(" (up {})", fmt_duration(s)))
.unwrap_or_default();
println!(
" {star}{:<22} {:<16} {:<12} {}{}",
r.name,
r.status,
r.version.as_deref().unwrap_or("—"),
r.pid.map(|p| format!("pid {p}")).unwrap_or_default(),
uptime,
);
}
println!();
println!("activity (last {})", data.activity.len());
for r in data.activity.iter().take(20) {
println!(
" {:<21} {:<10} {:<22} {}",
r.ts,
r.kind,
r.agent.as_deref().unwrap_or("-"),
r.detail
);
}
println!();
println!("stats");
for r in &data.stats {
println!(
" {:<22} {:>8} launches {:>8} failures {:>10}",
r.agent,
r.launches,
r.failures,
fmt_duration(r.total_duration_s),
);
}
println!();
println!("sessions");
for r in data.sessions.iter().take(20) {
println!(
" {:<18} {:<11} {:<22} {}",
r.id,
r.status,
r.agent.as_deref().unwrap_or("repl"),
r.duration_s.map(fmt_duration).unwrap_or_else(|| "running…".to_string()),
);
}
println!();
println!("projects");
for r in &data.projects {
println!(
" {:<28} {:<22} {} sessions {} launches",
r.root,
r.dominant.as_deref().unwrap_or("-"),
r.sessions,
r.launches,
);
}
Ok(0)
}
+1
View File
@@ -78,6 +78,7 @@ pub fn import_cmd(app: &App, file: &Path) -> Result<i32> {
include: vec![],
agents: export.config.agents.clone(),
projects: Default::default(),
profiles: export.config.profiles.clone(),
};
std::fs::write(&dest, serde_yaml::to_string(&cfg)?)
.with_context(|| format!("cannot write {}", dest.display()))?;
+48
View File
@@ -20,6 +20,12 @@ struct InfoJson {
pid: Option<u32>,
installable: bool,
note: Option<String>,
/// Personal note (issue #39).
personal_note: Option<String>,
/// Personal tags (issue #39).
personal_tags: Vec<String>,
/// Favorite (issue #39).
favorite: bool,
platforms: Vec<String>,
run: Option<String>,
args: Vec<String>,
@@ -83,6 +89,9 @@ pub fn run(app: &App, name: &str) -> Result<i32> {
pid: if running { entry.as_ref().and_then(|e| e.pid) } else { None },
installable: agent.installable,
note: agent.note.clone(),
personal_note: annotations_note(app, &agent.name),
personal_tags: annotations_tags(app, &agent.name),
favorite: annotations_favorite(app, &agent.name),
platforms: agent.platforms.clone(),
run: agent.run.clone(),
args: agent.args.clone(),
@@ -274,9 +283,48 @@ pub fn run(app: &App, name: &str) -> Result<i32> {
println!();
println!(" {}", dim(&format!("note {n}")));
}
// --- Personal annotations (issue #39) -------------------------------------
let p_note = annotations_note(app, &agent.name);
let p_tags = annotations_tags(app, &agent.name);
let p_fav = annotations_favorite(app, &agent.name);
if p_fav || p_tags.iter().any(|t| !agent.tags.contains(t)) || p_note.is_some() {
println!();
println!(" {}", bold("personal"));
if p_fav {
println!(" {} {}", dim("● favorite"), "★");
}
if !p_tags.is_empty() {
println!(
" {} {}",
dim("● tags"),
p_tags
.iter()
.map(|t| format!("#{t}"))
.collect::<Vec<_>>()
.join(" ")
);
}
if let Some(n) = &p_note {
println!(" {} {}", dim("● note"), n);
}
}
Ok(0)
}
// Personal annotations helpers (issue #39) — never fail.
fn annotations_note(app: &App, name: &str) -> Option<String> {
app.state.annotation(name).ok().and_then(|a| a.note)
}
fn annotations_tags(app: &App, name: &str) -> Vec<String> {
app.state.annotation(name).map(|a| a.tags).unwrap_or_default()
}
fn annotations_favorite(app: &App, name: &str) -> bool {
app.state
.annotation(name)
.map(|a| a.favorite)
.unwrap_or(false)
}
fn external_path(agent: &AgentDef) -> Option<std::path::PathBuf> {
let first = agent.first_token()?;
which::which(first).ok()
+8 -3
View File
@@ -22,6 +22,8 @@ struct AgentRow {
pid: Option<u32>,
installable: bool,
note: Option<String>,
/// Personal favorite (issue #39).
favorite: bool,
}
/// run: installed agents only, unless all is set (whole catalog).
@@ -44,7 +46,7 @@ pub fn run(
continue;
}
}
rows.push(row_for(agent, entries.get(&agent.name), &probe));
rows.push(row_for(app, agent, entries.get(&agent.name), &probe));
}
if !all {
rows.retain(|r| r.status == "running" || r.status == "installed" || r.status == "external");
@@ -67,8 +69,10 @@ pub fn run(
let theme = app.theme();
let mut table_rows = Vec::new();
for r in &rows {
// Favorite star (issue #39).
let star = if r.favorite { "★ " } else { "" };
table_rows.push(vec![
r.display_name.clone(),
format!("{star}{}", r.display_name),
r.name.clone(),
r.version.clone().unwrap_or_else(|| "—".to_string()),
status_style(&r.status, theme, color),
@@ -95,7 +99,7 @@ pub fn run(
Ok(0)
}
fn row_for(agent: &AgentDef, entry: Option<&InstalledEntry>, probe: &ExternalInfo) -> AgentRow {
fn row_for(app: &App, agent: &AgentDef, entry: Option<&InstalledEntry>, probe: &ExternalInfo) -> AgentRow {
let (status, source, path, version, running, pid, method) = if let Some(e) = entry {
let running = e.pid.map(crate::process::is_running).unwrap_or(false);
let status = if running { "running" } else { "installed" };
@@ -171,5 +175,6 @@ fn row_for(agent: &AgentDef, entry: Option<&InstalledEntry>, probe: &ExternalInf
pid,
installable: agent.installable,
note: agent.note.clone(),
favorite: crate::commands::annotations_cmd::is_favorite(app, &agent.name),
}
}
+70
View File
@@ -0,0 +1,70 @@
//! man: generate roff man pages from the clap definition (issue #41).
//! 'am man' prints am.1 for the whole CLI; 'am man <command>' prints one
//! page; '--output DIR' writes am.1 plus one am-<command>.1 per command.
use super::*;
use anyhow::Context;
use clap::CommandFactory;
use std::io::Write;
use std::path::Path;
pub fn run(app: &App, command: Option<&str>, output: Option<&Path>) -> Result<i32> {
let mut cmd = crate::cli::Cli::command();
if app.dry_run() {
app.log.dry("would generate man pages");
return Ok(0);
}
match command {
Some(name) => {
let sub = cmd.find_subcommand_mut(name).ok_or_else(|| {
anyhow!(
"unknown command '{name}' — 'am help commands' lists every command"
)
})?;
let man = clap_mangen::Man::new(sub.clone()).title(format!("am-{name}"));
match output {
Some(dir) => {
write_page(dir, &format!("am-{name}"), &man)?;
app.log.success(&format!(
"man page written to {}",
dir.join(format!("am-{name}.1")).display()
));
}
None => man.render(&mut std::io::stdout())?,
}
}
None => match output {
Some(dir) => {
let main = clap_mangen::Man::new(cmd.clone());
write_page(dir, "am", &main)?;
for sub in cmd.get_subcommands() {
if sub.is_hide_set() {
continue;
}
let name = format!("am-{}", sub.get_name());
let m = clap_mangen::Man::new(sub.clone()).title(name.clone());
write_page(dir, &name, &m)?;
}
app.log.success(&format!(
"man pages written to {} (am.1 + {} pages)",
dir.display(),
cmd.get_subcommands().count()
));
}
None => clap_mangen::Man::new(cmd).render(&mut std::io::stdout())?,
},
}
Ok(0)
}
fn write_page(dir: &Path, name: &str, man: &clap_mangen::Man) -> Result<()> {
std::fs::create_dir_all(dir)
.with_context(|| format!("cannot create {}", dir.display()))?;
let path = dir.join(format!("{name}.1"));
let mut file = std::fs::File::create(&path)
.with_context(|| format!("cannot create {}", path.display()))?;
man.render(&mut file)
.with_context(|| format!("cannot render {}", path.display()))?;
file.flush().ok();
Ok(())
}
+19 -2
View File
@@ -1,8 +1,11 @@
//! Command implementations and dispatch.
pub mod agents_cmd;
pub mod alias_cmd;
pub mod annotations_cmd;
pub mod completion_cmd;
pub mod config_cmd;
pub mod dashboard_cmd;
pub mod doctor_cmd;
pub mod export_import;
pub mod help_cmd;
@@ -13,7 +16,9 @@ pub mod install_cmd;
pub mod list_cmd;
pub mod log_cmd;
pub mod logs_cmd;
pub mod man_cmd;
pub mod open_cmd;
pub mod profile_cmd;
pub mod projects_cmd;
pub mod run_cmd;
pub mod search_cmd;
@@ -65,6 +70,16 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
notify,
interval,
} => watch_cmd::run(app, agent, *restart, *notify, *interval),
Command::Dashboard => dashboard_cmd::run(app),
Command::Favorite { agent } => annotations_cmd::run_favorite(app, agent),
Command::Unfavorite { agent } => annotations_cmd::run_unfavorite(app, agent),
Command::Note { agent, text } => annotations_cmd::run_note(app, agent, text),
Command::Tag { agent, words } => annotations_cmd::run_tag(app, agent, words),
Command::Untag { agent, tag } => annotations_cmd::run_untag(app, agent, tag),
Command::Tags { agent } => annotations_cmd::run_tags(app, agent.as_deref()),
Command::Profile(sub) => profile_cmd::run(app, sub),
Command::Man { command, output } => man_cmd::run(app, command.as_deref(), output.as_deref()),
Command::Agents { installed } => agents_cmd::run(app, *installed),
Command::Alias(sub) => alias_cmd::run(app, sub),
Command::Secret(sub) => secret_cmd::run(app, sub),
Command::Start(a) => run_cmd::start(app, a),
@@ -121,7 +136,9 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
} => install_cmd::run(app, agent, method.as_deref(), *force),
Command::Uninstall { agent, purge } => uninstall_cmd::run(app, agent, *purge),
Command::Update { agent, all } => update_cmd::run(app, agent.as_deref(), *all),
Command::Search { keyword, category } => search_cmd::run(app, keyword, category.as_deref()),
Command::Search { keyword, category, tag } => {
search_cmd::run(app, keyword.as_deref(), category.as_deref(), tag.as_deref())
}
Command::History {
kind,
cwd,
@@ -147,7 +164,7 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
Command::Config(sub) => config_cmd::run(app, sub),
Command::Doctor { fix } => doctor_cmd::run(app, *fix),
Command::Run { agent, args } => run_cmd::run(app, agent, args),
Command::Completion { shell } => completion_cmd::run(*shell),
Command::Completion { shell, installed } => completion_cmd::run(*shell, *installed),
Command::SelfUpdate { check, to } => self_update::run(app, *check, to.as_deref()),
Command::SelfUninstall => self_uninstall::run(app),
Command::Export { output } => export_import::export_cmd(app, output),
+214
View File
@@ -0,0 +1,214 @@
//! profile: environment profiles (issue #40) — a named set of
//! agent + env + args, defined in the config, applied with
//! 'am start <agent> --profile dev'.
use super::*;
use crate::cli::ProfileCmd;
use crate::config::Profile;
use crate::output::{boxed_table, print_json};
use serde::Serialize;
#[derive(Serialize)]
struct ProfileJson {
name: String,
agent: Option<String>,
env: BTreeMap<String, String>,
args: Vec<String>,
}
pub fn run(app: &App, cmd: &ProfileCmd) -> Result<i32> {
match cmd {
ProfileCmd::List => run_list(app),
ProfileCmd::Show { name } => run_show(app, name),
}
}
pub fn run_list(app: &App) -> Result<i32> {
if app.config.profiles.is_empty() {
app.log.info(
"no profiles defined — add a 'profiles:' section to your config, e.g.:\n\
profiles:\n\
\x20 dev:\n\
\x20 agent: claude-code\n\
\x20 env: {DEBUG: \"1\"}\n\
\x20 args: [--model, sonnet]",
);
return Ok(0);
}
let rows: Vec<ProfileJson> = app
.config
.profiles
.iter()
.map(|(name, p)| ProfileJson {
name: name.clone(),
agent: p.agent.clone(),
env: p.env.clone(),
args: p.args.clone(),
})
.collect();
if app.json() {
print_json(&rows);
return Ok(0);
}
let mut table_rows = Vec::new();
for r in &rows {
table_rows.push(vec![
r.name.clone(),
r.agent.clone().unwrap_or_else(|| "—".to_string()),
if r.env.is_empty() {
"—".to_string()
} else {
r.env
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join(" ")
},
if r.args.is_empty() {
"—".to_string()
} else {
r.args.join(" ")
},
]);
}
print!(
"{}",
boxed_table(
vec!["PROFILE", "AGENT", "ENV", "ARGS"],
table_rows,
app.theme(),
app.color(),
)
);
Ok(0)
}
pub fn run_show(app: &App, name: &str) -> Result<i32> {
let profile = resolve(app, name)?;
if app.json() {
print_json(&ProfileJson {
name: name.to_string(),
agent: profile.agent.clone(),
env: profile.env.clone(),
args: profile.args.clone(),
});
return Ok(0);
}
println!("{}", name);
if let Some(a) = &profile.agent {
println!(" agent {a}");
}
if profile.env.is_empty() {
println!(" env —");
} else {
for (k, v) in &profile.env {
println!(" env {k}={v}");
}
}
if profile.args.is_empty() {
println!(" args —");
} else {
println!(" args {}", profile.args.join(" "));
}
Ok(0)
}
/// Resolve a profile by name; unknown names fail with the list of profiles.
pub fn resolve<'a>(app: &'a App, name: &str) -> Result<&'a Profile> {
app.config.profiles.get(name).ok_or_else(|| {
let available = if app.config.profiles.is_empty() {
"no profile is defined in the config".to_string()
} else {
format!(
"available profiles: {}",
app.config.profiles.keys().cloned().collect::<Vec<_>>().join(", ")
)
};
anyhow!("unknown profile '{name}' — {available}")
})
}
/// Apply a profile to launch parameters: its args are appended and its env
/// entries override the base ones (the profile wins — it is the most
/// specific input at launch time).
pub fn apply(
app: &App,
name: Option<&str>,
base_args: &[String],
base_env: &BTreeMap<String, String>,
) -> Result<(Vec<String>, BTreeMap<String, String>)> {
let Some(name) = name else {
return Ok((base_args.to_vec(), base_env.clone()));
};
let profile = resolve(app, name)?;
let mut args = base_args.to_vec();
args.extend(profile.args.iter().cloned());
let mut env = base_env.clone();
env.extend(profile.env.clone());
Ok((args, env))
}
/// The default agent declared by a profile, if any.
pub fn default_agent<'a>(app: &'a App, name: Option<&str>) -> Result<Option<&'a str>> {
match name {
None => Ok(None),
Some(n) => Ok(resolve(app, n)?.agent.as_deref()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
fn cfg_with_profiles() -> App {
// The TempDir guard would delete the directory when this helper
// returns; leak it on purpose so the App keeps a live state dir.
let guard = tempfile::tempdir().unwrap();
let dir = guard.path().to_path_buf();
std::mem::forget(guard);
let cfg = dir.join("config.yaml");
std::fs::write(
&cfg,
"version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\nprofiles:\n dev:\n agent: claude-code\n env:\n DEBUG: \"1\"\n LOG_LEVEL: debug\n args: [--model, sonnet]\n prod:\n env:\n LOG_LEVEL: info\nagents: []\n",
)
.unwrap();
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
let mut app = App::from_cli(cli).unwrap();
let mut p = app.paths.clone();
p.install_dir = dir.join("agents");
p.bin_dir = dir.join("agents").join("bin");
p.log_dir = dir.join("logs");
p.state_file = dir.join("state.json");
p.probe_cache_file = dir.join("probe-cache.json");
p.config_dir = Some(dir.join("config"));
app.paths = p;
app
}
#[test]
fn apply_merges_args_and_env() {
let app = cfg_with_profiles();
let (args, env) = apply(&app, Some("dev"), &["--base".to_string()], &BTreeMap::from([("KEEP".into(), "1".into())]))
.unwrap();
assert_eq!(args, vec!["--base", "--model", "sonnet"]);
assert_eq!(env.get("DEBUG").map(String::as_str), Some("1"));
assert_eq!(env.get("KEEP").map(String::as_str), Some("1"));
}
#[test]
fn unknown_profile_lists_available() {
let app = cfg_with_profiles();
let err = resolve(&app, "staging").unwrap_err().to_string();
assert!(err.contains("unknown profile 'staging'"), "{err}");
assert!(err.contains("dev") && err.contains("prod"), "{err}");
}
#[test]
fn none_profile_is_noop() {
let app = cfg_with_profiles();
let (args, env) = apply(&app, None, &["x".to_string()], &BTreeMap::new()).unwrap();
assert_eq!(args, vec!["x"]);
assert!(env.is_empty());
}
}
+53 -6
View File
@@ -28,12 +28,47 @@ pub fn start(app: &App, opts: &StartArgs) -> Result<i32> {
start_with_cwd(app, opts, None)
}
/// Apply the environment profile (issue #40): the explicit --profile wins;
/// otherwise the current project's default profile applies when launching
/// the project's default agent.
fn apply_profile(
app: &App,
explicit: Option<&str>,
project_default_agent: bool,
extra_args: &[String],
extra_env: &BTreeMap<String, String>,
) -> Result<(Vec<String>, BTreeMap<String, String>)> {
let mut profile = explicit;
if profile.is_none() && project_default_agent {
let current = std::env::current_dir().unwrap_or_default();
if let Some((_, pp)) = crate::projects::profile_for(app, &current) {
profile = pp.profile.as_deref();
}
}
crate::commands::profile_cmd::apply(app, profile, extra_args, extra_env)
}
/// Start with an explicit working directory (used by sessions resume).
pub fn start_with_cwd(app: &App, opts: &StartArgs, cwd: Option<&std::path::Path>) -> Result<i32> {
let current = std::env::current_dir().unwrap_or_default();
let target = resolve_start_target(app, &current, opts.agent.as_deref())?;
let explicit = opts.agent.as_deref().filter(|s| !s.is_empty());
// A profile may declare its own default agent (issue #40).
let profile_default = profile_cmd::default_agent(app, opts.profile.as_deref())?;
let target = match (explicit, profile_default) {
(Some(a), _) => a.to_string(),
(None, Some(a)) => a.to_string(),
(None, None) => resolve_start_target(app, &current, None)?,
};
let extra_env = parse_env_list(&opts.env)?;
let extra_args = parse_extra_args(&opts.args);
let project_default_agent = explicit.is_none() && profile_default.is_none();
let (extra_args, extra_env) = apply_profile(
app,
opts.profile.as_deref(),
project_default_agent,
&extra_args,
&extra_env,
)?;
if let Some(group) = crate::catalog::Catalog::parse_group_selector(&target) {
let members = app.catalog.group_members(group);
if members.is_empty() {
@@ -228,7 +263,23 @@ fn stop_one(app: &App, agent: &AgentDef, force: bool, timeout: u64) -> Result<i3
pub fn restart(app: &App, opts: &StartArgs, force: bool, timeout: Option<u64>) -> Result<i32> {
let timeout = timeout.unwrap_or_else(|| app.config.settings.stop_timeout());
let cwd = std::env::current_dir().unwrap_or_default();
let target = resolve_start_target(app, &cwd, opts.agent.as_deref())?;
let explicit = opts.agent.as_deref().filter(|s| !s.is_empty());
let profile_default = profile_cmd::default_agent(app, opts.profile.as_deref())?;
let target = match (explicit, profile_default) {
(Some(a), _) => a.to_string(),
(None, Some(a)) => a.to_string(),
(None, None) => resolve_start_target(app, &cwd, None)?,
};
let extra_env = parse_env_list(&opts.env)?;
let extra_args = parse_extra_args(&opts.args);
let project_default_agent = explicit.is_none() && profile_default.is_none();
let (extra_args, extra_env) = apply_profile(
app,
opts.profile.as_deref(),
project_default_agent,
&extra_args,
&extra_env,
)?;
if let Some(group) = crate::catalog::Catalog::parse_group_selector(&target) {
let members = app.catalog.group_members(group);
if members.is_empty() {
@@ -237,8 +288,6 @@ pub fn restart(app: &App, opts: &StartArgs, force: bool, timeout: Option<u64>) -
for m in members.iter().rev() {
stop_one(app, m, force, timeout)?;
}
let extra_env = parse_env_list(&opts.env)?;
let extra_args = parse_extra_args(&opts.args);
for m in members {
start_one(app, m, &extra_args, &extra_env, opts.notify, true, None)?;
}
@@ -246,8 +295,6 @@ pub fn restart(app: &App, opts: &StartArgs, force: bool, timeout: Option<u64>) -
}
let agent = require_agent(app, &target)?;
stop_one(app, agent, force, timeout)?;
let extra_env = parse_env_list(&opts.env)?;
let extra_args = parse_extra_args(&opts.args);
start_one(app, agent, &extra_args, &extra_env, opts.notify, opts.background, None)
}
+55 -13
View File
@@ -13,31 +13,73 @@ struct SearchRow {
description: Option<String>,
website: Option<String>,
tags: Vec<String>,
/// Personal tags (issue #39).
personal_tags: Vec<String>,
favorite: bool,
}
pub fn run(app: &App, keyword: &str, category: Option<&str>) -> Result<i32> {
let hits = app.catalog.search(keyword, category);
pub fn run(app: &App, keyword: Option<&str>, category: Option<&str>, tag: Option<&str>) -> Result<i32> {
let keyword = keyword.unwrap_or("");
// An empty keyword (search --tag only) starts from the whole catalog.
let mut hits: Vec<&AgentDef> = if keyword.is_empty() {
app.catalog
.agents()
.iter()
.filter(|a| !a.hidden)
.filter(|a| category.map_or(true, |c| a.category.as_deref() == Some(c)))
.collect()
} else {
app.catalog.search(keyword, category)
};
// Personal tag filter (issue #39): keep agents carrying this tag.
if let Some(t) = tag {
let tagged = crate::commands::annotations_cmd::agents_with_tag(app, t);
if tagged.is_empty() {
app.log.info(&format!(
"no agent is tagged '{t}' — 'am tags' lists every personal tag"
));
return Ok(0);
}
hits.retain(|a| tagged.contains(&a.name));
}
if hits.is_empty() && tag.is_some() {
app.log.info(&format!(
"no agent matches the keyword and the tag '{}'",
tag.unwrap_or("")
));
return Ok(0);
}
if app.json() {
let rows: Vec<SearchRow> = hits
.iter()
.map(|a| SearchRow {
name: a.name.clone(),
display_name: a.title().to_string(),
category: a.category.clone(),
installable: a.installable,
description: a.description.clone(),
website: a.website.clone(),
tags: a.tags.clone(),
.map(|a| {
let personal = crate::commands::annotations_cmd::tags_of(app, &a.name);
SearchRow {
name: a.name.clone(),
display_name: a.title().to_string(),
category: a.category.clone(),
installable: a.installable,
description: a.description.clone(),
website: a.website.clone(),
tags: a.tags.clone(),
personal_tags: personal,
favorite: crate::commands::annotations_cmd::is_favorite(app, &a.name),
}
})
.collect();
print_json(&rows);
return Ok(0);
}
let mut table_rows = Vec::new();
for a in hits {
for a in &hits {
let desc = truncate(a.description.as_deref().unwrap_or(""), 56);
let star = if crate::commands::annotations_cmd::is_favorite(app, &a.name) {
"★ "
} else {
""
};
table_rows.push(vec![
a.title().to_string(),
format!("{star}{}", a.title()),
a.category.clone().unwrap_or_else(|| "—".to_string()),
if a.installable { "yes" } else { "no" }.to_string(),
desc,
@@ -52,7 +94,7 @@ pub fn run(app: &App, keyword: &str, category: Option<&str>) -> Result<i32> {
app.color(),
)
);
if app.catalog.search(keyword, category).is_empty() {
if hits.is_empty() && !keyword.is_empty() {
let suggestions = app.catalog.suggest(keyword);
if suggestions.is_empty() {
app.log
+4 -1
View File
@@ -13,7 +13,10 @@ pub fn run(app: &App, check: bool, to: Option<&Path>) -> Result<i32> {
);
};
let current = env!("CARGO_PKG_VERSION");
let release = download::latest_release(&repo)?;
let release = download::latest_release_with_base(
&repo,
app.config.settings.self_update_base_url.as_deref(),
)?;
let tag = release.tag_name.trim_start_matches('v').to_string();
if check {
+13 -2
View File
@@ -40,7 +40,13 @@ fn status_one(app: &App, name: &str, probe: &ExternalInfo) -> Result<i32> {
}
let color = app.color();
let theme = app.theme();
println!("{} ({})", agent.title(), agent.name);
// Favorite star (issue #39).
let star = if crate::commands::annotations_cmd::is_favorite(app, &agent.name) {
"★ "
} else {
""
};
println!("{}{} ({})", star, agent.title(), agent.name);
let status_line = status_style(&detail.status, theme, color);
let mut rows: Vec<(String, String)> = vec![
("agent".to_string(), agent.name.clone()),
@@ -107,8 +113,13 @@ fn status_all(app: &App, probe: &ExternalInfo) -> Result<i32> {
let theme = app.theme();
let mut table_rows = Vec::new();
for r in &rows {
let star = if crate::commands::annotations_cmd::is_favorite(app, &r.name) {
"★ "
} else {
""
};
table_rows.push(vec![
r.display_name.clone(),
format!("{star}{}", r.display_name),
r.version.clone().unwrap_or_else(|| "—".to_string()),
status_style(&r.status, theme, color),
r.pid.map(|p| p.to_string()).unwrap_or_else(|| "—".to_string()),