newpr 0.1.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.
Files changed (82) hide show
  1. package/README.md +189 -0
  2. package/package.json +78 -0
  3. package/src/analyzer/errors.ts +22 -0
  4. package/src/analyzer/pipeline.ts +299 -0
  5. package/src/analyzer/progress.ts +69 -0
  6. package/src/cli/args.ts +192 -0
  7. package/src/cli/auth.ts +82 -0
  8. package/src/cli/history-cmd.ts +64 -0
  9. package/src/cli/index.ts +115 -0
  10. package/src/cli/pretty.ts +79 -0
  11. package/src/config/index.ts +103 -0
  12. package/src/config/store.ts +50 -0
  13. package/src/diff/chunker.ts +30 -0
  14. package/src/diff/parser.ts +116 -0
  15. package/src/diff/stats.ts +37 -0
  16. package/src/github/auth.ts +16 -0
  17. package/src/github/fetch-diff.ts +24 -0
  18. package/src/github/fetch-pr.ts +90 -0
  19. package/src/github/parse-pr.ts +39 -0
  20. package/src/history/store.ts +96 -0
  21. package/src/history/types.ts +15 -0
  22. package/src/llm/claude-code-client.ts +134 -0
  23. package/src/llm/client.ts +240 -0
  24. package/src/llm/prompts.ts +176 -0
  25. package/src/llm/response-parser.ts +71 -0
  26. package/src/tui/App.tsx +97 -0
  27. package/src/tui/Footer.tsx +34 -0
  28. package/src/tui/Header.tsx +27 -0
  29. package/src/tui/HelpOverlay.tsx +46 -0
  30. package/src/tui/InputBar.tsx +65 -0
  31. package/src/tui/Loading.tsx +192 -0
  32. package/src/tui/Shell.tsx +384 -0
  33. package/src/tui/TabBar.tsx +31 -0
  34. package/src/tui/commands.ts +75 -0
  35. package/src/tui/narrative-parser.ts +143 -0
  36. package/src/tui/panels/FilesPanel.tsx +134 -0
  37. package/src/tui/panels/GroupsPanel.tsx +140 -0
  38. package/src/tui/panels/NarrativePanel.tsx +102 -0
  39. package/src/tui/panels/StoryPanel.tsx +296 -0
  40. package/src/tui/panels/SummaryPanel.tsx +59 -0
  41. package/src/tui/panels/WalkthroughPanel.tsx +149 -0
  42. package/src/tui/render.tsx +62 -0
  43. package/src/tui/theme.ts +44 -0
  44. package/src/types/config.ts +19 -0
  45. package/src/types/diff.ts +36 -0
  46. package/src/types/github.ts +28 -0
  47. package/src/types/output.ts +59 -0
  48. package/src/web/client/App.tsx +121 -0
  49. package/src/web/client/components/AppShell.tsx +203 -0
  50. package/src/web/client/components/DetailPane.tsx +141 -0
  51. package/src/web/client/components/ErrorScreen.tsx +119 -0
  52. package/src/web/client/components/InputScreen.tsx +41 -0
  53. package/src/web/client/components/LoadingTimeline.tsx +179 -0
  54. package/src/web/client/components/Markdown.tsx +109 -0
  55. package/src/web/client/components/ResizeHandle.tsx +45 -0
  56. package/src/web/client/components/ResultsScreen.tsx +185 -0
  57. package/src/web/client/components/SettingsPanel.tsx +299 -0
  58. package/src/web/client/hooks/useAnalysis.ts +153 -0
  59. package/src/web/client/hooks/useGithubUser.ts +24 -0
  60. package/src/web/client/hooks/useSessions.ts +17 -0
  61. package/src/web/client/hooks/useTheme.ts +34 -0
  62. package/src/web/client/main.tsx +12 -0
  63. package/src/web/client/panels/FilesPanel.tsx +85 -0
  64. package/src/web/client/panels/GroupsPanel.tsx +62 -0
  65. package/src/web/client/panels/NarrativePanel.tsx +9 -0
  66. package/src/web/client/panels/StoryPanel.tsx +54 -0
  67. package/src/web/client/panels/SummaryPanel.tsx +20 -0
  68. package/src/web/components/ui/button.tsx +46 -0
  69. package/src/web/components/ui/card.tsx +37 -0
  70. package/src/web/components/ui/scroll-area.tsx +39 -0
  71. package/src/web/components/ui/tabs.tsx +52 -0
  72. package/src/web/index.html +14 -0
  73. package/src/web/lib/utils.ts +6 -0
  74. package/src/web/server/routes.ts +202 -0
  75. package/src/web/server/session-manager.ts +147 -0
  76. package/src/web/server.ts +96 -0
  77. package/src/web/styles/globals.css +91 -0
  78. package/src/workspace/agent.ts +317 -0
  79. package/src/workspace/explore.ts +82 -0
  80. package/src/workspace/repo-cache.ts +69 -0
  81. package/src/workspace/types.ts +30 -0
  82. package/src/workspace/worktree.ts +129 -0
@@ -0,0 +1,30 @@
1
+ import type { DiffChunk, ParsedDiff } from "../types/diff.ts";
2
+
3
+ const DEFAULT_MAX_TOKENS = 8000;
4
+
5
+ function estimateTokens(text: string): number {
6
+ return Math.ceil(text.length / 4);
7
+ }
8
+
9
+ export function chunkDiff(parsed: ParsedDiff, maxTokensPerChunk = DEFAULT_MAX_TOKENS): DiffChunk[] {
10
+ return parsed.files.map((file) => {
11
+ let diffContent = file.raw;
12
+ let tokens = estimateTokens(diffContent);
13
+
14
+ if (tokens > maxTokensPerChunk) {
15
+ const maxChars = maxTokensPerChunk * 4;
16
+ diffContent = `${diffContent.slice(0, maxChars)}\n\n... [truncated: ${tokens} estimated tokens, limit ${maxTokensPerChunk}]`;
17
+ tokens = estimateTokens(diffContent);
18
+ }
19
+
20
+ return {
21
+ file_path: file.path,
22
+ status: file.status,
23
+ additions: file.additions,
24
+ deletions: file.deletions,
25
+ is_binary: file.is_binary,
26
+ diff_content: diffContent,
27
+ estimated_tokens: tokens,
28
+ };
29
+ });
30
+ }
@@ -0,0 +1,116 @@
1
+ import type { DiffHunk, FileDiff, ParsedDiff } from "../types/diff.ts";
2
+ import type { FileStatus } from "../types/output.ts";
3
+
4
+ function detectStatus(headerLines: string[]): FileStatus {
5
+ for (const line of headerLines) {
6
+ if (line.startsWith("new file mode")) return "added";
7
+ if (line.startsWith("deleted file mode")) return "deleted";
8
+ if (line.startsWith("rename from") || line.startsWith("rename to")) return "renamed";
9
+ }
10
+ return "modified";
11
+ }
12
+
13
+ function extractPaths(
14
+ headerLines: string[],
15
+ diffLine: string,
16
+ ): { path: string; oldPath: string | null } {
17
+ const diffMatch = diffLine.match(/^diff --git a\/(.+?) b\/(.+)$/);
18
+ const aPath = diffMatch?.[1] ?? "";
19
+ const bPath = diffMatch?.[2] ?? "";
20
+
21
+ let renameTo: string | null = null;
22
+ let renameFrom: string | null = null;
23
+ for (const line of headerLines) {
24
+ if (line.startsWith("rename from ")) renameFrom = line.slice("rename from ".length);
25
+ if (line.startsWith("rename to ")) renameTo = line.slice("rename to ".length);
26
+ }
27
+
28
+ if (renameTo) {
29
+ return { path: renameTo, oldPath: renameFrom };
30
+ }
31
+ return { path: bPath || aPath, oldPath: aPath !== bPath ? aPath : null };
32
+ }
33
+
34
+ function parseHunks(lines: string[]): { hunks: DiffHunk[]; additions: number; deletions: number } {
35
+ const hunks: DiffHunk[] = [];
36
+ let additions = 0;
37
+ let deletions = 0;
38
+ let currentHunk: { old_start: number; old_count: number; new_start: number; new_count: number; lines: string[] } | null = null;
39
+
40
+ for (const line of lines) {
41
+ const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
42
+ if (hunkMatch) {
43
+ if (currentHunk) {
44
+ hunks.push({ ...currentHunk, content: currentHunk.lines.join("\n") });
45
+ }
46
+ currentHunk = {
47
+ old_start: Number(hunkMatch[1]),
48
+ old_count: Number(hunkMatch[2] ?? "1"),
49
+ new_start: Number(hunkMatch[3]),
50
+ new_count: Number(hunkMatch[4] ?? "1"),
51
+ lines: [line],
52
+ };
53
+ continue;
54
+ }
55
+
56
+ if (currentHunk) {
57
+ currentHunk.lines.push(line);
58
+ if (line.startsWith("+") && !line.startsWith("+++")) additions++;
59
+ if (line.startsWith("-") && !line.startsWith("---")) deletions++;
60
+ }
61
+ }
62
+
63
+ if (currentHunk) {
64
+ hunks.push({ ...currentHunk, content: currentHunk.lines.join("\n") });
65
+ }
66
+
67
+ return { hunks, additions, deletions };
68
+ }
69
+
70
+ export function parseDiff(rawDiff: string): ParsedDiff {
71
+ if (!rawDiff.trim()) {
72
+ return { files: [], total_additions: 0, total_deletions: 0 };
73
+ }
74
+
75
+ const files: FileDiff[] = [];
76
+ const fileSections = rawDiff.split(/(?=^diff --git )/m);
77
+
78
+ for (const section of fileSections) {
79
+ if (!section.startsWith("diff --git ")) continue;
80
+
81
+ const lines = section.split("\n");
82
+ const diffLine = lines[0]!;
83
+
84
+ const headerEndIdx = lines.findIndex(
85
+ (l, i) => i > 0 && (l.startsWith("@@") || l.startsWith("Binary")),
86
+ );
87
+ const headerLines = headerEndIdx > 0 ? lines.slice(1, headerEndIdx) : lines.slice(1);
88
+
89
+ const isBinary =
90
+ lines.some((l) => l.startsWith("Binary files") || l.includes("GIT binary patch"));
91
+
92
+ const status = detectStatus(headerLines);
93
+ const { path, oldPath } = extractPaths(headerLines, diffLine);
94
+
95
+ const bodyLines = headerEndIdx > 0 ? lines.slice(headerEndIdx) : [];
96
+ const { hunks, additions, deletions } = isBinary
97
+ ? { hunks: [], additions: 0, deletions: 0 }
98
+ : parseHunks(bodyLines);
99
+
100
+ files.push({
101
+ path,
102
+ old_path: oldPath,
103
+ status,
104
+ additions,
105
+ deletions,
106
+ is_binary: isBinary,
107
+ hunks,
108
+ raw: section,
109
+ });
110
+ }
111
+
112
+ const total_additions = files.reduce((sum, f) => sum + f.additions, 0);
113
+ const total_deletions = files.reduce((sum, f) => sum + f.deletions, 0);
114
+
115
+ return { files, total_additions, total_deletions };
116
+ }
@@ -0,0 +1,37 @@
1
+ import type { ParsedDiff } from "../types/diff.ts";
2
+ import type { FileStatus } from "../types/output.ts";
3
+
4
+ export interface DiffStats {
5
+ total_files: number;
6
+ total_additions: number;
7
+ total_deletions: number;
8
+ files_by_status: Record<FileStatus, number>;
9
+ largest_file: { path: string; changes: number } | null;
10
+ }
11
+
12
+ export function extractDiffStats(parsed: ParsedDiff): DiffStats {
13
+ const filesByStatus: Record<FileStatus, number> = {
14
+ added: 0,
15
+ modified: 0,
16
+ deleted: 0,
17
+ renamed: 0,
18
+ };
19
+
20
+ let largest: { path: string; changes: number } | null = null;
21
+
22
+ for (const file of parsed.files) {
23
+ filesByStatus[file.status]++;
24
+ const changes = file.additions + file.deletions;
25
+ if (!largest || changes > largest.changes) {
26
+ largest = { path: file.path, changes };
27
+ }
28
+ }
29
+
30
+ return {
31
+ total_files: parsed.files.length,
32
+ total_additions: parsed.total_additions,
33
+ total_deletions: parsed.total_deletions,
34
+ files_by_status: filesByStatus,
35
+ largest_file: largest,
36
+ };
37
+ }
@@ -0,0 +1,16 @@
1
+ export async function getGithubToken(): Promise<string> {
2
+ const envToken = process.env.GITHUB_TOKEN;
3
+ if (envToken) return envToken;
4
+
5
+ try {
6
+ const result = await Bun.$`gh auth token`.text();
7
+ const token = result.trim();
8
+ if (token) return token;
9
+ } catch {
10
+ // intentionally empty: fall through to error below
11
+ }
12
+
13
+ throw new Error(
14
+ "GitHub token not found. Either set GITHUB_TOKEN env var or run `gh auth login`.",
15
+ );
16
+ }
@@ -0,0 +1,24 @@
1
+ import type { PrIdentifier } from "../types/github.ts";
2
+
3
+ export async function fetchPrDiff(pr: PrIdentifier, token: string): Promise<string> {
4
+ const url = `https://api.github.com/repos/${pr.owner}/${pr.repo}/pulls/${pr.number}`;
5
+ const response = await fetch(url, {
6
+ headers: {
7
+ Authorization: `token ${token}`,
8
+ Accept: "application/vnd.github.v3.diff",
9
+ "User-Agent": "newpr-cli",
10
+ },
11
+ });
12
+
13
+ if (response.status === 404) {
14
+ throw new Error(`PR not found: ${pr.owner}/${pr.repo}#${pr.number}`);
15
+ }
16
+ if (response.status === 401 || response.status === 403) {
17
+ throw new Error(`GitHub authentication failed. Check your token permissions.`);
18
+ }
19
+ if (!response.ok) {
20
+ throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
21
+ }
22
+
23
+ return response.text();
24
+ }
@@ -0,0 +1,90 @@
1
+ import type { GithubPrData, PrCommit, PrIdentifier } from "../types/github.ts";
2
+
3
+ export function mapPrResponse(json: Record<string, unknown>): Omit<GithubPrData, "commits"> {
4
+ const user = json.user as Record<string, unknown> | undefined;
5
+ const base = json.base as Record<string, unknown> | undefined;
6
+ const head = json.head as Record<string, unknown> | undefined;
7
+
8
+ return {
9
+ number: json.number as number,
10
+ title: json.title as string,
11
+ url: json.html_url as string,
12
+ base_branch: (base?.ref as string) ?? "unknown",
13
+ head_branch: (head?.ref as string) ?? "unknown",
14
+ author: (user?.login as string) ?? "unknown",
15
+ author_avatar: (user?.avatar_url as string) ?? undefined,
16
+ author_url: (user?.html_url as string) ?? undefined,
17
+ additions: (json.additions as number) ?? 0,
18
+ deletions: (json.deletions as number) ?? 0,
19
+ changed_files: (json.changed_files as number) ?? 0,
20
+ };
21
+ }
22
+
23
+ interface GithubCommitResponse {
24
+ sha: string;
25
+ commit: {
26
+ message: string;
27
+ author: { name: string; date: string };
28
+ };
29
+ files?: Array<{ filename: string }>;
30
+ }
31
+
32
+ export function mapCommitsResponse(items: GithubCommitResponse[]): PrCommit[] {
33
+ return items.map((item) => ({
34
+ sha: item.sha.slice(0, 8),
35
+ message: item.commit.message,
36
+ author: item.commit.author.name,
37
+ date: item.commit.author.date,
38
+ files: item.files?.map((f) => f.filename) ?? [],
39
+ }));
40
+ }
41
+
42
+ async function githubGet(url: string, token: string): Promise<Response> {
43
+ const response = await fetch(url, {
44
+ headers: {
45
+ Authorization: `token ${token}`,
46
+ Accept: "application/vnd.github.v3+json",
47
+ "User-Agent": "newpr-cli",
48
+ },
49
+ });
50
+
51
+ if (response.status === 404) {
52
+ throw new Error(`Not found: ${url}`);
53
+ }
54
+ if (response.status === 401 || response.status === 403) {
55
+ throw new Error("GitHub authentication failed. Check your token permissions.");
56
+ }
57
+ if (!response.ok) {
58
+ throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
59
+ }
60
+
61
+ return response;
62
+ }
63
+
64
+ export async function fetchPrCommits(pr: PrIdentifier, token: string): Promise<PrCommit[]> {
65
+ const allCommits: GithubCommitResponse[] = [];
66
+ let page = 1;
67
+
68
+ while (true) {
69
+ const url = `https://api.github.com/repos/${pr.owner}/${pr.repo}/pulls/${pr.number}/commits?per_page=100&page=${page}`;
70
+ const response = await githubGet(url, token);
71
+ const items = (await response.json()) as GithubCommitResponse[];
72
+ if (items.length === 0) break;
73
+ allCommits.push(...items);
74
+ if (items.length < 100) break;
75
+ page++;
76
+ }
77
+
78
+ return mapCommitsResponse(allCommits);
79
+ }
80
+
81
+ export async function fetchPrData(pr: PrIdentifier, token: string): Promise<GithubPrData> {
82
+ const url = `https://api.github.com/repos/${pr.owner}/${pr.repo}/pulls/${pr.number}`;
83
+ const response = await githubGet(url, token);
84
+ const json = (await response.json()) as Record<string, unknown>;
85
+ const base = mapPrResponse(json);
86
+
87
+ const commits = await fetchPrCommits(pr, token);
88
+
89
+ return { ...base, commits };
90
+ }
@@ -0,0 +1,39 @@
1
+ import type { PrIdentifier } from "../types/github.ts";
2
+
3
+ const GITHUB_URL_RE = /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/;
4
+ const OWNER_REPO_NUM_RE = /^([^/]+)\/([^#]+)#(\d+)$/;
5
+
6
+ export function parsePrInput(input: string, repoFlag?: string): PrIdentifier {
7
+ const urlMatch = input.match(GITHUB_URL_RE);
8
+ if (urlMatch) {
9
+ return { owner: urlMatch[1]!, repo: urlMatch[2]!, number: Number(urlMatch[3]) };
10
+ }
11
+
12
+ const ownerRepoMatch = input.match(OWNER_REPO_NUM_RE);
13
+ if (ownerRepoMatch) {
14
+ return {
15
+ owner: ownerRepoMatch[1]!,
16
+ repo: ownerRepoMatch[2]!,
17
+ number: Number(ownerRepoMatch[3]),
18
+ };
19
+ }
20
+
21
+ const numberOnly = input.replace(/^#/, "");
22
+ const prNumber = Number(numberOnly);
23
+ if (!Number.isNaN(prNumber) && prNumber > 0 && Number.isInteger(prNumber)) {
24
+ if (!repoFlag) {
25
+ throw new Error(
26
+ `PR number "${input}" requires --repo flag (e.g., --repo owner/repo)`,
27
+ );
28
+ }
29
+ const parts = repoFlag.split("/");
30
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
31
+ throw new Error(`Invalid --repo format: "${repoFlag}". Expected "owner/repo".`);
32
+ }
33
+ return { owner: parts[0], repo: parts[1], number: prNumber };
34
+ }
35
+
36
+ throw new Error(
37
+ `Cannot parse PR input: "${input}". Expected: URL, owner/repo#123, #123, or 123 with --repo`,
38
+ );
39
+ }
@@ -0,0 +1,96 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { mkdirSync, rmSync, existsSync } from "node:fs";
4
+ import { randomBytes } from "node:crypto";
5
+ import type { NewprOutput } from "../types/output.ts";
6
+ import type { SessionRecord } from "./types.ts";
7
+
8
+ const HISTORY_DIR = join(homedir(), ".newpr", "history");
9
+ const INDEX_FILE = join(HISTORY_DIR, "index.json");
10
+ const SESSIONS_DIR = join(HISTORY_DIR, "sessions");
11
+
12
+ function ensureDirs(): void {
13
+ mkdirSync(SESSIONS_DIR, { recursive: true });
14
+ }
15
+
16
+ export function generateSessionId(): string {
17
+ return randomBytes(8).toString("hex");
18
+ }
19
+
20
+ export function buildSessionRecord(id: string, data: NewprOutput): SessionRecord {
21
+ const { meta, summary } = data;
22
+ const repoParts = meta.pr_url.match(/github\.com\/([^/]+\/[^/]+)/);
23
+ return {
24
+ id,
25
+ pr_url: meta.pr_url,
26
+ pr_number: meta.pr_number,
27
+ pr_title: meta.pr_title,
28
+ repo: repoParts?.[1] ?? "unknown",
29
+ author: meta.author,
30
+ analyzed_at: meta.analyzed_at,
31
+ risk_level: summary.risk_level,
32
+ total_files: meta.total_files_changed,
33
+ total_additions: meta.total_additions,
34
+ total_deletions: meta.total_deletions,
35
+ summary_purpose: summary.purpose,
36
+ data_path: `sessions/${id}.json`,
37
+ };
38
+ }
39
+
40
+ async function readIndex(): Promise<SessionRecord[]> {
41
+ try {
42
+ const file = Bun.file(INDEX_FILE);
43
+ if (!(await file.exists())) return [];
44
+ return JSON.parse(await file.text()) as SessionRecord[];
45
+ } catch {
46
+ return [];
47
+ }
48
+ }
49
+
50
+ async function writeIndex(records: SessionRecord[]): Promise<void> {
51
+ ensureDirs();
52
+ await Bun.write(INDEX_FILE, `${JSON.stringify(records, null, 2)}\n`);
53
+ }
54
+
55
+ export async function saveSession(data: NewprOutput): Promise<SessionRecord> {
56
+ const id = generateSessionId();
57
+ const record = buildSessionRecord(id, data);
58
+
59
+ ensureDirs();
60
+ await Bun.write(join(SESSIONS_DIR, `${id}.json`), JSON.stringify(data, null, 2));
61
+
62
+ const index = await readIndex();
63
+ const deduped = index.filter(
64
+ (r) => !(r.pr_url === record.pr_url && r.pr_number === record.pr_number),
65
+ );
66
+ const updated = [record, ...deduped];
67
+ await writeIndex(updated);
68
+
69
+ return record;
70
+ }
71
+
72
+ export async function listSessions(limit = 20): Promise<SessionRecord[]> {
73
+ const index = await readIndex();
74
+ return index.slice(0, limit);
75
+ }
76
+
77
+ export async function loadSession(id: string): Promise<NewprOutput | null> {
78
+ try {
79
+ const filePath = join(SESSIONS_DIR, `${id}.json`);
80
+ const file = Bun.file(filePath);
81
+ if (!(await file.exists())) return null;
82
+ return JSON.parse(await file.text()) as NewprOutput;
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ export async function clearHistory(): Promise<void> {
89
+ if (existsSync(HISTORY_DIR)) {
90
+ rmSync(HISTORY_DIR, { recursive: true });
91
+ }
92
+ }
93
+
94
+ export function getHistoryPath(): string {
95
+ return HISTORY_DIR;
96
+ }
@@ -0,0 +1,15 @@
1
+ export interface SessionRecord {
2
+ id: string;
3
+ pr_url: string;
4
+ pr_number: number;
5
+ pr_title: string;
6
+ repo: string;
7
+ author: string;
8
+ analyzed_at: string;
9
+ risk_level: string;
10
+ total_files: number;
11
+ total_additions: number;
12
+ total_deletions: number;
13
+ summary_purpose: string;
14
+ data_path: string;
15
+ }
@@ -0,0 +1,134 @@
1
+ import type { LlmClient, LlmResponse, StreamChunkCallback } from "./client.ts";
2
+
3
+ async function findClaude(): Promise<string | null> {
4
+ try {
5
+ const result = await Bun.$`which claude`.text();
6
+ return result.trim() || null;
7
+ } catch {
8
+ return null;
9
+ }
10
+ }
11
+
12
+ let claudePath: string | null | undefined;
13
+
14
+ async function getClaude(): Promise<string> {
15
+ if (claudePath === undefined) {
16
+ claudePath = await findClaude();
17
+ }
18
+ if (!claudePath) {
19
+ throw new Error(
20
+ "Claude Code is not installed.\n\n" +
21
+ "To use newpr without an OpenRouter API key, install Claude Code:\n" +
22
+ " npm install -g @anthropic-ai/claude-code\n\n" +
23
+ "Or set OPENROUTER_API_KEY in your environment.",
24
+ );
25
+ }
26
+ return claudePath;
27
+ }
28
+
29
+ function buildPrompt(systemPrompt: string, userPrompt: string): string {
30
+ return `<system>\n${systemPrompt}\n</system>\n\n${userPrompt}`;
31
+ }
32
+
33
+ export function createClaudeCodeClient(timeout: number): LlmClient {
34
+ return {
35
+ async complete(systemPrompt: string, userPrompt: string): Promise<LlmResponse> {
36
+ const bin = await getClaude();
37
+ const prompt = buildPrompt(systemPrompt, userPrompt);
38
+
39
+ const proc = Bun.spawn(
40
+ [bin, "-p", "--output-format", "text", prompt],
41
+ { cwd: process.cwd(), stdin: "ignore", stdout: "pipe", stderr: "ignore" },
42
+ );
43
+
44
+ const timeoutId = setTimeout(() => proc.kill(), timeout * 1000);
45
+ const content = await new Response(proc.stdout).text();
46
+ clearTimeout(timeoutId);
47
+
48
+ const exitCode = await proc.exited;
49
+ if (exitCode !== 0 || !content.trim()) {
50
+ throw new Error(`Claude Code exited with code ${exitCode}`);
51
+ }
52
+
53
+ return {
54
+ content: content.trim(),
55
+ model: "claude-code",
56
+ usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
57
+ };
58
+ },
59
+
60
+ async completeStream(
61
+ systemPrompt: string,
62
+ userPrompt: string,
63
+ onChunk: StreamChunkCallback,
64
+ ): Promise<LlmResponse> {
65
+ const bin = await getClaude();
66
+ const prompt = buildPrompt(systemPrompt, userPrompt);
67
+
68
+ const proc = Bun.spawn(
69
+ [bin, "-p", "--output-format", "stream-json", prompt],
70
+ { cwd: process.cwd(), stdin: "ignore", stdout: "pipe", stderr: "ignore" },
71
+ );
72
+
73
+ let accumulated = "";
74
+ let resultText = "";
75
+ const reader = proc.stdout!.getReader();
76
+ const decoder = new TextDecoder();
77
+ let buffer = "";
78
+
79
+ const timeoutId = setTimeout(() => proc.kill(), timeout * 1000);
80
+
81
+ try {
82
+ while (true) {
83
+ const { done, value } = await reader.read();
84
+ if (done) break;
85
+ buffer += decoder.decode(value, { stream: true });
86
+ const lines = buffer.split("\n");
87
+ buffer = lines.pop() ?? "";
88
+
89
+ for (const raw of lines) {
90
+ const line = raw.trim();
91
+ if (!line) continue;
92
+ try {
93
+ const event = JSON.parse(line);
94
+ if (event.type === "assistant" && event.message?.content) {
95
+ for (const block of event.message.content) {
96
+ if (block.type === "text" && block.text) {
97
+ accumulated += block.text;
98
+ onChunk(block.text, accumulated);
99
+ }
100
+ }
101
+ } else if (event.type === "result") {
102
+ resultText = event.result ?? accumulated;
103
+ } else if (event.type === "content_block_delta") {
104
+ const delta = event.delta?.text ?? "";
105
+ if (delta) {
106
+ accumulated += delta;
107
+ onChunk(delta, accumulated);
108
+ }
109
+ }
110
+ } catch {}
111
+ }
112
+ }
113
+ } finally {
114
+ reader.releaseLock();
115
+ clearTimeout(timeoutId);
116
+ }
117
+
118
+ const content = resultText || accumulated;
119
+ if (!content.trim()) {
120
+ throw new Error("Claude Code returned empty response");
121
+ }
122
+
123
+ return {
124
+ content: content.trim(),
125
+ model: "claude-code",
126
+ usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
127
+ };
128
+ },
129
+ };
130
+ }
131
+
132
+ export async function isClaudeCodeAvailable(): Promise<boolean> {
133
+ return (await findClaude()) !== null;
134
+ }