@botconnector/bccli 0.5.2 → 0.5.4
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/bccli-bootstrap.cjs +61 -0
- package/branding.mjs +12 -10
- package/cli.mjs +15 -3
- package/core.mjs +37 -7
- package/dialog-models.mjs +114 -0
- package/package.json +9 -7
- package/provider-command.mjs +8 -2
- package/session-settings.mjs +26 -0
- package/startup-menu.mjs +12 -6
- package/model-command.mjs +0 -61
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict'
|
|
3
|
+
|
|
4
|
+
// This small launcher runs before importing the coding engine. npm's Windows
|
|
5
|
+
// shims use the system Node; on affected releases we may select a known-safe
|
|
6
|
+
// owner-installed Node without changing the system-wide PATH.
|
|
7
|
+
const { spawn, spawnSync } = require('node:child_process')
|
|
8
|
+
const { existsSync } = require('node:fs')
|
|
9
|
+
const { join, resolve } = require('node:path')
|
|
10
|
+
const { pathToFileURL } = require('node:url')
|
|
11
|
+
|
|
12
|
+
function unsafeWindowsNode(version, platform = process.platform) {
|
|
13
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(version))
|
|
14
|
+
if (!m) return true
|
|
15
|
+
const [major, minor, patch] = m.slice(1).map(Number)
|
|
16
|
+
return platform === 'win32' && major === 24 && (minor < 22 || (minor === 22 && patch < 1))
|
|
17
|
+
}
|
|
18
|
+
function safeRuntime(version) {
|
|
19
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(version))
|
|
20
|
+
if (!m) return false
|
|
21
|
+
const [major, minor, patch] = m.slice(1).map(Number)
|
|
22
|
+
return (major === 22 && (minor > 23 || (minor === 23 && patch >= 4))) ||
|
|
23
|
+
(major === 24 && (minor > 22 || (minor === 22 && patch >= 1)))
|
|
24
|
+
}
|
|
25
|
+
function selectRuntime({ platform = process.platform, nodeVersion = process.version,
|
|
26
|
+
execPath = process.execPath, env = process.env,
|
|
27
|
+
probe = (path) => {
|
|
28
|
+
const result = spawnSync(path, ['--version'], { encoding: 'utf8', timeout: 5000, windowsHide: true })
|
|
29
|
+
return result.status === 0 ? result.stdout.trim() : ''
|
|
30
|
+
},
|
|
31
|
+
exists = existsSync } = {}) {
|
|
32
|
+
if (!unsafeWindowsNode(nodeVersion, platform)) return execPath
|
|
33
|
+
const candidates = [
|
|
34
|
+
env.BCCLI_NODE_EXECUTABLE,
|
|
35
|
+
env.LOCALAPPDATA && join(env.LOCALAPPDATA, 'BotConnector', 'BCCLI-Preview', 'runtime', 'node.exe'),
|
|
36
|
+
].filter(Boolean)
|
|
37
|
+
for (const candidate of candidates) {
|
|
38
|
+
const path = resolve(candidate)
|
|
39
|
+
if (path === resolve(execPath) || !exists(path)) continue
|
|
40
|
+
if (safeRuntime(probe(path))) return path
|
|
41
|
+
}
|
|
42
|
+
throw Error('BCCLI requires Node.js 24.22.1+ or 22.23.4+ on Windows. Set BCCLI_NODE_EXECUTABLE to an installed compatible node.exe, or update Node.js. No coding request was sent.')
|
|
43
|
+
}
|
|
44
|
+
async function main() {
|
|
45
|
+
const exe = selectRuntime()
|
|
46
|
+
const entry = join(__dirname, 'cli.mjs')
|
|
47
|
+
if (exe === process.execPath) {
|
|
48
|
+
await import(pathToFileURL(entry).href)
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
const child = spawn(exe, [entry, ...process.argv.slice(2)], {
|
|
52
|
+
stdio: 'inherit', cwd: process.cwd(), env: process.env, windowsHide: false, shell: false,
|
|
53
|
+
})
|
|
54
|
+
child.once('error', (error) => { console.error('BCCLI runtime launch failed: ' + error.message); process.exitCode = 1 })
|
|
55
|
+
child.once('exit', (code, signal) => { process.exitCode = code ?? (signal ? 130 : 1) })
|
|
56
|
+
}
|
|
57
|
+
if (require.main === module) main().catch((error) => {
|
|
58
|
+
console.error(error.message)
|
|
59
|
+
process.exitCode = 1
|
|
60
|
+
})
|
|
61
|
+
module.exports = { unsafeWindowsNode, safeRuntime, selectRuntime }
|
package/branding.mjs
CHANGED
|
@@ -5,7 +5,6 @@ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, r
|
|
|
5
5
|
import { basename, dirname, join } from 'node:path'
|
|
6
6
|
import { fileURLToPath } from 'node:url'
|
|
7
7
|
import { homedir } from 'node:os'
|
|
8
|
-
import { BCCLI_MODEL_COMMAND } from './model-command.mjs'
|
|
9
8
|
import { BCCLI_PROVIDER_COMMAND } from './provider-command.mjs'
|
|
10
9
|
import { BCCLI_PROVIDER_DIALOG } from './provider-dialog.mjs'
|
|
11
10
|
|
|
@@ -33,7 +32,7 @@ const SOURCE_HASHES = Object.freeze({
|
|
|
33
32
|
'chunks/chunk-B3ZFUGTC.js': 'f3f57aafbaeab56ca58f5d3173751d30e5fef815aeaf8dc9b46e6ea9e85eb528',
|
|
34
33
|
'chunks/chunk-GGVWG4SI.js': '945c7f4e9b3697003c165b7bc5d007c9b87140d1fad7be084d7b253d8d2b476c',
|
|
35
34
|
})
|
|
36
|
-
const BRAND_REVISION = 'bccli-
|
|
35
|
+
const BRAND_REVISION = 'bccli-20261031-opentui-provider-fallback-v17'
|
|
37
36
|
// Deliberately not Qwen's blue→purple→rose gradient. BotConnector-facing
|
|
38
37
|
// coding CLI uses a distinct teal→green→amber palette in dark/light themes.
|
|
39
38
|
export const BCCLI_COLORS = Object.freeze(['#00AFA0', '#22C55E', '#EAB308'])
|
|
@@ -66,9 +65,11 @@ function replaceOnce(text, oldValue, newValue, label) {
|
|
|
66
65
|
|
|
67
66
|
|
|
68
67
|
// Embedded slash commands are part of the upstream renderer bundle. BCCLI's
|
|
69
|
-
// Gateway owns authentication
|
|
70
|
-
//
|
|
71
|
-
//
|
|
68
|
+
// Gateway owns authentication and release management, so those commands must
|
|
69
|
+
// not open arbitrary upstream provider settings or invoke the upstream npm /
|
|
70
|
+
// standalone auto-updater. /model intentionally stays upstream-owned: the
|
|
71
|
+
// interactive picker is fed by the gateway catalog that BCCLI writes into the
|
|
72
|
+
// session's modelProviders (see dialog-models.mjs).
|
|
72
73
|
export function guardUpstreamProviderCommands(text) {
|
|
73
74
|
const replacements = [
|
|
74
75
|
{
|
|
@@ -76,11 +77,6 @@ export function guardUpstreamProviderCommands(text) {
|
|
|
76
77
|
detect: 'dialog:"auth"',
|
|
77
78
|
code: BCCLI_PROVIDER_COMMAND,
|
|
78
79
|
},
|
|
79
|
-
{
|
|
80
|
-
command: 'model',
|
|
81
|
-
detect: 'dialog:"model"',
|
|
82
|
-
code: BCCLI_MODEL_COMMAND,
|
|
83
|
-
},
|
|
84
80
|
{
|
|
85
81
|
command: 'settings',
|
|
86
82
|
detect: 'dialog:"settings"',
|
|
@@ -249,6 +245,12 @@ export function patchUiChunk(file, text) {
|
|
|
249
245
|
'customBannerTitle?customBannerTitle:">_ BCCLI | BotConnector"',
|
|
250
246
|
'Ink banner title',
|
|
251
247
|
)
|
|
248
|
+
if (result.includes('const versionLabel=formatVersionLabel(version);')) {
|
|
249
|
+
result = replaceOnce(result,
|
|
250
|
+
'const versionLabel=formatVersionLabel(version);',
|
|
251
|
+
'const versionLabel="engine "+formatVersionLabel(version);',
|
|
252
|
+
'Clarify upstream engine version in BCCLI banner')
|
|
253
|
+
}
|
|
252
254
|
result = replaceOnce(result,
|
|
253
255
|
'Add a QWEN.md file to give Qwen Code persistent project context.',
|
|
254
256
|
'Use /help to explore BCCLI tools and commands.',
|
package/cli.mjs
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync } from 'node:fs'
|
|
3
|
-
import { DEFAULT_MODEL, assertCandidateRuntimeSupported, discoverModels, launchQwen, parseArgs, resolveQwenEntrypoint, resolveTrustedMcpConfig, selectModel } from './core.mjs'
|
|
3
|
+
import { DEFAULT_MODEL, assertCandidateRuntimeSupported, discoverModels, launchQwen, parseArgs, resolveQwenEntrypoint, resolveTrustedMcpConfig, selectModel, sessionHome } from './core.mjs'
|
|
4
4
|
import { prepareBrandedQwen } from './branding.mjs'
|
|
5
|
+
import { readDialogSelectedModel } from './dialog-models.mjs'
|
|
5
6
|
import { botConnectorCredentialStatus } from './credentials.mjs'
|
|
6
7
|
import { resolveProvider, selectedProvider, providerDefinition, isConfigured } from './providers.mjs'
|
|
7
8
|
import { runProviderCommand } from './provider-cli.mjs'
|
|
8
9
|
import { startupMenu, autoSelectStartup, PROVIDER_SWITCH_EXIT } from './startup-menu.mjs'
|
|
9
10
|
import { legacyCompatibilityMode, runLegacyCli } from './legacy-compat.mjs'
|
|
10
11
|
|
|
12
|
+
// An unreadable or unsafe settings file must not block startup; the picker
|
|
13
|
+
// simply falls back to the legacy active-model record.
|
|
14
|
+
function dialogModel(providerId) {
|
|
15
|
+
try { return readDialogSelectedModel(sessionHome(providerId, process.env)) } catch { return undefined }
|
|
16
|
+
}
|
|
17
|
+
|
|
11
18
|
function help() {
|
|
12
19
|
console.log([
|
|
13
20
|
'BCCLI | BotConnector Coding Agent',
|
|
@@ -84,18 +91,22 @@ async function main() {
|
|
|
84
91
|
if (!args.prompt && !process.stdin.isTTY) throw Error('Interactive mode requires a TTY; use -p "prompt".')
|
|
85
92
|
const mcpConfig = resolveTrustedMcpConfig(args.mcpConfig)
|
|
86
93
|
let chosen
|
|
94
|
+
let catalog
|
|
87
95
|
const showPicker = args.command === 'chat' && !args.prompt && !args.model
|
|
88
96
|
if (showPicker) {
|
|
89
97
|
chosen = forceChoose ? undefined : await autoSelectStartup({
|
|
90
98
|
...(args.provider ? { providerId: args.provider } : {}),
|
|
99
|
+
dialogModel,
|
|
91
100
|
})
|
|
92
101
|
if (!chosen) chosen = await startupMenu({
|
|
93
102
|
...(args.provider ? { currentProvider: () => args.provider } : {}),
|
|
103
|
+
dialogModel,
|
|
94
104
|
})
|
|
95
105
|
} else {
|
|
96
106
|
const providerId = args.provider ?? selectedProvider()
|
|
97
107
|
const profile = resolveProvider(providerId)
|
|
98
108
|
const models = await discoverModels({ key: profile.key, endpoint: profile.endpoint })
|
|
109
|
+
catalog = models
|
|
99
110
|
const requested = args.model ??
|
|
100
111
|
(providerId === 'botconnector' ? (process.env.BOTCONNECTOR_MODEL_ID ?? DEFAULT_MODEL) : undefined)
|
|
101
112
|
if (!requested) throw Error('Use --model MODEL_ID for ' + providerId + '.')
|
|
@@ -109,15 +120,16 @@ async function main() {
|
|
|
109
120
|
const code = await launchQwen({
|
|
110
121
|
key: chosen.key, model: chosen.model, prompt: args.prompt,
|
|
111
122
|
mcpConfig, entrypoint, upstream: chosen.endpoint,
|
|
112
|
-
providerId: chosen.providerId, continueSession, resumeSession,
|
|
123
|
+
providerId: chosen.providerId, continueSession, resumeSession, catalog,
|
|
113
124
|
})
|
|
114
125
|
if (code !== PROVIDER_SWITCH_EXIT) return code
|
|
115
126
|
if (args.prompt || !process.stdin.isTTY) throw Error('Provider menu requires an interactive terminal.')
|
|
116
127
|
// Older cached engine fallback only. Current /provider switches the live
|
|
117
128
|
// parent proxy and model Config without exiting or exposing API keys.
|
|
118
129
|
resumeSession = undefined // Native picker is used only on the first launch.
|
|
130
|
+
catalog = undefined // The next provider has its own eligible model list.
|
|
119
131
|
const previousProvider = chosen.providerId
|
|
120
|
-
chosen = await startupMenu()
|
|
132
|
+
chosen = await startupMenu({ dialogModel })
|
|
121
133
|
continueSession = previousProvider === chosen.providerId
|
|
122
134
|
}
|
|
123
135
|
}
|
package/core.mjs
CHANGED
|
@@ -7,11 +7,27 @@ import { dirname, join, isAbsolute } from 'node:path'
|
|
|
7
7
|
import { fileURLToPath } from 'node:url'
|
|
8
8
|
import { startGatewayProxy } from './proxy.mjs'
|
|
9
9
|
import { GATEWAY } from './gateway.mjs'
|
|
10
|
-
import { listProviderDefinitions } from './providers.mjs'
|
|
10
|
+
import { listProviderDefinitions, providerRoot } from './providers.mjs'
|
|
11
|
+
import { ensureIsolatedSessionSettings } from './session-settings.mjs'
|
|
12
|
+
import { syncDialogModelCatalog } from './dialog-models.mjs'
|
|
11
13
|
|
|
12
14
|
export { GATEWAY } from './gateway.mjs'
|
|
13
15
|
export const DEFAULT_MODEL = 'ling-3.0-flash'
|
|
14
16
|
|
|
17
|
+
// One engine home per provider keeps /model persistence provider-scoped.
|
|
18
|
+
// BCCLI_PREVIEW_HOME (already the owner config/key root) also relocates the
|
|
19
|
+
// engine home so isolated runs and tests never write to the real user profile.
|
|
20
|
+
export function sessionHome(providerId, env = process.env) {
|
|
21
|
+
const override = typeof env.BCCLI_PREVIEW_HOME === 'string' ? env.BCCLI_PREVIEW_HOME.trim() : ''
|
|
22
|
+
if (override) {
|
|
23
|
+
if (!isAbsolute(override)) throw Error('BCCLI_PREVIEW_HOME must be absolute.')
|
|
24
|
+
return providerId === 'botconnector' ? override : join(override, 'sessions-' + providerId)
|
|
25
|
+
}
|
|
26
|
+
return providerId === 'botconnector'
|
|
27
|
+
? join(homedir(), '.bccli-preview')
|
|
28
|
+
: join(homedir(), '.bccli-preview', 'sessions-' + providerId)
|
|
29
|
+
}
|
|
30
|
+
|
|
15
31
|
// Qwen Code 0.26.3 Windows tool-loop exits cleanly with Node 24.22.1
|
|
16
32
|
// (GitHub Actions), but repeatedly crashes on Windows Node 24.18.0
|
|
17
33
|
// with libuv UV_HANDLE_CLOSING after delivering the tool result.
|
|
@@ -21,7 +37,7 @@ export function assertCandidateRuntimeSupported({ platform = process.platform, n
|
|
|
21
37
|
if (!parts) throw Error('Cannot verify the Node.js version for BCCLI candidate.')
|
|
22
38
|
const major = Number(parts[1]), minor = Number(parts[2]), patch = Number(parts[3])
|
|
23
39
|
if (platform === 'win32' && major === 24 && (minor < 22 || (minor === 22 && patch < 1))) {
|
|
24
|
-
throw Error('Windows Node.js ' + nodeVersion + ' may crash after coding tools. Use Node.js 24.22.1 or newer, or Node.js 22.23.4.
|
|
40
|
+
throw Error('Windows Node.js ' + nodeVersion + ' may crash after coding tools. Use Node.js 24.22.1 or newer, or Node.js 22.23.4. No coding request was sent.')
|
|
25
41
|
}
|
|
26
42
|
}
|
|
27
43
|
const MODEL_ID = /^[a-zA-Z0-9][a-zA-Z0-9._:/+-]{0,255}$/
|
|
@@ -209,7 +225,7 @@ export function childEnvironment(base, key, model, { endpoint = GATEWAY, home, p
|
|
|
209
225
|
env.OPENAI_BASE_URL = endpoint
|
|
210
226
|
env.OPENAI_MODEL = model
|
|
211
227
|
env.QWEN_MODEL = model
|
|
212
|
-
env.QWEN_HOME = home ?? (providerId
|
|
228
|
+
env.QWEN_HOME = home ?? sessionHome(providerId, env)
|
|
213
229
|
env.BCCLI_ACTIVE_PROVIDER = providerId
|
|
214
230
|
env.BCCLI_WEB_SEARCH_AVAILABLE = searchAvailable ? '1' : '0'
|
|
215
231
|
env.CLI_TITLE = 'BCCLI - ' + process.cwd().split(/[\\/]/).filter(Boolean).at(-1)
|
|
@@ -219,8 +235,9 @@ export function childEnvironment(base, key, model, { endpoint = GATEWAY, home, p
|
|
|
219
235
|
|
|
220
236
|
// Explicit MCP servers run local commands on startup, so they must originate
|
|
221
237
|
// from a config file the user deliberately placed outside any cloned project.
|
|
222
|
-
// Full engine auto-discovery is subject to upstream workspace trust.
|
|
223
|
-
|
|
238
|
+
// Full engine auto-discovery is subject to upstream workspace trust. The owner
|
|
239
|
+
// config root follows BCCLI_PREVIEW_HOME so an isolated home is fully isolated.
|
|
240
|
+
export function resolveTrustedMcpConfig(input, { home = providerRoot() } = {}) {
|
|
224
241
|
if (!input) return undefined
|
|
225
242
|
if (!isAbsolute(input)) throw Error('MCP config must use an absolute path.')
|
|
226
243
|
let realHome, file, stats
|
|
@@ -259,15 +276,28 @@ export function resolveQwenEntrypoint() {
|
|
|
259
276
|
return join(dirname(require.resolve('@qwen-code/qwen-code/package.json')), 'cli-entry.js')
|
|
260
277
|
}
|
|
261
278
|
|
|
262
|
-
export async function launchQwen({ key, model, prompt, mcpConfig, continueSession = false, resumeSession, upstream = GATEWAY, providerId = 'botconnector', env = process.env, entrypoint = resolveQwenEntrypoint(), spawnImpl = spawn, proxyFactory = startGatewayProxy }) {
|
|
279
|
+
export async function launchQwen({ key, model, prompt, mcpConfig, continueSession = false, resumeSession, upstream = GATEWAY, providerId = 'botconnector', env = process.env, entrypoint = resolveQwenEntrypoint(), spawnImpl = spawn, proxyFactory = startGatewayProxy, catalog }) {
|
|
263
280
|
// The real API key never enters Qwen, its tools, or any MCP child process.
|
|
264
281
|
// Only this launcher retains it while the ephemeral proxy is running.
|
|
265
282
|
const searchApiKey = env.BRAVE_SEARCH_API_KEY
|
|
266
283
|
const proxy = await proxyFactory({ key, endpoint: upstream, liveProvider: true, initialProvider: providerId, searchApiKey })
|
|
267
284
|
try {
|
|
268
285
|
const args = [entrypoint, ...qwenArguments(model, prompt, proxy.baseUrl, { mcpConfig, continueSession, resumeSession, projectDir: process.cwd() })]
|
|
286
|
+
const childEnv = childEnvironment(env, proxy.token, model, { endpoint: proxy.baseUrl, providerId, searchAvailable: Boolean(searchApiKey?.trim()) })
|
|
287
|
+
ensureIsolatedSessionSettings(childEnv.QWEN_HOME)
|
|
288
|
+
// The upstream /model picker needs the catalog in modelProviders; without
|
|
289
|
+
// it the dialog only knows the single OPENAI_MODEL route. Never block a
|
|
290
|
+
// launch on catalog failure: chat still works with the startup model.
|
|
291
|
+
if (proxyFactory === startGatewayProxy) {
|
|
292
|
+
try {
|
|
293
|
+
const dialogCatalog = catalog ?? await discoverModels({ key, endpoint: upstream })
|
|
294
|
+
syncDialogModelCatalog(childEnv.QWEN_HOME, { models: dialogCatalog, baseUrl: proxy.baseUrl })
|
|
295
|
+
} catch (error) {
|
|
296
|
+
if (env.BCCLI_DEBUG) console.error('BCCLI: model picker catalog unavailable: ' + (error instanceof Error ? error.message : 'unknown'))
|
|
297
|
+
}
|
|
298
|
+
}
|
|
269
299
|
const child = spawnImpl(process.execPath, args, {
|
|
270
|
-
cwd: process.cwd(), env:
|
|
300
|
+
cwd: process.cwd(), env: childEnv,
|
|
271
301
|
stdio: 'inherit', windowsHide: false, shell: false,
|
|
272
302
|
})
|
|
273
303
|
return await new Promise((resolve, reject) => {
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// The upstream /model picker lists models from settings.modelProviders. BCCLI's
|
|
2
|
+
// engine can only reach the parent-owned loopback proxy, so publish the live
|
|
3
|
+
// gateway catalog into the per-provider QWEN_HOME as one OpenAI-compatible
|
|
4
|
+
// provider. The picker itself stays upstream-owned (arrow keys, search,
|
|
5
|
+
// --global/--project and one-off prompts); BCCLI only supplies the eligible
|
|
6
|
+
// model set and reads back the persisted selection.
|
|
7
|
+
import { lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
8
|
+
import { randomBytes } from 'node:crypto'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
|
|
11
|
+
const MODEL_ID = /^[a-zA-Z0-9][a-zA-Z0-9._:/+-]{0,255}$/
|
|
12
|
+
const LOOPBACK_ENDPOINT = /^http:\/\/127\.0\.0\.1:[0-9]{1,5}\/v1$/
|
|
13
|
+
const MAX_SETTINGS_BYTES = 1024 * 1024
|
|
14
|
+
|
|
15
|
+
export function buildDialogProviders(models, baseUrl) {
|
|
16
|
+
if (typeof baseUrl !== 'string' || !LOOPBACK_ENDPOINT.test(baseUrl)) {
|
|
17
|
+
throw Error('Model picker requires the local loopback provider endpoint.')
|
|
18
|
+
}
|
|
19
|
+
if (!Array.isArray(models) || models.length === 0) {
|
|
20
|
+
throw Error('Model picker requires a non-empty provider catalog.')
|
|
21
|
+
}
|
|
22
|
+
return models.map((item) => {
|
|
23
|
+
const id = item?.id
|
|
24
|
+
if (typeof id !== 'string' || !MODEL_ID.test(id)) {
|
|
25
|
+
throw Error('Invalid provider model id for the model picker.')
|
|
26
|
+
}
|
|
27
|
+
const name = typeof item?.name === 'string' && item.name.trim() ? item.name.trim().slice(0, 90) : id
|
|
28
|
+
return { id, name, baseUrl, envKey: 'OPENAI_API_KEY' }
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Keep unrelated settings and other configured providers byte-for-byte; only
|
|
33
|
+
// the OpenAI-compatible entry is owned by the live loopback proxy.
|
|
34
|
+
export function mergeDialogProviders(settings, providers) {
|
|
35
|
+
const base = settings && typeof settings === 'object' && !Array.isArray(settings) ? settings : {}
|
|
36
|
+
const existing = base.modelProviders
|
|
37
|
+
const modelProviders = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {}
|
|
38
|
+
return { ...base, modelProviders: { ...modelProviders, openai: providers } }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readSettings(home) {
|
|
42
|
+
const info = lstatSync(home)
|
|
43
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
44
|
+
throw Error('BCCLI session directory must not be a symlink.')
|
|
45
|
+
}
|
|
46
|
+
const file = join(home, 'settings.json')
|
|
47
|
+
let stats
|
|
48
|
+
try {
|
|
49
|
+
stats = lstatSync(file)
|
|
50
|
+
} catch {
|
|
51
|
+
return {}
|
|
52
|
+
}
|
|
53
|
+
if (!stats.isFile() || stats.isSymbolicLink()) {
|
|
54
|
+
throw Error('BCCLI session settings must be a regular file.')
|
|
55
|
+
}
|
|
56
|
+
if (stats.size > MAX_SETTINGS_BYTES) throw Error('BCCLI session settings are too large.')
|
|
57
|
+
const text = readFileSync(file, 'utf8').trim()
|
|
58
|
+
if (!text) return {}
|
|
59
|
+
const parsed = JSON.parse(text)
|
|
60
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
61
|
+
throw Error('BCCLI session settings must contain a JSON object.')
|
|
62
|
+
}
|
|
63
|
+
return parsed
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function writeSettings(home, settings) {
|
|
67
|
+
const file = join(home, 'settings.json')
|
|
68
|
+
const tmp = file + '.' + randomBytes(8).toString('hex') + '.tmp'
|
|
69
|
+
try {
|
|
70
|
+
writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n', { flag: 'wx', encoding: 'utf8', mode: 0o600 })
|
|
71
|
+
renameSync(tmp, file)
|
|
72
|
+
} finally {
|
|
73
|
+
rmSync(tmp, { force: true })
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A picker selection persists model.baseUrl alongside the id. The proxy port is
|
|
78
|
+
// ephemeral, so clear only an endpoint this module previously injected once the
|
|
79
|
+
// loopback port changes; unrelated endpoints are never rewritten.
|
|
80
|
+
function clearStalePickerEndpoint(settings, baseUrl) {
|
|
81
|
+
const injected = settings.modelProviders?.openai
|
|
82
|
+
if (!Array.isArray(injected)) return settings
|
|
83
|
+
const previous = new Set(injected
|
|
84
|
+
.map((entry) => entry?.baseUrl)
|
|
85
|
+
.filter((url) => typeof url === 'string' && LOOPBACK_ENDPOINT.test(url)))
|
|
86
|
+
const model = settings.model
|
|
87
|
+
if (!model || typeof model !== 'object' || Array.isArray(model)) return settings
|
|
88
|
+
if (typeof model.baseUrl !== 'string' || !previous.has(model.baseUrl) || model.baseUrl === baseUrl) return settings
|
|
89
|
+
return { ...settings, model: { ...model, baseUrl: '' } }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Returns true only when the catalog actually changed, so a normal relaunch
|
|
93
|
+
// does not touch the user's settings file.
|
|
94
|
+
export function syncDialogModelCatalog(home, { models, baseUrl }) {
|
|
95
|
+
if (typeof home !== 'string' || !home.trim()) throw Error('BCCLI session home missing.')
|
|
96
|
+
const providers = buildDialogProviders(models, baseUrl)
|
|
97
|
+
mkdirSync(home, { recursive: true, mode: 0o700 })
|
|
98
|
+
const current = clearStalePickerEndpoint(readSettings(home), baseUrl)
|
|
99
|
+
const next = mergeDialogProviders(current, providers)
|
|
100
|
+
if (JSON.stringify(next) === JSON.stringify(current)) return false
|
|
101
|
+
writeSettings(home, next)
|
|
102
|
+
return true
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// The upstream picker persists the chosen id to settings.model.name. Read it
|
|
106
|
+
// back so the next launch (and the pre-launch menu) starts on the same model.
|
|
107
|
+
export function readDialogSelectedModel(home) {
|
|
108
|
+
if (typeof home !== 'string' || !home.trim()) return undefined
|
|
109
|
+
const settings = readSettings(home)
|
|
110
|
+
const name = settings.model?.name
|
|
111
|
+
if (typeof name !== 'string') return undefined
|
|
112
|
+
const trimmed = name.trim()
|
|
113
|
+
return MODEL_ID.test(trimmed) ? trimmed : undefined
|
|
114
|
+
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@botconnector/bccli",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
4
4
|
"description": "BCCLI | Full coding engine and legacy SDK with verified Apache-2.0 bundled runtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"bccli": "./
|
|
7
|
+
"bccli": "./bccli-bootstrap.cjs"
|
|
8
8
|
},
|
|
9
9
|
"engines": {
|
|
10
10
|
"node": ">=22"
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"test": "node --test test/*.test.mjs",
|
|
25
25
|
"test:security": "node --test test/security-e2e.test.mjs test/proxy-mcp-e2e.test.mjs test/trusted-mcp.test.mjs test/proxy.test.mjs",
|
|
26
26
|
"test:integration": "node --test test-integration/*.test.mjs",
|
|
27
|
-
"build": "node --check cli.mjs && node --check core.mjs && node --check proxy.mjs && node --check gateway.mjs && node --check branding.mjs && node --check credentials.mjs && node --check
|
|
27
|
+
"build": "node --check cli.mjs && node --check core.mjs && node --check proxy.mjs && node --check gateway.mjs && node --check branding.mjs && node --check credentials.mjs && node --check dialog-models.mjs && node --check providers.mjs && node --check provider-cli.mjs && node --check startup-menu.mjs",
|
|
28
28
|
"rebuild": "npm run build && npm test && npm pack --ignore-scripts"
|
|
29
29
|
},
|
|
30
30
|
"license": "Apache-2.0",
|
|
@@ -36,12 +36,14 @@
|
|
|
36
36
|
"LICENSE",
|
|
37
37
|
"THIRD_PARTY_NOTICES.md",
|
|
38
38
|
"cli.mjs",
|
|
39
|
+
"bccli-bootstrap.cjs",
|
|
40
|
+
"session-settings.mjs",
|
|
39
41
|
"core.mjs",
|
|
40
42
|
"proxy.mjs",
|
|
41
43
|
"gateway.mjs",
|
|
42
44
|
"branding.mjs",
|
|
43
45
|
"credentials.mjs",
|
|
44
|
-
"
|
|
46
|
+
"dialog-models.mjs",
|
|
45
47
|
"providers.mjs",
|
|
46
48
|
"provider-cli.mjs",
|
|
47
49
|
"startup-menu.mjs",
|
|
@@ -79,8 +81,8 @@
|
|
|
79
81
|
},
|
|
80
82
|
"bccliRuntimeSource": {
|
|
81
83
|
"repository": "https://github.com/farijarifriyanto-debug/bccli-runtime",
|
|
82
|
-
"commit": "
|
|
83
|
-
"release": "bccli-v0.5.
|
|
84
|
-
"originalSha256": "
|
|
84
|
+
"commit": "999fe7d6179954f45e9492c55a8384cd93a51737",
|
|
85
|
+
"release": "bccli-v0.5.4",
|
|
86
|
+
"originalSha256": "52702af547846044b9aa36d0f7be33816d3fd26cdcb05f8bb3bdd30b3d290da3"
|
|
85
87
|
}
|
|
86
88
|
}
|
package/provider-command.mjs
CHANGED
|
@@ -21,12 +21,18 @@ export const BCCLI_PROVIDER_COMMAND = String.raw`var authCommand={name:"provider
|
|
|
21
21
|
if(!Array.isArray(data?.providers))return response("error","Provider list unavailable.");
|
|
22
22
|
var providers=data.providers;
|
|
23
23
|
var current=data.active||"(unknown)";
|
|
24
|
+
// OpenTUI renders the upstream AuthDialog for dialog:"auth", which would
|
|
25
|
+
// expose provider settings BCCLI does not own. Keep the same gateway-owned
|
|
26
|
+
// text flow there instead of opening that dialog.
|
|
27
|
+
var openTui=process.env.QWEN_TUI_RENDERER==="opentui";
|
|
28
|
+
var listText=()=>"BCCLI providers — active: "+current+"\n\n"+providers.map((p,i)=>(i+1)+". "+p.id+" — "+p.name+(p.active?" [ACTIVE]":"")+(p.configured?" [configured]":" [API key required]")).join("\n")+"\n\nChoose: /provider NUMBER or /provider NAME\nSave/replace key: /provider add NAME\nDelete saved key: /provider remove NAME\nSelect AI: /provider NAME MODEL_ID\nKeys open a hidden Windows dialog; NEVER paste keys into chat.";
|
|
24
29
|
if(!params.length){
|
|
30
|
+
if(openTui)return response("info",listText());
|
|
25
31
|
delete process.env.BCCLI_PROVIDER_PICKER_FOCUS;
|
|
26
32
|
return {type:"dialog",dialog:"auth"};
|
|
27
33
|
}
|
|
28
34
|
if(params[0]==="list"){
|
|
29
|
-
return response("info",
|
|
35
|
+
return response("info",listText());
|
|
30
36
|
}
|
|
31
37
|
if(params.length===1&¶ms[0].toLowerCase()==="add"){
|
|
32
38
|
const created=await request("/bccli/provider/custom/add",{method:"POST",timeoutMs:190000,
|
|
@@ -53,7 +59,7 @@ export const BCCLI_PROVIDER_COMMAND = String.raw`var authCommand={name:"provider
|
|
|
53
59
|
return response("error","The key operation was not confirmed.");
|
|
54
60
|
}
|
|
55
61
|
var id=/^[0-9]+$/.test(params[0])?providers[Number(params[0])-1]?.id:params[0].toLowerCase();
|
|
56
|
-
if(params.length===1&&providers.some(p=>p.id===id)){
|
|
62
|
+
if(params.length===1&&providers.some(p=>p.id===id)&&!openTui){
|
|
57
63
|
process.env.BCCLI_PROVIDER_PICKER_FOCUS=id;
|
|
58
64
|
return {type:"dialog",dialog:"auth"};
|
|
59
65
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// A provider-scoped QWEN_HOME is intentionally separate from ~/.qwen.
|
|
2
|
+
// The upstream engine warns when the isolated directory has no settings.json;
|
|
3
|
+
// create only the minimal blank configuration, never migrate tokens or plugins.
|
|
4
|
+
import { existsSync, lstatSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
5
|
+
import { dirname, join } from 'node:path'
|
|
6
|
+
|
|
7
|
+
export function ensureIsolatedSessionSettings(home) {
|
|
8
|
+
if (typeof home !== 'string' || !home.trim()) throw Error('BCCLI session home missing.')
|
|
9
|
+
mkdirSync(home, { recursive: true, mode: 0o700 })
|
|
10
|
+
for (const dir of [dirname(home), home]) {
|
|
11
|
+
const info = lstatSync(dir)
|
|
12
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw Error('BCCLI session directory must not be a symlink.')
|
|
13
|
+
}
|
|
14
|
+
const file = join(home, 'settings.json')
|
|
15
|
+
if (existsSync(file)) {
|
|
16
|
+
if (!lstatSync(file).isFile()) throw Error('BCCLI session settings must be a regular file.')
|
|
17
|
+
return false
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
writeFileSync(file, '{}\n', { flag: 'wx', mode: 0o600 })
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if (error.code !== 'EEXIST' || !lstatSync(file).isFile()) throw error
|
|
23
|
+
return false
|
|
24
|
+
}
|
|
25
|
+
return true
|
|
26
|
+
}
|
package/startup-menu.mjs
CHANGED
|
@@ -14,19 +14,24 @@ export async function autoSelectStartup({
|
|
|
14
14
|
providerId = selectedProvider(),
|
|
15
15
|
configured = isConfigured,
|
|
16
16
|
remembered = selectedModel,
|
|
17
|
+
dialogModel = () => undefined,
|
|
17
18
|
resolve = resolveProvider,
|
|
18
19
|
list = discoverModels,
|
|
19
20
|
defaultModel = DEFAULT_MODEL,
|
|
20
21
|
env = process.env,
|
|
21
22
|
} = {}) {
|
|
22
23
|
if (!configured(providerId)) return undefined
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
// A model chosen in the upstream /model picker is persisted in the engine's
|
|
25
|
+
// per-provider settings; it wins over the older active-model file, but only
|
|
26
|
+
// when the live catalog still offers it.
|
|
27
|
+
const candidates = providerId === 'botconnector'
|
|
28
|
+
? [env.BOTCONNECTOR_MODEL_ID, dialogModel(providerId), remembered(providerId), defaultModel]
|
|
29
|
+
: [dialogModel(providerId), remembered(providerId)]
|
|
30
|
+
if (!candidates.some(Boolean)) return undefined
|
|
27
31
|
const profile = resolve(providerId)
|
|
28
32
|
const models = await list({ key: profile.key, endpoint: profile.endpoint })
|
|
29
|
-
|
|
33
|
+
const requested = candidates.find(id => id && models.some(item => item.id === id))
|
|
34
|
+
if (!requested) return undefined
|
|
30
35
|
return { providerId, model: selectModel(models, requested), key: profile.key, endpoint: profile.endpoint }
|
|
31
36
|
}
|
|
32
37
|
|
|
@@ -52,6 +57,7 @@ export async function startupMenu({
|
|
|
52
57
|
saveKey = saveProviderKey,
|
|
53
58
|
use = useProvider,
|
|
54
59
|
remembered = selectedModel,
|
|
60
|
+
dialogModel = () => undefined,
|
|
55
61
|
saveModel = saveSelectedModel,
|
|
56
62
|
resolve = resolveProvider,
|
|
57
63
|
list = discoverModels,
|
|
@@ -90,7 +96,7 @@ export async function startupMenu({
|
|
|
90
96
|
const models = await list({ key: profile.key, endpoint: profile.endpoint })
|
|
91
97
|
if (!models.length) throw Error('Tidak ada model untuk provider ini.')
|
|
92
98
|
const all = [...models].sort((a, b) => a.id.localeCompare(b.id))
|
|
93
|
-
const previous = remembered(providerId)
|
|
99
|
+
const previous = dialogModel(providerId) ?? remembered(providerId)
|
|
94
100
|
const preferred = all.some(m => m.id === previous) ? previous :
|
|
95
101
|
providerId === 'botconnector' && all.some(m => m.id === defaultModel) ? defaultModel : undefined
|
|
96
102
|
let filtered = all
|
package/model-command.mjs
DELETED
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
// Generated, version-pinned Qwen slash command overlay for /model.
|
|
2
|
-
// Fetches the authenticated model catalog from the existing local BotConnector
|
|
3
|
-
// proxy on each invocation. Selection changes Qwen Config in the current
|
|
4
|
-
// session; it never opens upstream provider settings or writes project config.
|
|
5
|
-
export const BCCLI_MODEL_COMMAND = String.raw`var bccliModelLastList=[];var modelCommand={name:"model",get description(){return "Choose an AI model from the active provider"},argumentHint:"[number|model-id|search WORD|page N]",kind:"built-in",supportedModes:["interactive","non_interactive","acp"],action:__name(async(context,argumentText)=>{
|
|
6
|
-
var response=(type,content)=>({type:"message",messageType:type,content});
|
|
7
|
-
try{
|
|
8
|
-
var base=process.env.OPENAI_BASE_URL||"";
|
|
9
|
-
var key=process.env.OPENAI_API_KEY||"";
|
|
10
|
-
if(!/^http:\/\/127\.0\.0\.1:[0-9]{1,5}\/v1$/.test(base)||!key){
|
|
11
|
-
return response("error","BCCLI requires an authenticated local provider session.");
|
|
12
|
-
}
|
|
13
|
-
var result=await fetch(base+"/models",{method:"GET",headers:{Authorization:"Bearer "+key,Accept:"application/json"},redirect:"error",signal:AbortSignal.timeout(10000)});
|
|
14
|
-
if(!result.ok)return response("error","Provider model catalog is unavailable (HTTP "+result.status+"). No provider fallback.");
|
|
15
|
-
var payload=await result.json();
|
|
16
|
-
if(!Array.isArray(payload?.data))return response("error","Invalid provider model catalog.");
|
|
17
|
-
var catalog=new Map();
|
|
18
|
-
for(var entry of payload.data){
|
|
19
|
-
if(typeof entry?.id==="string" && /^[a-zA-Z0-9][a-zA-Z0-9._:/+-]{0,255}$/.test(entry.id) && !catalog.has(entry.id)){
|
|
20
|
-
catalog.set(entry.id,{id:entry.id,name:typeof entry.name==="string"?entry.name.slice(0,90):entry.id});
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
var all=[...catalog.values()].sort((a,b)=>a.id.localeCompare(b.id));
|
|
24
|
-
if(!all.length)return response("error","No provider models available for this API key.");
|
|
25
|
-
var text=String(context.invocation?.args||argumentText||"").trim();
|
|
26
|
-
var config=context.services?.config;
|
|
27
|
-
if(!config||typeof config.setModel!=="function")return response("error","BCCLI model switch is unavailable in this session.");
|
|
28
|
-
var current=config.getModel?.()||process.env.OPENAI_MODEL||"(unknown)";
|
|
29
|
-
var list=(entries,page=1)=>{
|
|
30
|
-
bccliModelLastList=entries.map(x=>x.id);
|
|
31
|
-
var size=12;
|
|
32
|
-
var pages=Math.max(1,Math.ceil(entries.length/size));
|
|
33
|
-
if(!Number.isSafeInteger(page)||page<1||page>pages)return response("error","Invalid page. Use /model page 1 ... "+pages);
|
|
34
|
-
var start=(page-1)*size;
|
|
35
|
-
var rows=entries.slice(start,start+size).map((x,i)=>(start+i+1)+". "+x.id+(x.id===current?" [ACTIVE]":""));
|
|
36
|
-
return response("info","Provider AI models ("+entries.length+" available; page "+page+"/"+pages+")\nActive: "+current+"\n\n"+rows.join("\n")+"\n\nUse /model NUMBER or /model MODEL_ID to switch now; /model page N; /model search KEYWORD. To manage providers and their keys in this chat, use /provider, /provider add NAME or /provider remove NAME; then /provider NAME MODEL_ID.");
|
|
37
|
-
};
|
|
38
|
-
if(!text)return list(all);
|
|
39
|
-
var page=text.match(/^page\s+([0-9]+)$/i);
|
|
40
|
-
if(page)return list(all,Number(page[1]));
|
|
41
|
-
var search=text.match(/^search\s+(.{1,80})$/i);
|
|
42
|
-
if(search)return list(all.filter(x=>x.id.toLowerCase().includes(search[1].toLowerCase())||x.name.toLowerCase().includes(search[1].toLowerCase())));
|
|
43
|
-
var item;
|
|
44
|
-
if(/^[0-9]+$/.test(text)){
|
|
45
|
-
var index=Number(text);
|
|
46
|
-
item=Number.isSafeInteger(index)&&index>=1?catalog.get(bccliModelLastList[index-1]||all[index-1]?.id):undefined;
|
|
47
|
-
}else if(/^[a-zA-Z0-9][a-zA-Z0-9._:/+-]{0,255}$/.test(text)){
|
|
48
|
-
item=catalog.get(text);
|
|
49
|
-
}
|
|
50
|
-
if(!item)return response("error","That model is not available in your active provider catalog. Run /model to view allowed models.");
|
|
51
|
-
if(item.id===current)return response("info","Already using "+item.id+" via the active provider.");
|
|
52
|
-
// Raw model IDs are supported by Config.setModel; this preserves OpenAI
|
|
53
|
-
// auth/loopback proxy instead of switching providers. It refreshes the
|
|
54
|
-
// live content generator, so the next message uses the new model.
|
|
55
|
-
await config.setModel(item.id,{reason:"bccli-gateway-model-picker"});
|
|
56
|
-
if(config.getModel?.()!==item.id)return response("error","Model switch did not take effect; previous model preserved.");
|
|
57
|
-
return response("info","Switched to "+item.id+" in the current session. Routing and billing remain with the active provider.");
|
|
58
|
-
}catch{
|
|
59
|
-
return response("error","Provider model selection failed; no upstream provider fallback was used.");
|
|
60
|
-
}
|
|
61
|
-
},"action")};`
|