w-dispatch-ai 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.editorconfig +9 -0
- package/.eslintignore +3 -0
- package/.eslintrc.js +55 -0
- package/.jsdoc +25 -0
- package/LICENSE +21 -0
- package/README.md +207 -0
- package/SECURITY.md +5 -0
- package/babel.config.js +16 -0
- package/dist/w-dispatch-ai.umd.js +7 -0
- package/dist/w-dispatch-ai.umd.js.map +1 -0
- package/docs/WDispatchAi.mjs.html +110 -0
- package/docs/adapters.mjs.html +100 -0
- package/docs/dispatchAi.mjs.html +133 -0
- package/docs/dispatchClaude.mjs.html +190 -0
- package/docs/dispatchCodex.mjs.html +191 -0
- package/docs/dispatchOpencode.mjs.html +260 -0
- package/docs/fonts/Montserrat/Montserrat-Bold.eot +0 -0
- package/docs/fonts/Montserrat/Montserrat-Bold.ttf +0 -0
- package/docs/fonts/Montserrat/Montserrat-Bold.woff +0 -0
- package/docs/fonts/Montserrat/Montserrat-Bold.woff2 +0 -0
- package/docs/fonts/Montserrat/Montserrat-Regular.eot +0 -0
- package/docs/fonts/Montserrat/Montserrat-Regular.ttf +0 -0
- package/docs/fonts/Montserrat/Montserrat-Regular.woff +0 -0
- package/docs/fonts/Montserrat/Montserrat-Regular.woff2 +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.eot +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.svg +978 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.ttf +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff2 +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.eot +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.svg +1049 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.ttf +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff2 +0 -0
- package/docs/getCliArgs.mjs.html +126 -0
- package/docs/getErrorResult.mjs.html +115 -0
- package/docs/global.html +2924 -0
- package/docs/index.html +84 -0
- package/docs/scripts/collapse.js +39 -0
- package/docs/scripts/commonNav.js +28 -0
- package/docs/scripts/linenumber.js +25 -0
- package/docs/scripts/nav.js +12 -0
- package/docs/scripts/polyfill.js +4 -0
- package/docs/scripts/prettify/Apache-License-2.0.txt +202 -0
- package/docs/scripts/prettify/lang-css.js +2 -0
- package/docs/scripts/prettify/prettify.js +28 -0
- package/docs/scripts/search.js +99 -0
- package/docs/styles/jsdoc.css +776 -0
- package/docs/styles/prettify.css +80 -0
- package/g.mjs +94 -0
- package/package.json +38 -0
- package/script.txt +17 -0
- package/src/WDispatchAi.mjs +38 -0
- package/src/adapters.mjs +28 -0
- package/src/dispatchAi.mjs +61 -0
- package/src/dispatchClaude.mjs +118 -0
- package/src/dispatchCodex.mjs +119 -0
- package/src/dispatchOpencode.mjs +188 -0
- package/src/getCliArgs.mjs +54 -0
- package/src/getErrorResult.mjs +43 -0
- package/test/tools/fakeCliForTest.mjs +139 -0
- package/test/unit-WDispatchAi.test.mjs +79 -0
- package/test/unit-adapters.test.mjs +34 -0
- package/test/unit-dispatchAi.test.mjs +104 -0
- package/test/unit-dispatchClaude.test.mjs +144 -0
- package/test/unit-dispatchCodex.test.mjs +113 -0
- package/test/unit-dispatchOpencode.test.mjs +176 -0
- package/test/unit-getCliArgs.test.mjs +55 -0
- package/test/unit-getErrorResult.test.mjs +45 -0
- package/toolg/addVersion.mjs +4 -0
- package/toolg/cleanFolder.mjs +5 -0
- package/toolg/gDistRollup.mjs +24 -0
- package/toolg/modifyReadme.mjs +4 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import get from 'lodash-es/get.js'
|
|
2
|
+
import omit from 'lodash-es/omit.js'
|
|
3
|
+
import isobj from 'wsemi/src/isobj.mjs'
|
|
4
|
+
import isestr from 'wsemi/src/isestr.mjs'
|
|
5
|
+
import execCli from 'wsemi/src/execCli.mjs'
|
|
6
|
+
import getCliArgs from './getCliArgs.mjs'
|
|
7
|
+
import getErrorResult from './getErrorResult.mjs'
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
// dispatchOpencode.mjs — 以opencode CLI呼叫AI模型
|
|
11
|
+
//
|
|
12
|
+
// 【金鑰注入(2026-08-08於本機實測確認,非讀碼推論)】
|
|
13
|
+
// OPENCODE_AUTH_CONTENT環境變數會「完全覆蓋」opencode的auth.json:
|
|
14
|
+
// 實測帶入無效key → `Error: Invalid API key.`(code=1、訊息在stderr、stdout空);
|
|
15
|
+
// 帶入真實key → 正常回應。以env傳入「不會改寫」auth.json(實測md5前後一致),
|
|
16
|
+
// 屬process層級,可與同時執行的其他專案並行而互不干擾。
|
|
17
|
+
// 註:OPENCODE_API_KEY在auth.json已有憑證時不生效,故一律走OPENCODE_AUTH_CONTENT。
|
|
18
|
+
//
|
|
19
|
+
// 【provider與model必須配對】不同provider的金鑰不可互換:
|
|
20
|
+
// 實測把agnes-ai的金鑰用於opencode/...模型 → `Invalid API key`。
|
|
21
|
+
// 故呼叫端傳入的key/provider/model須為同一組。
|
|
22
|
+
//
|
|
23
|
+
// 【第三方provider須另給config(2026-08-09於本機實測確認)】
|
|
24
|
+
// opencode僅內建自家provider,第三方(例如agnes-ai)未定義於設定檔時,
|
|
25
|
+
// 只注入金鑰仍無法使用:實測`opencode models`不列出該provider之模型,
|
|
26
|
+
// 直接指定模型則回`UnknownError/Unexpected server error`。
|
|
27
|
+
// OPENCODE_CONFIG_CONTENT環境變數可逐次注入設定內容,補上provider定義後
|
|
28
|
+
// `opencode models`即列出agnes-ai/agnes-2.0-flash且可正常對話,
|
|
29
|
+
// 與OPENCODE_AUTH_CONTENT同為process層級,不改寫使用者設定檔。
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
//預設值
|
|
33
|
+
let DEFAULT_EXE = 'opencode'
|
|
34
|
+
let DEFAULT_AGENT = 'build'
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
//本轉接器自用之設定鍵, 其餘鍵一律原樣轉傳execCli
|
|
38
|
+
let OWN_KEYS = ['exe', 'model', 'key', 'provider', 'agent', 'config', 'extraArgs', 'input', 'env']
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 以opencode CLI呼叫AI模型
|
|
43
|
+
*
|
|
44
|
+
* 特點:
|
|
45
|
+
* prompt一律走stdin而非位置參數,因摘要內文可達數萬字,當命令列參數會spawn ENAMETOOLONG,
|
|
46
|
+
* 而opencode run未帶位置message時即從stdin讀取;
|
|
47
|
+
* 同時給予key與provider時,以OPENCODE_AUTH_CONTENT環境變數逐次注入金鑰,
|
|
48
|
+
* 該注入僅作用於當次子進程且不改寫auth.json,故可多把金鑰輪替並與其他程序並行;
|
|
49
|
+
* 未給key或provider時沿用CLI既有登入狀態;
|
|
50
|
+
* 使用opencode未內建之第三方provider時,須另以config給予其provider定義;
|
|
51
|
+
* 本函數不會reject,一律以結果物件之ok與error欄位回報成敗
|
|
52
|
+
*
|
|
53
|
+
* @param {String} prompt 輸入提示詞字串,一律以stdin傳入子進程
|
|
54
|
+
* @param {Object} [opt={}] 輸入設定物件,預設{}
|
|
55
|
+
* @param {String} [opt.exe='opencode'] 輸入opencode執行檔名稱或絕對路徑字串,給予名稱時由execCli自系統PATH解析,預設'opencode'
|
|
56
|
+
* @param {String} [opt.model=''] 輸入模型ID字串,例如'opencode/deepseek-v4-flash-free',預設''代表不帶`-m`旗標
|
|
57
|
+
* @param {String} [opt.key=''] 輸入該provider之API key字串,須與provider同時給予才會注入,預設''代表沿用CLI既有登入狀態
|
|
58
|
+
* @param {String} [opt.provider=''] 輸入key所屬provider名稱字串,須與key同時給予才會注入,且須與model為同一組,預設''
|
|
59
|
+
* @param {Object|String} [opt.config=null] 輸入opencode設定內容物件或其JSON字串,將以OPENCODE_CONFIG_CONTENT逐次注入,供補上第三方provider之定義,預設null代表沿用使用者既有設定檔
|
|
60
|
+
* @param {String} [opt.agent='build'] 輸入opencode代理名稱字串,預設'build'
|
|
61
|
+
* @param {Array} [opt.extraArgs=[]] 輸入額外命令列旗標字串陣列,將接於固定旗標之後,預設[]
|
|
62
|
+
* @param {Object} [opt.env=undefined] 輸入本次調用額外注入之環境變數物件,同時給予key與provider時會再併入OPENCODE_AUTH_CONTENT,預設undefined
|
|
63
|
+
* @param {Number} [opt.timeoutMs=120000] 輸入逾時毫秒正整數,逾時將強制關閉子進程及其子孫程序,預設120000
|
|
64
|
+
* @param {String} [opt.cwd=process.cwd()] 輸入子進程工作目錄字串,預設process.cwd()
|
|
65
|
+
* @param {String|Function} [opt.validate=undefined] 輸入stdout驗證規則字串或自訂驗證函數,規則字串支援'nonempty'、'json'、'min:100',多規則可用逗號串接,預設undefined代表不驗證
|
|
66
|
+
* @param {Number} [opt.maxRetries=0] 輸入失敗後最大重試次數非負整數,預設0
|
|
67
|
+
* @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok(是否成功布林值)、stdout(標準輸出字串)、stderr(標準錯誤字串)、code(離開碼)、error(錯誤訊息字串,成功時為空字串)、durationMs(耗時毫秒)、attempts(實際嘗試次數),本函數不會reject
|
|
68
|
+
* @example
|
|
69
|
+
* //need opencode cli in system PATH
|
|
70
|
+
*
|
|
71
|
+
* import dispatchOpencode from './src/dispatchOpencode.mjs'
|
|
72
|
+
*
|
|
73
|
+
* let test = async () => {
|
|
74
|
+
*
|
|
75
|
+
* //沿用CLI既有登入狀態
|
|
76
|
+
* let r1 = await dispatchOpencode('請只回覆兩個字:完成', { model: 'opencode/deepseek-v4-flash-free' })
|
|
77
|
+
* console.log(r1.ok, r1.stdout.includes('完成'))
|
|
78
|
+
* // => true true
|
|
79
|
+
*
|
|
80
|
+
* //逐次注入金鑰, key與provider與model須為同一組
|
|
81
|
+
* let r2 = await dispatchOpencode('請只回覆兩個字:完成', {
|
|
82
|
+
* model: 'opencode/deepseek-v4-flash-free',
|
|
83
|
+
* provider: 'opencode',
|
|
84
|
+
* key: 'sk-xxxxxx',
|
|
85
|
+
* })
|
|
86
|
+
* console.log(r2.ok)
|
|
87
|
+
* // => true
|
|
88
|
+
*
|
|
89
|
+
* //opencode未內建之第三方provider, 須另以config給予其定義
|
|
90
|
+
* let r3 = await dispatchOpencode('請只回覆兩個字:完成', {
|
|
91
|
+
* model: 'agnes-ai/agnes-2.0-flash',
|
|
92
|
+
* provider: 'agnes-ai',
|
|
93
|
+
* key: 'sk-xxxxxx',
|
|
94
|
+
* config: {
|
|
95
|
+
* provider: {
|
|
96
|
+
* 'agnes-ai': {
|
|
97
|
+
* npm: '@ai-sdk/openai-compatible',
|
|
98
|
+
* name: 'Agnes',
|
|
99
|
+
* options: { baseURL: 'https://apihub.agnes-ai.com/v1' },
|
|
100
|
+
* models: { 'agnes-2.0-flash': { name: 'Agnes 2.0 Flash' } },
|
|
101
|
+
* },
|
|
102
|
+
* },
|
|
103
|
+
* },
|
|
104
|
+
* })
|
|
105
|
+
* console.log(r3.ok)
|
|
106
|
+
* // => true
|
|
107
|
+
*
|
|
108
|
+
* }
|
|
109
|
+
* await test()
|
|
110
|
+
* .catch((err) => {
|
|
111
|
+
* console.log(err)
|
|
112
|
+
* })
|
|
113
|
+
*
|
|
114
|
+
*/
|
|
115
|
+
async function dispatchOpencode(prompt, opt = {}) {
|
|
116
|
+
|
|
117
|
+
//check prompt, 不reject故以錯誤結果物件回報
|
|
118
|
+
if (!isestr(prompt)) {
|
|
119
|
+
return getErrorResult('prompt must be a non-empty string')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
//exe, 無效回退預設'opencode', 由execCli自系統PATH解析實體路徑
|
|
123
|
+
let exe = get(opt, 'exe', null)
|
|
124
|
+
if (!isestr(exe)) {
|
|
125
|
+
exe = DEFAULT_EXE
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
//model, 無效時整段`-m`旗標不出現, 由CLI自行決定使用模型
|
|
129
|
+
let model = get(opt, 'model', null)
|
|
130
|
+
|
|
131
|
+
//agent, 無效回退預設'build'
|
|
132
|
+
let agent = get(opt, 'agent', null)
|
|
133
|
+
if (!isestr(agent)) {
|
|
134
|
+
agent = DEFAULT_AGENT
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
//extraArgs
|
|
138
|
+
let extraArgs = get(opt, 'extraArgs', null)
|
|
139
|
+
|
|
140
|
+
//args
|
|
141
|
+
let args = getCliArgs(
|
|
142
|
+
'run',
|
|
143
|
+
['--agent', agent],
|
|
144
|
+
isestr(model) ? ['-m', model] : [],
|
|
145
|
+
extraArgs,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
//env, 無效回退undefined代表不覆寫任何變數
|
|
149
|
+
let env = get(opt, 'env', null)
|
|
150
|
+
if (!isobj(env)) {
|
|
151
|
+
env = undefined
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
//config有效才注入, 否則沿用使用者既有設定檔, 物件則序列化為JSON字串
|
|
155
|
+
let config = get(opt, 'config', null)
|
|
156
|
+
if (isobj(config)) {
|
|
157
|
+
config = JSON.stringify(config)
|
|
158
|
+
}
|
|
159
|
+
if (isestr(config)) {
|
|
160
|
+
env = {
|
|
161
|
+
...env,
|
|
162
|
+
OPENCODE_CONFIG_CONTENT: config,
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
//key與provider皆有效才注入, 否則沿用auth.json既有登入狀態(單金鑰時即為原有行為)
|
|
167
|
+
let key = get(opt, 'key', null)
|
|
168
|
+
let provider = get(opt, 'provider', null)
|
|
169
|
+
if (isestr(key) && isestr(provider)) {
|
|
170
|
+
env = {
|
|
171
|
+
...env,
|
|
172
|
+
OPENCODE_AUTH_CONTENT: JSON.stringify({ [provider]: { type: 'api', key } }),
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
//optCli, 剔除本轉接器自用鍵後原樣轉傳, 令呼叫端可用execCli全部設定(例如onStdout、maxBuffer)
|
|
177
|
+
let optCli = omit(opt, OWN_KEYS)
|
|
178
|
+
|
|
179
|
+
//execCli, prompt一律走stdin
|
|
180
|
+
return execCli(exe, args, {
|
|
181
|
+
...optCli,
|
|
182
|
+
input: prompt,
|
|
183
|
+
env,
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
export default dispatchOpencode
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import isarr from 'wsemi/src/isarr.mjs'
|
|
2
|
+
import isestr from 'wsemi/src/isestr.mjs'
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 將各段命令列參數展平為字串陣列,並濾除非有效字串
|
|
7
|
+
*
|
|
8
|
+
* 各轉接器之參數為「固定旗標」加「可選旗標」加「額外旗標」之組合,
|
|
9
|
+
* 其中可選旗標於未給值時須整段消失(例如未給model就不可出現懸空的`--model`),
|
|
10
|
+
* 故呼叫端須以「整段陣列給或不給」表達,本函數僅負責展平與濾除非有效字串,不判斷旗標配對;
|
|
11
|
+
* 過濾之必要在於Nodejs之spawn要求各參數必為字串,混入undefined或數字會直接拋出TypeError,
|
|
12
|
+
* 破壞本套件「不reject、僅以結果物件回報」之約定
|
|
13
|
+
*
|
|
14
|
+
* @param {...(String|Array)} args 輸入參數字串或參數字串陣列,可給多個
|
|
15
|
+
* @returns {Array} 回傳展平且濾除非有效字串後之參數字串陣列
|
|
16
|
+
* @example
|
|
17
|
+
*
|
|
18
|
+
* import getCliArgs from './src/getCliArgs.mjs'
|
|
19
|
+
*
|
|
20
|
+
* console.log(getCliArgs('-p', ['--model', 'sonnet']))
|
|
21
|
+
* // => ['-p', '--model', 'sonnet']
|
|
22
|
+
*
|
|
23
|
+
* console.log(getCliArgs('-p', null, 123, ['', '--verbose']))
|
|
24
|
+
* // => ['-p', '--verbose']
|
|
25
|
+
*
|
|
26
|
+
*/
|
|
27
|
+
function getCliArgs(...args) {
|
|
28
|
+
|
|
29
|
+
let r = []
|
|
30
|
+
|
|
31
|
+
for (let arg of args) {
|
|
32
|
+
|
|
33
|
+
//陣列則逐項展開, 僅展一層
|
|
34
|
+
if (isarr(arg)) {
|
|
35
|
+
for (let v of arg) {
|
|
36
|
+
if (isestr(v)) {
|
|
37
|
+
r.push(v)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
//非陣列即視為單一參數
|
|
44
|
+
if (isestr(arg)) {
|
|
45
|
+
r.push(arg)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return r
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
export default getCliArgs
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import isestr from 'wsemi/src/isestr.mjs'
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 產生與execCli同結構之錯誤結果物件
|
|
6
|
+
*
|
|
7
|
+
* 本套件各dispatch函數一律不reject,參數檢核失敗時即以本函數回傳錯誤結果物件,
|
|
8
|
+
* 其欄位與wsemi之execCli回傳結構一致,故呼叫端可用同一套欄位判斷成敗,
|
|
9
|
+
* 無須區分「參數錯誤」與「CLI執行失敗」兩種來源
|
|
10
|
+
*
|
|
11
|
+
* @param {String} error 輸入錯誤訊息字串
|
|
12
|
+
* @returns {Object} 回傳結果物件,內含ok(布林值,恆為false)、stdout(空字串)、stderr(空字串)、code(null)、error(錯誤訊息字串)、durationMs(0)、attempts(0)
|
|
13
|
+
* @example
|
|
14
|
+
*
|
|
15
|
+
* import getErrorResult from './src/getErrorResult.mjs'
|
|
16
|
+
*
|
|
17
|
+
* console.log(getErrorResult('prompt must be a non-empty string'))
|
|
18
|
+
* // => { ok: false, stdout: '', stderr: '', code: null, error: 'prompt must be a non-empty string', durationMs: 0, attempts: 0 }
|
|
19
|
+
*
|
|
20
|
+
* console.log(getErrorResult(null).error)
|
|
21
|
+
* // => 'unknown error'
|
|
22
|
+
*
|
|
23
|
+
*/
|
|
24
|
+
function getErrorResult(error) {
|
|
25
|
+
|
|
26
|
+
//check, 非有效字串時給予預設訊息, 確保error欄位恆為非空字串
|
|
27
|
+
if (!isestr(error)) {
|
|
28
|
+
error = 'unknown error'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
ok: false,
|
|
33
|
+
stdout: '',
|
|
34
|
+
stderr: '',
|
|
35
|
+
code: null,
|
|
36
|
+
error,
|
|
37
|
+
durationMs: 0,
|
|
38
|
+
attempts: 0,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
export default getErrorResult
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import { fileURLToPath } from 'url'
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
// fakeCliForTest.mjs — 測試用的假CLI
|
|
7
|
+
//
|
|
8
|
+
// 各dispatch函數之可觀察行為為「以什麼參數、什麼stdin、什麼環境變數啟動子進程」,
|
|
9
|
+
// 故產生一支會把args/stdin/env原樣印回stdout(JSON)的假CLI,即可對規格逐條斷言,
|
|
10
|
+
// 而無須真的呼叫claude/codex/opencode(耗時、需登入、輸出不決定性)。
|
|
11
|
+
//
|
|
12
|
+
// 【為何要做成npm shim形狀】Windows下wsemi之execCli遇.cmd會先解析出JS入口再直接以node執行,
|
|
13
|
+
// 解析不到才退回cmd.exe。做成npm全域安裝之shim形狀(入口置於node_modules下)即走前者,
|
|
14
|
+
// 可完全避開cmd.exe之編碼與跳脫問題,令中文與多行參數皆可正確傳遞。
|
|
15
|
+
//
|
|
16
|
+
// 【產物落點】產物落在本檔所在之test資料夾下(test/tmp/),不落主cwd根目錄,
|
|
17
|
+
// 避免與專案根之./tmp/混用而難以清理;測試結束由clean()逐一清除。
|
|
18
|
+
// 路徑由本檔位置衍生而非cwd-relative,故不論由何處啟動mocha皆落在同一處。
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
//fdBase, 由本檔位置衍生(test/tools/ → test/tmp/), 屬「產物落在腳本所在資料夾」之情境
|
|
22
|
+
//URL轉實體路徑只可用fileURLToPath, 不可用new URL(...).pathname(會回URL-encoded字串)
|
|
23
|
+
let FD_BASE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'tmp')
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
//假CLI本體, 由node執行
|
|
27
|
+
let CODE_ENTRY = `
|
|
28
|
+
let args = process.argv.slice(2)
|
|
29
|
+
|
|
30
|
+
//--fake-exit=N, 令假CLI以指定離開碼結束, 供測試失敗與重試路徑
|
|
31
|
+
let mExit = args.map((a) => /^--fake-exit=(\\d+)$/.exec(a)).find((m) => !!m)
|
|
32
|
+
let exitCode = mExit ? Number(mExit[1]) : 0
|
|
33
|
+
|
|
34
|
+
//--fake-stderr=XXX, 令假CLI輸出指定文字至stderr
|
|
35
|
+
let mErr = args.map((a) => /^--fake-stderr=(.*)$/.exec(a)).find((m) => !!m)
|
|
36
|
+
if (mErr) {
|
|
37
|
+
process.stderr.write(mErr[1])
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
//--fake-sleep=N, 令假CLI延遲N毫秒才結束, 供測試逾時路徑
|
|
41
|
+
let mSleep = args.map((a) => /^--fake-sleep=(\\d+)$/.exec(a)).find((m) => !!m)
|
|
42
|
+
|
|
43
|
+
let chunks = []
|
|
44
|
+
process.stdin.on('data', (c) => {
|
|
45
|
+
chunks.push(c)
|
|
46
|
+
})
|
|
47
|
+
process.stdin.on('end', () => {
|
|
48
|
+
let r = {
|
|
49
|
+
args,
|
|
50
|
+
stdin: Buffer.concat(chunks).toString('utf8'),
|
|
51
|
+
cwd: process.cwd(),
|
|
52
|
+
env: {
|
|
53
|
+
OPENCODE_AUTH_CONTENT: process.env.OPENCODE_AUTH_CONTENT || '',
|
|
54
|
+
OPENCODE_CONFIG_CONTENT: process.env.OPENCODE_CONFIG_CONTENT || '',
|
|
55
|
+
FAKE_ENV: process.env.FAKE_ENV || '',
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
let end = () => {
|
|
59
|
+
process.stdout.write(JSON.stringify(r))
|
|
60
|
+
process.exit(exitCode)
|
|
61
|
+
}
|
|
62
|
+
if (mSleep) {
|
|
63
|
+
setTimeout(end, Number(mSleep[1]))
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
end()
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
`
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 產生一支測試用的假CLI,回傳其執行檔路徑與清除函數
|
|
74
|
+
*
|
|
75
|
+
* @param {String} name 輸入假CLI名稱字串,各測試檔須給予不同名稱,避免mocha並行執行時互相干擾
|
|
76
|
+
* @returns {Object} 回傳物件,內含exe(假CLI執行檔絕對路徑字串)、fd(產物資料夾絕對路徑字串)、clean(清除產物之函數)
|
|
77
|
+
*/
|
|
78
|
+
function createFakeCli(name) {
|
|
79
|
+
|
|
80
|
+
//fd, 印出absolute路徑供除錯驗收
|
|
81
|
+
let fd = path.resolve(FD_BASE, name)
|
|
82
|
+
|
|
83
|
+
//fdEntry, 做成npm shim形狀, 入口須置於node_modules下才會被execCli解析出來
|
|
84
|
+
let fdEntry = path.join(fd, 'node_modules', name)
|
|
85
|
+
fs.mkdirSync(fdEntry, { recursive: true })
|
|
86
|
+
|
|
87
|
+
//entry
|
|
88
|
+
let fpEntry = path.join(fdEntry, 'entry.mjs')
|
|
89
|
+
fs.writeFileSync(fpEntry, CODE_ENTRY, 'utf8')
|
|
90
|
+
|
|
91
|
+
//exe
|
|
92
|
+
let exe = ''
|
|
93
|
+
if (process.platform === 'win32') {
|
|
94
|
+
|
|
95
|
+
//Windows, npm全域安裝之.cmd shim形狀
|
|
96
|
+
exe = path.join(fd, `${name}.cmd`)
|
|
97
|
+
let code = [
|
|
98
|
+
'@ECHO off',
|
|
99
|
+
'SETLOCAL',
|
|
100
|
+
'CALL :find_dp0',
|
|
101
|
+
'SET "_prog=node"',
|
|
102
|
+
`"%_prog%" "%dp0%\\node_modules\\${name}\\entry.mjs" %*`,
|
|
103
|
+
'ENDLOCAL',
|
|
104
|
+
'EXIT /b %errorlevel%',
|
|
105
|
+
':find_dp0',
|
|
106
|
+
'SET dp0=%~dp0',
|
|
107
|
+
'EXIT /b',
|
|
108
|
+
'',
|
|
109
|
+
].join('\r\n')
|
|
110
|
+
fs.writeFileSync(exe, code, 'utf8')
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
|
|
114
|
+
//POSIX, sh啟動器, node以絕對路徑給予避免PATH差異
|
|
115
|
+
exe = path.join(fd, name)
|
|
116
|
+
let code = [
|
|
117
|
+
'#!/bin/sh',
|
|
118
|
+
`exec "${process.execPath}" "$(dirname "$0")/node_modules/${name}/entry.mjs" "$@"`,
|
|
119
|
+
'',
|
|
120
|
+
].join('\n')
|
|
121
|
+
fs.writeFileSync(exe, code, 'utf8')
|
|
122
|
+
fs.chmodSync(exe, 0o755)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
//clean, 一併嘗試移除已空之上層資料夾, 令測試結束後test/tmp/不留殘跡
|
|
126
|
+
//上層可能仍有其他測試檔並行中之產物, 故rmdir失敗即忽略
|
|
127
|
+
let clean = () => {
|
|
128
|
+
fs.rmSync(fd, { recursive: true, force: true })
|
|
129
|
+
try {
|
|
130
|
+
fs.rmdirSync(FD_BASE)
|
|
131
|
+
}
|
|
132
|
+
catch {}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { exe, fd, clean }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
export default createFakeCli
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import assert from 'assert'
|
|
2
|
+
import keys from 'lodash-es/keys.js'
|
|
3
|
+
import map from 'lodash-es/map.js'
|
|
4
|
+
import wi from '../src/WDispatchAi.mjs'
|
|
5
|
+
import dispatchAi from '../src/dispatchAi.mjs'
|
|
6
|
+
import dispatchOpencode from '../src/dispatchOpencode.mjs'
|
|
7
|
+
import dispatchClaude from '../src/dispatchClaude.mjs'
|
|
8
|
+
import dispatchCodex from '../src/dispatchCodex.mjs'
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
describe('WDispatchAi', function() {
|
|
12
|
+
|
|
13
|
+
it('對外提供KINDS與各dispatch函數', function() {
|
|
14
|
+
let r = keys(wi)
|
|
15
|
+
let rr = [
|
|
16
|
+
'KINDS',
|
|
17
|
+
'dispatchAi',
|
|
18
|
+
'dispatchOpencode',
|
|
19
|
+
'dispatchClaude',
|
|
20
|
+
'dispatchCodex',
|
|
21
|
+
]
|
|
22
|
+
assert.strict.deepEqual(r, rr)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('KINDS為可用之供應商種類字串陣列', function() {
|
|
26
|
+
let r = wi.KINDS
|
|
27
|
+
let rr = ['opencode', 'claude', 'codex']
|
|
28
|
+
assert.strict.deepEqual(r, rr)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('除KINDS外各鍵值皆為函數', function() {
|
|
32
|
+
let r = map(keys(wi), (k) => typeof wi[k])
|
|
33
|
+
let rr = ['object', 'function', 'function', 'function', 'function']
|
|
34
|
+
assert.strict.deepEqual(r, rr)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('各鍵值即為對應模組之預設匯出', function() {
|
|
38
|
+
let r = [
|
|
39
|
+
wi.dispatchAi === dispatchAi,
|
|
40
|
+
wi.dispatchOpencode === dispatchOpencode,
|
|
41
|
+
wi.dispatchClaude === dispatchClaude,
|
|
42
|
+
wi.dispatchCodex === dispatchCodex,
|
|
43
|
+
]
|
|
44
|
+
let rr = [true, true, true, true]
|
|
45
|
+
assert.strict.deepEqual(r, rr)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('KINDS各項皆可由dispatchAi分派', async function() {
|
|
49
|
+
let r = []
|
|
50
|
+
for (let kind of wi.KINDS) {
|
|
51
|
+
let t = await wi.dispatchAi(kind, '')
|
|
52
|
+
r.push(t.error)
|
|
53
|
+
}
|
|
54
|
+
let rr = ['prompt must be a non-empty string', 'prompt must be a non-empty string', 'prompt must be a non-empty string']
|
|
55
|
+
assert.strict.deepEqual(r, rr)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('可由WDispatchAi呼叫dispatchAi', async function() {
|
|
59
|
+
let t = await wi.dispatchAi('gemini', 'abc')
|
|
60
|
+
let r = [t.ok, t.error]
|
|
61
|
+
let rr = [false, 'unknown ai kind: "gemini" (available: opencode, claude, codex)']
|
|
62
|
+
assert.strict.deepEqual(r, rr)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('可由WDispatchAi呼叫各轉接器', async function() {
|
|
66
|
+
let r = []
|
|
67
|
+
for (let fn of [wi.dispatchOpencode, wi.dispatchClaude, wi.dispatchCodex]) {
|
|
68
|
+
let t = await fn('')
|
|
69
|
+
r.push([t.ok, t.error])
|
|
70
|
+
}
|
|
71
|
+
let rr = [
|
|
72
|
+
[false, 'prompt must be a non-empty string'],
|
|
73
|
+
[false, 'prompt must be a non-empty string'],
|
|
74
|
+
[false, 'prompt must be a non-empty string'],
|
|
75
|
+
]
|
|
76
|
+
assert.strict.deepEqual(r, rr)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import assert from 'assert'
|
|
2
|
+
import keys from 'lodash-es/keys.js'
|
|
3
|
+
import map from 'lodash-es/map.js'
|
|
4
|
+
import adapters from '../src/adapters.mjs'
|
|
5
|
+
import dispatchOpencode from '../src/dispatchOpencode.mjs'
|
|
6
|
+
import dispatchClaude from '../src/dispatchClaude.mjs'
|
|
7
|
+
import dispatchCodex from '../src/dispatchCodex.mjs'
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
describe('adapters', function() {
|
|
11
|
+
|
|
12
|
+
it('對照表鍵名即為可用之供應商種類', function() {
|
|
13
|
+
let r = keys(adapters)
|
|
14
|
+
let rr = ['opencode', 'claude', 'codex']
|
|
15
|
+
assert.strict.deepEqual(r, rr)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('各鍵值皆為函數', function() {
|
|
19
|
+
let r = map(keys(adapters), (k) => typeof adapters[k])
|
|
20
|
+
let rr = ['function', 'function', 'function']
|
|
21
|
+
assert.strict.deepEqual(r, rr)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('各鍵值即為對應模組之預設匯出', function() {
|
|
25
|
+
let r = [
|
|
26
|
+
adapters.opencode === dispatchOpencode,
|
|
27
|
+
adapters.claude === dispatchClaude,
|
|
28
|
+
adapters.codex === dispatchCodex,
|
|
29
|
+
]
|
|
30
|
+
let rr = [true, true, true]
|
|
31
|
+
assert.strict.deepEqual(r, rr)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import assert from 'assert'
|
|
2
|
+
import dispatchAi from '../src/dispatchAi.mjs'
|
|
3
|
+
import createFakeCli from './tools/fakeCliForTest.mjs'
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
describe('dispatchAi', function() {
|
|
7
|
+
|
|
8
|
+
let fake = null
|
|
9
|
+
|
|
10
|
+
before(function() {
|
|
11
|
+
fake = createFakeCli('fake-dispatchai')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
after(function() {
|
|
15
|
+
if (fake) {
|
|
16
|
+
fake.clean()
|
|
17
|
+
}
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('未知kind時回傳錯誤結果物件且不reject', async function() {
|
|
21
|
+
let t = await dispatchAi('gemini', 'abc')
|
|
22
|
+
let r = [t.ok, t.error, t.attempts]
|
|
23
|
+
let rr = [false, 'unknown ai kind: "gemini" (available: opencode, claude, codex)', 0]
|
|
24
|
+
assert.strict.deepEqual(r, rr)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('kind非有效字串時回傳錯誤結果物件', async function() {
|
|
28
|
+
let r = []
|
|
29
|
+
for (let kind of [null, undefined, '', 123, {}]) {
|
|
30
|
+
let t = await dispatchAi(kind, 'abc')
|
|
31
|
+
r.push([t.ok, t.error.indexOf('unknown ai kind') === 0])
|
|
32
|
+
}
|
|
33
|
+
let rr = [[false, true], [false, true], [false, true], [false, true], [false, true]]
|
|
34
|
+
assert.strict.deepEqual(r, rr)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('kind為原型鍵名時不可誤判為有效供應商', async function() {
|
|
38
|
+
let r = []
|
|
39
|
+
for (let kind of ['constructor', 'toString', 'hasOwnProperty', '__proto__']) {
|
|
40
|
+
let t = await dispatchAi(kind, 'abc')
|
|
41
|
+
r.push([t.ok, t.error.indexOf('unknown ai kind') === 0])
|
|
42
|
+
}
|
|
43
|
+
let rr = [[false, true], [false, true], [false, true], [false, true]]
|
|
44
|
+
assert.strict.deepEqual(r, rr)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('prompt之檢核由各轉接器負責, 錯誤結果物件一致', async function() {
|
|
48
|
+
let r = []
|
|
49
|
+
for (let kind of ['opencode', 'claude', 'codex']) {
|
|
50
|
+
let t = await dispatchAi(kind, '')
|
|
51
|
+
r.push([t.ok, t.error])
|
|
52
|
+
}
|
|
53
|
+
let rr = [
|
|
54
|
+
[false, 'prompt must be a non-empty string'],
|
|
55
|
+
[false, 'prompt must be a non-empty string'],
|
|
56
|
+
[false, 'prompt must be a non-empty string'],
|
|
57
|
+
]
|
|
58
|
+
assert.strict.deepEqual(r, rr)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('各kind分派至對應轉接器, 由其固定旗標可辨識', async function() {
|
|
62
|
+
let r = []
|
|
63
|
+
for (let kind of ['opencode', 'claude', 'codex']) {
|
|
64
|
+
let t = await dispatchAi(kind, 'abc', { exe: fake.exe, model: 'mdl' })
|
|
65
|
+
let o = JSON.parse(t.stdout)
|
|
66
|
+
r.push([t.ok, o.args])
|
|
67
|
+
}
|
|
68
|
+
let rr = [
|
|
69
|
+
[true, ['run', '--agent', 'build', '-m', 'mdl']],
|
|
70
|
+
[true, ['-p', '--dangerously-skip-permissions', '--model', 'mdl']],
|
|
71
|
+
[true, ['exec', '--sandbox', 'workspace-write', '--skip-git-repo-check', '-m', 'mdl']],
|
|
72
|
+
]
|
|
73
|
+
assert.strict.deepEqual(r, rr)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('opt原樣轉傳至對應轉接器', async function() {
|
|
77
|
+
let t = await dispatchAi('opencode', 'abc', { exe: fake.exe, provider: 'opencode', key: 'sk-abc', extraArgs: ['--fake-stderr=warn'] })
|
|
78
|
+
let o = JSON.parse(t.stdout)
|
|
79
|
+
let r = [t.stderr, JSON.parse(o.env.OPENCODE_AUTH_CONTENT).opencode.key]
|
|
80
|
+
let rr = ['warn', 'sk-abc']
|
|
81
|
+
assert.strict.deepEqual(r, rr)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('供應商條目可逐一輪替, 各條目自帶kind與model', async function() {
|
|
85
|
+
let items = [
|
|
86
|
+
{ kind: 'opencode', model: 'opencode/free', provider: 'opencode', key: 'sk-1' },
|
|
87
|
+
{ kind: 'claude', model: 'sonnet' },
|
|
88
|
+
{ kind: 'codex', model: 'gpt-5.6-luna' },
|
|
89
|
+
]
|
|
90
|
+
let r = []
|
|
91
|
+
for (let item of items) {
|
|
92
|
+
let t = await dispatchAi(item.kind, 'abc', { ...item, exe: fake.exe })
|
|
93
|
+
let o = JSON.parse(t.stdout)
|
|
94
|
+
r.push([item.kind, o.args[0], o.args.includes(item.model)])
|
|
95
|
+
}
|
|
96
|
+
let rr = [
|
|
97
|
+
['opencode', 'run', true],
|
|
98
|
+
['claude', '-p', true],
|
|
99
|
+
['codex', 'exec', true],
|
|
100
|
+
]
|
|
101
|
+
assert.strict.deepEqual(r, rr)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
})
|