magic-agent 1.0.49 → 1.0.109

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 (94) hide show
  1. package/CLIENT-CONTRACT.md +514 -7
  2. package/README.md +31 -2
  3. package/client-ui/pc/app.css +3 -41
  4. package/client-ui/pc/js/04-task-list.js +6 -2
  5. package/client-ui/pc/js/90-glue.js +23 -4
  6. package/client-ui/pc/js/92-composer.js +11 -4
  7. package/client-ui/pc/js/93-rail.js +178 -19
  8. package/client-ui/shared/conv/03-chat-message.js +145 -25
  9. package/client-ui/shared/conv/09-stream-apply.js +55 -6
  10. package/client-ui/shared/conv/19-attachments.js +70 -6
  11. package/client-ui/shared/conv/27-conv-ledger.js +18 -0
  12. package/client-ui/shared/conv/29-msg-variants.js +10 -0
  13. package/client-ui/shared/conv/README.md +6 -0
  14. package/client-ui/shared/conv/conv.css +77 -0
  15. package/client-ui/shared/conversation.css +229 -43
  16. package/client-ui/shared/conversation.js +1316 -131
  17. package/client-ui/shared/transport-http.js +164 -12
  18. package/client-ui-mobile/index.html +2443 -178
  19. package/internal/agent/capability.go +16 -2
  20. package/internal/agent/claude.go +3 -2
  21. package/internal/agent/codebuddy.go +7 -1
  22. package/internal/agent/codex_test.go +3 -0
  23. package/internal/agent/custom_models_test.go +5 -0
  24. package/internal/agent/dsh.go +18 -2
  25. package/internal/agent/dsh_test.go +53 -2
  26. package/internal/agent/engine_test.go +34 -19
  27. package/internal/agent/env_test.go +14 -9
  28. package/internal/agent/models_test.go +9 -0
  29. package/internal/agent/runcmd_test.go +24 -3
  30. package/internal/agent/stream.go +33 -0
  31. package/internal/cli/ask.go +94 -4
  32. package/internal/cli/contract_test.go +26 -0
  33. package/internal/cli/events.go +18 -7
  34. package/internal/cli/keepalive_test.go +69 -0
  35. package/internal/cli/requirements.go +119 -0
  36. package/internal/cli/requirements_test.go +36 -0
  37. package/internal/cli/root.go +56 -4
  38. package/internal/cli/session.go +64 -25
  39. package/internal/cli/sessionlog.go +76 -7
  40. package/internal/cli/sessionlog_test.go +58 -0
  41. package/internal/cli/team.go +374 -0
  42. package/internal/cli/team_session.go +156 -0
  43. package/internal/cli/team_test.go +360 -0
  44. package/internal/client/plugins.go +7 -4
  45. package/internal/client/requirements.go +309 -0
  46. package/internal/client/requirements_test.go +239 -0
  47. package/internal/client/service.go +545 -19
  48. package/internal/client/service_test.go +47 -2
  49. package/internal/client/session_events_test.go +320 -0
  50. package/internal/client/team_flags.go +204 -0
  51. package/internal/client/team_flags_test.go +254 -0
  52. package/internal/requirements/bootstrap.go +423 -0
  53. package/internal/requirements/consolidate.go +908 -0
  54. package/internal/requirements/doc.go +473 -0
  55. package/internal/requirements/memory.go +324 -0
  56. package/internal/requirements/requirements.go +169 -0
  57. package/internal/requirements/requirements_test.go +530 -0
  58. package/internal/requirements/store.go +289 -0
  59. package/internal/session/delete.go +8 -4
  60. package/internal/session/session.go +194 -17
  61. package/internal/session/session_append.go +4 -1
  62. package/internal/session/session_append_test.go +19 -0
  63. package/internal/session/session_mark_test.go +42 -3
  64. package/internal/session/session_test.go +43 -0
  65. package/internal/session/sessionlog.go +50 -34
  66. package/internal/session/sessionlog_read_test.go +69 -11
  67. package/internal/session/status.go +205 -0
  68. package/internal/session/status_test.go +144 -0
  69. package/internal/team/adaptive.go +293 -0
  70. package/internal/team/board.go +216 -0
  71. package/internal/team/event.go +28 -0
  72. package/internal/team/execute.go +648 -0
  73. package/internal/team/plan.go +903 -0
  74. package/internal/team/planner.go +294 -0
  75. package/internal/team/reduce.go +192 -0
  76. package/internal/team/role.go +334 -0
  77. package/internal/team/route.go +206 -0
  78. package/internal/team/schedule.go +241 -0
  79. package/internal/team/schedule_test.go +286 -0
  80. package/internal/team/subsession.go +27 -0
  81. package/internal/team/team.go +401 -0
  82. package/internal/team/team_test.go +1997 -0
  83. package/npm/bin/magic-client.js +0 -0
  84. package/npm/dist/darwin-arm64/magic-agent +0 -0
  85. package/npm/dist/darwin-arm64/magic-client +0 -0
  86. package/npm/dist/darwin-x64/magic-agent +0 -0
  87. package/npm/dist/darwin-x64/magic-client +0 -0
  88. package/npm/dist/linux-arm64/magic-agent +0 -0
  89. package/npm/dist/linux-arm64/magic-client +0 -0
  90. package/npm/dist/linux-x64/magic-agent +0 -0
  91. package/npm/dist/linux-x64/magic-client +0 -0
  92. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  93. package/npm/dist/win32-x64/magic-client.exe +0 -0
  94. package/package.json +1 -1
@@ -0,0 +1,360 @@
1
+ package cli
2
+
3
+ // team_test.go - `--team` 的 CLI 层接线测试。
4
+ //
5
+ // 边界:这里**只验接线**(分发、参数校验、事件形状、收尾 envelope、会话登记)。
6
+ // 团队模式自己的逻辑(计划解析 / 分层 / 路由 / 降级)在 internal/team 里有单测,
7
+ // 这里不重复覆盖 —— 用一个会回固定计划的假引擎把流程走通即可。
8
+
9
+ import (
10
+ "context"
11
+ "encoding/json"
12
+ "fmt"
13
+ "os"
14
+ "path/filepath"
15
+ "strings"
16
+ "sync"
17
+ "sync/atomic"
18
+ "testing"
19
+
20
+ "github.com/darren/magic-agent/internal/agent"
21
+ "github.com/darren/magic-agent/internal/session"
22
+ )
23
+
24
+ /* uniqueTeamEngine 给每个用例一个**全局唯一**的假引擎名。
25
+ *
26
+ * 为什么必须唯一:`agent.Register` 只 append,`agent.Lookup` 按名返回**第一个**命中
27
+ * (见 engine.go 的 registry 注释 —— 内置引擎的惰性注册挂在「registry 为空」上,
28
+ * 改成覆盖/无条件注册会打断另一批用例)。`-count=2` 时同一个用例跑两遍,
29
+ * 第二遍 `Lookup` 拿到的是第一遍那个实例,而 `teamPlanEngine.calls` 早已 >1 →
30
+ * 它把「正文」当成计划返回 → 计划解析失败降级成单任务,断言全线崩
31
+ * (2026-10-21 实测:`result.tasks = 1, want 2`、`TASK-t2.md` 不存在)。
32
+ * 这在 `-count=1` 下看不出来,属于**测试隔离**问题而非产品行为。 */
33
+ var teamEngineSeq int64
34
+
35
+ func uniqueTeamEngine(base string) string {
36
+ return fmt.Sprintf("%s-%d", base, atomic.AddInt64(&teamEngineSeq, 1))
37
+ }
38
+
39
+ // teamPlanEngine 假引擎:第一次调用(计划阶段)回一段计划 JSON,之后回正文。
40
+ type teamPlanEngine struct {
41
+ name string
42
+ plan string
43
+ text string
44
+
45
+ mu sync.Mutex
46
+ calls int
47
+ }
48
+
49
+ func (e *teamPlanEngine) Name() string { return e.name }
50
+ func (e *teamPlanEngine) Detect() (bool, string) { return true, "fake://" + e.name }
51
+ func (e *teamPlanEngine) Complete(ctx context.Context, req agent.Request) (agent.Response, error) {
52
+ t, plan := e.next()
53
+ if !plan {
54
+ writeDesignDoc(req.Workspace)
55
+ }
56
+ return agent.Response{Engine: e.name, Text: t}, nil
57
+ }
58
+ func (e *teamPlanEngine) Stream(ctx context.Context, req agent.Request, onEvent func(agent.StreamEvent)) (agent.StreamResult, error) {
59
+ t, plan := e.next()
60
+ if !plan {
61
+ writeDesignDoc(req.Workspace)
62
+ }
63
+ onEvent(agent.StreamEvent{Kind: agent.KindText, Text: t})
64
+ return agent.StreamResult{Response: agent.Response{Engine: e.name, Text: t}}, nil
65
+ }
66
+ func (e *teamPlanEngine) next() (string, bool) {
67
+ e.mu.Lock()
68
+ defer e.mu.Unlock()
69
+ e.calls++
70
+ if e.calls == 1 {
71
+ return e.plan, true
72
+ }
73
+ return e.text, false
74
+ }
75
+
76
+ // writeDesignDoc 按计划里 t1 的 deliverables 落一个真文件。
77
+ //
78
+ // 团队模式会在收尾时核对交付物(缺了会重试一次,仍缺则 t1 失败、t2 被级联跳过),
79
+ // 假引擎不写文件就只剩预开卡、看不到「开跑」那一条。
80
+ func writeDesignDoc(dir string) {
81
+ if dir == "" {
82
+ return
83
+ }
84
+ if err := os.MkdirAll(dir, 0o755); err != nil {
85
+ return
86
+ }
87
+ _ = os.WriteFile(filepath.Join(dir, "design.md"), []byte("design"), 0o644)
88
+ }
89
+
90
+ const twoTaskPlan = `{"goal":"做一个小游戏","tasks":[
91
+ {"id":"t1","kind":"text","title":"设计文档","prompt":"写一份设计文档","deliverables":["design.md"]},
92
+ {"id":"t2","kind":"text","title":"实现","prompt":"按文档实现","deps":["t1"]}
93
+ ]}`
94
+
95
+ // TestTeamAskEndToEnd 跑一整轮 `--team`:必须有 ready → 委派卡 → 收尾 result(mode=team),
96
+ // 并且运行目录里落下了 PLAN.md / TASK-*.md。
97
+ func TestTeamAskEndToEnd(t *testing.T) {
98
+ name := uniqueTeamEngine("fake-team")
99
+ eng := &teamPlanEngine{name: name, plan: twoTaskPlan, text: "做完了"}
100
+ registerFake(eng)
101
+
102
+ runDir := t.TempDir()
103
+ stdout, stderr, err := runAskCmd(t, "",
104
+ "-e", name, "--stream", "--events", "--team",
105
+ "--team-run-dir", runDir,
106
+ "--save-registry",
107
+ "做一个小游戏")
108
+ if err != nil {
109
+ t.Fatalf("--team 失败: %v\nstderr=%s", err, stderr)
110
+ }
111
+
112
+ lines := nonEmptyLines(stdout)
113
+ if len(lines) < 3 {
114
+ t.Fatalf("事件太少(%d 行):\n%s", len(lines), stdout)
115
+ }
116
+ // 首行必须是 ready(--events 约定)。
117
+ if typ := eventType(t, lines[0]); typ != "ready" {
118
+ t.Errorf("首行 type = %q, want ready", typ)
119
+ }
120
+ // 收尾必须是 result,且带 mode/plan_path/run_dir/tasks。
121
+ last := decodeEventLine(t, lines[len(lines)-1])
122
+ if last["type"] != "result" {
123
+ t.Fatalf("末行 type = %v, want result(每轮以 result 或 error 收尾)", last["type"])
124
+ }
125
+ if last["mode"] != "team" {
126
+ t.Errorf("result.mode = %v, want team", last["mode"])
127
+ }
128
+ if s, _ := last["plan_path"].(string); !strings.HasSuffix(s, "PLAN.md") {
129
+ t.Errorf("result.plan_path = %v", last["plan_path"])
130
+ }
131
+ tasks, _ := last["tasks"].([]any)
132
+ if len(tasks) != 2 {
133
+ t.Errorf("result.tasks 数量 = %d, want 2", len(tasks))
134
+ }
135
+
136
+ // 主会话只有**一张任务列表卡**:计划落定先发一份(两行都在),每个任务开跑 /
137
+ // 收尾再整份重发;子任务的过程事件写各自的会话日志,不转发到主会话。
138
+ var snaps [][]map[string]any
139
+ var parented, taskCards int
140
+ for _, ln := range lines {
141
+ m := decodeEventLine(t, ln)
142
+ switch m["type"] {
143
+ case "tasks":
144
+ text, _ := m["text"].(string)
145
+ var snap struct {
146
+ Tasks []map[string]any `json:"tasks"`
147
+ }
148
+ if err := json.Unmarshal([]byte(text), &snap); err != nil {
149
+ t.Fatalf("tasks 快照不是合法 JSON:%v\n%s", err, text)
150
+ }
151
+ snaps = append(snaps, snap.Tasks)
152
+ case "tool_use":
153
+ if m["name"] == "Task" {
154
+ taskCards++
155
+ }
156
+ }
157
+ if p, _ := m["parent"].(string); p != "" {
158
+ parented++
159
+ }
160
+ }
161
+ if len(snaps) == 0 {
162
+ t.Fatal("没有 tasks 快照:主会话里只会显示任务列表卡")
163
+ }
164
+ if len(snaps[0]) != 2 {
165
+ t.Errorf("首份快照应当有两行(计划确定即全部登记),实际 %d 行", len(snaps[0]))
166
+ }
167
+ lastSnap := snaps[len(snaps)-1]
168
+ if len(lastSnap) != 2 {
169
+ t.Errorf("末份快照应当仍是两行全量,实际 %d 行", len(lastSnap))
170
+ }
171
+ for _, row := range lastSnap {
172
+ if row["status"] != "done" {
173
+ t.Errorf("末份快照里 %v 应当 done,实际 %v", row["id"], row["status"])
174
+ }
175
+ // 子任务 = 普通会话:快照里带它的登记 run_id,列表据此挂到主会话下面。
176
+ if id, _ := row["run_id"].(string); id == "" {
177
+ t.Errorf("%v 缺少 run_id:子会话没登记", row["id"])
178
+ }
179
+ }
180
+ if taskCards != 0 {
181
+ t.Errorf("不该再有委派卡(tool_use Task = %d)", taskCards)
182
+ }
183
+ if parented != 0 {
184
+ t.Errorf("子任务事件不该转发到主会话(parent = %d 条)", parented)
185
+ }
186
+
187
+ // 运行目录的文档。⚠️ `--team-run-dir` 只是**父目录**:真正的运行目录是
188
+ // `<父>/<run_id>`(同一台机器跑多轮团队会话时互不覆盖)—— 所以这里要下钻一层。
189
+ runs, _ := os.ReadDir(runDir)
190
+ if len(runs) != 1 {
191
+ t.Fatalf("父目录下应当恰好有一个运行目录,实际 %d 个", len(runs))
192
+ }
193
+ actual := filepath.Join(runDir, runs[0].Name())
194
+ for _, name := range []string{"PLAN.md", "TASK-t1.md", "TASK-t2.md", "OUT-t1.md", "OUT-t2.md"} {
195
+ if _, err := os.Stat(filepath.Join(actual, name)); err != nil {
196
+ t.Errorf("运行目录缺少 %s:%v", name, err)
197
+ }
198
+ }
199
+ // PLAN.md 是只读的(写权是机制保证的)。
200
+ if st, err := os.Stat(filepath.Join(actual, "PLAN.md")); err == nil {
201
+ if st.Mode().Perm()&0o200 != 0 {
202
+ t.Errorf("PLAN.md 应当只读,实际权限 %v", st.Mode().Perm())
203
+ }
204
+ }
205
+ }
206
+
207
+ // TestTeamAskRecordsMode 团队会话在登记表里带 mode=team 与 agents。
208
+ func TestTeamAskRecordsMode(t *testing.T) {
209
+ name := uniqueTeamEngine("fake-team-reg")
210
+ eng := &teamPlanEngine{name: name, plan: twoTaskPlan, text: "ok"}
211
+ registerFake(eng)
212
+
213
+ if _, _, err := runAskCmd(t, "",
214
+ "-e", name, "--stream", "--events", "--team",
215
+ "--team-run-dir", t.TempDir(), "--save-registry",
216
+ "做一个小游戏"); err != nil {
217
+ t.Fatalf("--team 失败: %v", err)
218
+ }
219
+
220
+ recs, err := session.List()
221
+ if err != nil {
222
+ t.Fatalf("读登记表: %v", err)
223
+ }
224
+ /* 子任务是**普通会话**,和主会话同引擎,所以不能只按 Engine 认主记录 ——
225
+ 得认 mode=team 且没有父指针的那条。 */
226
+ var found *session.Record
227
+ var children []session.Record
228
+ for i := range recs {
229
+ if recs[i].Engine != name {
230
+ continue
231
+ }
232
+ if recs[i].Mode == session.ModeTeam && recs[i].ParentRunID == "" {
233
+ found = &recs[i]
234
+ }
235
+ if recs[i].ParentRunID != "" {
236
+ children = append(children, recs[i])
237
+ }
238
+ }
239
+ if found == nil {
240
+ t.Fatal("登记表里没有这条团队会话")
241
+ }
242
+ if found.Mode != session.ModeTeam {
243
+ t.Errorf("record.mode = %q, want team", found.Mode)
244
+ }
245
+ if len(found.Agents) == 0 {
246
+ t.Error("record.agents 为空:列表页看不出「谁在干活」")
247
+ }
248
+ if found.State != session.StateDone {
249
+ t.Errorf("record.state = %q, want done", found.State)
250
+ }
251
+ // 每个子任务都是一条**普通会话**(独立 run/session、可点进去看过程),
252
+ // 靠父指针挂在主会话下面 —— 不是非交互的一次性运行。
253
+ if len(children) != 2 {
254
+ t.Fatalf("应当为 2 个子任务各登记一条子会话,得到 %d 条:%+v", len(children), children)
255
+ }
256
+ for _, c := range children {
257
+ if c.ParentRunID != found.RunID {
258
+ t.Errorf("子会话 %s 的 parent_run_id = %q,want %q", c.RunID, c.ParentRunID, found.RunID)
259
+ }
260
+ if c.State != session.StateDone {
261
+ t.Errorf("子会话 %s 的状态 = %q,want done", c.RunID, c.State)
262
+ }
263
+ }
264
+
265
+ /* 还要从**列表接口**再看一眼:登记表里有了不代表用户看得到 ——
266
+ `--sessions` 是把 Record 投影成另一套 row 结构(internal/cli/session.go 的
267
+ 匿名 struct,字段漏写就是静默消失)。第一版就漏了 mode / agents:
268
+ 会话是对的,列表上却是一条普通会话。这里钉住投影本身。 */
269
+ stdout, _, err := runAskCmd(t, "", "--sessions")
270
+ if err != nil {
271
+ t.Fatalf("--sessions: %v", err)
272
+ }
273
+ var rows []map[string]any
274
+ if err := json.Unmarshal([]byte(stdout), &rows); err != nil {
275
+ t.Fatalf("--sessions 输出不是 JSON:%v", err)
276
+ }
277
+ var row map[string]any
278
+ for _, r := range rows {
279
+ if r["run_id"] == found.RunID {
280
+ row = r
281
+ break
282
+ }
283
+ }
284
+ if row == nil {
285
+ t.Fatalf("--sessions 里没有 run_id=%s", found.RunID)
286
+ }
287
+ if row["mode"] != session.ModeTeam {
288
+ t.Errorf("--sessions 的 mode = %v,want %q(列表页靠它区分团队会话)", row["mode"], session.ModeTeam)
289
+ }
290
+ if ags, _ := row["agents"].([]any); len(ags) == 0 {
291
+ t.Errorf("--sessions 的 agents 为空/缺失:%v", row["agents"])
292
+ }
293
+ // 子会话行必须带父指针,否则列表页没法把它缩进挂到主会话下面。
294
+ for _, c := range children {
295
+ var kid map[string]any
296
+ for _, r := range rows {
297
+ if r["run_id"] == c.RunID {
298
+ kid = r
299
+ break
300
+ }
301
+ }
302
+ if kid == nil {
303
+ t.Errorf("--sessions 里没有子会话 run_id=%s", c.RunID)
304
+ continue
305
+ }
306
+ if kid["parent_run_id"] != found.RunID {
307
+ t.Errorf("子会话 %s 的 parent_run_id = %v,want %q", c.RunID, kid["parent_run_id"], found.RunID)
308
+ }
309
+ }
310
+ }
311
+
312
+ // TestTeamRequiresStream `--team` 不配 --stream 必须明确拒绝(exit 2),
313
+ // 而不是悄悄忽略开关 —— 后者会让用户以为开了团队模式却没开。
314
+ func TestTeamRequiresStream(t *testing.T) {
315
+ registerFake(&stringEngine{name: "fake-nostream", text: "x"})
316
+ _, stderr, err := runAskCmd(t, "", "-e", "fake-nostream", "--team", "hi")
317
+ if err == nil {
318
+ t.Fatal("--team 没有 --stream 时应当报错")
319
+ }
320
+ if !strings.Contains(err.Error(), "--stream") && !strings.Contains(stderr, "--stream") {
321
+ t.Errorf("报错应当提到 --stream:err=%v stderr=%q", err, stderr)
322
+ }
323
+ }
324
+
325
+ // TestTeamRouteParsingInvalid 非法 --team-route 要在开跑前就报(exit 2)。
326
+ func TestTeamRouteParsingInvalid(t *testing.T) {
327
+ // 必须用**流式**引擎:团队路径先校验引擎有没有 Stream,再看路由 ——
328
+ // 拿 stringEngine 会先撞「不支持流式」,测的就不是路由校验了。
329
+ name := uniqueTeamEngine("fake-badroute")
330
+ registerFake(&teamPlanEngine{name: name, plan: twoTaskPlan, text: "x"})
331
+ _, _, err := runAskCmd(t, "",
332
+ "-e", name, "--stream", "--team", "--team-route", "audio=llm", "hi")
333
+ if err == nil {
334
+ t.Fatal("未知类别应当报错")
335
+ }
336
+ if !strings.Contains(err.Error(), "类别") {
337
+ t.Errorf("err = %v,应提到类别", err)
338
+ }
339
+ }
340
+
341
+ // nonEmptyLines 去掉空行。
342
+ func nonEmptyLines(s string) []string {
343
+ out := make([]string, 0, 64)
344
+ for _, ln := range strings.Split(s, "\n") {
345
+ if strings.TrimSpace(ln) != "" {
346
+ out = append(out, ln)
347
+ }
348
+ }
349
+ return out
350
+ }
351
+
352
+ // eventType 取一行的 type。
353
+ func eventType(t *testing.T, line string) string {
354
+ t.Helper()
355
+ typ, _ := decodeEventLine(t, line)["type"].(string)
356
+ return typ
357
+ }
358
+
359
+ // 保持 json import 有用(decodeEventLine 在 events_test.go,这里只做断言辅助)。
360
+ var _ = json.Marshal
@@ -70,9 +70,11 @@ func pluginSpecs() []pluginSpec {
70
70
  ID: "magic-shorts",
71
71
  Name: "magic-shorts",
72
72
  CLI: "magic-shorts",
73
- Tagline: "知识短视频一条龙:选题 → 脚本 → 配音 → 素材 → 设计 → 成片",
73
+ Tagline: "擅长知识短视频:选题 → 脚本 → 配音 → 素材 → 设计 → 成片",
74
74
  Description: "把一句话主题做成可直接发布的横版/竖版短视频:先扫爆款定选题,再成稿、评审、分段配音、" +
75
- "按设计规范合成成片。工程自带工作台(magic-shorts serve,端口 8899)与 macOS 菜单栏 App;" +
75
+ "按设计规范合成成片。这是一套**成片能力**(选题、配音、设计规范都在里面)," +
76
+ "不只是转码工具 —— 选它就是让这一轮由擅长做短视频的 CLI 来主导。" +
77
+ "工程自带工作台(magic-shorts serve,端口 8899)与 macOS 菜单栏 App;" +
76
78
  "数据落在 ~/.magic-shorts/。",
77
79
  Requirements: "Node ≥18、Python ≥3.10(skillflow 依赖 click)",
78
80
  Homepage: "https://github.com/javacn/magic-shorts",
@@ -98,9 +100,10 @@ func pluginSpecs() []pluginSpec {
98
100
  ID: "magic-video",
99
101
  Name: "magic-video",
100
102
  CLI: "magic-video",
101
- Tagline: "短剧剧本 / 小说 / 视频分镜:三域一体的内容生产 CLI",
103
+ Tagline: "擅长短剧剧本 / 小说 / 视频分镜:三域一体的内容 CLI",
102
104
  Description: "一个入口管三条内容线:novel(长篇/短篇小说)、script(180–210 秒一集的短剧剧本)、" +
103
- "video(分镜与媒体)。另有 workbench 本地工作台、doctor 健康检查与 cost 用量统计;" +
105
+ "video(分镜与媒体)。它的强项是**写与拆**(剧本骨架、分集钩子、分镜)," +
106
+ "再往下才是画面与媒体处理。另有 workbench 本地工作台、doctor 健康检查与 cost 用量统计;" +
104
107
  "数据落在 ~/.magic-video/。",
105
108
  Requirements: "Node ≥18(npm 包内自带二进制,无需 Go 环境)",
106
109
  Homepage: "https://github.com/javacn/magic-video",