Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 3 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
10 changes: 3 additions & 7 deletions docs/superpowers/plans/2026-05-13-confluence-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
```

Expand Down
193 changes: 186 additions & 7 deletions src/commands/config_init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand All @@ -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<String, AppError> {
Expand Down Expand Up @@ -75,6 +89,105 @@ fn choose_default_space(spaces: &[Space]) -> Result<Option<String>, 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<SkillsInstallOutcome, AppError> {
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<SkillsInstallOutcome, AppError> {
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,
Expand Down Expand Up @@ -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::<Vec<_>>(),
));
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);
}
}
6 changes: 4 additions & 2 deletions tests/config_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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());
Expand Down Expand Up @@ -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()
Expand All @@ -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"));
}

Expand Down
Loading