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
+84 -2
View File
@@ -105,6 +105,65 @@ pub enum Command {
#[arg(long, value_name = "SECS", default_value = "5")]
interval: u64,
},
/// Live TUI dashboard: overview, activity, stats, sessions, projects
Dashboard,
/// Mark an agent as a favorite (star in list and status)
Favorite {
/// Agent name or alias
agent: String,
},
/// Remove the favorite star from an agent
Unfavorite {
/// Agent name or alias
agent: String,
},
/// Attach a free-form note to an agent (shown in 'am info');
/// 'am note <agent>' without text prints the current note
Note {
/// Agent name or alias
agent: String,
/// Note text (several words are joined); empty prints the note
#[arg(value_name = "TEXT...", num_args = 0..)]
text: Vec<String>,
},
/// Add a personal tag to an agent
Tag {
/// Agent name or alias
agent: String,
/// One or more tags to add
#[arg(value_name = "TAG", num_args = 1.., required = true)]
words: Vec<String>,
},
/// Remove a personal tag from an agent
Untag {
/// Agent name or alias
agent: String,
/// Tag to remove
tag: String,
},
/// List personal tags (with counts), optionally for one agent
Tags {
/// Only show the tags of this agent
agent: Option<String>,
},
/// Manage environment profiles (dev, prod, ...)
#[command(subcommand)]
Profile(ProfileCmd),
/// Generate a man page for am (or for one command)
Man {
/// Command name (omit for the whole CLI)
command: Option<String>,
/// Write the page(s) to this directory instead of stdout
#[arg(long, value_name = "DIR")]
output: Option<PathBuf>,
},
/// Internal: list agent names for shell completions (hidden)
#[command(name = "_agents", hide = true)]
Agents {
/// Only installed agents (managed + external), aliases and groups
#[arg(long)]
installed: bool,
},
/// Manage command aliases (cc -> claude-code)
#[command(subcommand)]
Alias(AliasCmd),
@@ -249,10 +308,14 @@ pub enum Command {
/// Search the catalog by keyword (name, description, category, tags)
Search {
/// Keyword to search for (case-insensitive substring)
keyword: String,
#[arg(value_name = "KEYWORD", required_unless_present = "tag")]
keyword: Option<String>,
/// Restrict to this category
#[arg(long, value_name = "CATEGORY")]
category: Option<String>,
/// Only agents carrying this personal tag
#[arg(long, value_name = "TAG")]
tag: Option<String>,
},
/// Search the command history (am and shell commands)
History {
@@ -321,8 +384,12 @@ pub enum Command {
/// Shell to generate completions for
#[arg(value_enum, value_name = "SHELL")]
shell: Shell,
/// Add dynamic completion of the installed agents, aliases and
/// groups (queried from 'am _agents' at Tab time)
#[arg(long)]
installed: bool,
},
/// Update agent-manager itself from GitHub Releases
/// Update agent-manager itself from the latest release
SelfUpdate {
/// Only check whether a newer version exists
#[arg(long)]
@@ -370,6 +437,21 @@ pub struct StartArgs {
/// Send a desktop notification once the agent has started
#[arg(long, action = ArgAction::SetTrue)]
pub notify: bool,
/// Apply an environment profile (env + args, defined in the config)
#[arg(long, value_name = "NAME")]
pub profile: Option<String>,
}
/// Environment profile subcommands (issue #40).
#[derive(Subcommand, Debug, Clone)]
pub enum ProfileCmd {
/// List every defined profile
List,
/// Show one profile in detail (agent, env, args)
Show {
/// Profile name
name: String,
},
}
/// Secret management subcommands.
+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()),
+36 -1
View File
@@ -38,6 +38,27 @@ pub struct Config {
/// Per-project profiles: root, default agent, env and hooks.
#[serde(default)]
pub projects: BTreeMap<String, ProjectProfile>,
/// Environment profiles: a reusable set of agent + env + args
/// (issue #40) - 'am start <agent> --profile dev'.
#[serde(default)]
pub profiles: BTreeMap<String, Profile>,
}
/// An environment profile: a named set of agent, env and args applied at
/// launch time (issue #40).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct Profile {
/// Default agent for this profile (used by 'am start --profile dev'
/// without an agent name).
#[serde(default)]
pub agent: Option<String>,
/// Environment variables injected when launching with this profile.
#[serde(default)]
pub env: BTreeMap<String, String>,
/// Extra arguments appended to the run command.
#[serde(default)]
pub args: Vec<String>,
}
fn default_version() -> String {
@@ -66,9 +87,13 @@ pub struct Settings {
/// Grace period (seconds) between SIGTERM and SIGKILL; 0 = default (5).
#[serde(default)]
pub stop_timeout_secs: u64,
/// GitHub repository (owner/repo) used by the self-update command.
/// Repository (owner/repo) used by the self-update command.
#[serde(default)]
pub self_update_repo: Option<String>,
/// API base URL of the release server (default: GitHub api.github.com;
/// set to https://git.dracodev.net/api/v1 for a Gitea instance).
#[serde(default)]
pub self_update_base_url: Option<String>,
/// Color theme for the output (see 'am help theme' for the list; the
/// --theme flag overrides it).
#[serde(default)]
@@ -94,6 +119,10 @@ pub struct ProjectProfile {
/// Hooks: on_start / on_stop / on_install / on_update command lines.
#[serde(default)]
pub hooks: BTreeMap<String, Vec<String>>,
/// Default environment profile applied when launching the default
/// agent (issue #40).
#[serde(default)]
pub profile: Option<String>,
}
impl Settings {
@@ -501,6 +530,9 @@ pub fn merge(base: &mut Config, overlay: Config) {
if o.self_update_repo.is_some() {
s.self_update_repo = o.self_update_repo;
}
if o.self_update_base_url.is_some() {
s.self_update_base_url = o.self_update_base_url;
}
for (k, v) in o.hooks {
s.hooks.insert(k, v);
}
@@ -520,6 +552,9 @@ pub fn merge(base: &mut Config, overlay: Config) {
for (k, v) in overlay.projects {
base.projects.insert(k, v);
}
for (k, v) in overlay.profiles {
base.profiles.insert(k, v);
}
}
/// Validate a merged config. Returns a list of human-readable problems.
+286
View File
@@ -0,0 +1,286 @@
//! Dashboard data: one snapshot of the whole fleet (issue #33), shared by
//! the TUI ('am dashboard') and its tests. Pure reads — never blocks on an
//! empty journal and never panics on missing files.
use crate::app::App;
use crate::commands::stats_cmd;
use crate::events;
use crate::projects;
use crate::projects::ProjectSummary;
use crate::sessions;
use crate::sessions::SessionRecord;
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
pub struct OverviewRow {
pub name: String,
pub display_name: String,
pub status: String,
pub version: Option<String>,
pub pid: Option<u32>,
pub uptime_s: Option<u64>,
pub favorite: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct ActivityRow {
pub ts: String,
pub kind: String,
pub agent: Option<String>,
pub detail: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct StatsRow {
pub agent: String,
pub launches: u64,
pub failures: u64,
pub total_duration_s: u64,
pub last_used: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SessionRow {
pub id: String,
pub kind: String,
pub agent: Option<String>,
pub project: Option<String>,
pub started_at: String,
pub status: String,
pub duration_s: Option<u64>,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct DashCounts {
pub installed: usize,
pub running: usize,
pub sessions_count: u64,
pub last_used: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct DashData {
pub counts: DashCounts,
pub overview: Vec<OverviewRow>,
pub activity: Vec<ActivityRow>,
pub stats: Vec<StatsRow>,
pub sessions: Vec<SessionRow>,
pub projects: Vec<ProjectSummary>,
}
/// Read uptime from a running entry.
pub fn uptime_s(entry: &crate::state::InstalledEntry) -> Option<u64> {
let started = entry.started_at.as_deref()?;
let ts = chrono::DateTime::parse_from_rfc3339(started).ok()?;
let now = chrono::Utc::now();
let dur = now.signed_duration_since(ts.with_timezone(&chrono::Utc));
Some(dur.num_seconds().max(0) as u64)
}
/// Build one snapshot of the fleet. Never fails: missing data degrades to
/// empty sections (the TUI must not block on an empty journal).
pub fn collect(app: &App) -> DashData {
let entries = app.state.all().unwrap_or_default();
let probe = crate::probe::detect_externals(app);
let mut overview: Vec<OverviewRow> = Vec::new();
let mut running = 0usize;
for agent in app.catalog.agents() {
if agent.hidden {
continue;
}
let entry = entries.get(&agent.name);
let (status, pid, uptime) = if let Some(e) = entry {
if e.pid.map(crate::process::is_running).unwrap_or(false) {
running += 1;
( "running".to_string(), e.pid, uptime_s(e) )
} else {
("installed".to_string(), None, None)
}
} else if probe.path_of(&agent.name).is_some() {
("external".to_string(), None, None)
} else if !agent.installable {
("not-installable".to_string(), None, None)
} else {
("not-installed".to_string(), None, None)
};
let version = entry
.and_then(|e| e.version.clone())
.or_else(|| agent.version.clone());
overview.push(OverviewRow {
name: agent.name.clone(),
display_name: agent.title().to_string(),
status,
version,
pid,
uptime_s: uptime,
favorite: crate::commands::annotations_cmd::is_favorite(app, &agent.name),
});
}
// Activity: newest first.
let mut activity: Vec<ActivityRow> = events::read_events(&app.events_dir(), 200)
.into_iter()
.rev()
.map(|ev| ActivityRow {
ts: ev.ts.clone(),
kind: ev.kind.as_str().to_string(),
agent: ev.agent.clone(),
detail: match ev.kind {
events::EventKind::Start | events::EventKind::Run => {
let mut d = ev.args.join(" ");
if d.is_empty() {
d = ev.reason.clone().unwrap_or_default();
}
d
}
events::EventKind::Install => "install".to_string(),
events::EventKind::Update => "update".to_string(),
events::EventKind::Stop => "stop".to_string(),
events::EventKind::Uninstall => "uninstall".to_string(),
events::EventKind::Annotate => ev.reason.clone().unwrap_or_default(),
_ => ev.reason.clone().unwrap_or_default(),
},
})
.collect();
activity.truncate(200);
// Stats: by launch count, then by name.
let mut stats: Vec<StatsRow> = stats_cmd::aggregate(app, None, None)
.unwrap_or_default()
.into_iter()
.map(|s| StatsRow {
agent: s.agent,
launches: s.launches,
failures: s.failures,
total_duration_s: s.total_duration_s,
last_used: s.last_used,
})
.collect();
stats.sort_by(|a, b| b.launches.cmp(&a.launches).then(a.agent.cmp(&b.agent)));
// Sessions: newest first.
let mut sessions: Vec<SessionRow> = sessions::load(app)
.map(|sf| {
sf.sessions
.into_values()
.map(|r: SessionRecord| {
let dur = sessions::duration_s(&r);
SessionRow {
id: r.id,
kind: r.kind,
agent: r.agent,
project: r.project,
started_at: r.started_at,
status: r.status,
duration_s: dur,
}
})
.collect()
})
.unwrap_or_default();
sessions.sort_by(|a, b| b.started_at.cmp(&a.started_at));
let counts = DashCounts {
installed: entries.len(),
running,
sessions_count: app
.state
.load()
.map(|sf| sf.sessions_count)
.unwrap_or(0),
last_used: app.state.load().ok().and_then(|sf| sf.last_used),
};
DashData {
counts,
overview,
activity,
stats,
sessions,
projects: projects::aggregate(app),
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
fn app_in(dir: &std::path::Path) -> App {
let cfg = dir.join("config.yaml");
std::fs::write(
&cfg,
"version: \"1.0\"\nsettings:\n auto_install_deps: false\nagents: []\n",
)
.unwrap();
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
let mut app = crate::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 empty_journal_produces_empty_sections_without_panicking() {
let guard = tempfile::tempdir().unwrap();
let dir = guard.path().to_path_buf();
std::mem::forget(guard);
let app = app_in(&dir);
let data = collect(&app);
assert_eq!(data.counts.installed, 0);
assert_eq!(data.counts.running, 0);
assert!(data.activity.is_empty());
assert!(data.stats.is_empty());
assert!(data.sessions.is_empty());
// Catalog agents still show in the overview (not-installed).
assert!(!data.overview.is_empty());
}
#[test]
fn journal_events_feed_activity_newest_first() {
let guard = tempfile::tempdir().unwrap();
let dir = guard.path().to_path_buf();
std::mem::forget(guard);
let month = chrono::Utc::now().format("%Y%m");
std::fs::write(
dir.join(format!("events-{month}.jsonl")),
concat!(
"{\"ts\":\"2026-08-01T10:00:00Z\",\"kind\":\"run\",\"agent\":\"a\",\"exit_code\":0}\n",
"{\"ts\":\"2026-08-02T10:00:00Z\",\"kind\":\"install\",\"agent\":\"b\"}\n",
),
)
.unwrap();
let app = app_in(&dir);
let data = collect(&app);
assert_eq!(data.activity.len(), 2);
assert_eq!(data.activity[0].agent.as_deref(), Some("b"));
assert_eq!(data.activity[0].kind, "install");
assert_eq!(data.stats.len(), 2);
}
#[test]
fn uptime_is_computed_from_started_at() {
let entry = crate::state::InstalledEntry {
name: "x".to_string(),
version: None,
method: "npm".to_string(),
install_dir: "/tmp/x".to_string(),
bins: vec![],
run: "x".to_string(),
installed_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: None,
pid: None,
started_at: Some(chrono::Utc::now().to_rfc3339()),
};
let s = uptime_s(&entry).unwrap();
assert!(s < 60, "uptime should be seconds, got {s}");
let old = crate::state::InstalledEntry { started_at: Some("2026-01-01T00:00:00Z".to_string()), ..entry };
assert!(uptime_s(&old).unwrap() > 3600);
}
}
+15 -6
View File
@@ -150,19 +150,28 @@ fn github_auth_header() -> Option<String> {
}
pub fn latest_release(repo: &str) -> Result<GhRelease> {
match latest_release_api(repo) {
latest_release_with_base(repo, None)
}
/// Resolve the latest release, with an optional API base URL override
/// (e.g. https://git.dracodev.net/api/v1 for a Gitea instance).
pub fn latest_release_with_base(repo: &str, api_base: Option<&str>) -> Result<GhRelease> {
let github = api_base.is_none() || api_base == Some("https://api.github.com");
match latest_release_api(repo, api_base) {
Ok(release) => Ok(release),
Err(e) if format!("{e:#}").contains("rate limit") => {
// The API is rate limited (60 unauthenticated requests/hour):
// fall back to the GitHub web pages, which are not limited.
Err(e) if github && format!("{e:#}").contains("rate limit") => {
// The GitHub API is rate limited (60 unauthenticated
// requests/hour): fall back to the GitHub web pages, which are
// not limited. (Gitea has no equivalent web fallback.)
latest_release_web(repo)
}
Err(e) => Err(e),
}
}
fn latest_release_api(repo: &str) -> Result<GhRelease> {
let url = format!("https://api.github.com/repos/{repo}/releases/latest");
fn latest_release_api(repo: &str, api_base: Option<&str>) -> Result<GhRelease> {
let base = api_base.unwrap_or("https://api.github.com");
let url = format!("{base}/repos/{repo}/releases/latest");
let mut req = ureq::get(&url).set("User-Agent", USER_AGENT);
if let Some(auth) = github_auth_header() {
req = req.set("Authorization", &auth);
+3
View File
@@ -30,6 +30,8 @@ pub enum EventKind {
Config,
Repl,
Shell,
/// Personal annotations: favorite, note, tags (issue #39).
Annotate,
}
impl EventKind {
@@ -46,6 +48,7 @@ impl EventKind {
EventKind::Config => "config",
EventKind::Repl => "repl",
EventKind::Shell => "shell",
EventKind::Annotate => "annotate",
}
}
}
+169 -6
View File
@@ -429,10 +429,11 @@ pub static HELP_SPECS: &[HelpSpec] = &[
name: "search",
category: "Commands",
usage: "search {flags} <keyword>",
about: "Search the catalog by keyword (name, description, category, tags).",
about: "Search the catalog by keyword (name, description, category, tags); --tag filters on personal tags.",
search_terms: &["find", "catalog", "keyword"],
flags: &[
HelpFlag { short: "", long: "--category", value: "CATEGORY", desc: "Restrict to this category" },
HelpFlag { short: "", long: "--tag", value: "TAG", desc: "Only agents carrying this personal tag" },
],
subcommands: &[],
parameters: &[
@@ -442,6 +443,7 @@ pub static HELP_SPECS: &[HelpSpec] = &[
examples: &[
HelpExample { desc: "Find agents related to rust.", code: "search rust" },
HelpExample { desc: "Search within one category.", code: "search coding --category coding-agent" },
HelpExample { desc: "Find agents tagged python.", code: "search --tag python" },
],
},
HelpSpec {
@@ -541,7 +543,7 @@ pub static HELP_SPECS: &[HelpSpec] = &[
name: "start",
category: "Commands",
usage: "start {flags} <agent>",
about: "Start an agent (foreground by default, or detached with --background).",
about: "Start an agent (foreground by default, or detached with --background). --profile applies an environment profile (env + args).",
search_terms: &["launch", "begin"],
flags: START_FLAGS,
subcommands: &[],
@@ -553,6 +555,7 @@ pub static HELP_SPECS: &[HelpSpec] = &[
HelpExample { desc: "Start an agent in the background.", code: "start claude-code --background" },
HelpExample { desc: "Start with extra arguments.", code: "start claude-code --args \"--model sonnet\"" },
HelpExample { desc: "Start every agent of a group.", code: "start group:dev --background" },
HelpExample { desc: "Start with an environment profile.", code: "start claude-code --profile prod" },
],
},
HelpSpec {
@@ -661,9 +664,11 @@ pub static HELP_SPECS: &[HelpSpec] = &[
name: "completion",
category: "Commands",
usage: "completion {flags} <shell>",
about: "Generate a shell completion script (bash, zsh, fish, powershell, elvish).",
about: "Generate a shell completion script (bash, zsh, fish, powershell, elvish). With --installed, Tab also completes the installed agents, aliases and groups.",
search_terms: &["autocomplete", "shell"],
flags: &[],
flags: &[
HelpFlag { short: "", long: "--installed", value: "", desc: "Add dynamic completion of installed agents, aliases and groups" },
],
subcommands: &[],
parameters: &[
HelpParam { name: "shell", typ: "oneof<bash, zsh, fish, powershell, elvish>", desc: "Shell to generate completions for" },
@@ -671,14 +676,15 @@ pub static HELP_SPECS: &[HelpSpec] = &[
io: None,
examples: &[
HelpExample { desc: "Generate PowerShell completions.", code: "completion powershell" },
HelpExample { desc: "Generate bash completions with the installed agents.", code: "completion bash --installed" },
],
},
HelpSpec {
name: "self-update",
category: "Commands",
usage: "self-update {flags}",
about: "Update agent-manager itself from GitHub Releases.",
search_terms: &["upgrade", "release"],
about: "Update agent-manager itself from the latest release (GitHub ou Gitea, selon settings.self_update_*).",
search_terms: &["upgrade", "release", "gitea"],
flags: &[
HelpFlag { short: "", long: "--check", value: "", desc: "Only check whether a newer version exists" },
HelpFlag { short: "", long: "--to", value: "FILE", desc: "Download the new binary to this path instead of replacing the current one" },
@@ -738,6 +744,163 @@ pub static HELP_SPECS: &[HelpSpec] = &[
HelpExample { desc: "Restore a backup.", code: "import am-export.json" },
],
},
HelpSpec {
name: "dashboard",
category: "Commands",
usage: "dashboard {flags}",
about: "Live TUI dashboard: the whole fleet in one screen — overview, activity, stats, sessions and projects, refreshed every second.",
search_terms: &["tui", "monitor", "overview", "cockpit"],
flags: &[],
subcommands: &[],
parameters: &[],
io: None,
examples: &[
HelpExample { desc: "Open the dashboard (Tab or arrows switch tabs, j/k scroll, q quits).", code: "dashboard" },
],
},
HelpSpec {
name: "favorite",
category: "Commands",
usage: "favorite {flags} <agent>",
about: "Mark an agent as a favorite: a star appears in 'am list', 'am status' and 'am info'.",
search_terms: &["star", "bookmark", "prefer"],
flags: &[],
subcommands: &[],
parameters: &[
HelpParam { name: "agent", typ: "string", desc: "Agent name or alias" },
],
io: None,
examples: &[
HelpExample { desc: "Mark claude-code as a favorite.", code: "favorite claude-code" },
],
},
HelpSpec {
name: "unfavorite",
category: "Commands",
usage: "unfavorite {flags} <agent>",
about: "Remove the favorite star from an agent.",
search_terms: &["star", "bookmark"],
flags: &[],
subcommands: &[],
parameters: &[
HelpParam { name: "agent", typ: "string", desc: "Agent name or alias" },
],
io: None,
examples: &[
HelpExample { desc: "Remove the star from claude-code.", code: "unfavorite claude-code" },
],
},
HelpSpec {
name: "note",
category: "Commands",
usage: "note {flags} <agent> [text...]",
about: "Attach a free-form note to an agent, shown in 'am info'. Without text, prints the current note.",
search_terms: &["comment", "remark", "memo"],
flags: &[],
subcommands: &[],
parameters: &[
HelpParam { name: "agent", typ: "string", desc: "Agent name or alias" },
HelpParam { name: "text", typ: "string", desc: "Note text (several words are joined); empty prints the note" },
],
io: None,
examples: &[
HelpExample { desc: "Set a note.", code: "note claude-code use the sonnet model for this repo" },
HelpExample { desc: "Show the note.", code: "note claude-code" },
],
},
HelpSpec {
name: "tag",
category: "Commands",
usage: "tag {flags} <agent> <tag>...",
about: "Add personal tags to an agent; they are searchable with 'am search --tag' and listed in 'am info'.",
search_terms: &["label", "keyword", "filter"],
flags: &[],
subcommands: &[],
parameters: &[
HelpParam { name: "agent", typ: "string", desc: "Agent name or alias" },
HelpParam { name: "tag", typ: "string", desc: "One or more tags to add" },
],
io: None,
examples: &[
HelpExample { desc: "Tag claude-code for Python work.", code: "tag claude-code python frontend" },
HelpExample { desc: "Find everything tagged python.", code: "search --tag python" },
],
},
HelpSpec {
name: "untag",
category: "Commands",
usage: "untag {flags} <agent> <tag>",
about: "Remove a personal tag from an agent.",
search_terms: &["label", "remove"],
flags: &[],
subcommands: &[],
parameters: &[
HelpParam { name: "agent", typ: "string", desc: "Agent name or alias" },
HelpParam { name: "tag", typ: "string", desc: "Tag to remove" },
],
io: None,
examples: &[
HelpExample { desc: "Remove the python tag from claude-code.", code: "untag claude-code python" },
],
},
HelpSpec {
name: "tags",
category: "Commands",
usage: "tags {flags} [agent]",
about: "List personal tags with the agents carrying them; with an agent, list only its tags.",
search_terms: &["labels", "tag list"],
flags: &[],
subcommands: &[],
parameters: &[
HelpParam { name: "agent", typ: "string", desc: "Agent name or alias (optional)" },
],
io: None,
examples: &[
HelpExample { desc: "List every personal tag.", code: "tags" },
HelpExample { desc: "List the tags of one agent.", code: "tags claude-code" },
],
},
HelpSpec {
name: "profile",
category: "Commands",
usage: "profile {flags} <list|show> [name]",
about: "Environment profiles: a named set of agent, env and args defined in the config, applied with 'am start --profile dev'.",
search_terms: &["environment", "context", "dev", "prod"],
flags: &[],
subcommands: &[
("list", "List every defined profile"),
("show", "Show one profile in detail"),
],
parameters: &[
HelpParam { name: "name", typ: "string", desc: "Profile to show" },
],
io: None,
examples: &[
HelpExample { desc: "List the profiles.", code: "profile list" },
HelpExample { desc: "Show the dev profile.", code: "profile show dev" },
HelpExample { desc: "Start an agent with the prod profile.", code: "start claude-code --profile prod" },
],
},
HelpSpec {
name: "man",
category: "Commands",
usage: "man {flags} [command]",
about: "Generate roff man pages from the command definitions: one page for the whole CLI, or one page per command with --output.",
search_terms: &["documentation", "roff", "unix"],
flags: &[
HelpFlag { short: "", long: "--output", value: "DIR", desc: "Write am.1 plus one am-<command>.1 per command into this directory" },
],
subcommands: &[],
parameters: &[
HelpParam { name: "command", typ: "string", desc: "Command to document; omit for the whole CLI" },
],
io: None,
examples: &[
HelpExample { desc: "Print the man page of the whole CLI.", code: "man" },
HelpExample { desc: "Print one command page.", code: "man install" },
HelpExample { desc: "Write every page into man/ (for a package).", code: "man --output man/" },
],
},
HelpSpec {
name: "help",
category: "Commands",
+17
View File
@@ -17,6 +17,7 @@ pub mod cli;
pub mod commands;
pub mod config;
pub mod context;
pub mod dashboard;
pub mod deps;
pub mod doctor;
pub mod download;
@@ -45,7 +46,23 @@ pub mod version;
use clap::Parser;
/// CLI entry point used by the am binary. Returns the process exit code.
///
/// The whole run happens on a dedicated thread with a large stack: clap's
/// derive code for the full command tree (40+ subcommands) uses deep stack
/// frames in debug builds, which overflows the Windows main-thread default
/// (1 MB). An 8 MB thread keeps every build mode on every platform safe.
pub fn main_entry() -> i32 {
std::thread::Builder::new()
.name("am-main".to_string())
.stack_size(8 * 1024 * 1024)
.spawn(real_main)
.expect("cannot spawn the am main thread")
.join()
.unwrap_or(1)
}
/// The actual entry point, running on the big-stack thread.
fn real_main() -> i32 {
// Enable ANSI escape sequences on Windows consoles (best effort): the
// colored crate and the theme palettes both emit raw VT codes.
#[cfg(windows)]
+139 -10
View File
@@ -46,8 +46,12 @@ pub struct AmCompleter {
gateway_shells: Vec<&'static str>,
options: Vec<&'static str>,
agents: Vec<String>,
/// Whole catalog (install/info/search only).
catalog_agents: Vec<String>,
aliases: Vec<String>,
groups: Vec<String>,
/// 'profile' subcommands.
profile_sub: Vec<&'static str>,
/// Short descriptions shown next to candidates in the Tab menu
/// (Nushell-style).
descriptions: BTreeMap<String, String>,
@@ -90,6 +94,15 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
("self-uninstall", "remove agent-manager"),
("export", "backup config + state"),
("import", "restore a backup"),
("dashboard", "live TUI dashboard of the fleet"),
("favorite", "mark an agent as a favorite"),
("unfavorite", "remove the favorite star"),
("note", "attach a note to an agent"),
("tag", "tag an agent (personal tags)"),
("untag", "remove a personal tag"),
("tags", "list personal tags"),
("profile", "manage environment profiles"),
("man", "generate man pages"),
("shell", "show or switch the system shell"),
("theme", "show or switch the color theme"),
("ls", "list files and directories (Nushell-style)"),
@@ -105,8 +118,30 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
impl AmCompleter {
pub fn new(app: &App) -> Self {
let completer =
Self::from_catalog(&app.catalog, &app.config.aliases, &app.config.groups);
// Issue #41: Tab proposes the installed agents (managed + external),
// aliases and groups — not the whole catalog.
let installed: std::collections::BTreeSet<String> = app
.state
.all()
.unwrap_or_default()
.keys()
.cloned()
.collect();
let mut completer = Self::from_catalog(
&app.catalog,
&app.config.aliases,
&app.config.groups,
Some(&installed),
);
// External agents found on PATH are launchable too.
let probe = crate::probe::detect_externals(app);
for a in app.catalog.agents() {
if probe.path_of(&a.name).is_some() {
completer.agents.push(a.name.clone());
}
}
completer.agents.sort();
completer.agents.dedup();
completer.theme.set(app.theme());
completer
}
@@ -120,14 +155,23 @@ impl AmCompleter {
catalog: &crate::catalog::Catalog,
aliases: &BTreeMap<String, String>,
groups: &BTreeMap<String, Vec<String>>,
installed: Option<&std::collections::BTreeSet<String>>,
) -> Self {
let mut agents: Vec<String> = catalog
.agents()
.iter()
.filter(|a| !a.hidden)
.filter(|a| installed.map(|set| set.contains(&a.name)).unwrap_or(true))
.map(|a| a.name.clone())
.collect();
agents.sort();
// The whole catalog stays available for install/info/search.
let catalog_agents: Vec<String> = catalog
.agents()
.iter()
.filter(|a| !a.hidden)
.map(|a| a.name.clone())
.collect();
let mut alias_list: Vec<String> = aliases.keys().cloned().collect();
alias_list.sort();
let mut group_list: Vec<String> = groups.keys().map(|g| format!("group:{g}")).collect();
@@ -153,9 +197,12 @@ impl AmCompleter {
"list", "status", "sessions", "stats", "top", "report", "projects", "timeline", "log", "logs", "history", "init", "alias", "secret", "open", "watch", "search", "info", "install", "uninstall", "update",
"start", "stop", "restart", "run", "doctor", "config", "completion",
"self-update", "self-uninstall", "export", "import", "shell", "theme",
"dashboard", "favorite", "unfavorite", "note", "tag", "untag", "tags",
"profile", "man",
"ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit",
],
config_sub: vec!["show", "path", "edit", "validate", "add"],
profile_sub: vec!["list", "show"],
shells: vec!["bash", "zsh", "fish", "powershell", "elvish"],
gateway_shells: vec![
"list", "bash", "zsh", "fish", "powershell", "pwsh", "cmd", "sh", "nu",
@@ -165,8 +212,10 @@ impl AmCompleter {
"--background", "--force", "--purge", "--method", "--all",
"--category", "--args", "--env", "--notify", "--timeout", "--yes",
"--verbose", "--quiet", "--json", "--dry-run", "--no-color",
"--profile", "--installed", "--tag", "--output",
],
agents,
catalog_agents,
aliases: alias_list,
groups: group_list,
descriptions,
@@ -194,6 +243,9 @@ impl AmCompleter {
"config" => {
pool = self.config_sub.iter().map(|s| s.to_string()).collect();
}
"profile" => {
pool = self.profile_sub.iter().map(|s| s.to_string()).collect();
}
"completion" => {
pool = self.shells.iter().map(|s| s.to_string()).collect();
}
@@ -216,8 +268,11 @@ impl AmCompleter {
let cwd = current_dir();
pool = crate::nav::path_candidates(&cwd, word, false);
}
"install" | "uninstall" | "update" | "start" | "stop" | "restart" | "run"
| "info" | "status" => {
// Issue #41: operational commands propose installed agents,
// aliases and groups only.
"start" | "stop" | "restart" | "run" | "status" | "logs" | "open"
| "watch" | "favorite" | "unfavorite" | "note" | "tag" | "untag"
| "sessions" | "stats" | "log" | "update" | "uninstall" => {
if word.starts_with("group:") {
pool = self.groups.clone();
} else if previous.len() == 1 {
@@ -231,6 +286,16 @@ impl AmCompleter {
pool = self.options.iter().map(|s| s.to_string()).collect();
}
}
"install" | "info" | "search" => {
if word.starts_with('-') {
pool = self.options.iter().map(|s| s.to_string()).collect();
} else if previous.len() == 1 {
pool = self.catalog_agents.clone();
pool.extend(self.aliases.iter().cloned());
} else {
pool = self.options.iter().map(|s| s.to_string()).collect();
}
}
_ => {
if word.starts_with('-') {
pool = self.options.iter().map(|s| s.to_string()).collect();
@@ -1270,6 +1335,47 @@ fn handle_line(
return Ok(false);
}
},
"dashboard" => Command::Dashboard,
"favorite" => Command::Favorite {
agent: need("agent name")?,
},
"unfavorite" => Command::Unfavorite {
agent: need("agent name")?,
},
"note" => Command::Note {
agent: need("agent name")?,
text: rest[1..].to_vec(),
},
"tag" => Command::Tag {
agent: need("agent name")?,
words: rest[1..].to_vec(),
},
"untag" => Command::Untag {
agent: need("agent name")?,
tag: need("tag")?,
},
"tags" => Command::Tags {
agent: rest.first().cloned(),
},
"profile" => match rest.first().map(|s| s.as_str()) {
Some("show") if rest.len() >= 2 => {
Command::Profile(crate::cli::ProfileCmd::Show {
name: rest[1].clone(),
})
}
Some("list") | None => Command::Profile(crate::cli::ProfileCmd::List),
other => {
app.log.error(&format!(
"usage: profile list | profile show <name>{}",
other.map(|o| format!(" (unknown 'profile {o}')")).unwrap_or_default()
));
return Ok(false);
}
},
"man" => Command::Man {
command: rest.first().cloned(),
output: opt_value("--output").map(PathBuf::from),
},
"status" => Command::Status {
agent: rest.first().cloned(),
},
@@ -1318,16 +1424,26 @@ fn handle_line(
rerun: opt_value("--rerun").and_then(|v| v.parse().ok()),
},
"init" => Command::Init { force: flag("--force") },
"search" => Command::Search {
keyword: rest.join(" "),
category: None,
},
"search" => {
let words: Vec<String> = rest
.iter()
.filter(|t| !t.starts_with("--"))
.cloned()
.collect();
let kw = words.join(" ");
Command::Search {
keyword: if kw.is_empty() { None } else { Some(kw) },
category: None,
tag: opt_value("--tag"),
}
}
"info" => Command::Info {
agent: need("agent name")?,
},
"start" => Command::Start(StartArgs {
agent: rest.first().cloned(),
background: flag("--background") || flag("-b"),
profile: opt_value("--profile"),
..Default::default()
}),
"stop" => Command::Stop {
@@ -1339,6 +1455,7 @@ fn handle_line(
start: StartArgs {
agent: rest.first().cloned(),
background: flag("--background") || flag("-b"),
profile: opt_value("--profile"),
..Default::default()
},
force: flag("--force"),
@@ -1380,7 +1497,10 @@ fn handle_line(
let shell: clap_complete::Shell = name
.parse()
.map_err(|e: String| anyhow!("unknown shell '{name}': {e}"))?;
Command::Completion { shell }
Command::Completion {
shell,
installed: flag("--installed"),
}
}
"self-update" => Command::SelfUpdate {
check: flag("--check"),
@@ -1453,6 +1573,15 @@ fn is_am_command(word: &str) -> bool {
| "self-uninstall"
| "export"
| "import"
| "dashboard"
| "favorite"
| "unfavorite"
| "note"
| "tag"
| "untag"
| "tags"
| "profile"
| "man"
)
}
@@ -1703,7 +1832,7 @@ mod tests {
cfg.aliases.clone(),
cfg.groups.clone(),
);
AmCompleter::from_catalog(&catalog, &cfg.aliases, &cfg.groups)
AmCompleter::from_catalog(&catalog, &cfg.aliases, &cfg.groups, None)
}
fn test_session() -> ShellSession {
+53 -3
View File
@@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub const STATE_VERSION: u32 = 2;
pub const STATE_VERSION: u32 = 3;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateFile {
@@ -21,6 +21,23 @@ pub struct StateFile {
/// RFC 3339 timestamp of the last start/run of any agent.
#[serde(default)]
pub last_used: Option<String>,
/// Personal annotations per agent: favorite, note, tags (issue #39).
#[serde(default)]
pub annotations: BTreeMap<String, Annotation>,
}
/// Personal annotations attached to an agent by the user (issue #39).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Annotation {
/// Marked as a favorite (star in list/status).
#[serde(default)]
pub favorite: bool,
/// Free-form note shown in 'am info'.
#[serde(default)]
pub note: Option<String>,
/// Personal tags, filterable with 'am search --tag'.
#[serde(default)]
pub tags: Vec<String>,
}
impl Default for StateFile {
@@ -30,6 +47,7 @@ impl Default for StateFile {
installed: BTreeMap::new(),
sessions_count: 0,
last_used: None,
annotations: BTreeMap::new(),
}
}
}
@@ -162,6 +180,38 @@ impl StateStore {
sf.sessions_count = sf.sessions_count.saturating_add(1);
self.save(&sf)
}
/// Every personal annotation, keyed by agent name (issue #39).
pub fn annotations(&self) -> Result<BTreeMap<String, Annotation>> {
Ok(self.load()?.annotations)
}
/// The annotation block of one agent (defaults to empty).
pub fn annotation(&self, name: &str) -> Result<Annotation> {
Ok(self
.load()?
.annotations
.get(name)
.cloned()
.unwrap_or_default())
}
/// Replace the annotation block of one agent.
pub fn set_annotation(&self, name: &str, ann: &Annotation) -> Result<()> {
let mut sf = self.load()?;
sf.annotations.insert(name.to_string(), ann.clone());
self.save(&sf)
}
/// Remove the annotation block of one agent (returns true when present).
pub fn remove_annotation(&self, name: &str) -> Result<bool> {
let mut sf = self.load()?;
let removed = sf.annotations.remove(name).is_some();
if removed {
self.save(&sf)?;
}
Ok(removed)
}
}
#[cfg(test)]
@@ -221,13 +271,13 @@ mod tests {
)
.unwrap();
let sf = st.load().unwrap();
assert_eq!(sf.version, 2);
assert_eq!(sf.version, 3);
assert_eq!(sf.sessions_count, 0);
assert_eq!(sf.last_used, None);
assert!(sf.installed.contains_key("claude-code"));
// The migration is persisted on first contact.
let raw = std::fs::read_to_string(st.path()).unwrap();
assert!(raw.contains(r#""version": 2"#), "migrated file rewritten: {raw}");
assert!(raw.contains(r#""version": 3"#), "migrated file rewritten: {raw}");
}
#[test]