@moda-ai/cli 1.17.1 → 1.18.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.
@@ -0,0 +1,80 @@
1
+ export type ProgressLaneStatus = 'running' | 'done' | 'warning' | 'failed';
2
+ export interface ProgressLane {
3
+ id: string;
4
+ title: string;
5
+ status: ProgressLaneStatus;
6
+ /** Latest one-line detail while running; frozen summary note when terminal. */
7
+ detail?: string;
8
+ /** Duration expectation appended to long-running lanes, e.g. "usually 2-4 min". */
9
+ expectation?: string;
10
+ startedAt: number;
11
+ finishedAt?: number;
12
+ }
13
+ export interface ProgressBoardFrameOptions {
14
+ now?: number;
15
+ spinnerFrame?: string;
16
+ color?: boolean;
17
+ /** Max visible line width; longer lines are truncated so in-place rewrites never wrap. */
18
+ width?: number;
19
+ }
20
+ /**
21
+ * Pure frame renderer (unit-testable without a TTY): one line per lane, in
22
+ * insertion order. Running lanes show spinner, title, elapsed, latest detail,
23
+ * and the "(usually X min)" expectation; terminal lanes freeze to a glyph,
24
+ * title, and their summary note.
25
+ */
26
+ export declare function renderProgressBoardFrame(lanes: readonly ProgressLane[], options?: ProgressBoardFrameOptions): string[];
27
+ export interface InitProgressBoardOptions {
28
+ stream?: NodeJS.WriteStream;
29
+ /** Overrides TTY detection (in-place rendering); defaults to stream.isTTY with TERM=dumb treated as non-TTY. */
30
+ isTTY?: boolean;
31
+ color?: boolean;
32
+ now?: () => number;
33
+ renderIntervalMs?: number;
34
+ fallbackIntervalMs?: number;
35
+ }
36
+ /**
37
+ * Stateful board driver. TTY mode repaints on a ~150ms interval (unref'd so it
38
+ * never holds the process open); non-TTY mode prints plain lines on lane
39
+ * start/terminal transitions and throttles intermediate updates to one line
40
+ * per lane per 10s.
41
+ */
42
+ export declare class InitProgressBoard {
43
+ private readonly stream;
44
+ private readonly tty;
45
+ private readonly color;
46
+ private readonly now;
47
+ private readonly renderIntervalMs;
48
+ private readonly fallbackIntervalMs;
49
+ private readonly laneList;
50
+ private readonly lanesById;
51
+ private readonly lastFallbackPrint;
52
+ private timer;
53
+ private renderedLines;
54
+ private spinnerIndex;
55
+ private stopped;
56
+ constructor(options?: InitProgressBoardOptions);
57
+ get lanes(): readonly ProgressLane[];
58
+ startLane(id: string, title: string, options?: {
59
+ detail?: string;
60
+ expectation?: string;
61
+ }): void;
62
+ update(id: string, detail: string): void;
63
+ complete(id: string, note?: string): void;
64
+ warn(id: string, note?: string): void;
65
+ fail(id: string, note?: string): void;
66
+ /** Freeze the final frame (TTY) and release the repaint timer. Idempotent. */
67
+ stop(): void;
68
+ /**
69
+ * Terminal transitions are refinable: finishing an already-terminal lane
70
+ * updates its glyph/note (keeping the first finish time for elapsed), so a
71
+ * lane frozen early (e.g. "analysis finished on Moda; finalizing after
72
+ * setup") can be corrected once the final outcome is known. Exact duplicate
73
+ * finishes stay no-ops; `update()` still applies to running lanes only.
74
+ */
75
+ private finish;
76
+ private ensureTimer;
77
+ private render;
78
+ private writeLine;
79
+ }
80
+ //# sourceMappingURL=progress-board.d.ts.map
@@ -0,0 +1,225 @@
1
+ // src/init/progress-board.ts. Multi-lane live progress board for moda init
2
+ // Step 4 (Set up), where several activities run at once (hosted harness
3
+ // analysis, fast local tasks, the SDK integration agent).
4
+ //
5
+ // Hand-rolled, no dependencies, stderr only. On a TTY the lane block is
6
+ // rewritten in place with cursor-up ANSI, throttled to ~150ms; completed lanes
7
+ // freeze to a check line so the board never grows unbounded. Non-TTY human
8
+ // mode falls back to plain progress lines at most every 10s per lane.
9
+ // Agent/json modes never construct a board (init emits NDJSON progress events
10
+ // instead). Color gating follows tui.ts conventions (NO_COLOR / TERM=dumb
11
+ // respected); spinner and duration helpers follow analyst-tui.ts.
12
+ import { stripAnsi } from '../markdown-terminal.js';
13
+ const SPINNER_FRAMES = ['◐', '◓', '◑', '◒'];
14
+ const GLYPHS = {
15
+ done: '✓',
16
+ warning: '!',
17
+ failed: '✗',
18
+ };
19
+ const GLYPH_COLORS = {
20
+ done: 32,
21
+ warning: 33,
22
+ failed: 31,
23
+ };
24
+ /** Same one-line cap as ask-stream's tool cards. */
25
+ const DETAIL_MAX = 100;
26
+ const RENDER_INTERVAL_MS = 150;
27
+ const NON_TTY_LANE_INTERVAL_MS = 10_000;
28
+ /**
29
+ * Pure frame renderer (unit-testable without a TTY): one line per lane, in
30
+ * insertion order. Running lanes show spinner, title, elapsed, latest detail,
31
+ * and the "(usually X min)" expectation; terminal lanes freeze to a glyph,
32
+ * title, and their summary note.
33
+ */
34
+ export function renderProgressBoardFrame(lanes, options = {}) {
35
+ const now = options.now ?? Date.now();
36
+ const color = options.color ?? false;
37
+ const width = Math.max(48, Math.min(options.width ?? 120, 200));
38
+ const paint = (code, text) => color ? `\u001b[${code}m${text}\u001b[0m` : text;
39
+ const titleWidth = Math.min(32, lanes.reduce((max, lane) => Math.max(max, lane.title.length), 0));
40
+ return lanes.map((lane) => {
41
+ const title = lane.title.padEnd(titleWidth);
42
+ if (lane.status === 'running') {
43
+ const elapsed = formatDuration(Math.max(0, Math.round((now - lane.startedAt) / 1000)));
44
+ const detail = lane.detail ? ` ${oneLine(lane.detail)}` : '';
45
+ const expectation = lane.expectation ? ` ${paint(90, `(${lane.expectation})`)}` : '';
46
+ const glyph = paint(36, options.spinnerFrame ?? SPINNER_FRAMES[0]);
47
+ return truncateVisible(` ${glyph} ${title} ${paint(90, elapsed.padStart(6))}${detail}${expectation}`, width);
48
+ }
49
+ const glyph = paint(GLYPH_COLORS[lane.status], GLYPHS[lane.status]);
50
+ const note = lane.detail
51
+ ? oneLine(lane.detail)
52
+ : formatDuration(Math.max(0, Math.round(((lane.finishedAt ?? now) - lane.startedAt) / 1000)));
53
+ return truncateVisible(` ${glyph} ${title} ${note}`, width);
54
+ });
55
+ }
56
+ /**
57
+ * Stateful board driver. TTY mode repaints on a ~150ms interval (unref'd so it
58
+ * never holds the process open); non-TTY mode prints plain lines on lane
59
+ * start/terminal transitions and throttles intermediate updates to one line
60
+ * per lane per 10s.
61
+ */
62
+ export class InitProgressBoard {
63
+ stream;
64
+ tty;
65
+ color;
66
+ now;
67
+ renderIntervalMs;
68
+ fallbackIntervalMs;
69
+ laneList = [];
70
+ lanesById = new Map();
71
+ lastFallbackPrint = new Map();
72
+ timer;
73
+ renderedLines = 0;
74
+ spinnerIndex = 0;
75
+ stopped = false;
76
+ constructor(options = {}) {
77
+ this.stream = options.stream ?? process.stderr;
78
+ const dumbTerm = process.env.TERM === 'dumb';
79
+ this.tty = options.isTTY ?? (this.stream.isTTY === true && !dumbTerm);
80
+ // NO_COLOR disables color when set to any value, per no-color.org.
81
+ this.color = options.color ?? (this.tty && process.env.NO_COLOR === undefined);
82
+ this.now = options.now ?? Date.now;
83
+ this.renderIntervalMs = options.renderIntervalMs ?? RENDER_INTERVAL_MS;
84
+ this.fallbackIntervalMs = options.fallbackIntervalMs ?? NON_TTY_LANE_INTERVAL_MS;
85
+ }
86
+ get lanes() {
87
+ return this.laneList;
88
+ }
89
+ startLane(id, title, options = {}) {
90
+ if (this.stopped || this.lanesById.has(id))
91
+ return;
92
+ const lane = {
93
+ id,
94
+ title,
95
+ status: 'running',
96
+ detail: options.detail,
97
+ expectation: options.expectation,
98
+ startedAt: this.now(),
99
+ };
100
+ this.laneList.push(lane);
101
+ this.lanesById.set(id, lane);
102
+ if (this.tty) {
103
+ this.ensureTimer();
104
+ this.render();
105
+ }
106
+ else {
107
+ this.lastFallbackPrint.set(id, this.now());
108
+ this.writeLine(` ${title}: ${lane.detail ? oneLine(lane.detail) : 'started'}${lane.expectation ? ` (${lane.expectation})` : ''}`);
109
+ }
110
+ }
111
+ update(id, detail) {
112
+ const lane = this.lanesById.get(id);
113
+ if (!lane || this.stopped || lane.status !== 'running')
114
+ return;
115
+ lane.detail = detail;
116
+ if (this.tty)
117
+ return; // interval repaints
118
+ const last = this.lastFallbackPrint.get(id) ?? 0;
119
+ if (this.now() - last < this.fallbackIntervalMs)
120
+ return;
121
+ this.lastFallbackPrint.set(id, this.now());
122
+ const elapsed = formatDuration(Math.max(0, Math.round((this.now() - lane.startedAt) / 1000)));
123
+ this.writeLine(` ${lane.title}: ${oneLine(detail)} (${elapsed} elapsed)`);
124
+ }
125
+ complete(id, note) {
126
+ this.finish(id, 'done', note);
127
+ }
128
+ warn(id, note) {
129
+ this.finish(id, 'warning', note);
130
+ }
131
+ fail(id, note) {
132
+ this.finish(id, 'failed', note);
133
+ }
134
+ /** Freeze the final frame (TTY) and release the repaint timer. Idempotent. */
135
+ stop() {
136
+ if (this.stopped)
137
+ return;
138
+ this.stopped = true;
139
+ if (this.timer) {
140
+ clearInterval(this.timer);
141
+ this.timer = undefined;
142
+ }
143
+ if (this.tty && this.laneList.length > 0)
144
+ this.render();
145
+ }
146
+ /**
147
+ * Terminal transitions are refinable: finishing an already-terminal lane
148
+ * updates its glyph/note (keeping the first finish time for elapsed), so a
149
+ * lane frozen early (e.g. "analysis finished on Moda; finalizing after
150
+ * setup") can be corrected once the final outcome is known. Exact duplicate
151
+ * finishes stay no-ops; `update()` still applies to running lanes only.
152
+ */
153
+ finish(id, status, note) {
154
+ const lane = this.lanesById.get(id);
155
+ if (!lane || this.stopped)
156
+ return;
157
+ if (lane.status !== 'running' && lane.status === status && lane.detail === note)
158
+ return;
159
+ lane.status = status;
160
+ lane.detail = note;
161
+ lane.finishedAt ??= this.now();
162
+ if (this.tty) {
163
+ this.render();
164
+ return;
165
+ }
166
+ const label = status === 'done' ? 'done' : status === 'warning' ? 'warning' : 'failed';
167
+ this.writeLine(` ${lane.title}: ${label}${note ? ` (${oneLine(note)})` : ''}`);
168
+ }
169
+ ensureTimer() {
170
+ if (this.timer || !this.tty)
171
+ return;
172
+ this.timer = setInterval(() => this.render(), this.renderIntervalMs);
173
+ // Never keep the process alive just to animate a spinner.
174
+ this.timer.unref?.();
175
+ }
176
+ render() {
177
+ if (!this.tty || this.laneList.length === 0)
178
+ return;
179
+ const lines = renderProgressBoardFrame(this.laneList, {
180
+ now: this.now(),
181
+ spinnerFrame: SPINNER_FRAMES[this.spinnerIndex++ % SPINNER_FRAMES.length],
182
+ color: this.color,
183
+ width: this.stream.columns ?? 120,
184
+ });
185
+ const up = this.renderedLines > 0 ? `\u001b[${this.renderedLines}A` : '';
186
+ this.stream.write(`${up}${lines.map((line) => `\u001b[2K${line}`).join('\n')}\n`);
187
+ this.renderedLines = lines.length;
188
+ }
189
+ writeLine(line) {
190
+ this.stream.write(`${line}\n`);
191
+ }
192
+ }
193
+ /**
194
+ * One-line convention shared with ask-stream tool cards: strip escapes,
195
+ * collapse whitespace, cap ~100 chars. Lane details carry backend/agent
196
+ * controlled text (hosted progress messages, tool inputs like file paths and
197
+ * shell commands), so ANSI/OSC escapes and remaining C0 controls must be
198
+ * removed before the text reaches the terminal; an embedded escape would
199
+ * otherwise corrupt the in-place cursor-up rewrite or inject control output.
200
+ */
201
+ function oneLine(text) {
202
+ const collapsed = stripAnsi(text)
203
+ .replace(/[\u0000-\u001f\u007f]+/g, ' ')
204
+ .replace(/\s+/g, ' ')
205
+ .trim();
206
+ return collapsed.length > DETAIL_MAX ? `${collapsed.slice(0, DETAIL_MAX - 1)}…` : collapsed;
207
+ }
208
+ /** Same shape as analyst-tui.ts formatDuration. */
209
+ function formatDuration(seconds) {
210
+ if (seconds < 60)
211
+ return `${seconds}s`;
212
+ const minutes = Math.floor(seconds / 60);
213
+ const remaining = seconds % 60;
214
+ return `${minutes}m ${remaining}s`;
215
+ }
216
+ function truncateVisible(value, width) {
217
+ if (visibleLength(value) <= width)
218
+ return value;
219
+ const stripped = stripAnsi(value);
220
+ return `${stripped.slice(0, Math.max(0, width - 3))}...`;
221
+ }
222
+ function visibleLength(value) {
223
+ return stripAnsi(value).length;
224
+ }
225
+ //# sourceMappingURL=progress-board.js.map
@@ -1,4 +1,5 @@
1
- import { type CodingAgentAdapter } from '../harness.js';
1
+ import { type CodingAgentAdapter, type CodingAgentEvent } from '../harness.js';
2
+ export { describeCodingAgentEvent } from '../harness.js';
2
3
  import { type SdkGuideContext } from './sdk-guide.js';
3
4
  export type SdkIntegrationResult = {
4
5
  status: 'integrated';
@@ -18,6 +19,13 @@ export interface SdkIntegrationOptions {
18
19
  /** Stream the agent's progress to the user (human terminals only). */
19
20
  human?: boolean;
20
21
  onProgress?: (message: string) => void;
22
+ /**
23
+ * Receive the structured coding-agent stream events incrementally (tool use,
24
+ * commands, agent text) so callers like the init progress board can render
25
+ * their own live view. Providing it enables the streaming invocation even
26
+ * when `human` is false.
27
+ */
28
+ onEvent?: (event: CodingAgentEvent) => void;
21
29
  }
22
30
  /**
23
31
  * Pick an installed coding-agent CLI and run it, in write mode, to integrate
@@ -8,7 +8,11 @@
8
8
  // stack, materialize it into .claude/skills/<id>/, and hand the agent a thin
9
9
  // prompt that points at the skill file — the prompt no longer inlines API
10
10
  // documentation.
11
- import { codingAgentDisplayName, detectAvailableCodingAgents, runCodingAgent, } from '../harness.js';
11
+ import { codingAgentDisplayName, describeCodingAgentEvent, detectAvailableCodingAgents, runCodingAgent, } from '../harness.js';
12
+ // Re-exported for callers/tests that treat this module as the card-rendering
13
+ // surface; the implementation lives in harness.ts next to the event parser so
14
+ // the agent-stream analyst path can reuse it without an import cycle.
15
+ export { describeCodingAgentEvent } from '../harness.js';
12
16
  import { installIntegrationSkill, readBundledSkillMd } from '../skills-bundled.js';
13
17
  import { detectSdkGuideContext, renderDetectionBlock, renderSdkIntegrationGuide, stripSkillFrontmatter, } from './sdk-guide.js';
14
18
  import { resolveIntegrationSkillId } from './skill-resolver.js';
@@ -70,9 +74,15 @@ export async function runSdkIntegration(options = {}) {
70
74
  prompt,
71
75
  rootDir: cwd,
72
76
  mode: 'write',
73
- stream: human,
77
+ stream: human || Boolean(options.onEvent),
74
78
  progressToStderr: false,
75
79
  onProgress: (message) => emit(message),
80
+ onAgentEvent: (event) => {
81
+ options.onEvent?.(event);
82
+ const card = describeCodingAgentEvent(event);
83
+ if (card)
84
+ emit(card);
85
+ },
76
86
  });
77
87
  if (run.failedToStart) {
78
88
  return { status: 'failed', adapter, detail: `${codingAgentDisplayName(adapter)} CLI failed to start.` };
package/dist/manifest.js CHANGED
@@ -76,6 +76,7 @@ export function buildManifest(commands) {
76
76
  { term: 'profile', meaning: 'A named local CLI configuration under ~/.moda that selects environment, tenant, and secret references.' },
77
77
  { term: 'harness', meaning: 'Moda’s local graph of runtime agents, prompts, tools, identities, and deployments in a codebase.' },
78
78
  { term: 'harness report', meaning: 'A cited analyst artifact under .moda/ that justifies the harness graph before sync.' },
79
+ { term: 'remote analyze run', meaning: 'A Moda-hosted harness analysis tracked in .moda/harness-remote-run.json; fetch its status or result with `moda harness pull`.' },
79
80
  { term: 'conversation', meaning: 'A user-agent interaction thread stored in Moda analytics.' },
80
81
  { term: 'trace', meaning: 'Raw OTLP span evidence for one run or conversation, exposed by `moda audit`.' },
81
82
  { term: 'ingest key', meaning: 'A `moda_sk_` API key for SDKs, CI, and Data API calls. Treat it as a secret.' },
@@ -0,0 +1,10 @@
1
+ import type { CommandContext } from './command-context.js';
2
+ import type { ProfileIOOptions } from './profiles.js';
3
+ /**
4
+ * `moda prompts propose` — turn a `prompts ab` run's failures into a revised
5
+ * candidate registered as a new (unlabeled) prompt version. Does NOT promote:
6
+ * the operator gates the candidate with a fresh `prompts ab` (ideally on the
7
+ * printed holdout cases the revision never saw) and promotes only if it wins.
8
+ */
9
+ export declare function runPromptPropose(positionals: string[], flags: Record<string, string>, profileOptions: ProfileIOOptions, context: CommandContext): Promise<void>;
10
+ //# sourceMappingURL=prompts-propose.d.ts.map
@@ -0,0 +1,76 @@
1
+ import { writeFileSync } from 'node:fs';
2
+ import { isAbsolute, resolve } from 'node:path';
3
+ import { callControlAPI } from './api-client.js';
4
+ import { validateConfig } from './config.js';
5
+ // Propose is a single OpenRouter revision behind the backend; give it room.
6
+ const PROPOSE_TIMEOUT_MS = 300_000;
7
+ /**
8
+ * `moda prompts propose` — turn a `prompts ab` run's failures into a revised
9
+ * candidate registered as a new (unlabeled) prompt version. Does NOT promote:
10
+ * the operator gates the candidate with a fresh `prompts ab` (ideally on the
11
+ * printed holdout cases the revision never saw) and promotes only if it wins.
12
+ */
13
+ export async function runPromptPropose(positionals, flags, profileOptions, context) {
14
+ validateConfig();
15
+ const promptKey = flags['prompt-key'] || positionals[1];
16
+ const fromRunId = flags['from-run'] || flags['from-run-id'] || flags['run-id'];
17
+ const replaySetId = flags['set-id'] || flags['replay-set-id'];
18
+ if (!promptKey || !fromRunId || !replaySetId) {
19
+ throw new Error('Usage: moda prompts propose --prompt-key=<key> --from-run=<run_id> --set-id=<replay_set_id> [--max-dossiers=8] [--out=path.prompt.md]');
20
+ }
21
+ const maxDossiers = parsePositiveInt(flags['max-dossiers'], 8, 16);
22
+ const result = (await callControlAPI(`/prompts/${encodeURIComponent(promptKey)}/propose`, { method: 'POST', body: JSON.stringify({ fromRunId, replaySetId, maxDossiers }) }, profileOptions, { timeoutMs: PROPOSE_TIMEOUT_MS, retries: 0 }));
23
+ let outPath;
24
+ if (result.status === 'proposed' && result.content && flags.out) {
25
+ outPath = isAbsolute(flags.out) ? flags.out : resolve(process.cwd(), flags.out);
26
+ writeFileSync(outPath, result.content, 'utf8');
27
+ }
28
+ if (context.outputMode === 'human') {
29
+ printHuman(result, outPath);
30
+ }
31
+ context.output.writeData(outPath ? { ...result, outPath } : result);
32
+ }
33
+ function printHuman(result, outPath) {
34
+ const w = (s) => process.stderr.write(s + '\n');
35
+ if (result.status !== 'proposed') {
36
+ w(`No candidate produced (${result.status}): ${result.note}`);
37
+ return;
38
+ }
39
+ w(`Proposed candidate for '${result.promptKey}'`);
40
+ w(` new version: ${result.versionId} (unlabeled — not promoted)`);
41
+ w(` base version: ${result.baseVersionId ?? '(none)'}`);
42
+ w(` from run: built from ${result.dossierCount} failure dossier(s)`);
43
+ if (result.changelog)
44
+ w(` changelog: ${result.changelog}`);
45
+ if (result.risks.length)
46
+ w(` risks: ${result.risks.map((r) => `- ${r}`).join('\n ')}`);
47
+ if (outPath)
48
+ w(` written to: ${outPath}`);
49
+ w('');
50
+ w('Next — gate on holdout cases the revision never saw, then promote if it wins:');
51
+ if (result.holdoutCaseIds.length) {
52
+ w(` holdout cases (${result.holdoutCaseIds.length}): ${result.holdoutCaseIds.join(', ')}`);
53
+ }
54
+ else {
55
+ w(' (too few failures to reserve a holdout — gate on a fresh --auto-generate set)');
56
+ }
57
+ if (outPath) {
58
+ // The candidate lives only in the registry as an unlabeled version; `prompts ab`
59
+ // resolves --candidate from local file content, so point it at the written --out
60
+ // file. Using --candidate-key=<key> would resolve to the baseline and compare the
61
+ // prompt against itself.
62
+ w(` moda prompts ab --baseline-key=${result.promptKey} --candidate=${outPath} --auto-generate`);
63
+ }
64
+ else {
65
+ w(` # re-run with --out=<path.prompt.md> to materialize the candidate, then:`);
66
+ w(` moda prompts ab --baseline-key=${result.promptKey} --candidate=<path.prompt.md> --auto-generate`);
67
+ }
68
+ w(` moda prompts promote ${result.promptKey} --label=prod --version=${result.versionId}`);
69
+ }
70
+ function parsePositiveInt(raw, fallback, max) {
71
+ const n = Number.parseInt(raw ?? '', 10);
72
+ if (!Number.isFinite(n) || n <= 0)
73
+ return fallback;
74
+ return Math.min(n, max);
75
+ }
76
+ //# sourceMappingURL=prompts-propose.js.map
package/dist/prompts.js CHANGED
@@ -5,6 +5,7 @@ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
5
5
  import { callControlAPI } from './api-client.js';
6
6
  import { createCommandContext } from './command-context.js';
7
7
  import { runPromptAb } from './prompts-ab.js';
8
+ import { runPromptPropose } from './prompts-propose.js';
8
9
  const MODA_DIR = '.moda';
9
10
  const MANIFEST_PATH = join(MODA_DIR, 'prompts.yml');
10
11
  const LOCK_PATH = join(MODA_DIR, 'prompts.lock.json');
@@ -55,8 +56,11 @@ export async function runPromptsCommand(input, legacyFlags, legacyProfileOptions
55
56
  case 'ab':
56
57
  await runPromptAb(flags, profileOptions, context);
57
58
  break;
59
+ case 'propose':
60
+ await runPromptPropose(positionals, flags, profileOptions, context);
61
+ break;
58
62
  default:
59
- throw new Error(`Unknown prompts command '${subcommand}'. Use init, status, diff, sync, promote, or ab.`);
63
+ throw new Error(`Unknown prompts command '${subcommand}'. Use init, status, diff, sync, promote, ab, or propose.`);
60
64
  }
61
65
  }
62
66
  export async function initPrompts(options = {}) {
package/dist/tui.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { DoctorCheckStatus, DoctorReport, StatusPayload } from './doctor.js';
2
- import type { InitPhaseResult, InitSetupAction } from './init/index.js';
2
+ import type { InitSetupAction } from './init/index.js';
3
3
  type TerminalBadge = DoctorCheckStatus | 'info' | 'run';
4
4
  export declare function renderDoctorHuman(report: DoctorReport, options?: {
5
5
  color?: boolean;
@@ -11,13 +11,21 @@ export declare function renderStatusHuman(status: StatusPayload, options?: {
11
11
  }): string;
12
12
  export declare function renderInitBanner(): string;
13
13
  export declare function renderInitStep(index: number, total: number, title: string, detail?: string): string;
14
- export declare function renderInitNote(message: string, status?: DoctorCheckStatus): string;
15
- export declare function renderInitSetupPlan(actions: InitSetupAction[]): string;
14
+ export declare function renderInitNote(message: string, status?: DoctorCheckStatus | 'info'): string;
15
+ export declare function renderInitSetupPlan(actions: InitSetupAction[], options?: {
16
+ snapshotConsent?: boolean;
17
+ }): string;
16
18
  export declare function renderInitComplete(options: {
17
19
  tenantName: string;
18
20
  filesChanged: string[];
19
21
  skipped: InitSetupAction[];
20
- phases: InitPhaseResult[];
22
+ dashboardUrl?: string;
23
+ harness?: {
24
+ state: 'mapped' | 'pending';
25
+ agents?: number;
26
+ artifacts?: number;
27
+ };
28
+ telemetrySeen?: boolean;
21
29
  }): string;
22
30
  export declare function renderHarnessProgress(message: string, status?: TerminalBadge): string;
23
31
  export declare function renderHarnessAnalystPreflight(options: {
package/dist/tui.js CHANGED
@@ -102,7 +102,7 @@ export function renderInitNote(message, status = 'pass') {
102
102
  const ui = createTerminalUi();
103
103
  return ` ${ui.status(status)} ${message}`;
104
104
  }
105
- export function renderInitSetupPlan(actions) {
105
+ export function renderInitSetupPlan(actions, options = {}) {
106
106
  const ui = createTerminalUi();
107
107
  const lines = actions.map((action) => {
108
108
  const status = action.approved
@@ -113,17 +113,25 @@ export function renderInitSetupPlan(actions) {
113
113
  const suffix = action.reason ? ` - ${action.reason}` : '';
114
114
  return ui.wrap(` ${ui.status(status)} ${action.title}${suffix}`, 4);
115
115
  });
116
+ if (options.snapshotConsent) {
117
+ lines.push('');
118
+ lines.push(ui.wrap(` ${ui.status('info')} Moda will analyze a snapshot of this repo on Moda's servers to map your agent harness. Secrets and .env files are excluded and the snapshot is deleted after the run.`, 4));
119
+ }
116
120
  return lines.join('\n');
117
121
  }
118
122
  export function renderInitComplete(options) {
119
123
  const ui = createTerminalUi();
124
+ const dashboardUrl = options.dashboardUrl ?? 'https://moda.dev';
120
125
  const lines = [];
121
126
  lines.push('');
122
- lines.push(ui.title(`Setup complete for ${options.tenantName}`));
127
+ lines.push(ui.title(`Workspace ready: ${options.tenantName}`));
128
+ lines.push(`Dashboard: ${ui.command(dashboardUrl)}`);
123
129
  lines.push('');
124
- lines.push(ui.section('Phases'));
125
- for (const phase of options.phases) {
126
- lines.push(` ${ui.status(phase.status === 'warning' ? 'warning' : phase.status === 'skipped' ? 'skipped' : 'pass')} ${phase.title}${phase.details ? ` - ${phase.details}` : ''}`);
130
+ if (options.harness?.state === 'pending') {
131
+ lines.push(ui.wrap(` ${ui.status('run')} Harness analysis is still running on Moda. Run ${ui.command('moda harness pull --sync')} in a few minutes.`, 4));
132
+ }
133
+ else if (options.harness) {
134
+ lines.push(ui.wrap(` ${ui.status('pass')} Harness mapped: ${options.harness.agents ?? 0} agent(s), ${options.harness.artifacts ?? 0} artifact(s). View: ${ui.command(dashboardUrl)}`, 4));
127
135
  }
128
136
  lines.push('');
129
137
  lines.push(ui.section('Files changed'));
@@ -142,16 +150,14 @@ export function renderInitComplete(options) {
142
150
  }
143
151
  }
144
152
  lines.push('');
145
- lines.push(ui.section('Next commands'));
146
- lines.push(` ${ui.command('moda status')}`);
147
- lines.push(` ${ui.command('moda doctor')}`);
148
- lines.push(` ${ui.command('moda harness sync --summary')}`);
149
- lines.push('');
150
- lines.push(ui.section('Agent handoff'));
151
- lines.push(' Integrate the Moda SDK into this project using .moda/SDK_INTEGRATION.md');
152
- lines.push(' and .moda/HARNESS.md. Package installs and source edits stay manual.');
153
- lines.push('');
154
- lines.push('Dashboard: https://moda.dev');
153
+ lines.push(ui.section('Next steps'));
154
+ if (options.telemetrySeen === true) {
155
+ lines.push(` Telemetry is flowing. Explore it: ${ui.command('moda overview')}`);
156
+ }
157
+ else {
158
+ lines.push(` Run your app to send telemetry, then: ${ui.command('moda doctor --online')}`);
159
+ }
160
+ lines.push(` Ask about your agents: ${ui.command('moda ask "what did my agents do today?"')}`);
155
161
  return lines.join('\n');
156
162
  }
157
163
  export function renderHarnessProgress(message, status = 'run') {
@@ -366,11 +372,12 @@ function createTerminalUi(options = {}) {
366
372
  function wrapLine(text, width, indent) {
367
373
  if (text.length <= width)
368
374
  return text;
369
- const words = text.split(/\s+/);
375
+ const lead = text.match(/^\s*/)?.[0] ?? '';
376
+ const words = text.trimStart().split(/\s+/);
370
377
  const lines = [];
371
378
  let current = '';
372
379
  for (const word of words) {
373
- const next = current ? `${current} ${word}` : word;
380
+ const next = current ? `${current} ${word}` : `${lead}${word}`;
374
381
  if (next.length > width && current) {
375
382
  lines.push(current);
376
383
  current = `${' '.repeat(indent)}${word}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moda-ai/cli",
3
- "version": "1.17.1",
3
+ "version": "1.18.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-08T22:22:27.468Z",
4
- "cli_version": "1.17.1",
3
+ "bundled_at": "2026-07-09T22:31:35.483Z",
4
+ "cli_version": "1.18.0",
5
5
  "skills": [
6
6
  {
7
7
  "id": "integration-node-anthropic",