@haystackeditor/cli 0.14.6 → 0.15.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.
@@ -11,4 +11,19 @@
11
11
  * 7. Confirm & write .haystack.json to selected repos
12
12
  * 8. Install session tracking (Entire CLI + .entire/settings.json + local hooks)
13
13
  */
14
- export declare function setupCommand(): Promise<void>;
14
+ /**
15
+ * Flags accepted by `haystack setup`. With none, it runs the interactive TTY
16
+ * wizard. `--json` switches to the coding-agent protocol (NDJSON on stdout,
17
+ * answers on stdin). Any pre-supplied answer (a flag or --answers entry) skips
18
+ * the matching prompt, so the same flow runs fully autonomously when every
19
+ * decision is known up-front.
20
+ */
21
+ export interface SetupOptions {
22
+ json?: boolean;
23
+ repos?: string[];
24
+ yes?: boolean;
25
+ autoMerge?: boolean;
26
+ skipEntire?: boolean;
27
+ answersFile?: string;
28
+ }
29
+ export declare function setupCommand(options?: SetupOptions): Promise<void>;
@@ -12,7 +12,6 @@
12
12
  * 8. Install session tracking (Entire CLI + .entire/settings.json + local hooks)
13
13
  */
14
14
  import chalk from 'chalk';
15
- import inquirer from 'inquirer';
16
15
  import { execFileSync, execSync } from 'child_process';
17
16
  import { mkdirSync } from 'fs';
18
17
  import { basename, join } from 'path';
@@ -20,6 +19,41 @@ import { resolveAuthContext } from '../utils/auth.js';
20
19
  import { hooksInstall } from './hooks.js';
21
20
  import { findGitRoot } from '../utils/hooks.js';
22
21
  import { trackError, trackSetupEvent } from '../utils/telemetry.js';
22
+ import { createPrompter, loadAnswersFile } from '../utils/prompter.js';
23
+ /** The active prompter for this `setup` invocation (TTY or JSON). */
24
+ let ui;
25
+ /**
26
+ * Translate CLI flags into a pre-supplied answer map keyed by question id.
27
+ * Explicit flags win; `--answers` fills the rest. Anything still missing is
28
+ * asked at runtime (TTY prompt or JSON `question` event).
29
+ */
30
+ function buildAnswerMap(options) {
31
+ const answers = options.answersFile ? loadAnswersFile(options.answersFile) : {};
32
+ if (options.repos && options.repos.length > 0)
33
+ answers.select_repos = options.repos;
34
+ if (typeof options.autoMerge === 'boolean')
35
+ answers.auto_merge = options.autoMerge;
36
+ if (options.skipEntire) {
37
+ answers.install_entire = false;
38
+ answers.upgrade_entire = false;
39
+ answers.install_hooks = false;
40
+ }
41
+ if (options.yes) {
42
+ // Accept defaults non-interactively: don't open the toggle UI (keep every
43
+ // discovered item) and confirm the write. Only fill ids not already set.
44
+ if (!('want_to_toggle' in answers))
45
+ answers.want_to_toggle = false;
46
+ if (!('confirm_write' in answers))
47
+ answers.confirm_write = true;
48
+ if (!('auto_merge' in answers))
49
+ answers.auto_merge = true;
50
+ if (!('install_entire' in answers))
51
+ answers.install_entire = false;
52
+ if (!('install_hooks' in answers))
53
+ answers.install_hooks = false;
54
+ }
55
+ return answers;
56
+ }
23
57
  // =============================================================================
24
58
  // Constants
25
59
  // =============================================================================
@@ -310,12 +344,16 @@ async function buildEntireSettingsContent(repoFullName, token) {
310
344
  return null;
311
345
  return { content: updatedContent, existingSha };
312
346
  }
313
- async function addBootstrapLabels(owner, repo, prNumber, prUrl, token) {
314
- // CRITICAL, not best-effort: without BOTH labels the worker's bootstrap
315
- // merge path never discovers the PR and it sits open forever. The label
316
- // API failing is almost always transient, so retry a few times before
317
- // giving up.
318
- const labels = [AUTO_MERGE_LABEL, ONBOARDING_BOOTSTRAP_LABEL];
347
+ async function addBootstrapLabels(owner, repo, prNumber, prUrl, token, autoMerge) {
348
+ // CRITICAL, not best-effort. ONBOARDING_BOOTSTRAP_LABEL is the identity marker
349
+ // that keeps the PR recognized in the user's Analyzing / Good to Merge tabs
350
+ // (isOnboardingBootstrapPR) and lets the worker's bootstrap path discover it
351
+ // so it's ALWAYS applied. AUTO_MERGE_LABEL enrolls the PR for auto-merge, so add
352
+ // it only when auto-merge is on. The label API failing is almost always
353
+ // transient, so retry a few times before giving up.
354
+ const labels = autoMerge
355
+ ? [ONBOARDING_BOOTSTRAP_LABEL, AUTO_MERGE_LABEL]
356
+ : [ONBOARDING_BOOTSTRAP_LABEL];
319
357
  let lastErr = '';
320
358
  for (let attempt = 1; attempt <= 3; attempt++) {
321
359
  const res = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/issues/${prNumber}/labels`, {
@@ -444,14 +482,11 @@ async function openBootstrapConfigPR(owner, repo, configFiles, token, defaultBra
444
482
  const commitSha = await createCommitWithFiles(owner, repo, baseSha, files, CONFIG_COMMIT_MSG, token);
445
483
  await createBranch(owner, repo, branchName, commitSha, token);
446
484
  const pr = await createBootstrapPR(owner, repo, branchName, defaultBranch, token, autoMergeEnabled);
447
- // Only enroll the PR for auto-merge when the repo hasn't opted out. Applying
448
- // the labels on an auto-merge-disabled repo would force-merge a config PR the
449
- // user wants to review themselves (e.g. a repo set auto-merge-off via the web
450
- // wizard, then re-run through `haystack setup`). Without labels the worker
451
- // never picks it up; the PR is still opened and analyzed, it just waits.
452
- if (autoMergeEnabled) {
453
- await addBootstrapLabels(owner, repo, pr.number, pr.html_url, token);
454
- }
485
+ // Always apply the onboarding-bootstrap marker so the PR stays recognized in
486
+ // the user's tabs (isOnboardingBootstrapPR); enroll for auto-merge only when
487
+ // the repo hasn't opted out. With auto-merge off the PR is still opened, marked,
488
+ // and analyzed it just waits for the user to merge it.
489
+ await addBootstrapLabels(owner, repo, pr.number, pr.html_url, token, autoMergeEnabled);
455
490
  return { prUrl: pr.html_url, entireSettingsBundled };
456
491
  }
457
492
  /**
@@ -551,7 +586,7 @@ async function runUnifiedScan(repo, token) {
551
586
  }
552
587
  if (statusResp.status === 404) {
553
588
  // State expired (TTL) or runId never registered — surface, don't hang.
554
- process.stdout.write('\r\x1b[K');
589
+ ui.clearProgress();
555
590
  throw new Error('Scan state expired — please retry.');
556
591
  }
557
592
  if (!statusResp.ok) {
@@ -570,14 +605,11 @@ async function runUnifiedScan(repo, token) {
570
605
  const key = `${state.progress.message}|${state.progress.detail ?? ''}`;
571
606
  if (key !== lastProgressKey) {
572
607
  lastProgressKey = key;
573
- process.stdout.write(`\r${chalk.dim(' ' + state.progress.message)}`);
574
- if (state.progress.detail)
575
- process.stdout.write(chalk.dim(` — ${state.progress.detail}`));
576
- process.stdout.write('\x1b[K');
608
+ ui.progress(state.progress.message, state.progress.detail);
577
609
  }
578
610
  }
579
611
  if (state.status === 'done') {
580
- process.stdout.write('\r\x1b[K');
612
+ ui.clearProgress();
581
613
  if (!state.result)
582
614
  throw new Error('Scan completed but returned no result.');
583
615
  return {
@@ -587,11 +619,11 @@ async function runUnifiedScan(repo, token) {
587
619
  };
588
620
  }
589
621
  if (state.status === 'error') {
590
- process.stdout.write('\r\x1b[K');
622
+ ui.clearProgress();
591
623
  throw new Error(state.error || 'Scan failed');
592
624
  }
593
625
  if (state.status === 'running' && Date.now() - state.updatedAt > SCAN_STALE_MS) {
594
- process.stdout.write('\r\x1b[K');
626
+ ui.clearProgress();
595
627
  throw new Error('Scan stalled (no progress) — please retry.');
596
628
  }
597
629
  }
@@ -694,16 +726,24 @@ function tryOpenBrowser(url) {
694
726
  return false;
695
727
  }
696
728
  }
729
+ /** True if the App is installed; tolerates transient errors (returns false so
730
+ * the poll keeps waiting) but rethrows a genuine auth failure so the caller can
731
+ * bail and prompt re-auth. */
732
+ async function isAppInstalled(token) {
733
+ return (await fetchHaystackInstallations(token)).length > 0;
734
+ }
697
735
  async function stepEnsureAppInstalled(token) {
698
736
  console.log(chalk.bold(' Step 0: Verify GitHub App'));
699
- let installed;
737
+ // Initial check — distinguish a genuine auth failure (bail, prompt re-auth)
738
+ // from a transient probe error (refuse-to-fail and proceed).
700
739
  try {
701
- installed = await fetchHaystackInstallations(token);
740
+ if (await isAppInstalled(token)) {
741
+ console.log(chalk.green(' ✓ Haystack App installed\n'));
742
+ return;
743
+ }
702
744
  }
703
745
  catch (err) {
704
746
  if (err instanceof InstallationsAuthError) {
705
- // Token is dead — retrying won't help. Prompt re-auth and exit so the
706
- // user doesn't waste a 10-minute poll on something that will never work.
707
747
  console.log(chalk.yellow(`\n Your GitHub token is no longer valid (${err.status}).`));
708
748
  console.log(chalk.dim(' Run `haystack login` to re-authenticate, then re-run `haystack setup`.\n'));
709
749
  trackError('haystack_setup_installations_auth_failure', { status: err.status });
@@ -711,81 +751,47 @@ async function stepEnsureAppInstalled(token) {
711
751
  }
712
752
  console.log(chalk.yellow(` Could not check App installations: ${err.message}`));
713
753
  console.log(chalk.dim(` Continuing — install manually at ${HAYSTACK_APP_INSTALL_URL} if not yet installed.\n`));
714
- // Probe failed but we proceed (refuse-to-fail UX so a transient blip can't
715
- // brick setup). Still log so we can see if probes start failing
716
- // systemically across users.
717
- trackError('haystack_setup_installations_probe_failed', {
718
- error_message: err.message,
719
- });
720
- return;
721
- }
722
- if (installed.length > 0) {
723
- const accounts = installed.map((i) => i.account?.login).filter(Boolean).join(', ');
724
- console.log(chalk.green(` ✓ Haystack App installed${accounts ? ` (${accounts})` : ''}\n`));
754
+ trackError('haystack_setup_installations_probe_failed', { error_message: err.message });
725
755
  return;
726
756
  }
727
757
  console.log(chalk.yellow(' The Haystack GitHub App is not installed yet.'));
728
- console.log(chalk.dim(' Without it, Haystack can\'t analyze, triage, or merge your PRs.'));
729
- console.log('');
730
- console.log(` Install at: ${chalk.cyan(HAYSTACK_APP_INSTALL_URL)}`);
731
- const opened = tryOpenBrowser(HAYSTACK_APP_INSTALL_URL);
732
- if (opened) {
733
- console.log(chalk.dim(' (Opened in your browser. Pick the repos to grant access.)'));
758
+ console.log(chalk.dim(" Without it, Haystack can't analyze, triage, or merge your PRs."));
759
+ // Only auto-open a browser for a local interactive user. Under a coding agent
760
+ // (JSON mode) the `action` event carries the URL for the agent to relay.
761
+ if (ui.interactive) {
762
+ const opened = tryOpenBrowser(HAYSTACK_APP_INSTALL_URL);
763
+ if (opened)
764
+ console.log(chalk.dim(' (Opened in your browser. Pick the repos to grant access.)'));
765
+ }
766
+ // ui.action emits an `action` event (JSON) or prints + spins (TTY), then polls
767
+ // until the App is detected or it times out. A mid-poll auth failure surfaces
768
+ // as a thrown InstallationsAuthError.
769
+ let installed = false;
770
+ try {
771
+ installed = await ui.action({
772
+ id: 'install_app',
773
+ message: 'Install the Haystack GitHub App, then it is detected automatically.',
774
+ url: HAYSTACK_APP_INSTALL_URL,
775
+ check: () => isAppInstalled(token),
776
+ pollIntervalMs: APP_INSTALL_POLL_INTERVAL_MS,
777
+ timeoutMs: APP_INSTALL_TIMEOUT_MS,
778
+ });
734
779
  }
735
- console.log('');
736
- console.log(chalk.dim(' Waiting for install to complete...'));
737
- const startedAt = Date.now();
738
- let consecutiveFailures = 0;
739
- let lastError = null;
740
- while (Date.now() - startedAt < APP_INSTALL_TIMEOUT_MS) {
741
- await new Promise((resolve) => setTimeout(resolve, APP_INSTALL_POLL_INTERVAL_MS));
742
- let current;
743
- try {
744
- current = await fetchHaystackInstallations(token);
745
- consecutiveFailures = 0;
746
- lastError = null;
747
- }
748
- catch (err) {
749
- // Auth errors during the poll loop mean the token went bad mid-flow
750
- // (e.g. user revoked it from GitHub settings). Same response as the
751
- // initial check — bail with re-auth guidance instead of grinding for
752
- // 10 minutes against a permanent failure.
753
- if (err instanceof InstallationsAuthError) {
754
- console.log(chalk.yellow(`\n Your GitHub token became invalid (${err.status}) while waiting.`));
755
- console.log(chalk.dim(' Run `haystack login` to re-authenticate, then re-run `haystack setup`.\n'));
756
- trackError('haystack_setup_installations_auth_failure', { status: err.status, during: 'poll' });
757
- process.exit(1);
758
- }
759
- // Per-tick failures are usually transient (token refresh race, brief
760
- // GitHub API blip). Log the first one quietly so the user sees we're
761
- // not silently spinning, but don't spam the terminal every 3s.
762
- consecutiveFailures += 1;
763
- lastError = err instanceof Error ? err.message : String(err);
764
- if (consecutiveFailures === 1) {
765
- console.log(chalk.dim(` (Transient error checking installations: ${lastError} — retrying...)`));
766
- }
767
- continue;
768
- }
769
- if (current.length > 0) {
770
- const accounts = current.map((i) => i.account?.login).filter(Boolean).join(', ');
771
- console.log(chalk.green(`\n ✓ App installed${accounts ? ` (${accounts})` : ''}\n`));
772
- return;
780
+ catch (err) {
781
+ if (err instanceof InstallationsAuthError) {
782
+ console.log(chalk.yellow(`\n Your GitHub token became invalid (${err.status}) while waiting.`));
783
+ console.log(chalk.dim(' Run `haystack login` to re-authenticate, then re-run `haystack setup`.\n'));
784
+ trackError('haystack_setup_installations_auth_failure', { status: err.status, during: 'poll' });
785
+ process.exit(1);
773
786
  }
787
+ throw err;
774
788
  }
775
- // Timeout is a real failure — emit a structured telemetry event so we can
776
- // route it to ops alerting (Slack via the existing trackError → PostHog
777
- // pipeline). Include whether the polling itself was failing (lastError
778
- // non-null after the final tick) so we can tell "user didn't click install"
779
- // apart from "GitHub API was down."
780
- trackError('haystack_setup_app_install_timeout', {
781
- consecutive_failures: consecutiveFailures,
782
- last_error: lastError,
783
- timeout_ms: APP_INSTALL_TIMEOUT_MS,
784
- });
785
- console.log(chalk.yellow('\n Timed out waiting for App install.'));
786
- if (lastError) {
787
- console.log(chalk.dim(` Last error checking installations: ${lastError}`));
789
+ if (installed) {
790
+ console.log(chalk.green('\n ✓ App installed\n'));
791
+ return;
788
792
  }
793
+ trackError('haystack_setup_app_install_timeout', { timeout_ms: APP_INSTALL_TIMEOUT_MS });
794
+ console.log(chalk.yellow('\n Timed out waiting for App install.'));
789
795
  console.log(chalk.dim(` Install at ${HAYSTACK_APP_INSTALL_URL}, then re-run \`haystack setup\`.\n`));
790
796
  process.exit(1);
791
797
  }
@@ -797,22 +803,15 @@ async function stepSelectRepos(token) {
797
803
  console.log(chalk.yellow('\n No repositories found.\n'));
798
804
  process.exit(1);
799
805
  }
800
- // Clear the "Fetching" message
801
- process.stdout.write('\x1b[1A\x1b[K');
802
- const { selectedRepos } = await inquirer.prompt([
803
- {
804
- type: 'checkbox',
805
- name: 'selectedRepos',
806
- message: 'Select repositories to configure:',
807
- choices: repos.map((r) => ({ name: r, value: r })),
808
- pageSize: 15,
809
- validate: (answer) => {
810
- if (answer.length === 0)
811
- return 'Select at least one repository.';
812
- return true;
813
- },
814
- },
815
- ]);
806
+ const selectedRepos = await ui.multiselect({
807
+ id: 'select_repos',
808
+ message: 'Select repositories to configure:',
809
+ choices: repos.map((r) => ({ name: r, value: r })),
810
+ });
811
+ if (selectedRepos.length === 0) {
812
+ console.log(chalk.yellow('\n No repositories selected.\n'));
813
+ process.exit(1);
814
+ }
816
815
  console.log(chalk.green(` ${selectedRepos.length} repo(s) selected\n`));
817
816
  return selectedRepos;
818
817
  }
@@ -886,14 +885,11 @@ async function stepReview(byRepo) {
886
885
  result.instructions.forEach((ins, i) => printInstruction(ins, i));
887
886
  }
888
887
  }
889
- const { wantToToggle } = await inquirer.prompt([
890
- {
891
- type: 'confirm',
892
- name: 'wantToToggle',
893
- message: 'Would you like to toggle any items?',
894
- default: false,
895
- },
896
- ]);
888
+ const wantToToggle = await ui.confirm({
889
+ id: 'want_to_toggle',
890
+ message: 'Would you like to toggle any items?',
891
+ default: false,
892
+ });
897
893
  if (!wantToToggle)
898
894
  return;
899
895
  // NUL separates repo / kind / id in the checkbox value so it can't collide
@@ -903,7 +899,7 @@ async function stepReview(byRepo) {
903
899
  for (const [repo, result] of byRepo) {
904
900
  if (result.rules.length + result.policies.length + result.instructions.length === 0)
905
901
  continue;
906
- allItems.push(new inquirer.Separator(`── ${repo} ──`));
902
+ allItems.push({ separator: `── ${repo} ──` });
907
903
  result.rules.forEach((r) => {
908
904
  allItems.push({
909
905
  name: `${r.name} ${chalk.dim(`[rule · ${CATEGORY_LABELS[r.category] || r.category}]`)}`,
@@ -926,15 +922,11 @@ async function stepReview(byRepo) {
926
922
  });
927
923
  });
928
924
  }
929
- const { enabled } = await inquirer.prompt([
930
- {
931
- type: 'checkbox',
932
- name: 'enabled',
933
- message: 'Select items to enable:',
934
- choices: allItems,
935
- pageSize: 20,
936
- },
937
- ]);
925
+ const enabled = await ui.multiselect({
926
+ id: 'review_enabled_items',
927
+ message: 'Select items to enable:',
928
+ choices: allItems,
929
+ });
938
930
  const enabledSet = new Set(enabled);
939
931
  for (const [repo, result] of byRepo) {
940
932
  result.rules.forEach((r) => { r.enabled = enabledSet.has(`${repo}${SEP}rule${SEP}${r.id}`); });
@@ -1090,22 +1082,16 @@ async function stepConfirm(byRepo, token) {
1090
1082
  // + the repo-level preferences.auto_merge opt-in model). When off, the
1091
1083
  // bootstrap PR is opened but NOT labeled, so the user merges it manually and
1092
1084
  // no PRs auto-merge.
1093
- const { autoMerge } = await inquirer.prompt([
1094
- {
1095
- type: 'confirm',
1096
- name: 'autoMerge',
1097
- message: 'Enable auto-merge? (Haystack merges PRs automatically once they pass review)',
1098
- default: true,
1099
- },
1100
- ]);
1101
- const { confirmed } = await inquirer.prompt([
1102
- {
1103
- type: 'confirm',
1104
- name: 'confirmed',
1105
- message: `Write Haystack config to ${selectedRepos.length} repo(s)?`,
1106
- default: true,
1107
- },
1108
- ]);
1085
+ const autoMerge = await ui.confirm({
1086
+ id: 'auto_merge',
1087
+ message: 'Enable auto-merge? (Haystack merges PRs automatically once they pass review)',
1088
+ default: true,
1089
+ });
1090
+ const confirmed = await ui.confirm({
1091
+ id: 'confirm_write',
1092
+ message: `Write Haystack config to ${selectedRepos.length} repo(s)?`,
1093
+ default: true,
1094
+ });
1109
1095
  if (!confirmed) {
1110
1096
  console.log(chalk.yellow('\n Setup cancelled.\n'));
1111
1097
  return { confirmed: false, bootstrapPRs: new Map() };
@@ -1117,9 +1103,9 @@ async function stepConfirm(byRepo, token) {
1117
1103
  const result = byRepo.get(repoFullName);
1118
1104
  const files = buildOnboardingFiles(result, autoMerge);
1119
1105
  try {
1120
- process.stdout.write(chalk.dim(` Writing to ${repoFullName}...`));
1106
+ ui.progress(`Writing to ${repoFullName}...`);
1121
1107
  const writeResult = await writeOnboardingFilesToRepo(owner, repo, files, autoMerge, token);
1122
- process.stdout.write('\r\x1b[K');
1108
+ ui.clearProgress();
1123
1109
  if (writeResult.bootstrapPR) {
1124
1110
  // Default branch was protected — config landed in a bootstrap PR. With
1125
1111
  // auto-merge on, Haystack merges it once its labels are processed; with
@@ -1137,7 +1123,7 @@ async function stepConfirm(byRepo, token) {
1137
1123
  }
1138
1124
  }
1139
1125
  catch (err) {
1140
- process.stdout.write('\r\x1b[K');
1126
+ ui.clearProgress();
1141
1127
  console.log(chalk.red(` ✗ ${repoFullName}: ${err.message}`));
1142
1128
  failedRepos.push(repoFullName);
1143
1129
  }
@@ -1152,7 +1138,18 @@ async function stepConfirm(byRepo, token) {
1152
1138
  // =============================================================================
1153
1139
  // Main command
1154
1140
  // =============================================================================
1155
- export async function setupCommand() {
1141
+ export async function setupCommand(options = {}) {
1142
+ // Create the prompter FIRST: in JSON mode it redirects console.* to stderr so
1143
+ // stdout carries only NDJSON, which the steps below rely on.
1144
+ ui = createPrompter({ json: options.json, answers: buildAnswerMap(options) });
1145
+ try {
1146
+ await runSetupFlow(options);
1147
+ }
1148
+ finally {
1149
+ ui.close();
1150
+ }
1151
+ }
1152
+ async function runSetupFlow(options) {
1156
1153
  console.log(chalk.cyan('\n Haystack Setup Wizard\n'));
1157
1154
  // Step 0: Check login
1158
1155
  let authContext;
@@ -1189,12 +1186,27 @@ export async function setupCommand() {
1189
1186
  await stepReview(byRepo);
1190
1187
  // Step 4: Confirm & write the config files
1191
1188
  const { confirmed, bootstrapPRs } = await stepConfirm(byRepo, token);
1192
- if (!confirmed)
1189
+ if (!confirmed) {
1190
+ ui.result({ status: 'cancelled' });
1193
1191
  return;
1192
+ }
1194
1193
  // Step 7: Install Entire CLI (session tracking). bootstrapPRs tells it
1195
1194
  // which repos had a protected default branch so it can commit
1196
1195
  // .entire/settings.json to the same PR instead of a doomed direct PUT.
1197
- await stepInstallEntire(selectedRepos, token, bootstrapPRs);
1196
+ // --skip-entire skips the whole step (binary install, hooks, AND the remote
1197
+ // .entire/settings.json write) — not just the install prompt.
1198
+ if (options.skipEntire) {
1199
+ console.log(chalk.dim(' Skipping session tracking (--skip-entire).\n'));
1200
+ }
1201
+ else {
1202
+ await stepInstallEntire(selectedRepos, token, bootstrapPRs);
1203
+ }
1204
+ // Final structured outcome for non-interactive/agent callers.
1205
+ ui.result({
1206
+ status: 'complete',
1207
+ repos: selectedRepos,
1208
+ pullRequests: [...bootstrapPRs.entries()].map(([repo, pr]) => ({ repo, url: pr.prUrl })),
1209
+ });
1198
1210
  }
1199
1211
  // =============================================================================
1200
1212
  // Step 7: Install Entire CLI for session tracking
@@ -1337,14 +1349,11 @@ async function stepInstallEntire(selectedRepos, token, bootstrapPRs) {
1337
1349
  }
1338
1350
  else if (status.installed) {
1339
1351
  console.log(chalk.dim(` Stock Entire CLI detected: ${status.version.split('\n')[0]}`));
1340
- const { upgrade } = await inquirer.prompt([
1341
- {
1342
- type: 'confirm',
1343
- name: 'upgrade',
1344
- message: 'Upgrade to Haystack fork? (adds transcript optimization — saves GB of git storage)',
1345
- default: true,
1346
- },
1347
- ]);
1352
+ const upgrade = await ui.confirm({
1353
+ id: 'upgrade_entire',
1354
+ message: 'Upgrade to Haystack fork? (adds transcript optimization — saves GB of git storage)',
1355
+ default: true,
1356
+ });
1348
1357
  if (upgrade) {
1349
1358
  binaryReady = await installEntireBinary();
1350
1359
  }
@@ -1353,14 +1362,11 @@ async function stepInstallEntire(selectedRepos, token, bootstrapPRs) {
1353
1362
  }
1354
1363
  }
1355
1364
  else {
1356
- const { install } = await inquirer.prompt([
1357
- {
1358
- type: 'confirm',
1359
- name: 'install',
1360
- message: 'Install Entire CLI? (tracks AI coding sessions for analysis)',
1361
- default: true,
1362
- },
1363
- ]);
1365
+ const install = await ui.confirm({
1366
+ id: 'install_entire',
1367
+ message: 'Install Entire CLI? (tracks AI coding sessions for analysis)',
1368
+ default: true,
1369
+ });
1364
1370
  if (install) {
1365
1371
  binaryReady = await installEntireBinary();
1366
1372
  }
@@ -1399,14 +1405,14 @@ async function stepInstallEntire(selectedRepos, token, bootstrapPRs) {
1399
1405
  continue;
1400
1406
  }
1401
1407
  try {
1402
- process.stdout.write(chalk.dim(` Configuring ${repoFullName}...`));
1408
+ ui.progress(`Configuring ${repoFullName}...`);
1403
1409
  await configureEntireSettingsViaAPI(repoFullName, token);
1404
- process.stdout.write('\r\x1b[K');
1410
+ ui.clearProgress();
1405
1411
  console.log(chalk.green(` ✓ ${repoFullName}`));
1406
1412
  configured++;
1407
1413
  }
1408
1414
  catch (err) {
1409
- process.stdout.write('\r\x1b[K');
1415
+ ui.clearProgress();
1410
1416
  console.log(chalk.yellow(` ⚠ ${repoFullName}: ${err instanceof Error ? err.message : err}`));
1411
1417
  }
1412
1418
  }
@@ -1429,14 +1435,11 @@ async function stepInstallEntire(selectedRepos, token, bootstrapPRs) {
1429
1435
  });
1430
1436
  if (repoName) {
1431
1437
  try {
1432
- const { installHooks } = await inquirer.prompt([
1433
- {
1434
- type: 'confirm',
1435
- name: 'installHooks',
1436
- message: `Install git hooks locally for ${repoName}? (agent detection, truncation checking, LLM rules)`,
1437
- default: true,
1438
- },
1439
- ]);
1438
+ const installHooks = await ui.confirm({
1439
+ id: 'install_hooks',
1440
+ message: `Install git hooks locally for ${repoName}? (agent detection, truncation checking, LLM rules)`,
1441
+ default: true,
1442
+ });
1440
1443
  if (installHooks) {
1441
1444
  console.log('');
1442
1445
  await hooksInstall({ skipEntire: true, force: true });
package/dist/index.js CHANGED
@@ -74,23 +74,48 @@ CI signals, and review policies.
74
74
  .action(initCommand);
75
75
  program
76
76
  .command('setup')
77
- .description('Interactive onboarding wizard — scan repos for rules, signals, and policies')
77
+ .description('Onboarding wizard — scan repos for rules/policies and write Haystack config')
78
+ .option('--json', 'Agent mode: emit NDJSON events on stdout, read answers on stdin (no TTY prompts)')
79
+ .option('--repo <repo...>', 'Repo(s) to configure (owner/name); skips the repo picker')
80
+ .option('-y, --yes', 'Accept defaults non-interactively (keep all discovered items, confirm write)')
81
+ // Defining --no-auto-merge also accepts --auto-merge; default is "ask" unless
82
+ // one is explicitly passed (detected via getOptionValueSource below).
83
+ .option('--no-auto-merge', 'Set auto-merge in the written config (--auto-merge / --no-auto-merge; omit to be asked)')
84
+ .option('--skip-entire', 'Skip the Entire CLI / git-hooks (session tracking) step')
85
+ .option('--answers <file>', 'JSON file of {questionId: value} pre-supplied answers')
78
86
  .addHelpText('after', `
79
- This walks you through Haystack setup step by step:
87
+ Steps: verify GitHub App select repos scan (rules/policies/instructions) →
88
+ review → write .haystack/pr-rules.yml + review-policy.md + .haystack.json.
80
89
 
81
- 1. Select GitHub repositories to configure
82
- 2. Scan for coding rules (conventions your team follows)
83
- 3. Scan for CI/bot signals (checks to wait for before merging)
84
- 4. Scan for review policies (who should review what)
85
- 5. Review and toggle discovered items
86
- 6. Write .haystack.json to your repos
90
+ Three ways to run:
91
+ Interactive (default): prompts in your terminal.
92
+ Autonomous/CI: supply every decision via flags, e.g.
93
+ haystack setup --repo owner/name --yes --no-auto-merge --skip-entire
94
+ Coding agent: --json speaks NDJSON on stdout and reads answers on stdin, so an
95
+ agent can auto-answer or relay questions to the user. Pre-supplied answers
96
+ (flags / --answers) skip the matching question.
87
97
 
88
98
  Requires authentication — run \`haystack login\` first.
89
99
 
90
100
  Examples:
91
101
  haystack setup
102
+ haystack setup --repo owner/name --yes
103
+ haystack setup --json --repo owner/name
92
104
  `)
93
- .action(setupCommand);
105
+ .action((options, command) => {
106
+ // Tri-state auto-merge: undefined (ask) unless --auto-merge/--no-auto-merge
107
+ // was explicitly passed. commander defaults autoMerge to true because of the
108
+ // --no- form, so check the value source rather than the value.
109
+ const autoMergeExplicit = command.getOptionValueSource('autoMerge') === 'cli';
110
+ return setupCommand({
111
+ json: options.json,
112
+ repos: options.repo,
113
+ yes: options.yes,
114
+ autoMerge: autoMergeExplicit ? options.autoMerge : undefined,
115
+ skipEntire: options.skipEntire,
116
+ answersFile: options.answers,
117
+ });
118
+ });
94
119
  program
95
120
  .command('status')
96
121
  .description('Check if .haystack.json exists and is valid')
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Prompter — decouples a wizard's *decisions* from *how they're answered*.
3
+ *
4
+ * `haystack setup` historically welded every decision to inquirer/TTY, so it
5
+ * couldn't run headless, in CI, or under a coding agent. The Prompter is the
6
+ * seam: step code calls `confirm` / `multiselect` / `action`, and the
7
+ * implementation decides how to source the answer.
8
+ *
9
+ * Three modes, one mechanism:
10
+ * - TtyPrompter — humans in a terminal (inquirer). Default.
11
+ * - JsonPrompter — a coding agent: NDJSON events on stdout, answers on stdin.
12
+ * The agent can relay a `question` to the user OR auto-answer.
13
+ * - Pre-supplied answers (flags / --answers file) short-circuit any question
14
+ * by id, so the same code runs fully autonomously when every answer is known.
15
+ *
16
+ * Human-facing chrome stays as `console.*`; in JSON mode the JsonPrompter
17
+ * redirects `console.*` to stderr so stdout carries only NDJSON.
18
+ */
19
+ /** A selectable option. A `{ separator }` entry renders a non-selectable group
20
+ * header in TTY mode and is ignored in JSON mode. */
21
+ export type Choice<T> = {
22
+ name: string;
23
+ value: T;
24
+ checked?: boolean;
25
+ } | {
26
+ separator: string;
27
+ };
28
+ export interface Prompter {
29
+ /** True for the human TTY prompter — gates side effects like opening a browser
30
+ * that only make sense for a local interactive user. */
31
+ readonly interactive: boolean;
32
+ /** Yes/no decision. */
33
+ confirm(q: {
34
+ id: string;
35
+ message: string;
36
+ default?: boolean;
37
+ }): Promise<boolean>;
38
+ /** Pick zero-or-more values. Items default-checked via `checked: true`. */
39
+ multiselect<T>(q: {
40
+ id: string;
41
+ message: string;
42
+ choices: Choice<T>[];
43
+ }): Promise<T[]>;
44
+ /**
45
+ * An action the user must take out-of-band (e.g. install the GitHub App).
46
+ * Polls `check()` until it returns true (satisfied) or `timeoutMs` elapses.
47
+ * A pre-supplied / stdin answer of "skip" resolves false without polling.
48
+ * Returns true when satisfied, false when skipped or timed out.
49
+ */
50
+ action(q: {
51
+ id: string;
52
+ message: string;
53
+ url?: string;
54
+ check: () => Promise<boolean>;
55
+ pollIntervalMs?: number;
56
+ timeoutMs?: number;
57
+ }): Promise<boolean>;
58
+ /** Transient progress line (scan ticker, per-repo write status). */
59
+ progress(message: string, detail?: string): void;
60
+ /** Clear the current progress line. */
61
+ clearProgress(): void;
62
+ /** Final structured outcome (emitted as a `result` event in JSON mode). */
63
+ result(data: Record<string, unknown>): void;
64
+ /** Release stdin/readline so the process can exit. */
65
+ close(): void;
66
+ }
67
+ /** Answers known up-front (from flags or an --answers file), keyed by question id. */
68
+ export type AnswerMap = Record<string, unknown>;
69
+ export declare function loadAnswersFile(path: string): AnswerMap;
70
+ export interface PrompterOptions {
71
+ json?: boolean;
72
+ answers?: AnswerMap;
73
+ }
74
+ export declare function createPrompter(options?: PrompterOptions): Prompter;
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Prompter — decouples a wizard's *decisions* from *how they're answered*.
3
+ *
4
+ * `haystack setup` historically welded every decision to inquirer/TTY, so it
5
+ * couldn't run headless, in CI, or under a coding agent. The Prompter is the
6
+ * seam: step code calls `confirm` / `multiselect` / `action`, and the
7
+ * implementation decides how to source the answer.
8
+ *
9
+ * Three modes, one mechanism:
10
+ * - TtyPrompter — humans in a terminal (inquirer). Default.
11
+ * - JsonPrompter — a coding agent: NDJSON events on stdout, answers on stdin.
12
+ * The agent can relay a `question` to the user OR auto-answer.
13
+ * - Pre-supplied answers (flags / --answers file) short-circuit any question
14
+ * by id, so the same code runs fully autonomously when every answer is known.
15
+ *
16
+ * Human-facing chrome stays as `console.*`; in JSON mode the JsonPrompter
17
+ * redirects `console.*` to stderr so stdout carries only NDJSON.
18
+ */
19
+ import chalk from 'chalk';
20
+ import inquirer from 'inquirer';
21
+ import * as readline from 'node:readline';
22
+ import { readFileSync } from 'node:fs';
23
+ function isSeparator(c) {
24
+ return c.separator !== undefined;
25
+ }
26
+ export function loadAnswersFile(path) {
27
+ const raw = readFileSync(path, 'utf8');
28
+ const parsed = JSON.parse(raw);
29
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
30
+ throw new Error(`--answers file must be a JSON object of {questionId: value}`);
31
+ }
32
+ return parsed;
33
+ }
34
+ // ---------------------------------------------------------------------------
35
+ // TTY (human) prompter
36
+ // ---------------------------------------------------------------------------
37
+ class TtyPrompter {
38
+ answers;
39
+ interactive = true;
40
+ constructor(answers) {
41
+ this.answers = answers;
42
+ }
43
+ async confirm(q) {
44
+ if (q.id in this.answers)
45
+ return Boolean(this.answers[q.id]);
46
+ const { value } = await inquirer.prompt([
47
+ { type: 'confirm', name: 'value', message: q.message, default: q.default ?? true },
48
+ ]);
49
+ return value;
50
+ }
51
+ async multiselect(q) {
52
+ if (q.id in this.answers)
53
+ return this.answers[q.id];
54
+ const choices = q.choices.map((c) => isSeparator(c) ? new inquirer.Separator(c.separator) : { name: c.name, value: c.value, checked: c.checked });
55
+ const { value } = await inquirer.prompt([
56
+ { type: 'checkbox', name: 'value', message: q.message, choices: choices, pageSize: 20 },
57
+ ]);
58
+ return value;
59
+ }
60
+ async action(q) {
61
+ if (this.answers[q.id] === 'skip')
62
+ return false;
63
+ if (await q.check())
64
+ return true;
65
+ console.log(chalk.yellow(` ${q.message}`));
66
+ if (q.url)
67
+ console.log(` ${chalk.cyan(q.url)}`);
68
+ console.log(chalk.dim(' Waiting...'));
69
+ const started = Date.now();
70
+ const timeout = q.timeoutMs ?? 10 * 60 * 1000;
71
+ const interval = q.pollIntervalMs ?? 3000;
72
+ while (Date.now() - started < timeout) {
73
+ await sleep(interval);
74
+ if (await q.check())
75
+ return true;
76
+ }
77
+ return false;
78
+ }
79
+ progress(message, detail) {
80
+ process.stdout.write(`\r${chalk.dim(' ' + message)}${detail ? chalk.dim(' — ' + detail) : ''}\x1b[K`);
81
+ }
82
+ clearProgress() {
83
+ process.stdout.write('\r\x1b[K');
84
+ }
85
+ result(_data) {
86
+ // Human output is rendered inline by the step code; nothing structured to emit.
87
+ }
88
+ close() {
89
+ /* nothing to release */
90
+ }
91
+ }
92
+ // ---------------------------------------------------------------------------
93
+ // JSON (coding-agent) prompter
94
+ // ---------------------------------------------------------------------------
95
+ class JsonPrompter {
96
+ answers;
97
+ interactive = false;
98
+ rl = null;
99
+ pending = new Map();
100
+ buffered = new Map();
101
+ restoreConsole = null;
102
+ constructor(answers) {
103
+ this.answers = answers;
104
+ // Keep stdout pure NDJSON: route human chrome to stderr.
105
+ const origLog = console.log;
106
+ const origWarn = console.warn;
107
+ const origError = console.error;
108
+ const toErr = (...args) => process.stderr.write(args.map(stringifyArg).join(' ') + '\n');
109
+ console.log = toErr;
110
+ console.warn = toErr;
111
+ console.error = toErr;
112
+ this.restoreConsole = () => {
113
+ console.log = origLog;
114
+ console.warn = origWarn;
115
+ console.error = origError;
116
+ };
117
+ }
118
+ emit(event) {
119
+ process.stdout.write(JSON.stringify(event) + '\n');
120
+ }
121
+ /** Lazily open stdin only when we actually need an answer the agent must send. */
122
+ ensureStdin() {
123
+ if (this.rl)
124
+ return;
125
+ this.rl = readline.createInterface({ input: process.stdin });
126
+ this.rl.on('line', (line) => {
127
+ const trimmed = line.trim();
128
+ if (!trimmed)
129
+ return;
130
+ let obj;
131
+ try {
132
+ obj = JSON.parse(trimmed);
133
+ }
134
+ catch (err) {
135
+ // Surface malformed input on stderr (PR001: no silent swallow) so an
136
+ // agent can tell its line was rejected and resend, instead of the
137
+ // prompt hanging on input that never parsed. stderr keeps stdout NDJSON.
138
+ process.stderr.write(`haystack setup: ignoring unparseable stdin line: ${err instanceof Error ? err.message : String(err)}\n`);
139
+ return;
140
+ }
141
+ if (!obj || typeof obj !== 'object')
142
+ return;
143
+ // Mirror OpenCode: question replies are keyed by `requestID`, permission
144
+ // replies by `permissionID`. Legacy `id` accepted too.
145
+ const key = (obj.requestID ?? obj.permissionID ?? obj.id);
146
+ if (typeof key !== 'string')
147
+ return;
148
+ const resolve = this.pending.get(key);
149
+ if (resolve) {
150
+ this.pending.delete(key);
151
+ resolve(obj);
152
+ }
153
+ else {
154
+ this.buffered.set(key, obj);
155
+ }
156
+ });
157
+ }
158
+ /** Wait for the agent's reply object, keyed by the request/permission id. */
159
+ awaitReply(id) {
160
+ if (this.buffered.has(id)) {
161
+ const v = this.buffered.get(id);
162
+ this.buffered.delete(id);
163
+ return Promise.resolve(v);
164
+ }
165
+ this.ensureStdin();
166
+ return new Promise((resolve) => this.pending.set(id, resolve));
167
+ }
168
+ async confirm(q) {
169
+ if (q.id in this.answers)
170
+ return Boolean(this.answers[q.id]);
171
+ // A confirm is a single-choice question; emit OpenCode's `question` envelope.
172
+ // Reply: {requestID, answers: <bool>}; reject: {requestID, reject: true}.
173
+ this.emit({ type: 'question', requestID: q.id, kind: 'confirm', prompt: q.message, default: q.default ?? true });
174
+ const reply = await this.awaitReply(q.id);
175
+ if (reply.reject)
176
+ return false;
177
+ const a = reply.answers ?? reply.value; // `answers` is canonical; `value` accepted
178
+ return typeof a === 'boolean' ? a : Boolean(a);
179
+ }
180
+ async multiselect(q) {
181
+ if (q.id in this.answers)
182
+ return this.answers[q.id];
183
+ const options = q.choices
184
+ .filter((c) => !isSeparator(c))
185
+ .map((c) => ({ value: c.value, label: c.name, default: c.checked ?? false }));
186
+ // Reply: {requestID, answers: <value[]>}; reject: {requestID, reject: true}.
187
+ this.emit({ type: 'question', requestID: q.id, kind: 'multiselect', prompt: q.message, options });
188
+ const reply = await this.awaitReply(q.id);
189
+ if (reply.reject)
190
+ return [];
191
+ const a = reply.answers ?? reply.value;
192
+ return Array.isArray(a) ? a : [];
193
+ }
194
+ async action(q) {
195
+ if (this.answers[q.id] === 'skip')
196
+ return false;
197
+ if (await q.check())
198
+ return true;
199
+ // An out-of-band action maps to OpenCode's `permission` request. Reply:
200
+ // {permissionID, response: "once"|"always"|"reject"}. We also keep polling
201
+ // check() so it resolves on its own once the action completes.
202
+ this.emit({ type: 'permission', permissionID: q.id, prompt: q.message, url: q.url, poll: true });
203
+ const started = Date.now();
204
+ const timeout = q.timeoutMs ?? 10 * 60 * 1000;
205
+ const interval = q.pollIntervalMs ?? 3000;
206
+ let override = null;
207
+ this.ensureStdin();
208
+ const answerPromise = this.awaitReply(q.id).then((reply) => {
209
+ const r = (reply.response ?? reply.value);
210
+ override = r === 'reject' || r === 'skip' ? 'skip' : 'done';
211
+ });
212
+ while (Date.now() - started < timeout) {
213
+ if (await q.check())
214
+ return true;
215
+ if (override === 'skip')
216
+ return false;
217
+ // 'done' means the agent reports the action complete — but completion may
218
+ // be eventually-consistent, so keep polling check() at the interval until
219
+ // it confirms or we time out (matching TTY behavior), rather than failing
220
+ // on a single immediate check.
221
+ if (override === 'done') {
222
+ await sleep(interval);
223
+ continue;
224
+ }
225
+ await Promise.race([sleep(interval), answerPromise]);
226
+ }
227
+ return false;
228
+ }
229
+ progress(message, detail) {
230
+ this.emit({ type: 'progress', message, ...(detail ? { detail } : {}) });
231
+ }
232
+ clearProgress() {
233
+ /* no ephemeral line in JSON mode */
234
+ }
235
+ result(data) {
236
+ this.emit({ type: 'result', ...data });
237
+ }
238
+ close() {
239
+ this.rl?.close();
240
+ this.rl = null;
241
+ this.restoreConsole?.();
242
+ this.restoreConsole = null;
243
+ }
244
+ }
245
+ export function createPrompter(options = {}) {
246
+ const answers = options.answers ?? {};
247
+ return options.json ? new JsonPrompter(answers) : new TtyPrompter(answers);
248
+ }
249
+ function sleep(ms) {
250
+ return new Promise((resolve) => setTimeout(resolve, ms));
251
+ }
252
+ function stringifyArg(a) {
253
+ return typeof a === 'string' ? a : String(a);
254
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haystackeditor/cli",
3
- "version": "0.14.6",
3
+ "version": "0.15.1",
4
4
  "description": "Set up Haystack for your project — automated PR review, triage, and merge queue",
5
5
  "type": "module",
6
6
  "bin": {