@haystackeditor/cli 0.19.0 → 0.21.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.
@@ -1,152 +1,399 @@
1
1
  /**
2
- * haystack init - Interactive setup wizard
2
+ * haystack init — set a repository up for `haystack verify` in one command.
3
3
  *
4
- * Creates .haystack.json with auto-detected settings.
4
+ * There is no setup file to write or keep current: the service works out how to
5
+ * run the app from what the repository already keeps working (APP-ONBOARDING-V1).
6
+ * init makes at most two small project-local changes, shows each as a diff
7
+ * before writing, and writes them only with consent (--yes, or yes at the prompt):
8
+ * 1. Claude Code's Stop hook, `haystack verify precompute --hook`, in the
9
+ * per-developer .claude/settings.local.json (kept out of commits through
10
+ * .git/info/exclude), when Claude Code is in use here;
11
+ * 2. a short marked block in AGENTS.md (and in CLAUDE.md when the repository
12
+ * keeps one) telling coding agents to run `haystack verify` after a change.
13
+ * It then starts onboarding the app at the base `haystack verify` would use, so
14
+ * the app is usually ready by the first verify. Running it again changes only
15
+ * what is missing or out of date and shows where onboarding is.
16
+ *
17
+ * Exit codes: 0 set up (onboarding running or ready), 1 failed, 2 changes shown
18
+ * but not made (run with --yes), 3 onboarding blocked (the output says what
19
+ * would unblock it), 4 not logged in, 5 the Haystack GitHub App is not
20
+ * installed, 6 set up but onboarding stopped before finishing (run init again).
5
21
  */
22
+ import { execFileSync } from 'node:child_process';
23
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
24
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
25
+ import { createInterface } from 'node:readline/promises';
6
26
  import chalk from 'chalk';
7
- import * as fs from 'fs/promises';
8
- import * as path from 'path';
9
- import { detectProject } from '../utils/detect.js';
10
- import { parseRemoteUrl, UnsupportedRemoteUrlError } from '../utils/git.js';
11
- import { saveConfig, configExists } from '../utils/config.js';
12
- import { validateConfigSecurity, formatSecurityReport } from '../utils/secrets.js';
13
- export async function initCommand(options) {
14
- console.log(chalk.cyan('\n🌾 Haystack Setup\n'));
15
- // Check if config already exists
16
- if (await configExists() && !options.force) {
17
- console.log(chalk.yellow('.haystack.json already exists. Use --force to overwrite.\n'));
18
- return;
27
+ import { withSchema } from '../schema.js';
28
+ import { NotLoggedInError, resolveAuthContext } from '../utils/auth.js';
29
+ import { HAYSTACK_APP_INSTALL_URL, HaystackApiError, INSTALLATION_REQUIRED_CODE } from '../utils/haystack-api.js';
30
+ import { findGitRoot } from '../utils/hooks.js';
31
+ import { assertNoSymlinkOnPath } from '../utils/safe-write.js';
32
+ import { CLAUDE_LOCAL_SETTINGS, CLAUDE_SHARED_SETTINGS, HAYSTACK_PRECOMPUTE_HOOK_COMMAND, claudeEntries, claudeSettingsPath, isCLIInstalled, isGitIgnored, isInstalledGlobally, isPrecomputeCommand, readClaudeSettings, } from './install-session-hooks.js';
33
+ import { formatOnboarding, reportOnboardingState, startOnboarding } from './verify-onboarding.js';
34
+ import { deriveBaseSha, EXPLICIT_WALL_MS, resolveOriginRepository } from './verify-precompute.js';
35
+ const NOTE_BEGIN = '<!-- haystack:begin -->';
36
+ const NOTE_END = '<!-- haystack:end -->';
37
+ /** The agent instructions init keeps in AGENTS.md / CLAUDE.md, between its markers. */
38
+ export const AGENT_NOTE = [
39
+ NOTE_BEGIN,
40
+ '## Haystack',
41
+ '',
42
+ 'After you change this app\'s code, run `haystack verify` before you call the work done. It runs the app with and',
43
+ 'without your change, explores it, and reports where the change shows up and what broke.',
44
+ '',
45
+ '- It often takes 10 to 15 minutes. If your command times out, run it again: it picks up the same crawl.',
46
+ '- Fix each bug it reports that your change caused, then run `haystack verify` again.',
47
+ '- If it says onboarding needs an answer, ask the user that question and file their choice with',
48
+ ' `haystack verify answer <question-id> <choice>`.',
49
+ '- `haystack verify --json` prints one JSON document; `haystack verify --help` explains the output and exit codes.',
50
+ '- If `haystack` is not installed, run each command as `npx -y @haystackeditor/cli@latest <command>`.',
51
+ NOTE_END,
52
+ ].join('\n');
53
+ const EXIT_CODES = { planned: 2, 'login-required': 4, 'app-required': 5 };
54
+ /** init's exit once set up: 0 onboarding running or ready, 3 blocked, 6 stopped before finishing. */
55
+ function setUpExitCode(onboarding) {
56
+ if (onboarding.state === 'blocked')
57
+ return 3;
58
+ return onboarding.state === 'ready' || onboarding.state === 'onboarding' ? 0 : 6;
59
+ }
60
+ /* ------------------------------------------------------------------- plan */
61
+ function isPlainFile(path) {
62
+ try {
63
+ return lstatSync(path).isFile();
19
64
  }
20
- // Detect project
21
- console.log(chalk.dim('Detecting project configuration...\n'));
22
- const detected = await detectProject();
23
- // Show what was detected
24
- console.log(chalk.green('āœ“ Detected:'));
25
- console.log(` Package manager: ${chalk.bold(detected.packageManager)}`);
26
- if (detected.framework) {
27
- console.log(` Framework: ${chalk.bold(detected.framework)}`);
28
- }
29
- if (detected.isMonorepo) {
30
- console.log(` Monorepo: ${chalk.bold(detected.monorepoTool || 'yes')}`);
31
- if (detected.services?.length) {
32
- console.log(` Services: ${chalk.bold(detected.services.length)} packages detected`);
33
- }
65
+ catch (error) {
66
+ if (error.code === 'ENOENT')
67
+ return false;
68
+ throw error;
69
+ }
70
+ }
71
+ function isPlainDirectory(path) {
72
+ try {
73
+ return lstatSync(path).isDirectory();
34
74
  }
35
- console.log('');
36
- // Build config from detection (no interactive prompts)
37
- const config = await buildConfigFromDetection(detected);
38
- if (config.dev_server) {
39
- console.log(` Dev server: ${chalk.bold(config.dev_server.command)} (port ${config.dev_server.port})\n`);
75
+ catch (error) {
76
+ if (error.code === 'ENOENT')
77
+ return false;
78
+ throw error;
40
79
  }
41
- const configPath = await saveConfig(config);
42
- console.log(chalk.green(`āœ“ Created ${configPath}`));
43
- // Security validation
44
- await runSecurityCheck(configPath);
45
- console.log(chalk.green('\nDone! Run `haystack setup` to configure rules, signals, and policies.\n'));
46
80
  }
47
- /**
48
- * Run security check on config file
49
- */
50
- async function runSecurityCheck(configPath) {
51
- const { safe, findings } = await validateConfigSecurity(configPath);
52
- if (!safe) {
53
- console.log(chalk.yellow('\nāš ļø Security Warning:\n'));
54
- console.log(formatSecurityReport(findings));
55
- console.log(chalk.yellow('Please remove hardcoded secrets before committing.\n'));
56
- }
57
- else if (findings.length > 0) {
58
- console.log(chalk.dim('\n' + formatSecurityReport(findings)));
59
- }
60
- }
61
- async function buildConfigFromDetection(detected) {
62
- const env = {};
63
- if (detected.suggestedAuthBypass) {
64
- const [key, value] = detected.suggestedAuthBypass.split('=');
65
- env[key] = value || 'true';
66
- }
67
- const pkgJson = await readPackageJson();
68
- const name = resolveProjectName(pkgJson);
69
- if (detected.isMonorepo && detected.services?.length) {
70
- const services = {};
71
- for (const s of detected.services) {
72
- services[s.name] = {
73
- ...(s.root !== './' ? { root: s.root } : {}),
74
- command: s.suggestedCommand || 'pnpm dev',
75
- ...(s.type === 'server' && s.suggestedPort ? { port: s.suggestedPort } : {}),
76
- ...(s.type === 'batch' ? { type: 'batch' } : {}),
77
- ready_pattern: 'ready|started|listening|Local:',
78
- ...(Object.keys(env).length > 0 ? { env } : {}),
79
- ...(s.suggestedDependsOn?.length ? { depends_on: s.suggestedDependsOn } : {}),
80
- };
81
+ function linesOf(text) {
82
+ return text === '' ? [] : text.replace(/\n$/, '').split('\n');
83
+ }
84
+ /** The lines `after` adds to or removes from `before` (`+`/`-`), with one line of
85
+ * context (` `) around each run of changes and `…` where unchanged lines are skipped. */
86
+ export function lineDiff(before, after) {
87
+ const a = linesOf(before);
88
+ const b = linesOf(after);
89
+ let prefix = 0;
90
+ while (prefix < a.length && prefix < b.length && a[prefix] === b[prefix])
91
+ prefix += 1;
92
+ let suffix = 0;
93
+ while (suffix < a.length - prefix && suffix < b.length - prefix && a[a.length - 1 - suffix] === b[b.length - 1 - suffix])
94
+ suffix += 1;
95
+ const oldMiddle = a.slice(prefix, a.length - suffix);
96
+ const newMiddle = b.slice(prefix, b.length - suffix);
97
+ // Longest common subsequence of the differing middle; these are small files.
98
+ const lcs = Array.from({ length: oldMiddle.length + 1 }, () => new Array(newMiddle.length + 1).fill(0));
99
+ for (let i = oldMiddle.length - 1; i >= 0; i -= 1) {
100
+ for (let j = newMiddle.length - 1; j >= 0; j -= 1) {
101
+ lcs[i][j] = oldMiddle[i] === newMiddle[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
81
102
  }
82
- return {
83
- version: '1',
84
- name,
85
- services,
86
- };
87
103
  }
88
- const devServer = buildDevServer(detected, pkgJson?.scripts, env);
89
- return {
90
- version: '1',
91
- name,
92
- ...(devServer ? { dev_server: devServer } : {}),
93
- };
94
- }
95
- /**
96
- * Prefer the GitHub repo name, then package.json name, then the directory
97
- * name. The directory basename alone is often a worktree or clone-dir name
98
- * that has nothing to do with the project.
99
- */
100
- function resolveProjectName(pkgJson) {
101
- try {
102
- return parseRemoteUrl().repo;
103
- }
104
- catch (err) {
105
- // Classify by type, not message text (PR007). A remote that exists but
106
- // isn't recognized deserves a warning — the fallback name may not match
107
- // the repo. A missing remote/repo is normal for fresh projects.
108
- if (err instanceof UnsupportedRemoteUrlError) {
109
- console.log(chalk.yellow(`⚠ Git remote URL not recognized (${err.message}); using package.json/directory name instead`));
104
+ const ops = a.slice(0, prefix).map(line => ` ${line}`);
105
+ let i = 0;
106
+ let j = 0;
107
+ while (i < oldMiddle.length || j < newMiddle.length) {
108
+ if (i < oldMiddle.length && j < newMiddle.length && oldMiddle[i] === newMiddle[j]) {
109
+ ops.push(` ${oldMiddle[i]}`);
110
+ i += 1;
111
+ j += 1;
112
+ }
113
+ else if (j < newMiddle.length && (i === oldMiddle.length || lcs[i][j + 1] >= lcs[i + 1][j])) {
114
+ ops.push(`+${newMiddle[j]}`);
115
+ j += 1;
110
116
  }
111
117
  else {
112
- console.log(chalk.dim(' No git remote found; using package.json/directory name'));
118
+ ops.push(`-${oldMiddle[i]}`);
119
+ i += 1;
113
120
  }
114
121
  }
115
- return pkgJson?.name || path.basename(process.cwd());
122
+ ops.push(...a.slice(a.length - suffix).map(line => ` ${line}`));
123
+ const changed = (k) => ops[k] !== undefined && ops[k][0] !== ' ';
124
+ const shown = [];
125
+ for (let k = 0; k < ops.length; k += 1) {
126
+ if (changed(k) || changed(k - 1) || changed(k + 1))
127
+ shown.push(ops[k]);
128
+ else if (shown.length > 0 && shown[shown.length - 1] !== '…')
129
+ shown.push('…');
130
+ }
131
+ if (shown[shown.length - 1] === '…')
132
+ shown.pop();
133
+ return shown;
116
134
  }
117
- /**
118
- * Build a dev_server block for single-repo projects, but only when there is
119
- * real evidence of one: a dev/start script in package.json. Without that
120
- * gate we'd fabricate commands like `npm dev` that don't exist.
121
- */
122
- function buildDevServer(detected, scripts, env) {
123
- const script = scripts?.dev ? 'dev' : scripts?.start ? 'start' : null;
124
- if (!script)
125
- return undefined;
126
- const pm = detected.packageManager;
127
- // npm and bun need the `run` form; pnpm/yarn run scripts directly.
128
- const command = pm === 'npm' || pm === 'bun' ? `${pm} run ${script}` : `${pm} ${script}`;
135
+ function change(gitRoot, target, reason, content) {
136
+ const before = existsSync(target) ? readFileSync(target, 'utf8') : null;
137
+ if (before === content)
138
+ return null;
139
+ const inside = relative(gitRoot, target);
129
140
  return {
130
- command,
131
- port: detected.suggestedPort || 3000,
132
- ready_pattern: detected.suggestedReadyPattern || 'ready|started|listening|Local:',
133
- ...(Object.keys(env).length > 0 ? { env } : {}),
141
+ path: inside.startsWith('..') || isAbsolute(inside) ? target : inside,
142
+ action: before === null ? 'create' : 'update',
143
+ reason,
144
+ diff: lineDiff(before ?? '', content),
145
+ target,
146
+ content,
134
147
  };
135
148
  }
136
- async function readPackageJson() {
149
+ function git(gitRoot, args) {
150
+ return execFileSync('git', args, { cwd: gitRoot, encoding: 'utf8' }).trim();
151
+ }
152
+ /** The ignore rule git applies to `path` (`source` as git names it), or null when none matches. */
153
+ function ignoreRule(gitRoot, path) {
154
+ let reported;
137
155
  try {
138
- const raw = await fs.readFile(path.join(process.cwd(), 'package.json'), 'utf-8');
139
- return JSON.parse(raw);
140
- }
141
- catch (err) {
142
- // A missing package.json is normal (non-Node projects). Anything else —
143
- // unreadable file, malformed JSON — degrades name resolution and
144
- // dev-server detection, so say so instead of silently falling back.
145
- if (err.code === 'ENOENT') {
156
+ reported = git(gitRoot, ['check-ignore', '-v', '--no-index', '--', path]);
157
+ }
158
+ catch (error) {
159
+ // Exit status 1: no rule matches.
160
+ if (error.status === 1)
146
161
  return null;
162
+ throw error;
163
+ }
164
+ const rule = /^(.*):\d+:(.*)\t/.exec(reported);
165
+ if (!rule)
166
+ throw new Error(`Could not read git's ignore rule for ${path}: ${reported}`);
167
+ return { source: rule[1], pattern: rule[2] };
168
+ }
169
+ /** Claude Code's Stop hook in the per-developer settings, and the exclude line that keeps that file out of commits. */
170
+ function planStopHook(gitRoot, notices) {
171
+ if (!isPlainDirectory(join(gitRoot, '.claude')) && !isCLIInstalled('claude'))
172
+ return [];
173
+ const settingsPath = `.claude/${CLAUDE_LOCAL_SETTINGS}`;
174
+ const localPath = claudeSettingsPath(gitRoot, CLAUDE_LOCAL_SETTINGS);
175
+ if (git(gitRoot, ['ls-files', '--', settingsPath]) !== '') {
176
+ // A hook that uploads working-tree snapshots is each developer's own opt-in, never inherited from a commit.
177
+ notices.push(`${settingsPath} is committed to the repository, so init left it alone: a hook there would run for everyone who pulls.`
178
+ + ` To add Claude Code's Stop hook, untrack it (\`git rm --cached ${settingsPath}\`) and run \`haystack init\` again.`);
179
+ return [];
180
+ }
181
+ // .gitignore outranks .git/info/exclude, so an un-ignore rule there would put the hook in commits whatever init adds.
182
+ const rule = ignoreRule(gitRoot, settingsPath);
183
+ if (rule !== null && rule.pattern.startsWith('!') && basename(rule.source) === '.gitignore') {
184
+ notices.push(`${rule.source} un-ignores ${settingsPath} (${rule.pattern}), so init cannot keep it out of commits and left it alone.`
185
+ + ' Remove that rule and run `haystack init` again to add Claude Code\'s Stop hook.');
186
+ return [];
187
+ }
188
+ // A settings file init cannot parse costs only the hook: the agent note and onboarding still go ahead.
189
+ const settings = {};
190
+ for (const [name, file] of [['local', CLAUDE_LOCAL_SETTINGS], ['shared', CLAUDE_SHARED_SETTINGS]]) {
191
+ try {
192
+ settings[name] = readClaudeSettings(claudeSettingsPath(gitRoot, file));
147
193
  }
148
- const message = err instanceof Error ? err.message : String(err);
149
- console.log(chalk.yellow(`⚠ Could not read package.json (${message}); skipping dev-server detection`));
150
- return null;
194
+ catch (error) {
195
+ notices.push(`.claude/${file} is not valid JSON (${error instanceof Error ? error.message : String(error)}), so init left`
196
+ + ' Claude Code\'s Stop hook out. Fix the file and run `haystack init` again to add it.');
197
+ return [];
198
+ }
199
+ }
200
+ const { local, shared } = settings;
201
+ const changes = [];
202
+ // Claude Code merges both files: a hook either one carries is installed.
203
+ const installed = [...claudeEntries(local, 'Stop'), ...claudeEntries(shared, 'Stop')].some(entry => entry.hooks?.some(isPrecomputeCommand));
204
+ if (!installed && !isInstalledGlobally('haystack')) {
205
+ notices.push('Claude Code\'s Stop hook runs `haystack`, which is not installed globally here (this run came through npx or a project\'s own copy), so init did not add it.'
206
+ + ' Install it with `npm install -g @haystackeditor/cli` and run `haystack init` again to add the hook.');
207
+ }
208
+ else if (!installed) {
209
+ const settings = structuredClone(local ?? {});
210
+ const hooks = (settings.hooks ?? {});
211
+ settings.hooks = hooks;
212
+ const entry = { matcher: '', hooks: [{ type: 'command', command: HAYSTACK_PRECOMPUTE_HOOK_COMMAND }] };
213
+ hooks.Stop = [...claudeEntries(settings, 'Stop'), entry];
214
+ const content = `${JSON.stringify(settings, null, 2)}\n`;
215
+ changes.push({
216
+ path: settingsPath,
217
+ action: local === undefined ? 'create' : 'update',
218
+ reason: 'Claude Code prepares each change for `haystack verify` when a session stops (this checkout only).',
219
+ // An existing file is never echoed: personal settings can hold tokens. Only the entry init adds is shown.
220
+ diff: local === undefined ? lineDiff('', content)
221
+ : [' … existing settings, kept as they are', '+ hooks.Stop gains:', ...JSON.stringify(entry, null, 2).split('\n').map(line => `+${line}`)],
222
+ target: localPath,
223
+ content,
224
+ });
225
+ }
226
+ if ((changes.length > 0 || existsSync(localPath)) && !isGitIgnored(gitRoot, settingsPath)) {
227
+ const excludePath = resolve(gitRoot, git(gitRoot, ['rev-parse', '--git-path', 'info/exclude']));
228
+ const before = existsSync(excludePath) ? readFileSync(excludePath, 'utf8') : '';
229
+ const line = `/${settingsPath}`;
230
+ // A line already there is outranked by an un-ignore rule in the same file after it; adding it again would not help.
231
+ const excluded = before.split('\n').includes(line) ? null : change(gitRoot, excludePath, 'Keeps the per-developer Claude Code settings out of commits.', `${before}${before === '' || before.endsWith('\n') ? '' : '\n'}${line}\n`);
232
+ if (excluded)
233
+ changes.push(excluded);
234
+ }
235
+ return changes;
236
+ }
237
+ function sameFile(a, b) {
238
+ try {
239
+ return realpathSync(a) === realpathSync(b);
240
+ }
241
+ catch (error) {
242
+ if (error.code === 'ENOENT')
243
+ return false;
244
+ throw error;
245
+ }
246
+ }
247
+ /** The agent note in AGENTS.md (which Codex, Cursor and most agents read), and in
248
+ * CLAUDE.md when the repository keeps one that does not import AGENTS.md. */
249
+ function planAgentNotes(gitRoot, notices) {
250
+ const agents = join(gitRoot, 'AGENTS.md');
251
+ const claude = join(gitRoot, 'CLAUDE.md');
252
+ const targets = [];
253
+ if (isPlainFile(agents) || !(existsSync(agents) || isLink(agents)))
254
+ targets.push('AGENTS.md');
255
+ if (isPlainFile(claude) && !/^@AGENTS\.md\b/m.test(readFileSync(claude, 'utf8')))
256
+ targets.push('CLAUDE.md');
257
+ if (!targets.includes('AGENTS.md') && !(targets.includes('CLAUDE.md') && sameFile(agents, claude))) {
258
+ notices.push(`AGENTS.md is not a plain file, so init left it alone. Add this note where your agents read it:\n${AGENT_NOTE}`);
259
+ }
260
+ return targets.flatMap(name => {
261
+ const target = join(gitRoot, name);
262
+ const before = existsSync(target) ? readFileSync(target, 'utf8') : '';
263
+ const begin = before.indexOf(NOTE_BEGIN);
264
+ const end = begin === -1 ? -1 : before.indexOf(NOTE_END, begin);
265
+ const after = begin !== -1 && end !== -1
266
+ ? `${before.slice(0, begin)}${AGENT_NOTE}${before.slice(end + NOTE_END.length)}`
267
+ : before.trim() === '' ? `${AGENT_NOTE}\n`
268
+ : `${before}${before.endsWith('\n') ? '' : '\n'}\n${AGENT_NOTE}\n`;
269
+ const planned = change(gitRoot, target, 'Tells coding agents to run `haystack verify` after a change.', after);
270
+ return planned ? [planned] : [];
271
+ });
272
+ }
273
+ function isLink(path) {
274
+ try {
275
+ return lstatSync(path).isSymbolicLink();
276
+ }
277
+ catch (error) {
278
+ if (error.code === 'ENOENT')
279
+ return false;
280
+ throw error;
281
+ }
282
+ }
283
+ /** Everything init would change in this checkout; reads only. */
284
+ export function planInit(gitRoot) {
285
+ const notices = [];
286
+ return { changes: [...planStopHook(gitRoot, notices), ...planAgentNotes(gitRoot, notices)], notices };
287
+ }
288
+ function applyChange(gitRoot, planned) {
289
+ const inside = relative(gitRoot, planned.target);
290
+ if (!inside.startsWith('..') && !isAbsolute(inside))
291
+ assertNoSymlinkOnPath(gitRoot, planned.target);
292
+ else if (isLink(planned.target))
293
+ throw new Error(`Refusing to write ${planned.target}: it is a symlink.`);
294
+ mkdirSync(dirname(planned.target), { recursive: true });
295
+ writeFileSync(planned.target, planned.content);
296
+ }
297
+ /* -------------------------------------------------------------- command */
298
+ function colored(line) {
299
+ if (line.startsWith('+'))
300
+ return chalk.green(line);
301
+ if (line.startsWith('-'))
302
+ return chalk.red(line);
303
+ return chalk.dim(line);
304
+ }
305
+ async function confirmed() {
306
+ const prompt = createInterface({ input: process.stdin, output: process.stdout });
307
+ try {
308
+ return /^y(es)?$/i.test((await prompt.question('Make these changes? [y/N] ')).trim());
309
+ }
310
+ finally {
311
+ prompt.close();
312
+ }
313
+ }
314
+ export async function initCommand(options) {
315
+ // In --json mode stdout carries the one document; every other line goes to stderr.
316
+ const say = (line = '') => { if (options.json)
317
+ console.error(line);
318
+ else
319
+ console.log(line); };
320
+ const gitRoot = findGitRoot();
321
+ if (!gitRoot)
322
+ throw new Error('Run `haystack init` inside a checkout of your app\'s repository.');
323
+ let origin;
324
+ try {
325
+ origin = resolveOriginRepository(gitRoot);
326
+ }
327
+ catch {
328
+ throw new Error('This checkout has no GitHub `origin` remote. Haystack verifies GitHub repositories: add the repository as `origin`, then run `haystack init` again.');
329
+ }
330
+ const repository = `${origin.owner}/${origin.repository}`;
331
+ const plan = planInit(gitRoot);
332
+ const finish = (status, onboarding, exitCode) => {
333
+ if (options.json) {
334
+ const changes = plan.changes.map(({ path, action, reason, diff }) => ({ path, action, reason, diff }));
335
+ process.stdout.write(`${JSON.stringify(withSchema('init', { status, repository, changes, notices: plan.notices, onboarding }), null, 2)}\n`);
336
+ }
337
+ process.exitCode = exitCode;
338
+ };
339
+ say(chalk.bold(`Haystack setup for ${repository}`));
340
+ if (plan.changes.length === 0)
341
+ say(chalk.dim('Nothing in this checkout needs changing.'));
342
+ for (const planned of plan.changes) {
343
+ say('');
344
+ say(`${chalk.bold(planned.action === 'create' ? 'Create' : 'Update')} ${planned.path}: ${planned.reason}`);
345
+ for (const line of planned.diff)
346
+ say(` ${colored(line)}`);
347
+ }
348
+ for (const notice of plan.notices)
349
+ say(`\n${chalk.yellow(notice)}`);
350
+ say('');
351
+ let token;
352
+ try {
353
+ token = (await resolveAuthContext({ owner: origin.owner, repo: origin.repository })).token;
354
+ }
355
+ catch (error) {
356
+ if (!(error instanceof NotLoggedInError))
357
+ throw error;
358
+ say(chalk.yellow('Not logged in, so nothing was changed. Run `haystack login`, then `haystack init` again.'));
359
+ finish('login-required', null, EXIT_CODES['login-required']);
360
+ return;
361
+ }
362
+ if (plan.changes.length > 0 && !options.yes) {
363
+ const interactive = !options.json && process.stdin.isTTY === true && process.stdout.isTTY === true;
364
+ if (!interactive || !await confirmed()) {
365
+ say('Nothing was changed. Run `haystack init --yes` to make these changes.');
366
+ finish('planned', null, EXIT_CODES.planned);
367
+ return;
368
+ }
369
+ }
370
+ for (const planned of plan.changes) {
371
+ applyChange(gitRoot, planned);
372
+ say(chalk.green(`āœ“ ${planned.action === 'create' ? 'Created' : 'Updated'} ${planned.path}`));
373
+ }
374
+ let onboarding;
375
+ try {
376
+ onboarding = await startOnboarding(repository, deriveBaseSha(gitRoot, Date.now() + EXPLICIT_WALL_MS), token);
377
+ }
378
+ catch (error) {
379
+ if (error instanceof HaystackApiError && error.code === INSTALLATION_REQUIRED_CODE) {
380
+ say(chalk.yellow(`Haystack needs its GitHub App on ${origin.owner} to read the repository. Install it at`));
381
+ say(` ${HAYSTACK_APP_INSTALL_URL}`);
382
+ say('then run `haystack init` again.');
383
+ finish('app-required', null, EXIT_CODES['app-required']);
384
+ return;
385
+ }
386
+ const done = plan.changes.length > 0 ? 'The changes above were made, but onboarding' : 'Onboarding';
387
+ throw new Error(`${done} could not start: ${error instanceof Error ? error.message : String(error)} Run \`haystack init\` again to start it.`);
388
+ }
389
+ reportOnboardingState(onboarding);
390
+ say(formatOnboarding(onboarding));
391
+ say('');
392
+ if (onboarding.state === 'ready')
393
+ say(chalk.green('Ready. After your next change, run `haystack verify`.'));
394
+ else if (onboarding.state === 'onboarding') {
395
+ say(chalk.green('Haystack is preparing the app in the background; the first time can take half an hour.'));
396
+ say('Keep working: `haystack verify` waits for it when needed, and `haystack verify onboarding --wait` follows it.');
151
397
  }
398
+ finish('done', onboarding, setUpExitCode(onboarding));
152
399
  }
@@ -14,13 +14,13 @@
14
14
  import { execFileSync } from 'child_process';
15
15
  import chalk from 'chalk';
16
16
  import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'fs';
17
- import { join } from 'path';
17
+ import { join, sep } from 'path';
18
18
  import { findGitRoot } from '../utils/git.js';
19
19
  import { assertNoSymlinkOnPath } from '../utils/safe-write.js';
20
20
  // ============================================================================
21
21
  // CLI detection (reused from triage runner pattern)
22
22
  // ============================================================================
23
- function isCLIInstalled(name) {
23
+ export function isCLIInstalled(name) {
24
24
  try {
25
25
  execFileSync(process.platform === 'win32' ? 'where' : 'which', [name], {
26
26
  stdio: 'ignore',
@@ -31,6 +31,23 @@ function isCLIInstalled(name) {
31
31
  return false;
32
32
  }
33
33
  }
34
+ /** Whether `name` is installed where a command run later (a Claude Code hook)
35
+ * finds it. Anything under a node_modules directory is left out: npx, pnpm dlx,
36
+ * bunx and npm's own project .bin are put on PATH for one run only. */
37
+ export function isInstalledGlobally(name) {
38
+ const windows = process.platform === 'win32';
39
+ let found;
40
+ try {
41
+ found = execFileSync(windows ? 'where' : 'which', windows ? [name] : ['-a', name], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
42
+ }
43
+ catch (error) {
44
+ // Exit status 1: nothing by that name on PATH.
45
+ if (error.status === 1)
46
+ return false;
47
+ throw error;
48
+ }
49
+ return found.split(/\r?\n/).some(path => path !== '' && !path.includes(`${sep}node_modules${sep}`));
50
+ }
34
51
  function detectInstalledCLIs() {
35
52
  const installed = [];
36
53
  if (isCLIInstalled('claude'))
@@ -45,27 +62,27 @@ function detectInstalledCLIs() {
45
62
  // Claude Code: Native SessionStart hook
46
63
  // ============================================================================
47
64
  const HAYSTACK_HOOK_COMMAND = 'haystack triage --hook';
48
- const HAYSTACK_PRECOMPUTE_HOOK_COMMAND = 'haystack verify precompute --hook';
65
+ export const HAYSTACK_PRECOMPUTE_HOOK_COMMAND = 'haystack verify precompute --hook';
49
66
  // The per-developer file is the default: a hook that uploads working-tree
50
67
  // snapshots must be opted into by each developer, not inherited from a commit.
51
- const CLAUDE_LOCAL_SETTINGS = 'settings.local.json';
52
- const CLAUDE_SHARED_SETTINGS = 'settings.json';
53
- function claudeSettingsPath(gitRoot, fileName) {
68
+ export const CLAUDE_LOCAL_SETTINGS = 'settings.local.json';
69
+ export const CLAUDE_SHARED_SETTINGS = 'settings.json';
70
+ export function claudeSettingsPath(gitRoot, fileName) {
54
71
  return join(gitRoot, '.claude', fileName);
55
72
  }
56
- function readClaudeSettings(settingsPath) {
73
+ export function readClaudeSettings(settingsPath) {
57
74
  if (!existsSync(settingsPath))
58
75
  return undefined;
59
76
  return JSON.parse(readFileSync(settingsPath, 'utf-8'));
60
77
  }
61
- function claudeEntries(settings, event) {
78
+ export function claudeEntries(settings, event) {
62
79
  const hooks = settings?.hooks;
63
80
  const entries = hooks?.[event];
64
81
  return Array.isArray(entries) ? entries : [];
65
82
  }
66
83
  const isTriageCommand = (h) => Boolean(h.command?.includes('haystack triage') || h.command?.includes('haystack check-pending'));
67
- const isPrecomputeCommand = (h) => Boolean(h.command?.includes(HAYSTACK_PRECOMPUTE_HOOK_COMMAND));
68
- function isGitIgnored(gitRoot, path) {
84
+ export const isPrecomputeCommand = (h) => Boolean(h.command?.includes(HAYSTACK_PRECOMPUTE_HOOK_COMMAND));
85
+ export function isGitIgnored(gitRoot, path) {
69
86
  try {
70
87
  execFileSync('git', ['check-ignore', '-q', '--', path], { cwd: gitRoot, stdio: 'ignore' });
71
88
  return true;