@moda-ai/cli 1.27.0 → 1.28.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.
@@ -546,14 +546,25 @@ async function runPromptsCommand(input, legacyFlags, legacyProfileOptions) {
546
546
  const profileOptions = legacyProfileOptions ?? { profile: context.profile, env: context.env };
547
547
  const subcommand = positionals[0] || "status";
548
548
  switch (subcommand) {
549
- case "init":
550
- await initPrompts({ silent: true });
549
+ case "init": {
550
+ const init = await initPrompts({ silent: true });
551
551
  context.output.writeData({
552
552
  manifest: MANIFEST_PATH,
553
+ manifestPath: init.manifestPath,
553
554
  lockfile: LOCK_PATH,
554
- promptPaths: readManifest().promptPaths
555
+ lockPath: init.lockPath,
556
+ cwd: init.cwd,
557
+ created: init.created,
558
+ promptPaths: init.promptPaths
559
+ }, {
560
+ artifacts: [{
561
+ kind: "prompt_manifest",
562
+ path: init.manifestPath,
563
+ description: init.created ? "Prompt discovery manifest created by this run." : "Prompt discovery manifest already existed; left unchanged."
564
+ }]
555
565
  });
556
566
  break;
567
+ }
557
568
  case "status":
558
569
  context.output.writeData(await getPromptStatus());
559
570
  break;
@@ -577,8 +588,12 @@ async function runPromptsCommand(input, legacyFlags, legacyProfileOptions) {
577
588
  }
578
589
  }
579
590
  async function initPrompts(options = {}) {
591
+ const cwd = process.cwd();
592
+ const manifestPath = resolve3(cwd, MANIFEST_PATH);
593
+ const lockPath = resolve3(cwd, LOCK_PATH);
580
594
  mkdirSync(MODA_DIR, { recursive: true });
581
- if (!existsSync2(MANIFEST_PATH)) {
595
+ const existed = existsSync2(MANIFEST_PATH);
596
+ if (!existed) {
582
597
  writeFileSync2(MANIFEST_PATH, [
583
598
  "version: 1",
584
599
  "prompt_paths:",
@@ -587,13 +602,28 @@ async function initPrompts(options = {}) {
587
602
  ].join(`
588
603
  `));
589
604
  }
605
+ if (!existsSync2(manifestPath)) {
606
+ throw new Error(`Could not write ${manifestPath}: the file is missing after init. Check directory permissions and available disk space.`);
607
+ }
608
+ const result = {
609
+ manifestPath,
610
+ lockPath,
611
+ cwd,
612
+ created: !existed,
613
+ promptPaths: readManifest().promptPaths
614
+ };
590
615
  if (!options.silent) {
591
616
  printJson({
592
617
  manifest: MANIFEST_PATH,
618
+ manifestPath,
593
619
  lockfile: LOCK_PATH,
594
- promptPaths: readManifest().promptPaths
620
+ lockPath,
621
+ cwd,
622
+ created: result.created,
623
+ promptPaths: result.promptPaths
595
624
  });
596
625
  }
626
+ return result;
597
627
  }
598
628
  async function syncPrompts(flags, profileOptions, context) {
599
629
  if (flags.watch === "true") {
package/dist/cli.js CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  runPromptsCommand,
9
9
  runSkillsCommand,
10
10
  runStatusCommand
11
- } from "./cli-cjqzrp9k.js";
11
+ } from "./cli-x1a56ws9.js";
12
12
  import {
13
13
  ApiError,
14
14
  HARNESS_REPORT_APPROVAL_PATH,
@@ -60,6 +60,7 @@ import {
60
60
  clearStoredApiKeyCredentials,
61
61
  createCliOutput,
62
62
  createCommandContext,
63
+ embeddedTenantIdFromApiKey,
63
64
  ensureSecretFilePermissions,
64
65
  getActiveProfile,
65
66
  getModaDir,
@@ -76,6 +77,7 @@ import {
76
77
  resolveTenantId,
77
78
  safeWriteStderr,
78
79
  safeWriteStdout,
80
+ saveCliConfig,
79
81
  secretFilePathFromRef,
80
82
  setActiveProfile,
81
83
  upsertProfile,
@@ -391,6 +393,7 @@ var FIX_USAGE = [
391
393
  " moda fix verify <fix_id> [--prompt-file=PATH] [--no-wait] [--timeout=ms]",
392
394
  " moda fix checkout <fix_id>",
393
395
  " moda fix submit <fix_id> --pr | --local-ref=<branch>",
396
+ " moda fix mark-applied <fix_id> [--note=TEXT]",
394
397
  " moda fix dismiss <fix_id> --reason=TEXT"
395
398
  ].join(`
396
399
  `);
@@ -454,6 +457,8 @@ async function runFixCommand(context) {
454
457
  return runFixCheckout(context, profileOptions, tenantId);
455
458
  case "submit":
456
459
  return runFixSubmit(context, profileOptions, tenantId);
460
+ case "mark-applied":
461
+ return runFixMarkApplied(context, profileOptions, tenantId);
457
462
  case "dismiss":
458
463
  return runFixDismiss(context, profileOptions, tenantId);
459
464
  default:
@@ -645,6 +650,9 @@ async function runFixCheckout(context, profileOptions, tenantId) {
645
650
  throw new CliInputError("<fix_id> is required", "Usage: moda fix checkout <fix_id>");
646
651
  }
647
652
  const fix = await getFix(tenantId, fixId, profileOptions);
653
+ if (isArtifactFix(fix)) {
654
+ throw new CliInputError(`Fix MODA-FIX-${fix.shortRef} is a ${fix.fixType} fix — its candidate is applied in your own stack, not checked out. Run \`moda fix ${fixId} --packet\` for the candidate (written as local artifacts), apply it, then confirm with \`moda fix mark-applied ${fixId}\`.`);
655
+ }
648
656
  if (!fix.targetSourcePath) {
649
657
  throw new CliInputError(`Fix MODA-FIX-${fix.shortRef} has no target source path — ${fix.fixType} fixes are packet-only. Run \`moda fix ${fixId} --packet\` for the diagnosis packet.`);
650
658
  }
@@ -752,6 +760,38 @@ async function runFixSubmit(context, profileOptions, tenantId) {
752
760
  });
753
761
  return EXIT_OK;
754
762
  }
763
+ async function runFixMarkApplied(context, profileOptions, tenantId) {
764
+ const usage = "Usage: moda fix mark-applied <fix_id> [--note=TEXT]";
765
+ const fixId = (context.positionals[1] || context.flags["fix-id"] || "").trim();
766
+ if (!fixId) {
767
+ throw new CliInputError("<fix_id> is required", usage);
768
+ }
769
+ const note = (context.flags.note ?? "").trim();
770
+ if (note.length > 600) {
771
+ throw new CliInputError(`--note must be 600 characters or fewer (got ${note.length})`, usage);
772
+ }
773
+ const fix = await callControlAPI(`${fixesBase(tenantId)}/${encodeURIComponent(fixId)}/mark-applied`, { method: "POST", body: JSON.stringify(note ? { note } : {}) }, profileOptions, { timeoutMs: 60000, retries: 0 });
774
+ if (context.outputMode === "human") {
775
+ process.stderr.write(fix.duplicate === true ? `Fix MODA-FIX-${fix.shortRef} was already confirmed (${fix.status}) — duplicate no-op; the mark_fixed feedback was re-posted.
776
+ ` : `Fix MODA-FIX-${fix.shortRef} marked applied (${fix.status}) — mark_fixed feedback posted, monitoring for recurrence.
777
+ `);
778
+ }
779
+ context.output.writeData(fix, {
780
+ summary: {
781
+ text: fix.duplicate === true ? `Fix MODA-FIX-${fix.shortRef} was already confirmed (${fix.status}); mark-applied was a duplicate no-op.` : `Fix MODA-FIX-${fix.shortRef} marked applied — status ${fix.status}; the problem enters fixed-monitoring.`,
782
+ confidence: "high"
783
+ },
784
+ nextCommands: [
785
+ {
786
+ command: `moda fix ${fix.id}`,
787
+ purpose: "Track confirmation monitoring (HELD after 14 clean days; REGRESSED on reopen).",
788
+ mutability: "read",
789
+ requires_approval: false
790
+ }
791
+ ]
792
+ });
793
+ return EXIT_OK;
794
+ }
755
795
  async function runFixDismiss(context, profileOptions, tenantId) {
756
796
  const usage = "Usage: moda fix dismiss <fix_id> --reason=TEXT";
757
797
  const fixId = (context.positionals[1] || context.flags["fix-id"] || "").trim();
@@ -778,8 +818,16 @@ async function runFixDismiss(context, profileOptions, tenantId) {
778
818
  async function runFixDetail(context, profileOptions, tenantId, fixId) {
779
819
  if (context.flags.packet === "true") {
780
820
  const packet = await callControlAPI(`${fixesBase(tenantId)}/${encodeURIComponent(fixId)}/packet`, { method: "GET" }, profileOptions);
821
+ const extras = materializePacketCandidates(packet, fixId, context.outputMode === "human");
781
822
  context.output.writeData(packet, {
782
- summary: { text: `moda.fix_packet.v1 for fix ${fixId}.`, confidence: "high" }
823
+ summary: {
824
+ text: `moda.fix_packet.v1 for fix ${fixId}.${extras.summarySuffix}`,
825
+ confidence: "high"
826
+ },
827
+ ...extras.findings.length > 0 ? { findings: extras.findings } : {},
828
+ ...extras.artifacts.length > 0 ? { artifacts: extras.artifacts } : {},
829
+ ...extras.warnings.length > 0 ? { warnings: extras.warnings } : {},
830
+ ...extras.nextCommands.length > 0 ? { nextCommands: extras.nextCommands } : {}
783
831
  });
784
832
  return EXIT_OK;
785
833
  }
@@ -841,6 +889,9 @@ async function driveAdvance(args) {
841
889
  announce(fix);
842
890
  if (!ADVANCING_STATUSES.has(fix.status))
843
891
  break;
892
+ if (fix.status === "PROPOSED" && /gate (pending|unavailable)/i.test(fix.statusReason ?? "")) {
893
+ break;
894
+ }
844
895
  await sleep(fix.status === "GATING" ? args.pollIntervalMs : Math.min(STEP_DELAY_MS, args.pollIntervalMs));
845
896
  }
846
897
  return fix;
@@ -871,6 +922,112 @@ async function fetchPacketFailSoft(tenantId, fixId, profileOptions) {
871
922
  return null;
872
923
  }
873
924
  }
925
+ function isArtifactFix(fix) {
926
+ return fix.fixType === "TOOL_SCHEMA" || fix.fixType === "SKILL";
927
+ }
928
+ function materializePacketCandidates(packet, fixId, human) {
929
+ const findings = [];
930
+ const artifacts = [];
931
+ const warnings = [];
932
+ const nextCommands = [];
933
+ const summaryParts = [];
934
+ const tool = packet.candidate_tool;
935
+ const skill = packet.candidate_skill;
936
+ const hasTool = typeof tool?.proposed_description === "string" && tool.proposed_description !== "";
937
+ const hasSkill = typeof skill?.skill_md === "string" && skill.skill_md !== "";
938
+ if (!hasTool && !hasSkill) {
939
+ return { findings, artifacts, warnings, nextCommands, summarySuffix: "" };
940
+ }
941
+ const rawRef = typeof packet.short_ref === "string" && packet.short_ref.trim() ? packet.short_ref : fixId;
942
+ const ref = rawRef.replace(/[^A-Za-z0-9._-]/g, "").replace(/^\.+/, "").slice(0, 64) || "fix";
943
+ const w = (line) => process.stderr.write(`${line}
944
+ `);
945
+ const block = (label, text) => {
946
+ w(` ${label}:`);
947
+ for (const line of (text || "(none)").split(`
948
+ `))
949
+ w(` ${line}`);
950
+ };
951
+ const writeCandidateFile = (name, content, kind, description) => {
952
+ const relPath = safeTargetSourcePath(`.moda/fixes/${ref}/${name}`);
953
+ const absPath = join(process.cwd(), relPath);
954
+ try {
955
+ assertRealpathContained(absPath, relPath);
956
+ writeContainedFile(absPath, relPath, content);
957
+ artifacts.push({ kind, path: relPath, description });
958
+ return relPath;
959
+ } catch (error) {
960
+ warnings.push(`Could not write ${relPath}: ${error instanceof Error ? error.message : String(error)} — the full candidate text is still in the packet payload.`);
961
+ return null;
962
+ }
963
+ };
964
+ if (hasTool && tool) {
965
+ const toolName = typeof tool.tool_name === "string" && tool.tool_name ? tool.tool_name : "unknown-tool";
966
+ const source = typeof tool.current_description_source === "string" ? tool.current_description_source : "";
967
+ const paramNotes = (Array.isArray(tool.param_notes) ? tool.param_notes : []).filter((note) => typeof note === "string" && note !== "");
968
+ const rationale = typeof tool.rationale === "string" ? tool.rationale : "";
969
+ const currentPath = writeCandidateFile("tool-description.current.md", typeof tool.current_description === "string" ? tool.current_description : "", "fix_tool_description_current", `Current description of tool '${toolName}'${source ? ` (from ${source})` : ""} — the diff base.`);
970
+ const proposedPath = writeCandidateFile("tool-description.proposed.md", tool.proposed_description ?? "", "fix_tool_description_proposed", `Proposed description for tool '${toolName}' — apply it to your tool definition, then \`moda fix mark-applied ${fixId}\`.`);
971
+ findings.push({
972
+ id: "fix:candidate_tool",
973
+ title: `tool '${toolName}' description candidate${source ? ` (current from ${source})` : ""}`,
974
+ status: "proposed",
975
+ severity: "info",
976
+ ...rationale || paramNotes.length > 0 ? {
977
+ description: [rationale, ...paramNotes.map((note) => `param note: ${note}`)].filter(Boolean).join(`
978
+ `)
979
+ } : {}
980
+ });
981
+ summaryParts.push(`Includes a tool-description candidate for '${toolName}'.`);
982
+ if (human) {
983
+ w("");
984
+ w(`Tool-description candidate — '${toolName}'${source ? ` (current description from ${source})` : ""}`);
985
+ block("current ", tool.current_description ?? "");
986
+ block("proposed", tool.proposed_description ?? "");
987
+ if (paramNotes.length > 0)
988
+ block("param notes", paramNotes.join(`
989
+ `));
990
+ if (rationale)
991
+ block("rationale", rationale);
992
+ if (currentPath && proposedPath) {
993
+ w(` diff: diff ${currentPath} ${proposedPath}`);
994
+ }
995
+ }
996
+ }
997
+ if (hasSkill && skill) {
998
+ const skillName = typeof skill.skill_name === "string" && skill.skill_name ? skill.skill_name : "moda-fix-skill";
999
+ const skillPath = writeCandidateFile("SKILL.md", skill.skill_md ?? "", "fix_candidate_skill", `Drafted SKILL.md for '${skillName}' — install it (for example .claude/skills/${skillName}/SKILL.md), then \`moda fix mark-applied ${fixId}\`.`);
1000
+ findings.push({
1001
+ id: "fix:candidate_skill",
1002
+ title: `skill '${skillName}' drafted (SKILL.md, ${(skill.skill_md ?? "").length} chars)`,
1003
+ status: "proposed",
1004
+ severity: "info",
1005
+ description: `Install the skill in your agent, then confirm with \`moda fix mark-applied ${fixId}\`. Gating on the replay holdout stays pending until the skill is registered with the skill harness.`
1006
+ });
1007
+ summaryParts.push(`Includes a drafted SKILL.md candidate ('${skillName}').`);
1008
+ if (human) {
1009
+ w("");
1010
+ w(`Skill candidate — '${skillName}'`);
1011
+ if (skillPath)
1012
+ w(` SKILL.md written to ${skillPath}`);
1013
+ w(` install it (for example .claude/skills/${skillName}/SKILL.md), then confirm:`);
1014
+ w(` moda fix mark-applied ${fixId} --note="installed skill ${skillName}"`);
1015
+ }
1016
+ }
1017
+ nextCommands.push({
1018
+ command: `moda fix mark-applied ${fixId} --note="where/how you applied it"`,
1019
+ purpose: "Confirm you applied the candidate in your own stack (no PR) — flips the fix to SHIPPED and starts recurrence monitoring.",
1020
+ mutability: "write",
1021
+ requires_approval: true
1022
+ });
1023
+ return {
1024
+ findings,
1025
+ artifacts,
1026
+ warnings,
1027
+ nextCommands,
1028
+ summarySuffix: summaryParts.length > 0 ? ` ${summaryParts.join(" ")}` : ""
1029
+ };
1030
+ }
874
1031
  function gateFindings(fix) {
875
1032
  const gateResult = fix.gateResult ?? null;
876
1033
  if (!gateResult)
@@ -918,8 +1075,24 @@ function passLabel(value) {
918
1075
  return value ? "pass" : "fail";
919
1076
  }
920
1077
  function nextCommandsFor(fix) {
1078
+ const packetCommand = {
1079
+ command: `moda fix ${fix.id} --packet`,
1080
+ purpose: "Read the packet — the tool/skill candidate is written as local artifacts to apply in your own stack.",
1081
+ mutability: "write",
1082
+ requires_approval: false
1083
+ };
1084
+ const markAppliedCommand = {
1085
+ command: `moda fix mark-applied ${fix.id} --note="where/how you applied it"`,
1086
+ purpose: "Confirm you applied the candidate in your own stack (no PR) — flips the fix to SHIPPED and starts recurrence monitoring.",
1087
+ mutability: "write",
1088
+ requires_approval: true
1089
+ };
1090
+ const skillGatePending = fix.fixType === "SKILL" && fix.candidateRef?.kind === "skill" && !fix.candidateRef?.skillId;
921
1091
  switch (fix.status) {
922
1092
  case "VERIFIED":
1093
+ if (isArtifactFix(fix)) {
1094
+ return [packetCommand, markAppliedCommand];
1095
+ }
923
1096
  return [
924
1097
  {
925
1098
  command: `moda fix submit ${fix.id} --pr`,
@@ -935,6 +1108,9 @@ function nextCommandsFor(fix) {
935
1108
  }
936
1109
  ];
937
1110
  case "PROPOSED":
1111
+ if (skillGatePending) {
1112
+ return [packetCommand, markAppliedCommand];
1113
+ }
938
1114
  return [
939
1115
  {
940
1116
  command: `moda fix verify ${fix.id}`,
@@ -944,6 +1120,17 @@ function nextCommandsFor(fix) {
944
1120
  }
945
1121
  ];
946
1122
  case "GATE_FAILED":
1123
+ if (isArtifactFix(fix)) {
1124
+ return [
1125
+ {
1126
+ command: `moda fix verify ${fix.id}`,
1127
+ purpose: "Re-gate the stored candidate on the frozen holdout.",
1128
+ mutability: "write",
1129
+ requires_approval: false
1130
+ },
1131
+ packetCommand
1132
+ ];
1133
+ }
947
1134
  return [
948
1135
  {
949
1136
  command: `moda fix checkout ${fix.id}`,
@@ -1843,6 +2030,47 @@ function renderJsonSchemas() {
1843
2030
  }, null, 2);
1844
2031
  }
1845
2032
 
2033
+ // src/api-key-auth.ts
2034
+ var PROBE_TIMEOUT_MS = 1e4;
2035
+ function resolveApiKeyCredential(options = {}) {
2036
+ const key = resolveApiKey(options);
2037
+ if (!key)
2038
+ return;
2039
+ const env = options.env ?? process.env;
2040
+ const source = env.MODA_API_KEY === key ? "env" : resolveApiKeyFromActiveProfile(options) === key ? "profile" : "config";
2041
+ return { key, source, tenantId: embeddedTenantIdFromApiKey(key) };
2042
+ }
2043
+ function redactApiKey(key) {
2044
+ if (key.length <= 12)
2045
+ return "<redacted>";
2046
+ return `${key.slice(0, 8)}...${key.slice(-4)}`;
2047
+ }
2048
+ async function probeApiKey(key, options = {}) {
2049
+ let response;
2050
+ try {
2051
+ response = await fetch(`${resolveModaBaseUrl(options)}/api/v1/data/overview`, {
2052
+ method: "GET",
2053
+ headers: { "x-api-key": key },
2054
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS)
2055
+ });
2056
+ } catch (error) {
2057
+ const reason = error instanceof Error ? error.message : String(error);
2058
+ return { outcome: "unreachable", reason };
2059
+ }
2060
+ if (response.status === 401 || response.status === 403) {
2061
+ return { outcome: "rejected", status: response.status };
2062
+ }
2063
+ if (response.status !== 200) {
2064
+ return { outcome: "unreachable", reason: `HTTP ${response.status}` };
2065
+ }
2066
+ try {
2067
+ await response.json();
2068
+ } catch {
2069
+ return { outcome: "unreachable", reason: "non-JSON response from the Data API" };
2070
+ }
2071
+ return { outcome: "live" };
2072
+ }
2073
+
1846
2074
  // src/keys.ts
1847
2075
  import { createHash } from "node:crypto";
1848
2076
  import { existsSync as existsSync2, readFileSync as readFileSync3 } from "node:fs";
@@ -2027,13 +2255,75 @@ function serializeProfile(profile, activeProfile) {
2027
2255
  auth_token_ref: profile.auth_token_ref
2028
2256
  };
2029
2257
  }
2258
+ async function readStdin() {
2259
+ const chunks = [];
2260
+ for await (const chunk of process.stdin)
2261
+ chunks.push(Buffer.from(chunk));
2262
+ return Buffer.concat(chunks).toString("utf8").trim();
2263
+ }
2264
+ async function apiKeyLoginCandidate(context) {
2265
+ if (boolFlag2(context, "api-key-stdin")) {
2266
+ const piped = await readStdin();
2267
+ if (piped)
2268
+ return { key: piped, fromArgv: false };
2269
+ throw new CliInputError("No API key received on stdin.", 'Pipe the key in, e.g. `printf %s "$KEY" | moda auth login --api-key-stdin`.');
2270
+ }
2271
+ const inline = flag(context, "api-key");
2272
+ if (inline)
2273
+ return { key: inline, fromArgv: true };
2274
+ const env = context.env ?? process.env;
2275
+ if (env.MODA_API_KEY)
2276
+ return { key: env.MODA_API_KEY, fromArgv: false };
2277
+ return;
2278
+ }
2279
+ async function loginWithApiKey(context, candidate) {
2280
+ const { key } = candidate;
2281
+ if (candidate.fromArgv) {
2282
+ console.error('Warning: passing the key as --api-key=<key> exposes it in this process\'s command line and your shell history. Prefer MODA_API_KEY, or `printf %s "$KEY" | moda auth login --api-key-stdin`.');
2283
+ }
2284
+ const authOptions = { profile: context.profile, env: context.env };
2285
+ const probe = await probeApiKey(key, authOptions);
2286
+ if (probe.outcome === "rejected") {
2287
+ throw new CliInputError(`API key rejected by the Moda API (HTTP ${probe.status}).`, 'The key is revoked, malformed, or belongs to another environment. Mint a new one with "moda provision --tenant-id=<org-id>", or check MODA_BASE_URL points at the right environment.');
2288
+ }
2289
+ if (probe.outcome === "unreachable") {
2290
+ throw new CliInputError(`Could not validate the API key: ${probe.reason}.`, "The key was not saved. Check network access to the Moda API and retry; the key itself may still be valid.");
2291
+ }
2292
+ const tenantId = embeddedTenantIdFromApiKey(key);
2293
+ saveCliConfig({ api_key: key, ...tenantId ? { tenant_id: tenantId } : {} }, authOptions);
2294
+ const envKey = (context.env ?? process.env).MODA_API_KEY;
2295
+ const shadowedByEnv = Boolean(envKey && envKey !== key);
2296
+ if (shadowedByEnv) {
2297
+ console.error(`Warning: MODA_API_KEY is set to a different key (${redactApiKey(envKey)}) and takes precedence over the key just saved. Later commands will use the environment key. Unset MODA_API_KEY to use the saved credential.`);
2298
+ }
2299
+ context.output.writeData({
2300
+ status: "authenticated",
2301
+ method: "api_key",
2302
+ profile: context.profile ?? context.env?.MODA_PROFILE ?? process.env.MODA_PROFILE ?? null,
2303
+ apiKeyCredential: { masked: redactApiKey(key) },
2304
+ tenant_id: tenantId ?? null,
2305
+ persisted: true,
2306
+ shadowed_by_env: shadowedByEnv,
2307
+ effective_credential: shadowedByEnv ? "env" : "saved"
2308
+ }, shadowedByEnv ? { status: "degraded" } : undefined);
2309
+ return { exitCode: shadowedByEnv ? EXIT_DEGRADED : EXIT_OK };
2310
+ }
2030
2311
  async function runAuthCommand(context) {
2031
2312
  switch (subcommand(context)) {
2032
2313
  case "login": {
2314
+ const wantsApiKey = "api-key" in context.flags || boolFlag2(context, "api-key-stdin");
2315
+ const nonInteractive = boolFlag2(context, "non-interactive");
2316
+ const candidate = wantsApiKey || nonInteractive ? await apiKeyLoginCandidate(context) : undefined;
2317
+ if (candidate)
2318
+ return loginWithApiKey(context, candidate);
2319
+ if (wantsApiKey || nonInteractive) {
2320
+ throw new CliInputError("No API key available for non-interactive login.", 'Set MODA_API_KEY, pipe the key with "moda auth login --api-key-stdin", or pass "moda auth login --api-key=<key>" (exposes it in argv). Mint a key with "moda provision --tenant-id=<org-id>" (needs an existing browser session), or run "moda init".');
2321
+ }
2033
2322
  const screen = flag(context, "screen") === "signup" ? "signup" : "login";
2034
2323
  await login(screen, { profile: context.profile, env: context.env });
2035
2324
  context.output.writeData({
2036
2325
  status: "authenticated",
2326
+ method: "browser",
2037
2327
  profile: context.profile ?? process.env.MODA_PROFILE ?? null
2038
2328
  });
2039
2329
  return { exitCode: 0 };
@@ -2058,7 +2348,33 @@ async function runAuthCommand(context) {
2058
2348
  case "whoami": {
2059
2349
  const session = loadAuthSession({ profile: context.profile, env: context.env });
2060
2350
  if (!session) {
2061
- throw new CliInputError("Not authenticated.", 'Run "moda auth login" or "moda init".');
2351
+ const credential = resolveApiKeyCredential({
2352
+ profile: context.profile,
2353
+ env: context.env
2354
+ });
2355
+ if (credential) {
2356
+ const probe = await probeApiKey(credential.key, {
2357
+ profile: context.profile,
2358
+ env: context.env
2359
+ });
2360
+ if (probe.outcome === "rejected") {
2361
+ throw new CliInputError(`API key rejected by the Moda API (HTTP ${probe.status}).`, 'The key is revoked or belongs to another environment. Mint a new one with "moda provision --tenant-id=<org-id>", or run "moda auth login".');
2362
+ }
2363
+ context.output.writeData({
2364
+ profile: context.profile ?? process.env.MODA_PROFILE ?? null,
2365
+ credential: "api_key",
2366
+ apiKeyCredential: {
2367
+ source: credential.source,
2368
+ masked: redactApiKey(credential.key),
2369
+ verified: probe.outcome === "live"
2370
+ },
2371
+ tenant_id: credential.tenantId ?? null,
2372
+ session: null,
2373
+ context: null
2374
+ }, probe.outcome === "live" ? undefined : { status: "degraded" });
2375
+ return { exitCode: probe.outcome === "live" ? EXIT_OK : EXIT_DEGRADED };
2376
+ }
2377
+ throw new CliInputError("Not authenticated.", 'Non-interactive: set MODA_API_KEY, or run "moda auth login --api-key=<key>". Interactive: run "moda auth login" or "moda init".');
2062
2378
  }
2063
2379
  const response = await authFetch("/api/cli/context", {}, { profile: context.profile, env: context.env });
2064
2380
  if (!response.ok) {
@@ -2080,8 +2396,17 @@ async function runAuthCommand(context) {
2080
2396
  const authOptions = { profile: context.profile, env: context.env };
2081
2397
  const session = loadAuthSession({ ...authOptions, includeExpired: true });
2082
2398
  const loggedIn = isAuthSessionValid(session);
2399
+ const credential = resolveApiKeyCredential(authOptions);
2083
2400
  const data = {
2084
2401
  loggedIn,
2402
+ authenticated: loggedIn || Boolean(credential),
2403
+ apiKeyCredential: credential ? {
2404
+ present: true,
2405
+ source: credential.source,
2406
+ masked: redactApiKey(credential.key),
2407
+ tenantId: credential.tenantId ?? null,
2408
+ verified: null
2409
+ } : { present: false },
2085
2410
  profile: context.profile ?? context.env.MODA_PROFILE ?? session?.profile ?? null,
2086
2411
  expiresAt: session ? new Date(session.expires_at * 1000).toISOString() : null
2087
2412
  };
@@ -2101,9 +2426,16 @@ async function runAuthCommand(context) {
2101
2426
  }
2102
2427
  } catch {}
2103
2428
  data.online = online;
2429
+ if (credential && data.apiKeyCredential.present) {
2430
+ const probe = await probeApiKey(credential.key, authOptions);
2431
+ data.apiKeyCredential.verified = probe.outcome === "live";
2432
+ if (probe.outcome === "rejected") {
2433
+ data.authenticated = loggedIn;
2434
+ }
2435
+ }
2104
2436
  }
2105
2437
  context.output.writeData(data);
2106
- return { exitCode: loggedIn ? EXIT_OK : EXIT_AUTH_REQUIRED };
2438
+ return { exitCode: data.authenticated ? EXIT_OK : EXIT_AUTH_REQUIRED };
2107
2439
  }
2108
2440
  case "token": {
2109
2441
  const session = loadAuthSession({ profile: context.profile, env: context.env });
@@ -4609,7 +4941,7 @@ function buildResumeCommand(command, flags) {
4609
4941
  ];
4610
4942
  }
4611
4943
  function printHelp() {
4612
- console.error(`Usage: moda <command> [options]
4944
+ console.log(`Usage: moda <command> [options]
4613
4945
 
4614
4946
  Commands:
4615
4947
  init Set up Moda in your project
@@ -4617,11 +4949,12 @@ Commands:
4617
4949
  status Show concise local Moda setup status
4618
4950
  manifest Describe the CLI machine protocol and commands
4619
4951
  doctor Diagnose local Moda setup and data flow
4620
- auth login Authenticate with Moda
4621
- auth status Show local auth session state (exit 4 when logged out); --online probes the API
4952
+ auth login Authenticate with Moda (browser)
4953
+ auth login --api-key Non-interactive: validate MODA_API_KEY (or --api-key=<key>) and save it
4954
+ auth status Show auth state for both credentials (exit 4 when neither is usable); --online probes the API
4622
4955
  auth token Print the raw bearer token for $(moda auth token)
4623
4956
  auth logout Clear cached Moda auth
4624
- auth whoami Show the authenticated Moda user
4957
+ auth whoami Show the authenticated Moda identity (session or API key)
4625
4958
  profiles list List local Moda profiles
4626
4959
  profiles use <name> Switch active Moda profile
4627
4960
  profiles create <name> Create or update a local Moda profile
@@ -4692,7 +5025,8 @@ Commands:
4692
5025
 
4693
5026
  Fixes (a fix with proof):
4694
5027
  fixes Ranked Fix queue (--status=S --limit=N --cursor=TOKEN)
4695
- fix <fix_id> One Fix: status + gate result; --packet prints moda.fix_packet.v1;
5028
+ fix <fix_id> One Fix: status + gate result; --packet prints moda.fix_packet.v1
5029
+ (tool/skill candidates are also written under .moda/fixes/<ref>/);
4696
5030
  --wait drives a still-running pipeline to rest
4697
5031
  fix start <problem_id> Draft a Fix from a Problem (--type=auto|prompt); --wait drives
4698
5032
  scope -> propose -> gate and prints the packet
@@ -4700,6 +5034,8 @@ Commands:
4700
5034
  (exit 0 pass / 1 fail with per-case findings / 3 degraded-inconclusive)
4701
5035
  fix checkout <fix_id> Write the candidate at its target source path (never runs git)
4702
5036
  fix submit <fix_id> Deliver: --pr opens a draft PR | --local-ref=<branch> records your branch
5037
+ fix mark-applied <fix_id> Confirm a candidate you applied in your own stack (--note=TEXT);
5038
+ no PR needed — flips to SHIPPED and starts monitoring
4703
5039
  fix dismiss <fix_id> Reject with --reason=TEXT (feeds problem feedback)
4704
5040
 
4705
5041
  Options:
@@ -4837,6 +5173,8 @@ Provision flags (headless; requires an existing "moda auth login" session):
4837
5173
 
4838
5174
  Examples:
4839
5175
  moda init
5176
+ moda auth login --api-key=$MODA_API_KEY
5177
+ moda auth whoami
4840
5178
  moda provision --tenant-id=<org-id>
4841
5179
  moda status
4842
5180
  moda manifest --json
@@ -4900,7 +5238,12 @@ Examples:
4900
5238
  moda skills pull
4901
5239
  moda fixes
4902
5240
  moda fix start <problem_id> --wait
4903
- moda fix verify <fix_id> --prompt-file=prompts/agent.prompt.md`);
5241
+ moda fix verify <fix_id> --prompt-file=prompts/agent.prompt.md
5242
+ moda fix mark-applied <fix_id> --note="tool description updated in our agent config"
5243
+
5244
+ Run \`moda <command> --help\` for a command's subcommands, and
5245
+ \`moda <command> <subcommand> --help\` for that subcommand's flags
5246
+ (e.g. \`moda auth login --help\`).`);
4904
5247
  }
4905
5248
  function printCommandHelp(definition) {
4906
5249
  const lines = [];
@@ -4911,6 +5254,14 @@ function printCommandHelp(definition) {
4911
5254
  if (definition.mutability === "write") {
4912
5255
  lines.push("This command can mutate state.");
4913
5256
  }
5257
+ if (definition.subcommands && definition.subcommands.length > 0) {
5258
+ lines.push("", "Subcommands:");
5259
+ const width = Math.max(...definition.subcommands.map((entry) => entry.name.length));
5260
+ for (const entry of definition.subcommands) {
5261
+ lines.push(` ${entry.name.padEnd(width)} ${entry.description}`);
5262
+ }
5263
+ lines.push("", `Run 'moda ${definition.name} <subcommand> --help' for that subcommand's flags.`);
5264
+ }
4914
5265
  if (definition.examples.length > 0) {
4915
5266
  lines.push("", "Examples:");
4916
5267
  for (const example of definition.examples) {
@@ -4918,7 +5269,28 @@ function printCommandHelp(definition) {
4918
5269
  }
4919
5270
  }
4920
5271
  lines.push("", "Run 'moda --help' for the full command list and common flags", "(--json, --agent, --profile, --days-back, --limit, --time-range, ...).");
4921
- console.error(lines.join(`
5272
+ console.log(lines.join(`
5273
+ `));
5274
+ }
5275
+ function printSubcommandHelp(parent, entry) {
5276
+ const lines = [];
5277
+ lines.push(`moda ${parent.name} ${entry.name} — ${entry.description}`, "");
5278
+ lines.push(`Usage: ${entry.usage}`);
5279
+ if (entry.flags && entry.flags.length > 0) {
5280
+ lines.push("", "Options:");
5281
+ const width = Math.max(...entry.flags.map((flag2) => flag2.name.length));
5282
+ for (const flag2 of entry.flags) {
5283
+ lines.push(` ${flag2.name.padEnd(width)} ${flag2.description}`);
5284
+ }
5285
+ }
5286
+ if (entry.examples && entry.examples.length > 0) {
5287
+ lines.push("", "Examples:");
5288
+ for (const example of entry.examples) {
5289
+ lines.push(` ${example}`);
5290
+ }
5291
+ }
5292
+ lines.push("", `Run 'moda ${parent.name} --help' for the other ${parent.name} subcommands.`);
5293
+ console.log(lines.join(`
4922
5294
  `));
4923
5295
  }
4924
5296
  async function attachWindows(rows, windowSize) {
@@ -5670,6 +6042,7 @@ async function runCommand(command, positional, flags, positionals = positional ?
5670
6042
  }
5671
6043
  return 0;
5672
6044
  }
6045
+ var OFFLINE_PROMPTS_SUBCOMMANDS = new Set(["init", "status", "diff"]);
5673
6046
  function legacyCommand(metadata) {
5674
6047
  return {
5675
6048
  ...metadata,
@@ -5763,7 +6136,7 @@ var commandRegistry = createCommandRegistry([
5763
6136
  resetApiRequestCountBeforeRun: true,
5764
6137
  telemetry: "result",
5765
6138
  handler: async (context) => {
5766
- const { runInit } = await import("./index-fwxpt51s.js");
6139
+ const { runInit } = await import("./index-g3gt62rm.js");
5767
6140
  if (context.outputMode === "agent-stream") {
5768
6141
  context.output.writeEvent({
5769
6142
  event: "started",
@@ -5836,15 +6209,81 @@ var commandRegistry = createCommandRegistry([
5836
6209
  },
5837
6210
  {
5838
6211
  name: "auth",
5839
- description: "Authenticate and inspect browser auth sessions",
6212
+ description: "Authenticate (browser or API key) and inspect auth state",
5840
6213
  examples: [
5841
6214
  "moda auth login",
6215
+ "moda auth login --api-key=$MODA_API_KEY",
5842
6216
  "moda auth status",
5843
6217
  "moda auth status --online",
5844
6218
  "moda auth token",
5845
6219
  "moda auth whoami",
5846
6220
  "moda auth logout"
5847
6221
  ],
6222
+ subcommands: [
6223
+ {
6224
+ name: "login",
6225
+ description: "Authenticate with Moda (browser by default, or an API key)",
6226
+ usage: "moda auth login [--api-key[=<key>] | --api-key-stdin] [--non-interactive] [--screen=login|signup]",
6227
+ flags: [
6228
+ {
6229
+ name: "--api-key",
6230
+ description: "Authenticate non-interactively with the API key in MODA_API_KEY. Validates it, saves it, and never opens a browser."
6231
+ },
6232
+ {
6233
+ name: "--api-key-stdin",
6234
+ description: "Read the API key from stdin. Safest form: the key never appears in argv or shell history."
6235
+ },
6236
+ {
6237
+ name: "--api-key=<key>",
6238
+ description: "Supply the key inline. Exposes it in this process's command line and shell history; prefer MODA_API_KEY or --api-key-stdin."
6239
+ },
6240
+ {
6241
+ name: "--non-interactive",
6242
+ description: "Never open a browser. Uses MODA_API_KEY if set, otherwise fails with the commands that would mint a key."
6243
+ },
6244
+ {
6245
+ name: "--screen=login|signup",
6246
+ description: "Which browser screen to open (interactive flow only)."
6247
+ }
6248
+ ],
6249
+ examples: [
6250
+ "MODA_API_KEY=<key> moda auth login --non-interactive",
6251
+ "moda auth login --api-key",
6252
+ 'printf %s "$KEY" | moda auth login --api-key-stdin',
6253
+ "moda auth login"
6254
+ ]
6255
+ },
6256
+ {
6257
+ name: "status",
6258
+ description: "Show local auth state for both credentials; exit 4 when neither is usable",
6259
+ usage: "moda auth status [--online]",
6260
+ flags: [
6261
+ {
6262
+ name: "--online",
6263
+ description: "Also probe the API to confirm the browser session is accepted."
6264
+ }
6265
+ ],
6266
+ examples: ["moda auth status", "moda auth status --online"]
6267
+ },
6268
+ {
6269
+ name: "whoami",
6270
+ description: "Show the authenticated identity (browser session, or API-key credential)",
6271
+ usage: "moda auth whoami",
6272
+ examples: ["moda auth whoami"]
6273
+ },
6274
+ {
6275
+ name: "token",
6276
+ description: "Print the raw browser session bearer token for $(moda auth token)",
6277
+ usage: "moda auth token",
6278
+ examples: ["moda auth token", 'curl -H "Authorization: Bearer $(moda auth token)" ...']
6279
+ },
6280
+ {
6281
+ name: "logout",
6282
+ description: "Revoke and clear the browser session and any stored API key",
6283
+ usage: "moda auth logout",
6284
+ examples: ["moda auth logout"]
6285
+ }
6286
+ ],
5848
6287
  auth: "none",
5849
6288
  outputModes: ["human", "json", "agent"],
5850
6289
  mutability: "write",
@@ -6147,11 +6586,75 @@ var commandRegistry = createCommandRegistry([
6147
6586
  legacyCommand({
6148
6587
  name: "prompts",
6149
6588
  description: "Manage prompt versions and labels",
6150
- examples: ["moda prompts status", "moda prompts sync", "moda prompts ab --baseline=old.md --candidate=new.md"],
6589
+ examples: ["moda prompts init", "moda prompts status", "moda prompts sync", "moda prompts ab --baseline=old.md --candidate=new.md"],
6590
+ subcommands: [
6591
+ {
6592
+ name: "init",
6593
+ description: "Create .moda/prompts.yml in the current directory (never overwrites)",
6594
+ usage: "moda prompts init",
6595
+ flags: [],
6596
+ examples: ["moda prompts init", "cd /path/to/project && moda prompts init"]
6597
+ },
6598
+ {
6599
+ name: "status",
6600
+ description: "Show prompt files that changed since the last sync",
6601
+ usage: "moda prompts status",
6602
+ examples: ["moda prompts status"]
6603
+ },
6604
+ {
6605
+ name: "diff",
6606
+ description: "Alias of status: show local prompt changes",
6607
+ usage: "moda prompts diff",
6608
+ examples: ["moda prompts diff"]
6609
+ },
6610
+ {
6611
+ name: "sync",
6612
+ description: "Upload changed prompt versions to Moda Cloud",
6613
+ usage: "moda prompts sync [--watch] [--dry-run]",
6614
+ flags: [
6615
+ { name: "--watch", description: "Re-upload whenever a prompt file changes." },
6616
+ { name: "--dry-run", description: "Show what would be uploaded without writing." }
6617
+ ],
6618
+ examples: ["moda prompts sync", "moda prompts sync --dry-run"]
6619
+ },
6620
+ {
6621
+ name: "promote",
6622
+ description: "Move a dev/staging/prod label to a prompt version",
6623
+ usage: "moda prompts promote <key> --label=<dev|staging|prod> --version=<version_id>",
6624
+ flags: [
6625
+ { name: "--label=<label>", description: "Release label to move (dev, staging, prod)." },
6626
+ { name: "--version=<id>", description: "Prompt version id to point the label at." }
6627
+ ],
6628
+ examples: ["moda prompts promote support.triage --label=prod --version=pver_abc123"]
6629
+ },
6630
+ {
6631
+ name: "ab",
6632
+ description: "Run a judged prompt A/B replay experiment",
6633
+ usage: "moda prompts ab --baseline=<path> --candidate=<path> --conversations=<ids>",
6634
+ flags: [
6635
+ { name: "--baseline=<path>", description: "Prompt file to treat as the control." },
6636
+ { name: "--candidate=<path>", description: "Prompt file to treat as the variant." },
6637
+ { name: "--conversations=<ids>", description: "Comma-separated conversation ids to replay." }
6638
+ ],
6639
+ examples: [
6640
+ "moda prompts ab --baseline=prompts/agent.prompt.md --candidate=prompts/agent-v2.prompt.md --conversations=conv_1,conv_2"
6641
+ ]
6642
+ },
6643
+ {
6644
+ name: "propose",
6645
+ description: "Revise a prompt from a run's failures",
6646
+ usage: "moda prompts propose [--gate] [--promote-on-win]",
6647
+ flags: [
6648
+ { name: "--gate", description: "Automatically A/B the proposed revision." },
6649
+ { name: "--promote-on-win", description: "Promote the candidate if it wins the gate." }
6650
+ ],
6651
+ examples: ["moda prompts propose --gate --promote-on-win"]
6652
+ }
6653
+ ],
6151
6654
  auth: "api-key",
6152
6655
  outputModes: ["human", "json", "agent"],
6153
6656
  mutability: "write",
6154
- validateConfigBeforeRun: true,
6657
+ validateConfigBeforeRun: (parsed) => !OFFLINE_PROMPTS_SUBCOMMANDS.has(parsed.positional ?? "status"),
6155
6658
  resetApiRequestCountBeforeRun: true,
6156
6659
  telemetry: "result-and-error"
6157
6660
  }),
@@ -6192,6 +6695,7 @@ var commandRegistry = createCommandRegistry([
6192
6695
  "moda fix verify <fix_id> --prompt-file=prompts/agent.prompt.md",
6193
6696
  "moda fix checkout <fix_id>",
6194
6697
  "moda fix submit <fix_id> --pr",
6698
+ 'moda fix mark-applied <fix_id> --note="tool description updated in our agent config"',
6195
6699
  'moda fix dismiss <fix_id> --reason="not actionable"'
6196
6700
  ],
6197
6701
  auth: "none",
@@ -6284,7 +6788,13 @@ async function dispatchParsedCommand(parsed, startedAt = Date.now()) {
6284
6788
  if (command === "--help" || command === "-h" || flags.help === "true") {
6285
6789
  const helpTarget = command && command !== "--help" && command !== "-h" ? commandRegistry.find(command) : undefined;
6286
6790
  if (helpTarget) {
6287
- printCommandHelp(helpTarget);
6791
+ const subcommandName = parsed.positional;
6792
+ const subcommandEntry = subcommandName ? helpTarget.subcommands?.find((entry) => entry.name === subcommandName) : undefined;
6793
+ if (subcommandEntry) {
6794
+ printSubcommandHelp(helpTarget, subcommandEntry);
6795
+ } else {
6796
+ printCommandHelp(helpTarget);
6797
+ }
6288
6798
  } else {
6289
6799
  printHelp();
6290
6800
  }
@@ -6329,7 +6839,8 @@ async function dispatchParsedCommand(parsed, startedAt = Date.now()) {
6329
6839
  })
6330
6840
  });
6331
6841
  setActiveContext(context);
6332
- if (definition.validateConfigBeforeRun) {
6842
+ const needsConfig = typeof definition.validateConfigBeforeRun === "function" ? definition.validateConfigBeforeRun(parsed) : definition.validateConfigBeforeRun;
6843
+ if (needsConfig) {
6333
6844
  try {
6334
6845
  validateConfig();
6335
6846
  } catch (error) {
@@ -9,7 +9,7 @@ import {
9
9
  runPromptSync,
10
10
  runSkillSync,
11
11
  upsertSkillManifestRecord
12
- } from "./cli-cjqzrp9k.js";
12
+ } from "./cli-x1a56ws9.js";
13
13
  import {
14
14
  codingAgentDisplayName,
15
15
  describeCodingAgentEvent,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moda-ai/cli",
3
- "version": "1.27.0",
3
+ "version": "1.28.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-08-17T23:28:03.271Z",
4
- "cli_version": "1.27.0",
3
+ "bundled_at": "2026-08-19T21:29:46.133Z",
4
+ "cli_version": "1.28.0",
5
5
  "skills": [
6
6
  {
7
7
  "id": "integration-node-anthropic",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: moda-cli
3
- version: 2.3.0
3
+ version: 2.4.0
4
4
  description: Query Moda's AI agent conversation analytics and manage code-first prompt versions from the terminal — semantic/keyword/hybrid message search, overview KPIs, topic clusters, message context, user frustration detections, tool failures, and moda prompts status/sync/promote. Use when the user asks about moda, modaflows, conversation analytics, prompt management, user frustrations, agent observability, tool failure debugging, wants to find conversations or tool calls about a topic, or wants to investigate how their AI agent is performing.
5
5
  ---
6
6
 
@@ -55,9 +55,9 @@ Optional:
55
55
 
56
56
  - `MODA_BASE_URL` defaults to `https://moda.dev`; override only for
57
57
  self-hosted or staging.
58
- - `MODA_SKILL_VERSION` — export to `2.3.0` so search-adoption telemetry can
58
+ - `MODA_SKILL_VERSION` — export to `2.4.0` so search-adoption telemetry can
59
59
  attribute usage to this skill version. Set it once per session:
60
- `export MODA_SKILL_VERSION=2.3.0`.
60
+ `export MODA_SKILL_VERSION=2.4.0`.
61
61
 
62
62
  ## Setup
63
63
 
@@ -106,10 +106,12 @@ Output shape:
106
106
 
107
107
  Rules and limitations:
108
108
 
109
- - **Requires an existing CLI session.** There is no browserless signup or
110
- login: with no stored session it exits `4` (`auth_required`) telling you to
109
+ - **Requires an existing CLI session.** Minting a *new* key is control-plane
110
+ work: with no stored session it exits `4` (`auth_required`) telling you to
111
111
  run `moda auth login` on a machine with a browser first, or to skip
112
- provisioning and export `MODA_API_KEY` directly.
112
+ provisioning and export `MODA_API_KEY` directly. If you already hold a key,
113
+ you do not need this command at all — `moda auth login --api-key` gets you
114
+ to an authenticated state with no browser.
113
115
  - **Never prompts, even on a TTY.** A single-tenant account auto-selects;
114
116
  with multiple tenants you must pass `--tenant-id=<id>` (or set
115
117
  `MODA_TENANT_ID`), otherwise it exits `5` (`needs_input`) with per-tenant
@@ -134,13 +136,34 @@ just need credentials.
134
136
 
135
137
  ```bash
136
138
  moda auth login # browser PKCE sign-in; stores a moda_ct_ session token
139
+ moda auth login --api-key # NON-INTERACTIVE: validate MODA_API_KEY and save it
140
+ moda auth login --non-interactive # never open a browser; fail if no key is available
141
+ printf %s "$KEY" | moda auth login --api-key-stdin # key never touches argv
142
+ moda auth login --api-key=<key> # inline; WARNS, key is visible in argv + shell history
137
143
  moda auth logout # revoke the session server-side, then delete it locally
138
- moda auth status # local session state; exit 0 logged in, exit 4 logged out
144
+ moda auth status # both credentials; exit 0 if either is usable, else 4
139
145
  moda auth status --online # additionally probe GET /api/cli/context
146
+ moda auth whoami # identity from the session, or from the API key
140
147
  moda auth token # print the raw bearer token + newline, nothing else
141
148
  curl -H "Authorization: Bearer $(moda auth token)" https://moda.dev/api/cli/context
142
149
  ```
143
150
 
151
+ **If you are an agent with `MODA_API_KEY` and no browser, this is the whole
152
+ flow:**
153
+
154
+ ```bash
155
+ moda auth whoami # exit 0 + "verified": true means you are authenticated
156
+ ```
157
+
158
+ The API key alone is sufficient for every data command; `moda auth login` is
159
+ only needed for control-plane work (`moda provision`, `moda init`). There are
160
+ two independent credentials and `auth` reports both:
161
+
162
+ | Credential | Obtained by | Used for |
163
+ | --- | --- | --- |
164
+ | `moda_ct_` session token | `moda auth login` (browser PKCE) | control plane: `provision`, `init`, `auth token` |
165
+ | `moda_sk_` API key | `MODA_API_KEY`, `moda provision`, `moda init` | every data command |
166
+
144
167
  - `moda auth login` opens the browser consent flow at `{MODA_BASE_URL}/auth/cli`
145
168
  with a localhost callback (ports 8432-8435, first free). Approving in the
146
169
  browser hands the CLI a one-time code that it exchanges for a `moda_ct_...`
@@ -151,13 +174,45 @@ curl -H "Authorization: Bearer $(moda auth token)" https://moda.dev/api/cli/cont
151
174
  - `moda auth logout` revokes the stored `moda_ct_` session server-side
152
175
  (best-effort) and deletes the local credential; legacy sessions are just
153
176
  deleted locally.
177
+ - `moda auth login --api-key` authenticates **without a browser**. Prefer
178
+ `MODA_API_KEY` (bare `--api-key`) or `--api-key-stdin`; `--api-key=<key>`
179
+ works but puts the secret in argv and shell history, so it warns. The key is
180
+ validated against the Data API before anything is written, then saved to
181
+ `~/.moda/config.json`. A rejected key (401/403) or an unreachable API both
182
+ fail without persisting. `--non-interactive` is the same path plus a
183
+ guarantee that no browser opens even when no key is found — it exits with
184
+ the commands that would mint one.
185
+ - If `MODA_API_KEY` holds a **different** key than the one being saved, the
186
+ environment still wins afterwards. Login says so instead of reporting a
187
+ clean success: `"shadowed_by_env": true`, `"effective_credential": "env"`,
188
+ exit `3`. Unset `MODA_API_KEY` to use the saved credential.
189
+ - `moda auth whoami` reports the browser session when there is one. With no
190
+ session but a usable API key it reports
191
+ `{ "credential": "api_key", "apiKeyCredential": { "source": <"env"|"profile"|"config">,
192
+ "masked": <string>, "verified": <bool> }, "tenant_id": <string|null> }`.
193
+ `verified: true` means the key was accepted by the API just now. If the API
194
+ is unreachable the command exits `3` (degraded) with `verified: false`
195
+ rather than claiming an identity it could not confirm; a rejected key is an
196
+ error, not a "logged out" state.
154
197
  - `moda auth status` reports
155
- `{ "loggedIn": <bool>, "profile": <string|null>, "expiresAt": <ISO|null> }`
198
+ `{ "loggedIn": <bool>, "authenticated": <bool>, "apiKeyCredential": {...},
199
+ "profile": <string|null>, "expiresAt": <ISO|null> }`
156
200
  plus `"user": { "email": <string> }` when the stored session carries an
157
- email (PKCE `moda_ct_` sessions do; legacy sessions omit it), and exits `4`
158
- when logged out. That exit is an **expected state signal, not
159
- an error**: the payload is clean data (no error block, no stderr noise), and
160
- no browser is ever opened. With `--online` the payload adds
201
+ email (PKCE `moda_ct_` sessions do; legacy sessions omit it). `loggedIn` is
202
+ **browser-session only**; `authenticated` is true when *either* credential is
203
+ usable, and the exit code follows `authenticated` (`0` usable, `4` neither).
204
+ `apiKeyCredential` is `{ "present": false }` or
205
+ `{ "present": true, "source": <"env"|"profile"|"config">, "masked": <string>,
206
+ "tenantId": <string|null> }` — `source: "env"` tells you a `MODA_API_KEY` is
207
+ shadowing anything on disk. The raw key is never emitted. Exit `4` is an
208
+ **expected state signal, not an error**: the payload is clean data (no error
209
+ block, no stderr noise), and no browser is ever opened.
210
+ Without `--online` there is no network I/O, so `authenticated` means the
211
+ credential is **present**, not that it still works, and
212
+ `apiKeyCredential.verified` is `null`. With `--online` the key is probed too:
213
+ a rejected key gets `verified: false`, stops counting toward `authenticated`,
214
+ and exits `4`; an unreachable API leaves it counting, so a blip never kills a
215
+ good credential. With `--online` the payload adds
161
216
  `{ "online": { "reachable": <bool>, "ok": <bool>, "tenantCount": <n> } }`:
162
217
  `reachable: true` means any HTTP response arrived (even a 401/500), `ok`
163
218
  reflects HTTP success, and `tenantCount` is present only when the response
@@ -621,23 +676,35 @@ replay **gate** that proves it on held-out production evidence. Use this loop
621
676
  whenever you are asked to fix a detected Problem, or before hand-editing
622
677
  prompt/skill files in a repo that talks to Moda.
623
678
 
679
+ Fixes are typed by where the evidence points: `PROMPT` (a managed prompt
680
+ revision — the checkout/verify/submit loop below), `TOOL_SCHEMA` (a proposed
681
+ rewrite of one tool's description), and `SKILL` (a drafted SKILL.md for an
682
+ agent-behavior problem). Tool and skill candidates are not repo files: read
683
+ them from the packet (they are also written locally under
684
+ `.moda/fixes/<SHORTREF>/`), apply them in your own stack, then confirm with
685
+ `moda fix mark-applied` — no GitHub integration required.
686
+
624
687
  The fail-to-pass loop:
625
688
 
626
689
  ```bash
627
690
  moda fixes # ranked queue of fixes with proof
628
691
  moda fix <fix_id> --packet # moda.fix_packet.v1: cause, target
629
692
  # file, evidence links, verify cmd,
630
- # handback + magic word
693
+ # handback + magic word; tool/skill
694
+ # candidates land in .moda/fixes/
631
695
  moda fix start <problem_id> --wait # draft from a Problem and drive
632
696
  # scope -> propose -> gate
633
- moda fix checkout <fix_id> # write the candidate at its target
634
- # source path (NEVER runs git)
697
+ moda fix checkout <fix_id> # PROMPT fixes: write the candidate
698
+ # at its target source path
699
+ # (NEVER runs git)
635
700
  # ...edit the target file...
636
701
  moda fix verify <fix_id> --prompt-file=<path> # re-gate local content on the
637
- # frozen holdout
702
+ # frozen holdout (PROMPT only)
638
703
  # iterate edit -> verify until exit 0, then hand back:
639
704
  moda fix submit <fix_id> --pr # draft PR via the Moda GitHub App
640
705
  moda fix submit <fix_id> --local-ref=<branch> # or record your own branch
706
+ moda fix mark-applied <fix_id> --note="…" # tool/skill/no-PR path: confirm a
707
+ # candidate you applied yourself
641
708
  moda fix dismiss <fix_id> --reason="why" # reject (feeds problem feedback)
642
709
  ```
643
710
 
@@ -660,6 +727,15 @@ Rules that keep the proof honest:
660
727
  - Fix PRs end with the magic word `Fixes MODA-FIX-<SHORTREF>` in the body —
661
728
  Moda-authored PRs carry it automatically; hand-authored PRs must include it
662
729
  so the merge confirms the fix and starts production monitoring.
730
+ - `mark-applied` is the confirmation for changes shipped without a PR (tool
731
+ descriptions, installed skills, hand-applied prompts): it flips the fix to
732
+ `SHIPPED`, records your `--note`, and starts the same monitoring the merge
733
+ webhook would. It is idempotent — repeating it is a duplicate no-op.
734
+ - `TOOL_SCHEMA` fixes gate like prompt fixes (both arms pin the same current
735
+ prompt; the proposed description is the only delta). `SKILL` fixes rest at
736
+ `PROPOSED` until the skill is registered with the Moda skill harness — an
737
+ inline drafted SKILL.md cannot enter the gate, so deliver it via the packet
738
+ and `mark-applied`.
663
739
  - The pipeline is advance-on-poll: `--wait` (on `start`, `verify`, or
664
740
  `moda fix <fix_id> --wait`) drives it; a fix left `GATING` will not finish
665
741
  on its own.
@@ -813,8 +889,10 @@ npx: `npx -p @moda-ai/cli moda <command>`.
813
889
  | `moda init` | Browser auth + provision API key + install agent rules |
814
890
  | `moda provision` | Headless tenant + API key provisioning; single JSON credentials doc on stdout (exit `4` without a session, `5` when `--tenant-id` is needed) |
815
891
  | `moda auth login` | Browser PKCE sign-in; stores a revocable `moda_ct_` session token |
892
+ | `moda auth login --api-key` | Non-interactive: validate `MODA_API_KEY` (or `--api-key=<key>`) and save it; never opens a browser |
893
+ | `moda auth whoami` | Authenticated identity from the session, or from the API key when there is no session |
816
894
  | `moda auth logout` | Revoke the CLI session server-side, then delete it locally |
817
- | `moda auth status` | Local auth session state (exit `4` = logged out — expected, not an error) |
895
+ | `moda auth status` | Both credentials; exit `0` if either is usable, `4` if neither (expected, not an error) |
818
896
  | `moda auth token` | Print the raw bearer token for `$(moda auth token)` composition |
819
897
  | `moda search "<query>"` | **Message-grain semantic/keyword/hybrid search (incl. tool calls); primary content-discovery path** |
820
898
  | `moda overview` | Dashboard KPIs, top clusters, recent activity |
@@ -842,11 +920,12 @@ npx: `npx -p @moda-ai/cli moda <command>`.
842
920
  | `moda prompts sync` | Upload changed prompt versions and update lockfile |
843
921
  | `moda prompts promote <key>` | Move a remote prompt label |
844
922
  | `moda fixes` | Ranked queue of Fixes ("a fix with proof"); `--status=S`, `--limit=N`, `--cursor=TOKEN` |
845
- | `moda fix <fix_id>` | One Fix: status + gate result; `--packet` prints `moda.fix_packet.v1`; `--wait` drives a running pipeline to rest |
923
+ | `moda fix <fix_id>` | One Fix: status + gate result; `--packet` prints `moda.fix_packet.v1` and writes tool/skill candidates under `.moda/fixes/<ref>/`; `--wait` drives a running pipeline to rest |
846
924
  | `moda fix start <problem_id>` | Draft a Fix from a Problem (`--type=auto\|prompt`); `--wait` drives scope → propose → gate |
847
- | `moda fix verify <fix_id>` | Re-gate on the frozen holdout; `--prompt-file=P` gates local content (exit `0` pass / `1` fail with per-case findings / `3` degraded — never a pass) |
848
- | `moda fix checkout <fix_id>` | Write the candidate at its target source path (never runs git) |
925
+ | `moda fix verify <fix_id>` | Re-gate on the frozen holdout; `--prompt-file=P` gates local content, PROMPT fixes only (exit `0` pass / `1` fail with per-case findings / `3` degraded — never a pass) |
926
+ | `moda fix checkout <fix_id>` | Write the candidate at its target source path (never runs git; PROMPT fixes only) |
849
927
  | `moda fix submit <fix_id>` | Deliver: `--pr` opens a draft PR \| `--local-ref=<branch>` records your branch |
928
+ | `moda fix mark-applied <fix_id>` | Confirm a candidate you applied in your own stack (`--note=TEXT`, ≤600 chars); no PR needed — flips to SHIPPED and starts monitoring |
850
929
  | `moda fix dismiss <fix_id>` | Reject with `--reason=TEXT` (feeds problem feedback) |
851
930
 
852
931
  `moda search` flags: `--mode=keyword|semantic|hybrid` (default `hybrid`),