mirror of
https://github.com/actionbook/actionbook.git
synced 2026-09-19 03:28:06 +08:00
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:
@@ -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
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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!(
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user