@wevion/cli 1.0.2 → 1.0.3

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/index.mjs CHANGED
@@ -12,7 +12,6 @@
12
12
  import { parseArgs } from 'node:util'
13
13
  import {
14
14
  chmodSync,
15
- existsSync,
16
15
  mkdirSync,
17
16
  readFileSync,
18
17
  realpathSync,
@@ -24,6 +23,7 @@ import { homedir } from 'node:os'
24
23
  import { createInterface } from 'node:readline/promises'
25
24
  import { fileURLToPath } from 'node:url'
26
25
  import { dirname, join } from 'node:path'
26
+ import { spawn } from 'node:child_process'
27
27
 
28
28
  const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete']
29
29
  const JSON_CONTENT_TYPE = 'application/json'
@@ -31,6 +31,7 @@ const SUPPORTED_AUTH_SCHEMES = new Set(['apiKeyAuth'])
31
31
  // ponytail: hardcoded prod default; stage via --base-url / WEVION_BASE_URL / config.
32
32
  const DEFAULT_BASE_URL = 'https://api.wevion.ai'
33
33
  const DEFAULT_TIMEOUT_MS = 30_000
34
+ const DEFAULT_SPEC_TIMEOUT_MS = 3000
34
35
 
35
36
  // ~/.config/wevion/config.json (honours XDG_CONFIG_HOME). Stores { apiKey, baseUrl }.
36
37
  export function configPath(env = process.env, platform = process.platform) {
@@ -162,6 +163,188 @@ function logout() {
162
163
  return 0
163
164
  }
164
165
 
166
+ // ── Update notifier ──────────────────────────────────────────────
167
+ // Zero-dep: a detached background process refreshes a cached "latest" at most
168
+ // once/day; the notice (stderr only, never stdout) shows on the next run.
169
+
170
+ function pkgInfo() {
171
+ try {
172
+ const p = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json')
173
+ const { name, version } = JSON.parse(readFileSync(p, 'utf8'))
174
+ return { name, version }
175
+ } catch {
176
+ return {}
177
+ }
178
+ }
179
+
180
+ export function updateCheckPath(env = process.env, platform = process.platform) {
181
+ return join(dirname(configPath(env, platform)), 'update-check.json')
182
+ }
183
+
184
+ function parseSemver(v) {
185
+ const match = String(v).match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/)
186
+ if (!match) return null
187
+ return {
188
+ major: Number(match[1]),
189
+ minor: Number(match[2]),
190
+ patch: Number(match[3]),
191
+ prerelease: match[4] ? match[4].split('.') : [],
192
+ }
193
+ }
194
+
195
+ function comparePrerelease(a, b) {
196
+ if (!a.length && !b.length) return 0
197
+ if (!a.length) return 1
198
+ if (!b.length) return -1
199
+ const len = Math.max(a.length, b.length)
200
+ for (let i = 0; i < len; i++) {
201
+ const ai = a[i]
202
+ const bi = b[i]
203
+ if (ai == null) return -1
204
+ if (bi == null) return 1
205
+ const an = /^\d+$/.test(ai) ? Number(ai) : null
206
+ const bn = /^\d+$/.test(bi) ? Number(bi) : null
207
+ if (an !== null && bn !== null && an !== bn) return an - bn
208
+ if (an !== null && bn === null) return -1
209
+ if (an === null && bn !== null) return 1
210
+ if (an === null && bn === null && ai !== bi) return ai < bi ? -1 : 1
211
+ }
212
+ return 0
213
+ }
214
+
215
+ // Minimal semver compare: numeric core, prerelease lower than release.
216
+ export function compareVersions(a, b) {
217
+ const pa = parseSemver(a)
218
+ const pb = parseSemver(b)
219
+ if (!pa || !pb) return 0
220
+ return pa.major - pb.major || pa.minor - pb.minor || pa.patch - pb.patch || comparePrerelease(pa.prerelease, pb.prerelease)
221
+ }
222
+
223
+ export function isSafeVersion(v) {
224
+ return typeof v === 'string' && /^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(v)
225
+ }
226
+
227
+ export function updateCheckEnv(env = process.env) {
228
+ const allowlist = [
229
+ 'HOME',
230
+ 'USERPROFILE',
231
+ 'APPDATA',
232
+ 'XDG_CONFIG_HOME',
233
+ 'HTTP_PROXY',
234
+ 'HTTPS_PROXY',
235
+ 'NO_PROXY',
236
+ 'http_proxy',
237
+ 'https_proxy',
238
+ 'no_proxy',
239
+ 'NODE_EXTRA_CA_CERTS',
240
+ 'SSL_CERT_FILE',
241
+ 'SSL_CERT_DIR',
242
+ 'SystemRoot',
243
+ 'WINDIR',
244
+ 'TEMP',
245
+ 'TMP',
246
+ ]
247
+ return Object.fromEntries(allowlist.filter((key) => env[key]).map((key) => [key, env[key]]))
248
+ }
249
+
250
+ function readUpdateCache() {
251
+ try {
252
+ const cache = JSON.parse(readFileSync(updateCheckPath(), 'utf8'))
253
+ return cache && typeof cache === 'object' ? cache : {}
254
+ } catch {
255
+ return {}
256
+ }
257
+ }
258
+
259
+ function writePrivateJsonFile(p, payload) {
260
+ const dir = dirname(p)
261
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
262
+ try {
263
+ chmodSync(dir, 0o700)
264
+ } catch {
265
+ /* best effort on platforms without POSIX modes */
266
+ }
267
+ const tmp = `${p}.${process.pid}.tmp`
268
+ try {
269
+ writeFileSync(tmp, JSON.stringify(payload) + '\n', { mode: 0o600 })
270
+ renameSync(tmp, p)
271
+ try {
272
+ chmodSync(p, 0o600)
273
+ } catch {
274
+ /* best effort on platforms without POSIX modes */
275
+ }
276
+ } catch (err) {
277
+ try {
278
+ rmSync(tmp, { force: true })
279
+ } catch {
280
+ /* ignore cleanup failure */
281
+ }
282
+ throw err
283
+ }
284
+ }
285
+
286
+ function writeUpdateCache(cache) {
287
+ writePrivateJsonFile(updateCheckPath(), cache)
288
+ }
289
+
290
+ // Hidden `__update-check` subcommand: fetch latest from npm, cache it. Best-effort.
291
+ async function refreshUpdateCache() {
292
+ const cache = readUpdateCache()
293
+ try {
294
+ const { name } = pkgInfo()
295
+ if (!name) return 0
296
+ const ctrl = new AbortController()
297
+ const t = setTimeout(() => ctrl.abort(), 5000)
298
+ const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}`, {
299
+ headers: { accept: 'application/vnd.npm.install-v1+json' },
300
+ signal: ctrl.signal,
301
+ }).finally(() => clearTimeout(t))
302
+ if (!res.ok) return 0
303
+ const data = await res.json()
304
+ const latest = data?.['dist-tags']?.latest
305
+ if (isSafeVersion(latest)) cache.latest = latest
306
+ } catch {
307
+ /* offline / registry down — never surface */
308
+ } finally {
309
+ try {
310
+ writeUpdateCache({ ...cache, lastCheck: Date.now() })
311
+ } catch {
312
+ /* cache is best-effort */
313
+ }
314
+ }
315
+ return 0
316
+ }
317
+
318
+ // Print an update notice from cache (if any) and kick off a background refresh
319
+ // when the cache is stale. Must never block or throw, and never write to stdout.
320
+ function notifyUpdate() {
321
+ try {
322
+ if (process.env.NO_UPDATE_NOTIFIER || process.env.CI || !process.stdout.isTTY || !process.stderr.isTTY) return
323
+ const { name, version } = pkgInfo()
324
+ if (!name || !version) return
325
+ const cache = readUpdateCache()
326
+ if (isSafeVersion(cache.latest) && compareVersions(cache.latest, version) > 0) {
327
+ process.stderr.write(
328
+ `\nUpdate available for ${name}: ${version} → ${cache.latest}\n` +
329
+ `Run: npm i -g ${name}@latest\n\n`,
330
+ )
331
+ }
332
+ const DAY_MS = 86_400_000
333
+ const lastCheck = Number(cache.lastCheck)
334
+ if (!Number.isFinite(lastCheck) || Date.now() - lastCheck > DAY_MS) {
335
+ const child = spawn(process.execPath, [fileURLToPath(import.meta.url), '__update-check'], {
336
+ detached: true,
337
+ stdio: 'ignore',
338
+ env: updateCheckEnv(),
339
+ windowsHide: true,
340
+ })
341
+ child.unref()
342
+ }
343
+ } catch {
344
+ /* never break the CLI over an update check */
345
+ }
346
+ }
347
+
165
348
  export function kebab(s) {
166
349
  return s
167
350
  .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
@@ -368,15 +551,139 @@ function validateSpec(spec, source) {
368
551
  return spec
369
552
  }
370
553
 
371
- function loadSpec(baseUrl) {
372
- // Prefer the bundled snapshot (offline help, reproducible per release).
554
+ // The API changes often (with breaking changes), so the live spec is the source
555
+ // of truth: commands/params always match the API. The bundled snapshot is only
556
+ // an offline fallback. A short on-disk cache avoids re-downloading the (large)
557
+ // spec on every command.
558
+ const CLI_VERSION_HEADER = 'x-wevion-cli-version'
559
+ const MIN_CLI_VERSION_HEADER = 'x-wevion-min-cli-version'
560
+
561
+ class UpgradeRequiredError extends Error {}
562
+
563
+ function cliVersion() {
564
+ return pkgInfo().version || '0.0.0'
565
+ }
566
+
567
+ function specCachePath(env = process.env, platform = process.platform) {
568
+ return join(dirname(configPath(env, platform)), 'spec-cache.json')
569
+ }
570
+
571
+ function specTtlMs(env = process.env) {
572
+ const parsed = Number(env.WEVION_SPEC_TTL_MS)
573
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 300_000 // 5 min
574
+ }
575
+
576
+ function specTimeoutMs(env = process.env) {
577
+ const parsed = Number(env.WEVION_SPEC_TIMEOUT_MS)
578
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_SPEC_TIMEOUT_MS
579
+ }
580
+
581
+ function specSignalFromEnv(env = process.env) {
582
+ return AbortSignal.timeout(specTimeoutMs(env))
583
+ }
584
+
585
+ // Extract the server's upgrade message (426 body), or a sensible default.
586
+ export function upgradeMessage(text, minVersion) {
587
+ try {
588
+ const j = JSON.parse(text)
589
+ if (j && typeof j.message === 'string') return j.message
590
+ } catch {
591
+ /* not JSON */
592
+ }
593
+ return (
594
+ `This @wevion/cli version (${cliVersion()}) is no longer supported.` +
595
+ `${minVersion ? ` Minimum: ${minVersion}.` : ''} Run: npm i -g @wevion/cli@latest`
596
+ )
597
+ }
598
+
599
+ function readSpecCache(cachePath) {
600
+ try {
601
+ const cache = JSON.parse(readFileSync(cachePath, 'utf8'))
602
+ return cache && typeof cache === 'object' ? cache : null
603
+ } catch {
604
+ return null
605
+ }
606
+ }
607
+
608
+ function validCachedSpec(cache, baseUrl, cachePath) {
609
+ if (!cache || cache.baseUrl !== baseUrl || !Number.isFinite(Number(cache.fetchedAt))) return null
610
+ try {
611
+ return validateSpec(cache.spec, cachePath)
612
+ } catch {
613
+ return null
614
+ }
615
+ }
616
+
617
+ async function assertCliVersionSupported(baseUrl) {
618
+ const url = `${baseUrl.replace(/\/$/, '')}/docs/json`
619
+ try {
620
+ const res = await fetch(url, {
621
+ method: 'HEAD',
622
+ headers: { [CLI_VERSION_HEADER]: cliVersion() },
623
+ signal: specSignalFromEnv(),
624
+ })
625
+ if (res.status === 426) {
626
+ throw new UpgradeRequiredError(upgradeMessage('', res.headers.get(MIN_CLI_VERSION_HEADER)))
627
+ }
628
+ } catch (err) {
629
+ if (err instanceof UpgradeRequiredError) throw err
630
+ /* A failed probe is not fatal; the cached spec still preserves offline list/help. */
631
+ }
632
+ }
633
+
634
+ function fallbackSpec(err, cacheSpec) {
635
+ if (cacheSpec) {
636
+ process.stderr.write(`warning: API spec unreachable, using cached copy (${err.message})\n`)
637
+ return { spec: cacheSpec, source: 'stale-cache' }
638
+ }
373
639
  const bundled = join(dirname(fileURLToPath(import.meta.url)), '..', 'openapi.json')
374
640
  try {
375
- return { spec: validateSpec(JSON.parse(readFileSync(bundled, 'utf8')), bundled), source: 'bundled' }
641
+ const spec = validateSpec(JSON.parse(readFileSync(bundled, 'utf8')), bundled)
642
+ process.stderr.write(`warning: API spec unreachable, using bundled snapshot (${err.message})\n`)
643
+ return { spec, source: 'bundled' }
644
+ } catch {
645
+ throw err
646
+ }
647
+ }
648
+
649
+ async function resolveSpec(baseUrl) {
650
+ const cachePath = specCachePath()
651
+ const cache = readSpecCache(cachePath)
652
+ const cacheSpec = validCachedSpec(cache, baseUrl, cachePath)
653
+ if (cacheSpec && Date.now() - Number(cache.fetchedAt) < specTtlMs()) {
654
+ await assertCliVersionSupported(baseUrl)
655
+ return { spec: cacheSpec, source: 'cache' }
656
+ }
657
+
658
+ const url = `${baseUrl.replace(/\/$/, '')}/docs/json`
659
+ let res
660
+ let text
661
+ try {
662
+ res = await fetch(url, {
663
+ headers: { [CLI_VERSION_HEADER]: cliVersion() },
664
+ signal: specSignalFromEnv(),
665
+ })
666
+ text = await res.text()
667
+ } catch (err) {
668
+ return fallbackSpec(err, cacheSpec)
669
+ }
670
+
671
+ if (res.status === 426) throw new UpgradeRequiredError(upgradeMessage(text, res.headers.get(MIN_CLI_VERSION_HEADER)))
672
+ if (!res.ok) return fallbackSpec(new Error(`cannot fetch spec from ${url}: ${res.status}`), cacheSpec)
673
+
674
+ let spec
675
+ try {
676
+ spec = validateSpec(JSON.parse(text), url)
376
677
  } catch (err) {
377
- if (existsSync(bundled)) throw err
378
- return { source: 'remote', url: `${baseUrl.replace(/\/$/, '')}/docs/json` }
678
+ return fallbackSpec(new Error(`invalid OpenAPI spec from ${url}: ${err.message}`), cacheSpec)
379
679
  }
680
+
681
+ try {
682
+ writePrivateJsonFile(cachePath, { fetchedAt: Date.now(), baseUrl, spec })
683
+ } catch {
684
+ /* cache is best-effort */
685
+ }
686
+ return { spec, source: 'live' }
380
687
  }
381
688
 
382
689
  function timeoutMs(env = process.env) {
@@ -388,6 +695,37 @@ function signalFromEnv(env = process.env) {
388
695
  return AbortSignal.timeout(timeoutMs(env))
389
696
  }
390
697
 
698
+ function isLoopbackHostname(hostname) {
699
+ const h = hostname.toLowerCase()
700
+ return h === 'localhost' || h === '::1' || h === '[::1]' || /^127\./.test(h)
701
+ }
702
+
703
+ function assertSafeAuthenticatedUrl(rawUrl) {
704
+ const url = new URL(rawUrl)
705
+ if (url.username || url.password) {
706
+ throw new Error('base URL must not include credentials')
707
+ }
708
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && isLoopbackHostname(url.hostname))) {
709
+ throw new Error('refusing to send API key to a non-HTTPS base URL')
710
+ }
711
+ }
712
+
713
+ function redactUrl(rawUrl) {
714
+ try {
715
+ const url = new URL(rawUrl)
716
+ if (url.username) url.username = '[REDACTED]'
717
+ if (url.password) url.password = '[REDACTED]'
718
+ for (const key of [...url.searchParams.keys()]) {
719
+ if (/token|secret|password|api[-_]?key|authorization|code/i.test(key)) {
720
+ url.searchParams.set(key, '[REDACTED]')
721
+ }
722
+ }
723
+ return url.toString()
724
+ } catch {
725
+ return rawUrl
726
+ }
727
+ }
728
+
391
729
  function resolveBaseUrl(argv, config, env = process.env) {
392
730
  const globalOptions = { 'base-url': { type: 'string' } }
393
731
  try {
@@ -397,12 +735,127 @@ function resolveBaseUrl(argv, config, env = process.env) {
397
735
  allowPositionals: true,
398
736
  strict: false,
399
737
  })
400
- return values['base-url'] || env.WEVION_BASE_URL || config.baseUrl || DEFAULT_BASE_URL
738
+ return (typeof values['base-url'] === 'string' && values['base-url']) || env.WEVION_BASE_URL || config.baseUrl || DEFAULT_BASE_URL
401
739
  } catch {
402
740
  return env.WEVION_BASE_URL || config.baseUrl || DEFAULT_BASE_URL
403
741
  }
404
742
  }
405
743
 
744
+ function stripLeadingGlobalOptions(argv) {
745
+ for (let i = 0; i < argv.length; i++) {
746
+ const arg = argv[i]
747
+ if (arg === '--base-url') {
748
+ i += 1
749
+ continue
750
+ }
751
+ if (arg.startsWith('--base-url=')) continue
752
+ return argv.slice(i)
753
+ }
754
+ return []
755
+ }
756
+
757
+ function parseJsonOutputFlag(args) {
758
+ const { values } = parseArgs({
759
+ args,
760
+ options: { json: { type: 'boolean' }, 'base-url': { type: 'string' } },
761
+ allowPositionals: false,
762
+ })
763
+ return Boolean(values.json)
764
+ }
765
+
766
+ function schemaMeta(schema = {}) {
767
+ return {
768
+ type: schema.type || 'string',
769
+ ...(schema.format ? { format: schema.format } : {}),
770
+ ...(schema.default !== undefined ? { default: schema.default } : {}),
771
+ ...(schema.enum ? { enum: schema.enum } : {}),
772
+ ...(schema.description ? { description: schema.description } : {}),
773
+ ...(schema.items ? { items: schemaMeta(schema.items) } : {}),
774
+ }
775
+ }
776
+
777
+ // Machine-readable view of a command, for agents (list --json / help --json).
778
+ export function opToJson(op) {
779
+ return {
780
+ command: op.id,
781
+ method: op.method,
782
+ path: op.path,
783
+ summary: op.summary,
784
+ tags: op.tags,
785
+ params: op.params
786
+ .filter((p) => p.in === 'path' || p.in === 'query' || p.in === 'header')
787
+ .map((p) => ({
788
+ flag: `--${p.name}`,
789
+ in: p.in,
790
+ required: Boolean(p.required),
791
+ ...(p.description && !p.schema?.description ? { description: p.description } : {}),
792
+ ...schemaMeta(p.schema),
793
+ array: p.schema?.type === 'array',
794
+ })),
795
+ body: op.bodyFields.length
796
+ ? {
797
+ flags: op.bodyFields.map((f) => ({
798
+ flag: `--${f.flag}`,
799
+ field: f.name,
800
+ required: op.bodyRequired.includes(f.name),
801
+ ...schemaMeta(f.schema),
802
+ })),
803
+ raw: "--json '<json>'",
804
+ }
805
+ : null,
806
+ ...(op.unsupported ? { unsupported: op.unsupported } : {}),
807
+ }
808
+ }
809
+
810
+ // One-shot primer so an LLM/agent with no Wevion knowledge can use the CLI.
811
+ function printAgent() {
812
+ const { name = '@wevion/cli', version = '?' } = pkgInfo()
813
+ process.stdout.write(`# ${name} — agent guide (v${version})
814
+
815
+ Command-line access to the Wevion advertising API. Everything you need is here;
816
+ no prior Wevion knowledge required.
817
+
818
+ ## Model
819
+ - Every command maps to ONE Wevion REST endpoint. The command id is the
820
+ kebab-case operationId, e.g. \`get-api-v1-ad-accounts\` -> GET /api/v1/ad-accounts.
821
+ - Commands are derived LIVE from the API's OpenAPI spec, so they always match
822
+ the current API.
823
+
824
+ ## Auth
825
+ - Set the API key: \`WEVION_API_KEY=<key>\` (env) or run \`wevion login\`.
826
+ - Create a key in the Wevion app under Settings → API keys (permission-scoped).
827
+
828
+ ## Discover (machine-readable)
829
+ - \`wevion list --json\` → all commands: [{command, method, path, summary, tags, params, body}]
830
+ - \`wevion help <command> --json\` → one command's exact params/body schema
831
+
832
+ ## Invoke
833
+ - Path, query and supported header params are flags using the exact OpenAPI
834
+ name. Names can be camelCase or snake_case (\`--accountId\`, \`--ad_id\`,
835
+ \`--folderId\`, \`--limit\`). Get exact names from \`help <command> --json\`;
836
+ do not guess.
837
+ - Array params: repeat the flag or pass a comma-separated list.
838
+ - Request body: pass fields as flags, or the whole JSON via \`--json '<json>'\`.
839
+
840
+ ## Output & exit codes
841
+ - Success: JSON on stdout, exit 0.
842
+ - API error: request line + JSON body on stderr, exit 1.
843
+ - Usage error (unknown command / missing required flag / no API key): exit 2.
844
+ - Upgrade required (CLI too old for the API): message on stderr, exit 3
845
+ → run \`npm i -g ${name}@latest\`.
846
+
847
+ ## Config
848
+ - Base URL: \`--base-url\` | \`WEVION_BASE_URL\` | stored login config |
849
+ default ${DEFAULT_BASE_URL} (staging: https://api-stage.wevion.ai).
850
+
851
+ ## Typical flow
852
+ 1. wevion agent # this guide
853
+ 2. wevion list --json # find the command
854
+ 3. wevion help <command> --json # get its params
855
+ 4. wevion <command> --flag value ... # run it; parse JSON from stdout
856
+ `)
857
+ }
858
+
406
859
  function printList(ops) {
407
860
  const byTag = new Map()
408
861
  for (const op of ops.values()) {
@@ -435,33 +888,55 @@ function printHelp(op) {
435
888
  }
436
889
 
437
890
  async function main(argv) {
438
- const [cmd, ...rest] = argv
891
+ const commandArgv = stripLeadingGlobalOptions(argv)
892
+ const [cmd, ...rest] = commandArgv
439
893
 
894
+ if (cmd === '__update-check') return refreshUpdateCache()
440
895
  if (cmd === 'login') return login(rest)
441
896
  if (cmd === 'logout') return logout()
897
+ if (cmd === 'agent') {
898
+ printAgent()
899
+ return 0
900
+ }
442
901
 
443
902
  const config = readConfig()
444
903
  const baseUrl = resolveBaseUrl(argv, config)
445
904
 
446
- const loaded = loadSpec(baseUrl)
447
- const spec =
448
- loaded.spec ||
449
- (await fetch(loaded.url, { signal: signalFromEnv() }).then((r) => {
450
- if (!r.ok) throw new Error(`cannot fetch spec from ${loaded.url}: ${r.status}`)
451
- return r.json().then((json) => validateSpec(json, loaded.url))
452
- }))
905
+ const { spec, source } = await resolveSpec(baseUrl)
453
906
  const ops = listOperations(spec)
454
907
 
455
908
  if (!cmd || cmd === 'list' || cmd === '--help' || cmd === '-h') {
456
- process.stdout.write(`Wevion CLI ${ops.size} commands (${loaded.source} spec)\n`)
909
+ let asJson = false
910
+ try {
911
+ asJson = parseJsonOutputFlag(rest)
912
+ } catch (err) {
913
+ return fail(err.message)
914
+ }
915
+ if (asJson) {
916
+ process.stdout.write(`${JSON.stringify([...ops.values()].map(opToJson), null, 2)}\n`)
917
+ return 0
918
+ }
919
+ process.stdout.write(`Wevion CLI — ${ops.size} commands (${source} spec)\n`)
457
920
  printList(ops)
458
921
  process.stdout.write(`\nUsage: wevion <command> [--param value ...]\n`)
459
- process.stdout.write(` wevion login | logout | help <command>\n`)
922
+ process.stdout.write(` wevion agent # guide for LLMs/agents\n`)
923
+ process.stdout.write(` wevion list --json | help <command> [--json]\n`)
924
+ process.stdout.write(` wevion login | logout\n`)
460
925
  return 0
461
926
  }
462
927
  if (cmd === 'help') {
463
928
  const op = ops.get(rest[0])
464
929
  if (!op) return fail(`unknown command: ${rest[0] || '(none)'}`)
930
+ let asJson = false
931
+ try {
932
+ asJson = parseJsonOutputFlag(rest.slice(1))
933
+ } catch (err) {
934
+ return fail(err.message)
935
+ }
936
+ if (asJson) {
937
+ process.stdout.write(`${JSON.stringify(opToJson(op), null, 2)}\n`)
938
+ return 0
939
+ }
465
940
  printHelp(op)
466
941
  return 0
467
942
  }
@@ -469,32 +944,62 @@ async function main(argv) {
469
944
  const op = ops.get(cmd)
470
945
  if (!op) return fail(`unknown command: ${cmd} (try "wevion list")`)
471
946
 
472
- const { values } = parseArgs({
473
- args: rest,
474
- options: buildParseOptions(op),
475
- allowPositionals: false,
476
- })
947
+ let values
948
+ try {
949
+ ;({ values } = parseArgs({
950
+ args: rest,
951
+ options: buildParseOptions(op),
952
+ allowPositionals: false,
953
+ }))
954
+ } catch (err) {
955
+ return fail(err.message)
956
+ }
477
957
 
478
958
  const apiKey = resolveApiKey(process.env, config)
479
959
  if (!apiKey) return fail('no API key — run "wevion login" or set WEVION_API_KEY')
480
960
 
481
- const req = buildRequest(op, values, values['base-url'] || baseUrl)
961
+ let req
962
+ try {
963
+ req = buildRequest(op, values, values['base-url'] || baseUrl)
964
+ assertSafeAuthenticatedUrl(req.url)
965
+ } catch (err) {
966
+ return fail(err.message)
967
+ }
482
968
  const res = await fetch(req.url, {
483
969
  method: req.method,
484
970
  headers: {
485
971
  ...req.headers,
486
972
  'x-api-key': apiKey,
973
+ [CLI_VERSION_HEADER]: cliVersion(),
487
974
  ...(req.body ? { 'content-type': 'application/json' } : {}),
488
975
  },
489
976
  body: req.body,
490
977
  signal: signalFromEnv(),
491
978
  })
492
- const text = await res.text()
979
+ const bytes = Buffer.from(await res.arrayBuffer())
980
+ const text = bytes.toString('utf8')
981
+ if (res.status === 426) throw new UpgradeRequiredError(upgradeMessage(text, res.headers.get(MIN_CLI_VERSION_HEADER)))
493
982
  if (!res.ok) {
494
- process.stderr.write(`${req.method} ${req.url} -> ${res.status}\n`)
983
+ process.stderr.write(`${req.method} ${redactUrl(req.url)} -> ${res.status}\n`)
495
984
  if (text) process.stderr.write(text.endsWith('\n') ? text : text + '\n')
496
985
  return 1
497
986
  }
987
+ const contentType = res.headers.get('content-type') || ''
988
+ if (res.status === 204 || bytes.length === 0) {
989
+ process.stdout.write(`${JSON.stringify({ ok: true, status: res.status })}\n`)
990
+ return 0
991
+ }
992
+ if (!contentType.includes('application/json')) {
993
+ process.stdout.write(
994
+ `${JSON.stringify({ ok: true, status: res.status, contentType, bodyBase64: bytes.toString('base64') })}\n`,
995
+ )
996
+ return 0
997
+ }
998
+ try {
999
+ JSON.parse(text)
1000
+ } catch {
1001
+ throw new Error(`API returned invalid JSON for ${req.method} ${req.url}`)
1002
+ }
498
1003
  process.stdout.write(text.endsWith('\n') ? text : text + '\n')
499
1004
  return 0
500
1005
  }
@@ -515,9 +1020,17 @@ function isExecutedAsBin() {
515
1020
 
516
1021
  // Run only when executed as a bin, not when imported by selftest.
517
1022
  if (isExecutedAsBin()) {
518
- main(process.argv.slice(2))
519
- .then((code) => process.exit(code))
1023
+ const cliArgs = process.argv.slice(2)
1024
+ main(cliArgs)
1025
+ .then((code) => {
1026
+ if (cliArgs[0] !== '__update-check') notifyUpdate()
1027
+ process.exit(code)
1028
+ })
520
1029
  .catch((err) => {
1030
+ if (err instanceof UpgradeRequiredError) {
1031
+ process.stderr.write(`\n${err.message}\n\n`)
1032
+ process.exit(3)
1033
+ }
521
1034
  process.stderr.write(`error: ${err.message}\n`)
522
1035
  process.exit(1)
523
1036
  })