@pippit-dev/cli 1.0.17 → 1.0.20

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 (50) hide show
  1. package/README.md +41 -2
  2. package/checksums.txt +6 -6
  3. package/cmd/auth/auth.go +136 -142
  4. package/cmd/auth/auth_test.go +134 -0
  5. package/cmd/canvas/canvas.go +241 -0
  6. package/cmd/canvas/canvas_test.go +182 -0
  7. package/cmd/get_credit_balance.go +37 -0
  8. package/cmd/get_credit_balance_test.go +73 -0
  9. package/cmd/root.go +31 -5
  10. package/cmd/root_test.go +69 -0
  11. package/cmd/short_drama_test.go +5 -7
  12. package/cmd/update/update.go +34 -5
  13. package/cmd/update/update_test.go +84 -0
  14. package/dist/checksums.txt +6 -0
  15. package/dist/xyq-canvas-command-runtime.cjs +16 -0
  16. package/dist/xyq-canvas-command-runtime.cjs.LEGAL.txt +599 -0
  17. package/dist/xyq-canvas-command-runtime.cjs.sha256 +2 -0
  18. package/internal/auth/auth_test.go +813 -0
  19. package/internal/auth/browser_darwin.go +14 -0
  20. package/internal/auth/browser_env.go +35 -0
  21. package/internal/auth/browser_linux.go +14 -0
  22. package/internal/auth/browser_windows.go +14 -0
  23. package/internal/auth/identity.go +89 -0
  24. package/internal/auth/loopback.go +324 -0
  25. package/internal/auth/manager.go +338 -144
  26. package/internal/auth/store.go +303 -0
  27. package/internal/auth/store_file_unix.go +176 -0
  28. package/internal/auth/store_file_windows.go +11 -0
  29. package/internal/auth/types.go +72 -0
  30. package/internal/canvas/allocate.go +70 -0
  31. package/internal/canvas/apply.go +279 -0
  32. package/internal/canvas/canvas_test.go +559 -0
  33. package/internal/canvas/create.go +380 -0
  34. package/internal/canvas/get.go +156 -0
  35. package/internal/canvas/types.go +68 -0
  36. package/internal/canvas/upload.go +250 -0
  37. package/internal/common/access_key.go +42 -6
  38. package/internal/common/access_key_test.go +113 -0
  39. package/internal/common/client.go +119 -21
  40. package/internal/common/client_test.go +213 -0
  41. package/internal/common/get_credit_balance.go +61 -0
  42. package/internal/common/get_credit_balance_test.go +75 -0
  43. package/internal/common/runner.go +15 -0
  44. package/internal/config/config.go +11 -28
  45. package/internal/config/config_test.go +3 -18
  46. package/package.json +9 -2
  47. package/scripts/canvas-command.js +881 -0
  48. package/scripts/run.js +21 -4
  49. package/skills/short-drama/SKILL.md +4 -4
  50. package/skills/xyq-nest-skill/SKILL.md +11 -1
@@ -0,0 +1,241 @@
1
+ package canvas
2
+
3
+ import (
4
+ "crypto/rand"
5
+ "encoding/hex"
6
+ "encoding/json"
7
+ "fmt"
8
+ "io"
9
+ "os"
10
+ "path/filepath"
11
+ "strings"
12
+ "time"
13
+
14
+ canvascore "github.com/Pippit-dev/pippit-cli/internal/canvas"
15
+ "github.com/Pippit-dev/pippit-cli/internal/common"
16
+ "github.com/spf13/cobra"
17
+ )
18
+
19
+ const maxApplyRequestBytes = 64 << 20
20
+
21
+ // NewCommand builds the provider-neutral personal Canvas command tree.
22
+ func NewCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
23
+ cmd := &cobra.Command{
24
+ Use: "canvas",
25
+ Short: "Create and operate personal novel canvases",
26
+ Args: cobra.NoArgs,
27
+ }
28
+ cmd.SetOut(stdout)
29
+ cmd.SetErr(stderr)
30
+ cmd.AddCommand(newCreateCommand(stdout, stderr, runner))
31
+ cmd.AddCommand(newGetCommand(stdout, stderr, runner))
32
+ cmd.AddCommand(newAllocateCommand(stdout, stderr, runner))
33
+ cmd.AddCommand(newApplyCommand(stdout, stderr, runner))
34
+ cmd.AddCommand(newUploadCommand(stdout, stderr, runner))
35
+ return cmd
36
+ }
37
+
38
+ func newAllocateCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
39
+ var count int
40
+ cmd := &cobra.Command{
41
+ Use: "allocate",
42
+ Short: "Allocate IDs for assets created by a later Canvas transaction",
43
+ Args: cobra.NoArgs,
44
+ RunE: func(cmd *cobra.Command, _ []string) error {
45
+ result, err := canvascore.Allocate(cmd.Context(), count, runner)
46
+ if err != nil {
47
+ logCanvasError("canvas allocate", err, map[string]string{"count": fmt.Sprint(count)})
48
+ return err
49
+ }
50
+ return common.WriteJSON(stdout, result)
51
+ },
52
+ }
53
+ cmd.SetOut(stdout)
54
+ cmd.SetErr(stderr)
55
+ cmd.Flags().IntVar(&count, "count", 0, "number of unique Pippit asset IDs to allocate")
56
+ return cmd
57
+ }
58
+
59
+ func newCreateCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
60
+ var opts canvascore.CreateOptions
61
+ cmd := &cobra.Command{
62
+ Use: "create",
63
+ Short: "Create a personal novel Canvas project",
64
+ Args: cobra.NoArgs,
65
+ RunE: func(cmd *cobra.Command, _ []string) error {
66
+ if strings.TrimSpace(opts.RequestID) == "" {
67
+ requestID, err := newRequestID()
68
+ if err != nil {
69
+ return fmt.Errorf("generate canvas request ID: %w", err)
70
+ }
71
+ opts.RequestID = requestID
72
+ }
73
+ result, err := canvascore.Create(cmd.Context(), opts, runner)
74
+ if result != nil {
75
+ if writeErr := common.WriteJSON(stdout, result); writeErr != nil {
76
+ return writeErr
77
+ }
78
+ }
79
+ if err != nil {
80
+ logCanvasError("canvas create", err, map[string]string{"request_id": opts.RequestID})
81
+ return err
82
+ }
83
+ return nil
84
+ },
85
+ }
86
+ cmd.SetOut(stdout)
87
+ cmd.SetErr(stderr)
88
+ flags := cmd.Flags()
89
+ flags.StringVar(&opts.Title, "title", "", "project title (maximum 50 characters)")
90
+ flags.StringVar(&opts.RequestID, "request-id", "", "caller request ID; generated when omitted")
91
+ flags.BoolVar(&opts.Wait, "wait", false, "wait for the novel overview artifact")
92
+ flags.DurationVar(&opts.PollInterval, "poll-interval", time.Second, "artifact polling interval")
93
+ flags.DurationVar(&opts.WaitTimeout, "timeout", 2*time.Minute, "maximum artifact wait time")
94
+ return cmd
95
+ }
96
+
97
+ func newGetCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
98
+ var assetIDs []string
99
+ cmd := &cobra.Command{
100
+ Use: "get",
101
+ Short: "Get personal Canvas assets by ID",
102
+ Args: cobra.NoArgs,
103
+ RunE: func(cmd *cobra.Command, _ []string) error {
104
+ result, err := canvascore.Get(cmd.Context(), canvascore.GetOptions{AssetIDs: assetIDs}, runner)
105
+ if err != nil {
106
+ logCanvasError("canvas get", err, map[string]string{"asset_count": fmt.Sprint(len(assetIDs))})
107
+ return err
108
+ }
109
+ return common.WriteJSON(stdout, result)
110
+ },
111
+ }
112
+ cmd.SetOut(stdout)
113
+ cmd.SetErr(stderr)
114
+ cmd.Flags().StringArrayVar(&assetIDs, "asset-id", nil, "Pippit asset ID; repeat for multiple assets")
115
+ return cmd
116
+ }
117
+
118
+ func newApplyCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
119
+ var filePath string
120
+ var projectID string
121
+ var transportResult bool
122
+ cmd := &cobra.Command{
123
+ Use: "apply",
124
+ Short: "Apply one Canvas patch transaction",
125
+ Args: cobra.NoArgs,
126
+ RunE: func(cmd *cobra.Command, _ []string) error {
127
+ request, err := readApplyRequest(cmd.InOrStdin(), filePath)
128
+ if err != nil {
129
+ return err
130
+ }
131
+ result, err := canvascore.Apply(cmd.Context(), canvascore.ApplyOptions{
132
+ ProjectID: projectID,
133
+ Request: request,
134
+ AllowNonAcknowledgedResults: transportResult,
135
+ }, runner)
136
+ if err != nil {
137
+ logCanvasError("canvas apply", err, map[string]string{
138
+ "batch_id": request.BatchID,
139
+ "project_id": projectID,
140
+ "transactions": fmt.Sprint(len(request.Transactions)),
141
+ })
142
+ return err
143
+ }
144
+ return common.WriteJSON(stdout, result)
145
+ },
146
+ }
147
+ cmd.SetOut(stdout)
148
+ cmd.SetErr(stderr)
149
+ flags := cmd.Flags()
150
+ flags.StringVar(&filePath, "file", "-", "BatchPatch JSON request file, or - for stdin")
151
+ flags.StringVar(&projectID, "project-id", "", "personal novel project ID as a decimal string")
152
+ flags.BoolVar(&transportResult, "transport-result", false, "return explicit transaction outcomes for transport decoding")
153
+ if err := flags.MarkHidden("transport-result"); err != nil {
154
+ panic(err)
155
+ }
156
+ return cmd
157
+ }
158
+
159
+ func newUploadCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
160
+ var opts canvascore.UploadOptions
161
+ cmd := &cobra.Command{
162
+ Use: "upload",
163
+ Short: "Upload a file to personal assets and wait until it is queryable",
164
+ Args: cobra.NoArgs,
165
+ RunE: func(cmd *cobra.Command, _ []string) error {
166
+ result, err := canvascore.Upload(cmd.Context(), opts, runner)
167
+ if err != nil {
168
+ logCanvasError("canvas upload", err, map[string]string{"file_name": filepath.Base(strings.TrimSpace(opts.Path))})
169
+ return err
170
+ }
171
+ return common.WriteJSON(stdout, result)
172
+ },
173
+ }
174
+ cmd.SetOut(stdout)
175
+ cmd.SetErr(stderr)
176
+ flags := cmd.Flags()
177
+ flags.StringVar(&opts.Path, "path", "", "local file path to upload")
178
+ flags.DurationVar(&opts.PollInterval, "poll-interval", time.Second, "asset visibility polling interval")
179
+ flags.DurationVar(&opts.WaitTimeout, "timeout", 2*time.Minute, "maximum asset visibility wait time")
180
+ return cmd
181
+ }
182
+
183
+ func newRequestID() (string, error) {
184
+ random := make([]byte, 16)
185
+ if _, err := rand.Read(random); err != nil {
186
+ return "", err
187
+ }
188
+ return "pippit_cli_canvas_" + hex.EncodeToString(random), nil
189
+ }
190
+
191
+ func readApplyRequest(stdin io.Reader, filePath string) (canvascore.ApplyRequest, error) {
192
+ filePath = strings.TrimSpace(filePath)
193
+ if filePath == "" {
194
+ return canvascore.ApplyRequest{}, fmt.Errorf("canvas apply --file must not be empty")
195
+ }
196
+ var reader io.Reader
197
+ var file *os.File
198
+ if filePath == "-" {
199
+ reader = stdin
200
+ } else {
201
+ var err error
202
+ file, err = os.Open(filePath)
203
+ if err != nil {
204
+ return canvascore.ApplyRequest{}, fmt.Errorf("open canvas apply request: %w", err)
205
+ }
206
+ defer file.Close()
207
+ reader = file
208
+ }
209
+ limited := io.LimitReader(reader, maxApplyRequestBytes+1)
210
+ payload, err := io.ReadAll(limited)
211
+ if err != nil {
212
+ return canvascore.ApplyRequest{}, fmt.Errorf("read canvas apply request: %w", err)
213
+ }
214
+ if len(payload) > maxApplyRequestBytes {
215
+ return canvascore.ApplyRequest{}, fmt.Errorf("canvas apply request exceeds %d bytes", maxApplyRequestBytes)
216
+ }
217
+ decoder := json.NewDecoder(strings.NewReader(string(payload)))
218
+ decoder.DisallowUnknownFields()
219
+ var request canvascore.ApplyRequest
220
+ if err := decoder.Decode(&request); err != nil {
221
+ return canvascore.ApplyRequest{}, fmt.Errorf("decode canvas apply request: %w", err)
222
+ }
223
+ if err := ensureJSONEOF(decoder); err != nil {
224
+ return canvascore.ApplyRequest{}, err
225
+ }
226
+ return request, nil
227
+ }
228
+
229
+ func ensureJSONEOF(decoder *json.Decoder) error {
230
+ var trailing any
231
+ if err := decoder.Decode(&trailing); err == io.EOF {
232
+ return nil
233
+ } else if err != nil {
234
+ return fmt.Errorf("decode trailing canvas apply data: %w", err)
235
+ }
236
+ return fmt.Errorf("canvas apply request must contain exactly one JSON object")
237
+ }
238
+
239
+ func logCanvasError(command string, err error, fields map[string]string) {
240
+ _ = common.AppendDailyErrorLog(command, err, fields)
241
+ }
@@ -0,0 +1,182 @@
1
+ package canvas
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "encoding/json"
7
+ "strings"
8
+ "testing"
9
+
10
+ "github.com/Pippit-dev/pippit-cli/internal/common"
11
+ "github.com/spf13/cobra"
12
+ )
13
+
14
+ type commandFakeClient struct {
15
+ response string
16
+ request map[string]any
17
+ handler func(string, any, any) error
18
+ }
19
+
20
+ func (f *commandFakeClient) SendRequest(_ context.Context, path string, body any, out any) error {
21
+ payload, _ := json.Marshal(body)
22
+ _ = json.Unmarshal(payload, &f.request)
23
+ if f.handler != nil {
24
+ return f.handler(path, body, out)
25
+ }
26
+ return json.Unmarshal([]byte(f.response), out)
27
+ }
28
+
29
+ func (f *commandFakeClient) SendRequestWithHeaders(ctx context.Context, path string, body any, _ map[string]string, out any) error {
30
+ return f.SendRequest(ctx, path, body, out)
31
+ }
32
+
33
+ func (f *commandFakeClient) SendMultipartRequest(context.Context, string, map[string]string, common.MultipartFile, any) error {
34
+ return nil
35
+ }
36
+
37
+ func TestCommandExposesOnlyProviderNeutralPublicVerbs(t *testing.T) {
38
+ cmd := NewCommand(&bytes.Buffer{}, &bytes.Buffer{}, &common.Runner{Client: &commandFakeClient{}})
39
+ got := make([]string, 0, len(cmd.Commands()))
40
+ for _, child := range cmd.Commands() {
41
+ got = append(got, child.Name())
42
+ }
43
+ if strings.Join(got, ",") != "allocate,apply,create,get,upload" {
44
+ t.Fatalf("commands = %v, want allocate/apply/create/get/upload", got)
45
+ }
46
+ for _, forbidden := range []string{"import", "bind", "team"} {
47
+ if strings.Contains(strings.ToLower(cmd.CommandPath()+" "+cmd.Short+" "+strings.Join(got, " ")), forbidden) {
48
+ t.Fatalf("public command surface contains forbidden verb %q", forbidden)
49
+ }
50
+ }
51
+ }
52
+
53
+ func TestAllocateCommandPrintsOneMachineReadableJSONLine(t *testing.T) {
54
+ client := &commandFakeClient{response: `{"ret":"0","log_id":"log-1","data":{"ids":["10","11"]}}`}
55
+ var stdout, stderr bytes.Buffer
56
+ cmd := NewCommand(&stdout, &stderr, &common.Runner{Client: client})
57
+ cmd.SetArgs([]string{"allocate", "--count", "2"})
58
+ if err := cmd.Execute(); err != nil {
59
+ t.Fatalf("Execute() error = %v", err)
60
+ }
61
+ if strings.Count(stdout.String(), "\n") != 1 {
62
+ t.Fatalf("stdout = %q, want one JSON line", stdout.String())
63
+ }
64
+ var result map[string]any
65
+ if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &result); err != nil {
66
+ t.Fatalf("stdout is not JSON: %v", err)
67
+ }
68
+ if len(result["asset_ids"].([]any)) != 2 || client.request["count"] != float64(2) {
69
+ t.Fatalf("result/request = (%#v, %#v), want two allocated IDs", result, client.request)
70
+ }
71
+ }
72
+
73
+ func TestCreateCommandPrintsOneMachineReadableJSONLine(t *testing.T) {
74
+ client := &commandFakeClient{response: `{"ret":"0","log_id":"log-1","data":{"state":"creating","project_id":"100","thread_id":"thread-1","run_id":"run-1","canvas_asset_id":"200","web_url":"https://xyq.jianying.com/novel/detail/canvas?projectId=100&canvasId=200"}}`}
75
+ var stdout, stderr bytes.Buffer
76
+ cmd := NewCommand(&stdout, &stderr, &common.Runner{Client: client})
77
+ cmd.SetArgs([]string{"create", "--title", "Demo", "--request-id", "request-1"})
78
+ if err := cmd.Execute(); err != nil {
79
+ t.Fatalf("Execute() error = %v", err)
80
+ }
81
+ if strings.Count(stdout.String(), "\n") != 1 {
82
+ t.Fatalf("stdout = %q, want one JSON line", stdout.String())
83
+ }
84
+ var result map[string]any
85
+ if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &result); err != nil {
86
+ t.Fatalf("stdout is not JSON: %v", err)
87
+ }
88
+ if result["request_id"] != "request-1" || result["project_id"] != "100" || client.request["surface"] != "novel" {
89
+ t.Fatalf("result/request = (%#v, %#v), want personal novel create", result, client.request)
90
+ }
91
+ }
92
+
93
+ func TestCreateCommandWaitTimeoutPrintsAcceptedIDs(t *testing.T) {
94
+ client := &commandFakeClient{handler: func(path string, _ any, out any) error {
95
+ response := `{"ret":"0","data":{"thread":{"run_list":[{"run_id":"run-1","state":1}]}}}`
96
+ if path == "/api/biz/v1/skill/canvas/create" {
97
+ response = `{"ret":"0","data":{"state":"creating","project_id":"100","thread_id":"thread-1","run_id":"run-1","canvas_asset_id":"200","web_url":"https://xyq.jianying.com/novel/detail/canvas?projectId=100&canvasId=200"}}`
98
+ }
99
+ return json.Unmarshal([]byte(response), out)
100
+ }}
101
+ var stdout, stderr bytes.Buffer
102
+ cmd := NewCommand(&stdout, &stderr, &common.Runner{Client: client})
103
+ cmd.SetArgs([]string{
104
+ "create", "--request-id", "request-1", "--wait",
105
+ "--poll-interval", "50ms", "--timeout", "1ms",
106
+ })
107
+ if err := cmd.Execute(); err != nil {
108
+ t.Fatalf("Execute() error = %v, want accepted create outcome", err)
109
+ }
110
+ var result map[string]any
111
+ if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &result); err != nil {
112
+ t.Fatalf("stdout = %q, want JSON: %v", stdout.String(), err)
113
+ }
114
+ if result["project_id"] != "100" || result["state"] != "creating" || result["warning"] == "" {
115
+ t.Fatalf("result = %#v, want accepted IDs and wait warning", result)
116
+ }
117
+ }
118
+
119
+ func TestApplyRequestRejectsTeamOrImportExtensions(t *testing.T) {
120
+ for _, field := range []string{"team_id", "provider", "import_source"} {
121
+ payload := `{"batch_id":"b","client_id":"c","transactions":[],"` + field + `":"x"}`
122
+ _, err := readApplyRequest(strings.NewReader(payload), "-")
123
+ if err == nil || !strings.Contains(err.Error(), "unknown field") {
124
+ t.Fatalf("readApplyRequest(%s) error = %v, want unknown field rejection", field, err)
125
+ }
126
+ }
127
+ }
128
+
129
+ func TestApplyTransportResultFlagIsHiddenAndPrintsExplicitReject(t *testing.T) {
130
+ client := &commandFakeClient{response: `{"ret":"0","log_id":"transport-log","data":{"results":[{"transaction_id":"tx-1","status":"reject","message":"version conflict","current_asset_versions":{"asset-1":9},"server_detail":{"reason":"conflict"}}]}}`}
131
+ var stdout, stderr bytes.Buffer
132
+ cmd := NewCommand(&stdout, &stderr, &common.Runner{Client: client})
133
+ var applyCommand *cobra.Command
134
+ for _, child := range cmd.Commands() {
135
+ if child.Name() == "apply" {
136
+ applyCommand = child
137
+ break
138
+ }
139
+ }
140
+ if applyCommand == nil {
141
+ t.Fatal("apply command not found")
142
+ }
143
+ flag := applyCommand.Flags().Lookup("transport-result")
144
+ if flag == nil || !flag.Hidden {
145
+ t.Fatalf("transport-result flag = %#v, want hidden flag", flag)
146
+ }
147
+
148
+ request := `{"batch_id":"batch-1","client_id":"client-1","transactions":[{"transaction_id":"tx-1","patches":[{"asset_id":"asset-1","op":"replace","path":"","value":{}}]}]}`
149
+ cmd.SetIn(strings.NewReader(request))
150
+ cmd.SetArgs([]string{"apply", "--file", "-", "--transport-result"})
151
+ if err := cmd.Execute(); err != nil {
152
+ t.Fatalf("Execute() error = %v", err)
153
+ }
154
+ var output map[string]any
155
+ if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &output); err != nil {
156
+ t.Fatalf("stdout = %q, want JSON: %v", stdout.String(), err)
157
+ }
158
+ result := output["results"].([]any)[0].(map[string]any)
159
+ if result["status"] != "reject" || result["message"] != "version conflict" || result["server_detail"] == nil {
160
+ t.Fatalf("result = %#v, want complete explicit reject", result)
161
+ }
162
+ }
163
+
164
+ func TestApplyTransportResultPrintsRootCreateAckWithoutVersion(t *testing.T) {
165
+ client := &commandFakeClient{response: `{"ret":"0","data":{"results":[{"transaction_id":"tx-1","status":"ack","server_detail":{"created":true}}]}}`}
166
+ var stdout, stderr bytes.Buffer
167
+ cmd := NewCommand(&stdout, &stderr, &common.Runner{Client: client})
168
+ request := `{"batch_id":"batch-1","client_id":"client-1","transactions":[{"transaction_id":"tx-1","patches":[{"asset_id":"asset-new","op":"add","path":"","value":{}}]}]}`
169
+ cmd.SetIn(strings.NewReader(request))
170
+ cmd.SetArgs([]string{"apply", "--file", "-", "--transport-result"})
171
+ if err := cmd.Execute(); err != nil {
172
+ t.Fatalf("Execute() error = %v", err)
173
+ }
174
+ var output map[string]any
175
+ if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &output); err != nil {
176
+ t.Fatalf("stdout = %q, want JSON: %v", stdout.String(), err)
177
+ }
178
+ result := output["results"].([]any)[0].(map[string]any)
179
+ if result["status"] != "ack" || result["asset_versions"] != nil || result["server_detail"] == nil {
180
+ t.Fatalf("result = %#v, want unchanged root-create ACK", result)
181
+ }
182
+ }
@@ -0,0 +1,37 @@
1
+ package cmd
2
+
3
+ import (
4
+ "io"
5
+
6
+ "github.com/Pippit-dev/pippit-cli/internal/common"
7
+ "github.com/spf13/cobra"
8
+ )
9
+
10
+ func newGetCreditBalanceCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
11
+ var withLogID bool
12
+ cmd := &cobra.Command{
13
+ Use: "get-credit-balance",
14
+ Short: "Get the effective credit balance",
15
+ Args: cobra.NoArgs,
16
+ RunE: withErrorLog("get-credit-balance", nil, func(cmd *cobra.Command, _ []string) error {
17
+ result, err := common.GetCreditBalance(cmd.Context(), runner)
18
+ if err != nil {
19
+ return err
20
+ }
21
+ if withLogID {
22
+ return common.WriteJSON(stdout, struct {
23
+ TotalRemainAmount int64 `json:"total_remain_amount,string"`
24
+ LogID string `json:"log_id"`
25
+ }{
26
+ TotalRemainAmount: result.TotalRemainAmount,
27
+ LogID: result.LogID,
28
+ })
29
+ }
30
+ return common.WriteJSON(stdout, result)
31
+ }),
32
+ }
33
+ cmd.SetOut(stdout)
34
+ cmd.SetErr(stderr)
35
+ cmd.Flags().BoolVar(&withLogID, "with-log-id", false, "include the request log ID in JSON output")
36
+ return cmd
37
+ }
@@ -0,0 +1,73 @@
1
+ package cmd
2
+
3
+ import (
4
+ "bytes"
5
+ "io"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "testing"
9
+ )
10
+
11
+ func TestGetCreditBalanceCommand(t *testing.T) {
12
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
13
+ if r.Method != http.MethodPost {
14
+ t.Fatalf("method = %s, want POST", r.Method)
15
+ }
16
+ if r.URL.Path != "/api/biz/v1/skill/get_credit_balance" {
17
+ t.Fatalf("path = %s, want get_credit_balance path", r.URL.Path)
18
+ }
19
+ if r.Header.Get("Authorization") != "Bearer test-token" {
20
+ t.Fatalf("Authorization = %q, want test bearer token", r.Header.Get("Authorization"))
21
+ }
22
+ body, err := io.ReadAll(r.Body)
23
+ if err != nil {
24
+ t.Fatalf("read body: %v", err)
25
+ }
26
+ if string(body) != "{}" {
27
+ t.Fatalf("body = %s, want empty JSON object", body)
28
+ }
29
+ w.Header().Set("Content-Type", "application/json")
30
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"total_remain_amount":"0"}}`))
31
+ }))
32
+ defer server.Close()
33
+
34
+ var stdout, stderr bytes.Buffer
35
+ root := newTestRootCommand(t, &stdout, &stderr, server.URL)
36
+ root.SetArgs([]string{"get-credit-balance"})
37
+
38
+ if err := root.Execute(); err != nil {
39
+ t.Fatalf("Execute() error = %v, stderr = %s", err, stderr.String())
40
+ }
41
+ if got, want := stdout.String(), "{\"total_remain_amount\":\"0\"}\n"; got != want {
42
+ t.Fatalf("stdout = %q, want %q", got, want)
43
+ }
44
+ }
45
+
46
+ func TestGetCreditBalanceCommandRejectsArguments(t *testing.T) {
47
+ var stdout, stderr bytes.Buffer
48
+ root := NewRootCommand(&stdout, &stderr)
49
+ root.SetArgs([]string{"get-credit-balance", "unexpected"})
50
+
51
+ if err := root.Execute(); err == nil {
52
+ t.Fatal("Execute() error = nil, want argument rejection")
53
+ }
54
+ }
55
+
56
+ func TestGetCreditBalanceCommandSupportsLogID(t *testing.T) {
57
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
58
+ w.Header().Set("Content-Type", "application/json")
59
+ _, _ = w.Write([]byte(`{"ret":"0","log_id":"log_123","data":{"total_remain_amount":"48592"}}`))
60
+ }))
61
+ defer server.Close()
62
+
63
+ var stdout, stderr bytes.Buffer
64
+ root := newTestRootCommand(t, &stdout, &stderr, server.URL)
65
+ root.SetArgs([]string{"get-credit-balance", "--with-log-id"})
66
+
67
+ if err := root.Execute(); err != nil {
68
+ t.Fatalf("Execute() error = %v, stderr = %s", err, stderr.String())
69
+ }
70
+ if got, want := stdout.String(), "{\"total_remain_amount\":\"48592\",\"log_id\":\"log_123\"}\n"; got != want {
71
+ t.Fatalf("stdout = %q, want %q", got, want)
72
+ }
73
+ }
package/cmd/root.go CHANGED
@@ -1,17 +1,20 @@
1
1
  package cmd
2
2
 
3
3
  import (
4
+ "context"
4
5
  "fmt"
5
6
  "io"
6
7
  "os"
7
8
  "strings"
8
9
 
9
- // authcmd "github.com/Pippit-dev/pippit-cli/cmd/auth"
10
+ authcmd "github.com/Pippit-dev/pippit-cli/cmd/auth"
11
+ canvascmd "github.com/Pippit-dev/pippit-cli/cmd/canvas"
10
12
  "github.com/Pippit-dev/pippit-cli/cmd/generate_image"
11
13
  "github.com/Pippit-dev/pippit-cli/cmd/generate_video"
12
14
  "github.com/Pippit-dev/pippit-cli/cmd/short_drama"
13
15
  updatecmd "github.com/Pippit-dev/pippit-cli/cmd/update"
14
16
  "github.com/Pippit-dev/pippit-cli/cmd/video_tool"
17
+ internal_auth "github.com/Pippit-dev/pippit-cli/internal/auth"
15
18
  "github.com/Pippit-dev/pippit-cli/internal/common"
16
19
  "github.com/Pippit-dev/pippit-cli/internal/config"
17
20
  "github.com/Pippit-dev/pippit-cli/internal/version"
@@ -25,16 +28,35 @@ func Execute() error {
25
28
 
26
29
  func NewRootCommand(stdout, stderr io.Writer) *cobra.Command {
27
30
  cfg := config.Load()
28
- client := common.NewHTTPClient(cfg.BaseURL, cfg.HTTPTimeout, common.NewAccessKeyAuthorizer(cfg.AccessKey))
29
- runner := common.NewRunner(cfg, client)
31
+ runner := newRootRunner(cfg)
30
32
  return newRootCommand(stdout, stderr, runner)
31
33
  }
32
34
 
35
+ func newRootRunner(cfg *config.Config) *common.Runner {
36
+ 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
+ )
43
+ return runner
44
+ }
45
+
46
+ func newRunnerAuthorizer(runner *common.Runner) common.RequestAuthorizer {
47
+ return common.NewAccessKeyContextProviderAuthorizer(func(ctx context.Context) (string, error) {
48
+ if runner.Auth == nil {
49
+ return "", nil
50
+ }
51
+ return runner.Auth.ResolveAccessKey(ctx)
52
+ })
53
+ }
54
+
33
55
  func newRootCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
34
56
  root := &cobra.Command{
35
57
  Use: "pippit-tool-cli",
36
58
  Short: "Pippit CLI",
37
- Long: "Pippit CLI generates and processes videos and images, submits short-drama workflows, downloads generated assets, and updates the installed CLI package.",
59
+ 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.",
38
60
  Version: version.Current(),
39
61
  SilenceUsage: true,
40
62
  SilenceErrors: true,
@@ -43,8 +65,12 @@ func newRootCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Comm
43
65
  root.SetVersionTemplate("{{.Version}}\n")
44
66
  root.SetOut(stdout)
45
67
  root.SetErr(stderr)
46
- // root.AddCommand(authcmd.NewCommand(stdout, stderr, runner)) // temporarily disabled; auth is via access key injection
68
+ root.AddCommand(authcmd.NewLoginCommand(stdout, stderr, runner))
69
+ root.AddCommand(authcmd.NewStatusCommand(stdout, stderr, runner))
70
+ root.AddCommand(authcmd.NewLogoutCommand(stdout, stderr, runner))
71
+ root.AddCommand(canvascmd.NewCommand(stdout, stderr, runner))
47
72
  root.AddCommand(newDownloadResultCommand(stdout, stderr, runner))
73
+ root.AddCommand(newGetCreditBalanceCommand(stdout, stderr, runner))
48
74
  root.AddCommand(newGetThreadCommand(stdout, stderr, runner))
49
75
  root.AddCommand(newListThreadFileCommand(stdout, stderr, runner))
50
76
  root.AddCommand(generate_image.NewCommand(stdout, stderr, runner))
@@ -0,0 +1,69 @@
1
+ package cmd
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "strings"
9
+ "testing"
10
+ "time"
11
+
12
+ "github.com/Pippit-dev/pippit-cli/internal/config"
13
+ )
14
+
15
+ func TestRootRunnerReadsUpdatedAccessKeyForEveryRequest(t *testing.T) {
16
+ received := make([]string, 0, 2)
17
+ server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
18
+ received = append(received, request.Header.Get("Authorization"))
19
+ writer.Header().Set("Content-Type", "application/json")
20
+ _, _ = writer.Write([]byte(`{"ok":true}`))
21
+ }))
22
+ defer server.Close()
23
+
24
+ cfg := config.Load()
25
+ cfg.BaseURL = server.URL
26
+ cfg.HTTPTimeout = time.Second
27
+ cfg.AccessKey = "first-key"
28
+ runner := newRootRunner(cfg)
29
+
30
+ for _, accessKey := range []string{"first-key", "second-key"} {
31
+ runner.Config.AccessKey = accessKey
32
+ var response map[string]any
33
+ if err := runner.Client.SendRequest(context.Background(), "/probe", map[string]any{}, &response); err != nil {
34
+ t.Fatalf("SendRequest(%q) error = %v", accessKey, err)
35
+ }
36
+ }
37
+ if got, want := strings.Join(received, ","), "Bearer first-key,Bearer second-key"; got != want {
38
+ t.Fatalf("Authorization headers = %q, want %q", got, want)
39
+ }
40
+ }
41
+
42
+ func TestRootRegistersCanvas(t *testing.T) {
43
+ var stdout, stderr bytes.Buffer
44
+ root := NewRootCommand(&stdout, &stderr)
45
+ command, _, err := root.Find([]string{"canvas"})
46
+ if err != nil || command == nil || command.Name() != "canvas" {
47
+ t.Fatalf("root.Find(%q) = %#v, %v", "canvas", command, err)
48
+ }
49
+ }
50
+
51
+ func TestRootRegistersGetCreditBalance(t *testing.T) {
52
+ var stdout, stderr bytes.Buffer
53
+ root := NewRootCommand(&stdout, &stderr)
54
+ command, _, err := root.Find([]string{"get-credit-balance"})
55
+ if err != nil || command == nil || command.Name() != "get-credit-balance" {
56
+ t.Fatalf("root.Find(%q) = %#v, %v", "get-credit-balance", command, err)
57
+ }
58
+ }
59
+
60
+ func TestRootRegistersTopLevelBrowserAuthCommands(t *testing.T) {
61
+ var stdout, stderr bytes.Buffer
62
+ root := NewRootCommand(&stdout, &stderr)
63
+ for _, name := range []string{"login", "status", "logout"} {
64
+ command, _, err := root.Find([]string{name})
65
+ if err != nil || command == nil || command.Name() != name {
66
+ t.Fatalf("root.Find(%q) = %#v, %v", name, command, err)
67
+ }
68
+ }
69
+ }