From 79601409db1647b6bd2ec2a53b2d8fe69b75832e Mon Sep 17 00:00:00 2001 From: liuyu345 Date: Wed, 23 Sep 2026 13:40:37 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=90=A5=E9=94=80=20Skill=20=E5=A4=8D?= =?UTF-8?q?=E7=94=A8=20CLI=20=E7=BD=91=E9=A1=B5=E7=99=BB=E5=BD=95=E5=87=AD?= =?UTF-8?q?=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- cmd/marketing.go | 144 ++++++++++++++ cmd/marketing_test.go | 179 ++++++++++++++++++ cmd/root.go | 1 + internal/common/client.go | 12 +- scripts/marketing-skill.test.js | 109 ++++++----- skills/xyq-marketing-skill/SKILL.md | 14 +- .../examples/product-video.md | 2 +- skills/xyq-marketing-skill/references/api.md | 4 +- .../xyq-marketing-skill/scripts/marketing.js | 114 +++++++---- 10 files changed, 481 insertions(+), 100 deletions(-) create mode 100644 cmd/marketing.go create mode 100644 cmd/marketing_test.go diff --git a/README.md b/README.md index fb439fe..3456694 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ npx skills add Pippit-dev/cli --skill xyq-marketing-skill ``` -需要 Node.js 16+,使用 Skill 内自包含脚本,无额外 npm 依赖。脚本从当前进程读取 `XYQ_ACCESS_KEY`;API 与 CLI 可使用同一 Access Key,但 CLI 浏览器登录不会设置此环境变量。密钥在本机安全配置,不写入请求 JSON 或命令参数。 +需要 Node.js 16+ 和支持 `marketing` 命令的 CLI。Skill 脚本通过原生 `marketing` 命令复用 CLI 登录态;先运行 `pippit-tool-cli status`,未登录时执行 `pippit-tool-cli login` 完成浏览器授权,无需用户提供 access_token 或 Access Key。生成请求通过 stdin 传入原生 CLI,凭据仅在 CLI 内用于鉴权。 ```bash # 从仓库根目录执行;营销请求字段见接口契约,默认只预览 diff --git a/cmd/marketing.go b/cmd/marketing.go new file mode 100644 index 0000000..b9121c5 --- /dev/null +++ b/cmd/marketing.go @@ -0,0 +1,144 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "io" + "mime" + "os" + "path/filepath" + "strings" + "time" + + "github.com/Pippit-dev/pippit-cli/internal/common" + "github.com/Pippit-dev/pippit-cli/internal/config" + "github.com/spf13/cobra" +) + +var marketingPaths = map[string]string{ + "generate": "/api/biz/v1/agent/submit_marketing_run", + "query": "/api/biz/v1/agent/query_generate_video_result", + "upload": config.UploadFilePath, + "balance": config.GetCreditBalancePath, +} + +// Marketing uses the same AuthManager as login/status and all other commands. +// Only fixed public endpoints are exposed; credentials never leave the CLI. +func newMarketingCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command { + root := &cobra.Command{Use: "marketing", Short: "Marketing API using the shared CLI login"} + root.SetOut(stdout) + root.SetErr(stderr) + for _, action := range []string{"generate", "query", "upload", "balance"} { + root.AddCommand(newMarketingAction(action, stdout, stderr, runner)) + } + return root +} + +func newMarketingAction(action string, stdout, stderr io.Writer, runner *common.Runner) *cobra.Command { + var requestFile, file, threadID, runID string + var execute bool + var timeout time.Duration + command := &cobra.Command{Use: action, Args: cobra.NoArgs, Short: "Call marketing " + action} + command.SetOut(stdout) + command.SetErr(stderr) + command.Flags().DurationVar(&timeout, "timeout", 60*time.Second, "request deadline (e.g. 60s)") + switch action { + case "generate": + command.Flags().StringVar(&requestFile, "request", "", "request JSON file, or - for stdin") + command.Flags().BoolVar(&execute, "execute", false, "submit generation; otherwise preview only") + case "query": + command.Flags().StringVar(&threadID, "thread-id", "", "marketing thread ID") + command.Flags().StringVar(&runID, "run-id", "", "marketing run ID") + case "upload": + command.Flags().StringVar(&file, "file", "", "local media file") + } + command.RunE = func(cmd *cobra.Command, _ []string) error { + if timeout <= 0 || timeout > 30*time.Minute { + return fmt.Errorf("timeout 必须大于 0 且不超过 30m") + } + var body any = map[string]any{} + switch action { + case "generate": + if requestFile == "" { + return fmt.Errorf("缺少必填参数 --request") + } + reader := cmd.InOrStdin() + if requestFile != "-" { + f, err := os.Open(requestFile) + if err != nil { + return err + } + defer f.Close() + reader = f + } + var value map[string]json.RawMessage + decoder := json.NewDecoder(io.LimitReader(reader, 8*1024*1024+1)) + if err := decoder.Decode(&value); err != nil { + return fmt.Errorf("请求 JSON 无效: %w", err) + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + return fmt.Errorf("请求只能包含一个 JSON 对象") + } + for key := range value { + if key != "message" && key != "asset_ids" && key != "thread_id" && key != "general_agent_settings" { + return fmt.Errorf("未支持的营销请求字段: %s", key) + } + } + var message string + var settings struct { + VideoModel string `json:"video_model"` + } + if json.Unmarshal(value["message"], &message) != nil || strings.TrimSpace(message) == "" { + return fmt.Errorf("message 必须为非空字符串") + } + if json.Unmarshal(value["general_agent_settings"], &settings) != nil || strings.TrimSpace(settings.VideoModel) == "" { + return fmt.Errorf("general_agent_settings.video_model 必填") + } + body = value + if !execute { + return common.WriteJSON(stdout, map[string]any{"dry_run": true, "url": config.DefaultBaseURL + marketingPaths[action], "body": body}) + } + case "query": + if strings.TrimSpace(threadID) == "" || strings.TrimSpace(runID) == "" { + return fmt.Errorf("query 需要 --thread-id 和 --run-id") + } + body = map[string]string{"thread_id": threadID, "run_id": runID} + case "upload": + if err := validateMediaUpload(file); err != nil { + return err + } + info, err := os.Stat(file) + if err != nil { + return err + } + if info.Size() == 0 { + return fmt.Errorf("上传需要非空文件") + } + } + ctx, cancel := context.WithTimeout(cmd.Context(), timeout) + defer cancel() + client := common.NewNonRedirectingHTTPClient(runner.Config.BaseURL, timeout, newRunnerAuthorizer(runner)) + var result map[string]json.RawMessage + var err error + if action == "upload" { + contentType := mime.TypeByExtension(strings.ToLower(filepath.Ext(file))) + err = client.SendMultipartRequest(ctx, marketingPaths[action], nil, common.MultipartFile{FieldName: "file", Path: file, ContentType: contentType}, &result) + } else { + err = client.SendRequest(ctx, marketingPaths[action], body, &result) + } + if err != nil { + return err + } + ret := strings.TrimSpace(string(result["ret"])) + if ret != `"0"` && ret != "0" { + var message, logID string + _ = json.Unmarshal(result["errmsg"], &message) + _ = json.Unmarshal(result["log_id"], &logID) + return common.NewLogIDError(fmt.Sprintf("营销 API 失败: ret=%s errmsg=%s", ret, message), logID) + } + return common.WriteJSON(stdout, result) + } + return command +} diff --git a/cmd/marketing_test.go b/cmd/marketing_test.go new file mode 100644 index 0000000..854fac3 --- /dev/null +++ b/cmd/marketing_test.go @@ -0,0 +1,179 @@ +package cmd + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Pippit-dev/pippit-cli/internal/auth" + "github.com/Pippit-dev/pippit-cli/internal/config" +) + +type marketingCredentialStore struct { + auth.CredentialStore + credential *auth.Credential + loads int +} + +func (s *marketingCredentialStore) Load(context.Context) (*auth.Credential, error) { + s.loads++ + if s.credential == nil { + return nil, auth.ErrCredentialNotFound + } + return s.credential, nil +} + +const marketingRequest = `{"message":"make an ad","general_agent_settings":{"video_model":"chosen-model","show_subtitle":false}}` + +func TestMarketingUsesSharedBrowserAuth(t *testing.T) { + for _, action := range []string{"generate", "query", "upload", "balance"} { + t.Run(action, func(t *testing.T) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.Header.Get("Authorization") != "Bearer browser-secret" { + t.Error("browser login not reused") + } + if r.URL.Path != marketingPaths[action] || r.Method != "POST" { + t.Error("wrong endpoint") + } + data, _ := io.ReadAll(r.Body) + if bytes.Contains(data, []byte("browser-secret")) { + t.Error("credential leaked into payload") + } + if action == "upload" && !bytes.Contains(data, []byte(`name="file"`)) { + t.Error("missing multipart file") + } + fmt.Fprint(w, `{"ret":"0","log_id":"log-test","data":{"thread_id":"thread","run_id":"run"}}`) + })) + defer server.Close() + cfg := config.Load() + cfg.BaseURL = server.URL + cfg.AccessKey = "" + store := &marketingCredentialStore{credential: &auth.Credential{AccessKey: "browser-secret", UID: "user", DeviceID: "device", ExpiredAt: time.Now().Add(time.Hour).Unix()}} + runner := newRootRunner(cfg) + runner.Auth = auth.NewManager(cfg, auth.WithCredentialStore(store)) + var output bytes.Buffer + root := newRootCommand(&output, io.Discard, runner) + args := []string{"marketing", action} + switch action { + case "generate": + args = append(args, "--request", "-", "--execute") + root.SetIn(strings.NewReader(marketingRequest)) + case "query": + args = append(args, "--thread-id", "thread", "--run-id", "run") + case "upload": + file := filepath.Join(t.TempDir(), "product.png") + if err := os.WriteFile(file, []byte("image"), 0600); err != nil { + t.Fatal(err) + } + args = append(args, "--file", file) + } + root.SetArgs(args) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if calls != 1 || store.loads != 1 { + t.Fatalf("HTTP calls=%d credential loads=%d", calls, store.loads) + } + if strings.Contains(output.String(), "browser-secret") || !strings.Contains(output.String(), "log-test") { + t.Fatal("raw result or credential boundary broken") + } + }) + } +} + +func TestMarketingAuthFailureAndEnvironmentPrecedence(t *testing.T) { + for _, scenario := range []string{"missing", "expired", "override"} { + t.Run(scenario, func(t *testing.T) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.Header.Get("Authorization") != "Bearer explicit-key" { + t.Error("environment override not preserved") + } + fmt.Fprint(w, `{"ret":"0","data":{}}`) + })) + defer server.Close() + cfg := config.Load() + cfg.BaseURL = server.URL + cfg.AccessKey = "" + store := &marketingCredentialStore{} + if scenario == "expired" { + store.credential = &auth.Credential{AccessKey: "expired", ExpiredAt: 1} + } + if scenario == "override" { + cfg.AccessKey = "explicit-key" + } + runner := newRootRunner(cfg) + runner.Auth = auth.NewManager(cfg, auth.WithCredentialStore(store)) + root := newRootCommand(io.Discard, io.Discard, runner) + root.SetArgs([]string{"marketing", "balance"}) + err := root.Execute() + if scenario == "override" { + if err != nil || calls != 1 || store.loads != 0 { + t.Fatalf("override: err=%v calls=%d loads=%d", err, calls, store.loads) + } + } else if err == nil || !strings.Contains(err.Error(), "pippit-tool-cli login") || calls != 0 { + t.Fatalf("missing/expired: err=%v calls=%d", err, calls) + } + }) + } +} + +func TestMarketingPreviewAndHelpDoNotReadCredentials(t *testing.T) { + for _, args := range [][]string{{"marketing", "--help"}, {"marketing", "generate", "--request", "-"}, {"marketing", "query"}} { + cfg := config.Load() + cfg.AccessKey = "" + store := &marketingCredentialStore{} + runner := newRootRunner(cfg) + runner.Auth = auth.NewManager(cfg, auth.WithCredentialStore(store)) + root := newRootCommand(io.Discard, io.Discard, runner) + root.SetIn(strings.NewReader(marketingRequest)) + root.SetArgs(args) + err := root.Execute() + if args[1] != "query" && err != nil { + t.Fatal(err) + } + if args[1] == "query" && err == nil { + t.Fatal("missing IDs accepted") + } + if store.loads != 0 { + t.Fatal("offline operation accessed credentials") + } + } +} + +func TestMarketingDoesNotReplaySubmission(t *testing.T) { + for _, status := range []int{302, 307, 504} { + t.Run(fmt.Sprint(status), func(t *testing.T) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + w.Header().Set("Location", "/unexpected") + w.WriteHeader(status) + })) + defer server.Close() + cfg := config.Load() + cfg.BaseURL = server.URL + cfg.AccessKey = "test-key" + root := newRootCommand(io.Discard, io.Discard, newRootRunner(cfg)) + root.SetIn(strings.NewReader(marketingRequest)) + root.SetArgs([]string{"marketing", "generate", "--request", "-", "--execute"}) + if err := root.Execute(); err == nil { + t.Fatal("failed/redirected request succeeded") + } + if calls != 1 { + t.Fatalf("submission replayed %d times", calls) + } + }) + } +} diff --git a/cmd/root.go b/cmd/root.go index a768fa3..576d1d0 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -73,6 +73,7 @@ func newRootCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Comm root.AddCommand(newDownloadResultCommand(stdout, stderr, runner)) root.AddCommand(newGetCreditBalanceCommand(stdout, stderr, runner)) root.AddCommand(newModelCommand(stdout, stderr, runner)) + root.AddCommand(newMarketingCommand(stdout, stderr, runner)) root.AddCommand(newGetThreadCommand(stdout, stderr, runner)) root.AddCommand(newSubmitRunCommand(stdout, stderr, runner)) root.AddCommand(newUploadFileCommand(stdout, stderr, runner)) diff --git a/internal/common/client.go b/internal/common/client.go index b81ab32..9559449 100644 --- a/internal/common/client.go +++ b/internal/common/client.go @@ -46,7 +46,17 @@ func NewHTTPClient(baseURL string, timeout time.Duration, authorizer RequestAuth return newHTTPClient(baseURL, timeout, authorizer) } -func newHTTPClient(baseURL string, timeout time.Duration, authorizer RequestAuthorizer) Client { +// NewNonRedirectingHTTPClient keeps one-shot submissions from being replayed. +// Authentication and request handling still use the shared CLI client. +func NewNonRedirectingHTTPClient(baseURL string, timeout time.Duration, authorizer RequestAuthorizer) Client { + client := newHTTPClient(baseURL, timeout, authorizer) + client.httpClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { + return fmt.Errorf("拒绝营销 API 重定向;请求未重试") + } + return client +} + +func newHTTPClient(baseURL string, timeout time.Duration, authorizer RequestAuthorizer) *httpClient { client := &httpClient{ baseURL: strings.TrimRight(baseURL, "/"), headers: make(http.Header), diff --git a/scripts/marketing-skill.test.js b/scripts/marketing-skill.test.js index 038fe52..7409480 100644 --- a/scripts/marketing-skill.test.js +++ b/scripts/marketing-skill.test.js @@ -4,7 +4,7 @@ const os = require('os'); const path = require('path'); const http = require('http'); const { spawnSync } = require('child_process'); -const { BASE, PATHS, validate, createClient, main } = require('../skills/xyq-marketing-skill/scripts/marketing'); +const { BASE, PATHS, validate, checkResponse, resolveCLI, invokeCLI, createClient, main } = require('../skills/xyq-marketing-skill/scripts/marketing'); async function test() { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'xyq-marketing-')); @@ -23,7 +23,9 @@ async function test() { targets.push(url.href); return http.request({ hostname: '127.0.0.1', port: server.address().port, path: url.pathname + url.search, ...opts }, callback); }; - const client = createClient({ key: 'test-secret', request, timeout: 1000 }); + const calls = []; + let cliResult; + const client = createClient({ request, timeout: 1000, invoke: async (...args) => { calls.push(args); return cliResult; } }); const json = value => { respond = (_, res) => { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(value)); }; }; const ids = { thread_id: 'marketing-thread', run_id: 'marketing-run' }; const response = (state, extra = {}) => ({ ret: '0', data: { ...ids, run_state: state, ...extra } }); @@ -57,55 +59,62 @@ async function test() { await assert.rejects(main(['query', '--thread-id', ids.thread_id]), /run-id/); await assert.rejects(main(['query', '--thread-id', ids.thread_id, '--run-id', ids.run_id, '--timeout', '0']), /timeout/); await assert.rejects(main(['generate', '--request', file, '--source', 'codex']), /无效/); - await assert.rejects(createClient({ key: '', request }).api('balance', {}), /XYQ_ACCESS_KEY/); assert.strictEqual(received.length, 0); - json({ ret: 0, log_id: 'log-submit', data: { run: { ...ids, state: 1 }, web_thread_link: 'https://xyq.jianying.com/task' } }); + cliResult = { ret: 0, log_id: 'log-submit', data: { run: { ...ids, state: 1 } } }; assert.strictEqual(await main(['generate', '--request', file, '--execute'], { out: () => {}, clientFactory: () => client }), 0); - assert.strictEqual(received.length, 1); - assert.deepStrictEqual(JSON.parse(received[0].body), body); - assert.strictEqual(received[0].url, PATHS.generate); - assert.strictEqual(received[0].headers.authorization, 'Bearer test-secret'); - assert.strictEqual(targets[0], BASE + PATHS.generate); - - const uploadFile = path.join(dir, '商品.png'); - const imageBytes = Buffer.from([0, 1, 2, 255, 13, 10]); - fs.writeFileSync(uploadFile, imageBytes); - json({ ret: '0', data: { pippit_asset_id: 'asset-real' } }); + assert.deepStrictEqual(calls[0][0], ['marketing', 'generate', '--timeout', '1000ms', '--request', '-', '--execute']); + assert.deepStrictEqual(JSON.parse(calls[0][1]), body); + assert(!calls[0][0].includes(body.message), 'request must use stdin, not command arguments'); + assert.strictEqual(received.length, 0, 'Node must never make authenticated API requests'); + const uploadFile = path.join(dir, '商品 with spaces.png'); + fs.writeFileSync(uploadFile, Buffer.from([0, 1, 2, 255])); + cliResult = { ret: '0', data: { pippit_asset_id: 'asset-real' } }; assert.strictEqual((await client.upload(uploadFile)).data.pippit_asset_id, 'asset-real'); - const upload = received[received.length - 1]; - assert.strictEqual(upload.url, PATHS.upload); - assert(upload.body.includes(imageBytes)); - assert(upload.body.includes(Buffer.from('name="file"'))); - assert(upload.headers['content-type'].startsWith('multipart/form-data; boundary=')); - assert.strictEqual(Number(upload.headers['content-length']), upload.body.length); - assert(!upload.body.includes(Buffer.from('test-secret'))); + assert.deepStrictEqual(calls[calls.length - 1][0], ['marketing', 'upload', '--file', uploadFile, '--timeout', '1000ms']); await assert.rejects(client.upload(dir), /非空文件/); - - json({ ret: '0', data: { total_remain_amount: '0' } }); + cliResult = { ret: '0', data: { total_remain_amount: '0' } }; assert.strictEqual((await client.api('balance', {})).data.total_remain_amount, '0'); - json({ ret: '12004', errmsg: 'permission denied', log_id: 'log-failure' }); - await assert.rejects(client.api('generate', body), /12004.*permission denied.*log-failure/); - json({ ret: '0', data: { run: { thread_id: ids.thread_id } } }); - await assert.rejects(client.api('generate', body), /禁止自动重提/); - json({ ret: false, data: {} }); - await assert.rejects(client.api('balance', {}), /API 失败/); - json(response('3', { run_id: 'different' })); - await assert.rejects(client.api('query', ids), /不一致/); - json(response('unknown')); - await assert.rejects(client.api('query', ids), /run_state 缺失或格式异常/); - respond = (_, res) => { res.writeHead(302, { Location: 'https://other.example/secret' }); res.end(); }; - let before = received.length; - await assert.rejects(client.api('generate', body), /HTTP 302/); - assert.strictEqual(received.length - before, 1, 'API redirect must not be followed'); - respond = (_, res) => { res.writeHead(504); res.end(); }; - before = received.length; - await assert.rejects(client.api('generate', body), /HTTP 504/); - assert.strictEqual(received.length - before, 1, 'submission must not retry'); - respond = (_, res) => res.end('not json'); - await assert.rejects(client.api('generate', body), /有效 JSON/); - respond = () => {}; - await assert.rejects(createClient({ key: 'test-secret', request, timeout: 20 }).api('generate', body), /超时/); + cliResult = response('3', { video_urls: ['https://cdn.example/final.mp4'] }); + await client.api('query', ids); + assert.deepStrictEqual(calls[calls.length - 1][0], ['marketing', 'query', '--timeout', '1000ms', '--thread-id', ids.thread_id, '--run-id', ids.run_id]); + assert.throws(() => checkResponse('generate', { ret: '0', data: { run: {} } }), /禁止自动重提/); + assert.throws(() => checkResponse('query', response('unknown'), ids), /run_state/); + assert.throws(() => checkResponse('query', response(3, { run_id: 'other' }), ids), /不一致/); + assert.throws(() => checkResponse('balance', { ret: false, data: {} }), /API 失败/); + let failedCalls = 0; + const failedClient = createClient({ invoke: async () => { failedCalls++; throw new Error('请先运行 pippit-tool-cli login'); } }); + await assert.rejects(failedClient.api('generate', body), /pippit-tool-cli login/); + assert.strictEqual(failedCalls, 1, 'failed submission must never retry'); + + // Installed npm layouts must work without cmd.exe/shell interpolation. + for (const platform of ['linux', 'win32']) { + const prefix = path.join(dir, platform); + const packageRoot = path.join(prefix, 'node_modules/@pippit-dev/cli'); + const binary = path.join(packageRoot, 'bin', platform === 'win32' ? 'pippit-tool-cli.exe' : 'pippit-tool-cli'); + fs.mkdirSync(path.dirname(binary), { recursive: true }); + fs.writeFileSync(path.join(packageRoot, 'package.json'), JSON.stringify({ name: '@pippit-dev/cli' })); + fs.writeFileSync(binary, 'fixture'); + assert.strictEqual(resolveCLI({ platform, packageRoot, searchPath: '' }).command, binary); + if (platform === 'win32') { + assert.strictEqual(resolveCLI({ platform, packageRoot: dir, searchPath: prefix }).command, binary); + } + } + assert.throws(() => resolveCLI({ packageRoot: dir, searchPath: '' }), /pippit-tool-cli login/); + + // Real subprocess bridge: stdin, arguments, bounded wait and login guidance. + const fixture = path.join(dir, 'fake cli.js'); + fs.writeFileSync(fixture, `let input = ''; process.stdin.on('data', c => input += c); process.stdin.on('end', () => console.log(JSON.stringify({args: process.argv.slice(2), input})));`); + const invocation = { command: process.execPath, args: [fixture] }; + const bridge = await invokeCLI(['marketing', 'generate', '--request', '-'], JSON.stringify(body), 3000, invocation); + assert.deepStrictEqual(bridge.args, ['marketing', 'generate', '--request', '-']); + assert.deepStrictEqual(JSON.parse(bridge.input), body); + fs.writeFileSync(fixture, `console.error('请先运行 pippit-tool-cli login'); process.exitCode = 1;`); + await assert.rejects(invokeCLI(['marketing', 'balance'], undefined, 3000, invocation), /pippit-tool-cli login/); + fs.writeFileSync(fixture, `console.log('invalid JSON');`); + await assert.rejects(invokeCLI(['marketing', 'balance'], undefined, 3000, invocation), /未返回有效 JSON/); + fs.writeFileSync(fixture, `setInterval(() => {}, 1000);`); + await assert.rejects(invokeCLI(['marketing', 'balance'], undefined, 50, invocation), /超时/); const queryArgs = ['query', '--thread-id', ids.thread_id, '--run-id', ids.run_id]; const seenActions = []; @@ -163,7 +172,7 @@ async function test() { if (req.url === '/redirect') { res.writeHead(302, { Location: 'https://cdn.example/final.mp4' }); res.end(); } else { res.setHeader('Content-Type', 'video/mp4'); res.end(media); } }; - before = received.length; + let before = received.length; const dest = path.join(dir, 'video.mp4'); await client.download('https://cdn.example/redirect', dest); assert.deepStrictEqual(fs.readFileSync(dest), media); @@ -180,10 +189,8 @@ async function test() { assert(!fs.existsSync(emptyFile)); // Exercise query + download orchestration and preserve raw IDs before delivery. - respond = (req, res) => { - if (req.url === PATHS.query) res.end(JSON.stringify(response('3', { video_urls: ['https://cdn.example/v.mp4'], image_urls: ['https://cdn.example/i.png'] }))); - else { res.setHeader('Content-Type', 'application/octet-stream'); res.end(media); } - }; + cliResult = response('3', { video_urls: ['https://cdn.example/v.mp4'], image_urls: ['https://cdn.example/i.png'] }); + respond = (_, res) => { res.setHeader('Content-Type', 'application/octet-stream'); res.end(media); }; const delivery = []; assert.strictEqual(await main([...queryArgs, '--output-dir', path.join(dir, 'results')], { out: line => delivery.push(JSON.parse(line)), clientFactory: () => client }), 0); assert.strictEqual(delivery[0].data.run_id, ids.run_id); @@ -194,7 +201,7 @@ async function test() { const help = spawnSync(process.execPath, [executable, '--help'], { encoding: 'utf8', env: { ...process.env, XYQ_ACCESS_KEY: '' } }); assert.strictEqual(help.status, 0); assert(help.stdout.includes('generate --request')); - console.log('Marketing Skill: validation, multipart, API errors, no replay, polling and media delivery passed'); + console.log('Marketing Skill: CLI login reuse, stdin transport, no replay, polling and media delivery passed'); } finally { await new Promise(resolve => server.close(resolve)); fs.rmSync(dir, { recursive: true, force: true }); diff --git a/skills/xyq-marketing-skill/SKILL.md b/skills/xyq-marketing-skill/SKILL.md index 2c65e7e..518b96c 100644 --- a/skills/xyq-marketing-skill/SKILL.md +++ b/skills/xyq-marketing-skill/SKILL.md @@ -3,7 +3,7 @@ name: xyq-marketing-skill description: 使用小云雀公开营销 API,根据商品图文生成剧情广告、品牌大片或达人带货营销视频;上传素材、提交营销成片、查询进度、下载交付结果及查询积分。用户要求小云雀营销一键成片或接入营销 API 时使用。 user-invocable: true metadata: - {"openclaw": {"emoji": "🛍️", "requires": {"bins": ["node"], "env": ["XYQ_ACCESS_KEY"]}}} + {"openclaw": {"emoji": "🛍️", "requires": {"bins": ["node", "pippit-tool-cli"]}}} --- # 小云雀营销成片 @@ -12,9 +12,11 @@ metadata: ## Setup -本 Skill 自包含,只需要 Node.js 16+,不依赖其它 Skill 或 npm 安装。脚本位置为 `"{baseDir}/scripts/marketing.js"`;下面示例中的相对路径从本 Skill 目录执行。 +需要 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`,再检查一次;仍不支持就报告版本阻塞,不改为向用户索要密钥。 -API 和 CLI 可以使用同一个 Access Key,但脚本只读取当前进程的 `XYQ_ACCESS_KEY`。CLI 浏览器登录保存的凭据不会自动变成环境变量,不读取系统钥匙串或 CLI 凭据文件。缺少变量时,引导用户在 [官网 API 页](https://xyq.jianying.com/cli?tab=api) 管理 Access Key 并在本机安全配置,不能让用户在聊天中发送密钥,也不要将密钥写入请求文件、命令参数或日志。 +营销 API 调用复用其它 CLI 命令的登录态,脚本不读取或导出凭据。执行真实请求前运行 `pippit-tool-cli status`,读取 JSON 的 `logged_in`,不能只看退出码。未登录或凭据过期时运行 `pippit-tool-cli login`,让用户在浏览器完成授权;等待 CLI 成功返回后再次检查 status,再继续原任务。已有有效登录态直接复用,不重复登录;离线预览和帮助不要求登录。 + +不要让用户提供或复制 `access_token`、Access Key,也不要把凭据写入聊天、请求文件、命令参数或日志。CLI 保留原有 `XYQ_ACCESS_KEY` 显式环境覆盖规则,优先于网页登录;这仅用于已配置的自动化环境,不作为普通用户的必填项。覆盖无效时不能静默切换账号。网页登录凭据被拒绝时按 CLI 的 `login --force` 流程处理,不自动重提可能已创建的生成任务。 先按 [接口契约](references/api.md) 准备请求;需要完整执行示例时读 [商品图到营销视频](examples/product-video.md)。 @@ -24,7 +26,7 @@ API 和 CLI 可以使用同一个 Access Key,但脚本只读取当前进程的 2. 有本地商品素材时逐个上传,保留返回的 `data.pippit_asset_id`。远程素材先取得用户授权使用的本地文件;不能把 URL 或路径放进 `asset_ids`。用户已提供有效资产 ID 时直接复用。无素材的纯文字请求无需上传。 3. 写入 UTF-8 JSON 请求文件。`message` 保留用户指令;`general_agent_settings.video_model` 必填,不能传 `{}`。用户指定模型时原样使用;未指定时询问,或在用户已明确授权“你决定”等选择范围内选定并说明。其它选项按用户给定或已授权的偏好设置,不静默换模型。`thread_id` 仅在继续已有营销会话时传入真实 ID。 4. 先预览校验,已获生成授权后使用 `--execute` 提交。立即保存响应并展示真实 `data.web_thread_link`,保留 `data.run.thread_id`、`data.run.run_id`。缺少网页链接时只报告实际返回信息,不自行拼接链接。 -5. 用当前 thread/run 查询到结束并下载媒体。遇到确认或问卷时沿用已有授权;缺少必要选择再问用户。确认后继续同一 thread,并取得最新 run_id 再查询:旧 Run 可永久保持等待交互状态。可通过宿主浏览器查看已返回的网页链接;安装了 `pippit-tool-cli` 时,也可用 `get-thread --thread-id THREAD_ID` 读取各 Run 的真实 ID。不要用旧 Run 重复确认或重新生成。提交成功、网页链接或进度链接都不等于交付完成。 +5. 用当前 thread/run 查询到结束并下载媒体。遇到确认或问卷时沿用已有授权;缺少必要选择再问用户。确认后继续同一 thread,并取得最新 run_id 再查询:旧 Run 可永久保持等待交互状态。可通过宿主浏览器查看已返回的网页链接;也可用 `pippit-tool-cli` 的 `get-thread --thread-id THREAD_ID` 读取各 Run 的真实 ID。不要用旧 Run 重复确认或重新生成。提交成功、网页链接或进度链接都不等于交付完成。 ```bash 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 node scripts/marketing.js balance ``` -生成默认是离线预览;预览不需要密钥。上传、查询、余额是实际 API 请求。`--timeout` 设置单请求总时限(秒,默认 60)。`--wait` 由脚本每 10 秒查询,默认最多 900 秒;不再叠加其它轮询器。没有 `--wait` 时只查询一次。脚本输出逐行 JSON,查询下载前先输出服务端响应,再逐个输出已下载文件,最后输出含 `downloaded_files` 的响应。 +生成默认是离线预览;预览不需要登录。上传、查询、余额是实际 API 请求。`--timeout` 设置单请求总时限(秒,默认 60)。`--wait` 由脚本每 10 秒查询,默认最多 900 秒;不再叠加其它轮询器。没有 `--wait` 时只查询一次。脚本输出逐行 JSON,查询下载前先输出服务端响应,再逐个输出已下载文件,最后输出含 `downloaded_files` 的响应。 ## Completion and Recovery @@ -48,4 +50,4 @@ node scripts/marketing.js balance ## Scope -本 Skill 仅使用正式公开营销接口;不宣称支持团队空间切换。鉴权范围由用户配置的 Access Key 和服务端决定,公开请求没有 `TeamID` 字段,不自行加入团队字段或跨账号复用资产/任务 ID。沉浸式短片、火山引擎服务和现有 CLI 的来源统计参数不在此脚本范围;不要把 `--source` 等未公开字段传给营销接口。 +本 Skill 仅使用正式公开营销接口;不宣称支持团队空间切换。鉴权范围沿用当前 CLI 登录身份和服务端授权,公开请求没有 `TeamID` 字段,不自行加入团队字段或跨账号复用资产/任务 ID。沉浸式短片、火山引擎服务和现有 CLI 的来源统计参数不在此脚本范围;不要把 `--source` 等未公开字段传给营销接口。 diff --git a/skills/xyq-marketing-skill/examples/product-video.md b/skills/xyq-marketing-skill/examples/product-video.md index 62f23c8..1ecaa8e 100644 --- a/skills/xyq-marketing-skill/examples/product-video.md +++ b/skills/xyq-marketing-skill/examples/product-video.md @@ -2,7 +2,7 @@ 用户示例:“用这张保温杯商品图做一个 15 秒竖屏达人带货视频,不要字幕,用 Seedance 2.0 VIP,1080p。” -1. 确认当前进程已配置 `XYQ_ACCESS_KEY`,然后上传用户给出的真实文件: +1. 先检查 `pippit-tool-cli marketing --help` 与 `pippit-tool-cli status`;未登录时执行 `pippit-tool-cli login` 并等待浏览器授权成功,无需用户提供 token。随后上传用户给出的真实文件: ```bash node scripts/marketing.js upload --file /path/to/cup.png diff --git a/skills/xyq-marketing-skill/references/api.md b/skills/xyq-marketing-skill/references/api.md index b7ec37e..9a53460 100644 --- a/skills/xyq-marketing-skill/references/api.md +++ b/skills/xyq-marketing-skill/references/api.md @@ -4,11 +4,11 @@ ## Endpoints -Base URL:`https://xyq.jianying.com`。全部使用 POST,认证为 `Authorization: Bearer `,`Accept: application/json`。 +Base URL:`https://xyq.jianying.com`。全部使用 POST,`Accept: application/json`。CLI 的统一认证层从现有登录态注入 Authorization 请求头;营销脚本不接触凭据,不要求用户提供 token。 | 操作 | 路径 | 请求体 | | --- | --- | --- | -| 上传单文件 | `/api/biz/v1/skill/upload_file` | multipart/form-data,字段 `file`,boundary 由脚本生成 | +| 上传单文件 | `/api/biz/v1/skill/upload_file` | multipart/form-data,字段 `file`,boundary 由 CLI 生成 | | 营销成片 | `/api/biz/v1/agent/submit_marketing_run` | JSON,字段见下表 | | 查询结果 | `/api/biz/v1/agent/query_generate_video_result` | JSON:真实 `thread_id`、`run_id` | | 查询积分 | `/api/biz/v1/skill/get_credit_balance` | JSON:`{}` | diff --git a/skills/xyq-marketing-skill/scripts/marketing.js b/skills/xyq-marketing-skill/scripts/marketing.js index 16405e4..2062068 100644 --- a/skills/xyq-marketing-skill/scripts/marketing.js +++ b/skills/xyq-marketing-skill/scripts/marketing.js @@ -1,10 +1,10 @@ #!/usr/bin/env node -// Documented Xiaoyunque marketing API; Node.js >= 16, no dependencies. +// Marketing orchestration; authenticated API calls stay inside pippit-tool-cli. const fs = require('fs'); const path = require('path'); const https = require('https'); const crypto = require('crypto'); -const { Readable } = require('stream'); +const { spawn } = require('child_process'); const { pipeline } = require('stream/promises'); const BASE = 'https://xyq.jianying.com'; @@ -63,7 +63,64 @@ function checkResponse(action, result, body) { return result; } -function createClient({ key = process.env.XYQ_ACCESS_KEY, request = https.request, timeout = 60000 } = {}) { +function resolveCLI({ platform = process.platform, searchPath = process.env.PATH || '', packageRoot = path.resolve(__dirname, '../../..') } = {}) { + const binaryName = platform === 'win32' ? 'pippit-tool-cli.exe' : 'pippit-tool-cli'; + function fromPackage(root) { + const manifest = path.join(root, 'package.json'); + if (!fs.existsSync(manifest) || JSON.parse(fs.readFileSync(manifest, 'utf8')).name !== '@pippit-dev/cli') return null; + const command = path.join(root, 'bin', binaryName); + requireValue(fs.existsSync(command), 'CLI 原生程序缺失,请重新安装 @pippit-dev/cli;无需提供 access_token'); + return { command, args: [] }; + } + // Run the native binary directly so deadlines also stop the API process. + const bundled = fromPackage(packageRoot); + if (bundled) return bundled; + for (const dir of searchPath.split(path.delimiter).filter(Boolean)) { + const binary = path.join(dir, binaryName); + if (fs.existsSync(binary)) { + const resolved = fs.realpathSync(binary); + const npmPackage = fromPackage(path.resolve(path.dirname(resolved), '..')); + return npmPackage || { command: binary, args: [] }; + } + if (platform === 'win32') { + const npmPackage = fromPackage(path.join(dir, 'node_modules/@pippit-dev/cli')); + if (npmPackage) return npmPackage; + } + } + throw new Error('请先安装或更新 @pippit-dev/cli,并运行 pippit-tool-cli login;无需提供 access_token'); +} + +function invokeCLI(args, input, timeout, invocation = resolveCLI()) { + return new Promise((resolve, reject) => { + const child = spawn(invocation.command, [...invocation.args, ...args], { + shell: false, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], + }); + let stdout = '', stderr = '', failure; + const stop = message => { failure = new Error(message); child.kill(); }; + const timer = setTimeout(() => stop('CLI 请求超时;提交结果可能不明确,请勿自动重提'), timeout); + child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8'); + child.stdout.on('data', chunk => { + stdout += chunk; + if (Buffer.byteLength(stdout) > 8 * 1024 * 1024) stop('CLI 响应超出 8 MiB 限制'); + }); + child.stderr.on('data', chunk => { + stderr += chunk; + if (Buffer.byteLength(stderr) > 1024 * 1024) stop('CLI 错误输出过大'); + }); + child.once('error', () => { clearTimeout(timer); reject(new Error('无法启动 pippit-tool-cli,请检查安装;无需提供 access_token')); }); + child.stdin.on('error', error => { if (error.code !== 'EPIPE') failure = new Error('无法写入 CLI 请求'); }); + child.once('close', code => { + clearTimeout(timer); + if (failure) return reject(failure); + if (code !== 0) return reject(new Error(`CLI 调用失败:${stderr.trim() || `退出码 ${code}`};请求未重试。若不支持 marketing 命令,请更新 CLI。`)); + try { resolve(JSON.parse(stdout)); } + catch (_) { reject(new Error('CLI 未返回有效 JSON;提交结果可能不明确,请勿自动重提')); } + }); + child.stdin.end(input); + }); +} + +function createClient({ request = https.request, timeout = 60000, invoke = invokeCLI } = {}) { function open(url, method, headers, body) { return new Promise((resolve, reject) => { const target = new URL(url); @@ -84,42 +141,23 @@ function createClient({ key = process.env.XYQ_ACCESS_KEY, request = https.reques }); } - async function api(action, body, headers = { 'Content-Type': 'application/json' }) { - requireValue(nonempty(key) && !/[\r\n]/.test(key), '请在本机环境设置 XYQ_ACCESS_KEY,不要在聊天中发送密钥;CLI 登录不会设置此变量'); - const wire = headers['Content-Type'] === 'application/json' ? JSON.stringify(body) : body; - const res = await open(BASE + PATHS[action], 'POST', { ...headers, Accept: 'application/json', Authorization: `Bearer ${key}` }, wire); - // Never follow API redirects or automatically retry a POST. - if (res.statusCode < 200 || res.statusCode >= 300) { - res.resume(); - throw new Error(`HTTP ${res.statusCode};请求未重试,生成结果可能不明确`); - } - const chunks = []; - let size = 0; - for await (const chunk of res) { - size += chunk.length; - requireValue(size <= 8 * 1024 * 1024, 'API 响应超出 8 MiB 限制'); - chunks.push(chunk); + async function api(action, body) { + requireValue(['generate', 'query', 'balance'].includes(action), '无效营销 API 操作'); + const args = ['marketing', action, '--timeout', `${timeout}ms`]; + let input; + if (action === 'generate') { + validate(body); + args.push('--request', '-', '--execute'); + input = JSON.stringify(body); } - let result; - try { result = JSON.parse(Buffer.concat(chunks).toString('utf8')); } - catch (_) { throw new Error('API 未返回有效 JSON;提交结果可能不明确,请勿自动重提'); } - return checkResponse(action, result, body); + if (action === 'query') args.push('--thread-id', body.thread_id, '--run-id', body.run_id); + return checkResponse(action, await invoke(args, input, timeout), body); } async function upload(file) { const stat = await fs.promises.stat(file); requireValue(stat.isFile() && stat.size > 0 && stat.size < 500000000, '上传需要非空文件且小于 500 MB'); - const boundary = 'xyq-' + crypto.randomBytes(16).toString('hex'); - const name = path.basename(file).replace(/["\r\n\\]/g, '_'); - 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'; - const head = Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${name}"\r\nContent-Type: ${mime}\r\n\r\n`); - const tail = Buffer.from(`\r\n--${boundary}--\r\n`); - const body = Readable.from((async function* () { - yield head; - for await (const chunk of fs.createReadStream(file)) yield chunk; - yield tail; - })()); - return api('upload', body, { 'Content-Type': `multipart/form-data; boundary=${boundary}`, 'Content-Length': head.length + stat.size + tail.length }); + return checkResponse('upload', await invoke(['marketing', 'upload', '--file', path.resolve(file), '--timeout', `${timeout}ms`], undefined, timeout)); } async function download(url, destination) { @@ -159,7 +197,7 @@ const HELP = `小云雀营销 Skill(Node.js >= 16) node marketing.js upload --file product.png node marketing.js query --thread-id ID --run-id ID [--wait] [--max-wait 900] [--output-dir DIR] node marketing.js balance -所有 API 调用从环境读取 XYQ_ACCESS_KEY;generate 默认仅预览。 +所有 API 调用复用 pippit-tool-cli 登录态;未登录先运行 pippit-tool-cli login,无需提供 access_token。generate 默认仅预览。 --timeout 秒数:单请求总时限,默认 60;--max-wait:轮询总时限,默认 900。 query 输出 API 原始响应;有 --output-dir 时成功结果附带 downloaded_files。 退出码:0 成功/单次查询进行中;1 输入或接口错误;2 生成失败/取消/无视频;3 等待超时;4 等待用户交互;5 未知或未指定状态。 @@ -189,7 +227,7 @@ function parseArgs(argv) { return { action, options }; } -async function main(argv, { env = process.env, out = console.log, clientFactory = createClient, now = Date.now, pause = ms => new Promise(resolve => setTimeout(resolve, ms)) } = {}) { +async function main(argv, { out = console.log, clientFactory = createClient, now = Date.now, pause = ms => new Promise(resolve => setTimeout(resolve, ms)) } = {}) { if (!argv.length || argv.includes('--help') || argv.includes('-h')) { out(HELP); return 0; } const { action, options } = parseArgs(argv); let body = {}; @@ -203,13 +241,13 @@ async function main(argv, { env = process.env, out = console.log, clientFactory body = { thread_id: options['thread-id'], run_id: options['run-id'] }; } if (action === 'upload') requireValue(nonempty(options.file), 'upload 需要 --file'); - const client = clientFactory({ key: env.XYQ_ACCESS_KEY, timeout: options.timeout * 1000 }); + const client = clientFactory({ timeout: options.timeout * 1000 }); let result; const deadline = now() + options['max-wait'] * 1000; do { // A poll request cannot overrun the remaining wait budget. const pollClient = action === 'query' && options.wait - ? clientFactory({ key: env.XYQ_ACCESS_KEY, timeout: Math.min(options.timeout * 1000, Math.max(1, deadline - now())) }) : client; + ? clientFactory({ timeout: Math.min(options.timeout * 1000, Math.max(1, deadline - now())) }) : client; result = action === 'upload' ? await client.upload(options.file) : await pollClient.api(action, body); if (action !== 'query' || !options.wait || !WAIT_STATES.includes(String(result.data.run_state))) break; if (now() >= deadline) { @@ -265,4 +303,4 @@ if (require.main === module) { process.exitCode = 1; }); } -module.exports = { BASE, PATHS, validate, checkResponse, createClient, parseArgs, main }; +module.exports = { BASE, PATHS, validate, checkResponse, resolveCLI, invokeCLI, createClient, parseArgs, main };