openreport 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.
- package/LICENSE +21 -0
- package/README.md +117 -0
- package/bin/openreport.ts +6 -0
- package/package.json +61 -0
- package/src/agents/api-documentation.ts +66 -0
- package/src/agents/architecture-analyst.ts +46 -0
- package/src/agents/code-quality-reviewer.ts +59 -0
- package/src/agents/dependency-analyzer.ts +51 -0
- package/src/agents/onboarding-guide.ts +59 -0
- package/src/agents/orchestrator.ts +41 -0
- package/src/agents/performance-analyzer.ts +57 -0
- package/src/agents/registry.ts +50 -0
- package/src/agents/security-auditor.ts +61 -0
- package/src/agents/test-coverage-analyst.ts +58 -0
- package/src/agents/todo-generator.ts +50 -0
- package/src/app/App.tsx +151 -0
- package/src/app/theme.ts +54 -0
- package/src/cli.ts +145 -0
- package/src/commands/init.ts +81 -0
- package/src/commands/interactive.tsx +29 -0
- package/src/commands/list.ts +53 -0
- package/src/commands/run.ts +168 -0
- package/src/commands/view.tsx +52 -0
- package/src/components/generation/AgentStatusItem.tsx +125 -0
- package/src/components/generation/AgentStatusList.tsx +70 -0
- package/src/components/generation/ProgressSummary.tsx +107 -0
- package/src/components/generation/StreamingOutput.tsx +154 -0
- package/src/components/layout/Container.tsx +24 -0
- package/src/components/layout/Footer.tsx +52 -0
- package/src/components/layout/Header.tsx +50 -0
- package/src/components/report/MarkdownRenderer.tsx +50 -0
- package/src/components/report/ReportCard.tsx +31 -0
- package/src/components/report/ScrollableView.tsx +164 -0
- package/src/config/cli-detection.ts +130 -0
- package/src/config/cli-model.ts +397 -0
- package/src/config/cli-prompt-formatter.ts +129 -0
- package/src/config/defaults.ts +79 -0
- package/src/config/loader.ts +168 -0
- package/src/config/ollama.ts +48 -0
- package/src/config/providers.ts +199 -0
- package/src/config/resolve-provider.ts +62 -0
- package/src/config/saver.ts +50 -0
- package/src/config/schema.ts +51 -0
- package/src/errors.ts +34 -0
- package/src/hooks/useReportGeneration.ts +199 -0
- package/src/hooks/useTerminalSize.ts +35 -0
- package/src/ingestion/context-selector.ts +247 -0
- package/src/ingestion/file-tree.ts +227 -0
- package/src/ingestion/token-budget.ts +52 -0
- package/src/pipeline/agent-runner.ts +360 -0
- package/src/pipeline/combiner.ts +199 -0
- package/src/pipeline/context.ts +108 -0
- package/src/pipeline/extraction.ts +153 -0
- package/src/pipeline/progress.ts +192 -0
- package/src/pipeline/runner.ts +526 -0
- package/src/report/html-renderer.ts +294 -0
- package/src/report/html-script.ts +123 -0
- package/src/report/html-styles.ts +1127 -0
- package/src/report/md-to-html.ts +153 -0
- package/src/report/open-browser.ts +22 -0
- package/src/schemas/findings.ts +48 -0
- package/src/schemas/report.ts +64 -0
- package/src/screens/ConfigScreen.tsx +271 -0
- package/src/screens/GenerationScreen.tsx +278 -0
- package/src/screens/HistoryScreen.tsx +108 -0
- package/src/screens/HomeScreen.tsx +143 -0
- package/src/screens/ViewerScreen.tsx +82 -0
- package/src/storage/metadata.ts +69 -0
- package/src/storage/report-store.ts +128 -0
- package/src/tools/get-file-tree.ts +157 -0
- package/src/tools/get-git-info.ts +123 -0
- package/src/tools/glob.ts +48 -0
- package/src/tools/grep.ts +149 -0
- package/src/tools/index.ts +30 -0
- package/src/tools/list-directory.ts +57 -0
- package/src/tools/read-file.ts +52 -0
- package/src/tools/read-package-json.ts +48 -0
- package/src/tools/run-command.ts +154 -0
- package/src/tools/shared-ignore.ts +58 -0
- package/src/types/index.ts +127 -0
- package/src/types/marked-terminal.d.ts +17 -0
- package/src/utils/debug.ts +25 -0
- package/src/utils/file-utils.ts +77 -0
- package/src/utils/format.ts +56 -0
- package/src/utils/grade-colors.ts +43 -0
- package/src/utils/project-detector.ts +296 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import React, { useMemo } from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { useTerminalSize } from "../../hooks/useTerminalSize.js";
|
|
4
|
+
|
|
5
|
+
interface FooterAction {
|
|
6
|
+
key: string;
|
|
7
|
+
label: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface FooterProps {
|
|
11
|
+
actions: FooterAction[];
|
|
12
|
+
statusText?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const Footer = React.memo(function Footer({ actions, statusText }: FooterProps) {
|
|
16
|
+
const { columns } = useTerminalSize();
|
|
17
|
+
const separator = useMemo(() => "─".repeat(columns), [columns]);
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<Box flexDirection="column" width={columns}>
|
|
21
|
+
<Box width={columns}>
|
|
22
|
+
<Text color="gray" dimColor>
|
|
23
|
+
{separator}
|
|
24
|
+
</Text>
|
|
25
|
+
</Box>
|
|
26
|
+
<Box
|
|
27
|
+
flexDirection="row"
|
|
28
|
+
justifyContent="space-between"
|
|
29
|
+
paddingX={1}
|
|
30
|
+
width={columns}
|
|
31
|
+
>
|
|
32
|
+
<Box flexDirection="row" gap={2}>
|
|
33
|
+
{actions.map((action) => (
|
|
34
|
+
<Box key={action.key} gap={1}>
|
|
35
|
+
<Text color="yellow" bold>
|
|
36
|
+
[{action.key}]
|
|
37
|
+
</Text>
|
|
38
|
+
<Text color="gray">{action.label}</Text>
|
|
39
|
+
</Box>
|
|
40
|
+
))}
|
|
41
|
+
</Box>
|
|
42
|
+
{statusText && (
|
|
43
|
+
<Box>
|
|
44
|
+
<Text color="gray" dimColor>
|
|
45
|
+
{statusText}
|
|
46
|
+
</Text>
|
|
47
|
+
</Box>
|
|
48
|
+
)}
|
|
49
|
+
</Box>
|
|
50
|
+
</Box>
|
|
51
|
+
);
|
|
52
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import React, { useMemo } from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { useTerminalSize } from "../../hooks/useTerminalSize.js";
|
|
4
|
+
|
|
5
|
+
interface HeaderProps {
|
|
6
|
+
modelName?: string;
|
|
7
|
+
phase?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const Header = React.memo(function Header({ modelName, phase }: HeaderProps) {
|
|
11
|
+
const { columns } = useTerminalSize();
|
|
12
|
+
const separator = useMemo(() => "─".repeat(columns), [columns]);
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<Box flexDirection="column" width={columns}>
|
|
16
|
+
<Box
|
|
17
|
+
flexDirection="row"
|
|
18
|
+
justifyContent="space-between"
|
|
19
|
+
paddingX={1}
|
|
20
|
+
width={columns}
|
|
21
|
+
>
|
|
22
|
+
<Box gap={1}>
|
|
23
|
+
<Text bold color="magenta">
|
|
24
|
+
OPENREPORT
|
|
25
|
+
</Text>
|
|
26
|
+
{phase && (
|
|
27
|
+
<>
|
|
28
|
+
<Text color="gray" dimColor>
|
|
29
|
+
/
|
|
30
|
+
</Text>
|
|
31
|
+
<Text color="white">{phase}</Text>
|
|
32
|
+
</>
|
|
33
|
+
)}
|
|
34
|
+
</Box>
|
|
35
|
+
<Box gap={2}>
|
|
36
|
+
{modelName && (
|
|
37
|
+
<Text color="cyan" dimColor>
|
|
38
|
+
{modelName}
|
|
39
|
+
</Text>
|
|
40
|
+
)}
|
|
41
|
+
</Box>
|
|
42
|
+
</Box>
|
|
43
|
+
<Box width={columns}>
|
|
44
|
+
<Text color="gray" dimColor>
|
|
45
|
+
{separator}
|
|
46
|
+
</Text>
|
|
47
|
+
</Box>
|
|
48
|
+
</Box>
|
|
49
|
+
);
|
|
50
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Text } from "ink";
|
|
3
|
+
import { marked } from "marked";
|
|
4
|
+
import TerminalRenderer from "marked-terminal";
|
|
5
|
+
|
|
6
|
+
const terminalRenderer = new TerminalRenderer({
|
|
7
|
+
reflowText: true,
|
|
8
|
+
showSectionPrefix: false,
|
|
9
|
+
tab: 2,
|
|
10
|
+
tableOptions: {
|
|
11
|
+
chars: {
|
|
12
|
+
top: "─",
|
|
13
|
+
"top-mid": "┬",
|
|
14
|
+
"top-left": "┌",
|
|
15
|
+
"top-right": "┐",
|
|
16
|
+
bottom: "─",
|
|
17
|
+
"bottom-mid": "┴",
|
|
18
|
+
"bottom-left": "└",
|
|
19
|
+
"bottom-right": "┘",
|
|
20
|
+
left: "│",
|
|
21
|
+
"left-mid": "├",
|
|
22
|
+
mid: "─",
|
|
23
|
+
"mid-mid": "┼",
|
|
24
|
+
right: "│",
|
|
25
|
+
"right-mid": "┤",
|
|
26
|
+
middle: "│",
|
|
27
|
+
},
|
|
28
|
+
style: {
|
|
29
|
+
head: ["cyan", "bold"],
|
|
30
|
+
border: ["gray"],
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// marked-terminal returns a renderer compatible with marked at runtime
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
37
|
+
marked.setOptions({ renderer: terminalRenderer as any });
|
|
38
|
+
|
|
39
|
+
interface MarkdownRendererProps {
|
|
40
|
+
content: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function MarkdownRenderer({ content }: MarkdownRendererProps) {
|
|
44
|
+
const rendered = renderMarkdown(content);
|
|
45
|
+
return <Text>{rendered}</Text>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function renderMarkdown(content: string): string {
|
|
49
|
+
return marked.parse(content) as string;
|
|
50
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import type { ReportMetadata } from "../../types/index.js";
|
|
4
|
+
import { formatRelativeDate, formatDuration } from "../../utils/format.js";
|
|
5
|
+
import { getGradeTerminalColor } from "../../utils/grade-colors.js";
|
|
6
|
+
|
|
7
|
+
interface ReportCardProps {
|
|
8
|
+
metadata: ReportMetadata;
|
|
9
|
+
selected?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function ReportCard({ metadata, selected }: ReportCardProps) {
|
|
13
|
+
const gradeColor = getGradeTerminalColor(metadata.grade || "");
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<Box flexDirection="row" gap={2}>
|
|
17
|
+
<Text color={selected ? "yellow" : undefined}>
|
|
18
|
+
{selected ? ">" : " "}
|
|
19
|
+
</Text>
|
|
20
|
+
<Text bold={selected} color={selected ? "white" : "gray"}>
|
|
21
|
+
{metadata.type}
|
|
22
|
+
</Text>
|
|
23
|
+
<Text color={gradeColor as string}>[{metadata.grade || "?"}]</Text>
|
|
24
|
+
<Text color="gray">{formatRelativeDate(metadata.createdAt)}</Text>
|
|
25
|
+
<Text color="gray">{formatDuration(metadata.duration)}</Text>
|
|
26
|
+
<Text color="gray" dimColor>
|
|
27
|
+
{metadata.model}
|
|
28
|
+
</Text>
|
|
29
|
+
</Box>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import React, { useState, useMemo } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
|
|
4
|
+
const stripAnsi = (s: string) => s.replace(/[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[-a-zA-Z\d\/#&.:=?%@~_]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g, '');
|
|
5
|
+
|
|
6
|
+
interface ScrollableViewProps {
|
|
7
|
+
content: string;
|
|
8
|
+
height?: number;
|
|
9
|
+
onExit?: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function ScrollableView({
|
|
13
|
+
content,
|
|
14
|
+
height = 20,
|
|
15
|
+
onExit,
|
|
16
|
+
}: ScrollableViewProps) {
|
|
17
|
+
const lines = useMemo(() => content.split("\n"), [content]);
|
|
18
|
+
const [scrollOffset, setScrollOffset] = useState(0);
|
|
19
|
+
|
|
20
|
+
// Search state
|
|
21
|
+
const [searchMode, setSearchMode] = useState(false);
|
|
22
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
23
|
+
const [searchMatchSet, setSearchMatchSet] = useState<Set<number>>(new Set());
|
|
24
|
+
const [searchMatchList, setSearchMatchList] = useState<number[]>([]);
|
|
25
|
+
const [currentMatch, setCurrentMatch] = useState(0);
|
|
26
|
+
|
|
27
|
+
// Reserve 1 line for status bar, 1 for search bar when active
|
|
28
|
+
const viewHeight = searchMode ? height - 2 : height - 1;
|
|
29
|
+
const maxScroll = Math.max(0, lines.length - viewHeight);
|
|
30
|
+
|
|
31
|
+
function performSearch(query: string) {
|
|
32
|
+
if (!query) {
|
|
33
|
+
setSearchMatchSet(new Set());
|
|
34
|
+
setSearchMatchList([]);
|
|
35
|
+
setCurrentMatch(0);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const q = query.toLowerCase();
|
|
39
|
+
const matches: number[] = [];
|
|
40
|
+
for (let i = 0; i < lines.length; i++) {
|
|
41
|
+
if (stripAnsi(lines[i]).toLowerCase().includes(q)) {
|
|
42
|
+
matches.push(i);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
setSearchMatchSet(new Set(matches));
|
|
46
|
+
setSearchMatchList(matches);
|
|
47
|
+
setCurrentMatch(0);
|
|
48
|
+
if (matches.length > 0) {
|
|
49
|
+
setScrollOffset(Math.min(matches[0], maxScroll));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
useInput((input, key) => {
|
|
54
|
+
// Search mode: capture all input for the query
|
|
55
|
+
if (searchMode) {
|
|
56
|
+
if (key.return) {
|
|
57
|
+
setSearchMode(false);
|
|
58
|
+
performSearch(searchQuery);
|
|
59
|
+
} else if (key.escape) {
|
|
60
|
+
setSearchMode(false);
|
|
61
|
+
setSearchQuery("");
|
|
62
|
+
} else if (key.delete || key.backspace) {
|
|
63
|
+
setSearchQuery((prev) => prev.slice(0, -1));
|
|
64
|
+
} else if (input && !key.ctrl && !key.meta) {
|
|
65
|
+
setSearchQuery((prev) => prev + input);
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Normal mode
|
|
71
|
+
if (key.upArrow) {
|
|
72
|
+
setScrollOffset((prev) => Math.max(0, prev - 1));
|
|
73
|
+
} else if (key.downArrow) {
|
|
74
|
+
setScrollOffset((prev) => Math.min(maxScroll, prev + 1));
|
|
75
|
+
} else if (key.pageUp) {
|
|
76
|
+
setScrollOffset((prev) => Math.max(0, prev - viewHeight));
|
|
77
|
+
} else if (key.pageDown) {
|
|
78
|
+
setScrollOffset((prev) => Math.min(maxScroll, prev + viewHeight));
|
|
79
|
+
} else if (input === "g") {
|
|
80
|
+
setScrollOffset(0);
|
|
81
|
+
} else if (input === "G") {
|
|
82
|
+
setScrollOffset(maxScroll);
|
|
83
|
+
} else if (input === "/") {
|
|
84
|
+
setSearchMode(true);
|
|
85
|
+
setSearchQuery("");
|
|
86
|
+
} else if (input === "n" && searchMatchList.length > 0) {
|
|
87
|
+
const next = (currentMatch + 1) % searchMatchList.length;
|
|
88
|
+
setCurrentMatch(next);
|
|
89
|
+
setScrollOffset(Math.min(searchMatchList[next], maxScroll));
|
|
90
|
+
} else if (input === "N" && searchMatchList.length > 0) {
|
|
91
|
+
const prev =
|
|
92
|
+
(currentMatch - 1 + searchMatchList.length) % searchMatchList.length;
|
|
93
|
+
setCurrentMatch(prev);
|
|
94
|
+
setScrollOffset(Math.min(searchMatchList[prev], maxScroll));
|
|
95
|
+
} else if (key.escape) {
|
|
96
|
+
if (searchMatchList.length > 0) {
|
|
97
|
+
setSearchMatchSet(new Set());
|
|
98
|
+
setSearchMatchList([]);
|
|
99
|
+
setSearchQuery("");
|
|
100
|
+
setCurrentMatch(0);
|
|
101
|
+
} else {
|
|
102
|
+
onExit?.();
|
|
103
|
+
}
|
|
104
|
+
} else if (input === "q") {
|
|
105
|
+
onExit?.();
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const visibleLines = lines.slice(scrollOffset, scrollOffset + viewHeight);
|
|
110
|
+
const scrollPercentage =
|
|
111
|
+
maxScroll > 0 ? Math.round((scrollOffset / maxScroll) * 100) : 100;
|
|
112
|
+
|
|
113
|
+
// Status bar text
|
|
114
|
+
let statusLeft: string;
|
|
115
|
+
if (searchMatchList.length > 0) {
|
|
116
|
+
statusLeft = `Match ${currentMatch + 1}/${searchMatchList.length} [n/N next/prev] [esc clear]`;
|
|
117
|
+
} else if (searchQuery && !searchMode) {
|
|
118
|
+
statusLeft = "No matches";
|
|
119
|
+
} else {
|
|
120
|
+
statusLeft = `Lines ${scrollOffset + 1}-${Math.min(scrollOffset + viewHeight, lines.length)} of ${lines.length}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<Box flexDirection="column">
|
|
125
|
+
<Box flexDirection="column" height={viewHeight}>
|
|
126
|
+
{visibleLines.map((line, i) => {
|
|
127
|
+
const lineIdx = scrollOffset + i;
|
|
128
|
+
const isCurrentMatch = searchMatchList[currentMatch] === lineIdx;
|
|
129
|
+
const isMatch = searchMatchSet.has(lineIdx);
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<Box key={lineIdx} flexDirection="row">
|
|
133
|
+
{searchMatchList.length > 0 && (
|
|
134
|
+
<Text
|
|
135
|
+
color={
|
|
136
|
+
isCurrentMatch ? "yellow" : isMatch ? "cyan" : undefined
|
|
137
|
+
}
|
|
138
|
+
>
|
|
139
|
+
{isCurrentMatch ? ">" : isMatch ? "│" : " "}
|
|
140
|
+
</Text>
|
|
141
|
+
)}
|
|
142
|
+
<Text>{line}</Text>
|
|
143
|
+
</Box>
|
|
144
|
+
);
|
|
145
|
+
})}
|
|
146
|
+
</Box>
|
|
147
|
+
|
|
148
|
+
{/* Search input bar */}
|
|
149
|
+
{searchMode && (
|
|
150
|
+
<Box>
|
|
151
|
+
<Text color="yellow">/</Text>
|
|
152
|
+
<Text>{searchQuery}</Text>
|
|
153
|
+
<Text color="gray">█</Text>
|
|
154
|
+
</Box>
|
|
155
|
+
)}
|
|
156
|
+
|
|
157
|
+
{/* Status bar */}
|
|
158
|
+
<Box justifyContent="space-between" paddingX={1}>
|
|
159
|
+
<Text color="gray">{statusLeft}</Text>
|
|
160
|
+
<Text color="gray">{scrollPercentage}%</Text>
|
|
161
|
+
</Box>
|
|
162
|
+
</Box>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { execFile } from "child_process";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
import { debugLog } from "../utils/debug.js";
|
|
4
|
+
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
|
|
7
|
+
// ── CLI tool definitions ────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
export interface CliToolDef {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
detectCommand: string;
|
|
13
|
+
buildArgs: (model: string | null) => string[];
|
|
14
|
+
buildStreamArgs: (model: string | null) => string[];
|
|
15
|
+
supportsStreaming: boolean;
|
|
16
|
+
defaultModel: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const CLI_TOOLS: CliToolDef[] = [
|
|
20
|
+
{
|
|
21
|
+
id: "claude-code",
|
|
22
|
+
name: "Claude Code",
|
|
23
|
+
detectCommand: "claude --version",
|
|
24
|
+
buildArgs: (model) => {
|
|
25
|
+
const args = ["-p", "--output-format", "text"];
|
|
26
|
+
if (model && model !== "sonnet") args.push("--model", model);
|
|
27
|
+
return args;
|
|
28
|
+
},
|
|
29
|
+
buildStreamArgs: (model) => {
|
|
30
|
+
const args = [
|
|
31
|
+
"-p",
|
|
32
|
+
"--output-format", "stream-json",
|
|
33
|
+
"--verbose",
|
|
34
|
+
"--include-partial-messages",
|
|
35
|
+
];
|
|
36
|
+
if (model && model !== "sonnet") args.push("--model", model);
|
|
37
|
+
return args;
|
|
38
|
+
},
|
|
39
|
+
supportsStreaming: true,
|
|
40
|
+
defaultModel: "sonnet",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: "gemini-cli",
|
|
44
|
+
name: "Gemini CLI",
|
|
45
|
+
detectCommand: "gemini --version",
|
|
46
|
+
buildArgs: (_model) => [],
|
|
47
|
+
buildStreamArgs: (_model) => [],
|
|
48
|
+
supportsStreaming: false,
|
|
49
|
+
defaultModel: "gemini-cli",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: "codex-cli",
|
|
53
|
+
name: "Codex CLI",
|
|
54
|
+
detectCommand: "codex --version",
|
|
55
|
+
// Use --auto-edit instead of --full-auto: auto-edits files but still
|
|
56
|
+
// prompts before running shell commands, reducing the attack surface if
|
|
57
|
+
// a malicious repo manipulates the AI context.
|
|
58
|
+
buildArgs: (_model) => ["--quiet", "--auto-edit"],
|
|
59
|
+
buildStreamArgs: (_model) => ["--quiet", "--auto-edit"],
|
|
60
|
+
supportsStreaming: false,
|
|
61
|
+
defaultModel: "codex-cli",
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
// ── Detection ───────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
export interface DetectedCli {
|
|
68
|
+
id: string;
|
|
69
|
+
name: string;
|
|
70
|
+
version: string;
|
|
71
|
+
defaultModel: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let _cachedDetectedClis: DetectedCli[] | null = null;
|
|
75
|
+
|
|
76
|
+
export function clearCliCache(): void {
|
|
77
|
+
_cachedDetectedClis = null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function detectInstalledClis(): Promise<DetectedCli[]> {
|
|
81
|
+
if (_cachedDetectedClis) return _cachedDetectedClis;
|
|
82
|
+
|
|
83
|
+
const results = await Promise.allSettled(
|
|
84
|
+
CLI_TOOLS.map(async (tool) => {
|
|
85
|
+
const [command, ...args] = tool.detectCommand.split(" ");
|
|
86
|
+
const { stdout } = await execFileAsync(command, args, {
|
|
87
|
+
encoding: "utf-8",
|
|
88
|
+
timeout: 2_000,
|
|
89
|
+
});
|
|
90
|
+
return {
|
|
91
|
+
id: tool.id,
|
|
92
|
+
name: tool.name,
|
|
93
|
+
version: stdout.trim().split("\n")[0].slice(0, 50),
|
|
94
|
+
defaultModel: tool.defaultModel,
|
|
95
|
+
};
|
|
96
|
+
})
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const detected: DetectedCli[] = results
|
|
100
|
+
.filter((r): r is PromiseFulfilledResult<DetectedCli> => r.status === "fulfilled")
|
|
101
|
+
.map((r) => r.value);
|
|
102
|
+
|
|
103
|
+
_cachedDetectedClis = detected;
|
|
104
|
+
return detected;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── CLI command safety ───────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
const SAFE_CLI_NAME = /^[a-zA-Z0-9_-]+$/;
|
|
110
|
+
|
|
111
|
+
export function validateCliCommand(command: string): void {
|
|
112
|
+
if (!SAFE_CLI_NAME.test(command)) {
|
|
113
|
+
throw new Error(`Unsafe CLI command name: ${command}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── CLI command resolver ────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
export function getCliCommand(toolId: string): string {
|
|
120
|
+
switch (toolId) {
|
|
121
|
+
case "claude-code":
|
|
122
|
+
return "claude";
|
|
123
|
+
case "gemini-cli":
|
|
124
|
+
return "gemini";
|
|
125
|
+
case "codex-cli":
|
|
126
|
+
return "codex";
|
|
127
|
+
default:
|
|
128
|
+
return toolId;
|
|
129
|
+
}
|
|
130
|
+
}
|