src/projects.js (3291 bytes)
1 // The folders that an idea can belong to. The triage pairs each idea with one of them, because the 2 // folder where the user saved an idea is often the scratch folder of an unrelated chat. 3 4 import fs from 'node:fs'; 5 import os from 'node:os'; 6 import path from 'node:path'; 7 8 const MAX_PROJECTS = 40; 9 10 /** True when two paths name the same folder: case-insensitive on Windows, trailing slashes ignored. */ 11 export function samePath(a, b) { 12 if (!a || !b) return false; 13 const norm = (p) => { 14 const r = path.resolve(p).replace(/[\\/]+$/, ''); 15 return process.platform === 'win32' ? r.toLowerCase() : r; 16 }; 17 return norm(a) === norm(b); 18 } 19 20 /** A folder that can hold a project: it exists, and it is not a drive root, the home, or a scratch folder. */ 21 function isProjectDir(dir) { 22 let stat; 23 try { 24 stat = fs.statSync(dir); 25 } catch { 26 return false; 27 } 28 const full = path.resolve(dir); 29 // The Claude desktop app makes a scratch folder for each chat that has no project folder. 30 return stat.isDirectory() && path.dirname(full) !== full && !samePath(full, os.homedir()) && !/[\\/]scratch-workspaces[\\/]/i.test(full); 31 } 32 33 /** The first line of the README, else the package.json description: enough for the triage to match. */ 34 function describe(dir) { 35 for (const name of ['README.md', 'readme.md', 'README']) { 36 try { 37 const lines = fs.readFileSync(path.join(dir, name), 'utf8').split(/\r?\n/); 38 const first = lines.map((l) => l.replace(/^#+\s*/, '').trim()).find(Boolean); 39 if (first) return first.slice(0, 100); 40 } catch { 41 // No README with this name. 42 } 43 } 44 try { 45 return String(JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).description || '').slice(0, 100); 46 } catch { 47 return ''; 48 } 49 } 50 51 /** 52 * Project folders on this machine: the folders of the ideas, then the projects that Claude Code 53 * knows (the keys of "projects" in .claude.json). Each has a unique `name` (the folder name, with a 54 * number when two folders have the same name), the `dir`, and a one-line `about`. 55 */ 56 export function knownProjects(db, { configDir = process.env.CLAUDE_CONFIG_DIR || os.homedir() } = {}) { 57 const dirs = []; 58 const add = (dir) => { 59 if (dir && isProjectDir(dir) && !dirs.some((d) => samePath(d, dir))) dirs.push(path.resolve(dir)); 60 }; 61 for (const idea of db.ideas) add(idea.project); 62 try { 63 const config = JSON.parse(fs.readFileSync(path.join(configDir, '.claude.json'), 'utf8')); 64 for (const dir of Object.keys(config.projects || {})) add(dir); 65 } catch { 66 // No Claude Code config: the folders of the ideas are the only candidates. 67 } 68 const taken = new Set(); 69 return dirs.slice(0, MAX_PROJECTS).map((dir) => { 70 const base = path.basename(dir); 71 let name = base; 72 for (let n = 2; taken.has(name.toLowerCase()); n++) name = `${base}-${n}`; 73 taken.add(name.toLowerCase()); 74 return { name, dir, about: describe(dir) }; 75 }); 76 } 77 78 /** The project that a triage answer names: by its name (as the prompt shows it) or by its full path. */ 79 export function findProject(projects, answer) { 80 if (!answer) return null; 81 const key = String(answer).trim().toLowerCase(); 82 return projects.find((p) => p.name.toLowerCase() === key) || projects.find((p) => samePath(p.dir, answer)) || null; 83 }