@haystackeditor/cli 0.15.10 → 0.15.12

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.
package/dist/index.js CHANGED
@@ -22,6 +22,7 @@
22
22
  import { readFileSync } from 'node:fs';
23
23
  import { fileURLToPath } from 'node:url';
24
24
  import { dirname, join } from 'node:path';
25
+ import chalk from 'chalk';
25
26
  import { Command } from 'commander';
26
27
  import { statusCommand } from './commands/status.js';
27
28
  import { initCommand } from './commands/init.js';
@@ -37,6 +38,16 @@ import { dismissCommand, markReviewedCommand, undismissCommand } from './command
37
38
  import { requestReviewCommand } from './commands/request-review.js';
38
39
  import { prStatusCommand } from './commands/pr-status.js';
39
40
  import { setupCommand } from './commands/setup.js';
41
+ import { schemaCommand, listSchemas } from './commands/schema-cmd.js';
42
+ import { registerWebhook, listWebhooks, rotateWebhookSecret, setWebhookEnabled, listDeliveries, replayDelivery, } from './commands/webhooks.js';
43
+ import { editRulesCommand, validateRulesCommand } from './commands/rules.js';
44
+ import { headlessLoginCommand, headlessLogoutCommand, listTokensCommand, revokeTokenCommand, } from './commands/tokens.js';
45
+ import { runMcpServer } from './commands/mcp.js';
46
+ // pr-state read helpers (parsePrRef, prReadCommand) are imported by the MCP
47
+ // server directly from ./commands/pr.js; no top-level CLI command is wired
48
+ // here. The previous `pr <ref> [show|merge|snooze|dismiss]` and `fix <ref>`
49
+ // surfaces were scope creep on the CLI-as-platform spec; `haystack pr-status`
50
+ // already covered the read use case.
40
51
  /** Read the published version from package.json (dist/index.js → ../package.json)
41
52
  * so `haystack --version` can't drift from the package version. Falls back
42
53
  * gracefully so the flag never throws if the file can't be read. */
@@ -58,6 +69,16 @@ program
58
69
  .name('haystack')
59
70
  .description('Haystack CLI — automated PR review, triage, and merge queue')
60
71
  .version(getVersion());
72
+ program
73
+ .command('schema [name]')
74
+ .description('Print the JSON Schema for a command\'s --json output (triage, setup, inbox, pr, chat)')
75
+ .action((name) => {
76
+ if (!name) {
77
+ listSchemas();
78
+ return;
79
+ }
80
+ schemaCommand(name);
81
+ });
61
82
  program
62
83
  .command('init')
63
84
  .description('Create .haystack.json configuration')
@@ -122,12 +143,68 @@ program
122
143
  .action(statusCommand);
123
144
  program
124
145
  .command('login')
125
- .description('Add or refresh a saved GitHub account')
126
- .action(loginCommand);
146
+ .description('Add or refresh a saved GitHub account (use --headless for CI tokens)')
147
+ .option('--headless', 'Mint a long-lived hsk_live_* token instead of an interactive OAuth session')
148
+ .option('--label <name>', '(--headless) Label to display in the dashboard for this token')
149
+ .option('--scope <repos>', '(--headless) Comma-separated repos to scope the token to (or "all")', (val) => val.split(',').map((s) => s.trim()).filter(Boolean))
150
+ .option('--token <hsk_live_...>', '(--headless) Skip the browser handshake — paste a token minted in the dashboard')
151
+ .action(async (opts) => {
152
+ if (opts.headless) {
153
+ try {
154
+ await headlessLoginCommand({ label: opts.label, scope: opts.scope, token: opts.token });
155
+ }
156
+ catch (err) {
157
+ console.error(chalk.red('headless login failed:'), err instanceof Error ? err.message : err);
158
+ process.exit(1);
159
+ }
160
+ return;
161
+ }
162
+ return loginCommand();
163
+ });
127
164
  program
128
165
  .command('logout [login]')
129
- .description('Remove a saved GitHub account (defaults to the active account)')
130
- .action(logoutCommand);
166
+ .description('Remove a saved GitHub account (use --headless to remove the local CLI token)')
167
+ .option('--headless', 'Remove the local hsk_live_* token file (does not revoke server-side — use `haystack tokens revoke <id>`)')
168
+ .action(async (login, opts) => {
169
+ if (opts.headless) {
170
+ try {
171
+ await headlessLogoutCommand();
172
+ }
173
+ catch (err) {
174
+ console.error(chalk.red('headless logout failed:'), err instanceof Error ? err.message : err);
175
+ process.exit(1);
176
+ }
177
+ return;
178
+ }
179
+ return logoutCommand(login);
180
+ });
181
+ // ─── tokens (server-side hsk_live_*) ─────────────────────────────────────────
182
+ const tokens = program.command('tokens').description('Manage server-side hsk_live_* tokens');
183
+ tokens
184
+ .command('list')
185
+ .description('List your CLI tokens')
186
+ .option('--json', 'Machine-readable output')
187
+ .action(async (opts) => {
188
+ try {
189
+ await listTokensCommand(opts);
190
+ }
191
+ catch (err) {
192
+ console.error(chalk.red('list failed:'), err instanceof Error ? err.message : err);
193
+ process.exit(1);
194
+ }
195
+ });
196
+ tokens
197
+ .command('revoke <id>')
198
+ .description('Revoke a token by id (cannot be undone)')
199
+ .action(async (id) => {
200
+ try {
201
+ await revokeTokenCommand(id);
202
+ }
203
+ catch (err) {
204
+ console.error(chalk.red('revoke failed:'), err instanceof Error ? err.message : err);
205
+ process.exit(1);
206
+ }
207
+ });
131
208
  program
132
209
  .command('whoami')
133
210
  .description('Show the active Haystack GitHub account')
@@ -606,6 +683,148 @@ The generated policies appear in Haystack's "Human Review Needed"
606
683
  section when a PR touches matching files.
607
684
  `)
608
685
  .action(initPolicies);
686
+ // ─── webhooks ────────────────────────────────────────────────────────────────
687
+ //
688
+ // `haystack webhooks ...` — register outbound webhook subscriptions and inspect
689
+ // delivery status. Talks to the haystack-webhooks worker.
690
+ //
691
+ // See docs/SPEC-CLI-FIRST-CLASS.md §1 for the wire protocol (HMAC, retries,
692
+ // idempotency).
693
+ const webhooks = program
694
+ .command('webhooks')
695
+ .description('Manage outbound webhooks (events: triage.completed, fixer.completed, ...)');
696
+ webhooks
697
+ .command('register')
698
+ .description('Register a webhook URL for the current repo and mint a fresh HMAC secret')
699
+ .requiredOption('-u, --url <url>', 'Receiver URL (https only)')
700
+ .option('-e, --events <events>', 'Comma-separated event list', (val) => val.split(',').map((s) => s.trim()).filter(Boolean))
701
+ .option('--secret-env <name>', 'Environment-variable name where you\'ll stash the secret (recorded in .haystack.json)')
702
+ .option('--repo <owner/name>', 'Override the inferred repo')
703
+ .action(async (opts) => {
704
+ try {
705
+ await registerWebhook(opts);
706
+ }
707
+ catch (err) {
708
+ console.error(chalk.red('register failed:'), err instanceof Error ? err.message : err);
709
+ process.exit(1);
710
+ }
711
+ });
712
+ webhooks
713
+ .command('list')
714
+ .description('List active webhook registrations for the current repo')
715
+ .option('--repo <owner/name>', 'Override the inferred repo')
716
+ .option('--json', 'Machine-readable JSON output')
717
+ .action(async (opts) => {
718
+ try {
719
+ await listWebhooks(opts);
720
+ }
721
+ catch (err) {
722
+ console.error(chalk.red('list failed:'), err instanceof Error ? err.message : err);
723
+ process.exit(1);
724
+ }
725
+ });
726
+ webhooks
727
+ .command('rotate-secret <id>')
728
+ .description('Rotate the HMAC secret for a registration (prints the new secret once)')
729
+ .action(async (id) => {
730
+ try {
731
+ await rotateWebhookSecret(id);
732
+ }
733
+ catch (err) {
734
+ console.error(chalk.red('rotate-secret failed:'), err instanceof Error ? err.message : err);
735
+ process.exit(1);
736
+ }
737
+ });
738
+ webhooks
739
+ .command('disable <id>')
740
+ .description('Disable a registration (stops delivery; existing in-flight retries continue)')
741
+ .action(async (id) => {
742
+ try {
743
+ await setWebhookEnabled(id, false);
744
+ }
745
+ catch (err) {
746
+ console.error(chalk.red('disable failed:'), err instanceof Error ? err.message : err);
747
+ process.exit(1);
748
+ }
749
+ });
750
+ webhooks
751
+ .command('enable <id>')
752
+ .description('Re-enable a previously disabled registration')
753
+ .action(async (id) => {
754
+ try {
755
+ await setWebhookEnabled(id, true);
756
+ }
757
+ catch (err) {
758
+ console.error(chalk.red('enable failed:'), err instanceof Error ? err.message : err);
759
+ process.exit(1);
760
+ }
761
+ });
762
+ webhooks
763
+ .command('deliveries')
764
+ .description('Show recent webhook delivery attempts for the current repo')
765
+ .option('--repo <owner/name>', 'Override the inferred repo')
766
+ .option('--limit <n>', 'Number of deliveries (default 20)', (v) => parseInt(v, 10))
767
+ .option('--json', 'Machine-readable JSON output')
768
+ .action(async (opts) => {
769
+ try {
770
+ await listDeliveries(opts);
771
+ }
772
+ catch (err) {
773
+ console.error(chalk.red('deliveries failed:'), err instanceof Error ? err.message : err);
774
+ process.exit(1);
775
+ }
776
+ });
777
+ webhooks
778
+ .command('replay <id>')
779
+ .description('Replay a previous delivery (idempotent via X-Haystack-Delivery)')
780
+ .action(async (id) => {
781
+ try {
782
+ await replayDelivery(id);
783
+ }
784
+ catch (err) {
785
+ console.error(chalk.red('replay failed:'), err instanceof Error ? err.message : err);
786
+ process.exit(1);
787
+ }
788
+ });
789
+ // ─── rules ───────────────────────────────────────────────────────────────────
790
+ const rules = program.command('rules').description('Manage .haystack/pr-rules.yml');
791
+ rules
792
+ .command('edit')
793
+ .description('Open .haystack/pr-rules.yml in $EDITOR (creates a starter if missing)')
794
+ .action(async () => {
795
+ try {
796
+ await editRulesCommand();
797
+ }
798
+ catch (err) {
799
+ console.error(chalk.red('edit failed:'), err instanceof Error ? err.message : err);
800
+ process.exit(1);
801
+ }
802
+ });
803
+ rules
804
+ .command('validate')
805
+ .description('Parse pr-rules.yml with the same parser the server uses')
806
+ .action(async () => {
807
+ try {
808
+ await validateRulesCommand();
809
+ }
810
+ catch (err) {
811
+ console.error(chalk.red('validate failed:'), err instanceof Error ? err.message : err);
812
+ process.exit(1);
813
+ }
814
+ });
815
+ // ─── mcp ─────────────────────────────────────────────────────────────────────
816
+ program
817
+ .command('mcp')
818
+ .description('Run a stdio MCP server exposing Haystack tools to Claude Code, Cursor, etc.')
819
+ .action(async () => {
820
+ try {
821
+ await runMcpServer();
822
+ }
823
+ catch (err) {
824
+ console.error(chalk.red('mcp server failed:'), err instanceof Error ? err.message : err);
825
+ process.exit(1);
826
+ }
827
+ });
609
828
  // Show help if no command provided
610
829
  if (process.argv.length === 2) {
611
830
  program.help();
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Stable machine-readable schema versions for each `--json` output.
3
+ *
4
+ * Bump semantics (per docs/SPEC-CLI-FIRST-CLASS.md):
5
+ * patch — additive fields only
6
+ * minor — field deprecated (kept emitting for one minor with _deprecated: true)
7
+ * major — removal / rename
8
+ *
9
+ * One schema, two transports: every webhook payload for the corresponding noun
10
+ * uses the same envelope as the `--json` output, byte-for-byte.
11
+ */
12
+ export declare const SCHEMA_VERSIONS: {
13
+ readonly triage: "1.0.0";
14
+ readonly setup: "1.0.0";
15
+ readonly pr: "1.0.0";
16
+ };
17
+ export type SchemaName = keyof typeof SCHEMA_VERSIONS;
18
+ /** Wrap a payload with the `schema_version` envelope. */
19
+ export declare function withSchema<T extends Record<string, unknown>>(name: SchemaName, payload: T): T & {
20
+ schema_version: string;
21
+ };
package/dist/schema.js ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Stable machine-readable schema versions for each `--json` output.
3
+ *
4
+ * Bump semantics (per docs/SPEC-CLI-FIRST-CLASS.md):
5
+ * patch — additive fields only
6
+ * minor — field deprecated (kept emitting for one minor with _deprecated: true)
7
+ * major — removal / rename
8
+ *
9
+ * One schema, two transports: every webhook payload for the corresponding noun
10
+ * uses the same envelope as the `--json` output, byte-for-byte.
11
+ */
12
+ export const SCHEMA_VERSIONS = {
13
+ triage: '1.0.0',
14
+ setup: '1.0.0',
15
+ pr: '1.0.0',
16
+ };
17
+ /** Wrap a payload with the `schema_version` envelope. */
18
+ export function withSchema(name, payload) {
19
+ return { schema_version: SCHEMA_VERSIONS[name], ...payload };
20
+ }
@@ -10,6 +10,7 @@ import { join } from 'path';
10
10
  import chalk from 'chalk';
11
11
  import { buildCodeReviewPrompt, buildRulesValidatorPrompt, buildIntentDriftPrompt } from './prompts.js';
12
12
  import { findRelevantTraces } from './traces.js';
13
+ import { resolveDiffBaseRef } from '../utils/git.js';
13
14
  import { trackError } from '../utils/telemetry.js';
14
15
  // ============================================================================
15
16
  // Constants
@@ -272,11 +273,20 @@ export async function runTriage(gitRoot, baseBranch, options = {}) {
272
273
  rmSync(triageDir, { recursive: true });
273
274
  }
274
275
  mkdirSync(triageDir, { recursive: true });
276
+ // Resolve the ref to diff against once. Prefers origin/<base> over the bare
277
+ // local <base> ref (which is often stale and balloons the diff with already-
278
+ // merged code). Every git read below — the precomputed diff, the agent diff
279
+ // commands in the prompts, and the changed-file scan in findRelevantTraces —
280
+ // uses this single resolved ref so they all see the same fork point.
281
+ const diffBaseRef = resolveDiffBaseRef(baseBranch);
282
+ if (diffBaseRef !== baseBranch) {
283
+ console.log(chalk.dim(` Diff base: ${diffBaseRef}`));
284
+ }
275
285
  // Pre-compute the diff once so sub-agents don't waste turns running git diff.
276
286
  // Includes 10 lines of context around each hunk for reviewability.
277
287
  let precomputedDiff = null;
278
288
  try {
279
- const diffOutput = execSync(`git diff ${baseBranch}...HEAD -U10`, { cwd: gitRoot, encoding: 'utf-8', maxBuffer: 5 * 1024 * 1024 }).trim();
289
+ const diffOutput = execSync(`git diff ${diffBaseRef}...HEAD -U10`, { cwd: gitRoot, encoding: 'utf-8', maxBuffer: 5 * 1024 * 1024 }).trim();
280
290
  // Only inline if the diff is under 100K chars — beyond that, let the agent
281
291
  // run git diff itself (it can paginate or review file-by-file).
282
292
  if (diffOutput.length > 0 && diffOutput.length <= 100_000) {
@@ -292,7 +302,7 @@ export async function runTriage(gitRoot, baseBranch, options = {}) {
292
302
  const codeReviewOutput = join(triageDir, 'code-review.json');
293
303
  checkers.push({
294
304
  name: 'code-review',
295
- prompt: buildCodeReviewPrompt(baseBranch, codeReviewOutput, maxTurns['code-review'], timeoutMs, precomputedDiff),
305
+ prompt: buildCodeReviewPrompt(diffBaseRef, codeReviewOutput, maxTurns['code-review'], timeoutMs, precomputedDiff),
296
306
  outputFile: codeReviewOutput,
297
307
  maxTurns: maxTurns['code-review'],
298
308
  });
@@ -303,7 +313,7 @@ export async function runTriage(gitRoot, baseBranch, options = {}) {
303
313
  const agentPolicyFiles = discoverAgentPolicyFiles(gitRoot);
304
314
  if (hasRulesYaml || agentPolicyFiles.length > 0) {
305
315
  const rulesValidatorOutput = join(triageDir, 'rules-validator.json');
306
- const rulesPrompt = buildRulesValidatorPrompt(baseBranch, rulesYaml, rulesValidatorOutput, maxTurns['rules-validator'], timeoutMs, agentPolicyFiles, precomputedDiff);
316
+ const rulesPrompt = buildRulesValidatorPrompt(diffBaseRef, rulesYaml, rulesValidatorOutput, maxTurns['rules-validator'], timeoutMs, agentPolicyFiles, precomputedDiff);
307
317
  if (rulesPrompt) {
308
318
  checkers.push({
309
319
  name: 'rules-validator',
@@ -314,10 +324,10 @@ export async function runTriage(gitRoot, baseBranch, options = {}) {
314
324
  }
315
325
  }
316
326
  // 3. Intent drift (only if relevant trace files exist)
317
- const traceFiles = findRelevantTraces(gitRoot, baseBranch);
327
+ const traceFiles = findRelevantTraces(gitRoot, diffBaseRef);
318
328
  if (traceFiles.length > 0) {
319
329
  const intentDriftOutput = join(triageDir, 'intent-drift.json');
320
- const driftPrompt = buildIntentDriftPrompt(baseBranch, traceFiles, intentDriftOutput, maxTurns['intent-drift'], timeoutMs, precomputedDiff);
330
+ const driftPrompt = buildIntentDriftPrompt(diffBaseRef, traceFiles, intentDriftOutput, maxTurns['intent-drift'], timeoutMs, precomputedDiff);
321
331
  if (driftPrompt) {
322
332
  checkers.push({
323
333
  name: 'intent-drift',
@@ -15,8 +15,18 @@ export interface AnalysisVerdict {
15
15
  summary: string;
16
16
  issues: AnalysisIssue[];
17
17
  reviewUrl: string;
18
- /** Whether the PR was auto-merged after analysis */
18
+ /**
19
+ * Whether the PR has actually been merged on GitHub. Read from the GH
20
+ * REST PR endpoint — NOT inferred from review policy / canAutoMerge /
21
+ * haystackRating, all of which mean "eligible", not "merged".
22
+ */
19
23
  autoMerged?: boolean;
24
+ /**
25
+ * Whether the rating synthesis flagged that a human reviewer is required
26
+ * (review policy, security-sensitive paths, etc.). Distinct from
27
+ * `state === 'needs-input'` — analysis can be clean AND still need a human.
28
+ */
29
+ needsHumanReview?: boolean;
20
30
  }
21
31
  export interface AnalysisIssue {
22
32
  file: string;
@@ -55,7 +65,7 @@ export interface HumanReviewReason {
55
65
  }
56
66
  export interface RatingSynthesis {
57
67
  haystackRating: 1 | 2 | 3 | 4 | 5;
58
- analysisVerdict?: 'clean' | 'has-issues';
68
+ analysisVerdict?: 'clean' | 'has-issues' | 'needs-review';
59
69
  synthesis: string;
60
70
  synthesisDisplay?: SynthesisDisplayIssue[];
61
71
  verifiedBugs: VerifiedBug[];
@@ -99,9 +109,6 @@ export type AnalysisReadyResult = {
99
109
  * Returns 'ready' if complete, 'pending' if not yet available, or 'error' for server failures.
100
110
  */
101
111
  export declare function checkAnalysisReady(owner: string, repo: string, prNumber: number, token?: string): Promise<AnalysisReadyResult>;
102
- /**
103
- * Fetch analysis results once complete. Combines bug detection and rule violations.
104
- */
105
112
  export declare function fetchAnalysisResults(owner: string, repo: string, prNumber: number, token?: string): Promise<AnalysisVerdict>;
106
113
  /**
107
114
  * Fetch the full rating synthesis for a PR.