UNPKG

@gonzui/claude-task-manager

Version:

Task management extension for Claude Code with archiving and history

774 lines (725 loc) 32 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.TaskManager = void 0; const fs = __importStar(require("fs-extra")); const path = __importStar(require("path")); const date_fns_1 = require("date-fns"); const child_process_1 = require("child_process"); const chalk_1 = __importDefault(require("chalk")); const types_1 = require("../types"); const i18n_1 = require("./i18n"); class TaskManager { constructor(workingDir) { // If no directory specified, try to find project root with .claude-tasks const baseDir = workingDir || this.findProjectRoot() || process.cwd(); this.config = { workingDir: baseDir, taskFile: path.join(baseDir, 'task.md'), archiveDir: path.join(baseDir, 'archive'), configDir: path.join(baseDir, '.claude-tasks'), configFile: path.join(baseDir, '.claude-tasks', 'config.json') }; this.i18n = i18n_1.I18n.getInstance(); // Initialize i18n with default language if not already initialized // This ensures i18n is ready even if init() hasn't been called yet if (!this.i18n.isInitialized()) { this.i18n.init('en').catch(() => { // Ignore initialization errors in constructor }); } } /** * Detect initial language from environment */ detectInitialLanguage() { // Check environment variables const lang = process.env.LANG || process.env.LANGUAGE || ''; // If Japanese locale is detected, use Japanese if (lang.toLowerCase().includes('ja')) { return 'ja'; } // Default to English return 'en'; } /** * Find the project root by looking for .claude-tasks directory * Similar to how git finds .git directory */ findProjectRoot() { let currentDir = process.cwd(); const root = path.parse(currentDir).root; while (currentDir !== root) { const configDir = path.join(currentDir, '.claude-tasks'); try { if (fs.existsSync(configDir) && fs.statSync(configDir).isDirectory()) { return currentDir; } } catch { // Ignore errors and continue searching } currentDir = path.dirname(currentDir); } // Check root directory as well const rootConfigDir = path.join(root, '.claude-tasks'); try { if (fs.existsSync(rootConfigDir) && fs.statSync(rootConfigDir).isDirectory()) { return root; } } catch { // Ignore errors } return null; } async init() { try { // Create necessary directories await fs.ensureDir(this.config.archiveDir); await fs.ensureDir(this.config.configDir); // Create config file if it doesn't exist if (!await fs.pathExists(this.config.configFile)) { // Determine initial language from environment or default to 'en' const initialLang = this.detectInitialLanguage(); // Initialize i18n BEFORE creating config to get proper defaults await this.i18n.init(initialLang); // Now create config with language-aware defaults const isJapanese = initialLang === 'ja'; const defaultConfig = { created: new Date().toISOString(), taskTemplate: this.getDefaultTaskTemplate(), claudeCommand: 'claude', defaultTaskTitle: isJapanese ? '新しいタスク' : 'New Task', archiveDir: 'archive', language: initialLang, defaultPrerequisites: isJapanese ? ['<!-- 前提条件を記入してください -->'] : ['<!-- Add prerequisites here -->'], defaultRules: isJapanese ? ['<!-- ルールを記入してください -->'] : ['<!-- Add rules here -->'], defaultTasks: isJapanese ? ['タスク 1', 'タスク 2', 'タスク 3'] : ['Task 1', 'Task 2', 'Task 3'] }; await fs.writeJson(this.config.configFile, defaultConfig, { spaces: 2 }); } else { // If config exists, initialize i18n with configured language const config = await this.getConfig(); await this.i18n.init(config.language || 'en'); } // Create initial task file if it doesn't exist if (!await fs.pathExists(this.config.taskFile)) { await this.createTaskFile('Initial Task', 'Welcome to Claude Task Manager!'); } // Create Claude Code custom command if .claude/commands directory exists await this.createClaudeCustomCommand(); // Update .gitignore await this.updateGitignore(); } catch (error) { throw new types_1.FileSystemError(this.i18n.t('errors.initFailed', { error: error instanceof Error ? error.message : 'Unknown error' }), this.config.workingDir, 'init'); } } async createNewTask(options = {}) { try { // Ensure i18n is initialized with project config if (!this.i18n.isInitialized()) { try { const config = await this.getConfig(); await this.i18n.init(config.language || 'en'); } catch { await this.i18n.init('en'); } } // Archive current task if it exists if (await fs.pathExists(this.config.taskFile)) { await this.archiveCurrentTask(); } // Create new task file const title = options.title || `Task ${(0, date_fns_1.format)(new Date(), 'yyyy-MM-dd HH:mm')}`; const description = options.description || this.i18n.t('commands.new.success', { title: '' }).replace('✅ ', '').replace(': ', ''); await this.createTaskFile(title, description, options); return this.config.taskFile; } catch (error) { throw new types_1.TaskManagerError(this.i18n.t('errors.createTaskFailed', { error: error instanceof Error ? error.message : 'Unknown error' }), 'CREATE_TASK_ERROR'); } } async createTaskFile(title, description, options = {}) { const template = await this.getTaskTemplate(); const config = await this.getConfig(); const isJapanese = config.language === 'ja'; // Helper function to format array to markdown list const formatArrayToMarkdown = (items, defaultValue) => { if (Array.isArray(items)) { if (items.length === 0) { return defaultValue; } // First item without bullet if it's a comment const firstItem = items[0]; const isComment = firstItem.startsWith('<!--') && firstItem.endsWith('-->'); let result = isComment ? firstItem : `- ${firstItem}`; // Rest of items with bullets for (let i = 1; i < items.length; i++) { result += `\n- ${items[i]}`; } return result; } return items || defaultValue; }; const defaultPrerequisites = isJapanese ? '<!-- 前提条件を記入してください -->' : '<!-- Add prerequisites here -->'; const defaultRules = isJapanese ? '<!-- ルールを記入してください -->' : '<!-- Add rules here -->'; const defaultTaskItems = isJapanese ? '- タスク 1\n- タスク 2\n- タスク 3' : '- Task 1\n- Task 2\n- Task 3'; const variables = { TITLE: title, DESCRIPTION: description, DATE: new Date().toISOString(), TIMESTAMP: (0, date_fns_1.format)(new Date(), 'yyyy-MM-dd HH:mm:ss'), PRIORITY: options.priority || 'medium', TAGS: options.tags?.join(', ') || '', PREREQUISITES: options.prerequisites || formatArrayToMarkdown(config.defaultPrerequisites, defaultPrerequisites), RULES: options.rules || formatArrayToMarkdown(config.defaultRules, defaultRules), TASKS: options.tasks || formatArrayToMarkdown(config.defaultTasks, defaultTaskItems) }; let content = template; Object.entries(variables).forEach(([key, value]) => { content = content.replace(new RegExp(`{{${key}}}`, 'g'), value); }); await fs.writeFile(this.config.taskFile, content); } async archiveCurrentTask() { try { if (!await fs.pathExists(this.config.taskFile)) { return null; } const now = new Date(); const timestamp = (0, date_fns_1.format)(now, 'yyyy-MM-dd_HH-mm-ss'); const milliseconds = now.getMilliseconds().toString().padStart(3, '0'); const archiveFileName = `${timestamp}-${milliseconds}_task.md`; const archivePath = path.join(this.config.archiveDir, archiveFileName); // Read current task and add archive metadata const currentContent = await fs.readFile(this.config.taskFile, 'utf8'); const archivedContent = `<!-- Archived: ${new Date().toISOString()} -->\n\n${currentContent}`; await fs.writeFile(archivePath, archivedContent); await fs.remove(this.config.taskFile); return archivePath; } catch (error) { throw new types_1.FileSystemError(`Failed to archive current task: ${error instanceof Error ? error.message : 'Unknown error'}`, this.config.taskFile, 'archive'); } } async runTask(verbose = false, debug = false, editPermission = true) { if (!await fs.pathExists(this.config.taskFile)) { throw new types_1.TaskManagerError('No task.md file found. Run "claude-task new" first.', 'NO_TASK_FILE'); } const startTime = Date.now(); try { const config = await this.getConfig(); const taskContent = await fs.readFile(this.config.taskFile, 'utf8'); // Execute Claude with task content const claudeCommand = config.claudeCommand || 'claude'; console.log(`\n🚀 Executing task with ${claudeCommand}...`); // Get relative path for display and prompt const taskPath = path.resolve(this.config.taskFile); const relativePath = path.relative(process.cwd(), taskPath); // Create prompt with @task.md reference and exit instruction const prompt = `Please execute the tasks in @${relativePath} and then exit. Do not enter interactive mode.`; // Show task file being executed console.log(chalk_1.default.gray(`📄 Task file: ${relativePath}`)); // Debug output if (debug) { console.log(chalk_1.default.gray(`🔍 Command: ${claudeCommand}`)); console.log(chalk_1.default.gray(`🔍 Full path: ${taskPath}`)); console.log(chalk_1.default.gray(`🔍 Prompt: ${prompt}`)); } let result; try { // Execute Claude with the prompt result = await this.executeClaude(prompt, claudeCommand, editPermission); console.log(chalk_1.default.green('\n✅ Claude Code execution completed')); } catch (error) { console.error(chalk_1.default.red('\n❌ Claude Code execution failed')); throw error; } const duration = Date.now() - startTime; const executionResult = { success: true, output: result, timestamp: new Date().toISOString(), duration }; // Update task file with execution log await this.logExecution(executionResult); return executionResult; } catch (error) { const duration = Date.now() - startTime; const executionResult = { success: false, output: '', error: error instanceof Error ? error.message : 'Unknown error', timestamp: new Date().toISOString(), duration }; await this.logExecution(executionResult); throw error; } } async executeClaude(prompt, claudeCommand = 'claude', editPermission = true) { return new Promise((resolve, reject) => { // Execute claude with the prompt in non-interactive mode // Use 'inherit' to allow Claude to show output in the terminal // Use --print flag to exit after completing the task // Use --dangerously-skip-permissions to allow file edits (if editPermission is true) const args = editPermission ? ['--dangerously-skip-permissions', '--print', prompt] : ['--print', prompt]; const claudeProcess = (0, child_process_1.spawn)(claudeCommand, args, { stdio: 'inherit', shell: false }); claudeProcess.on('close', (code) => { if (code === 0) { resolve('Claude Code execution completed'); } else { reject(new types_1.ClaudeExecutionError(`Claude Code failed with exit code ${code}`, code || undefined)); } }); claudeProcess.on('error', (error) => { reject(new types_1.ClaudeExecutionError(`Failed to start Claude Code: ${error.message}`)); }); }); } async recordExecution(result) { await this.logExecution(result); } async logExecution(result) { const status = result.success ? '✅ Success' : '❌ Failed'; const logEntry = `\n\n## Execution Log - ${(0, date_fns_1.format)(new Date(result.timestamp), 'yyyy-MM-dd HH:mm:ss')} (${result.duration}ms)\n\n**Status:** ${status}\n\n${result.success ? result.output : result.error}\n\n---\n`; await fs.appendFile(this.config.taskFile, logEntry); } async getHistory(limit = 10) { try { if (!await fs.pathExists(this.config.archiveDir)) { return []; } const files = await fs.readdir(this.config.archiveDir); const taskFiles = files .filter(file => file.endsWith('_task.md')) .sort() .reverse() .slice(0, limit); const history = []; for (const file of taskFiles) { const filePath = path.join(this.config.archiveDir, file); const content = await fs.readFile(filePath, 'utf8'); const stats = await fs.stat(filePath); const titleMatch = content.match(/# (.+)/); const title = titleMatch ? titleMatch[1] : 'Untitled'; const date = file.split('_')[0]; history.push({ file, title, date, path: filePath, size: stats.size }); } return history; } catch (error) { throw new types_1.FileSystemError(this.i18n.t('errors.historyFailed', { error: error instanceof Error ? error.message : 'Unknown error' }), this.config.archiveDir, 'read'); } } async getStatus() { try { const currentTaskExists = await fs.pathExists(this.config.taskFile); let currentTask = null; let currentTaskSize; if (currentTaskExists) { const content = await fs.readFile(this.config.taskFile, 'utf8'); const stats = await fs.stat(this.config.taskFile); const titleMatch = content.match(/# (.+)/); currentTask = titleMatch ? titleMatch[1] : 'Untitled Task'; currentTaskSize = stats.size; } const archiveFiles = await fs.pathExists(this.config.archiveDir) ? (await fs.readdir(this.config.archiveDir)).filter(f => f.endsWith('_task.md')) : []; // Get last run time and count executions from task file let lastRun = null; let totalExecutions = 0; if (currentTaskExists) { const content = await fs.readFile(this.config.taskFile, 'utf8'); const logMatches = content.match(/## Execution Log - (.+?) \(/g); if (logMatches && logMatches.length > 0) { totalExecutions = logMatches.length; const lastLogMatch = logMatches[logMatches.length - 1]; const timeMatch = lastLogMatch.match(/## Execution Log - (.+?) \(/); lastRun = timeMatch ? timeMatch[1] : null; } } return { currentTask, archivedCount: archiveFiles.length, lastRun, totalExecutions, currentTaskSize }; } catch (error) { throw new types_1.TaskManagerError(this.i18n.t('errors.statusFailed', { error: error instanceof Error ? error.message : 'Unknown error' }), 'GET_STATUS_ERROR'); } } async getTaskTemplate() { try { const config = await this.getConfig(); return config.taskTemplate || this.getDefaultTaskTemplate(); } catch { return this.getDefaultTaskTemplate(); } } getDefaultTaskTemplate() { try { const t = this.i18n.getNamespace('taskTemplate'); // Check if namespace is properly loaded if (!t || !t.title) { throw new Error('i18n taskTemplate namespace not loaded'); } return `${t.title} ${t.created} ${t.priority} ${t.tags} ${t.description} ${t.prerequisites} ${t.rules} ${t.tasks} ${t.context} ${t.notes} ${t.footer} `; } catch { // Fallback template if i18n is not initialized return `# {{TITLE}} **Created:** {{DATE}} **Priority:** {{PRIORITY}} **Tags:** {{TAGS}} ## Description {{DESCRIPTION}} ## Prerequisites {{PREREQUISITES}} ## Rules {{RULES}} ## Tasks {{TASKS}} ## Context <!-- Add context for Claude Code execution --> ## Notes <!-- Add your notes here --> --- *Generated by Claude Task Manager*`; } } async getConfig() { try { if (await fs.pathExists(this.config.configFile)) { return await fs.readJson(this.config.configFile); } return { created: new Date().toISOString(), taskTemplate: this.getDefaultTaskTemplate(), claudeCommand: 'claude' }; } catch (error) { throw new types_1.FileSystemError(this.i18n.t('errors.configFailed', { error: error instanceof Error ? error.message : 'Unknown error' }), this.config.configFile, 'read'); } } async updateConfig(updates) { try { const currentConfig = await this.getConfig(); let newConfig = { ...currentConfig, ...updates }; // Update i18n language if changed if (updates.language && updates.language !== this.i18n.getLanguage()) { await this.i18n.init(updates.language); // Update language-specific defaults if they haven't been customized const isJapanese = updates.language === 'ja'; // Only update defaults if they match the old language defaults const isDefaultPrerequisites = (val) => { if (typeof val === 'string') { return val === '<!-- Add prerequisites here -->' || val === '<!-- 前提条件を記入してください -->'; } if (Array.isArray(val) && val.length === 1) { return val[0] === '<!-- Add prerequisites here -->' || val[0] === '<!-- 前提条件を記入してください -->'; } return false; }; const isDefaultRules = (val) => { if (typeof val === 'string') { return val === '<!-- Add rules here -->' || val === '<!-- ルールを記入してください -->'; } if (Array.isArray(val) && val.length === 1) { return val[0] === '<!-- Add rules here -->' || val[0] === '<!-- ルールを記入してください -->'; } return false; }; const isDefaultTasks = (val) => { if (typeof val === 'string') { return val === '- [ ] Task 1\n- [ ] Task 2\n- [ ] Task 3' || val === '- [ ] タスク 1\n- [ ] タスク 2\n- [ ] タスク 3' || val === '- Task 1\n- Task 2\n- Task 3' || val === '- タスク 1\n- タスク 2\n- タスク 3'; } if (Array.isArray(val) && val.length === 3) { return (val[0] === 'Task 1' && val[1] === 'Task 2' && val[2] === 'Task 3') || (val[0] === 'タスク 1' && val[1] === 'タスク 2' && val[2] === 'タスク 3'); } return false; }; if (isDefaultPrerequisites(currentConfig.defaultPrerequisites)) { newConfig.defaultPrerequisites = isJapanese ? ['<!-- 前提条件を記入してください -->'] : ['<!-- Add prerequisites here -->']; } if (isDefaultRules(currentConfig.defaultRules)) { newConfig.defaultRules = isJapanese ? ['<!-- ルールを記入してください -->'] : ['<!-- Add rules here -->']; } if (isDefaultTasks(currentConfig.defaultTasks)) { newConfig.defaultTasks = isJapanese ? ['タスク 1', 'タスク 2', 'タスク 3'] : ['Task 1', 'Task 2', 'Task 3']; } if (currentConfig.defaultTaskTitle === 'New Task' || currentConfig.defaultTaskTitle === '新しいタスク') { newConfig.defaultTaskTitle = isJapanese ? '新しいタスク' : 'New Task'; } // Update the task template with the new language newConfig.taskTemplate = this.getDefaultTaskTemplate(); } await fs.writeJson(this.config.configFile, newConfig, { spaces: 2 }); } catch (error) { throw new types_1.FileSystemError(`Failed to update config: ${error instanceof Error ? error.message : 'Unknown error'}`, this.config.configFile, 'write'); } } async createClaudeCustomCommand() { const claudeDir = path.join(this.config.workingDir, '.claude'); const claudeCommandsDir = path.join(claudeDir, 'commands'); try { // Check if .claude directory exists if (await fs.pathExists(claudeDir)) { // Create commands directory inside existing .claude directory await fs.ensureDir(claudeCommandsDir); const taskCommandPath = path.join(claudeCommandsDir, 'task.md'); // Generate custom command content based on current language const taskCommandContent = this.generateCustomCommandContent(); await fs.writeFile(taskCommandPath, taskCommandContent); console.log(this.i18n.t('commands.init.customCommand')); } } catch (error) { // If we can't create the custom command, just log a warning and continue console.warn('Could not create Claude custom command:', error); } } generateCustomCommandContent() { // Always generate English version for better Claude Code compatibility return `# /task - Claude Task Manager Task management custom command for Claude Code. ## Usage \`/task <action> [options]\` ## Actions ### Create New Task \`/task new "<task name>" [--priority high|medium|low] [--tags tag1,tag2]\` Creates a task and saves it to task.md file. ### Check Current Task \`/task status\` Shows current task and its progress. ### Execute Task \`/task run\` Executes current task with Claude Code using task.md content as context. ### Task History \`/task history [--limit n]\` Shows past and archived tasks. ### Archive Task \`/task archive\` Moves completed task to archive folder. ## Implementation This command uses the \`claude-task\` CLI tool for task management. ### How to Process Commands **IMPORTANT**: Follow these instructions to execute actual CLI commands using the Bash tool. 1. When \`/task new "task name"\` is executed: \`\`\`bash claude-task new "task name" \`\`\` - Execute this command using the Bash tool - Pass optional arguments appropriately (e.g., \`--priority high --tags auth,backend\`) 2. When \`/task status\` is executed: \`\`\`bash claude-task status \`\`\` - Execute this command using the Bash tool 3. When \`/task run\` is executed: - Reference the \`@task.md\` file content - Execute work according to the task content 4. When \`/task history\` is executed: \`\`\`bash claude-task history \`\`\` - Execute this command using the Bash tool - Add \`--limit\` option if provided 5. When \`/task archive\` is executed: \`\`\`bash claude-task archive \`\`\` - Execute this command using the Bash tool ### Process Flow \`\`\` User: /task new "Implement new feature" ↓ Claude: Execute \`claude-task new "Implement new feature"\` using Bash tool ↓ Display result \`\`\` ## Examples 1. Create new task: \`\`\` /task new "Implement user authentication" --priority high --tags auth,backend \`\`\` → Execute with Bash: \`claude-task new "Implement user authentication" --priority high --tags auth,backend\` 2. Check current task: \`\`\` /task status \`\`\` → Execute with Bash: \`claude-task status\` 3. Execute task: \`\`\` /task run \`\`\` → Reference @task.md and execute work according to content 4. View task history: \`\`\` /task history --limit 10 \`\`\` → Execute with Bash: \`claude-task history --limit 10\` 5. Archive completed task: \`\`\` /task archive \`\`\` → Execute with Bash: \`claude-task archive\` ## Important Notes - **All commands must be executed using the Bash tool to run actual \`claude-task\` CLI commands** - Task names with spaces must be enclosed in quotes - Only \`/task run\` requires special processing (reference @task.md and execute)`; } async getTaskContent() { if (!await fs.pathExists(this.config.taskFile)) { throw new types_1.TaskManagerError(this.i18n.t('errors.noTaskFile'), 'NO_TASK_FILE'); } return await fs.readFile(this.config.taskFile, 'utf8'); } async getLanguage() { const config = await this.getConfig(); return config.language || 'en'; } async setLanguage(lang) { await this.updateConfig({ language: lang }); } async updateGitignore() { const gitignorePath = path.join(this.config.workingDir, '.gitignore'); const entriesToAdd = [ '# Claude Task Manager', 'task.md', 'archive/', '.claude-tasks/', '', '# Temporary task files', '*.tmp.md', 'task.*.md', '', '# Claude Code custom commands (if you want to exclude them)', '# .claude/commands/task.md' ]; try { let content = ''; let existingEntries = new Set(); // Read existing .gitignore if it exists if (await fs.pathExists(gitignorePath)) { content = await fs.readFile(gitignorePath, 'utf8'); // Parse existing entries (normalize by removing comments and whitespace) existingEntries = new Set(content.split('\n') .map(line => line.trim()) .filter(line => line && !line.startsWith('#'))); } // Check if we need to add any entries const newEntries = []; let needsUpdate = false; for (const entry of entriesToAdd) { if (entry.startsWith('#') || entry === '') { // Always add comments and empty lines for formatting newEntries.push(entry); } else if (!existingEntries.has(entry)) { // Only add if not already present newEntries.push(entry); needsUpdate = true; } } // Only update if there are new entries to add if (needsUpdate) { // Add a newline before our section if file exists and doesn't end with newline if (content && !content.endsWith('\n')) { content += '\n'; } // Add another newline if file has content to separate our section if (content) { content += '\n'; } content += newEntries.join('\n'); // Ensure file ends with newline if (!content.endsWith('\n')) { content += '\n'; } await fs.writeFile(gitignorePath, content); console.log(this.i18n.t('commands.init.gitignoreUpdated') || '✅ Updated .gitignore with task-related entries'); } } catch (error) { // Don't fail init if .gitignore update fails console.warn('Warning: Could not update .gitignore:', error); } } } exports.TaskManager = TaskManager; //# sourceMappingURL=TaskManager.js.map