project-compass 1.0.10 → 2.0.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.
package/src/cli.js CHANGED
@@ -1,23 +1,28 @@
1
1
  #!/usr/bin/env node
2
2
  import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
3
3
  import {render, Box, Text, useApp, useInput} from 'ink';
4
- import fastGlob from 'fast-glob';
5
4
  import path from 'path';
6
5
  import fs from 'fs';
7
- import os from 'os';
8
6
  import kleur from 'kleur';
9
7
  import {execa} from 'execa';
8
+ import {discoverProjects, SCHEMA_GUIDE} from './projectDetection.js';
9
+ import {CONFIG_PATH, PLUGIN_FILE, ensureConfigDir} from './configPaths.js';
10
10
 
11
11
  const create = React.createElement;
12
- const CONFIG_DIR = path.join(os.homedir(), '.project-compass');
13
- const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
14
- const PLUGIN_FILE = path.join(CONFIG_DIR, 'plugins.json');
15
12
  const DEFAULT_CONFIG = {customCommands: {}};
16
- function ensureConfigDir() {
17
- if (!fs.existsSync(CONFIG_DIR)) {
18
- fs.mkdirSync(CONFIG_DIR, {recursive: true});
19
- }
20
- }
13
+ const ART_CHARS = ['▁', '▃', '▄', '▅', '▇'];
14
+ const ART_COLORS = ['magenta', 'blue', 'cyan', 'yellow', 'red'];
15
+ const OUTPUT_WINDOW_SIZE = 8;
16
+ const OUTPUT_WINDOW_HEIGHT = OUTPUT_WINDOW_SIZE + 2;
17
+ const PROJECTS_MIN_WIDTH = 32;
18
+ const DETAILS_MIN_WIDTH = 44;
19
+ const HELP_CARD_MIN_WIDTH = 28;
20
+ const RECENT_RUN_LIMIT = 5;
21
+ const ACTION_MAP = {
22
+ b: 'build',
23
+ t: 'test',
24
+ r: 'run'
25
+ };
21
26
 
22
27
  function saveConfig(config) {
23
28
  try {
@@ -48,744 +53,6 @@ function loadConfig() {
48
53
  return {...DEFAULT_CONFIG};
49
54
  }
50
55
 
51
- function parseCommandTokens(value) {
52
- if (Array.isArray(value)) {
53
- return value.map((token) => String(token));
54
- }
55
- if (typeof value === 'string') {
56
- return value.trim().split(/\s+/).filter(Boolean);
57
- }
58
- return [];
59
- }
60
-
61
- function resolveScriptCommand(project, scriptName, fallback = null) {
62
- const scripts = project.metadata?.scripts || {};
63
- if (Object.prototype.hasOwnProperty.call(scripts, scriptName)) {
64
- return ['npm', 'run', scriptName];
65
- }
66
- if (typeof fallback === 'function') {
67
- return fallback();
68
- }
69
- return fallback;
70
- }
71
-
72
- function gatherNodeDependencies(pkg) {
73
- const deps = new Set();
74
- ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'].forEach((key) => {
75
- if (pkg[key]) {
76
- Object.keys(pkg[key]).forEach((name) => deps.add(name));
77
- }
78
- });
79
- return Array.from(deps);
80
- }
81
-
82
- function gatherPythonDependencies(projectPath) {
83
- const set = new Set();
84
- const addFromFile = (filePath) => {
85
- if (!fs.existsSync(filePath)) {
86
- return;
87
- }
88
- const raw = fs.readFileSync(filePath, 'utf-8');
89
- raw.split(/\r?\n/).forEach((line) => {
90
- const clean = line.trim().split('#')[0].trim();
91
- if (clean) {
92
- const token = clean.split(/[>=<=~!]/)[0].trim().toLowerCase();
93
- if (token) {
94
- set.add(token);
95
- }
96
- }
97
- });
98
- };
99
- addFromFile(path.join(projectPath, 'requirements.txt'));
100
- const pyproject = path.join(projectPath, 'pyproject.toml');
101
- if (fs.existsSync(pyproject)) {
102
- const content = fs.readFileSync(pyproject, 'utf-8').toLowerCase();
103
- const matches = content.match(/\b[a-z0-9-_/]+\b/g);
104
- (matches || []).forEach((match) => {
105
- if (match) {
106
- set.add(match);
107
- }
108
- });
109
- }
110
- return Array.from(set);
111
- }
112
-
113
- function dependencyMatches(project, needle) {
114
- const dependencies = (project.metadata?.dependencies || []).map((dep) => dep.toLowerCase());
115
- const target = needle.toLowerCase();
116
- return dependencies.some((value) => value === target || value.startsWith(`${target}@`) || value.includes(`/${target}`));
117
- }
118
-
119
- function hasProjectFile(project, file) {
120
- return fs.existsSync(path.join(project.path, file));
121
- }
122
-
123
-
124
- const builtInFrameworks = [
125
- {
126
- id: 'next',
127
- name: 'Next.js',
128
- icon: '🧭',
129
- description: 'React + Next.js (SSR/SSG) apps',
130
- languages: ['Node.js'],
131
- priority: 115,
132
- match(project) {
133
- const hasNextConfig = fs.existsSync(path.join(project.path, 'next.config.js'));
134
- return dependencyMatches(project, 'next') || hasNextConfig;
135
- },
136
- commands(project) {
137
- const commands = {};
138
- const add = (key, label, fallback) => {
139
- const tokens = resolveScriptCommand(project, key, fallback);
140
- if (tokens) {
141
- commands[key] = {label, command: tokens, source: 'framework'};
142
- }
143
- };
144
- const buildFallback = () => ['npx', 'next', 'build'];
145
- const startFallback = () => ['npx', 'next', 'start'];
146
- const devFallback = () => ['npx', 'next', 'dev'];
147
- add('run', 'Next dev', devFallback);
148
- add('build', 'Next build', buildFallback);
149
- add('test', 'Next test', () => ['npm', 'run', 'test']);
150
- add('start', 'Next start', startFallback);
151
- return commands;
152
- }
153
- },
154
- {
155
- id: 'react',
156
- name: 'React',
157
- icon: '⚛️',
158
- description: 'React apps (CRA, Vite React)',
159
- languages: ['Node.js'],
160
- priority: 112,
161
- match(project) {
162
- return dependencyMatches(project, 'react') && (dependencyMatches(project, 'react-scripts') || dependencyMatches(project, 'vite') || hasProjectFile(project, 'vite.config.js'));
163
- },
164
- commands(project) {
165
- const commands = {};
166
- const add = (key, label, fallback) => {
167
- const tokens = resolveScriptCommand(project, key, fallback);
168
- if (tokens) {
169
- commands[key] = {label, command: tokens, source: 'framework'};
170
- }
171
- };
172
- add('run', 'React dev', () => ['npm', 'run', 'dev']);
173
- add('build', 'React build', () => ['npm', 'run', 'build']);
174
- add('test', 'React test', () => ['npm', 'run', 'test']);
175
- return commands;
176
- }
177
- },
178
- {
179
- id: 'vue',
180
- name: 'Vue.js',
181
- icon: '🟩',
182
- description: 'Vue CLI or Vite + Vue apps',
183
- languages: ['Node.js'],
184
- priority: 111,
185
- match(project) {
186
- return dependencyMatches(project, 'vue') && (hasProjectFile(project, 'vue.config.js') || dependencyMatches(project, '@vue/cli-service') || dependencyMatches(project, 'vite'));
187
- },
188
- commands(project) {
189
- const commands = {};
190
- const add = (key, label, fallback) => {
191
- const tokens = resolveScriptCommand(project, key, fallback);
192
- if (tokens) {
193
- commands[key] = {label, command: tokens, source: 'framework'};
194
- }
195
- };
196
- add('run', 'Vue dev', () => ['npm', 'run', 'dev']);
197
- add('build', 'Vue build', () => ['npm', 'run', 'build']);
198
- add('test', 'Vue test', () => ['npm', 'run', 'test']);
199
- return commands;
200
- }
201
- },
202
- {
203
- id: 'nest',
204
- name: 'NestJS',
205
- icon: '🛡️',
206
- description: 'NestJS backend',
207
- languages: ['Node.js'],
208
- priority: 110,
209
- match(project) {
210
- return dependencyMatches(project, '@nestjs/cli') || dependencyMatches(project, '@nestjs/core');
211
- },
212
- commands(project) {
213
- const commands = {};
214
- const add = (key, label, fallback) => {
215
- const tokens = resolveScriptCommand(project, key, fallback);
216
- if (tokens) {
217
- commands[key] = {label, command: tokens, source: 'framework'};
218
- }
219
- };
220
- add('run', 'Nest dev', () => ['npm', 'run', 'start:dev']);
221
- add('build', 'Nest build', () => ['npm', 'run', 'build']);
222
- add('test', 'Nest test', () => ['npm', 'run', 'test']);
223
- return commands;
224
- }
225
- },
226
- {
227
- id: 'angular',
228
- name: 'Angular',
229
- icon: '🅰️',
230
- description: 'Angular CLI projects',
231
- languages: ['Node.js'],
232
- priority: 109,
233
- match(project) {
234
- return hasProjectFile(project, 'angular.json') || dependencyMatches(project, '@angular/cli');
235
- },
236
- commands(project) {
237
- const commands = {};
238
- const add = (key, label, fallback) => {
239
- const tokens = resolveScriptCommand(project, key, fallback);
240
- if (tokens) {
241
- commands[key] = {label, command: tokens, source: 'framework'};
242
- }
243
- };
244
- add('run', 'Angular serve', () => ['npm', 'run', 'start']);
245
- add('build', 'Angular build', () => ['npm', 'run', 'build']);
246
- add('test', 'Angular test', () => ['npm', 'run', 'test']);
247
- return commands;
248
- }
249
- },
250
- {
251
- id: 'sveltekit',
252
- name: 'SvelteKit',
253
- icon: '🌀',
254
- description: 'SvelteKit apps',
255
- languages: ['Node.js'],
256
- priority: 108,
257
- match(project) {
258
- return hasProjectFile(project, 'svelte.config.js') || dependencyMatches(project, '@sveltejs/kit');
259
- },
260
- commands(project) {
261
- const commands = {};
262
- const add = (key, label, fallback) => {
263
- const tokens = resolveScriptCommand(project, key, fallback);
264
- if (tokens) {
265
- commands[key] = {label, command: tokens, source: 'framework'};
266
- }
267
- };
268
- add('run', 'SvelteKit dev', () => ['npm', 'run', 'dev']);
269
- add('build', 'SvelteKit build', () => ['npm', 'run', 'build']);
270
- add('test', 'SvelteKit test', () => ['npm', 'run', 'test']);
271
- add('preview', 'SvelteKit preview', () => ['npm', 'run', 'preview']);
272
- return commands;
273
- }
274
- },
275
- {
276
- id: 'nuxt',
277
- name: 'Nuxt',
278
- icon: '🪄',
279
- description: 'Nuxt.js / Vue SSR',
280
- languages: ['Node.js'],
281
- priority: 107,
282
- match(project) {
283
- return hasProjectFile(project, 'nuxt.config.js') || dependencyMatches(project, 'nuxt');
284
- },
285
- commands(project) {
286
- const commands = {};
287
- const add = (key, label, fallback) => {
288
- const tokens = resolveScriptCommand(project, key, fallback);
289
- if (tokens) {
290
- commands[key] = {label, command: tokens, source: 'framework'};
291
- }
292
- };
293
- add('run', 'Nuxt dev', () => ['npm', 'run', 'dev']);
294
- add('build', 'Nuxt build', () => ['npm', 'run', 'build']);
295
- add('start', 'Nuxt start', () => ['npm', 'run', 'start']);
296
- return commands;
297
- }
298
- },
299
- {
300
- id: 'astro',
301
- name: 'Astro',
302
- icon: '✨',
303
- description: 'Astro static sites',
304
- languages: ['Node.js'],
305
- priority: 106,
306
- match(project) {
307
- const matches = ['astro.config.mjs', 'astro.config.ts'].some((file) => hasProjectFile(project, file));
308
- return matches || dependencyMatches(project, 'astro');
309
- },
310
- commands(project) {
311
- const commands = {};
312
- const add = (key, label, fallback) => {
313
- const tokens = resolveScriptCommand(project, key, fallback);
314
- if (tokens) {
315
- commands[key] = {label, command: tokens, source: 'framework'};
316
- }
317
- };
318
- add('run', 'Astro dev', () => ['npm', 'run', 'dev']);
319
- add('build', 'Astro build', () => ['npm', 'run', 'build']);
320
- add('preview', 'Astro preview', () => ['npm', 'run', 'preview']);
321
- return commands;
322
- }
323
- },
324
- {
325
- id: 'django',
326
- name: 'Django',
327
- icon: '🌿',
328
- description: 'Django web application',
329
- languages: ['Python'],
330
- priority: 110,
331
- match(project) {
332
- return dependencyMatches(project, 'django') || hasProjectFile(project, 'manage.py');
333
- },
334
- commands(project) {
335
- const managePath = path.join(project.path, 'manage.py');
336
- if (!fs.existsSync(managePath)) {
337
- return {};
338
- }
339
- return {
340
- run: {label: 'Django runserver', command: ['python', 'manage.py', 'runserver'], source: 'framework'},
341
- test: {label: 'Django test', command: ['python', 'manage.py', 'test'], source: 'framework'},
342
- migrate: {label: 'Django migrate', command: ['python', 'manage.py', 'migrate'], source: 'framework'}
343
- };
344
- }
345
- },
346
- {
347
- id: 'flask',
348
- name: 'Flask',
349
- icon: '🍶',
350
- description: 'Flask microservices',
351
- languages: ['Python'],
352
- priority: 105,
353
- match(project) {
354
- return dependencyMatches(project, 'flask') || hasProjectFile(project, 'app.py');
355
- },
356
- commands(project) {
357
- const commands = {};
358
- const entry = hasProjectFile(project, 'app.py') ? 'app.py' : 'main.py';
359
- commands.run = {label: 'Flask app', command: ['python', entry], source: 'framework'};
360
- commands.test = {label: 'Pytest', command: ['pytest'], source: 'framework'};
361
- return commands;
362
- }
363
- },
364
- {
365
- id: 'fastapi',
366
- name: 'FastAPI',
367
- icon: '⚡',
368
- description: 'FastAPI + Uvicorn',
369
- languages: ['Python'],
370
- priority: 105,
371
- match(project) {
372
- return dependencyMatches(project, 'fastapi');
373
- },
374
- commands(project) {
375
- const entry = hasProjectFile(project, 'main.py') ? 'main.py' : 'app.py';
376
- return {
377
- run: {label: 'Uvicorn reload', command: ['uvicorn', `${entry.split('.')[0]}:app`, '--reload'], source: 'framework'},
378
- test: {label: 'Pytest', command: ['pytest'], source: 'framework'}
379
- };
380
- }
381
- },
382
- {
383
- id: 'spring',
384
- name: 'Spring Boot',
385
- icon: '🌱',
386
- description: 'Spring Boot apps',
387
- languages: ['Java'],
388
- priority: 105,
389
- match(project) {
390
- return dependencyMatches(project, 'spring-boot-starter') || hasProjectFile(project, 'src/main/java');
391
- },
392
- commands(project) {
393
- const hasMvnw = fs.existsSync(path.join(project.path, 'mvnw'));
394
- const base = hasMvnw ? './mvnw' : 'mvn';
395
- return {
396
- run: {label: 'Spring Boot run', command: [base, 'spring-boot:run'], source: 'framework'},
397
- build: {label: 'Maven package', command: [base, 'package'], source: 'framework'},
398
- test: {label: 'Maven test', command: [base, 'test'], source: 'framework'}
399
- };
400
- }
401
- }
402
- ];
403
- function loadUserFrameworks() {
404
- ensureConfigDir();
405
- try {
406
- if (!fs.existsSync(PLUGIN_FILE)) {
407
- return [];
408
- }
409
- const payload = JSON.parse(fs.readFileSync(PLUGIN_FILE, 'utf-8') || '{}');
410
- const plugins = payload.plugins || [];
411
- return plugins.map((entry) => {
412
- const normalizedId = entry.id || (entry.name ? entry.name.toLowerCase().replace(/\s+/g, '-') : `plugin-${Math.random().toString(36).slice(2, 8)}`);
413
- const commands = {};
414
- Object.entries(entry.commands || {}).forEach(([key, value]) => {
415
- const command = parseCommandTokens(typeof value === 'object' ? value.command : value);
416
- if (!command.length) {
417
- return;
418
- }
419
- commands[key] = {
420
- label: typeof value === 'object' ? value.label || key : key,
421
- command,
422
- source: 'plugin'
423
- };
424
- });
425
- return {
426
- id: normalizedId,
427
- name: entry.name || normalizedId,
428
- icon: entry.icon || '🧩',
429
- description: entry.description || '',
430
- languages: entry.languages || [],
431
- files: entry.files || [],
432
- dependencies: entry.dependencies || [],
433
- scripts: entry.scripts || [],
434
- priority: Number.isFinite(entry.priority) ? entry.priority : 70,
435
- commands,
436
- match: entry.match
437
- };
438
- })
439
- .filter((plugin) => plugin.name && plugin.commands && Object.keys(plugin.commands).length);
440
- } catch (error) {
441
- console.error(`Failed to parse plugins.json: ${error.message}`);
442
- return [];
443
- }
444
- }
445
-
446
- let cachedFrameworkPlugins = null;
447
-
448
- function getFrameworkPlugins() {
449
- if (cachedFrameworkPlugins) {
450
- return cachedFrameworkPlugins;
451
- }
452
- cachedFrameworkPlugins = [...builtInFrameworks, ...loadUserFrameworks()];
453
- return cachedFrameworkPlugins;
454
- }
455
-
456
- function matchesPlugin(project, plugin) {
457
- if (plugin.languages && plugin.languages.length > 0 && !plugin.languages.includes(project.type)) {
458
- return false;
459
- }
460
- if (plugin.files && plugin.files.length > 0) {
461
- const hit = plugin.files.some((file) => fs.existsSync(path.join(project.path, file)));
462
- if (!hit) {
463
- return false;
464
- }
465
- }
466
- if (plugin.dependencies && plugin.dependencies.length > 0) {
467
- const hit = plugin.dependencies.some((dep) => dependencyMatches(project, dep));
468
- if (!hit) {
469
- return false;
470
- }
471
- }
472
- if (plugin.scripts && plugin.scripts.length > 0) {
473
- const scripts = project.metadata?.scripts || {};
474
- const hit = plugin.scripts.some((name) => Object.prototype.hasOwnProperty.call(scripts, name));
475
- if (!hit) {
476
- return false;
477
- }
478
- }
479
- if (typeof plugin.match === 'function') {
480
- if (!plugin.match(project)) {
481
- return false;
482
- }
483
- }
484
- return true;
485
- }
486
-
487
- function applyFrameworkPlugins(project) {
488
- const plugins = getFrameworkPlugins();
489
- let commands = {...project.commands};
490
- const frameworks = [];
491
- let maxPriority = project.priority || 0;
492
- for (const plugin of plugins) {
493
- if (!matchesPlugin(project, plugin)) {
494
- continue;
495
- }
496
- frameworks.push({id: plugin.id, name: plugin.name, icon: plugin.icon, description: plugin.description});
497
- if (plugin.priority && plugin.priority > maxPriority) {
498
- maxPriority = plugin.priority;
499
- }
500
- const pluginCommands = typeof plugin.commands === 'function' ? plugin.commands(project) : plugin.commands;
501
- if (pluginCommands) {
502
- Object.entries(pluginCommands).forEach(([key, command]) => {
503
- if (!Array.isArray(command.command) || command.command.length === 0) {
504
- return;
505
- }
506
- commands = {
507
- ...commands,
508
- [key]: {
509
- ...command,
510
- source: command.source || 'framework'
511
- }
512
- };
513
- });
514
- }
515
- }
516
- return {...project, commands, frameworks, priority: maxPriority};
517
- }
518
-
519
- const IGNORE_PATTERNS = ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**', '**/target/**'];
520
-
521
- const SCHEMAS = [
522
- {
523
- type: 'node',
524
- label: 'Node.js',
525
- icon: '🟢',
526
- priority: 90,
527
- files: ['package.json'],
528
- async build(projectPath, manifest) {
529
- const pkgPath = path.join(projectPath, 'package.json');
530
- if (!fs.existsSync(pkgPath)) {
531
- return null;
532
- }
533
- const content = await fs.promises.readFile(pkgPath, 'utf-8');
534
- const pkg = JSON.parse(content);
535
- const scripts = pkg.scripts || {};
536
- const commands = {};
537
- const preferScript = (targetKey, names, label) => {
538
- for (const name of names) {
539
- if (Object.prototype.hasOwnProperty.call(scripts, name)) {
540
- commands[targetKey] = {label, command: ['npm', 'run', name]};
541
- break;
542
- }
543
- }
544
- };
545
- preferScript('build', ['build', 'compile', 'dist'], 'Build');
546
- preferScript('test', ['test', 'check', 'spec'], 'Test');
547
- preferScript('run', ['start', 'dev', 'serve', 'run'], 'Start');
548
-
549
- const metadata = {
550
- dependencies: gatherNodeDependencies(pkg),
551
- scripts,
552
- packageJson: pkg
553
- };
554
-
555
- return {
556
- id: `${projectPath}::node`,
557
- path: projectPath,
558
- name: pkg.name || path.basename(projectPath),
559
- type: 'Node.js',
560
- icon: '🟢',
561
- priority: this.priority,
562
- commands,
563
- metadata,
564
- manifest: path.basename(manifest),
565
- description: pkg.description || '',
566
- extra: {
567
- scripts: Object.keys(scripts)
568
- }
569
- };
570
- }
571
- },
572
- {
573
- type: 'python',
574
- label: 'Python',
575
- icon: '🐍',
576
- priority: 80,
577
- files: ['pyproject.toml', 'requirements.txt', 'setup.py'],
578
- async build(projectPath, manifest) {
579
- const commands = {};
580
- if (fs.existsSync(path.join(projectPath, 'pyproject.toml'))) {
581
- commands.test = {label: 'Pytest', command: ['pytest']};
582
- } else {
583
- commands.test = {label: 'Unittest', command: ['python', '-m', 'unittest', 'discover']};
584
- }
585
-
586
- const entry = await findPythonEntry(projectPath);
587
- if (entry) {
588
- commands.run = {label: 'Run', command: ['python', entry]};
589
- }
590
-
591
- const metadata = {
592
- dependencies: gatherPythonDependencies(projectPath)
593
- };
594
-
595
- return {
596
- id: `${projectPath}::python`,
597
- path: projectPath,
598
- name: path.basename(projectPath),
599
- type: 'Python',
600
- icon: '🐍',
601
- priority: this.priority,
602
- commands,
603
- metadata,
604
- manifest: path.basename(manifest),
605
- description: '',
606
- extra: {
607
- entry
608
- }
609
- };
610
- }
611
- },
612
- {
613
- type: 'rust',
614
- label: 'Rust',
615
- icon: '🦀',
616
- priority: 85,
617
- files: ['Cargo.toml'],
618
- async build(projectPath, manifest) {
619
- return {
620
- id: `${projectPath}::rust`,
621
- path: projectPath,
622
- name: path.basename(projectPath),
623
- type: 'Rust',
624
- icon: '🦀',
625
- priority: this.priority,
626
- commands: {
627
- build: {label: 'Cargo build', command: ['cargo', 'build']},
628
- test: {label: 'Cargo test', command: ['cargo', 'test']},
629
- run: {label: 'Cargo run', command: ['cargo', 'run']}
630
- },
631
- metadata: {},
632
- manifest: path.basename(manifest),
633
- description: '',
634
- extra: {}
635
- };
636
- }
637
- },
638
- {
639
- type: 'go',
640
- label: 'Go',
641
- icon: '🐹',
642
- priority: 80,
643
- files: ['go.mod'],
644
- async build(projectPath, manifest) {
645
- return {
646
- id: `${projectPath}::go`,
647
- path: projectPath,
648
- name: path.basename(projectPath),
649
- type: 'Go',
650
- icon: '🐹',
651
- priority: this.priority,
652
- commands: {
653
- build: {label: 'Go build', command: ['go', 'build', './...']},
654
- test: {label: 'Go test', command: ['go', 'test', './...']},
655
- run: {label: 'Go run', command: ['go', 'run', '.']}
656
- },
657
- metadata: {},
658
- manifest: path.basename(manifest),
659
- description: '',
660
- extra: {}
661
- };
662
- }
663
- },
664
- {
665
- type: 'java',
666
- label: 'Java',
667
- icon: '☕️',
668
- priority: 75,
669
- files: ['pom.xml', 'build.gradle', 'build.gradle.kts'],
670
- async build(projectPath, manifest) {
671
- const hasMvnw = fs.existsSync(path.join(projectPath, 'mvnw'));
672
- const hasGradlew = fs.existsSync(path.join(projectPath, 'gradlew'));
673
- const commands = {};
674
- if (hasGradlew) {
675
- commands.build = {label: 'Gradle build', command: ['./gradlew', 'build']};
676
- commands.test = {label: 'Gradle test', command: ['./gradlew', 'test']};
677
- } else if (hasMvnw) {
678
- commands.build = {label: 'Maven package', command: ['./mvnw', 'package']};
679
- commands.test = {label: 'Maven test', command: ['./mvnw', 'test']};
680
- } else {
681
- commands.build = {label: 'Maven package', command: ['mvn', 'package']};
682
- commands.test = {label: 'Maven test', command: ['mvn', 'test']};
683
- }
684
-
685
- return {
686
- id: `${projectPath}::java`,
687
- path: projectPath,
688
- name: path.basename(projectPath),
689
- type: 'Java',
690
- icon: '☕️',
691
- priority: this.priority,
692
- commands,
693
- metadata: {},
694
- manifest: path.basename(manifest),
695
- description: '',
696
- extra: {}
697
- };
698
- }
699
- },
700
- {
701
- type: 'scala',
702
- label: 'Scala',
703
- icon: '🔵',
704
- priority: 70,
705
- files: ['build.sbt'],
706
- async build(projectPath, manifest) {
707
- return {
708
- id: `${projectPath}::scala`,
709
- path: projectPath,
710
- name: path.basename(projectPath),
711
- type: 'Scala',
712
- icon: '🔵',
713
- priority: this.priority,
714
- commands: {
715
- build: {label: 'sbt compile', command: ['sbt', 'compile']},
716
- test: {label: 'sbt test', command: ['sbt', 'test']},
717
- run: {label: 'sbt run', command: ['sbt', 'run']}
718
- },
719
- metadata: {},
720
- manifest: path.basename(manifest),
721
- description: '',
722
- extra: {}
723
- };
724
- }
725
- }
726
- ];
727
-
728
- async function findPythonEntry(projectPath) {
729
- const candidates = ['main.py', 'app.py', 'src/main.py', 'src/app.py'];
730
- for (const candidate of candidates) {
731
- const candidatePath = path.join(projectPath, candidate);
732
- if (fs.existsSync(candidatePath)) {
733
- return candidate;
734
- }
735
- }
736
- return null;
737
- }
738
-
739
- async function discoverProjects(root) {
740
- const projectMap = new Map();
741
- for (const schema of SCHEMAS) {
742
- const patterns = schema.files.map((file) => `**/${file}`);
743
- const matches = await fastGlob(patterns, {
744
- cwd: root,
745
- ignore: IGNORE_PATTERNS,
746
- onlyFiles: true,
747
- deep: 5
748
- });
749
-
750
- for (const match of matches) {
751
- const projectDir = path.resolve(root, path.dirname(match));
752
- const existing = projectMap.get(projectDir);
753
- if (existing && existing.priority >= schema.priority) {
754
- continue;
755
- }
756
- const entry = await schema.build(projectDir, match);
757
- if (!entry) {
758
- continue;
759
- }
760
- const withFrameworks = applyFrameworkPlugins(entry);
761
- projectMap.set(projectDir, withFrameworks);
762
- }
763
- }
764
- return Array.from(projectMap.values()).sort((a, b) => b.priority - a.priority);
765
- }
766
-
767
- const SCHEMA_GUIDE = SCHEMAS.map((schema) => ({
768
- type: schema.type,
769
- label: schema.label || schema.type,
770
- icon: schema.icon || '⚙',
771
- files: schema.files,
772
- hint: schema.label || schema.type
773
- }));
774
-
775
- const ACTION_MAP = {
776
- b: 'build',
777
- t: 'test',
778
- r: 'run'
779
- };
780
- const ART_CHARS = ['▁', '▃', '▄', '▅', '▇'];
781
- const ART_COLORS = ['magenta', 'blue', 'cyan', 'yellow', 'red'];
782
- const OUTPUT_WINDOW_SIZE = 8;
783
- const OUTPUT_WINDOW_HEIGHT = OUTPUT_WINDOW_SIZE + 2;
784
- const PROJECTS_MIN_WIDTH = 32;
785
- const DETAILS_MIN_WIDTH = 44;
786
- const HELP_CARD_MIN_WIDTH = 28;
787
- const RECENT_RUN_LIMIT = 5;
788
-
789
56
  function useScanner(rootPath) {
790
57
  const [state, setState] = useState({projects: [], loading: true, error: null});
791
58
 
@@ -996,11 +263,14 @@ function Compass({rootPath}) {
996
263
  }
997
264
 
998
265
  const normalizedInput = input?.toLowerCase();
999
- if (normalizedInput === 'h') {
266
+ const ctrlCombo = (char) => key.ctrl && normalizedInput === char;
267
+ const shiftCombo = (char) => key.shift && normalizedInput === char;
268
+ const toggleShortcut = (char) => ctrlCombo(char) || shiftCombo(char);
269
+ if (toggleShortcut('h')) {
1000
270
  setShowHelpCards((prev) => !prev);
1001
271
  return;
1002
272
  }
1003
- if (normalizedInput === 's') {
273
+ if (toggleShortcut('s')) {
1004
274
  setShowStructureGuide((prev) => !prev);
1005
275
  return;
1006
276
  }
@@ -1048,7 +318,7 @@ function Compass({rootPath}) {
1048
318
  setShowHelp((prev) => !prev);
1049
319
  return;
1050
320
  }
1051
- if (normalizedInput === 'l' && lastCommandRef.current) {
321
+ if (ctrlCombo('l') && lastCommandRef.current) {
1052
322
  runProjectCommand(lastCommandRef.current.commandMeta, lastCommandRef.current.project);
1053
323
  return;
1054
324
  }
@@ -1068,7 +338,7 @@ function Compass({rootPath}) {
1068
338
  setViewMode((prev) => (prev === 'detail' ? 'list' : 'detail'));
1069
339
  return;
1070
340
  }
1071
- if (normalizedInput === 'q') {
341
+ if (ctrlCombo('q')) {
1072
342
  exit();
1073
343
  return;
1074
344
  }
@@ -1149,6 +419,11 @@ const projectRows = [];
1149
419
  if (!detailedIndexed.length) {
1150
420
  detailContent.push(create(Text, {dimColor: true}, 'No built-in commands yet. Add a custom command with C.'));
1151
421
  }
422
+ const setupHints = selectedProject.extra?.setupHints || [];
423
+ if (setupHints.length) {
424
+ detailContent.push(create(Text, {dimColor: true, marginTop: 1}, 'Setup hints:'));
425
+ setupHints.forEach((hint) => detailContent.push(create(Text, {dimColor: true}, ` • ${hint}`)));
426
+ }
1152
427
  detailContent.push(create(Text, {dimColor: true}, 'Press C → label|cmd to save custom actions, Enter to close detail view.'));
1153
428
  } else {
1154
429
  detailContent.push(create(Text, {dimColor: true}, 'Press Enter on a project to reveal details (icons, commands, frameworks, custom actions).'));
@@ -1250,19 +525,19 @@ const projectRows = [];
1250
525
  color: 'magenta',
1251
526
  body: [
1252
527
  '↑ / ↓ move the project focus',
1253
- 'Enter toggles detail view',
1254
- 'Shift + ↑ / ↓ scrolls the log buffer',
1255
- 'H toggles cards, ? opens the overlay'
528
+ 'Enter opens or closes details',
529
+ 'Shift + ↑ / ↓ scrolls only the log buffer',
530
+ 'Ctrl+H toggles cards, ? opens the overlay'
1256
531
  ]
1257
532
  },
1258
533
  {
1259
534
  label: 'Command flow',
1260
535
  color: 'cyan',
1261
536
  body: [
1262
- 'B / T / R run build / test / run',
1263
- '1-9 execute the numbered detail commands',
1264
- 'L reruns the last command',
1265
- 'Ctrl+C aborts, typing feeds stdin (buffer mirrors text)'
537
+ 'B / T / R trigger build/test/run',
538
+ '1-9 execute detail commands in order',
539
+ 'Ctrl+L reruns the last command you launched',
540
+ 'Ctrl+C aborts, typing feeds stdin (buffer mirrors it)'
1266
541
  ]
1267
542
  },
1268
543
  {
@@ -1270,12 +545,11 @@ const projectRows = [];
1270
545
  color: 'yellow',
1271
546
  body: [
1272
547
  recentRuns.length ? `${recentRuns.length} runs recorded` : 'No runs yet · start with B/T/R',
1273
- 'Press S to show the structure guide',
548
+ 'Ctrl+S shows the structure guide when unsure',
1274
549
  'Save custom commands with C → label|cmd'
1275
550
  ]
1276
551
  }
1277
552
  ];
1278
-
1279
553
  const helpSection = showHelpCards
1280
554
  ? create(
1281
555
  Box,
@@ -1333,17 +607,17 @@ const projectRows = [];
1333
607
  },
1334
608
  create(Text, {color: 'cyan', bold: true}, 'Help overlay · press ? to hide'),
1335
609
  create(Text, null, 'Shift+arrows scroll the output buffer.'),
1336
- create(Text, null, 'Run commands and type to feed stdin (Enter submits, Ctrl+C aborts); the buffer shows what you type.'),
1337
- create(Text, null, 'H hides/shows the navigation cards, S shows structure tips, and L reruns the previous command.'),
610
+ create(Text, null, 'Run commands and type to feed stdin (buffer mirrors your keystrokes, Enter submits, Ctrl+C aborts).'),
611
+ create(Text, null, 'Ctrl+H toggles the navigation cards, Ctrl+S shows structure tips, Ctrl+L reruns the previous command, Ctrl+Q quits.'),
1338
612
  create(Text, null, 'Projects + Details stay side-by-side while Output and the log buffer stay fixed in their own band.'),
1339
- create(Text, null, 'Structure guide lists the manifests that trigger each language detection (press S to toggle).')
613
+ create(Text, null, 'Structure guide lists the manifests that trigger each language detection (press Ctrl+S to toggle).')
1340
614
  )
1341
615
  : null;
1342
616
 
1343
- const toggleHint = showHelpCards ? 'H hides the help cards' : 'H shows the help cards';
617
+ const toggleHint = showHelpCards ? 'Ctrl+H hides the help cards' : 'Ctrl+H shows the help cards';
1344
618
  const headerHint = viewMode === 'detail'
1345
- ? `Detail mode · 1-${Math.max(detailedIndexed.length, 1)} to execute, C: add custom commands, Enter: back to list, q: quit · ${toggleHint}, S shows structure guide`
1346
- : `Quick run · B/T/R to build/test/run, Enter: view details, q: quit · ${toggleHint}, S shows structure guide`;
619
+ ? `Detail mode · 1-${Math.max(detailedIndexed.length, 1)} to execute, C: add custom commands, Enter: back to list, Ctrl+Q: quit · ${toggleHint}, Ctrl+S shows structure guide`
620
+ : `Quick run · B/T/R to build/test/run, Enter: view details, Ctrl+Q: quit · ${toggleHint}, Ctrl+S shows structure guide`;
1347
621
 
1348
622
  return create(
1349
623
  Box,