agent-messenger 2.16.0 → 2.18.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 (65) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/dist/package.json +1 -1
  3. package/dist/src/platforms/instagram/commands/auth.d.ts.map +1 -1
  4. package/dist/src/platforms/instagram/commands/auth.js +1 -3
  5. package/dist/src/platforms/instagram/commands/auth.js.map +1 -1
  6. package/dist/src/platforms/kakaotalk/commands/auth.d.ts.map +1 -1
  7. package/dist/src/platforms/kakaotalk/commands/auth.js +2 -17
  8. package/dist/src/platforms/kakaotalk/commands/auth.js.map +1 -1
  9. package/dist/src/platforms/kakaotalk/index.d.ts +4 -2
  10. package/dist/src/platforms/kakaotalk/index.d.ts.map +1 -1
  11. package/dist/src/platforms/kakaotalk/index.js +2 -1
  12. package/dist/src/platforms/kakaotalk/index.js.map +1 -1
  13. package/dist/src/platforms/line/commands/auth.d.ts.map +1 -1
  14. package/dist/src/platforms/line/commands/auth.js +2 -4
  15. package/dist/src/platforms/line/commands/auth.js.map +1 -1
  16. package/dist/src/platforms/slackbot/types.d.ts +4 -0
  17. package/dist/src/platforms/slackbot/types.d.ts.map +1 -1
  18. package/dist/src/platforms/telegram/commands/auth.d.ts.map +1 -1
  19. package/dist/src/platforms/telegram/commands/auth.js +5 -7
  20. package/dist/src/platforms/telegram/commands/auth.js.map +1 -1
  21. package/dist/src/platforms/webex/commands/auth.d.ts +5 -2
  22. package/dist/src/platforms/webex/commands/auth.d.ts.map +1 -1
  23. package/dist/src/platforms/webex/commands/auth.js +59 -1
  24. package/dist/src/platforms/webex/commands/auth.js.map +1 -1
  25. package/dist/src/platforms/webex/credential-manager.d.ts +11 -0
  26. package/dist/src/platforms/webex/credential-manager.d.ts.map +1 -1
  27. package/dist/src/platforms/webex/credential-manager.js +37 -0
  28. package/dist/src/platforms/webex/credential-manager.js.map +1 -1
  29. package/dist/src/platforms/whatsapp/commands/auth.d.ts.map +1 -1
  30. package/dist/src/platforms/whatsapp/commands/auth.js +2 -4
  31. package/dist/src/platforms/whatsapp/commands/auth.js.map +1 -1
  32. package/dist/src/shared/utils/interactive.d.ts +3 -0
  33. package/dist/src/shared/utils/interactive.d.ts.map +1 -0
  34. package/dist/src/shared/utils/interactive.js +16 -0
  35. package/dist/src/shared/utils/interactive.js.map +1 -0
  36. package/package.json +1 -1
  37. package/skills/agent-channeltalk/SKILL.md +1 -1
  38. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  39. package/skills/agent-discord/SKILL.md +1 -1
  40. package/skills/agent-discordbot/SKILL.md +1 -1
  41. package/skills/agent-instagram/SKILL.md +1 -1
  42. package/skills/agent-kakaotalk/SKILL.md +1 -1
  43. package/skills/agent-line/SKILL.md +1 -1
  44. package/skills/agent-slack/SKILL.md +1 -1
  45. package/skills/agent-slackbot/SKILL.md +1 -1
  46. package/skills/agent-teams/SKILL.md +1 -1
  47. package/skills/agent-telegram/SKILL.md +1 -1
  48. package/skills/agent-telegrambot/SKILL.md +1 -1
  49. package/skills/agent-webex/SKILL.md +32 -1
  50. package/skills/agent-wechatbot/SKILL.md +1 -1
  51. package/skills/agent-whatsapp/SKILL.md +1 -1
  52. package/skills/agent-whatsappbot/SKILL.md +1 -1
  53. package/src/platforms/instagram/commands/auth.ts +1 -4
  54. package/src/platforms/kakaotalk/commands/auth.ts +2 -18
  55. package/src/platforms/kakaotalk/index.ts +6 -0
  56. package/src/platforms/line/commands/auth.ts +2 -5
  57. package/src/platforms/slackbot/types.ts +16 -0
  58. package/src/platforms/telegram/commands/auth.ts +5 -8
  59. package/src/platforms/webex/commands/auth.test.ts +154 -0
  60. package/src/platforms/webex/commands/auth.ts +102 -3
  61. package/src/platforms/webex/credential-manager.test.ts +78 -0
  62. package/src/platforms/webex/credential-manager.ts +59 -0
  63. package/src/platforms/whatsapp/commands/auth.ts +2 -5
  64. package/src/shared/utils/interactive.test.ts +55 -0
  65. package/src/shared/utils/interactive.ts +15 -0
@@ -13,8 +13,11 @@ export type {
13
13
  KakaoEmoticonKind,
14
14
  KakaoEmoticonMessageType,
15
15
  KakaoFileExtra,
16
+ KakaoLoginResult,
17
+ KakaoMarkReadResult,
16
18
  KakaoMember,
17
19
  KakaoMessage,
20
+ KakaoMultiPhotoExtra,
18
21
  KakaoPhotoExtra,
19
22
  KakaoProfile,
20
23
  KakaoSendResult,
@@ -33,6 +36,7 @@ export {
33
36
  KakaoAccountCredentialsSchema,
34
37
  KakaoChatSchema,
35
38
  KakaoConfigSchema,
39
+ KakaoMarkReadResultSchema,
36
40
  KakaoMemberSchema,
37
41
  KakaoMessageSchema,
38
42
  KakaoProfileSchema,
@@ -42,6 +46,8 @@ export {
42
46
  KakaoTalkPushMessageEventSchema,
43
47
  KakaoTalkPushReadEventSchema,
44
48
  } from './types'
49
+ export { attemptLogin, generateDeviceUuid, loginFlow, registerDevice, requestPasscode } from './auth/kakao-login'
50
+ export type { LoginCredentials } from './auth/kakao-login'
45
51
  export { sha1Hex } from './media-upload'
46
52
  export { detectImageDimensions } from './image-meta'
47
53
  export type { AttachmentInput, AttachmentPlan, ResolvedAttachment, SingleAttachmentKind } from './attachment-router'
@@ -1,6 +1,7 @@
1
1
  import { Command } from 'commander'
2
2
 
3
3
  import { handleError } from '@/shared/utils/error-handler'
4
+ import { isInteractive } from '@/shared/utils/interactive'
4
5
  import { formatOutput } from '@/shared/utils/output'
5
6
  import { displayQR } from '@/shared/utils/qr'
6
7
  import { info } from '@/shared/utils/stderr'
@@ -9,10 +10,6 @@ import { LineClient } from '../client'
9
10
  import { LineCredentialManager } from '../credential-manager'
10
11
  import type { LineDevice } from '../types'
11
12
 
12
- function isInteractiveSession(): boolean {
13
- return Boolean(process.stdin.isTTY && process.stdout.isTTY)
14
- }
15
-
16
13
  function getDefaultDevice(): LineDevice {
17
14
  return 'ANDROIDSECONDARY'
18
15
  }
@@ -28,7 +25,7 @@ async function loginAction(options: {
28
25
  const credManager = new LineCredentialManager()
29
26
  const client = new LineClient(credManager)
30
27
  const device: LineDevice = (options.device as LineDevice | undefined) ?? getDefaultDevice()
31
- const interactive = isInteractiveSession()
28
+ const interactive = isInteractive()
32
29
 
33
30
  if (options.token) {
34
31
  const now = new Date().toISOString()
@@ -334,6 +334,18 @@ export interface SlackSocketModeMessageEvent {
334
334
  event_ts?: string
335
335
  edited?: { user: string; ts: string }
336
336
  hidden?: boolean
337
+ // Set on every reply within a thread; identifies the author of the message
338
+ // the thread is rooted at. Useful for deciding whether a reply targets the
339
+ // bot, another human, or an unknown parent.
340
+ parent_user_id?: string
341
+ // Client-generated UUID on user-authored messages, stable across Slack-side
342
+ // resends of the same gesture. Primary dedupe key for the "one user action
343
+ // surfaces as two events" case.
344
+ client_msg_id?: string
345
+ // Attachments delivered inline on the same message event. Slack does not
346
+ // fire a separate file_share envelope for messages we receive over Socket
347
+ // Mode, so consumers reading attachments off `message` events look here.
348
+ files?: SlackFile[]
337
349
  [key: string]: unknown
338
350
  }
339
351
 
@@ -345,6 +357,10 @@ export interface SlackSocketModeAppMentionEvent {
345
357
  ts: string
346
358
  thread_ts?: string
347
359
  event_ts?: string
360
+ // `app_mention` envelopes do not always carry `client_msg_id`, but typing
361
+ // it keeps the promotion to a message-shaped event lossless if Slack ever
362
+ // starts sending it on this event.
363
+ client_msg_id?: string
348
364
  [key: string]: unknown
349
365
  }
350
366
 
@@ -2,6 +2,7 @@ import { Writable } from 'node:stream'
2
2
 
3
3
  import { Command } from 'commander'
4
4
 
5
+ import { isInteractive } from '@/shared/utils/interactive'
5
6
  import { info, error as stderrError } from '@/shared/utils/stderr'
6
7
 
7
8
  import { handleError } from '../../../shared/utils/error-handler'
@@ -57,10 +58,6 @@ function parseApiId(apiId?: string): number | undefined {
57
58
  return parsed
58
59
  }
59
60
 
60
- function isInteractiveSession(): boolean {
61
- return Boolean(process.stdin.isTTY && process.stdout.isTTY)
62
- }
63
-
64
61
  async function promptLine(message: string): Promise<string | undefined> {
65
62
  const { createInterface } = await import('node:readline/promises')
66
63
  const rl = createInterface({
@@ -107,7 +104,7 @@ async function promptHidden(message: string): Promise<string | undefined> {
107
104
  }
108
105
 
109
106
  function shouldUseInteractivePrompts(): boolean {
110
- return isInteractiveSession()
107
+ return isInteractive()
111
108
  }
112
109
 
113
110
  async function fillMissingBootstrappingInputs(
@@ -168,7 +165,7 @@ async function fillMissingBootstrappingInputs(
168
165
  }
169
166
 
170
167
  if (!resolved.apiHash && !existing?.api_hash) {
171
- if (!isInteractiveSession()) {
168
+ if (!isInteractive()) {
172
169
  console.log(formatOutput({ error: 'missing_credentials', message: 'Provide --api-hash flag.' }, options.pretty))
173
170
  process.exit(1)
174
171
  }
@@ -176,7 +173,7 @@ async function fillMissingBootstrappingInputs(
176
173
  }
177
174
 
178
175
  if (!existing && !resolved.phone) {
179
- if (!isInteractiveSession()) {
176
+ if (!isInteractive()) {
180
177
  console.log(formatOutput({ next_action: 'provide_phone', message: 'Provide --phone flag.' }, options.pretty))
181
178
  process.exit(0)
182
179
  }
@@ -288,7 +285,7 @@ export async function promptNextLoginInput(
288
285
  result: { next_action?: string },
289
286
  options: AuthOptions,
290
287
  ): Promise<AuthOptions | null> {
291
- if (!isInteractiveSession() && result.next_action) {
288
+ if (!isInteractive() && result.next_action) {
292
289
  return null
293
290
  }
294
291
 
@@ -12,6 +12,8 @@ describe('auth commands', () => {
12
12
  let consoleErrorSpy: ReturnType<typeof spyOn>
13
13
  let execSpy: ReturnType<typeof spyOn>
14
14
  const protoSpies: ReturnType<typeof spyOn>[] = []
15
+ let originalStdinTTY: boolean | undefined
16
+ let originalStdoutTTY: boolean | undefined
15
17
  const mockPerson = {
16
18
  id: 'person-1',
17
19
  displayName: 'Test User',
@@ -27,10 +29,19 @@ describe('auth commands', () => {
27
29
  return s
28
30
  }
29
31
 
32
+ function setTTY(value: boolean | undefined): void {
33
+ Object.defineProperty(process.stdin, 'isTTY', { value, writable: true, configurable: true })
34
+ Object.defineProperty(process.stdout, 'isTTY', { value, writable: true, configurable: true })
35
+ }
36
+
30
37
  beforeEach(() => {
31
38
  consoleSpy = spyOn(console, 'log').mockImplementation(() => {})
32
39
  consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {})
33
40
  execSpy = spyOn(childProcess, 'exec').mockImplementation((() => {}) as any)
41
+ originalStdinTTY = process.stdin.isTTY
42
+ originalStdoutTTY = process.stdout.isTTY
43
+ // Default to interactive TTY for existing tests; non-interactive tests override.
44
+ setTTY(true)
34
45
  })
35
46
 
36
47
  afterEach(() => {
@@ -39,6 +50,12 @@ describe('auth commands', () => {
39
50
  execSpy.mockRestore()
40
51
  for (const s of protoSpies) s.mockRestore()
41
52
  protoSpies.length = 0
53
+ setTTY(originalStdinTTY)
54
+ Object.defineProperty(process.stdout, 'isTTY', {
55
+ value: originalStdoutTTY,
56
+ writable: true,
57
+ configurable: true,
58
+ })
42
59
  })
43
60
 
44
61
  describe('loginAction with --token', () => {
@@ -151,6 +168,143 @@ describe('auth commands', () => {
151
168
  })
152
169
  })
153
170
 
171
+ describe('loginAction non-interactive (no TTY)', () => {
172
+ const device = {
173
+ deviceCode: 'webex-device-code-abc123',
174
+ userCode: 'USER-CODE',
175
+ verificationUri: 'https://webex.com/verify',
176
+ verificationUriComplete: 'https://webex.com/verify?user_code=USER-CODE',
177
+ expiresIn: 300,
178
+ interval: 1,
179
+ }
180
+
181
+ it('first call (no --device-code): requests device code and returns it in JSON', async () => {
182
+ setTTY(false)
183
+ const requestSpy = protoSpy(WebexCredentialManager.prototype, 'requestDeviceCode').mockResolvedValue(device)
184
+ const exchangeSpy = protoSpy(WebexCredentialManager.prototype, 'exchangeDeviceCode')
185
+ const pollSpy = protoSpy(WebexCredentialManager.prototype, 'pollDeviceToken')
186
+ const exitSpy = protoSpy(process, 'exit').mockImplementation(() => undefined as never)
187
+
188
+ await loginAction({ pretty: false })
189
+
190
+ expect(requestSpy).toHaveBeenCalled()
191
+ expect(exchangeSpy).not.toHaveBeenCalled()
192
+ expect(pollSpy).not.toHaveBeenCalled()
193
+
194
+ const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
195
+ const output = JSON.parse(lastCall)
196
+ expect(output.next_action).toBe('authorize_in_browser')
197
+ expect(output.verification_uri).toBe(device.verificationUri)
198
+ expect(output.verification_uri_complete).toBe(device.verificationUriComplete)
199
+ expect(output.user_code).toBe(device.userCode)
200
+ expect(output.device_code).toBe(device.deviceCode)
201
+ expect(output.message).toContain('--device-code')
202
+ expect(exitSpy).toHaveBeenCalledWith(0)
203
+ })
204
+
205
+ it('does not open a browser on the first non-interactive call', async () => {
206
+ setTTY(false)
207
+ protoSpy(WebexCredentialManager.prototype, 'requestDeviceCode').mockResolvedValue(device)
208
+ protoSpy(process, 'exit').mockImplementation(() => undefined as never)
209
+
210
+ await loginAction({ pretty: false })
211
+
212
+ expect(execSpy).not.toHaveBeenCalled()
213
+ })
214
+
215
+ it('second call (--device-code, pending): returns still_pending and echoes back the device_code', async () => {
216
+ setTTY(false)
217
+ protoSpy(WebexCredentialManager.prototype, 'exchangeDeviceCode').mockResolvedValue({ status: 'pending' })
218
+ const requestSpy = protoSpy(WebexCredentialManager.prototype, 'requestDeviceCode')
219
+ const exitSpy = protoSpy(process, 'exit').mockImplementation(() => undefined as never)
220
+
221
+ await loginAction({ deviceCode: 'webex-device-code-abc123', pretty: false })
222
+
223
+ expect(requestSpy).not.toHaveBeenCalled()
224
+
225
+ const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
226
+ const output = JSON.parse(lastCall)
227
+ expect(output.next_action).toBe('still_pending')
228
+ expect(output.device_code).toBe('webex-device-code-abc123')
229
+ expect(exitSpy).toHaveBeenCalledWith(0)
230
+ })
231
+
232
+ it('second call (--device-code, success): saves token and returns authenticated=true', async () => {
233
+ setTTY(false)
234
+ const exchangeSpy = protoSpy(WebexCredentialManager.prototype, 'exchangeDeviceCode').mockResolvedValue({
235
+ status: 'success',
236
+ config: { accessToken: 'at', refreshToken: 'rt', expiresAt: Date.now() + 3_600_000 },
237
+ })
238
+ const saveConfigSpy = protoSpy(WebexCredentialManager.prototype, 'saveConfig').mockResolvedValue(undefined)
239
+ protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
240
+ protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
241
+
242
+ await loginAction({
243
+ deviceCode: 'webex-device-code-abc123',
244
+ clientId: 'my-id',
245
+ clientSecret: 'my-secret',
246
+ pretty: false,
247
+ })
248
+
249
+ expect(exchangeSpy).toHaveBeenCalledWith('webex-device-code-abc123', 'my-id', 'my-secret')
250
+ const savedConfig = saveConfigSpy.mock.calls[0][0] as { tokenType: string; clientId: string }
251
+ expect(savedConfig.tokenType).toBe('oauth')
252
+ expect(savedConfig.clientId).toBe('my-id')
253
+
254
+ const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
255
+ const output = JSON.parse(lastCall)
256
+ expect(output.authenticated).toBe(true)
257
+ expect(output.user.displayName).toBe('Test User')
258
+ })
259
+
260
+ it('second call (--device-code, expired): returns next_action=restart, exits 1', async () => {
261
+ setTTY(false)
262
+ protoSpy(WebexCredentialManager.prototype, 'exchangeDeviceCode').mockResolvedValue({ status: 'expired' })
263
+ const exitSpy = protoSpy(process, 'exit').mockImplementation(() => undefined as never)
264
+
265
+ await loginAction({ deviceCode: 'webex-device-code-abc123', pretty: false })
266
+
267
+ const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
268
+ const output = JSON.parse(lastCall)
269
+ expect(output.next_action).toBe('restart')
270
+ expect(output.error).toContain('expired')
271
+ expect(exitSpy).toHaveBeenCalledWith(1)
272
+ })
273
+
274
+ it('--device-code works even with a TTY (interactive sessions can also resume)', async () => {
275
+ setTTY(true)
276
+ const exchangeSpy = protoSpy(WebexCredentialManager.prototype, 'exchangeDeviceCode').mockResolvedValue({
277
+ status: 'success',
278
+ config: { accessToken: 'at', refreshToken: 'rt', expiresAt: Date.now() + 3_600_000 },
279
+ })
280
+ const requestSpy = protoSpy(WebexCredentialManager.prototype, 'requestDeviceCode')
281
+ const pollSpy = protoSpy(WebexCredentialManager.prototype, 'pollDeviceToken')
282
+ protoSpy(WebexCredentialManager.prototype, 'saveConfig').mockResolvedValue(undefined)
283
+ protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
284
+ protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
285
+
286
+ await loginAction({ deviceCode: 'webex-device-code-abc123', pretty: false })
287
+
288
+ expect(exchangeSpy).toHaveBeenCalled()
289
+ expect(requestSpy).not.toHaveBeenCalled()
290
+ expect(pollSpy).not.toHaveBeenCalled()
291
+ })
292
+
293
+ it('still allows --token login when non-interactive (bot/PAT path)', async () => {
294
+ setTTY(false)
295
+ protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
296
+ protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
297
+ protoSpy(WebexCredentialManager.prototype, 'saveConfig').mockResolvedValue(undefined)
298
+
299
+ await loginAction({ token: 'bot-token-123', pretty: false })
300
+
301
+ const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
302
+ const output = JSON.parse(lastCall)
303
+ expect(output.authenticated).toBe(true)
304
+ expect(output.user.displayName).toBe('Test User')
305
+ })
306
+ })
307
+
154
308
  describe('statusAction', () => {
155
309
  it('shows authenticated status when token is valid', async () => {
156
310
  protoSpy(WebexCredentialManager.prototype, 'loadConfig').mockResolvedValue(null)
@@ -2,6 +2,7 @@ import { Command } from 'commander'
2
2
 
3
3
  import { collectBrowserProfileOption } from '@/shared/chromium'
4
4
  import { handleError } from '@/shared/utils/error-handler'
5
+ import { isInteractive } from '@/shared/utils/interactive'
5
6
  import { formatOutput } from '@/shared/utils/output'
6
7
  import { info, debug } from '@/shared/utils/stderr'
7
8
 
@@ -43,12 +44,15 @@ async function resolveClientCredentials(options: {
43
44
  return getWebexAppCredentials()
44
45
  }
45
46
 
46
- export async function loginAction(options: {
47
+ interface LoginOptions {
47
48
  token?: string
49
+ deviceCode?: string
48
50
  clientId?: string
49
51
  clientSecret?: string
50
52
  pretty?: boolean
51
- }): Promise<void> {
53
+ }
54
+
55
+ export async function loginAction(options: LoginOptions): Promise<void> {
52
56
  try {
53
57
  const credManager = new WebexCredentialManager()
54
58
 
@@ -73,6 +77,16 @@ export async function loginAction(options: {
73
77
  return
74
78
  }
75
79
 
80
+ if (options.deviceCode) {
81
+ await finishDeviceGrant(credManager, options)
82
+ return
83
+ }
84
+
85
+ if (!isInteractive()) {
86
+ await startNonInteractiveDeviceGrant(credManager, options)
87
+ return
88
+ }
89
+
76
90
  const { clientId, clientSecret } = await resolveClientCredentials(options)
77
91
 
78
92
  const device = await credManager.requestDeviceCode(clientId)
@@ -110,6 +124,81 @@ export async function loginAction(options: {
110
124
  }
111
125
  }
112
126
 
127
+ async function startNonInteractiveDeviceGrant(
128
+ credManager: WebexCredentialManager,
129
+ options: LoginOptions,
130
+ ): Promise<void> {
131
+ const { clientId } = await resolveClientCredentials(options)
132
+ const device = await credManager.requestDeviceCode(clientId)
133
+ const expiresAt = Date.now() + device.expiresIn * 1000
134
+
135
+ console.log(
136
+ formatOutput(
137
+ {
138
+ next_action: 'authorize_in_browser',
139
+ verification_uri: device.verificationUri,
140
+ verification_uri_complete: device.verificationUriComplete,
141
+ user_code: device.userCode,
142
+ device_code: device.deviceCode,
143
+ expires_at: expiresAt,
144
+ message:
145
+ 'Show the user `verification_uri` and `user_code` (or just `verification_uri_complete`) and ask them to approve access in any browser. After they approve, run `agent-webex auth login --device-code <device_code>` to retrieve the token. The device code expires at `expires_at`. If you passed `--client-id`/`--client-secret`, pass them again on the second call.',
146
+ },
147
+ options.pretty,
148
+ ),
149
+ )
150
+ process.exit(0)
151
+ }
152
+
153
+ async function finishDeviceGrant(credManager: WebexCredentialManager, options: LoginOptions): Promise<void> {
154
+ const { clientId, clientSecret } = await resolveClientCredentials(options)
155
+ const result = await credManager.exchangeDeviceCode(options.deviceCode!, clientId, clientSecret)
156
+
157
+ if (result.status === 'success') {
158
+ await credManager.saveConfig({ ...result.config, clientId, clientSecret, tokenType: 'oauth' })
159
+ const client = await new WebexClient().login({ token: result.config.accessToken })
160
+ const person = await client.testAuth()
161
+ console.log(
162
+ formatOutput(
163
+ {
164
+ authenticated: true,
165
+ user: { id: person.id, displayName: person.displayName, emails: person.emails },
166
+ },
167
+ options.pretty,
168
+ ),
169
+ )
170
+ return
171
+ }
172
+
173
+ if (result.status === 'pending') {
174
+ console.log(
175
+ formatOutput(
176
+ {
177
+ next_action: 'still_pending',
178
+ device_code: options.deviceCode,
179
+ message:
180
+ 'User has not approved access yet. Confirm with the user that they completed authorization in the browser, then run `agent-webex auth login --device-code <device_code>` again to retry.',
181
+ },
182
+ options.pretty,
183
+ ),
184
+ )
185
+ process.exit(0)
186
+ return
187
+ }
188
+
189
+ console.log(
190
+ formatOutput(
191
+ {
192
+ next_action: 'restart',
193
+ error: result.status === 'expired' ? 'Device code expired.' : `Device code exchange failed: ${result.message}`,
194
+ message: 'This device code is no longer valid. Run `agent-webex auth login` to start a new login.',
195
+ },
196
+ options.pretty,
197
+ ),
198
+ )
199
+ process.exit(1)
200
+ }
201
+
113
202
  export async function statusAction(options: { pretty?: boolean }): Promise<void> {
114
203
  try {
115
204
  const credManager = new WebexCredentialManager()
@@ -276,8 +365,18 @@ export const authCommand = new Command('auth')
276
365
  .description('Authentication commands')
277
366
  .addCommand(
278
367
  new Command('login')
279
- .description('Login to Webex')
368
+ .description(
369
+ 'Log in to Webex. In a TTY this opens a browser and waits for approval. ' +
370
+ 'When non-interactive (AI agents, CI/CD): first call starts the OAuth Device Grant flow ' +
371
+ 'and returns a verification URL, user code, and device code. After the user approves in a ' +
372
+ 'browser, call again with --device-code to exchange it for a token. Pass --token for ' +
373
+ 'fully unattended auth.',
374
+ )
280
375
  .option('--token <token>', 'Use a bot token or personal access token directly')
376
+ .option(
377
+ '--device-code <code>',
378
+ 'OAuth Device Grant code returned from a previous non-interactive `auth login` call',
379
+ )
281
380
  .option('--client-id <id>', 'Webex Integration client ID')
282
381
  .option('--client-secret <secret>', 'Webex Integration client secret')
283
382
  .option('--pretty', 'Pretty print JSON output')
@@ -373,4 +373,82 @@ describe('WebexCredentialManager', () => {
373
373
  expect(loaded?.clientId).toBeUndefined()
374
374
  expect(loaded?.clientSecret).toBeUndefined()
375
375
  })
376
+
377
+ describe('exchangeDeviceCode', () => {
378
+ let originalFetch: typeof globalThis.fetch
379
+
380
+ beforeEach(() => {
381
+ originalFetch = globalThis.fetch
382
+ })
383
+
384
+ afterEach(() => {
385
+ globalThis.fetch = originalFetch
386
+ })
387
+
388
+ it('returns success with config on 200', async () => {
389
+ globalThis.fetch = mock(() =>
390
+ Promise.resolve(
391
+ new Response(JSON.stringify({ access_token: 'at', refresh_token: 'rt', expires_in: 3600 }), {
392
+ status: 200,
393
+ headers: { 'Content-Type': 'application/json' },
394
+ }),
395
+ ),
396
+ ) as unknown as typeof globalThis.fetch
397
+
398
+ const result = await credManager.exchangeDeviceCode('dc', 'cid', 'csec')
399
+ expect(result.status).toBe('success')
400
+ if (result.status === 'success') {
401
+ expect(result.config.accessToken).toBe('at')
402
+ expect(result.config.refreshToken).toBe('rt')
403
+ }
404
+ })
405
+
406
+ it('returns pending on 428', async () => {
407
+ globalThis.fetch = mock(() =>
408
+ Promise.resolve(new Response('', { status: 428 })),
409
+ ) as unknown as typeof globalThis.fetch
410
+ const result = await credManager.exchangeDeviceCode('dc', 'cid', 'csec')
411
+ expect(result.status).toBe('pending')
412
+ })
413
+
414
+ it('returns pending when error description includes authorization_pending', async () => {
415
+ globalThis.fetch = mock(() =>
416
+ Promise.resolve(
417
+ new Response(JSON.stringify({ errors: [{ description: 'authorization_pending' }] }), {
418
+ status: 400,
419
+ headers: { 'Content-Type': 'application/json' },
420
+ }),
421
+ ),
422
+ ) as unknown as typeof globalThis.fetch
423
+ const result = await credManager.exchangeDeviceCode('dc', 'cid', 'csec')
424
+ expect(result.status).toBe('pending')
425
+ })
426
+
427
+ it('returns expired when error description signals expiry', async () => {
428
+ globalThis.fetch = mock(() =>
429
+ Promise.resolve(
430
+ new Response(JSON.stringify({ errors: [{ description: 'expired_token' }] }), {
431
+ status: 400,
432
+ headers: { 'Content-Type': 'application/json' },
433
+ }),
434
+ ),
435
+ ) as unknown as typeof globalThis.fetch
436
+ const result = await credManager.exchangeDeviceCode('dc', 'cid', 'csec')
437
+ expect(result.status).toBe('expired')
438
+ })
439
+
440
+ it('returns error on other failures', async () => {
441
+ globalThis.fetch = mock(() =>
442
+ Promise.resolve(
443
+ new Response(JSON.stringify({ errors: [{ description: 'access_denied' }] }), {
444
+ status: 403,
445
+ headers: { 'Content-Type': 'application/json' },
446
+ }),
447
+ ),
448
+ ) as unknown as typeof globalThis.fetch
449
+ const result = await credManager.exchangeDeviceCode('dc', 'cid', 'csec')
450
+ expect(result.status).toBe('error')
451
+ if (result.status === 'error') expect(result.message).toContain('access_denied')
452
+ })
453
+ })
376
454
  })
@@ -212,6 +212,65 @@ export class WebexCredentialManager {
212
212
  throw new Error('Device authorization timed out')
213
213
  }
214
214
 
215
+ async exchangeDeviceCode(
216
+ deviceCode: string,
217
+ clientId: string,
218
+ clientSecret?: string,
219
+ ): Promise<
220
+ | { status: 'success'; config: Pick<WebexConfig, 'accessToken' | 'refreshToken' | 'expiresAt'> }
221
+ | { status: 'pending' }
222
+ | { status: 'expired' }
223
+ | { status: 'error'; message: string }
224
+ > {
225
+ const basicAuth = btoa(`${clientId}:${clientSecret ?? ''}`)
226
+
227
+ const response = await fetch(OAUTH_DEVICE_TOKEN_URL, {
228
+ method: 'POST',
229
+ headers: {
230
+ Authorization: `Basic ${basicAuth}`,
231
+ 'Content-Type': 'application/x-www-form-urlencoded',
232
+ },
233
+ body: new URLSearchParams({
234
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
235
+ device_code: deviceCode,
236
+ client_id: clientId,
237
+ }),
238
+ })
239
+
240
+ if (response.ok) {
241
+ const data = (await response.json()) as {
242
+ access_token: string
243
+ refresh_token: string
244
+ expires_in: number
245
+ }
246
+ return {
247
+ status: 'success',
248
+ config: {
249
+ accessToken: data.access_token,
250
+ refreshToken: data.refresh_token,
251
+ expiresAt: Date.now() + data.expires_in * 1000,
252
+ },
253
+ }
254
+ }
255
+
256
+ if (response.status === 428) return { status: 'pending' }
257
+
258
+ const errorBody = (await response.json().catch(() => null)) as {
259
+ errors?: Array<{ description: string }>
260
+ } | null
261
+ const errorDesc = errorBody?.errors?.[0]?.description ?? ''
262
+
263
+ if (errorDesc.includes('authorization_pending') || errorDesc.includes('slow_down')) {
264
+ return { status: 'pending' }
265
+ }
266
+
267
+ if (errorDesc.includes('expired_token') || errorDesc.includes('expired')) {
268
+ return { status: 'expired' }
269
+ }
270
+
271
+ return { status: 'error', message: errorDesc || `http_${response.status}` }
272
+ }
273
+
215
274
  async clearCredentials(): Promise<void> {
216
275
  if (existsSync(this.credentialsPath)) {
217
276
  await rm(this.credentialsPath)
@@ -3,6 +3,7 @@ import { rm } from 'node:fs/promises'
3
3
  import { Command } from 'commander'
4
4
 
5
5
  import { handleError } from '@/shared/utils/error-handler'
6
+ import { isInteractive } from '@/shared/utils/interactive'
6
7
  import { formatOutput } from '@/shared/utils/output'
7
8
  import { displayQR } from '@/shared/utils/qr'
8
9
  import { info } from '@/shared/utils/stderr'
@@ -22,10 +23,6 @@ interface StatusOptions {
22
23
  pretty?: boolean
23
24
  }
24
25
 
25
- function isInteractiveSession(): boolean {
26
- return Boolean(process.stdin.isTTY && process.stdout.isTTY)
27
- }
28
-
29
26
  async function loginWithPairingCode(options: LoginOptions & { phone: string }): Promise<void> {
30
27
  const manager = new WhatsAppCredentialManager()
31
28
  const accountId = createAccountId(options.phone)
@@ -95,7 +92,7 @@ async function loginWithQR(options: LoginOptions): Promise<void> {
95
92
  await rm(existingPaths.auth_dir, { recursive: true, force: true })
96
93
  const paths = await manager.ensureAccountPaths(accountId)
97
94
  const client = await new WhatsAppClient().login({ authDir: paths.auth_dir })
98
- const interactive = isInteractiveSession()
95
+ const interactive = isInteractive()
99
96
 
100
97
  let waitForAuth: () => Promise<void>
101
98
  let browserOpened = false