From baa098f4bb08bd0b0730986b72c819c5f6624ccf Mon Sep 17 00:00:00 2001 From: limityan Date: Thu, 30 Jul 2026 01:01:20 +0800 Subject: [PATCH] fix(extensions): align OpenCode local source ordering --- .../opencode-config-assets-adapter-design.md | 40 ++- .../opencode-extension-compatibility.md | 30 +- .../opencode-adapter/src/agent_source.rs | 220 +++++------- .../opencode-adapter/src/command_source.rs | 219 ++++-------- .../opencode-adapter/src/hook_source.rs | 4 +- .../adapters/opencode-adapter/src/lib.rs | 1 + .../src/local_source_paths.rs | 328 ++++++++++++++++++ .../opencode-adapter/src/mcp_source.rs | 179 ++++------ .../tests/opencode_mcp_adapter.rs | 58 +++- .../assembly/core/src/external_sources.rs | 7 + .../opencode_local_source_order_tests.rs | 296 ++++++++++++++++ 11 files changed, 940 insertions(+), 442 deletions(-) create mode 100644 src/crates/adapters/opencode-adapter/src/local_source_paths.rs create mode 100644 src/crates/assembly/core/src/external_sources/opencode_local_source_order_tests.rs diff --git a/docs/architecture/extensions/opencode-config-assets-adapter-design.md b/docs/architecture/extensions/opencode-config-assets-adapter-design.md index e2ed8c5b3e..ea2ef55660 100644 --- a/docs/architecture/extensions/opencode-config-assets-adapter-design.md +++ b/docs/architecture/extensions/opencode-config-assets-adapter-design.md @@ -11,8 +11,9 @@ 配置字段与来源以 [OpenCode 配置文档](https://opencode.ai/docs/config/)和稳定提交中的主/TUI 配置实现为准。 本文同时记录当前可用切片与后续目标。BitFun 已实现本地用户全局/项目 Prompt Command 的来源发现、 -JSON/JSONC/Markdown 解析、参数展开、运行时刷新和冲突选择,也已接入全局/项目 Subagent 声明的安全子集; -但尚未实现本文定义的完整 OpenCode 配置来源图、全部合并语义和其他资产映射。 +JSON/JSONC/Markdown 解析、参数展开、运行时刷新和冲突选择,也已接入全局/项目 Subagent 声明的安全子集与 +本地 MCP 配置。三类 provider 复用 adapter 内部的本地路径顺序与监听根解析;但尚未实现本文定义的完整 +OpenCode 配置来源序列、全部合并语义和其他资产映射。 ## 1. 目标与边界 @@ -61,7 +62,12 @@ R1 的“自动应用”仅包含不启动外部进程、不 import 第三方 mo ## 3. 配置层级与来源 -### 3.1 OpenCode 来源图 +### 3.1 OpenCode 来源顺序 + +本节的来源顺序是解释 OpenCode 覆盖行为的概念模型,不要求实现公共 Graph DTO、缓存、Coordinator 或产品协议。 +当前生产切片只在 `opencode-adapter` 内部按需生成有序本地路径,各能力 provider 继续分别拥有解析、合并和物化; +候选版本与原子替换仍由现有 `ExternalSourceControlPlane` 持有。只有完整配置消费方落地后,才评审是否需要更丰富的 +内部来源事实,不能为目标矩阵预建公共“来源图”基础设施。 稳定版配置按“后加载覆盖前加载、非冲突字段合并”处理: @@ -95,18 +101,20 @@ OpenCode 当前版本的真实合并/去重语义,不用 BitFun 常规配置 所有来源合并后再应用 `OPENCODE_PERMISSION`、旧 `tools` 到 permission 的迁移,以及关闭自动压缩/裁剪的环境覆盖。这些属于固定版本的后处理,不是新的配置来源。 -当前 Prompt Command 子集只实现上述本地来源:XDG 用户配置根、`OPENCODE_CONFIG`、root-first 项目配置、用户/项目 -`command(s)/`、兼容 `~/.opencode` 与 `OPENCODE_CONFIG_DIR`;`OPENCODE_DISABLE_PROJECT_CONFIG` 可整体关闭项目 -扫描。`OPENCODE_CONFIG_CONTENT`、远程、组织、系统管理员与 MDM 来源仍保留在目标来源图中,不得在产品状态中误报 -为已加载。路径按规范化来源身份去重,显式环境路径与默认路径相同时只保留 OpenCode 顺序中的最后一个阶段。 +当前 Prompt Command、Subagent 与 MCP 子集只实现上述本地来源:XDG 用户配置根、`OPENCODE_CONFIG`、root-first +项目配置、opened-to-root 的项目 `.opencode` 目录阶段、兼容 `~/.opencode` 与最后应用的 +`OPENCODE_CONFIG_DIR`。Command/Subagent 再从对应目录读取 `command(s)/` 与 `agent(s)/mode(s)/`; +`OPENCODE_DISABLE_PROJECT_CONFIG` 会同时关闭项目配置、项目目录资产和对应监听根。三类 provider 复用同一私有 +路径顺序和 creation-safe 监听根,`OPENCODE_CONFIG_DIR` 不替换 XDG 用户根,并在 BitFun 来源标签中保持 +`WorkspaceLocal`。`ConfigPaths.directories` 按规范化来源身份保留首次出现的位置;显式目录与 XDG、项目 +`.opencode` 或兼容目录指向同一物理路径时,在该首次位置应用显式目录的加载语义和来源标签,不把它移动到末尾。 -当前 MCP 子集复用同一来源解释,但只读取本地用户全局配置、`OPENCODE_CONFIG`、root-first 项目配置以及作为全局 -晚覆盖的 `OPENCODE_CONFIG_DIR`;设置该目录后不再叠加默认全局目录。`OPENCODE_CONFIG_CONTENT`、远程、组织、 -系统管理员和 MDM 来源尚未接入,不能因 MCP 候选可运行就把目标来源图标记为完整实现。 +`OPENCODE_CONFIG_CONTENT`、远程、组织、系统管理员与 MDM 来源尚未接入,不能因三类本地候选可运行就把目标来源 +序列标记为完整实现。 ### 3.2 TUI 独立来源顺序 -`tui.json/jsonc` 不是主配置来源图的附属字段。稳定版使用独立顺序: +`tui.json/jsonc` 不是主配置来源顺序的附属字段。稳定版使用独立顺序: ```text 用户全局 TUI 配置 @@ -129,13 +137,14 @@ OpenCode 来源顺序决定兼容输入如何合并;BitFun 产品能力上限 ### 3.4 变化与切换 -每次解析生成不可变候选版本,包含来源图、有效值、未知字段、诊断、内容摘要和风险摘要。文件变化时: +完整配置目标中,每次解析生成不可变候选版本,包含有序来源事实、有效值、未知字段、诊断、内容摘要和风险摘要。 +当前本地 Command/Subagent/MCP 切片则继续发布各自既有 provider snapshot,不新增聚合配置对象。文件变化时: 1. 后台重新解析,不阻塞 TUI 或 Agent 主循环。 2. L1 新结果完整校验后在同一次状态提交中替换;失败时保留仍合规的上一份有效结果并显示更新失败原因。 3. L2/L3 的能力、凭据范围或执行域扩大时不激活候选;健康旧结果仍合规时继续服务,等待用户确认。 4. 与插件执行相关的入口或依赖变化只使对应执行版本候选失效,不清空无关配置和会话。 -5. 文件观察事件先聚合并在稳定窗口后重扫来源图;稳定重扫确认删除、停用、来源撤销、权限收紧或安全策略 +5. 文件观察事件先聚合并在稳定窗口后重扫来源顺序;稳定重扫确认删除、停用、来源撤销、权限收紧或安全策略 失效时撤下旧结果并重新计算下一来源,不能以缓存绕过。显式停用和安全撤销不等待文件稳定窗口。 6. 暂时不可读与明确删除分开表达;只有无安全影响且可验证的上一结果可在有界宽限期内标记为“暂时过期”。 7. 安全相关配置解析失败时不自动放宽已生效策略。 @@ -289,7 +298,7 @@ OpenCode 配置文档还包含下列不属于声明式目录资产、但会改 | 配置项 | 适配方式 | 明确边界 | |---|---|---| -| 独立 TUI 配置 | 按独立来源顺序处理 `$schema/theme/keybinds/plugin/plugin_enabled/leader_timeout/attention/prompt/scroll_speed/scroll_acceleration/diff_style/mouse` | 主配置来源图和构建期 TUI 布局选择不参与其运行时优先级。 | +| 独立 TUI 配置 | 按独立来源顺序处理 `$schema/theme/keybinds/plugin/plugin_enabled/leader_timeout/attention/prompt/scroll_speed/scroll_acceleration/diff_style/mouse` | 主配置来源顺序和构建期 TUI 布局选择不参与其运行时优先级。 | | `shell` | 交给实际执行域的 Terminal/Tool Runtime,保留短名称、绝对路径和平台默认发现 | 命令不存在时只使相关 shell/工具调用不可用,不阻止项目启动。 | | `logLevel`、`username` | 分别进入日志配置和会话展示身份 | 不改变插件权限或系统账户。 | | `tools` | 映射到 Tool 归属模块的模型可见性和启用状态 | 不能用“隐藏工具”代替真实权限控制,也不能启用产品未提供的工具。 | @@ -352,4 +361,5 @@ OpenCode 配置文档还包含下列不属于声明式目录资产、但会改 8. References 本地/Git 来源、Skills paths/urls、旧字段迁移和所有稳定顶层键。 9. Remote 执行域不静默回本机。 -完整样例集固定到 OpenCode release commit;开发分支变化只触发差异报告,不能静默改变稳定兼容行为。 +完整样例集固定到 OpenCode `v1.18.9` release commit +`4da7bb44c84e013fa53e9c5d02ac753d1435c81a`;开发分支变化只触发差异报告,不能静默改变稳定兼容行为。 diff --git a/docs/architecture/extensions/opencode-extension-compatibility.md b/docs/architecture/extensions/opencode-extension-compatibility.md index 321cc4f204..ac362da702 100644 --- a/docs/architecture/extensions/opencode-extension-compatibility.md +++ b/docs/architecture/extensions/opencode-extension-compatibility.md @@ -19,18 +19,18 @@ ## 1. 基线与判断方法 -本次清单刷新于 2026-07-21: +本次清单刷新于 2026-07-30: -- 最新稳定版为 [`v1.18.4`](https://github.com/anomalyco/opencode/releases/tag/v1.18.4),提交为 [`49c69c5ed3ccf706b61b3febb43c8aaff7f8325e`](https://github.com/anomalyco/opencode/commit/49c69c5ed3ccf706b61b3febb43c8aaff7f8325e)。 -- 开发分支前瞻检查记录为提交 [`849c2598abc7d2b40261e74b5826bc74ffc78308`](https://github.com/anomalyco/opencode/commit/849c2598abc7d2b40261e74b5826bc74ffc78308)。该值会持续变化,只用于发现差异,不计入稳定兼容承诺。 -- `v2` 前瞻分支检查记录为提交 [`44b6938b2ad2895bdd89d5d5cf753fb01ca8e3dd`](https://github.com/anomalyco/opencode/commit/44b6938b2ad2895bdd89d5d5cf753fb01ca8e3dd):仓库仍以 Bun 作为 package manager、开发和默认编译路径,同时提供 Node 26 SEA 并行构建与 Node 启动器。它不是“已经完全切换 Node”的稳定承诺。 +- 最新稳定版为 [`v1.18.9`](https://github.com/anomalyco/opencode/releases/tag/v1.18.9),提交为 [`4da7bb44c84e013fa53e9c5d02ac753d1435c81a`](https://github.com/anomalyco/opencode/commit/4da7bb44c84e013fa53e9c5d02ac753d1435c81a)。 +- 开发分支前瞻检查记录为提交 [`7565e03536d19e850f9996c407f9bf5e932b5f7a`](https://github.com/anomalyco/opencode/commit/7565e03536d19e850f9996c407f9bf5e932b5f7a)。该值会持续变化,只用于发现差异,不计入稳定兼容承诺。 +- `v2` 前瞻分支检查记录为提交 [`247f14f9556c31ee532cb4a79a83283e753adc62`](https://github.com/anomalyco/opencode/commit/247f14f9556c31ee532cb4a79a83283e753adc62):仓库仍以 Bun 作为 package manager、开发和默认编译路径,同时提供 Node 26 SEA 并行构建与 Node 启动器。它不是“已经完全切换 Node”的稳定承诺。 - 配置、插件、工具、Agent、Skill、Command、Rule、MCP、LSP、Formatter、Theme、Keybind、开发工具包、Server 和 ACP 以 [OpenCode 官方文档](https://opencode.ai/docs/) 为准。 -- 稳定服务插件接口以 [`packages/plugin/src/index.ts`](https://github.com/anomalyco/opencode/blob/49c69c5ed3ccf706b61b3febb43c8aaff7f8325e/packages/plugin/src/index.ts) 为准; -- custom tool 接口以 [`packages/plugin/src/tool.ts`](https://github.com/anomalyco/opencode/blob/49c69c5ed3ccf706b61b3febb43c8aaff7f8325e/packages/plugin/src/tool.ts) 为准; -- 终端插件接口以 [`packages/plugin/src/tui.ts`](https://github.com/anomalyco/opencode/blob/49c69c5ed3ccf706b61b3febb43c8aaff7f8325e/packages/plugin/src/tui.ts) 为准; -- 终端插件行为说明以 [`tui-plugins.md`](https://github.com/anomalyco/opencode/blob/49c69c5ed3ccf706b61b3febb43c8aaff7f8325e/packages/opencode/specs/tui-plugins.md) 为准。 +- 稳定服务插件接口以 [`packages/plugin/src/index.ts`](https://github.com/anomalyco/opencode/blob/4da7bb44c84e013fa53e9c5d02ac753d1435c81a/packages/plugin/src/index.ts) 为准; +- custom tool 接口以 [`packages/plugin/src/tool.ts`](https://github.com/anomalyco/opencode/blob/4da7bb44c84e013fa53e9c5d02ac753d1435c81a/packages/plugin/src/tool.ts) 为准; +- 终端插件接口以 [`packages/plugin/src/tui.ts`](https://github.com/anomalyco/opencode/blob/4da7bb44c84e013fa53e9c5d02ac753d1435c81a/packages/plugin/src/tui.ts) 为准; +- 终端插件行为说明以 [`tui-plugins.md`](https://github.com/anomalyco/opencode/blob/4da7bb44c84e013fa53e9c5d02ac753d1435c81a/packages/opencode/specs/tui-plugins.md) 为准。 -稳定兼容只固定 `v1.18.4` 的公开文档、接口源码和样例;开发及 `v2` 提交仅用于发现未来差异,不进入当前承诺。升级时必须 +稳定兼容只固定 `v1.18.9` 的公开文档、接口源码和样例;开发及 `v2` 提交仅用于发现未来差异,不进入当前承诺。升级时必须 重新比较实际消费的文件和行为,不能沿用本次结论。 ### 1.1 差异类型 @@ -103,7 +103,7 @@ OpenCode,和 OpenCode 配置/插件进入 BitFun 是两个独立验收方向 | OpenCode 扩展项 | BitFun 差异 | 当前状态 | 目标可实现性 | 成熟度依赖(非执行顺序) | BitFun 需要完成的工作 | 细节 | |---|---|---|---|---|---|---| -| 配置层级与合并 | 融合现有能力 | 未实现 | 可完整适配 | OC-R1 | 按 remote、global、自定义文件、project、`.opencode`、内联和组织配置构造来源图并保留最终来源 | [来源与合并](opencode-config-assets-adapter-design.md#3-配置层级与来源) | +| 配置层级与合并 | 融合现有能力 | 部分实现:Command/Subagent/MCP 本地来源顺序 | 可完整适配 | OC-R1 | 当前三类 provider 复用私有本地路径顺序与监听根;后续按 remote、global、自定义文件、project、`.opencode`、内联和组织配置补齐有序来源并保留最终来源 | [来源与合并](opencode-config-assets-adapter-design.md#3-配置层级与来源) | | JSON、JSONC、环境变量、文件引用 | 转换参数 + 明确降级 | 未实现 | 可主要适配 | OC-R1 | 有效配置保持 OpenCode 解码语义;未知字段保留和非安全字段局部恢复属于 BitFun 鲁棒性增强,安全/执行字段无效时不激活受影响结果 | [解析与鲁棒性](opencode-config-assets-adapter-design.md#4-解析与鲁棒性) | | 独立 `tui.json/jsonc` | 融合现有能力 + 转换参数 | 未实现 | 可完整适配 | OC-R1 | 按 global、`OPENCODE_TUI_CONFIG`、project、`.opencode` 独立顺序加载,不能复用主配置优先级 | [TUI 来源](opencode-config-assets-adapter-design.md#32-tui-独立来源顺序) | | Rules / Instructions | 转换参数 | 未实现 | 可完整适配 | OC-R1 | R1 映射本地/已缓存内容;需要主动联网的远程 instruction 在 R2 通过归属模块保护后获取 | [声明式资产](opencode-config-assets-adapter-design.md#5-声明式资产映射) | @@ -122,7 +122,7 @@ OpenCode,和 OpenCode 配置/插件进入 BitFun 是两个独立验收方向 | `server` | 明确降级 | 未实现 | 明确降级 | OC-R4-P | 只供显式外部协议兼容服务使用,不改变普通 BitFun 启动方式 | [其他稳定配置](opencode-config-assets-adapter-design.md#55-其他稳定配置项) | | `autoupdate` | 明确降级 | 不适用 | 明确降级 | 不安排 | 不控制 BitFun 产品更新;保留来源并显示“不适用于 BitFun 更新” | [其他稳定配置](opencode-config-assets-adapter-design.md#55-其他稳定配置项) | -本类整体风险是来源优先级错误、相似能力语义不一致和远程执行域错配。控制点集中在来源图、字段级诊断、归属模块校验和官方配置样例,不在每个配置项内重复设计。 +本类整体风险是来源优先级错误、相似能力语义不一致和远程执行域错配。控制点集中在有序来源事实、字段级诊断、归属模块校验和官方配置样例,不在每个配置项内重复设计,也不为概念完整性新增公共 Graph 对象。 ### 3.2 工具与服务插件 @@ -133,7 +133,7 @@ OpenCode,和 OpenCode 配置/插件进入 BitFun 是两个独立验收方向 | 插件 `tool` map | 补基础能力 + 补扩展接口 | 未实现 | 可完整适配 | OC-R2 | 运行插件工厂,按同一双表示注册真实工具,并接到 Tool 归属模块 | [工具加载](opencode-plugin-runtime-adapter-design.md#5-工具与插件加载) | | 项目与用户目录插件 | 补基础能力 | 未实现 | 可完整适配 | OC-R2 | 直接发现本地 JS/TS 模块,不要求 BitFun 专用清单;来源、插件身份和执行域确认后,在旧 Host 停止后由新 Host 加载 | [服务插件](opencode-plugin-runtime-adapter-design.md#52-服务插件) | | 配置中的软件包插件 | 补基础能力 | 未实现 | 可完整适配 | OC-R2 | 确认来源、插件身份和执行域后,用 npm 配置、Arborist、package-lock 和 `ignoreScripts: true` 准备依赖,再由与固定插件样例匹配的 Node/Bun 脚本执行后端加载 | [服务插件](opencode-plugin-runtime-adapter-design.md#52-服务插件) | -| 全局插件加载 | 补基础能力 | 未实现 | 可完整适配 | OC-R2 | 自动发现全局配置和 ConfigPaths 全局目录,并按完整来源图生成 `plugin_origins`;首次可执行启用按来源、插件身份和执行域确认,决定只提示一次且可按项目覆盖 | [服务插件](opencode-plugin-runtime-adapter-design.md#52-服务插件) | +| 全局插件加载 | 补基础能力 | 未实现 | 可完整适配 | OC-R2 | 自动发现全局配置和 ConfigPaths 全局目录,并按完整来源顺序生成 `plugin_origins`;首次可执行启用按来源、插件身份和执行域确认,决定只提示一次且可按项目覆盖 | [服务插件](opencode-plugin-runtime-adapter-design.md#52-服务插件) | | `package.json`、入口与依赖 | 补基础能力 | 未实现 | 可主要适配 | OC-R2 | 复现 server 入口、入口回退、`engines.opencode`、npm 配置和锁文件;原生模块失败只影响对应插件 | [来源与执行版本](opencode-plugin-runtime-adapter-design.md#4-来源与执行版本) | | 内置/MCP/外部同名工具;后续 pure/重复插件顺序 | 融合现有能力 | standalone Tool 显式选择已实现 | 可完整适配 | OC-R2 | 当前按候选身份与内容版本记忆选择且不静默覆盖;package plugin 阶段再复现 internal-first、pure、来源顺序和去重 | [注册与覆盖](opencode-plugin-runtime-adapter-design.md#53-注册与覆盖) | | `project` / `directory` / `worktree` | 直接桥接 | standalone Tool 已传 `directory/worktree/sessionID`;完整 `project` 未实现 | 可完整适配 | OC-R2 | 当前 `directory` 为打开的 workspace、`worktree` 为 Git 根并传递真实 session;完整插件 `project` 和 Remote 在 OC-R5 前保持 `unsupported` | [插件兼容接口](opencode-plugin-runtime-adapter-design.md#7-opencode-插件兼容接口) | @@ -232,14 +232,14 @@ OpenCode 发布新稳定版时按以下顺序升级: ### 4.2 首次加载与全局插件 -- 启动时按[完整来源图](opencode-config-assets-adapter-design.md#31-opencode-来源图)生成 `plugin_origins`,并包含 +- 启动时按[完整来源顺序](opencode-config-assets-adapter-design.md#31-opencode-来源顺序)生成 `plugin_origins`,并包含 ConfigPaths 中各配置/插件目录;目录自动发现只适用于服务插件,TUI 插件必须出现在合并后的 `tui.json/jsonc` `plugin` 列表。发现本身不授予执行资格。 - 当前能够安全消费的非执行内容按用户的“自动应用低风险内容 / 先询问”偏好处理。默认自动应用并显示一次 可撤销摘要;当前支持范围内的 JS standalone Tool 在确认前显示“已发现,静态预览,未执行”,范围外 Tool 显示稳定不支持原因,不能进入 worker。 - 可执行插件、Tool、Hook 和 TUI 插件的来源级加载偏好按“来源限定身份 + 插件身份 + 入口类型 + 执行域 + 更新策略”确认; - activation/import 再按有效来源图、工作目录、实际 OS 用户、文件/网络/进程权限、凭据和能力摘要重新检查。workspace + activation/import 再按有效来源顺序、工作目录、实际 OS 用户、文件/网络/进程权限、凭据和能力摘要重新检查。workspace 只在配置或插件实例确有独立状态时限定该状态,不拥有 runtime 或 Plugin Host。确认是非阻塞待办;同一有效 摘要下的依赖准备、Host 启动和贡献注册不再逐层重复询问。 - 当前内置/MCP 候选内容摘要基于 Tool Catalog 已公开的身份、描述和 schema;若实现行为变化但这些摘要完全不变,当前 standalone Tool 端到端能力 @@ -317,7 +317,7 @@ Node 进程永久累积;这不是 package-plugin 的 workspace-scoped runtime | 大类 | 整体风险 | 主要控制点 | |---|---|---| -| 配置与声明式资产 | 来源优先级错误、字段语义错配、远程路径误用 | 来源图、字段级诊断、版本化样例、实际执行域解析 | +| 配置与声明式资产 | 来源优先级错误、字段语义错配、远程路径误用 | 有序来源事实、字段级诊断、版本化样例、实际执行域解析 | | 工具与服务插件 | 任意代码副作用、依赖失败、顺序不一致、进程与系统资源失控 | import 前策略、安全启动、独立进程树、平台资源预算、固定运行时、顺序测试、期限、取消、有界队列、可验证的旧版本 | | 稳定 Hook | 把变换误作通知、非法结果污染业务状态 | 类型化调用、顺序执行、每步结构检查、归属模块终检 | | 终端插件 | 组件运行时不等价、焦点/模式锁死、终端恢复失败 | 宿主操作与渲染分离、安全降级页、强制清理和终端恢复测试 | diff --git a/src/crates/adapters/opencode-adapter/src/agent_source.rs b/src/crates/adapters/opencode-adapter/src/agent_source.rs index f7af5f2e49..e14d0995f9 100644 --- a/src/crates/adapters/opencode-adapter/src/agent_source.rs +++ b/src/crates/adapters/opencode-adapter/src/agent_source.rs @@ -1,3 +1,8 @@ +use crate::local_source_paths::{ + find_project_root, local_watch_roots, ordered_local_config_directories, + project_asset_directories, project_config_directories, user_config_dir, + LocalConfigDirectoryKind, +}; use bitfun_product_domains::external_sources::{ EcosystemId, ExternalSourceAssetKind, ExternalSourceContext, ExternalSourceDiagnostic, ExternalSourceHealth, ExternalSourceProviderError, ExternalSourceRecord, ExternalSourceScope, @@ -69,11 +74,10 @@ pub struct OpenCodeSubagentProviderOptions { impl OpenCodeSubagentProviderOptions { pub fn from_environment() -> Self { let home = dirs::home_dir(); - let user_config_dir = std::env::var_os("XDG_CONFIG_HOME") - .map(PathBuf::from) - .or_else(|| home.as_ref().map(|home| home.join(".config"))) - .unwrap_or_else(|| PathBuf::from(".config")) - .join("opencode"); + let user_config_dir = user_config_dir( + std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from), + home.clone(), + ); Self { user_config_dir, legacy_user_config_dir: home.map(|home| home.join(".opencode")), @@ -135,7 +139,7 @@ impl OpenCodeSubagentProvider { if self.options.project_config_enabled { if let Some(workspace_root) = &context.workspace_root { let project_root = self.project_root(workspace_root); - for directory in directories_between(&project_root, workspace_root) { + for directory in project_config_directories(&project_root, workspace_root) { push_config_files( &mut layers, &directory, @@ -145,62 +149,74 @@ impl OpenCodeSubagentProvider { } } } - push_agent_files( - &mut layers, + let project_directories = if self.options.project_config_enabled { + context + .workspace_root + .as_ref() + .map(|workspace_root| { + project_asset_directories(&self.project_root(workspace_root), workspace_root) + }) + .unwrap_or_default() + } else { + Vec::new() + }; + for directory in ordered_local_config_directories( &self.options.user_config_dir, - ExternalSourceScope::UserGlobal, - "OpenCode user agents", - )?; - if self.options.project_config_enabled { - if let Some(workspace_root) = &context.workspace_root { - let project_root = self.project_root(workspace_root); - for directory in directories_between(&project_root, workspace_root) { - let opencode = directory.join(".opencode"); + self.options.legacy_user_config_dir.as_deref(), + self.options.explicit_config_dir.as_deref(), + &project_directories, + ) { + match directory.kind { + LocalConfigDirectoryKind::User => push_agent_files( + &mut layers, + &directory.path, + directory.scope, + "OpenCode user agents", + )?, + LocalConfigDirectoryKind::Project => { push_config_files( &mut layers, - &opencode, - ExternalSourceScope::Project, + &directory.path, + directory.scope, "OpenCode project agent configuration", ); push_agent_files( &mut layers, - &opencode, - ExternalSourceScope::Project, + &directory.path, + directory.scope, "OpenCode project agents", )?; } + LocalConfigDirectoryKind::Legacy => { + push_config_files( + &mut layers, + &directory.path, + directory.scope, + "OpenCode legacy user configuration", + ); + push_agent_files( + &mut layers, + &directory.path, + directory.scope, + "OpenCode legacy user agents", + )?; + } + LocalConfigDirectoryKind::Explicit => { + push_config_files( + &mut layers, + &directory.path, + directory.scope, + "OpenCode OPENCODE_CONFIG_DIR", + ); + push_agent_files( + &mut layers, + &directory.path, + directory.scope, + "OpenCode explicit agents", + )?; + } } } - if let Some(legacy) = &self.options.legacy_user_config_dir { - if legacy != &self.options.user_config_dir { - push_config_files( - &mut layers, - legacy, - ExternalSourceScope::UserGlobal, - "OpenCode legacy user configuration", - ); - push_agent_files( - &mut layers, - legacy, - ExternalSourceScope::UserGlobal, - "OpenCode legacy user agents", - )?; - } - } - if let Some(directory) = &self.options.explicit_config_dir { - push_config_files( - &mut layers, - directory, - ExternalSourceScope::WorkspaceLocal, - "OpenCode OPENCODE_CONFIG_DIR", - ); - push_agent_files( - &mut layers, - directory, - ExternalSourceScope::WorkspaceLocal, - "OpenCode explicit agents", - )?; - } Ok(deduplicate_layers_keep_last(layers)) } } @@ -344,32 +360,24 @@ impl ExternalSubagentSourceProvider for OpenCodeSubagentProvider { } fn watch_roots(&self, context: &ExternalSourceContext) -> Vec { - let mut roots = BTreeMap::new(); - add_directory_watch_roots(&mut roots, &self.options.user_config_dir); - if let Some(path) = &self.options.legacy_user_config_dir { - add_directory_watch_roots(&mut roots, path); - } - if let Some(path) = &self.options.explicit_config_file { - if let Some(parent) = path.parent() { - add_nearest_existing_watch_root(&mut roots, parent); - } - } - if let Some(path) = &self.options.explicit_config_dir { - add_directory_watch_roots(&mut roots, path); - } - if self.options.project_config_enabled { - if let Some(workspace_root) = &context.workspace_root { - let project_root = self.project_root(workspace_root); - for directory in directories_between(&project_root, workspace_root) { - add_watch_root(&mut roots, directory.clone(), false); - add_directory_watch_roots(&mut roots, &directory.join(".opencode")); - } - } - } - roots - .into_iter() - .map(|(path, recursive)| ExternalWatchRoot { path, recursive }) - .collect() + let project_directories = if self.options.project_config_enabled { + context + .workspace_root + .as_ref() + .map(|workspace_root| { + project_config_directories(&self.project_root(workspace_root), workspace_root) + }) + .unwrap_or_default() + } else { + Vec::new() + }; + local_watch_roots( + &self.options.user_config_dir, + self.options.legacy_user_config_dir.as_deref(), + self.options.explicit_config_file.as_deref(), + self.options.explicit_config_dir.as_deref(), + &project_directories, + ) } } @@ -1132,63 +1140,3 @@ fn environment_truthy(key: &str) -> bool { .ok() .is_some_and(|value| matches!(value.to_ascii_lowercase().as_str(), "true" | "1")) } - -fn find_project_root(start: &Path) -> PathBuf { - let start = if start.is_file() { - start.parent().unwrap_or(start) - } else { - start - }; - start - .ancestors() - .find(|path| path.join(".git").exists()) - .unwrap_or(start) - .to_path_buf() -} - -fn directories_between(root: &Path, opened: &Path) -> Vec { - let opened = if opened.is_file() { - opened.parent().unwrap_or(opened) - } else { - opened - }; - let mut directories = opened - .ancestors() - .take_while(|path| path.starts_with(root)) - .map(Path::to_path_buf) - .collect::>(); - directories.reverse(); - directories -} - -fn nearest_existing_path(mut path: PathBuf) -> Option { - loop { - if path.exists() { - return Some(path); - } - if !path.pop() { - return None; - } - } -} - -fn add_watch_root(roots: &mut BTreeMap, path: PathBuf, recursive: bool) { - roots - .entry(path) - .and_modify(|existing| *existing |= recursive) - .or_insert(recursive); -} - -fn add_nearest_existing_watch_root(roots: &mut BTreeMap, path: &Path) { - if let Some(path) = nearest_existing_path(path.to_path_buf()) { - add_watch_root(roots, path, false); - } -} - -fn add_directory_watch_roots(roots: &mut BTreeMap, directory: &Path) { - if directory.exists() { - add_watch_root(roots, directory.to_path_buf(), true); - } else if let Some(parent) = directory.parent() { - add_nearest_existing_watch_root(roots, parent); - } -} diff --git a/src/crates/adapters/opencode-adapter/src/command_source.rs b/src/crates/adapters/opencode-adapter/src/command_source.rs index 40fb0c9747..a9905cd5c2 100644 --- a/src/crates/adapters/opencode-adapter/src/command_source.rs +++ b/src/crates/adapters/opencode-adapter/src/command_source.rs @@ -1,3 +1,8 @@ +use crate::local_source_paths::{ + find_project_root, local_watch_roots, ordered_local_config_directories, + project_asset_directories, project_config_directories, user_config_dir, + LocalConfigDirectoryKind, +}; use bitfun_product_domains::external_sources::{ EcosystemId, ExpandedPromptCommand, ExternalSourceAssetKind, ExternalSourceContext, ExternalSourceDiagnostic, ExternalSourceHealth, ExternalSourceProviderError, @@ -37,7 +42,7 @@ pub struct OpenCodeCommandProviderOptions { impl OpenCodeCommandProviderOptions { pub fn from_environment() -> Self { let home = dirs::home_dir(); - let user_config_dir = opencode_user_config_dir( + let user_config_dir = user_config_dir( std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from), home.clone(), ); @@ -95,7 +100,7 @@ impl OpenCodeCommandProvider { if self.options.project_config_enabled { if let Some(workspace_root) = &context.workspace_root { let project_root = find_project_root(workspace_root); - for directory in directories_between(&project_root, workspace_root) { + for directory in project_config_directories(&project_root, workspace_root) { push_config_directory_layers( &mut layers, &directory, @@ -105,51 +110,54 @@ impl OpenCodeCommandProvider { } } } - // Phase 4: global command directories. - push_command_directory_layer( - &mut layers, + // Phase 4: ConfigPaths.directories. OpenCode keeps the first physical + // directory when an environment path aliases an earlier entry. + let project_directories = if self.options.project_config_enabled { + context + .workspace_root + .as_ref() + .map(|workspace_root| { + project_asset_directories(&find_project_root(workspace_root), workspace_root) + }) + .unwrap_or_default() + } else { + Vec::new() + }; + for directory in ordered_local_config_directories( &self.options.user_config_dir, - ExternalSourceScope::UserGlobal, - "OpenCode user command directory", - ); - // Phase 5: project .opencode directories, nearest first. Since later - // values win, the outer project directory wins a same-name tie. - if self.options.project_config_enabled { - if let Some(workspace_root) = &context.workspace_root { - let project_root = find_project_root(workspace_root); - for directory in directories_between(&project_root, workspace_root) - .into_iter() - .rev() - { + self.options.legacy_user_config_dir.as_deref(), + self.options.explicit_config_dir.as_deref(), + &project_directories, + ) { + match directory.kind { + LocalConfigDirectoryKind::User => push_command_directory_layer( + &mut layers, + &directory.path, + directory.scope, + "OpenCode user command directory", + ), + LocalConfigDirectoryKind::Project => { push_directory_layers( &mut layers, - &directory.join(".opencode"), - ExternalSourceScope::Project, + &directory.path, + directory.scope, "OpenCode project command directory", ); } - } - } - // Phase 6: ~/.opencode compatibility directory. - if let Some(legacy) = &self.options.legacy_user_config_dir { - if legacy != &self.options.user_config_dir { - push_directory_layers( + LocalConfigDirectoryKind::Legacy => push_directory_layers( &mut layers, - legacy, - ExternalSourceScope::UserGlobal, + &directory.path, + directory.scope, "OpenCode legacy user configuration", - ); + ), + LocalConfigDirectoryKind::Explicit => push_directory_layers( + &mut layers, + &directory.path, + directory.scope, + "OpenCode OPENCODE_CONFIG_DIR", + ), } } - // Phase 7: OPENCODE_CONFIG_DIR. - if let Some(directory) = &self.options.explicit_config_dir { - push_directory_layers( - &mut layers, - directory, - ExternalSourceScope::WorkspaceLocal, - "OpenCode OPENCODE_CONFIG_DIR", - ); - } deduplicate_layers_keep_last(layers) } } @@ -366,32 +374,24 @@ impl PromptCommandSourceProvider for OpenCodeCommandProvider { } fn watch_roots(&self, context: &ExternalSourceContext) -> Vec { - let mut roots = BTreeMap::new(); - add_directory_watch_roots(&mut roots, &self.options.user_config_dir); - if let Some(path) = &self.options.legacy_user_config_dir { - add_directory_watch_roots(&mut roots, path); - } - if let Some(path) = &self.options.explicit_config_file { - if let Some(parent) = path.parent() { - add_nearest_existing_watch_root(&mut roots, parent); - } - } - if let Some(path) = &self.options.explicit_config_dir { - add_directory_watch_roots(&mut roots, path); - } - if self.options.project_config_enabled { - if let Some(workspace_root) = &context.workspace_root { - let project_root = find_project_root(workspace_root); - for directory in directories_between(&project_root, workspace_root) { - add_watch_root(&mut roots, directory.clone(), false); - add_directory_watch_roots(&mut roots, &directory.join(".opencode")); - } - } - } - roots - .into_iter() - .map(|(path, recursive)| ExternalWatchRoot { path, recursive }) - .collect() + let project_directories = if self.options.project_config_enabled { + context + .workspace_root + .as_ref() + .map(|workspace_root| { + project_config_directories(&find_project_root(workspace_root), workspace_root) + }) + .unwrap_or_default() + } else { + Vec::new() + }; + local_watch_roots( + &self.options.user_config_dir, + self.options.legacy_user_config_dir.as_deref(), + self.options.explicit_config_file.as_deref(), + self.options.explicit_config_dir.as_deref(), + &project_directories, + ) } } @@ -958,16 +958,6 @@ fn command_content_version(name: &str, input: &OpenCodeCommandInput) -> String { format!("sha256:{}", hex::encode(hasher.finalize())) } -pub(crate) fn opencode_user_config_dir( - xdg_config_home: Option, - home: Option, -) -> PathBuf { - xdg_config_home - .or_else(|| home.map(|home| home.join(".config"))) - .unwrap_or_else(|| PathBuf::from(".config")) - .join("opencode") -} - fn environment_truthy(key: &str) -> bool { std::env::var(key) .ok() @@ -1164,86 +1154,3 @@ fn content_version<'a>(entries: impl IntoIterator) } format!("sha256:{}", hex::encode(hasher.finalize())) } - -fn find_project_root(start: &Path) -> PathBuf { - let start = if start.is_file() { - start.parent().unwrap_or(start) - } else { - start - }; - start - .ancestors() - .find(|path| path.join(".git").exists()) - .unwrap_or(start) - .to_path_buf() -} - -fn directories_between(root: &Path, opened: &Path) -> Vec { - let opened = if opened.is_file() { - opened.parent().unwrap_or(opened) - } else { - opened - }; - let mut directories = opened - .ancestors() - .take_while(|path| path.starts_with(root)) - .map(Path::to_path_buf) - .collect::>(); - directories.reverse(); - directories -} - -fn nearest_existing_path(mut path: PathBuf) -> Option { - loop { - if path.exists() { - return Some(path); - } - if !path.pop() { - return None; - } - } -} - -#[cfg(test)] -mod tests { - use super::opencode_user_config_dir; - use std::path::PathBuf; - - #[test] - fn default_config_root_uses_xdg_semantics_on_every_platform() { - assert_eq!( - opencode_user_config_dir(None, Some(PathBuf::from("home"))), - PathBuf::from("home/.config/opencode") - ); - assert_eq!( - opencode_user_config_dir( - Some(PathBuf::from("custom-config")), - Some(PathBuf::from("home")) - ), - PathBuf::from("custom-config/opencode") - ); - } -} - -fn add_watch_root(roots: &mut BTreeMap, path: PathBuf, recursive: bool) { - roots - .entry(path) - .and_modify(|existing| *existing |= recursive) - .or_insert(recursive); -} - -fn add_nearest_existing_watch_root(roots: &mut BTreeMap, path: &Path) { - if let Some(path) = nearest_existing_path(path.to_path_buf()) { - add_watch_root(roots, path, false); - } -} - -fn add_directory_watch_roots(roots: &mut BTreeMap, directory: &Path) { - if let Some(parent) = directory.parent() { - add_nearest_existing_watch_root(roots, parent); - } - // Keep the desired root even before it exists. The host watches its nearest - // existing parent non-recursively, then promotes this root to a recursive - // watch after a creation event and a successful rescan. - add_watch_root(roots, directory.to_path_buf(), true); -} diff --git a/src/crates/adapters/opencode-adapter/src/hook_source.rs b/src/crates/adapters/opencode-adapter/src/hook_source.rs index fb17413d6e..3f237e9056 100644 --- a/src/crates/adapters/opencode-adapter/src/hook_source.rs +++ b/src/crates/adapters/opencode-adapter/src/hook_source.rs @@ -1,5 +1,5 @@ use crate::{ - command_source::{opencode_user_config_dir, strip_jsonc}, + command_source::strip_jsonc, local_source_paths::user_config_dir, source_adapter::statically_discover_hook_events, }; use bitfun_product_domains::external_hook_catalog::{ @@ -47,7 +47,7 @@ pub struct OpenCodeHookProviderOptions { impl OpenCodeHookProviderOptions { pub fn from_environment() -> Self { let home = dirs::home_dir(); - let user_config_dir = opencode_user_config_dir( + let user_config_dir = user_config_dir( std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from), home.clone(), ); diff --git a/src/crates/adapters/opencode-adapter/src/lib.rs b/src/crates/adapters/opencode-adapter/src/lib.rs index 52497348e5..a8fa37fe60 100644 --- a/src/crates/adapters/opencode-adapter/src/lib.rs +++ b/src/crates/adapters/opencode-adapter/src/lib.rs @@ -10,6 +10,7 @@ mod agent_source; mod command_source; mod hook_contributions; mod hook_source; +mod local_source_paths; mod mcp_source; mod source_adapter; mod tool_source; diff --git a/src/crates/adapters/opencode-adapter/src/local_source_paths.rs b/src/crates/adapters/opencode-adapter/src/local_source_paths.rs new file mode 100644 index 0000000000..b77c04e983 --- /dev/null +++ b/src/crates/adapters/opencode-adapter/src/local_source_paths.rs @@ -0,0 +1,328 @@ +use bitfun_product_domains::external_sources::{ExternalSourceScope, ExternalWatchRoot}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LocalConfigDirectoryKind { + User, + Project, + Legacy, + Explicit, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LocalConfigDirectory { + pub(crate) path: PathBuf, + pub(crate) kind: LocalConfigDirectoryKind, + pub(crate) scope: ExternalSourceScope, +} + +pub(crate) fn user_config_dir(xdg_config_home: Option, home: Option) -> PathBuf { + xdg_config_home + .or_else(|| home.map(|home| home.join(".config"))) + .unwrap_or_else(|| PathBuf::from(".config")) + .join("opencode") +} + +pub(crate) fn find_project_root(start: &Path) -> PathBuf { + let start = opened_directory(start); + start + .ancestors() + .find(|path| path.join(".git").exists()) + .unwrap_or(start) + .to_path_buf() +} + +/// OpenCode loads project config files from the project boundary toward the +/// opened directory so that the closest config file is applied last. +pub(crate) fn project_config_directories(root: &Path, opened: &Path) -> Vec { + let opened = opened_directory(opened); + let mut directories = opened + .ancestors() + .take_while(|path| path.starts_with(root)) + .map(Path::to_path_buf) + .collect::>(); + directories.reverse(); + directories +} + +/// OpenCode's ConfigPaths directory phase keeps the upward traversal order: +/// the opened directory is applied first and the outer project directory last. +pub(crate) fn project_asset_directories(root: &Path, opened: &Path) -> Vec { + let mut directories = project_config_directories(root, opened); + directories.reverse(); + directories +} + +/// Reproduces OpenCode's `ConfigPaths.directories` order for the local slice. +/// Physical aliases keep their first position, while an explicit alias keeps +/// the `OPENCODE_CONFIG_DIR` scope and loading semantics at that position. +pub(crate) fn ordered_local_config_directories( + user_config_dir: &Path, + legacy_user_config_dir: Option<&Path>, + explicit_config_dir: Option<&Path>, + project_directories: &[PathBuf], +) -> Vec { + let mut directories = Vec::new(); + let mut indices = BTreeMap::new(); + push_config_directory( + &mut directories, + &mut indices, + user_config_dir, + LocalConfigDirectoryKind::User, + ExternalSourceScope::UserGlobal, + ); + for directory in project_directories { + push_config_directory( + &mut directories, + &mut indices, + &directory.join(".opencode"), + LocalConfigDirectoryKind::Project, + ExternalSourceScope::Project, + ); + } + if let Some(directory) = legacy_user_config_dir { + push_config_directory( + &mut directories, + &mut indices, + directory, + LocalConfigDirectoryKind::Legacy, + ExternalSourceScope::UserGlobal, + ); + } + if let Some(directory) = explicit_config_dir { + let identity = path_identity(directory); + if let Some(index) = indices.get(&identity).copied() { + directories[index].kind = LocalConfigDirectoryKind::Explicit; + directories[index].scope = ExternalSourceScope::WorkspaceLocal; + } else { + push_config_directory( + &mut directories, + &mut indices, + directory, + LocalConfigDirectoryKind::Explicit, + ExternalSourceScope::WorkspaceLocal, + ); + } + } + directories +} + +pub(crate) fn local_watch_roots( + user_config_dir: &Path, + legacy_user_config_dir: Option<&Path>, + explicit_config_file: Option<&Path>, + explicit_config_dir: Option<&Path>, + project_directories: &[PathBuf], +) -> Vec { + let mut roots = BTreeMap::new(); + add_directory_watch_roots(&mut roots, user_config_dir); + if let Some(directory) = legacy_user_config_dir { + add_directory_watch_roots(&mut roots, directory); + } + if let Some(file) = explicit_config_file { + if let Some(parent) = file.parent() { + add_nearest_existing_watch_root(&mut roots, parent); + } + } + if let Some(directory) = explicit_config_dir { + add_directory_watch_roots(&mut roots, directory); + } + for directory in project_directories { + add_watch_root(&mut roots, directory.clone(), false); + add_directory_watch_roots(&mut roots, &directory.join(".opencode")); + } + roots + .into_iter() + .map(|(path, recursive)| ExternalWatchRoot { path, recursive }) + .collect() +} + +fn push_config_directory( + directories: &mut Vec, + indices: &mut BTreeMap, + path: &Path, + kind: LocalConfigDirectoryKind, + scope: ExternalSourceScope, +) { + let identity = path_identity(path); + if indices.contains_key(&identity) { + return; + } + indices.insert(identity, directories.len()); + directories.push(LocalConfigDirectory { + path: path.to_path_buf(), + kind, + scope, + }); +} + +fn path_identity(path: &Path) -> PathBuf { + dunce::canonicalize(path).unwrap_or_else(|_| normalize_path_lexically(path)) +} + +fn normalize_path_lexically(path: &Path) -> PathBuf { + use std::path::Component; + + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + component => normalized.push(component.as_os_str()), + } + } + normalized +} + +fn opened_directory(path: &Path) -> &Path { + if path.is_file() { + path.parent().unwrap_or(path) + } else { + path + } +} + +fn nearest_existing_path(mut path: PathBuf) -> Option { + loop { + if path.exists() { + return Some(path); + } + if !path.pop() { + return None; + } + } +} + +fn add_watch_root(roots: &mut BTreeMap, path: PathBuf, recursive: bool) { + roots + .entry(path) + .and_modify(|existing| *existing |= recursive) + .or_insert(recursive); +} + +fn add_nearest_existing_watch_root(roots: &mut BTreeMap, path: &Path) { + if let Some(path) = nearest_existing_path(path.to_path_buf()) { + add_watch_root(roots, path, false); + } +} + +fn add_directory_watch_roots(roots: &mut BTreeMap, directory: &Path) { + if let Some(parent) = directory.parent() { + add_nearest_existing_watch_root(roots, parent); + } + // Preserve the desired recursive root even before it exists. The shared + // watcher can promote it after the nearest existing parent reports creation. + add_watch_root(roots, directory.to_path_buf(), true); +} + +#[cfg(test)] +mod tests { + use super::{ + ordered_local_config_directories, project_asset_directories, project_config_directories, + user_config_dir, LocalConfigDirectory, LocalConfigDirectoryKind, + }; + use bitfun_product_domains::external_sources::ExternalSourceScope; + use std::path::{Path, PathBuf}; + + #[test] + fn default_config_root_uses_xdg_semantics_on_every_platform() { + assert_eq!( + user_config_dir(None, Some(PathBuf::from("home"))), + PathBuf::from("home/.config/opencode") + ); + assert_eq!( + user_config_dir( + Some(PathBuf::from("custom-config")), + Some(PathBuf::from("home")) + ), + PathBuf::from("custom-config/opencode") + ); + } + + #[test] + fn project_config_and_asset_phases_use_distinct_upstream_orders() { + let root = Path::new("workspace"); + let opened = root.join("packages/app"); + + assert_eq!( + project_config_directories(root, &opened), + vec![ + PathBuf::from("workspace"), + PathBuf::from("workspace/packages"), + PathBuf::from("workspace/packages/app"), + ] + ); + assert_eq!( + project_asset_directories(root, &opened), + vec![ + PathBuf::from("workspace/packages/app"), + PathBuf::from("workspace/packages"), + PathBuf::from("workspace"), + ] + ); + } + + #[test] + fn config_directory_aliases_keep_the_first_position_and_explicit_scope() { + let user = Path::new("user/opencode"); + let project_directories = vec![ + PathBuf::from("workspace/packages/app"), + PathBuf::from("workspace"), + ]; + + assert_eq!( + ordered_local_config_directories( + user, + None, + Some(user), + project_directories.as_slice() + ), + vec![ + LocalConfigDirectory { + path: user.to_path_buf(), + kind: LocalConfigDirectoryKind::Explicit, + scope: ExternalSourceScope::WorkspaceLocal, + }, + LocalConfigDirectory { + path: PathBuf::from("workspace/packages/app/.opencode"), + kind: LocalConfigDirectoryKind::Project, + scope: ExternalSourceScope::Project, + }, + LocalConfigDirectory { + path: PathBuf::from("workspace/.opencode"), + kind: LocalConfigDirectoryKind::Project, + scope: ExternalSourceScope::Project, + }, + ] + ); + + assert_eq!( + ordered_local_config_directories( + user, + None, + Some(Path::new("workspace/packages/app/.opencode")), + project_directories.as_slice(), + ), + vec![ + LocalConfigDirectory { + path: user.to_path_buf(), + kind: LocalConfigDirectoryKind::User, + scope: ExternalSourceScope::UserGlobal, + }, + LocalConfigDirectory { + path: PathBuf::from("workspace/packages/app/.opencode"), + kind: LocalConfigDirectoryKind::Explicit, + scope: ExternalSourceScope::WorkspaceLocal, + }, + LocalConfigDirectory { + path: PathBuf::from("workspace/.opencode"), + kind: LocalConfigDirectoryKind::Project, + scope: ExternalSourceScope::Project, + }, + ] + ); + } +} diff --git a/src/crates/adapters/opencode-adapter/src/mcp_source.rs b/src/crates/adapters/opencode-adapter/src/mcp_source.rs index 00706342d1..2818dc1400 100644 --- a/src/crates/adapters/opencode-adapter/src/mcp_source.rs +++ b/src/crates/adapters/opencode-adapter/src/mcp_source.rs @@ -1,3 +1,8 @@ +use crate::local_source_paths::{ + find_project_root, local_watch_roots, ordered_local_config_directories, + project_asset_directories, project_config_directories, user_config_dir, + LocalConfigDirectoryKind, +}; use bitfun_product_domains::external_sources::{ EcosystemId, ExternalMcpDiscoveryInput, ExternalMcpProviderIdentity, ExternalMcpProviderSnapshot, ExternalMcpServerDefinition, ExternalMcpSourceProvider, @@ -46,19 +51,13 @@ impl OpenCodeMcpProviderOptions { pub fn from_environment() -> Self { let home = dirs::home_dir(); let explicit_config_dir = std::env::var_os("OPENCODE_CONFIG_DIR").map(PathBuf::from); - let user_config_dir = explicit_config_dir.clone().unwrap_or_else(|| { - std::env::var_os("XDG_CONFIG_HOME") - .map(PathBuf::from) - .or_else(|| home.as_ref().map(|home| home.join(".config"))) - .unwrap_or_else(|| PathBuf::from(".config")) - .join("opencode") - }); + let user_config_dir = user_config_dir( + std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from), + home.clone(), + ); Self { user_config_dir, - legacy_user_config_dir: explicit_config_dir - .is_none() - .then(|| home.map(|home| home.join(".opencode"))) - .flatten(), + legacy_user_config_dir: home.map(|home| home.join(".opencode")), explicit_config_file: std::env::var_os("OPENCODE_CONFIG").map(PathBuf::from), explicit_config_dir, project_config_enabled: !environment_truthy("OPENCODE_DISABLE_PROJECT_CONFIG"), @@ -114,40 +113,57 @@ impl OpenCodeMcpProvider { if self.options.project_config_enabled { if let Some(workspace_root) = &context.workspace_root { let project_root = self.project_root(workspace_root); - for directory in directories_between(&project_root, workspace_root) { + for directory in project_config_directories(&project_root, workspace_root) { push_config_files( &mut layers, &directory, ExternalSourceScope::Project, "OpenCode project configuration", ); + } + } + } + let project_directories = if self.options.project_config_enabled { + context + .workspace_root + .as_ref() + .map(|workspace_root| { + project_asset_directories(&self.project_root(workspace_root), workspace_root) + }) + .unwrap_or_default() + } else { + Vec::new() + }; + for directory in ordered_local_config_directories( + &self.options.user_config_dir, + self.options.legacy_user_config_dir.as_deref(), + self.options.explicit_config_dir.as_deref(), + &project_directories, + ) { + match directory.kind { + LocalConfigDirectoryKind::User => {} + LocalConfigDirectoryKind::Project => { push_config_files( &mut layers, - &directory.join(".opencode"), - ExternalSourceScope::Project, + &directory.path, + directory.scope, "OpenCode project configuration", ); } - } - } - if let Some(legacy) = &self.options.legacy_user_config_dir { - if legacy != &self.options.user_config_dir { - push_config_files( + LocalConfigDirectoryKind::Legacy => push_config_files( &mut layers, - legacy, - ExternalSourceScope::UserGlobal, + &directory.path, + directory.scope, "OpenCode legacy configuration", - ); + ), + LocalConfigDirectoryKind::Explicit => push_config_files( + &mut layers, + &directory.path, + directory.scope, + "OpenCode OPENCODE_CONFIG_DIR", + ), } } - if let Some(directory) = &self.options.explicit_config_dir { - push_config_files( - &mut layers, - directory, - ExternalSourceScope::UserGlobal, - "OpenCode OPENCODE_CONFIG_DIR", - ); - } deduplicate_layers_keep_last(layers) } @@ -381,32 +397,24 @@ impl ExternalMcpSourceProvider for OpenCodeMcpProvider { } fn watch_roots(&self, context: &ExternalSourceContext) -> Vec { - let mut roots = BTreeMap::new(); - add_directory_watch_roots(&mut roots, &self.options.user_config_dir); - if let Some(legacy) = &self.options.legacy_user_config_dir { - add_directory_watch_roots(&mut roots, legacy); - } - if let Some(path) = &self.options.explicit_config_file { - if let Some(parent) = path.parent() { - add_nearest_existing_watch_root(&mut roots, parent); - } - } - if let Some(directory) = &self.options.explicit_config_dir { - add_directory_watch_roots(&mut roots, directory); - } - if self.options.project_config_enabled { - if let Some(workspace_root) = &context.workspace_root { - let project_root = self.project_root(workspace_root); - for directory in directories_between(&project_root, workspace_root) { - add_watch_root(&mut roots, directory.clone(), false); - add_directory_watch_roots(&mut roots, &directory.join(".opencode")); - } - } - } - roots - .into_iter() - .map(|(path, recursive)| ExternalWatchRoot { path, recursive }) - .collect() + let project_directories = if self.options.project_config_enabled { + context + .workspace_root + .as_ref() + .map(|workspace_root| { + project_config_directories(&self.project_root(workspace_root), workspace_root) + }) + .unwrap_or_default() + } else { + Vec::new() + }; + local_watch_roots( + &self.options.user_config_dir, + self.options.legacy_user_config_dir.as_deref(), + self.options.explicit_config_file.as_deref(), + self.options.explicit_config_dir.as_deref(), + &project_directories, + ) } } @@ -1307,34 +1315,6 @@ fn environment_truthy(key: &str) -> bool { .is_some_and(|value| matches!(value.to_ascii_lowercase().as_str(), "true" | "1")) } -fn find_project_root(start: &Path) -> PathBuf { - let start = if start.is_file() { - start.parent().unwrap_or(start) - } else { - start - }; - start - .ancestors() - .find(|path| path.join(".git").exists()) - .unwrap_or(start) - .to_path_buf() -} - -fn directories_between(root: &Path, opened: &Path) -> Vec { - let opened = if opened.is_file() { - opened.parent().unwrap_or(opened) - } else { - opened - }; - let mut directories = opened - .ancestors() - .take_while(|path| path.starts_with(root)) - .map(Path::to_path_buf) - .collect::>(); - directories.reverse(); - directories -} - fn normalize_path_lexically(path: &Path) -> PathBuf { use std::path::Component; let mut normalized = PathBuf::new(); @@ -1349,34 +1329,3 @@ fn normalize_path_lexically(path: &Path) -> PathBuf { } normalized } - -fn nearest_existing_path(mut path: PathBuf) -> Option { - loop { - if path.exists() { - return Some(path); - } - if !path.pop() { - return None; - } - } -} - -fn add_watch_root(roots: &mut BTreeMap, path: PathBuf, recursive: bool) { - roots - .entry(path) - .and_modify(|existing| *existing |= recursive) - .or_insert(recursive); -} - -fn add_nearest_existing_watch_root(roots: &mut BTreeMap, path: &Path) { - if let Some(path) = nearest_existing_path(path.to_path_buf()) { - add_watch_root(roots, path, false); - } -} - -fn add_directory_watch_roots(roots: &mut BTreeMap, directory: &Path) { - if let Some(parent) = directory.parent() { - add_nearest_existing_watch_root(roots, parent); - } - add_watch_root(roots, directory.to_path_buf(), true); -} diff --git a/src/crates/adapters/opencode-adapter/tests/opencode_mcp_adapter.rs b/src/crates/adapters/opencode-adapter/tests/opencode_mcp_adapter.rs index 6bfa23fabd..34275492d8 100644 --- a/src/crates/adapters/opencode-adapter/tests/opencode_mcp_adapter.rs +++ b/src/crates/adapters/opencode-adapter/tests/opencode_mcp_adapter.rs @@ -8,6 +8,7 @@ use bitfun_product_domains::external_sources::{ use std::collections::BTreeSet; use std::fs; use std::path::PathBuf; +use std::process::Command; use tempfile::TempDir; fn context(workspace_root: PathBuf) -> ExternalSourceContext { @@ -32,6 +33,53 @@ fn options(user_config_dir: PathBuf) -> OpenCodeMcpProviderOptions { } } +#[test] +fn opencode_config_dir_keeps_xdg_user_config_when_read_from_environment() { + const CHILD_MARKER: &str = "BITFUN_OPENCODE_MCP_ENV_CHILD"; + const CHILD_XDG: &str = "BITFUN_OPENCODE_MCP_ENV_XDG"; + const CHILD_EXPLICIT: &str = "BITFUN_OPENCODE_MCP_ENV_EXPLICIT"; + + if std::env::var_os(CHILD_MARKER).is_some() { + let xdg = PathBuf::from(std::env::var_os(CHILD_XDG).expect("child XDG path")); + let explicit = + PathBuf::from(std::env::var_os(CHILD_EXPLICIT).expect("child explicit path")); + let provider_options = OpenCodeMcpProviderOptions::from_environment(); + assert_eq!(provider_options.user_config_dir, xdg.join("opencode")); + assert_eq!( + provider_options.explicit_config_dir.as_deref(), + Some(explicit.as_path()) + ); + assert!( + provider_options.legacy_user_config_dir.is_some(), + "OPENCODE_CONFIG_DIR must not remove the normal compatibility directory" + ); + return; + } + + let temp = TempDir::new().unwrap(); + let xdg = temp.path().join("xdg"); + let explicit = temp.path().join("explicit"); + let output = Command::new(std::env::current_exe().expect("current test executable")) + .arg("--exact") + .arg("opencode_config_dir_keeps_xdg_user_config_when_read_from_environment") + .arg("--nocapture") + .env(CHILD_MARKER, "1") + .env(CHILD_XDG, &xdg) + .env(CHILD_EXPLICIT, &explicit) + .env("XDG_CONFIG_HOME", &xdg) + .env("OPENCODE_CONFIG_DIR", &explicit) + .env_remove("OPENCODE_CONFIG") + .env_remove("OPENCODE_DISABLE_PROJECT_CONFIG") + .output() + .expect("run isolated environment child"); + + assert!( + output.status.success(), + "isolated environment assertion failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); +} + #[test] fn discovery_deep_merges_layers_without_exposing_or_executing_runtime_values() { let temp = TempDir::new().unwrap(); @@ -443,7 +491,7 @@ fn unsupported_or_source_disabled_servers_remain_visible_but_cannot_be_prepared( } #[test] -fn opencode_config_dir_is_a_global_late_override_like_the_source_application() { +fn opencode_config_dir_is_a_workspace_local_late_override_and_keeps_global_sources() { let temp = TempDir::new().unwrap(); let user = temp.path().join("user"); let project = temp.path().join("project"); @@ -466,7 +514,7 @@ fn opencode_config_dir_is_a_global_late_override_like_the_source_application() { r#"{"mcp":{"github":{"url":"https://explicit.example.test/mcp"}}}"#, ) .unwrap(); - let mut provider_options = options(user); + let mut provider_options = options(user.clone()); provider_options.explicit_config_dir = Some(explicit.clone()); let provider = OpenCodeMcpProvider::new(provider_options); let snapshot = provider @@ -490,5 +538,9 @@ fn opencode_config_dir_is_a_global_late_override_like_the_source_application() { .iter() .find(|source| source.location == explicit.join("opencode.jsonc").to_string_lossy()) .unwrap(); - assert_eq!(explicit_source.scope, ExternalSourceScope::UserGlobal); + assert_eq!(explicit_source.scope, ExternalSourceScope::WorkspaceLocal); + assert!(snapshot.sources.iter().any(|source| { + source.location == user.join("opencode.json").to_string_lossy() + && source.scope == ExternalSourceScope::UserGlobal + })); } diff --git a/src/crates/assembly/core/src/external_sources.rs b/src/crates/assembly/core/src/external_sources.rs index 6dfab5fd4b..8f1a0c549e 100644 --- a/src/crates/assembly/core/src/external_sources.rs +++ b/src/crates/assembly/core/src/external_sources.rs @@ -64,6 +64,10 @@ use bitfun_external_sources::{ use bitfun_opencode_adapter::{ OpenCodeCommandProvider, OpenCodeMcpProvider, OpenCodeSubagentProvider, OpenCodeToolProvider, }; +#[cfg(test)] +use bitfun_opencode_adapter::{ + OpenCodeCommandProviderOptions, OpenCodeMcpProviderOptions, OpenCodeSubagentProviderOptions, +}; use bitfun_product_domains::external_integration_policy::{ external_integration_policy_snapshot, incompatible_external_integration_policy_snapshot, ExternalIntegrationCapabilityDescriptor, ExternalIntegrationEcosystemDescriptor, @@ -5723,6 +5727,9 @@ impl ExternalSourceSubscription { } } +#[cfg(test)] +mod opencode_local_source_order_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/src/crates/assembly/core/src/external_sources/opencode_local_source_order_tests.rs b/src/crates/assembly/core/src/external_sources/opencode_local_source_order_tests.rs new file mode 100644 index 0000000000..05ff17b56b --- /dev/null +++ b/src/crates/assembly/core/src/external_sources/opencode_local_source_order_tests.rs @@ -0,0 +1,296 @@ +use super::*; +use std::fs; +use std::path::{Path, PathBuf}; +use tempfile::TempDir; + +struct Fixture { + _temp: TempDir, + user_config: PathBuf, + project: PathBuf, + opened_directory: PathBuf, +} + +impl Fixture { + fn new() -> Self { + let temp = TempDir::new().expect("tempdir"); + let user_config = temp.path().join("user/opencode"); + let project = temp.path().join("workspace"); + let opened_directory = project.join("packages/app"); + fs::create_dir_all(project.join(".git")).expect("git boundary"); + fs::create_dir_all(&opened_directory).expect("opened directory"); + Self { + _temp: temp, + user_config, + project, + opened_directory, + } + } + + fn context(&self) -> ExternalSourceContext { + ExternalSourceContext { + workspace_root: Some(self.opened_directory.clone()), + execution_domain_id: ExecutionDomainId::new("local-user").unwrap(), + } + } + + fn command_provider(&self, explicit_config_dir: Option) -> OpenCodeCommandProvider { + OpenCodeCommandProvider::new(OpenCodeCommandProviderOptions { + user_config_dir: self.user_config.clone(), + legacy_user_config_dir: None, + explicit_config_file: None, + explicit_config_dir, + project_config_enabled: true, + }) + } + + fn subagent_provider(&self, explicit_config_dir: Option) -> OpenCodeSubagentProvider { + OpenCodeSubagentProvider::new(OpenCodeSubagentProviderOptions { + user_config_dir: self.user_config.clone(), + legacy_user_config_dir: None, + explicit_config_file: None, + explicit_config_dir, + project_config_enabled: true, + project_root_override: Some(self.project.clone()), + }) + } + + fn mcp_provider(&self, explicit_config_dir: Option) -> OpenCodeMcpProvider { + OpenCodeMcpProvider::new(OpenCodeMcpProviderOptions { + user_config_dir: self.user_config.clone(), + legacy_user_config_dir: None, + explicit_config_file: None, + explicit_config_dir, + project_config_enabled: true, + project_root_override: Some(self.project.clone()), + }) + } + + fn write_layer(&self, directory: &Path, value: &str) -> PathBuf { + self.write_config(&directory.join(".opencode"), value) + } + + fn write_config(&self, directory: &Path, value: &str) -> PathBuf { + let path = directory.join("opencode.json"); + write( + &path, + &format!( + r#"{{ + "command": {{ + "review": {{ + "description": "{value} command", + "template": "{value} $ARGUMENTS" + }} + }}, + "agent": {{ + "review": {{ + "description": "{value} agent", + "prompt": "{value} agent prompt", + "mode": "subagent" + }} + }}, + "mcp": {{ + "docs": {{ + "type": "remote", + "url": "https://{value}.example.test/mcp" + }} + }} + }}"# + ), + ); + path + } + + fn control_plane(&self) -> ExternalSourceControlPlane { + self.control_plane_with_explicit_dir(None) + } + + fn control_plane_with_explicit_dir( + &self, + explicit_config_dir: Option, + ) -> ExternalSourceControlPlane { + ExternalSourceControlPlane::new( + self.context(), + ExternalMcpRevisionKey::new([17; 32]), + vec![Arc::new(self.command_provider(explicit_config_dir.clone()))], + Vec::new(), + vec![Arc::new( + self.subagent_provider(explicit_config_dir.clone()), + )], + vec![Arc::new(self.mcp_provider(explicit_config_dir))], + ) + .expect("OpenCode control plane") + } +} + +fn write(path: &Path, contents: &str) { + fs::create_dir_all(path.parent().unwrap()).expect("source parent"); + fs::write(path, contents).expect("source file"); +} + +fn refresh_all(plane: &ExternalSourceControlPlane) { + plane.commands_mut(|coordinator| { + coordinator.refresh(); + }); + plane.subagents_mut(|coordinator| { + coordinator.refresh(); + }); + plane.mcp_mut(|coordinator| { + coordinator.refresh(); + }); +} + +fn assert_winners(plane: &ExternalSourceControlPlane, expected: &str) { + let expanded = plane + .commands(|coordinator| coordinator.expand_command("review", "src/lib.rs")) + .expect("expanded slash command"); + assert_eq!(expanded.content, format!("{expected} src/lib.rs")); + + plane.subagents(|coordinator| { + let snapshot = coordinator.snapshot(); + let review = snapshot + .definitions + .iter() + .find(|definition| definition.logical_id == "review") + .expect("external review agent"); + assert_eq!( + review.prompt.expose(), + format!("{expected} agent prompt").as_str() + ); + }); + + plane.mcp(|coordinator| { + let snapshot = coordinator.snapshot(); + let docs = snapshot + .servers + .iter() + .find(|server| server.name == "docs") + .expect("external docs MCP server"); + assert_eq!( + docs.remote_url_preview.as_deref(), + Some(format!("https://{expected}.example.test/").as_str()) + ); + }); +} + +fn source_stable_key_for_location( + sources: &[ExternalSourceCatalogEntry], + location: &Path, +) -> String { + sources + .iter() + .find(|source| Path::new(&source.record.location) == location) + .map(|source| source.stable_key.clone()) + .unwrap_or_else(|| panic!("source not found for {}", location.display())) +} + +#[test] +fn nested_local_sources_flow_through_one_control_plane_without_stale_winners() { + let fixture = Fixture::new(); + let outer_path = fixture.write_layer(&fixture.project, "outer"); + let inner_path = fixture.write_layer(&fixture.opened_directory, "inner"); + let plane = fixture.control_plane(); + + refresh_all(&plane); + assert_winners(&plane, "outer"); + for (capability, sources) in [ + ( + "command", + plane.commands(|coordinator| coordinator.snapshot().sources), + ), + ( + "subagent", + plane.subagents(|coordinator| coordinator.snapshot().sources), + ), + ( + "mcp", + plane.mcp(|coordinator| coordinator.snapshot().sources), + ), + ] { + let outer = sources + .iter() + .find(|source| Path::new(&source.record.location) == outer_path) + .unwrap_or_else(|| panic!("{capability} outer source missing from {sources:?}")); + assert_eq!(outer.record.scope, ExternalSourceScope::Project); + assert!(outer.record.display_name.starts_with("OpenCode project")); + } + + fixture.write_layer(&fixture.project, "outer-v2"); + refresh_all(&plane); + assert_winners(&plane, "outer-v2"); + + fs::remove_file(&outer_path).expect("remove outer source"); + refresh_all(&plane); + assert_winners(&plane, "inner"); + + let suppressed = [ + plane.commands(|coordinator| { + source_stable_key_for_location(&coordinator.snapshot().sources, &inner_path) + }), + plane.subagents(|coordinator| { + source_stable_key_for_location(&coordinator.snapshot().sources, &inner_path) + }), + plane.mcp(|coordinator| { + source_stable_key_for_location(&coordinator.snapshot().sources, &inner_path) + }), + ] + .into_iter() + .collect::>(); + plane.replace_suppressed_sources(suppressed); + refresh_all(&plane); + + assert!(plane.commands(|coordinator| coordinator.snapshot().commands.is_empty())); + assert!(plane.subagents(|coordinator| coordinator.snapshot().definitions.is_empty())); + assert!(plane.mcp(|coordinator| coordinator.snapshot().servers.is_empty())); +} + +#[test] +fn command_subagent_and_mcp_share_creation_safe_watch_roots() { + let fixture = Fixture::new(); + let context = fixture.context(); + let as_map = |roots: Vec| { + roots + .into_iter() + .map(|root| (root.path, root.recursive)) + .collect::>() + }; + + let command_roots = as_map(fixture.command_provider(None).watch_roots(&context)); + let subagent_roots = as_map(fixture.subagent_provider(None).watch_roots(&context)); + let mcp_roots = as_map(fixture.mcp_provider(None).watch_roots(&context)); + + assert_eq!(subagent_roots, command_roots); + assert_eq!(mcp_roots, command_roots); + assert_eq!( + command_roots.get(&fixture.user_config), + Some(&true), + "the desired global root remains watchable before it exists" + ); + assert_eq!( + command_roots.get(&fixture.project.join(".opencode")), + Some(&true), + "the desired project root remains watchable before it exists" + ); + assert_eq!( + command_roots.get(&fixture.opened_directory.join(".opencode")), + Some(&true), + "the desired nested root remains watchable before it exists" + ); +} + +#[test] +fn config_directory_aliases_keep_their_first_upstream_position() { + let xdg_alias = Fixture::new(); + xdg_alias.write_config(&xdg_alias.user_config, "user"); + xdg_alias.write_layer(&xdg_alias.project, "outer"); + let plane = xdg_alias.control_plane_with_explicit_dir(Some(xdg_alias.user_config.clone())); + refresh_all(&plane); + assert_winners(&plane, "outer"); + + let nested_alias = Fixture::new(); + nested_alias.write_layer(&nested_alias.project, "outer"); + nested_alias.write_layer(&nested_alias.opened_directory, "inner"); + let plane = nested_alias + .control_plane_with_explicit_dir(Some(nested_alias.opened_directory.join(".opencode"))); + refresh_all(&plane); + assert_winners(&plane, "outer"); +}