Recently Written · git

ideamine

An idea inbox for Claude Code: /idea saves ideas at zero tokens; Claude triages them and routes each to the cheapest model that can build it.

git clone https://github.com/equwal/ideamine

Log | Files | Refs


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 }