@pippit-dev/cli 1.0.31 → 1.0.32

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/README.md CHANGED
@@ -28,7 +28,7 @@
28
28
  npx skills add Pippit-dev/cli --skill xyq-marketing-skill
29
29
  ```
30
30
 
31
- 需要 Node.js 16+,使用 Skill 内自包含脚本,无额外 npm 依赖。脚本从当前进程读取 `XYQ_ACCESS_KEY`;API 与 CLI 可使用同一 Access Key,但 CLI 浏览器登录不会设置此环境变量。密钥在本机安全配置,不写入请求 JSON 或命令参数。
31
+ 需要 Node.js 16+ 和支持 `marketing` 命令的 CLI。Skill 脚本通过原生 `marketing` 命令复用 CLI 登录态;先运行 `pippit-tool-cli status`,未登录时执行 `pippit-tool-cli login` 完成浏览器授权,无需用户提供 access_token 或 Access Key。生成请求通过 stdin 传入原生 CLI,凭据仅在 CLI 内用于鉴权。
32
32
 
33
33
  ```bash
34
34
  # 从仓库根目录执行;营销请求字段见接口契约,默认只预览
package/checksums.txt CHANGED
@@ -1,6 +1,6 @@
1
- 0023facaf65dc042d7dd08b285efbd046efd3dd4cebd29f31e3421b919d38bc9 pippit-tool-cli-1.0.31-darwin-amd64.tar.gz
2
- a6989ce590556924da7a2ceb5d94a8d75f81b459dae5fe016d0d929ae9c31a0f pippit-tool-cli-1.0.31-darwin-arm64.tar.gz
3
- 2ccc7bfb01fc00a0a7c2948abb18eda6da0769c7c1f21bca3db942a1e7b3a0b3 pippit-tool-cli-1.0.31-linux-amd64.tar.gz
4
- 2a6c10aada4090701a97ef8af69ad91abf4d0fda000634c344157487c7895da7 pippit-tool-cli-1.0.31-linux-arm64.tar.gz
5
- fcf2e3e988952e046e23a4505630d758e7471d6d7125541eba7d5882588b67cb pippit-tool-cli-1.0.31-windows-amd64.zip
6
- 5b9ce586c808866404c3d6e006c9ed359d1d8aecee105ac38e0015b79be78d64 pippit-tool-cli-1.0.31-windows-arm64.zip
1
+ 2f3c2a486a66e408a6d3841f19ecb909fbaa657636a469ee782a424e59596a68 pippit-tool-cli-1.0.32-darwin-amd64.tar.gz
2
+ f4ccd97a007167a9677ea85be52339a041f5448c4e4afd2ec996d32006ccec83 pippit-tool-cli-1.0.32-darwin-arm64.tar.gz
3
+ 91ae74eb2067844be741aa8a5f5f932152214c168d43efae801244d46e3f5cab pippit-tool-cli-1.0.32-linux-amd64.tar.gz
4
+ 1928d81ea0f11fa20ae65a689654a9d2dddc4cacb4e6a5ae41a5cdd35e40eac5 pippit-tool-cli-1.0.32-linux-arm64.tar.gz
5
+ 79283336e84c3196454aecf96c367383795636f4e153234a11996124a78dd7a5 pippit-tool-cli-1.0.32-windows-amd64.zip
6
+ aa73d963a3dd7d0eb5dba433fdf14d97510dfbe90515aab5318e55c0147d14d9 pippit-tool-cli-1.0.32-windows-arm64.zip
@@ -0,0 +1,144 @@
1
+ package cmd
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "io"
8
+ "mime"
9
+ "os"
10
+ "path/filepath"
11
+ "strings"
12
+ "time"
13
+
14
+ "github.com/Pippit-dev/pippit-cli/internal/common"
15
+ "github.com/Pippit-dev/pippit-cli/internal/config"
16
+ "github.com/spf13/cobra"
17
+ )
18
+
19
+ var marketingPaths = map[string]string{
20
+ "generate": "/api/biz/v1/agent/submit_marketing_run",
21
+ "query": "/api/biz/v1/agent/query_generate_video_result",
22
+ "upload": config.UploadFilePath,
23
+ "balance": config.GetCreditBalancePath,
24
+ }
25
+
26
+ // Marketing uses the same AuthManager as login/status and all other commands.
27
+ // Only fixed public endpoints are exposed; credentials never leave the CLI.
28
+ func newMarketingCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
29
+ root := &cobra.Command{Use: "marketing", Short: "Marketing API using the shared CLI login"}
30
+ root.SetOut(stdout)
31
+ root.SetErr(stderr)
32
+ for _, action := range []string{"generate", "query", "upload", "balance"} {
33
+ root.AddCommand(newMarketingAction(action, stdout, stderr, runner))
34
+ }
35
+ return root
36
+ }
37
+
38
+ func newMarketingAction(action string, stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
39
+ var requestFile, file, threadID, runID string
40
+ var execute bool
41
+ var timeout time.Duration
42
+ command := &cobra.Command{Use: action, Args: cobra.NoArgs, Short: "Call marketing " + action}
43
+ command.SetOut(stdout)
44
+ command.SetErr(stderr)
45
+ command.Flags().DurationVar(&timeout, "timeout", 60*time.Second, "request deadline (e.g. 60s)")
46
+ switch action {
47
+ case "generate":
48
+ command.Flags().StringVar(&requestFile, "request", "", "request JSON file, or - for stdin")
49
+ command.Flags().BoolVar(&execute, "execute", false, "submit generation; otherwise preview only")
50
+ case "query":
51
+ command.Flags().StringVar(&threadID, "thread-id", "", "marketing thread ID")
52
+ command.Flags().StringVar(&runID, "run-id", "", "marketing run ID")
53
+ case "upload":
54
+ command.Flags().StringVar(&file, "file", "", "local media file")
55
+ }
56
+ command.RunE = func(cmd *cobra.Command, _ []string) error {
57
+ if timeout <= 0 || timeout > 30*time.Minute {
58
+ return fmt.Errorf("timeout 必须大于 0 且不超过 30m")
59
+ }
60
+ var body any = map[string]any{}
61
+ switch action {
62
+ case "generate":
63
+ if requestFile == "" {
64
+ return fmt.Errorf("缺少必填参数 --request")
65
+ }
66
+ reader := cmd.InOrStdin()
67
+ if requestFile != "-" {
68
+ f, err := os.Open(requestFile)
69
+ if err != nil {
70
+ return err
71
+ }
72
+ defer f.Close()
73
+ reader = f
74
+ }
75
+ var value map[string]json.RawMessage
76
+ decoder := json.NewDecoder(io.LimitReader(reader, 8*1024*1024+1))
77
+ if err := decoder.Decode(&value); err != nil {
78
+ return fmt.Errorf("请求 JSON 无效: %w", err)
79
+ }
80
+ var extra any
81
+ if err := decoder.Decode(&extra); err != io.EOF {
82
+ return fmt.Errorf("请求只能包含一个 JSON 对象")
83
+ }
84
+ for key := range value {
85
+ if key != "message" && key != "asset_ids" && key != "thread_id" && key != "general_agent_settings" {
86
+ return fmt.Errorf("未支持的营销请求字段: %s", key)
87
+ }
88
+ }
89
+ var message string
90
+ var settings struct {
91
+ VideoModel string `json:"video_model"`
92
+ }
93
+ if json.Unmarshal(value["message"], &message) != nil || strings.TrimSpace(message) == "" {
94
+ return fmt.Errorf("message 必须为非空字符串")
95
+ }
96
+ if json.Unmarshal(value["general_agent_settings"], &settings) != nil || strings.TrimSpace(settings.VideoModel) == "" {
97
+ return fmt.Errorf("general_agent_settings.video_model 必填")
98
+ }
99
+ body = value
100
+ if !execute {
101
+ return common.WriteJSON(stdout, map[string]any{"dry_run": true, "url": config.DefaultBaseURL + marketingPaths[action], "body": body})
102
+ }
103
+ case "query":
104
+ if strings.TrimSpace(threadID) == "" || strings.TrimSpace(runID) == "" {
105
+ return fmt.Errorf("query 需要 --thread-id 和 --run-id")
106
+ }
107
+ body = map[string]string{"thread_id": threadID, "run_id": runID}
108
+ case "upload":
109
+ if err := validateMediaUpload(file); err != nil {
110
+ return err
111
+ }
112
+ info, err := os.Stat(file)
113
+ if err != nil {
114
+ return err
115
+ }
116
+ if info.Size() == 0 {
117
+ return fmt.Errorf("上传需要非空文件")
118
+ }
119
+ }
120
+ ctx, cancel := context.WithTimeout(cmd.Context(), timeout)
121
+ defer cancel()
122
+ client := common.NewNonRedirectingHTTPClient(runner.Config.BaseURL, timeout, newRunnerAuthorizer(runner))
123
+ var result map[string]json.RawMessage
124
+ var err error
125
+ if action == "upload" {
126
+ contentType := mime.TypeByExtension(strings.ToLower(filepath.Ext(file)))
127
+ err = client.SendMultipartRequest(ctx, marketingPaths[action], nil, common.MultipartFile{FieldName: "file", Path: file, ContentType: contentType}, &result)
128
+ } else {
129
+ err = client.SendRequest(ctx, marketingPaths[action], body, &result)
130
+ }
131
+ if err != nil {
132
+ return err
133
+ }
134
+ ret := strings.TrimSpace(string(result["ret"]))
135
+ if ret != `"0"` && ret != "0" {
136
+ var message, logID string
137
+ _ = json.Unmarshal(result["errmsg"], &message)
138
+ _ = json.Unmarshal(result["log_id"], &logID)
139
+ return common.NewLogIDError(fmt.Sprintf("营销 API 失败: ret=%s errmsg=%s", ret, message), logID)
140
+ }
141
+ return common.WriteJSON(stdout, result)
142
+ }
143
+ return command
144
+ }
@@ -0,0 +1,179 @@
1
+ package cmd
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "fmt"
7
+ "io"
8
+ "net/http"
9
+ "net/http/httptest"
10
+ "os"
11
+ "path/filepath"
12
+ "strings"
13
+ "testing"
14
+ "time"
15
+
16
+ "github.com/Pippit-dev/pippit-cli/internal/auth"
17
+ "github.com/Pippit-dev/pippit-cli/internal/config"
18
+ )
19
+
20
+ type marketingCredentialStore struct {
21
+ auth.CredentialStore
22
+ credential *auth.Credential
23
+ loads int
24
+ }
25
+
26
+ func (s *marketingCredentialStore) Load(context.Context) (*auth.Credential, error) {
27
+ s.loads++
28
+ if s.credential == nil {
29
+ return nil, auth.ErrCredentialNotFound
30
+ }
31
+ return s.credential, nil
32
+ }
33
+
34
+ const marketingRequest = `{"message":"make an ad","general_agent_settings":{"video_model":"chosen-model","show_subtitle":false}}`
35
+
36
+ func TestMarketingUsesSharedBrowserAuth(t *testing.T) {
37
+ for _, action := range []string{"generate", "query", "upload", "balance"} {
38
+ t.Run(action, func(t *testing.T) {
39
+ calls := 0
40
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
41
+ calls++
42
+ if r.Header.Get("Authorization") != "Bearer browser-secret" {
43
+ t.Error("browser login not reused")
44
+ }
45
+ if r.URL.Path != marketingPaths[action] || r.Method != "POST" {
46
+ t.Error("wrong endpoint")
47
+ }
48
+ data, _ := io.ReadAll(r.Body)
49
+ if bytes.Contains(data, []byte("browser-secret")) {
50
+ t.Error("credential leaked into payload")
51
+ }
52
+ if action == "upload" && !bytes.Contains(data, []byte(`name="file"`)) {
53
+ t.Error("missing multipart file")
54
+ }
55
+ fmt.Fprint(w, `{"ret":"0","log_id":"log-test","data":{"thread_id":"thread","run_id":"run"}}`)
56
+ }))
57
+ defer server.Close()
58
+ cfg := config.Load()
59
+ cfg.BaseURL = server.URL
60
+ cfg.AccessKey = ""
61
+ store := &marketingCredentialStore{credential: &auth.Credential{AccessKey: "browser-secret", UID: "user", DeviceID: "device", ExpiredAt: time.Now().Add(time.Hour).Unix()}}
62
+ runner := newRootRunner(cfg)
63
+ runner.Auth = auth.NewManager(cfg, auth.WithCredentialStore(store))
64
+ var output bytes.Buffer
65
+ root := newRootCommand(&output, io.Discard, runner)
66
+ args := []string{"marketing", action}
67
+ switch action {
68
+ case "generate":
69
+ args = append(args, "--request", "-", "--execute")
70
+ root.SetIn(strings.NewReader(marketingRequest))
71
+ case "query":
72
+ args = append(args, "--thread-id", "thread", "--run-id", "run")
73
+ case "upload":
74
+ file := filepath.Join(t.TempDir(), "product.png")
75
+ if err := os.WriteFile(file, []byte("image"), 0600); err != nil {
76
+ t.Fatal(err)
77
+ }
78
+ args = append(args, "--file", file)
79
+ }
80
+ root.SetArgs(args)
81
+ if err := root.Execute(); err != nil {
82
+ t.Fatal(err)
83
+ }
84
+ if calls != 1 || store.loads != 1 {
85
+ t.Fatalf("HTTP calls=%d credential loads=%d", calls, store.loads)
86
+ }
87
+ if strings.Contains(output.String(), "browser-secret") || !strings.Contains(output.String(), "log-test") {
88
+ t.Fatal("raw result or credential boundary broken")
89
+ }
90
+ })
91
+ }
92
+ }
93
+
94
+ func TestMarketingAuthFailureAndEnvironmentPrecedence(t *testing.T) {
95
+ for _, scenario := range []string{"missing", "expired", "override"} {
96
+ t.Run(scenario, func(t *testing.T) {
97
+ calls := 0
98
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
99
+ calls++
100
+ if r.Header.Get("Authorization") != "Bearer explicit-key" {
101
+ t.Error("environment override not preserved")
102
+ }
103
+ fmt.Fprint(w, `{"ret":"0","data":{}}`)
104
+ }))
105
+ defer server.Close()
106
+ cfg := config.Load()
107
+ cfg.BaseURL = server.URL
108
+ cfg.AccessKey = ""
109
+ store := &marketingCredentialStore{}
110
+ if scenario == "expired" {
111
+ store.credential = &auth.Credential{AccessKey: "expired", ExpiredAt: 1}
112
+ }
113
+ if scenario == "override" {
114
+ cfg.AccessKey = "explicit-key"
115
+ }
116
+ runner := newRootRunner(cfg)
117
+ runner.Auth = auth.NewManager(cfg, auth.WithCredentialStore(store))
118
+ root := newRootCommand(io.Discard, io.Discard, runner)
119
+ root.SetArgs([]string{"marketing", "balance"})
120
+ err := root.Execute()
121
+ if scenario == "override" {
122
+ if err != nil || calls != 1 || store.loads != 0 {
123
+ t.Fatalf("override: err=%v calls=%d loads=%d", err, calls, store.loads)
124
+ }
125
+ } else if err == nil || !strings.Contains(err.Error(), "pippit-tool-cli login") || calls != 0 {
126
+ t.Fatalf("missing/expired: err=%v calls=%d", err, calls)
127
+ }
128
+ })
129
+ }
130
+ }
131
+
132
+ func TestMarketingPreviewAndHelpDoNotReadCredentials(t *testing.T) {
133
+ for _, args := range [][]string{{"marketing", "--help"}, {"marketing", "generate", "--request", "-"}, {"marketing", "query"}} {
134
+ cfg := config.Load()
135
+ cfg.AccessKey = ""
136
+ store := &marketingCredentialStore{}
137
+ runner := newRootRunner(cfg)
138
+ runner.Auth = auth.NewManager(cfg, auth.WithCredentialStore(store))
139
+ root := newRootCommand(io.Discard, io.Discard, runner)
140
+ root.SetIn(strings.NewReader(marketingRequest))
141
+ root.SetArgs(args)
142
+ err := root.Execute()
143
+ if args[1] != "query" && err != nil {
144
+ t.Fatal(err)
145
+ }
146
+ if args[1] == "query" && err == nil {
147
+ t.Fatal("missing IDs accepted")
148
+ }
149
+ if store.loads != 0 {
150
+ t.Fatal("offline operation accessed credentials")
151
+ }
152
+ }
153
+ }
154
+
155
+ func TestMarketingDoesNotReplaySubmission(t *testing.T) {
156
+ for _, status := range []int{302, 307, 504} {
157
+ t.Run(fmt.Sprint(status), func(t *testing.T) {
158
+ calls := 0
159
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
160
+ calls++
161
+ w.Header().Set("Location", "/unexpected")
162
+ w.WriteHeader(status)
163
+ }))
164
+ defer server.Close()
165
+ cfg := config.Load()
166
+ cfg.BaseURL = server.URL
167
+ cfg.AccessKey = "test-key"
168
+ root := newRootCommand(io.Discard, io.Discard, newRootRunner(cfg))
169
+ root.SetIn(strings.NewReader(marketingRequest))
170
+ root.SetArgs([]string{"marketing", "generate", "--request", "-", "--execute"})
171
+ if err := root.Execute(); err == nil {
172
+ t.Fatal("failed/redirected request succeeded")
173
+ }
174
+ if calls != 1 {
175
+ t.Fatalf("submission replayed %d times", calls)
176
+ }
177
+ })
178
+ }
179
+ }
package/cmd/root.go CHANGED
@@ -73,6 +73,7 @@ func newRootCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Comm
73
73
  root.AddCommand(newDownloadResultCommand(stdout, stderr, runner))
74
74
  root.AddCommand(newGetCreditBalanceCommand(stdout, stderr, runner))
75
75
  root.AddCommand(newModelCommand(stdout, stderr, runner))
76
+ root.AddCommand(newMarketingCommand(stdout, stderr, runner))
76
77
  root.AddCommand(newGetThreadCommand(stdout, stderr, runner))
77
78
  root.AddCommand(newSubmitRunCommand(stdout, stderr, runner))
78
79
  root.AddCommand(newUploadFileCommand(stdout, stderr, runner))
@@ -1,6 +1,6 @@
1
- 0023facaf65dc042d7dd08b285efbd046efd3dd4cebd29f31e3421b919d38bc9 pippit-tool-cli-1.0.31-darwin-amd64.tar.gz
2
- a6989ce590556924da7a2ceb5d94a8d75f81b459dae5fe016d0d929ae9c31a0f pippit-tool-cli-1.0.31-darwin-arm64.tar.gz
3
- 2ccc7bfb01fc00a0a7c2948abb18eda6da0769c7c1f21bca3db942a1e7b3a0b3 pippit-tool-cli-1.0.31-linux-amd64.tar.gz
4
- 2a6c10aada4090701a97ef8af69ad91abf4d0fda000634c344157487c7895da7 pippit-tool-cli-1.0.31-linux-arm64.tar.gz
5
- fcf2e3e988952e046e23a4505630d758e7471d6d7125541eba7d5882588b67cb pippit-tool-cli-1.0.31-windows-amd64.zip
6
- 5b9ce586c808866404c3d6e006c9ed359d1d8aecee105ac38e0015b79be78d64 pippit-tool-cli-1.0.31-windows-arm64.zip
1
+ 2f3c2a486a66e408a6d3841f19ecb909fbaa657636a469ee782a424e59596a68 pippit-tool-cli-1.0.32-darwin-amd64.tar.gz
2
+ f4ccd97a007167a9677ea85be52339a041f5448c4e4afd2ec996d32006ccec83 pippit-tool-cli-1.0.32-darwin-arm64.tar.gz
3
+ 91ae74eb2067844be741aa8a5f5f932152214c168d43efae801244d46e3f5cab pippit-tool-cli-1.0.32-linux-amd64.tar.gz
4
+ 1928d81ea0f11fa20ae65a689654a9d2dddc4cacb4e6a5ae41a5cdd35e40eac5 pippit-tool-cli-1.0.32-linux-arm64.tar.gz
5
+ 79283336e84c3196454aecf96c367383795636f4e153234a11996124a78dd7a5 pippit-tool-cli-1.0.32-windows-amd64.zip
6
+ aa73d963a3dd7d0eb5dba433fdf14d97510dfbe90515aab5318e55c0147d14d9 pippit-tool-cli-1.0.32-windows-arm64.zip
@@ -46,7 +46,17 @@ func NewHTTPClient(baseURL string, timeout time.Duration, authorizer RequestAuth
46
46
  return newHTTPClient(baseURL, timeout, authorizer)
47
47
  }
48
48
 
49
- func newHTTPClient(baseURL string, timeout time.Duration, authorizer RequestAuthorizer) Client {
49
+ // NewNonRedirectingHTTPClient keeps one-shot submissions from being replayed.
50
+ // Authentication and request handling still use the shared CLI client.
51
+ func NewNonRedirectingHTTPClient(baseURL string, timeout time.Duration, authorizer RequestAuthorizer) Client {
52
+ client := newHTTPClient(baseURL, timeout, authorizer)
53
+ client.httpClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
54
+ return fmt.Errorf("拒绝营销 API 重定向;请求未重试")
55
+ }
56
+ return client
57
+ }
58
+
59
+ func newHTTPClient(baseURL string, timeout time.Duration, authorizer RequestAuthorizer) *httpClient {
50
60
  client := &httpClient{
51
61
  baseURL: strings.TrimRight(baseURL, "/"),
52
62
  headers: make(http.Header),
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.32",
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 };