agent-messenger 2.27.1 → 2.28.0

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 (194) hide show
  1. package/.claude-plugin/README.md +11 -1
  2. package/.claude-plugin/marketplace.json +14 -1
  3. package/.claude-plugin/plugin.json +4 -2
  4. package/AGENTS.md +4 -0
  5. package/README.md +40 -9
  6. package/dist/package.json +10 -2
  7. package/dist/src/cli.d.ts.map +1 -1
  8. package/dist/src/cli.js +3 -0
  9. package/dist/src/cli.js.map +1 -1
  10. package/dist/src/platforms/discordbot/cli.js +1 -1
  11. package/dist/src/platforms/imessage/cli.d.ts +5 -0
  12. package/dist/src/platforms/imessage/cli.d.ts.map +1 -0
  13. package/dist/src/platforms/imessage/cli.js +31 -0
  14. package/dist/src/platforms/imessage/cli.js.map +1 -0
  15. package/dist/src/platforms/imessage/client.d.ts +43 -0
  16. package/dist/src/platforms/imessage/client.d.ts.map +1 -0
  17. package/dist/src/platforms/imessage/client.js +190 -0
  18. package/dist/src/platforms/imessage/client.js.map +1 -0
  19. package/dist/src/platforms/imessage/commands/auth.d.ts +31 -0
  20. package/dist/src/platforms/imessage/commands/auth.d.ts.map +1 -0
  21. package/dist/src/platforms/imessage/commands/auth.js +100 -0
  22. package/dist/src/platforms/imessage/commands/auth.js.map +1 -0
  23. package/dist/src/platforms/imessage/commands/chat.d.ts +3 -0
  24. package/dist/src/platforms/imessage/commands/chat.d.ts.map +1 -0
  25. package/dist/src/platforms/imessage/commands/chat.js +42 -0
  26. package/dist/src/platforms/imessage/commands/chat.js.map +1 -0
  27. package/dist/src/platforms/imessage/commands/doctor.d.ts +25 -0
  28. package/dist/src/platforms/imessage/commands/doctor.d.ts.map +1 -0
  29. package/dist/src/platforms/imessage/commands/doctor.js +101 -0
  30. package/dist/src/platforms/imessage/commands/doctor.js.map +1 -0
  31. package/dist/src/platforms/imessage/commands/index.d.ts +7 -0
  32. package/dist/src/platforms/imessage/commands/index.d.ts.map +1 -0
  33. package/dist/src/platforms/imessage/commands/index.js +7 -0
  34. package/dist/src/platforms/imessage/commands/index.js.map +1 -0
  35. package/dist/src/platforms/imessage/commands/message.d.ts +5 -0
  36. package/dist/src/platforms/imessage/commands/message.d.ts.map +1 -0
  37. package/dist/src/platforms/imessage/commands/message.js +127 -0
  38. package/dist/src/platforms/imessage/commands/message.js.map +1 -0
  39. package/dist/src/platforms/imessage/commands/setup.d.ts +20 -0
  40. package/dist/src/platforms/imessage/commands/setup.d.ts.map +1 -0
  41. package/dist/src/platforms/imessage/commands/setup.js +57 -0
  42. package/dist/src/platforms/imessage/commands/setup.js.map +1 -0
  43. package/dist/src/platforms/imessage/commands/shared.d.ts +10 -0
  44. package/dist/src/platforms/imessage/commands/shared.d.ts.map +1 -0
  45. package/dist/src/platforms/imessage/commands/shared.js +53 -0
  46. package/dist/src/platforms/imessage/commands/shared.js.map +1 -0
  47. package/dist/src/platforms/imessage/commands/whoami.d.ts +3 -0
  48. package/dist/src/platforms/imessage/commands/whoami.d.ts.map +1 -0
  49. package/dist/src/platforms/imessage/commands/whoami.js +20 -0
  50. package/dist/src/platforms/imessage/commands/whoami.js.map +1 -0
  51. package/dist/src/platforms/imessage/credential-manager.d.ts +22 -0
  52. package/dist/src/platforms/imessage/credential-manager.d.ts.map +1 -0
  53. package/dist/src/platforms/imessage/credential-manager.js +111 -0
  54. package/dist/src/platforms/imessage/credential-manager.js.map +1 -0
  55. package/dist/src/platforms/imessage/ensure-auth.d.ts +2 -0
  56. package/dist/src/platforms/imessage/ensure-auth.d.ts.map +1 -0
  57. package/dist/src/platforms/imessage/ensure-auth.js +13 -0
  58. package/dist/src/platforms/imessage/ensure-auth.js.map +1 -0
  59. package/dist/src/platforms/imessage/errors.d.ts +3 -0
  60. package/dist/src/platforms/imessage/errors.d.ts.map +1 -0
  61. package/dist/src/platforms/imessage/errors.js +25 -0
  62. package/dist/src/platforms/imessage/errors.js.map +1 -0
  63. package/dist/src/platforms/imessage/index.d.ts +7 -0
  64. package/dist/src/platforms/imessage/index.d.ts.map +1 -0
  65. package/dist/src/platforms/imessage/index.js +5 -0
  66. package/dist/src/platforms/imessage/index.js.map +1 -0
  67. package/dist/src/platforms/imessage/rpc.d.ts +20 -0
  68. package/dist/src/platforms/imessage/rpc.d.ts.map +1 -0
  69. package/dist/src/platforms/imessage/rpc.js +133 -0
  70. package/dist/src/platforms/imessage/rpc.js.map +1 -0
  71. package/dist/src/platforms/imessage/types.d.ts +59 -0
  72. package/dist/src/platforms/imessage/types.d.ts.map +1 -0
  73. package/dist/src/platforms/imessage/types.js +29 -0
  74. package/dist/src/platforms/imessage/types.js.map +1 -0
  75. package/dist/src/platforms/instagram/cli.js +1 -1
  76. package/dist/src/platforms/instagram/client.d.ts +6 -1
  77. package/dist/src/platforms/instagram/client.d.ts.map +1 -1
  78. package/dist/src/platforms/instagram/client.js +17 -0
  79. package/dist/src/platforms/instagram/client.js.map +1 -1
  80. package/dist/src/platforms/instagram/hybrid-listener.d.ts +48 -0
  81. package/dist/src/platforms/instagram/hybrid-listener.d.ts.map +1 -0
  82. package/dist/src/platforms/instagram/hybrid-listener.js +154 -0
  83. package/dist/src/platforms/instagram/hybrid-listener.js.map +1 -0
  84. package/dist/src/platforms/instagram/index.d.ts +2 -0
  85. package/dist/src/platforms/instagram/index.d.ts.map +1 -1
  86. package/dist/src/platforms/instagram/index.js +2 -0
  87. package/dist/src/platforms/instagram/index.js.map +1 -1
  88. package/dist/src/platforms/instagram/mqtt/connection.d.ts +4 -0
  89. package/dist/src/platforms/instagram/mqtt/connection.d.ts.map +1 -0
  90. package/dist/src/platforms/instagram/mqtt/connection.js +66 -0
  91. package/dist/src/platforms/instagram/mqtt/connection.js.map +1 -0
  92. package/dist/src/platforms/instagram/mqtt/thrift.d.ts +22 -0
  93. package/dist/src/platforms/instagram/mqtt/thrift.d.ts.map +1 -0
  94. package/dist/src/platforms/instagram/mqtt/thrift.js +143 -0
  95. package/dist/src/platforms/instagram/mqtt/thrift.js.map +1 -0
  96. package/dist/src/platforms/instagram/mqtt/transport.d.ts +42 -0
  97. package/dist/src/platforms/instagram/mqtt/transport.d.ts.map +1 -0
  98. package/dist/src/platforms/instagram/mqtt/transport.js +246 -0
  99. package/dist/src/platforms/instagram/mqtt/transport.js.map +1 -0
  100. package/dist/src/platforms/instagram/realtime-listener.d.ts +33 -0
  101. package/dist/src/platforms/instagram/realtime-listener.d.ts.map +1 -0
  102. package/dist/src/platforms/instagram/realtime-listener.js +151 -0
  103. package/dist/src/platforms/instagram/realtime-listener.js.map +1 -0
  104. package/dist/src/platforms/kakaotalk/cli.js +1 -1
  105. package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
  106. package/dist/src/platforms/kakaotalk/client.js +4 -4
  107. package/dist/src/platforms/kakaotalk/client.js.map +1 -1
  108. package/dist/src/platforms/line/cli.js +1 -1
  109. package/dist/src/platforms/webex/cli.js +1 -1
  110. package/dist/src/platforms/webexbot/cli.js +1 -1
  111. package/dist/src/platforms/wechatbot/cli.js +1 -1
  112. package/dist/src/platforms/whatsapp/cli.js +1 -1
  113. package/dist/src/platforms/whatsappbot/cli.js +1 -1
  114. package/dist/src/tui/adapters/imessage-adapter.d.ts +21 -0
  115. package/dist/src/tui/adapters/imessage-adapter.d.ts.map +1 -0
  116. package/dist/src/tui/adapters/imessage-adapter.js +110 -0
  117. package/dist/src/tui/adapters/imessage-adapter.js.map +1 -0
  118. package/dist/src/tui/app.d.ts.map +1 -1
  119. package/dist/src/tui/app.js +9 -0
  120. package/dist/src/tui/app.js.map +1 -1
  121. package/docs/content/docs/agent-skills.mdx +1 -1
  122. package/docs/content/docs/cli/imessage.mdx +99 -0
  123. package/docs/content/docs/cli/instagram.mdx +1 -1
  124. package/docs/content/docs/cli/meta.json +1 -0
  125. package/docs/content/docs/index.mdx +9 -6
  126. package/docs/content/docs/quick-start.mdx +2 -0
  127. package/docs/content/docs/sdk/instagram.mdx +90 -5
  128. package/docs/content/docs/tui.mdx +4 -3
  129. package/package.json +10 -2
  130. package/scripts/postbuild.ts +12 -15
  131. package/skills/agent-channeltalk/SKILL.md +1 -1
  132. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  133. package/skills/agent-discord/SKILL.md +1 -1
  134. package/skills/agent-discordbot/SKILL.md +1 -1
  135. package/skills/agent-imessage/SKILL.md +133 -0
  136. package/skills/agent-imessage/references/authentication.md +66 -0
  137. package/skills/agent-imessage/references/permissions.md +31 -0
  138. package/skills/agent-imessage/references/setup.md +49 -0
  139. package/skills/agent-instagram/SKILL.md +72 -2
  140. package/skills/agent-instagram/references/common-patterns.md +36 -0
  141. package/skills/agent-instagram/templates/monitor-chat.sh +5 -0
  142. package/skills/agent-kakaotalk/SKILL.md +1 -1
  143. package/skills/agent-line/SKILL.md +1 -1
  144. package/skills/agent-slack/SKILL.md +1 -1
  145. package/skills/agent-slackbot/SKILL.md +1 -1
  146. package/skills/agent-teams/SKILL.md +1 -1
  147. package/skills/agent-telegram/SKILL.md +1 -1
  148. package/skills/agent-telegrambot/SKILL.md +1 -1
  149. package/skills/agent-webex/SKILL.md +1 -1
  150. package/skills/agent-webexbot/SKILL.md +1 -1
  151. package/skills/agent-wechatbot/SKILL.md +1 -1
  152. package/skills/agent-whatsapp/SKILL.md +1 -1
  153. package/skills/agent-whatsappbot/SKILL.md +1 -1
  154. package/src/cli.ts +4 -0
  155. package/src/platforms/imessage/cli.ts +39 -0
  156. package/src/platforms/imessage/client.test.ts +137 -0
  157. package/src/platforms/imessage/client.ts +253 -0
  158. package/src/platforms/imessage/commands/auth.ts +142 -0
  159. package/src/platforms/imessage/commands/chat.ts +51 -0
  160. package/src/platforms/imessage/commands/doctor.test.ts +80 -0
  161. package/src/platforms/imessage/commands/doctor.ts +139 -0
  162. package/src/platforms/imessage/commands/index.ts +6 -0
  163. package/src/platforms/imessage/commands/message.test.ts +20 -0
  164. package/src/platforms/imessage/commands/message.ts +151 -0
  165. package/src/platforms/imessage/commands/setup.test.ts +52 -0
  166. package/src/platforms/imessage/commands/setup.ts +76 -0
  167. package/src/platforms/imessage/commands/shared.test.ts +19 -0
  168. package/src/platforms/imessage/commands/shared.ts +69 -0
  169. package/src/platforms/imessage/commands/whoami.ts +22 -0
  170. package/src/platforms/imessage/credential-manager.test.ts +122 -0
  171. package/src/platforms/imessage/credential-manager.ts +123 -0
  172. package/src/platforms/imessage/ensure-auth.ts +18 -0
  173. package/src/platforms/imessage/errors.ts +27 -0
  174. package/src/platforms/imessage/index.test.ts +13 -0
  175. package/src/platforms/imessage/index.ts +16 -0
  176. package/src/platforms/imessage/rpc.ts +157 -0
  177. package/src/platforms/imessage/test-stub-imsg.mjs +147 -0
  178. package/src/platforms/imessage/types.test.ts +47 -0
  179. package/src/platforms/imessage/types.ts +95 -0
  180. package/src/platforms/instagram/client.ts +23 -0
  181. package/src/platforms/instagram/hybrid-listener.test.ts +135 -0
  182. package/src/platforms/instagram/hybrid-listener.ts +196 -0
  183. package/src/platforms/instagram/index.ts +10 -0
  184. package/src/platforms/instagram/mqtt/connection.ts +72 -0
  185. package/src/platforms/instagram/mqtt/thrift.test.ts +91 -0
  186. package/src/platforms/instagram/mqtt/thrift.ts +159 -0
  187. package/src/platforms/instagram/mqtt/transport.ts +285 -0
  188. package/src/platforms/instagram/realtime-listener.test.ts +34 -0
  189. package/src/platforms/instagram/realtime-listener.ts +201 -0
  190. package/src/platforms/kakaotalk/client.test.ts +40 -0
  191. package/src/platforms/kakaotalk/client.ts +6 -4
  192. package/src/platforms/webexbot/cli.ts +0 -0
  193. package/src/tui/adapters/imessage-adapter.ts +131 -0
  194. package/src/tui/app.ts +13 -0
@@ -0,0 +1,253 @@
1
+ import { spawn } from 'node:child_process'
2
+
3
+ import { classifyImsgFailure } from './errors'
4
+ import { ImsgRpc } from './rpc'
5
+ import { type IMessageChatSummary, type IMessageMessageSummary, type IMessageStatus, IMessageError } from './types'
6
+
7
+ interface ImsgChat {
8
+ id: number
9
+ name?: string | null
10
+ identifier?: string | null
11
+ guid?: string | null
12
+ service?: string | null
13
+ is_group?: boolean
14
+ participants?: string[] | null
15
+ last_message?: ImsgMessage | null
16
+ }
17
+
18
+ interface ImsgMessage {
19
+ id: number
20
+ chat_id: number
21
+ guid: string
22
+ sender?: string | null
23
+ sender_name?: string | null
24
+ is_from_me?: boolean
25
+ text?: string | null
26
+ created_at?: string | null
27
+ }
28
+
29
+ const STANDARD_REACTIONS = new Set(['love', 'like', 'dislike', 'laugh', 'emphasis', 'question'])
30
+
31
+ export interface SendTarget {
32
+ chatId?: number
33
+ chatGuid?: string
34
+ chatIdentifier?: string
35
+ to?: string
36
+ }
37
+
38
+ export type OneShotRunner = (
39
+ binaryPath: string,
40
+ args: string[],
41
+ ) => Promise<{ code: number; stdout: string; stderr: string }>
42
+
43
+ const defaultRunner: OneShotRunner = (binaryPath, args) =>
44
+ new Promise((resolve, reject) => {
45
+ let proc: ReturnType<typeof spawn>
46
+ try {
47
+ proc = spawn(binaryPath, args, { stdio: ['ignore', 'pipe', 'pipe'] })
48
+ } catch {
49
+ reject(new IMessageError(`Could not run "${binaryPath}".`, 'imsg_not_found'))
50
+ return
51
+ }
52
+ let stdout = ''
53
+ let stderr = ''
54
+ proc.stdout?.on('data', (c: Buffer) => (stdout += c.toString()))
55
+ proc.stderr?.on('data', (c: Buffer) => (stderr += c.toString()))
56
+ proc.once('error', (err: NodeJS.ErrnoException) => {
57
+ reject(
58
+ err.code === 'ENOENT'
59
+ ? new IMessageError(`Could not run "${binaryPath}".`, 'imsg_not_found', {
60
+ suggestion: 'Install imsg: "brew install steipete/tap/imsg".',
61
+ doctorCommand: 'agent-imessage doctor',
62
+ })
63
+ : err,
64
+ )
65
+ })
66
+ proc.once('close', (code) => resolve({ code: code ?? 0, stdout, stderr }))
67
+ })
68
+
69
+ function summarizeMessage(msg: ImsgMessage): IMessageMessageSummary {
70
+ return {
71
+ id: msg.id,
72
+ guid: msg.guid,
73
+ chat_id: msg.chat_id,
74
+ from: msg.is_from_me ? '' : (msg.sender ?? ''),
75
+ from_name: msg.sender_name ?? undefined,
76
+ is_outgoing: Boolean(msg.is_from_me),
77
+ timestamp: msg.created_at ?? new Date(0).toISOString(),
78
+ text: msg.text ?? undefined,
79
+ }
80
+ }
81
+
82
+ function summarizeChat(chat: ImsgChat): IMessageChatSummary {
83
+ return {
84
+ id: chat.id,
85
+ guid: chat.guid ?? null,
86
+ identifier: chat.identifier ?? null,
87
+ name: chat.name?.trim() || chat.identifier || chat.guid || String(chat.id),
88
+ service: chat.service ?? 'iMessage',
89
+ is_group: Boolean(chat.is_group),
90
+ participants: chat.participants ?? [],
91
+ last_message: chat.last_message ? summarizeMessage(chat.last_message) : undefined,
92
+ }
93
+ }
94
+
95
+ export class ImsgClient {
96
+ private binaryPath = 'imsg'
97
+ private region: string | undefined
98
+ private rpc: ImsgRpc
99
+ private runner: OneShotRunner
100
+
101
+ constructor(deps?: { rpc?: ImsgRpc; runner?: OneShotRunner }) {
102
+ this.rpc = deps?.rpc ?? new ImsgRpc()
103
+ this.runner = deps?.runner ?? defaultRunner
104
+ }
105
+
106
+ async login(credentials?: { binaryPath?: string; region?: string }): Promise<this> {
107
+ if (credentials) {
108
+ this.binaryPath = credentials.binaryPath ?? 'imsg'
109
+ this.region = credentials.region
110
+ return this
111
+ }
112
+ const { IMessageCredentialManager } = await import('./credential-manager')
113
+ const resolved = await new IMessageCredentialManager().resolveAccount()
114
+ this.binaryPath = resolved?.binary_path ?? 'imsg'
115
+ this.region = resolved?.region
116
+ return this
117
+ }
118
+
119
+ async connect(): Promise<void> {
120
+ await this.rpc.start(this.binaryPath)
121
+ await this.rpc.request('chats.list', { limit: 1 })
122
+ }
123
+
124
+ async getVersion(): Promise<string | null> {
125
+ const result = await this.runner(this.binaryPath, ['--version'])
126
+ if (result.code !== 0) return null
127
+ return result.stdout.trim() || null
128
+ }
129
+
130
+ async getStatus(): Promise<IMessageStatus> {
131
+ let version: string | null = null
132
+ let fullDiskAccess = false
133
+ try {
134
+ version = await this.getVersion()
135
+ } catch {
136
+ version = null
137
+ }
138
+ try {
139
+ await this.rpc.request('chats.list', { limit: 1 })
140
+ fullDiskAccess = true
141
+ } catch {
142
+ fullDiskAccess = false
143
+ }
144
+ return {
145
+ imsg_version: version,
146
+ binary_path: this.binaryPath,
147
+ full_disk_access: fullDiskAccess,
148
+ automation: 'unknown',
149
+ bridge_available: false,
150
+ }
151
+ }
152
+
153
+ async listChats(limit = 25): Promise<IMessageChatSummary[]> {
154
+ const result = await this.rpc.request<{ chats: ImsgChat[] }>('chats.list', { limit })
155
+ return result.chats.map(summarizeChat)
156
+ }
157
+
158
+ async searchChats(query: string, limit = 25): Promise<IMessageChatSummary[]> {
159
+ const all = await this.listChats(Math.max(limit, 100))
160
+ const lower = query.toLowerCase()
161
+ return all
162
+ .filter((c) => c.name.toLowerCase().includes(lower) || (c.identifier ?? '').toLowerCase().includes(lower))
163
+ .slice(0, limit)
164
+ }
165
+
166
+ async getMessages(chatId: number, limit = 25, start?: string): Promise<IMessageMessageSummary[]> {
167
+ const result = await this.rpc.request<{ messages: ImsgMessage[] }>('messages.history', {
168
+ chat_id: chatId,
169
+ limit,
170
+ ...(start ? { start } : {}),
171
+ })
172
+ return result.messages.map(summarizeMessage).sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp))
173
+ }
174
+
175
+ async sendMessage(target: SendTarget, text: string): Promise<IMessageMessageSummary> {
176
+ const params: Record<string, unknown> = { text }
177
+ if (target.chatId !== undefined) params.chat_id = target.chatId
178
+ else if (target.chatGuid) params.chat_guid = target.chatGuid
179
+ else if (target.chatIdentifier) params.chat_identifier = target.chatIdentifier
180
+ else if (target.to) params.to = target.to
181
+ else throw new IMessageError('A chat id, guid, or recipient is required to send.', 'chat_not_found')
182
+ if (this.region) params.region = this.region
183
+
184
+ const result = await this.rpc.request<{ ok?: boolean; guid?: string; id?: number; service?: string }>(
185
+ 'send',
186
+ params,
187
+ )
188
+ return {
189
+ id: result.id ?? 0,
190
+ guid: result.guid ?? '',
191
+ chat_id: target.chatId ?? 0,
192
+ from: '',
193
+ is_outgoing: true,
194
+ timestamp: new Date().toISOString(),
195
+ text,
196
+ }
197
+ }
198
+
199
+ async sendReaction(chatId: number, reaction: string, messageGuid?: string): Promise<void> {
200
+ if (messageGuid) {
201
+ throw new IMessageError(
202
+ 'Reacting to a specific message requires the imsg Private API bridge.',
203
+ 'private_api_required',
204
+ { suggestion: 'Enable the bridge with "imsg launch" (requires SIP disabled).' },
205
+ )
206
+ }
207
+ if (!STANDARD_REACTIONS.has(reaction)) {
208
+ throw new IMessageError(
209
+ `Custom reactions require the imsg Private API bridge. Standard tapbacks: ${[...STANDARD_REACTIONS].join(', ')}.`,
210
+ 'private_api_required',
211
+ { suggestion: 'Enable the bridge with "imsg launch" (requires SIP disabled).' },
212
+ )
213
+ }
214
+ const result = await this.runner(this.binaryPath, [
215
+ 'react',
216
+ '--chat-id',
217
+ String(chatId),
218
+ '--reaction',
219
+ reaction,
220
+ '--json',
221
+ ])
222
+ if (result.code !== 0) {
223
+ const detail = result.stderr.trim() || result.stdout.trim() || 'Reaction failed.'
224
+ throw classifyImsgFailure(detail, 'send_failed')
225
+ }
226
+ }
227
+
228
+ async watch(
229
+ onMessage: (msg: IMessageMessageSummary) => void,
230
+ options: { chatId?: number; sinceRowId?: number; onError?: (msg: string) => void } = {},
231
+ ): Promise<() => Promise<void>> {
232
+ const params: Record<string, unknown> = { include_reactions: false }
233
+ if (options.chatId !== undefined) params.chat_id = options.chatId
234
+ if (options.sinceRowId !== undefined) params.since_rowid = options.sinceRowId
235
+
236
+ const subscription = await this.rpc.subscribe(
237
+ params,
238
+ (raw) => onMessage(summarizeMessage(raw as ImsgMessage)),
239
+ options.onError,
240
+ )
241
+ return async () => {
242
+ await this.rpc.unsubscribe(subscription)
243
+ }
244
+ }
245
+
246
+ async getProfile(): Promise<IMessageStatus> {
247
+ return this.getStatus()
248
+ }
249
+
250
+ async close(): Promise<void> {
251
+ this.rpc.close()
252
+ }
253
+ }
@@ -0,0 +1,142 @@
1
+ import { Command } from 'commander'
2
+
3
+ import { handleError } from '@/shared/utils/error-handler'
4
+ import { formatOutput } from '@/shared/utils/output'
5
+
6
+ import { IMessageCredentialManager } from '../credential-manager'
7
+ import { createAccountId } from '../types'
8
+
9
+ async function guard(fn: () => Promise<void>): Promise<void> {
10
+ try {
11
+ await fn()
12
+ } catch (error) {
13
+ handleError(error as Error)
14
+ }
15
+ }
16
+
17
+ interface SetOptions {
18
+ bin?: string
19
+ region?: string
20
+ account?: string
21
+ label?: string
22
+ current?: boolean
23
+ pretty?: boolean
24
+ _manager?: IMessageCredentialManager
25
+ }
26
+
27
+ async function runSet(options: SetOptions): Promise<void> {
28
+ const manager = options._manager ?? new IMessageCredentialManager()
29
+ const now = new Date().toISOString()
30
+ const accountId = createAccountId(options.account ?? options.label ?? options.bin ?? 'default')
31
+
32
+ await manager.setAccount({
33
+ account_id: accountId,
34
+ provider: 'imsg',
35
+ label: options.label,
36
+ binary_path: options.bin,
37
+ region: options.region,
38
+ created_at: now,
39
+ updated_at: now,
40
+ })
41
+ if (options.current) await manager.setCurrent(accountId)
42
+
43
+ console.log(
44
+ formatOutput({ success: true, account_id: accountId, binary_path: options.bin ?? 'imsg' }, options.pretty),
45
+ )
46
+ process.exit(0)
47
+ }
48
+
49
+ async function runList(options: { pretty?: boolean; _manager?: IMessageCredentialManager }): Promise<void> {
50
+ const manager = options._manager ?? new IMessageCredentialManager()
51
+ const accounts = await manager.listAccounts()
52
+ console.log(
53
+ formatOutput(
54
+ accounts.map((a) => ({
55
+ account_id: a.account_id,
56
+ label: a.label,
57
+ binary_path: a.binary_path ?? 'imsg',
58
+ region: a.region,
59
+ is_current: a.is_current,
60
+ })),
61
+ options.pretty,
62
+ ),
63
+ )
64
+ process.exit(0)
65
+ }
66
+
67
+ async function runUse(
68
+ accountId: string,
69
+ options: { pretty?: boolean; _manager?: IMessageCredentialManager },
70
+ ): Promise<void> {
71
+ const manager = options._manager ?? new IMessageCredentialManager()
72
+ const found = await manager.setCurrent(accountId)
73
+ if (!found) {
74
+ console.log(formatOutput({ error: `Account "${accountId}" not found. Run "auth list".` }, options.pretty))
75
+ process.exit(1)
76
+ }
77
+ console.log(formatOutput({ success: true, account_id: createAccountId(accountId) }, options.pretty))
78
+ process.exit(0)
79
+ }
80
+
81
+ async function runRemove(
82
+ accountId: string,
83
+ options: { pretty?: boolean; _manager?: IMessageCredentialManager },
84
+ ): Promise<void> {
85
+ const manager = options._manager ?? new IMessageCredentialManager()
86
+ const removed = await manager.removeAccount(accountId)
87
+ if (!removed) {
88
+ console.log(formatOutput({ error: `Account "${accountId}" not found. Run "auth list".` }, options.pretty))
89
+ process.exit(1)
90
+ }
91
+ console.log(formatOutput({ success: true }, options.pretty))
92
+ process.exit(0)
93
+ }
94
+
95
+ async function runLogout(options: { pretty?: boolean; _manager?: IMessageCredentialManager }): Promise<void> {
96
+ const manager = options._manager ?? new IMessageCredentialManager()
97
+ await manager.clearCredentials()
98
+ console.log(formatOutput({ success: true }, options.pretty))
99
+ process.exit(0)
100
+ }
101
+
102
+ export const authCommand = new Command('auth')
103
+ .description('Account configuration commands')
104
+ .addCommand(
105
+ new Command('set')
106
+ .description('Configure an iMessage account (imsg binary path / region)')
107
+ .option('--bin <path>', 'Path to the imsg binary (default: imsg on PATH)')
108
+ .option('--region <code>', 'Default region for local-format phone numbers (e.g. US)')
109
+ .option('--account <id>', 'Account id/alias')
110
+ .option('--label <label>', 'Human-friendly label')
111
+ .option('--current', 'Set as the active account')
112
+ .option('--pretty', 'Pretty print JSON output')
113
+ .action(async (opts: SetOptions) => guard(() => runSet(opts))),
114
+ )
115
+ .addCommand(
116
+ new Command('list')
117
+ .description('List configured accounts')
118
+ .option('--pretty', 'Pretty print JSON output')
119
+ .action(async (opts: { pretty?: boolean }) => guard(() => runList(opts))),
120
+ )
121
+ .addCommand(
122
+ new Command('use')
123
+ .description('Switch the active account')
124
+ .argument('<account-id>', 'Account id')
125
+ .option('--pretty', 'Pretty print JSON output')
126
+ .action(async (accountId: string, opts: { pretty?: boolean }) => guard(() => runUse(accountId, opts))),
127
+ )
128
+ .addCommand(
129
+ new Command('remove')
130
+ .description('Remove a configured account')
131
+ .argument('<account-id>', 'Account id')
132
+ .option('--pretty', 'Pretty print JSON output')
133
+ .action(async (accountId: string, opts: { pretty?: boolean }) => guard(() => runRemove(accountId, opts))),
134
+ )
135
+ .addCommand(
136
+ new Command('logout')
137
+ .description('Clear all stored accounts')
138
+ .option('--pretty', 'Pretty print JSON output')
139
+ .action(async (opts: { pretty?: boolean }) => guard(() => runLogout(opts))),
140
+ )
141
+
142
+ export { runSet, runList, runUse, runRemove, runLogout }
@@ -0,0 +1,51 @@
1
+ import { Command } from 'commander'
2
+
3
+ import { handleError } from '@/shared/utils/error-handler'
4
+ import { formatOutput } from '@/shared/utils/output'
5
+
6
+ import { parseLimitOption, withImsgClient } from './shared'
7
+
8
+ async function listAction(options: { account?: string; pretty?: boolean; limit?: string }): Promise<void> {
9
+ try {
10
+ const limit = parseLimitOption(options.limit, 25)
11
+ const chats = await withImsgClient(options, (client) => client.listChats(limit))
12
+ console.log(formatOutput(chats, options.pretty))
13
+ process.exit(0)
14
+ } catch (error) {
15
+ handleError(error as Error)
16
+ }
17
+ }
18
+
19
+ async function searchAction(
20
+ query: string,
21
+ options: { account?: string; pretty?: boolean; limit?: string },
22
+ ): Promise<void> {
23
+ try {
24
+ const limit = parseLimitOption(options.limit, 25)
25
+ const chats = await withImsgClient(options, (client) => client.searchChats(query, limit))
26
+ console.log(formatOutput(chats, options.pretty))
27
+ process.exit(0)
28
+ } catch (error) {
29
+ handleError(error as Error)
30
+ }
31
+ }
32
+
33
+ export const chatCommand = new Command('chat')
34
+ .description('iMessage chat commands')
35
+ .addCommand(
36
+ new Command('list')
37
+ .description('List chats')
38
+ .option('--limit <n>', 'Number of chats to fetch', '25')
39
+ .option('--account <id>', 'Use a specific iMessage account')
40
+ .option('--pretty', 'Pretty print JSON output')
41
+ .action(listAction),
42
+ )
43
+ .addCommand(
44
+ new Command('search')
45
+ .description('Search chats by name or identifier')
46
+ .argument('<query>', 'Search query')
47
+ .option('--limit <n>', 'Number of results to return', '25')
48
+ .option('--account <id>', 'Use a specific iMessage account')
49
+ .option('--pretty', 'Pretty print JSON output')
50
+ .action(searchAction),
51
+ )
@@ -0,0 +1,80 @@
1
+ import { afterEach, describe, expect, it } from 'bun:test'
2
+ import { join } from 'node:path'
3
+
4
+ import { runDoctor } from './doctor'
5
+
6
+ const STUB = join(import.meta.dir, '..', 'test-stub-imsg.mjs')
7
+ const saved: Record<string, string | undefined> = {}
8
+
9
+ function setMode(mode: string, bin = STUB): void {
10
+ saved.IMSG_STUB_MODE = process.env.IMSG_STUB_MODE
11
+ saved.AGENT_IMESSAGE_BIN = process.env.AGENT_IMESSAGE_BIN
12
+ process.env.IMSG_STUB_MODE = mode
13
+ process.env.AGENT_IMESSAGE_BIN = bin
14
+ }
15
+
16
+ afterEach(() => {
17
+ for (const [k, v] of Object.entries(saved)) {
18
+ if (v === undefined) delete process.env[k]
19
+ else process.env[k] = v
20
+ }
21
+ })
22
+
23
+ describe('runDoctor', () => {
24
+ it('reports imsg_not_found when the binary is missing', async () => {
25
+ setMode('ok', '/nonexistent/imsg-xyz')
26
+ const report = await runDoctor({})
27
+ expect(report.ok).toBe(false)
28
+ expect(report.code).toBe('imsg_not_found')
29
+ expect(report.suggestion).toContain('brew install')
30
+ })
31
+
32
+ it('reports full_disk_access denied with Settings guidance', async () => {
33
+ setMode('fda_denied')
34
+ const report = await runDoctor({})
35
+ expect(report.ok).toBe(false)
36
+ expect(report.full_disk_access).toBe('denied')
37
+ expect(report.code).toBe('full_disk_access')
38
+ expect(report.suggestion).toContain('Full Disk Access')
39
+ })
40
+
41
+ it('reports healthy with version, FDA ok, and bridge note', async () => {
42
+ setMode('ok')
43
+ const report = await runDoctor({})
44
+ expect(report.ok).toBe(true)
45
+ expect(report.imsg).toBe('found')
46
+ expect(report.imsg_version).toBe('0.11.1')
47
+ expect(report.full_disk_access).toBe('ok')
48
+ expect(report.bridge).toBe('disabled')
49
+ expect(report.warnings.some((w) => w.includes('Private API bridge'))).toBe(true)
50
+ })
51
+
52
+ it('does not mislabel a non-FDA connect failure as Full Disk Access denied', async () => {
53
+ setMode('connect_rpc_fail')
54
+ const report = await runDoctor({})
55
+ expect(report.ok).toBe(false)
56
+ expect(report.full_disk_access).toBe('unknown')
57
+ expect(report.code).toBe('rpc_error')
58
+ })
59
+
60
+ it('honors an explicit --bin override (verifies the supplied binary, not the default)', async () => {
61
+ setMode('ok', '/nonexistent/default-imsg')
62
+ const report = await runDoctor({ bin: STUB })
63
+ expect(report.ok).toBe(true)
64
+ expect(report.binary_path).toBe(STUB)
65
+ })
66
+
67
+ it('fails on an invalid --bin even when the default/env binary is valid', async () => {
68
+ setMode('ok', STUB)
69
+ const report = await runDoctor({ bin: '/nonexistent/custom-imsg' })
70
+ expect(report.ok).toBe(false)
71
+ expect(report.code).toBe('imsg_not_found')
72
+ expect(report.binary_path).toBe('/nonexistent/custom-imsg')
73
+ })
74
+
75
+ it('runs a test-chat send when requested', async () => {
76
+ setMode('ok')
77
+ const report = await runDoctor({ testChat: 42 })
78
+ expect(report.test_chat).toBe('sent')
79
+ })
80
+ })
@@ -0,0 +1,139 @@
1
+ import { Command } from 'commander'
2
+
3
+ import { formatOutput } from '@/shared/utils/output'
4
+
5
+ import { ImsgClient } from '../client'
6
+ import { IMessageCredentialManager } from '../credential-manager'
7
+ import { IMessageError } from '../types'
8
+
9
+ export interface DoctorReport {
10
+ ok: boolean
11
+ imsg: 'found' | 'missing'
12
+ imsg_version?: string | null
13
+ binary_path: string
14
+ full_disk_access: 'ok' | 'denied' | 'unknown'
15
+ automation: string
16
+ bridge: 'enabled' | 'disabled'
17
+ test_chat?: string
18
+ warnings: string[]
19
+ error?: string
20
+ code?: string
21
+ suggestion?: string
22
+ }
23
+
24
+ export async function runDoctor(options: {
25
+ account?: string
26
+ bin?: string
27
+ region?: string
28
+ testChat?: number
29
+ client?: ImsgClient
30
+ }): Promise<DoctorReport> {
31
+ const resolved = await new IMessageCredentialManager().resolveAccount(options.account)
32
+ const binaryPath = options.bin ?? resolved?.binary_path ?? process.env.AGENT_IMESSAGE_BIN ?? 'imsg'
33
+ const region = options.region ?? resolved?.region
34
+
35
+ const client = options.client ?? new ImsgClient()
36
+ await client.login({ binaryPath, region })
37
+
38
+ const warnings: string[] = []
39
+
40
+ let version: string | null = null
41
+ let versionError: IMessageError | undefined
42
+ try {
43
+ version = await client.getVersion()
44
+ } catch (error) {
45
+ versionError = error as IMessageError
46
+ }
47
+
48
+ if (version === null) {
49
+ const code = versionError?.code ?? 'imsg_not_found'
50
+ return {
51
+ ok: false,
52
+ imsg: 'missing',
53
+ binary_path: binaryPath,
54
+ full_disk_access: 'unknown',
55
+ automation: 'unknown',
56
+ bridge: 'disabled',
57
+ warnings,
58
+ error: versionError?.message ?? `Could not run "${binaryPath} --version".`,
59
+ code,
60
+ suggestion:
61
+ versionError?.suggestion ??
62
+ (code === 'imsg_not_found' ? 'Install imsg: "brew install steipete/tap/imsg".' : undefined),
63
+ }
64
+ }
65
+
66
+ let connectError: IMessageError | undefined
67
+ try {
68
+ await client.connect()
69
+ } catch (error) {
70
+ connectError = error as IMessageError
71
+ }
72
+
73
+ if (connectError) {
74
+ await client.close()
75
+ const isFda = connectError.code === 'full_disk_access'
76
+ return {
77
+ ok: false,
78
+ imsg: 'found',
79
+ imsg_version: version,
80
+ binary_path: binaryPath,
81
+ full_disk_access: isFda ? 'denied' : 'unknown',
82
+ automation: 'unknown',
83
+ bridge: 'disabled',
84
+ warnings,
85
+ error: connectError.message,
86
+ code: connectError.code,
87
+ suggestion:
88
+ connectError.suggestion ??
89
+ (isFda
90
+ ? 'Grant Full Disk Access to the app/terminal launching agent-messenger (System Settings → Privacy & Security → Full Disk Access). macOS grants to the parent process.'
91
+ : undefined),
92
+ }
93
+ }
94
+
95
+ warnings.push(
96
+ 'Automation (Messages) is verified on first send. If sends fail, grant Privacy → Automation → Messages.',
97
+ )
98
+ warnings.push(
99
+ 'Private API bridge disabled — send/read/watch/standard tapbacks work; typing/edit/group mgmt need "imsg launch" (SIP off).',
100
+ )
101
+
102
+ let testChatResult: string | undefined
103
+ if (options.testChat !== undefined) {
104
+ try {
105
+ await client.sendMessage({ chatId: options.testChat }, 'agent-imessage doctor test message')
106
+ testChatResult = 'sent'
107
+ } catch (error) {
108
+ testChatResult = `failed: ${(error as Error).message}`
109
+ }
110
+ }
111
+
112
+ await client.close()
113
+
114
+ return {
115
+ ok: true,
116
+ imsg: 'found',
117
+ imsg_version: version,
118
+ binary_path: binaryPath,
119
+ full_disk_access: 'ok',
120
+ automation: 'unknown',
121
+ bridge: 'disabled',
122
+ test_chat: testChatResult,
123
+ warnings,
124
+ }
125
+ }
126
+
127
+ export const doctorCommand = new Command('doctor')
128
+ .description('Diagnose imsg availability and macOS permissions')
129
+ .option('--account <id>', 'Check a specific account (default: current)')
130
+ .option('--bin <path>', 'Check a specific imsg binary path')
131
+ .option('--region <code>', 'Default region for local-format phone numbers')
132
+ .option('--test-chat <chatId>', 'Send a test message to this chat id')
133
+ .option('--pretty', 'Pretty print JSON output')
134
+ .action(async (opts: { account?: string; bin?: string; region?: string; testChat?: string; pretty?: boolean }) => {
135
+ const testChat = opts.testChat && /^\d+$/.test(opts.testChat) ? Number.parseInt(opts.testChat, 10) : undefined
136
+ const report = await runDoctor({ account: opts.account, bin: opts.bin, region: opts.region, testChat })
137
+ console.log(formatOutput(report, opts.pretty))
138
+ process.exit(report.ok ? 0 : 1)
139
+ })
@@ -0,0 +1,6 @@
1
+ export { authCommand } from './auth'
2
+ export { chatCommand } from './chat'
3
+ export { doctorCommand } from './doctor'
4
+ export { messageCommand } from './message'
5
+ export { setupCommand } from './setup'
6
+ export { whoamiCommand } from './whoami'
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { IMessageError } from '../types'
4
+ import { parseRowId } from './message'
5
+
6
+ describe('parseRowId', () => {
7
+ it('parses a non-negative integer', () => {
8
+ expect(parseRowId('9000')).toBe(9000)
9
+ expect(parseRowId('0')).toBe(0)
10
+ })
11
+
12
+ it('returns undefined when absent', () => {
13
+ expect(parseRowId(undefined)).toBeUndefined()
14
+ })
15
+
16
+ it('rejects non-integers', () => {
17
+ expect(() => parseRowId('abc')).toThrow(IMessageError)
18
+ expect(() => parseRowId('-5')).toThrow(IMessageError)
19
+ })
20
+ })