@moda-ai/cli 1.12.3 → 1.13.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.
package/dist/auth.d.ts CHANGED
@@ -13,10 +13,17 @@ export declare function loadToken(options?: AuthSessionOptions): string | null;
13
13
  export declare function loadAuthSession(options?: AuthSessionOptions): StoredAuth | null;
14
14
  export declare function saveAuthSession(accessToken: string, expiresIn: number, options?: ProfileIOOptions): StoredAuth;
15
15
  export declare function clearAuthSession(options?: ProfileIOOptions): void;
16
- export interface LoginOptions {
16
+ export interface LoginOptions extends ProfileIOOptions {
17
+ /** Use this exact port for the localhost callback (no fallback ladder). */
17
18
  callbackPort?: number;
18
19
  timeoutMs?: number;
19
20
  openUrl?: (url: string) => Promise<void>;
21
+ /**
22
+ * Test hook: readable stream used for the manual-paste fallback instead of
23
+ * process.stdin. A truthy `isTTY` property on the stream enables the
24
+ * interactive bare-token confirmation path.
25
+ */
26
+ promptStream?: NodeJS.ReadableStream;
20
27
  }
21
28
  export declare function login(screen?: 'login' | 'signup', options?: LoginOptions): Promise<string>;
22
29
  export declare function authFetch(path: string, options?: RequestInit, authOptions?: AuthSessionOptions): Promise<Response>;
package/dist/auth.js CHANGED
@@ -2,11 +2,17 @@
2
2
  import { createServer } from 'node:http';
3
3
  import { existsSync, readFileSync, rmSync } from 'node:fs';
4
4
  import { randomBytes, timingSafeEqual } from 'node:crypto';
5
+ import { createInterface } from 'node:readline';
5
6
  import { resolveModaBaseUrl } from './config.js';
6
7
  import { CliAuthError } from './errors.js';
7
8
  import { getModaPaths, loadProfileConfig, saveProfileConfig, secretFilePathFromRef, writeSecretJsonFile, } from './profiles.js';
8
9
  const CALLBACK_PORT = 8432;
10
+ // Fallback ports tried in order when CALLBACK_PORT is taken. The frontend
11
+ // callback redirect is port-parameterized, so any of these works as long as
12
+ // the auth URL carries the port that actually got bound.
13
+ const CALLBACK_PORT_LADDER = [CALLBACK_PORT, 8433, 8434, 8435];
9
14
  const AUTH_TIMEOUT_MS = 120_000;
15
+ const DEFAULT_EXPIRES_IN_SECONDS = 86_400;
10
16
  function escapeHtml(value) {
11
17
  return value
12
18
  .replace(/&/g, '&amp;')
@@ -154,8 +160,8 @@ export function clearAuthSession(options = {}) {
154
160
  }
155
161
  rmSync(tokenPath(options), { force: true });
156
162
  }
157
- function saveToken(accessToken, expiresIn) {
158
- saveAuthSession(accessToken, expiresIn);
163
+ function saveToken(accessToken, expiresIn, options = {}) {
164
+ saveAuthSession(accessToken, expiresIn, options);
159
165
  }
160
166
  // --- Login Flow ---
161
167
  const SUCCESS_HTML = `<!DOCTYPE html>
@@ -196,9 +202,20 @@ function closeAuthServer(server) {
196
202
  export async function login(screen = 'login', options = {}) {
197
203
  return new Promise((resolve, reject) => {
198
204
  let settled = false;
199
- let localPort = options.callbackPort ?? CALLBACK_PORT;
200
- let timeout;
201
205
  const state = randomBytes(16).toString('hex');
206
+ // An explicit callbackPort is used as-is (tests bind port 0 for an
207
+ // ephemeral port); only the default flow walks the fallback ladder.
208
+ const portsToTry = options.callbackPort !== undefined
209
+ ? [options.callbackPort]
210
+ : [...CALLBACK_PORT_LADDER];
211
+ let portIndex = 0;
212
+ let localPort = portsToTry[0];
213
+ let timeout;
214
+ // Manual-paste fallback state (used when the browser can't reach this box).
215
+ let promptInput;
216
+ let promptStreamIsTTY = false;
217
+ // Holds a bare token awaiting the interactive [y/N] confirmation.
218
+ let pendingBareToken = null;
202
219
  const server = createServer(async (req, res) => {
203
220
  if (settled)
204
221
  return;
@@ -244,7 +261,10 @@ export async function login(screen = 'login', options = {}) {
244
261
  });
245
262
  return;
246
263
  }
247
- saveToken(token, parseInt(expiresIn || '86400', 10));
264
+ saveToken(token, parseInt(expiresIn || '86400', 10), {
265
+ profile: options.profile,
266
+ env: options.env,
267
+ });
248
268
  res.writeHead(200, { 'Content-Type': 'text/html', Connection: 'close' });
249
269
  res.end(SUCCESS_HTML, () => {
250
270
  succeed(token);
@@ -254,6 +274,13 @@ export async function login(screen = 'login', options = {}) {
254
274
  process.off('SIGINT', handleSigint);
255
275
  process.off('SIGTERM', handleSigterm);
256
276
  }
277
+ function closePromptInput() {
278
+ if (!promptInput)
279
+ return;
280
+ promptInput.removeListener('line', handlePastedLine);
281
+ promptInput.close();
282
+ promptInput = undefined;
283
+ }
257
284
  function finish(callback) {
258
285
  if (settled)
259
286
  return;
@@ -261,6 +288,7 @@ export async function login(screen = 'login', options = {}) {
261
288
  if (timeout)
262
289
  clearTimeout(timeout);
263
290
  removeSignalHandlers();
291
+ closePromptInput();
264
292
  // Always attempt to close the server: if SIGINT arrives between
265
293
  // server.listen() and its listening callback, the socket may already be
266
294
  // bound by the OS. closeAuthServer ignores ERR_SERVER_NOT_RUNNING.
@@ -273,35 +301,138 @@ export async function login(screen = 'login', options = {}) {
273
301
  function fail(error) {
274
302
  finish(() => reject(error));
275
303
  }
304
+ function completeWithToken(token, expiresIn) {
305
+ saveToken(token, expiresIn, { profile: options.profile, env: options.env });
306
+ succeed(token);
307
+ }
276
308
  function handleSigint() {
277
309
  fail(new Error('Authentication cancelled.'));
278
310
  }
279
311
  function handleSigterm() {
280
312
  fail(new Error('Authentication terminated.'));
281
313
  }
314
+ // Accepts a line pasted by the user when the browser cannot reach this
315
+ // machine: a full /callback-token?token=... URL, a /callback#token=...
316
+ // fragment URL, or a bare token string.
317
+ function handlePastedLine(line) {
318
+ if (settled)
319
+ return;
320
+ const trimmed = line.trim();
321
+ if (pendingBareToken !== null) {
322
+ const token = pendingBareToken;
323
+ pendingBareToken = null;
324
+ if (/^y(es)?$/i.test(trimmed)) {
325
+ completeWithToken(token, DEFAULT_EXPIRES_IN_SECONDS);
326
+ }
327
+ else {
328
+ // A bare Enter is the conventional No for a [y/N] prompt.
329
+ fail(new Error('Bare token rejected: state cannot be verified.'));
330
+ }
331
+ return;
332
+ }
333
+ if (!trimmed)
334
+ return;
335
+ if (/^https?:\/\//i.test(trimmed)) {
336
+ let pastedUrl;
337
+ try {
338
+ pastedUrl = new URL(trimmed);
339
+ }
340
+ catch {
341
+ // A malformed paste intentionally aborts the login so a fresh state
342
+ // is minted on the next attempt.
343
+ fail(new Error('Could not parse the pasted URL. Paste the full callback URL from the browser.'));
344
+ return;
345
+ }
346
+ // Fragment URLs (/callback#token=...) carry the same key/value pairs as
347
+ // the /callback-token query string; parse whichever is present.
348
+ const params = pastedUrl.hash.length > 1
349
+ ? new URLSearchParams(pastedUrl.hash.slice(1))
350
+ : pastedUrl.searchParams;
351
+ const token = params.get('token');
352
+ const expiresIn = params.get('expires_in');
353
+ const pastedState = params.get('state');
354
+ if (!pastedState || !safeEqual(pastedState, state)) {
355
+ fail(new Error('Invalid authentication state.'));
356
+ return;
357
+ }
358
+ if (!token) {
359
+ fail(new Error('No token received'));
360
+ return;
361
+ }
362
+ completeWithToken(token, parseInt(expiresIn || String(DEFAULT_EXPIRES_IN_SECONDS), 10));
363
+ return;
364
+ }
365
+ // Bare token: there is no state to verify, so require an explicit,
366
+ // interactive confirmation before trusting it.
367
+ if (promptStreamIsTTY) {
368
+ pendingBareToken = trimmed;
369
+ console.error(' State cannot be verified for a bare token. Continue? [y/N]');
370
+ return;
371
+ }
372
+ fail(new Error('State cannot be verified for a bare token in non-interactive mode. Paste the full callback URL instead.'));
373
+ }
374
+ function attachPromptInput() {
375
+ if (settled || promptInput)
376
+ return;
377
+ const stream = options.promptStream ??
378
+ (process.stdin && (process.stdin.isTTY || !process.stdin.destroyed)
379
+ ? process.stdin
380
+ : undefined);
381
+ if (!stream)
382
+ return;
383
+ promptStreamIsTTY = Boolean(stream.isTTY);
384
+ promptInput = createInterface({ input: stream, terminal: false });
385
+ promptInput.on('line', handlePastedLine);
386
+ // Waiting for a paste must not keep the process alive on its own; the
387
+ // callback server and the auth timeout already do that.
388
+ stream.unref?.();
389
+ }
282
390
  timeout = setTimeout(() => {
283
391
  fail(new Error('Authentication timed out. Please try again.'));
284
392
  }, options.timeoutMs ?? AUTH_TIMEOUT_MS);
285
393
  server.on('error', (err) => {
394
+ if (settled)
395
+ return;
286
396
  if (err.code === 'EADDRINUSE') {
287
- fail(new Error(`Port ${localPort} is in use. Close the process using it and try again.`));
397
+ portIndex += 1;
398
+ if (portIndex < portsToTry.length) {
399
+ localPort = portsToTry[portIndex];
400
+ server.listen(localPort, '127.0.0.1');
401
+ return;
402
+ }
403
+ if (options.callbackPort !== undefined) {
404
+ fail(new Error(`Port ${options.callbackPort} is in use. Close the process using it and try again.`));
405
+ }
406
+ else {
407
+ fail(new Error(`Ports ${CALLBACK_PORT_LADDER.join(', ')} are all in use. ` +
408
+ 'Close a process using one of them and try again.'));
409
+ }
288
410
  }
289
411
  else {
290
412
  fail(err);
291
413
  }
292
414
  });
293
415
  server.listen(localPort, '127.0.0.1', async () => {
416
+ if (settled)
417
+ return;
294
418
  const address = server.address();
295
419
  if (address && typeof address === 'object') {
296
420
  localPort = address.port;
297
421
  }
298
- const authUrl = `${resolveModaBaseUrl()}/auth/cli?port=${localPort}&screen=${screen}&state=${state}`;
422
+ // Resolve the auth URL with the same profile/env context so `--profile
423
+ // staging` opens the staging flow (not whatever the active profile is)
424
+ // and the minted session matches where it will be stored.
425
+ const authUrl = `${resolveModaBaseUrl({ profile: options.profile, env: options.env })}/auth/cli?port=${localPort}&screen=${screen}&state=${state}`;
426
+ attachPromptInput();
299
427
  try {
300
428
  await (options.openUrl ?? openAuthUrl)(authUrl);
301
429
  }
302
430
  catch {
303
- console.error(` Open this URL in your browser:\n ${authUrl}`);
431
+ // The URL is printed below regardless of whether the browser opened.
304
432
  }
433
+ console.error(` Open this URL in your browser if it did not open automatically:\n ${authUrl}`);
434
+ console.error(" Paste the callback URL or code here if the browser can't reach this machine\n" +
435
+ "(if the browser page failed to redirect, copy the URL from the browser's address bar):");
305
436
  });
306
437
  process.once('SIGINT', handleSigint);
307
438
  process.once('SIGTERM', handleSigterm);
@@ -321,8 +452,12 @@ export async function authFetch(path, options = {}, authOptions = {}) {
321
452
  if (!headers.has('Content-Type') && options.body) {
322
453
  headers.set('Content-Type', 'application/json');
323
454
  }
324
- const origin = new URL(resolveModaBaseUrl()).origin;
325
- let url = `${resolveModaBaseUrl()}${path}`;
455
+ // Resolve the origin from the same profile/env context used to load the
456
+ // token, or a profile-scoped session (e.g. `--profile staging`) is sent to
457
+ // the active/default origin — provisioning against the wrong environment.
458
+ const baseUrl = resolveModaBaseUrl(authOptions);
459
+ const origin = new URL(baseUrl).origin;
460
+ let url = `${baseUrl}${path}`;
326
461
  // Follow redirects manually so EVERY hop is origin-checked, not just the
327
462
  // first. Native `redirect: 'follow'` would silently strip the Authorization
328
463
  // header on a cross-host hop (per the Fetch spec) — the exact failure that
package/dist/cli.js CHANGED
@@ -12,7 +12,7 @@ import { runSkillsCommand } from './skills.js';
12
12
  import { runHarnessCommand } from './harness.js';
13
13
  import { runSyncCommand } from './sync.js';
14
14
  import { runDoctorCommand, runStatusCommand } from './doctor.js';
15
- import { CliAuthError, CliCancellationError, CliInputError, CliInputRequiredError, isApiLikeError, isNetworkLikeError } from './errors.js';
15
+ import { CliAccessError, CliAuthError, CliCancellationError, CliInputError, CliInputRequiredError, isApiLikeError, isNetworkLikeError } from './errors.js';
16
16
  import { buildSearchAdoptionTelemetry, isTelemetryDisabled, SEARCH_MODES, sendCliUsageTelemetry, stripTelemetryFlag, } from './telemetry.js';
17
17
  import { isUpdateCheckDisabled, printUpdateNotice, startUpdateCheck, stripUpdateCheckFlag, } from './update-check.js';
18
18
  import { createCommandContext } from './command-context.js';
@@ -111,6 +111,8 @@ Commands:
111
111
  manifest Describe the CLI machine protocol and commands
112
112
  doctor Diagnose local Moda setup and data flow
113
113
  auth login Authenticate with Moda
114
+ auth status Show local auth session state (exit 4 when logged out); --online probes the API
115
+ auth token Print the raw bearer token for $(moda auth token)
114
116
  auth logout Clear cached Moda auth
115
117
  auth whoami Show the authenticated Moda user
116
118
  profiles list List local Moda profiles
@@ -759,7 +761,14 @@ export const commandRegistry = createCommandRegistry([
759
761
  {
760
762
  name: 'auth',
761
763
  description: 'Authenticate and inspect browser auth sessions',
762
- examples: ['moda auth login', 'moda auth whoami', 'moda auth logout'],
764
+ examples: [
765
+ 'moda auth login',
766
+ 'moda auth status',
767
+ 'moda auth status --online',
768
+ 'moda auth token',
769
+ 'moda auth whoami',
770
+ 'moda auth logout',
771
+ ],
763
772
  auth: 'none',
764
773
  outputModes: ['human', 'json', 'agent'],
765
774
  mutability: 'write',
@@ -1075,10 +1084,12 @@ export function classifyCliError(error) {
1075
1084
  if (error instanceof z.ZodError ||
1076
1085
  error instanceof CliInputError ||
1077
1086
  error instanceof CliInputRequiredError ||
1078
- error instanceof CliAuthError) {
1079
- // Auth/input-required are caller-actionable states (missing credentials,
1080
- // needs a choice), not unknown failures. Bucket them with validation so
1081
- // the missing-API-key case keeps its pre-hardening telemetry type.
1087
+ error instanceof CliAuthError ||
1088
+ error instanceof CliAccessError) {
1089
+ // Auth/input-required/access are caller-actionable states (missing
1090
+ // credentials, needs a choice, insufficient role), not unknown failures.
1091
+ // Bucket them with validation so the missing-API-key case keeps its
1092
+ // pre-hardening telemetry type.
1082
1093
  return 'validation_error';
1083
1094
  }
1084
1095
  if (error instanceof ApiError || isApiLikeError(error)) {
@@ -1095,6 +1106,11 @@ export function printCliError(error) {
1095
1106
  if (error.usage)
1096
1107
  safeWriteStderr(`${error.usage}\n`);
1097
1108
  }
1109
+ else if (error instanceof CliAccessError) {
1110
+ safeWriteStderr(`Error: ${error.message}\n`);
1111
+ if (error.guidance)
1112
+ safeWriteStderr(`${error.guidance}\n`);
1113
+ }
1098
1114
  else if (error instanceof z.ZodError) {
1099
1115
  const issues = error.errors.map((e) => {
1100
1116
  const field = e.path.length ? e.path.join('.') : 'input';
package/dist/config.d.ts CHANGED
@@ -17,8 +17,11 @@ export declare function loadCliConfig(options?: ProfileIOOptions): CliConfig;
17
17
  /**
18
18
  * Merge-and-write the persisted config. Always chmod 0600 since it holds the
19
19
  * API key. Best-effort: never throws on chmod failure (some filesystems).
20
+ * `options.profile` scopes the best-effort profile sync (e.g. `moda init
21
+ * --profile staging`), so the key is synced to the requested profile rather
22
+ * than whatever profile happens to be active.
20
23
  */
21
- export declare function saveCliConfig(update: CliConfig): void;
24
+ export declare function saveCliConfig(update: CliConfig, options?: ProfileIOOptions): void;
22
25
  /**
23
26
  * Resolve the API key: prefer the environment (so CI / explicit exports win),
24
27
  * then fall back to the persisted `~/.moda/config.json`. Read lazily so a
package/dist/config.js CHANGED
@@ -14,11 +14,14 @@ export function loadCliConfig(options = {}) {
14
14
  /**
15
15
  * Merge-and-write the persisted config. Always chmod 0600 since it holds the
16
16
  * API key. Best-effort: never throws on chmod failure (some filesystems).
17
+ * `options.profile` scopes the best-effort profile sync (e.g. `moda init
18
+ * --profile staging`), so the key is synced to the requested profile rather
19
+ * than whatever profile happens to be active.
17
20
  */
18
- export function saveCliConfig(update) {
19
- const merged = writeLegacyCliConfig(update);
21
+ export function saveCliConfig(update, options = {}) {
22
+ const merged = writeLegacyCliConfig(update, options);
20
23
  try {
21
- syncLegacyConfigToProfile(merged);
24
+ syncLegacyConfigToProfile(merged, options);
22
25
  }
23
26
  catch {
24
27
  // The legacy config write is the compatibility contract; profile sync is a
package/dist/errors.d.ts CHANGED
@@ -2,6 +2,19 @@ export declare class CliInputError extends Error {
2
2
  readonly usage?: string | undefined;
3
3
  constructor(message: string, usage?: string | undefined);
4
4
  }
5
+ /**
6
+ * The authenticated user cannot perform the requested action with their
7
+ * current access (no organizations, or insufficient role). This is an access
8
+ * problem, not missing auth — the dispatcher maps it to EXIT_ERROR (1),
9
+ * never EXIT_AUTH_REQUIRED.
10
+ */
11
+ export declare class CliAccessError extends Error {
12
+ /** Optional follow-up line telling the user how to obtain access. */
13
+ readonly guidance?: string;
14
+ constructor(message: string, options?: {
15
+ guidance?: string;
16
+ });
17
+ }
5
18
  export interface AgentInputChoice {
6
19
  value: string;
7
20
  label: string;
package/dist/errors.js CHANGED
@@ -6,6 +6,21 @@ export class CliInputError extends Error {
6
6
  this.name = 'CliInputError';
7
7
  }
8
8
  }
9
+ /**
10
+ * The authenticated user cannot perform the requested action with their
11
+ * current access (no organizations, or insufficient role). This is an access
12
+ * problem, not missing auth — the dispatcher maps it to EXIT_ERROR (1),
13
+ * never EXIT_AUTH_REQUIRED.
14
+ */
15
+ export class CliAccessError extends Error {
16
+ /** Optional follow-up line telling the user how to obtain access. */
17
+ guidance;
18
+ constructor(message, options = {}) {
19
+ super(message);
20
+ this.name = 'CliAccessError';
21
+ this.guidance = options.guidance;
22
+ }
23
+ }
9
24
  export class CliInputRequiredError extends Error {
10
25
  input_request;
11
26
  constructor(message, input_request, usage) {
@@ -1,4 +1,4 @@
1
- import { CliAuthError, CliCancellationError, CliInputRequiredError, } from './errors.js';
1
+ import { CliAccessError, CliAuthError, CliCancellationError, CliInputRequiredError, } from './errors.js';
2
2
  import { EXIT_AUTH_REQUIRED, EXIT_CANCELLED, EXIT_ERROR, EXIT_INPUT_REQUIRED, EXIT_OK, wasOutputCancelled, } from './output.js';
3
3
  const finishedContexts = new WeakSet();
4
4
  export async function finishRun(context, outcome) {
@@ -37,6 +37,10 @@ export function exitCodeForError(error) {
37
37
  return EXIT_INPUT_REQUIRED;
38
38
  if (error instanceof CliCancellationError)
39
39
  return EXIT_CANCELLED;
40
+ // An access problem (missing org / insufficient role) is a hard failure,
41
+ // never auth-required — it stays EXIT_ERROR (the default), made explicit here.
42
+ if (error instanceof CliAccessError)
43
+ return EXIT_ERROR;
40
44
  return EXIT_ERROR;
41
45
  }
42
46
  function exitCodeForOutcome(outcome) {
@@ -622,7 +622,8 @@ function graphEvidenceById(graph) {
622
622
  const evidence = item.evidence
623
623
  .filter((entry) => (isRecord(entry) &&
624
624
  typeof entry.path === 'string' &&
625
- typeof entry.reason === 'string'));
625
+ typeof entry.reason === 'string'))
626
+ .map((entry) => normalizeEvidenceEntry(entry));
626
627
  if (evidence.length > 0)
627
628
  byId.set(item.id, evidence);
628
629
  }
@@ -637,10 +638,15 @@ function dedupeEvidence(evidence) {
637
638
  if (seen.has(key))
638
639
  continue;
639
640
  seen.add(key);
640
- deduped.push({ path: item.path, reason: item.reason });
641
+ deduped.push(normalizeEvidenceEntry(item));
641
642
  }
642
643
  return deduped;
643
644
  }
645
+ /** Keep the `{ path, reason }` shape, preserving a valid structured `line`. */
646
+ function normalizeEvidenceEntry(item) {
647
+ const line = evidenceLine(item);
648
+ return line ? { path: item.path, reason: item.reason, line } : { path: item.path, reason: item.reason };
649
+ }
644
650
  function normalizeHarnessClaim(claim, graph) {
645
651
  if (!isRecord(claim))
646
652
  return claim;
@@ -792,11 +798,52 @@ function validateGraphCollection(graph, errors, field, shape) {
792
798
  for (const [evidenceIndex, evidence] of item.evidence.entries()) {
793
799
  if (!isRecord(evidence) || typeof evidence.path !== 'string' || typeof evidence.reason !== 'string') {
794
800
  errors.push(`Graph ${label} evidence ${evidenceIndex + 1} must include path and reason.`);
801
+ continue;
802
+ }
803
+ if (evidence.line !== undefined && (typeof evidence.line !== 'number' || !Number.isFinite(evidence.line) || evidence.line <= 0)) {
804
+ errors.push(`Graph ${label} evidence ${evidenceIndex + 1} line must be a positive number.`);
795
805
  }
796
806
  }
797
807
  }
808
+ if (field === 'artifacts' && requiresArtifactEvidenceLine(item.type) && !artifactHasRetrievableLocation(item)) {
809
+ errors.push(`Graph ${label} is a ${String(item.type)} artifact and must include a numeric evidence line (or a dedicated whole-file source) so its exact text can be retrieved.`);
810
+ }
798
811
  }
799
812
  }
813
+ /** prompt/tool artifacts must pin an exact source location for downstream retrieval. */
814
+ function requiresArtifactEvidenceLine(type) {
815
+ return type === 'prompt' || type === 'tool';
816
+ }
817
+ /**
818
+ * A prompt/tool artifact's text is retrievable when either (a) at least one
819
+ * evidence entry carries a resolvable line, or (b) the artifact is a whole
820
+ * dedicated file — its evidence points at the same file named in `sourcePath`,
821
+ * so the entire file IS the artifact and no line is needed.
822
+ */
823
+ function artifactHasRetrievableLocation(item) {
824
+ if (hasEvidenceLine(item.evidence))
825
+ return true;
826
+ return isWholeFileArtifact(item);
827
+ }
828
+ function isWholeFileArtifact(item) {
829
+ if (typeof item.sourcePath !== 'string' || item.sourcePath.length === 0)
830
+ return false;
831
+ if (!Array.isArray(item.evidence))
832
+ return false;
833
+ return item.evidence.some((entry) => isRecord(entry) && entry.path === item.sourcePath);
834
+ }
835
+ function hasEvidenceLine(evidence) {
836
+ if (!Array.isArray(evidence))
837
+ return false;
838
+ return evidence.some((entry) => (isRecord(entry) &&
839
+ typeof entry.path === 'string' &&
840
+ typeof entry.reason === 'string' &&
841
+ evidenceLine({
842
+ path: entry.path,
843
+ reason: entry.reason,
844
+ line: typeof entry.line === 'number' ? entry.line : undefined,
845
+ }) !== undefined));
846
+ }
800
847
  function normalizeGraphItemFieldAliases(collection, item) {
801
848
  if (!isRecord(item))
802
849
  return;
@@ -1155,7 +1202,7 @@ function upsertCitation(citations, citationIndex, evidence, rootDir) {
1155
1202
  return undefined;
1156
1203
  if (evidence.path === '.')
1157
1204
  return undefined;
1158
- const line = lineFromReason(evidence.reason);
1205
+ const line = evidenceLine(evidence);
1159
1206
  const key = `${evidence.path}:${line ?? ''}:${evidence.reason}`;
1160
1207
  const existing = citationIndex.get(key);
1161
1208
  if (existing)
@@ -1389,6 +1436,16 @@ function lineFromReason(reason) {
1389
1436
  const value = Number(match[1]);
1390
1437
  return Number.isFinite(value) && value > 0 ? value : undefined;
1391
1438
  }
1439
+ /**
1440
+ * Resolve the 1-based source line for an evidence entry, preferring the
1441
+ * structured `line` field and falling back to a "line N" mention in the reason.
1442
+ */
1443
+ function evidenceLine(evidence) {
1444
+ if (typeof evidence.line === 'number' && Number.isFinite(evidence.line) && evidence.line > 0) {
1445
+ return Math.trunc(evidence.line);
1446
+ }
1447
+ return lineFromReason(evidence.reason);
1448
+ }
1392
1449
  function inferenceFromConfidence(confidence) {
1393
1450
  if (confidence >= 0.85)
1394
1451
  return 'direct';
package/dist/harness.d.ts CHANGED
@@ -4,6 +4,12 @@ declare const SCHEMA_VERSION = "harness.v0.1";
4
4
  export interface HarnessEvidence {
5
5
  path: string;
6
6
  reason: string;
7
+ /**
8
+ * 1-based line where the cited item is defined. Optional in general, but
9
+ * required for `prompt` and `tool` artifacts so downstream prompt/tool
10
+ * optimization can retrieve the exact source text of every enumerated item.
11
+ */
12
+ line?: number;
7
13
  }
8
14
  export interface HarnessDetection {
9
15
  confidence: number;
package/dist/harness.js CHANGED
@@ -1343,11 +1343,12 @@ function renderExternalHarnessAnalystPrompt(options) {
1343
1343
  '',
1344
1344
  'Your task:',
1345
1345
  renderExternalAnalystProtocol(),
1346
- '1. Identify the actual runtime agent harnesses, agent families, prompts, tools, channel adapters, identity mappings, evals, model providers, frameworks, deployments, and open questions.',
1347
- '2. Cite every claim with local file paths, exact line numbers, and excerpts. File citations without line numbers are invalid for agent-authored reports.',
1348
- `3. After completing investigation, print the report JSON between ${REPORT_JSON_BEGIN} and ${REPORT_JSON_END}. Moda CLI will validate it and write the local artifacts.`,
1346
+ '1. Identify the runtime agent harnesses, agent families, channel adapters, identity mappings, model providers, frameworks, deployments, and open questions.',
1347
+ '2. Exhaustively enumerate EVERY prompt, tool, guardrail, and eval as its own graph artifact — the complete inventory, not a sample. This report feeds prompt optimization and tool optimization, so a missing tool or prompt is a real defect. Runtime agents may be modeled representatively on framework/example repos, but prompts and tools must be complete.',
1348
+ '3. Cite every narrative claim with local file paths, exact line numbers, and excerpts. File citations without line numbers are invalid for agent-authored reports. Enumerated artifacts only need evidence:[{path, reason}], so completeness of the artifact inventory is not limited by the citation cap.',
1349
+ `4. After completing investigation, print the report JSON between ${REPORT_JSON_BEGIN} and ${REPORT_JSON_END}. Moda CLI will validate it and write the local artifacts.`,
1349
1350
  'Your final response must contain the sentinel-wrapped JSON report. Do not end with commentary about being read-only, do not summarize without JSON, and do not omit the sentinel markers.',
1350
- 'If the repository is too large to fully enumerate, stop broad exploration and produce the best conservative cited report with unknowns instead of continuing indefinitely.',
1351
+ 'If the repository is very large, keep enumerating prompts/tools/guardrails/evals as artifacts (uncapped) and only summarize runtime AGENTS representatively — do not drop tools or prompts to save space.',
1351
1352
  '',
1352
1353
  'Final output format:',
1353
1354
  REPORT_JSON_BEGIN,
@@ -1372,7 +1373,8 @@ function renderExternalHarnessAnalystPrompt(options) {
1372
1373
  'Use exact field names and numeric confidence values from 0 to 1. Do not write string confidence values like "high". Do not use confidence >= 0.90 unless the claim is direct evidence from the cited line(s).',
1373
1374
  'Each graph item with evidence must use evidence: [{ path, reason }], not citationIds/citations.',
1374
1375
  'Runtime agents must include id, name, kind:"runtime_agent", root, entrypointIds, channelAdapterIds, artifactIds, identityNamespaceIds, deploymentIds, frameworkIds, modelProviderIds, confidence, evidence.',
1375
- 'Artifacts must include id, type, name, scope, usedByAgentIds, confidence, evidence, and optional sourcePath/contentHash/ownedByAgentId.',
1376
+ 'Artifacts must include id, type, name, scope, usedByAgentIds, confidence, evidence, and optional sourcePath/contentHash/ownedByAgentId. Emit one artifact PER tool, PER prompt (including each inline system prompt), PER guardrail, and PER eval — enumerate them all; artifact types are entrypoint, prompt, tool, skill, eval, retrieval_index, memory, guardrail, model_config, unknown.',
1377
+ 'For every `prompt` and `tool` artifact, at least one evidence entry MUST pin an exact source location so downstream optimization can retrieve the item text: carry a numeric `line` (the definition line, or the first line of the range) — evidence: [{ path, line, reason }]. The only exception is an artifact whose entire dedicated file is the text (e.g. a standalone prompt file); set its sourcePath and cite that same file and no line is required. When a single file holds multiple inline prompts or tool definitions, give each its own distinct line — a bare { path, reason } is a defect there.',
1376
1378
  'Agent families must include id, name, runtimeAgentIds, sharedArtifactIds, confidence, evidence.',
1377
1379
  'Frameworks/providers must include packageNames and runtimeAgentIds arrays; providers also include envVars and modelHints arrays.',
1378
1380
  'Unknowns in graph must include id, question, candidateIds, evidence. Report-level unknowns must include id, question, citationIds.',
@@ -1393,11 +1395,15 @@ function renderExternalAnalystProtocol() {
1393
1395
  return [
1394
1396
  'Focused investigation protocol:',
1395
1397
  '- First build a quick repo map with read-only commands such as pwd, git ls-files, rg, and targeted manifest/doc reads.',
1396
- '- Prioritize runtime entrypoints, prompt/tool definitions, framework/provider calls, channel adapters, evals, and deploy config over exhaustive file enumeration.',
1397
- '- If the repository is a framework, SDK, or examples collection, do not enumerate every example as a production runtime agent. Model representative harnesses, reusable frameworks, and unknowns conservatively with citations.',
1398
- '- For every claimed runtime agent, read and cite at least one concrete entrypoint or orchestration line. Add prompt/tool/config citations when available.',
1399
- '- Stop exploring once the cited graph is sufficiently supported. Use unknowns for unresolved questions instead of continuing broad exploration.',
1400
- '- Prefer a compact report: target 8-16 high-signal claims and 12-40 citations unless the repo clearly needs more.',
1398
+ '- EXHAUSTIVELY enumerate every prompt, tool, guardrail, eval, memory/retrieval store, and model config in the repository — one graph artifact each. This inventory feeds downstream prompt optimization and tool optimization, so completeness matters more than brevity: do NOT stop at representative examples and do NOT sample.',
1399
+ '- Tools: emit a `tool` artifact for EVERY tool definition — each `@function_tool` / `function_tool(...)`-decorated function, every hosted/first-party tool class (e.g. WebSearchTool, FileSearchTool, ComputerTool, CodeInterpreterTool, HostedMCPTool, ImageGenerationTool, LocalShellTool), and every `tool({...})` / `createTool(...)` / `new *Tool(...)` factory. Name each artifact after the tool/function and cite its definition line.',
1400
+ '- Prompts: emit a `prompt` artifact for EVERY distinct prompt — dedicated prompt files AND inline system prompts (`instructions=`, `system=`, `SYSTEM_PROMPT`, prompt-template strings), one per agent/definition, each with its exact file+line location so the prompt text can be retrieved and optimized later.',
1401
+ '- Also emit artifacts for guardrails (type "guardrail"), evals/tests-of-agents (type "eval"), memory/session stores (type "memory"), retrieval indexes (type "retrieval_index"), and model configuration (type "model_config").',
1402
+ '- Only runtime AGENTS may be modeled representatively: if the repo is a framework/SDK/examples collection, you need not treat every example as a distinct production agent — but you MUST still enumerate every distinct tool, prompt, guardrail, and eval as its own artifact.',
1403
+ '- Each enumerated artifact needs evidence: [{ path, reason }] (file path + short reason); a full line+excerpt citation is required only for the narrative `claims`, NOT for every artifact — so listing hundreds of tools/prompts is cheap and expected.',
1404
+ '- EXCEPTION — `prompt` and `tool` artifacts MUST pin an exact location: put a numeric `line` on at least one evidence entry (evidence: [{ path, line, reason }]) at the definition line. That structured location is what lets downstream optimization retrieve the exact prompt/tool text; when one file has several inline prompts or tools, each artifact gets its own line. The only case that needs no line is a whole dedicated file whose entire content is the artifact (set sourcePath to that file and cite it). Still no excerpt required — just the line.',
1405
+ '- For every runtime agent, read and cite at least one concrete entrypoint or orchestration line, and link its prompts/tools via artifactIds.',
1406
+ '- Keep going until every prompt, tool, guardrail, and eval in scope is represented as an artifact. Use unknowns only for genuinely ambiguous items — never to skip enumeration. Respect the hard caps (<=250 claims, <=400 citations): artifacts are uncapped, so when there are many items keep enumerating them as artifacts and reserve claims/citations for the most important ones.',
1401
1407
  ].join('\n');
1402
1408
  }
1403
1409
  function renderAnalystPreflightSection(rootDir) {
@@ -77,11 +77,17 @@ export async function runInit(flags, options = {}) {
77
77
  // Step 3: Tenant + API Key
78
78
  log(renderInitStep(3, 7, 'Workspace & Profile'));
79
79
  emit('workspace', 'Selecting workspace and creating API key.');
80
+ // --tenant-id flag wins over MODA_TENANT_ID; the source drives the
81
+ // env-shadow warning so we don't warn when the env var was the selection.
82
+ const tenantIdFlag = stringFlag(flagValue(flags, 'tenant-id', 'tenant_id'));
83
+ const tenantIdEnv = process.env.MODA_TENANT_ID;
80
84
  const { apiKey, tenantName } = await selectTenantAndCreateKey({
81
- tenantId: stringFlag(flagValue(flags, 'tenant-id', 'tenant_id')),
85
+ tenantId: tenantIdFlag ?? tenantIdEnv,
86
+ tenantIdSource: tenantIdFlag ? 'flag' : tenantIdEnv ? 'env' : undefined,
82
87
  nonInteractive: json || agent,
83
88
  silent: !human,
84
89
  resumeCommand: ['moda', 'init', ...resumeFlagsForInit(flags)],
90
+ profile: stringFlag(flags.profile),
85
91
  });
86
92
  log(renderInitNote(`Active workspace: ${tenantName}`));
87
93
  emit('workspace', `Active workspace: ${tenantName}`, 'completed');
@@ -4,11 +4,25 @@ interface BootstrapResult {
4
4
  tenantName: string;
5
5
  tenantSlug: string;
6
6
  }
7
- export declare function selectTenantAndCreateKey(options?: {
7
+ export interface SelectTenantAndCreateKeyOptions {
8
+ /** Tenant id preselected via --tenant-id (flag) or MODA_TENANT_ID (env). */
8
9
  tenantId?: string;
10
+ /** Where the preselected tenant id came from; drives the env-shadow warning. */
11
+ tenantIdSource?: 'flag' | 'env';
12
+ /** Non-interactive runs must never open the selection prompt. */
9
13
  nonInteractive?: boolean;
14
+ /** Suppress human prose so it never interleaves with JSON envelopes on stdout. */
10
15
  silent?: boolean;
16
+ /** Resume command echoed into the needs_input event for multi-tenant runs. */
11
17
  resumeCommand?: string[];
12
- }): Promise<BootstrapResult>;
18
+ /**
19
+ * Profile context (--profile) threaded from init: scopes the auth-session
20
+ * reads and request origin behind authFetch, the stored-key candidate
21
+ * lookup, and the liveness-probe base URL to the given profile.
22
+ */
23
+ profile?: string;
24
+ env?: NodeJS.ProcessEnv;
25
+ }
26
+ export declare function selectTenantAndCreateKey(options?: SelectTenantAndCreateKeyOptions): Promise<BootstrapResult>;
13
27
  export {};
14
28
  //# sourceMappingURL=tenant.d.ts.map
@@ -1,36 +1,133 @@
1
1
  // src/init/tenant.ts — Tenant selection and API key provisioning
2
2
  import { select } from '@inquirer/prompts';
3
3
  import { authFetch } from '../auth.js';
4
- import { resolveIngestUrl, saveCliConfig } from '../config.js';
5
- import { CliInputError, CliInputRequiredError } from '../errors.js';
4
+ import { loadCliConfig, resolveIngestUrl, resolveModaBaseUrl, saveCliConfig, } from '../config.js';
5
+ import { resolveApiKeyFromActiveProfile, resolveTenantIdFromActiveProfile, } from '../profiles.js';
6
+ import { CliAccessError, CliInputError, CliInputRequiredError } from '../errors.js';
7
+ // Mirrors the backend's checkAdminAccess: only the viewer role is denied.
8
+ const ADMIN_ACCESS_MESSAGE = 'You need admin or owner access to create API keys.';
9
+ const ADMIN_ACCESS_GUIDANCE = 'Ask your organization owner to upgrade your role.';
10
+ const API_KEY_PREFIX = 'moda_sk_';
11
+ const SIGNED_KEY_VERSION = 'v1';
12
+ // Bound the pre-bootstrap liveness probe tightly: a slow or unreachable
13
+ // endpoint must never block init — we just mint a fresh key instead.
14
+ const VALIDATE_TIMEOUT_MS = 3_000;
15
+ /**
16
+ * Signed CLI keys embed their tenant: the format is
17
+ * moda_sk_<base64url("v1:keyId:tenantId:timestamp:signature")> — see
18
+ * createApiKey/parseAndVerifySignedKey in
19
+ * apps/moda-backend/src/api-keys/api-keys.service.ts. Decoding locally lets
20
+ * dedup reject a cross-tenant or malformed key structurally, with no network
21
+ * call. The HMAC signature can only be verified server-side; liveness (not
22
+ * revoked) is probed separately by isStoredKeyLive().
23
+ */
24
+ function embeddedTenantId(key) {
25
+ if (!key.startsWith(API_KEY_PREFIX))
26
+ return undefined;
27
+ try {
28
+ const decoded = Buffer.from(key.slice(API_KEY_PREFIX.length), 'base64url').toString('utf8');
29
+ // Expected format: version:keyId:tenantId:timestamp:signature
30
+ const parts = decoded.split(':');
31
+ if (parts.length !== 5 || parts[0] !== SIGNED_KEY_VERSION)
32
+ return undefined;
33
+ return parts[2] || undefined;
34
+ }
35
+ catch {
36
+ return undefined;
37
+ }
38
+ }
39
+ /**
40
+ * Liveness probe for a stored key. Deliberately NOT the backend's public
41
+ * POST /api/api-keys/validate: the CLI's resolved base URL is the FRONTEND
42
+ * origin (moda.dev in prod, localhost:3000 in dev), which has no
43
+ * /api/api-keys/validate route and no rewrite for it — only /api/v1/data/* is
44
+ * proxied to the backend. Probing the Data API is the only path that reaches
45
+ * key validation from here; revisit when a validate proxy is added. Only a
46
+ * 200 with a JSON body counts as live; 401/403 (revoked/invalid), any other
47
+ * status, a non-JSON 200 (e.g. a frontend error page), a network error, or a
48
+ * timeout all return false so init falls through to minting — never block.
49
+ * The key value must never appear in logs, errors, or telemetry.
50
+ */
51
+ async function isStoredKeyLive(key, options = {}) {
52
+ try {
53
+ // Resolve the base URL with the same profile/env context as the stored-key
54
+ // lookup, or a valid key for a non-active profile (e.g. `init --profile
55
+ // staging` while prod is active) is probed against the wrong origin, read
56
+ // as dead, and re-minted on every run.
57
+ const response = await fetch(`${resolveModaBaseUrl(options)}/api/v1/data/overview`, {
58
+ method: 'GET',
59
+ headers: { 'x-api-key': key },
60
+ signal: AbortSignal.timeout(VALIDATE_TIMEOUT_MS),
61
+ });
62
+ if (response.status !== 200)
63
+ return false;
64
+ await response.json();
65
+ return true;
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ }
71
+ /**
72
+ * A CLI key previously stored for this tenant, if any: the legacy
73
+ * ~/.moda/config.json first, then the active profile's key reference. Only a
74
+ * key whose stored tenant_id matches the selected tenant is a candidate.
75
+ */
76
+ function findStoredKeyForTenant(tenantId, options = {}) {
77
+ const legacy = loadCliConfig(options);
78
+ if (legacy.api_key && legacy.tenant_id === tenantId) {
79
+ return legacy.api_key;
80
+ }
81
+ // Honors options.profile when init was run with --profile; otherwise the
82
+ // active profile / MODA_PROFILE resolution applies.
83
+ if (resolveTenantIdFromActiveProfile(options) === tenantId) {
84
+ const profileKey = resolveApiKeyFromActiveProfile(options);
85
+ if (profileKey)
86
+ return profileKey;
87
+ }
88
+ return undefined;
89
+ }
6
90
  export async function selectTenantAndCreateKey(options = {}) {
91
+ const log = (message) => {
92
+ if (!options.silent)
93
+ console.log(message);
94
+ };
95
+ // Auth-session reads and stored-key lookups must honor init's --profile, or
96
+ // a profile-scoped login (secrets/auth/<profile>.json) is invisible here and
97
+ // init fails with "Not authenticated" despite a fresh token.
98
+ const authOptions = {
99
+ profile: options.profile,
100
+ env: options.env,
101
+ };
7
102
  // 1. Fetch CLI context
8
- const contextRes = await authFetch('/api/cli/context');
103
+ const contextRes = await authFetch('/api/cli/context', {}, authOptions);
9
104
  if (!contextRes.ok) {
10
105
  const body = await contextRes.text();
11
106
  throw new Error(`Failed to fetch context (${contextRes.status}): ${body}`);
12
107
  }
13
108
  const ctx = (await contextRes.json());
14
- if (!options.silent)
15
- console.log(` Authenticated as ${ctx.user.email}`);
109
+ log(` Authenticated as ${ctx.user.email}`);
16
110
  // 2. Select tenant
17
111
  if (ctx.tenants.length === 0) {
18
- throw new CliInputError('No organizations found. Create one at https://moda.dev', 'Create an organization in Moda, then rerun `moda init`.');
112
+ throw new CliAccessError('No organizations found. Create one at https://moda.dev');
19
113
  }
20
- let tenantId;
21
- if (options.tenantId) {
22
- const selected = ctx.tenants.find((tenant) => tenant.id === options.tenantId);
23
- if (!selected) {
24
- throw new CliInputError(`Tenant '${options.tenantId}' is not available for the authenticated user.`, 'Run `moda init` without --tenant-id in a human terminal, or choose a tenant from the needs_input event.');
114
+ let selected;
115
+ if (options.tenantId !== undefined) {
116
+ // Exact id match only (case-sensitive; never slug/name, never a silent
117
+ // fallback to another tenant).
118
+ const match = ctx.tenants.find((tenant) => tenant.id === options.tenantId);
119
+ if (!match) {
120
+ const valid = ctx.tenants
121
+ .map((tenant) => ` ${tenant.id} ${tenant.name} (${tenant.role})`)
122
+ .join('\n');
123
+ throw new CliInputError(`No organization with id '${options.tenantId}'. Valid organizations:\n${valid}`, 'Re-run with --tenant-id=<id> using one of the ids listed above.');
25
124
  }
26
- tenantId = selected.id;
27
- if (!options.silent)
28
- console.log(` Organization: ${selected.name}`);
125
+ selected = match;
126
+ log(` Organization: ${selected.name}`);
29
127
  }
30
128
  else if (ctx.tenants.length === 1) {
31
- tenantId = ctx.tenants[0].id;
32
- if (!options.silent)
33
- console.log(` Organization: ${ctx.tenants[0].name}`);
129
+ selected = ctx.tenants[0];
130
+ log(` Organization: ${selected.name}`);
34
131
  }
35
132
  else if (options.nonInteractive) {
36
133
  throw new CliInputRequiredError('Multiple Moda organizations are available; choose one and re-run with --tenant-id.', {
@@ -46,28 +143,52 @@ export async function selectTenantAndCreateKey(options = {}) {
46
143
  }, 'Re-run `moda init --tenant-id <tenant_id>`.');
47
144
  }
48
145
  else {
49
- tenantId = await select({
146
+ selected = await select({
50
147
  message: 'Select organization',
51
- choices: ctx.tenants.map((t) => ({
52
- name: `${t.name} (${t.role})`,
53
- value: t.id,
148
+ choices: ctx.tenants.map((tenant) => ({
149
+ name: `${tenant.name} (${tenant.role})`,
150
+ value: tenant,
54
151
  })),
55
152
  });
56
153
  }
57
- // 3. Create API key via bootstrap endpoint
58
- const bootstrapRes = await authFetch('/api/cli/bootstrap', {
59
- method: 'POST',
60
- headers: { 'Content-Type': 'application/json' },
61
- body: JSON.stringify({ tenantId }),
62
- });
63
- if (bootstrapRes.status === 403) {
64
- throw new CliInputError('You need admin or owner access to create API keys.', 'Ask your organization owner to upgrade your role.');
154
+ // Role pre-check: the backend's bootstrap endpoint 403s viewers, so fail
155
+ // before creating anything. The 403 handler below stays as a backstop.
156
+ if (selected.role.toLowerCase() === 'viewer') {
157
+ throw new CliAccessError(ADMIN_ACCESS_MESSAGE, { guidance: ADMIN_ACCESS_GUIDANCE });
158
+ }
159
+ // 3. Reuse a previously minted key for this tenant when it structurally
160
+ // belongs to it (embedded tenant id) AND still works against the Data API,
161
+ // so re-running init does not accumulate one CLI key per run. A failed
162
+ // decode, tenant mismatch, or probe failure falls through to minting.
163
+ let result;
164
+ const storedKey = findStoredKeyForTenant(selected.id, authOptions);
165
+ if (storedKey &&
166
+ embeddedTenantId(storedKey) === selected.id &&
167
+ (await isStoredKeyLive(storedKey, authOptions))) {
168
+ log(` Using existing CLI key for ${selected.name} (created earlier)`);
169
+ result = {
170
+ apiKey: storedKey,
171
+ tenantId: selected.id,
172
+ tenantName: selected.name,
173
+ tenantSlug: selected.slug,
174
+ };
65
175
  }
66
- if (!bootstrapRes.ok) {
67
- const body = await bootstrapRes.text();
68
- throw new Error(`Failed to create API key (${bootstrapRes.status}): ${body}`);
176
+ // 4. Create API key via bootstrap endpoint
177
+ if (!result) {
178
+ const bootstrapRes = await authFetch('/api/cli/bootstrap', {
179
+ method: 'POST',
180
+ headers: { 'Content-Type': 'application/json' },
181
+ body: JSON.stringify({ tenantId: selected.id }),
182
+ }, authOptions);
183
+ if (bootstrapRes.status === 403) {
184
+ throw new CliAccessError(ADMIN_ACCESS_MESSAGE, { guidance: ADMIN_ACCESS_GUIDANCE });
185
+ }
186
+ if (!bootstrapRes.ok) {
187
+ const body = await bootstrapRes.text();
188
+ throw new Error(`Failed to create API key (${bootstrapRes.status}): ${body}`);
189
+ }
190
+ result = (await bootstrapRes.json());
69
191
  }
70
- const result = (await bootstrapRes.json());
71
192
  // Persist to ~/.moda/config.json (mode 0600) so the snapshot/session-end
72
193
  // hook can authenticate (api_key) and the local dev orchestrator can scope
73
194
  // ClickHouse (tenant_id) without the user exporting env vars into the shell
@@ -75,12 +196,17 @@ export async function selectTenantAndCreateKey(options = {}) {
75
196
  saveCliConfig({
76
197
  api_key: result.apiKey,
77
198
  tenant_id: result.tenantId,
78
- ingest_url: resolveIngestUrl(),
79
- });
80
- if (!options.silent) {
81
- console.log(' API key + tenant saved to ~/.moda/config.json\n');
82
- console.log(' (Set MODA_API_KEY / MODA_TENANT_ID to override for scripts/CI.)\n');
199
+ ingest_url: resolveIngestUrl(authOptions),
200
+ }, authOptions);
201
+ // resolveTenantId() prefers MODA_TENANT_ID over the config file, so a stale
202
+ // env var silently shadows the selection we just saved. Warn unless the env
203
+ // var was itself the source of the selection.
204
+ const envTenantId = (options.env ?? process.env).MODA_TENANT_ID;
205
+ if (envTenantId && options.tenantIdSource !== 'env' && envTenantId !== result.tenantId) {
206
+ console.error(` Warning: MODA_TENANT_ID is set to a different organization id and will shadow the saved selection (${result.tenantId}) for later commands. Unset it or set it to the selected organization id.`);
83
207
  }
208
+ log(' API key + tenant saved to ~/.moda/config.json\n');
209
+ log(' (Set MODA_API_KEY / MODA_TENANT_ID to override for scripts/CI.)\n');
84
210
  return result;
85
211
  }
86
212
  //# sourceMappingURL=tenant.js.map
package/dist/output.d.ts CHANGED
@@ -19,7 +19,7 @@ export declare const EXIT_DEGRADED = 3;
19
19
  export declare const EXIT_AUTH_REQUIRED = 4;
20
20
  export declare const EXIT_INPUT_REQUIRED = 5;
21
21
  export declare const EXIT_CANCELLED = 130;
22
- export declare const AGENT_ERROR_CODES: readonly ["input_error", "api_error", "network_error", "auth_required", "input_required", "cancelled", "command_error", "unknown_error", "cloud_unavailable"];
22
+ export declare const AGENT_ERROR_CODES: readonly ["input_error", "api_error", "network_error", "auth_required", "input_required", "cancelled", "command_error", "access_denied", "unknown_error", "cloud_unavailable"];
23
23
  export type AgentErrorCode = typeof AGENT_ERROR_CODES[number];
24
24
  export type AgentStatus = 'ok' | 'degraded' | 'error';
25
25
  export type AgentConfidence = 'high' | 'medium' | 'low' | 'unknown';
package/dist/output.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { CliAuthError, CliCancellationError, CliInputError, CliInputRequiredError, isApiLikeError, isNetworkLikeError, } from './errors.js';
2
+ import { CliAccessError, CliAuthError, CliCancellationError, CliInputError, CliInputRequiredError, isApiLikeError, isNetworkLikeError, } from './errors.js';
3
3
  import { redactDeep } from './redact.js';
4
4
  export const AGENT_SCHEMA_VERSION = 'moda.agent.v1';
5
5
  export const AGENT_EVENT_SCHEMA_VERSION = 'moda.agent_event.v1';
@@ -28,6 +28,7 @@ export const AGENT_ERROR_CODES = [
28
28
  'input_required',
29
29
  'cancelled',
30
30
  'command_error',
31
+ 'access_denied',
31
32
  'unknown_error',
32
33
  'cloud_unavailable',
33
34
  ];
@@ -416,6 +417,16 @@ export function errorToAgentError(error) {
416
417
  blocking: true,
417
418
  };
418
419
  }
420
+ if (error instanceof CliAccessError) {
421
+ // Access problem (missing organization or insufficient role), not
422
+ // auth-missing: exit stays EXIT_ERROR and no login command is suggested.
423
+ return {
424
+ code: 'access_denied',
425
+ message: error.message,
426
+ cause: error.guidance,
427
+ blocking: true,
428
+ };
429
+ }
419
430
  if (error instanceof Error) {
420
431
  if (error instanceof CliInputError || error.name === 'ZodError') {
421
432
  return {
@@ -1,7 +1,11 @@
1
- import { authFetch, clearAuthSession, loadAuthSession, login } from './auth.js';
1
+ import { authFetch, clearAuthSession, isAuthSessionValid, loadAuthSession, login } from './auth.js';
2
+ import { EXIT_AUTH_REQUIRED, EXIT_OK } from './output.js';
2
3
  import { KEY_LIFECYCLE_ENDPOINT_REQUIREMENTS, createLocalKeyDescriptor, loadLocalKeyDescriptors, upsertLocalKeyDescriptor, writeLocalApiKeySecret, } from './keys.js';
3
4
  import { DEFAULT_INGEST_URL, PROD_BASE_URL, ensureSecretFilePermissions, getActiveProfile, getModaPaths, loadProfileConfig, resolveApiKeyFromActiveProfile, resolveSecretReference, secretFilePathFromRef, setActiveProfile, upsertProfile, } from './profiles.js';
4
5
  import { CliInputError } from './errors.js';
6
+ // Bound the `auth status --online` probe: without a signal, undici's defaults
7
+ // can stall a status check for minutes on a black-holed connection.
8
+ const ONLINE_PROBE_TIMEOUT_MS = 5_000;
5
9
  function subcommand(context) {
6
10
  return context.positionals[0];
7
11
  }
@@ -60,7 +64,7 @@ export async function runAuthCommand(context) {
60
64
  switch (subcommand(context)) {
61
65
  case 'login': {
62
66
  const screen = flag(context, 'screen') === 'signup' ? 'signup' : 'login';
63
- await login(screen);
67
+ await login(screen, { profile: context.profile, env: context.env });
64
68
  context.output.writeData({
65
69
  status: 'authenticated',
66
70
  profile: context.profile ?? process.env.MODA_PROFILE ?? null,
@@ -96,8 +100,73 @@ export async function runAuthCommand(context) {
96
100
  });
97
101
  return { exitCode: 0 };
98
102
  }
103
+ case 'status': {
104
+ // Being logged out is an expected state for `auth status`, not a
105
+ // failure: it must never throw CliAuthError (no error block, no error
106
+ // telemetry classification) and must never trigger a browser open. It
107
+ // reports clean data in every mode and signals session validity purely
108
+ // through the exit code (0 valid, 4 auth required).
109
+ const authOptions = { profile: context.profile, env: context.env };
110
+ const session = loadAuthSession({ ...authOptions, includeExpired: true });
111
+ const loggedIn = isAuthSessionValid(session);
112
+ const data = {
113
+ loggedIn,
114
+ profile: context.profile ??
115
+ context.env.MODA_PROFILE ??
116
+ session?.profile ??
117
+ null,
118
+ expiresAt: session
119
+ ? new Date(session.expires_at * 1000).toISOString()
120
+ : null,
121
+ };
122
+ if (boolFlag(context, 'online')) {
123
+ let online = { reachable: false };
124
+ try {
125
+ const response = await authFetch('/api/cli/context', { signal: AbortSignal.timeout(ONLINE_PROBE_TIMEOUT_MS) }, authOptions);
126
+ // Any HTTP response means the server was reached; `ok` separates a
127
+ // healthy 2xx from e.g. a 401/500.
128
+ online = { reachable: true, ok: response.ok };
129
+ if (response.ok) {
130
+ const body = await response.json();
131
+ if (Array.isArray(body.tenants)) {
132
+ online.tenantCount = body.tenants.length;
133
+ }
134
+ }
135
+ }
136
+ catch {
137
+ // Network failure, probe timeout, or no stored token to send: report
138
+ // reachable: false; the exit code still follows local session
139
+ // validity, never the online probe.
140
+ }
141
+ data.online = online;
142
+ }
143
+ context.output.writeData(data);
144
+ return { exitCode: loggedIn ? EXIT_OK : EXIT_AUTH_REQUIRED };
145
+ }
146
+ case 'token': {
147
+ const session = loadAuthSession({ profile: context.profile, env: context.env });
148
+ if (!session) {
149
+ if (context.flags.json === 'true') {
150
+ // An explicit --json caller gets a parseable failure on stdout.
151
+ process.stdout.write(`${JSON.stringify({ error: 'not_authenticated' })}\n`);
152
+ }
153
+ else {
154
+ console.error('Not authenticated. Run "moda auth login".');
155
+ }
156
+ return { exitCode: EXIT_AUTH_REQUIRED };
157
+ }
158
+ // `auth token` exists to compose as $(moda auth token) and in pipes.
159
+ // Command substitution IS a pipe, so stdout is a non-TTY and this CLI
160
+ // would auto-select agent mode and wrap the token in a JSON envelope,
161
+ // breaking every composition. This command therefore deliberately
162
+ // bypasses the CliOutput envelope machinery in EVERY output mode
163
+ // (human/json/agent/agent-stream) and writes exactly the bare token plus
164
+ // a trailing newline to stdout — nothing else.
165
+ process.stdout.write(`${session.access_token}\n`);
166
+ return { exitCode: EXIT_OK };
167
+ }
99
168
  default:
100
- throw new CliInputError('Unknown auth command.', 'Usage: moda auth <login|logout|whoami>');
169
+ throw new CliInputError('Unknown auth command.', 'Usage: moda auth <login|logout|status|token|whoami>');
101
170
  }
102
171
  }
103
172
  export async function runProfilesCommand(context) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moda-ai/cli",
3
- "version": "1.12.3",
3
+ "version": "1.13.0",
4
4
  "description": "CLI for Moda - AI agent analytics and observability",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema_version": "moda.skill_index.v1",
3
- "bundled_at": "2026-07-06T05:27:59.799Z",
4
- "cli_version": "1.12.3",
3
+ "bundled_at": "2026-07-06T09:53:55.227Z",
4
+ "cli_version": "1.13.0",
5
5
  "skills": [
6
6
  {
7
7
  "id": "integration-node-anthropic",
@@ -140,7 +140,10 @@ Fields an agent should read:
140
140
 
141
141
  `errors[].code` is one of `input_error`, `api_error`, `network_error`,
142
142
  `auth_required`, `input_required`, `cancelled`, `command_error`,
143
- `unknown_error`, or a documented domain code such as `cloud_unavailable`.
143
+ `access_denied`, `unknown_error`, or a documented domain code such as
144
+ `cloud_unavailable`. `access_denied` (exit 1) is an access problem — no
145
+ organizations or an insufficient (viewer) role — distinct from `auth_required`
146
+ (exit 4, missing/expired credentials).
144
147
 
145
148
  ### The `moda.agent_event.v1` stream
146
149