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
This commit is contained in:
+84
-2
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 "));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()))?;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
@@ -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, ¤t) {
|
||||
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, ¤t, 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, ¤t, 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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user