From 2737ca8743d245c2ba21a94a1bca5272d33dd150 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=98=8A=E5=AE=87?= Date: Wed, 16 Sep 2026 00:26:54 +0800 Subject: [PATCH] fix: harden client detection and Windows auth handling - detect host by executable basename (kilo first) to avoid parent-dir false positives - prefer ~/.local/share for auth.json, fall back to the other client's store - atomic state writes with temp-dir fallback when the plugin dir is read-only - install.ps1: -LiteralPath, legacy cleanup, duplicate-plugin warning, UTF-8 BOM - docs: Windows env examples; tests: regression coverage; add .gitattributes --- .gitattributes | 4 ++ README.md | 19 ++++++- index.js | 128 ++++++++++++++++++++++++++++++++++--------- install.ps1 | 28 ++++++++-- package.json | 2 +- test/plugin.test.mjs | 92 +++++++++++++++++++++++++++++-- 6 files changed, 233 insertions(+), 40 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..db069db --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# 保持 shell 脚本为 LF,避免 Windows(core.autocrlf=true)下 Git-Bash 执行报错 +*.sh text eol=lf +# PowerShell 脚本保持 CRLF +*.ps1 text eol=crlf diff --git a/README.md b/README.md index 3325154..ff5d750 100644 --- a/README.md +++ b/README.md @@ -46,18 +46,35 @@ kilo auth login -p cmdcode # Kilo opencode auth login # OpenCode,选择 Command Code ``` -不想登录,就用环境变量直接给 key: +不想登录,就用环境变量直接给 key。 + +macOS / Linux / Git-Bash: ```bash CMD_API_KEY=sk-xxxx kilo CMD_API_KEY=sk-xxxx opencode ``` +Windows PowerShell: + +```powershell +$env:CMD_API_KEY = "sk-xxxx"; kilo +$env:CMD_API_KEY = "sk-xxxx"; opencode +``` + +Windows cmd: + +```bat +set CMD_API_KEY=sk-xxxx && kilo +set CMD_API_KEY=sk-xxxx && opencode +``` + 重启客户端生效。 > **提示:模型按连接状态显示** > - 未连接(没有 API key)时,`/models` 里不会出现任何 cmdcode 模型。 > - 连接后**需重启客户端一次**,模型列表才会出现(插件的 `config` 钩子只在启动时运行;这一点与原生 provider 的即时刷新不同)。 +> - 启动时按此顺序找 key:环境变量 → 凭据库 `~/.local/share/kilo/auth.json` / `~/.local/share/opencode/auth.json`(Windows 上同样是 `%USERPROFILE%\.local\share\...`)。识别不出当前客户端时,会回退到另一个客户端的凭据库,避免“连了却不显示模型”。 ### 3. 使用 diff --git a/index.js b/index.js index 57e0a55..3f43faf 100644 --- a/index.js +++ b/index.js @@ -19,8 +19,11 @@ const PLUGIN_DIR = ? import.meta.dirname : path.dirname(fileURLToPath(import.meta.url)); -const CACHE_FILE = path.join(PLUGIN_DIR, ".cache.json"); -const CAP_FILE = path.join(PLUGIN_DIR, ".capabilities.json"); +// 状态文件目录:优先插件目录;只读(如全局/只读安装位置)时回退到临时目录。 +const STATE_DIRS = [ + PLUGIN_DIR, + path.join(os.tmpdir(), "kilo-opencode-command-code"), +]; const MODALITY_KEYS = ["text", "image", "audio", "video", "pdf"]; @@ -32,10 +35,38 @@ const readJson = (file) => { } }; +// 原子写:先写临时文件再 rename,避免并发下产生半截 JSON。 const writeJson = (file, value) => { + const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; try { - fs.writeFileSync(file, JSON.stringify(value)); - } catch {} + fs.writeFileSync(tmp, JSON.stringify(value)); + fs.renameSync(tmp, file); + return true; + } catch { + try { + fs.rmSync(tmp, { force: true }); + } catch {} + return false; + } +}; + +// 按目录优先级读取状态文件(读第一个存在的)。 +const readState = (name) => { + for (const dir of STATE_DIRS) { + const data = readJson(path.join(dir, name)); + if (data) return data; + } + return null; +}; + +// 按目录优先级写入状态文件(写第一个可写的)。 +const writeState = (name, value) => { + for (const dir of STATE_DIRS) { + try { + fs.mkdirSync(dir, { recursive: true }); + } catch {} + if (writeJson(path.join(dir, name), value)) return; + } }; /* ------------------------------------------------------------------ */ @@ -93,7 +124,7 @@ function parseCapabilities(html) { /** 拉取并解析官方能力表;失败时回退到旧缓存,再不行返回 null(走关键词兜底)。 */ async function loadCapabilities() { - const cached = readJson(CAP_FILE); + const cached = readState(".capabilities.json"); if ( cached?.capabilities && Date.now() - (cached.fetchedAt ?? 0) < CAP_TTL_MS @@ -108,7 +139,7 @@ async function loadCapabilities() { if (!res.ok) throw new Error(`HTTP ${res.status}`); const capabilities = parseCapabilities(await res.text()); if (Object.keys(capabilities).length > 0) { - writeJson(CAP_FILE, { fetchedAt: Date.now(), capabilities }); + writeState(".capabilities.json", { fetchedAt: Date.now(), capabilities }); return capabilities; } throw new Error("empty capability map"); @@ -186,12 +217,12 @@ async function loadModelList() { if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); if (Array.isArray(data?.data) && data.data.length > 0) { - writeJson(CACHE_FILE, data); + writeState(".cache.json", data); return data.data; } throw new Error("empty model list"); } catch { - const cached = readJson(CACHE_FILE); + const cached = readState(".cache.json"); if (Array.isArray(cached?.data) && cached.data.length > 0) return cached.data; return null; } @@ -269,39 +300,52 @@ function envApiKey() { return undefined; } +/* 支持的两个客户端;顺序仅在无法识别时作为回退优先级。 */ +const CLIENTS = ["kilo", "opencode"]; + /** * 判断当前运行在哪个客户端:Kilo 还是 OpenCode。 * 两者的登录凭据库是分开的,必须知道该读哪一个,否则会把另一个客户端的 * 登录状态误当成已连接。只认进程可执行文件路径(对环境变量和参数都免疫: * 在 Kilo 里启动 OpenCode 会同时带上 KILO=1,argv 也可能含插件路径)。 + * 用 basename 而非整串匹配,避免用户名/安装目录含 "kilo"/"opencode" 时误判; + * 先判 kilo,避免 @kilocode 路径被 "opencode" 抢先匹配。 */ -function detectClient() { - const probe = (process.execPath || "").toLowerCase(); - if (probe.includes("opencode")) return "opencode"; - if (probe.includes("kilo")) return "kilo"; +function detectClient(execPath = process.execPath) { + const base = path.basename(execPath || "").toLowerCase(); + if (base.includes("kilo")) return "kilo"; + if (base.includes("opencode")) return "opencode"; return null; } -/** 当前客户端可能的 auth.json 位置(跨平台)。 */ -function authFileCandidates(app) { - const home = os.homedir(); +/** 各平台可能存放客户端数据的根目录;官方真实位置 ~/.local/share 优先。 */ +function authRoots( + platform = process.platform, + home = os.homedir(), + env = process.env +) { const roots = []; - if (process.env.XDG_DATA_HOME) roots.push(process.env.XDG_DATA_HOME); - if (process.platform === "win32") { - if (process.env.APPDATA) roots.push(process.env.APPDATA); - if (process.env.LOCALAPPDATA) roots.push(process.env.LOCALAPPDATA); - } else if (process.platform === "darwin") { + if (env.XDG_DATA_HOME) roots.push(env.XDG_DATA_HOME); + roots.push(path.join(home, ".local", "share")); + if (platform === "win32") { + if (env.LOCALAPPDATA) roots.push(env.LOCALAPPDATA); + if (env.APPDATA) roots.push(env.APPDATA); + } else if (platform === "darwin") { roots.push(path.join(home, "Library", "Application Support")); } - roots.push(path.join(home, ".local", "share")); - return roots.map((root) => path.join(root, app, "auth.json")); + return [...new Set(roots)]; } -/** 从当前客户端的凭据库里读 cmdcode 的 key;没有则返回 undefined。 */ -function storedApiKey() { - const client = detectClient(); - if (!client) return undefined; - for (const file of authFileCandidates(client)) { +/** 某客户端可能的 auth.json 位置(跨平台)。 */ +function authFileCandidates(app, opts = {}) { + return authRoots(opts.platform, opts.home, opts.env).map((root) => + path.join(root, app, "auth.json") + ); +} + +/** 从指定客户端的凭据库里读 cmdcode 的 key;没有则返回 undefined。 */ +function readStoredKey(app) { + for (const file of authFileCandidates(app)) { const entry = readJson(file)?.cmdcode; const key = typeof entry === "string" ? entry : entry?.key; if (key && key.trim()) return key.trim(); @@ -309,6 +353,26 @@ function storedApiKey() { return undefined; } +/** + * 按优先级解析已存储的 key:优先当前客户端,读不到则回退另一个客户端。 + * 纯函数,便于测试。识别失败(preferred 为 null)时按 CLIENTS 顺序尝试。 + */ +function resolveStoredApiKey(preferred, readKey) { + const order = preferred + ? [preferred, ...CLIENTS.filter((c) => c !== preferred)] + : CLIENTS; + for (const app of order) { + const key = readKey(app); + if (key) return key; + } + return undefined; +} + +/** 当前是否已连接 Command Code(读凭据库)。 */ +function storedApiKey() { + return resolveStoredApiKey(detectClient(), readStoredKey); +} + /** 当前是否已连接 Command Code:环境变量优先,其次凭据库。 */ function resolveApiKey() { return envApiKey() ?? storedApiKey(); @@ -380,3 +444,13 @@ export const CommandCode = async () => ({ }); export default CommandCode; + +/* 仅供测试使用的内部纯函数,不属于对外 API。 */ +export const _internal = { + detectClient, + authRoots, + authFileCandidates, + resolveStoredApiKey, + parseCapabilities, + normId, +}; diff --git a/install.ps1 b/install.ps1 index e5ce0a2..a04242c 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1,4 +1,4 @@ -# install.ps1 — Windows (PowerShell 5.1+) 安装脚本 +# install.ps1 — Windows (PowerShell 5.1+) 安装脚本 # # 原理:Kilo 与 OpenCode 都会自动加载全局 plugins 目录下的 *.js 文件, # 因此这里只做“复制文件”,不修改任何 json/jsonc 配置文件,规避 JSONC 注释 @@ -14,24 +14,40 @@ $PluginDir = Split-Path -Parent $MyInvocation.MyCommand.Path $IndexFile = Join-Path $PluginDir "index.js" $HomeDir = $env:USERPROFILE -if (-not (Test-Path $IndexFile)) { +if (-not (Test-Path -LiteralPath $IndexFile)) { throw "index.js not found: $IndexFile" } # Kilo 全局 plugins 目录 $KiloPlugins = Join-Path $HomeDir ".config\kilo\plugins" New-Item -ItemType Directory -Force -Path $KiloPlugins | Out-Null -Copy-Item $IndexFile (Join-Path $KiloPlugins "command-code.js") -Force +# 清理历史遗留的旧版 cmdcode 插件文件,避免重复注册 +Get-ChildItem -LiteralPath $KiloPlugins -Filter "cmdcode*.js" -ErrorAction SilentlyContinue | + Remove-Item -Force -ErrorAction SilentlyContinue +Copy-Item -LiteralPath $IndexFile -Destination (Join-Path $KiloPlugins "command-code.js") -Force Write-Host "Installed to Kilo: $KiloPlugins\command-code.js" # OpenCode 全局 plugins 目录 $OcPlugins = Join-Path $HomeDir ".config\opencode\plugins" New-Item -ItemType Directory -Force -Path $OcPlugins | Out-Null -Copy-Item $IndexFile (Join-Path $OcPlugins "command-code.js") -Force +Copy-Item -LiteralPath $IndexFile -Destination (Join-Path $OcPlugins "command-code.js") -Force Write-Host "Installed to OpenCode: $OcPlugins\command-code.js" -# 提示:若 kilo.jsonc / opencode.json 里手动写过指向旧路径的 file:// 插件条目, -# 且该路径已不存在,请手动删除该条目,避免启动报错或重复注册。 +# 提示:若配置文件的 "plugin" 数组里已经写了 npm 包 "kilo-opencode-command-code", +# 本地插件与同名 npm 插件会被分别加载(重复注册),二选一即可。 +$configs = @( + (Join-Path $HomeDir ".config\kilo\kilo.jsonc"), + (Join-Path $HomeDir ".config\kilo\opencode.json"), + (Join-Path $HomeDir ".config\opencode\opencode.json") +) +foreach ($cfg in $configs) { + if (Test-Path -LiteralPath $cfg) { + $text = Get-Content -LiteralPath $cfg -Raw -ErrorAction SilentlyContinue + if ($text -and $text -match "kilo-opencode-command-code") { + Write-Warning "Found npm plugin 'kilo-opencode-command-code' in $cfg. Remove it to avoid loading the plugin twice (local plugins and npm plugins are loaded separately)." + } + } +} Write-Host "" Write-Host "Done! Restart Kilo / OpenCode, then run:" diff --git a/package.json b/package.json index 8e2d409..496d528 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kilo-opencode-command-code", - "version": "1.2.0", + "version": "1.2.1", "description": "Command Code Provider API plugin for Kilo and OpenCode — auto-syncs models and capabilities (vision/reasoning), handles auth, routes Claude via the Anthropic Messages endpoint", "type": "module", "main": "index.js", diff --git a/test/plugin.test.mjs b/test/plugin.test.mjs index 1a6a9c7..48c1e13 100644 --- a/test/plugin.test.mjs +++ b/test/plugin.test.mjs @@ -1,6 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -9,11 +10,17 @@ const pluginDir = path.resolve( ".." ); -// 去掉可能存在的真实缓存,保证测试走 stub,结果确定。 +// 去掉可能存在的真实缓存(插件目录与临时回退目录),保证测试走 stub,结果确定。 +const stateDirs = [ + pluginDir, + path.join(os.tmpdir(), "kilo-opencode-command-code"), +]; for (const file of [".cache.json", ".capabilities.json"]) { - try { - fs.unlinkSync(path.join(pluginDir, file)); - } catch {} + for (const dir of stateDirs) { + try { + fs.unlinkSync(path.join(dir, file)); + } catch {} + } } const MODELS = { @@ -66,7 +73,7 @@ globalThis.fetch = async (url) => { // 插件只在“已连接”时注册模型;测试里用环境变量模拟已连接。 process.env.CMD_API_KEY = "test-env-key"; -const { CommandCode } = await import("../index.js"); +const { CommandCode, _internal } = await import("../index.js"); const plugin = await CommandCode(); const config = {}; await plugin.config(config); @@ -150,3 +157,78 @@ test("does not register models until the user has connected", async () => { } } }); + +test("detectClient identifies the host by executable basename (Windows + POSIX)", () => { + const { detectClient } = _internal; + assert.equal( + detectClient( + "C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\opencode-ai\\bin\\opencode.exe" + ), + "opencode" + ); + assert.equal( + detectClient( + "C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@kilocode\\cli\\node_modules\\@kilocode\\cli-windows-x64\\bin\\kilo.exe" + ), + "kilo" + ); + assert.equal(detectClient("/usr/local/bin/opencode"), "opencode"); + assert.equal(detectClient("/opt/kilo/bin/kilo"), "kilo"); + assert.equal(detectClient("C:\\Program Files\\nodejs\\node.exe"), null); + assert.equal(detectClient("C:\\Program Files\\nodejs\\bun.exe"), null); +}); + +test("detectClient ignores client names appearing in parent directories", () => { + const { detectClient } = _internal; + // 用户名/目录名含 kilo,但可执行文件是 opencode → 仍判 opencode + assert.equal( + detectClient("C:\\Users\\kilo\\node_modules\\opencode-ai\\bin\\opencode.exe"), + "opencode" + ); + // 目录名含 opencode,但宿主是 node → 不误判 + assert.equal(detectClient("C:\\Users\\opencode\\node.exe"), null); +}); + +test("authRoots prefers XDG_DATA_HOME then ~/.local/share on Windows", () => { + const { authRoots, authFileCandidates } = _internal; + const home = "C:\\Users\\me"; + const env = { + XDG_DATA_HOME: "D:\\xdg", + LOCALAPPDATA: "C:\\Users\\me\\AppData\\Local", + APPDATA: "C:\\Users\\me\\AppData\\Roaming", + }; + const roots = authRoots("win32", home, env); + assert.equal(roots[0], "D:\\xdg"); + assert.equal(roots[1], path.join(home, ".local", "share")); + // 真实位置必须排在 %APPDATA% / %LOCALAPPDATA% 之前 + assert.ok( + roots.indexOf(path.join(home, ".local", "share")) < + roots.indexOf(env.APPDATA) + ); + + const files = authFileCandidates("opencode", { platform: "win32", home, env }); + assert.equal(files[0], path.join("D:\\xdg", "opencode", "auth.json")); + assert.equal(files[1], path.join(home, ".local", "share", "opencode", "auth.json")); +}); + +test("resolveStoredApiKey falls back to the other client's store", () => { + const { resolveStoredApiKey } = _internal; + + // 识别为 opencode,但只有 kilo 存了 key → 回退读到 kilo 的 key + assert.equal( + resolveStoredApiKey("opencode", (app) => (app === "kilo" ? "k" : undefined)), + "k" + ); + + // 识别失败时按 CLIENTS 顺序(kilo 优先)尝试,且不早退 + const seen = []; + const key = resolveStoredApiKey(null, (app) => { + seen.push(app); + return app === "opencode" ? "o" : undefined; + }); + assert.equal(key, "o"); + assert.deepEqual(seen, ["kilo", "opencode"]); + + // 两端都没有 + assert.equal(resolveStoredApiKey("kilo", () => undefined), undefined); +});