dispatchOpencode.mjs

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