@moda-ai/cli 1.15.0 → 1.16.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.
@@ -13,6 +13,7 @@
13
13
  // failure: it is returned as an outcome (`errorMessage`) and rendered the same
14
14
  // way buffered agent errors render.
15
15
  import { resolveApiKey, resolveModaBaseUrl } from './config.js';
16
+ import { stripAnsi } from './markdown-terminal.js';
16
17
  /** Signals "fall back to the buffered ask endpoint" — never surfaced raw. */
17
18
  export class AskStreamError extends Error {
18
19
  constructor(message) {
@@ -116,8 +117,10 @@ export class AskStreamCollector {
116
117
  case 'tool_start': {
117
118
  const tool = event.tool;
118
119
  const id = typeof tool?.id === 'string' ? tool.id : '';
119
- const name = typeof tool?.tool === 'string' ? tool.tool : 'tool';
120
- const title = typeof tool?.title === 'string' ? tool.title : name;
120
+ // Backend-controlled strings — strip ANSI so a rogue/compromised backend
121
+ // (or prompt-injected LLM output) can't inject terminal control sequences.
122
+ const name = stripAnsi(typeof tool?.tool === 'string' ? tool.tool : 'tool');
123
+ const title = stripAnsi(typeof tool?.title === 'string' ? tool.title : name);
121
124
  if (id)
122
125
  this.toolNames.set(id, name);
123
126
  this.onProgress(`• ${name} — ${title}…`);
@@ -191,7 +194,9 @@ export class AskStreamCollector {
191
194
  */
192
195
  const TOOL_CARD_DETAIL_MAX = 100;
193
196
  function oneLine(text) {
194
- const collapsed = text.replace(/\s+/g, ' ').trim();
197
+ // Strip ANSI first: the source is backend-controlled tool summaries/errors,
198
+ // and ESC bytes survive the whitespace collapse below.
199
+ const collapsed = stripAnsi(text).replace(/\s+/g, ' ').trim();
195
200
  return collapsed.length > TOOL_CARD_DETAIL_MAX
196
201
  ? `${collapsed.slice(0, TOOL_CARD_DETAIL_MAX - 1)}…`
197
202
  : collapsed;
@@ -907,6 +907,12 @@ function normalizeGraphItemFieldAliases(collection, item) {
907
907
  else if (typeof item.target === 'string')
908
908
  item.to = item.target;
909
909
  }
910
+ // Pre-1.14 analysts (e.g. a hosted container whose baked CLI predates the
911
+ // rename) emit the relationship type as `kind`. Observed in production:
912
+ // hosted reports failing validation with "relationships.<id> must include
913
+ // string type" while carrying the value under `kind`.
914
+ if (typeof item.type !== 'string' && typeof item.kind === 'string')
915
+ item.type = item.kind;
910
916
  }
911
917
  }
912
918
  /** Canonical evidence path for graph items: prefer explicit sourcePath, then evidence. */
@@ -0,0 +1,49 @@
1
+ export interface MarkdownTerminalOptions {
2
+ /** When false, all structural fixes still apply but zero ANSI codes are emitted. */
3
+ color: boolean;
4
+ }
5
+ /** Minimal ANSI styling helpers shared by the renderer and ask chrome. */
6
+ export interface TerminalStyles {
7
+ bold(text: string): string;
8
+ italic(text: string): string;
9
+ dim(text: string): string;
10
+ code(text: string): string;
11
+ /** Section heading: bold + underline. */
12
+ heading(text: string): string;
13
+ }
14
+ /**
15
+ * The CLI's color convention (mirrors `src/tui.ts` / `src/analyst-tui.ts`):
16
+ * color only for a human at a TTY, honoring `NO_COLOR` (present with ANY value,
17
+ * including the empty string, disables — per the no-color.org spec) and
18
+ * `TERM=dumb`.
19
+ */
20
+ export declare function shouldColorTerminalOutput(opts?: {
21
+ isTTY?: boolean;
22
+ env?: NodeJS.ProcessEnv;
23
+ }): boolean;
24
+ /**
25
+ * ANSI style constructors. Closers restore the specific attribute (22/23/39)
26
+ * rather than a full reset so nested styles (code inside bold, bold inside a
27
+ * heading) survive. When `color` is false every helper is the identity.
28
+ */
29
+ export declare function terminalStyles(color: boolean): TerminalStyles;
30
+ /** Remove ANSI/OSC control sequences from untrusted content. */
31
+ export declare function stripAnsi(text: string): string;
32
+ /**
33
+ * Render an agent markdown answer for direct terminal display.
34
+ *
35
+ * - Headers lose their `#` sigils; h1/h2 render bold+underlined, h3+ bold,
36
+ * with a blank line before.
37
+ * - `**bold**`, `*italic*`/`_italic_`, and backtick `code` spans lose their
38
+ * sigils and gain ANSI styling (bold / italic / cyan) when `color` is on.
39
+ * - List items are re-indented to consistent 2-space-per-level steps based on
40
+ * their original RELATIVE nesting (odd indents round to the nearest level; a
41
+ * top-level-looking item after deeper items stays top-level). Bullets render
42
+ * `•` at level 0 and `-` deeper; ordered items keep their numbers.
43
+ * - `|` table rows and fenced code blocks pass through untouched (only ANSI
44
+ * control sequences are stripped, since that content is backend-controlled).
45
+ * - Runs of 3+ blank lines collapse to a single blank line.
46
+ * - Prose is never reflowed or wrapped.
47
+ */
48
+ export declare function renderMarkdownForTerminal(markdown: string, opts: MarkdownTerminalOptions): string;
49
+ //# sourceMappingURL=markdown-terminal.d.ts.map
@@ -0,0 +1,212 @@
1
+ // src/markdown-terminal.ts
2
+ //
3
+ // Pure markdown -> terminal renderer for human-mode output (`moda ask`).
4
+ // The cloud agent answers in markdown; printing it raw leaves literal `##`
5
+ // header sigils, `**bold**` asterisks, and the model's erratic list
6
+ // indentation on screen. This module strips the sigils, applies ANSI styling
7
+ // when the terminal supports it, and normalizes list nesting to consistent
8
+ // 2-space steps — WITHOUT reflowing prose (terminals wrap themselves) and
9
+ // WITHOUT touching `|` tables or fenced code blocks.
10
+ //
11
+ // No dependencies: raw ANSI escape constants only (the CLI has no color lib).
12
+ /**
13
+ * The CLI's color convention (mirrors `src/tui.ts` / `src/analyst-tui.ts`):
14
+ * color only for a human at a TTY, honoring `NO_COLOR` (present with ANY value,
15
+ * including the empty string, disables — per the no-color.org spec) and
16
+ * `TERM=dumb`.
17
+ */
18
+ export function shouldColorTerminalOutput(opts = {}) {
19
+ const env = opts.env ?? process.env;
20
+ const isTTY = opts.isTTY ?? process.stdout.isTTY === true;
21
+ if (!isTTY)
22
+ return false;
23
+ if (env.NO_COLOR !== undefined)
24
+ return false;
25
+ if (env.TERM === 'dumb')
26
+ return false;
27
+ return true;
28
+ }
29
+ /**
30
+ * ANSI style constructors. Closers restore the specific attribute (22/23/39)
31
+ * rather than a full reset so nested styles (code inside bold, bold inside a
32
+ * heading) survive. When `color` is false every helper is the identity.
33
+ */
34
+ export function terminalStyles(color) {
35
+ const paint = (open, close) => (text) => color ? `\u001b[${open}m${text}\u001b[${close}m` : text;
36
+ return {
37
+ bold: paint('1', '22'),
38
+ italic: paint('3', '23'),
39
+ dim: paint('2', '22'),
40
+ code: paint('36', '39'),
41
+ heading: paint('1;4', '22;24'),
42
+ };
43
+ }
44
+ const FENCE_RE = /^\s*(`{3,}|~{3,})/;
45
+ const FENCE_CLOSE_RE = /^\s*(`{3,}|~{3,})\s*$/;
46
+ const TABLE_ROW_RE = /^\s*\|.*\S/;
47
+ // Matches ANSI/OSC control sequences: CSI (ESC [ … final byte), OSC (ESC ] …
48
+ // BEL or ST), and other two-byte escapes. Backend-controlled content (table
49
+ // cells, code-fence bodies, streamed tool names/titles) is passed through
50
+ // verbatim, so any escape sequences an LLM or a compromised backend emits must
51
+ // be stripped before they reach the terminal — otherwise they could clear the
52
+ // screen, spoof UI, or trigger OSC side-effects in some emulators.
53
+ const ANSI_RE = /\u001b(?:\[[0-9;?]*[ -/]*[@-~]|\][^\u0007\u001b]*(?:\u0007|\u001b\\)|[@-Z\\-_])/g;
54
+ /** Remove ANSI/OSC control sequences from untrusted content. */
55
+ export function stripAnsi(text) {
56
+ return text.replace(ANSI_RE, '');
57
+ }
58
+ /**
59
+ * A fenced code block's opening marker: the fence character (`` ` `` or `~`)
60
+ * and its length. CommonMark closes a fence only with the same character and at
61
+ * least as many markers, so tracking these prevents a `~~~` line from closing a
62
+ * ` ``` ` block (and a 3-backtick line from closing a 4-backtick opener).
63
+ */
64
+ function parseFenceOpen(line) {
65
+ const match = FENCE_RE.exec(line);
66
+ if (!match)
67
+ return null;
68
+ return { char: match[1][0], len: match[1].length };
69
+ }
70
+ /** Whether `line` closes an open fence per the CommonMark same-char/length rule. */
71
+ function isFenceClose(line, fence) {
72
+ const match = FENCE_CLOSE_RE.exec(line);
73
+ return match !== null && match[1][0] === fence.char && match[1].length >= fence.len;
74
+ }
75
+ const HEADER_RE = /^(#{1,6})\s+(.*?)\s*#*\s*$/;
76
+ const BULLET_RE = /^(\s*)([-*+])\s+(.*)$/;
77
+ const ORDERED_RE = /^(\s*)(\d+)[.)]\s+(.*)$/;
78
+ /**
79
+ * Render an agent markdown answer for direct terminal display.
80
+ *
81
+ * - Headers lose their `#` sigils; h1/h2 render bold+underlined, h3+ bold,
82
+ * with a blank line before.
83
+ * - `**bold**`, `*italic*`/`_italic_`, and backtick `code` spans lose their
84
+ * sigils and gain ANSI styling (bold / italic / cyan) when `color` is on.
85
+ * - List items are re-indented to consistent 2-space-per-level steps based on
86
+ * their original RELATIVE nesting (odd indents round to the nearest level; a
87
+ * top-level-looking item after deeper items stays top-level). Bullets render
88
+ * `•` at level 0 and `-` deeper; ordered items keep their numbers.
89
+ * - `|` table rows and fenced code blocks pass through untouched (only ANSI
90
+ * control sequences are stripped, since that content is backend-controlled).
91
+ * - Runs of 3+ blank lines collapse to a single blank line.
92
+ * - Prose is never reflowed or wrapped.
93
+ */
94
+ export function renderMarkdownForTerminal(markdown, opts) {
95
+ const style = terminalStyles(opts.color);
96
+ const out = [];
97
+ // Original indent width backing each active list level; index == level.
98
+ const listStack = [];
99
+ // The open fence's char + length, or null outside a fenced code block.
100
+ let fence = null;
101
+ for (const raw of markdown.replace(/\r\n/g, '\n').split('\n')) {
102
+ if (fence) {
103
+ out.push(stripAnsi(raw));
104
+ if (isFenceClose(raw, fence))
105
+ fence = null;
106
+ continue;
107
+ }
108
+ const opened = parseFenceOpen(raw);
109
+ if (opened) {
110
+ fence = opened;
111
+ listStack.length = 0;
112
+ out.push(stripAnsi(raw));
113
+ continue;
114
+ }
115
+ if (TABLE_ROW_RE.test(raw)) {
116
+ // Tables depend on exact column spacing — pass through monospaced.
117
+ out.push(stripAnsi(raw));
118
+ continue;
119
+ }
120
+ const line = raw.replace(/\s+$/, '');
121
+ if (line === '') {
122
+ out.push('');
123
+ continue;
124
+ }
125
+ const header = HEADER_RE.exec(line);
126
+ if (header) {
127
+ listStack.length = 0;
128
+ // Strip inline sigils inside the header text (plain), then style the
129
+ // whole line so nested `**bold**` can't fight the heading attributes.
130
+ const text = renderInline(header[2], terminalStyles(false));
131
+ if (out.length > 0 && out[out.length - 1] !== '')
132
+ out.push('');
133
+ out.push(header[1].length <= 2 ? style.heading(text) : style.bold(text));
134
+ continue;
135
+ }
136
+ const bullet = BULLET_RE.exec(line);
137
+ const ordered = bullet ? null : ORDERED_RE.exec(line);
138
+ if (bullet || ordered) {
139
+ const match = (bullet ?? ordered);
140
+ const level = resolveListLevel(match[1].length, listStack);
141
+ const indent = ' '.repeat(level);
142
+ const text = renderInline(match[3], style);
143
+ if (bullet) {
144
+ out.push(`${indent}${level === 0 ? '•' : '-'} ${text}`);
145
+ }
146
+ else {
147
+ out.push(`${indent}${match[2]}. ${text}`);
148
+ }
149
+ continue;
150
+ }
151
+ // Unindented prose ends any open list; indented non-list lines are treated
152
+ // as continuations and keep the current nesting context.
153
+ if (!/^\s{2,}/.test(line))
154
+ listStack.length = 0;
155
+ out.push(renderInline(line, style));
156
+ }
157
+ return collapseBlankRuns(out).join('\n');
158
+ }
159
+ /**
160
+ * Map an item's original indent width onto a consistent nesting level.
161
+ * `stack[level]` remembers the original indent that opened that level:
162
+ * meaningfully shallower indents pop back out, meaningfully deeper indents
163
+ * open a new level, and anything within +/-1 space sticks to the current
164
+ * level (rounding the model's odd 3/5/7-space indents to the nearest level).
165
+ */
166
+ function resolveListLevel(indent, stack) {
167
+ while (stack.length > 0 && indent < stack[stack.length - 1] - 1)
168
+ stack.pop();
169
+ if (stack.length === 0 || indent > stack[stack.length - 1] + 1)
170
+ stack.push(indent);
171
+ return stack.length - 1;
172
+ }
173
+ /**
174
+ * Strip/style inline markdown sigils: `code` spans first (protected via
175
+ * placeholders so asterisks/underscores inside them are never re-parsed),
176
+ * then `**bold**`/`__bold__`, then word-boundary `*italic*`/`_italic_`
177
+ * (mid-word underscores as in `silent_empty_result` never match).
178
+ */
179
+ function renderInline(text, style) {
180
+ const codeSpans = [];
181
+ // Strip any ANSI escapes already in the (backend-controlled) text BEFORE we
182
+ // add our own styling, so this only removes injected sequences — never the
183
+ // codes the style helpers emit below.
184
+ let result = stripAnsi(text).replace(/`([^`]+)`/g, (_, code) => {
185
+ codeSpans.push(code);
186
+ return `\u0000${codeSpans.length - 1}\u0000`;
187
+ });
188
+ result = result.replace(/\*\*([^\s*](?:[^*]*[^\s*])?)\*\*/g, (_, inner) => style.bold(inner));
189
+ result = result.replace(/__([^\s_](?:[^_]*[^\s_])?)__/g, (_, inner) => style.bold(inner));
190
+ result = result.replace(/(?<![\w*])\*([^\s*](?:[^*\n]*[^\s*])?)\*(?![\w*])/g, (_, inner) => style.italic(inner));
191
+ result = result.replace(/(?<![\w_])_([^\s_](?:[^_\n]*[^\s_])?)_(?![\w_])/g, (_, inner) => style.italic(inner));
192
+ return result.replace(/\u0000(\d+)\u0000/g, (_, index) => style.code(codeSpans[Number(index)]));
193
+ }
194
+ /** Collapse runs of 3+ blank lines down to a single blank line. */
195
+ function collapseBlankRuns(lines) {
196
+ const collapsed = [];
197
+ let blanks = 0;
198
+ for (const line of lines) {
199
+ if (line === '') {
200
+ blanks += 1;
201
+ continue;
202
+ }
203
+ if (blanks > 0)
204
+ collapsed.push('', ...(blanks === 2 ? [''] : []));
205
+ blanks = 0;
206
+ collapsed.push(line);
207
+ }
208
+ if (blanks > 0)
209
+ collapsed.push('', ...(blanks === 2 ? [''] : []));
210
+ return collapsed;
211
+ }
212
+ //# sourceMappingURL=markdown-terminal.js.map
@@ -166,6 +166,8 @@ export declare function shouldStreamAsk(context: Pick<CommandContext, 'outputMod
166
166
  * confidence — the buffered endpoint also returns HTTP 200 in that case).
167
167
  */
168
168
  export declare function askResultFromStreamOutcome(outcome: AskStreamOutcome, question: string): ProductionAskResult;
169
- export declare function renderAskHuman(result: ProductionAskResult): string;
169
+ export declare function renderAskHuman(result: ProductionAskResult, options?: {
170
+ color?: boolean;
171
+ }): string;
170
172
  export {};
171
173
  //# sourceMappingURL=production-intelligence.d.ts.map
@@ -5,6 +5,7 @@ import { streamAsk } from './ask-stream.js';
5
5
  import { resolveModaBaseUrl } from './config.js';
6
6
  import { CliAuthError, CliInputError } from './errors.js';
7
7
  import { EXIT_DEGRADED, EXIT_OK, safeWriteStderr } from './output.js';
8
+ import { renderMarkdownForTerminal, shouldColorTerminalOutput, stripAnsi, terminalStyles, } from './markdown-terminal.js';
8
9
  import { asNumber, asRecord, asString } from './transforms.js';
9
10
  import { INTELLIGENCE_SCHEMA_VERSION, } from './intelligence-schema.js';
10
11
  const INVESTIGATION_SCHEMA_VERSION = 'production_investigation.v0.1';
@@ -262,6 +263,9 @@ export function shouldStreamAsk(context, isTTY = process.stdout.isTTY === true)
262
263
  */
263
264
  async function askModaForContext(context, question, daysBack) {
264
265
  if (shouldStreamAsk(context)) {
266
+ // Progress lines go to stderr, so key color off the stderr TTY. Tool cards
267
+ // (`• …`) are dimmed subtly; phase lines (`▸ …`) stay at full strength.
268
+ const progressStyle = terminalStyles(shouldColorTerminalOutput({ isTTY: process.stderr.isTTY === true }));
265
269
  try {
266
270
  const outcome = await streamAsk({
267
271
  question,
@@ -269,7 +273,7 @@ async function askModaForContext(context, question, daysBack) {
269
273
  profile: context.profile,
270
274
  harness: localHarnessSummary(context.cwd),
271
275
  timeoutMs: askTimeoutMs(),
272
- onProgress: (line) => safeWriteStderr(`${line}\n`),
276
+ onProgress: (line) => safeWriteStderr(`${line.startsWith('•') ? progressStyle.dim(line) : line}\n`),
273
277
  });
274
278
  return askResultFromStreamOutcome(outcome, question);
275
279
  }
@@ -1023,22 +1027,26 @@ function renderInvestigationHuman(investigation) {
1023
1027
  }
1024
1028
  return lines.join('\n');
1025
1029
  }
1026
- export function renderAskHuman(result) {
1030
+ export function renderAskHuman(result, options = {}) {
1031
+ // Same convention as the rest of the CLI: color only for a human at a TTY,
1032
+ // honoring NO_COLOR and TERM=dumb. All information is identical either way.
1033
+ const color = options.color ?? shouldColorTerminalOutput();
1034
+ const style = terminalStyles(color);
1027
1035
  const lines = [];
1028
- lines.push('Moda ask');
1029
- lines.push(`Question: ${result.question}`);
1036
+ lines.push(style.heading('Moda ask'));
1037
+ lines.push(`${style.dim('Question:')} ${result.question}`);
1030
1038
  if (result.degraded) {
1031
1039
  lines.push('');
1032
1040
  lines.push('[degraded] Moda Cloud was unavailable — this answer is synthesized from local Data API evidence (lower trust).');
1033
1041
  }
1034
1042
  lines.push('');
1035
- lines.push(result.answer);
1043
+ lines.push(renderMarkdownForTerminal(result.answer, { color }));
1036
1044
  lines.push('');
1037
- lines.push(`Confidence: ${result.confidence}`);
1045
+ lines.push(`Confidence: ${style.bold(result.confidence)}`);
1038
1046
  lines.push(`Source: ${result.source}${result.degraded ? ' (degraded)' : ''}`);
1039
1047
  if (result.evidenceRefs.length > 0) {
1040
1048
  lines.push('');
1041
- lines.push('Evidence');
1049
+ lines.push(style.bold('Evidence'));
1042
1050
  // Subtle grounding marker: flag model-inferred evidence so a human reader
1043
1051
  // can weight it. Ids/anchors stay hidden in the human projection.
1044
1052
  const grounding = new Map(result.intelligence.evidence_refs.map((ref) => [ref.id, ref.grounding]));
@@ -1047,8 +1055,11 @@ export function renderAskHuman(result) {
1047
1055
  const marker = grounding.get(ref.id) === 'inferred' ? ' (inferred)' : '';
1048
1056
  // Backend hrefs are root-relative dashboard paths; print the absolute
1049
1057
  // URL so terminals auto-linkify it and the reader can click through.
1050
- const link = ref.href ? `\n ${dashboardBase}${ref.href}` : '';
1051
- lines.push(` - ${ref.label}${ref.path ? ` (${ref.path})` : ''}${marker}${link}`);
1058
+ // Strip ANSI from all backend-controlled fields before printing.
1059
+ const label = stripAnsi(ref.label);
1060
+ const path = ref.path ? ` (${stripAnsi(ref.path)})` : '';
1061
+ const link = ref.href ? `\n ${style.dim(`${dashboardBase}${stripAnsi(ref.href)}`)}` : '';
1062
+ lines.push(` - ${label}${path}${marker}${link}`);
1052
1063
  }
1053
1064
  }
1054
1065
  const unverifiedClaims = result.intelligence.answer_claims.filter((claim) => claim.status !== 'grounded').length;
@@ -1058,15 +1069,15 @@ export function renderAskHuman(result) {
1058
1069
  }
1059
1070
  if (result.nextCommands.length > 0) {
1060
1071
  lines.push('');
1061
- lines.push('Next commands');
1072
+ lines.push(style.bold('Next commands'));
1062
1073
  for (const command of result.nextCommands.slice(0, 5))
1063
- lines.push(` ${command}`);
1074
+ lines.push(` ${stripAnsi(command)}`);
1064
1075
  }
1065
1076
  if (result.warnings.length > 0) {
1066
1077
  lines.push('');
1067
- lines.push('Warnings');
1078
+ lines.push(style.bold('Warnings'));
1068
1079
  for (const warning of result.warnings.slice(0, 3))
1069
- lines.push(` - ${warning}`);
1080
+ lines.push(` - ${stripAnsi(warning)}`);
1070
1081
  }
1071
1082
  return lines.join('\n');
1072
1083
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moda-ai/cli",
3
- "version": "1.15.0",
3
+ "version": "1.16.0",
4
4
  "description": "CLI for Moda - AI agent analytics and observability",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema_version": "moda.skill_index.v1",
3
- "bundled_at": "2026-07-08T14:05:25.151Z",
4
- "cli_version": "1.15.0",
3
+ "bundled_at": "2026-07-08T20:47:23.985Z",
4
+ "cli_version": "1.16.0",
5
5
  "skills": [
6
6
  {
7
7
  "id": "integration-node-anthropic",