diff --git a/AGENTS.md b/AGENTS.md index ea97014..1fee781 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,10 +2,10 @@ - 模型查询详情应提供可直接用于生成命令的参数值;图片 `ratio` 只接受服务端数字枚举,查询的 `options/default` 同样使用数字,并用 `option_labels` 展示比例含义;视频 `ratio` 保持其接口要求的比例字符串。不提供数字和比例字符串两套图片输入,映射不按数组位置或展示文案推断。 - 覆盖 IDL 已定义且生成入口可消费的枚举;未知枚举直接跳过,不猜测或回退到其他值。已定义但没有 CLI 请求表达方式的枚举须明确识别为不可用。 -- 内部配置指纹不向用户输出;原始配置缓存和用户展示结构分离,保留可选字段缺失与零值的区别。参数通用元数据(`label`、`description`、`required_field`、`active_when_any`)统一保留,只在确有契约差异时按模型类型分支。 +- 内部配置指纹不向用户输出;原始配置缓存和用户展示结构分离,保留可选字段缺失与零值的区别。参数通用元数据(`label`、`description`、`required_field`、`active_when_any`)统一保留,只在确有契约差异时按模型类型分支。创作模式的提交值按服务端下发的模式 key 映射,不额外硬编码模型名单;模型特例必须有明确契约依据。 - 模型级 `is_default` 不向 CLI 用户输出;服务端默认标记不等于用户授权代选模型。参数级默认值继续展示,原始配置缓存保持完整。 - API 模型查询保留上游 `name` 展示名称和 `key` 模型枚举,白名单只控制准入,不用枚举覆盖名称。CLI 图片模型列表、搜索、详情、帮助和示例只向宿主展示 `name`,详情不透传含枚举的计费等内部配置;查询和生成使用完整名称,仅提交时从原始配置解析 key。不维护静态名称映射,不按简称猜模型;名称缺失、与 key 相同、重复或未找到时明确失败。视频保持 key 契约。 -- 模型发现验收必须分别核对用户确认的预期模型集合与真实接口返回值转换;CLI 与接口同时少返回模型不能判为完整通过。请求携带 PPE 头也不能替代实际路由和生效配置证据。 +- 模型发现验收必须分别核对用户确认的预期模型集合与真实接口返回值转换;CLI 与接口同时少返回模型不能判为完整通过。 - `Seedance_2.0_mini` 和 `Seedance_2.0_mini_lite` 允许省略生成分辨率,由服务端默认 `720p`;CLI 不主动补值,不因模型查询缺少分辨率维度而将该参数标为必填或伪造配置。 - 安装引导的最小兼容修复只补必需命令检查;可选参数已有 `--help` 判断约定时复用该约定,不另增能力返回字段或升级条件。 @@ -13,3 +13,13 @@ # 短剧 Skill 描述约定 - description 应覆盖剧本创作与解析、资产素材生成、故事板规划生成、视频产物交付的完整能力及触发场景,不能将短剧 Agent 缩窄为剧本文本创作或文件查询工具。 + +# 最小改动范围 + +- 用户要求最小接入时,只新增用户明确要求的能力和完成流程不可缺少的字段;不能因为下游协议包含某字段,就顺手扩展 CLI 参数、素材入口或配套抽象。先证明它属于本次必需范围,再修改。放宽某一阶段的参数要求时,只调整该阶段所需条件,保留无关的既有校验;服务端校验简化不得自动扩展为删除 CLI 原有校验。 + +# 宿主来源统计 + +- `--source` 由宿主 Agent 根据可信运行环境填写真实、稳定的平台标识;不得固定为 `codex` 或默认代填,来源未知时省略。示例中的平台名仅为示例,生成提交由 CLI 透传为服务端 `platform`;安装/更新的 `report_telemetry` 用新增 `host_platform` 承载宿主标识,保留已有 `source` 的安装入口语义和 `platform` 的操作系统语义。已有上报字段被消费时,不得改写其含义,应新增独立字段。 + +- 来源采集应尽力而为:显式 `--source` 优先,其次宿主提供的 `PIPPIT_CLI_SOURCE`,最后使用已核实的运行标记;显式空值禁用兜底。仅上报稳定宿主名,不上传会话 ID,不用安装痕迹、API Key 或 prompt 猜来源,冲突时省略。新增运行标记须有实际环境或官方实现依据。 diff --git a/README.md b/README.md index 29abc38..6d7414c 100644 --- a/README.md +++ b/README.md @@ -274,7 +274,7 @@ pippit-tool-cli generate-video \ --resolution "720p" ``` -命令输出 `thread_id`、`run_id` 和 `web_thread_link`。提交生视频 HTTP 请求时,参考图、参考视频和参考音频会使用上传接口返回的 `pippit_asset_id`,并分别写入 `video_part_tool_param.images`、`video_part_tool_param.videos` 和 `video_part_tool_param.audios`。图片支持 `.jpg`、`.jpeg`、`.png`、`.gif`、`.bmp`、`.webp`、`.svg`;视频支持 `.mp4`、`.avi`、`.mov`、`.wmv`、`.flv`、`.webm`、`.mkv`、`.m4v`;音频仅支持 `.mp3`、`.wav`。当前可用模型通过 `model list` 查询,参数详情通过 `model describe MODEL_KEY` 查询。CLI 会在提交前校验 prompt 和文件后缀;模型、比例、分辨率等语义校验由服务端处理。 +命令输出 `thread_id`、`run_id` 和 `web_thread_link`。提交生视频 HTTP 请求时,参考图、参考视频和参考音频会使用上传接口返回的 `pippit_asset_id`,并分别写入 `video_part_tool_param.images`、`video_part_tool_param.videos` 和 `video_part_tool_param.audios`。图片支持 `.jpg`、`.jpeg`、`.png`、`.gif`、`.bmp`、`.webp`、`.svg`;视频支持 `.mp4`、`.avi`、`.mov`、`.wmv`、`.flv`、`.webm`、`.mkv`、`.m4v`;音频仅支持 `.mp3`、`.wav`。当前可用模型通过 `model list` 查询,参数详情通过 `model describe MODEL_KEY` 查询。CLI 会在提交前校验 prompt 和文件后缀,仅传入非空 `--draft-task-id` 时允许省略 prompt;模型、比例、分辨率等语义校验由服务端处理。 首尾帧生视频时,按首帧、尾帧的顺序传入两次 `--image`,并设置 `--generate-type 1`: @@ -292,6 +292,25 @@ pippit-tool-cli generate-video \ `--generate-type` 可选,填写后原样写入 `video_part_tool_param.generate_type`;值 `1` 表示首尾帧生成。CLI 保持图片上传和请求中的输入顺序,不在本地校验该参数的枚举值,具体能力与约束由服务端决定。 +### Seedance 2.5 Draft + +复用 `generate-video` 分两次提交。需要目标服务端支持 Draft 协议和无 prompt 的成片请求。 + +```bash +# 样片 +pippit-tool-cli generate-video --model Seedance_2.5_draft --draft \ + --prompt "小猫钓鱼视频" --task-type reference --duration 10 --ratio 16:9 +pippit-tool-cli query-result --thread-id DRAFT_THREAD_ID --run-id DRAFT_RUN_ID --download-dir ./draft + +# 用户预览后要求生成成片:使用 videos[].draft_task_id 原值 +pippit-tool-cli generate-video --model Seedance_2.5_draft --draft-task-id DRAFT_TASK_ID +pippit-tool-cli query-result --thread-id FINAL_THREAD_ID --run-id FINAL_RUN_ID --download-dir ./final +``` + +`query-result` 在视频结果中保留可选的 `draft`、`draft_task_id`,继续返回 `download_url` 和 `output_path`。两阶段分别计费,下游固定生成 480p 样片和 1080p 成片;样片创建后 7 天内可转成片。CLI 不自动续跑,不要求重复提示词和素材。 + +新增 `--task-type`、`--seed` 透传生成模式与 seed。完整参数与两阶段示例见 [生视频命令](skills/xyq-nest-skill/commands/generate-video.md)。 + ## 视频处理工具 CLI `video-super-resolution` 会上传一个本地视频并提交视频超分任务: @@ -340,11 +359,33 @@ pippit-tool-cli query-result \ CI 或 Agent 可继续显式设置 `XYQ_ACCESS_KEY`,它会覆盖本机网页登录凭证;配置错误时不会静默回退到个人登录。会话提交和查询共享上述凭据。 +### 安装与更新来源统计 + +宿主执行安装/更新时,按可信运行环境静默传入真实稳定标识,未知时省略;不固定为某个平台,不询问用户。以下 `HOST` 是占位符: + +```bash +pippit-tool-cli install --source HOST +npx @pippit-dev/cli install --source HOST +pippit-tool-cli update --source HOST +``` + +直接使用 `npm install -g @pippit-dev/cli` 时,可由宿主在该次命令的环境中设置 `PIPPIT_CLI_SOURCE`。install/update 的显式 `--source` 优先于此环境变量;首尾空白去除,显式空值清空来源。该值不持久化;生成命令运行环境中若也存在此变量,同样会读取它作为宿主来源,具体兜底规则见下文。 + +`report_telemetry` 新增可选请求字段 `host_platform` 上报宿主标识;原有 `source` 保留 `npm_install`、`npx_install`、`cli_update`,`event` 区分 install/update,`platform` 仍表示操作系统。当前仅完成 CLI 字段上报,服务端协议和指标接收仍需适配。帮助不安装也不上报,`PIPPIT_CLI_DISABLE_TELEMETRY=1` 仍可关闭上报。内部 `install-cli.js` 仅安装二进制,沿用不安装 Skill、不上报的原有行为。 + ### 宿主来源统计 -调用 Skill 提交接口的命令均支持可选 `--source`:`submit-run`、`generate-image`、`generate-video`、`video-super-resolution`、`erase-video-subtitle`、`short-drama +submit-run`。 +生成提交命令均支持可选 `--source`:`submit-run`、`generate-image`、`generate-video`、`video-super-resolution`、`erase-video-subtitle`、`short-drama +submit-run`、`marketing generate`。 + +由宿主 Agent 根据实际环境静默填写稳定标识,例如豆包办公 `doubao_office`、WorkBuddy `workbuddy`、Codex `codex`。其它来源可使用其真实产品标识;来源未知时省略,不询问用户,也不从 prompt 猜测。该值去掉首尾空白后写入请求顶层 `platform`,仅供统计,不参与创作、模型选择或鉴权;无法解析来源或显式空值时不发送该字段。省略 `--source` 时,CLI 按以下顺序尽力补齐来源: + +1. 宿主显式设置的 `PIPPIT_CLI_SOURCE`。 +2. 运行标记:`CODEX_THREAD_ID` / `CODEX_SESSION_ID` → `codex`,`CLAUDECODE` → `claude_code`,`CURSOR_AGENT` → `cursor`,`GEMINI_CLI` → `gemini_cli`。 +3. 标记缺失、为 0/false 或不同宿主标记冲突时不猜测,省略来源。 + +显式 `--source` 始终优先,显式空值可关闭本次归因。仅消费环境标记是否存在,不上传会话标识,不根据 API Key、已安装软件、普通终端名或用户 prompt 猜来源,也不持久化。查询、上传、下载、Canvas 不附加来源。营销脚本的预览与提交采用相同规则;原生 update 也复用该解析,仍写入 `host_platform`,保留旧 source。豆包办公、WorkBuddy 等尚无已核实运行标记的宿主,应优先主动传参或设置上述环境变量。 -由宿主 Agent 根据实际环境静默填写稳定标识,例如豆包办公 `doubao_office`、WorkBuddy `workbuddy`、Codex `codex`。其它来源可使用其真实产品标识;来源未知时省略,不询问用户,也不从 prompt 猜测。该值去掉首尾空白后写入请求顶层 `platform`,仅供统计,不参与创作、模型选择或鉴权;未提供/空值时不发送该字段。不会自动读取环境变量、持久化来源或影响查询、上传、下载、Canvas 命令。 +Cursor 标记依据:[官方终端文档](https://docs.cursor.com/en/agent/terminal);Gemini 标记依据:[官方命令文档](https://geminicli.com/docs/reference/commands/)。Codex 标记已在本机运行环境核实;Claude Code 标记已在本机安装产物核实。 ```bash pippit-tool-cli generate-video --prompt "小猫在花园散步" --model Seedance_2.0_mini --source workbuddy diff --git a/cmd/generate_video/generate_video.go b/cmd/generate_video/generate_video.go index 9e2c650..0fef73b 100644 --- a/cmd/generate_video/generate_video.go +++ b/cmd/generate_video/generate_video.go @@ -14,6 +14,8 @@ func NewCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command opts := &internalgen.Options{} var durationSec int var generateType int64 + var seed int64 + var draft bool cmd := &cobra.Command{ Use: "generate-video", @@ -26,6 +28,12 @@ func NewCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command if cmd.Flags().Changed("generate-type") { opts.GenerateType = &generateType } + if cmd.Flags().Changed("seed") { + opts.Seed = &seed + } + if cmd.Flags().Changed("draft") { + opts.Draft = &draft + } result, err := internalgen.Run(cmd.Context(), opts, runner) if err != nil { @@ -43,7 +51,7 @@ func NewCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command cmd.SetErr(stderr) flags := cmd.Flags() flags.StringVar(&opts.Source, "source", "", "optional host agent/platform identifier for statistics only; filled silently by the host agent (e.g. doubao_office, workbuddy, codex)") - flags.StringVar(&opts.Prompt, "prompt", "", "video generation prompt") + flags.StringVar(&opts.Prompt, "prompt", "", "video generation prompt; may be omitted when rendering a draft") flags.StringArrayVar(&opts.ImagePaths, "image", nil, "local reference image path; repeat for multiple images") flags.StringArrayVar(&opts.VideoPaths, "video", nil, "local reference video path; repeat for multiple videos") flags.StringArrayVar(&opts.AudioPaths, "audio", nil, "local reference audio path; repeat for multiple audios") @@ -52,5 +60,9 @@ func NewCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command flags.StringVar(&opts.Model, "model", "", "video model key; use 'model list' to discover available models") flags.StringVar(&opts.Resolution, "resolution", "", "video resolution; optional for Seedance_2.0_mini and Seedance_2.0_mini_lite (server defaults to 720p); use 'model describe ' for current configuration") flags.Int64Var(&generateType, "generate-type", 0, "generation type passed to the service; set 1 for first-and-last-frame generation and provide --image values in first-frame, last-frame order; MiniMax and Wan also accept a single first-frame image in this mode") + flags.StringVar(&opts.TaskType, "task-type", "", "video task type passed to the service: auto, reference, edit, extend") + flags.Int64Var(&seed, "seed", 0, "custom generation seed; omitted when not supplied") + flags.BoolVar(&draft, "draft", false, "generate a Seedance_2.5_draft preview; --draft=false explicitly requests a final video") + flags.StringVar(&opts.DraftTaskID, "draft-task-id", "", "original draft_task_id returned by query-result, used to render a final video") return cmd } diff --git a/cmd/generate_video_test.go b/cmd/generate_video_test.go index 07dfd53..aa5824c 100644 --- a/cmd/generate_video_test.go +++ b/cmd/generate_video_test.go @@ -189,6 +189,82 @@ func TestGenerateVideoRequiresPrompt(t *testing.T) { } } +func TestGenerateVideoDraftParameters(t *testing.T) { + for _, tt := range []struct { + name string + args []string + want map[string]any + absent []string + }{ + { + name: "preview with explicit zero values", + args: []string{"--draft", "--prompt", "cat", "--seed", "0", "--generate-type", "0", "--task-type", "reference"}, + want: map[string]any{"draft": true, "prompt": "cat", "seed": float64(0), "generate_type": float64(0), "task_type": "reference"}, + absent: []string{"draft_task_id", "resolution", "ratio", "duration_sec"}, + }, + { + name: "final with ID only", + args: []string{"--draft-task-id", "cgt-example-draft"}, + want: map[string]any{"draft_task_id": "cgt-example-draft", "prompt": ""}, + absent: []string{"draft", "resolution", "ratio", "duration_sec", "seed", "task_type", "generate_type", "images", "videos", "audios"}, + }, + { + name: "explicit false", + args: []string{"--draft=false", "--draft-task-id", "cgt-example-draft", "--seed", "-1"}, + want: map[string]any{"draft": false, "draft_task_id": "cgt-example-draft", "seed": float64(-1)}, + absent: []string{"resolution", "ratio", "duration_sec"}, + }, + { + name: "conflicting parameters are left to the service", + args: []string{"--draft", "--draft-task-id", "cgt-example-draft", "--task-type", "future-mode", "--duration", "-1", "--ratio", "adaptive"}, + want: map[string]any{"draft": true, "draft_task_id": "cgt-example-draft", "task_type": "future-mode", "duration_sec": float64(-1), "ratio": "adaptive"}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + submitted := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/biz/v1/skill/submit_run" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + data, err := io.ReadAll(r.Body) + if err != nil { + t.Fatal(err) + } + body := decodeJSON(t, data) + param, ok := body["video_part_tool_param"].(map[string]any) + if !ok { + t.Fatalf("missing video_part_tool_param: %#v", body) + } + if param["model"] != "Seedance_2.5_draft" || body["agent_name"] != "pippit_video_part_agent" { + t.Fatalf("unexpected route/model: %#v", body) + } + for key, want := range tt.want { + if param[key] != want { + t.Fatalf("%s = %#v, want %#v", key, param[key], want) + } + } + for _, key := range tt.absent { + if _, exists := param[key]; exists { + t.Fatalf("%s must stay omitted: %#v", key, param) + } + } + submitted = true + _, _ = w.Write([]byte(`{"ret":"0","data":{"run":{"thread_id":"thread_123","run_id":"run_456"}}}`)) + })) + defer server.Close() + var stdout, stderr bytes.Buffer + root := newTestRootCommand(t, &stdout, &stderr, server.URL) + root.SetArgs(append([]string{"generate-video", "--model", "Seedance_2.5_draft"}, tt.args...)) + if err := root.Execute(); err != nil { + t.Fatalf("Execute(): %v, stderr=%s", err, stderr.String()) + } + if !submitted { + t.Fatal("request not submitted") + } + }) + } +} + func TestGenerateVideoAcceptsReferencesBeyondFormerLimits(t *testing.T) { cwd := chdirTemp(t) args := []string{"generate-video", "--prompt", "x"} @@ -430,7 +506,7 @@ func TestQueryResultDownloadsCompletedVideo(t *testing.T) { if video["download_url"] != downloadURL || video["output_path"] != outputPath { t.Fatalf("video = %#v, want download_url/output_path", video) } - for _, unwanted := range []string{"vid", "asset_id", "title"} { + for _, unwanted := range []string{"vid", "asset_id", "title", "draft", "draft_task_id"} { if _, ok := video[unwanted]; ok { t.Fatalf("video = %#v, should not contain %s", video, unwanted) } @@ -438,6 +514,55 @@ func TestQueryResultDownloadsCompletedVideo(t *testing.T) { assertFileContent(t, outputPath, "video-data") } +func TestQueryResultPreservesDraftMetadata(t *testing.T) { + for _, draft := range []bool{true, false} { + t.Run(map[bool]string{true: "preview", false: "final"}[draft], func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/biz/v1/skill/get_thread": + videoJSON, err := sonic.Marshal(map[string]any{"video": map[string]any{ + "download_url": serverURL(r) + "/video.mp4", + "draft": draft, "draft_task_id": "cgt-example-draft", + }}) + if err != nil { + t.Fatal(err) + } + // Agent parts carry their data as a JSON string. + encoded, err := sonic.Marshal(string(videoJSON)) + if err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte(`{"ret":"0","data":{"thread":{"thread_id":"draft_thread","run_list":[{"run_id":"draft_run","state":3,"entry_list":[{"artifact":{"content":[{"sub_type":"biz/x_data_video","data":` + string(encoded) + `}]}}]}]}}}`)) + case "/video.mp4": + _, _ = w.Write([]byte("video-data")) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer server.Close() + var stdout, stderr bytes.Buffer + root := newTestRootCommand(t, &stdout, &stderr, server.URL) + root.SetArgs([]string{"query-result", "--thread-id", "draft_thread", "--run-id", "draft_run", "--download-dir", t.TempDir()}) + if err := root.Execute(); err != nil { + t.Fatalf("Execute(): %v", err) + } + got := decodeJSON(t, stdout.Bytes()) + if got["completed"] != true || got["error_message"] != "" { + t.Fatalf("unexpected result: %#v", got) + } + videos, ok := got["videos"].([]any) + if !ok || len(videos) != 1 { + t.Fatalf("unexpected videos: %#v", got) + } + video := videos[0].(map[string]any) + if video["draft"] != draft || video["draft_task_id"] != "cgt-example-draft" { + t.Fatalf("draft metadata was lost: %#v", video) + } + assertFileContent(t, video["output_path"].(string), "video-data") + }) + } +} + func TestQueryResultIgnoresVideoDataWithoutVideoSubType(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { diff --git a/cmd/marketing.go b/cmd/marketing.go index b9121c5..666b377 100644 --- a/cmd/marketing.go +++ b/cmd/marketing.go @@ -36,7 +36,7 @@ func newMarketingCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra } func newMarketingAction(action string, stdout, stderr io.Writer, runner *common.Runner) *cobra.Command { - var requestFile, file, threadID, runID string + var requestFile, file, threadID, runID, source string var execute bool var timeout time.Duration command := &cobra.Command{Use: action, Args: cobra.NoArgs, Short: "Call marketing " + action} @@ -45,6 +45,7 @@ func newMarketingAction(action string, stdout, stderr io.Writer, runner *common. command.Flags().DurationVar(&timeout, "timeout", 60*time.Second, "request deadline (e.g. 60s)") switch action { case "generate": + command.Flags().StringVar(&source, "source", "", "optional host agent/platform identifier for statistics only; filled silently by the host agent (e.g. doubao_office, workbuddy, codex)") command.Flags().StringVar(&requestFile, "request", "", "request JSON file, or - for stdin") command.Flags().BoolVar(&execute, "execute", false, "submit generation; otherwise preview only") case "query": @@ -96,6 +97,9 @@ func newMarketingAction(action string, stdout, stderr io.Writer, runner *common. if json.Unmarshal(value["general_agent_settings"], &settings) != nil || strings.TrimSpace(settings.VideoModel) == "" { return fmt.Errorf("general_agent_settings.video_model 必填") } + if platform := strings.TrimSpace(source); platform != "" { + value["platform"], _ = json.Marshal(platform) + } body = value if !execute { return common.WriteJSON(stdout, map[string]any{"dry_run": true, "url": config.DefaultBaseURL + marketingPaths[action], "body": body}) diff --git a/cmd/marketing_test.go b/cmd/marketing_test.go index 854fac3..38be0a9 100644 --- a/cmd/marketing_test.go +++ b/cmd/marketing_test.go @@ -3,6 +3,7 @@ package cmd import ( "bytes" "context" + "encoding/json" "fmt" "io" "net/http" @@ -49,6 +50,15 @@ func TestMarketingUsesSharedBrowserAuth(t *testing.T) { if bytes.Contains(data, []byte("browser-secret")) { t.Error("credential leaked into payload") } + if action == "generate" { + var body map[string]any + if err := json.Unmarshal(data, &body); err != nil || body["platform"] != "codex" || body["message"] != "make an ad" { + t.Errorf("unexpected marketing payload: %s", data) + } + if _, ok := body["source"]; ok { + t.Error("source must be sent as platform") + } + } if action == "upload" && !bytes.Contains(data, []byte(`name="file"`)) { t.Error("missing multipart file") } @@ -66,7 +76,7 @@ func TestMarketingUsesSharedBrowserAuth(t *testing.T) { args := []string{"marketing", action} switch action { case "generate": - args = append(args, "--request", "-", "--execute") + args = append(args, "--request", "-", "--execute", "--source", " codex ") root.SetIn(strings.NewReader(marketingRequest)) case "query": args = append(args, "--thread-id", "thread", "--run-id", "run") diff --git a/cmd/root.go b/cmd/root.go index 576d1d0..d73376f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -62,6 +62,17 @@ func newRootCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Comm SilenceUsage: true, SilenceErrors: true, } + // Only commands already exposing --source receive attribution. Explicit empty + // flags suppress fallback, and no source is persisted between CLI processes. + root.PersistentPreRunE = func(cmd *cobra.Command, _ []string) error { + flag := cmd.Flags().Lookup("source") + if flag != nil && !flag.Changed { + if source := common.DetectHostSource(); source != "" { + return cmd.Flags().Set("source", source) + } + } + return nil + } root.CompletionOptions.DisableDefaultCmd = true root.SetVersionTemplate("{{.Version}}\n") root.SetOut(stdout) diff --git a/cmd/source_test.go b/cmd/source_test.go index 79eb0fc..85c1b54 100644 --- a/cmd/source_test.go +++ b/cmd/source_test.go @@ -16,6 +16,10 @@ import ( ) func TestSkillSubmitSourcePreservesCreativeRequest(t *testing.T) { + for _, key := range []string{"PIPPIT_CLI_SOURCE", "CODEX_THREAD_ID", "CODEX_SESSION_ID", "CLAUDECODE", "CURSOR_AGENT", "GEMINI_CLI"} { + t.Setenv(key, "") + } + t.Setenv("HOME", t.TempDir()) t.Setenv("XDG_CACHE_HOME", t.TempDir()) t.Setenv("LocalAppData", t.TempDir()) @@ -121,6 +125,26 @@ func TestSkillSubmitSourcePreservesCreativeRequest(t *testing.T) { } }) } + t.Run("host_environment", func(t *testing.T) { + t.Setenv("CODEX_THREAD_ID", "session-must-not-be-reported") + execute() + if received["platform"] != "codex" { + t.Errorf("runtime host not detected: %#v", received["platform"]) + } + t.Setenv("PIPPIT_CLI_SOURCE", " workbuddy ") + execute() + if received["platform"] != "workbuddy" { + t.Error("explicit environment must win") + } + execute("--source", "doubao_office") + if received["platform"] != "doubao_office" { + t.Error("explicit flag must win") + } + execute("--source", "") + if _, exists := received["platform"]; exists { + t.Error("explicit empty must suppress attribution") + } + }) // A fresh invocation without source must not reuse previous attribution. execute() if _, exists := received["platform"]; exists { diff --git a/cmd/submit_run_test.go b/cmd/submit_run_test.go index f299b6c..32b81f3 100644 --- a/cmd/submit_run_test.go +++ b/cmd/submit_run_test.go @@ -17,7 +17,7 @@ func TestSubmitRunCommandPreservesScriptContract(t *testing.T) { 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} + args := []string{"submit-run", "--message", message, "--source", ""} if existing { want["thread_id"] = "skill_original_thread" want["asset_ids"] = []any{"asset_original_1", "asset_original_2"} diff --git a/cmd/update/update.go b/cmd/update/update.go index e83ec8a..98f7b0a 100644 --- a/cmd/update/update.go +++ b/cmd/update/update.go @@ -42,17 +42,22 @@ var telemetryHTTPClient = &http.Client{Timeout: telemetryWaitTimeout} // NewCommand builds the update command. func NewCommand(stdout, stderr io.Writer) *cobra.Command { + var source string cmd := &cobra.Command{ Use: "update", Short: "Update pippit-tool-cli and bundled skills", RunE: func(cmd *cobra.Command, _ []string) error { - return runUpdate(stdout, stderr) + if !cmd.Flags().Changed("source") { + source = os.Getenv("PIPPIT_CLI_SOURCE") + } + return runUpdate(stdout, stderr, source) }, } + cmd.Flags().StringVar(&source, "source", "", "optional real host identifier reported as host_platform; defaults to PIPPIT_CLI_SOURCE when unset") return cmd } -func runUpdate(stdout, stderr io.Writer) error { +func runUpdate(stdout, stderr io.Writer, source string) error { pkg := os.Getenv("PIPPIT_CLI_INSTALL_PACKAGE") if pkg == "" { pkg = defaultPackage + "@latest" @@ -78,7 +83,7 @@ func runUpdate(stdout, stderr io.Writer) error { return fmt.Errorf("更新 pippit-tool-cli skills 失败: %w", err) } - reportBundledSkillTelemetry("update", "cli_update", stderr) + reportBundledSkillTelemetry("update", "cli_update", source, stderr) fmt.Fprintln(stdout, "pippit-tool-cli and skills updated") return nil } @@ -129,27 +134,29 @@ func cleanupLegacyGlobalSkills(globalSkillsDir string) error { } type telemetryPayload struct { - Event string `json:"event"` - SkillName string `json:"skill_name"` - Source string `json:"source"` - CliVersion string `json:"cli_version"` - Platform string `json:"platform"` - Arch string `json:"arch"` + Event string `json:"event"` + SkillName string `json:"skill_name"` + Source string `json:"source"` + HostPlatform string `json:"host_platform,omitempty"` + CliVersion string `json:"cli_version"` + Platform string `json:"platform"` + Arch string `json:"arch"` } -func reportBundledSkillTelemetry(event string, source string, stderr io.Writer) { +func reportBundledSkillTelemetry(event, source, hostPlatform string, stderr io.Writer) { if os.Getenv("PIPPIT_CLI_DISABLE_TELEMETRY") == "1" { return } var wg sync.WaitGroup for _, skillName := range telemetrySkillNames { payload := telemetryPayload{ - Event: event, - SkillName: skillName, - Source: source, - CliVersion: telemetryCliVersion(), - Platform: runtime.GOOS, - Arch: runtime.GOARCH, + Event: event, + SkillName: skillName, + Source: source, + HostPlatform: strings.TrimSpace(hostPlatform), + CliVersion: telemetryCliVersion(), + Platform: runtime.GOOS, + Arch: runtime.GOARCH, } wg.Add(1) go func(payload telemetryPayload) { diff --git a/cmd/update/update_test.go b/cmd/update/update_test.go index 150a0f6..522a622 100644 --- a/cmd/update/update_test.go +++ b/cmd/update/update_test.go @@ -93,12 +93,13 @@ func TestReportSkillTelemetry(t *testing.T) { t.Setenv("PIPPIT_CLI_TELEMETRY_BASE_URL", server.URL+"/") err := reportSkillTelemetry(telemetryPayload{ - Event: "update", - SkillName: "xyq-skill", - Source: "cli_update", - CliVersion: "0.0.26", - Platform: "darwin", - Arch: "arm64", + Event: "update", + SkillName: "xyq-skill", + Source: "cli_update", + HostPlatform: "workbuddy", + CliVersion: "0.0.26", + Platform: "darwin", + Arch: "arm64", }) if err != nil { t.Fatalf("reportSkillTelemetry() error = %v", err) @@ -106,7 +107,7 @@ func TestReportSkillTelemetry(t *testing.T) { if gotAuth != telemetryAuthHeader { t.Fatalf("Authorization = %q, want %q", gotAuth, telemetryAuthHeader) } - if gotPayload.Event != "update" || gotPayload.SkillName != "xyq-skill" || gotPayload.Source != "cli_update" { + if gotPayload.Event != "update" || gotPayload.SkillName != "xyq-skill" || gotPayload.Source != "cli_update" || gotPayload.HostPlatform != "workbuddy" { t.Fatalf("payload = %#v", gotPayload) } } @@ -120,7 +121,7 @@ func TestReportBundledSkillTelemetryWaitsBriefly(t *testing.T) { t.Setenv("PIPPIT_CLI_TELEMETRY_BASE_URL", server.URL) start := time.Now() - reportBundledSkillTelemetry("update", "cli_update", &bytes.Buffer{}) + reportBundledSkillTelemetry("update", "cli_update", " workbuddy ", &bytes.Buffer{}) if elapsed := time.Since(start); elapsed > 1500*time.Millisecond { t.Fatalf("reportBundledSkillTelemetry() blocked for %v, want <= 1.5s", elapsed) } @@ -133,13 +134,16 @@ func TestReportBundledSkillTelemetryReportsBothSkills(t *testing.T) { if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { t.Fatalf("decode request body: %v", err) } + if payload.Source != "cli_update" || payload.HostPlatform != "workbuddy" || payload.Platform != runtime.GOOS { + t.Errorf("unexpected attribution: %#v", payload) + } skillNames <- payload.SkillName _, _ = w.Write([]byte(`{"ret":"0","errmsg":""}`)) })) defer server.Close() t.Setenv("PIPPIT_CLI_TELEMETRY_BASE_URL", server.URL) - reportBundledSkillTelemetry("update", "cli_update", &bytes.Buffer{}) + reportBundledSkillTelemetry("update", "cli_update", " workbuddy ", &bytes.Buffer{}) got := map[string]bool{} for i := 0; i < len(telemetrySkillNames); i++ { @@ -256,3 +260,13 @@ func parseEnvironment(environment string) map[string]string { } return result } + +func TestTelemetryOmitsUnknownHostPlatform(t *testing.T) { + body, err := json.Marshal(telemetryPayload{Event: "update", Source: "cli_update"}) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(body, []byte(`"host_platform"`)) { + t.Fatalf("unknown host_platform must be omitted: %s", body) + } +} diff --git a/internal/common/host_source.go b/internal/common/host_source.go new file mode 100644 index 0000000..f0691ee --- /dev/null +++ b/internal/common/host_source.go @@ -0,0 +1,36 @@ +package common + +import ( + "os" + "strings" +) + +// DetectHostSource uses explicit environment attribution before runtime markers. +// It never reads credentials, session contents, process arguments or installed apps. +func DetectHostSource() string { + return detectHostSource(os.Getenv) +} + +func detectHostSource(getenv func(string) string) string { + if source := strings.TrimSpace(getenv("PIPPIT_CLI_SOURCE")); source != "" { + return source + } + source := "" + for _, marker := range []struct{ key, host string }{ + {"CODEX_THREAD_ID", "codex"}, + {"CODEX_SESSION_ID", "codex"}, + {"CLAUDECODE", "claude_code"}, + {"CURSOR_AGENT", "cursor"}, + {"GEMINI_CLI", "gemini_cli"}, + } { + value := strings.ToLower(strings.TrimSpace(getenv(marker.key))) + if value == "" || value == "0" || value == "false" { + continue + } + if source != "" && source != marker.host { + return "" + } + source = marker.host + } + return source +} diff --git a/internal/common/host_source_test.go b/internal/common/host_source_test.go new file mode 100644 index 0000000..679613d --- /dev/null +++ b/internal/common/host_source_test.go @@ -0,0 +1,27 @@ +package common + +import "testing" + +func TestDetectHostSource(t *testing.T) { + for _, tc := range []struct { + name string + env map[string]string + want string + }{ + {"unknown", nil, ""}, + {"explicit host", map[string]string{"PIPPIT_CLI_SOURCE": " workbuddy ", "CODEX_THREAD_ID": "secret"}, "workbuddy"}, + {"codex", map[string]string{"CODEX_THREAD_ID": "secret", "CODEX_SESSION_ID": "other-secret"}, "codex"}, + {"claude", map[string]string{"CLAUDECODE": "1"}, "claude_code"}, + {"cursor", map[string]string{"CURSOR_AGENT": "1"}, "cursor"}, + {"gemini", map[string]string{"GEMINI_CLI": "1"}, "gemini_cli"}, + {"ambiguous", map[string]string{"CODEX_THREAD_ID": "secret", "CURSOR_AGENT": "1"}, ""}, + {"disabled markers", map[string]string{"CLAUDECODE": "0", "GEMINI_CLI": " false "}, ""}, + {"configuration is not runtime evidence", map[string]string{"CODEX_HOME": "/home/example", "ANTHROPIC_API_KEY": "secret", "TERM_PROGRAM": "vscode"}, ""}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := detectHostSource(func(key string) string { return tc.env[key] }); got != tc.want { + t.Errorf("source = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/internal/common/video_part.go b/internal/common/video_part.go index 3768f46..e50f1d9 100644 --- a/internal/common/video_part.go +++ b/internal/common/video_part.go @@ -16,6 +16,10 @@ type VideoPartToolParam struct { Model string `json:"model,omitempty"` Resolution string `json:"resolution,omitempty"` GenerateType *int64 `json:"generate_type,omitempty"` + TaskType string `json:"task_type,omitempty"` + Seed *int64 `json:"seed,omitempty"` + Draft *bool `json:"draft,omitempty"` + DraftTaskID string `json:"draft_task_id,omitempty"` MiniToolParam *VideoMiniToolParam `json:"mini_tool_param,omitempty"` } diff --git a/internal/generate_video/generate_video.go b/internal/generate_video/generate_video.go index d0c87b4..2b98d6c 100644 --- a/internal/generate_video/generate_video.go +++ b/internal/generate_video/generate_video.go @@ -28,6 +28,10 @@ type Options struct { Model string Resolution string GenerateType *int64 + TaskType string + Seed *int64 + Draft *bool + DraftTaskID string } // Result is the JSON envelope printed by `pippit-tool-cli generate-video`. @@ -70,7 +74,7 @@ func ValidateOptions(opts *Options) error { if opts == nil { return fmt.Errorf("缺少必填参数 --prompt") } - if strings.TrimSpace(opts.Prompt) == "" { + if strings.TrimSpace(opts.DraftTaskID) == "" && strings.TrimSpace(opts.Prompt) == "" { return fmt.Errorf("缺少必填参数 --prompt") } if err := validateMediaExtensions("图片", opts.ImagePaths, allowedImageExtensions, allowedImageExtensionList); err != nil { @@ -123,6 +127,10 @@ func buildSubmitRunBody(opts *Options, imageAssetIDs []string, videoAssetIDs []s Model: strings.TrimSpace(opts.Model), Resolution: strings.TrimSpace(opts.Resolution), GenerateType: opts.GenerateType, + TaskType: strings.TrimSpace(opts.TaskType), + Seed: opts.Seed, + Draft: opts.Draft, + DraftTaskID: strings.TrimSpace(opts.DraftTaskID), } return common.WithSubmitRunSource(map[string]any{ diff --git a/internal/generate_video/query_result.go b/internal/generate_video/query_result.go index cf18302..4763c5c 100644 --- a/internal/generate_video/query_result.go +++ b/internal/generate_video/query_result.go @@ -39,6 +39,8 @@ type QueryResultResult struct { type QueryResultVideo struct { DownloadURL string `json:"download_url"` OutputPath string `json:"output_path"` + Draft *bool `json:"draft,omitempty"` + DraftTaskID string `json:"draft_task_id,omitempty"` } // QueryResultImage describes a downloaded image from query-result. @@ -83,6 +85,8 @@ type queryContentData struct { type queryVideo struct { DownloadURL string `json:"download_url"` + Draft *bool `json:"draft"` + DraftTaskID string `json:"draft_task_id"` Title string `json:"title"` VID string `json:"vid"` AssetID string `json:"asset_id"` @@ -173,6 +177,8 @@ func QueryResult(ctx context.Context, opts *QueryResultOptions, runner *common.R resultVideos = append(resultVideos, QueryResultVideo{ DownloadURL: video.DownloadURL, OutputPath: actualOutputPath, + Draft: video.Draft, + DraftTaskID: video.DraftTaskID, }) } diff --git a/internal/models/describe.go b/internal/models/describe.go index be91319..dc45568 100644 --- a/internal/models/describe.go +++ b/internal/models/describe.go @@ -204,6 +204,14 @@ func describeModel(raw json.RawMessage) (json.RawMessage, error) { case "first_last_frame": entry["generate_type"] = 1 } + switch mode.Key { + case "reference_generation": + entry["task_type"] = "reference" + case "video_edit": + entry["task_type"] = "edit" + case "video_extend": + entry["task_type"] = "extend" + } switch mode.RatioPolicy.ValueMode { case "smart": entry["ratio"] = map[string]any{"options": []string{"adaptive"}, "default": "adaptive"} diff --git a/package-lock.json b/package-lock.json index fc49f44..247c0b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pippit-dev/cli", - "version": "1.0.35", + "version": "1.0.37", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pippit-dev/cli", - "version": "1.0.35", + "version": "1.0.37", "hasInstallScript": true, "license": "MIT", "bin": { diff --git a/package.json b/package.json index 45ab7a2..957f60e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pippit-dev/cli", - "version": "1.0.35", + "version": "1.0.37", "description": "Pippit CLI", "bin": { "pippit-tool-cli": "scripts/run.js" diff --git a/scripts/install-wizard.js b/scripts/install-wizard.js index 76c0677..bd5f5ad 100755 --- a/scripts/install-wizard.js +++ b/scripts/install-wizard.js @@ -20,6 +20,8 @@ and skill directories. Installs the version of this package by default. PIPPIT_CLI_INSTALL_PACKAGE can override the package/version to install. Options: + --source HOST Real host identifier reported as host_platform; unknown hosts omit it + Defaults to PIPPIT_CLI_SOURCE when unset; an explicit empty value omits host_platform -h, --help Show this help without installing, updating, or sending telemetry `; @@ -65,11 +67,19 @@ function main(args = process.argv.slice(2)) { console.log(INSTALL_HELP); return; } - if (args.length > 0) { - console.error(`Unknown install argument: ${args[0]}. Run pippit-tool-cli install --help.`); - process.exitCode = 1; - return; + let source = process.env.PIPPIT_CLI_SOURCE || ''; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--source' && i + 1 < args.length && !args[i + 1].startsWith('--')) { + source = args[++i]; + } else if (args[i].startsWith('--source=')) { + source = args[i].slice('--source='.length); + } else { + console.error(`Invalid install argument or missing value: ${args[i]}. Run pippit-tool-cli install --help.`); + process.exitCode = 1; + return; + } } + source = source.trim(); const pkg = installPackage(); const installed = getGloballyInstalledVersion(); if (installed) { @@ -90,7 +100,7 @@ function main(args = process.argv.slice(2)) { throw err; } console.log('Existing global package does not contain skills; reinstalling...'); - run('npm', ['install', '-g', pkg], { timeout: 120000 }); + run('npm', ['install', '-g', pkg], { timeout: 120000, env: { ...process.env, PIPPIT_CLI_SKIP_SKILLS: '1' } }); installGlobalPackageSkills(DEFAULT_PKG); } @@ -102,7 +112,7 @@ function main(args = process.argv.slice(2)) { } console.log(`pippit-tool-cli is ready: ${bin}`); - reportBundledSkillTelemetry('install', 'npx_install'); + reportBundledSkillTelemetry('install', 'npx_install', source); console.log('Try: pippit-tool-cli short-drama +submit-run --message "写一个短剧开头"'); } diff --git a/scripts/install-wizard.test.js b/scripts/install-wizard.test.js index 651da27..ec01598 100644 --- a/scripts/install-wizard.test.js +++ b/scripts/install-wizard.test.js @@ -11,3 +11,73 @@ assert.strictEqual(installPackage(), `${DEFAULT_PKG}@${version}`); process.env.PIPPIT_CLI_INSTALL_PACKAGE = `${DEFAULT_PKG}@0.0.26`; assert.strictEqual(installPackage(), `${DEFAULT_PKG}@0.0.26`); + +// Exercise install attribution without npm, Skill writes, or network requests. +const fs = require('fs'); +const vm = require('vm'); +function installFixture(args, source, retry = false) { + const reports = [], installs = []; + const proc = { argv: [], env: source === undefined ? {} : { PIPPIT_CLI_SOURCE: source } }; + const module = { exports: {} }; + let skillCalls = 0; + const modules = { + fs: { existsSync: () => true }, + './platform': { + isWindows: false, + run: (...args) => installs.push(args), + runSilent: (_, args) => args[0] === 'list' ? '@pippit-dev/cli@1.0.0' : '/fixture', + }, + './skills': { DEFAULT_PKG, installGlobalPackageSkills: () => { + if (retry && skillCalls++ === 0) throw new Error('missing skills'); + } }, + './telemetry': { reportBundledSkillTelemetry: (...args) => reports.push(args) }, + }; + vm.runInNewContext(fs.readFileSync(require.resolve('./install-wizard'), 'utf8'), { + module, process: proc, console: { log() {}, error() {} }, + require: name => modules[name] || require(name), + }); + module.exports.main(args); + return { reports, installs, proc }; +} +for (const [args, env, want] of [ + [[], undefined, ''], [[], ' workbuddy ', 'workbuddy'], + [['--source', ' doubao_office '], 'workbuddy', 'doubao_office'], + [['--source=custom-host'], undefined, 'custom-host'], + [['--source', ''], 'workbuddy', ''], [['--source= '], 'workbuddy', ''], +]) { + const result = installFixture(args, env, true); + assert.deepStrictEqual(result.reports, [['install', 'npx_install', want]]); + assert.strictEqual(result.installs.length, 2); + assert(result.installs.every(call => call[2].env.PIPPIT_CLI_SKIP_SKILLS === '1'), 'nested postinstall must not double report'); +} +for (const args of [['--help'], ['--source'], ['--unknown']]) { + const result = installFixture(args); + assert.strictEqual(result.installs.length, 0); + assert.strictEqual(result.reports.length, 0); +} + +// Verify the HTTP payload, including omission and telemetry opt-out. +function telemetryFixture(source, disabled) { + const payloads = []; + const request = () => ({ on() {}, end: body => payloads.push(JSON.parse(body)) }); + const module = { exports: {} }; + vm.runInNewContext(fs.readFileSync(require.resolve('./telemetry'), 'utf8'), { + module, URL, Buffer, console, + process: { env: { PIPPIT_CLI_DISABLE_TELEMETRY: disabled } }, + require: name => ['http', 'https'].includes(name) ? { request } : require(name), + }); + module.exports.reportBundledSkillTelemetry('install', 'npm_install', source); + return payloads; +} +for (const source of [undefined, '', ' ', ' workbuddy ', 'another-host']) { + const payloads = telemetryFixture(source); + assert.strictEqual(payloads.length, 2); + for (const payload of payloads) { + assert.strictEqual(payload.source, 'npm_install'); + assert.strictEqual(payload.host_platform, (source || '').trim() || undefined); + assert.strictEqual(payload.platform, process.platform); + assert.strictEqual(payload.event, 'install'); + } +} +assert.strictEqual(telemetryFixture('workbuddy', '1').length, 0); +console.log('Install source forwarding, precedence, omission, retry and telemetry payload checks passed'); diff --git a/scripts/install.js b/scripts/install.js index b77bbbe..33519cd 100644 --- a/scripts/install.js +++ b/scripts/install.js @@ -137,7 +137,7 @@ function install({ cliOnly = false } = {}) { if (!cliOnly) { if (process.env.PIPPIT_CLI_SKIP_SKILLS !== "1") { installSkillsFromRoot(ROOT); - reportBundledSkillTelemetry("install", "npm_install"); + reportBundledSkillTelemetry("install", "npm_install", process.env.PIPPIT_CLI_SOURCE); } else { cleanupLegacyGlobalSkills(); } diff --git a/scripts/marketing-skill.test.js b/scripts/marketing-skill.test.js index 7409480..272ae6a 100644 --- a/scripts/marketing-skill.test.js +++ b/scripts/marketing-skill.test.js @@ -4,9 +4,23 @@ const os = require('os'); const path = require('path'); const http = require('http'); const { spawnSync } = require('child_process'); -const { BASE, PATHS, validate, checkResponse, resolveCLI, invokeCLI, createClient, main } = require('../skills/xyq-marketing-skill/scripts/marketing'); +const { BASE, PATHS, validate, checkResponse, resolveCLI, invokeCLI, createClient, resolveHostSource, main } = require('../skills/xyq-marketing-skill/scripts/marketing'); async function test() { + for (const key of ['PIPPIT_CLI_SOURCE', 'CODEX_THREAD_ID', 'CODEX_SESSION_ID', 'CLAUDECODE', 'CURSOR_AGENT', 'GEMINI_CLI']) delete process.env[key]; + for (const [explicit, env, want] of [ + [undefined, {}, ''], + [' workbuddy ', { CODEX_THREAD_ID: 'private-id' }, 'workbuddy'], + ['', { PIPPIT_CLI_SOURCE: 'codex' }, ''], + [undefined, { PIPPIT_CLI_SOURCE: ' doubao_office ', CODEX_THREAD_ID: 'private-id' }, 'doubao_office'], + [undefined, { CODEX_THREAD_ID: 'private-id', CODEX_SESSION_ID: 'private-session' }, 'codex'], + [undefined, { CLAUDECODE: '1' }, 'claude_code'], + [undefined, { CURSOR_AGENT: '1' }, 'cursor'], + [undefined, { GEMINI_CLI: '1' }, 'gemini_cli'], + [undefined, { CODEX_THREAD_ID: 'private-id', CURSOR_AGENT: '1' }, ''], + [undefined, { CLAUDECODE: '0', GEMINI_CLI: 'false', TERM_PROGRAM: 'vscode' }, ''], + ]) assert.strictEqual(resolveHostSource(explicit, env), want); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'xyq-marketing-')); const received = []; let respond; @@ -25,7 +39,7 @@ async function test() { }; const calls = []; let cliResult; - const client = createClient({ request, timeout: 1000, invoke: async (...args) => { calls.push(args); return cliResult; } }); + const client = createClient({ request, timeout: 1000, invoke: async (...args) => { calls.push(args); return args[0].includes('--help') ? ' --source string Host identifier\n' : cliResult; } }); const json = value => { respond = (_, res) => { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(value)); }; }; const ids = { thread_id: 'marketing-thread', run_id: 'marketing-run' }; const response = (state, extra = {}) => ({ ret: '0', data: { ...ids, run_state: state, ...extra } }); @@ -58,7 +72,12 @@ async function test() { await assert.rejects(main(['generate', '--request', file, '--execute', '--dry-run']), /不能同时/); await assert.rejects(main(['query', '--thread-id', ids.thread_id]), /run-id/); await assert.rejects(main(['query', '--thread-id', ids.thread_id, '--run-id', ids.run_id, '--timeout', '0']), /timeout/); - await assert.rejects(main(['generate', '--request', file, '--source', 'codex']), /无效/); + for (const source of [' codex ', '', ' ']) { + const preview = []; + await main(['generate', '--request', file, '--source', source], { out: line => preview.push(JSON.parse(line)), clientFactory: () => { throw new Error('dry-run contacted API'); } }); + assert.deepStrictEqual(preview[0].body, source.trim() ? { ...body, platform: source.trim() } : body); + } + await assert.rejects(main(['balance', '--source', 'codex']), /无效/); assert.strictEqual(received.length, 0); cliResult = { ret: 0, log_id: 'log-submit', data: { run: { ...ids, state: 1 } } }; @@ -67,6 +86,19 @@ async function test() { assert.deepStrictEqual(JSON.parse(calls[0][1]), body); assert(!calls[0][0].includes(body.message), 'request must use stdin, not command arguments'); assert.strictEqual(received.length, 0, 'Node must never make authenticated API requests'); + await main(['generate', '--request', file, '--source', ' codex ', '--execute'], { out: () => {}, clientFactory: () => client }); + assert.deepStrictEqual(calls[calls.length - 1][0], ['marketing', 'generate', '--timeout', '1000ms', '--request', '-', '--execute', '--source', 'codex']); + assert.deepStrictEqual(JSON.parse(calls[calls.length - 1][1]), body); + process.env.PIPPIT_CLI_SOURCE = ' workbuddy '; + const inferredPreview = []; + await main(['generate', '--request', file], { out: line => inferredPreview.push(JSON.parse(line)) }); + assert.strictEqual(inferredPreview[0].body.platform, 'workbuddy'); + await main(['generate', '--request', file, '--execute'], { out: () => {}, clientFactory: () => client }); + assert.deepStrictEqual(calls[calls.length - 1][0].slice(-2), ['--source', 'workbuddy']); + await main(['generate', '--request', file, '--source', '', '--execute'], { out: () => {}, clientFactory: () => client }); + assert.deepStrictEqual(calls[calls.length - 1][0].slice(-2), ['--source', '']); + delete process.env.PIPPIT_CLI_SOURCE; + const uploadFile = path.join(dir, '商品 with spaces.png'); fs.writeFileSync(uploadFile, Buffer.from([0, 1, 2, 255])); cliResult = { ret: '0', data: { pippit_asset_id: 'asset-real' } }; @@ -109,6 +141,36 @@ async function test() { const bridge = await invokeCLI(['marketing', 'generate', '--request', '-'], JSON.stringify(body), 3000, invocation); assert.deepStrictEqual(bridge.args, ['marketing', 'generate', '--request', '-']); assert.deepStrictEqual(JSON.parse(bridge.input), body); + // Standalone Skill + old CLI: probe help, omit unsupported attribution, never replay. + for (const support of ['new', 'old', 'help-failed']) { + fs.writeFileSync(fixture, ` + if (process.argv.includes('--help')) { + if (${JSON.stringify(support)} === 'help-failed') process.exit(1); + console.log(${JSON.stringify(support === 'new' ? ' --source string Host identifier' : ' --request string Request JSON')}); + } else if (${JSON.stringify(support)} !== 'new' && process.argv.includes('--source')) { + console.error('unknown flag: --source'); process.exitCode = 1; + } else { + process.stdin.resume(); + process.stdin.on('end', () => console.log(JSON.stringify({ret: 0, data: {run: ${JSON.stringify(ids)}}}))); + } + `); + process.env.CODEX_THREAD_ID = 'fixture-session'; + for (const sourceArgs of [[], ['--source', 'workbuddy'], ['--source', '']]) { + const legacyCalls = []; + const legacyClient = createClient({ invoke: async (...args) => { + legacyCalls.push(args); + return invokeCLI(...args, invocation); + } }); + assert.strictEqual(await main(['generate', '--request', file, '--execute', ...sourceArgs], { out: () => {}, clientFactory: () => legacyClient }), 0); + assert.strictEqual(legacyCalls.length, 2, 'one help probe and exactly one submission'); + assert.deepStrictEqual(legacyCalls[0][0], ['marketing', 'generate', '--help']); + const submitted = legacyCalls[1]; + assert.strictEqual(submitted[0].includes('--source'), support === 'new'); + if (support === 'new') assert.deepStrictEqual(submitted[0].slice(-2), ['--source', sourceArgs.length ? sourceArgs[1] : 'codex']); + assert.deepStrictEqual(JSON.parse(submitted[1]), body); + } + delete process.env.CODEX_THREAD_ID; + } fs.writeFileSync(fixture, `console.error('请先运行 pippit-tool-cli login'); process.exitCode = 1;`); await assert.rejects(invokeCLI(['marketing', 'balance'], undefined, 3000, invocation), /pippit-tool-cli login/); fs.writeFileSync(fixture, `console.log('invalid JSON');`); diff --git a/scripts/skills.test.js b/scripts/skills.test.js index 038a6fd..0150974 100644 --- a/scripts/skills.test.js +++ b/scripts/skills.test.js @@ -122,7 +122,8 @@ for (const [moduleName, commands] of Object.entries(commandModules)) { } } const documentedCommands = [...new Set([...skillDocuments.matchAll(/\bpippit-tool-cli ([a-z][a-z-]*)\b/g)].map((match) => match[1]))].sort(); -assert.deepStrictEqual(documentedCommands, Object.values(commandModules).flat().sort(), "Skill must document exactly its supported CLI commands"); +// The npm launcher install command is documented in scripts/install.md, not a command module. +assert.deepStrictEqual(documentedCommands, [...Object.values(commandModules).flat(), "install"].sort(), "Skill must document exactly its supported CLI commands"); for (const requiredText of ["XYQ_ACCESS_KEY", "web_thread_link", "request_user_input", "ask_user_question"]) { assert(skillDocuments.includes(requiredText), `xyq-skill missing contract: ${requiredText}`); } diff --git a/scripts/telemetry.js b/scripts/telemetry.js index a4fd1d8..ee4950b 100644 --- a/scripts/telemetry.js +++ b/scripts/telemetry.js @@ -19,7 +19,7 @@ function telemetryBaseURL() { return DEFAULT_BASE_URL; } -function reportBundledSkillTelemetry(event, source) { +function reportBundledSkillTelemetry(event, source, hostPlatform) { if (process.env.PIPPIT_CLI_DISABLE_TELEMETRY === "1") { return; } @@ -28,6 +28,7 @@ function reportBundledSkillTelemetry(event, source) { event, skill_name: skillName, source, + ...((hostPlatform || "").trim() ? { host_platform: hostPlatform.trim() } : {}), cli_version: VERSION, platform: os.platform(), arch: os.arch(), diff --git a/skills/short-drama/SKILL.md b/skills/short-drama/SKILL.md index ac20159..88eee77 100644 --- a/skills/short-drama/SKILL.md +++ b/skills/short-drama/SKILL.md @@ -41,6 +41,8 @@ metadata: ## 宿主来源统计 +优先从宿主的系统身份、当前工具运行环境和明确的平台元数据确认真实宿主,并在每次提交主动填写 `--source`;不要仅因字段可选就跳过。缺少显式值时,CLI 依次尝试 `PIPPIT_CLI_SOURCE` 和已核实的宿主运行标记。只上报稳定的平台名,不上报标记中的会话 ID;来源冲突或仍无法确认则省略,不询问用户、不扫描登录凭据或安装目录。显式 `--source ""` 禁用该次自动归因。 + 仅 `short-drama +submit-run` 静默附加可选 `--source`,用于统计:豆包办公 `doubao_office`、WorkBuddy `workbuddy`、Codex `codex`;其它宿主使用可信环境中的真实稳定标识,无法确认则省略。不从创作文本猜来源,不附带版本、会话 ID、用户信息或 prompt,不向用户询问,不改写 `--message`。上传、查询、下载不附加此参数;旧 CLI 的 `--help` 不支持时省略,不为统计字段重提任务。 ## 按需参考的完整场景 diff --git a/skills/short-drama/scripts/install.md b/skills/short-drama/scripts/install.md index aafe7d5..6eb7799 100644 --- a/skills/short-drama/scripts/install.md +++ b/skills/short-drama/scripts/install.md @@ -26,6 +26,8 @@ npm install -g @pippit-dev/cli@latest npm 入口声明 Node.js 16+;安装/升级还需 npm、全局目录写权限、访问 npm 和 GitHub Releases 的网络、curl,以及 macOS/Linux 的 tar 或 Windows 的 PowerShell。无需 Go 编译器或 Python。默认安装会同时安装全局 Skills,`pippit-tool-cli update` 也会更新它们;不能当成无副作用的例行检查。升级成功后只恢复原任务,不重复创作提交。 +宿主调用 `pippit-tool-cli install` / `update` 时,按可信运行环境静默附加可选 `--source HOST`;直接运行 npm 全局安装时,在该次命令环境中设置 `PIPPIT_CLI_SOURCE`。`HOST` 为真实宿主标识的占位符,不固定平台,未知则省略,不询问用户。安装/更新将该值写入 `host_platform`,保留原有 `source` 和操作系统 `platform`;服务端需支持新字段才能计入统计。旧版命令不支持该参数时省略,不为统计重复安装;内部仅安装 CLI 的脚本沿用原有不上报行为。 + 安装或升级一次后仍失败、命令仍缺失时停止,说明失败环节,不循环重装。版本和普通命令可能触发 npm 版本提示检查;宿主需禁用时可设置 `PIPPIT_CLI_DISABLE_UPDATE_CHECK=1`。 本技能没有单独的自动安装脚本,也不依赖综合创作 Skill 的脚本。独立 ZIP 应保留整个 commands、workflows、examples、scripts 目录及相对引用;`SKILL.md` 的 name 与 ZIP 文件名保持一致。 diff --git a/skills/xyq-marketing-skill/SKILL.md b/skills/xyq-marketing-skill/SKILL.md index 518b96c..b96bafb 100644 --- a/skills/xyq-marketing-skill/SKILL.md +++ b/skills/xyq-marketing-skill/SKILL.md @@ -14,6 +14,8 @@ metadata: 需要 Node.js 16+ 和支持 `marketing` 命令的 `@pippit-dev/cli`。脚本位置为 `"{baseDir}/scripts/marketing.js"`;下面示例中的相对路径从本 Skill 目录执行。先检查 `pippit-tool-cli marketing --help`;命令不存在时安装或更新 CLI:`npm install -g @pippit-dev/cli@latest`,再检查一次;仍不支持就报告版本阻塞,不改为向用户索要密钥。 +宿主调用 `pippit-tool-cli install` / `update` 时,按可信运行环境静默附加可选 `--source HOST`;直接运行 npm 全局安装时,在该次命令环境中设置 `PIPPIT_CLI_SOURCE`。`HOST` 为真实宿主标识的占位符,不固定平台,未知则省略,不询问用户。安装/更新将该值写入 `host_platform`,保留原有 `source` 和操作系统 `platform`;服务端需支持新字段才能计入统计。旧版命令不支持该参数时省略,不为统计重复安装;内部仅安装 CLI 的脚本沿用原有不上报行为。 + 营销 API 调用复用其它 CLI 命令的登录态,脚本不读取或导出凭据。执行真实请求前运行 `pippit-tool-cli status`,读取 JSON 的 `logged_in`,不能只看退出码。未登录或凭据过期时运行 `pippit-tool-cli login`,让用户在浏览器完成授权;等待 CLI 成功返回后再次检查 status,再继续原任务。已有有效登录态直接复用,不重复登录;离线预览和帮助不要求登录。 不要让用户提供或复制 `access_token`、Access Key,也不要把凭据写入聊天、请求文件、命令参数或日志。CLI 保留原有 `XYQ_ACCESS_KEY` 显式环境覆盖规则,优先于网页登录;这仅用于已配置的自动化环境,不作为普通用户的必填项。覆盖无效时不能静默切换账号。网页登录凭据被拒绝时按 CLI 的 `login --force` 流程处理,不自动重提可能已创建的生成任务。 @@ -38,6 +40,12 @@ node scripts/marketing.js balance 生成默认是离线预览;预览不需要登录。上传、查询、余额是实际 API 请求。`--timeout` 设置单请求总时限(秒,默认 60)。`--wait` 由脚本每 10 秒查询,默认最多 900 秒;不再叠加其它轮询器。没有 `--wait` 时只查询一次。脚本输出逐行 JSON,查询下载前先输出服务端响应,再逐个输出已下载文件,最后输出含 `downloaded_files` 的响应。 +## Source Attribution + +仅营销生成命令(`marketing generate` 或 `marketing.js generate`)静默附加可选 `--source`,由 CLI 映射为请求体 `platform`,不写入创作文本或请求文件。豆包办公填 `doubao_office`,WorkBuddy 填 `workbuddy`,Codex 填 `codex`;其它宿主使用可信环境中的真实稳定标识,未知则省略,不询问用户。不附带版本、会话 ID、用户信息或 prompt。首尾空白去除,未传或空值省略;首次生成和同一会话继续提交均适用。上传、查询、下载、余额不附加。旧 CLI 不支持时省略,不为来源统计重提任务。 + +优先从宿主的系统身份、当前工具运行环境和明确的平台元数据确认真实宿主,并在每次提交主动填写 `--source`;不要仅因字段可选就跳过。缺少显式值时,CLI 依次尝试 `PIPPIT_CLI_SOURCE` 和已核实的宿主运行标记。只上报稳定的平台名,不上报标记中的会话 ID;来源冲突或仍无法确认则省略,不询问用户、不扫描登录凭据或安装目录。显式 `--source ""` 禁用该次自动归因。 + ## Completion and Recovery - `ret` 为字符串或数字 `0` 才是 API 业务成功。查询 `data.run_state`:1/2/7 为已提交或进行中;8 为程序中断(可能等待工具回调),有时限地继续查询;3 为本轮完成;4/5 为失败/取消;6/9 为等待用户交互。不能把退出码 0 的单次进行中查询当作生成完成。 @@ -50,4 +58,4 @@ node scripts/marketing.js balance ## Scope -本 Skill 仅使用正式公开营销接口;不宣称支持团队空间切换。鉴权范围沿用当前 CLI 登录身份和服务端授权,公开请求没有 `TeamID` 字段,不自行加入团队字段或跨账号复用资产/任务 ID。沉浸式短片、火山引擎服务和现有 CLI 的来源统计参数不在此脚本范围;不要把 `--source` 等未公开字段传给营销接口。 +本 Skill 仅使用正式公开营销接口;不宣称支持团队空间切换。鉴权范围沿用当前 CLI 登录身份和服务端授权,公开请求没有 `TeamID` 字段,不自行加入团队字段或跨账号复用资产/任务 ID。沉浸式短片、火山引擎服务不在此脚本范围。 diff --git a/skills/xyq-marketing-skill/references/api.md b/skills/xyq-marketing-skill/references/api.md index 9a53460..bb3f7b4 100644 --- a/skills/xyq-marketing-skill/references/api.md +++ b/skills/xyq-marketing-skill/references/api.md @@ -22,6 +22,8 @@ Base URL:`https://xyq.jianying.com`。全部使用 POST,`Accept: application | `general_agent_settings` | object | 必传,且必须包含非空 `video_model` | | `thread_id` | string | 可选,只在继续已有营销会话时使用 | +来源统计由生成命令的可选 `--source HOST` 提供,CLI 在提交及预览的请求体中映射为 `platform` 字符串;不传或去除首尾空白后为空时省略。请求文件仍只填写上表中的创作字段,不直接添加 `source` 或 `platform`。 + 设置块必须传对象,且 `video_model` 必填。2026-09-22 实际调用及服务端 `validateMarketingGeneralAgentSettings` 均确认:`{}` 或仅有比例/时长会返回 `ret=2`、缺少 `video_model`。不再按文档中的默认策略说明发送空对象;预览和提交都先本地校验。用户未指定模型时先询问,已有明确选择授权时按授权选择,不隐式降级。 | 设置字段 | 类型 | 含义 | diff --git a/skills/xyq-marketing-skill/scripts/marketing.js b/skills/xyq-marketing-skill/scripts/marketing.js index 2062068..29d5585 100644 --- a/skills/xyq-marketing-skill/scripts/marketing.js +++ b/skills/xyq-marketing-skill/scripts/marketing.js @@ -113,6 +113,7 @@ function invokeCLI(args, input, timeout, invocation = resolveCLI()) { clearTimeout(timer); if (failure) return reject(failure); if (code !== 0) return reject(new Error(`CLI 调用失败:${stderr.trim() || `退出码 ${code}`};请求未重试。若不支持 marketing 命令,请更新 CLI。`)); + if (args.includes('--help')) return resolve(stdout); try { resolve(JSON.parse(stdout)); } catch (_) { reject(new Error('CLI 未返回有效 JSON;提交结果可能不明确,请勿自动重提')); } }); @@ -141,13 +142,19 @@ function createClient({ request = https.request, timeout = 60000, invoke = invok }); } - async function api(action, body) { + async function api(action, body, source) { requireValue(['generate', 'query', 'balance'].includes(action), '无效营销 API 操作'); const args = ['marketing', action, '--timeout', `${timeout}ms`]; let input; if (action === 'generate') { validate(body); args.push('--request', '-', '--execute'); + if (source !== undefined) { + // Probe before submitting; optional attribution must not break older CLIs. + let help = ''; + try { help = await invoke(['marketing', 'generate', '--help'], undefined, timeout); } catch (_) {} + if (typeof help === 'string' && /(?:^|\s)--source(?:[=\s,]|$)/m.test(help)) args.push('--source', source.trim()); + } input = JSON.stringify(body); } if (action === 'query') args.push('--thread-id', body.thread_id, '--run-id', body.run_id); @@ -193,28 +200,44 @@ function createClient({ request = https.request, timeout = 60000, invoke = invok } const HELP = `小云雀营销 Skill(Node.js >= 16) - node marketing.js generate --request request.json [--dry-run | --execute] + node marketing.js generate --request request.json [--source HOST] [--dry-run | --execute] node marketing.js upload --file product.png node marketing.js query --thread-id ID --run-id ID [--wait] [--max-wait 900] [--output-dir DIR] node marketing.js balance 所有 API 调用复用 pippit-tool-cli 登录态;未登录先运行 pippit-tool-cli login,无需提供 access_token。generate 默认仅预览。 +--source HOST:仅生成提交使用,映射为请求体 platform;去除首尾空白,空值省略。 --timeout 秒数:单请求总时限,默认 60;--max-wait:轮询总时限,默认 900。 query 输出 API 原始响应;有 --output-dir 时成功结果附带 downloaded_files。 退出码:0 成功/单次查询进行中;1 输入或接口错误;2 生成失败/取消/无视频;3 等待超时;4 等待用户交互;5 未知或未指定状态。 `; +// Keep runtime marker semantics aligned with internal/common/host_source.go. +function resolveHostSource(explicit, env = process.env) { + if (explicit !== undefined) return explicit.trim(); + if ((env.PIPPIT_CLI_SOURCE || '').trim()) return env.PIPPIT_CLI_SOURCE.trim(); + const hosts = new Set(); + for (const [key, host] of [ + ['CODEX_THREAD_ID', 'codex'], ['CODEX_SESSION_ID', 'codex'], + ['CLAUDECODE', 'claude_code'], ['CURSOR_AGENT', 'cursor'], ['GEMINI_CLI', 'gemini_cli'], + ]) { + const value = (env[key] || '').trim().toLowerCase(); + if (value && value !== '0' && value !== 'false') hosts.add(host); + } + return hosts.size === 1 ? [...hosts][0] : ''; +} + function parseArgs(argv) { const [action, ...rest] = argv; requireValue(Object.hasOwnProperty.call(PATHS, action), '操作必须是 generate/upload/query/balance'); const options = {}; const flags = ['execute', 'dry-run', 'wait']; - const allowed = { generate: ['request', 'execute', 'dry-run'], upload: ['file'], query: ['thread-id', 'run-id', 'wait', 'max-wait', 'output-dir'], balance: [] }[action].concat('timeout'); + const allowed = { generate: ['request', 'execute', 'dry-run', 'source'], upload: ['file'], query: ['thread-id', 'run-id', 'wait', 'max-wait', 'output-dir'], balance: [] }[action].concat('timeout'); for (let i = 0; i < rest.length; i++) { const name = rest[i].replace(/^--/, ''); requireValue(rest[i].startsWith('--') && allowed.includes(name) && !(name in options), `无效或重复参数:${rest[i]}`); if (flags.includes(name)) options[name] = true; else { - requireValue(nonempty(rest[i + 1]) && !rest[i + 1].startsWith('--'), `${name} 缺少值`); + requireValue(typeof rest[i + 1] === 'string' && (name === 'source' || nonempty(rest[i + 1])) && !rest[i + 1].startsWith('--'), `${name} 缺少值`); options[name] = rest[++i]; } } @@ -232,9 +255,15 @@ async function main(argv, { out = console.log, clientFactory = createClient, now const { action, options } = parseArgs(argv); let body = {}; if (action === 'generate') { + const source = resolveHostSource(options.source); + if (options.source !== undefined || source) options.source = source; requireValue(options.request, 'generate 需要 --request JSON 文件'); body = validate(JSON.parse(await fs.promises.readFile(options.request, 'utf8'))); - if (!options.execute) { out(JSON.stringify({ dry_run: true, url: BASE + PATHS.generate, body })); return 0; } + if (!options.execute) { + const source = (options.source || '').trim(); + out(JSON.stringify({ dry_run: true, url: BASE + PATHS.generate, body: source ? { ...body, platform: source } : body })); + return 0; + } } if (action === 'query') { requireValue(nonempty(options['thread-id']) && nonempty(options['run-id']), 'query 需要 thread-id 和 run-id'); @@ -248,7 +277,7 @@ async function main(argv, { out = console.log, clientFactory = createClient, now // A poll request cannot overrun the remaining wait budget. const pollClient = action === 'query' && options.wait ? clientFactory({ timeout: Math.min(options.timeout * 1000, Math.max(1, deadline - now())) }) : client; - result = action === 'upload' ? await client.upload(options.file) : await pollClient.api(action, body); + result = action === 'upload' ? await client.upload(options.file) : await pollClient.api(action, body, options.source); if (action !== 'query' || !options.wait || !WAIT_STATES.includes(String(result.data.run_state))) break; if (now() >= deadline) { out(JSON.stringify({ ...result, wait_timed_out: true })); return 3; @@ -303,4 +332,4 @@ if (require.main === module) { process.exitCode = 1; }); } -module.exports = { BASE, PATHS, validate, checkResponse, resolveCLI, invokeCLI, createClient, parseArgs, main }; +module.exports = { BASE, PATHS, validate, checkResponse, resolveCLI, invokeCLI, createClient, resolveHostSource, parseArgs, main }; diff --git a/skills/xyq-nest-skill/SKILL.md b/skills/xyq-nest-skill/SKILL.md index 22601ec..02e3a3e 100644 --- a/skills/xyq-nest-skill/SKILL.md +++ b/skills/xyq-nest-skill/SKILL.md @@ -28,7 +28,7 @@ metadata: | 创建或查询小云雀个人画布,编辑节点、布局、连线、角色/场景、提示词、3D 或多轨草稿 | `canvas` | [Canvas 能力与命令发现](commands/canvas.md) | | 生成图片,或基于参考图修改图片 | `generate-image` | [生图与图片编辑](commands/generate-image.md) | | 查询当前可用的图片/视频模型、比例、分辨率及推理强度等参数配置 | `model list` / `model describe` | [模型发现](commands/model.md) | -| 生成视频,使用图/视频/音频参考,首尾帧生视频 | `generate-video` | [生视频](commands/generate-video.md) | +| 生成视频、参考生成、视频编辑、视频延长、首尾帧生成、Draft 样片及转成片 | `generate-video` | [生视频](commands/generate-video.md) | | 提升已有视频分辨率、视频超分 | `video-super-resolution` | [超分](commands/video-super-resolution.md) | | 去除已有视频字幕 | `erase-video-subtitle` | [擦字幕](commands/erase-video-subtitle.md) | | 查询已有任务进度、下载生成结果 | `query-result` | [查询结果](commands/query-result.md) | @@ -59,6 +59,8 @@ metadata: - 不向用户询问、不增加确认步骤,不为此改写 prompt。无法确认来源时直接省略;不传或空值均不阻塞提交。 - 来源只随本次提交发送,不加入上传、查询、下载或 Canvas 命令。使用已有旧版 CLI 时先按 `--help` 确认是否支持;不支持则省略,不因统计字段中断任务或在未知提交结果时重提。 +优先从宿主的系统身份、当前工具运行环境和明确的平台元数据确认真实宿主,并在每次提交主动填写 `--source`;不要仅因字段可选就跳过。缺少显式值时,CLI 依次尝试 `PIPPIT_CLI_SOURCE` 和已核实的宿主运行标记。只上报稳定的平台名,不上报标记中的会话 ID;来源冲突或仍无法确认则省略,不询问用户、不扫描登录凭据或安装目录。显式 `--source ""` 禁用该次自动归因。 + ## 按需参考的完整场景 命令文档含最小调用示例;需要了解从需求到交付的组合过程时,再读对应场景: diff --git a/skills/xyq-nest-skill/commands/generate-video.md b/skills/xyq-nest-skill/commands/generate-video.md index 7d8cd8c..a2aff35 100644 --- a/skills/xyq-nest-skill/commands/generate-video.md +++ b/skills/xyq-nest-skill/commands/generate-video.md @@ -1,24 +1,75 @@ # generate-video:生视频 -适用于文生视频、参考图/视频/音频生成新视频以及首尾帧生成。仅处理已有视频的清晰度或字幕时,分别使用 [超分](video-super-resolution.md)、[擦字幕](erase-video-subtitle.md)。 +适用于文生视频、参考图/视频/音频生成、视频编辑、视频延长、首尾帧生成,以及 Draft 样片和转成片。仅处理已有视频的清晰度或字幕时,分别使用 [超分](video-super-resolution.md)、[擦字幕](erase-video-subtitle.md)。 ## 输入与参数 | 参数 | 必填 | 规则 | | --- | --- | --- | -| `--prompt` | 是 | 用户原始描述,不能全为空白 | +| `--prompt` | 按阶段 | 用户原始描述,不能全为空白;传入非空 `--draft-task-id` 转成片时可省略 | | `--model` | 是 | 用户指定的准确模型枚举;未明确模型时先确认,不猜测 | | `--image` | 否 | 本地图片路径,重复参数 | -| `--video` | 否 | 本地参考视频路径,重复参数 | +| `--video` | 按模式 | 本地视频路径,重复参数;视频编辑、视频延长需提供原视频 | | `--audio` | 否 | 本地 `.mp3/.wav` 音频路径,重复参数 | -| `--duration` | 按模型 | 整数秒;MiniMax、Wan、HappyHorse 可省略由服务端补齐默认值,其他模型需明确提供;用户只给时长范围时先确认具体秒数 | -| `--ratio` | 按模式 | 比例字符串,不转换为生图枚举;MiniMax、Wan、HappyHorse 可省略使用服务端配置默认值;其他模型需明确提供 | -| `--resolution` | 按模型 | `Seedance_2.0_mini`、`Seedance_2.0_mini_lite` 可省略,服务端默认 `720p`;MiniMax、Wan、HappyHorse 可省略使用服务端配置默认值;其他模型需明确提供 | +| `--duration` | 按模式、阶段 | 整数秒;Seedance 2.5 / Draft 编辑模式不传或传 `-1`,其他三种模式为 `4–30` 秒;Draft 转成片不传。其他模式按模型配置:MiniMax、Wan、HappyHorse 可省略,其他模型需明确提供;用户只给时长范围时先确认具体秒数 | +| `--ratio` | 按模式、阶段 | 比例字符串,不转换为生图枚举;Seedance 2.5 编辑、延长、首尾帧模式不传或传 `adaptive`;Draft 转成片不传。其他模式按模型配置:MiniMax、Wan、HappyHorse 可省略,其他模型需明确提供 | +| `--resolution` | 按模型 | Draft 两阶段不传,服务端固定样片 480p、成片 1080p;`Seedance_2.0_mini`、`Seedance_2.0_mini_lite` 可省略,服务端默认 `720p`;MiniMax、Wan、HappyHorse 可省略;其他模型需明确提供 | | `--generate-type` | 否 | 首尾帧任务传 `1`;其他显式值交服务端处理 | +| `--task-type` | 否 | 生成模式:`auto/reference/edit/extend`,未传时保持缺省 | +| `--seed` | 否 | 自定义 seed,显式 `0/-1` 保留,未传时保持缺省 | +| `--draft` | 否 | 生成样片;显式 `--draft=false` 保留 false,未传时省略字段 | +| `--draft-task-id` | 否 | 生成成片时提供查询结果中的原始样片任务 ID | 当前支持的模型以 `pippit-tool-cli model list` 为准,完整参数配置用 `pippit-tool-cli model describe MODEL_KEY` 查询;缓存有效期为 5 分钟,需要最新结果时加 `--refresh`。详情见 [模型发现](model.md)。CLI 不自行维护模型白名单或分辨率组合校验。 -本地图片支持 `.jpg/.jpeg/.png/.gif/.bmp/.webp/.svg`;视频支持 `.mp4/.avi/.mov/.wmv/.flv/.webm/.mkv/.m4v`。CLI 内部上传参考素材。 +本地图片支持 `.jpg/.jpeg/.png/.gif/.bmp/.webp/.svg`;视频支持 `.mp4/.avi/.mov/.wmv/.flv/.webm/.mkv/.m4v`;音频支持 `.mp3/.wav`。CLI 内部上传参考素材,保留文件后缀校验和 prompt 非空校验,仅传入非空 `--draft-task-id` 时允许省略 prompt。不新增生成参数范围或组合校验,不补可选参数默认值。 + +## Seedance 2.5 四种模式 + +先确认当前模型配置已开放对应模式,再按下表组装输入。Draft 样片在服务端开放后沿用这组模式输入;转成片只需模型和 `draft_task_id`,见下节。 + +| 模式 | `creation_modes.key` | CLI 参数与输入 | 时长、比例 | +| --- | --- | --- | --- | +| 参考生成 | `reference_generation` | `--task-type reference`、prompt,按需提供图片/视频/音频 | duration 为 `4–30` 秒;ratio 按用户要求和模型配置提供 | +| 视频编辑 | `video_edit` | `--task-type edit`、prompt、`--video` 原视频 | duration 不传或 `-1`;ratio 不传或 `adaptive` | +| 视频延长 | `video_extend` | `--task-type extend`、prompt、`--video` 原视频 | duration 为 `4–30` 秒;ratio 不传或 `adaptive` | +| 首尾帧 | `first_last_frame` | `--generate-type 1`、prompt,按首帧、尾帧顺序传图片(支持仅首帧,最多两张),不传 task-type,不传视频/音频 | duration 为 `4–30` 秒;ratio 不传或 `adaptive` | + +`auto` 是下游自动判断模式的值,不对应上述四个页面选项。不要把 `video_edit`、`video_extend` 等配置 key 直接作为 `--task-type` 的值。 + +## Seedance 2.5 Draft 两阶段 + +两个阶段都使用准确枚举 `Seedance_2.5_draft`。这些参数需要服务端已支持 Draft 协议,并允许无 prompt 的二阶段请求;CLI 支持不等于目标环境已经开放。 + +第一阶段:提交样片并查询下载结果。 + +```bash +pippit-tool-cli generate-video \ + --model Seedance_2.5_draft --draft \ + --prompt "小猫钓鱼视频" --task-type reference \ + --ratio 16:9 --duration 10 + +pippit-tool-cli query-result \ + --thread-id DRAFT_THREAD_ID --run-id DRAFT_RUN_ID \ + --download-dir ./xyq_output/draft +``` + +向用户展示样片视频。保存查询结果中该视频的 `draft_task_id`,不要使用 `run_id`、视频资产 ID 或编辑器 `draft_key` 替代。用户尚未要求生成成片时,在预览阶段停止。 + +第二阶段:用户明确要求生成成片后,提交同一模型和样片 ID。 + +```bash +pippit-tool-cli generate-video \ + --model Seedance_2.5_draft \ + --draft-task-id DRAFT_TASK_ID + +pippit-tool-cli query-result \ + --thread-id FINAL_THREAD_ID --run-id FINAL_RUN_ID \ + --download-dir ./xyq_output/final +``` + +成片继承样片的提示词、素材、时长、比例和 seed,无需重复提交,也不切换成 `Seedance_2.5`。样片与成片分别计费;下游固定样片 480p、成片 1080p,样片须在创建后 7 天内转成片。CLI 不推算过期时间、不自动进入下一阶段、不维护本地状态机。需要恢复样片 ID 时查询第一阶段的 thread/run,以实际返回的 `draft_task_id` 为准;缺失时按[样片结果说明](query-result.md#样片结果)处理。 + ## 模型与模式 diff --git a/skills/xyq-nest-skill/commands/model.md b/skills/xyq-nest-skill/commands/model.md index f164559..e14abaa 100644 --- a/skills/xyq-nest-skill/commands/model.md +++ b/skills/xyq-nest-skill/commands/model.md @@ -93,7 +93,7 @@ pippit-tool-cli model describe MiniMax-H3 --refresh - `Seedance_2.0_mini`、`Seedance_2.0_mini_lite` 的生成请求可以省略 `--resolution`,服务端默认 `720p`。查询未返回分辨率维度时,不因此要求用户补填,也不在查询结果中伪造选项。 - `duration` 来自参数维度:范围输出 `min/max/step`,选项输出数字 `options`,均以秒为单位,不把选项枚举号当秒数。若只下发旧 `supported_duration_list`,暂保留该原始字段并明确提示不能直接用于 `--duration`。 - `material_limits` 保留数量字段;大小字段使用 `max_image_size_bytes`,视频时长字段使用 `min_video_duration_ms/max_video_duration_ms/max_total_video_duration_ms`。字段未返回与值为 `0` 保持区别。 -- `creation_modes` 保留 `enabled`、素材校验等服务端策略,并为文本/参考/首尾帧模式标注对应 `generate_type`。`smart` 比例策略显示 `adaptive`;其他可识别模式继承模型比例。MiniMax 的文本模式排除 `adaptive`,纯文生视频使用固定比例。原始模式 key 不是生成参数,未标注 `generate_type` 的模式不代表 CLI 已接入。 +- `creation_modes` 保留 `enabled`、素材校验等服务端策略,并为文本/参考/首尾帧模式标注对应 `generate_type`。按服务端下发的模式 key,为 `reference_generation`、`video_edit`、`video_extend` 分别输出 `task_type: reference/edit/extend`,可直接用于 `--task-type`;首尾帧使用 `generate_type: 1`,不传 `task_type`。`creation_modes.key` 本身不是提交值。`smart` 比例策略显示 `adaptive`;其他可识别模式继承模型比例。MiniMax 的文本模式排除 `adaptive`,纯文生视频使用固定比例。素材和参数要求见[生成说明](generate-video.md#seedance-25-四种模式),实际可用性仍以目标服务端开放情况为准。 - 条件维度的 `active_when_any`、参数组合约束及其他未转换字段保留;模型级选项不保证任意组合都可用。配置不一致时通过 `warnings` 提示刷新,未知比例枚举直接跳过。 内部 `config_key` 不输出。缓存仍保存服务端原始配置,列表/详情展示时转换。图片模型标识仅在提交时从名称解析,视频请求契约保持不变,不新增静态模型准入限制。生成参数格式见 [生视频命令](generate-video.md)。 diff --git a/skills/xyq-nest-skill/commands/query-result.md b/skills/xyq-nest-skill/commands/query-result.md index 085268b..afed3b7 100644 --- a/skills/xyq-nest-skill/commands/query-result.md +++ b/skills/xyq-nest-skill/commands/query-result.md @@ -24,6 +24,8 @@ stdout 是 JSON;命令可能将错误编码进 JSON 并以退出码 0 返回 | `error_message` | 非空即错误,不能因为 `completed=false` 而忽略 | | `thread_id` / `run_id` | 对应的查询任务 | | `images[]` / `videos[]` | 成功后获取的媒体,每项含 `download_url`、`output_path` | +| `videos[].draft` | 服务端明确返回时才输出;`true` 为样片,`false` 为成片,未返回时省略 | +| `videos[].draft_task_id` | 原始方舟样片任务 ID,用于样片转成片;有值时才输出 | 成功示例(ID、URL 和文件名仅为示意): @@ -40,6 +42,14 @@ stdout 是 JSON;命令可能将错误编码进 JSON 并以退出码 0 返回 任务尚未完成时通常返回 `completed=false`、空错误、空媒体数组。命令不提供完整会话消息、用户反问或可区分的所有状态;不能仅凭这个响应断言具体进度、取消状态或等待用户输入。轮询停止条件见 [共用流程](../workflows/async-delivery.md)。 +## 样片结果 + +样片仍按普通视频下载,使用 `output_path` 向用户展示预览。保存所选视频的 `videos[].draft_task_id`,与该视频的预览文件保持一一对应。仅保存下载链接无法继续生成成片,不要用 thread/run、资产 ID 或编辑器 draft_key 替换该字段。 + +样片可预览但未返回 `draft_task_id` 时,不能继续转成片。报告该字段缺失并保留原任务的 thread/run ID,不猜 ID,也不重新生成样片。 + +用户明确要求生成成片后,使用 `generate-video --model Seedance_2.5_draft --draft-task-id DRAFT_TASK_ID`;详见 [两阶段生成](generate-video.md#seedance-25-draft-两阶段)。CLI 不自动发起第二阶段。普通视频没有样片字段时保持原来的输出形态。 + ## 下载行为 文件名由 CLI 根据产物信息生成,以 `output_path` 为准,不自行拼接编号或推测扩展名。同目录已有同名文件可能被复用;复用不证明内容相同,也不代表本次新下载。发现同名文件属于其他产物时,选择用户认可范围内的未冲突目录再查询,不删除已有文件。 diff --git a/skills/xyq-nest-skill/scripts/install.md b/skills/xyq-nest-skill/scripts/install.md index 423b6bc..ee0c9cc 100644 --- a/skills/xyq-nest-skill/scripts/install.md +++ b/skills/xyq-nest-skill/scripts/install.md @@ -8,6 +8,8 @@ node "{baseDir}/scripts/ensure-cli.js" `{baseDir}` 是当前 Skill 的根目录。运行环境需 Node.js 16+,并允许执行本地程序。首次安装或自动升级还需要 npm、可写的用户缓存目录、访问 npm 源和 GitHub Release 的网络、`curl` 与解压工具(macOS/Linux 的 `tar`,Windows 的 PowerShell)。复用已有 CLI 不需要下载网络或 npm。 +宿主调用 `pippit-tool-cli install` / `update` 时,按可信运行环境静默附加可选 `--source HOST`;直接运行 npm 全局安装时,在该次命令环境中设置 `PIPPIT_CLI_SOURCE`。`HOST` 为真实宿主标识的占位符,不固定平台,未知则省略,不询问用户。安装/更新将该值写入 `host_platform`,保留原有 `source` 和操作系统 `platform`;服务端需支持新字段才能计入统计。旧版命令不支持该参数时省略,不为统计重复安装;内部仅安装 CLI 的脚本沿用原有不上报行为。 + ## 查找与复用 脚本依次检查 PATH 中的 CLI 和自身缓存,验证版本及本 Skill 使用命令的 `--help`;命令集合维护在脚本的 `REQUIRED_COMMANDS`,包含 `model list` 和 `model describe`。帮助检查不调用生成服务,也不需要凭据,不证明账号权限或服务端运行状态。