diff --git a/src/commands/projects.test.ts b/src/commands/projects.test.ts index f5e3723..76cdb1c 100644 --- a/src/commands/projects.test.ts +++ b/src/commands/projects.test.ts @@ -10,6 +10,15 @@ vi.mock('../projects/projects.js', () => ({ pruneClones: vi.fn(), })); +vi.mock('../daemon/config.js', () => ({ + loadConfig: vi.fn().mockReturnValue({ + agents: { default: '/home/u/agents', additional: [] }, + projects: { default: '/home/u/projects', additional: [] }, + }), + getProjectsDirs: vi.fn().mockReturnValue(['/home/u/projects']), + getConfigDir: vi.fn().mockReturnValue('/mock/config'), +})); + import { loadProjects, addProject, @@ -164,20 +173,20 @@ describe('projects command', () => { await program.parseAsync(['node', 'agentage', 'projects', 'discover', '/tmp/root']); - expect(mockDiscoverProjects).toHaveBeenCalledWith('/tmp/root'); + expect(mockDiscoverProjects).toHaveBeenCalledWith(['/tmp/root']); expect(logs.some((l) => l.includes('Discovered 2 new project(s)'))).toBe(true); expect(logs.some((l) => l.includes('proj-a'))).toBe(true); expect(logs.some((l) => l.includes('proj-b'))).toBe(true); expect(mockExit).toHaveBeenCalledWith(0); }); - it('defaults to cwd when no path given', async () => { + it('defaults to configured projects.dirs when no path given', async () => { mockLoadProjects.mockReturnValue([]); mockDiscoverProjects.mockReturnValue([]); await program.parseAsync(['node', 'agentage', 'projects', 'discover']); - expect(mockDiscoverProjects).toHaveBeenCalledWith(expect.any(String)); + expect(mockDiscoverProjects).toHaveBeenCalledWith(['/home/u/projects']); expect(logs.some((l) => l.includes('No new projects discovered'))).toBe(true); expect(mockExit).toHaveBeenCalledWith(0); }); diff --git a/src/commands/projects.ts b/src/commands/projects.ts index b3acc4f..ea7ce4c 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -9,6 +9,7 @@ import { getWorktrees, pruneClones, } from '../projects/projects.js'; +import { loadConfig, getProjectsDirs } from '../daemon/config.js'; export const registerProjects = (program: Command): void => { const cmd = program.command('projects').description('Manage tracked projects'); @@ -142,13 +143,14 @@ const handleRemove = (name: string): void => { }; const handleDiscover = (path?: string): void => { - const resolvedPath = resolve(path ?? process.cwd()); + const roots = path ? [resolve(path)] : getProjectsDirs(loadConfig()); const before = loadProjects(); - const after = discoverProjects(resolvedPath); + const after = discoverProjects(roots); const newProjects = after.filter((p) => !before.some((b) => b.path === p.path)); if (newProjects.length === 0) { console.log(chalk.gray('No new projects discovered.')); + console.log(chalk.dim(`Searched: ${roots.join(', ')}`)); } else { console.log(chalk.green(`Discovered ${newProjects.length} new project(s):`)); for (const p of newProjects) { diff --git a/src/discovery/scanner.test.ts b/src/discovery/scanner.test.ts index d2fdef1..05162a2 100644 --- a/src/discovery/scanner.test.ts +++ b/src/discovery/scanner.test.ts @@ -109,6 +109,40 @@ describe('scanner', () => { expect(warnings[0].message).toContain('Import failed'); }); + it('skips agents inside IGNORE_DIRS (node_modules, .git, dist, etc.)', async () => { + const root = join(testDir, 'scan-ignore'); + mkdirSync(join(root, 'real'), { recursive: true }); + mkdirSync(join(root, 'node_modules', 'pkg'), { recursive: true }); + mkdirSync(join(root, 'dist'), { recursive: true }); + mkdirSync(join(root, '.git'), { recursive: true }); + + writeFileSync(join(root, 'real', 'keep.agent.md'), '---\nname: keep\n---\n'); + writeFileSync(join(root, 'node_modules', 'pkg', 'ghost.agent.md'), '---\nname: ghost\n---\n'); + writeFileSync(join(root, 'dist', 'built.agent.md'), '---\nname: built\n---\n'); + writeFileSync(join(root, '.git', 'hooked.agent.md'), '---\nname: hooked\n---\n'); + + const factory: AgentFactory = async (path) => { + if (!path.endsWith('.agent.md')) return null; + const name = path.split('/').pop()!.replace('.agent.md', ''); + return { + manifest: { name, path }, + async run() { + return { + runId: 'x', + events: (async function* () {})(), + cancel: () => {}, + sendInput: () => {}, + }; + }, + }; + }; + + const { scanAgents } = await import('./scanner.js'); + const agents = await scanAgents([root], [factory]); + const names = agents.map((a) => a.manifest.name).sort(); + expect(names).toEqual(['keep']); + }); + it('clears warnings on each scan', async () => { const agentsDir = join(testDir, 'agents-clear'); mkdirSync(agentsDir, { recursive: true }); diff --git a/src/discovery/scanner.ts b/src/discovery/scanner.ts index 774ae14..5ee6e81 100644 --- a/src/discovery/scanner.ts +++ b/src/discovery/scanner.ts @@ -17,6 +17,20 @@ let lastWarnings: ScanWarning[] = []; export const getLastScanWarnings = (): ScanWarning[] => lastWarnings; +export const IGNORE_DIRS = new Set([ + 'node_modules', + '.git', + '.github', + '.github-private', + '.claude', + 'dist', + 'build', + '.next', + 'coverage', + '.turbo', + '.cache', +]); + const getAllFiles = (dir: string): string[] => { const results: string[] = []; @@ -27,6 +41,7 @@ const getAllFiles = (dir: string): string[] => { const entries = readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { + if (entry.isDirectory() && IGNORE_DIRS.has(entry.name)) continue; const fullPath = join(dir, entry.name); if (entry.isDirectory()) { results.push(...getAllFiles(fullPath)); diff --git a/src/projects/projects.test.ts b/src/projects/projects.test.ts index 069bb79..2450916 100644 --- a/src/projects/projects.test.ts +++ b/src/projects/projects.test.ts @@ -351,6 +351,98 @@ describe('discoverProjects', () => { expect(result[0].remote).toBeUndefined(); }); + it('accepts an array of roots and dedupes results', () => { + mockExistsSync.mockImplementation((p) => { + const s = String(p); + if (s.endsWith('projects.json')) return false; + if (s === '/a/repo-x/.git') return true; + if (s === '/b/repo-x/.git') return true; + return false; + }); + mockReaddirSync.mockImplementation((p) => { + const s = String(p); + if (s === '/a') return [{ name: 'repo-x', isDirectory: () => true }] as never; + if (s === '/b') return [{ name: 'repo-x', isDirectory: () => true }] as never; + return []; + }); + mockStatSync.mockReturnValue({ isDirectory: () => true } as never); + + const result = discoverProjects(['/a', '/b']); + expect(result.map((r) => r.path).sort()).toEqual(['/a/repo-x', '/b/repo-x']); + }); + + it('skips node_modules, .git, and other ignored dirs', () => { + mockExistsSync.mockImplementation((p) => { + const s = String(p); + if (s.endsWith('projects.json')) return false; + if (s === '/root/keep/.git') return true; + return false; + }); + mockReaddirSync.mockImplementation((p) => { + const s = String(p); + if (s === '/root') + return [ + { name: 'keep', isDirectory: () => true }, + { name: 'node_modules', isDirectory: () => true }, + { name: '.github', isDirectory: () => true }, + { name: '.claude', isDirectory: () => true }, + { name: 'dist', isDirectory: () => true }, + ] as never; + return []; + }); + mockStatSync.mockReturnValue({ isDirectory: () => true } as never); + + const result = discoverProjects('/root'); + + expect(result).toHaveLength(1); + expect(result[0].path).toBe('/root/keep'); + // Sanity: readdirSync never called on ignored paths + const readCalls = mockReaddirSync.mock.calls.map((c) => String(c[0])); + expect(readCalls).not.toContain('/root/node_modules'); + expect(readCalls).not.toContain('/root/.github'); + }); + + it('walks recursively to find nested git repos', () => { + mockExistsSync.mockImplementation((p) => { + const s = String(p); + if (s.endsWith('projects.json')) return false; + if (s === '/root/group/inner/.git') return true; + return false; + }); + mockReaddirSync.mockImplementation((p) => { + const s = String(p); + if (s === '/root') return [{ name: 'group', isDirectory: () => true }] as never; + if (s === '/root/group') return [{ name: 'inner', isDirectory: () => true }] as never; + return []; + }); + mockStatSync.mockReturnValue({ isDirectory: () => true } as never); + + const result = discoverProjects('/root'); + expect(result).toHaveLength(1); + expect(result[0].path).toBe('/root/group/inner'); + }); + + it('stops descending into a matched repo', () => { + mockExistsSync.mockImplementation((p) => { + const s = String(p); + if (s.endsWith('projects.json')) return false; + if (s === '/root/outer/.git') return true; + if (s === '/root/outer/sub/.git') return true; + return false; + }); + mockReaddirSync.mockImplementation((p) => { + const s = String(p); + if (s === '/root') return [{ name: 'outer', isDirectory: () => true }] as never; + if (s === '/root/outer') return [{ name: 'sub', isDirectory: () => true }] as never; + return []; + }); + mockStatSync.mockReturnValue({ isDirectory: () => true } as never); + + const result = discoverProjects('/root'); + expect(result).toHaveLength(1); + expect(result[0].path).toBe('/root/outer'); + }); + it('backfills remote on existing entries that are missing it', () => { const existing: Project[] = [{ name: 'cli', path: '/root/cli', discovered: true }]; mockExistsSync.mockImplementation((p) => { diff --git a/src/projects/projects.ts b/src/projects/projects.ts index 38af707..165bcb3 100644 --- a/src/projects/projects.ts +++ b/src/projects/projects.ts @@ -144,28 +144,76 @@ export const removeProject = (name: string): boolean => { return true; }; -export const discoverProjects = (rootDir: string): Project[] => { - const entries = readdirSync(rootDir, { withFileTypes: true }); - const projects = loadProjects(); +export const PROJECTS_IGNORE_DIRS = new Set([ + 'node_modules', + '.github', + '.github-private', + '.claude', + 'dist', + 'build', + '.next', + 'coverage', + '.turbo', + '.cache', +]); + +const MAX_DISCOVERY_DEPTH = 5; + +const walkForGitRepos = (dir: string, depth: number, out: string[]): void => { + if (depth > MAX_DISCOVERY_DEPTH) return; + + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } for (const entry of entries) { if (!entry.isDirectory()) continue; + if (PROJECTS_IGNORE_DIRS.has(entry.name)) continue; - const dirPath = join(rootDir, entry.name); + const dirPath = join(dir, entry.name); const gitPath = join(dirPath, '.git'); - if (!existsSync(gitPath)) continue; - if (!statSync(gitPath).isDirectory()) continue; + let isRepo = false; + try { + isRepo = existsSync(gitPath) && statSync(gitPath).isDirectory(); + } catch { + isRepo = false; + } + + if (isRepo) { + out.push(dirPath); + // Don't descend into a matched repo — we treat it as the project boundary. + continue; + } + + walkForGitRepos(dirPath, depth + 1, out); + } +}; + +export const discoverProjects = (rootDirs: string | string[]): Project[] => { + const roots = Array.isArray(rootDirs) ? rootDirs : [rootDirs]; + const found: string[] = []; + for (const root of roots) { + walkForGitRepos(root, 0, found); + } + + const projects = loadProjects(); + const seen = new Set(projects.map((p) => p.path)); + for (const dirPath of found) { const existing = projects.find((p) => p.path === dirPath); if (existing) { - // Backfill remote on previously-discovered entries that predate remote capture. if (!existing.remote) { const remote = getOriginUrl(dirPath); if (remote) existing.remote = remote; } continue; } + if (seen.has(dirPath)) continue; + seen.add(dirPath); const name = deriveNameFromDir(dirPath); const remote = getOriginUrl(dirPath);