@ridit/lens 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 (51) hide show
  1. package/LENS.md +25 -0
  2. package/LICENSE +21 -0
  3. package/README.md +0 -0
  4. package/dist/index.js +49363 -0
  5. package/package.json +38 -0
  6. package/src/colors.ts +1 -0
  7. package/src/commands/chat.tsx +23 -0
  8. package/src/commands/provider.tsx +224 -0
  9. package/src/commands/repo.tsx +120 -0
  10. package/src/commands/review.tsx +294 -0
  11. package/src/commands/task.tsx +36 -0
  12. package/src/commands/timeline.tsx +22 -0
  13. package/src/components/chat/ChatMessage.tsx +176 -0
  14. package/src/components/chat/ChatOverlays.tsx +329 -0
  15. package/src/components/chat/ChatRunner.tsx +732 -0
  16. package/src/components/provider/ApiKeyStep.tsx +243 -0
  17. package/src/components/provider/ModelStep.tsx +73 -0
  18. package/src/components/provider/ProviderTypeStep.tsx +54 -0
  19. package/src/components/provider/RemoveProviderStep.tsx +83 -0
  20. package/src/components/repo/DiffViewer.tsx +175 -0
  21. package/src/components/repo/FileReviewer.tsx +70 -0
  22. package/src/components/repo/FileViewer.tsx +60 -0
  23. package/src/components/repo/IssueFixer.tsx +666 -0
  24. package/src/components/repo/LensFileMenu.tsx +122 -0
  25. package/src/components/repo/NoProviderPrompt.tsx +28 -0
  26. package/src/components/repo/PreviewRunner.tsx +217 -0
  27. package/src/components/repo/ProviderPicker.tsx +76 -0
  28. package/src/components/repo/RepoAnalysis.tsx +343 -0
  29. package/src/components/repo/StepRow.tsx +69 -0
  30. package/src/components/task/TaskRunner.tsx +396 -0
  31. package/src/components/timeline/CommitDetail.tsx +274 -0
  32. package/src/components/timeline/CommitList.tsx +174 -0
  33. package/src/components/timeline/TimelineChat.tsx +167 -0
  34. package/src/components/timeline/TimelineRunner.tsx +1209 -0
  35. package/src/index.tsx +60 -0
  36. package/src/types/chat.ts +69 -0
  37. package/src/types/config.ts +20 -0
  38. package/src/types/repo.ts +42 -0
  39. package/src/utils/ai.ts +233 -0
  40. package/src/utils/chat.ts +833 -0
  41. package/src/utils/config.ts +61 -0
  42. package/src/utils/files.ts +104 -0
  43. package/src/utils/git.ts +155 -0
  44. package/src/utils/history.ts +86 -0
  45. package/src/utils/lensfile.ts +77 -0
  46. package/src/utils/llm.ts +81 -0
  47. package/src/utils/preview.ts +119 -0
  48. package/src/utils/repo.ts +69 -0
  49. package/src/utils/stats.ts +174 -0
  50. package/src/utils/thinking.tsx +191 -0
  51. package/tsconfig.json +24 -0
@@ -0,0 +1,61 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
+ import path from "path";
3
+ import os from "os";
4
+ import type { Config, Provider } from "../types/config";
5
+
6
+ const CONFIG_DIR = path.join(os.homedir(), ".lens");
7
+ const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
8
+
9
+ export function configExists(): boolean {
10
+ return existsSync(CONFIG_PATH);
11
+ }
12
+
13
+ export function loadConfig(): Config {
14
+ if (!configExists()) return { providers: [] };
15
+ try {
16
+ return JSON.parse(readFileSync(CONFIG_PATH, "utf-8")) as Config;
17
+ } catch {
18
+ return { providers: [] };
19
+ }
20
+ }
21
+
22
+ export function saveConfig(config: Config): void {
23
+ if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true });
24
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8");
25
+ }
26
+
27
+ export function addProvider(provider: Provider): void {
28
+ const config = loadConfig();
29
+ const existing = config.providers.findIndex((p) => p.id === provider.id);
30
+ if (existing >= 0) {
31
+ config.providers[existing] = provider;
32
+ } else {
33
+ config.providers.push(provider);
34
+ }
35
+ if (!config.defaultProviderId) config.defaultProviderId = provider.id;
36
+ saveConfig(config);
37
+ }
38
+
39
+ export function setDefaultProvider(id: string): void {
40
+ const config = loadConfig();
41
+ config.defaultProviderId = id;
42
+ saveConfig(config);
43
+ }
44
+
45
+ export function getDefaultProvider(): Provider | undefined {
46
+ const config = loadConfig();
47
+ return config.providers.find((p) => p.id === config.defaultProviderId);
48
+ }
49
+
50
+ export const DEFAULT_MODELS: Record<string, string[]> = {
51
+ anthropic: [
52
+ "claude-sonnet-4-20250514",
53
+ "claude-opus-4-20250514",
54
+ "claude-haiku-4-5-20251001",
55
+ ],
56
+ openai: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo"],
57
+ ollama: ["llama3", "mistral", "codellama", "phi3"],
58
+ custom: [],
59
+ };
60
+
61
+ export type CustomResult = { apiKey: string; baseUrl: string };
@@ -0,0 +1,104 @@
1
+ import { readFileSync } from "fs";
2
+ import path from "path";
3
+ import { exec } from "child_process";
4
+ import figures from "figures";
5
+ import type { FileTree, ImportantFile } from "../types/repo";
6
+
7
+ export const IMPORTANT_PATTERNS = [
8
+ /^\.gitignore$/,
9
+ /^\.env(\..+)?$/,
10
+ /^\.env\.example$/,
11
+ /^package\.json$/,
12
+ /^tsconfig(\..+)?\.json$/,
13
+ /^jsconfig\.json$/,
14
+ /^vite\.config\.(ts|js)$/,
15
+ /^webpack\.config\.(ts|js)$/,
16
+ /^rollup\.config\.(ts|js)$/,
17
+ /^babel\.config\.(ts|js|json)$/,
18
+ /^\.babelrc$/,
19
+ /^eslint\.config\.(ts|js|json)$/,
20
+ /^\.eslintrc(\..+)?$/,
21
+ /^\.prettierrc(\..+)?$/,
22
+ /^prettier\.config\.(ts|js)$/,
23
+ /^docker-compose(\..+)?\.yml$/,
24
+ /^Dockerfile(\..+)?$/,
25
+ /^README(\..+)?\.md$/,
26
+ /^LICENSE(\..+)?$/,
27
+ /^Makefile$/,
28
+ /^\.github\/workflows\/.+\.yml$/,
29
+ ];
30
+
31
+ export function isImportantFile(filePath: string): boolean {
32
+ const fileName = path.basename(filePath);
33
+ return IMPORTANT_PATTERNS.some(
34
+ (pattern) => pattern.test(fileName) || pattern.test(filePath),
35
+ );
36
+ }
37
+
38
+ export function buildTree(files: string[]): FileTree[] {
39
+ const root: FileTree[] = [];
40
+ for (const file of files) {
41
+ const parts = file.split("/");
42
+ let current = root;
43
+ for (let i = 0; i < parts.length; i++) {
44
+ const part = parts[i] ?? "";
45
+ const isFile = i === parts.length - 1;
46
+ const existing = current.find((n) => n.name === part);
47
+ if (existing) {
48
+ if (!isFile && existing.children) current = existing.children;
49
+ } else {
50
+ const node: FileTree = isFile
51
+ ? { name: part }
52
+ : { name: part, children: [] };
53
+ current.push(node);
54
+ if (!isFile && node.children) current = node.children;
55
+ }
56
+ }
57
+ }
58
+ return root;
59
+ }
60
+
61
+ export function fetchFileTree(repoPath: string): Promise<string[]> {
62
+ return new Promise((resolve, reject) => {
63
+ exec("git ls-files", { cwd: repoPath }, (err, stdout) => {
64
+ if (err) return reject(err);
65
+ resolve(stdout.trim().split("\n").filter(Boolean));
66
+ });
67
+ });
68
+ }
69
+
70
+ export function readImportantFiles(
71
+ repoPath: string,
72
+ files: string[],
73
+ ): ImportantFile[] {
74
+ return files.filter(isImportantFile).flatMap((filePath) => {
75
+ try {
76
+ const content = readFileSync(path.join(repoPath, filePath), "utf-8");
77
+ return [{ path: filePath, content }];
78
+ } catch {
79
+ return [];
80
+ }
81
+ });
82
+ }
83
+
84
+ const FILE_ICON: Record<string, string> = {
85
+ ".gitignore": figures.pointer,
86
+ "package.json": figures.nodejs,
87
+ Dockerfile: figures.square,
88
+ Makefile: figures.play,
89
+ "README.md": figures.info,
90
+ LICENSE: figures.star,
91
+ ".env": figures.warning,
92
+ ".env.example": figures.warning,
93
+ };
94
+
95
+ export function iconForFile(filePath: string): string {
96
+ const name = path.basename(filePath);
97
+ for (const [key, icon] of Object.entries(FILE_ICON)) {
98
+ if (name === key || name.startsWith(key)) return icon;
99
+ }
100
+ if (name.endsWith(".json")) return figures.arrowRight;
101
+ if (name.endsWith(".yml") || name.endsWith(".yaml")) return figures.bullet;
102
+ if (name.startsWith(".")) return figures.dot;
103
+ return figures.pointerSmall;
104
+ }
@@ -0,0 +1,155 @@
1
+ import { execSync } from "child_process";
2
+
3
+ export type Commit = {
4
+ hash: string;
5
+ shortHash: string;
6
+ author: string;
7
+ email: string;
8
+ date: string;
9
+ relativeDate: string;
10
+ message: string;
11
+ body: string;
12
+ refs: string;
13
+ parents: string[];
14
+ filesChanged: number;
15
+ insertions: number;
16
+ deletions: number;
17
+ };
18
+
19
+ export type DiffFile = {
20
+ path: string;
21
+ status: "added" | "modified" | "deleted" | "renamed";
22
+ insertions: number;
23
+ deletions: number;
24
+ lines: { type: "add" | "remove" | "context" | "header"; content: string }[];
25
+ };
26
+
27
+ function run(cmd: string, cwd: string): string {
28
+ try {
29
+ return execSync(cmd, {
30
+ cwd,
31
+ encoding: "utf-8",
32
+ stdio: ["pipe", "pipe", "pipe"],
33
+ }).trim();
34
+ } catch {
35
+ return "";
36
+ }
37
+ }
38
+
39
+ export function isGitRepo(p: string): boolean {
40
+ return run("git rev-parse --is-inside-work-tree", p) === "true";
41
+ }
42
+
43
+ export function fetchCommits(repoPath: string, limit = 200): Commit[] {
44
+ const SEP = "|||";
45
+ const RS = "^^^";
46
+ const raw = run(
47
+ `git log --max-count=${limit} --format="${RS}%H${SEP}%h${SEP}%an${SEP}%ae${SEP}%ci${SEP}%cr${SEP}%s${SEP}%b${SEP}%D${SEP}%P" --shortstat`,
48
+ repoPath,
49
+ );
50
+ if (!raw) return [];
51
+
52
+ return raw
53
+ .split(RS)
54
+ .filter(Boolean)
55
+ .flatMap((block) => {
56
+ const lines = block.split("\n");
57
+ const parts = lines[0]!.split(SEP);
58
+ if (parts.length < 10) return [];
59
+ const [
60
+ hash,
61
+ shortHash,
62
+ author,
63
+ email,
64
+ date,
65
+ relativeDate,
66
+ message,
67
+ body,
68
+ refs,
69
+ parentsRaw,
70
+ ] = parts;
71
+ const statLine = lines.find((l) => l.includes("changed")) ?? "";
72
+ return [
73
+ {
74
+ hash: hash!.trim(),
75
+ shortHash: shortHash!.trim(),
76
+ author: author!.trim(),
77
+ email: email!.trim(),
78
+ date: date!.trim(),
79
+ relativeDate: relativeDate!.trim(),
80
+ message: message!.trim(),
81
+ body: body!.trim(),
82
+ refs: refs!.trim(),
83
+ parents: parentsRaw!.trim().split(" ").filter(Boolean),
84
+ filesChanged: parseInt(statLine.match(/(\d+) file/)?.[1] ?? "0"),
85
+ insertions: parseInt(statLine.match(/(\d+) insertion/)?.[1] ?? "0"),
86
+ deletions: parseInt(statLine.match(/(\d+) deletion/)?.[1] ?? "0"),
87
+ },
88
+ ];
89
+ });
90
+ }
91
+
92
+ export function fetchDiff(repoPath: string, hash: string): DiffFile[] {
93
+ const raw = run(
94
+ `git show --unified=3 --diff-filter=ACDMR "${hash}"`,
95
+ repoPath,
96
+ );
97
+ if (!raw) return [];
98
+
99
+ const files: DiffFile[] = [];
100
+ let cur: DiffFile | null = null;
101
+
102
+ for (const line of raw.split("\n")) {
103
+ if (line.startsWith("diff --git")) {
104
+ if (cur) files.push(cur);
105
+ cur = {
106
+ path: "",
107
+ status: "modified",
108
+ insertions: 0,
109
+ deletions: 0,
110
+ lines: [],
111
+ };
112
+ } else if (line.startsWith("+++ b/") && cur) {
113
+ cur.path = line.slice(6);
114
+ } else if (line.startsWith("new file") && cur) {
115
+ cur.status = "added";
116
+ } else if (line.startsWith("deleted file") && cur) {
117
+ cur.status = "deleted";
118
+ } else if (line.startsWith("rename") && cur) {
119
+ cur.status = "renamed";
120
+ } else if (line.startsWith("@@") && cur) {
121
+ cur.lines.push({ type: "header", content: line });
122
+ } else if (line.startsWith("+") && cur && !line.startsWith("+++")) {
123
+ cur.insertions++;
124
+ cur.lines.push({ type: "add", content: line.slice(1) });
125
+ } else if (line.startsWith("-") && cur && !line.startsWith("---")) {
126
+ cur.deletions++;
127
+ cur.lines.push({ type: "remove", content: line.slice(1) });
128
+ } else if (cur && line !== "\") {
129
+ cur.lines.push({ type: "context", content: line.slice(1) });
130
+ }
131
+ }
132
+ if (cur) files.push(cur);
133
+ return files.filter((f) => f.path);
134
+ }
135
+
136
+ export function summarizeTimeline(commits: Commit[]): string {
137
+ if (!commits.length) return "No commits.";
138
+ const authors = [...new Set(commits.map((c) => c.author))];
139
+ const biggest = [...commits].sort(
140
+ (a, b) => b.insertions + b.deletions - (a.insertions + a.deletions),
141
+ )[0]!;
142
+ return [
143
+ `Total commits: ${commits.length}`,
144
+ `Authors: ${authors.join(", ")}`,
145
+ `Newest: "${commits[0]!.message}" (${commits[0]!.shortHash}) — ${commits[0]!.relativeDate}`,
146
+ `Oldest: "${commits[commits.length - 1]!.message}" (${commits[commits.length - 1]!.shortHash}) — ${commits[commits.length - 1]!.relativeDate}`,
147
+ `Biggest change: "${biggest.message}" (${biggest.shortHash}) +${biggest.insertions}/-${biggest.deletions}`,
148
+ ``,
149
+ `Full log (hash | date | author | message | +ins/-del):`,
150
+ ...commits.map(
151
+ (c) =>
152
+ `${c.shortHash} | ${c.date.slice(0, 10)} | ${c.author} | ${c.message} | +${c.insertions}/-${c.deletions}`,
153
+ ),
154
+ ].join("\n");
155
+ }
@@ -0,0 +1,86 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
+ import path from "path";
3
+ import os from "os";
4
+
5
+ export type HistoryEntryKind =
6
+ | "file-written"
7
+ | "file-read"
8
+ | "url-fetched"
9
+ | "shell-run"
10
+ | "code-applied"
11
+ | "code-skipped";
12
+
13
+ export type HistoryEntry = {
14
+ kind: HistoryEntryKind;
15
+ detail: string;
16
+ summary: string;
17
+ timestamp: string;
18
+ repoPath: string;
19
+ };
20
+
21
+ export type HistoryFile = {
22
+ entries: HistoryEntry[];
23
+ };
24
+
25
+ const LENS_DIR = path.join(os.homedir(), ".lens");
26
+ const HISTORY_PATH = path.join(LENS_DIR, "history.json");
27
+
28
+ function loadHistory(): HistoryFile {
29
+ if (!existsSync(HISTORY_PATH)) return { entries: [] };
30
+ try {
31
+ return JSON.parse(readFileSync(HISTORY_PATH, "utf-8")) as HistoryFile;
32
+ } catch {
33
+ return { entries: [] };
34
+ }
35
+ }
36
+
37
+ function saveHistory(h: HistoryFile): void {
38
+ if (!existsSync(LENS_DIR)) mkdirSync(LENS_DIR, { recursive: true });
39
+ writeFileSync(HISTORY_PATH, JSON.stringify(h, null, 2), "utf-8");
40
+ }
41
+
42
+ export function appendHistory(entry: Omit<HistoryEntry, "timestamp">): void {
43
+ const h = loadHistory();
44
+ h.entries.push({ ...entry, timestamp: new Date().toISOString() });
45
+
46
+ if (h.entries.length > 500) h.entries = h.entries.slice(-500);
47
+ saveHistory(h);
48
+ }
49
+
50
+ /**
51
+ * Returns a compact summary string to inject into the system prompt.
52
+ * Only includes entries for the current repo, most recent 50.
53
+ */
54
+ export function buildHistorySummary(repoPath: string): string {
55
+ const h = loadHistory();
56
+ const relevant = h.entries.filter((e) => e.repoPath === repoPath).slice(-50);
57
+
58
+ if (relevant.length === 0) return "";
59
+
60
+ const lines = relevant.map((e) => {
61
+ const ts = new Date(e.timestamp).toLocaleString();
62
+ return `[${ts}] ${e.kind}: ${e.detail} — ${e.summary}`;
63
+ });
64
+
65
+ return `## WHAT YOU HAVE ALREADY DONE IN THIS REPO
66
+
67
+ The following actions have already been completed. Do NOT repeat them unless the user explicitly asks you to redo something:
68
+
69
+ ${lines.join("\n")}`;
70
+ }
71
+
72
+ /**
73
+ * Returns all entries for a repo, for display purposes.
74
+ */
75
+ export function getRepoHistory(repoPath: string): HistoryEntry[] {
76
+ return loadHistory().entries.filter((e) => e.repoPath === repoPath);
77
+ }
78
+
79
+ /**
80
+ * Clears all history for a repo.
81
+ */
82
+ export function clearRepoHistory(repoPath: string): void {
83
+ const h = loadHistory();
84
+ h.entries = h.entries.filter((e) => e.repoPath !== repoPath);
85
+ saveHistory(h);
86
+ }
@@ -0,0 +1,77 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "fs";
2
+ import path from "path";
3
+ import type { AnalysisResult } from "../types/repo";
4
+
5
+ export const LENS_FILENAME = "LENS.md";
6
+
7
+ export type LensFile = {
8
+ overview: string;
9
+ importantFolders: string[];
10
+ missingConfigs: string[];
11
+ securityIssues: string[];
12
+ suggestions: string[];
13
+ generatedAt: string;
14
+ };
15
+
16
+ export function lensFilePath(repoPath: string): string {
17
+ return path.join(repoPath, LENS_FILENAME);
18
+ }
19
+
20
+ export function lensFileExists(repoPath: string): boolean {
21
+ return existsSync(lensFilePath(repoPath));
22
+ }
23
+
24
+ export function writeLensFile(repoPath: string, result: AnalysisResult): void {
25
+ const data: LensFile = {
26
+ ...result,
27
+ generatedAt: new Date().toISOString(),
28
+ };
29
+
30
+ const content = `# Lens Analysis
31
+ > Generated: ${data.generatedAt}
32
+
33
+ ## Overview
34
+ ${data.overview}
35
+
36
+ ## Important Folders
37
+ ${data.importantFolders.map((f) => `- ${f}`).join("\n")}
38
+
39
+ ## Missing Configs
40
+ ${data.missingConfigs.length > 0 ? data.missingConfigs.map((f) => `- ${f}`).join("\n") : "- None detected"}
41
+
42
+ ## Security Issues
43
+ ${data.securityIssues.length > 0 ? data.securityIssues.map((s) => `- ${s}`).join("\n") : "- None detected"}
44
+
45
+ ## Suggestions
46
+ ${data.suggestions.map((s) => `- ${s}`).join("\n")}
47
+
48
+ <!--lens-json
49
+ ${JSON.stringify(data)}
50
+ lens-json-->
51
+ `;
52
+
53
+ writeFileSync(lensFilePath(repoPath), content, "utf-8");
54
+ }
55
+
56
+ export function readLensFile(repoPath: string): LensFile | null {
57
+ const filePath = lensFilePath(repoPath);
58
+ if (!existsSync(filePath)) return null;
59
+ try {
60
+ const content = readFileSync(filePath, "utf-8");
61
+ const match = content.match(/<!--lens-json\n([\s\S]*?)\nlens-json-->/);
62
+ if (!match) return null;
63
+ return JSON.parse(match[1]!) as LensFile;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ export function lensFileToAnalysisResult(lf: LensFile): AnalysisResult {
70
+ return {
71
+ overview: lf.overview,
72
+ importantFolders: lf.importantFolders,
73
+ missingConfigs: lf.missingConfigs,
74
+ securityIssues: lf.securityIssues,
75
+ suggestions: lf.suggestions,
76
+ };
77
+ }
@@ -0,0 +1,81 @@
1
+ import type { Provider } from "../types/config";
2
+
3
+ type Message = { role: "user" | "assistant"; content: string };
4
+
5
+ export async function runPrompt(
6
+ provider: Provider,
7
+ prompt: string,
8
+ ): Promise<string> {
9
+ if (provider.type === "anthropic") {
10
+ return runAnthropic(provider, prompt);
11
+ }
12
+ if (provider.type === "openai" || provider.type === "custom") {
13
+ return runOpenAICompat(provider, prompt);
14
+ }
15
+ if (provider.type === "ollama") {
16
+ return runOllama(provider, prompt);
17
+ }
18
+ throw new Error(`Unknown provider type: ${provider.type}`);
19
+ }
20
+
21
+ async function runAnthropic(
22
+ provider: Provider,
23
+ prompt: string,
24
+ ): Promise<string> {
25
+ const res = await fetch("https://api.anthropic.com/v1/messages", {
26
+ method: "POST",
27
+ headers: {
28
+ "Content-Type": "application/json",
29
+ "x-api-key": provider.apiKey ?? "",
30
+ "anthropic-version": "2023-06-01",
31
+ },
32
+ body: JSON.stringify({
33
+ model: provider.model,
34
+ max_tokens: 2000,
35
+ messages: [{ role: "user", content: prompt }],
36
+ }),
37
+ });
38
+ if (!res.ok) throw new Error(`Anthropic error: ${res.statusText}`);
39
+ const data = (await res.json()) as any;
40
+ return data.content
41
+ .filter((b: { type: string }) => b.type === "text")
42
+ .map((b: { text: string }) => b.text)
43
+ .join("");
44
+ }
45
+
46
+ async function runOpenAICompat(
47
+ provider: Provider,
48
+ prompt: string,
49
+ ): Promise<string> {
50
+ const baseUrl = provider.baseUrl ?? "https://api.openai.com/v1";
51
+ const res = await fetch(`${baseUrl}/chat/completions`, {
52
+ method: "POST",
53
+ headers: {
54
+ "Content-Type": "application/json",
55
+ Authorization: `Bearer ${provider.apiKey ?? ""}`,
56
+ },
57
+ body: JSON.stringify({
58
+ model: provider.model,
59
+ messages: [{ role: "user", content: prompt }],
60
+ }),
61
+ });
62
+ if (!res.ok) throw new Error(`OpenAI error: ${res.statusText}`);
63
+ const data = (await res.json()) as any;
64
+ return data.choices?.[0]?.message?.content ?? "";
65
+ }
66
+
67
+ async function runOllama(provider: Provider, prompt: string): Promise<string> {
68
+ const baseUrl = provider.baseUrl ?? "http://localhost:11434";
69
+ const res = await fetch(`${baseUrl}/api/chat`, {
70
+ method: "POST",
71
+ headers: { "Content-Type": "application/json" },
72
+ body: JSON.stringify({
73
+ model: provider.model,
74
+ stream: false,
75
+ messages: [{ role: "user", content: prompt }],
76
+ }),
77
+ });
78
+ if (!res.ok) throw new Error(`Ollama error: ${res.statusText}`);
79
+ const data = (await res.json()) as any;
80
+ return data.message?.content ?? "";
81
+ }
@@ -0,0 +1,119 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import path from "path";
3
+ import { spawn, type ChildProcess } from "child_process";
4
+ import type { PackageManager, PreviewInfo } from "../types/repo";
5
+
6
+ export function detectPreview(repoPath: string): PreviewInfo | null {
7
+
8
+ const pkgPath = path.join(repoPath, "package.json");
9
+ if (existsSync(pkgPath)) {
10
+ try {
11
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as {
12
+ scripts?: Record<string, string>;
13
+ };
14
+ const scripts = pkg.scripts ?? {};
15
+
16
+ const pm: PackageManager = existsSync(
17
+ path.join(repoPath, "pnpm-lock.yaml"),
18
+ )
19
+ ? "pnpm"
20
+ : existsSync(path.join(repoPath, "yarn.lock"))
21
+ ? "yarn"
22
+ : "npm";
23
+
24
+ const devScript =
25
+ scripts["dev"] ?? scripts["start"] ?? scripts["serve"] ?? null;
26
+ if (!devScript) return null;
27
+
28
+ const devCmd =
29
+ pm === "npm" ? "npm run dev" : pm === "yarn" ? "yarn dev" : "pnpm dev";
30
+
31
+
32
+ const portMatch = devScript.match(/--port[= ](\d+)/);
33
+ const port = portMatch ? parseInt(portMatch[1]!, 10) : 5173;
34
+
35
+ return {
36
+ packageManager: pm,
37
+ installCmd:
38
+ pm === "npm"
39
+ ? "npm install"
40
+ : pm === "yarn"
41
+ ? "yarn install"
42
+ : "pnpm install",
43
+ devCmd,
44
+ port,
45
+ };
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+
52
+ if (
53
+ existsSync(path.join(repoPath, "requirements.txt")) ||
54
+ existsSync(path.join(repoPath, "pyproject.toml"))
55
+ ) {
56
+ return {
57
+ packageManager: "pip",
58
+ installCmd: "pip install -r requirements.txt",
59
+ devCmd: existsSync(path.join(repoPath, "manage.py"))
60
+ ? "python manage.py runserver"
61
+ : "python main.py",
62
+ port: 8000,
63
+ };
64
+ }
65
+
66
+ return null;
67
+ }
68
+
69
+ export type PreviewProcess = {
70
+ kill: () => void;
71
+ onLog: (cb: (line: string) => void) => void;
72
+ onError: (cb: (line: string) => void) => void;
73
+ onExit: (cb: (code: number | null) => void) => void;
74
+ };
75
+
76
+ export function runInstall(
77
+ repoPath: string,
78
+ installCmd: string,
79
+ ): PreviewProcess {
80
+ return spawnProcess(repoPath, installCmd);
81
+ }
82
+
83
+ export function runDev(repoPath: string, devCmd: string): PreviewProcess {
84
+ return spawnProcess(repoPath, devCmd);
85
+ }
86
+
87
+ function spawnProcess(cwd: string, cmd: string): PreviewProcess {
88
+ const [bin, ...args] = cmd.split(" ") as [string, ...string[]];
89
+ const child: ChildProcess = spawn(bin, args, {
90
+ cwd,
91
+ shell: true,
92
+ env: { ...process.env },
93
+ });
94
+
95
+ const logCallbacks: ((line: string) => void)[] = [];
96
+ const errorCallbacks: ((line: string) => void)[] = [];
97
+ const exitCallbacks: ((code: number | null) => void)[] = [];
98
+
99
+ child.stdout?.on("data", (data: Buffer) => {
100
+ const lines = data.toString().split("\n").filter(Boolean);
101
+ lines.forEach((line) => logCallbacks.forEach((cb) => cb(line)));
102
+ });
103
+
104
+ child.stderr?.on("data", (data: Buffer) => {
105
+ const lines = data.toString().split("\n").filter(Boolean);
106
+ lines.forEach((line) => errorCallbacks.forEach((cb) => cb(line)));
107
+ });
108
+
109
+ child.on("exit", (code) => {
110
+ exitCallbacks.forEach((cb) => cb(code));
111
+ });
112
+
113
+ return {
114
+ kill: () => child.kill(),
115
+ onLog: (cb) => logCallbacks.push(cb),
116
+ onError: (cb) => errorCallbacks.push(cb),
117
+ onExit: (cb) => exitCallbacks.push(cb),
118
+ };
119
+ }