@xxkeefer/mrkl 0.3.1 → 0.4.1
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 +35 -6
- package/dist/chunks/list-tui.mjs +252 -0
- package/dist/cli.mjs +157 -17
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -80,6 +80,7 @@ mrkl x PROJ-002 # close
|
|
|
80
80
|
| `done` | `d` | Mark a task as done and archive it |
|
|
81
81
|
| `close` | `x` | Close a task (won't do, duplicate, etc.) and archive it |
|
|
82
82
|
| `prune` | `p` | Delete archived tasks created on or before a given date |
|
|
83
|
+
| `migrate_prior_verbose` | — | Migrate legacy verbose-filename tasks to frontmatter-based format |
|
|
83
84
|
| `install-skills` | — | Install bundled Claude Code skills |
|
|
84
85
|
|
|
85
86
|
### `mrkl init <prefix>`
|
|
@@ -175,6 +176,25 @@ mrkl prune 2026-01-31
|
|
|
175
176
|
mrkl prune 2026-01-31 --force
|
|
176
177
|
```
|
|
177
178
|
|
|
179
|
+
### `mrkl migrate_prior_verbose`
|
|
180
|
+
|
|
181
|
+
Migrates task files from the legacy verbose-filename format to the current format. This is a **one-time migration** for projects that were using mrkl before v0.4.0.
|
|
182
|
+
|
|
183
|
+
**What it does:**
|
|
184
|
+
|
|
185
|
+
1. Scans all task files in `.tasks/` and `.tasks/.archive/`
|
|
186
|
+
2. Extracts the title from the verbose filename (e.g., `PROJ-001 feat - user auth.md`)
|
|
187
|
+
3. Writes the title into YAML frontmatter
|
|
188
|
+
4. If `verbose_files = false` (default): renames files to short format (`PROJ-001.md`)
|
|
189
|
+
5. If `verbose_files = true`: keeps verbose filenames as-is
|
|
190
|
+
|
|
191
|
+
```sh
|
|
192
|
+
mrkl migrate_prior_verbose
|
|
193
|
+
# ✅ Migrated 12 file(s), skipped 3 file(s).
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
> **Note:** After upgrading to v0.4.0+, existing task files will fail to parse until migrated. Run this command once to fix them.
|
|
197
|
+
|
|
178
198
|
### `mrkl install-skills`
|
|
179
199
|
|
|
180
200
|
Installs bundled Claude Code skills into the current project.
|
|
@@ -211,15 +231,16 @@ mrkl uses [conventional commit](https://www.conventionalcommits.org/) types:
|
|
|
211
231
|
|
|
212
232
|
## Task File Format 📄
|
|
213
233
|
|
|
214
|
-
Each task is a markdown file with YAML frontmatter:
|
|
234
|
+
Each task is a markdown file with YAML frontmatter. By default, filenames use the short format:
|
|
215
235
|
|
|
216
236
|
```
|
|
217
|
-
.tasks/PROJ-001
|
|
237
|
+
.tasks/PROJ-001.md
|
|
218
238
|
```
|
|
219
239
|
|
|
220
240
|
```markdown
|
|
221
241
|
---
|
|
222
242
|
id: PROJ-001
|
|
243
|
+
title: user authentication
|
|
223
244
|
type: feat
|
|
224
245
|
status: todo
|
|
225
246
|
created: '2026-03-01'
|
|
@@ -235,7 +256,13 @@ Implement user authentication with OAuth2.
|
|
|
235
256
|
- [ ] session persists across refreshes
|
|
236
257
|
```
|
|
237
258
|
|
|
238
|
-
|
|
259
|
+
With `verbose_files = true`, filenames include the type and title:
|
|
260
|
+
|
|
261
|
+
```
|
|
262
|
+
.tasks/PROJ-001 feat - user authentication.md
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
The `title` is always stored in frontmatter regardless of filename format. Edit task files directly when you need to update descriptions, change status, or check off criteria.
|
|
239
266
|
|
|
240
267
|
## Project Structure 🗂️
|
|
241
268
|
|
|
@@ -247,10 +274,10 @@ your-project/
|
|
|
247
274
|
mrkl.toml # project configuration
|
|
248
275
|
mrkl_counter # current task number
|
|
249
276
|
.tasks/
|
|
250
|
-
PROJ-001
|
|
251
|
-
PROJ-002
|
|
277
|
+
PROJ-001.md
|
|
278
|
+
PROJ-002.md
|
|
252
279
|
.archive/
|
|
253
|
-
PROJ-000
|
|
280
|
+
PROJ-000.md
|
|
254
281
|
```
|
|
255
282
|
|
|
256
283
|
Commit `.config/mrkl/` and `.tasks/` to version control. They're designed to be tracked alongside your code.
|
|
@@ -286,12 +313,14 @@ Configuration lives in `.config/mrkl/mrkl.toml` (or `mrkl.toml` at the project r
|
|
|
286
313
|
```toml
|
|
287
314
|
prefix = "PROJ"
|
|
288
315
|
tasks_dir = ".tasks"
|
|
316
|
+
verbose_files = false
|
|
289
317
|
```
|
|
290
318
|
|
|
291
319
|
| Key | Default | Description |
|
|
292
320
|
|-----|---------|-------------|
|
|
293
321
|
| `prefix` | *(required)* | Project prefix for task IDs |
|
|
294
322
|
| `tasks_dir` | `".tasks"` | Directory for task files |
|
|
323
|
+
| `verbose_files` | `false` | Use verbose filenames (`PROJ-001 feat - title.md` vs `PROJ-001.md`) |
|
|
295
324
|
|
|
296
325
|
## Development 🧑💻
|
|
297
326
|
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { Fzf } from 'fzf';
|
|
2
|
+
|
|
3
|
+
const ESC = "\x1B";
|
|
4
|
+
const CSI = `${ESC}[`;
|
|
5
|
+
const ALT_SCREEN_ON = `${CSI}?1049h`;
|
|
6
|
+
const ALT_SCREEN_OFF = `${CSI}?1049l`;
|
|
7
|
+
const CURSOR_HIDE = `${CSI}?25l`;
|
|
8
|
+
const CURSOR_SHOW = `${CSI}?25h`;
|
|
9
|
+
const CLEAR_SCREEN = `${CSI}2J${CSI}H`;
|
|
10
|
+
const BOLD = `${CSI}1m`;
|
|
11
|
+
const UNDERLINE = `${CSI}4m`;
|
|
12
|
+
const RESET = `${CSI}0m`;
|
|
13
|
+
const INVERSE = `${CSI}7m`;
|
|
14
|
+
const FG_CYAN = `${CSI}36m`;
|
|
15
|
+
const FG_YELLOW = `${CSI}33m`;
|
|
16
|
+
const FG_GREEN = `${CSI}32m`;
|
|
17
|
+
const FG_RED = `${CSI}31m`;
|
|
18
|
+
const FG_GRAY = `${CSI}90m`;
|
|
19
|
+
function statusColor(status) {
|
|
20
|
+
switch (status) {
|
|
21
|
+
case "todo":
|
|
22
|
+
return FG_YELLOW;
|
|
23
|
+
case "in-progress":
|
|
24
|
+
return FG_CYAN;
|
|
25
|
+
case "done":
|
|
26
|
+
return FG_GREEN;
|
|
27
|
+
case "closed":
|
|
28
|
+
return FG_RED;
|
|
29
|
+
default:
|
|
30
|
+
return "";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function buildEntries(tasks) {
|
|
34
|
+
return tasks.map((task) => ({
|
|
35
|
+
task,
|
|
36
|
+
searchText: `${task.id} ${task.type} ${task.status} ${task.title} ${task.description}`
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
async function interactiveList(tasks, archivedTasks) {
|
|
40
|
+
const { stdin, stdout } = process;
|
|
41
|
+
const datasets = [
|
|
42
|
+
{ label: "Tasks", entries: buildEntries(tasks) },
|
|
43
|
+
{ label: "Archive", entries: buildEntries(archivedTasks) }
|
|
44
|
+
];
|
|
45
|
+
let activeTab = 0;
|
|
46
|
+
let query = "";
|
|
47
|
+
let selectedIndex = 0;
|
|
48
|
+
let scrollOffset = 0;
|
|
49
|
+
function getFiltered() {
|
|
50
|
+
const entries = datasets[activeTab].entries;
|
|
51
|
+
if (!query) return entries;
|
|
52
|
+
const fzf = new Fzf(entries, { selector: (e) => e.searchText });
|
|
53
|
+
return fzf.find(query).map((r) => r.item);
|
|
54
|
+
}
|
|
55
|
+
function getTermSize() {
|
|
56
|
+
return { cols: stdout.columns || 80, rows: stdout.rows || 24 };
|
|
57
|
+
}
|
|
58
|
+
function render() {
|
|
59
|
+
const { cols, rows } = getTermSize();
|
|
60
|
+
const filtered = getFiltered();
|
|
61
|
+
const buf = [];
|
|
62
|
+
const tabParts = datasets.map((ds, i) => {
|
|
63
|
+
if (i === activeTab) {
|
|
64
|
+
return `${FG_CYAN}${BOLD}[${ds.label}]${RESET}`;
|
|
65
|
+
}
|
|
66
|
+
return `${FG_GRAY} ${ds.label} ${RESET}`;
|
|
67
|
+
});
|
|
68
|
+
buf.push(tabParts.join(" "));
|
|
69
|
+
buf.push("");
|
|
70
|
+
buf.push(`${FG_CYAN}>${RESET} ${query}${UNDERLINE} ${RESET}`);
|
|
71
|
+
const listWidth = Math.floor(cols * 0.55);
|
|
72
|
+
const previewWidth = cols - listWidth - 3;
|
|
73
|
+
buf.push(`${FG_GRAY}${"\u2500".repeat(listWidth)}\u252C${"\u2500".repeat(previewWidth + 2)}${RESET}`);
|
|
74
|
+
const headerLine = formatRow("ID", "TYPE", "STATUS", "TITLE", listWidth);
|
|
75
|
+
buf.push(`${BOLD}${headerLine}${RESET}${FG_GRAY}\u2502${RESET}${BOLD} Preview${RESET}`);
|
|
76
|
+
buf.push(`${FG_GRAY}${"\u2500".repeat(listWidth)}\u253C${"\u2500".repeat(previewWidth + 2)}${RESET}`);
|
|
77
|
+
const contentRows = rows - 9;
|
|
78
|
+
const maxVisible = Math.max(1, contentRows);
|
|
79
|
+
if (filtered.length === 0) {
|
|
80
|
+
selectedIndex = 0;
|
|
81
|
+
} else {
|
|
82
|
+
selectedIndex = Math.min(selectedIndex, filtered.length - 1);
|
|
83
|
+
selectedIndex = Math.max(selectedIndex, 0);
|
|
84
|
+
}
|
|
85
|
+
if (selectedIndex < scrollOffset) scrollOffset = selectedIndex;
|
|
86
|
+
if (selectedIndex >= scrollOffset + maxVisible) scrollOffset = selectedIndex - maxVisible + 1;
|
|
87
|
+
const selectedTask = filtered[selectedIndex]?.task;
|
|
88
|
+
const previewLines = buildPreviewLines(selectedTask, previewWidth);
|
|
89
|
+
for (let i = 0; i < maxVisible; i++) {
|
|
90
|
+
const taskIdx = scrollOffset + i;
|
|
91
|
+
const entry = filtered[taskIdx];
|
|
92
|
+
let leftPart;
|
|
93
|
+
if (!entry) {
|
|
94
|
+
leftPart = " ".repeat(listWidth);
|
|
95
|
+
} else {
|
|
96
|
+
const isSelected = taskIdx === selectedIndex;
|
|
97
|
+
const row = formatRow(entry.task.id, entry.task.type, entry.task.status, entry.task.title, listWidth);
|
|
98
|
+
if (isSelected) {
|
|
99
|
+
leftPart = `${INVERSE}${row}${RESET}`;
|
|
100
|
+
} else {
|
|
101
|
+
const sc = statusColor(entry.task.status);
|
|
102
|
+
leftPart = colorizeRow(entry.task.id, entry.task.type, entry.task.status, entry.task.title, listWidth, sc);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const rightPart = previewLines[i] ?? "";
|
|
106
|
+
buf.push(`${leftPart}${FG_GRAY}\u2502${RESET} ${rightPart}`);
|
|
107
|
+
}
|
|
108
|
+
buf.push(`${FG_GRAY}${"\u2500".repeat(listWidth)}\u2534${"\u2500".repeat(previewWidth + 2)}${RESET}`);
|
|
109
|
+
const countInfo = `${filtered.length}/${datasets[activeTab].entries.length}`;
|
|
110
|
+
buf.push(`${FG_GRAY}${countInfo} tasks \u2191\u2193: navigate Tab: switch Enter: select Esc: quit Type to search${RESET}`);
|
|
111
|
+
stdout.write(CLEAR_SCREEN + buf.join("\n"));
|
|
112
|
+
}
|
|
113
|
+
function formatRow(id, type, status, title, width) {
|
|
114
|
+
const idCol = id.padEnd(14);
|
|
115
|
+
const typeCol = type.padEnd(12);
|
|
116
|
+
const statusCol = status.padEnd(14);
|
|
117
|
+
const usedWidth = 14 + 12 + 14;
|
|
118
|
+
const titleWidth = Math.max(1, width - usedWidth);
|
|
119
|
+
const titleCol = title.length > titleWidth ? title.slice(0, titleWidth - 1) + "\u2026" : title.padEnd(titleWidth);
|
|
120
|
+
return `${idCol}${typeCol}${statusCol}${titleCol}`;
|
|
121
|
+
}
|
|
122
|
+
function colorizeRow(id, type, status, title, width, sc) {
|
|
123
|
+
const idCol = id.padEnd(14);
|
|
124
|
+
const typeCol = type.padEnd(12);
|
|
125
|
+
const statusCol = status.padEnd(14);
|
|
126
|
+
const usedWidth = 14 + 12 + 14;
|
|
127
|
+
const titleWidth = Math.max(1, width - usedWidth);
|
|
128
|
+
const titleCol = title.length > titleWidth ? title.slice(0, titleWidth - 1) + "\u2026" : title.padEnd(titleWidth);
|
|
129
|
+
return `${FG_CYAN}${idCol}${RESET}${typeCol}${sc}${statusCol}${RESET}${titleCol}`;
|
|
130
|
+
}
|
|
131
|
+
function buildPreviewLines(task, width) {
|
|
132
|
+
if (!task) return [];
|
|
133
|
+
const lines = [];
|
|
134
|
+
lines.push(`${BOLD}${task.id}${RESET} ${FG_GRAY}${task.type}${RESET} ${statusColor(task.status)}${task.status}${RESET}`);
|
|
135
|
+
lines.push(`${BOLD}${task.title}${RESET}`);
|
|
136
|
+
lines.push("");
|
|
137
|
+
if (task.description) {
|
|
138
|
+
lines.push(`${UNDERLINE}Description${RESET}`);
|
|
139
|
+
for (const line of wrapText(task.description, width)) {
|
|
140
|
+
lines.push(line);
|
|
141
|
+
}
|
|
142
|
+
lines.push("");
|
|
143
|
+
}
|
|
144
|
+
if (task.acceptance_criteria.length > 0) {
|
|
145
|
+
lines.push(`${UNDERLINE}Acceptance Criteria${RESET}`);
|
|
146
|
+
for (const ac of task.acceptance_criteria) {
|
|
147
|
+
for (const line of wrapText(`- [ ] ${ac}`, width)) {
|
|
148
|
+
lines.push(line);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return lines;
|
|
153
|
+
}
|
|
154
|
+
function wrapText(text, width) {
|
|
155
|
+
if (width <= 0) return [text];
|
|
156
|
+
const result = [];
|
|
157
|
+
for (const rawLine of text.split("\n")) {
|
|
158
|
+
const words = rawLine.split(" ");
|
|
159
|
+
let current = "";
|
|
160
|
+
for (const word of words) {
|
|
161
|
+
if (current.length + word.length + 1 > width && current.length > 0) {
|
|
162
|
+
result.push(current);
|
|
163
|
+
current = word;
|
|
164
|
+
} else {
|
|
165
|
+
current = current ? `${current} ${word}` : word;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (current) result.push(current);
|
|
169
|
+
}
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
if (stdin.isTTY) stdin.setRawMode(true);
|
|
173
|
+
stdin.resume();
|
|
174
|
+
stdin.setEncoding("utf-8");
|
|
175
|
+
stdout.write(ALT_SCREEN_ON + CURSOR_HIDE);
|
|
176
|
+
render();
|
|
177
|
+
return new Promise((resolve) => {
|
|
178
|
+
function cleanup() {
|
|
179
|
+
stdout.write(CURSOR_SHOW + ALT_SCREEN_OFF);
|
|
180
|
+
if (stdin.isTTY) stdin.setRawMode(false);
|
|
181
|
+
stdin.pause();
|
|
182
|
+
stdin.removeListener("data", onData);
|
|
183
|
+
}
|
|
184
|
+
function onData(data) {
|
|
185
|
+
const filtered = getFiltered();
|
|
186
|
+
for (let i = 0; i < data.length; i++) {
|
|
187
|
+
const ch = data[i];
|
|
188
|
+
if (ch === ESC) {
|
|
189
|
+
if (data[i + 1] === "[") {
|
|
190
|
+
const arrow = data[i + 2];
|
|
191
|
+
if (arrow === "A") {
|
|
192
|
+
if (selectedIndex > 0) selectedIndex--;
|
|
193
|
+
i += 2;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (arrow === "B") {
|
|
197
|
+
if (selectedIndex < filtered.length - 1) selectedIndex++;
|
|
198
|
+
i += 2;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
i += 2;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
cleanup();
|
|
205
|
+
resolve();
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (ch === "") {
|
|
209
|
+
cleanup();
|
|
210
|
+
resolve();
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (ch === " ") {
|
|
214
|
+
activeTab = (activeTab + 1) % datasets.length;
|
|
215
|
+
query = "";
|
|
216
|
+
selectedIndex = 0;
|
|
217
|
+
scrollOffset = 0;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (ch === "\r" || ch === "\n") {
|
|
221
|
+
cleanup();
|
|
222
|
+
const selected = getFiltered()[selectedIndex];
|
|
223
|
+
if (selected) {
|
|
224
|
+
process.stdout.write(`
|
|
225
|
+
Selected: ${selected.task.id} - ${selected.task.title}
|
|
226
|
+
`);
|
|
227
|
+
}
|
|
228
|
+
resolve();
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (ch === "\x7F" || ch === "\b") {
|
|
232
|
+
if (query.length > 0) {
|
|
233
|
+
query = query.slice(0, -1);
|
|
234
|
+
selectedIndex = 0;
|
|
235
|
+
scrollOffset = 0;
|
|
236
|
+
}
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (ch >= " " && ch <= "~") {
|
|
240
|
+
query += ch;
|
|
241
|
+
selectedIndex = 0;
|
|
242
|
+
scrollOffset = 0;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
render();
|
|
246
|
+
}
|
|
247
|
+
stdin.on("data", onData);
|
|
248
|
+
stdout.on("resize", () => render());
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export { interactiveList };
|
package/dist/cli.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { defineCommand, runMain } from 'citty';
|
|
3
3
|
import consola from 'consola';
|
|
4
|
-
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, unlinkSync, statSync } from 'node:fs';
|
|
5
|
-
import { join, dirname } from 'node:path';
|
|
4
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, unlinkSync, renameSync, statSync } from 'node:fs';
|
|
5
|
+
import { join, basename, dirname } from 'node:path';
|
|
6
6
|
import { stringify, parse as parse$1 } from 'smol-toml';
|
|
7
7
|
import matter from 'gray-matter';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
@@ -20,7 +20,8 @@ function loadConfig(dir) {
|
|
|
20
20
|
const parsed = parse$1(raw);
|
|
21
21
|
return {
|
|
22
22
|
prefix: parsed.prefix,
|
|
23
|
-
tasks_dir: parsed.tasks_dir ?? ".tasks"
|
|
23
|
+
tasks_dir: parsed.tasks_dir ?? ".tasks",
|
|
24
|
+
verbose_files: parsed.verbose_files ?? false
|
|
24
25
|
};
|
|
25
26
|
}
|
|
26
27
|
function initConfig(dir, opts) {
|
|
@@ -29,7 +30,8 @@ function initConfig(dir, opts) {
|
|
|
29
30
|
if (!existsSync(configPath)) {
|
|
30
31
|
const config2 = {
|
|
31
32
|
prefix: opts?.prefix ?? "TASK",
|
|
32
|
-
tasks_dir: opts?.tasks_dir ?? ".tasks"
|
|
33
|
+
tasks_dir: opts?.tasks_dir ?? ".tasks",
|
|
34
|
+
verbose_files: opts?.verbose_files ?? false
|
|
33
35
|
};
|
|
34
36
|
mkdirSync(configDir, { recursive: true });
|
|
35
37
|
writeFileSync(configPath, stringify(config2));
|
|
@@ -78,6 +80,7 @@ function nextId(dir) {
|
|
|
78
80
|
function render(task) {
|
|
79
81
|
const frontmatter = {
|
|
80
82
|
id: task.id,
|
|
83
|
+
title: task.title,
|
|
81
84
|
type: task.type,
|
|
82
85
|
status: task.status,
|
|
83
86
|
created: task.created
|
|
@@ -97,8 +100,12 @@ function render(task) {
|
|
|
97
100
|
}
|
|
98
101
|
function parse(content, filename) {
|
|
99
102
|
const { data, content: body } = matter(content);
|
|
100
|
-
const
|
|
101
|
-
|
|
103
|
+
const title = data.title;
|
|
104
|
+
if (!title) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
"Task file missing title in frontmatter. Run 'mrkl migrate_prior_verbose' to fix."
|
|
107
|
+
);
|
|
108
|
+
}
|
|
102
109
|
const acRegex = /^- \[[ x]\] (.+)$/gm;
|
|
103
110
|
const acceptance_criteria = [];
|
|
104
111
|
let match;
|
|
@@ -137,7 +144,7 @@ function createTask(opts) {
|
|
|
137
144
|
description: opts.description ?? "",
|
|
138
145
|
acceptance_criteria: opts.acceptance_criteria ?? []
|
|
139
146
|
};
|
|
140
|
-
const filename = `${id} ${task.type} - ${task.title}.md`;
|
|
147
|
+
const filename = config.verbose_files ? `${id} ${task.type} - ${task.title}.md` : `${id}.md`;
|
|
141
148
|
const tasksDir = join(opts.dir, config.tasks_dir);
|
|
142
149
|
writeFileSync(join(tasksDir, filename), render(task));
|
|
143
150
|
return task;
|
|
@@ -172,7 +179,7 @@ function archiveTask(dir, id) {
|
|
|
172
179
|
}
|
|
173
180
|
const filePath = join(tasksDir, file);
|
|
174
181
|
const content = readFileSync(filePath, "utf-8");
|
|
175
|
-
const task = parse(content
|
|
182
|
+
const task = parse(content);
|
|
176
183
|
task.status = "done";
|
|
177
184
|
const archivePath = join(tasksDir, ".archive", file);
|
|
178
185
|
writeFileSync(archivePath, render(task));
|
|
@@ -224,6 +231,25 @@ function executePrune(dir, filenames) {
|
|
|
224
231
|
unlinkSync(join(archiveDir, f));
|
|
225
232
|
}
|
|
226
233
|
}
|
|
234
|
+
function listArchivedTasks(filter) {
|
|
235
|
+
const config = loadConfig(filter.dir);
|
|
236
|
+
const archiveDir = join(filter.dir, config.tasks_dir, ".archive");
|
|
237
|
+
if (!existsSync(archiveDir)) return [];
|
|
238
|
+
const files = readdirSync(archiveDir).filter((f) => f.endsWith(".md") && !f.startsWith("."));
|
|
239
|
+
let tasks = files.flatMap((f) => {
|
|
240
|
+
try {
|
|
241
|
+
const content = readFileSync(join(archiveDir, f), "utf-8");
|
|
242
|
+
const task = parse(content, f);
|
|
243
|
+
if (!task.id || !task.type || !task.status) return [];
|
|
244
|
+
return [task];
|
|
245
|
+
} catch {
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
if (filter.type) tasks = tasks.filter((t) => t.type === filter.type);
|
|
250
|
+
if (filter.status) tasks = tasks.filter((t) => t.status === filter.status);
|
|
251
|
+
return tasks;
|
|
252
|
+
}
|
|
227
253
|
function closeTask(dir, id) {
|
|
228
254
|
const config = loadConfig(dir);
|
|
229
255
|
const tasksDir = join(dir, config.tasks_dir);
|
|
@@ -236,7 +262,7 @@ function closeTask(dir, id) {
|
|
|
236
262
|
}
|
|
237
263
|
const filePath = join(tasksDir, file);
|
|
238
264
|
const content = readFileSync(filePath, "utf-8");
|
|
239
|
-
const task = parse(content
|
|
265
|
+
const task = parse(content);
|
|
240
266
|
task.status = "closed";
|
|
241
267
|
const archivePath = join(tasksDir, ".archive", file);
|
|
242
268
|
writeFileSync(archivePath, render(task));
|
|
@@ -282,7 +308,7 @@ async function promptForTask(dir) {
|
|
|
282
308
|
criteria.length === 0 ? "Acceptance criterion (Esc to skip)" : `Criterion #${criteria.length + 1} (Esc to finish)`,
|
|
283
309
|
{ type: "text" }
|
|
284
310
|
);
|
|
285
|
-
if (typeof ac
|
|
311
|
+
if (typeof ac !== "string") break;
|
|
286
312
|
if (ac.trim()) criteria.push(ac.trim());
|
|
287
313
|
}
|
|
288
314
|
return {
|
|
@@ -350,6 +376,12 @@ const createCommand = defineCommand({
|
|
|
350
376
|
}
|
|
351
377
|
});
|
|
352
378
|
|
|
379
|
+
const COL_ID = 14;
|
|
380
|
+
const COL_TYPE = 12;
|
|
381
|
+
const COL_STATUS = 14;
|
|
382
|
+
function formatRow(id, type, status, title) {
|
|
383
|
+
return `${id.padEnd(COL_ID)}${type.padEnd(COL_TYPE)}${status.padEnd(COL_STATUS)}${title}`;
|
|
384
|
+
}
|
|
353
385
|
const listCommand = defineCommand({
|
|
354
386
|
meta: {
|
|
355
387
|
name: "list",
|
|
@@ -365,23 +397,46 @@ const listCommand = defineCommand({
|
|
|
365
397
|
type: "string",
|
|
366
398
|
alias: "s",
|
|
367
399
|
description: "Filter by status (todo, in-progress, done)"
|
|
400
|
+
},
|
|
401
|
+
plain: {
|
|
402
|
+
type: "boolean",
|
|
403
|
+
alias: "p",
|
|
404
|
+
description: "Plain text output (no interactive TUI)"
|
|
368
405
|
}
|
|
369
406
|
},
|
|
370
|
-
run({ args }) {
|
|
407
|
+
async run({ args }) {
|
|
371
408
|
const dir = process.cwd();
|
|
372
409
|
try {
|
|
373
|
-
const
|
|
410
|
+
const filter = {
|
|
374
411
|
dir,
|
|
375
412
|
type: args.type,
|
|
376
413
|
status: args.status
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
|
|
414
|
+
};
|
|
415
|
+
const tasks = listTasks(filter);
|
|
416
|
+
const archivedTasks = listArchivedTasks(filter);
|
|
417
|
+
if (tasks.length === 0 && archivedTasks.length === 0) {
|
|
418
|
+
consola.info("No tasks found");
|
|
380
419
|
return;
|
|
381
420
|
}
|
|
382
|
-
|
|
383
|
-
|
|
421
|
+
const usePlain = args.plain || !process.stdout.isTTY;
|
|
422
|
+
if (usePlain) {
|
|
423
|
+
consola.log(formatRow("ID", "TYPE", "STATUS", "TITLE"));
|
|
424
|
+
consola.log("\u2500".repeat(60));
|
|
425
|
+
for (const task of tasks) {
|
|
426
|
+
consola.log(formatRow(task.id, task.type, task.status, task.title));
|
|
427
|
+
}
|
|
428
|
+
if (archivedTasks.length > 0) {
|
|
429
|
+
consola.log("");
|
|
430
|
+
consola.log(`Archive (${archivedTasks.length}):`);
|
|
431
|
+
consola.log("\u2500".repeat(60));
|
|
432
|
+
for (const task of archivedTasks) {
|
|
433
|
+
consola.log(formatRow(task.id, task.type, task.status, task.title));
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return;
|
|
384
437
|
}
|
|
438
|
+
const { interactiveList } = await import('./chunks/list-tui.mjs');
|
|
439
|
+
await interactiveList(tasks, archivedTasks);
|
|
385
440
|
} catch (err) {
|
|
386
441
|
consola.error(String(err.message));
|
|
387
442
|
process.exit(1);
|
|
@@ -487,6 +542,90 @@ const closeCommand = defineCommand({
|
|
|
487
542
|
}
|
|
488
543
|
});
|
|
489
544
|
|
|
545
|
+
const VERBOSE_REGEX = /^(\S+)\s+(\S+)\s+-\s+(.+)$/;
|
|
546
|
+
function migrateDir(dirPath, verboseFiles) {
|
|
547
|
+
let migrated = 0;
|
|
548
|
+
let skipped = 0;
|
|
549
|
+
const warnings = [];
|
|
550
|
+
let files;
|
|
551
|
+
try {
|
|
552
|
+
files = readdirSync(dirPath).filter((f) => f.endsWith(".md") && !f.startsWith("."));
|
|
553
|
+
} catch {
|
|
554
|
+
return { migrated, skipped, warnings };
|
|
555
|
+
}
|
|
556
|
+
for (const file of files) {
|
|
557
|
+
const filePath = join(dirPath, file);
|
|
558
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
559
|
+
const { data, content: body } = matter(raw);
|
|
560
|
+
if (data.title) {
|
|
561
|
+
skipped++;
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
const stem = basename(file, ".md");
|
|
565
|
+
const match = stem.match(VERBOSE_REGEX);
|
|
566
|
+
if (!match) {
|
|
567
|
+
warnings.push(`Could not extract title from filename: ${file}`);
|
|
568
|
+
skipped++;
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
const title = match[3];
|
|
572
|
+
const task = {
|
|
573
|
+
id: data.id,
|
|
574
|
+
type: data.type,
|
|
575
|
+
status: data.status,
|
|
576
|
+
created: data.created instanceof Date ? data.created.toISOString().slice(0, 10) : String(data.created),
|
|
577
|
+
title,
|
|
578
|
+
description: "",
|
|
579
|
+
acceptance_criteria: []
|
|
580
|
+
};
|
|
581
|
+
const descMatch = body.match(/## Description\n\n([\s\S]*?)(?:\n\n## Acceptance Criteria|$)/);
|
|
582
|
+
task.description = descMatch ? descMatch[1].trim() : "";
|
|
583
|
+
const acRegex = /^- \[[ x]\] (.+)$/gm;
|
|
584
|
+
let acMatch;
|
|
585
|
+
while ((acMatch = acRegex.exec(body)) !== null) {
|
|
586
|
+
task.acceptance_criteria.push(acMatch[1]);
|
|
587
|
+
}
|
|
588
|
+
writeFileSync(filePath, render(task));
|
|
589
|
+
if (!verboseFiles) {
|
|
590
|
+
const newName = `${data.id}.md`;
|
|
591
|
+
if (file !== newName) {
|
|
592
|
+
renameSync(filePath, join(dirPath, newName));
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
migrated++;
|
|
596
|
+
}
|
|
597
|
+
return { migrated, skipped, warnings };
|
|
598
|
+
}
|
|
599
|
+
const migrateCommand = defineCommand({
|
|
600
|
+
meta: {
|
|
601
|
+
name: "migrate",
|
|
602
|
+
description: "Migrate task files to include title in frontmatter"
|
|
603
|
+
},
|
|
604
|
+
run() {
|
|
605
|
+
const dir = process.cwd();
|
|
606
|
+
try {
|
|
607
|
+
const config = loadConfig(dir);
|
|
608
|
+
const tasksDir = join(dir, config.tasks_dir);
|
|
609
|
+
const archiveDir = join(tasksDir, ".archive");
|
|
610
|
+
const active = migrateDir(tasksDir, config.verbose_files);
|
|
611
|
+
const archived = migrateDir(archiveDir, config.verbose_files);
|
|
612
|
+
const totalMigrated = active.migrated + archived.migrated;
|
|
613
|
+
const totalSkipped = active.skipped + archived.skipped;
|
|
614
|
+
const allWarnings = [...active.warnings, ...archived.warnings];
|
|
615
|
+
consola.success(`Migrated ${totalMigrated} file(s), skipped ${totalSkipped} file(s).`);
|
|
616
|
+
for (const w of allWarnings) {
|
|
617
|
+
consola.warn(w);
|
|
618
|
+
}
|
|
619
|
+
if (totalMigrated > 0) {
|
|
620
|
+
consola.info("Breaking change: title is now stored in frontmatter. Old parsers may need updating.");
|
|
621
|
+
}
|
|
622
|
+
} catch (err) {
|
|
623
|
+
consola.error(String(err.message));
|
|
624
|
+
process.exit(1);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
|
|
490
629
|
function findPackageRoot() {
|
|
491
630
|
let dir = dirname(fileURLToPath(import.meta.url));
|
|
492
631
|
while (dir !== dirname(dir)) {
|
|
@@ -563,6 +702,7 @@ const main = defineCommand({
|
|
|
563
702
|
p: pruneCommand,
|
|
564
703
|
close: closeCommand,
|
|
565
704
|
x: closeCommand,
|
|
705
|
+
migrate_prior_verbose: migrateCommand,
|
|
566
706
|
"install-skills": installSkillsCommand
|
|
567
707
|
}
|
|
568
708
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xxkeefer/mrkl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Lightweight CLI tool for structured markdown task tracking",
|
|
6
6
|
"bin": {
|
|
@@ -28,19 +28,21 @@
|
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"citty": "^0.1.6",
|
|
30
30
|
"consola": "^3.4.0",
|
|
31
|
+
"fzf": "^0.5.2",
|
|
31
32
|
"gray-matter": "^4.0.3",
|
|
32
33
|
"smol-toml": "^1.3.1"
|
|
33
34
|
},
|
|
34
35
|
"devDependencies": {
|
|
35
36
|
"@types/node": "^25.3.3",
|
|
36
37
|
"changelogen": "^0.6.2",
|
|
38
|
+
"tsx": "^4.21.0",
|
|
37
39
|
"typescript": "^5.7.0",
|
|
38
40
|
"unbuild": "^3.3.1",
|
|
39
41
|
"vitest": "^3.0.0"
|
|
40
42
|
},
|
|
41
43
|
"scripts": {
|
|
42
44
|
"build": "unbuild",
|
|
43
|
-
"dev": "
|
|
45
|
+
"dev": "tsx src/cli.ts",
|
|
44
46
|
"test": "vitest run",
|
|
45
47
|
"changelog": "changelogen --output",
|
|
46
48
|
"changelog:preview": "changelogen --no-output"
|