project-compass 1.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +72 -0
  3. package/package.json +29 -0
  4. package/src/cli.js +1170 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Satyaa & Clawdy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # Project Compass
2
+
3
+ Project Compass is a futuristic CLI navigator built with [Ink](https://github.com/vadimdemedes/ink) that scans your current folder tree for familiar code projects and gives you one keystroke access to build, test, or run them.
4
+
5
+ ## Highlights
6
+
7
+ - 🔍 Scans directories for Node.js, Python, Rust, Go, Java, and Scala projects by looking at their manifest files.
8
+ - ✨ Presents a modern Ink dashboard with an interactive project list, icons, and live stdout/stderr logs.
9
+ - 🚀 Press **Enter** on any project to open the detail view, where you can inspect the type, manifest, frameworks, commands, and save custom actions.
10
+ - 🎯 Built-in shortcuts (B/T/R) run the canonical build/test/run workflow, while numeric hotkeys (1, 2, 3...) execute whichever command is listed in the detail view.
11
+ - 🧠 Add bespoke commands via **C** in detail view and store them globally (`~/.project-compass/config.json`) so every workspace remembers your favorite invocations.
12
+ - 🔌 Extend detection via plugins (JSON specs under `~/.project-compass/plugins.json`) to teach Project Compass about extra frameworks or command sets.
13
+ - 📦 Install globally and invoke `project-compass` from any folder to activate the UI instantly.
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ npm install -g project-compass
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ ```bash
24
+ project-compass [--dir /path/to/workspace]
25
+ ```
26
+
27
+ ### Keyboard guide
28
+
29
+ | Key | Action |
30
+ | --- | --- |
31
+ | ↑ / ↓ | Navigate the project list |
32
+ | Enter | Toggle the detail view with icons, commands, frameworks, and info |
33
+ | B / T / R | Quick build / test / run actions (when available) |
34
+ | 1‑9 | Execute the numbered command inside the detail view |
35
+ | C | Add a custom command (`label|cmd`) that saves to `~/.project-compass/config.json` |
36
+ | Q | Quit |
37
+
38
+ ## Framework & plugin support
39
+
40
+ Project Compass detects a wide range of modern stacks—**Next.js**, **React**, **Vue**, **NestJS**, **Angular**, **SvelteKit**, **Nuxt**, **Astro**, **Django**, **Flask**, **FastAPI**, and **Spring Boot**—and shows their badges in the detail view. When a framework is recognized, it injects framework-specific build/run/test commands (e.g., Next dev/build, Django runserver/test, Spring Boot run/test).
41
+
42
+ You can teach it new frameworks by adding a `plugins.json` file in your config directory (`~/.project-compass/plugins.json`). Each entry can declare the languages, files, dependencies, and commands that identify the framework. A sample plugin entry looks like this:
43
+
44
+ ```json
45
+ {
46
+ "plugins": [
47
+ {
48
+ "name": "Remix",
49
+ "languages": ["Node.js"],
50
+ "files": ["remix.config.js"],
51
+ "dependencies": ["@remix-run/node"],
52
+ "commands": {
53
+ "run": "npm run dev",
54
+ "build": "npm run build"
55
+ }
56
+ }
57
+ ]
58
+ }
59
+ ```
60
+
61
+ Each command value can be a string or an array of tokens. When a plugin matches a project, its commands appear in the detail view with a `framework` badge, and the shortcut keys (B/T/R or numeric) can execute them.
62
+
63
+ ## Developer notes
64
+
65
+ - `npm start` launches the Ink UI in the current directory.
66
+ - `npm test` runs `node src/cli.js --mode test` to verify the scanner output.
67
+ - Extend support for more languages by editing `SCHEMAS` or add plugin definitions under `~/.project-compass/plugins.json`.
68
+ - Config lives at `~/.project-compass/config.json`. Drop custom commands there if you want to preseed them or share with teammates.
69
+
70
+ ## License
71
+
72
+ MIT © 2026 Satyaa & Clawdy
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "project-compass",
3
+ "version": "1.0.0",
4
+ "description": "Ink-based project explorer that detects local repos and lets you build/test/run them without memorizing commands.",
5
+ "main": "src/cli.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "project-compass": "src/cli.js"
9
+ },
10
+ "scripts": {
11
+ "start": "node src/cli.js",
12
+ "test": "node src/cli.js --mode test"
13
+ },
14
+ "keywords": [
15
+ "cli",
16
+ "ink",
17
+ "project",
18
+ "runner",
19
+ "dashboard"
20
+ ],
21
+ "author": "Satyaa & Clawdy",
22
+ "license": "MIT",
23
+ "dependencies": {
24
+ "execa": "^9.6.1",
25
+ "fast-glob": "^3.3.3",
26
+ "ink": "^6.6.0",
27
+ "kleur": "^4.1.5"
28
+ }
29
+ }
package/src/cli.js ADDED
@@ -0,0 +1,1170 @@
1
+ #!/usr/bin/env node
2
+ import React, {useCallback, useEffect, useMemo, useState} from 'react';
3
+ import {render, Box, Text, useApp, useInput} from 'ink';
4
+ import fastGlob from 'fast-glob';
5
+ import path from 'path';
6
+ import fs from 'fs';
7
+ import os from 'os';
8
+ import {fileURLToPath} from 'url';
9
+ import kleur from 'kleur';
10
+ import {execa} from 'execa';
11
+
12
+ const create = React.createElement;
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
+
15
+ const CONFIG_DIR = path.join(os.homedir(), '.project-compass');
16
+ const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
17
+ const PLUGIN_FILE = path.join(CONFIG_DIR, 'plugins.json');
18
+ const DEFAULT_CONFIG = {customCommands: {}};
19
+ const DEFAULT_PLUGIN_CONFIG = {plugins: []};
20
+
21
+ function ensureConfigDir() {
22
+ if (!fs.existsSync(CONFIG_DIR)) {
23
+ fs.mkdirSync(CONFIG_DIR, {recursive: true});
24
+ }
25
+ }
26
+
27
+ function saveConfig(config) {
28
+ try {
29
+ ensureConfigDir();
30
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
31
+ } catch (error) {
32
+ console.error(`Unable to persist config: ${error.message}`);
33
+ }
34
+ }
35
+
36
+ function loadConfig() {
37
+ try {
38
+ if (fs.existsSync(CONFIG_PATH)) {
39
+ const payload = fs.readFileSync(CONFIG_PATH, 'utf-8');
40
+ const parsed = JSON.parse(payload || '{}');
41
+ return {
42
+ ...DEFAULT_CONFIG,
43
+ ...parsed,
44
+ customCommands: {
45
+ ...DEFAULT_CONFIG.customCommands,
46
+ ...(parsed.customCommands || {})
47
+ }
48
+ };
49
+ }
50
+ } catch (error) {
51
+ console.error(`Ignoring corrupt config: ${error.message}`);
52
+ }
53
+ return {...DEFAULT_CONFIG};
54
+ }
55
+
56
+ function parseCommandTokens(value) {
57
+ if (Array.isArray(value)) {
58
+ return value.map((token) => String(token));
59
+ }
60
+ if (typeof value === 'string') {
61
+ return value.trim().split(/\s+/).filter(Boolean);
62
+ }
63
+ return [];
64
+ }
65
+
66
+ function resolveScriptCommand(project, scriptName, fallback = null) {
67
+ const scripts = project.metadata?.scripts || {};
68
+ if (Object.prototype.hasOwnProperty.call(scripts, scriptName)) {
69
+ return ['npm', 'run', scriptName];
70
+ }
71
+ if (typeof fallback === 'function') {
72
+ return fallback();
73
+ }
74
+ return fallback;
75
+ }
76
+
77
+ function gatherNodeDependencies(pkg) {
78
+ const deps = new Set();
79
+ ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'].forEach((key) => {
80
+ if (pkg[key]) {
81
+ Object.keys(pkg[key]).forEach((name) => deps.add(name));
82
+ }
83
+ });
84
+ return Array.from(deps);
85
+ }
86
+
87
+ function gatherPythonDependencies(projectPath) {
88
+ const set = new Set();
89
+ const addFromFile = (filePath) => {
90
+ if (!fs.existsSync(filePath)) {
91
+ return;
92
+ }
93
+ const raw = fs.readFileSync(filePath, 'utf-8');
94
+ raw.split(/\r?\n/).forEach((line) => {
95
+ const clean = line.trim().split('#')[0].trim();
96
+ if (clean) {
97
+ const token = clean.split(/[>=<=~!]/)[0].trim().toLowerCase();
98
+ if (token) {
99
+ set.add(token);
100
+ }
101
+ }
102
+ });
103
+ };
104
+ addFromFile(path.join(projectPath, 'requirements.txt'));
105
+ const pyproject = path.join(projectPath, 'pyproject.toml');
106
+ if (fs.existsSync(pyproject)) {
107
+ const content = fs.readFileSync(pyproject, 'utf-8').toLowerCase();
108
+ const matches = content.match(/\b[a-z0-9-_/]+\b/g);
109
+ (matches || []).forEach((match) => {
110
+ if (match) {
111
+ set.add(match);
112
+ }
113
+ });
114
+ }
115
+ return Array.from(set);
116
+ }
117
+
118
+ function dependencyMatches(project, needle) {
119
+ const dependencies = (project.metadata?.dependencies || []).map((dep) => dep.toLowerCase());
120
+ const target = needle.toLowerCase();
121
+ return dependencies.some((value) => value === target || value.startsWith(`${target}@`) || value.includes(`/${target}`));
122
+ }
123
+
124
+ function hasProjectFile(project, file) {
125
+ return fs.existsSync(path.join(project.path, file));
126
+ }
127
+
128
+
129
+ const builtInFrameworks = [
130
+ {
131
+ id: 'next',
132
+ name: 'Next.js',
133
+ icon: '🧭',
134
+ description: 'React + Next.js (SSR/SSG) apps',
135
+ languages: ['Node.js'],
136
+ priority: 115,
137
+ match(project) {
138
+ const hasNextConfig = fs.existsSync(path.join(project.path, 'next.config.js'));
139
+ return dependencyMatches(project, 'next') || hasNextConfig;
140
+ },
141
+ commands(project) {
142
+ const commands = {};
143
+ const add = (key, label, fallback) => {
144
+ const tokens = resolveScriptCommand(project, key, fallback);
145
+ if (tokens) {
146
+ commands[key] = {label, command: tokens, source: 'framework'};
147
+ }
148
+ };
149
+ const buildFallback = () => ['npx', 'next', 'build'];
150
+ const startFallback = () => ['npx', 'next', 'start'];
151
+ const devFallback = () => ['npx', 'next', 'dev'];
152
+ add('run', 'Next dev', devFallback);
153
+ add('build', 'Next build', buildFallback);
154
+ add('test', 'Next test', () => ['npm', 'run', 'test']);
155
+ add('start', 'Next start', startFallback);
156
+ return commands;
157
+ }
158
+ },
159
+ {
160
+ id: 'react',
161
+ name: 'React',
162
+ icon: '⚛️',
163
+ description: 'React apps (CRA, Vite React)',
164
+ languages: ['Node.js'],
165
+ priority: 112,
166
+ match(project) {
167
+ return dependencyMatches(project, 'react') && (dependencyMatches(project, 'react-scripts') || dependencyMatches(project, 'vite') || hasProjectFile(project, 'vite.config.js'));
168
+ },
169
+ commands(project) {
170
+ const commands = {};
171
+ const add = (key, label, fallback) => {
172
+ const tokens = resolveScriptCommand(project, key, fallback);
173
+ if (tokens) {
174
+ commands[key] = {label, command: tokens, source: 'framework'};
175
+ }
176
+ };
177
+ add('run', 'React dev', () => ['npm', 'run', 'dev']);
178
+ add('build', 'React build', () => ['npm', 'run', 'build']);
179
+ add('test', 'React test', () => ['npm', 'run', 'test']);
180
+ return commands;
181
+ }
182
+ },
183
+ {
184
+ id: 'vue',
185
+ name: 'Vue.js',
186
+ icon: '🟩',
187
+ description: 'Vue CLI or Vite + Vue apps',
188
+ languages: ['Node.js'],
189
+ priority: 111,
190
+ match(project) {
191
+ return dependencyMatches(project, 'vue') && (hasProjectFile(project, 'vue.config.js') || dependencyMatches(project, '@vue/cli-service') || dependencyMatches(project, 'vite'));
192
+ },
193
+ commands(project) {
194
+ const commands = {};
195
+ const add = (key, label, fallback) => {
196
+ const tokens = resolveScriptCommand(project, key, fallback);
197
+ if (tokens) {
198
+ commands[key] = {label, command: tokens, source: 'framework'};
199
+ }
200
+ };
201
+ add('run', 'Vue dev', () => ['npm', 'run', 'dev']);
202
+ add('build', 'Vue build', () => ['npm', 'run', 'build']);
203
+ add('test', 'Vue test', () => ['npm', 'run', 'test']);
204
+ return commands;
205
+ }
206
+ },
207
+ {
208
+ id: 'nest',
209
+ name: 'NestJS',
210
+ icon: '🛡️',
211
+ description: 'NestJS backend',
212
+ languages: ['Node.js'],
213
+ priority: 110,
214
+ match(project) {
215
+ return dependencyMatches(project, '@nestjs/cli') || dependencyMatches(project, '@nestjs/core');
216
+ },
217
+ commands(project) {
218
+ const commands = {};
219
+ const add = (key, label, fallback) => {
220
+ const tokens = resolveScriptCommand(project, key, fallback);
221
+ if (tokens) {
222
+ commands[key] = {label, command: tokens, source: 'framework'};
223
+ }
224
+ };
225
+ add('run', 'Nest dev', () => ['npm', 'run', 'start:dev']);
226
+ add('build', 'Nest build', () => ['npm', 'run', 'build']);
227
+ add('test', 'Nest test', () => ['npm', 'run', 'test']);
228
+ return commands;
229
+ }
230
+ },
231
+ {
232
+ id: 'angular',
233
+ name: 'Angular',
234
+ icon: '🅰️',
235
+ description: 'Angular CLI projects',
236
+ languages: ['Node.js'],
237
+ priority: 109,
238
+ match(project) {
239
+ return hasProjectFile(project, 'angular.json') || dependencyMatches(project, '@angular/cli');
240
+ },
241
+ commands(project) {
242
+ const commands = {};
243
+ const add = (key, label, fallback) => {
244
+ const tokens = resolveScriptCommand(project, key, fallback);
245
+ if (tokens) {
246
+ commands[key] = {label, command: tokens, source: 'framework'};
247
+ }
248
+ };
249
+ add('run', 'Angular serve', () => ['npm', 'run', 'start']);
250
+ add('build', 'Angular build', () => ['npm', 'run', 'build']);
251
+ add('test', 'Angular test', () => ['npm', 'run', 'test']);
252
+ return commands;
253
+ }
254
+ },
255
+ {
256
+ id: 'sveltekit',
257
+ name: 'SvelteKit',
258
+ icon: '🌀',
259
+ description: 'SvelteKit apps',
260
+ languages: ['Node.js'],
261
+ priority: 108,
262
+ match(project) {
263
+ return hasProjectFile(project, 'svelte.config.js') || dependencyMatches(project, '@sveltejs/kit');
264
+ },
265
+ commands(project) {
266
+ const commands = {};
267
+ const add = (key, label, fallback) => {
268
+ const tokens = resolveScriptCommand(project, key, fallback);
269
+ if (tokens) {
270
+ commands[key] = {label, command: tokens, source: 'framework'};
271
+ }
272
+ };
273
+ add('run', 'SvelteKit dev', () => ['npm', 'run', 'dev']);
274
+ add('build', 'SvelteKit build', () => ['npm', 'run', 'build']);
275
+ add('test', 'SvelteKit test', () => ['npm', 'run', 'test']);
276
+ add('preview', 'SvelteKit preview', () => ['npm', 'run', 'preview']);
277
+ return commands;
278
+ }
279
+ },
280
+ {
281
+ id: 'nuxt',
282
+ name: 'Nuxt',
283
+ icon: '🪄',
284
+ description: 'Nuxt.js / Vue SSR',
285
+ languages: ['Node.js'],
286
+ priority: 107,
287
+ match(project) {
288
+ return hasProjectFile(project, 'nuxt.config.js') || dependencyMatches(project, 'nuxt');
289
+ },
290
+ commands(project) {
291
+ const commands = {};
292
+ const add = (key, label, fallback) => {
293
+ const tokens = resolveScriptCommand(project, key, fallback);
294
+ if (tokens) {
295
+ commands[key] = {label, command: tokens, source: 'framework'};
296
+ }
297
+ };
298
+ add('run', 'Nuxt dev', () => ['npm', 'run', 'dev']);
299
+ add('build', 'Nuxt build', () => ['npm', 'run', 'build']);
300
+ add('start', 'Nuxt start', () => ['npm', 'run', 'start']);
301
+ return commands;
302
+ }
303
+ },
304
+ {
305
+ id: 'astro',
306
+ name: 'Astro',
307
+ icon: '✨',
308
+ description: 'Astro static sites',
309
+ languages: ['Node.js'],
310
+ priority: 106,
311
+ match(project) {
312
+ const matches = ['astro.config.mjs', 'astro.config.ts'].some((file) => hasProjectFile(project, file));
313
+ return matches || dependencyMatches(project, 'astro');
314
+ },
315
+ commands(project) {
316
+ const commands = {};
317
+ const add = (key, label, fallback) => {
318
+ const tokens = resolveScriptCommand(project, key, fallback);
319
+ if (tokens) {
320
+ commands[key] = {label, command: tokens, source: 'framework'};
321
+ }
322
+ };
323
+ add('run', 'Astro dev', () => ['npm', 'run', 'dev']);
324
+ add('build', 'Astro build', () => ['npm', 'run', 'build']);
325
+ add('preview', 'Astro preview', () => ['npm', 'run', 'preview']);
326
+ return commands;
327
+ }
328
+ },
329
+ {
330
+ id: 'django',
331
+ name: 'Django',
332
+ icon: '🌿',
333
+ description: 'Django web application',
334
+ languages: ['Python'],
335
+ priority: 110,
336
+ match(project) {
337
+ return dependencyMatches(project, 'django') || hasProjectFile(project, 'manage.py');
338
+ },
339
+ commands(project) {
340
+ const managePath = path.join(project.path, 'manage.py');
341
+ if (!fs.existsSync(managePath)) {
342
+ return {};
343
+ }
344
+ return {
345
+ run: {label: 'Django runserver', command: ['python', 'manage.py', 'runserver'], source: 'framework'},
346
+ test: {label: 'Django test', command: ['python', 'manage.py', 'test'], source: 'framework'},
347
+ migrate: {label: 'Django migrate', command: ['python', 'manage.py', 'migrate'], source: 'framework'}
348
+ };
349
+ }
350
+ },
351
+ {
352
+ id: 'flask',
353
+ name: 'Flask',
354
+ icon: '🍶',
355
+ description: 'Flask microservices',
356
+ languages: ['Python'],
357
+ priority: 105,
358
+ match(project) {
359
+ return dependencyMatches(project, 'flask') || hasProjectFile(project, 'app.py');
360
+ },
361
+ commands(project) {
362
+ const commands = {};
363
+ const entry = hasProjectFile(project, 'app.py') ? 'app.py' : 'main.py';
364
+ commands.run = {label: 'Flask app', command: ['python', entry], source: 'framework'};
365
+ commands.test = {label: 'Pytest', command: ['pytest'], source: 'framework'};
366
+ return commands;
367
+ }
368
+ },
369
+ {
370
+ id: 'fastapi',
371
+ name: 'FastAPI',
372
+ icon: '⚡',
373
+ description: 'FastAPI + Uvicorn',
374
+ languages: ['Python'],
375
+ priority: 105,
376
+ match(project) {
377
+ return dependencyMatches(project, 'fastapi');
378
+ },
379
+ commands(project) {
380
+ const entry = hasProjectFile(project, 'main.py') ? 'main.py' : 'app.py';
381
+ return {
382
+ run: {label: 'Uvicorn reload', command: ['uvicorn', `${entry.split('.')[0]}:app`, '--reload'], source: 'framework'},
383
+ test: {label: 'Pytest', command: ['pytest'], source: 'framework'}
384
+ };
385
+ }
386
+ },
387
+ {
388
+ id: 'spring',
389
+ name: 'Spring Boot',
390
+ icon: '🌱',
391
+ description: 'Spring Boot apps',
392
+ languages: ['Java'],
393
+ priority: 105,
394
+ match(project) {
395
+ return dependencyMatches(project, 'spring-boot-starter') || hasProjectFile(project, 'src/main/java');
396
+ },
397
+ commands(project) {
398
+ const hasMvnw = fs.existsSync(path.join(project.path, 'mvnw'));
399
+ const base = hasMvnw ? './mvnw' : 'mvn';
400
+ return {
401
+ run: {label: 'Spring Boot run', command: [base, 'spring-boot:run'], source: 'framework'},
402
+ build: {label: 'Maven package', command: [base, 'package'], source: 'framework'},
403
+ test: {label: 'Maven test', command: [base, 'test'], source: 'framework'}
404
+ };
405
+ }
406
+ }
407
+ ];
408
+ function loadUserFrameworks() {
409
+ ensureConfigDir();
410
+ try {
411
+ if (!fs.existsSync(PLUGIN_FILE)) {
412
+ return [];
413
+ }
414
+ const payload = JSON.parse(fs.readFileSync(PLUGIN_FILE, 'utf-8') || '{}');
415
+ const plugins = payload.plugins || [];
416
+ return plugins.map((entry) => {
417
+ const normalizedId = entry.id || (entry.name ? entry.name.toLowerCase().replace(/\s+/g, '-') : `plugin-${Math.random().toString(36).slice(2, 8)}`);
418
+ const commands = {};
419
+ Object.entries(entry.commands || {}).forEach(([key, value]) => {
420
+ const command = parseCommandTokens(typeof value === 'object' ? value.command : value);
421
+ if (!command.length) {
422
+ return;
423
+ }
424
+ commands[key] = {
425
+ label: typeof value === 'object' ? value.label || key : key,
426
+ command,
427
+ source: 'plugin'
428
+ };
429
+ });
430
+ return {
431
+ id: normalizedId,
432
+ name: entry.name || normalizedId,
433
+ icon: entry.icon || '🧩',
434
+ description: entry.description || '',
435
+ languages: entry.languages || [],
436
+ files: entry.files || [],
437
+ dependencies: entry.dependencies || [],
438
+ scripts: entry.scripts || [],
439
+ priority: Number.isFinite(entry.priority) ? entry.priority : 70,
440
+ commands,
441
+ match: entry.match
442
+ };
443
+ })
444
+ .filter((plugin) => plugin.name && plugin.commands && Object.keys(plugin.commands).length);
445
+ } catch (error) {
446
+ console.error(`Failed to parse plugins.json: ${error.message}`);
447
+ return [];
448
+ }
449
+ }
450
+
451
+ let cachedFrameworkPlugins = null;
452
+
453
+ function getFrameworkPlugins() {
454
+ if (cachedFrameworkPlugins) {
455
+ return cachedFrameworkPlugins;
456
+ }
457
+ cachedFrameworkPlugins = [...builtInFrameworks, ...loadUserFrameworks()];
458
+ return cachedFrameworkPlugins;
459
+ }
460
+
461
+ function matchesPlugin(project, plugin) {
462
+ if (plugin.languages && plugin.languages.length > 0 && !plugin.languages.includes(project.type)) {
463
+ return false;
464
+ }
465
+ if (plugin.files && plugin.files.length > 0) {
466
+ const hit = plugin.files.some((file) => fs.existsSync(path.join(project.path, file)));
467
+ if (!hit) {
468
+ return false;
469
+ }
470
+ }
471
+ if (plugin.dependencies && plugin.dependencies.length > 0) {
472
+ const hit = plugin.dependencies.some((dep) => dependencyMatches(project, dep));
473
+ if (!hit) {
474
+ return false;
475
+ }
476
+ }
477
+ if (plugin.scripts && plugin.scripts.length > 0) {
478
+ const scripts = project.metadata?.scripts || {};
479
+ const hit = plugin.scripts.some((name) => Object.prototype.hasOwnProperty.call(scripts, name));
480
+ if (!hit) {
481
+ return false;
482
+ }
483
+ }
484
+ if (typeof plugin.match === 'function') {
485
+ if (!plugin.match(project)) {
486
+ return false;
487
+ }
488
+ }
489
+ return true;
490
+ }
491
+
492
+ function applyFrameworkPlugins(project) {
493
+ const plugins = getFrameworkPlugins();
494
+ let commands = {...project.commands};
495
+ const frameworks = [];
496
+ let maxPriority = project.priority || 0;
497
+ for (const plugin of plugins) {
498
+ if (!matchesPlugin(project, plugin)) {
499
+ continue;
500
+ }
501
+ frameworks.push({id: plugin.id, name: plugin.name, icon: plugin.icon, description: plugin.description});
502
+ if (plugin.priority && plugin.priority > maxPriority) {
503
+ maxPriority = plugin.priority;
504
+ }
505
+ const pluginCommands = typeof plugin.commands === 'function' ? plugin.commands(project) : plugin.commands;
506
+ if (pluginCommands) {
507
+ Object.entries(pluginCommands).forEach(([key, command]) => {
508
+ if (!Array.isArray(command.command) || command.command.length === 0) {
509
+ return;
510
+ }
511
+ commands = {
512
+ ...commands,
513
+ [key]: {
514
+ ...command,
515
+ source: command.source || 'framework'
516
+ }
517
+ };
518
+ });
519
+ }
520
+ }
521
+ return {...project, commands, frameworks, priority: maxPriority};
522
+ }
523
+
524
+ const IGNORE_PATTERNS = ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**', '**/target/**'];
525
+
526
+ const SCHEMAS = [
527
+ {
528
+ type: 'node',
529
+ label: 'Node.js',
530
+ icon: '🟢',
531
+ priority: 90,
532
+ files: ['package.json'],
533
+ async build(projectPath, manifest) {
534
+ const pkgPath = path.join(projectPath, 'package.json');
535
+ if (!fs.existsSync(pkgPath)) {
536
+ return null;
537
+ }
538
+ const content = await fs.promises.readFile(pkgPath, 'utf-8');
539
+ const pkg = JSON.parse(content);
540
+ const scripts = pkg.scripts || {};
541
+ const commands = {};
542
+ const preferScript = (targetKey, names, label) => {
543
+ for (const name of names) {
544
+ if (Object.prototype.hasOwnProperty.call(scripts, name)) {
545
+ commands[targetKey] = {label, command: ['npm', 'run', name]};
546
+ break;
547
+ }
548
+ }
549
+ };
550
+ preferScript('build', ['build', 'compile', 'dist'], 'Build');
551
+ preferScript('test', ['test', 'check', 'spec'], 'Test');
552
+ preferScript('run', ['start', 'dev', 'serve', 'run'], 'Start');
553
+
554
+ const metadata = {
555
+ dependencies: gatherNodeDependencies(pkg),
556
+ scripts,
557
+ packageJson: pkg
558
+ };
559
+
560
+ return {
561
+ id: `${projectPath}::node`,
562
+ path: projectPath,
563
+ name: pkg.name || path.basename(projectPath),
564
+ type: 'Node.js',
565
+ icon: '🟢',
566
+ priority: this.priority,
567
+ commands,
568
+ metadata,
569
+ manifest: path.basename(manifest),
570
+ description: pkg.description || '',
571
+ extra: {
572
+ scripts: Object.keys(scripts)
573
+ }
574
+ };
575
+ }
576
+ },
577
+ {
578
+ type: 'python',
579
+ label: 'Python',
580
+ icon: '🐍',
581
+ priority: 80,
582
+ files: ['pyproject.toml', 'requirements.txt', 'setup.py'],
583
+ async build(projectPath, manifest) {
584
+ const commands = {};
585
+ if (fs.existsSync(path.join(projectPath, 'pyproject.toml'))) {
586
+ commands.test = {label: 'Pytest', command: ['pytest']};
587
+ } else {
588
+ commands.test = {label: 'Unittest', command: ['python', '-m', 'unittest', 'discover']};
589
+ }
590
+
591
+ const entry = await findPythonEntry(projectPath);
592
+ if (entry) {
593
+ commands.run = {label: 'Run', command: ['python', entry]};
594
+ }
595
+
596
+ const metadata = {
597
+ dependencies: gatherPythonDependencies(projectPath)
598
+ };
599
+
600
+ return {
601
+ id: `${projectPath}::python`,
602
+ path: projectPath,
603
+ name: path.basename(projectPath),
604
+ type: 'Python',
605
+ icon: '🐍',
606
+ priority: this.priority,
607
+ commands,
608
+ metadata,
609
+ manifest: path.basename(manifest),
610
+ description: '',
611
+ extra: {
612
+ entry
613
+ }
614
+ };
615
+ }
616
+ },
617
+ {
618
+ type: 'rust',
619
+ label: 'Rust',
620
+ icon: '🦀',
621
+ priority: 85,
622
+ files: ['Cargo.toml'],
623
+ async build(projectPath, manifest) {
624
+ return {
625
+ id: `${projectPath}::rust`,
626
+ path: projectPath,
627
+ name: path.basename(projectPath),
628
+ type: 'Rust',
629
+ icon: '🦀',
630
+ priority: this.priority,
631
+ commands: {
632
+ build: {label: 'Cargo build', command: ['cargo', 'build']},
633
+ test: {label: 'Cargo test', command: ['cargo', 'test']},
634
+ run: {label: 'Cargo run', command: ['cargo', 'run']}
635
+ },
636
+ metadata: {},
637
+ manifest: path.basename(manifest),
638
+ description: '',
639
+ extra: {}
640
+ };
641
+ }
642
+ },
643
+ {
644
+ type: 'go',
645
+ label: 'Go',
646
+ icon: '🐹',
647
+ priority: 80,
648
+ files: ['go.mod'],
649
+ async build(projectPath, manifest) {
650
+ return {
651
+ id: `${projectPath}::go`,
652
+ path: projectPath,
653
+ name: path.basename(projectPath),
654
+ type: 'Go',
655
+ icon: '🐹',
656
+ priority: this.priority,
657
+ commands: {
658
+ build: {label: 'Go build', command: ['go', 'build', './...']},
659
+ test: {label: 'Go test', command: ['go', 'test', './...']},
660
+ run: {label: 'Go run', command: ['go', 'run', '.']}
661
+ },
662
+ metadata: {},
663
+ manifest: path.basename(manifest),
664
+ description: '',
665
+ extra: {}
666
+ };
667
+ }
668
+ },
669
+ {
670
+ type: 'java',
671
+ label: 'Java',
672
+ icon: '☕️',
673
+ priority: 75,
674
+ files: ['pom.xml', 'build.gradle', 'build.gradle.kts'],
675
+ async build(projectPath, manifest) {
676
+ const hasMvnw = fs.existsSync(path.join(projectPath, 'mvnw'));
677
+ const hasGradlew = fs.existsSync(path.join(projectPath, 'gradlew'));
678
+ const commands = {};
679
+ if (hasGradlew) {
680
+ commands.build = {label: 'Gradle build', command: ['./gradlew', 'build']};
681
+ commands.test = {label: 'Gradle test', command: ['./gradlew', 'test']};
682
+ } else if (hasMvnw) {
683
+ commands.build = {label: 'Maven package', command: ['./mvnw', 'package']};
684
+ commands.test = {label: 'Maven test', command: ['./mvnw', 'test']};
685
+ } else {
686
+ commands.build = {label: 'Maven package', command: ['mvn', 'package']};
687
+ commands.test = {label: 'Maven test', command: ['mvn', 'test']};
688
+ }
689
+
690
+ return {
691
+ id: `${projectPath}::java`,
692
+ path: projectPath,
693
+ name: path.basename(projectPath),
694
+ type: 'Java',
695
+ icon: '☕️',
696
+ priority: this.priority,
697
+ commands,
698
+ metadata: {},
699
+ manifest: path.basename(manifest),
700
+ description: '',
701
+ extra: {}
702
+ };
703
+ }
704
+ },
705
+ {
706
+ type: 'scala',
707
+ label: 'Scala',
708
+ icon: '🔵',
709
+ priority: 70,
710
+ files: ['build.sbt'],
711
+ async build(projectPath, manifest) {
712
+ return {
713
+ id: `${projectPath}::scala`,
714
+ path: projectPath,
715
+ name: path.basename(projectPath),
716
+ type: 'Scala',
717
+ icon: '🔵',
718
+ priority: this.priority,
719
+ commands: {
720
+ build: {label: 'sbt compile', command: ['sbt', 'compile']},
721
+ test: {label: 'sbt test', command: ['sbt', 'test']},
722
+ run: {label: 'sbt run', command: ['sbt', 'run']}
723
+ },
724
+ metadata: {},
725
+ manifest: path.basename(manifest),
726
+ description: '',
727
+ extra: {}
728
+ };
729
+ }
730
+ }
731
+ ];
732
+
733
+ async function findPythonEntry(projectPath) {
734
+ const candidates = ['main.py', 'app.py', 'src/main.py', 'src/app.py'];
735
+ for (const candidate of candidates) {
736
+ const candidatePath = path.join(projectPath, candidate);
737
+ if (fs.existsSync(candidatePath)) {
738
+ return candidate;
739
+ }
740
+ }
741
+ return null;
742
+ }
743
+
744
+ async function discoverProjects(root) {
745
+ const projectMap = new Map();
746
+ for (const schema of SCHEMAS) {
747
+ const patterns = schema.files.map((file) => `**/${file}`);
748
+ const matches = await fastGlob(patterns, {
749
+ cwd: root,
750
+ ignore: IGNORE_PATTERNS,
751
+ onlyFiles: true,
752
+ deep: 5
753
+ });
754
+
755
+ for (const match of matches) {
756
+ const projectDir = path.resolve(root, path.dirname(match));
757
+ const existing = projectMap.get(projectDir);
758
+ if (existing && existing.priority >= schema.priority) {
759
+ continue;
760
+ }
761
+ const entry = await schema.build(projectDir, match);
762
+ if (!entry) {
763
+ continue;
764
+ }
765
+ const withFrameworks = applyFrameworkPlugins(entry);
766
+ projectMap.set(projectDir, withFrameworks);
767
+ }
768
+ }
769
+ return Array.from(projectMap.values()).sort((a, b) => b.priority - a.priority);
770
+ }
771
+
772
+ const ACTION_MAP = {
773
+ b: 'build',
774
+ t: 'test',
775
+ r: 'run'
776
+ };
777
+
778
+ function useScanner(rootPath) {
779
+ const [state, setState] = useState({projects: [], loading: true, error: null});
780
+
781
+ useEffect(() => {
782
+ let cancelled = false;
783
+ (async () => {
784
+ try {
785
+ const projects = await discoverProjects(rootPath);
786
+ if (!cancelled) {
787
+ setState({projects, loading: false, error: null});
788
+ }
789
+ } catch (error) {
790
+ if (!cancelled) {
791
+ setState({projects: [], loading: false, error: error.message});
792
+ }
793
+ }
794
+ })();
795
+ return () => {
796
+ cancelled = true;
797
+ };
798
+ }, [rootPath]);
799
+
800
+ return state;
801
+ }
802
+
803
+ function buildDetailCommands(project, config) {
804
+ if (!project) {
805
+ return [];
806
+ }
807
+ const builtins = Object.entries(project.commands || {}).map(([key, command]) => ({
808
+ label: command.label || key,
809
+ command: command.command,
810
+ source: command.source || 'builtin'
811
+ }));
812
+ const custom = (config.customCommands?.[project.path] || []).map((entry) => ({
813
+ label: entry.label,
814
+ command: entry.command,
815
+ source: 'custom'
816
+ }));
817
+ return [...builtins, ...custom];
818
+ }
819
+
820
+ function Compass({rootPath}) {
821
+ const {exit} = useApp();
822
+ const {projects, loading, error} = useScanner(rootPath);
823
+ const [selectedIndex, setSelectedIndex] = useState(0);
824
+ const [viewMode, setViewMode] = useState('list');
825
+ const [logLines, setLogLines] = useState([]);
826
+ const [running, setRunning] = useState(false);
827
+ const [lastAction, setLastAction] = useState(null);
828
+ const [customMode, setCustomMode] = useState(false);
829
+ const [customInput, setCustomInput] = useState('');
830
+ const [config, setConfig] = useState(() => loadConfig());
831
+
832
+ const selectedProject = projects[selectedIndex] || null;
833
+
834
+ const addLog = useCallback((line) => {
835
+ setLogLines((prev) => [...prev.slice(-200), typeof line === 'string' ? line : JSON.stringify(line)]);
836
+ }, []);
837
+
838
+ const detailCommands = useMemo(() => buildDetailCommands(selectedProject, config), [selectedProject, config]);
839
+ const detailedIndexed = useMemo(() => detailCommands.map((command, index) => ({
840
+ ...command,
841
+ shortcut: `${index + 1}`
842
+ })), [detailCommands]);
843
+ const detailShortcutMap = useMemo(() => {
844
+ const map = new Map();
845
+ detailedIndexed.forEach((cmd) => map.set(cmd.shortcut, cmd));
846
+ return map;
847
+ }, [detailedIndexed]);
848
+
849
+ const runProjectCommand = useCallback(async (commandMeta) => {
850
+ if (!selectedProject) {
851
+ return;
852
+ }
853
+ if (!commandMeta || !Array.isArray(commandMeta.command) || commandMeta.command.length === 0) {
854
+ addLog(kleur.gray('(no command configured)'));
855
+ return;
856
+ }
857
+ if (running) {
858
+ addLog(kleur.yellow('→ Wait for the current task to finish.'));
859
+ return;
860
+ }
861
+
862
+ setRunning(true);
863
+ setLastAction(`${selectedProject.name} · ${commandMeta.label}`);
864
+ const fullCmd = commandMeta.command;
865
+ addLog(kleur.cyan(`> ${fullCmd.join(' ')}`));
866
+
867
+ try {
868
+ const subprocess = execa(fullCmd[0], fullCmd.slice(1), {
869
+ cwd: selectedProject.path,
870
+ env: process.env
871
+ });
872
+
873
+ subprocess.stdout?.on('data', (chunk) => {
874
+ addLog(chunk.toString().trimEnd());
875
+ });
876
+ subprocess.stderr?.on('data', (chunk) => {
877
+ addLog(kleur.red(chunk.toString().trimEnd()));
878
+ });
879
+
880
+ await subprocess;
881
+ addLog(kleur.green(`✓ ${commandMeta.label} finished`));
882
+ } catch (error) {
883
+ addLog(kleur.red(`✗ ${commandMeta.label} failed: ${error.shortMessage || error.message}`));
884
+ } finally {
885
+ setRunning(false);
886
+ }
887
+ }, [selectedProject, addLog, running]);
888
+
889
+ const handleAddCustomCommand = useCallback((label, commandTokens) => {
890
+ if (!selectedProject) {
891
+ return;
892
+ }
893
+ setConfig((prev) => {
894
+ const projectKey = selectedProject.path;
895
+ const existing = prev.customCommands?.[projectKey] || [];
896
+ const nextCustom = [...existing, {label, command: commandTokens}];
897
+ const nextConfig = {
898
+ ...prev,
899
+ customCommands: {
900
+ ...prev.customCommands,
901
+ [projectKey]: nextCustom
902
+ }
903
+ };
904
+ saveConfig(nextConfig);
905
+ return nextConfig;
906
+ });
907
+ addLog(kleur.yellow(`Saved custom command "${label}" for ${selectedProject.name}`));
908
+ }, [selectedProject, addLog]);
909
+
910
+ const handleCustomSubmit = useCallback(() => {
911
+ const raw = customInput.trim();
912
+ if (!selectedProject) {
913
+ setCustomMode(false);
914
+ return;
915
+ }
916
+ if (!raw) {
917
+ addLog(kleur.gray('Canceled custom command (empty).'));
918
+ setCustomMode(false);
919
+ setCustomInput('');
920
+ return;
921
+ }
922
+ const [labelPart, commandPart] = raw.split('|');
923
+ const commandTokens = (commandPart || labelPart).trim().split(/\s+/).filter(Boolean);
924
+ if (!commandTokens.length) {
925
+ addLog(kleur.red('Custom command needs at least one token.'));
926
+ setCustomMode(false);
927
+ setCustomInput('');
928
+ return;
929
+ }
930
+ const label = commandPart ? labelPart.trim() : `Custom ${selectedProject.name}`;
931
+ handleAddCustomCommand(label || 'Custom', commandTokens);
932
+ setCustomMode(false);
933
+ setCustomInput('');
934
+ }, [customInput, selectedProject, handleAddCustomCommand, addLog]);
935
+
936
+ useInput((input, key) => {
937
+ if (customMode) {
938
+ if (key.return) {
939
+ handleCustomSubmit();
940
+ return;
941
+ }
942
+ if (key.escape) {
943
+ setCustomMode(false);
944
+ setCustomInput('');
945
+ return;
946
+ }
947
+ if (key.backspace) {
948
+ setCustomInput((prev) => prev.slice(0, -1));
949
+ return;
950
+ }
951
+ if (input) {
952
+ setCustomInput((prev) => prev + input);
953
+ }
954
+ return;
955
+ }
956
+
957
+ if (key.upArrow && projects.length > 0) {
958
+ setSelectedIndex((prev) => (prev - 1 + projects.length) % projects.length);
959
+ return;
960
+ }
961
+ if (key.downArrow && projects.length > 0) {
962
+ setSelectedIndex((prev) => (prev + 1) % projects.length);
963
+ return;
964
+ }
965
+ if (key.return) {
966
+ if (!selectedProject) {
967
+ return;
968
+ }
969
+ setViewMode((prev) => (prev === 'detail' ? 'list' : 'detail'));
970
+ return;
971
+ }
972
+ if (input === 'q') {
973
+ exit();
974
+ return;
975
+ }
976
+ if (input === 'c' && viewMode === 'detail' && selectedProject) {
977
+ setCustomMode(true);
978
+ setCustomInput('');
979
+ return;
980
+ }
981
+ if (ACTION_MAP[input]) {
982
+ const commandMeta = selectedProject?.commands?.[ACTION_MAP[input]];
983
+ runProjectCommand(commandMeta);
984
+ return;
985
+ }
986
+ if (viewMode === 'detail' && detailShortcutMap.has(input)) {
987
+ runProjectCommand(detailShortcutMap.get(input));
988
+ }
989
+ });
990
+
991
+ const projectRows = [];
992
+ if (loading) {
993
+ projectRows.push(create(Text, {dimColor: true}, 'Scanning for projects…'));
994
+ }
995
+ if (error) {
996
+ projectRows.push(create(Text, {color: 'red'}, `Unable to scan: ${error}`));
997
+ }
998
+ if (!loading && !error && projects.length === 0) {
999
+ projectRows.push(create(Text, {dimColor: true}, 'No recognizable project manifests found.'));
1000
+ }
1001
+ if (!loading) {
1002
+ projects.forEach((project, index) => {
1003
+ const frameworkBadges = (project.frameworks || []).map((frame) => `${frame.icon} ${frame.name}`).join(', ');
1004
+ projectRows.push(
1005
+ create(
1006
+ Box,
1007
+ {key: project.id, flexDirection: 'column', marginBottom: 1},
1008
+ create(
1009
+ Box,
1010
+ {flexDirection: 'row'},
1011
+ create(
1012
+ Text,
1013
+ {
1014
+ color: index === selectedIndex ? 'green' : undefined,
1015
+ bold: index === selectedIndex
1016
+ },
1017
+ `${project.icon} ${project.name}`
1018
+ ),
1019
+ create(Text, {dimColor: true}, ` ${project.type} · ${path.relative(rootPath, project.path) || '.'}`)
1020
+ ),
1021
+ frameworkBadges && create(Text, {dimColor: true}, ` ${frameworkBadges}`)
1022
+ )
1023
+ );
1024
+ });
1025
+ }
1026
+
1027
+ const detailContent = [];
1028
+ if (viewMode === 'detail' && selectedProject) {
1029
+ detailContent.push(
1030
+ create(Text, {color: 'yellow', bold: true}, `${selectedProject.icon} ${selectedProject.name}`),
1031
+ create(Text, {dimColor: true}, `${selectedProject.type} · ${selectedProject.manifest || 'detected manifest'}`),
1032
+ create(Text, {dimColor: true}, `Location: ${path.relative(rootPath, selectedProject.path) || '.'}`)
1033
+ );
1034
+ if (selectedProject.description) {
1035
+ detailContent.push(create(Text, null, selectedProject.description));
1036
+ }
1037
+ const frameworks = (selectedProject.frameworks || []).map((lib) => `${lib.icon} ${lib.name}`).join(', ');
1038
+ if (frameworks) {
1039
+ detailContent.push(create(Text, {dimColor: true}, `Frameworks: ${frameworks}`));
1040
+ }
1041
+ if (selectedProject.extra?.scripts && selectedProject.extra.scripts.length) {
1042
+ detailContent.push(create(Text, {dimColor: true}, `Scripts: ${selectedProject.extra.scripts.join(', ')}`));
1043
+ }
1044
+ detailContent.push(create(Text, {dimColor: true}, `Custom commands stored in ${CONFIG_PATH}`));
1045
+ detailContent.push(create(Text, {dimColor: true, marginBottom: 1}, `Extend frameworks via ${PLUGIN_FILE}`));
1046
+ detailContent.push(create(Text, {bold: true, marginTop: 1}, 'Commands'));
1047
+ detailedIndexed.forEach((command) => {
1048
+ detailContent.push(
1049
+ create(Text, {key: `${command.shortcut}-${command.label}`}, `${command.shortcut}. ${command.label} ${command.source === 'custom' ? kleur.magenta('(custom)') : command.source === 'framework' ? kleur.cyan('(framework)') : ''}`)
1050
+ );
1051
+ detailContent.push(create(Text, {dimColor: true}, ` ↳ ${command.command.join(' ')}`));
1052
+ });
1053
+ if (!detailedIndexed.length) {
1054
+ detailContent.push(create(Text, {dimColor: true}, 'No built-in commands yet. Add a custom command with C.'));
1055
+ }
1056
+ detailContent.push(create(Text, {dimColor: true}, 'Press C → label|cmd to save custom actions, Enter to close detail view.'));
1057
+ } else {
1058
+ detailContent.push(create(Text, {dimColor: true}, 'Press Enter on a project to reveal details (icons, commands, frameworks, custom actions).'));
1059
+ }
1060
+
1061
+ if (customMode) {
1062
+ detailContent.push(create(Text, {color: 'cyan'}, `Type label|cmd (Enter to save, Esc to cancel): ${customInput}`));
1063
+ }
1064
+
1065
+ const logNodes = logLines.length
1066
+ ? logLines.map((line, index) => create(Text, {key: `${line}-${index}`}, line))
1067
+ : [create(Text, {dimColor: true}, 'Logs will appear here once you run a command.')];
1068
+
1069
+ const headerHint = viewMode === 'detail'
1070
+ ? `Detail mode · 1-${Math.max(detailedIndexed.length, 1)} to execute, C: add custom commands, Enter: back to list, q: quit`
1071
+ : `Quick run · B/T/R to build/test/run, Enter: view details, q: quit`;
1072
+
1073
+ return create(
1074
+ Box,
1075
+ {flexDirection: 'column', padding: 1},
1076
+ create(
1077
+ Box,
1078
+ {justifyContent: 'space-between'},
1079
+ create(
1080
+ Box,
1081
+ {flexDirection: 'column'},
1082
+ create(Text, {color: 'cyan', bold: true}, 'Project Compass'),
1083
+ create(Text, null, loading ? 'Scanning workspaces…' : `${projects.length} project(s) detected in ${rootPath}`)
1084
+ ),
1085
+ create(
1086
+ Box,
1087
+ {flexDirection: 'column', alignItems: 'flex-end'},
1088
+ create(Text, null, running ? 'Busy 🔁' : lastAction ? `Last: ${lastAction}` : 'Idle'),
1089
+ create(Text, {dimColor: true}, headerHint)
1090
+ )
1091
+ ),
1092
+ create(
1093
+ Box,
1094
+ {marginTop: 1},
1095
+ create(
1096
+ Box,
1097
+ {flexDirection: 'column', width: 60, marginRight: 2},
1098
+ create(Text, {bold: true}, 'Projects'),
1099
+ create(Box, {flexDirection: 'column', marginTop: 1}, ...projectRows)
1100
+ ),
1101
+ create(
1102
+ Box,
1103
+ {
1104
+ flexDirection: 'column',
1105
+ width: 44,
1106
+ marginRight: 2,
1107
+ borderStyle: 'round',
1108
+ borderColor: 'gray',
1109
+ padding: 1
1110
+ },
1111
+ create(Text, {bold: true}, 'Details'),
1112
+ ...detailContent
1113
+ ),
1114
+ create(
1115
+ Box,
1116
+ {
1117
+ flexDirection: 'column',
1118
+ flexGrow: 1,
1119
+ borderStyle: 'round',
1120
+ borderColor: 'gray'
1121
+ },
1122
+ create(Text, {bold: true}, 'Output'),
1123
+ create(Box, {flexDirection: 'column', marginTop: 1, height: 12, overflow: 'hidden'}, ...logNodes)
1124
+ )
1125
+ )
1126
+ );
1127
+ }
1128
+
1129
+ function parseArgs() {
1130
+ const args = {};
1131
+ const tokens = process.argv.slice(2);
1132
+ for (let i = 0; i < tokens.length; i += 1) {
1133
+ const token = tokens[i];
1134
+ if ((token === '--dir' || token === '--path') && tokens[i + 1]) {
1135
+ args.root = tokens[i + 1];
1136
+ i += 1;
1137
+ } else if (token === '--mode' && tokens[i + 1]) {
1138
+ args.mode = tokens[i + 1];
1139
+ i += 1;
1140
+ } else if (token === '--help' || token === '-h') {
1141
+ args.help = true;
1142
+ }
1143
+ }
1144
+ return args;
1145
+ }
1146
+
1147
+ async function main() {
1148
+ const args = parseArgs();
1149
+ if (args.help) {
1150
+ console.log('Project Compass · Ink project runner');
1151
+ console.log('Usage: project-compass [--dir <path>] [--mode test]');
1152
+ return;
1153
+ }
1154
+ const rootPath = args.root ? path.resolve(args.root) : process.cwd();
1155
+ if (args.mode === 'test') {
1156
+ const projects = await discoverProjects(rootPath);
1157
+ console.log(`Detected ${projects.length} project(s) under ${rootPath}`);
1158
+ projects.forEach((project) => {
1159
+ console.log(` • [${project.type}] ${project.name} (${project.path})`);
1160
+ });
1161
+ return;
1162
+ }
1163
+
1164
+ render(create(Compass, {rootPath}));
1165
+ }
1166
+
1167
+ main().catch((error) => {
1168
+ console.error(error);
1169
+ process.exit(1);
1170
+ });