museav-cli 2.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.
Files changed (70) hide show
  1. package/.github/workflows/ci.yml +19 -0
  2. package/.github/workflows/publish.yml +79 -0
  3. package/AGENTS.md +90 -0
  4. package/CHANGELOG.md +49 -0
  5. package/LICENSE +21 -0
  6. package/README.md +490 -0
  7. package/SECURITY.md +11 -0
  8. package/dist/client.d.ts +387 -0
  9. package/dist/client.js +372 -0
  10. package/dist/commands/assets.d.ts +14 -0
  11. package/dist/commands/assets.js +30 -0
  12. package/dist/commands/balance.d.ts +3 -0
  13. package/dist/commands/balance.js +11 -0
  14. package/dist/commands/bind-feishu.d.ts +3 -0
  15. package/dist/commands/bind-feishu.js +63 -0
  16. package/dist/commands/gen.d.ts +16 -0
  17. package/dist/commands/gen.js +88 -0
  18. package/dist/commands/image-to-template.d.ts +24 -0
  19. package/dist/commands/image-to-template.js +111 -0
  20. package/dist/commands/jobs.d.ts +11 -0
  21. package/dist/commands/jobs.js +27 -0
  22. package/dist/commands/login.d.ts +3 -0
  23. package/dist/commands/login.js +67 -0
  24. package/dist/commands/models.d.ts +3 -0
  25. package/dist/commands/models.js +9 -0
  26. package/dist/commands/products.d.ts +9 -0
  27. package/dist/commands/products.js +16 -0
  28. package/dist/commands/reverse.d.ts +3 -0
  29. package/dist/commands/reverse.js +16 -0
  30. package/dist/commands/skills.d.ts +5 -0
  31. package/dist/commands/skills.js +22 -0
  32. package/dist/commands/templates.d.ts +28 -0
  33. package/dist/commands/templates.js +79 -0
  34. package/dist/commands/upload.d.ts +9 -0
  35. package/dist/commands/upload.js +8 -0
  36. package/dist/commands/video-templates.d.ts +21 -0
  37. package/dist/commands/video-templates.js +71 -0
  38. package/dist/commands/welcome.d.ts +6 -0
  39. package/dist/commands/welcome.js +93 -0
  40. package/dist/commands/whoami.d.ts +3 -0
  41. package/dist/commands/whoami.js +14 -0
  42. package/dist/config.d.ts +31 -0
  43. package/dist/config.js +93 -0
  44. package/dist/index.d.ts +2 -0
  45. package/dist/index.js +292 -0
  46. package/dist/tenant-client.d.ts +31 -0
  47. package/dist/tenant-client.js +84 -0
  48. package/package.json +55 -0
  49. package/src/client.ts +636 -0
  50. package/src/commands/assets.ts +47 -0
  51. package/src/commands/balance.ts +12 -0
  52. package/src/commands/bind-feishu.ts +77 -0
  53. package/src/commands/gen.ts +111 -0
  54. package/src/commands/image-to-template.ts +150 -0
  55. package/src/commands/jobs.ts +37 -0
  56. package/src/commands/login.ts +80 -0
  57. package/src/commands/models.ts +12 -0
  58. package/src/commands/products.ts +38 -0
  59. package/src/commands/reverse.ts +21 -0
  60. package/src/commands/skills.ts +29 -0
  61. package/src/commands/templates.ts +98 -0
  62. package/src/commands/upload.ts +18 -0
  63. package/src/commands/video-templates.ts +89 -0
  64. package/src/commands/welcome.ts +108 -0
  65. package/src/commands/whoami.ts +19 -0
  66. package/src/config.ts +114 -0
  67. package/src/index.ts +318 -0
  68. package/src/tenant-client.ts +90 -0
  69. package/src/types/update-notifier.d.ts +31 -0
  70. package/tsconfig.json +19 -0
@@ -0,0 +1,12 @@
1
+ /** museav balance —— 查租户余额(¥,实时计算:充值总额 − 历史消耗) */
2
+ import type { StudioClient } from '../client.js'
3
+
4
+ export async function balance(client: StudioClient): Promise<void> {
5
+ const r = await client.balance()
6
+ // 单位 ¥ 人民币(后台 2026-08-09 起只返回租户自己的余额,不再下发上游供应商聚合数据)
7
+ process.stderr.write(`余额: ¥${r.balance_usd?.toFixed(2) ?? '?'}`)
8
+ if (r.markup_pct) process.stderr.write(` 加价率: ${(r.markup_pct * 100).toFixed(0)}%`)
9
+ if (r.checked_at) process.stderr.write(` 校验时间: ${r.checked_at.slice(0, 19).replace('T', ' ')}`)
10
+ process.stderr.write('\n')
11
+ console.log(JSON.stringify(r))
12
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * museav bind-feishu —— CLI 绑定飞书(设备码模式,2026-08-15)
3
+ *
4
+ * 流程:CLI 调 /feishu-bind/cli-start(需个人 login token)→ 终端显示验证码 + 授权链接
5
+ * → 用户在浏览器打开链接(已登录后台)授权飞书 → CLI 轮询 /feishu-bind/cli-poll
6
+ * → 绑定完成,提示已关联的平台账户数。
7
+ *
8
+ * 一个飞书 open_id 可绑定多个平台账户(owner 的 gmail/163 双 superadmin):
9
+ * 绑定后 agent 在飞书里能认出你(resolveSpeaker superadmin 优先)。
10
+ * 只支持个人 login 账户;租户 apiKey 身份无法绑定个人飞书。
11
+ */
12
+ import { loadConfig, DEFAULT_BASE_URL } from '../config.js'
13
+
14
+ interface BindStart {
15
+ device_code: string
16
+ user_code: string
17
+ authorize_url: string
18
+ expires_in: number
19
+ interval: number
20
+ }
21
+
22
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
23
+
24
+ export async function bindFeishu(opts: { baseUrl?: string }): Promise<void> {
25
+ const baseUrl = (opts.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '')
26
+ const cfg = loadConfig()
27
+ if (!cfg.token) {
28
+ throw new Error('绑定飞书需要个人账户登录:先执行 museav login(租户 apiKey 无法绑定个人飞书)')
29
+ }
30
+
31
+ process.stderr.write('正在发起飞书绑定...\n')
32
+ const startResp = await fetch(`${baseUrl}/api/feishu-bind/cli-start`, {
33
+ method: 'POST',
34
+ headers: { Authorization: `Bearer ${cfg.token}` },
35
+ })
36
+ if (!startResp.ok) {
37
+ const d = await startResp.json().catch(() => ({})) as { error?: string }
38
+ throw new Error(`发起绑定失败: HTTP ${startResp.status} ${d?.error || ''}`)
39
+ }
40
+ const start = await startResp.json() as BindStart
41
+
42
+ process.stderr.write('\n')
43
+ process.stderr.write('┌──────────────────────────────────────────────────┐\n')
44
+ process.stderr.write('│ │\n')
45
+ process.stderr.write(`│ 验证码: ${start.user_code.padEnd(34)} │\n`)
46
+ process.stderr.write('│ │\n')
47
+ process.stderr.write('│ 请在浏览器打开以下地址,授权绑定你的飞书: │\n')
48
+ process.stderr.write('│ │\n')
49
+ process.stderr.write('│ ' + start.authorize_url + '\n')
50
+ process.stderr.write('│ │\n')
51
+ process.stderr.write('└──────────────────────────────────────────────────┘\n')
52
+ process.stderr.write('\n等待授权完成...(可随时 Ctrl+C 取消)\n')
53
+
54
+ const interval = (start.interval || 3) * 1000
55
+ const maxAttempts = Math.floor(((start.expires_in || 600) * 1000) / interval)
56
+
57
+ for (let i = 0; i < maxAttempts; i++) {
58
+ await sleep(interval)
59
+ const pollResp = await fetch(`${baseUrl}/api/feishu-bind/cli-poll`, {
60
+ method: 'POST',
61
+ headers: { 'Content-Type': 'application/json' },
62
+ body: JSON.stringify({ device_code: start.device_code }),
63
+ })
64
+ if (!pollResp.ok) continue
65
+ const poll = await pollResp.json() as { status: string; linked_count?: number; error?: string }
66
+
67
+ if (poll.status === 'approved') {
68
+ const n = poll.linked_count || 1
69
+ process.stderr.write(`\n✅ 飞书绑定成功!已关联 ${n} 个平台账户。agent 在飞书里能认出你了。\n`)
70
+ return
71
+ }
72
+ if (poll.status === 'expired') {
73
+ throw new Error('绑定请求已过期,请重新执行 museav bind-feishu')
74
+ }
75
+ }
76
+ throw new Error('绑定超时,请重新执行 museav bind-feishu')
77
+ }
@@ -0,0 +1,111 @@
1
+ /** museav gen —— 出图 / 出视频(核心命令) */
2
+ import type { StudioClient } from '../client.js'
3
+
4
+ export async function gen(client: StudioClient, opts: {
5
+ prompt?: string
6
+ skill?: string
7
+ input?: string
8
+ template?: string
9
+ fields?: string
10
+ ratio?: string
11
+ model?: string
12
+ quality?: string
13
+ ref?: string
14
+ // 视频
15
+ video?: boolean
16
+ duration?: number
17
+ image?: string
18
+ }): Promise<void> {
19
+ // prompt / skill / template 三选一。commander 不好表达互斥,在这里校验,报错要说清怎么改
20
+ const picked = [opts.prompt, opts.skill, opts.template].filter(Boolean).length
21
+ if (picked === 0) {
22
+ throw new Error('需要 --prompt "完整提示词" 或 --skill <技能名>(museav skills 查)或 --template <模板id>(museav templates 查)')
23
+ }
24
+ if (picked > 1) {
25
+ throw new Error('--prompt / --skill / --template 只能给一个:分别对应自己写提示词、用中台技能展开、用图片模板展开')
26
+ }
27
+ if (opts.input && !opts.skill) {
28
+ throw new Error('--input 是配合 --skill 的业务描述;只写提示词请用 --prompt')
29
+ }
30
+ if (opts.fields && !opts.template) {
31
+ throw new Error('--fields 是配合 --template 的占位符取值;用技能请用 --input')
32
+ }
33
+ if (opts.video && opts.skill) {
34
+ throw new Error('--video 暂不支持配合 --skill(视频模板走 --template 或直接 --prompt)')
35
+ }
36
+ let templateFields: Record<string, string> | undefined
37
+ if (opts.fields) {
38
+ try {
39
+ templateFields = JSON.parse(opts.fields)
40
+ } catch {
41
+ throw new Error(`--fields 必须是合法 JSON 对象,如 '{"artist":"王嘉尔","city":"南京"}',收到: ${opts.fields}`)
42
+ }
43
+ }
44
+
45
+ // 可选:先上传垫图(图片出图 --ref / 视频图生视频 --image 都走这里)
46
+ let referenceImage: string | undefined
47
+ const refPath = opts.ref || opts.image
48
+ if (refPath) {
49
+ process.stderr.write(`上传垫图 ${refPath} ...\n`)
50
+ const up = await client.uploadRef(refPath)
51
+ referenceImage = up.url
52
+ process.stderr.write(`垫图就绪: ${referenceImage}\n`)
53
+ }
54
+
55
+ // ── 视频模式:走 /api/videos 独立链路 ──
56
+ if (opts.video) {
57
+ if (opts.quality) throw new Error('--quality 仅图片出图支持')
58
+ if (opts.skill) throw new Error('--video 暂不支持配合 --skill(视频模板走 --template,清单用 museav video-templates 查)')
59
+ process.stderr.write(
60
+ `提交视频: ${opts.template ? `模板 ${opts.template}` : opts.prompt?.slice(0, 40) || ''}${opts.image ? ' · 图生视频' : ''}\n`,
61
+ )
62
+ const { jobId } = await client.generateVideo({
63
+ prompt: opts.prompt,
64
+ model: opts.model,
65
+ ratio: opts.ratio,
66
+ duration: opts.duration,
67
+ image_url: referenceImage,
68
+ template_id: opts.template,
69
+ input: templateFields,
70
+ })
71
+ process.stderr.write(`视频任务已提交: ${jobId}\n生成中(视频通常 1-5 分钟)...\n`)
72
+ const result = await client.waitVideo(jobId, (status) => {
73
+ if (status === 'processing') process.stderr.write('生成中...\r')
74
+ })
75
+ if (result.status !== 'completed' || !result.cdn_url) {
76
+ throw new Error(`视频生成失败: ${result.error || '未知原因'}`)
77
+ }
78
+ process.stderr.write(`✅ 视频完成\n`)
79
+ console.log(result.cdn_url)
80
+ return
81
+ }
82
+
83
+ // ── 图片模式(原逻辑)──
84
+ process.stderr.write(
85
+ opts.skill
86
+ ? `提交出图: 技能 ${opts.skill}${opts.input ? ` · ${opts.input.slice(0, 30)}` : '(未给描述,按技能规范自由发挥)'}\n`
87
+ : opts.template
88
+ ? `提交出图: 模板 ${opts.template}${templateFields ? ` · ${JSON.stringify(templateFields).slice(0, 40)}` : ''}\n`
89
+ : `提交出图: ${opts.prompt!.slice(0, 40)}...\n`,
90
+ )
91
+ const job = await client.generateAndWait(
92
+ {
93
+ prompt: opts.prompt,
94
+ skill_slug: opts.skill,
95
+ input: opts.input,
96
+ template_id: opts.template,
97
+ template_fields: templateFields,
98
+ ratio: opts.ratio,
99
+ model: opts.model,
100
+ quality: opts.quality as 'low' | 'medium' | 'high' | undefined,
101
+ reference_image: referenceImage,
102
+ },
103
+ (status) => {
104
+ if (status === 'processing') process.stderr.write('生成中...\r')
105
+ },
106
+ )
107
+
108
+ // 成功:图片 URL 输出到 stdout(便于管道),元信息到 stderr
109
+ process.stderr.write(`✅ 完成 (${job.elapsed_ms ? (job.elapsed_ms / 1000).toFixed(1) + 's' : '?'})\n`)
110
+ console.log(job.cdn_url)
111
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * museav image-to-template —— 一张图 → 一个可复用的图片模板。
3
+ *
4
+ * 跟 reverse 的分工(中台 2026-08-16 拆成两个接口,别再混):
5
+ * reverse 只读图:SCULPT 六要素 + 出图 prompt,一次模型调用,同步返回。
6
+ * image-to-template 读图 + 文字层逆向 + 变量化 + 建模板,原图还会被焊成模板的参考图。
7
+ * 想要哪个就调哪个,没有开关可拨——调用这个命令本身就是「我要模板」的意图。
8
+ *
9
+ * 同步/异步不由 --async 一个人说了算(中台契约 §7.6):
10
+ * 建模板(默认)或显式 --async → 异步,先拿 jobId 再轮询
11
+ * --no-create 且没给 --async → 同步,直接返回草稿
12
+ */
13
+ import type { StudioClient, ImageToTemplateResult, JobStep, TemplateField } from '../client.js'
14
+ import { isAsyncJob } from '../client.js'
15
+
16
+ export interface ImageToTemplateOpts {
17
+ /** commander 的 --no-create 会把 create 置 false,默认 true */
18
+ create?: boolean
19
+ name?: string
20
+ slug?: string
21
+ category?: string
22
+ variables?: string
23
+ labels?: string
24
+ async?: boolean
25
+ }
26
+
27
+ const STEP_MARK: Record<JobStep['status'], string> = { running: '🔄', ok: '✅', fail: '❌' }
28
+
29
+ export async function imageToTemplate(
30
+ client: StudioClient,
31
+ input: string,
32
+ opts: ImageToTemplateOpts = {},
33
+ ): Promise<void> {
34
+ const isUrl = /^https?:\/\//.test(input)
35
+ const createTemplate = opts.create !== false
36
+
37
+ // --variables 收窄白名单:逗号分隔。中台只认它那套通用语义 key(title/subject/location…),
38
+ // 传了白名单外的会被忽略并在 template_warnings 里说明,全都不合法才 400。
39
+ const variables = opts.variables
40
+ ? opts.variables.split(',').map((s) => s.trim()).filter(Boolean)
41
+ : undefined
42
+ if (opts.variables && !variables?.length) {
43
+ throw new Error('--variables 给了但解析不出任何变量名,格式是逗号分隔,如 --variables title,subject,location')
44
+ }
45
+
46
+ let variableLabels: Record<string, string> | undefined
47
+ if (opts.labels) {
48
+ try {
49
+ variableLabels = JSON.parse(opts.labels)
50
+ } catch {
51
+ throw new Error(`--labels 必须是合法 JSON 对象,如 '{"subject":"艺人","location":"城市"}',收到: ${opts.labels}`)
52
+ }
53
+ }
54
+
55
+ // 模板元信息全是可选的,一个都没给就整个不传,让中台自己生成 slug / 中文名
56
+ const template = opts.name || opts.slug || opts.category
57
+ ? { zh_name: opts.name, slug: opts.slug, category: opts.category }
58
+ : undefined
59
+ if (template && !createTemplate) {
60
+ process.stderr.write('⚠ --no-create 只看草稿,--name / --slug / --category 不会生效\n')
61
+ }
62
+
63
+ process.stderr.write(
64
+ `${createTemplate ? '图生模板' : '图生模板草稿(--no-create,不建模板)'}: ${input}\n`,
65
+ )
66
+
67
+ const submitted = await client.imageToTemplate({
68
+ file: isUrl ? undefined : input,
69
+ imageUrl: isUrl ? input : undefined,
70
+ variables,
71
+ variableLabels,
72
+ createTemplate,
73
+ template,
74
+ async: opts.async,
75
+ })
76
+
77
+ let result: ImageToTemplateResult
78
+ if (isAsyncJob(submitted)) {
79
+ process.stderr.write(`任务已提交: ${submitted.jobId}\n`)
80
+ // 阶段名去重打印:中台每轮返回的是完整 steps 数组,不过滤的话每 3 秒把同样的阶段重刷一遍
81
+ const printed = new Set<string>()
82
+ result = await client.waitImageToTemplate(submitted.jobId, (steps) => {
83
+ for (const s of steps) {
84
+ const key = `${s.name}:${s.status}`
85
+ if (printed.has(key)) continue
86
+ printed.add(key)
87
+ const cost = s.ms != null ? ` (${(s.ms / 1000).toFixed(1)}s)` : ''
88
+ process.stderr.write(` ${STEP_MARK[s.status] || '·'} ${s.name}${cost}\n`)
89
+ }
90
+ })
91
+ } else {
92
+ result = submitted
93
+ }
94
+
95
+ printResult(result, createTemplate)
96
+ }
97
+
98
+ function printResult(r: ImageToTemplateResult, createTemplate: boolean): void {
99
+ process.stderr.write(`\n✅ 读图完成\n`)
100
+ process.stderr.write(`风格: ${r.zh_name || '-'} 分类: ${r.image_category || '-'} 比例: ${r.aspect_ratio}\n`)
101
+ if (r.style_tags?.length) process.stderr.write(`标签: ${r.style_tags.join(', ')}\n`)
102
+
103
+ const layers = r.text_layers || []
104
+ if (layers.length) {
105
+ process.stderr.write(`\n文字层(${layers.length} 处):\n`)
106
+ for (const l of layers) {
107
+ const varTag = l.is_variable ? `{${l.variable}}` : '固定'
108
+ process.stderr.write(
109
+ ` ${(l.role || '-').padEnd(12)} ${varTag.padEnd(14)} ${(l.position || '').padEnd(12)} ${String(l.content || '').slice(0, 30)}\n`,
110
+ )
111
+ }
112
+ }
113
+
114
+ if (r.prompt_template) {
115
+ process.stderr.write(`\n提示词模具:\n ${r.prompt_template}\n`)
116
+ process.stderr.write(`变量: ${fieldSummary(r.fields)}\n`)
117
+ } else {
118
+ // 中台的降级口径:文字层/变量化/建模板任一步失败都只降级,读图结果照常返回。
119
+ // 所以这里不抛错——上面那段读图结果是真的,用户可以照常拿去出图。
120
+ process.stderr.write(`\n⚠ 没能做出提示词模具${r.template_error ? `:${r.template_error}` : ''}\n`)
121
+ process.stderr.write(` 读图结果仍然有效,可直接用下面的 prompt 出图\n`)
122
+ }
123
+
124
+ for (const w of r.template_warnings || []) process.stderr.write(`⚠ ${w}\n`)
125
+
126
+ if (r.template) {
127
+ process.stderr.write(`\n✅ 模板已建:${r.template.id}\n`)
128
+ process.stderr.write(` slug: ${r.template.slug} 名称: ${r.template.zh_name}\n`)
129
+ process.stderr.write(` 归属:${r.template.tenant_id ? '当前租户(其他租户看不到)' : '平台共享(所有租户可见)'}\n`)
130
+ if (r.template.reference_image) process.stderr.write(` 参考图(已焊进模板): ${r.template.reference_image}\n`)
131
+ const example = r.fields?.length ? ` --fields '{"${r.fields[0].key}":"..."}'` : ''
132
+ process.stderr.write(`\n出图: museav gen --template ${r.template.id}${example}\n`)
133
+ // stdout 只出模板 id,跟 templates create 一致,便于脚本链式使用
134
+ console.log(r.template.id)
135
+ return
136
+ }
137
+
138
+ if (createTemplate && r.template_error) {
139
+ process.stderr.write(`\n❌ 模板没建成:${r.template_error}\n`)
140
+ } else if (!createTemplate) {
141
+ process.stderr.write(`\n这是草稿(--no-create)。满意的话去掉 --no-create 重跑一次即可建成模板。\n`)
142
+ }
143
+ // 没建成模板时 stdout 给完整草稿 JSON——脚本还能拿它自己调 POST /api/templates
144
+ console.log(JSON.stringify(r))
145
+ }
146
+
147
+ function fieldSummary(fields?: TemplateField[]): string {
148
+ if (!fields?.length) return '(无)'
149
+ return fields.map((f) => (f.label && f.label !== f.key ? `${f.key}(${f.label})` : f.key)).join(', ')
150
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * museav jobs —— 查自己名下的出图工作流
3
+ *
4
+ * 范围自动跟着鉴权凭证走:个人 login 只看自己的记录;租户 apiKey 看自己业务下的全部记录。
5
+ * 不需要额外传租户/用户 id——你拿的是谁的凭证,就是谁的数据。
6
+ */
7
+ import type { StudioClient, Job } from '../client.js'
8
+
9
+ const STATUS_MARK: Record<Job['status'], string> = {
10
+ pending: '⏳',
11
+ processing: '🔄',
12
+ done: '✅',
13
+ failed: '❌',
14
+ }
15
+
16
+ export async function jobs(client: StudioClient, opts: { limit?: string; status?: Job['status'] }): Promise<void> {
17
+ const limit = opts.limit ? Number(opts.limit) : 20
18
+ const list = await client.listJobs({ limit, status: opts.status })
19
+
20
+ if (!list.length) {
21
+ process.stderr.write('没有找到工作流记录\n')
22
+ console.log('[]')
23
+ return
24
+ }
25
+
26
+ process.stderr.write(`最近 ${list.length} 条工作流:\n\n`)
27
+ for (const j of list) {
28
+ const mark = STATUS_MARK[j.status] || '?'
29
+ const time = j.created_at ? new Date(j.created_at).toLocaleString() : '?'
30
+ process.stderr.write(`${mark} ${j.id} ${j.status.padEnd(10)} ${(j.model || '?').padEnd(16)} ${time}\n`)
31
+ if (j.cdn_url) process.stderr.write(` → ${j.cdn_url}\n`)
32
+ if (j.status === 'failed' && j.error) process.stderr.write(` error: ${j.error}\n`)
33
+ }
34
+
35
+ // stdout 输出完整 JSON,供脚本消费
36
+ console.log(JSON.stringify(list))
37
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * museav login —— 设备授权登录(RFC 8628)
3
+ *
4
+ * 流程:CLI 调 /cli-auth/start 拿验证码 → 用户浏览器打开审批页授权
5
+ * → CLI 轮询 /cli-auth/poll 拿 JWT → 存到 ~/.museav.json
6
+ *
7
+ * 之后所有命令用 Bearer JWT 调 API,权限/额度与网页端一致。
8
+ */
9
+ import { saveConfig, DEFAULT_BASE_URL } from '../config.js'
10
+ import { StudioClient } from '../client.js'
11
+ import { printWelcome } from './welcome.js'
12
+
13
+ export async function login(opts: { baseUrl?: string }): Promise<void> {
14
+ const baseUrl = (opts.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '')
15
+
16
+ // 1. 发起设备授权
17
+ process.stderr.write('正在发起授权...\n')
18
+ const startResp = await fetch(`${baseUrl}/api/cli-auth/start`, { method: 'POST' })
19
+ if (!startResp.ok) {
20
+ throw new Error(`发起授权失败: HTTP ${startResp.status}`)
21
+ }
22
+ const start = await startResp.json() as {
23
+ device_code: string
24
+ user_code: string
25
+ verification_uri: string
26
+ expires_in: number
27
+ interval: number
28
+ }
29
+
30
+ // 2. 展示验证码和链接,等用户去浏览器授权
31
+ process.stderr.write('\n')
32
+ process.stderr.write('┌──────────────────────────────────────────────────┐\n')
33
+ process.stderr.write('│ │\n')
34
+ process.stderr.write(`│ 验证码: ${start.user_code.padEnd(34)} │\n`)
35
+ process.stderr.write('│ │\n')
36
+ process.stderr.write('│ 请在浏览器打开以下地址,登录并批准授权: │\n')
37
+ process.stderr.write('│ │\n')
38
+ process.stderr.write('│ ' + start.verification_uri + '\n')
39
+ process.stderr.write('│ │\n')
40
+ process.stderr.write('└──────────────────────────────────────────────────┘\n')
41
+ process.stderr.write('\n等待授权完成...(可随时 Ctrl+C 取消)\n')
42
+
43
+ // 3. 轮询直到拿到 token
44
+ const interval = (start.interval || 3) * 1000
45
+ const maxAttempts = Math.floor((start.expires_in || 600) * 1000 / interval)
46
+
47
+ for (let i = 0; i < maxAttempts; i++) {
48
+ await sleep(interval)
49
+ const pollResp = await fetch(`${baseUrl}/api/cli-auth/poll`, {
50
+ method: 'POST',
51
+ headers: { 'Content-Type': 'application/json' },
52
+ body: JSON.stringify({ device_code: start.device_code }),
53
+ })
54
+ if (!pollResp.ok) continue
55
+ const poll = await pollResp.json() as { status: string; token?: string; error?: string }
56
+
57
+ if (poll.status === 'approved' && poll.token) {
58
+ saveConfig({ baseUrl, token: poll.token })
59
+ process.stderr.write('\n✅ 登录成功!token 已保存到 ~/.museav.json\n')
60
+ await printWelcome(baseUrl, { token: poll.token })
61
+ return
62
+ }
63
+ if (poll.status === 'expired') {
64
+ throw new Error('授权已过期,请重新运行 museav login')
65
+ }
66
+ // 服务端目前只有 pending → approved 或过期两条路,没有"拒绝"这个动作
67
+ // (浏览器审批页没有拒绝按钮,不批准就是不动,最终走 expired)。
68
+ // 这里仍保留未知状态的兜底,万一以后服务端加了新状态,报错文案不会文不对题。
69
+ if (poll.status !== 'pending') {
70
+ throw new Error(`授权失败: ${poll.error || poll.status || '未知错误'}`)
71
+ }
72
+ // pending:继续等
73
+ process.stderr.write('.')
74
+ }
75
+ throw new Error('等待授权超时,请重新运行 museav login')
76
+ }
77
+
78
+ function sleep(ms: number): Promise<void> {
79
+ return new Promise((r) => setTimeout(r, ms))
80
+ }
@@ -0,0 +1,12 @@
1
+ /** museav models —— 查可用模型列表 */
2
+ import type { StudioClient } from '../client.js'
3
+
4
+ export async function models(client: StudioClient): Promise<void> {
5
+ const list = await client.models()
6
+ process.stderr.write(`可用模型(${list.length} 个):\n`)
7
+ for (const m of list) {
8
+ process.stderr.write(` ${m.value.padEnd(28)} ${m.label}\n`)
9
+ }
10
+ // stdout 输出 value 列表(便于脚本解析)
11
+ console.log(list.map((m) => m.value).join('\n'))
12
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * museav products —— 查所属租户自己的产品目录。
3
+ *
4
+ * 数据不在 Studio 中台,在租户自己的后台(见 ../tenant-client.ts 顶部注释)。
5
+ * 不是每个租户都有"产品"这个概念——比如好易美是演唱会海报/票务业务,没有实体商品,
6
+ * 它的后台没有开通这个端点,调用会报错,这是预期行为,不是 bug。
7
+ */
8
+ import type { TenantClient } from '../tenant-client.js'
9
+
10
+ interface TenantProduct {
11
+ id: string
12
+ name: string
13
+ series?: string | null
14
+ status?: string | null
15
+ active?: boolean
16
+ cover_image_url?: string | null
17
+ images?: string[]
18
+ [key: string]: unknown
19
+ }
20
+
21
+ export async function products(client: TenantClient): Promise<void> {
22
+ const list = await client.get<TenantProduct[]>('tenant-products')
23
+ if (!Array.isArray(list) || !list.length) {
24
+ process.stderr.write('该租户暂无产品数据\n')
25
+ console.log('[]')
26
+ return
27
+ }
28
+ process.stderr.write(`产品(${list.length} 个):\n`)
29
+ for (const p of list) {
30
+ const imgCount = (p.images?.length || 0) + (p.cover_image_url ? 1 : 0)
31
+ process.stderr.write(
32
+ ` ${String(p.id).padEnd(38)} ${(p.name || '').padEnd(20)} ${(p.series || '').padEnd(12)} ${(p.status || '').padEnd(10)} 图${imgCount}张\n`,
33
+ )
34
+ }
35
+ process.stderr.write(`\n配合出图: museav gen --template <模板id> --fields '{"...":"..."}'(参考图 URL 从上面产品的 cover_image_url / images 里挑)\n`)
36
+ // stdout 输出完整 JSON,方便 agent/脚本挑参考图字段
37
+ console.log(JSON.stringify(list))
38
+ }
@@ -0,0 +1,21 @@
1
+ /** museav reverse —— 图片逆向(SCULPT 六要素反推 prompt) */
2
+ import type { StudioClient } from '../client.js'
3
+
4
+ export async function reverse(client: StudioClient, input: string): Promise<void> {
5
+ // 输入是文件路径还是 URL
6
+ const isUrl = /^https?:\/\//.test(input)
7
+ const result = await client.reverse(isUrl ? { imageUrl: input } : { file: input })
8
+
9
+ process.stderr.write(`✅ 逆向完成\n\n`)
10
+ process.stderr.write(`风格: ${result.zh_name || '-'} 比例: ${result.aspect_ratio}\n`)
11
+ process.stderr.write(`标签: ${result.style_tags.join(', ')}\n\n`)
12
+ process.stderr.write(`SCULPT 六要素:\n`)
13
+ for (const [k, v] of Object.entries(result.sculpt)) {
14
+ process.stderr.write(` ${k}: ${v}\n`)
15
+ }
16
+ process.stderr.write(`\n英文 prompt:\n ${result.prompt}\n`)
17
+ process.stderr.write(`中文 prompt:\n ${result.prompt_cn}\n`)
18
+
19
+ // stdout 输出英文 prompt(便于管道直接喂给 gen)
20
+ console.log(result.prompt)
21
+ }
@@ -0,0 +1,29 @@
1
+ /** museav skills —— 查可用技能(私有 + 所属租户专属模板 + 公共库) */
2
+ import type { StudioClient } from '../client.js'
3
+
4
+ export async function skills(client: StudioClient, opts: { genre?: string } = {}): Promise<void> {
5
+ let list = await client.skills()
6
+ if (opts.genre) {
7
+ const kw = opts.genre.toLowerCase()
8
+ list = list.filter((s) => (s.genre || '').toLowerCase().includes(kw))
9
+ }
10
+ if (!list.length) {
11
+ process.stderr.write(opts.genre ? `没有匹配「${opts.genre}」的技能\n` : '没有可用技能\n')
12
+ return
13
+ }
14
+
15
+ // 归属标记:自己建的 / 所属租户的专属模板 / 公共库——同名时服务端按这个顺序取
16
+ const tag = (s: (typeof list)[number]) => (s.private ? '[私有]' : s.agency ? '[专属]' : '')
17
+
18
+ process.stderr.write(`可用技能(${list.length} 个):\n`)
19
+ for (const s of list) {
20
+ const flag = s.ref_required ? '需垫图' : ''
21
+ process.stderr.write(
22
+ ` ${s.slug.padEnd(30)} ${(s.zh_name || '').padEnd(16)} ${(s.genre || '').padEnd(10)} ${(s.ratio || '').padEnd(6)} ${flag.padEnd(6)} ${tag(s)}\n`,
23
+ )
24
+ }
25
+ process.stderr.write(`\n出图: museav gen --skill <技能名> --input "一句业务描述"\n`)
26
+ process.stderr.write(`标「需垫图」的必须加 --ref ./图.jpg,否则服务端会拒绝\n`)
27
+ // stdout 只出 slug,便于脚本与 agent 解析
28
+ console.log(list.map((s) => s.slug).join('\n'))
29
+ }