feat: hints de configuration post-install (setup_hints) — commandes manuelles affichees apres install avec substitution {model}/{provider}/{base_url} (picoclaw onboard/model/auth)
release / release (push) Failing after 2m10s
release / macos (push) Skipped

This commit is contained in:
2026-08-21 11:08:22 -04:00
parent e71cffb3c0
commit 66a3eebbd6
8 changed files with 128 additions and 1 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "agent-manager" name = "agent-manager"
version = "1.1.3" version = "1.1.4"
edition = "2021" edition = "2021"
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog." description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
license = "MIT" license = "MIT"
+8
View File
@@ -92,6 +92,14 @@
> enregistré existant » (les branches non-installé/binaire manquant > enregistré existant » (les branches non-installé/binaire manquant
> étaient déjà correctes). Test de régression > étaient déjà correctes). Test de régression
> `resolve_exec_installed_agent_does_not_leak_run_token`. > `resolve_exec_installed_agent_does_not_leak_run_token`.
>
> 🔧 **Maintenance v1.1.4 (2026-08-21)** : hints de configuration
> post-install — nouveau champ `setup_hints` dans la définition d'un agent
> (config.yaml) : commandes de configuration manuelle affichées après
> l'install quand l'agent gère sa propre config (ex. picoclaw
> onboard/model/auth), avec substitution des placeholders `{model}`,
> `{provider}` et `{base_url}` depuis le provider résolu. picoclaw est le
> premier agent du catalogue à les déclarer.
--- ---
+6
View File
@@ -1025,6 +1025,12 @@ agents:
repo: sipeed/picoclaw repo: sipeed/picoclaw
binary: picoclaw binary: picoclaw
run: picoclaw run: picoclaw
# PicoClaw gère sa config par sous-commandes (pas de fichier à écrire) :
# ces commandes sont affichées après l'install avec {model} substitué.
setup_hints:
- "picoclaw onboard"
- "picoclaw model {model}"
- "picoclaw auth login"
tags: [go, tiny] tags: [go, tiny]
- name: ironclaw - name: ironclaw
+104
View File
@@ -111,6 +111,46 @@ pub fn apply_env_map_resolved(
} }
} }
/// Substitute the {model}, {provider} and {base_url} placeholders of a
/// setup hint with the resolved provider configuration (v1.1.4). When no
/// provider is resolved, the hint is returned unchanged.
pub fn render_hint(
hint: &str,
resolved: &Option<(String, Option<String>)>,
config: &Config,
) -> String {
let Some((pname, model)) = resolved else {
return hint.to_string();
};
let base_url = crate::providers::get(config, pname)
.map(|d| d.base_url.clone())
.unwrap_or_default();
hint.replace("{provider}", pname)
.replace(
"{model}",
model.as_deref().unwrap_or("{model}"),
)
.replace("{base_url}", &base_url)
}
/// Print the manual setup commands of an agent (agents that manage their
/// own configuration, e.g. picoclaw onboard/model/auth). No-op when the
/// agent declares none (v1.1.4).
fn print_hints(
app: &App,
agent: &AgentDef,
resolved: &Option<(String, Option<String>)>,
) {
if agent.setup_hints.is_empty() {
return;
}
app.log
.success(crate::i18n::tr("Configuration manuelle requise — à exécuter :"));
for hint in &agent.setup_hints {
app.log.info(&format!(" {}", render_hint(hint, resolved, &app.config)));
}
}
/// Write the agent configuration files after a successful install /// Write the agent configuration files after a successful install
/// (issue #91). The caller skips this on `--no-config`, on non-configurable /// (issue #91). The caller skips this on `--no-config`, on non-configurable
/// agents, and in dry-run (never reached). Never writes a token in clear: /// agents, and in dry-run (never reached). Never writes a token in clear:
@@ -124,6 +164,7 @@ pub fn apply_post_install(
app.log.info(crate::i18n::tr( app.log.info(crate::i18n::tr(
"agent sans adaptateur de config — voir sa doc", "agent sans adaptateur de config — voir sa doc",
)); ));
print_hints(app, agent, resolved);
return Ok(()); return Ok(());
}; };
if cfg.files.is_empty() { if cfg.files.is_empty() {
@@ -136,12 +177,14 @@ pub fn apply_post_install(
"config par variables d'env — injectée au démarrage", "config par variables d'env — injectée au démarrage",
)); ));
} }
print_hints(app, agent, resolved);
return Ok(()); return Ok(());
} }
let Some((pname, model)) = resolved else { let Some((pname, model)) = resolved else {
app.log.warn(crate::i18n::tr( app.log.warn(crate::i18n::tr(
"aucun provider résolu — fichiers de config non écrits", "aucun provider résolu — fichiers de config non écrits",
)); ));
print_hints(app, agent, resolved);
return Ok(()); return Ok(());
}; };
let Some(def) = crate::providers::get(&app.config, pname) else { let Some(def) = crate::providers::get(&app.config, pname) else {
@@ -418,6 +461,7 @@ mod tests {
provider, provider,
model: None, model: None,
config: Some(config), config: Some(config),
setup_hints: vec![],
sandbox: None, sandbox: None,
tags: vec![], tags: vec![],
installable: false, installable: false,
@@ -515,4 +559,64 @@ mod tests {
); );
assert_eq!(provider_pref(&agent2), Some("openai")); assert_eq!(provider_pref(&agent2), Some("openai"));
} }
#[test]
fn render_hint_substitutes_resolved_values() {
let c: Config = serde_yaml::from_str(
"version: \"1.0\"\nsettings:\n default_provider: openrouter\n providers:\n openrouter:\n base_url: https://openrouter.ai/api/v1\n default_model: deepseek/deepseek-chat-v3-0324\n models: [deepseek/deepseek-chat-v3-0324]\n",
)
.unwrap();
let resolved = Some(("openrouter".to_string(), Some("deepseek/deepseek-chat-v3-0324".to_string())));
assert_eq!(
render_hint("picoclaw model {model}", &resolved, &c),
"picoclaw model deepseek/deepseek-chat-v3-0324"
);
assert_eq!(render_hint("picoclaw onboard", &resolved, &c), "picoclaw onboard");
assert_eq!(
render_hint("cli --provider {provider} --url {base_url}", &resolved, &c),
"cli --provider openrouter --url https://openrouter.ai/api/v1"
);
// No resolved provider: the hint is left untouched.
assert_eq!(
render_hint("picoclaw model {model}", &None, &c),
"picoclaw model {model}"
);
// Resolved provider without a model: {model} stays visible.
let no_model = Some(("openrouter".to_string(), None));
assert_eq!(
render_hint("picoclaw model {model}", &no_model, &c),
"picoclaw model {model}"
);
}
#[test]
fn apply_post_install_with_setup_hints_is_ok() {
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\nagents: []\n",
)
.unwrap();
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
let app = crate::app::App::from_cli(cli).unwrap();
let mut agent = agent_with_config(
AgentConfig {
env_map: BTreeMap::new(),
files: vec![],
provider_default: None,
},
None,
);
agent.config = None;
agent.setup_hints = vec![
"picoclaw onboard".to_string(),
"picoclaw model {model}".to_string(),
];
// No provider resolved: hints printed with placeholders, no crash.
assert!(apply_post_install(&app, &agent, &None).is_ok());
assert!(apply_post_install(&app, &agent, &Some(("openrouter".to_string(), None))).is_ok());
}
} }
+1
View File
@@ -669,6 +669,7 @@ mod tests {
files: vec![], files: vec![],
provider_default: None, provider_default: None,
}), }),
setup_hints: vec![],
sandbox: None, sandbox: None,
tags: vec![], tags: vec![],
installable: false, installable: false,
+1
View File
@@ -200,6 +200,7 @@ mod tests {
provider: None, provider: None,
model: None, model: None,
config: None, config: None,
setup_hints: vec![],
sandbox: None, sandbox: None,
tags: tags.iter().map(|s| s.to_string()).collect(), tags: tags.iter().map(|s| s.to_string()).collect(),
installable: false, installable: false,
+6
View File
@@ -467,6 +467,12 @@ pub struct AgentDef {
/// Post-install provider configuration (issue #91). /// Post-install provider configuration (issue #91).
#[serde(default)] #[serde(default)]
pub config: Option<AgentConfig>, pub config: Option<AgentConfig>,
/// Manual setup commands shown after install when the agent manages its
/// own configuration (onboard/model/auth...). Placeholders {model},
/// {provider} and {base_url} are substituted with the resolved values
/// (v1.1.4).
#[serde(default)]
pub setup_hints: Vec<String>,
/// Sandbox profile (issue #79): allowed commands, working directories /// Sandbox profile (issue #79): allowed commands, working directories
/// and network policy. Disabled by default — the unsandboxed mode /// and network policy. Disabled by default — the unsandboxed mode
/// stays available and documented. /// stays available and documented.
+1
View File
@@ -214,6 +214,7 @@ pub const CATALOG: &[(&str, &str)] = &[
("provider {} (modèle {}) au lancement de {}", "provider {} (model {}) when launching {}"), ("provider {} (modèle {}) au lancement de {}", "provider {} (model {}) when launching {}"),
("agent sans adaptateur de config — voir sa doc", "agent without a config adapter — see its docs"), ("agent sans adaptateur de config — voir sa doc", "agent without a config adapter — see its docs"),
("config par variables d'env — injectée au démarrage", "env-var configuration — injected at start"), ("config par variables d'env — injectée au démarrage", "env-var configuration — injected at start"),
("Configuration manuelle requise — à exécuter :", "Manual setup required — run:"),
("aucun provider résolu — fichiers de config non écrits", "no provider resolved — config files not written"), ("aucun provider résolu — fichiers de config non écrits", "no provider resolved — config files not written"),
("provider '{}' introuvable — fichiers de config non écrits", "provider '{}' not found — config files not written"), ("provider '{}' introuvable — fichiers de config non écrits", "provider '{}' not found — config files not written"),
("format inconnu pour {} — adaptateurs: toml, yaml, json, key=value", "unknown format for {} — adapters: toml, yaml, json, key=value"), ("format inconnu pour {} — adaptateurs: toml, yaml, json, key=value", "unknown format for {} — adapters: toml, yaml, json, key=value"),