From 4eed179d958471a36eb131b55a305092e3419256 Mon Sep 17 00:00:00 2001 From: Sion Smith Date: Tue, 12 May 2026 11:44:18 +0100 Subject: [PATCH 1/4] Add skill onboarding commands --- src/cli/mod.rs | 3 + src/cli/skill.rs | 74 +++++++++ src/commands/auth.rs | 25 ++- src/commands/mod.rs | 1 + src/commands/skill.rs | 350 +++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + tests/cli_integration.rs | 136 +++++++++++++++ 7 files changed, 589 insertions(+), 1 deletion(-) create mode 100644 src/cli/skill.rs create mode 100644 src/commands/skill.rs diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 2363656..9143324 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,6 +1,7 @@ pub mod auth; pub mod clients; pub mod projects; +pub mod skill; pub mod time; use clap::{Parser, Subcommand}; @@ -104,4 +105,6 @@ pub enum Command { Time(time::TimeCommand), /// Browse projects and tasks Projects(projects::ProjectsCommand), + /// Install and verify the Keito agent skill + Skill(skill::SkillCommand), } diff --git a/src/cli/skill.rs b/src/cli/skill.rs new file mode 100644 index 0000000..f51c7ce --- /dev/null +++ b/src/cli/skill.rs @@ -0,0 +1,74 @@ +use clap::{Args, Subcommand, ValueEnum}; + +#[derive(Args)] +#[command(after_long_help = "\ +The Keito Skill is the agent UX layer on top of the Keito CLI. The skill +uses the CLI for authentication and API writes, then installs Claude Code / +Codex lifecycle hooks so local coding sessions can be logged automatically. + +EXAMPLES: + keito skill install + keito skill install --agent codex + keito skill status --json + keito skill doctor")] +pub struct SkillCommand { + #[command(subcommand)] + pub command: SkillSubcommand, +} + +#[derive(Subcommand)] +pub enum SkillSubcommand { + /// Install the Keito Skill and configure supported agent hooks + #[command(long_about = "\ +Install the Keito Skill via the open skills CLI, then run the installed hook +installer for each selected agent. + +By default this configures both Codex and Claude Code. The skill still needs +per-repository setup after installation: cd into a client repo and run +/track-time-keito to select its Keito client, project, and task.")] + Install { + /// Skill source for npx skills add + #[arg( + long, + default_value = "keito-ai/keito-skill", + env = "KEITO_SKILL_SOURCE" + )] + source: String, + + /// Agent hook target to configure + #[arg(long, value_enum)] + agent: Vec, + + /// Skip running npx skills add and only run hook installers if present + #[arg(long)] + skip_skills_add: bool, + }, + + /// Show install/auth/hook status for the Keito Skill + Status, + + /// Run readiness checks and print next actions + Doctor, +} + +#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)] +pub enum SkillAgent { + Codex, + ClaudeCode, +} + +impl SkillAgent { + pub fn skills_cli_name(self) -> &'static str { + match self { + SkillAgent::Codex => "codex", + SkillAgent::ClaudeCode => "claude-code", + } + } + + pub fn display_name(self) -> &'static str { + match self { + SkillAgent::Codex => "Codex", + SkillAgent::ClaudeCode => "Claude Code", + } + } +} diff --git a/src/commands/auth.rs b/src/commands/auth.rs index 7452f8d..85b7dc5 100644 --- a/src/commands/auth.rs +++ b/src/commands/auth.rs @@ -1,9 +1,11 @@ use colored::Colorize; -use dialoguer::{Input, Password}; +use dialoguer::{Confirm, Input, Password}; +use std::io::IsTerminal; use crate::api::KeitorClient; use crate::cli::auth::{AuthCommand, AuthSubcommand}; use crate::cli::GlobalFlags; +use crate::commands::skill; use crate::config::{AppConfig, ResolvedAuth}; use crate::error::AppError; use crate::output::{self, OutputMode}; @@ -71,6 +73,27 @@ async fn login(global: &GlobalFlags, mode: OutputMode) -> Result<(), AppError> { "Credentials saved to {}.", AppConfig::config_path()?.display() ); + maybe_offer_skill_install(global, mode).await?; + } + + Ok(()) +} + +async fn maybe_offer_skill_install(global: &GlobalFlags, mode: OutputMode) -> Result<(), AppError> { + if mode != OutputMode::Table || global.quiet || !std::io::stdin().is_terminal() { + return Ok(()); + } + + let install = Confirm::new() + .with_prompt("Install the Keito agent skill for Claude Code / Codex?") + .default(true) + .interact() + .map_err(|e| AppError::Config(format!("Input error: {e}")))?; + + if install { + skill::install_defaults(global, mode).await?; + } else { + println!("You can install it later with: keito skill install"); } Ok(()) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 9ab4da7..1330985 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod auth; pub mod clients; pub mod projects; +pub mod skill; pub mod time; diff --git a/src/commands/skill.rs b/src/commands/skill.rs new file mode 100644 index 0000000..8f00368 --- /dev/null +++ b/src/commands/skill.rs @@ -0,0 +1,350 @@ +use colored::Colorize; +use serde::Serialize; +use std::path::{Path, PathBuf}; +use std::process::{Command as ProcessCommand, Stdio}; + +use crate::cli::skill::{SkillAgent, SkillCommand, SkillSubcommand}; +use crate::cli::GlobalFlags; +use crate::config::ResolvedAuth; +use crate::error::AppError; +use crate::output::OutputMode; + +const SKILL_NAME: &str = "keito-time-track"; + +#[derive(Debug, Serialize)] +struct SkillStatus { + cli_installed: bool, + cli_path: Option, + npx_installed: bool, + jq_installed: bool, + authenticated: bool, + account_id: Option, + codex: AgentStatus, + claude_code: AgentStatus, +} + +#[derive(Debug, Serialize)] +struct AgentStatus { + skill_installed: bool, + skill_path: Option, + hooks_configured: bool, + hook_config_path: Option, +} + +pub async fn run( + cmd: SkillCommand, + global: &GlobalFlags, + mode: OutputMode, +) -> Result<(), AppError> { + match cmd.command { + SkillSubcommand::Install { + source, + agent, + skip_skills_add, + } => { + install( + global, + mode, + &source, + selected_agents(agent), + skip_skills_add, + ) + .await + } + SkillSubcommand::Status => status(global, mode, false).await, + SkillSubcommand::Doctor => status(global, mode, true).await, + } +} + +pub async fn install_defaults(global: &GlobalFlags, mode: OutputMode) -> Result<(), AppError> { + let source = + std::env::var("KEITO_SKILL_SOURCE").unwrap_or_else(|_| "keito-ai/keito-skill".into()); + install(global, mode, &source, default_agents(), false).await +} + +async fn install( + global: &GlobalFlags, + mode: OutputMode, + source: &str, + agents: Vec, + skip_skills_add: bool, +) -> Result<(), AppError> { + if !skip_skills_add && find_in_path("npx").is_none() { + return Err(AppError::Config( + "npx is required to install the Keito Skill. Install Node.js or run the manual skill installer.".into(), + )); + } + if find_in_path("jq").is_none() { + return Err(AppError::Config( + "jq is required by the Keito Skill hook installers and runtime scripts.".into(), + )); + } + + for agent in &agents { + let show_child_output = !global.quiet && mode == OutputMode::Table; + if !skip_skills_add { + run_skills_add(source, *agent, show_child_output)?; + } + run_hook_installer(*agent, show_child_output)?; + } + + if global.quiet { + return Ok(()); + } + + let current = collect_status(global); + if mode == OutputMode::Json { + println!( + "{}", + serde_json::to_string_pretty(¤t) + .map_err(|e| AppError::ServerError(format!("JSON serialization failed: {e}")))? + ); + } else { + println!("{}", "Keito Skill installed.".green().bold()); + println!("Next: cd into each client repo and run /track-time-keito."); + println!("Check readiness any time with: keito skill doctor"); + } + + Ok(()) +} + +async fn status(global: &GlobalFlags, mode: OutputMode, doctor: bool) -> Result<(), AppError> { + let current = collect_status(global); + + if global.quiet { + return Ok(()); + } + + if mode == OutputMode::Json { + println!( + "{}", + serde_json::to_string_pretty(¤t) + .map_err(|e| AppError::ServerError(format!("JSON serialization failed: {e}")))? + ); + return Ok(()); + } + + print_status(¤t, doctor); + Ok(()) +} + +fn selected_agents(agents: Vec) -> Vec { + if agents.is_empty() { + default_agents() + } else { + agents + } +} + +fn default_agents() -> Vec { + vec![SkillAgent::Codex, SkillAgent::ClaudeCode] +} + +fn run_skills_add(source: &str, agent: SkillAgent, show_output: bool) -> Result<(), AppError> { + let status = ProcessCommand::new("npx") + .args([ + "--yes", + "skills@latest", + "add", + source, + "-g", + "-a", + agent.skills_cli_name(), + "-s", + SKILL_NAME, + "-y", + "--copy", + ]) + .stdin(Stdio::null()) + .stdout(child_stdio(show_output)) + .stderr(child_stdio(show_output)) + .status() + .map_err(|e| AppError::Config(format!("Failed to run npx skills: {e}")))?; + + if !status.success() { + return Err(AppError::Config(format!( + "npx skills add failed for {}", + agent.display_name() + ))); + } + + Ok(()) +} + +fn run_hook_installer(agent: SkillAgent, show_output: bool) -> Result<(), AppError> { + let script = hook_installer_path(agent).ok_or_else(|| { + AppError::Config(format!( + "Keito Skill files were not found for {} after install", + agent.display_name() + )) + })?; + + let current_exe = std::env::current_exe() + .map_err(|e| AppError::Config(format!("Could not resolve current keito binary: {e}")))?; + + let status = ProcessCommand::new("bash") + .arg(&script) + .env("KEITO_CLI_BIN", current_exe) + .stdin(Stdio::null()) + .stdout(child_stdio(show_output)) + .stderr(child_stdio(show_output)) + .status() + .map_err(|e| AppError::Config(format!("Failed to run hook installer: {e}")))?; + + if !status.success() { + return Err(AppError::Config(format!( + "Hook installer failed for {}", + agent.display_name() + ))); + } + + Ok(()) +} + +fn child_stdio(show_output: bool) -> Stdio { + if show_output { + Stdio::inherit() + } else { + Stdio::null() + } +} + +fn hook_installer_path(agent: SkillAgent) -> Option { + let home = dirs::home_dir()?; + let candidates = match agent { + SkillAgent::Codex => vec![ + home.join(".agents/skills").join(SKILL_NAME), + home.join(".codex/skills").join(SKILL_NAME), + ], + SkillAgent::ClaudeCode => vec![home.join(".claude/skills").join(SKILL_NAME)], + }; + + let installer_name = match agent { + SkillAgent::Codex => "install-codex.sh", + SkillAgent::ClaudeCode => "install-claude-code.sh", + }; + + candidates + .into_iter() + .map(|root| root.join("installers").join(installer_name)) + .find(|path| path.exists()) +} + +fn collect_status(global: &GlobalFlags) -> SkillStatus { + let auth = ResolvedAuth::resolve(global).ok(); + SkillStatus { + cli_installed: true, + cli_path: std::env::current_exe() + .ok() + .map(|path| path.display().to_string()), + npx_installed: find_in_path("npx").is_some(), + jq_installed: find_in_path("jq").is_some(), + authenticated: auth.is_some(), + account_id: auth.map(|auth| auth.workspace_id), + codex: agent_status(SkillAgent::Codex), + claude_code: agent_status(SkillAgent::ClaudeCode), + } +} + +fn agent_status(agent: SkillAgent) -> AgentStatus { + let home = dirs::home_dir(); + let skill_path = home.as_ref().and_then(|home| match agent { + SkillAgent::Codex => first_existing(&[ + home.join(".agents/skills").join(SKILL_NAME), + home.join(".codex/skills").join(SKILL_NAME), + ]), + SkillAgent::ClaudeCode => first_existing(&[home.join(".claude/skills").join(SKILL_NAME)]), + }); + let hook_config_path = home.as_ref().map(|home| match agent { + SkillAgent::Codex => home.join(".codex/hooks.json"), + SkillAgent::ClaudeCode => home.join(".claude/settings.json"), + }); + let hooks_configured = hook_config_path + .as_ref() + .is_some_and(|path| hook_configured(path)); + + AgentStatus { + skill_installed: skill_path.is_some(), + skill_path: skill_path.map(|path| path.display().to_string()), + hooks_configured, + hook_config_path: hook_config_path.map(|path| path.display().to_string()), + } +} + +fn first_existing(paths: &[PathBuf]) -> Option { + paths.iter().find(|path| path.exists()).cloned() +} + +fn hook_configured(path: &Path) -> bool { + let Ok(contents) = std::fs::read_to_string(path) else { + return false; + }; + contents.contains(SKILL_NAME) + && contents.contains("session-start.sh") + && contents.contains("session-end.sh") +} + +fn find_in_path(binary: &str) -> Option { + if binary.contains(std::path::MAIN_SEPARATOR) { + let path = PathBuf::from(binary); + return path.exists().then_some(path); + } + + let path_var = std::env::var_os("PATH")?; + std::env::split_paths(&path_var) + .map(|dir| dir.join(binary)) + .find(|path| path.is_file()) +} + +fn print_status(status: &SkillStatus, doctor: bool) { + println!("Keito CLI: {}", yes_no(status.cli_installed)); + if let Some(path) = &status.cli_path { + println!("CLI path: {path}"); + } + println!("npx available: {}", yes_no(status.npx_installed)); + println!("jq available: {}", yes_no(status.jq_installed)); + println!("Authenticated: {}", yes_no(status.authenticated)); + if let Some(account_id) = &status.account_id { + println!("Account ID: {account_id}"); + } + print_agent_status("Codex", &status.codex); + print_agent_status("Claude Code", &status.claude_code); + + if doctor { + println!(); + println!("Next actions:"); + if !status.npx_installed { + println!("- Install Node.js/npm so npx is available."); + } + if !status.jq_installed { + println!("- Install jq."); + } + if !status.authenticated { + println!("- Run keito auth login."); + } + if !status.codex.hooks_configured && !status.claude_code.hooks_configured { + println!("- Run keito skill install."); + } + if status.authenticated + && (status.codex.hooks_configured || status.claude_code.hooks_configured) + { + println!("- cd into each client repo and run /track-time-keito."); + } + } +} + +fn print_agent_status(name: &str, status: &AgentStatus) { + println!( + "{name} skill: {}, hooks: {}", + yes_no(status.skill_installed), + yes_no(status.hooks_configured) + ); +} + +fn yes_no(value: bool) -> String { + if value { + "yes".green().to_string() + } else { + "no".red().to_string() + } +} diff --git a/src/main.rs b/src/main.rs index b15d616..f12d774 100644 --- a/src/main.rs +++ b/src/main.rs @@ -39,5 +39,6 @@ async fn run(cli: Cli) -> Result<(), AppError> { cli::Command::Clients(cmd) => commands::clients::run(cmd, &cli.global, output_mode).await, cli::Command::Time(cmd) => commands::time::run(cmd, &cli.global, output_mode).await, cli::Command::Projects(cmd) => commands::projects::run(cmd, &cli.global, output_mode).await, + cli::Command::Skill(cmd) => commands::skill::run(cmd, &cli.global, output_mode).await, } } diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index dc3c7a9..cbcc143 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -37,6 +37,82 @@ fn command_with_mock_config(home: &Path, api_url: &str) -> Command { cmd } +fn prepend_path(cmd: &mut Command, dir: &Path) { + let current_path = std::env::var("PATH").unwrap_or_default(); + cmd.env("PATH", format!("{}:{current_path}", dir.display())); +} + +fn write_fake_skill_tools(home: &Path) -> std::path::PathBuf { + let bin = home.join("bin"); + fs::create_dir_all(&bin).unwrap(); + + let npx = bin.join("npx"); + fs::write( + &npx, + r#"#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> "$KEITO_FAKE_NPX_LOG" +echo "fake npx install output" +agent="" +while [ "$#" -gt 0 ]; do + if [ "$1" = "-a" ]; then + shift + agent="${1:-}" + fi + shift || true +done + +case "$agent" in + codex) + root="$HOME/.agents/skills/keito-time-track" + mkdir -p "$root/installers" + cat > "$root/installers/install-codex.sh" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +echo "fake codex installer output" +mkdir -p "$HOME/.codex" +printf '{"hooks":{"SessionStart":[{"hooks":[{"command":"keito-time-track/hooks/session-start.sh"}]}],"Stop":[{"hooks":[{"command":"keito-time-track/hooks/session-end.sh"}]}]}}\n' > "$HOME/.codex/hooks.json" +SH + chmod +x "$root/installers/install-codex.sh" + ;; + claude-code) + root="$HOME/.claude/skills/keito-time-track" + mkdir -p "$root/installers" + cat > "$root/installers/install-claude-code.sh" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +echo "fake claude installer output" +mkdir -p "$HOME/.claude" +printf '{"hooks":{"SessionStart":[{"hooks":[{"command":"keito-time-track/hooks/session-start.sh"}]}],"Stop":[{"hooks":[{"command":"keito-time-track/hooks/session-end.sh"}]}]}}\n' > "$HOME/.claude/settings.json" +SH + chmod +x "$root/installers/install-claude-code.sh" + ;; + *) + echo "unexpected agent: $agent" >&2 + exit 2 + ;; +esac +"#, + ) + .unwrap(); + set_executable(&npx); + + let jq = bin.join("jq"); + fs::write(&jq, "#!/usr/bin/env bash\nexit 0\n").unwrap(); + set_executable(&jq); + + bin +} + +#[cfg(unix)] +fn set_executable(path: &Path) { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap(); +} + +#[cfg(not(unix))] +fn set_executable(_path: &Path) {} + #[test] fn help_flag_works() { Command::cargo_bin("keito") @@ -155,6 +231,66 @@ fn clients_help_works() { .stdout(predicate::str::contains("list")); } +#[test] +fn skill_help_works() { + Command::cargo_bin("keito") + .unwrap() + .args(["skill", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("install")); +} + +#[test] +fn skill_install_configures_agent_hooks_with_fake_skills_cli() { + let temp_dir = tempfile::tempdir().unwrap(); + let bin = write_fake_skill_tools(temp_dir.path()); + let npx_log = temp_dir.path().join("npx.log"); + + let mut cmd = Command::cargo_bin("keito").unwrap(); + prepend_path(&mut cmd, &bin); + let output = cmd + .env("HOME", temp_dir.path()) + .env("XDG_CONFIG_HOME", temp_dir.path().join("config")) + .env("APPDATA", temp_dir.path().join("AppData").join("Roaming")) + .env("KEITO_FAKE_NPX_LOG", &npx_log) + .env("KEITO_API_KEY", "kto_test_key") + .env("KEITO_ACCOUNT_ID", "co_test") + .args([ + "--json", + "skill", + "install", + "--source", + "/tmp/keito-skill", + "--agent", + "codex", + "--agent", + "claude-code", + ]) + .assert() + .success() + .get_output() + .stdout + .clone(); + + let status_json: serde_json::Value = serde_json::from_slice(&output).unwrap(); + assert_eq!(status_json["authenticated"], true); + assert_eq!(status_json["codex"]["hooks_configured"], true); + assert_eq!(status_json["claude_code"]["hooks_configured"], true); + + let output_text = String::from_utf8(output).unwrap(); + assert!(!output_text.contains("fake npx install output")); + assert!(!output_text.contains("fake codex installer output")); + assert!(!output_text.contains("fake claude installer output")); + + let log = fs::read_to_string(npx_log).unwrap(); + assert!(log.contains("skills@latest add /tmp/keito-skill")); + assert!(log.contains("-a codex")); + assert!(log.contains("-a claude-code")); + assert!(temp_dir.path().join(".codex/hooks.json").exists()); + assert!(temp_dir.path().join(".claude/settings.json").exists()); +} + #[tokio::test] async fn clients_list_sends_account_header_against_mock_api() { let server = MockServer::start().await; From 7587e5e3aa602648f2a7a33b4052b765eebfb982 Mon Sep 17 00:00:00 2001 From: Sion Smith Date: Tue, 12 May 2026 11:49:46 +0100 Subject: [PATCH 2/4] Bump version for skill onboarding release --- CHANGELOG.md | 8 ++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6189945..363b00f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [0.1.5] - 2026-05-12 + +### Added + +- `keito skill install`, `keito skill status`, and `keito skill doctor` commands for installing and verifying the Keito Agent Skill. +- Optional interactive Agent Skill install prompt after `keito auth login`. +- JSON-safe skill installer execution so child installer output does not contaminate `--json` responses. + ## [0.1.4] - 2026-05-12 ### Added diff --git a/Cargo.lock b/Cargo.lock index 879b7fa..981ef18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -872,7 +872,7 @@ dependencies = [ [[package]] name = "keito-cli" -version = "0.1.4" +version = "0.1.5" dependencies = [ "assert_cmd", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 7c021a8..9ca8d1a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "keito-cli" -version = "0.1.4" +version = "0.1.5" edition = "2021" description = "CLI for AI agents and humans to track billable time against the Keito platform" license = "MIT" From b3751d817e51120ee8a69ce50cfd2e106a12d6da Mon Sep 17 00:00:00 2001 From: Sion Smith Date: Tue, 12 May 2026 11:56:53 +0100 Subject: [PATCH 3/4] Fix skill command tests on Windows --- src/commands/skill.rs | 25 ++++++++++++++++++++++++- tests/cli_integration.rs | 8 ++++++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/commands/skill.rs b/src/commands/skill.rs index 8f00368..d5900fa 100644 --- a/src/commands/skill.rs +++ b/src/commands/skill.rs @@ -291,11 +291,34 @@ fn find_in_path(binary: &str) -> Option { } let path_var = std::env::var_os("PATH")?; + let names = executable_names(binary); std::env::split_paths(&path_var) - .map(|dir| dir.join(binary)) + .flat_map(|dir| names.iter().map(move |name| dir.join(name))) .find(|path| path.is_file()) } +fn executable_names(binary: &str) -> Vec { + #[cfg(not(windows))] + { + vec![binary.to_string()] + } + #[cfg(windows)] + { + let mut names = vec![binary.to_string()]; + if Path::new(binary).extension().is_none() { + let pathext = + std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string()); + names.extend( + pathext + .split(';') + .filter(|ext| !ext.is_empty()) + .map(|ext| format!("{binary}{ext}")), + ); + } + names + } +} + fn print_status(status: &SkillStatus, doctor: bool) { println!("Keito CLI: {}", yes_no(status.cli_installed)); if let Some(path) = &status.cli_path { diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index cbcc143..6ec5ee7 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -38,8 +38,11 @@ fn command_with_mock_config(home: &Path, api_url: &str) -> Command { } fn prepend_path(cmd: &mut Command, dir: &Path) { - let current_path = std::env::var("PATH").unwrap_or_default(); - cmd.env("PATH", format!("{}:{current_path}", dir.display())); + let mut paths = vec![dir.to_path_buf()]; + if let Some(current_path) = std::env::var_os("PATH") { + paths.extend(std::env::split_paths(¤t_path)); + } + cmd.env("PATH", std::env::join_paths(paths).unwrap()); } fn write_fake_skill_tools(home: &Path) -> std::path::PathBuf { @@ -242,6 +245,7 @@ fn skill_help_works() { } #[test] +#[cfg(unix)] fn skill_install_configures_agent_hooks_with_fake_skills_cli() { let temp_dir = tempfile::tempdir().unwrap(); let bin = write_fake_skill_tools(temp_dir.path()); From aceeaf180904d41274750187799ad1f1303aaeb6 Mon Sep 17 00:00:00 2001 From: Sion Smith Date: Tue, 12 May 2026 12:01:10 +0100 Subject: [PATCH 4/4] Guard Unix-only skill install test helpers --- tests/cli_integration.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index 6ec5ee7..71eac84 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -37,6 +37,7 @@ fn command_with_mock_config(home: &Path, api_url: &str) -> Command { cmd } +#[cfg(unix)] fn prepend_path(cmd: &mut Command, dir: &Path) { let mut paths = vec![dir.to_path_buf()]; if let Some(current_path) = std::env::var_os("PATH") { @@ -45,6 +46,7 @@ fn prepend_path(cmd: &mut Command, dir: &Path) { cmd.env("PATH", std::env::join_paths(paths).unwrap()); } +#[cfg(unix)] fn write_fake_skill_tools(home: &Path) -> std::path::PathBuf { let bin = home.join("bin"); fs::create_dir_all(&bin).unwrap(); @@ -113,9 +115,6 @@ fn set_executable(path: &Path) { fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap(); } -#[cfg(not(unix))] -fn set_executable(_path: &Path) {} - #[test] fn help_flag_works() { Command::cargo_bin("keito")