@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/commands/mcp.d.ts +1 -0
- package/dist/commands/mcp.js +185 -0
- package/dist/commands/pr.d.ts +10 -0
- package/dist/commands/pr.js +103 -0
- package/dist/commands/rules.d.ts +4 -0
- package/dist/commands/rules.js +98 -0
- package/dist/commands/schema-cmd.d.ts +2 -0
- package/dist/commands/schema-cmd.js +41 -0
- package/dist/commands/setup.js +62 -7
- package/dist/commands/submit.js +32 -4
- package/dist/commands/tokens.d.ts +14 -0
- package/dist/commands/tokens.js +184 -0
- package/dist/commands/triage.js +3 -2
- package/dist/commands/webhooks.d.ts +18 -0
- package/dist/commands/webhooks.js +210 -0
- package/dist/index.js +223 -4
- package/dist/schema.d.ts +21 -0
- package/dist/schema.js +20 -0
- package/dist/triage/runner.js +15 -5
- package/dist/utils/analysis-api.d.ts +12 -5
- package/dist/utils/analysis-api.js +155 -82
- package/dist/utils/auth.d.ts +29 -0
- package/dist/utils/auth.js +70 -2
- package/dist/utils/git.d.ts +36 -0
- package/dist/utils/git.js +113 -2
- package/dist/utils/prompter.js +4 -1
- package/package.json +6 -3
- package/schemas/pr.v1.json +34 -0
- package/schemas/setup.v1.json +21 -0
- package/schemas/triage.v1.json +50 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runMcpServer(): Promise<void>;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `haystack mcp` — Model Context Protocol server.
|
|
3
|
+
*
|
|
4
|
+
* Exposes Haystack's read surface to Claude Code, Cursor, Codex, etc. as
|
|
5
|
+
* stdio MCP tools. Every tool wraps the same library function that powers
|
|
6
|
+
* the corresponding `--json` CLI command, so an MCP client and a webhook
|
|
7
|
+
* receiver see byte-identical payloads.
|
|
8
|
+
*
|
|
9
|
+
* Tools:
|
|
10
|
+
* triage_pr(ref) same shape as `haystack triage <ref> --json`
|
|
11
|
+
* pr_state(ref) PR state in the Haystack pipeline (schemas/pr.v1.json)
|
|
12
|
+
* schema(name) prints the JSON Schema for a noun (debugging)
|
|
13
|
+
*
|
|
14
|
+
* Auth: inherited from the local CLI (loadToken). MCP clients run as the
|
|
15
|
+
* operator's user, so they get whatever credentials are on disk — same model
|
|
16
|
+
* as `git`, `gh`, etc.
|
|
17
|
+
*/
|
|
18
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
19
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
20
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
21
|
+
import { readFileSync } from 'node:fs';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
import { dirname, join } from 'node:path';
|
|
24
|
+
import { parsePrRef, prReadCommand } from './pr.js';
|
|
25
|
+
import { fetchRatingSynthesis, fetchAutoFixManifest } from '../utils/analysis-api.js';
|
|
26
|
+
import { loadToken } from '../utils/auth.js';
|
|
27
|
+
import { SCHEMA_VERSIONS, withSchema } from '../schema.js';
|
|
28
|
+
const TOOLS = [
|
|
29
|
+
{
|
|
30
|
+
name: 'triage_pr',
|
|
31
|
+
description: 'Fetch the latest triage results for a pull request. Returns rating, findings, ' +
|
|
32
|
+
'and which findings the auto-fixer is already handling. Identical shape to ' +
|
|
33
|
+
'`haystack triage <ref> --json`.',
|
|
34
|
+
inputSchema: {
|
|
35
|
+
type: 'object',
|
|
36
|
+
properties: {
|
|
37
|
+
ref: {
|
|
38
|
+
type: 'string',
|
|
39
|
+
description: 'PR reference: bare number, owner/repo#number, or GitHub URL.',
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
required: ['ref'],
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'pr_state',
|
|
47
|
+
description: 'Show the Haystack state of a single PR: bucket, last triage rating, merge state.',
|
|
48
|
+
inputSchema: {
|
|
49
|
+
type: 'object',
|
|
50
|
+
properties: {
|
|
51
|
+
ref: { type: 'string', description: 'PR reference.' },
|
|
52
|
+
},
|
|
53
|
+
required: ['ref'],
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: 'schema',
|
|
58
|
+
description: 'Return the JSON Schema for a Haystack --json command output. ' +
|
|
59
|
+
'Use this to discover the exact shape of triage / setup / pr envelopes.',
|
|
60
|
+
inputSchema: {
|
|
61
|
+
type: 'object',
|
|
62
|
+
properties: {
|
|
63
|
+
name: {
|
|
64
|
+
type: 'string',
|
|
65
|
+
enum: Object.keys(SCHEMA_VERSIONS),
|
|
66
|
+
description: 'Schema noun.',
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
required: ['name'],
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
async function readSchema(name) {
|
|
74
|
+
const major = SCHEMA_VERSIONS[name].split('.')[0];
|
|
75
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
76
|
+
// dist/commands/mcp.js → ../../schemas/<name>.v<major>.json
|
|
77
|
+
const p = join(here, '..', '..', 'schemas', `${name}.v${major}.json`);
|
|
78
|
+
return JSON.parse(readFileSync(p, 'utf8'));
|
|
79
|
+
}
|
|
80
|
+
/** Capture process.stdout written by CLI commands and return it. MCP tool
|
|
81
|
+
* callbacks must return their result, not write it. The simplest way to
|
|
82
|
+
* reuse the existing --json printers without refactoring them is to
|
|
83
|
+
* redirect stdout for the duration of the call. */
|
|
84
|
+
async function captureStdout(fn) {
|
|
85
|
+
const origWrite = process.stdout.write.bind(process.stdout);
|
|
86
|
+
let captured = '';
|
|
87
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
88
|
+
process.stdout.write = (chunk, encoding, cb) => {
|
|
89
|
+
captured += typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk);
|
|
90
|
+
if (typeof encoding === 'function')
|
|
91
|
+
encoding();
|
|
92
|
+
else if (cb)
|
|
93
|
+
cb();
|
|
94
|
+
return true;
|
|
95
|
+
};
|
|
96
|
+
try {
|
|
97
|
+
await fn();
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
process.stdout.write = origWrite;
|
|
101
|
+
}
|
|
102
|
+
return captured;
|
|
103
|
+
}
|
|
104
|
+
function jsonFromCapture(captured) {
|
|
105
|
+
try {
|
|
106
|
+
return JSON.parse(captured);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return { error: 'tool produced non-JSON output', raw: captured.slice(0, 1000) };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
export async function runMcpServer() {
|
|
113
|
+
const server = new Server({
|
|
114
|
+
name: 'haystack-cli',
|
|
115
|
+
version: '1.0.0',
|
|
116
|
+
}, {
|
|
117
|
+
capabilities: { tools: {} },
|
|
118
|
+
});
|
|
119
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
120
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
121
|
+
const { name, arguments: args } = request.params;
|
|
122
|
+
const ok = (data) => ({
|
|
123
|
+
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
|
|
124
|
+
});
|
|
125
|
+
const fail = (msg) => ({
|
|
126
|
+
isError: true,
|
|
127
|
+
content: [{ type: 'text', text: msg }],
|
|
128
|
+
});
|
|
129
|
+
try {
|
|
130
|
+
if (name === 'triage_pr') {
|
|
131
|
+
const ref = args?.ref;
|
|
132
|
+
if (!ref)
|
|
133
|
+
return fail('ref required');
|
|
134
|
+
const pr = parsePrRef(ref);
|
|
135
|
+
const token = (await loadToken()) ?? undefined;
|
|
136
|
+
const [synthesis, manifest] = await Promise.all([
|
|
137
|
+
fetchRatingSynthesis(pr.owner, pr.repo, pr.prNumber, token),
|
|
138
|
+
fetchAutoFixManifest(pr.owner, pr.repo, pr.prNumber, token),
|
|
139
|
+
]);
|
|
140
|
+
if (!synthesis)
|
|
141
|
+
return fail(`No triage data for ${pr.owner}/${pr.repo}#${pr.prNumber}`);
|
|
142
|
+
// Mirror triage --json envelope.
|
|
143
|
+
return ok(withSchema('triage', {
|
|
144
|
+
owner: pr.owner,
|
|
145
|
+
repo: pr.repo,
|
|
146
|
+
prNumber: pr.prNumber,
|
|
147
|
+
rating: synthesis.haystackRating,
|
|
148
|
+
autoFixerHandling: manifest?.fixedItems ?? [],
|
|
149
|
+
autoFixerSkipped: manifest?.leftForReview ?? [],
|
|
150
|
+
findings: (synthesis.synthesisDisplay ?? []).map((f) => ({
|
|
151
|
+
category: f.category,
|
|
152
|
+
summary: f.summary,
|
|
153
|
+
detail: f.detail,
|
|
154
|
+
agentFixPrompt: f.agentFixPrompt ?? null,
|
|
155
|
+
source: f.source ?? null,
|
|
156
|
+
})),
|
|
157
|
+
}));
|
|
158
|
+
}
|
|
159
|
+
if (name === 'pr_state') {
|
|
160
|
+
const ref = args?.ref;
|
|
161
|
+
if (!ref)
|
|
162
|
+
return fail('ref required');
|
|
163
|
+
const captured = await captureStdout(async () => {
|
|
164
|
+
await prReadCommand(ref, { json: true });
|
|
165
|
+
});
|
|
166
|
+
return ok(jsonFromCapture(captured));
|
|
167
|
+
}
|
|
168
|
+
if (name === 'schema') {
|
|
169
|
+
const n = args?.name;
|
|
170
|
+
if (!n || !(n in SCHEMA_VERSIONS)) {
|
|
171
|
+
return fail(`unknown schema; choose one of ${Object.keys(SCHEMA_VERSIONS).join(', ')}`);
|
|
172
|
+
}
|
|
173
|
+
return ok(await readSchema(n));
|
|
174
|
+
}
|
|
175
|
+
return fail(`unknown tool: ${name}`);
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
return fail(`tool error: ${err instanceof Error ? err.message : String(err)}`);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
const transport = new StdioServerTransport();
|
|
182
|
+
await server.connect(transport);
|
|
183
|
+
// The server runs until stdin closes. Stdio MCP gives no exit signal beyond
|
|
184
|
+
// that; we don't return.
|
|
185
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface ParsedPR {
|
|
2
|
+
owner: string;
|
|
3
|
+
repo: string;
|
|
4
|
+
prNumber: number;
|
|
5
|
+
}
|
|
6
|
+
export declare function parsePrRef(ref: string): ParsedPR;
|
|
7
|
+
export interface PrReadOptions {
|
|
8
|
+
json?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export declare function prReadCommand(ref: string, opts: PrReadOptions): Promise<void>;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PR-reference helpers shared by the MCP server's `pr_state` tool.
|
|
3
|
+
*
|
|
4
|
+
* No top-level `haystack pr` CLI command — the original `pr <ref>` /
|
|
5
|
+
* `pr <ref> merge|snooze|dismiss` surface was scope creep on the
|
|
6
|
+
* CLI-as-platform spec. Use the pre-existing `haystack pr-status <pr>`
|
|
7
|
+
* for the same read.
|
|
8
|
+
*
|
|
9
|
+
* Output shape is stamped with `schema_version` per `schemas/pr.v1.json`.
|
|
10
|
+
*/
|
|
11
|
+
import chalk from 'chalk';
|
|
12
|
+
import { loadToken } from '../utils/auth.js';
|
|
13
|
+
import { fetchRatingSynthesis } from '../utils/analysis-api.js';
|
|
14
|
+
import { parseRemoteUrl } from '../utils/git.js';
|
|
15
|
+
import { withSchema } from '../schema.js';
|
|
16
|
+
const GITHUB_PROXY = process.env.HAYSTACK_API_BASE ?? 'https://haystackeditor.com';
|
|
17
|
+
export function parsePrRef(ref) {
|
|
18
|
+
const urlMatch = ref.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/);
|
|
19
|
+
if (urlMatch)
|
|
20
|
+
return { owner: urlMatch[1], repo: urlMatch[2], prNumber: parseInt(urlMatch[3], 10) };
|
|
21
|
+
const qualifiedMatch = ref.match(/^([^/]+)\/([^#]+)#(\d+)$/);
|
|
22
|
+
if (qualifiedMatch)
|
|
23
|
+
return { owner: qualifiedMatch[1], repo: qualifiedMatch[2], prNumber: parseInt(qualifiedMatch[3], 10) };
|
|
24
|
+
const bare = ref.match(/^#?(\d+)$/);
|
|
25
|
+
if (bare) {
|
|
26
|
+
const r = parseRemoteUrl('origin');
|
|
27
|
+
return { owner: r.owner, repo: r.repo, prNumber: parseInt(bare[1], 10) };
|
|
28
|
+
}
|
|
29
|
+
throw new Error(`Invalid PR reference: "${ref}". Use 123, owner/repo#123, or a GitHub URL.`);
|
|
30
|
+
}
|
|
31
|
+
async function ghPr(token, owner, repo, num) {
|
|
32
|
+
const url = `${GITHUB_PROXY}/api/github/repos/${owner}/${repo}/pulls/${num}`;
|
|
33
|
+
const res = await fetch(url, {
|
|
34
|
+
headers: {
|
|
35
|
+
Accept: 'application/vnd.github+json',
|
|
36
|
+
Authorization: `Bearer ${token}`,
|
|
37
|
+
'User-Agent': 'Haystack-CLI',
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
if (!res.ok) {
|
|
41
|
+
throw new Error(`GitHub API ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
42
|
+
}
|
|
43
|
+
return res.json();
|
|
44
|
+
}
|
|
45
|
+
export async function prReadCommand(ref, opts) {
|
|
46
|
+
const pr = parsePrRef(ref);
|
|
47
|
+
const token = await loadToken();
|
|
48
|
+
if (!token) {
|
|
49
|
+
console.error(chalk.red('Not authenticated. Run `haystack login` first.'));
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
const [meta, synthesis] = await Promise.all([
|
|
53
|
+
ghPr(token, pr.owner, pr.repo, pr.prNumber),
|
|
54
|
+
// 404 → null is the "not analyzed yet" signal; we still log unexpected
|
|
55
|
+
// failures so they don't look the same as "not analyzed".
|
|
56
|
+
fetchRatingSynthesis(pr.owner, pr.repo, pr.prNumber, token).catch((err) => {
|
|
57
|
+
console.error(chalk.dim(`[pr] rating fetch failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
58
|
+
return null;
|
|
59
|
+
}),
|
|
60
|
+
]);
|
|
61
|
+
let bucket = 'analyzing';
|
|
62
|
+
if (meta.merged)
|
|
63
|
+
bucket = 'merged';
|
|
64
|
+
else if (meta.state === 'closed')
|
|
65
|
+
bucket = 'merged';
|
|
66
|
+
else if (synthesis?.haystackRating != null) {
|
|
67
|
+
bucket = synthesis.haystackRating <= 2
|
|
68
|
+
? 'issues'
|
|
69
|
+
: synthesis.haystackRating === 5
|
|
70
|
+
? 'good-to-merge'
|
|
71
|
+
: 'needs-attention';
|
|
72
|
+
}
|
|
73
|
+
const payload = {
|
|
74
|
+
owner: pr.owner,
|
|
75
|
+
repo: pr.repo,
|
|
76
|
+
prNumber: pr.prNumber,
|
|
77
|
+
bucket,
|
|
78
|
+
state: meta.merged ? 'merged' : meta.state,
|
|
79
|
+
title: meta.title,
|
|
80
|
+
headSha: meta.head.sha,
|
|
81
|
+
haystackUrl: `https://haystackeditor.com/review/${pr.owner}/${pr.repo}/${pr.prNumber}`,
|
|
82
|
+
mergeQueue: null,
|
|
83
|
+
lastTriage: synthesis?.haystackRating != null
|
|
84
|
+
? {
|
|
85
|
+
rating: synthesis.haystackRating,
|
|
86
|
+
completedAt: new Date().toISOString(),
|
|
87
|
+
headSha: meta.head.sha,
|
|
88
|
+
}
|
|
89
|
+
: null,
|
|
90
|
+
};
|
|
91
|
+
if (opts.json) {
|
|
92
|
+
console.log(JSON.stringify(withSchema('pr', payload), null, 2));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
console.log(`\n ${chalk.bold(`${pr.owner}/${pr.repo}#${pr.prNumber}`)} ${chalk.dim(meta.title)}`);
|
|
96
|
+
console.log(` ${chalk.dim('bucket:')} ${bucket}`);
|
|
97
|
+
console.log(` ${chalk.dim('state:')} ${payload.state}`);
|
|
98
|
+
console.log(` ${chalk.dim('head:')} ${meta.head.sha.slice(0, 7)} (${meta.head.ref})`);
|
|
99
|
+
if (payload.lastTriage) {
|
|
100
|
+
console.log(` ${chalk.dim('rating:')} ★${payload.lastTriage.rating}`);
|
|
101
|
+
}
|
|
102
|
+
console.log(` ${chalk.dim(payload.haystackUrl)}\n`);
|
|
103
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `haystack rules edit|validate` — manage .haystack/pr-rules.yml.
|
|
3
|
+
*
|
|
4
|
+
* Why local, not server-round-trip:
|
|
5
|
+
* pr-rules.yml is the source of truth, checked into the repo. The CLI's job
|
|
6
|
+
* is to make it ergonomic to edit (open in $EDITOR) and to fail loudly if
|
|
7
|
+
* the file would break the server-side parser (`packages/custom-rules`).
|
|
8
|
+
* The same parser is used here so "validate passes locally" == "won't
|
|
9
|
+
* break the next PR analysis."
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from 'node:fs';
|
|
12
|
+
import { spawnSync } from 'node:child_process';
|
|
13
|
+
import { resolve, dirname } from 'node:path';
|
|
14
|
+
import chalk from 'chalk';
|
|
15
|
+
const RULES_PATH = '.haystack/pr-rules.yml';
|
|
16
|
+
const STARTER_YAML = `version: 1
|
|
17
|
+
|
|
18
|
+
rules:
|
|
19
|
+
# Replace with your own rules. Each rule is either type: llm (judged by an
|
|
20
|
+
# LLM) or type: deterministic (regex / structural). See
|
|
21
|
+
# packages/custom-rules/README.md for the full schema.
|
|
22
|
+
- id: EXAMPLE001
|
|
23
|
+
name: Example rule
|
|
24
|
+
type: llm
|
|
25
|
+
severity: info
|
|
26
|
+
message: Replace this rule with one that matters to your team
|
|
27
|
+
llm:
|
|
28
|
+
prompt: >
|
|
29
|
+
This is a placeholder. Edit .haystack/pr-rules.yml and remove this rule.
|
|
30
|
+
`;
|
|
31
|
+
function rulesAbsPath() {
|
|
32
|
+
return resolve(process.cwd(), RULES_PATH);
|
|
33
|
+
}
|
|
34
|
+
export async function editRulesCommand() {
|
|
35
|
+
const path = rulesAbsPath();
|
|
36
|
+
if (!existsSync(path)) {
|
|
37
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
38
|
+
writeFileSync(path, STARTER_YAML, 'utf8');
|
|
39
|
+
console.log(chalk.dim(`Created ${RULES_PATH} with a starter rule.`));
|
|
40
|
+
}
|
|
41
|
+
const editor = process.env.VISUAL ?? process.env.EDITOR;
|
|
42
|
+
if (!editor) {
|
|
43
|
+
console.error(chalk.red('No $EDITOR or $VISUAL set.') +
|
|
44
|
+
`\nOpen ${chalk.bold(RULES_PATH)} manually, then run ` +
|
|
45
|
+
chalk.bold('haystack rules validate') + ' to check it.');
|
|
46
|
+
process.exit(2);
|
|
47
|
+
}
|
|
48
|
+
const before = statSync(path).mtimeMs;
|
|
49
|
+
const result = spawnSync(editor, [path], { stdio: 'inherit' });
|
|
50
|
+
if (result.status !== 0) {
|
|
51
|
+
console.error(chalk.red(`Editor exited with status ${result.status}.`));
|
|
52
|
+
process.exit(result.status ?? 1);
|
|
53
|
+
}
|
|
54
|
+
const after = statSync(path).mtimeMs;
|
|
55
|
+
if (after === before) {
|
|
56
|
+
console.log(chalk.dim('No changes.'));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
console.log(chalk.dim('Validating…'));
|
|
60
|
+
await validateRulesCommand({ silent: false });
|
|
61
|
+
}
|
|
62
|
+
export async function validateRulesCommand(opts = {}) {
|
|
63
|
+
const path = rulesAbsPath();
|
|
64
|
+
if (!existsSync(path)) {
|
|
65
|
+
console.error(chalk.red(`${RULES_PATH} not found.`));
|
|
66
|
+
console.error(`Run ${chalk.bold('haystack rules edit')} to create one.`);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
const raw = readFileSync(path, 'utf8');
|
|
70
|
+
// Use the canonical parser. Importing dynamically keeps the CLI's hot path
|
|
71
|
+
// free of haystack-custom-rules unless this subcommand is actually run.
|
|
72
|
+
let parser = null;
|
|
73
|
+
try {
|
|
74
|
+
parser = (await import('haystack-custom-rules'));
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
console.error(chalk.red('Could not load the rules parser (haystack-custom-rules).'), err instanceof Error ? err.message : err);
|
|
78
|
+
console.error(chalk.dim('The CLI uses the same parser as the server. If this failed, the workspace install is incomplete — run `pnpm install` from the repo root.'));
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const cfg = parser.parseRulesYaml(raw);
|
|
83
|
+
const ruleCount = cfg.rules?.length ?? 0;
|
|
84
|
+
if (!opts.silent) {
|
|
85
|
+
console.log(chalk.green(`✓ ${RULES_PATH} is valid (${ruleCount} rule${ruleCount === 1 ? '' : 's'}).`));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
console.error(chalk.red(`✗ ${RULES_PATH} is invalid:`));
|
|
90
|
+
if (err && typeof err === 'object' && 'message' in err) {
|
|
91
|
+
console.error(` ${err.message}`);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
console.error(' ' + String(err));
|
|
95
|
+
}
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `haystack schema <command>` — print the committed JSON Schema for the
|
|
3
|
+
* machine-readable `--json` output of a given command.
|
|
4
|
+
*
|
|
5
|
+
* Schemas live in packages/haystack-cli/schemas/<name>.v<major>.json and ship
|
|
6
|
+
* with the npm package. A CI guard fails if the schema_version in code drifts
|
|
7
|
+
* from the committed schema without a version bump.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { dirname, join } from 'node:path';
|
|
12
|
+
import chalk from 'chalk';
|
|
13
|
+
import { SCHEMA_VERSIONS } from '../schema.js';
|
|
14
|
+
// Derive valid schema names from the registry so adding a new schema only
|
|
15
|
+
// requires editing SCHEMA_VERSIONS (PR008 — single source of truth).
|
|
16
|
+
const VALID = Object.keys(SCHEMA_VERSIONS);
|
|
17
|
+
export function schemaCommand(name) {
|
|
18
|
+
if (!VALID.includes(name)) {
|
|
19
|
+
console.error(chalk.red(`Unknown schema: "${name}".`) +
|
|
20
|
+
`\n\nAvailable: ${VALID.join(', ')}`);
|
|
21
|
+
process.exit(2);
|
|
22
|
+
}
|
|
23
|
+
const major = SCHEMA_VERSIONS[name].split('.')[0];
|
|
24
|
+
// dist/commands/schema-cmd.js → ../../schemas/<name>.v<major>.json
|
|
25
|
+
const schemaPath = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'schemas', `${name}.v${major}.json`);
|
|
26
|
+
try {
|
|
27
|
+
process.stdout.write(readFileSync(schemaPath, 'utf8'));
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
console.error(chalk.red(`Could not read schema at ${schemaPath}:`), err instanceof Error ? err.message : err);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function listSchemas() {
|
|
35
|
+
console.log(chalk.bold('Available --json schemas:\n'));
|
|
36
|
+
for (const name of VALID) {
|
|
37
|
+
console.log(` ${chalk.cyan(name.padEnd(10))} v${SCHEMA_VERSIONS[name]}`);
|
|
38
|
+
}
|
|
39
|
+
console.log(`\nPrint one with: ${chalk.dim('haystack schema <name>')}\n` +
|
|
40
|
+
`External URL: ${chalk.dim('https://docs.haystackeditor.com/cli/schemas/<name>.v1.json')}`);
|
|
41
|
+
}
|
package/dist/commands/setup.js
CHANGED
|
@@ -344,6 +344,53 @@ async function addBootstrapLabels(owner, repo, prNumber, prUrl, token, autoMerge
|
|
|
344
344
|
`Add these labels to the PR manually so Haystack picks it up: ${labels.join(', ')}. ` +
|
|
345
345
|
`Don't re-run \`haystack setup\` — that opens a duplicate PR.`);
|
|
346
346
|
}
|
|
347
|
+
/**
|
|
348
|
+
* Assign the onboarding actor (the human running `haystack setup`) to the
|
|
349
|
+
* bootstrap PR.
|
|
350
|
+
*
|
|
351
|
+
* Load-bearing for backfill parity with the web wizard
|
|
352
|
+
* (src/features/onboarding/services/onboardingApi.ts → assignAuthorToPr): when
|
|
353
|
+
* the bootstrap PR merges, the webhook handler's
|
|
354
|
+
* `_extract_onboarding_backfill_actor` figures out whose existing open PRs to
|
|
355
|
+
* analyze by reading the merged PR's assignees. The PR is authored by
|
|
356
|
+
* haystack[bot] (excluded as a bot) and carries no actor marker, so WITHOUT an
|
|
357
|
+
* assignee the actor can't be determined and the backfill is silently skipped
|
|
358
|
+
* (`[ONBOARDING_BACKFILL] Could not determine onboarding actor`) — leaving a
|
|
359
|
+
* freshly-onboarded user's pre-existing PRs un-analyzed. The web wizard assigns
|
|
360
|
+
* the user; the CLI must do the same.
|
|
361
|
+
*
|
|
362
|
+
* Best-effort: the PR is already open + labeled (which is what gates onboarding
|
|
363
|
+
* completion), so a failed assignment must not fail setup. Tracked so we notice
|
|
364
|
+
* if it starts failing systemically (e.g. after a GitHub App permission
|
|
365
|
+
* narrowing).
|
|
366
|
+
*/
|
|
367
|
+
async function assignActorToPr(owner, repo, prNumber, login, token) {
|
|
368
|
+
if (!login) {
|
|
369
|
+
trackError('haystack_setup_assign_actor_no_login', { repo: `${owner}/${repo}`, pr_number: prNumber });
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
try {
|
|
373
|
+
const res = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/issues/${prNumber}/assignees`, {
|
|
374
|
+
method: 'POST',
|
|
375
|
+
headers: ghHeaders(token),
|
|
376
|
+
body: JSON.stringify({ assignees: [login] }),
|
|
377
|
+
});
|
|
378
|
+
if (!res.ok) {
|
|
379
|
+
trackError('haystack_setup_assign_actor_failed', {
|
|
380
|
+
repo: `${owner}/${repo}`,
|
|
381
|
+
pr_number: prNumber,
|
|
382
|
+
status: res.status,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
catch (err) {
|
|
387
|
+
trackError('haystack_setup_assign_actor_failed', {
|
|
388
|
+
repo: `${owner}/${repo}`,
|
|
389
|
+
pr_number: prNumber,
|
|
390
|
+
error_message: err instanceof Error ? err.message : String(err),
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
}
|
|
347
394
|
/**
|
|
348
395
|
* Protected-branch fallback: build ONE commit containing ALL the onboarding
|
|
349
396
|
* files (`configFiles` + `.entire/settings.json`) via the Git Data API, CREATE
|
|
@@ -358,7 +405,7 @@ async function addBootstrapLabels(owner, repo, prNumber, prUrl, token, autoMerge
|
|
|
358
405
|
* trace capture intent-drift triage runs on) must land in the single commit the
|
|
359
406
|
* branch is created at. Mirrors the web wizard's PR-fallback path.
|
|
360
407
|
*/
|
|
361
|
-
async function openBootstrapConfigPR(owner, repo, configFiles, token, defaultBranch, autoMergeEnabled) {
|
|
408
|
+
async function openBootstrapConfigPR(owner, repo, configFiles, token, defaultBranch, autoMergeEnabled, assigneeLogin) {
|
|
362
409
|
const baseSha = await getBranchSha(owner, repo, defaultBranch, token);
|
|
363
410
|
// Branch prefix MUST stay `haystack/onboarding-` — the worker keys off it.
|
|
364
411
|
// The timestamp avoids collisions when setup is re-run.
|
|
@@ -400,6 +447,11 @@ async function openBootstrapConfigPR(owner, repo, configFiles, token, defaultBra
|
|
|
400
447
|
const commitSha = await createCommitWithFiles(owner, repo, baseSha, files, CONFIG_COMMIT_MSG, token);
|
|
401
448
|
await createBranch(owner, repo, branchName, commitSha, token);
|
|
402
449
|
const pr = await createBootstrapPR(owner, repo, branchName, defaultBranch, token, autoMergeEnabled);
|
|
450
|
+
// Assign the actor BEFORE labeling. The onboarding-bootstrap backfill keys off
|
|
451
|
+
// the merged PR's assignees, and the auto-merge label can let the merge-queue
|
|
452
|
+
// cron merge this PR promptly — so the assignee must be in place first, else
|
|
453
|
+
// the merge webhook fires without it and the backfill can't attribute the run.
|
|
454
|
+
await assignActorToPr(owner, repo, pr.number, assigneeLogin, token);
|
|
403
455
|
// Always apply the onboarding-bootstrap marker so the PR stays recognized in
|
|
404
456
|
// the user's tabs (isOnboardingBootstrapPR); enroll for auto-merge only when
|
|
405
457
|
// the repo hasn't opted out. With auto-merge off the PR is still opened, marked,
|
|
@@ -415,9 +467,9 @@ async function openBootstrapConfigPR(owner, repo, configFiles, token, defaultBra
|
|
|
415
467
|
* Haystack" PR. The PR is enrolled for auto-merge only when the user kept
|
|
416
468
|
* auto-merge on; otherwise it waits for them to review and merge it.
|
|
417
469
|
*/
|
|
418
|
-
async function writeOnboardingFilesToRepo(owner, repo, files, autoMergeEnabled, token) {
|
|
470
|
+
async function writeOnboardingFilesToRepo(owner, repo, files, autoMergeEnabled, token, assigneeLogin) {
|
|
419
471
|
const defaultBranch = await getDefaultBranch(owner, repo, token);
|
|
420
|
-
const bootstrapPR = await openBootstrapConfigPR(owner, repo, files, token, defaultBranch, autoMergeEnabled);
|
|
472
|
+
const bootstrapPR = await openBootstrapConfigPR(owner, repo, files, token, defaultBranch, autoMergeEnabled, assigneeLogin);
|
|
421
473
|
return { bootstrapPR };
|
|
422
474
|
}
|
|
423
475
|
const SCAN_POLL_INTERVAL_MS = 1500;
|
|
@@ -980,7 +1032,7 @@ function buildOnboardingFiles(result, autoMerge) {
|
|
|
980
1032
|
files.push({ path: HAYSTACK_CONFIG_PATH, content: buildHaystackConfigJson(autoMerge) });
|
|
981
1033
|
return files;
|
|
982
1034
|
}
|
|
983
|
-
async function stepConfirm(byRepo, token) {
|
|
1035
|
+
async function stepConfirm(byRepo, token, assigneeLogin) {
|
|
984
1036
|
const selectedRepos = [...byRepo.keys()];
|
|
985
1037
|
console.log(chalk.bold('\n Step 4: Confirm & write configuration\n'));
|
|
986
1038
|
// Per-repo summary of what will be written.
|
|
@@ -1025,7 +1077,7 @@ async function stepConfirm(byRepo, token) {
|
|
|
1025
1077
|
const files = buildOnboardingFiles(result, autoMerge);
|
|
1026
1078
|
try {
|
|
1027
1079
|
ui.progress(`Opening Configure Haystack PR for ${repoFullName}...`);
|
|
1028
|
-
const writeResult = await writeOnboardingFilesToRepo(owner, repo, files, autoMerge, token);
|
|
1080
|
+
const writeResult = await writeOnboardingFilesToRepo(owner, repo, files, autoMerge, token, assigneeLogin);
|
|
1029
1081
|
ui.clearProgress();
|
|
1030
1082
|
// Onboarding always opens a PR (never a direct commit). With auto-merge on,
|
|
1031
1083
|
// Haystack merges it once its labels are processed; with auto-merge off it
|
|
@@ -1108,8 +1160,11 @@ async function runSetupFlow(options) {
|
|
|
1108
1160
|
const byRepo = await stepScanRepos(selectedRepos, token);
|
|
1109
1161
|
// Step 3: Review (toggle items off)
|
|
1110
1162
|
await stepReview(byRepo);
|
|
1111
|
-
// Step 4: Confirm & write the config files
|
|
1112
|
-
|
|
1163
|
+
// Step 4: Confirm & write the config files. Pass the authenticated user's
|
|
1164
|
+
// login so each bootstrap PR is assigned to them — the onboarding-bootstrap
|
|
1165
|
+
// backfill keys off the merged PR's assignees to decide whose existing open
|
|
1166
|
+
// PRs to analyze (parity with the web wizard).
|
|
1167
|
+
const { confirmed, bootstrapPRs } = await stepConfirm(byRepo, token, user.login);
|
|
1113
1168
|
if (!confirmed) {
|
|
1114
1169
|
ui.result({ status: 'cancelled' });
|
|
1115
1170
|
return;
|
package/dist/commands/submit.js
CHANGED
|
@@ -433,7 +433,16 @@ export async function submitCommand(options) {
|
|
|
433
433
|
try {
|
|
434
434
|
await ensureHaystackLabels(remoteInfo.owner, remoteInfo.repo, ['haystack:auto-merge'], githubToken);
|
|
435
435
|
await addLabelsToIssue(remoteInfo.owner, remoteInfo.repo, prNumber, ['haystack:auto-merge'], githubToken);
|
|
436
|
-
|
|
436
|
+
// With --review, the PR still enters the auto-merge queue but the
|
|
437
|
+
// merge queue's needs-review gate holds it until a human approves
|
|
438
|
+
// (removes haystack:needs-review or marks it reviewed). Don't print a
|
|
439
|
+
// bare "Auto-merge enabled" that reads as if it'll merge unreviewed.
|
|
440
|
+
if (options.review) {
|
|
441
|
+
console.log(chalk.green('✓ Auto-merge enabled (held until a human approves this PR)'));
|
|
442
|
+
}
|
|
443
|
+
else {
|
|
444
|
+
console.log(chalk.green('✓ Auto-merge enabled for this PR'));
|
|
445
|
+
}
|
|
437
446
|
}
|
|
438
447
|
catch (err) {
|
|
439
448
|
console.log(chalk.yellow(`⚠ Could not set auto-merge label: ${err.message}`));
|
|
@@ -482,7 +491,15 @@ export async function submitCommand(options) {
|
|
|
482
491
|
console.log(` ${chalk.dim('Title:')} ${options.title || getLastCommitMessage()}`);
|
|
483
492
|
console.log(` ${chalk.dim('Branch:')} ${currentBranch} → ${baseBranch}`);
|
|
484
493
|
if (options.autoMerge) {
|
|
485
|
-
|
|
494
|
+
// --review and auto-merge are not contradictory: the PR is queued but the
|
|
495
|
+
// merge queue's needs-review gate blocks the merge until a human approves.
|
|
496
|
+
// Report it that way instead of a flat "Auto-merge if safe".
|
|
497
|
+
if (options.review) {
|
|
498
|
+
console.log(` ${chalk.dim('Merge:')} ${chalk.yellow('Held for human review, then auto-merge')}`);
|
|
499
|
+
}
|
|
500
|
+
else {
|
|
501
|
+
console.log(` ${chalk.dim('Merge:')} ${chalk.green('Auto-merge if safe')}`);
|
|
502
|
+
}
|
|
486
503
|
}
|
|
487
504
|
if (options.autoFix) {
|
|
488
505
|
console.log(` ${chalk.dim('Fix:')} ${chalk.cyan('Auto-fix alpha enabled')}`);
|
|
@@ -548,15 +565,26 @@ export async function submitCommand(options) {
|
|
|
548
565
|
console.log(chalk.bold(' Haystack Analysis Results'));
|
|
549
566
|
console.log(chalk.bold('━'.repeat(50) + '\n'));
|
|
550
567
|
if (verdict.state === 'good-to-merge') {
|
|
568
|
+
// autoMerged here is the GH "merged: true" bit (not the
|
|
569
|
+
// analysis's canAutoMerge eligibility). Only claim the PR was
|
|
570
|
+
// merged when GitHub confirms it.
|
|
551
571
|
if (verdict.autoMerged) {
|
|
552
|
-
console.log(chalk.green.bold(' ✓
|
|
553
|
-
console.log(chalk.dim(' No issues
|
|
572
|
+
console.log(chalk.green.bold(' ✓ Merged\n'));
|
|
573
|
+
console.log(chalk.dim(' No blocking issues. PR is merged on GitHub.'));
|
|
554
574
|
}
|
|
555
575
|
else {
|
|
556
576
|
console.log(chalk.green.bold(' ✓ Good to merge\n'));
|
|
557
577
|
console.log(chalk.dim(` ${verdict.summary}`));
|
|
558
578
|
}
|
|
559
579
|
}
|
|
580
|
+
else if (verdict.needsHumanReview) {
|
|
581
|
+
// Distinct from generic needs-input: analysis is clean but
|
|
582
|
+
// policy/sensitivity demands a human. Don't lump this in with
|
|
583
|
+
// "issues found" — the user's action is "request a reviewer",
|
|
584
|
+
// not "fix bugs".
|
|
585
|
+
console.log(chalk.cyan.bold(' 👤 Human review required\n'));
|
|
586
|
+
console.log(chalk.dim(` ${verdict.summary}`));
|
|
587
|
+
}
|
|
560
588
|
else if (options.autoFix) {
|
|
561
589
|
// Auto-fix flow: agent fixes straightforward issues in the background.
|
|
562
590
|
// Issues appear in Feed immediately — user can review while agent works.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface HeadlessLoginOptions {
|
|
2
|
+
label?: string;
|
|
3
|
+
scope?: string[];
|
|
4
|
+
/** Skip the browser handshake and accept a pre-minted token directly.
|
|
5
|
+
* Useful for CI bootstrap (a one-time admin mints a token, stores it in
|
|
6
|
+
* the CI secret store, and the CI job pastes it via this flag). */
|
|
7
|
+
token?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function headlessLoginCommand(opts: HeadlessLoginOptions): Promise<void>;
|
|
10
|
+
export declare function headlessLogoutCommand(): Promise<void>;
|
|
11
|
+
export declare function listTokensCommand(opts: {
|
|
12
|
+
json?: boolean;
|
|
13
|
+
}): Promise<void>;
|
|
14
|
+
export declare function revokeTokenCommand(id: string): Promise<void>;
|