@botconnector/bccli 0.4.16 → 0.5.0-rc.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.
Files changed (185) hide show
  1. package/LICENSE +203 -21
  2. package/THIRD_PARTY_NOTICES.md +9 -0
  3. package/branding.mjs +347 -0
  4. package/cli.mjs +120 -0
  5. package/core.mjs +260 -0
  6. package/credentials.mjs +52 -0
  7. package/gateway.mjs +2 -0
  8. package/model-command.mjs +61 -0
  9. package/package.json +35 -59
  10. package/provider-cli.mjs +77 -0
  11. package/providers.mjs +166 -0
  12. package/proxy.mjs +242 -0
  13. package/startup-menu.mjs +120 -0
  14. package/README.en.md +0 -253
  15. package/README.md +0 -250
  16. package/dist/abort.d.ts +0 -5
  17. package/dist/acp-JZW3XHE5.js +0 -221
  18. package/dist/acp.d.ts +0 -44
  19. package/dist/agent.d.ts +0 -147
  20. package/dist/agents.d.ts +0 -4
  21. package/dist/args-XVO7TB4C.js +0 -13
  22. package/dist/args.d.ts +0 -59
  23. package/dist/budget.d.ts +0 -26
  24. package/dist/checkpoints.d.ts +0 -35
  25. package/dist/chunk-3CYQYHPK.js +0 -202
  26. package/dist/chunk-6JK4IBGK.js +0 -1075
  27. package/dist/chunk-6SDRWEPN.js +0 -50
  28. package/dist/chunk-7GM6NN5E.js +0 -341
  29. package/dist/chunk-7XD47KN5.js +0 -71
  30. package/dist/chunk-INMSTUB2.js +0 -56
  31. package/dist/chunk-ISOKH5WB.js +0 -256
  32. package/dist/chunk-JUC3ZOCV.js +0 -50
  33. package/dist/chunk-KDVL2NP6.js +0 -861
  34. package/dist/chunk-KEKDRROL.js +0 -216
  35. package/dist/chunk-KJIXA6MP.js +0 -159
  36. package/dist/chunk-OK2USAXX.js +0 -88
  37. package/dist/chunk-PSO7R5AD.js +0 -10
  38. package/dist/chunk-Q42ZGVAQ.js +0 -91
  39. package/dist/chunk-R754XIBJ.js +0 -523
  40. package/dist/chunk-S3QEZDKB.js +0 -270
  41. package/dist/chunk-SWNRA3EW.js +0 -78
  42. package/dist/chunk-TLCL6DC7.js +0 -378
  43. package/dist/chunk-V5D6PJQN.js +0 -8
  44. package/dist/chunk-YYTFJUO2.js +0 -148
  45. package/dist/chunk-ZJRZTMJF.js +0 -2669
  46. package/dist/cli.d.ts +0 -1
  47. package/dist/cli.js +0 -233
  48. package/dist/clipboardImage.d.ts +0 -17
  49. package/dist/commands.d.ts +0 -16
  50. package/dist/completion-Q7VHOPPV.js +0 -125
  51. package/dist/completion.d.ts +0 -14
  52. package/dist/config-T7MKGJVG.js +0 -34
  53. package/dist/config-YYEAEN2C.js +0 -25
  54. package/dist/config.d.ts +0 -99
  55. package/dist/context.d.ts +0 -13
  56. package/dist/contextWindow.d.ts +0 -3
  57. package/dist/diff.d.ts +0 -7
  58. package/dist/exit.d.ts +0 -9
  59. package/dist/extensions.d.ts +0 -34
  60. package/dist/ghBotCli-7VUGI63S.js +0 -680
  61. package/dist/ghBotCli.d.ts +0 -9
  62. package/dist/ghBotDispatch.d.ts +0 -68
  63. package/dist/ghBotRunner.d.ts +0 -82
  64. package/dist/ghBotServe.d.ts +0 -21
  65. package/dist/ghBotWebhook.d.ts +0 -45
  66. package/dist/hooks.d.ts +0 -27
  67. package/dist/i18n/id.d.ts +0 -1
  68. package/dist/i18n.d.ts +0 -16
  69. package/dist/integrationCli-FBRSTF5F.js +0 -876
  70. package/dist/integrationCli.d.ts +0 -9
  71. package/dist/keybinds.d.ts +0 -22
  72. package/dist/login-BSMUGW7L.js +0 -18
  73. package/dist/login.d.ts +0 -21
  74. package/dist/lsp.d.ts +0 -73
  75. package/dist/mcp/catalog.d.ts +0 -14
  76. package/dist/mcp/config.d.ts +0 -36
  77. package/dist/mcp/manager.d.ts +0 -52
  78. package/dist/mcp/oauth.d.ts +0 -82
  79. package/dist/mcpCli-ZT7CRYMP.js +0 -113
  80. package/dist/mcpCli.d.ts +0 -3
  81. package/dist/mcpServer/serve.d.ts +0 -8
  82. package/dist/mcpServer/server.d.ts +0 -19
  83. package/dist/models.d.ts +0 -13
  84. package/dist/modelsCli-2ZRW4CC4.js +0 -36
  85. package/dist/modelsCli.d.ts +0 -15
  86. package/dist/npmRunner-LU43GM3W.js +0 -46
  87. package/dist/npmRunner.d.ts +0 -21
  88. package/dist/oauthLogin-THSM6FE7.js +0 -210
  89. package/dist/oauthLogin.d.ts +0 -47
  90. package/dist/permissions.d.ts +0 -30
  91. package/dist/pluginSpec.d.ts +0 -25
  92. package/dist/plugins.d.ts +0 -45
  93. package/dist/pluginsCli-EKQI4CJW.js +0 -288
  94. package/dist/pluginsCli.d.ts +0 -16
  95. package/dist/prCli-FMQTOBIV.js +0 -94
  96. package/dist/prCli.d.ts +0 -46
  97. package/dist/presets.d.ts +0 -8
  98. package/dist/print.d.ts +0 -17
  99. package/dist/provider.d.ts +0 -145
  100. package/dist/providerCli-3TFEKV4C.js +0 -15
  101. package/dist/providerCli.d.ts +0 -17
  102. package/dist/providers.d.ts +0 -6
  103. package/dist/reasoning.d.ts +0 -12
  104. package/dist/repomap.d.ts +0 -10
  105. package/dist/sdk.d.ts +0 -61
  106. package/dist/sdk.js +0 -6494
  107. package/dist/serve-3BODPEPR.js +0 -532
  108. package/dist/serve-V6YVZTNR.js +0 -178
  109. package/dist/serve.d.ts +0 -37
  110. package/dist/serveHeartbeat.d.ts +0 -14
  111. package/dist/serveProtocol.d.ts +0 -47
  112. package/dist/session-LX4UNPCD.js +0 -12
  113. package/dist/session.d.ts +0 -33
  114. package/dist/setup-MFG3BWEU.js +0 -24
  115. package/dist/setup.d.ts +0 -79
  116. package/dist/slash/copy.d.ts +0 -1
  117. package/dist/slash/diff.d.ts +0 -1
  118. package/dist/slash/doctor.d.ts +0 -18
  119. package/dist/slash/export.d.ts +0 -2
  120. package/dist/slash/info.d.ts +0 -46
  121. package/dist/slash/memory.d.ts +0 -10
  122. package/dist/statsCli-7MIQHLVW.js +0 -353
  123. package/dist/statsCli.d.ts +0 -62
  124. package/dist/textToolCalls.d.ts +0 -6
  125. package/dist/thinking.d.ts +0 -17
  126. package/dist/tokenBudget.d.ts +0 -22
  127. package/dist/tools/bash.d.ts +0 -54
  128. package/dist/tools/diagnostics.d.ts +0 -13
  129. package/dist/tools/edit.d.ts +0 -12
  130. package/dist/tools/fetch.d.ts +0 -41
  131. package/dist/tools/glob.d.ts +0 -5
  132. package/dist/tools/globSafety.d.ts +0 -7
  133. package/dist/tools/grep.d.ts +0 -25
  134. package/dist/tools/ignore.d.ts +0 -2
  135. package/dist/tools/index.d.ts +0 -12
  136. package/dist/tools/memory.d.ts +0 -13
  137. package/dist/tools/paths.d.ts +0 -4
  138. package/dist/tools/plan.d.ts +0 -10
  139. package/dist/tools/read.d.ts +0 -16
  140. package/dist/tools/repomap.d.ts +0 -2
  141. package/dist/tools/sandbox.d.ts +0 -15
  142. package/dist/tools/shell.d.ts +0 -18
  143. package/dist/tools/skill.d.ts +0 -3
  144. package/dist/tools/task.d.ts +0 -28
  145. package/dist/tools/todo.d.ts +0 -13
  146. package/dist/tools/types.d.ts +0 -52
  147. package/dist/tools/websearch.d.ts +0 -9
  148. package/dist/tools/write.d.ts +0 -5
  149. package/dist/transferCli-SRSYQLSJ.js +0 -73
  150. package/dist/transferCli.d.ts +0 -23
  151. package/dist/ui/App.d.ts +0 -7
  152. package/dist/ui/DiffView.d.ts +0 -3
  153. package/dist/ui/EditableText.d.ts +0 -7
  154. package/dist/ui/LinePrompt.d.ts +0 -6
  155. package/dist/ui/ListPicker.d.ts +0 -11
  156. package/dist/ui/Markdown.d.ts +0 -8
  157. package/dist/ui/McpMenu.d.ts +0 -11
  158. package/dist/ui/ModelPicker.d.ts +0 -6
  159. package/dist/ui/PermissionPrompt.d.ts +0 -5
  160. package/dist/ui/PlanApproval.d.ts +0 -5
  161. package/dist/ui/PromptInput.d.ts +0 -20
  162. package/dist/ui/ProviderMenu.d.ts +0 -10
  163. package/dist/ui/Spinner.d.ts +0 -4
  164. package/dist/ui/StatusBar.d.ts +0 -9
  165. package/dist/ui/TodoList.d.ts +0 -4
  166. package/dist/ui/ToolBlock.d.ts +0 -14
  167. package/dist/ui/complete.d.ts +0 -8
  168. package/dist/ui/index.d.ts +0 -7
  169. package/dist/ui/lineEdit.d.ts +0 -42
  170. package/dist/ui/liveCap.d.ts +0 -9
  171. package/dist/ui/replay.d.ts +0 -4
  172. package/dist/ui/stdinFilter.d.ts +0 -18
  173. package/dist/ui/table.d.ts +0 -20
  174. package/dist/ui/theme.d.ts +0 -5
  175. package/dist/ui/thinkingMouse.d.ts +0 -38
  176. package/dist/ui/transcript.d.ts +0 -55
  177. package/dist/ui/tty.d.ts +0 -11
  178. package/dist/ui/vim.d.ts +0 -26
  179. package/dist/ui-LZ4JGF2V.js +0 -2913
  180. package/dist/update-P7MU4ZM7.js +0 -145
  181. package/dist/update.d.ts +0 -30
  182. package/dist/verify.d.ts +0 -16
  183. package/dist/version.d.ts +0 -1
  184. package/dist/webBudget.d.ts +0 -25
  185. package/dist/worktree.d.ts +0 -12
@@ -0,0 +1,77 @@
1
+ // Provider management is an explicitly invoked CLI operation, not a model prompt.
2
+ import { PROVIDERS, isConfigured, providerDefinition, removeProvider, saveProviderKey, selectedProvider, useProvider } from './providers.mjs'
3
+
4
+ export async function hiddenApiKey({ input = process.stdin, output = process.stdout } = {}) {
5
+ if (!input.isTTY || typeof input.setRawMode !== 'function') throw Error('Provider add requires an interactive terminal to enter a hidden API key.')
6
+ output.write('Paste API key (hidden; Enter to save, Ctrl+C to cancel): ')
7
+ return new Promise((resolve, reject) => {
8
+ let value = ''
9
+ const finish = (error) => {
10
+ input.off('data', onData)
11
+ input.off('end', onEnd)
12
+ input.setRawMode(false)
13
+ input.pause()
14
+ output.write('\n')
15
+ error ? reject(error) : resolve(value)
16
+ }
17
+ const onEnd = () => finish(Error('API key input ended before confirmation.'))
18
+ const onData = buffer => {
19
+ for (const ch of buffer.toString('utf8')) {
20
+ if (ch === '\u0003') return finish(Error('Cancelled.'))
21
+ if (ch === '\r' || ch === '\n') return finish(value ? null : Error('Empty API key; nothing saved.'))
22
+ if (ch === '\u007f' || ch === '\b') { value = value.slice(0, -1); continue }
23
+ if (ch.charCodeAt(0) < 32) continue
24
+ value += ch
25
+ if (value.length > 8192) return finish(Error('API key is too long; nothing saved.'))
26
+ }
27
+ }
28
+ input.on('data', onData)
29
+ input.on('end', onEnd)
30
+ input.setRawMode(true)
31
+ input.resume()
32
+ })
33
+ }
34
+
35
+ export async function runProviderCommand(words, {
36
+ env = process.env,
37
+ getKey = hiddenApiKey,
38
+ out = text => console.log(text),
39
+ save = saveProviderKey,
40
+ select = useProvider,
41
+ remove = removeProvider,
42
+ configured = isConfigured,
43
+ active = selectedProvider,
44
+ } = {}) {
45
+ const [action = 'list', id, ...extra] = words
46
+ if (!['list', 'add', 'use', 'remove'].includes(action) || (action === 'list' ? words.length > 1 : !id || extra.length)) {
47
+ throw Error('Use: provider list | add PROVIDER | use PROVIDER | remove PROVIDER.')
48
+ }
49
+ if (action === 'list') {
50
+ const current = active({env})
51
+ for (const [name, info] of Object.entries(PROVIDERS)) {
52
+ out(name + ' - ' + info.name + ' [' + (configured(name,{env}) ? 'configured' : 'no key') + ']' + (name === current ? ' [ACTIVE]' : ''))
53
+ }
54
+ out('Add: bccli provider add openrouter; select: bccli provider use openrouter')
55
+ out('Provider keys are stored encrypted for your Windows user. Other systems: use the provider environment variable.')
56
+ return 0
57
+ }
58
+ providerDefinition(id)
59
+ if (action === 'add') {
60
+ if (id === 'botconnector') throw Error('For BotConnector, use the existing "bccli-legacy login bc-cloud".')
61
+ out('Provider: ' + PROVIDERS[id].name + ' (direct API; provider billing and terms apply).')
62
+ const key = await getKey()
63
+ save(id, key)
64
+ select(id,{env})
65
+ out('API key securely saved and provider selected: ' + id + '. Key was not printed. No AI request sent.')
66
+ out('Run: bccli models, then bccli chat --model MODEL_ID')
67
+ return 0
68
+ }
69
+ if (action === 'use') {
70
+ select(id,{env})
71
+ out('Active provider: ' + id + '. New sessions only; existing chats are unchanged.')
72
+ return 0
73
+ }
74
+ remove(id,{env})
75
+ out('Saved provider key removed for ' + id + '. Environment variables, if set, are not changed.')
76
+ return 0
77
+ }
package/providers.mjs ADDED
@@ -0,0 +1,166 @@
1
+ // BCCLI Preview provider keys: explicit OpenAI-compatible presets only.
2
+ // Windows stores provider API keys as per-user DPAPI encrypted blobs. The
3
+ // upstream Qwen process receives only an ephemeral loopback proxy token.
4
+ import { randomBytes } from 'node:crypto'
5
+ import { spawnSync } from 'node:child_process'
6
+ import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
7
+ import { homedir } from 'node:os'
8
+ import { isAbsolute, join } from 'node:path'
9
+ import { botConnectorCredentialStatus, botConnectorKey } from './credentials.mjs'
10
+ import { GATEWAY } from './gateway.mjs'
11
+
12
+ export const PROVIDERS = Object.freeze({
13
+ botconnector: Object.freeze({ name: 'BotConnector Cloud', endpoint: GATEWAY }),
14
+ openai: Object.freeze({ name: 'OpenAI API', endpoint: 'https://api.openai.com/v1', env: 'OPENAI_API_KEY' }),
15
+ openrouter: Object.freeze({ name: 'OpenRouter', endpoint: 'https://openrouter.ai/api/v1', env: 'OPENROUTER_API_KEY' }),
16
+ deepseek: Object.freeze({ name: 'DeepSeek', endpoint: 'https://api.deepseek.com/v1', env: 'DEEPSEEK_API_KEY' }),
17
+ groq: Object.freeze({ name: 'Groq', endpoint: 'https://api.groq.com/openai/v1', env: 'GROQ_API_KEY' }),
18
+ xai: Object.freeze({ name: 'xAI', endpoint: 'https://api.x.ai/v1', env: 'XAI_API_KEY' }),
19
+ })
20
+ export const ALLOWED_PROVIDER_ENDPOINTS = Object.freeze(Object.values(PROVIDERS).map(p => p.endpoint))
21
+ const ID = /^[a-z][a-z0-9-]{0,31}$/
22
+ const MAX_BLOB_BYTES = 64 * 1024
23
+ const ENC_SCRIPT = '$ErrorActionPreference="Stop"; $plain=[Console]::In.ReadToEnd().Trim(); if(-not $plain){exit 1}; $sec=ConvertTo-SecureString -String $plain -AsPlainText -Force; [Console]::Out.Write((ConvertFrom-SecureString -SecureString $sec))'
24
+ const DEC_SCRIPT = '$ErrorActionPreference="Stop"; $encoded=[Console]::In.ReadToEnd().Trim(); $sec=ConvertTo-SecureString -String $encoded; $ptr=[Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec); try{[Console]::Out.Write([Runtime.InteropServices.Marshal]::PtrToStringBSTR($ptr))}finally{[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr)}'
25
+
26
+ export function providerRoot({ home = homedir(), env = process.env } = {}) {
27
+ const path = env.BCCLI_PREVIEW_HOME || join(home, '.bccli-preview')
28
+ if (!isAbsolute(path)) throw Error('BCCLI_PREVIEW_HOME must be absolute.')
29
+ return path
30
+ }
31
+ export function providerDefinition(id) {
32
+ if (!Object.hasOwn(PROVIDERS, id)) throw Error('Unknown provider. Choose from: ' + Object.keys(PROVIDERS).join(', '))
33
+ return PROVIDERS[id]
34
+ }
35
+ function ownerDirectory(root, { create = false } = {}) {
36
+ if (create) mkdirSync(root, { recursive: true, mode: 0o700 })
37
+ if (!existsSync(root)) return false
38
+ const info = lstatSync(root)
39
+ if (!info.isDirectory() || info.isSymbolicLink()) throw Error('Preview credential directory is not a safe regular directory.')
40
+ return true
41
+ }
42
+ function keyPath(root, id) {
43
+ if (!ID.test(id) || id === 'botconnector') throw Error('Provider key storage requires a supported non-BotConnector provider.')
44
+ return join(root, 'provider-' + id + '.dpapi')
45
+ }
46
+ function dpapi(script, input, { platform = process.platform, runner = spawnSync } = {}) {
47
+ if (platform !== 'win32') throw Error('Persisting provider API keys currently requires Windows DPAPI. Use the provider environment variable on other systems.')
48
+ const result = runner('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
49
+ input, encoding: 'utf8', timeout: 15000, maxBuffer: MAX_BLOB_BYTES, windowsHide: true,
50
+ env: { ...process.env, PSModulePath: process.env.PSModulePath },
51
+ })
52
+ if (result.error || result.status !== 0 || !result.stdout?.trim()) throw Error('Windows protected credential operation failed; no key was printed.')
53
+ return result.stdout.trim()
54
+ }
55
+ export function saveProviderKey(id, key, { root = providerRoot(), platform = process.platform, runner = spawnSync } = {}) {
56
+ providerDefinition(id)
57
+ if (id === 'botconnector') throw Error('Use bccli-legacy login bc-cloud or BOTCONNECTOR_API_KEY for BotConnector credentials.')
58
+ if (typeof key !== 'string' || !key.trim() || key.length > 8192 || /[\r\n]/.test(key)) throw Error('API key cannot be empty, multiline, or oversized.')
59
+ if (platform !== 'win32') throw Error('On non-Windows systems, use the provider API key environment variable.')
60
+ ownerDirectory(root, { create: true })
61
+ const ciphertext = dpapi(ENC_SCRIPT, key.trim(), { platform, runner })
62
+ if (ciphertext.length > MAX_BLOB_BYTES || !/^[0-9a-f]+$/i.test(ciphertext)) throw Error('Windows credential encryption returned an invalid format.')
63
+ const path = keyPath(root, id)
64
+ if (existsSync(path) && (!lstatSync(path).isFile() || lstatSync(path).isSymbolicLink())) throw Error('Unsafe provider credentials path.')
65
+ const temporary = path + '.' + randomBytes(8).toString('hex') + '.tmp'
66
+ try {
67
+ writeFileSync(temporary, ciphertext + '\n', { encoding: 'utf8', flag: 'wx', mode: 0o600 })
68
+ renameSync(temporary, path)
69
+ } finally { rmSync(temporary, { force: true }) }
70
+ }
71
+ export function storedProviderKey(id, { root = providerRoot(), platform = process.platform, runner = spawnSync } = {}) {
72
+ providerDefinition(id)
73
+ if (id === 'botconnector') throw Error('BotConnector credentials are managed by public BCCLI.')
74
+ if (!ownerDirectory(root)) throw Error('Provider not configured. Run provider add.')
75
+ const path = keyPath(root, id)
76
+ let info
77
+ try { info = lstatSync(path) }
78
+ catch { throw Error('Provider not configured. Run provider add.') }
79
+ if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_BLOB_BYTES) throw Error('Unsafe provider credentials file.')
80
+ const cipher = readFileSync(path, 'utf8')
81
+ if (!/^[0-9a-f]+\s*$/i.test(cipher)) throw Error('Provider credentials are invalid.')
82
+ return dpapi(DEC_SCRIPT, cipher, { platform, runner })
83
+ }
84
+ export function isConfigured(id, { env = process.env, root = providerRoot({env}) } = {}) {
85
+ providerDefinition(id)
86
+ if (id === 'botconnector') return botConnectorCredentialStatus({ env }).configured
87
+ if (env[PROVIDERS[id].env]?.trim()) return true
88
+ try { return ownerDirectory(root) && lstatSync(keyPath(root, id)).isFile() && !lstatSync(keyPath(root, id)).isSymbolicLink() }
89
+ catch { return false }
90
+ }
91
+ export function selectedProvider({ env = process.env, root = providerRoot({env}) } = {}) {
92
+ if (!ownerDirectory(root)) return 'botconnector'
93
+ const path = join(root, 'active-provider')
94
+ if (!existsSync(path)) return 'botconnector'
95
+ const info = lstatSync(path)
96
+ if (!info.isFile() || info.isSymbolicLink() || info.size > 128) throw Error('Unsafe provider selection file.')
97
+ const id = readFileSync(path, 'utf8').trim()
98
+ providerDefinition(id)
99
+ return id
100
+ }
101
+ // Model IDs are public selections, never credentials. Keep one per provider
102
+ // so a returning user goes directly to chat without repeating the model picker.
103
+ const MODEL_ID = /^[a-zA-Z0-9][a-zA-Z0-9._:/+-]{0,255}$/
104
+ export function selectedModel(id, { env = process.env, root = providerRoot({ env }) } = {}) {
105
+ providerDefinition(id)
106
+ if (!ownerDirectory(root)) return undefined
107
+ const path = join(root, 'active-model-' + id)
108
+ if (!existsSync(path)) return undefined
109
+ const info = lstatSync(path)
110
+ if (!info.isFile() || info.isSymbolicLink() || info.size > 300) throw Error('Unsafe model selection file.')
111
+ const value = readFileSync(path, 'utf8').trim()
112
+ if (!MODEL_ID.test(value)) throw Error('Invalid saved model selection.')
113
+ return value
114
+ }
115
+
116
+ export function saveSelectedModel(id, model, { env = process.env, root = providerRoot({ env }) } = {}) {
117
+ providerDefinition(id)
118
+ if (!MODEL_ID.test(model)) throw Error('Invalid model ID.')
119
+ ownerDirectory(root, { create: true })
120
+ const path = join(root, 'active-model-' + id)
121
+ if (existsSync(path) && (!lstatSync(path).isFile() || lstatSync(path).isSymbolicLink())) {
122
+ throw Error('Unsafe model selection path.')
123
+ }
124
+ const tmp = path + '.' + randomBytes(8).toString('hex') + '.tmp'
125
+ try { writeFileSync(tmp, model + '\n', { flag: 'wx', encoding: 'utf8', mode: 0o600 }); renameSync(tmp, path) }
126
+ finally { rmSync(tmp, { force: true }) }
127
+ }
128
+
129
+ export function useProvider(id, { env = process.env, root = providerRoot({env}) } = {}) {
130
+ providerDefinition(id)
131
+ if (!isConfigured(id, { env, root })) throw Error('Provider is not configured. Use: provider add ' + id + ' (or its API key environment variable).')
132
+ ownerDirectory(root, { create: true })
133
+ const path = join(root, 'active-provider')
134
+ if (existsSync(path) && (!lstatSync(path).isFile() || lstatSync(path).isSymbolicLink())) throw Error('Unsafe provider selection path.')
135
+ const tmp = path + '.' + randomBytes(8).toString('hex') + '.tmp'
136
+ try { writeFileSync(tmp, id + '\n', { flag: 'wx', encoding: 'utf8', mode: 0o600 }); renameSync(tmp, path) }
137
+ finally { rmSync(tmp, { force: true }) }
138
+ }
139
+ export function removeProvider(id, { env = process.env, root = providerRoot({env}) } = {}) {
140
+ providerDefinition(id)
141
+ if (id === 'botconnector') throw Error('BotConnector credentials are managed by bccli-legacy or BOTCONNECTOR_API_KEY.')
142
+ if (!ownerDirectory(root)) return
143
+ const path = keyPath(root, id)
144
+ if (existsSync(path)) {
145
+ if (!lstatSync(path).isFile() || lstatSync(path).isSymbolicLink()) throw Error('Unsafe provider credential path.')
146
+ rmSync(path)
147
+ }
148
+ if (selectedProvider({env,root}) === id) {
149
+ const active = join(root, 'active-provider')
150
+ rmSync(active, {force:true})
151
+ }
152
+ }
153
+ export function resolveProvider(id, { env = process.env, root = providerRoot({env}) } = {}) {
154
+ const info = providerDefinition(id)
155
+ if (id === 'botconnector') return { id, ...info, key: botConnectorKey({env}) }
156
+ // An explicit key saved through "provider add" must win over stale environment
157
+ // variables. Never silently fall back if the encrypted file is corrupted.
158
+ let key
159
+ try { key = storedProviderKey(id, {root}) }
160
+ catch (error) {
161
+ if (error?.message !== 'Provider not configured. Run provider add.') throw error
162
+ key = env[info.env]?.trim()
163
+ }
164
+ if (!key) throw Error('Provider not configured. Run provider add ' + id + ' or set ' + info.env + '.')
165
+ return { id, ...info, key }
166
+ }
package/proxy.mjs ADDED
@@ -0,0 +1,242 @@
1
+ import { randomBytes, timingSafeEqual } from 'node:crypto'
2
+ import { createServer } from 'node:http'
3
+ import { GATEWAY } from './gateway.mjs'
4
+ import { ALLOWED_PROVIDER_ENDPOINTS } from './providers.mjs'
5
+
6
+ // This proxy lives only for one Qwen Code session. Real provider keys remain in
7
+ // the launcher process; MCP and bash subprocesses can see only a short-lived
8
+ // local bearer token. The proxy cannot be redirected to arbitrary hosts.
9
+ const ALLOWED_PATHS = new Set(['/v1/models', '/v1/chat/completions', '/v1/responses'])
10
+ const MAX_REQUEST_BYTES = 16 * 1024 * 1024
11
+
12
+ // Qwen 0.26.3 produces get_goal without parameters and read_file with
13
+ // optional JSON-schema type unions such as ["integer", "null"]. Strict
14
+ // OpenAI-compatible providers reject both formats while parsing tools.
15
+ // Normalize only the incompatible pieces, preserving all coding tools.
16
+ function normalizeOptionalNullableSchema(schema) {
17
+ if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return schema
18
+ let out = schema
19
+ if (schema.properties && typeof schema.properties === 'object' && !Array.isArray(schema.properties)) {
20
+ const required = new Set(Array.isArray(schema.required) ? schema.required : [])
21
+ let properties = schema.properties
22
+ for (const [name, field] of Object.entries(schema.properties)) {
23
+ if (!field || typeof field !== 'object' || Array.isArray(field)) continue
24
+ let candidate = field
25
+ // The optional absence of a field already represents null; dropping
26
+ // "null" from this one optional JSON-Schema union is safe. Do not rewrite
27
+ // required nullable fields or multi-type unions (fail closed upstream).
28
+ if (!required.has(name) && Array.isArray(field.type) && field.type.length === 2
29
+ && field.type.includes('null')) {
30
+ const primitive = field.type.find((type) => type !== 'null')
31
+ if (['string', 'number', 'integer', 'boolean', 'object', 'array'].includes(primitive)) {
32
+ candidate = { ...field, type: primitive }
33
+ }
34
+ }
35
+ candidate = normalizeOptionalNullableSchema(candidate)
36
+ if (candidate !== field) {
37
+ if (properties === schema.properties) properties = { ...properties }
38
+ properties[name] = candidate
39
+ }
40
+ }
41
+ if (properties !== schema.properties) out = { ...out, properties }
42
+ }
43
+ if (schema.items && typeof schema.items === 'object' && !Array.isArray(schema.items)) {
44
+ const items = normalizeOptionalNullableSchema(schema.items)
45
+ if (items !== schema.items) out = { ...out, items }
46
+ }
47
+ return out
48
+ }
49
+
50
+ export function normalizeZeroArgFunctionTools(requestBody) {
51
+ if (!requestBody || typeof requestBody !== 'object' || !Array.isArray(requestBody.tools)) return requestBody
52
+ let changed = false
53
+ const tools = requestBody.tools.map((tool) => {
54
+ const fn = tool?.type === 'function' ? tool.function : null
55
+ if (!fn || typeof fn.name !== 'string') return tool
56
+ const parameters = fn.parameters == null
57
+ ? { type: 'object', properties: {} }
58
+ : normalizeOptionalNullableSchema(fn.parameters)
59
+ if (parameters === fn.parameters) return tool
60
+ changed = true
61
+ return { ...tool, function: { ...fn, parameters } }
62
+ })
63
+ return changed ? { ...requestBody, tools } : requestBody
64
+ }
65
+
66
+ // The model can be switched with /model without restarting the coding agent.
67
+ // Annotate each request with the ACTUAL model chosen by Qwen Config, rather
68
+ // than retaining a stale --append-system-prompt model from CLI startup.
69
+ export function attachRuntimeModelContext(payload) {
70
+ if (!payload || typeof payload !== 'object' || !Array.isArray(payload.messages)) return payload
71
+ const model = payload.model
72
+ if (typeof model !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._:/+-]{0,255}$/.test(model)) return payload
73
+ const index = payload.messages.findIndex(item => item?.role === 'system' && typeof item.content === 'string')
74
+ if (index === -1) return payload
75
+ const runtime = '\n\nRuntime metadata (not model instructions): active_model_id=' + model + '; client_app=BCCLI; transport=local-proxy.'
76
+ const messages = [...payload.messages]
77
+ messages[index] = { ...messages[index], content: messages[index].content + runtime }
78
+ return { ...payload, messages }
79
+ }
80
+
81
+ // Qwen 0.26.3 sends max_tokens=64000 for many model IDs. Several Gateway
82
+ // upstream routes reject that value (e.g. GLM Flash). Bound the requested
83
+ // output conservatively for coding-agent interoperability; never increase it.
84
+ // This does not change the context window or input-token budget.
85
+ const DEFAULT_OUTPUT_TOKEN_CEILING = 4096
86
+ export function boundChatOutputTokens(payload) {
87
+ if (!payload || typeof payload !== 'object') return payload
88
+ const legacy = payload.max_tokens
89
+ const modern = payload.max_completion_tokens
90
+ const valid = (value) => Number.isInteger(value) && value > 0
91
+
92
+ // Real Gateway's exact Kimi route rejects legacy max_tokens. Keep parity
93
+ // with PAYG validation for positive decimal numeric strings, without
94
+ // changing the output-ceiling policy for any other model.
95
+ if (payload.model === 'kimi-k2.7-code') {
96
+ const parsed = (value) => {
97
+ if (typeof value !== 'number' && !(typeof value === 'string' && /^[0-9]+$/.test(value))) return null
98
+ const n = Number(value)
99
+ return Number.isSafeInteger(n) && n > 0 ? n : null
100
+ }
101
+ const legacyValue = parsed(legacy)
102
+ const modernValue = parsed(modern)
103
+ if (legacyValue != null) {
104
+ const requested = modernValue != null ? Math.min(legacyValue, modernValue) : legacyValue
105
+ const { max_tokens: _ignored, ...rest } = payload
106
+ return { ...rest, max_completion_tokens: Math.min(requested, DEFAULT_OUTPUT_TOKEN_CEILING) }
107
+ }
108
+ if (modernValue != null && (typeof modern !== 'number' || modernValue > DEFAULT_OUTPUT_TOKEN_CEILING)) {
109
+ return { ...payload, max_completion_tokens: Math.min(modernValue, DEFAULT_OUTPUT_TOKEN_CEILING) }
110
+ }
111
+ }
112
+ if (valid(legacy) && legacy > DEFAULT_OUTPUT_TOKEN_CEILING) {
113
+ return { ...payload, max_tokens: DEFAULT_OUTPUT_TOKEN_CEILING }
114
+ }
115
+ if (valid(modern) && modern > DEFAULT_OUTPUT_TOKEN_CEILING) {
116
+ return { ...payload, max_completion_tokens: DEFAULT_OUTPUT_TOKEN_CEILING }
117
+ }
118
+ return payload
119
+ }
120
+
121
+ function waitForDrainOrClose(res) {
122
+ if (res.destroyed) return Promise.resolve()
123
+ return new Promise((resolve) => {
124
+ const done = () => {
125
+ res.off('drain', done)
126
+ res.off('close', done)
127
+ resolve()
128
+ }
129
+ res.once('drain', done)
130
+ res.once('close', done)
131
+ })
132
+ }
133
+
134
+ function matchesToken(auth, token) {
135
+ if (typeof auth !== 'string') return false
136
+ const actual = Buffer.from(auth)
137
+ const expected = Buffer.from('Bearer ' + token)
138
+ return actual.length === expected.length && timingSafeEqual(actual, expected)
139
+ }
140
+
141
+ export async function startGatewayProxy({ key, endpoint = GATEWAY, token = randomBytes(32).toString('hex'), fetchImpl = fetch } = {}) {
142
+ if (!ALLOWED_PROVIDER_ENDPOINTS.includes(endpoint)) throw Error('Unapproved provider endpoint; local proxy refused to start.')
143
+ if (typeof key !== 'string' || !key.trim()) throw Error('Gateway key is required for local proxy.')
144
+ if (typeof token !== 'string' || token.length < 6 || token === key) throw Error('Invalid or non-isolated proxy token.')
145
+
146
+ const server = createServer(async (req, res) => {
147
+ const fail = (status, body) => {
148
+ if (res.headersSent || res.destroyed) return
149
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' })
150
+ res.end(JSON.stringify({ error: body }))
151
+ }
152
+
153
+ if (!matchesToken(req.headers.authorization, token)) {
154
+ fail(401, 'Local session token is required.')
155
+ return
156
+ }
157
+ if (!ALLOWED_PATHS.has(req.url ?? '')) {
158
+ fail(404, 'Unsupported local gateway route.')
159
+ return
160
+ }
161
+ const method = req.url === '/v1/models' ? 'GET' : 'POST'
162
+ if (req.method !== method) {
163
+ fail(405, 'Unsupported request method.')
164
+ return
165
+ }
166
+ if (req.headers['content-length'] && Number(req.headers['content-length']) > MAX_REQUEST_BYTES) {
167
+ fail(413, 'Request too large.')
168
+ return
169
+ }
170
+
171
+ const abort = new AbortController()
172
+ const onClose = () => { if (!res.writableEnded) abort.abort() }
173
+ res.once('close', onClose)
174
+ try {
175
+ let body
176
+ if (method === 'POST') {
177
+ const buffers = []
178
+ let bytes = 0
179
+ for await (const chunk of req) {
180
+ bytes += chunk.length
181
+ if (bytes > MAX_REQUEST_BYTES) {
182
+ fail(413, 'Request too large.')
183
+ return
184
+ }
185
+ buffers.push(chunk)
186
+ }
187
+ body = Buffer.concat(buffers)
188
+ if (req.url === '/v1/chat/completions') {
189
+ let parsed
190
+ try { parsed = JSON.parse(body.toString('utf8')) }
191
+ catch { fail(400, 'Invalid JSON request.'); return }
192
+ const normalized = boundChatOutputTokens(attachRuntimeModelContext(normalizeZeroArgFunctionTools(parsed)))
193
+ if (normalized !== parsed) body = Buffer.from(JSON.stringify(normalized))
194
+ }
195
+ }
196
+ const upstream = await fetchImpl(endpoint + req.url.slice(3), {
197
+ method,
198
+ headers: {
199
+ authorization: 'Bearer ' + key,
200
+ accept: 'application/json, text/event-stream',
201
+ ...(method === 'POST' ? { 'content-type': 'application/json' } : {}),
202
+ },
203
+ body,
204
+ redirect: 'error',
205
+ signal: abort.signal,
206
+ })
207
+ res.writeHead(upstream.status, {
208
+ 'content-type': upstream.headers.get('content-type') ?? 'application/json',
209
+ 'cache-control': 'no-store',
210
+ })
211
+ if (upstream.body) {
212
+ for await (const chunk of upstream.body) {
213
+ if (res.destroyed) break
214
+ if (!res.write(chunk)) await waitForDrainOrClose(res)
215
+ }
216
+ }
217
+ res.end()
218
+ } catch {
219
+ if (res.headersSent) res.destroy()
220
+ else fail(502, 'Provider connection failed.')
221
+ } finally {
222
+ res.off('close', onClose)
223
+ }
224
+ })
225
+
226
+ await new Promise((resolve, reject) => {
227
+ const onError = (error) => { server.off('listening', onListening); reject(error) }
228
+ const onListening = () => { server.off('error', onError); resolve() }
229
+ server.once('error', onError)
230
+ server.once('listening', onListening)
231
+ server.listen(0, '127.0.0.1')
232
+ })
233
+ const port = server.address()?.port
234
+ return {
235
+ token,
236
+ baseUrl: 'http://127.0.0.1:' + port + '/v1',
237
+ async close() {
238
+ server.closeAllConnections()
239
+ await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
240
+ },
241
+ }
242
+ }
@@ -0,0 +1,120 @@
1
+ // In-terminal BCCLI first-run / provider-switch menu.
2
+ // This runs in the parent process BEFORE the coding agent is launched; real
3
+ // API keys never enter Qwen's process or any coding/MCP subprocess.
4
+ import { createInterface } from 'node:readline/promises'
5
+ import { PROVIDERS, isConfigured, resolveProvider, selectedProvider, selectedModel, saveSelectedModel, saveProviderKey, useProvider } from './providers.mjs'
6
+ import { hiddenApiKey } from './provider-cli.mjs'
7
+ import { DEFAULT_MODEL, discoverModels, selectModel } from './core.mjs'
8
+
9
+ export const PROVIDER_SWITCH_EXIT = 51
10
+
11
+ // Returning users go straight to chat with the selected provider and model.
12
+ // Eligibility is still verified against the live provider's available models.
13
+ export async function autoSelectStartup({
14
+ providerId = selectedProvider(),
15
+ configured = isConfigured,
16
+ remembered = selectedModel,
17
+ resolve = resolveProvider,
18
+ list = discoverModels,
19
+ defaultModel = DEFAULT_MODEL,
20
+ env = process.env,
21
+ } = {}) {
22
+ if (!configured(providerId)) return undefined
23
+ const requested = providerId === 'botconnector'
24
+ ? (env.BOTCONNECTOR_MODEL_ID || remembered(providerId) || defaultModel)
25
+ : remembered(providerId)
26
+ if (!requested) return undefined
27
+ const profile = resolve(providerId)
28
+ const models = await list({ key: profile.key, endpoint: profile.endpoint })
29
+ if (!models.some(item => item.id === requested)) return undefined
30
+ return { providerId, model: selectModel(models, requested), key: profile.key, endpoint: profile.endpoint }
31
+ }
32
+
33
+ export async function terminalQuestion(label, { input = process.stdin, output = process.stdout } = {}) {
34
+ const rl = createInterface({ input, output, terminal: !!input.isTTY })
35
+ try { return (await rl.question(label)).trim() }
36
+ finally { rl.close() }
37
+ }
38
+
39
+ function selectedName(answer, choices, defaultValue) {
40
+ if (!answer) return defaultValue
41
+ const clean = answer.replace(/!$/, '').trim().toLowerCase()
42
+ if (/^[0-9]+$/.test(clean)) return choices[Number(clean) - 1]
43
+ return choices.includes(clean) ? clean : undefined
44
+ }
45
+
46
+ export async function startupMenu({
47
+ ask = terminalQuestion,
48
+ askSecret = hiddenApiKey,
49
+ out = value => console.log(value),
50
+ currentProvider = selectedProvider,
51
+ configured = isConfigured,
52
+ saveKey = saveProviderKey,
53
+ use = useProvider,
54
+ remembered = selectedModel,
55
+ saveModel = saveSelectedModel,
56
+ resolve = resolveProvider,
57
+ list = discoverModels,
58
+ defaultModel = DEFAULT_MODEL,
59
+ } = {}) {
60
+ const ids = Object.keys(PROVIDERS)
61
+ const active = currentProvider()
62
+ out('')
63
+ out('BCCLI | Provider dan model')
64
+ out('Pilih provider. Masukkan tanda ! setelah angka untuk mengganti API key yang sudah tersimpan.')
65
+ ids.forEach((id, idx) => {
66
+ out(' ' + (idx + 1) + '. ' + PROVIDERS[id].name +
67
+ (configured(id) ? ' [key tersedia]' : ' [perlu API key]') +
68
+ (id === active ? ' [aktif]' : ''))
69
+ })
70
+ let providerId
71
+ let replace
72
+ for (;;) {
73
+ const value = await ask('Provider (angka/nama; Enter = aktif; Ctrl+C untuk batal): ')
74
+ providerId = selectedName(value, ids, active)
75
+ if (!providerId) { out('Pilihan provider tidak dikenal.'); continue }
76
+ replace = value.endsWith('!')
77
+ if (providerId === 'botconnector' && replace) {
78
+ out('Gunakan bccli-legacy login bc-cloud untuk mengubah key BotConnector lama; key tidak akan diubah dari menu ini.')
79
+ continue
80
+ }
81
+ break
82
+ }
83
+ if ((replace || !configured(providerId)) && providerId !== 'botconnector') {
84
+ out('Masukkan API key ' + PROVIDERS[providerId].name + ' (input tersembunyi).')
85
+ const key = await askSecret()
86
+ // A cancel or missing key cannot silently select an unusable provider.
87
+ if (!key) throw Error('Tidak ada API key; provider tidak diubah.')
88
+ saveKey(providerId, key)
89
+ out('API key disimpan terlindungi untuk akun Windows. Tidak ada request AI.')
90
+ }
91
+ if (!configured(providerId)) throw Error('Provider ' + providerId + ' belum memiliki API key. Tidak ada permintaan AI.')
92
+ const profile = resolve(providerId)
93
+ const models = await list({ key: profile.key, endpoint: profile.endpoint })
94
+ if (!models.length) throw Error('Tidak ada model untuk provider ini.')
95
+ const all = [...models].sort((a, b) => a.id.localeCompare(b.id))
96
+ const previous = remembered(providerId)
97
+ const preferred = all.some(m => m.id === previous) ? previous :
98
+ providerId === 'botconnector' && all.some(m => m.id === defaultModel) ? defaultModel : undefined
99
+ let filtered = all
100
+ for (;;) {
101
+ out('')
102
+ out('Model ' + PROVIDERS[providerId].name + ' (' + filtered.length + ' dari ' + all.length + ' model):')
103
+ filtered.slice(0, 14).forEach((m, i) => out(' ' + (i + 1) + '. ' + m.id + (m.id === preferred ? ' [default]' : '')))
104
+ if (filtered.length > 14) out(' ... ketik ?kata untuk mencari model.')
105
+ const value = await ask('Model (nomor/ID/?cari' + (preferred ? '/Enter = ' + preferred : '') + '): ')
106
+ if (value.startsWith('?')) {
107
+ const term = value.slice(1).trim().toLowerCase()
108
+ filtered = term ? all.filter(m => m.id.toLowerCase().includes(term) || m.name?.toLowerCase().includes(term)) : all
109
+ if (!filtered.length) { out('Tidak ada model yang cocok.'); filtered = all }
110
+ continue
111
+ }
112
+ const candidate = !value ? preferred : /^[0-9]+$/.test(value) ? filtered[Number(value) - 1]?.id : value
113
+ if (!candidate || !all.some(m => m.id === candidate)) { out('Pilih nomor atau ID model yang tampil.'); continue }
114
+ selectModel(all, candidate)
115
+ // Save active provider only after valid credentials and model are confirmed.
116
+ use(providerId)
117
+ saveModel(providerId, candidate)
118
+ return { providerId, model: candidate, key: profile.key, endpoint: profile.endpoint }
119
+ }
120
+ }