@pippit-dev/cli 1.0.42 → 1.0.44

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 (55) hide show
  1. package/README.md +42 -11
  2. package/checksums.txt +6 -6
  3. package/cmd/auth/auth.go +19 -5
  4. package/cmd/auth/auth_test.go +24 -1
  5. package/cmd/canvas/canvas.go +7 -7
  6. package/cmd/download_result.go +3 -2
  7. package/cmd/marketing.go +19 -3
  8. package/cmd/marketing_test.go +11 -5
  9. package/cmd/model.go +5 -4
  10. package/cmd/root.go +13 -5
  11. package/cmd/short_drama_test.go +2 -0
  12. package/cmd/source_test.go +27 -4
  13. package/cmd/space.go +199 -0
  14. package/cmd/space_test.go +469 -0
  15. package/cmd/upload_file_test.go +3 -1
  16. package/dist/checksums.txt +6 -6
  17. package/internal/adaptercli/commands_test.go +9 -5
  18. package/internal/auth/auth_test.go +21 -17
  19. package/internal/auth/loopback_origins_test.go +1 -1
  20. package/internal/auth/manager.go +1 -1
  21. package/internal/auth/space.go +26 -0
  22. package/internal/auth/space_test.go +42 -0
  23. package/internal/canvas/get.go +9 -2
  24. package/internal/commandnames/names.go +9 -4
  25. package/internal/common/client.go +23 -0
  26. package/internal/common/host_source.go +5 -0
  27. package/internal/common/host_source_test.go +9 -1
  28. package/internal/common/runner.go +5 -3
  29. package/internal/common/space.go +185 -0
  30. package/internal/common/space_test.go +132 -0
  31. package/internal/common/submit_run.go +2 -0
  32. package/internal/common/upload_file.go +2 -0
  33. package/internal/config/config.go +16 -12
  34. package/internal/generate_image/generate_image.go +3 -9
  35. package/internal/short_drama/submit_run.go +3 -9
  36. package/internal/space/session.go +153 -0
  37. package/internal/space/store.go +211 -0
  38. package/internal/space/store_test.go +246 -0
  39. package/package.json +1 -1
  40. package/scripts/canvas-command.js +37 -4
  41. package/scripts/install-wizard.js +1 -1
  42. package/scripts/run.js +2 -2
  43. package/scripts/telemetry.js +3 -1
  44. package/skills/short-drama/SKILL.md +12 -1
  45. package/skills/xyq-marketing-skill/SKILL.md +22 -3
  46. package/skills/xyq-marketing-skill/references/api.md +7 -1
  47. package/skills/xyq-marketing-skill/scripts/marketing.js +42 -18
  48. package/skills/xyq-nest-skill/SKILL.md +15 -4
  49. package/skills/xyq-nest-skill/commands/canvas-assets.md +1 -1
  50. package/skills/xyq-nest-skill/commands/canvas.md +5 -3
  51. package/skills/xyq-nest-skill/commands/get-credit-balance.md +2 -2
  52. package/skills/xyq-nest-skill/commands/model.md +1 -1
  53. package/skills/xyq-nest-skill/scripts/ensure-cli.js +8 -1
  54. package/skills/xyq-nest-skill/scripts/install.md +1 -1
  55. package/skills/xyq-nest-skill/workflows/canvas-edit.md +2 -2
@@ -1,6 +1,6 @@
1
- a27b2392e4a291329599d6ee02dd704fde6eb2bf34f3390b30a973bce0009259 pippit-tool-cli-1.0.42-darwin-amd64.tar.gz
2
- 921fa4fff779f0c7fca7a6176d0aa47c23fe3debd78df29d3bca50ae1eff0e54 pippit-tool-cli-1.0.42-darwin-arm64.tar.gz
3
- f786c2954c1d537638ef248c420bbf8d9fe8040f6ee6ce7a5467c6fe73689747 pippit-tool-cli-1.0.42-linux-amd64.tar.gz
4
- a6adb356975c1724e553ec8fdc9aa602a259a081171f41c84680946c1459cea9 pippit-tool-cli-1.0.42-linux-arm64.tar.gz
5
- 9d1385bec216144594b83c42c244ebd9cc6eafe7758f54a1dc636f7ee231aea7 pippit-tool-cli-1.0.42-windows-amd64.zip
6
- 008d4f238b2fd66db3e4ede1425ec6919853889359dd60b379939b7f73452154 pippit-tool-cli-1.0.42-windows-arm64.zip
1
+ 8bf338b7fc4bb3de289778896059f364f91743e7fe3127380e193f29bb10fecf pippit-tool-cli-1.0.44-darwin-amd64.tar.gz
2
+ fd7aac74fda167481b76e3a7aa2f30e45271670cad11f0e969080db1b135411a pippit-tool-cli-1.0.44-darwin-arm64.tar.gz
3
+ 526e3641047d7e997fb5c271d795337692b0a47c092622747f6e2b4e9d22e473 pippit-tool-cli-1.0.44-linux-amd64.tar.gz
4
+ 6170436a3ec60dc2a2681e6f790c6a52d9daa4248ce8c3eede83136a62ccb4b6 pippit-tool-cli-1.0.44-linux-arm64.tar.gz
5
+ d3c7166314bb52deae5e0caf383ac7e3cdde27c61325d6556de43f8f46aa9963 pippit-tool-cli-1.0.44-windows-amd64.zip
6
+ 9cb3d470688cd89a42a2267d9d181159dd624937c9590d3b744db25a66edbac9 pippit-tool-cli-1.0.44-windows-arm64.zip
@@ -12,22 +12,26 @@ func TestConfigureDefaultAllowRootCommands(t *testing.T) {
12
12
  for _, tc := range []struct {
13
13
  name string
14
14
  defaultAllow bool
15
+ localAuth bool
15
16
  allowlist []string
16
17
  want []string
17
18
  }{
18
19
  {name: "默认拒绝且 nil 白名单"},
19
20
  {name: "默认拒绝且空白名单", allowlist: []string{}},
20
21
  {name: "默认拒绝且有白名单", allowlist: []string{"canvas"}, want: []string{"canvas"}},
21
- {name: "默认放行且 nil 白名单", defaultAllow: true, want: []string{"canvas", "future-command"}},
22
- {name: "默认放行且空白名单", defaultAllow: true, allowlist: []string{}, want: []string{"canvas", "future-command"}},
23
- {name: "默认放行且有白名单", defaultAllow: true, allowlist: []string{"canvas"}, want: []string{"canvas", "future-command"}},
22
+ {name: "默认放行且 nil 白名单", defaultAllow: true, want: []string{"canvas", "future-command", "space"}},
23
+ {name: "默认放行且空白名单", defaultAllow: true, allowlist: []string{}, want: []string{"canvas", "future-command", "space"}},
24
+ {name: "默认放行且有白名单", defaultAllow: true, allowlist: []string{"canvas"}, want: []string{"canvas", "future-command", "space"}},
25
+ {name: "启用本地账号", defaultAllow: true, localAuth: true, want: []string{"canvas", "future-command", "login", "logout", "space", "status"}},
26
+ {name: "启用本地账号仍受白名单限制", localAuth: true, allowlist: []string{"space"}, want: []string{"space"}},
27
+ {name: "关闭本地登录仍允许空间命令", allowlist: []string{"login", "logout", "space", "status"}, want: []string{"space"}},
24
28
  } {
25
29
  t.Run(tc.name, func(t *testing.T) {
26
30
  root := &cobra.Command{Use: "tool"}
27
- for _, name := range []string{"canvas", "future-command", "login", "logout", "status", "update"} {
31
+ for _, name := range []string{"canvas", "future-command", "login", "logout", "space", "status", "update"} {
28
32
  root.AddCommand(&cobra.Command{Use: name})
29
33
  }
30
- Configure(root, adapter.CLIOptions{DefaultAllowRootCommands: tc.defaultAllow, AllowedRootCommands: tc.allowlist})
34
+ Configure(root, adapter.CLIOptions{DefaultAllowRootCommands: tc.defaultAllow, AllowedRootCommands: tc.allowlist, LocalCredentialsEnabled: tc.localAuth})
31
35
  var got []string
32
36
  for _, child := range root.Commands() {
33
37
  got = append(got, child.Name())
@@ -96,7 +96,7 @@ func (s *memoryCredentialStore) Delete(ctx context.Context) error {
96
96
  }
97
97
 
98
98
  func TestBrowserFlowUsesDedicatedPageAndStrictBoundCallback(t *testing.T) {
99
- authURL, _ := url.Parse(config.DefaultBaseURL)
99
+ authURL, _ := url.Parse(config.DefaultAuthBaseURL)
100
100
  deviceID, _ := randomEncoded(bytes.NewReader(bytes.Repeat([]byte{0x21}, deviceIDBytes)), deviceIDBytes)
101
101
  flow, err := startBrowserFlow(
102
102
  authURL, bytes.NewReader(bytes.Repeat([]byte{0x42}, 2*randomBindingBytes)),
@@ -111,7 +111,7 @@ func TestBrowserFlowUsesDedicatedPageAndStrictBoundCallback(t *testing.T) {
111
111
  if err != nil {
112
112
  t.Fatal(err)
113
113
  }
114
- if loginURL.Scheme+"://"+loginURL.Host != config.DefaultBaseURL || loginURL.Path != loginPagePath {
114
+ if loginURL.Scheme+"://"+loginURL.Host != config.DefaultAuthBaseURL || loginURL.Path != loginPagePath {
115
115
  t.Fatalf("login URL target = %s://%s%s", loginURL.Scheme, loginURL.Host, loginURL.Path)
116
116
  }
117
117
  query := loginURL.Query()
@@ -152,30 +152,30 @@ func TestBrowserFlowUsesDedicatedPageAndStrictBoundCallback(t *testing.T) {
152
152
  }
153
153
  wrongSecret := payload
154
154
  wrongSecret.RandomSecretKey = "wrong-secret"
155
- if status := sendCallback(t, callbackURL, config.DefaultBaseURL, wrongSecret); status != http.StatusBadRequest {
155
+ if status := sendCallback(t, callbackURL, config.DefaultAuthBaseURL, wrongSecret); status != http.StatusBadRequest {
156
156
  t.Fatalf("wrong secret status = %d", status)
157
157
  }
158
- if status := postRawCallback(t, callbackURL, config.DefaultBaseURL, `{"type":"access_key","unknown":true}`); status != http.StatusBadRequest {
158
+ if status := postRawCallback(t, callbackURL, config.DefaultAuthBaseURL, `{"type":"access_key","unknown":true}`); status != http.StatusBadRequest {
159
159
  t.Fatalf("unknown field status = %d", status)
160
160
  }
161
- if status := sendCallback(t, callbackURL+"&state=duplicate", config.DefaultBaseURL, payload); status != http.StatusBadRequest {
161
+ if status := sendCallback(t, callbackURL+"&state=duplicate", config.DefaultAuthBaseURL, payload); status != http.StatusBadRequest {
162
162
  t.Fatalf("duplicate state status = %d", status)
163
163
  }
164
- if status := sendCallback(t, callbackURL+"&unexpected=value", config.DefaultBaseURL, payload); status != http.StatusBadRequest {
164
+ if status := sendCallback(t, callbackURL+"&unexpected=value", config.DefaultAuthBaseURL, payload); status != http.StatusBadRequest {
165
165
  t.Fatalf("unexpected transport query status = %d", status)
166
166
  }
167
- if status := sendPreflight(t, callbackURL, config.DefaultBaseURL); status != http.StatusNoContent {
167
+ if status := sendPreflight(t, callbackURL, config.DefaultAuthBaseURL); status != http.StatusNoContent {
168
168
  t.Fatalf("preflight status = %d", status)
169
169
  }
170
170
  securityRuntimeCallback := callbackURL + "&a_bogus=signed-request&msToken=transport-token"
171
- if status := sendCallback(t, securityRuntimeCallback, config.DefaultBaseURL, payload); status != http.StatusOK {
171
+ if status := sendCallback(t, securityRuntimeCallback, config.DefaultAuthBaseURL, payload); status != http.StatusOK {
172
172
  t.Fatalf("security-runtime callback status = %d", status)
173
173
  }
174
174
  got, err := flow.wait(context.Background())
175
175
  if err != nil || got.AccessKey != payload.AccessKey || got.UID != payload.UID || got.TokenID != payload.TokenID {
176
176
  t.Fatalf("callback payload = %#v, %v", credentialPayloadWithoutSecret(got), err)
177
177
  }
178
- if status := sendCallback(t, callbackURL, config.DefaultBaseURL, payload); status != http.StatusConflict {
178
+ if status := sendCallback(t, callbackURL, config.DefaultAuthBaseURL, payload); status != http.StatusConflict {
179
179
  t.Fatalf("replayed callback status = %d", status)
180
180
  }
181
181
  }
@@ -185,6 +185,7 @@ func TestManagerLoginStoresPageIssuedCredentialWithoutServerExchange(t *testing.
185
185
  store := &memoryCredentialStore{}
186
186
  cfg := config.Load()
187
187
  cfg.AccessKey = ""
188
+ cfg.BaseURL = "https://untrusted.invalid"
188
189
  manager := NewManager(
189
190
  cfg,
190
191
  WithCredentialStore(store),
@@ -204,6 +205,9 @@ func TestManagerLoginStoresPageIssuedCredentialWithoutServerExchange(t *testing.
204
205
  if parseErr != nil {
205
206
  return parseErr
206
207
  }
208
+ if loginURL.Scheme != "https" || loginURL.Host != "www.xiaoyunque.com" {
209
+ t.Errorf("login origin = %s://%s, want https://www.xiaoyunque.com", loginURL.Scheme, loginURL.Host)
210
+ }
207
211
  if loginURL.Path != loginPagePath || loginURL.Query().Get("force") != "" {
208
212
  return fmt.Errorf("unexpected login URL")
209
213
  }
@@ -218,7 +222,7 @@ func TestManagerLoginStoresPageIssuedCredentialWithoutServerExchange(t *testing.
218
222
  Source: loginSource,
219
223
  CallbackURL: loginURL.Query().Get("callback"),
220
224
  }
221
- if status := sendCallback(t, payload.CallbackURL, config.DefaultBaseURL, payload); status != http.StatusOK {
225
+ if status := sendCallback(t, payload.CallbackURL, loginURL.Scheme+"://"+loginURL.Host, payload); status != http.StatusOK {
222
226
  return fmt.Errorf("callback status %d", status)
223
227
  }
224
228
  return nil
@@ -273,7 +277,7 @@ func TestManagerWaitsForCallbackWhenBrowserOpenFails(t *testing.T) {
273
277
  Source: loginSource,
274
278
  CallbackURL: loginURL.Query().Get("callback"),
275
279
  }
276
- if status := sendCallback(t, payload.CallbackURL, config.DefaultBaseURL, payload); status != http.StatusOK {
280
+ if status := sendCallback(t, payload.CallbackURL, config.DefaultAuthBaseURL, payload); status != http.StatusOK {
277
281
  return fmt.Errorf("callback status %d", status)
278
282
  }
279
283
  return errors.New("browser opener unavailable")
@@ -347,7 +351,7 @@ func TestManagerReauthenticationPinsAccountAndRequiresRotation(t *testing.T) {
347
351
  }
348
352
 
349
353
  func TestBrowserFlowRequiresExpectedAccountForForceAndNeverUsesRawUID(t *testing.T) {
350
- authURL, _ := url.Parse(config.DefaultBaseURL)
354
+ authURL, _ := url.Parse(config.DefaultAuthBaseURL)
351
355
  deviceID, _ := randomEncoded(bytes.NewReader(bytes.Repeat([]byte{0x22}, deviceIDBytes)), deviceIDBytes)
352
356
  if _, err := startBrowserFlow(
353
357
  authURL, bytes.NewReader(bytes.Repeat([]byte{0x42}, 2*randomBindingBytes)),
@@ -378,7 +382,7 @@ func TestBrowserFlowRequiresExpectedAccountForForceAndNeverUsesRawUID(t *testing
378
382
  }
379
383
 
380
384
  func TestBrowserFlowSendsStoredTokenIDOnNormalLogin(t *testing.T) {
381
- authURL, _ := url.Parse(config.DefaultBaseURL)
385
+ authURL, _ := url.Parse(config.DefaultAuthBaseURL)
382
386
  deviceID, _ := randomEncoded(bytes.NewReader(bytes.Repeat([]byte{0x23}, deviceIDBytes)), deviceIDBytes)
383
387
  flow, err := startBrowserFlow(
384
388
  authURL, bytes.NewReader(bytes.Repeat([]byte{0x44}, 2*randomBindingBytes)),
@@ -398,7 +402,7 @@ func TestBrowserFlowSendsStoredTokenIDOnNormalLogin(t *testing.T) {
398
402
  }
399
403
 
400
404
  func TestBrowserFlowAllowsLogoutIdentityTokenWithoutAccountBinding(t *testing.T) {
401
- authURL, _ := url.Parse(config.DefaultBaseURL)
405
+ authURL, _ := url.Parse(config.DefaultAuthBaseURL)
402
406
  deviceID, _ := randomEncoded(bytes.NewReader(bytes.Repeat([]byte{0x24}, deviceIDBytes)), deviceIDBytes)
403
407
  flow, err := startBrowserFlow(
404
408
  authURL, bytes.NewReader(bytes.Repeat([]byte{0x45}, 2*randomBindingBytes)),
@@ -464,7 +468,7 @@ func TestManagerDerivesExpectedAccountFromDurableScope(t *testing.T) {
464
468
  Source: loginSource,
465
469
  CallbackURL: loginURL.Query().Get("callback"),
466
470
  }
467
- if status := sendCallback(t, payload.CallbackURL, config.DefaultBaseURL, payload); status != http.StatusOK {
471
+ if status := sendCallback(t, payload.CallbackURL, config.DefaultAuthBaseURL, payload); status != http.StatusOK {
468
472
  return fmt.Errorf("callback status %d", status)
469
473
  }
470
474
  return nil
@@ -488,7 +492,7 @@ func TestResolveStatusLogoutAndAuthOrigin(t *testing.T) {
488
492
  cfg.AccessKey = " env-ak "
489
493
  cfg.BaseURL = "https://untrusted.invalid"
490
494
  manager := NewManager(cfg, WithCredentialStore(store), withClockForTest(func() time.Time { return fixedNow }))
491
- if manager.authBaseURL.String() != config.DefaultBaseURL {
495
+ if manager.authBaseURL.String() != config.DefaultAuthBaseURL {
492
496
  t.Fatalf("browser login followed runtime business base URL: %q", manager.authBaseURL)
493
497
  }
494
498
  if key, err := manager.ResolveAccessKey(context.Background()); err != nil || key != "env-ak" {
@@ -738,7 +742,7 @@ func callbackOpener(t *testing.T, expiry time.Time, uid, tokenID, accessKey stri
738
742
  Source: loginSource,
739
743
  CallbackURL: loginURL.Query().Get("callback"),
740
744
  }
741
- if status := sendCallback(t, payload.CallbackURL, config.DefaultBaseURL, payload); status != http.StatusOK {
745
+ if status := sendCallback(t, payload.CallbackURL, config.DefaultAuthBaseURL, payload); status != http.StatusOK {
742
746
  return fmt.Errorf("callback status %d", status)
743
747
  }
744
748
  return nil
@@ -16,7 +16,7 @@ import (
16
16
 
17
17
  func newOriginTestFlow(t *testing.T) (*browserFlow, accessKeyPayload) {
18
18
  t.Helper()
19
- authURL, _ := url.Parse(config.DefaultBaseURL)
19
+ authURL, _ := url.Parse(config.DefaultAuthBaseURL)
20
20
  deviceID, _ := randomEncoded(bytes.NewReader(bytes.Repeat([]byte{0x21}, deviceIDBytes)), deviceIDBytes)
21
21
  flow, err := startBrowserFlow(authURL, bytes.NewReader(bytes.Repeat([]byte{0x42}, 2*randomBindingBytes)), deviceID, "", "", false)
22
22
  if err != nil {
@@ -46,7 +46,7 @@ func WithCredentialStore(store CredentialStore) ManagerOption {
46
46
  // API configuration does not affect the browser identity origin.
47
47
  func NewManager(cfg *config.Config, options ...ManagerOption) *Manager {
48
48
  serviceName := config.DefaultAuthStoreServiceName
49
- authBaseURL, _ := url.Parse(config.DefaultBaseURL)
49
+ authBaseURL, _ := url.Parse(config.DefaultAuthBaseURL)
50
50
  manager := &Manager{
51
51
  localCredentialsEnabled: true,
52
52
  cfg: cfg,
@@ -0,0 +1,26 @@
1
+ package auth
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+
7
+ "github.com/Pippit-dev/pippit-cli/internal/space"
8
+ )
9
+
10
+ // SpaceAccount uses the verified browser UID, stable across AK rotation.
11
+ func (m *Manager) SpaceAccount(ctx context.Context) (string, error) {
12
+ // Re-read at request boundaries so an account switch in another process
13
+ // cannot keep using this process's cached identity or credential.
14
+ m.clearCredentialCache()
15
+ credential, err := m.loadCredential(ctx)
16
+ if errors.Is(err, ErrCredentialNotFound) {
17
+ return "", nil
18
+ }
19
+ if err != nil {
20
+ return "", err
21
+ }
22
+ if credential.UID == "" || credential.AccessKey == "" {
23
+ return "", nil
24
+ }
25
+ return "user:" + space.Digest(credential.UID), nil
26
+ }
@@ -0,0 +1,42 @@
1
+ package auth
2
+
3
+ import (
4
+ "context"
5
+ "strings"
6
+ "testing"
7
+ "time"
8
+
9
+ "github.com/Pippit-dev/pippit-cli/internal/config"
10
+ )
11
+
12
+ func TestSpaceAccountRefreshesCredentialAndSurvivesAKRotation(t *testing.T) {
13
+ ctx := context.Background()
14
+ cfg := config.Load()
15
+ cfg.AccessKey = ""
16
+ store := &memoryCredentialStore{credential: &Credential{UID: "user-a", AccessKey: "key-a", ExpiredAt: time.Now().Add(time.Hour).Unix()}}
17
+ manager := NewManager(cfg, WithCredentialStore(store))
18
+ a, err := manager.SpaceAccount(ctx)
19
+ if err != nil {
20
+ t.Fatal(err)
21
+ }
22
+ if strings.Contains(a, "user-a") || strings.Contains(a, "key-a") {
23
+ t.Fatal("raw identity leaked")
24
+ }
25
+ if err := store.Save(ctx, &Credential{UID: "user-a", AccessKey: "key-rotated", ExpiredAt: time.Now().Add(time.Hour).Unix()}); err != nil {
26
+ t.Fatal(err)
27
+ }
28
+ after, err := manager.SpaceAccount(ctx)
29
+ if err != nil || after != a {
30
+ t.Fatalf("rotation changed identity: %s, %v", after, err)
31
+ }
32
+ if key, err := manager.ResolveAccessKey(ctx); err != nil || key != "key-rotated" {
33
+ t.Fatalf("request used stale credential: %v", err)
34
+ }
35
+ if err := store.Save(ctx, &Credential{UID: "user-b", AccessKey: "key-b", ExpiredAt: time.Now().Add(time.Hour).Unix()}); err != nil {
36
+ t.Fatal(err)
37
+ }
38
+ b, err := manager.SpaceAccount(ctx)
39
+ if err != nil || b == a {
40
+ t.Fatal("new account inherited old identity", err)
41
+ }
42
+ }
@@ -14,6 +14,8 @@ type GetOptions struct {
14
14
  }
15
15
 
16
16
  type GetResult struct {
17
+ TeamID string `json:"team_id"`
18
+ SpaceContext string `json:"space_context,omitempty"`
17
19
  RequestedAssetIDs []string `json:"requested_asset_ids"`
18
20
  Assets []json.RawMessage `json:"assets"`
19
21
  LogID string `json:"log_id,omitempty"`
@@ -77,11 +79,16 @@ func queryAssets(ctx context.Context, assetIDs []string, requireAll bool, runner
77
79
  envelope.LogID,
78
80
  )
79
81
  }
80
- return &GetResult{
82
+ result := &GetResult{
83
+ TeamID: common.SpaceTeamID(ctx),
81
84
  RequestedAssetIDs: append([]string(nil), assetIDs...),
82
85
  Assets: ordered,
83
86
  LogID: strings.TrimSpace(envelope.LogID),
84
- }, nil
87
+ }
88
+ if session := common.SpaceSession(ctx); session != nil {
89
+ result.SpaceContext = session.Token()
90
+ }
91
+ return result, nil
85
92
  }
86
93
 
87
94
  func normalizeAssetIDs(values []string) ([]string, error) {
@@ -2,8 +2,13 @@
2
2
  package commandnames
3
3
 
4
4
  const (
5
- Login = "login"
6
- Logout = "logout"
7
- Status = "status"
8
- Update = "update"
5
+ Login = "login"
6
+ Logout = "logout"
7
+ Status = "status"
8
+ Space = "space"
9
+ Update = "update"
10
+ Completion = "completion"
11
+ Help = "help"
12
+ Model = "model"
13
+ DownloadResult = "download-result"
9
14
  )
@@ -90,6 +90,11 @@ func (c *httpClient) SendRequest(ctx context.Context, path string, body any, out
90
90
  }
91
91
 
92
92
  func (c *httpClient) SendRequestWithHeaders(ctx context.Context, path string, body any, headers map[string]string, out any) error {
93
+ var scopeErr error
94
+ ctx, scopeErr = c.prepareSpaceRequest(ctx, path, body)
95
+ if scopeErr != nil {
96
+ return scopeErr
97
+ }
93
98
  method := http.MethodPost
94
99
  if body == nil {
95
100
  method = http.MethodGet
@@ -145,6 +150,11 @@ func (c *httpClient) SendRequestWithHeaders(ctx context.Context, path string, bo
145
150
  }
146
151
 
147
152
  func (c *httpClient) SendMultipartRequest(ctx context.Context, path string, fields map[string]string, file MultipartFile, out any) error {
153
+ var scopeErr error
154
+ ctx, scopeErr = c.prepareSpaceRequest(ctx, path, nil)
155
+ if scopeErr != nil {
156
+ return scopeErr
157
+ }
148
158
  reqURL, err := c.resolveURL(path, nil)
149
159
  if err != nil {
150
160
  return err
@@ -242,6 +252,7 @@ func (c *httpClient) prepareRequest(ctx context.Context, req *http.Request, head
242
252
  c.injectHeaders(req, headers)
243
253
  // Rebuild the authentication header only for requests to the configured API origin; general request headers cannot override it.
244
254
  req.Header.Del("Authorization")
255
+ req.Header.Del("X-Team-Id")
245
256
  if !isAPIOrigin {
246
257
  c.adapter.StripSensitiveHeaders(req)
247
258
  return nil
@@ -250,6 +261,16 @@ func (c *httpClient) prepareRequest(ctx context.Context, req *http.Request, head
250
261
  if err != nil {
251
262
  return err
252
263
  }
264
+ // The selected space, like credentials, is rebuilt only for the API origin.
265
+ if session := SpaceSession(ctx); session != nil {
266
+ if err := session.CheckIdentity(ctx); err != nil {
267
+ return err
268
+ }
269
+ req.Header.Del("X-Team-Id")
270
+ if session.TeamID() != "0" {
271
+ req.Header.Set("X-Team-Id", session.TeamID())
272
+ }
273
+ }
253
274
  switch decision {
254
275
  case adapter.AuthProvided:
255
276
  return nil
@@ -313,6 +334,7 @@ func (c *httpClient) checkRedirect(req *http.Request, via []*http.Request) error
313
334
  }
314
335
  if !trusted {
315
336
  req.Header.Del("Authorization")
337
+ req.Header.Del("X-Team-Id")
316
338
  c.adapter.StripSensitiveHeaders(req)
317
339
  }
318
340
  return nil
@@ -362,6 +384,7 @@ func (c *httpClient) do(req *http.Request, out any) error {
362
384
  if err := sonic.Unmarshal(data, out); err != nil {
363
385
  return fmt.Errorf("解析响应体失败: %w", err)
364
386
  }
387
+ rememberSpaceResponse(req.Context(), data)
365
388
  return nil
366
389
  }
367
390
 
@@ -22,6 +22,11 @@ func detectHostSource(getenv func(string) string) string {
22
22
  {"CLAUDECODE", "claude_code"},
23
23
  {"CURSOR_AGENT", "cursor"},
24
24
  {"GEMINI_CLI", "gemini_cli"},
25
+ {"OPENCODE", "opencode"},
26
+ {"QWEN_CODE", "qwen_code"},
27
+ {"CLINE_ACTIVE", "cline"},
28
+ {"COPILOT_CLI", "github_copilot_cli"},
29
+ {"COPILOT_AGENT_SESSION_ID", "github_copilot_cli"},
25
30
  } {
26
31
  value := strings.ToLower(strings.TrimSpace(getenv(marker.key)))
27
32
  if value == "" || value == "0" || value == "false" {
@@ -14,9 +14,17 @@ func TestDetectHostSource(t *testing.T) {
14
14
  {"claude", map[string]string{"CLAUDECODE": "1"}, "claude_code"},
15
15
  {"cursor", map[string]string{"CURSOR_AGENT": "1"}, "cursor"},
16
16
  {"gemini", map[string]string{"GEMINI_CLI": "1"}, "gemini_cli"},
17
+ {"opencode", map[string]string{"OPENCODE": "1"}, "opencode"},
18
+ {"qwen", map[string]string{"QWEN_CODE": "1"}, "qwen_code"},
19
+ {"cline", map[string]string{"CLINE_ACTIVE": " true "}, "cline"},
20
+ {"copilot cli", map[string]string{"COPILOT_CLI": "1"}, "github_copilot_cli"},
21
+ {"copilot session", map[string]string{"COPILOT_AGENT_SESSION_ID": "secret"}, "github_copilot_cli"},
22
+ {"copilot markers agree", map[string]string{"COPILOT_CLI": "1", "COPILOT_AGENT_SESSION_ID": "secret"}, "github_copilot_cli"},
17
23
  {"ambiguous", map[string]string{"CODEX_THREAD_ID": "secret", "CURSOR_AGENT": "1"}, ""},
24
+ {"new markers conflict", map[string]string{"OPENCODE": "1", "CLINE_ACTIVE": "true"}, ""},
18
25
  {"disabled markers", map[string]string{"CLAUDECODE": "0", "GEMINI_CLI": " false "}, ""},
19
- {"configuration is not runtime evidence", map[string]string{"CODEX_HOME": "/home/example", "ANTHROPIC_API_KEY": "secret", "TERM_PROGRAM": "vscode"}, ""},
26
+ {"new markers disabled", map[string]string{"OPENCODE": "0", "QWEN_CODE": " FALSE ", "CLINE_ACTIVE": "", "COPILOT_CLI": "false", "COPILOT_AGENT_SESSION_ID": " "}, ""},
27
+ {"configuration is not runtime evidence", map[string]string{"CODEX_HOME": "/home/example", "ANTHROPIC_API_KEY": "secret", "TERM_PROGRAM": "vscode", "AGENT": "1", "OPENCODE_CONFIG": "/example/config.json", "QWEN_API_KEY": "secret", "COPILOT_HOME": "/example/copilot"}, ""},
20
28
  } {
21
29
  t.Run(tc.name, func(t *testing.T) {
22
30
  if got := detectHostSource(func(key string) string { return tc.env[key] }); got != tc.want {
@@ -5,6 +5,7 @@ import (
5
5
 
6
6
  "github.com/Pippit-dev/pippit-cli/internal/auth"
7
7
  "github.com/Pippit-dev/pippit-cli/internal/config"
8
+ "github.com/Pippit-dev/pippit-cli/internal/space"
8
9
  )
9
10
 
10
11
  // AuthManager is the credential boundary shared by native CLI commands. It
@@ -20,9 +21,10 @@ type AuthManager interface {
20
21
 
21
22
  // Runner carries runtime dependencies for command execution.
22
23
  type Runner struct {
23
- Config *config.Config
24
- Client Client
25
- Auth AuthManager
24
+ Config *config.Config
25
+ Client Client
26
+ Auth AuthManager
27
+ SpaceStore *space.Store
26
28
  }
27
29
 
28
30
  func NewRunner(cfg *config.Config, client Client) *Runner {
@@ -0,0 +1,185 @@
1
+ package common
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "net/url"
8
+
9
+ "github.com/Pippit-dev/pippit-cli/internal/config"
10
+ "github.com/Pippit-dev/pippit-cli/internal/space"
11
+ )
12
+
13
+ type spaceContextKey struct{}
14
+ type spaceRequestKey struct{}
15
+ type spaceRequest struct {
16
+ operation string
17
+ refs space.References
18
+ }
19
+
20
+ func WithSpace(ctx context.Context, session *space.Session) context.Context {
21
+ return context.WithValue(ctx, spaceContextKey{}, session)
22
+ }
23
+
24
+ func SpaceSession(ctx context.Context) *space.Session {
25
+ session, _ := ctx.Value(spaceContextKey{}).(*space.Session)
26
+ return session
27
+ }
28
+
29
+ func SpaceTeamID(ctx context.Context) string { return SpaceSession(ctx).TeamID() }
30
+
31
+ // spaceOperation classifies successful responses for local records only.
32
+ // It does not decide whether a request carries the selected TeamID.
33
+ func spaceOperation(path string) string {
34
+ u, err := url.Parse(path)
35
+ if err != nil {
36
+ return ""
37
+ }
38
+ switch u.Path {
39
+ case config.SubmitRunPath, config.SubmitMarketingRunPath:
40
+ return "submit"
41
+ case config.GetThreadPath, config.QueryGenerateVideoResultPath:
42
+ return "query"
43
+ case config.ListThreadFilePath:
44
+ return "files"
45
+ case config.UploadFilePath:
46
+ return "upload"
47
+ }
48
+ return ""
49
+ }
50
+
51
+ func SpaceReferences(body any) (space.References, error) {
52
+ var ref space.References
53
+ if body == nil {
54
+ return ref, nil
55
+ }
56
+ data, err := json.Marshal(body)
57
+ if err != nil {
58
+ return ref, err
59
+ }
60
+ if err := json.Unmarshal(data, &ref); err != nil {
61
+ return ref, fmt.Errorf("任务或素材 ID 格式无效: %w", err)
62
+ }
63
+ return ref, nil
64
+ }
65
+
66
+ func (c *httpClient) prepareSpaceRequest(ctx context.Context, path string, body any) (context.Context, error) {
67
+ session := SpaceSession(ctx)
68
+ if session == nil {
69
+ return ctx, nil
70
+ }
71
+ if target, err := url.Parse(path); err == nil && target.IsAbs() {
72
+ trusted, err := c.isBaseURLOrigin(target)
73
+ if err != nil {
74
+ return ctx, err
75
+ }
76
+ if !trusted {
77
+ return ctx, nil
78
+ }
79
+ }
80
+ ref, err := SpaceReferences(body)
81
+ if err != nil {
82
+ return ctx, err
83
+ }
84
+ if _, err := session.Resolve(ref); err != nil {
85
+ return ctx, err
86
+ }
87
+ return context.WithValue(ctx, spaceRequestKey{}, spaceRequest{operation: spaceOperation(path), refs: ref}), nil
88
+ }
89
+
90
+ func rememberSpaceResponse(ctx context.Context, data []byte) {
91
+ session := SpaceSession(ctx)
92
+ request, ok := ctx.Value(spaceRequestKey{}).(spaceRequest)
93
+ if session == nil || !ok || request.operation == "" {
94
+ return
95
+ }
96
+ var response struct {
97
+ Ret json.RawMessage `json:"ret"`
98
+ Data *struct {
99
+ ThreadID string `json:"thread_id"`
100
+ RunID string `json:"run_id"`
101
+ PippitAssetID string `json:"pippit_asset_id"`
102
+ Run struct {
103
+ ThreadID string `json:"thread_id"`
104
+ RunID string `json:"run_id"`
105
+ } `json:"run"`
106
+ } `json:"data"`
107
+ }
108
+ if json.Unmarshal(data, &response) != nil || response.Data == nil || (string(response.Ret) != `"0"` && string(response.Ret) != "0") {
109
+ return
110
+ }
111
+ ref := request.refs
112
+ switch request.operation {
113
+ case "submit":
114
+ if response.Data.Run.ThreadID == "" || response.Data.Run.RunID == "" {
115
+ return
116
+ }
117
+ if ref.ThreadID != "" && ref.ThreadID != response.Data.Run.ThreadID {
118
+ return
119
+ }
120
+ ref.ThreadID, ref.RunID = response.Data.Run.ThreadID, response.Data.Run.RunID
121
+ case "query", "files":
122
+ if ref.ThreadID == "" {
123
+ return
124
+ }
125
+ if response.Data.ThreadID != "" && response.Data.ThreadID != ref.ThreadID {
126
+ return
127
+ }
128
+ if response.Data.RunID != "" && response.Data.RunID != ref.RunID {
129
+ return
130
+ }
131
+ case "upload":
132
+ if response.Data.PippitAssetID == "" {
133
+ return
134
+ }
135
+ ref.AssetIDs = []string{response.Data.PippitAssetID}
136
+ default:
137
+ return
138
+ }
139
+ session.Remember(ref)
140
+ }
141
+
142
+ func SpaceAccount(ctx context.Context, runner *Runner) (string, error) {
143
+ // Without an AuthManager, authentication belongs to the adapter. An unused
144
+ // environment AK must not stand in for the adapter's actual account.
145
+ if runner.Auth == nil {
146
+ return "", nil
147
+ }
148
+ if provider, ok := runner.Auth.(interface {
149
+ SpaceAccount(context.Context) (string, error)
150
+ }); ok {
151
+ return provider.SpaceAccount(ctx)
152
+ }
153
+ status, err := runner.Auth.Status(ctx)
154
+ if err != nil {
155
+ return "", err
156
+ }
157
+ if status != nil && status.UID != "" {
158
+ return "user:" + space.Digest(status.UID), nil
159
+ }
160
+ return "", nil
161
+ }
162
+
163
+ // SpaceStoreForRunner returns nil when the current account cannot be identified.
164
+ // Callers can use an explicit space without persisting local records.
165
+ func SpaceStoreForRunner(ctx context.Context, runner *Runner) (*space.Store, error) {
166
+ account, err := SpaceAccount(ctx, runner)
167
+ if err != nil {
168
+ return nil, err
169
+ }
170
+ if account == "" {
171
+ return nil, nil
172
+ }
173
+ base := config.DefaultBaseURL
174
+ if runner.Config != nil && runner.Config.BaseURL != "" {
175
+ base = runner.Config.BaseURL
176
+ }
177
+ store, err := space.NewStore(base, account)
178
+ if err != nil {
179
+ return nil, err
180
+ }
181
+ if runner.SpaceStore != nil {
182
+ store.Dir = runner.SpaceStore.Dir
183
+ }
184
+ return store, nil
185
+ }