@moda-ai/cli 1.17.0 → 1.17.2

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.
@@ -1,7 +1,7 @@
1
1
  import { authFetch, clearAuthSession, isAuthSessionValid, loadAuthSession, login, revokeCliSessionToken, } from './auth.js';
2
2
  import { EXIT_AUTH_REQUIRED, EXIT_OK } from './output.js';
3
3
  import { KEY_LIFECYCLE_ENDPOINT_REQUIREMENTS, createLocalKeyDescriptor, loadLocalKeyDescriptors, upsertLocalKeyDescriptor, writeLocalApiKeySecret, } from './keys.js';
4
- import { DEFAULT_INGEST_URL, PROD_BASE_URL, ensureSecretFilePermissions, getActiveProfile, getModaPaths, loadProfileConfig, resolveApiKeyFromActiveProfile, resolveSecretReference, secretFilePathFromRef, setActiveProfile, upsertProfile, } from './profiles.js';
4
+ import { DEFAULT_INGEST_URL, PROD_BASE_URL, clearStoredApiKeyCredentials, ensureSecretFilePermissions, getActiveProfile, getModaPaths, loadProfileConfig, resolveApiKeyFromActiveProfile, resolveSecretReference, secretFilePathFromRef, setActiveProfile, upsertProfile, } from './profiles.js';
5
5
  import { CliInputError } from './errors.js';
6
6
  // Bound the `auth status --online` probe: without a signal, undici's defaults
7
7
  // can stall a status check for minutes on a black-holed connection.
@@ -77,9 +77,21 @@ export async function runAuthCommand(context) {
77
77
  // auth0-kind sessions have nothing to revoke.
78
78
  await revokeCliSessionToken({ profile: context.profile, env: context.env });
79
79
  clearAuthSession({ profile: context.profile, env: context.env });
80
+ // The session token is only the control-plane credential. Data commands
81
+ // authenticate with the moda_sk_ API key written by `moda init`
82
+ // (~/.moda/config.json + profile api_key_ref), so logout must clear it
83
+ // too or the next login keeps acting as the old tenant.
84
+ const apiKeyCleared = clearStoredApiKeyCredentials({
85
+ profile: context.profile,
86
+ env: context.env,
87
+ });
88
+ if (context.env.MODA_API_KEY) {
89
+ console.error('Note: MODA_API_KEY is set in the environment and still takes precedence. Unset it to fully sign out.');
90
+ }
80
91
  context.output.writeData({
81
92
  status: 'logged_out',
82
93
  profile: context.profile ?? process.env.MODA_PROFILE ?? null,
94
+ api_key_cleared: apiKeyCleared,
83
95
  });
84
96
  return { exitCode: 0 };
85
97
  }
@@ -80,6 +80,20 @@ export declare function getActiveProfile(options?: ProfileIOOptions): ModaProfil
80
80
  export declare function setActiveProfile(name: string, options?: ProfileIOOptions): ProfileConfig;
81
81
  export declare function upsertProfile(profile: ModaProfile, options?: ProfileIOOptions): ProfileConfig;
82
82
  export declare function syncLegacyConfigToProfile(legacy: LegacyCliConfig, options?: ProfileIOOptions): ProfileConfig;
83
+ /**
84
+ * Forget the stored data-plane credentials (`moda_sk_` API key + tenant
85
+ * binding written by `moda init`): the legacy config's api_key/tenant_id and
86
+ * the target profile's api_key_ref/tenant_id, deleting `file:` key secrets
87
+ * under ~/.moda. Called by `moda auth logout` — without this, analytics
88
+ * commands keep authenticating as the old tenant after a re-login, because
89
+ * they use the x-api-key plane, not the session token. MODA_API_KEY in the
90
+ * environment still wins at resolve time.
91
+ *
92
+ * The legacy config must be cleared BEFORE reading the profile config:
93
+ * applyLegacyAwareness re-derives api_key_ref onto the prod profile on every
94
+ * load while config.json still holds an api_key.
95
+ */
96
+ export declare function clearStoredApiKeyCredentials(options?: ProfileIOOptions): boolean;
83
97
  export declare function secretFilePathFromRef(reference: SecretReference, options?: ProfileIOOptions): string | undefined;
84
98
  export declare function resolveSecretReference(reference: SecretReference | undefined, options?: ProfileIOOptions): string | undefined;
85
99
  export declare function resolveApiKeyFromActiveProfile(options?: ProfileIOOptions): string | undefined;
package/dist/profiles.js CHANGED
@@ -1,4 +1,4 @@
1
- import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync, } from 'node:fs';
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync, } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { dirname, isAbsolute, join, normalize, relative } from 'node:path';
4
4
  export const PROFILE_CONFIG_VERSION = 1;
@@ -347,6 +347,44 @@ export function syncLegacyConfigToProfile(legacy, options = {}) {
347
347
  },
348
348
  }, options);
349
349
  }
350
+ /**
351
+ * Forget the stored data-plane credentials (`moda_sk_` API key + tenant
352
+ * binding written by `moda init`): the legacy config's api_key/tenant_id and
353
+ * the target profile's api_key_ref/tenant_id, deleting `file:` key secrets
354
+ * under ~/.moda. Called by `moda auth logout` — without this, analytics
355
+ * commands keep authenticating as the old tenant after a re-login, because
356
+ * they use the x-api-key plane, not the session token. MODA_API_KEY in the
357
+ * environment still wins at resolve time.
358
+ *
359
+ * The legacy config must be cleared BEFORE reading the profile config:
360
+ * applyLegacyAwareness re-derives api_key_ref onto the prod profile on every
361
+ * load while config.json still holds an api_key.
362
+ */
363
+ export function clearStoredApiKeyCredentials(options = {}) {
364
+ let cleared = false;
365
+ const paths = getModaPaths(options);
366
+ const legacy = loadLegacyCliConfig(options);
367
+ if (legacy.api_key || legacy.tenant_id) {
368
+ writeSecretJsonFile(paths.legacyConfigPath, legacy.ingest_url ? { ingest_url: legacy.ingest_url } : {});
369
+ cleared = true;
370
+ }
371
+ const config = loadProfileConfig(options);
372
+ const profileName = options.profile ||
373
+ optionalString(getEnv(options).MODA_PROFILE) ||
374
+ config.active_profile;
375
+ const profile = config.profiles[profileName];
376
+ if (profile && (profile.api_key_ref || profile.tenant_id)) {
377
+ if (profile.api_key_ref?.startsWith('file:')) {
378
+ const secretPath = resolveFileSecretPath(profile.api_key_ref, options);
379
+ if (secretPath)
380
+ rmSync(secretPath, { force: true });
381
+ }
382
+ const { api_key_ref: _key, tenant_id: _tenant, ...rest } = profile;
383
+ saveProfileConfig({ ...config, profiles: { ...config.profiles, [profileName]: rest } }, options);
384
+ cleared = true;
385
+ }
386
+ return cleared;
387
+ }
350
388
  function resolveFileSecretPath(reference, options) {
351
389
  if (!reference.startsWith('file:'))
352
390
  return undefined;
@@ -0,0 +1,10 @@
1
+ import type { CommandContext } from './command-context.js';
2
+ import type { ProfileIOOptions } from './profiles.js';
3
+ /**
4
+ * `moda prompts propose` — turn a `prompts ab` run's failures into a revised
5
+ * candidate registered as a new (unlabeled) prompt version. Does NOT promote:
6
+ * the operator gates the candidate with a fresh `prompts ab` (ideally on the
7
+ * printed holdout cases the revision never saw) and promotes only if it wins.
8
+ */
9
+ export declare function runPromptPropose(positionals: string[], flags: Record<string, string>, profileOptions: ProfileIOOptions, context: CommandContext): Promise<void>;
10
+ //# sourceMappingURL=prompts-propose.d.ts.map
@@ -0,0 +1,76 @@
1
+ import { writeFileSync } from 'node:fs';
2
+ import { isAbsolute, resolve } from 'node:path';
3
+ import { callControlAPI } from './api-client.js';
4
+ import { validateConfig } from './config.js';
5
+ // Propose is a single OpenRouter revision behind the backend; give it room.
6
+ const PROPOSE_TIMEOUT_MS = 300_000;
7
+ /**
8
+ * `moda prompts propose` — turn a `prompts ab` run's failures into a revised
9
+ * candidate registered as a new (unlabeled) prompt version. Does NOT promote:
10
+ * the operator gates the candidate with a fresh `prompts ab` (ideally on the
11
+ * printed holdout cases the revision never saw) and promotes only if it wins.
12
+ */
13
+ export async function runPromptPropose(positionals, flags, profileOptions, context) {
14
+ validateConfig();
15
+ const promptKey = flags['prompt-key'] || positionals[1];
16
+ const fromRunId = flags['from-run'] || flags['from-run-id'] || flags['run-id'];
17
+ const replaySetId = flags['set-id'] || flags['replay-set-id'];
18
+ if (!promptKey || !fromRunId || !replaySetId) {
19
+ throw new Error('Usage: moda prompts propose --prompt-key=<key> --from-run=<run_id> --set-id=<replay_set_id> [--max-dossiers=8] [--out=path.prompt.md]');
20
+ }
21
+ const maxDossiers = parsePositiveInt(flags['max-dossiers'], 8, 16);
22
+ const result = (await callControlAPI(`/prompts/${encodeURIComponent(promptKey)}/propose`, { method: 'POST', body: JSON.stringify({ fromRunId, replaySetId, maxDossiers }) }, profileOptions, { timeoutMs: PROPOSE_TIMEOUT_MS, retries: 0 }));
23
+ let outPath;
24
+ if (result.status === 'proposed' && result.content && flags.out) {
25
+ outPath = isAbsolute(flags.out) ? flags.out : resolve(process.cwd(), flags.out);
26
+ writeFileSync(outPath, result.content, 'utf8');
27
+ }
28
+ if (context.outputMode === 'human') {
29
+ printHuman(result, outPath);
30
+ }
31
+ context.output.writeData(outPath ? { ...result, outPath } : result);
32
+ }
33
+ function printHuman(result, outPath) {
34
+ const w = (s) => process.stderr.write(s + '\n');
35
+ if (result.status !== 'proposed') {
36
+ w(`No candidate produced (${result.status}): ${result.note}`);
37
+ return;
38
+ }
39
+ w(`Proposed candidate for '${result.promptKey}'`);
40
+ w(` new version: ${result.versionId} (unlabeled — not promoted)`);
41
+ w(` base version: ${result.baseVersionId ?? '(none)'}`);
42
+ w(` from run: built from ${result.dossierCount} failure dossier(s)`);
43
+ if (result.changelog)
44
+ w(` changelog: ${result.changelog}`);
45
+ if (result.risks.length)
46
+ w(` risks: ${result.risks.map((r) => `- ${r}`).join('\n ')}`);
47
+ if (outPath)
48
+ w(` written to: ${outPath}`);
49
+ w('');
50
+ w('Next — gate on holdout cases the revision never saw, then promote if it wins:');
51
+ if (result.holdoutCaseIds.length) {
52
+ w(` holdout cases (${result.holdoutCaseIds.length}): ${result.holdoutCaseIds.join(', ')}`);
53
+ }
54
+ else {
55
+ w(' (too few failures to reserve a holdout — gate on a fresh --auto-generate set)');
56
+ }
57
+ if (outPath) {
58
+ // The candidate lives only in the registry as an unlabeled version; `prompts ab`
59
+ // resolves --candidate from local file content, so point it at the written --out
60
+ // file. Using --candidate-key=<key> would resolve to the baseline and compare the
61
+ // prompt against itself.
62
+ w(` moda prompts ab --baseline-key=${result.promptKey} --candidate=${outPath} --auto-generate`);
63
+ }
64
+ else {
65
+ w(` # re-run with --out=<path.prompt.md> to materialize the candidate, then:`);
66
+ w(` moda prompts ab --baseline-key=${result.promptKey} --candidate=<path.prompt.md> --auto-generate`);
67
+ }
68
+ w(` moda prompts promote ${result.promptKey} --label=prod --version=${result.versionId}`);
69
+ }
70
+ function parsePositiveInt(raw, fallback, max) {
71
+ const n = Number.parseInt(raw ?? '', 10);
72
+ if (!Number.isFinite(n) || n <= 0)
73
+ return fallback;
74
+ return Math.min(n, max);
75
+ }
76
+ //# sourceMappingURL=prompts-propose.js.map
package/dist/prompts.js CHANGED
@@ -5,6 +5,7 @@ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
5
5
  import { callControlAPI } from './api-client.js';
6
6
  import { createCommandContext } from './command-context.js';
7
7
  import { runPromptAb } from './prompts-ab.js';
8
+ import { runPromptPropose } from './prompts-propose.js';
8
9
  const MODA_DIR = '.moda';
9
10
  const MANIFEST_PATH = join(MODA_DIR, 'prompts.yml');
10
11
  const LOCK_PATH = join(MODA_DIR, 'prompts.lock.json');
@@ -55,8 +56,11 @@ export async function runPromptsCommand(input, legacyFlags, legacyProfileOptions
55
56
  case 'ab':
56
57
  await runPromptAb(flags, profileOptions, context);
57
58
  break;
59
+ case 'propose':
60
+ await runPromptPropose(positionals, flags, profileOptions, context);
61
+ break;
58
62
  default:
59
- throw new Error(`Unknown prompts command '${subcommand}'. Use init, status, diff, sync, promote, or ab.`);
63
+ throw new Error(`Unknown prompts command '${subcommand}'. Use init, status, diff, sync, promote, ab, or propose.`);
60
64
  }
61
65
  }
62
66
  export async function initPrompts(options = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moda-ai/cli",
3
- "version": "1.17.0",
3
+ "version": "1.17.2",
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-08T22:14:49.889Z",
4
- "cli_version": "1.17.0",
3
+ "bundled_at": "2026-07-09T02:37:26.434Z",
4
+ "cli_version": "1.17.2",
5
5
  "skills": [
6
6
  {
7
7
  "id": "integration-node-anthropic",