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,151 @@
1
+ import { Command } from 'commander'
2
+
3
+ import { handleError } from '@/shared/utils/error-handler'
4
+ import { formatOutput } from '@/shared/utils/output'
5
+
6
+ import { IMessageError, type IMessageMessageSummary } from '../types'
7
+ import { parseLimitOption, resolveChatId, resolveChatTarget, withImsgClient } from './shared'
8
+
9
+ async function listAction(
10
+ chat: string,
11
+ options: { account?: string; pretty?: boolean; limit?: string; start?: string },
12
+ ): Promise<void> {
13
+ try {
14
+ const limit = parseLimitOption(options.limit, 25)
15
+ const messages = await withImsgClient(options, async (client) => {
16
+ const chatId = await resolveChatId(client, chat)
17
+ return client.getMessages(chatId, limit, options.start)
18
+ })
19
+ console.log(formatOutput(messages, options.pretty))
20
+ process.exit(0)
21
+ } catch (error) {
22
+ handleError(error as Error)
23
+ }
24
+ }
25
+
26
+ async function sendAction(chat: string, text: string, options: { account?: string; pretty?: boolean }): Promise<void> {
27
+ try {
28
+ const message = await withImsgClient(options, (client) => client.sendMessage(resolveChatTarget(chat), text))
29
+ console.log(formatOutput(message, options.pretty))
30
+ process.exit(0)
31
+ } catch (error) {
32
+ handleError(error as Error)
33
+ }
34
+ }
35
+
36
+ async function reactAction(
37
+ chat: string,
38
+ reaction: string,
39
+ options: { account?: string; pretty?: boolean },
40
+ ): Promise<void> {
41
+ try {
42
+ await withImsgClient(options, async (client) => {
43
+ const chatId = await resolveChatId(client, chat)
44
+ await client.sendReaction(chatId, reaction)
45
+ })
46
+ console.log(formatOutput({ success: true, chat, reaction }, options.pretty))
47
+ process.exit(0)
48
+ } catch (error) {
49
+ handleError(error as Error)
50
+ }
51
+ }
52
+
53
+ function parseRowId(raw: string | undefined): number | undefined {
54
+ if (raw === undefined) return undefined
55
+ if (!/^\d+$/.test(raw.trim())) {
56
+ throw new IMessageError('--since-rowid must be a non-negative integer.', 'invalid_limit')
57
+ }
58
+ return Number.parseInt(raw.trim(), 10)
59
+ }
60
+
61
+ async function watchAction(options: {
62
+ account?: string
63
+ pretty?: boolean
64
+ chat?: string
65
+ sinceRowid?: string
66
+ jsonl?: boolean
67
+ }): Promise<void> {
68
+ try {
69
+ const sinceRowId = parseRowId(options.sinceRowid)
70
+ await withImsgClient(options, async (client) => {
71
+ const chatId = options.chat && options.chat !== 'all' ? await resolveChatId(client, options.chat) : undefined
72
+ const seen = new Set<string>()
73
+ const SEEN_CAP = 2000
74
+ let running = true
75
+
76
+ const emit = (msg: IMessageMessageSummary): void => {
77
+ if (msg.guid) {
78
+ if (seen.has(msg.guid)) return
79
+ seen.add(msg.guid)
80
+ if (seen.size > SEEN_CAP) {
81
+ const oldest = seen.values().next().value
82
+ if (oldest !== undefined) seen.delete(oldest)
83
+ }
84
+ }
85
+ console.log(options.jsonl ? JSON.stringify(msg) : formatOutput(msg, options.pretty))
86
+ }
87
+
88
+ const stop = await client.watch(emit, {
89
+ chatId,
90
+ sinceRowId,
91
+ onError: (m) => console.error(JSON.stringify({ warning: m })),
92
+ })
93
+
94
+ await new Promise<void>((resolve) => {
95
+ const shutdown = (): void => {
96
+ if (!running) return
97
+ running = false
98
+ void stop().finally(resolve)
99
+ }
100
+ process.on('SIGINT', shutdown)
101
+ process.on('SIGTERM', shutdown)
102
+ })
103
+ })
104
+ process.exit(0)
105
+ } catch (error) {
106
+ handleError(error as Error)
107
+ }
108
+ }
109
+
110
+ export const messageCommand = new Command('message')
111
+ .description('iMessage message commands')
112
+ .addCommand(
113
+ new Command('list')
114
+ .description('List messages from a chat')
115
+ .argument('<chat>', 'Chat id, guid, or identifier')
116
+ .option('--limit <n>', 'Number of messages to fetch', '25')
117
+ .option('--start <iso>', 'Only messages on/after this ISO timestamp')
118
+ .option('--account <id>', 'Use a specific iMessage account')
119
+ .option('--pretty', 'Pretty print JSON output')
120
+ .action(listAction),
121
+ )
122
+ .addCommand(
123
+ new Command('send')
124
+ .description('Send a text message to a chat or recipient')
125
+ .argument('<chat>', 'Chat id/guid, or a phone/email recipient')
126
+ .argument('<text>', 'Message text')
127
+ .option('--account <id>', 'Use a specific iMessage account')
128
+ .option('--pretty', 'Pretty print JSON output')
129
+ .action(sendAction),
130
+ )
131
+ .addCommand(
132
+ new Command('react')
133
+ .description('React to the most recent incoming message in a chat (standard tapbacks)')
134
+ .argument('<chat>', 'Chat id, guid, or identifier')
135
+ .argument('<reaction>', 'love | like | dislike | laugh | emphasis | question')
136
+ .option('--account <id>', 'Use a specific iMessage account')
137
+ .option('--pretty', 'Pretty print JSON output')
138
+ .action(reactAction),
139
+ )
140
+ .addCommand(
141
+ new Command('watch')
142
+ .description('Stream new messages via imsg (resumable with --since-rowid)')
143
+ .option('--chat <ref|all>', 'Watch a specific chat or "all"', 'all')
144
+ .option('--since-rowid <n>', 'Replay messages after this message rowid, then stream live')
145
+ .option('--jsonl', 'Emit one JSON object per line')
146
+ .option('--account <id>', 'Use a specific iMessage account')
147
+ .option('--pretty', 'Pretty print JSON output')
148
+ .action(watchAction),
149
+ )
150
+
151
+ export { parseRowId }
@@ -0,0 +1,52 @@
1
+ import { afterEach, describe, expect, it } from 'bun:test'
2
+ import { existsSync, rmSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ import { IMessageCredentialManager } from '../credential-manager'
6
+ import { performSetup } from './setup'
7
+
8
+ const STUB = join(import.meta.dir, '..', 'test-stub-imsg.mjs')
9
+ const testDirs: string[] = []
10
+ const saved: Record<string, string | undefined> = {}
11
+
12
+ function freshManager(): { manager: IMessageCredentialManager; dir: string } {
13
+ const dir = join(import.meta.dir, `.test-setup-${Date.now()}-${Math.random().toString(36).slice(2)}`)
14
+ testDirs.push(dir)
15
+ return { manager: new IMessageCredentialManager(dir), dir }
16
+ }
17
+
18
+ function setEnv(bin?: string): void {
19
+ saved.IMSG_STUB_MODE = process.env.IMSG_STUB_MODE
20
+ saved.AGENT_IMESSAGE_BIN = process.env.AGENT_IMESSAGE_BIN
21
+ process.env.IMSG_STUB_MODE = 'ok'
22
+ if (bin === undefined) delete process.env.AGENT_IMESSAGE_BIN
23
+ else process.env.AGENT_IMESSAGE_BIN = bin
24
+ }
25
+
26
+ afterEach(() => {
27
+ for (const [k, v] of Object.entries(saved)) {
28
+ if (v === undefined) delete process.env[k]
29
+ else process.env[k] = v
30
+ }
31
+ for (const dir of testDirs) rmSync(dir, { recursive: true, force: true })
32
+ })
33
+
34
+ describe('performSetup', () => {
35
+ it('saves the account when the supplied --bin verifies', async () => {
36
+ setEnv()
37
+ const { manager, dir } = freshManager()
38
+ const result = await performSetup({ bin: STUB, manager })
39
+ expect(result.ok).toBe(true)
40
+ expect(existsSync(join(dir, 'imessage-credentials.json'))).toBe(true)
41
+ expect((await manager.resolveAccount())?.binary_path).toBe(STUB)
42
+ })
43
+
44
+ it('does NOT persist an account when an invalid --bin fails, even if the default binary is valid', async () => {
45
+ setEnv(STUB)
46
+ const { manager, dir } = freshManager()
47
+ const result = await performSetup({ bin: '/nonexistent/custom-imsg', manager })
48
+ expect(result.ok).toBe(false)
49
+ expect(result.report.code).toBe('imsg_not_found')
50
+ expect(existsSync(join(dir, 'imessage-credentials.json'))).toBe(false)
51
+ })
52
+ })
@@ -0,0 +1,76 @@
1
+ import { Command } from 'commander'
2
+
3
+ import { formatOutput } from '@/shared/utils/output'
4
+
5
+ import { IMessageCredentialManager } from '../credential-manager'
6
+ import { createAccountId } from '../types'
7
+ import { runDoctor } from './doctor'
8
+
9
+ const CHECKLIST = `iMessage runs on THIS Mac through the "imsg" tool. One-time setup:
10
+ 1. Install imsg: brew install steipete/tap/imsg
11
+ 2. Sign Messages.app into your Apple ID (a dedicated Apple ID is recommended).
12
+ 3. Grant Full Disk Access to the app/terminal that launches agent-messenger
13
+ (System Settings > Privacy & Security > Full Disk Access). macOS grants to the parent process.
14
+ 4. Grant Automation > Messages when first prompted (needed to send).
15
+
16
+ Basic send / read / watch / standard tapbacks need NO SIP changes.
17
+ Typing, edit/unsend, group management need "imsg launch" (SIP disabled) — a later tier.`
18
+
19
+ export async function performSetup(options: {
20
+ bin?: string
21
+ region?: string
22
+ manager?: IMessageCredentialManager
23
+ }): Promise<{ ok: boolean; report: Awaited<ReturnType<typeof runDoctor>>; account_id?: string }> {
24
+ const report = await runDoctor({ bin: options.bin, region: options.region })
25
+ if (!report.ok) {
26
+ return { ok: false, report }
27
+ }
28
+
29
+ const manager = options.manager ?? new IMessageCredentialManager()
30
+ const now = new Date().toISOString()
31
+ const accountId = createAccountId(options.bin ?? 'default')
32
+ await manager.setAccount({
33
+ account_id: accountId,
34
+ provider: 'imsg',
35
+ binary_path: options.bin,
36
+ region: options.region,
37
+ created_at: now,
38
+ updated_at: now,
39
+ })
40
+ await manager.setCurrent(accountId)
41
+
42
+ return { ok: true, report, account_id: accountId }
43
+ }
44
+
45
+ async function runSetup(options: { bin?: string; region?: string; pretty?: boolean }): Promise<void> {
46
+ console.error(CHECKLIST)
47
+ console.error('')
48
+
49
+ const result = await performSetup({ bin: options.bin, region: options.region })
50
+ if (!result.ok) {
51
+ console.log(formatOutput(result.report, options.pretty))
52
+ process.exit(1)
53
+ }
54
+
55
+ console.log(
56
+ formatOutput(
57
+ {
58
+ success: true,
59
+ account_id: result.account_id,
60
+ imsg_version: result.report.imsg_version,
61
+ full_disk_access: result.report.full_disk_access,
62
+ },
63
+ options.pretty,
64
+ ),
65
+ )
66
+ process.exit(0)
67
+ }
68
+
69
+ export const setupCommand = new Command('setup')
70
+ .description('Guided setup: verify imsg + permissions and save an account')
71
+ .option('--bin <path>', 'Path to the imsg binary (default: imsg on PATH)')
72
+ .option('--region <code>', 'Default region for local-format phone numbers')
73
+ .option('--pretty', 'Pretty print JSON output')
74
+ .action(async (opts: { bin?: string; region?: string; pretty?: boolean }) => runSetup(opts))
75
+
76
+ export { runSetup }
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { resolveChatTarget } from './shared'
4
+
5
+ describe('resolveChatTarget', () => {
6
+ it('treats a digits-only ref as a chat id (rowid)', () => {
7
+ expect(resolveChatTarget('42')).toEqual({ chatId: 42 })
8
+ })
9
+
10
+ it('passes a portable guid/identifier through as chat_guid', () => {
11
+ expect(resolveChatTarget('iMessage;-;+15551234567')).toEqual({ chatGuid: 'iMessage;-;+15551234567' })
12
+ expect(resolveChatTarget('iMessage;+;chat99')).toEqual({ chatGuid: 'iMessage;+;chat99' })
13
+ })
14
+
15
+ it('treats a non-numeric, non-guid ref as a recipient handle', () => {
16
+ expect(resolveChatTarget('+15551234567')).toEqual({ to: '+15551234567' })
17
+ expect(resolveChatTarget('jane@icloud.com')).toEqual({ to: 'jane@icloud.com' })
18
+ })
19
+ })
@@ -0,0 +1,69 @@
1
+ import { formatOutput } from '@/shared/utils/output'
2
+
3
+ import { ImsgClient, type SendTarget } from '../client'
4
+ import { IMessageCredentialManager } from '../credential-manager'
5
+ import { IMessageError } from '../types'
6
+
7
+ export interface AccountOption {
8
+ account?: string
9
+ pretty?: boolean
10
+ }
11
+
12
+ export function parseLimitOption(rawLimit: string | undefined, defaultValue: number, maxValue = 100): number {
13
+ const trimmed = (rawLimit ?? `${defaultValue}`).trim()
14
+ if (!/^\d+$/.test(trimmed)) {
15
+ throw new IMessageError(`--limit must be an integer between 1 and ${maxValue}.`, 'invalid_limit')
16
+ }
17
+ const parsed = Number.parseInt(trimmed, 10)
18
+ if (!Number.isInteger(parsed) || parsed <= 0 || parsed > maxValue) {
19
+ throw new IMessageError(`--limit must be an integer between 1 and ${maxValue}.`, 'invalid_limit')
20
+ }
21
+ return parsed
22
+ }
23
+
24
+ export function resolveChatTarget(chatRef: string): SendTarget {
25
+ if (/^\d+$/.test(chatRef)) return { chatId: Number.parseInt(chatRef, 10) }
26
+ if (chatRef.includes(';')) return { chatGuid: chatRef }
27
+ return { to: chatRef }
28
+ }
29
+
30
+ const CHAT_RESOLVE_LIMIT = 1000
31
+
32
+ export async function resolveChatId(client: ImsgClient, chatRef: string): Promise<number> {
33
+ if (/^\d+$/.test(chatRef)) return Number.parseInt(chatRef, 10)
34
+
35
+ if (chatRef.includes(';')) {
36
+ const chats = await client.listChats(CHAT_RESOLVE_LIMIT)
37
+ const match = chats.find((c) => c.guid === chatRef || c.identifier === chatRef)
38
+ if (match) return match.id
39
+ }
40
+
41
+ throw new IMessageError(
42
+ `Could not resolve "${chatRef}" to a chat id (searched the ${CHAT_RESOLVE_LIMIT} most recent chats). Use a numeric chat id from "agent-imessage chat list".`,
43
+ 'chat_not_found',
44
+ )
45
+ }
46
+
47
+ export async function withImsgClient<T>(options: AccountOption, fn: (client: ImsgClient) => Promise<T>): Promise<T> {
48
+ const resolved = await new IMessageCredentialManager().resolveAccount(options.account)
49
+ if (!resolved) {
50
+ console.log(
51
+ formatOutput(
52
+ {
53
+ error: 'Not configured. Run "agent-imessage setup" first.',
54
+ code: 'not_authenticated',
55
+ },
56
+ options.pretty,
57
+ ),
58
+ )
59
+ process.exit(1)
60
+ }
61
+
62
+ const client = await new ImsgClient().login({ binaryPath: resolved.binary_path, region: resolved.region })
63
+ try {
64
+ await client.connect()
65
+ return await fn(client)
66
+ } finally {
67
+ await client.close()
68
+ }
69
+ }
@@ -0,0 +1,22 @@
1
+ import { Command } from 'commander'
2
+
3
+ import { handleError } from '@/shared/utils/error-handler'
4
+ import { formatOutput } from '@/shared/utils/output'
5
+
6
+ import { withImsgClient } from './shared'
7
+
8
+ async function whoamiAction(options: { account?: string; pretty?: boolean }): Promise<void> {
9
+ try {
10
+ const status = await withImsgClient(options, (client) => client.getStatus())
11
+ console.log(formatOutput(status, options.pretty))
12
+ process.exit(0)
13
+ } catch (error) {
14
+ handleError(error as Error)
15
+ }
16
+ }
17
+
18
+ export const whoamiCommand = new Command('whoami')
19
+ .description('Show the active iMessage account and imsg status')
20
+ .option('--account <id>', 'Use a specific iMessage account')
21
+ .option('--pretty', 'Pretty print JSON output')
22
+ .action(whoamiAction)
@@ -0,0 +1,122 @@
1
+ import { afterEach, describe, expect, it } from 'bun:test'
2
+ import { existsSync, mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ import { IMessageCredentialManager } from './credential-manager'
6
+ import type { IMessageAccount } from './types'
7
+
8
+ const testDirs: string[] = []
9
+ const saved: Record<string, string | undefined> = {}
10
+
11
+ function setup(): IMessageCredentialManager {
12
+ const dir = join(import.meta.dir, `.test-imsg-${Date.now()}-${Math.random().toString(36).slice(2)}`)
13
+ testDirs.push(dir)
14
+ return new IMessageCredentialManager(dir)
15
+ }
16
+
17
+ function makeAccount(overrides?: Partial<IMessageAccount>): IMessageAccount {
18
+ const now = new Date().toISOString()
19
+ return {
20
+ account_id: 'home',
21
+ provider: 'imsg',
22
+ binary_path: '/opt/homebrew/bin/imsg',
23
+ region: 'US',
24
+ created_at: now,
25
+ updated_at: now,
26
+ ...overrides,
27
+ }
28
+ }
29
+
30
+ function clearEnv(): void {
31
+ for (const k of ['AGENT_IMESSAGE_BIN', 'AGENT_IMESSAGE_REGION']) {
32
+ saved[k] = process.env[k]
33
+ delete process.env[k]
34
+ }
35
+ }
36
+
37
+ afterEach(() => {
38
+ for (const [k, v] of Object.entries(saved)) {
39
+ if (v === undefined) delete process.env[k]
40
+ else process.env[k] = v
41
+ }
42
+ for (const dir of testDirs) rmSync(dir, { recursive: true, force: true })
43
+ })
44
+
45
+ describe('IMessageCredentialManager', () => {
46
+ it('persists provider-aware schema with enforced 0600', async () => {
47
+ clearEnv()
48
+ const manager = setup()
49
+ await manager.setAccount(makeAccount())
50
+ const path = join(testDirs[testDirs.length - 1]!, 'imessage-credentials.json')
51
+ expect(existsSync(path)).toBe(true)
52
+ expect(statSync(path).mode & 0o777).toBe(0o600)
53
+ const config = await manager.loadConfig()
54
+ expect(config.accounts.home?.provider).toBe('imsg')
55
+ })
56
+
57
+ it('resolveAccount: env AGENT_IMESSAGE_BIN/REGION override stored, not persisted', async () => {
58
+ clearEnv()
59
+ const manager = setup()
60
+ await manager.setAccount(makeAccount({ binary_path: '/stored/imsg', region: 'GB' }))
61
+
62
+ expect(await manager.resolveAccount()).toEqual({ binary_path: '/stored/imsg', region: 'GB' })
63
+
64
+ process.env.AGENT_IMESSAGE_BIN = '/env/imsg'
65
+ process.env.AGENT_IMESSAGE_REGION = 'US'
66
+ expect(await manager.resolveAccount()).toEqual({ binary_path: '/env/imsg', region: 'US' })
67
+
68
+ const fresh = await manager.getAccount('home')
69
+ expect(fresh?.binary_path).toBe('/stored/imsg')
70
+ })
71
+
72
+ it('resolveAccount returns env-only when no account but AGENT_IMESSAGE_BIN set', async () => {
73
+ clearEnv()
74
+ const manager = setup()
75
+ expect(await manager.resolveAccount()).toBeNull()
76
+ process.env.AGENT_IMESSAGE_BIN = '/env/imsg'
77
+ expect(await manager.resolveAccount()).toEqual({ binary_path: '/env/imsg', region: undefined })
78
+ })
79
+
80
+ it('coerces malformed config and drops invalid accounts', async () => {
81
+ clearEnv()
82
+ const dir = join(import.meta.dir, `.test-imsg-bad-${Date.now()}`)
83
+ testDirs.push(dir)
84
+ mkdirSync(dir, { recursive: true })
85
+ writeFileSync(
86
+ join(dir, 'imessage-credentials.json'),
87
+ JSON.stringify({ current: 'missing', accounts: { good: { account_id: 'good' }, junk: 'x' } }),
88
+ )
89
+ const config = await new IMessageCredentialManager(dir).loadConfig()
90
+ expect(Object.keys(config.accounts)).toEqual(['good'])
91
+ expect(config.current).toBe('good')
92
+ })
93
+
94
+ it('re-indexes accounts by validated account_id when the persisted key differs', async () => {
95
+ clearEnv()
96
+ const dir = join(import.meta.dir, `.test-imsg-reindex-${Date.now()}`)
97
+ testDirs.push(dir)
98
+ mkdirSync(dir, { recursive: true })
99
+ writeFileSync(
100
+ join(dir, 'imessage-credentials.json'),
101
+ JSON.stringify({
102
+ current: 'home',
103
+ accounts: { 'stale-key': { account_id: 'home', server_url: undefined, binary_path: '/x/imsg' } },
104
+ }),
105
+ )
106
+ const manager = new IMessageCredentialManager(dir)
107
+ const config = await manager.loadConfig()
108
+ expect(Object.keys(config.accounts)).toEqual(['home'])
109
+ expect(config.current).toBe('home')
110
+ expect((await manager.getAccount('home'))?.binary_path).toBe('/x/imsg')
111
+ })
112
+
113
+ it('removeAccount reassigns current; setCurrent false for unknown', async () => {
114
+ clearEnv()
115
+ const manager = setup()
116
+ await manager.setAccount(makeAccount({ account_id: 'a' }))
117
+ await manager.setAccount(makeAccount({ account_id: 'b' }))
118
+ expect(await manager.removeAccount('a')).toBe(true)
119
+ expect((await manager.getAccount())?.account_id).toBe('b')
120
+ expect(await manager.setCurrent('ghost')).toBe(false)
121
+ })
122
+ })
@@ -0,0 +1,123 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+
5
+ import { getConfigDir } from '../../shared/utils/config-dir'
6
+ import { createAccountId, type IMessageAccount, type IMessageConfig } from './types'
7
+
8
+ export interface ResolvedAccount {
9
+ binary_path: string
10
+ region?: string
11
+ }
12
+
13
+ function coerceConfig(raw: unknown): IMessageConfig {
14
+ if (typeof raw !== 'object' || raw === null) return { current: null, accounts: {} }
15
+
16
+ const obj = raw as Record<string, unknown>
17
+ const accounts: Record<string, IMessageAccount> = {}
18
+
19
+ if (typeof obj.accounts === 'object' && obj.accounts !== null) {
20
+ for (const value of Object.values(obj.accounts as Record<string, unknown>)) {
21
+ if (typeof value !== 'object' || value === null) continue
22
+ const a = value as Record<string, unknown>
23
+ if (typeof a.account_id !== 'string' || a.account_id.length === 0) continue
24
+ accounts[a.account_id] = {
25
+ account_id: a.account_id,
26
+ provider: 'imsg',
27
+ label: typeof a.label === 'string' ? a.label : undefined,
28
+ binary_path: typeof a.binary_path === 'string' ? a.binary_path : undefined,
29
+ region: typeof a.region === 'string' ? a.region : undefined,
30
+ created_at: typeof a.created_at === 'string' ? a.created_at : new Date().toISOString(),
31
+ updated_at: typeof a.updated_at === 'string' ? a.updated_at : new Date().toISOString(),
32
+ }
33
+ }
34
+ }
35
+
36
+ const current =
37
+ typeof obj.current === 'string' && accounts[obj.current] ? obj.current : (Object.keys(accounts)[0] ?? null)
38
+ return { current, accounts }
39
+ }
40
+
41
+ export class IMessageCredentialManager {
42
+ private configDir: string
43
+ private credentialsPath: string
44
+
45
+ constructor(configDir?: string) {
46
+ this.configDir = configDir ?? getConfigDir()
47
+ this.credentialsPath = join(this.configDir, 'imessage-credentials.json')
48
+ }
49
+
50
+ async loadConfig(): Promise<IMessageConfig> {
51
+ if (!existsSync(this.credentialsPath)) return { current: null, accounts: {} }
52
+ try {
53
+ return coerceConfig(JSON.parse(await readFile(this.credentialsPath, 'utf-8')))
54
+ } catch {
55
+ return { current: null, accounts: {} }
56
+ }
57
+ }
58
+
59
+ async saveConfig(config: IMessageConfig): Promise<void> {
60
+ await mkdir(this.configDir, { recursive: true })
61
+ await writeFile(this.credentialsPath, JSON.stringify(config, null, 2), { mode: 0o600 })
62
+ await chmod(this.credentialsPath, 0o600)
63
+ }
64
+
65
+ async getAccount(accountId?: string): Promise<IMessageAccount | null> {
66
+ const config = await this.loadConfig()
67
+ if (!accountId) return config.current ? (config.accounts[config.current] ?? null) : null
68
+ return config.accounts[accountId] ?? config.accounts[createAccountId(accountId)] ?? null
69
+ }
70
+
71
+ async listAccounts(): Promise<Array<IMessageAccount & { is_current: boolean }>> {
72
+ const config = await this.loadConfig()
73
+ return Object.values(config.accounts).map((account) => ({
74
+ ...account,
75
+ is_current: account.account_id === config.current,
76
+ }))
77
+ }
78
+
79
+ async setAccount(account: IMessageAccount): Promise<void> {
80
+ const config = await this.loadConfig()
81
+ config.accounts[account.account_id] = account
82
+ if (!config.current) config.current = account.account_id
83
+ await this.saveConfig(config)
84
+ }
85
+
86
+ async setCurrent(accountId: string): Promise<boolean> {
87
+ const config = await this.loadConfig()
88
+ const account = config.accounts[accountId] ?? config.accounts[createAccountId(accountId)]
89
+ if (!account) return false
90
+ config.current = account.account_id
91
+ await this.saveConfig(config)
92
+ return true
93
+ }
94
+
95
+ async removeAccount(accountId: string): Promise<boolean> {
96
+ const config = await this.loadConfig()
97
+ const account = config.accounts[accountId] ?? config.accounts[createAccountId(accountId)]
98
+ if (!account) return false
99
+ delete config.accounts[account.account_id]
100
+ if (config.current === account.account_id) {
101
+ config.current = Object.keys(config.accounts)[0] ?? null
102
+ }
103
+ await this.saveConfig(config)
104
+ return true
105
+ }
106
+
107
+ async clearCredentials(): Promise<void> {
108
+ if (existsSync(this.credentialsPath)) await rm(this.credentialsPath, { force: true })
109
+ }
110
+
111
+ async resolveAccount(accountId?: string): Promise<ResolvedAccount | null> {
112
+ const envBin = process.env.AGENT_IMESSAGE_BIN
113
+ const envRegion = process.env.AGENT_IMESSAGE_REGION
114
+
115
+ const account = await this.getAccount(accountId)
116
+ if (!account && !envBin) return null
117
+
118
+ return {
119
+ binary_path: envBin ?? account?.binary_path ?? 'imsg',
120
+ region: envRegion ?? account?.region,
121
+ }
122
+ }
123
+ }
@@ -0,0 +1,18 @@
1
+ import { formatOutput } from '@/shared/utils/output'
2
+
3
+ import { IMessageCredentialManager } from './credential-manager'
4
+
5
+ export async function ensureIMessageAuth(): Promise<void> {
6
+ const resolved = await new IMessageCredentialManager().resolveAccount()
7
+
8
+ if (!resolved) {
9
+ console.log(
10
+ formatOutput({
11
+ error:
12
+ 'Not configured. iMessage runs on this Mac via the "imsg" tool. Run "agent-imessage setup" for a guided walkthrough, or "agent-imessage doctor" to check requirements.',
13
+ code: 'not_authenticated',
14
+ }),
15
+ )
16
+ process.exit(1)
17
+ }
18
+ }
@@ -0,0 +1,27 @@
1
+ import { IMessageError, type IMessageErrorCode } from './types'
2
+
3
+ const FULL_DISK_ACCESS = /full disk access|cannot access messages database|unable to open database/i
4
+ const AUTOMATION_DENIED = /automation|not authorized|not allowed to send/i
5
+ const APPLESCRIPT_SEND = /unjoined empty outgoing row|delivery to the target chat was not confirmed|applescript/i
6
+
7
+ export function classifyImsgFailure(text: string, fallback: IMessageErrorCode = 'rpc_error'): IMessageError {
8
+ if (FULL_DISK_ACCESS.test(text)) {
9
+ return new IMessageError('imsg cannot read the Messages database.', 'full_disk_access', {
10
+ suggestion:
11
+ 'Grant Full Disk Access to the app/terminal that launches agent-messenger (System Settings → Privacy & Security → Full Disk Access).',
12
+ doctorCommand: 'agent-imessage doctor',
13
+ })
14
+ }
15
+ if (AUTOMATION_DENIED.test(text)) {
16
+ return new IMessageError('imsg is not allowed to control Messages.app.', 'automation_denied', {
17
+ suggestion: 'Grant Automation → Messages in System Settings → Privacy & Security.',
18
+ doctorCommand: 'agent-imessage doctor',
19
+ })
20
+ }
21
+ if (APPLESCRIPT_SEND.test(text)) {
22
+ return new IMessageError(text || 'imsg could not send the message.', 'send_failed', {
23
+ doctorCommand: 'agent-imessage doctor',
24
+ })
25
+ }
26
+ return new IMessageError(text || 'imsg request failed.', fallback, { doctorCommand: 'agent-imessage doctor' })
27
+ }