Compare commits

..

15 Commits

Author SHA1 Message Date
github-actions[bot]
24e480ce87 docs: auto-update documentation based on changes in next branch
This PR was automatically generated to update documentation based on recent changes.

  Original commit: feat: handle new command errors better (#1318)\n\n\n

  Co-authored-by: Claude <claude-assistant@anthropic.com>
2025-10-16 20:39:02 +00:00
Ralph Khreish
662e3865f3 feat: handle new command errors better (#1318) 2025-10-16 22:31:50 +02:00
Ralph Khreish
8649c8a347 chore: apply requested coderabbit changes 2025-10-16 19:24:29 +02:00
Ralph Khreish
f7cab246b0 fix: storage issue (show file or api more consistently) 2025-10-16 19:24:29 +02:00
Ralph Khreish
5aca107827 fix: runtime env variables working with new tm_ env variables 2025-10-16 19:24:29 +02:00
Ralph Khreish
fb68c9fe1f feat: improve auth login by adding context selection immediately after logging in 2025-10-16 19:24:29 +02:00
Ralph Khreish
ff3bd7add8 chore: CI format 2025-10-16 19:24:29 +02:00
Ralph Khreish
c8228e913b feat: show brief title when listing brief instead of uuid
- add search for brief selection
2025-10-16 19:24:29 +02:00
Ralph Khreish
218b68a31e feat: implement runtime and build time env variables for remote access 2025-10-16 19:24:29 +02:00
Ralph Khreish
6bc75c0ac6 fix: auth refresh (#1314) 2025-10-15 17:32:15 +02:00
Ralph Khreish
d7fca1844f feat: add "next" command to new command structure (#1312) 2025-10-15 15:26:34 +02:00
Ben Coombs
a98d96ef04 fix: standardize UI box width calculations across components (#1305)
Co-authored-by: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com>
2025-10-14 20:08:11 +02:00
Karol Fabjańczuk
a69d8c91dc feat: add configurable MCP tool loading to reduce LLM context usage (#1181)
Co-authored-by: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com>
2025-10-14 20:01:21 +02:00
Ralph Khreish
474a86cebb Merge pull request #1308 from eyaltoledano/ralph/chore/update.from.main.october 2025-10-14 18:49:08 +02:00
Ben Coombs
3283506444 fix: enhance findProjectRoot to traverse parent directories (#1302)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com>
2025-10-14 18:32:10 +02:00
63 changed files with 3228 additions and 762 deletions

View File

@@ -0,0 +1,5 @@
---
"task-master-ai": patch
---
Improve auth token refresh flow

View File

@@ -0,0 +1,7 @@
---
"task-master-ai": patch
---
Enable Task Master commands to traverse parent directories to find project root from nested paths
Fixes #1301

View File

@@ -0,0 +1,5 @@
---
"@tm/cli": patch
---
Fix warning message box width to match dashboard box width for consistent UI alignment

View File

@@ -0,0 +1,35 @@
---
"task-master-ai": minor
---
Add configurable MCP tool loading to optimize LLM context usage
You can now control which Task Master MCP tools are loaded by setting the `TASK_MASTER_TOOLS` environment variable in your MCP configuration. This helps reduce context usage for LLMs by only loading the tools you need.
**Configuration Options:**
- `all` (default): Load all 36 tools
- `core` or `lean`: Load only 7 essential tools for daily development
- Includes: `get_tasks`, `next_task`, `get_task`, `set_task_status`, `update_subtask`, `parse_prd`, `expand_task`
- `standard`: Load 15 commonly used tools (all core tools plus 8 more)
- Additional tools: `initialize_project`, `analyze_project_complexity`, `expand_all`, `add_subtask`, `remove_task`, `generate`, `add_task`, `complexity_report`
- Custom list: Comma-separated tool names (e.g., `get_tasks,next_task,set_task_status`)
**Example .mcp.json configuration:**
```json
{
"mcpServers": {
"task-master-ai": {
"command": "npx",
"args": ["-y", "task-master-ai"],
"env": {
"TASK_MASTER_TOOLS": "standard",
"ANTHROPIC_API_KEY": "your_key_here"
}
}
}
}
```
For complete details on all available tools, configuration examples, and usage guidelines, see the [MCP Tools documentation](https://docs.task-master.dev/capabilities/mcp#configurable-tool-loading).

View File

@@ -0,0 +1,5 @@
---
"task-master-ai": minor
---
Improve next command to work with remote

View File

@@ -119,6 +119,7 @@ MCP (Model Control Protocol) lets you run Task Master directly from your editor.
"command": "npx",
"args": ["-y", "task-master-ai"],
"env": {
// "TASK_MASTER_TOOLS": "all", // Options: "all", "standard", "core", or comma-separated list of tools
"ANTHROPIC_API_KEY": "YOUR_ANTHROPIC_API_KEY_HERE",
"PERPLEXITY_API_KEY": "YOUR_PERPLEXITY_API_KEY_HERE",
"OPENAI_API_KEY": "YOUR_OPENAI_KEY_HERE",
@@ -148,6 +149,7 @@ MCP (Model Control Protocol) lets you run Task Master directly from your editor.
"command": "npx",
"args": ["-y", "task-master-ai"],
"env": {
// "TASK_MASTER_TOOLS": "all", // Options: "all", "standard", "core", or comma-separated list of tools
"ANTHROPIC_API_KEY": "YOUR_ANTHROPIC_API_KEY_HERE",
"PERPLEXITY_API_KEY": "YOUR_PERPLEXITY_API_KEY_HERE",
"OPENAI_API_KEY": "YOUR_OPENAI_KEY_HERE",
@@ -196,7 +198,7 @@ Initialize taskmaster-ai in my project
#### 5. Make sure you have a PRD (Recommended)
For **new projects**: Create your PRD at `.taskmaster/docs/prd.txt`
For **new projects**: Create your PRD at `.taskmaster/docs/prd.txt`.
For **existing projects**: You can use `scripts/prd.txt` or migrate with `task-master migrate`
An example PRD template is available after initialization in `.taskmaster/templates/example_prd.txt`.
@@ -282,6 +284,76 @@ task-master generate
task-master rules add windsurf,roo,vscode
```
## Tool Loading Configuration
### Optimizing MCP Tool Loading
Task Master's MCP server supports selective tool loading to reduce context window usage. By default, all 36 tools are loaded (~21,000 tokens) to maintain backward compatibility with existing installations.
You can optimize performance by configuring the `TASK_MASTER_TOOLS` environment variable:
### Available Modes
| Mode | Tools | Context Usage | Use Case |
|------|-------|--------------|----------|
| `all` (default) | 36 | ~21,000 tokens | Complete feature set - all tools available |
| `standard` | 15 | ~10,000 tokens | Common task management operations |
| `core` (or `lean`) | 7 | ~5,000 tokens | Essential daily development workflow |
| `custom` | Variable | Variable | Comma-separated list of specific tools |
### Configuration Methods
#### Method 1: Environment Variable in MCP Configuration
Add `TASK_MASTER_TOOLS` to your MCP configuration file's `env` section:
```jsonc
{
"mcpServers": { // or "servers" for VS Code
"task-master-ai": {
"command": "npx",
"args": ["-y", "--package=task-master-ai", "task-master-ai"],
"env": {
"TASK_MASTER_TOOLS": "standard", // Options: "all", "standard", "core", "lean", or comma-separated list
"ANTHROPIC_API_KEY": "your-key-here",
// ... other API keys
}
}
}
}
```
#### Method 2: Claude Code CLI (One-Time Setup)
For Claude Code users, you can set the mode during installation:
```bash
# Core mode example (~70% token reduction)
claude mcp add task-master-ai --scope user \
--env TASK_MASTER_TOOLS="core" \
-- npx -y task-master-ai@latest
# Custom tools example
claude mcp add task-master-ai --scope user \
--env TASK_MASTER_TOOLS="get_tasks,next_task,set_task_status" \
-- npx -y task-master-ai@latest
```
### Tool Sets Details
**Core Tools (7):** `get_tasks`, `next_task`, `get_task`, `set_task_status`, `update_subtask`, `parse_prd`, `expand_task`
**Standard Tools (15):** All core tools plus `initialize_project`, `analyze_project_complexity`, `expand_all`, `add_subtask`, `remove_task`, `generate`, `add_task`, `complexity_report`
**All Tools (36):** Complete set including project setup, task management, analysis, dependencies, tags, research, and more
### Recommendations
- **New users**: Start with `"standard"` mode for a good balance
- **Large projects**: Use `"core"` mode to minimize token usage
- **Complex workflows**: Use `"all"` mode or custom selection
- **Backward compatibility**: If not specified, defaults to `"all"` mode
## Claude Code Support
Task Master now supports Claude models through the Claude Code CLI, which requires no API key:

View File

@@ -22,6 +22,7 @@
"test:ci": "vitest run --coverage --reporter=dot"
},
"dependencies": {
"@inquirer/search": "^3.2.0",
"@tm/core": "*",
"boxen": "^8.0.1",
"chalk": "5.6.2",

View File

@@ -8,6 +8,7 @@ import { Command } from 'commander';
// Import all commands
import { ListTasksCommand } from './commands/list.command.js';
import { ShowCommand } from './commands/show.command.js';
import { NextCommand } from './commands/next.command.js';
import { AuthCommand } from './commands/auth.command.js';
import { ContextCommand } from './commands/context.command.js';
import { StartCommand } from './commands/start.command.js';
@@ -45,6 +46,12 @@ export class CommandRegistry {
commandClass: ShowCommand as any,
category: 'task'
},
{
name: 'next',
description: 'Find the next available task to work on',
commandClass: NextCommand as any,
category: 'task'
},
{
name: 'start',
description: 'Start working on a task with claude-code',

View File

@@ -14,6 +14,8 @@ import {
type AuthCredentials
} from '@tm/core/auth';
import * as ui from '../utils/ui.js';
import { ContextCommand } from './context.command.js';
import { displayError } from '../utils/error-handler.js';
/**
* Result type from auth command
@@ -116,8 +118,7 @@ export class AuthCommand extends Command {
process.exit(0);
}, 100);
} catch (error: any) {
this.handleError(error);
process.exit(1);
displayError(error);
}
}
@@ -133,8 +134,7 @@ export class AuthCommand extends Command {
process.exit(1);
}
} catch (error: any) {
this.handleError(error);
process.exit(1);
displayError(error);
}
}
@@ -143,11 +143,10 @@ export class AuthCommand extends Command {
*/
private async executeStatus(): Promise<void> {
try {
const result = await this.displayStatus();
const result = this.displayStatus();
this.setLastResult(result);
} catch (error: any) {
this.handleError(error);
process.exit(1);
displayError(error);
}
}
@@ -163,16 +162,15 @@ export class AuthCommand extends Command {
process.exit(1);
}
} catch (error: any) {
this.handleError(error);
process.exit(1);
displayError(error);
}
}
/**
* Display authentication status
*/
private async displayStatus(): Promise<AuthResult> {
const credentials = await this.authManager.getCredentials();
private displayStatus(): AuthResult {
const credentials = this.authManager.getCredentials();
console.log(chalk.cyan('\n🔐 Authentication Status\n'));
@@ -325,7 +323,7 @@ export class AuthCommand extends Command {
]);
if (!continueAuth) {
const credentials = await this.authManager.getCredentials();
const credentials = this.authManager.getCredentials();
ui.displaySuccess('Using existing authentication');
if (credentials) {
@@ -351,6 +349,37 @@ export class AuthCommand extends Command {
chalk.gray(` Logged in as: ${credentials.email || credentials.userId}`)
);
// Post-auth: Set up workspace context
console.log(); // Add spacing
try {
const contextCommand = new ContextCommand();
const contextResult = await contextCommand.setupContextInteractive();
if (contextResult.success) {
if (contextResult.orgSelected && contextResult.briefSelected) {
console.log(
chalk.green('✓ Workspace context configured successfully')
);
} else if (contextResult.orgSelected) {
console.log(chalk.green('✓ Organization selected'));
}
} else {
console.log(
chalk.yellow('⚠ Context setup was skipped or encountered issues')
);
console.log(
chalk.gray(' You can set up context later with "tm context"')
);
}
} catch (contextError) {
console.log(chalk.yellow('⚠ Context setup encountered an error'));
console.log(
chalk.gray(' You can set up context later with "tm context"')
);
if (process.env.DEBUG) {
console.error(chalk.gray((contextError as Error).message));
}
}
return {
success: true,
action: 'login',
@@ -358,7 +387,7 @@ export class AuthCommand extends Command {
message: 'Authentication successful'
};
} catch (error) {
this.handleAuthError(error as AuthenticationError);
displayError(error, { skipExit: true });
return {
success: false,
@@ -421,51 +450,6 @@ export class AuthCommand extends Command {
}
}
/**
* Handle authentication errors
*/
private handleAuthError(error: AuthenticationError): void {
console.error(chalk.red(`\n✗ ${error.message}`));
switch (error.code) {
case 'NETWORK_ERROR':
ui.displayWarning(
'Please check your internet connection and try again.'
);
break;
case 'INVALID_CREDENTIALS':
ui.displayWarning('Please check your credentials and try again.');
break;
case 'AUTH_EXPIRED':
ui.displayWarning(
'Your session has expired. Please authenticate again.'
);
break;
default:
if (process.env.DEBUG) {
console.error(chalk.gray(error.stack || ''));
}
}
}
/**
* Handle general errors
*/
private handleError(error: any): void {
if (error instanceof AuthenticationError) {
this.handleAuthError(error);
} else {
const msg = error?.getSanitizedDetails?.() ?? {
message: error?.message ?? String(error)
};
console.error(chalk.red(`Error: ${msg.message || 'Unexpected error'}`));
if (error.stack && process.env.DEBUG) {
console.error(chalk.gray(error.stack));
}
}
}
/**
* Set the last result for programmatic access
*/
@@ -490,7 +474,7 @@ export class AuthCommand extends Command {
/**
* Get current credentials (for programmatic usage)
*/
getCredentials(): Promise<AuthCredentials | null> {
getCredentials(): AuthCredentials | null {
return this.authManager.getCredentials();
}

View File

@@ -6,13 +6,11 @@
import { Command } from 'commander';
import chalk from 'chalk';
import inquirer from 'inquirer';
import search from '@inquirer/search';
import ora, { Ora } from 'ora';
import {
AuthManager,
AuthenticationError,
type UserContext
} from '@tm/core/auth';
import { AuthManager, type UserContext } from '@tm/core/auth';
import * as ui from '../utils/ui.js';
import { displayError } from '../utils/error-handler.js';
/**
* Result type from context command
@@ -115,18 +113,17 @@ export class ContextCommand extends Command {
*/
private async executeShow(): Promise<void> {
try {
const result = await this.displayContext();
const result = this.displayContext();
this.setLastResult(result);
} catch (error: any) {
this.handleError(error);
process.exit(1);
displayError(error);
}
}
/**
* Display current context
*/
private async displayContext(): Promise<ContextResult> {
private displayContext(): ContextResult {
// Check authentication first
if (!this.authManager.isAuthenticated()) {
console.log(chalk.yellow('✗ Not authenticated'));
@@ -139,7 +136,7 @@ export class ContextCommand extends Command {
};
}
const context = await this.authManager.getContext();
const context = this.authManager.getContext();
console.log(chalk.cyan('\n🌍 Workspace Context\n'));
@@ -156,10 +153,14 @@ export class ContextCommand extends Command {
if (context.briefName || context.briefId) {
console.log(chalk.green('\n✓ Brief'));
if (context.briefName) {
if (context.briefName && context.briefId) {
const shortId = context.briefId.slice(0, 8);
console.log(
chalk.white(` ${context.briefName} `) + chalk.gray(`(${shortId})`)
);
} else if (context.briefName) {
console.log(chalk.white(` ${context.briefName}`));
}
if (context.briefId) {
} else if (context.briefId) {
console.log(chalk.gray(` ID: ${context.briefId}`));
}
}
@@ -211,8 +212,7 @@ export class ContextCommand extends Command {
process.exit(1);
}
} catch (error: any) {
this.handleError(error);
process.exit(1);
displayError(error);
}
}
@@ -250,9 +250,10 @@ export class ContextCommand extends Command {
]);
// Update context
await this.authManager.updateContext({
this.authManager.updateContext({
orgId: selectedOrg.id,
orgName: selectedOrg.name,
orgSlug: selectedOrg.slug,
// Clear brief when changing org
briefId: undefined,
briefName: undefined
@@ -263,7 +264,7 @@ export class ContextCommand extends Command {
return {
success: true,
action: 'select-org',
context: (await this.authManager.getContext()) || undefined,
context: this.authManager.getContext() || undefined,
message: `Selected organization: ${selectedOrg.name}`
};
} catch (error) {
@@ -284,7 +285,7 @@ export class ContextCommand extends Command {
}
// Check if org is selected
const context = await this.authManager.getContext();
const context = this.authManager.getContext();
if (!context?.orgId) {
ui.displayError(
'No organization selected. Run "tm context org" first.'
@@ -299,8 +300,7 @@ export class ContextCommand extends Command {
process.exit(1);
}
} catch (error: any) {
this.handleError(error);
process.exit(1);
displayError(error);
}
}
@@ -324,26 +324,54 @@ export class ContextCommand extends Command {
};
}
// Prompt for selection
const { selectedBrief } = await inquirer.prompt([
{
type: 'list',
name: 'selectedBrief',
message: 'Select a brief:',
choices: [
{ name: '(No brief - organization level)', value: null },
...briefs.map((brief) => ({
name: `Brief ${brief.id} (${new Date(brief.createdAt).toLocaleDateString()})`,
// Prompt for selection with search
const selectedBrief = await search<(typeof briefs)[0] | null>({
message: 'Search for a brief:',
source: async (input) => {
const searchTerm = input?.toLowerCase() || '';
// Static option for no brief
const noBriefOption = {
name: '(No brief - organization level)',
value: null as any,
description: 'Clear brief selection'
};
// Filter and map brief options
const briefOptions = briefs
.filter((brief) => {
if (!searchTerm) return true;
const title = brief.document?.title || '';
const shortId = brief.id.slice(0, 8);
// Search by title first, then by UUID
return (
title.toLowerCase().includes(searchTerm) ||
brief.id.toLowerCase().includes(searchTerm) ||
shortId.toLowerCase().includes(searchTerm)
);
})
.map((brief) => {
const title =
brief.document?.title || `Brief ${brief.id.slice(0, 8)}`;
const shortId = brief.id.slice(0, 8);
return {
name: `${title} ${chalk.gray(`(${shortId})`)}`,
value: brief
}))
]
};
});
return [noBriefOption, ...briefOptions];
}
]);
});
if (selectedBrief) {
// Update context with brief
const briefName = `Brief ${selectedBrief.id.slice(0, 8)}`;
await this.authManager.updateContext({
const briefName =
selectedBrief.document?.title ||
`Brief ${selectedBrief.id.slice(0, 8)}`;
this.authManager.updateContext({
briefId: selectedBrief.id,
briefName: briefName
});
@@ -353,12 +381,12 @@ export class ContextCommand extends Command {
return {
success: true,
action: 'select-brief',
context: (await this.authManager.getContext()) || undefined,
message: `Selected brief: ${selectedBrief.name}`
context: this.authManager.getContext() || undefined,
message: `Selected brief: ${selectedBrief.document?.title}`
};
} else {
// Clear brief selection
await this.authManager.updateContext({
this.authManager.updateContext({
briefId: undefined,
briefName: undefined
});
@@ -368,7 +396,7 @@ export class ContextCommand extends Command {
return {
success: true,
action: 'select-brief',
context: (await this.authManager.getContext()) || undefined,
context: this.authManager.getContext() || undefined,
message: 'Cleared brief selection'
};
}
@@ -396,8 +424,7 @@ export class ContextCommand extends Command {
process.exit(1);
}
} catch (error: any) {
this.handleError(error);
process.exit(1);
displayError(error);
}
}
@@ -443,8 +470,7 @@ export class ContextCommand extends Command {
process.exit(1);
}
} catch (error: any) {
this.handleError(error);
process.exit(1);
displayError(error);
}
}
@@ -468,7 +494,7 @@ export class ContextCommand extends Command {
if (!briefId) {
spinner.fail('Could not extract a brief ID from the provided input');
ui.displayError(
`Provide a valid brief ID or a Hamster brief URL, e.g. https://${process.env.TM_PUBLIC_BASE_DOMAIN}/home/hamster/briefs/<id>`
`Provide a valid brief ID or a Hamster brief URL, e.g. https://${process.env.TM_BASE_DOMAIN || process.env.TM_PUBLIC_BASE_DOMAIN}/home/hamster/briefs/<id>`
);
process.exit(1);
}
@@ -480,20 +506,24 @@ export class ContextCommand extends Command {
process.exit(1);
}
// Fetch org to get a friendly name (optional)
// Fetch org to get a friendly name and slug (optional)
let orgName: string | undefined;
let orgSlug: string | undefined;
try {
const org = await this.authManager.getOrganization(brief.accountId);
orgName = org?.name;
orgSlug = org?.slug;
} catch {
// Non-fatal if org lookup fails
}
// Update context: set org and brief
const briefName = `Brief ${brief.id.slice(0, 8)}`;
await this.authManager.updateContext({
const briefName =
brief.document?.title || `Brief ${brief.id.slice(0, 8)}`;
this.authManager.updateContext({
orgId: brief.accountId,
orgName,
orgSlug,
briefId: brief.id,
briefName
});
@@ -508,15 +538,14 @@ export class ContextCommand extends Command {
this.setLastResult({
success: true,
action: 'set',
context: (await this.authManager.getContext()) || undefined,
context: this.authManager.getContext() || undefined,
message: 'Context set from brief'
});
} catch (error: any) {
try {
if (spinner?.isSpinning) spinner.stop();
} catch {}
this.handleError(error);
process.exit(1);
displayError(error);
}
}
@@ -613,7 +642,7 @@ export class ContextCommand extends Command {
};
}
await this.authManager.updateContext(context);
this.authManager.updateContext(context);
ui.displaySuccess('Context updated');
// Display what was set
@@ -631,7 +660,7 @@ export class ContextCommand extends Command {
return {
success: true,
action: 'set',
context: (await this.authManager.getContext()) || undefined,
context: this.authManager.getContext() || undefined,
message: 'Context updated'
};
} catch (error) {
@@ -645,26 +674,6 @@ export class ContextCommand extends Command {
}
}
/**
* Handle errors
*/
private handleError(error: any): void {
if (error instanceof AuthenticationError) {
console.error(chalk.red(`\n✗ ${error.message}`));
if (error.code === 'NOT_AUTHENTICATED') {
ui.displayWarning('Please authenticate first: tm auth login');
}
} else {
const msg = error?.message ?? String(error);
console.error(chalk.red(`Error: ${msg}`));
if (error.stack && process.env.DEBUG) {
console.error(chalk.gray(error.stack));
}
}
}
/**
* Set the last result for programmatic access
*/
@@ -682,10 +691,57 @@ export class ContextCommand extends Command {
/**
* Get current context (for programmatic usage)
*/
getContext(): Promise<UserContext | null> {
getContext(): UserContext | null {
return this.authManager.getContext();
}
/**
* Interactive context setup (for post-auth flow)
* Prompts user to select org and brief
*/
async setupContextInteractive(): Promise<{
success: boolean;
orgSelected: boolean;
briefSelected: boolean;
}> {
try {
// Ask if user wants to set up workspace context
const { setupContext } = await inquirer.prompt([
{
type: 'confirm',
name: 'setupContext',
message: 'Would you like to set up your workspace context now?',
default: true
}
]);
if (!setupContext) {
return { success: true, orgSelected: false, briefSelected: false };
}
// Select organization
const orgResult = await this.selectOrganization();
if (!orgResult.success || !orgResult.context?.orgId) {
return { success: false, orgSelected: false, briefSelected: false };
}
// Select brief
const briefResult = await this.selectBrief(orgResult.context.orgId);
return {
success: true,
orgSelected: true,
briefSelected: briefResult.success
};
} catch (error) {
console.error(
chalk.yellow(
'\nContext setup skipped due to error. You can set it up later with "tm context"'
)
);
return { success: false, orgSelected: false, briefSelected: false };
}
}
/**
* Clean up resources
*/

View File

@@ -7,13 +7,10 @@ import { Command } from 'commander';
import chalk from 'chalk';
import inquirer from 'inquirer';
import ora, { Ora } from 'ora';
import {
AuthManager,
AuthenticationError,
type UserContext
} from '@tm/core/auth';
import { AuthManager, type UserContext } from '@tm/core/auth';
import { TaskMasterCore, type ExportResult } from '@tm/core';
import * as ui from '../utils/ui.js';
import { displayError } from '../utils/error-handler.js';
/**
* Result type from export command
@@ -197,8 +194,7 @@ export class ExportCommand extends Command {
};
} catch (error: any) {
if (spinner?.isSpinning) spinner.fail('Export failed');
this.handleError(error);
process.exit(1);
displayError(error);
}
}
@@ -334,26 +330,6 @@ export class ExportCommand extends Command {
return confirmed;
}
/**
* Handle errors
*/
private handleError(error: any): void {
if (error instanceof AuthenticationError) {
console.error(chalk.red(`\n✗ ${error.message}`));
if (error.code === 'NOT_AUTHENTICATED') {
ui.displayWarning('Please authenticate first: tm auth login');
}
} else {
const msg = error?.message ?? String(error);
console.error(chalk.red(`Error: ${msg}`));
if (error.stack && process.env.DEBUG) {
console.error(chalk.gray(error.stack));
}
}
}
/**
* Get the last export result (useful for testing)
*/

View File

@@ -17,8 +17,9 @@ import {
} from '@tm/core';
import type { StorageType } from '@tm/core/types';
import * as ui from '../utils/ui.js';
import { displayError } from '../utils/error-handler.js';
import { displayCommandHeader } from '../utils/display-helpers.js';
import {
displayHeader,
displayDashboards,
calculateTaskStatistics,
calculateSubtaskStatistics,
@@ -106,14 +107,7 @@ export class ListTasksCommand extends Command {
this.displayResults(result, options);
}
} catch (error: any) {
const msg = error?.getSanitizedDetails?.() ?? {
message: error?.message ?? String(error)
};
console.error(chalk.red(`Error: ${msg.message || 'Unexpected error'}`));
if (error.stack && process.env.DEBUG) {
console.error(chalk.gray(error.stack));
}
process.exit(1);
displayError(error);
}
}
@@ -257,15 +251,12 @@ export class ListTasksCommand extends Command {
* Display in text format with tables
*/
private displayText(data: ListTasksResult, withSubtasks?: boolean): void {
const { tasks, tag } = data;
const { tasks, tag, storageType } = data;
// Get file path for display
const filePath = this.tmCore ? `.taskmaster/tasks/tasks.json` : undefined;
// Display header without banner (banner already shown by main CLI)
displayHeader({
// Display header using utility function
displayCommandHeader(this.tmCore, {
tag: tag || 'master',
filePath: filePath
storageType
});
// No tasks message

View File

@@ -0,0 +1,248 @@
/**
* @fileoverview NextCommand using Commander's native class pattern
* Extends Commander.Command for better integration with the framework
*/
import path from 'node:path';
import { Command } from 'commander';
import chalk from 'chalk';
import boxen from 'boxen';
import { createTaskMasterCore, type Task, type TaskMasterCore } from '@tm/core';
import type { StorageType } from '@tm/core/types';
import { displayError } from '../utils/error-handler.js';
import { displayTaskDetails } from '../ui/components/task-detail.component.js';
import { displayCommandHeader } from '../utils/display-helpers.js';
/**
* Options interface for the next command
*/
export interface NextCommandOptions {
tag?: string;
format?: 'text' | 'json';
silent?: boolean;
project?: string;
}
/**
* Result type from next command
*/
export interface NextTaskResult {
task: Task | null;
found: boolean;
tag: string;
storageType: Exclude<StorageType, 'auto'>;
}
/**
* NextCommand extending Commander's Command class
* This is a thin presentation layer over @tm/core
*/
export class NextCommand extends Command {
private tmCore?: TaskMasterCore;
private lastResult?: NextTaskResult;
constructor(name?: string) {
super(name || 'next');
// Configure the command
this.description('Find the next available task to work on')
.option('-t, --tag <tag>', 'Filter by tag')
.option('-f, --format <format>', 'Output format (text, json)', 'text')
.option('--silent', 'Suppress output (useful for programmatic usage)')
.option('-p, --project <path>', 'Project root directory', process.cwd())
.action(async (options: NextCommandOptions) => {
await this.executeCommand(options);
});
}
/**
* Execute the next command
*/
private async executeCommand(options: NextCommandOptions): Promise<void> {
let hasError = false;
try {
// Validate options (throws on invalid options)
this.validateOptions(options);
// Initialize tm-core
await this.initializeCore(options.project || process.cwd());
// Get next task from core
const result = await this.getNextTask(options);
// Store result for programmatic access
this.setLastResult(result);
// Display results
if (!options.silent) {
this.displayResults(result, options);
}
} catch (error: any) {
hasError = true;
displayError(error, { skipExit: true });
} finally {
// Always clean up resources, even on error
await this.cleanup();
}
// Exit after cleanup completes
if (hasError) {
process.exit(1);
}
}
/**
* Validate command options
*/
private validateOptions(options: NextCommandOptions): void {
// Validate format
if (options.format && !['text', 'json'].includes(options.format)) {
throw new Error(
`Invalid format: ${options.format}. Valid formats are: text, json`
);
}
}
/**
* Initialize TaskMasterCore
*/
private async initializeCore(projectRoot: string): Promise<void> {
if (!this.tmCore) {
const resolved = path.resolve(projectRoot);
this.tmCore = await createTaskMasterCore({ projectPath: resolved });
}
}
/**
* Get next task from tm-core
*/
private async getNextTask(
options: NextCommandOptions
): Promise<NextTaskResult> {
if (!this.tmCore) {
throw new Error('TaskMasterCore not initialized');
}
// Call tm-core to get next task
const task = await this.tmCore.getNextTask(options.tag);
// Get storage type and active tag
const storageType = this.tmCore.getStorageType();
if (storageType === 'auto') {
throw new Error('Storage type must be resolved before use');
}
const activeTag = options.tag || this.tmCore.getActiveTag();
return {
task,
found: task !== null,
tag: activeTag,
storageType
};
}
/**
* Display results based on format
*/
private displayResults(
result: NextTaskResult,
options: NextCommandOptions
): void {
const format = options.format || 'text';
switch (format) {
case 'json':
this.displayJson(result);
break;
case 'text':
default:
this.displayText(result);
break;
}
}
/**
* Display in JSON format
*/
private displayJson(result: NextTaskResult): void {
console.log(JSON.stringify(result, null, 2));
}
/**
* Display in text format
*/
private displayText(result: NextTaskResult): void {
// Display header with storage info
displayCommandHeader(this.tmCore, {
tag: result.tag || 'master',
storageType: result.storageType
});
if (!result.found || !result.task) {
// No next task available
console.log(
boxen(
chalk.yellow(
'No tasks available to work on. All tasks are either completed, blocked by dependencies, or in progress.'
),
{
padding: 1,
borderStyle: 'round',
borderColor: 'yellow',
title: '⚠ NO TASKS AVAILABLE ⚠',
titleAlignment: 'center'
}
)
);
console.log(
`\n${chalk.dim('Tip: Try')} ${chalk.cyan('task-master list --status pending')} ${chalk.dim('to see all pending tasks')}`
);
return;
}
const task = result.task;
// Display the task details using the same component as 'show' command
// with a custom header indicating this is the next task
const customHeader = `Next Task: #${task.id} - ${task.title}`;
displayTaskDetails(task, {
customHeader,
headerColor: 'green',
showSuggestedActions: true
});
}
/**
* Set the last result for programmatic access
*/
private setLastResult(result: NextTaskResult): void {
this.lastResult = result;
}
/**
* Get the last result (for programmatic usage)
*/
getLastResult(): NextTaskResult | undefined {
return this.lastResult;
}
/**
* Clean up resources
*/
async cleanup(): Promise<void> {
if (this.tmCore) {
await this.tmCore.close();
this.tmCore = undefined;
}
}
/**
* Register this command on an existing program
*/
static register(program: Command, name?: string): NextCommand {
const nextCommand = new NextCommand(name);
program.addCommand(nextCommand);
return nextCommand;
}
}

View File

@@ -12,6 +12,7 @@ import {
type TaskStatus
} from '@tm/core';
import type { StorageType } from '@tm/core/types';
import { displayError } from '../utils/error-handler.js';
/**
* Valid task status values for validation
@@ -85,6 +86,7 @@ export class SetStatusCommand extends Command {
private async executeCommand(
options: SetStatusCommandOptions
): Promise<void> {
let hasError = false;
try {
// Validate required options
if (!options.id) {
@@ -135,16 +137,15 @@ export class SetStatusCommand extends Command {
oldStatus: result.oldStatus,
newStatus: result.newStatus
});
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
if (!options.silent) {
console.error(
chalk.red(`Failed to update task ${taskId}: ${errorMessage}`)
);
}
} catch (error: any) {
hasError = true;
if (options.format === 'json') {
const errorMessage = error?.getSanitizedDetails
? error.getSanitizedDetails().message
: error instanceof Error
? error.message
: String(error);
console.log(
JSON.stringify({
success: false,
@@ -153,8 +154,13 @@ export class SetStatusCommand extends Command {
timestamp: new Date().toISOString()
})
);
} else if (!options.silent) {
// Show which task failed with context
console.error(chalk.red(`\nFailed to update task ${taskId}:`));
displayError(error, { skipExit: true });
}
process.exit(1);
// Don't exit here - let finally block clean up first
break;
}
}
@@ -170,25 +176,26 @@ export class SetStatusCommand extends Command {
// Display results
this.displayResults(this.lastResult, options);
} catch (error) {
} catch (error: any) {
hasError = true;
if (options.format === 'json') {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error occurred';
if (!options.silent) {
console.error(chalk.red(`Error: ${errorMessage}`));
}
if (options.format === 'json') {
console.log(JSON.stringify({ success: false, error: errorMessage }));
} else if (!options.silent) {
displayError(error, { skipExit: true });
}
process.exit(1);
} finally {
// Clean up resources
if (this.tmCore) {
await this.tmCore.close();
}
}
// Exit after cleanup completes
if (hasError) {
process.exit(1);
}
}
/**

View File

@@ -9,7 +9,9 @@ import boxen from 'boxen';
import { createTaskMasterCore, type Task, type TaskMasterCore } from '@tm/core';
import type { StorageType } from '@tm/core/types';
import * as ui from '../utils/ui.js';
import { displayError } from '../utils/error-handler.js';
import { displayTaskDetails } from '../ui/components/task-detail.component.js';
import { displayCommandHeader } from '../utils/display-helpers.js';
/**
* Options interface for the show command
@@ -112,14 +114,7 @@ export class ShowCommand extends Command {
this.displayResults(result, options);
}
} catch (error: any) {
const msg = error?.getSanitizedDetails?.() ?? {
message: error?.message ?? String(error)
};
console.error(chalk.red(`Error: ${msg.message || 'Unexpected error'}`));
if (error.stack && process.env.DEBUG) {
console.error(chalk.gray(error.stack));
}
process.exit(1);
displayError(error);
}
}
@@ -257,6 +252,15 @@ export class ShowCommand extends Command {
return;
}
// Display header with storage info
const activeTag = this.tmCore?.getActiveTag() || 'master';
displayCommandHeader(this.tmCore, {
tag: activeTag,
storageType: result.storageType
});
console.log(); // Add spacing
// Use the global task details display function
displayTaskDetails(result.task, {
statusFilter: options.status,
@@ -271,8 +275,12 @@ export class ShowCommand extends Command {
result: ShowMultipleTasksResult,
_options: ShowCommandOptions
): void {
// Header
ui.displayBanner(`Tasks (${result.tasks.length} found)`);
// Display header with storage info
const activeTag = this.tmCore?.getActiveTag() || 'master';
displayCommandHeader(this.tmCore, {
tag: activeTag,
storageType: result.storageType
});
if (result.notFound.length > 0) {
console.log(chalk.yellow(`\n⚠ Not found: ${result.notFound.join(', ')}`));
@@ -291,8 +299,6 @@ export class ShowCommand extends Command {
showDependencies: true
})
);
console.log(`\n${chalk.gray('Storage: ' + result.storageType)}`);
}
/**

View File

@@ -16,6 +16,7 @@ import {
} from '@tm/core';
import { displayTaskDetails } from '../ui/components/task-detail.component.js';
import * as ui from '../utils/ui.js';
import { displayError } from '../utils/error-handler.js';
/**
* CLI-specific options interface for the start command
@@ -160,8 +161,7 @@ export class StartCommand extends Command {
if (spinner) {
spinner.fail('Operation failed');
}
this.handleError(error);
process.exit(1);
displayError(error);
}
}
@@ -452,22 +452,6 @@ export class StartCommand extends Command {
console.log(`\n${chalk.gray('Storage: ' + result.storageType)}`);
}
/**
* Handle general errors
*/
private handleError(error: any): void {
const msg = error?.getSanitizedDetails?.() ?? {
message: error?.message ?? String(error)
};
console.error(chalk.red(`Error: ${msg.message || 'Unexpected error'}`));
// Show stack trace in development mode or when DEBUG is set
const isDevelopment = process.env.NODE_ENV !== 'production';
if ((isDevelopment || process.env.DEBUG) && error.stack) {
console.error(chalk.gray(error.stack));
}
}
/**
* Set the last result for programmatic access
*/

View File

@@ -6,6 +6,7 @@
// Commands
export { ListTasksCommand } from './commands/list.command.js';
export { ShowCommand } from './commands/show.command.js';
export { NextCommand } from './commands/next.command.js';
export { AuthCommand } from './commands/auth.command.js';
export { ContextCommand } from './commands/context.command.js';
export { StartCommand } from './commands/start.command.js';
@@ -23,6 +24,9 @@ export {
// UI utilities (for other commands to use)
export * as ui from './utils/ui.js';
// Error handling utilities
export { displayError, isDebugMode } from './utils/error-handler.js';
// Auto-update utilities
export {
checkForUpdate,

View File

@@ -5,6 +5,16 @@
import chalk from 'chalk';
/**
* Brief information for API storage
*/
export interface BriefInfo {
briefId: string;
briefName: string;
orgSlug?: string;
webAppUrl?: string;
}
/**
* Header configuration options
*/
@@ -12,16 +22,44 @@ export interface HeaderOptions {
title?: string;
tag?: string;
filePath?: string;
storageType?: 'api' | 'file';
briefInfo?: BriefInfo;
}
/**
* Display the Task Master header with project info
*/
export function displayHeader(options: HeaderOptions = {}): void {
const { filePath, tag } = options;
const { filePath, tag, storageType, briefInfo } = options;
// Display tag and file path info
if (tag) {
// Display different header based on storage type
if (storageType === 'api' && briefInfo) {
// API storage: Show brief information
const briefDisplay = `🏷 Brief: ${chalk.cyan(briefInfo.briefName)} ${chalk.gray(`(${briefInfo.briefId})`)}`;
console.log(briefDisplay);
// Construct and display the brief URL or ID
if (briefInfo.webAppUrl && briefInfo.orgSlug) {
const briefUrl = `${briefInfo.webAppUrl}/home/${briefInfo.orgSlug}/briefs/${briefInfo.briefId}/plan`;
console.log(`Listing tasks from: ${chalk.dim(briefUrl)}`);
} else if (briefInfo.webAppUrl) {
// Show web app URL and brief ID if org slug is missing
console.log(
`Listing tasks from: ${chalk.dim(`${briefInfo.webAppUrl} (Brief: ${briefInfo.briefId})`)}`
);
console.log(
chalk.yellow(
`💡 Tip: Run ${chalk.cyan('tm context select')} to set your organization and see the full URL`
)
);
} else {
// Fallback: just show the brief ID if we can't get web app URL
console.log(
`Listing tasks from: ${chalk.dim(`API (Brief ID: ${briefInfo.briefId})`)}`
);
}
} else if (tag) {
// File storage: Show tag information
let tagInfo = '';
if (tag && tag !== 'master') {
@@ -39,7 +77,5 @@ export function displayHeader(options: HeaderOptions = {}): void {
: `${process.cwd()}/${filePath}`;
console.log(`Listing tasks from: ${chalk.dim(absolutePath)}`);
}
console.log(); // Empty line for spacing
}
}

View File

@@ -6,7 +6,7 @@
import chalk from 'chalk';
import boxen from 'boxen';
import type { Task } from '@tm/core/types';
import { getComplexityWithColor } from '../../utils/ui.js';
import { getComplexityWithColor, getBoxWidth } from '../../utils/ui.js';
/**
* Next task display options
@@ -113,7 +113,7 @@ export function displayRecommendedNextTask(
borderColor: '#FFA500', // Orange color
title: chalk.hex('#FFA500')('⚡ RECOMMENDED NEXT TASK ⚡'),
titleAlignment: 'center',
width: process.stdout.columns * 0.97,
width: getBoxWidth(0.97),
fullscreen: false
})
);

View File

@@ -5,6 +5,7 @@
import chalk from 'chalk';
import boxen from 'boxen';
import { getBoxWidth } from '../../utils/ui.js';
/**
* Display suggested next steps section
@@ -24,7 +25,7 @@ export function displaySuggestedNextSteps(): void {
margin: { top: 0, bottom: 1 },
borderStyle: 'round',
borderColor: 'gray',
width: process.stdout.columns * 0.97
width: getBoxWidth(0.97)
}
)
);

View File

@@ -0,0 +1,75 @@
/**
* @fileoverview Display helper utilities for commands
* Provides DRY utilities for displaying headers and other command output
*/
import type { TaskMasterCore } from '@tm/core';
import type { StorageType } from '@tm/core/types';
import { displayHeader, type BriefInfo } from '../ui/index.js';
/**
* Get web app base URL from environment
*/
function getWebAppUrl(): string | undefined {
const baseDomain =
process.env.TM_BASE_DOMAIN || process.env.TM_PUBLIC_BASE_DOMAIN;
if (!baseDomain) {
return undefined;
}
// If it already includes protocol, use as-is
if (baseDomain.startsWith('http://') || baseDomain.startsWith('https://')) {
return baseDomain;
}
// Otherwise, add protocol based on domain
if (baseDomain.includes('localhost') || baseDomain.includes('127.0.0.1')) {
return `http://${baseDomain}`;
}
return `https://${baseDomain}`;
}
/**
* Display the command header with appropriate storage information
* Handles both API and file storage displays
*/
export function displayCommandHeader(
tmCore: TaskMasterCore | undefined,
options: {
tag?: string;
storageType: Exclude<StorageType, 'auto'>;
}
): void {
const { tag, storageType } = options;
// Get brief info if using API storage
let briefInfo: BriefInfo | undefined;
if (storageType === 'api' && tmCore) {
const storageInfo = tmCore.getStorageDisplayInfo();
if (storageInfo) {
// Construct full brief info with web app URL
briefInfo = {
...storageInfo,
webAppUrl: getWebAppUrl()
};
}
}
// Get file path for display (only for file storage)
// Note: The file structure is fixed for file storage and won't change.
// This is a display-only relative path, not used for actual file operations.
const filePath =
storageType === 'file' && tmCore
? `.taskmaster/tasks/tasks.json`
: undefined;
// Display header
displayHeader({
tag: tag || 'master',
filePath: filePath,
storageType: storageType === 'api' ? 'api' : 'file',
briefInfo: briefInfo
});
}

View File

@@ -0,0 +1,60 @@
/**
* @fileoverview Centralized error handling utilities for CLI
* Provides consistent error formatting and debug mode detection
*/
import chalk from 'chalk';
/**
* Check if debug mode is enabled via environment variable
* Only returns true when DEBUG is explicitly set to 'true' or '1'
*
* @returns True if debug mode is enabled
*/
export function isDebugMode(): boolean {
return process.env.DEBUG === 'true' || process.env.DEBUG === '1';
}
/**
* Display an error to the user with optional stack trace in debug mode
* Handles both TaskMasterError instances and regular errors
*
* @param error - The error to display
* @param options - Display options
*/
export function displayError(
error: any,
options: {
/** Skip exit, useful when caller wants to handle exit */
skipExit?: boolean;
/** Force show stack trace regardless of debug mode */
forceStack?: boolean;
} = {}
): void {
// Check if it's a TaskMasterError with sanitized details
if (error?.getSanitizedDetails) {
const sanitized = error.getSanitizedDetails();
console.error(chalk.red(`\n${sanitized.message}`));
// Show stack trace in debug mode or if forced
if ((isDebugMode() || options.forceStack) && error.stack) {
console.error(chalk.gray('\nStack trace:'));
console.error(chalk.gray(error.stack));
}
} else {
// For other errors, show the message
const message = error?.message ?? String(error);
console.error(chalk.red(`\nError: ${message}`));
// Show stack trace in debug mode or if forced
if ((isDebugMode() || options.forceStack) && error?.stack) {
console.error(chalk.gray('\nStack trace:'));
console.error(chalk.gray(error.stack));
}
}
// Exit if not skipped
if (!options.skipExit) {
process.exit(1);
}
}

View File

@@ -0,0 +1,158 @@
/**
* CLI UI utilities tests
* Tests for apps/cli/src/utils/ui.ts
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type { MockInstance } from 'vitest';
import { getBoxWidth } from './ui.js';
describe('CLI UI Utilities', () => {
describe('getBoxWidth', () => {
let columnsSpy: MockInstance;
let originalDescriptor: PropertyDescriptor | undefined;
beforeEach(() => {
// Store original descriptor if it exists
originalDescriptor = Object.getOwnPropertyDescriptor(
process.stdout,
'columns'
);
// If columns doesn't exist or isn't a getter, define it as one
if (!originalDescriptor || !originalDescriptor.get) {
const currentValue = process.stdout.columns || 80;
Object.defineProperty(process.stdout, 'columns', {
get() {
return currentValue;
},
configurable: true
});
}
// Now spy on the getter
columnsSpy = vi.spyOn(process.stdout, 'columns', 'get');
});
afterEach(() => {
// Restore the spy
columnsSpy.mockRestore();
// Restore original descriptor or delete the property
if (originalDescriptor) {
Object.defineProperty(process.stdout, 'columns', originalDescriptor);
} else {
delete (process.stdout as any).columns;
}
});
it('should calculate width as percentage of terminal width', () => {
columnsSpy.mockReturnValue(100);
const width = getBoxWidth(0.9, 40);
expect(width).toBe(90);
});
it('should use default percentage of 0.9 when not specified', () => {
columnsSpy.mockReturnValue(100);
const width = getBoxWidth();
expect(width).toBe(90);
});
it('should use default minimum width of 40 when not specified', () => {
columnsSpy.mockReturnValue(30);
const width = getBoxWidth();
expect(width).toBe(40); // Should enforce minimum
});
it('should enforce minimum width when terminal is too narrow', () => {
columnsSpy.mockReturnValue(50);
const width = getBoxWidth(0.9, 60);
expect(width).toBe(60); // Should use minWidth instead of 45
});
it('should handle undefined process.stdout.columns', () => {
columnsSpy.mockReturnValue(undefined);
const width = getBoxWidth(0.9, 40);
// Should fall back to 80 columns: Math.floor(80 * 0.9) = 72
expect(width).toBe(72);
});
it('should handle custom percentage values', () => {
columnsSpy.mockReturnValue(100);
expect(getBoxWidth(0.95, 40)).toBe(95);
expect(getBoxWidth(0.8, 40)).toBe(80);
expect(getBoxWidth(0.5, 40)).toBe(50);
});
it('should handle custom minimum width values', () => {
columnsSpy.mockReturnValue(60);
expect(getBoxWidth(0.9, 70)).toBe(70); // 60 * 0.9 = 54, but min is 70
expect(getBoxWidth(0.9, 50)).toBe(54); // 60 * 0.9 = 54, min is 50
});
it('should floor the calculated width', () => {
columnsSpy.mockReturnValue(99);
const width = getBoxWidth(0.9, 40);
// 99 * 0.9 = 89.1, should floor to 89
expect(width).toBe(89);
});
it('should match warning box width calculation', () => {
// Test the specific case from displayWarning()
columnsSpy.mockReturnValue(80);
const width = getBoxWidth(0.9, 40);
expect(width).toBe(72);
});
it('should match table width calculation', () => {
// Test the specific case from createTaskTable()
columnsSpy.mockReturnValue(111);
const width = getBoxWidth(0.9, 100);
// 111 * 0.9 = 99.9, floor to 99, but max(99, 100) = 100
expect(width).toBe(100);
});
it('should match recommended task box width calculation', () => {
// Test the specific case from displayRecommendedNextTask()
columnsSpy.mockReturnValue(120);
const width = getBoxWidth(0.97, 40);
// 120 * 0.97 = 116.4, floor to 116
expect(width).toBe(116);
});
it('should handle edge case of zero terminal width', () => {
columnsSpy.mockReturnValue(0);
const width = getBoxWidth(0.9, 40);
// When columns is 0, it uses fallback of 80: Math.floor(80 * 0.9) = 72
expect(width).toBe(72);
});
it('should handle very large terminal widths', () => {
columnsSpy.mockReturnValue(1000);
const width = getBoxWidth(0.9, 40);
expect(width).toBe(900);
});
it('should handle very small percentages', () => {
columnsSpy.mockReturnValue(100);
const width = getBoxWidth(0.1, 5);
// 100 * 0.1 = 10, which is greater than min 5
expect(width).toBe(10);
});
it('should handle percentage of 1.0 (100%)', () => {
columnsSpy.mockReturnValue(80);
const width = getBoxWidth(1.0, 40);
expect(width).toBe(80);
});
it('should consistently return same value for same inputs', () => {
columnsSpy.mockReturnValue(100);
const width1 = getBoxWidth(0.9, 40);
const width2 = getBoxWidth(0.9, 40);
const width3 = getBoxWidth(0.9, 40);
expect(width1).toBe(width2);
expect(width2).toBe(width3);
});
});
});

View File

@@ -126,6 +126,20 @@ export function getComplexityWithScore(complexity: number | undefined): string {
return color(`${complexity}/10 (${label})`);
}
/**
* Calculate box width as percentage of terminal width
* @param percentage - Percentage of terminal width to use (default: 0.9)
* @param minWidth - Minimum width to enforce (default: 40)
* @returns Calculated box width
*/
export function getBoxWidth(
percentage: number = 0.9,
minWidth: number = 40
): number {
const terminalWidth = process.stdout.columns || 80;
return Math.max(Math.floor(terminalWidth * percentage), minWidth);
}
/**
* Truncate text to specified length
*/
@@ -176,6 +190,8 @@ export function displayBanner(title: string = 'Task Master'): void {
* Display an error message (matches scripts/modules/ui.js style)
*/
export function displayError(message: string, details?: string): void {
const boxWidth = getBoxWidth();
console.error(
boxen(
chalk.red.bold('X Error: ') +
@@ -184,7 +200,8 @@ export function displayError(message: string, details?: string): void {
{
padding: 1,
borderStyle: 'round',
borderColor: 'red'
borderColor: 'red',
width: boxWidth
}
)
);
@@ -194,13 +211,16 @@ export function displayError(message: string, details?: string): void {
* Display a success message
*/
export function displaySuccess(message: string): void {
const boxWidth = getBoxWidth();
console.log(
boxen(
chalk.green.bold(String.fromCharCode(8730) + ' ') + chalk.white(message),
{
padding: 1,
borderStyle: 'round',
borderColor: 'green'
borderColor: 'green',
width: boxWidth
}
)
);
@@ -210,11 +230,14 @@ export function displaySuccess(message: string): void {
* Display a warning message
*/
export function displayWarning(message: string): void {
const boxWidth = getBoxWidth();
console.log(
boxen(chalk.yellow.bold('⚠ ') + chalk.white(message), {
padding: 1,
borderStyle: 'round',
borderColor: 'yellow'
borderColor: 'yellow',
width: boxWidth
})
);
}
@@ -223,11 +246,14 @@ export function displayWarning(message: string): void {
* Display info message
*/
export function displayInfo(message: string): void {
const boxWidth = getBoxWidth();
console.log(
boxen(chalk.blue.bold('i ') + chalk.white(message), {
padding: 1,
borderStyle: 'round',
borderColor: 'blue'
borderColor: 'blue',
width: boxWidth
})
);
}
@@ -282,23 +308,23 @@ export function createTaskTable(
} = options || {};
// Calculate dynamic column widths based on terminal width
const terminalWidth = process.stdout.columns * 0.9 || 100;
const tableWidth = getBoxWidth(0.9, 100);
// Adjust column widths to better match the original layout
const baseColWidths = showComplexity
? [
Math.floor(terminalWidth * 0.1),
Math.floor(terminalWidth * 0.4),
Math.floor(terminalWidth * 0.15),
Math.floor(terminalWidth * 0.1),
Math.floor(terminalWidth * 0.2),
Math.floor(terminalWidth * 0.1)
Math.floor(tableWidth * 0.1),
Math.floor(tableWidth * 0.4),
Math.floor(tableWidth * 0.15),
Math.floor(tableWidth * 0.1),
Math.floor(tableWidth * 0.2),
Math.floor(tableWidth * 0.1)
] // ID, Title, Status, Priority, Dependencies, Complexity
: [
Math.floor(terminalWidth * 0.08),
Math.floor(terminalWidth * 0.4),
Math.floor(terminalWidth * 0.18),
Math.floor(terminalWidth * 0.12),
Math.floor(terminalWidth * 0.2)
Math.floor(tableWidth * 0.08),
Math.floor(tableWidth * 0.4),
Math.floor(tableWidth * 0.18),
Math.floor(tableWidth * 0.12),
Math.floor(tableWidth * 0.2)
]; // ID, Title, Status, Priority, Dependencies
const headers = [

View File

@@ -3,6 +3,22 @@ title: CLI Commands
sidebarTitle: "CLI Commands"
---
## Debug Mode
For troubleshooting issues, you can enable debug mode to get detailed error information:
```bash
# Enable debug mode for any command
DEBUG=true task-master <command>
# Example with next command
DEBUG=true task-master next
```
Debug mode shows full error messages, stack traces, and additional context. See the [Troubleshooting guide](/getting-started/troubleshooting) for more details.
## Commands
<AccordionGroup>
<Accordion title="Parse PRD">

View File

@@ -13,6 +13,126 @@ The MCP interface is built on top of the `fastmcp` library and registers a set o
Each tool is defined with a name, a description, and a set of parameters that are validated using the `zod` library. The `execute` function of each tool calls the corresponding core logic function from `scripts/modules/task-manager.js`.
## Configurable Tool Loading
To optimize LLM context usage, you can control which Task Master MCP tools are loaded using the `TASK_MASTER_TOOLS` environment variable. This is particularly useful when working with LLMs that have context limits or when you only need a subset of tools.
### Configuration Modes
#### All Tools (Default)
Loads all 36 available tools. Use when you need full Task Master functionality.
```json
{
"mcpServers": {
"task-master-ai": {
"command": "npx",
"args": ["-y", "task-master-ai"],
"env": {
"TASK_MASTER_TOOLS": "all",
"ANTHROPIC_API_KEY": "your_key_here"
}
}
}
}
```
If `TASK_MASTER_TOOLS` is not set, all tools are loaded by default.
#### Core Tools (Lean Mode)
Loads only 7 essential tools for daily development. Ideal for minimal context usage.
**Core tools included:**
- `get_tasks` - List all tasks
- `next_task` - Find the next task to work on
- `get_task` - Get detailed task information
- `set_task_status` - Update task status
- `update_subtask` - Add implementation notes
- `parse_prd` - Generate tasks from PRD
- `expand_task` - Break down tasks into subtasks
```json
{
"mcpServers": {
"task-master-ai": {
"command": "npx",
"args": ["-y", "task-master-ai"],
"env": {
"TASK_MASTER_TOOLS": "core",
"ANTHROPIC_API_KEY": "your_key_here"
}
}
}
}
```
You can also use `"lean"` as an alias for `"core"`.
#### Standard Tools
Loads 15 commonly used tools. Balances functionality with context efficiency.
**Standard tools include all core tools plus:**
- `initialize_project` - Set up new projects
- `analyze_project_complexity` - Analyze task complexity
- `expand_all` - Expand all eligible tasks
- `add_subtask` - Add subtasks manually
- `remove_task` - Remove tasks
- `generate` - Generate task markdown files
- `add_task` - Create new tasks
- `complexity_report` - View complexity analysis
```json
{
"mcpServers": {
"task-master-ai": {
"command": "npx",
"args": ["-y", "task-master-ai"],
"env": {
"TASK_MASTER_TOOLS": "standard",
"ANTHROPIC_API_KEY": "your_key_here"
}
}
}
}
```
#### Custom Tool Selection
Specify exactly which tools to load using a comma-separated list. Tool names are case-insensitive and support both underscores and hyphens.
```json
{
"mcpServers": {
"task-master-ai": {
"command": "npx",
"args": ["-y", "task-master-ai"],
"env": {
"TASK_MASTER_TOOLS": "get_tasks,next_task,set_task_status,update_subtask",
"ANTHROPIC_API_KEY": "your_key_here"
}
}
}
}
```
### Choosing the Right Configuration
- **Use `core`/`lean`**: When working with basic task management workflows or when context limits are strict
- **Use `standard`**: For most development workflows that include task creation and analysis
- **Use `all`**: When you need full functionality including tag management, dependencies, and advanced features
- **Use custom list**: When you have specific tool requirements or want to experiment with minimal sets
### Verification
When the MCP server starts, it logs which tools were loaded:
```
Task Master MCP Server starting...
Tool mode configuration: standard
Loading standard tools
Registering 15 MCP tools (mode: standard)
Successfully registered 15/15 tools
```
## Tool Categories
The MCP tools can be categorized in the same way as the core functionalities:

View File

@@ -33,6 +33,7 @@
]
},
"getting-started/api-keys",
"getting-started/troubleshooting",
"getting-started/faq",
"getting-started/contribute"
]

View File

@@ -3,7 +3,32 @@ title: FAQ
sidebarTitle: "FAQ"
---
Coming soon.
## Common Questions
### How do I get more detailed error information?
Enable debug mode by setting the `DEBUG` environment variable:
```bash
DEBUG=true task-master <command>
```
This shows full error messages, stack traces, and additional context. See our [Troubleshooting guide](/getting-started/troubleshooting) for complete details.
### Why are my commands failing silently?
Task Master uses sanitized error messages by default to provide a clean user experience. Enable debug mode to see technical details about what went wrong.
### How do I report a bug?
When reporting issues, please:
1. Enable debug mode and include the full error output
2. Provide your environment details (OS, Node.js version, Task Master version)
3. Include the command that failed and your project configuration
See our [Troubleshooting guide](/getting-started/troubleshooting) for complete bug reporting guidelines.
More frequently asked questions coming soon.
## 💬 Getting Help

View File

@@ -37,6 +37,25 @@ For MCP/Cursor usage: Configure keys in the env section of your .cursor/mcp.json
}
```
<Tip>
**Optimize Context Usage**: You can control which Task Master MCP tools are loaded using the `TASK_MASTER_TOOLS` environment variable. This helps reduce LLM context usage by only loading the tools you need.
Options:
- `all` (default) - All 36 tools
- `standard` - 15 commonly used tools
- `core` or `lean` - 7 essential tools
Example:
```json
"env": {
"TASK_MASTER_TOOLS": "standard",
"ANTHROPIC_API_KEY": "your_key_here"
}
```
See the [MCP Tools documentation](/capabilities/mcp#configurable-tool-loading) for details.
</Tip>
### CLI Usage: `.env` File
Create a `.env` file in your project root and include the keys for the providers you plan to use:

View File

@@ -0,0 +1,155 @@
---
title: Troubleshooting
sidebarTitle: "Troubleshooting"
---
This guide helps you troubleshoot common issues and get more detailed error information when using Task Master.
## Debug Mode
Task Master includes a debug mode that provides detailed error information including stack traces. This is especially useful when reporting issues or debugging problems.
### Enabling Debug Mode
Set the `DEBUG` environment variable to enable debug mode:
```bash
# Enable debug mode for a single command
DEBUG=true task-master next
# Or use '1' instead of 'true'
DEBUG=1 task-master list
# Enable for entire session (bash/zsh)
export DEBUG=true
task-master next # Now shows detailed errors
# Enable for entire session (fish shell)
set -x DEBUG true
task-master next
```
### What Debug Mode Shows
With debug mode enabled, errors will include:
- **Full error messages**: Complete technical details instead of sanitized user messages
- **Stack traces**: Detailed information about where errors occurred
- **Error context**: Additional metadata about the failed operation
- **Error chains**: If an error was caused by another error, both are shown
### Example Output
**Normal mode (default):**
```
Error: Unable to read tasks file
```
**Debug mode:**
```
TaskMasterError[FILE_READ_ERROR]: Unable to read tasks file
Stack trace:
at TaskService.loadTasks (/path/to/task-service.ts:123)
at NextCommand.execute (/path/to/next.command.ts:45)
...
```
## Common Issues
### File Permission Errors
**Problem**: Commands fail with file access errors
**Solution**:
1. Check file permissions in `.taskmaster/` directory
2. Ensure your user has write access to the project directory
3. Run with debug mode to see the exact file path causing issues
### API Connection Issues
**Problem**: Commands timeout or fail when using API storage
**Solutions**:
1. Check your internet connection
2. Verify API keys are correctly configured
3. Check if your organization has firewall restrictions
4. Use debug mode to see specific network errors
### Task File Corruption
**Problem**: Commands report invalid task data
**Solutions**:
1. Check if `.taskmaster/tasks/tasks.json` is valid JSON
2. Restore from backup if available
3. Run `task-master generate` to regenerate task files from main data
### Model Configuration Issues
**Problem**: AI-powered commands fail
**Solutions**:
1. Verify API keys are set: `task-master models`
2. Check if the configured model is available
3. Try switching to a different model
4. Use debug mode to see specific API error messages
## Getting Help
When reporting issues, please:
1. **Enable debug mode** and include the full error output
2. **Specify your environment**:
- Operating system and version
- Node.js version (`node --version`)
- Task Master version (`task-master --version`)
3. **Include relevant context**:
- Command that failed
- Project configuration
- Recent changes to your setup
### Support Channels
- **GitHub Issues**: [Report bugs and request features](https://github.com/eyaltoledano/claude-task-master/issues)
- **Discord**: [Join our community for help](https://discord.gg/taskmasterai)
- **GitHub Discussions**: [Ask questions and share ideas](https://github.com/eyaltoledano/claude-task-master/discussions)
## Advanced Debugging
### Inspecting Task Data
If you suspect task data corruption:
```bash
# View raw task data
cat .taskmaster/tasks/tasks.json | jq '.'
# Check specific task
task-master show <task-id> --format=json
```
### Configuration Debugging
Check your current configuration:
```bash
# View model configuration
task-master models
# Check if initialization is complete
ls -la .taskmaster/
```
### Network Debugging
For API storage issues:
```bash
# Test with curl (replace with your API endpoint)
curl -H "Authorization: Bearer $ANTHROPIC_API_KEY" \
-H "Content-Type: application/json" \
https://api.anthropic.com/v1/models
```
Remember to enable debug mode (`DEBUG=true`) when troubleshooting to get the most helpful error information.

View File

@@ -59,6 +59,76 @@ Taskmaster uses two primary methods for configuration:
- **Migration:** Use `task-master migrate` to move this to `.taskmaster/config.json`.
- **Deprecation:** While still supported, you'll see warnings encouraging migration to the new structure.
## MCP Tool Loading Configuration
### TASK_MASTER_TOOLS Environment Variable
The `TASK_MASTER_TOOLS` environment variable controls which tools are loaded by the Task Master MCP server. This allows you to optimize token usage based on your workflow needs.
> Note
> Prefer setting `TASK_MASTER_TOOLS` in your MCP client's `env` block (e.g., `.cursor/mcp.json`) or in CI/deployment env. The `.env` file is reserved for API keys/endpoints; avoid persisting non-secret settings there.
#### Configuration Options
- **`all`** (default): Loads all 36 available tools (~21,000 tokens)
- Best for: Users who need the complete feature set
- Use when: Working with complex projects requiring all Task Master features
- Backward compatibility: This is the default to maintain compatibility with existing installations
- **`standard`**: Loads 15 commonly used tools (~10,000 tokens, 50% reduction)
- Best for: Regular task management workflows
- Tools included: All core tools plus project initialization, complexity analysis, task generation, and more
- Use when: You need a balanced set of features with reduced token usage
- **`core`** (or `lean`): Loads 7 essential tools (~5,000 tokens, 70% reduction)
- Best for: Daily development with minimal token overhead
- Tools included: `get_tasks`, `next_task`, `get_task`, `set_task_status`, `update_subtask`, `parse_prd`, `expand_task`
- Use when: Working in large contexts where token usage is critical
- Note: "lean" is an alias for "core" (same tools, token estimate and recommended use). You can refer to it as either "core" or "lean" when configuring.
- **Custom list**: Comma-separated list of specific tool names
- Best for: Specialized workflows requiring specific tools
- Example: `"get_tasks,next_task,set_task_status"`
- Use when: You know exactly which tools you need
#### How to Configure
1. **In MCP configuration files** (`.cursor/mcp.json`, `.vscode/mcp.json`, etc.) - **Recommended**:
```jsonc
{
"mcpServers": {
"task-master-ai": {
"env": {
"TASK_MASTER_TOOLS": "standard", // Set tool loading mode
// API keys can still use .env for security
}
}
}
}
```
2. **Via Claude Code CLI**:
```bash
claude mcp add task-master-ai --scope user \
--env TASK_MASTER_TOOLS="core" \
-- npx -y task-master-ai@latest
```
3. **In CI/deployment environment variables**:
```bash
export TASK_MASTER_TOOLS="standard"
node mcp-server/server.js
```
#### Tool Loading Behavior
- When `TASK_MASTER_TOOLS` is unset or empty, the system defaults to `"all"`
- Invalid tool names in a user-specified list are ignored (a warning is emitted for each)
- If every tool name in a custom list is invalid, the system falls back to `"all"`
- Tool names are case-insensitive (e.g., `"CORE"`, `"core"`, and `"Core"` are treated identically)
## Environment Variables (`.env` file or MCP `env` block - For API Keys Only)
- Used **exclusively** for sensitive API keys and specific endpoint URLs.

View File

@@ -4,12 +4,14 @@ import dotenv from 'dotenv';
import { fileURLToPath } from 'url';
import fs from 'fs';
import logger from './logger.js';
import { registerTaskMasterTools } from './tools/index.js';
import {
registerTaskMasterTools,
getToolsConfiguration
} from './tools/index.js';
import ProviderRegistry from '../../src/provider-registry/index.js';
import { MCPProvider } from './providers/mcp-provider.js';
import packageJson from '../../package.json' with { type: 'json' };
// Load environment variables
dotenv.config();
// Constants
@@ -29,12 +31,10 @@ class TaskMasterMCPServer {
this.server = new FastMCP(this.options);
this.initialized = false;
// Bind methods
this.init = this.init.bind(this);
this.start = this.start.bind(this);
this.stop = this.stop.bind(this);
// Setup logging
this.logger = logger;
}
@@ -44,8 +44,34 @@ class TaskMasterMCPServer {
async init() {
if (this.initialized) return;
// Pass the manager instance to the tool registration function
registerTaskMasterTools(this.server, this.asyncManager);
const normalizedToolMode = getToolsConfiguration();
this.logger.info('Task Master MCP Server starting...');
this.logger.info(`Tool mode configuration: ${normalizedToolMode}`);
const registrationResult = registerTaskMasterTools(
this.server,
normalizedToolMode
);
this.logger.info(
`Normalized tool mode: ${registrationResult.normalizedMode}`
);
this.logger.info(
`Registered ${registrationResult.registeredTools.length} tools successfully`
);
if (registrationResult.registeredTools.length > 0) {
this.logger.debug(
`Registered tools: ${registrationResult.registeredTools.join(', ')}`
);
}
if (registrationResult.failedTools.length > 0) {
this.logger.warn(
`Failed to register ${registrationResult.failedTools.length} tools: ${registrationResult.failedTools.join(', ')}`
);
}
this.initialized = true;

View File

@@ -3,109 +3,238 @@
* Export all Task Master CLI tools for MCP server
*/
import { registerListTasksTool } from './get-tasks.js';
import logger from '../logger.js';
import { registerSetTaskStatusTool } from './set-task-status.js';
import { registerParsePRDTool } from './parse-prd.js';
import { registerUpdateTool } from './update.js';
import { registerUpdateTaskTool } from './update-task.js';
import { registerUpdateSubtaskTool } from './update-subtask.js';
import { registerGenerateTool } from './generate.js';
import { registerShowTaskTool } from './get-task.js';
import { registerNextTaskTool } from './next-task.js';
import { registerExpandTaskTool } from './expand-task.js';
import { registerAddTaskTool } from './add-task.js';
import { registerAddSubtaskTool } from './add-subtask.js';
import { registerRemoveSubtaskTool } from './remove-subtask.js';
import { registerAnalyzeProjectComplexityTool } from './analyze.js';
import { registerClearSubtasksTool } from './clear-subtasks.js';
import { registerExpandAllTool } from './expand-all.js';
import { registerRemoveDependencyTool } from './remove-dependency.js';
import { registerValidateDependenciesTool } from './validate-dependencies.js';
import { registerFixDependenciesTool } from './fix-dependencies.js';
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';
import { registerMoveTaskTool } from './move-task.js';
import { registerResponseLanguageTool } from './response-language.js';
import { registerAddTagTool } from './add-tag.js';
import { registerDeleteTagTool } from './delete-tag.js';
import { registerListTagsTool } from './list-tags.js';
import { registerUseTagTool } from './use-tag.js';
import { registerRenameTagTool } from './rename-tag.js';
import { registerCopyTagTool } from './copy-tag.js';
import { registerResearchTool } from './research.js';
import { registerRulesTool } from './rules.js';
import { registerScopeUpTool } from './scope-up.js';
import { registerScopeDownTool } from './scope-down.js';
import {
toolRegistry,
coreTools,
standardTools,
getAvailableTools,
getToolRegistration,
isValidTool
} from './tool-registry.js';
/**
* Register all Task Master tools with the MCP server
* @param {Object} server - FastMCP server instance
* Helper function to safely read and normalize the TASK_MASTER_TOOLS environment variable
* @returns {string} The tools configuration string, defaults to 'all'
*/
export function registerTaskMasterTools(server) {
export function getToolsConfiguration() {
const rawValue = process.env.TASK_MASTER_TOOLS;
if (!rawValue || rawValue.trim() === '') {
logger.debug('No TASK_MASTER_TOOLS env var found, defaulting to "all"');
return 'all';
}
const normalizedValue = rawValue.trim();
logger.debug(`TASK_MASTER_TOOLS env var: "${normalizedValue}"`);
return normalizedValue;
}
/**
* Register Task Master tools with the MCP server
* Supports selective tool loading via TASK_MASTER_TOOLS environment variable
* @param {Object} server - FastMCP server instance
* @param {string} toolMode - The tool mode configuration (defaults to 'all')
* @returns {Object} Object containing registered tools, failed tools, and normalized mode
*/
export function registerTaskMasterTools(server, toolMode = 'all') {
const registeredTools = [];
const failedTools = [];
try {
// Register each tool in a logical workflow order
const enabledTools = toolMode.trim();
let toolsToRegister = [];
// Group 1: Initialization & Setup
registerInitializeProjectTool(server);
registerModelsTool(server);
registerRulesTool(server);
registerParsePRDTool(server);
const lowerCaseConfig = enabledTools.toLowerCase();
// Group 2: Task Analysis & Expansion
registerAnalyzeProjectComplexityTool(server);
registerExpandTaskTool(server);
registerExpandAllTool(server);
registerScopeUpTool(server);
registerScopeDownTool(server);
switch (lowerCaseConfig) {
case 'all':
toolsToRegister = Object.keys(toolRegistry);
logger.info('Loading all available tools');
break;
case 'core':
case 'lean':
toolsToRegister = coreTools;
logger.info('Loading core tools only');
break;
case 'standard':
toolsToRegister = standardTools;
logger.info('Loading standard tools');
break;
default:
const requestedTools = enabledTools
.split(',')
.map((t) => t.trim())
.filter((t) => t.length > 0);
// Group 3: Task Listing & Viewing
registerListTasksTool(server);
registerShowTaskTool(server);
registerNextTaskTool(server);
registerComplexityReportTool(server);
const uniqueTools = new Set();
const unknownTools = [];
// Group 4: Task Status & Management
registerSetTaskStatusTool(server);
registerGenerateTool(server);
const aliasMap = {
response_language: 'response-language'
};
// Group 5: Task Creation & Modification
registerAddTaskTool(server);
registerAddSubtaskTool(server);
registerUpdateTool(server);
registerUpdateTaskTool(server);
registerUpdateSubtaskTool(server);
registerRemoveTaskTool(server);
registerRemoveSubtaskTool(server);
registerClearSubtasksTool(server);
registerMoveTaskTool(server);
for (const toolName of requestedTools) {
let resolvedName = null;
const lowerToolName = toolName.toLowerCase();
// Group 6: Dependency Management
registerAddDependencyTool(server);
registerRemoveDependencyTool(server);
registerValidateDependenciesTool(server);
registerFixDependenciesTool(server);
registerResponseLanguageTool(server);
if (aliasMap[lowerToolName]) {
const aliasTarget = aliasMap[lowerToolName];
for (const registryKey of Object.keys(toolRegistry)) {
if (registryKey.toLowerCase() === aliasTarget.toLowerCase()) {
resolvedName = registryKey;
break;
}
}
}
// Group 7: Tag Management
registerListTagsTool(server);
registerAddTagTool(server);
registerDeleteTagTool(server);
registerUseTagTool(server);
registerRenameTagTool(server);
registerCopyTagTool(server);
if (!resolvedName) {
for (const registryKey of Object.keys(toolRegistry)) {
if (registryKey.toLowerCase() === lowerToolName) {
resolvedName = registryKey;
break;
}
}
}
// Group 8: Research Features
registerResearchTool(server);
if (!resolvedName) {
const withHyphens = lowerToolName.replace(/_/g, '-');
for (const registryKey of Object.keys(toolRegistry)) {
if (registryKey.toLowerCase() === withHyphens) {
resolvedName = registryKey;
break;
}
}
}
if (!resolvedName) {
const withUnderscores = lowerToolName.replace(/-/g, '_');
for (const registryKey of Object.keys(toolRegistry)) {
if (registryKey.toLowerCase() === withUnderscores) {
resolvedName = registryKey;
break;
}
}
}
if (resolvedName) {
uniqueTools.add(resolvedName);
logger.debug(`Resolved tool "${toolName}" to "${resolvedName}"`);
} else {
unknownTools.push(toolName);
logger.warn(`Unknown tool specified: "${toolName}"`);
}
}
toolsToRegister = Array.from(uniqueTools);
if (unknownTools.length > 0) {
logger.warn(`Unknown tools: ${unknownTools.join(', ')}`);
}
if (toolsToRegister.length === 0) {
logger.warn(
`No valid tools found in custom list. Loading all tools as fallback.`
);
toolsToRegister = Object.keys(toolRegistry);
} else {
logger.info(
`Loading ${toolsToRegister.length} custom tools from list (${uniqueTools.size} unique after normalization)`
);
}
break;
}
logger.info(
`Registering ${toolsToRegister.length} MCP tools (mode: ${enabledTools})`
);
toolsToRegister.forEach((toolName) => {
try {
const registerFunction = getToolRegistration(toolName);
if (registerFunction) {
registerFunction(server);
logger.debug(`Registered tool: ${toolName}`);
registeredTools.push(toolName);
} else {
logger.warn(`Tool ${toolName} not found in registry`);
failedTools.push(toolName);
}
} catch (error) {
logger.error(`Error registering Task Master tools: ${error.message}`);
throw error;
if (error.message && error.message.includes('already registered')) {
logger.debug(`Tool ${toolName} already registered, skipping`);
registeredTools.push(toolName);
} else {
logger.error(`Failed to register tool ${toolName}: ${error.message}`);
failedTools.push(toolName);
}
}
});
logger.info(
`Successfully registered ${registeredTools.length}/${toolsToRegister.length} tools`
);
if (failedTools.length > 0) {
logger.warn(`Failed tools: ${failedTools.join(', ')}`);
}
return {
registeredTools,
failedTools,
normalizedMode: lowerCaseConfig
};
} catch (error) {
logger.error(
`Error parsing TASK_MASTER_TOOLS environment variable: ${error.message}`
);
logger.info('Falling back to loading all tools');
const fallbackTools = Object.keys(toolRegistry);
for (const toolName of fallbackTools) {
const registerFunction = getToolRegistration(toolName);
if (registerFunction) {
try {
registerFunction(server);
registeredTools.push(toolName);
} catch (err) {
if (err.message && err.message.includes('already registered')) {
logger.debug(
`Fallback tool ${toolName} already registered, skipping`
);
registeredTools.push(toolName);
} else {
logger.warn(
`Failed to register fallback tool '${toolName}': ${err.message}`
);
failedTools.push(toolName);
}
}
} else {
logger.warn(`Tool '${toolName}' not found in registry`);
failedTools.push(toolName);
}
}
logger.info(
`Successfully registered ${registeredTools.length} fallback tools`
);
return {
registeredTools,
failedTools,
normalizedMode: 'all'
};
}
}
export {
toolRegistry,
coreTools,
standardTools,
getAvailableTools,
getToolRegistration,
isValidTool
};
export default {
registerTaskMasterTools
};

View File

@@ -0,0 +1,168 @@
/**
* tool-registry.js
* Tool Registry Object Structure - Maps all 36 tool names to registration functions
*/
import { registerListTasksTool } from './get-tasks.js';
import { registerSetTaskStatusTool } from './set-task-status.js';
import { registerParsePRDTool } from './parse-prd.js';
import { registerUpdateTool } from './update.js';
import { registerUpdateTaskTool } from './update-task.js';
import { registerUpdateSubtaskTool } from './update-subtask.js';
import { registerGenerateTool } from './generate.js';
import { registerShowTaskTool } from './get-task.js';
import { registerNextTaskTool } from './next-task.js';
import { registerExpandTaskTool } from './expand-task.js';
import { registerAddTaskTool } from './add-task.js';
import { registerAddSubtaskTool } from './add-subtask.js';
import { registerRemoveSubtaskTool } from './remove-subtask.js';
import { registerAnalyzeProjectComplexityTool } from './analyze.js';
import { registerClearSubtasksTool } from './clear-subtasks.js';
import { registerExpandAllTool } from './expand-all.js';
import { registerRemoveDependencyTool } from './remove-dependency.js';
import { registerValidateDependenciesTool } from './validate-dependencies.js';
import { registerFixDependenciesTool } from './fix-dependencies.js';
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';
import { registerMoveTaskTool } from './move-task.js';
import { registerResponseLanguageTool } from './response-language.js';
import { registerAddTagTool } from './add-tag.js';
import { registerDeleteTagTool } from './delete-tag.js';
import { registerListTagsTool } from './list-tags.js';
import { registerUseTagTool } from './use-tag.js';
import { registerRenameTagTool } from './rename-tag.js';
import { registerCopyTagTool } from './copy-tag.js';
import { registerResearchTool } from './research.js';
import { registerRulesTool } from './rules.js';
import { registerScopeUpTool } from './scope-up.js';
import { registerScopeDownTool } from './scope-down.js';
/**
* Comprehensive tool registry mapping all 36 tool names to their registration functions
* Used for dynamic tool registration and validation
*/
export const toolRegistry = {
initialize_project: registerInitializeProjectTool,
models: registerModelsTool,
rules: registerRulesTool,
parse_prd: registerParsePRDTool,
'response-language': registerResponseLanguageTool,
analyze_project_complexity: registerAnalyzeProjectComplexityTool,
expand_task: registerExpandTaskTool,
expand_all: registerExpandAllTool,
scope_up_task: registerScopeUpTool,
scope_down_task: registerScopeDownTool,
get_tasks: registerListTasksTool,
get_task: registerShowTaskTool,
next_task: registerNextTaskTool,
complexity_report: registerComplexityReportTool,
set_task_status: registerSetTaskStatusTool,
generate: registerGenerateTool,
add_task: registerAddTaskTool,
add_subtask: registerAddSubtaskTool,
update: registerUpdateTool,
update_task: registerUpdateTaskTool,
update_subtask: registerUpdateSubtaskTool,
remove_task: registerRemoveTaskTool,
remove_subtask: registerRemoveSubtaskTool,
clear_subtasks: registerClearSubtasksTool,
move_task: registerMoveTaskTool,
add_dependency: registerAddDependencyTool,
remove_dependency: registerRemoveDependencyTool,
validate_dependencies: registerValidateDependenciesTool,
fix_dependencies: registerFixDependenciesTool,
list_tags: registerListTagsTool,
add_tag: registerAddTagTool,
delete_tag: registerDeleteTagTool,
use_tag: registerUseTagTool,
rename_tag: registerRenameTagTool,
copy_tag: registerCopyTagTool,
research: registerResearchTool
};
/**
* Core tools array containing the 7 essential tools for daily development
* These represent the minimal set needed for basic task management operations
*/
export const coreTools = [
'get_tasks',
'next_task',
'get_task',
'set_task_status',
'update_subtask',
'parse_prd',
'expand_task'
];
/**
* Standard tools array containing the 15 most commonly used tools
* Includes all core tools plus frequently used additional tools
*/
export const standardTools = [
...coreTools,
'initialize_project',
'analyze_project_complexity',
'expand_all',
'add_subtask',
'remove_task',
'generate',
'add_task',
'complexity_report'
];
/**
* Get all available tool names
* @returns {string[]} Array of tool names
*/
export function getAvailableTools() {
return Object.keys(toolRegistry);
}
/**
* Get tool counts for all categories
* @returns {Object} Object with core, standard, and total counts
*/
export function getToolCounts() {
return {
core: coreTools.length,
standard: standardTools.length,
total: Object.keys(toolRegistry).length
};
}
/**
* Get tool arrays organized by category
* @returns {Object} Object with arrays for each category
*/
export function getToolCategories() {
const allTools = Object.keys(toolRegistry);
return {
core: [...coreTools],
standard: [...standardTools],
all: [...allTools],
extended: allTools.filter((t) => !standardTools.includes(t))
};
}
/**
* Get registration function for a specific tool
* @param {string} toolName - Name of the tool
* @returns {Function|null} Registration function or null if not found
*/
export function getToolRegistration(toolName) {
return toolRegistry[toolName] || null;
}
/**
* Validate if a tool exists in the registry
* @param {string} toolName - Name of the tool
* @returns {boolean} True if tool exists
*/
export function isValidTool(toolName) {
return toolName in toolRegistry;
}
export default toolRegistry;

62
output.txt Normal file

File diff suppressed because one or more lines are too long

86
package-lock.json generated
View File

@@ -104,6 +104,7 @@
"name": "@tm/cli",
"license": "MIT",
"dependencies": {
"@inquirer/search": "^3.2.0",
"@tm/core": "*",
"boxen": "^8.0.1",
"chalk": "5.6.2",
@@ -124,6 +125,91 @@
"node": ">=18.0.0"
}
},
"apps/cli/node_modules/@inquirer/ansi": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.1.tgz",
"integrity": "sha512-yqq0aJW/5XPhi5xOAL1xRCpe1eh8UFVgYFpFsjEqmIR8rKLyP+HINvFXwUaxYICflJrVlxnp7lLN6As735kVpw==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"apps/cli/node_modules/@inquirer/figures": {
"version": "1.0.14",
"resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.14.tgz",
"integrity": "sha512-DbFgdt+9/OZYFM+19dbpXOSeAstPy884FPy1KjDu4anWwymZeOYhMY1mdFri172htv6mvc/uvIAAi7b7tvjJBQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"apps/cli/node_modules/@inquirer/search": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.0.tgz",
"integrity": "sha512-a5SzB/qrXafDX1Z4AZW3CsVoiNxcIYCzYP7r9RzrfMpaLpB+yWi5U8BWagZyLmwR0pKbbL5umnGRd0RzGVI8bQ==",
"license": "MIT",
"dependencies": {
"@inquirer/core": "^10.3.0",
"@inquirer/figures": "^1.0.14",
"@inquirer/type": "^3.0.9",
"yoctocolors-cjs": "^2.1.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"apps/cli/node_modules/@inquirer/search/node_modules/@inquirer/core": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.0.tgz",
"integrity": "sha512-Uv2aPPPSK5jeCplQmQ9xadnFx2Zhj9b5Dj7bU6ZeCdDNNY11nhYy4btcSdtDguHqCT2h5oNeQTcUNSGGLA7NTA==",
"license": "MIT",
"dependencies": {
"@inquirer/ansi": "^1.0.1",
"@inquirer/figures": "^1.0.14",
"@inquirer/type": "^3.0.9",
"cli-width": "^4.1.0",
"mute-stream": "^2.0.0",
"signal-exit": "^4.1.0",
"wrap-ansi": "^6.2.0",
"yoctocolors-cjs": "^2.1.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"apps/cli/node_modules/@inquirer/search/node_modules/@inquirer/type": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.9.tgz",
"integrity": "sha512-QPaNt/nmE2bLGQa9b7wwyRJoLZ7pN6rcyXvzU0YCmivmJyq1BVo94G98tStRWkoD1RgDX5C+dPlhhHzNdu/W/w==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"apps/docs": {
"version": "0.0.6",
"devDependencies": {

View File

@@ -35,7 +35,7 @@ vi.mock('./credential-store.js', () => {
}
saveCredentials() {}
clearCredentials() {}
hasValidCredentials() {
hasCredentials() {
return false;
}
}

View File

@@ -29,8 +29,6 @@ export class AuthManager {
private oauthService: OAuthService;
private supabaseClient: SupabaseAuthClient;
private organizationService?: OrganizationService;
private logger = getLogger('AuthManager');
private refreshPromise: Promise<AuthCredentials> | null = null;
private constructor(config?: Partial<AuthConfig>) {
this.credentialStore = CredentialStore.getInstance(config);
@@ -83,60 +81,10 @@ export class AuthManager {
/**
* Get stored authentication credentials
* Automatically refreshes the token if expired
* Returns credentials as-is (even if expired). Refresh must be triggered explicitly
* via refreshToken() or will occur automatically when using the Supabase client for API calls.
*/
async getCredentials(): Promise<AuthCredentials | null> {
const credentials = this.credentialStore.getCredentials({
allowExpired: true
});
if (!credentials) {
return null;
}
// Check if credentials are expired (with 30-second clock skew buffer)
const CLOCK_SKEW_MS = 30_000;
const isExpired = credentials.expiresAt
? new Date(credentials.expiresAt).getTime() <= Date.now() + CLOCK_SKEW_MS
: false;
// If expired and we have a refresh token, attempt refresh
if (isExpired && credentials.refreshToken) {
// Return existing refresh promise if one is in progress
if (this.refreshPromise) {
try {
return await this.refreshPromise;
} catch {
return null;
}
}
try {
this.logger.info('Token expired, attempting automatic refresh...');
this.refreshPromise = this.refreshToken();
const result = await this.refreshPromise;
return result;
} catch (error) {
this.logger.warn('Automatic token refresh failed:', error);
return null;
} finally {
this.refreshPromise = null;
}
}
// Return null if expired and no refresh token
if (isExpired) {
return null;
}
return credentials;
}
/**
* Get stored authentication credentials (synchronous version)
* Does not attempt automatic refresh
*/
getCredentialsSync(): AuthCredentials | null {
getCredentials(): AuthCredentials | null {
return this.credentialStore.getCredentials();
}
@@ -219,25 +167,26 @@ export class AuthManager {
}
/**
* Check if authenticated
* Check if authenticated (credentials exist, regardless of expiration)
* @returns true if credentials are stored, including expired credentials
*/
isAuthenticated(): boolean {
return this.credentialStore.hasValidCredentials();
return this.credentialStore.hasCredentials();
}
/**
* Get the current user context (org/brief selection)
*/
async getContext(): Promise<UserContext | null> {
const credentials = await this.getCredentials();
getContext(): UserContext | null {
const credentials = this.getCredentials();
return credentials?.selectedContext || null;
}
/**
* Update the user context (org/brief selection)
*/
async updateContext(context: Partial<UserContext>): Promise<void> {
const credentials = await this.getCredentials();
updateContext(context: Partial<UserContext>): void {
const credentials = this.getCredentials();
if (!credentials) {
throw new AuthenticationError('Not authenticated', 'NOT_AUTHENTICATED');
}
@@ -262,8 +211,8 @@ export class AuthManager {
/**
* Clear the user context
*/
async clearContext(): Promise<void> {
const credentials = await this.getCredentials();
clearContext(): void {
const credentials = this.getCredentials();
if (!credentials) {
throw new AuthenticationError('Not authenticated', 'NOT_AUTHENTICATED');
}
@@ -280,7 +229,7 @@ export class AuthManager {
private async getOrganizationService(): Promise<OrganizationService> {
if (!this.organizationService) {
// First check if we have credentials with a token
const credentials = await this.getCredentials();
const credentials = this.getCredentials();
if (!credentials || !credentials.token) {
throw new AuthenticationError('Not authenticated', 'NOT_AUTHENTICATED');
}

View File

@@ -7,11 +7,13 @@ import path from 'path';
import { AuthConfig } from './types.js';
// Single base domain for all URLs
// Build-time: process.env.TM_PUBLIC_BASE_DOMAIN gets replaced by tsup's env option
// Runtime vars (TM_*) take precedence over build-time vars (TM_PUBLIC_*)
// Build-time: process.env.TM_PUBLIC_BASE_DOMAIN gets replaced by tsdown's env option
// Runtime: process.env.TM_BASE_DOMAIN can override for staging/development
// Default: https://tryhamster.com for production
const BASE_DOMAIN =
process.env.TM_PUBLIC_BASE_DOMAIN || // This gets replaced at build time by tsup
'https://tryhamster.com';
process.env.TM_BASE_DOMAIN || // Runtime override (for staging/tux)
process.env.TM_PUBLIC_BASE_DOMAIN; // Build-time (baked into compiled code)
/**
* Default authentication configuration
@@ -19,7 +21,7 @@ const BASE_DOMAIN =
*/
export const DEFAULT_AUTH_CONFIG: AuthConfig = {
// Base domain for all services
baseUrl: BASE_DOMAIN,
baseUrl: BASE_DOMAIN!,
// Configuration directory and file paths
configDir: path.join(os.homedir(), '.taskmaster'),

View File

@@ -52,7 +52,7 @@ describe('CredentialStore - Token Expiration', () => {
credentialStore.saveCredentials(expiredCredentials);
const retrieved = credentialStore.getCredentials();
const retrieved = credentialStore.getCredentials({ allowExpired: false });
expect(retrieved).toBeNull();
});
@@ -69,7 +69,7 @@ describe('CredentialStore - Token Expiration', () => {
credentialStore.saveCredentials(validCredentials);
const retrieved = credentialStore.getCredentials();
const retrieved = credentialStore.getCredentials({ allowExpired: false });
expect(retrieved).not.toBeNull();
expect(retrieved?.token).toBe('valid-token');
@@ -92,6 +92,25 @@ describe('CredentialStore - Token Expiration', () => {
expect(retrieved).not.toBeNull();
expect(retrieved?.token).toBe('expired-token');
});
it('should return expired token by default (allowExpired defaults to true)', () => {
const expiredCredentials: AuthCredentials = {
token: 'expired-token-default',
refreshToken: 'refresh-token',
userId: 'test-user',
email: 'test@example.com',
expiresAt: new Date(Date.now() - 60000).toISOString(),
savedAt: new Date().toISOString()
};
credentialStore.saveCredentials(expiredCredentials);
// Call without options - should default to allowExpired: true
const retrieved = credentialStore.getCredentials();
expect(retrieved).not.toBeNull();
expect(retrieved?.token).toBe('expired-token-default');
});
});
describe('Clock Skew Tolerance', () => {
@@ -108,7 +127,7 @@ describe('CredentialStore - Token Expiration', () => {
credentialStore.saveCredentials(almostExpiredCredentials);
const retrieved = credentialStore.getCredentials();
const retrieved = credentialStore.getCredentials({ allowExpired: false });
expect(retrieved).toBeNull();
});
@@ -126,7 +145,7 @@ describe('CredentialStore - Token Expiration', () => {
credentialStore.saveCredentials(validCredentials);
const retrieved = credentialStore.getCredentials();
const retrieved = credentialStore.getCredentials({ allowExpired: false });
expect(retrieved).not.toBeNull();
expect(retrieved?.token).toBe('valid-token');
@@ -146,7 +165,7 @@ describe('CredentialStore - Token Expiration', () => {
credentialStore.saveCredentials(credentials);
const retrieved = credentialStore.getCredentials();
const retrieved = credentialStore.getCredentials({ allowExpired: false });
expect(retrieved).not.toBeNull();
expect(typeof retrieved?.expiresAt).toBe('number'); // Normalized to number
@@ -164,7 +183,7 @@ describe('CredentialStore - Token Expiration', () => {
credentialStore.saveCredentials(credentials);
const retrieved = credentialStore.getCredentials();
const retrieved = credentialStore.getCredentials({ allowExpired: false });
expect(retrieved).not.toBeNull();
expect(typeof retrieved?.expiresAt).toBe('number');
@@ -185,7 +204,7 @@ describe('CredentialStore - Token Expiration', () => {
mode: 0o600
});
const retrieved = credentialStore.getCredentials();
const retrieved = credentialStore.getCredentials({ allowExpired: false });
expect(retrieved).toBeNull();
});
@@ -203,7 +222,7 @@ describe('CredentialStore - Token Expiration', () => {
mode: 0o600
});
const retrieved = credentialStore.getCredentials();
const retrieved = credentialStore.getCredentials({ allowExpired: false });
expect(retrieved).toBeNull();
});
@@ -244,15 +263,15 @@ describe('CredentialStore - Token Expiration', () => {
credentialStore.saveCredentials(credentials);
const retrieved = credentialStore.getCredentials();
const retrieved = credentialStore.getCredentials({ allowExpired: false });
// Should be normalized to number for runtime use
expect(typeof retrieved?.expiresAt).toBe('number');
});
});
describe('hasValidCredentials', () => {
it('should return false for expired credentials', () => {
describe('hasCredentials', () => {
it('should return true for expired credentials', () => {
const expiredCredentials: AuthCredentials = {
token: 'expired-token',
refreshToken: 'refresh-token',
@@ -264,7 +283,7 @@ describe('CredentialStore - Token Expiration', () => {
credentialStore.saveCredentials(expiredCredentials);
expect(credentialStore.hasValidCredentials()).toBe(false);
expect(credentialStore.hasCredentials()).toBe(true);
});
it('should return true for valid credentials', () => {
@@ -279,11 +298,11 @@ describe('CredentialStore - Token Expiration', () => {
credentialStore.saveCredentials(validCredentials);
expect(credentialStore.hasValidCredentials()).toBe(true);
expect(credentialStore.hasCredentials()).toBe(true);
});
it('should return false when no credentials exist', () => {
expect(credentialStore.hasValidCredentials()).toBe(false);
expect(credentialStore.hasCredentials()).toBe(false);
});
});
});

View File

@@ -197,7 +197,7 @@ describe('CredentialStore', () => {
JSON.stringify(mockCredentials)
);
const result = store.getCredentials();
const result = store.getCredentials({ allowExpired: false });
expect(result).toBeNull();
expect(mockLogger.warn).toHaveBeenCalledWith(
@@ -226,6 +226,31 @@ describe('CredentialStore', () => {
expect(result).not.toBeNull();
expect(result?.token).toBe('expired-token');
});
it('should return expired tokens by default (allowExpired defaults to true)', () => {
const expiredTimestamp = Date.now() - 3600000; // 1 hour ago
const mockCredentials = {
token: 'expired-token-default',
userId: 'user-expired',
expiresAt: expiredTimestamp,
tokenType: 'standard',
savedAt: new Date().toISOString()
};
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify(mockCredentials)
);
// Call without options - should default to allowExpired: true
const result = store.getCredentials();
expect(result).not.toBeNull();
expect(result?.token).toBe('expired-token-default');
expect(mockLogger.warn).not.toHaveBeenCalledWith(
expect.stringContaining('Authentication token has expired')
);
});
});
describe('saveCredentials with timestamp normalization', () => {
@@ -451,7 +476,7 @@ describe('CredentialStore', () => {
});
});
describe('hasValidCredentials', () => {
describe('hasCredentials', () => {
it('should return true when valid unexpired credentials exist', () => {
const futureDate = new Date(Date.now() + 3600000); // 1 hour from now
const credentials = {
@@ -465,10 +490,10 @@ describe('CredentialStore', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(credentials));
expect(store.hasValidCredentials()).toBe(true);
expect(store.hasCredentials()).toBe(true);
});
it('should return false when credentials are expired', () => {
it('should return true when credentials are expired', () => {
const pastDate = new Date(Date.now() - 3600000); // 1 hour ago
const credentials = {
token: 'expired-token',
@@ -481,13 +506,13 @@ describe('CredentialStore', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(credentials));
expect(store.hasValidCredentials()).toBe(false);
expect(store.hasCredentials()).toBe(true);
});
it('should return false when no credentials exist', () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
expect(store.hasValidCredentials()).toBe(false);
expect(store.hasCredentials()).toBe(false);
});
it('should return false when file contains invalid JSON', () => {
@@ -495,7 +520,7 @@ describe('CredentialStore', () => {
vi.mocked(fs.readFileSync).mockReturnValue('invalid json {');
vi.mocked(fs.renameSync).mockImplementation(() => undefined);
expect(store.hasValidCredentials()).toBe(false);
expect(store.hasCredentials()).toBe(false);
});
it('should return false for credentials without expiry', () => {
@@ -510,7 +535,7 @@ describe('CredentialStore', () => {
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(credentials));
// Credentials without expiry are considered invalid
expect(store.hasValidCredentials()).toBe(false);
expect(store.hasCredentials()).toBe(false);
// Should log warning about missing expiration
expect(mockLogger.warn).toHaveBeenCalledWith(
@@ -518,14 +543,14 @@ describe('CredentialStore', () => {
);
});
it('should use allowExpired=false by default', () => {
it('should use allowExpired=true', () => {
// Spy on getCredentials to verify it's called with correct params
const getCredentialsSpy = vi.spyOn(store, 'getCredentials');
vi.mocked(fs.existsSync).mockReturnValue(false);
store.hasValidCredentials();
store.hasCredentials();
expect(getCredentialsSpy).toHaveBeenCalledWith({ allowExpired: false });
expect(getCredentialsSpy).toHaveBeenCalledWith({ allowExpired: true });
});
});

View File

@@ -24,6 +24,8 @@ export class CredentialStore {
private config: AuthConfig;
// Clock skew tolerance for expiry checks (30 seconds)
private readonly CLOCK_SKEW_MS = 30_000;
// Track if we've already warned about missing expiration to avoid spam
private hasWarnedAboutMissingExpiration = false;
private constructor(config?: Partial<AuthConfig>) {
this.config = getAuthConfig(config);
@@ -54,9 +56,12 @@ export class CredentialStore {
/**
* Get stored authentication credentials
* @param options.allowExpired - Whether to return expired credentials (default: true)
* @returns AuthCredentials with expiresAt as number (milliseconds) for runtime use
*/
getCredentials(options?: { allowExpired?: boolean }): AuthCredentials | null {
getCredentials({
allowExpired = true
}: { allowExpired?: boolean } = {}): AuthCredentials | null {
try {
if (!fs.existsSync(this.config.configFile)) {
return null;
@@ -81,7 +86,11 @@ export class CredentialStore {
// Validate expiration time for tokens
if (expiresAtMs === undefined) {
// Only log this warning once to avoid spam during auth flows
if (!this.hasWarnedAboutMissingExpiration) {
this.logger.warn('No valid expiration time provided for token');
this.hasWarnedAboutMissingExpiration = true;
}
return null;
}
@@ -90,7 +99,6 @@ export class CredentialStore {
// Check if the token has expired (with clock skew tolerance)
const now = Date.now();
const allowExpired = options?.allowExpired ?? false;
if (now >= expiresAtMs - this.CLOCK_SKEW_MS && !allowExpired) {
this.logger.warn(
'Authentication token has expired or is about to expire',
@@ -103,7 +111,7 @@ export class CredentialStore {
return null;
}
// Return valid token
// Return credentials (even if expired) to enable refresh flows
return authData;
} catch (error) {
this.logger.error(
@@ -172,6 +180,9 @@ export class CredentialStore {
mode: 0o600
});
fs.renameSync(tempFile, this.config.configFile);
// Reset the warning flag so it can be shown again for future invalid tokens
this.hasWarnedAboutMissingExpiration = false;
} catch (error) {
throw new AuthenticationError(
`Failed to save auth credentials: ${(error as Error).message}`,
@@ -199,10 +210,11 @@ export class CredentialStore {
}
/**
* Check if credentials exist and are valid
* Check if credentials exist (regardless of expiration status)
* @returns true if credentials are stored, including expired credentials
*/
hasValidCredentials(): boolean {
const credentials = this.getCredentials({ allowExpired: false });
hasCredentials(): boolean {
const credentials = this.getCredentials({ allowExpired: true });
return credentials !== null;
}

View File

@@ -281,15 +281,26 @@ export class OAuthService {
// Exchange code for session using PKCE
const session = await this.supabaseClient.exchangeCodeForSession(code);
// Calculate expiration - can be overridden with TM_TOKEN_EXPIRY_MINUTES
let expiresAt: string | undefined;
const tokenExpiryMinutes = process.env.TM_TOKEN_EXPIRY_MINUTES;
if (tokenExpiryMinutes) {
const minutes = parseInt(tokenExpiryMinutes);
expiresAt = new Date(Date.now() + minutes * 60 * 1000).toISOString();
this.logger.warn(`Token expiry overridden to ${minutes} minute(s)`);
} else {
expiresAt = session.expires_at
? new Date(session.expires_at * 1000).toISOString()
: undefined;
}
// Save authentication data
const authData: AuthCredentials = {
token: session.access_token,
refreshToken: session.refresh_token,
userId: session.user.id,
email: session.user.email,
expiresAt: session.expires_at
? new Date(session.expires_at * 1000).toISOString()
: undefined,
expiresAt,
tokenType: 'standard',
savedAt: new Date().toISOString()
};
@@ -340,10 +351,18 @@ export class OAuthService {
// Get user info from the session
const user = await this.supabaseClient.getUser();
// Calculate expiration time
const expiresAt = expiresIn
// Calculate expiration time - can be overridden with TM_TOKEN_EXPIRY_MINUTES
let expiresAt: string | undefined;
const tokenExpiryMinutes = process.env.TM_TOKEN_EXPIRY_MINUTES;
if (tokenExpiryMinutes) {
const minutes = parseInt(tokenExpiryMinutes);
expiresAt = new Date(Date.now() + minutes * 60 * 1000).toISOString();
this.logger.warn(`Token expiry overridden to ${minutes} minute(s)`);
} else {
expiresAt = expiresIn
? new Date(Date.now() + parseInt(expiresIn) * 1000).toISOString()
: undefined;
}
// Save authentication data
const authData: AuthCredentials = {
@@ -351,7 +370,7 @@ export class OAuthService {
refreshToken: refreshToken || undefined,
userId: user?.id || 'unknown',
email: user?.email,
expiresAt: expiresAt,
expiresAt,
tokenType: 'standard',
savedAt: new Date().toISOString()
};

View File

@@ -98,11 +98,11 @@ export class SupabaseSessionStorage implements SupportedStorage {
// Only handle Supabase session keys
if (key === STORAGE_KEY || key.includes('auth-token')) {
try {
this.logger.info('Supabase called setItem - storing refreshed session');
// Parse the session and update our credentials
const sessionUpdates = this.parseSessionToCredentials(value);
const existingCredentials = this.store.getCredentials({
allowExpired: true
});
const existingCredentials = this.store.getCredentials();
if (sessionUpdates.token) {
const updatedCredentials: AuthCredentials = {
@@ -113,6 +113,9 @@ export class SupabaseSessionStorage implements SupportedStorage {
} as AuthCredentials;
this.store.saveCredentials(updatedCredentials);
this.logger.info(
'Successfully saved refreshed credentials from Supabase'
);
}
} catch (error) {
this.logger.error('Error setting session:', error);

View File

@@ -16,6 +16,7 @@ export interface AuthCredentials {
export interface UserContext {
orgId?: string;
orgName?: string;
orgSlug?: string;
briefId?: string;
briefName?: string;
updatedAt: string;

View File

@@ -17,10 +17,11 @@ export class SupabaseAuthClient {
private client: SupabaseJSClient | null = null;
private sessionStorage: SupabaseSessionStorage;
private logger = getLogger('SupabaseAuthClient');
private credentialStore: CredentialStore;
constructor() {
const credentialStore = CredentialStore.getInstance();
this.sessionStorage = new SupabaseSessionStorage(credentialStore);
this.credentialStore = CredentialStore.getInstance();
this.sessionStorage = new SupabaseSessionStorage(this.credentialStore);
}
/**
@@ -28,13 +29,17 @@ export class SupabaseAuthClient {
*/
getClient(): SupabaseJSClient {
if (!this.client) {
// Get Supabase configuration from environment - using TM_PUBLIC prefix
const supabaseUrl = process.env.TM_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.TM_PUBLIC_SUPABASE_ANON_KEY;
// Get Supabase configuration from environment
// Runtime vars (TM_*) take precedence over build-time vars (TM_PUBLIC_*)
const supabaseUrl =
process.env.TM_SUPABASE_URL || process.env.TM_PUBLIC_SUPABASE_URL;
const supabaseAnonKey =
process.env.TM_SUPABASE_ANON_KEY ||
process.env.TM_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
throw new AuthenticationError(
'Supabase configuration missing. Please set TM_PUBLIC_SUPABASE_URL and TM_PUBLIC_SUPABASE_ANON_KEY environment variables.',
'Supabase configuration missing. Please set TM_SUPABASE_URL and TM_SUPABASE_ANON_KEY (runtime) or TM_PUBLIC_SUPABASE_URL and TM_PUBLIC_SUPABASE_ANON_KEY (build-time) environment variables.',
'CONFIG_MISSING'
);
}

View File

@@ -52,7 +52,10 @@ export const ERROR_CODES = {
INVALID_INPUT: 'INVALID_INPUT',
NOT_IMPLEMENTED: 'NOT_IMPLEMENTED',
UNKNOWN_ERROR: 'UNKNOWN_ERROR',
NOT_FOUND: 'NOT_FOUND'
NOT_FOUND: 'NOT_FOUND',
// Context errors
NO_BRIEF_SELECTED: 'NO_BRIEF_SELECTED'
} as const;
export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];

View File

@@ -358,11 +358,12 @@ export class ExportService {
tasks: any[]
): Promise<void> {
// Check if we should use the API endpoint or direct Supabase
const useAPIEndpoint = process.env.TM_PUBLIC_BASE_DOMAIN;
const apiEndpoint =
process.env.TM_BASE_DOMAIN || process.env.TM_PUBLIC_BASE_DOMAIN;
if (useAPIEndpoint) {
if (apiEndpoint) {
// Use the new bulk import API endpoint
const apiUrl = `${process.env.TM_PUBLIC_BASE_DOMAIN}/ai/api/v1/briefs/${briefId}/tasks`;
const apiUrl = `${apiEndpoint}/ai/api/v1/briefs/${briefId}/tasks`;
// Transform tasks to flat structure for API
const flatTasks = this.transformTasksForBulkImport(tasks);

View File

@@ -27,6 +27,12 @@ export interface Brief {
status: string;
createdAt: string;
updatedAt: string;
document?: {
id: string;
title: string;
document_name: string;
description?: string;
};
}
/**
@@ -171,7 +177,12 @@ export class OrganizationService {
document_id,
status,
created_at,
updated_at
updated_at,
document:document_id (
id,
document_name,
title
)
`)
.eq('account_id', orgId);
@@ -196,7 +207,14 @@ export class OrganizationService {
documentId: brief.document_id,
status: brief.status,
createdAt: brief.created_at,
updatedAt: brief.updated_at
updatedAt: brief.updated_at,
document: brief.document
? {
id: brief.document.id,
document_name: brief.document.document_name,
title: brief.document.title
}
: undefined
}));
} catch (error) {
if (error instanceof TaskMasterError) {
@@ -224,7 +242,13 @@ export class OrganizationService {
document_id,
status,
created_at,
updated_at
updated_at,
document:document_id (
id,
document_name,
title,
description
)
`)
.eq('id', briefId)
.single();
@@ -253,7 +277,15 @@ export class OrganizationService {
documentId: briefData.document_id,
status: briefData.status,
createdAt: briefData.created_at,
updatedAt: briefData.updated_at
updatedAt: briefData.updated_at,
document: briefData.document
? {
id: briefData.document.id,
document_name: briefData.document.document_name,
title: briefData.document.title,
description: briefData.document.description
}
: undefined
};
} catch (error) {
if (error instanceof TaskMasterError) {

View File

@@ -161,6 +161,16 @@ export class TaskService {
storageType: this.getStorageType()
};
} catch (error) {
// If it's a user-facing error (like NO_BRIEF_SELECTED), don't log it as an internal error
if (
error instanceof TaskMasterError &&
error.is(ERROR_CODES.NO_BRIEF_SELECTED)
) {
// Just re-throw user-facing errors without wrapping
throw error;
}
// Log internal errors
this.logger.error('Failed to get task list', error);
throw new TaskMasterError(
'Failed to get task list',
@@ -186,6 +196,14 @@ export class TaskService {
// Delegate to storage layer which handles the specific logic for tasks vs subtasks
return await this.storage.loadTask(String(taskId), activeTag);
} catch (error) {
// If it's a user-facing error (like NO_BRIEF_SELECTED), don't wrap it
if (
error instanceof TaskMasterError &&
error.is(ERROR_CODES.NO_BRIEF_SELECTED)
) {
throw error;
}
throw new TaskMasterError(
`Failed to get task ${taskId}`,
ERROR_CODES.STORAGE_ERROR,
@@ -522,6 +540,14 @@ export class TaskService {
activeTag
);
} catch (error) {
// If it's a user-facing error (like NO_BRIEF_SELECTED), don't wrap it
if (
error instanceof TaskMasterError &&
error.is(ERROR_CODES.NO_BRIEF_SELECTED)
) {
throw error;
}
throw new TaskMasterError(
`Failed to update task status for ${taskIdStr}`,
ERROR_CODES.STORAGE_ERROR,

View File

@@ -37,6 +37,13 @@ export interface ApiStorageConfig {
maxRetries?: number;
}
/**
* Auth context with a guaranteed briefId
*/
type ContextWithBrief = NonNullable<
ReturnType<typeof AuthManager.prototype.getContext>
> & { briefId: string };
/**
* ApiStorage implementation using repository pattern
* Provides flexibility to swap between different backend implementations
@@ -112,6 +119,13 @@ export class ApiStorage implements IStorage {
}
}
/**
* Get the storage type
*/
getType(): 'api' {
return 'api';
}
/**
* Load tags into cache
* In our API-based system, "tags" represent briefs
@@ -119,7 +133,7 @@ export class ApiStorage implements IStorage {
private async loadTagsIntoCache(): Promise<void> {
try {
const authManager = AuthManager.getInstance();
const context = await authManager.getContext();
const context = authManager.getContext();
// If we have a selected brief, create a virtual "tag" for it
if (context?.briefId) {
@@ -151,15 +165,7 @@ export class ApiStorage implements IStorage {
await this.ensureInitialized();
try {
const authManager = AuthManager.getInstance();
const context = await authManager.getContext();
// If no brief is selected in context, throw an error
if (!context?.briefId) {
throw new Error(
'No brief selected. Please select a brief first using: tm context brief <brief-id>'
);
}
const context = this.ensureBriefSelected('loadTasks');
// Load tasks from the current brief context with filters pushed to repository
const tasks = await this.retryOperation(() =>
@@ -174,12 +180,11 @@ export class ApiStorage implements IStorage {
return tasks;
} catch (error) {
throw new TaskMasterError(
'Failed to load tasks from API',
ERROR_CODES.STORAGE_ERROR,
{ operation: 'loadTasks', tag, context: 'brief-based loading' },
error as Error
);
this.wrapError(error, 'Failed to load tasks from API', {
operation: 'loadTasks',
tag,
context: 'brief-based loading'
});
}
}
@@ -230,16 +235,17 @@ export class ApiStorage implements IStorage {
await this.ensureInitialized();
try {
this.ensureBriefSelected('loadTask');
return await this.retryOperation(() =>
this.repository.getTask(this.projectId, taskId)
);
} catch (error) {
throw new TaskMasterError(
'Failed to load task from API',
ERROR_CODES.STORAGE_ERROR,
{ operation: 'loadTask', taskId, tag },
error as Error
);
this.wrapError(error, 'Failed to load task from API', {
operation: 'loadTask',
taskId,
tag
});
}
}
@@ -318,7 +324,7 @@ export class ApiStorage implements IStorage {
try {
const authManager = AuthManager.getInstance();
const context = await authManager.getContext();
const context = authManager.getContext();
// In our API-based system, we only have one "tag" at a time - the current brief
if (context?.briefId) {
@@ -503,6 +509,8 @@ export class ApiStorage implements IStorage {
await this.ensureInitialized();
try {
this.ensureBriefSelected('updateTaskStatus');
const existingTask = await this.retryOperation(() =>
this.repository.getTask(this.projectId, taskId)
);
@@ -539,12 +547,12 @@ export class ApiStorage implements IStorage {
taskId
};
} catch (error) {
throw new TaskMasterError(
'Failed to update task status via API',
ERROR_CODES.STORAGE_ERROR,
{ operation: 'updateTaskStatus', taskId, newStatus, tag },
error as Error
);
this.wrapError(error, 'Failed to update task status via API', {
operation: 'updateTaskStatus',
taskId,
newStatus,
tag
});
}
}
@@ -762,6 +770,29 @@ export class ApiStorage implements IStorage {
}
}
/**
* Ensure a brief is selected in the current context
* @returns The current auth context with a valid briefId
*/
private ensureBriefSelected(operation: string): ContextWithBrief {
const authManager = AuthManager.getInstance();
const context = authManager.getContext();
if (!context?.briefId) {
throw new TaskMasterError(
'No brief selected',
ERROR_CODES.NO_BRIEF_SELECTED,
{
operation,
userMessage:
'No brief selected. Please select a brief first using: tm context brief <brief-id> or tm context brief <brief-url>'
}
);
}
return context as ContextWithBrief;
}
/**
* Retry an operation with exponential backoff
*/
@@ -780,4 +811,28 @@ export class ApiStorage implements IStorage {
throw error;
}
}
/**
* Wrap an error unless it's already a NO_BRIEF_SELECTED error
*/
private wrapError(
error: unknown,
message: string,
context: Record<string, unknown>
): never {
// If it's already a NO_BRIEF_SELECTED error, don't wrap it
if (
error instanceof TaskMasterError &&
error.is(ERROR_CODES.NO_BRIEF_SELECTED)
) {
throw error;
}
throw new TaskMasterError(
message,
ERROR_CODES.STORAGE_ERROR,
context,
error as Error
);
}
}

View File

@@ -44,6 +44,13 @@ export class FileStorage implements IStorage {
await this.fileOps.cleanup();
}
/**
* Get the storage type
*/
getType(): 'file' {
return 'file';
}
/**
* Get statistics about the storage
*/

View File

@@ -73,7 +73,7 @@ export class StorageFactory {
);
}
// Use auth token from AuthManager (synchronous - no auto-refresh here)
const credentials = authManager.getCredentialsSync();
const credentials = authManager.getCredentials();
if (credentials) {
// Merge with existing storage config, ensuring required fields
const nextStorage: StorageSettings = {
@@ -82,8 +82,8 @@ export class StorageFactory {
apiAccessToken: credentials.token,
apiEndpoint:
config.storage?.apiEndpoint ||
process.env.TM_PUBLIC_BASE_DOMAIN ||
'https://tryhamster.com/api'
process.env.TM_BASE_DOMAIN ||
process.env.TM_PUBLIC_BASE_DOMAIN
};
config.storage = nextStorage;
}
@@ -103,7 +103,7 @@ export class StorageFactory {
// Then check if authenticated via AuthManager
if (authManager.isAuthenticated()) {
const credentials = authManager.getCredentialsSync();
const credentials = authManager.getCredentials();
if (credentials) {
// Configure API storage with auth credentials
const nextStorage: StorageSettings = {
@@ -112,6 +112,7 @@ export class StorageFactory {
apiAccessToken: credentials.token,
apiEndpoint:
config.storage?.apiEndpoint ||
process.env.TM_BASE_DOMAIN ||
process.env.TM_PUBLIC_BASE_DOMAIN ||
'https://tryhamster.com/api'
};

View File

@@ -201,6 +201,44 @@ export class TaskMasterCore {
return this.taskService.getStorageType();
}
/**
* Get storage configuration
*/
getStorageConfig() {
return this.configManager.getStorageConfig();
}
/**
* Get storage display information for headers
* Returns context info for API storage, null for file storage
*/
getStorageDisplayInfo(): {
briefId: string;
briefName: string;
orgSlug?: string;
} | null {
// Only return info if using API storage
const storageType = this.getStorageType();
if (storageType !== 'api') {
return null;
}
// Get credentials from auth manager
const authManager = AuthManager.getInstance();
const credentials = authManager.getCredentials();
const selectedContext = credentials?.selectedContext;
if (!selectedContext?.briefId || !selectedContext?.briefName) {
return null;
}
return {
briefId: selectedContext.briefId,
briefName: selectedContext.briefName,
orgSlug: selectedContext.orgSlug
};
}
/**
* Get current active tag
*/

View File

@@ -50,7 +50,7 @@ describe('AuthManager Token Refresh', () => {
}
});
it('should not make concurrent refresh requests', async () => {
it('should return expired credentials to enable refresh flows', () => {
// Set up expired credentials with refresh token
const expiredCredentials: AuthCredentials = {
token: 'expired_access_token',
@@ -63,50 +63,16 @@ describe('AuthManager Token Refresh', () => {
credentialStore.saveCredentials(expiredCredentials);
// Mock the refreshToken method to track calls
const refreshTokenSpy = vi.spyOn(authManager as any, 'refreshToken');
const mockSession: Session = {
access_token: 'new_access_token',
refresh_token: 'new_refresh_token',
expires_at: Math.floor(Date.now() / 1000) + 3600,
user: {
id: 'test-user-id',
email: 'test@example.com',
app_metadata: {},
user_metadata: {},
aud: 'authenticated',
created_at: new Date().toISOString()
}
};
// Get credentials should return them even if expired
// Refresh will be handled by explicit calls or client operations
const credentials = authManager.getCredentials();
refreshTokenSpy.mockResolvedValue({
token: mockSession.access_token,
refreshToken: mockSession.refresh_token,
userId: mockSession.user.id,
email: mockSession.user.email,
expiresAt: new Date(mockSession.expires_at! * 1000).toISOString(),
savedAt: new Date().toISOString()
expect(credentials).not.toBeNull();
expect(credentials?.token).toBe('expired_access_token');
expect(credentials?.refreshToken).toBe('valid_refresh_token');
});
// Make multiple concurrent calls to getCredentials
const promises = [
authManager.getCredentials(),
authManager.getCredentials(),
authManager.getCredentials()
];
const results = await Promise.all(promises);
// Verify all calls returned the same new credentials
expect(results[0]?.token).toBe('new_access_token');
expect(results[1]?.token).toBe('new_access_token');
expect(results[2]?.token).toBe('new_access_token');
// Verify refreshToken was only called once, not three times
expect(refreshTokenSpy).toHaveBeenCalledTimes(1);
});
it('should return valid credentials without attempting refresh', async () => {
it('should return valid credentials', () => {
// Set up valid (non-expired) credentials
const validCredentials: AuthCredentials = {
token: 'valid_access_token',
@@ -119,17 +85,14 @@ describe('AuthManager Token Refresh', () => {
credentialStore.saveCredentials(validCredentials);
// Spy on refreshToken to ensure it's not called
const refreshTokenSpy = vi.spyOn(authManager as any, 'refreshToken');
const credentials = await authManager.getCredentials();
const credentials = authManager.getCredentials();
expect(credentials?.token).toBe('valid_access_token');
expect(refreshTokenSpy).not.toHaveBeenCalled();
});
it('should return null if credentials are expired with no refresh token', async () => {
it('should return expired credentials even without refresh token', () => {
// Set up expired credentials WITHOUT refresh token
// We still return them - it's up to the caller to handle
const expiredCredentials: AuthCredentials = {
token: 'expired_access_token',
refreshToken: undefined,
@@ -141,17 +104,19 @@ describe('AuthManager Token Refresh', () => {
credentialStore.saveCredentials(expiredCredentials);
const credentials = await authManager.getCredentials();
const credentials = authManager.getCredentials();
// Returns credentials even if expired
expect(credentials).not.toBeNull();
expect(credentials?.token).toBe('expired_access_token');
});
it('should return null if no credentials exist', () => {
const credentials = authManager.getCredentials();
expect(credentials).toBeNull();
});
it('should return null if no credentials exist', async () => {
const credentials = await authManager.getCredentials();
expect(credentials).toBeNull();
});
it('should handle refresh failures gracefully', async () => {
it('should return credentials regardless of refresh token validity', () => {
// Set up expired credentials with refresh token
const expiredCredentials: AuthCredentials = {
token: 'expired_access_token',
@@ -164,13 +129,11 @@ describe('AuthManager Token Refresh', () => {
credentialStore.saveCredentials(expiredCredentials);
// Mock refreshToken to throw an error
const refreshTokenSpy = vi.spyOn(authManager as any, 'refreshToken');
refreshTokenSpy.mockRejectedValue(new Error('Refresh failed'));
const credentials = authManager.getCredentials();
const credentials = await authManager.getCredentials();
expect(credentials).toBeNull();
expect(refreshTokenSpy).toHaveBeenCalledTimes(1);
// Returns credentials - refresh will be attempted by the client which will handle failure
expect(credentials).not.toBeNull();
expect(credentials?.token).toBe('expired_access_token');
expect(credentials?.refreshToken).toBe('invalid_refresh_token');
});
});

View File

@@ -76,7 +76,7 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
});
describe('Expired Token Detection', () => {
it('should detect expired token', async () => {
it('should return expired token for Supabase to refresh', () => {
// Set up expired credentials
const expiredCredentials: AuthCredentials = {
token: 'expired-token',
@@ -91,24 +91,15 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
authManager = AuthManager.getInstance();
// Mock the Supabase refreshSession to return new tokens
const mockRefreshSession = vi
.fn()
.mockResolvedValue(mockRefreshedSession);
vi.spyOn(
authManager['supabaseClient'],
'refreshSession'
).mockImplementation(mockRefreshSession);
// Get credentials returns them even if expired
const credentials = authManager.getCredentials();
// Get credentials should trigger refresh
const credentials = await authManager.getCredentials();
expect(mockRefreshSession).toHaveBeenCalledTimes(1);
expect(credentials).not.toBeNull();
expect(credentials?.token).toBe('new-access-token-xyz');
expect(credentials?.token).toBe('expired-token');
expect(credentials?.refreshToken).toBe('valid-refresh-token');
});
it('should not refresh valid token', async () => {
it('should return valid token', () => {
// Set up valid credentials
const validCredentials: AuthCredentials = {
token: 'valid-token',
@@ -123,22 +114,14 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
authManager = AuthManager.getInstance();
// Mock refresh to ensure it's not called
const mockRefreshSession = vi.fn();
vi.spyOn(
authManager['supabaseClient'],
'refreshSession'
).mockImplementation(mockRefreshSession);
const credentials = authManager.getCredentials();
const credentials = await authManager.getCredentials();
expect(mockRefreshSession).not.toHaveBeenCalled();
expect(credentials?.token).toBe('valid-token');
});
});
describe('Token Refresh Flow', () => {
it('should refresh expired token and save new credentials', async () => {
it('should manually refresh expired token and save new credentials', async () => {
const expiredCredentials: AuthCredentials = {
token: 'old-token',
refreshToken: 'old-refresh-token',
@@ -162,23 +145,24 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
'refreshSession'
).mockResolvedValue(mockRefreshedSession);
const refreshedCredentials = await authManager.getCredentials();
// Explicitly call refreshToken() method
const refreshedCredentials = await authManager.refreshToken();
expect(refreshedCredentials).not.toBeNull();
expect(refreshedCredentials?.token).toBe('new-access-token-xyz');
expect(refreshedCredentials?.refreshToken).toBe('new-refresh-token-xyz');
expect(refreshedCredentials.token).toBe('new-access-token-xyz');
expect(refreshedCredentials.refreshToken).toBe('new-refresh-token-xyz');
// Verify context was preserved
expect(refreshedCredentials?.selectedContext?.orgId).toBe('test-org');
expect(refreshedCredentials?.selectedContext?.briefId).toBe('test-brief');
expect(refreshedCredentials.selectedContext?.orgId).toBe('test-org');
expect(refreshedCredentials.selectedContext?.briefId).toBe('test-brief');
// Verify new expiration is in the future
const newExpiry = new Date(refreshedCredentials!.expiresAt!).getTime();
const newExpiry = new Date(refreshedCredentials.expiresAt!).getTime();
const now = Date.now();
expect(newExpiry).toBeGreaterThan(now);
});
it('should return null if refresh fails', async () => {
it('should throw error if manual refresh fails', async () => {
const expiredCredentials: AuthCredentials = {
token: 'expired-token',
refreshToken: 'invalid-refresh-token',
@@ -198,12 +182,11 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
'refreshSession'
).mockRejectedValue(new Error('Refresh token expired'));
const credentials = await authManager.getCredentials();
expect(credentials).toBeNull();
// Explicit refreshToken() call should throw
await expect(authManager.refreshToken()).rejects.toThrow();
});
it('should return null if no refresh token available', async () => {
it('should return expired credentials even without refresh token', () => {
const expiredCredentials: AuthCredentials = {
token: 'expired-token',
// No refresh token
@@ -217,18 +200,21 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
authManager = AuthManager.getInstance();
const credentials = await authManager.getCredentials();
const credentials = authManager.getCredentials();
expect(credentials).toBeNull();
// Credentials are returned even without refresh token
expect(credentials).not.toBeNull();
expect(credentials?.token).toBe('expired-token');
expect(credentials?.refreshToken).toBeUndefined();
});
it('should return null if credentials missing expiresAt', async () => {
it('should return null if credentials missing expiresAt', () => {
const credentialsWithoutExpiry: AuthCredentials = {
token: 'test-token',
refreshToken: 'refresh-token',
userId: 'test-user-id',
email: 'test@example.com',
// Missing expiresAt
// Missing expiresAt - invalid token
savedAt: new Date().toISOString()
} as any;
@@ -236,16 +222,17 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
authManager = AuthManager.getInstance();
const credentials = await authManager.getCredentials();
const credentials = authManager.getCredentials();
// Should return null because no valid expiration
// Tokens without valid expiration are considered invalid
expect(credentials).toBeNull();
});
});
describe('Clock Skew Tolerance', () => {
it('should refresh token within 30-second expiry window', async () => {
it('should return credentials within 30-second expiry window', () => {
// Token expires in 15 seconds (within 30-second buffer)
// Supabase will handle refresh automatically
const almostExpiredCredentials: AuthCredentials = {
token: 'almost-expired-token',
refreshToken: 'valid-refresh-token',
@@ -259,23 +246,16 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
authManager = AuthManager.getInstance();
const mockRefreshSession = vi
.fn()
.mockResolvedValue(mockRefreshedSession);
vi.spyOn(
authManager['supabaseClient'],
'refreshSession'
).mockImplementation(mockRefreshSession);
const credentials = authManager.getCredentials();
const credentials = await authManager.getCredentials();
// Should trigger refresh due to 30-second buffer
expect(mockRefreshSession).toHaveBeenCalledTimes(1);
expect(credentials?.token).toBe('new-access-token-xyz');
// Credentials are returned (Supabase handles auto-refresh in background)
expect(credentials).not.toBeNull();
expect(credentials?.token).toBe('almost-expired-token');
expect(credentials?.refreshToken).toBe('valid-refresh-token');
});
it('should not refresh token well before expiry', async () => {
// Token expires in 5 minutes (well outside 30-second buffer)
it('should return valid token well before expiry', () => {
// Token expires in 5 minutes
const validCredentials: AuthCredentials = {
token: 'valid-token',
refreshToken: 'valid-refresh-token',
@@ -289,21 +269,17 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
authManager = AuthManager.getInstance();
const mockRefreshSession = vi.fn();
vi.spyOn(
authManager['supabaseClient'],
'refreshSession'
).mockImplementation(mockRefreshSession);
const credentials = authManager.getCredentials();
const credentials = await authManager.getCredentials();
expect(mockRefreshSession).not.toHaveBeenCalled();
// Valid credentials are returned as-is
expect(credentials).not.toBeNull();
expect(credentials?.token).toBe('valid-token');
expect(credentials?.refreshToken).toBe('valid-refresh-token');
});
});
describe('Synchronous vs Async Methods', () => {
it('getCredentialsSync should not trigger refresh', () => {
it('getCredentials should return expired credentials', () => {
const expiredCredentials: AuthCredentials = {
token: 'expired-token',
refreshToken: 'valid-refresh-token',
@@ -317,40 +293,17 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
authManager = AuthManager.getInstance();
// Synchronous call should return null without refresh
const credentials = authManager.getCredentialsSync();
expect(credentials).toBeNull();
});
it('getCredentials async should trigger refresh', async () => {
const expiredCredentials: AuthCredentials = {
token: 'expired-token',
refreshToken: 'valid-refresh-token',
userId: 'test-user-id',
email: 'test@example.com',
expiresAt: new Date(Date.now() - 60000).toISOString(),
savedAt: new Date().toISOString()
};
credentialStore.saveCredentials(expiredCredentials);
authManager = AuthManager.getInstance();
vi.spyOn(
authManager['supabaseClient'],
'refreshSession'
).mockResolvedValue(mockRefreshedSession);
const credentials = await authManager.getCredentials();
// Returns credentials even if expired - Supabase will handle refresh
const credentials = authManager.getCredentials();
expect(credentials).not.toBeNull();
expect(credentials?.token).toBe('new-access-token-xyz');
expect(credentials?.token).toBe('expired-token');
expect(credentials?.refreshToken).toBe('valid-refresh-token');
});
});
describe('Multiple Concurrent Calls', () => {
it('should handle concurrent getCredentials calls gracefully', async () => {
it('should handle concurrent getCredentials calls gracefully', () => {
const expiredCredentials: AuthCredentials = {
token: 'expired-token',
refreshToken: 'valid-refresh-token',
@@ -364,29 +317,20 @@ describe('AuthManager - Token Auto-Refresh Integration', () => {
authManager = AuthManager.getInstance();
const mockRefreshSession = vi
.fn()
.mockResolvedValue(mockRefreshedSession);
vi.spyOn(
authManager['supabaseClient'],
'refreshSession'
).mockImplementation(mockRefreshSession);
// Make multiple concurrent calls (synchronous now)
const creds1 = authManager.getCredentials();
const creds2 = authManager.getCredentials();
const creds3 = authManager.getCredentials();
// Make multiple concurrent calls
const [creds1, creds2, creds3] = await Promise.all([
authManager.getCredentials(),
authManager.getCredentials(),
authManager.getCredentials()
]);
// All should get the same credentials (even if expired)
expect(creds1?.token).toBe('expired-token');
expect(creds2?.token).toBe('expired-token');
expect(creds3?.token).toBe('expired-token');
// All should get the refreshed token
expect(creds1?.token).toBe('new-access-token-xyz');
expect(creds2?.token).toBe('new-access-token-xyz');
expect(creds3?.token).toBe('new-access-token-xyz');
// Refresh might be called multiple times, but that's okay
// (ideally we'd debounce, but this is acceptable behavior)
expect(mockRefreshSession).toHaveBeenCalled();
// All include refresh token for Supabase to use
expect(creds1?.refreshToken).toBe('valid-refresh-token');
expect(creds2?.refreshToken).toBe('valid-refresh-token');
expect(creds3?.refreshToken).toBe('valid-refresh-token');
});
});
});

View File

@@ -9,6 +9,8 @@
*/
import dotenv from 'dotenv';
// Load .env BEFORE any other imports to ensure env vars are available
dotenv.config();
// Add at the very beginning of the file
@@ -16,7 +18,8 @@ if (process.env.DEBUG === '1') {
console.error('DEBUG - dev.js received args:', process.argv.slice(2));
}
import { runCLI } from './modules/commands.js';
// Use dynamic import to ensure dotenv.config() runs before module-level code executes
const { runCLI } = await import('./modules/commands.js');
// Run the CLI with the process arguments
runCLI(process.argv);

View File

@@ -19,7 +19,8 @@ import {
registerAllCommands,
checkForUpdate,
performAutoUpdate,
displayUpgradeNotification
displayUpgradeNotification,
displayError
} from '@tm/cli';
import {
@@ -2441,57 +2442,6 @@ ${result.result}
}
});
// next command
programInstance
.command('next')
.description(
`Show the next task to work on based on dependencies and status${chalk.reset('')}`
)
.option(
'-f, --file <file>',
'Path to the tasks file',
TASKMASTER_TASKS_FILE
)
.option(
'-r, --report <report>',
'Path to the complexity report file',
COMPLEXITY_REPORT_FILE
)
.option('--tag <tag>', 'Specify tag context for task operations')
.action(async (options) => {
const initOptions = {
tasksPath: options.file || true,
tag: options.tag
};
if (options.report && options.report !== COMPLEXITY_REPORT_FILE) {
initOptions.complexityReportPath = options.report;
}
// Initialize TaskMaster
const taskMaster = initTaskMaster({
tasksPath: options.file || true,
tag: options.tag,
complexityReportPath: options.report || false
});
const tag = taskMaster.getCurrentTag();
const context = {
projectRoot: taskMaster.getProjectRoot(),
tag
};
// Show current tag context
displayCurrentTagIndicator(tag);
await displayNextTask(
taskMaster.getTasksPath(),
taskMaster.getComplexityReportPath(),
context
);
});
// add-dependency command
programInstance
.command('add-dependency')
@@ -5207,10 +5157,7 @@ async function runCLI(argv = process.argv) {
);
} else {
// Generic error handling for other errors
console.error(chalk.red(`Error: ${error.message}`));
if (getDebugFlag()) {
console.error(error);
}
displayError(error);
}
process.exit(1);

View File

@@ -47,21 +47,33 @@ export function normalizeProjectRoot(projectRoot) {
/**
* Find the project root directory by looking for project markers
* @param {string} startDir - Directory to start searching from
* @returns {string|null} - Project root path or null if not found
* Traverses upwards from startDir until a project marker is found or filesystem root is reached
* Limited to 50 parent directory levels to prevent excessive traversal
* @param {string} startDir - Directory to start searching from (defaults to process.cwd())
* @returns {string} - Project root path (falls back to current directory if no markers found)
*/
export function findProjectRoot(startDir = process.cwd()) {
// Define project markers that indicate a project root
// Prioritize Task Master specific markers first
const projectMarkers = [
'.taskmaster',
TASKMASTER_TASKS_FILE,
'tasks.json',
LEGACY_TASKS_FILE,
'.git',
'.svn',
'package.json',
'yarn.lock',
'package-lock.json',
'pnpm-lock.yaml'
'.taskmaster', // Task Master directory (highest priority)
TASKMASTER_CONFIG_FILE, // .taskmaster/config.json
TASKMASTER_TASKS_FILE, // .taskmaster/tasks/tasks.json
LEGACY_CONFIG_FILE, // .taskmasterconfig (legacy)
LEGACY_TASKS_FILE, // tasks/tasks.json (legacy)
'tasks.json', // Root tasks.json (legacy)
'.git', // Git repository
'.svn', // SVN repository
'package.json', // Node.js project
'yarn.lock', // Yarn project
'package-lock.json', // npm project
'pnpm-lock.yaml', // pnpm project
'Cargo.toml', // Rust project
'go.mod', // Go project
'pyproject.toml', // Python project
'requirements.txt', // Python project
'Gemfile', // Ruby project
'composer.json' // PHP project
];
let currentDir = path.resolve(startDir);
@@ -69,19 +81,36 @@ export function findProjectRoot(startDir = process.cwd()) {
const maxDepth = 50; // Reasonable limit to prevent infinite loops
let depth = 0;
// Traverse upwards looking for project markers
while (currentDir !== rootDir && depth < maxDepth) {
// Check if current directory contains any project markers
for (const marker of projectMarkers) {
const markerPath = path.join(currentDir, marker);
try {
if (fs.existsSync(markerPath)) {
// Found a project marker - return this directory as project root
return currentDir;
}
} catch (error) {
// Ignore permission errors and continue searching
continue;
}
currentDir = path.dirname(currentDir);
}
// Move up one directory level
const parentDir = path.dirname(currentDir);
// Safety check: if dirname returns the same path, we've hit the root
if (parentDir === currentDir) {
break;
}
currentDir = parentDir;
depth++;
}
// Fallback to current working directory if no project root found
// This ensures the function always returns a valid path
return process.cwd();
}

View File

@@ -0,0 +1,123 @@
/**
* tool-counts.js
* Shared helper for validating tool counts across tests and validation scripts
*/
import {
getToolCounts,
getToolCategories
} from '../../mcp-server/src/tools/tool-registry.js';
/**
* Expected tool counts - update these when tools are added/removed
* These serve as the canonical source of truth for expected counts
*/
export const EXPECTED_TOOL_COUNTS = {
core: 7,
standard: 15,
total: 36
};
/**
* Expected core tools list for validation
*/
export const EXPECTED_CORE_TOOLS = [
'get_tasks',
'next_task',
'get_task',
'set_task_status',
'update_subtask',
'parse_prd',
'expand_task'
];
/**
* Validate that actual tool counts match expected counts
* @returns {Object} Validation result with isValid flag and details
*/
export function validateToolCounts() {
const actual = getToolCounts();
const expected = EXPECTED_TOOL_COUNTS;
const isValid =
actual.core === expected.core &&
actual.standard === expected.standard &&
actual.total === expected.total;
return {
isValid,
actual,
expected,
differences: {
core: actual.core - expected.core,
standard: actual.standard - expected.standard,
total: actual.total - expected.total
}
};
}
/**
* Validate that tool categories have correct structure and content
* @returns {Object} Validation result
*/
export function validateToolStructure() {
const categories = getToolCategories();
const counts = getToolCounts();
// Check that core tools are subset of standard tools
const coreInStandard = categories.core.every((tool) =>
categories.standard.includes(tool)
);
// Check that standard tools are subset of all tools
const standardInAll = categories.standard.every((tool) =>
categories.all.includes(tool)
);
// Check that expected core tools match actual
const expectedCoreMatch =
EXPECTED_CORE_TOOLS.every((tool) => categories.core.includes(tool)) &&
categories.core.every((tool) => EXPECTED_CORE_TOOLS.includes(tool));
// Check array lengths match counts
const lengthsMatch =
categories.core.length === counts.core &&
categories.standard.length === counts.standard &&
categories.all.length === counts.total;
return {
isValid:
coreInStandard && standardInAll && expectedCoreMatch && lengthsMatch,
details: {
coreInStandard,
standardInAll,
expectedCoreMatch,
lengthsMatch
},
categories,
counts
};
}
/**
* Get a detailed report of all tool information
* @returns {Object} Comprehensive tool information
*/
export function getToolReport() {
const counts = getToolCounts();
const categories = getToolCategories();
const validation = validateToolCounts();
const structure = validateToolStructure();
return {
counts,
categories,
validation,
structure,
summary: {
totalValid: validation.isValid && structure.isValid,
countsValid: validation.isValid,
structureValid: structure.isValid
}
};
}

View File

@@ -0,0 +1,410 @@
/**
* tool-registration.test.js
* Comprehensive unit tests for the Task Master MCP tool registration system
* Tests environment variable control system covering all configuration modes and edge cases
*/
import {
describe,
it,
expect,
beforeEach,
afterEach,
jest
} from '@jest/globals';
import {
EXPECTED_TOOL_COUNTS,
EXPECTED_CORE_TOOLS,
validateToolCounts,
validateToolStructure
} from '../../../helpers/tool-counts.js';
import { registerTaskMasterTools } from '../../../../mcp-server/src/tools/index.js';
import {
toolRegistry,
coreTools,
standardTools
} from '../../../../mcp-server/src/tools/tool-registry.js';
// Derive constants from imported registry to avoid brittle magic numbers
const ALL_COUNT = Object.keys(toolRegistry).length;
const CORE_COUNT = coreTools.length;
const STANDARD_COUNT = standardTools.length;
describe('Task Master Tool Registration System', () => {
let mockServer;
let originalEnv;
beforeEach(() => {
originalEnv = process.env.TASK_MASTER_TOOLS;
mockServer = {
tools: [],
addTool: jest.fn((tool) => {
mockServer.tools.push(tool);
return tool;
})
};
delete process.env.TASK_MASTER_TOOLS;
});
afterEach(() => {
if (originalEnv !== undefined) {
process.env.TASK_MASTER_TOOLS = originalEnv;
} else {
delete process.env.TASK_MASTER_TOOLS;
}
jest.clearAllMocks();
});
describe('Test Environment Setup', () => {
it('should have properly configured mock server', () => {
expect(mockServer).toBeDefined();
expect(typeof mockServer.addTool).toBe('function');
expect(Array.isArray(mockServer.tools)).toBe(true);
expect(mockServer.tools.length).toBe(0);
});
it('should have correct tool registry structure', () => {
const validation = validateToolCounts();
expect(validation.isValid).toBe(true);
if (!validation.isValid) {
console.error('Tool count validation failed:', validation);
}
expect(validation.actual.total).toBe(EXPECTED_TOOL_COUNTS.total);
expect(validation.actual.core).toBe(EXPECTED_TOOL_COUNTS.core);
expect(validation.actual.standard).toBe(EXPECTED_TOOL_COUNTS.standard);
});
it('should have correct core tools', () => {
const structure = validateToolStructure();
expect(structure.isValid).toBe(true);
if (!structure.isValid) {
console.error('Tool structure validation failed:', structure);
}
expect(coreTools).toEqual(expect.arrayContaining(EXPECTED_CORE_TOOLS));
expect(coreTools.length).toBe(EXPECTED_TOOL_COUNTS.core);
});
it('should have correct standard tools that include all core tools', () => {
const structure = validateToolStructure();
expect(structure.details.coreInStandard).toBe(true);
expect(standardTools.length).toBe(EXPECTED_TOOL_COUNTS.standard);
coreTools.forEach((tool) => {
expect(standardTools).toContain(tool);
});
});
it('should have all expected tools in registry', () => {
const expectedTools = [
'initialize_project',
'models',
'research',
'add_tag',
'delete_tag',
'get_tasks',
'next_task',
'get_task'
];
expectedTools.forEach((tool) => {
expect(toolRegistry).toHaveProperty(tool);
});
});
});
describe('Configuration Modes', () => {
it(`should register all tools (${ALL_COUNT}) when TASK_MASTER_TOOLS is not set (default behavior)`, () => {
delete process.env.TASK_MASTER_TOOLS;
registerTaskMasterTools(mockServer);
expect(mockServer.addTool).toHaveBeenCalledTimes(
EXPECTED_TOOL_COUNTS.total
);
});
it(`should register all tools (${ALL_COUNT}) when TASK_MASTER_TOOLS=all`, () => {
process.env.TASK_MASTER_TOOLS = 'all';
registerTaskMasterTools(mockServer);
expect(mockServer.addTool).toHaveBeenCalledTimes(ALL_COUNT);
});
it(`should register exactly ${CORE_COUNT} core tools when TASK_MASTER_TOOLS=core`, () => {
process.env.TASK_MASTER_TOOLS = 'core';
registerTaskMasterTools(mockServer, 'core');
expect(mockServer.addTool).toHaveBeenCalledTimes(
EXPECTED_TOOL_COUNTS.core
);
});
it(`should register exactly ${STANDARD_COUNT} standard tools when TASK_MASTER_TOOLS=standard`, () => {
process.env.TASK_MASTER_TOOLS = 'standard';
registerTaskMasterTools(mockServer, 'standard');
expect(mockServer.addTool).toHaveBeenCalledTimes(
EXPECTED_TOOL_COUNTS.standard
);
});
it(`should treat lean as alias for core mode (${CORE_COUNT} tools)`, () => {
process.env.TASK_MASTER_TOOLS = 'lean';
registerTaskMasterTools(mockServer, 'lean');
expect(mockServer.addTool).toHaveBeenCalledTimes(CORE_COUNT);
});
it('should handle case insensitive configuration values', () => {
process.env.TASK_MASTER_TOOLS = 'CORE';
registerTaskMasterTools(mockServer, 'CORE');
expect(mockServer.addTool).toHaveBeenCalledTimes(CORE_COUNT);
});
});
describe('Custom Tool Selection and Edge Cases', () => {
it('should register specific tools from comma-separated list', () => {
process.env.TASK_MASTER_TOOLS = 'get_tasks,next_task,get_task';
registerTaskMasterTools(mockServer, 'get_tasks,next_task,get_task');
expect(mockServer.addTool).toHaveBeenCalledTimes(3);
});
it('should handle mixed valid and invalid tool names gracefully', () => {
process.env.TASK_MASTER_TOOLS =
'invalid_tool,get_tasks,fake_tool,next_task';
registerTaskMasterTools(
mockServer,
'invalid_tool,get_tasks,fake_tool,next_task'
);
expect(mockServer.addTool).toHaveBeenCalledTimes(2);
});
it('should default to all tools with completely invalid input', () => {
process.env.TASK_MASTER_TOOLS = 'completely_invalid';
registerTaskMasterTools(mockServer);
expect(mockServer.addTool).toHaveBeenCalledTimes(ALL_COUNT);
});
it('should handle empty string environment variable', () => {
process.env.TASK_MASTER_TOOLS = '';
registerTaskMasterTools(mockServer);
expect(mockServer.addTool).toHaveBeenCalledTimes(ALL_COUNT);
});
it('should handle whitespace in comma-separated lists', () => {
process.env.TASK_MASTER_TOOLS = ' get_tasks , next_task , get_task ';
registerTaskMasterTools(mockServer, ' get_tasks , next_task , get_task ');
expect(mockServer.addTool).toHaveBeenCalledTimes(3);
});
it('should ignore duplicate tools in list', () => {
process.env.TASK_MASTER_TOOLS = 'get_tasks,get_tasks,next_task,get_tasks';
registerTaskMasterTools(
mockServer,
'get_tasks,get_tasks,next_task,get_tasks'
);
expect(mockServer.addTool).toHaveBeenCalledTimes(2);
});
it('should handle only commas and empty entries', () => {
process.env.TASK_MASTER_TOOLS = ',,,';
registerTaskMasterTools(mockServer);
expect(mockServer.addTool).toHaveBeenCalledTimes(ALL_COUNT);
});
it('should handle single tool selection', () => {
process.env.TASK_MASTER_TOOLS = 'get_tasks';
registerTaskMasterTools(mockServer, 'get_tasks');
expect(mockServer.addTool).toHaveBeenCalledTimes(1);
});
});
describe('Coverage Analysis and Integration Tests', () => {
it('should provide 100% code coverage for environment control logic', () => {
const testCases = [
{
env: undefined,
expectedCount: ALL_COUNT,
description: 'undefined env (all)'
},
{
env: '',
expectedCount: ALL_COUNT,
description: 'empty string (all)'
},
{ env: 'all', expectedCount: ALL_COUNT, description: 'all mode' },
{ env: 'core', expectedCount: CORE_COUNT, description: 'core mode' },
{
env: 'lean',
expectedCount: CORE_COUNT,
description: 'lean mode (alias)'
},
{
env: 'standard',
expectedCount: STANDARD_COUNT,
description: 'standard mode'
},
{
env: 'get_tasks,next_task',
expectedCount: 2,
description: 'custom list'
},
{
env: 'invalid_tool',
expectedCount: ALL_COUNT,
description: 'invalid fallback'
}
];
testCases.forEach((testCase) => {
delete process.env.TASK_MASTER_TOOLS;
if (testCase.env !== undefined) {
process.env.TASK_MASTER_TOOLS = testCase.env;
}
mockServer.tools = [];
mockServer.addTool.mockClear();
registerTaskMasterTools(mockServer, testCase.env || 'all');
expect(mockServer.addTool).toHaveBeenCalledTimes(
testCase.expectedCount
);
});
});
it('should have optimal performance characteristics', () => {
const startTime = Date.now();
process.env.TASK_MASTER_TOOLS = 'all';
registerTaskMasterTools(mockServer);
const endTime = Date.now();
const executionTime = endTime - startTime;
expect(executionTime).toBeLessThan(100);
expect(mockServer.addTool).toHaveBeenCalledTimes(ALL_COUNT);
});
it('should validate token reduction claims', () => {
expect(coreTools.length).toBeLessThan(standardTools.length);
expect(standardTools.length).toBeLessThan(
Object.keys(toolRegistry).length
);
expect(coreTools.length).toBe(CORE_COUNT);
expect(standardTools.length).toBe(STANDARD_COUNT);
expect(Object.keys(toolRegistry).length).toBe(ALL_COUNT);
const allToolsCount = Object.keys(toolRegistry).length;
const coreReduction =
((allToolsCount - coreTools.length) / allToolsCount) * 100;
const standardReduction =
((allToolsCount - standardTools.length) / allToolsCount) * 100;
expect(coreReduction).toBeGreaterThan(80);
expect(standardReduction).toBeGreaterThan(50);
});
it('should maintain referential integrity of tool registry', () => {
coreTools.forEach((tool) => {
expect(standardTools).toContain(tool);
});
standardTools.forEach((tool) => {
expect(toolRegistry).toHaveProperty(tool);
});
Object.keys(toolRegistry).forEach((tool) => {
expect(typeof toolRegistry[tool]).toBe('function');
});
});
it('should handle concurrent registration attempts', () => {
process.env.TASK_MASTER_TOOLS = 'core';
registerTaskMasterTools(mockServer, 'core');
registerTaskMasterTools(mockServer, 'core');
registerTaskMasterTools(mockServer, 'core');
expect(mockServer.addTool).toHaveBeenCalledTimes(CORE_COUNT * 3);
});
it('should validate all documented tool categories exist', () => {
const allTools = Object.keys(toolRegistry);
const projectSetupTools = allTools.filter((tool) =>
['initialize_project', 'models', 'rules', 'parse_prd'].includes(tool)
);
expect(projectSetupTools.length).toBeGreaterThan(0);
const taskManagementTools = allTools.filter((tool) =>
['get_tasks', 'get_task', 'next_task', 'set_task_status'].includes(tool)
);
expect(taskManagementTools.length).toBeGreaterThan(0);
const analysisTools = allTools.filter((tool) =>
['analyze_project_complexity', 'complexity_report'].includes(tool)
);
expect(analysisTools.length).toBeGreaterThan(0);
const tagManagementTools = allTools.filter((tool) =>
['add_tag', 'delete_tag', 'list_tags', 'use_tag'].includes(tool)
);
expect(tagManagementTools.length).toBeGreaterThan(0);
});
it('should handle error conditions gracefully', () => {
const problematicInputs = [
'null',
'undefined',
' ',
'\n\t',
'special!@#$%^&*()characters',
'very,very,very,very,very,very,very,long,comma,separated,list,with,invalid,tools,that,should,fallback,to,all'
];
problematicInputs.forEach((input) => {
mockServer.tools = [];
mockServer.addTool.mockClear();
process.env.TASK_MASTER_TOOLS = input;
expect(() => registerTaskMasterTools(mockServer)).not.toThrow();
expect(mockServer.addTool).toHaveBeenCalledTimes(ALL_COUNT);
});
});
});
});

View File

@@ -0,0 +1,223 @@
/**
* Unit tests for findProjectRoot() function
* Tests the parent directory traversal functionality
*/
import { jest } from '@jest/globals';
import path from 'path';
import fs from 'fs';
// Import the function to test
import { findProjectRoot } from '../../src/utils/path-utils.js';
describe('findProjectRoot', () => {
describe('Parent Directory Traversal', () => {
test('should find .taskmaster in parent directory', () => {
const mockExistsSync = jest.spyOn(fs, 'existsSync');
mockExistsSync.mockImplementation((checkPath) => {
const normalized = path.normalize(checkPath);
// .taskmaster exists only at /project
return normalized === path.normalize('/project/.taskmaster');
});
const result = findProjectRoot('/project/subdir');
expect(result).toBe('/project');
mockExistsSync.mockRestore();
});
test('should find .git in parent directory', () => {
const mockExistsSync = jest.spyOn(fs, 'existsSync');
mockExistsSync.mockImplementation((checkPath) => {
const normalized = path.normalize(checkPath);
return normalized === path.normalize('/project/.git');
});
const result = findProjectRoot('/project/subdir');
expect(result).toBe('/project');
mockExistsSync.mockRestore();
});
test('should find package.json in parent directory', () => {
const mockExistsSync = jest.spyOn(fs, 'existsSync');
mockExistsSync.mockImplementation((checkPath) => {
const normalized = path.normalize(checkPath);
return normalized === path.normalize('/project/package.json');
});
const result = findProjectRoot('/project/subdir');
expect(result).toBe('/project');
mockExistsSync.mockRestore();
});
test('should traverse multiple levels to find project root', () => {
const mockExistsSync = jest.spyOn(fs, 'existsSync');
mockExistsSync.mockImplementation((checkPath) => {
const normalized = path.normalize(checkPath);
// Only exists at /project, not in any subdirectories
return normalized === path.normalize('/project/.taskmaster');
});
const result = findProjectRoot('/project/subdir/deep/nested');
expect(result).toBe('/project');
mockExistsSync.mockRestore();
});
test('should return current directory as fallback when no markers found', () => {
const mockExistsSync = jest.spyOn(fs, 'existsSync');
// No project markers exist anywhere
mockExistsSync.mockReturnValue(false);
const result = findProjectRoot('/some/random/path');
// Should fall back to process.cwd()
expect(result).toBe(process.cwd());
mockExistsSync.mockRestore();
});
test('should find markers at current directory before checking parent', () => {
const mockExistsSync = jest.spyOn(fs, 'existsSync');
mockExistsSync.mockImplementation((checkPath) => {
const normalized = path.normalize(checkPath);
// .git exists at /project/subdir, .taskmaster exists at /project
if (normalized.includes('/project/subdir/.git')) return true;
if (normalized.includes('/project/.taskmaster')) return true;
return false;
});
const result = findProjectRoot('/project/subdir');
// Should find /project/subdir first because .git exists there,
// even though .taskmaster is earlier in the marker array
expect(result).toBe('/project/subdir');
mockExistsSync.mockRestore();
});
test('should handle permission errors gracefully', () => {
const mockExistsSync = jest.spyOn(fs, 'existsSync');
mockExistsSync.mockImplementation((checkPath) => {
const normalized = path.normalize(checkPath);
// Throw permission error for checks in /project/subdir
if (normalized.startsWith('/project/subdir/')) {
throw new Error('EACCES: permission denied');
}
// Return true only for .taskmaster at /project
return normalized.includes('/project/.taskmaster');
});
const result = findProjectRoot('/project/subdir');
// Should handle permission errors in subdirectory and traverse to parent
expect(result).toBe('/project');
mockExistsSync.mockRestore();
});
test('should detect filesystem root correctly', () => {
const mockExistsSync = jest.spyOn(fs, 'existsSync');
// No markers exist
mockExistsSync.mockReturnValue(false);
const result = findProjectRoot('/');
// Should stop at root and fall back to process.cwd()
expect(result).toBe(process.cwd());
mockExistsSync.mockRestore();
});
test('should recognize various project markers', () => {
const projectMarkers = [
'.taskmaster',
'.git',
'package.json',
'Cargo.toml',
'go.mod',
'pyproject.toml',
'requirements.txt',
'Gemfile',
'composer.json'
];
projectMarkers.forEach((marker) => {
const mockExistsSync = jest.spyOn(fs, 'existsSync');
mockExistsSync.mockImplementation((checkPath) => {
const normalized = path.normalize(checkPath);
return normalized.includes(`/project/${marker}`);
});
const result = findProjectRoot('/project/subdir');
expect(result).toBe('/project');
mockExistsSync.mockRestore();
});
});
});
describe('Edge Cases', () => {
test('should handle empty string as startDir', () => {
const result = findProjectRoot('');
// Should use process.cwd() or fall back appropriately
expect(typeof result).toBe('string');
expect(result.length).toBeGreaterThan(0);
});
test('should handle relative paths', () => {
const mockExistsSync = jest.spyOn(fs, 'existsSync');
mockExistsSync.mockImplementation((checkPath) => {
// Simulate .git existing in the resolved path
return checkPath.includes('.git');
});
const result = findProjectRoot('./subdir');
expect(typeof result).toBe('string');
mockExistsSync.mockRestore();
});
test('should not exceed max depth limit', () => {
const mockExistsSync = jest.spyOn(fs, 'existsSync');
// Track how many times existsSync is called
let callCount = 0;
mockExistsSync.mockImplementation(() => {
callCount++;
return false; // Never find a marker
});
// Create a very deep path
const deepPath = '/a/'.repeat(100) + 'deep';
const result = findProjectRoot(deepPath);
// Should stop after max depth (50) and not check 100 levels
// Each level checks multiple markers, so callCount will be high but bounded
expect(callCount).toBeLessThan(1000); // Reasonable upper bound
// With 18 markers and max depth of 50, expect around 900 calls maximum
expect(callCount).toBeLessThanOrEqual(50 * 18);
mockExistsSync.mockRestore();
});
});
});