fix: add --json back to tm list and tm show (#1408)

This commit is contained in:
Ralph Khreish
2025-11-14 19:52:53 +01:00
committed by GitHub
parent 0003b6fca6
commit 10ec025581
5 changed files with 400 additions and 2 deletions

View File

@@ -0,0 +1,5 @@
---
"task-master-ai": patch
---
Add --json back to `task-master list` and `task-master show` for when using the commands with ai agents (less context)

View File

@@ -41,6 +41,7 @@ export interface ListCommandOptions {
tag?: string;
withSubtasks?: boolean;
format?: OutputFormat;
json?: boolean;
silent?: boolean;
project?: string;
}
@@ -78,6 +79,7 @@ export class ListTasksCommand extends Command {
'Output format (text, json, compact)',
'text'
)
.option('--json', 'Output in JSON format (shorthand for --format json)')
.option('--silent', 'Suppress output (useful for programmatic usage)')
.option(
'-p, --project <path>',
@@ -194,7 +196,10 @@ export class ListTasksCommand extends Command {
result: ListTasksResult,
options: ListCommandOptions
): void {
const format = (options.format || 'text') as OutputFormat | 'text';
// If --json flag is set, override format to 'json'
const format = (
options.json ? 'json' : options.format || 'text'
) as OutputFormat;
switch (format) {
case 'json':

View File

@@ -21,6 +21,7 @@ export interface ShowCommandOptions {
id?: string;
status?: string;
format?: 'text' | 'json';
json?: boolean;
silent?: boolean;
project?: string;
}
@@ -64,6 +65,7 @@ export class ShowCommand extends Command {
)
.option('-s, --status <status>', 'Filter subtasks by status')
.option('-f, --format <format>', 'Output format (text, json)', 'text')
.option('--json', 'Output in JSON format (shorthand for --format json)')
.option('--silent', 'Suppress output (useful for programmatic usage)')
.option(
'-p, --project <path>',
@@ -212,7 +214,8 @@ export class ShowCommand extends Command {
result: ShowTaskResult | ShowMultipleTasksResult,
options: ShowCommandOptions
): void {
const format = options.format || 'text';
// If --json flag is set, override format to 'json'
const format = options.json ? 'json' : options.format || 'text';
switch (format) {
case 'json':

View File

@@ -0,0 +1,195 @@
/**
* @fileoverview Unit tests for ListTasksCommand
*/
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import type { TmCore } from '@tm/core';
// Mock dependencies
vi.mock('@tm/core', () => ({
createTmCore: vi.fn(),
OUTPUT_FORMATS: ['text', 'json', 'compact'],
TASK_STATUSES: [
'pending',
'in-progress',
'done',
'review',
'deferred',
'cancelled'
],
STATUS_ICONS: {
pending: '⏳',
'in-progress': '🔄',
done: '✅',
review: '👀',
deferred: '⏸️',
cancelled: '❌'
}
}));
vi.mock('../../../src/utils/project-root.js', () => ({
getProjectRoot: vi.fn((path?: string) => path || '/test/project')
}));
vi.mock('../../../src/utils/error-handler.js', () => ({
displayError: vi.fn()
}));
vi.mock('../../../src/utils/display-helpers.js', () => ({
displayCommandHeader: vi.fn()
}));
vi.mock('../../../src/ui/index.js', () => ({
calculateDependencyStatistics: vi.fn(() => ({ total: 0, blocked: 0 })),
calculateSubtaskStatistics: vi.fn(() => ({ total: 0, completed: 0 })),
calculateTaskStatistics: vi.fn(() => ({ total: 0, completed: 0 })),
displayDashboards: vi.fn(),
displayRecommendedNextTask: vi.fn(),
displaySuggestedNextSteps: vi.fn(),
getPriorityBreakdown: vi.fn(() => ({})),
getTaskDescription: vi.fn(() => 'Test description')
}));
vi.mock('../../../src/utils/ui.js', () => ({
createTaskTable: vi.fn(() => 'Table output'),
displayWarning: vi.fn()
}));
import { ListTasksCommand } from '../../../src/commands/list.command.js';
describe('ListTasksCommand', () => {
let consoleLogSpy: any;
let mockTmCore: Partial<TmCore>;
beforeEach(() => {
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
mockTmCore = {
tasks: {
list: vi.fn().mockResolvedValue({
tasks: [{ id: '1', title: 'Test Task', status: 'pending' }],
total: 1,
filtered: 1,
storageType: 'json'
}),
getStorageType: vi.fn().mockReturnValue('json')
} as any,
config: {
getActiveTag: vi.fn().mockReturnValue('master')
} as any
};
});
afterEach(() => {
vi.clearAllMocks();
consoleLogSpy.mockRestore();
});
describe('JSON output format', () => {
it('should use JSON format when --json flag is set', async () => {
const command = new ListTasksCommand();
// Mock the tmCore initialization
(command as any).tmCore = mockTmCore;
// Execute with --json flag
await (command as any).executeCommand({
json: true,
format: 'text' // Should be overridden by --json
});
// Verify JSON output was called
expect(consoleLogSpy).toHaveBeenCalled();
const output = consoleLogSpy.mock.calls[0][0];
// Should be valid JSON
expect(() => JSON.parse(output)).not.toThrow();
const parsed = JSON.parse(output);
expect(parsed).toHaveProperty('tasks');
expect(parsed).toHaveProperty('metadata');
});
it('should override --format when --json is set', async () => {
const command = new ListTasksCommand();
(command as any).tmCore = mockTmCore;
await (command as any).executeCommand({
json: true,
format: 'compact' // Should be overridden
});
// Should output JSON, not compact format
const output = consoleLogSpy.mock.calls[0][0];
expect(() => JSON.parse(output)).not.toThrow();
});
it('should use specified format when --json is not set', async () => {
const command = new ListTasksCommand();
(command as any).tmCore = mockTmCore;
await (command as any).executeCommand({
format: 'compact'
});
// Should use compact format (not JSON)
const output = consoleLogSpy.mock.calls;
// In compact mode, output is not JSON
expect(output.length).toBeGreaterThan(0);
});
it('should default to text format when neither flag is set', async () => {
const command = new ListTasksCommand();
(command as any).tmCore = mockTmCore;
await (command as any).executeCommand({});
// Should use text format (not JSON)
// If any console.log was called, verify it's not JSON
if (consoleLogSpy.mock.calls.length > 0) {
const output = consoleLogSpy.mock.calls[0][0];
// Text format output should not be parseable JSON
// or should be the table string we mocked
expect(
output === 'Table output' ||
(() => {
try {
JSON.parse(output);
return false;
} catch {
return true;
}
})()
).toBe(true);
}
});
});
describe('format validation', () => {
it('should accept valid formats', () => {
const command = new ListTasksCommand();
expect((command as any).validateOptions({ format: 'text' })).toBe(true);
expect((command as any).validateOptions({ format: 'json' })).toBe(true);
expect((command as any).validateOptions({ format: 'compact' })).toBe(
true
);
});
it('should reject invalid formats', () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const command = new ListTasksCommand();
expect((command as any).validateOptions({ format: 'invalid' })).toBe(
false
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Invalid format: invalid')
);
consoleErrorSpy.mockRestore();
});
});
});

View File

@@ -0,0 +1,190 @@
/**
* @fileoverview Unit tests for ShowCommand
*/
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import type { TmCore } from '@tm/core';
// Mock dependencies
vi.mock('@tm/core', () => ({
createTmCore: vi.fn()
}));
vi.mock('../../../src/utils/project-root.js', () => ({
getProjectRoot: vi.fn((path?: string) => path || '/test/project')
}));
vi.mock('../../../src/utils/error-handler.js', () => ({
displayError: vi.fn()
}));
vi.mock('../../../src/utils/display-helpers.js', () => ({
displayCommandHeader: vi.fn()
}));
vi.mock('../../../src/ui/components/task-detail.component.js', () => ({
displayTaskDetails: vi.fn()
}));
vi.mock('../../../src/utils/ui.js', () => ({
createTaskTable: vi.fn(() => 'Table output'),
displayWarning: vi.fn()
}));
import { ShowCommand } from '../../../src/commands/show.command.js';
describe('ShowCommand', () => {
let consoleLogSpy: any;
let mockTmCore: Partial<TmCore>;
beforeEach(() => {
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
mockTmCore = {
tasks: {
get: vi.fn().mockResolvedValue({
task: {
id: '1',
title: 'Test Task',
status: 'pending',
description: 'Test description'
},
isSubtask: false
}),
getStorageType: vi.fn().mockReturnValue('json')
} as any,
config: {
getActiveTag: vi.fn().mockReturnValue('master')
} as any
};
});
afterEach(() => {
vi.clearAllMocks();
consoleLogSpy.mockRestore();
});
describe('JSON output format', () => {
it('should use JSON format when --json flag is set', async () => {
const command = new ShowCommand();
// Mock the tmCore initialization
(command as any).tmCore = mockTmCore;
// Execute with --json flag
await (command as any).executeCommand('1', {
id: '1',
json: true,
format: 'text' // Should be overridden by --json
});
// Verify JSON output was called
expect(consoleLogSpy).toHaveBeenCalled();
const output = consoleLogSpy.mock.calls[0][0];
// Should be valid JSON
expect(() => JSON.parse(output)).not.toThrow();
const parsed = JSON.parse(output);
expect(parsed).toHaveProperty('task');
expect(parsed).toHaveProperty('found');
expect(parsed).toHaveProperty('storageType');
});
it('should override --format when --json is set', async () => {
const command = new ShowCommand();
(command as any).tmCore = mockTmCore;
await (command as any).executeCommand('1', {
id: '1',
json: true,
format: 'text' // Should be overridden
});
// Should output JSON, not text format
const output = consoleLogSpy.mock.calls[0][0];
expect(() => JSON.parse(output)).not.toThrow();
});
it('should use text format when --json is not set', async () => {
const command = new ShowCommand();
(command as any).tmCore = mockTmCore;
await (command as any).executeCommand('1', {
id: '1',
format: 'text'
});
// Should use text format (not JSON)
// Text format will call displayCommandHeader and displayTaskDetails
// We just verify it was called (mocked functions)
expect(consoleLogSpy).toHaveBeenCalled();
});
it('should default to text format when neither flag is set', async () => {
const command = new ShowCommand();
(command as any).tmCore = mockTmCore;
await (command as any).executeCommand('1', {
id: '1'
});
// Should use text format by default
expect(consoleLogSpy).toHaveBeenCalled();
});
});
describe('format validation', () => {
it('should accept valid formats', () => {
const command = new ShowCommand();
expect((command as any).validateOptions({ format: 'text' })).toBe(true);
expect((command as any).validateOptions({ format: 'json' })).toBe(true);
});
it('should reject invalid formats', () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const command = new ShowCommand();
expect((command as any).validateOptions({ format: 'invalid' })).toBe(
false
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Invalid format: invalid')
);
consoleErrorSpy.mockRestore();
});
});
describe('multiple task IDs', () => {
it('should handle comma-separated task IDs', async () => {
const command = new ShowCommand();
(command as any).tmCore = mockTmCore;
// Mock getMultipleTasks
const getMultipleTasksSpy = vi
.spyOn(command as any, 'getMultipleTasks')
.mockResolvedValue({
tasks: [
{ id: '1', title: 'Task 1' },
{ id: '2', title: 'Task 2' }
],
notFound: [],
storageType: 'json'
});
await (command as any).executeCommand('1,2', {
id: '1,2',
json: true
});
expect(getMultipleTasksSpy).toHaveBeenCalledWith(
['1', '2'],
expect.any(Object)
);
});
});
});