magic-agent 1.0.2 → 1.0.46

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 (159) hide show
  1. package/CLIENT-CONTRACT.md +254 -8
  2. package/README.md +48 -5
  3. package/client-ui/index.html +32 -0
  4. package/client-ui/pc/app.css +89 -1
  5. package/client-ui/pc/index.html +18 -8
  6. package/client-ui/pc/js/00-base.js +8 -30
  7. package/client-ui/pc/js/05-task-list-render.js +4 -1
  8. package/client-ui/pc/js/07-chat-stream-render.js +2 -85
  9. package/client-ui/pc/js/10-markdown.js +3 -171
  10. package/client-ui/pc/js/18-askbox.js +2 -176
  11. package/client-ui/pc/js/90-glue.js +1 -1
  12. package/client-ui/pc/js/91-workbench-stubs.js +4 -10
  13. package/client-ui/pc/js/93-rail.js +10 -3
  14. package/client-ui/shared/conv/01-dom.js +52 -0
  15. package/client-ui/shared/conv/02-media-kinds.js +34 -0
  16. package/client-ui/shared/conv/03-chat-message.js +1561 -0
  17. package/client-ui/shared/conv/06-ask-answer.js +377 -0
  18. package/client-ui/shared/conv/07-stream-delegation.js +158 -0
  19. package/client-ui/{pc/js → shared/conv}/09-stream-apply.js +121 -6
  20. package/client-ui/shared/conv/10-markdown-render.js +252 -0
  21. package/client-ui/shared/conv/18-askbox-markup.js +185 -0
  22. package/client-ui/{pc/js → shared/conv}/19-attachments.js +365 -21
  23. package/client-ui/shared/conv/27-conv-ledger.js +381 -0
  24. package/client-ui/{pc/js → shared/conv}/29-msg-variants.js +112 -3
  25. package/client-ui/shared/conv/README.md +257 -0
  26. package/client-ui/shared/conv/conv.css +1638 -0
  27. package/client-ui/shared/conv/dom-map.md +170 -0
  28. package/client-ui/shared/conv/manifest.json +44 -0
  29. package/client-ui/shared/conversation.css +368 -190
  30. package/client-ui/shared/conversation.js +1722 -788
  31. package/client-ui/shared/host-glue.js +143 -0
  32. package/client-ui/shared/transport-http.js +130 -6
  33. package/client-ui-mobile/index.html +2417 -103
  34. package/cmd/magic-client/clientctl.go +273 -0
  35. package/cmd/magic-client/main.go +71 -5
  36. package/go.mod +6 -3
  37. package/go.sum +4 -1
  38. package/internal/agent/arkclaw.go +3 -2
  39. package/internal/agent/arkclaw_stream.go +126 -8
  40. package/internal/agent/arkclaw_stream_test.go +97 -15
  41. package/internal/agent/arkclaw_test.go +3 -3
  42. package/internal/agent/attachments.go +18 -5
  43. package/internal/agent/attachments_test.go +7 -1
  44. package/internal/agent/capability_test.go +11 -1
  45. package/internal/agent/claude.go +96 -158
  46. package/internal/agent/codebuddy.go +108 -133
  47. package/internal/agent/codebuddy_gateway.go +8 -4
  48. package/internal/agent/codebuddy_gateway_stream.go +82 -2
  49. package/internal/agent/codebuddy_gateway_test.go +236 -1
  50. package/internal/agent/codebuddy_stream_test.go +262 -0
  51. package/internal/agent/codex.go +50 -9
  52. package/internal/agent/codex_stream_test.go +30 -0
  53. package/internal/agent/codex_test.go +24 -0
  54. package/internal/agent/custom_models.go +260 -8
  55. package/internal/agent/custom_models_test.go +204 -7
  56. package/internal/agent/dsh.go +464 -130
  57. package/internal/agent/dsh_resume.go +258 -0
  58. package/internal/agent/dsh_sdk.go +68 -17
  59. package/internal/agent/dsh_sdk_test.go +102 -2
  60. package/internal/agent/dsh_test.go +409 -36
  61. package/internal/agent/engine.go +26 -6
  62. package/internal/agent/engine_base.go +46 -2
  63. package/internal/agent/engine_base_test.go +29 -0
  64. package/internal/agent/engine_test.go +101 -0
  65. package/internal/agent/env.go +78 -2
  66. package/internal/agent/env_test.go +37 -0
  67. package/internal/agent/itemtracker.go +271 -0
  68. package/internal/agent/launch.go +192 -0
  69. package/internal/agent/launch_test.go +206 -0
  70. package/internal/agent/launch_unix.go +15 -0
  71. package/internal/agent/launch_windows.go +24 -0
  72. package/internal/agent/limitnotice.go +103 -0
  73. package/internal/agent/limitnotice_test.go +211 -0
  74. package/internal/agent/llmengine.go +2 -2
  75. package/internal/agent/models.go +3 -2
  76. package/internal/agent/openclaw.go +4 -3
  77. package/internal/agent/openclaw_acp.go +7 -0
  78. package/internal/agent/openclaw_test.go +2 -2
  79. package/internal/agent/permission.go +100 -11
  80. package/internal/agent/permission_test.go +100 -36
  81. package/internal/agent/repair_models_test.go +194 -0
  82. package/internal/agent/runcmd.go +13 -3
  83. package/internal/agent/runner.go +7 -1
  84. package/internal/agent/runner_test.go +1 -1
  85. package/internal/agent/stream.go +777 -43
  86. package/internal/agent/stream_test.go +3 -2
  87. package/internal/agent/stream_timeline_test.go +905 -0
  88. package/internal/agent/streamjson.go +36 -2
  89. package/internal/agent/toolkind.go +20 -1
  90. package/internal/agent/toolkind_test.go +7 -1
  91. package/internal/agent/tools_suffix_test.go +10 -3
  92. package/internal/agent/trae.go +5 -3
  93. package/internal/agent/workspace_test.go +52 -0
  94. package/internal/cli/ask.go +282 -51
  95. package/internal/cli/ask_test.go +15 -11
  96. package/internal/cli/attach_test.go +32 -20
  97. package/internal/cli/clientcmd.go +156 -0
  98. package/internal/cli/events.go +53 -8
  99. package/internal/cli/events_test.go +230 -0
  100. package/internal/cli/keepalive_test.go +101 -6
  101. package/internal/cli/login.go +61 -4
  102. package/internal/cli/login_test.go +99 -0
  103. package/internal/cli/permission_test.go +5 -1
  104. package/internal/cli/repair.go +60 -0
  105. package/internal/cli/repair_test.go +51 -0
  106. package/internal/cli/root.go +195 -32
  107. package/internal/cli/session.go +183 -29
  108. package/internal/cli/session_test.go +144 -5
  109. package/internal/cli/sessionlog.go +385 -38
  110. package/internal/cli/sessionlog_test.go +708 -0
  111. package/internal/cli/sessionlog_user_test.go +142 -1
  112. package/internal/cli/sessiontitle.go +141 -0
  113. package/internal/client/browse.go +456 -0
  114. package/internal/client/browse_test.go +199 -0
  115. package/internal/client/cors_test.go +35 -0
  116. package/internal/client/daemon.go +525 -0
  117. package/internal/client/daemon_test.go +212 -0
  118. package/internal/client/detach_unix.go +19 -0
  119. package/internal/client/detach_windows.go +27 -0
  120. package/internal/client/pair.go +3 -3
  121. package/internal/client/plugins.go +376 -0
  122. package/internal/client/plugins_test.go +223 -0
  123. package/internal/client/proxy.go +374 -0
  124. package/internal/client/proxy_test.go +212 -0
  125. package/internal/client/service.go +1464 -48
  126. package/internal/client/service_test.go +649 -206
  127. package/internal/client/session_events_test.go +130 -0
  128. package/internal/client/wsproxy.go +113 -0
  129. package/internal/client/wsproxy_test.go +102 -0
  130. package/internal/session/delete.go +151 -0
  131. package/internal/session/delete_test.go +103 -0
  132. package/internal/session/session.go +155 -20
  133. package/internal/session/session_append.go +79 -21
  134. package/internal/session/session_test.go +124 -10
  135. package/internal/session/sessionlog.go +572 -6
  136. package/internal/session/sessionlog_read_test.go +410 -0
  137. package/internal/session/title.go +486 -0
  138. package/internal/session/title_test.go +298 -0
  139. package/npm/bin/magic-agent.js +78 -10
  140. package/npm/bin/magic-client.js +100 -0
  141. package/npm/dist/darwin-arm64/magic-agent +0 -0
  142. package/npm/dist/darwin-arm64/magic-client +0 -0
  143. package/npm/dist/darwin-x64/magic-agent +0 -0
  144. package/npm/dist/darwin-x64/magic-client +0 -0
  145. package/npm/dist/linux-arm64/magic-agent +0 -0
  146. package/npm/dist/linux-arm64/magic-client +0 -0
  147. package/npm/dist/linux-x64/magic-agent +0 -0
  148. package/npm/dist/linux-x64/magic-client +0 -0
  149. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  150. package/npm/dist/win32-x64/magic-client.exe +0 -0
  151. package/npm/install.js +36 -13
  152. package/npm/lib/platform.js +27 -5
  153. package/package.json +8 -3
  154. package/client-ui/pc/js/03-chat-message.js +0 -880
  155. package/client-ui/pc/js/06-ask-answer.js +0 -204
  156. /package/client-ui/{pc/js → shared/conv}/08-ask-event-map.js +0 -0
  157. /package/client-ui/{pc → shared}/vendor/highlight.min.js +0 -0
  158. /package/client-ui/{pc → shared}/vendor/marked.min.js +0 -0
  159. /package/client-ui/{pc → shared}/vendor/purify.min.js +0 -0
@@ -0,0 +1,212 @@
1
+ package client
2
+
3
+ // daemon_test.go - 后台常驻的状态文件与探活口径。
4
+ //
5
+ // 这里**不启动真的 serve 进程**(那需要 core 与整套 UI,属于真机验收的事),
6
+ // 只钉住三件容易悄悄错掉的事:
7
+ // 1) 状态文件是 0600(里面有令牌,权限就是门锁);
8
+ // 2) 令牌跨重启稳定(手机里存的是它,换了等于配对作废);
9
+ // 3) status 的判据是「进程在 **且** 健康检查答得上」,不是只看 pid。
10
+
11
+ import (
12
+ "net/http"
13
+ "net/http/httptest"
14
+ "os"
15
+ "path/filepath"
16
+ "strconv"
17
+ "strings"
18
+ "testing"
19
+ )
20
+
21
+ func TestDaemonStateFileIsPrivate(t *testing.T) {
22
+ dir := t.TempDir()
23
+ p := filepath.Join(dir, "client.json")
24
+ st := &DaemonState{PID: 1, Port: 1234, Token: "0123456789abcdef0123456789abcdef", StatePath: p}
25
+ if err := writeDaemonState(p, st); err != nil {
26
+ t.Fatalf("writeDaemonState: %v", err)
27
+ }
28
+ info, err := os.Stat(p)
29
+ if err != nil {
30
+ t.Fatalf("stat: %v", err)
31
+ }
32
+ if perm := info.Mode().Perm(); perm != 0o600 {
33
+ t.Errorf("状态文件权限 = %o,want 600(里面有令牌,权限是唯一门锁)", perm)
34
+ }
35
+ got, err := ReadDaemonState(p)
36
+ if err != nil {
37
+ t.Fatalf("ReadDaemonState: %v", err)
38
+ }
39
+ if got == nil || got.Token != st.Token {
40
+ t.Fatalf("读回来对不上:%+v", got)
41
+ }
42
+ if got.StatePath != p {
43
+ t.Errorf("StatePath = %q,want %q", got.StatePath, p)
44
+ }
45
+ }
46
+
47
+ func TestReadDaemonStateMissingIsNil(t *testing.T) {
48
+ got, err := ReadDaemonState(filepath.Join(t.TempDir(), "nope.json"))
49
+ if err != nil {
50
+ t.Fatalf("不存在应当不算错误:%v", err)
51
+ }
52
+ if got != nil {
53
+ t.Errorf("got %+v,want nil", got)
54
+ }
55
+ }
56
+
57
+ // 令牌必须跨 start 稳定:手机里存的就是它。
58
+ func TestResolveTokenIsStable(t *testing.T) {
59
+ dir := t.TempDir()
60
+ statePath := filepath.Join(dir, "client.json")
61
+
62
+ first := resolveToken(statePath, "")
63
+ if len(first) < MinLanTokenLen {
64
+ t.Fatalf("生成的令牌长度 %d < %d", len(first), MinLanTokenLen)
65
+ }
66
+ second := resolveToken(statePath, "")
67
+ if first != second {
68
+ t.Errorf("两次取到的令牌不同(%q vs %q):手机配对会作废", first, second)
69
+ }
70
+
71
+ info, err := os.Stat(TokenPath(statePath))
72
+ if err != nil {
73
+ t.Fatalf("令牌文件没落盘:%v", err)
74
+ }
75
+ if perm := info.Mode().Perm(); perm != 0o600 {
76
+ t.Errorf("令牌文件权限 = %o,want 600", perm)
77
+ }
78
+
79
+ // 显式给了就用显式的(临时换令牌的排障路径),但**不改**已存的那份。
80
+ if got := resolveToken(statePath, "explicit-token-000000000000"); got != "explicit-token-000000000000" {
81
+ t.Errorf("显式令牌没被采用:%q", got)
82
+ }
83
+ if again := resolveToken(statePath, ""); again != first {
84
+ t.Errorf("显式令牌把存的那份覆盖了:%q,want %q", again, first)
85
+ }
86
+ }
87
+
88
+ // 存的令牌坏掉(太短 / 空)时应当重新生成,而不是拿个弱令牌去 --lan。
89
+ func TestResolveTokenRejectsShortStored(t *testing.T) {
90
+ dir := t.TempDir()
91
+ statePath := filepath.Join(dir, "client.json")
92
+ if err := os.WriteFile(TokenPath(statePath), []byte("abc\n"), 0o600); err != nil {
93
+ t.Fatal(err)
94
+ }
95
+ got := resolveToken(statePath, "")
96
+ if len(got) < MinLanTokenLen {
97
+ t.Errorf("存着的短令牌被直接采用了:%q", got)
98
+ }
99
+ }
100
+
101
+ func TestWriteReadyFileRoundTrip(t *testing.T) {
102
+ p := filepath.Join(t.TempDir(), "sub", "ready.json")
103
+ if got, err := readReadyFile(p); err != nil || got != nil {
104
+ t.Fatalf("不存在时应返回 (nil,nil),got %+v err %v", got, err)
105
+ }
106
+ info := ReadyInfo{Port: 50022, URL: "http://127.0.0.1:50022/?token=x", Lan: true,
107
+ LanAddrs: []string{"http://192.168.1.5:50022/?token=x"}}
108
+ if err := WriteReadyFile(p, info); err != nil {
109
+ t.Fatalf("WriteReadyFile: %v", err)
110
+ }
111
+ got, err := readReadyFile(p)
112
+ if err != nil {
113
+ t.Fatalf("readReadyFile: %v", err)
114
+ }
115
+ if got == nil || got.Port != 50022 || got.PID == 0 {
116
+ t.Fatalf("读回来不对:%+v", got)
117
+ }
118
+ if len(got.LanAddrs) != 1 || !strings.Contains(got.LanAddrs[0], "192.168.1.5") {
119
+ t.Errorf("lanAddrs 丢了:%v", got.LanAddrs)
120
+ }
121
+ }
122
+
123
+ // status 的判据:pid 活着但服务答不上来 → **不算 running**。
124
+ // 只看 pid 会把「契约不匹配后卡住」的服务报成可用,用户照它去连必然失败。
125
+ func TestStatusRequiresHealthyService(t *testing.T) {
126
+ dir := t.TempDir()
127
+ statePath := filepath.Join(dir, "client.json")
128
+
129
+ // 拿一个真的在监听、但**_不是_**我们的服务:能连上、答的不是 health JSON。
130
+ other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
131
+ _, _ = w.Write([]byte("<html>not magic</html>"))
132
+ }))
133
+ defer other.Close()
134
+ port := portOf(t, other.URL)
135
+
136
+ st := &DaemonState{PID: os.Getpid(), Port: port, Token: "x", StatePath: statePath}
137
+ if err := writeDaemonState(statePath, st); err != nil {
138
+ t.Fatal(err)
139
+ }
140
+ got, err := StatusDaemon(statePath)
141
+ if err != nil {
142
+ t.Fatalf("StatusDaemon: %v", err)
143
+ }
144
+ if got.Running {
145
+ t.Errorf("不是本服务也报 running:判据不能只看 pid / 端口通")
146
+ }
147
+ if got.Note == "" {
148
+ t.Errorf("不算 running 时必须给出原因,否则排障无从下手")
149
+ }
150
+ }
151
+
152
+ // 没有状态文件时 status 也是一条明确的回答,不是错误。
153
+ func TestStatusWithoutStateFile(t *testing.T) {
154
+ got, err := StatusDaemon(filepath.Join(t.TempDir(), "none.json"))
155
+ if err != nil {
156
+ t.Fatalf("StatusDaemon: %v", err)
157
+ }
158
+ if got.Running || got.State != nil {
159
+ t.Errorf("got %+v,want 未运行", got)
160
+ }
161
+ if got.Note == "" {
162
+ t.Errorf("未运行也要说明原因")
163
+ }
164
+ }
165
+
166
+ // 没有状态文件时 stop 是幂等的空操作。
167
+ func TestStopWithoutStateIsNoop(t *testing.T) {
168
+ stopped, st, err := StopDaemon(StopOptions{StatePath: filepath.Join(t.TempDir(), "none.json")})
169
+ if err != nil {
170
+ t.Fatalf("StopDaemon: %v", err)
171
+ }
172
+ if stopped || st != nil {
173
+ t.Errorf("stopped=%v st=%+v,want false/nil", stopped, st)
174
+ }
175
+ }
176
+
177
+ // 进程已不在时 stop 要清掉陈旧状态,否则 status 会一直报「进程已不在」。
178
+ func TestStopClearsStaleState(t *testing.T) {
179
+ dir := t.TempDir()
180
+ statePath := filepath.Join(dir, "client.json")
181
+ // pid 1 在容器里可能真实存在;用一个几乎不可能存在的 pid(bool 判活对无效 pid 返回 false)。
182
+ st := &DaemonState{PID: 1 << 22, Port: 1, Token: "x", StatePath: statePath}
183
+ if err := writeDaemonState(statePath, st); err != nil {
184
+ t.Fatal(err)
185
+ }
186
+ stopped, got, err := StopDaemon(StopOptions{StatePath: statePath})
187
+ if err != nil {
188
+ t.Fatalf("StopDaemon: %v", err)
189
+ }
190
+ if stopped {
191
+ t.Errorf("进程本来就不在,不该报「这次停了它」")
192
+ }
193
+ if got == nil {
194
+ t.Fatalf("应当回一条陈旧状态,便于 CLI 说清楚")
195
+ }
196
+ if _, err := os.Stat(statePath); !os.IsNotExist(err) {
197
+ t.Errorf("陈旧状态文件没被清掉(err=%v)", err)
198
+ }
199
+ }
200
+
201
+ func portOf(t *testing.T, rawURL string) int {
202
+ t.Helper()
203
+ i := strings.LastIndex(rawURL, ":")
204
+ if i < 0 {
205
+ t.Fatalf("URL 没有端口:%s", rawURL)
206
+ }
207
+ p, err := strconv.Atoi(rawURL[i+1:])
208
+ if err != nil {
209
+ t.Fatalf("端口解析失败 %s:%v", rawURL, err)
210
+ }
211
+ return p
212
+ }
@@ -0,0 +1,19 @@
1
+ //go:build !windows
2
+
3
+ package client
4
+
5
+ // detach_unix.go - 让后台 serve 与启动它的终端彻底分手。
6
+ //
7
+ // 为什么要 setsid:不脱离会话的话,关掉终端(SIGHUP)或用户 Ctrl-C 会把服务一起带走 ——
8
+ // 而 `start` 的全部意义就是「关掉窗口它还在」。stdin 已经在调用方置空(see StartDaemon),
9
+ // 这里只处理会话与进程组。
10
+
11
+ import (
12
+ "os/exec"
13
+ "syscall"
14
+ )
15
+
16
+ // detach 让子进程进入新会话(= 脱离控制终端)。
17
+ func detach(cmd *exec.Cmd) {
18
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
19
+ }
@@ -0,0 +1,27 @@
1
+ //go:build windows
2
+
3
+ package client
4
+
5
+ // detach_windows.go - Windows 上没有 setsid,用 CREATE_NEW_PROCESS_GROUP +
6
+ // DETACHED_PROCESS 达到同样效果:新进程组、不附着到父控制台。
7
+ //
8
+ // 为什么不用 windows service:那需要管理员权限与额外安装步骤,而这里只需要
9
+ // 「关掉命令行窗口后还能连」。DETACHED_PROCESS 已经够。
10
+
11
+ import (
12
+ "os/exec"
13
+ "syscall"
14
+ )
15
+
16
+ const (
17
+ createNewProcessGroup = 0x00000200
18
+ detachedProcess = 0x00000008
19
+ )
20
+
21
+ // detach 让子进程脱离父控制台并自成进程组。
22
+ func detach(cmd *exec.Cmd) {
23
+ cmd.SysProcAttr = &syscall.SysProcAttr{
24
+ CreationFlags: createNewProcessGroup | detachedProcess,
25
+ HideWindow: true,
26
+ }
27
+ }
@@ -117,7 +117,7 @@ func (s *Service) handlePairPage(w http.ResponseWriter, r *http.Request) {
117
117
  page := `<!doctype html>
118
118
  <html lang="zh-CN"><head><meta charset="utf-8">
119
119
  <meta name="viewport" content="width=device-width,initial-scale=1">
120
- <title>掌天瓶 · 配对</title>
120
+ <title>magic-agent · 配对</title>
121
121
  <style>
122
122
  body{margin:0;background:#0A0A0B;color:#FAFAFA;padding:30px 24px 44px;
123
123
  font:15px/1.65 -apple-system,BlinkMacSystemFont,"PingFang SC","Noto Sans CJK SC",sans-serif}
@@ -130,8 +130,8 @@ func (s *Service) handlePairPage(w http.ResponseWriter, r *http.Request) {
130
130
  ol{color:#8A8A8F;font-size:13px;padding-left:20px;margin:22px 0 0}
131
131
  li{margin:5px 0}
132
132
  </style></head><body>
133
- <h1>掌天瓶 · 配对</h1>
134
- <p class="sub">用手机上的掌天瓶 App 扫下面这张码。手机要和这台电脑在同一个网络里。</p>
133
+ <h1>magic-agent · 配对</h1>
134
+ <p class="sub">用手机上的 magic-agent 扫下面这张码。手机要和这台电脑在同一个网络里。</p>
135
135
  <div class="qr"><img src="/pair/qr.png?token=__TOKENRAW__" alt="配对二维码"></div>
136
136
  <div class="row"><div class="k">桌面地址</div><div class="v">__ADDR__</div></div>
137
137
  <div class="row"><div class="k">令牌</div><div class="v">__TOKEN__</div></div>
@@ -0,0 +1,376 @@
1
+ package client
2
+
3
+ import (
4
+ "bufio"
5
+ "context"
6
+ "encoding/json"
7
+ "errors"
8
+ "fmt"
9
+ "net/http"
10
+ "os"
11
+ "os/exec"
12
+ "path/filepath"
13
+ "strings"
14
+ )
15
+
16
+ /* 插件目录:magic-* 工程各自的 CLI(magic-shorts / magic-video)。
17
+ *
18
+ * 为什么放在插件(magic-client)这一侧而不是 core:这些 CLI 不是引擎,core 对它们一无所知
19
+ *(`--engines` 里没有它们,也不该有);它们是**另一个工程**的入口,只在「发现」页被介绍、
20
+ * 安装、升级,并被当作谈资丢进对话里让 Agent 去调。放进 core 会污染引擎注册表,
21
+ * 也会让「装不装插件时 core 行为一致」那条边界断言失效。
22
+ *
23
+ * ── 纪律 ──
24
+ * 1) 命令**只来自下面这张表**(argv 数组,不过 shell)—— 接口不接受客户端传来的命令,
25
+ * 唯一的外部输入是插件 id 与 action,找不到就 404。否则这个接口等于一个任意命令执行器。
26
+ * 2) 不假装成功:探测不到就报 installed=false + 说明;安装失败把 stderr 原样流回去。
27
+ * 3) 不自动装依赖:步骤是固定的、可见的,用户点「安装」才跑,跑完把每行输出给到手机。
28
+ */
29
+
30
+ // pluginStep 安装/升级里的一条命令。Optional 的失败不中止后续步骤(例如本地有未提交改动时的 git pull)。
31
+ type pluginStep struct {
32
+ Args []string
33
+ Optional bool
34
+ SkipNote string // Optional 步骤跳过时展示的原因
35
+ }
36
+
37
+ func (p pluginStep) Command() string { return strings.Join(p.Args, " ") }
38
+
39
+ // pluginSpec 插件目录里的一项(服务端固定表,不接受客户端输入)。
40
+ type pluginSpec struct {
41
+ ID string
42
+ Name string
43
+ CLI string
44
+ Tagline string
45
+ Description string
46
+ Requirements string
47
+ Homepage string
48
+ // Repo 源码目录(绝对路径;可能不存在 —— 那样只剩 npm 这条安装路径)。
49
+ Repo string
50
+ Install []pluginStep
51
+ Upgrade []pluginStep
52
+ Examples []string
53
+ // UsePrompt 是「让它干活」时预填到输入框里的话(用户补参数即可发送)。
54
+ UsePrompt string
55
+ }
56
+
57
+ func pluginSpecs() []pluginSpec {
58
+ home, _ := os.UserHomeDir()
59
+ shortsRepo := filepath.Join(home, ".openclaw", "workspace", "magic-shorts")
60
+ videoRepo := filepath.Join(home, ".openclaw", "workspace", "magic-video")
61
+ // 允许用环境变量指向别处的源码(与 core 的 MAGIC_AGENT_*_BIN 同一套思路)。
62
+ if v := strings.TrimSpace(os.Getenv("MAGIC_CLIENT_SHORTS_DIR")); v != "" {
63
+ shortsRepo = v
64
+ }
65
+ if v := strings.TrimSpace(os.Getenv("MAGIC_CLIENT_VIDEO_DIR")); v != "" {
66
+ videoRepo = v
67
+ }
68
+ return []pluginSpec{
69
+ {
70
+ ID: "magic-shorts",
71
+ Name: "magic-shorts",
72
+ CLI: "magic-shorts",
73
+ Tagline: "知识短视频一条龙:选题 → 脚本 → 配音 → 素材 → 设计 → 成片",
74
+ Description: "把一句话主题做成可直接发布的横版/竖版短视频:先扫爆款定选题,再成稿、评审、分段配音、" +
75
+ "按设计规范合成成片。工程自带工作台(magic-shorts serve,端口 8899)与 macOS 菜单栏 App;" +
76
+ "数据落在 ~/.magic-shorts/。",
77
+ Requirements: "Node ≥18、Python ≥3.10(skillflow 依赖 click)",
78
+ Homepage: "https://github.com/javacn/magic-shorts",
79
+ Repo: shortsRepo,
80
+ Install: []pluginStep{
81
+ {Args: []string{"npm", "install", "-g", shortsRepo}},
82
+ {Args: []string{"python3", "-m", "pip", "install", "-e", shortsRepo}},
83
+ },
84
+ Upgrade: []pluginStep{
85
+ {Args: []string{"git", "-C", shortsRepo, "pull", "--ff-only"}, Optional: true,
86
+ SkipNote: "本地有未提交改动或拉取失败 —— 跳过更新源码,按当前源码重装"},
87
+ {Args: []string{"npm", "install", "-g", shortsRepo}},
88
+ {Args: []string{"python3", "-m", "pip", "install", "-e", shortsRepo}},
89
+ },
90
+ Examples: []string{
91
+ "magic-shorts make -p demo -n \"讲清波浪理论的核心铁律,面向散户\"",
92
+ "magic-shorts video -p demo --seg 2 # 只重生成第 2 段",
93
+ "magic-shorts serve # 工作台(http://127.0.0.1:8899)",
94
+ },
95
+ UsePrompt: "用 magic-shorts 做一条知识短视频。\n主题:\n目标时长:60 秒(竖版)\n受众:\n要求:先给我选题与大纲,确认后再跑后续阶段。",
96
+ },
97
+ {
98
+ ID: "magic-video",
99
+ Name: "magic-video",
100
+ CLI: "magic-video",
101
+ Tagline: "短剧剧本 / 小说 / 视频分镜:三域一体的内容生产 CLI",
102
+ Description: "一个入口管三条内容线:novel(长篇/短篇小说)、script(180–210 秒一集的短剧剧本)、" +
103
+ "video(分镜与媒体)。另有 workbench 本地工作台、doctor 健康检查与 cost 用量统计;" +
104
+ "数据落在 ~/.magic-video/。",
105
+ Requirements: "Node ≥18(npm 包内自带二进制,无需 Go 环境)",
106
+ Homepage: "https://github.com/javacn/magic-video",
107
+ Repo: videoRepo,
108
+ Install: []pluginStep{
109
+ {Args: []string{"npm", "install", "-g", "@magicstock/magic-cli"}},
110
+ },
111
+ Upgrade: []pluginStep{
112
+ {Args: []string{"npm", "install", "-g", "@magicstock/magic-cli@latest"}},
113
+ },
114
+ Examples: []string{
115
+ "magic-video doctor # 13 项健康检查",
116
+ "magic-video script episode generate --project X --episode episode_001",
117
+ "magic-video workbench # 本地工作台",
118
+ },
119
+ UsePrompt: "用 magic-video 写一集短剧。\n题材:\n主角:\n时长:180–210 秒\n要求:先给三幕大纲与分集钩子,确认后再写成稿。",
120
+ },
121
+ }
122
+ }
123
+
124
+ func findPlugin(id string) (pluginSpec, bool) {
125
+ id = strings.TrimSpace(strings.ToLower(id))
126
+ for _, p := range pluginSpecs() {
127
+ if p.ID == id {
128
+ return p, true
129
+ }
130
+ }
131
+ return pluginSpec{}, false
132
+ }
133
+
134
+ // pluginInfo GET /desk/plugins 的一项(客户端只读,不做第二次推断)。
135
+ type pluginInfo struct {
136
+ ID string `json:"id"`
137
+ Name string `json:"name"`
138
+ CLI string `json:"cli"`
139
+ Tagline string `json:"tagline"`
140
+ Description string `json:"description"`
141
+ Requirements string `json:"requirements,omitempty"`
142
+ Homepage string `json:"homepage,omitempty"`
143
+ Installed bool `json:"installed"`
144
+ Version string `json:"version,omitempty"`
145
+ Bin string `json:"bin,omitempty"`
146
+ SourcePath string `json:"sourcePath,omitempty"`
147
+ SourceExists bool `json:"sourceExists"`
148
+ Install string `json:"install"`
149
+ Upgrade string `json:"upgrade"`
150
+ Examples []string `json:"examples"`
151
+ UsePrompt string `json:"usePrompt"`
152
+ // Note 讲清「为什么是现在这个状态」,客户端原样显示,不自己编。
153
+ Note string `json:"note,omitempty"`
154
+ }
155
+
156
+ func pluginInfoOf(p pluginSpec) pluginInfo {
157
+ info := pluginInfo{
158
+ ID: p.ID, Name: p.Name, CLI: p.CLI,
159
+ Tagline: p.Tagline, Description: p.Description, Requirements: p.Requirements,
160
+ Homepage: p.Homepage, Examples: p.Examples, UsePrompt: p.UsePrompt,
161
+ Install: pluginCommands(p.Install), Upgrade: pluginCommands(p.Upgrade),
162
+ }
163
+ if st, err := os.Stat(p.Repo); err == nil && st.IsDir() {
164
+ info.SourceExists = true
165
+ info.SourcePath = p.Repo
166
+ }
167
+ if bin, err := exec.LookPath(p.CLI); err == nil {
168
+ info.Installed = true
169
+ info.Bin = bin
170
+ info.Version = versionNearBin(bin)
171
+ }
172
+ switch {
173
+ case info.Installed && info.Version != "":
174
+ info.Note = "已安装,命令在 PATH 上"
175
+ case info.Installed:
176
+ info.Note = "已安装,但读不到版本号(该 CLI 没有 --version)"
177
+ case info.SourceExists:
178
+ info.Note = "未安装:源码在 " + p.Repo + ",可直接安装"
179
+ default:
180
+ info.Note = "未安装:PATH 上没有 " + p.CLI
181
+ }
182
+ return info
183
+ }
184
+
185
+ func pluginCommands(steps []pluginStep) string {
186
+ parts := make([]string, 0, len(steps))
187
+ for _, s := range steps {
188
+ parts = append(parts, s.Command())
189
+ }
190
+ return strings.Join(parts, " && ")
191
+ }
192
+
193
+ /* versionNearBin 顺着可执行文件找回它所属 npm 包的 package.json。
194
+ * 刻意不跑 `--version`:magic-video 不认这个 flag(会 exit 2),跑一次还会拖慢列表。 */
195
+ func versionNearBin(bin string) string {
196
+ p := bin
197
+ if real, err := filepath.EvalSymlinks(bin); err == nil {
198
+ p = real
199
+ }
200
+ dir := filepath.Dir(p)
201
+ for i := 0; i < 4; i++ {
202
+ if v := packageVersion(filepath.Join(dir, "package.json")); v != "" {
203
+ return v
204
+ }
205
+ parent := filepath.Dir(dir)
206
+ if parent == dir {
207
+ break
208
+ }
209
+ dir = parent
210
+ }
211
+ return ""
212
+ }
213
+
214
+ func packageVersion(path string) string {
215
+ b, err := os.ReadFile(path)
216
+ if err != nil {
217
+ return ""
218
+ }
219
+ var pkg struct {
220
+ Name string `json:"name"`
221
+ Version string `json:"version"`
222
+ }
223
+ if err := json.Unmarshal(b, &pkg); err != nil {
224
+ return ""
225
+ }
226
+ if pkg.Version == "" {
227
+ return ""
228
+ }
229
+ return pkg.Version
230
+ }
231
+
232
+ // handlePlugins GET /desk/plugins:插件目录 + 本机安装状态。
233
+ func (s *Service) handlePlugins(w http.ResponseWriter, r *http.Request) {
234
+ if r.Method != http.MethodGet {
235
+ writeErr(w, http.StatusMethodNotAllowed, "GET only")
236
+ return
237
+ }
238
+ specs := pluginSpecs()
239
+ items := make([]pluginInfo, 0, len(specs))
240
+ for _, p := range specs {
241
+ items = append(items, pluginInfoOf(p))
242
+ }
243
+ writeJSON(w, http.StatusOK, map[string]any{"plugins": items})
244
+ }
245
+
246
+ /* handlePluginAction POST /desk/plugins/{id}/{install|upgrade}
247
+ *
248
+ * 回 SSE:`event: open`(这次要跑哪几条命令)→ 每条命令一行 `event: step`,
249
+ * 输出逐行 `event: line` → `event: end`(ok / code / 刷新后的版本)。
250
+ * 用 SSE 而不是等一个 JSON:npm 装一个包几秒到几十秒,手机上必须看得见在动。 */
251
+ func (s *Service) handlePluginAction(w http.ResponseWriter, r *http.Request) {
252
+ if r.Method != http.MethodPost {
253
+ writeErr(w, http.StatusMethodNotAllowed, "POST only")
254
+ return
255
+ }
256
+ rest := strings.TrimPrefix(r.URL.Path, "/desk/plugins/")
257
+ parts := strings.Split(strings.Trim(rest, "/"), "/")
258
+ if len(parts) != 2 {
259
+ writeErr(w, http.StatusNotFound, "用法:POST /desk/plugins/{id}/{install|upgrade}")
260
+ return
261
+ }
262
+ spec, ok := findPlugin(parts[0])
263
+ if !ok {
264
+ writeErr(w, http.StatusNotFound, "没有这个插件:"+parts[0])
265
+ return
266
+ }
267
+ var steps []pluginStep
268
+ switch parts[1] {
269
+ case "install":
270
+ steps = spec.Install
271
+ case "upgrade":
272
+ steps = spec.Upgrade
273
+ default:
274
+ writeErr(w, http.StatusNotFound, "动作只有 install / upgrade")
275
+ return
276
+ }
277
+ if len(steps) == 0 {
278
+ writeErr(w, http.StatusBadRequest, "这个插件没有配置安装命令")
279
+ return
280
+ }
281
+
282
+ w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
283
+ w.Header().Set("Cache-Control", "no-cache")
284
+ w.Header().Set("X-Accel-Buffering", "no")
285
+ flusher, _ := w.(http.Flusher)
286
+ send := func(event string, payload any) {
287
+ b, _ := json.Marshal(payload)
288
+ if event != "" {
289
+ fmt.Fprintf(w, "event: %s\n", event)
290
+ }
291
+ fmt.Fprintf(w, "data: %s\n\n", b)
292
+ if flusher != nil {
293
+ flusher.Flush()
294
+ }
295
+ }
296
+
297
+ cmds := make([]string, 0, len(steps))
298
+ for _, st := range steps {
299
+ cmds = append(cmds, st.Command())
300
+ }
301
+ send("open", map[string]any{"id": spec.ID, "action": parts[1], "commands": cmds})
302
+
303
+ /* 与 /desk/ask 同一口径:客户端断连不是「取消安装」。
304
+ 装到一半被手机锁屏杀掉会更糟(半个包装在盘上),所以跑完再说。 */
305
+ ctx := context.WithoutCancel(r.Context())
306
+ failed := ""
307
+ failCode := 0
308
+ for i, st := range steps {
309
+ send("step", map[string]any{"index": i, "command": st.Command()})
310
+ code, err := runPluginStep(ctx, st, func(line string) {
311
+ send("line", map[string]any{"index": i, "line": line})
312
+ })
313
+ if err != nil || code != 0 {
314
+ if st.Optional {
315
+ send("line", map[string]any{"index": i, "line": "↷ " + st.SkipNote})
316
+ continue
317
+ }
318
+ if err != nil {
319
+ failed = err.Error()
320
+ } else {
321
+ failed = fmt.Sprintf("%s 退出码 %d", st.Command(), code)
322
+ }
323
+ failCode = code
324
+ break
325
+ }
326
+ }
327
+
328
+ info := pluginInfoOf(spec) // 跑完重新探一次:成功与否都让界面拿到最新状态
329
+ if failed != "" {
330
+ send("end", map[string]any{"ok": false, "code": failCode, "error": failed, "plugin": info})
331
+ return
332
+ }
333
+ send("end", map[string]any{"ok": true, "code": 0, "plugin": info})
334
+ }
335
+
336
+ // runPluginStep 跑一条命令并把 stdout+stderr 按行回调(顺序即实际发生顺序)。
337
+ func runPluginStep(ctx context.Context, st pluginStep, onLine func(string)) (int, error) {
338
+ if len(st.Args) == 0 {
339
+ return 0, errors.New("空命令")
340
+ }
341
+ cmd := exec.CommandContext(ctx, st.Args[0], st.Args[1:]...)
342
+ pr, pw, err := os.Pipe()
343
+ if err != nil {
344
+ return 1, err
345
+ }
346
+ cmd.Stdout = pw
347
+ cmd.Stderr = pw
348
+ if err := cmd.Start(); err != nil {
349
+ pw.Close()
350
+ pr.Close()
351
+ return 1, fmt.Errorf("启动 %s 失败:%w(该命令不在 PATH 上?)", st.Args[0], err)
352
+ }
353
+ pw.Close()
354
+
355
+ done := make(chan struct{})
356
+ go func() {
357
+ sc := bufio.NewScanner(pr)
358
+ sc.Buffer(make([]byte, 0, 64<<10), 1<<20)
359
+ for sc.Scan() {
360
+ onLine(sc.Text())
361
+ }
362
+ close(done)
363
+ }()
364
+ err = cmd.Wait()
365
+ <-done
366
+ pr.Close()
367
+
368
+ code := 0
369
+ if cmd.ProcessState != nil {
370
+ code = cmd.ProcessState.ExitCode()
371
+ }
372
+ if err != nil && code == 0 {
373
+ code = 1
374
+ }
375
+ return code, nil
376
+ }