@task-mcp/cli 1.0.4
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.
- package/README.md +88 -0
- package/package.json +36 -0
- package/src/ansi.ts +422 -0
- package/src/commands/dashboard.ts +142 -0
- package/src/commands/list.ts +128 -0
- package/src/index.ts +159 -0
- package/src/storage.ts +267 -0
package/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# @task-mcp/cli
|
|
2
|
+
|
|
3
|
+
Zero-dependency CLI for task-mcp with beautiful terminal visualization.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
████████ █████ ███████ ██ ██ ███ ███ ██████ ██████
|
|
7
|
+
██ ██ ██ ██ ██ ██ ████ ████ ██ ██ ██
|
|
8
|
+
██ ███████ ███████ █████ ██ ████ ██ ██ ██████
|
|
9
|
+
██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
|
|
10
|
+
██ ██ ██ ███████ ██ ██ ██ ██ ██████ ██
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Requirements
|
|
14
|
+
|
|
15
|
+
- **Bun** runtime (not Node.js)
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# Install globally
|
|
21
|
+
bun add -g @task-mcp/cli
|
|
22
|
+
|
|
23
|
+
# Or run directly
|
|
24
|
+
bunx @task-mcp/cli
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# Show dashboard (default)
|
|
31
|
+
task
|
|
32
|
+
|
|
33
|
+
# Show dashboard for specific project
|
|
34
|
+
task dashboard proj_abc123
|
|
35
|
+
task d proj_abc123
|
|
36
|
+
|
|
37
|
+
# List all tasks
|
|
38
|
+
task list
|
|
39
|
+
task ls
|
|
40
|
+
|
|
41
|
+
# List tasks with filters
|
|
42
|
+
task list --status pending --priority high
|
|
43
|
+
task list --status pending,in_progress
|
|
44
|
+
task list --all # Include completed/cancelled
|
|
45
|
+
|
|
46
|
+
# List projects
|
|
47
|
+
task projects
|
|
48
|
+
task p
|
|
49
|
+
task projects --all # Include archived
|
|
50
|
+
|
|
51
|
+
# Help & version
|
|
52
|
+
task help
|
|
53
|
+
task version
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Commands
|
|
57
|
+
|
|
58
|
+
| Command | Alias | Description |
|
|
59
|
+
|---------|-------|-------------|
|
|
60
|
+
| `dashboard [id]` | `d` | Show project dashboard with stats and task table |
|
|
61
|
+
| `list` | `ls` | List tasks with optional filters |
|
|
62
|
+
| `projects` | `p` | List all projects |
|
|
63
|
+
| `help` | - | Show help message |
|
|
64
|
+
| `version` | - | Show version |
|
|
65
|
+
|
|
66
|
+
## Options
|
|
67
|
+
|
|
68
|
+
| Option | Description |
|
|
69
|
+
|--------|-------------|
|
|
70
|
+
| `--status <status>` | Filter by status (pending, in_progress, blocked, completed, cancelled) |
|
|
71
|
+
| `--priority <priority>` | Filter by priority (critical, high, medium, low) |
|
|
72
|
+
| `--all` | Include completed/cancelled tasks or archived projects |
|
|
73
|
+
|
|
74
|
+
## Dashboard Features
|
|
75
|
+
|
|
76
|
+
- **Progress Bar**: Visual task completion progress
|
|
77
|
+
- **Priority Breakdown**: Tasks grouped by priority level
|
|
78
|
+
- **Dependency Metrics**: Ready tasks, blocked tasks, most depended-on task
|
|
79
|
+
- **Next Task Suggestion**: AI-powered recommendation for next task
|
|
80
|
+
- **Task Table**: Full task list with status, priority, and dependencies
|
|
81
|
+
|
|
82
|
+
## Data Location
|
|
83
|
+
|
|
84
|
+
The CLI reads data from the `.tasks/` directory created by the task-mcp MCP server.
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@task-mcp/cli",
|
|
3
|
+
"version": "1.0.4",
|
|
4
|
+
"description": "Zero-dependency CLI for task-mcp with Bun native visualization",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"task": "./src/index.ts"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"dev": "bun run ./src/index.ts",
|
|
15
|
+
"dashboard": "bun run ./src/index.ts dashboard",
|
|
16
|
+
"typecheck": "bun --bun tsc --noEmit"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"task-mcp",
|
|
20
|
+
"cli",
|
|
21
|
+
"task-management",
|
|
22
|
+
"terminal",
|
|
23
|
+
"dashboard"
|
|
24
|
+
],
|
|
25
|
+
"author": "addsalt1t",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/addsalt1t/task-mcp.git",
|
|
30
|
+
"directory": "packages/cli"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/bun": "^1.1.14",
|
|
34
|
+
"typescript": "^5.7.2"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/ansi.ts
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zero-dependency ANSI utilities for terminal visualization
|
|
3
|
+
* Uses Bun native APIs where available, falls back to raw ANSI codes
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// ANSI escape codes
|
|
7
|
+
const ESC = "\x1b[";
|
|
8
|
+
const RESET = `${ESC}0m`;
|
|
9
|
+
|
|
10
|
+
// Color codes (foreground)
|
|
11
|
+
const FG = {
|
|
12
|
+
black: 30,
|
|
13
|
+
red: 31,
|
|
14
|
+
green: 32,
|
|
15
|
+
yellow: 33,
|
|
16
|
+
blue: 34,
|
|
17
|
+
magenta: 35,
|
|
18
|
+
cyan: 36,
|
|
19
|
+
white: 37,
|
|
20
|
+
gray: 90,
|
|
21
|
+
brightRed: 91,
|
|
22
|
+
brightGreen: 92,
|
|
23
|
+
brightYellow: 93,
|
|
24
|
+
brightBlue: 94,
|
|
25
|
+
brightMagenta: 95,
|
|
26
|
+
brightCyan: 96,
|
|
27
|
+
brightWhite: 97,
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
// Style codes
|
|
31
|
+
const STYLE = {
|
|
32
|
+
bold: 1,
|
|
33
|
+
dim: 2,
|
|
34
|
+
italic: 3,
|
|
35
|
+
underline: 4,
|
|
36
|
+
inverse: 7,
|
|
37
|
+
strikethrough: 9,
|
|
38
|
+
} as const;
|
|
39
|
+
|
|
40
|
+
type ColorName = keyof typeof FG;
|
|
41
|
+
type StyleName = keyof typeof STYLE;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Apply color to text
|
|
45
|
+
*/
|
|
46
|
+
export function color(text: string, colorName: ColorName): string {
|
|
47
|
+
return `${ESC}${FG[colorName]}m${text}${RESET}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Apply style to text
|
|
52
|
+
*/
|
|
53
|
+
export function style(text: string, styleName: StyleName): string {
|
|
54
|
+
return `${ESC}${STYLE[styleName]}m${text}${RESET}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Combine color and style
|
|
59
|
+
*/
|
|
60
|
+
export function styled(text: string, colorName: ColorName, styleName?: StyleName): string {
|
|
61
|
+
if (styleName) {
|
|
62
|
+
return `${ESC}${STYLE[styleName]};${FG[colorName]}m${text}${RESET}`;
|
|
63
|
+
}
|
|
64
|
+
return color(text, colorName);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Convenience color functions
|
|
68
|
+
export const c = {
|
|
69
|
+
red: (t: string) => color(t, "red"),
|
|
70
|
+
green: (t: string) => color(t, "green"),
|
|
71
|
+
yellow: (t: string) => color(t, "yellow"),
|
|
72
|
+
blue: (t: string) => color(t, "blue"),
|
|
73
|
+
cyan: (t: string) => color(t, "cyan"),
|
|
74
|
+
magenta: (t: string) => color(t, "magenta"),
|
|
75
|
+
gray: (t: string) => color(t, "gray"),
|
|
76
|
+
white: (t: string) => color(t, "white"),
|
|
77
|
+
bold: (t: string) => style(t, "bold"),
|
|
78
|
+
dim: (t: string) => style(t, "dim"),
|
|
79
|
+
|
|
80
|
+
// Semantic colors
|
|
81
|
+
success: (t: string) => color(t, "green"),
|
|
82
|
+
error: (t: string) => color(t, "red"),
|
|
83
|
+
warning: (t: string) => color(t, "yellow"),
|
|
84
|
+
info: (t: string) => color(t, "cyan"),
|
|
85
|
+
muted: (t: string) => color(t, "gray"),
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Box drawing characters (Unicode)
|
|
89
|
+
export const BOX = {
|
|
90
|
+
// Single line
|
|
91
|
+
topLeft: "┌",
|
|
92
|
+
topRight: "┐",
|
|
93
|
+
bottomLeft: "└",
|
|
94
|
+
bottomRight: "┘",
|
|
95
|
+
horizontal: "─",
|
|
96
|
+
vertical: "│",
|
|
97
|
+
|
|
98
|
+
// T-junctions
|
|
99
|
+
tTop: "┬",
|
|
100
|
+
tBottom: "┴",
|
|
101
|
+
tLeft: "├",
|
|
102
|
+
tRight: "┤",
|
|
103
|
+
cross: "┼",
|
|
104
|
+
|
|
105
|
+
// Double line
|
|
106
|
+
dTopLeft: "╔",
|
|
107
|
+
dTopRight: "╗",
|
|
108
|
+
dBottomLeft: "╚",
|
|
109
|
+
dBottomRight: "╝",
|
|
110
|
+
dHorizontal: "═",
|
|
111
|
+
dVertical: "║",
|
|
112
|
+
|
|
113
|
+
// Rounded
|
|
114
|
+
rTopLeft: "╭",
|
|
115
|
+
rTopRight: "╮",
|
|
116
|
+
rBottomLeft: "╰",
|
|
117
|
+
rBottomRight: "╯",
|
|
118
|
+
} as const;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Create a horizontal line
|
|
122
|
+
*/
|
|
123
|
+
export function hline(width: number, char = BOX.horizontal): string {
|
|
124
|
+
return char.repeat(width);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Create a box around text
|
|
129
|
+
*/
|
|
130
|
+
export function box(content: string, options: {
|
|
131
|
+
padding?: number;
|
|
132
|
+
borderColor?: ColorName;
|
|
133
|
+
title?: string;
|
|
134
|
+
rounded?: boolean;
|
|
135
|
+
} = {}): string {
|
|
136
|
+
const { padding = 1, borderColor = "cyan", title, rounded = true } = options;
|
|
137
|
+
|
|
138
|
+
const lines = content.split("\n");
|
|
139
|
+
const maxLen = Math.max(...lines.map(l => stripAnsi(l).length), title ? title.length + 2 : 0);
|
|
140
|
+
const innerWidth = maxLen + padding * 2;
|
|
141
|
+
|
|
142
|
+
const tl = rounded ? BOX.rTopLeft : BOX.topLeft;
|
|
143
|
+
const tr = rounded ? BOX.rTopRight : BOX.topRight;
|
|
144
|
+
const bl = rounded ? BOX.rBottomLeft : BOX.bottomLeft;
|
|
145
|
+
const br = rounded ? BOX.rBottomRight : BOX.bottomRight;
|
|
146
|
+
const h = BOX.horizontal;
|
|
147
|
+
const v = BOX.vertical;
|
|
148
|
+
|
|
149
|
+
const applyBorder = (s: string) => color(s, borderColor);
|
|
150
|
+
|
|
151
|
+
// Top border with optional title
|
|
152
|
+
let top: string;
|
|
153
|
+
if (title) {
|
|
154
|
+
const titlePart = ` ${title} `;
|
|
155
|
+
const remaining = innerWidth - titlePart.length;
|
|
156
|
+
const leftPad = Math.floor(remaining / 2);
|
|
157
|
+
const rightPad = remaining - leftPad;
|
|
158
|
+
top = applyBorder(tl + h.repeat(leftPad)) + c.bold(titlePart) + applyBorder(h.repeat(rightPad) + tr);
|
|
159
|
+
} else {
|
|
160
|
+
top = applyBorder(tl + h.repeat(innerWidth) + tr);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Padding lines
|
|
164
|
+
const padLine = applyBorder(v) + " ".repeat(innerWidth) + applyBorder(v);
|
|
165
|
+
const paddingLines = Array(padding).fill(padLine);
|
|
166
|
+
|
|
167
|
+
// Content lines
|
|
168
|
+
const contentLines = lines.map(line => {
|
|
169
|
+
const stripped = stripAnsi(line);
|
|
170
|
+
const padRight = innerWidth - stripped.length - padding;
|
|
171
|
+
return applyBorder(v) + " ".repeat(padding) + line + " ".repeat(Math.max(0, padRight)) + applyBorder(v);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Bottom border
|
|
175
|
+
const bottom = applyBorder(bl + h.repeat(innerWidth) + br);
|
|
176
|
+
|
|
177
|
+
return [top, ...paddingLines, ...contentLines, ...paddingLines, bottom].join("\n");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Strip ANSI codes from string (for length calculation)
|
|
182
|
+
*/
|
|
183
|
+
export function stripAnsi(str: string): string {
|
|
184
|
+
return str.replace(/\x1b\[[0-9;]*m/g, "");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Check if a character is a wide character (CJK, emoji, etc.)
|
|
189
|
+
* Wide characters take 2 columns in terminal
|
|
190
|
+
*/
|
|
191
|
+
function isWideChar(char: string): boolean {
|
|
192
|
+
const code = char.codePointAt(0) ?? 0;
|
|
193
|
+
|
|
194
|
+
// CJK ranges
|
|
195
|
+
if (
|
|
196
|
+
(code >= 0x1100 && code <= 0x115F) || // Hangul Jamo
|
|
197
|
+
(code >= 0x2E80 && code <= 0x9FFF) || // CJK
|
|
198
|
+
(code >= 0xAC00 && code <= 0xD7AF) || // Hangul Syllables
|
|
199
|
+
(code >= 0xF900 && code <= 0xFAFF) || // CJK Compatibility
|
|
200
|
+
(code >= 0xFE10 && code <= 0xFE1F) || // Vertical forms
|
|
201
|
+
(code >= 0xFE30 && code <= 0xFE6F) || // CJK Compatibility Forms
|
|
202
|
+
(code >= 0xFF00 && code <= 0xFF60) || // Fullwidth Forms
|
|
203
|
+
(code >= 0xFFE0 && code <= 0xFFE6) || // Fullwidth Signs
|
|
204
|
+
(code >= 0x20000 && code <= 0x2FFFF) // CJK Extension B+
|
|
205
|
+
) {
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Get display width of a string (CJK = 2, ASCII = 1)
|
|
213
|
+
*/
|
|
214
|
+
export function displayWidth(str: string): number {
|
|
215
|
+
const stripped = stripAnsi(str);
|
|
216
|
+
let width = 0;
|
|
217
|
+
for (const char of stripped) {
|
|
218
|
+
width += isWideChar(char) ? 2 : 1;
|
|
219
|
+
}
|
|
220
|
+
return width;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Pad string to width (accounting for ANSI codes and wide characters)
|
|
225
|
+
*/
|
|
226
|
+
export function pad(str: string, width: number, align: "left" | "right" | "center" = "left"): string {
|
|
227
|
+
const len = displayWidth(str);
|
|
228
|
+
const diff = width - len;
|
|
229
|
+
if (diff <= 0) return str;
|
|
230
|
+
|
|
231
|
+
switch (align) {
|
|
232
|
+
case "right":
|
|
233
|
+
return " ".repeat(diff) + str;
|
|
234
|
+
case "center":
|
|
235
|
+
const left = Math.floor(diff / 2);
|
|
236
|
+
return " ".repeat(left) + str + " ".repeat(diff - left);
|
|
237
|
+
default:
|
|
238
|
+
return str + " ".repeat(diff);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Truncate string to max display width
|
|
244
|
+
*/
|
|
245
|
+
export function truncate(str: string, maxLen: number, suffix = "..."): string {
|
|
246
|
+
const stripped = stripAnsi(str);
|
|
247
|
+
if (displayWidth(stripped) <= maxLen) return str;
|
|
248
|
+
|
|
249
|
+
// Truncate by display width
|
|
250
|
+
const suffixWidth = displayWidth(suffix);
|
|
251
|
+
let width = 0;
|
|
252
|
+
let result = "";
|
|
253
|
+
|
|
254
|
+
for (const char of stripped) {
|
|
255
|
+
const charWidth = isWideChar(char) ? 2 : 1;
|
|
256
|
+
if (width + charWidth > maxLen - suffixWidth) break;
|
|
257
|
+
result += char;
|
|
258
|
+
width += charWidth;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return result + suffix;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Create a simple table
|
|
266
|
+
*/
|
|
267
|
+
export interface TableColumn {
|
|
268
|
+
header: string;
|
|
269
|
+
key: string;
|
|
270
|
+
width?: number;
|
|
271
|
+
align?: "left" | "right" | "center";
|
|
272
|
+
format?: (value: unknown) => string;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function table<T extends Record<string, unknown>>(
|
|
276
|
+
data: T[],
|
|
277
|
+
columns: TableColumn[],
|
|
278
|
+
options: {
|
|
279
|
+
headerColor?: ColorName;
|
|
280
|
+
borderColor?: ColorName;
|
|
281
|
+
} = {}
|
|
282
|
+
): string {
|
|
283
|
+
const { headerColor = "cyan", borderColor = "gray" } = options;
|
|
284
|
+
|
|
285
|
+
// Calculate column widths using display width
|
|
286
|
+
const widths = columns.map(col => {
|
|
287
|
+
const headerWidth = displayWidth(col.header);
|
|
288
|
+
const maxDataWidth = Math.max(
|
|
289
|
+
...data.map(row => {
|
|
290
|
+
const val = col.format ? col.format(row[col.key]) : String(row[col.key] ?? "");
|
|
291
|
+
return displayWidth(val);
|
|
292
|
+
}),
|
|
293
|
+
0
|
|
294
|
+
);
|
|
295
|
+
return col.width ?? Math.max(headerWidth, maxDataWidth);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
const border = color(BOX.vertical, borderColor);
|
|
299
|
+
const hBorder = color(BOX.horizontal, borderColor);
|
|
300
|
+
|
|
301
|
+
// Header
|
|
302
|
+
const headerRow = columns
|
|
303
|
+
.map((col, i) => color(pad(col.header, widths[i], col.align), headerColor))
|
|
304
|
+
.join(` ${border} `);
|
|
305
|
+
|
|
306
|
+
// Separator
|
|
307
|
+
const separator = widths.map(w => hBorder.repeat(w)).join(color(`─${BOX.cross}─`, borderColor));
|
|
308
|
+
|
|
309
|
+
// Data rows
|
|
310
|
+
const dataRows = data.map(row =>
|
|
311
|
+
columns
|
|
312
|
+
.map((col, i) => {
|
|
313
|
+
const val = col.format ? col.format(row[col.key]) : String(row[col.key] ?? "");
|
|
314
|
+
return pad(truncate(val, widths[i]), widths[i], col.align);
|
|
315
|
+
})
|
|
316
|
+
.join(` ${border} `)
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
return [headerRow, separator, ...dataRows].join("\n");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Create a progress bar
|
|
324
|
+
*/
|
|
325
|
+
export function progressBar(
|
|
326
|
+
current: number,
|
|
327
|
+
total: number,
|
|
328
|
+
options: {
|
|
329
|
+
width?: number;
|
|
330
|
+
filled?: string;
|
|
331
|
+
empty?: string;
|
|
332
|
+
showPercent?: boolean;
|
|
333
|
+
filledColor?: ColorName;
|
|
334
|
+
emptyColor?: ColorName;
|
|
335
|
+
} = {}
|
|
336
|
+
): string {
|
|
337
|
+
const {
|
|
338
|
+
width = 20,
|
|
339
|
+
filled = "█",
|
|
340
|
+
empty = "░",
|
|
341
|
+
showPercent = true,
|
|
342
|
+
filledColor = "green",
|
|
343
|
+
emptyColor = "gray",
|
|
344
|
+
} = options;
|
|
345
|
+
|
|
346
|
+
const percent = total > 0 ? Math.round((current / total) * 100) : 0;
|
|
347
|
+
const filledCount = Math.round((percent / 100) * width);
|
|
348
|
+
const emptyCount = width - filledCount;
|
|
349
|
+
|
|
350
|
+
const bar = color(filled.repeat(filledCount), filledColor) + color(empty.repeat(emptyCount), emptyColor);
|
|
351
|
+
|
|
352
|
+
return showPercent ? `${bar} ${percent}%` : bar;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Status icons
|
|
357
|
+
*/
|
|
358
|
+
export const icons = {
|
|
359
|
+
// Status
|
|
360
|
+
pending: c.yellow("○"),
|
|
361
|
+
in_progress: c.blue("◐"),
|
|
362
|
+
completed: c.green("✓"),
|
|
363
|
+
blocked: c.red("✗"),
|
|
364
|
+
cancelled: c.gray("⊘"),
|
|
365
|
+
|
|
366
|
+
// Priority
|
|
367
|
+
critical: c.red("!!!"),
|
|
368
|
+
high: c.yellow("!!"),
|
|
369
|
+
medium: c.blue("!"),
|
|
370
|
+
low: c.gray("·"),
|
|
371
|
+
|
|
372
|
+
// Misc
|
|
373
|
+
arrow: c.cyan("→"),
|
|
374
|
+
bullet: c.gray("•"),
|
|
375
|
+
check: c.green("✓"),
|
|
376
|
+
cross: c.red("✗"),
|
|
377
|
+
warning: c.yellow("⚠"),
|
|
378
|
+
info: c.cyan("ℹ"),
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* ASCII art text (simple implementation)
|
|
383
|
+
*/
|
|
384
|
+
export function banner(text: string): string {
|
|
385
|
+
// Simple block letters for "TASK MCP"
|
|
386
|
+
const letters: Record<string, string[]> = {
|
|
387
|
+
T: ["████", " ██ ", " ██ ", " ██ ", " ██ "],
|
|
388
|
+
A: [" ██ ", "████", "██ █", "████", "██ █"],
|
|
389
|
+
S: ["████", "██ ", "████", " ██", "████"],
|
|
390
|
+
K: ["██ █", "███ ", "██ ", "███ ", "██ █"],
|
|
391
|
+
M: ["█ █", "██ ██", "█ █ █", "█ █", "█ █"],
|
|
392
|
+
C: ["████", "██ ", "██ ", "██ ", "████"],
|
|
393
|
+
P: ["████", "██ █", "████", "██ ", "██ "],
|
|
394
|
+
" ": [" ", " ", " ", " ", " "],
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
const chars = text.toUpperCase().split("");
|
|
398
|
+
const lines: string[] = ["", "", "", "", ""];
|
|
399
|
+
|
|
400
|
+
for (const char of chars) {
|
|
401
|
+
const letterLines = letters[char] ?? letters[" "];
|
|
402
|
+
for (let i = 0; i < 5; i++) {
|
|
403
|
+
lines[i] += (letterLines[i] ?? "") + " ";
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return lines.map(l => c.cyan(l)).join("\n");
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Clear screen
|
|
412
|
+
*/
|
|
413
|
+
export function clearScreen(): void {
|
|
414
|
+
process.stdout.write("\x1b[2J\x1b[H");
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Move cursor
|
|
419
|
+
*/
|
|
420
|
+
export function moveCursor(row: number, col: number): void {
|
|
421
|
+
process.stdout.write(`\x1b[${row};${col}H`);
|
|
422
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard command - displays project overview like Taskmaster
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
c,
|
|
7
|
+
box,
|
|
8
|
+
progressBar,
|
|
9
|
+
table,
|
|
10
|
+
icons,
|
|
11
|
+
banner,
|
|
12
|
+
hline,
|
|
13
|
+
pad,
|
|
14
|
+
type TableColumn,
|
|
15
|
+
} from "../ansi.js";
|
|
16
|
+
import {
|
|
17
|
+
listProjects,
|
|
18
|
+
listTasks,
|
|
19
|
+
listAllTasks,
|
|
20
|
+
calculateStats,
|
|
21
|
+
calculateDependencyMetrics,
|
|
22
|
+
suggestNextTask,
|
|
23
|
+
type Task,
|
|
24
|
+
type Project,
|
|
25
|
+
} from "../storage.js";
|
|
26
|
+
|
|
27
|
+
function formatStatus(status: Task["status"]): string {
|
|
28
|
+
const icon = icons[status] ?? icons.pending;
|
|
29
|
+
return `${icon} ${status}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function formatPriority(priority: Task["priority"]): string {
|
|
33
|
+
const colors: Record<string, (s: string) => string> = {
|
|
34
|
+
critical: c.red,
|
|
35
|
+
high: c.yellow,
|
|
36
|
+
medium: c.blue,
|
|
37
|
+
low: c.gray,
|
|
38
|
+
};
|
|
39
|
+
return (colors[priority] ?? c.gray)(priority);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function formatDependencies(deps: Task["dependencies"]): string {
|
|
43
|
+
if (!deps || deps.length === 0) return c.gray("None");
|
|
44
|
+
return c.cyan(deps.map(d => d.taskId.slice(0, 4)).join(", "));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function dashboard(projectId?: string): Promise<void> {
|
|
48
|
+
// Print banner
|
|
49
|
+
console.log();
|
|
50
|
+
console.log(banner("TASK MCP"));
|
|
51
|
+
console.log();
|
|
52
|
+
|
|
53
|
+
// Get projects and tasks
|
|
54
|
+
const projects = await listProjects();
|
|
55
|
+
|
|
56
|
+
if (projects.length === 0) {
|
|
57
|
+
console.log(c.yellow("No projects found. Create a project first using the MCP server."));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// If projectId specified, show single project. Otherwise show all.
|
|
62
|
+
let project: Project | undefined;
|
|
63
|
+
let tasks: Task[];
|
|
64
|
+
|
|
65
|
+
if (projectId) {
|
|
66
|
+
project = projects.find(p => p.id === projectId || p.id.startsWith(projectId));
|
|
67
|
+
if (!project) {
|
|
68
|
+
console.log(c.error(`Project not found: ${projectId}`));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
tasks = await listTasks(project.id);
|
|
72
|
+
} else if (projects.length === 1) {
|
|
73
|
+
project = projects[0];
|
|
74
|
+
tasks = await listTasks(project!.id);
|
|
75
|
+
} else {
|
|
76
|
+
// Show all tasks across all projects
|
|
77
|
+
tasks = await listAllTasks();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Project info
|
|
81
|
+
const projectInfo = project
|
|
82
|
+
? `${c.bold("Project:")} ${project.name}`
|
|
83
|
+
: `${c.bold("All Projects")} (${projects.length} projects)`;
|
|
84
|
+
|
|
85
|
+
console.log(c.dim(`Version: 1.0.4 ${projectInfo}`));
|
|
86
|
+
console.log();
|
|
87
|
+
|
|
88
|
+
// Calculate stats
|
|
89
|
+
const stats = calculateStats(tasks);
|
|
90
|
+
const depMetrics = calculateDependencyMetrics(tasks);
|
|
91
|
+
const nextTask = suggestNextTask(tasks);
|
|
92
|
+
|
|
93
|
+
// Left panel: Project Dashboard
|
|
94
|
+
const dashboardContent = `${c.bold("Project Dashboard")}
|
|
95
|
+
Tasks Progress: ${progressBar(stats.completed, stats.total - stats.cancelled, { width: 30 })}
|
|
96
|
+
${c.green(`Done: ${stats.completed}`)} ${c.blue(`In Progress: ${stats.inProgress}`)} ${c.yellow(`Pending: ${stats.pending}`)} ${c.red(`Blocked: ${stats.blocked}`)}
|
|
97
|
+
${stats.cancelled > 0 ? c.gray(`Cancelled: ${stats.cancelled}`) : ""}
|
|
98
|
+
|
|
99
|
+
${c.bold("Priority Breakdown")}
|
|
100
|
+
${c.red("•")} Critical: ${stats.byPriority.critical}
|
|
101
|
+
${c.yellow("•")} High: ${stats.byPriority.high}
|
|
102
|
+
${c.blue("•")} Medium: ${stats.byPriority.medium}
|
|
103
|
+
${c.gray("•")} Low: ${stats.byPriority.low}`;
|
|
104
|
+
|
|
105
|
+
// Right panel: Dependency Status
|
|
106
|
+
const depContent = `${c.bold("Dependency Status & Next Task")}
|
|
107
|
+
${c.cyan("Dependency Metrics:")}
|
|
108
|
+
• Tasks with no dependencies: ${depMetrics.noDependencies}
|
|
109
|
+
• Tasks ready to work on: ${c.green(String(depMetrics.readyToWork))}
|
|
110
|
+
• Tasks blocked by dependencies: ${depMetrics.blockedByDependencies}
|
|
111
|
+
${depMetrics.mostDependedOn ? `• Most depended-on task: ${c.cyan(`#${depMetrics.mostDependedOn.id.slice(0, 4)}`)} (${depMetrics.mostDependedOn.count} dependents)` : ""}
|
|
112
|
+
• Avg dependencies per task: ${depMetrics.avgDependencies}
|
|
113
|
+
|
|
114
|
+
${c.yellow("Next Task to Work On:")}
|
|
115
|
+
${nextTask
|
|
116
|
+
? `ID: ${c.cyan(nextTask.id.slice(0, 4))} - ${c.bold(nextTask.title)}
|
|
117
|
+
Priority: ${formatPriority(nextTask.priority)} Dependencies: ${formatDependencies(nextTask.dependencies)}`
|
|
118
|
+
: c.gray("No actionable tasks available")}`;
|
|
119
|
+
|
|
120
|
+
// Print boxes side by side (simplified: stacked for now)
|
|
121
|
+
console.log(box(dashboardContent, { title: "Dashboard", borderColor: "cyan" }));
|
|
122
|
+
console.log();
|
|
123
|
+
console.log(box(depContent, { title: "Dependencies", borderColor: "yellow" }));
|
|
124
|
+
console.log();
|
|
125
|
+
|
|
126
|
+
// Task table
|
|
127
|
+
if (tasks.length > 0) {
|
|
128
|
+
const columns: TableColumn[] = [
|
|
129
|
+
{ header: "ID", key: "id", width: 6, format: (v) => c.cyan(String(v).slice(0, 4)) },
|
|
130
|
+
{ header: "Title", key: "title", width: 50 },
|
|
131
|
+
{ header: "Status", key: "status", width: 14, format: (v) => formatStatus(v as Task["status"]) },
|
|
132
|
+
{ header: "Priority", key: "priority", width: 10, format: (v) => formatPriority(v as Task["priority"]) },
|
|
133
|
+
{ header: "Dependencies", key: "dependencies", width: 16, format: (v) => formatDependencies(v as Task["dependencies"]) },
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
console.log(table(tasks, columns));
|
|
137
|
+
} else {
|
|
138
|
+
console.log(c.gray("No tasks found."));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
console.log();
|
|
142
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* List commands - list tasks and projects
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { c, table, icons, type TableColumn } from "../ansi.js";
|
|
6
|
+
import {
|
|
7
|
+
listProjects,
|
|
8
|
+
listTasks,
|
|
9
|
+
listAllTasks,
|
|
10
|
+
type Task,
|
|
11
|
+
type Project,
|
|
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
|
+
}
|
|
28
|
+
|
|
29
|
+
function formatProjectStatus(status: Project["status"]): string {
|
|
30
|
+
const statusMap: Record<string, string> = {
|
|
31
|
+
active: c.green("● active"),
|
|
32
|
+
on_hold: c.yellow("◐ on_hold"),
|
|
33
|
+
completed: c.blue("✓ completed"),
|
|
34
|
+
archived: c.gray("⊘ archived"),
|
|
35
|
+
};
|
|
36
|
+
return statusMap[status] ?? status;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function formatDate(dateStr?: string): string {
|
|
40
|
+
if (!dateStr) return c.gray("-");
|
|
41
|
+
const date = new Date(dateStr);
|
|
42
|
+
return date.toLocaleDateString();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function listTasksCmd(options: {
|
|
46
|
+
projectId?: string;
|
|
47
|
+
status?: string;
|
|
48
|
+
priority?: string;
|
|
49
|
+
all?: boolean;
|
|
50
|
+
}): Promise<void> {
|
|
51
|
+
let tasks: Task[];
|
|
52
|
+
|
|
53
|
+
if (options.projectId) {
|
|
54
|
+
tasks = await listTasks(options.projectId);
|
|
55
|
+
} else {
|
|
56
|
+
tasks = await listAllTasks();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Apply filters
|
|
60
|
+
if (options.status) {
|
|
61
|
+
const statuses = options.status.split(",");
|
|
62
|
+
tasks = tasks.filter(t => statuses.includes(t.status));
|
|
63
|
+
} else if (!options.all) {
|
|
64
|
+
// By default, hide completed/cancelled
|
|
65
|
+
tasks = tasks.filter(t => t.status !== "completed" && t.status !== "cancelled");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (options.priority) {
|
|
69
|
+
const priorities = options.priority.split(",");
|
|
70
|
+
tasks = tasks.filter(t => priorities.includes(t.priority));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (tasks.length === 0) {
|
|
74
|
+
console.log(c.gray("No tasks found."));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
console.log(c.bold(`\nTasks (${tasks.length})\n`));
|
|
79
|
+
|
|
80
|
+
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) },
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
console.log(table(tasks, columns));
|
|
89
|
+
console.log();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function listProjectsCmd(options: {
|
|
93
|
+
all?: boolean;
|
|
94
|
+
}): Promise<void> {
|
|
95
|
+
const projects = await listProjects(options.all);
|
|
96
|
+
|
|
97
|
+
if (projects.length === 0) {
|
|
98
|
+
console.log(c.gray("No projects found."));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
console.log(c.bold(`\nProjects (${projects.length})\n`));
|
|
103
|
+
|
|
104
|
+
// Get task counts for each project
|
|
105
|
+
const projectsWithStats = await Promise.all(
|
|
106
|
+
projects.map(async p => {
|
|
107
|
+
const tasks = await listTasks(p.id);
|
|
108
|
+
const completed = tasks.filter(t => t.status === "completed").length;
|
|
109
|
+
const total = tasks.filter(t => t.status !== "cancelled").length;
|
|
110
|
+
return {
|
|
111
|
+
...p,
|
|
112
|
+
taskCount: tasks.length,
|
|
113
|
+
progress: total > 0 ? `${completed}/${total}` : "0/0",
|
|
114
|
+
};
|
|
115
|
+
})
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
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) },
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
console.log(table(projectsWithStats, columns));
|
|
127
|
+
console.log();
|
|
128
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Task MCP CLI - Zero-dependency terminal visualization
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* task # Show dashboard (default)
|
|
7
|
+
* task dashboard [id] # Show project dashboard
|
|
8
|
+
* task d [id] # Alias for dashboard
|
|
9
|
+
* task list # List tasks
|
|
10
|
+
* task ls # Alias for list
|
|
11
|
+
* task projects # List projects
|
|
12
|
+
* task p # Alias for projects
|
|
13
|
+
* task help # Show help
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { c, banner } from "./ansi.js";
|
|
17
|
+
import { dashboard } from "./commands/dashboard.js";
|
|
18
|
+
import { listTasksCmd, listProjectsCmd } from "./commands/list.js";
|
|
19
|
+
|
|
20
|
+
const VERSION = "1.0.4";
|
|
21
|
+
|
|
22
|
+
interface ParsedArgs {
|
|
23
|
+
command: string;
|
|
24
|
+
args: string[];
|
|
25
|
+
flags: Record<string, string | boolean>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parseArgs(argv: string[]): ParsedArgs {
|
|
29
|
+
const args = argv.slice(2); // Skip bun and script path
|
|
30
|
+
const flags: Record<string, string | boolean> = {};
|
|
31
|
+
const positional: string[] = [];
|
|
32
|
+
|
|
33
|
+
for (let i = 0; i < args.length; i++) {
|
|
34
|
+
const arg = args[i]!;
|
|
35
|
+
if (arg.startsWith("--")) {
|
|
36
|
+
const [key, value] = arg.slice(2).split("=");
|
|
37
|
+
if (value !== undefined) {
|
|
38
|
+
flags[key!] = value;
|
|
39
|
+
} else if (args[i + 1] && !args[i + 1]!.startsWith("-")) {
|
|
40
|
+
flags[key!] = args[++i]!;
|
|
41
|
+
} else {
|
|
42
|
+
flags[key!] = true;
|
|
43
|
+
}
|
|
44
|
+
} else if (arg.startsWith("-") && arg.length === 2) {
|
|
45
|
+
const key = arg.slice(1);
|
|
46
|
+
if (args[i + 1] && !args[i + 1]!.startsWith("-")) {
|
|
47
|
+
flags[key] = args[++i]!;
|
|
48
|
+
} else {
|
|
49
|
+
flags[key] = true;
|
|
50
|
+
}
|
|
51
|
+
} else {
|
|
52
|
+
positional.push(arg);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
command: positional[0] ?? "dashboard",
|
|
58
|
+
args: positional.slice(1),
|
|
59
|
+
flags,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function showHelp(): void {
|
|
64
|
+
console.log(`
|
|
65
|
+
${c.cyan(c.bold("Task MCP CLI"))} ${c.gray(`v${VERSION}`)}
|
|
66
|
+
${c.dim("Zero-dependency terminal visualization for task-mcp")}
|
|
67
|
+
|
|
68
|
+
${c.yellow("USAGE")}
|
|
69
|
+
${c.bold("task")} [command] [options]
|
|
70
|
+
|
|
71
|
+
${c.yellow("COMMANDS")}
|
|
72
|
+
${c.cyan("dashboard")} [project-id] Show project dashboard (default)
|
|
73
|
+
${c.cyan("d")} [project-id] Alias for dashboard
|
|
74
|
+
${c.cyan("list")} List all tasks
|
|
75
|
+
${c.cyan("ls")} Alias for list
|
|
76
|
+
${c.cyan("projects")} List all projects
|
|
77
|
+
${c.cyan("p")} Alias for projects
|
|
78
|
+
${c.cyan("help")} Show this help message
|
|
79
|
+
${c.cyan("version")} Show version
|
|
80
|
+
|
|
81
|
+
${c.yellow("OPTIONS")}
|
|
82
|
+
${c.cyan("--status")} <status> Filter tasks by status (pending,in_progress,blocked,completed,cancelled)
|
|
83
|
+
${c.cyan("--priority")} <priority> Filter tasks by priority (critical,high,medium,low)
|
|
84
|
+
${c.cyan("--all")} Include completed/cancelled tasks
|
|
85
|
+
|
|
86
|
+
${c.yellow("EXAMPLES")}
|
|
87
|
+
${c.dim("# Show dashboard for all projects")}
|
|
88
|
+
task
|
|
89
|
+
|
|
90
|
+
${c.dim("# Show dashboard for a specific project")}
|
|
91
|
+
task dashboard proj_abc123
|
|
92
|
+
|
|
93
|
+
${c.dim("# List pending high-priority tasks")}
|
|
94
|
+
task list --status pending --priority high
|
|
95
|
+
|
|
96
|
+
${c.dim("# List all projects including archived")}
|
|
97
|
+
task projects --all
|
|
98
|
+
`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function showVersion(): void {
|
|
102
|
+
console.log(`task-mcp-cli v${VERSION}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function main(): Promise<void> {
|
|
106
|
+
const { command, args, flags } = parseArgs(process.argv);
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
switch (command) {
|
|
110
|
+
case "dashboard":
|
|
111
|
+
case "d":
|
|
112
|
+
await dashboard(args[0]);
|
|
113
|
+
break;
|
|
114
|
+
|
|
115
|
+
case "list":
|
|
116
|
+
case "ls":
|
|
117
|
+
await listTasksCmd({
|
|
118
|
+
projectId: args[0],
|
|
119
|
+
status: flags.status as string | undefined,
|
|
120
|
+
priority: flags.priority as string | undefined,
|
|
121
|
+
all: flags.all === true,
|
|
122
|
+
});
|
|
123
|
+
break;
|
|
124
|
+
|
|
125
|
+
case "projects":
|
|
126
|
+
case "p":
|
|
127
|
+
await listProjectsCmd({
|
|
128
|
+
all: flags.all === true,
|
|
129
|
+
});
|
|
130
|
+
break;
|
|
131
|
+
|
|
132
|
+
case "help":
|
|
133
|
+
case "-h":
|
|
134
|
+
case "--help":
|
|
135
|
+
showHelp();
|
|
136
|
+
break;
|
|
137
|
+
|
|
138
|
+
case "version":
|
|
139
|
+
case "-v":
|
|
140
|
+
case "--version":
|
|
141
|
+
showVersion();
|
|
142
|
+
break;
|
|
143
|
+
|
|
144
|
+
default:
|
|
145
|
+
console.log(c.error(`Unknown command: ${command}`));
|
|
146
|
+
console.log(c.dim("Run 'task help' for usage information."));
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if (error instanceof Error) {
|
|
151
|
+
console.error(c.error(`Error: ${error.message}`));
|
|
152
|
+
} else {
|
|
153
|
+
console.error(c.error("An unexpected error occurred"));
|
|
154
|
+
}
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
main();
|
package/src/storage.ts
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple file-based storage reader for CLI
|
|
3
|
+
* Reads directly from .tasks/ directory
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
export interface Task {
|
|
10
|
+
id: string;
|
|
11
|
+
title: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
status: "pending" | "in_progress" | "blocked" | "completed" | "cancelled";
|
|
14
|
+
priority: "critical" | "high" | "medium" | "low";
|
|
15
|
+
projectId: string;
|
|
16
|
+
dependencies?: { taskId: string; type: string; reason?: string }[];
|
|
17
|
+
dueDate?: string;
|
|
18
|
+
createdAt: string;
|
|
19
|
+
updatedAt: string;
|
|
20
|
+
completedAt?: string;
|
|
21
|
+
contexts?: string[];
|
|
22
|
+
tags?: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface Project {
|
|
26
|
+
id: string;
|
|
27
|
+
name: string;
|
|
28
|
+
description?: string;
|
|
29
|
+
status: "active" | "on_hold" | "completed" | "archived";
|
|
30
|
+
defaultPriority?: string;
|
|
31
|
+
createdAt: string;
|
|
32
|
+
updatedAt: string;
|
|
33
|
+
targetDate?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface TasksFile {
|
|
37
|
+
version: number;
|
|
38
|
+
tasks: Task[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getTasksDir(): string {
|
|
42
|
+
return join(process.cwd(), ".tasks");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function fileExists(path: string): Promise<boolean> {
|
|
46
|
+
try {
|
|
47
|
+
await readFile(path);
|
|
48
|
+
return true;
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function readJson<T>(path: string): Promise<T | null> {
|
|
55
|
+
try {
|
|
56
|
+
const content = await readFile(path, "utf-8");
|
|
57
|
+
return JSON.parse(content) as T;
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function listDirs(path: string): Promise<string[]> {
|
|
64
|
+
try {
|
|
65
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
66
|
+
return entries.filter(e => e.isDirectory()).map(e => e.name);
|
|
67
|
+
} catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* List all projects
|
|
74
|
+
*/
|
|
75
|
+
export async function listProjects(includeArchived = false): Promise<Project[]> {
|
|
76
|
+
const projectsDir = join(getTasksDir(), "projects");
|
|
77
|
+
const projectIds = await listDirs(projectsDir);
|
|
78
|
+
|
|
79
|
+
const projects: Project[] = [];
|
|
80
|
+
for (const id of projectIds) {
|
|
81
|
+
const project = await readJson<Project>(join(projectsDir, id, "project.json"));
|
|
82
|
+
if (project) {
|
|
83
|
+
if (includeArchived || project.status !== "archived") {
|
|
84
|
+
projects.push(project);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return projects.sort(
|
|
90
|
+
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Get a project by ID
|
|
96
|
+
*/
|
|
97
|
+
export async function getProject(projectId: string): Promise<Project | null> {
|
|
98
|
+
const path = join(getTasksDir(), "projects", projectId, "project.json");
|
|
99
|
+
return readJson<Project>(path);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* List tasks for a project
|
|
104
|
+
*/
|
|
105
|
+
export async function listTasks(projectId: string): Promise<Task[]> {
|
|
106
|
+
const path = join(getTasksDir(), "projects", projectId, "tasks.json");
|
|
107
|
+
const data = await readJson<TasksFile>(path);
|
|
108
|
+
return data?.tasks ?? [];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* List all tasks across all projects
|
|
113
|
+
*/
|
|
114
|
+
export async function listAllTasks(): Promise<Task[]> {
|
|
115
|
+
const projects = await listProjects(true);
|
|
116
|
+
const allTasks: Task[] = [];
|
|
117
|
+
|
|
118
|
+
for (const project of projects) {
|
|
119
|
+
const tasks = await listTasks(project.id);
|
|
120
|
+
allTasks.push(...tasks);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return allTasks.sort(
|
|
124
|
+
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Get project statistics
|
|
130
|
+
*/
|
|
131
|
+
export interface ProjectStats {
|
|
132
|
+
total: number;
|
|
133
|
+
completed: number;
|
|
134
|
+
inProgress: number;
|
|
135
|
+
pending: number;
|
|
136
|
+
blocked: number;
|
|
137
|
+
cancelled: number;
|
|
138
|
+
byPriority: Record<string, number>;
|
|
139
|
+
completionPercent: number;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function calculateStats(tasks: Task[]): ProjectStats {
|
|
143
|
+
const stats: ProjectStats = {
|
|
144
|
+
total: tasks.length,
|
|
145
|
+
completed: 0,
|
|
146
|
+
inProgress: 0,
|
|
147
|
+
pending: 0,
|
|
148
|
+
blocked: 0,
|
|
149
|
+
cancelled: 0,
|
|
150
|
+
byPriority: { critical: 0, high: 0, medium: 0, low: 0 },
|
|
151
|
+
completionPercent: 0,
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
for (const task of tasks) {
|
|
155
|
+
switch (task.status) {
|
|
156
|
+
case "completed":
|
|
157
|
+
stats.completed++;
|
|
158
|
+
break;
|
|
159
|
+
case "in_progress":
|
|
160
|
+
stats.inProgress++;
|
|
161
|
+
break;
|
|
162
|
+
case "pending":
|
|
163
|
+
stats.pending++;
|
|
164
|
+
break;
|
|
165
|
+
case "blocked":
|
|
166
|
+
stats.blocked++;
|
|
167
|
+
break;
|
|
168
|
+
case "cancelled":
|
|
169
|
+
stats.cancelled++;
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
stats.byPriority[task.priority]++;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const nonCancelled = stats.total - stats.cancelled;
|
|
176
|
+
stats.completionPercent = nonCancelled > 0
|
|
177
|
+
? Math.round((stats.completed / nonCancelled) * 100)
|
|
178
|
+
: 0;
|
|
179
|
+
|
|
180
|
+
return stats;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Get dependency metrics
|
|
185
|
+
*/
|
|
186
|
+
export interface DependencyMetrics {
|
|
187
|
+
noDependencies: number;
|
|
188
|
+
readyToWork: number;
|
|
189
|
+
blockedByDependencies: number;
|
|
190
|
+
mostDependedOn: { id: string; title: string; count: number } | null;
|
|
191
|
+
avgDependencies: number;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function calculateDependencyMetrics(tasks: Task[]): DependencyMetrics {
|
|
195
|
+
const activeTasks = tasks.filter(t => t.status !== "completed" && t.status !== "cancelled");
|
|
196
|
+
const completedIds = new Set(tasks.filter(t => t.status === "completed").map(t => t.id));
|
|
197
|
+
|
|
198
|
+
// Count how many tasks depend on each task
|
|
199
|
+
const dependentCounts: Record<string, number> = {};
|
|
200
|
+
for (const task of activeTasks) {
|
|
201
|
+
for (const dep of task.dependencies ?? []) {
|
|
202
|
+
dependentCounts[dep.taskId] = (dependentCounts[dep.taskId] ?? 0) + 1;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let mostDependedOn: DependencyMetrics["mostDependedOn"] = null;
|
|
207
|
+
let maxCount = 0;
|
|
208
|
+
for (const [id, count] of Object.entries(dependentCounts)) {
|
|
209
|
+
if (count > maxCount) {
|
|
210
|
+
maxCount = count;
|
|
211
|
+
const task = tasks.find(t => t.id === id);
|
|
212
|
+
if (task) {
|
|
213
|
+
mostDependedOn = { id, title: task.title, count };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const noDependencies = activeTasks.filter(t => (t.dependencies?.length ?? 0) === 0).length;
|
|
219
|
+
|
|
220
|
+
// Ready to work: no uncompleted dependencies
|
|
221
|
+
const readyToWork = activeTasks.filter(t => {
|
|
222
|
+
const deps = t.dependencies ?? [];
|
|
223
|
+
if (deps.length === 0) return true;
|
|
224
|
+
return deps.every(d => completedIds.has(d.taskId));
|
|
225
|
+
}).length;
|
|
226
|
+
|
|
227
|
+
const blockedByDependencies = activeTasks.length - readyToWork;
|
|
228
|
+
|
|
229
|
+
const totalDeps = activeTasks.reduce((sum, t) => sum + (t.dependencies?.length ?? 0), 0);
|
|
230
|
+
const avgDependencies = activeTasks.length > 0 ? totalDeps / activeTasks.length : 0;
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
noDependencies,
|
|
234
|
+
readyToWork,
|
|
235
|
+
blockedByDependencies,
|
|
236
|
+
mostDependedOn,
|
|
237
|
+
avgDependencies: Math.round(avgDependencies * 10) / 10,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Get next task suggestion
|
|
243
|
+
*/
|
|
244
|
+
export function suggestNextTask(tasks: Task[]): Task | null {
|
|
245
|
+
const completedIds = new Set(tasks.filter(t => t.status === "completed").map(t => t.id));
|
|
246
|
+
|
|
247
|
+
// Filter to actionable tasks
|
|
248
|
+
const actionable = tasks.filter(t => {
|
|
249
|
+
if (t.status !== "pending" && t.status !== "in_progress") return false;
|
|
250
|
+
|
|
251
|
+
// Check if all dependencies are completed
|
|
252
|
+
const deps = t.dependencies ?? [];
|
|
253
|
+
return deps.every(d => completedIds.has(d.taskId));
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
if (actionable.length === 0) return null;
|
|
257
|
+
|
|
258
|
+
// Sort by priority then by creation date
|
|
259
|
+
const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
260
|
+
actionable.sort((a, b) => {
|
|
261
|
+
const pDiff = priorityOrder[a.priority] - priorityOrder[b.priority];
|
|
262
|
+
if (pDiff !== 0) return pDiff;
|
|
263
|
+
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
return actionable[0] ?? null;
|
|
267
|
+
}
|