From 91cd92103685dfbc7b824523d6123c8e9ec6f603 Mon Sep 17 00:00:00 2001 From: -LAN- Date: Fri, 15 May 2026 03:11:55 +0800 Subject: [PATCH] feat(config): prompt to install skills during init --- README.md | 10 +- .../plans/2026-05-13-confluence-cli.md | 10 +- src/commands/config_init.rs | 193 +++++++++++++++++- tests/config_contract.rs | 6 +- 4 files changed, 196 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 8f6deaf..664c65d 100644 --- a/README.md +++ b/README.md @@ -46,16 +46,12 @@ cargo build --release ## Agent Skills Package -If you are using an Agent, install the companion Skills package after installing the CLI binary: +If you are using an Agent, `confluence-cli config init` asks whether to install the companion Skills package after saving your config. Press Enter to install it, or enter `n` to skip. -```text -skills/confluence-cli -``` - -Install it with the Skills installer used by your Agent environment: +To install or reinstall it manually: ```bash -npx skills install ./skills/confluence-cli +npx skills add laipz8200/confluence-cli --skill confluence-cli ``` The skill instructs Agents to use dry-runs first, summarize planned writes, and ask for explicit approval before adding `--execute`. diff --git a/docs/superpowers/plans/2026-05-13-confluence-cli.md b/docs/superpowers/plans/2026-05-13-confluence-cli.md index e78c8fa..994f500 100644 --- a/docs/superpowers/plans/2026-05-13-confluence-cli.md +++ b/docs/superpowers/plans/2026-05-13-confluence-cli.md @@ -2586,16 +2586,12 @@ Agents should run write commands without `--execute` first, inspect the returned ## Skills Package -The repository includes a generic Skills package at: +`confluence-cli config init` asks whether to install the companion Skills package after saving your config. Press Enter to install it, or enter `n` to skip. -```text -skills/confluence-cli -``` - -Install it with the Skills installer used by your Agent environment, for example: +To install or reinstall it manually: ```bash -npx skills install ./skills/confluence-cli +npx skills add laipz8200/confluence-cli --skill confluence-cli ``` ``` diff --git a/src/commands/config_init.rs b/src/commands/config_init.rs index e2ab38e..cf7703d 100644 --- a/src/commands/config_init.rs +++ b/src/commands/config_init.rs @@ -4,8 +4,18 @@ use crate::config::{config_path, save_config, Config}; use crate::error::{AppError, ErrorCode}; use clap::Args; use std::io::{self, Write}; +use std::path::Path; +use std::process::Command; pub const COMMAND: &str = "config.init"; +const SKILLS_INSTALL_PROGRAM: &str = "npx"; +const SKILLS_INSTALL_ARGS: [&str; 5] = [ + "skills", + "add", + "laipz8200/confluence-cli", + "--skill", + "confluence-cli", +]; #[derive(Debug, Args)] pub struct ConfigInitArgs {} @@ -30,14 +40,18 @@ pub async fn run(_args: ConfigInitArgs) -> CommandResult { let path = config_path()?; save_config(&path, &config)?; + let skills_install = + prompt_and_install_skills().map_err(|error| after_config_save_error(error, &path))?; - Ok(CommandOutput::text( - COMMAND, - format!( - "Congratulations, setup is complete.\nConfig saved to: {}", - path.display() - ), - )) + let mut message = format!( + "Congratulations, setup is complete.\nConfig saved to: {}", + path.display() + ); + if skills_install == SkillsInstallOutcome::Installed { + message.push_str("\nAgent Skills package installed."); + } + + Ok(CommandOutput::text(COMMAND, message)) } fn prompt(label: &str) -> Result { @@ -75,6 +89,105 @@ fn choose_default_space(spaces: &[Space]) -> Result, AppError> { choose_default_space_with_io(spaces, &mut input, &mut output) } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SkillsInstallOutcome { + Installed, + Skipped, +} + +fn prompt_and_install_skills() -> Result { + let stdin = io::stdin(); + let mut input = stdin.lock(); + let stderr = io::stderr(); + let mut prompt_output = stderr.lock(); + + prompt_and_install_skills_with_io(&mut input, &mut prompt_output, run_skills_install_command) +} + +fn prompt_and_install_skills_with_io( + input: &mut impl io::BufRead, + prompt_output: &mut impl Write, + mut installer: impl FnMut(&str, &[&str]) -> Result<(), AppError>, +) -> Result { + loop { + write!( + prompt_output, + "Install the companion Agent Skills package now? [Y/n]: " + ) + .map_err(prompt_write_error)?; + prompt_output.flush().map_err(|source| { + AppError::new( + ErrorCode::ConfigInvalid, + format!("Failed to flush prompt: {source}"), + ) + })?; + + let mut value = String::new(); + let bytes_read = input.read_line(&mut value).map_err(|source| { + AppError::new( + ErrorCode::ConfigInvalid, + format!("Failed to read Agent Skills install choice: {source}"), + ) + })?; + if bytes_read == 0 { + return Ok(SkillsInstallOutcome::Skipped); + } + + let value = value.trim(); + if value.is_empty() || value.eq_ignore_ascii_case("y") || value.eq_ignore_ascii_case("yes") + { + installer(SKILLS_INSTALL_PROGRAM, &SKILLS_INSTALL_ARGS)?; + return Ok(SkillsInstallOutcome::Installed); + } + + if value.eq_ignore_ascii_case("n") || value.eq_ignore_ascii_case("no") { + return Ok(SkillsInstallOutcome::Skipped); + } + + writeln!(prompt_output, "Please answer y or n.").map_err(prompt_write_error)?; + } +} + +fn run_skills_install_command(program: &str, args: &[&str]) -> Result<(), AppError> { + let status = Command::new(program) + .args(args) + .status() + .map_err(|source| { + AppError::new( + ErrorCode::InternalError, + format!( + "Failed to start Agent Skills installer `{}`: {source}", + skills_install_command() + ), + ) + })?; + + if status.success() { + return Ok(()); + } + + Err(AppError::new( + ErrorCode::InternalError, + format!( + "Agent Skills installer `{}` exited with {status}.", + skills_install_command() + ), + )) +} + +fn skills_install_command() -> String { + format!( + "{} {}", + SKILLS_INSTALL_PROGRAM, + SKILLS_INSTALL_ARGS.join(" ") + ) +} + +fn after_config_save_error(mut error: AppError, path: &Path) -> AppError { + error.message = format!("Config saved to: {}. {}", path.display(), error.message); + error +} + fn choose_default_space_with_io( spaces: &[Space], input: &mut impl io::BufRead, @@ -241,4 +354,70 @@ mod tests { assert_eq!(selected, None); } + + #[test] + fn skills_install_prompt_defaults_enter_to_install() { + let mut input = "\n".as_bytes(); + let mut prompt_output = Vec::new(); + let mut calls = Vec::new(); + + let outcome = super::prompt_and_install_skills_with_io( + &mut input, + &mut prompt_output, + |program, args| { + calls.push(( + program.to_string(), + args.iter().map(|arg| arg.to_string()).collect::>(), + )); + Ok(()) + }, + ) + .unwrap(); + + assert_eq!(outcome, super::SkillsInstallOutcome::Installed); + assert_eq!( + calls, + vec![( + "npx".to_string(), + vec![ + "skills".to_string(), + "add".to_string(), + "laipz8200/confluence-cli".to_string(), + "--skill".to_string(), + "confluence-cli".to_string(), + ], + )] + ); + assert!(String::from_utf8(prompt_output) + .unwrap() + .contains("Install the companion Agent Skills package now? [Y/n]")); + } + + #[test] + fn skills_install_prompt_skips_when_user_says_no() { + let mut input = "n\n".as_bytes(); + let mut prompt_output = Vec::new(); + + let outcome = + super::prompt_and_install_skills_with_io(&mut input, &mut prompt_output, |_, _| { + panic!("installer should not run when user says no"); + }) + .unwrap(); + + assert_eq!(outcome, super::SkillsInstallOutcome::Skipped); + } + + #[test] + fn skills_install_prompt_skips_on_eof_without_running_installer() { + let mut input = "".as_bytes(); + let mut prompt_output = Vec::new(); + + let outcome = + super::prompt_and_install_skills_with_io(&mut input, &mut prompt_output, |_, _| { + panic!("installer should not run on EOF"); + }) + .unwrap(); + + assert_eq!(outcome, super::SkillsInstallOutcome::Skipped); + } } diff --git a/tests/config_contract.rs b/tests/config_contract.rs index 95821a7..33edfdc 100644 --- a/tests/config_contract.rs +++ b/tests/config_contract.rs @@ -203,7 +203,7 @@ async fn config_init_lists_spaces_and_selects_default_by_number() { .arg("init") .env("CONFLUENCE_CLI_CONFIG", &path) .write_stdin(format!( - "{}/\nuser@example.com\ntoken-value\n1\n", + "{}/\nuser@example.com\ntoken-value\n1\nn\n", server.uri() )) .output() @@ -223,6 +223,7 @@ async fn config_init_lists_spaces_and_selects_default_by_number() { assert!(stderr.contains("API token")); assert!(stderr.contains("Engineering")); assert!(stderr.contains("Documentation")); + assert!(stderr.contains("Install the companion Agent Skills package now? [Y/n]")); let loaded = load_config(&path).unwrap(); assert_eq!(loaded.site_url, server.uri()); @@ -261,7 +262,7 @@ async fn config_init_allows_no_spaces_without_default_space() { .arg("init") .env("CONFLUENCE_CLI_CONFIG", &path) .write_stdin(format!( - "{}/\nuser@example.com\ntoken-value\n", + "{}/\nuser@example.com\ntoken-value\nn\n", server.uri() )) .output() @@ -277,6 +278,7 @@ async fn config_init_allows_no_spaces_without_default_space() { assert!(stdout.contains("Congratulations")); assert!(stdout.contains(path.to_str().unwrap())); assert!(stderr.contains("No accessible spaces")); + assert!(stderr.contains("Install the companion Agent Skills package now? [Y/n]")); assert!(!written.contains("default_space")); }