@pippit-dev/cli 1.0.38 → 1.0.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +84 -1
  2. package/checksums.txt +6 -6
  3. package/cmd/auth/auth.go +4 -3
  4. package/cmd/marketing.go +5 -4
  5. package/cmd/marketing_test.go +58 -0
  6. package/cmd/root.go +40 -14
  7. package/cmd/root_test.go +63 -0
  8. package/cmd/source_test.go +2 -3
  9. package/cmd/submit_run_test.go +20 -81
  10. package/cmd/update/update.go +36 -10
  11. package/cmd/update/update_test.go +73 -3
  12. package/dist/checksums.txt +6 -6
  13. package/dist/xyq-canvas-command-runtime.cjs +14 -14
  14. package/dist/xyq-canvas-command-runtime.cjs.LEGAL.txt +0 -206
  15. package/dist/xyq-canvas-command-runtime.cjs.sha256 +2 -2
  16. package/internal/adapter/adapter.go +73 -0
  17. package/internal/adapter/ppe.go +36 -0
  18. package/internal/adaptercli/commands.go +27 -0
  19. package/internal/adaptercli/commands_test.go +41 -0
  20. package/internal/auth/manager.go +25 -13
  21. package/internal/canvas/apply.go +3 -0
  22. package/internal/canvas/media_identity.go +300 -0
  23. package/internal/canvas/media_identity_test.go +160 -0
  24. package/internal/canvas/upload.go +30 -1
  25. package/internal/commandnames/names.go +9 -0
  26. package/internal/common/adapter_test.go +74 -0
  27. package/internal/common/client.go +37 -12
  28. package/internal/config/config.go +41 -0
  29. package/internal/config/config_test.go +41 -1
  30. package/internal/generate_video/generate_video.go +1 -0
  31. package/internal/generate_video/generate_video_test.go +25 -3
  32. package/internal/models/models.go +6 -2
  33. package/internal/models/models_test.go +7 -0
  34. package/package.json +2 -2
  35. package/scripts/canvas-command.js +4 -2
  36. package/skills/short-drama/SKILL.md +6 -3
  37. package/skills/short-drama/commands/download-result.md +9 -3
  38. package/skills/short-drama/commands/get-thread.md +2 -0
  39. package/skills/short-drama/commands/list-thread-file.md +12 -6
  40. package/skills/short-drama/commands/submit-run.md +3 -1
  41. package/skills/short-drama/examples/create-and-deliver.md +2 -2
  42. package/skills/short-drama/examples/reference-and-continue.md +20 -1
  43. package/skills/short-drama/workflows/creation.md +28 -14
  44. package/skills/short-drama/workflows/poll-and-deliver.md +27 -9
  45. package/skills/xyq-nest-skill/commands/canvas-assets.md +9 -1
package/README.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  面向 Pippit / 小云雀工作流的命令行工具与智能体技能集合。
4
4
 
5
+ ## 本地运行配置
6
+
7
+ CLI 每次启动时读取**当前工作目录**中的 `config.toml`。在仓库根目录执行 CLI 时使用仓库配置,修改后下一次执行直接生效,无需重新编译:
8
+
9
+ ```toml
10
+ xyq_api_url = "https://xyq.jianying.com"
11
+ x-tt-env = ""
12
+ ```
13
+
14
+ `x-tt-env` 填写本次实际 PPE 环境名,格式为 `ppe_[A-Za-z0-9_]+`。非空时,发往 `xyq_api_url` 的提交、查询、上传等 API 请求自动携带 `x-tt-env: <环境名>` 和 `x-use-ppe: 1`;外部媒体下载不携带这两个 header。留空时不添加 PPE header。仓库默认留空,避免默认路由到某个个人泳道。
15
+
16
+ 文件不存在时继续使用线上默认配置;不会搜索父目录、用户目录或二进制目录。文件格式、字段名或值错误会终止执行。旧的 `PIPPIT_CLI_PPE_ENV` 不再生效;`XYQ_ACCESS_KEY` 等认证设置保持原有行为。
17
+
18
+ `tests/` 中的验证脚本随源码仓库维护,不包含在 npm 发布包中,也不接入正式 CLI 二进制入口。
19
+
5
20
  ## 技能列表
6
21
 
7
22
  本仓库在 `skills/` 目录下包含三个智能体技能:
@@ -79,6 +94,45 @@ Canvas 任务使用 `ensure-cli.js --canvas`,额外返回 `canvas_entry`;原
79
94
  - 文档使用 Skill 内相对链接,打包时保留结构。规范副本位于 `skills/xyq-nest-skill/`,项目发现入口 `.agents/skills/xyq-skill` 指向该目录。
80
95
  - 修改后运行 `node scripts/skills.test.js` 与 `node scripts/install-cli.test.js`,检查引用完整、保留命令与安装检查一致及缺命令升级/缓存复用;Agent 行为用例见 [测试场景](skills/xyq-nest-skill/tests/agent_test_cases.md)。这些检查不代表真实生成已验证。
81
96
 
97
+ ## CLI 更新渠道与发布
98
+
99
+ 普通用户使用稳定渠道(npm `latest`);试用用户主动选择 `canary`:
100
+
101
+ ```bash
102
+ pippit-tool-cli update # 默认安装 latest
103
+ pippit-tool-cli update --channel canary # 本次安装 canary,并更新配套 Skills
104
+ pippit-tool-cli update --channel stable # 本次安装 latest,并更新配套 Skills
105
+ ```
106
+
107
+ `--channel` 只影响本次更新,不保存渠道偏好。下一次不带参数的 `update` 仍走 `latest`,因此可能从试用版退回当前稳定版。显式 `--channel` 优先于 `PIPPIT_CLI_INSTALL_PACKAGE`;不传渠道时保留该环境变量指定包版本的既有行为。新版本提示仍检查稳定渠道。
108
+
109
+ 首次安装试用版,或旧 CLI 尚不支持 `--channel` 时,使用现有安装入口:
110
+
111
+ ```bash
112
+ npx --prefer-online @pippit-dev/cli@canary install
113
+ pippit-tool-cli --version
114
+ ```
115
+
116
+ 该命令会替换全局 CLI 和配套 Skills。`canary` 是公开 npm 分发渠道,适合主动参与的小范围试用,不限制谁可以下载。Skill 的 `ensure-cli.js` 在需要下载时仍使用 `latest`;希望试用的用户应先安装 canary。
117
+
118
+ 维护者发布步骤:
119
+
120
+ 1. 保持 `package.json`、`package-lock.json` 与 `vX.Y.Z` tag 的版本一致;使用普通版本号,不使用 `-beta` / `-rc` 后缀(现有安装器按普通版本号定位二进制)。
121
+ 2. 推送 tag 后,`Release` workflow 构建 GitHub Release 产物并执行 `npm publish --tag canary`,保留原 `latest`。GitHub Release 产物仍公开可见,本流程控制的是 npm 分发渠道。
122
+ 3. 试用用户安装 canary,核对版本并验证实际业务流程。
123
+ 4. 验证通过后,在 GitHub Actions 的 `Promote canary to latest` workflow 中点击 `Run workflow`,填写已验证的 `X.Y.Z`。该 workflow 须先合入默认分支才会显示手动入口,并复用仓库已有的 `NPM_TOKEN`。
124
+ 5. 提升流程要求输入版本与当前 `canary` 一致,只移动 npm `latest` 指针并回读确认,不重新构建或发布 npm 包。如果 canary 已更新,需要验证新的候选版本后再提升。
125
+
126
+ 维护者回滚步骤:
127
+
128
+ 1. 在 [GitHub Actions](https://github.com/Pippit-dev/cli/actions) 选择 `Rollback CLI`,点击 `Run workflow`。该 workflow 须先合入默认分支;复用 `NPM_TOKEN` 修改 npm 标签,使用只读 `GITHUB_TOKEN` 检查 Release。
129
+ 2. 填写 `channel`(`stable` 对应 `latest`,或 `canary`)、`expected_version`(预期渠道当前版本)和 `target_version`(已验证的目标旧版本)。两个版本均须为普通 `X.Y.Z`,目标必须更低;例如 `stable / 1.0.39 / 1.0.38`,仅在确认 `1.0.38` 可用后操作。
130
+ 3. 流水线核对渠道当前值、目标 npm 版本及 GitHub Release 元数据:版本 tag 匹配、已公开发布、六种平台/架构的压缩包及 `checksums.txt` 均已上传且非空。该检查不运行旧版程序,不替代业务验证;回滚依赖这些历史产物继续保留。
131
+ 4. 写入前再次核对当前渠道,随后只移动所选 npm 标签并回读确认。不会重新构建、发包、修改 Git tag 或改变另一渠道。Actions 日志和运行摘要会记录目标及确认结果;写入或回读失败时,先用 `npm view @pippit-dev/cli dist-tags --prefer-online` 核实实际状态,再决定是否重试。
132
+ 5. 已安装问题版本的用户需要主动执行 `pippit-tool-cli update --channel stable`(回滚 canary 时使用 `--channel canary`)并核对 `pippit-tool-cli --version`。旧 CLI 可执行默认 `update` 获取 `latest`,但须确保未设置 `PIPPIT_CLI_INSTALL_PACKAGE` 覆盖变量。已运行的进程及 Skill 独立缓存中的 CLI 不会被渠道回滚自动替换。
133
+
134
+ 发布、提升和回滚共用并发组,避免这些 workflow 同时修改渠道;不取消正在运行的任务。GitHub 默认只保留一个等待中的同组任务,连续触发时请检查是否有等待任务被替换。并发组不能锁住维护者在终端执行的 npm 写入,操作期间应避免其它渠道修改。此阶段不提供百分比放量或自动回滚,普通客户端也不会因指针变化而自动安装;显式指定问题版本的安装仍然可用。
135
+
82
136
  ## 短剧工作流技能
83
137
 
84
138
  包发布后可以通过 npm 安装。安装器会按当前系统下载匹配的预构建二进制文件,支持 macOS、Linux 和 Windows:
@@ -349,6 +403,35 @@ pippit-tool-cli query-result \
349
403
 
350
404
  `query-result` 会查询指定 Run 并输出 JSON。Run 成功完成后下载视频和图片产物,`completed=true`,`videos` 和 `images` 中各包含 `download_url` 和 `output_path`;图片扩展名取自产物 `metadata.format`,缺省时兜底 `.png`。Run 失败也视为终态,`completed=true` 且填充 `error_message`;Run 未到终态时 `completed=false`。
351
405
 
406
+ ## Adapter 接入边界
407
+
408
+ 公共接口位于 `internal/adapter`,通过实例注入适用于当前运行环境的命令和 HTTP 请求策略;公共命令与 HTTP 层只依赖接口,不依赖具体实现。接口提供 `CLIOptions`、`PrepareAPIRequest`、`WrapTransport`、`StripSensitiveHeaders` 四个方法,`adapter.Default{}` 提供默认行为。
409
+
410
+ - 根入口 `main.go` 调用 `cmd.Execute()`,使用完整命令、本机认证和原始 API 路由。
411
+ - 同进程集成可使用 `cmd.NewRootCommandWithAdapter(stdout, stderr, policy)` 或 `common.NewHTTPClientWithAdapter(baseURL, timeout, authorizer, policy)`;传 nil 使用默认 adapter,不存在全局 adapter 注册。
412
+ - 同源判断由公共 HTTP client 按 scheme、hostname、有效端口执行;只有同源 API 请求才调用 `PrepareAPIRequest`。非同源请求清除默认认证和 adapter 管理的敏感字段;API 跨域重定向会被拒绝。
413
+ - `AuthProvided` 只表示 adapter 已附加认证材料,不代表服务端验证通过;失败立即返回,不回退到本机认证。`UseDefaultAuth` 才调用默认认证器,未知决策会被拒绝。
414
+ - CLI 策略与 Cobra 解耦。`DefaultAllowRootCommands` 明确指定是否默认放行一级业务命令;为 false 时只开放 `AllowedRootCommands` 白名单,nil 和空列表都表示没有白名单命令。默认 adapter 将该开关设为 true,本机认证和自更新仍受各自开关控制;标准帮助和版本行为仍由 Cobra 提供。可通过 `auth.WithCLIOptions` 将本机认证能力配置应用于独立 Manager。
415
+ - 业务 client 与更新遥测 client 都在构造时包装 Transport;遥测 client 属于命令实例,避免使用不同 adapter 的实例共享可变客户端。
416
+
417
+ 例如,自定义 adapter 可以复用默认实现,仅调整命令能力,并在自己的程序入口注入:
418
+
419
+ ```go
420
+ type customAdapter struct {
421
+ adapter.Default
422
+ }
423
+
424
+ func (customAdapter) CLIOptions() adapter.CLIOptions {
425
+ options := adapter.Default{}.CLIOptions()
426
+ options.SelfUpdateEnabled = false
427
+ return options
428
+ }
429
+
430
+ // 程序入口:cmd.ExecuteWithAdapter(customAdapter{})
431
+ ```
432
+
433
+ 新增业务请求应复用 `Runner.Client`;需要独立 `http.Client` 时,通过所注入策略的 `WrapTransport` 包装。直接创建未包装的客户端不会自动应用策略。
434
+
352
435
  ## HTTP 客户端
353
436
 
354
437
  命令模块通过 `common.Runner` 发起服务调用。运行时配置,例如基础地址、HTTP 超时时间和接口路径,由 `internal/config` 加载,并在运行器中与 `common.Client` 组合使用。
@@ -375,7 +458,7 @@ pippit-tool-cli update --source HOST
375
458
 
376
459
  ### 宿主来源统计
377
460
 
378
- 生成提交命令均支持可选 `--source`:`submit-run`、`generate-image`、`generate-video`、`video-super-resolution`、`erase-video-subtitle`、`short-drama +submit-run`、`marketing generate`。
461
+ 生成提交命令均支持可选 `--source`:`generate-image`、`generate-video`、`video-super-resolution`、`erase-video-subtitle`、`short-drama +submit-run`、`marketing generate`。
379
462
 
380
463
  由宿主 Agent 根据实际环境静默填写稳定标识,例如豆包办公 `doubao_office`、WorkBuddy `workbuddy`、Codex `codex`。其它来源可使用其真实产品标识;来源未知时省略,不询问用户,也不从 prompt 猜测。该值去掉首尾空白后写入请求顶层 `platform`,仅供统计,不参与创作、模型选择或鉴权;无法解析来源或显式空值时不发送该字段。省略 `--source` 时,CLI 按以下顺序尽力补齐来源:
381
464
 
package/checksums.txt CHANGED
@@ -1,6 +1,6 @@
1
- 3fca50bc9ffb2c14e74ea65781fb706d4fa2c79484d5b20b3c08ad15847afc30 pippit-tool-cli-1.0.38-darwin-amd64.tar.gz
2
- 0e7d976179d944a2b8f99800acb91c6d3c035f0920cf4ec4348010b3a03d9e38 pippit-tool-cli-1.0.38-darwin-arm64.tar.gz
3
- 4f5723713fc910121982885172079ab1e787d2af9f2515961e59f7e53ba48737 pippit-tool-cli-1.0.38-linux-amd64.tar.gz
4
- 07d8ff5920dfa6748ad46076fcffbabc7b5d8dcef6714d802c7fa5b4b515494e pippit-tool-cli-1.0.38-linux-arm64.tar.gz
5
- 61f18a00f457ffff80c20593b3c0af84b93a3e8db871b00ed043f61024c6b441 pippit-tool-cli-1.0.38-windows-amd64.zip
6
- c07399e11956f7c6565e00c74a24f864012613539a61341636f9900d85a72654 pippit-tool-cli-1.0.38-windows-arm64.zip
1
+ 14eec459ef3d526057582d47ec5611a48146b825181f06652f31c76e203a7f9c pippit-tool-cli-1.0.40-darwin-amd64.tar.gz
2
+ 81d6967ef44953e91d9a2504bba20979570de7c0554aae4d7b55660da9fb32e8 pippit-tool-cli-1.0.40-darwin-arm64.tar.gz
3
+ 8811832e3c840cb0418654953f354b254f3889346966c161ab65279f0cf03662 pippit-tool-cli-1.0.40-linux-amd64.tar.gz
4
+ 28ce9f374f34fed4fa3e7b44a2b1b1fcc59c8cd7d83ad7621e8c694dd35194ad pippit-tool-cli-1.0.40-linux-arm64.tar.gz
5
+ ffd11a5de082b50b54d8b9f701890186c28785bca4cfd6e8e05254ce6eed965c pippit-tool-cli-1.0.40-windows-amd64.zip
6
+ 88ed4320bbef1146355890753226ec6a99b0f0653556e1a4665ad7f23dd17c30 pippit-tool-cli-1.0.40-windows-arm64.zip
package/cmd/auth/auth.go CHANGED
@@ -9,6 +9,7 @@ import (
9
9
  "time"
10
10
 
11
11
  internal_auth "github.com/Pippit-dev/pippit-cli/internal/auth"
12
+ "github.com/Pippit-dev/pippit-cli/internal/commandnames"
12
13
  "github.com/Pippit-dev/pippit-cli/internal/common"
13
14
  "github.com/spf13/cobra"
14
15
  )
@@ -31,7 +32,7 @@ type logoutResult struct {
31
32
  func NewLoginCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
32
33
  var forceRefresh bool
33
34
  command := &cobra.Command{
34
- Use: "login",
35
+ Use: commandnames.Login,
35
36
  Short: "通过浏览器登录小云雀 CLI",
36
37
  Args: cobra.NoArgs,
37
38
  RunE: func(command *cobra.Command, _ []string) error {
@@ -66,7 +67,7 @@ func NewLoginCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Com
66
67
  // NewStatusCommand creates the top-level `pippit-tool-cli status` command.
67
68
  func NewStatusCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
68
69
  command := &cobra.Command{
69
- Use: "status",
70
+ Use: commandnames.Status,
70
71
  Short: "查看小云雀 CLI 登录状态",
71
72
  Args: cobra.NoArgs,
72
73
  RunE: func(command *cobra.Command, _ []string) error {
@@ -95,7 +96,7 @@ func NewStatusCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Co
95
96
  // modified or revoked by this command.
96
97
  func NewLogoutCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
97
98
  command := &cobra.Command{
98
- Use: "logout",
99
+ Use: commandnames.Logout,
99
100
  Short: "清除本机小云雀 CLI 登录(不撤销远程 Access Key)",
100
101
  Args: cobra.NoArgs,
101
102
  RunE: func(command *cobra.Command, _ []string) error {
package/cmd/marketing.go CHANGED
@@ -11,6 +11,7 @@ import (
11
11
  "strings"
12
12
  "time"
13
13
 
14
+ "github.com/Pippit-dev/pippit-cli/internal/adapter"
14
15
  "github.com/Pippit-dev/pippit-cli/internal/common"
15
16
  "github.com/Pippit-dev/pippit-cli/internal/config"
16
17
  "github.com/spf13/cobra"
@@ -25,17 +26,17 @@ var marketingPaths = map[string]string{
25
26
 
26
27
  // Marketing uses the same AuthManager as login/status and all other commands.
27
28
  // Only fixed public endpoints are exposed; credentials never leave the CLI.
28
- func newMarketingCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
29
+ func newMarketingCommand(stdout, stderr io.Writer, runner *common.Runner, policy adapter.HTTPAdapter) *cobra.Command {
29
30
  root := &cobra.Command{Use: "marketing", Short: "Marketing API using the shared CLI login"}
30
31
  root.SetOut(stdout)
31
32
  root.SetErr(stderr)
32
33
  for _, action := range []string{"generate", "query", "upload", "balance"} {
33
- root.AddCommand(newMarketingAction(action, stdout, stderr, runner))
34
+ root.AddCommand(newMarketingAction(action, stdout, stderr, runner, policy))
34
35
  }
35
36
  return root
36
37
  }
37
38
 
38
- func newMarketingAction(action string, stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
39
+ func newMarketingAction(action string, stdout, stderr io.Writer, runner *common.Runner, policy adapter.HTTPAdapter) *cobra.Command {
39
40
  var requestFile, file, threadID, runID, source string
40
41
  var execute bool
41
42
  var timeout time.Duration
@@ -123,7 +124,7 @@ func newMarketingAction(action string, stdout, stderr io.Writer, runner *common.
123
124
  }
124
125
  ctx, cancel := context.WithTimeout(cmd.Context(), timeout)
125
126
  defer cancel()
126
- client := common.NewNonRedirectingHTTPClient(runner.Config.BaseURL, timeout, newRunnerAuthorizer(runner))
127
+ client := common.NewNonRedirectingHTTPClientWithAdapter(runner.Config.BaseURL, timeout, newRunnerAuthorizer(runner), adapter.WithPPE(policy, runner.Config.PPEEnv))
127
128
  var result map[string]json.RawMessage
128
129
  var err error
129
130
  if action == "upload" {
@@ -14,6 +14,7 @@ import (
14
14
  "testing"
15
15
  "time"
16
16
 
17
+ "github.com/Pippit-dev/pippit-cli/internal/adapter"
17
18
  "github.com/Pippit-dev/pippit-cli/internal/auth"
18
19
  "github.com/Pippit-dev/pippit-cli/internal/config"
19
20
  )
@@ -34,6 +35,63 @@ func (s *marketingCredentialStore) Load(context.Context) (*auth.Credential, erro
34
35
 
35
36
  const marketingRequest = `{"message":"make an ad","general_agent_settings":{"video_model":"chosen-model","show_subtitle":false}}`
36
37
 
38
+ type marketingTestAdapter struct{}
39
+
40
+ func (marketingTestAdapter) CLIOptions() adapter.CLIOptions {
41
+ return adapter.CLIOptions{DefaultAllowRootCommands: true}
42
+ }
43
+
44
+ func (marketingTestAdapter) PrepareAPIRequest(_ context.Context, req *http.Request) (adapter.AuthDecision, error) {
45
+ req.Header.Set("X-Test-Adapter-Auth", "present")
46
+ return adapter.AuthProvided, nil
47
+ }
48
+
49
+ func (marketingTestAdapter) WrapTransport(next http.RoundTripper) http.RoundTripper {
50
+ if next == nil {
51
+ next = http.DefaultTransport
52
+ }
53
+ return marketingTestTransport{next: next}
54
+ }
55
+
56
+ func (marketingTestAdapter) StripSensitiveHeaders(*http.Request) {}
57
+
58
+ type marketingTestTransport struct{ next http.RoundTripper }
59
+
60
+ func (t marketingTestTransport) RoundTrip(req *http.Request) (*http.Response, error) {
61
+ cloned := req.Clone(req.Context())
62
+ cloned.Header.Set("X-Test-Adapter-Transport", "present")
63
+ return t.next.RoundTrip(cloned)
64
+ }
65
+
66
+ func TestMarketingUsesRootAdapterForIndependentClient(t *testing.T) {
67
+ called := 0
68
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
69
+ called++
70
+ if r.Header.Get("X-Test-Adapter-Auth") != "present" || r.Header.Get("X-Test-Adapter-Transport") != "present" {
71
+ t.Errorf("marketing request bypassed root adapter: %#v", r.Header)
72
+ }
73
+ if r.Header.Get("x-tt-env") != "ppe_fixture" || r.Header.Get("x-use-ppe") != "1" {
74
+ t.Error("marketing request bypassed runtime PPE config")
75
+ }
76
+ fmt.Fprint(w, `{"ret":"0","data":{}}`)
77
+ }))
78
+ defer server.Close()
79
+
80
+ cfg := config.Load()
81
+ cfg.BaseURL = server.URL
82
+ cfg.PPEEnv = "ppe_fixture"
83
+ policy := marketingTestAdapter{}
84
+ runner := newRootRunnerWithAdapter(cfg, policy)
85
+ root := newRootCommandWithAdapter(io.Discard, io.Discard, runner, policy)
86
+ root.SetArgs([]string{"marketing", "balance"})
87
+ if err := root.Execute(); err != nil {
88
+ t.Fatal(err)
89
+ }
90
+ if called != 1 {
91
+ t.Fatalf("marketing requests = %d, want 1", called)
92
+ }
93
+ }
94
+
37
95
  func TestMarketingUsesSharedBrowserAuth(t *testing.T) {
38
96
  for _, action := range []string{"generate", "query", "upload", "balance"} {
39
97
  t.Run(action, func(t *testing.T) {
package/cmd/root.go CHANGED
@@ -14,6 +14,8 @@ import (
14
14
  "github.com/Pippit-dev/pippit-cli/cmd/short_drama"
15
15
  updatecmd "github.com/Pippit-dev/pippit-cli/cmd/update"
16
16
  "github.com/Pippit-dev/pippit-cli/cmd/video_tool"
17
+ "github.com/Pippit-dev/pippit-cli/internal/adapter"
18
+ "github.com/Pippit-dev/pippit-cli/internal/adaptercli"
17
19
  internal_auth "github.com/Pippit-dev/pippit-cli/internal/auth"
18
20
  "github.com/Pippit-dev/pippit-cli/internal/common"
19
21
  "github.com/Pippit-dev/pippit-cli/internal/config"
@@ -22,24 +24,42 @@ import (
22
24
  )
23
25
 
24
26
  // Execute runs the pippit-tool-cli command tree.
25
- func Execute() error {
26
- return NewRootCommand(os.Stdout, os.Stderr).Execute()
27
+ func Execute() error { return ExecuteWithAdapter(adapter.Default{}) }
28
+
29
+ func ExecuteWithAdapter(policy adapter.Adapter) error {
30
+ cfg, err := config.LoadFile(config.FileName)
31
+ if err != nil {
32
+ return err
33
+ }
34
+ policy = adapter.OrDefault(policy)
35
+ runner := newRootRunnerWithAdapter(cfg, policy)
36
+ return newRootCommandWithAdapter(os.Stdout, os.Stderr, runner, policy).Execute()
27
37
  }
28
38
 
29
39
  func NewRootCommand(stdout, stderr io.Writer) *cobra.Command {
40
+ return NewRootCommandWithAdapter(stdout, stderr, adapter.Default{})
41
+ }
42
+
43
+ func NewRootCommandWithAdapter(stdout, stderr io.Writer, policy adapter.Adapter) *cobra.Command {
44
+ policy = adapter.OrDefault(policy)
30
45
  cfg := config.Load()
31
- runner := newRootRunner(cfg)
32
- return newRootCommand(stdout, stderr, runner)
46
+ runner := newRootRunnerWithAdapter(cfg, policy)
47
+ return newRootCommandWithAdapter(stdout, stderr, runner, policy)
33
48
  }
34
49
 
35
50
  func newRootRunner(cfg *config.Config) *common.Runner {
51
+ return newRootRunnerWithAdapter(cfg, adapter.Default{})
52
+ }
53
+
54
+ func newRootRunnerWithAdapter(cfg *config.Config, policy adapter.Adapter) *common.Runner {
36
55
  runner := common.NewRunner(cfg, nil)
37
- runner.Auth = internal_auth.NewManager(cfg)
38
- runner.Client = common.NewHTTPClient(
39
- cfg.BaseURL,
40
- cfg.HTTPTimeout,
41
- newRunnerAuthorizer(runner),
42
- )
56
+ var authorizer common.RequestAuthorizer
57
+ options := policy.CLIOptions()
58
+ if options.LocalCredentialsEnabled {
59
+ runner.Auth = internal_auth.NewManager(cfg, internal_auth.WithCLIOptions(options))
60
+ authorizer = newRunnerAuthorizer(runner)
61
+ }
62
+ runner.Client = common.NewHTTPClientWithAdapter(cfg.BaseURL, cfg.HTTPTimeout, authorizer, adapter.WithPPE(policy, cfg.PPEEnv))
43
63
  return runner
44
64
  }
45
65
 
@@ -53,10 +73,14 @@ func newRunnerAuthorizer(runner *common.Runner) common.RequestAuthorizer {
53
73
  }
54
74
 
55
75
  func newRootCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
76
+ return newRootCommandWithAdapter(stdout, stderr, runner, adapter.Default{})
77
+ }
78
+
79
+ func newRootCommandWithAdapter(stdout, stderr io.Writer, runner *common.Runner, policy adapter.Adapter) *cobra.Command {
56
80
  root := &cobra.Command{
57
81
  Use: "pippit-tool-cli",
58
82
  Short: "Pippit CLI",
59
- 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.\n\n" +
83
+ 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.\n\n" +
60
84
  canvascmd.CommandDiscoveryHelp + "\n\nInstallation help (npm launcher): pippit-tool-cli install --help",
61
85
  Version: version.Current(),
62
86
  SilenceUsage: true,
@@ -84,9 +108,10 @@ func newRootCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Comm
84
108
  root.AddCommand(newDownloadResultCommand(stdout, stderr, runner))
85
109
  root.AddCommand(newGetCreditBalanceCommand(stdout, stderr, runner))
86
110
  root.AddCommand(newModelCommand(stdout, stderr, runner))
87
- root.AddCommand(newMarketingCommand(stdout, stderr, runner))
111
+ root.AddCommand(newMarketingCommand(stdout, stderr, runner, policy))
88
112
  root.AddCommand(newGetThreadCommand(stdout, stderr, runner))
89
- root.AddCommand(newSubmitRunCommand(stdout, stderr, runner))
113
+ // 暂停对外提供通用综合 Agent 提交入口,保留实现供后续恢复。
114
+ // root.AddCommand(newSubmitRunCommand(stdout, stderr, runner))
90
115
  root.AddCommand(newUploadFileCommand(stdout, stderr, runner))
91
116
  root.AddCommand(newListThreadFileCommand(stdout, stderr, runner))
92
117
  root.AddCommand(generate_image.NewCommand(stdout, stderr, runner))
@@ -95,7 +120,8 @@ func newRootCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Comm
95
120
  root.AddCommand(video_tool.NewSuperResolutionCommand(stdout, stderr, runner))
96
121
  root.AddCommand(video_tool.NewEraseSubtitleCommand(stdout, stderr, runner))
97
122
  root.AddCommand(short_drama.NewCommand(stdout, stderr, runner))
98
- root.AddCommand(updatecmd.NewCommand(stdout, stderr))
123
+ root.AddCommand(updatecmd.NewCommandWithAdapter(stdout, stderr, policy))
124
+ adaptercli.Configure(root, policy.CLIOptions())
99
125
  localizeFlagErrors(root)
100
126
  return root
101
127
  }
package/cmd/root_test.go CHANGED
@@ -3,15 +3,78 @@ package cmd
3
3
  import (
4
4
  "bytes"
5
5
  "context"
6
+ "fmt"
6
7
  "net/http"
7
8
  "net/http/httptest"
9
+ "os"
10
+ "path/filepath"
8
11
  "strings"
9
12
  "testing"
10
13
  "time"
11
14
 
15
+ "github.com/Pippit-dev/pippit-cli/internal/common"
12
16
  "github.com/Pippit-dev/pippit-cli/internal/config"
13
17
  )
14
18
 
19
+ func TestRootRunnerUsesRuntimePPERouting(t *testing.T) {
20
+ headers := make(chan http.Header, 1)
21
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
22
+ headers <- r.Header.Clone()
23
+ _, _ = w.Write([]byte(`{}`))
24
+ })
25
+ api := httptest.NewServer(handler)
26
+ defer api.Close()
27
+ media := httptest.NewServer(handler)
28
+ defer media.Close()
29
+ dir := t.TempDir()
30
+ configPath, mediaPath := filepath.Join(dir, config.FileName), filepath.Join(dir, "sample.txt")
31
+ if err := os.WriteFile(mediaPath, []byte("fixture"), 0600); err != nil {
32
+ t.Fatal(err)
33
+ }
34
+ // Re-read the same file after changes, including clearing the PPE setting.
35
+ for _, env := range []string{"ppe_first", "ppe_second", ""} {
36
+ contents := fmt.Sprintf("xyq_api_url = %q\nx-tt-env = %q\n", api.URL, env)
37
+ if err := os.WriteFile(configPath, []byte(contents), 0600); err != nil {
38
+ t.Fatal(err)
39
+ }
40
+ cfg, err := config.LoadFile(configPath)
41
+ if err != nil {
42
+ t.Fatal(err)
43
+ }
44
+ cfg.AccessKey = "fixture-key"
45
+ runner := newRootRunner(cfg)
46
+ for _, request := range []struct {
47
+ name string
48
+ external bool
49
+ send func() error
50
+ }{
51
+ {"submit", false, func() error {
52
+ return runner.Client.SendRequest(context.Background(), config.SubmitRunPath, map[string]any{}, nil)
53
+ }},
54
+ {"query", false, func() error { return runner.Client.SendRequest(context.Background(), config.GetThreadPath, nil, nil) }},
55
+ {"upload", false, func() error {
56
+ return runner.Client.SendMultipartRequest(context.Background(), config.UploadFilePath, nil, common.MultipartFile{FieldName: "file", Path: mediaPath}, nil)
57
+ }},
58
+ {"media", true, func() error { return runner.Client.SendRequest(context.Background(), media.URL, nil, nil) }},
59
+ } {
60
+ if err := request.send(); err != nil {
61
+ t.Fatalf("%s: %v", request.name, err)
62
+ }
63
+ got := <-headers
64
+ wantEnv, wantUsePPE := env, ""
65
+ if request.external {
66
+ wantEnv = ""
67
+ }
68
+ if wantEnv != "" {
69
+ wantUsePPE = "1"
70
+ }
71
+ if got.Get("x-tt-env") != wantEnv || got.Get("x-use-ppe") != wantUsePPE {
72
+ t.Fatalf("%s with env %q: unexpected PPE headers", request.name, env)
73
+ }
74
+ }
75
+ }
76
+ }
77
+
15
78
  func TestRootRunnerReadsUpdatedAccessKeyForEveryRequest(t *testing.T) {
16
79
  received := make([]string, 0, 2)
17
80
  server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
@@ -23,14 +23,13 @@ func TestSkillSubmitSourcePreservesCreativeRequest(t *testing.T) {
23
23
  t.Setenv("HOME", t.TempDir())
24
24
  t.Setenv("XDG_CACHE_HOME", t.TempDir())
25
25
  t.Setenv("LocalAppData", t.TempDir())
26
- for _, name := range []string{"submit-run", "generate-image", "generate-video", "video-super-resolution", "erase-video-subtitle", "short-drama"} {
26
+ for _, name := range []string{"generate-image", "generate-video", "video-super-resolution", "erase-video-subtitle", "short-drama"} {
27
27
  t.Run(name, func(t *testing.T) {
28
28
  video := filepath.Join(t.TempDir(), "input.mp4")
29
29
  if err := os.WriteFile(video, []byte("test-video"), 0600); err != nil {
30
30
  t.Fatal(err)
31
31
  }
32
32
  args := map[string][]string{
33
- "submit-run": {"submit-run", "--message", " 保留原文\nworkbuddy 只是内容 ", "--thread-id", "skill_existing", "--asset-ids", "asset_1"},
34
33
  "generate-image": {"generate-image", "--prompt", "cat", "--model", "Seedream 5.0 Pro", "--ratio", "6", "--resolution", "4K"},
35
34
  "generate-video": {"generate-video", "--prompt", "cat", "--model", "Seedance_2.0_mini", "--duration", "4", "--ratio", "16:9"},
36
35
  "video-super-resolution": {"video-super-resolution", "--video", video, "--output-resolution", "1080p"},
@@ -192,7 +191,7 @@ func TestSkillSubmitSourcePreservesCreativeRequest(t *testing.T) {
192
191
 
193
192
  func TestSourceHelpWithoutCredentials(t *testing.T) {
194
193
  for _, args := range [][]string{
195
- {"submit-run"}, {"generate-image"}, {"generate-video"},
194
+ {"generate-image"}, {"generate-video"},
196
195
  {"video-super-resolution"}, {"erase-video-subtitle"}, {"short-drama", "+submit-run"},
197
196
  } {
198
197
  var stdout, stderr bytes.Buffer
@@ -2,102 +2,41 @@ package cmd
2
2
 
3
3
  import (
4
4
  "bytes"
5
- "encoding/json"
6
- "net/http"
7
- "net/http/httptest"
8
- "reflect"
9
5
  "strings"
10
6
  "testing"
11
-
12
- "github.com/Pippit-dev/pippit-cli/internal/config"
13
7
  )
14
8
 
15
- func TestSubmitRunCommandPreservesScriptContract(t *testing.T) {
16
- for _, existing := range []bool{false, true} {
17
- t.Run(map[bool]string{false: "new", true: "existing_with_assets"}[existing], func(t *testing.T) {
18
- message := " 根据参考素材生成视频\n保留原文 "
19
- want := map[string]any{"message": message}
20
- args := []string{"submit-run", "--message", message, "--source", ""}
21
- if existing {
22
- want["thread_id"] = "skill_original_thread"
23
- want["asset_ids"] = []any{"asset_original_1", "asset_original_2"}
24
- args = append(args, "--thread-id", "skill_original_thread", "--asset-ids", "asset_original_1", "--asset-ids", "asset_original_2")
25
- }
26
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
27
- if r.Method != http.MethodPost || r.URL.Path != config.SubmitRunPath {
28
- t.Errorf("request = %s %s", r.Method, r.URL.Path)
29
- }
30
- if r.Header.Get("Authorization") != "Bearer test-token" {
31
- t.Error("missing bearer authorization")
32
- }
33
- var got map[string]any
34
- if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
35
- t.Error(err)
36
- }
37
- if !reflect.DeepEqual(got, want) {
38
- t.Errorf("body = %#v, want %#v", got, want)
39
- }
40
- _, _ = w.Write([]byte(`{"ret":"0","data":{"run":{"thread_id":"thread_1","run_id":"run_1"},"web_thread_link":"https://xyq.jianying.com/thread_1"}}`))
41
- }))
42
- defer server.Close()
43
- var stdout, stderr bytes.Buffer
44
- root := newTestRootCommand(t, &stdout, &stderr, server.URL)
45
- root.SetArgs(args)
46
- if err := root.Execute(); err != nil {
47
- t.Fatal(err)
48
- }
49
- if got := stdout.String(); got != "{\"thread_id\":\"thread_1\",\"run_id\":\"run_1\",\"web_thread_link\":\"https://xyq.jianying.com/thread_1\"}\n" {
50
- t.Fatalf("unexpected output: %s", got)
51
- }
52
- })
53
- }
54
- }
55
-
56
- func TestSubmitRunCommandValidatesBeforeRequest(t *testing.T) {
57
- for _, args := range [][]string{
58
- {"submit-run"},
59
- {"submit-run", "--message", " \n "},
60
- {"submit-run", "--message", "hello", "unexpected"},
61
- } {
62
- var stdout, stderr bytes.Buffer
63
- root := newRootCommand(&stdout, &stderr, nil)
64
- root.SetArgs(args)
65
- if err := root.Execute(); err == nil {
66
- t.Fatalf("Execute(%v) should fail before accessing the runner", args)
67
- }
68
- }
69
- }
70
-
71
- func TestSubmitRunHelpDoesNotRequireCredentials(t *testing.T) {
9
+ func TestSubmitRunIsNotExposedInRootHelp(t *testing.T) {
72
10
  var stdout, stderr bytes.Buffer
73
11
  root := newRootCommand(&stdout, &stderr, nil)
74
- root.SetArgs([]string{"submit-run", "--help"})
12
+ root.SetArgs([]string{"--help"})
75
13
  if err := root.Execute(); err != nil {
76
14
  t.Fatal(err)
77
15
  }
78
- for _, flag := range []string{"--message", "--thread-id", "--asset-ids"} {
79
- if !strings.Contains(stdout.String(), flag) {
80
- t.Errorf("help missing %s", flag)
81
- }
16
+ if strings.Contains(stdout.String(), "submit-run") || strings.Contains(stdout.String(), "creative conversations") {
17
+ t.Fatalf("root help still exposes creative conversations: %s", stdout.String())
18
+ }
19
+ command, _, err := root.Find([]string{"short-drama", "+submit-run"})
20
+ if err != nil || command == nil || command.Name() != "+submit-run" {
21
+ t.Fatalf("short-drama submission must remain available: command=%v, err=%v", command, err)
82
22
  }
83
23
  }
84
24
 
85
- func TestSubmitRunCommandRejectsInvalidResponse(t *testing.T) {
86
- for _, response := range []string{
87
- `{"ret":"1","errmsg":"rejected"}`,
88
- `{"ret":"0","data":{"run":{"run_id":"run_1"}}}`,
89
- `{"ret":"0","data":{"run":{"thread_id":"thread_1"}}}`,
25
+ func TestSubmitRunIsUnavailable(t *testing.T) {
26
+ for _, args := range [][]string{
27
+ {"submit-run"},
28
+ {"submit-run", "--message", "生成一个小猫在花园散步的视频"},
29
+ {"submit-run", "--help"},
90
30
  } {
91
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
92
- _, _ = w.Write([]byte(response))
93
- }))
94
31
  var stdout, stderr bytes.Buffer
95
- root := newTestRootCommand(t, &stdout, &stderr, server.URL)
96
- root.SetArgs([]string{"submit-run", "--message", "hello"})
32
+ root := newRootCommand(&stdout, &stderr, nil)
33
+ root.SetArgs(args)
97
34
  err := root.Execute()
98
- server.Close()
99
- if err == nil || stdout.Len() != 0 {
100
- t.Fatalf("response %s: error = %v, stdout = %s", response, err, stdout.String())
35
+ if err == nil || !strings.Contains(err.Error(), "unknown command") {
36
+ t.Fatalf("Execute(%v) = %v, want unknown command", args, err)
37
+ }
38
+ if stdout.Len() != 0 {
39
+ t.Fatalf("disabled command produced output: %s", stdout.String())
101
40
  }
102
41
  }
103
42
  }