import { existsSync } from "node:fs"; import { readdir, readFile } from "node:fs/promises"; import { basename, join } from "node:path"; import { normalizeGitRemote } from "../utils/git-remote"; export interface DiscoveredProject { path: string; detectedName: string; gitRemote?: string; // Git remote origin URL (normalized: github.com/user/repo) peacockColor?: string; // VS Code Peacock color from .vscode/settings.json type: "git" | "npm" | "cargo" | "go" | "python" | "unknown"; } export interface ProjectRootObservation { path: string; detectedName: string; gitRemote?: string | null; peacockColor?: string; } /** * Overlapping scan roots can observe the same folder with different metadata. * Keep the remote-bearing observation so scan order cannot erase git identity. */ export function mergeProjectRootObservations( observations: readonly T[], ): T[] { const byPath = new Map(); for (const observation of observations) { const normalizedRemote = normalizeGitRemote(observation.gitRemote); const normalizedObservation = { ...observation, gitRemote: normalizedRemote ?? undefined, } as T; const existing = byPath.get(observation.path); if (!existing) { byPath.set(observation.path, normalizedObservation); continue; } const existingRemote = normalizeGitRemote(existing.gitRemote); const preferObservation = normalizedRemote !== null && !existingRemote; const preferred = preferObservation ? normalizedObservation : existing; const secondary = preferObservation ? existing : normalizedObservation; byPath.set(observation.path, { ...secondary, ...preferred, detectedName: preferred.detectedName || secondary.detectedName, gitRemote: existingRemote ?? normalizedRemote ?? undefined, peacockColor: preferred.peacockColor ?? secondary.peacockColor, }); } return [...byPath.values()]; } // Directories to skip during scanning const SKIP_DIRS = new Set([ "node_modules", ".git", "dist", "build", ".next", "target", "vendor", "__pycache__", ".venv", "venv", ".cache", ".turbo", ]); /** * Read Peacock color from .vscode/settings.json */ export async function getPeacockColor( projectPath: string, ): Promise { const settingsPath = join(projectPath, ".vscode", "settings.json"); if (!existsSync(settingsPath)) return undefined; try { const content = await readFile(settingsPath, "utf-8"); const settings = JSON.parse(content); const color = settings["peacock.color"]; return typeof color === "string" ? color : undefined; } catch { return undefined; } } /** * Extract git remote origin URL from a git repository * Returns normalized form: github.com/user/repo (no protocol, no .git suffix) */ export async function getGitRemote( projectPath: string, ): Promise { const gitConfigPath = join(projectPath, ".git", "config"); if (!existsSync(gitConfigPath)) return undefined; try { const content = await readFile(gitConfigPath, "utf-8"); const remotes: Array<{ name: string; remote: string }> = []; const remoteRegex = /\[remote "([^"]+)"\][^[]*?url\s*=\s*(\S+)/g; for (const match of content.matchAll(remoteRegex)) { const name = match[1]; const remote = normalizeGitRemote(match[2]); if (remote) { remotes.push({ name, remote }); } } if (remotes.length === 0) return undefined; const origin = remotes.find((r) => r.name === "origin"); if (origin) return origin.remote; const githubRemote = remotes.find((r) => r.remote.startsWith("github.com/"), ); if (githubRemote) return githubRemote.remote; return remotes[0].remote; } catch { return undefined; } } /** * Find all git repositories under a root path */ export async function findGitRepos( rootPath: string, maxDepth = 4, ): Promise { const projects: DiscoveredProject[] = []; await scanForGit(rootPath, projects, 0, maxDepth); return projects; } async function scanForGit( dir: string, projects: DiscoveredProject[], depth: number, maxDepth: number, ): Promise { if (depth > maxDepth) return; try { const gitPath = join(dir, ".git"); if (existsSync(gitPath)) { // Found a git repo - detect project name, remote, and peacock color const [detectedName, gitRemote, peacockColor] = await Promise.all([ detectProjectName(dir), getGitRemote(dir), getPeacockColor(dir), ]); projects.push({ path: dir, detectedName, gitRemote, peacockColor, type: "git", }); // Don't recurse into git repos (they're the project root) return; } // Recurse into subdirectories const entries = await readdir(dir, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory()) continue; if (SKIP_DIRS.has(entry.name)) continue; if (entry.name.startsWith(".")) continue; await scanForGit(join(dir, entry.name), projects, depth + 1, maxDepth); } } catch { // Permission denied or other error - skip } } /** * Detect project name from manifest files or folder name */ async function detectProjectName(projectPath: string): Promise { // Try package.json const packageJsonPath = join(projectPath, "package.json"); if (existsSync(packageJsonPath)) { try { const content = await readFile(packageJsonPath, "utf-8"); const pkg = JSON.parse(content); if (pkg.name) return pkg.name; } catch { // Invalid JSON - fall through } } // Try Cargo.toml const cargoTomlPath = join(projectPath, "Cargo.toml"); if (existsSync(cargoTomlPath)) { try { const content = await readFile(cargoTomlPath, "utf-8"); const match = content.match(/name\s*=\s*"([^"]+)"/); if (match) return match[1]; } catch { // Fall through } } // Try go.mod const goModPath = join(projectPath, "go.mod"); if (existsSync(goModPath)) { try { const content = await readFile(goModPath, "utf-8"); const match = content.match(/module\s+(\S+)/); if (match) { // Extract last part of module path const parts = match[1].split("/"); return parts[parts.length - 1]; } } catch { // Fall through } } // Try pyproject.toml const pyprojectPath = join(projectPath, "pyproject.toml"); if (existsSync(pyprojectPath)) { try { const content = await readFile(pyprojectPath, "utf-8"); const match = content.match(/name\s*=\s*"([^"]+)"/); if (match) return match[1]; } catch { // Fall through } } // Fallback to folder name return basename(projectPath); } /** * Find projects in a file-based scan root (looks for manifest files) */ export async function findFileProjects( rootPath: string, maxDepth = 4, ): Promise { const projects: DiscoveredProject[] = []; await scanForManifests(rootPath, projects, 0, maxDepth); return projects; } async function scanForManifests( dir: string, projects: DiscoveredProject[], depth: number, maxDepth: number, ): Promise { if (depth > maxDepth) return; try { const entries = await readdir(dir, { withFileTypes: true }); const fileNames = entries.filter((e) => e.isFile()).map((e) => e.name); // Check for project markers let foundProject = false; if (fileNames.includes("package.json")) { const [name, gitRemote, peacockColor] = await Promise.all([ detectProjectName(dir), getGitRemote(dir), getPeacockColor(dir), ]); projects.push({ path: dir, detectedName: name, gitRemote, peacockColor, type: "npm", }); foundProject = true; } else if (fileNames.includes("Cargo.toml")) { const [name, gitRemote, peacockColor] = await Promise.all([ detectProjectName(dir), getGitRemote(dir), getPeacockColor(dir), ]); projects.push({ path: dir, detectedName: name, gitRemote, peacockColor, type: "cargo", }); foundProject = true; } else if (fileNames.includes("go.mod")) { const [name, gitRemote, peacockColor] = await Promise.all([ detectProjectName(dir), getGitRemote(dir), getPeacockColor(dir), ]); projects.push({ path: dir, detectedName: name, gitRemote, peacockColor, type: "go", }); foundProject = true; } else if ( fileNames.includes("pyproject.toml") || fileNames.includes("setup.py") ) { const [name, gitRemote, peacockColor] = await Promise.all([ detectProjectName(dir), getGitRemote(dir), getPeacockColor(dir), ]); projects.push({ path: dir, detectedName: name, gitRemote, peacockColor, type: "python", }); foundProject = true; } // Don't recurse into found projects (they're the root) if (foundProject) return; // Recurse into subdirectories for (const entry of entries) { if (!entry.isDirectory()) continue; if (SKIP_DIRS.has(entry.name)) continue; if (entry.name.startsWith(".")) continue; await scanForManifests( join(dir, entry.name), projects, depth + 1, maxDepth, ); } } catch { // Permission denied or other error - skip } } /** * Chat provider evidence is matched at ingestion time via scan root paths, * not at discovery time. Only git repos and file manifests create project roots. */ export async function findClaudeProjects( _claudePath: string, ): Promise { return []; } /** * Chat provider evidence is matched at ingestion time via scan root paths, * not at discovery time. Only git repos and file manifests create project roots. */ export async function findGeminiProjects( _geminiTmpPath: string, ): Promise { return []; } /** * Chat provider evidence is matched at ingestion time via scan root paths, * not at discovery time. Only git repos and file manifests create project roots. */ export async function findCodexProjects( _codexSessionsPath: string, ): Promise { return []; } /** * Chat provider evidence is matched at ingestion time via scan root paths, * not at discovery time. Only git repos and file manifests create project roots. */ export async function findOpenCodeProjects( _openCodePath: string, ): Promise { return []; } /** * Cursor chat roots are keyed by workspace hash, not canonical paths. * We intentionally skip project discovery here to avoid creating unusable * project roots and instead rely on ingestion-time hash matching. */ export async function findCursorProjects( _cursorChatsPath: string, ): Promise { return []; } /** * Main discovery function that handles all scan root types */ export async function discoverProjects( scanRootPath: string, scanRootType: | "git" | "files" | "claude" | "claude_desktop" | "gemini" | "codex" | "opencode" | "cursor" | "cline" | "amp" | "copilot" | "grok" | "linear", ): Promise { switch (scanRootType) { case "git": return findGitRepos(scanRootPath); case "files": return findFileProjects(scanRootPath); case "claude": return findClaudeProjects(scanRootPath); case "gemini": return findGeminiProjects(scanRootPath); case "codex": return findCodexProjects(scanRootPath); case "opencode": return findOpenCodeProjects(scanRootPath); case "cursor": return findCursorProjects(scanRootPath); default: return []; } }