use std::collections::VecDeque;
use std::path::{Component, Path, PathBuf};
use std::sync::{Mutex, MutexGuard};
use serde::{Deserialize, Serialize};
use super::manifest::{AgentPath, ConfigScope, WatchStrategy};
use crate::path_compat::dedup_key;
pub const MAX_REPORTS: usize = 8;
const MAX_PATH_LEN: usize = 4096;
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(default)]
pub struct LoaderEnv {
#[serde(skip_serializing_if = "Option::is_none")]
pub config_dir: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub config_file: Option<String>,
pub config_content_set: bool,
pub disable_claude_code: bool,
pub disable_claude_code_prompt: bool,
pub disable_claude_code_skills: bool,
pub disable_external_skills: bool,
}
impl LoaderEnv {
pub fn from_process_env() -> Self {
Self::from_lookup(|name| std::env::var(name).ok())
}
pub fn from_lookup(get: impl Fn(&str) -> Option<String>) -> Self {
let set = |name: &str| get(name).filter(|v| !v.is_empty());
let flag = |name: &str| {
set(name).is_some_and(|v| {
matches!(
v.to_ascii_lowercase().as_str(),
"true" | "1" | "yes" | "on" | "y"
)
})
};
Self {
config_dir: set("OPENCODE_CONFIG_DIR"),
config_file: set("OPENCODE_CONFIG"),
config_content_set: set("OPENCODE_CONFIG_CONTENT").is_some(),
disable_claude_code: flag("OPENCODE_DISABLE_CLAUDE_CODE"),
disable_claude_code_prompt: flag("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"),
disable_claude_code_skills: flag("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS"),
disable_external_skills: flag("OPENCODE_DISABLE_EXTERNAL_SKILLS"),
}
}
pub fn config_dir_path(&self) -> Option<PathBuf> {
usable_path(self.config_dir.as_deref()).filter(|p| p.parent().is_some())
}
pub fn config_file_path(&self) -> Option<PathBuf> {
usable_path(self.config_file.as_deref())
}
pub fn drops_claude_prompt(&self) -> bool {
self.disable_claude_code || self.disable_claude_code_prompt
}
pub fn drops_claude_skills(&self) -> bool {
self.disable_external_skills || self.disable_claude_code || self.disable_claude_code_skills
}
pub fn drops_external_skills(&self) -> bool {
self.disable_external_skills
}
}
fn usable_path(raw: Option<&str>) -> Option<PathBuf> {
let raw = raw?;
if raw.is_empty() || raw.len() > MAX_PATH_LEN || raw.contains('\0') {
return None;
}
let path = PathBuf::from(raw);
path.is_absolute().then_some(path)
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct KeptReports(VecDeque<LoaderEnv>);
impl KeptReports {
pub fn record(&mut self, env: LoaderEnv) -> bool {
if let Some(i) = self.0.iter().position(|e| *e == env) {
if let Some(known) = self.0.remove(i) {
self.0.push_back(known);
}
return false;
}
self.0.push_back(env);
if self.0.len() > MAX_REPORTS {
self.0.pop_front();
}
true
}
pub fn effective(&self) -> Vec<LoaderEnv> {
if self.0.is_empty() {
vec![LoaderEnv::from_process_env()]
} else {
self.0.iter().cloned().collect()
}
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
static KEPT: Mutex<KeptReports> = Mutex::new(KeptReports(VecDeque::new()));
fn kept() -> MutexGuard<'static, KeptReports> {
KEPT.lock().unwrap_or_else(|e| e.into_inner())
}
pub fn record(env: LoaderEnv) -> bool {
kept().record(env)
}
pub fn effective() -> Vec<LoaderEnv> {
kept().effective()
}
#[cfg(test)]
pub(crate) static TEST_LOCK: Mutex<()> = Mutex::new(());
#[cfg(any(test, feature = "insecure-test-keys"))]
pub fn reset_reports() {
*kept() = KeptReports::default();
}
pub fn env_dirs() -> Vec<PathBuf> {
distinct(
effective()
.iter()
.rev()
.filter_map(LoaderEnv::config_dir_path),
)
}
pub fn env_config_files() -> Vec<PathBuf> {
distinct(
effective()
.iter()
.rev()
.filter_map(LoaderEnv::config_file_path),
)
}
fn distinct(paths: impl Iterator<Item = PathBuf>) -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = Vec::new();
for p in paths {
if !out.iter().any(|q| dedup_key(q) == dedup_key(&p)) {
out.push(p);
}
}
out
}
pub fn user_home() -> Option<PathBuf> {
#[cfg(any(test, feature = "insecure-test-keys"))]
if let Some(home) = std::env::var_os("OPENCODE_TEST_HOME").filter(|v| !v.is_empty()) {
return Some(PathBuf::from(home));
}
dirs::home_dir()
}
pub const MANAGED_PLIST_NAME: &str = "ai.opencode.managed.plist";
const MANAGED_PREFS_ROOT: &str = "/Library/Managed Preferences";
#[cfg(target_os = "macos")]
const PLIST_META: &[&str] = &[
"PayloadDisplayName",
"PayloadIdentifier",
"PayloadType",
"PayloadUUID",
"PayloadVersion",
"_manualProfile",
];
pub fn managed_prefs_dirs_for(user: Option<&str>) -> Vec<PathBuf> {
let root = PathBuf::from(MANAGED_PREFS_ROOT);
let user =
user.filter(|u| !u.is_empty() && *u != "." && *u != ".." && !u.contains(['/', '\\', '\0']));
user.map(|u| root.join(u))
.into_iter()
.chain(std::iter::once(root))
.collect()
}
pub fn managed_prefs_dirs() -> Vec<PathBuf> {
if !cfg!(target_os = "macos") {
return Vec::new();
}
managed_prefs_dirs_for(crate::daemon::identity::os_user::os_user().as_deref())
}
pub fn is_managed_plist(path: &Path) -> bool {
path.file_name().and_then(|n| n.to_str()) == Some(MANAGED_PLIST_NAME)
}
pub fn managed_plist_json(path: &Path) -> Option<String> {
let meta = std::fs::metadata(path).ok()?;
if !meta.is_file() || meta.len() > CONFIG_NAMED_FILE_MAX_BYTES {
return None;
}
plist_to_json(&std::fs::read(path).ok()?).ok()
}
#[cfg(target_os = "macos")]
pub fn plist_to_json(bytes: &[u8]) -> Result<String, String> {
fn convert(value: plist::Value) -> Result<serde_json::Value, String> {
use serde_json::Value as J;
Ok(match value {
plist::Value::Dictionary(dict) => J::Object(
dict.into_iter()
.map(|(k, v)| convert(v).map(|v| (k, v)))
.collect::<Result<_, _>>()?,
),
plist::Value::Array(items) => {
J::Array(items.into_iter().map(convert).collect::<Result<_, _>>()?)
}
plist::Value::String(s) => J::String(s),
plist::Value::Boolean(b) => J::Bool(b),
plist::Value::Integer(i) => match (i.as_signed(), i.as_unsigned()) {
(Some(n), _) => J::from(n),
(None, Some(n)) => J::from(n),
(None, None) => return Err("integer out of range".into()),
},
plist::Value::Real(r) => serde_json::Number::from_f64(r)
.map(J::Number)
.ok_or("non-finite real")?,
_ => return Err("a date, data or uid value has no JSON form".into()),
})
}
let value =
plist::Value::from_reader(std::io::Cursor::new(bytes)).map_err(|e| e.to_string())?;
let mut json = convert(value)?;
if let Some(top) = json.as_object_mut() {
for key in PLIST_META {
top.remove(*key);
}
}
serde_json::to_string(&json).map_err(|e| e.to_string())
}
pub fn first_managed_plist(candidates: &[PathBuf]) -> Option<&PathBuf> {
candidates.iter().find(|p| managed_plist_json(p).is_some())
}
#[cfg(not(target_os = "macos"))]
pub fn plist_to_json(_bytes: &[u8]) -> Result<String, String> {
Err("managed preferences are read on macOS only".into())
}
pub const CONFIG_NAMED_GLOB_MAX_FILES: usize = 64;
pub const CONFIG_NAMED_FILE_MAX_BYTES: u64 = 512 * 1024;
const CONFIG_NAMED_GLOB_MAX_VISITS: usize = 4096;
pub const CONFIG_CONTENT_ITEM: &str = "OPENCODE_CONFIG_CONTENT";
const HOME_PREFIX: &str = "~/";
const INSTRUCTION_NAMES: [&str; 3] = ["AGENTS.md", "CLAUDE.md", "CONTEXT.md"];
pub const MAX_PROJECT_LEVELS: usize = 32;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Extra {
pub path: PathBuf,
pub kind: &'static str,
pub opaque: bool,
pub body: Option<String>,
pub project: Option<PathBuf>,
}
impl Extra {
pub fn entry(&self) -> AgentPath {
selected_entry(self.kind, self.opaque, self.project.is_some())
}
}
pub fn selected_entry(kind: &str, opaque: bool, project: bool) -> AgentPath {
AgentPath {
kind: kind.to_string(),
scope: project.then_some(ConfigScope::Project),
paths: Vec::new(),
paths_relative: Vec::new(),
paths_glob: None,
paths_glob_relative: Vec::new(),
json_slice_paths: Vec::new(),
watch_strategy: WatchStrategy::ExactFile,
slice_kind_subpath: false,
opaque,
projected: false,
claude_plugins: false,
opencode_select: true,
projection: None,
}
}
pub const MAX_STARTS_PER_ROOT: usize = 32;
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct ProjectStarts(Vec<RootStarts>);
#[derive(Debug, Clone, PartialEq, Eq)]
struct RootStarts {
key: PathBuf,
starts: VecDeque<(PathBuf, PathBuf)>,
}
impl ProjectStarts {
pub fn register(&mut self, root: &Path, start: &Path) -> bool {
let root_key = dedup_key(root);
let key = dedup_key(start);
if !key.starts_with(&root_key) {
return false;
}
let i = match self.0.iter().position(|r| r.key == root_key) {
Some(i) => i,
None => {
self.0.push(RootStarts {
key: root_key,
starts: VecDeque::new(),
});
self.0.len() - 1
}
};
let starts = &mut self.0[i].starts;
if let Some(j) = starts.iter().position(|(k, _)| *k == key) {
if let Some(known) = starts.remove(j) {
starts.push_back(known);
}
return false;
}
starts.push_back((key, start.to_path_buf()));
if starts.len() > MAX_STARTS_PER_ROOT {
starts.pop_front();
}
true
}
pub fn forget(&mut self, root: &Path) {
let key = dedup_key(root);
self.0.retain(|r| r.key != key);
}
pub fn starts(&self, root: &Path) -> Vec<PathBuf> {
let key = dedup_key(root);
self.0
.iter()
.find(|r| r.key == key)
.map(|r| r.starts.iter().map(|(_, s)| s.clone()).collect())
.unwrap_or_default()
}
}
static STARTS: Mutex<ProjectStarts> = Mutex::new(ProjectStarts(Vec::new()));
fn starts_store() -> MutexGuard<'static, ProjectStarts> {
STARTS.lock().unwrap_or_else(|e| e.into_inner())
}
pub fn register_start(root: &Path, start: &Path) -> bool {
starts_store().register(root, start)
}
pub fn forget_root(root: &Path) {
starts_store().forget(root);
}
pub fn is_instruction_file(path: &Path) -> bool {
path.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| INSTRUCTION_NAMES.contains(&n))
}
pub fn levels(root: &Path, start: Option<&Path>) -> Vec<PathBuf> {
let root_key = dedup_key(root);
let Some(start) = start.filter(|s| dedup_key(s).starts_with(&root_key)) else {
return vec![root.to_path_buf()];
};
let mut out = Vec::new();
let mut dir = Some(start);
while let Some(d) = dir {
out.push(d.to_path_buf());
if dedup_key(d) == root_key || out.len() >= MAX_PROJECT_LEVELS {
break;
}
dir = d.parent();
}
out
}
#[derive(Debug, Clone)]
pub struct Selector {
envs: Vec<LoaderEnv>,
global: Option<PathBuf>,
home: Option<PathBuf>,
starts: ProjectStarts,
}
impl Selector {
pub fn current() -> Self {
Self::new(effective(), crate::hooks::opencode::root(), user_home())
.with_starts(starts_store().clone())
}
pub fn new(envs: Vec<LoaderEnv>, global: Option<PathBuf>, home: Option<PathBuf>) -> Self {
Self {
envs,
global,
home,
starts: ProjectStarts::default(),
}
}
pub fn with_starts(mut self, starts: ProjectStarts) -> Self {
self.starts = starts;
self
}
fn walks(&self, root: &Path) -> Vec<Vec<PathBuf>> {
let starts = self.starts.starts(root);
if starts.is_empty() {
return vec![levels(root, None)];
}
starts.iter().map(|s| levels(root, Some(s))).collect()
}
pub fn project_levels(&self, root: &Path) -> Vec<PathBuf> {
distinct(self.walks(root).into_iter().flatten())
}
fn home_join(&self, rel: &str) -> Option<PathBuf> {
self.home.as_ref().map(|h| h.join(rel))
}
fn env_dirs(&self) -> Vec<PathBuf> {
distinct(self.envs.iter().filter_map(LoaderEnv::config_dir_path))
}
fn env_files(&self) -> Vec<PathBuf> {
distinct(self.envs.iter().filter_map(LoaderEnv::config_file_path))
}
pub fn admits(&self, path: &Path, project: Option<&Path>) -> bool {
self.envs
.iter()
.any(|env| self.selected_by(env, path, project))
}
pub fn retain_selected(&self, paths: &mut Vec<PathBuf>, project: Option<&Path>) {
paths.retain(|p| self.admits(p, project));
}
fn selected_by(&self, env: &LoaderEnv, path: &Path, project: Option<&Path>) -> bool {
let key = dedup_key(path);
let under = |root: &Path| key.starts_with(dedup_key(root));
let own_dir = env.config_dir_path();
let own_file = env.config_file_path();
let own_roots: Vec<PathBuf> = [
own_dir.clone(),
own_file.clone(),
self.global.clone(),
self.home_join(".opencode"),
self.home_join(".claude"),
self.home_join(".agents"),
crate::hooks::opencode::managed_dir(),
project.map(Path::to_path_buf),
]
.into_iter()
.flatten()
.collect();
let foreign = self
.env_dirs()
.into_iter()
.chain(self.env_files())
.any(|root| under(&root));
if foreign && !own_roots.iter().any(|r| under(r)) {
return false;
}
if is_managed_plist(path) {
let candidates: Vec<PathBuf> = managed_prefs_dirs()
.into_iter()
.map(|d| d.join(MANAGED_PLIST_NAME))
.collect();
return first_managed_plist(&candidates).is_some_and(|first| dedup_key(first) == key);
}
let claude_md = self.home_join(".claude/CLAUDE.md");
let global_agents_md: Vec<PathBuf> = self
.global
.iter()
.cloned()
.chain(self.env_dirs())
.map(|d| d.join("AGENTS.md"))
.collect();
let is_global_rule = claude_md.as_deref().is_some_and(|c| dedup_key(c) == key)
|| global_agents_md.iter().any(|p| dedup_key(p) == key);
if is_global_rule {
let rules_dir = own_dir.or_else(|| self.global.clone());
let candidates = rules_dir
.map(|d| d.join("AGENTS.md"))
.into_iter()
.chain(claude_md.filter(|_| !env.drops_claude_prompt()));
return candidates
.into_iter()
.find(|c| c.is_file())
.is_some_and(|first| dedup_key(&first) == key);
}
if skill_tree_dropped(env, path) {
return false;
}
if let (Some(root), Some(dir)) = (project, path.parent()) {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if INSTRUCTION_NAMES.contains(&name) {
let dir_key = dedup_key(dir);
let walks: Vec<Vec<PathBuf>> = self
.walks(root)
.into_iter()
.filter(|w| w.iter().any(|l| dedup_key(l) == dir_key))
.collect();
if !walks.is_empty() {
return walks
.iter()
.any(|w| self.project_rule_name(env, w) == Some(name));
}
}
}
true
}
fn project_rule_name(&self, env: &LoaderEnv, levels: &[PathBuf]) -> Option<&'static str> {
INSTRUCTION_NAMES
.into_iter()
.filter(|n| *n != "CLAUDE.md" || !env.drops_claude_prompt())
.find(|n| levels.iter().any(|l| l.join(n).is_file()))
}
fn known_roots(&self, project: Option<&Path>) -> Vec<PathBuf> {
[
self.global.clone(),
self.home_join(".opencode"),
self.home_join(".claude"),
self.home_join(".agents"),
crate::hooks::opencode::managed_dir(),
project.map(Path::to_path_buf),
]
.into_iter()
.flatten()
.chain(self.env_dirs())
.filter_map(|r| std::fs::canonicalize(r).ok())
.collect()
}
fn user_config_files(&self) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = self.home_join(".opencode").into_iter().collect();
dirs.extend(self.env_dirs());
dirs.extend(crate::hooks::opencode::managed_dir());
let mut files: Vec<PathBuf> = self
.global
.iter()
.flat_map(|g| ["config.json", "opencode.json", "opencode.jsonc"].map(|n| g.join(n)))
.collect();
files.extend(self.env_files());
for d in dirs {
files.extend(["opencode.json", "opencode.jsonc"].map(|n| d.join(n)));
}
files.retain(|f| f.is_file());
distinct(files.into_iter())
}
fn project_config_files(&self, root: &Path) -> Vec<PathBuf> {
let mut files = Vec::new();
for level in self.project_levels(root) {
for n in ["opencode.json", "opencode.jsonc"] {
files.push(level.join(n));
files.push(level.join(".opencode").join(n));
}
}
files.retain(|f| f.is_file());
files
}
pub fn extras(&self, project: Option<&Path>) -> Vec<Extra> {
let user = self.user_config_files();
let mut out = Vec::new();
let named = |files: &[PathBuf]| -> Vec<ConfigNamed> {
files.iter().filter_map(|f| config_named(f)).collect()
};
let known = self.known_roots(project);
match project {
None => {
for cfg in named(&user) {
for raw in &cfg.instructions {
if let Some(abs) = self.absolute(raw) {
self.glob_into(&mut out, &abs, "rules", &known, None);
}
}
for raw in &cfg.skill_paths {
if let Some(abs) = self.absolute(raw) {
self.glob_into(
&mut out,
&abs.join("**").join("SKILL.md"),
"skill",
&known,
None,
);
}
}
}
if self.envs.iter().any(|e| e.config_content_set) {
if let Some(global) = &self.global {
out.push(Extra {
path: global.join(CONFIG_CONTENT_ITEM),
kind: "hooks",
opaque: true,
body: Some(CONFIG_CONTENT_ITEM.to_string()),
project: None,
});
}
}
}
Some(root) => {
let own = named(&self.project_config_files(root));
let user_named = named(&user);
let lvls = self.project_levels(root);
let starts = match self.starts.starts(root) {
seen if seen.is_empty() => vec![root.to_path_buf()],
seen => seen,
};
let configs = own
.iter()
.map(|c| (c, true))
.chain(user_named.iter().map(|c| (c, false)));
for (cfg, from_project) in configs {
for raw in &cfg.instructions {
match self.absolute(raw) {
Some(abs) if from_project => {
self.glob_into(&mut out, &abs, "rules", &known, Some(root))
}
Some(_) => {}
None if is_url(raw) => {}
None => {
for level in &lvls {
let pattern = level.join(raw);
self.glob_into(&mut out, &pattern, "rules", &known, Some(root));
}
}
}
}
for raw in &cfg.skill_paths {
let dirs = match self.absolute(raw) {
Some(abs) if from_project => vec![abs],
Some(_) => continue,
None => starts.iter().map(|s| s.join(raw)).collect(),
};
for dir in dirs {
let pattern = dir.join("**").join("SKILL.md");
self.glob_into(&mut out, &pattern, "skill", &known, Some(root));
}
}
}
}
}
let mut seen = std::collections::HashSet::new();
out.retain(|e| seen.insert((dedup_key(&e.path), e.kind)));
out
}
fn absolute(&self, raw: &str) -> Option<PathBuf> {
if is_url(raw) {
return None;
}
if let Some(rest) = raw.strip_prefix(HOME_PREFIX) {
return self.home.as_ref().map(|h| h.join(rest));
}
let p = PathBuf::from(raw);
p.is_absolute().then_some(p)
}
fn glob_into(
&self,
out: &mut Vec<Extra>,
pattern: &Path,
kind: &'static str,
known: &[PathBuf],
project: Option<&Path>,
) {
let Some(base) = static_dir(pattern).and_then(|b| std::fs::canonicalize(b).ok()) else {
return;
};
let mut found = 0usize;
for path in bounded_glob(pattern) {
let Ok(real) = std::fs::canonicalize(&path) else {
continue;
};
if !real.starts_with(&base) {
continue;
}
let Ok(meta) = std::fs::metadata(&real) else {
continue;
};
if !meta.is_file() {
continue;
}
if kind == "skill" && self.envs.iter().all(|env| skill_tree_dropped(env, &path)) {
continue;
}
let over_cap = meta.len() > CONFIG_NAMED_FILE_MAX_BYTES;
let outside = !known.iter().any(|k| real.starts_with(k));
out.push(Extra {
body: over_cap.then(|| {
format!(
"{}:{}",
path.file_name().unwrap_or_default().to_string_lossy(),
meta.len()
)
}),
path,
kind,
opaque: over_cap || outside,
project: project.map(Path::to_path_buf),
});
found += 1;
if found >= CONFIG_NAMED_GLOB_MAX_FILES {
break;
}
}
}
}
fn bounded_glob(pattern: &Path) -> Vec<PathBuf> {
let components: Vec<Component> = pattern.components().collect();
let Some(wildcard) = components
.iter()
.position(|c| crate::path_compat::is_wildcard_segment(c, &GLOB_WILDCARDS))
else {
let folded = static_dir(pattern)
.zip(pattern.file_name())
.map(|(dir, name)| dir.join(name));
return folded
.filter(|p| std::fs::symlink_metadata(p).is_ok())
.into_iter()
.collect();
};
let Some(base) = static_dir(pattern) else {
return Vec::new();
};
let rest = &components[wildcard..];
let Some(mut folded) = base
.to_str()
.map(|b| PathBuf::from(glob::Pattern::escape(b)))
else {
return Vec::new();
};
for c in rest {
folded.push(c.as_os_str());
}
let Some(matcher) = folded.to_str().and_then(|p| glob::Pattern::new(p).ok()) else {
return Vec::new();
};
let options = glob::MatchOptions {
require_literal_separator: true,
..Default::default()
};
let max_depth = if rest.iter().any(|c| c.as_os_str() == "**") {
usize::MAX
} else {
rest.len()
};
let mut matches = Vec::new();
let mut visits = 0usize;
let mut queue = VecDeque::from([(base, 0usize)]);
'walk: while let Some((dir, depth)) = queue.pop_front() {
let Ok(read) = std::fs::read_dir(&dir) else {
continue;
};
let mut entries: Vec<_> = read.flatten().collect();
entries.sort_by_key(|e| e.file_name());
for entry in entries {
visits += 1;
if visits > CONFIG_NAMED_GLOB_MAX_VISITS {
break 'walk;
}
let path = entry.path();
if matcher.matches_path_with(&path, options) {
matches.push(path.clone());
}
let is_dir = entry.file_type().is_ok_and(|t| t.is_dir());
if is_dir && depth + 1 < max_depth {
queue.push_back((path, depth + 1));
}
}
}
matches.sort();
matches
}
fn skill_tree_dropped(env: &LoaderEnv, path: &Path) -> bool {
(has_segments(path, ".claude", "skills") && env.drops_claude_skills())
|| (has_segments(path, ".agents", "skills") && env.drops_external_skills())
}
fn is_url(raw: &str) -> bool {
raw.starts_with("https://") || raw.starts_with("http://")
}
fn has_segments(path: &Path, a: &str, b: &str) -> bool {
let parts: Vec<_> = path.components().map(|c| c.as_os_str()).collect();
parts.windows(2).any(|w| w[0] == a && w[1] == b)
}
const GLOB_WILDCARDS: [char; 3] = ['*', '?', '['];
fn static_dir(pattern: &Path) -> Option<PathBuf> {
let mut out = PathBuf::new();
let components: Vec<Component> = pattern.components().collect();
let wildcard = components
.iter()
.position(|c| crate::path_compat::is_wildcard_segment(c, &GLOB_WILDCARDS));
let end = wildcard.unwrap_or(components.len().saturating_sub(1));
for c in &components[..end] {
match c {
Component::ParentDir => {
out.pop();
}
Component::CurDir => {}
other => out.push(other.as_os_str()),
}
}
(!out.as_os_str().is_empty()).then_some(out)
}
struct ConfigNamed {
instructions: Vec<String>,
skill_paths: Vec<String>,
}
fn config_named(file: &Path) -> Option<ConfigNamed> {
let meta = std::fs::metadata(file).ok()?;
if meta.len() > CONFIG_NAMED_FILE_MAX_BYTES {
return None;
}
let raw = std::fs::read_to_string(file).ok()?;
let doc = crate::hooks::jsonc::parse_settings_value(&raw).ok()?;
let strings = |v: Option<&serde_json::Value>| -> Vec<String> {
v.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.filter_map(|s| s.as_str())
.filter(|s| !s.is_empty() && s.len() <= MAX_PATH_LEN)
.map(str::to_string)
.collect()
};
Some(ConfigNamed {
instructions: strings(doc.get("instructions")),
skill_paths: strings(doc.pointer("/skills/paths")),
})
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
fn env(dir: &str) -> LoaderEnv {
LoaderEnv {
config_dir: Some(dir.to_string()),
..LoaderEnv::default()
}
}
#[test]
fn loader_env_deserializes_the_wire_object() {
let v = serde_json::json!({
"config_dir": "/x/oc",
"config_content_set": true,
"disable_claude_code_prompt": true,
"unknown": 1
});
let e: LoaderEnv = serde_json::from_value(v).unwrap();
assert_eq!(e.config_dir.as_deref(), Some("/x/oc"));
assert_eq!(e.config_file, None);
assert!(e.config_content_set);
assert!(e.disable_claude_code_prompt);
assert!(!e.disable_claude_code);
let empty: LoaderEnv = serde_json::from_value(serde_json::json!({})).unwrap();
assert_eq!(empty, LoaderEnv::default());
}
#[test]
fn kept_reports_hold_the_last_eight_distinct() {
let mut kept = KeptReports::default();
assert!(kept.record(env("/a")));
assert!(!kept.record(env("/a")), "a repeat changes nothing");
for i in 0..MAX_REPORTS {
kept.record(env(&format!("/d{i}")));
}
assert_eq!(kept.len(), MAX_REPORTS);
assert!(
!kept.effective().contains(&env("/a")),
"the least recent report is evicted"
);
}
#[test]
fn a_repeated_report_is_refreshed_not_evicted() {
let mut kept = KeptReports::default();
kept.record(env("/a"));
for i in 0..MAX_REPORTS - 1 {
kept.record(env(&format!("/d{i}")));
}
kept.record(env("/a"));
kept.record(env("/new"));
assert!(kept.effective().contains(&env("/a")));
}
#[test]
fn no_report_falls_back_to_the_process_environment() {
let kept = KeptReports::default();
assert_eq!(kept.effective(), vec![LoaderEnv::from_process_env()]);
}
#[test]
fn from_lookup_reads_opencode_boolean_spellings() {
let e = LoaderEnv::from_lookup(|k| match k {
"OPENCODE_DISABLE_CLAUDE_CODE" => Some("1".into()),
"OPENCODE_DISABLE_EXTERNAL_SKILLS" => Some("false".into()),
"OPENCODE_CONFIG_DIR" => Some("".into()),
"OPENCODE_CONFIG_CONTENT" => Some("{}".into()),
_ => None,
});
assert!(e.disable_claude_code);
assert!(!e.disable_external_skills);
assert_eq!(e.config_dir, None, "an empty variable is unset");
assert!(e.config_content_set);
}
#[test]
fn config_paths_must_be_absolute_and_not_a_root() {
assert_eq!(env("relative/dir").config_dir_path(), None);
assert_eq!(env("/").config_dir_path(), None);
let abs = if cfg!(windows) { "C:\\oc" } else { "/oc" };
assert_eq!(env(abs).config_dir_path(), Some(PathBuf::from(abs)));
}
fn write(path: &Path, body: &str) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, body).unwrap();
}
struct Tree {
_tmp: tempfile::TempDir,
root: PathBuf,
global: PathBuf,
home: PathBuf,
}
fn tree() -> Tree {
let tmp = tempfile::tempdir().unwrap();
let root = std::fs::canonicalize(tmp.path()).unwrap();
Tree {
global: root.join("xdg/opencode"),
home: root.join("home"),
root,
_tmp: tmp,
}
}
impl Tree {
fn selector(&self, envs: Vec<LoaderEnv>) -> Selector {
Selector::new(envs, Some(self.global.clone()), Some(self.home.clone()))
}
}
fn disabled(f: impl Fn(&mut LoaderEnv)) -> LoaderEnv {
let mut e = LoaderEnv::default();
f(&mut e);
e
}
#[test]
fn global_rules_are_first_match() {
let t = tree();
let agents = t.global.join("AGENTS.md");
let claude = t.home.join(".claude/CLAUDE.md");
write(&agents, "a");
write(&claude, "c");
let s = t.selector(vec![LoaderEnv::default()]);
assert!(s.admits(&agents, None));
assert!(!s.admits(&claude, None), "AGENTS.md shadows CLAUDE.md");
std::fs::remove_file(&agents).unwrap();
assert!(s.admits(&claude, None));
let off = t.selector(vec![disabled(|e| e.disable_claude_code_prompt = true)]);
assert!(!off.admits(&claude, None));
}
#[test]
fn opencode_config_dir_replaces_the_global_agents_md() {
let t = tree();
let dir = t.root.join("envdir");
let global_agents = t.global.join("AGENTS.md");
let env_agents = dir.join("AGENTS.md");
write(&global_agents, "g");
write(&env_agents, "e");
let with_dir = LoaderEnv {
config_dir: Some(dir.display().to_string()),
..LoaderEnv::default()
};
let s = t.selector(vec![with_dir.clone()]);
assert!(s.admits(&env_agents, None));
assert!(!s.admits(&global_agents, None));
let both = t.selector(vec![with_dir, LoaderEnv::default()]);
assert!(both.admits(&env_agents, None));
assert!(both.admits(&global_agents, None));
}
#[test]
fn a_root_only_another_environment_has_is_its_alone() {
let t = tree();
let dir = t.root.join("envdir");
let skill = dir.join("skills/x/SKILL.md");
write(&skill, "s");
let with_dir = LoaderEnv {
config_dir: Some(dir.display().to_string()),
..LoaderEnv::default()
};
let plain = LoaderEnv::default();
let s = t.selector(vec![plain.clone(), with_dir.clone()]);
assert!(
s.admits(&skill, None),
"the environment that names it loads it"
);
assert!(s.selected_by(&with_dir, &skill, None));
assert!(
!s.selected_by(&plain, &skill, None),
"an OpenCode without the variable never reads that directory"
);
}
#[test]
fn project_rules_take_the_first_name_found() {
let t = tree();
let repo = t.root.join("repo");
write(&repo.join("AGENTS.md"), "a");
write(&repo.join("CLAUDE.md"), "c");
write(&repo.join("CONTEXT.md"), "x");
let s = t.selector(vec![LoaderEnv::default()]);
assert!(s.admits(&repo.join("AGENTS.md"), Some(&repo)));
assert!(!s.admits(&repo.join("CLAUDE.md"), Some(&repo)));
assert!(!s.admits(&repo.join("CONTEXT.md"), Some(&repo)));
std::fs::remove_file(repo.join("AGENTS.md")).unwrap();
assert!(s.admits(&repo.join("CLAUDE.md"), Some(&repo)));
let off = disabled(|e| e.disable_claude_code = true);
let s_off = t.selector(vec![off.clone()]);
assert!(!s_off.admits(&repo.join("CLAUDE.md"), Some(&repo)));
assert!(s_off.admits(&repo.join("CONTEXT.md"), Some(&repo)));
let mixed = t.selector(vec![off, LoaderEnv::default()]);
assert!(mixed.admits(&repo.join("CLAUDE.md"), Some(&repo)));
}
#[test]
fn disable_switches_drop_external_skills_only_when_every_report_sets_them() {
let t = tree();
let claude_skill = t.home.join(".claude/skills/x/SKILL.md");
let agents_skill = t.home.join(".agents/skills/y/SKILL.md");
let own_skill = t.global.join("skills/z/SKILL.md");
for p in [&claude_skill, &agents_skill, &own_skill] {
write(p, "s");
}
let skills_off = disabled(|e| e.disable_claude_code_skills = true);
let s = t.selector(vec![skills_off.clone()]);
assert!(!s.admits(&claude_skill, None));
assert!(s.admits(&agents_skill, None));
assert!(s.admits(&own_skill, None));
let external_off = t.selector(vec![disabled(|e| e.disable_external_skills = true)]);
assert!(!external_off.admits(&claude_skill, None));
assert!(!external_off.admits(&agents_skill, None));
assert!(external_off.admits(&own_skill, None));
let mixed = t.selector(vec![skills_off, LoaderEnv::default()]);
assert!(mixed.admits(&claude_skill, None));
let repo = t.root.join("repo");
let project_skill = repo.join(".claude/skills/p/SKILL.md");
write(&project_skill, "s");
let all_off = t.selector(vec![disabled(|e| e.disable_claude_code = true)]);
assert!(!all_off.admits(&project_skill, Some(&repo)));
}
#[test]
fn config_named_paths_follow_the_untrusted_path_rules() {
let t = tree();
let dir = t.root.join("envdir");
write(
&t.global.join("opencode.json"),
r#"{
// jsonc
"instructions": ["~/docs/*.md", "https://example.test/x.md", "relative.md"],
"skills": {"paths": ["~/shared-skills"]}
}"#,
);
write(
&dir.join("opencode.json"),
&format!(
r#"{{"instructions": ["{}/rules/*.md", "{}/many/*.md", "{}/big.md"]}}"#,
slash(&dir),
slash(&t.home),
slash(&dir)
),
);
write(&t.home.join("docs/a.md"), "outside");
write(&t.home.join("shared-skills/s/SKILL.md"), "skill");
write(&dir.join("rules/r.md"), "inside");
for i in 0..CONFIG_NAMED_GLOB_MAX_FILES + 6 {
write(&t.home.join(format!("many/{i:03}.md")), "m");
}
let big = dir.join("big.md");
write(&big, &"x".repeat(CONFIG_NAMED_FILE_MAX_BYTES as usize + 1));
#[cfg(unix)]
{
write(&t.root.join("secret/s.md"), "secret");
std::os::unix::fs::symlink(t.root.join("secret/s.md"), dir.join("rules/link.md"))
.unwrap();
}
let env = LoaderEnv {
config_dir: Some(dir.display().to_string()),
config_content_set: true,
..LoaderEnv::default()
};
let extras = t.selector(vec![env]).extras(None);
let find = |p: &Path| extras.iter().find(|e| dedup_key(&e.path) == dedup_key(p));
let docs = find(&t.home.join("docs/a.md")).expect("~/ instruction resolved");
assert_eq!(docs.kind, "rules");
assert!(docs.opaque, "outside every known root: name only");
let skill = find(&t.home.join("shared-skills/s/SKILL.md")).expect("skills.paths");
assert_eq!(skill.kind, "skill");
assert!(skill.opaque);
let inside = find(&dir.join("rules/r.md")).expect("inside the config dir");
assert!(!inside.opaque);
let big = find(&big).expect("an over-cap file is still reported");
assert!(big.opaque && big.body.is_some(), "never read: {big:?}");
let many = extras
.iter()
.filter(|e| dedup_key(&e.path).starts_with(dedup_key(&t.home.join("many"))))
.count();
assert_eq!(many, CONFIG_NAMED_GLOB_MAX_FILES, "a glob stops at the cap");
assert!(
!extras.iter().any(|e| e.path.ends_with("link.md")),
"a symlink leaving its root is skipped"
);
assert!(
!extras.iter().any(|e| e.path.ends_with("relative.md")),
"a relative instruction needs a project"
);
let presence = find(&t.global.join(CONFIG_CONTENT_ITEM)).expect("presence item");
assert_eq!(presence.kind, "hooks");
assert!(presence.opaque);
assert_eq!(presence.body.as_deref(), Some(CONFIG_CONTENT_ITEM));
}
#[test]
fn project_extras_resolve_relative_paths_in_the_project() {
let t = tree();
let repo = t.root.join("repo");
write(
&t.global.join("opencode.json"),
r#"{"instructions": ["team.md"]}"#,
);
write(
&repo.join("opencode.json"),
r#"{"instructions": ["docs/*.md"], "skills": {"paths": ["tools/skills"]}}"#,
);
write(&repo.join("team.md"), "t");
write(&repo.join("docs/a.md"), "a");
write(&repo.join("tools/skills/x/SKILL.md"), "s");
let extras = t.selector(vec![LoaderEnv::default()]).extras(Some(&repo));
for (path, kind) in [
(repo.join("team.md"), "rules"),
(repo.join("docs/a.md"), "rules"),
(repo.join("tools/skills/x/SKILL.md"), "skill"),
] {
let e = extras
.iter()
.find(|e| e.path == path)
.unwrap_or_else(|| panic!("{path:?} missing from {extras:?}"));
assert_eq!(e.kind, kind);
assert!(!e.opaque, "inside the worktree");
assert_eq!(e.project.as_deref(), Some(repo.as_path()));
}
assert!(t
.selector(vec![LoaderEnv::default()])
.extras(None)
.is_empty());
}
#[test]
fn levels_walk_from_start_up_to_the_root() {
let root = PathBuf::from(if cfg!(windows) { "C:\\r" } else { "/r" });
assert_eq!(levels(&root, None), vec![root.clone()]);
let start = root.join("a").join("b");
assert_eq!(
levels(&root, Some(&start)),
vec![start.clone(), root.join("a"), root.clone()]
);
let outside = PathBuf::from(if cfg!(windows) { "C:\\other" } else { "/other" });
assert_eq!(levels(&root, Some(&outside)), vec![root]);
}
#[test]
fn a_walk_stops_at_the_level_cap() {
let root = PathBuf::from(if cfg!(windows) { "C:\\r" } else { "/r" });
let mut start = root.clone();
for i in 0..MAX_PROJECT_LEVELS + 8 {
start.push(format!("d{i}"));
}
let walk = levels(&root, Some(&start));
assert_eq!(walk.len(), MAX_PROJECT_LEVELS);
assert_eq!(walk[0], start, "the walk begins at the start");
assert!(
!walk.contains(&root),
"a start deeper than the cap never reaches the root"
);
}
#[test]
fn starts_are_kept_per_root_least_recent_evicted() {
let root = PathBuf::from(if cfg!(windows) { "C:\\r" } else { "/r" });
let other = PathBuf::from(if cfg!(windows) { "C:\\o" } else { "/o" });
let mut s = ProjectStarts::default();
assert!(s.register(&root, &root.join("a")));
assert!(!s.register(&root, &root.join("a")), "a repeat is not new");
assert!(
!s.register(&root, &other.join("x")),
"a start outside the root is never kept"
);
for i in 0..MAX_STARTS_PER_ROOT - 1 {
assert!(s.register(&root, &root.join(format!("s{i}"))));
}
assert_eq!(s.starts(&root).len(), MAX_STARTS_PER_ROOT);
assert!(!s.register(&root, &root.join("a")));
assert!(s.register(&root, &root.join("new")));
let kept = s.starts(&root);
assert_eq!(kept.len(), MAX_STARTS_PER_ROOT);
assert!(kept.contains(&root.join("a")));
assert!(!kept.contains(&root.join("s0")), "{kept:?}");
assert!(s.register(&root, &root.join("s0")), "evicted, so new again");
assert!(s.register(&other, &other.join("x")));
s.forget(&root);
assert!(s.starts(&root).is_empty(), "a root's starts go with it");
assert_eq!(s.starts(&other), vec![other.join("x")]);
assert!(s.register(&root, &root), "the root is a start of its own");
assert!(!s.register(&root, &root), "and a repeat of it is not new");
assert_eq!(s.starts(&root), vec![root.clone()]);
}
#[test]
fn project_levels_are_the_union_of_every_walk() {
let root = PathBuf::from(if cfg!(windows) { "C:\\r" } else { "/r" });
let mut starts = ProjectStarts::default();
let s = Selector::new(vec![LoaderEnv::default()], None, None);
assert_eq!(s.project_levels(&root), vec![root.clone()], "no start yet");
starts.register(&root, &root.join("a").join("x"));
starts.register(&root, &root.join("b"));
let s = s.with_starts(starts);
let mut got = s.project_levels(&root);
got.sort();
let mut want = vec![
root.clone(),
root.join("a"),
root.join("a").join("x"),
root.join("b"),
];
want.sort();
assert_eq!(got, want);
}
#[test]
fn project_rules_are_first_match_per_walk() {
let t = tree();
let repo = t.root.join("repo");
write(&repo.join("a/CLAUDE.md"), "a");
write(&repo.join("b/AGENTS.md"), "b");
write(&repo.join("CONTEXT.md"), "x");
let mut starts = ProjectStarts::default();
starts.register(&repo, &repo.join("a"));
let s = t
.selector(vec![LoaderEnv::default()])
.with_starts(starts.clone());
assert!(s.admits(&repo.join("a/CLAUDE.md"), Some(&repo)));
assert!(
!s.admits(&repo.join("CONTEXT.md"), Some(&repo)),
"the walk from a found CLAUDE.md first"
);
starts.register(&repo, &repo.join("b"));
let s = t.selector(vec![LoaderEnv::default()]).with_starts(starts);
assert!(s.admits(&repo.join("a/CLAUDE.md"), Some(&repo)));
assert!(s.admits(&repo.join("b/AGENTS.md"), Some(&repo)));
assert!(!s.admits(&repo.join("CONTEXT.md"), Some(&repo)));
write(&repo.join("AGENTS.md"), "root");
assert!(s.admits(&repo.join("AGENTS.md"), Some(&repo)));
assert!(
!s.admits(&repo.join("a/CLAUDE.md"), Some(&repo)),
"the root's AGENTS.md now shadows a's CLAUDE.md"
);
}
#[test]
fn a_start_at_the_root_is_selected_after_a_nested_one() {
let t = tree();
let repo = t.root.join("repo");
write(&repo.join("a/AGENTS.md"), "a");
write(&repo.join("CLAUDE.md"), "root");
let mut starts = ProjectStarts::default();
assert!(starts.register(&repo, &repo.join("a")));
let nested = t
.selector(vec![LoaderEnv::default()])
.with_starts(starts.clone());
assert!(
!nested.admits(&repo.join("CLAUDE.md"), Some(&repo)),
"a's walk found AGENTS.md first"
);
assert!(starts.register(&repo, &repo), "the root is a new start");
let both = t.selector(vec![LoaderEnv::default()]).with_starts(starts);
assert!(both.admits(&repo.join("a/AGENTS.md"), Some(&repo)));
assert!(
both.admits(&repo.join("CLAUDE.md"), Some(&repo)),
"the root's own run reads its CLAUDE.md"
);
}
#[test]
fn relative_skill_paths_resolve_against_each_start() {
let t = tree();
let repo = t.root.join("repo");
write(
&repo.join("opencode.json"),
r#"{"skills": {"paths": ["extra-skills"]}}"#,
);
for dir in ["a", "b", ""] {
let base = if dir.is_empty() {
repo.clone()
} else {
repo.join(dir)
};
write(&base.join("extra-skills/s/SKILL.md"), dir);
}
let mut starts = ProjectStarts::default();
starts.register(&repo, &repo.join("a"));
starts.register(&repo, &repo.join("b"));
let extras = t
.selector(vec![LoaderEnv::default()])
.with_starts(starts)
.extras(Some(&repo));
let skills: Vec<&Path> = extras
.iter()
.filter(|e| e.kind == "skill")
.map(|e| e.path.as_path())
.collect();
assert!(skills.contains(&repo.join("a/extra-skills/s/SKILL.md").as_path()));
assert!(skills.contains(&repo.join("b/extra-skills/s/SKILL.md").as_path()));
assert!(
!skills.contains(&repo.join("extra-skills/s/SKILL.md").as_path()),
"no start sits at the root: {skills:?}"
);
}
#[test]
fn config_named_skills_obey_the_disable_switches() {
let t = tree();
write(
&t.global.join("opencode.json"),
r#"{"skills": {"paths": ["~/.claude/skills", "~/.agents/skills", "~/own"]}}"#,
);
let claude = t.home.join(".claude/skills/c/SKILL.md");
let agents = t.home.join(".agents/skills/g/SKILL.md");
let own = t.home.join("own/o/SKILL.md");
for p in [&claude, &agents, &own] {
write(p, "s");
}
let paths = |envs: Vec<LoaderEnv>| -> Vec<PathBuf> {
t.selector(envs)
.extras(None)
.into_iter()
.filter(|e| e.kind == "skill")
.map(|e| e.path)
.collect()
};
let all_on = paths(vec![LoaderEnv::default()]);
for p in [&claude, &agents, &own] {
assert!(all_on.contains(p), "{p:?} in {all_on:?}");
}
let claude_off = disabled(|e| e.disable_claude_code = true);
let off = paths(vec![claude_off.clone()]);
assert!(!off.contains(&claude), "{off:?}");
assert!(off.contains(&agents) && off.contains(&own));
let external_off = paths(vec![disabled(|e| e.disable_external_skills = true)]);
assert!(!external_off.contains(&claude) && !external_off.contains(&agents));
assert!(external_off.contains(&own));
let mixed = paths(vec![claude_off, LoaderEnv::default()]);
assert!(mixed.contains(&claude), "one report still loads it");
}
#[test]
fn a_glob_walk_is_bounded_by_entries_visited() {
let t = tree();
let wide = t.root.join("wide");
for i in 0..CONFIG_NAMED_GLOB_MAX_VISITS + 50 {
std::fs::create_dir_all(wide.join(format!("d{i:05}"))).unwrap();
}
write(&wide.join("zz/deep/unlikely-name.md"), "x");
let pattern = wide.join("**").join("unlikely-name.md");
assert!(
bounded_glob(&pattern).is_empty(),
"the budget stops the walk"
);
let small = t.root.join("small");
write(&small.join("x/y/unlikely-name.md"), "x");
write(&small.join("top.md"), "x");
assert_eq!(
bounded_glob(&small.join("**").join("unlikely-name.md")),
vec![small.join("x/y/unlikely-name.md")]
);
assert_eq!(
bounded_glob(&small.join("*.md")),
vec![small.join("top.md")],
"a `*` never crosses a separator"
);
assert_eq!(
bounded_glob(&small.join("top.md")),
vec![small.join("top.md")],
"a plain path is the file itself"
);
#[cfg(unix)]
{
let outside = t.root.join("outside");
write(&outside.join("unlikely-name.md"), "x");
std::os::unix::fs::symlink(&outside, small.join("link")).unwrap();
assert_eq!(
bounded_glob(&small.join("**").join("unlikely-name.md")),
vec![small.join("x/y/unlikely-name.md")],
"a symlinked directory is not descended"
);
}
}
#[test]
fn project_config_files_are_read_at_every_level() {
let t = tree();
let repo = t.root.join("repo");
write(
&repo.join("a/.opencode/opencode.json"),
r#"{"instructions": ["notes/*.md"]}"#,
);
write(&repo.join("a/notes/n.md"), "n");
let mut starts = ProjectStarts::default();
starts.register(&repo, &repo.join("a"));
let extras = t
.selector(vec![LoaderEnv::default()])
.with_starts(starts)
.extras(Some(&repo));
assert!(
extras.iter().any(|e| e.path == repo.join("a/notes/n.md")),
"{extras:?}"
);
}
pub(crate) const MANAGED_PLIST: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadIdentifier</key><string>ai.opencode.managed.SENTINEL-payload</string>
<key>PayloadVersion</key><integer>1</integer>
<key>permission</key><dict><key>bash</key><string>deny</string></dict>
<key>share</key><string>disabled</string>
<key>provider</key><dict><key>anthropic</key><dict><key>options</key><dict>
<key>apiKey</key><string>SENTINEL-key</string>
<key>baseURL</key><string>https://gw.example.com/v1</string>
</dict></dict></dict>
<key>mcp</key><dict><key>corp</key><dict>
<key>type</key><string>remote</string>
<key>url</key><string>https://mcp.example.com/sse</string>
<key>enabled</key><true/>
<key>headers</key><dict><key>Authorization</key><string>SENTINEL-header</string></dict>
</dict></dict>
<key>ratio</key><real>0.5</real>
</dict>
</plist>"#;
#[test]
fn a_managed_plist_converts_to_opencode_json() {
let got = plist_to_json(MANAGED_PLIST.as_bytes());
if !cfg!(target_os = "macos") {
assert!(got.is_err(), "{got:?}");
return;
}
let json: serde_json::Value = serde_json::from_str(&got.unwrap()).unwrap();
assert!(json.get("PayloadIdentifier").is_none());
assert!(json.get("PayloadVersion").is_none());
assert_eq!(json["permission"]["bash"], "deny");
assert_eq!(json["mcp"]["corp"]["enabled"], true);
assert_eq!(json["ratio"], 0.5);
let dated = MANAGED_PLIST.replace(
"<key>ratio</key><real>0.5</real>",
"<key>when</key><date>2026-01-01T00:00:00Z</date>",
);
assert!(plist_to_json(dated.as_bytes()).is_err());
assert!(plist_to_json(b"not a plist").is_err());
}
#[test]
fn the_first_managed_plist_that_converts_is_the_one_loaded() {
let tmp = tempfile::tempdir().unwrap();
let user = tmp.path().join("alice").join(MANAGED_PLIST_NAME);
let machine = tmp.path().join(MANAGED_PLIST_NAME);
write(&machine, MANAGED_PLIST);
let candidates = vec![user.clone(), machine.clone()];
if !cfg!(target_os = "macos") {
assert_eq!(first_managed_plist(&candidates), None);
return;
}
assert_eq!(first_managed_plist(&candidates), Some(&machine));
write(&user, MANAGED_PLIST);
assert_eq!(first_managed_plist(&candidates), Some(&user));
write(
&user,
&MANAGED_PLIST.replace(
"<key>ratio</key><real>0.5</real>",
"<key>when</key><date>2026-01-01T00:00:00Z</date>",
),
);
assert_eq!(first_managed_plist(&candidates), Some(&machine));
assert!(is_managed_plist(&machine));
assert!(!is_managed_plist(&tmp.path().join("opencode.json")));
}
fn slash(p: &Path) -> String {
crate::path_compat::display_path(p).replace('\\', "/")
}
}