@nijaru/tk 0.0.2 → 0.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.
@@ -6,7 +6,7 @@ import {
6
6
  formatTaskDetail,
7
7
  formatJson,
8
8
  } from "./format";
9
- import type { Task, TaskWithMeta, LogEntry } from "../types";
9
+ import type { TaskWithMeta, LogEntry } from "../types";
10
10
 
11
11
  describe("shouldUseColor", () => {
12
12
  const originalEnv = process.env.NO_COLOR;
@@ -38,7 +38,7 @@ describe("shouldUseColor", () => {
38
38
  });
39
39
 
40
40
  describe("formatTaskRow", () => {
41
- const task: Task & { id: string } = {
41
+ const task: TaskWithMeta = {
42
42
  id: "tk-a1b2",
43
43
  project: "tk",
44
44
  ref: "a1b2",
@@ -57,6 +57,8 @@ describe("formatTaskRow", () => {
57
57
  updated_at: new Date().toISOString(),
58
58
  completed_at: null,
59
59
  external: {},
60
+ blocked_by_incomplete: false,
61
+ is_overdue: false,
60
62
  };
61
63
 
62
64
  test("formats task without color", () => {
@@ -75,7 +77,7 @@ describe("formatTaskRow", () => {
75
77
  });
76
78
 
77
79
  test("truncates long titles to 50 chars", () => {
78
- const longTask: Task & { id: string } = {
80
+ const longTask: TaskWithMeta = {
79
81
  ...task,
80
82
  title: "A".repeat(100),
81
83
  };
@@ -90,13 +92,13 @@ describe("formatTaskRow", () => {
90
92
  expect(parts.length).toBe(4); // ID | PRIO | STATUS | TITLE
91
93
  });
92
94
 
93
- test("truncates long project names to 6 chars", () => {
94
- const longProjectTask: Task & { id: string } = {
95
+ test("truncates long project names with ellipsis", () => {
96
+ const longProjectTask: TaskWithMeta = {
95
97
  ...task,
96
98
  id: "mylongproject-a1b2",
97
99
  };
98
100
  const result = formatTaskRow(longProjectTask, false);
99
- expect(result).toContain("mylong-a1b2");
101
+ expect(result).toContain("mylon…-a1b2");
100
102
  expect(result).not.toContain("mylongproject");
101
103
  });
102
104
 
@@ -106,9 +108,10 @@ describe("formatTaskRow", () => {
106
108
  });
107
109
 
108
110
  test("shows overdue marker for overdue tasks", () => {
109
- const overdueTask: Task & { id: string } = {
111
+ const overdueTask: TaskWithMeta = {
110
112
  ...task,
111
113
  due_date: "2020-01-01",
114
+ is_overdue: true,
112
115
  };
113
116
  const result = formatTaskRow(overdueTask, false);
114
117
  expect(result).toContain("[OVERDUE]");
@@ -116,9 +119,10 @@ describe("formatTaskRow", () => {
116
119
 
117
120
  test("does NOT show overdue marker for tasks due today", () => {
118
121
  const today = new Date().toISOString().split("T")[0] ?? null;
119
- const todayTask: Task & { id: string } = {
122
+ const todayTask: TaskWithMeta = {
120
123
  ...task,
121
124
  due_date: today,
125
+ is_overdue: false,
122
126
  };
123
127
  const result = formatTaskRow(todayTask, false);
124
128
  expect(result).not.toContain("[OVERDUE]");
@@ -126,12 +130,16 @@ describe("formatTaskRow", () => {
126
130
  });
127
131
 
128
132
  describe("formatTaskList", () => {
129
- test("returns message for empty list", () => {
130
- expect(formatTaskList([])).toBe("No tasks found.");
133
+ test("returns helpful message for empty list", () => {
134
+ expect(formatTaskList([])).toBe("No tasks found. Run 'tk add \"title\"' to create one.");
135
+ });
136
+
137
+ test("allows custom empty hint", () => {
138
+ expect(formatTaskList([], undefined, "Custom hint")).toBe("Custom hint");
131
139
  });
132
140
 
133
141
  test("includes header and divider", () => {
134
- const tasks: (Task & { id: string })[] = [
142
+ const tasks: TaskWithMeta[] = [
135
143
  {
136
144
  id: "tk-a1b2",
137
145
  project: "tk",
@@ -151,6 +159,8 @@ describe("formatTaskList", () => {
151
159
  updated_at: new Date().toISOString(),
152
160
  completed_at: null,
153
161
  external: {},
162
+ blocked_by_incomplete: false,
163
+ is_overdue: false,
154
164
  },
155
165
  ];
156
166
  const result = formatTaskList(tasks, false);
@@ -162,7 +172,7 @@ describe("formatTaskList", () => {
162
172
  });
163
173
 
164
174
  test("formats multiple tasks", () => {
165
- const tasks: (Task & { id: string })[] = [
175
+ const tasks: TaskWithMeta[] = [
166
176
  {
167
177
  id: "tk-a1b2",
168
178
  project: "tk",
@@ -182,6 +192,8 @@ describe("formatTaskList", () => {
182
192
  updated_at: new Date().toISOString(),
183
193
  completed_at: null,
184
194
  external: {},
195
+ blocked_by_incomplete: false,
196
+ is_overdue: false,
185
197
  },
186
198
  {
187
199
  id: "tk-c3d4",
@@ -202,6 +214,8 @@ describe("formatTaskList", () => {
202
214
  updated_at: new Date().toISOString(),
203
215
  completed_at: null,
204
216
  external: {},
217
+ blocked_by_incomplete: false,
218
+ is_overdue: false,
205
219
  },
206
220
  ];
207
221
  const result = formatTaskList(tasks, false);
package/src/lib/format.ts CHANGED
@@ -1,6 +1,7 @@
1
- import type { Task, TaskWithMeta, LogEntry } from "../types";
1
+ import type { TaskWithMeta, LogEntry } from "../types";
2
2
  import { PRIORITY_COLORS, STATUS_COLORS, OVERDUE_COLOR, RESET } from "../types";
3
3
  import { formatPriority } from "./priority";
4
+ import { formatDate, formatRelativeTime } from "./time";
4
5
 
5
6
  // Message colors
6
7
  const GREEN = "\x1b[32m";
@@ -42,56 +43,59 @@ export function dim(msg: string): string {
42
43
  return shouldUseColor() ? `${DIM}${msg}${RESET}` : msg;
43
44
  }
44
45
 
45
- function formatId(id: string): string {
46
- // Truncate project prefix to 6 chars, keep full 4-char ref
47
- // "myproject-a1b2" -> "myproj-a1b2"
46
+ function truncate(text: string, maxLen: number): string {
47
+ return text.length <= maxLen ? text : text.slice(0, maxLen - 1) + "…";
48
+ }
49
+
50
+ function formatId(id: string, maxLen = 11): string {
51
+ // Truncate project prefix to fit maxLen, keep full 4-char ref
52
+ // "myproject-a1b2" -> "mypr…-a1b2"
48
53
  const dash = id.lastIndexOf("-");
49
- if (dash === -1) return id.slice(0, 11);
54
+ if (dash === -1) return truncate(id, maxLen);
50
55
  const project = id.slice(0, dash);
51
56
  const ref = id.slice(dash + 1);
52
- const truncatedProject = project.length > 6 ? project.slice(0, 6) : project;
53
- return `${truncatedProject}-${ref}`;
57
+ const maxProjectLen = maxLen - ref.length - 1; // -1 for dash
58
+ return project.length > maxProjectLen
59
+ ? `${truncate(project, maxProjectLen)}-${ref}`
60
+ : `${project}-${ref}`;
54
61
  }
55
62
 
56
- export function formatTaskRow(task: Task & { id: string }, useColor?: boolean): string {
63
+ export function formatTaskRow(task: TaskWithMeta, useColor?: boolean): string {
57
64
  const color = useColor ?? shouldUseColor();
58
65
  const id = formatId(task.id).padEnd(11);
59
66
  const priority = formatPriority(task.priority).padEnd(4);
60
- const status = task.status.padEnd(7);
61
- const title = task.title.slice(0, 50);
62
-
63
- // Check if overdue (due date is before today, not including today)
64
- let isOverdue = false;
65
- if (task.due_date && task.status !== "done") {
66
- const today = new Date();
67
- today.setHours(0, 0, 0, 0);
68
- // Parse YYYY-MM-DD as local date (not UTC)
69
- const parts = task.due_date.split("-").map(Number);
70
- const year = parts[0];
71
- const month = parts[1];
72
- const day = parts[2];
73
- if (year && month && day) {
74
- const dueDate = new Date(year, month - 1, day);
75
- isOverdue = dueDate < today;
76
- }
67
+ const title = truncate(task.title, 50);
68
+ const isOverdue = task.is_overdue;
69
+
70
+ let statusText: string = task.status;
71
+ if (task.status === "done" && task.completed_at) {
72
+ statusText = `done ${formatRelativeTime(task.completed_at)}`;
77
73
  }
74
+ const status = statusText.padEnd(12);
78
75
 
79
76
  if (color) {
80
77
  const pc = PRIORITY_COLORS[task.priority];
81
78
  const sc = isOverdue ? OVERDUE_COLOR : STATUS_COLORS[task.status];
82
- return `${id} | ${pc}${priority}${RESET} | ${sc}${status}${RESET} | ${title}`;
79
+ const tc = task.status === "done" ? DIM : "";
80
+ return `${id} | ${pc}${priority}${RESET} | ${sc}${status}${RESET} | ${tc}${title}${RESET}`;
83
81
  }
84
82
 
85
83
  const overdueMarker = isOverdue ? " [OVERDUE]" : "";
86
84
  return `${id} | ${priority} | ${status} | ${title}${overdueMarker}`;
87
85
  }
88
86
 
89
- export function formatTaskList(tasks: (Task & { id: string })[], useColor?: boolean): string {
90
- if (tasks.length === 0) return "No tasks found.";
87
+ export function formatTaskList(
88
+ tasks: TaskWithMeta[],
89
+ useColor?: boolean,
90
+ emptyHint?: string,
91
+ ): string {
92
+ if (tasks.length === 0) {
93
+ return emptyHint ?? "No tasks found. Run 'tk add \"title\"' to create one.";
94
+ }
91
95
  const color = useColor ?? shouldUseColor();
92
96
 
93
- const header = "ID | PRIO | STATUS | TITLE";
94
- const divider = "-".repeat(60);
97
+ const header = "ID | PRIO | STATUS | TITLE";
98
+ const divider = "-".repeat(65);
95
99
  const rows = tasks.map((t) => formatTaskRow(t, color));
96
100
 
97
101
  return [header, divider, ...rows].join("\n");
@@ -159,10 +163,6 @@ export function formatTaskDetail(task: TaskWithMeta, logs: LogEntry[], useColor?
159
163
  return lines.join("\n");
160
164
  }
161
165
 
162
- function formatDate(timestamp: string): string {
163
- return new Date(timestamp).toLocaleString();
164
- }
165
-
166
166
  export function formatJson(data: unknown): string {
167
167
  return JSON.stringify(data, null, 2);
168
168
  }
@@ -0,0 +1,249 @@
1
+ import { version } from "../../package.json";
2
+
3
+ export const MAIN_HELP = `tk v${version} - Task tracker for AI agents
4
+
5
+ USAGE:
6
+ tk <command> [options]
7
+
8
+ COMMANDS:
9
+ init Initialize .tasks/ directory
10
+ add Create task
11
+ ls, list List tasks
12
+ ready List ready tasks (active/open + unblocked)
13
+ show Show task details
14
+ start Start working (open -> active)
15
+ done Complete task
16
+ reopen Reopen task
17
+ edit Edit task
18
+ log Add log entry
19
+ block Add blocker
20
+ unblock Remove blocker
21
+ rm, remove Delete task
22
+ clean Remove old done tasks
23
+ check Check for data issues
24
+ config Show/set configuration
25
+ completions Output shell completions
26
+
27
+ GLOBAL OPTIONS:
28
+ -C <dir> Run in different directory
29
+ --json Output as JSON
30
+ -h, --help Show help
31
+ -V Show version
32
+
33
+ Run 'tk <command> --help' for command-specific options.
34
+ `;
35
+
36
+ export const COMMAND_HELP: Record<string, string> = {
37
+ init: `tk init - Initialize .tasks/ directory
38
+
39
+ USAGE:
40
+ tk init [options]
41
+
42
+ OPTIONS:
43
+ -P, --project <name> Set default project name
44
+ `,
45
+ add: `tk add - Create a new task
46
+
47
+ USAGE:
48
+ tk add <title> [options]
49
+
50
+ OPTIONS:
51
+ -p, --priority <0-4> Priority (0=none, 1=urgent, 2=high, 3=medium, 4=low)
52
+ -P, --project <name> Project prefix for ID
53
+ -d, --description <text> Description
54
+ -l, --labels <csv> Labels (comma-separated)
55
+ -A, --assignees <csv> Assignees (comma-separated, @me for git user)
56
+ --parent <id> Parent task ID
57
+ --estimate <n> Estimate (user-defined units)
58
+ --due <date> Due date (YYYY-MM-DD or +Nh/+Nd/+Nw/+Nm)
59
+
60
+ EXAMPLES:
61
+ tk add "Fix login bug" -p 1
62
+ tk add "New feature" -P api -l bug,urgent
63
+ tk add "Sprint task" --due +7d
64
+ `,
65
+ ls: `tk ls - List tasks
66
+
67
+ USAGE:
68
+ tk ls [options]
69
+
70
+ OPTIONS:
71
+ -s, --status <status> Filter by status (open, active, done)
72
+ -p, --priority <0-4> Filter by priority
73
+ -P, --project <name> Filter by project
74
+ -l, --label <label> Filter by label
75
+ --assignee <name> Filter by assignee
76
+ --parent <id> Filter by parent
77
+ --roots Show only root tasks (no parent)
78
+ --overdue Show only overdue tasks
79
+ -n, --limit <n> Limit results (default: 20)
80
+ -a, --all Show all (no limit)
81
+
82
+ EXAMPLES:
83
+ tk ls -s open -p 1 # Urgent open tasks
84
+ tk ls --overdue # Overdue tasks
85
+ tk ls -P api --roots # Root tasks in api project
86
+ `,
87
+ list: `tk list - List tasks (alias for 'ls')
88
+
89
+ Run 'tk ls --help' for options.
90
+ `,
91
+ ready: `tk ready - List ready tasks (active/open + unblocked)
92
+
93
+ USAGE:
94
+ tk ready
95
+
96
+ Shows active or open tasks that are not blocked by any incomplete task.
97
+ `,
98
+ show: `tk show - Show task details
99
+
100
+ USAGE:
101
+ tk show <id>
102
+
103
+ EXAMPLES:
104
+ tk show tk-1
105
+ tk show 1 # If unambiguous
106
+ `,
107
+ start: `tk start - Start working on a task
108
+
109
+ USAGE:
110
+ tk start <id>
111
+
112
+ Changes status from open to active.
113
+ `,
114
+ done: `tk done - Complete a task
115
+
116
+ USAGE:
117
+ tk done <id>
118
+
119
+ Changes status to done.
120
+ `,
121
+ reopen: `tk reopen - Reopen a task
122
+
123
+ USAGE:
124
+ tk reopen <id>
125
+
126
+ Changes status back to open.
127
+ `,
128
+ edit: `tk edit - Edit a task
129
+
130
+ USAGE:
131
+ tk edit <id> [options]
132
+
133
+ OPTIONS:
134
+ -t, --title <text> New title
135
+ -d, --description <text> New description
136
+ -p, --priority <0-4> New priority
137
+ -l, --labels <csv> Replace labels (use +tag/-tag to add/remove)
138
+ -A, --assignees <csv> Replace assignees
139
+ --parent <id> Set parent (use - to clear)
140
+ --estimate <n> Set estimate (use - to clear)
141
+ --due <date> Set due date (use - to clear)
142
+
143
+ EXAMPLES:
144
+ tk edit tk-1 -t "New title"
145
+ tk edit tk-1 -l +urgent # Add label
146
+ tk edit tk-1 --due - # Clear due date
147
+ `,
148
+ log: `tk log - Add a log entry to a task
149
+
150
+ USAGE:
151
+ tk log <id> "<message>"
152
+
153
+ Message must be quoted.
154
+
155
+ EXAMPLES:
156
+ tk log tk-1 "Started implementation"
157
+ tk log tk-1 "Blocked on API changes"
158
+ `,
159
+ block: `tk block - Add a blocker dependency
160
+
161
+ USAGE:
162
+ tk block <task> <blocker>
163
+
164
+ The first task becomes blocked by the second.
165
+
166
+ EXAMPLES:
167
+ tk block tk-2 tk-1 # tk-2 is blocked by tk-1
168
+ `,
169
+ unblock: `tk unblock - Remove a blocker dependency
170
+
171
+ USAGE:
172
+ tk unblock <task> <blocker>
173
+
174
+ EXAMPLES:
175
+ tk unblock tk-2 tk-1
176
+ `,
177
+ rm: `tk rm - Delete a task
178
+
179
+ USAGE:
180
+ tk rm <id>
181
+
182
+ EXAMPLES:
183
+ tk rm tk-1
184
+ `,
185
+ remove: `tk remove - Delete a task (alias for 'rm')
186
+
187
+ USAGE:
188
+ tk remove <id>
189
+ `,
190
+ clean: `tk clean - Remove old completed tasks
191
+
192
+ USAGE:
193
+ tk clean [options]
194
+
195
+ OPTIONS:
196
+ --older-than <days> Age threshold in days (default: from config, 14)
197
+ -f, --force Remove all done tasks (ignores age and disabled state)
198
+
199
+ EXAMPLES:
200
+ tk clean # Remove done tasks older than config.clean_after days
201
+ tk clean --older-than 30 # Remove done tasks older than 30 days
202
+ tk clean --force # Remove all done tasks regardless of age
203
+ `,
204
+ check: `tk check - Check for data issues
205
+
206
+ USAGE:
207
+ tk check
208
+
209
+ Scans all tasks for issues. Auto-fixable issues (orphaned references, ID
210
+ mismatches) are fixed automatically. Unfixable issues (corrupted JSON) are
211
+ reported for manual intervention.
212
+
213
+ Note: Auto-fixing also happens during normal task operations (show, done,
214
+ edit, etc.) - this command is for bulk cleanup or diagnostics.
215
+
216
+ EXAMPLES:
217
+ tk check # Scan and fix all tasks
218
+ `,
219
+ config: `tk config - Show or set configuration
220
+
221
+ USAGE:
222
+ tk config Show all config
223
+ tk config project Show default project
224
+ tk config project <name> Set default project
225
+ tk config project <new> --rename <old> Rename project (old-* → new-*)
226
+ tk config alias List aliases
227
+ tk config alias <name> <path> Add alias
228
+ tk config alias --rm <name> Remove alias
229
+
230
+ EXAMPLES:
231
+ tk config project api
232
+ tk config project lsmvec --rename cloudlsmvec
233
+ tk config alias web src/web
234
+ `,
235
+ completions: `tk completions - Output shell completions
236
+
237
+ USAGE:
238
+ tk completions <shell>
239
+
240
+ SHELLS:
241
+ bash Bash completion script
242
+ zsh Zsh completion script
243
+ fish Fish completion script
244
+
245
+ EXAMPLES:
246
+ eval "$(tk completions bash)" # Add to ~/.bashrc
247
+ eval "$(tk completions zsh)" # Add to ~/.zshrc
248
+ `,
249
+ };
@@ -0,0 +1,115 @@
1
+ import type { Status } from "../types";
2
+
3
+ /**
4
+ * Determines if a task is overdue.
5
+ * Done tasks are never overdue.
6
+ */
7
+ export function isTaskOverdue(dueDate: string | null, status: Status): boolean {
8
+ if (!dueDate || status === "done") return false;
9
+ const today = new Date();
10
+ today.setHours(0, 0, 0, 0);
11
+ const parts = dueDate.split("-").map(Number);
12
+ const year = parts[0];
13
+ const month = parts[1];
14
+ const day = parts[2];
15
+ if (!year || !month || !day) return false;
16
+ const due = new Date(year, month - 1, day);
17
+ return due < today;
18
+ }
19
+
20
+ /**
21
+ * Format a timestamp into a human-readable relative time (e.g., "2d", "5h").
22
+ */
23
+ export function formatRelativeTime(timestamp: string): string {
24
+ const now = new Date();
25
+ const date = new Date(timestamp);
26
+ const diffMs = now.getTime() - date.getTime();
27
+ const diffSec = Math.floor(diffMs / 1000);
28
+ const diffMin = Math.floor(diffSec / 60);
29
+ const diffHour = Math.floor(diffMin / 60);
30
+ const diffDay = Math.floor(diffHour / 24);
31
+
32
+ if (diffDay > 0) return `${diffDay}d`;
33
+ if (diffHour > 0) return `${diffHour}h`;
34
+ if (diffMin > 0) return `${diffMin}m`;
35
+ return "now";
36
+ }
37
+
38
+ /**
39
+ * Format a timestamp into a localized date string.
40
+ */
41
+ export function formatDate(timestamp: string): string {
42
+ return new Date(timestamp).toLocaleString();
43
+ }
44
+
45
+ /**
46
+ * Format a Date object into YYYY-MM-DD.
47
+ */
48
+ export function formatLocalDate(date: Date): string {
49
+ const year = date.getFullYear();
50
+ const month = String(date.getMonth() + 1).padStart(2, "0");
51
+ const day = String(date.getDate()).padStart(2, "0");
52
+ return `${year}-${month}-${day}`;
53
+ }
54
+
55
+ /**
56
+ * Parse a due date input string. Supports YYYY-MM-DD and relative formats like +7d.
57
+ */
58
+ export function parseDueDate(input: string | undefined): string | undefined {
59
+ if (!input) return undefined;
60
+ if (input === "-") return undefined;
61
+
62
+ // Handle relative dates like +7d
63
+ if (input.startsWith("+")) {
64
+ const match = input.match(/^\+(\d+)([dwmh])$/);
65
+ if (match && match[1] && match[2]) {
66
+ const num = match[1];
67
+ const unit = match[2] as "h" | "d" | "w" | "m";
68
+ const n = Number(num);
69
+ const now = new Date();
70
+ now.setSeconds(0, 0); // Normalize to start of minute for predictability
71
+ switch (unit) {
72
+ case "h":
73
+ now.setHours(now.getHours() + n);
74
+ break;
75
+ case "d":
76
+ now.setDate(now.getDate() + n);
77
+ break;
78
+ case "w":
79
+ now.setDate(now.getDate() + n * 7);
80
+ break;
81
+ case "m":
82
+ now.setMonth(now.getMonth() + n);
83
+ break;
84
+ }
85
+ return formatLocalDate(now);
86
+ }
87
+ throw new Error(`Invalid relative date: ${input}. Use format like +7d, +2w, +1m`);
88
+ }
89
+
90
+ // Validate YYYY-MM-DD format
91
+ const dateMatch = input.match(/^(\d{4})-(\d{2})-(\d{2})$/);
92
+ if (dateMatch) {
93
+ const [, year, month, day] = dateMatch;
94
+ const y = parseInt(year!, 10);
95
+ const m = parseInt(month!, 10);
96
+ const d = parseInt(day!, 10);
97
+ const date = new Date(y, m - 1, d);
98
+ if (date.getFullYear() === y && date.getMonth() === m - 1 && date.getDate() === d) {
99
+ return input;
100
+ }
101
+ }
102
+
103
+ throw new Error(`Invalid date format: ${input}. Use YYYY-MM-DD or relative like +7d`);
104
+ }
105
+
106
+ /**
107
+ * Parse an estimate string (number of minutes/hours/etc. depending on convention).
108
+ */
109
+ export function parseEstimate(input: string | undefined): number | undefined {
110
+ if (!input) return undefined;
111
+ if (!/^\d+$/.test(input)) {
112
+ throw new Error(`Invalid estimate: ${input}. Must be a non-negative number.`);
113
+ }
114
+ return Number(input);
115
+ }