agent-messenger 2.27.2 → 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 (189) 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/line/cli.js +1 -1
  106. package/dist/src/platforms/webex/cli.js +1 -1
  107. package/dist/src/platforms/webexbot/cli.js +1 -1
  108. package/dist/src/platforms/wechatbot/cli.js +1 -1
  109. package/dist/src/platforms/whatsapp/cli.js +1 -1
  110. package/dist/src/platforms/whatsappbot/cli.js +1 -1
  111. package/dist/src/tui/adapters/imessage-adapter.d.ts +21 -0
  112. package/dist/src/tui/adapters/imessage-adapter.d.ts.map +1 -0
  113. package/dist/src/tui/adapters/imessage-adapter.js +110 -0
  114. package/dist/src/tui/adapters/imessage-adapter.js.map +1 -0
  115. package/dist/src/tui/app.d.ts.map +1 -1
  116. package/dist/src/tui/app.js +9 -0
  117. package/dist/src/tui/app.js.map +1 -1
  118. package/docs/content/docs/agent-skills.mdx +1 -1
  119. package/docs/content/docs/cli/imessage.mdx +99 -0
  120. package/docs/content/docs/cli/instagram.mdx +1 -1
  121. package/docs/content/docs/cli/meta.json +1 -0
  122. package/docs/content/docs/index.mdx +9 -6
  123. package/docs/content/docs/quick-start.mdx +2 -0
  124. package/docs/content/docs/sdk/instagram.mdx +90 -5
  125. package/docs/content/docs/tui.mdx +4 -3
  126. package/package.json +10 -2
  127. package/scripts/postbuild.ts +12 -15
  128. package/skills/agent-channeltalk/SKILL.md +1 -1
  129. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  130. package/skills/agent-discord/SKILL.md +1 -1
  131. package/skills/agent-discordbot/SKILL.md +1 -1
  132. package/skills/agent-imessage/SKILL.md +133 -0
  133. package/skills/agent-imessage/references/authentication.md +66 -0
  134. package/skills/agent-imessage/references/permissions.md +31 -0
  135. package/skills/agent-imessage/references/setup.md +49 -0
  136. package/skills/agent-instagram/SKILL.md +72 -2
  137. package/skills/agent-instagram/references/common-patterns.md +36 -0
  138. package/skills/agent-instagram/templates/monitor-chat.sh +5 -0
  139. package/skills/agent-kakaotalk/SKILL.md +1 -1
  140. package/skills/agent-line/SKILL.md +1 -1
  141. package/skills/agent-slack/SKILL.md +1 -1
  142. package/skills/agent-slackbot/SKILL.md +1 -1
  143. package/skills/agent-teams/SKILL.md +1 -1
  144. package/skills/agent-telegram/SKILL.md +1 -1
  145. package/skills/agent-telegrambot/SKILL.md +1 -1
  146. package/skills/agent-webex/SKILL.md +1 -1
  147. package/skills/agent-webexbot/SKILL.md +1 -1
  148. package/skills/agent-wechatbot/SKILL.md +1 -1
  149. package/skills/agent-whatsapp/SKILL.md +1 -1
  150. package/skills/agent-whatsappbot/SKILL.md +1 -1
  151. package/src/cli.ts +4 -0
  152. package/src/platforms/imessage/cli.ts +39 -0
  153. package/src/platforms/imessage/client.test.ts +137 -0
  154. package/src/platforms/imessage/client.ts +253 -0
  155. package/src/platforms/imessage/commands/auth.ts +142 -0
  156. package/src/platforms/imessage/commands/chat.ts +51 -0
  157. package/src/platforms/imessage/commands/doctor.test.ts +80 -0
  158. package/src/platforms/imessage/commands/doctor.ts +139 -0
  159. package/src/platforms/imessage/commands/index.ts +6 -0
  160. package/src/platforms/imessage/commands/message.test.ts +20 -0
  161. package/src/platforms/imessage/commands/message.ts +151 -0
  162. package/src/platforms/imessage/commands/setup.test.ts +52 -0
  163. package/src/platforms/imessage/commands/setup.ts +76 -0
  164. package/src/platforms/imessage/commands/shared.test.ts +19 -0
  165. package/src/platforms/imessage/commands/shared.ts +69 -0
  166. package/src/platforms/imessage/commands/whoami.ts +22 -0
  167. package/src/platforms/imessage/credential-manager.test.ts +122 -0
  168. package/src/platforms/imessage/credential-manager.ts +123 -0
  169. package/src/platforms/imessage/ensure-auth.ts +18 -0
  170. package/src/platforms/imessage/errors.ts +27 -0
  171. package/src/platforms/imessage/index.test.ts +13 -0
  172. package/src/platforms/imessage/index.ts +16 -0
  173. package/src/platforms/imessage/rpc.ts +157 -0
  174. package/src/platforms/imessage/test-stub-imsg.mjs +147 -0
  175. package/src/platforms/imessage/types.test.ts +47 -0
  176. package/src/platforms/imessage/types.ts +95 -0
  177. package/src/platforms/instagram/client.ts +23 -0
  178. package/src/platforms/instagram/hybrid-listener.test.ts +135 -0
  179. package/src/platforms/instagram/hybrid-listener.ts +196 -0
  180. package/src/platforms/instagram/index.ts +10 -0
  181. package/src/platforms/instagram/mqtt/connection.ts +72 -0
  182. package/src/platforms/instagram/mqtt/thrift.test.ts +91 -0
  183. package/src/platforms/instagram/mqtt/thrift.ts +159 -0
  184. package/src/platforms/instagram/mqtt/transport.ts +285 -0
  185. package/src/platforms/instagram/realtime-listener.test.ts +34 -0
  186. package/src/platforms/instagram/realtime-listener.ts +201 -0
  187. package/src/platforms/webexbot/cli.ts +0 -0
  188. package/src/tui/adapters/imessage-adapter.ts +131 -0
  189. package/src/tui/app.ts +13 -0
@@ -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
+ })
@@ -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
+ })