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/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
+ }