Spaces:
Running
Running
| import fs from 'node:fs'; | |
| import path from 'node:path'; | |
| import { parseArgs, getUsageText, SUPPORTED_TYPES } from './arg-parser.js'; | |
| import { resolveInput } from './input-resolver.js'; | |
| import { createServer } from './server.js'; | |
| import { startWatcher } from './watcher.js'; | |
| import { log } from './logger.js'; | |
| import { getBundlePath } from './bundle-path.js'; | |
| import { generateTemplates } from './template-generator.js'; | |
| import { analyze } from './analyzer/index.js'; | |
| import { generateDomainContext } from './analyzer/domain-context.js'; | |
| import open from 'open'; | |
| /** | |
| * Opens the default system browser at the given URL. | |
| * If the browser fails to open, logs a warning and continues. | |
| */ | |
| async function openBrowser(url) { | |
| try { | |
| await open(url); | |
| } | |
| catch { | |
| log('warn', 'Could not open browser automatically'); | |
| } | |
| } | |
| async function main() { | |
| // Parse CLI arguments | |
| const parseResult = parseArgs(process.argv.slice(2)); | |
| if (!parseResult.success) { | |
| log('error', parseResult.error); | |
| process.stdout.write(getUsageText() + '\n'); | |
| process.exit(1); | |
| } | |
| const { options } = parseResult; | |
| // Default behavior (no command) or --help β show help | |
| if (options.command === 'help') { | |
| process.stdout.write(getUsageText() + '\n'); | |
| process.exit(0); | |
| } | |
| // --preview <path>: start the preview server | |
| if (options.command === 'preview') { | |
| await runPreview(options); | |
| } | |
| // --analyze [path]: run static analysis and generate knowledge graph | |
| if (options.command === 'analyze') { | |
| await runAnalyze(options); | |
| } | |
| // --create-md [path]: generate agent config files | |
| if (options.command === 'create-md') { | |
| await runCreateMd(options.inputPath || '.', options.type, options.init); | |
| } | |
| } | |
| async function runPreview(options) { | |
| // Validate input path exists on filesystem | |
| if (!fs.existsSync(options.inputPath)) { | |
| log('error', `Path does not exist: ${options.inputPath}`); | |
| process.exit(1); | |
| } | |
| // Resolve input to manifest and file mapping | |
| const resolveResult = resolveInput(options.inputPath); | |
| if (!resolveResult.success) { | |
| log('error', resolveResult.error); | |
| process.exit(1); | |
| } | |
| const { manifest, fileMapping } = resolveResult; | |
| // Log discovery summary | |
| const projectCount = manifest.length; | |
| const graphFileCount = manifest.reduce((sum, entry) => sum + entry.graphFiles.length, 0); | |
| log('info', `Found ${projectCount} projects, ${graphFileCount} graph files`); | |
| // Resolve bundle path | |
| let bundlePath; | |
| try { | |
| bundlePath = getBundlePath(); | |
| } | |
| catch (err) { | |
| const message = err instanceof Error ? err.message : String(err); | |
| log('error', message); | |
| process.exit(1); | |
| } | |
| // Create and start server | |
| const server = createServer({ | |
| port: options.port, | |
| manifest, | |
| fileMapping, | |
| bundlePath, | |
| }); | |
| try { | |
| await server.start(); | |
| } | |
| catch (err) { | |
| const message = err instanceof Error ? err.message : String(err); | |
| log('error', message); | |
| process.exit(1); | |
| } | |
| log('info', `Server running at http://localhost:${options.port}`); | |
| // Open browser unless --no-open | |
| if (options.open) { | |
| await openBrowser(`http://localhost:${options.port}`); | |
| } | |
| // Start watcher unless --no-watch | |
| let watcher = null; | |
| if (options.watch) { | |
| watcher = startWatcher({ | |
| inputPath: options.inputPath, | |
| onManifestUpdate: (updatedManifest, updatedFileMapping) => { | |
| server.updateManifest(updatedManifest, updatedFileMapping); | |
| }, | |
| }); | |
| } | |
| // Register SIGINT handler for graceful shutdown | |
| process.on('SIGINT', async () => { | |
| if (watcher) { | |
| watcher.close(); | |
| } | |
| await server.stop(); | |
| process.exit(0); | |
| }); | |
| } | |
| async function runAnalyze(options) { | |
| // Resolve target path (default to current directory if not provided) | |
| const targetPath = path.resolve(options.inputPath || '.'); | |
| // Validate the target path exists | |
| if (!fs.existsSync(targetPath)) { | |
| log('error', `Path does not exist: ${targetPath}`); | |
| process.exit(1); | |
| } | |
| log('info', `Analyzing: ${targetPath}`); | |
| // Run the analysis pipeline | |
| const { dashboard, stats, files } = await analyze(targetPath, { full: options.full }); | |
| // Create the .understand-anything/ output directory if it doesn't exist | |
| const outputDir = path.join(targetPath, '.understand-anything'); | |
| if (!fs.existsSync(outputDir)) { | |
| fs.mkdirSync(outputDir, { recursive: true }); | |
| } | |
| // Write knowledge-graph.json with full schema | |
| const knowledgeGraphPath = path.join(outputDir, 'knowledge-graph.json'); | |
| fs.writeFileSync(knowledgeGraphPath, JSON.stringify(dashboard, null, 2)); | |
| // Write meta.json with timestamp, git commit hash, version, and analyzedFiles count | |
| const meta = { | |
| lastAnalyzedAt: new Date().toISOString(), | |
| gitCommitHash: dashboard.project.gitCommitHash || '', | |
| version: '1.0.0', | |
| analyzedFiles: stats.filesAnalyzed, | |
| }; | |
| const metaPath = path.join(outputDir, 'meta.json'); | |
| fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2)); | |
| // Generate domain context | |
| const domainContext = generateDomainContext(targetPath, files); | |
| const domainContextPath = path.join(outputDir, 'domain-context.json'); | |
| fs.writeFileSync(domainContextPath, JSON.stringify(domainContext, null, 2)); | |
| // Log summary | |
| const nodeTypeSummary = Object.entries(stats.nodesByType) | |
| .map(([type, count]) => `${type}: ${count}`) | |
| .join(', '); | |
| const tourSteps = dashboard.tour ? dashboard.tour.length : 0; | |
| log('info', `Analysis complete:`); | |
| log('info', ` Files analyzed: ${stats.filesAnalyzed}`); | |
| log('info', ` Nodes: ${nodeTypeSummary || '(none)'}`); | |
| log('info', ` Edges: ${stats.edgesCreated}`); | |
| log('info', ` Layers: ${stats.layersIdentified}`); | |
| log('info', ` Tour steps: ${tourSteps}`); | |
| log('info', ` Domain context: ${domainContext.entryPoints.length} entry points, ${domainContext.fileSignatures.length} signatures`); | |
| log('info', `Output written to: ${outputDir}`); | |
| process.exit(0); | |
| } | |
| async function runOpenspecInit(targetDir) { | |
| const { execSync } = await import('node:child_process'); | |
| // Check if openspec is installed | |
| try { | |
| execSync('openspec --version', { stdio: 'pipe' }); | |
| log('info', 'openspec is already installed'); | |
| } | |
| catch { | |
| log('info', 'Installing openspec globally...'); | |
| try { | |
| execSync('npm install -g openspec@latest', { stdio: 'inherit' }); | |
| log('info', 'openspec installed successfully'); | |
| } | |
| catch (err) { | |
| const message = err instanceof Error ? err.message : String(err); | |
| log('error', `Failed to install openspec: ${message}`); | |
| process.exit(1); | |
| } | |
| } | |
| // Check if openspec is already initialized (look for openspec config files) | |
| const openspecConfig = ['openspec.json', '.openspec.json', 'openspec.yaml', '.openspec.yaml', 'openspec/AGENTS.md'] | |
| .some(f => fs.existsSync(`${targetDir}/${f}`)); | |
| if (openspecConfig) { | |
| log('info', 'openspec already initialized (config file found)'); | |
| } | |
| else { | |
| log('info', 'Running openspec init...'); | |
| try { | |
| execSync('openspec init', { stdio: 'inherit', cwd: targetDir }); | |
| log('info', 'openspec init completed'); | |
| } | |
| catch (err) { | |
| const message = err instanceof Error ? err.message : String(err); | |
| log('warn', `openspec init failed: ${message}. Continuing with file generation...`); | |
| } | |
| } | |
| } | |
| async function runCreateMd(targetPath, type, init) { | |
| // If no type specified, prompt user to choose interactively | |
| if (!type) { | |
| type = await promptForType(); | |
| } | |
| const targetDir = targetPath || '.'; | |
| if (!fs.existsSync(targetDir)) { | |
| log('error', `Target directory does not exist: ${targetDir}`); | |
| process.exit(1); | |
| } | |
| // --init: install openspec and run openspec init | |
| if (init) { | |
| await runOpenspecInit(targetDir); | |
| } | |
| log('info', `Generating ${type} templates in: ${targetDir}`); | |
| const result = generateTemplates(targetDir, type); | |
| if (!result.success) { | |
| log('error', result.error || 'Failed to generate templates'); | |
| process.exit(1); | |
| } | |
| if (result.filesCreated.length === 0) { | |
| log('info', 'No new files created (all files already exist)'); | |
| } | |
| else { | |
| log('info', `Created ${result.filesCreated.length} files:`); | |
| for (const file of result.filesCreated) { | |
| log('info', ` ${file}`); | |
| } | |
| // Show skill descriptions and usage guide | |
| process.stdout.write('\n'); | |
| process.stdout.write('ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ\n'); | |
| process.stdout.write('\n'); | |
| process.stdout.write(' π¦ Available skills:\n'); | |
| process.stdout.write('\n'); | |
| process.stdout.write(' understand-chat Ask questions about the codebase using the knowledge graph\n'); | |
| process.stdout.write(' understand-diff Analyze code changes to identify affected components and risks\n'); | |
| process.stdout.write(' understand-domain Extract business domain knowledge, flows, and process steps\n'); | |
| process.stdout.write(' understand-explain Deep-dive explanation of a specific file, function, or module\n'); | |
| process.stdout.write(' understand-knowledge Analyze a wiki/knowledge base and generate a knowledge graph\n'); | |
| process.stdout.write(' understand-onboard Generate an onboarding guide for new team members\n'); | |
| process.stdout.write('\n'); | |
| process.stdout.write('ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ\n'); | |
| process.stdout.write('\n'); | |
| process.stdout.write(' π Getting started:\n'); | |
| process.stdout.write('\n'); | |
| process.stdout.write(' 1. Generate a knowledge graph for your project:\n'); | |
| process.stdout.write(' Run the /understand skill in your AI coding tool\n'); | |
| process.stdout.write(' This creates .understand-anything/knowledge-graph.json\n'); | |
| process.stdout.write('\n'); | |
| process.stdout.write(' 2. Use skills in your AI tool:\n'); | |
| process.stdout.write(' /understand-chat "How does authentication work?"\n'); | |
| process.stdout.write(' /understand-explain src/auth/login.ts\n'); | |
| process.stdout.write(' /understand-diff\n'); | |
| process.stdout.write(' /understand-onboard\n'); | |
| process.stdout.write('\n'); | |
| process.stdout.write(' 3. Preview the knowledge graph in browser:\n'); | |
| process.stdout.write(' project-understand --preview .understand-anything\n'); | |
| process.stdout.write('\n'); | |
| process.stdout.write('ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ\n'); | |
| process.stdout.write('\n'); | |
| process.stdout.write(' π Notes:\n'); | |
| process.stdout.write(' β’ Steering/config files teach the AI agent about the knowledge graph structure\n'); | |
| process.stdout.write(' β’ Skills define specific actions the agent can perform\n'); | |
| process.stdout.write(' β’ Hooks (where supported) auto-trigger updates after code changes\n'); | |
| process.stdout.write(' β’ The knowledge graph must exist before skills can be used\n'); | |
| process.stdout.write('\n'); | |
| process.stdout.write('ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ\n'); | |
| process.stdout.write('\n'); | |
| process.stdout.write(' π‘ Usage examples by platform:\n'); | |
| process.stdout.write('\n'); | |
| process.stdout.write(' Kiro:\n'); | |
| process.stdout.write(' β’ Skills appear in chat via # context key (e.g., #understand-chat)\n'); | |
| process.stdout.write(' β’ Steering files auto-load into every conversation\n'); | |
| process.stdout.write(' β’ Hooks trigger automatically (e.g., post-commit graph update)\n'); | |
| process.stdout.write('\n'); | |
| process.stdout.write(' Claude Code:\n'); | |
| process.stdout.write(' β’ Use /understand-chat "query" to invoke skills\n'); | |
| process.stdout.write(' β’ PostToolUse hooks run after git commits to check graph staleness\n'); | |
| process.stdout.write(' β’ Plugin metadata in plugin.json registers all skills\n'); | |
| process.stdout.write('\n'); | |
| process.stdout.write(' Codex / OpenCode:\n'); | |
| process.stdout.write(' β’ AGENTS.md is read automatically at session start\n'); | |
| process.stdout.write(' β’ Skills in .codex/skills/ or .opencode/skills/ are discovered by the agent\n'); | |
| process.stdout.write(' β’ Invoke: /understand-chat, /understand-explain <file>\n'); | |
| process.stdout.write('\n'); | |
| process.stdout.write(' OpenClaw:\n'); | |
| process.stdout.write(' β’ AGENT.md provides steering context\n'); | |
| process.stdout.write(' β’ Skills in .agent/skills/ are auto-discovered\n'); | |
| process.stdout.write(' β’ Hooks in .agent/hooks/ trigger on events (e.g., post-commit)\n'); | |
| process.stdout.write('\n'); | |
| process.stdout.write(' Cursor:\n'); | |
| process.stdout.write(' β’ Rules in .cursor/rules/ are loaded based on globs and alwaysApply\n'); | |
| process.stdout.write(' β’ No skills/hooks β use rules to guide the agent behavior\n'); | |
| process.stdout.write(' β’ The agent reads the knowledge graph when rules reference it\n'); | |
| process.stdout.write('\n'); | |
| process.stdout.write(' OpenSpec (GitHub Copilot):\n'); | |
| process.stdout.write(' β’ openspec/AGENTS.md provides agent-level steering\n'); | |
| process.stdout.write(' β’ .github/instructions/ files are loaded as project context\n'); | |
| process.stdout.write(' β’ .github/prompts/ files are available as reusable prompts\n'); | |
| process.stdout.write('\n'); | |
| } | |
| } | |
| async function promptForType() { | |
| const { createInterface } = await import('node:readline'); | |
| const rl = createInterface({ input: process.stdin, output: process.stdout }); | |
| const descriptions = { | |
| 'kiro': '.kiro/ (steering, skills, hooks)', | |
| 'codex': 'AGENTS.md + .codex/skills/ (OpenAI Codex)', | |
| 'opencode': 'AGENTS.md + .opencode/skills/ (OpenCode)', | |
| 'claude-code': '.claude-plugin/ (plugin.json + skills/)', | |
| 'openclaw': '.agent/AGENT.md + .agent/skills/ (OpenClaw)', | |
| 'cursor': '.cursor/rules/ (Cursor)', | |
| 'openspec': 'openspec/AGENTS.md + .github/ (GitHub Copilot)', | |
| }; | |
| process.stdout.write('\nSelect a template type:\n\n'); | |
| SUPPORTED_TYPES.forEach((t, i) => { | |
| process.stdout.write(` ${i + 1}) ${t.padEnd(14)} β ${descriptions[t]}\n`); | |
| }); | |
| process.stdout.write('\n'); | |
| return new Promise((resolve) => { | |
| rl.question('Enter number or type name: ', (answer) => { | |
| rl.close(); | |
| const trimmed = answer.trim().toLowerCase(); | |
| // Try as number | |
| const num = parseInt(trimmed, 10); | |
| if (num >= 1 && num <= SUPPORTED_TYPES.length) { | |
| resolve(SUPPORTED_TYPES[num - 1]); | |
| return; | |
| } | |
| // Try as type name | |
| if (SUPPORTED_TYPES.includes(trimmed)) { | |
| resolve(trimmed); | |
| return; | |
| } | |
| log('error', `Invalid selection: "${answer.trim()}". Supported types: ${SUPPORTED_TYPES.join(', ')}`); | |
| process.exit(1); | |
| }); | |
| }); | |
| } | |
| main(); | |