@task-mcp/cli 1.0.15 → 1.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,352 @@
1
+ import { describe, expect, test, mock, beforeEach, afterEach, spyOn } from "bun:test";
2
+ import type { Task, Project } from "../storage.js";
3
+
4
+ // Helper to create test tasks
5
+ function createTask(overrides: Partial<Task> = {}): Task {
6
+ return {
7
+ id: `task_${Math.random().toString(36).slice(2, 10)}`,
8
+ title: "Test Task",
9
+ status: "pending",
10
+ priority: "medium",
11
+ projectId: "proj_test",
12
+ createdAt: new Date().toISOString(),
13
+ updatedAt: new Date().toISOString(),
14
+ ...overrides,
15
+ };
16
+ }
17
+
18
+ // Helper to create test projects
19
+ function createProject(overrides: Partial<Project> = {}): Project {
20
+ return {
21
+ id: `proj_${Math.random().toString(36).slice(2, 10)}`,
22
+ name: "Test Project",
23
+ status: "active",
24
+ createdAt: new Date().toISOString(),
25
+ updatedAt: new Date().toISOString(),
26
+ ...overrides,
27
+ };
28
+ }
29
+
30
+ describe("list commands - unit tests", () => {
31
+ describe("task filtering logic", () => {
32
+ test("filters tasks by single status", () => {
33
+ const tasks = [
34
+ createTask({ id: "task_1", status: "pending" }),
35
+ createTask({ id: "task_2", status: "completed" }),
36
+ createTask({ id: "task_3", status: "in_progress" }),
37
+ ];
38
+
39
+ const status = "pending";
40
+ const statuses = status.split(",");
41
+ const filtered = tasks.filter((t) => statuses.includes(t.status));
42
+
43
+ expect(filtered.length).toBe(1);
44
+ expect(filtered[0]?.id).toBe("task_1");
45
+ });
46
+
47
+ test("filters tasks by multiple statuses", () => {
48
+ const tasks = [
49
+ createTask({ id: "task_1", status: "pending" }),
50
+ createTask({ id: "task_2", status: "completed" }),
51
+ createTask({ id: "task_3", status: "in_progress" }),
52
+ createTask({ id: "task_4", status: "blocked" }),
53
+ ];
54
+
55
+ const status = "pending,in_progress";
56
+ const statuses = status.split(",");
57
+ const filtered = tasks.filter((t) => statuses.includes(t.status));
58
+
59
+ expect(filtered.length).toBe(2);
60
+ expect(filtered.map((t) => t.id).sort()).toEqual(["task_1", "task_3"]);
61
+ });
62
+
63
+ test("filters tasks by priority", () => {
64
+ const tasks = [
65
+ createTask({ id: "task_1", priority: "high" }),
66
+ createTask({ id: "task_2", priority: "low" }),
67
+ createTask({ id: "task_3", priority: "high" }),
68
+ createTask({ id: "task_4", priority: "medium" }),
69
+ ];
70
+
71
+ const priority = "high";
72
+ const priorities = priority.split(",");
73
+ const filtered = tasks.filter((t) => priorities.includes(t.priority));
74
+
75
+ expect(filtered.length).toBe(2);
76
+ expect(filtered.map((t) => t.id).sort()).toEqual(["task_1", "task_3"]);
77
+ });
78
+
79
+ test("filters by multiple priorities", () => {
80
+ const tasks = [
81
+ createTask({ id: "task_1", priority: "high" }),
82
+ createTask({ id: "task_2", priority: "low" }),
83
+ createTask({ id: "task_3", priority: "critical" }),
84
+ createTask({ id: "task_4", priority: "medium" }),
85
+ ];
86
+
87
+ const priority = "high,critical";
88
+ const priorities = priority.split(",");
89
+ const filtered = tasks.filter((t) => priorities.includes(t.priority));
90
+
91
+ expect(filtered.length).toBe(2);
92
+ expect(filtered.map((t) => t.id).sort()).toEqual(["task_1", "task_3"]);
93
+ });
94
+
95
+ test("default filter hides completed and cancelled tasks", () => {
96
+ const tasks = [
97
+ createTask({ id: "task_1", status: "pending" }),
98
+ createTask({ id: "task_2", status: "completed" }),
99
+ createTask({ id: "task_3", status: "cancelled" }),
100
+ createTask({ id: "task_4", status: "in_progress" }),
101
+ ];
102
+
103
+ // Default behavior: hide completed/cancelled
104
+ const filtered = tasks.filter(
105
+ (t) => t.status !== "completed" && t.status !== "cancelled"
106
+ );
107
+
108
+ expect(filtered.length).toBe(2);
109
+ expect(filtered.map((t) => t.id).sort()).toEqual(["task_1", "task_4"]);
110
+ });
111
+
112
+ test("all flag includes completed and cancelled tasks", () => {
113
+ const tasks = [
114
+ createTask({ id: "task_1", status: "pending" }),
115
+ createTask({ id: "task_2", status: "completed" }),
116
+ createTask({ id: "task_3", status: "cancelled" }),
117
+ ];
118
+
119
+ const all = true;
120
+ // With --all, no filtering
121
+ const filtered = all ? tasks : tasks.filter(
122
+ (t) => t.status !== "completed" && t.status !== "cancelled"
123
+ );
124
+
125
+ expect(filtered.length).toBe(3);
126
+ });
127
+
128
+ test("combined status and priority filtering", () => {
129
+ const tasks = [
130
+ createTask({ id: "task_1", status: "pending", priority: "high" }),
131
+ createTask({ id: "task_2", status: "pending", priority: "low" }),
132
+ createTask({ id: "task_3", status: "completed", priority: "high" }),
133
+ createTask({ id: "task_4", status: "in_progress", priority: "high" }),
134
+ ];
135
+
136
+ const status = "pending,in_progress";
137
+ const priority = "high";
138
+ const statuses = status.split(",");
139
+ const priorities = priority.split(",");
140
+
141
+ let filtered = tasks.filter((t) => statuses.includes(t.status));
142
+ filtered = filtered.filter((t) => priorities.includes(t.priority));
143
+
144
+ expect(filtered.length).toBe(2);
145
+ expect(filtered.map((t) => t.id).sort()).toEqual(["task_1", "task_4"]);
146
+ });
147
+ });
148
+
149
+ describe("project progress calculation", () => {
150
+ test("calculates progress for empty project", () => {
151
+ const tasks: Task[] = [];
152
+ const completed = tasks.filter((t) => t.status === "completed").length;
153
+ const total = tasks.filter((t) => t.status !== "cancelled").length;
154
+ const progress = total > 0 ? `${completed}/${total}` : "0/0";
155
+
156
+ expect(progress).toBe("0/0");
157
+ });
158
+
159
+ test("calculates progress excluding cancelled tasks", () => {
160
+ const tasks = [
161
+ createTask({ status: "completed" }),
162
+ createTask({ status: "completed" }),
163
+ createTask({ status: "pending" }),
164
+ createTask({ status: "cancelled" }),
165
+ createTask({ status: "cancelled" }),
166
+ ];
167
+
168
+ const completed = tasks.filter((t) => t.status === "completed").length;
169
+ const total = tasks.filter((t) => t.status !== "cancelled").length;
170
+ const progress = `${completed}/${total}`;
171
+
172
+ expect(progress).toBe("2/3"); // 2 completed out of 3 non-cancelled
173
+ });
174
+
175
+ test("calculates 100% completion", () => {
176
+ const tasks = [
177
+ createTask({ status: "completed" }),
178
+ createTask({ status: "completed" }),
179
+ createTask({ status: "completed" }),
180
+ ];
181
+
182
+ const completed = tasks.filter((t) => t.status === "completed").length;
183
+ const total = tasks.filter((t) => t.status !== "cancelled").length;
184
+ const progress = `${completed}/${total}`;
185
+
186
+ expect(progress).toBe("3/3");
187
+ });
188
+
189
+ test("handles all cancelled tasks", () => {
190
+ const tasks = [
191
+ createTask({ status: "cancelled" }),
192
+ createTask({ status: "cancelled" }),
193
+ ];
194
+
195
+ const completed = tasks.filter((t) => t.status === "completed").length;
196
+ const total = tasks.filter((t) => t.status !== "cancelled").length;
197
+ const progress = total > 0 ? `${completed}/${total}` : "0/0";
198
+
199
+ expect(progress).toBe("0/0");
200
+ });
201
+ });
202
+
203
+ describe("formatProjectStatus", () => {
204
+ test("maps project statuses correctly", () => {
205
+ type ProjectStatus = "active" | "on_hold" | "completed" | "archived";
206
+
207
+ const formatProjectStatus = (status: ProjectStatus): string => {
208
+ const statusMap: Record<ProjectStatus, string> = {
209
+ active: "active",
210
+ on_hold: "on_hold",
211
+ completed: "completed",
212
+ archived: "archived",
213
+ };
214
+ return statusMap[status] ?? status;
215
+ };
216
+
217
+ expect(formatProjectStatus("active")).toBe("active");
218
+ expect(formatProjectStatus("on_hold")).toBe("on_hold");
219
+ expect(formatProjectStatus("completed")).toBe("completed");
220
+ expect(formatProjectStatus("archived")).toBe("archived");
221
+ });
222
+ });
223
+
224
+ describe("formatDate", () => {
225
+ test("formats valid date string", () => {
226
+ const formatDate = (dateStr?: string): string => {
227
+ if (!dateStr) return "-";
228
+ const date = new Date(dateStr);
229
+ return date.toLocaleDateString();
230
+ };
231
+
232
+ const result = formatDate("2024-12-25");
233
+ // Result depends on locale, but should be non-empty
234
+ expect(result).not.toBe("-");
235
+ expect(result.length).toBeGreaterThan(0);
236
+ });
237
+
238
+ test("returns dash for undefined date", () => {
239
+ const formatDate = (dateStr?: string): string => {
240
+ if (!dateStr) return "-";
241
+ const date = new Date(dateStr);
242
+ return date.toLocaleDateString();
243
+ };
244
+
245
+ expect(formatDate(undefined)).toBe("-");
246
+ });
247
+
248
+ test("returns dash for empty date string", () => {
249
+ const formatDate = (dateStr?: string): string => {
250
+ if (!dateStr) return "-";
251
+ const date = new Date(dateStr);
252
+ return date.toLocaleDateString();
253
+ };
254
+
255
+ expect(formatDate("")).toBe("-");
256
+ });
257
+ });
258
+
259
+ describe("table column definitions", () => {
260
+ test("task table has required columns", () => {
261
+ const columns = [
262
+ { header: "ID", key: "id", width: 6 },
263
+ { header: "Title", key: "title", width: 45 },
264
+ { header: "Status", key: "status", width: 14 },
265
+ { header: "Priority", key: "priority", width: 10 },
266
+ { header: "Due", key: "dueDate", width: 12 },
267
+ ];
268
+
269
+ expect(columns.find((c) => c.key === "id")).toBeDefined();
270
+ expect(columns.find((c) => c.key === "title")).toBeDefined();
271
+ expect(columns.find((c) => c.key === "status")).toBeDefined();
272
+ expect(columns.find((c) => c.key === "priority")).toBeDefined();
273
+ expect(columns.find((c) => c.key === "dueDate")).toBeDefined();
274
+ });
275
+
276
+ test("project table has required columns", () => {
277
+ const columns = [
278
+ { header: "ID", key: "id", width: 6 },
279
+ { header: "Name", key: "name", width: 30 },
280
+ { header: "Status", key: "status", width: 14 },
281
+ { header: "Tasks", key: "progress", width: 10 },
282
+ { header: "Updated", key: "updatedAt", width: 12 },
283
+ ];
284
+
285
+ expect(columns.find((c) => c.key === "id")).toBeDefined();
286
+ expect(columns.find((c) => c.key === "name")).toBeDefined();
287
+ expect(columns.find((c) => c.key === "status")).toBeDefined();
288
+ expect(columns.find((c) => c.key === "progress")).toBeDefined();
289
+ expect(columns.find((c) => c.key === "updatedAt")).toBeDefined();
290
+ });
291
+ });
292
+
293
+ describe("ID truncation", () => {
294
+ test("truncates task ID to 4 characters", () => {
295
+ const id = "task_abc12345";
296
+ const truncated = id.slice(0, 4);
297
+ expect(truncated).toBe("task");
298
+ });
299
+
300
+ test("truncates project ID to 4 characters", () => {
301
+ const id = "proj_xyz98765";
302
+ const truncated = id.slice(0, 4);
303
+ expect(truncated).toBe("proj");
304
+ });
305
+
306
+ test("handles short IDs", () => {
307
+ const id = "ab";
308
+ const truncated = id.slice(0, 4);
309
+ expect(truncated).toBe("ab");
310
+ });
311
+ });
312
+ });
313
+
314
+ describe("list commands - exports", () => {
315
+ test("listTasksCmd is exported", async () => {
316
+ const { listTasksCmd } = await import("../commands/list.js");
317
+ expect(typeof listTasksCmd).toBe("function");
318
+ });
319
+
320
+ test("listProjectsCmd is exported", async () => {
321
+ const { listProjectsCmd } = await import("../commands/list.js");
322
+ expect(typeof listProjectsCmd).toBe("function");
323
+ });
324
+
325
+ test("listTasksCmd accepts options object", async () => {
326
+ const { listTasksCmd } = await import("../commands/list.js");
327
+
328
+ // Verify the function signature accepts the expected options
329
+ const options = {
330
+ projectId: "proj_123",
331
+ status: "pending",
332
+ priority: "high",
333
+ all: true,
334
+ };
335
+
336
+ // Type check passes if this compiles
337
+ expect(options.projectId).toBeDefined();
338
+ expect(options.status).toBeDefined();
339
+ expect(options.priority).toBeDefined();
340
+ expect(options.all).toBeDefined();
341
+ });
342
+
343
+ test("listProjectsCmd accepts options object", async () => {
344
+ const { listProjectsCmd } = await import("../commands/list.js");
345
+
346
+ const options = {
347
+ all: true,
348
+ };
349
+
350
+ expect(options.all).toBeDefined();
351
+ });
352
+ });
@@ -16,6 +16,7 @@ import {
16
16
  listTasks,
17
17
  listAllTasks,
18
18
  listInboxItems,
19
+ getActiveTag,
19
20
  type Task,
20
21
  type Project,
21
22
  } from "../storage.js";
@@ -25,8 +26,9 @@ import {
25
26
  // =============================================================================
26
27
 
27
28
  export async function dashboard(projectId?: string): Promise<void> {
28
- // Get projects and tasks
29
+ // Get projects, tasks, and active tag
29
30
  const projects = await listProjects();
31
+ const activeTag = await getActiveTag();
30
32
 
31
33
  if (projects.length === 0) {
32
34
  console.log(c.yellow("No projects found. Create a project first using the MCP server."));
@@ -55,10 +57,13 @@ export async function dashboard(projectId?: string): Promise<void> {
55
57
  // Get inbox items
56
58
  const inboxItems = await listInboxItems("pending");
57
59
 
58
- // Create task lookup for projects table
60
+ // Create task lookup for projects table (batch query to avoid N+1)
61
+ const allTasksForLookup = await listAllTasks();
59
62
  const tasksByProject = new Map<string, Task[]>();
60
- for (const p of projects) {
61
- tasksByProject.set(p.id, await listTasks(p.id));
63
+ for (const task of allTasksForLookup) {
64
+ const projectTasks = tasksByProject.get(task.projectId) ?? [];
65
+ projectTasks.push(task);
66
+ tasksByProject.set(task.projectId, projectTasks);
62
67
  }
63
68
  const getProjectTasks = (pid: string) => tasksByProject.get(pid) ?? [];
64
69
 
@@ -69,7 +74,7 @@ export async function dashboard(projectId?: string): Promise<void> {
69
74
  output = renderProjectDashboard(
70
75
  project as SharedProject,
71
76
  tasks as SharedTask[],
72
- { version: VERSION }
77
+ { version: VERSION, activeTag }
73
78
  );
74
79
  } else {
75
80
  output = renderGlobalDashboard(
@@ -77,7 +82,7 @@ export async function dashboard(projectId?: string): Promise<void> {
77
82
  tasks as SharedTask[],
78
83
  inboxItems,
79
84
  (pid) => (getProjectTasks(pid) as SharedTask[]),
80
- { version: VERSION }
85
+ { version: VERSION, activeTag }
81
86
  );
82
87
  }
83
88
 
@@ -3,9 +3,15 @@
3
3
  * Quick capture and management of ideas
4
4
  */
5
5
 
6
- import { c } from "../ansi.js";
6
+ import { c, formatStatus, formatPriority } from "../ansi.js";
7
7
  import { InboxStore, TaskStore, ProjectStore } from "@task-mcp/mcp-server/storage";
8
8
  import { parseInboxInput } from "@task-mcp/shared";
9
+ import {
10
+ LIST_SEPARATOR_WIDTH,
11
+ MENU_SEPARATOR_WIDTH,
12
+ INBOX_PROMOTE_TITLE_MAX_LENGTH,
13
+ INBOX_DISCARD_PREVIEW_LENGTH,
14
+ } from "../constants.js";
9
15
 
10
16
  const inboxStore = new InboxStore();
11
17
  const taskStore = new TaskStore();
@@ -246,22 +252,3 @@ export async function inboxCountCmd(): Promise<void> {
246
252
  console.log();
247
253
  }
248
254
 
249
- // Helper functions
250
- function formatStatus(status: string): string {
251
- switch (status) {
252
- case "pending": return c.yellow("pending");
253
- case "promoted": return c.green("promoted");
254
- case "discarded": return c.dim("discarded");
255
- default: return status;
256
- }
257
- }
258
-
259
- function formatPriority(priority: string): string {
260
- switch (priority) {
261
- case "critical": return c.red("critical");
262
- case "high": return c.yellow("high");
263
- case "medium": return c.blue("medium");
264
- case "low": return c.dim("low");
265
- default: return priority;
266
- }
267
- }
@@ -2,7 +2,7 @@
2
2
  * List commands - list tasks and projects
3
3
  */
4
4
 
5
- import { c, table, icons, type TableColumn } from "../ansi.js";
5
+ import { c, table, formatStatus, formatPriority, type TableColumn } from "../ansi.js";
6
6
  import {
7
7
  listProjects,
8
8
  listTasks,
@@ -10,21 +10,14 @@ import {
10
10
  type Task,
11
11
  type Project,
12
12
  } from "../storage.js";
13
-
14
- function formatStatus(status: Task["status"]): string {
15
- const icon = icons[status] ?? icons.pending;
16
- return `${icon} ${status}`;
17
- }
18
-
19
- function formatPriority(priority: Task["priority"]): string {
20
- const colors: Record<string, (s: string) => string> = {
21
- critical: c.red,
22
- high: c.yellow,
23
- medium: c.blue,
24
- low: c.gray,
25
- };
26
- return (colors[priority] ?? c.gray)(priority);
27
- }
13
+ import {
14
+ COLUMN_WIDTH_ID,
15
+ COLUMN_WIDTH_TITLE,
16
+ COLUMN_WIDTH_PROJECT_NAME,
17
+ COLUMN_WIDTH_STATUS,
18
+ COLUMN_WIDTH_PRIORITY,
19
+ COLUMN_WIDTH_DATE,
20
+ } from "../constants.js";
28
21
 
29
22
  function formatProjectStatus(status: Project["status"]): string {
30
23
  const statusMap: Record<string, string> = {
@@ -78,11 +71,11 @@ export async function listTasksCmd(options: {
78
71
  console.log(c.bold(`\nTasks (${tasks.length})\n`));
79
72
 
80
73
  const columns: TableColumn[] = [
81
- { header: "ID", key: "id", width: 6, format: (v) => c.cyan(String(v).slice(0, 4)) },
82
- { header: "Title", key: "title", width: 45 },
83
- { header: "Status", key: "status", width: 14, format: (v) => formatStatus(v as Task["status"]) },
84
- { header: "Priority", key: "priority", width: 10, format: (v) => formatPriority(v as Task["priority"]) },
85
- { header: "Due", key: "dueDate", width: 12, format: (v) => formatDate(v as string) },
74
+ { header: "ID", key: "id", width: COLUMN_WIDTH_ID, format: (v) => c.cyan(String(v).slice(0, 4)) },
75
+ { header: "Title", key: "title", width: COLUMN_WIDTH_TITLE },
76
+ { header: "Status", key: "status", width: COLUMN_WIDTH_STATUS, format: (v) => formatStatus(v as Task["status"]) },
77
+ { header: "Priority", key: "priority", width: COLUMN_WIDTH_PRIORITY, format: (v) => formatPriority(v as Task["priority"]) },
78
+ { header: "Due", key: "dueDate", width: COLUMN_WIDTH_DATE, format: (v) => formatDate(v as string) },
86
79
  ];
87
80
 
88
81
  console.log(table(tasks as unknown as Record<string, unknown>[], columns));
@@ -116,11 +109,11 @@ export async function listProjectsCmd(options: {
116
109
  );
117
110
 
118
111
  const columns: TableColumn[] = [
119
- { header: "ID", key: "id", width: 6, format: (v) => c.cyan(String(v).slice(0, 4)) },
120
- { header: "Name", key: "name", width: 30 },
121
- { header: "Status", key: "status", width: 14, format: (v) => formatProjectStatus(v as Project["status"]) },
122
- { header: "Tasks", key: "progress", width: 10 },
123
- { header: "Updated", key: "updatedAt", width: 12, format: (v) => formatDate(v as string) },
112
+ { header: "ID", key: "id", width: COLUMN_WIDTH_ID, format: (v) => c.cyan(String(v).slice(0, 4)) },
113
+ { header: "Name", key: "name", width: COLUMN_WIDTH_PROJECT_NAME },
114
+ { header: "Status", key: "status", width: COLUMN_WIDTH_STATUS, format: (v) => formatProjectStatus(v as Project["status"]) },
115
+ { header: "Tasks", key: "progress", width: COLUMN_WIDTH_PRIORITY },
116
+ { header: "Updated", key: "updatedAt", width: COLUMN_WIDTH_DATE, format: (v) => formatDate(v as string) },
124
117
  ];
125
118
 
126
119
  console.log(table(projectsWithStats, columns));
@@ -0,0 +1,56 @@
1
+ /**
2
+ * CLI Constants
3
+ * Centralized magic numbers for column widths, limits, and UI settings
4
+ */
5
+
6
+ // =============================================================================
7
+ // UI Layout
8
+ // =============================================================================
9
+
10
+ /** Width for horizontal separators in menus */
11
+ export const MENU_SEPARATOR_WIDTH = 40;
12
+
13
+ /** Width for horizontal separators in lists */
14
+ export const LIST_SEPARATOR_WIDTH = 50;
15
+
16
+ // =============================================================================
17
+ // Timing
18
+ // =============================================================================
19
+
20
+ /** Delay in ms after showing error message */
21
+ export const ERROR_MESSAGE_DELAY_MS = 1000;
22
+
23
+ /** Delay in ms for welcome screen display */
24
+ export const WELCOME_SCREEN_DELAY_MS = 1000;
25
+
26
+ // =============================================================================
27
+ // Table Column Widths
28
+ // =============================================================================
29
+
30
+ /** ID column width (shows truncated ID) */
31
+ export const COLUMN_WIDTH_ID = 6;
32
+
33
+ /** Task title column width */
34
+ export const COLUMN_WIDTH_TITLE = 45;
35
+
36
+ /** Project name column width */
37
+ export const COLUMN_WIDTH_PROJECT_NAME = 30;
38
+
39
+ /** Status column width */
40
+ export const COLUMN_WIDTH_STATUS = 14;
41
+
42
+ /** Priority column width */
43
+ export const COLUMN_WIDTH_PRIORITY = 10;
44
+
45
+ /** Date column width */
46
+ export const COLUMN_WIDTH_DATE = 12;
47
+
48
+ // =============================================================================
49
+ // Text Limits
50
+ // =============================================================================
51
+
52
+ /** Maximum length for task title when promoting from inbox */
53
+ export const INBOX_PROMOTE_TITLE_MAX_LENGTH = 100;
54
+
55
+ /** Maximum length for content preview when discarding inbox item */
56
+ export const INBOX_DISCARD_PREVIEW_LENGTH = 50;