fix(onboard,skills): align workspace defaults and open-skills discovery

This commit is contained in:
chumyin 2026-02-21 18:47:08 +08:00 committed by Chummy
parent 66ee7e31ac
commit cd4bb8d10d
3 changed files with 122 additions and 15 deletions

View File

@ -3178,7 +3178,7 @@ pub(crate) async fn persist_active_workspace_config_dir(config_dir: &Path) -> Re
Ok(())
}
fn resolve_config_dir_for_workspace(workspace_dir: &Path) -> (PathBuf, PathBuf) {
pub(crate) fn resolve_config_dir_for_workspace(workspace_dir: &Path) -> (PathBuf, PathBuf) {
let workspace_config_dir = workspace_dir.to_path_buf();
if workspace_config_dir.join("config.toml").exists() {
return (
@ -3209,6 +3209,17 @@ fn resolve_config_dir_for_workspace(workspace_dir: &Path) -> (PathBuf, PathBuf)
)
}
/// Resolve the current runtime config/workspace directories for onboarding flows.
///
/// This mirrors the same precedence used by `Config::load_or_init()`:
/// `ZEROCLAW_CONFIG_DIR` > `ZEROCLAW_WORKSPACE` > active workspace marker > defaults.
pub(crate) async fn resolve_runtime_dirs_for_onboarding() -> Result<(PathBuf, PathBuf)> {
let (default_zeroclaw_dir, default_workspace_dir) = default_config_and_workspace_dirs()?;
let (config_dir, workspace_dir, _) =
resolve_runtime_config_dirs(&default_zeroclaw_dir, &default_workspace_dir).await?;
Ok((config_dir, workspace_dir))
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ConfigResolutionSource {
EnvConfigDir,

View File

@ -444,6 +444,28 @@ pub async fn run_quick_setup(
.await
}
fn resolve_quick_setup_dirs_with_home(home: &Path) -> (PathBuf, PathBuf) {
if let Ok(custom_config_dir) = std::env::var("ZEROCLAW_CONFIG_DIR") {
let trimmed = custom_config_dir.trim();
if !trimmed.is_empty() {
let config_dir = PathBuf::from(trimmed);
return (config_dir.clone(), config_dir.join("workspace"));
}
}
if let Ok(custom_workspace) = std::env::var("ZEROCLAW_WORKSPACE") {
let trimmed = custom_workspace.trim();
if !trimmed.is_empty() {
return crate::config::schema::resolve_config_dir_for_workspace(&PathBuf::from(
trimmed,
));
}
}
let config_dir = home.join(".zeroclaw");
(config_dir.clone(), config_dir.join("workspace"))
}
#[allow(clippy::too_many_lines)]
async fn run_quick_setup_with_home(
credential_override: Option<&str>,
@ -462,8 +484,7 @@ async fn run_quick_setup_with_home(
);
println!();
let zeroclaw_dir = home.join(".zeroclaw");
let workspace_dir = zeroclaw_dir.join("workspace");
let (zeroclaw_dir, workspace_dir) = resolve_quick_setup_dirs_with_home(home);
let config_path = zeroclaw_dir.join("config.toml");
ensure_onboard_overwrite_allowed(&config_path, force)?;
@ -1856,14 +1877,12 @@ async fn persist_workspace_selection(config_path: &Path) -> Result<()> {
// ── Step 1: Workspace ────────────────────────────────────────────
async fn setup_workspace() -> Result<(PathBuf, PathBuf)> {
let home = directories::UserDirs::new()
.map(|u| u.home_dir().to_path_buf())
.context("Could not find home directory")?;
let default_dir = home.join(".zeroclaw");
let (default_config_dir, default_workspace_dir) =
crate::config::schema::resolve_runtime_dirs_for_onboarding().await?;
print_bullet(&format!(
"Default location: {}",
style(default_dir.display()).green()
style(default_workspace_dir.display()).green()
));
let use_default = Confirm::new()
@ -1871,18 +1890,17 @@ async fn setup_workspace() -> Result<(PathBuf, PathBuf)> {
.default(true)
.interact()?;
let zeroclaw_dir = if use_default {
default_dir
let (config_dir, workspace_dir) = if use_default {
(default_config_dir, default_workspace_dir)
} else {
let custom: String = Input::new()
.with_prompt(" Enter workspace path")
.interact_text()?;
let expanded = shellexpand::tilde(&custom).to_string();
PathBuf::from(expanded)
crate::config::schema::resolve_config_dir_for_workspace(&PathBuf::from(expanded))
};
let workspace_dir = zeroclaw_dir.join("workspace");
let config_path = zeroclaw_dir.join("config.toml");
let config_path = config_dir.join("config.toml");
fs::create_dir_all(&workspace_dir)
.await
@ -5541,8 +5559,43 @@ fn print_summary(config: &Config) {
mod tests {
use super::*;
use serde_json::json;
use std::sync::{Mutex, OnceLock};
use tempfile::TempDir;
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
struct EnvVarGuard {
key: &'static str,
previous: Option<String>,
}
impl EnvVarGuard {
fn set(key: &'static str, value: &str) -> Self {
let previous = std::env::var(key).ok();
std::env::set_var(key, value);
Self { key, previous }
}
fn unset(key: &'static str) -> Self {
let previous = std::env::var(key).ok();
std::env::remove_var(key);
Self { key, previous }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
if let Some(previous) = &self.previous {
std::env::set_var(self.key, previous);
} else {
std::env::remove_var(self.key);
}
}
}
// ── ProjectContext defaults ──────────────────────────────────
#[test]
@ -5710,6 +5763,35 @@ mod tests {
assert!(config_raw.contains("default_model = \"custom-model-fresh\""));
}
#[tokio::test]
async fn quick_setup_respects_zero_claw_workspace_env_layout() {
let _env_guard = env_lock().lock().unwrap();
let tmp = TempDir::new().unwrap();
let workspace_root = tmp.path().join("zeroclaw-data");
let workspace_dir = workspace_root.join("workspace");
let expected_config_path = workspace_root.join(".zeroclaw").join("config.toml");
let _workspace_env = EnvVarGuard::set(
"ZEROCLAW_WORKSPACE",
workspace_dir.to_string_lossy().as_ref(),
);
let _config_env = EnvVarGuard::unset("ZEROCLAW_CONFIG_DIR");
let config = run_quick_setup_with_home(
Some("sk-env"),
Some("openrouter"),
Some("model-env"),
Some("sqlite"),
false,
tmp.path(),
)
.await
.expect("quick setup should honor ZEROCLAW_WORKSPACE");
assert_eq!(config.workspace_dir, workspace_dir);
assert_eq!(config.config_path, expected_config_path);
}
// ── scaffold_workspace: basic file creation ─────────────────
#[tokio::test]

View File

@ -141,6 +141,14 @@ fn load_skills_from_directory(skills_dir: &Path) -> Vec<Skill> {
}
fn load_open_skills(repo_dir: &Path) -> Vec<Skill> {
// Modern open-skills layout stores skill packages in `skills/<name>/SKILL.md`.
// Prefer that structure to avoid treating repository docs (e.g. CONTRIBUTING.md)
// as executable skills.
let nested_skills_dir = repo_dir.join("skills");
if nested_skills_dir.is_dir() {
return load_skills_from_directory(&nested_skills_dir);
}
let mut skills = Vec::new();
let Ok(entries) = std::fs::read_dir(repo_dir) else {
@ -1294,10 +1302,15 @@ description = "Bare minimum"
fs::create_dir_all(workspace_dir.join("skills")).unwrap();
let open_skills_dir = dir.path().join("open-skills-local");
fs::create_dir_all(&open_skills_dir).unwrap();
fs::create_dir_all(open_skills_dir.join("skills/http_request")).unwrap();
fs::write(open_skills_dir.join("README.md"), "# open skills\n").unwrap();
fs::write(
open_skills_dir.join("http_request.md"),
open_skills_dir.join("CONTRIBUTING.md"),
"# contribution guide\n",
)
.unwrap();
fs::write(
open_skills_dir.join("skills/http_request/SKILL.md"),
"# HTTP request\nFetch API responses.\n",
)
.unwrap();
@ -1310,6 +1323,7 @@ description = "Bare minimum"
let skills = load_skills_with_config(&workspace_dir, &config);
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].name, "http_request");
assert_ne!(skills[0].name, "CONTRIBUTING");
}
}