init
This commit is contained in:
474
.opencode/plugin/lib/project-detector.cjs
Normal file
474
.opencode/plugin/lib/project-detector.cjs
Normal file
@@ -0,0 +1,474 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* project-detector.cjs - Project and environment detection logic
|
||||
*
|
||||
* Extracted from session-init.cjs for reuse in both Claude hooks and OpenCode plugins.
|
||||
* Detects project type, package manager, framework, and runtime versions.
|
||||
*
|
||||
* @module project-detector
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { execSync, execFileSync } = require('child_process');
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// SAFE EXECUTION HELPERS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Safely execute shell command with optional timeout
|
||||
* @param {string} cmd - Command to execute
|
||||
* @param {number} [timeoutMs=5000] - Timeout in milliseconds
|
||||
* @returns {string|null} Output or null on error
|
||||
*/
|
||||
function execSafe(cmd, timeoutMs = 5000) {
|
||||
try {
|
||||
return execSync(cmd, {
|
||||
encoding: 'utf8',
|
||||
timeout: timeoutMs,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
}).trim();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely execute a binary with arguments (no shell interpolation)
|
||||
* @param {string} binary - Path to the executable
|
||||
* @param {string[]} args - Arguments array
|
||||
* @param {number} [timeoutMs=2000] - Timeout in milliseconds
|
||||
* @returns {string|null} Output or null on error
|
||||
*/
|
||||
function execFileSafe(binary, args, timeoutMs = 2000) {
|
||||
try {
|
||||
return execFileSync(binary, args, {
|
||||
encoding: 'utf8',
|
||||
timeout: timeoutMs,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
}).trim();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// PYTHON DETECTION
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Validate that a path is a file and doesn't contain shell metacharacters
|
||||
* @param {string} p - Path to validate
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidPythonPath(p) {
|
||||
if (!p || typeof p !== 'string') return false;
|
||||
if (/[;&|`$(){}[\]<>!#*?]/.test(p)) return false;
|
||||
try {
|
||||
const stat = fs.statSync(p);
|
||||
return stat.isFile();
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build platform-specific Python paths for fast filesystem check
|
||||
* @returns {string[]} Array of potential Python paths
|
||||
*/
|
||||
function getPythonPaths() {
|
||||
const paths = [];
|
||||
|
||||
if (process.env.PYTHON_PATH) {
|
||||
paths.push(process.env.PYTHON_PATH);
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const localAppData = process.env.LOCALAPPDATA;
|
||||
const programFiles = process.env.ProgramFiles || 'C:\\Program Files';
|
||||
const programFilesX86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)';
|
||||
|
||||
if (localAppData) {
|
||||
paths.push(path.join(localAppData, 'Microsoft', 'WindowsApps', 'python.exe'));
|
||||
paths.push(path.join(localAppData, 'Microsoft', 'WindowsApps', 'python3.exe'));
|
||||
for (const ver of ['313', '312', '311', '310', '39']) {
|
||||
paths.push(path.join(localAppData, 'Programs', 'Python', `Python${ver}`, 'python.exe'));
|
||||
}
|
||||
}
|
||||
|
||||
for (const ver of ['313', '312', '311', '310', '39']) {
|
||||
paths.push(path.join(programFiles, `Python${ver}`, 'python.exe'));
|
||||
paths.push(path.join(programFilesX86, `Python${ver}`, 'python.exe'));
|
||||
}
|
||||
|
||||
paths.push('C:\\Python313\\python.exe');
|
||||
paths.push('C:\\Python312\\python.exe');
|
||||
paths.push('C:\\Python311\\python.exe');
|
||||
paths.push('C:\\Python310\\python.exe');
|
||||
paths.push('C:\\Python39\\python.exe');
|
||||
} else {
|
||||
paths.push('/usr/bin/python3');
|
||||
paths.push('/usr/local/bin/python3');
|
||||
paths.push('/opt/homebrew/bin/python3');
|
||||
paths.push('/opt/homebrew/bin/python');
|
||||
paths.push('/usr/bin/python');
|
||||
paths.push('/usr/local/bin/python');
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find Python binary using fast `which` lookup first, then filesystem check
|
||||
* @returns {string|null} Python binary path or null
|
||||
*/
|
||||
function findPythonBinary() {
|
||||
// Fast path: try `which` command first (10ms vs 2000ms per path)
|
||||
if (process.platform !== 'win32') {
|
||||
const whichPython3 = execSafe('which python3', 500);
|
||||
if (whichPython3 && isValidPythonPath(whichPython3)) return whichPython3;
|
||||
|
||||
const whichPython = execSafe('which python', 500);
|
||||
if (whichPython && isValidPythonPath(whichPython)) return whichPython;
|
||||
} else {
|
||||
// Windows: try `where` command
|
||||
const wherePython = execSafe('where python', 500);
|
||||
if (wherePython) {
|
||||
const firstPath = wherePython.split('\n')[0].trim();
|
||||
if (isValidPythonPath(firstPath)) return firstPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: check known paths
|
||||
const paths = getPythonPaths();
|
||||
for (const p of paths) {
|
||||
if (isValidPythonPath(p)) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Python version with optimized detection
|
||||
* @returns {string|null} Python version string or null
|
||||
*/
|
||||
function getPythonVersion() {
|
||||
const pythonPath = findPythonBinary();
|
||||
if (pythonPath) {
|
||||
const result = execFileSafe(pythonPath, ['--version']);
|
||||
if (result) return result;
|
||||
}
|
||||
|
||||
const commands = ['python3', 'python'];
|
||||
for (const cmd of commands) {
|
||||
const result = execFileSafe(cmd, ['--version']);
|
||||
if (result) return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// GIT DETECTION
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Check if current directory is inside a git repository (fast check)
|
||||
* Uses filesystem traversal instead of git command to avoid command failures
|
||||
* @param {string} [startDir] - Directory to check from (defaults to cwd)
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isGitRepo(startDir) {
|
||||
let dir;
|
||||
try {
|
||||
dir = startDir || process.cwd();
|
||||
} catch (e) {
|
||||
// CWD deleted or inaccessible
|
||||
return false;
|
||||
}
|
||||
const root = path.parse(dir).root;
|
||||
|
||||
while (dir !== root) {
|
||||
if (fs.existsSync(path.join(dir, '.git'))) return true;
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
return fs.existsSync(path.join(root, '.git'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get git remote URL
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function getGitRemoteUrl() {
|
||||
if (!isGitRepo()) return null;
|
||||
return execFileSafe('git', ['config', '--get', 'remote.origin.url']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current git branch
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function getGitBranch() {
|
||||
if (!isGitRepo()) return null;
|
||||
return execFileSafe('git', ['branch', '--show-current']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get git repository root
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function getGitRoot() {
|
||||
if (!isGitRepo()) return null;
|
||||
return execFileSafe('git', ['rev-parse', '--show-toplevel']);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// PROJECT DETECTION
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Detect project type based on workspace indicators
|
||||
* @param {string} [configOverride] - Manual override from config
|
||||
* @returns {'monorepo' | 'library' | 'single-repo'}
|
||||
*/
|
||||
function detectProjectType(configOverride) {
|
||||
if (configOverride && configOverride !== 'auto') return configOverride;
|
||||
|
||||
if (fs.existsSync('pnpm-workspace.yaml')) return 'monorepo';
|
||||
if (fs.existsSync('lerna.json')) return 'monorepo';
|
||||
|
||||
if (fs.existsSync('package.json')) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
|
||||
if (pkg.workspaces) return 'monorepo';
|
||||
if (pkg.main || pkg.exports) return 'library';
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
return 'single-repo';
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect package manager from lock files
|
||||
* @param {string} [configOverride] - Manual override from config
|
||||
* @returns {'npm' | 'pnpm' | 'yarn' | 'bun' | null}
|
||||
*/
|
||||
function detectPackageManager(configOverride) {
|
||||
if (configOverride && configOverride !== 'auto') return configOverride;
|
||||
|
||||
if (fs.existsSync('bun.lockb')) return 'bun';
|
||||
if (fs.existsSync('pnpm-lock.yaml')) return 'pnpm';
|
||||
if (fs.existsSync('yarn.lock')) return 'yarn';
|
||||
if (fs.existsSync('package-lock.json')) return 'npm';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect framework from package.json dependencies
|
||||
* @param {string} [configOverride] - Manual override from config
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function detectFramework(configOverride) {
|
||||
if (configOverride && configOverride !== 'auto') return configOverride;
|
||||
if (!fs.existsSync('package.json')) return null;
|
||||
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
|
||||
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
||||
|
||||
if (deps['next']) return 'next';
|
||||
if (deps['nuxt']) return 'nuxt';
|
||||
if (deps['astro']) return 'astro';
|
||||
if (deps['@remix-run/node'] || deps['@remix-run/react']) return 'remix';
|
||||
if (deps['svelte'] || deps['@sveltejs/kit']) return 'svelte';
|
||||
if (deps['vue']) return 'vue';
|
||||
if (deps['react']) return 'react';
|
||||
if (deps['express']) return 'express';
|
||||
if (deps['fastify']) return 'fastify';
|
||||
if (deps['hono']) return 'hono';
|
||||
if (deps['elysia']) return 'elysia';
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// CODING LEVEL
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Get coding level style name mapping
|
||||
* @param {number} level - Coding level (0-5)
|
||||
* @returns {string} Style name
|
||||
*/
|
||||
function getCodingLevelStyleName(level) {
|
||||
const styleMap = {
|
||||
0: 'coding-level-0-eli5',
|
||||
1: 'coding-level-1-junior',
|
||||
2: 'coding-level-2-mid',
|
||||
3: 'coding-level-3-senior',
|
||||
4: 'coding-level-4-lead',
|
||||
5: 'coding-level-5-god'
|
||||
};
|
||||
return styleMap[level] || 'coding-level-5-god';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get coding level guidelines by reading from output-styles .md files
|
||||
* @param {number} level - Coding level (-1 to 5)
|
||||
* @param {string} [configDir] - Config directory path
|
||||
* @returns {string|null} Guidelines text or null if disabled
|
||||
*/
|
||||
function getCodingLevelGuidelines(level, configDir) {
|
||||
if (level === -1 || level === null || level === undefined) return null;
|
||||
|
||||
const styleName = getCodingLevelStyleName(level);
|
||||
const basePath = configDir || path.join(process.cwd(), '.claude');
|
||||
const stylePath = path.join(basePath, 'output-styles', `${styleName}.md`);
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(stylePath)) return null;
|
||||
const content = fs.readFileSync(stylePath, 'utf8');
|
||||
const withoutFrontmatter = content.replace(/^---[\s\S]*?---\n*/, '').trim();
|
||||
return withoutFrontmatter;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// CONTEXT OUTPUT
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Build context summary for output (compact, single line)
|
||||
* @param {Object} config - Loaded config
|
||||
* @param {Object} detections - Project detections
|
||||
* @param {{ path: string|null, resolvedBy: string|null }} resolved - Plan resolution
|
||||
* @param {string|null} gitRoot - Git repository root
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildContextOutput(config, detections, resolved, gitRoot) {
|
||||
const lines = [`Project: ${detections.type || 'unknown'}`];
|
||||
if (detections.pm) lines.push(`PM: ${detections.pm}`);
|
||||
lines.push(`Plan naming: ${config.plan.namingFormat}`);
|
||||
|
||||
if (gitRoot && gitRoot !== process.cwd()) {
|
||||
lines.push(`Root: ${gitRoot}`);
|
||||
}
|
||||
|
||||
if (resolved.path) {
|
||||
if (resolved.resolvedBy === 'session') {
|
||||
lines.push(`Plan: ${resolved.path}`);
|
||||
} else {
|
||||
lines.push(`Suggested: ${resolved.path}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join(' | ');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// MAIN ENTRY POINT
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Detect all project information
|
||||
*
|
||||
* @param {Object} [options]
|
||||
* @param {Object} [options.configOverrides] - Override auto-detection
|
||||
* @returns {{
|
||||
* type: 'monorepo' | 'library' | 'single-repo',
|
||||
* packageManager: 'npm' | 'pnpm' | 'yarn' | 'bun' | null,
|
||||
* framework: string | null,
|
||||
* pythonVersion: string | null,
|
||||
* nodeVersion: string,
|
||||
* gitBranch: string | null,
|
||||
* gitRoot: string | null,
|
||||
* gitUrl: string | null,
|
||||
* osPlatform: string,
|
||||
* user: string,
|
||||
* locale: string,
|
||||
* timezone: string
|
||||
* }}
|
||||
*/
|
||||
function detectProject(options = {}) {
|
||||
const { configOverrides = {} } = options;
|
||||
|
||||
return {
|
||||
type: detectProjectType(configOverrides.type),
|
||||
packageManager: detectPackageManager(configOverrides.packageManager),
|
||||
framework: detectFramework(configOverrides.framework),
|
||||
pythonVersion: getPythonVersion(),
|
||||
nodeVersion: process.version,
|
||||
gitBranch: getGitBranch(),
|
||||
gitRoot: getGitRoot(),
|
||||
gitUrl: getGitRemoteUrl(),
|
||||
osPlatform: process.platform,
|
||||
user: process.env.USERNAME || process.env.USER || process.env.LOGNAME || os.userInfo().username,
|
||||
locale: process.env.LANG || '',
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build static environment info object
|
||||
* @param {string} [configDir] - Config directory path
|
||||
* @returns {Object} Static environment info
|
||||
*/
|
||||
function buildStaticEnv(configDir) {
|
||||
return {
|
||||
nodeVersion: process.version,
|
||||
pythonVersion: getPythonVersion(),
|
||||
osPlatform: process.platform,
|
||||
gitUrl: getGitRemoteUrl(),
|
||||
gitBranch: getGitBranch(),
|
||||
gitRoot: getGitRoot(),
|
||||
user: process.env.USERNAME || process.env.USER || process.env.LOGNAME || os.userInfo().username,
|
||||
locale: process.env.LANG || '',
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
configDir: configDir || path.join(process.cwd(), '.claude')
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// EXPORTS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
module.exports = {
|
||||
// Main entry points
|
||||
detectProject,
|
||||
buildStaticEnv,
|
||||
|
||||
// Detection functions
|
||||
detectProjectType,
|
||||
detectPackageManager,
|
||||
detectFramework,
|
||||
|
||||
// Python detection
|
||||
getPythonVersion,
|
||||
findPythonBinary,
|
||||
getPythonPaths,
|
||||
isValidPythonPath,
|
||||
|
||||
// Git detection
|
||||
isGitRepo,
|
||||
getGitRemoteUrl,
|
||||
getGitBranch,
|
||||
getGitRoot,
|
||||
|
||||
// Coding level
|
||||
getCodingLevelStyleName,
|
||||
getCodingLevelGuidelines,
|
||||
|
||||
// Output
|
||||
buildContextOutput,
|
||||
|
||||
// Helpers
|
||||
execSafe,
|
||||
execFileSafe
|
||||
};
|
||||
Reference in New Issue
Block a user