@pippit-dev/cli 1.0.31 → 1.0.33

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.
@@ -45,11 +45,11 @@ func TestMissingCredentialsCannotReadFreshCache(t *testing.T) {
45
45
  requests++
46
46
  _, _ = w.Write([]byte(validResponse))
47
47
  })
48
- if _, err := s.Get(context.Background(), false); err != nil {
48
+ if _, err := s.Get(context.Background(), "video", false); err != nil {
49
49
  t.Fatal(err)
50
50
  }
51
51
  s.runner.Auth = unavailableAuth{}
52
- result, err := s.Get(context.Background(), false)
52
+ result, err := s.Get(context.Background(), "video", false)
53
53
  if err == nil || result != nil || requests != 1 || !strings.Contains(err.Error(), "login") {
54
54
  t.Fatalf("must require login before cache: result=%+v err=%v requests=%d", result, err, requests)
55
55
  }
@@ -63,7 +63,7 @@ func TestCacheTTLRefreshAndIsolation(t *testing.T) {
63
63
  t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
64
64
  }
65
65
  var body map[string]string
66
- if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body["scene"] != Scene || body["source_model_key"] != "" {
66
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body["scene"] != VideoScene || body["source_model_key"] != "" {
67
67
  t.Errorf("unexpected body: %v, err=%v", body, err)
68
68
  }
69
69
  _, _ = w.Write([]byte(validResponse))
@@ -72,7 +72,7 @@ func TestCacheTTLRefreshAndIsolation(t *testing.T) {
72
72
  s.now = func() time.Time { return now }
73
73
  check := func(refresh, cached bool, count int) *Result {
74
74
  t.Helper()
75
- result, err := s.Get(context.Background(), refresh)
75
+ result, err := s.Get(context.Background(), "video", refresh)
76
76
  if err != nil || result.Cached != cached || requests != count {
77
77
  t.Fatalf("Get: result=%+v err=%v requests=%d, want cached=%v requests=%d", result, err, requests, cached, count)
78
78
  }
@@ -89,9 +89,9 @@ func TestCacheTTLRefreshAndIsolation(t *testing.T) {
89
89
  check(true, false, 3)
90
90
  s.runner.Config.AccessKey = "second-account"
91
91
  check(false, false, 4)
92
- oldPath := s.cachePath("second-account", config.GetAvailableModelListPath)
92
+ oldPath := s.cachePath("second-account", config.GetAvailableModelListPath, VideoScene)
93
93
  s.runner.Config.BaseURL += "/other-api"
94
- if s.cachePath("second-account", config.GetAvailableModelListPath) == oldPath {
94
+ if s.cachePath("second-account", config.GetAvailableModelListPath, VideoScene) == oldPath {
95
95
  t.Fatal("API base URLs must not share cache")
96
96
  }
97
97
  files, _ := os.ReadDir(s.cacheDir)
@@ -110,7 +110,7 @@ func TestFailedQueryDoesNotUseOrCacheStaleData(t *testing.T) {
110
110
  requests++
111
111
  _, _ = w.Write([]byte(response))
112
112
  })
113
- if _, err := s.Get(context.Background(), false); err != nil {
113
+ if _, err := s.Get(context.Background(), "video", false); err != nil {
114
114
  t.Fatal(err)
115
115
  }
116
116
  for _, invalid := range []string{
@@ -120,29 +120,29 @@ func TestFailedQueryDoesNotUseOrCacheStaleData(t *testing.T) {
120
120
  `not-json`,
121
121
  } {
122
122
  response = invalid
123
- if result, err := s.Get(context.Background(), true); err == nil || result != nil || !strings.Contains(err.Error(), "重试") {
123
+ if result, err := s.Get(context.Background(), "video", true); err == nil || result != nil || !strings.Contains(err.Error(), "重试") {
124
124
  t.Fatalf("failed query must suggest retry: result=%+v err=%v", result, err)
125
125
  }
126
126
  before := requests
127
- if _, err := s.Get(context.Background(), false); err == nil || requests != before+1 {
127
+ if _, err := s.Get(context.Background(), "video", false); err == nil || requests != before+1 {
128
128
  t.Fatal("retry must query the server, not reuse cached success or failure")
129
129
  }
130
130
  }
131
131
  response = validResponse
132
- if _, err := s.Get(context.Background(), false); err != nil {
132
+ if _, err := s.Get(context.Background(), "video", false); err != nil {
133
133
  t.Fatal(err)
134
134
  }
135
- cachePath := s.cachePath(s.runner.Config.AccessKey, config.GetAvailableModelListPath)
135
+ cachePath := s.cachePath(s.runner.Config.AccessKey, config.GetAvailableModelListPath, VideoScene)
136
136
  if err := os.WriteFile(cachePath, []byte("broken"), 0600); err != nil {
137
137
  t.Fatal(err)
138
138
  }
139
- result, err := s.Get(context.Background(), false)
139
+ result, err := s.Get(context.Background(), "video", false)
140
140
  if err != nil || result.Cached {
141
141
  t.Fatalf("corrupt cache should refresh: %+v %v", result, err)
142
142
  }
143
143
  // A timestamp in the future must not extend visibility indefinitely.
144
144
  s.now = func() time.Time { return result.FetchedAt.Add(-time.Second) }
145
- if s.readCache(cachePath) != nil {
145
+ if s.readCache(cachePath, VideoScene) != nil {
146
146
  t.Fatal("future-dated cache must be ignored")
147
147
  }
148
148
  }
@@ -150,7 +150,7 @@ func TestFailedQueryDoesNotUseOrCacheStaleData(t *testing.T) {
150
150
  func TestModelConfigurationPreservedAcrossCache(t *testing.T) {
151
151
  s := newTestService(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(validResponse)) })
152
152
  for i := 0; i < 2; i++ {
153
- result, err := s.Get(context.Background(), false)
153
+ result, err := s.Get(context.Background(), "video", false)
154
154
  if err != nil {
155
155
  t.Fatal(err)
156
156
  }
@@ -188,7 +188,7 @@ func TestCacheWriteFailureStillReturnsServerResult(t *testing.T) {
188
188
  t.Fatal(err)
189
189
  }
190
190
  s.cacheDir = file
191
- result, err := s.Get(context.Background(), false)
191
+ result, err := s.Get(context.Background(), "video", false)
192
192
  if err != nil || result.Cached || result.Warning == "" {
193
193
  t.Fatalf("result=%+v err=%v", result, err)
194
194
  }
@@ -207,8 +207,150 @@ func TestEmptyAndDuplicateModels(t *testing.T) {
207
207
  if err := json.Unmarshal([]byte(tc.data), &catalog); err != nil {
208
208
  t.Fatal(err)
209
209
  }
210
- if err := validateCatalog(&catalog); (err == nil) != tc.valid {
210
+ if err := validateCatalog(&catalog, VideoScene); (err == nil) != tc.valid {
211
211
  t.Fatalf("validateCatalog = %v", err)
212
212
  }
213
213
  }
214
214
  }
215
+
216
+ func TestImageAndVideoCacheIsolation(t *testing.T) {
217
+ requests := map[string]int{}
218
+ failImage := false
219
+ s := newTestService(t, func(w http.ResponseWriter, r *http.Request) {
220
+ var body map[string]string
221
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
222
+ t.Error(err)
223
+ return
224
+ }
225
+ scene := body["scene"]
226
+ requests[scene]++
227
+ if body["source_model_key"] != "" || len(body) != 2 {
228
+ t.Errorf("unexpected identity or selector fields: %v", body)
229
+ }
230
+ response := validResponse
231
+ switch scene {
232
+ case ImageScene:
233
+ response = strings.ReplaceAll(strings.ReplaceAll(validResponse, VideoScene, ImageScene), `"kind":"video"`, `"kind":"image"`)
234
+ if failImage {
235
+ response = `{"ret":"1001","errmsg":"scene not supported"}`
236
+ }
237
+ case VideoScene:
238
+ default:
239
+ t.Errorf("unexpected scene %q", scene)
240
+ }
241
+ _, _ = w.Write([]byte(response))
242
+ })
243
+ for _, kind := range []string{"image", "video"} {
244
+ for i := 0; i < 2; i++ {
245
+ result, err := s.Get(context.Background(), kind, false)
246
+ if err != nil {
247
+ t.Fatal(err)
248
+ }
249
+ if result.Cached != (i == 1) || result.Catalog.Search("")[0].Kind != kind {
250
+ t.Fatalf("cache crossed model type: %+v", result)
251
+ }
252
+ }
253
+ }
254
+ if requests[ImageScene] != 1 || requests[VideoScene] != 1 {
255
+ t.Fatalf("requests=%v", requests)
256
+ }
257
+ // Even a syntactically valid video cache at the image path must be ignored.
258
+ videoPath := s.cachePath(s.runner.Config.AccessKey, config.GetAvailableModelListPath, VideoScene)
259
+ imagePath := s.cachePath(s.runner.Config.AccessKey, config.GetAvailableModelListPath, ImageScene)
260
+ raw, err := os.ReadFile(videoPath)
261
+ if err != nil {
262
+ t.Fatal(err)
263
+ }
264
+ if err := os.WriteFile(imagePath, raw, 0600); err != nil {
265
+ t.Fatal(err)
266
+ }
267
+ result, err := s.Get(context.Background(), "image", false)
268
+ if err != nil || result.Cached || requests[ImageScene] != 2 {
269
+ t.Fatalf("mismatched cache not refreshed: result=%+v err=%v requests=%v", result, err, requests)
270
+ }
271
+ failImage = true
272
+ for _, refresh := range []bool{true, false} {
273
+ if result, err := s.Get(context.Background(), "image", refresh); err == nil || result != nil {
274
+ t.Fatalf("image failure reused a catalog: result=%+v err=%v", result, err)
275
+ }
276
+ }
277
+ result, err = s.Get(context.Background(), "video", false)
278
+ if err != nil || !result.Cached || requests[ImageScene] != 4 || requests[VideoScene] != 1 {
279
+ t.Fatalf("image refresh invalidated video: result=%+v err=%v requests=%v", result, err, requests)
280
+ }
281
+ }
282
+
283
+ func TestImageCatalogValidation(t *testing.T) {
284
+ for _, tc := range []struct {
285
+ name, scene, models string
286
+ valid bool
287
+ }{
288
+ {"empty", ImageScene, `[]`, true},
289
+ {"image", ImageScene, `[{"key":"dynamic-image","name":"图片测试模型","kind":"image"}]`, true},
290
+ {"missing name", ImageScene, `[{"key":"x","kind":"image"}]`, false},
291
+ {"blank name", ImageScene, `[{"key":"x","kind":"image","name":" "}]`, false},
292
+ {"unknown enum is not a display name", ImageScene, `[{"key":"future_code","name":"future_code","kind":"image"}]`, false},
293
+ {"duplicate name", ImageScene, `[{"key":"x","kind":"image","name":"名称"},{"key":"y","kind":"image","name":" 名称 "}]`, false},
294
+ {"wrong scene", VideoScene, `[{"key":"x","kind":"image"}]`, false},
295
+ {"wrong kind", ImageScene, `[{"key":"x","kind":"video"}]`, false},
296
+ {"duplicate", ImageScene, `[{"key":"x","kind":"image"},{"key":"x","kind":"image"}]`, false},
297
+ } {
298
+ t.Run(tc.name, func(t *testing.T) {
299
+ s := newTestService(t, func(w http.ResponseWriter, _ *http.Request) {
300
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"scene":"` + tc.scene + `","config_key":"key","config":{"models":` + tc.models + `}}}`))
301
+ })
302
+ result, err := s.Get(context.Background(), "image", false)
303
+ if (err == nil) != tc.valid {
304
+ t.Fatalf("result=%+v err=%v valid=%v", result, err, tc.valid)
305
+ }
306
+ })
307
+ }
308
+ }
309
+
310
+ func TestImageNamesKeepWireKeysInternalAcrossCache(t *testing.T) {
311
+ const response = `{"ret":"0","data":{"scene":"web_image_agent","config_key":"image-config","config":{"models":[{"key":"wire-image","name":"智能图片V2","kind":"image"},{"key":"wire-image-fast","report_name":"wire-image-fast","name":"智能图片V2.5 Fast","kind":"image","is_default":true,"supported_ratio_list":[3,6]}]}}}`
312
+ s := newTestService(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(response)) })
313
+ for i := 0; i < 2; i++ {
314
+ result, err := s.Get(context.Background(), "image", false)
315
+ if err != nil {
316
+ t.Fatal(err)
317
+ }
318
+ if result.Cached != (i == 1) {
319
+ t.Fatalf("cached=%v", result.Cached)
320
+ }
321
+ catalog := result.Catalog
322
+ list := catalog.Search("智能图片")
323
+ if len(list) != 2 || list[1].Name != "智能图片V2.5 Fast" {
324
+ t.Fatalf("unexpected image names: %+v", list)
325
+ }
326
+ if len(catalog.Search("wire-image")) != 0 || len(catalog.Search("fast")) != 1 {
327
+ t.Fatal("image search must use display names only")
328
+ }
329
+ raw, err := json.Marshal(list)
330
+ if err != nil || strings.Contains(string(raw), `"key"`) || strings.Contains(string(raw), "wire-image") {
331
+ t.Fatalf("list leaked identifiers: %s, %v", raw, err)
332
+ }
333
+ detail, err := catalog.Describe(list[1].Name)
334
+ if err != nil || strings.Contains(string(detail), "wire-image") || !strings.Contains(string(detail), "智能图片V2.5 Fast") {
335
+ t.Fatalf("detail leaked identifiers or lost name: %s, %v", detail, err)
336
+ }
337
+ key, err := catalog.ImageModelKey(" 智能图片V2.5 Fast ")
338
+ if err != nil || key != "wire-image-fast" {
339
+ t.Fatalf("name resolution: key=%q, err=%v", key, err)
340
+ }
341
+ for _, invalid := range []string{"", "智能图片", "wire-image-fast", "不存在"} {
342
+ if _, err := catalog.ImageModelKey(invalid); err == nil || strings.Contains(err.Error(), "wire-image") {
343
+ t.Fatalf("unresolved input accepted or leaked: %q, %v", invalid, err)
344
+ }
345
+ if _, err := catalog.Describe(invalid); err == nil || strings.Contains(err.Error(), "wire-image") {
346
+ t.Fatalf("detail accepted unresolved input: %q, %v", invalid, err)
347
+ }
348
+ }
349
+ if !strings.Contains(string(catalog.Config.Models[1]), "wire-image-fast") {
350
+ t.Fatal("presentation mutated the cached request identifier")
351
+ }
352
+ if got := catalog.ImageDisplayMessage("wire-image-fast / wire-image"); got != "智能图片V2.5 Fast / 智能图片V2" {
353
+ t.Fatalf("error must use names: %s", got)
354
+ }
355
+ }
356
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pippit-dev/cli",
3
- "version": "1.0.31",
3
+ "version": "1.0.33",
4
4
  "description": "Pippit CLI",
5
5
  "bin": {
6
6
  "pippit-tool-cli": "scripts/run.js"
@@ -3,7 +3,7 @@ name: xyq-marketing-skill
3
3
  description: 使用小云雀公开营销 API,根据商品图文生成剧情广告、品牌大片或达人带货营销视频;上传素材、提交营销成片、查询进度、下载交付结果及查询积分。用户要求小云雀营销一键成片或接入营销 API 时使用。
4
4
  user-invocable: true
5
5
  metadata:
6
- {"openclaw": {"emoji": "🛍️", "requires": {"bins": ["node"], "env": ["XYQ_ACCESS_KEY"]}}}
6
+ {"openclaw": {"emoji": "🛍️", "requires": {"bins": ["node", "pippit-tool-cli"]}}}
7
7
  ---
8
8
 
9
9
  # 小云雀营销成片
@@ -12,9 +12,11 @@ metadata:
12
12
 
13
13
  ## Setup
14
14
 
15
- 本 Skill 自包含,只需要 Node.js 16+,不依赖其它 Skill 或 npm 安装。脚本位置为 `"{baseDir}/scripts/marketing.js"`;下面示例中的相对路径从本 Skill 目录执行。
15
+ 需要 Node.js 16+ 和支持 `marketing` 命令的 `@pippit-dev/cli`。脚本位置为 `"{baseDir}/scripts/marketing.js"`;下面示例中的相对路径从本 Skill 目录执行。先检查 `pippit-tool-cli marketing --help`;命令不存在时安装或更新 CLI:`npm install -g @pippit-dev/cli@latest`,再检查一次;仍不支持就报告版本阻塞,不改为向用户索要密钥。
16
16
 
17
- API 和 CLI 可以使用同一个 Access Key,但脚本只读取当前进程的 `XYQ_ACCESS_KEY`。CLI 浏览器登录保存的凭据不会自动变成环境变量,不读取系统钥匙串或 CLI 凭据文件。缺少变量时,引导用户在 [官网 API 页](https://xyq.jianying.com/cli?tab=api) 管理 Access Key 并在本机安全配置,不能让用户在聊天中发送密钥,也不要将密钥写入请求文件、命令参数或日志。
17
+ 营销 API 调用复用其它 CLI 命令的登录态,脚本不读取或导出凭据。执行真实请求前运行 `pippit-tool-cli status`,读取 JSON 的 `logged_in`,不能只看退出码。未登录或凭据过期时运行 `pippit-tool-cli login`,让用户在浏览器完成授权;等待 CLI 成功返回后再次检查 status,再继续原任务。已有有效登录态直接复用,不重复登录;离线预览和帮助不要求登录。
18
+
19
+ 不要让用户提供或复制 `access_token`、Access Key,也不要把凭据写入聊天、请求文件、命令参数或日志。CLI 保留原有 `XYQ_ACCESS_KEY` 显式环境覆盖规则,优先于网页登录;这仅用于已配置的自动化环境,不作为普通用户的必填项。覆盖无效时不能静默切换账号。网页登录凭据被拒绝时按 CLI 的 `login --force` 流程处理,不自动重提可能已创建的生成任务。
18
20
 
19
21
  先按 [接口契约](references/api.md) 准备请求;需要完整执行示例时读 [商品图到营销视频](examples/product-video.md)。
20
22
 
@@ -24,7 +26,7 @@ API 和 CLI 可以使用同一个 Access Key,但脚本只读取当前进程的
24
26
  2. 有本地商品素材时逐个上传,保留返回的 `data.pippit_asset_id`。远程素材先取得用户授权使用的本地文件;不能把 URL 或路径放进 `asset_ids`。用户已提供有效资产 ID 时直接复用。无素材的纯文字请求无需上传。
25
27
  3. 写入 UTF-8 JSON 请求文件。`message` 保留用户指令;`general_agent_settings.video_model` 必填,不能传 `{}`。用户指定模型时原样使用;未指定时询问,或在用户已明确授权“你决定”等选择范围内选定并说明。其它选项按用户给定或已授权的偏好设置,不静默换模型。`thread_id` 仅在继续已有营销会话时传入真实 ID。
26
28
  4. 先预览校验,已获生成授权后使用 `--execute` 提交。立即保存响应并展示真实 `data.web_thread_link`,保留 `data.run.thread_id`、`data.run.run_id`。缺少网页链接时只报告实际返回信息,不自行拼接链接。
27
- 5. 用当前 thread/run 查询到结束并下载媒体。遇到确认或问卷时沿用已有授权;缺少必要选择再问用户。确认后继续同一 thread,并取得最新 run_id 再查询:旧 Run 可永久保持等待交互状态。可通过宿主浏览器查看已返回的网页链接;安装了 `pippit-tool-cli` 时,也可用 `get-thread --thread-id THREAD_ID` 读取各 Run 的真实 ID。不要用旧 Run 重复确认或重新生成。提交成功、网页链接或进度链接都不等于交付完成。
29
+ 5. 用当前 thread/run 查询到结束并下载媒体。遇到确认或问卷时沿用已有授权;缺少必要选择再问用户。确认后继续同一 thread,并取得最新 run_id 再查询:旧 Run 可永久保持等待交互状态。可通过宿主浏览器查看已返回的网页链接;也可用 `pippit-tool-cli` 的 `get-thread --thread-id THREAD_ID` 读取各 Run 的真实 ID。不要用旧 Run 重复确认或重新生成。提交成功、网页链接或进度链接都不等于交付完成。
28
30
 
29
31
  ```bash
30
32
  node scripts/marketing.js upload --file /path/to/product.png
@@ -34,7 +36,7 @@ node scripts/marketing.js query --thread-id THREAD_ID --run-id RUN_ID --wait --m
34
36
  node scripts/marketing.js balance
35
37
  ```
36
38
 
37
- 生成默认是离线预览;预览不需要密钥。上传、查询、余额是实际 API 请求。`--timeout` 设置单请求总时限(秒,默认 60)。`--wait` 由脚本每 10 秒查询,默认最多 900 秒;不再叠加其它轮询器。没有 `--wait` 时只查询一次。脚本输出逐行 JSON,查询下载前先输出服务端响应,再逐个输出已下载文件,最后输出含 `downloaded_files` 的响应。
39
+ 生成默认是离线预览;预览不需要登录。上传、查询、余额是实际 API 请求。`--timeout` 设置单请求总时限(秒,默认 60)。`--wait` 由脚本每 10 秒查询,默认最多 900 秒;不再叠加其它轮询器。没有 `--wait` 时只查询一次。脚本输出逐行 JSON,查询下载前先输出服务端响应,再逐个输出已下载文件,最后输出含 `downloaded_files` 的响应。
38
40
 
39
41
  ## Completion and Recovery
40
42
 
@@ -48,4 +50,4 @@ node scripts/marketing.js balance
48
50
 
49
51
  ## Scope
50
52
 
51
- 本 Skill 仅使用正式公开营销接口;不宣称支持团队空间切换。鉴权范围由用户配置的 Access Key 和服务端决定,公开请求没有 `TeamID` 字段,不自行加入团队字段或跨账号复用资产/任务 ID。沉浸式短片、火山引擎服务和现有 CLI 的来源统计参数不在此脚本范围;不要把 `--source` 等未公开字段传给营销接口。
53
+ 本 Skill 仅使用正式公开营销接口;不宣称支持团队空间切换。鉴权范围沿用当前 CLI 登录身份和服务端授权,公开请求没有 `TeamID` 字段,不自行加入团队字段或跨账号复用资产/任务 ID。沉浸式短片、火山引擎服务和现有 CLI 的来源统计参数不在此脚本范围;不要把 `--source` 等未公开字段传给营销接口。
@@ -2,7 +2,7 @@
2
2
 
3
3
  用户示例:“用这张保温杯商品图做一个 15 秒竖屏达人带货视频,不要字幕,用 Seedance 2.0 VIP,1080p。”
4
4
 
5
- 1. 确认当前进程已配置 `XYQ_ACCESS_KEY`,然后上传用户给出的真实文件:
5
+ 1. 先检查 `pippit-tool-cli marketing --help` 与 `pippit-tool-cli status`;未登录时执行 `pippit-tool-cli login` 并等待浏览器授权成功,无需用户提供 token。随后上传用户给出的真实文件:
6
6
 
7
7
  ```bash
8
8
  node scripts/marketing.js upload --file /path/to/cup.png
@@ -4,11 +4,11 @@
4
4
 
5
5
  ## Endpoints
6
6
 
7
- Base URL:`https://xyq.jianying.com`。全部使用 POST,认证为 `Authorization: Bearer <Access Key>`,`Accept: application/json`。
7
+ Base URL:`https://xyq.jianying.com`。全部使用 POST,`Accept: application/json`。CLI 的统一认证层从现有登录态注入 Authorization 请求头;营销脚本不接触凭据,不要求用户提供 token。
8
8
 
9
9
  | 操作 | 路径 | 请求体 |
10
10
  | --- | --- | --- |
11
- | 上传单文件 | `/api/biz/v1/skill/upload_file` | multipart/form-data,字段 `file`,boundary 由脚本生成 |
11
+ | 上传单文件 | `/api/biz/v1/skill/upload_file` | multipart/form-data,字段 `file`,boundary 由 CLI 生成 |
12
12
  | 营销成片 | `/api/biz/v1/agent/submit_marketing_run` | JSON,字段见下表 |
13
13
  | 查询结果 | `/api/biz/v1/agent/query_generate_video_result` | JSON:真实 `thread_id`、`run_id` |
14
14
  | 查询积分 | `/api/biz/v1/skill/get_credit_balance` | JSON:`{}` |
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
- // Documented Xiaoyunque marketing API; Node.js >= 16, no dependencies.
2
+ // Marketing orchestration; authenticated API calls stay inside pippit-tool-cli.
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const https = require('https');
6
6
  const crypto = require('crypto');
7
- const { Readable } = require('stream');
7
+ const { spawn } = require('child_process');
8
8
  const { pipeline } = require('stream/promises');
9
9
 
10
10
  const BASE = 'https://xyq.jianying.com';
@@ -63,7 +63,64 @@ function checkResponse(action, result, body) {
63
63
  return result;
64
64
  }
65
65
 
66
- function createClient({ key = process.env.XYQ_ACCESS_KEY, request = https.request, timeout = 60000 } = {}) {
66
+ function resolveCLI({ platform = process.platform, searchPath = process.env.PATH || '', packageRoot = path.resolve(__dirname, '../../..') } = {}) {
67
+ const binaryName = platform === 'win32' ? 'pippit-tool-cli.exe' : 'pippit-tool-cli';
68
+ function fromPackage(root) {
69
+ const manifest = path.join(root, 'package.json');
70
+ if (!fs.existsSync(manifest) || JSON.parse(fs.readFileSync(manifest, 'utf8')).name !== '@pippit-dev/cli') return null;
71
+ const command = path.join(root, 'bin', binaryName);
72
+ requireValue(fs.existsSync(command), 'CLI 原生程序缺失,请重新安装 @pippit-dev/cli;无需提供 access_token');
73
+ return { command, args: [] };
74
+ }
75
+ // Run the native binary directly so deadlines also stop the API process.
76
+ const bundled = fromPackage(packageRoot);
77
+ if (bundled) return bundled;
78
+ for (const dir of searchPath.split(path.delimiter).filter(Boolean)) {
79
+ const binary = path.join(dir, binaryName);
80
+ if (fs.existsSync(binary)) {
81
+ const resolved = fs.realpathSync(binary);
82
+ const npmPackage = fromPackage(path.resolve(path.dirname(resolved), '..'));
83
+ return npmPackage || { command: binary, args: [] };
84
+ }
85
+ if (platform === 'win32') {
86
+ const npmPackage = fromPackage(path.join(dir, 'node_modules/@pippit-dev/cli'));
87
+ if (npmPackage) return npmPackage;
88
+ }
89
+ }
90
+ throw new Error('请先安装或更新 @pippit-dev/cli,并运行 pippit-tool-cli login;无需提供 access_token');
91
+ }
92
+
93
+ function invokeCLI(args, input, timeout, invocation = resolveCLI()) {
94
+ return new Promise((resolve, reject) => {
95
+ const child = spawn(invocation.command, [...invocation.args, ...args], {
96
+ shell: false, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
97
+ });
98
+ let stdout = '', stderr = '', failure;
99
+ const stop = message => { failure = new Error(message); child.kill(); };
100
+ const timer = setTimeout(() => stop('CLI 请求超时;提交结果可能不明确,请勿自动重提'), timeout);
101
+ child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8');
102
+ child.stdout.on('data', chunk => {
103
+ stdout += chunk;
104
+ if (Buffer.byteLength(stdout) > 8 * 1024 * 1024) stop('CLI 响应超出 8 MiB 限制');
105
+ });
106
+ child.stderr.on('data', chunk => {
107
+ stderr += chunk;
108
+ if (Buffer.byteLength(stderr) > 1024 * 1024) stop('CLI 错误输出过大');
109
+ });
110
+ child.once('error', () => { clearTimeout(timer); reject(new Error('无法启动 pippit-tool-cli,请检查安装;无需提供 access_token')); });
111
+ child.stdin.on('error', error => { if (error.code !== 'EPIPE') failure = new Error('无法写入 CLI 请求'); });
112
+ child.once('close', code => {
113
+ clearTimeout(timer);
114
+ if (failure) return reject(failure);
115
+ if (code !== 0) return reject(new Error(`CLI 调用失败:${stderr.trim() || `退出码 ${code}`};请求未重试。若不支持 marketing 命令,请更新 CLI。`));
116
+ try { resolve(JSON.parse(stdout)); }
117
+ catch (_) { reject(new Error('CLI 未返回有效 JSON;提交结果可能不明确,请勿自动重提')); }
118
+ });
119
+ child.stdin.end(input);
120
+ });
121
+ }
122
+
123
+ function createClient({ request = https.request, timeout = 60000, invoke = invokeCLI } = {}) {
67
124
  function open(url, method, headers, body) {
68
125
  return new Promise((resolve, reject) => {
69
126
  const target = new URL(url);
@@ -84,42 +141,23 @@ function createClient({ key = process.env.XYQ_ACCESS_KEY, request = https.reques
84
141
  });
85
142
  }
86
143
 
87
- async function api(action, body, headers = { 'Content-Type': 'application/json' }) {
88
- requireValue(nonempty(key) && !/[\r\n]/.test(key), '请在本机环境设置 XYQ_ACCESS_KEY,不要在聊天中发送密钥;CLI 登录不会设置此变量');
89
- const wire = headers['Content-Type'] === 'application/json' ? JSON.stringify(body) : body;
90
- const res = await open(BASE + PATHS[action], 'POST', { ...headers, Accept: 'application/json', Authorization: `Bearer ${key}` }, wire);
91
- // Never follow API redirects or automatically retry a POST.
92
- if (res.statusCode < 200 || res.statusCode >= 300) {
93
- res.resume();
94
- throw new Error(`HTTP ${res.statusCode};请求未重试,生成结果可能不明确`);
95
- }
96
- const chunks = [];
97
- let size = 0;
98
- for await (const chunk of res) {
99
- size += chunk.length;
100
- requireValue(size <= 8 * 1024 * 1024, 'API 响应超出 8 MiB 限制');
101
- chunks.push(chunk);
144
+ async function api(action, body) {
145
+ requireValue(['generate', 'query', 'balance'].includes(action), '无效营销 API 操作');
146
+ const args = ['marketing', action, '--timeout', `${timeout}ms`];
147
+ let input;
148
+ if (action === 'generate') {
149
+ validate(body);
150
+ args.push('--request', '-', '--execute');
151
+ input = JSON.stringify(body);
102
152
  }
103
- let result;
104
- try { result = JSON.parse(Buffer.concat(chunks).toString('utf8')); }
105
- catch (_) { throw new Error('API 未返回有效 JSON;提交结果可能不明确,请勿自动重提'); }
106
- return checkResponse(action, result, body);
153
+ if (action === 'query') args.push('--thread-id', body.thread_id, '--run-id', body.run_id);
154
+ return checkResponse(action, await invoke(args, input, timeout), body);
107
155
  }
108
156
 
109
157
  async function upload(file) {
110
158
  const stat = await fs.promises.stat(file);
111
159
  requireValue(stat.isFile() && stat.size > 0 && stat.size < 500000000, '上传需要非空文件且小于 500 MB');
112
- const boundary = 'xyq-' + crypto.randomBytes(16).toString('hex');
113
- const name = path.basename(file).replace(/["\r\n\\]/g, '_');
114
- const mime = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.mp4': 'video/mp4', '.mp3': 'audio/mpeg', '.wav': 'audio/wav' }[path.extname(file).toLowerCase()] || 'application/octet-stream';
115
- const head = Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${name}"\r\nContent-Type: ${mime}\r\n\r\n`);
116
- const tail = Buffer.from(`\r\n--${boundary}--\r\n`);
117
- const body = Readable.from((async function* () {
118
- yield head;
119
- for await (const chunk of fs.createReadStream(file)) yield chunk;
120
- yield tail;
121
- })());
122
- return api('upload', body, { 'Content-Type': `multipart/form-data; boundary=${boundary}`, 'Content-Length': head.length + stat.size + tail.length });
160
+ return checkResponse('upload', await invoke(['marketing', 'upload', '--file', path.resolve(file), '--timeout', `${timeout}ms`], undefined, timeout));
123
161
  }
124
162
 
125
163
  async function download(url, destination) {
@@ -159,7 +197,7 @@ const HELP = `小云雀营销 Skill(Node.js >= 16)
159
197
  node marketing.js upload --file product.png
160
198
  node marketing.js query --thread-id ID --run-id ID [--wait] [--max-wait 900] [--output-dir DIR]
161
199
  node marketing.js balance
162
- 所有 API 调用从环境读取 XYQ_ACCESS_KEY;generate 默认仅预览。
200
+ 所有 API 调用复用 pippit-tool-cli 登录态;未登录先运行 pippit-tool-cli login,无需提供 access_token。generate 默认仅预览。
163
201
  --timeout 秒数:单请求总时限,默认 60;--max-wait:轮询总时限,默认 900。
164
202
  query 输出 API 原始响应;有 --output-dir 时成功结果附带 downloaded_files。
165
203
  退出码:0 成功/单次查询进行中;1 输入或接口错误;2 生成失败/取消/无视频;3 等待超时;4 等待用户交互;5 未知或未指定状态。
@@ -189,7 +227,7 @@ function parseArgs(argv) {
189
227
  return { action, options };
190
228
  }
191
229
 
192
- async function main(argv, { env = process.env, out = console.log, clientFactory = createClient, now = Date.now, pause = ms => new Promise(resolve => setTimeout(resolve, ms)) } = {}) {
230
+ async function main(argv, { out = console.log, clientFactory = createClient, now = Date.now, pause = ms => new Promise(resolve => setTimeout(resolve, ms)) } = {}) {
193
231
  if (!argv.length || argv.includes('--help') || argv.includes('-h')) { out(HELP); return 0; }
194
232
  const { action, options } = parseArgs(argv);
195
233
  let body = {};
@@ -203,13 +241,13 @@ async function main(argv, { env = process.env, out = console.log, clientFactory
203
241
  body = { thread_id: options['thread-id'], run_id: options['run-id'] };
204
242
  }
205
243
  if (action === 'upload') requireValue(nonempty(options.file), 'upload 需要 --file');
206
- const client = clientFactory({ key: env.XYQ_ACCESS_KEY, timeout: options.timeout * 1000 });
244
+ const client = clientFactory({ timeout: options.timeout * 1000 });
207
245
  let result;
208
246
  const deadline = now() + options['max-wait'] * 1000;
209
247
  do {
210
248
  // A poll request cannot overrun the remaining wait budget.
211
249
  const pollClient = action === 'query' && options.wait
212
- ? clientFactory({ key: env.XYQ_ACCESS_KEY, timeout: Math.min(options.timeout * 1000, Math.max(1, deadline - now())) }) : client;
250
+ ? clientFactory({ timeout: Math.min(options.timeout * 1000, Math.max(1, deadline - now())) }) : client;
213
251
  result = action === 'upload' ? await client.upload(options.file) : await pollClient.api(action, body);
214
252
  if (action !== 'query' || !options.wait || !WAIT_STATES.includes(String(result.data.run_state))) break;
215
253
  if (now() >= deadline) {
@@ -265,4 +303,4 @@ if (require.main === module) {
265
303
  process.exitCode = 1;
266
304
  });
267
305
  }
268
- module.exports = { BASE, PATHS, validate, checkResponse, createClient, parseArgs, main };
306
+ module.exports = { BASE, PATHS, validate, checkResponse, resolveCLI, invokeCLI, createClient, parseArgs, main };
@@ -10,8 +10,6 @@ metadata:
10
10
 
11
11
  通过 CLI 完成生成、处理、结果下载与媒体交付。支持下表中的操作;不提供多轮会话续写或自动拆分剧本、分镜并编排成片的能力。复杂需求先确认能由所列命令完成的具体操作,不承诺未覆盖的流程。
12
12
 
13
- 商品图文营销一键成片(剧情广告、品牌大片、达人带货)由独立的 `xyq-marketing-skill` 提供,不用普通生视频代替营销编排;未安装该 Skill 时先说明当前能力边界。
14
-
15
13
  ## 开始执行
16
14
 
17
15
  1. 画布任务运行 `node "{baseDir}/scripts/ensure-cli.js" --canvas`,其他任务运行 `node "{baseDir}/scripts/ensure-cli.js"`。保存返回的 `cli_path`;Canvas 还需保存 `canvas_entry`。文档中的 `pippit-tool-cli` 替换为带引号的 `cli_path`;画布语义命令按模块说明通过 Node 入口执行。同一任务复用,安装细节见 [安装说明](scripts/install.md)。
@@ -29,7 +27,7 @@ metadata:
29
27
  | 查看登录状态、登录、退出或切换账号 | `status` / `login` / `logout` | [授权](commands/auth.md) |
30
28
  | 创建或查询小云雀个人画布,编辑节点、布局、连线、角色/场景、提示词、3D 或多轨草稿 | `canvas` | [Canvas 能力与命令发现](commands/canvas.md) |
31
29
  | 生成图片,或基于参考图修改图片 | `generate-image` | [生图与图片编辑](commands/generate-image.md) |
32
- | 查询当前可用的视频模型、参数配置 | `model list` / `model describe` | [模型发现](commands/model.md) |
30
+ | 查询当前可用的图片/视频模型、比例、分辨率及推理强度等参数配置 | `model list` / `model describe` | [模型发现](commands/model.md) |
33
31
  | 生成视频,使用图/视频/音频参考,首尾帧生视频 | `generate-video` | [生视频](commands/generate-video.md) |
34
32
  | 提升已有视频分辨率、视频超分 | `video-super-resolution` | [超分](commands/video-super-resolution.md) |
35
33
  | 去除已有视频字幕 | `erase-video-subtitle` | [擦字幕](commands/erase-video-subtitle.md) |
@@ -45,6 +43,7 @@ metadata:
45
43
  ## 执行总则
46
44
 
47
45
  - 保留用户原始 prompt,不擅自扩写、润色、翻译或增加风格词;参数转换按对应命令文档执行。生成和视频处理只传用户给定的可选创作参数,缺少必填项先询问。画布输入还需使用实际查询的 ID、版本和当前 schema。模型和参数最终合法性由服务端判断。
46
+ - 图片模型对用户只展示服务端返回的名称;列表、选择、详情与生成命令均使用完整名称,不解释或展示对应底层枚举。名称到提交标识由 CLI 解析,详见 [模型发现](commands/model.md)。
48
47
  - 用户明确要求生成或处理,即可在该范围内执行;仅咨询用法、费用或方案时不提交。范围、必填信息或消耗 credits 的授权不明确时,先确认,不重复索要已给出的授权。
49
48
  - 提问优先使用宿主实际提供且当前模式允许的工具:Codex 的 `request_user_input` 或 `request_user_input_async`,WorkBuddy 的 `ask_user_question`;不可用时用普通聊天。需要答案时等待答复。
50
49
  - 素材参数接收本地文件路径,CLI 内部上传。远程链接不能冒充本地路径;缺少可访问文件时先解决素材获取。单文件必须小于 500 MB(500000000 字节)。
@@ -7,25 +7,30 @@
7
7
  | 参数 | 必填 | 规则 |
8
8
  | --- | --- | --- |
9
9
  | `--prompt` | 是 | 用户原始描述,不能全为空白 |
10
- | `--model` | 是 | 用户选择的图片模型;缺少时先询问 |
10
+ | `--model` | 是 | 用户选择的图片模型完整名称;包含空格时加引号,缺少时先询问 |
11
11
  | `--image` | 否 | 本地参考图路径,多张图重复此参数,保留用户指定的角色与顺序 |
12
- | `--ratio` | 否 | 整数枚举,按下表转换明确的比例要求 |
13
- | `--resolution` | 否 | 仅 `seedream_5.0_pro` 支持 `1K`、`2K`、`4K` 选项 |
12
+ | `--ratio` | 否 | 查询返回的数字枚举(如 `3` 表示 `9:16`);比例含义见 `option_labels` |
13
+ | `--resolution` | 否 | 对应模型查询返回的分辨率;提交时转为大写 |
14
+ | `--effort` | 否 | 对应模型查询返回的推理强度;提交为 `general_agent_settings.image_effort`,转为小写 |
14
15
  | `--generate-image-count` | 否 | 用户指定的生成数量 |
15
16
 
16
- 比例映射:`0=原始比例/自动`、`2=16:9`、`13=21:9`、`3=9:16`、`4=4:3`、`5=3:4`、`6=1:1`。此命令接收整数,不传 `--ratio "16:9"`。用户未给比例时省略;不明确的比例先确认,不猜枚举。
17
+ 先用 `pippit-tool-cli model list --type image` 查询模型,再用 `pippit-tool-cli model describe "模型名称" --type image` 查询比例、分辨率和可选推理强度。对用户只展示名称,不提供底层模型枚举。生成命令用 `--model "完整名称"`,CLI 负责从当前配置中解析提交标识;名称不存在或重复时停止。查询失败时按错误处理,不回退静态模型目录;细节见 [模型发现](model.md)。
17
18
 
18
- 当前 CLI 帮助列出的模型包括 `seedream_5.0_pro`、`seedream_5.0`、`seedream_4.3`、`nova2`、`seedream_4.5`、`seedream_4.1`、`seedream_4`。用于提示选择,实际支持情况以当前帮助和服务端为准,不在 Skill 中增加模型白名单校验。
19
+ 图片比例只传数字,例如用户指定 `9:16` 时使用 `--ratio 3`。从模型查询的 `option_labels` 确认含义,再将对应的数字 `options` 传入;`0` 表示自动比例。CLI 校验整数格式,模型是否支持该枚举由服务端决定。
20
+
21
+ 推理强度只有部分模型包含;未下发 `effort` 维度时不主动提供档位选择。用户未指定的比例、分辨率、推理强度均省略,不从查询默认值自动补齐。条件维度和参数组合以服务端下发为准,不将每个维度的选项任意组合;不在 Skill 或 CLI 维护模型白名单。
22
+
23
+ 使用旧版 CLI 时先检查 `generate-image --help`;若 `--model` 仍要求 key,或缺少用户指定的 `--effort`,先升级,不能把底层枚举交给用户或静默丢弃推理强度。
19
24
 
20
25
  本地图片后缀支持 `.jpg/.jpeg/.png/.gif/.bmp/.webp/.svg`。参考图由命令内部上传,不需要自行获取资产 ID。
21
26
 
22
27
  ## 最小调用
23
28
 
24
29
  ```bash
25
- pippit-tool-cli generate-image --prompt "用户原始描述" --model IMAGE_MODEL
30
+ pippit-tool-cli generate-image --prompt "用户原始描述" --model "智能图片V2.5 Fast"
26
31
  ```
27
32
 
28
- 只追加用户已提供的可选参数。多图编辑见 [参考图编辑示例](../examples/image-edit.md)。
33
+ 上例名称须存在于实际查询结果,不能当作静态可用列表。CLI 在上传前复用当前凭证下的有效缓存或查询模型列表,提交时才使用底层枚举,不让用户填写枚举。只追加用户已提供的可选参数。多图编辑见 [参考图编辑示例](../examples/image-edit.md)。
29
34
 
30
35
  ## 返回与处理
31
36