@moda-ai/cli 1.17.2 → 1.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,13 @@
1
- // src/init/index.ts — Full moda init wizard orchestrator
1
+ // src/init/index.ts. Full moda init wizard orchestrator.
2
+ //
3
+ // Five visible steps: Sign in, Workspace, Plan, Set up, Verify. Every prompt
4
+ // lives in Step 3 (Plan); Step 4 then runs unattended, firing the hosted
5
+ // harness analysis first and overlapping it with the fast local tasks and the
6
+ // SDK integration agent while a live progress board (progress-board.ts)
7
+ // renders each lane.
2
8
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
9
  import { dirname, join } from 'node:path';
4
- import { confirm, select } from '@inquirer/prompts';
10
+ import { checkbox, confirm, select } from '@inquirer/prompts';
5
11
  import { login, loadToken } from '../auth.js';
6
12
  import { selectTenantAndCreateKey } from './tenant.js';
7
13
  import { detectAgents } from './mcp.js';
@@ -10,12 +16,18 @@ import { initPrompts, runPromptSync } from '../prompts.js';
10
16
  import { runSkillSync } from '../skills.js';
11
17
  import { SDK_GUIDE_PATH, writeSdkIntegrationGuide } from './sdk-guide.js';
12
18
  import { runSdkIntegration } from './sdk-integration.js';
19
+ import { InitProgressBoard } from './progress-board.js';
13
20
  import { createCommandContext } from '../command-context.js';
14
- import { VERSION } from '../config.js';
21
+ import { resolveModaBaseUrl, VERSION } from '../config.js';
15
22
  import { CliAuthError, CliInputError } from '../errors.js';
16
- import { isDeterministicLocalAnalyst, runHarnessCommand } from '../harness.js';
23
+ import { detectAvailableCodingAgents, isDeterministicLocalAnalyst, runHarnessCommand, } from '../harness.js';
24
+ import { pollRemoteAnalyzeUntilDone, startRemoteAnalyze, } from '../harness-remote.js';
17
25
  import { buildDoctorReport } from '../doctor.js';
18
26
  import { renderInitBanner, renderInitComplete, renderInitNote, renderInitSetupPlan, renderInitStep, } from '../tui.js';
27
+ /** Recovery command printed whenever init detaches from a hosted analysis run. */
28
+ const HARNESS_PULL_RECOVERY = 'moda harness pull --sync';
29
+ /** Minimum gap between board-driven progress events per lane in machine modes. */
30
+ const LANE_EVENT_THROTTLE_MS = 2_000;
19
31
  export async function runInit(flags, options = {}) {
20
32
  const explicitYes = flags.yes === true ||
21
33
  flags.yes === 'true' ||
@@ -37,17 +49,29 @@ export async function runInit(flags, options = {}) {
37
49
  const emit = (phase, message, status) => {
38
50
  options.onEvent?.({ phase, message, status });
39
51
  };
52
+ // Board-driven lane updates arrive as fast as the underlying stream; machine
53
+ // modes get them as progress events throttled to one per lane per 2s so
54
+ // agent NDJSON stays readable (the human board repaints at 150ms instead).
55
+ const laneEventAt = new Map();
56
+ const emitLaneProgress = (phase, laneId, message) => {
57
+ if (!options.onEvent || human)
58
+ return;
59
+ const now = Date.now();
60
+ if (now - (laneEventAt.get(laneId) ?? 0) < LANE_EVENT_THROTTLE_MS)
61
+ return;
62
+ laneEventAt.set(laneId, now);
63
+ emit(phase, message);
64
+ };
40
65
  log(renderInitBanner());
41
- log(renderInitStep(1, 7, 'Welcome', 'No environment variable files are written. File changes are listed before completion.'));
42
66
  emit('welcome', 'Onboarding this project as a Moda agent harness.', 'completed');
43
67
  phases.push({ id: 'welcome', title: 'Welcome', status: 'completed' });
44
- // Step 2: Authentication
68
+ // Step 1: Sign in.
45
69
  // Every auth touchpoint below (cached-token check, browser login, tenant
46
70
  // setup) must share the same --profile context, or `moda init --profile X`
47
71
  // logs in / stores under the default profile while the rest of init reads
48
- // from X — the session then looks unauthenticated or uses the wrong key.
72
+ // from X, and the session then looks unauthenticated or uses the wrong key.
49
73
  const profile = stringFlag(flags.profile);
50
- log(renderInitStep(2, 7, 'Authentication'));
74
+ log(renderInitStep(1, 5, 'Sign in', 'No environment variable files are written. File changes are listed before completion.'));
51
75
  let token = loadToken({ profile });
52
76
  if (!token) {
53
77
  if (json || agent) {
@@ -76,9 +100,9 @@ export async function runInit(flags, options = {}) {
76
100
  log(renderInitNote('Already authenticated (cached token)'));
77
101
  }
78
102
  emit('auth', 'Authentication complete.', 'completed');
79
- phases.push({ id: 'auth', title: 'Authentication', status: 'completed' });
80
- // Step 3: Tenant + API Key
81
- log(renderInitStep(3, 7, 'Workspace & Profile'));
103
+ phases.push({ id: 'auth', title: 'Sign in', status: 'completed' });
104
+ // Step 2: Workspace (tenant + API key).
105
+ log(renderInitStep(2, 5, 'Workspace'));
82
106
  emit('workspace', 'Selecting workspace and creating API key.');
83
107
  // --tenant-id flag wins over MODA_TENANT_ID; the source drives the
84
108
  // env-shadow warning so we don't warn when the env var was the selection.
@@ -86,7 +110,7 @@ export async function runInit(flags, options = {}) {
86
110
  // Normalize the env var the same way as the flag: an empty/whitespace
87
111
  // MODA_TENANT_ID (e.g. `export MODA_TENANT_ID=`, or a harness that blanks it
88
112
  // to clear an inherited value) means "unset", not an explicit empty-id
89
- // selection — otherwise the exact-id match below fails with
113
+ // selection; otherwise the exact-id match below fails with
90
114
  // "No organization with id ''" instead of auto-selecting a sole org.
91
115
  const tenantIdEnv = stringFlag(process.env.MODA_TENANT_ID);
92
116
  const { apiKey, tenantName } = await selectTenantAndCreateKey({
@@ -99,211 +123,431 @@ export async function runInit(flags, options = {}) {
99
123
  });
100
124
  log(renderInitNote(`Active workspace: ${tenantName}`));
101
125
  emit('workspace', `Active workspace: ${tenantName}`, 'completed');
102
- phases.push({ id: 'workspace', title: 'Workspace & Profile', status: 'completed', details: tenantName });
103
- // Step 4: Harness analyst report. Onboarding runs the analyst SERVER-SIDE by
104
- // default (Moda uploads a safe source snapshot and runs the agent on its own
105
- // infra) so the customer needs no local coding agent. Opt out with `--local`
106
- // (or `--remote=false`) to run a local coding-agent analyst instead. If the
107
- // remote run fails (service/network), we fall back to the local analyst so
108
- // onboarding never hard-fails.
109
- const remote = resolveHarnessRemote(flags);
126
+ phases.push({ id: 'workspace', title: 'Workspace', status: 'completed', details: tenantName });
127
+ // Step 3: Plan. Every remaining decision lives in this single prompt beat;
128
+ // Step 4 then runs unattended with zero prompts. Explicit flags keep
129
+ // overriding exactly as before, and --yes/--json/--agent never prompt
130
+ // (optional items default off, snapshot consent implied).
131
+ log(renderInitStep(3, 5, 'Plan', 'One confirmation covers everything below; setup then runs unattended.'));
132
+ let remote = resolveHarnessRemote(flags);
133
+ const remotePinned = hasExplicitRemoteChoice(flags);
134
+ const sdkFlag = resolveSdkIntegrateFlag(flags);
135
+ const ciCdFlag = resolveCiCdFlag(flags);
136
+ const autosyncFlag = resolveAutosyncFlag(flags);
137
+ // SDK integration installs packages and edits source via a coding agent, so
138
+ // it defaults ON only when an agent CLI is installed to do the work; it
139
+ // never runs unattended without an explicit --sdk-integrate.
140
+ const codingAgentAvailable = detectAvailableCodingAgents().length > 0;
141
+ let integrateSdk = sdkFlag !== undefined ? sdkFlag : yes ? false : codingAgentAvailable;
142
+ let configureCiCd = ciCdFlag ?? false;
143
+ let configureAutosync = autosyncFlag ?? false;
144
+ let setupPlan = [];
145
+ const renderPlan = () => {
146
+ setupPlan = buildSetupPlan({
147
+ installHooks: false,
148
+ integrateSdk,
149
+ configureCiCd,
150
+ configureAutosync,
151
+ remote,
152
+ });
153
+ log(renderInitSetupPlan(setupPlan, { snapshotConsent: remote }));
154
+ };
155
+ renderPlan();
156
+ if (!yes) {
157
+ const proceed = await confirm({ message: 'Proceed with this plan?', default: true });
158
+ if (!proceed) {
159
+ const choices = [];
160
+ if (sdkFlag === undefined) {
161
+ choices.push({
162
+ name: 'SDK integration via your coding agent (installs packages, edits source)',
163
+ value: 'sdk',
164
+ checked: integrateSdk,
165
+ });
166
+ }
167
+ if (!remotePinned) {
168
+ choices.push({
169
+ name: "Harness analysis on Moda's servers (uncheck to run a local coding agent analyst)",
170
+ value: 'remote',
171
+ checked: remote,
172
+ });
173
+ }
174
+ if (ciCdFlag === undefined) {
175
+ choices.push({
176
+ name: 'CI/CD validation workflow (.github/workflows/moda-validation.yml)',
177
+ value: 'ci-cd',
178
+ checked: configureCiCd,
179
+ });
180
+ }
181
+ if (autosyncFlag === undefined) {
182
+ choices.push({
183
+ name: 'Autosync prompts and skills on push to main (.github/workflows/moda-autosync.yml)',
184
+ value: 'autosync',
185
+ checked: configureAutosync,
186
+ });
187
+ }
188
+ if (choices.length > 0) {
189
+ const selected = await checkbox({
190
+ message: 'Choose what runs (space toggles, enter confirms)',
191
+ choices,
192
+ });
193
+ if (sdkFlag === undefined)
194
+ integrateSdk = selected.includes('sdk');
195
+ if (!remotePinned)
196
+ remote = selected.includes('remote');
197
+ if (ciCdFlag === undefined)
198
+ configureCiCd = selected.includes('ci-cd');
199
+ if (autosyncFlag === undefined)
200
+ configureAutosync = selected.includes('autosync');
201
+ renderPlan();
202
+ }
203
+ }
204
+ }
205
+ if (json || agent) {
206
+ const skipped = setupPlan
207
+ .filter((action) => (action.category === 'sdk' || action.category === 'ci-cd' || action.category === 'autosync') &&
208
+ action.approved === false)
209
+ .map((action) => `${action.category}: ${action.command ?? 'explicit flag required'}`);
210
+ if (skipped.length > 0) {
211
+ emit('plan', `Non-interactive init skipped optional actions (${skipped.join('; ')}).`, 'warning');
212
+ }
213
+ }
214
+ emit('plan', 'Setup plan ready.', 'completed');
215
+ phases.push({ id: 'plan', title: 'Plan', status: 'completed' });
216
+ // Step 4: Set up. Zero prompts from here on. The hosted harness analysis is
217
+ // fired FIRST and polled concurrently while the fast local tasks and the SDK
218
+ // integration agent run; a live progress board renders each lane in human
219
+ // mode. Local-analyst mode stays sequential (AnalystTui provides its own
220
+ // live progress).
221
+ log(renderInitStep(4, 5, 'Set up', remote
222
+ ? 'Harness analysis runs on Moda while local setup continues. No further input is needed.'
223
+ : 'Runs unattended. No further input is needed.'));
224
+ const rootDir = process.cwd();
225
+ // An explicit --remote pin means "never fall back to a local coding agent".
226
+ const allowLocalFallback = !isTruthyFlag(flagValue(flags, 'remote'));
227
+ const nestedHarnessFlags = (extra) => commandFlags({
228
+ summary: true,
229
+ // The Step 3 plan confirm covers the broad local read and (for remote)
230
+ // the snapshot upload, so nested harness commands must never re-prompt.
231
+ yes: true,
232
+ json: json || agent,
233
+ agent,
234
+ quiet: true,
235
+ 'no-telemetry': true,
236
+ profile,
237
+ ...extra,
238
+ });
110
239
  const runAnalyze = (useRemote) => runHarnessCommand(createCommandContext({
111
240
  command: 'harness',
112
241
  positional: 'analyze',
113
242
  positionals: ['analyze'],
114
- flags: commandFlags({
115
- summary: true,
116
- // In machine mode the nested harness scan must not emit its own
117
- // `needs_input` with a `moda harness analyze --yes` resume command — an
118
- // orchestrator obeying it would resume the scan and silently skip the
119
- // rest of init. The broad local read is part of the init flow the caller
120
- // already opted into, so auto-approve it here; human init still confirms.
121
- yes: yes || json || agent,
122
- json: json || agent,
123
- agent,
243
+ flags: nestedHarnessFlags({
124
244
  analyst: stringFlag(flags.analyst) ?? 'auto',
125
245
  // Pass the remote/local decision EXPLICITLY. `commandFlags` drops
126
246
  // `remote: false`, so a bare `remote: useRemote` would leave the child
127
247
  // with no signal and it would re-derive remoteness from
128
- // `MODA_HARNESS_REMOTE` — silently overriding `--local` and the
129
- // remote-failure fallback below. `--no-remote` survives (it's `true`)
130
- // and takes precedence over the env in `isRemoteHarnessAnalyze`.
248
+ // `MODA_HARNESS_REMOTE`, silently overriding `--local` and the
249
+ // remote-failure fallback. `--no-remote` survives (it's `true`).
131
250
  remote: useRemote ? true : undefined,
132
251
  'no-remote': useRemote ? undefined : true,
133
- quiet: true,
134
252
  progress: true,
135
- 'no-telemetry': true,
136
253
  }),
137
254
  }));
138
- log(renderInitStep(4, 7, 'Harness Discovery', remote
139
- ? 'Moda runs the harness analyst server-side and writes cited local artifacts.'
140
- : 'Moda Analyst investigates the harness and writes cited local artifacts.'));
141
- emit('harness', remote
142
- ? 'Running Moda-hosted harness discovery.'
143
- : 'Running Moda Analyst harness discovery.');
144
- let harnessResult;
145
- try {
146
- harnessResult = await runAnalyze(remote);
147
- }
148
- catch (error) {
149
- // A remote failure (service down, upload error) must not sink onboarding —
150
- // fall back to the local analyst path. If the caller pinned `--remote`
151
- // explicitly, respect that and surface the failure.
152
- if (remote && !isTruthyFlag(flagValue(flags, 'remote'))) {
255
+ let board;
256
+ const startBoard = () => {
257
+ if (!human)
258
+ return undefined;
259
+ board ??= new InitProgressBoard();
260
+ return board;
261
+ };
262
+ const validationFailure = () => new CliInputError('Moda Analyst produced a harness report that did not pass validation.', 'Review .moda/harness-report-validation.json, then rerun `moda init --local --analyst=claude|cursor|codex` or `moda harness analyze --analyst=local-scan --summary`.');
263
+ let harnessPhase = { id: 'harness', title: 'Harness discovery', status: 'completed' };
264
+ let harnessDeferred = false;
265
+ // Upload the cited harness graph so Moda's backend has the understanding,
266
+ // not just local artifacts. `harness sync --from-report` refuses an
267
+ // unapproved report, so approve it first. Best-effort: a network blip on
268
+ // either call must not abort onboarding.
269
+ const approveAndSyncHarness = async () => {
270
+ try {
271
+ await runHarnessCommand(createCommandContext({
272
+ command: 'harness',
273
+ positional: 'approve',
274
+ positionals: ['approve'],
275
+ flags: nestedHarnessFlags({}),
276
+ }));
277
+ await runHarnessCommand(createCommandContext({
278
+ command: 'harness',
279
+ positional: 'sync',
280
+ positionals: ['sync'],
281
+ flags: nestedHarnessFlags({ 'from-report': true }),
282
+ }));
283
+ log(renderInitNote('Synced harness graph to Moda'));
284
+ emit('harness', 'Harness graph synced to Moda.', 'completed');
285
+ }
286
+ catch (err) {
287
+ const message = err instanceof Error ? err.message : String(err);
288
+ warn(renderInitNote(`Harness sync skipped: ${message}`, 'warning'));
289
+ warn(' Run `moda harness approve --yes && moda harness sync --from-report` later to upload.');
290
+ emit('harness', 'Harness sync skipped.', 'warning');
291
+ harnessPhase = { id: 'harness', title: 'Harness discovery', status: 'warning', details: 'sync skipped' };
292
+ }
293
+ };
294
+ const finishLocalAnalyze = async () => {
295
+ emit('harness', 'Running Moda Analyst harness discovery.');
296
+ const result = await runAnalyze(false);
297
+ if (result.exitCode !== 0)
298
+ throw validationFailure();
299
+ log(renderInitNote('Wrote or verified .moda/harness-report.json, .moda/harness.json, and .moda/HARNESS.md'));
300
+ if (human)
301
+ printHarnessDetectionSummary();
302
+ emit('harness', 'Harness discovery complete.', 'completed');
303
+ await approveAndSyncHarness();
304
+ };
305
+ // Fire the hosted analysis first so it overlaps everything below.
306
+ let pollPromise;
307
+ let pollError;
308
+ let remoteRunId;
309
+ // Covers the whole overlapped window (fast tasks, SDK agent, final await):
310
+ // a Ctrl-C leaves the hosted run resumable and prints the recovery command.
311
+ const onRemoteSigint = () => {
312
+ board?.stop();
313
+ process.stderr.write(`\nHarness analysis keeps running on Moda. Resume anytime with \`${HARNESS_PULL_RECOVERY}\`.\n`);
314
+ process.exit(130);
315
+ };
316
+ if (remote) {
317
+ emit('harness', 'Running Moda-hosted harness discovery.');
318
+ try {
319
+ const harnessContext = createCommandContext({
320
+ command: 'harness',
321
+ positional: 'analyze',
322
+ positionals: ['analyze'],
323
+ flags: nestedHarnessFlags({
324
+ analyst: stringFlag(flags.analyst) ?? 'auto',
325
+ remote: true,
326
+ }),
327
+ });
328
+ const started = await startRemoteAnalyze({ context: harnessContext, rootDir });
329
+ remoteRunId = started.runId;
330
+ // From this point Ctrl-C is safe and resumable: the state file already
331
+ // says `running`, so exiting early just detaches from the hosted run.
332
+ process.once('SIGINT', onRemoteSigint);
333
+ emit('harness', `Moda accepted harness analysis run ${started.runId}.`);
334
+ startBoard()?.startLane('harness', 'Harness analysis on Moda', {
335
+ detail: 'waiting for the hosted analyst',
336
+ expectation: 'usually 2-4 min',
337
+ });
338
+ pollPromise = pollRemoteAnalyzeUntilDone({
339
+ context: harnessContext,
340
+ runId: started.runId,
341
+ onProgress: ({ status, progress }) => {
342
+ if (status === 'completed' || status === 'failed') {
343
+ // The hosted run just turned terminal, but the lane is only
344
+ // finalized after the SDK lane below. Freeze it now so the board
345
+ // does not keep a live spinner and climbing elapsed for minutes
346
+ // after the analysis actually finished; the post-setup pull
347
+ // refines the note (the board allows terminal refinements).
348
+ if (status === 'completed') {
349
+ board?.complete('harness', 'analysis finished on Moda; finalizing after setup');
350
+ }
351
+ else {
352
+ board?.warn('harness', 'hosted run finished with issues; finalizing after setup');
353
+ }
354
+ emitLaneProgress('harness', 'harness', `Hosted analysis ${status}; finalizing after setup.`);
355
+ return;
356
+ }
357
+ const message = progress?.message
358
+ ?? (typeof progress?.tool_calls === 'number'
359
+ ? `${progress.tool_calls} tool call(s) so far`
360
+ : `analysis ${status}`);
361
+ board?.update('harness', message);
362
+ emitLaneProgress('harness', 'harness', message);
363
+ },
364
+ }).catch((error) => {
365
+ // Captured, not thrown: an unhandled rejection here would crash init
366
+ // in the middle of the fast tasks. Inspected after the SDK lane.
367
+ pollError = error;
368
+ return undefined;
369
+ });
370
+ }
371
+ catch (error) {
372
+ // A remote failure (service down, upload error) must not sink
373
+ // onboarding; fall back to the local analyst path unless --remote was
374
+ // pinned explicitly.
375
+ if (!allowLocalFallback)
376
+ throw error;
153
377
  warn(renderInitNote(`Server-side harness discovery unavailable (${error instanceof Error ? error.message : String(error)}); falling back to local analyst.`, 'warning'));
154
378
  emit('harness', 'Server-side discovery failed; falling back to local analyst.', 'warning');
155
- harnessResult = await runAnalyze(false);
156
- }
157
- else {
158
- throw error;
379
+ remote = false;
159
380
  }
160
381
  }
161
- if (harnessResult.exitCode !== 0) {
162
- throw new CliInputError('Moda Analyst produced a harness report that did not pass validation.', 'Review .moda/harness-report-validation.json, then rerun `moda init --local --analyst=claude|cursor|codex` or `moda harness analyze --analyst=local-scan --summary`.');
163
- }
164
- log(renderInitNote('Wrote or verified .moda/harness-report.json, .moda/harness.json, and .moda/HARNESS.md'));
165
- if (human)
166
- printHarnessDetectionSummary();
167
- emit('harness', 'Harness discovery complete.', 'completed');
168
- // Upload the cited harness graph so Moda's backend has the understanding, not
169
- // just local artifacts. `harness sync --from-report` refuses an unapproved
170
- // report, so approve it first. Best-effort: a network blip on either call must
171
- // not abort onboarding — auth + key provisioning already succeeded.
172
- try {
173
- await runHarnessCommand(createCommandContext({
174
- command: 'harness',
175
- positional: 'approve',
176
- positionals: ['approve'],
177
- flags: commandFlags({ yes: true, json: json || agent, agent, quiet: true, 'no-telemetry': true }),
178
- }));
179
- await runHarnessCommand(createCommandContext({
180
- command: 'harness',
181
- positional: 'sync',
182
- positionals: ['sync'],
183
- flags: commandFlags({ 'from-report': true, yes: true, json: json || agent, agent, quiet: true, 'no-telemetry': true }),
184
- }));
185
- log(renderInitNote('Synced harness graph to Moda'));
186
- emit('harness', 'Harness graph synced to Moda.', 'completed');
187
- }
188
- catch (err) {
189
- const message = err instanceof Error ? err.message : String(err);
190
- warn(renderInitNote(`Harness sync skipped: ${message}`, 'warning'));
191
- warn(' Run `moda harness approve --yes && moda harness sync --from-report` later to upload.');
192
- emit('harness', 'Harness sync skipped.', 'warning');
193
- }
194
- phases.push({ id: 'harness', title: 'Harness Discovery', status: 'completed' });
195
- // Step 5: Setup plan
196
- log(renderInitStep(5, 7, 'Setup Plan', 'Every write/sync action is explicit before it is applied.'));
197
- const setupPlan = buildSetupPlan({
198
- installHooks: false,
199
- integrateSdk: await shouldIntegrateSdk(flags, yes),
200
- configureCiCd: await shouldConfigureCiCd(flags, yes),
201
- configureAutosync: await shouldConfigureAutosync(flags, yes),
202
- });
203
- if (json || agent) {
204
- const skipped = setupPlan
205
- .filter((action) => (action.category === 'sdk' || action.category === 'ci-cd' || action.category === 'autosync') &&
206
- action.approved === false)
207
- .map((action) => `${action.category}: ${action.command ?? 'explicit flag required'}`);
208
- if (skipped.length > 0) {
209
- emit('plan', `Non-interactive init skipped optional actions (${skipped.join('; ')}).`, 'warning');
210
- }
382
+ // Local-analyst mode keeps today's sequential behavior: analyze (AnalystTui
383
+ // renders live progress), then approve + sync, then the fast tasks below.
384
+ if (!remote) {
385
+ await finishLocalAnalyze();
211
386
  }
212
- log(renderInitSetupPlan(setupPlan));
213
- emit('plan', 'Setup plan ready.', 'completed');
214
- phases.push({ id: 'plan', title: 'Setup Plan', status: 'completed' });
215
- // Step 6: Apply approved setup changes.
216
- log(renderInitStep(6, 7, 'Apply Approved Changes'));
217
387
  emit('apply', 'Applying approved setup changes.');
388
+ const laneBoard = startBoard();
218
389
  const ingestCodingAgentTraces = false;
219
390
  const agents = detectAgents();
391
+ laneBoard?.startLane('agent-skills', 'Agent skills');
220
392
  if (agents.length > 0) {
221
- log(renderInitNote(`Detected coding agents: ${agents.join(', ')}`));
222
- // Install only the moda-cli skill so those agents can query Moda analytics
223
- // from the terminal. SDK integration is a one-shot agent run below, not a
224
- // persistent skill.
225
- await installRules(agents, { yes, silent: !human });
393
+ // The Step 3 plan already covered the skill install; never re-prompt.
394
+ await installRules(agents, { yes: true, silent: true });
395
+ laneBoard?.complete('agent-skills', `moda-cli skill installed for ${agents.join(', ')}`);
226
396
  }
227
397
  else {
228
- log(renderInitNote('No coding agents detected, skipping', 'skipped'));
398
+ laneBoard?.complete('agent-skills', 'no coding agents detected');
229
399
  }
230
400
  await initPrompts({ silent: true });
231
- log(renderInitNote('Created or verified .moda/prompts.yml'));
401
+ laneBoard?.startLane('prompts', 'Prompts', { detail: 'Created or verified .moda/prompts.yml' });
232
402
  // Push the manifest's prompt files to Moda so production traces can attribute
233
- // managed prompt versions. A fresh repo has none yet — that's a harmless no-op.
403
+ // managed prompt versions. A fresh repo has none yet; that's a harmless no-op.
234
404
  try {
235
- const promptSync = await runPromptSync({}, { profile: stringFlag(flags.profile) });
236
- log(renderInitNote(promptSync.synced.length > 0
237
- ? `Synced ${promptSync.synced.length} prompt(s) to Moda`
238
- : 'No prompt files to sync yet (add paths to .moda/prompts.yml)'));
405
+ const promptSync = await runPromptSync({}, { profile });
406
+ laneBoard?.complete('prompts', promptSync.synced.length > 0
407
+ ? `${promptSync.synced.length} prompt(s) synced`
408
+ : 'manifest ready (no prompt files to sync yet)');
239
409
  }
240
410
  catch (err) {
241
411
  const message = err instanceof Error ? err.message : String(err);
242
- warn(renderInitNote(`Prompt sync skipped: ${message}`, 'warning'));
412
+ laneBoard?.warn('prompts', `sync skipped: ${message}`);
243
413
  }
244
414
  // Push locally-authored skills (`.claude/skills/**/SKILL.md`) to Moda, in
245
- // parallel intent with prompt sync. A fresh repo has none yet — harmless
246
- // no-op. Best-effort: a network blip must not abort onboarding.
415
+ // parallel intent with prompt sync. Best-effort: a network blip must not
416
+ // abort onboarding.
417
+ laneBoard?.startLane('skills', 'Skills');
247
418
  try {
248
- const skillSync = await runSkillSync({}, { profile: stringFlag(flags.profile) });
249
- if (skillSync.synced.length > 0) {
250
- log(renderInitNote(`Synced ${skillSync.synced.length} skill(s) to Moda`));
251
- }
252
- else {
253
- log(renderInitNote('No local skills to sync yet (add .claude/skills/<name>/SKILL.md)'));
254
- }
419
+ const skillSync = await runSkillSync({}, { profile });
420
+ laneBoard?.complete('skills', skillSync.synced.length > 0
421
+ ? `${skillSync.synced.length} skill(s) synced`
422
+ : 'no local skills to sync yet');
255
423
  }
256
424
  catch (err) {
257
425
  const message = err instanceof Error ? err.message : String(err);
258
- warn(renderInitNote(`Skill sync skipped: ${message}`, 'warning'));
426
+ laneBoard?.warn('skills', `sync skipped: ${message}`);
427
+ }
428
+ if (setupPlan.find((action) => action.category === 'ci-cd')?.approved === true) {
429
+ const workflowPath = writeCiCdWorkflow();
430
+ laneBoard?.startLane('ci-cd', 'CI/CD workflow');
431
+ laneBoard?.complete('ci-cd', `wrote ${workflowPath}`);
432
+ markApplied(setupPlan, 'ci-cd');
433
+ }
434
+ if (setupPlan.find((action) => action.category === 'autosync')?.approved === true) {
435
+ const workflowPath = writeAutosyncWorkflow();
436
+ laneBoard?.startLane('autosync', 'Autosync workflow');
437
+ laneBoard?.complete('autosync', `wrote ${workflowPath}`);
438
+ markApplied(setupPlan, 'autosync');
259
439
  }
260
440
  const sdkAction = setupPlan.find((action) => action.category === 'sdk');
261
441
  if (sdkAction?.approved === true) {
262
- log(renderInitNote('Running your coding agent to integrate the Moda SDK (this can take a few minutes)...', 'warning'));
263
- const result = await runSdkIntegration({ human });
442
+ laneBoard?.startLane('sdk', 'SDK integration', {
443
+ detail: 'launching your coding agent',
444
+ expectation: 'usually 3-10 min',
445
+ });
446
+ emit('apply', 'Running your coding agent to integrate the Moda SDK.');
447
+ const result = await runSdkIntegration({
448
+ onProgress: (message) => {
449
+ laneBoard?.update('sdk', message);
450
+ emitLaneProgress('apply', 'sdk', message);
451
+ },
452
+ // Presence of onEvent selects the streaming agent invocation so tool
453
+ // cards arrive incrementally; onProgress above receives the rendered
454
+ // cards (describeCodingAgentEvent), which is all the lane needs.
455
+ onEvent: () => { },
456
+ });
264
457
  if (result.status === 'integrated') {
265
- log(renderInitNote(`Integrated the Moda SDK with ${result.adapter}. Review the diff before committing.`));
458
+ laneBoard?.complete('sdk', `integrated with ${result.adapter}; review the diff before committing`);
266
459
  markApplied(setupPlan, 'sdk');
267
460
  }
268
461
  else if (result.status === 'no-agent') {
269
462
  const guidePath = writeSdkIntegrationGuide();
270
463
  sdkAction.files = [guidePath];
271
- log(renderInitNote(`No coding agent CLI found; wrote ${guidePath} to integrate by hand`, 'warning'));
464
+ laneBoard?.warn('sdk', `no coding agent CLI found; wrote ${guidePath} to integrate by hand`);
272
465
  markApplied(setupPlan, 'sdk');
273
466
  }
274
467
  else {
275
468
  const guidePath = writeSdkIntegrationGuide();
276
469
  sdkAction.files = [guidePath];
277
- warn(renderInitNote(`SDK integration via ${result.adapter} did not complete: ${result.detail}`, 'warning'));
278
- log(renderInitNote(`Wrote ${guidePath} so you can finish the integration manually`));
470
+ laneBoard?.warn('sdk', `${result.adapter} did not complete; wrote ${guidePath} to finish manually`);
279
471
  markApplied(setupPlan, 'sdk');
280
472
  }
281
473
  }
282
474
  else {
283
- log(renderInitNote('Skipped SDK integration', 'skipped'));
284
475
  markApplied(setupPlan, 'sdk', 'User skipped SDK integration.');
285
476
  }
286
- const configureCiCd = setupPlan.find((action) => action.category === 'ci-cd')?.approved === true;
287
- if (configureCiCd) {
288
- const workflowPath = writeCiCdWorkflow();
289
- log(renderInitNote(`Wrote ${workflowPath}`));
290
- markApplied(setupPlan, 'ci-cd');
291
- }
292
- const configureAutosync = setupPlan.find((action) => action.category === 'autosync')?.approved === true;
293
- if (configureAutosync) {
294
- const workflowPath = writeAutosyncWorkflow();
295
- log(renderInitNote(`Wrote ${workflowPath}`));
296
- markApplied(setupPlan, 'autosync');
297
- }
298
- log(renderInitNote('Reusing approved Step 4 harness report; no second analyst run needed'));
299
- markApplied(setupPlan, 'harness');
300
477
  markApplied(setupPlan, 'hooks', ingestCodingAgentTraces ? undefined : 'Coding-agent trace capture is temporarily unavailable.');
301
478
  markApplied(setupPlan, 'prompts');
302
479
  markApplied(setupPlan, 'skills');
480
+ // Await the hosted analysis last: it has been polling concurrently since the
481
+ // top of this step. Ctrl-C or a poll deadline leaves the run resumable
482
+ // (state file stays `running`) and prints the pull recovery command.
483
+ if (remote && pollPromise && remoteRunId) {
484
+ const final = await pollPromise;
485
+ process.removeListener('SIGINT', onRemoteSigint);
486
+ const fallBackToLocal = async (error) => {
487
+ board?.fail('harness', 'hosted analysis failed');
488
+ board?.stop();
489
+ if (!allowLocalFallback)
490
+ throw error;
491
+ warn(renderInitNote(`Server-side harness discovery failed (${error instanceof Error ? error.message : String(error)}); falling back to local analyst.`, 'warning'));
492
+ emit('harness', 'Server-side discovery failed; falling back to local analyst.', 'warning');
493
+ await finishLocalAnalyze();
494
+ };
495
+ if (pollError !== undefined) {
496
+ await fallBackToLocal(pollError);
497
+ }
498
+ else if (!final || (final.status !== 'completed' && final.status !== 'failed')) {
499
+ // The poll deadline passed while the hosted run kept going. Expected on
500
+ // very large repos, and safe: the run state file stays `running`.
501
+ harnessDeferred = true;
502
+ harnessPhase = { id: 'harness', title: 'Harness discovery', status: 'warning', details: 'still running on Moda' };
503
+ markApplied(setupPlan, 'harness', `Analysis still running on Moda; run \`${HARNESS_PULL_RECOVERY}\` to fetch and sync it.`);
504
+ board?.warn('harness', `still running on Moda; run \`${HARNESS_PULL_RECOVERY}\` in a few minutes`);
505
+ board?.stop();
506
+ log(renderInitNote(`Harness analysis is still running on Moda. Run \`${HARNESS_PULL_RECOVERY}\` in a few minutes to fetch and sync the result.`, 'info'));
507
+ emit('harness', `Harness analysis still running on Moda; resume with \`${HARNESS_PULL_RECOVERY}\`.`, 'warning');
508
+ }
509
+ else {
510
+ board?.update('harness', 'writing local artifacts');
511
+ let pullResult;
512
+ try {
513
+ // `harness pull` finalizes exactly like `harness analyze --remote`
514
+ // (repair, local re-validation, .moda artifact writes) from the
515
+ // already-terminal hosted run.
516
+ pullResult = await runHarnessCommand(createCommandContext({
517
+ command: 'harness',
518
+ positional: 'pull',
519
+ positionals: ['pull'],
520
+ flags: nestedHarnessFlags({ 'run-id': remoteRunId }),
521
+ }));
522
+ }
523
+ catch (error) {
524
+ await fallBackToLocal(error);
525
+ }
526
+ if (pullResult) {
527
+ if (pullResult.exitCode !== 0) {
528
+ board?.fail('harness', 'report validation failed');
529
+ board?.stop();
530
+ throw validationFailure();
531
+ }
532
+ board?.complete('harness', 'report written to .moda/');
533
+ board?.stop();
534
+ log(renderInitNote('Wrote or verified .moda/harness-report.json, .moda/harness.json, and .moda/HARNESS.md'));
535
+ if (human)
536
+ printHarnessDetectionSummary();
537
+ emit('harness', 'Harness discovery complete.', 'completed');
538
+ await approveAndSyncHarness();
539
+ }
540
+ }
541
+ }
542
+ board?.stop();
543
+ if (!harnessDeferred)
544
+ markApplied(setupPlan, 'harness');
545
+ phases.push(harnessPhase);
303
546
  emit('apply', 'Approved changes applied.', 'completed');
304
- phases.push({ id: 'apply', title: 'Apply Approved Changes', status: 'completed' });
547
+ phases.push({ id: 'apply', title: 'Set up', status: 'completed' });
548
+ // Step 5: Verify.
305
549
  const offlineValidation = isTruthyFlag(flagValue(flags, 'offline'));
306
- log(renderInitStep(7, 7, 'Validate', offlineValidation
550
+ log(renderInitStep(5, 5, 'Verify', offlineValidation
307
551
  ? 'Running local doctor checks so the setup leaves a clear state.'
308
552
  : 'Running Moda doctor online to confirm setup and that telemetry is reaching Moda.'));
309
553
  emit('validate', `Running Moda doctor diagnostics (${offlineValidation ? 'offline' : 'online'}).`);
@@ -315,20 +559,33 @@ export async function runInit(flags, options = {}) {
315
559
  }), { offline: offlineValidation });
316
560
  markApplied(setupPlan, 'validation');
317
561
  log(renderInitNote(`Validation checks completed: ${doctorReport.summary.pass} passing, ${doctorReport.summary.warning} warning, ${doctorReport.summary.fail} failing`));
562
+ let telemetrySeen;
318
563
  if (!offlineValidation) {
319
564
  const cloud = doctorReport.checks.find((check) => check.id === 'cloud_data_flow');
320
565
  if (cloud) {
321
- log(renderInitNote(`Moda Cloud telemetry: ${cloud.status}`, cloud.status === 'pass' ? undefined : 'warning'));
566
+ telemetrySeen = cloud.status === 'pass';
567
+ if (telemetrySeen) {
568
+ log(renderInitNote('Moda Cloud telemetry: flowing'));
569
+ }
570
+ else {
571
+ // Expected before the app has run with the SDK installed; surfaced as
572
+ // a next step on the completion screen, not a warning badge.
573
+ log(renderInitNote('No telemetry yet. Expected until your app runs with the SDK; see next steps.', 'info'));
574
+ }
322
575
  }
323
576
  }
324
577
  emit('validate', 'Validation checks complete.', doctorReport.summary.fail > 0 ? 'warning' : 'completed');
325
- phases.push({ id: 'validate', title: 'Validate', status: 'completed' });
326
- // Done
578
+ phases.push({ id: 'validate', title: 'Verify', status: 'completed' });
579
+ // Done: end on the payoff.
327
580
  log(renderInitComplete({
328
581
  tenantName,
582
+ dashboardUrl: `${resolveModaBaseUrl({ profile })}/dashboard`,
583
+ harness: harnessDeferred
584
+ ? { state: 'pending' }
585
+ : { state: 'mapped', ...readHarnessGraphCounts() },
329
586
  filesChanged: changedFiles(setupPlan),
330
587
  skipped: setupPlan.filter((item) => item.status === 'skipped' || item.status === 'manual'),
331
- phases,
588
+ telemetrySeen,
332
589
  }));
333
590
  return { apiKey, tenantName, phases, setupPlan };
334
591
  }
@@ -336,7 +593,9 @@ function buildSetupPlan(options) {
336
593
  return [
337
594
  {
338
595
  category: 'harness',
339
- title: 'Analyze harness, write cited report, and sync the graph to Moda',
596
+ title: options.remote
597
+ ? "Analyze the harness on Moda's servers, write the cited report, and sync the graph to Moda"
598
+ : 'Analyze the harness locally, write the cited report, and sync the graph to Moda',
340
599
  status: 'planned',
341
600
  approved: true,
342
601
  files: [
@@ -346,7 +605,9 @@ function buildSetupPlan(options) {
346
605
  '.moda/harness.json',
347
606
  '.moda/HARNESS.md',
348
607
  ],
349
- command: 'moda harness analyze --analyst=auto --summary && moda harness approve --yes && moda harness sync --from-report',
608
+ command: options.remote
609
+ ? 'moda harness analyze --remote --summary && moda harness approve --yes && moda harness sync --from-report'
610
+ : 'moda harness analyze --analyst=auto --summary && moda harness approve --yes && moda harness sync --from-report',
350
611
  },
351
612
  {
352
613
  category: 'hooks',
@@ -436,7 +697,7 @@ function changedFiles(setupPlan) {
436
697
  // The CLI version generated workflows pin to. Pinning (rather than `@latest`,
437
698
  // which resolves at CI run time) keeps a future compromised `@moda-ai/cli`
438
699
  // publish from silently executing in adopters' CI with their MODA_API_KEY /
439
- // MODA_TENANT_ID present — especially important for autosync, which *writes* to
700
+ // MODA_TENANT_ID present. Especially important for autosync, which *writes* to
440
701
  // the Moda registry. The pin tracks the CLI that generated the file, so each
441
702
  // `moda init` upgrade is an explicit, reviewable bump of the workflow.
442
703
  const GENERATED_WORKFLOW_CLI = `@moda-ai/cli@${VERSION}`;
@@ -541,6 +802,22 @@ function printHarnessDetectionSummary(cwd = process.cwd()) {
541
802
  console.log(' Detected harness summary is available in .moda/HARNESS.md');
542
803
  }
543
804
  }
805
+ /** Agent/artifact counts for the completion screen's harness payoff line. */
806
+ function readHarnessGraphCounts(cwd = process.cwd()) {
807
+ const manifestPath = join(cwd, '.moda', 'harness.json');
808
+ if (!existsSync(manifestPath))
809
+ return {};
810
+ try {
811
+ const graph = JSON.parse(readFileSync(manifestPath, 'utf8'));
812
+ return {
813
+ agents: Array.isArray(graph.runtimeAgents) ? graph.runtimeAgents.length : 0,
814
+ artifacts: Array.isArray(graph.artifacts) ? graph.artifacts.length : 0,
815
+ };
816
+ }
817
+ catch {
818
+ return {};
819
+ }
820
+ }
544
821
  function commandFlags(flags) {
545
822
  return Object.fromEntries(Object.entries(flags)
546
823
  .filter(([, value]) => value !== undefined && value !== false)
@@ -579,47 +856,41 @@ function resumeFlagsForInit(flags) {
579
856
  }
580
857
  return out;
581
858
  }
582
- async function shouldConfigureCiCd(flags, yes) {
859
+ /**
860
+ * Explicit CI/CD decision from flags, or undefined when the Plan beat decides
861
+ * (interactive default off; --yes/--json/--agent default off).
862
+ */
863
+ function resolveCiCdFlag(flags) {
583
864
  const explicit = parseOptionalBoolean(flagValue(flags, 'ci-cd', 'ci_cd', 'ci'));
584
865
  if (explicit !== undefined)
585
866
  return explicit;
586
867
  if (isTruthyFlag(flagValue(flags, 'no-ci-cd', 'no_ci_cd')))
587
868
  return false;
588
- if (yes)
589
- return false;
590
- return confirm({
591
- message: 'Create a GitHub Actions workflow for Moda validation?',
592
- default: false,
593
- });
869
+ return undefined;
594
870
  }
595
- async function shouldConfigureAutosync(flags, yes) {
871
+ /** Explicit autosync decision from flags, or undefined when the Plan beat decides. */
872
+ function resolveAutosyncFlag(flags) {
596
873
  const explicit = parseOptionalBoolean(flagValue(flags, 'autosync', 'auto-sync', 'auto_sync'));
597
874
  if (explicit !== undefined)
598
875
  return explicit;
599
876
  if (isTruthyFlag(flagValue(flags, 'no-autosync', 'no_autosync', 'no-auto-sync', 'no_auto_sync')))
600
877
  return false;
601
- if (yes)
602
- return false;
603
- return confirm({
604
- message: 'Create a GitHub Actions workflow to autosync prompts and skills to Moda on push to main?',
605
- default: false,
606
- });
878
+ return undefined;
607
879
  }
608
- async function shouldIntegrateSdk(flags, yes) {
880
+ /**
881
+ * Explicit SDK-integration decision from flags, or undefined when the Plan
882
+ * beat decides (interactive default: on when a coding-agent CLI is installed;
883
+ * --yes/--json/--agent default off because the agent edits source and installs
884
+ * packages, which must never happen unattended without an explicit opt-in).
885
+ */
886
+ function resolveSdkIntegrateFlag(flags) {
609
887
  const explicit = parseOptionalBoolean(flagValue(flags, 'sdk-integrate', 'sdk_integrate', 'sdk-guide', 'sdk_guide', 'sdk'));
610
888
  if (explicit !== undefined)
611
889
  return explicit;
612
890
  if (isTruthyFlag(flagValue(flags, 'no-sdk-integrate', 'no_sdk_integrate', 'no-sdk-guide', 'no_sdk_guide', 'no-sdk', 'no_sdk'))) {
613
891
  return false;
614
892
  }
615
- // SDK integration now installs packages and edits source via a coding agent.
616
- // Never do that unattended (--yes/--json/--agent); require an explicit opt-in.
617
- if (yes)
618
- return false;
619
- return confirm({
620
- message: 'Run your coding agent now to install the Moda SDK and instrument your LLM calls? (Edits source, installs packages.)',
621
- default: true,
622
- });
893
+ return undefined;
623
894
  }
624
895
  function parseOptionalBoolean(value) {
625
896
  if (value === undefined)
@@ -661,6 +932,19 @@ export function resolveHarnessRemote(flags) {
661
932
  }
662
933
  return true;
663
934
  }
935
+ /**
936
+ * True when the user pinned the remote/local decision (flags or a
937
+ * deterministic analyst), so the Plan checkbox must not offer to re-toggle it.
938
+ */
939
+ function hasExplicitRemoteChoice(flags) {
940
+ if (isTruthyFlag(flagValue(flags, 'local')))
941
+ return true;
942
+ if (isTruthyFlag(flagValue(flags, 'no-remote', 'no_remote')))
943
+ return true;
944
+ if (parseOptionalBoolean(flagValue(flags, 'remote')) !== undefined)
945
+ return true;
946
+ return isDeterministicLocalAnalyst(stringFlag(flagValue(flags, 'analyst')));
947
+ }
664
948
  function flagValue(flags, ...keys) {
665
949
  for (const key of keys) {
666
950
  if (flags[key] !== undefined)