feat(cli): restore skills install step and --target quick mode in setup (#499)

* feat(cli): restore skills install step and --target quick mode in setup

- Add setup/skills.rs porting the `npx skills add actionbook/actionbook`
  integration from the previous actionbook-rs CLI. Defines SetupTarget
  enum, SkillsResult, and both wizard-flow and quick-mode install paths.
- Wire Step 5 "Install Skills" into the setup wizard (TOTAL_STEPS 4 -> 5),
  running after Save. Non-interactive / JSON mode auto-installs with -y;
  interactive mode prompts Install / Skip.
- Add quick mode: `actionbook setup --target <agent>` skips the full
  wizard and only runs skills install for the specified target. Restores
  the CI / one-shot bootstrap path. Standalone target is special-cased
  to skip entirely (no agent integration needed).
- Change --target flag from Option<String> to Option<SetupTarget> (clap
  ValueEnum, kebab-case values: claude, codex, cursor, windsurf,
  antigravity, opencode, standalone, all). Unknown values rejected at
  parse time.
- Propagate skills install failure as CliError so CI / non-interactive
  callers see a non-zero exit.

Scope: only packages/cli/src/setup/ plus the cli.rs test block. No
changes to browser, daemon, config, error types, or Cargo.toml.

* feat(cli): show API key input and guide extension install to CWS / GitHub Releases

- API key prompt switches from `Password` to `Input` so typed characters
  echo by default. Users reported the hidden-by-default behavior felt
  broken when pasting. `allow_empty` + the validation loop are unchanged.

- Extension mode now guides the user to install the Chrome extension:
  - Primary: Chrome Web Store (recommended, one-click).
  - Fallback: manual install from GitHub Releases when CWS is
    unavailable (region-blocked, offline, corporate policy). 5-step
    Load-unpacked flow covers download -> unzip -> chrome://extensions
    -> Developer mode -> Load unpacked.
  - Interactive flow asks "Installed from CWS?" -> if no, shows the
    GitHub Releases path and asks "Loaded the unpacked extension?"
    -> if no, errors with both URLs listed for out-of-band install.
  - Non-interactive (`--browser extension`) text output lists both
    URLs; JSON payload gains `recommended_install_source`,
    `web_store_url`, `fallback_install_source`, `github_releases_url`.

- The GitHub Releases URL uses a tag-search query
  `?q=%22Chrome+Extension%22&expanded=true` so users land directly on
  extension releases (the repo mixes `actionbook-cli-v*`,
  `actionbook-extension-v*`, and `actionbook-dify-plugin-v*` in the
  same feed — a plain /releases URL buries the extension zip).

Tests: 292 lib passed. New: `test_chrome_web_store_url_is_canonical`,
`test_github_releases_url_is_extension_filtered`,
`test_apply_extension_mode_records_web_store_hint_in_json`.

* fix(cli): address Codex P1 review feedback on setup skills step

Two P1 issues flagged by Codex automated review on PR #499:

1. setup/skills.rs: JSON mode deadlock risk when `npx skills add`
   output exceeds the OS pipe buffer (~16KB on macOS). The code used
   `Stdio::piped()` for child stdout/stderr but then called `.status()`
   instead of `.output()`, which does not drain the pipes. A verbose
   skills install (common on first run) would fill the buffer and block
   the child on write, hanging setup indefinitely in CI. Switch to
   `Stdio::null()` in JSON mode to discard subprocess output entirely —
   we already emit our own JSON status event, so the subprocess output
   has no consumer anyway. Also close stdin in JSON mode (`Stdio::null`)
   so a stray read attempt can't hang either.

2. setup/mod.rs: quick mode (`setup --target <agent>`) silently exited
   with status 0 when npx was not available. `install_skills_for_target`
   returns `SkillsAction::Prompted` in that case, but `run_target_only`
   only mapped `SkillsAction::Failed` to an error. CI bootstrap relying
   on `--target` would silently pass broken runs. Extract a pure helper
   `target_only_exit_status` that maps every non-`Installed` outcome to
   `CliError::Internal` with a target-specific message; in the `Prompted`
   branch the message points at the Node.js install docs and the exact
   `npx` command to retry manually. The full-wizard Skills step is
   unchanged — there `Prompted` is still a soft prompt because the user
   can finish setup without skills.

Add 4 unit tests on `target_only_exit_status` covering all four
`SkillsAction` variants, including a regression guard for the P1 #2
silent-success case.

Tests: 296 lib passed (71 in setup::, 4 new).

* fix(cli): tighten setup target quick mode semantics

* fix(cli): install setup target all for all agents

* style(cli): format setup quick mode tests and JSON output

* chore(cli): add changeset for setup improvements
This commit is contained in:
sen
2026-04-11 16:22:45 +08:00
committed by GitHub
parent 32035a7a91
commit 3092c612dc
7 changed files with 928 additions and 66 deletions
+11
View File
@@ -0,0 +1,11 @@
---
"@actionbookdev/cli": minor
---
Improve `actionbook setup` with a new skills installation step and targeted quick mode.
- add a fifth setup step to install Actionbook skills during setup
- add `actionbook setup --target <agent>` quick mode for one-shot skills installation
- improve extension-mode setup guidance with Chrome Web Store and GitHub Releases fallback instructions
- make API key input visible by default during interactive setup
- tighten setup failure handling so quick mode and JSON flows report skills install failures correctly
+1 -1
View File
@@ -409,7 +409,7 @@ Interactive configuration wizard.
| Parameter | Type | Required | Description |
|------|------|------|------|
| `--target` | string | No | Configuration target |
| `--target` | string | No | Quick mode only: install skills for the specified agent and skip the setup wizard. Conflicts with `--api-key`, `--browser`, and `--reset`. |
| `--api-key` | string | No | API Key |
| `--browser` | string | No | Browser configuration |
| `--non-interactive` | bool | No | Non-interactive mode |
+59 -17
View File
@@ -752,31 +752,73 @@ mod tests {
use super::*;
#[test]
fn try_parse_from_parses_setup_non_interactive_flags() {
let cli = Cli::try_parse_from([
fn try_parse_from_parses_setup_target_only_flags() {
let cli = Cli::try_parse_from(["actionbook", "setup", "--target", "codex"])
.expect("parse setup target-only");
match cli.command {
Some(Commands::Setup(cmd)) => {
assert_eq!(cmd.target, Some(setup::skills::SetupTarget::Codex));
assert!(cmd.api_key.is_none());
assert!(cmd.browser.is_none());
assert!(!cmd.non_interactive);
assert!(!cmd.reset);
}
other => panic!("expected setup command, got {other:?}"),
}
}
#[test]
fn try_parse_from_parses_setup_target_short_flag() {
let cli = Cli::try_parse_from(["actionbook", "setup", "-t", "claude"])
.expect("parse setup quick mode");
match cli.command {
Some(Commands::Setup(cmd)) => {
assert_eq!(cmd.target, Some(setup::skills::SetupTarget::Claude));
assert!(cmd.api_key.is_none());
assert!(!cmd.non_interactive);
}
other => panic!("expected setup command, got {other:?}"),
}
}
#[test]
fn try_parse_from_rejects_unknown_setup_target() {
let result = Cli::try_parse_from(["actionbook", "setup", "--target", "vim"]);
assert!(result.is_err(), "expected clap to reject unknown target");
}
#[test]
fn try_parse_from_rejects_setup_target_with_api_key() {
let result = Cli::try_parse_from([
"actionbook",
"setup",
"--target",
"codex",
"--api-key",
"sk-test",
"sk",
]);
assert!(result.is_err(), "expected clap to reject conflicting flags");
}
#[test]
fn try_parse_from_rejects_setup_target_with_browser() {
let result = Cli::try_parse_from([
"actionbook",
"setup",
"--target",
"codex",
"--browser",
"local",
"--non-interactive",
"--reset",
])
.expect("parse setup");
]);
assert!(result.is_err(), "expected clap to reject conflicting flags");
}
match cli.command {
Some(Commands::Setup(cmd)) => {
assert_eq!(cmd.target.as_deref(), Some("codex"));
assert_eq!(cmd.api_key.as_deref(), Some("sk-test"));
assert_eq!(cmd.browser.as_deref(), Some("local"));
assert!(cmd.non_interactive);
assert!(cmd.reset);
}
other => panic!("expected setup command, got {other:?}"),
}
#[test]
fn try_parse_from_rejects_setup_target_with_reset() {
let result = Cli::try_parse_from(["actionbook", "setup", "--target", "codex", "--reset"]);
assert!(result.is_err(), "expected clap to reject conflicting flags");
}
#[test]
+4 -4
View File
@@ -1,4 +1,4 @@
use dialoguer::{Confirm, Password};
use dialoguer::{Confirm, Input};
use super::detect::EnvironmentInfo;
use super::theme::setup_theme;
@@ -67,10 +67,10 @@ pub(crate) async fn configure_api_key(
}
loop {
let key = Password::with_theme(&setup_theme())
let key: String = Input::with_theme(&setup_theme())
.with_prompt("API key (leave blank to skip)")
.allow_empty_password(true)
.interact()
.allow_empty(true)
.interact_text()
.map_err(|e| CliError::Internal(format!("Prompt failed: {e}")))?;
if key.trim().is_empty() {
+152 -13
View File
@@ -6,6 +6,25 @@ use crate::config::ConfigFile;
use crate::error::CliError;
use crate::types::Mode;
/// Canonical Chrome Web Store listing for the Actionbook extension.
const CHROME_WEB_STORE_URL: &str =
"https://chromewebstore.google.com/detail/actionbook/bebchpafpemheedhcdabookaifcijmfo";
/// GitHub Releases page (filtered to extension releases only) — used as
/// the manual-install fallback when the Chrome Web Store install is unavailable
/// (region blocked, offline, corporate policy). Users grab the latest
/// `actionbook-extension-v*.zip`, unzip, and `chrome://extensions` -> Load unpacked.
///
/// Why the `?q="Chrome Extension"&expanded=true` query: this repo publishes
/// three release families (`actionbook-cli-v*`, `actionbook-extension-v*`,
/// `actionbook-dify-plugin-v*`) to the same Releases feed. A plain `/releases`
/// URL buries the extension zip under dozens of CLI releases. Searching for
/// the quoted phrase "Chrome Extension" matches the extension release titles,
/// and `expanded=true` auto-expands the first match so assets are visible
/// without clicking. `%22` is the URL-encoded double quote.
const GITHUB_RELEASES_URL: &str =
"https://github.com/actionbook/actionbook/releases?q=%22Chrome+Extension%22&expanded=true";
/// Configure browser mode (local vs cloud), executable, and headless preference.
pub(crate) async fn configure_browser(
json: bool,
@@ -34,6 +53,9 @@ pub(crate) async fn configure_browser(
}
}
/// Configure extension mode: guide the user to install from Chrome Web Store,
/// falling back to a manual GitHub Releases + Load-unpacked flow if the CWS
/// install is unavailable (region-blocked, offline, corporate policy, etc.).
fn configure_extension(json: bool, config: &mut ConfigFile) -> Result<(), CliError> {
config.browser.executable_path = None;
config.browser.headless = false;
@@ -45,13 +67,81 @@ fn configure_extension(json: bool, config: &mut ConfigFile) -> Result<(), CliErr
serde_json::json!({
"step": "browser",
"mode": "extension",
"recommended_install_source": "chrome_web_store",
"web_store_url": CHROME_WEB_STORE_URL,
"fallback_install_source": "github_releases",
"github_releases_url": GITHUB_RELEASES_URL,
})
);
} else {
println!(" - Browser mode: extension");
return Ok(());
}
Ok(())
println!(" - Browser mode: extension");
print_cws_guidance();
if select_yes_no("Installed from Chrome Web Store successfully?", true)? {
println!(" - Extension mode will use your Chrome extension directly.");
return Ok(());
}
// CWS unavailable — fall through to manual install from GitHub Releases.
print_github_releases_guidance();
if select_yes_no(
"Loaded the unpacked extension in Chrome successfully?",
true,
)? {
println!(" - Extension mode will use your manually-loaded Chrome extension.");
return Ok(());
}
Err(CliError::InvalidArgument(format!(
"Extension setup not completed. Install the Actionbook extension from one of:\n \
- Chrome Web Store: {CHROME_WEB_STORE_URL}\n \
- GitHub Releases: {GITHUB_RELEASES_URL}\n\
and re-run `actionbook setup`. If you don't need your existing Chrome session, \
choose `local` or `cloud` mode instead."
)))
}
/// Print the Chrome Web Store install guidance (3 steps).
fn print_cws_guidance() {
println!(" |");
println!(" | Install the Actionbook extension from the Chrome Web Store:");
println!(" | 1. Open {CHROME_WEB_STORE_URL} in Chrome");
println!(" | 2. Click \"Add to Chrome\" -> \"Add extension\"");
println!(" | 3. Keep Chrome open and run `actionbook browser open https://example.com`");
println!(" |");
}
/// Print the GitHub Releases manual-install fallback (5 steps: download,
/// unzip, open chrome://extensions, enable Developer mode, Load unpacked).
fn print_github_releases_guidance() {
println!(" |");
println!(" | Chrome Web Store unavailable? Install manually from GitHub Releases:");
println!(" | 1. Open {GITHUB_RELEASES_URL}");
println!(" | 2. Download the latest `actionbook-extension-v*.zip` asset");
println!(" | 3. Unzip to a local folder");
println!(" | 4. Open `chrome://extensions` in Chrome, enable Developer mode");
println!(" | 5. Click \"Load unpacked\" and select the unzipped folder");
println!(" |");
}
/// Visible yes/no picker. Uses `Select` instead of `Confirm` so it behaves
/// consistently in terminals where `Confirm`'s raw-mode input is flaky.
fn select_yes_no(prompt: &str, default_yes: bool) -> Result<bool, CliError> {
let options = ["yes", "no"];
let default = if default_yes { 0 } else { 1 };
let selection = Select::with_theme(&setup_theme())
.with_prompt(prompt)
.items(&options)
.default(default)
.report(false)
.interact()
.map_err(|e| CliError::Internal(format!("Prompt failed: {e}")))?;
Ok(selection == 0)
}
fn configure_cloud(json: bool, config: &mut ConfigFile) -> Result<(), CliError> {
@@ -150,10 +240,16 @@ fn apply_existing_browser_mode(
serde_json::json!({
"step": "browser",
"mode": "extension",
"recommended_install_source": "chrome_web_store",
"web_store_url": CHROME_WEB_STORE_URL,
"fallback_install_source": "github_releases",
"github_releases_url": GITHUB_RELEASES_URL,
})
);
} else {
println!(" - Browser mode: extension");
println!(" | Install extension from Chrome Web Store: {CHROME_WEB_STORE_URL}");
println!(" | Or manual install from GitHub Releases: {GITHUB_RELEASES_URL}");
}
}
}
@@ -306,16 +402,23 @@ fn apply_browser_mode(
}
if json {
println!(
"{}",
serde_json::json!({
"step": "browser",
"mode": format!("{}", mode),
"executable": config.browser.executable_path,
"headless": config.browser.headless,
"cdp_endpoint": config.browser.cdp_endpoint,
})
);
let mut payload = serde_json::json!({
"step": "browser",
"mode": format!("{}", mode),
"executable": config.browser.executable_path,
"headless": config.browser.headless,
"cdp_endpoint": config.browser.cdp_endpoint,
});
if mode == Mode::Extension {
payload["recommended_install_source"] =
serde_json::Value::String("chrome_web_store".to_string());
payload["web_store_url"] = serde_json::Value::String(CHROME_WEB_STORE_URL.to_string());
payload["fallback_install_source"] =
serde_json::Value::String("github_releases".to_string());
payload["github_releases_url"] =
serde_json::Value::String(GITHUB_RELEASES_URL.to_string());
}
println!("{}", payload);
} else {
let mode_label = match mode {
Mode::Local => "local".to_string(),
@@ -323,6 +426,10 @@ fn apply_browser_mode(
Mode::Extension => "extension".to_string(),
};
println!(" - Browser mode: {mode_label}");
if mode == Mode::Extension {
println!(" | Install extension from Chrome Web Store: {CHROME_WEB_STORE_URL}");
println!(" | Or manual install from GitHub Releases: {GITHUB_RELEASES_URL}");
}
}
Ok(())
@@ -512,6 +619,38 @@ mod tests {
);
}
#[test]
fn test_chrome_web_store_url_is_canonical() {
assert!(CHROME_WEB_STORE_URL.starts_with("https://chromewebstore.google.com/detail/"));
assert!(CHROME_WEB_STORE_URL.contains("actionbook"));
}
#[test]
fn test_github_releases_url_is_extension_filtered() {
// The repo mixes CLI/extension/dify-plugin releases. The URL must
// filter to extension releases so users don't have to scroll past
// dozens of CLI releases to find the .zip.
assert!(
GITHUB_RELEASES_URL.starts_with("https://github.com/actionbook/actionbook/releases")
);
// Quoted phrase "Chrome Extension" (URL-encoded)
assert!(GITHUB_RELEASES_URL.contains("%22Chrome+Extension%22"));
assert!(GITHUB_RELEASES_URL.contains("expanded=true"));
}
#[test]
fn test_apply_extension_mode_records_web_store_hint_in_json() {
// The non-interactive --browser extension path must still guide users
// to the Chrome Web Store. This guards against silent regression.
let env = make_env_with_browsers(vec![]);
let mut config = ConfigFile::default();
let result = apply_browser_mode(true, &env, Mode::Extension, &mut config);
assert!(result.is_ok());
assert_eq!(config.browser.mode, Mode::Extension);
assert!(config.browser.executable_path.is_none());
}
#[test]
fn test_browser_options_keep_auto_detect_as_default_when_current_is_none() {
let browser = BrowserInfo {
+227 -31
View File
@@ -1,6 +1,7 @@
pub mod api_key;
pub mod browser_cfg;
pub mod detect;
pub mod skills;
pub mod theme;
use std::time::Duration;
@@ -9,6 +10,7 @@ use clap::Args;
use dialoguer::Select;
use indicatif::{ProgressBar, ProgressStyle};
use self::skills::{SetupTarget, SkillsAction, SkillsResult};
use self::theme::setup_theme;
use crate::config::{self, ConfigFile};
use crate::error::CliError;
@@ -16,11 +18,20 @@ use crate::types::Mode;
#[derive(Args, Debug, Clone, Default, PartialEq, Eq)]
pub struct Cmd {
/// Configuration target
#[arg(long)]
pub target: Option<String>,
/// AI coding tool target. When set, skips the wizard and only installs
/// skills for the given agent via `npx skills add` (quick mode).
///
/// Mutually exclusive with full setup options like `--api-key`,
/// `--browser`, and `--reset` to avoid silently ignoring them.
#[arg(
short = 't',
long,
value_enum,
conflicts_with_all = ["api_key", "browser", "reset"]
)]
pub target: Option<SetupTarget>,
/// API key
/// API key (non-interactive). Overrides the global --api-key / ACTIONBOOK_API_KEY.
#[arg(long)]
pub api_key: Option<String>,
@@ -28,21 +39,31 @@ pub struct Cmd {
#[arg(long)]
pub browser: Option<String>,
/// Non-interactive mode
/// Skip all interactive prompts. Requires that every value be resolvable
/// from flags, env vars, or an existing config.
#[arg(long)]
pub non_interactive: bool,
/// Reset configuration
/// Reset existing configuration and start fresh
#[arg(long)]
pub reset: bool,
}
const TOTAL_STEPS: u8 = 4;
const TOTAL_STEPS: u8 = 5;
/// Run the setup wizard. Orchestrates all steps in order.
///
/// Quick mode: if `--target` is set, the full wizard is skipped and only
/// `npx skills add` runs for the specified agent. This matches the CI /
/// non-interactive "one-shot install" path from the previous CLI.
pub async fn execute(cmd: &Cmd, json: bool) -> Result<(), CliError> {
let non_interactive = cmd.non_interactive || json;
// Quick mode: --target only → install skills for that target and exit.
if let Some(target) = cmd.target {
return run_target_only(json, target);
}
// Handle existing config (re-run protection)
let mut config = handle_existing_config(json, non_interactive, cmd.reset)?;
@@ -125,15 +146,115 @@ pub async fn execute(cmd: &Cmd, json: bool) -> Result<(), CliError> {
println!(" - Configuration saved to {}", path.display());
}
// TODO: Step 5: Health check (API connectivity) — requires ApiClient
// TODO: Step 6: Install Skills — requires SetupTarget + npx skills integration
// TODO: Health check (API connectivity) — requires ApiClient
// Step 5: Install Skills
if !json {
print_step_connector();
print_step_header(5, "Skills");
}
let skills_result = skills::install_skills(json, &env, non_interactive)?;
// Completion summary
print_completion(json, &config);
print_completion(json, &config, &skills_result);
// Propagate skills failure so non-interactive / CI callers see a non-zero exit.
if skills_result.action == SkillsAction::Failed {
return Err(CliError::Internal(
"Skills installation failed.".to_string(),
));
}
Ok(())
}
/// Quick mode: only install skills for the given target via `npx skills add`.
/// Used by `actionbook setup --target <agent>` for one-shot CI / bootstrap runs.
fn run_target_only(json: bool, target: SetupTarget) -> Result<(), CliError> {
// Standalone = CLI only, no agent integration.
if target == SetupTarget::Standalone {
if json {
println!(
"{}",
serde_json::json!({
"command": "setup",
"mode": "target_only",
"target": "Standalone CLI",
"action": "skipped",
"reason": "no_agent_integration_needed",
})
);
} else {
println!();
println!(" - Standalone CLI requires no skills integration.");
println!(" Run `actionbook setup` to configure the CLI.");
println!();
}
return Ok(());
}
if !json {
println!();
println!(
" + Installing skills for {}",
skills::target_display_name(&target)
);
println!(" |");
}
let result = skills::install_skills_for_target(json, &target)?;
if json {
println!(
"{}",
serde_json::json!({
"command": "setup",
"mode": "target_only",
"target": skills::target_display_name(&target),
"npx_available": result.npx_available,
"action": format!("{}", result.action),
"skills_command": result.command,
})
);
} else if result.action == SkillsAction::Installed {
println!(" + Done!");
println!();
}
target_only_exit_status(&result, &target)
}
/// Decide the exit status of `run_target_only` based on the skills outcome.
///
/// Quick mode is an explicit "install for this agent now" request, so any
/// outcome other than `Installed` must surface as an error. This differs from
/// the full-wizard Skills step, which treats `Prompted` (npx missing) as a
/// soft prompt — there the user can still complete setup by hand. In quick
/// mode the user's entire intent was to install; there's no other work to do.
fn target_only_exit_status(
result: &skills::SkillsResult,
target: &SetupTarget,
) -> Result<(), CliError> {
match result.action {
SkillsAction::Installed => Ok(()),
SkillsAction::Failed => Err(CliError::Internal(format!(
"Skills installation failed for {}.",
skills::target_display_name(target)
))),
SkillsAction::Prompted => Err(CliError::Internal(format!(
"Skills installation skipped for {}: npx is not available. \
Install Node.js (https://nodejs.org) and re-run, or run \
`{}` manually.",
skills::target_display_name(target),
result.command,
))),
SkillsAction::Skipped => Err(CliError::Internal(format!(
"Skills installation skipped for {}.",
skills::target_display_name(target)
))),
}
}
/// Print a step header, e.g. `◆ Environment`
fn print_step_header(step: u8, title: &str) {
println!(" * {} ({}/{})", title, step, TOTAL_STEPS);
@@ -271,33 +392,52 @@ fn print_welcome() {
println!(" |");
}
fn setup_completion_status(skills_result: &SkillsResult) -> &'static str {
match skills_result.action {
SkillsAction::Failed => "failed",
SkillsAction::Installed | SkillsAction::Skipped | SkillsAction::Prompted => "complete",
}
}
/// Print the completion summary with next steps.
fn print_completion(json: bool, config: &ConfigFile) {
fn print_completion(json: bool, config: &ConfigFile, skills_result: &SkillsResult) {
if json {
println!(
"{}",
serde_json::json!({
"command": "setup",
"status": "complete",
"config_path": config::config_path().display().to_string(),
"browser_mode": format!("{}", config.browser.mode),
"browser": match config.browser.mode {
Mode::Local => config.browser.executable_path.as_deref().unwrap_or("built-in"),
Mode::Cloud => config
.browser
.cdp_endpoint
.as_deref()
.unwrap_or("endpoint not configured"),
Mode::Extension => "extension (bridge)",
},
"headless": config.browser.headless,
})
);
let mut payload = serde_json::json!({
"command": "setup",
"status": setup_completion_status(skills_result),
"config_path": config::config_path().display().to_string(),
"browser_mode": format!("{}", config.browser.mode),
"browser": match config.browser.mode {
Mode::Local => config.browser.executable_path.as_deref().unwrap_or("built-in"),
Mode::Cloud => config
.browser
.cdp_endpoint
.as_deref()
.unwrap_or("endpoint not configured"),
Mode::Extension => "extension (bridge)",
},
"headless": config.browser.headless,
"skills": {
"npx_available": skills_result.npx_available,
"action": format!("{}", skills_result.action),
"command": skills_result.command,
},
});
if skills_result.action == SkillsAction::Failed {
payload["error"] = serde_json::Value::String("Skills installation failed.".to_string());
}
println!("{}", payload);
return;
}
println!(" |");
println!(" + Setup completed.");
match skills_result.action {
SkillsAction::Installed => println!(" + Actionbook is ready!"),
SkillsAction::Failed => println!(" + Setup completed with errors."),
SkillsAction::Skipped | SkillsAction::Prompted => println!(" + Setup completed."),
}
// Configuration recap
let api_display = config
@@ -382,6 +522,62 @@ fn shorten_browser_path(path: &str) -> String {
mod tests {
use super::*;
fn make_skills_result(action: SkillsAction) -> skills::SkillsResult {
skills::SkillsResult {
npx_available: action != SkillsAction::Prompted,
action,
command: "npx skills add actionbook/actionbook -a claude-code".to_string(),
}
}
#[test]
fn target_only_installed_returns_ok() {
let result = make_skills_result(SkillsAction::Installed);
assert!(target_only_exit_status(&result, &SetupTarget::Claude).is_ok());
}
#[test]
fn target_only_failed_returns_err() {
let result = make_skills_result(SkillsAction::Failed);
let err = target_only_exit_status(&result, &SetupTarget::Claude)
.expect_err("failed must propagate");
assert!(err.to_string().contains("Claude Code"));
}
#[test]
fn target_only_prompted_returns_err_when_npx_missing() {
// P1 regression guard: quick mode must not silently succeed when
// npx is unavailable. `install_skills_for_target` returns Prompted
// in that case, and CI bootstrap relying on `--target` needs a
// non-zero exit to notice the missing prereq.
let result = make_skills_result(SkillsAction::Prompted);
let err = target_only_exit_status(&result, &SetupTarget::Codex)
.expect_err("prompted must propagate in quick mode");
assert!(err.to_string().contains("npx is not available"));
assert!(err.to_string().contains("Codex"));
}
#[test]
fn target_only_skipped_returns_err() {
// Skipped shouldn't happen in quick mode (auto_confirm=true is passed
// to run_npx_skills), but if it ever surfaces, quick mode must still
// fail loudly rather than silently exit 0.
let result = make_skills_result(SkillsAction::Skipped);
assert!(target_only_exit_status(&result, &SetupTarget::Cursor).is_err());
}
#[test]
fn setup_completion_status_is_complete_when_skills_install_succeeds() {
let result = make_skills_result(SkillsAction::Installed);
assert_eq!(setup_completion_status(&result), "complete");
}
#[test]
fn setup_completion_status_is_failed_when_skills_install_fails() {
let result = make_skills_result(SkillsAction::Failed);
assert_eq!(setup_completion_status(&result), "failed");
}
#[test]
fn parse_browser_flag_accepts_supported_values() {
assert_eq!(
+474
View File
@@ -0,0 +1,474 @@
use std::process::{Command, Stdio};
use clap::ValueEnum;
use dialoguer::Select;
use super::detect::EnvironmentInfo;
use super::theme::setup_theme;
use crate::error::CliError;
/// The skills package installed via `npx skills add`.
pub const SKILLS_PACKAGE: &str = "actionbook/actionbook";
/// AI coding tool target for skills installation.
///
/// Used by the `--target` flag to run `npx skills add` in quick mode
/// (bypassing the full setup wizard) and as the `-a` agent hint passed
/// through to the skills CLI.
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
#[value(rename_all = "kebab-case")]
pub enum SetupTarget {
/// Claude Code
Claude,
/// Codex
Codex,
/// Cursor
Cursor,
/// Windsurf
Windsurf,
/// Antigravity
Antigravity,
/// Opencode
Opencode,
/// Standalone CLI (no AI tool integration)
Standalone,
/// Install for all known agents
All,
}
/// Map a `SetupTarget` to the skills CLI `-a` agent flag value.
pub fn target_to_agent_flag(target: &SetupTarget) -> Option<&'static str> {
match target {
SetupTarget::Claude => Some("claude-code"),
SetupTarget::Codex => Some("codex"),
SetupTarget::Cursor => Some("cursor"),
SetupTarget::Windsurf => Some("windsurf"),
SetupTarget::Antigravity => Some("antigravity"),
SetupTarget::Opencode => Some("opencode"),
SetupTarget::Standalone => None,
SetupTarget::All => Some("*"),
}
}
/// Human-readable display name for a target.
pub fn target_display_name(t: &SetupTarget) -> &'static str {
match t {
SetupTarget::Claude => "Claude Code",
SetupTarget::Codex => "Codex",
SetupTarget::Cursor => "Cursor",
SetupTarget::Windsurf => "Windsurf",
SetupTarget::Antigravity => "Antigravity",
SetupTarget::Opencode => "Opencode",
SetupTarget::Standalone => "Standalone CLI",
SetupTarget::All => "All",
}
}
/// Build the `npx` subcommand arguments (without the `npx` prefix).
fn build_skills_command(target: Option<&SetupTarget>, auto_confirm: bool) -> Vec<String> {
let mut args = vec![
"skills".to_string(),
"add".to_string(),
SKILLS_PACKAGE.to_string(),
];
if let Some(t) = target
&& let Some(agent) = target_to_agent_flag(t)
{
args.push("-a".to_string());
args.push(agent.to_string());
}
if auto_confirm {
args.push("-y".to_string());
}
args
}
/// Format the full command string for display / logging.
fn format_skills_command(target: Option<&SetupTarget>) -> String {
let mut cmd = format!("npx skills add {}", SKILLS_PACKAGE);
if let Some(t) = target
&& let Some(agent) = target_to_agent_flag(t)
{
if agent == "*" {
cmd.push_str(" -a '*'");
} else {
cmd.push_str(&format!(" -a {}", agent));
}
}
cmd
}
/// Result of the skills installation step.
#[derive(Debug)]
pub struct SkillsResult {
pub npx_available: bool,
pub action: SkillsAction,
pub command: String,
}
#[derive(Debug, PartialEq, Eq)]
pub enum SkillsAction {
Installed,
Skipped,
Prompted,
Failed,
}
impl std::fmt::Display for SkillsAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SkillsAction::Installed => write!(f, "installed"),
SkillsAction::Skipped => write!(f, "skipped"),
SkillsAction::Prompted => write!(f, "prompted"),
SkillsAction::Failed => write!(f, "failed"),
}
}
}
/// Install skills via `npx skills add`. Used inside the full setup wizard.
///
/// - npx missing → print manual instructions, return `Prompted`.
/// - non-interactive → install silently with `-y`.
/// - interactive → ask the user whether to install now.
pub fn install_skills(
json: bool,
env: &EnvironmentInfo,
non_interactive: bool,
) -> Result<SkillsResult, CliError> {
let command_str = format_skills_command(None);
if !env.npx_available {
print_missing_npx(json, &command_str);
return Ok(SkillsResult {
npx_available: false,
action: SkillsAction::Prompted,
command: command_str,
});
}
if !json && !non_interactive {
println!(" | source: https://github.com/{}.git", SKILLS_PACKAGE);
println!(" |");
}
if non_interactive {
return run_npx_skills(json, None, true);
}
let choices = ["Install now (recommended)", "Skip"];
let selection = Select::with_theme(&setup_theme())
.with_prompt(" Install Actionbook skills for your AI coding tools?")
.items(&choices)
.default(0)
.report(false)
.interact()
.map_err(|e| CliError::Internal(format!("Prompt failed: {e}")))?;
match selection {
0 => run_npx_skills(json, None, false),
_ => {
if json {
println!(
"{}",
serde_json::json!({
"step": "skills",
"npx_available": true,
"action": "skipped",
"command": command_str,
})
);
} else {
println!(" | Skills installation skipped");
println!(" | Install later with: {}", command_str);
}
Ok(SkillsResult {
npx_available: true,
action: SkillsAction::Skipped,
command: command_str,
})
}
}
}
/// Quick mode: install skills for a specific target via `npx skills add`.
/// Skips the full setup wizard — only runs the skills step.
pub fn install_skills_for_target(
json: bool,
target: &SetupTarget,
) -> Result<SkillsResult, CliError> {
let npx_available = which::which("npx").is_ok();
let command_str = format_skills_command(Some(target));
if !npx_available {
print_missing_npx(json, &command_str);
return Ok(SkillsResult {
npx_available: false,
action: SkillsAction::Prompted,
command: command_str,
});
}
if !json {
println!(" | source: https://github.com/{}.git", SKILLS_PACKAGE);
println!(" |");
}
run_npx_skills(json, Some(target), true)
}
fn print_missing_npx(json: bool, command_str: &str) {
if json {
println!(
"{}",
serde_json::json!({
"step": "skills",
"npx_available": false,
"action": "prompted",
"command": command_str,
})
);
} else {
println!(" | npx not found");
println!(" | To install Actionbook skills manually, run:");
println!(" | $ {}", command_str);
println!(" | (requires Node.js: https://nodejs.org)");
}
}
/// Execute `npx skills add` as a child process.
///
/// In non-JSON mode stdio is inherited so the user sees the skills CLI
/// output directly. In JSON mode subprocess output is piped and discarded
/// to keep stdout a clean JSON stream.
fn run_npx_skills(
json: bool,
target: Option<&SetupTarget>,
auto_confirm: bool,
) -> Result<SkillsResult, CliError> {
let args = build_skills_command(target, auto_confirm);
let command_str = format_skills_command(target);
if !json {
println!(" | running: npx {}", args.join(" "));
println!(" |");
}
// In JSON mode discard subprocess output entirely (Stdio::null) to keep
// stdout a clean JSON stream. Piping + waiting via .status() would risk a
// deadlock when `npx skills add` exceeds the ~16KB pipe buffer (the
// "Installation Summary" block alone can exceed it). Using Stdio::null
// side-steps the issue without needing to drain buffers.
let (stdout_cfg, stderr_cfg) = if json {
(Stdio::null(), Stdio::null())
} else {
(Stdio::inherit(), Stdio::inherit())
};
let status = Command::new("npx")
.args(&args)
.stdin(if json {
Stdio::null()
} else {
Stdio::inherit()
})
.stdout(stdout_cfg)
.stderr(stderr_cfg)
.status();
match status {
Ok(exit) if exit.success() => {
if json {
println!(
"{}",
serde_json::json!({
"step": "skills",
"npx_available": true,
"action": "installed",
"command": command_str,
})
);
} else {
println!(" - Skills installed successfully");
}
Ok(SkillsResult {
npx_available: true,
action: SkillsAction::Installed,
command: command_str,
})
}
Ok(exit) => {
let code = exit.code().unwrap_or(-1);
if json {
println!(
"{}",
serde_json::json!({
"step": "skills",
"npx_available": true,
"action": "failed",
"command": command_str,
"exit_code": code,
})
);
} else {
println!(" ! Skills installation failed (exit code: {})", code);
println!(" | You can retry manually:");
println!(" | $ {}", command_str);
}
Ok(SkillsResult {
npx_available: true,
action: SkillsAction::Failed,
command: command_str,
})
}
Err(e) => {
if json {
println!(
"{}",
serde_json::json!({
"step": "skills",
"npx_available": true,
"action": "failed",
"command": command_str,
"error": e.to_string(),
})
);
} else {
println!(" ! Failed to run npx: {}", e);
println!(" | You can retry manually:");
println!(" | $ {}", command_str);
}
Ok(SkillsResult {
npx_available: true,
action: SkillsAction::Failed,
command: command_str,
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn target_to_agent_flag_maps_known_agents() {
assert_eq!(
target_to_agent_flag(&SetupTarget::Claude),
Some("claude-code")
);
assert_eq!(target_to_agent_flag(&SetupTarget::Codex), Some("codex"));
assert_eq!(target_to_agent_flag(&SetupTarget::Cursor), Some("cursor"));
assert_eq!(
target_to_agent_flag(&SetupTarget::Windsurf),
Some("windsurf")
);
assert_eq!(
target_to_agent_flag(&SetupTarget::Antigravity),
Some("antigravity")
);
assert_eq!(
target_to_agent_flag(&SetupTarget::Opencode),
Some("opencode")
);
assert_eq!(target_to_agent_flag(&SetupTarget::Standalone), None);
assert_eq!(target_to_agent_flag(&SetupTarget::All), Some("*"));
}
#[test]
fn target_display_name_returns_human_strings() {
assert_eq!(target_display_name(&SetupTarget::Claude), "Claude Code");
assert_eq!(target_display_name(&SetupTarget::Codex), "Codex");
assert_eq!(target_display_name(&SetupTarget::Cursor), "Cursor");
assert_eq!(target_display_name(&SetupTarget::Windsurf), "Windsurf");
assert_eq!(
target_display_name(&SetupTarget::Antigravity),
"Antigravity"
);
assert_eq!(target_display_name(&SetupTarget::Opencode), "Opencode");
assert_eq!(
target_display_name(&SetupTarget::Standalone),
"Standalone CLI"
);
assert_eq!(target_display_name(&SetupTarget::All), "All");
}
#[test]
fn build_skills_command_no_target() {
let args = build_skills_command(None, false);
assert_eq!(args, vec!["skills", "add", SKILLS_PACKAGE]);
}
#[test]
fn build_skills_command_with_target() {
let args = build_skills_command(Some(&SetupTarget::Claude), false);
assert_eq!(
args,
vec!["skills", "add", SKILLS_PACKAGE, "-a", "claude-code"]
);
}
#[test]
fn build_skills_command_auto_confirm_adds_y() {
let args = build_skills_command(Some(&SetupTarget::Cursor), true);
assert_eq!(
args,
vec!["skills", "add", SKILLS_PACKAGE, "-a", "cursor", "-y"]
);
}
#[test]
fn build_skills_command_all_target_omits_agent_flag() {
let args = build_skills_command(Some(&SetupTarget::All), true);
assert_eq!(args, vec!["skills", "add", SKILLS_PACKAGE, "-a", "*", "-y"]);
}
#[test]
fn format_skills_command_no_target() {
let cmd = format_skills_command(None);
assert_eq!(cmd, format!("npx skills add {}", SKILLS_PACKAGE));
}
#[test]
fn format_skills_command_with_target() {
let cmd = format_skills_command(Some(&SetupTarget::Claude));
assert_eq!(
cmd,
format!("npx skills add {} -a claude-code", SKILLS_PACKAGE)
);
}
#[test]
fn format_skills_command_with_all_target_quotes_star_agent() {
let cmd = format_skills_command(Some(&SetupTarget::All));
assert_eq!(cmd, format!("npx skills add {} -a '*'", SKILLS_PACKAGE));
}
#[test]
fn skills_action_display() {
assert_eq!(format!("{}", SkillsAction::Installed), "installed");
assert_eq!(format!("{}", SkillsAction::Skipped), "skipped");
assert_eq!(format!("{}", SkillsAction::Prompted), "prompted");
assert_eq!(format!("{}", SkillsAction::Failed), "failed");
}
#[test]
fn install_skills_without_npx_returns_prompted() {
let env = EnvironmentInfo {
os: "macos".to_string(),
arch: "aarch64".to_string(),
shell: None,
browsers: vec![],
npx_available: false,
node_version: None,
existing_config: false,
existing_api_key: None,
};
let result = install_skills(true, &env, true).expect("should succeed");
assert!(!result.npx_available);
assert_eq!(result.action, SkillsAction::Prompted);
assert_eq!(result.command, format!("npx skills add {}", SKILLS_PACKAGE));
}
}