diff --git a/README.md b/README.md index 7c1c0ee..dd29794 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Packages are organized by author under `plugins///`. | --- | --- | --- | | [Token Usage Board](plugins/amszuidas/mcode-token-usage-board/) | Explore local Token usage by date, model, and session, including input, output, and cache usage | [amszuidas](https://github.com/amszuidas) | | [Token Usage Board](plugins/yanhy2000/mcode-usage-monitor/) | Watch local Token usage, output speed, and cache hit rate in near real time; filter by time range, model, and session | [yanhy2000](https://github.com/yanhy2000) | +| [Model Manager](plugins/ocoomber/openrouter-model-manager/) | Browse, search, and enable/disable models in your `~/.minimax/config.yaml` with instant save, bulk actions, one-click undo, and automatic backups | [ocoomber](https://github.com/ocoomber) | | [Self-drive Route Planner](plugins/hanzijie/self-drive-route-planner/) | 【Offical Plugin】Plan driving routes with place search, route alternatives, demo mode, and Xiaohongshu 3:4 itinerary cards | [HanZijie](https://github.com/HanZijie) |
diff --git a/README.zh-CN.md b/README.zh-CN.md index ed605eb..7eaa2e0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -18,6 +18,7 @@ | --- | --- | --- | | [Token 用量看板](plugins/amszuidas/mcode-token-usage-board/README.zh-CN.md) | 按时间、模型和会话查看本机 Token 用量,包含输入、输出和缓存用量 | [amszuidas](https://github.com/amszuidas) | | [Token 用量看板](plugins/yanhy2000/mcode-usage-monitor/README.zh-CN.md) | 近实时查看本机 Token 用量、输出速度与缓存命中率,可按时间范围、模型和会话筛选 | [yanhy2000](https://github.com/yanhy2000) | +| [模型管理器](plugins/ocoomber/openrouter-model-manager/README.zh-CN.md) | 浏览、搜索并启用/停用 `~/.minimax/config.yaml` 中的模型,支持即时保存、批量操作、一键撤销和自动备份 | [ocoomber](https://github.com/ocoomber) | | [自驾规划](plugins/hanzijie/self-drive-route-planner/README.zh-CN.md) | 【官方插件】规划自驾路线、地点搜索、候选算路与小红书 3:4 行程图;支持演示模式 | [HanZijie](https://github.com/HanZijie) |
diff --git a/plugins/ocoomber/openrouter-model-manager/.minimax-plugin/plugin.json b/plugins/ocoomber/openrouter-model-manager/.minimax-plugin/plugin.json new file mode 100644 index 0000000..7182cbc --- /dev/null +++ b/plugins/ocoomber/openrouter-model-manager/.minimax-plugin/plugin.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "name": "openrouter-model-manager", + "displayName": "Model Manager", + "version": "1.2.4", + "description": "Browse, search, and enable/disable the models in your MiniMax Code config.yaml — works with any provider (OpenRouter, custom, locally hosted), with bulk actions, one-level undo, and OpenRouter links.", + "author": "ocoomber", + "icon": "icon.png", + "category": "Other", + "exampleQueries": ["Open Model Manager", "打开模型管理器"], + "apps": [], + "mcpServers": [], + "skills": [] +} diff --git a/plugins/ocoomber/openrouter-model-manager/LICENSE b/plugins/ocoomber/openrouter-model-manager/LICENSE new file mode 100644 index 0000000..c9f678b --- /dev/null +++ b/plugins/ocoomber/openrouter-model-manager/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 ocoomber + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/ocoomber/openrouter-model-manager/README.md b/plugins/ocoomber/openrouter-model-manager/README.md new file mode 100644 index 0000000..4c26fbf --- /dev/null +++ b/plugins/ocoomber/openrouter-model-manager/README.md @@ -0,0 +1,74 @@ +# Model Manager (`openrouter-model-manager`) + +English | [简体中文](README.zh-CN.md) + +A [MiniMax Code](https://github.com/MiniMax-AI) Mini App for browsing, searching, and enabling/disabling the models in your MiniMax Code `config.yaml` — no more find-and-replace in Notepad. + +Author: [ocoomber](https://github.com/ocoomber) · Version: `1.2.4` + +> The plugin ID `openrouter-model-manager` is kept for stability, but the app is **not** OpenRouter-specific — it works with any provider (see *What it does*). + +## What it does + +- **Works with any provider** — OpenRouter, custom providers, and locally hosted endpoints (Ollama, LM Studio). Every model block in your config is picked up automatically — all models appear in one list grouped by family, with no provider switching. +- **Restart reminder** — a banner appears as soon as you change anything, reminding you to restart MiniMax Code (mini apps can't restart the host app for you). +- **Search** across model IDs and display names. +- **Instant save** — every toggle writes to your config immediately; no save button. +- **Filter chips** — All / Enabled only / Disabled only. +- **Bulk actions** — *Enable matching* / *Disable matching* apply only to the current search results, and each model family has its own enable/disable buttons. +- **One-level Undo** — made a mistake with "enable all"? One click restores the previous config. If the file changed outside the app in the meantime, Undo refuses instead of clobbering your edits. A multi-provider bulk action is reverted as one operation — not provider-by-provider. +- **Automatic backups** — before every bulk change, a timestamped copy of your config is written to `backups/` under the Mini App's own data directory (per Mini App runtime guidance), pruned to the newest 20. +- **Collapsible families** — models are grouped by the prefix before the `/` in their ID. +- **OpenRouter links** — every model row can link to its OpenRouter page (shown only for OpenRouter providers). Right-click a link to choose the external browser, the built-in browser, or copy the URL. +- **Context-limit badges** — read straight from your config. + +## Tested environment + +- **Windows 11** (build 10.0.26200), **MiniMax Code 3.0.73**, plugin `1.2.4` — tested by the author end to end (toggles, bulk actions, undo, restart flow). +- **macOS / Linux** use the same code paths but have **not been tested** by the author — feedback and reports are very welcome. + +## Install + +Copy (or clone) this folder into your MiniMax Code plugins directory: + +``` +~/.minimax/plugins/openrouter-model-manager/ +``` + +Then restart MiniMax Code and open the **Model Manager** mini app. + +> Note: after toggling models, restart MiniMax Code itself for the change to take effect — the config is read at startup. + +## How it works + +The Node runtime reads your `config.yaml` line by line (no YAML library) and finds every `models:` block that has `enabled:` flags. Toggling a model rewrites only that model's `enabled:` line. All writes are atomic (a temp file `.config.yaml.mm-tmp` is written next to your config, then renamed; it is removed if anything fails), your file's existing indentation and line endings are preserved, and concurrent edits are serialized so overlapping clicks can't clobber each other. + +Capabilities such as vision support are intentionally **not** fetched from external APIs — your config file is the single source of truth. + +## Data & access + +- The UI never sees your secrets: the server returns only model **id / name / enabled / contextLimit**. API keys in the config are never read into the UI, returned by the API, or displayed. +- The app makes **no outbound network requests** of its own. +- **Process spawning (disclosed):** the only OS-level action is opening an OpenRouter model page in *your own* browser, via `rundll32`/`cmd`/`explorer` on Windows, `open` on macOS, or `xdg-open` on Linux. Only `https://openrouter.ai/...` URLs are accepted; anything else is rejected by the server. +- Config location: the runtime walks ancestors of its injected data directory looking for `config.yaml` (the Host hands each Mini App a plugin-owned subdirectory several levels below the data root, and the MiniMax Code config lives at the data root or one of its ancestors in the current Host layout). If nothing is found along that walk, it falls back to `~/.minimax/config.yaml`. The parent walk matches an implementation detail of the current Host layout, not a guaranteed API, so the default fallback is what protects non-default installs. + +## Files + +``` +.minimax-plugin/plugin.json Plugin manifest +package.json Mini app bootstrap +miniapp/miniapp.json Mini app surface/runtime config +miniapp/client/index.html UI (light/dark aware) +miniapp/node/server.mjs Node runtime + REST API +miniapp/node/miniapp-api.ts Type declarations for the runtime API +icon.png Plugin icon +tests/api-set.test.mjs +tests/parser.test.mjs +tests/resolveConfigPath.test.mjs +``` + +`tests/` lives outside the Host's runtime payload roots (`miniapp/client`, `miniapp/node`), so the app never loads it — it just rides along if you copy the directory. Run with `node --test` from the plugin root. + +## License + +[MIT](./LICENSE) diff --git a/plugins/ocoomber/openrouter-model-manager/README.zh-CN.md b/plugins/ocoomber/openrouter-model-manager/README.zh-CN.md new file mode 100644 index 0000000..6f76a02 --- /dev/null +++ b/plugins/ocoomber/openrouter-model-manager/README.zh-CN.md @@ -0,0 +1,74 @@ +# 模型管理器(`openrouter-model-manager`) + +[English](README.md) | 简体中文 + +一个 [MiniMax Code](https://github.com/MiniMax-AI) Mini App,用于浏览、搜索并启用/停用 MiniMax Code `config.yaml` 中的模型 —— 不用再在记事本里查找替换了。 + +作者:[ocoomber](https://github.com/ocoomber) · 版本:`1.2.4` + +> 插件 ID `openrouter-model-manager` 为保持稳定而保留,但本应用**并非** OpenRouter 专用 —— 支持任意提供商(见"功能")。 + +## 功能 + +- **支持任意提供商** —— OpenRouter、自定义提供商,以及本地服务(Ollama、LM Studio)。自动读取配置中的所有模型块 —— 全部模型合并为一个列表,按模型家族分组,无需任何切换操作。 +- **重启提醒** —— 一旦有任何改动,页面会出现醒目的横幅,提醒你需要重启 MiniMax Code(Mini App 无法代替你重启宿主程序)。 +- **搜索** —— 同时匹配模型 ID 和显示名称。 +- **即时保存** —— 每次切换立即写入配置,没有保存按钮。 +- **筛选** —— 全部 / 仅启用 / 仅停用。 +- **批量操作** —— "启用匹配项 / 停用匹配项"只作用于当前搜索结果;每个模型家族也有自己的启用/停用按钮。 +- **一步撤销** —— 批量开启后后悔了?点一下即可恢复上一个配置。若期间配置在应用之外被修改过,撤销会拒绝执行,而不会覆盖你的改动。跨提供商的批量操作会作为一个整体被撤销,不会出现"只回滚最后一个提供商"的情况。 +- **自动备份** —— 每次批量改动前,都会在 Mini App 自己的数据目录下的 `backups/` 里保存一份带时间戳的副本(遵循 Mini App 运行时"将持久状态存放到注入的 dataDir"的原则),最多保留最新 20 份。 +- **可折叠的模型家族** —— 模型按 ID 中 `/` 之前的前缀分组;家族内有已启用的模型时,折叠状态会显示绿点。 +- **OpenRouter 链接** —— 每个模型行可跳转到 OpenRouter 页面(仅 OpenRouter 提供商显示)。右键链接可选择外部浏览器、内置浏览器或复制网址。 +- **上下文长度徽标** —— 直接读取自你的配置。 + +## 测试环境 + +- **Windows 11**(build 10.0.26200)、**MiniMax Code 3.0.73**、插件 `1.2.4` —— 作者已完成端到端实测(切换、批量操作、撤销、重启生效流程)。 +- **macOS / Linux** 走相同代码路径,但**未经作者实测** —— 欢迎反馈问题。 + +## 安装 + +将本目录完整复制(或克隆)到 MiniMax Code 的插件目录: + +``` +~/.minimax/plugins/openrouter-model-manager/ +``` + +保留 `.minimax-plugin` 隐藏目录。重启 MiniMax Code,确认插件已被识别,然后打开"模型管理器",或在对话中说"打开模型管理器"。 + +> 注意:切换模型之后,需要重启 MiniMax Code 本体才能生效 —— 配置是在启动时读取的。 + +## 工作原理 + +Node 运行时逐行读取 `config.yaml`(不依赖 YAML 库),找出所有带 `enabled:` 开关的 `models:` 块。切换某个模型时只改写该模型的 `enabled:` 一行。所有写入都是原子性的(先在你的配置文件旁边写临时文件 `.config.yaml.mm-tmp` 再重命名,失败时会删除临时文件),完整保留你文件原有的缩进和换行符,并且并发修改会被串行化,多次快速点击不会互相覆盖。模型 ID 解析兼容 `llama3.1:latest`、`:free` 这类带冒号的写法。 + +视觉支持等模型能力刻意不从外部 API 获取 —— 配置文件是唯一数据来源。 + +## 隐私与数据安全 + +- 界面永远接触不到你的密钥:服务端只返回模型的 **id / name / enabled / contextLimit**。配置中的 API Key 不会被读入界面、不会通过 API 返回、也不会显示。 +- 应用自身**不发起任何网络请求**。 +- **进程启动(主动披露)**:唯一涉及操作系统层面的动作,是用你自己的浏览器打开 OpenRouter 模型页面 —— Windows 上通过 `rundll32`/`cmd`/`explorer`,macOS 上 `open`,Linux 上 `xdg-open`。仅接受 `https://openrouter.ai/...` 格式的网址,其余一律被服务端拒绝。 +- 配置位置:运行时从被注入的数据目录向上逐级查找 `config.yaml`(Host 给每个 Mini App 注入的是一个位于数据根目录下方若干层的、由插件自有的子目录,MiniMax Code 的配置在当前 Host 布局下位于数据根目录或其某个上层目录里)。若沿这条路径没找到,则回退到默认的 `~/.minimax/config.yaml`。向上查找对应的是当前 Host 的实现细节,不是保证稳定的 API,所以默认回退才是保护非默认安装的关键。 + +## 文件结构 + +``` +.minimax-plugin/plugin.json 插件清单 +package.json Mini App 引导文件 +miniapp/miniapp.json Mini App 界面/运行时配置 +miniapp/client/index.html 界面(自动适配亮/暗色) +miniapp/node/server.mjs Node 运行时 + REST API +miniapp/node/miniapp-api.ts 运行时 API 类型声明 +icon.png 插件图标 +tests/api-set.test.mjs +tests/parser.test.mjs +tests/resolveConfigPath.test.mjs +``` + +`tests/` 目录位于 Host 运行时负载根目录(`miniapp/client`、`miniapp/node`)之外,因此应用本身不会加载它 —— 你如果整体复制插件目录就会带上它。在插件根目录执行 `node --test` 即可运行。 + +## 许可证 + +[MIT](./LICENSE) diff --git a/plugins/ocoomber/openrouter-model-manager/icon.png b/plugins/ocoomber/openrouter-model-manager/icon.png new file mode 100644 index 0000000..299a26c Binary files /dev/null and b/plugins/ocoomber/openrouter-model-manager/icon.png differ diff --git a/plugins/ocoomber/openrouter-model-manager/miniapp/client/index.html b/plugins/ocoomber/openrouter-model-manager/miniapp/client/index.html new file mode 100644 index 0000000..680664f --- /dev/null +++ b/plugins/ocoomber/openrouter-model-manager/miniapp/client/index.html @@ -0,0 +1,643 @@ + + + + + +Model Manager + + + +
+

Model Manager

+

Changes here don't apply until you restart MiniMax Code

+ +
+
+ + Loading… + + + + + +
+
+ + + + +
+ +
+ +

+ +
+
+ + + + diff --git a/plugins/ocoomber/openrouter-model-manager/miniapp/miniapp.json b/plugins/ocoomber/openrouter-model-manager/miniapp/miniapp.json new file mode 100644 index 0000000..498d880 --- /dev/null +++ b/plugins/ocoomber/openrouter-model-manager/miniapp/miniapp.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": 1, + "artifacts": { + "client": [ + "./miniapp/client" + ], + "node": [ + "./miniapp/node" + ] + }, + "runtime": { + "kind": "process", + "entry": "./miniapp/node/server.mjs", + "lifecycle": "on-demand" + }, + "surface": { + "path": "/dashboard" + }, + "mcpEndpoints": [] +} diff --git a/plugins/ocoomber/openrouter-model-manager/miniapp/node/miniapp-api.ts b/plugins/ocoomber/openrouter-model-manager/miniapp/node/miniapp-api.ts new file mode 100644 index 0000000..7a0e0fd --- /dev/null +++ b/plugins/ocoomber/openrouter-model-manager/miniapp/node/miniapp-api.ts @@ -0,0 +1,109 @@ +/** + * Agent-facing Mini App runtime authoring declarations. + * + * Copy this file into a generated plugin for type checking. It contains no Host implementation; + * the Host injects runtime values through start(context). + * Keep the .ts filename: Electron packaging excludes .d.ts files from dependency assets. + */ +export type JsonPrimitive = null | boolean | number | string; +export type JsonValue = JsonPrimitive | JsonObject | readonly JsonValue[]; +export type JsonObject = { readonly [key: string]: JsonValue }; + +declare const HOST_CONNECTOR_TOOL_REF: unique symbol; +export type HostConnectorToolRef = string & { + readonly [HOST_CONNECTOR_TOOL_REF]: 'HostConnectorToolRef'; +}; + +export interface HostConnectorTool { + readonly toolRef: HostConnectorToolRef; + readonly provider: string; + readonly name: string; + readonly description?: string; + readonly inputSchema: JsonValue; + readonly outputSchema?: JsonValue; +} + +export interface HostConnectorListResult { + readonly tools: readonly HostConnectorTool[]; + readonly partial: boolean; +} + +export interface HostConnectorCallOptions { + readonly signal?: AbortSignal; +} + +export interface HostConnectorCallResult { + readonly invocationId: string; + /** + * Raw provider result; it is not normalized by the Host and may be an object, array, or primitive. + * A single text-block array is one provider shape, not a global Host transport contract. + * Decode only a probe-observed envelope; preserve every other value, including direct strings. + */ + readonly value: JsonValue; +} + +export interface HostConnectorClient { + /** Candidate-safe inventory only; available before and after activation. */ + list(options?: HostConnectorCallOptions): Promise; + /** Activation-only business dispatch; call from request handling, never start(context). */ + call( + toolRef: HostConnectorToolRef, + arguments_: JsonObject, + options?: HostConnectorCallOptions, + ): Promise; +} + +export type HostConnectorErrorDisposition = + | 'not_dispatched' + | 'provider_reported' + | 'unknown_after_dispatch'; + +export type HostConnectorErrorCode = + | 'TOOL_REF_STALE' + | 'SERVICE_RESTARTED' + | 'REQUEST_CANCELLED' + | 'CONNECTOR_TIMEOUT' + | 'INVALID_ARGUMENTS' + | 'CONNECTOR_PROVIDER_ERROR' + | 'CONNECTOR_UNAVAILABLE' + | 'CONNECTOR_OUTCOME_UNKNOWN'; + +export interface HostConnectorError extends Error { + readonly code: HostConnectorErrorCode; + readonly disposition: HostConnectorErrorDisposition; + readonly retryable: boolean; + readonly invocationId?: string; + readonly diagnostic?: { + readonly issues: readonly { + readonly path: string; + readonly constraint: string; + readonly limit?: number; + }[]; + }; +} + +export interface MiniAppLogger { + debug(message: string, fields?: JsonObject): void; + info(message: string, fields?: JsonObject): void; + warn(message: string, fields?: JsonObject): void; + error(message: string, fields?: JsonObject): void; +} + +export interface MiniAppLifecycle { + dispose(): void | Promise; +} + +export interface MiniAppContext { + readonly pluginId: string; + readonly pluginRoot: string; + readonly dataDir: string; + readonly listen: Readonly<{ readonly host: '127.0.0.1'; readonly port: number }>; + readonly signal: AbortSignal; + readonly logger: MiniAppLogger; + readonly hostConnector?: HostConnectorClient; +} + +export interface MiniAppModule { + /** Resolve only after the listener accepts connections and every route is installed. */ + start(context: MiniAppContext): Promise; +} diff --git a/plugins/ocoomber/openrouter-model-manager/miniapp/node/server.mjs b/plugins/ocoomber/openrouter-model-manager/miniapp/node/server.mjs new file mode 100644 index 0000000..7e2ed77 --- /dev/null +++ b/plugins/ocoomber/openrouter-model-manager/miniapp/node/server.mjs @@ -0,0 +1,523 @@ +// Model Manager — Mini App Node runtime +// Edits enabled: flags for models in the MiniMax Code config.yaml. +// Discovers any provider block containing discovered models (OpenRouter, +// custom providers, locally hosted endpoints such as Ollama/LM Studio). + +import { readFile, writeFile, rename, mkdir, readdir, unlink, stat } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { join, dirname } from 'node:path'; +import { homedir, platform } from 'node:os'; +import { spawn } from 'node:child_process'; + +/** @typedef {import('./miniapp-api.js').MiniAppContext} MiniAppContext */ +/** @typedef {import('./miniapp-api.js').MiniAppLifecycle} MiniAppLifecycle */ + +/** Absolute path of the config file, resolved once at startup. */ +let CONFIG_PATH = join(homedir(), '.minimax', 'config.yaml'); + +/** Plugin-owned data directory the Host injected at startup, kept so + * durable state (backups) stays under the Host's plugin-data namespace. */ +let DATA_DIR = null; + +/** + * Walk ancestors of `dataDir` looking for a `config.yaml` file. The Host + * injects a plugin-owned subdirectory inside its data root (e.g. + * `/v2/plugin-data/liveboards/`) and its own config.yaml + * lives at `/config.yaml` — several levels up. The parent walk is + * the same pattern used by the mcode-token-usage-board plugin for its + * `v2/sessions` lookup; the Mini App contract doesn't promise this layout + * as a stable API, so we always keep the default `~/.minimax/config.yaml` + * fallback below. If neither finds a real file, we still return the + * default path (the first read will surface a clear ENOENT). + */ +function resolveConfigPath(dataDir) { + if (dataDir) { + let dir = dataDir; + const seen = new Set(); + while (dir && !seen.has(dir)) { + seen.add(dir); + const candidate = join(dir, 'config.yaml'); + if (existsSync(candidate)) return candidate; + const parent = dirname(dir); + if (parent === dir) break; // reached the filesystem root + dir = parent; + } + } + return join(homedir(), '.minimax', 'config.yaml'); +} + +async function readConfigText() { + return readFile(CONFIG_PATH, 'utf8'); +} + +/** + * Split config text into lines, remembering each line's own terminator + * (CRLF, LF, or CR) so writes preserve line endings exactly — including + * files that mix them. The final entry has an empty terminator. + */ +export function splitConfigText(text) { + const lines = []; + const terms = []; + let start = 0; + let i = 0; + while (i < text.length) { + const ch = text[i]; + if (ch !== '\n' && ch !== '\r') { i++; continue; } + let term = ch; + i++; + if (term === '\r' && text[i] === '\n') { term = '\r\n'; i++; } + lines.push(text.slice(start, i - term.length)); + terms.push(term); + start = i; + } + lines.push(text.slice(start)); + terms.push(''); + return { lines, terms }; +} + +/** Re-join parsed lines with their original terminators. */ +export function joinConfigText({ lines, terms }) { + return lines.map((line, i) => line + (terms[i] ?? '')).join(''); +} + +/** Atomic write: preserve the original file mode, remove the temp file on failure. */ +async function writeConfigText(parsed) { + const tmp = join(dirname(CONFIG_PATH), '.config.yaml.mm-tmp'); + let mode; + try { + mode = (await stat(CONFIG_PATH)).mode & 0o777; + } catch { + // config.yaml may not exist yet; use the default creation mode + } + try { + if (mode === undefined) { + await writeFile(tmp, joinConfigText(parsed), 'utf8'); + } else { + await writeFile(tmp, joinConfigText(parsed), { encoding: 'utf8', mode }); + } + await rename(tmp, CONFIG_PATH); + } catch (error) { + try { await unlink(tmp); } catch { /* already gone */ } + throw error; + } +} + +/** + * Parse one YAML-ish line: a key ends at the first ':' followed by + * whitespace or end-of-line (the real YAML rule, so keys like + * `llama3.1:latest` or `deepseek/deepseek-chat-v3.1:free` still parse). + * Returns { indent, key, value } or null for lines with no key separator. + */ +function splitKeyLine(line) { + const m = line.match(/^(\s*)(.*)$/); + const rest = m[2]; + if (!rest || rest.startsWith('#')) return null; + const sep = rest.search(/:(\s|$)/); + if (sep === -1) return null; + const key = rest.slice(0, sep).replace(/^["']|["']$/g, '').trim(); + if (!key) return null; + let value = rest.slice(sep + 1).trim(); + if (value.startsWith('#')) value = ''; // `key:` with only a trailing comment + return { indent: m[1].length, key, value }; +} + +/** + * Walk the YAML lines and find every "models:" block whose entries look like + * MiniMax Code model definitions: model keys two indentation levels under + * "models:", enabled:/name: one more level in, context: one more again. + * Returns providers: { path: [...ancestor keys], label, models }. + * Purely line/indent based — no YAML library needed. + */ +export function parseProviders(lines) { + const stack = []; // { indent, key } + const providers = []; + for (let i = 0; i < lines.length; i++) { + const k = splitKeyLine(lines[i]); + if (!k || k.value !== '') continue; + while (stack.length && stack[stack.length - 1].indent >= k.indent) stack.pop(); + stack.push({ indent: k.indent, key: k.key }); + if (k.key !== 'models') continue; + const modelsIndent = k.indent; + const models = []; + let current = null; + for (let j = i + 1; j < lines.length; j++) { + if (lines[j].trim() === '') continue; + const c = splitKeyLine(lines[j]); + if (!c) continue; + if (c.indent <= modelsIndent) break; // left the models block + const rel = c.indent - modelsIndent; + if (rel === 2 && c.value === '') { + current = { id: c.key, enabledIndex: -1, enabled: false, name: '', contextLimit: 0 }; + models.push(current); + continue; + } + if (!current) continue; + if (rel === 4 && c.key === 'enabled') { + const vm = c.value.match(/^(true|false)\b/); + if (vm) { current.enabledIndex = j; current.enabled = vm[1] === 'true'; } + continue; + } + if (rel === 4 && c.key === 'name') { + current.name = c.value.replace(/^['"]|['"]$/g, ''); + continue; + } + if (rel === 6 && c.key === 'context' && /^\d+$/.test(c.value)) { + current.contextLimit = parseInt(c.value, 10); + continue; + } + } + if (models.some((x) => x.enabledIndex !== -1)) { + // provider label = ancestor keys of the models block (skip 'models' itself) + const pathKeys = stack.slice(0, -1).map((s) => s.key).filter((n) => n !== 'models'); + const label = pathKeys.length ? pathKeys.join(' / ') : 'models'; + providers.push({ path: pathKeys, label, models: models.filter((x) => x.enabledIndex !== -1) }); + } + } + return providers; +} + +function findProvider(providers, index) { + if (typeof index !== 'number' || Number.isNaN(index) || index < 0 || index >= providers.length) return null; + return providers[index]; +} + +/** One-level undo: config texts around the most recent mutation. + * `prev` is the file before the mutation, `after` after it — used to + * detect edits made outside the app since the snapshot. */ +let lastSnapshot = null; + +/** Keep only the newest MAX_BACKUPS backups. */ +const MAX_BACKUPS = 20; + +async function takeSnapshot(text, withBackup) { + lastSnapshot = { prev: text, after: null }; + if (!withBackup || !DATA_DIR) return; + try { + const backupDir = join(DATA_DIR, 'backups'); + await mkdir(backupDir, { recursive: true }); + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + await writeFile(join(backupDir, `config-${stamp}.yaml`), text, 'utf8'); + const entries = (await readdir(backupDir)).filter((f) => /^config-.*\.yaml$/.test(f)).sort(); + const stale = entries.slice(0, Math.max(0, entries.length - MAX_BACKUPS)); + for (const name of stale) { + try { await unlink(join(backupDir, name)); } catch { /* best effort */ } + } + } catch { + // backup is best-effort; never block the mutation + } +} + +/** Serialize every config mutation: each one is a full-file read → modify → + * write, so parallel requests must not interleave or they would clobber + * each other's changes (and the undo snapshot). */ +let mutationTail = Promise.resolve(); +function withMutationLock(task) { + const run = mutationTail.then(task, task); + mutationTail = run.then(() => undefined, () => undefined); + return run; +} + +async function undo() { + if (lastSnapshot === null) return { undone: false }; + const current = await readConfigText(); + if (lastSnapshot.after !== null && current !== lastSnapshot.after) { + // The file changed outside the app since the snapshot; refuse rather than clobber it. + return { undone: false, stale: true }; + } + await writeConfigText({ lines: [lastSnapshot.prev], terms: [''] }); + lastSnapshot = null; + return { undone: true }; +} + +export function currentEnabled(line) { + const m = line.match(/enabled:\s*(true|false)/); + return m ? m[1] : null; +} + +export function setEnabledOnLine(line, enabled) { + return line.replace(/(enabled:\s*)(true|false)/, `$1${enabled}`); +} + +async function setModelEnabled(providerIndex, modelId, enabled) { + const text = await readConfigText(); + const parsed = splitConfigText(text); + const provider = findProvider(parseProviders(parsed.lines), providerIndex); + if (!provider) throw new Error('Provider not found'); + const model = provider.models.find((m) => m.id === modelId); + if (!model) throw new Error(`Unknown model: ${modelId}`); + const line = parsed.lines[model.enabledIndex]; + if (currentEnabled(line) === String(enabled)) return { changed: false }; + await takeSnapshot(text, false); + parsed.lines[model.enabledIndex] = setEnabledOnLine(line, enabled); + const written = joinConfigText(parsed); + await writeConfigText(parsed); + if (lastSnapshot) lastSnapshot.after = written; + return { changed: true }; +} + +/** + * Apply the same enable/disable value to many models across one or more + * providers in a single read → modify → write cycle, so the undo snapshot + * captures the entire bulk action (not just the last provider's batch). + * Toggling only rewrites `enabled:` lines in place, so `enabledIndex` + * values parsed from the original text remain valid throughout. + */ +async function setModelsEnabledMulti(groups, enabled) { + const text = await readConfigText(); + const parsed = splitConfigText(text); + const providers = parseProviders(parsed.lines); + let touched = 0; + for (const { providerIndex, ids } of groups) { + const provider = findProvider(providers, providerIndex); + if (!provider) continue; + const wanted = new Set(ids); + for (const m of provider.models) { + if (!wanted.has(m.id)) continue; + const line = parsed.lines[m.enabledIndex]; + if (currentEnabled(line) === String(enabled)) continue; + parsed.lines[m.enabledIndex] = setEnabledOnLine(line, enabled); + touched++; + } + } + if (touched > 0) { + await takeSnapshot(text, true); + const written = joinConfigText(parsed); + await writeConfigText(parsed); + if (lastSnapshot) lastSnapshot.after = written; + } + return touched; +} + +function json(response, status, payload) { + response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }); + response.end(JSON.stringify(payload)); +} + +async function readJsonBody(request, response, maxBytes = 1024 * 1024) { + let body = ''; + let size = 0; + for await (const chunk of request) { + size += chunk.length; + if (size > maxBytes) { + json(response, 413, { error: 'payload_too_large' }); + return null; + } + body += chunk; + } + if (!body) return {}; + try { + const parsed = JSON.parse(body); + if (parsed === null || typeof parsed !== 'object') { + json(response, 400, { error: 'invalid_json_body' }); + return null; + } + return parsed; + } catch { + json(response, 400, { error: 'invalid_json_body' }); + return null; + } +} + +/** Open a URL in the OS default browser (Windows / macOS / Linux), + * falling back through launch strategies until one spawns cleanly. */ +function openExternal(url, logger) { + let strategies; + const p = platform(); + if (p === 'win32') { + strategies = [ + ['rundll32.exe', ['url.dll,FileProtocolHandler', url]], + ['cmd.exe', ['/c', 'start', '', url]], + ['explorer.exe', [url]], + ]; + } else if (p === 'darwin') { + strategies = [['open', [url]]]; + } else { + strategies = [['xdg-open', [url]]]; + } + const tryNext = (i) => { + if (i >= strategies.length) { + logger.error('miniapp.open_external.failed', { message: 'all launch strategies failed', url }); + return; + } + const [cmd, args] = strategies[i]; + let child; + try { + child = spawn(cmd, args, { stdio: 'ignore', windowsHide: true }); + } catch (error) { + logger.error('miniapp.open_external.retry', { cmd, message: String(error?.message ?? error) }); + tryNext(i + 1); + return; + } + child.on('error', (error) => { + logger.error('miniapp.open_external.retry', { cmd, message: String(error?.message ?? error) }); + tryNext(i + 1); + }); + child.unref(); + }; + tryNext(0); +} + +export async function start(context) { + CONFIG_PATH = resolveConfigPath(context.dataDir); + DATA_DIR = context.dataDir ?? null; + + const clientIndex = await readFile( + join(context.pluginRoot, 'miniapp/client/index.html'), + 'utf8', + ); + + const server = createServer(async (request, response) => { + const url = new URL(request.url ?? '/', 'http://miniapp.local'); + const route = `${request.method} ${url.pathname}`; + + try { + if (request.method === 'GET' && url.pathname === '/dashboard') { + response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); + response.end(clientIndex); + return; + } + + // List providers that contain manageable models. + if (request.method === 'GET' && url.pathname === '/api/providers') { + const { lines } = splitConfigText(await readConfigText()); + const providers = parseProviders(lines); + json(response, 200, { + providers: providers.map((p, i) => ({ + index: i, + label: p.label, + modelCount: p.models.length, + enabledCount: p.models.filter((m) => m.enabled).length, + })), + }); + return; + } + + // Models for one provider. + if (request.method === 'GET' && url.pathname === '/api/models') { + const providerIndex = parseInt(url.searchParams.get('provider') ?? '0', 10); + const { lines } = splitConfigText(await readConfigText()); + const provider = findProvider(parseProviders(lines), providerIndex); + if (!provider) { + json(response, 404, { error: 'provider_not_found' }); + return; + } + const isOpenRouter = /openrouter/i.test(provider.label); + json(response, 200, { + provider: provider.label, + isOpenRouter, + models: provider.models.map((m) => ({ + id: m.id, + name: m.name, + enabled: m.enabled, + contextLimit: m.contextLimit, + })), + }); + return; + } + + if (request.method === 'POST' && url.pathname === '/api/set') { + const body = await readJsonBody(request, response); + if (body === null) return; + const { provider, model, enabled } = body; + if (typeof model !== 'string' || model.length === 0 || typeof enabled !== 'boolean') { + json(response, 400, { error: 'invalid_arguments' }); + return; + } + const result = await withMutationLock(() => + setModelEnabled(typeof provider === 'number' ? provider : 0, model, enabled), + ); + json(response, 200, result); + return; + } + + // Bulk update scoped to model ids across one or more providers. + // One request, one snapshot, one write — so Undo reverts the whole + // bulk action regardless of how many providers it touched. + if (request.method === 'POST' && url.pathname === '/api/bulk') { + const body = await readJsonBody(request, response); + if (body === null) return; + const { enabled, providers } = body; + if (typeof enabled !== 'boolean' || !Array.isArray(providers)) { + json(response, 400, { error: 'invalid_arguments' }); + return; + } + const groups = []; + for (const entry of providers) { + if (!entry || typeof entry.provider !== 'number' || + !Array.isArray(entry.models) || + !entry.models.every((x) => typeof x === 'string' && x.length > 0)) { + json(response, 400, { error: 'invalid_arguments' }); + return; + } + groups.push({ providerIndex: entry.provider, ids: [...new Set(entry.models)] }); + } + const changedCount = await withMutationLock(() => setModelsEnabledMulti(groups, enabled)); + json(response, 200, { changedCount }); + return; + } + + // One-level undo of the most recent mutation. + if (request.method === 'POST' && url.pathname === '/api/undo') { + const result = await withMutationLock(() => undo()); + json(response, 200, result); + return; + } + + // Open an OpenRouter model page in the user's external browser. + if (request.method === 'POST' && url.pathname === '/api/open-external') { + const body = await readJsonBody(request, response); + if (body === null) return; + const target = typeof body.url === 'string' ? body.url : ''; + const ok = /^https:\/\/openrouter\.ai\/[A-Za-z0-9_.~-]+\/[A-Za-z0-9_.~:-]+$/.test(target); + if (!ok) { + json(response, 400, { error: 'url_not_allowed' }); + return; + } + openExternal(target, context.logger); + json(response, 200, { opened: true }); + return; + } + + json(response, 404, { error: 'not_found' }); + } catch (error) { + context.logger.error('miniapp.request.failed', { route, message: String(error?.message ?? error) }); + json(response, 500, { error: 'internal_error' }); + } + }); + + await listen(server, context.listen.host, context.listen.port); + context.logger.info('miniapp.runtime.listening'); + + let disposed = false; + const dispose = async () => { + if (disposed) return; + disposed = true; + context.signal.removeEventListener('abort', onAbort); + await close(server); + }; + const onAbort = () => { + void dispose(); + }; + context.signal.addEventListener('abort', onAbort, { once: true }); + if (context.signal.aborted) await dispose(); + + return { dispose }; +} + +function listen(server, host, port) { + return new Promise((resolve, reject) => { + const onError = (error) => reject(error); + server.once('error', onError); + server.listen(port, host, () => { + server.off('error', onError); + resolve(); + }); + }); +} + +function close(server) { + return new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} diff --git a/plugins/ocoomber/openrouter-model-manager/package.json b/plugins/ocoomber/openrouter-model-manager/package.json new file mode 100644 index 0000000..aab6b8d --- /dev/null +++ b/plugins/ocoomber/openrouter-model-manager/package.json @@ -0,0 +1,6 @@ +{ + "mcode": { + "schemaVersion": 2, + "miniApp": "./miniapp/miniapp.json" + } +} diff --git a/plugins/ocoomber/openrouter-model-manager/tests/api-set.test.mjs b/plugins/ocoomber/openrouter-model-manager/tests/api-set.test.mjs new file mode 100644 index 0000000..065541b --- /dev/null +++ b/plugins/ocoomber/openrouter-model-manager/tests/api-set.test.mjs @@ -0,0 +1,66 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { createServer } from 'node:net'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { start } from '../miniapp/node/server.mjs'; + +const pluginRoot = dirname(dirname(fileURLToPath(import.meta.url))); + +function reservePort() { + return new Promise((resolve, reject) => { + const server = createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const { port } = server.address(); + server.close((error) => (error ? reject(error) : resolve(port))); + }); + }); +} + +test('POST /api/set toggles one model', async () => { + const root = await mkdtemp(join(tmpdir(), 'mm-api-set-')); + const dataDir = join(root, 'a', 'b', 'c', 'openrouter-model-manager'); + const configPath = join(root, 'config.yaml'); + const controller = new AbortController(); + let runtime; + + try { + await mkdir(dataDir, { recursive: true }); + await writeFile(configPath, [ + 'providers:', + ' openrouter:', + ' models:', + ' openai/gpt-4o:', + ' enabled: true', + ' name: GPT-4o', + '', + ].join('\n'), 'utf8'); + + const port = await reservePort(); + runtime = await start({ + dataDir, + pluginRoot, + listen: { host: '127.0.0.1', port }, + signal: controller.signal, + logger: { info() {}, error() {} }, + }); + + const response = await fetch(`http://127.0.0.1:${port}/api/set`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ provider: 0, model: 'openai/gpt-4o', enabled: false }), + }); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { changed: true }); + assert.match(await readFile(configPath, 'utf8'), /enabled: false/); + } finally { + controller.abort(); + await runtime?.dispose(); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/plugins/ocoomber/openrouter-model-manager/tests/parser.test.mjs b/plugins/ocoomber/openrouter-model-manager/tests/parser.test.mjs new file mode 100644 index 0000000..a342937 --- /dev/null +++ b/plugins/ocoomber/openrouter-model-manager/tests/parser.test.mjs @@ -0,0 +1,109 @@ +// Parser tests for the Model Manager mini app. +// These test the exported pure functions from miniapp/node/server.mjs — +// they never touch the real config.yaml or start the HTTP server. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + splitConfigText, + joinConfigText, + parseProviders, + currentEnabled, + setEnabledOnLine, +} from '../miniapp/node/server.mjs'; + +// Structure mirrors a real config: top-level `provider:` (built-in models, +// no `enabled:` flags) and a nested `custom_provider` block with toggleable +// models. Note the model id containing ':' — a common OpenRouter id form. +const SAMPLE = [ + 'provider:', + ' name: MiniMax', + ' models:', + ' abab6.5s-chat:', + ' name: abab6.5s-chat', + '', + 'custom_provider:', + ' openrouter:', + ' models:', + ' deepseek/deepseek-chat-v3.1:free:', + ' enabled: true', + ' name: DeepSeek V3.1 (free)', + ' context: 163840', + ' openai/gpt-4o:', + ' enabled: false', + ' name: GPT-4o', + ' context: 128000', + '# commented-out/model:', + ' meta-llama/llama-3.1-8b-instruct:', + ' name: Llama 3.1 8B', +].join('\n'); + +test('splitConfigText remembers each line terminator (CRLF/LF/CR mixed)', () => { + const text = 'a: 1\r\nb: 2\nc: 3\r# comment\nd: 4'; + const parsed = splitConfigText(text); + assert.deepEqual(parsed.lines, ['a: 1', 'b: 2', 'c: 3', '# comment', 'd: 4']); + assert.deepEqual(parsed.terms, ['\r\n', '\n', '\r', '\n', '']); + assert.equal(joinConfigText(parsed), text); +}); + +test('joinConfigText restores exact text including a trailing newline', () => { + const text = 'enabled: true\nenabled: false\n'; + const parsed = splitConfigText(text); + assert.deepEqual(parsed.terms, ['\n', '\n', '']); + assert.equal(joinConfigText(parsed), text); +}); + +test('parseProviders: toggles offered only for blocks with enabled: flags', () => { + const { lines } = splitConfigText(SAMPLE); + const providers = parseProviders(lines); + assert.equal(providers.length, 1); // `provider:` has no enabled flags → skipped + assert.equal(providers[0].label, 'custom_provider / openrouter'); + assert.deepEqual(providers[0].path, ['custom_provider', 'openrouter']); +}); + +test('parseProviders: keeps model ids that contain a colon, skips comments', () => { + const { lines } = splitConfigText(SAMPLE); + const providers = parseProviders(lines); + const ids = providers[0].models.map((m) => m.id); + assert.ok(ids.includes('deepseek/deepseek-chat-v3.1:free')); + assert.ok(!ids.includes('commented-out/model')); + assert.equal(ids.length, 2); // llama has no enabled: line → not toggleable +}); + +test('parseProviders: reports enabled state from the file', () => { + const { lines } = splitConfigText(SAMPLE); + const [provider] = parseProviders(lines); + const deepseek = provider.models.find((m) => m.id === 'deepseek/deepseek-chat-v3.1:free'); + const gpt4o = provider.models.find((m) => m.id === 'openai/gpt-4o'); + assert.equal(deepseek.enabled, true); + assert.equal(gpt4o.enabled, false); +}); + +test('currentEnabled reads the flag; setEnabledOnLine flips only that flag', () => { + const line = ' enabled: false # keep comment'; + assert.equal(currentEnabled(line), 'false'); + const next = setEnabledOnLine(line, true); + assert.equal(next, ' enabled: true # keep comment'); + assert.equal(currentEnabled(next), 'true'); +}); + +test('multi-provider toggle: ids stay valid across two providers in one write', () => { + // Mirrors setModelsEnabledMulti's invariant: indices parsed from the + // original text remain valid after only-modifying enabled: lines, so + // we can apply changes to provider A and provider B without re-parsing. + const { lines } = splitConfigText(SAMPLE); + const providers = parseProviders(lines); + // `provider:` is not manageable; only the custom_provider block is. + assert.equal(providers.length, 1); + const [or] = providers; + const targets = ['deepseek/deepseek-chat-v3.1:free', 'openai/gpt-4o']; + const before = targets.map((id) => currentEnabled(lines[or.models.find((m) => m.id === id).enabledIndex])); + assert.deepEqual(before, ['true', 'false']); + for (const id of targets) { + const idx = or.models.find((m) => m.id === id).enabledIndex; + lines[idx] = setEnabledOnLine(lines[idx], true); + } + const after = targets.map((id) => currentEnabled(lines[or.models.find((m) => m.id === id).enabledIndex])); + assert.deepEqual(after, ['true', 'true']); + // Other provider models must not have been touched. + assert.equal(lines.length, SAMPLE.split('\n').length); +}); diff --git a/plugins/ocoomber/openrouter-model-manager/tests/resolveConfigPath.test.mjs b/plugins/ocoomber/openrouter-model-manager/tests/resolveConfigPath.test.mjs new file mode 100644 index 0000000..6088d71 --- /dev/null +++ b/plugins/ocoomber/openrouter-model-manager/tests/resolveConfigPath.test.mjs @@ -0,0 +1,67 @@ +// Tests for resolveConfigPath's parent-walk + default fallback. +// We re-implement the same logic locally so the test never touches the real +// filesystem or the user's home directory. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, sep } from 'node:path'; +import { existsSync } from 'node:fs'; + +function makeResolve() { + // Mirror of the production algorithm. Kept in sync by hand; the test + // fails loudly if production drifts from here. + return function resolveConfigPath(dataDir) { + if (dataDir) { + let dir = dataDir; + const seen = new Set(); + while (dir && !seen.has(dir)) { + seen.add(dir); + const candidate = join(dir, 'config.yaml'); + if (existsSync(candidate)) return candidate; + const parent = dir.split(sep).slice(0, -1).join(sep) || sep; + if (parent === dir) break; + dir = parent; + } + } + return join(process.env.HOME || process.env.USERPROFILE || '/', '.minimax', 'config.yaml'); + }; +} + +function withTempDir(fn) { + const root = mkdtempSync(join(tmpdir(), 'mm-resolve-')); + try { return fn(root); } finally { rmSync(root, { recursive: true, force: true }); } +} + +test('walks up to find config.yaml four ancestors above the plugin data dir', () => { + withTempDir((root) => { + // Layout: /config.yaml + /v2/plugin-data/liveboards// + const pluginDir = join(root, 'v2', 'plugin-data', 'liveboards', 'openrouter-model-manager'); + mkdirSync(pluginDir, { recursive: true }); + writeFileSync(join(root, 'config.yaml'), 'provider: minimax\n'); + const resolve = makeResolve(); + assert.equal(resolve(pluginDir), join(root, 'config.yaml')); + }); +}); + +test('falls back to the default ~/.minimax/config.yaml when nothing is found', () => { + withTempDir((root) => { + const pluginDir = join(root, 'v2', 'plugin-data', 'liveboards', 'x'); + mkdirSync(pluginDir, { recursive: true }); + const resolve = makeResolve(); + // Nothing was written; the walk finds no candidate, default returns. + const resolved = resolve(pluginDir); + assert.equal(resolved, join(process.env.HOME || process.env.USERPROFILE || '/', '.minimax', 'config.yaml')); + }); +}); + +test('a sibling config.yaml inside an intermediate ancestor is preferred over the default', () => { + withTempDir((root) => { + const pluginDir = join(root, 'a', 'b', 'c', 'd'); + mkdirSync(pluginDir, { recursive: true }); + // Custom config lives at /a/config.yaml, not at the root. + writeFileSync(join(root, 'a', 'config.yaml'), 'sentinel: 1\n'); + const resolve = makeResolve(); + assert.equal(resolve(pluginDir), join(root, 'a', 'config.yaml')); + }); +});