@rafter-security/cli 0.5.3 → 0.5.9

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.
Files changed (40) hide show
  1. package/README.md +15 -3
  2. package/dist/commands/agent/audit-skill.js +2 -2
  3. package/dist/commands/agent/audit.js +96 -0
  4. package/dist/commands/agent/baseline.js +213 -0
  5. package/dist/commands/agent/exec.js +1 -1
  6. package/dist/commands/agent/index.js +4 -0
  7. package/dist/commands/agent/init.js +371 -29
  8. package/dist/commands/agent/install-hook.js +41 -47
  9. package/dist/commands/agent/scan.js +196 -23
  10. package/dist/commands/agent/status.js +65 -4
  11. package/dist/commands/agent/update-gitleaks.js +40 -0
  12. package/dist/commands/agent/verify.js +18 -4
  13. package/dist/commands/backend/run.js +69 -61
  14. package/dist/commands/ci/init.js +10 -3
  15. package/dist/commands/completion.js +320 -110
  16. package/dist/commands/hook/posttool.js +21 -7
  17. package/dist/commands/hook/pretool.js +50 -13
  18. package/dist/commands/issues/dedup.js +39 -0
  19. package/dist/commands/issues/from-scan.js +143 -0
  20. package/dist/commands/issues/from-text.js +185 -0
  21. package/dist/commands/issues/github-client.js +85 -0
  22. package/dist/commands/issues/index.js +25 -0
  23. package/dist/commands/issues/issue-builder.js +101 -0
  24. package/dist/commands/policy/export.js +7 -2
  25. package/dist/commands/scan/index.js +44 -0
  26. package/dist/core/audit-logger.js +41 -0
  27. package/dist/core/config-defaults.js +28 -0
  28. package/dist/core/config-manager.js +19 -2
  29. package/dist/core/pattern-engine.js +26 -1
  30. package/dist/core/risk-rules.js +5 -3
  31. package/dist/index.js +8 -2
  32. package/dist/scanners/gitleaks.js +5 -5
  33. package/dist/scanners/regex-scanner.js +12 -1
  34. package/dist/scanners/secret-patterns.js +3 -3
  35. package/dist/utils/binary-manager.js +59 -20
  36. package/dist/utils/skill-manager.js +5 -3
  37. package/package.json +2 -1
  38. package/resources/pre-commit-hook.sh +2 -2
  39. package/resources/pre-push-hook.sh +60 -0
  40. package/resources/rafter-security-skill.md +7 -11
@@ -3,31 +3,65 @@ import { CommandInterceptor } from "../../core/command-interceptor.js";
3
3
  import { RegexScanner } from "../../scanners/regex-scanner.js";
4
4
  import { AuditLogger } from "../../core/audit-logger.js";
5
5
  import { execSync } from "child_process";
6
+ const RISK_LABELS = {
7
+ critical: "CRITICAL", high: "HIGH", medium: "MEDIUM", low: "LOW",
8
+ };
9
+ const RISK_DESCRIPTIONS = {
10
+ critical: "irreversible system damage",
11
+ high: "significant system changes",
12
+ medium: "moderate risk operation",
13
+ low: "minimal risk",
14
+ };
15
+ function formatBlockedMessage(command, evaluation) {
16
+ const cmdDisplay = command.length > 60 ? command.slice(0, 60) + "..." : command;
17
+ const rule = evaluation.matchedPattern ?? "policy violation";
18
+ const label = RISK_LABELS[evaluation.riskLevel] ?? evaluation.riskLevel.toUpperCase();
19
+ const desc = RISK_DESCRIPTIONS[evaluation.riskLevel] ?? "";
20
+ return `\u2717 Rafter blocked: ${cmdDisplay}\n Rule: ${rule}\n Risk: ${label}\u2014${desc}`;
21
+ }
22
+ function formatApprovalMessage(command, evaluation) {
23
+ const cmdDisplay = command.length > 60 ? command.slice(0, 60) + "..." : command;
24
+ const rule = evaluation.matchedPattern ?? "policy match";
25
+ const label = RISK_LABELS[evaluation.riskLevel] ?? evaluation.riskLevel.toUpperCase();
26
+ const desc = RISK_DESCRIPTIONS[evaluation.riskLevel] ?? "";
27
+ return `\u26a0 Rafter: approval required\n Command: ${cmdDisplay}\n Rule: ${rule}\n Risk: ${label}\u2014${desc}\n\nTo approve: rafter agent exec --approve "${command}"\nTo configure: rafter agent config set agent.riskLevel minimal`;
28
+ }
6
29
  export function createHookPretoolCommand() {
7
30
  return new Command("pretool")
8
31
  .description("PreToolUse hook handler (reads stdin, writes JSON decision to stdout)")
9
32
  .action(async () => {
10
- const input = await readStdin();
11
- let payload;
12
33
  try {
13
- payload = JSON.parse(input);
34
+ const input = await readStdin();
35
+ let payload;
36
+ try {
37
+ payload = JSON.parse(input);
38
+ }
39
+ catch {
40
+ // Can't parse → fail open
41
+ writeDecision({ decision: "allow" });
42
+ return;
43
+ }
44
+ // Validate payload is an object with expected shape
45
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
46
+ writeDecision({ decision: "allow" });
47
+ return;
48
+ }
49
+ const decision = evaluateToolCall(payload);
50
+ writeDecision(decision);
14
51
  }
15
52
  catch {
16
- // Can't parse → fail open
53
+ // Any unexpected error → fail open
17
54
  writeDecision({ decision: "allow" });
18
- return;
19
55
  }
20
- const decision = evaluateToolCall(payload);
21
- writeDecision(decision);
22
56
  });
23
57
  }
24
58
  function evaluateToolCall(payload) {
25
59
  const { tool_name, tool_input } = payload;
26
60
  if (tool_name === "Bash") {
27
- return evaluateBash(tool_input.command || "");
61
+ return evaluateBash(tool_input?.command || "");
28
62
  }
29
63
  if (tool_name === "Write" || tool_name === "Edit") {
30
- return evaluateWrite(tool_input);
64
+ return evaluateWrite(tool_input || {});
31
65
  }
32
66
  return { decision: "allow" };
33
67
  }
@@ -40,7 +74,7 @@ function evaluateBash(command) {
40
74
  audit.logCommandIntercepted(command, false, "blocked", evaluation.reason);
41
75
  return {
42
76
  decision: "deny",
43
- reason: `Blocked by Rafter policy: ${evaluation.reason}`,
77
+ reason: formatBlockedMessage(command, evaluation),
44
78
  };
45
79
  }
46
80
  // Requires approval — deny (agent can't provide interactive approval)
@@ -48,7 +82,7 @@ function evaluateBash(command) {
48
82
  audit.logCommandIntercepted(command, false, "blocked", evaluation.reason);
49
83
  return {
50
84
  decision: "deny",
51
- reason: `Rafter policy requires approval: ${evaluation.reason}`,
85
+ reason: formatApprovalMessage(command, evaluation),
52
86
  };
53
87
  }
54
88
  // Git commit/push — scan staged files for secrets
@@ -59,7 +93,7 @@ function evaluateBash(command) {
59
93
  audit.logSecretDetected("staged files", `${scanResult.count} secret(s)`, "blocked");
60
94
  return {
61
95
  decision: "deny",
62
- reason: `${scanResult.count} secret(s) detected in ${scanResult.files} staged file(s). Run 'rafter agent scan --staged' for details.`,
96
+ reason: `${scanResult.count} secret(s) detected in ${scanResult.files} staged file(s). Run 'rafter scan local --staged' for details.`,
63
97
  };
64
98
  }
65
99
  }
@@ -108,12 +142,15 @@ function scanStagedFiles() {
108
142
  return { secretsFound: false, count: 0, files: 0 };
109
143
  }
110
144
  }
145
+ const STDIN_TIMEOUT_MS = 5000;
111
146
  function readStdin() {
112
147
  return new Promise((resolve) => {
113
148
  let data = "";
149
+ const timeout = setTimeout(() => { resolve(data); }, STDIN_TIMEOUT_MS);
114
150
  process.stdin.setEncoding("utf-8");
115
151
  process.stdin.on("data", (chunk) => { data += chunk; });
116
- process.stdin.on("end", () => { resolve(data); });
152
+ process.stdin.on("end", () => { clearTimeout(timeout); resolve(data); });
153
+ process.stdin.on("error", () => { clearTimeout(timeout); resolve(data); });
117
154
  process.stdin.resume();
118
155
  });
119
156
  }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Deduplication logic for GitHub issues created from scan findings.
3
+ *
4
+ * Uses a fingerprint hash (file + pattern/ruleId) embedded in the issue body
5
+ * to avoid creating duplicate issues for the same finding.
6
+ */
7
+ import crypto from "crypto";
8
+ const FINGERPRINT_PREFIX = "<!-- rafter-fingerprint:";
9
+ const FINGERPRINT_SUFFIX = " -->";
10
+ export function fingerprint(file, ruleId) {
11
+ const hash = crypto
12
+ .createHash("sha256")
13
+ .update(`${file}:${ruleId}`)
14
+ .digest("hex")
15
+ .slice(0, 12);
16
+ return hash;
17
+ }
18
+ export function embedFingerprint(body, fp) {
19
+ return `${body}\n\n${FINGERPRINT_PREFIX}${fp}${FINGERPRINT_SUFFIX}`;
20
+ }
21
+ export function extractFingerprint(body) {
22
+ const idx = body.indexOf(FINGERPRINT_PREFIX);
23
+ if (idx === -1)
24
+ return null;
25
+ const start = idx + FINGERPRINT_PREFIX.length;
26
+ const end = body.indexOf(FINGERPRINT_SUFFIX, start);
27
+ if (end === -1)
28
+ return null;
29
+ return body.slice(start, end);
30
+ }
31
+ export function findDuplicates(existingIssues, newFingerprints) {
32
+ const existingFps = new Set();
33
+ for (const issue of existingIssues) {
34
+ const fp = extractFingerprint(issue.body);
35
+ if (fp)
36
+ existingFps.add(fp);
37
+ }
38
+ return new Set(newFingerprints.filter((fp) => existingFps.has(fp)));
39
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * rafter issues create from-scan — create GitHub issues from scan results.
3
+ *
4
+ * Supports both:
5
+ * - Backend scans: --scan-id <id> (fetches from Rafter API)
6
+ * - Local scans: --from-local <path> (reads JSON file from `rafter scan local --format json`)
7
+ */
8
+ import { Command } from "commander";
9
+ import fs from "fs";
10
+ import axios from "axios";
11
+ import { API, resolveKey, EXIT_GENERAL_ERROR } from "../../utils/api.js";
12
+ import { detectRepo } from "../../utils/git.js";
13
+ import { fmt } from "../../utils/formatter.js";
14
+ import { createIssue, listOpenIssues } from "./github-client.js";
15
+ import { findDuplicates } from "./dedup.js";
16
+ import { buildFromBackendVulnerability, buildFromLocalMatch, } from "./issue-builder.js";
17
+ export function createFromScanCommand() {
18
+ return new Command("from-scan")
19
+ .description("Create GitHub issues from scan results")
20
+ .option("--scan-id <id>", "Backend scan ID to create issues from")
21
+ .option("--from-local <path>", "Path to local scan JSON (from rafter scan local --format json)")
22
+ .option("-r, --repo <repo>", "Target GitHub repo (org/repo)")
23
+ .option("-k, --api-key <key>", "Rafter API key (for --scan-id)")
24
+ .option("--no-dedup", "Skip deduplication check")
25
+ .option("--dry-run", "Show issues that would be created without creating them")
26
+ .option("--quiet", "Suppress status messages")
27
+ .action(async (opts) => {
28
+ try {
29
+ await runFromScan(opts);
30
+ }
31
+ catch (e) {
32
+ console.error(fmt.error(e.message || String(e)));
33
+ process.exit(EXIT_GENERAL_ERROR);
34
+ }
35
+ });
36
+ }
37
+ async function runFromScan(opts) {
38
+ if (!opts.scanId && !opts.fromLocal) {
39
+ console.error(fmt.error("Provide --scan-id or --from-local"));
40
+ process.exit(EXIT_GENERAL_ERROR);
41
+ }
42
+ // Resolve target repo
43
+ let repo;
44
+ if (opts.repo) {
45
+ repo = opts.repo;
46
+ }
47
+ else {
48
+ const detected = detectRepo({});
49
+ repo = detected.repo;
50
+ }
51
+ if (!opts.quiet) {
52
+ console.error(fmt.info(`Target repo: ${repo}`));
53
+ }
54
+ // Build issue drafts from scan results
55
+ let drafts;
56
+ if (opts.scanId) {
57
+ drafts = await draftsFromBackendScan(opts.scanId, opts.apiKey);
58
+ }
59
+ else {
60
+ drafts = draftsFromLocalScan(opts.fromLocal);
61
+ }
62
+ if (drafts.length === 0) {
63
+ if (!opts.quiet) {
64
+ console.error(fmt.success("No findings to create issues for"));
65
+ }
66
+ return;
67
+ }
68
+ if (!opts.quiet) {
69
+ console.error(fmt.info(`Found ${drafts.length} findings`));
70
+ }
71
+ // Dedup against existing open issues
72
+ if (opts.dedup !== false) {
73
+ const existing = listOpenIssues(repo);
74
+ const dupes = findDuplicates(existing, drafts.map((d) => d.fingerprint));
75
+ const before = drafts.length;
76
+ drafts = drafts.filter((d) => !dupes.has(d.fingerprint));
77
+ if (before !== drafts.length && !opts.quiet) {
78
+ console.error(fmt.info(`Skipped ${before - drafts.length} duplicate(s)`));
79
+ }
80
+ }
81
+ if (drafts.length === 0) {
82
+ if (!opts.quiet) {
83
+ console.error(fmt.success("All findings already have open issues"));
84
+ }
85
+ return;
86
+ }
87
+ // Dry run — print what would be created
88
+ if (opts.dryRun) {
89
+ console.error(fmt.info(`Would create ${drafts.length} issue(s):`));
90
+ for (const draft of drafts) {
91
+ console.error(` - ${draft.title}`);
92
+ }
93
+ // Output drafts as JSON to stdout for piping
94
+ process.stdout.write(JSON.stringify(drafts, null, 2));
95
+ return;
96
+ }
97
+ // Create issues
98
+ const created = [];
99
+ for (const draft of drafts) {
100
+ try {
101
+ const issue = createIssue({
102
+ repo,
103
+ title: draft.title,
104
+ body: draft.body,
105
+ labels: draft.labels,
106
+ });
107
+ created.push(issue.html_url);
108
+ if (!opts.quiet) {
109
+ console.error(fmt.success(`Created: ${issue.html_url}`));
110
+ }
111
+ }
112
+ catch (e) {
113
+ console.error(fmt.error(`Failed to create issue: ${e.message}`));
114
+ }
115
+ }
116
+ // Output created issue URLs to stdout
117
+ if (created.length > 0) {
118
+ process.stdout.write(created.join("\n") + "\n");
119
+ }
120
+ if (!opts.quiet) {
121
+ console.error(fmt.success(`Created ${created.length}/${drafts.length} issue(s)`));
122
+ }
123
+ }
124
+ async function draftsFromBackendScan(scanId, apiKey) {
125
+ const key = resolveKey(apiKey);
126
+ const { data } = await axios.get(`${API}/static/scan`, {
127
+ params: { scan_id: scanId, format: "json" },
128
+ headers: { "x-api-key": key },
129
+ });
130
+ const vulns = data.vulnerabilities || [];
131
+ return vulns.map(buildFromBackendVulnerability);
132
+ }
133
+ function draftsFromLocalScan(filePath) {
134
+ const raw = fs.readFileSync(filePath, "utf-8");
135
+ const results = JSON.parse(raw);
136
+ const drafts = [];
137
+ for (const result of results) {
138
+ for (const match of result.matches) {
139
+ drafts.push(buildFromLocalMatch(result.file, match));
140
+ }
141
+ }
142
+ return drafts;
143
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * rafter issues create from-text — create a GitHub issue from natural language.
3
+ *
4
+ * Input sources: stdin (pipe), --file, --text inline.
5
+ * Parses natural text to extract title, description, labels, severity, affected files.
6
+ * Works as a skill: /rafter-issue in Claude Code / OpenClaw.
7
+ */
8
+ import { Command } from "commander";
9
+ import fs from "fs";
10
+ import { detectRepo } from "../../utils/git.js";
11
+ import { fmt } from "../../utils/formatter.js";
12
+ import { EXIT_GENERAL_ERROR } from "../../utils/api.js";
13
+ import { createIssue } from "./github-client.js";
14
+ export function createFromTextCommand() {
15
+ return new Command("from-text")
16
+ .description("Create a GitHub issue from natural language text (stdin, file, or inline)")
17
+ .option("-r, --repo <repo>", "Target GitHub repo (org/repo)")
18
+ .option("-t, --text <text>", "Inline text to convert to an issue")
19
+ .option("-f, --file <path>", "Read text from file")
20
+ .option("--title <title>", "Override extracted title")
21
+ .option("--labels <labels>", "Comma-separated labels to add")
22
+ .option("--dry-run", "Show parsed issue without creating it")
23
+ .option("--quiet", "Suppress status messages")
24
+ .action(async (opts) => {
25
+ try {
26
+ await runFromText(opts);
27
+ }
28
+ catch (e) {
29
+ console.error(fmt.error(e.message || String(e)));
30
+ process.exit(EXIT_GENERAL_ERROR);
31
+ }
32
+ });
33
+ }
34
+ async function runFromText(opts) {
35
+ // Read input text
36
+ const input = await readInput(opts);
37
+ if (!input.trim()) {
38
+ console.error(fmt.error("No input text provided. Use --text, --file, or pipe via stdin"));
39
+ process.exit(EXIT_GENERAL_ERROR);
40
+ }
41
+ // Resolve target repo
42
+ let repo;
43
+ if (opts.repo) {
44
+ repo = opts.repo;
45
+ }
46
+ else {
47
+ const detected = detectRepo({});
48
+ repo = detected.repo;
49
+ }
50
+ // Parse the natural text into a structured issue
51
+ const parsed = parseNaturalText(input);
52
+ // Apply overrides
53
+ if (opts.title)
54
+ parsed.title = opts.title;
55
+ if (opts.labels) {
56
+ const extra = opts.labels.split(",").map((l) => l.trim()).filter(Boolean);
57
+ parsed.labels.push(...extra);
58
+ }
59
+ if (!opts.quiet) {
60
+ console.error(fmt.info(`Target repo: ${repo}`));
61
+ console.error(fmt.info(`Title: ${parsed.title}`));
62
+ if (parsed.labels.length) {
63
+ console.error(fmt.info(`Labels: ${parsed.labels.join(", ")}`));
64
+ }
65
+ }
66
+ if (opts.dryRun) {
67
+ process.stdout.write(JSON.stringify(parsed, null, 2));
68
+ return;
69
+ }
70
+ const issue = createIssue({
71
+ repo,
72
+ title: parsed.title,
73
+ body: parsed.body,
74
+ labels: parsed.labels,
75
+ });
76
+ if (!opts.quiet) {
77
+ console.error(fmt.success(`Created: ${issue.html_url}`));
78
+ }
79
+ process.stdout.write(issue.html_url + "\n");
80
+ }
81
+ async function readInput(opts) {
82
+ if (opts.text)
83
+ return opts.text;
84
+ if (opts.file)
85
+ return fs.readFileSync(opts.file, "utf-8");
86
+ // Read from stdin if available (piped input)
87
+ if (!process.stdin.isTTY) {
88
+ return new Promise((resolve) => {
89
+ let data = "";
90
+ process.stdin.setEncoding("utf-8");
91
+ process.stdin.on("data", (chunk) => (data += chunk));
92
+ process.stdin.on("end", () => resolve(data));
93
+ });
94
+ }
95
+ return "";
96
+ }
97
+ /**
98
+ * Parse natural language text into a structured GitHub issue.
99
+ *
100
+ * Heuristics-based extraction:
101
+ * - First line or sentence → title
102
+ * - Severity keywords → labels
103
+ * - File paths → mentioned in body
104
+ * - Security keywords → security label
105
+ */
106
+ function parseNaturalText(text) {
107
+ const lines = text.trim().split("\n");
108
+ const labels = [];
109
+ // Extract title: first non-empty line, cleaned up
110
+ let title = "";
111
+ let bodyStart = 0;
112
+ for (let i = 0; i < lines.length; i++) {
113
+ const line = lines[i].trim();
114
+ if (line) {
115
+ // Strip markdown headers
116
+ title = line.replace(/^#+\s*/, "").trim();
117
+ bodyStart = i + 1;
118
+ break;
119
+ }
120
+ }
121
+ if (!title) {
122
+ title = "Security issue reported via Rafter CLI";
123
+ }
124
+ // Truncate long titles
125
+ if (title.length > 120) {
126
+ title = title.slice(0, 117) + "...";
127
+ }
128
+ // Build body from remaining text
129
+ const bodyLines = lines.slice(bodyStart);
130
+ let body = bodyLines.join("\n").trim();
131
+ if (!body) {
132
+ body = text.trim();
133
+ }
134
+ // Extract severity from text
135
+ const textLower = text.toLowerCase();
136
+ if (textLower.includes("critical") || textLower.includes("p0")) {
137
+ labels.push("severity:critical");
138
+ }
139
+ else if (textLower.includes("high severity") ||
140
+ textLower.includes("high risk") ||
141
+ textLower.includes("p1")) {
142
+ labels.push("severity:high");
143
+ }
144
+ else if (textLower.includes("medium") || textLower.includes("p2")) {
145
+ labels.push("severity:medium");
146
+ }
147
+ else if (textLower.includes("low") || textLower.includes("p3")) {
148
+ labels.push("severity:low");
149
+ }
150
+ // Detect security-related content
151
+ const securityKeywords = [
152
+ "security",
153
+ "vulnerability",
154
+ "cve",
155
+ "cwe",
156
+ "owasp",
157
+ "secret",
158
+ "credential",
159
+ "token",
160
+ "password",
161
+ "injection",
162
+ "xss",
163
+ "csrf",
164
+ "ssrf",
165
+ "exploit",
166
+ ];
167
+ if (securityKeywords.some((kw) => textLower.includes(kw))) {
168
+ labels.push("security");
169
+ }
170
+ // Extract file paths mentioned in text
171
+ const fileRefs = text.match(/(?:^|\s)([a-zA-Z0-9_./-]+\.[a-zA-Z]{1,10})(?::(\d+))?/gm);
172
+ if (fileRefs && fileRefs.length > 0) {
173
+ const files = fileRefs
174
+ .map((f) => f.trim())
175
+ .filter((f) => f.includes("/") || f.includes("."));
176
+ if (files.length > 0) {
177
+ body += `\n\n### Referenced Files\n\n`;
178
+ for (const f of files.slice(0, 10)) {
179
+ body += `- \`${f}\`\n`;
180
+ }
181
+ }
182
+ }
183
+ body += `\n\n---\n*Created by [Rafter CLI](https://rafter.so) — security for AI builders*\n`;
184
+ return { title, body, labels: [...new Set(labels)] };
185
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * GitHub API client — wraps `gh` CLI for issue operations.
3
+ *
4
+ * Prefers `gh` CLI (handles auth, rate limits, pagination) over raw REST.
5
+ * Falls back to GITHUB_TOKEN + REST if gh is unavailable.
6
+ */
7
+ import { execFileSync } from "child_process";
8
+ function ghAvailable() {
9
+ try {
10
+ execFileSync("gh", ["--version"], { stdio: "ignore" });
11
+ return true;
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
17
+ export function createIssue(params) {
18
+ const { repo, title, body, labels } = params;
19
+ if (!ghAvailable()) {
20
+ throw new Error("GitHub CLI (gh) is required. Install: https://cli.github.com");
21
+ }
22
+ const args = [
23
+ "issue",
24
+ "create",
25
+ "--repo",
26
+ repo,
27
+ "--title",
28
+ title,
29
+ "--body",
30
+ body,
31
+ ];
32
+ if (labels && labels.length > 0) {
33
+ for (const label of labels) {
34
+ args.push("--label", label);
35
+ }
36
+ }
37
+ const result = execFileSync("gh", args, {
38
+ encoding: "utf-8",
39
+ stdio: ["ignore", "pipe", "pipe"],
40
+ }).trim();
41
+ // gh issue create returns the issue URL
42
+ const urlMatch = result.match(/https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/(\d+)/);
43
+ const number = urlMatch ? parseInt(urlMatch[1], 10) : 0;
44
+ return {
45
+ number,
46
+ title,
47
+ body,
48
+ labels: labels || [],
49
+ html_url: result,
50
+ state: "open",
51
+ };
52
+ }
53
+ export function listOpenIssues(repo) {
54
+ if (!ghAvailable()) {
55
+ return [];
56
+ }
57
+ try {
58
+ const result = execFileSync("gh", [
59
+ "issue",
60
+ "list",
61
+ "--repo",
62
+ repo,
63
+ "--state",
64
+ "open",
65
+ "--json",
66
+ "number,title,body,labels,url",
67
+ "--limit",
68
+ "200",
69
+ ], { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim();
70
+ if (!result)
71
+ return [];
72
+ const issues = JSON.parse(result);
73
+ return issues.map((i) => ({
74
+ number: i.number,
75
+ title: i.title,
76
+ body: i.body || "",
77
+ labels: (i.labels || []).map((l) => l.name),
78
+ html_url: i.url,
79
+ state: "open",
80
+ }));
81
+ }
82
+ catch {
83
+ return [];
84
+ }
85
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * rafter issues — GitHub Issues integration.
3
+ *
4
+ * Subcommands:
5
+ * rafter issues create --from-scan <id> Create issues from backend scan results
6
+ * rafter issues create --from-local <path> Create issues from local scan JSON
7
+ * rafter issues create --from-text Create issue from natural text (stdin/file/inline)
8
+ */
9
+ import { Command } from "commander";
10
+ import { createFromScanCommand } from "./from-scan.js";
11
+ import { createFromTextCommand } from "./from-text.js";
12
+ export function createIssuesCommand() {
13
+ const issuesGroup = new Command("issues")
14
+ .description("GitHub Issues integration — create issues from scan results or text");
15
+ const createGroup = new Command("create")
16
+ .description("Create GitHub issues from scan findings or natural text");
17
+ createGroup.addCommand(createFromScanCommand());
18
+ createGroup.addCommand(createFromTextCommand());
19
+ // Default action for `rafter issues create` with no subcommand — show help
20
+ createGroup.action(() => {
21
+ createGroup.help();
22
+ });
23
+ issuesGroup.addCommand(createGroup);
24
+ return issuesGroup;
25
+ }