agent-messenger 2.22.0 → 2.23.1

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 (102) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +21 -0
  3. package/dist/package.json +1 -1
  4. package/dist/src/platforms/webex/client.d.ts +6 -0
  5. package/dist/src/platforms/webex/client.d.ts.map +1 -1
  6. package/dist/src/platforms/webex/client.js +34 -4
  7. package/dist/src/platforms/webex/client.js.map +1 -1
  8. package/dist/src/platforms/webex/commands/auth.d.ts +9 -1
  9. package/dist/src/platforms/webex/commands/auth.d.ts.map +1 -1
  10. package/dist/src/platforms/webex/commands/auth.js +141 -25
  11. package/dist/src/platforms/webex/commands/auth.js.map +1 -1
  12. package/dist/src/platforms/webex/credential-manager.d.ts.map +1 -1
  13. package/dist/src/platforms/webex/credential-manager.js +8 -4
  14. package/dist/src/platforms/webex/credential-manager.js.map +1 -1
  15. package/dist/src/platforms/webex/id-normalizer.d.ts +19 -0
  16. package/dist/src/platforms/webex/id-normalizer.d.ts.map +1 -0
  17. package/dist/src/platforms/webex/id-normalizer.js +60 -0
  18. package/dist/src/platforms/webex/id-normalizer.js.map +1 -0
  19. package/dist/src/platforms/webex/index.d.ts +6 -0
  20. package/dist/src/platforms/webex/index.d.ts.map +1 -1
  21. package/dist/src/platforms/webex/index.js +3 -0
  22. package/dist/src/platforms/webex/index.js.map +1 -1
  23. package/dist/src/platforms/webex/listener.d.ts +61 -0
  24. package/dist/src/platforms/webex/listener.d.ts.map +1 -0
  25. package/dist/src/platforms/webex/listener.js +222 -0
  26. package/dist/src/platforms/webex/listener.js.map +1 -0
  27. package/dist/src/platforms/webex/password-login.d.ts +18 -0
  28. package/dist/src/platforms/webex/password-login.d.ts.map +1 -0
  29. package/dist/src/platforms/webex/password-login.js +259 -0
  30. package/dist/src/platforms/webex/password-login.js.map +1 -0
  31. package/dist/src/platforms/webex/types.d.ts +2 -1
  32. package/dist/src/platforms/webex/types.d.ts.map +1 -1
  33. package/dist/src/platforms/webex/types.js +1 -1
  34. package/dist/src/platforms/webex/types.js.map +1 -1
  35. package/dist/src/platforms/webex/wdm-discovery.d.ts.map +1 -0
  36. package/dist/src/platforms/{webexbot → webex}/wdm-discovery.js +3 -3
  37. package/dist/src/platforms/webex/wdm-discovery.js.map +1 -0
  38. package/dist/src/platforms/webexbot/client.d.ts +4 -0
  39. package/dist/src/platforms/webexbot/client.d.ts.map +1 -1
  40. package/dist/src/platforms/webexbot/client.js +70 -8
  41. package/dist/src/platforms/webexbot/client.js.map +1 -1
  42. package/dist/src/platforms/webexbot/index.d.ts +2 -0
  43. package/dist/src/platforms/webexbot/index.d.ts.map +1 -1
  44. package/dist/src/platforms/webexbot/index.js +1 -0
  45. package/dist/src/platforms/webexbot/index.js.map +1 -1
  46. package/dist/src/platforms/webexbot/listener.d.ts +3 -41
  47. package/dist/src/platforms/webexbot/listener.d.ts.map +1 -1
  48. package/dist/src/platforms/webexbot/listener.js +13 -208
  49. package/dist/src/platforms/webexbot/listener.js.map +1 -1
  50. package/dist/src/platforms/webexbot/types.d.ts +1 -18
  51. package/dist/src/platforms/webexbot/types.d.ts.map +1 -1
  52. package/dist/src/platforms/webexbot/types.js.map +1 -1
  53. package/docs/content/docs/cli/webex.mdx +38 -12
  54. package/docs/content/docs/sdk/webexbot.mdx +16 -0
  55. package/package.json +1 -1
  56. package/skills/agent-channeltalk/SKILL.md +1 -1
  57. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  58. package/skills/agent-discord/SKILL.md +1 -1
  59. package/skills/agent-discordbot/SKILL.md +1 -1
  60. package/skills/agent-instagram/SKILL.md +1 -1
  61. package/skills/agent-kakaotalk/SKILL.md +1 -1
  62. package/skills/agent-line/SKILL.md +1 -1
  63. package/skills/agent-slack/SKILL.md +1 -1
  64. package/skills/agent-slackbot/SKILL.md +1 -1
  65. package/skills/agent-teams/SKILL.md +1 -1
  66. package/skills/agent-telegram/SKILL.md +1 -1
  67. package/skills/agent-telegrambot/SKILL.md +1 -1
  68. package/skills/agent-webex/SKILL.md +76 -22
  69. package/skills/agent-webex/references/authentication.md +55 -14
  70. package/skills/agent-webex/references/common-patterns.md +5 -2
  71. package/skills/agent-webexbot/SKILL.md +3 -1
  72. package/skills/agent-wechatbot/SKILL.md +1 -1
  73. package/skills/agent-whatsapp/SKILL.md +1 -1
  74. package/skills/agent-whatsappbot/SKILL.md +1 -1
  75. package/src/platforms/webex/cli.test.ts +31 -1
  76. package/src/platforms/webex/client.test.ts +57 -0
  77. package/src/platforms/webex/client.ts +39 -4
  78. package/src/platforms/webex/commands/auth.test.ts +189 -28
  79. package/src/platforms/webex/commands/auth.ts +194 -35
  80. package/src/platforms/webex/credential-manager.test.ts +40 -0
  81. package/src/platforms/webex/credential-manager.ts +7 -4
  82. package/src/platforms/webex/id-normalizer.test.ts +207 -0
  83. package/src/platforms/webex/id-normalizer.ts +76 -0
  84. package/src/platforms/webex/index.test.ts +10 -0
  85. package/src/platforms/webex/index.ts +6 -0
  86. package/src/platforms/webex/listener.test.ts +243 -0
  87. package/src/platforms/webex/listener.ts +285 -0
  88. package/src/platforms/webex/password-login.test.ts +193 -0
  89. package/src/platforms/webex/password-login.ts +332 -0
  90. package/src/platforms/webex/types.test.ts +16 -0
  91. package/src/platforms/webex/types.ts +2 -2
  92. package/src/platforms/{webexbot → webex}/wdm-discovery.ts +3 -3
  93. package/src/platforms/webexbot/client.test.ts +125 -1
  94. package/src/platforms/webexbot/client.ts +79 -8
  95. package/src/platforms/webexbot/index.ts +2 -0
  96. package/src/platforms/webexbot/listener.test.ts +37 -224
  97. package/src/platforms/webexbot/listener.ts +18 -250
  98. package/src/platforms/webexbot/types.ts +2 -23
  99. package/dist/src/platforms/webexbot/wdm-discovery.d.ts.map +0 -1
  100. package/dist/src/platforms/webexbot/wdm-discovery.js.map +0 -1
  101. /package/dist/src/platforms/{webexbot → webex}/wdm-discovery.d.ts +0 -0
  102. /package/src/platforms/{webexbot → webex}/wdm-discovery.test.ts +0 -0
@@ -1,11 +1,21 @@
1
- import { afterEach, beforeEach, describe, expect, spyOn, it } from 'bun:test'
1
+ import { afterEach, beforeEach, describe, expect, mock, spyOn, it } from 'bun:test'
2
2
  import * as childProcess from 'node:child_process'
3
+ import * as fs from 'node:fs'
3
4
 
4
5
  import { WebexClient } from '../client'
5
6
  import { WebexCredentialManager } from '../credential-manager'
7
+ import * as passwordLogin from '../password-login'
6
8
  import { WebexTokenExtractor } from '../token-extractor'
7
9
  import { WebexError } from '../types'
8
- import { extractAction, loginAction, logoutAction, statusAction } from './auth'
10
+ import { extractAction, loginAction, logoutAction, oauthAction, statusAction } from './auth'
11
+
12
+ let promptQueue: string[] = []
13
+ mock.module('node:readline/promises', () => ({
14
+ createInterface: () => ({
15
+ question: async () => promptQueue.shift() ?? '',
16
+ close: () => {},
17
+ }),
18
+ }))
9
19
 
10
20
  class ProcessExit extends Error {
11
21
  constructor(readonly code?: string | number | null) {
@@ -18,6 +28,7 @@ describe('auth commands', () => {
18
28
  let consoleErrorSpy: ReturnType<typeof spyOn>
19
29
  let execSpy: ReturnType<typeof spyOn>
20
30
  let stderrWriteSpy: ReturnType<typeof spyOn>
31
+ let stdoutWriteSpy: ReturnType<typeof spyOn>
21
32
  const protoSpies: ReturnType<typeof spyOn>[] = []
22
33
  let originalStdinTTY: boolean | undefined
23
34
  let originalStdoutTTY: boolean | undefined
@@ -42,10 +53,12 @@ describe('auth commands', () => {
42
53
  }
43
54
 
44
55
  beforeEach(() => {
56
+ promptQueue = []
45
57
  consoleSpy = spyOn(console, 'log').mockImplementation(() => {})
46
58
  consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {})
47
59
  execSpy = spyOn(childProcess, 'exec').mockImplementation((() => {}) as any)
48
60
  stderrWriteSpy = spyOn(process.stderr, 'write').mockImplementation(() => true)
61
+ stdoutWriteSpy = spyOn(process.stdout, 'write').mockImplementation(() => true)
49
62
  originalStdinTTY = process.stdin.isTTY
50
63
  originalStdoutTTY = process.stdout.isTTY
51
64
  // Default to interactive TTY for existing tests; non-interactive tests override.
@@ -62,6 +75,7 @@ describe('auth commands', () => {
62
75
  consoleErrorSpy.mockRestore()
63
76
  execSpy.mockRestore()
64
77
  stderrWriteSpy.mockRestore()
78
+ stdoutWriteSpy.mockRestore()
65
79
  for (const s of protoSpies) s.mockRestore()
66
80
  protoSpies.length = 0
67
81
  setTTY(originalStdinTTY)
@@ -99,9 +113,170 @@ describe('auth commands', () => {
99
113
  expect(savedConfig.expiresAt).toBe(0)
100
114
  expect(savedConfig.refreshToken).toBe('')
101
115
  })
116
+
117
+ it('still allows --token login when non-interactive (bot/PAT path)', async () => {
118
+ setTTY(false)
119
+ protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
120
+ protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
121
+ protoSpy(WebexCredentialManager.prototype, 'saveConfig').mockResolvedValue(undefined)
122
+
123
+ await loginAction({ token: 'bot-token-123', pretty: false })
124
+
125
+ const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
126
+ const output = JSON.parse(lastCall)
127
+ expect(output.authenticated).toBe(true)
128
+ expect(output.user.displayName).toBe('Test User')
129
+ })
130
+ })
131
+
132
+ describe('loginAction with --email/--password', () => {
133
+ const passwordConfig = {
134
+ accessToken: 'pw-access-token',
135
+ refreshToken: 'pw-refresh-token',
136
+ expiresAt: Date.now() + 3_600_000,
137
+ deviceUrl: 'https://wdm-r.wbx2.com/wdm/api/v1/devices/test-device',
138
+ userId: 'user-1',
139
+ }
140
+
141
+ it('logs in with email/password and saves the password token type', async () => {
142
+ const pwSpy = protoSpy(passwordLogin, 'loginWithPassword').mockResolvedValue(passwordConfig)
143
+ const saveSpy = protoSpy(WebexCredentialManager.prototype, 'saveConfig').mockResolvedValue(undefined)
144
+ protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
145
+ protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
146
+
147
+ await loginAction({ email: 'alice@example.com', password: 'hunter2', pretty: false })
148
+
149
+ expect(pwSpy).toHaveBeenCalledWith('alice@example.com', 'hunter2', { idbrokerHost: undefined })
150
+ const savedConfig = saveSpy.mock.calls[0][0] as { tokenType: string; clientId: string }
151
+ expect(savedConfig.tokenType).toBe('password')
152
+ expect(savedConfig.clientId).toBe(passwordLogin.WEB_CLIENT_ID)
153
+
154
+ const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
155
+ const output = JSON.parse(lastCall)
156
+ expect(output.authenticated).toBe(true)
157
+ expect(output.user.displayName).toBe('Test User')
158
+ })
159
+
160
+ it('forwards --idbroker-host to the password login flow', async () => {
161
+ const pwSpy = protoSpy(passwordLogin, 'loginWithPassword').mockResolvedValue(passwordConfig)
162
+ protoSpy(WebexCredentialManager.prototype, 'saveConfig').mockResolvedValue(undefined)
163
+ protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
164
+ protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
165
+
166
+ await loginAction({
167
+ email: 'alice@example.com',
168
+ password: 'hunter2',
169
+ idbrokerHost: 'https://idbroker.example.com',
170
+ pretty: false,
171
+ })
172
+
173
+ expect(pwSpy).toHaveBeenCalledWith('alice@example.com', 'hunter2', {
174
+ idbrokerHost: 'https://idbroker.example.com',
175
+ })
176
+ })
177
+
178
+ it('reads the password from stdin with --password-stdin', async () => {
179
+ const pwSpy = protoSpy(passwordLogin, 'loginWithPassword').mockResolvedValue(passwordConfig)
180
+ protoSpy(fs, 'readFileSync').mockReturnValue('stdin-secret\n')
181
+ protoSpy(WebexCredentialManager.prototype, 'saveConfig').mockResolvedValue(undefined)
182
+ protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
183
+ protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
184
+
185
+ await loginAction({ email: 'alice@example.com', passwordStdin: true, pretty: false })
186
+
187
+ expect(pwSpy).toHaveBeenCalledWith('alice@example.com', 'stdin-secret', { idbrokerHost: undefined })
188
+ })
189
+
190
+ it('rejects passing both --password and --password-stdin', async () => {
191
+ const exitSpy = protoSpy(process, 'exit').mockImplementation((code?: string | number | null) => {
192
+ throw new ProcessExit(code)
193
+ })
194
+
195
+ await expect(
196
+ loginAction({ email: 'alice@example.com', password: 'p', passwordStdin: true, pretty: false }),
197
+ ).rejects.toThrow(ProcessExit)
198
+
199
+ const lastCall = stderrWriteSpy.mock.calls[stderrWriteSpy.mock.calls.length - 1][0] as string
200
+ const output = JSON.parse(lastCall)
201
+ expect(output.error).toContain('Use only one of --password or --password-stdin')
202
+ expect(exitSpy).toHaveBeenCalledWith(1)
203
+ })
204
+ })
205
+
206
+ describe('loginAction interactive prompts', () => {
207
+ const passwordConfig = {
208
+ accessToken: 'pw-access-token',
209
+ refreshToken: 'pw-refresh-token',
210
+ expiresAt: Date.now() + 3_600_000,
211
+ deviceUrl: 'https://wdm-r.wbx2.com/wdm/api/v1/devices/test-device',
212
+ userId: 'user-1',
213
+ }
214
+
215
+ it('prompts for email and password when none are provided', async () => {
216
+ promptQueue = ['alice@example.com', 'prompted-secret']
217
+ const pwSpy = protoSpy(passwordLogin, 'loginWithPassword').mockResolvedValue(passwordConfig)
218
+ protoSpy(WebexCredentialManager.prototype, 'saveConfig').mockResolvedValue(undefined)
219
+ protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
220
+ protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
221
+
222
+ await loginAction({ pretty: false })
223
+
224
+ expect(pwSpy).toHaveBeenCalledWith('alice@example.com', 'prompted-secret', { idbrokerHost: undefined })
225
+ })
226
+
227
+ it('prompts only for the password when --email is provided without a password', async () => {
228
+ promptQueue = ['prompted-secret']
229
+ const pwSpy = protoSpy(passwordLogin, 'loginWithPassword').mockResolvedValue(passwordConfig)
230
+ protoSpy(WebexCredentialManager.prototype, 'saveConfig').mockResolvedValue(undefined)
231
+ protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
232
+ protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
233
+
234
+ await loginAction({ email: 'alice@example.com', pretty: false })
235
+
236
+ expect(pwSpy).toHaveBeenCalledWith('alice@example.com', 'prompted-secret', { idbrokerHost: undefined })
237
+ })
238
+
239
+ it('preserves whitespace in the prompted password', async () => {
240
+ promptQueue = [' spaced secret ']
241
+ const pwSpy = protoSpy(passwordLogin, 'loginWithPassword').mockResolvedValue(passwordConfig)
242
+ protoSpy(WebexCredentialManager.prototype, 'saveConfig').mockResolvedValue(undefined)
243
+ protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
244
+ protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
245
+
246
+ await loginAction({ email: 'alice@example.com', pretty: false })
247
+
248
+ expect(pwSpy).toHaveBeenCalledWith('alice@example.com', ' spaced secret ', { idbrokerHost: undefined })
249
+ })
250
+ })
251
+
252
+ describe('loginAction non-interactive without credentials', () => {
253
+ it('errors when no email is provided and points to auth oauth', async () => {
254
+ setTTY(false)
255
+ const exitSpy = protoSpy(process, 'exit').mockImplementation(() => undefined as never)
256
+
257
+ await loginAction({ pretty: false })
258
+
259
+ const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
260
+ const output = JSON.parse(lastCall)
261
+ expect(output.error).toContain('Email required')
262
+ expect(output.error).toContain('auth oauth')
263
+ expect(exitSpy).toHaveBeenCalledWith(1)
264
+ })
265
+
266
+ it('errors when --email is provided but no password is available', async () => {
267
+ setTTY(false)
268
+ const exitSpy = protoSpy(process, 'exit').mockImplementation(() => undefined as never)
269
+
270
+ await loginAction({ email: 'alice@example.com', pretty: false })
271
+
272
+ const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
273
+ const output = JSON.parse(lastCall)
274
+ expect(output.error).toContain('Password required')
275
+ expect(exitSpy).toHaveBeenCalledWith(1)
276
+ })
102
277
  })
103
278
 
104
- describe('loginAction with --client-id and --client-secret', () => {
279
+ describe('oauthAction with --client-id and --client-secret', () => {
105
280
  it('uses provided credentials for Device Grant flow', async () => {
106
281
  protoSpy(WebexCredentialManager.prototype, 'requestDeviceCode').mockResolvedValue({
107
282
  deviceCode: 'd',
@@ -120,7 +295,7 @@ describe('auth commands', () => {
120
295
  protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
121
296
  protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
122
297
 
123
- await loginAction({ clientId: 'my-id', clientSecret: 'my-secret', pretty: false })
298
+ await oauthAction({ clientId: 'my-id', clientSecret: 'my-secret', pretty: false })
124
299
 
125
300
  expect(WebexCredentialManager.prototype.requestDeviceCode).toHaveBeenCalledWith('my-id')
126
301
  expect(WebexCredentialManager.prototype.pollDeviceToken).toHaveBeenCalledWith(
@@ -150,7 +325,7 @@ describe('auth commands', () => {
150
325
  protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
151
326
  protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
152
327
 
153
- await loginAction({ clientId: 'my-id', clientSecret: 'my-secret', pretty: false })
328
+ await oauthAction({ clientId: 'my-id', clientSecret: 'my-secret', pretty: false })
154
329
 
155
330
  const savedConfig = saveSpy.mock.calls[0][0] as { tokenType: string }
156
331
  expect(savedConfig.tokenType).toBe('oauth')
@@ -174,7 +349,7 @@ describe('auth commands', () => {
174
349
  protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
175
350
  protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
176
351
 
177
- await loginAction({ clientId: 'my-id', clientSecret: 'my-secret', pretty: false })
352
+ await oauthAction({ clientId: 'my-id', clientSecret: 'my-secret', pretty: false })
178
353
 
179
354
  const savedConfig = saveSpy.mock.calls[0][0] as { clientId: string; clientSecret: string }
180
355
  expect(savedConfig.clientId).toBe('my-id')
@@ -182,7 +357,7 @@ describe('auth commands', () => {
182
357
  })
183
358
  })
184
359
 
185
- describe('loginAction non-interactive (no TTY)', () => {
360
+ describe('oauthAction non-interactive (no TTY)', () => {
186
361
  const device = {
187
362
  deviceCode: 'webex-device-code-abc123',
188
363
  userCode: 'USER-CODE',
@@ -199,7 +374,7 @@ describe('auth commands', () => {
199
374
  const pollSpy = protoSpy(WebexCredentialManager.prototype, 'pollDeviceToken')
200
375
  const exitSpy = protoSpy(process, 'exit').mockImplementation(() => undefined as never)
201
376
 
202
- await loginAction({ pretty: false })
377
+ await oauthAction({ pretty: false })
203
378
 
204
379
  expect(requestSpy).toHaveBeenCalled()
205
380
  expect(exchangeSpy).not.toHaveBeenCalled()
@@ -212,7 +387,7 @@ describe('auth commands', () => {
212
387
  expect(output.verification_uri_complete).toBe(device.verificationUriComplete)
213
388
  expect(output.user_code).toBe(device.userCode)
214
389
  expect(output.device_code).toBe(device.deviceCode)
215
- expect(output.message).toContain('--device-code')
390
+ expect(output.message).toContain('auth oauth --device-code')
216
391
  expect(exitSpy).toHaveBeenCalledWith(0)
217
392
  })
218
393
 
@@ -221,7 +396,7 @@ describe('auth commands', () => {
221
396
  protoSpy(WebexCredentialManager.prototype, 'requestDeviceCode').mockResolvedValue(device)
222
397
  protoSpy(process, 'exit').mockImplementation(() => undefined as never)
223
398
 
224
- await loginAction({ pretty: false })
399
+ await oauthAction({ pretty: false })
225
400
 
226
401
  expect(execSpy).not.toHaveBeenCalled()
227
402
  })
@@ -232,7 +407,7 @@ describe('auth commands', () => {
232
407
  const requestSpy = protoSpy(WebexCredentialManager.prototype, 'requestDeviceCode')
233
408
  const exitSpy = protoSpy(process, 'exit').mockImplementation(() => undefined as never)
234
409
 
235
- await loginAction({ deviceCode: 'webex-device-code-abc123', pretty: false })
410
+ await oauthAction({ deviceCode: 'webex-device-code-abc123', pretty: false })
236
411
 
237
412
  expect(requestSpy).not.toHaveBeenCalled()
238
413
 
@@ -253,7 +428,7 @@ describe('auth commands', () => {
253
428
  protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
254
429
  protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
255
430
 
256
- await loginAction({
431
+ await oauthAction({
257
432
  deviceCode: 'webex-device-code-abc123',
258
433
  clientId: 'my-id',
259
434
  clientSecret: 'my-secret',
@@ -276,7 +451,7 @@ describe('auth commands', () => {
276
451
  protoSpy(WebexCredentialManager.prototype, 'exchangeDeviceCode').mockResolvedValue({ status: 'expired' })
277
452
  const exitSpy = protoSpy(process, 'exit').mockImplementation(() => undefined as never)
278
453
 
279
- await loginAction({ deviceCode: 'webex-device-code-abc123', pretty: false })
454
+ await oauthAction({ deviceCode: 'webex-device-code-abc123', pretty: false })
280
455
 
281
456
  const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
282
457
  const output = JSON.parse(lastCall)
@@ -297,26 +472,12 @@ describe('auth commands', () => {
297
472
  protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
298
473
  protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
299
474
 
300
- await loginAction({ deviceCode: 'webex-device-code-abc123', pretty: false })
475
+ await oauthAction({ deviceCode: 'webex-device-code-abc123', pretty: false })
301
476
 
302
477
  expect(exchangeSpy).toHaveBeenCalled()
303
478
  expect(requestSpy).not.toHaveBeenCalled()
304
479
  expect(pollSpy).not.toHaveBeenCalled()
305
480
  })
306
-
307
- it('still allows --token login when non-interactive (bot/PAT path)', async () => {
308
- setTTY(false)
309
- protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
310
- protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
311
- protoSpy(WebexCredentialManager.prototype, 'saveConfig').mockResolvedValue(undefined)
312
-
313
- await loginAction({ token: 'bot-token-123', pretty: false })
314
-
315
- const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
316
- const output = JSON.parse(lastCall)
317
- expect(output.authenticated).toBe(true)
318
- expect(output.user.displayName).toBe('Test User')
319
- })
320
481
  })
321
482
 
322
483
  describe('statusAction', () => {
@@ -1,14 +1,18 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { Writable } from 'node:stream'
3
+
1
4
  import { Command } from 'commander'
2
5
 
3
6
  import { collectBrowserProfileOption } from '@/shared/chromium'
4
7
  import { handleError } from '@/shared/utils/error-handler'
5
8
  import { isInteractive } from '@/shared/utils/interactive'
6
9
  import { formatOutput } from '@/shared/utils/output'
7
- import { info, debug } from '@/shared/utils/stderr'
10
+ import { info, debug, error as stderrError } from '@/shared/utils/stderr'
8
11
 
9
12
  import { getWebexAppCredentials } from '../app-config'
10
13
  import { WebexClient } from '../client'
11
14
  import { WebexCredentialManager } from '../credential-manager'
15
+ import { loginWithPassword, WEB_CLIENT_ID, WEB_CLIENT_SECRET } from '../password-login'
12
16
  import { WebexTokenExtractor } from '../token-extractor'
13
17
  import { WebexError } from '../types'
14
18
 
@@ -28,6 +32,39 @@ async function openBrowser(url: string): Promise<void> {
28
32
  exec(command)
29
33
  }
30
34
 
35
+ async function promptText(message: string): Promise<string | undefined> {
36
+ const { createInterface } = await import('node:readline/promises')
37
+ const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true })
38
+ try {
39
+ const answer = await rl.question(`${message}: `)
40
+ return answer.trim() || undefined
41
+ } finally {
42
+ rl.close()
43
+ }
44
+ }
45
+
46
+ async function promptHidden(message: string): Promise<string | undefined> {
47
+ const { createInterface } = await import('node:readline/promises')
48
+ const hiddenOutput = new (class extends Writable {
49
+ muted = false
50
+ _write(chunk: Buffer | string, encoding: BufferEncoding, cb: (error?: Error | null) => void): void {
51
+ if (!this.muted) process.stdout.write(chunk, encoding)
52
+ cb()
53
+ }
54
+ })()
55
+ const rl = createInterface({ input: process.stdin, output: hiddenOutput, terminal: true })
56
+ try {
57
+ hiddenOutput.muted = true
58
+ process.stdout.write(`${message}: `)
59
+ const answer = await rl.question('')
60
+ process.stdout.write('\n')
61
+ return answer === '' ? undefined : answer
62
+ } finally {
63
+ hiddenOutput.muted = false
64
+ rl.close()
65
+ }
66
+ }
67
+
31
68
  async function resolveClientCredentials(options: {
32
69
  clientId?: string
33
70
  clientSecret?: string
@@ -46,9 +83,10 @@ async function resolveClientCredentials(options: {
46
83
 
47
84
  interface LoginOptions {
48
85
  token?: string
49
- deviceCode?: string
50
- clientId?: string
51
- clientSecret?: string
86
+ email?: string
87
+ password?: string
88
+ passwordStdin?: boolean
89
+ idbrokerHost?: string
52
90
  pretty?: boolean
53
91
  }
54
92
 
@@ -57,26 +95,134 @@ export async function loginAction(options: LoginOptions): Promise<void> {
57
95
  const credManager = new WebexCredentialManager()
58
96
 
59
97
  if (options.token) {
60
- const client = await new WebexClient().login({ token: options.token })
61
- const person = await client.testAuth()
62
- await credManager.saveConfig({
63
- accessToken: options.token,
64
- refreshToken: '',
65
- expiresAt: 0,
66
- tokenType: 'manual',
67
- })
68
- console.log(
69
- formatOutput(
70
- {
71
- user: { id: person.id, displayName: person.displayName, emails: person.emails },
72
- authenticated: true,
73
- },
74
- options.pretty,
75
- ),
76
- )
98
+ await loginWithToken(credManager, options.token, options.pretty)
77
99
  return
78
100
  }
79
101
 
102
+ if (options.password && options.passwordStdin) {
103
+ throw new Error('Use only one of --password or --password-stdin.')
104
+ }
105
+
106
+ const interactive = isInteractive()
107
+
108
+ let email = options.email
109
+ if (!email) {
110
+ if (!interactive) {
111
+ console.log(
112
+ formatOutput(
113
+ {
114
+ error:
115
+ 'Email required. Use --email <email> with --password or --password-stdin, ' +
116
+ 'or run "agent-webex auth oauth" for browser-based login.',
117
+ },
118
+ options.pretty,
119
+ ),
120
+ )
121
+ process.exit(1)
122
+ return
123
+ }
124
+ email = await promptText('Webex email')
125
+ if (!email) {
126
+ stderrError('Email is required.')
127
+ process.exit(1)
128
+ return
129
+ }
130
+ }
131
+
132
+ let password = options.password
133
+ if (!password && options.passwordStdin) {
134
+ password = readFileSync(0, 'utf-8').replace(/\r?\n$/, '')
135
+ }
136
+ if (!password) {
137
+ if (!interactive) {
138
+ console.log(
139
+ formatOutput(
140
+ { error: 'Password required. Use --password or --password-stdin, or run interactively to be prompted.' },
141
+ options.pretty,
142
+ ),
143
+ )
144
+ process.exit(1)
145
+ return
146
+ }
147
+ password = await promptHidden('Password')
148
+ if (!password) {
149
+ stderrError('Password is required.')
150
+ process.exit(1)
151
+ return
152
+ }
153
+ }
154
+
155
+ await loginWithEmailPassword(credManager, email, password, options)
156
+ } catch (error) {
157
+ handleError(error as Error)
158
+ }
159
+ }
160
+
161
+ async function loginWithToken(credManager: WebexCredentialManager, token: string, pretty?: boolean): Promise<void> {
162
+ const client = await new WebexClient().login({ token })
163
+ const person = await client.testAuth()
164
+ await credManager.saveConfig({
165
+ accessToken: token,
166
+ refreshToken: '',
167
+ expiresAt: 0,
168
+ tokenType: 'manual',
169
+ })
170
+ console.log(
171
+ formatOutput(
172
+ {
173
+ user: { id: person.id, displayName: person.displayName, emails: person.emails },
174
+ authenticated: true,
175
+ },
176
+ pretty,
177
+ ),
178
+ )
179
+ }
180
+
181
+ async function loginWithEmailPassword(
182
+ credManager: WebexCredentialManager,
183
+ email: string,
184
+ password: string,
185
+ options: LoginOptions,
186
+ ): Promise<void> {
187
+ const config = await loginWithPassword(email, password, { idbrokerHost: options.idbrokerHost })
188
+
189
+ await credManager.saveConfig({
190
+ ...config,
191
+ tokenType: 'password',
192
+ clientId: WEB_CLIENT_ID,
193
+ clientSecret: WEB_CLIENT_SECRET,
194
+ encryptionKeys: {},
195
+ })
196
+
197
+ const client = await new WebexClient().login({
198
+ token: config.accessToken,
199
+ deviceUrl: config.deviceUrl,
200
+ tokenType: 'password',
201
+ })
202
+ const person = await client.testAuth()
203
+
204
+ console.log(
205
+ formatOutput(
206
+ {
207
+ authenticated: true,
208
+ user: { id: person.id, displayName: person.displayName, emails: person.emails },
209
+ },
210
+ options.pretty,
211
+ ),
212
+ )
213
+ }
214
+
215
+ interface OAuthOptions {
216
+ deviceCode?: string
217
+ clientId?: string
218
+ clientSecret?: string
219
+ pretty?: boolean
220
+ }
221
+
222
+ export async function oauthAction(options: OAuthOptions): Promise<void> {
223
+ try {
224
+ const credManager = new WebexCredentialManager()
225
+
80
226
  if (options.deviceCode) {
81
227
  await finishDeviceGrant(credManager, options)
82
228
  return
@@ -126,7 +272,7 @@ export async function loginAction(options: LoginOptions): Promise<void> {
126
272
 
127
273
  async function startNonInteractiveDeviceGrant(
128
274
  credManager: WebexCredentialManager,
129
- options: LoginOptions,
275
+ options: OAuthOptions,
130
276
  ): Promise<void> {
131
277
  const { clientId } = await resolveClientCredentials(options)
132
278
  const device = await credManager.requestDeviceCode(clientId)
@@ -142,7 +288,7 @@ async function startNonInteractiveDeviceGrant(
142
288
  device_code: device.deviceCode,
143
289
  expires_at: expiresAt,
144
290
  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.',
291
+ '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 oauth --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
292
  },
147
293
  options.pretty,
148
294
  ),
@@ -150,7 +296,7 @@ async function startNonInteractiveDeviceGrant(
150
296
  process.exit(0)
151
297
  }
152
298
 
153
- async function finishDeviceGrant(credManager: WebexCredentialManager, options: LoginOptions): Promise<void> {
299
+ async function finishDeviceGrant(credManager: WebexCredentialManager, options: OAuthOptions): Promise<void> {
154
300
  const { clientId, clientSecret } = await resolveClientCredentials(options)
155
301
  const result = await credManager.exchangeDeviceCode(options.deviceCode!, clientId, clientSecret)
156
302
 
@@ -177,7 +323,7 @@ async function finishDeviceGrant(credManager: WebexCredentialManager, options: L
177
323
  next_action: 'still_pending',
178
324
  device_code: options.deviceCode,
179
325
  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.',
326
+ 'User has not approved access yet. Confirm with the user that they completed authorization in the browser, then run `agent-webex auth oauth --device-code <device_code>` again to retry.',
181
327
  },
182
328
  options.pretty,
183
329
  ),
@@ -191,7 +337,7 @@ async function finishDeviceGrant(credManager: WebexCredentialManager, options: L
191
337
  {
192
338
  next_action: 'restart',
193
339
  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.',
340
+ message: 'This device code is no longer valid. Run `agent-webex auth oauth` to start a new login.',
195
341
  },
196
342
  options.pretty,
197
343
  ),
@@ -252,7 +398,7 @@ export async function extractAction(options: {
252
398
  {
253
399
  error:
254
400
  'No Webex token found in any browser. Make sure you are logged in at https://web.webex.com (not webex.com) in Chrome, Edge, Arc, or Brave.',
255
- hint: 'Run "auth login" for OAuth Device Grant flow, or --debug for more info.',
401
+ hint: 'Run "auth oauth" for OAuth Device Grant flow, or --debug for more info.',
256
402
  },
257
403
  options.pretty,
258
404
  ),
@@ -304,7 +450,7 @@ export async function extractAction(options: {
304
450
  formatOutput(
305
451
  {
306
452
  error: 'Extracted browser token is expired and could not be refreshed.',
307
- hint: 'Log in at https://web.webex.com (not webex.com) in your browser, then run "auth extract" again. Or use "auth login" for OAuth Device Grant flow.',
453
+ hint: 'Log in at https://web.webex.com (not webex.com) in your browser, then run "auth extract" again. Or use "auth oauth" for OAuth Device Grant flow.',
308
454
  },
309
455
  options.pretty,
310
456
  ),
@@ -366,21 +512,34 @@ export const authCommand = new Command('auth')
366
512
  .addCommand(
367
513
  new Command('login')
368
514
  .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.',
515
+ 'Log in to Webex with your email and password (first-party token, messages appear as you). ' +
516
+ 'Run with no flags in a terminal to be prompted for email and password. ' +
517
+ 'Pass --token for a bot or personal access token. For browser-based OAuth, use `auth oauth`.',
374
518
  )
519
+ .option('--email <email>', 'Webex email address (prompted if omitted in a terminal)')
520
+ .option('--password <password>', 'Password for --email login (prompted securely if omitted in a terminal)')
521
+ .option('--password-stdin', 'Read the password for --email login from stdin')
522
+ .option('--idbroker-host <host>', 'Override IdBroker host for email/password login')
375
523
  .option('--token <token>', 'Use a bot token or personal access token directly')
524
+ .option('--pretty', 'Pretty print JSON output')
525
+ .action(loginAction),
526
+ )
527
+ .addCommand(
528
+ new Command('oauth')
529
+ .description(
530
+ 'Log in to Webex via OAuth Device Grant. In a TTY this opens a browser and waits for approval. ' +
531
+ 'When non-interactive (AI agents, CI/CD): the first call starts the flow and returns a verification ' +
532
+ 'URL, user code, and device code. After the user approves in a browser, call again with --device-code ' +
533
+ 'to exchange it for a token.',
534
+ )
376
535
  .option(
377
536
  '--device-code <code>',
378
- 'OAuth Device Grant code returned from a previous non-interactive `auth login` call',
537
+ 'OAuth Device Grant code returned from a previous non-interactive `auth oauth` call',
379
538
  )
380
539
  .option('--client-id <id>', 'Webex Integration client ID')
381
540
  .option('--client-secret <secret>', 'Webex Integration client secret')
382
541
  .option('--pretty', 'Pretty print JSON output')
383
- .action(loginAction),
542
+ .action(oauthAction),
384
543
  )
385
544
  .addCommand(
386
545
  new Command('extract')