423 lines
16 KiB
JavaScript
423 lines
16 KiB
JavaScript
/**
|
|
* tools/utils.js
|
|
* Utility functions for Task Master CLI integration
|
|
*/
|
|
|
|
import { spawnSync } from "child_process";
|
|
import path from "path";
|
|
import fs from 'fs';
|
|
import { contextManager } from '../core/context-manager.js'; // Import the singleton
|
|
|
|
// Import path utilities to ensure consistent path resolution
|
|
import { lastFoundProjectRoot, PROJECT_MARKERS } from '../core/utils/path-utils.js';
|
|
|
|
/**
|
|
* Get normalized project root path
|
|
* @param {string|undefined} projectRootRaw - Raw project root from arguments
|
|
* @param {Object} log - Logger object
|
|
* @returns {string} - Normalized absolute path to project root
|
|
*/
|
|
function getProjectRoot(projectRootRaw, log) {
|
|
// PRECEDENCE ORDER:
|
|
// 1. Environment variable override
|
|
// 2. Explicitly provided projectRoot in args
|
|
// 3. Previously found/cached project root
|
|
// 4. Current directory if it has project markers
|
|
// 5. Current directory with warning
|
|
|
|
// 1. Check for environment variable override
|
|
if (process.env.TASK_MASTER_PROJECT_ROOT) {
|
|
const envRoot = process.env.TASK_MASTER_PROJECT_ROOT;
|
|
const absolutePath = path.isAbsolute(envRoot)
|
|
? envRoot
|
|
: path.resolve(process.cwd(), envRoot);
|
|
log.info(`Using project root from TASK_MASTER_PROJECT_ROOT environment variable: ${absolutePath}`);
|
|
return absolutePath;
|
|
}
|
|
|
|
// 2. If project root is explicitly provided, use it
|
|
if (projectRootRaw) {
|
|
const absolutePath = path.isAbsolute(projectRootRaw)
|
|
? projectRootRaw
|
|
: path.resolve(process.cwd(), projectRootRaw);
|
|
|
|
log.info(`Using explicitly provided project root: ${absolutePath}`);
|
|
return absolutePath;
|
|
}
|
|
|
|
// 3. If we have a last found project root from a tasks.json search, use that for consistency
|
|
if (lastFoundProjectRoot) {
|
|
log.info(`Using last known project root where tasks.json was found: ${lastFoundProjectRoot}`);
|
|
return lastFoundProjectRoot;
|
|
}
|
|
|
|
// 4. Check if the current directory has any indicators of being a task-master project
|
|
const currentDir = process.cwd();
|
|
if (PROJECT_MARKERS.some(marker => {
|
|
const markerPath = path.join(currentDir, marker);
|
|
return fs.existsSync(markerPath);
|
|
})) {
|
|
log.info(`Using current directory as project root (found project markers): ${currentDir}`);
|
|
return currentDir;
|
|
}
|
|
|
|
// 5. Default to current working directory but warn the user
|
|
log.warn(`No task-master project detected in current directory. Using ${currentDir} as project root.`);
|
|
log.warn('Consider using --project-root to specify the correct project location or set TASK_MASTER_PROJECT_ROOT environment variable.');
|
|
return currentDir;
|
|
}
|
|
|
|
/**
|
|
* Extracts the project root path from the FastMCP session object.
|
|
* @param {Object} session - The FastMCP session object.
|
|
* @param {Object} log - Logger object.
|
|
* @returns {string|null} - The absolute path to the project root, or null if not found.
|
|
*/
|
|
function getProjectRootFromSession(session, log) {
|
|
try {
|
|
// Add detailed logging of session structure
|
|
log.info(`Session object: ${JSON.stringify({
|
|
hasSession: !!session,
|
|
hasRoots: !!session?.roots,
|
|
rootsType: typeof session?.roots,
|
|
isRootsArray: Array.isArray(session?.roots),
|
|
rootsLength: session?.roots?.length,
|
|
firstRoot: session?.roots?.[0],
|
|
hasRootsRoots: !!session?.roots?.roots,
|
|
rootsRootsType: typeof session?.roots?.roots,
|
|
isRootsRootsArray: Array.isArray(session?.roots?.roots),
|
|
rootsRootsLength: session?.roots?.roots?.length,
|
|
firstRootsRoot: session?.roots?.roots?.[0]
|
|
})}`);
|
|
|
|
// ALWAYS ensure we return a valid path for project root
|
|
const cwd = process.cwd();
|
|
|
|
// If we have a session with roots array
|
|
if (session?.roots?.[0]?.uri) {
|
|
const rootUri = session.roots[0].uri;
|
|
log.info(`Found rootUri in session.roots[0].uri: ${rootUri}`);
|
|
const rootPath = rootUri.startsWith('file://')
|
|
? decodeURIComponent(rootUri.slice(7))
|
|
: rootUri;
|
|
log.info(`Decoded rootPath: ${rootPath}`);
|
|
return rootPath;
|
|
}
|
|
|
|
// If we have a session with roots.roots array (different structure)
|
|
if (session?.roots?.roots?.[0]?.uri) {
|
|
const rootUri = session.roots.roots[0].uri;
|
|
log.info(`Found rootUri in session.roots.roots[0].uri: ${rootUri}`);
|
|
const rootPath = rootUri.startsWith('file://')
|
|
? decodeURIComponent(rootUri.slice(7))
|
|
: rootUri;
|
|
log.info(`Decoded rootPath: ${rootPath}`);
|
|
return rootPath;
|
|
}
|
|
|
|
// Get the server's location and try to find project root -- this is a fallback necessary in Cursor IDE
|
|
const serverPath = process.argv[1]; // This should be the path to server.js, which is in mcp-server/
|
|
if (serverPath && serverPath.includes('mcp-server')) {
|
|
// Find the mcp-server directory first
|
|
const mcpServerIndex = serverPath.indexOf('mcp-server');
|
|
if (mcpServerIndex !== -1) {
|
|
// Get the path up to mcp-server, which should be the project root
|
|
const projectRoot = serverPath.substring(0, mcpServerIndex - 1); // -1 to remove trailing slash
|
|
|
|
// Verify this looks like our project root by checking for key files/directories
|
|
if (fs.existsSync(path.join(projectRoot, '.cursor')) ||
|
|
fs.existsSync(path.join(projectRoot, 'mcp-server')) ||
|
|
fs.existsSync(path.join(projectRoot, 'package.json'))) {
|
|
log.info(`Found project root from server path: ${projectRoot}`);
|
|
return projectRoot;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ALWAYS ensure we return a valid path as a last resort
|
|
log.info(`Using current working directory as ultimate fallback: ${cwd}`);
|
|
return cwd;
|
|
} catch (e) {
|
|
// If we have a server path, use it as a basis for project root
|
|
const serverPath = process.argv[1];
|
|
if (serverPath && serverPath.includes('mcp-server')) {
|
|
const mcpServerIndex = serverPath.indexOf('mcp-server');
|
|
return mcpServerIndex !== -1 ? serverPath.substring(0, mcpServerIndex - 1) : process.cwd();
|
|
}
|
|
|
|
// Only use cwd if it's not "/"
|
|
const cwd = process.cwd();
|
|
return cwd !== '/' ? cwd : '/';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle API result with standardized error handling and response formatting
|
|
* @param {Object} result - Result object from API call with success, data, and error properties
|
|
* @param {Object} log - Logger object
|
|
* @param {string} errorPrefix - Prefix for error messages
|
|
* @param {Function} processFunction - Optional function to process successful result data
|
|
* @returns {Object} - Standardized MCP response object
|
|
*/
|
|
function handleApiResult(result, log, errorPrefix = 'API error', processFunction = processMCPResponseData) {
|
|
if (!result.success) {
|
|
const errorMsg = result.error?.message || `Unknown ${errorPrefix}`;
|
|
// Include cache status in error logs
|
|
log.error(`${errorPrefix}: ${errorMsg}. From cache: ${result.fromCache}`); // Keep logging cache status on error
|
|
return createErrorResponse(errorMsg);
|
|
}
|
|
|
|
// Process the result data if needed
|
|
const processedData = processFunction ? processFunction(result.data) : result.data;
|
|
|
|
// Log success including cache status
|
|
log.info(`Successfully completed operation. From cache: ${result.fromCache}`); // Add success log with cache status
|
|
|
|
// Create the response payload including the fromCache flag
|
|
const responsePayload = {
|
|
fromCache: result.fromCache, // Get the flag from the original 'result'
|
|
data: processedData // Nest the processed data under a 'data' key
|
|
};
|
|
|
|
// Pass this combined payload to createContentResponse
|
|
return createContentResponse(responsePayload);
|
|
}
|
|
|
|
/**
|
|
* Executes a task-master CLI command synchronously.
|
|
* @param {string} command - The command to execute (e.g., 'add-task')
|
|
* @param {Object} log - Logger instance
|
|
* @param {Array} args - Arguments for the command
|
|
* @param {string|undefined} projectRootRaw - Optional raw project root path (will be normalized internally)
|
|
* @param {Object|null} customEnv - Optional object containing environment variables to pass to the child process
|
|
* @returns {Object} - The result of the command execution
|
|
*/
|
|
function executeTaskMasterCommand(
|
|
command,
|
|
log,
|
|
args = [],
|
|
projectRootRaw = null,
|
|
customEnv = null // Changed from session to customEnv
|
|
) {
|
|
try {
|
|
// Normalize project root internally using the getProjectRoot utility
|
|
const cwd = getProjectRoot(projectRootRaw, log);
|
|
|
|
log.info(
|
|
`Executing task-master ${command} with args: ${JSON.stringify(
|
|
args
|
|
)} in directory: ${cwd}`
|
|
);
|
|
|
|
// Prepare full arguments array
|
|
const fullArgs = [command, ...args];
|
|
|
|
// Common options for spawn
|
|
const spawnOptions = {
|
|
encoding: "utf8",
|
|
cwd: cwd,
|
|
// Merge process.env with customEnv, giving precedence to customEnv
|
|
env: { ...process.env, ...(customEnv || {}) }
|
|
};
|
|
|
|
// Log the environment being passed (optional, for debugging)
|
|
// log.info(`Spawn options env: ${JSON.stringify(spawnOptions.env)}`);
|
|
|
|
// Execute the command using the global task-master CLI or local script
|
|
// Try the global CLI first
|
|
let result = spawnSync("task-master", fullArgs, spawnOptions);
|
|
|
|
// If global CLI is not available, try fallback to the local script
|
|
if (result.error && result.error.code === "ENOENT") {
|
|
log.info("Global task-master not found, falling back to local script");
|
|
// Pass the same spawnOptions (including env) to the fallback
|
|
result = spawnSync("node", ["scripts/dev.js", ...fullArgs], spawnOptions);
|
|
}
|
|
|
|
if (result.error) {
|
|
throw new Error(`Command execution error: ${result.error.message}`);
|
|
}
|
|
|
|
if (result.status !== 0) {
|
|
// Improve error handling by combining stderr and stdout if stderr is empty
|
|
const errorOutput = result.stderr
|
|
? result.stderr.trim()
|
|
: result.stdout
|
|
? result.stdout.trim()
|
|
: "Unknown error";
|
|
throw new Error(
|
|
`Command failed with exit code ${result.status}: ${errorOutput}`
|
|
);
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
stdout: result.stdout,
|
|
stderr: result.stderr,
|
|
};
|
|
} catch (error) {
|
|
log.error(`Error executing task-master command: ${error.message}`);
|
|
return {
|
|
success: false,
|
|
error: error.message,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks cache for a result using the provided key. If not found, executes the action function,
|
|
* caches the result upon success, and returns the result.
|
|
*
|
|
* @param {Object} options - Configuration options.
|
|
* @param {string} options.cacheKey - The unique key for caching this operation's result.
|
|
* @param {Function} options.actionFn - The async function to execute if the cache misses.
|
|
* Should return an object like { success: boolean, data?: any, error?: { code: string, message: string } }.
|
|
* @param {Object} options.log - The logger instance.
|
|
* @returns {Promise<Object>} - An object containing the result, indicating if it was from cache.
|
|
* Format: { success: boolean, data?: any, error?: { code: string, message: string }, fromCache: boolean }
|
|
*/
|
|
async function getCachedOrExecute({ cacheKey, actionFn, log }) {
|
|
// Check cache first
|
|
const cachedResult = contextManager.getCachedData(cacheKey);
|
|
|
|
if (cachedResult !== undefined) {
|
|
log.info(`Cache hit for key: ${cacheKey}`);
|
|
// Return the cached data in the same structure as a fresh result
|
|
return {
|
|
...cachedResult, // Spread the cached result to maintain its structure
|
|
fromCache: true // Just add the fromCache flag
|
|
};
|
|
}
|
|
|
|
log.info(`Cache miss for key: ${cacheKey}. Executing action function.`);
|
|
|
|
// Execute the action function if cache missed
|
|
const result = await actionFn();
|
|
|
|
// If the action was successful, cache the result (but without fromCache flag)
|
|
if (result.success && result.data !== undefined) {
|
|
log.info(`Action successful. Caching result for key: ${cacheKey}`);
|
|
// Cache the entire result structure (minus the fromCache flag)
|
|
const { fromCache, ...resultToCache } = result;
|
|
contextManager.setCachedData(cacheKey, resultToCache);
|
|
} else if (!result.success) {
|
|
log.warn(`Action failed for cache key ${cacheKey}. Result not cached. Error: ${result.error?.message}`);
|
|
} else {
|
|
log.warn(`Action for cache key ${cacheKey} succeeded but returned no data. Result not cached.`);
|
|
}
|
|
|
|
// Return the fresh result, indicating it wasn't from cache
|
|
return {
|
|
...result,
|
|
fromCache: false
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Recursively removes specified fields from task objects, whether single or in an array.
|
|
* Handles common data structures returned by task commands.
|
|
* @param {Object|Array} taskOrData - A single task object or a data object containing a 'tasks' array.
|
|
* @param {string[]} fieldsToRemove - An array of field names to remove.
|
|
* @returns {Object|Array} - The processed data with specified fields removed.
|
|
*/
|
|
function processMCPResponseData(taskOrData, fieldsToRemove = ['details', 'testStrategy']) {
|
|
if (!taskOrData) {
|
|
return taskOrData;
|
|
}
|
|
|
|
// Helper function to process a single task object
|
|
const processSingleTask = (task) => {
|
|
if (typeof task !== 'object' || task === null) {
|
|
return task;
|
|
}
|
|
|
|
const processedTask = { ...task };
|
|
|
|
// Remove specified fields from the task
|
|
fieldsToRemove.forEach(field => {
|
|
delete processedTask[field];
|
|
});
|
|
|
|
// Recursively process subtasks if they exist and are an array
|
|
if (processedTask.subtasks && Array.isArray(processedTask.subtasks)) {
|
|
// Use processArrayOfTasks to handle the subtasks array
|
|
processedTask.subtasks = processArrayOfTasks(processedTask.subtasks);
|
|
}
|
|
|
|
return processedTask;
|
|
};
|
|
|
|
// Helper function to process an array of tasks
|
|
const processArrayOfTasks = (tasks) => {
|
|
return tasks.map(processSingleTask);
|
|
};
|
|
|
|
// Check if the input is a data structure containing a 'tasks' array (like from listTasks)
|
|
if (typeof taskOrData === 'object' && taskOrData !== null && Array.isArray(taskOrData.tasks)) {
|
|
return {
|
|
...taskOrData, // Keep other potential fields like 'stats', 'filter'
|
|
tasks: processArrayOfTasks(taskOrData.tasks),
|
|
};
|
|
}
|
|
// Check if the input is likely a single task object (add more checks if needed)
|
|
else if (typeof taskOrData === 'object' && taskOrData !== null && 'id' in taskOrData && 'title' in taskOrData) {
|
|
return processSingleTask(taskOrData);
|
|
}
|
|
// Check if the input is an array of tasks directly (less common but possible)
|
|
else if (Array.isArray(taskOrData)) {
|
|
return processArrayOfTasks(taskOrData);
|
|
}
|
|
|
|
// If it doesn't match known task structures, return it as is
|
|
return taskOrData;
|
|
}
|
|
|
|
/**
|
|
* Creates standard content response for tools
|
|
* @param {string|Object} content - Content to include in response
|
|
* @returns {Object} - Content response object in FastMCP format
|
|
*/
|
|
function createContentResponse(content) {
|
|
// FastMCP requires text type, so we format objects as JSON strings
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: typeof content === 'object' ?
|
|
// Format JSON nicely with indentation
|
|
JSON.stringify(content, null, 2) :
|
|
// Keep other content types as-is
|
|
String(content)
|
|
}
|
|
]
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Creates error response for tools
|
|
* @param {string} errorMessage - Error message to include in response
|
|
* @returns {Object} - Error content response object in FastMCP format
|
|
*/
|
|
export function createErrorResponse(errorMessage) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Error: ${errorMessage}`
|
|
}
|
|
],
|
|
isError: true
|
|
};
|
|
}
|
|
|
|
// Ensure all functions are exported
|
|
export {
|
|
getProjectRoot,
|
|
getProjectRootFromSession,
|
|
handleApiResult,
|
|
executeTaskMasterCommand,
|
|
getCachedOrExecute,
|
|
processMCPResponseData,
|
|
createContentResponse,
|
|
};
|