@pippit-dev/cli 0.0.1
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.
- package/LICENSE +21 -0
- package/README.md +50 -0
- package/cmd/auth/auth.go +144 -0
- package/cmd/root.go +42 -0
- package/cmd/short_drama/short_drama.go +196 -0
- package/cmd/short_drama_test.go +587 -0
- package/cmd/update/update.go +124 -0
- package/internal/auth/manager.go +146 -0
- package/internal/common/access_key.go +29 -0
- package/internal/common/client.go +137 -0
- package/internal/common/download_results.go +243 -0
- package/internal/common/list_thread_file.go +104 -0
- package/internal/common/runner.go +21 -0
- package/internal/common/upload_file.go +49 -0
- package/internal/config/config.go +72 -0
- package/internal/config/config_test.go +49 -0
- package/internal/short_drama/get_thread.go +137 -0
- package/internal/short_drama/mock.go +47 -0
- package/internal/short_drama/submit_run.go +81 -0
- package/package.json +45 -0
- package/scripts/install-wizard.js +71 -0
- package/scripts/install.js +170 -0
- package/scripts/platform.js +28 -0
- package/scripts/run.js +62 -0
- package/scripts/skills.js +32 -0
- package/skills/short-drama/SKILL.md +280 -0
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
package cmd
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"io"
|
|
6
|
+
"net/http"
|
|
7
|
+
"net/http/httptest"
|
|
8
|
+
"os"
|
|
9
|
+
"path/filepath"
|
|
10
|
+
"strings"
|
|
11
|
+
"testing"
|
|
12
|
+
|
|
13
|
+
"github.com/Pippit-dev/pippit-cli/internal/auth"
|
|
14
|
+
"github.com/Pippit-dev/pippit-cli/internal/common"
|
|
15
|
+
"github.com/Pippit-dev/pippit-cli/internal/config"
|
|
16
|
+
"github.com/bytedance/sonic"
|
|
17
|
+
"github.com/spf13/cobra"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
func TestShortDramaSubmitRun(t *testing.T) {
|
|
21
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
22
|
+
if r.Method != http.MethodPost {
|
|
23
|
+
t.Fatalf("method = %s, want POST", r.Method)
|
|
24
|
+
}
|
|
25
|
+
if r.URL.Path != "/api/biz/v1/skill/submit_run" {
|
|
26
|
+
t.Fatalf("path = %s, want submit_run path", r.URL.Path)
|
|
27
|
+
}
|
|
28
|
+
if r.Header.Get("Authorization") != "Bearer test-token" {
|
|
29
|
+
t.Fatalf("Authorization = %q, want test bearer token", r.Header.Get("Authorization"))
|
|
30
|
+
}
|
|
31
|
+
data, err := io.ReadAll(r.Body)
|
|
32
|
+
if err != nil {
|
|
33
|
+
t.Fatalf("read body: %v", err)
|
|
34
|
+
}
|
|
35
|
+
var body map[string]any
|
|
36
|
+
if err := sonic.Unmarshal(data, &body); err != nil {
|
|
37
|
+
t.Fatalf("decode body: %v", err)
|
|
38
|
+
}
|
|
39
|
+
if body["message"] != "write a cyberpunk opening" {
|
|
40
|
+
t.Fatalf("message = %v, want submitted message", body["message"])
|
|
41
|
+
}
|
|
42
|
+
if body["thread_id"] != "thread_123" {
|
|
43
|
+
t.Fatalf("thread_id = %v, want thread_123", body["thread_id"])
|
|
44
|
+
}
|
|
45
|
+
if body["agent_name"] != "pippit_nest_novel_agent" {
|
|
46
|
+
t.Fatalf("agent_name = %v, want pippit_nest_novel_agent", body["agent_name"])
|
|
47
|
+
}
|
|
48
|
+
assetIDs, ok := body["asset_ids"].([]any)
|
|
49
|
+
if !ok || len(assetIDs) != 2 || assetIDs[0] != "asset_1" || assetIDs[1] != "asset_2" {
|
|
50
|
+
t.Fatalf("asset_ids = %#v, want two asset ids", body["asset_ids"])
|
|
51
|
+
}
|
|
52
|
+
_, _ = w.Write([]byte(`{"ret":"0","errmsg":"","data":{"run":{"thread_id":"thread_123","run_id":"run_456"},"web_thread_link":"https://xyq.example/thread_123"}}`))
|
|
53
|
+
}))
|
|
54
|
+
defer server.Close()
|
|
55
|
+
|
|
56
|
+
var stdout, stderr bytes.Buffer
|
|
57
|
+
root := newTestRootCommand(t, &stdout, &stderr, server.URL)
|
|
58
|
+
root.SetArgs([]string{
|
|
59
|
+
"short-drama", "+submit-run",
|
|
60
|
+
"--message", "write a cyberpunk opening",
|
|
61
|
+
"--thread-id", "thread_123",
|
|
62
|
+
"--asset-ids", "asset_1",
|
|
63
|
+
"--asset-ids", "asset_2",
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
if err := root.Execute(); err != nil {
|
|
67
|
+
t.Fatalf("Execute() error = %v, stderr = %s", err, stderr.String())
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
got := decodeJSON(t, stdout.Bytes())
|
|
71
|
+
if got["thread_id"] != "thread_123" {
|
|
72
|
+
t.Fatalf("thread_id = %v, want thread_123", got["thread_id"])
|
|
73
|
+
}
|
|
74
|
+
if got["run_id"] != "run_456" {
|
|
75
|
+
t.Fatalf("run_id = %v, want run_456", got["run_id"])
|
|
76
|
+
}
|
|
77
|
+
if got["web_thread_link"] != "https://xyq.example/thread_123" {
|
|
78
|
+
t.Fatalf("web_thread_link = %v, want returned link", got["web_thread_link"])
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
func TestRootIncludesUpdateCommand(t *testing.T) {
|
|
83
|
+
var stdout, stderr bytes.Buffer
|
|
84
|
+
root := NewRootCommand(&stdout, &stderr)
|
|
85
|
+
cmd, _, err := root.Find([]string{"update"})
|
|
86
|
+
if err != nil {
|
|
87
|
+
t.Fatalf("Find(update) error = %v", err)
|
|
88
|
+
}
|
|
89
|
+
if cmd == nil || cmd.Name() != "update" {
|
|
90
|
+
t.Fatalf("Find(update) = %#v, want update command", cmd)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
func TestShortDramaSubmitRunRequiresMessage(t *testing.T) {
|
|
95
|
+
var stdout, stderr bytes.Buffer
|
|
96
|
+
root := NewRootCommand(&stdout, &stderr)
|
|
97
|
+
root.SetArgs([]string{"short-drama", "+submit-run"})
|
|
98
|
+
|
|
99
|
+
err := root.Execute()
|
|
100
|
+
if err == nil {
|
|
101
|
+
t.Fatal("Execute() error = nil, want validation error")
|
|
102
|
+
}
|
|
103
|
+
if !strings.Contains(err.Error(), "--message is required") {
|
|
104
|
+
t.Fatalf("error = %q, want message validation", err)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
func TestShortDramaSubmitRunRequiresAccessKey(t *testing.T) {
|
|
109
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
110
|
+
t.Fatal("server should not receive request without access key")
|
|
111
|
+
}))
|
|
112
|
+
defer server.Close()
|
|
113
|
+
|
|
114
|
+
var stdout, stderr bytes.Buffer
|
|
115
|
+
root := newTestRootCommandWithAccessKey(t, &stdout, &stderr, server.URL, "")
|
|
116
|
+
root.SetArgs([]string{
|
|
117
|
+
"short-drama", "+submit-run",
|
|
118
|
+
"--message", "write a cyberpunk opening",
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
err := root.Execute()
|
|
122
|
+
if err == nil {
|
|
123
|
+
t.Fatal("Execute() error = nil, want access key error")
|
|
124
|
+
}
|
|
125
|
+
if !strings.Contains(err.Error(), "XYQ_ACCESS_KEY is required") {
|
|
126
|
+
t.Fatalf("error = %q, want access key guidance", err)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
func TestShortDramaUploadFile(t *testing.T) {
|
|
131
|
+
var stdout, stderr bytes.Buffer
|
|
132
|
+
root := NewRootCommand(&stdout, &stderr)
|
|
133
|
+
root.SetArgs([]string{"short-drama", "+upload-file", "--path", "/tmp/story.md"})
|
|
134
|
+
|
|
135
|
+
if err := root.Execute(); err != nil {
|
|
136
|
+
t.Fatalf("Execute() error = %v, stderr = %s", err, stderr.String())
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
got := decodeJSON(t, stdout.Bytes())
|
|
140
|
+
if got["scene"] != "short-drama" {
|
|
141
|
+
t.Fatalf("scene = %v, want short-drama", got["scene"])
|
|
142
|
+
}
|
|
143
|
+
if got["status"] != "uploaded" {
|
|
144
|
+
t.Fatalf("status = %v, want uploaded", got["status"])
|
|
145
|
+
}
|
|
146
|
+
if !strings.HasPrefix(got["file_id"].(string), "file_mock_") {
|
|
147
|
+
t.Fatalf("file_id = %v, want mock file id", got["file_id"])
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
func TestShortDramaUploadFileRequiresPath(t *testing.T) {
|
|
152
|
+
var stdout, stderr bytes.Buffer
|
|
153
|
+
root := NewRootCommand(&stdout, &stderr)
|
|
154
|
+
root.SetArgs([]string{"short-drama", "+upload-file"})
|
|
155
|
+
|
|
156
|
+
err := root.Execute()
|
|
157
|
+
if err == nil {
|
|
158
|
+
t.Fatal("Execute() error = nil, want validation error")
|
|
159
|
+
}
|
|
160
|
+
if !strings.Contains(err.Error(), "--path is required") {
|
|
161
|
+
t.Fatalf("error = %q, want path validation", err)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
func TestShortDramaDownloadResult(t *testing.T) {
|
|
166
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
167
|
+
if r.Header.Get("User-Agent") != "Pippit-CLI/1.0" {
|
|
168
|
+
t.Fatalf("User-Agent = %q, want Pippit-CLI/1.0", r.Header.Get("User-Agent"))
|
|
169
|
+
}
|
|
170
|
+
switch r.URL.Path {
|
|
171
|
+
case "/image":
|
|
172
|
+
_, _ = w.Write([]byte("image-data"))
|
|
173
|
+
default:
|
|
174
|
+
t.Fatalf("unexpected path %s", r.URL.Path)
|
|
175
|
+
}
|
|
176
|
+
}))
|
|
177
|
+
defer server.Close()
|
|
178
|
+
|
|
179
|
+
chdirTemp(t)
|
|
180
|
+
var stdout, stderr bytes.Buffer
|
|
181
|
+
root := NewRootCommand(&stdout, &stderr)
|
|
182
|
+
outputPath := filepath.Join("results", "cover.jpeg")
|
|
183
|
+
root.SetArgs([]string{
|
|
184
|
+
"short-drama", "+download-result",
|
|
185
|
+
"--output-dir", outputPath,
|
|
186
|
+
"--workers", "2",
|
|
187
|
+
"--url", server.URL + "/image?filename=ignored.jpeg",
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
if err := root.Execute(); err != nil {
|
|
191
|
+
t.Fatalf("Execute() error = %v, stderr = %s", err, stderr.String())
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
got := decodeJSON(t, stdout.Bytes())
|
|
195
|
+
if got["output_dir"] != outputPath {
|
|
196
|
+
t.Fatalf("output_dir = %v, want %s", got["output_dir"], outputPath)
|
|
197
|
+
}
|
|
198
|
+
if got["total"] != float64(1) {
|
|
199
|
+
t.Fatalf("total = %v, want 1", got["total"])
|
|
200
|
+
}
|
|
201
|
+
downloaded, ok := got["downloaded"].([]any)
|
|
202
|
+
if !ok || len(downloaded) != 1 {
|
|
203
|
+
t.Fatalf("downloaded = %#v, want one file", got["downloaded"])
|
|
204
|
+
}
|
|
205
|
+
wantFiles := []string{
|
|
206
|
+
outputPath,
|
|
207
|
+
}
|
|
208
|
+
for i, want := range wantFiles {
|
|
209
|
+
if downloaded[i] != want {
|
|
210
|
+
t.Fatalf("downloaded[%d] = %v, want %s", i, downloaded[i], want)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
assertFileContent(t, wantFiles[0], "image-data")
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
func TestShortDramaDownloadResultSkipsExistingFile(t *testing.T) {
|
|
217
|
+
serverCalled := false
|
|
218
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
219
|
+
serverCalled = true
|
|
220
|
+
t.Fatal("server should not receive request when target file already exists")
|
|
221
|
+
}))
|
|
222
|
+
defer server.Close()
|
|
223
|
+
|
|
224
|
+
chdirTemp(t)
|
|
225
|
+
outputPath := filepath.Join("results", "cover.jpeg")
|
|
226
|
+
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
|
227
|
+
t.Fatalf("MkdirAll(): %v", err)
|
|
228
|
+
}
|
|
229
|
+
if err := os.WriteFile(outputPath, []byte("existing-data"), 0o644); err != nil {
|
|
230
|
+
t.Fatalf("WriteFile(): %v", err)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
var stdout, stderr bytes.Buffer
|
|
234
|
+
root := NewRootCommand(&stdout, &stderr)
|
|
235
|
+
root.SetArgs([]string{
|
|
236
|
+
"short-drama", "+download-result",
|
|
237
|
+
"--output-dir", outputPath,
|
|
238
|
+
"--url", server.URL + "/image",
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
if err := root.Execute(); err != nil {
|
|
242
|
+
t.Fatalf("Execute() error = %v, stderr = %s", err, stderr.String())
|
|
243
|
+
}
|
|
244
|
+
if serverCalled {
|
|
245
|
+
t.Fatal("server was called, want existing file to skip download")
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
got := decodeJSON(t, stdout.Bytes())
|
|
249
|
+
if got["output_dir"] != outputPath {
|
|
250
|
+
t.Fatalf("output_dir = %v, want %s", got["output_dir"], outputPath)
|
|
251
|
+
}
|
|
252
|
+
if got["total"] != float64(0) {
|
|
253
|
+
t.Fatalf("total = %v, want 0", got["total"])
|
|
254
|
+
}
|
|
255
|
+
alreadyExist, ok := got["already_exist"].([]any)
|
|
256
|
+
if !ok || len(alreadyExist) != 1 || alreadyExist[0] != outputPath {
|
|
257
|
+
t.Fatalf("already_exist = %#v, want existing output path", got["already_exist"])
|
|
258
|
+
}
|
|
259
|
+
assertFileContent(t, outputPath, "existing-data")
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
func TestShortDramaDownloadResultRequiresOutputDir(t *testing.T) {
|
|
263
|
+
var stdout, stderr bytes.Buffer
|
|
264
|
+
root := NewRootCommand(&stdout, &stderr)
|
|
265
|
+
root.SetArgs([]string{"short-drama", "+download-result", "--url", "https://example.com/image.png"})
|
|
266
|
+
|
|
267
|
+
err := root.Execute()
|
|
268
|
+
if err == nil {
|
|
269
|
+
t.Fatal("Execute() error = nil, want validation error")
|
|
270
|
+
}
|
|
271
|
+
if !strings.Contains(err.Error(), "--output-dir is required") {
|
|
272
|
+
t.Fatalf("error = %q, want output-dir validation", err)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
func TestShortDramaDownloadResultRequiresURL(t *testing.T) {
|
|
277
|
+
var stdout, stderr bytes.Buffer
|
|
278
|
+
root := NewRootCommand(&stdout, &stderr)
|
|
279
|
+
root.SetArgs([]string{"short-drama", "+download-result", "--output-dir", filepath.Join("results", "image.png")})
|
|
280
|
+
|
|
281
|
+
err := root.Execute()
|
|
282
|
+
if err == nil {
|
|
283
|
+
t.Fatal("Execute() error = nil, want validation error")
|
|
284
|
+
}
|
|
285
|
+
if !strings.Contains(err.Error(), "--url is required") {
|
|
286
|
+
t.Fatalf("error = %q, want url validation", err)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
func TestShortDramaDownloadResultRejectsInvalidScheme(t *testing.T) {
|
|
291
|
+
var stdout, stderr bytes.Buffer
|
|
292
|
+
root := NewRootCommand(&stdout, &stderr)
|
|
293
|
+
root.SetArgs([]string{"short-drama", "+download-result", "--output-dir", filepath.Join("results", "image.png"), "--url", "file:///etc/passwd"})
|
|
294
|
+
|
|
295
|
+
err := root.Execute()
|
|
296
|
+
if err == nil {
|
|
297
|
+
t.Fatal("Execute() error = nil, want scheme validation error")
|
|
298
|
+
}
|
|
299
|
+
if !strings.Contains(err.Error(), "only http and https are allowed") {
|
|
300
|
+
t.Fatalf("error = %q, want scheme validation", err)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
func TestShortDramaDownloadResultAllFailed(t *testing.T) {
|
|
305
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
306
|
+
w.WriteHeader(http.StatusNotFound)
|
|
307
|
+
}))
|
|
308
|
+
defer server.Close()
|
|
309
|
+
|
|
310
|
+
chdirTemp(t)
|
|
311
|
+
var stdout, stderr bytes.Buffer
|
|
312
|
+
root := NewRootCommand(&stdout, &stderr)
|
|
313
|
+
root.SetArgs([]string{
|
|
314
|
+
"short-drama", "+download-result",
|
|
315
|
+
"--output-dir", filepath.Join("results", "missing.png"),
|
|
316
|
+
"--url", server.URL + "/notfound",
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
err := root.Execute()
|
|
320
|
+
if err == nil {
|
|
321
|
+
t.Fatal("Execute() error = nil, want all-failed error")
|
|
322
|
+
}
|
|
323
|
+
if !strings.Contains(err.Error(), "all 1 download(s) failed") {
|
|
324
|
+
t.Fatalf("error = %q, want all-failed message", err)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
func TestShortDramaDownloadResultOutputPath(t *testing.T) {
|
|
329
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
330
|
+
_, _ = w.Write([]byte("image-data"))
|
|
331
|
+
}))
|
|
332
|
+
defer server.Close()
|
|
333
|
+
|
|
334
|
+
chdirTemp(t)
|
|
335
|
+
|
|
336
|
+
var stdout, stderr bytes.Buffer
|
|
337
|
+
root := NewRootCommand(&stdout, &stderr)
|
|
338
|
+
outputPath := filepath.Join("custom", "nested", "cover.png")
|
|
339
|
+
root.SetArgs([]string{
|
|
340
|
+
"short-drama", "+download-result",
|
|
341
|
+
"--output-dir", outputPath,
|
|
342
|
+
"--url", server.URL + "/image.png",
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
if err := root.Execute(); err != nil {
|
|
346
|
+
t.Fatalf("Execute() error = %v, stderr = %s", err, stderr.String())
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
got := decodeJSON(t, stdout.Bytes())
|
|
350
|
+
if got["output_dir"] != outputPath {
|
|
351
|
+
t.Fatalf("output_dir = %v, want %s", got["output_dir"], outputPath)
|
|
352
|
+
}
|
|
353
|
+
assertFileContent(t, outputPath, "image-data")
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
func TestShortDramaGetThread(t *testing.T) {
|
|
357
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
358
|
+
if r.Method != http.MethodPost {
|
|
359
|
+
t.Fatalf("method = %s, want POST", r.Method)
|
|
360
|
+
}
|
|
361
|
+
if r.URL.Path != "/api/biz/v1/skill/get_thread" {
|
|
362
|
+
t.Fatalf("path = %s, want get_thread path", r.URL.Path)
|
|
363
|
+
}
|
|
364
|
+
if r.Header.Get("Authorization") != "Bearer test-token" {
|
|
365
|
+
t.Fatalf("Authorization = %q, want test bearer token", r.Header.Get("Authorization"))
|
|
366
|
+
}
|
|
367
|
+
data, err := io.ReadAll(r.Body)
|
|
368
|
+
if err != nil {
|
|
369
|
+
t.Fatalf("read body: %v", err)
|
|
370
|
+
}
|
|
371
|
+
var body map[string]any
|
|
372
|
+
if err := sonic.Unmarshal(data, &body); err != nil {
|
|
373
|
+
t.Fatalf("decode body: %v", err)
|
|
374
|
+
}
|
|
375
|
+
if body["thread_id"] != "thread_123" {
|
|
376
|
+
t.Fatalf("thread_id = %v, want thread_123", body["thread_id"])
|
|
377
|
+
}
|
|
378
|
+
if body["run_id"] != "run_456" {
|
|
379
|
+
t.Fatalf("run_id = %v, want run_456", body["run_id"])
|
|
380
|
+
}
|
|
381
|
+
if body["after_seq"] != float64(7) {
|
|
382
|
+
t.Fatalf("after_seq = %v, want 7", body["after_seq"])
|
|
383
|
+
}
|
|
384
|
+
_, _ = w.Write([]byte(`{"ret":"0","errmsg":"","data":{"thread":{"run_list":[{"state":3,"entry_list":[{"message":{"message_id":"msg_1","role":"assistant","content":[{"text":"hello"}],"client_tool_calls":[{"name":"tool_call"}]}},{"artifact":{"artifact_id":"artifact_1","role":"assistant","content":[{"type":"image"}]}}]}]}}}`))
|
|
385
|
+
}))
|
|
386
|
+
defer server.Close()
|
|
387
|
+
|
|
388
|
+
var stdout, stderr bytes.Buffer
|
|
389
|
+
root := newTestRootCommand(t, &stdout, &stderr, server.URL)
|
|
390
|
+
root.SetArgs([]string{
|
|
391
|
+
"short-drama", "+get-thread",
|
|
392
|
+
"--thread-id", "thread_123",
|
|
393
|
+
"--run-id", "run_456",
|
|
394
|
+
"--after-seq", "7",
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
if err := root.Execute(); err != nil {
|
|
398
|
+
t.Fatalf("Execute() error = %v, stderr = %s", err, stderr.String())
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
got := decodeJSON(t, stdout.Bytes())
|
|
402
|
+
messages, ok := got["messages"].([]any)
|
|
403
|
+
if !ok || len(messages) != 2 {
|
|
404
|
+
t.Fatalf("messages = %#v, want two entries", got["messages"])
|
|
405
|
+
}
|
|
406
|
+
first, ok := messages[0].(map[string]any)
|
|
407
|
+
if !ok {
|
|
408
|
+
t.Fatalf("first message = %#v, want object", messages[0])
|
|
409
|
+
}
|
|
410
|
+
if first["id"] != "msg_1" {
|
|
411
|
+
t.Fatalf("first id = %v, want msg_1", first["id"])
|
|
412
|
+
}
|
|
413
|
+
content, ok := first["content"].([]any)
|
|
414
|
+
if !ok || len(content) != 2 {
|
|
415
|
+
t.Fatalf("first content = %#v, want message content plus tool call", first["content"])
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
func TestShortDramaGetThreadRequiresThreadID(t *testing.T) {
|
|
420
|
+
var stdout, stderr bytes.Buffer
|
|
421
|
+
root := NewRootCommand(&stdout, &stderr)
|
|
422
|
+
root.SetArgs([]string{"short-drama", "+get-thread"})
|
|
423
|
+
|
|
424
|
+
err := root.Execute()
|
|
425
|
+
if err == nil {
|
|
426
|
+
t.Fatal("Execute() error = nil, want validation error")
|
|
427
|
+
}
|
|
428
|
+
if !strings.Contains(err.Error(), "--thread-id is required") {
|
|
429
|
+
t.Fatalf("error = %q, want thread-id validation", err)
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
func TestShortDramaGetThreadRequiresAccessKey(t *testing.T) {
|
|
434
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
435
|
+
t.Fatal("server should not receive request without access key")
|
|
436
|
+
}))
|
|
437
|
+
defer server.Close()
|
|
438
|
+
|
|
439
|
+
var stdout, stderr bytes.Buffer
|
|
440
|
+
root := newTestRootCommandWithAccessKey(t, &stdout, &stderr, server.URL, "")
|
|
441
|
+
root.SetArgs([]string{"short-drama", "+get-thread", "--thread-id", "thread_123"})
|
|
442
|
+
|
|
443
|
+
err := root.Execute()
|
|
444
|
+
if err == nil {
|
|
445
|
+
t.Fatal("Execute() error = nil, want access key error")
|
|
446
|
+
}
|
|
447
|
+
if !strings.Contains(err.Error(), "XYQ_ACCESS_KEY is required") {
|
|
448
|
+
t.Fatalf("error = %q, want access key guidance", err)
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
func TestShortDramaListThreadFile(t *testing.T) {
|
|
453
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
454
|
+
if r.Method != http.MethodPost {
|
|
455
|
+
t.Fatalf("method = %s, want POST", r.Method)
|
|
456
|
+
}
|
|
457
|
+
if r.URL.Path != "/api/biz/v1/skill/list_thread_file" {
|
|
458
|
+
t.Fatalf("path = %s, want list_thread_file path", r.URL.Path)
|
|
459
|
+
}
|
|
460
|
+
if r.Header.Get("Authorization") != "Bearer test-token" {
|
|
461
|
+
t.Fatalf("Authorization = %q, want test bearer token", r.Header.Get("Authorization"))
|
|
462
|
+
}
|
|
463
|
+
data, err := io.ReadAll(r.Body)
|
|
464
|
+
if err != nil {
|
|
465
|
+
t.Fatalf("read body: %v", err)
|
|
466
|
+
}
|
|
467
|
+
var body map[string]any
|
|
468
|
+
if err := sonic.Unmarshal(data, &body); err != nil {
|
|
469
|
+
t.Fatalf("decode body: %v", err)
|
|
470
|
+
}
|
|
471
|
+
if body["thread_id"] != "thread_123" {
|
|
472
|
+
t.Fatalf("thread_id = %v, want thread_123", body["thread_id"])
|
|
473
|
+
}
|
|
474
|
+
if body["page_num"] != float64(2) {
|
|
475
|
+
t.Fatalf("page_num = %v, want 2", body["page_num"])
|
|
476
|
+
}
|
|
477
|
+
if body["page_size"] != float64(10) {
|
|
478
|
+
t.Fatalf("page_size = %v, want 10", body["page_size"])
|
|
479
|
+
}
|
|
480
|
+
_, _ = w.Write([]byte(`{"ret":"0","errmsg":"","data":{"total":1,"files":[{"file_name":"ignored.png","file_path":"results/images/cover.png","download_url":"https://example.com/cover.png"}]}}`))
|
|
481
|
+
}))
|
|
482
|
+
defer server.Close()
|
|
483
|
+
|
|
484
|
+
var stdout, stderr bytes.Buffer
|
|
485
|
+
root := newTestRootCommand(t, &stdout, &stderr, server.URL)
|
|
486
|
+
root.SetArgs([]string{
|
|
487
|
+
"short-drama", "+list-thread-file",
|
|
488
|
+
"--thread-id", "thread_123",
|
|
489
|
+
"--page-num", "2",
|
|
490
|
+
"--page-size", "10",
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
if err := root.Execute(); err != nil {
|
|
494
|
+
t.Fatalf("Execute() error = %v, stderr = %s", err, stderr.String())
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
got := decodeJSON(t, stdout.Bytes())
|
|
498
|
+
if got["total"] != float64(1) {
|
|
499
|
+
t.Fatalf("total = %v, want 1", got["total"])
|
|
500
|
+
}
|
|
501
|
+
files, ok := got["files"].([]any)
|
|
502
|
+
if !ok || len(files) != 1 {
|
|
503
|
+
t.Fatalf("files = %#v, want one file", got["files"])
|
|
504
|
+
}
|
|
505
|
+
file, ok := files[0].(map[string]any)
|
|
506
|
+
if !ok {
|
|
507
|
+
t.Fatalf("file = %#v, want object", files[0])
|
|
508
|
+
}
|
|
509
|
+
if _, ok := file["file_name"]; ok {
|
|
510
|
+
t.Fatalf("file_name should not be returned: %#v", file)
|
|
511
|
+
}
|
|
512
|
+
wantPath := "." + string(os.PathSeparator) + filepath.Join("thread_123", "results/images/cover.png")
|
|
513
|
+
if file["file_path"] != wantPath {
|
|
514
|
+
t.Fatalf("file_path = %v, want %s", file["file_path"], wantPath)
|
|
515
|
+
}
|
|
516
|
+
if file["download_url"] != "https://example.com/cover.png" {
|
|
517
|
+
t.Fatalf("download_url = %v, want returned url", file["download_url"])
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
func TestShortDramaListThreadFileRequiresThreadID(t *testing.T) {
|
|
522
|
+
var stdout, stderr bytes.Buffer
|
|
523
|
+
root := NewRootCommand(&stdout, &stderr)
|
|
524
|
+
root.SetArgs([]string{"short-drama", "+list-thread-file"})
|
|
525
|
+
|
|
526
|
+
err := root.Execute()
|
|
527
|
+
if err == nil {
|
|
528
|
+
t.Fatal("Execute() error = nil, want validation error")
|
|
529
|
+
}
|
|
530
|
+
if !strings.Contains(err.Error(), "--thread-id is required") {
|
|
531
|
+
t.Fatalf("error = %q, want thread-id validation", err)
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
func newTestRootCommand(t *testing.T, stdout, stderr io.Writer, baseURL string) *cobra.Command {
|
|
536
|
+
t.Helper()
|
|
537
|
+
return newTestRootCommandWithAccessKey(t, stdout, stderr, baseURL, "test-token")
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
func newTestRootCommandWithAccessKey(t *testing.T, stdout, stderr io.Writer, baseURL string, accessKey string) *cobra.Command {
|
|
541
|
+
t.Helper()
|
|
542
|
+
cfg := config.Load()
|
|
543
|
+
cfg.BaseURL = baseURL
|
|
544
|
+
cfg.AccessKey = accessKey
|
|
545
|
+
authAuthorizer := auth.NewManager(cfg)
|
|
546
|
+
client := common.NewHTTPClient(cfg.BaseURL, cfg.HTTPTimeout, common.NewAccessKeyAuthorizer(cfg.AccessKey))
|
|
547
|
+
runner := common.NewRunner(cfg, client, authAuthorizer)
|
|
548
|
+
return newRootCommand(stdout, stderr, runner)
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
func decodeJSON(t *testing.T, data []byte) map[string]any {
|
|
552
|
+
t.Helper()
|
|
553
|
+
var got map[string]any
|
|
554
|
+
if err := sonic.Unmarshal(data, &got); err != nil {
|
|
555
|
+
t.Fatalf("stdout is not JSON: %v\n%s", err, string(data))
|
|
556
|
+
}
|
|
557
|
+
return got
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
func chdirTemp(t *testing.T) string {
|
|
561
|
+
t.Helper()
|
|
562
|
+
cwd := t.TempDir()
|
|
563
|
+
oldWD, err := os.Getwd()
|
|
564
|
+
if err != nil {
|
|
565
|
+
t.Fatalf("Getwd(): %v", err)
|
|
566
|
+
}
|
|
567
|
+
if err := os.Chdir(cwd); err != nil {
|
|
568
|
+
t.Fatalf("Chdir(%s): %v", cwd, err)
|
|
569
|
+
}
|
|
570
|
+
t.Cleanup(func() {
|
|
571
|
+
if err := os.Chdir(oldWD); err != nil {
|
|
572
|
+
t.Fatalf("restore working dir: %v", err)
|
|
573
|
+
}
|
|
574
|
+
})
|
|
575
|
+
return cwd
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
func assertFileContent(t *testing.T, path string, want string) {
|
|
579
|
+
t.Helper()
|
|
580
|
+
data, err := os.ReadFile(path)
|
|
581
|
+
if err != nil {
|
|
582
|
+
t.Fatalf("ReadFile(%s): %v", path, err)
|
|
583
|
+
}
|
|
584
|
+
if string(data) != want {
|
|
585
|
+
t.Fatalf("ReadFile(%s) = %q, want %q", path, string(data), want)
|
|
586
|
+
}
|
|
587
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
package updatecmd
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"io"
|
|
6
|
+
"os"
|
|
7
|
+
"os/exec"
|
|
8
|
+
"path/filepath"
|
|
9
|
+
"runtime"
|
|
10
|
+
"strings"
|
|
11
|
+
|
|
12
|
+
"github.com/spf13/cobra"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
const defaultPackage = "@pippit-dev/cli"
|
|
16
|
+
|
|
17
|
+
// NewCommand builds the update command.
|
|
18
|
+
func NewCommand(stdout, stderr io.Writer) *cobra.Command {
|
|
19
|
+
cmd := &cobra.Command{
|
|
20
|
+
Use: "update",
|
|
21
|
+
Short: "Update pippit-cli and bundled skills",
|
|
22
|
+
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
23
|
+
return runUpdate(stdout, stderr)
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
return cmd
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
func runUpdate(stdout, stderr io.Writer) error {
|
|
30
|
+
pkg := os.Getenv("PIPPIT_CLI_INSTALL_PACKAGE")
|
|
31
|
+
if pkg == "" {
|
|
32
|
+
pkg = defaultPackage + "@latest"
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
fmt.Fprintf(stderr, "Updating pippit-cli via npm: %s\n", pkg)
|
|
36
|
+
restore, err := prepareSelfReplace()
|
|
37
|
+
if err != nil {
|
|
38
|
+
return fmt.Errorf("prepare self replace: %w", err)
|
|
39
|
+
}
|
|
40
|
+
if err := runInheritEnv(stderr, []string{"PIPPIT_CLI_SKIP_SKILLS=1"}, "npm", "install", "-g", pkg); err != nil {
|
|
41
|
+
restore()
|
|
42
|
+
return fmt.Errorf("update pippit-cli: %w", err)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
root, err := globalPackageRoot(defaultPackage)
|
|
46
|
+
if err != nil {
|
|
47
|
+
return fmt.Errorf("locate global package: %w", err)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
fmt.Fprintln(stderr, "Updating pippit-cli skills...")
|
|
51
|
+
if err := installSkills(root, stderr); err != nil {
|
|
52
|
+
return fmt.Errorf("update pippit-cli skills: %w", err)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
fmt.Fprintln(stdout, "pippit-cli and skills updated")
|
|
56
|
+
return nil
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
func globalPackageRoot(pkg string) (string, error) {
|
|
60
|
+
out, err := command("npm", "root", "-g").Output()
|
|
61
|
+
if err != nil {
|
|
62
|
+
return "", err
|
|
63
|
+
}
|
|
64
|
+
npmRoot := strings.TrimSpace(string(out))
|
|
65
|
+
if npmRoot == "" {
|
|
66
|
+
return "", fmt.Errorf("npm root -g returned empty output")
|
|
67
|
+
}
|
|
68
|
+
return filepath.Join(append([]string{npmRoot}, strings.Split(pkg, "/")...)...), nil
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
func installSkills(root string, stderr io.Writer) error {
|
|
72
|
+
if info, err := os.Stat(filepath.Join(root, "skills")); err != nil {
|
|
73
|
+
return err
|
|
74
|
+
} else if !info.IsDir() {
|
|
75
|
+
return fmt.Errorf("%s is not a directory", filepath.Join(root, "skills"))
|
|
76
|
+
}
|
|
77
|
+
return runInherit(stderr, "npx", "-y", "skills", "add", root, "-g", "-y")
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
func runInherit(stderr io.Writer, name string, args ...string) error {
|
|
81
|
+
return runInheritEnv(stderr, nil, name, args...)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
func runInheritEnv(stderr io.Writer, env []string, name string, args ...string) error {
|
|
85
|
+
cmd := command(name, args...)
|
|
86
|
+
if len(env) > 0 {
|
|
87
|
+
cmd.Env = append(os.Environ(), env...)
|
|
88
|
+
}
|
|
89
|
+
cmd.Stdout = stderr
|
|
90
|
+
cmd.Stderr = stderr
|
|
91
|
+
return cmd.Run()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
func command(name string, args ...string) *exec.Cmd {
|
|
95
|
+
if runtime.GOOS == "windows" {
|
|
96
|
+
cmdArgs := append([]string{"/c", name}, args...)
|
|
97
|
+
return exec.Command("cmd.exe", cmdArgs...)
|
|
98
|
+
}
|
|
99
|
+
return exec.Command(name, args...)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
func prepareSelfReplace() (func(), error) {
|
|
103
|
+
if runtime.GOOS != "windows" {
|
|
104
|
+
return func() {}, nil
|
|
105
|
+
}
|
|
106
|
+
exe, err := os.Executable()
|
|
107
|
+
if err != nil {
|
|
108
|
+
return nil, err
|
|
109
|
+
}
|
|
110
|
+
if filepath.Base(exe) != "pippit-cli.exe" || !strings.Contains(exe, "node_modules") {
|
|
111
|
+
return func() {}, nil
|
|
112
|
+
}
|
|
113
|
+
old := exe + ".old"
|
|
114
|
+
_ = os.Remove(old)
|
|
115
|
+
if err := os.Rename(exe, old); err != nil {
|
|
116
|
+
return nil, err
|
|
117
|
+
}
|
|
118
|
+
return func() {
|
|
119
|
+
if _, err := os.Stat(exe); err == nil {
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
_ = os.Rename(old, exe)
|
|
123
|
+
}, nil
|
|
124
|
+
}
|