From ee748db7298ce2c15657fc241df153253bb5548d Mon Sep 17 00:00:00 2001 From: ykb Date: Tue, 15 Sep 2026 11:38:28 +0800 Subject: [PATCH 1/5] feat: migrate skill submit and media upload to native CLI --- .agents/skills/xyq-skill | 1 + README.md | 51 +++--- cmd/root.go | 4 +- cmd/submit_run.go | 40 +++++ cmd/submit_run_test.go | 103 ++++++++++++ cmd/upload_file.go | 64 ++++++++ cmd/upload_file_test.go | 155 +++++++++++++++++++ internal/common/upload_file.go | 2 +- internal/config/config_test.go | 10 ++ scripts/skills.test.js | 13 +- scripts/xyq-security.test.py | 96 ++++++++++++ skills/xyq-nest-skill/SKILL.md | 81 +++++----- skills/xyq-nest-skill/scripts/get_thread.py | 2 +- skills/xyq-nest-skill/scripts/submit_run.py | 76 --------- skills/xyq-nest-skill/scripts/upload_file.py | 133 ---------------- skills/xyq-nest-skill/scripts/xyq_common.py | 60 +++---- 16 files changed, 587 insertions(+), 304 deletions(-) create mode 120000 .agents/skills/xyq-skill create mode 100644 cmd/submit_run.go create mode 100644 cmd/submit_run_test.go create mode 100644 cmd/upload_file.go create mode 100644 cmd/upload_file_test.go create mode 100644 scripts/xyq-security.test.py delete mode 100644 skills/xyq-nest-skill/scripts/submit_run.py delete mode 100644 skills/xyq-nest-skill/scripts/upload_file.py diff --git a/.agents/skills/xyq-skill b/.agents/skills/xyq-skill new file mode 120000 index 0000000..8431361 --- /dev/null +++ b/.agents/skills/xyq-skill @@ -0,0 +1 @@ +../../skills/xyq-nest-skill \ No newline at end of file diff --git a/README.md b/README.md index de104c2..a137a8d 100644 --- a/README.md +++ b/README.md @@ -43,49 +43,44 @@ ### 配置 -所有 `xyq-skill` 脚本都使用 Bearer 令牌鉴权: +`submit-run` 和 `upload-file` 使用原生 CLI 登录凭证(`pippit-tool-cli login`),也可通过 `XYQ_ACCESS_KEY` 显式覆盖。保留的查询脚本 `get_thread.py` 仍需配置同一用户的 Bearer 凭证: ```bash export XYQ_ACCESS_KEY="" ``` -可选 API 地址: - -```bash -export XYQ_OPENAPI_BASE="https://xyq.jianying.com" -# 或 -export XYQ_BASE_URL="https://xyq.jianying.com" -``` +CLI 和 Python 脚本携带用户密钥的 API 请求地址固定为 `https://xyq.jianying.com`,不接受 `XYQ_OPENAPI_BASE` / `XYQ_BASE_URL` 覆盖。CLI 拒绝 API 跨域重定向,Python API 脚本禁止自动重定向。上传只在 Authorization 请求头携带密钥。 ### 创建会话 / 发送消息 ```bash # 创建新会话 -python3 skills/xyq-nest-skill/scripts/submit_run.py --message "生一个动漫视频" +pippit-tool-cli submit-run --message "生一个动漫视频" # 向已有会话发送消息 -python3 skills/xyq-nest-skill/scripts/submit_run.py \ +pippit-tool-cli submit-run \ --message "再生成一个故事视频" \ --thread-id THREAD_ID # 携带参考文件发送 -python3 skills/xyq-nest-skill/scripts/submit_run.py \ +pippit-tool-cli submit-run \ --message "参考这个视频做修改" \ - --asset-ids asset_id1 asset_id2 + --asset-ids asset_id1 --asset-ids asset_id2 ``` | 参数 | 必填 | 说明 | |------|------|------| -| `--message` | 是 | 创作指令内容。 | +| `--message` | 是 | 非空白的创作指令,原样发送。 | | `--thread-id` | 否 | 已有会话 ID,不传则创建新会话。 | -| `--asset-ids` | 否 | 资产 ID 列表,支持多个。 | +| `--asset-ids` | 否 | 每次传一个资产 ID;多个素材重复该参数。 | 返回示例: ```json { "thread_id": "90f05e0c-...", - "run_id": "abc123-..." + "run_id": "abc123-...", + "web_thread_link": "https://xyq.jianying.com/..." } ``` @@ -108,18 +103,20 @@ python3 skills/xyq-nest-skill/scripts/get_thread.py \ ### 上传文件 +使用顶层 `upload-file --path`,每次上传一个本地图片、视频或 MP3/WAV 音频文件,文件须小于 500 MB(500000000 字节)。成功返回 `{"asset_id":"..."}`,可直接传给 `submit-run --asset-ids`。 + ```bash # 上传图片 -python3 skills/xyq-nest-skill/scripts/upload_file.py /path/to/image.png +pippit-tool-cli upload-file --path /path/to/image.png # 上传视频 -python3 skills/xyq-nest-skill/scripts/upload_file.py /path/to/video.mp4 +pippit-tool-cli upload-file --path /path/to/video.mp4 # 上传音频 -python3 skills/xyq-nest-skill/scripts/upload_file.py /path/to/audio.mp3 +pippit-tool-cli upload-file --path /path/to/audio.mp3 ``` -仅支持 `image/*`、`video/*` 和 `.mp3/.wav` 音频文件,单文件大小限制 200 MB。 +仅支持 `image/*`、`video/*` 和 `.mp3/.wav` 音频文件,单文件大小限制 500 MB。 返回示例: @@ -161,7 +158,7 @@ python3 skills/xyq-nest-skill/scripts/download_results.py \ 文生视频: ```text -1. submit_run.py --message "生成一个赛博朋克风格的城市夜景视频" +1. pippit-tool-cli submit-run --message "生成一个赛博朋克风格的城市夜景视频" 2. 每 10 秒轮询: get_thread.py --thread-id THREAD_ID --run-id RUN_ID --after-seq SEQUENCE 3. 拿到产物 URL 后下载: @@ -171,25 +168,25 @@ python3 skills/xyq-nest-skill/scripts/download_results.py \ 编辑已有视频: ```text -1. upload_file.py /path/to/video.mp4 -2. submit_run.py --message "把背景换成星空" --asset-ids asset_id +1. pippit-tool-cli upload-file --path /path/to/video.mp4 +2. pippit-tool-cli submit-run --message "把背景换成星空" --asset-ids asset_id 3. 按文生视频流程轮询和下载。 ``` 多参考图/视频生成: ```text -1. upload_file.py /path/to/ref1.png -2. upload_file.py /path/to/ref2.png -3. upload_file.py /path/to/ref3.mp4 -4. submit_run.py --message "根据参考图和视频生成科普故事视频" --asset-ids asset_id1 asset_id2 asset_id3 +1. pippit-tool-cli upload-file --path /path/to/ref1.png +2. pippit-tool-cli upload-file --path /path/to/ref2.png +3. pippit-tool-cli upload-file --path /path/to/ref3.mp4 +4. pippit-tool-cli submit-run --message "根据参考图和视频生成科普故事视频" --asset-ids asset_id1 --asset-ids asset_id2 --asset-ids asset_id3 5. 按文生视频流程轮询和下载。 ``` 在已有会话中追加需求: ```text -1. submit_run.py --message "把刚才的视频加个片头" --thread-id EXISTING_THREAD_ID +1. pippit-tool-cli submit-run --message "把刚才的视频加个片头" --thread-id EXISTING_THREAD_ID 2. 使用新的 run_id 轮询和下载。 ``` diff --git a/cmd/root.go b/cmd/root.go index 3ac6387..00acfce 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -56,7 +56,7 @@ func newRootCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Comm root := &cobra.Command{ Use: "pippit-tool-cli", Short: "Pippit CLI", - Long: "Pippit CLI generates and processes videos and images, queries credit balances, submits short-drama workflows, downloads generated assets, and updates the installed CLI package.", + Long: "Pippit CLI generates and processes videos and images, queries credit balances, submits creative conversations and short-drama workflows, downloads generated assets, and updates the installed CLI package.", Version: version.Current(), SilenceUsage: true, SilenceErrors: true, @@ -72,6 +72,8 @@ 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(newGetThreadCommand(stdout, stderr, runner)) + root.AddCommand(newSubmitRunCommand(stdout, stderr, runner)) + root.AddCommand(newUploadFileCommand(stdout, stderr, runner)) root.AddCommand(newListThreadFileCommand(stdout, stderr, runner)) root.AddCommand(generate_image.NewCommand(stdout, stderr, runner)) root.AddCommand(generate_video.NewCommand(stdout, stderr, runner)) diff --git a/cmd/submit_run.go b/cmd/submit_run.go new file mode 100644 index 0000000..cbdc520 --- /dev/null +++ b/cmd/submit_run.go @@ -0,0 +1,40 @@ +package cmd + +import ( + "fmt" + "io" + "strings" + + "github.com/Pippit-dev/pippit-cli/internal/common" + "github.com/spf13/cobra" +) + +func newSubmitRunCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command { + var body struct { + Message string `json:"message"` + ThreadID string `json:"thread_id,omitempty"` + AssetIDs []string `json:"asset_ids,omitempty"` + } + cmd := &cobra.Command{ + Use: "submit-run", + Short: "Create a creative conversation or send a message to an existing thread", + Args: cobra.NoArgs, + RunE: withErrorLog("submit-run", nil, func(cmd *cobra.Command, _ []string) error { + if strings.TrimSpace(body.Message) == "" { + return fmt.Errorf("缺少必填参数 --message") + } + // Preserve the original message and identifiers; the backend owns routing. + result, err := common.SubmitRun(cmd.Context(), "submit-run", body, runner) + if err != nil { + return err + } + return common.WriteJSON(stdout, result) + }), + } + cmd.SetOut(stdout) + cmd.SetErr(stderr) + cmd.Flags().StringVar(&body.Message, "message", "", "original message to send (required)") + cmd.Flags().StringVar(&body.ThreadID, "thread-id", "", "existing thread ID; omit to create a new thread") + cmd.Flags().StringArrayVar(&body.AssetIDs, "asset-ids", nil, "asset ID to attach; repeat for multiple assets") + return cmd +} diff --git a/cmd/submit_run_test.go b/cmd/submit_run_test.go new file mode 100644 index 0000000..f299b6c --- /dev/null +++ b/cmd/submit_run_test.go @@ -0,0 +1,103 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/Pippit-dev/pippit-cli/internal/config" +) + +func TestSubmitRunCommandPreservesScriptContract(t *testing.T) { + for _, existing := range []bool{false, true} { + t.Run(map[bool]string{false: "new", true: "existing_with_assets"}[existing], func(t *testing.T) { + message := " 根据参考素材生成视频\n保留原文 " + want := map[string]any{"message": message} + args := []string{"submit-run", "--message", message} + if existing { + want["thread_id"] = "skill_original_thread" + want["asset_ids"] = []any{"asset_original_1", "asset_original_2"} + args = append(args, "--thread-id", "skill_original_thread", "--asset-ids", "asset_original_1", "--asset-ids", "asset_original_2") + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != config.SubmitRunPath { + t.Errorf("request = %s %s", r.Method, r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer test-token" { + t.Error("missing bearer authorization") + } + var got map[string]any + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Error(err) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("body = %#v, want %#v", got, want) + } + _, _ = w.Write([]byte(`{"ret":"0","data":{"run":{"thread_id":"thread_1","run_id":"run_1"},"web_thread_link":"https://xyq.jianying.com/thread_1"}}`)) + })) + defer server.Close() + var stdout, stderr bytes.Buffer + root := newTestRootCommand(t, &stdout, &stderr, server.URL) + root.SetArgs(args) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if got := stdout.String(); got != "{\"thread_id\":\"thread_1\",\"run_id\":\"run_1\",\"web_thread_link\":\"https://xyq.jianying.com/thread_1\"}\n" { + t.Fatalf("unexpected output: %s", got) + } + }) + } +} + +func TestSubmitRunCommandValidatesBeforeRequest(t *testing.T) { + for _, args := range [][]string{ + {"submit-run"}, + {"submit-run", "--message", " \n "}, + {"submit-run", "--message", "hello", "unexpected"}, + } { + var stdout, stderr bytes.Buffer + root := newRootCommand(&stdout, &stderr, nil) + root.SetArgs(args) + if err := root.Execute(); err == nil { + t.Fatalf("Execute(%v) should fail before accessing the runner", args) + } + } +} + +func TestSubmitRunHelpDoesNotRequireCredentials(t *testing.T) { + var stdout, stderr bytes.Buffer + root := newRootCommand(&stdout, &stderr, nil) + root.SetArgs([]string{"submit-run", "--help"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + for _, flag := range []string{"--message", "--thread-id", "--asset-ids"} { + if !strings.Contains(stdout.String(), flag) { + t.Errorf("help missing %s", flag) + } + } +} + +func TestSubmitRunCommandRejectsInvalidResponse(t *testing.T) { + for _, response := range []string{ + `{"ret":"1","errmsg":"rejected"}`, + `{"ret":"0","data":{"run":{"run_id":"run_1"}}}`, + `{"ret":"0","data":{"run":{"thread_id":"thread_1"}}}`, + } { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(response)) + })) + var stdout, stderr bytes.Buffer + root := newTestRootCommand(t, &stdout, &stderr, server.URL) + root.SetArgs([]string{"submit-run", "--message", "hello"}) + err := root.Execute() + server.Close() + if err == nil || stdout.Len() != 0 { + t.Fatalf("response %s: error = %v, stdout = %s", response, err, stdout.String()) + } + } +} diff --git a/cmd/upload_file.go b/cmd/upload_file.go new file mode 100644 index 0000000..4a83ca1 --- /dev/null +++ b/cmd/upload_file.go @@ -0,0 +1,64 @@ +package cmd + +import ( + "fmt" + "io" + "mime" + "os" + "path/filepath" + "strings" + + "github.com/Pippit-dev/pippit-cli/internal/common" + "github.com/spf13/cobra" +) + +const maxMediaUploadBytes int64 = 500 * 1000 * 1000 + +func newUploadFileCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command { + var opts common.UploadFileOptions + cmd := &cobra.Command{ + Use: "upload-file", + Short: "Upload an image, video, or MP3/WAV audio file", + Args: cobra.NoArgs, + RunE: withErrorLog("upload-file", nil, func(cmd *cobra.Command, _ []string) error { + opts.Path = strings.TrimSpace(opts.Path) + if err := validateMediaUpload(opts.Path); err != nil { + return err + } + result, err := common.UploadFile(cmd.Context(), opts, runner) + if err != nil { + return err + } + return common.WriteJSON(stdout, result) + }), + } + cmd.SetOut(stdout) + cmd.SetErr(stderr) + cmd.Flags().StringVar(&opts.Path, "path", "", "local media file to upload (required, less than 500 MB)") + return cmd +} + +func validateMediaUpload(path string) error { + if path == "" { + return fmt.Errorf("缺少必填参数 --path") + } + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("获取上传文件信息失败: %w", err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("上传路径必须是普通文件") + } + if info.Size() >= maxMediaUploadBytes { + return fmt.Errorf("上传文件必须小于 500 MB(500000000 字节)") + } + ext := strings.ToLower(filepath.Ext(path)) + contentType := mime.TypeByExtension(ext) + if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { + return nil + } + if ext == ".mp3" || ext == ".wav" { + return nil + } + return fmt.Errorf("不支持的文件类型 %q,仅支持图片、视频和 .mp3/.wav 音频", ext) +} diff --git a/cmd/upload_file_test.go b/cmd/upload_file_test.go new file mode 100644 index 0000000..6f69a9c --- /dev/null +++ b/cmd/upload_file_test.go @@ -0,0 +1,155 @@ +package cmd + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Pippit-dev/pippit-cli/internal/config" +) + +func TestUploadFileMediaContract(t *testing.T) { + for _, name := range []string{"image.png", "image.WEBP", "video.mp4", "audio.mp3", "audio.WAV"} { + t.Run(name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(path, []byte("media-content"), 0o600); err != nil { + t.Fatal(err) + } + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.Method != http.MethodPost || r.URL.Path != config.UploadFilePath { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer test-token" { + t.Error("missing bearer authorization") + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Error(err) + return + } + if bytes.Contains(body, []byte("test-token")) || bytes.Contains(body, []byte(`name="accessKey"`)) { + t.Error("credentials must not appear in multipart body") + } + r.Body = io.NopCloser(bytes.NewReader(body)) + if err := r.ParseMultipartForm(1 << 20); err != nil { + t.Error(err) + return + } + defer r.MultipartForm.RemoveAll() + files := r.MultipartForm.File["file"] + if len(files) != 1 || files[0].Filename != name { + t.Errorf("unexpected file parts: %#v", files) + return + } + contentType := files[0].Header.Get("Content-Type") + prefix := "audio/" + if strings.HasPrefix(name, "image") { + prefix = "image/" + } else if strings.HasPrefix(name, "video") { + prefix = "video/" + } + if !strings.HasPrefix(contentType, prefix) { + t.Errorf("content type = %s, want %s", contentType, prefix) + } + file, err := files[0].Open() + if err != nil { + t.Error(err) + return + } + defer file.Close() + data, err := io.ReadAll(file) + if err != nil || string(data) != "media-content" { + t.Errorf("uploaded content = %q, err = %v", data, err) + } + _, _ = w.Write([]byte(`{"ret":"0","data":{"pippit_asset_id":"asset_original"}}`)) + })) + defer server.Close() + var stdout, stderr bytes.Buffer + root := newTestRootCommand(t, &stdout, &stderr, server.URL) + root.SetArgs([]string{"upload-file", "--path", path}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if stdout.String() != "{\"asset_id\":\"asset_original\"}\n" || calls != 1 { + t.Fatalf("stdout = %q, requests = %d", stdout.String(), calls) + } + }) + } +} + +func TestValidateMediaUpload(t *testing.T) { + dir := t.TempDir() + for _, tc := range []struct { + name string + size int64 + ok bool + }{ + {"below-limit.png", maxMediaUploadBytes - 1, true}, + {"at-limit.png", maxMediaUploadBytes, false}, + {"over-limit.png", maxMediaUploadBytes + 1, false}, + {"script.docx", 1, false}, + {"audio.flac", 1, false}, + {"file.unknown", 1, false}, + } { + path := filepath.Join(dir, tc.name) + if err := os.WriteFile(path, nil, 0o600); err != nil { + t.Fatal(err) + } + // Sparse files exercise the exact size boundary without large allocations. + if err := os.Truncate(path, tc.size); err != nil { + t.Fatal(err) + } + if err := validateMediaUpload(path); (err == nil) != tc.ok { + t.Errorf("validateMediaUpload(%s) = %v, want accepted=%t", tc.name, err, tc.ok) + } + } + for _, path := range []string{"", dir, filepath.Join(dir, "missing.png")} { + if err := validateMediaUpload(path); err == nil { + t.Errorf("validateMediaUpload(%q) should fail", path) + } + } +} + +func TestUploadFileCommandHelpAndArguments(t *testing.T) { + var stdout, stderr bytes.Buffer + root := newRootCommand(&stdout, &stderr, nil) + root.SetArgs([]string{"upload-file", "--help"}) + if err := root.Execute(); err != nil || !strings.Contains(stdout.String(), "--path") { + t.Fatalf("help must work without credentials: %v, %s", err, stdout.String()) + } + for _, args := range [][]string{{"upload-file"}, {"upload-file", "--path", " "}, {"upload-file", "unexpected"}} { + root := newRootCommand(&stdout, &stderr, nil) + root.SetArgs(args) + if err := root.Execute(); err == nil { + t.Errorf("Execute(%v) should fail", args) + } + } +} + +func TestUploadFileRejectsInvalidResponse(t *testing.T) { + path := filepath.Join(t.TempDir(), "image.png") + if err := os.WriteFile(path, []byte("media"), 0o600); err != nil { + t.Fatal(err) + } + for _, response := range []string{`{"ret":"1","errmsg":"rejected"}`, `{"ret":"0","data":{"asset_id":"wrong_field"}}`} { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(io.Discard, r.Body) + _, _ = w.Write([]byte(response)) + })) + var stdout, stderr bytes.Buffer + root := newTestRootCommand(t, &stdout, &stderr, server.URL) + root.SetArgs([]string{"upload-file", "--path", path}) + err := root.Execute() + server.Close() + if err == nil || stdout.Len() != 0 { + t.Errorf("response %s: error = %v, stdout = %s", response, err, stdout.String()) + } + } +} diff --git a/internal/common/upload_file.go b/internal/common/upload_file.go index e396ec0..6b01d82 100644 --- a/internal/common/upload_file.go +++ b/internal/common/upload_file.go @@ -18,7 +18,7 @@ type UploadFileOptions struct { FileName string `json:"file_name"` } -// UploadFileResult is the JSON envelope printed by `pippit-tool-cli short-drama +upload-file`. +// UploadFileResult is the shared JSON output for media and short-drama uploads. type UploadFileResult struct { AssetID string `json:"asset_id"` } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 512d060..5237cfe 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -32,3 +32,13 @@ func TestLoadReadsAccessKey(t *testing.T) { t.Fatalf("AccessKey = %q, want trimmed token", cfg.AccessKey) } } + +func TestLoadIgnoresUntrustedBaseURLEnvironment(t *testing.T) { + for _, name := range []string{"XYQ_OPENAPI_BASE", "XYQ_BASE_URL"} { + t.Setenv(name, "https://untrusted.example") + } + cfg := Load() + if cfg.BaseURL != "https://xyq.jianying.com" { + t.Fatalf("BaseURL must remain the production HTTPS origin") + } +} diff --git a/scripts/skills.test.js b/scripts/skills.test.js index 345a8db..ec3ded8 100644 --- a/scripts/skills.test.js +++ b/scripts/skills.test.js @@ -37,7 +37,8 @@ for (const requiredText of [ "pippit-tool-cli query-result", "pippit-tool-cli login", "XYQ_ACCESS_KEY", - "submit_run.py", + "pippit-tool-cli submit-run", + "pippit-tool-cli upload-file", "web_thread_link", "request_user_input", "ask_user_question", @@ -63,6 +64,16 @@ for (const requiredText of [ assert.ok(readme.includes(requiredText), `README missing skill contract: ${requiredText}`); } +for (const script of ["submit_run.py", "upload_file.py"]) { + assert.ok(!generalSkill.includes(script), `xyq-skill must migrate ${script} to CLI`); + assert.ok(!readme.includes(script), `README must migrate ${script} to CLI`); + assert.strictEqual( + fs.existsSync(path.join(repoRoot, "skills", "xyq-nest-skill", "scripts", script)), + false, + `legacy ${script} must be removed`, + ); +} + const globalSkillsDir = fs.mkdtempSync(path.join(os.tmpdir(), "pippit-skills-test-")); for (const skillName of [ diff --git a/scripts/xyq-security.test.py b/scripts/xyq-security.test.py new file mode 100644 index 0000000..7accf09 --- /dev/null +++ b/scripts/xyq-security.test.py @@ -0,0 +1,96 @@ +"""Offline regression checks for the remaining authenticated Python scripts.""" + +import contextlib +import importlib +import io +import os +from pathlib import Path +import sys +import unittest +from unittest import mock +import urllib.error +import urllib.request +import urllib.response +from email.message import Message + +SCRIPT_DIR = Path(__file__).resolve().parents[1] / "skills/xyq-nest-skill/scripts" +sys.path.insert(0, str(SCRIPT_DIR)) +TEST_KEY = "offline-test-access-key" +with mock.patch.dict(os.environ, { + "XYQ_ACCESS_KEY": TEST_KEY, + "XYQ_OPENAPI_BASE": "https://untrusted.example", + "XYQ_BASE_URL": "http://untrusted.example", +}): + common = importlib.import_module("xyq_common") + + +class SecurityTests(unittest.TestCase): + def test_environment_cannot_change_authenticated_origin(self): + self.assertEqual(common.XYQ_BASE, "https://xyq.jianying.com") + with mock.patch.object(common, "authenticated_open", return_value=io.BytesIO(b'{}')) as send: + common.api_post(common.GET_THREAD_PATH, {"thread_id": "thread_original"}) + request = send.call_args.args[0] + self.assertEqual(request.full_url, "https://xyq.jianying.com/api/biz/v1/skill/get_thread") + self.assertEqual(request.get_header("Authorization"), f"Bearer {TEST_KEY}") + + def test_untrusted_targets_rejected_before_network(self): + for target in ( + "http://xyq.jianying.com/api", + "https://untrusted.example/api", + "https://xyq.jianying.com.untrusted.example/api", + "https://xyq.jianying.com:444/api", + "https://user@xyq.jianying.com/api", + ): + with self.subTest(target=target), mock.patch.object(urllib.request, "build_opener") as build: + with self.assertRaises(urllib.error.URLError): + common.authenticated_open(urllib.request.Request(target)) + build.assert_not_called() + + def test_redirects_never_forward_authorization_or_body(self): + for status in (301, 302, 303, 307, 308): + for target in ("https://untrusted.example/steal", "https://xyq.jianying.com/next"): + requests = [] + + class RedirectServer(urllib.request.BaseHandler): + handler_order = 100 + + def https_open(self, req): + requests.append(req) + headers = Message() + headers["Location"] = target + response = urllib.response.addinfourl(io.BytesIO(b""), headers, req.full_url, status) + response.msg = "Redirect" + return response + + opener = urllib.request.build_opener(RedirectServer(), common._NoRedirect()) + request = urllib.request.Request( + common.XYQ_BASE + common.GET_THREAD_PATH, + data=b'{"private":"body"}', + headers={"Authorization": f"Bearer {TEST_KEY}"}, + ) + with self.subTest(status=status, target=target): + with mock.patch.object(urllib.request, "build_opener", return_value=opener): + with self.assertRaises(urllib.error.HTTPError): + common.authenticated_open(request) + self.assertEqual(len(requests), 1) + + def test_error_response_does_not_echo_key(self): + for error in ( + urllib.error.HTTPError(common.XYQ_BASE, 500, "failure", {}, io.BytesIO(TEST_KEY.encode())), + urllib.error.URLError(TEST_KEY), + ): + stderr = io.StringIO() + with mock.patch.object(common, "authenticated_open", side_effect=error): + with contextlib.redirect_stderr(stderr), self.assertRaises(SystemExit): + common.api_post(common.GET_THREAD_PATH, {}) + self.assertNotIn(TEST_KEY, stderr.getvalue()) + self.assertIn("[REDACTED]", stderr.getvalue()) + + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr), self.assertRaises(SystemExit): + common.parse_response({"ret": "1", "errmsg": TEST_KEY}) + self.assertNotIn(TEST_KEY, stderr.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/xyq-nest-skill/SKILL.md b/skills/xyq-nest-skill/SKILL.md index 81a4a49..66c8e3f 100644 --- a/skills/xyq-nest-skill/SKILL.md +++ b/skills/xyq-nest-skill/SKILL.md @@ -9,7 +9,7 @@ metadata: "emoji": "💬", "requires": { - "bins": ["python3"], + "bins": ["python3", "pippit-tool-cli"], "env": ["XYQ_ACCESS_KEY"] }, "primaryEnv": "XYQ_ACCESS_KEY" @@ -37,7 +37,7 @@ metadata: ### 路由 A:图片模型直出 -满足任一条件时,必须直接使用 `pippit-tool-cli generate-image`,不要改走 `submit_run.py`: +满足任一条件时,必须直接使用 `pippit-tool-cli generate-image`,不要改走 `pippit-tool-cli submit-run`: - 用户明确说“图片模型直出”、“直接调图片模型”或明确要求用 CLI 生图。 - 用户指定了具体图片模型(如 `seedream_5.0_pro`),并希望单次直接生成图片。 @@ -70,7 +70,7 @@ pippit-tool-cli query-result \ ### 路由 B:视频模型直出 -满足任一条件时,必须直接使用 `pippit-tool-cli generate-video`,不要改走 `submit_run.py`: +满足任一条件时,必须直接使用 `pippit-tool-cli generate-video`,不要改走 `pippit-tool-cli submit-run`: - 用户明确说“视频模型直出”、“直接调模型”或“直接调用 CLI”。 - 用户指定了具体视频模型(如 `Seedance_2.5`),并希望单次直接生成视频。 @@ -107,7 +107,7 @@ pippit-tool-cli query-result \ ### 路由 C:视频超分和擦字幕 -用户明确要求视频超分、提升视频清晰度、擦字幕或去字幕时,直接调用对应的 `pippit-tool-cli` 视频处理命令,不要改走 `submit_run.py`: +用户明确要求视频超分、提升视频清晰度、擦字幕或去字幕时,直接调用对应的 `pippit-tool-cli` 视频处理命令,不要改走 `pippit-tool-cli submit-run`: - 视频超分、提升视频清晰度:`video-super-resolution` - 擦字幕、去字幕:`erase-video-subtitle` @@ -136,11 +136,13 @@ pippit-tool-cli query-result \ ### 路由 D:小云雀后端 Agent 编排 -需要意图确认、脚本/分镜拆解、MV、TVC、局部编辑、复杂参考素材编排,或者用户未明确要求模型直出的创作需求,继续使用本技能内置的 `submit_run.py` / `get_thread.py` 会话工作流;明确的首尾帧请求走路由 B,明确的视频超分和擦字幕请求走路由 C;积分余额查询走路由 F。 +需要意图确认、脚本/分镜拆解、MV、TVC、局部编辑、复杂参考素材编排,或者用户未明确要求模型直出的创作需求,使用 `pippit-tool-cli submit-run` 提交消息,再用 `get_thread.py` 查询会话进展;明确的首尾帧请求走路由 B,明确的视频超分和擦字幕请求走路由 C;积分余额查询走路由 F。 + +提交前运行 `command -v pippit-tool-cli` 和 `pippit-tool-cli submit-run --help` 确认 CLI 及命令可用;不可用时报告版本或安装阻塞。 ### 路由 E:短剧工作流 -用户要求短剧生成、续写、改写、剧情扩展、人物设定、分集草稿或短剧会话文件处理时,停止本技能流程并转交 `xyq-short-drama-skill`,不要用 `submit_run.py`、`generate-image` 或 `generate-video` 假装执行完整短剧流程。 +用户要求短剧生成、续写、改写、剧情扩展、人物设定、分集草稿或短剧会话文件处理时,停止本技能流程并转交 `xyq-short-drama-skill`,不要用 `pippit-tool-cli submit-run`、`generate-image` 或 `generate-video` 假装执行完整短剧流程。 ### 路由 F:积分余额查询 @@ -148,7 +150,7 @@ pippit-tool-cli query-result \ 执行原则: -1. 执行前用 `command -v pippit-tool-cli` 确认 CLI 可用;不可用或版本不支持该命令时报告阻塞,不要改走 `submit_run.py`。 +1. 执行前用 `command -v pippit-tool-cli` 确认 CLI 可用;不可用或版本不支持该命令时报告阻塞,不要改走 `pippit-tool-cli submit-run`。 2. 使用当前 CLI 登录凭证或显式配置的 `XYQ_ACCESS_KEY` 查询凭证所属用户的个人有效积分余额;无需传入用户 ID、`thread_id` 或 `run_id`。鉴权要求见“前置要求”。 3. 这是只读查询,不需要积分消耗确认;不创建会话,不提交生成任务,也不调用 `get_thread.py` 或 `query-result` 轮询。 4. 成功时读取 JSON 中字符串类型的 `total_remain_amount`,向用户展示当前有效积分余额;`"0"` 是有效的零余额。查询失败或缺少余额字段时报告错误,不得当作零余额。 @@ -194,7 +196,7 @@ pippit-tool-cli get-credit-balance --with-log-id ## 前置要求 -图片/视频模型直出、视频处理和积分余额查询(路由 A/B/C/F)使用原生 CLI。首次使用时运行网页登录,CLI 会自动申请或复用本机专属凭证,并保存到系统安全凭证库: +创建会话/发送消息、媒体上传、图片/视频模型直出、视频处理和积分余额查询(路由 A/B/C/D/F)使用原生 CLI。首次使用时运行网页登录,CLI 会自动申请或复用本机专属凭证,并保存到系统安全凭证库: ```bash pippit-tool-cli login @@ -202,15 +204,15 @@ pippit-tool-cli login `XYQ_ACCESS_KEY` 仅作为原生 CLI 在 CI、Agent 等非交互环境中的显式覆盖。如果该环境变量已经设置但无效,CLI 不会静默改用网页登录凭证,应先修正或取消该环境变量。 -默认的后端 Agent 编排(路由 D)仍由独立 Python 脚本 `submit_run.py`、`get_thread.py` 和 `upload_file.py` 执行。这些脚本尚未接入 CLI 的系统安全凭证库,使用前必须配置: +路由 D 使用 CLI 提交消息和上传素材;查询进展仍使用独立 Python 脚本 `get_thread.py`。该脚本尚未接入 CLI 的系统安全凭证库,使用前必须配置同一用户的凭证: ```bash export XYQ_ACCESS_KEY="your-access-key" ``` -可选:`XYQ_OPENAPI_BASE` 或 `XYQ_BASE_URL`,默认 `https://xyq.jianying.com`。 +原生 CLI 和保留的 Python 脚本携带用户密钥的 API 请求固定发往 `https://xyq.jianying.com`,不接受 `XYQ_OPENAPI_BASE` 或 `XYQ_BASE_URL` 覆盖。CLI 拒绝 API 跨域重定向,Python API 脚本禁止自动重定向;上传只通过 Authorization 请求头携带密钥。 -会话 API 路由无需安装额外依赖,仅使用 Python 标准库。图片/视频模型直出、视频处理和积分余额查询路由要求 `pippit-tool-cli` 在 `PATH` 中可用。 +所有 CLI 路由要求 `pippit-tool-cli` 在 `PATH` 中可用且支持对应命令。保留的 Python 脚本仅使用标准库。 ## 使用方法 @@ -218,12 +220,17 @@ export XYQ_ACCESS_KEY="your-access-key" ```bash # 创建新会话并发送「生一个动漫视频」 -python3 {baseDir}/scripts/submit_run.py --message "生一个动漫视频" +pippit-tool-cli submit-run --message "生一个动漫视频" # 向已有会话发送消息 -python3 {baseDir}/scripts/submit_run.py --message "再生成一个故事视频" --thread-id THREAD_ID +pippit-tool-cli submit-run --message "再生成一个故事视频" --thread-id THREAD_ID + +# 携带多个已上传的素材,每个 ID 重复一次参数 +pippit-tool-cli submit-run --message "根据参考素材生成视频" --asset-ids ASSET_ID1 --asset-ids ASSET_ID2 ``` +`--message` 必填且不能全为空白,内容原样发送。`--thread-id` 可选;`--asset-ids` 每次接收一个 ID,多个素材必须重复该参数,不能在一次参数后以空格罗列多个 ID。 + ### 2. 查询会话进展 ```bash @@ -235,18 +242,20 @@ python3 {baseDir}/scripts/get_thread.py --thread-id THREAD_ID --run-id RUN_ID -- ### 3. 上传文件 +先用 `pippit-tool-cli upload-file --help` 确认当前 CLI 支持此命令;缺少命令时报告版本或安装阻塞。该命令使用 CLI 登录凭证或显式设置的 `XYQ_ACCESS_KEY`,成功输出 `{"asset_id":"..."}`。 + - 当用户提供了参考的文件地址时,先进行文件上传,仅支持图片、视频、`.mp3/.wav` 音频。 -- 单次指令执行仅支持单个文件,多个文件可并行调用,单个文件大小必须在200MB以下。 +- 单次指令执行仅支持单个文件,多个文件可并行调用,单个文件必须小于 500 MB(500000000 字节,达到上限会拒绝上传)。 ```bash # 上传图片 -python3 {baseDir}/scripts/upload_file.py /path/to/image.png +pippit-tool-cli upload-file --path /path/to/image.png # 上传视频 -python3 {baseDir}/scripts/upload_file.py /path/to/video.mp4 +pippit-tool-cli upload-file --path /path/to/video.mp4 # 上传音频 -python3 {baseDir}/scripts/upload_file.py /path/to/audio.mp3 +pippit-tool-cli upload-file --path /path/to/audio.mp3 ``` ### 4. 下载结果 @@ -260,12 +269,12 @@ python3 {baseDir}/scripts/download_results.py --urls URL1 URL2 URL3 --output-dir ## 典型工作流 -理解这些工作流,才能正确组合上面的脚本完成用户需求。 +理解这些工作流,才能正确组合上面的 CLI 和脚本完成用户需求。 ### 场景 1:用户要求生成图片或视频(非模型直出) ``` -1. submit_run.py --message "用户的描述" → 拿到 thread_id、run_id 和 web_thread_link +1. pippit-tool-cli submit-run --message "用户的描述" → 拿到 thread_id、run_id 和 web_thread_link 2. **立即**将 `web_thread_link` 展示给用户(如"任务已提交,可在此查看:{web_thread_link}") 3. 每隔 `10` 秒钟调用 get_thread.py --thread-id THREAD_ID --run-id RUN_ID --after-seq SEQUENCE 进行轮询 4. 检查 messages: @@ -307,9 +316,9 @@ python3 {baseDir}/scripts/download_results.py --urls URL1 URL2 URL3 --output-dir ### 场景 4:用户提供图片/视频/音频要求编辑修改或作为参考(如"参考这个视频做一个新的"、"用这首歌做MV") ``` -1. upload_file.py /path/to/video.mp4 → 拿到 asset_id1 -2. upload_file.py /path/to/audio.mp3 → 拿到 asset_id2 -3. submit_run.py --message "参考这个视频并用这首歌做一个新的" --asset-ids asset_id1 asset_id2 → 拿到 thread_id、run_id、web_thread_link +1. pippit-tool-cli upload-file --path /path/to/video.mp4 → 拿到 asset_id1 +2. pippit-tool-cli upload-file --path /path/to/audio.mp3 → 拿到 asset_id2 +3. pippit-tool-cli submit-run --message "参考这个视频并用这首歌做一个新的" --asset-ids asset_id1 --asset-ids asset_id2 → 拿到 thread_id、run_id、web_thread_link 4. 后续同场景 1 的步骤 2-6 ``` @@ -318,18 +327,18 @@ python3 {baseDir}/scripts/download_results.py --urls URL1 URL2 URL3 --output-dir ### 场景 5:用户提供参考图/视频/音频要求生成新内容 ``` -1. upload_file.py /path/to/ref1.png → 拿到 asset_id1 -2. upload_file.py /path/to/ref2.mp4 → 拿到 asset_id2 -3. upload_file.py /path/to/ref3.mp3 → 拿到 asset_id3 +1. pippit-tool-cli upload-file --path /path/to/ref1.png → 拿到 asset_id1 +2. pippit-tool-cli upload-file --path /path/to/ref2.mp4 → 拿到 asset_id2 +3. pippit-tool-cli upload-file --path /path/to/ref3.mp3 → 拿到 asset_id3 4. 直到所有文件上传完成,拿到所有 asset_id -5. submit_run.py --message "根据参考图、视频、音频生成xxx" --asset-ids asset_id1 asset_id2 asset_id3, ... → 拿到 thread_id、run_id、web_thread_link +5. pippit-tool-cli submit-run --message "根据参考图、视频、音频生成xxx" --asset-ids asset_id1 --asset-ids asset_id2 --asset-ids asset_id3 → 拿到 thread_id、run_id、web_thread_link 6. 后续同场景 1 的步骤 2-6 ``` ### 场景 6:在已有会话中追加新需求 ``` -1. submit_run.py --message "新的描述" --thread-id THREAD_ID → 拿到 thread_id、run_id、web_thread_link +1. pippit-tool-cli submit-run --message "新的描述" --thread-id THREAD_ID → 拿到 thread_id、run_id、web_thread_link 2. 后续同场景 1 的步骤 2-6 ``` @@ -353,7 +362,7 @@ python3 {baseDir}/scripts/download_results.py --urls URL1 URL2 URL3 --output-dir ## 输出格式 -**submit_run** 返回: +**pippit-tool-cli submit-run** 返回: ```json { "thread_id": "90f05e0c-...", @@ -385,7 +394,7 @@ python3 {baseDir}/scripts/download_results.py --urls URL1 URL2 URL3 --output-dir } ``` -**upload_file** 返回: +**pippit-tool-cli upload-file** 返回: ```json { "asset_id": "{asset_id}" @@ -415,8 +424,8 @@ python3 {baseDir}/scripts/download_results.py --urls URL1 URL2 URL3 --output-dir 你(用户侧 Agent)的职责是**搬运工**,不是创作者。会话 API 路由由后端 Agent 负责理解需求、拆解分镜、编排工作流、选模型、写 prompt;图片/视频模型直出和视频处理路由把用户原始参数传给 CLI。积分余额查询按路由 F 直接查询并展示余额;以下步骤适用于创作和视频处理任务: -1. **准备素材**:会话 API 路由用 `upload_file.py` 把本地文件转为 asset_id;图片/视频模型直出和视频处理路由把本地路径直接交给对应 CLI;首尾帧任务固定传 `--generate-type 1` 并保持首帧、尾帧顺序 -2. **提交任务**:先按“执行路由”判断;图片模型直出调用 `pippit-tool-cli generate-image`,视频模型直出调用 `pippit-tool-cli generate-video`,视频超分和擦字幕调用对应的视频处理命令,其余通用创作任务把用户的原始描述 + asset_id 原封不动发给 `submit_run.py` +1. **准备素材**:会话 API 路由用 `pippit-tool-cli upload-file` 把本地文件转为 asset_id;图片/视频模型直出和视频处理路由把本地路径直接交给对应 CLI;首尾帧任务固定传 `--generate-type 1` 并保持首帧、尾帧顺序 +2. **提交任务**:先按“执行路由”判断;图片模型直出调用 `pippit-tool-cli generate-image`,视频模型直出调用 `pippit-tool-cli generate-video`,视频超分和擦字幕调用对应的视频处理命令,其余通用创作任务把用户的原始描述 + asset_id 原封不动发给 `pippit-tool-cli submit-run` 3. **传话**:根据 `get_thread.py` 返回的消息列表,展示过程中的意图询问、创作信息等 4. **取件**:会话 API 路由用 `get_thread.py` 轮询,图片/视频模型直出和视频处理路由用 `query-result` 轮询 → 检查结果 → 下载产物 → 结果展示给用户 @@ -432,10 +441,10 @@ python3 {baseDir}/scripts/download_results.py --urls URL1 URL2 URL3 --output-dir 用户说:「根据多张参考图,做个科普故事视频」 用户给了参考图:/path/to/ref1.png, /path/to/ref2.png, /path/to/ref3.png -→ upload_file.py /path/to/ref1.png → 拿到 asset_id1 -→ upload_file.py /path/to/ref2.png → 拿到 asset_id2 -→ upload_file.py /path/to/ref3.png → 拿到 asset_id3 -→ submit_run.py --message "根据参考图、视频生成xxx" --asset-ids asset_id1 asset_id2, asset_id3 → 拿到 web_thread_link,立即展示给用户 +→ pippit-tool-cli upload-file --path /path/to/ref1.png → 拿到 asset_id1 +→ pippit-tool-cli upload-file --path /path/to/ref2.png → 拿到 asset_id2 +→ pippit-tool-cli upload-file --path /path/to/ref3.png → 拿到 asset_id3 +→ pippit-tool-cli submit-run --message "根据参考图、视频生成xxx" --asset-ids asset_id1 --asset-ids asset_id2 --asset-ids asset_id3 → 拿到 web_thread_link,立即展示给用户 → 轮询 ─┬─ 意图确认 → 用户确认 → 使用 thread_id 重新提交 → 继续轮询 └─ 无意图确认 → 信息展示 → 下载产物 → 结果展示 ``` @@ -452,6 +461,6 @@ python3 {baseDir}/scripts/download_results.py --urls URL1 URL2 URL3 --output-dir - 独立 Python 会话 API 脚本的鉴权方式为请求头 `Authorization: Bearer ` - 创建会话时 `message` 是用户的指令要求,不能为空 - 查询会话时可用 --after-seq 做增量拉取,便于轮询新消息(含 assistant 回复与生图/生视频结果) -- 上传文件仅支持图片(image/*)、视频(video/*)和 `.mp3/.wav` 音频文件,其他类型会被拒绝,文件大小须在 200MB 以下 +- 上传文件仅支持图片(image/*)、视频(video/*)和 `.mp3/.wav` 音频文件,其他类型会被拒绝,文件必须小于 500 MB(500000000 字节) - 生成过程中将过程中的创作信息展示给用户;任务完成后给出**产物结果(图片/视频)URL链接**和下载的**本地文件列表**。 - 图片/视频模型直出和视频处理任务必须保留 CLI 返回的 `thread_id` / `run_id`,并用 `query-result` 取回最终图片或视频。 diff --git a/skills/xyq-nest-skill/scripts/get_thread.py b/skills/xyq-nest-skill/scripts/get_thread.py index 2b625f8..4d50c92 100644 --- a/skills/xyq-nest-skill/scripts/get_thread.py +++ b/skills/xyq-nest-skill/scripts/get_thread.py @@ -17,7 +17,7 @@ def main(): epilog=""" 环境变量: XYQ_ACCESS_KEY 必填,Bearer 鉴权 - XYQ_OPENAPI_BASE 或 XYQ_BASE_URL 可选,默认 https://xyq.jianying.com + API 地址固定为 https://xyq.jianying.com,不支持环境变量覆盖 示例: python3 get_thread.py --thread-id abc123 --run-id def456 --after-seq 0 diff --git a/skills/xyq-nest-skill/scripts/submit_run.py b/skills/xyq-nest-skill/scripts/submit_run.py deleted file mode 100644 index c1eb63d..0000000 --- a/skills/xyq-nest-skill/scripts/submit_run.py +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env python3 -"""创建会话 / 向会话发送消息(生图、生视频等):POST /api/biz/v1/skill/submit_run""" - -import argparse -import json -import sys -import os - -sys.path.insert(0, os.path.dirname(__file__)) -from xyq_common import submit_run - - -def main(): - parser = argparse.ArgumentParser( - description="创建会话或向已有会话发送消息(仅用于生视频)", - epilog=""" -环境变量: - XYQ_ACCESS_KEY 必填,Bearer 鉴权 - XYQ_OPENAPI_BASE 或 XYQ_BASE_URL 可选,默认 https://xyq.jianying.com - -示例: - # 创建新会话并发送「生一个动漫视频」 - python3 submit_run.py --message 生一个动漫视频 - - # 向已有会话发送消息 - python3 submit_run.py --message 再生成一个动漫视频 --thread-id 90f05e0c-5d08-4148-be40-e30fc7c7bedf - - # 传入文件资产 ID - python3 submit_run.py --message 生成视频 --asset-ids asset123 - - # 传入多个文件资产 ID - python3 submit_run.py --message 生成视频 --asset-ids asset123 asset456 asset789 - """, - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - parser.add_argument( - "--message", - required=True, - help="要发送的消息内容(生图/生视频描述等),必填", - ) - parser.add_argument( - "--thread-id", - default="", - help="已有会话 ID,不传则创建新会话或返回已有默认会话", - ) - parser.add_argument( - "--asset-ids", - nargs="+", - default=[], - help="资产 ID 列表,可传入多个,例如:--asset-ids id1 id2 id3", - ) - args = parser.parse_args() - - data = submit_run( - thread_id=args.thread_id or "", - message=args.message or "", - asset_ids=args.asset_ids if args.asset_ids else None - ) - run_data = data.get("run", {}) - web_thread_link = data.get("web_thread_link", "") - thread_id = run_data.get("thread_id", "") - run_id = run_data.get("run_id", "") - - if not thread_id: - print("错误:未返回 thread_id", file=sys.stderr) - sys.exit(1) - if not run_id: - print("错误:未返回 run_id", file=sys.stderr) - sys.exit(1) - - out = {"thread_id": thread_id, "run_id": run_id, "web_thread_link": web_thread_link} - print(json.dumps(out, ensure_ascii=False, indent=2)) - - -if __name__ == "__main__": - main() diff --git a/skills/xyq-nest-skill/scripts/upload_file.py b/skills/xyq-nest-skill/scripts/upload_file.py deleted file mode 100644 index 793bde2..0000000 --- a/skills/xyq-nest-skill/scripts/upload_file.py +++ /dev/null @@ -1,133 +0,0 @@ -#!/usr/bin/env python3 -"""上传图片/视频/mp3或wav音频到小云雀资产库:POST /api/biz/v1/skill/upload_file(multipart/form-data)""" - -import argparse -import json -import mimetypes -import os -import sys -import uuid -import urllib.request -import urllib.error - -sys.path.insert(0, os.path.dirname(__file__)) -from xyq_common import XYQ_BASE, ACCESS_KEY, UPLOAD_FILE_PATH, HTTP_TIMEOUT_SECONDS, parse_response - -# 允许的 MIME 类型前缀 -ALLOWED_PREFIXES = ("image/", "video/") -ALLOWED_AUDIO_EXTENSIONS = {".mp3", ".wav"} -EXTRA_MIME_MAP = { - ".mp3": "audio/mpeg", - ".wav": "audio/wav", -} - - -def upload_file(file_path: str) -> dict: - """ - 上传本地文件到小云雀资产库。 - 返回 data: { asset_id: str }。 - """ - if not os.path.isfile(file_path): - print(f"错误:文件不存在: {file_path}", file=sys.stderr) - sys.exit(1) - - # 检查 MIME 类型 - ext = os.path.splitext(file_path)[1].lower() - mime_type, _ = mimetypes.guess_type(file_path) - if not mime_type: - mime_type = EXTRA_MIME_MAP.get(ext) - is_supported_media = bool(mime_type) and any(mime_type.startswith(p) for p in ALLOWED_PREFIXES) - is_supported_audio = bool(mime_type) and mime_type.startswith("audio/") and ext in ALLOWED_AUDIO_EXTENSIONS - if not is_supported_media and not is_supported_audio: - print(f"错误:不支持的文件类型: {mime_type or '未知'},仅支持图片、视频和 .mp3/.wav 音频", file=sys.stderr) - sys.exit(1) - - # 构建 multipart/form-data 请求体 - boundary = f"----PythonUpload{uuid.uuid4().hex}" - filename = os.path.basename(file_path) - - body_parts = [] - - # accessKey 字段 - body_parts.append(f"--{boundary}\r\n".encode()) - body_parts.append(b'Content-Disposition: form-data; name="accessKey"\r\n\r\n') - body_parts.append(f"{ACCESS_KEY}\r\n".encode()) - - # file 字段 - content_type = mime_type or "application/octet-stream" - body_parts.append(f"--{boundary}\r\n".encode()) - body_parts.append( - f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'.encode() - ) - body_parts.append(f"Content-Type: {content_type}\r\n\r\n".encode()) - with open(file_path, "rb") as f: - body_parts.append(f.read()) - body_parts.append(b"\r\n") - - # 结束边界 - body_parts.append(f"--{boundary}--\r\n".encode()) - - data = b"".join(body_parts) - - url = f"{XYQ_BASE.rstrip('/')}{UPLOAD_FILE_PATH}" - req = urllib.request.Request( - url, - data=data, - method="POST", - headers={ - "Authorization": f"Bearer {ACCESS_KEY}", - "Content-Type": f"multipart/form-data; boundary={boundary}" - }, - ) - try: - with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_SECONDS) as resp: - result = json.loads(resp.read().decode("utf-8")) - return parse_response(result) - except urllib.error.HTTPError as e: - err_body = e.read().decode("utf-8") if e.fp else "" - print(f"API 错误 {e.code}: {err_body}", file=sys.stderr) - sys.exit(1) - except urllib.error.URLError as e: - print(f"网络错误: {e.reason}", file=sys.stderr) - sys.exit(1) - - -def main(): - parser = argparse.ArgumentParser( - description="上传图片、视频或 mp3/wav 音频文件到小云雀资产库", - epilog=""" -环境变量: - XYQ_ACCESS_KEY 必填,Bearer 鉴权 - XYQ_OPENAPI_BASE 或 XYQ_BASE_URL 可选,默认 https://xyq.jianying.com - -示例: - # 上传图片 - python3 upload_file.py /path/to/image.png - - # 上传视频 - python3 upload_file.py /path/to/video.mp4 - - # 上传音频 - python3 upload_file.py /path/to/audio.mp3 - """, - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - parser.add_argument( - "file", - help="要上传的图片、视频或 mp3/wav 音频文件路径", - ) - args = parser.parse_args() - - data = upload_file(args.file) - asset_id = data.get("pippit_asset_id", "") - - if not asset_id: - print("错误:未返回 asset_id", file=sys.stderr) - sys.exit(1) - - out = {"asset_id": asset_id} - print(json.dumps(out, ensure_ascii=False, indent=2)) - - -if __name__ == "__main__": - main() diff --git a/skills/xyq-nest-skill/scripts/xyq_common.py b/skills/xyq-nest-skill/scripts/xyq_common.py index 87f0757..e69d17d 100644 --- a/skills/xyq-nest-skill/scripts/xyq_common.py +++ b/skills/xyq-nest-skill/scripts/xyq_common.py @@ -1,18 +1,18 @@ -"""小云雀 agent-im OpenAPI 公共模块:创建会话、查询会话(鉴权为 Authorization: Bearer )""" +"""小云雀 agent-im OpenAPI 公共模块:查询会话(鉴权为 Authorization: Bearer )""" import json import os import sys import urllib.request import urllib.error +import urllib.parse -XYQ_BASE = os.environ.get("XYQ_OPENAPI_BASE", os.environ.get("XYQ_BASE_URL", "https://xyq.jianying.com")) +# Credentials may only be sent to the fixed production HTTPS origin. +XYQ_BASE = "https://xyq.jianying.com" ACCESS_KEY = os.environ.get("XYQ_ACCESS_KEY", "") # API 路径常量 -SUBMIT_RUN_PATH = "/api/biz/v1/skill/submit_run" GET_THREAD_PATH = "/api/biz/v1/skill/get_thread" -UPLOAD_FILE_PATH = "/api/biz/v1/skill/upload_file" HTTP_TIMEOUT_SECONDS = 30 * 60 if not ACCESS_KEY: @@ -20,6 +20,26 @@ sys.exit(1) +class _NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + # Never forward credentials or request bodies through a redirect. + raise urllib.error.HTTPError(req.full_url, code, "API 重定向已拒绝", headers, fp) + + +def authenticated_open(req): + target = urllib.parse.urlsplit(req.full_url) + if (target.scheme != "https" or target.hostname != "xyq.jianying.com" + or target.port not in (None, 443) or target.username is not None + or target.password is not None): + raise urllib.error.URLError("仅允许小云雀生产 HTTPS 地址") + return urllib.request.build_opener(_NoRedirect()).open(req, timeout=HTTP_TIMEOUT_SECONDS) + + +def redact_error(value): + text = str(value) + return text.replace(ACCESS_KEY, "[REDACTED]") if ACCESS_KEY else text + + def _headers(): return { "Authorization": f"Bearer {ACCESS_KEY}", @@ -38,14 +58,14 @@ def api_post(path: str, body: dict) -> dict: headers=_headers(), ) try: - with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_SECONDS) as resp: + with authenticated_open(req) as resp: return json.loads(resp.read().decode("utf-8")) except urllib.error.HTTPError as e: err_body = e.read().decode("utf-8") if e.fp else "" - print(f"API 错误 {e.code}: {err_body}", file=sys.stderr) + print(f"API 错误 {e.code}: {redact_error(err_body)}", file=sys.stderr) sys.exit(1) except urllib.error.URLError as e: - print(f"网络错误: {e.reason}", file=sys.stderr) + print(f"网络错误: {redact_error(e.reason)}", file=sys.stderr) sys.exit(1) @@ -54,14 +74,14 @@ def api_get(path: str) -> dict: url = f"{XYQ_BASE.rstrip('/')}{path}" req = urllib.request.Request(url, method="GET", headers=_headers()) try: - with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_SECONDS) as resp: + with authenticated_open(req) as resp: return json.loads(resp.read().decode("utf-8")) except urllib.error.HTTPError as e: err_body = e.read().decode("utf-8") if e.fp else "" - print(f"API 错误 {e.code}: {err_body}", file=sys.stderr) + print(f"API 错误 {e.code}: {redact_error(err_body)}", file=sys.stderr) sys.exit(1) except urllib.error.URLError as e: - print(f"网络错误: {e.reason}", file=sys.stderr) + print(f"网络错误: {redact_error(e.reason)}", file=sys.stderr) sys.exit(1) @@ -74,27 +94,11 @@ def parse_response(resp: dict) -> dict: ret = resp.get("ret", "") if ret != "0": errmsg = resp.get("errmsg", "未知错误") - print(f"错误码: {ret}, 错误信息: {errmsg}", file=sys.stderr) + print(f"错误码: {redact_error(ret)}, 错误信息: {redact_error(errmsg)}", file=sys.stderr) sys.exit(1) return resp.get("data", {}) -def submit_run(thread_id: str = "", message: str = "", asset_ids: list = None) -> dict: - """ - 创建会话或向已有会话发消息。 - 返回 data: { projectUuid, sessionId }。 - """ - body = {} - if thread_id: - body["thread_id"] = thread_id - if message: - body["message"] = message - if asset_ids: - body["asset_ids"] = asset_ids - resp = api_post(SUBMIT_RUN_PATH, body) - return parse_response(resp) - - def get_thread(thread_id: str, run_id: str = "", after_seq: int = 0) -> dict: """ 查询会话消息列表。 @@ -124,7 +128,7 @@ def get_thread(thread_id: str, run_id: str = "", after_seq: int = 0) -> dict: elif run_state == 4: # 失败 fail_reason = run.get("fail_reason", "未知失败原因") - print(f"错误:{fail_reason}", file=sys.stderr) + print(f"错误:{redact_error(fail_reason)}", file=sys.stderr) sys.exit(1) elif run_state == 5: # 取消 From 506940c48620d63dfff5a208cd792adb9b5733fc Mon Sep 17 00:00:00 2001 From: ykb Date: Tue, 15 Sep 2026 13:59:51 +0800 Subject: [PATCH 2/5] refactor: migrate skill result downloads to native CLI --- README.md | 34 +++--- scripts/skills.test.js | 2 +- skills/xyq-nest-skill/SKILL.md | 46 ++++++-- .../scripts/download_results.py | 105 ------------------ 4 files changed, 56 insertions(+), 131 deletions(-) delete mode 100644 skills/xyq-nest-skill/scripts/download_results.py diff --git a/README.md b/README.md index a137a8d..c95111e 100644 --- a/README.md +++ b/README.md @@ -128,31 +128,36 @@ pippit-tool-cli upload-file --path /path/to/audio.mp3 ### 下载结果 +每个产物 URL 调用一次 CLI,`--output-path` 必须包含文件名: + ```bash -python3 skills/xyq-nest-skill/scripts/download_results.py \ - --urls URL1 URL2 URL3 \ - --output-dir ./xyq_output \ - --prefix "storyboard" \ - --workers 5 +pippit-tool-cli download-result \ + --url "URL1" \ + --output-path "./xyq_output/storyboard_01.png" ``` | 参数 | 必填 | 说明 | |------|------|------| -| `--urls` | 是 | 要下载的 URL 列表。 | -| `--output-dir` | 否 | 输出目录,默认 `./xyq_output`。 | -| `--prefix` | 否 | 文件名前缀,例如 `storyboard_01.png`。 | -| `--workers` | 否 | 并行下载线程数,默认 `5`。 | +| `--url` | 是 | 单个产物的下载 URL。 | +| `--output-path` | 是 | 包含文件名的本地目标路径。 | +| `--updated-at` | 否 | 远端文件真实更新时间(Unix 秒),用于判断是否覆盖已有文件。 | +| `--workers` | 否 | 下载 worker 数,默认 `5`;当前单 URL 调用实际只使用一个 worker。 | -返回示例: +Skill 沿用用户指定的输出目录,未指定时使用 `./xyq_output`,按 URL 列表顺序从 `01` 编号,组成 `前缀_01.ext`(无前缀时为 `01.ext`)。扩展名优先取 URL 查询参数 `filename`,其次取 URL 路径,无法取得时使用 `.bin`。多文件逐项调用,可最多并行执行 5 个命令;重试保持原目标路径。 + +下载成功返回示例: ```json { - "output_dir": "./xyq_output", - "downloaded": ["./xyq_output/storyboard_01.png"], - "total": 1 + "output_path": "./xyq_output/storyboard_01.png", + "downloaded": ["./xyq_output/storyboard_01.png"] } ``` +未传 `--updated-at` 时,CLI 默认跳过已有文件,返回 `already_exist`,此时 `downloaded` 为 `null`;传入真实更新时间后,仅当本地文件修改时间早于该时间时覆盖更新。跳过不代表已校验本地内容与远端一致。 + +下载失败时命令以非零退出码返回错误,不保证输出 JSON。Skill 汇总各次调用的下载成功、已存在跳过和失败项,只对失败项重试一次;仍有失败时明确报告未完整交付。 + ### 典型示例 文生视频: @@ -162,7 +167,8 @@ python3 skills/xyq-nest-skill/scripts/download_results.py \ 2. 每 10 秒轮询: get_thread.py --thread-id THREAD_ID --run-id RUN_ID --after-seq SEQUENCE 3. 拿到产物 URL 后下载: - download_results.py --urls URL1 URL2 --output-dir ./output --prefix "cyberpunk" + pippit-tool-cli download-result --url "URL1" --output-path "./output/cyberpunk_01.mp4" + pippit-tool-cli download-result --url "URL2" --output-path "./output/cyberpunk_02.mp4" ``` 编辑已有视频: diff --git a/scripts/skills.test.js b/scripts/skills.test.js index ec3ded8..e7ca7d5 100644 --- a/scripts/skills.test.js +++ b/scripts/skills.test.js @@ -64,7 +64,7 @@ for (const requiredText of [ assert.ok(readme.includes(requiredText), `README missing skill contract: ${requiredText}`); } -for (const script of ["submit_run.py", "upload_file.py"]) { +for (const script of ["submit_run.py", "upload_file.py", "download_results.py"]) { assert.ok(!generalSkill.includes(script), `xyq-skill must migrate ${script} to CLI`); assert.ok(!readme.includes(script), `README must migrate ${script} to CLI`); assert.strictEqual( diff --git a/skills/xyq-nest-skill/SKILL.md b/skills/xyq-nest-skill/SKILL.md index 66c8e3f..bf3545f 100644 --- a/skills/xyq-nest-skill/SKILL.md +++ b/skills/xyq-nest-skill/SKILL.md @@ -260,13 +260,22 @@ pippit-tool-cli upload-file --path /path/to/audio.mp3 ### 4. 下载结果 -任务完成后,可以将会话中的所有产物批量下载到本地。 +会话 API 路由从 `get_thread.py` 返回的 `messages` 中提取产物 URL,逐文件调用 `pippit-tool-cli download-result` 下载到本地。 + +- 输出目录沿用用户指定的目录,未指定时使用 `./xyq_output`。 +- 保留原有命名规则:按产物 URL 列表顺序从 `01` 开始编号,有前缀时为 `前缀_01.ext`,无前缀时为 `01.ext`。扩展名优先取 URL 查询参数 `filename` 中的扩展名,其次取 URL 路径的扩展名,无法取得时使用 `.bin`。 +- 将目录和文件名拼成完整的 `--output-path`;每个 URL 调用一次,可最多并行执行 5 个下载命令。重试时保持 URL 与目标路径的对应关系。 ```bash -# 指定 URL 列表,指定输出目录,指定文件名前缀(如 artifact_01.png, artifact_02.png ...)进行下载 -python3 {baseDir}/scripts/download_results.py --urls URL1 URL2 URL3 --output-dir ./xyq_output --prefix "artifact" +# 示例:输出目录 ./xyq_output,前缀 artifact,两个 URL 的扩展名分别为 .png 和 .mp4 +pippit-tool-cli download-result --url "URL1" --output-path "./xyq_output/artifact_01.png" +pippit-tool-cli download-result --url "URL2" --output-path "./xyq_output/artifact_02.mp4" ``` +CLI 默认跳过已存在的目标文件,返回 `already_exist`。仅在来源提供真实的文件更新时间时传入 `--updated-at`(Unix 秒),让 CLI 根据本地文件修改时间决定是否重新下载;不要用当前时间代替远端更新时间。跳过不代表已校验本地内容与远端一致,不能将已知属于其他产物的同名文件当作本次结果。 + +逐项收集下载结果;单项失败不阻断其他文件,只对失败项重试一次,仍失败则记录该产物、目标路径及 CLI 返回的错误。 + ## 典型工作流 理解这些工作流,才能正确组合上面的 CLI 和脚本完成用户需求。 @@ -287,8 +296,8 @@ python3 {baseDir}/scripts/download_results.py --urls URL1 URL2 URL3 --output-dir → 回到步骤 2 继续轮询(可能多轮,直到不再意图确认) - 如果 content 中包含产物 URL: → 信息展示 → 下载产物 → 结果展示 -5. 自动下载:download_results.py --urls URL1 URL2 URL3 --output-dir 输出目录 --prefix 有意义的前缀 -6. 向用户展示:过程中的创作信息,以及下载后的本地文件列表 +5. 自动下载:按“下载结果”的目录、前缀和编号规则,为每个产物 URL 调用 pippit-tool-cli download-result --url URL --output-path 完整文件路径 +6. 汇总每次调用的下载成功、已存在跳过和失败结果,向用户展示产物链接及对应的本地文件 ``` ### 场景 2:用户明确要求图片模型直出 @@ -401,15 +410,31 @@ python3 {baseDir}/scripts/download_results.py --urls URL1 URL2 URL3 --output-dir } ``` -**download_results** 返回: +**pippit-tool-cli download-result** 每次下载一个文件,成功返回: ```json { - "output_dir": "./xyq_output", - "downloaded": ["./xyq_output/01.png", "..."], - "total": 10 + "output_path": "./xyq_output/artifact_01.png", + "downloaded": ["./xyq_output/artifact_01.png"] } ``` +目标文件已存在而跳过时返回: +```json +{ + "output_path": "./xyq_output/artifact_01.png", + "downloaded": null, + "already_exist": ["./xyq_output/artifact_01.png"] +} +``` + +单文件下载失败时,命令以非零退出码返回错误,不保证输出 JSON;不能只检查 JSON 中是否有 `errors` 来判断成功。由用户侧 Agent 汇总各次调用的 `downloaded`、`already_exist` 和失败项,不再依赖批量返回的 `output_dir`、`total`。 + +## 会话 API 路由的下载完成标准 + +- run 结束后,先处理意图确认或流程中断;收到产物 URL 后才进入下载交付。 +- 每个待交付产物都要有对应结果:本次下载成功、已存在而跳过,或下载失败。只有所有产物均已下载或明确复用已有文件时,才能报告本地交付完成。 +- 已存在跳过的文件须单独说明,不能计为本次新下载;仍有失败项时报告“生成已完成,部分产物下载失败”,列出失败项和原始产物链接,不宣称全部下载完成。 + ## 向用户展示内容 - 任务提交后:立即将 `web_thread_link` 展示给用户,方便用户直接打开浏览器查看任务页面 @@ -417,8 +442,7 @@ python3 {baseDir}/scripts/download_results.py --urls URL1 URL2 URL3 --output-dir - 展示过程中的创作信息等,继续轮询 - 任务完成(run 结束): - 若涉及意图确认/流程中断(如"请回答以下问题")→ 按“用户确认与反问”规则优先调用结构化提问工具 → 等待用户回复 → 使用同一 `thread_id` 重新提交任务 → 继续轮询(可能多轮) - - 若 content 中包含产物 URL: - - 结果地址:来自 `get_thread` 返回的 `messages` 中,任务创作完成会包含产物 URL,将产物链接、下载的本地文件等信息告知用户。 + - 若 content 中包含产物 URL:展示来自 `get_thread` 返回的 `messages` 的产物链接,以及对应本地文件的可点击绝对路径;区分本次下载、已存在跳过和下载失败,并按上述完成标准说明交付状态。 ## 核心原则:用户侧不做创作,只做传话 diff --git a/skills/xyq-nest-skill/scripts/download_results.py b/skills/xyq-nest-skill/scripts/download_results.py deleted file mode 100644 index 0cf0ffc..0000000 --- a/skills/xyq-nest-skill/scripts/download_results.py +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env python3 -"""下载生成结果:从会话中提取所有图片/视频 URL 并批量下载到本地""" - -import argparse -import json -import os -import sys -import urllib.request -import urllib.error -from concurrent.futures import ThreadPoolExecutor, as_completed - -sys.path.insert(0, os.path.dirname(__file__)) - -HTTP_TIMEOUT_SECONDS = 30 * 60 - - -def download_file(url, filepath): - """下载单个文件""" - import shutil - req = urllib.request.Request(url, headers={"User-Agent": "XYQ-Nest-Skill/1.0"}) - tmp_path = filepath + ".tmp" - try: - with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_SECONDS) as resp: - with open(tmp_path, "wb") as f: - shutil.copyfileobj(resp, f, length=1024 * 1024) - os.replace(tmp_path, filepath) - return filepath, None - except Exception as e: - if os.path.exists(tmp_path): - os.remove(tmp_path) - return filepath, str(e) - - -def main(): - parser = argparse.ArgumentParser( - description="根据产物URL,下载生成的产物到本地,支持指定输出目录和文件名前缀", - epilog=""" -使用方式: - # 直接下载指定 URL 列表 - python3 download_results.py --urls URL1 URL2 URL3 --output-dir ./output --prefix "storyboard" - """, - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - parser.add_argument("--urls", nargs="+", required=True, help="直接指定要下载的 URL 列表") - parser.add_argument("--output-dir", default="", help="输出目录(默认 ./xyq_output") - parser.add_argument("--prefix", default="", help="文件名前缀(如 'storyboard' → storyboard_01.png)") - parser.add_argument("--workers", type=int, default=5, help="并行下载线程数(默认 5)") - args = parser.parse_args() - - # 准备输出目录 - output_dir = args.output_dir or "./xyq_output" - os.makedirs(output_dir, exist_ok=True) - - def _get_ext(url): - """从 URL 中提取文件扩展名,优先从 query 的 filename 参数取,其次从路径取""" - from urllib.parse import urlparse, parse_qs - parsed = urlparse(url) - qs = parse_qs(parsed.query) - filenames = qs.get("filename", []) - if filenames: - _, ext = os.path.splitext(filenames[0]) - if ext: - return ext - _, ext = os.path.splitext(parsed.path) - return ext or ".bin" - - # 构建下载任务 - tasks = [] - for i, url in enumerate(args.urls, 1): - ext = _get_ext(url) - if args.prefix: - filename = f"{args.prefix}_{i:02d}{ext}" - else: - filename = f"{i:02d}{ext}" - filepath = os.path.join(output_dir, filename) - tasks.append((url, filepath)) - - # 并行下载 - results = [] - errors = [] - with ThreadPoolExecutor(max_workers=args.workers) as pool: - futures = {pool.submit(download_file, url, fp): (url, fp) for url, fp in tasks} - for future in as_completed(futures): - fp, err = future.result() - if err: - errors.append({"file": fp, "error": err}) - else: - results.append(fp) - - # 按文件名排序输出 - results.sort() - - output = { - "output_dir": output_dir, - "downloaded": results, - "total": len(results), - } - if errors: - output["errors"] = errors - - print(json.dumps(output, ensure_ascii=False, indent=2)) - - -if __name__ == "__main__": - main() From af4d6c579b792d9f1ce27bbcf9ae3e86a761aea5 Mon Sep 17 00:00:00 2001 From: ykb Date: Tue, 15 Sep 2026 14:07:07 +0800 Subject: [PATCH 3/5] fix: defer Python skill authentication until API requests --- scripts/xyq-security.test.py | 32 +++++++++++++++++++++ skills/xyq-nest-skill/scripts/xyq_common.py | 7 ++--- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/scripts/xyq-security.test.py b/scripts/xyq-security.test.py index 7accf09..e6ffc10 100644 --- a/scripts/xyq-security.test.py +++ b/scripts/xyq-security.test.py @@ -5,6 +5,7 @@ import io import os from pathlib import Path +import subprocess import sys import unittest from unittest import mock @@ -25,6 +26,37 @@ class SecurityTests(unittest.TestCase): + def test_help_and_argument_validation_without_credentials(self): + env = os.environ.copy() + env.pop("XYQ_ACCESS_KEY", None) + for args, exit_code, expected in ( + (["--help"], 0, "--thread-id"), + ([], 2, "--thread-id"), + (["--thread-id", "thread_test", "--after-seq", "invalid"], 2, "--after-seq"), + ): + with self.subTest(args=args): + result = subprocess.run( + [sys.executable, "-B", str(SCRIPT_DIR / "get_thread.py"), *args], + env=env, capture_output=True, text=True, timeout=10, + ) + self.assertEqual(result.returncode, exit_code, result.stderr) + self.assertIn(expected, result.stdout + result.stderr) + self.assertNotIn("错误:请设置 XYQ_ACCESS_KEY", result.stderr) + + def test_missing_credentials_block_requests_before_network(self): + for method in ("GET", "POST"): + stderr = io.StringIO() + with self.subTest(method=method), mock.patch.object(common, "ACCESS_KEY", ""): + with mock.patch.object(common, "authenticated_open") as send: + with contextlib.redirect_stderr(stderr), self.assertRaises(SystemExit) as raised: + if method == "GET": + common.api_get(common.GET_THREAD_PATH) + else: + common.get_thread("thread_test") + self.assertEqual(raised.exception.code, 1) + send.assert_not_called() + self.assertIn("请设置 XYQ_ACCESS_KEY", stderr.getvalue()) + def test_environment_cannot_change_authenticated_origin(self): self.assertEqual(common.XYQ_BASE, "https://xyq.jianying.com") with mock.patch.object(common, "authenticated_open", return_value=io.BytesIO(b'{}')) as send: diff --git a/skills/xyq-nest-skill/scripts/xyq_common.py b/skills/xyq-nest-skill/scripts/xyq_common.py index e69d17d..91d2c90 100644 --- a/skills/xyq-nest-skill/scripts/xyq_common.py +++ b/skills/xyq-nest-skill/scripts/xyq_common.py @@ -15,10 +15,6 @@ GET_THREAD_PATH = "/api/biz/v1/skill/get_thread" HTTP_TIMEOUT_SECONDS = 30 * 60 -if not ACCESS_KEY: - print("错误:请设置 XYQ_ACCESS_KEY 环境变量", file=sys.stderr) - sys.exit(1) - class _NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): @@ -41,6 +37,9 @@ def redact_error(value): def _headers(): + if not ACCESS_KEY: + print("错误:请设置 XYQ_ACCESS_KEY 环境变量", file=sys.stderr) + sys.exit(1) return { "Authorization": f"Bearer {ACCESS_KEY}", "Content-Type": "application/json", From 560097a77c93ba75851f8d8f89e3392fa81f279c Mon Sep 17 00:00:00 2001 From: ykb Date: Tue, 15 Sep 2026 15:18:44 +0800 Subject: [PATCH 4/5] feat: skill opt --- .github/workflows/ci.yml | 3 + README.md | 12 +- package.json | 3 +- scripts/install-cli.js | 19 ++ scripts/install-cli.test.js | 253 ++++++++++++++++++++ scripts/install.js | 14 +- scripts/skills.test.js | 3 +- skills/xyq-nest-skill/.npmignore | 4 + skills/xyq-nest-skill/SKILL.md | 64 +++-- skills/xyq-nest-skill/scripts/ensure-cli.js | 149 ++++++++++++ 10 files changed, 493 insertions(+), 31 deletions(-) create mode 100644 scripts/install-cli.js create mode 100644 scripts/install-cli.test.js create mode 100644 skills/xyq-nest-skill/.npmignore create mode 100644 skills/xyq-nest-skill/scripts/ensure-cli.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1533d3..b835704 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,9 @@ jobs: - name: Test runtime artifact assembly run: node scripts/prepare-canvas-runtime.test.js + - name: Test CLI-only installation and Skill bootstrap + run: node scripts/install-cli.test.js + go: name: Go tests runs-on: ubuntu-22.04 diff --git a/README.md b/README.md index c95111e..a62c21b 100644 --- a/README.md +++ b/README.md @@ -39,10 +39,20 @@ - 生成:文生图、文生视频、图生视频、视频续写。 - 编辑:局部修改、元素替换、镜头调整、风格迁移。 -- 复杂创作:一句话生成短剧、复刻视频/TVC/宣传片、音乐 MV 生成、产品展示片制作。 +- 复杂创作:复刻视频/TVC/宣传片、音乐 MV 生成、产品展示片制作。 ### 配置 +单独上传 `xyq-skill` ZIP 时,保留 Skill 内的 `scripts/ensure-cli.js`。每次开始执行 Skill 任务先运行: + +```bash +node /path/to/xyq-skill/scripts/ensure-cli.js +``` + +环境需要 Node.js 16+ 和 Python 3;首次安装或自动升级时需要 npm、`curl`、系统解压工具(macOS/Linux 的 `tar`,Windows 的 PowerShell)及访问 npm 源和 GitHub Release 的网络。脚本优先复用 PATH 或自身缓存中命令齐全的 CLI;均不存在或缺少必需命令时获取 `@pippit-dev/cli@latest`,安装到 `~/.cache/pippit-tool-cli/xyq-skill/<平台>-<架构>/current`,返回 `{ "cli_path": "CLI绝对路径", "version": "实际安装版本" }`。后续示例中的 `pippit-tool-cli` 替换为该绝对路径;同一任务内复用,不在轮询时重复安装。已有 CLI 缺少关键命令时自动升级;升级后的缓存可被后续任务复用,避免 PATH 旧版本触发重复下载。每次调用最多下载安装一次,升级失败保留原安装;最新版本仍缺少必需命令时报告阻塞。 + +新入口 `node scripts/install-cli.js` 只安装 npm 包对应版本的 CLI 二进制,不安装或清理全局 Skill。ZIP 安装脚本先以 `--ignore-scripts` 获取 npm 包,再调用这个入口。发布包含新入口的 npm 包及对应 GitHub Release 后,ZIP 的最新版本安装流程才能完整使用。 + `submit-run` 和 `upload-file` 使用原生 CLI 登录凭证(`pippit-tool-cli login`),也可通过 `XYQ_ACCESS_KEY` 显式覆盖。保留的查询脚本 `get_thread.py` 仍需配置同一用户的 Bearer 凭证: ```bash diff --git a/package.json b/package.json index 890c39b..cdabae1 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "postinstall": "node scripts/install.js", "prepack": "node scripts/prepare-canvas-runtime.js", "prepare:canvas-runtime": "node scripts/prepare-canvas-runtime.js", - "test": "node scripts/version-check.test.js && node scripts/skills.test.js && node scripts/install-wizard.test.js && node scripts/canvas-command.test.js && node scripts/prepare-canvas-runtime.test.js && go test ./... && go vet ./...", + "test": "node scripts/version-check.test.js && node scripts/skills.test.js && node scripts/install-wizard.test.js && node scripts/install-cli.test.js && node scripts/canvas-command.test.js && node scripts/prepare-canvas-runtime.test.js && go test ./... && go vet ./...", "verify:canvas-runtime": "node scripts/prepare-canvas-runtime.js --check-only" }, "os": [ @@ -34,6 +34,7 @@ "internal", "skills", "scripts/install.js", + "scripts/install-cli.js", "scripts/install-wizard.js", "scripts/canvas-command.js", "scripts/platform.js", diff --git a/scripts/install-cli.js b/scripts/install-cli.js new file mode 100644 index 0000000..445faea --- /dev/null +++ b/scripts/install-cli.js @@ -0,0 +1,19 @@ +#!/usr/bin/env node + +const { install } = require("./install"); + +if (require.main === module) { + if (process.argv.length === 3 && process.argv[2] === "--help") { + console.log("Usage: node scripts/install-cli.js\nInstall this package's CLI binary without changing global Skills."); + } else if (process.argv.length !== 2) { + console.error("Unsupported arguments. Usage: node scripts/install-cli.js"); + process.exitCode = 1; + } else { + try { + install({ cliOnly: true }); + } catch (err) { + console.error(`Failed to install pippit-tool-cli: ${err.message || err}`); + process.exitCode = 1; + } + } +} diff --git a/scripts/install-cli.test.js b/scripts/install-cli.test.js new file mode 100644 index 0000000..67ef1c7 --- /dev/null +++ b/scripts/install-cli.test.js @@ -0,0 +1,253 @@ +const assert = require("assert"); +const crypto = require("crypto"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const vm = require("vm"); + +const root = fs.mkdtempSync(path.join(os.tmpdir(), "xyq install test & ")); +const repository = path.resolve(__dirname, ".."); +const bootstrap = path.join(repository, "skills/xyq-nest-skill/scripts/ensure-cli.js"); + +function load(file, { modules = {}, process: overrides = {}, dirname, main = false } = {}) { + const module = { exports: {} }; + const output = []; + const errors = []; + const proc = { + platform: process.platform, arch: process.arch, versions: process.versions, + execPath: process.execPath, env: {}, argv: [process.execPath, file], exitCode: 0, + ...overrides, + }; + const req = (name) => Object.prototype.hasOwnProperty.call(modules, name) ? modules[name] : require(name); + req.main = main ? module : null; + vm.runInNewContext(fs.readFileSync(file, "utf8"), { + require: req, module, process: proc, Buffer, + __dirname: dirname || path.dirname(file), + console: { log: (s) => output.push(s), warn: (s) => errors.push(s), error: (s) => errors.push(s) }, + }, { filename: file }); + return { api: module.exports, proc, output, errors }; +} + +function checkInstaller() { + const helpPath = path.join(repository, "scripts/install-cli.js"); + const help = load(helpPath, { + main: true, + process: { argv: [process.execPath, helpPath, "--help"] }, + modules: { "./install": { install: () => assert.fail("Help must not install anything") } }, + }); + assert.strictEqual(help.proc.exitCode, 0); + const packageDir = path.join(root, "package"); + fs.mkdirSync(packageDir); + const effects = []; + const archive = Buffer.from("verified archive fixture"); + const mockPlatform = { + isWindows: process.platform === "win32", + run(command, args, opts) { + if (command === "curl") { + fs.writeFileSync(args[args.indexOf("--output") + 1], archive); + } else if (command === "tar" || command === "powershell.exe") { + const dest = command === "tar" ? args[args.indexOf("-C") + 1] : opts.env.PIPPIT_CLI_DEST; + fs.writeFileSync(path.join(dest, `pippit-tool-cli${mockPlatform.isWindows ? ".exe" : ""}`), "binary fixture"); + } else { + assert.fail(`Unexpected external command: ${command}`); + } + }, + }; + const installer = load(path.join(repository, "scripts/install.js"), { + dirname: path.join(packageDir, "scripts"), + modules: { + "../package.json": { version: "9.9.9" }, + "./platform": mockPlatform, + "./skills": { + installSkillsFromRoot: () => effects.push("install-skills"), + cleanupLegacyGlobalSkills: () => effects.push("cleanup-skills"), + }, + "./telemetry": { reportBundledSkillTelemetry: () => effects.push("telemetry") }, + }, + }); + const hash = crypto.createHash("sha256").update(archive).digest("hex"); + const checksumFile = path.join(packageDir, "checksums.txt"); + fs.writeFileSync(checksumFile, `${hash} ${installer.api.archiveName}\n`); + for (const skipSkills of [undefined, "1"]) { + installer.proc.env.PIPPIT_CLI_SKIP_SKILLS = skipSkills; + const entry = load(path.join(repository, "scripts/install-cli.js"), { + main: true, modules: { "./install": installer.api }, + }); + assert.strictEqual(entry.proc.exitCode, 0); + assert.deepStrictEqual(effects, [], "CLI-only entry must not touch Skills or telemetry"); + assert.strictEqual(fs.readFileSync(path.join(packageDir, "bin", `pippit-tool-cli${mockPlatform.isWindows ? ".exe" : ""}`), "utf8"), "binary fixture"); + } + + // Preserve the existing installer behavior for normal npm installs. + delete installer.proc.env.PIPPIT_CLI_SKIP_SKILLS; + installer.api.install(); + assert.deepStrictEqual(effects.splice(0), ["install-skills", "telemetry"]); + installer.proc.env.PIPPIT_CLI_SKIP_SKILLS = "1"; + installer.api.install(); + assert.deepStrictEqual(effects.splice(0), ["cleanup-skills"]); + + fs.writeFileSync(checksumFile, `${"0".repeat(64)} ${installer.api.archiveName}\n`); + const failed = load(path.join(repository, "scripts/install-cli.js"), { + main: true, modules: { "./install": installer.api }, + }); + assert.strictEqual(failed.proc.exitCode, 1); + assert(failed.errors[0].includes("Checksum mismatch")); + assert.deepStrictEqual(effects, []); +} + +function bootstrapFixture({ failure, platform = "linux", version = "9.9.9" } = {}) { + const dirs = []; + const calls = []; + const home = fs.mkdtempSync(path.join(root, "user-")); + const npmDir = path.join(home, "node installation"); + const nodePath = path.join(npmDir, platform === "win32" ? "node.exe" : "node"); + const npmScript = path.join(npmDir, "node_modules/npm/bin/npm-cli.js"); + fs.mkdirSync(path.dirname(npmScript), { recursive: true }); + fs.writeFileSync(npmScript, "npm fixture"); + return { + dirs, calls, npmDir, + options: { + main: true, + process: { platform, execPath: nodePath, env: { XYQ_ACCESS_KEY: "test-key", PATH: npmDir } }, + modules: { + os: { homedir: () => home }, + child_process: { + execFileSync(command, args, options) { + calls.push({ command, args, options }); + assert.strictEqual(options.env.XYQ_ACCESS_KEY, undefined); + if (args.includes("@pippit-dev/cli@latest")) { + const dir = args[args.indexOf("--prefix") + 1]; + dirs.push(dir); + assert(args.includes("--ignore-scripts")); + assert(args.includes("--global=false")); + assert(args.includes("--prefer-online")); + assert.strictEqual(args[args.indexOf("--cache") + 1], path.join(dir, ".npm-cache")); + assert.strictEqual(options.cwd, dir); + if (platform === "win32") { + assert.strictEqual(command, nodePath); + assert.strictEqual(args[0], npmScript); + } else { + assert.strictEqual(command, "npm"); + } + if (failure === "npm") throw Object.assign(new Error("missing npm"), { code: "ENOENT" }); + const pkg = path.join(dir, "node_modules/@pippit-dev/cli"); + fs.mkdirSync(path.join(pkg, "scripts"), { recursive: true }); + fs.writeFileSync(path.join(pkg, "package.json"), JSON.stringify({ version })); + if (failure !== "missing-installer") fs.writeFileSync(path.join(pkg, "scripts/install-cli.js"), "fixture"); + } else if (args[0].endsWith("install-cli.js")) { + assert.strictEqual(command, nodePath); + if (failure === "download") throw Object.assign(new Error("network"), { status: 1 }); + const bin = path.resolve(path.dirname(args[0]), "../bin"); + fs.mkdirSync(bin, { recursive: true }); + fs.writeFileSync(path.join(bin, platform === "win32" ? "pippit-tool-cli.exe" : "pippit-tool-cli"), "binary fixture"); + } else if (args[0] === "--version") { + assert(path.isAbsolute(command)); + return Buffer.from(failure === "version" ? "0.0.1\n" : `${version}\n`); + } else { + assert.strictEqual(args[1], "--help"); + if (args[0] === "upload-file" && fs.existsSync(command) + && fs.readFileSync(command, "utf8").includes("missing-command")) { + throw Object.assign(new Error("unknown command"), { status: 1 }); + } + if (failure === args[0]) throw Object.assign(new Error("unknown command"), { status: 1 }); + } + return Buffer.from(""); + }, + }, + }, + }, + }; +} + +function checkBootstrap() { + for (const platform of ["linux", "darwin", "win32"]) { + const fixture = bootstrapFixture({ platform }); + const first = load(bootstrap, fixture.options); + const next = load(bootstrap, fixture.options); + assert.strictEqual(first.proc.exitCode, 0, first.errors.join("\n")); + assert.strictEqual(next.proc.exitCode, 0, next.errors.join("\n")); + const result = JSON.parse(first.output[0]); + assert.strictEqual(result.version, "9.9.9"); + assert(path.isAbsolute(result.cli_path)); + assert(result.cli_path.endsWith(platform === "win32" ? "pippit-tool-cli.exe" : "pippit-tool-cli")); + assert.strictEqual(result.cli_path, JSON.parse(next.output[0]).cli_path); + assert.strictEqual(fixture.dirs.length, 1, "Subsequent invocations must reuse the cached CLI"); + for (const command of ["login", "submit-run", "upload-file", "download-result", "query-result", + "generate-image", "generate-video", "video-super-resolution", "erase-video-subtitle", "get-credit-balance"]) { + assert.strictEqual(fixture.calls.filter((call) => call.args[0] === command).length, 2); + } + assert.strictEqual(fixture.calls.filter((call) => call.args[0].endsWith("install-cli.js")).length, 1); + // If the binary is removed, repair the incomplete cache with a fresh installation. + fs.rmSync(result.cli_path); + const repaired = load(bootstrap, fixture.options); + assert.strictEqual(repaired.proc.exitCode, 0, repaired.errors.join("\n")); + assert.strictEqual(fixture.dirs.length, 2); + } + for (const platform of ["linux", "win32"]) { + for (const missingCommand of [false, true]) { + const fixture = bootstrapFixture({ platform }); + const existing = path.join(fixture.npmDir, platform === "win32" ? "pippit-tool-cli.exe" : "pippit-tool-cli"); + const original = missingCommand ? "missing-command" : "existing CLI fixture"; + fs.writeFileSync(existing, original); + // A complete existing CLI must work without npm. Missing commands trigger one upgrade. + if (!missingCommand) fs.rmSync(path.join(fixture.npmDir, "node_modules/npm/bin/npm-cli.js")); + const result = load(bootstrap, fixture.options); + assert.strictEqual(result.proc.exitCode, 0, result.errors.join("\n")); + if (!missingCommand) assert.strictEqual(JSON.parse(result.output[0]).cli_path, existing); + else assert.notStrictEqual(JSON.parse(result.output[0]).cli_path, existing); + assert.strictEqual(fixture.dirs.length, missingCommand ? 1 : 0); + assert.strictEqual(fs.readFileSync(existing, "utf8"), original); + const next = load(bootstrap, fixture.options); + assert.strictEqual(next.proc.exitCode, 0, next.errors.join("\n")); + assert.strictEqual(fixture.dirs.length, missingCommand ? 1 : 0, "An old PATH CLI must not cause repeated upgrades when cache is usable"); + } + } + const outdatedCache = bootstrapFixture(); + const initial = load(bootstrap, outdatedCache.options); + const cachedPath = JSON.parse(initial.output[0]).cli_path; + fs.writeFileSync(cachedPath, "missing-command"); + const upgraded = load(bootstrap, outdatedCache.options); + assert.strictEqual(upgraded.proc.exitCode, 0, upgraded.errors.join("\n")); + assert.strictEqual(fs.readFileSync(cachedPath, "utf8"), "binary fixture"); + assert.strictEqual(outdatedCache.dirs.length, 2); + assert.strictEqual(load(bootstrap, outdatedCache.options).proc.exitCode, 0); + assert.strictEqual(outdatedCache.dirs.length, 2); + + fs.writeFileSync(cachedPath, "missing-command"); + const execute = outdatedCache.options.modules.child_process.execFileSync; + outdatedCache.options.modules.child_process.execFileSync = (command, args, options) => { + if (args[0].endsWith("install-cli.js")) throw Object.assign(new Error("download failed"), { status: 1 }); + return execute(command, args, options); + }; + assert.strictEqual(load(bootstrap, outdatedCache.options).proc.exitCode, 1); + assert.strictEqual(fs.readFileSync(cachedPath, "utf8"), "missing-command", "Failed upgrades must preserve the original cache"); + + const failedUpgrade = bootstrapFixture({ failure: "upload-file" }); + const oldPath = path.join(failedUpgrade.npmDir, "pippit-tool-cli"); + fs.writeFileSync(oldPath, "missing-command"); + assert.strictEqual(load(bootstrap, failedUpgrade.options).proc.exitCode, 1); + assert.strictEqual(failedUpgrade.dirs.length, 1, "An incompatible latest release must fail without an upgrade loop"); + assert.strictEqual(fs.readFileSync(oldPath, "utf8"), "missing-command"); + for (const failure of ["npm", "missing-installer", "download", "version", "upload-file"]) { + const fixture = bootstrapFixture({ failure }); + const result = load(bootstrap, fixture.options); + assert.strictEqual(result.proc.exitCode, 1, failure); + assert.strictEqual(result.output.length, 0, "Failure must not return a usable CLI path"); + assert(result.errors.length > 0); + for (const dir of fixture.dirs) assert.strictEqual(fs.existsSync(dir), false); + } + const help = bootstrapFixture(); + help.options.process.argv = [process.execPath, bootstrap, "--help"]; + assert.strictEqual(load(bootstrap, help.options).proc.exitCode, 0); + assert.strictEqual(help.calls.length, 0, "Help must not install or download anything"); +} + +try { + checkInstaller(); + checkBootstrap(); + const pkg = require("../package.json"); + assert(pkg.files.includes("scripts/install-cli.js"), "npm package must ship the CLI-only entry"); + console.log("CLI-only installer and Skill bootstrap checks passed"); +} finally { + fs.rmSync(root, { recursive: true, force: true }); +} diff --git a/scripts/install.js b/scripts/install.js index d618762..f0cd135 100644 --- a/scripts/install.js +++ b/scripts/install.js @@ -115,7 +115,7 @@ function extractArchive(archivePath, destDir) { run("tar", ["-xzf", archivePath, "-C", destDir]); } -function install() { +function install({ cliOnly = false } = {}) { if (!platform || !arch) { throw new Error(`Unsupported platform: ${process.platform}-${process.arch}`); } @@ -133,11 +133,13 @@ function install() { fs.copyFileSync(extracted, dest); fs.chmodSync(dest, 0o755); - if (process.env.PIPPIT_CLI_SKIP_SKILLS !== "1") { - installSkillsFromRoot(ROOT); - reportBundledSkillTelemetry("install", "npm_install"); - } else { - cleanupLegacyGlobalSkills(); + if (!cliOnly) { + if (process.env.PIPPIT_CLI_SKIP_SKILLS !== "1") { + installSkillsFromRoot(ROOT); + reportBundledSkillTelemetry("install", "npm_install"); + } else { + cleanupLegacyGlobalSkills(); + } } console.log(`${NAME} v${VERSION} installed successfully`); } finally { diff --git a/scripts/skills.test.js b/scripts/skills.test.js index e7ca7d5..880fc28 100644 --- a/scripts/skills.test.js +++ b/scripts/skills.test.js @@ -42,11 +42,12 @@ for (const requiredText of [ "web_thread_link", "request_user_input", "ask_user_question", - "xyq-short-drama-skill", ]) { assert.ok(generalSkill.includes(requiredText), `xyq-skill missing contract: ${requiredText}`); } +assert.ok(!generalSkill.includes("xyq-short-drama-skill"), "xyq-skill must not depend on the short-drama Skill"); + for (const requiredText of ["request_user_input", "ask_user_question", "credits"]) { assert.ok( shortDramaSkill.includes(requiredText), diff --git a/skills/xyq-nest-skill/.npmignore b/skills/xyq-nest-skill/.npmignore new file mode 100644 index 0000000..4326f4e --- /dev/null +++ b/skills/xyq-nest-skill/.npmignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +tests/ +.DS_Store diff --git a/skills/xyq-nest-skill/SKILL.md b/skills/xyq-nest-skill/SKILL.md index bf3545f..cf8f74a 100644 --- a/skills/xyq-nest-skill/SKILL.md +++ b/skills/xyq-nest-skill/SKILL.md @@ -1,6 +1,6 @@ --- name: xyq-skill -description: 通过小云雀的 AI 能力进行综合创作,支持生成和编辑图片/视频,并在用户明确要求图片或视频模型直出、指定图片或视频模型或直接调用 CLI 时使用 pippit-tool-cli generate-image / generate-video;用户要求视频超分、提升视频清晰度、擦字幕或去字幕时,使用 video-super-resolution / erase-video-subtitle。覆盖文生图、文生视频、图生视频、首尾帧生视频、视频编辑、风格转换、视频续写、视频复刻、TVC、宣传片、音乐 MV、产品广告、分镜和教育短视频等场景。当用户提到小云雀、xyq、上传参考图/视频/mp3或wav音频、查看生成进度,或查询小云雀积分余额、剩余积分、credits 时也应触发;积分查询使用 pippit-tool-cli get-credit-balance。短剧生成、续写、改写、人物设定和分集创作应使用 xyq-short-drama-skill,不在本技能中执行。 +description: 通过小云雀的 AI 能力进行综合创作,支持生成和编辑图片/视频,并在用户明确要求图片或视频模型直出、指定图片或视频模型或直接调用 CLI 时使用 pippit-tool-cli generate-image / generate-video;用户要求视频超分、提升视频清晰度、擦字幕或去字幕时,使用 video-super-resolution / erase-video-subtitle。覆盖文生图、文生视频、图生视频、首尾帧生视频、视频编辑、风格转换、视频续写、视频复刻、TVC、宣传片、音乐 MV、产品广告、分镜和教育短视频等场景。当用户提到小云雀、xyq、上传参考图/视频/mp3或wav音频、查看生成进度,或查询小云雀积分余额、剩余积分、credits 时也应触发;积分查询使用 pippit-tool-cli get-credit-balance。 user-invocable: true metadata: { @@ -9,7 +9,7 @@ metadata: "emoji": "💬", "requires": { - "bins": ["python3", "pippit-tool-cli"], + "bins": ["python3", "node"], "env": ["XYQ_ACCESS_KEY"] }, "primaryEnv": "XYQ_ACCESS_KEY" @@ -23,6 +23,8 @@ metadata: 小云雀是一个 AI 综合创作平台,同时为人类创作者和 Agent 设计。Agent 通过 Skill 入口理解任务、调用模型并自动编排工作流。 +每次开始执行本技能任务时,先按“前置要求”运行 `scripts/ensure-cli.js`,检查已有 CLI,不存在或缺少必需命令时获取最新版本。本文命令中的 `pippit-tool-cli` 均代表该脚本返回的 `cli_path`;实际执行时替换为带引号的绝对路径。 + **平台核心能力:** - **生成**:文生图、文生视频、图生视频、视频续写 - **编辑**:局部修改、元素替换、镜头调整、风格迁移 @@ -33,7 +35,7 @@ metadata: ## 执行路由(必须先判断) -积分余额查询优先走路由 F,不进入创作、视频处理或短剧工作流。 +积分余额查询优先走路由 E,不进入创作或视频处理工作流。 ### 路由 A:图片模型直出 @@ -45,7 +47,7 @@ metadata: 执行原则: -1. 执行前用 `command -v pippit-tool-cli` 确认 CLI 可用;不可用时报告阻塞,不要悄悄降级到会话 API。 +1. 执行前完成“前置要求”的CLI 安装检查,使用返回的 `cli_path`;失败时报告阻塞,不要悄悄降级到会话 API。 2. 真实提交会消耗 credits;如果用户本轮尚未明确确认生成,按“用户确认与反问”规则征得明确确认后再运行。 3. 保留用户原始 prompt,不要自行扩写、润色、翻译或增加风格词。 4. `--model` 必填;用户未提供图片模型时,先询问使用哪个模型。只添加用户已经给出的 `--ratio`、`--resolution`、`--generate-image-count`、`--image` 参数,不补默认值。 @@ -79,7 +81,7 @@ pippit-tool-cli query-result \ 执行原则: -1. 执行前用 `command -v pippit-tool-cli` 确认 CLI 可用;不可用时报告阻塞,不要悄悄降级到会话 API。 +1. 执行前完成“前置要求”的CLI 安装检查,使用返回的 `cli_path`;失败时报告阻塞,不要悄悄降级到会话 API。 2. 真实提交会消耗 credits;如果用户本轮尚未明确确认生成,按“用户确认与反问”规则征得明确确认后再运行。 3. 保留用户原始 prompt,不要自行扩写、润色、翻译或增加风格词。 4. 只添加用户已经给出的 `--model`、`--duration`、`--ratio`、`--resolution`、`--image`、`--video`、`--audio`、`--generate-type` 参数;未给参数交给 CLI 默认值。 @@ -114,7 +116,7 @@ pippit-tool-cli query-result \ 执行原则: -1. 执行前用 `command -v pippit-tool-cli` 确认 CLI 可用;不可用时报告阻塞,不要悄悄降级到会话 API。 +1. 执行前完成“前置要求”的CLI 安装检查,使用返回的 `cli_path`;失败时报告阻塞,不要悄悄降级到会话 API。 2. 真实提交会消耗 credits;如果用户本轮尚未明确确认处理,按“用户确认与反问”规则征得明确确认后再运行。 3. 把用户提供的本地视频路径和处理参数直接交给对应 CLI;缺少必填输入时先询问用户。 4. 命令返回后,保存 `thread_id`、`run_id`,并立即向用户展示 `web_thread_link`。 @@ -136,21 +138,17 @@ pippit-tool-cli query-result \ ### 路由 D:小云雀后端 Agent 编排 -需要意图确认、脚本/分镜拆解、MV、TVC、局部编辑、复杂参考素材编排,或者用户未明确要求模型直出的创作需求,使用 `pippit-tool-cli submit-run` 提交消息,再用 `get_thread.py` 查询会话进展;明确的首尾帧请求走路由 B,明确的视频超分和擦字幕请求走路由 C;积分余额查询走路由 F。 - -提交前运行 `command -v pippit-tool-cli` 和 `pippit-tool-cli submit-run --help` 确认 CLI 及命令可用;不可用时报告版本或安装阻塞。 +需要意图确认、脚本/分镜拆解、MV、TVC、局部编辑、复杂参考素材编排,或者用户未明确要求模型直出的创作需求,使用 `pippit-tool-cli submit-run` 提交消息,再用 `get_thread.py` 查询会话进展;明确的首尾帧请求走路由 B,明确的视频超分和擦字幕请求走路由 C;积分余额查询走路由 E。 -### 路由 E:短剧工作流 +提交前完成“前置要求”的CLI 安装检查,使用返回的 `cli_path` 调用 `submit-run`;失败时报告版本或安装阻塞。 -用户要求短剧生成、续写、改写、剧情扩展、人物设定、分集草稿或短剧会话文件处理时,停止本技能流程并转交 `xyq-short-drama-skill`,不要用 `pippit-tool-cli submit-run`、`generate-image` 或 `generate-video` 假装执行完整短剧流程。 - -### 路由 F:积分余额查询 +### 路由 E:积分余额查询 用户询问小云雀“积分余额”、“还剩多少积分”、“剩余 credits”或要求查询个人有效积分时,直接使用 `pippit-tool-cli get-credit-balance`。 执行原则: -1. 执行前用 `command -v pippit-tool-cli` 确认 CLI 可用;不可用或版本不支持该命令时报告阻塞,不要改走 `pippit-tool-cli submit-run`。 +1. 执行前完成“前置要求”的CLI 安装检查,使用返回的 `cli_path`;不可用或版本不支持该命令时报告阻塞,不要改走 `pippit-tool-cli submit-run`。 2. 使用当前 CLI 登录凭证或显式配置的 `XYQ_ACCESS_KEY` 查询凭证所属用户的个人有效积分余额;无需传入用户 ID、`thread_id` 或 `run_id`。鉴权要求见“前置要求”。 3. 这是只读查询,不需要积分消耗确认;不创建会话,不提交生成任务,也不调用 `get_thread.py` 或 `query-result` 轮询。 4. 成功时读取 JSON 中字符串类型的 `total_remain_amount`,向用户展示当前有效积分余额;`"0"` 是有效的零余额。查询失败或缺少余额字段时报告错误,不得当作零余额。 @@ -177,7 +175,7 @@ pippit-tool-cli get-credit-balance --with-log-id - **Codex**:准确工具名是 `request_user_input`。仅在工具已暴露且当前模式允许时调用;不可用时退回普通聊天提问。不要在 Codex 中调用 `ask_user_question`。 - **WorkBuddy**:准确工具名是 `ask_user_question`(Ask User Question)。需要用户补充、选择或确认时优先调用;工具未暴露时才退回普通聊天提问。 - **Trae 及其他宿主**:先查看当前宿主实际暴露的工具,再使用同类结构化提问、确认或表单工具;不要臆造具体工具名。没有同类工具时退回普通聊天提问。 -2. 涉及 credits 消耗、真实生成、外部提交或不可逆操作时,必须等待用户明确答复;不要默认同意或超时后继续。路由 F 的只读积分余额查询不需要额外确认。 +2. 涉及 credits 消耗、真实生成、外部提交或不可逆操作时,必须等待用户明确答复;不要默认同意或超时后继续。路由 E 的只读积分余额查询不需要额外确认。 3. 后端已经给出问题或选项时,保持原意传给用户,不要代替用户回答。 4. 当前宿主没有结构化提问工具,或当前模式不允许调用时,使用一条简洁的普通聊天问题并暂停。 5. 收到回复后,把用户答案原样发回同一 `thread_id`,获取新的 `run_id`,再继续轮询;不要新开会话。 @@ -196,7 +194,29 @@ pippit-tool-cli get-credit-balance --with-log-id ## 前置要求 -创建会话/发送消息、媒体上传、图片/视频模型直出、视频处理和积分余额查询(路由 A/B/C/D/F)使用原生 CLI。首次使用时运行网页登录,CLI 会自动申请或复用本机专属凭证,并保存到系统安全凭证库: +### 检查并按需安装 CLI + +运行环境需要 Python 3、Node.js 16+,支持执行本地程序。首次安装或自动升级 CLI 时需要 npm、可写的用户缓存目录、访问 npm 源及 GitHub Release 的网络、`curl` 和解压工具(macOS/Linux 的 `tar`,Windows 的 PowerShell)。复用已有 CLI 不需要 npm 或下载网络;安装或升级缺少这些能力时,报告具体安装阻塞。 + +开始执行本技能任务时运行以下脚本。它先检查 PATH 中的 CLI,再检查自身的安装缓存;找到命令齐全的 CLI 就复用,不访问 npm 或下载二进制。两处均不存在 CLI,或已有 CLI 的必需命令帮助检查返回非零退出码时,获取 npm `latest` 并安装或升级。PATH 中的旧版本缺少命令但缓存可用时,直接复用缓存,不重复升级。同一任务内的提交、轮询、上传和下载复用返回路径。 + +```bash +node "{baseDir}/scripts/ensure-cli.js" +``` + +需要安装或升级时,脚本跳过 npm 生命周期脚本获取 `@pippit-dev/cli@latest`,再调用包内的 `scripts/install-cli.js` 只安装 CLI。新版本通过全部检查后保存在 `~/.cache/pippit-tool-cli/xyq-skill/<平台>-<架构>/current`,供后续任务复用。它不会安装、清理全局 Skill,也不要求全局 npm 写入权限。安装与检查日志写入 stderr,成功时 stdout 返回 JSON: + +```json +{"cli_path":"/absolute/cache/path/current/node_modules/@pippit-dev/cli/bin/pippit-tool-cli","version":"实际安装版本"} +``` + +保存 `cli_path`,后续用它替换所有示例中的 `pippit-tool-cli`。不要依赖上一次 shell 调用中的临时环境变量;Windows PowerShell 用 `& "绝对路径" 参数` 调用。保留安装缓存以便后续任务复用;如果路径已被清理,重新运行安装脚本。 + +脚本验证 CLI 版本命令,以及 `login`、`submit-run`、`upload-file`、`download-result`、`query-result`、`generate-image`、`generate-video`、`video-super-resolution`、`erase-video-subtitle`、`get-credit-balance` 的 `--help`。检查不发送创作请求,也不需要凭据。已有 CLI 缺少必需命令时自动升级;版本命令无法运行或检查超时时报告运行错误。单次调用最多下载安装一次,最新版本仍不支持必需命令时停止并报告,不反复升级。升级成功前保留原安装;下载失败或最新包缺少只安装 CLI 的入口时,报告安装阻塞。 + +### 配置凭据 + +创建会话/发送消息、媒体上传、图片/视频模型直出、视频处理和积分余额查询(路由 A/B/C/D/E)使用原生 CLI。首次使用时运行网页登录,CLI 会自动申请或复用本机专属凭证,并保存到系统安全凭证库: ```bash pippit-tool-cli login @@ -212,7 +232,7 @@ export XYQ_ACCESS_KEY="your-access-key" 原生 CLI 和保留的 Python 脚本携带用户密钥的 API 请求固定发往 `https://xyq.jianying.com`,不接受 `XYQ_OPENAPI_BASE` 或 `XYQ_BASE_URL` 覆盖。CLI 拒绝 API 跨域重定向,Python API 脚本禁止自动重定向;上传只通过 Authorization 请求头携带密钥。 -所有 CLI 路由要求 `pippit-tool-cli` 在 `PATH` 中可用且支持对应命令。保留的 Python 脚本仅使用标准库。 +所有 CLI 路由使用本次安装检查返回的 `cli_path`。保留的 Python 脚本仅使用标准库;安装检查脚本仅使用 Node.js 内置模块。 ## 使用方法 @@ -242,7 +262,7 @@ python3 {baseDir}/scripts/get_thread.py --thread-id THREAD_ID --run-id RUN_ID -- ### 3. 上传文件 -先用 `pippit-tool-cli upload-file --help` 确认当前 CLI 支持此命令;缺少命令时报告版本或安装阻塞。该命令使用 CLI 登录凭证或显式设置的 `XYQ_ACCESS_KEY`,成功输出 `{"asset_id":"..."}`。 +先完成“前置要求”的CLI 安装检查,再使用返回的 `cli_path` 调用 `upload-file`;缺少命令时报告版本或安装阻塞。该命令使用 CLI 登录凭证或显式设置的 `XYQ_ACCESS_KEY`,成功输出 `{"asset_id":"..."}`。 - 当用户提供了参考的文件地址时,先进行文件上传,仅支持图片、视频、`.mp3/.wav` 音频。 - 单次指令执行仅支持单个文件,多个文件可并行调用,单个文件必须小于 500 MB(500000000 字节,达到上限会拒绝上传)。 @@ -303,7 +323,7 @@ CLI 默认跳过已存在的目标文件,返回 `already_exist`。仅在来源 ### 场景 2:用户明确要求图片模型直出 ``` -1. command -v pippit-tool-cli → 确认 CLI 可用 +1. 按“前置要求”检查并按需安装 CLI,后续使用返回的 cli_path 2. 检查图片模型:用户未提供时先询问,不要自行选择 3. pippit-tool-cli generate-image --prompt "用户原始描述" --model IMAGE_MODEL [仅添加用户已给出的其他参数] 4. 拿到 thread_id、run_id 和 web_thread_link,立即展示 web_thread_link @@ -314,7 +334,7 @@ CLI 默认跳过已存在的目标文件,返回 `already_exist`。仅在来源 ### 场景 3:用户明确要求视频模型直出(含首尾帧) ``` -1. command -v pippit-tool-cli → 确认 CLI 可用 +1. 按“前置要求”检查并按需安装 CLI,后续使用返回的 cli_path 2. 普通视频模型直出:pippit-tool-cli generate-video --prompt "用户原始描述" [仅添加用户已给出的其他参数] 3. 首尾帧直出:确认两张图片的首帧/尾帧角色,按顺序执行 generate-video --image FIRST_FRAME_PATH --image LAST_FRAME_PATH --generate-type 1 4. 拿到 thread_id、run_id 和 web_thread_link,立即展示 web_thread_link @@ -354,7 +374,7 @@ CLI 默认跳过已存在的目标文件,返回 `already_exist`。仅在来源 ### 场景 7:用户要求视频超分或擦字幕 ``` -1. command -v pippit-tool-cli → 确认 CLI 可用 +1. 按“前置要求”检查并按需安装 CLI,后续使用返回的 cli_path 2. 根据用户意图调用 video-super-resolution 或 erase-video-subtitle,并传入用户提供的本地视频路径和处理参数 3. 拿到 thread_id、run_id 和 web_thread_link,立即展示 web_thread_link 4. 每隔 10 秒调用 query-result --thread-id THREAD_ID --run-id RUN_ID --download-dir OUTPUT_DIR @@ -446,7 +466,7 @@ CLI 默认跳过已存在的目标文件,返回 `already_exist`。仅在来源 ## 核心原则:用户侧不做创作,只做传话 -你(用户侧 Agent)的职责是**搬运工**,不是创作者。会话 API 路由由后端 Agent 负责理解需求、拆解分镜、编排工作流、选模型、写 prompt;图片/视频模型直出和视频处理路由把用户原始参数传给 CLI。积分余额查询按路由 F 直接查询并展示余额;以下步骤适用于创作和视频处理任务: +你(用户侧 Agent)的职责是**搬运工**,不是创作者。会话 API 路由由后端 Agent 负责理解需求、拆解分镜、编排工作流、选模型、写 prompt;图片/视频模型直出和视频处理路由把用户原始参数传给 CLI。积分余额查询按路由 E 直接查询并展示余额;以下步骤适用于创作和视频处理任务: 1. **准备素材**:会话 API 路由用 `pippit-tool-cli upload-file` 把本地文件转为 asset_id;图片/视频模型直出和视频处理路由把本地路径直接交给对应 CLI;首尾帧任务固定传 `--generate-type 1` 并保持首帧、尾帧顺序 2. **提交任务**:先按“执行路由”判断;图片模型直出调用 `pippit-tool-cli generate-image`,视频模型直出调用 `pippit-tool-cli generate-video`,视频超分和擦字幕调用对应的视频处理命令,其余通用创作任务把用户的原始描述 + asset_id 原封不动发给 `pippit-tool-cli submit-run` diff --git a/skills/xyq-nest-skill/scripts/ensure-cli.js b/skills/xyq-nest-skill/scripts/ensure-cli.js new file mode 100644 index 0000000..072fe6c --- /dev/null +++ b/skills/xyq-nest-skill/scripts/ensure-cli.js @@ -0,0 +1,149 @@ +#!/usr/bin/env node + +// Self-contained entrypoint shipped inside the Skill ZIP. No npm dependencies. +const { execFileSync } = require("child_process"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const REQUIRED_COMMANDS = [ + "login", "submit-run", "upload-file", "download-result", "query-result", + "generate-image", "generate-video", "video-super-resolution", + "erase-video-subtitle", "get-credit-balance", +]; + +function npmCommand() { + if (process.platform !== "win32") return ["npm", []]; + // Run npm's JS entrypoint directly, avoiding cmd.exe quoting for paths with spaces. + const dirs = [path.dirname(process.execPath), ...(process.env.PATH || "").split(path.delimiter)]; + for (const dir of dirs.filter(Boolean)) { + const script = path.join(dir, "node_modules", "npm", "bin", "npm-cli.js"); + if (fs.existsSync(script)) return [process.execPath, [script]]; + } + throw new Error("未找到 npm,请先安装 Node.js 16+ 和 npm。"); +} + +function findCLIOnPath() { + for (const dir of (process.env.PATH || "").split(path.delimiter).filter(Boolean)) { + const candidate = path.resolve(dir, `pippit-tool-cli${process.platform === "win32" ? ".exe" : ""}`); + if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) { + const resolved = fs.realpathSync(candidate); + // Bypass the npm JS launcher, which may download a binary or check for updates. + if (resolved.endsWith(path.join("scripts", "run.js"))) { + const binary = path.resolve(path.dirname(resolved), "../bin/pippit-tool-cli"); + if (fs.existsSync(binary)) return binary; + } else { + return candidate; + } + } + if (process.platform === "win32" && fs.existsSync(path.join(dir, "pippit-tool-cli.cmd"))) { + const binary = path.resolve(dir, "node_modules/@pippit-dev/cli/bin/pippit-tool-cli.exe"); + if (fs.existsSync(binary)) return binary; + } + } + return null; +} + +function ensureCLI() { + if (Number(process.versions.node.split(".")[0]) < 16) { + throw new Error("需要 Node.js 16+ 和 npm。"); + } + if (!["darwin", "linux", "win32"].includes(process.platform) + || !["x64", "arm64"].includes(process.arch)) { + throw new Error(`不支持的平台:${process.platform}-${process.arch}`); + } + const cacheDir = path.join(os.homedir(), ".cache", "pippit-tool-cli", "xyq-skill", `${process.platform}-${process.arch}`); + const installedDir = path.join(cacheDir, "current"); + const binaryRelative = path.join("node_modules", "@pippit-dev", "cli", "bin", `pippit-tool-cli${process.platform === "win32" ? ".exe" : ""}`); + const cachedCLI = path.join(installedDir, binaryRelative); + const env = { ...process.env }; + delete env.XYQ_ACCESS_KEY; + env.PIPPIT_CLI_DISABLE_UPDATE_CHECK = "1"; + let installDir; + + function run(command, args, label, quiet = false) { + try { + return execFileSync(command, args, { + cwd: installDir, env, timeout: quiet ? 10000 : 180000, + stdio: quiet ? ["ignore", "pipe", "pipe"] : ["ignore", 2, 2], + }); + } catch (err) { + const failure = new Error(`${label}失败(${err.code || err.status || "unknown"}),请检查安装、命令支持情况和网络。`); + failure.exitStatus = err.status; + throw failure; + } + } + + function checkCLI(cliPath, expectedVersion) { + const version = run(cliPath, ["--version"], "检查 CLI 版本", true).toString().trim(); + if (expectedVersion && version !== expectedVersion) { + throw new Error(`CLI 版本 ${version} 与 npm 包版本 ${expectedVersion} 不一致。`); + } + for (const command of REQUIRED_COMMANDS) { + try { + run(cliPath, [command, "--help"], `检查 ${command} 命令`, true); + } catch (err) { + if (Number.isInteger(err.exitStatus) && err.exitStatus !== 0) { + err.missingCommand = command; + } + throw err; + } + } + return { cli_path: cliPath, version }; + } + + const candidates = new Set([findCLIOnPath(), fs.existsSync(cachedCLI) ? cachedCLI : null]); + for (const candidate of candidates) { + if (!candidate) continue; + try { + return checkCLI(candidate); + } catch (err) { + if (!err.missingCommand) throw err; + console.error(`已有 CLI 的 ${err.missingCommand} 命令检查未通过;继续检查缓存,无可用缓存时自动安装最新版本。`); + } + } + + const [npm, npmArgs] = npmCommand(); + fs.mkdirSync(cacheDir, { recursive: true }); + installDir = fs.mkdtempSync(path.join(cacheDir, "install-")); + try { + run(npm, [...npmArgs, "install", "--prefix", installDir, + "--cache", path.join(installDir, ".npm-cache"), "--global=false", "--no-save", + "--package-lock=false", "--ignore-scripts", "--prefer-online", + "--no-audit", "--no-fund", "@pippit-dev/cli@latest"], "下载最新 npm 包"); + const packageDir = path.join(installDir, "node_modules", "@pippit-dev", "cli"); + const installer = path.join(packageDir, "scripts", "install-cli.js"); + if (!fs.existsSync(installer)) { + throw new Error("npm latest 尚未提供 scripts/install-cli.js,请先发布包含只安装 CLI 入口的版本。"); + } + run(process.execPath, [installer], "下载并安装最新 CLI 二进制"); + const cliPath = path.join(packageDir, "bin", `pippit-tool-cli${process.platform === "win32" ? ".exe" : ""}`); + const pkg = JSON.parse(fs.readFileSync(path.join(packageDir, "package.json"), "utf8")); + const result = checkCLI(cliPath, pkg.version); + // Preserve the previous installation until the replacement passes all checks. + fs.rmSync(installedDir, { recursive: true, force: true }); + fs.renameSync(installDir, installedDir); + return { ...result, cli_path: cachedCLI }; + } catch (err) { + fs.rmSync(installDir, { recursive: true, force: true }); + throw err; + } +} + +if (require.main === module) { + if (process.argv.length === 3 && process.argv[2] === "--help") { + console.log("Usage: node ensure-cli.js\n优先复用 PATH 或缓存中命令齐全的 CLI,不存在或缺少必需命令时安装 npm latest,成功输出 {cli_path, version} JSON。"); + } else if (process.argv.length !== 2) { + console.error("不支持的参数。用法:node ensure-cli.js"); + process.exitCode = 1; + } else { + try { + console.log(JSON.stringify(ensureCLI())); + } catch (err) { + console.error(err.message); + process.exitCode = 1; + } + } +} + +module.exports = { ensureCLI }; From 3672ddd8c336fc32d0b2563e8bf82b0916c2c60c Mon Sep 17 00:00:00 2001 From: ykb Date: Tue, 15 Sep 2026 15:39:33 +0800 Subject: [PATCH 5/5] chore: release v1.0.23 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index f7bfc7d..abd61fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pippit-dev/cli", - "version": "1.0.22", + "version": "1.0.23", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pippit-dev/cli", - "version": "1.0.22", + "version": "1.0.23", "hasInstallScript": true, "license": "MIT", "bin": { diff --git a/package.json b/package.json index daf9a93..4a9bcda 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pippit-dev/cli", - "version": "1.0.22", + "version": "1.0.23", "description": "Pippit CLI", "bin": { "pippit-tool-cli": "scripts/run.js"