zcode-workbuddy-connect 0.0.0-stage → 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/.zcode-plugin/plugin.json +40 -0
- package/LICENSE +22 -0
- package/NOTICE.md +49 -0
- package/README.md +264 -2
- package/bin/cli.mjs +8 -0
- package/docs/adr/0001-loopback-provider-for-zcode.md +66 -0
- package/docs/adr/0002-widest-window-and-output-ceiling.md +98 -0
- package/docs/adr/0003-region-by-url-path.md +97 -0
- package/docs/adr/0004-workbuddy-56-at-rest-credentials.md +111 -0
- package/docs/validate-provider-config.mjs +203 -0
- package/docs/verify-endpoint.mjs +162 -0
- package/hooks/service-ensure.mjs +133 -0
- package/package.json +59 -4
- package/skills/workbuddy-models/SKILL.md +107 -0
- package/src/app-version.js +119 -0
- package/src/auth.js +441 -0
- package/src/catalog.js +100 -0
- package/src/cli.js +1010 -0
- package/src/client-identity.js +213 -0
- package/src/config.js +91 -0
- package/src/desktop-credential-protection.js +890 -0
- package/src/loopback.js +40 -0
- package/src/server.js +873 -0
- package/src/upstream.js +762 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,1010 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* zcode-workbuddy-connect CLI.
|
|
4
|
+
*
|
|
5
|
+
* serve run the loopback OpenAI-compatible endpoint (default)
|
|
6
|
+
* status sign-in state, remaining credit, model roster
|
|
7
|
+
* refresh re-pull the model roster from the upstream
|
|
8
|
+
* setup write the ZCode provider entry for this endpoint
|
|
9
|
+
* doctor check credentials, upstream reachability, and the endpoint
|
|
10
|
+
* logout drop the plugin-owned credential copy
|
|
11
|
+
*
|
|
12
|
+
* @module zcode-workbuddy-connect/cli
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { randomBytes } from 'node:crypto'
|
|
16
|
+
import { readFile, writeFile, mkdir, rename, rm } from 'node:fs/promises'
|
|
17
|
+
import { existsSync } from 'node:fs'
|
|
18
|
+
import { spawn } from 'node:child_process'
|
|
19
|
+
import { dirname, join } from 'node:path'
|
|
20
|
+
import { fileURLToPath } from 'node:url'
|
|
21
|
+
import { WorkBuddyCredentialStore, DESKTOP_AUTH_FILENAME, DESKTOP_AUTH_AI_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_AI_AUTH_FILE_ENV } from './auth.js'
|
|
22
|
+
import {
|
|
23
|
+
WorkBuddyAtRestKeyProvider,
|
|
24
|
+
defaultDiscoveryFor,
|
|
25
|
+
defaultWorkBuddyElectronPaths,
|
|
26
|
+
} from './desktop-credential-protection.js'
|
|
27
|
+
import { WorkBuddyCatalog } from './catalog.js'
|
|
28
|
+
import { WorkBuddyUpstreamClient } from './upstream.js'
|
|
29
|
+
import { createWorkBuddyServer } from './server.js'
|
|
30
|
+
import {
|
|
31
|
+
configureEndpoint,
|
|
32
|
+
configureStateDir,
|
|
33
|
+
defaultStateDir,
|
|
34
|
+
endpointPath,
|
|
35
|
+
loadToken,
|
|
36
|
+
ownAuthAiPath,
|
|
37
|
+
ownAuthPath,
|
|
38
|
+
setToken,
|
|
39
|
+
stateDir,
|
|
40
|
+
} from './config.js'
|
|
41
|
+
|
|
42
|
+
/** Fixed loopback port the endpoint listens on by default. */
|
|
43
|
+
export const DEFAULT_PORT = 39271
|
|
44
|
+
|
|
45
|
+
const HELP = `zcode-workbuddy-connect — use WorkBuddy desktop-app models from any OpenAI-compatible client
|
|
46
|
+
|
|
47
|
+
Usage: node bin/cli.mjs <command> [options]
|
|
48
|
+
|
|
49
|
+
Commands:
|
|
50
|
+
serve Run the loopback OpenAI-compatible endpoint (default)
|
|
51
|
+
status Show sign-in state, remaining credit, and the model roster
|
|
52
|
+
refresh Re-pull the model roster from the upstream
|
|
53
|
+
setup Write the ZCode provider entry for this endpoint
|
|
54
|
+
doctor Check credentials, upstream reachability, and the endpoint
|
|
55
|
+
install-service Register a logon task that keeps the endpoint running
|
|
56
|
+
uninstall-service Remove that logon task
|
|
57
|
+
service-status Report whether the task is registered and the endpoint is up
|
|
58
|
+
logout Drop the plugin-owned credential copy
|
|
59
|
+
token Print the endpoint bearer and exit
|
|
60
|
+
checkin Claim today's WorkBuddy check-in credit (idempotent)
|
|
61
|
+
|
|
62
|
+
Options:
|
|
63
|
+
--port <n> Fixed port for serve (default 39271; 0 = OS-assigned)
|
|
64
|
+
--token <t> Override the endpoint bearer
|
|
65
|
+
--home <dir> State directory (default ${defaultStateDir()})
|
|
66
|
+
--auth-file <path> Explicit WorkBuddy desktop auth-file path
|
|
67
|
+
--config <path> provider_config.json to write (setup only)
|
|
68
|
+
--default-context-window
|
|
69
|
+
Advertise the upstream's softer default window instead of
|
|
70
|
+
the widest one it admits (serve only; the default is the max)
|
|
71
|
+
--json Machine-readable output
|
|
72
|
+
-h, --help This text
|
|
73
|
+
`
|
|
74
|
+
|
|
75
|
+
function parseArgs(argv) {
|
|
76
|
+
const args = { _: [] }
|
|
77
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
78
|
+
const item = argv[i]
|
|
79
|
+
if (item.startsWith('--')) {
|
|
80
|
+
const [flag, inline] = item.slice(2).split('=')
|
|
81
|
+
const key = flag.replace(/-([a-z])/gu, (_, c) => c.toUpperCase())
|
|
82
|
+
if (inline !== undefined) {
|
|
83
|
+
args[key] = inline
|
|
84
|
+
} else if (i + 1 < argv.length && !argv[i + 1].startsWith('--')) {
|
|
85
|
+
args[key] = argv[i + 1]
|
|
86
|
+
i += 1
|
|
87
|
+
} else {
|
|
88
|
+
args[key] = true
|
|
89
|
+
}
|
|
90
|
+
} else {
|
|
91
|
+
args._.push(item)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return args
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function mask(value) {
|
|
98
|
+
if (typeof value !== 'string' || value === '') return '(none)'
|
|
99
|
+
return value.length <= 10 ? '***' : `${value.slice(0, 6)}…${value.slice(-4)} (${value.length} chars)`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function formatTime(ms) {
|
|
103
|
+
if (typeof ms !== 'number' || ms <= 0) return '(unknown)'
|
|
104
|
+
return new Date(ms).toISOString().replace('T', ' ').slice(0, 19)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Load or create the persisted endpoint bearer. */
|
|
108
|
+
async function ensureToken(args) {
|
|
109
|
+
if (typeof args.token === 'string' && args.token !== '') return args.token
|
|
110
|
+
const path = endpointPath()
|
|
111
|
+
try {
|
|
112
|
+
const document = JSON.parse(await readFile(path, 'utf8'))
|
|
113
|
+
if (typeof document['token'] === 'string' && document['token'] !== '') {
|
|
114
|
+
return loadToken(document['token'])
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
// No persisted endpoint yet.
|
|
118
|
+
}
|
|
119
|
+
const token = randomBytes(32).toString('base64url')
|
|
120
|
+
await mkdir(stateDir(), { recursive: true })
|
|
121
|
+
await writeFile(path, `${JSON.stringify({ token, port: Number(args.port) || DEFAULT_PORT, createdAt: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 })
|
|
122
|
+
setToken(token)
|
|
123
|
+
return token
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The two regions this service serves. Each gets its own credential store
|
|
128
|
+
* (separate desktop file, separate owned copy, separate env override), its own
|
|
129
|
+
* catalog, and its own URL mount: the root for CN, `/ai` for the international
|
|
130
|
+
* one. The mount is what keeps the shared ids (glm-5.3, hy3…) distinct without
|
|
131
|
+
* polluting the id a client displays. `prefix` is the legacy disambiguator,
|
|
132
|
+
* still honoured on the wire for configurations written before the split.
|
|
133
|
+
*/
|
|
134
|
+
const VARIANTS = [
|
|
135
|
+
{
|
|
136
|
+
id: 'cn',
|
|
137
|
+
path: '',
|
|
138
|
+
prefix: '',
|
|
139
|
+
appName: 'WorkBuddy',
|
|
140
|
+
desktopFilename: DESKTOP_AUTH_FILENAME,
|
|
141
|
+
authFileEnv: WORKBUDDY_AUTH_FILE_ENV,
|
|
142
|
+
ownPath: ownAuthPath(),
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
id: 'ai',
|
|
146
|
+
path: '/ai',
|
|
147
|
+
prefix: 'wbai:',
|
|
148
|
+
appName: 'WorkBuddy AI',
|
|
149
|
+
desktopFilename: DESKTOP_AUTH_AI_FILENAME,
|
|
150
|
+
authFileEnv: WORKBUDDY_AI_AUTH_FILE_ENV,
|
|
151
|
+
ownPath: ownAuthAiPath(),
|
|
152
|
+
},
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The at-rest key provider for one variant.
|
|
157
|
+
*
|
|
158
|
+
* The difference between the two variants is deliberately expressed as
|
|
159
|
+
* configuration, not as variant logic inside the provider: the CN app has a
|
|
160
|
+
* verified install layout and a verified encrypted-credential chain on both
|
|
161
|
+
* platforms, so it gets the platform default and (on macOS) Spotlight
|
|
162
|
+
* discovery; the international app gets a Windows default only, because its
|
|
163
|
+
* macOS layout and its encrypted credential chain have never been verified
|
|
164
|
+
* live — upstream refuses to auto-discover it for exactly that reason, and this
|
|
165
|
+
* port keeps that refusal on macOS and Linux.
|
|
166
|
+
*/
|
|
167
|
+
function variantKeyProvider(variant) {
|
|
168
|
+
return new WorkBuddyAtRestKeyProvider({
|
|
169
|
+
discovery: defaultDiscoveryFor(variant.id),
|
|
170
|
+
defaultElectronPaths: defaultWorkBuddyElectronPaths(variant.id),
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function buildStores(args) {
|
|
175
|
+
const client = new WorkBuddyUpstreamClient()
|
|
176
|
+
const stores = VARIANTS.map(variant => ({
|
|
177
|
+
...variant,
|
|
178
|
+
store: new WorkBuddyCredentialStore({
|
|
179
|
+
refresh: credential => client.refreshToken(credential),
|
|
180
|
+
ownPath: variant.ownPath,
|
|
181
|
+
desktopFilename: variant.desktopFilename,
|
|
182
|
+
authFileEnv: variant.authFileEnv,
|
|
183
|
+
appName: variant.appName,
|
|
184
|
+
...(variant.id === 'cn' && typeof args.authFile === 'string' ? { desktopPath: args.authFile } : {}),
|
|
185
|
+
keyProvider: variantKeyProvider(variant),
|
|
186
|
+
}),
|
|
187
|
+
}))
|
|
188
|
+
return { client, stores }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Preflight: parse the auth files and report what a user can act on. */
|
|
192
|
+
async function commandDoctor(args) {
|
|
193
|
+
const { client, stores } = buildStores(args)
|
|
194
|
+
const report = { stateDir: stateDir(), ownAuthPath: ownAuthPath(), ownAuthAiPath: ownAuthAiPath(), checks: [] }
|
|
195
|
+
|
|
196
|
+
for (const { id, appName, store } of stores) {
|
|
197
|
+
const candidates = store.resolveDesktopCandidates()
|
|
198
|
+
const present = candidates.filter(path => existsSync(path))
|
|
199
|
+
report['checks'].push({
|
|
200
|
+
name: `${appName} auth file`,
|
|
201
|
+
ok: present.length > 0,
|
|
202
|
+
detail: present.length > 0 ? present[0] : `not found; looked at ${candidates.join(' , ')}`,
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
// How the on-disk file reads. WorkBuddy 5.6 seals the token fields at
|
|
206
|
+
// rest, so "encrypted" is the healthy state for a current install, not an
|
|
207
|
+
// error — the unlock happens in the sign-in state below. Classification
|
|
208
|
+
// spawns nothing and decrypts nothing, so this line stays cheap.
|
|
209
|
+
const format = await store.desktopAuthFormat()
|
|
210
|
+
report[`${id}AuthFormat`] = format
|
|
211
|
+
report['checks'].push({
|
|
212
|
+
name: `${appName} auth file format`,
|
|
213
|
+
ok: format !== 'unrecognized',
|
|
214
|
+
detail: format === 'unrecognized'
|
|
215
|
+
? 'neither a plaintext credential nor a decodable WorkBuddy 5.6 envelope; fix or remove the file'
|
|
216
|
+
: format === 'encrypted'
|
|
217
|
+
? 'WorkBuddy 5.6 at-rest envelope (tokens sealed)'
|
|
218
|
+
: format,
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
const status = await store.status()
|
|
222
|
+
report[`${id}AuthStatus`] = status
|
|
223
|
+
report['checks'].push({
|
|
224
|
+
name: `${appName} signed in`,
|
|
225
|
+
ok: status.state === 'signed-in',
|
|
226
|
+
detail: status.state === 'signed-in'
|
|
227
|
+
? `${status.nickname ?? '(unnamed)'} · uid ${mask(status.uid)} · expires ${formatTime(status.expiresAtMs)}`
|
|
228
|
+
: (status.reason ?? `no credential found; sign in once in the ${appName} desktop app`),
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
// Only an encrypted document needs the app's own Electron to be opened.
|
|
232
|
+
// Naming the binary (or the fact that none is configured) is what makes a
|
|
233
|
+
// signed-out state beside a sealed file actionable.
|
|
234
|
+
if (format === 'encrypted') {
|
|
235
|
+
const helper = store.keyHelperPath()
|
|
236
|
+
report['checks'].push({
|
|
237
|
+
name: `${appName} key helper`,
|
|
238
|
+
ok: helper !== undefined && existsSync(helper),
|
|
239
|
+
detail: helper === undefined
|
|
240
|
+
? 'no WorkBuddy Electron binary is configured for this platform; set WORKBUDDY_ELECTRON_BIN to the app\'s Electron binary'
|
|
241
|
+
: helper,
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (status.state === 'signed-in') {
|
|
246
|
+
try {
|
|
247
|
+
const credential = await store.resolve()
|
|
248
|
+
const models = await client.fetchModels(credential)
|
|
249
|
+
report['checks'].push({ name: `${appName} catalog`, ok: true, detail: `${models.length} models from ${client.lastCatalog?.source}` })
|
|
250
|
+
try {
|
|
251
|
+
const credits = await client.fetchCredits(credential)
|
|
252
|
+
report[`${id}Credits`] = credits
|
|
253
|
+
report['checks'].push({
|
|
254
|
+
name: `${appName} credit`,
|
|
255
|
+
ok: true,
|
|
256
|
+
detail: credits.unlimited === true ? 'unlimited cycle quota' : `total ${credits.total} across ${credits.accounts.length} package(s)`,
|
|
257
|
+
})
|
|
258
|
+
} catch (error) {
|
|
259
|
+
report['checks'].push({ name: `${appName} credit`, ok: false, detail: String(error) })
|
|
260
|
+
}
|
|
261
|
+
} catch (error) {
|
|
262
|
+
report['checks'].push({ name: `${appName} catalog`, ok: false, detail: String(error) })
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const token = await ensureToken(args)
|
|
268
|
+
report['endpoint'] = { baseUrl: `http://127.0.0.1:${Number(args.port) || DEFAULT_PORT}/v1`, token: mask(token) }
|
|
269
|
+
report['checks'].push({ name: 'endpoint bearer', ok: token !== '', detail: report['endpoint'].token })
|
|
270
|
+
|
|
271
|
+
if (args.json) {
|
|
272
|
+
console.log(JSON.stringify(report, null, 2))
|
|
273
|
+
} else {
|
|
274
|
+
console.log(`zcode-workbuddy-connect doctor`)
|
|
275
|
+
console.log(` dir ${report.stateDir}`)
|
|
276
|
+
console.log(` own credential ${report.ownAuthPath}`)
|
|
277
|
+
console.log(` own ai credential ${report.ownAuthAiPath}`)
|
|
278
|
+
console.log(` endpoint ${report.endpoint.baseUrl}`)
|
|
279
|
+
console.log(` bearer ${report.endpoint.token}`)
|
|
280
|
+
console.log('')
|
|
281
|
+
for (const check of report['checks']) {
|
|
282
|
+
console.log(` [${check.ok ? ' OK ' : 'FAIL'}] ${check.name.padEnd(24)} ${check.detail}`)
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const failed = report['checks'].filter(check => !check.ok)
|
|
286
|
+
process.exitCode = failed.length === 0 ? 0 : 1
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function commandCheckin(args) {
|
|
290
|
+
const { client, stores } = buildStores(args)
|
|
291
|
+
for (const { id, appName, store } of stores) {
|
|
292
|
+
const status = await store.status()
|
|
293
|
+
if (status.state !== 'signed-in') {
|
|
294
|
+
console.log(`[${appName}] not signed in — skip`)
|
|
295
|
+
continue
|
|
296
|
+
}
|
|
297
|
+
try {
|
|
298
|
+
const credential = await store.resolve()
|
|
299
|
+
const current = await client.fetchCheckinStatus(credential)
|
|
300
|
+
if (!current.active) {
|
|
301
|
+
console.log(`[${appName}] check-in activity not active — nothing to claim`)
|
|
302
|
+
continue
|
|
303
|
+
}
|
|
304
|
+
if (current.todayCheckedIn) {
|
|
305
|
+
console.log(`[${appName}] already checked in today (streak ${current.streakDays}d, today +${current.todayCredit})`)
|
|
306
|
+
continue
|
|
307
|
+
}
|
|
308
|
+
const claim = await client.claimDailyCheckin(credential)
|
|
309
|
+
const after = await client.fetchCheckinStatus(credential)
|
|
310
|
+
console.log(`[${appName}] checked in: +${claim.credit} credits, streak ${claim.streakDays}d${claim.isStreakDay ? ' (streak bonus day)' : ''} · balance-day total ${after.todayCredit}`)
|
|
311
|
+
} catch (error) {
|
|
312
|
+
console.log(`[${appName}] checkin failed — ${String(error?.message ?? error).slice(0, 200)}`)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function commandStatus(args) {
|
|
318
|
+
const { client, stores } = buildStores(args)
|
|
319
|
+
const report = {}
|
|
320
|
+
const modelReports = []
|
|
321
|
+
|
|
322
|
+
for (const { id, appName, store } of stores) {
|
|
323
|
+
const status = await store.status()
|
|
324
|
+
const entry = { auth: status }
|
|
325
|
+
if (status.state === 'signed-in') {
|
|
326
|
+
try {
|
|
327
|
+
const credential = await store.resolve()
|
|
328
|
+
const models = await client.fetchModels(credential)
|
|
329
|
+
entry['models'] = models.map(model => ({
|
|
330
|
+
id: model.id,
|
|
331
|
+
name: model.name,
|
|
332
|
+
contextWindow: model.contextWindow,
|
|
333
|
+
maxTokens: model.maxTokens,
|
|
334
|
+
supportsImages: model.supportsImages,
|
|
335
|
+
efforts: model.reasoning?.supportedEfforts ?? (model.reasoning?.defaultEffort === undefined ? [] : [model.reasoning.defaultEffort]),
|
|
336
|
+
credits: model.billing?.credits,
|
|
337
|
+
free: model.billing?.free === true,
|
|
338
|
+
badges: model.billing?.badges ?? [],
|
|
339
|
+
}))
|
|
340
|
+
try {
|
|
341
|
+
entry['credits'] = await client.fetchCredits(credential)
|
|
342
|
+
} catch (error) {
|
|
343
|
+
entry['creditsError'] = String(error)
|
|
344
|
+
}
|
|
345
|
+
} catch (error) {
|
|
346
|
+
entry['catalogError'] = String(error)
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
report[id] = entry
|
|
350
|
+
modelReports.push({ id, appName, entry })
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (args.json) {
|
|
354
|
+
console.log(JSON.stringify(report, null, 2))
|
|
355
|
+
return
|
|
356
|
+
}
|
|
357
|
+
for (const { id, appName, entry } of modelReports) {
|
|
358
|
+
const status = entry.auth
|
|
359
|
+
console.log(`\n=== ${appName} (${id}) ===`)
|
|
360
|
+
if (status.state !== 'signed-in') {
|
|
361
|
+
console.log(` Not signed in. ${status.reason ?? `Sign in once in the ${appName} desktop app.`}`)
|
|
362
|
+
continue
|
|
363
|
+
}
|
|
364
|
+
console.log(` Signed in: ${status.nickname ?? '(unnamed)'} (uid ${mask(status.uid)}, source ${status.source})`)
|
|
365
|
+
console.log(` Token expires: ${formatTime(status.expiresAtMs)}`)
|
|
366
|
+
console.log(` Refresh expires: ${formatTime(status.refreshExpiresAtMs)}`)
|
|
367
|
+
const credits = entry['credits']
|
|
368
|
+
if (credits !== undefined) {
|
|
369
|
+
console.log(` Remaining credit: ${credits.unlimited === true ? 'unlimited (cycle quota)' : credits.total}${credits.cycleResetTime === undefined ? '' : ` · resets ${credits.cycleResetTime}`}`)
|
|
370
|
+
} else if (entry['creditsError'] !== undefined) {
|
|
371
|
+
console.log(` Remaining credit: lookup failed — ${entry['creditsError']}`)
|
|
372
|
+
}
|
|
373
|
+
const models = entry['models'] ?? []
|
|
374
|
+
console.log(` Models (${models.length}):`)
|
|
375
|
+
for (const model of models) {
|
|
376
|
+
const flags = [
|
|
377
|
+
model.supportsImages ? 'img' : 'text-only',
|
|
378
|
+
model.efforts.length === 0 ? 'no explicit efforts' : `efforts ${model.efforts.join('/')}`,
|
|
379
|
+
model.free ? 'FREE' : (model.credits ?? 'rate unknown'),
|
|
380
|
+
...model.badges,
|
|
381
|
+
].join(' · ')
|
|
382
|
+
console.log(` ${model.id.padEnd(22)} ${model.name.padEnd(20)} ${String(model.contextWindow).padStart(9)} ctx ${flags}`)
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function commandRefresh(args) {
|
|
388
|
+
const { client, stores } = buildStores(args)
|
|
389
|
+
for (const { id, appName, store } of stores) {
|
|
390
|
+
try {
|
|
391
|
+
const credential = await store.resolve()
|
|
392
|
+
const models = await client.fetchModels(credential)
|
|
393
|
+
if (args.json) {
|
|
394
|
+
console.log(JSON.stringify({ variant: id, source: client.lastCatalog?.source, count: models.length, ids: models.map(m => m.id) }, null, 2))
|
|
395
|
+
} else {
|
|
396
|
+
console.log(`${appName}: refreshed ${models.length} models from ${client.lastCatalog?.source}`)
|
|
397
|
+
console.log(` ${models.map(model => model.id).join(', ')}`)
|
|
398
|
+
}
|
|
399
|
+
} catch (error) {
|
|
400
|
+
if (args.json) {
|
|
401
|
+
console.log(JSON.stringify({ variant: id, ok: false, error: String(error) }, null, 2))
|
|
402
|
+
} else {
|
|
403
|
+
console.log(`${appName}: refresh failed — ${String(error)}`)
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function commandLogout(args) {
|
|
410
|
+
const { stores } = buildStores(args)
|
|
411
|
+
for (const { appName, store } of stores) {
|
|
412
|
+
await store.logout()
|
|
413
|
+
console.log(`Removed ${store.ownAuthPath()} (the ${appName} desktop app's own sign-in is untouched).`)
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const AUTOSTART_BASENAME = 'zcode-workbuddy-connect.vbs'
|
|
418
|
+
|
|
419
|
+
/** The current user's Startup folder, or undefined off Windows. */
|
|
420
|
+
function startupFolder() {
|
|
421
|
+
const appData = process.env['APPDATA']
|
|
422
|
+
if (appData === undefined || appData === '') return undefined
|
|
423
|
+
return join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup')
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Path of the Startup-folder launcher, or undefined off Windows. */
|
|
427
|
+
function autostartPath() {
|
|
428
|
+
const folder = startupFolder()
|
|
429
|
+
return folder === undefined ? undefined : join(folder, AUTOSTART_BASENAME)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Register the endpoint to start hidden at logon.
|
|
434
|
+
*
|
|
435
|
+
* The Startup folder rather than a scheduled task: creating a task needs
|
|
436
|
+
* elevation on a locked-down host (it fails outright with access denied), while
|
|
437
|
+
* the Startup folder is per-user and needs none. A VBS trampoline is what keeps
|
|
438
|
+
* the console window from appearing — launching node.exe directly flashes a
|
|
439
|
+
* console on every logon.
|
|
440
|
+
*
|
|
441
|
+
* A third policy layer can still refuse the write: some hosts block script
|
|
442
|
+
* files (`.vbs`, `.cmd`, `.bat`, `.ps1`) from being created in the Startup
|
|
443
|
+
* folder while allowing ordinary files beside them — verified on this machine,
|
|
444
|
+
* where `.txt` writes succeed and every script extension returns EPERM. That is
|
|
445
|
+
* a deliberate security control, so this command reports it and stops rather
|
|
446
|
+
* than working around it. Autostart is only the fallback path anyway: the
|
|
447
|
+
* plugin's `SessionStart` hook is what normally brings the endpoint up.
|
|
448
|
+
*/
|
|
449
|
+
async function commandInstallService(args) {
|
|
450
|
+
const target = autostartPath()
|
|
451
|
+
if (target === undefined) {
|
|
452
|
+
console.error('install-service supports Windows only (no %APPDATA%).')
|
|
453
|
+
process.exitCode = 1
|
|
454
|
+
return
|
|
455
|
+
}
|
|
456
|
+
const port = Number(args.port) || DEFAULT_PORT
|
|
457
|
+
const cliPath = fileURLToPath(new URL('../bin/cli.mjs', import.meta.url))
|
|
458
|
+
const nodePath = process.execPath
|
|
459
|
+
|
|
460
|
+
const vbs = [
|
|
461
|
+
"' ZCode WorkBuddy Connect — starts the local OpenAI-compatible endpoint hidden at logon.",
|
|
462
|
+
"' Remove this file to disable autostart; it writes nothing else outside the state dir.",
|
|
463
|
+
'Set shell = CreateObject("WScript.Shell")',
|
|
464
|
+
`shell.Run """${nodePath}"" ""${cliPath}"" serve --port ${port}", 0, False`,
|
|
465
|
+
'',
|
|
466
|
+
].join('\r\n')
|
|
467
|
+
|
|
468
|
+
try {
|
|
469
|
+
await writeFile(target, vbs, 'utf8')
|
|
470
|
+
} catch (error) {
|
|
471
|
+
const blocked = error?.code === 'EPERM' || error?.code === 'EACCES'
|
|
472
|
+
console.error(`error: could not write ${target} (${error?.code ?? 'unknown'}).`)
|
|
473
|
+
if (blocked) {
|
|
474
|
+
console.error(' This host blocks script files in the Startup folder. Ordinary files can be')
|
|
475
|
+
console.error(' written there, but .vbs/.cmd/.bat/.ps1 are refused — a deliberate policy, not a bug.')
|
|
476
|
+
}
|
|
477
|
+
console.error('')
|
|
478
|
+
console.error(' Autostart is optional. The plugin\'s SessionStart hook already starts the')
|
|
479
|
+
console.error(' endpoint whenever ZCode opens a session, which covers every use of these')
|
|
480
|
+
console.error(' models from ZCode. Without it, the endpoint still comes up for any session')
|
|
481
|
+
console.error(' begun after a logon; only a client talking to the port with ZCode closed')
|
|
482
|
+
console.error(' would need autostart, and that can be started by hand:')
|
|
483
|
+
console.error(` node "${cliPath}" serve --port ${port}`)
|
|
484
|
+
process.exitCode = 1
|
|
485
|
+
return
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (args.json) {
|
|
489
|
+
console.log(JSON.stringify({ autostart: target, nodePath, cliPath, port }, null, 2))
|
|
490
|
+
return
|
|
491
|
+
}
|
|
492
|
+
console.log(`Registered logon autostart: ${target}`)
|
|
493
|
+
console.log(` command "${nodePath}" "${cliPath}" serve --port ${port}`)
|
|
494
|
+
console.log('')
|
|
495
|
+
console.log('It starts at your next logon. To start it now without logging out:')
|
|
496
|
+
console.log(` wscript.exe "${target}"`)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function commandUninstallService(args) {
|
|
500
|
+
const target = autostartPath()
|
|
501
|
+
if (target === undefined) {
|
|
502
|
+
console.error('uninstall-service supports Windows only.')
|
|
503
|
+
process.exitCode = 1
|
|
504
|
+
return
|
|
505
|
+
}
|
|
506
|
+
const existed = existsSync(target)
|
|
507
|
+
if (existed) await rm(target, { force: true })
|
|
508
|
+
if (args.json) {
|
|
509
|
+
console.log(JSON.stringify({ autostart: target, removed: existed }, null, 2))
|
|
510
|
+
return
|
|
511
|
+
}
|
|
512
|
+
console.log(existed ? `Removed ${target}.` : `Nothing to remove (${target} did not exist).`)
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Whether the endpoint answers on the configured port. */
|
|
516
|
+
async function probeEndpoint(port, token) {
|
|
517
|
+
try {
|
|
518
|
+
const response = await fetch(`http://127.0.0.1:${port}/healthz`, {
|
|
519
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
520
|
+
signal: AbortSignal.timeout(3000),
|
|
521
|
+
})
|
|
522
|
+
if (!response.ok) return { up: true, healthy: false, status: response.status }
|
|
523
|
+
return { up: true, healthy: true, body: await response.json() }
|
|
524
|
+
} catch {
|
|
525
|
+
return { up: false, healthy: false }
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function commandServiceStatus(args) {
|
|
530
|
+
const port = Number(args.port) || DEFAULT_PORT
|
|
531
|
+
const token = await ensureToken(args)
|
|
532
|
+
const probe = await probeEndpoint(port, token)
|
|
533
|
+
const target = autostartPath()
|
|
534
|
+
const registered = target !== undefined && existsSync(target)
|
|
535
|
+
const report = {
|
|
536
|
+
autostartPath: target,
|
|
537
|
+
autostartRegistered: registered,
|
|
538
|
+
endpoint: `http://127.0.0.1:${port}/v1`,
|
|
539
|
+
reachable: probe.up,
|
|
540
|
+
healthy: probe.healthy,
|
|
541
|
+
...(probe.body === undefined ? {} : { state: probe.body }),
|
|
542
|
+
}
|
|
543
|
+
if (args.json) {
|
|
544
|
+
console.log(JSON.stringify(report, null, 2))
|
|
545
|
+
return
|
|
546
|
+
}
|
|
547
|
+
console.log(`autostart ${registered ? `registered (${target})` : 'not registered'}`)
|
|
548
|
+
console.log(`endpoint ${report.endpoint} — ${probe.up
|
|
549
|
+
? (probe.healthy ? `up (${probe.body?.models} models)` : `answering but unhealthy (HTTP ${probe.status})`)
|
|
550
|
+
: 'NOT reachable'}`)
|
|
551
|
+
if (!probe.up) {
|
|
552
|
+
console.log('')
|
|
553
|
+
console.log(registered
|
|
554
|
+
? `Start it with: wscript.exe "${target}"`
|
|
555
|
+
: 'Register it with: node bin/cli.mjs install-service')
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Write the ZCode provider entries pointing at this endpoint (one per region). */
|
|
560
|
+
async function commandSetup(args) {
|
|
561
|
+
const token = await ensureToken(args)
|
|
562
|
+
const port = Number(args.port) || DEFAULT_PORT
|
|
563
|
+
const baseUrl = `http://127.0.0.1:${port}/v1`
|
|
564
|
+
const configPath = args.config ?? join(process.env['USERPROFILE'] ?? process.env['HOME'] ?? '.', '.zcode', 'v2', 'provider_config.json')
|
|
565
|
+
|
|
566
|
+
const { client, stores } = buildStores(args)
|
|
567
|
+
|
|
568
|
+
let document
|
|
569
|
+
try {
|
|
570
|
+
document = JSON.parse(await readFile(configPath, 'utf8'))
|
|
571
|
+
} catch {
|
|
572
|
+
// A fresh document must satisfy the same strict schema as an edited one:
|
|
573
|
+
// `provider` is NOT a valid key here (it belongs to the legacy CLI registry
|
|
574
|
+
// in config.json, a different document). Writing it produced a config ZCode
|
|
575
|
+
// silently discarded in full — caught by docs/validate-provider-config.mjs.
|
|
576
|
+
document = {
|
|
577
|
+
schemaVersion: 1,
|
|
578
|
+
config: {
|
|
579
|
+
providerOrder: [],
|
|
580
|
+
providerConfigRules: { providerRules: [] },
|
|
581
|
+
modelConfigRules: { providerModelRules: [], manualProviderModelRules: [] },
|
|
582
|
+
},
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
const config = document['config'] ??= {}
|
|
586
|
+
const order = config['providerOrder'] ??= []
|
|
587
|
+
const rules = config['providerConfigRules'] ??= {}
|
|
588
|
+
const providerRules = rules['providerRules'] ??= []
|
|
589
|
+
|
|
590
|
+
// Per-model window, output ceiling and vision flags, so ZCode plans context
|
|
591
|
+
// and output budget correctly instead of assuming one window for every row.
|
|
592
|
+
//
|
|
593
|
+
// The rule object is strict (zod `.strict()`), so ONE unrecognised key fails
|
|
594
|
+
// the WHOLE document, and ZCode's failure mode is to discard the ENTIRE
|
|
595
|
+
// personal provider config and fall back to an empty one -- silently breaking
|
|
596
|
+
// every other provider too. The two writable paths, both verified against the
|
|
597
|
+
// compiled schema in `ZCode\resources\app.asar` (`out/host/index.js`, the
|
|
598
|
+
// `extractManualModelConfig` / `providerModelRules` schemas):
|
|
599
|
+
//
|
|
600
|
+
// config.properties {contextWindow, inputFormat.{supportsImage,
|
|
601
|
+
// supportsVideo,supportsPdf}, supportsToolCall,
|
|
602
|
+
// supportsJsonSchemaOutput,
|
|
603
|
+
// supportsNativeWebSearch,
|
|
604
|
+
// supportsMidConversationSystem,
|
|
605
|
+
// requiresMfjsToolSchema}
|
|
606
|
+
// config.optionSpecs {reasoningLevel:{values,map},
|
|
607
|
+
// maxOutputTokens:{max,map}}
|
|
608
|
+
//
|
|
609
|
+
// There is NO `maxTokens` property: an output limit lives in
|
|
610
|
+
// `optionSpecs.maxOutputTokens.max`, which is the ceiling on the value the
|
|
611
|
+
// user may select (the CLI rejects a selection above it). Writing `maxTokens`
|
|
612
|
+
// into `properties` is the mistake that caused the whole-document discard.
|
|
613
|
+
// `map` is optional and deliberately omitted here: the shipped
|
|
614
|
+
// `openai-chat-completions` API rule already supplies the wire mapping
|
|
615
|
+
// (`{'max_completion_tokens': maxOutputTokens}`), and an expression-language
|
|
616
|
+
// typo in a hand-written `map` would fail validation and take the document
|
|
617
|
+
// down with it. Do not add a key without finding it in the schema first.
|
|
618
|
+
const modelRules = (config['modelConfigRules'] ??= {})
|
|
619
|
+
const providerModelRules = (modelRules['providerModelRules'] ??= [])
|
|
620
|
+
modelRules['manualProviderModelRules'] ??= []
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* The widest window the upstream admits for a model.
|
|
624
|
+
*
|
|
625
|
+
* `contextWindow` is already `maxInputTokens` (the honest ceiling); the
|
|
626
|
+
* international catalog additionally declares `supportedContextWindows`,
|
|
627
|
+
* whose maximum is that same ceiling. Taking the max over both is therefore
|
|
628
|
+
* a no-op on today's wire but keeps the intent explicit if the upstream ever
|
|
629
|
+
* ships a `supportedLengths` entry above `maxInputTokens`.
|
|
630
|
+
*/
|
|
631
|
+
const effectiveContextWindow = model => {
|
|
632
|
+
const declared = Array.isArray(model.supportedContextWindows) ? model.supportedContextWindows : []
|
|
633
|
+
return Math.max(model.contextWindow, ...declared, 0)
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const summary = []
|
|
637
|
+
for (const { id, path: mount, appName, store } of stores) {
|
|
638
|
+
const providerId = id === 'cn' ? 'workbuddy' : 'workbuddy-ai'
|
|
639
|
+
const providerName = appName
|
|
640
|
+
let models = []
|
|
641
|
+
try {
|
|
642
|
+
const credential = await store.resolve()
|
|
643
|
+
models = await client.fetchModels(credential)
|
|
644
|
+
} catch (error) {
|
|
645
|
+
// No credential for this region is a normal state (app not installed):
|
|
646
|
+
// skip the provider entirely rather than register one that only fails.
|
|
647
|
+
console.warn(`warning: ${appName} unavailable, skipping its provider (${String(error)})`)
|
|
648
|
+
summary.push({ providerId, appName, skipped: true, reason: String(error) })
|
|
649
|
+
continue
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Ids stay exactly as the region names them. The region travels in the
|
|
653
|
+
// baseUrl mount below, so nothing has to be encoded into the name a user
|
|
654
|
+
// reads in the picker.
|
|
655
|
+
const wiredIds = models.map(model => model.id)
|
|
656
|
+
const variantBaseUrl = `http://127.0.0.1:${port}${mount}/v1`
|
|
657
|
+
|
|
658
|
+
const rule = {
|
|
659
|
+
providerId,
|
|
660
|
+
providerName,
|
|
661
|
+
config: {
|
|
662
|
+
group: 'standard-personal',
|
|
663
|
+
access: { type: 'api-key', apiKey: token },
|
|
664
|
+
api: { type: 'openai-chat-completions', baseUrl: variantBaseUrl },
|
|
665
|
+
personalModelIds: wiredIds,
|
|
666
|
+
modelOrder: wiredIds,
|
|
667
|
+
},
|
|
668
|
+
}
|
|
669
|
+
const existing = providerRules.findIndex(item => item?.providerId === providerId)
|
|
670
|
+
if (existing === -1) providerRules.push(rule)
|
|
671
|
+
else providerRules[existing] = rule
|
|
672
|
+
if (!order.includes(providerId)) order.push(providerId)
|
|
673
|
+
|
|
674
|
+
let modelCount = 0
|
|
675
|
+
for (const model of models) {
|
|
676
|
+
modelCount += 1
|
|
677
|
+
const properties = { contextWindow: effectiveContextWindow(model) }
|
|
678
|
+
if (model.supportsImages) {
|
|
679
|
+
properties['inputFormat'] = { supportsImage: true }
|
|
680
|
+
}
|
|
681
|
+
const config = { enabled: true, properties }
|
|
682
|
+
// Ceiling on the selectable output budget, taken from the upstream's own
|
|
683
|
+
// `maxOutputTokens` rather than a per-model guess.
|
|
684
|
+
if (Number.isInteger(model.maxTokens) && model.maxTokens > 0) {
|
|
685
|
+
config['optionSpecs'] = { maxOutputTokens: { max: model.maxTokens } }
|
|
686
|
+
}
|
|
687
|
+
const entry = { modelId: model.id, providerId, config }
|
|
688
|
+
const index = providerModelRules.findIndex(item => item?.modelId === entry.modelId && item?.providerId === providerId)
|
|
689
|
+
if (index === -1) providerModelRules.push(entry)
|
|
690
|
+
else providerModelRules[index] = entry
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
summary.push({
|
|
694
|
+
providerId,
|
|
695
|
+
appName,
|
|
696
|
+
models: wiredIds.length,
|
|
697
|
+
modelRules: modelCount,
|
|
698
|
+
baseUrl: variantBaseUrl,
|
|
699
|
+
modelIds: wiredIds,
|
|
700
|
+
// Full shapes for the 3.15.3 personal-provider mirror (see
|
|
701
|
+
// writePersonalProviders): the legacy document only needs ids.
|
|
702
|
+
detail: models.map(model => ({
|
|
703
|
+
id: model.id,
|
|
704
|
+
name: model.name,
|
|
705
|
+
contextWindow: model.contextWindow,
|
|
706
|
+
maxTokens: model.maxTokens,
|
|
707
|
+
supportsImages: model.supportsImages === true,
|
|
708
|
+
})),
|
|
709
|
+
})
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Setup has to be idempotent: it only ever adds or updates, so a rule left
|
|
713
|
+
// behind by an earlier run — a renamed model, or the retired `wbai:` prefix —
|
|
714
|
+
// would otherwise linger in the picker forever. Drop every rule that belongs
|
|
715
|
+
// to a provider we manage but is no longer declared by it.
|
|
716
|
+
const managed = new Map(stores.map(({ id }) => [id === 'cn' ? 'workbuddy' : 'workbuddy-ai', undefined]))
|
|
717
|
+
for (const item of summary) {
|
|
718
|
+
if (item.skipped !== true) managed.set(item.providerId, new Set(item.modelIds))
|
|
719
|
+
}
|
|
720
|
+
for (let index = providerModelRules.length - 1; index >= 0; index -= 1) {
|
|
721
|
+
const declared = managed.get(providerModelRules[index]?.providerId)
|
|
722
|
+
if (declared !== undefined && !declared.has(providerModelRules[index].modelId)) {
|
|
723
|
+
providerModelRules.splice(index, 1)
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// Write through a temp file and validate before the real path is touched.
|
|
728
|
+
//
|
|
729
|
+
// ZCode's provider schema is `.strict()`, and its failure mode is to discard
|
|
730
|
+
// the ENTIRE document and fall back to an empty config when any rule carries
|
|
731
|
+
// an unrecognised key. The user's other providers (OpenRouter, DeepSeek, …)
|
|
732
|
+
// would go with it, and nothing would say why. The validator mirrors the
|
|
733
|
+
// schema, so a bad key fails loudly here instead of silently there.
|
|
734
|
+
await mkdir(dirname(configPath), { recursive: true })
|
|
735
|
+
const tempPath = `${configPath}.${process.pid}.tmp`
|
|
736
|
+
const serialized = `${JSON.stringify(document, null, 2)}\n`
|
|
737
|
+
await writeFile(tempPath, serialized, 'utf8')
|
|
738
|
+
const verdict = await validateProviderConfig(tempPath)
|
|
739
|
+
if (!verdict.ok) {
|
|
740
|
+
await rm(tempPath, { force: true })
|
|
741
|
+
console.error(`error: refusing to write ${configPath} — the document would be rejected by ZCode's schema:`)
|
|
742
|
+
for (const problem of verdict.problems) console.error(` - ${problem}`)
|
|
743
|
+
console.error(' ZCode discards the whole provider config in that case, so nothing was written.')
|
|
744
|
+
process.exitCode = 1
|
|
745
|
+
return
|
|
746
|
+
}
|
|
747
|
+
await rename(tempPath, configPath)
|
|
748
|
+
|
|
749
|
+
// ZCode 3.15.3 no longer reads this document — mirror the same providers
|
|
750
|
+
// into the personal provider config it does read (v2/config.json).
|
|
751
|
+
const mirrored = await writePersonalProviders(port, token, summary)
|
|
752
|
+
if (mirrored.error !== undefined) {
|
|
753
|
+
console.warn(`warning: personal provider config not updated: ${mirrored.error}`)
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
if (args.json) {
|
|
757
|
+
console.log(JSON.stringify({ configPath, baseUrl, providers: summary, personalConfig: mirrored }, null, 2))
|
|
758
|
+
return
|
|
759
|
+
}
|
|
760
|
+
console.log(`Wrote provider entries into ${configPath}`)
|
|
761
|
+
console.log(` baseUrl ${baseUrl}`)
|
|
762
|
+
console.log(` bearer ${mask(token)}`)
|
|
763
|
+
if (mirrored.path !== undefined) {
|
|
764
|
+
console.log(` personal ${mirrored.path}`)
|
|
765
|
+
console.log(` ${mirrored.providers} provider(s), ${mirrored.models} model(s) — takes effect after a ZCode restart`)
|
|
766
|
+
}
|
|
767
|
+
for (const item of summary) {
|
|
768
|
+
if (item.skipped) console.log(` ${item.appName.padEnd(14)} SKIPPED — ${item.reason.slice(0, 90)}`)
|
|
769
|
+
else console.log(` ${item.appName.padEnd(14)} ${item.models} models (${item.modelRules} per-model rules) @ ${item.baseUrl}`)
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Mirror the endpoint providers into ZCode 3.15.3+'s personal provider config.
|
|
775
|
+
*
|
|
776
|
+
* The legacy `provider_config.json` document above is no longer read by ZCode
|
|
777
|
+
* 3.15.3: personal providers moved to the `provider` object of
|
|
778
|
+
* `~/.zcode/v2/config.json`, keyed by provider id, each entry shaped
|
|
779
|
+
* `{name, kind, options:{apiKey, baseURL, apiKeyRequired}, source, models}`.
|
|
780
|
+
* Both files are written so one setup run serves old and new ZCode alike.
|
|
781
|
+
*
|
|
782
|
+
* The id is a fixed UUID rather than a readable slug: the ids ZCode itself
|
|
783
|
+
* mints for custom providers (`crypto.randomUUID()` in the app) are UUIDs, and
|
|
784
|
+
* matching that shape avoids any id-format assumption in the registry. The
|
|
785
|
+
* entry is rewritten in place on every run, so re-running setup is idempotent.
|
|
786
|
+
*
|
|
787
|
+
* Caveat: ZCode keeps this document in memory while it runs and may persist
|
|
788
|
+
* its own view over ours on exit. Run setup (or this mirror) while ZCode is
|
|
789
|
+
* closed, or simply re-run it after a restart, if the providers go missing.
|
|
790
|
+
*/
|
|
791
|
+
async function writePersonalProviders(port, token, summary) {
|
|
792
|
+
const configPath = join(process.env['USERPROFILE'] ?? process.env['HOME'] ?? '.', '.zcode', 'v2', 'config.json')
|
|
793
|
+
const live = summary.filter(item => item.skipped !== true && Array.isArray(item.detail) && item.detail.length > 0)
|
|
794
|
+
if (live.length === 0) {
|
|
795
|
+
return { path: configPath, providers: 0, models: 0 }
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const idFor = providerId => providerId === 'workbuddy'
|
|
799
|
+
? '7c9e2f31-4a5b-4c8d-9e0f-a1b2c3d4e5f6'
|
|
800
|
+
: 'b2d4e6f8-1a3c-4e5f-9a7b-c8d9e0f1a2b3'
|
|
801
|
+
|
|
802
|
+
let document
|
|
803
|
+
try {
|
|
804
|
+
document = JSON.parse(await readFile(configPath, 'utf8'))
|
|
805
|
+
} catch {
|
|
806
|
+
document = {}
|
|
807
|
+
}
|
|
808
|
+
if (typeof document !== 'object' || document === null || Array.isArray(document)) document = {}
|
|
809
|
+
const providers = (typeof document['provider'] === 'object' && document['provider'] !== null && !Array.isArray(document['provider']))
|
|
810
|
+
? document['provider']
|
|
811
|
+
: {}
|
|
812
|
+
document['provider'] = providers
|
|
813
|
+
|
|
814
|
+
let modelTotal = 0
|
|
815
|
+
for (const item of live) {
|
|
816
|
+
const models = {}
|
|
817
|
+
for (const model of item.detail) {
|
|
818
|
+
modelTotal += 1
|
|
819
|
+
models[model.id] = {
|
|
820
|
+
name: model.name,
|
|
821
|
+
limit: {
|
|
822
|
+
context: Number.isInteger(model.contextWindow) && model.contextWindow > 0 ? model.contextWindow : 200000,
|
|
823
|
+
output: Number.isInteger(model.maxTokens) && model.maxTokens > 0 ? model.maxTokens : 64000,
|
|
824
|
+
},
|
|
825
|
+
modalities: {
|
|
826
|
+
input: model.supportsImages ? ['text', 'image'] : ['text'],
|
|
827
|
+
output: ['text'],
|
|
828
|
+
},
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
providers[idFor(item.providerId)] = {
|
|
832
|
+
name: item.appName,
|
|
833
|
+
kind: 'openai-compatible',
|
|
834
|
+
options: {
|
|
835
|
+
apiKey: token,
|
|
836
|
+
baseURL: item.baseUrl,
|
|
837
|
+
apiKeyRequired: true,
|
|
838
|
+
},
|
|
839
|
+
source: 'custom',
|
|
840
|
+
models,
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// Same temp-file-then-rename discipline as the legacy write: a half-written
|
|
845
|
+
// config.json would be discarded wholesale by ZCode, taking every provider
|
|
846
|
+
// (including the user's own) with it.
|
|
847
|
+
try {
|
|
848
|
+
await mkdir(dirname(configPath), { recursive: true })
|
|
849
|
+
const tempPath = `${configPath}.${process.pid}.tmp`
|
|
850
|
+
await writeFile(tempPath, `${JSON.stringify(document, null, 2)}\n`, 'utf8')
|
|
851
|
+
await rename(tempPath, configPath)
|
|
852
|
+
} catch (error) {
|
|
853
|
+
return { path: configPath, providers: 0, models: 0, error: String(error) }
|
|
854
|
+
}
|
|
855
|
+
return { path: configPath, providers: live.length, models: modelTotal }
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Run the schema mirror over a candidate document.
|
|
860
|
+
*
|
|
861
|
+
* Delegates to `docs/validate-provider-config.mjs` rather than reimplementing
|
|
862
|
+
* the shapes: that file is the one place the schema is written down, and it is
|
|
863
|
+
* also runnable by hand. A validator that disagrees with itself is worse than
|
|
864
|
+
* none, so this shells out to it and reads the exit code.
|
|
865
|
+
*
|
|
866
|
+
* @returns {Promise<{ok: boolean, problems: string[]}>}
|
|
867
|
+
*/
|
|
868
|
+
async function validateProviderConfig(candidatePath) {
|
|
869
|
+
const script = fileURLToPath(new URL('../docs/validate-provider-config.mjs', import.meta.url))
|
|
870
|
+
return await new Promise(resolve => {
|
|
871
|
+
const child = spawn(process.execPath, [script, candidatePath], { stdio: ['ignore', 'ignore', 'pipe'] })
|
|
872
|
+
let stderr = ''
|
|
873
|
+
child.stderr.on('data', chunk => { stderr += chunk })
|
|
874
|
+
child.on('error', error => resolve({ ok: false, problems: [String(error)] }))
|
|
875
|
+
child.on('close', code => {
|
|
876
|
+
const problems = stderr.split('\n')
|
|
877
|
+
.filter(line => line.trim().startsWith('- '))
|
|
878
|
+
.map(line => line.trim().slice(2))
|
|
879
|
+
resolve({ ok: code === 0, problems: problems.length > 0 ? problems : [stderr.trim() || `validator exited ${code}`] })
|
|
880
|
+
})
|
|
881
|
+
})
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
async function commandServe(args) {
|
|
885
|
+
const token = await ensureToken(args)
|
|
886
|
+
const port = args.port === undefined ? DEFAULT_PORT : Number(args.port)
|
|
887
|
+
configureEndpoint({ port, token })
|
|
888
|
+
const { client, stores } = buildStores(args)
|
|
889
|
+
|
|
890
|
+
const server = createWorkBuddyServer({
|
|
891
|
+
token,
|
|
892
|
+
port,
|
|
893
|
+
client,
|
|
894
|
+
variants: stores.map(({ id, path: mount, prefix, store }) => {
|
|
895
|
+
const catalog = new WorkBuddyCatalog()
|
|
896
|
+
// Report the widest window the upstream admits rather than the softer
|
|
897
|
+
// `defaultLength` tier, so a client that reads `/v1/models` plans against
|
|
898
|
+
// the real ceiling. Same intent as `setup`'s per-model rules; see there
|
|
899
|
+
// for why this is a no-op on today's wire but explicit on purpose.
|
|
900
|
+
catalog.setUseMaximumContextWindow(!args.defaultContextWindow)
|
|
901
|
+
return { id, path: mount, prefix, store, catalog }
|
|
902
|
+
}),
|
|
903
|
+
})
|
|
904
|
+
await server.start()
|
|
905
|
+
|
|
906
|
+
// Daily check-in: claim once per UTC+8 day while the service runs. The
|
|
907
|
+
// desktop app's own check-in stays authoritative — this only tops up on days
|
|
908
|
+
// the app was never opened. State is a one-line date stamp in the state dir.
|
|
909
|
+
const checkinStatePath = () => join(stateDir(), 'checkin-date.txt')
|
|
910
|
+
const runAutoCheckin = async () => {
|
|
911
|
+
try {
|
|
912
|
+
const today = new Date(Date.now() + 8 * 3600_000).toISOString().slice(0, 10)
|
|
913
|
+
let last = ''
|
|
914
|
+
try { last = (await readFile(checkinStatePath(), 'utf8')).trim() } catch {}
|
|
915
|
+
if (last === today) return
|
|
916
|
+
for (const { appName, store } of stores) {
|
|
917
|
+
const status = await store.status()
|
|
918
|
+
if (status.state !== 'signed-in') continue
|
|
919
|
+
const credential = await store.resolve()
|
|
920
|
+
const current = await client.fetchCheckinStatus(credential)
|
|
921
|
+
if (!current.active || current.todayCheckedIn) continue
|
|
922
|
+
const claim = await client.claimDailyCheckin(credential)
|
|
923
|
+
console.log(`[zcode-workbuddy-connect] auto check-in [${appName}]: +${claim.credit} credits (streak ${claim.streakDays}d)`)
|
|
924
|
+
}
|
|
925
|
+
await writeFile(checkinStatePath(), today, 'utf8')
|
|
926
|
+
} catch (error) {
|
|
927
|
+
console.log(`[zcode-workbuddy-connect] auto check-in skipped — ${String(error?.message ?? error).slice(0, 120)}`)
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
void runAutoCheckin()
|
|
931
|
+
const checkinTimer = setInterval(() => { void runAutoCheckin() }, 60 * 60 * 1000)
|
|
932
|
+
checkinTimer.unref?.()
|
|
933
|
+
|
|
934
|
+
const url = `http://127.0.0.1:${server.port()}`
|
|
935
|
+
console.log(`[zcode-workbuddy-connect] listening on ${url}`)
|
|
936
|
+
console.log(`[zcode-workbuddy-connect] OpenAI-compatible base: ${url}/v1`)
|
|
937
|
+
for (const { id, path: mount } of stores) {
|
|
938
|
+
console.log(`[zcode-workbuddy-connect] ${id.padEnd(3)} mount: ${url}${mount}/v1`)
|
|
939
|
+
}
|
|
940
|
+
console.log(`[zcode-workbuddy-connect] bearer: ${token}`)
|
|
941
|
+
console.log(`[zcode-workbuddy-connect] state dir: ${stateDir()}`)
|
|
942
|
+
|
|
943
|
+
const shutdown = async signal => {
|
|
944
|
+
console.log(`\n[zcode-workbuddy-connect] ${signal} — shutting down`)
|
|
945
|
+
await server.close()
|
|
946
|
+
process.exit(0)
|
|
947
|
+
}
|
|
948
|
+
process.on('SIGINT', () => void shutdown('SIGINT'))
|
|
949
|
+
process.on('SIGTERM', () => void shutdown('SIGTERM'))
|
|
950
|
+
// Never exit on an unhandled stream error; a single failed request must not
|
|
951
|
+
// take down the endpoint every model call depends on.
|
|
952
|
+
process.on('uncaughtException', error => {
|
|
953
|
+
console.error(`[zcode-workbuddy-connect] uncaught: ${String(error)}`)
|
|
954
|
+
})
|
|
955
|
+
process.on('unhandledRejection', error => {
|
|
956
|
+
console.error(`[zcode-workbuddy-connect] unhandled rejection: ${String(error)}`)
|
|
957
|
+
})
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/** Run a command from argv. Exported so the bin entry stays a one-liner. */
|
|
961
|
+
export async function main() {
|
|
962
|
+
const args = parseArgs(process.argv.slice(2))
|
|
963
|
+
if (args.help === true || args.h === true) {
|
|
964
|
+
console.log(HELP)
|
|
965
|
+
return
|
|
966
|
+
}
|
|
967
|
+
configureStateDir(typeof args.home === 'string' ? args.home : defaultStateDir())
|
|
968
|
+
configureEndpoint({ port: Number(args.port) || 0, token: typeof args.token === 'string' ? args.token : '' })
|
|
969
|
+
|
|
970
|
+
const command = args._[0] ?? 'serve'
|
|
971
|
+
switch (command) {
|
|
972
|
+
case 'serve':
|
|
973
|
+
await commandServe(args)
|
|
974
|
+
return
|
|
975
|
+
case 'status':
|
|
976
|
+
await commandStatus(args)
|
|
977
|
+
return
|
|
978
|
+
case 'refresh':
|
|
979
|
+
await commandRefresh(args)
|
|
980
|
+
return
|
|
981
|
+
case 'setup':
|
|
982
|
+
await commandSetup(args)
|
|
983
|
+
return
|
|
984
|
+
case 'doctor':
|
|
985
|
+
await commandDoctor(args)
|
|
986
|
+
return
|
|
987
|
+
case 'logout':
|
|
988
|
+
await commandLogout(args)
|
|
989
|
+
return
|
|
990
|
+
case 'install-service':
|
|
991
|
+
await commandInstallService(args)
|
|
992
|
+
return
|
|
993
|
+
case 'uninstall-service':
|
|
994
|
+
await commandUninstallService(args)
|
|
995
|
+
return
|
|
996
|
+
case 'service-status':
|
|
997
|
+
await commandServiceStatus(args)
|
|
998
|
+
return
|
|
999
|
+
case 'token':
|
|
1000
|
+
console.log(await ensureToken(args))
|
|
1001
|
+
return
|
|
1002
|
+
case 'checkin':
|
|
1003
|
+
await commandCheckin(args)
|
|
1004
|
+
return
|
|
1005
|
+
default:
|
|
1006
|
+
console.error(`Unknown command: ${command}\n`)
|
|
1007
|
+
console.log(HELP)
|
|
1008
|
+
process.exitCode = 2
|
|
1009
|
+
}
|
|
1010
|
+
}
|