import path from 'path'
import get from 'lodash-es/get.js'
import omit from 'lodash-es/omit.js'
import isobj from 'wsemi/src/isobj.mjs'
import isestr from 'wsemi/src/isestr.mjs'
import execCli from 'wsemi/src/execCli.mjs'
import getCliArgs from './getCliArgs.mjs'
import getErrorResult from './getErrorResult.mjs'
import { attachErrorType } from './getErrorType.mjs'
import castPintOr from './castPintOr.mjs'
import dfTimeoutMs from './dfTimeoutMs.mjs'
// dispatchOpencode.mjs — 以opencode CLI呼叫AI模型
//
// 【金鑰注入(2026-08-08於本機實測確認,非讀碼推論)】
// OPENCODE_AUTH_CONTENT環境變數會「完全覆蓋」opencode的auth.json:
// 實測帶入無效key → `Error: Invalid API key.`(code=1、訊息在stderr、stdout空);
// 帶入真實key → 正常回應。以env傳入「不會改寫」auth.json(實測md5前後一致),
// 屬process層級,可與同時執行的其他專案並行而互不干擾。
// 註:OPENCODE_API_KEY在auth.json已有憑證時不生效,故一律走OPENCODE_AUTH_CONTENT。
// 原始碼依據(anomalyco/opencode dev, packages/opencode/src/auth/index.ts之Auth.all):
// `if (process.env.OPENCODE_AUTH_CONTENT) return JSON.parse(...)`, 解析成功即整份取代, 不與auth.json合併;
// 空字串為falsy、非法JSON會落回讀auth.json。auth.json位於<XDG_DATA_HOME|~/.local/share>/opencode/。
//
// 【本轉接器只驗證過opencode v1(實測至1.18.32), v2尚未支援(2026-09-22查證, 2026-09-23補④)】
// opencode已另發布v2(2026-09-23已至2.0.15, npm套件@opencode/cli, bin另含opencode2; 文件自成一棵樹
// https://opencode.ai/v2/docs), 官方〈Migrate from V1〉明載「OpenCode 1 and OpenCode 2 both use the opencode
// command and are no longer installed side by side by default... the V2 curl installer replaces the V1 binary」
// ——即升級v2會取代同名執行檔。v1之npm套件opencode-ai(1.18.32)仍為latest且未標deprecated。
// v2仍有`opencode run`(文件列為自動化用途)與`--agent`, 但至少四處與本轉接器之假設不同, 升級前須逐一實測:
// ① 模型旗標: v2文件一律寫`--model provider/model`, 本轉接器用`-m`(v2是否保留為別名未驗證);
// ② provider設定形狀: v2改為`providers.<id>.package`(單數provider→providers, npm→package並加aisdk:前綴),
// 與本轉接器經OPENCODE_CONFIG_CONTENT注入之v1形狀(provider.<id>.npm)不同;
// ③ 金鑰注入: v2憑證走/connect與新憑證庫, OPENCODE_AUTH_CONTENT是否仍被讀取未驗證;
// ④ 權限設定形狀: v2改為單一有序`permissions`陣列, 動作亦改名(bash→shell、task→subagent、write/patch→edit),
// providers.mjs之OC_READONLY({edit:'deny', bash:'ask'})為v1形狀, 在v2不會被認得——唯讀鎖等同失效,
// 升v2前須以金絲雀(要求寫檔, 驗未落地)重驗防寫, 不可假設仍有效。
// 故本機若升v2, 請先以單一呼叫實測上述四項再調整本檔; 條目端可先以exe指定v1執行檔路徑過渡。
//
// 【cwd須同步PWD環境變數(2026-09-23實測後新增)】opencode run決定session目錄之原始碼為
// `const root = Filesystem.resolve(process.env.PWD ?? process.cwd())`(packages/opencode/src/cli/cmd/run.ts),
// 即「繼承來的PWD優先於子進程真實cwd」。Git Bash與Linux/macOS之shell皆會設PWD, 故呼叫端給opt.cwd而
// 父進程PWD指向他處時, opencode會在父進程目錄作業: 讀相對路徑落空(回NOTFOUND且ok:true, 靜默失敗),
// 允許寫檔時檔案落在父進程目錄(本專案曾因此於根目錄留下金絲雀孤兒檔, 當時誤判為「以git根目錄為準」)。
// 實測(cwd指向探針目錄, 父PWD=專案根): 不處理→working directory為專案根、讀MARKER.txt失敗;
// 注入PWD=cwd→正確; 帶--dir亦正確。採注入PWD: 不動命令列(與--attach等extraArgs無衝突), 語意即shell
// 於該目錄啟動程式時之環境; 且以本函數之值蓋過opt.env之PWD(呼叫端常整份展開process.env, 若讓其覆寫
// 則缺陷靜默回歸)。claude與codex以同組探針實測皆遵循spawn cwd, 不受影響, 故只在本轉接器處理。
//
// 【Zen免費層閘門與config注入(2026-09-18實測)】opencode自2026-09-17起限制免費模型只准在opencode本體內使用
// (403 FreeTierError: free tier can only be used from within OpenCode)。CLI本身可通, 但經OPENCODE_CONFIG_CONTENT
// 注入之設定若把bash工具deny掉(permission.bash:'deny'、tools.bash:false、agent覆寫deny), 請求之工具清單
// 少了bash即被判非opencode而403; 改permission.bash:'ask'則通過, 且非互動run下ask自動拒絕, 仍為機械防寫。
// 詳見providers.mjs檔頭【Zen免費層閘門】與OC_READONLY常數。
//
// 【Zen免費層429限流時run不會結束(2026-09-23實測, 1.18.32)】REST探測0.7s即回429 FreeUsageLimitError
// (Rate limit exceeded), 但`opencode run`收到同一錯誤後不退出: 預設stderr只有session標頭, 直到呼叫端逾時;
// 帶--print-logs --log-level DEBUG可見`stream error ... AI_APICallError: Rate limit exceeded`, 其後再無輸出。
// 不經本套件直接跑原始CLI亦同, 非本轉接器所致。對本套件之意義: 此情形以TIMEOUT回報(errorType 'timeout'),
// dispatchAiFallback內建之TIMEOUT冷卻觸發可涵蓋, 但每次嘗試都耗滿timeoutMs——免費oc:條目宜以
// resolveProviders之patch給較短timeoutMs; 需在stderr看到字樣(供coolDetect)時於extraArgs加
// --print-logs --log-level ERROR。
//
// 【不讀auth.json之匿名呼叫: useStoredAuth:false(2026-09-17實測後新增)】
// 未注入金鑰時opencode會自動沿用auth.json之登入; 該帳號工作區若未開某免費模型(如union-alpha),
// 就回`Error: Model is disabled`, 而同一模型在「無auth.json」之機器上匿名可用——結果隨執行機器而異。
// useStoredAuth:false時注入OPENCODE_AUTH_CONTENT='{}'(空憑證), 令本次不讀auth.json而以匿名免費存取。
// 實測(以XDG_DATA_HOME指向內含opencode金鑰之暫存auth.json): 不帶key→Model is disabled;
// 加'{}'→7.0s成功; 改注入''→仍Model is disabled(空字串無效, 故不可讓呼叫端自填); auth.json前後md5一致。
// 同時給key與provider時金鑰注入本就整份取代auth.json, 此旗標不再作用。
// 風險備忘: 原始碼Auth.set會以Auth.all()(即注入內容)為底寫回auth.json, 僅於OAuth權杖刷新等寫入時發生;
// api型金鑰與空憑證皆無刷新, 實測未觸發。
//
// 【provider與model必須配對】不同provider的金鑰不可互換:
// 實測把agnes-ai的金鑰用於opencode/...模型 → `Invalid API key`。
// 故呼叫端傳入的key/provider/model須為同一組。
//
// 【第三方provider須另給config(2026-08-09於本機實測確認)】
// opencode僅內建自家provider,第三方(例如agnes-ai)未定義於設定檔時,
// 只注入金鑰仍無法使用:實測`opencode models`不列出該provider之模型,
// 直接指定模型則回`UnknownError/Unexpected server error`。
// OPENCODE_CONFIG_CONTENT環境變數可逐次注入設定內容,補上provider定義後
// `opencode models`即列出agnes-ai/agnes-2.0-flash且可正常對話,
// 與OPENCODE_AUTH_CONTENT同為process層級,不改寫使用者設定檔。
//預設值
let DEFAULT_EXE = 'opencode'
let DEFAULT_AGENT = 'build'
//本轉接器自用之設定鍵, 其餘鍵一律原樣轉傳execCli
let OWN_KEYS = ['exe', 'model', 'key', 'provider', 'useStoredAuth', 'agent', 'config', 'extraArgs', 'input', 'env']
/**
* 以opencode CLI呼叫AI模型
*
* 特點:
* prompt一律走stdin而非位置參數,因摘要內文可達數萬字,當命令列參數會spawn ENAMETOOLONG,
* 而opencode run未帶位置message時即從stdin讀取;
* 同時給予key與provider時,以OPENCODE_AUTH_CONTENT環境變數逐次注入金鑰,
* 該注入僅作用於當次子進程且不改寫auth.json,故可多把金鑰輪替並與其他程序並行;
* 未給key或provider時沿用CLI既有登入狀態(auth.json),useStoredAuth為false則改以匿名存取;
* 使用opencode未內建之第三方provider時,須另以config給予其provider定義;
* 本函數不會reject,一律以結果物件之ok與error欄位回報成敗
*
* @param {String} prompt 輸入提示詞字串,一律以stdin傳入子進程
* @param {Object} [opt={}] 輸入設定物件,預設{}
* @param {String} [opt.exe='opencode'] 輸入opencode執行檔名稱或絕對路徑字串,給予名稱時由execCli自系統PATH解析,預設'opencode'
* @param {String} [opt.model=''] 輸入模型ID字串,例如'opencode/muse-spark-1.3-contributor-free',預設''代表不帶`-m`旗標
* @param {String} [opt.key=''] 輸入該provider之API key字串,須與provider同時給予才會注入,預設''代表沿用CLI既有登入狀態
* @param {String} [opt.provider=''] 輸入key所屬provider名稱字串,須與key同時給予才會注入,且須與model為同一組,預設''
* @param {Boolean} [opt.useStoredAuth=true] 輸入未注入金鑰時是否沿用本機auth.json之登入布林值,false代表注入空憑證令本次以匿名存取(適用opencode之免費模型,避免結果隨本機登入帳號而異);已同時給key與provider時不作用,預設true
* @param {Object|String} [opt.config=null] 輸入opencode設定內容物件或其JSON字串,將以OPENCODE_CONFIG_CONTENT逐次注入,供補上第三方provider之定義,預設null代表沿用使用者既有設定檔
* @param {String} [opt.agent='build'] 輸入opencode代理名稱字串,預設'build'
* @param {Array} [opt.extraArgs=[]] 輸入額外命令列旗標字串陣列,將接於固定旗標之後,預設[]
* @param {Object} [opt.env=undefined] 輸入本次調用額外注入之環境變數物件,同時給予key與provider時會再併入OPENCODE_AUTH_CONTENT,預設undefined
* @param {Number} [opt.timeoutMs=300000] 輸入逾時毫秒正整數,逾時將強制關閉子進程及其子孫程序,全套件統一預設300000
* @param {String} [opt.cwd=process.cwd()] 輸入子進程工作目錄字串,預設process.cwd();其絕對路徑並同步注入環境變數PWD(opencode以繼承之PWD優先於真實cwd決定session目錄,見檔頭),呼叫端env內之PWD會被覆寫
* @param {String|Function} [opt.validate=undefined] 輸入stdout驗證規則字串或自訂驗證函數,規則字串支援'nonempty'、'json'、'min:100',多規則可用逗號串接,預設undefined代表不驗證
* @param {Number} [opt.maxRetries=0] 輸入失敗後最大重試次數非負整數,預設0
* @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok(是否成功布林值)、stdout(標準輸出字串)、stderr(標準錯誤字串)、code(離開碼)、error(錯誤訊息字串,成功時為空字串)、durationMs(耗時毫秒)、attempts(實際嘗試次數),本函數不會reject
* @example
* //need opencode cli in system PATH
*
* import dispatchOpencode from './src/dispatchOpencode.mjs'
*
* let test = async () => {
*
* //沿用CLI既有登入狀態(auth.json)
* let r1 = await dispatchOpencode('請只回覆兩個字:完成', { model: 'opencode/muse-spark-1.3-contributor-free' })
* console.log(r1.ok, r1.stdout.includes('完成'))
* // => true true
*
* //不沿用本機登入之匿名存取(opencode自家免費模型建議如此, 見檔頭【Zen免費層閘門】)
* let r2 = await dispatchOpencode('請只回覆兩個字:完成', {
* model: 'opencode/muse-spark-1.3-contributor-free',
* useStoredAuth: false,
* })
* console.log(r2.ok)
* // => true
*
* //opencode未內建之第三方provider, 須另以config給予其定義
* let r3 = await dispatchOpencode('請只回覆兩個字:完成', {
* model: 'agnes-ai/agnes-2.0-flash',
* provider: 'agnes-ai',
* key: 'sk-xxxxxx',
* config: {
* provider: {
* 'agnes-ai': {
* npm: '@ai-sdk/openai-compatible',
* name: 'Agnes',
* options: { baseURL: 'https://apihub.agnes-ai.com/v1' },
* models: { 'agnes-2.0-flash': { name: 'Agnes 2.0 Flash' } },
* },
* },
* },
* })
* console.log(r3.ok)
* // => true
*
* }
* await test()
* .catch((err) => {
* console.log(err)
* })
*
*/
async function dispatchOpencode(prompt, opt = {}) {
//check prompt, 不reject故以錯誤結果物件回報
if (!isestr(prompt)) {
return getErrorResult('prompt must be a non-empty string')
}
//exe, 無效回退預設'opencode', 由execCli自系統PATH解析實體路徑
let exe = get(opt, 'exe', null)
if (!isestr(exe)) {
exe = DEFAULT_EXE
}
//model, 無效時整段`-m`旗標不出現, 由CLI自行決定使用模型
let model = get(opt, 'model', null)
//agent, 無效回退預設'build'
let agent = get(opt, 'agent', null)
if (!isestr(agent)) {
agent = DEFAULT_AGENT
}
//extraArgs
let extraArgs = get(opt, 'extraArgs', null)
//args
let args = getCliArgs(
'run',
['--agent', agent],
isestr(model) ? ['-m', model] : [],
extraArgs,
)
//env, 無效回退undefined代表不覆寫任何變數
let env = get(opt, 'env', null)
if (!isobj(env)) {
env = undefined
}
//config有效才注入, 否則沿用使用者既有設定檔, 物件則序列化為JSON字串
let config = get(opt, 'config', null)
if (isobj(config)) {
config = JSON.stringify(config)
}
if (isestr(config)) {
env = {
...env,
OPENCODE_CONFIG_CONTENT: config,
}
}
//key與provider皆有效才注入, 否則沿用auth.json既有登入狀態(單金鑰時即為原有行為);
//useStoredAuth為false且未注入金鑰時, 注入'{}'令opencode本次不讀auth.json(匿名, 見檔頭)
let key = get(opt, 'key', null)
let provider = get(opt, 'provider', null)
let useStoredAuth = get(opt, 'useStoredAuth', null) !== false
if (isestr(key) && isestr(provider)) {
env = {
...env,
OPENCODE_AUTH_CONTENT: JSON.stringify({ [provider]: { type: 'api', key } }),
}
}
else if (!useStoredAuth) {
env = {
...env,
OPENCODE_AUTH_CONTENT: '{}',
}
}
//PWD同步為有效cwd之絕對路徑(有給cwd則解析之, 否則process.cwd(), 與execCli之spawn cwd同源),
//以本值蓋過呼叫端env之PWD——opencode以繼承之PWD優先於真實cwd決定session目錄(見檔頭【cwd須同步PWD】)
let cwdEff = get(opt, 'cwd', null)
cwdEff = isestr(cwdEff) ? path.resolve(cwdEff) : process.cwd()
env = {
...env,
PWD: cwdEff,
}
//timeoutMs, 無效回退全套件統一預設(dfTimeoutMs=300000), 各轉接器一致令呼叫方無須記多套數字
let timeoutMs = castPintOr(get(opt, 'timeoutMs', null), dfTimeoutMs)
//optCli, 剔除本轉接器自用鍵後原樣轉傳, 令呼叫端可用execCli全部設定(例如onStdout、maxBuffer)
let optCli = omit(opt, OWN_KEYS)
//execCli, prompt一律走stdin; 失敗結果補上機器可讀之errorType(僅機械可判者, 見getErrorType.mjs)
let r = await execCli(exe, args, {
...optCli,
input: prompt,
env,
timeoutMs,
})
return attachErrorType(r)
}
export default dispatchOpencode