@casatwy/deyo 0.1.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.
Files changed (3) hide show
  1. package/README.md +100 -0
  2. package/deyo.mjs +844 -0
  3. package/package.json +28 -0
package/README.md ADDED
@@ -0,0 +1,100 @@
1
+ # deyo
2
+
3
+ `@casatwy/deyo` installs the `deyo` command for sending transcription jobs to Deyo from your terminal.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g @casatwy/deyo
9
+ ```
10
+
11
+ ```bash
12
+ pnpm add -g @casatwy/deyo
13
+ ```
14
+
15
+ ## First-time login
16
+
17
+ Create a Deyo API key from `https://deyo.miaobi.fun/me/api-keys`, then save it once locally:
18
+
19
+ ```bash
20
+ deyo auth login --api-key 'deyo_sk_xxx'
21
+ ```
22
+
23
+ Check whether the local config is active:
24
+
25
+ ```bash
26
+ deyo auth status
27
+ ```
28
+
29
+ Remove the saved local config:
30
+
31
+ ```bash
32
+ deyo auth logout
33
+ ```
34
+
35
+ ## Usage
36
+
37
+ The published CLI defaults to the production service at `https://deyo.miaobi.fun`.
38
+
39
+ After `deyo auth login`, you can run transcription commands without repeating `--api-key`:
40
+
41
+ ```bash
42
+ deyo --language zh -O ./transcript.txt 'https://www.bilibili.com/video/BVxxxx'
43
+ ```
44
+
45
+ ```bash
46
+ deyo --language zh --format srt -O ./transcript.srt 'https://www.youtube.com/watch?v=xxxx'
47
+ ```
48
+
49
+ ```bash
50
+ deyo --language en 'https://www.youtube.com/watch?v=xxxx'
51
+ ```
52
+
53
+ If you need local development, pass `--base-url` explicitly:
54
+
55
+ ```bash
56
+ deyo auth login --api-key 'deyo_sk_xxx' --base-url 'http://deyo.mac-studio'
57
+ deyo --base-url 'http://deyo.mac-studio' --language zh -O ./transcript.txt 'https://www.bilibili.com/video/BVxxxx'
58
+ ```
59
+
60
+ For a one-off override, `--api-key` still takes precedence over local config:
61
+
62
+ ```bash
63
+ deyo --api-key 'deyo_sk_other' --language zh 'https://www.youtube.com/watch?v=xxxx'
64
+ ```
65
+
66
+ By default, the CLI requests Chinese output, which is equivalent to `--language zh`.
67
+
68
+ Run `deyo --help` to see the full argument list.
69
+
70
+ ## Install in AI tools
71
+
72
+ The official install pages live at:
73
+
74
+ - Codex: `https://deyo.miaobi.fun/ai/install/codex`
75
+ - Claude: `https://deyo.miaobi.fun/ai/install/claude`
76
+
77
+ These pages tell the AI how to install the `deyo` skill or plugin, ask for the user's API key once, and run `deyo auth login` locally.
78
+
79
+ ## Publish
80
+
81
+ For maintainers, publish from the `cli_` directory:
82
+
83
+ ```bash
84
+ cd cli_
85
+ make deploy
86
+ ```
87
+
88
+ If you need to publish a new version, pass it explicitly:
89
+
90
+ ```bash
91
+ cd cli_
92
+ make deploy VERSION=0.1.1
93
+ ```
94
+
95
+ With npm 2FA enabled, pass the current OTP code explicitly:
96
+
97
+ ```bash
98
+ cd cli_
99
+ make deploy OTP=123456
100
+ ```
package/deyo.mjs ADDED
@@ -0,0 +1,844 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
4
+ import { dirname, extname, resolve } from 'node:path'
5
+ import { homedir } from 'node:os'
6
+ import process from 'node:process'
7
+
8
+ const DEFAULT_BASE_URL = 'https://deyo.miaobi.fun'
9
+ const DEFAULT_LANGUAGE = 'zh'
10
+ const POLL_INTERVAL_MS = 1500
11
+ const RESULT_FORMATS = new Set(['text', 'srt', 'vtt', 'json', 'verbose_json'])
12
+ const TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled'])
13
+
14
+ function safeJsonParse(value) {
15
+ try {
16
+ return JSON.parse(value)
17
+ } catch {
18
+ return value
19
+ }
20
+ }
21
+
22
+ function fail(message, exitCode = 1) {
23
+ process.stderr.write(`${message}\n`)
24
+ process.exit(exitCode)
25
+ }
26
+
27
+ function printUsage() {
28
+ process.stderr.write(`用法:
29
+ deyo [选项] <url>
30
+ deyo auth <login|logout|status> [选项]
31
+
32
+ 转写选项:
33
+ --api-key <key> 可选,Deyo API key
34
+ -O, --output <path> 可选,输出文件路径
35
+ --source <name> 可选,显式指定来源
36
+ --language <value> 可选,转写语言,默认 ${DEFAULT_LANGUAGE}
37
+ --format <value> 可选,text | srt | vtt | json | verbose_json
38
+ --base-url <url> 可选,默认 ${DEFAULT_BASE_URL}
39
+ -h, --help 显示帮助
40
+
41
+ auth 子命令:
42
+ deyo auth login --api-key <key> [--base-url <url>]
43
+ deyo auth logout
44
+ deyo auth status
45
+
46
+ API key 读取优先级:
47
+ 1. --api-key
48
+ 2. 环境变量 DEYO_API_KEY
49
+ 3. 本地配置文件(先执行 deyo auth login)
50
+ `)
51
+ }
52
+
53
+ function printAuthUsage() {
54
+ process.stderr.write(`用法:
55
+ deyo auth login --api-key <key> [--base-url <url>]
56
+ deyo auth logout
57
+ deyo auth status
58
+
59
+ 说明:
60
+ login 保存本地默认 API key,后续无需每次传 --api-key
61
+ logout 删除本地保存的配置
62
+ status 查看当前生效来源与本地配置状态
63
+ `)
64
+ }
65
+
66
+ function parseTranscriptionArgs(argv) {
67
+ const args = {
68
+ apiKey: '',
69
+ output: '',
70
+ source: '',
71
+ language: '',
72
+ format: '',
73
+ baseUrl: '',
74
+ url: '',
75
+ }
76
+
77
+ for (let index = 0; index < argv.length; index += 1) {
78
+ const current = argv[index]
79
+
80
+ if (current === '-h' || current === '--help') {
81
+ printUsage()
82
+ process.exit(0)
83
+ }
84
+
85
+ if (current === '-O' || current === '--output') {
86
+ args.output = argv[index + 1] || ''
87
+ index += 1
88
+ continue
89
+ }
90
+
91
+ if (current === '--api-key') {
92
+ args.apiKey = argv[index + 1] || ''
93
+ index += 1
94
+ continue
95
+ }
96
+
97
+ if (current === '--source') {
98
+ args.source = argv[index + 1] || ''
99
+ index += 1
100
+ continue
101
+ }
102
+
103
+ if (current === '--language') {
104
+ args.language = argv[index + 1] || ''
105
+ index += 1
106
+ continue
107
+ }
108
+
109
+ if (current === '--format') {
110
+ args.format = argv[index + 1] || ''
111
+ index += 1
112
+ continue
113
+ }
114
+
115
+ if (current === '--base-url') {
116
+ args.baseUrl = argv[index + 1] || ''
117
+ index += 1
118
+ continue
119
+ }
120
+
121
+ if (current.startsWith('-')) {
122
+ fail(`未知参数:${current}`)
123
+ }
124
+
125
+ if (args.url) {
126
+ fail('只能提供一个内容链接')
127
+ }
128
+
129
+ args.url = current
130
+ }
131
+
132
+ return args
133
+ }
134
+
135
+ function parseAuthArgs(argv) {
136
+ const action = argv[1] || ''
137
+ const args = {
138
+ action,
139
+ apiKey: '',
140
+ baseUrl: '',
141
+ }
142
+
143
+ if (!action) {
144
+ printAuthUsage()
145
+ fail('缺少 auth 子命令')
146
+ }
147
+
148
+ for (let index = 2; index < argv.length; index += 1) {
149
+ const current = argv[index]
150
+
151
+ if (current === '-h' || current === '--help') {
152
+ printAuthUsage()
153
+ process.exit(0)
154
+ }
155
+
156
+ if (current === '--api-key') {
157
+ args.apiKey = argv[index + 1] || ''
158
+ index += 1
159
+ continue
160
+ }
161
+
162
+ if (current === '--base-url') {
163
+ args.baseUrl = argv[index + 1] || ''
164
+ index += 1
165
+ continue
166
+ }
167
+
168
+ fail(`未知参数:${current}`)
169
+ }
170
+
171
+ return args
172
+ }
173
+
174
+ function normalizeBaseUrl(baseUrl) {
175
+ try {
176
+ const url = new URL(baseUrl)
177
+ return url.toString().replace(/\/$/, '')
178
+ } catch {
179
+ fail(`--base-url 不是合法 URL:${baseUrl}`)
180
+ }
181
+ }
182
+
183
+ function inferFormat(outputPath, explicitFormat) {
184
+ if (explicitFormat) {
185
+ if (!RESULT_FORMATS.has(explicitFormat)) {
186
+ fail(`--format 不支持:${explicitFormat}`)
187
+ }
188
+
189
+ return explicitFormat
190
+ }
191
+
192
+ if (!outputPath) {
193
+ return 'text'
194
+ }
195
+
196
+ const extension = extname(outputPath).toLowerCase()
197
+
198
+ if (extension === '.srt') return 'srt'
199
+ if (extension === '.vtt') return 'vtt'
200
+ if (extension === '.json') return 'json'
201
+ return 'text'
202
+ }
203
+
204
+ function getConfigFileCandidates() {
205
+ const home = homedir()
206
+ const xdgConfigHome = String(process.env.XDG_CONFIG_HOME || '').trim()
207
+ const candidates = []
208
+
209
+ if (xdgConfigHome) {
210
+ candidates.push(resolve(xdgConfigHome, 'deyo', 'config.json'))
211
+ } else if (home) {
212
+ candidates.push(resolve(home, '.config', 'deyo', 'config.json'))
213
+ candidates.push(resolve(home, '.deyo', 'config.json'))
214
+ }
215
+
216
+ if (!candidates.length) {
217
+ candidates.push(resolve(process.cwd(), '.deyo', 'config.json'))
218
+ }
219
+
220
+ return candidates
221
+ }
222
+
223
+ function normalizeStoredConfig(rawConfig, path) {
224
+ if (!rawConfig || typeof rawConfig !== 'object') {
225
+ throw new Error(`本地配置文件格式无效:${path}`)
226
+ }
227
+
228
+ const apiKey = typeof rawConfig.apiKey === 'string' ? rawConfig.apiKey.trim() : ''
229
+ const baseUrl = typeof rawConfig.baseUrl === 'string' ? rawConfig.baseUrl.trim() : ''
230
+
231
+ return {
232
+ apiKey,
233
+ baseUrl: baseUrl ? normalizeBaseUrl(baseUrl) : '',
234
+ }
235
+ }
236
+
237
+ async function readStoredConfig() {
238
+ const candidates = getConfigFileCandidates()
239
+
240
+ for (const path of candidates) {
241
+ try {
242
+ const content = await readFile(path, 'utf8')
243
+ return {
244
+ path,
245
+ config: normalizeStoredConfig(JSON.parse(content), path),
246
+ }
247
+ } catch (error) {
248
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
249
+ continue
250
+ }
251
+
252
+ if (error instanceof SyntaxError) {
253
+ throw new Error(`本地配置文件不是合法 JSON:${path}`)
254
+ }
255
+
256
+ throw error
257
+ }
258
+ }
259
+
260
+ return null
261
+ }
262
+
263
+ async function writeStoredConfig(nextConfig) {
264
+ const candidates = getConfigFileCandidates()
265
+ const normalizedConfig = {
266
+ apiKey: String(nextConfig.apiKey || '').trim(),
267
+ baseUrl: nextConfig.baseUrl ? normalizeBaseUrl(nextConfig.baseUrl) : '',
268
+ }
269
+
270
+ let lastError = null
271
+
272
+ for (const path of candidates) {
273
+ try {
274
+ await mkdir(dirname(path), { recursive: true })
275
+ await writeFile(path, `${JSON.stringify(normalizedConfig, null, 2)}\n`, 'utf8')
276
+ await chmod(path, 0o600).catch(() => {})
277
+ return path
278
+ } catch (error) {
279
+ lastError = error
280
+ }
281
+ }
282
+
283
+ throw lastError || new Error('写入本地配置失败')
284
+ }
285
+
286
+ async function removeStoredConfig() {
287
+ const candidates = getConfigFileCandidates()
288
+ const removedPaths = []
289
+
290
+ for (const path of candidates) {
291
+ try {
292
+ await rm(path, { force: true })
293
+ removedPaths.push(path)
294
+ } catch (error) {
295
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
296
+ continue
297
+ }
298
+
299
+ throw error
300
+ }
301
+ }
302
+
303
+ return removedPaths
304
+ }
305
+
306
+ function maskApiKey(apiKey) {
307
+ const normalized = String(apiKey || '').trim()
308
+
309
+ if (!normalized) {
310
+ return ''
311
+ }
312
+
313
+ if (normalized.length <= 10) {
314
+ return `${normalized.slice(0, 2)}***${normalized.slice(-2)}`
315
+ }
316
+
317
+ return `${normalized.slice(0, 6)}...${normalized.slice(-4)}`
318
+ }
319
+
320
+ function resolveBaseUrl(explicitBaseUrl, storedConfig) {
321
+ if (explicitBaseUrl) {
322
+ return {
323
+ baseUrl: normalizeBaseUrl(explicitBaseUrl),
324
+ source: '--base-url',
325
+ }
326
+ }
327
+
328
+ const envBaseUrl = String(process.env.DEYO_BASE_URL || '').trim()
329
+
330
+ if (envBaseUrl) {
331
+ return {
332
+ baseUrl: normalizeBaseUrl(envBaseUrl),
333
+ source: 'DEYO_BASE_URL',
334
+ }
335
+ }
336
+
337
+ if (storedConfig?.config.baseUrl) {
338
+ return {
339
+ baseUrl: storedConfig.config.baseUrl,
340
+ source: '本地配置',
341
+ }
342
+ }
343
+
344
+ return {
345
+ baseUrl: DEFAULT_BASE_URL,
346
+ source: '默认值',
347
+ }
348
+ }
349
+
350
+ function resolveApiKey(explicitApiKey, storedConfig) {
351
+ const directApiKey = String(explicitApiKey || '').trim()
352
+
353
+ if (directApiKey) {
354
+ return {
355
+ apiKey: directApiKey,
356
+ source: '--api-key',
357
+ }
358
+ }
359
+
360
+ const envApiKey = String(process.env.DEYO_API_KEY || '').trim()
361
+
362
+ if (envApiKey) {
363
+ return {
364
+ apiKey: envApiKey,
365
+ source: 'DEYO_API_KEY',
366
+ }
367
+ }
368
+
369
+ if (storedConfig?.config.apiKey) {
370
+ return {
371
+ apiKey: storedConfig.config.apiKey,
372
+ source: '本地配置',
373
+ }
374
+ }
375
+
376
+ return null
377
+ }
378
+
379
+ async function readErrorMessage(response) {
380
+ const contentType = response.headers.get('content-type') || ''
381
+
382
+ if (contentType.includes('application/json')) {
383
+ const data = await response.json().catch(() => null)
384
+ const message = data?.message || data?.statusMessage || data?.error?.message
385
+
386
+ if (message) {
387
+ return String(message)
388
+ }
389
+ }
390
+
391
+ const text = await response.text().catch(() => '')
392
+ return text || `${response.status} ${response.statusText}`
393
+ }
394
+
395
+ async function requestJson(baseUrl, apiKey, path, options = {}) {
396
+ const response = await fetch(`${baseUrl}${path}`, {
397
+ method: options.method || 'GET',
398
+ headers: {
399
+ Authorization: `Bearer ${apiKey}`,
400
+ 'Content-Type': 'application/json',
401
+ ...(options.headers || {}),
402
+ },
403
+ body: typeof options.body === 'undefined' ? undefined : JSON.stringify(options.body),
404
+ })
405
+
406
+ if (!response.ok) {
407
+ throw new Error(await readErrorMessage(response))
408
+ }
409
+
410
+ return response.json()
411
+ }
412
+
413
+ async function requestResult(baseUrl, apiKey, taskId, format) {
414
+ const response = await fetch(`${baseUrl}/api/cli/transcriptions/${encodeURIComponent(taskId)}/result?format=${encodeURIComponent(format)}`, {
415
+ method: 'GET',
416
+ headers: {
417
+ Authorization: `Bearer ${apiKey}`,
418
+ },
419
+ })
420
+
421
+ if (!response.ok) {
422
+ throw new Error(await readErrorMessage(response))
423
+ }
424
+
425
+ if (format === 'json' || format === 'verbose_json') {
426
+ return JSON.stringify(await response.json(), null, 2)
427
+ }
428
+
429
+ return response.text()
430
+ }
431
+
432
+ function sleep(ms) {
433
+ return new Promise(resolvePromise => setTimeout(resolvePromise, ms))
434
+ }
435
+
436
+ function formatStatus(status) {
437
+ if (status === 'queued') return '排队中'
438
+ if (status === 'downloading') return '下载中'
439
+ if (status === 'preprocessing') return '预处理中'
440
+ if (status === 'chunking') return '切分中'
441
+ if (status === 'transcribing') return '转写中'
442
+ if (status === 'merging') return '合并中'
443
+ if (status === 'completed') return '已完成'
444
+ if (status === 'failed') return '失败'
445
+ if (status === 'cancelled') return '已取消'
446
+ return status || '未知状态'
447
+ }
448
+
449
+ function createOutputState() {
450
+ return {
451
+ inlineActive: false,
452
+ }
453
+ }
454
+
455
+ function clearInline(outputState) {
456
+ if (!outputState.inlineActive) {
457
+ return
458
+ }
459
+
460
+ process.stderr.write('\r\x1b[2K')
461
+ outputState.inlineActive = false
462
+ }
463
+
464
+ function writeStatusLine(outputState, message) {
465
+ clearInline(outputState)
466
+ process.stderr.write(`${message}\n`)
467
+ }
468
+
469
+ function writeInline(outputState, message) {
470
+ process.stderr.write(`\r\x1b[2K${message}`)
471
+ outputState.inlineActive = true
472
+ }
473
+
474
+ function finishInline(outputState) {
475
+ if (!outputState.inlineActive) {
476
+ return
477
+ }
478
+
479
+ process.stderr.write('\n')
480
+ outputState.inlineActive = false
481
+ }
482
+
483
+ function normalizeProgressValue(progress, finishedChunks, totalChunks) {
484
+ if (Number.isFinite(progress) && progress >= 0) {
485
+ return Math.max(0, Math.min(1, progress))
486
+ }
487
+
488
+ if (
489
+ Number.isFinite(finishedChunks)
490
+ && Number.isFinite(totalChunks)
491
+ && totalChunks > 0
492
+ ) {
493
+ return Math.max(0, Math.min(1, finishedChunks / totalChunks))
494
+ }
495
+
496
+ return null
497
+ }
498
+
499
+ function buildTranscribingProgressMessage(input = {}) {
500
+ const finishedChunks = Number(input.finishedChunks)
501
+ const totalChunks = Number(input.totalChunks)
502
+ const normalizedProgress = normalizeProgressValue(
503
+ Number(input.progress),
504
+ finishedChunks,
505
+ totalChunks,
506
+ )
507
+
508
+ if (normalizedProgress === null) {
509
+ return '转写中'
510
+ }
511
+
512
+ const percent = Math.round(normalizedProgress * 100)
513
+ const chunksPart = Number.isFinite(finishedChunks) && Number.isFinite(totalChunks) && totalChunks > 0
514
+ ? ` (${Math.max(0, finishedChunks)}/${totalChunks})`
515
+ : ''
516
+
517
+ return `转写中 ${percent}%${chunksPart}`
518
+ }
519
+
520
+ function parseSseBlock(block) {
521
+ const lines = block.split('\n')
522
+ let event = 'message'
523
+ const dataLines = []
524
+
525
+ for (const line of lines) {
526
+ if (!line || line.startsWith(':')) {
527
+ continue
528
+ }
529
+
530
+ if (line.startsWith('event:')) {
531
+ event = line.slice(6).trim()
532
+ continue
533
+ }
534
+
535
+ if (line.startsWith('data:')) {
536
+ dataLines.push(line.slice(5).trimStart())
537
+ }
538
+ }
539
+
540
+ return {
541
+ event,
542
+ data: safeJsonParse(dataLines.join('\n')),
543
+ }
544
+ }
545
+
546
+ async function consumeTaskEvents(baseUrl, apiKey, taskId, initialTask, outputState) {
547
+ const response = await fetch(`${baseUrl}/api/cli/transcriptions/${encodeURIComponent(taskId)}/events`, {
548
+ method: 'GET',
549
+ headers: {
550
+ Authorization: `Bearer ${apiKey}`,
551
+ },
552
+ })
553
+
554
+ if (!response.ok || !response.body) {
555
+ throw new Error(await readErrorMessage(response))
556
+ }
557
+
558
+ const reader = response.body.getReader()
559
+ const decoder = new TextDecoder()
560
+ let buffer = ''
561
+ let task = {
562
+ ...initialTask,
563
+ }
564
+ let currentStatus = task.status
565
+
566
+ function applyStatus(nextStatus) {
567
+ if (!nextStatus || nextStatus === currentStatus) {
568
+ return
569
+ }
570
+
571
+ currentStatus = nextStatus
572
+ task = {
573
+ ...task,
574
+ status: nextStatus,
575
+ }
576
+ writeStatusLine(outputState, `状态更新:${formatStatus(nextStatus)}`)
577
+ }
578
+
579
+ function applyTerminal(nextStatus, errorMessage) {
580
+ currentStatus = nextStatus
581
+ task = {
582
+ ...task,
583
+ status: nextStatus,
584
+ error: errorMessage || task.error || null,
585
+ }
586
+ finishInline(outputState)
587
+ }
588
+
589
+ function renderProgress(data) {
590
+ currentStatus = 'transcribing'
591
+ task = {
592
+ ...task,
593
+ status: 'transcribing',
594
+ }
595
+ writeInline(outputState, buildTranscribingProgressMessage(data))
596
+ }
597
+
598
+ while (true) {
599
+ const { value, done } = await reader.read()
600
+
601
+ if (done) {
602
+ break
603
+ }
604
+
605
+ buffer += decoder.decode(value, { stream: true })
606
+ buffer = buffer.replace(/\r\n/g, '\n')
607
+
608
+ let separatorIndex = buffer.indexOf('\n\n')
609
+
610
+ while (separatorIndex >= 0) {
611
+ const block = buffer.slice(0, separatorIndex).trim()
612
+ buffer = buffer.slice(separatorIndex + 2)
613
+ separatorIndex = buffer.indexOf('\n\n')
614
+
615
+ if (!block) {
616
+ continue
617
+ }
618
+
619
+ const { event, data } = parseSseBlock(block)
620
+
621
+ if (event === 'job.snapshot') {
622
+ if (data?.status === 'transcribing') {
623
+ renderProgress(data)
624
+ } else {
625
+ applyStatus(data?.status)
626
+ }
627
+ continue
628
+ }
629
+
630
+ if (event === 'job.status_changed') {
631
+ applyStatus(data?.status)
632
+ continue
633
+ }
634
+
635
+ if (event === 'job.progress') {
636
+ renderProgress(data)
637
+ continue
638
+ }
639
+
640
+ if (event === 'job.completed') {
641
+ applyTerminal('completed', null)
642
+ return task
643
+ }
644
+
645
+ if (event === 'job.failed') {
646
+ applyTerminal('failed', data?.error || data?.message || '转写失败')
647
+ return task
648
+ }
649
+
650
+ if (event === 'job.cancelled') {
651
+ applyTerminal('cancelled', '转写任务已取消')
652
+ return task
653
+ }
654
+ }
655
+ }
656
+
657
+ finishInline(outputState)
658
+ return task
659
+ }
660
+
661
+ async function writeOutput(outputPath, content) {
662
+ const resolvedPath = resolve(process.cwd(), outputPath)
663
+ await mkdir(dirname(resolvedPath), { recursive: true })
664
+ await writeFile(resolvedPath, content, 'utf8')
665
+ return resolvedPath
666
+ }
667
+
668
+ async function handleAuthLogin(args) {
669
+ if (!args.apiKey) {
670
+ printAuthUsage()
671
+ fail('缺少 --api-key')
672
+ }
673
+
674
+ const existingConfig = await readStoredConfig().catch(() => null)
675
+ const outputPath = await writeStoredConfig({
676
+ apiKey: args.apiKey,
677
+ baseUrl: args.baseUrl || existingConfig?.config.baseUrl || '',
678
+ })
679
+
680
+ process.stdout.write(`已保存本地配置:${outputPath}\n`)
681
+ process.stdout.write(`API key:${maskApiKey(args.apiKey)}\n`)
682
+
683
+ if (args.baseUrl || existingConfig?.config.baseUrl) {
684
+ const resolvedBaseUrl = normalizeBaseUrl(args.baseUrl || existingConfig?.config.baseUrl || DEFAULT_BASE_URL)
685
+ process.stdout.write(`默认服务地址:${resolvedBaseUrl}\n`)
686
+ }
687
+ }
688
+
689
+ async function handleAuthLogout() {
690
+ const removedPaths = await removeStoredConfig()
691
+
692
+ if (!removedPaths.length) {
693
+ process.stdout.write('本地没有可删除的 Deyo 配置。\n')
694
+ return
695
+ }
696
+
697
+ process.stdout.write(`已删除本地配置:${removedPaths.join('、')}\n`)
698
+ }
699
+
700
+ async function handleAuthStatus() {
701
+ const storedConfig = await readStoredConfig()
702
+ const activeApiKey = resolveApiKey('', storedConfig)
703
+ const activeBaseUrl = resolveBaseUrl('', storedConfig)
704
+
705
+ process.stdout.write(`当前 API key 来源:${activeApiKey?.source || '未配置'}\n`)
706
+
707
+ if (storedConfig?.config.apiKey) {
708
+ process.stdout.write(`本地配置文件:${storedConfig.path}\n`)
709
+ process.stdout.write(`本地 API key:${maskApiKey(storedConfig.config.apiKey)}\n`)
710
+ } else {
711
+ process.stdout.write('本地配置文件:未找到\n')
712
+ }
713
+
714
+ process.stdout.write(`当前服务地址:${activeBaseUrl.baseUrl}\n`)
715
+ process.stdout.write(`服务地址来源:${activeBaseUrl.source}\n`)
716
+ }
717
+
718
+ async function handleAuthCommand(argv) {
719
+ const args = parseAuthArgs(argv)
720
+
721
+ if (args.action === 'login') {
722
+ await handleAuthLogin(args)
723
+ return
724
+ }
725
+
726
+ if (args.action === 'logout') {
727
+ await handleAuthLogout()
728
+ return
729
+ }
730
+
731
+ if (args.action === 'status') {
732
+ await handleAuthStatus()
733
+ return
734
+ }
735
+
736
+ printAuthUsage()
737
+ fail(`不支持的 auth 子命令:${args.action}`)
738
+ }
739
+
740
+ async function handleTranscription(argv) {
741
+ const args = parseTranscriptionArgs(argv)
742
+ const storedConfig = await readStoredConfig()
743
+ const resolvedApiKey = resolveApiKey(args.apiKey, storedConfig)
744
+
745
+ if (!resolvedApiKey) {
746
+ printUsage()
747
+ fail('缺少 API key。请传 --api-key、设置 DEYO_API_KEY,或先执行 deyo auth login')
748
+ }
749
+
750
+ if (!args.url) {
751
+ printUsage()
752
+ fail('缺少内容链接')
753
+ }
754
+
755
+ const { baseUrl } = resolveBaseUrl(args.baseUrl, storedConfig)
756
+ const format = inferFormat(args.output, args.format)
757
+ const createPayload = {
758
+ url: args.url,
759
+ language: args.language || DEFAULT_LANGUAGE,
760
+ }
761
+
762
+ if (args.source) {
763
+ createPayload.source = args.source
764
+ }
765
+
766
+ const created = await requestJson(baseUrl, resolvedApiKey.apiKey, '/api/cli/transcriptions', {
767
+ method: 'POST',
768
+ body: createPayload,
769
+ })
770
+ const outputState = createOutputState()
771
+
772
+ process.stderr.write(`任务已创建:${created.taskId}\n`)
773
+ process.stderr.write(`标题:${created.title}\n`)
774
+ process.stderr.write(`来源:${created.sourceType}\n`)
775
+ process.stderr.write(`状态:${formatStatus(created.status)}\n`)
776
+ process.stderr.write(`本次消耗分钟:${created.consumedMinutes}\n`)
777
+ process.stderr.write(`剩余分钟:${created.remainingMinutes}\n`)
778
+
779
+ let task = created
780
+ let lastStatus = created.status
781
+
782
+ if (task.mode === 'transcription' && !TERMINAL_STATUSES.has(task.status)) {
783
+ try {
784
+ task = await consumeTaskEvents(baseUrl, resolvedApiKey.apiKey, task.taskId, task, outputState)
785
+ lastStatus = task.status
786
+ } catch (error) {
787
+ finishInline(outputState)
788
+ process.stderr.write(`事件流中断,回退到轮询状态:${error instanceof Error ? error.message : String(error)}\n`)
789
+ }
790
+ }
791
+
792
+ while (!TERMINAL_STATUSES.has(task.status)) {
793
+ await sleep(POLL_INTERVAL_MS)
794
+ task = await requestJson(baseUrl, resolvedApiKey.apiKey, `/api/cli/transcriptions/${encodeURIComponent(task.taskId)}`)
795
+
796
+ if (task.status !== lastStatus) {
797
+ finishInline(outputState)
798
+ process.stderr.write(`状态更新:${formatStatus(task.status)}\n`)
799
+ lastStatus = task.status
800
+ }
801
+ }
802
+
803
+ finishInline(outputState)
804
+
805
+ if (task.status !== 'completed') {
806
+ throw new Error(task.error || `任务未成功完成:${formatStatus(task.status)}`)
807
+ }
808
+
809
+ const result = await requestResult(baseUrl, resolvedApiKey.apiKey, task.taskId, format)
810
+
811
+ if (args.output) {
812
+ const outputPath = await writeOutput(args.output, result)
813
+ process.stderr.write(`结果已写入:${outputPath}\n`)
814
+ return
815
+ }
816
+
817
+ process.stdout.write(result)
818
+
819
+ if (!result.endsWith('\n')) {
820
+ process.stdout.write('\n')
821
+ }
822
+ }
823
+
824
+ async function main() {
825
+ const argv = process.argv.slice(2)
826
+ const primaryCommand = argv[0]
827
+
828
+ if (!primaryCommand || primaryCommand === '-h' || primaryCommand === '--help') {
829
+ printUsage()
830
+ return
831
+ }
832
+
833
+ if (primaryCommand === 'auth') {
834
+ await handleAuthCommand(argv)
835
+ return
836
+ }
837
+
838
+ await handleTranscription(argv)
839
+ }
840
+
841
+ main().catch((error) => {
842
+ const message = error instanceof Error ? error.message : String(error)
843
+ fail(`执行失败:${message}`)
844
+ })
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@casatwy/deyo",
3
+ "version": "0.1.0",
4
+ "description": "CLI for submitting Deyo transcription jobs from the terminal",
5
+ "homepage": "https://deyo.miaobi.fun/docs",
6
+ "license": "UNLICENSED",
7
+ "type": "module",
8
+ "files": [
9
+ "deyo.mjs",
10
+ "README.md"
11
+ ],
12
+ "keywords": [
13
+ "deyo",
14
+ "transcription",
15
+ "cli",
16
+ "subtitle",
17
+ "whisper"
18
+ ],
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "bin": {
26
+ "deyo": "./deyo.mjs"
27
+ }
28
+ }