epismo 0.19.1 → 1.0.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.
package/README.md CHANGED
@@ -1,101 +1,111 @@
1
1
  # Epismo CLI
2
2
 
3
- GitHub made code reusable.
4
- Hugging Face made AI models reusable.
5
- Epismo makes agentic workflows reusable.
6
-
7
- Capture how you got your best results — not just the output — and share them as executable workflows that agents and humans can discover, adapt, and run across projects.
8
-
9
- ## CLI Contract
10
-
11
- Epismo CLI is designed for agents first.
12
-
13
- - Success output is machine-readable JSON on `stdout`, in both interactive and non-interactive runs.
14
- - `stderr` carries prompts, browser-login guidance, warnings, and errors (all JSON-shaped).
15
- - Exit code `0` means success; any non-zero code means failure.
16
- - In non-interactive environments, commands that would need a prompt fail fast instead of waiting for input.
3
+ Agent-friendly CLI for Playbooks, Cases, Tasks, Records, Suggestions, Stars, and Aliases. Successful commands print JSON to stdout; warnings and failures are JSON-shaped on stderr.
17
4
 
18
5
  ## Install
19
6
 
7
+ Node.js 22.12 or newer is required.
8
+
20
9
  ```bash
21
- npm install -g epismo # or run ad hoc with: npx epismo <command>
10
+ npm install -g epismo
11
+ # or
12
+ npx epismo <command>
22
13
  ```
23
14
 
24
- ## Authentication
15
+ ## Authentication and workspace selection
25
16
 
26
17
  ```bash
27
- epismo login # prompts for email, then OTP
28
- epismo logout # clear local credentials
29
- EPISMO_TOKEN=<access-token> epismo whoami # preferred for scripts
18
+ epismo login
19
+ epismo login --email you@example.com
20
+ epismo whoami
21
+ epismo logout
30
22
  ```
31
23
 
32
- If `EPISMO_TOKEN` is set, the CLI uses it instead of stored credentials and resolves the workspace from the token itself (the saved default workspace is ignored).
24
+ Commands use the workspace embedded in `EPISMO_TOKEN`, then the saved default from
25
+ `epismo workspace use`, then personal space. A workspace-scoped CI token can be issued
26
+ with `epismo token create --workspace-id <workspace-id>`.
33
27
 
34
28
  ## Commands
35
29
 
36
- Each command group maps to an Epismo resource.
37
- Run `epismo <command> --help` for the full flag set; the highlights:
38
-
39
- | Command | Purpose |
40
- | ------------ | ----------------------------------------------------------------------------- |
41
- | `whoami` | Show the current user, effective workspace, and accessible workspaces. |
42
- | `workspace` | List/create/update workspaces, manage members, and get billing checkout URLs. |
43
- | `project` | Manage projects within a workspace and their members. |
44
- | `agent` | Manage which AI teammates appear in the assignee roster. |
45
- | `track` | Create, update, search, and bulk-apply task/goal project tracks. |
46
- | `log` | Append, list, and delete activity/comment logs on tracks. |
47
- | `pack` | Create, update, fetch, search, like, rate, and delete packs. |
48
- | `suggestion` | Send and triage improvement suggestions against packs. |
49
- | `alias` | Manage pack aliases as a top-level resource. |
50
- | `credit` | Check the credit balance and start credit checkout. |
51
- | `token` | Issue workspace-scoped CLI tokens for CI/CD. |
30
+ ```text
31
+ epismo login | logout | whoami
52
32
 
53
- A few group-specific notes that aren't obvious from `--help`:
33
+ epismo workspace list | current | use | clear | create | checkout | update
34
+ epismo workspace member list | upsert | delete
35
+ epismo project list | create | update
36
+ epismo project member list | add | delete
37
+ epismo credit balance | checkout
38
+ epismo token create
54
39
 
55
- - **Bulk member ops** accept comma-separated IDs and use one backend transaction. Membership does not change Stripe billing quantity.
56
- - **`track apply`** creates, updates, and deletes multiple tracks in one request; use a non-UUID `id` to create, and cross-references between new entries are resolved server-side.
57
- - **`track review`** generates a read-only detailed review for one or more completed/postponed task or goal tracks. It returns JSON with `targets` and a `review` object; it does not create or update packs automatically.
58
- - **`log`** entries are immutable comments/activity notes attached to a track (`kind`: `comment`, `update`, `review`, or the reserved `system`). `log list <track-reference>` reads one track, newest first by default; pass a task/goal UUID or URL containing it. Omit `<track-reference>` for an ACL-scoped activity feed across every track you can access, optionally `--author-id`-filtered either way. `--order asc|desc` and `--cursor <logId>` (the previous response's `nextCursor`) control pagination. Appending a log is free; listing costs credits like other reads. Authors outside what the caller can see (e.g. someone who has since left) come back as `"unknown"`.
40
+ epismo playbook search | create | get | acl | archive | star | unstar | starred
41
+ epismo playbook version list | get | publish
42
+ epismo playbook share create
43
+ epismo alias set | list | delete
59
44
 
60
- ### Pack references
45
+ epismo case start | get | list | assign | acl | update | close | reopen
46
+ epismo task create | list | assign | update | close | reopen
47
+ epismo record append | list
61
48
 
62
- Any command that takes a pack accepts a bare UUID, an alias (`@alias` or `@handle/alias`), a share URL, or a hub URL interchangeably.
63
-
64
- ### Track references
49
+ epismo suggestion create | get | list | update | resolve
50
+ ```
65
51
 
66
- Any command that takes an existing track accepts a task/goal UUID or a URL containing that UUID. Tracks do not have aliases.
52
+ The Playbook, Case, Task, Record, Suggestion, Star, and Alias operations are also
53
+ available through MCP. MCP names mirror the CLI resource/verb hierarchy in snake_case;
54
+ for example, `epismo_playbook_version_list` maps to `epismo playbook version list`.
67
55
 
68
- ### Access scope
56
+ Run `epismo <command> --help` for flags and positional arguments.
69
57
 
70
- `track` and `pack` writes share one access model: `scope` is `{ "type": "personal" }` or `{ "type": "projects", "ids": [...] }` (required on create; omit on update to preserve existing access), with an optional `sharedWith`.
71
- The matching CLI flags are `--personal`, `--projects <id...>`, and `--share-with <userIdOrEmail...>`.
72
- Search uses the same selectors additively; omit them to use the default scope for the current context.
58
+ ## Structured input
73
59
 
74
- ## Workspace Selection
60
+ Commands that accept payloads support:
75
61
 
76
- There is no per-command `--workspace-id` flag — every command resolves a workspace, in this order:
62
+ ```bash
63
+ epismo playbook create --input @playbook.json
64
+ epismo record append <case-id> --input @record.json
65
+ epismo suggestion create --input - < suggestion.json
66
+ ```
77
67
 
78
- 1. The workspace embedded in `EPISMO_TOKEN` (issued via `epismo token create --workspace-id`).
79
- 2. The saved default from `epismo workspace use` (interactive sessions).
80
- 3. Personal space, when nothing else is configured.
68
+ `--input` accepts an inline JSON object, `@file`, or `-` for stdin. Explicit flags
69
+ override fields from `--input`. Complex Playbook Definitions and close-time Record
70
+ arrays are best supplied through files.
81
71
 
82
- For CI/CD, issue a scoped token once and export it:
72
+ `--input` always carries the whole request payload, so `epismo case start` takes the
73
+ Case's own input object through `--case-input` instead:
83
74
 
84
75
  ```bash
85
- epismo token create --workspace-id <workspace-id>
86
- export EPISMO_TOKEN=<accessToken> # all commands now use that workspace
76
+ epismo case start --version-id <version-id> --acl <account-id> \
77
+ --case-input '{"companyName":"Acme"}'
87
78
  ```
88
79
 
89
- ## Input / Output
80
+ Mutation commands accept `--idempotency-key <uuid>`. When omitted, the CLI generates
81
+ a UUID automatically. Reuse an explicit key when retrying a command whose first result
82
+ was uncertain; reusing one with different arguments is rejected.
83
+
84
+ Every mutation of an existing Case or Task requires `--lock-version`, taken from the
85
+ most recent object returned by the API: `case assign | acl | update | close | reopen`
86
+ and `task assign | update | close | reopen`. After a conflict, re-read the object and
87
+ decide again rather than resending with a bumped number. Updates are partial: Case
88
+ currently supports `title`, while Task supports `title` and `instructions`.
89
+
90
+ `epismo record list` is a cross-Case activity feed constrained by current Case ACLs.
91
+ Use `--case-id`, `--task-id`, `--created-by`, `--kinds`, `--origins`, or `--acl` to
92
+ narrow it; these filters never grant access. Results default to newest first, and
93
+ `--order asc` walks a timeline forward.
90
94
 
91
- - `--input <json>`, `--input @file.json`, or `--input -` (stdin) supply structured input; explicit flags override fields from `--input`, and enum-like flags are validated locally.
92
- - Success is JSON on `stdout`; errors and warnings are JSON on `stderr`:
95
+ ## Playbook references
96
+
97
+ The CLI's REST commands currently use UUIDs for Playbook path arguments. Aliases are
98
+ managed with `epismo alias`; MCP exposes the same alias set/list/delete operations and
99
+ also accepts `pb:alias` and `pb:handle/alias` in `epismo_playbook_get`.
100
+
101
+ ## Output contract
102
+
103
+ - Success: JSON on stdout and exit code `0`.
104
+ - Failure: JSON on stderr and a non-zero exit code.
105
+ - Non-interactive commands fail instead of waiting for an unavailable prompt.
93
106
 
94
107
  ```json
95
- { "error": { "code": "NOT_FOUND", "message": "...", "retryable": false, "hint": "..." } }
108
+ { "error": { "code": "NOT_FOUND", "message": "...", "retryable": false } }
96
109
  ```
97
110
 
98
- ## Update Checks
99
-
100
- Update checks run once per day in interactive sessions.
101
- Set `EPISMO_UPDATE_CHECK=0` to disable the background npm version check.
111
+ Set `EPISMO_UPDATE_CHECK=0` to disable the daily interactive npm update check.
package/dist/auth.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { createServer } from "node:http";
2
2
  import { randomBytes, createHash, timingSafeEqual } from "node:crypto";
3
3
  import { spawn } from "node:child_process";
4
- import { createInterface } from "node:readline/promises";
5
4
  import { once } from "node:events";
5
+ import { createInterface } from "node:readline/promises";
6
6
  import { stdin as input, stderr as output } from "node:process";
7
7
  import { clearCredentials, readCliConfig, readCredentials, resolveApiUrl, resolveWebUrl, writeCliConfig, writeCredentials } from "./config.js";
8
8
  import { requestJson } from "./api.js";
@@ -14,13 +14,14 @@ const CLI_CLIENT_ID = "epismo-cli";
14
14
  const TOKEN_REFRESH_THRESHOLD_MS = 5 * 60 * 1000;
15
15
  // #8: common hint strings — defined once so wording is consistent everywhere
16
16
  const HINT_RUN_LOGIN = "Set EPISMO_TOKEN or run `epismo login`.";
17
- const HINT_RUN_LOGIN_BROWSER = "Run `epismo login --browser` in a terminal, or use the default OTP login with `epismo login`.";
17
+ const HINT_RUN_LOGIN_BROWSER = "Run `epismo login` in an interactive terminal, or use a pre-issued EPISMO_TOKEN.";
18
18
  const REFRESH_RACE_RETRY_DELAYS_MS = [100, 200, 400, 800];
19
19
  // #9: browser login progress warning codes — defined once so wording is consistent everywhere
20
20
  const WARNING_BROWSER_OPENING = "BROWSER_OPENING";
21
21
  const WARNING_BROWSER_FALLBACK_URL = "BROWSER_FALLBACK_URL";
22
22
  const WARNING_BROWSER_WAITING = "BROWSER_WAITING";
23
23
  const WARNING_BROWSER_AUTHORIZED = "BROWSER_AUTHORIZED";
24
+ const WARNING_OTP_SENT = "OTP_SENT";
24
25
  export function hasEnvTokenAuthentication() {
25
26
  return Boolean(process.env.EPISMO_TOKEN?.trim());
26
27
  }
@@ -28,8 +29,8 @@ async function prompt(question) {
28
29
  if (input.isTTY !== true || output.isTTY !== true) {
29
30
  throw new CliError({
30
31
  code: "NON_INTERACTIVE_INPUT_REQUIRED",
31
- message: "This command requires interactive input, but no TTY is available.",
32
- hint: "Pass the required flags explicitly, use --input, or run the command in a terminal."
32
+ message: "This login requires interactive input, but no TTY is available.",
33
+ hint: "Run the command in a terminal, or use a pre-issued EPISMO_TOKEN."
33
34
  });
34
35
  }
35
36
  const readline = createInterface({ input, output });
@@ -186,6 +187,59 @@ async function postOAuthToken(apiUrl, body) {
186
187
  body
187
188
  });
188
189
  }
190
+ async function resolveLoginMethod(apiUrl, email) {
191
+ const response = await requestJson(apiUrl, "/v1/login-options", {
192
+ method: "POST",
193
+ body: { email }
194
+ });
195
+ if (response.method !== "sso" && response.method !== "otp") {
196
+ throw new CliError({
197
+ code: "LOGIN_METHOD_INVALID",
198
+ message: "The server returned an unsupported login method.",
199
+ retryable: true
200
+ });
201
+ }
202
+ return response.method;
203
+ }
204
+ async function loginWithOtp(apiUrl, email) {
205
+ if (input.isTTY !== true || output.isTTY !== true) {
206
+ throw new CliError({
207
+ code: "NON_INTERACTIVE_INPUT_REQUIRED",
208
+ message: "Email-code login requires an interactive terminal.",
209
+ hint: "Run the command in a terminal, or use a pre-issued EPISMO_TOKEN."
210
+ });
211
+ }
212
+ const issued = await requestJson(apiUrl, "/v1/otp-tokens", {
213
+ method: "POST",
214
+ body: { email }
215
+ });
216
+ const otpId = issued.otpId?.trim();
217
+ if (!otpId) {
218
+ throw new CliError({
219
+ code: "OTP_ID_MISSING",
220
+ message: "Failed to obtain an OTP id from the server.",
221
+ retryable: true
222
+ });
223
+ }
224
+ printWarning({
225
+ warning: { code: WARNING_OTP_SENT, message: `A sign-in code was sent to ${email}.` }
226
+ });
227
+ const otp = await prompt("Code: ");
228
+ if (!otp) {
229
+ throw new CliError({
230
+ code: "OTP_CODE_REQUIRED",
231
+ message: "A sign-in code is required.",
232
+ hint: `Run \`epismo login --email ${email}\` to request a new code.`
233
+ });
234
+ }
235
+ const tokenResponse = await postOAuthToken(apiUrl, {
236
+ grant_type: "otp",
237
+ otp_id: otpId,
238
+ otp,
239
+ client_id: CLI_CLIENT_ID
240
+ });
241
+ return toVerifiedLogin(tokenResponse, await fetchUserInfo(apiUrl, tokenResponse.access_token));
242
+ }
189
243
  async function fetchUserInfo(apiUrl, accessToken) {
190
244
  return requestJson(apiUrl, "/oauth/userinfo", {
191
245
  authToken: accessToken
@@ -214,7 +268,21 @@ function toSavedWorkspace(workspace) {
214
268
  role: workspace.role
215
269
  };
216
270
  }
217
- async function loginWithBrowser(apiUrl, webUrl) {
271
+ export function buildCliAuthorizationUrl(input) {
272
+ const authorizeUrl = new URL(`${input.webUrl}/oauth/authorize`);
273
+ authorizeUrl.searchParams.set("response_type", "code");
274
+ authorizeUrl.searchParams.set("client_id", CLI_CLIENT_ID);
275
+ authorizeUrl.searchParams.set("redirect_uri", input.redirectUri);
276
+ authorizeUrl.searchParams.set("scope", "read write offline_access");
277
+ authorizeUrl.searchParams.set("code_challenge", input.challenge);
278
+ authorizeUrl.searchParams.set("code_challenge_method", "S256");
279
+ authorizeUrl.searchParams.set("state", input.state);
280
+ const email = input.email?.trim();
281
+ if (email)
282
+ authorizeUrl.searchParams.set("login_hint", email);
283
+ return authorizeUrl;
284
+ }
285
+ async function loginWithBrowser(apiUrl, webUrl, email) {
218
286
  if (output.isTTY !== true) {
219
287
  throw new CliError({
220
288
  code: "NON_INTERACTIVE_BROWSER_LOGIN",
@@ -225,21 +293,40 @@ async function loginWithBrowser(apiUrl, webUrl) {
225
293
  const { verifier, challenge } = generatePkce();
226
294
  const state = generateState();
227
295
  const { redirectUri, waitForCallback } = await startOAuthCallbackServer(state);
228
- const authorizeUrl = new URL(`${webUrl}/oauth/authorize`);
229
- authorizeUrl.searchParams.set("response_type", "code");
230
- authorizeUrl.searchParams.set("client_id", CLI_CLIENT_ID);
231
- authorizeUrl.searchParams.set("redirect_uri", redirectUri);
232
- authorizeUrl.searchParams.set("scope", "read write offline_access");
233
- authorizeUrl.searchParams.set("code_challenge", challenge);
234
- authorizeUrl.searchParams.set("code_challenge_method", "S256");
235
- authorizeUrl.searchParams.set("state", state);
296
+ const authorizeUrl = buildCliAuthorizationUrl({ webUrl, redirectUri, challenge, state, email });
236
297
  const authorizeUrlString = authorizeUrl.toString();
237
- printWarning({ warning: { code: WARNING_BROWSER_OPENING, message: "Opening browser for authorization..." } });
238
- printWarning({ warning: { code: WARNING_BROWSER_FALLBACK_URL, message: `If the browser does not open, visit: ${authorizeUrlString}` } });
239
- await openBrowser(authorizeUrlString);
240
- printWarning({ warning: { code: WARNING_BROWSER_WAITING, message: "Waiting for authorization in your browser (times out in 5 minutes)..." } });
298
+ printWarning({
299
+ warning: { code: WARNING_BROWSER_OPENING, message: "Opening browser for authorization..." }
300
+ });
301
+ printWarning({
302
+ warning: {
303
+ code: WARNING_BROWSER_FALLBACK_URL,
304
+ message: `If the browser does not open, visit: ${authorizeUrlString}`
305
+ }
306
+ });
307
+ try {
308
+ await openBrowser(authorizeUrlString);
309
+ }
310
+ catch (cause) {
311
+ if (!(cause instanceof CliError) || cause.code !== "BROWSER_OPEN_FAILED") {
312
+ throw cause;
313
+ }
314
+ // The callback server is already listening and the URL was printed above,
315
+ // so manual browser navigation can complete the same authorization flow.
316
+ }
317
+ printWarning({
318
+ warning: {
319
+ code: WARNING_BROWSER_WAITING,
320
+ message: "Waiting for authorization in your browser (times out in 5 minutes)..."
321
+ }
322
+ });
241
323
  const { code } = await waitForCallback();
242
- printWarning({ warning: { code: WARNING_BROWSER_AUTHORIZED, message: "Authorization received. Completing sign-in..." } });
324
+ printWarning({
325
+ warning: {
326
+ code: WARNING_BROWSER_AUTHORIZED,
327
+ message: "Authorization received. Completing sign-in..."
328
+ }
329
+ });
243
330
  const tokenResponse = await postOAuthToken(apiUrl, {
244
331
  grant_type: "authorization_code",
245
332
  code,
@@ -249,9 +336,8 @@ async function loginWithBrowser(apiUrl, webUrl) {
249
336
  });
250
337
  return toVerifiedLogin(tokenResponse, await fetchUserInfo(apiUrl, tokenResponse.access_token));
251
338
  }
252
- // The server lowercases emails before binding an OTP to them, so compare and
253
- // store the canonical lowercased form otherwise the same address typed in a
254
- // different case looks like a different account.
339
+ // Compare and store canonical lowercased addresses so the same account typed
340
+ // with different casing does not trigger an unnecessary browser login.
255
341
  export function normalizeEmail(email) {
256
342
  return email.trim().toLowerCase();
257
343
  }
@@ -268,86 +354,11 @@ function toVerifiedLogin(tokenResponse, userInfo) {
268
354
  email: userInfo.email ? normalizeEmail(userInfo.email) : undefined
269
355
  };
270
356
  }
271
- // Send a code (hits the rate-limited /v1/otp-tokens) and return the otpId that
272
- // pairs with it. There is no local expiry — the server alone decides whether the
273
- // otpId/code is still valid when it is later verified.
274
- async function sendOtp(apiUrl, email) {
275
- const response = await requestJson(apiUrl, "/v1/otp-tokens", {
276
- method: "POST",
277
- body: { email }
278
- });
279
- const otpId = response.otpId?.trim();
280
- if (!otpId) {
281
- throw new CliError({
282
- code: "OTP_ID_MISSING",
283
- message: "Failed to obtain an OTP id from the server.",
284
- retryable: true
285
- });
286
- }
287
- return { otpId, email };
288
- }
289
- // Exchange an (otpId, otp) pair for tokens. Hits /oauth/token only — never the
290
- // rate-limited send endpoint — so verify retries are cheap and self-contained.
291
- async function verifyOtp(apiUrl, otpId, otp) {
292
- const tokenResponse = await postOAuthToken(apiUrl, {
293
- grant_type: "otp",
294
- otp_id: otpId,
295
- otp,
296
- client_id: CLI_CLIENT_ID
297
- });
298
- return toVerifiedLogin(tokenResponse, await fetchUserInfo(apiUrl, tokenResponse.access_token));
299
- }
300
- // Resolve which otpId to verify against without sending a new code: an explicit
301
- // --otp-id wins (CI/stateless), otherwise the one saved by an earlier send. We
302
- // never reject on a local clock — verifyOtp surfaces the server's verdict on an
303
- // expired/consumed code.
304
- export function resolvePendingOtpId(pending, options) {
305
- const explicit = options.otpId?.trim();
306
- if (explicit) {
307
- return explicit;
308
- }
309
- if (!pending) {
310
- throw new CliError({
311
- code: "PENDING_LOGIN_NOT_FOUND",
312
- message: "No pending login to verify.",
313
- hint: "Run `epismo login --email you@example.com` first to receive a code."
314
- });
315
- }
316
- const requestedEmail = options.email?.trim();
317
- if (requestedEmail && normalizeEmail(requestedEmail) !== normalizeEmail(pending.email)) {
318
- throw new CliError({
319
- code: "PENDING_LOGIN_EMAIL_MISMATCH",
320
- message: `The pending login is for ${pending.email}, not ${requestedEmail}.`,
321
- hint: "Omit --email, or run `epismo login --email ...` to start over."
322
- });
323
- }
324
- return pending.otpId;
325
- }
326
- async function resolveEmailForSend(optionEmail, lastLoginEmail) {
327
- const provided = optionEmail?.trim();
328
- if (provided) {
329
- return normalizeEmail(provided);
330
- }
331
- const defaultEmail = lastLoginEmail?.trim();
332
- const entered = defaultEmail
333
- ? (await prompt(`Email [${defaultEmail}]: `)) || defaultEmail
334
- : await prompt("Email: ");
335
- const email = entered.trim();
336
- if (!email) {
337
- throw new CliError({
338
- code: "EMAIL_REQUIRED",
339
- message: "Email is required.",
340
- hint: "Pass --email or provide it interactively."
341
- });
342
- }
343
- return normalizeEmail(email);
344
- }
345
- // Persist tokens, clear any pending login, remember the email, and identify.
357
+ // Persist tokens, remember the verified email, and identify.
346
358
  async function persistLogin(credentials, config, lastLoginEmail) {
347
359
  await writeCredentials(credentials);
348
360
  await writeCliConfig({
349
361
  ...config,
350
- pendingLogin: undefined,
351
362
  lastLoginEmail: lastLoginEmail ?? config.lastLoginEmail
352
363
  });
353
364
  // auth.signed_in is emitted server-side by the API at /oauth/token.
@@ -368,103 +379,30 @@ function toLoggedIn(credentials, lastLoginEmail, config, alreadyLoggedIn) {
368
379
  ...(hint ? { hint } : {})
369
380
  };
370
381
  }
371
- // Read the OTP code from the terminal, mapping the no-TTY/blank cases to
372
- // OTP_CODE_REQUIRED so a caller that supplied an otpId but no code gets a clear
373
- // error instead of silently re-sending.
374
- async function promptForOtp() {
375
- const interactive = input.isTTY === true && output.isTTY === true;
376
- const otp = interactive ? (await prompt("OTP: ")).trim() : "";
377
- if (!otp) {
378
- throw new CliError({
379
- code: "OTP_CODE_REQUIRED",
380
- message: "OTP code is required.",
381
- hint: "Pass --otp <code> to verify."
382
- });
383
- }
384
- return otp;
385
- }
386
- // The OTP flow is split by which inputs are present, so a send and its verify
387
- // can happen in separate processes (e.g. an agent sending, then a human pasting
388
- // the code back):
389
- // --otp or --otp-id present → VERIFY against the pending/explicit otpId
390
- // (prompts for the code on a TTY when --otp absent)
391
- // --email present, no --otp → SEND; on a TTY also prompt + verify in-process
392
- // neither, on a TTY → interactive: prompt email → send → prompt → verify
393
- // neither, no TTY → error (prompt() throws NON_INTERACTIVE)
394
- // The email persisted as lastLoginEmail always comes from the verified token, so
395
- // it reflects the account actually signed into — not whatever the user typed.
382
+ // Without an email, Web browser OAuth owns login-method selection. With an
383
+ // email, the API performs the same domain discovery: SSO continues in the
384
+ // browser while OTP completes interactively here in the terminal.
396
385
  export async function login(options) {
397
386
  const apiUrl = resolveApiUrl();
398
387
  const webUrl = resolveWebUrl();
399
388
  const config = await readCliConfig();
400
- if (options.browser) {
401
- const { credentials, email } = await loginWithBrowser(apiUrl, webUrl);
402
- const lastLoginEmail = email ?? config.lastLoginEmail;
403
- await persistLogin(credentials, config, lastLoginEmail);
404
- return toLoggedIn(credentials, lastLoginEmail, config, false);
405
- }
406
- const providedOtp = options.otp?.trim();
407
- const explicitOtpId = options.otpId?.trim();
408
- // VERIFY: a code (or an explicit --otp-id signalling verify intent) was given.
409
- // Never call the send endpoint here — so retries can't trip its rate limit or
410
- // invalidate the code already sitting in the user's inbox.
411
- if (providedOtp || explicitOtpId) {
412
- const otpId = resolvePendingOtpId(config.pendingLogin, {
413
- otpId: explicitOtpId,
414
- email: options.email
415
- });
416
- const otp = providedOtp || (await promptForOtp());
417
- const { credentials, email: verifiedEmail } = await verifyOtp(apiUrl, otpId, otp);
418
- const lastLoginEmail = verifiedEmail ??
419
- config.pendingLogin?.email ??
420
- (options.email ? normalizeEmail(options.email) : config.lastLoginEmail);
421
- await persistLogin(credentials, config, lastLoginEmail);
422
- return toLoggedIn(credentials, lastLoginEmail, config, false);
423
- }
424
- // Already authenticated? Don't waste an OTP email (or a slot against the send
425
- // rate limit). Short-circuit only when the session is still fresh and the same
426
- // account is implied — an expired/near-expiry session, or a different --email,
427
- // falls through to a normal send so the user can (re-)authenticate.
389
+ const requestedEmail = options.email?.trim() ? normalizeEmail(options.email) : undefined;
390
+ // Reuse a fresh credential only when the requested account matches.
428
391
  const existing = await readCredentials();
429
392
  if (existing && !shouldRefreshCredentials(existing)) {
430
- const requestedEmail = options.email?.trim();
431
393
  const sameAccount = !requestedEmail ||
432
- (!!config.lastLoginEmail &&
433
- normalizeEmail(requestedEmail) === normalizeEmail(config.lastLoginEmail));
394
+ (!!config.lastLoginEmail && requestedEmail === normalizeEmail(config.lastLoginEmail));
434
395
  if (sameAccount) {
435
396
  return toLoggedIn(existing, config.lastLoginEmail, config, true);
436
397
  }
437
398
  }
438
- // SEND: no code yet. Resolve the email (prompting only when a TTY is present)
439
- // and send the code.
440
- const email = await resolveEmailForSend(options.email, config.lastLoginEmail);
441
- const pendingLogin = await sendOtp(apiUrl, email);
442
- const interactive = input.isTTY === true && output.isTTY === true;
443
- // Interactive terminal: keep today's one-shot UX — prompt for the code now and
444
- // verify in the same process, so the pending login never has to be persisted.
445
- if (interactive) {
446
- const otp = (await prompt("OTP: ")).trim();
447
- if (otp) {
448
- const { credentials, email: verifiedEmail } = await verifyOtp(apiUrl, pendingLogin.otpId, otp);
449
- const lastLoginEmail = verifiedEmail ?? email;
450
- await persistLogin(credentials, config, lastLoginEmail);
451
- return toLoggedIn(credentials, lastLoginEmail, config, false);
452
- }
453
- // No code entered — fall through to persist the pending login so the user can
454
- // resume with `epismo login --otp <code>` once it arrives.
455
- }
456
- // Remember the otpId so a later `--otp` (possibly another process) can verify it.
457
- await writeCliConfig({ ...config, lastLoginEmail: email, pendingLogin });
458
- if (interactive) {
459
- throw new CliError({
460
- code: "OTP_CODE_REQUIRED",
461
- message: "OTP code is required.",
462
- hint: "Run `epismo login --otp <code>` once the code arrives."
463
- });
464
- }
465
- // Non-interactive (agent / CI): the code was emailed; the caller verifies with
466
- // `epismo login --otp <code>`.
467
- return { kind: "otp_sent", email };
399
+ const verified = requestedEmail && (await resolveLoginMethod(apiUrl, requestedEmail)) === "otp"
400
+ ? await loginWithOtp(apiUrl, requestedEmail)
401
+ : await loginWithBrowser(apiUrl, webUrl, requestedEmail);
402
+ const lastLoginEmail = verified.email ?? config.lastLoginEmail;
403
+ const { credentials } = verified;
404
+ await persistLogin(credentials, config, lastLoginEmail);
405
+ return toLoggedIn(credentials, lastLoginEmail, config, false);
468
406
  }
469
407
  export async function logout() {
470
408
  const credentials = await readCredentials();