UNPKG

pmac-cli

Version:

CLI tools for Project Management as Code (PMaC) - Standalone npm package with interactive backlog viewer

1,237 lines (1,216 loc) โ€ข 55.6 kB
#!/usr/bin/env node import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; import { resolve, dirname, join } from 'path'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import { createServer } from 'http'; import { readFile } from 'fs/promises'; import { fileURLToPath } from 'url'; import { createServer as createNetServer } from 'net'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); /** * Find an available port starting from the given port number * @param startPort The port to start checking from * @param maxAttempts Maximum number of ports to try * @returns Promise that resolves to an available port number * @throws Error if no available port is found within maxAttempts */ async function findAvailablePort(startPort = 5173, maxAttempts = 10) { for (let port = startPort; port < startPort + maxAttempts; port++) { if (await isPortAvailable(port)) { return port; } } throw new Error(`No available port found in range ${startPort}-${startPort + maxAttempts - 1}`); } /** * Check if a specific port is available * @param port The port number to check * @returns Promise that resolves to true if port is available, false otherwise */ function isPortAvailable(port) { return new Promise((resolve) => { const server = createNetServer(); server.listen(port, () => { server.close(() => { resolve(true); }); }); server.on('error', () => { resolve(false); }); }); } class PMaCCLI { backlogPath; backlog; constructor(customPath, skipLoad = false) { if (customPath) { // Custom path: resolve relative to project root this.backlogPath = resolve(process.cwd(), customPath); } else { // Default: project-backlog.yml at project root this.backlogPath = resolve(process.cwd(), 'project-backlog.yml'); } if (!skipLoad) { this.loadBacklog(); } } loadBacklog() { try { const content = readFileSync(this.backlogPath, 'utf8'); this.backlog = parseYaml(content); // Show which file is being used for transparency if (process.env.PMAC_DEBUG || this.backlogPath !== resolve(process.cwd(), 'project-backlog.yml')) { console.log(`๐Ÿ“ Using backlog file: ${this.backlogPath}`); } } catch (error) { if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { console.error(` โŒ PMaC Project Not Initialized The file '${this.backlogPath}' was not found. To get started: 1. Copy the template: cp templates/project-backlog.yml project-backlog.yml 2. Edit the project metadata and tasks for your specific project 3. Run PMaC commands to manage your project backlog Alternative option: - Use --backlog flag: pmac --backlog custom/path/project-backlog.yml <command> For more information, see: project-management-as-code.md `); } else if (error instanceof Error && error.name === 'YAMLParseError') { console.error(` โŒ Invalid YAML Format The file 'project-backlog.yml' contains invalid YAML syntax: ${error.message} Please check the file format and fix any syntax errors. `); } else { console.error(` โŒ Error Loading Project Backlog Failed to load 'project-backlog.yml': ${error instanceof Error ? error.message : 'Unknown error'} Please check the file permissions and format. `); } process.exit(1); } } saveBacklog() { try { const yamlContent = stringifyYaml(this.backlog, { indent: 2, lineWidth: 120, minContentWidth: 20, nullStr: 'null', }); writeFileSync(this.backlogPath, yamlContent); } catch (error) { console.error('Error saving project-backlog.yml:', error instanceof Error ? error.message : 'Unknown error'); process.exit(1); } } findTask(taskId) { for (const [phaseName, phase] of Object.entries(this.backlog.phases)) { const taskIndex = phase.tasks.findIndex(task => task.id === taskId); if (taskIndex !== -1) { return { phase: phaseName, taskIndex, task: phase.tasks[taskIndex], }; } } return null; } updateTaskStatus(taskId, status, note) { const taskInfo = this.findTask(taskId); if (!taskInfo) { console.log(`Task ${taskId} not found`); return; } const { phase, taskIndex } = taskInfo; this.backlog.phases[phase].tasks[taskIndex].status = status; if (note) { const timestamp = this.formatTimestamp(); if (!this.backlog.phases[phase].tasks[taskIndex].notes) { this.backlog.phases[phase].tasks[taskIndex].notes = []; } this.backlog.phases[phase].tasks[taskIndex].notes.push(`${timestamp}: ${note}`); } this.saveBacklog(); console.log(`Updated ${taskId} status to ${status}`); if (note) { console.log(`Added note: ${note}`); } } addTaskNote(taskId, note) { const taskInfo = this.findTask(taskId); if (!taskInfo) { console.log(`Task ${taskId} not found`); return; } const { phase, taskIndex } = taskInfo; const timestamp = this.formatTimestamp(); if (!this.backlog.phases[phase].tasks[taskIndex].notes) { this.backlog.phases[phase].tasks[taskIndex].notes = []; } // Always prepend timestamp - CLI automatically adds timestamps to all notes const formattedNote = `${timestamp}: ${note}`; this.backlog.phases[phase].tasks[taskIndex].notes.push(formattedNote); this.saveBacklog(); console.log(`Added note to ${taskId}: ${note}`); } listTasks(statusFilter, priorityFilter) { console.log('Project Tasks:'); console.log('=============='); for (const [phaseName, phase] of Object.entries(this.backlog.phases)) { const filteredTasks = phase.tasks.filter(task => { const statusMatch = !statusFilter || task.status === statusFilter; const priorityMatch = !priorityFilter || task.priority === priorityFilter; return statusMatch && priorityMatch; }); if (filteredTasks.length > 0) { console.log(`\n๐Ÿ“‹ ${phase.title} (${phaseName})`); filteredTasks.forEach(task => { const statusIcon = this.getStatusIcon(task.status); const priorityIcon = this.getPriorityIcon(task.priority); console.log(` ${statusIcon} ${task.id}: ${task.title} ${priorityIcon}`); if (task.dependencies && task.dependencies.length > 0) { console.log(` Dependencies: ${task.dependencies.join(', ')}`); } if (task.blocks && task.blocks.length > 0) { console.log(` Blocks: ${task.blocks.join(', ')}`); } }); } } } validateDependencies() { console.log('Dependency Validation:'); console.log('====================='); const allTaskIds = new Set(); const issues = []; // Collect all task IDs for (const phase of Object.values(this.backlog.phases)) { for (const task of phase.tasks) { allTaskIds.add(task.id); } } // Validate dependencies and blocks for (const [, phase] of Object.entries(this.backlog.phases)) { for (const task of phase.tasks) { // Check dependencies exist if (task.dependencies) { for (const depId of task.dependencies) { if (!allTaskIds.has(depId)) { issues.push(`โŒ ${task.id}: Dependency '${depId}' does not exist`); } } } // Check blocks exist if (task.blocks) { for (const blockId of task.blocks) { if (!allTaskIds.has(blockId)) { issues.push(`โŒ ${task.id}: Blocks '${blockId}' which does not exist`); } } } // Check for circular dependencies if (this.hasCircularDependency(task.id, [])) { issues.push(`โŒ ${task.id}: Circular dependency detected`); } } } if (issues.length === 0) { console.log('โœ… All dependencies are valid'); } else { console.log('Issues found:'); issues.forEach(issue => console.log(issue)); } } hasCircularDependency(taskId, visited) { if (visited.includes(taskId)) { return true; } const taskInfo = this.findTask(taskId); if (!taskInfo) return false; const newVisited = [...visited, taskId]; if (taskInfo.task.dependencies) { for (const depId of taskInfo.task.dependencies) { if (this.hasCircularDependency(depId, newVisited)) { return true; } } } return false; } showCriticalPath() { console.log('Critical Path Analysis:'); console.log('======================'); const taskMap = new Map(); for (const phase of Object.values(this.backlog.phases)) { for (const task of phase.tasks) { taskMap.set(task.id, task); } } // Find tasks with no dependencies (entry points) const entryTasks = Array.from(taskMap.values()).filter(task => !task.dependencies || task.dependencies.length === 0); console.log('\n๐Ÿš€ Entry Points (no dependencies):'); entryTasks.forEach(task => { console.log(` ${this.getStatusIcon(task.status)} ${task.id}: ${task.title} (${task.estimated_hours}h)`); }); // Find longest path let longestPath = { tasks: [], totalHours: 0, }; for (const entryTask of entryTasks) { const path = this.findLongestPath(entryTask.id, taskMap); if (path.totalHours > longestPath.totalHours) { longestPath = path; } } console.log('\nโšก Critical Path:'); longestPath.tasks.forEach(taskId => { const task = taskMap.get(taskId); console.log(` ${this.getStatusIcon(task.status)} ${task.id}: ${task.title} (${task.estimated_hours}h)`); }); console.log(`\n๐Ÿ“Š Total Critical Path: ${longestPath.totalHours} hours`); } findLongestPath(taskId, taskMap) { const task = taskMap.get(taskId); if (!task) return { tasks: [], totalHours: 0 }; const blockedTasks = Array.from(taskMap.values()).filter(t => t.dependencies && t.dependencies.includes(taskId)); if (blockedTasks.length === 0) { return { tasks: [taskId], totalHours: task.estimated_hours }; } let longestSubPath = { tasks: [], totalHours: 0 }; for (const blockedTask of blockedTasks) { const subPath = this.findLongestPath(blockedTask.id, taskMap); if (subPath.totalHours > longestSubPath.totalHours) { longestSubPath = subPath; } } return { tasks: [taskId, ...longestSubPath.tasks], totalHours: task.estimated_hours + longestSubPath.totalHours, }; } bulkUpdatePhase(phaseName, status) { if (!this.backlog.phases[phaseName]) { console.log(`Phase '${phaseName}' not found`); return; } const phase = this.backlog.phases[phaseName]; const timestamp = new Date().toISOString().split('T')[0]; phase.tasks.forEach(task => { task.status = status; if (!task.notes) { task.notes = []; } task.notes.push(`${timestamp}: Bulk status update to ${status}`); }); this.saveBacklog(); console.log(`Updated all tasks in phase '${phaseName}' to status '${status}'`); } createTask(taskId, title, phaseName, priority = 'medium', estimatedHours = 8) { if (!this.backlog.phases[phaseName]) { console.log(`Phase '${phaseName}' not found`); console.log('Available phases:', Object.keys(this.backlog.phases).join(', ')); return; } // Enhanced task ID validation const existingTask = this.findTask(taskId); if (existingTask) { console.log(`โŒ Task ${taskId} already exists in phase '${existingTask.phase}'`); // Suggest similar available IDs const suggestions = this.suggestTaskIds(taskId, phaseName); if (suggestions.length > 0) { console.log(`๐Ÿ’ก Suggested alternatives: ${suggestions.join(', ')}`); } // Pattern validation suggestion const phasePrefix = phaseName.toUpperCase().replace(/[^A-Z]/g, '').substring(0, 6); console.log(`๐Ÿ’ก Consider using pattern: ${phasePrefix}-001, ${phasePrefix}-002, etc.`); return; } // Validate task ID pattern this.validateTaskIdPattern(taskId); const newTask = { id: taskId, title: title, status: 'ready', priority: priority, estimated_hours: estimatedHours, requirements: [], dependencies: [], blocks: [], notes: [], }; const timestamp = this.formatTimestamp(); newTask.notes.push(`${timestamp}: Task created via PMaC CLI`); this.backlog.phases[phaseName].tasks.push(newTask); this.saveBacklog(); console.log(`โœ… Created task ${taskId}: ${title} in phase ${phaseName}`); console.log(` Priority: ${priority}, Estimated hours: ${estimatedHours}`); } updateTaskAttribute(taskId, attribute, value) { const taskInfo = this.findTask(taskId); if (!taskInfo) { console.log(`Task ${taskId} not found`); return; } const { phase, taskIndex } = taskInfo; const task = this.backlog.phases[phase].tasks[taskIndex]; const timestamp = new Date().toISOString().split('T')[0]; // Handle different attribute types switch (attribute) { case 'priority': if (!['critical', 'high', 'medium', 'low'].includes(value)) { console.log('Priority must be: critical, high, medium, or low'); return; } const oldPriority = task.priority; task.priority = value; if (!task.notes) task.notes = []; task.notes.push(`${timestamp}: Priority changed from ${oldPriority} to ${value}`); break; case 'estimated_hours': const hours = parseInt(value); if (isNaN(hours) || hours <= 0) { console.log('Estimated hours must be a positive number'); return; } const oldHours = task.estimated_hours; task.estimated_hours = hours; if (!task.notes) task.notes = []; task.notes.push(`${timestamp}: Estimated hours changed from ${oldHours} to ${hours}`); break; case 'title': const oldTitle = task.title; task.title = value; if (!task.notes) task.notes = []; task.notes.push(`${timestamp}: Title changed from "${oldTitle}" to "${value}"`); break; case 'assignee': const oldAssignee = task.assignee || 'unassigned'; task.assignee = value; if (!task.notes) task.notes = []; task.notes.push(`${timestamp}: Assignee changed from ${oldAssignee} to ${value}`); break; case 'dependencies': task.dependencies = this.parseArrayInput(value); if (!task.notes) task.notes = []; task.notes.push(`${timestamp}: Dependencies updated to: ${task.dependencies.join(', ') || 'none'}`); break; case 'blocks': task.blocks = this.parseArrayInput(value); if (!task.notes) task.notes = []; task.notes.push(`${timestamp}: Blocks updated to: ${task.blocks.join(', ') || 'none'}`); break; case 'requirements': task.requirements = this.parseArrayInput(value); if (!task.notes) task.notes = []; task.notes.push(`${timestamp}: Requirements updated (${task.requirements.length} items)`); break; default: console.error(`Attribute '${String(attribute)}' is not supported for updates`); return; } this.saveBacklog(); // Get the final formatted value from the task let displayValue; switch (attribute) { case 'dependencies': case 'blocks': case 'requirements': displayValue = this.formatValue(task[attribute] || []); break; default: displayValue = this.formatValue(value); } console.log(`โœ… Updated ${taskId} ${String(attribute)} to: ${displayValue}`); } parseArrayInput(input) { if (!input || input.trim() === '') return []; return input .split(',') .map(item => item.trim()) .filter(item => item.length > 0); } formatValue(value) { if (Array.isArray(value)) { return value.join(', ') || 'none'; } return String(value); } addDependency(taskId, dependencyId) { const taskInfo = this.findTask(taskId); const depInfo = this.findTask(dependencyId); if (!taskInfo) { console.log(`Task ${taskId} not found`); return; } if (!depInfo) { console.log(`Dependency task ${dependencyId} not found`); return; } const { phase, taskIndex } = taskInfo; const task = this.backlog.phases[phase].tasks[taskIndex]; if (!task.dependencies) task.dependencies = []; if (task.dependencies.includes(dependencyId)) { console.error(`${taskId} already depends on ${dependencyId}`); return; } // Check for circular dependencies if (this.wouldCreateCircularDependency(taskId, dependencyId)) { console.log(`Adding dependency ${dependencyId} to ${taskId} would create a circular dependency`); return; } task.dependencies.push(dependencyId); const timestamp = new Date().toISOString().split('T')[0]; if (!task.notes) task.notes = []; task.notes.push(`${timestamp}: Added dependency on ${dependencyId}`); this.saveBacklog(); console.log(`โœ… Added dependency: ${taskId} now depends on ${dependencyId}`); } removeDependency(taskId, dependencyId) { const taskInfo = this.findTask(taskId); if (!taskInfo) { console.log(`Task ${taskId} not found`); return; } const { phase, taskIndex } = taskInfo; const task = this.backlog.phases[phase].tasks[taskIndex]; if (!task.dependencies || !task.dependencies.includes(dependencyId)) { console.log(`${taskId} does not depend on ${dependencyId}`); return; } task.dependencies = task.dependencies.filter(dep => dep !== dependencyId); const timestamp = new Date().toISOString().split('T')[0]; if (!task.notes) task.notes = []; task.notes.push(`${timestamp}: Removed dependency on ${dependencyId}`); this.saveBacklog(); console.log(`โœ… Removed dependency: ${taskId} no longer depends on ${dependencyId}`); } wouldCreateCircularDependency(taskId, newDependencyId) { // Check if newDependencyId (directly or indirectly) depends on taskId const visited = new Set(); const stack = [newDependencyId]; while (stack.length > 0) { const currentId = stack.pop(); if (visited.has(currentId)) continue; visited.add(currentId); if (currentId === taskId) { return true; // Found circular dependency } const currentTask = this.findTask(currentId); if (currentTask && currentTask.task.dependencies) { stack.push(...currentTask.task.dependencies); } } return false; } moveTask(taskId, targetPhase, position) { const taskInfo = this.findTask(taskId); if (!taskInfo) { console.log(`Task ${taskId} not found`); return; } if (!this.backlog.phases[targetPhase]) { console.log(`Target phase '${targetPhase}' not found`); console.log('Available phases:', Object.keys(this.backlog.phases).join(', ')); return; } const { phase: currentPhase, taskIndex, task } = taskInfo; if (currentPhase === targetPhase) { console.log(`Task ${taskId} is already in phase ${targetPhase}`); return; } // Remove from current phase this.backlog.phases[currentPhase].tasks.splice(taskIndex, 1); // Add to target phase const targetTasks = this.backlog.phases[targetPhase].tasks; if (position !== undefined && position >= 0 && position <= targetTasks.length) { targetTasks.splice(position, 0, task); } else { targetTasks.push(task); } const timestamp = new Date().toISOString().split('T')[0]; if (!task.notes) task.notes = []; task.notes.push(`${timestamp}: Moved from ${currentPhase} to ${targetPhase}`); this.saveBacklog(); console.log(`โœ… Moved task ${taskId} from ${currentPhase} to ${targetPhase}`); } listPhases() { console.log('Available Phases:'); console.log('================'); for (const [phaseName, phase] of Object.entries(this.backlog.phases)) { console.log(`๐Ÿ“‚ ${phaseName}: ${phase.title}`); console.log(` ${phase.description}`); console.log(` Status: ${phase.status}, Duration: ${phase.estimated_duration}`); console.log(` Tasks: ${phase.tasks.length}\n`); } } formatTimestamp() { const now = new Date(); // Get timezone abbreviation using the same method as existing code const timezone = now .toLocaleString('en-CA', { timeZoneName: 'short' }) .split(' ') .pop() || 'UTC'; const year = now.getFullYear(); const month = String(now.getMonth() + 1).padStart(2, '0'); const day = String(now.getDate()).padStart(2, '0'); let hours = now.getHours(); const minutes = String(now.getMinutes()).padStart(2, '0'); const seconds = String(now.getSeconds()).padStart(2, '0'); const ampm = hours >= 12 ? 'p.m.' : 'a.m.'; hours = hours % 12; hours = hours ? hours : 12; // 0 should be 12 const formattedHours = String(hours).padStart(2, '0'); return `${year}-${month}-${day} ${formattedHours}:${minutes}:${seconds} ${ampm} ${timezone}`; } logPrompt(prompt) { const logPath = resolve(process.cwd(), 'prompts-log.md'); const timestamp = this.formatTimestamp(); const logEntry = ` ## ${timestamp} - Prompt: "${prompt}" `; try { // Check if file exists if (!existsSync(logPath)) { // Create file with header if it doesn't exist writeFileSync(logPath, '# PMaC Prompt Log\n\n'); } // Append the log entry writeFileSync(logPath, logEntry, { flag: 'a' }); console.log('๐Ÿ“ Prompt logged successfully'); } catch (error) { console.error('โŒ Error logging prompt:', error instanceof Error ? error.message : 'Unknown error'); process.exit(1); } } suggestTaskIds(taskId, phaseName) { const suggestions = []; const existingIds = this.getAllTaskIds(); // Extract base pattern and number const match = taskId.match(/^(.*?)(-?\d*)$/); if (match) { const [, base] = match; // Suggest numbered variants for (let i = 1; i <= 5; i++) { const suggestion = `${base}-${String(i).padStart(3, '0')}`; if (!existingIds.includes(suggestion)) { suggestions.push(suggestion); } } } // Suggest phase-based pattern const phasePrefix = phaseName.toUpperCase().replace(/[^A-Z]/g, '').substring(0, 6); for (let i = 1; i <= 3; i++) { const suggestion = `${phasePrefix}-${String(i).padStart(3, '0')}`; if (!existingIds.includes(suggestion)) { suggestions.push(suggestion); } } return suggestions.slice(0, 3); // Return max 3 suggestions } getAllTaskIds() { const ids = []; for (const phase of Object.values(this.backlog.phases)) { for (const task of phase.tasks) { ids.push(task.id); } } return ids; } validateTaskIdPattern(taskId) { // Check for common pattern recommendations if (!/^[A-Z]/.test(taskId)) { console.log(`โš ๏ธ Recommendation: Task IDs typically start with uppercase letters (e.g., TASK-001)`); } if (!/\d/.test(taskId)) { console.log(`โš ๏ธ Recommendation: Consider adding numbers for better organization (e.g., ${taskId}-001)`); } if (taskId.length > 20) { console.log(`โš ๏ธ Recommendation: Task IDs shorter than 20 characters are easier to reference`); } } createPhase(phaseId, title, description, estimatedDuration = '1 week') { // Check if phase ID already exists if (this.backlog.phases[phaseId]) { console.log(`Phase '${phaseId}' already exists`); console.log('Existing phases:', Object.keys(this.backlog.phases).join(', ')); return; } const newPhase = { title: title, description: description, status: 'ready', estimated_duration: estimatedDuration, tasks: [] }; this.backlog.phases[phaseId] = newPhase; this.saveBacklog(); console.log(`โœ… Created phase ${phaseId}: ${title}`); console.log(` Description: ${description}`); console.log(` Estimated duration: ${estimatedDuration}`); } getStatusIcon(status) { const icons = { ready: 'โณ', in_progress: '๐Ÿ”„', testing: '๐Ÿงช', completed: 'โœ…', }; return icons[status] || 'โ“'; } getPriorityIcon(priority) { const icons = { critical: '๐Ÿ”ฅ', high: 'โšก', medium: '๐Ÿ“‹', low: '๐Ÿ“', }; return icons[priority] || 'โ“'; } validateStatus(status) { const validStatuses = ['ready', 'in_progress', 'testing', 'completed']; if (validStatuses.includes(status)) { return status; } return null; } validatePriority(priority) { const validPriorities = ['critical', 'high', 'medium', 'low']; if (validPriorities.includes(priority)) { return priority; } return null; } viewTasks(args) { // Parse arguments let taskId; const filters = {}; let format = 'pretty'; // Iterate through args for (let i = 0; i < args.length; i++) { if (args[i] === '--status' && i + 1 < args.length) { const status = this.validateStatus(args[i + 1]); if (!status) { console.error(`Invalid status: ${args[i + 1]}`); console.error('Valid statuses: ready, in_progress, testing, completed'); process.exit(1); } filters.status = status; i++; // Skip next arg } else if (args[i] === '--priority' && i + 1 < args.length) { const priority = this.validatePriority(args[i + 1]); if (!priority) { console.error(`Invalid priority: ${args[i + 1]}`); console.error('Valid priorities: critical, high, medium, low'); process.exit(1); } filters.priority = priority; i++; } else if (args[i] === '--phase' && i + 1 < args.length) { filters.phase = args[i + 1]; i++; } else if (args[i] === '--json') { format = 'json'; } else if (args[i] === '--yaml') { format = 'yaml'; } else if (!args[i].startsWith('--')) { taskId = args[i]; } } // Validate mutually exclusive modes const hasFilters = Object.keys(filters).length > 0; if (taskId && hasFilters) { console.error('Error: Cannot combine task ID with filter flags'); process.exit(1); } // Execute appropriate mode if (taskId) { this.viewSingleTask(taskId, format); } else if (hasFilters) { this.viewFilteredTasks(filters, format); } else { console.error('Error: Must specify either a task ID or filter flags'); process.exit(1); } } viewSingleTask(taskId, format) { const taskInfo = this.findTask(taskId); if (!taskInfo) { console.log(`Task ${taskId} not found`); return; } const tasks = [{ task: taskInfo.task, phase: taskInfo.phase }]; if (format === 'json') { this.formatTasksJson(tasks); } else if (format === 'yaml') { this.formatTasksYaml(tasks); } else { this.formatTaskPretty(taskInfo.task, taskInfo.phase); } } viewFilteredTasks(filters, format) { const matchingTasks = []; // Iterate through phases and find matching tasks for (const [phaseName, phase] of Object.entries(this.backlog.phases)) { // Apply phase filter first if (filters.phase && phaseName !== filters.phase) { continue; } // Filter tasks within phase const filteredTasks = phase.tasks.filter(task => { const statusMatch = !filters.status || task.status === filters.status; const priorityMatch = !filters.priority || task.priority === filters.priority; return statusMatch && priorityMatch; }); // Add to results with phase information filteredTasks.forEach(task => { matchingTasks.push({ task, phase: phaseName }); }); } // Display results based on format if (format === 'json') { this.formatTasksJson(matchingTasks, filters); } else if (format === 'yaml') { this.formatTasksYaml(matchingTasks, filters); } else { // Pretty-print format const filterDesc = this.buildFilterDescription(filters); console.log(`\nFound ${matchingTasks.length} task(s) matching filters${filterDesc}`); console.log('='.repeat(80) + '\n'); if (matchingTasks.length === 0) { console.log('No tasks match the specified filters.\n'); return; } matchingTasks.forEach(({ task, phase }) => { this.formatTaskPretty(task, phase); }); } } printSection(title, divider, content) { console.log(`\n${title}`); console.log(divider); content(); } buildFilterDescription(filters) { const parts = []; if (filters.status) parts.push(`status: ${filters.status}`); if (filters.priority) parts.push(`priority: ${filters.priority}`); if (filters.phase) parts.push(`phase: ${filters.phase}`); return parts.length > 0 ? ` (${parts.join(', ')})` : ''; } formatTaskPretty(task, phase) { const width = 80; const divider = 'โ”€'.repeat(width); const doubleDivider = '='.repeat(width); console.log('\n' + doubleDivider); console.log(`Task: ${task.id}`); console.log(doubleDivider); // Basic Information section this.printSection('๐Ÿ“‹ BASIC INFORMATION', divider, () => { console.log(` ID: ${task.id}`); console.log(` Title: ${task.title}`); console.log(` Status: ${this.getStatusIcon(task.status)} ${task.status}`); console.log(` Priority: ${this.getPriorityIcon(task.priority)} ${task.priority}`); console.log(` Phase: ${phase}`); console.log(` Estimated Hours: ${task.estimated_hours}`); console.log(` Actual Hours: ${task.actual_hours || '-'}`); console.log(` Assignee: ${task.assignee || '-'}`); }); // Requirements section (only if exists) if (task.requirements && task.requirements.length > 0) { this.printSection('๐Ÿ“ REQUIREMENTS', divider, () => { task.requirements.forEach(req => { console.log(` โ€ข ${req}`); }); }); } // Acceptance criteria section (only if exists) if (task.acceptance_criteria && task.acceptance_criteria.length > 0) { this.printSection('โœ… ACCEPTANCE CRITERIA', divider, () => { task.acceptance_criteria.forEach(criteria => { console.log(` โ€ข ${criteria}`); }); }); } // Dependencies & Relationships section this.printSection('๐Ÿ”— DEPENDENCIES & RELATIONSHIPS', divider, () => { const deps = task.dependencies && task.dependencies.length > 0 ? task.dependencies.join(', ') : '-'; const blocks = task.blocks && task.blocks.length > 0 ? task.blocks.join(', ') : '-'; console.log(` Dependencies: ${deps}`); console.log(` Blocks: ${blocks}`); }); // Notes section (only if exists) if (task.notes && task.notes.length > 0) { this.printSection('๐Ÿ“’ NOTES', divider, () => { task.notes.forEach(note => { console.log(` ${note}`); }); }); } console.log(doubleDivider + '\n'); } formatTasksJson(tasks, filters) { const output = { tasks: tasks.map(({ task, phase }) => ({ ...task, phase })), count: tasks.length, ...(filters && { filters }) }; console.log(JSON.stringify(output, null, 2)); } formatTasksYaml(tasks, filters) { const output = { tasks: tasks.map(({ task, phase }) => ({ ...task, phase })), count: tasks.length, ...(filters && { filters }) }; console.log(stringifyYaml(output, { indent: 2, lineWidth: 120, minContentWidth: 20, })); } getVersion() { let version = 'unknown'; // Try to find package root by looking for package.json (similar to viewer logic) let currentDir = __dirname; while (currentDir !== resolve(currentDir, '..')) { const packageJsonPath = join(currentDir, 'package.json'); if (existsSync(packageJsonPath)) { try { const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); if (packageJson.name === 'pmac-cli') { version = packageJson.version; break; } } catch { // Continue searching if package.json is malformed } } currentDir = resolve(currentDir, '..'); } return version; } showVersion() { console.log(this.getVersion()); } showHelp() { const version = this.getVersion(); console.log(` PMaC CLI - Project Management as Code Tool Version: ${version} Usage: pmac [--backlog <path>] <command> [options] Global Options: --backlog <path> Specify path to project-backlog.yml file --version, -v Show version number Project Setup Commands: init [project-name] Initialize PMaC project with template files init --existing Initialize PMaC in existing project directory Task Management Commands: list [status] [priority] List all tasks, optionally filtered by status and/or priority view <taskId> [--json|--yaml] Display full details for a specific task view --status <status> [options] View all tasks matching filters (supports --priority, --phase, --json, --yaml) create <taskId> <title> <phase> Create a new task in specified phase (taskId must be unique across entire backlog) update <taskId> <status> [note] Update task status (ready|in_progress|testing|completed) note <taskId> <note> Add note to task move <taskId> <targetPhase> Move task to different phase Task Attribute Updates: set <taskId> <attribute> <value> Update task attributes: - priority: critical|high|medium|low - estimated_hours: number - title: "new title" - assignee: person - dependencies: "TASK-1,TASK-2" (comma-separated) - blocks: "TASK-3,TASK-4" (comma-separated) - requirements: "req1,req2" (comma-separated) Dependency Management: add-dep <taskId> <dependencyId> Add dependency relationship rm-dep <taskId> <dependencyId> Remove dependency relationship Phase Management: phases List all phases and their details phase-create <phaseId> <title> <description> [duration] Create a new phase Analysis & Validation: validate Validate all dependencies critical-path Show critical path analysis Prompt Logging: log-prompt <prompt> Log a prompt with AI directives Viewer: viewer Start PMaC Backlog Viewer Bulk Operations: bulk-phase <phase> <status> Update all tasks in a phase to given status Examples: pmac --version # Show version information pmac init my-project # Initialize new PMaC project pmac init --existing # Initialize PMaC in existing directory pmac create TEST-001 "New feature implementation" core_data pmac set TEST-001 priority high pmac set TEST-001 estimated_hours 12 pmac set TEST-001 dependencies "PMAC-001,INFRA-001" pmac add-dep API-002 API-001 pmac move TEST-001 api_foundation pmac list in_progress high pmac view TEST-001 # View full task details pmac view TEST-001 --json # View task as JSON pmac view --status ready # View all ready tasks pmac view --status ready --priority high # View ready high-priority tasks pmac view --phase foundation --yaml # View phase tasks as YAML pmac update PMAC-002 testing "Implementation complete" pmac phases pmac phase-create new_phase "New Phase Title" "Description of new phase" "2 weeks" pmac viewer pmac log-prompt "Add a feature to log prompts via CLI" `); } async startViewer() { console.log('๐Ÿ” PMaC Backlog Viewer'); console.log('======================'); // Validate backlog file exists if (!existsSync(this.backlogPath)) { console.error(`โŒ Backlog file not found: ${this.backlogPath}`); console.error('Please ensure the backlog file exists or specify a valid path with --backlog'); process.exit(1); } console.log(`๐Ÿ“ Using backlog file: ${this.backlogPath}`); // Path to pre-built viewer assets // Use more robust path resolution for both development and global installations let viewerAssetsPath; // Try to find package root by looking for package.json let currentDir = __dirname; let packageRoot = null; while (currentDir !== resolve(currentDir, '..')) { const packageJsonPath = join(currentDir, 'package.json'); if (existsSync(packageJsonPath)) { try { const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); if (packageJson.name === 'pmac-cli') { packageRoot = currentDir; break; } } catch { // Continue searching if package.json is malformed } } currentDir = resolve(currentDir, '..'); } if (packageRoot) { viewerAssetsPath = join(packageRoot, 'dist', 'viewer'); } else { // Fallback to relative path resolution viewerAssetsPath = resolve(__dirname, '../dist/viewer'); } if (!existsSync(viewerAssetsPath)) { console.error(`โŒ Pre-built viewer assets not found at: ${viewerAssetsPath}`); console.error(''); console.error('Possible solutions:'); console.error('1. If this is a development environment: Run "pnpm build:viewer"'); console.error('2. If globally installed: Try reinstalling with "npm install -g pmac-cli"'); console.error('3. If using locally: Ensure package is properly built and installed'); console.error(''); console.error(`Package root detected: ${packageRoot || 'not found'}`); console.error(`Current __dirname: ${__dirname}`); process.exit(1); } // Find an available port, starting with the default 5173 let port; try { port = await findAvailablePort(5173, 10); } catch (error) { console.error(`โŒ Unable to find an available port: ${error instanceof Error ? error.message : 'Unknown error'}`); console.error('Please free up some network ports and try again.'); process.exit(1); } const server = createServer(async (req, res) => { try { // Set CORS headers for local development res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); const filePath = req.url === '/' ? '/index.html' : (req.url || '/index.html'); // Handle backlog API endpoint if (filePath === '/api/backlog') { const backlogContent = readFileSync(this.backlogPath, 'utf8'); res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ content: backlogContent, path: this.backlogPath })); return; } // Serve static files const fullPath = join(viewerAssetsPath, filePath); if (!existsSync(fullPath)) { // Fallback to index.html for SPA routing const indexPath = join(viewerAssetsPath, 'index.html'); if (existsSync(indexPath)) { const content = await readFile(indexPath, 'utf8'); res.setHeader('Content-Type', 'text/html'); res.end(content); } else { res.statusCode = 404; res.end('Not Found'); } return; } const content = await readFile(fullPath); // Set content type based on extension const ext = filePath.split('.').pop()?.toLowerCase(); const contentTypes = { 'html': 'text/html', 'js': 'application/javascript', 'css': 'text/css', 'json': 'application/json', 'png': 'image/png', 'svg': 'image/svg+xml' }; if (ext && contentTypes[ext]) { res.setHeader('Content-Type', contentTypes[ext]); } res.end(content); } catch (error) { console.error('Server error:', error instanceof Error ? error.message : 'Unknown error'); res.statusCode = 500; res.end('Internal Server Error'); } }); server.listen(port, () => { if (port !== 5173) { console.log(`๐Ÿš€ PMaC Viewer running at http://localhost:${port} (port 5173 was in use)`); } else { console.log(`๐Ÿš€ PMaC Viewer running at http://localhost:${port}`); } console.log(`๐Ÿ“ Serving backlog: ${this.backlogPath}`); console.log(`โŒจ๏ธ Press Ctrl+C to stop`); }); // Handle graceful shutdown process.on('SIGINT', () => { console.log('\n๐Ÿ›‘ Stopping viewer...'); server.close(() => { process.exit(0); }); }); process.on('SIGTERM', () => { console.log('\n๐Ÿ›‘ Stopping viewer...'); server.close(() => { process.exit(0); }); }); } initProject(projectName, isExisting = false) { const targetDir = projectName && !isExisting ? projectName : '.'; const templatesDir = resolve(__dirname, '../../../templates'); console.log('๐Ÿš€ Initializing PMaC Project'); console.log('============================'); if (projectName && !isExisting) { // Create new project directory if (!existsSync(targetDir)) { try { mkdirSync(targetDir, { recursive: true }); console.log(`๐Ÿ“ Created project directory: ${projectName}`); } catch (error) { console.error(`โŒ Failed to create directory: ${error instanceof Error ? error.message : 'Unknown error'}`); process.exit(1); } } else { console.error(`โŒ Directory '${projectName}' already exists`); process.exit(1); } } // Copy template files const templateFiles = [ 'project-backlog.yml', 'prompts-log.md', 'project-requirements.md', 'README.md', 'CLAUDE.md' ]; let copiedFiles = 0; for (const file of templateFiles) { const templatePath = join(templatesDir, file); const targetPath = join(targetDir, file); if (existsSync(targetPath) && !isExisting) { console.log(`โš ๏ธ File ${file} already exists, skipping...`); continue; } if (existsSync(templatePath)) { try { const content = readFileSync(templatePath, 'utf8'); writeFileSync(targetPath, content); console.log(`โœ… Created ${file}`); copiedFiles++; } catch (error) { console.error(`โŒ Failed to create ${file}: ${error instanceof Error ? error.message : 'Unknown error'}`); } } else { console.error(`โš ๏ธ Template ${file} not found at ${templatePath}`); } } if (copiedFiles > 0) { console.log(`\n๐ŸŽ‰ PMaC project initialized successfully!`); console.log(`๐Ÿ“ ${copiedFiles} template files created`); if (projectName && !isExisting) {