@awiki/cli 0.0.1-beta.2

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 (119) hide show
  1. package/.github/workflows/release.yml +44 -0
  2. package/.goreleaser.yml +44 -0
  3. package/AGENTS.md +60 -0
  4. package/CLAUDE.md +192 -0
  5. package/README.md +2 -0
  6. package/docs/architecture/awiki-command-v2.md +955 -0
  7. package/docs/architecture/awiki-skill-architecture.md +475 -0
  8. package/docs/architecture/awiki-v2-architecture.md +1063 -0
  9. package/docs/architecture//345/217/202/350/200/203/346/226/207/346/241/243/cli-init.md +1008 -0
  10. package/docs/architecture//345/217/202/350/200/203/346/226/207/346/241/243/output-format.md +407 -0
  11. package/docs/architecture//345/217/202/350/200/203/346/226/207/346/241/243/overall-init.md +741 -0
  12. package/docs/harness/review-spec.md +474 -0
  13. package/docs/installation.md +372 -0
  14. package/docs/plan/awiki-v2-implementation-plan.md +903 -0
  15. package/docs/plan/phase-0/adr-index.md +56 -0
  16. package/docs/plan/phase-0/audit-findings.md +251 -0
  17. package/docs/plan/phase-0/capability-mapping.md +108 -0
  18. package/docs/plan/phase-0/implementation-constraints.md +363 -0
  19. package/docs/publish.md +169 -0
  20. package/go.mod +29 -0
  21. package/go.sum +73 -0
  22. package/internal/anpsdk/registry.go +63 -0
  23. package/internal/authsdk/session.go +351 -0
  24. package/internal/buildinfo/buildinfo.go +34 -0
  25. package/internal/cli/app.go +136 -0
  26. package/internal/cli/app_test.go +88 -0
  27. package/internal/cli/debug.go +104 -0
  28. package/internal/cli/group.go +263 -0
  29. package/internal/cli/id.go +473 -0
  30. package/internal/cli/init.go +134 -0
  31. package/internal/cli/msg.go +228 -0
  32. package/internal/cli/page.go +267 -0
  33. package/internal/cli/root.go +499 -0
  34. package/internal/cli/runtime.go +232 -0
  35. package/internal/cli/upgrade.go +60 -0
  36. package/internal/cmdmeta/catalog.go +203 -0
  37. package/internal/cmdmeta/catalog_test.go +21 -0
  38. package/internal/config/config.go +399 -0
  39. package/internal/config/config_test.go +104 -0
  40. package/internal/config/write.go +37 -0
  41. package/internal/content/service.go +314 -0
  42. package/internal/content/service_test.go +165 -0
  43. package/internal/content/types.go +44 -0
  44. package/internal/docs/topics.go +110 -0
  45. package/internal/doctor/doctor.go +306 -0
  46. package/internal/identity/client.go +267 -0
  47. package/internal/identity/did.go +85 -0
  48. package/internal/identity/did_test.go +50 -0
  49. package/internal/identity/layout.go +206 -0
  50. package/internal/identity/legacy.go +378 -0
  51. package/internal/identity/public.go +70 -0
  52. package/internal/identity/public_test.go +73 -0
  53. package/internal/identity/readiness.go +74 -0
  54. package/internal/identity/service.go +826 -0
  55. package/internal/identity/store.go +385 -0
  56. package/internal/identity/store_test.go +180 -0
  57. package/internal/identity/types.go +204 -0
  58. package/internal/message/auth.go +167 -0
  59. package/internal/message/group_service.go +838 -0
  60. package/internal/message/group_wire.go +350 -0
  61. package/internal/message/group_wire_test.go +67 -0
  62. package/internal/message/helpers.go +61 -0
  63. package/internal/message/http_client.go +334 -0
  64. package/internal/message/proof.go +156 -0
  65. package/internal/message/proof_test.go +61 -0
  66. package/internal/message/service.go +696 -0
  67. package/internal/message/service_test.go +97 -0
  68. package/internal/message/types.go +155 -0
  69. package/internal/message/wire.go +100 -0
  70. package/internal/message/wire_test.go +49 -0
  71. package/internal/message/ws_proxy_client.go +151 -0
  72. package/internal/output/output.go +350 -0
  73. package/internal/output/output_test.go +48 -0
  74. package/internal/runtime/config.go +117 -0
  75. package/internal/runtime/config_test.go +46 -0
  76. package/internal/runtime/listener/files.go +65 -0
  77. package/internal/runtime/listener/manager.go +142 -0
  78. package/internal/runtime/listener/server.go +983 -0
  79. package/internal/runtime/listener/server_test.go +319 -0
  80. package/internal/runtime/listener/sysproc_unix.go +17 -0
  81. package/internal/runtime/listener/sysproc_windows.go +13 -0
  82. package/internal/runtime/listener/types.go +21 -0
  83. package/internal/runtime/listener/wsclient.go +299 -0
  84. package/internal/runtime/listener/wsclient_test.go +41 -0
  85. package/internal/store/dao.go +632 -0
  86. package/internal/store/dao_test.go +87 -0
  87. package/internal/store/helpers.go +197 -0
  88. package/internal/store/import.go +499 -0
  89. package/internal/store/import_test.go +103 -0
  90. package/internal/store/open.go +71 -0
  91. package/internal/store/query.go +151 -0
  92. package/internal/store/schema.go +277 -0
  93. package/internal/store/schema_test.go +56 -0
  94. package/internal/store/types.go +177 -0
  95. package/internal/update/update.go +368 -0
  96. package/package.json +17 -0
  97. package/scripts/install.js +171 -0
  98. package/scripts/release/release-prerelease.sh +86 -0
  99. package/scripts/release/tag-release.sh +66 -0
  100. package/scripts/release/withdraw-release.sh +78 -0
  101. package/scripts/run.js +69 -0
  102. package/skills/README.md +32 -0
  103. package/skills/awiki-bundle/SKILL.md +76 -0
  104. package/skills/awiki-debug/SKILL.md +80 -0
  105. package/skills/awiki-group/SKILL.md +111 -0
  106. package/skills/awiki-id/SKILL.md +123 -0
  107. package/skills/awiki-msg/SKILL.md +131 -0
  108. package/skills/awiki-page/SKILL.md +93 -0
  109. package/skills/awiki-people/SKILL.md +66 -0
  110. package/skills/awiki-runtime/SKILL.md +137 -0
  111. package/skills/awiki-shared/SKILL.md +124 -0
  112. package/skills/awiki-workflow-discovery/SKILL.md +93 -0
  113. package/skills/awiki-workflow-onboarding/SKILL.md +119 -0
  114. package/skills/manifests/skills.yaml +260 -0
  115. package/skills/templates/bundle-skill-template.md +42 -0
  116. package/skills/templates/debug-skill-template.md +44 -0
  117. package/skills/templates/domain-skill-template.md +56 -0
  118. package/skills/templates/shared-skill-template.md +46 -0
  119. package/skills/templates/workflow-skill-template.md +46 -0
@@ -0,0 +1,319 @@
1
+ package listener
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "path/filepath"
9
+ "strings"
10
+ "sync/atomic"
11
+ "testing"
12
+ "time"
13
+
14
+ appconfig "github.com/agentconnect/awiki-cli/internal/config"
15
+ "github.com/agentconnect/awiki-cli/internal/identity"
16
+ "github.com/agentconnect/awiki-cli/internal/store"
17
+ "github.com/coder/websocket"
18
+ )
19
+
20
+ func TestMessageRecordFromDirectIncomingUsesProtocolFieldsOnly(t *testing.T) {
21
+ t.Parallel()
22
+
23
+ notification := map[string]any{
24
+ "jsonrpc": "2.0",
25
+ "method": "direct.incoming",
26
+ "params": map[string]any{
27
+ "meta": map[string]any{
28
+ "sender_did": "did:wba:example.com:user:bob:e1_yyy",
29
+ "message_id": "msg-001",
30
+ "created_at": "2026-04-07T00:00:00Z",
31
+ "content_type": "text/plain",
32
+ "target": map[string]any{
33
+ "kind": "agent",
34
+ "did": "did:wba:example.com:user:alice:e1_xxx",
35
+ },
36
+ },
37
+ "auth": map[string]any{
38
+ "scheme": "anp-rfc9421-origin-proof-v1",
39
+ "sender_proof": map[string]any{
40
+ "contentDigest": "sha-256=:digest:",
41
+ "signatureInput": "sig1=(\"@method\");created=1;keyid=\"did:wba:example.com:user:bob:e1_yyy#key-1\"",
42
+ "signature": "sig1=:signature:",
43
+ },
44
+ },
45
+ "body": map[string]any{
46
+ "text": "hello back",
47
+ },
48
+ },
49
+ }
50
+
51
+ record, ok := messageRecordFromDirectIncoming(notification, "alice")
52
+ if !ok {
53
+ t.Fatalf("messageRecordFromDirectIncoming() ok = false, want true")
54
+ }
55
+ if record.OwnerDID != "did:wba:example.com:user:alice:e1_xxx" {
56
+ t.Fatalf("record.OwnerDID = %q", record.OwnerDID)
57
+ }
58
+ if record.SenderDID != "did:wba:example.com:user:bob:e1_yyy" {
59
+ t.Fatalf("record.SenderDID = %q", record.SenderDID)
60
+ }
61
+ if record.SentAt != "2026-04-07T00:00:00Z" {
62
+ t.Fatalf("record.SentAt = %q", record.SentAt)
63
+ }
64
+ if !strings.Contains(record.Metadata, "anp-rfc9421-origin-proof-v1") {
65
+ t.Fatalf("record.Metadata = %q, want auth payload", record.Metadata)
66
+ }
67
+ if strings.Contains(record.Metadata, "\"server\"") {
68
+ t.Fatalf("record.Metadata = %q, should not depend on server wrapper", record.Metadata)
69
+ }
70
+ }
71
+
72
+ func TestMessageRecordFromDirectIncomingRejectsNonDirectNotification(t *testing.T) {
73
+ t.Parallel()
74
+
75
+ _, ok := messageRecordFromDirectIncoming(map[string]any{"method": "group.incoming"}, "alice")
76
+ if ok {
77
+ t.Fatalf("messageRecordFromDirectIncoming() ok = true, want false")
78
+ }
79
+ }
80
+
81
+ func TestMessageRecordFromGroupIncomingUsesProtocolFieldsOnly(t *testing.T) {
82
+ t.Parallel()
83
+
84
+ notification := map[string]any{
85
+ "jsonrpc": "2.0",
86
+ "method": "group.incoming",
87
+ "params": map[string]any{
88
+ "meta": map[string]any{
89
+ "sender_did": "did:wba:example.com:user:bob:e1_bob",
90
+ "message_id": "msg-group-001",
91
+ "content_type": "text/plain",
92
+ "target": map[string]any{
93
+ "kind": "agent",
94
+ "did": "did:wba:example.com:user:alice:e1_alice",
95
+ },
96
+ },
97
+ "body": map[string]any{
98
+ "text": "hello group",
99
+ "group_did": "did:wba:example.com:groups:demo:e1_group",
100
+ "group_event_seq": "5",
101
+ "accepted_at": "2026-04-07T09:11:01Z",
102
+ },
103
+ },
104
+ }
105
+
106
+ record, ok := messageRecordFromGroupIncoming(notification, "alice")
107
+ if !ok {
108
+ t.Fatalf("messageRecordFromGroupIncoming() ok = false, want true")
109
+ }
110
+ if record.GroupDID != "did:wba:example.com:groups:demo:e1_group" {
111
+ t.Fatalf("record.GroupDID = %q", record.GroupDID)
112
+ }
113
+ if record.ThreadID != "group:did:wba:example.com:groups:demo:e1_group" {
114
+ t.Fatalf("record.ThreadID = %q", record.ThreadID)
115
+ }
116
+ if record.Content != "hello group" {
117
+ t.Fatalf("record.Content = %q", record.Content)
118
+ }
119
+ }
120
+
121
+ func TestRecordsFromGroupStateChangedBuildsMemberAndSystemMessage(t *testing.T) {
122
+ t.Parallel()
123
+
124
+ notification := map[string]any{
125
+ "jsonrpc": "2.0",
126
+ "method": "group.state_changed",
127
+ "params": map[string]any{
128
+ "meta": map[string]any{
129
+ "target": map[string]any{
130
+ "kind": "agent",
131
+ "did": "did:wba:example.com:user:alice:e1_alice",
132
+ },
133
+ },
134
+ "body": map[string]any{
135
+ "event_id": "evt-3",
136
+ "group_did": "did:wba:example.com:groups:demo:e1_group",
137
+ "group_event_seq": "3",
138
+ "subject_method": "group.remove",
139
+ "subject_did": "did:wba:example.com:user:carol:e1_carol",
140
+ "actor_did": "did:wba:example.com:user:alice:e1_alice",
141
+ "membership_status": "removed",
142
+ "changed_at": "2026-04-07T09:06:01Z",
143
+ },
144
+ },
145
+ }
146
+
147
+ groupRecord, memberRecord, messageRecord, ok := recordsFromGroupStateChanged(notification, "alice")
148
+ if !ok {
149
+ t.Fatalf("recordsFromGroupStateChanged() ok = false, want true")
150
+ }
151
+ if groupRecord == nil || groupRecord.GroupDID != "did:wba:example.com:groups:demo:e1_group" {
152
+ t.Fatalf("groupRecord = %#v", groupRecord)
153
+ }
154
+ if memberRecord == nil || memberRecord.Status != "removed" {
155
+ t.Fatalf("memberRecord = %#v", memberRecord)
156
+ }
157
+ if messageRecord == nil || messageRecord.ContentType != "group_system_member_kicked" {
158
+ t.Fatalf("messageRecord = %#v", messageRecord)
159
+ }
160
+ }
161
+
162
+ func TestSessionLoopReconnectsAndStoresNotifications(t *testing.T) {
163
+ t.Parallel()
164
+
165
+ var connectionCount atomic.Int32
166
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
167
+ if r.URL.Path != "/ws" {
168
+ http.NotFound(w, r)
169
+ return
170
+ }
171
+ conn, err := websocket.Accept(w, r, nil)
172
+ if err != nil {
173
+ t.Errorf("websocket.Accept() error = %v", err)
174
+ return
175
+ }
176
+ defer conn.Close(websocket.StatusNormalClosure, "done")
177
+
178
+ index := connectionCount.Add(1)
179
+ payload := map[string]any{
180
+ "jsonrpc": "2.0",
181
+ "method": "direct.incoming",
182
+ "params": map[string]any{
183
+ "meta": map[string]any{
184
+ "sender_did": "did:wba:example.com:user:bob:e1_bob",
185
+ "message_id": "msg-" + string(rune('0'+index)),
186
+ "created_at": "2026-04-07T00:00:00Z",
187
+ "content_type": "text/plain",
188
+ "target": map[string]any{
189
+ "kind": "agent",
190
+ "did": "did:wba:awiki.ai:user:alice:e1_alice",
191
+ },
192
+ },
193
+ "body": map[string]any{
194
+ "text": "hello-" + string(rune('0'+index)),
195
+ },
196
+ },
197
+ }
198
+ raw, err := json.Marshal(payload)
199
+ if err != nil {
200
+ t.Errorf("json.Marshal() error = %v", err)
201
+ return
202
+ }
203
+ writeCtx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
204
+ defer cancel()
205
+ if err := conn.Write(writeCtx, websocket.MessageText, raw); err != nil {
206
+ t.Errorf("conn.Write() error = %v", err)
207
+ return
208
+ }
209
+ if index == 1 {
210
+ return
211
+ }
212
+ <-r.Context().Done()
213
+ }))
214
+ defer server.Close()
215
+
216
+ resolved := testResolvedConfig(t, server.URL)
217
+ supervisor, err := NewSupervisor(resolved)
218
+ if err != nil {
219
+ t.Fatalf("NewSupervisor() error = %v", err)
220
+ }
221
+ defer supervisor.Close()
222
+
223
+ session, err := supervisor.ensureSession("alice")
224
+ if err != nil {
225
+ t.Fatalf("ensureSession() error = %v", err)
226
+ }
227
+ deadline := time.Now().Add(10 * time.Second)
228
+ for {
229
+ if connectionCount.Load() >= 2 {
230
+ break
231
+ }
232
+ if time.Now().After(deadline) {
233
+ t.Fatalf("connectionCount = %d, want at least 2", connectionCount.Load())
234
+ }
235
+ time.Sleep(100 * time.Millisecond)
236
+ }
237
+ if session.currentClient() == nil {
238
+ t.Fatalf("session.currentClient() = nil, want active client after reconnect")
239
+ }
240
+
241
+ db, err := store.Open(resolved.Paths)
242
+ if err != nil {
243
+ t.Fatalf("store.Open() error = %v", err)
244
+ }
245
+ defer db.Close()
246
+ deadline = time.Now().Add(10 * time.Second)
247
+ for {
248
+ var count int
249
+ row := db.QueryRow(`SELECT COUNT(*) FROM messages WHERE owner_did = ?`, "did:wba:awiki.ai:user:alice:e1_alice")
250
+ if scanErr := row.Scan(&count); scanErr != nil {
251
+ t.Fatalf("Scan() error = %v", scanErr)
252
+ }
253
+ if count >= 2 {
254
+ break
255
+ }
256
+ if time.Now().After(deadline) {
257
+ t.Fatalf("stored message count = %d, want at least 2", count)
258
+ }
259
+ time.Sleep(100 * time.Millisecond)
260
+ }
261
+ }
262
+
263
+ func testResolvedConfig(t *testing.T, messageServiceURL string) *appconfig.Resolved {
264
+ t.Helper()
265
+
266
+ root := t.TempDir()
267
+ manager := identity.NewManager(appconfig.Paths{
268
+ IdentityDir: filepath.Join(root, "identities"),
269
+ LegacyCredentialsDir: filepath.Join(root, "legacy"),
270
+ DataDir: filepath.Join(root, "data"),
271
+ StateDir: filepath.Join(root, "state"),
272
+ DatabaseFile: filepath.Join(root, "data", "awiki-cli.db"),
273
+ })
274
+ createTestIdentity(t, manager, identity.SaveInput{
275
+ IdentityName: "alice",
276
+ DisplayName: "Alice",
277
+ Handle: "alice",
278
+ UserID: "user-alice-123",
279
+ JWTToken: "token-123",
280
+ })
281
+ return &appconfig.Resolved{
282
+ Paths: appconfig.Paths{
283
+ IdentityDir: filepath.Join(root, "identities"),
284
+ LegacyCredentialsDir: filepath.Join(root, "legacy"),
285
+ DataDir: filepath.Join(root, "data"),
286
+ StateDir: filepath.Join(root, "state"),
287
+ DatabaseFile: filepath.Join(root, "data", "awiki-cli.db"),
288
+ },
289
+ UserServiceURL: messageServiceURL,
290
+ MessageServiceURL: messageServiceURL,
291
+ MessageServiceWSURL: messageServiceURL,
292
+ DIDDomain: "awiki.ai",
293
+ RuntimeMode: "websocket",
294
+ ActiveIdentity: "alice",
295
+ }
296
+ }
297
+
298
+ func createTestIdentity(t *testing.T, manager *identity.Manager, input identity.SaveInput) {
299
+ t.Helper()
300
+
301
+ generated, err := identity.GenerateIdentity(identity.GenerateOptions{
302
+ Hostname: "awiki.ai",
303
+ PathPrefix: []string{"user"},
304
+ ProofDomain: "awiki.ai",
305
+ })
306
+ if err != nil {
307
+ t.Fatalf("GenerateIdentity() error = %v", err)
308
+ }
309
+ input.DID = "did:wba:awiki.ai:user:alice:e1_alice"
310
+ input.UniqueID = generated.UniqueID
311
+ input.DIDDocument = generated.DIDDocument
312
+ input.Key1PrivatePEM = generated.Key1PrivatePEM
313
+ input.Key1PublicPEM = generated.Key1PublicPEM
314
+ input.E2EESigningPrivatePEM = generated.E2EESigningPrivatePEM
315
+ input.E2EEAgreementPrivatePEM = generated.E2EEAgreementPrivatePEM
316
+ if _, err := manager.Save(input); err != nil {
317
+ t.Fatalf("Save() error = %v", err)
318
+ }
319
+ }
@@ -0,0 +1,17 @@
1
+ //go:build !windows
2
+ // +build !windows
3
+
4
+ package listener
5
+
6
+ import (
7
+ "os/exec"
8
+ "syscall"
9
+ )
10
+
11
+ // setSysProcAttr configures the child process attributes for Unix-like systems.
12
+ // It is a no-op on Windows (see sysproc_windows.go).
13
+ func setSysProcAttr(cmd *exec.Cmd) {
14
+ cmd.SysProcAttr = &syscall.SysProcAttr{
15
+ Setsid: true,
16
+ }
17
+ }
@@ -0,0 +1,13 @@
1
+ //go:build windows
2
+ // +build windows
3
+
4
+ package listener
5
+
6
+ import "os/exec"
7
+
8
+ // setSysProcAttr is a no-op on Windows. The listener supervisor is not yet
9
+ // supported as a background service on this platform.
10
+ func setSysProcAttr(cmd *exec.Cmd) {
11
+ // Intentionally empty.
12
+ _ = cmd
13
+ }
@@ -0,0 +1,21 @@
1
+ package listener
2
+
3
+ type SessionStatus struct {
4
+ IdentityName string `json:"identity_name"`
5
+ DID string `json:"did,omitempty"`
6
+ Connected bool `json:"connected"`
7
+ LastError string `json:"last_error,omitempty"`
8
+ }
9
+
10
+ type Status struct {
11
+ Mode string `json:"mode"`
12
+ Running bool `json:"running"`
13
+ PID int `json:"pid,omitempty"`
14
+ PIDFile string `json:"pid_file,omitempty"`
15
+ SocketPath string `json:"socket_path,omitempty"`
16
+ LogFile string `json:"log_file,omitempty"`
17
+ StatusFile string `json:"status_file,omitempty"`
18
+ StartedAt string `json:"started_at,omitempty"`
19
+ Sessions []SessionStatus `json:"sessions,omitempty"`
20
+ Warnings []string `json:"warnings,omitempty"`
21
+ }
@@ -0,0 +1,299 @@
1
+ package listener
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "io"
8
+ "net/http"
9
+ "net/url"
10
+ "strings"
11
+ "sync"
12
+ "sync/atomic"
13
+ "time"
14
+
15
+ "github.com/agentconnect/awiki-cli/internal/authsdk"
16
+ appconfig "github.com/agentconnect/awiki-cli/internal/config"
17
+ "github.com/agentconnect/awiki-cli/internal/message"
18
+ "github.com/coder/websocket"
19
+ )
20
+
21
+ type WSClient struct {
22
+ requestURL string
23
+ websocketURL string
24
+ httpClient *http.Client
25
+ auth *authsdk.Session
26
+ conn *websocket.Conn
27
+ nextID int64
28
+ pendingMu sync.Mutex
29
+ pending map[string]chan map[string]any
30
+ notifications chan map[string]any
31
+ readerErr atomic.Value
32
+ writeMu sync.Mutex
33
+ }
34
+
35
+ func (c *WSClient) ReaderError() error {
36
+ if c == nil {
37
+ return nil
38
+ }
39
+ raw := c.readerErr.Load()
40
+ if raw == nil {
41
+ return nil
42
+ }
43
+ err, _ := raw.(error)
44
+ return err
45
+ }
46
+
47
+ func NewWSClient(resolved *appconfig.Resolved, auth *authsdk.Session) (*WSClient, error) {
48
+ if auth == nil {
49
+ return nil, fmt.Errorf("auth session is required for websocket mode")
50
+ }
51
+ targetHTTPURL := strings.TrimSpace(resolved.MessageServiceURL)
52
+ if targetHTTPURL == "" {
53
+ return nil, fmt.Errorf("message service url is required for websocket mode")
54
+ }
55
+ targetWSURL := strings.TrimSpace(resolved.MessageServiceWSURL)
56
+ if targetWSURL == "" {
57
+ targetWSURL = strings.Replace(targetHTTPURL, "https://", "wss://", 1)
58
+ targetWSURL = strings.Replace(targetWSURL, "http://", "ws://", 1)
59
+ }
60
+ targetHTTPURL = appendEndpointIfMissing(targetHTTPURL, message.MessageWSEndpoint)
61
+ targetWSURL = appendEndpointIfMissing(targetWSURL, message.MessageWSEndpoint)
62
+ return &WSClient{
63
+ requestURL: targetHTTPURL,
64
+ websocketURL: targetWSURL,
65
+ httpClient: &http.Client{},
66
+ auth: auth,
67
+ pending: map[string]chan map[string]any{},
68
+ notifications: make(chan map[string]any, 128),
69
+ }, nil
70
+ }
71
+
72
+ func (c *WSClient) Connect(ctx context.Context) error {
73
+ if token := strings.TrimSpace(c.auth.CurrentJWT()); token != "" {
74
+ conn, response, err := c.dial(ctx, map[string]string{
75
+ "Authorization": "Bearer " + token,
76
+ })
77
+ if err == nil {
78
+ if response != nil {
79
+ c.auth.CaptureToken(c.requestURL, response.Header)
80
+ }
81
+ c.conn = conn
82
+ go c.readLoop()
83
+ return nil
84
+ }
85
+ if response == nil || response.StatusCode != http.StatusUnauthorized {
86
+ return formatDialError(err, response)
87
+ }
88
+ }
89
+ headers, err := c.auth.Headers(c.requestURL, http.MethodGet, nil, false)
90
+ if err != nil {
91
+ return err
92
+ }
93
+ conn, response, err := c.dial(ctx, headers)
94
+ if err != nil {
95
+ if response != nil && response.StatusCode == http.StatusUnauthorized {
96
+ var retryHeaders map[string]string
97
+ if c.auth.ShouldRetryAfter401(response.Header) {
98
+ retryHeaders, err = c.auth.ChallengeHeaders(c.requestURL, response.Header, http.MethodGet, nil)
99
+ } else {
100
+ c.auth.ClearToken(c.requestURL)
101
+ retryHeaders, err = c.auth.Headers(c.requestURL, http.MethodGet, nil, true)
102
+ }
103
+ if err != nil {
104
+ return err
105
+ }
106
+ conn, response, err = c.dial(ctx, retryHeaders)
107
+ }
108
+ if err != nil {
109
+ return formatDialError(err, response)
110
+ }
111
+ }
112
+ if response != nil {
113
+ c.auth.CaptureToken(c.requestURL, response.Header)
114
+ }
115
+ c.conn = conn
116
+ go c.readLoop()
117
+ return nil
118
+ }
119
+
120
+ func (c *WSClient) dial(ctx context.Context, headers map[string]string) (*websocket.Conn, *http.Response, error) {
121
+ httpHeaders := http.Header{}
122
+ for key, value := range headers {
123
+ httpHeaders.Set(key, value)
124
+ }
125
+ return websocket.Dial(ctx, c.websocketURL, &websocket.DialOptions{
126
+ HTTPClient: c.httpClient,
127
+ HTTPHeader: httpHeaders,
128
+ CompressionMode: websocket.CompressionDisabled,
129
+ })
130
+ }
131
+
132
+ func formatDialError(err error, response *http.Response) error {
133
+ if err == nil {
134
+ return nil
135
+ }
136
+ if response == nil || response.Body == nil {
137
+ return err
138
+ }
139
+ defer response.Body.Close()
140
+ raw, readErr := io.ReadAll(io.LimitReader(response.Body, 4096))
141
+ if readErr != nil || len(raw) == 0 {
142
+ return err
143
+ }
144
+ return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(raw)))
145
+ }
146
+
147
+ func appendEndpointIfMissing(base string, endpoint string) string {
148
+ trimmed := strings.TrimRight(strings.TrimSpace(base), "/")
149
+ if strings.HasSuffix(trimmed, endpoint) {
150
+ return trimmed
151
+ }
152
+ return trimmed + endpoint
153
+ }
154
+
155
+ func (c *WSClient) Close() error {
156
+ if c.conn == nil {
157
+ return nil
158
+ }
159
+ return c.conn.Close(websocket.StatusNormalClosure, "closing")
160
+ }
161
+
162
+ func (c *WSClient) Notifications() <-chan map[string]any {
163
+ return c.notifications
164
+ }
165
+
166
+ func (c *WSClient) SendRPC(ctx context.Context, method string, params map[string]any) (map[string]any, error) {
167
+ if c.conn == nil {
168
+ return nil, fmt.Errorf("websocket not connected")
169
+ }
170
+ id := atomic.AddInt64(&c.nextID, 1)
171
+ requestID := fmt.Sprintf("req-%d", id)
172
+ request := map[string]any{"jsonrpc": "2.0", "id": requestID, "method": method}
173
+ if params != nil {
174
+ request["params"] = params
175
+ }
176
+ responseCh := make(chan map[string]any, 1)
177
+ c.pendingMu.Lock()
178
+ c.pending[requestID] = responseCh
179
+ c.pendingMu.Unlock()
180
+ defer func() {
181
+ c.pendingMu.Lock()
182
+ delete(c.pending, requestID)
183
+ c.pendingMu.Unlock()
184
+ }()
185
+ c.writeMu.Lock()
186
+ err := wsjsonWrite(ctx, c.conn, request)
187
+ c.writeMu.Unlock()
188
+ if err != nil {
189
+ return nil, err
190
+ }
191
+ select {
192
+ case response := <-responseCh:
193
+ if errValue, ok := response["error"].(map[string]any); ok {
194
+ return nil, fmt.Errorf("json-rpc error %v: %v", errValue["code"], errValue["message"])
195
+ }
196
+ if result, ok := response["result"].(map[string]any); ok {
197
+ return result, nil
198
+ }
199
+ return map[string]any{}, nil
200
+ case <-ctx.Done():
201
+ return nil, ctx.Err()
202
+ }
203
+ }
204
+
205
+ func (c *WSClient) Ping(ctx context.Context) error {
206
+ if c.conn == nil {
207
+ return fmt.Errorf("websocket not connected")
208
+ }
209
+ return c.conn.Ping(ctx)
210
+ }
211
+
212
+ func (c *WSClient) readLoop() {
213
+ defer close(c.notifications)
214
+ for {
215
+ ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
216
+ var message map[string]any
217
+ err := wsjsonRead(ctx, c.conn, &message)
218
+ cancel()
219
+ if err != nil {
220
+ c.readerErr.Store(err)
221
+ c.failPending(err)
222
+ return
223
+ }
224
+ if rawID, ok := message["id"]; ok {
225
+ id := requestIDFromAny(rawID)
226
+ c.pendingMu.Lock()
227
+ responseCh := c.pending[id]
228
+ c.pendingMu.Unlock()
229
+ if responseCh != nil {
230
+ responseCh <- message
231
+ }
232
+ continue
233
+ }
234
+ select {
235
+ case c.notifications <- message:
236
+ default:
237
+ }
238
+ }
239
+ }
240
+
241
+ func (c *WSClient) failPending(err error) {
242
+ c.pendingMu.Lock()
243
+ defer c.pendingMu.Unlock()
244
+ for id, responseCh := range c.pending {
245
+ responseCh <- map[string]any{"error": map[string]any{"message": err.Error()}, "id": id}
246
+ }
247
+ }
248
+
249
+ func wsjsonWrite(ctx context.Context, conn *websocket.Conn, payload any) error {
250
+ raw, err := json.Marshal(payload)
251
+ if err != nil {
252
+ return err
253
+ }
254
+ return conn.Write(ctx, websocket.MessageText, raw)
255
+ }
256
+
257
+ func wsjsonRead(ctx context.Context, conn *websocket.Conn, out any) error {
258
+ _, raw, err := conn.Read(ctx)
259
+ if err != nil {
260
+ return err
261
+ }
262
+ return json.Unmarshal(raw, out)
263
+ }
264
+
265
+ func requestIDFromAny(value any) string {
266
+ switch typed := value.(type) {
267
+ case string:
268
+ return typed
269
+ case int64:
270
+ return fmt.Sprintf("%d", typed)
271
+ case int:
272
+ return fmt.Sprintf("%d", typed)
273
+ case float64:
274
+ return fmt.Sprintf("%.0f", typed)
275
+ default:
276
+ return ""
277
+ }
278
+ }
279
+
280
+ func int64FromAny(value any) int64 {
281
+ switch typed := value.(type) {
282
+ case int64:
283
+ return typed
284
+ case int:
285
+ return int64(typed)
286
+ case float64:
287
+ return int64(typed)
288
+ default:
289
+ return 0
290
+ }
291
+ }
292
+
293
+ func hostForURL(raw string) string {
294
+ parsed, err := url.Parse(raw)
295
+ if err != nil {
296
+ return raw
297
+ }
298
+ return parsed.Host
299
+ }