Compare commits

...

11 Commits

Author SHA1 Message Date
github-actions[bot]
3ec3741f3b 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: add sonnet and haiku to supported providers (#1317)\n\n\n

  Co-authored-by: Claude <claude-assistant@anthropic.com>
2025-10-18 09:21:15 +00:00
Ralph Khreish
548beb4344 feat: add sonnet and haiku to supported providers (#1317) 2025-10-18 11:12:48 +02:00
Ralph Khreish
555da2b5b9 fix: downgrade log level to silent (#1321) 2025-10-18 11:03:27 +02: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
40 changed files with 936 additions and 290 deletions

View File

@@ -0,0 +1,5 @@
---
"task-master-ai": minor
---
Add 4.5 haiku and sonnet to supported models for claude-code and anthropic ai providers

View File

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

View File

@@ -14,6 +14,8 @@ import {
type AuthCredentials type AuthCredentials
} from '@tm/core/auth'; } from '@tm/core/auth';
import * as ui from '../utils/ui.js'; 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 * Result type from auth command
@@ -116,8 +118,7 @@ export class AuthCommand extends Command {
process.exit(0); process.exit(0);
}, 100); }, 100);
} catch (error: any) { } catch (error: any) {
this.handleError(error); displayError(error);
process.exit(1);
} }
} }
@@ -133,8 +134,7 @@ export class AuthCommand extends Command {
process.exit(1); process.exit(1);
} }
} catch (error: any) { } catch (error: any) {
this.handleError(error); displayError(error);
process.exit(1);
} }
} }
@@ -146,8 +146,7 @@ export class AuthCommand extends Command {
const result = this.displayStatus(); const result = this.displayStatus();
this.setLastResult(result); this.setLastResult(result);
} catch (error: any) { } catch (error: any) {
this.handleError(error); displayError(error);
process.exit(1);
} }
} }
@@ -163,8 +162,7 @@ export class AuthCommand extends Command {
process.exit(1); process.exit(1);
} }
} catch (error: any) { } catch (error: any) {
this.handleError(error); displayError(error);
process.exit(1);
} }
} }
@@ -351,6 +349,37 @@ export class AuthCommand extends Command {
chalk.gray(` Logged in as: ${credentials.email || credentials.userId}`) 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 { return {
success: true, success: true,
action: 'login', action: 'login',
@@ -358,7 +387,7 @@ export class AuthCommand extends Command {
message: 'Authentication successful' message: 'Authentication successful'
}; };
} catch (error) { } catch (error) {
this.handleAuthError(error as AuthenticationError); displayError(error, { skipExit: true });
return { return {
success: false, 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 * Set the last result for programmatic access
*/ */

View File

@@ -6,13 +6,11 @@
import { Command } from 'commander'; import { Command } from 'commander';
import chalk from 'chalk'; import chalk from 'chalk';
import inquirer from 'inquirer'; import inquirer from 'inquirer';
import search from '@inquirer/search';
import ora, { Ora } from 'ora'; import ora, { Ora } from 'ora';
import { import { AuthManager, type UserContext } from '@tm/core/auth';
AuthManager,
AuthenticationError,
type UserContext
} from '@tm/core/auth';
import * as ui from '../utils/ui.js'; import * as ui from '../utils/ui.js';
import { displayError } from '../utils/error-handler.js';
/** /**
* Result type from context command * Result type from context command
@@ -118,8 +116,7 @@ export class ContextCommand extends Command {
const result = this.displayContext(); const result = this.displayContext();
this.setLastResult(result); this.setLastResult(result);
} catch (error: any) { } catch (error: any) {
this.handleError(error); displayError(error);
process.exit(1);
} }
} }
@@ -156,10 +153,14 @@ export class ContextCommand extends Command {
if (context.briefName || context.briefId) { if (context.briefName || context.briefId) {
console.log(chalk.green('\n✓ Brief')); 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}`)); console.log(chalk.white(` ${context.briefName}`));
} } else if (context.briefId) {
if (context.briefId) {
console.log(chalk.gray(` ID: ${context.briefId}`)); console.log(chalk.gray(` ID: ${context.briefId}`));
} }
} }
@@ -211,8 +212,7 @@ export class ContextCommand extends Command {
process.exit(1); process.exit(1);
} }
} catch (error: any) { } catch (error: any) {
this.handleError(error); displayError(error);
process.exit(1);
} }
} }
@@ -253,6 +253,7 @@ export class ContextCommand extends Command {
this.authManager.updateContext({ this.authManager.updateContext({
orgId: selectedOrg.id, orgId: selectedOrg.id,
orgName: selectedOrg.name, orgName: selectedOrg.name,
orgSlug: selectedOrg.slug,
// Clear brief when changing org // Clear brief when changing org
briefId: undefined, briefId: undefined,
briefName: undefined briefName: undefined
@@ -299,8 +300,7 @@ export class ContextCommand extends Command {
process.exit(1); process.exit(1);
} }
} catch (error: any) { } catch (error: any) {
this.handleError(error); displayError(error);
process.exit(1);
} }
} }
@@ -324,25 +324,53 @@ export class ContextCommand extends Command {
}; };
} }
// Prompt for selection // Prompt for selection with search
const { selectedBrief } = await inquirer.prompt([ const selectedBrief = await search<(typeof briefs)[0] | null>({
{ message: 'Search for a brief:',
type: 'list', source: async (input) => {
name: 'selectedBrief', const searchTerm = input?.toLowerCase() || '';
message: 'Select a brief:',
choices: [ // Static option for no brief
{ name: '(No brief - organization level)', value: null }, const noBriefOption = {
...briefs.map((brief) => ({ name: '(No brief - organization level)',
name: `Brief ${brief.id} (${new Date(brief.createdAt).toLocaleDateString()})`, value: null as any,
value: brief 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) { if (selectedBrief) {
// Update context with brief // Update context with brief
const briefName = `Brief ${selectedBrief.id.slice(0, 8)}`; const briefName =
selectedBrief.document?.title ||
`Brief ${selectedBrief.id.slice(0, 8)}`;
this.authManager.updateContext({ this.authManager.updateContext({
briefId: selectedBrief.id, briefId: selectedBrief.id,
briefName: briefName briefName: briefName
@@ -354,7 +382,7 @@ export class ContextCommand extends Command {
success: true, success: true,
action: 'select-brief', action: 'select-brief',
context: this.authManager.getContext() || undefined, context: this.authManager.getContext() || undefined,
message: `Selected brief: ${selectedBrief.name}` message: `Selected brief: ${selectedBrief.document?.title}`
}; };
} else { } else {
// Clear brief selection // Clear brief selection
@@ -396,8 +424,7 @@ export class ContextCommand extends Command {
process.exit(1); process.exit(1);
} }
} catch (error: any) { } catch (error: any) {
this.handleError(error); displayError(error);
process.exit(1);
} }
} }
@@ -443,8 +470,7 @@ export class ContextCommand extends Command {
process.exit(1); process.exit(1);
} }
} catch (error: any) { } catch (error: any) {
this.handleError(error); displayError(error);
process.exit(1);
} }
} }
@@ -468,7 +494,7 @@ export class ContextCommand extends Command {
if (!briefId) { if (!briefId) {
spinner.fail('Could not extract a brief ID from the provided input'); spinner.fail('Could not extract a brief ID from the provided input');
ui.displayError( 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); process.exit(1);
} }
@@ -480,20 +506,24 @@ export class ContextCommand extends Command {
process.exit(1); 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 orgName: string | undefined;
let orgSlug: string | undefined;
try { try {
const org = await this.authManager.getOrganization(brief.accountId); const org = await this.authManager.getOrganization(brief.accountId);
orgName = org?.name; orgName = org?.name;
orgSlug = org?.slug;
} catch { } catch {
// Non-fatal if org lookup fails // Non-fatal if org lookup fails
} }
// Update context: set org and brief // Update context: set org and brief
const briefName = `Brief ${brief.id.slice(0, 8)}`; const briefName =
brief.document?.title || `Brief ${brief.id.slice(0, 8)}`;
this.authManager.updateContext({ this.authManager.updateContext({
orgId: brief.accountId, orgId: brief.accountId,
orgName, orgName,
orgSlug,
briefId: brief.id, briefId: brief.id,
briefName briefName
}); });
@@ -515,8 +545,7 @@ export class ContextCommand extends Command {
try { try {
if (spinner?.isSpinning) spinner.stop(); if (spinner?.isSpinning) spinner.stop();
} catch {} } catch {}
this.handleError(error); displayError(error);
process.exit(1);
} }
} }
@@ -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 * Set the last result for programmatic access
*/ */
@@ -686,6 +695,53 @@ export class ContextCommand extends Command {
return this.authManager.getContext(); 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 * Clean up resources
*/ */

View File

@@ -7,13 +7,10 @@ import { Command } from 'commander';
import chalk from 'chalk'; import chalk from 'chalk';
import inquirer from 'inquirer'; import inquirer from 'inquirer';
import ora, { Ora } from 'ora'; import ora, { Ora } from 'ora';
import { import { AuthManager, type UserContext } from '@tm/core/auth';
AuthManager,
AuthenticationError,
type UserContext
} from '@tm/core/auth';
import { TaskMasterCore, type ExportResult } from '@tm/core'; import { TaskMasterCore, type ExportResult } from '@tm/core';
import * as ui from '../utils/ui.js'; import * as ui from '../utils/ui.js';
import { displayError } from '../utils/error-handler.js';
/** /**
* Result type from export command * Result type from export command
@@ -197,8 +194,7 @@ export class ExportCommand extends Command {
}; };
} catch (error: any) { } catch (error: any) {
if (spinner?.isSpinning) spinner.fail('Export failed'); if (spinner?.isSpinning) spinner.fail('Export failed');
this.handleError(error); displayError(error);
process.exit(1);
} }
} }
@@ -334,26 +330,6 @@ export class ExportCommand extends Command {
return confirmed; 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) * Get the last export result (useful for testing)
*/ */

View File

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

View File

@@ -9,8 +9,9 @@ import chalk from 'chalk';
import boxen from 'boxen'; import boxen from 'boxen';
import { createTaskMasterCore, type Task, type TaskMasterCore } from '@tm/core'; import { createTaskMasterCore, type Task, type TaskMasterCore } from '@tm/core';
import type { StorageType } from '@tm/core/types'; import type { StorageType } from '@tm/core/types';
import { displayError } from '../utils/error-handler.js';
import { displayTaskDetails } from '../ui/components/task-detail.component.js'; import { displayTaskDetails } from '../ui/components/task-detail.component.js';
import { displayHeader } from '../ui/index.js'; import { displayCommandHeader } from '../utils/display-helpers.js';
/** /**
* Options interface for the next command * Options interface for the next command
@@ -58,6 +59,7 @@ export class NextCommand extends Command {
* Execute the next command * Execute the next command
*/ */
private async executeCommand(options: NextCommandOptions): Promise<void> { private async executeCommand(options: NextCommandOptions): Promise<void> {
let hasError = false;
try { try {
// Validate options (throws on invalid options) // Validate options (throws on invalid options)
this.validateOptions(options); this.validateOptions(options);
@@ -76,16 +78,17 @@ export class NextCommand extends Command {
this.displayResults(result, options); this.displayResults(result, options);
} }
} catch (error: any) { } catch (error: any) {
const msg = error?.getSanitizedDetails?.() ?? { hasError = true;
message: error?.message ?? String(error) displayError(error, { skipExit: true });
};
// Allow error to propagate for library compatibility
throw new Error(msg.message || 'Unexpected error in next command');
} finally { } finally {
// Always clean up resources, even on error // Always clean up resources, even on error
await this.cleanup(); await this.cleanup();
} }
// Exit after cleanup completes
if (hasError) {
process.exit(1);
}
} }
/** /**
@@ -170,9 +173,10 @@ export class NextCommand extends Command {
* Display in text format * Display in text format
*/ */
private displayText(result: NextTaskResult): void { private displayText(result: NextTaskResult): void {
// Display header with tag (no file path for next command) // Display header with storage info
displayHeader({ displayCommandHeader(this.tmCore, {
tag: result.tag || 'master' tag: result.tag || 'master',
storageType: result.storageType
}); });
if (!result.found || !result.task) { if (!result.found || !result.task) {
@@ -191,7 +195,6 @@ export class NextCommand extends Command {
} }
) )
); );
console.log(`\n${chalk.gray('Storage: ' + result.storageType)}`);
console.log( console.log(
`\n${chalk.dim('Tip: Try')} ${chalk.cyan('task-master list --status pending')} ${chalk.dim('to see all pending tasks')}` `\n${chalk.dim('Tip: Try')} ${chalk.cyan('task-master list --status pending')} ${chalk.dim('to see all pending tasks')}`
); );
@@ -208,8 +211,6 @@ export class NextCommand extends Command {
headerColor: 'green', headerColor: 'green',
showSuggestedActions: true showSuggestedActions: true
}); });
console.log(`\n${chalk.gray('Storage: ' + result.storageType)}`);
} }
/** /**

View File

@@ -12,6 +12,7 @@ import {
type TaskStatus type TaskStatus
} from '@tm/core'; } from '@tm/core';
import type { StorageType } from '@tm/core/types'; import type { StorageType } from '@tm/core/types';
import { displayError } from '../utils/error-handler.js';
/** /**
* Valid task status values for validation * Valid task status values for validation
@@ -85,6 +86,7 @@ export class SetStatusCommand extends Command {
private async executeCommand( private async executeCommand(
options: SetStatusCommandOptions options: SetStatusCommandOptions
): Promise<void> { ): Promise<void> {
let hasError = false;
try { try {
// Validate required options // Validate required options
if (!options.id) { if (!options.id) {
@@ -135,16 +137,15 @@ export class SetStatusCommand extends Command {
oldStatus: result.oldStatus, oldStatus: result.oldStatus,
newStatus: result.newStatus newStatus: result.newStatus
}); });
} catch (error) { } catch (error: any) {
const errorMessage = hasError = true;
error instanceof Error ? error.message : String(error);
if (!options.silent) {
console.error(
chalk.red(`Failed to update task ${taskId}: ${errorMessage}`)
);
}
if (options.format === 'json') { if (options.format === 'json') {
const errorMessage = error?.getSanitizedDetails
? error.getSanitizedDetails().message
: error instanceof Error
? error.message
: String(error);
console.log( console.log(
JSON.stringify({ JSON.stringify({
success: false, success: false,
@@ -153,8 +154,13 @@ export class SetStatusCommand extends Command {
timestamp: new Date().toISOString() 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 // Display results
this.displayResults(this.lastResult, options); this.displayResults(this.lastResult, options);
} catch (error) { } catch (error: any) {
const errorMessage = hasError = true;
error instanceof Error ? error.message : 'Unknown error occurred';
if (!options.silent) {
console.error(chalk.red(`Error: ${errorMessage}`));
}
if (options.format === 'json') { if (options.format === 'json') {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error occurred';
console.log(JSON.stringify({ success: false, error: errorMessage })); console.log(JSON.stringify({ success: false, error: errorMessage }));
} else if (!options.silent) {
displayError(error, { skipExit: true });
} }
process.exit(1);
} finally { } finally {
// Clean up resources // Clean up resources
if (this.tmCore) { if (this.tmCore) {
await this.tmCore.close(); 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 { createTaskMasterCore, type Task, type TaskMasterCore } from '@tm/core';
import type { StorageType } from '@tm/core/types'; import type { StorageType } from '@tm/core/types';
import * as ui from '../utils/ui.js'; import * as ui from '../utils/ui.js';
import { displayError } from '../utils/error-handler.js';
import { displayTaskDetails } from '../ui/components/task-detail.component.js'; import { displayTaskDetails } from '../ui/components/task-detail.component.js';
import { displayCommandHeader } from '../utils/display-helpers.js';
/** /**
* Options interface for the show command * Options interface for the show command
@@ -112,14 +114,7 @@ export class ShowCommand extends Command {
this.displayResults(result, options); this.displayResults(result, options);
} }
} catch (error: any) { } catch (error: any) {
const msg = error?.getSanitizedDetails?.() ?? { displayError(error);
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);
} }
} }
@@ -257,6 +252,15 @@ export class ShowCommand extends Command {
return; 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 // Use the global task details display function
displayTaskDetails(result.task, { displayTaskDetails(result.task, {
statusFilter: options.status, statusFilter: options.status,
@@ -271,8 +275,12 @@ export class ShowCommand extends Command {
result: ShowMultipleTasksResult, result: ShowMultipleTasksResult,
_options: ShowCommandOptions _options: ShowCommandOptions
): void { ): void {
// Header // Display header with storage info
ui.displayBanner(`Tasks (${result.tasks.length} found)`); const activeTag = this.tmCore?.getActiveTag() || 'master';
displayCommandHeader(this.tmCore, {
tag: activeTag,
storageType: result.storageType
});
if (result.notFound.length > 0) { if (result.notFound.length > 0) {
console.log(chalk.yellow(`\n⚠ Not found: ${result.notFound.join(', ')}`)); console.log(chalk.yellow(`\n⚠ Not found: ${result.notFound.join(', ')}`));
@@ -291,8 +299,6 @@ export class ShowCommand extends Command {
showDependencies: true showDependencies: true
}) })
); );
console.log(`\n${chalk.gray('Storage: ' + result.storageType)}`);
} }
/** /**

View File

@@ -16,6 +16,7 @@ import {
} from '@tm/core'; } from '@tm/core';
import { displayTaskDetails } from '../ui/components/task-detail.component.js'; import { displayTaskDetails } from '../ui/components/task-detail.component.js';
import * as ui from '../utils/ui.js'; import * as ui from '../utils/ui.js';
import { displayError } from '../utils/error-handler.js';
/** /**
* CLI-specific options interface for the start command * CLI-specific options interface for the start command
@@ -160,8 +161,7 @@ export class StartCommand extends Command {
if (spinner) { if (spinner) {
spinner.fail('Operation failed'); spinner.fail('Operation failed');
} }
this.handleError(error); displayError(error);
process.exit(1);
} }
} }
@@ -452,22 +452,6 @@ export class StartCommand extends Command {
console.log(`\n${chalk.gray('Storage: ' + result.storageType)}`); 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 * Set the last result for programmatic access
*/ */

View File

@@ -24,6 +24,9 @@ export {
// UI utilities (for other commands to use) // UI utilities (for other commands to use)
export * as ui from './utils/ui.js'; export * as ui from './utils/ui.js';
// Error handling utilities
export { displayError, isDebugMode } from './utils/error-handler.js';
// Auto-update utilities // Auto-update utilities
export { export {
checkForUpdate, checkForUpdate,

View File

@@ -5,6 +5,16 @@
import chalk from 'chalk'; import chalk from 'chalk';
/**
* Brief information for API storage
*/
export interface BriefInfo {
briefId: string;
briefName: string;
orgSlug?: string;
webAppUrl?: string;
}
/** /**
* Header configuration options * Header configuration options
*/ */
@@ -12,16 +22,44 @@ export interface HeaderOptions {
title?: string; title?: string;
tag?: string; tag?: string;
filePath?: string; filePath?: string;
storageType?: 'api' | 'file';
briefInfo?: BriefInfo;
} }
/** /**
* Display the Task Master header with project info * Display the Task Master header with project info
*/ */
export function displayHeader(options: HeaderOptions = {}): void { export function displayHeader(options: HeaderOptions = {}): void {
const { filePath, tag } = options; const { filePath, tag, storageType, briefInfo } = options;
// Display tag and file path info // Display different header based on storage type
if (tag) { 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 = ''; let tagInfo = '';
if (tag && tag !== 'master') { if (tag && tag !== 'master') {

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

@@ -1,4 +1,4 @@
# Available Models as of October 5, 2025 # Available Models as of October 18, 2025
## Main Models ## Main Models
@@ -6,10 +6,13 @@
| ----------- | ---------------------------------------------- | --------- | ---------- | ----------- | | ----------- | ---------------------------------------------- | --------- | ---------- | ----------- |
| anthropic | claude-sonnet-4-20250514 | 0.727 | 3 | 15 | | anthropic | claude-sonnet-4-20250514 | 0.727 | 3 | 15 |
| anthropic | claude-opus-4-20250514 | 0.725 | 15 | 75 | | anthropic | claude-opus-4-20250514 | 0.725 | 15 | 75 |
| anthropic | claude-sonnet-4-5-20250929 | 0.73 | 3 | 15 |
| anthropic | claude-3-7-sonnet-20250219 | 0.623 | 3 | 15 | | anthropic | claude-3-7-sonnet-20250219 | 0.623 | 3 | 15 |
| anthropic | claude-3-5-sonnet-20241022 | 0.49 | 3 | 15 | | anthropic | claude-3-5-sonnet-20241022 | 0.49 | 3 | 15 |
| anthropic | claude-haiku-4-5-20251001 | 0.45 | 1 | 5 |
| claude-code | opus | 0.725 | 0 | 0 | | claude-code | opus | 0.725 | 0 | 0 |
| claude-code | sonnet | 0.727 | 0 | 0 | | claude-code | sonnet | 0.727 | 0 | 0 |
| claude-code | haiku | 0.45 | 0 | 0 |
| codex-cli | gpt-5 | 0.749 | 0 | 0 | | codex-cli | gpt-5 | 0.749 | 0 | 0 |
| codex-cli | gpt-5-codex | 0.749 | 0 | 0 | | codex-cli | gpt-5-codex | 0.749 | 0 | 0 |
| mcp | mcp-sampling | — | 0 | 0 | | mcp | mcp-sampling | — | 0 | 0 |
@@ -140,10 +143,13 @@
| ----------- | ---------------------------------------------- | --------- | ---------- | ----------- | | ----------- | ---------------------------------------------- | --------- | ---------- | ----------- |
| anthropic | claude-sonnet-4-20250514 | 0.727 | 3 | 15 | | anthropic | claude-sonnet-4-20250514 | 0.727 | 3 | 15 |
| anthropic | claude-opus-4-20250514 | 0.725 | 15 | 75 | | anthropic | claude-opus-4-20250514 | 0.725 | 15 | 75 |
| anthropic | claude-sonnet-4-5-20250929 | 0.73 | 3 | 15 |
| anthropic | claude-3-7-sonnet-20250219 | 0.623 | 3 | 15 | | anthropic | claude-3-7-sonnet-20250219 | 0.623 | 3 | 15 |
| anthropic | claude-3-5-sonnet-20241022 | 0.49 | 3 | 15 | | anthropic | claude-3-5-sonnet-20241022 | 0.49 | 3 | 15 |
| anthropic | claude-haiku-4-5-20251001 | 0.45 | 1 | 5 |
| claude-code | opus | 0.725 | 0 | 0 | | claude-code | opus | 0.725 | 0 | 0 |
| claude-code | sonnet | 0.727 | 0 | 0 | | claude-code | sonnet | 0.727 | 0 | 0 |
| claude-code | haiku | 0.45 | 0 | 0 |
| codex-cli | gpt-5 | 0.749 | 0 | 0 | | codex-cli | gpt-5 | 0.749 | 0 | 0 |
| codex-cli | gpt-5-codex | 0.749 | 0 | 0 | | codex-cli | gpt-5-codex | 0.749 | 0 | 0 |
| mcp | mcp-sampling | — | 0 | 0 | | mcp | mcp-sampling | — | 0 | 0 |

106
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", "name": "@tm/cli",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@inquirer/search": "^3.2.0",
"@tm/core": "*", "@tm/core": "*",
"boxen": "^8.0.1", "boxen": "^8.0.1",
"chalk": "5.6.2", "chalk": "5.6.2",
@@ -124,6 +125,91 @@
"node": ">=18.0.0" "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": { "apps/docs": {
"version": "0.0.6", "version": "0.0.6",
"devDependencies": { "devDependencies": {

View File

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

View File

@@ -24,6 +24,8 @@ export class CredentialStore {
private config: AuthConfig; private config: AuthConfig;
// Clock skew tolerance for expiry checks (30 seconds) // Clock skew tolerance for expiry checks (30 seconds)
private readonly CLOCK_SKEW_MS = 30_000; 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>) { private constructor(config?: Partial<AuthConfig>) {
this.config = getAuthConfig(config); this.config = getAuthConfig(config);
@@ -84,7 +86,11 @@ export class CredentialStore {
// Validate expiration time for tokens // Validate expiration time for tokens
if (expiresAtMs === undefined) { if (expiresAtMs === undefined) {
this.logger.warn('No valid expiration time provided for token'); // 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; return null;
} }
@@ -174,6 +180,9 @@ export class CredentialStore {
mode: 0o600 mode: 0o600
}); });
fs.renameSync(tempFile, this.config.configFile); 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) { } catch (error) {
throw new AuthenticationError( throw new AuthenticationError(
`Failed to save auth credentials: ${(error as Error).message}`, `Failed to save auth credentials: ${(error as Error).message}`,

View File

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

View File

@@ -29,13 +29,17 @@ export class SupabaseAuthClient {
*/ */
getClient(): SupabaseJSClient { getClient(): SupabaseJSClient {
if (!this.client) { if (!this.client) {
// Get Supabase configuration from environment - using TM_PUBLIC prefix // Get Supabase configuration from environment
const supabaseUrl = process.env.TM_PUBLIC_SUPABASE_URL; // Runtime vars (TM_*) take precedence over build-time vars (TM_PUBLIC_*)
const supabaseAnonKey = process.env.TM_PUBLIC_SUPABASE_ANON_KEY; 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) { if (!supabaseUrl || !supabaseAnonKey) {
throw new AuthenticationError( 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' 'CONFIG_MISSING'
); );
} }

View File

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

View File

@@ -25,7 +25,7 @@ export interface LoggerConfig {
export class Logger { export class Logger {
private config: Required<LoggerConfig>; private config: Required<LoggerConfig>;
private static readonly DEFAULT_CONFIG: Required<LoggerConfig> = { private static readonly DEFAULT_CONFIG: Required<LoggerConfig> = {
level: LogLevel.WARN, level: LogLevel.SILENT,
silent: false, silent: false,
prefix: '', prefix: '',
timestamp: false, timestamp: false,

View File

@@ -358,11 +358,12 @@ export class ExportService {
tasks: any[] tasks: any[]
): Promise<void> { ): Promise<void> {
// Check if we should use the API endpoint or direct Supabase // 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 // 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 // Transform tasks to flat structure for API
const flatTasks = this.transformTasksForBulkImport(tasks); const flatTasks = this.transformTasksForBulkImport(tasks);

View File

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

View File

@@ -161,6 +161,16 @@ export class TaskService {
storageType: this.getStorageType() storageType: this.getStorageType()
}; };
} catch (error) { } 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); this.logger.error('Failed to get task list', error);
throw new TaskMasterError( throw new TaskMasterError(
'Failed to get task list', '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 // Delegate to storage layer which handles the specific logic for tasks vs subtasks
return await this.storage.loadTask(String(taskId), activeTag); return await this.storage.loadTask(String(taskId), activeTag);
} catch (error) { } 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( throw new TaskMasterError(
`Failed to get task ${taskId}`, `Failed to get task ${taskId}`,
ERROR_CODES.STORAGE_ERROR, ERROR_CODES.STORAGE_ERROR,
@@ -522,6 +540,14 @@ export class TaskService {
activeTag activeTag
); );
} catch (error) { } 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( throw new TaskMasterError(
`Failed to update task status for ${taskIdStr}`, `Failed to update task status for ${taskIdStr}`,
ERROR_CODES.STORAGE_ERROR, ERROR_CODES.STORAGE_ERROR,

View File

@@ -37,6 +37,13 @@ export interface ApiStorageConfig {
maxRetries?: number; maxRetries?: number;
} }
/**
* Auth context with a guaranteed briefId
*/
type ContextWithBrief = NonNullable<
ReturnType<typeof AuthManager.prototype.getContext>
> & { briefId: string };
/** /**
* ApiStorage implementation using repository pattern * ApiStorage implementation using repository pattern
* Provides flexibility to swap between different backend implementations * 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 * Load tags into cache
* In our API-based system, "tags" represent briefs * In our API-based system, "tags" represent briefs
@@ -119,7 +133,7 @@ export class ApiStorage implements IStorage {
private async loadTagsIntoCache(): Promise<void> { private async loadTagsIntoCache(): Promise<void> {
try { try {
const authManager = AuthManager.getInstance(); 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 we have a selected brief, create a virtual "tag" for it
if (context?.briefId) { if (context?.briefId) {
@@ -151,15 +165,7 @@ export class ApiStorage implements IStorage {
await this.ensureInitialized(); await this.ensureInitialized();
try { try {
const authManager = AuthManager.getInstance(); const context = this.ensureBriefSelected('loadTasks');
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>'
);
}
// Load tasks from the current brief context with filters pushed to repository // Load tasks from the current brief context with filters pushed to repository
const tasks = await this.retryOperation(() => const tasks = await this.retryOperation(() =>
@@ -174,12 +180,11 @@ export class ApiStorage implements IStorage {
return tasks; return tasks;
} catch (error) { } catch (error) {
throw new TaskMasterError( this.wrapError(error, 'Failed to load tasks from API', {
'Failed to load tasks from API', operation: 'loadTasks',
ERROR_CODES.STORAGE_ERROR, tag,
{ operation: 'loadTasks', tag, context: 'brief-based loading' }, context: 'brief-based loading'
error as Error });
);
} }
} }
@@ -230,16 +235,17 @@ export class ApiStorage implements IStorage {
await this.ensureInitialized(); await this.ensureInitialized();
try { try {
this.ensureBriefSelected('loadTask');
return await this.retryOperation(() => return await this.retryOperation(() =>
this.repository.getTask(this.projectId, taskId) this.repository.getTask(this.projectId, taskId)
); );
} catch (error) { } catch (error) {
throw new TaskMasterError( this.wrapError(error, 'Failed to load task from API', {
'Failed to load task from API', operation: 'loadTask',
ERROR_CODES.STORAGE_ERROR, taskId,
{ operation: 'loadTask', taskId, tag }, tag
error as Error });
);
} }
} }
@@ -318,7 +324,7 @@ export class ApiStorage implements IStorage {
try { try {
const authManager = AuthManager.getInstance(); 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 // In our API-based system, we only have one "tag" at a time - the current brief
if (context?.briefId) { if (context?.briefId) {
@@ -503,6 +509,8 @@ export class ApiStorage implements IStorage {
await this.ensureInitialized(); await this.ensureInitialized();
try { try {
this.ensureBriefSelected('updateTaskStatus');
const existingTask = await this.retryOperation(() => const existingTask = await this.retryOperation(() =>
this.repository.getTask(this.projectId, taskId) this.repository.getTask(this.projectId, taskId)
); );
@@ -539,12 +547,12 @@ export class ApiStorage implements IStorage {
taskId taskId
}; };
} catch (error) { } catch (error) {
throw new TaskMasterError( this.wrapError(error, 'Failed to update task status via API', {
'Failed to update task status via API', operation: 'updateTaskStatus',
ERROR_CODES.STORAGE_ERROR, taskId,
{ operation: 'updateTaskStatus', taskId, newStatus, tag }, newStatus,
error as Error 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 * Retry an operation with exponential backoff
*/ */
@@ -780,4 +811,28 @@ export class ApiStorage implements IStorage {
throw error; 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(); await this.fileOps.cleanup();
} }
/**
* Get the storage type
*/
getType(): 'file' {
return 'file';
}
/** /**
* Get statistics about the storage * Get statistics about the storage
*/ */

View File

@@ -82,8 +82,8 @@ export class StorageFactory {
apiAccessToken: credentials.token, apiAccessToken: credentials.token,
apiEndpoint: apiEndpoint:
config.storage?.apiEndpoint || config.storage?.apiEndpoint ||
process.env.TM_PUBLIC_BASE_DOMAIN || process.env.TM_BASE_DOMAIN ||
'https://tryhamster.com/api' process.env.TM_PUBLIC_BASE_DOMAIN
}; };
config.storage = nextStorage; config.storage = nextStorage;
} }
@@ -112,6 +112,7 @@ export class StorageFactory {
apiAccessToken: credentials.token, apiAccessToken: credentials.token,
apiEndpoint: apiEndpoint:
config.storage?.apiEndpoint || config.storage?.apiEndpoint ||
process.env.TM_BASE_DOMAIN ||
process.env.TM_PUBLIC_BASE_DOMAIN || process.env.TM_PUBLIC_BASE_DOMAIN ||
'https://tryhamster.com/api' 'https://tryhamster.com/api'
}; };

View File

@@ -201,6 +201,44 @@ export class TaskMasterCore {
return this.taskService.getStorageType(); 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 * Get current active tag
*/ */

View File

@@ -9,6 +9,8 @@
*/ */
import dotenv from 'dotenv'; import dotenv from 'dotenv';
// Load .env BEFORE any other imports to ensure env vars are available
dotenv.config(); dotenv.config();
// Add at the very beginning of the file // 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)); 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 // Run the CLI with the process arguments
runCLI(process.argv); runCLI(process.argv);

View File

@@ -19,7 +19,8 @@ import {
registerAllCommands, registerAllCommands,
checkForUpdate, checkForUpdate,
performAutoUpdate, performAutoUpdate,
displayUpgradeNotification displayUpgradeNotification,
displayError
} from '@tm/cli'; } from '@tm/cli';
import { import {
@@ -5156,10 +5157,7 @@ async function runCLI(argv = process.argv) {
); );
} else { } else {
// Generic error handling for other errors // Generic error handling for other errors
console.error(chalk.red(`Error: ${error.message}`)); displayError(error);
if (getDebugFlag()) {
console.error(error);
}
} }
process.exit(1); process.exit(1);

View File

@@ -307,6 +307,20 @@ function validateProviderModelCombination(providerName, modelId) {
); );
} }
/**
* Gets the list of supported model IDs for a given provider from supported-models.json
* @param {string} providerName - The name of the provider (e.g., 'claude-code', 'anthropic')
* @returns {string[]} Array of supported model IDs, or empty array if provider not found
*/
export function getSupportedModelsForProvider(providerName) {
if (!MODEL_MAP[providerName]) {
return [];
}
return MODEL_MAP[providerName]
.filter((model) => model.supported !== false)
.map((model) => model.id);
}
/** /**
* Validates Claude Code AI provider custom settings * Validates Claude Code AI provider custom settings
* @param {object} settings The settings to validate * @param {object} settings The settings to validate

View File

@@ -43,6 +43,28 @@
"allowed_roles": ["main", "fallback"], "allowed_roles": ["main", "fallback"],
"max_tokens": 8192, "max_tokens": 8192,
"supported": true "supported": true
},
{
"id": "claude-sonnet-4-5-20250929",
"swe_score": 0.73,
"cost_per_1m_tokens": {
"input": 3.0,
"output": 15.0
},
"allowed_roles": ["main", "fallback"],
"max_tokens": 64000,
"supported": true
},
{
"id": "claude-haiku-4-5-20251001",
"swe_score": 0.45,
"cost_per_1m_tokens": {
"input": 1.0,
"output": 5.0
},
"allowed_roles": ["main", "fallback"],
"max_tokens": 200000,
"supported": true
} }
], ],
"claude-code": [ "claude-code": [
@@ -67,6 +89,17 @@
"allowed_roles": ["main", "fallback", "research"], "allowed_roles": ["main", "fallback", "research"],
"max_tokens": 64000, "max_tokens": 64000,
"supported": true "supported": true
},
{
"id": "haiku",
"swe_score": 0.45,
"cost_per_1m_tokens": {
"input": 0,
"output": 0
},
"allowed_roles": ["main", "fallback", "research"],
"max_tokens": 200000,
"supported": true
} }
], ],
"codex-cli": [ "codex-cli": [

View File

@@ -12,7 +12,10 @@
import { createClaudeCode } from 'ai-sdk-provider-claude-code'; import { createClaudeCode } from 'ai-sdk-provider-claude-code';
import { BaseAIProvider } from './base-provider.js'; import { BaseAIProvider } from './base-provider.js';
import { getClaudeCodeSettingsForCommand } from '../../scripts/modules/config-manager.js'; import {
getClaudeCodeSettingsForCommand,
getSupportedModelsForProvider
} from '../../scripts/modules/config-manager.js';
import { execSync } from 'child_process'; import { execSync } from 'child_process';
import { log } from '../../scripts/modules/utils.js'; import { log } from '../../scripts/modules/utils.js';
@@ -24,14 +27,24 @@ let _claudeCliAvailable = null;
* *
* Features: * Features:
* - No API key required (uses local Claude Code CLI) * - No API key required (uses local Claude Code CLI)
* - Supports 'sonnet' and 'opus' models * - Supported models loaded from supported-models.json
* - Command-specific configuration support * - Command-specific configuration support
*/ */
export class ClaudeCodeProvider extends BaseAIProvider { export class ClaudeCodeProvider extends BaseAIProvider {
constructor() { constructor() {
super(); super();
this.name = 'Claude Code'; this.name = 'Claude Code';
this.supportedModels = ['sonnet', 'opus']; // Load supported models from supported-models.json
this.supportedModels = getSupportedModelsForProvider('claude-code');
// Validate that models were loaded successfully
if (this.supportedModels.length === 0) {
log(
'warn',
'No supported models found for claude-code provider. Check supported-models.json configuration.'
);
}
// Claude Code requires explicit JSON schema mode // Claude Code requires explicit JSON schema mode
this.needsExplicitJsonSchema = true; this.needsExplicitJsonSchema = true;
// Claude Code does not support temperature parameter // Claude Code does not support temperature parameter

View File

@@ -10,7 +10,10 @@ import { createCodexCli } from 'ai-sdk-provider-codex-cli';
import { BaseAIProvider } from './base-provider.js'; import { BaseAIProvider } from './base-provider.js';
import { execSync } from 'child_process'; import { execSync } from 'child_process';
import { log } from '../../scripts/modules/utils.js'; import { log } from '../../scripts/modules/utils.js';
import { getCodexCliSettingsForCommand } from '../../scripts/modules/config-manager.js'; import {
getCodexCliSettingsForCommand,
getSupportedModelsForProvider
} from '../../scripts/modules/config-manager.js';
export class CodexCliProvider extends BaseAIProvider { export class CodexCliProvider extends BaseAIProvider {
constructor() { constructor() {
@@ -20,8 +23,17 @@ export class CodexCliProvider extends BaseAIProvider {
this.needsExplicitJsonSchema = false; this.needsExplicitJsonSchema = false;
// Codex CLI does not support temperature parameter // Codex CLI does not support temperature parameter
this.supportsTemperature = false; this.supportsTemperature = false;
// Restrict to supported models for OAuth subscription usage // Load supported models from supported-models.json
this.supportedModels = ['gpt-5', 'gpt-5-codex']; this.supportedModels = getSupportedModelsForProvider('codex-cli');
// Validate that models were loaded successfully
if (this.supportedModels.length === 0) {
log(
'warn',
'No supported models found for codex-cli provider. Check supported-models.json configuration.'
);
}
// CLI availability check cache // CLI availability check cache
this._codexCliChecked = false; this._codexCliChecked = false;
this._codexCliAvailable = null; this._codexCliAvailable = null;

View File

@@ -43,9 +43,9 @@ describe('Claude Code Error Handling', () => {
// These should work even if CLI is not available // These should work even if CLI is not available
expect(provider.name).toBe('Claude Code'); expect(provider.name).toBe('Claude Code');
expect(provider.getSupportedModels()).toEqual(['sonnet', 'opus']); expect(provider.getSupportedModels()).toEqual(['opus', 'sonnet', 'haiku']);
expect(provider.isModelSupported('sonnet')).toBe(true); expect(provider.isModelSupported('sonnet')).toBe(true);
expect(provider.isModelSupported('haiku')).toBe(false); expect(provider.isModelSupported('haiku')).toBe(true);
expect(provider.isRequiredApiKey()).toBe(false); expect(provider.isRequiredApiKey()).toBe(false);
expect(() => provider.validateAuth()).not.toThrow(); expect(() => provider.validateAuth()).not.toThrow();
}); });

View File

@@ -40,14 +40,14 @@ describe('Claude Code Integration (Optional)', () => {
it('should create a working provider instance', () => { it('should create a working provider instance', () => {
const provider = new ClaudeCodeProvider(); const provider = new ClaudeCodeProvider();
expect(provider.name).toBe('Claude Code'); expect(provider.name).toBe('Claude Code');
expect(provider.getSupportedModels()).toEqual(['sonnet', 'opus']); expect(provider.getSupportedModels()).toEqual(['opus', 'sonnet', 'haiku']);
}); });
it('should support model validation', () => { it('should support model validation', () => {
const provider = new ClaudeCodeProvider(); const provider = new ClaudeCodeProvider();
expect(provider.isModelSupported('sonnet')).toBe(true); expect(provider.isModelSupported('sonnet')).toBe(true);
expect(provider.isModelSupported('opus')).toBe(true); expect(provider.isModelSupported('opus')).toBe(true);
expect(provider.isModelSupported('haiku')).toBe(false); expect(provider.isModelSupported('haiku')).toBe(true);
expect(provider.isModelSupported('unknown')).toBe(false); expect(provider.isModelSupported('unknown')).toBe(false);
}); });

View File

@@ -28,6 +28,14 @@ jest.unstable_mockModule('../../../src/ai-providers/base-provider.js', () => ({
} }
})); }));
// Mock config getters
jest.unstable_mockModule('../../../scripts/modules/config-manager.js', () => ({
getClaudeCodeSettingsForCommand: jest.fn(() => ({})),
getSupportedModelsForProvider: jest.fn(() => ['opus', 'sonnet', 'haiku']),
getDebugFlag: jest.fn(() => false),
getLogLevel: jest.fn(() => 'info')
}));
// Import after mocking // Import after mocking
const { ClaudeCodeProvider } = await import( const { ClaudeCodeProvider } = await import(
'../../../src/ai-providers/claude-code.js' '../../../src/ai-providers/claude-code.js'
@@ -96,13 +104,13 @@ describe('ClaudeCodeProvider', () => {
describe('model support', () => { describe('model support', () => {
it('should return supported models', () => { it('should return supported models', () => {
const models = provider.getSupportedModels(); const models = provider.getSupportedModels();
expect(models).toEqual(['sonnet', 'opus']); expect(models).toEqual(['opus', 'sonnet', 'haiku']);
}); });
it('should check if model is supported', () => { it('should check if model is supported', () => {
expect(provider.isModelSupported('sonnet')).toBe(true); expect(provider.isModelSupported('sonnet')).toBe(true);
expect(provider.isModelSupported('opus')).toBe(true); expect(provider.isModelSupported('opus')).toBe(true);
expect(provider.isModelSupported('haiku')).toBe(false); expect(provider.isModelSupported('haiku')).toBe(true);
expect(provider.isModelSupported('unknown')).toBe(false); expect(provider.isModelSupported('unknown')).toBe(false);
}); });
}); });

View File

@@ -20,6 +20,7 @@ jest.unstable_mockModule('ai-sdk-provider-codex-cli', () => ({
// Mock config getters // Mock config getters
jest.unstable_mockModule('../../../scripts/modules/config-manager.js', () => ({ jest.unstable_mockModule('../../../scripts/modules/config-manager.js', () => ({
getCodexCliSettingsForCommand: jest.fn(() => ({ allowNpx: true })), getCodexCliSettingsForCommand: jest.fn(() => ({ allowNpx: true })),
getSupportedModelsForProvider: jest.fn(() => ['gpt-5', 'gpt-5-codex']),
// Provide commonly imported getters to satisfy other module imports if any // Provide commonly imported getters to satisfy other module imports if any
getDebugFlag: jest.fn(() => false), getDebugFlag: jest.fn(() => false),
getLogLevel: jest.fn(() => 'info') getLogLevel: jest.fn(() => 'info')