@moda-ai/cli 1.10.1 → 1.11.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.
@@ -14,6 +14,7 @@
14
14
  // - ProductionAskResult in ./production-intelligence.ts (production_ask.v0.1)
15
15
  // - ModaIntelligenceV1 in ./intelligence-schema.ts (moda.intelligence.v1)
16
16
  import { MODA_INTELLIGENCE_V1_JSON_SCHEMA } from './intelligence-schema.js';
17
+ import { AGENT_ERROR_CODES } from './output.js';
17
18
  const agentSummarySchema = {
18
19
  type: 'object',
19
20
  additionalProperties: false,
@@ -83,15 +84,54 @@ const agentArtifactSchema = {
83
84
  description: { type: 'string' },
84
85
  },
85
86
  };
87
+ const agentInputRequestSchema = {
88
+ type: 'object',
89
+ additionalProperties: false,
90
+ required: ['id', 'question', 'kind'],
91
+ properties: {
92
+ id: { type: 'string' },
93
+ question: { type: 'string' },
94
+ kind: { type: 'string', enum: ['select', 'confirm', 'text'] },
95
+ choices: {
96
+ type: 'array',
97
+ items: {
98
+ type: 'object',
99
+ additionalProperties: false,
100
+ required: ['value', 'label', 'resume_flags'],
101
+ properties: {
102
+ value: { type: 'string' },
103
+ label: { type: 'string' },
104
+ resume_flags: { type: 'array', items: { type: 'string' } },
105
+ },
106
+ },
107
+ },
108
+ resume_command: { type: 'array', items: { type: 'string' } },
109
+ },
110
+ };
111
+ const agentAuthSchema = {
112
+ type: 'object',
113
+ additionalProperties: false,
114
+ required: ['reason', 'login_command'],
115
+ properties: {
116
+ reason: {
117
+ type: 'string',
118
+ enum: ['no_stored_credentials', 'token_expired', 'token_revoked', 'renew_failed'],
119
+ },
120
+ login_command: { type: 'array', items: { type: 'string' } },
121
+ resume_command: { type: 'array', items: { type: 'string' } },
122
+ },
123
+ };
86
124
  const agentErrorSchema = {
87
125
  type: 'object',
88
126
  additionalProperties: false,
89
127
  required: ['code', 'message', 'blocking'],
90
128
  properties: {
91
- code: { type: 'string' },
129
+ code: { type: 'string', enum: AGENT_ERROR_CODES },
92
130
  message: { type: 'string' },
93
131
  cause: { type: 'string' },
94
132
  suggested_commands: { type: 'array', items: { type: 'string' } },
133
+ input_request: agentInputRequestSchema,
134
+ auth: agentAuthSchema,
95
135
  blocking: { type: 'boolean' },
96
136
  },
97
137
  };
@@ -138,7 +178,7 @@ const agentEnvelopeSchema = {
138
178
  const agentEventSchema = {
139
179
  $id: 'moda.agent_event.v1',
140
180
  title: 'AgentEvent',
141
- description: 'Streaming NDJSON event emitted in agent-stream mode by the moda CLI.',
181
+ description: 'Streaming NDJSON event emitted in agent-stream mode by the moda CLI. The first event is started; run_id is constant; seq starts at 1 and increments without gaps; exactly one terminal event (completed, error, cancelled, needs_input, auth_required) ends every stream; heartbeat absence for more than 30 seconds is the stall signal.',
142
182
  type: 'object',
143
183
  additionalProperties: false,
144
184
  required: ['schema_version', 'event', 'command', 'generated_at'],
@@ -146,10 +186,25 @@ const agentEventSchema = {
146
186
  schema_version: { type: 'string', const: 'moda.agent_event.v1' },
147
187
  event: {
148
188
  type: 'string',
149
- enum: ['started', 'progress', 'artifact', 'approval_required', 'completed', 'error'],
189
+ enum: [
190
+ 'started',
191
+ 'progress',
192
+ 'artifact',
193
+ 'approval_required',
194
+ 'completed',
195
+ 'error',
196
+ 'heartbeat',
197
+ 'needs_input',
198
+ 'auth_required',
199
+ 'cancelled',
200
+ 'warning',
201
+ ],
150
202
  },
151
203
  command: { type: 'string' },
152
204
  generated_at: { type: 'string', format: 'date-time' },
205
+ run_id: { type: 'string' },
206
+ seq: { type: 'number' },
207
+ event_version: { type: 'number' },
153
208
  status: { type: 'string', enum: ['ok', 'degraded', 'error'] },
154
209
  phase: { type: 'string' },
155
210
  message: { type: 'string' },
@@ -157,6 +212,8 @@ const agentEventSchema = {
157
212
  artifact: agentArtifactSchema,
158
213
  result: agentEnvelopeSchema,
159
214
  error: agentErrorSchema,
215
+ input_request: agentInputRequestSchema,
216
+ auth: agentAuthSchema,
160
217
  },
161
218
  };
162
219
  const productionEvidenceRefSchema = {
package/dist/auth.js CHANGED
@@ -3,6 +3,7 @@ import { createServer } from 'node:http';
3
3
  import { existsSync, readFileSync, rmSync } from 'node:fs';
4
4
  import { randomBytes, timingSafeEqual } from 'node:crypto';
5
5
  import { resolveModaBaseUrl } from './config.js';
6
+ import { CliAuthError } from './errors.js';
6
7
  import { getModaPaths, loadProfileConfig, saveProfileConfig, secretFilePathFromRef, writeSecretJsonFile, } from './profiles.js';
7
8
  const CALLBACK_PORT = 8432;
8
9
  const AUTH_TIMEOUT_MS = 120_000;
@@ -310,7 +311,10 @@ export async function login(screen = 'login', options = {}) {
310
311
  export async function authFetch(path, options = {}, authOptions = {}) {
311
312
  const token = loadToken(authOptions);
312
313
  if (!token) {
313
- throw new Error('Not authenticated. Run "moda init" to log in.');
314
+ throw new CliAuthError('Not authenticated. Run "moda auth login" to log in.', {
315
+ reason: 'no_stored_credentials',
316
+ login_command: ['moda', 'auth', 'login'],
317
+ });
314
318
  }
315
319
  const headers = new Headers(options.headers);
316
320
  headers.set('Authorization', `Bearer ${token}`);
package/dist/cli.js CHANGED
@@ -12,16 +12,18 @@ 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 { CliInputError } from './errors.js';
15
+ import { 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';
19
19
  import { createCommandRegistry } from './command-registry.js';
20
- import { createAgentErrorEnvelope, errorToAgentError, resolveOutputMode, } from './output.js';
20
+ import { createCliOutput, EXIT_CANCELLED, resolveOutputMode, safeWriteStderr, safeWriteStdout, } from './output.js';
21
21
  import { renderJsonSchemas } from './agent-schemas.js';
22
22
  import { runAuthCommand, runConfigCommand, runKeysCommand, runProfilesCommand } from './profile-commands.js';
23
23
  import { runAgentCommand } from './agent.js';
24
24
  import { runProductionCommand } from './production-intelligence.js';
25
+ import { exitCodeForError, finishRun } from './exit-funnel.js';
26
+ import { buildManifest, renderManifestHuman } from './manifest.js';
25
27
  export { CliInputError } from './errors.js';
26
28
  export function parseArgs(argv) {
27
29
  const args = argv.slice(2);
@@ -106,6 +108,7 @@ function printHelp() {
106
108
  Commands:
107
109
  init Set up Moda in your project
108
110
  status Show concise local Moda setup status
111
+ manifest Describe the CLI machine protocol and commands
109
112
  doctor Diagnose local Moda setup and data flow
110
113
  auth login Authenticate with Moda
111
114
  auth logout Clear cached Moda auth
@@ -136,6 +139,7 @@ Commands:
136
139
  sync prompts Sync prompt versions to Moda Cloud
137
140
  doctor --json Emit structured diagnostic checks
138
141
  status --json Emit concise setup state
142
+ manifest --json Emit the CLI machine protocol manifest
139
143
  overview Harness health briefing with production signals
140
144
  clusters Browse topic cluster hierarchy
141
145
  cluster-conversations <node_id> List conversations in a cluster
@@ -228,6 +232,7 @@ Init flags:
228
232
  Examples:
229
233
  moda init
230
234
  moda status
235
+ moda manifest --json
231
236
  moda doctor --json
232
237
  moda profiles list
233
238
  moda profiles use staging
@@ -377,10 +382,7 @@ function recordConversationsTelemetry(context, data) {
377
382
  export async function runCommand(command, positional, flags, positionals = positional ? [positional] : [], context = createCommandContext({ command, positional, positionals, flags })) {
378
383
  switch (command) {
379
384
  case 'prompts': {
380
- await runPromptsCommand(positionals, flags, {
381
- profile: context.profile,
382
- env: context.env,
383
- });
385
+ await runPromptsCommand(context);
384
386
  break;
385
387
  }
386
388
  case 'overview': {
@@ -713,7 +715,7 @@ export const commandRegistry = createCommandRegistry([
713
715
  const result = await runInit(flagsToArgs(context.flags), {
714
716
  onEvent: (event) => {
715
717
  context.output.writeEvent({
716
- event: 'progress',
718
+ event: event.status === 'warning' ? 'warning' : 'progress',
717
719
  phase: event.phase,
718
720
  message: event.message,
719
721
  elapsed_ms: Date.now() - context.startedAt,
@@ -726,6 +728,30 @@ export const commandRegistry = createCommandRegistry([
726
728
  return { exitCode: 0, apiKey: result.apiKey };
727
729
  },
728
730
  },
731
+ {
732
+ name: 'manifest',
733
+ description: 'Describe the CLI machine protocol, commands, events, and exit codes',
734
+ examples: ['moda manifest --json', 'moda --agent manifest'],
735
+ auth: 'none',
736
+ outputModes: ['human', 'json', 'agent'],
737
+ mutability: 'read',
738
+ validateConfigBeforeRun: false,
739
+ resetApiRequestCountBeforeRun: false,
740
+ telemetry: 'none',
741
+ handler: async (context) => {
742
+ const manifest = buildManifest(commandRegistry.all());
743
+ if (context.outputMode === 'human') {
744
+ if (!context.quiet)
745
+ safeWriteStdout(`${renderManifestHuman(manifest)}\n`);
746
+ }
747
+ else {
748
+ context.output.writeData(manifest, {
749
+ summary: 'Moda CLI machine protocol manifest.',
750
+ });
751
+ }
752
+ return { exitCode: 0 };
753
+ },
754
+ },
729
755
  {
730
756
  name: 'auth',
731
757
  description: 'Authenticate and inspect browser auth sessions',
@@ -1035,55 +1061,58 @@ export const commandRegistry = createCommandRegistry([
1035
1061
  resetApiRequestCountBeforeRun: false,
1036
1062
  telemetry: 'result',
1037
1063
  handler: async (context) => ({
1038
- exitCode: await runSkillsCommand({
1039
- command: context.command,
1040
- positional: context.positional,
1041
- positionals: context.positionals,
1042
- flags: context.flags,
1043
- }),
1064
+ exitCode: await runSkillsCommand(context),
1044
1065
  }),
1045
1066
  },
1046
1067
  ]);
1047
1068
  // --- Main ---
1048
1069
  export function classifyCliError(error) {
1049
- if (error instanceof z.ZodError || error instanceof CliInputError) {
1070
+ if (error instanceof z.ZodError ||
1071
+ error instanceof CliInputError ||
1072
+ error instanceof CliInputRequiredError ||
1073
+ error instanceof CliAuthError) {
1074
+ // Auth/input-required are caller-actionable states (missing credentials,
1075
+ // needs a choice), not unknown failures. Bucket them with validation so
1076
+ // the missing-API-key case keeps its pre-hardening telemetry type.
1050
1077
  return 'validation_error';
1051
1078
  }
1052
- if (error instanceof ApiError) {
1079
+ if (error instanceof ApiError || isApiLikeError(error)) {
1053
1080
  return 'api_error';
1054
1081
  }
1055
- if (error instanceof Error &&
1056
- (error.message.startsWith('Network error:') ||
1057
- error.message.startsWith('Request timed out'))) {
1082
+ if (isNetworkLikeError(error)) {
1058
1083
  return 'network_error';
1059
1084
  }
1060
1085
  return 'unknown_error';
1061
1086
  }
1062
1087
  export function printCliError(error) {
1063
1088
  if (error instanceof CliInputError) {
1064
- console.error(`Error: ${error.message}`);
1089
+ safeWriteStderr(`Error: ${error.message}\n`);
1065
1090
  if (error.usage)
1066
- console.error(error.usage);
1091
+ safeWriteStderr(`${error.usage}\n`);
1067
1092
  }
1068
1093
  else if (error instanceof z.ZodError) {
1069
1094
  const issues = error.errors.map((e) => {
1070
1095
  const field = e.path.length ? e.path.join('.') : 'input';
1071
1096
  return ` ${field}: ${e.message}`;
1072
1097
  });
1073
- console.error(`Validation error:\n${issues.join('\n')}`);
1098
+ safeWriteStderr(`Validation error:\n${issues.join('\n')}\n`);
1074
1099
  }
1075
1100
  else if (error instanceof ApiError) {
1076
- console.error(`API error (HTTP ${error.statusCode}): ${error.responseBody || error.message}`);
1101
+ safeWriteStderr(`API error (HTTP ${error.statusCode}): ${error.responseBody || error.message}\n`);
1077
1102
  }
1078
1103
  else if (error instanceof Error) {
1079
- console.error(`Error: ${error.message}`);
1104
+ safeWriteStderr(`Error: ${error.message}\n`);
1080
1105
  }
1081
1106
  else {
1082
- console.error('Error:', error);
1107
+ safeWriteStderr(`Error: ${String(error)}\n`);
1083
1108
  }
1084
1109
  }
1085
- function printAgentCliError(command, error) {
1086
- console.log(JSON.stringify(createAgentErrorEnvelope(command || 'unknown', errorToAgentError(error))));
1110
+ function printAgentCliError(command, error, mode = 'agent') {
1111
+ const output = createCliOutput({
1112
+ command: command || 'unknown',
1113
+ mode,
1114
+ });
1115
+ output.writeError(error);
1087
1116
  }
1088
1117
  export async function dispatchParsedCommand(parsed, startedAt = Date.now()) {
1089
1118
  const { command, flags } = parsed;
@@ -1114,7 +1143,7 @@ export async function dispatchParsedCommand(parsed, startedAt = Date.now()) {
1114
1143
  env: process.env,
1115
1144
  });
1116
1145
  if (mode === 'agent' || mode === 'agent-stream') {
1117
- printAgentCliError(command, error);
1146
+ printAgentCliError(command, error, mode);
1118
1147
  }
1119
1148
  else {
1120
1149
  printCliError(error);
@@ -1129,12 +1158,6 @@ export async function dispatchParsedCommand(parsed, startedAt = Date.now()) {
1129
1158
  if (!updateCheckDisabled) {
1130
1159
  void startUpdateCheck();
1131
1160
  }
1132
- if (definition.validateConfigBeforeRun) {
1133
- validateConfig();
1134
- }
1135
- if (definition.resetApiRequestCountBeforeRun) {
1136
- resetApiRequestCount();
1137
- }
1138
1161
  const context = createCommandContext(parsed, {
1139
1162
  commandName: definition.name,
1140
1163
  commandFlags,
@@ -1148,6 +1171,43 @@ export async function dispatchParsedCommand(parsed, startedAt = Date.now()) {
1148
1171
  env: process.env,
1149
1172
  }),
1150
1173
  });
1174
+ setActiveContext(context);
1175
+ if (definition.validateConfigBeforeRun) {
1176
+ try {
1177
+ validateConfig();
1178
+ }
1179
+ catch (error) {
1180
+ if (!telemetryDisabled && definition.telemetry === 'result-and-error') {
1181
+ void sendCliUsageTelemetry({
1182
+ apiKey: resolveApiKey(),
1183
+ command: definition.name,
1184
+ flags: commandFlags,
1185
+ status: 'error',
1186
+ errorType: classifyCliError(error),
1187
+ exitCode: exitCodeForError(error),
1188
+ durationMs: Date.now() - startedAt,
1189
+ apiRequestCount: getApiRequestCount(),
1190
+ adoption: buildSearchAdoptionTelemetry({
1191
+ command: definition.name,
1192
+ positional: parsed.positional,
1193
+ flags: commandFlags,
1194
+ outputMode: context.outputMode,
1195
+ recorded: context.getTelemetryMeta(),
1196
+ skillVersion: process.env.MODA_SKILL_VERSION,
1197
+ }),
1198
+ });
1199
+ }
1200
+ if (context.outputMode === 'agent' || context.outputMode === 'agent-stream' || context.outputMode === 'json') {
1201
+ return finishRun(context, { kind: 'error', error });
1202
+ }
1203
+ printCliError(error);
1204
+ context.output.close();
1205
+ return exitCodeForError(error);
1206
+ }
1207
+ }
1208
+ if (definition.resetApiRequestCountBeforeRun) {
1209
+ resetApiRequestCount();
1210
+ }
1151
1211
  try {
1152
1212
  const result = await definition.handler(context);
1153
1213
  if (!telemetryDisabled && definition.telemetry !== 'none') {
@@ -1172,7 +1232,7 @@ export async function dispatchParsedCommand(parsed, startedAt = Date.now()) {
1172
1232
  if (!updateCheckDisabled) {
1173
1233
  printUpdateNotice();
1174
1234
  }
1175
- return result.exitCode;
1235
+ return finishRun(context, { kind: 'completed', exitCode: result.exitCode });
1176
1236
  }
1177
1237
  catch (error) {
1178
1238
  if (!telemetryDisabled && definition.telemetry === 'result-and-error') {
@@ -1182,7 +1242,7 @@ export async function dispatchParsedCommand(parsed, startedAt = Date.now()) {
1182
1242
  flags: commandFlags,
1183
1243
  status: 'error',
1184
1244
  errorType: classifyCliError(error),
1185
- exitCode: 1,
1245
+ exitCode: exitCodeForError(error),
1186
1246
  durationMs: Date.now() - startedAt,
1187
1247
  apiRequestCount: getApiRequestCount(),
1188
1248
  adoption: buildSearchAdoptionTelemetry({
@@ -1195,8 +1255,8 @@ export async function dispatchParsedCommand(parsed, startedAt = Date.now()) {
1195
1255
  }),
1196
1256
  });
1197
1257
  }
1198
- if (context.outputMode === 'agent' || context.outputMode === 'agent-stream') {
1199
- printAgentCliError(definition.name, error);
1258
+ if (context.outputMode === 'agent' || context.outputMode === 'agent-stream' || context.outputMode === 'json') {
1259
+ return finishRun(context, { kind: 'error', error });
1200
1260
  }
1201
1261
  else {
1202
1262
  printCliError(error);
@@ -1204,9 +1264,34 @@ export async function dispatchParsedCommand(parsed, startedAt = Date.now()) {
1204
1264
  if (!updateCheckDisabled) {
1205
1265
  printUpdateNotice();
1206
1266
  }
1207
- return 1;
1267
+ context.output.close();
1268
+ return exitCodeForError(error);
1208
1269
  }
1209
1270
  }
1271
+ let activeContext;
1272
+ let signalCount = 0;
1273
+ function setActiveContext(context) {
1274
+ activeContext = context;
1275
+ }
1276
+ function installSignalHandlers() {
1277
+ const handleSignal = (signal) => {
1278
+ signalCount += 1;
1279
+ if (signalCount > 1) {
1280
+ process.exit(EXIT_CANCELLED);
1281
+ }
1282
+ const reason = `Command cancelled by ${signal}.`;
1283
+ if (activeContext) {
1284
+ void finishRun(activeContext, { kind: 'cancelled', reason }).finally(() => {
1285
+ process.exit(EXIT_CANCELLED);
1286
+ });
1287
+ return;
1288
+ }
1289
+ printCliError(new CliCancellationError(reason));
1290
+ process.exit(EXIT_CANCELLED);
1291
+ };
1292
+ process.once('SIGINT', handleSignal);
1293
+ process.once('SIGTERM', handleSignal);
1294
+ }
1210
1295
  async function main() {
1211
1296
  // Set exitCode (don't process.exit) so any in-flight telemetry request
1212
1297
  // can resolve before Node drains its event loop and exits naturally.
@@ -1218,6 +1303,7 @@ import { realpathSync } from 'fs';
1218
1303
  const isMain = process.argv[1] &&
1219
1304
  fileURLToPath(import.meta.url) === realpathSync(process.argv[1]);
1220
1305
  if (isMain) {
1306
+ installSignalHandlers();
1221
1307
  main().catch((error) => {
1222
1308
  printCliError(error);
1223
1309
  process.exitCode = 1;
package/dist/config.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { DEFAULT_INGEST_URL, PROD_BASE_URL, getLegacyConfigPath, loadLegacyCliConfig, resolveApiKeyFromActiveProfile, resolveBaseUrl, resolveIngestUrl as resolveProfileIngestUrl, resolveTenantIdFromActiveProfile, syncLegacyConfigToProfile, writeLegacyCliConfig, } from './profiles.js';
3
+ import { CliAuthError } from './errors.js';
3
4
  const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
4
5
  export const VERSION = pkg.version;
5
6
  // Canonical host is moda.dev. The www host 308-redirects to the apex, and
@@ -65,9 +66,10 @@ export function resolveModaBaseUrl(options = {}) {
65
66
  }
66
67
  export function validateConfig() {
67
68
  if (!resolveApiKey()) {
68
- console.error('Error: no Moda API key found. Set MODA_API_KEY or run "moda init".');
69
- console.error('Get your API key from https://moda.dev/settings');
70
- process.exit(1);
69
+ throw new CliAuthError('No Moda API key found. Set MODA_API_KEY or run "moda init".', {
70
+ reason: 'no_stored_credentials',
71
+ login_command: ['moda', 'init'],
72
+ }, 'Get your API key from https://moda.dev/settings');
71
73
  }
72
74
  }
73
75
  // Kept for callers/tests that need the legacy path during migration.
package/dist/errors.d.ts CHANGED
@@ -2,4 +2,36 @@ export declare class CliInputError extends Error {
2
2
  readonly usage?: string | undefined;
3
3
  constructor(message: string, usage?: string | undefined);
4
4
  }
5
+ export interface AgentInputChoice {
6
+ value: string;
7
+ label: string;
8
+ resume_flags: string[];
9
+ }
10
+ export interface AgentInputRequest {
11
+ id: string;
12
+ question: string;
13
+ kind: 'select' | 'confirm' | 'text';
14
+ choices?: AgentInputChoice[];
15
+ resume_command?: string[];
16
+ }
17
+ export interface AgentAuthRequest {
18
+ reason: 'no_stored_credentials' | 'token_expired' | 'token_revoked' | 'renew_failed';
19
+ login_command: string[];
20
+ resume_command?: string[];
21
+ }
22
+ export declare class CliInputRequiredError extends Error {
23
+ readonly input_request: AgentInputRequest;
24
+ constructor(message: string, input_request: AgentInputRequest, usage?: string);
25
+ readonly usage?: string;
26
+ }
27
+ export declare class CliAuthError extends Error {
28
+ readonly auth: AgentAuthRequest;
29
+ constructor(message: string, auth: AgentAuthRequest, usage?: string);
30
+ readonly usage?: string;
31
+ }
32
+ export declare class CliCancellationError extends Error {
33
+ constructor(message: string);
34
+ }
35
+ export declare function isNetworkLikeError(error: unknown): boolean;
36
+ export declare function isApiLikeError(error: unknown): boolean;
5
37
  //# sourceMappingURL=errors.d.ts.map
package/dist/errors.js CHANGED
@@ -6,4 +6,44 @@ export class CliInputError extends Error {
6
6
  this.name = 'CliInputError';
7
7
  }
8
8
  }
9
+ export class CliInputRequiredError extends Error {
10
+ input_request;
11
+ constructor(message, input_request, usage) {
12
+ super(message);
13
+ this.input_request = input_request;
14
+ this.name = 'CliInputRequiredError';
15
+ if (usage)
16
+ this.usage = usage;
17
+ }
18
+ usage;
19
+ }
20
+ export class CliAuthError extends Error {
21
+ auth;
22
+ constructor(message, auth, usage) {
23
+ super(message);
24
+ this.auth = auth;
25
+ this.name = 'CliAuthError';
26
+ if (usage)
27
+ this.usage = usage;
28
+ }
29
+ usage;
30
+ }
31
+ export class CliCancellationError extends Error {
32
+ constructor(message) {
33
+ super(message);
34
+ this.name = 'CliCancellationError';
35
+ }
36
+ }
37
+ export function isNetworkLikeError(error) {
38
+ return error instanceof Error &&
39
+ (error.message.startsWith('Network error:') ||
40
+ error.message.startsWith('Request timed out') ||
41
+ error.name === 'AbortError' ||
42
+ /(?:ECONNRESET|ECONNREFUSED|ETIMEDOUT|ENOTFOUND|socket hang up)/i.test(error.message));
43
+ }
44
+ export function isApiLikeError(error) {
45
+ return error instanceof Error &&
46
+ (error.name === 'ApiError' ||
47
+ ('statusCode' in error && typeof error.statusCode === 'number'));
48
+ }
9
49
  //# sourceMappingURL=errors.js.map
@@ -0,0 +1,14 @@
1
+ import type { CommandContext } from './command-context.js';
2
+ export type RunOutcome = {
3
+ kind: 'completed';
4
+ exitCode?: number;
5
+ } | {
6
+ kind: 'error';
7
+ error: unknown;
8
+ } | {
9
+ kind: 'cancelled';
10
+ reason?: string;
11
+ };
12
+ export declare function finishRun(context: CommandContext, outcome: RunOutcome): Promise<number>;
13
+ export declare function exitCodeForError(error: unknown): number;
14
+ //# sourceMappingURL=exit-funnel.d.ts.map
@@ -0,0 +1,49 @@
1
+ import { CliAuthError, CliCancellationError, CliInputRequiredError, } from './errors.js';
2
+ import { EXIT_AUTH_REQUIRED, EXIT_CANCELLED, EXIT_ERROR, EXIT_INPUT_REQUIRED, EXIT_OK, wasOutputCancelled, } from './output.js';
3
+ const finishedContexts = new WeakSet();
4
+ export async function finishRun(context, outcome) {
5
+ if (finishedContexts.has(context)) {
6
+ return exitCodeForOutcome(outcome);
7
+ }
8
+ finishedContexts.add(context);
9
+ if (outcome.kind === 'completed') {
10
+ // Guarantee the stream contract structurally: exactly one terminal event
11
+ // ends every stream. A handler that returned without writing one gets a
12
+ // synthetic `completed` (a no-op in non-stream modes and when a terminal
13
+ // event was already emitted).
14
+ if (!context.output.hasTerminalOutput()) {
15
+ context.output.writeEvent({ event: 'completed', status: 'ok' });
16
+ }
17
+ context.output.close();
18
+ if (wasOutputCancelled())
19
+ return EXIT_CANCELLED;
20
+ return outcome.exitCode ?? EXIT_OK;
21
+ }
22
+ if (outcome.kind === 'cancelled') {
23
+ context.output.writeCancelled(outcome.reason ?? 'Command cancelled.');
24
+ context.output.close();
25
+ return EXIT_CANCELLED;
26
+ }
27
+ context.output.writeError(outcome.error);
28
+ context.output.close();
29
+ if (wasOutputCancelled())
30
+ return EXIT_CANCELLED;
31
+ return exitCodeForError(outcome.error);
32
+ }
33
+ export function exitCodeForError(error) {
34
+ if (error instanceof CliAuthError)
35
+ return EXIT_AUTH_REQUIRED;
36
+ if (error instanceof CliInputRequiredError)
37
+ return EXIT_INPUT_REQUIRED;
38
+ if (error instanceof CliCancellationError)
39
+ return EXIT_CANCELLED;
40
+ return EXIT_ERROR;
41
+ }
42
+ function exitCodeForOutcome(outcome) {
43
+ if (outcome.kind === 'completed')
44
+ return outcome.exitCode ?? EXIT_OK;
45
+ if (outcome.kind === 'cancelled')
46
+ return EXIT_CANCELLED;
47
+ return exitCodeForError(outcome.error);
48
+ }
49
+ //# sourceMappingURL=exit-funnel.js.map
package/dist/harness.js CHANGED
@@ -7,7 +7,7 @@ import { confirm, select } from '@inquirer/prompts';
7
7
  import { callControlAPI } from './api-client.js';
8
8
  import { AnalystTui, shouldUseAnalystTui } from './analyst-tui.js';
9
9
  import { resolveApiKey, resolveTenantId } from './config.js';
10
- import { CliInputError } from './errors.js';
10
+ import { CliInputError, CliInputRequiredError } from './errors.js';
11
11
  import { getActiveProfile } from './profiles.js';
12
12
  import { approveHarnessReport, createHarnessReport, graphForReport, hashHarnessReport, hashStableJson as hashHarnessReportStableJson, HARNESS_REPORT_PATH, loadHarnessReportForSync, normalizeHarnessReport, readHarnessReport, resolveHarnessReportPath, validateHarnessReport, writeHarnessReportArtifacts, } from './harness-report.js';
13
13
  import { renderHarnessAnalystChoice, renderHarnessAnalystLaunch, renderHarnessAnalystPreflight, renderHarnessAnalyzeHumanResult, renderHarnessProgress, } from './tui.js';
@@ -1623,7 +1623,7 @@ function buildCommandScanPlan(context, rootDir) {
1623
1623
  const rel = normalizePath(relative(context.cwd, rootDir)) || '.';
1624
1624
  const scope = explicitPath && rel !== '.' ? 'narrow' : 'broad';
1625
1625
  const machineReadable = context.outputMode !== 'human';
1626
- const approvalRequired = scope === 'broad' && !context.yes && !machineReadable && process.stdin.isTTY === true;
1626
+ const approvalRequired = context.positional === 'analyze' && scope === 'broad' && !context.yes;
1627
1627
  return {
1628
1628
  scope,
1629
1629
  root: rel,
@@ -1632,10 +1632,10 @@ function buildCommandScanPlan(context, rootDir) {
1632
1632
  granted: !approvalRequired,
1633
1633
  reason: approvalRequired
1634
1634
  ? 'broad scan requires confirmation'
1635
- : machineReadable
1636
- ? 'machine-readable output'
1637
- : context.yes
1638
- ? '--yes'
1635
+ : context.yes
1636
+ ? '--yes'
1637
+ : machineReadable
1638
+ ? 'machine-readable output'
1639
1639
  : scope === 'narrow'
1640
1640
  ? 'narrow path'
1641
1641
  : 'non-interactive terminal',
@@ -1646,6 +1646,18 @@ function buildCommandScanPlan(context, rootDir) {
1646
1646
  async function approveScanIfNeeded(scanPlan, options = {}) {
1647
1647
  if (!scanPlan.approval.required)
1648
1648
  return;
1649
+ if (options.context && (options.context.outputMode !== 'human' || process.stdin.isTTY !== true)) {
1650
+ throw new CliInputRequiredError(options.message ?? 'Scan this project to build a Moda harness manifest?', {
1651
+ id: 'harness_scan_approval',
1652
+ question: options.message ?? 'Scan this project to build a Moda harness manifest?',
1653
+ kind: 'confirm',
1654
+ choices: [
1655
+ { value: 'yes', label: 'Approve broad scan', resume_flags: ['--yes'] },
1656
+ { value: 'no', label: 'Decline broad scan', resume_flags: ['--path', scanPlan.root === '.' ? './src' : scanPlan.root] },
1657
+ ],
1658
+ resume_command: ['moda', 'harness', 'analyze', '--yes'],
1659
+ }, 'Re-run with `--yes` to approve the broad local read plan, or pass --path=<dir> for a narrower scan.');
1660
+ }
1649
1661
  if (options.context?.outputMode === 'human') {
1650
1662
  writeHumanBlock(options.context, renderHarnessAnalystPreflight({
1651
1663
  root: scanPlan.root,