= {
blankLineAfter: true
},
cleanupProtection: {},
+ windows: {},
plugins: []
}
@@ -79,6 +81,7 @@ export function userConfigToPluginOptions(userConfig: UserConfigFile): Partial Option> {
optional_details(serde_json::json!({
@@ -126,6 +128,27 @@ pub struct UserProfile {
pub extra: HashMap,
}
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
+#[serde(untagged)]
+pub enum StringOrStrings {
+ Single(String),
+ Multiple(Vec),
+}
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct WindowsWsl2Options {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub instances: Option,
+}
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct WindowsOptions {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub wsl2: Option,
+}
+
/// User configuration file (.tnmsc.json).
/// All fields are optional — missing fields use default values.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
@@ -143,6 +166,8 @@ pub struct UserConfigFile {
pub fast_command_series_options: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub windows: Option,
}
// ---------------------------------------------------------------------------
@@ -178,14 +203,334 @@ pub struct GlobalConfigValidationResult {
// Path helpers
// ---------------------------------------------------------------------------
+#[derive(Debug, Clone, Default)]
+pub struct RuntimeEnvironmentContext {
+ pub is_wsl: bool,
+ pub native_home_dir: Option,
+ pub effective_home_dir: Option,
+ pub selected_global_config_path: Option,
+ pub windows_users_root: PathBuf,
+}
+
fn home_dir() -> Option {
dirs::home_dir()
}
+fn normalize_posix_like_path(raw_path: &str) -> String {
+ let replaced = raw_path.replace('\\', "/");
+ let has_root = replaced.starts_with('/');
+ let mut components: Vec<&str> = Vec::new();
+
+ for component in replaced.split('/') {
+ if component.is_empty() || component == "." {
+ continue;
+ }
+
+ if component == ".." {
+ if let Some(last_component) = components.last() {
+ if *last_component != ".." {
+ components.pop();
+ continue;
+ }
+ }
+
+ if !has_root {
+ components.push(component);
+ }
+ continue;
+ }
+
+ components.push(component);
+ }
+
+ let joined = components.join("/");
+ if has_root {
+ if joined.is_empty() {
+ "/".to_string()
+ } else {
+ format!("/{joined}")
+ }
+ } else {
+ joined
+ }
+}
+
+fn is_same_or_child_path(candidate_path: &str, parent_path: &str) -> bool {
+ let normalized_candidate = normalize_posix_like_path(candidate_path);
+ let normalized_parent = normalize_posix_like_path(parent_path);
+
+ normalized_candidate == normalized_parent
+ || normalized_candidate.starts_with(&format!("{normalized_parent}/"))
+}
+
+fn convert_windows_path_to_wsl(raw_path: &str) -> Option {
+ let bytes = raw_path.as_bytes();
+ if bytes.len() < 3
+ || !bytes[0].is_ascii_alphabetic()
+ || bytes[1] != b':'
+ || (bytes[2] != b'\\' && bytes[2] != b'/')
+ {
+ return None;
+ }
+
+ let drive_letter = char::from(bytes[0]).to_ascii_lowercase();
+ let relative_path = raw_path[2..]
+ .trim_start_matches(['\\', '/'])
+ .replace('\\', "/");
+ let base_path = format!("/mnt/{drive_letter}");
+
+ if relative_path.is_empty() {
+ Some(PathBuf::from(base_path))
+ } else {
+ Some(Path::new(&base_path).join(relative_path))
+ }
+}
+
+fn resolve_wsl_host_home_candidate(users_root: &Path, raw_path: Option<&str>) -> Option {
+ let raw_path = raw_path?.trim();
+ if raw_path.is_empty() {
+ return None;
+ }
+
+ let normalized_users_root = normalize_posix_like_path(&users_root.to_string_lossy());
+ let candidate_paths = [
+ convert_windows_path_to_wsl(raw_path)
+ .map(|candidate_path| normalize_posix_like_path(&candidate_path.to_string_lossy())),
+ Some(normalize_posix_like_path(raw_path)),
+ ];
+
+ for candidate_path in candidate_paths.into_iter().flatten() {
+ if is_same_or_child_path(&candidate_path, &normalized_users_root) {
+ return Some(PathBuf::from(candidate_path));
+ }
+ }
+
+ None
+}
+
+fn resolve_preferred_wsl_host_home_dirs_for(
+ users_root: &Path,
+ userprofile: Option<&str>,
+ homedrive: Option<&str>,
+ homepath: Option<&str>,
+ home: Option<&str>,
+) -> Vec {
+ let mut preferred_home_dirs: Vec = Vec::new();
+ let combined_home_path = match (homedrive, homepath) {
+ (Some(drive), Some(home_path)) if !drive.is_empty() && !home_path.is_empty() => {
+ Some(format!("{drive}{home_path}"))
+ }
+ _ => None,
+ };
+
+ for candidate in [
+ resolve_wsl_host_home_candidate(users_root, userprofile),
+ resolve_wsl_host_home_candidate(users_root, combined_home_path.as_deref()),
+ resolve_wsl_host_home_candidate(users_root, home),
+ ]
+ .into_iter()
+ .flatten()
+ {
+ if !preferred_home_dirs.iter().any(|existing| existing == &candidate) {
+ preferred_home_dirs.push(candidate);
+ }
+ }
+
+ preferred_home_dirs
+}
+
+fn non_empty_env_var(name: &str) -> Option {
+ env::var(name).ok().filter(|value| !value.is_empty())
+}
+
+fn resolve_preferred_wsl_host_home_dirs_with_root(users_root: &Path) -> Vec {
+ let userprofile = non_empty_env_var("USERPROFILE");
+ let homedrive = non_empty_env_var("HOMEDRIVE");
+ let homepath = non_empty_env_var("HOMEPATH");
+ let home = non_empty_env_var("HOME");
+
+ resolve_preferred_wsl_host_home_dirs_for(
+ users_root,
+ userprofile.as_deref(),
+ homedrive.as_deref(),
+ homepath.as_deref(),
+ home.as_deref(),
+ )
+}
+
+fn global_config_home_dir(candidate_path: &Path) -> Option {
+ candidate_path
+ .parent()
+ .and_then(|parent| parent.parent())
+ .map(PathBuf::from)
+}
+
+fn select_wsl_host_global_config_path_for(
+ users_root: &Path,
+ userprofile: Option<&str>,
+ homedrive: Option<&str>,
+ homepath: Option<&str>,
+ home: Option<&str>,
+) -> Option {
+ let candidates = find_wsl_host_global_config_paths_with_root(users_root);
+ let preferred_home_dirs = resolve_preferred_wsl_host_home_dirs_for(
+ users_root,
+ userprofile,
+ homedrive,
+ homepath,
+ home,
+ );
+
+ if !preferred_home_dirs.is_empty() {
+ for preferred_home_dir in preferred_home_dirs {
+ if let Some(candidate_path) = candidates.iter().find(|candidate_path| {
+ global_config_home_dir(candidate_path).as_ref() == Some(&preferred_home_dir)
+ }) {
+ return Some(candidate_path.clone());
+ }
+ }
+
+ return None;
+ }
+
+ if candidates.len() == 1 {
+ return candidates.into_iter().next();
+ }
+
+ None
+}
+
+fn select_wsl_host_global_config_path_with_root(users_root: &Path) -> Option {
+ let userprofile = non_empty_env_var("USERPROFILE");
+ let homedrive = non_empty_env_var("HOMEDRIVE");
+ let homepath = non_empty_env_var("HOMEPATH");
+ let home = non_empty_env_var("HOME");
+
+ select_wsl_host_global_config_path_for(
+ users_root,
+ userprofile.as_deref(),
+ homedrive.as_deref(),
+ homepath.as_deref(),
+ home.as_deref(),
+ )
+}
+
+fn build_required_wsl_config_resolution_error(users_root: &Path) -> String {
+ let preferred_home_dirs = resolve_preferred_wsl_host_home_dirs_with_root(users_root);
+ let candidates = find_wsl_host_global_config_paths_with_root(users_root);
+ let config_lookup_pattern = format!(
+ "\"{}/*/{}/{}\"",
+ users_root.to_string_lossy(),
+ DEFAULT_GLOBAL_CONFIG_DIR,
+ DEFAULT_CONFIG_FILE_NAME
+ );
+
+ if candidates.is_empty() {
+ return format!("WSL host config file not found under {config_lookup_pattern}.");
+ }
+
+ if !preferred_home_dirs.is_empty() {
+ return format!(
+ "WSL host config file for the current Windows user was not found under {config_lookup_pattern}."
+ );
+ }
+
+ format!(
+ "WSL host config file could not be matched to the current Windows user under {config_lookup_pattern}."
+ )
+}
+
+fn is_wsl_runtime_for(os_name: &str, wsl_distro_name: Option<&str>, wsl_interop: Option<&str>, release: &str) -> bool {
+ if os_name != "linux" {
+ return false;
+ }
+
+ if wsl_distro_name.is_some_and(|value| !value.is_empty()) || wsl_interop.is_some_and(|value| !value.is_empty()) {
+ return true;
+ }
+
+ release.to_lowercase().contains("microsoft")
+}
+
+pub fn is_wsl_runtime() -> bool {
+ let release = fs::read_to_string("/proc/sys/kernel/osrelease").unwrap_or_default();
+ let wsl_distro_name = env::var("WSL_DISTRO_NAME").ok();
+ let wsl_interop = env::var("WSL_INTEROP").ok();
+
+ is_wsl_runtime_for(
+ env::consts::OS,
+ wsl_distro_name.as_deref(),
+ wsl_interop.as_deref(),
+ &release,
+ )
+}
+
+pub fn find_wsl_host_global_config_paths_with_root(users_root: &Path) -> Vec {
+ if !users_root.is_dir() {
+ return vec![];
+ }
+
+ let mut candidates: Vec = match fs::read_dir(users_root) {
+ Ok(entries) => entries
+ .filter_map(|entry| entry.ok())
+ .filter_map(|entry| {
+ let entry_path = entry.path();
+ if !entry_path.is_dir() {
+ return None;
+ }
+
+ let candidate_path = entry_path
+ .join(DEFAULT_GLOBAL_CONFIG_DIR)
+ .join(DEFAULT_CONFIG_FILE_NAME);
+ if candidate_path.is_file() {
+ Some(candidate_path)
+ } else {
+ None
+ }
+ })
+ .collect(),
+ Err(_) => vec![],
+ };
+
+ candidates.sort_by(|a, b| a.to_string_lossy().cmp(&b.to_string_lossy()));
+ candidates
+}
+
+pub fn resolve_runtime_environment_with_root(users_root: PathBuf) -> RuntimeEnvironmentContext {
+ let native_home_dir = home_dir();
+ let is_wsl = is_wsl_runtime();
+ let selected_global_config_path = if is_wsl {
+ select_wsl_host_global_config_path_with_root(&users_root)
+ } else {
+ None
+ };
+ let effective_home_dir = selected_global_config_path
+ .as_ref()
+ .and_then(|config_path| config_path.parent().and_then(|parent| parent.parent()))
+ .map(PathBuf::from)
+ .or_else(|| native_home_dir.clone());
+
+ RuntimeEnvironmentContext {
+ is_wsl,
+ native_home_dir,
+ effective_home_dir,
+ selected_global_config_path,
+ windows_users_root: users_root,
+ }
+}
+
+pub fn resolve_runtime_environment() -> RuntimeEnvironmentContext {
+ resolve_runtime_environment_with_root(PathBuf::from(DEFAULT_WSL_WINDOWS_USERS_ROOT))
+}
+
/// Resolve `~` prefix to the user's home directory.
pub fn resolve_tilde(p: &str) -> PathBuf {
+ let runtime_environment = resolve_runtime_environment();
if let Some(rest) = p.strip_prefix('~') {
- if let Some(home) = home_dir() {
+ if let Some(home) = runtime_environment
+ .effective_home_dir
+ .or(runtime_environment.native_home_dir)
+ {
let rest = rest
.strip_prefix('/')
.or_else(|| rest.strip_prefix('\\'))
@@ -198,7 +543,16 @@ pub fn resolve_tilde(p: &str) -> PathBuf {
/// Get the global config file path: `~/.aindex/.tnmsc.json`
pub fn get_global_config_path() -> PathBuf {
- match home_dir() {
+ let runtime_environment = resolve_runtime_environment();
+
+ if let Some(selected_path) = runtime_environment.selected_global_config_path {
+ return selected_path;
+ }
+
+ match runtime_environment
+ .effective_home_dir
+ .or(runtime_environment.native_home_dir)
+ {
Some(home) => home
.join(DEFAULT_GLOBAL_CONFIG_DIR)
.join(DEFAULT_CONFIG_FILE_NAME),
@@ -206,6 +560,18 @@ pub fn get_global_config_path() -> PathBuf {
}
}
+pub fn get_required_global_config_path() -> Result {
+ let runtime_environment = resolve_runtime_environment();
+
+ if runtime_environment.is_wsl && runtime_environment.selected_global_config_path.is_none() {
+ return Err(build_required_wsl_config_resolution_error(
+ &runtime_environment.windows_users_root,
+ ));
+ }
+
+ Ok(get_global_config_path())
+}
+
// ---------------------------------------------------------------------------
// Merge logic
// ---------------------------------------------------------------------------
@@ -230,9 +596,31 @@ fn merge_aindex(a: &Option, b: &Option) -> Option, b: &Option) -> Option {
+ match (a, b) {
+ (None, None) => None,
+ (Some(v), None) => Some(v.clone()),
+ (None, Some(v)) => Some(v.clone()),
+ (Some(base), Some(over)) => Some(WindowsOptions {
+ wsl2: match (&base.wsl2, &over.wsl2) {
+ (None, None) => None,
+ (Some(v), None) => Some(v.clone()),
+ (None, Some(v)) => Some(v.clone()),
+ (Some(base_wsl2), Some(over_wsl2)) => Some(WindowsWsl2Options {
+ instances: over_wsl2
+ .instances
+ .clone()
+ .or_else(|| base_wsl2.instances.clone()),
+ }),
+ },
+ }),
+ }
+}
+
/// Merge two configs. `over` fields take priority over `base`.
pub fn merge_configs_pair(base: &UserConfigFile, over: &UserConfigFile) -> UserConfigFile {
let merged_aindex = merge_aindex(&base.aindex, &over.aindex);
+ let merged_windows = merge_windows(&base.windows, &over.windows);
UserConfigFile {
version: over.version.clone().or_else(|| base.version.clone()),
@@ -247,6 +635,7 @@ pub fn merge_configs_pair(base: &UserConfigFile, over: &UserConfigFile) -> UserC
.clone()
.or_else(|| base.fast_command_series_options.clone()),
profile: over.profile.clone().or_else(|| base.profile.clone()),
+ windows: merged_windows,
}
}
@@ -293,6 +682,34 @@ impl ConfigLoader {
Self::new(ConfigLoaderOptions::default())
}
+ pub fn try_get_search_paths(&self, _cwd: &Path) -> Result, String> {
+ let runtime_environment = resolve_runtime_environment();
+
+ if runtime_environment.is_wsl {
+ self.logger.info(
+ Value::String("wsl environment detected".into()),
+ Some(serde_json::json!({
+ "effectiveHomeDir": runtime_environment
+ .effective_home_dir
+ .as_ref()
+ .map(|path| path.to_string_lossy().into_owned())
+ })),
+ );
+ }
+
+ let config_path = get_required_global_config_path()?;
+ if runtime_environment.is_wsl {
+ self.logger.info(
+ Value::String("using wsl host global config".into()),
+ Some(serde_json::json!({
+ "path": config_path.to_string_lossy()
+ })),
+ );
+ }
+
+ Ok(vec![config_path])
+ }
+
/// Get the list of config file paths to search.
pub fn get_search_paths(&self, _cwd: &Path) -> Vec {
vec![get_global_config_path()]
@@ -353,9 +770,8 @@ impl ConfigLoader {
}
}
- /// Load and merge all config files.
- pub fn load(&self, cwd: &Path) -> MergedConfigResult {
- let search_paths = self.get_search_paths(cwd);
+ pub fn try_load(&self, cwd: &Path) -> Result {
+ let search_paths = self.try_get_search_paths(cwd)?;
let mut loaded: Vec = Vec::new();
for path in &search_paths {
@@ -369,11 +785,33 @@ impl ConfigLoader {
let merged = merge_configs(&configs);
let sources: Vec = loaded.iter().filter_map(|r| r.source.clone()).collect();
- MergedConfigResult {
+ Ok(MergedConfigResult {
config: merged,
sources,
found: !loaded.is_empty(),
- }
+ })
+ }
+
+ /// Load and merge all config files.
+ pub fn load(&self, cwd: &Path) -> MergedConfigResult {
+ self.try_load(cwd).unwrap_or_else(|error| {
+ self.logger.error(diagnostic(
+ "GLOBAL_CONFIG_PATH_RESOLUTION_FAILED",
+ "Failed to resolve the global config path",
+ line("The runtime could not determine which global config file should be loaded."),
+ Some(line(
+ "Ensure the expected global config exists and retry the command.",
+ )),
+ None,
+ optional_details(serde_json::json!({ "error": error })),
+ ));
+
+ MergedConfigResult {
+ config: UserConfigFile::default(),
+ sources: vec![],
+ found: false,
+ }
+ })
}
fn parse_config(&self, content: &str, file_path: &Path) -> Result {
@@ -414,8 +852,8 @@ impl ConfigLoader {
// ---------------------------------------------------------------------------
/// Load user configuration using default loader.
-pub fn load_user_config(cwd: &Path) -> MergedConfigResult {
- ConfigLoader::with_defaults().load(cwd)
+pub fn load_user_config(cwd: &Path) -> Result {
+ ConfigLoader::with_defaults().try_load(cwd)
}
// ---------------------------------------------------------------------------
@@ -475,7 +913,27 @@ pub fn validate_and_ensure_global_config(
default_config: &UserConfigFile,
) -> GlobalConfigValidationResult {
let logger = create_logger("ConfigLoader", None);
- let config_path = get_global_config_path();
+ let config_path = match get_required_global_config_path() {
+ Ok(path) => path,
+ Err(error) => {
+ logger.error(diagnostic(
+ "GLOBAL_CONFIG_PATH_RESOLUTION_FAILED",
+ "Failed to resolve the global config path",
+ line("The runtime could not determine the expected global config file location."),
+ Some(line(
+ "Ensure the required host config exists before retrying tnmsc.",
+ )),
+ None,
+ optional_details(serde_json::json!({ "error": error })),
+ ));
+ return GlobalConfigValidationResult {
+ valid: false,
+ exists: false,
+ errors: vec![error],
+ should_exit: true,
+ };
+ }
+ };
if !config_path.exists() {
logger.warn(diagnostic(
@@ -690,6 +1148,29 @@ mod tests {
);
}
+ #[test]
+ fn test_user_config_file_deserialize_with_windows_wsl2_instances() {
+ let json = r#"{
+ "windows": {
+ "wsl2": {
+ "instances": ["Ubuntu", "Debian"]
+ }
+ }
+ }"#;
+ let config: UserConfigFile = serde_json::from_str(json).unwrap();
+
+ match config
+ .windows
+ .and_then(|windows| windows.wsl2)
+ .and_then(|wsl2| wsl2.instances)
+ {
+ Some(StringOrStrings::Multiple(instances)) => {
+ assert_eq!(instances, vec!["Ubuntu".to_string(), "Debian".to_string()]);
+ }
+ other => panic!("expected windows.wsl2.instances array, got {:?}", other),
+ }
+ }
+
#[test]
fn test_user_config_file_roundtrip() {
let config = UserConfigFile {
@@ -752,6 +1233,32 @@ mod tests {
);
}
+ #[test]
+ fn test_merge_configs_merges_windows_options() {
+ let base_config = UserConfigFile {
+ windows: Some(WindowsOptions {
+ wsl2: Some(WindowsWsl2Options {
+ instances: Some(StringOrStrings::Single("Ubuntu".into())),
+ }),
+ }),
+ ..Default::default()
+ };
+ let override_config = UserConfigFile {
+ log_level: Some("debug".into()),
+ ..Default::default()
+ };
+
+ let merged = merge_configs_pair(&base_config, &override_config);
+ match merged
+ .windows
+ .and_then(|windows| windows.wsl2)
+ .and_then(|wsl2| wsl2.instances)
+ {
+ Some(StringOrStrings::Single(instance)) => assert_eq!(instance, "Ubuntu"),
+ other => panic!("expected merged windows.wsl2.instances value, got {:?}", other),
+ }
+ }
+
#[test]
fn test_merge_aindex_deep() {
let cwd_config = UserConfigFile {
@@ -800,6 +1307,83 @@ mod tests {
assert_eq!(paths, vec![get_global_config_path()]);
}
+ #[test]
+ fn test_find_wsl_host_global_config_paths_with_root_sorts_candidates() {
+ let temp_dir = TempDir::new().unwrap();
+ let users_root = temp_dir.path().join("Users");
+ let alpha_config_path = users_root.join("alpha").join(".aindex").join(".tnmsc.json");
+ let bravo_config_path = users_root.join("bravo").join(".aindex").join(".tnmsc.json");
+
+ fs::create_dir_all(alpha_config_path.parent().unwrap()).unwrap();
+ fs::create_dir_all(bravo_config_path.parent().unwrap()).unwrap();
+ fs::write(&alpha_config_path, "{}\n").unwrap();
+ fs::write(&bravo_config_path, "{}\n").unwrap();
+
+ let candidates = find_wsl_host_global_config_paths_with_root(&users_root);
+ assert_eq!(candidates, vec![alpha_config_path, bravo_config_path]);
+ }
+
+ #[test]
+ fn test_select_wsl_host_global_config_path_for_prefers_matching_userprofile() {
+ let temp_dir = TempDir::new().unwrap();
+ let users_root = temp_dir.path().join("Users");
+ let alpha_config_path = users_root.join("alpha").join(".aindex").join(".tnmsc.json");
+ let bravo_config_path = users_root.join("bravo").join(".aindex").join(".tnmsc.json");
+
+ fs::create_dir_all(alpha_config_path.parent().unwrap()).unwrap();
+ fs::create_dir_all(bravo_config_path.parent().unwrap()).unwrap();
+ fs::write(&alpha_config_path, "{}\n").unwrap();
+ fs::write(&bravo_config_path, "{}\n").unwrap();
+
+ let selected = select_wsl_host_global_config_path_for(
+ &users_root,
+ Some(&users_root.join("bravo").to_string_lossy()),
+ None,
+ None,
+ None,
+ );
+
+ assert_eq!(selected, Some(bravo_config_path));
+ }
+
+ #[test]
+ fn test_select_wsl_host_global_config_path_for_rejects_other_windows_profile() {
+ let temp_dir = TempDir::new().unwrap();
+ let users_root = temp_dir.path().join("Users");
+ let alpha_config_path = users_root.join("alpha").join(".aindex").join(".tnmsc.json");
+
+ fs::create_dir_all(alpha_config_path.parent().unwrap()).unwrap();
+ fs::write(&alpha_config_path, "{}\n").unwrap();
+
+ let selected = select_wsl_host_global_config_path_for(
+ &users_root,
+ Some(&users_root.join("bravo").to_string_lossy()),
+ None,
+ None,
+ None,
+ );
+
+ assert_eq!(selected, None);
+ }
+
+ #[test]
+ fn test_is_wsl_runtime_for_detects_linux_wsl_inputs() {
+ assert!(is_wsl_runtime_for("linux", Some("Ubuntu"), None, ""));
+ assert!(is_wsl_runtime_for(
+ "linux",
+ None,
+ Some("/run/WSL/12_interop"),
+ ""
+ ));
+ assert!(is_wsl_runtime_for(
+ "linux",
+ None,
+ None,
+ "5.15.167.4-microsoft-standard-WSL2"
+ ));
+ assert!(!is_wsl_runtime_for("windows", Some("Ubuntu"), None, ""));
+ }
+
#[test]
fn test_config_loader_load_nonexistent() {
let loader = ConfigLoader::with_defaults();
@@ -875,14 +1459,16 @@ mod napi_binding {
#[napi]
pub fn load_user_config(cwd: String) -> napi::Result {
let path = std::path::Path::new(&cwd);
- let result = ConfigLoader::with_defaults().load(path);
+ let result = super::load_user_config(path).map_err(napi::Error::from_reason)?;
serde_json::to_string(&result.config).map_err(|e| napi::Error::from_reason(e.to_string()))
}
/// Get the global config file path (~/.aindex/.tnmsc.json).
#[napi]
- pub fn get_global_config_path_str() -> String {
- get_global_config_path().to_string_lossy().into_owned()
+ pub fn get_global_config_path_str() -> napi::Result {
+ get_required_global_config_path()
+ .map(|path| path.to_string_lossy().into_owned())
+ .map_err(napi::Error::from_reason)
}
/// Merge two config JSON strings. `over` fields take priority over `base`.
diff --git a/cli/src/inputs/AbstractInputCapability.ts b/cli/src/inputs/AbstractInputCapability.ts
index c3a318a3..fc44100c 100644
--- a/cli/src/inputs/AbstractInputCapability.ts
+++ b/cli/src/inputs/AbstractInputCapability.ts
@@ -14,13 +14,13 @@ import type {
} from '@/plugins/plugin-core'
import {spawn} from 'node:child_process'
-import * as os from 'node:os'
import * as path from 'node:path'
import {createLogger} from '@truenine/logger'
import {parseMarkdown} from '@truenine/md-compiler/markdown'
import {buildDiagnostic, diagnosticLines} from '@/diagnostics'
import {PathPlaceholders} from '@/plugins/plugin-core'
import {logProtectedDeletionGuardError, ProtectedDeletionGuardError} from '@/ProtectedDeletionGuard'
+import {resolveUserPath} from '@/runtime-environment'
export abstract class AbstractInputCapability implements InputCapability {
private readonly inputEffects: InputEffectRegistration[] = []
@@ -165,11 +165,11 @@ export abstract class AbstractInputCapability implements InputCapability {
protected resolvePath(rawPath: string, workspaceDir: string): string {
let resolved = rawPath
- if (resolved.startsWith(PathPlaceholders.USER_HOME)) resolved = resolved.replace(PathPlaceholders.USER_HOME, os.homedir())
-
if (resolved.includes(PathPlaceholders.WORKSPACE)) resolved = resolved.replace(PathPlaceholders.WORKSPACE, workspaceDir)
- return path.normalize(resolved)
+ if (resolved.startsWith(PathPlaceholders.USER_HOME)) return resolveUserPath(resolved)
+
+ return path.normalize(resolveUserPath(resolved))
}
protected resolveAindexPath(relativePath: string, aindexDir: string): string {
diff --git a/cli/src/inputs/input-global-memory.ts b/cli/src/inputs/input-global-memory.ts
index 0c8b7d70..3b5de5dd 100644
--- a/cli/src/inputs/input-global-memory.ts
+++ b/cli/src/inputs/input-global-memory.ts
@@ -1,17 +1,18 @@
import type {InputCapabilityContext, InputCollectedContext} from '../plugins/plugin-core'
-import * as os from 'node:os'
import process from 'node:process'
import {mdxToMd} from '@truenine/md-compiler'
import {CompilerDiagnosticError, ScopeError} from '@truenine/md-compiler/errors'
import {parseMarkdown} from '@truenine/md-compiler/markdown'
+import {getGlobalConfigPath} from '@/ConfigLoader'
import {
buildConfigDiagnostic,
buildPathStateDiagnostic,
buildPromptCompilerDiagnostic,
diagnosticLines
} from '@/diagnostics'
+import {getEffectiveHomeDir} from '@/runtime-environment'
import {AbstractInputCapability, FilePathKind, GlobalConfigDirectoryType, PromptKind} from '../plugins/plugin-core'
import {assertNoResidualModuleSyntax} from '../plugins/plugin-core/DistPromptGuards'
import {formatPromptCompilerDiagnostic} from '../plugins/plugin-core/PromptCompilerDiagnostics'
@@ -24,6 +25,8 @@ export class GlobalMemoryInputCapability extends AbstractInputCapability {
async collect(ctx: InputCapabilityContext): Promise> {
const {userConfigOptions: options, fs, path, globalScope} = ctx
const {aindexDir} = this.resolveBasePaths(options)
+ const globalConfigPath = getGlobalConfigPath()
+ const effectiveHomeDir = getEffectiveHomeDir()
const globalMemoryFile = this.resolveAindexPath(options.aindex.globalPrompt.dist, aindexDir)
@@ -84,11 +87,11 @@ export class GlobalMemoryInputCapability extends AbstractInputCapability {
code: 'GLOBAL_MEMORY_SCOPE_VARIABLES_MISSING',
title: 'Global memory prompt references missing config variables',
reason: diagnosticLines(
- 'The global memory prompt uses scope variables that are not defined in `~/.aindex/.tnmsc.json`.'
+ `The global memory prompt uses scope variables that are not defined in "${globalConfigPath}".`
),
- configPath: '~/.aindex/.tnmsc.json',
+ configPath: globalConfigPath,
exactFix: diagnosticLines(
- 'Add the missing variables to `~/.aindex/.tnmsc.json` and rerun tnmsc.'
+ `Add the missing variables to "${globalConfigPath}" and rerun tnmsc.`
),
possibleFixes: [
diagnosticLines('If you reference `{profile.name}`, define `profile.name` in the config file.')
@@ -127,9 +130,9 @@ export class GlobalMemoryInputCapability extends AbstractInputCapability {
directory: {
pathKind: FilePathKind.Relative,
path: '',
- basePath: os.homedir(),
- getDirectoryName: () => path.basename(os.homedir()),
- getAbsolutePath: () => os.homedir()
+ basePath: effectiveHomeDir,
+ getDirectoryName: () => path.basename(effectiveHomeDir),
+ getAbsolutePath: () => effectiveHomeDir
}
}
}
diff --git a/cli/src/inputs/input-project-prompt.ts b/cli/src/inputs/input-project-prompt.ts
index 7204590a..44ee79e6 100644
--- a/cli/src/inputs/input-project-prompt.ts
+++ b/cli/src/inputs/input-project-prompt.ts
@@ -12,6 +12,7 @@ import process from 'node:process'
import {mdxToMd} from '@truenine/md-compiler'
import {CompilerDiagnosticError, ScopeError} from '@truenine/md-compiler/errors'
import {parseMarkdown} from '@truenine/md-compiler/markdown'
+import {getGlobalConfigPath} from '@/ConfigLoader'
import {
buildConfigDiagnostic,
buildFileOperationDiagnostic,
@@ -126,15 +127,16 @@ export class ProjectPromptInputCapability extends AbstractInputCapability {
}
}))
if (e instanceof ScopeError) {
+ const globalConfigPath = getGlobalConfigPath()
logger.error(buildConfigDiagnostic({
code: 'WORKSPACE_ROOT_MEMORY_SCOPE_VARIABLES_MISSING',
title: 'Workspace root memory prompt references missing config variables',
reason: diagnosticLines(
- 'The workspace root memory prompt uses scope variables that are not defined in `~/.aindex/.tnmsc.json`.'
+ `The workspace root memory prompt uses scope variables that are not defined in "${globalConfigPath}".`
),
- configPath: '~/.aindex/.tnmsc.json',
+ configPath: globalConfigPath,
exactFix: diagnosticLines(
- 'Define the missing variables in `~/.aindex/.tnmsc.json` and rerun tnmsc.'
+ `Define the missing variables in "${globalConfigPath}" and rerun tnmsc.`
),
details: {
promptPath: filePath,
@@ -231,15 +233,16 @@ export class ProjectPromptInputCapability extends AbstractInputCapability {
}
}))
if (e instanceof ScopeError) {
+ const globalConfigPath = getGlobalConfigPath()
logger.error(buildConfigDiagnostic({
code: 'PROJECT_ROOT_MEMORY_SCOPE_VARIABLES_MISSING',
title: 'Project root memory prompt references missing config variables',
reason: diagnosticLines(
- 'The project root memory prompt uses scope variables that are not defined in `~/.aindex/.tnmsc.json`.'
+ `The project root memory prompt uses scope variables that are not defined in "${globalConfigPath}".`
),
- configPath: '~/.aindex/.tnmsc.json',
+ configPath: globalConfigPath,
exactFix: diagnosticLines(
- 'Define the missing variables in `~/.aindex/.tnmsc.json` and rerun tnmsc.'
+ `Define the missing variables in "${globalConfigPath}" and rerun tnmsc.`
),
details: {
promptPath: filePath,
@@ -377,15 +380,16 @@ export class ProjectPromptInputCapability extends AbstractInputCapability {
}
}))
if (e instanceof ScopeError) {
+ const globalConfigPath = getGlobalConfigPath()
logger.error(buildConfigDiagnostic({
code: 'PROJECT_CHILD_MEMORY_SCOPE_VARIABLES_MISSING',
title: 'Project child memory prompt references missing config variables',
reason: diagnosticLines(
- 'The project child memory prompt uses scope variables that are not defined in `~/.aindex/.tnmsc.json`.'
+ `The project child memory prompt uses scope variables that are not defined in "${globalConfigPath}".`
),
- configPath: '~/.aindex/.tnmsc.json',
+ configPath: globalConfigPath,
exactFix: diagnosticLines(
- 'Define the missing variables in `~/.aindex/.tnmsc.json` and rerun tnmsc.'
+ `Define the missing variables in "${globalConfigPath}" and rerun tnmsc.`
),
details: {
promptPath: filePath,
diff --git a/cli/src/inputs/input-readme.ts b/cli/src/inputs/input-readme.ts
index 3a5647f8..086f9098 100644
--- a/cli/src/inputs/input-readme.ts
+++ b/cli/src/inputs/input-readme.ts
@@ -4,6 +4,7 @@ import process from 'node:process'
import {mdxToMd} from '@truenine/md-compiler'
import {CompilerDiagnosticError, ScopeError} from '@truenine/md-compiler/errors'
+import {getGlobalConfigPath} from '@/ConfigLoader'
import {
buildConfigDiagnostic,
buildFileOperationDiagnostic,
@@ -117,15 +118,16 @@ export class ReadmeMdInputCapability extends AbstractInputCapability {
}
}))
if (e instanceof ScopeError) {
+ const globalConfigPath = getGlobalConfigPath()
logger.error(buildConfigDiagnostic({
code: 'README_SCOPE_VARIABLES_MISSING',
title: 'Readme-family prompt references missing config variables',
reason: diagnosticLines(
- 'The readme-family prompt uses scope variables that are not defined in `~/.aindex/.tnmsc.json`.'
+ `The readme-family prompt uses scope variables that are not defined in "${globalConfigPath}".`
),
- configPath: '~/.aindex/.tnmsc.json',
+ configPath: globalConfigPath,
exactFix: diagnosticLines(
- 'Define the missing variables in `~/.aindex/.tnmsc.json` and rerun tnmsc.'
+ `Define the missing variables in "${globalConfigPath}" and rerun tnmsc.`
),
details: {
promptPath: filePath,
diff --git a/cli/src/lib.rs b/cli/src/lib.rs
index 1c1a1b20..01062b49 100644
--- a/cli/src/lib.rs
+++ b/cli/src/lib.rs
@@ -54,12 +54,16 @@ pub fn version() -> &'static str {
/// Load and merge configuration from the canonical global config path.
pub fn load_config(cwd: &Path) -> Result {
- Ok(core::config::ConfigLoader::with_defaults().load(cwd))
+ core::config::ConfigLoader::with_defaults()
+ .try_load(cwd)
+ .map_err(CliError::ConfigError)
}
/// Return the merged global configuration as a pretty-printed JSON string.
pub fn config_show(cwd: &Path) -> Result {
- let result = core::config::ConfigLoader::with_defaults().load(cwd);
+ let result = core::config::ConfigLoader::with_defaults()
+ .try_load(cwd)
+ .map_err(CliError::ConfigError)?;
serde_json::to_string_pretty(&result.config).map_err(CliError::from)
}
diff --git a/cli/src/plugins/ClaudeCodeCLIOutputPlugin.ts b/cli/src/plugins/ClaudeCodeCLIOutputPlugin.ts
index 85ee8e0d..1ff83185 100644
--- a/cli/src/plugins/ClaudeCodeCLIOutputPlugin.ts
+++ b/cli/src/plugins/ClaudeCodeCLIOutputPlugin.ts
@@ -53,6 +53,10 @@ export class ClaudeCodeCLIOutputPlugin extends AbstractOutputPlugin {
}
}
},
+ wslMirrors: [
+ '~/.claude/settings.json',
+ '~/.claude/config.json'
+ ],
capabilities: {
prompt: {
scopes: ['project', 'global'],
diff --git a/cli/src/plugins/CodexCLIOutputPlugin.ts b/cli/src/plugins/CodexCLIOutputPlugin.ts
index 83146137..986cdcfa 100644
--- a/cli/src/plugins/CodexCLIOutputPlugin.ts
+++ b/cli/src/plugins/CodexCLIOutputPlugin.ts
@@ -60,6 +60,10 @@ const CODEX_OUTPUT_OPTIONS = {
}
}
},
+ wslMirrors: [
+ '~/.codex/config.toml',
+ '~/.codex/auth.json'
+ ],
dependsOn: [PLUGIN_NAMES.AgentsOutput],
capabilities: {
prompt: {
diff --git a/cli/src/plugins/WslMirrorDeclarations.test.ts b/cli/src/plugins/WslMirrorDeclarations.test.ts
new file mode 100644
index 00000000..69f48e58
--- /dev/null
+++ b/cli/src/plugins/WslMirrorDeclarations.test.ts
@@ -0,0 +1,25 @@
+import {describe, expect, it} from 'vitest'
+import {ClaudeCodeCLIOutputPlugin} from './ClaudeCodeCLIOutputPlugin'
+import {CodexCLIOutputPlugin} from './CodexCLIOutputPlugin'
+
+describe('wSL mirror declarations', () => {
+ it('declares the expected Claude host config files', async () => {
+ const plugin = new ClaudeCodeCLIOutputPlugin()
+ const declarations = await plugin.declareWslMirrorFiles?.({} as never)
+
+ expect(declarations).toEqual([
+ {sourcePath: '~/.claude/settings.json'},
+ {sourcePath: '~/.claude/config.json'}
+ ])
+ })
+
+ it('declares the expected Codex host config files', async () => {
+ const plugin = new CodexCLIOutputPlugin()
+ const declarations = await plugin.declareWslMirrorFiles?.({} as never)
+
+ expect(declarations).toEqual([
+ {sourcePath: '~/.codex/config.toml'},
+ {sourcePath: '~/.codex/auth.json'}
+ ])
+ })
+})
diff --git a/cli/src/plugins/desk-paths.test.ts b/cli/src/plugins/desk-paths.test.ts
new file mode 100644
index 00000000..8dc16f43
--- /dev/null
+++ b/cli/src/plugins/desk-paths.test.ts
@@ -0,0 +1,66 @@
+import * as path from 'node:path'
+import {afterEach, describe, expect, it, vi} from 'vitest'
+
+import {getPlatformFixedDir} from './desk-paths'
+
+const {resolveRuntimeEnvironmentMock, resolveUserPathMock} = vi.hoisted(() => ({
+ resolveRuntimeEnvironmentMock: vi.fn(),
+ resolveUserPathMock: vi.fn((value: string) => value)
+}))
+
+vi.mock('@/runtime-environment', async importActual => {
+ const actual = await importActual()
+ return {
+ ...actual,
+ resolveRuntimeEnvironment: resolveRuntimeEnvironmentMock,
+ resolveUserPath: resolveUserPathMock
+ }
+})
+
+const originalXdgDataHome = process.env['XDG_DATA_HOME']
+const originalLocalAppData = process.env['LOCALAPPDATA']
+
+describe('desk paths', () => {
+ afterEach(() => {
+ vi.clearAllMocks()
+
+ if (originalXdgDataHome == null) delete process.env['XDG_DATA_HOME']
+ else process.env['XDG_DATA_HOME'] = originalXdgDataHome
+ if (originalLocalAppData == null) delete process.env['LOCALAPPDATA']
+ else process.env['LOCALAPPDATA'] = originalLocalAppData
+ })
+
+ it('uses linux data paths outside WSL', () => {
+ delete process.env['XDG_DATA_HOME']
+ resolveRuntimeEnvironmentMock.mockReturnValue({
+ platform: 'linux',
+ isWsl: false,
+ nativeHomeDir: '/home/alpha',
+ effectiveHomeDir: '/home/alpha',
+ globalConfigCandidates: [],
+ windowsUsersRoot: '/mnt/c/Users',
+ expandedEnv: {}
+ })
+
+ expect(getPlatformFixedDir().replaceAll('\\', '/')).toBe(path.join('/home/alpha', '.local', 'share').replaceAll('\\', '/'))
+ })
+
+ it('uses Windows fixed-dir semantics when WSL targets the host home', () => {
+ process.env['LOCALAPPDATA'] = 'C:\\Users\\alpha\\AppData\\Local'
+ resolveRuntimeEnvironmentMock.mockReturnValue({
+ platform: 'linux',
+ isWsl: true,
+ nativeHomeDir: '/home/alpha',
+ effectiveHomeDir: '/mnt/c/Users/alpha',
+ globalConfigCandidates: ['/mnt/c/Users/alpha/.aindex/.tnmsc.json'],
+ selectedGlobalConfigPath: '/mnt/c/Users/alpha/.aindex/.tnmsc.json',
+ wslHostHomeDir: '/mnt/c/Users/alpha',
+ windowsUsersRoot: '/mnt/c/Users',
+ expandedEnv: {}
+ })
+ resolveUserPathMock.mockReturnValue('/mnt/c/Users/alpha/AppData/Local')
+
+ expect(getPlatformFixedDir()).toBe('/mnt/c/Users/alpha/AppData/Local')
+ expect(resolveUserPathMock).toHaveBeenCalledWith('C:\\Users\\alpha\\AppData\\Local')
+ })
+})
diff --git a/cli/src/plugins/desk-paths.ts b/cli/src/plugins/desk-paths.ts
index e8dc920a..84f98fe1 100644
--- a/cli/src/plugins/desk-paths.ts
+++ b/cli/src/plugins/desk-paths.ts
@@ -1,10 +1,10 @@
import type {Buffer} from 'node:buffer'
import type {LoggerDiagnosticInput} from './plugin-core'
import * as fs from 'node:fs'
-import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
import {buildFileOperationDiagnostic} from '@/diagnostics'
+import {resolveRuntimeEnvironment, resolveUserPath} from '@/runtime-environment'
/**
* Represents a fixed set of platform directory identifiers.
@@ -32,7 +32,7 @@ type PlatformFixedDir = 'win32' | 'darwin' | 'linux'
*/
function getLinuxDataDir(homeDir: string): string {
const xdgDataHome = process.env['XDG_DATA_HOME']
- if (typeof xdgDataHome === 'string' && xdgDataHome.trim().length > 0) return xdgDataHome
+ if (typeof xdgDataHome === 'string' && xdgDataHome.trim().length > 0) return resolveUserPath(xdgDataHome)
return path.join(homeDir, '.local', 'share')
}
@@ -44,10 +44,11 @@ function getLinuxDataDir(homeDir: string): string {
* @throws {Error} If the platform is unsupported.
*/
export function getPlatformFixedDir(): string {
- const platform = process.platform as PlatformFixedDir
- const homeDir = os.homedir()
+ const runtimeEnvironment = resolveRuntimeEnvironment()
+ const platform = (runtimeEnvironment.isWsl ? 'win32' : runtimeEnvironment.platform) as PlatformFixedDir
+ const homeDir = runtimeEnvironment.effectiveHomeDir
- if (platform === 'win32') return process.env['LOCALAPPDATA'] ?? path.join(homeDir, 'AppData', 'Local')
+ if (platform === 'win32') return resolveUserPath(process.env['LOCALAPPDATA'] ?? path.join(homeDir, 'AppData', 'Local'))
if (platform === 'darwin') return path.join(homeDir, 'Library', 'Application Support')
if (platform === 'linux') return getLinuxDataDir(homeDir)
diff --git a/cli/src/plugins/plugin-core/AbstractOutputPlugin.ts b/cli/src/plugins/plugin-core/AbstractOutputPlugin.ts
index 4080230d..6100c915 100644
--- a/cli/src/plugins/plugin-core/AbstractOutputPlugin.ts
+++ b/cli/src/plugins/plugin-core/AbstractOutputPlugin.ts
@@ -1,15 +1,15 @@
import type {BuildPromptTomlArtifactOptions} from '@truenine/md-compiler'
import type {ToolPresetName} from './GlobalScopeCollector'
import type {RegistryWriter} from './RegistryWriter'
-import type {CommandPrompt, CommandSeriesPluginOverride, ILogger, OutputCleanContext, OutputCleanupDeclarations, OutputCleanupPathDeclaration, OutputCleanupScope, OutputDeclarationScope, OutputFileDeclaration, OutputPlugin, OutputPluginCapabilities, OutputPluginContext, OutputScopeSelection, OutputScopeTopic, OutputTopicCapability, OutputWriteContext, Path, Project, ProjectConfig, RegistryData, RegistryOperationResult, RulePrompt, RuleScope, SkillPrompt, SubAgentPrompt} from './types'
+import type {CommandPrompt, CommandSeriesPluginOverride, ILogger, OutputCleanContext, OutputCleanupDeclarations, OutputCleanupPathDeclaration, OutputCleanupScope, OutputDeclarationScope, OutputFileDeclaration, OutputPlugin, OutputPluginCapabilities, OutputPluginContext, OutputScopeSelection, OutputScopeTopic, OutputTopicCapability, OutputWriteContext, Path, Project, ProjectConfig, RegistryData, RegistryOperationResult, RulePrompt, RuleScope, SkillPrompt, SubAgentPrompt, WslMirrorFileDeclaration} from './types'
import {Buffer} from 'node:buffer'
-import * as os from 'node:os'
import * as path from 'node:path'
import process from 'node:process'
import {buildPromptTomlArtifact, mdxToMd} from '@truenine/md-compiler'
import {buildMarkdownWithFrontMatter, buildMarkdownWithRawFrontMatter} from '@truenine/md-compiler/markdown'
import {buildConfigDiagnostic, diagnosticLines} from '@/diagnostics'
+import {getEffectiveHomeDir} from '@/runtime-environment'
import {AbstractPlugin} from './AbstractPlugin'
import {FilePathKind, PluginKind} from './enums'
import {
@@ -197,6 +197,9 @@ export interface AbstractOutputPluginOptions {
/** Cleanup configuration (declarative) */
cleanup?: OutputCleanupConfig
+ /** Host-home files that should be mirrored into configured WSL instances */
+ wslMirrors?: readonly string[]
+
/** Explicit output capability matrix for scope override validation */
capabilities?: OutputPluginCapabilities
@@ -290,6 +293,8 @@ export abstract class AbstractOutputPlugin extends AbstractPlugin implements Out
protected readonly cleanupConfig: OutputCleanupConfig
+ protected readonly wslMirrorPaths: readonly string[]
+
protected readonly supportsBlankLineAfterFrontMatter: boolean
private readonly registryWriterCache: Map> = new Map()
@@ -317,6 +322,7 @@ export abstract class AbstractOutputPlugin extends AbstractPlugin implements Out
sourceScopes: options?.rules?.sourceScopes ?? ['project', 'global']
} // Initialize rule output config with defaults
this.cleanupConfig = options?.cleanup ?? {}
+ this.wslMirrorPaths = options?.wslMirrors ?? []
this.supportsBlankLineAfterFrontMatter = options?.supportsBlankLineAfterFrontMatter ?? true
this.outputCapabilities = options?.capabilities != null
@@ -538,7 +544,7 @@ export abstract class AbstractOutputPlugin extends AbstractPlugin implements Out
}
protected getHomeDir(): string {
- return os.homedir()
+ return getEffectiveHomeDir()
}
protected joinPath(...segments: string[]): string {
@@ -1113,6 +1119,11 @@ export abstract class AbstractOutputPlugin extends AbstractPlugin implements Out
}
}
+ async declareWslMirrorFiles(ctx: OutputWriteContext): Promise {
+ void ctx
+ return this.wslMirrorPaths.map(sourcePath => ({sourcePath}))
+ }
+
async convertContent(
declaration: OutputFileDeclaration,
ctx: OutputWriteContext
diff --git a/cli/src/plugins/plugin-core/ConfigTypes.schema.ts b/cli/src/plugins/plugin-core/ConfigTypes.schema.ts
index a48579cd..03fd772b 100644
--- a/cli/src/plugins/plugin-core/ConfigTypes.schema.ts
+++ b/cli/src/plugins/plugin-core/ConfigTypes.schema.ts
@@ -82,6 +82,13 @@ export const ZCleanupProtectionRule = z.object({
})
export const ZCleanupProtectionOptions = z.object({rules: z.array(ZCleanupProtectionRule).optional()})
+export const ZStringOrStringArray = z.union([z.string(), z.array(z.string()).min(1)])
+export const ZWindowsWsl2Options = z.object({
+ instances: ZStringOrStringArray.optional()
+})
+export const ZWindowsOptions = z.object({
+ wsl2: ZWindowsWsl2Options.optional()
+})
/**
* Zod schema for user profile information.
@@ -105,6 +112,7 @@ export const ZUserConfigFile = z.object({
outputScopes: ZOutputScopeOptions.optional(),
frontMatter: ZFrontMatterOptions.optional(),
cleanupProtection: ZCleanupProtectionOptions.optional(),
+ windows: ZWindowsOptions.optional(),
profile: ZUserProfile.optional()
})
@@ -152,6 +160,9 @@ export type ProtectionMode = z.infer
export type ProtectionRuleMatcher = z.infer
export type CleanupProtectionRule = z.infer
export type CleanupProtectionOptions = z.infer
+export type StringOrStringArray = z.infer
+export type WindowsWsl2Options = z.infer
+export type WindowsOptions = z.infer
export type UserConfigFile = z.infer
export type McpProjectConfig = z.infer
export type TypeSeriesConfig = z.infer
diff --git a/cli/src/plugins/plugin-core/GlobalScopeCollector.ts b/cli/src/plugins/plugin-core/GlobalScopeCollector.ts
index 45042e26..2e6157b8 100644
--- a/cli/src/plugins/plugin-core/GlobalScopeCollector.ts
+++ b/cli/src/plugins/plugin-core/GlobalScopeCollector.ts
@@ -4,6 +4,7 @@ import type {UserConfigFile} from './types'
import * as os from 'node:os'
import process from 'node:process'
import {OsKind, ShellKind, ToolPresets} from '@truenine/md-compiler/globals'
+import {getEffectiveHomeDir} from '@/runtime-environment'
/**
* Tool preset names supported by GlobalScopeCollector
@@ -49,7 +50,7 @@ export class GlobalScopeCollector {
platform,
arch: os.arch(),
hostname: os.hostname(),
- homedir: os.homedir(),
+ homedir: getEffectiveHomeDir(),
tmpdir: os.tmpdir(),
type: os.type(),
release: os.release(),
diff --git a/cli/src/plugins/plugin-core/RegistryWriter.ts b/cli/src/plugins/plugin-core/RegistryWriter.ts
index 1b4a334f..4e74cd69 100644
--- a/cli/src/plugins/plugin-core/RegistryWriter.ts
+++ b/cli/src/plugins/plugin-core/RegistryWriter.ts
@@ -10,7 +10,6 @@
import type {ILogger, RegistryData, RegistryOperationResult} from './types'
import * as fs from 'node:fs'
-import * as os from 'node:os'
import * as path from 'node:path'
import {createLogger} from '@truenine/logger'
import {
@@ -18,6 +17,7 @@ import {
buildFileOperationDiagnostic,
diagnosticLines
} from '@/diagnostics'
+import {resolveUserPath} from '@/runtime-environment'
/**
* Abstract base class for registry configuration writers.
@@ -42,7 +42,7 @@ export abstract class RegistryWriter<
}
protected resolvePath(p: string): string {
- if (p.startsWith('~')) return path.join(os.homedir(), p.slice(1))
+ if (p.startsWith('~')) return resolveUserPath(p)
return path.resolve(p)
}
diff --git a/cli/src/plugins/plugin-core/plugin.ts b/cli/src/plugins/plugin-core/plugin.ts
index 4c287fbf..d4def91e 100644
--- a/cli/src/plugins/plugin-core/plugin.ts
+++ b/cli/src/plugins/plugin-core/plugin.ts
@@ -8,7 +8,8 @@ import type {
OutputScopeOptions,
OutputScopeSelection,
PluginOutputScopeTopics,
- ProtectionMode
+ ProtectionMode,
+ WindowsOptions
} from './ConfigTypes.schema'
import type {PluginKind} from './enums'
import type {
@@ -100,6 +101,16 @@ export interface OutputWriteContext extends OutputPluginContext {
readonly registeredPluginNames?: readonly string[]
}
+/**
+ * Declarative host-home file that should be mirrored into configured WSL instances.
+ */
+export interface WslMirrorFileDeclaration {
+ /** Source path on the Windows host, typically under ~ */
+ readonly sourcePath: string
+ /** Optional label for diagnostics/logging */
+ readonly label?: string
+}
+
/**
* Result of a single write operation
*/
@@ -219,6 +230,8 @@ export interface OutputPlugin extends Plugin {
convertContent: (declaration: OutputFileDeclaration, ctx: OutputWriteContext) => Awaitable
declareCleanupPaths?: (ctx: OutputCleanContext) => Awaitable
+
+ declareWslMirrorFiles?: (ctx: OutputWriteContext) => Awaitable
}
/**
@@ -530,6 +543,8 @@ export interface PluginOptions {
readonly cleanupProtection?: CleanupProtectionOptions
+ readonly windows?: WindowsOptions
+
plugins?: readonly (InputCapability | OutputPlugin)[]
logLevel?: 'trace' | 'debug' | 'info' | 'warn' | 'error'
}
diff --git a/cli/src/prompts.ts b/cli/src/prompts.ts
index 2e27eb69..554b80b2 100644
--- a/cli/src/prompts.ts
+++ b/cli/src/prompts.ts
@@ -1,12 +1,12 @@
import type {PluginOptions, YAMLFrontMatter} from '@/plugins/plugin-core'
import * as fs from 'node:fs'
-import * as os from 'node:os'
import * as path from 'node:path'
import {parseMarkdown} from '@truenine/md-compiler/markdown'
import glob from 'fast-glob'
import {mergeConfig, userConfigToPluginOptions} from './config'
import {getConfigLoader} from './ConfigLoader'
import {PathPlaceholders} from './plugins/plugin-core'
+import {resolveUserPath} from './runtime-environment'
export type ManagedPromptKind
= | 'global-memory'
@@ -144,11 +144,9 @@ function isSingleSegmentIdentifier(value: string): boolean {
function resolveConfiguredPath(rawPath: string, workspaceDir: string): string {
let resolved = rawPath
- if (resolved.startsWith(PathPlaceholders.USER_HOME)) resolved = resolved.replace(PathPlaceholders.USER_HOME, os.homedir())
-
if (resolved.includes(PathPlaceholders.WORKSPACE)) resolved = resolved.replace(PathPlaceholders.WORKSPACE, workspaceDir)
- return path.normalize(resolved)
+ return resolveUserPath(resolved)
}
function resolvePromptEnvironment(options: PromptServiceOptions = {}): ResolvedPromptEnvironment {
diff --git a/cli/src/runtime-environment.test.ts b/cli/src/runtime-environment.test.ts
new file mode 100644
index 00000000..ad577c0c
--- /dev/null
+++ b/cli/src/runtime-environment.test.ts
@@ -0,0 +1,149 @@
+import * as fs from 'node:fs'
+import * as os from 'node:os'
+import * as path from 'node:path'
+import {afterEach, describe, expect, it} from 'vitest'
+import {
+ getRequiredGlobalConfigPath,
+ resolveRuntimeEnvironment,
+ resolveUserPath
+} from './runtime-environment'
+
+describe('runtime environment', () => {
+ let tempDir: string | undefined
+
+ afterEach(() => {
+ if (tempDir != null) fs.rmSync(tempDir, {recursive: true, force: true})
+ tempDir = void 0
+ })
+
+ it('uses the native Windows home config path when running on Windows', () => {
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-win-runtime-'))
+ const windowsHomeDir = path.join(tempDir, 'WindowsHome')
+ const configPath = path.join(windowsHomeDir, '.aindex', '.tnmsc.json')
+
+ fs.mkdirSync(path.dirname(configPath), {recursive: true})
+ fs.writeFileSync(configPath, '{}\n', 'utf8')
+
+ const runtimeEnvironment = resolveRuntimeEnvironment({
+ fs,
+ platform: 'win32',
+ env: {
+ USERPROFILE: windowsHomeDir
+ },
+ homedir: windowsHomeDir
+ })
+
+ expect(runtimeEnvironment.isWsl).toBe(false)
+ expect(runtimeEnvironment.selectedGlobalConfigPath).toBeUndefined()
+ expect(runtimeEnvironment.effectiveHomeDir).toBe(windowsHomeDir)
+ expect(getRequiredGlobalConfigPath({
+ fs,
+ platform: 'win32',
+ env: {
+ USERPROFILE: windowsHomeDir
+ },
+ homedir: windowsHomeDir
+ })).toBe(configPath)
+ expect(resolveUserPath('~/.codex/config.toml', {
+ fs,
+ platform: 'win32',
+ env: {
+ USERPROFILE: windowsHomeDir
+ },
+ homedir: windowsHomeDir
+ })).toBe(path.join(windowsHomeDir, '.codex', 'config.toml'))
+ })
+
+ it('selects the host config path that matches the current Windows profile in WSL', () => {
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-wsl-runtime-'))
+ const usersRoot = path.join(tempDir, 'Users')
+ const alphaConfigPath = path.join(usersRoot, 'alpha', '.aindex', '.tnmsc.json')
+ const bravoConfigPath = path.join(usersRoot, 'bravo', '.aindex', '.tnmsc.json')
+
+ fs.mkdirSync(path.dirname(alphaConfigPath), {recursive: true})
+ fs.mkdirSync(path.dirname(bravoConfigPath), {recursive: true})
+ fs.writeFileSync(alphaConfigPath, '{}\n', 'utf8')
+ fs.writeFileSync(bravoConfigPath, '{}\n', 'utf8')
+
+ const runtimeEnvironment = resolveRuntimeEnvironment({
+ fs,
+ platform: 'linux',
+ env: {
+ WSL_DISTRO_NAME: 'Ubuntu',
+ USERPROFILE: path.join(usersRoot, 'bravo')
+ },
+ homedir: '/home/linux-user',
+ windowsUsersRoot: usersRoot
+ })
+
+ expect(runtimeEnvironment.isWsl).toBe(true)
+ expect(runtimeEnvironment.selectedGlobalConfigPath).toBe(bravoConfigPath)
+ expect(runtimeEnvironment.effectiveHomeDir).toBe(path.join(usersRoot, 'bravo').replaceAll('\\', '/'))
+ expect(getRequiredGlobalConfigPath({
+ fs,
+ platform: 'linux',
+ env: {
+ WSL_DISTRO_NAME: 'Ubuntu',
+ USERPROFILE: path.join(usersRoot, 'bravo')
+ },
+ homedir: '/home/linux-user',
+ windowsUsersRoot: usersRoot
+ })).toBe(bravoConfigPath)
+ })
+
+ it('fails when the discovered config belongs to another Windows profile', () => {
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-wsl-runtime-mismatch-'))
+ const usersRoot = path.join(tempDir, 'Users')
+ const alphaConfigPath = path.join(usersRoot, 'alpha', '.aindex', '.tnmsc.json')
+
+ fs.mkdirSync(path.dirname(alphaConfigPath), {recursive: true})
+ fs.writeFileSync(alphaConfigPath, '{}\n', 'utf8')
+
+ expect(() => getRequiredGlobalConfigPath({
+ fs,
+ platform: 'linux',
+ env: {
+ WSL_DISTRO_NAME: 'Ubuntu',
+ USERPROFILE: path.join(usersRoot, 'bravo')
+ },
+ homedir: '/home/linux-user',
+ windowsUsersRoot: usersRoot
+ })).toThrow('current Windows user')
+ })
+
+ it('fails when WSL is active but no host config exists', () => {
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-wsl-runtime-missing-'))
+
+ expect(() => getRequiredGlobalConfigPath({
+ fs,
+ platform: 'linux',
+ env: {WSL_DISTRO_NAME: 'Ubuntu'},
+ homedir: '/home/linux-user',
+ windowsUsersRoot: path.join(tempDir, 'Users')
+ })).toThrow('WSL host config file not found')
+ })
+
+ it('maps host-home, windows drive, and environment-variable paths for WSL workloads', () => {
+ const runtimeEnvironment = {
+ platform: 'linux',
+ isWsl: true,
+ nativeHomeDir: '/home/linux-user',
+ effectiveHomeDir: '/mnt/c/Users/alpha',
+ globalConfigCandidates: ['/mnt/c/Users/alpha/.aindex/.tnmsc.json'],
+ selectedGlobalConfigPath: '/mnt/c/Users/alpha/.aindex/.tnmsc.json',
+ wslHostHomeDir: '/mnt/c/Users/alpha',
+ windowsUsersRoot: '/mnt/c/Users',
+ expandedEnv: {
+ HOME: '/mnt/c/Users/alpha',
+ USERPROFILE: '/mnt/c/Users/alpha',
+ HOMEDRIVE: 'C:',
+ HOMEPATH: '\\Users\\alpha'
+ }
+ } as const
+
+ expect(resolveUserPath('~/workspace\\foo', runtimeEnvironment)).toBe('/mnt/c/Users/alpha/workspace/foo')
+ expect(resolveUserPath('C:\\Work\\Repo', runtimeEnvironment)).toBe('/mnt/c/Work/Repo')
+ expect(resolveUserPath('%USERPROFILE%\\workspace\\bar', runtimeEnvironment)).toBe('/mnt/c/Users/alpha/workspace/bar')
+ expect(resolveUserPath('$HOME/workspace/baz', runtimeEnvironment)).toBe('/mnt/c/Users/alpha/workspace/baz')
+ })
+})
diff --git a/cli/src/runtime-environment.ts b/cli/src/runtime-environment.ts
new file mode 100644
index 00000000..7c2db229
--- /dev/null
+++ b/cli/src/runtime-environment.ts
@@ -0,0 +1,361 @@
+import * as fs from 'node:fs'
+import * as os from 'node:os'
+import * as path from 'node:path'
+import process from 'node:process'
+
+export const DEFAULT_WSL_WINDOWS_USERS_ROOT = '/mnt/c/Users'
+export const DEFAULT_GLOBAL_CONFIG_DIR = '.aindex'
+export const DEFAULT_GLOBAL_CONFIG_FILE_NAME = '.tnmsc.json'
+
+const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/u
+const PERCENT_ENV_PATTERN = /%([^%]+)%/gu
+const BRACED_ENV_PATTERN = /\$\{([A-Za-z_]\w*)\}/gu
+const SHELL_ENV_PATTERN = /\$([A-Za-z_]\w*)/gu
+
+type RuntimeFs = Pick
+
+export interface RuntimeEnvironmentDependencies {
+ readonly fs?: RuntimeFs
+ readonly env?: NodeJS.ProcessEnv
+ readonly platform?: NodeJS.Platform
+ readonly homedir?: string
+ readonly release?: string
+ readonly windowsUsersRoot?: string
+}
+
+export interface RuntimeEnvironmentContext {
+ readonly platform: NodeJS.Platform
+ readonly isWsl: boolean
+ readonly nativeHomeDir: string
+ readonly effectiveHomeDir: string
+ readonly globalConfigCandidates: readonly string[]
+ readonly selectedGlobalConfigPath?: string
+ readonly wslHostHomeDir?: string
+ readonly windowsUsersRoot: string
+ readonly expandedEnv: Readonly>
+}
+
+function isRuntimeEnvironmentContext(
+ value: RuntimeEnvironmentDependencies | RuntimeEnvironmentContext | undefined
+): value is RuntimeEnvironmentContext {
+ return value != null
+ && 'effectiveHomeDir' in value
+ && 'expandedEnv' in value
+}
+
+function getFs(dependencies?: RuntimeEnvironmentDependencies): RuntimeFs {
+ return dependencies?.fs ?? fs
+}
+
+function getPlatform(dependencies?: RuntimeEnvironmentDependencies): NodeJS.Platform {
+ return dependencies?.platform ?? process.platform
+}
+
+function getRelease(dependencies?: RuntimeEnvironmentDependencies): string {
+ return dependencies?.release ?? os.release()
+}
+
+function getNativeHomeDir(dependencies?: RuntimeEnvironmentDependencies): string {
+ return dependencies?.homedir ?? os.homedir()
+}
+
+function getEnv(dependencies?: RuntimeEnvironmentDependencies): NodeJS.ProcessEnv {
+ return dependencies?.env ?? process.env
+}
+
+function getWindowsUsersRoot(dependencies?: RuntimeEnvironmentDependencies): string {
+ return dependencies?.windowsUsersRoot ?? DEFAULT_WSL_WINDOWS_USERS_ROOT
+}
+
+function normalizePosixLikePath(rawPath: string): string {
+ return path.posix.normalize(rawPath.replaceAll('\\', '/'))
+}
+
+function isSameOrChildPath(candidatePath: string, parentPath: string): boolean {
+ const normalizedCandidate = normalizePosixLikePath(candidatePath)
+ const normalizedParent = normalizePosixLikePath(parentPath)
+
+ if (normalizedCandidate === normalizedParent) return true
+ return normalizedCandidate.startsWith(`${normalizedParent}/`)
+}
+
+function resolveWslHostHomeCandidate(
+ rawPath: string | undefined,
+ usersRoot: string
+): string | undefined {
+ if (typeof rawPath !== 'string') return void 0
+
+ const trimmedPath = rawPath.trim()
+ if (trimmedPath.length === 0) return void 0
+
+ const candidatePaths = [
+ convertWindowsPathToWsl(trimmedPath),
+ normalizePosixLikePath(trimmedPath)
+ ]
+
+ for (const candidatePath of candidatePaths) {
+ if (candidatePath == null) continue
+ if (isSameOrChildPath(candidatePath, usersRoot)) return normalizePosixLikePath(candidatePath)
+ }
+
+ return void 0
+}
+
+function getPreferredWslHostHomeDirs(
+ dependencies?: RuntimeEnvironmentDependencies
+): string[] {
+ const env = getEnv(dependencies)
+ const usersRoot = normalizePosixLikePath(getWindowsUsersRoot(dependencies))
+ const homeDrive = env['HOMEDRIVE']
+ const homePath = env['HOMEPATH']
+ const preferredHomeDirs = [
+ resolveWslHostHomeCandidate(env['USERPROFILE'], usersRoot),
+ typeof homeDrive === 'string' && homeDrive.length > 0 && typeof homePath === 'string' && homePath.length > 0
+ ? resolveWslHostHomeCandidate(`${homeDrive}${homePath}`, usersRoot)
+ : void 0,
+ resolveWslHostHomeCandidate(env['HOME'], usersRoot)
+ ]
+
+ return [...new Set(preferredHomeDirs.filter((candidate): candidate is string => candidate != null))]
+}
+
+function getWslHostHomeDirForConfigPath(configPath: string): string {
+ const normalizedConfigPath = normalizePosixLikePath(configPath)
+ return path.posix.dirname(path.posix.dirname(normalizedConfigPath))
+}
+
+function selectWslHostGlobalConfigPath(
+ globalConfigCandidates: readonly string[],
+ dependencies?: RuntimeEnvironmentDependencies
+): string | undefined {
+ const preferredHomeDirs = getPreferredWslHostHomeDirs(dependencies)
+
+ if (preferredHomeDirs.length <= 0) return globalConfigCandidates.length === 1 ? globalConfigCandidates[0] : void 0
+
+ for (const preferredHomeDir of preferredHomeDirs) {
+ const matchedCandidate = globalConfigCandidates.find(candidatePath =>
+ getWslHostHomeDirForConfigPath(candidatePath) === preferredHomeDir)
+ if (matchedCandidate != null) return matchedCandidate
+ }
+ return void 0
+}
+
+function isDirectory(fsImpl: RuntimeFs, targetPath: string): boolean {
+ try {
+ return fsImpl.statSync(targetPath).isDirectory()
+ }
+ catch {
+ return false
+ }
+}
+
+function isFile(fsImpl: RuntimeFs, targetPath: string): boolean {
+ try {
+ return fsImpl.statSync(targetPath).isFile()
+ }
+ catch {
+ return false
+ }
+}
+
+function getPathModule(platform: NodeJS.Platform): typeof path.posix | typeof path.win32 {
+ return platform === 'win32' ? path.win32 : path.posix
+}
+
+function buildExpandedEnv(
+ rawEnv: NodeJS.ProcessEnv,
+ nativeHomeDir: string,
+ effectiveHomeDir: string
+): Readonly> {
+ const expandedEnv: Record = {}
+
+ for (const [key, value] of Object.entries(rawEnv)) {
+ if (typeof value === 'string') expandedEnv[key] = value
+ }
+
+ if (effectiveHomeDir === nativeHomeDir) return expandedEnv
+
+ expandedEnv['HOME'] = effectiveHomeDir
+ expandedEnv['USERPROFILE'] = effectiveHomeDir
+ const hostHomeMatch = /^\/mnt\/([a-zA-Z])\/(.+)$/u.exec(effectiveHomeDir)
+ if (hostHomeMatch == null) return expandedEnv
+
+ const driveLetter = hostHomeMatch[1]
+ const relativePath = hostHomeMatch[2]
+ if (driveLetter == null || relativePath == null) return expandedEnv
+ expandedEnv['HOMEDRIVE'] = `${driveLetter.toUpperCase()}:`
+ expandedEnv['HOMEPATH'] = `\\${relativePath.replaceAll('/', '\\')}`
+ return expandedEnv
+}
+
+function expandEnvironmentVariables(
+ rawPath: string,
+ environment: Readonly>
+): string {
+ const replaceValue = (match: string, key: string): string => environment[key] ?? match
+
+ return rawPath
+ .replaceAll(PERCENT_ENV_PATTERN, replaceValue)
+ .replaceAll(BRACED_ENV_PATTERN, replaceValue)
+ .replaceAll(SHELL_ENV_PATTERN, replaceValue)
+}
+
+function expandHomeDirectory(
+ rawPath: string,
+ homeDir: string,
+ platform: NodeJS.Platform
+): string {
+ if (rawPath === '~') return homeDir
+ if (!(rawPath.startsWith('~/') || rawPath.startsWith('~\\'))) return rawPath
+
+ const pathModule = getPathModule(platform)
+ const normalizedSuffix = platform === 'win32'
+ ? rawPath.slice(2).replaceAll('/', '\\')
+ : rawPath.slice(2).replaceAll('\\', '/')
+
+ return pathModule.resolve(homeDir, normalizedSuffix)
+}
+
+function convertWindowsPathToWsl(rawPath: string): string | undefined {
+ if (!WINDOWS_DRIVE_PATH_PATTERN.test(rawPath)) return void 0
+
+ const driveLetter = rawPath.slice(0, 1).toLowerCase()
+ const relativePath = rawPath
+ .slice(2)
+ .replaceAll('\\', '/')
+ .replace(/^\/+/u, '')
+
+ const basePath = `/mnt/${driveLetter}`
+ if (relativePath.length === 0) return basePath
+ return path.posix.join(basePath, relativePath)
+}
+
+function normalizeResolvedPath(rawPath: string, platform: NodeJS.Platform): string {
+ if (platform === 'win32') return path.win32.normalize(rawPath.replaceAll('/', '\\'))
+ return path.posix.normalize(rawPath)
+}
+
+export function isWslRuntime(
+ dependencies?: RuntimeEnvironmentDependencies
+): boolean {
+ if (getPlatform(dependencies) !== 'linux') return false
+
+ const env = getEnv(dependencies)
+ if (typeof env['WSL_DISTRO_NAME'] === 'string' && env['WSL_DISTRO_NAME'].length > 0) return true
+ if (typeof env['WSL_INTEROP'] === 'string' && env['WSL_INTEROP'].length > 0) return true
+
+ return getRelease(dependencies).toLowerCase().includes('microsoft')
+}
+
+export function findWslHostGlobalConfigPaths(
+ dependencies?: RuntimeEnvironmentDependencies
+): string[] {
+ const fsImpl = getFs(dependencies)
+ const usersRoot = getWindowsUsersRoot(dependencies)
+
+ if (!isDirectory(fsImpl, usersRoot)) return []
+
+ try {
+ const dirEntries = fsImpl.readdirSync(usersRoot, {withFileTypes: true})
+ const candidates = dirEntries
+ .filter(dirEntry => dirEntry.isDirectory())
+ .map(dirEntry => path.join(usersRoot, dirEntry.name, DEFAULT_GLOBAL_CONFIG_DIR, DEFAULT_GLOBAL_CONFIG_FILE_NAME))
+ .filter(candidatePath => fsImpl.existsSync(candidatePath) && isFile(fsImpl, candidatePath))
+
+ candidates.sort((a, b) => a.localeCompare(b))
+ return candidates
+ }
+ catch {
+ return []
+ }
+}
+
+export function resolveRuntimeEnvironment(
+ dependencies?: RuntimeEnvironmentDependencies
+): RuntimeEnvironmentContext {
+ const platform = getPlatform(dependencies)
+ const nativeHomeDir = getNativeHomeDir(dependencies)
+ const wslRuntime = isWslRuntime(dependencies)
+ const globalConfigCandidates = wslRuntime ? findWslHostGlobalConfigPaths(dependencies) : []
+ const selectedGlobalConfigPath = wslRuntime
+ ? selectWslHostGlobalConfigPath(globalConfigCandidates, dependencies)
+ : void 0
+ const effectiveHomeDir = selectedGlobalConfigPath != null
+ ? getWslHostHomeDirForConfigPath(selectedGlobalConfigPath)
+ : nativeHomeDir
+
+ return {
+ platform,
+ isWsl: wslRuntime,
+ nativeHomeDir,
+ effectiveHomeDir,
+ globalConfigCandidates,
+ ...selectedGlobalConfigPath != null && {selectedGlobalConfigPath},
+ ...selectedGlobalConfigPath != null && {wslHostHomeDir: effectiveHomeDir},
+ windowsUsersRoot: getWindowsUsersRoot(dependencies),
+ expandedEnv: buildExpandedEnv(getEnv(dependencies), nativeHomeDir, effectiveHomeDir)
+ }
+}
+
+export function getEffectiveHomeDir(
+ dependencies?: RuntimeEnvironmentDependencies
+): string {
+ return resolveRuntimeEnvironment(dependencies).effectiveHomeDir
+}
+
+export function getGlobalConfigPath(
+ dependencies?: RuntimeEnvironmentDependencies
+): string {
+ const runtimeEnvironment = resolveRuntimeEnvironment(dependencies)
+ if (runtimeEnvironment.selectedGlobalConfigPath != null) return runtimeEnvironment.selectedGlobalConfigPath
+
+ return path.join(
+ runtimeEnvironment.effectiveHomeDir,
+ DEFAULT_GLOBAL_CONFIG_DIR,
+ DEFAULT_GLOBAL_CONFIG_FILE_NAME
+ )
+}
+
+export function getRequiredGlobalConfigPath(
+ dependencies?: RuntimeEnvironmentDependencies
+): string {
+ const runtimeEnvironment = resolveRuntimeEnvironment(dependencies)
+
+ if (!runtimeEnvironment.isWsl || runtimeEnvironment.selectedGlobalConfigPath != null) {
+ return getGlobalConfigPath(dependencies)
+ }
+
+ const configLookupPattern = `"${runtimeEnvironment.windowsUsersRoot}/*/${DEFAULT_GLOBAL_CONFIG_DIR}/${DEFAULT_GLOBAL_CONFIG_FILE_NAME}"`
+ if (runtimeEnvironment.globalConfigCandidates.length === 0) {
+ throw new Error(`WSL host config file not found under ${configLookupPattern}.`)
+ }
+ if (getPreferredWslHostHomeDirs(dependencies).length > 0) {
+ throw new Error(`WSL host config file for the current Windows user was not found under ${configLookupPattern}.`)
+ }
+ throw new Error(`WSL host config file could not be matched to the current Windows user under ${configLookupPattern}.`)
+}
+
+export function resolveUserPath(
+ rawPath: string,
+ dependenciesOrContext?: RuntimeEnvironmentDependencies | RuntimeEnvironmentContext
+): string {
+ const runtimeEnvironment = isRuntimeEnvironmentContext(dependenciesOrContext)
+ ? dependenciesOrContext
+ : resolveRuntimeEnvironment(dependenciesOrContext)
+
+ let resolvedPath = expandEnvironmentVariables(rawPath, runtimeEnvironment.expandedEnv)
+ resolvedPath = expandHomeDirectory(resolvedPath, runtimeEnvironment.effectiveHomeDir, runtimeEnvironment.platform)
+
+ if (!runtimeEnvironment.isWsl) return normalizeResolvedPath(resolvedPath, runtimeEnvironment.platform)
+
+ const convertedWindowsPath = convertWindowsPathToWsl(resolvedPath)
+ if (convertedWindowsPath != null) resolvedPath = convertedWindowsPath
+ else if (
+ resolvedPath.startsWith(runtimeEnvironment.effectiveHomeDir)
+ || resolvedPath.startsWith('/mnt/')
+ || resolvedPath.startsWith('/')
+ ) {
+ resolvedPath = resolvedPath.replaceAll('\\', '/')
+ }
+ return normalizeResolvedPath(resolvedPath, runtimeEnvironment.platform)
+}
diff --git a/cli/src/wsl-mirror-sync.test.ts b/cli/src/wsl-mirror-sync.test.ts
new file mode 100644
index 00000000..d4af7962
--- /dev/null
+++ b/cli/src/wsl-mirror-sync.test.ts
@@ -0,0 +1,588 @@
+import type {ILogger, OutputFileDeclaration, OutputPlugin, OutputWriteContext} from './plugins/plugin-core'
+import {Buffer} from 'node:buffer'
+import * as path from 'node:path'
+import {describe, expect, it, vi} from 'vitest'
+import {PluginKind} from './plugins/plugin-core'
+import {syncWindowsConfigIntoWsl} from './wsl-mirror-sync'
+
+class MemoryMirrorFs {
+ readonly files = new Map()
+
+ readonly directories = new Set()
+
+ private normalizePath(targetPath: string): string {
+ if (targetPath.includes('\\') || /^[A-Za-z]:[\\/]/u.test(targetPath)) {
+ return path.win32.normalize(targetPath)
+ }
+
+ return path.posix.normalize(targetPath)
+ }
+
+ private getPathModule(targetPath: string): typeof path.win32 | typeof path.posix {
+ if (targetPath.includes('\\') || /^[A-Za-z]:[\\/]/u.test(targetPath)) {
+ return path.win32
+ }
+
+ return path.posix
+ }
+
+ existsSync(targetPath: string): boolean {
+ const normalizedPath = this.normalizePath(targetPath)
+ return this.files.has(normalizedPath) || this.directories.has(normalizedPath)
+ }
+
+ mkdirSync(targetPath: string, options?: {recursive?: boolean}): void {
+ const pathModule = this.getPathModule(targetPath)
+ const normalizedPath = pathModule.normalize(targetPath)
+
+ if (options?.recursive === true) {
+ let currentPath = normalizedPath
+ while (currentPath.length > 0 && !this.directories.has(currentPath)) {
+ this.directories.add(currentPath)
+ const parentPath = pathModule.dirname(currentPath)
+ if (parentPath === currentPath) break
+ currentPath = parentPath
+ }
+ return
+ }
+
+ this.directories.add(normalizedPath)
+ }
+
+ readFileSync(targetPath: string): Buffer {
+ const normalizedPath = this.normalizePath(targetPath)
+ const content = this.files.get(normalizedPath)
+ if (content == null) throw new Error(`ENOENT: ${normalizedPath}`)
+ return Buffer.from(content)
+ }
+
+ writeFileSync(targetPath: string, data: string | NodeJS.ArrayBufferView): void {
+ const pathModule = this.getPathModule(targetPath)
+ const normalizedPath = pathModule.normalize(targetPath)
+ this.directories.add(pathModule.dirname(normalizedPath))
+
+ if (typeof data === 'string') {
+ this.files.set(normalizedPath, Buffer.from(data, 'utf8'))
+ return
+ }
+
+ this.files.set(normalizedPath, Buffer.from(data.buffer, data.byteOffset, data.byteLength))
+ }
+
+ seedDirectory(targetPath: string): void {
+ this.directories.add(this.normalizePath(targetPath))
+ }
+
+ seedFile(targetPath: string, content: string): void {
+ const pathModule = this.getPathModule(targetPath)
+ const normalizedPath = pathModule.normalize(targetPath)
+ this.directories.add(pathModule.dirname(normalizedPath))
+ this.files.set(normalizedPath, Buffer.from(content, 'utf8'))
+ }
+}
+
+interface RecordedLogger extends ILogger {
+ readonly infoMessages: string[]
+}
+
+function createLogger(): RecordedLogger {
+ const infoMessages: string[] = []
+ return {
+ trace: () => {},
+ debug: () => {},
+ info: (message: unknown) => {
+ infoMessages.push(String(message))
+ },
+ warn: () => {},
+ error: () => {},
+ fatal: () => {},
+ infoMessages
+ } as RecordedLogger
+}
+
+function createMirrorPlugin(sourcePaths: string | readonly string[] = []): OutputPlugin {
+ const normalizedPaths = Array.isArray(sourcePaths) ? sourcePaths : [sourcePaths]
+
+ return {
+ type: PluginKind.Output,
+ name: 'MirrorPlugin',
+ log: createLogger(),
+ declarativeOutput: true,
+ outputCapabilities: {},
+ async declareOutputFiles() {
+ return []
+ },
+ async convertContent() {
+ return ''
+ },
+ async declareWslMirrorFiles() {
+ return normalizedPaths
+ .filter(sourcePath => sourcePath.length > 0)
+ .map(sourcePath => ({sourcePath}))
+ }
+ }
+}
+
+function createWriteContext(instances?: string | string[], dryRun: boolean = false): OutputWriteContext {
+ return {
+ logger: createLogger(),
+ dryRun,
+ runtimeTargets: {
+ jetbrainsCodexDirs: []
+ },
+ pluginOptions: {
+ windows: {
+ wsl2: {
+ instances
+ }
+ }
+ },
+ collectedOutputContext: {
+ workspace: {
+ directory: {
+ pathKind: 'absolute',
+ path: 'C:\\workspace',
+ getDirectoryName: () => 'workspace'
+ },
+ projects: []
+ }
+ }
+ } as unknown as OutputWriteContext
+}
+
+function createPredeclaredOutputs(
+ plugin: OutputPlugin,
+ declarations: readonly OutputFileDeclaration[]
+): ReadonlyMap {
+ return new Map([[plugin, declarations]])
+}
+
+function createGlobalOutputDeclaration(
+ targetPath: string
+): OutputFileDeclaration {
+ return {
+ path: targetPath,
+ scope: 'global',
+ source: {kind: 'generated'}
+ }
+}
+
+function createWslSpawnSyncMock(
+ homesByInstance: Readonly>,
+ discoveredInstances: readonly string[] = Object.keys(homesByInstance)
+) {
+ return vi.fn((_command: string, args: readonly string[]) => {
+ if (args[0] === '--list' && args[1] === '--quiet') {
+ return {
+ status: 0,
+ stdout: Buffer.from(discoveredInstances.join('\r\n'), 'utf16le'),
+ stderr: Buffer.alloc(0)
+ }
+ }
+
+ if (args[0] === '-d') {
+ const instance = args[1]
+ const linuxHomeDir = instance == null ? void 0 : homesByInstance[instance]
+
+ if (linuxHomeDir == null) {
+ return {
+ status: 1,
+ stdout: Buffer.alloc(0),
+ stderr: Buffer.from(`distribution "${instance}" not found`, 'utf8')
+ }
+ }
+
+ return {
+ status: 0,
+ stdout: Buffer.from(linuxHomeDir, 'utf8'),
+ stderr: Buffer.alloc(0)
+ }
+ }
+
+ throw new Error(`Unexpected spawnSync args: ${JSON.stringify(args)}`)
+ })
+}
+
+function wasWslListCalled(
+ spawnSyncMock: ReturnType
+): boolean {
+ return spawnSyncMock.mock.calls.some(([, args]) => Array.isArray(args) && args[0] === '--list' && args[1] === '--quiet')
+}
+
+describe('wsl mirror sync', () => {
+ it('copies declared host config files into each resolved WSL home', async () => {
+ const memoryFs = new MemoryMirrorFs()
+ const hostHomeDir = 'C:\\Users\\alpha'
+ const sourcePath = path.win32.join(hostHomeDir, '.codex', 'config.toml')
+ const targetHomeDir = '\\\\wsl$\\Ubuntu\\home\\alpha'
+ const targetPath = path.win32.join(targetHomeDir, '.codex', 'config.toml')
+
+ memoryFs.seedFile(sourcePath, 'codex = true\n')
+ memoryFs.seedDirectory(targetHomeDir)
+
+ const spawnSyncMock = createWslSpawnSyncMock({Ubuntu: '/home/alpha'})
+
+ const result = await syncWindowsConfigIntoWsl(
+ [createMirrorPlugin('~/.codex/config.toml')],
+ createWriteContext('Ubuntu'),
+ {
+ fs: memoryFs,
+ spawnSync: spawnSyncMock as never,
+ platform: 'win32',
+ effectiveHomeDir: hostHomeDir
+ }
+ )
+
+ expect(result.errors).toEqual([])
+ expect(result.warnings).toEqual([])
+ expect(result.mirroredFiles).toBe(1)
+ expect(memoryFs.readFileSync(targetPath).toString('utf8')).toBe('codex = true\n')
+ expect(wasWslListCalled(spawnSyncMock)).toBe(false)
+ })
+
+ it('copies generated global outputs under the host home into each resolved WSL home', async () => {
+ const memoryFs = new MemoryMirrorFs()
+ const hostHomeDir = 'C:\\Users\\alpha'
+ const sourcePath = path.win32.join(hostHomeDir, '.codex', 'AGENTS.md')
+ const targetHomeDir = '\\\\wsl$\\Ubuntu\\home\\alpha'
+ const targetPath = path.win32.join(targetHomeDir, '.codex', 'AGENTS.md')
+ const plugin = createMirrorPlugin()
+
+ memoryFs.seedFile(sourcePath, 'global prompt\n')
+ memoryFs.seedDirectory(targetHomeDir)
+
+ const result = await syncWindowsConfigIntoWsl(
+ [plugin],
+ createWriteContext('Ubuntu'),
+ {
+ fs: memoryFs,
+ spawnSync: createWslSpawnSyncMock({Ubuntu: '/home/alpha'}) as never,
+ platform: 'win32',
+ effectiveHomeDir: hostHomeDir
+ },
+ createPredeclaredOutputs(plugin, [createGlobalOutputDeclaration(sourcePath)])
+ )
+
+ expect(result.errors).toEqual([])
+ expect(result.warnings).toEqual([])
+ expect(result.mirroredFiles).toBe(1)
+ expect(memoryFs.readFileSync(targetPath).toString('utf8')).toBe('global prompt\n')
+ })
+
+ it('excludes generated Windows app-data globals from WSL mirroring', async () => {
+ const memoryFs = new MemoryMirrorFs()
+ const hostHomeDir = 'C:\\Users\\alpha'
+ const sourcePath = path.win32.join(hostHomeDir, 'AppData', 'Local', 'JetBrains', 'IntelliJIdea2026.1', 'aia', 'codex', 'AGENTS.md')
+ const targetHomeDir = '\\\\wsl$\\Ubuntu\\home\\alpha'
+ const plugin = createMirrorPlugin()
+
+ memoryFs.seedFile(sourcePath, 'jetbrains prompt\n')
+ memoryFs.seedDirectory(targetHomeDir)
+
+ const result = await syncWindowsConfigIntoWsl(
+ [plugin],
+ createWriteContext('Ubuntu'),
+ {
+ fs: memoryFs,
+ spawnSync: createWslSpawnSyncMock({Ubuntu: '/home/alpha'}) as never,
+ platform: 'win32',
+ effectiveHomeDir: hostHomeDir
+ },
+ createPredeclaredOutputs(plugin, [createGlobalOutputDeclaration(sourcePath)])
+ )
+
+ expect(result).toEqual({
+ mirroredFiles: 0,
+ warnings: [],
+ errors: []
+ })
+ })
+
+ it('unions generated globals with declared mirror files and dedupes by source path', async () => {
+ const memoryFs = new MemoryMirrorFs()
+ const hostHomeDir = 'C:\\Users\\alpha'
+ const configPath = path.win32.join(hostHomeDir, '.codex', 'config.toml')
+ const authPath = path.win32.join(hostHomeDir, '.codex', 'auth.json')
+ const promptPath = path.win32.join(hostHomeDir, '.codex', 'AGENTS.md')
+ const targetHomeDir = '\\\\wsl$\\Ubuntu\\home\\alpha'
+ const plugin = createMirrorPlugin(['~/.codex/config.toml', '~/.codex/auth.json'])
+
+ memoryFs.seedFile(configPath, 'codex = true\n')
+ memoryFs.seedFile(authPath, '{"token":"abc"}\n')
+ memoryFs.seedFile(promptPath, 'global prompt\n')
+ memoryFs.seedDirectory(targetHomeDir)
+
+ const result = await syncWindowsConfigIntoWsl(
+ [plugin],
+ createWriteContext('Ubuntu'),
+ {
+ fs: memoryFs,
+ spawnSync: createWslSpawnSyncMock({Ubuntu: '/home/alpha'}) as never,
+ platform: 'win32',
+ effectiveHomeDir: hostHomeDir
+ },
+ createPredeclaredOutputs(plugin, [
+ createGlobalOutputDeclaration(configPath),
+ createGlobalOutputDeclaration(promptPath)
+ ])
+ )
+
+ expect(result.errors).toEqual([])
+ expect(result.warnings).toEqual([])
+ expect(result.mirroredFiles).toBe(3)
+ expect(memoryFs.readFileSync(path.win32.join(targetHomeDir, '.codex', 'config.toml')).toString('utf8')).toBe('codex = true\n')
+ expect(memoryFs.readFileSync(path.win32.join(targetHomeDir, '.codex', 'auth.json')).toString('utf8')).toBe('{"token":"abc"}\n')
+ expect(memoryFs.readFileSync(path.win32.join(targetHomeDir, '.codex', 'AGENTS.md')).toString('utf8')).toBe('global prompt\n')
+ })
+
+ it('auto-discovers WSL instances when none are configured', async () => {
+ const memoryFs = new MemoryMirrorFs()
+ const hostHomeDir = 'C:\\Users\\alpha'
+ const sourcePath = path.win32.join(hostHomeDir, '.codex', 'config.toml')
+ const spawnSyncMock = createWslSpawnSyncMock({
+ Ubuntu: '/home/alpha',
+ Debian: '/home/beta'
+ }, ['Ubuntu', 'Debian'])
+
+ memoryFs.seedFile(sourcePath, 'codex = true\n')
+ memoryFs.seedDirectory('\\\\wsl$\\Ubuntu\\home\\alpha')
+ memoryFs.seedDirectory('\\\\wsl$\\Debian\\home\\beta')
+
+ const result = await syncWindowsConfigIntoWsl(
+ [createMirrorPlugin('~/.codex/config.toml')],
+ createWriteContext(),
+ {
+ fs: memoryFs,
+ spawnSync: spawnSyncMock as never,
+ platform: 'win32',
+ effectiveHomeDir: hostHomeDir
+ }
+ )
+
+ expect(result.errors).toEqual([])
+ expect(result.warnings).toEqual([])
+ expect(result.mirroredFiles).toBe(2)
+ expect(wasWslListCalled(spawnSyncMock)).toBe(true)
+ })
+
+ it('prefers configured WSL instances over auto-discovery', async () => {
+ const memoryFs = new MemoryMirrorFs()
+ const hostHomeDir = 'C:\\Users\\alpha'
+ const sourcePath = path.win32.join(hostHomeDir, '.codex', 'config.toml')
+ const spawnSyncMock = createWslSpawnSyncMock({
+ Ubuntu: '/home/alpha',
+ Debian: '/home/beta'
+ }, ['Ubuntu', 'Debian'])
+
+ memoryFs.seedFile(sourcePath, 'codex = true\n')
+ memoryFs.seedDirectory('\\\\wsl$\\Ubuntu\\home\\alpha')
+
+ const result = await syncWindowsConfigIntoWsl(
+ [createMirrorPlugin('~/.codex/config.toml')],
+ createWriteContext('Ubuntu'),
+ {
+ fs: memoryFs,
+ spawnSync: spawnSyncMock as never,
+ platform: 'win32',
+ effectiveHomeDir: hostHomeDir
+ }
+ )
+
+ expect(result.errors).toEqual([])
+ expect(result.warnings).toEqual([])
+ expect(result.mirroredFiles).toBe(1)
+ expect(wasWslListCalled(spawnSyncMock)).toBe(false)
+ })
+
+ it('warns and skips when a declared host config file does not exist', async () => {
+ const memoryFs = new MemoryMirrorFs()
+ memoryFs.seedDirectory('\\\\wsl$\\Ubuntu\\home\\alpha')
+
+ const result = await syncWindowsConfigIntoWsl(
+ [createMirrorPlugin('~/.claude/settings.json')],
+ createWriteContext('Ubuntu'),
+ {
+ fs: memoryFs,
+ spawnSync: createWslSpawnSyncMock({Ubuntu: '/home/alpha'}) as never,
+ platform: 'win32',
+ effectiveHomeDir: 'C:\\Users\\alpha'
+ }
+ )
+
+ expect(result.errors).toEqual([])
+ expect(result.warnings).toEqual([
+ 'Skipping missing WSL mirror source file: C:\\Users\\alpha\\.claude\\settings.json'
+ ])
+ expect(result.mirroredFiles).toBe(0)
+ })
+
+ it('validates WSL instance probing before writing any mirrored files', async () => {
+ const memoryFs = new MemoryMirrorFs()
+ const hostHomeDir = 'C:\\Users\\alpha'
+ memoryFs.seedFile(path.win32.join(hostHomeDir, '.codex', 'auth.json'), '{"ok":true}\n')
+
+ const result = await syncWindowsConfigIntoWsl(
+ [createMirrorPlugin('~/.codex/auth.json')],
+ createWriteContext('BrokenUbuntu'),
+ {
+ fs: memoryFs,
+ spawnSync: vi.fn(() => ({
+ status: 1,
+ stdout: Buffer.alloc(0),
+ stderr: Buffer.from('distribution not found', 'utf8')
+ })) as never,
+ platform: 'win32',
+ effectiveHomeDir: hostHomeDir
+ }
+ )
+
+ expect(result.mirroredFiles).toBe(0)
+ expect(result.warnings).toEqual([])
+ expect(result.errors).toEqual([
+ 'Failed to probe WSL instance "BrokenUbuntu". distribution not found'
+ ])
+ })
+
+ it('counts dry-run mirror operations without writing explicit mirror files', async () => {
+ const memoryFs = new MemoryMirrorFs()
+ const hostHomeDir = 'C:\\Users\\alpha'
+ const sourcePath = path.win32.join(hostHomeDir, '.claude', 'config.json')
+ const targetHomeDir = '\\\\wsl$\\Ubuntu\\home\\alpha'
+ const targetPath = path.win32.join(targetHomeDir, '.claude', 'config.json')
+
+ memoryFs.seedFile(sourcePath, '{"theme":"dark"}\n')
+ memoryFs.seedDirectory(targetHomeDir)
+
+ const result = await syncWindowsConfigIntoWsl(
+ [createMirrorPlugin('~/.claude/config.json')],
+ createWriteContext('Ubuntu', true),
+ {
+ fs: memoryFs,
+ spawnSync: createWslSpawnSyncMock({Ubuntu: '/home/alpha'}) as never,
+ platform: 'win32',
+ effectiveHomeDir: hostHomeDir
+ }
+ )
+
+ expect(result.errors).toEqual([])
+ expect(result.mirroredFiles).toBe(1)
+ expect(memoryFs.existsSync(targetPath)).toBe(false)
+ })
+
+ it('counts generated outputs during dry-run even before the host file exists', async () => {
+ const memoryFs = new MemoryMirrorFs()
+ const hostHomeDir = 'C:\\Users\\alpha'
+ const sourcePath = path.win32.join(hostHomeDir, '.codex', 'AGENTS.md')
+ const targetHomeDir = '\\\\wsl$\\Ubuntu\\home\\alpha'
+ const targetPath = path.win32.join(targetHomeDir, '.codex', 'AGENTS.md')
+ const plugin = createMirrorPlugin()
+
+ memoryFs.seedDirectory(targetHomeDir)
+
+ const result = await syncWindowsConfigIntoWsl(
+ [plugin],
+ createWriteContext('Ubuntu', true),
+ {
+ fs: memoryFs,
+ spawnSync: createWslSpawnSyncMock({Ubuntu: '/home/alpha'}) as never,
+ platform: 'win32',
+ effectiveHomeDir: hostHomeDir
+ },
+ createPredeclaredOutputs(plugin, [createGlobalOutputDeclaration(sourcePath)])
+ )
+
+ expect(result.errors).toEqual([])
+ expect(result.warnings).toEqual([])
+ expect(result.mirroredFiles).toBe(1)
+ expect(memoryFs.existsSync(targetPath)).toBe(false)
+ })
+
+ it('logs info and skips mirror sync when WSL is unavailable on the host', async () => {
+ const memoryFs = new MemoryMirrorFs()
+ const logger = createLogger()
+
+ const result = await syncWindowsConfigIntoWsl(
+ [createMirrorPlugin('~/.codex/config.toml')],
+ {
+ ...createWriteContext('Ubuntu'),
+ logger
+ },
+ {
+ fs: memoryFs,
+ spawnSync: vi.fn(() => ({
+ status: null,
+ stdout: Buffer.alloc(0),
+ stderr: Buffer.alloc(0),
+ error: Object.assign(new Error('spawnSync wsl.exe ENOENT'), {code: 'ENOENT'})
+ })) as never,
+ platform: 'win32',
+ effectiveHomeDir: 'C:\\Users\\alpha'
+ }
+ )
+
+ expect(result).toEqual({
+ mirroredFiles: 0,
+ warnings: [],
+ errors: []
+ })
+ expect(logger.infoMessages).toContain('wsl is unavailable, skipping WSL mirror sync')
+ })
+
+ it('mirrors declared host config files back into the current WSL home when running inside WSL', async () => {
+ const memoryFs = new MemoryMirrorFs()
+ const hostHomeDir = '/mnt/c/Users/alpha'
+ const nativeHomeDir = '/home/alpha'
+ const sourcePath = path.posix.join(hostHomeDir, '.codex', 'config.toml')
+ const targetPath = path.posix.join(nativeHomeDir, '.codex', 'config.toml')
+
+ memoryFs.seedFile(sourcePath, 'codex = true\n')
+ memoryFs.seedDirectory(nativeHomeDir)
+
+ const result = await syncWindowsConfigIntoWsl(
+ [createMirrorPlugin('~/.codex/config.toml')],
+ createWriteContext(),
+ {
+ fs: memoryFs,
+ platform: 'linux',
+ isWsl: true,
+ effectiveHomeDir: hostHomeDir,
+ nativeHomeDir
+ }
+ )
+
+ expect(result.errors).toEqual([])
+ expect(result.warnings).toEqual([])
+ expect(result.mirroredFiles).toBe(1)
+ expect(memoryFs.readFileSync(targetPath).toString('utf8')).toBe('codex = true\n')
+ })
+
+ it('mirrors generated global outputs back into the current WSL home when running inside WSL', async () => {
+ const memoryFs = new MemoryMirrorFs()
+ const hostHomeDir = '/mnt/c/Users/alpha'
+ const nativeHomeDir = '/home/alpha'
+ const sourcePath = path.posix.join(hostHomeDir, '.codex', 'AGENTS.md')
+ const targetPath = path.posix.join(nativeHomeDir, '.codex', 'AGENTS.md')
+ const plugin = createMirrorPlugin()
+
+ memoryFs.seedFile(sourcePath, 'global prompt\n')
+ memoryFs.seedDirectory(nativeHomeDir)
+
+ const result = await syncWindowsConfigIntoWsl(
+ [plugin],
+ createWriteContext(),
+ {
+ fs: memoryFs,
+ platform: 'linux',
+ isWsl: true,
+ effectiveHomeDir: hostHomeDir,
+ nativeHomeDir
+ },
+ createPredeclaredOutputs(plugin, [createGlobalOutputDeclaration(sourcePath)])
+ )
+
+ expect(result.errors).toEqual([])
+ expect(result.warnings).toEqual([])
+ expect(result.mirroredFiles).toBe(1)
+ expect(memoryFs.readFileSync(targetPath).toString('utf8')).toBe('global prompt\n')
+ })
+})
diff --git a/cli/src/wsl-mirror-sync.ts b/cli/src/wsl-mirror-sync.ts
new file mode 100644
index 00000000..a39343bc
--- /dev/null
+++ b/cli/src/wsl-mirror-sync.ts
@@ -0,0 +1,655 @@
+import type {
+ ILogger,
+ OutputFileDeclaration,
+ OutputPlugin,
+ OutputWriteContext,
+ PluginOptions,
+ WslMirrorFileDeclaration
+} from './plugins/plugin-core'
+import {Buffer} from 'node:buffer'
+import type {RuntimeEnvironmentContext} from './runtime-environment'
+import {spawnSync} from 'node:child_process'
+import * as fs from 'node:fs'
+import * as path from 'node:path'
+import process from 'node:process'
+import {getEffectiveHomeDir, resolveRuntimeEnvironment, resolveUserPath} from './runtime-environment'
+
+type MirrorFs = Pick
+type SpawnSyncFn = typeof spawnSync
+type SpawnSyncResult = ReturnType
+
+export interface WslMirrorRuntimeDependencies {
+ readonly fs?: MirrorFs
+ readonly spawnSync?: SpawnSyncFn
+ readonly platform?: NodeJS.Platform
+ readonly effectiveHomeDir?: string
+ readonly nativeHomeDir?: string
+ readonly isWsl?: boolean
+}
+
+export interface ResolvedWslInstanceTarget {
+ readonly instance: string
+ readonly linuxHomeDir: string
+ readonly windowsHomeDir: string
+}
+
+export interface WslMirrorSyncResult {
+ readonly mirroredFiles: number
+ readonly warnings: readonly string[]
+ readonly errors: readonly string[]
+}
+
+class WslUnavailableError extends Error {}
+
+interface ResolvedWslMirrorSource {
+ readonly kind: 'declared' | 'generated'
+ readonly sourcePath: string
+ readonly relativePathSegments: readonly string[]
+}
+
+function getFs(dependencies?: WslMirrorRuntimeDependencies): MirrorFs {
+ return dependencies?.fs ?? fs
+}
+
+function getSpawnSync(dependencies?: WslMirrorRuntimeDependencies): SpawnSyncFn {
+ return dependencies?.spawnSync ?? spawnSync
+}
+
+function getPlatform(dependencies?: WslMirrorRuntimeDependencies): NodeJS.Platform {
+ return dependencies?.platform ?? process.platform
+}
+
+function getHostHomeDir(dependencies?: WslMirrorRuntimeDependencies): string {
+ return dependencies?.effectiveHomeDir ?? getEffectiveHomeDir()
+}
+
+function getNativeHomeDir(dependencies?: WslMirrorRuntimeDependencies): string {
+ return dependencies?.nativeHomeDir ?? resolveRuntimeEnvironment().nativeHomeDir
+}
+
+function isWslExecutionRuntime(dependencies?: WslMirrorRuntimeDependencies): boolean {
+ return dependencies?.isWsl ?? resolveRuntimeEnvironment().isWsl
+}
+
+function getPathModuleForPlatform(
+ platform: NodeJS.Platform
+): typeof path.win32 | typeof path.posix {
+ return platform === 'win32' ? path.win32 : path.posix
+}
+
+function normalizeInstanceNames(
+ instances: readonly string[]
+): string[] {
+ return [...new Set(instances.map(instance => instance.trim()).filter(instance => instance.length > 0))]
+}
+
+function normalizeConfiguredInstances(
+ pluginOptions?: PluginOptions
+): string[] {
+ const configuredInstances = pluginOptions?.windows?.wsl2?.instances
+ const instanceList = configuredInstances == null
+ ? []
+ : Array.isArray(configuredInstances)
+ ? configuredInstances
+ : [configuredInstances]
+
+ return normalizeInstanceNames(instanceList)
+}
+
+function buildWindowsWslHomePath(
+ instance: string,
+ linuxHomeDir: string
+): string {
+ if (!linuxHomeDir.startsWith('/')) {
+ throw new Error(`WSL instance "${instance}" returned a non-absolute home path: "${linuxHomeDir}".`)
+ }
+
+ const pathSegments = linuxHomeDir.split('/').filter(segment => segment.length > 0)
+ return path.win32.join(`\\\\wsl$\\${instance}`, ...pathSegments)
+}
+
+function resolveMirroredRelativePathSegments(
+ sourcePath: string,
+ hostHomeDir: string,
+ platform: NodeJS.Platform
+): string[] {
+ const pathModule = getPathModuleForPlatform(platform)
+ const normalizedHostHome = pathModule.normalize(hostHomeDir)
+ const normalizedSourcePath = pathModule.normalize(sourcePath)
+ const relativePath = pathModule.relative(normalizedHostHome, normalizedSourcePath)
+
+ if (
+ relativePath.length === 0
+ || relativePath.startsWith('..')
+ || pathModule.isAbsolute(relativePath)
+ ) {
+ throw new Error(
+ `WSL mirror source "${sourcePath}" must stay under the host home directory "${hostHomeDir}".`
+ )
+ }
+
+ return relativePath.split(/[\\/]+/u).filter(segment => segment.length > 0)
+}
+
+function decodeWslCliOutput(
+ value: unknown
+): string {
+ if (typeof value === 'string') return value
+ if (!Buffer.isBuffer(value) || value.length === 0) return ''
+
+ const hasUtf16LeBom = value.length >= 2 && value[0] === 0xff && value[1] === 0xfe
+ const hasUtf16BeBom = value.length >= 2 && value[0] === 0xfe && value[1] === 0xff
+ if (hasUtf16LeBom || hasUtf16BeBom) return value.toString('utf16le').replace(/^\uFEFF/u, '')
+
+ const utf8Text = value.toString('utf8')
+ if (utf8Text.includes('\u0000')) return value.toString('utf16le').replace(/^\uFEFF/u, '')
+ return utf8Text
+}
+
+function getSpawnOutputText(
+ value: unknown
+): string {
+ return decodeWslCliOutput(value).replaceAll('\u0000', '')
+}
+
+function getSpawnSyncErrorCode(result: SpawnSyncResult): string | undefined {
+ const {error} = result
+ if (error == null || typeof error !== 'object') return void 0
+ return 'code' in error && typeof error.code === 'string' ? error.code : void 0
+}
+
+function getWslUnavailableReason(result: SpawnSyncResult): string | undefined {
+ const errorCode = getSpawnSyncErrorCode(result)
+ if (errorCode === 'ENOENT') return 'wsl.exe is not available on PATH.'
+
+ const combinedOutput = [result.stderr, result.stdout]
+ .map(value => getSpawnOutputText(value).trim())
+ .filter(value => value.length > 0)
+ .join('\n')
+ .toLowerCase()
+
+ if (combinedOutput.length === 0) return void 0
+
+ const unavailableMarkers = [
+ 'windows subsystem for linux has no installed distributions',
+ 'windows subsystem for linux has not been enabled',
+ 'the windows subsystem for linux optional component is not enabled',
+ 'wsl is not installed',
+ 'run \'wsl.exe --install\'',
+ 'run "wsl.exe --install"',
+ 'wslregisterdistribution failed with error: 0x8007019e'
+ ]
+
+ return unavailableMarkers.some(marker => combinedOutput.includes(marker))
+ ? combinedOutput
+ : void 0
+}
+
+export async function collectDeclaredWslMirrorFiles(
+ outputPlugins: readonly OutputPlugin[],
+ ctx: OutputWriteContext
+): Promise {
+ const declarations = await Promise.all(outputPlugins.map(async plugin => {
+ if (plugin.declareWslMirrorFiles == null) return []
+ return plugin.declareWslMirrorFiles(ctx)
+ }))
+
+ const dedupedDeclarations = new Map()
+ for (const group of declarations) {
+ for (const declaration of group) {
+ dedupedDeclarations.set(declaration.sourcePath, declaration)
+ }
+ }
+
+ return [...dedupedDeclarations.values()]
+}
+
+function buildWindowsMirrorPathRuntimeContext(
+ hostHomeDir: string
+): RuntimeEnvironmentContext {
+ return {
+ platform: 'win32',
+ isWsl: false,
+ nativeHomeDir: hostHomeDir,
+ effectiveHomeDir: hostHomeDir,
+ globalConfigCandidates: [],
+ windowsUsersRoot: '',
+ expandedEnv: {
+ HOME: hostHomeDir,
+ USERPROFILE: hostHomeDir
+ }
+ }
+}
+
+function buildWslHostMirrorPathRuntimeContext(
+ hostHomeDir: string,
+ nativeHomeDir: string
+): RuntimeEnvironmentContext {
+ return {
+ platform: 'linux',
+ isWsl: true,
+ nativeHomeDir,
+ effectiveHomeDir: hostHomeDir,
+ globalConfigCandidates: [],
+ windowsUsersRoot: '',
+ expandedEnv: {
+ HOME: hostHomeDir,
+ USERPROFILE: hostHomeDir
+ }
+ }
+}
+
+function parseWslInstanceList(
+ rawOutput: string
+): string[] {
+ const instanceList = rawOutput
+ .split(/\r?\n/u)
+ .map(line => line.replace(/^\*/u, '').trim())
+ .filter(line => line.length > 0)
+
+ return normalizeInstanceNames(instanceList)
+}
+
+function discoverWslInstances(
+ logger: ILogger,
+ dependencies?: WslMirrorRuntimeDependencies
+): string[] {
+ const spawnSyncImpl = getSpawnSync(dependencies)
+ const listResult = spawnSyncImpl('wsl.exe', ['--list', '--quiet'], {
+ shell: false,
+ windowsHide: true
+ })
+
+ const unavailableReason = getWslUnavailableReason(listResult)
+ if (unavailableReason != null) throw new WslUnavailableError(unavailableReason)
+
+ if (listResult.status !== 0) {
+ const stderr = getSpawnOutputText(listResult.stderr).trim()
+ throw new Error(
+ `Failed to enumerate WSL instances. ${stderr.length > 0 ? stderr : 'wsl.exe returned a non-zero exit status.'}`
+ )
+ }
+
+ const discoveredInstances = parseWslInstanceList(getSpawnOutputText(listResult.stdout))
+ logger.info('discovered wsl instances', {
+ instances: discoveredInstances
+ })
+ return discoveredInstances
+}
+
+function resolveConfiguredOrDiscoveredInstances(
+ pluginOptions: Required,
+ logger: ILogger,
+ dependencies?: WslMirrorRuntimeDependencies
+): string[] {
+ const configuredInstances = normalizeConfiguredInstances(pluginOptions)
+ if (configuredInstances.length > 0) return configuredInstances
+ return discoverWslInstances(logger, dependencies)
+}
+
+function resolveGeneratedWslMirrorSource(
+ declaration: OutputFileDeclaration,
+ hostHomeDir: string,
+ platform: NodeJS.Platform
+): ResolvedWslMirrorSource | undefined {
+ if (declaration.scope !== 'global') return void 0
+
+ const pathModule = getPathModuleForPlatform(platform)
+ const sourcePath = pathModule.normalize(declaration.path)
+ let relativePathSegments: string[]
+ try {
+ relativePathSegments = resolveMirroredRelativePathSegments(sourcePath, hostHomeDir, platform)
+ }
+ catch {
+ return void 0
+ }
+
+ const [topLevelSegment] = relativePathSegments
+
+ // Mirror home-style tool config roots only. Windows app-data trees such as
+ // AppData\Local\JetBrains\... stay Windows-only even though they live under the user profile.
+ if (topLevelSegment == null || !topLevelSegment.startsWith('.')) return void 0
+
+ return {
+ kind: 'generated',
+ sourcePath,
+ relativePathSegments
+ }
+}
+
+function collectGeneratedWslMirrorSources(
+ predeclaredOutputs: ReadonlyMap | undefined,
+ hostHomeDir: string,
+ platform: NodeJS.Platform
+): readonly ResolvedWslMirrorSource[] {
+ if (predeclaredOutputs == null) return []
+
+ const dedupedSources = new Map()
+ for (const declarations of predeclaredOutputs.values()) {
+ for (const declaration of declarations) {
+ const resolvedSource = resolveGeneratedWslMirrorSource(declaration, hostHomeDir, platform)
+ if (resolvedSource == null) continue
+ dedupedSources.set(resolvedSource.sourcePath, resolvedSource)
+ }
+ }
+
+ return [...dedupedSources.values()]
+}
+
+function resolveDeclaredWslMirrorSource(
+ declaration: WslMirrorFileDeclaration,
+ pathRuntimeContext: RuntimeEnvironmentContext,
+ hostHomeDir: string,
+ platform: NodeJS.Platform
+): ResolvedWslMirrorSource {
+ const pathModule = getPathModuleForPlatform(platform)
+ const sourcePath = pathModule.normalize(resolveUserPath(declaration.sourcePath, pathRuntimeContext))
+ const relativePathSegments = resolveMirroredRelativePathSegments(sourcePath, hostHomeDir, platform)
+
+ return {
+ kind: 'declared',
+ sourcePath,
+ relativePathSegments
+ }
+}
+
+function combineWslMirrorSources(
+ mirrorDeclarations: readonly WslMirrorFileDeclaration[],
+ generatedMirrorSources: readonly ResolvedWslMirrorSource[],
+ pathRuntimeContext: RuntimeEnvironmentContext,
+ hostHomeDir: string,
+ platform: NodeJS.Platform
+): {readonly sources: readonly ResolvedWslMirrorSource[], readonly errors: readonly string[]} {
+ const dedupedSources = new Map()
+ const errors: string[] = []
+
+ for (const declaration of mirrorDeclarations) {
+ try {
+ const resolvedSource = resolveDeclaredWslMirrorSource(declaration, pathRuntimeContext, hostHomeDir, platform)
+ dedupedSources.set(resolvedSource.sourcePath, resolvedSource)
+ }
+ catch (error) {
+ errors.push(error instanceof Error ? error.message : String(error))
+ }
+ }
+
+ for (const source of generatedMirrorSources) {
+ dedupedSources.set(source.sourcePath, source)
+ }
+
+ return {
+ sources: [...dedupedSources.values()],
+ errors
+ }
+}
+
+export function resolveWslInstanceTargets(
+ pluginOptions: Required,
+ logger: ILogger,
+ dependencies?: WslMirrorRuntimeDependencies
+): ResolvedWslInstanceTarget[] {
+ if (getPlatform(dependencies) !== 'win32') return []
+
+ const configuredInstances = resolveConfiguredOrDiscoveredInstances(pluginOptions, logger, dependencies)
+ if (configuredInstances.length === 0) return []
+
+ const fsImpl = getFs(dependencies)
+ const spawnSyncImpl = getSpawnSync(dependencies)
+ const resolvedTargets: ResolvedWslInstanceTarget[] = []
+
+ for (const instance of configuredInstances) {
+ const probeResult = spawnSyncImpl('wsl.exe', ['-d', instance, 'sh', '-lc', 'printf %s "$HOME"'], {
+ shell: false,
+ windowsHide: true
+ })
+
+ const unavailableReason = getWslUnavailableReason(probeResult)
+ if (unavailableReason != null) throw new WslUnavailableError(unavailableReason)
+
+ if (probeResult.status !== 0) {
+ const stderr = getSpawnOutputText(probeResult.stderr).trim()
+ throw new Error(
+ `Failed to probe WSL instance "${instance}". ${stderr.length > 0 ? stderr : 'wsl.exe returned a non-zero exit status.'}`
+ )
+ }
+
+ const linuxHomeDir = getSpawnOutputText(probeResult.stdout).trim()
+ if (linuxHomeDir.length === 0) throw new Error(`WSL instance "${instance}" returned an empty home directory.`)
+
+ const windowsHomeDir = buildWindowsWslHomePath(instance, linuxHomeDir)
+ if (!fsImpl.existsSync(windowsHomeDir)) {
+ throw new Error(
+ `WSL instance "${instance}" home directory is unavailable at "${windowsHomeDir}".`
+ )
+ }
+
+ logger.info('resolved wsl instance home', {
+ instance,
+ linuxHomeDir,
+ windowsHomeDir
+ })
+
+ resolvedTargets.push({
+ instance,
+ linuxHomeDir,
+ windowsHomeDir
+ })
+ }
+
+ return resolvedTargets
+}
+
+function syncResolvedMirrorSourcesIntoCurrentWslHome(
+ sources: readonly ResolvedWslMirrorSource[],
+ ctx: OutputWriteContext,
+ dependencies?: WslMirrorRuntimeDependencies
+): WslMirrorSyncResult {
+ const fsImpl = getFs(dependencies)
+ const nativeHomeDir = path.posix.normalize(getNativeHomeDir(dependencies))
+ let mirroredFiles = 0
+ const warnings: string[] = []
+ const errors: string[] = []
+
+ for (const source of sources) {
+ if (source.kind === 'declared' && !fsImpl.existsSync(source.sourcePath)) {
+ const warningMessage = `Skipping missing WSL mirror source file: ${source.sourcePath}`
+ warnings.push(warningMessage)
+ ctx.logger.warn({
+ code: 'WSL_MIRROR_SOURCE_MISSING',
+ title: 'WSL mirror source file is missing',
+ rootCause: [warningMessage],
+ exactFix: [
+ 'Create the source file on the Windows host or remove the WSL mirror declaration before retrying tnmsc.'
+ ]
+ })
+ continue
+ }
+
+ const targetPath = path.posix.join(nativeHomeDir, ...source.relativePathSegments)
+ try {
+ if (ctx.dryRun === true) {
+ ctx.logger.info('would mirror host config into wsl runtime home', {
+ sourcePath: source.sourcePath,
+ targetPath,
+ dryRun: true
+ })
+ } else {
+ const content = fsImpl.readFileSync(source.sourcePath)
+ fsImpl.mkdirSync(path.posix.dirname(targetPath), {recursive: true})
+ fsImpl.writeFileSync(targetPath, content)
+ ctx.logger.info('mirrored host config into wsl runtime home', {
+ sourcePath: source.sourcePath,
+ targetPath
+ })
+ }
+
+ mirroredFiles += 1
+ }
+ catch (error) {
+ errors.push(
+ `Failed to mirror "${source.sourcePath}" into the current WSL home at "${targetPath}": ${error instanceof Error ? error.message : String(error)}`
+ )
+ }
+ }
+
+ return {
+ mirroredFiles,
+ warnings,
+ errors
+ }
+}
+
+export async function syncWindowsConfigIntoWsl(
+ outputPlugins: readonly OutputPlugin[],
+ ctx: OutputWriteContext,
+ dependencies?: WslMirrorRuntimeDependencies,
+ predeclaredOutputs?: ReadonlyMap
+): Promise {
+ const platform = getPlatform(dependencies)
+ const wslRuntime = platform === 'linux' && isWslExecutionRuntime(dependencies)
+ if (platform !== 'win32' && !wslRuntime) {
+ return {
+ mirroredFiles: 0,
+ warnings: [],
+ errors: []
+ }
+ }
+
+ const hostHomeDir = wslRuntime
+ ? path.posix.normalize(getHostHomeDir(dependencies))
+ : path.win32.normalize(getHostHomeDir(dependencies))
+ const mirrorDeclarations = await collectDeclaredWslMirrorFiles(outputPlugins, ctx)
+ const generatedMirrorSources = collectGeneratedWslMirrorSources(predeclaredOutputs, hostHomeDir, platform)
+ if (mirrorDeclarations.length === 0 && generatedMirrorSources.length === 0) {
+ return {
+ mirroredFiles: 0,
+ warnings: [],
+ errors: []
+ }
+ }
+
+ const pluginOptions = (ctx.pluginOptions ?? {}) as Required
+ const nativeHomeDir = wslRuntime ? path.posix.normalize(getNativeHomeDir(dependencies)) : void 0
+ const pathRuntimeContext = wslRuntime
+ ? buildWslHostMirrorPathRuntimeContext(hostHomeDir, nativeHomeDir ?? hostHomeDir)
+ : buildWindowsMirrorPathRuntimeContext(hostHomeDir)
+ const resolvedMirrorSources = combineWslMirrorSources(
+ mirrorDeclarations,
+ generatedMirrorSources,
+ pathRuntimeContext,
+ hostHomeDir,
+ platform
+ )
+
+ if (wslRuntime) {
+ if (resolvedMirrorSources.sources.length === 0 || nativeHomeDir == null || hostHomeDir === nativeHomeDir) {
+ return {
+ mirroredFiles: 0,
+ warnings: [],
+ errors: [...resolvedMirrorSources.errors]
+ }
+ }
+
+ const localMirrorResult = syncResolvedMirrorSourcesIntoCurrentWslHome(
+ resolvedMirrorSources.sources,
+ ctx,
+ dependencies
+ )
+
+ return {
+ mirroredFiles: localMirrorResult.mirroredFiles,
+ warnings: [...localMirrorResult.warnings],
+ errors: [...resolvedMirrorSources.errors, ...localMirrorResult.errors]
+ }
+ }
+
+ let resolvedTargets: ResolvedWslInstanceTarget[]
+ try {
+ resolvedTargets = resolveWslInstanceTargets(pluginOptions, ctx.logger, dependencies)
+ }
+ catch (error) {
+ if (error instanceof WslUnavailableError) {
+ ctx.logger.info('wsl is unavailable, skipping WSL mirror sync', {
+ reason: error.message
+ })
+ return {
+ mirroredFiles: 0,
+ warnings: [],
+ errors: []
+ }
+ }
+
+ return {
+ mirroredFiles: 0,
+ warnings: [],
+ errors: [error instanceof Error ? error.message : String(error)]
+ }
+ }
+
+ if (resolvedTargets.length === 0 || resolvedMirrorSources.sources.length === 0) {
+ return {
+ mirroredFiles: 0,
+ warnings: [],
+ errors: [...resolvedMirrorSources.errors]
+ }
+ }
+
+ const fsImpl = getFs(dependencies)
+ let mirroredFiles = 0
+ const warnings: string[] = []
+ const errors: string[] = [...resolvedMirrorSources.errors]
+
+ for (const declaration of resolvedMirrorSources.sources) {
+ if (declaration.kind === 'declared' && !fsImpl.existsSync(declaration.sourcePath)) {
+ const warningMessage = `Skipping missing WSL mirror source file: ${declaration.sourcePath}`
+ warnings.push(warningMessage)
+ ctx.logger.warn({
+ code: 'WSL_MIRROR_SOURCE_MISSING',
+ title: 'WSL mirror source file is missing',
+ rootCause: [warningMessage],
+ exactFix: [
+ 'Create the source file on the Windows host or remove the WSL mirror declaration before retrying tnmsc.'
+ ]
+ })
+ continue
+ }
+
+ for (const resolvedTarget of resolvedTargets) {
+ const sourcePath = declaration.sourcePath
+ const targetPath = path.win32.join(resolvedTarget.windowsHomeDir, ...declaration.relativePathSegments)
+
+ try {
+ if (ctx.dryRun === true) {
+ ctx.logger.info('would mirror windows config into wsl', {
+ instance: resolvedTarget.instance,
+ sourcePath,
+ targetPath,
+ dryRun: true
+ })
+ } else {
+ const content = fsImpl.readFileSync(sourcePath)
+ fsImpl.mkdirSync(path.win32.dirname(targetPath), {recursive: true})
+ fsImpl.writeFileSync(targetPath, content)
+ ctx.logger.info('mirrored windows config into wsl', {
+ instance: resolvedTarget.instance,
+ sourcePath,
+ targetPath
+ })
+ }
+
+ mirroredFiles += 1
+ }
+ catch (error) {
+ errors.push(
+ `Failed to mirror "${sourcePath}" into WSL instance "${resolvedTarget.instance}" at "${targetPath}": ${error instanceof Error ? error.message : String(error)}`
+ )
+ }
+ }
+ }
+
+ return {
+ mirroredFiles,
+ warnings,
+ errors
+ }
+}
diff --git a/doc/app/docs/layout.tsx b/doc/app/docs/layout.tsx
index c5a98b41..172a0dd5 100644
--- a/doc/app/docs/layout.tsx
+++ b/doc/app/docs/layout.tsx
@@ -19,22 +19,30 @@ export default async function DocsLayout({children}: {readonly children: ReactNo
pageMap={pageMap}
navbar={(
+ Docs
memory-sync
)}
- align="left"
>
-
+
+
+
+
+
)}
footer={(
diff --git a/doc/app/globals.css b/doc/app/globals.css
index f30c6ff6..8c9f7876 100644
--- a/doc/app/globals.css
+++ b/doc/app/globals.css
@@ -127,15 +127,19 @@ samp {
padding: 6px 0 18px;
}
-.home-brand,
-.docs-brand {
+.home-brand {
display: inline-flex;
align-items: baseline;
gap: 10px;
}
-.home-brand strong,
-.docs-brand-title {
+.docs-brand {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.home-brand strong {
font-size: 0.95rem;
font-weight: 600;
letter-spacing: -0.02em;
@@ -147,6 +151,28 @@ samp {
letter-spacing: 0.02em;
}
+.docs-brand-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 24px;
+ padding: 0 8px;
+ border: 1px solid var(--surface-border);
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--surface-strong) 82%, transparent);
+ color: var(--page-fg-muted);
+ font-size: 0.68rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.docs-brand-title {
+ font-size: 0.95rem;
+ font-weight: 600;
+ letter-spacing: -0.02em;
+}
+
.home-topbar-nav {
display: inline-flex;
align-items: center;
@@ -170,15 +196,75 @@ samp {
transform 0.2s ease;
}
+.docs-site-navbar nav:not(.docs-navbar-links) {
+ gap: 14px;
+}
+
+.docs-site-navbar nav:not(.docs-navbar-links) > :nth-child(2) {
+ display: none;
+}
+
+.docs-site-navbar nav:not(.docs-navbar-links) > :nth-child(3) {
+ order: 3;
+}
+
+.docs-site-navbar nav:not(.docs-navbar-links) > :nth-child(4) {
+ order: 2;
+}
+
+.docs-site-navbar nav:not(.docs-navbar-links) > :nth-child(5) {
+ order: 4;
+}
+
+.docs-navbar-shell {
+ display: inline-flex;
+ flex: 1 1 auto;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 12px;
+ min-width: 0;
+ overflow: hidden;
+}
+
.docs-navbar-links {
display: inline-flex;
align-items: center;
- flex-wrap: wrap;
+ flex-wrap: nowrap;
+ justify-content: flex-end;
gap: 8px;
+ min-width: 0;
+ overflow-x: auto;
+ padding-bottom: 2px;
+}
+
+.docs-navbar-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.docs-navbar-action {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 36px;
+ padding: 0 12px;
+ border: 1px solid var(--surface-border);
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--surface-strong) 84%, transparent);
+ color: var(--page-fg-soft);
+ font-size: 0.84rem;
+ font-weight: 500;
+ transition:
+ border-color 0.2s ease,
+ background-color 0.2s ease,
+ color 0.2s ease,
+ transform 0.2s ease;
}
.home-topbar-nav a:hover,
-.docs-nav-link:hover {
+.docs-nav-link:hover,
+.docs-navbar-action:hover {
border-color: var(--surface-border);
background: var(--surface);
color: var(--page-fg);
@@ -467,10 +553,6 @@ samp {
border-color: var(--surface-border);
}
-.nextra-navbar nav > div:first-of-type {
- display: none;
-}
-
.nextra-sidebar,
.nextra-toc,
.nextra-footer {
@@ -737,7 +819,8 @@ samp {
}
html.dark .home-topbar-nav a:hover,
-html.dark .docs-nav-link:hover {
+html.dark .docs-nav-link:hover,
+html.dark .docs-navbar-action:hover {
border-color: var(--surface-border);
background: var(--surface-highlight);
}
@@ -833,6 +916,16 @@ html.dark .nextra-navbar-blur {
border-color: var(--surface-separator);
}
+html.dark .docs-brand-badge {
+ border-color: var(--surface-border);
+ background: color-mix(in srgb, var(--surface-highlight) 100%, var(--surface-overlay));
+}
+
+html.dark .docs-navbar-action {
+ border-color: var(--surface-border);
+ background: color-mix(in srgb, var(--surface-highlight) 100%, var(--surface-overlay));
+}
+
html.dark .nextra-sidebar,
html.dark .nextra-toc {
border-color: var(--surface-separator);
@@ -1062,10 +1155,15 @@ html.dark .nextra-body-typesetting-article .mermaid-diagram__fallback {
font-size: clamp(1.8rem, 10vw, 2.45rem);
}
- .docs-navbar-links {
- overflow-x: auto;
- max-width: 100%;
- padding-bottom: 2px;
+ .docs-site-navbar nav:not(.docs-navbar-links) {
+ gap: 12px;
+ }
+}
+
+@media (max-width: 768px) {
+ .docs-brand-badge,
+ .docs-navbar-shell {
+ display: none;
}
}
diff --git a/doc/package.json b/doc/package.json
index ace67077..b8b4db2d 100644
--- a/doc/package.json
+++ b/doc/package.json
@@ -1,6 +1,6 @@
{
"name": "@truenine/memory-sync-docs",
- "version": "2026.10323.10152",
+ "version": "2026.10323.10738",
"private": true,
"description": "Chinese-first manifesto-led documentation site for @truenine/memory-sync.",
"engines": {
diff --git a/gui/package.json b/gui/package.json
index a30f24ff..e530c892 100644
--- a/gui/package.json
+++ b/gui/package.json
@@ -1,6 +1,6 @@
{
"name": "@truenine/memory-sync-gui",
- "version": "2026.10323.10152",
+ "version": "2026.10323.10738",
"private": true,
"engines": {
"node": ">=25.2.1",
diff --git a/gui/src-tauri/Cargo.toml b/gui/src-tauri/Cargo.toml
index 65079007..1cb5826c 100644
--- a/gui/src-tauri/Cargo.toml
+++ b/gui/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "memory-sync-gui"
-version = "2026.10323.10152"
+version = "2026.10323.10738"
description = "Memory Sync desktop GUI application"
authors.workspace = true
edition.workspace = true
diff --git a/gui/src-tauri/tauri.conf.json b/gui/src-tauri/tauri.conf.json
index 239de566..a57b7f68 100644
--- a/gui/src-tauri/tauri.conf.json
+++ b/gui/src-tauri/tauri.conf.json
@@ -1,6 +1,6 @@
{
"$schema": "https://schema.tauri.app/config/2",
- "version": "2026.10323.10152",
+ "version": "2026.10323.10738",
"productName": "Memory Sync",
"identifier": "org.truenine.memory-sync",
"build": {
diff --git a/libraries/logger/package.json b/libraries/logger/package.json
index 433d7045..d2d593df 100644
--- a/libraries/logger/package.json
+++ b/libraries/logger/package.json
@@ -1,7 +1,7 @@
{
"name": "@truenine/logger",
"type": "module",
- "version": "2026.10323.10152",
+ "version": "2026.10323.10738",
"private": true,
"description": "Rust-powered structured logger for Node.js via N-API",
"license": "AGPL-3.0-only",
diff --git a/libraries/md-compiler/package.json b/libraries/md-compiler/package.json
index b309c45d..b8bef19c 100644
--- a/libraries/md-compiler/package.json
+++ b/libraries/md-compiler/package.json
@@ -1,7 +1,7 @@
{
"name": "@truenine/md-compiler",
"type": "module",
- "version": "2026.10323.10152",
+ "version": "2026.10323.10738",
"private": true,
"description": "Rust-powered MDX→Markdown compiler for Node.js with pure-TS fallback",
"license": "AGPL-3.0-only",
diff --git a/libraries/script-runtime/package.json b/libraries/script-runtime/package.json
index e439b8b0..7a8ca748 100644
--- a/libraries/script-runtime/package.json
+++ b/libraries/script-runtime/package.json
@@ -1,7 +1,7 @@
{
"name": "@truenine/script-runtime",
"type": "module",
- "version": "2026.10323.10152",
+ "version": "2026.10323.10738",
"private": true,
"description": "Rust-backed TypeScript proxy runtime for tnmsc",
"license": "AGPL-3.0-only",
diff --git a/mcp/package.json b/mcp/package.json
index a3a432d9..9d3780dd 100644
--- a/mcp/package.json
+++ b/mcp/package.json
@@ -1,7 +1,7 @@
{
"name": "@truenine/memory-sync-mcp",
"type": "module",
- "version": "2026.10323.10152",
+ "version": "2026.10323.10738",
"description": "MCP stdio server for managing memory-sync prompt sources and translation artifacts",
"author": "TrueNine",
"license": "AGPL-3.0-only",
diff --git a/package.json b/package.json
index 11d77f1e..cec36846 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@truenine/memory-sync",
- "version": "2026.10323.10152",
+ "version": "2026.10323.10738",
"description": "Cross-AI-tool prompt synchronisation toolkit (CLI + Tauri desktop GUI) — one ruleset, multi-target adaptation. Monorepo powered by pnpm + Turbo.",
"license": "AGPL-3.0-only",
"keywords": [