eat(models): Add MCP support for models command and improve configuration docs
This commit implements several related improvements to the models command and configuration system: - Added MCP support for the models command: - Created new direct function implementation in models.js - Registered modelsDirect in task-master-core.js for proper export - Added models tool registration in tools/index.js - Ensured project name replacement when copying .taskmasterconfig in init.js - Improved .taskmasterconfig copying during project initialization: - Added copyTemplateFile() call in createProjectStructure() - Ensured project name is properly replaced in the config - Restructured tool registration in logical workflow groups: - Organized registration into 6 functional categories - Improved command ordering to follow typical workflow - Added clear group comments for maintainability - Enhanced documentation in cursor rules: - Updated dev_workflow.mdc with clearer config management instructions - Added comprehensive models command reference to taskmaster.mdc - Clarified CLI vs MCP usage patterns and options - Added warning against manual .taskmasterconfig editing
This commit is contained in:
8
.changeset/violet-parrots-march.md
Normal file
8
.changeset/violet-parrots-march.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
'task-master-ai': patch
|
||||
---
|
||||
|
||||
- Adds a 'models' CLI and MCP command to get the current model configuration, available models, and gives the ability to set main/research/fallback models."
|
||||
- In the CLI, `task-master models` shows the current models config. Using the `--setup` flag launches an interactive set up that allows you to easily select the models you want to use for each of the three roles. Use `q` during the interactive setup to cancel the setup.
|
||||
- In the MCP, responses are simplified in RESTful format (instead of the full CLI output). The agent can use the `models` tool with different arguments, including `listAvailableModels` to get available models. Run without arguments, it will return the current configuration. Arguments are available to set the model for each of the three roles. This allows you to manage Taskmaster AI providers and models directly from either the CLI or MCP or both.
|
||||
- Updated the CLI help menu when you run `task-master` to include missing commands and .taskmasterconfig information.
|
||||
@@ -107,8 +107,8 @@ Taskmaster configuration is managed through two main mechanisms:
|
||||
* Located in the project root directory.
|
||||
* Stores most configuration settings: AI model selections (main, research, fallback), parameters (max tokens, temperature), logging level, default subtasks/priority, project name, etc.
|
||||
* **Managed via `task-master models --setup` command.** Do not edit manually unless you know what you are doing.
|
||||
* **View/Set specific models via `task-master models` command or `models` MCP tool.**
|
||||
* Created automatically when you run `task-master models --setup` for the first time.
|
||||
* View the current configuration using `task-master models`.
|
||||
|
||||
2. **Environment Variables (`.env` / `mcp.json`):**
|
||||
* Used **only** for sensitive API keys and specific endpoint URLs.
|
||||
@@ -116,7 +116,7 @@ Taskmaster configuration is managed through two main mechanisms:
|
||||
* For MCP/Cursor integration, configure these keys in the `env` section of `.cursor/mcp.json`.
|
||||
* Available keys/variables: See `assets/env.example` or the `Configuration` section in [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc).
|
||||
|
||||
**Important:** Non-API key settings (like `MODEL`, `MAX_TOKENS`, `LOG_LEVEL`) are **no longer configured via environment variables**. Use `task-master models --setup` instead.
|
||||
**Important:** Non-API key settings (like model selections, `MAX_TOKENS`, `LOG_LEVEL`) are **no longer configured via environment variables**. Use the `task-master models` command (or `--setup` for interactive configuration) or the `models` MCP tool.
|
||||
**If AI commands FAIL in MCP** verify that the API key for the selected provider is present in the mcp.json
|
||||
**If AI commands FAIL in CLI** verify that the API key for the selected provider is present in the .env in the root of the project.
|
||||
|
||||
@@ -215,5 +215,10 @@ Once a task has been broken down into subtasks using `expand_task` or similar me
|
||||
`rg "export (async function|function|const) \w+"` or similar patterns.
|
||||
- Can help compare functions between files during migrations or identify potential naming conflicts.
|
||||
|
||||
---
|
||||
*This workflow provides a general guideline. Adapt it based on your specific project needs and team practices.*
|
||||
`rg "export (async function|function|const) \w+"` or similar patterns.
|
||||
- Can help compare functions between files during migrations or identify potential naming conflicts.
|
||||
|
||||
---
|
||||
*This workflow provides a general guideline. Adapt it based on your specific project needs and team practices.*
|
||||
@@ -55,6 +55,31 @@ This document provides a detailed reference for interacting with Taskmaster, cov
|
||||
|
||||
---
|
||||
|
||||
## AI Model Configuration
|
||||
|
||||
### 2. Manage Models (`models`)
|
||||
* **MCP Tool:** `models`
|
||||
* **CLI Command:** `task-master models [options]`
|
||||
* **Description:** `View the current AI model configuration or set specific models for different roles (main, research, fallback).`
|
||||
* **Key MCP Parameters/Options:**
|
||||
* `setMain <model_id>`: `Set the primary model ID for task generation/updates.` (CLI: `--set-main <model_id>`)
|
||||
* `setResearch <model_id>`: `Set the model ID for research-backed operations.` (CLI: `--set-research <model_id>`)
|
||||
* `setFallback <model_id>`: `Set the model ID to use if the primary fails.` (CLI: `--set-fallback <model_id>`)
|
||||
* `listAvailableModels <boolean>`: `If true, lists available models not currently assigned to a role.` (CLI: No direct equivalent; CLI lists available automatically)
|
||||
* `projectRoot <string>`: `Optional. Absolute path to the project root directory.` (CLI: Determined automatically)
|
||||
* **Key CLI Options:**
|
||||
* `--set-main <model_id>`: `Set the primary model.`
|
||||
* `--set-research <model_id>`: `Set the research model.`
|
||||
* `--set-fallback <model_id>`: `Set the fallback model.`
|
||||
* `--setup`: `Run interactive setup to configure models and other settings.`
|
||||
* **Usage (MCP):** Call without set flags to get current config. Use `setMain`, `setResearch`, or `setFallback` with a valid model ID to update the configuration. Use `listAvailableModels: true` to get a list of unassigned models.
|
||||
* **Usage (CLI):** Run without flags to view current configuration and available models. Use set flags to update specific roles. Use `--setup` for guided configuration.
|
||||
* **Notes:** Configuration is stored in `.taskmasterconfig` in the project root. This command/tool modifies that file. Use `listAvailableModels` to ensure the selected model is supported.
|
||||
* **API note:** API keys for selected AI providers (based on their model) need to exist in the mcp.json file to be accessible in MCP context. The API keys must be present in the local .env file for the CLI to be able to read them.
|
||||
* **Warning:** DO NOT MANUALLY EDIT THE .taskmasterconfig FILE. Use the included commands either in the MCP or CLI format as needed. Always prioritize MCP tools when available and use the CLI as a fallback.
|
||||
|
||||
---
|
||||
|
||||
## Task Listing & Viewing
|
||||
|
||||
### 3. Get Tasks (`get_tasks`)
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"provider": "anthropic",
|
||||
"modelId": "claude-3.5-sonnet-20240620",
|
||||
"maxTokens": 120000,
|
||||
"temperature": 0.1
|
||||
"temperature": 0.2
|
||||
}
|
||||
},
|
||||
"global": {
|
||||
|
||||
30
assets/.taskmasterconfig
Normal file
30
assets/.taskmasterconfig
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"models": {
|
||||
"main": {
|
||||
"provider": "anthropic",
|
||||
"modelId": "claude-3-7-sonnet-20250219",
|
||||
"maxTokens": 120000,
|
||||
"temperature": 0.2
|
||||
},
|
||||
"research": {
|
||||
"provider": "perplexity",
|
||||
"modelId": "sonar-pro",
|
||||
"maxTokens": 8700,
|
||||
"temperature": 0.1
|
||||
},
|
||||
"fallback": {
|
||||
"provider": "anthropic",
|
||||
"modelId": "claude-3.5-sonnet-20240620",
|
||||
"maxTokens": 120000,
|
||||
"temperature": 0.1
|
||||
}
|
||||
},
|
||||
"global": {
|
||||
"logLevel": "info",
|
||||
"debug": false,
|
||||
"defaultSubtasks": 5,
|
||||
"defaultPriority": "medium",
|
||||
"projectName": "Taskmaster",
|
||||
"ollamaBaseUrl": "http://localhost:11434/api"
|
||||
}
|
||||
}
|
||||
98
mcp-server/src/core/direct-functions/models.js
Normal file
98
mcp-server/src/core/direct-functions/models.js
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* models.js
|
||||
* Direct function for managing AI model configurations via MCP
|
||||
*/
|
||||
|
||||
import {
|
||||
getModelConfiguration,
|
||||
getAvailableModelsList,
|
||||
setModel
|
||||
} from '../../../../scripts/modules/task-manager/models.js';
|
||||
import {
|
||||
enableSilentMode,
|
||||
disableSilentMode
|
||||
} from '../../../../scripts/modules/utils.js';
|
||||
|
||||
/**
|
||||
* Get or update model configuration
|
||||
* @param {Object} args - Arguments passed by the MCP tool
|
||||
* @param {Object} log - MCP logger
|
||||
* @param {Object} context - MCP context (contains session)
|
||||
* @returns {Object} Result object with success, data/error fields
|
||||
*/
|
||||
export async function modelsDirect(args, log, context = {}) {
|
||||
const { session } = context;
|
||||
const { projectRoot } = args; // Extract projectRoot from args
|
||||
|
||||
// Create a logger wrapper that the core functions can use
|
||||
const logWrapper = {
|
||||
info: (message, ...args) => log.info(message, ...args),
|
||||
warn: (message, ...args) => log.warn(message, ...args),
|
||||
error: (message, ...args) => log.error(message, ...args),
|
||||
debug: (message, ...args) =>
|
||||
log.debug ? log.debug(message, ...args) : null,
|
||||
success: (message, ...args) => log.info(message, ...args)
|
||||
};
|
||||
|
||||
log.info(`Executing models_direct with args: ${JSON.stringify(args)}`);
|
||||
log.info(`Using project root: ${projectRoot}`);
|
||||
|
||||
try {
|
||||
enableSilentMode();
|
||||
|
||||
try {
|
||||
// Check for the listAvailableModels flag
|
||||
if (args.listAvailableModels === true) {
|
||||
return await getAvailableModelsList({
|
||||
session,
|
||||
mcpLog: logWrapper,
|
||||
projectRoot // Pass projectRoot to function
|
||||
});
|
||||
}
|
||||
|
||||
// Handle setting a specific model
|
||||
if (args.setMain) {
|
||||
return await setModel('main', args.setMain, {
|
||||
session,
|
||||
mcpLog: logWrapper,
|
||||
projectRoot // Pass projectRoot to function
|
||||
});
|
||||
}
|
||||
|
||||
if (args.setResearch) {
|
||||
return await setModel('research', args.setResearch, {
|
||||
session,
|
||||
mcpLog: logWrapper,
|
||||
projectRoot // Pass projectRoot to function
|
||||
});
|
||||
}
|
||||
|
||||
if (args.setFallback) {
|
||||
return await setModel('fallback', args.setFallback, {
|
||||
session,
|
||||
mcpLog: logWrapper,
|
||||
projectRoot // Pass projectRoot to function
|
||||
});
|
||||
}
|
||||
|
||||
// Default action: get current configuration
|
||||
return await getModelConfiguration({
|
||||
session,
|
||||
mcpLog: logWrapper,
|
||||
projectRoot // Pass projectRoot to function
|
||||
});
|
||||
} finally {
|
||||
disableSilentMode();
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(`Error in models_direct: ${error.message}`);
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'DIRECT_FUNCTION_ERROR',
|
||||
message: error.message,
|
||||
details: error.stack
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { complexityReportDirect } from './direct-functions/complexity-report.js'
|
||||
import { addDependencyDirect } from './direct-functions/add-dependency.js';
|
||||
import { removeTaskDirect } from './direct-functions/remove-task.js';
|
||||
import { initializeProjectDirect } from './direct-functions/initialize-project-direct.js';
|
||||
import { modelsDirect } from './direct-functions/models.js';
|
||||
|
||||
// Re-export utility functions
|
||||
export { findTasksJsonPath } from './utils/path-utils.js';
|
||||
@@ -66,7 +67,9 @@ export const directFunctions = new Map([
|
||||
['fixDependenciesDirect', fixDependenciesDirect],
|
||||
['complexityReportDirect', complexityReportDirect],
|
||||
['addDependencyDirect', addDependencyDirect],
|
||||
['removeTaskDirect', removeTaskDirect]
|
||||
['removeTaskDirect', removeTaskDirect],
|
||||
['initializeProjectDirect', initializeProjectDirect],
|
||||
['modelsDirect', modelsDirect]
|
||||
]);
|
||||
|
||||
// Re-export all direct function implementations
|
||||
@@ -94,5 +97,6 @@ export {
|
||||
complexityReportDirect,
|
||||
addDependencyDirect,
|
||||
removeTaskDirect,
|
||||
initializeProjectDirect
|
||||
initializeProjectDirect,
|
||||
modelsDirect
|
||||
};
|
||||
|
||||
@@ -27,6 +27,7 @@ import { registerComplexityReportTool } from './complexity-report.js';
|
||||
import { registerAddDependencyTool } from './add-dependency.js';
|
||||
import { registerRemoveTaskTool } from './remove-task.js';
|
||||
import { registerInitializeProjectTool } from './initialize-project.js';
|
||||
import { registerModelsTool } from './models.js';
|
||||
|
||||
/**
|
||||
* Register all Task Master tools with the MCP server
|
||||
@@ -34,30 +35,43 @@ import { registerInitializeProjectTool } from './initialize-project.js';
|
||||
*/
|
||||
export function registerTaskMasterTools(server) {
|
||||
try {
|
||||
// Register each tool
|
||||
registerListTasksTool(server);
|
||||
registerSetTaskStatusTool(server);
|
||||
// Register each tool in a logical workflow order
|
||||
|
||||
// Group 1: Initialization & Setup
|
||||
registerInitializeProjectTool(server);
|
||||
registerModelsTool(server);
|
||||
registerParsePRDTool(server);
|
||||
|
||||
// Group 2: Task Listing & Viewing
|
||||
registerListTasksTool(server);
|
||||
registerShowTaskTool(server);
|
||||
registerNextTaskTool(server);
|
||||
registerComplexityReportTool(server);
|
||||
|
||||
// Group 3: Task Status & Management
|
||||
registerSetTaskStatusTool(server);
|
||||
registerGenerateTool(server);
|
||||
|
||||
// Group 4: Task Creation & Modification
|
||||
registerAddTaskTool(server);
|
||||
registerAddSubtaskTool(server);
|
||||
registerUpdateTool(server);
|
||||
registerUpdateTaskTool(server);
|
||||
registerUpdateSubtaskTool(server);
|
||||
registerGenerateTool(server);
|
||||
registerShowTaskTool(server);
|
||||
registerNextTaskTool(server);
|
||||
registerExpandTaskTool(server);
|
||||
registerAddTaskTool(server);
|
||||
registerAddSubtaskTool(server);
|
||||
registerRemoveTaskTool(server);
|
||||
registerRemoveSubtaskTool(server);
|
||||
registerAnalyzeTool(server);
|
||||
registerClearSubtasksTool(server);
|
||||
|
||||
// Group 5: Task Analysis & Expansion
|
||||
registerAnalyzeTool(server);
|
||||
registerExpandTaskTool(server);
|
||||
registerExpandAllTool(server);
|
||||
|
||||
// Group 6: Dependency Management
|
||||
registerAddDependencyTool(server);
|
||||
registerRemoveDependencyTool(server);
|
||||
registerValidateDependenciesTool(server);
|
||||
registerFixDependenciesTool(server);
|
||||
registerComplexityReportTool(server);
|
||||
registerAddDependencyTool(server);
|
||||
registerRemoveTaskTool(server);
|
||||
registerInitializeProjectTool(server);
|
||||
} catch (error) {
|
||||
logger.error(`Error registering Task Master tools: ${error.message}`);
|
||||
throw error;
|
||||
|
||||
81
mcp-server/src/tools/models.js
Normal file
81
mcp-server/src/tools/models.js
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* models.js
|
||||
* MCP tool for managing AI model configurations
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
getProjectRootFromSession,
|
||||
handleApiResult,
|
||||
createErrorResponse
|
||||
} from './utils.js';
|
||||
import { modelsDirect } from '../core/task-master-core.js';
|
||||
|
||||
/**
|
||||
* Register the models tool with the MCP server
|
||||
* @param {Object} server - FastMCP server instance
|
||||
*/
|
||||
export function registerModelsTool(server) {
|
||||
server.addTool({
|
||||
name: 'models',
|
||||
description:
|
||||
'Get information about available AI models or set model configurations. Run without arguments to get the current model configuration and API key status for the selected model providers.',
|
||||
parameters: z.object({
|
||||
setMain: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Set the primary model for task generation/updates. Model provider API key is required in the MCP config ENV.'
|
||||
),
|
||||
setResearch: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Set the model for research-backed operations. Model provider API key is required in the MCP config ENV.'
|
||||
),
|
||||
setFallback: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Set the model to use if the primary fails. Model provider API key is required in the MCP config ENV.'
|
||||
),
|
||||
listAvailableModels: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('List all available models not currently in use.'),
|
||||
projectRoot: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The directory of the project. Must be an absolute path.')
|
||||
}),
|
||||
execute: async (args, { log, session }) => {
|
||||
try {
|
||||
log.info(`Starting models tool with args: ${JSON.stringify(args)}`);
|
||||
|
||||
// Get project root from args or session
|
||||
const rootFolder =
|
||||
args.projectRoot || getProjectRootFromSession(session, log);
|
||||
|
||||
// Ensure project root was determined
|
||||
if (!rootFolder) {
|
||||
return createErrorResponse(
|
||||
'Could not determine project root. Please provide it explicitly or ensure your session contains valid root information.'
|
||||
);
|
||||
}
|
||||
|
||||
// Call the direct function
|
||||
const result = await modelsDirect(
|
||||
{ ...args, projectRoot: rootFolder },
|
||||
log,
|
||||
{ session }
|
||||
);
|
||||
|
||||
// Handle and return the result
|
||||
return handleApiResult(result, log);
|
||||
} catch (error) {
|
||||
log.error(`Error in models tool: ${error.message}`);
|
||||
return createErrorResponse(error.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -701,6 +701,15 @@ function createProjectStructure(
|
||||
replacements
|
||||
);
|
||||
|
||||
// Copy .taskmasterconfig with project name
|
||||
copyTemplateFile(
|
||||
'.taskmasterconfig',
|
||||
path.join(targetDir, '.taskmasterconfig'),
|
||||
{
|
||||
...replacements
|
||||
}
|
||||
);
|
||||
|
||||
// Copy .gitignore
|
||||
copyTemplateFile('gitignore', path.join(targetDir, '.gitignore'));
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
386
scripts/modules/task-manager/models.js
Normal file
386
scripts/modules/task-manager/models.js
Normal file
@@ -0,0 +1,386 @@
|
||||
/**
|
||||
* models.js
|
||||
* Core functionality for managing AI model configurations
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import {
|
||||
getMainModelId,
|
||||
getResearchModelId,
|
||||
getFallbackModelId,
|
||||
getAvailableModels,
|
||||
VALID_PROVIDERS,
|
||||
getMainProvider,
|
||||
getResearchProvider,
|
||||
getFallbackProvider,
|
||||
isApiKeySet,
|
||||
getMcpApiKeyStatus,
|
||||
getConfig,
|
||||
writeConfig,
|
||||
isConfigFilePresent
|
||||
} from '../config-manager.js';
|
||||
|
||||
/**
|
||||
* Get the current model configuration
|
||||
* @param {Object} [options] - Options for the operation
|
||||
* @param {Object} [options.session] - Session object containing environment variables (for MCP)
|
||||
* @param {Function} [options.mcpLog] - MCP logger object (for MCP)
|
||||
* @param {string} [options.projectRoot] - Project root directory
|
||||
* @returns {Object} RESTful response with current model configuration
|
||||
*/
|
||||
async function getModelConfiguration(options = {}) {
|
||||
const { mcpLog, projectRoot } = options;
|
||||
|
||||
const report = (level, ...args) => {
|
||||
if (mcpLog && typeof mcpLog[level] === 'function') {
|
||||
mcpLog[level](...args);
|
||||
}
|
||||
};
|
||||
|
||||
// Check if configuration file exists using provided project root
|
||||
let configPath;
|
||||
let configExists = false;
|
||||
|
||||
if (projectRoot) {
|
||||
configPath = path.join(projectRoot, '.taskmasterconfig');
|
||||
configExists = fs.existsSync(configPath);
|
||||
report(
|
||||
'info',
|
||||
`Checking for .taskmasterconfig at: ${configPath}, exists: ${configExists}`
|
||||
);
|
||||
} else {
|
||||
configExists = isConfigFilePresent();
|
||||
report(
|
||||
'info',
|
||||
`Checking for .taskmasterconfig using isConfigFilePresent(), exists: ${configExists}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!configExists) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'CONFIG_MISSING',
|
||||
message:
|
||||
'The .taskmasterconfig file is missing. Run "task-master models --setup" to create it.'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Get current settings - these should use the config from the found path automatically
|
||||
const mainProvider = getMainProvider(projectRoot);
|
||||
const mainModelId = getMainModelId(projectRoot);
|
||||
const researchProvider = getResearchProvider(projectRoot);
|
||||
const researchModelId = getResearchModelId(projectRoot);
|
||||
const fallbackProvider = getFallbackProvider(projectRoot);
|
||||
const fallbackModelId = getFallbackModelId(projectRoot);
|
||||
|
||||
// Check API keys
|
||||
const mainCliKeyOk = isApiKeySet(mainProvider);
|
||||
const mainMcpKeyOk = getMcpApiKeyStatus(mainProvider);
|
||||
const researchCliKeyOk = isApiKeySet(researchProvider);
|
||||
const researchMcpKeyOk = getMcpApiKeyStatus(researchProvider);
|
||||
const fallbackCliKeyOk = fallbackProvider
|
||||
? isApiKeySet(fallbackProvider)
|
||||
: true;
|
||||
const fallbackMcpKeyOk = fallbackProvider
|
||||
? getMcpApiKeyStatus(fallbackProvider)
|
||||
: true;
|
||||
|
||||
// Get available models to find detailed info
|
||||
const availableModels = getAvailableModels(projectRoot);
|
||||
|
||||
// Find model details
|
||||
const mainModelData = availableModels.find((m) => m.id === mainModelId);
|
||||
const researchModelData = availableModels.find(
|
||||
(m) => m.id === researchModelId
|
||||
);
|
||||
const fallbackModelData = fallbackModelId
|
||||
? availableModels.find((m) => m.id === fallbackModelId)
|
||||
: null;
|
||||
|
||||
// Return structured configuration data
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
activeModels: {
|
||||
main: {
|
||||
provider: mainProvider,
|
||||
modelId: mainModelId,
|
||||
sweScore: mainModelData?.swe_score || null,
|
||||
cost: mainModelData?.cost_per_1m_tokens || null,
|
||||
keyStatus: {
|
||||
cli: mainCliKeyOk,
|
||||
mcp: mainMcpKeyOk
|
||||
}
|
||||
},
|
||||
research: {
|
||||
provider: researchProvider,
|
||||
modelId: researchModelId,
|
||||
sweScore: researchModelData?.swe_score || null,
|
||||
cost: researchModelData?.cost_per_1m_tokens || null,
|
||||
keyStatus: {
|
||||
cli: researchCliKeyOk,
|
||||
mcp: researchMcpKeyOk
|
||||
}
|
||||
},
|
||||
fallback: fallbackProvider
|
||||
? {
|
||||
provider: fallbackProvider,
|
||||
modelId: fallbackModelId,
|
||||
sweScore: fallbackModelData?.swe_score || null,
|
||||
cost: fallbackModelData?.cost_per_1m_tokens || null,
|
||||
keyStatus: {
|
||||
cli: fallbackCliKeyOk,
|
||||
mcp: fallbackMcpKeyOk
|
||||
}
|
||||
}
|
||||
: null
|
||||
},
|
||||
message: 'Successfully retrieved current model configuration'
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
report('error', `Error getting model configuration: ${error.message}`);
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'CONFIG_ERROR',
|
||||
message: error.message
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available models not currently in use
|
||||
* @param {Object} [options] - Options for the operation
|
||||
* @param {Object} [options.session] - Session object containing environment variables (for MCP)
|
||||
* @param {Function} [options.mcpLog] - MCP logger object (for MCP)
|
||||
* @param {string} [options.projectRoot] - Project root directory
|
||||
* @returns {Object} RESTful response with available models
|
||||
*/
|
||||
async function getAvailableModelsList(options = {}) {
|
||||
const { mcpLog, projectRoot } = options;
|
||||
|
||||
const report = (level, ...args) => {
|
||||
if (mcpLog && typeof mcpLog[level] === 'function') {
|
||||
mcpLog[level](...args);
|
||||
}
|
||||
};
|
||||
|
||||
// Check if configuration file exists using provided project root
|
||||
let configPath;
|
||||
let configExists = false;
|
||||
|
||||
if (projectRoot) {
|
||||
configPath = path.join(projectRoot, '.taskmasterconfig');
|
||||
configExists = fs.existsSync(configPath);
|
||||
report(
|
||||
'info',
|
||||
`Checking for .taskmasterconfig at: ${configPath}, exists: ${configExists}`
|
||||
);
|
||||
} else {
|
||||
configExists = isConfigFilePresent();
|
||||
report(
|
||||
'info',
|
||||
`Checking for .taskmasterconfig using isConfigFilePresent(), exists: ${configExists}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!configExists) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'CONFIG_MISSING',
|
||||
message:
|
||||
'The .taskmasterconfig file is missing. Run "task-master models --setup" to create it.'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Get all available models
|
||||
const allAvailableModels = getAvailableModels(projectRoot);
|
||||
|
||||
if (!allAvailableModels || allAvailableModels.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
models: [],
|
||||
message: 'No available models found'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Get currently used model IDs
|
||||
const mainModelId = getMainModelId(projectRoot);
|
||||
const researchModelId = getResearchModelId(projectRoot);
|
||||
const fallbackModelId = getFallbackModelId(projectRoot);
|
||||
|
||||
// Filter out placeholder models and active models
|
||||
const activeIds = [mainModelId, researchModelId, fallbackModelId].filter(
|
||||
Boolean
|
||||
);
|
||||
const otherAvailableModels = allAvailableModels.map((model) => ({
|
||||
provider: model.provider || 'N/A',
|
||||
modelId: model.id,
|
||||
sweScore: model.swe_score || null,
|
||||
cost: model.cost_per_1m_tokens || null,
|
||||
allowedRoles: model.allowed_roles || []
|
||||
}));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
models: otherAvailableModels,
|
||||
message: `Successfully retrieved ${otherAvailableModels.length} available models`
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
report('error', `Error getting available models: ${error.message}`);
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'MODELS_LIST_ERROR',
|
||||
message: error.message
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a specific model in the configuration
|
||||
* @param {string} role - The model role to update ('main', 'research', 'fallback')
|
||||
* @param {string} modelId - The model ID to set for the role
|
||||
* @param {Object} [options] - Options for the operation
|
||||
* @param {Object} [options.session] - Session object containing environment variables (for MCP)
|
||||
* @param {Function} [options.mcpLog] - MCP logger object (for MCP)
|
||||
* @param {string} [options.projectRoot] - Project root directory
|
||||
* @returns {Object} RESTful response with result of update operation
|
||||
*/
|
||||
async function setModel(role, modelId, options = {}) {
|
||||
const { mcpLog, projectRoot } = options;
|
||||
|
||||
const report = (level, ...args) => {
|
||||
if (mcpLog && typeof mcpLog[level] === 'function') {
|
||||
mcpLog[level](...args);
|
||||
}
|
||||
};
|
||||
|
||||
// Check if configuration file exists using provided project root
|
||||
let configPath;
|
||||
let configExists = false;
|
||||
|
||||
if (projectRoot) {
|
||||
configPath = path.join(projectRoot, '.taskmasterconfig');
|
||||
configExists = fs.existsSync(configPath);
|
||||
report(
|
||||
'info',
|
||||
`Checking for .taskmasterconfig at: ${configPath}, exists: ${configExists}`
|
||||
);
|
||||
} else {
|
||||
configExists = isConfigFilePresent();
|
||||
report(
|
||||
'info',
|
||||
`Checking for .taskmasterconfig using isConfigFilePresent(), exists: ${configExists}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!configExists) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'CONFIG_MISSING',
|
||||
message:
|
||||
'The .taskmasterconfig file is missing. Run "task-master models --setup" to create it.'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Validate role
|
||||
if (!['main', 'research', 'fallback'].includes(role)) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INVALID_ROLE',
|
||||
message: `Invalid role: ${role}. Must be one of: main, research, fallback.`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Validate model ID
|
||||
if (typeof modelId !== 'string' || modelId.trim() === '') {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INVALID_MODEL_ID',
|
||||
message: `Invalid model ID: ${modelId}. Must be a non-empty string.`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const availableModels = getAvailableModels(projectRoot);
|
||||
const currentConfig = getConfig(projectRoot);
|
||||
|
||||
// Find the model data
|
||||
const modelData = availableModels.find((m) => m.id === modelId);
|
||||
if (!modelData || !modelData.provider) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'MODEL_NOT_FOUND',
|
||||
message: `Model ID "${modelId}" not found or invalid in available models.`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Update configuration
|
||||
currentConfig.models[role] = {
|
||||
...currentConfig.models[role], // Keep existing params like maxTokens
|
||||
provider: modelData.provider,
|
||||
modelId: modelId
|
||||
};
|
||||
|
||||
// Write updated configuration
|
||||
const writeResult = writeConfig(currentConfig, projectRoot);
|
||||
if (!writeResult) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'WRITE_ERROR',
|
||||
message: 'Error writing updated configuration to .taskmasterconfig'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
report(
|
||||
'info',
|
||||
`Set ${role} model to: ${modelId} (Provider: ${modelData.provider})`
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
role,
|
||||
provider: modelData.provider,
|
||||
modelId,
|
||||
message: `Successfully set ${role} model to ${modelId} (Provider: ${modelData.provider})`
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
report('error', `Error setting ${role} model: ${error.message}`);
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'SET_MODEL_ERROR',
|
||||
message: error.message
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export { getModelConfiguration, getAvailableModelsList, setModel };
|
||||
@@ -21,13 +21,8 @@ import fs from 'fs';
|
||||
import { findNextTask, analyzeTaskComplexity } from './task-manager.js';
|
||||
import {
|
||||
getProjectName,
|
||||
getDefaultSubtasks,
|
||||
getMainModelId,
|
||||
getMainMaxTokens,
|
||||
getMainTemperature,
|
||||
getDebugFlag,
|
||||
getLogLevel,
|
||||
getDefaultPriority
|
||||
getDefaultSubtasks
|
||||
} from './config-manager.js';
|
||||
|
||||
// Create a color gradient for the banner
|
||||
@@ -386,6 +381,9 @@ function formatDependenciesWithStatus(
|
||||
function displayHelp() {
|
||||
displayBanner();
|
||||
|
||||
// Get terminal width - moved to top of function to make it available throughout
|
||||
const terminalWidth = process.stdout.columns || 100; // Default to 100 if can't detect
|
||||
|
||||
console.log(
|
||||
boxen(chalk.white.bold('Task Master CLI'), {
|
||||
padding: 1,
|
||||
@@ -397,6 +395,42 @@ function displayHelp() {
|
||||
|
||||
// Command categories
|
||||
const commandCategories = [
|
||||
{
|
||||
title: 'Project Setup & Configuration',
|
||||
color: 'blue',
|
||||
commands: [
|
||||
{
|
||||
name: 'init',
|
||||
args: '[--name=<name>] [--description=<desc>] [-y]',
|
||||
desc: 'Initialize a new project with Task Master structure'
|
||||
},
|
||||
{
|
||||
name: 'models',
|
||||
args: '',
|
||||
desc: 'View current AI model configuration and available models'
|
||||
},
|
||||
{
|
||||
name: 'models --setup',
|
||||
args: '',
|
||||
desc: 'Run interactive setup to configure AI models'
|
||||
},
|
||||
{
|
||||
name: 'models --set-main',
|
||||
args: '<model_id>',
|
||||
desc: 'Set the primary model for task generation'
|
||||
},
|
||||
{
|
||||
name: 'models --set-research',
|
||||
args: '<model_id>',
|
||||
desc: 'Set the model for research operations'
|
||||
},
|
||||
{
|
||||
name: 'models --set-fallback',
|
||||
args: '<model_id>',
|
||||
desc: 'Set the fallback model (optional)'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Task Generation',
|
||||
color: 'cyan',
|
||||
@@ -430,7 +464,17 @@ function displayHelp() {
|
||||
{
|
||||
name: 'update',
|
||||
args: '--from=<id> --prompt="<context>"',
|
||||
desc: 'Update tasks based on new requirements'
|
||||
desc: 'Update multiple tasks based on new requirements'
|
||||
},
|
||||
{
|
||||
name: 'update-task',
|
||||
args: '--id=<id> --prompt="<context>"',
|
||||
desc: 'Update a single specific task with new information'
|
||||
},
|
||||
{
|
||||
name: 'update-subtask',
|
||||
args: '--id=<parentId.subtaskId> --prompt="<context>"',
|
||||
desc: 'Append additional information to a subtask'
|
||||
},
|
||||
{
|
||||
name: 'add-task',
|
||||
@@ -438,20 +482,46 @@ function displayHelp() {
|
||||
desc: 'Add a new task using AI'
|
||||
},
|
||||
{
|
||||
name: 'add-dependency',
|
||||
args: '--id=<id> --depends-on=<id>',
|
||||
desc: 'Add a dependency to a task'
|
||||
},
|
||||
{
|
||||
name: 'remove-dependency',
|
||||
args: '--id=<id> --depends-on=<id>',
|
||||
desc: 'Remove a dependency from a task'
|
||||
name: 'remove-task',
|
||||
args: '--id=<id> [-y]',
|
||||
desc: 'Permanently remove a task or subtask'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Task Analysis & Detail',
|
||||
title: 'Subtask Management',
|
||||
color: 'yellow',
|
||||
commands: [
|
||||
{
|
||||
name: 'add-subtask',
|
||||
args: '--parent=<id> --title="<title>" [--description="<desc>"]',
|
||||
desc: 'Add a new subtask to a parent task'
|
||||
},
|
||||
{
|
||||
name: 'add-subtask',
|
||||
args: '--parent=<id> --task-id=<id>',
|
||||
desc: 'Convert an existing task into a subtask'
|
||||
},
|
||||
{
|
||||
name: 'remove-subtask',
|
||||
args: '--id=<parentId.subtaskId> [--convert]',
|
||||
desc: 'Remove a subtask (optionally convert to standalone task)'
|
||||
},
|
||||
{
|
||||
name: 'clear-subtasks',
|
||||
args: '--id=<id>',
|
||||
desc: 'Remove all subtasks from specified tasks'
|
||||
},
|
||||
{
|
||||
name: 'clear-subtasks --all',
|
||||
args: '',
|
||||
desc: 'Remove subtasks from all tasks'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Task Analysis & Breakdown',
|
||||
color: 'magenta',
|
||||
commands: [
|
||||
{
|
||||
name: 'analyze-complexity',
|
||||
@@ -472,17 +542,12 @@ function displayHelp() {
|
||||
name: 'expand --all',
|
||||
args: '[--force] [--research]',
|
||||
desc: 'Expand all pending tasks with subtasks'
|
||||
},
|
||||
{
|
||||
name: 'clear-subtasks',
|
||||
args: '--id=<id>',
|
||||
desc: 'Remove subtasks from specified tasks'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Task Navigation & Viewing',
|
||||
color: 'magenta',
|
||||
color: 'cyan',
|
||||
commands: [
|
||||
{
|
||||
name: 'next',
|
||||
@@ -500,6 +565,16 @@ function displayHelp() {
|
||||
title: 'Dependency Management',
|
||||
color: 'blue',
|
||||
commands: [
|
||||
{
|
||||
name: 'add-dependency',
|
||||
args: '--id=<id> --depends-on=<id>',
|
||||
desc: 'Add a dependency to a task'
|
||||
},
|
||||
{
|
||||
name: 'remove-dependency',
|
||||
args: '--id=<id> --depends-on=<id>',
|
||||
desc: 'Remove a dependency from a task'
|
||||
},
|
||||
{
|
||||
name: 'validate-dependencies',
|
||||
args: '',
|
||||
@@ -525,8 +600,13 @@ function displayHelp() {
|
||||
})
|
||||
);
|
||||
|
||||
// Calculate dynamic column widths - adjust ratios as needed
|
||||
const nameWidth = Math.max(25, Math.floor(terminalWidth * 0.2)); // 20% of width but min 25
|
||||
const argsWidth = Math.max(40, Math.floor(terminalWidth * 0.35)); // 35% of width but min 40
|
||||
const descWidth = Math.max(45, Math.floor(terminalWidth * 0.45) - 10); // 45% of width but min 45, minus some buffer
|
||||
|
||||
const commandTable = new Table({
|
||||
colWidths: [25, 40, 45],
|
||||
colWidths: [nameWidth, argsWidth, descWidth],
|
||||
chars: {
|
||||
top: '',
|
||||
'top-mid': '',
|
||||
@@ -544,7 +624,8 @@ function displayHelp() {
|
||||
'right-mid': '',
|
||||
middle: ' '
|
||||
},
|
||||
style: { border: [], 'padding-left': 4 }
|
||||
style: { border: [], 'padding-left': 4 },
|
||||
wordWrap: true
|
||||
});
|
||||
|
||||
category.commands.forEach((cmd, index) => {
|
||||
@@ -559,9 +640,9 @@ function displayHelp() {
|
||||
console.log('');
|
||||
});
|
||||
|
||||
// Display environment variables section
|
||||
// Display configuration section
|
||||
console.log(
|
||||
boxen(chalk.cyan.bold('Environment Variables'), {
|
||||
boxen(chalk.cyan.bold('Configuration'), {
|
||||
padding: { left: 2, right: 2, top: 0, bottom: 0 },
|
||||
margin: { top: 1, bottom: 0 },
|
||||
borderColor: 'cyan',
|
||||
@@ -569,8 +650,19 @@ function displayHelp() {
|
||||
})
|
||||
);
|
||||
|
||||
const envTable = new Table({
|
||||
colWidths: [30, 50, 30],
|
||||
// Get terminal width if not already defined
|
||||
const configTerminalWidth = terminalWidth || process.stdout.columns || 100;
|
||||
|
||||
// Calculate dynamic column widths for config table
|
||||
const configKeyWidth = Math.max(30, Math.floor(configTerminalWidth * 0.25));
|
||||
const configDescWidth = Math.max(50, Math.floor(configTerminalWidth * 0.45));
|
||||
const configValueWidth = Math.max(
|
||||
30,
|
||||
Math.floor(configTerminalWidth * 0.3) - 10
|
||||
);
|
||||
|
||||
const configTable = new Table({
|
||||
colWidths: [configKeyWidth, configDescWidth, configValueWidth],
|
||||
chars: {
|
||||
top: '',
|
||||
'top-mid': '',
|
||||
@@ -588,69 +680,59 @@ function displayHelp() {
|
||||
'right-mid': '',
|
||||
middle: ' '
|
||||
},
|
||||
style: { border: [], 'padding-left': 4 }
|
||||
style: { border: [], 'padding-left': 4 },
|
||||
wordWrap: true
|
||||
});
|
||||
|
||||
envTable.push(
|
||||
configTable.push(
|
||||
[
|
||||
`${chalk.yellow('ANTHROPIC_API_KEY')}${chalk.reset('')}`,
|
||||
`${chalk.white('Your Anthropic API key')}${chalk.reset('')}`,
|
||||
`${chalk.dim('Required')}${chalk.reset('')}`
|
||||
`${chalk.yellow('.taskmasterconfig')}${chalk.reset('')}`,
|
||||
`${chalk.white('AI model configuration file (project root)')}${chalk.reset('')}`,
|
||||
`${chalk.dim('Managed by models cmd')}${chalk.reset('')}`
|
||||
],
|
||||
[
|
||||
`${chalk.yellow('MODEL')}${chalk.reset('')}`,
|
||||
`${chalk.white('Claude model to use')}${chalk.reset('')}`,
|
||||
`${chalk.dim(`Default: ${getMainModelId()}`)}${chalk.reset('')}`
|
||||
`${chalk.yellow('API Keys (.env)')}${chalk.reset('')}`,
|
||||
`${chalk.white('API keys for AI providers (ANTHROPIC_API_KEY, etc.)')}${chalk.reset('')}`,
|
||||
`${chalk.dim('Required in .env file')}${chalk.reset('')}`
|
||||
],
|
||||
[
|
||||
`${chalk.yellow('MAX_TOKENS')}${chalk.reset('')}`,
|
||||
`${chalk.white('Maximum tokens for responses')}${chalk.reset('')}`,
|
||||
`${chalk.dim(`Default: ${getMainMaxTokens()}`)}${chalk.reset('')}`
|
||||
],
|
||||
[
|
||||
`${chalk.yellow('TEMPERATURE')}${chalk.reset('')}`,
|
||||
`${chalk.white('Temperature for model responses')}${chalk.reset('')}`,
|
||||
`${chalk.dim(`Default: ${getMainTemperature()}`)}${chalk.reset('')}`
|
||||
],
|
||||
[
|
||||
`${chalk.yellow('PERPLEXITY_API_KEY')}${chalk.reset('')}`,
|
||||
`${chalk.white('Perplexity API key for research')}${chalk.reset('')}`,
|
||||
`${chalk.dim('Optional')}${chalk.reset('')}`
|
||||
],
|
||||
[
|
||||
`${chalk.yellow('PERPLEXITY_MODEL')}${chalk.reset('')}`,
|
||||
`${chalk.white('Perplexity model to use')}${chalk.reset('')}`,
|
||||
`${chalk.dim('Default: sonar-pro')}${chalk.reset('')}`
|
||||
],
|
||||
[
|
||||
`${chalk.yellow('DEBUG')}${chalk.reset('')}`,
|
||||
`${chalk.white('Enable debug logging')}${chalk.reset('')}`,
|
||||
`${chalk.dim(`Default: ${getDebugFlag()}`)}${chalk.reset('')}`
|
||||
],
|
||||
[
|
||||
`${chalk.yellow('LOG_LEVEL')}${chalk.reset('')}`,
|
||||
`${chalk.white('Console output level (debug,info,warn,error)')}${chalk.reset('')}`,
|
||||
`${chalk.dim(`Default: ${getLogLevel()}`)}${chalk.reset('')}`
|
||||
],
|
||||
[
|
||||
`${chalk.yellow('DEFAULT_SUBTASKS')}${chalk.reset('')}`,
|
||||
`${chalk.white('Default number of subtasks to generate')}${chalk.reset('')}`,
|
||||
`${chalk.dim(`Default: ${getDefaultSubtasks()}`)}${chalk.reset('')}`
|
||||
],
|
||||
[
|
||||
`${chalk.yellow('DEFAULT_PRIORITY')}${chalk.reset('')}`,
|
||||
`${chalk.white('Default task priority')}${chalk.reset('')}`,
|
||||
`${chalk.dim(`Default: ${getDefaultPriority()}`)}${chalk.reset('')}`
|
||||
],
|
||||
[
|
||||
`${chalk.yellow('PROJECT_NAME')}${chalk.reset('')}`,
|
||||
`${chalk.white('Project name displayed in UI')}${chalk.reset('')}`,
|
||||
`${chalk.dim(`Default: ${getProjectName()}`)}${chalk.reset('')}`
|
||||
`${chalk.yellow('MCP Keys (mcp.json)')}${chalk.reset('')}`,
|
||||
`${chalk.white('API keys for Cursor integration')}${chalk.reset('')}`,
|
||||
`${chalk.dim('Required in .cursor/')}${chalk.reset('')}`
|
||||
]
|
||||
);
|
||||
|
||||
console.log(envTable.toString());
|
||||
console.log(configTable.toString());
|
||||
console.log('');
|
||||
|
||||
// Show helpful hints
|
||||
console.log(
|
||||
boxen(
|
||||
chalk.white.bold('Quick Start:') +
|
||||
'\n\n' +
|
||||
chalk.cyan('1. Create Project: ') +
|
||||
chalk.white('task-master init') +
|
||||
'\n' +
|
||||
chalk.cyan('2. Setup Models: ') +
|
||||
chalk.white('task-master models --setup') +
|
||||
'\n' +
|
||||
chalk.cyan('3. Parse PRD: ') +
|
||||
chalk.white('task-master parse-prd --input=<prd-file>') +
|
||||
'\n' +
|
||||
chalk.cyan('4. List Tasks: ') +
|
||||
chalk.white('task-master list') +
|
||||
'\n' +
|
||||
chalk.cyan('5. Find Next Task: ') +
|
||||
chalk.white('task-master next'),
|
||||
{
|
||||
padding: 1,
|
||||
borderColor: 'yellow',
|
||||
borderStyle: 'round',
|
||||
margin: { top: 1 },
|
||||
width: Math.min(configTerminalWidth - 10, 100) // Limit width to terminal width minus padding, max 100
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user