task-memory 0.3.0
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/LICENSE +21 -0
- package/README.md +158 -0
- package/dist/index.js +2708 -0
- package/package.json +39 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2708 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/store.ts
|
|
4
|
+
import { join, resolve, dirname } from "path";
|
|
5
|
+
import { homedir } from "os";
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync, statSync } from "fs";
|
|
7
|
+
|
|
8
|
+
// src/utils/orderUtils.ts
|
|
9
|
+
function parseOrder(order) {
|
|
10
|
+
if (!order || order === "") {
|
|
11
|
+
return [];
|
|
12
|
+
}
|
|
13
|
+
return order.split("-").map((s) => parseFloat(s));
|
|
14
|
+
}
|
|
15
|
+
function formatOrder(parts) {
|
|
16
|
+
if (parts.length === 0) {
|
|
17
|
+
return "";
|
|
18
|
+
}
|
|
19
|
+
return parts.map((n) => String(n)).join("-");
|
|
20
|
+
}
|
|
21
|
+
function compareOrders(a, b) {
|
|
22
|
+
if (a == null && b == null)
|
|
23
|
+
return 0;
|
|
24
|
+
if (a == null)
|
|
25
|
+
return 1;
|
|
26
|
+
if (b == null)
|
|
27
|
+
return -1;
|
|
28
|
+
const partsA = parseOrder(a);
|
|
29
|
+
const partsB = parseOrder(b);
|
|
30
|
+
const maxLen = Math.max(partsA.length, partsB.length);
|
|
31
|
+
for (let i = 0;i < maxLen; i++) {
|
|
32
|
+
if (i >= partsA.length)
|
|
33
|
+
return -1;
|
|
34
|
+
if (i >= partsB.length)
|
|
35
|
+
return 1;
|
|
36
|
+
if (partsA[i] !== partsB[i]) {
|
|
37
|
+
return partsA[i] - partsB[i];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
function normalizeOrders(orders, tiebreakers) {
|
|
43
|
+
const indexed = orders.map((order, index) => ({ order, index }));
|
|
44
|
+
const nonNull = indexed.filter((item) => item.order != null && item.order !== "");
|
|
45
|
+
if (nonNull.length === 0) {
|
|
46
|
+
return orders.map((o) => o == null ? null : o);
|
|
47
|
+
}
|
|
48
|
+
const parsed = nonNull.map((item) => ({
|
|
49
|
+
index: item.index,
|
|
50
|
+
parts: parseOrder(item.order),
|
|
51
|
+
originalOrder: item.order
|
|
52
|
+
}));
|
|
53
|
+
parsed.sort((a, b) => {
|
|
54
|
+
const orderCmp = compareOrders(a.originalOrder, b.originalOrder);
|
|
55
|
+
if (orderCmp !== 0)
|
|
56
|
+
return orderCmp;
|
|
57
|
+
if (tiebreakers) {
|
|
58
|
+
const prioA = tiebreakers[a.index] ?? 0;
|
|
59
|
+
const prioB = tiebreakers[b.index] ?? 0;
|
|
60
|
+
return prioB - prioA;
|
|
61
|
+
}
|
|
62
|
+
return 0;
|
|
63
|
+
});
|
|
64
|
+
const explicitCountBySlot = new Map;
|
|
65
|
+
const implicitOnlySlots = new Set;
|
|
66
|
+
for (const item of parsed) {
|
|
67
|
+
const parentKey = item.parts.length === 1 ? "" : formatOrder(item.parts.slice(0, -1));
|
|
68
|
+
const childNum = item.parts[item.parts.length - 1];
|
|
69
|
+
const slotKey = `${parentKey}::${childNum}`;
|
|
70
|
+
explicitCountBySlot.set(slotKey, (explicitCountBySlot.get(slotKey) ?? 0) + 1);
|
|
71
|
+
for (let len = 1;len < item.parts.length; len++) {
|
|
72
|
+
const pKey = len === 1 ? "" : formatOrder(item.parts.slice(0, len - 1));
|
|
73
|
+
const cNum = item.parts[len - 1];
|
|
74
|
+
const implicitKey = `${pKey}::${cNum}`;
|
|
75
|
+
if (!explicitCountBySlot.has(implicitKey)) {
|
|
76
|
+
implicitOnlySlots.add(implicitKey);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const slotsByParent = new Map;
|
|
81
|
+
for (const [slotKey, count] of explicitCountBySlot.entries()) {
|
|
82
|
+
const sep = slotKey.lastIndexOf("::");
|
|
83
|
+
const pKey = slotKey.slice(0, sep);
|
|
84
|
+
const cNum = parseFloat(slotKey.slice(sep + 2));
|
|
85
|
+
if (!slotsByParent.has(pKey))
|
|
86
|
+
slotsByParent.set(pKey, new Map);
|
|
87
|
+
slotsByParent.get(pKey).set(cNum, count);
|
|
88
|
+
}
|
|
89
|
+
for (const slotKey of implicitOnlySlots) {
|
|
90
|
+
const sep = slotKey.lastIndexOf("::");
|
|
91
|
+
const pKey = slotKey.slice(0, sep);
|
|
92
|
+
const cNum = parseFloat(slotKey.slice(sep + 2));
|
|
93
|
+
if (!slotsByParent.has(pKey))
|
|
94
|
+
slotsByParent.set(pKey, new Map);
|
|
95
|
+
if (!slotsByParent.get(pKey).has(cNum)) {
|
|
96
|
+
slotsByParent.get(pKey).set(cNum, 0);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const renumberMap = new Map;
|
|
100
|
+
for (const [pKey, slots] of slotsByParent.entries()) {
|
|
101
|
+
const sorted = Array.from(slots.keys()).sort((a, b) => a - b);
|
|
102
|
+
const mapping = new Map;
|
|
103
|
+
let newNum = 1;
|
|
104
|
+
for (const oldNum of sorted) {
|
|
105
|
+
const count = slots.get(oldNum);
|
|
106
|
+
const newNums = [];
|
|
107
|
+
const occurrences = count === 0 ? 1 : count;
|
|
108
|
+
for (let i = 0;i < occurrences; i++) {
|
|
109
|
+
newNums.push(newNum++);
|
|
110
|
+
}
|
|
111
|
+
mapping.set(oldNum, newNums);
|
|
112
|
+
}
|
|
113
|
+
renumberMap.set(pKey, mapping);
|
|
114
|
+
}
|
|
115
|
+
const normalizedMap = new Map;
|
|
116
|
+
const occurrenceTracker = new Map;
|
|
117
|
+
for (const item of parsed) {
|
|
118
|
+
const newParts = [];
|
|
119
|
+
for (let i = 0;i < item.parts.length; i++) {
|
|
120
|
+
const originalParentKey = i === 0 ? "" : formatOrder(item.parts.slice(0, i));
|
|
121
|
+
const oldNum = item.parts[i];
|
|
122
|
+
const mapping = renumberMap.get(originalParentKey);
|
|
123
|
+
if (!mapping) {
|
|
124
|
+
throw new Error(`Internal error: no renumbering map for parent ${originalParentKey}`);
|
|
125
|
+
}
|
|
126
|
+
const newNums = mapping.get(oldNum);
|
|
127
|
+
if (newNums === undefined || newNums.length === 0) {
|
|
128
|
+
throw new Error(`Internal error: no new number for ${oldNum} in parent ${originalParentKey}`);
|
|
129
|
+
}
|
|
130
|
+
const isLeaf = i === item.parts.length - 1;
|
|
131
|
+
if (isLeaf && newNums.length > 1) {
|
|
132
|
+
const trackerKey = `${originalParentKey}::${oldNum}`;
|
|
133
|
+
const occIdx = occurrenceTracker.get(trackerKey) ?? 0;
|
|
134
|
+
occurrenceTracker.set(trackerKey, occIdx + 1);
|
|
135
|
+
newParts.push(newNums[occIdx] ?? newNums[newNums.length - 1]);
|
|
136
|
+
} else {
|
|
137
|
+
newParts.push(newNums[0]);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
normalizedMap.set(item.index, formatOrder(newParts));
|
|
141
|
+
}
|
|
142
|
+
return orders.map((o, index) => {
|
|
143
|
+
if (o == null)
|
|
144
|
+
return null;
|
|
145
|
+
return normalizedMap.get(index) ?? o;
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
function sortByOrder(items, getOrder, getId) {
|
|
149
|
+
return [...items].sort((a, b) => {
|
|
150
|
+
const orderA = getOrder(a);
|
|
151
|
+
const orderB = getOrder(b);
|
|
152
|
+
const cmp = compareOrders(orderA, orderB);
|
|
153
|
+
if (cmp !== 0)
|
|
154
|
+
return cmp;
|
|
155
|
+
const idA = getId(a);
|
|
156
|
+
const idB = getId(b);
|
|
157
|
+
const numA = parseInt(idA.replace(/\D/g, ""), 10) || 0;
|
|
158
|
+
const numB = parseInt(idB.replace(/\D/g, ""), 10) || 0;
|
|
159
|
+
return numA - numB;
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// src/store.ts
|
|
164
|
+
function findGitPath(startDir) {
|
|
165
|
+
let currentDir = startDir;
|
|
166
|
+
const home = homedir();
|
|
167
|
+
while (true) {
|
|
168
|
+
const gitPath = join(currentDir, ".git");
|
|
169
|
+
if (existsSync(gitPath)) {
|
|
170
|
+
return gitPath;
|
|
171
|
+
}
|
|
172
|
+
if (currentDir === home) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
const parentDir = dirname(currentDir);
|
|
176
|
+
if (parentDir === currentDir) {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
currentDir = parentDir;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function getDbPath() {
|
|
183
|
+
if (process.env.TASK_MEMORY_PATH) {
|
|
184
|
+
return resolve(process.cwd(), process.env.TASK_MEMORY_PATH);
|
|
185
|
+
}
|
|
186
|
+
const gitPath = findGitPath(process.cwd());
|
|
187
|
+
if (gitPath) {
|
|
188
|
+
let isGitDir = false;
|
|
189
|
+
try {
|
|
190
|
+
isGitDir = statSync(gitPath).isDirectory();
|
|
191
|
+
} catch {}
|
|
192
|
+
if (isGitDir) {
|
|
193
|
+
const gitTaskMemory = join(gitPath, "task-memory.json");
|
|
194
|
+
if (existsSync(gitTaskMemory)) {
|
|
195
|
+
return gitTaskMemory;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const projectDir = dirname(gitPath);
|
|
199
|
+
const taskMemoryPath = join(projectDir, "task-memory.json");
|
|
200
|
+
if (existsSync(taskMemoryPath)) {
|
|
201
|
+
return taskMemoryPath;
|
|
202
|
+
}
|
|
203
|
+
const hiddenPath = join(projectDir, ".task-memory.json");
|
|
204
|
+
if (existsSync(hiddenPath)) {
|
|
205
|
+
return hiddenPath;
|
|
206
|
+
}
|
|
207
|
+
if (isGitDir) {
|
|
208
|
+
return join(gitPath, "task-memory.json");
|
|
209
|
+
}
|
|
210
|
+
return taskMemoryPath;
|
|
211
|
+
}
|
|
212
|
+
return join(homedir(), ".task-memory.json");
|
|
213
|
+
}
|
|
214
|
+
var DB_PATH = getDbPath();
|
|
215
|
+
var cachedStore = null;
|
|
216
|
+
var afterSaveCallback = null;
|
|
217
|
+
function setAfterSaveCallback(callback) {
|
|
218
|
+
afterSaveCallback = callback;
|
|
219
|
+
}
|
|
220
|
+
function loadStore() {
|
|
221
|
+
if (!existsSync(DB_PATH)) {
|
|
222
|
+
return { tasks: [] };
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
const data = readFileSync(DB_PATH, "utf-8");
|
|
226
|
+
const parsed = JSON.parse(data);
|
|
227
|
+
if (Array.isArray(parsed)) {
|
|
228
|
+
return { tasks: parsed };
|
|
229
|
+
}
|
|
230
|
+
return parsed;
|
|
231
|
+
} catch (e) {
|
|
232
|
+
console.error(`Error loading store from ${DB_PATH}:`, e);
|
|
233
|
+
return { tasks: [] };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
function saveStore(store) {
|
|
237
|
+
try {
|
|
238
|
+
writeFileSync(DB_PATH, JSON.stringify(store, null, 2), "utf-8");
|
|
239
|
+
cachedStore = store;
|
|
240
|
+
if (afterSaveCallback) {
|
|
241
|
+
afterSaveCallback(store);
|
|
242
|
+
}
|
|
243
|
+
} catch (e) {
|
|
244
|
+
console.error(`Error saving store to ${DB_PATH}:`, e);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
function loadTasks() {
|
|
248
|
+
const store = loadStore();
|
|
249
|
+
cachedStore = store;
|
|
250
|
+
return store.tasks;
|
|
251
|
+
}
|
|
252
|
+
function normalizeTaskOrders(tasks) {
|
|
253
|
+
const activeIndices = [];
|
|
254
|
+
const activeOrders = [];
|
|
255
|
+
const activeTiebreakers = [];
|
|
256
|
+
tasks.forEach((task, index) => {
|
|
257
|
+
if (task.status === "todo" || task.status === "wip") {
|
|
258
|
+
activeIndices.push(index);
|
|
259
|
+
activeOrders.push(task.order ?? null);
|
|
260
|
+
activeTiebreakers.push(new Date(task.updated_at || 0).getTime());
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
const normalizedOrders = normalizeOrders(activeOrders, activeTiebreakers);
|
|
264
|
+
const result = tasks.map((task, index) => {
|
|
265
|
+
if (task.status === "todo" || task.status === "wip") {
|
|
266
|
+
const activeIndex = activeIndices.indexOf(index);
|
|
267
|
+
if (activeIndex !== -1) {
|
|
268
|
+
const newOrder = normalizedOrders[activeIndex];
|
|
269
|
+
if (task.order !== newOrder) {
|
|
270
|
+
return { ...task, order: newOrder };
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return task;
|
|
274
|
+
} else {
|
|
275
|
+
if (task.order != null) {
|
|
276
|
+
return { ...task, order: null };
|
|
277
|
+
}
|
|
278
|
+
return task;
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
return result;
|
|
282
|
+
}
|
|
283
|
+
function saveTasks(tasks) {
|
|
284
|
+
const store = cachedStore || loadStore();
|
|
285
|
+
store.tasks = normalizeTaskOrders(tasks);
|
|
286
|
+
saveStore(store);
|
|
287
|
+
}
|
|
288
|
+
function loadSyncConfig() {
|
|
289
|
+
const store = cachedStore || loadStore();
|
|
290
|
+
return store.sync;
|
|
291
|
+
}
|
|
292
|
+
function saveSyncConfig(sync) {
|
|
293
|
+
const store = cachedStore || loadStore();
|
|
294
|
+
store.sync = sync;
|
|
295
|
+
saveStore(store);
|
|
296
|
+
}
|
|
297
|
+
function getTaskById(tasks, idOrIndex) {
|
|
298
|
+
if (typeof idOrIndex === "number") {
|
|
299
|
+
const targetId = `TASK-${idOrIndex}`;
|
|
300
|
+
return tasks.find((t) => t.id === targetId);
|
|
301
|
+
}
|
|
302
|
+
const idStr = idOrIndex.toString();
|
|
303
|
+
if (idStr.match(/^\d+$/)) {
|
|
304
|
+
return tasks.find((t) => t.id === `TASK-${idStr}`);
|
|
305
|
+
}
|
|
306
|
+
return tasks.find((t) => t.id === idStr);
|
|
307
|
+
}
|
|
308
|
+
function getNextId(tasks) {
|
|
309
|
+
let max = 0;
|
|
310
|
+
for (const task of tasks) {
|
|
311
|
+
const match = task.id.match(/^TASK-(\d+)$/);
|
|
312
|
+
if (match) {
|
|
313
|
+
const num = parseInt(match[1], 10);
|
|
314
|
+
if (num > max)
|
|
315
|
+
max = num;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return `TASK-${max + 1}`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// src/utils/taskBuilder.ts
|
|
322
|
+
function parseTaskArgs(args) {
|
|
323
|
+
const summaryParts = [];
|
|
324
|
+
let status = "todo";
|
|
325
|
+
let priority;
|
|
326
|
+
let goal;
|
|
327
|
+
let order;
|
|
328
|
+
const bodies = [];
|
|
329
|
+
const addFiles = [];
|
|
330
|
+
const readFiles = [];
|
|
331
|
+
for (let i = 0;i < args.length; i++) {
|
|
332
|
+
const arg = args[i];
|
|
333
|
+
if (!arg)
|
|
334
|
+
continue;
|
|
335
|
+
if (arg.startsWith("-")) {
|
|
336
|
+
switch (arg) {
|
|
337
|
+
case "--status":
|
|
338
|
+
case "-s":
|
|
339
|
+
const s = args[i + 1];
|
|
340
|
+
if (s && !s.startsWith("-")) {
|
|
341
|
+
if (["todo", "wip", "done", "pending", "long", "closed"].includes(s)) {
|
|
342
|
+
status = s;
|
|
343
|
+
i++;
|
|
344
|
+
} else {
|
|
345
|
+
throw new Error(`Invalid status '${s}'. Allowed: todo, wip, done, pending, long, closed.`);
|
|
346
|
+
}
|
|
347
|
+
} else {
|
|
348
|
+
throw new Error("--status requires a value.");
|
|
349
|
+
}
|
|
350
|
+
break;
|
|
351
|
+
case "--goal":
|
|
352
|
+
case "-g":
|
|
353
|
+
const g = args[i + 1];
|
|
354
|
+
if (g && !g.startsWith("-")) {
|
|
355
|
+
goal = g;
|
|
356
|
+
i++;
|
|
357
|
+
} else {
|
|
358
|
+
throw new Error("--goal requires a value.");
|
|
359
|
+
}
|
|
360
|
+
break;
|
|
361
|
+
case "--priority":
|
|
362
|
+
case "-p":
|
|
363
|
+
const p = args[i + 1];
|
|
364
|
+
if (p && !p.startsWith("-")) {
|
|
365
|
+
priority = p;
|
|
366
|
+
i++;
|
|
367
|
+
} else {
|
|
368
|
+
throw new Error("--priority requires a value.");
|
|
369
|
+
}
|
|
370
|
+
break;
|
|
371
|
+
case "--order":
|
|
372
|
+
case "-o":
|
|
373
|
+
const o = args[i + 1];
|
|
374
|
+
if (o && !o.startsWith("-")) {
|
|
375
|
+
order = o === "null" ? null : o;
|
|
376
|
+
i++;
|
|
377
|
+
} else {
|
|
378
|
+
throw new Error("--order requires a value.");
|
|
379
|
+
}
|
|
380
|
+
break;
|
|
381
|
+
case "--body":
|
|
382
|
+
case "-b":
|
|
383
|
+
const b = args[i + 1];
|
|
384
|
+
if (b && !b.startsWith("-")) {
|
|
385
|
+
bodies.push(b);
|
|
386
|
+
i++;
|
|
387
|
+
} else {
|
|
388
|
+
throw new Error("--body requires a value.");
|
|
389
|
+
}
|
|
390
|
+
break;
|
|
391
|
+
case "--add-file":
|
|
392
|
+
case "-a":
|
|
393
|
+
const af = args[i + 1];
|
|
394
|
+
if (af && !af.startsWith("-")) {
|
|
395
|
+
addFiles.push(af);
|
|
396
|
+
i++;
|
|
397
|
+
} else {
|
|
398
|
+
throw new Error("--add-file requires a path.");
|
|
399
|
+
}
|
|
400
|
+
break;
|
|
401
|
+
case "--read-file":
|
|
402
|
+
case "-r":
|
|
403
|
+
const rf = args[i + 1];
|
|
404
|
+
if (rf && !rf.startsWith("-")) {
|
|
405
|
+
readFiles.push(rf);
|
|
406
|
+
i++;
|
|
407
|
+
} else {
|
|
408
|
+
throw new Error("--read-file requires a path.");
|
|
409
|
+
}
|
|
410
|
+
break;
|
|
411
|
+
default:
|
|
412
|
+
throw new Error(`Unknown option '${arg}'.`);
|
|
413
|
+
}
|
|
414
|
+
} else {
|
|
415
|
+
summaryParts.push(arg);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return {
|
|
419
|
+
summary: summaryParts.join(" ") || undefined,
|
|
420
|
+
status,
|
|
421
|
+
priority,
|
|
422
|
+
goal,
|
|
423
|
+
order,
|
|
424
|
+
bodies,
|
|
425
|
+
addFiles,
|
|
426
|
+
readFiles
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
function buildTask(id, options) {
|
|
430
|
+
const now = new Date().toISOString();
|
|
431
|
+
const status = options.status || "todo";
|
|
432
|
+
const order = status === "todo" || status === "wip" ? options.order ?? null : null;
|
|
433
|
+
return {
|
|
434
|
+
id,
|
|
435
|
+
status,
|
|
436
|
+
priority: options.priority,
|
|
437
|
+
version: options.version || "tbd",
|
|
438
|
+
goal: options.goal,
|
|
439
|
+
order,
|
|
440
|
+
summary: options.summary || "",
|
|
441
|
+
bodies: (options.bodies || []).map((text) => ({ text, created_at: now })),
|
|
442
|
+
files: {
|
|
443
|
+
read: options.readFiles || [],
|
|
444
|
+
edit: options.addFiles || []
|
|
445
|
+
},
|
|
446
|
+
created_at: now,
|
|
447
|
+
updated_at: now
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// src/commands/new.ts
|
|
452
|
+
function newCommand(args) {
|
|
453
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
454
|
+
console.log(`
|
|
455
|
+
Usage: tm new <summary> [options]
|
|
456
|
+
|
|
457
|
+
Options:
|
|
458
|
+
--status, -s <status> Set initial status (todo, wip, done, pending, long, closed)
|
|
459
|
+
--priority, -p <value> Set priority
|
|
460
|
+
--goal, -g <text> Set completion goal
|
|
461
|
+
--order, -o <value> Set progress order (e.g., 1, 1-1, 2-3)
|
|
462
|
+
--body, -b <text> Add initial body text
|
|
463
|
+
--add-file, -a <path> Add editable file
|
|
464
|
+
--read-file, -r <path> Add read-only file
|
|
465
|
+
`);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
let options;
|
|
469
|
+
try {
|
|
470
|
+
options = parseTaskArgs(args);
|
|
471
|
+
} catch (error) {
|
|
472
|
+
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
if (!options.summary) {
|
|
476
|
+
console.error("Error: Task summary is required. Usage: tm new <summary> [options]");
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
const tasks = loadTasks();
|
|
480
|
+
const id = getNextId(tasks);
|
|
481
|
+
const newTask = buildTask(id, options);
|
|
482
|
+
tasks.push(newTask);
|
|
483
|
+
saveTasks(tasks);
|
|
484
|
+
console.log(`${id} ${options.summary}`);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// src/reviewStore.ts
|
|
488
|
+
import { join as join2, dirname as dirname2 } from "path";
|
|
489
|
+
import { homedir as homedir2 } from "os";
|
|
490
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2, statSync as statSync2 } from "fs";
|
|
491
|
+
function getReviewDbPath() {
|
|
492
|
+
if (process.env.REVIEW_MEMORY_PATH) {
|
|
493
|
+
return process.env.REVIEW_MEMORY_PATH;
|
|
494
|
+
}
|
|
495
|
+
const gitPath = findGitPath(process.cwd());
|
|
496
|
+
if (gitPath) {
|
|
497
|
+
let isGitDir = false;
|
|
498
|
+
try {
|
|
499
|
+
isGitDir = statSync2(gitPath).isDirectory();
|
|
500
|
+
} catch {}
|
|
501
|
+
if (isGitDir) {
|
|
502
|
+
return join2(gitPath, "review-memory.json");
|
|
503
|
+
}
|
|
504
|
+
return join2(dirname2(gitPath), "review-memory.json");
|
|
505
|
+
}
|
|
506
|
+
return join2(homedir2(), ".review-memory.json");
|
|
507
|
+
}
|
|
508
|
+
var DB_PATH2 = getReviewDbPath();
|
|
509
|
+
function loadReviews() {
|
|
510
|
+
if (!existsSync2(DB_PATH2)) {
|
|
511
|
+
return [];
|
|
512
|
+
}
|
|
513
|
+
try {
|
|
514
|
+
const data = readFileSync2(DB_PATH2, "utf-8");
|
|
515
|
+
const store = JSON.parse(data);
|
|
516
|
+
if (Array.isArray(store)) {
|
|
517
|
+
return store;
|
|
518
|
+
}
|
|
519
|
+
return store.reviews || [];
|
|
520
|
+
} catch (e) {
|
|
521
|
+
console.error(`Error loading reviews from ${DB_PATH2}:`, e);
|
|
522
|
+
return [];
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
function saveReviews(reviews) {
|
|
526
|
+
try {
|
|
527
|
+
writeFileSync2(DB_PATH2, JSON.stringify(reviews, null, 2), "utf-8");
|
|
528
|
+
} catch (e) {
|
|
529
|
+
console.error(`Error saving reviews to ${DB_PATH2}:`, e);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
function getReviewById(reviews, idOrIndex) {
|
|
533
|
+
if (typeof idOrIndex === "number") {
|
|
534
|
+
const targetId = `REVIEW-${idOrIndex}`;
|
|
535
|
+
return reviews.find((r) => r.id === targetId);
|
|
536
|
+
}
|
|
537
|
+
const idStr = idOrIndex.toString();
|
|
538
|
+
if (idStr.match(/^\d+$/)) {
|
|
539
|
+
return reviews.find((r) => r.id === `REVIEW-${idStr}`);
|
|
540
|
+
}
|
|
541
|
+
return reviews.find((r) => r.id === idStr);
|
|
542
|
+
}
|
|
543
|
+
function getNextReviewId(reviews) {
|
|
544
|
+
let max = 0;
|
|
545
|
+
for (const review of reviews) {
|
|
546
|
+
const match = review.id.match(/^REVIEW-(\d+)$/);
|
|
547
|
+
if (match) {
|
|
548
|
+
const num = parseInt(match[1], 10);
|
|
549
|
+
if (num > max)
|
|
550
|
+
max = num;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return `REVIEW-${max + 1}`;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// src/commands/list.ts
|
|
557
|
+
function listCommand(args = []) {
|
|
558
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
559
|
+
console.log(`
|
|
560
|
+
Usage: tm list [options]
|
|
561
|
+
|
|
562
|
+
Options:
|
|
563
|
+
--status-all, -a Show all tasks (including done/closed)
|
|
564
|
+
--open Show all open tasks (todo, wip, pending, long)
|
|
565
|
+
--priority <p> Filter by priority
|
|
566
|
+
--status, -s <s> Filter by status
|
|
567
|
+
--version <v> Filter by version
|
|
568
|
+
--tbd Filter by version 'tbd' (includes closed/done)
|
|
569
|
+
--released Filter by released tasks (non-tbd version, includes closed/done)
|
|
570
|
+
--sort <key> Sort by: order (default), id, created
|
|
571
|
+
--head [N] Show first N tasks (default: 10)
|
|
572
|
+
--tail [N] Show last N tasks (default: 10)
|
|
573
|
+
`);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
function parseNumericOption(optionName, defaultValue) {
|
|
577
|
+
const index = args.indexOf(optionName);
|
|
578
|
+
if (index === -1)
|
|
579
|
+
return null;
|
|
580
|
+
const nextArg = args[index + 1];
|
|
581
|
+
if (nextArg && !nextArg.startsWith("-")) {
|
|
582
|
+
const parsed = parseInt(nextArg, 10);
|
|
583
|
+
return isNaN(parsed) ? defaultValue : parsed;
|
|
584
|
+
}
|
|
585
|
+
return defaultValue;
|
|
586
|
+
}
|
|
587
|
+
let showAll = false;
|
|
588
|
+
let showOpen = false;
|
|
589
|
+
let filterPriority = null;
|
|
590
|
+
let filterStatus = null;
|
|
591
|
+
let filterVersion = null;
|
|
592
|
+
let released = false;
|
|
593
|
+
let sortBy = "order";
|
|
594
|
+
for (let i = 0;i < args.length; i++) {
|
|
595
|
+
const arg = args[i];
|
|
596
|
+
switch (arg) {
|
|
597
|
+
case "--status-all":
|
|
598
|
+
case "-a":
|
|
599
|
+
showAll = true;
|
|
600
|
+
break;
|
|
601
|
+
case "--open":
|
|
602
|
+
showOpen = true;
|
|
603
|
+
break;
|
|
604
|
+
case "--priority":
|
|
605
|
+
if (i + 1 < args.length && !args[i + 1].startsWith("--")) {
|
|
606
|
+
filterPriority = args[++i];
|
|
607
|
+
} else {
|
|
608
|
+
console.error("Error: --priority requires a value.");
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
break;
|
|
612
|
+
case "--status":
|
|
613
|
+
case "-s":
|
|
614
|
+
if (i + 1 < args.length && !args[i + 1].startsWith("--")) {
|
|
615
|
+
filterStatus = args[++i];
|
|
616
|
+
} else {
|
|
617
|
+
console.error("Error: --status requires a value.");
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
break;
|
|
621
|
+
case "--version":
|
|
622
|
+
if (i + 1 < args.length && !args[i + 1].startsWith("--")) {
|
|
623
|
+
filterVersion = args[++i];
|
|
624
|
+
} else {
|
|
625
|
+
console.error("Error: --version requires a value.");
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
break;
|
|
629
|
+
case "--tbd":
|
|
630
|
+
filterVersion = "tbd";
|
|
631
|
+
showAll = true;
|
|
632
|
+
break;
|
|
633
|
+
case "--released":
|
|
634
|
+
released = true;
|
|
635
|
+
showAll = true;
|
|
636
|
+
break;
|
|
637
|
+
case "--sort":
|
|
638
|
+
if (i + 1 < args.length && !args[i + 1].startsWith("--")) {
|
|
639
|
+
const sortValue = args[++i];
|
|
640
|
+
if (sortValue === "order" || sortValue === "id" || sortValue === "created") {
|
|
641
|
+
sortBy = sortValue;
|
|
642
|
+
} else {
|
|
643
|
+
console.error(`Error: Invalid sort key '${sortValue}'. Allowed: order, id, created.`);
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
} else {
|
|
647
|
+
console.error("Error: --sort requires a value.");
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
break;
|
|
651
|
+
case "--head":
|
|
652
|
+
case "--tail":
|
|
653
|
+
const nextArg = args[i + 1];
|
|
654
|
+
if (nextArg && !nextArg.startsWith("-")) {
|
|
655
|
+
i++;
|
|
656
|
+
}
|
|
657
|
+
break;
|
|
658
|
+
default:
|
|
659
|
+
if (arg.startsWith("--")) {
|
|
660
|
+
console.error(`Error: Unknown option '${arg}'.`);
|
|
661
|
+
return;
|
|
662
|
+
} else if (!arg.match(/^\d+$/)) {
|
|
663
|
+
console.error(`Error: Unknown argument '${arg}'.`);
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
break;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
const headCount = parseNumericOption("--head", 10);
|
|
670
|
+
const tailCount = parseNumericOption("--tail", 10);
|
|
671
|
+
const tasks = loadTasks();
|
|
672
|
+
const activeTasks = tasks.filter((t) => {
|
|
673
|
+
if (filterPriority && t.priority !== filterPriority)
|
|
674
|
+
return false;
|
|
675
|
+
if (filterStatus && t.status !== filterStatus)
|
|
676
|
+
return false;
|
|
677
|
+
if (filterVersion && t.version !== filterVersion)
|
|
678
|
+
return false;
|
|
679
|
+
if (released) {
|
|
680
|
+
if (!t.version || t.version === "tbd" || t.version === "")
|
|
681
|
+
return false;
|
|
682
|
+
}
|
|
683
|
+
if (filterStatus)
|
|
684
|
+
return true;
|
|
685
|
+
if (showAll)
|
|
686
|
+
return true;
|
|
687
|
+
if (t.status === "done" || t.status === "closed")
|
|
688
|
+
return false;
|
|
689
|
+
if (!showOpen && (t.status === "pending" || t.status === "long"))
|
|
690
|
+
return false;
|
|
691
|
+
return true;
|
|
692
|
+
});
|
|
693
|
+
let sortedTasks = activeTasks;
|
|
694
|
+
switch (sortBy) {
|
|
695
|
+
case "order":
|
|
696
|
+
sortedTasks = sortByOrder(activeTasks, (t) => t.order ?? null, (t) => t.id);
|
|
697
|
+
break;
|
|
698
|
+
case "id":
|
|
699
|
+
sortedTasks = [...activeTasks].sort((a, b) => {
|
|
700
|
+
const numA = parseInt(a.id.replace(/\D/g, ""), 10) || 0;
|
|
701
|
+
const numB = parseInt(b.id.replace(/\D/g, ""), 10) || 0;
|
|
702
|
+
return numA - numB;
|
|
703
|
+
});
|
|
704
|
+
break;
|
|
705
|
+
case "created":
|
|
706
|
+
sortedTasks = [...activeTasks].sort((a, b) => {
|
|
707
|
+
return a.created_at.localeCompare(b.created_at);
|
|
708
|
+
});
|
|
709
|
+
break;
|
|
710
|
+
}
|
|
711
|
+
const reviews = loadReviews();
|
|
712
|
+
const checkingReviews = reviews.filter((r) => r.status === "checking");
|
|
713
|
+
let displayTasks = sortedTasks;
|
|
714
|
+
let displayReviews = checkingReviews;
|
|
715
|
+
if (headCount !== null) {
|
|
716
|
+
displayTasks = sortedTasks.slice(0, headCount);
|
|
717
|
+
const remaining = headCount - displayTasks.length;
|
|
718
|
+
displayReviews = remaining > 0 ? checkingReviews.slice(0, remaining) : [];
|
|
719
|
+
} else if (tailCount !== null) {
|
|
720
|
+
const totalLength = sortedTasks.length + checkingReviews.length;
|
|
721
|
+
const startIndex = Math.max(0, totalLength - tailCount);
|
|
722
|
+
const tasksToSkip = Math.min(startIndex, sortedTasks.length);
|
|
723
|
+
displayTasks = sortedTasks.slice(tasksToSkip);
|
|
724
|
+
const reviewsToSkip = Math.max(0, startIndex - sortedTasks.length);
|
|
725
|
+
displayReviews = checkingReviews.slice(reviewsToSkip);
|
|
726
|
+
}
|
|
727
|
+
if (displayTasks.length === 0 && displayReviews.length === 0) {
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
displayTasks.forEach((task) => {
|
|
731
|
+
const match = task.id.match(/^TASK-(\d+)$/);
|
|
732
|
+
const displayId = match ? match[1] : task.id;
|
|
733
|
+
const priorityStr = task.priority ? ` (Priority: ${task.priority})` : "";
|
|
734
|
+
let versionStr = "";
|
|
735
|
+
if (task.version && task.version !== "tbd") {
|
|
736
|
+
versionStr = ` [v:${task.version}]`;
|
|
737
|
+
}
|
|
738
|
+
console.log(`${displayId}: ${task.summary} [${task.status}]${priorityStr}${versionStr}`);
|
|
739
|
+
});
|
|
740
|
+
displayReviews.forEach((review) => {
|
|
741
|
+
console.log(`${review.id}: ${review.title} [${review.status}]`);
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// src/commands/get.ts
|
|
746
|
+
function getCommand(args) {
|
|
747
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
748
|
+
console.log(`
|
|
749
|
+
Usage: tm get <id...> [options]
|
|
750
|
+
|
|
751
|
+
Options:
|
|
752
|
+
--all, -a, --history Show full history of bodies
|
|
753
|
+
`);
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
const showAllHistory = args.includes("--all") || args.includes("-a") || args.includes("--history");
|
|
757
|
+
const ids = [];
|
|
758
|
+
for (const arg of args) {
|
|
759
|
+
if (arg.startsWith("-")) {
|
|
760
|
+
if (arg !== "--all" && arg !== "-a" && arg !== "--history") {
|
|
761
|
+
console.error(`Error: Unknown option '${arg}'.`);
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
} else {
|
|
765
|
+
ids.push(arg);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
if (ids.length === 0) {
|
|
769
|
+
console.error("Error: Task ID is required. Usage: tm get <id...> [options]");
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
const tasks = loadTasks();
|
|
773
|
+
const result = [];
|
|
774
|
+
for (const id of ids) {
|
|
775
|
+
const task = getTaskById(tasks, id);
|
|
776
|
+
if (task) {
|
|
777
|
+
const taskOutput = { ...task };
|
|
778
|
+
if (!showAllHistory && task.bodies.length > 0) {
|
|
779
|
+
const lastBody = task.bodies[task.bodies.length - 1];
|
|
780
|
+
if (lastBody) {
|
|
781
|
+
taskOutput.bodies = [lastBody];
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
result.push(taskOutput);
|
|
785
|
+
} else {
|
|
786
|
+
console.error(`Error: ID '${id}' not found.`);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
console.log(JSON.stringify(result, null, 2));
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// src/commands/finish.ts
|
|
793
|
+
function finishCommand(args) {
|
|
794
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
795
|
+
console.log(`
|
|
796
|
+
Usage: tm finish <id...> [options]
|
|
797
|
+
|
|
798
|
+
Options:
|
|
799
|
+
--body <body> Add a closing comment
|
|
800
|
+
`);
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
if (args.length === 0) {
|
|
804
|
+
console.error("Error: Task ID is required. Usage: tm finish <id...> [--body <body>]");
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
const tasks = loadTasks();
|
|
808
|
+
let currentTargetIds = [];
|
|
809
|
+
let updated = false;
|
|
810
|
+
let lastActionWasOption = false;
|
|
811
|
+
for (let i = 0;i < args.length; i++) {
|
|
812
|
+
const arg = args[i];
|
|
813
|
+
if (!arg)
|
|
814
|
+
continue;
|
|
815
|
+
if (arg.startsWith("--")) {
|
|
816
|
+
lastActionWasOption = true;
|
|
817
|
+
if (arg === "--body") {
|
|
818
|
+
if (currentTargetIds.length === 0) {
|
|
819
|
+
console.error("Error: --body must be preceded by at least one task ID.");
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
const bodyText = args[++i];
|
|
823
|
+
if (bodyText) {
|
|
824
|
+
const now = new Date().toISOString();
|
|
825
|
+
for (const id of currentTargetIds) {
|
|
826
|
+
const task = getTaskById(tasks, id);
|
|
827
|
+
if (task) {
|
|
828
|
+
task.bodies.push({ text: bodyText, created_at: now });
|
|
829
|
+
task.updated_at = now;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
} else {
|
|
833
|
+
console.error("Error: --body requires a text argument.");
|
|
834
|
+
}
|
|
835
|
+
} else {
|
|
836
|
+
console.error(`Error: Unknown option '${arg}'.`);
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
} else {
|
|
840
|
+
if (lastActionWasOption) {
|
|
841
|
+
currentTargetIds = [];
|
|
842
|
+
lastActionWasOption = false;
|
|
843
|
+
}
|
|
844
|
+
const id = arg;
|
|
845
|
+
const task = getTaskById(tasks, id);
|
|
846
|
+
if (task) {
|
|
847
|
+
if (task.status !== "done") {
|
|
848
|
+
task.status = "done";
|
|
849
|
+
task.updated_at = new Date().toISOString();
|
|
850
|
+
console.log(`Task ${task.id} marked as done.`);
|
|
851
|
+
updated = true;
|
|
852
|
+
} else {
|
|
853
|
+
console.log(`Task ${task.id} is already done.`);
|
|
854
|
+
}
|
|
855
|
+
currentTargetIds.push(id);
|
|
856
|
+
} else {
|
|
857
|
+
console.error(`Error: ID '${id}' not found.`);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
if (updated) {
|
|
862
|
+
saveTasks(tasks);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// src/commands/update.ts
|
|
867
|
+
function updateCommand(args) {
|
|
868
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
869
|
+
console.log(`
|
|
870
|
+
Usage: tm update <id...> [options]
|
|
871
|
+
|
|
872
|
+
Options:
|
|
873
|
+
--status, -s <status> Update status (todo, wip, done, pending, long, closed)
|
|
874
|
+
--priority, -p <value> Update priority
|
|
875
|
+
--version, -v <value> Update version
|
|
876
|
+
--goal, -g <text> Update completion goal
|
|
877
|
+
--order, -o <value> Update progress order (use 'null' to clear)
|
|
878
|
+
--body, -b <text> Append body text
|
|
879
|
+
--add-file, -a <path> Add editable file
|
|
880
|
+
--rm-file, -d <path> Remove editable file
|
|
881
|
+
--read-file, -r <path> Add read-only file
|
|
882
|
+
`);
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
const tasks = loadTasks();
|
|
886
|
+
let currentTargetIds = [];
|
|
887
|
+
let updated = false;
|
|
888
|
+
let lastActionWasOption = false;
|
|
889
|
+
const applyUpdate = (action) => {
|
|
890
|
+
if (currentTargetIds.length === 0) {
|
|
891
|
+
console.error("Error: No task ID specified for update. Usage: tm update <id> [options] ...");
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
for (const id of currentTargetIds) {
|
|
895
|
+
const task = getTaskById(tasks, id);
|
|
896
|
+
if (task) {
|
|
897
|
+
action(task);
|
|
898
|
+
task.updated_at = new Date().toISOString();
|
|
899
|
+
updated = true;
|
|
900
|
+
} else {
|
|
901
|
+
console.error(`Error: ID '${id}' not found.`);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
};
|
|
905
|
+
for (let i = 0;i < args.length; i++) {
|
|
906
|
+
const arg = args[i];
|
|
907
|
+
if (!arg)
|
|
908
|
+
continue;
|
|
909
|
+
if (arg.startsWith("-")) {
|
|
910
|
+
lastActionWasOption = true;
|
|
911
|
+
switch (arg) {
|
|
912
|
+
case "--status":
|
|
913
|
+
case "-s":
|
|
914
|
+
const status = args[++i];
|
|
915
|
+
if (status && ["todo", "wip", "done", "pending", "long", "closed"].includes(status)) {
|
|
916
|
+
applyUpdate((t) => {
|
|
917
|
+
t.status = status;
|
|
918
|
+
if (status !== "todo" && status !== "wip") {
|
|
919
|
+
t.order = null;
|
|
920
|
+
}
|
|
921
|
+
});
|
|
922
|
+
} else {
|
|
923
|
+
console.error(`Error: Invalid status '${status}'. Allowed: todo, wip, done, pending, long, closed.`);
|
|
924
|
+
}
|
|
925
|
+
break;
|
|
926
|
+
case "--priority":
|
|
927
|
+
case "-p":
|
|
928
|
+
const priority = args[++i];
|
|
929
|
+
if (priority) {
|
|
930
|
+
applyUpdate((t) => t.priority = priority);
|
|
931
|
+
} else {
|
|
932
|
+
console.error("Error: --priority requires a value.");
|
|
933
|
+
}
|
|
934
|
+
break;
|
|
935
|
+
case "--version":
|
|
936
|
+
case "-v":
|
|
937
|
+
const version = args[++i];
|
|
938
|
+
if (version) {
|
|
939
|
+
applyUpdate((t) => t.version = version);
|
|
940
|
+
} else {
|
|
941
|
+
console.error("Error: --version requires a value.");
|
|
942
|
+
}
|
|
943
|
+
break;
|
|
944
|
+
case "--goal":
|
|
945
|
+
case "-g":
|
|
946
|
+
const goal = args[++i];
|
|
947
|
+
if (goal) {
|
|
948
|
+
applyUpdate((t) => t.goal = goal);
|
|
949
|
+
} else {
|
|
950
|
+
console.error("Error: --goal requires a value.");
|
|
951
|
+
}
|
|
952
|
+
break;
|
|
953
|
+
case "--order":
|
|
954
|
+
case "-o":
|
|
955
|
+
const order = args[++i];
|
|
956
|
+
if (order) {
|
|
957
|
+
applyUpdate((t) => {
|
|
958
|
+
if (t.status === "todo" || t.status === "wip") {
|
|
959
|
+
t.order = order === "null" ? null : order;
|
|
960
|
+
} else {
|
|
961
|
+
console.error(`Error: Cannot set order for task with status '${t.status}'. Only todo/wip allowed.`);
|
|
962
|
+
}
|
|
963
|
+
});
|
|
964
|
+
} else {
|
|
965
|
+
console.error("Error: --order requires a value.");
|
|
966
|
+
}
|
|
967
|
+
break;
|
|
968
|
+
case "--body":
|
|
969
|
+
case "-b":
|
|
970
|
+
const bodyText = args[++i];
|
|
971
|
+
if (bodyText) {
|
|
972
|
+
applyUpdate((t) => t.bodies.push({
|
|
973
|
+
text: bodyText,
|
|
974
|
+
created_at: new Date().toISOString()
|
|
975
|
+
}));
|
|
976
|
+
} else {
|
|
977
|
+
console.error("Error: --body requires a text argument.");
|
|
978
|
+
}
|
|
979
|
+
break;
|
|
980
|
+
case "--add-file":
|
|
981
|
+
case "-a":
|
|
982
|
+
const addPath = args[++i];
|
|
983
|
+
if (addPath) {
|
|
984
|
+
applyUpdate((t) => {
|
|
985
|
+
if (!t.files.edit.includes(addPath)) {
|
|
986
|
+
t.files.edit.push(addPath);
|
|
987
|
+
}
|
|
988
|
+
});
|
|
989
|
+
} else {
|
|
990
|
+
console.error("Error: --add-file requires a path argument.");
|
|
991
|
+
}
|
|
992
|
+
break;
|
|
993
|
+
case "--rm-file":
|
|
994
|
+
case "-d":
|
|
995
|
+
const rmPath = args[++i];
|
|
996
|
+
if (rmPath) {
|
|
997
|
+
applyUpdate((t) => {
|
|
998
|
+
t.files.edit = t.files.edit.filter((p) => p !== rmPath);
|
|
999
|
+
});
|
|
1000
|
+
} else {
|
|
1001
|
+
console.error("Error: --rm-file requires a path argument.");
|
|
1002
|
+
}
|
|
1003
|
+
break;
|
|
1004
|
+
case "--read-file":
|
|
1005
|
+
case "-r":
|
|
1006
|
+
const readPath = args[++i];
|
|
1007
|
+
if (readPath) {
|
|
1008
|
+
applyUpdate((t) => {
|
|
1009
|
+
if (!t.files.read.includes(readPath)) {
|
|
1010
|
+
t.files.read.push(readPath);
|
|
1011
|
+
}
|
|
1012
|
+
});
|
|
1013
|
+
} else {
|
|
1014
|
+
console.error("Error: --read-file requires a path argument.");
|
|
1015
|
+
}
|
|
1016
|
+
break;
|
|
1017
|
+
default:
|
|
1018
|
+
console.error(`Error: Unknown option '${arg}'.`);
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
} else {
|
|
1022
|
+
if (lastActionWasOption) {
|
|
1023
|
+
currentTargetIds = [];
|
|
1024
|
+
lastActionWasOption = false;
|
|
1025
|
+
}
|
|
1026
|
+
currentTargetIds.push(arg);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
if (updated) {
|
|
1030
|
+
saveTasks(tasks);
|
|
1031
|
+
console.log("Tasks updated.");
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// src/commands/env.ts
|
|
1036
|
+
function envCommand(args = []) {
|
|
1037
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
1038
|
+
console.log(`
|
|
1039
|
+
Usage: tm env
|
|
1040
|
+
|
|
1041
|
+
Description:
|
|
1042
|
+
Show the current task data file path.
|
|
1043
|
+
`);
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
if (args.length > 0) {
|
|
1047
|
+
console.error(`Error: env command doesn't accept arguments.`);
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
console.log(getDbPath());
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// src/commands/review.ts
|
|
1054
|
+
function reviewCommand(args) {
|
|
1055
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
1056
|
+
console.log(`
|
|
1057
|
+
Usage: tm review <command> [args]
|
|
1058
|
+
|
|
1059
|
+
Commands:
|
|
1060
|
+
new <title> --body <body>
|
|
1061
|
+
list
|
|
1062
|
+
get <id> [--history]
|
|
1063
|
+
update <id> [--status <status> --body <body>]
|
|
1064
|
+
return <id> [--status <status> --body <body>]
|
|
1065
|
+
accept <id> [--new <summary>...]
|
|
1066
|
+
reject <id>
|
|
1067
|
+
`);
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
const subcommand = args[0];
|
|
1071
|
+
const subArgs = args.slice(1);
|
|
1072
|
+
switch (subcommand) {
|
|
1073
|
+
case "new":
|
|
1074
|
+
handleNew(subArgs);
|
|
1075
|
+
break;
|
|
1076
|
+
case "list":
|
|
1077
|
+
handleList();
|
|
1078
|
+
break;
|
|
1079
|
+
case "get":
|
|
1080
|
+
handleGet(subArgs);
|
|
1081
|
+
break;
|
|
1082
|
+
case "update":
|
|
1083
|
+
handleUpdate(subArgs);
|
|
1084
|
+
break;
|
|
1085
|
+
case "return":
|
|
1086
|
+
handleReturn(subArgs);
|
|
1087
|
+
break;
|
|
1088
|
+
case "accept":
|
|
1089
|
+
handleAccept(subArgs);
|
|
1090
|
+
break;
|
|
1091
|
+
case "reject":
|
|
1092
|
+
handleReject(subArgs);
|
|
1093
|
+
break;
|
|
1094
|
+
default:
|
|
1095
|
+
console.error(`Unknown review subcommand: ${subcommand}`);
|
|
1096
|
+
console.log(`
|
|
1097
|
+
Usage: tm review <command> [args]
|
|
1098
|
+
|
|
1099
|
+
Commands:
|
|
1100
|
+
new <title> --body <body>
|
|
1101
|
+
list
|
|
1102
|
+
get <id> [--history]
|
|
1103
|
+
update <id> [--status <status> --body <body>]
|
|
1104
|
+
return <id> [--status <status> --body <body>]
|
|
1105
|
+
accept <id> [--new <summary>...]
|
|
1106
|
+
reject <id>
|
|
1107
|
+
`);
|
|
1108
|
+
process.exit(1);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
function handleNew(args) {
|
|
1112
|
+
let title = "";
|
|
1113
|
+
let body = "";
|
|
1114
|
+
const titleParts = [];
|
|
1115
|
+
let i = 0;
|
|
1116
|
+
while (i < args.length) {
|
|
1117
|
+
if (args[i].startsWith("--"))
|
|
1118
|
+
break;
|
|
1119
|
+
titleParts.push(args[i]);
|
|
1120
|
+
i++;
|
|
1121
|
+
}
|
|
1122
|
+
title = titleParts.join(" ");
|
|
1123
|
+
while (i < args.length) {
|
|
1124
|
+
if (args[i] === "--body") {
|
|
1125
|
+
body = args[i + 1] || "";
|
|
1126
|
+
i += 2;
|
|
1127
|
+
} else if (args[i].startsWith("--")) {
|
|
1128
|
+
console.error(`Error: Unknown option '${args[i]}'.`);
|
|
1129
|
+
return;
|
|
1130
|
+
} else {
|
|
1131
|
+
console.error(`Error: Unknown argument '${args[i]}'.`);
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
if (!title) {
|
|
1136
|
+
console.error("Error: Title is required");
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
const reviews = loadReviews();
|
|
1140
|
+
const newId = getNextReviewId(reviews);
|
|
1141
|
+
const now = new Date().toISOString();
|
|
1142
|
+
const newReview = {
|
|
1143
|
+
id: newId,
|
|
1144
|
+
title,
|
|
1145
|
+
bodies: [{ text: body, created_at: now }],
|
|
1146
|
+
status: "todo",
|
|
1147
|
+
created_at: now,
|
|
1148
|
+
updated_at: now
|
|
1149
|
+
};
|
|
1150
|
+
reviews.push(newReview);
|
|
1151
|
+
saveReviews(reviews);
|
|
1152
|
+
console.log(`Created review ${newId}`);
|
|
1153
|
+
}
|
|
1154
|
+
function handleList() {
|
|
1155
|
+
const reviews = loadReviews();
|
|
1156
|
+
const activeReviews = reviews.filter((r) => r.status !== "closed" && r.status !== "done");
|
|
1157
|
+
if (activeReviews.length === 0) {
|
|
1158
|
+
console.log("No active reviews.");
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
console.log("ID\tStatus\tTitle");
|
|
1162
|
+
console.log("--\t------\t-----");
|
|
1163
|
+
activeReviews.forEach((r) => {
|
|
1164
|
+
console.log(`${r.id} ${r.status} ${r.title}`);
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
function handleGet(args) {
|
|
1168
|
+
const id = args[0];
|
|
1169
|
+
const showHistory = args.includes("--history") || args.includes("--all");
|
|
1170
|
+
if (!id) {
|
|
1171
|
+
console.error("Error: Review ID is required");
|
|
1172
|
+
process.exit(1);
|
|
1173
|
+
}
|
|
1174
|
+
const reviews = loadReviews();
|
|
1175
|
+
const review = getReviewById(reviews, id);
|
|
1176
|
+
if (!review) {
|
|
1177
|
+
console.error(`Error: Review ${id} not found`);
|
|
1178
|
+
process.exit(1);
|
|
1179
|
+
}
|
|
1180
|
+
if (showHistory) {
|
|
1181
|
+
console.log(JSON.stringify(review, null, 2));
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
console.log(`Review: ${review.title}`);
|
|
1185
|
+
console.log(`ID: ${review.id}`);
|
|
1186
|
+
console.log(`Status: ${review.status}`);
|
|
1187
|
+
console.log("---");
|
|
1188
|
+
console.log("Description:");
|
|
1189
|
+
const description = review.bodies[0]?.text || "";
|
|
1190
|
+
console.log(description);
|
|
1191
|
+
if (review.bodies.length > 1) {
|
|
1192
|
+
console.log(`
|
|
1193
|
+
Answers:`);
|
|
1194
|
+
review.bodies.slice(1).forEach((b, i) => {
|
|
1195
|
+
console.log(`
|
|
1196
|
+
[${i + 1}] ${b.created_at}`);
|
|
1197
|
+
console.log(b.text);
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
console.log(`
|
|
1201
|
+
To reply to this review, run:
|
|
1202
|
+
tm review return ${id} --body "Your reply here"`);
|
|
1203
|
+
}
|
|
1204
|
+
function handleUpdate(args) {
|
|
1205
|
+
const id = args[0];
|
|
1206
|
+
if (!id || id.startsWith("--")) {
|
|
1207
|
+
console.error("Error: Review ID is required");
|
|
1208
|
+
process.exit(1);
|
|
1209
|
+
}
|
|
1210
|
+
const reviews = loadReviews();
|
|
1211
|
+
const review = getReviewById(reviews, id);
|
|
1212
|
+
if (!review) {
|
|
1213
|
+
console.error(`Error: Review ${id} not found`);
|
|
1214
|
+
process.exit(1);
|
|
1215
|
+
}
|
|
1216
|
+
let status;
|
|
1217
|
+
let body;
|
|
1218
|
+
let i = 1;
|
|
1219
|
+
while (i < args.length) {
|
|
1220
|
+
if (args[i] === "--status") {
|
|
1221
|
+
const newStatus = args[i + 1];
|
|
1222
|
+
const validStatuses = ["todo", "wip", "checking", "closed", "done", "pending"];
|
|
1223
|
+
if (!validStatuses.includes(newStatus)) {
|
|
1224
|
+
console.error(`Error: Invalid status '${newStatus}'. Valid statuses are: ${validStatuses.join(", ")}`);
|
|
1225
|
+
process.exit(1);
|
|
1226
|
+
}
|
|
1227
|
+
status = newStatus;
|
|
1228
|
+
i += 2;
|
|
1229
|
+
} else if (args[i] === "--body") {
|
|
1230
|
+
body = args[i + 1];
|
|
1231
|
+
i += 2;
|
|
1232
|
+
} else if (args[i].startsWith("--")) {
|
|
1233
|
+
console.error(`Error: Unknown option '${args[i]}'.`);
|
|
1234
|
+
return;
|
|
1235
|
+
} else {
|
|
1236
|
+
console.error(`Error: Unknown argument '${args[i]}'.`);
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
const now = new Date().toISOString();
|
|
1241
|
+
let updated = false;
|
|
1242
|
+
if (status) {
|
|
1243
|
+
review.status = status;
|
|
1244
|
+
updated = true;
|
|
1245
|
+
}
|
|
1246
|
+
if (body) {
|
|
1247
|
+
review.bodies.push({ text: body, created_at: now });
|
|
1248
|
+
updated = true;
|
|
1249
|
+
}
|
|
1250
|
+
if (updated) {
|
|
1251
|
+
review.updated_at = now;
|
|
1252
|
+
saveReviews(reviews);
|
|
1253
|
+
console.log(`Updated review ${review.id}`);
|
|
1254
|
+
} else {
|
|
1255
|
+
console.log("No changes made.");
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
function handleReturn(args) {
|
|
1259
|
+
if (!args.includes("--status")) {
|
|
1260
|
+
args.push("--status", "checking");
|
|
1261
|
+
}
|
|
1262
|
+
handleUpdate(args);
|
|
1263
|
+
}
|
|
1264
|
+
function handleAccept(args) {
|
|
1265
|
+
const id = args[0];
|
|
1266
|
+
if (!id || id.startsWith("--")) {
|
|
1267
|
+
console.error("Error: Review ID is required");
|
|
1268
|
+
process.exit(1);
|
|
1269
|
+
}
|
|
1270
|
+
const reviews = loadReviews();
|
|
1271
|
+
const review = getReviewById(reviews, id);
|
|
1272
|
+
if (!review) {
|
|
1273
|
+
console.error(`Error: Review ${id} not found`);
|
|
1274
|
+
process.exit(1);
|
|
1275
|
+
}
|
|
1276
|
+
const newTasksArgs = [];
|
|
1277
|
+
let currentNewArgs = [];
|
|
1278
|
+
let collectingNew = false;
|
|
1279
|
+
for (let i = 1;i < args.length; i++) {
|
|
1280
|
+
if (args[i] === "--new") {
|
|
1281
|
+
if (collectingNew && currentNewArgs.length > 0) {
|
|
1282
|
+
newTasksArgs.push([...currentNewArgs]);
|
|
1283
|
+
}
|
|
1284
|
+
currentNewArgs = [];
|
|
1285
|
+
collectingNew = true;
|
|
1286
|
+
} else if (collectingNew) {
|
|
1287
|
+
if (args[i] === "--add") {
|
|
1288
|
+
if (collectingNew && currentNewArgs.length > 0) {
|
|
1289
|
+
newTasksArgs.push([...currentNewArgs]);
|
|
1290
|
+
}
|
|
1291
|
+
collectingNew = false;
|
|
1292
|
+
} else {
|
|
1293
|
+
currentNewArgs.push(args[i]);
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
if (collectingNew && currentNewArgs.length > 0) {
|
|
1298
|
+
newTasksArgs.push([...currentNewArgs]);
|
|
1299
|
+
}
|
|
1300
|
+
const tasks = loadTasks();
|
|
1301
|
+
const createdTaskIds = [];
|
|
1302
|
+
const now = new Date().toISOString();
|
|
1303
|
+
for (const taskArgs of newTasksArgs) {
|
|
1304
|
+
try {
|
|
1305
|
+
const options = parseTaskArgs(taskArgs);
|
|
1306
|
+
if (options.summary) {
|
|
1307
|
+
const newTaskId = getNextId(tasks);
|
|
1308
|
+
const newTask = buildTask(newTaskId, options);
|
|
1309
|
+
tasks.push(newTask);
|
|
1310
|
+
createdTaskIds.push(newTaskId);
|
|
1311
|
+
console.log(`Created task ${newTaskId} from review`);
|
|
1312
|
+
}
|
|
1313
|
+
} catch (error) {
|
|
1314
|
+
console.error(`Error parsing task args: ${error instanceof Error ? error.message : String(error)}`);
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
if (createdTaskIds.length > 0) {
|
|
1319
|
+
saveTasks(tasks);
|
|
1320
|
+
review.related_task_ids = [...review.related_task_ids || [], ...createdTaskIds];
|
|
1321
|
+
}
|
|
1322
|
+
review.status = "done";
|
|
1323
|
+
review.updated_at = now;
|
|
1324
|
+
saveReviews(reviews);
|
|
1325
|
+
console.log(`Review ${id} accepted and marked as done.`);
|
|
1326
|
+
}
|
|
1327
|
+
function handleReject(args) {
|
|
1328
|
+
const id = args[0];
|
|
1329
|
+
if (!id) {
|
|
1330
|
+
console.error("Error: Review ID is required");
|
|
1331
|
+
process.exit(1);
|
|
1332
|
+
}
|
|
1333
|
+
const reviews = loadReviews();
|
|
1334
|
+
const review = getReviewById(reviews, id);
|
|
1335
|
+
if (!review) {
|
|
1336
|
+
console.error(`Error: Review ${id} not found`);
|
|
1337
|
+
process.exit(1);
|
|
1338
|
+
}
|
|
1339
|
+
review.status = "closed";
|
|
1340
|
+
review.updated_at = new Date().toISOString();
|
|
1341
|
+
saveReviews(reviews);
|
|
1342
|
+
console.log(`Review ${id} rejected and marked as closed.`);
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
// src/commands/release.ts
|
|
1346
|
+
function releaseCommand(args) {
|
|
1347
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
1348
|
+
console.log(`
|
|
1349
|
+
Usage: tm release <id...> --version <v>
|
|
1350
|
+
|
|
1351
|
+
Options:
|
|
1352
|
+
--version <v> Set version for task(s)
|
|
1353
|
+
`);
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
let version = null;
|
|
1357
|
+
const ids = [];
|
|
1358
|
+
for (let i = 0;i < args.length; i++) {
|
|
1359
|
+
const arg = args[i];
|
|
1360
|
+
if (arg === "--version") {
|
|
1361
|
+
if (i + 1 < args.length && !args[i + 1].startsWith("--")) {
|
|
1362
|
+
version = args[++i];
|
|
1363
|
+
} else {
|
|
1364
|
+
console.error("Error: --version requires a value.");
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
} else if (arg.startsWith("--")) {
|
|
1368
|
+
console.error(`Error: Unknown option '${arg}'.`);
|
|
1369
|
+
return;
|
|
1370
|
+
} else {
|
|
1371
|
+
ids.push(arg);
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
if (!version) {
|
|
1375
|
+
console.error("Error: --version <v> is required.");
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
if (ids.length === 0) {
|
|
1379
|
+
console.error("Error: No task IDs specified.");
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
const tasks = loadTasks();
|
|
1383
|
+
let updated = false;
|
|
1384
|
+
for (const id of ids) {
|
|
1385
|
+
const task = getTaskById(tasks, id);
|
|
1386
|
+
if (task) {
|
|
1387
|
+
task.version = version;
|
|
1388
|
+
task.updated_at = new Date().toISOString();
|
|
1389
|
+
updated = true;
|
|
1390
|
+
console.log(`Task ${id} version set to ${version}`);
|
|
1391
|
+
} else {
|
|
1392
|
+
console.error(`Error: Task ${id} not found.`);
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
if (updated) {
|
|
1396
|
+
saveTasks(tasks);
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
// src/commands/close.ts
|
|
1401
|
+
function closeCommand(args) {
|
|
1402
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
1403
|
+
console.log(`
|
|
1404
|
+
Usage: tm close <id...> [options]
|
|
1405
|
+
|
|
1406
|
+
Options:
|
|
1407
|
+
--body <body> Add a closing comment
|
|
1408
|
+
`);
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
const ids = [];
|
|
1412
|
+
let bodyText = null;
|
|
1413
|
+
for (let i = 0;i < args.length; i++) {
|
|
1414
|
+
const arg = args[i];
|
|
1415
|
+
if (arg === "--body") {
|
|
1416
|
+
if (i + 1 < args.length && !args[i + 1].startsWith("--")) {
|
|
1417
|
+
bodyText = args[++i];
|
|
1418
|
+
} else {
|
|
1419
|
+
console.error("Error: --body requires a text argument.");
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
} else if (arg.startsWith("--")) {
|
|
1423
|
+
console.error(`Error: Unknown option '${arg}'.`);
|
|
1424
|
+
return;
|
|
1425
|
+
} else {
|
|
1426
|
+
ids.push(arg);
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
if (ids.length === 0) {
|
|
1430
|
+
console.error("Error: No task IDs provided.");
|
|
1431
|
+
return;
|
|
1432
|
+
}
|
|
1433
|
+
const tasks = loadTasks();
|
|
1434
|
+
let updatedCount = 0;
|
|
1435
|
+
ids.forEach((id) => {
|
|
1436
|
+
const task = getTaskById(tasks, id);
|
|
1437
|
+
if (task) {
|
|
1438
|
+
task.status = "closed";
|
|
1439
|
+
task.updated_at = new Date().toISOString();
|
|
1440
|
+
if (task.version === "tbd") {
|
|
1441
|
+
task.version = "";
|
|
1442
|
+
}
|
|
1443
|
+
if (bodyText) {
|
|
1444
|
+
const newBody = {
|
|
1445
|
+
text: bodyText,
|
|
1446
|
+
created_at: new Date().toISOString()
|
|
1447
|
+
};
|
|
1448
|
+
task.bodies.push(newBody);
|
|
1449
|
+
}
|
|
1450
|
+
updatedCount++;
|
|
1451
|
+
console.log(`Task ${task.id} closed.`);
|
|
1452
|
+
} else {
|
|
1453
|
+
console.warn(`Task ${id} not found.`);
|
|
1454
|
+
}
|
|
1455
|
+
});
|
|
1456
|
+
if (updatedCount > 0) {
|
|
1457
|
+
saveTasks(tasks);
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// src/syncStore.ts
|
|
1462
|
+
import { join as join3 } from "path";
|
|
1463
|
+
import { homedir as homedir3 } from "os";
|
|
1464
|
+
import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync3, writeFileSync as writeFileSync3, readdirSync } from "fs";
|
|
1465
|
+
import { spawnSync } from "child_process";
|
|
1466
|
+
var SYNC_DIR = join3(homedir3(), ".local", "task-memory");
|
|
1467
|
+
var PROJECTS_DIR = join3(SYNC_DIR, "projects");
|
|
1468
|
+
var CONFIG_FILE = join3(SYNC_DIR, "config.json");
|
|
1469
|
+
function getSyncDir() {
|
|
1470
|
+
return SYNC_DIR;
|
|
1471
|
+
}
|
|
1472
|
+
function isSyncInitialized() {
|
|
1473
|
+
return existsSync3(SYNC_DIR) && existsSync3(join3(SYNC_DIR, ".git"));
|
|
1474
|
+
}
|
|
1475
|
+
function initSyncRepo() {
|
|
1476
|
+
if (!existsSync3(SYNC_DIR)) {
|
|
1477
|
+
mkdirSync(SYNC_DIR, { recursive: true });
|
|
1478
|
+
}
|
|
1479
|
+
if (!existsSync3(PROJECTS_DIR)) {
|
|
1480
|
+
mkdirSync(PROJECTS_DIR, { recursive: true });
|
|
1481
|
+
}
|
|
1482
|
+
if (!existsSync3(join3(SYNC_DIR, ".git"))) {
|
|
1483
|
+
const result = spawnSync("git", ["init"], { cwd: SYNC_DIR, stdio: "inherit" });
|
|
1484
|
+
if (result.status !== 0) {
|
|
1485
|
+
console.error("Failed to initialize git repository");
|
|
1486
|
+
return false;
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
if (!existsSync3(CONFIG_FILE)) {
|
|
1490
|
+
const config = { defaultAuto: false };
|
|
1491
|
+
writeFileSync3(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
|
|
1492
|
+
}
|
|
1493
|
+
const gitignorePath = join3(SYNC_DIR, ".gitignore");
|
|
1494
|
+
if (!existsSync3(gitignorePath)) {
|
|
1495
|
+
writeFileSync3(gitignorePath, `# Add patterns to ignore
|
|
1496
|
+
`, "utf-8");
|
|
1497
|
+
}
|
|
1498
|
+
return true;
|
|
1499
|
+
}
|
|
1500
|
+
function loadGlobalConfig() {
|
|
1501
|
+
if (!existsSync3(CONFIG_FILE)) {
|
|
1502
|
+
return { defaultAuto: false };
|
|
1503
|
+
}
|
|
1504
|
+
try {
|
|
1505
|
+
const data = readFileSync3(CONFIG_FILE, "utf-8");
|
|
1506
|
+
return JSON.parse(data);
|
|
1507
|
+
} catch (e) {
|
|
1508
|
+
return { defaultAuto: false };
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
function saveGlobalConfig(config) {
|
|
1512
|
+
writeFileSync3(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
|
|
1513
|
+
}
|
|
1514
|
+
function getProjectFilePath(syncId) {
|
|
1515
|
+
return join3(PROJECTS_DIR, `${syncId}.json`);
|
|
1516
|
+
}
|
|
1517
|
+
function getEncryptedProjectFilePath(syncId) {
|
|
1518
|
+
return join3(PROJECTS_DIR, `${syncId}.json.age`);
|
|
1519
|
+
}
|
|
1520
|
+
function getPublicKeyFromIdentityFile(keyFile) {
|
|
1521
|
+
try {
|
|
1522
|
+
const content = readFileSync3(keyFile, "utf-8");
|
|
1523
|
+
const match = content.match(/^# public key: (.+)$/m);
|
|
1524
|
+
return match ? match[1].trim() : null;
|
|
1525
|
+
} catch {
|
|
1526
|
+
return null;
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
function encryptData(data, recipient) {
|
|
1530
|
+
const result = spawnSync("age", ["-e", "-r", recipient, "-a"], {
|
|
1531
|
+
input: data,
|
|
1532
|
+
encoding: "utf-8"
|
|
1533
|
+
});
|
|
1534
|
+
if (result.error) {
|
|
1535
|
+
console.error(`Encryption failed: ${result.error.message}. Is 'age' command installed and in your PATH?`);
|
|
1536
|
+
return null;
|
|
1537
|
+
}
|
|
1538
|
+
if (result.status !== 0) {
|
|
1539
|
+
console.error(`Encryption failed: ${result.stderr}`);
|
|
1540
|
+
return null;
|
|
1541
|
+
}
|
|
1542
|
+
return result.stdout;
|
|
1543
|
+
}
|
|
1544
|
+
function decryptData(ciphertext, keyFile) {
|
|
1545
|
+
const result = spawnSync("age", ["-d", "-i", keyFile], {
|
|
1546
|
+
input: ciphertext,
|
|
1547
|
+
encoding: "utf-8"
|
|
1548
|
+
});
|
|
1549
|
+
if (result.error) {
|
|
1550
|
+
console.error(`Decryption failed: ${result.error.message}. Is 'age' command installed and in your PATH?`);
|
|
1551
|
+
return null;
|
|
1552
|
+
}
|
|
1553
|
+
if (result.status !== 0) {
|
|
1554
|
+
console.error(`Decryption failed: ${result.stderr}`);
|
|
1555
|
+
return null;
|
|
1556
|
+
}
|
|
1557
|
+
return result.stdout;
|
|
1558
|
+
}
|
|
1559
|
+
function generateAgeKey(outputPath) {
|
|
1560
|
+
const result = spawnSync("age-keygen", ["-o", outputPath], {
|
|
1561
|
+
encoding: "utf-8"
|
|
1562
|
+
});
|
|
1563
|
+
if (result.error) {
|
|
1564
|
+
console.error(`Key generation failed: ${result.error.message}. Is 'age-keygen' command installed and in your PATH?`);
|
|
1565
|
+
return false;
|
|
1566
|
+
}
|
|
1567
|
+
return result.status === 0;
|
|
1568
|
+
}
|
|
1569
|
+
function pushToSync(syncId, store, encryptRecipient) {
|
|
1570
|
+
if (!isSyncInitialized()) {
|
|
1571
|
+
console.error('Sync repository not initialized. Run "tm sync add" first.');
|
|
1572
|
+
return false;
|
|
1573
|
+
}
|
|
1574
|
+
const projectFile = encryptRecipient ? getEncryptedProjectFilePath(syncId) : getProjectFilePath(syncId);
|
|
1575
|
+
try {
|
|
1576
|
+
const jsonData = JSON.stringify(store, null, 2);
|
|
1577
|
+
if (encryptRecipient) {
|
|
1578
|
+
const encrypted = encryptData(jsonData, encryptRecipient);
|
|
1579
|
+
if (!encrypted)
|
|
1580
|
+
return false;
|
|
1581
|
+
writeFileSync3(projectFile, encrypted, "utf-8");
|
|
1582
|
+
} else {
|
|
1583
|
+
writeFileSync3(projectFile, jsonData, "utf-8");
|
|
1584
|
+
}
|
|
1585
|
+
return true;
|
|
1586
|
+
} catch (e) {
|
|
1587
|
+
console.error(`Failed to push to sync: ${e}`);
|
|
1588
|
+
return false;
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
function tryAutoSync(syncConfig, store, encryptRecipient) {
|
|
1592
|
+
if (!syncConfig?.enabled || !syncConfig.auto) {
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1595
|
+
if (!isSyncInitialized()) {
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
pushToSync(syncConfig.id, store, encryptRecipient);
|
|
1599
|
+
}
|
|
1600
|
+
function pullFromSync(syncId, encryptIdentityFile) {
|
|
1601
|
+
if (!isSyncInitialized()) {
|
|
1602
|
+
console.error('Sync repository not initialized. Run "tm sync add" first.');
|
|
1603
|
+
return null;
|
|
1604
|
+
}
|
|
1605
|
+
const ageFile = getEncryptedProjectFilePath(syncId);
|
|
1606
|
+
const jsonFile = getProjectFilePath(syncId);
|
|
1607
|
+
try {
|
|
1608
|
+
if (encryptIdentityFile && existsSync3(ageFile)) {
|
|
1609
|
+
const ciphertext = readFileSync3(ageFile, "utf-8");
|
|
1610
|
+
const decrypted = decryptData(ciphertext, encryptIdentityFile);
|
|
1611
|
+
if (!decrypted)
|
|
1612
|
+
return null;
|
|
1613
|
+
return JSON.parse(decrypted);
|
|
1614
|
+
}
|
|
1615
|
+
if (existsSync3(jsonFile)) {
|
|
1616
|
+
const data = readFileSync3(jsonFile, "utf-8");
|
|
1617
|
+
return JSON.parse(data);
|
|
1618
|
+
}
|
|
1619
|
+
console.error(`Project "${syncId}" not found in sync repository.`);
|
|
1620
|
+
return null;
|
|
1621
|
+
} catch (e) {
|
|
1622
|
+
console.error(`Failed to pull from sync: ${e}`);
|
|
1623
|
+
return null;
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
function listSyncedProjects() {
|
|
1627
|
+
if (!existsSync3(PROJECTS_DIR)) {
|
|
1628
|
+
return [];
|
|
1629
|
+
}
|
|
1630
|
+
const files = readdirSync(PROJECTS_DIR);
|
|
1631
|
+
return files.filter((f) => f.endsWith(".json")).map((f) => f.replace(/\.json$/, ""));
|
|
1632
|
+
}
|
|
1633
|
+
function runGitCommand(args) {
|
|
1634
|
+
if (!isSyncInitialized()) {
|
|
1635
|
+
console.error('Sync repository not initialized. Run "tm sync add" first.');
|
|
1636
|
+
return 1;
|
|
1637
|
+
}
|
|
1638
|
+
const result = spawnSync("git", args, { cwd: SYNC_DIR, stdio: "inherit" });
|
|
1639
|
+
return result.status ?? 1;
|
|
1640
|
+
}
|
|
1641
|
+
function normalizeRemoteUrl(url) {
|
|
1642
|
+
let s = url.trim();
|
|
1643
|
+
s = s.replace(/^https?:\/\//, "");
|
|
1644
|
+
s = s.replace(/^git:\/\//, "");
|
|
1645
|
+
s = s.replace(/^[^@]+@/, "");
|
|
1646
|
+
s = s.replace(":", "/");
|
|
1647
|
+
s = s.replace(/\.git$/, "");
|
|
1648
|
+
s = s.replace(/\//g, "-");
|
|
1649
|
+
s = s.replace(/[^a-zA-Z0-9._-]/g, "-");
|
|
1650
|
+
s = s.replace(/^-+|-+$/g, "");
|
|
1651
|
+
return s || "unknown";
|
|
1652
|
+
}
|
|
1653
|
+
function generateSyncId() {
|
|
1654
|
+
const originResult = spawnSync("git", ["remote", "get-url", "origin"], {
|
|
1655
|
+
cwd: process.cwd(),
|
|
1656
|
+
encoding: "utf-8"
|
|
1657
|
+
});
|
|
1658
|
+
if (originResult.status === 0 && originResult.stdout) {
|
|
1659
|
+
return normalizeRemoteUrl(originResult.stdout.trim());
|
|
1660
|
+
}
|
|
1661
|
+
const toplevelResult = spawnSync("git", ["rev-parse", "--show-toplevel"], {
|
|
1662
|
+
cwd: process.cwd(),
|
|
1663
|
+
encoding: "utf-8"
|
|
1664
|
+
});
|
|
1665
|
+
if (toplevelResult.status === 0 && toplevelResult.stdout) {
|
|
1666
|
+
return toplevelResult.stdout.trim().split("/").pop() || "unknown";
|
|
1667
|
+
}
|
|
1668
|
+
return process.cwd().split("/").pop() || "unknown";
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
// src/commands/sync.ts
|
|
1672
|
+
import { existsSync as existsSync4 } from "fs";
|
|
1673
|
+
import { join as join4 } from "path";
|
|
1674
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
1675
|
+
function mergeTasks(localTasks, remoteTasks) {
|
|
1676
|
+
const merged = [...localTasks];
|
|
1677
|
+
let idCollisions = 0;
|
|
1678
|
+
let conflictsResolved = 0;
|
|
1679
|
+
for (const remoteTask of remoteTasks) {
|
|
1680
|
+
const sameTaskIndex = merged.findIndex((t) => t.id === remoteTask.id && t.created_at === remoteTask.created_at);
|
|
1681
|
+
if (sameTaskIndex >= 0) {
|
|
1682
|
+
const existing = merged[sameTaskIndex];
|
|
1683
|
+
if (existing && new Date(remoteTask.updated_at) > new Date(existing.updated_at)) {
|
|
1684
|
+
merged[sameTaskIndex] = remoteTask;
|
|
1685
|
+
conflictsResolved++;
|
|
1686
|
+
}
|
|
1687
|
+
} else {
|
|
1688
|
+
const hasIdCollision = merged.some((t) => t.id === remoteTask.id);
|
|
1689
|
+
if (hasIdCollision) {
|
|
1690
|
+
const newId = getNextId(merged);
|
|
1691
|
+
merged.push({ ...remoteTask, id: newId });
|
|
1692
|
+
idCollisions++;
|
|
1693
|
+
} else {
|
|
1694
|
+
merged.push(remoteTask);
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
return { tasks: merged, idCollisions, conflictsResolved };
|
|
1699
|
+
}
|
|
1700
|
+
function parseArgs(args) {
|
|
1701
|
+
const subcommand = args[0] || "";
|
|
1702
|
+
const options = {};
|
|
1703
|
+
const positional = [];
|
|
1704
|
+
let i = 1;
|
|
1705
|
+
while (i < args.length) {
|
|
1706
|
+
const arg = args[i];
|
|
1707
|
+
if (arg?.startsWith("--")) {
|
|
1708
|
+
const key = arg.slice(2);
|
|
1709
|
+
const nextArg = args[i + 1];
|
|
1710
|
+
if (nextArg && !nextArg.startsWith("--")) {
|
|
1711
|
+
options[key] = nextArg;
|
|
1712
|
+
i += 2;
|
|
1713
|
+
} else {
|
|
1714
|
+
options[key] = true;
|
|
1715
|
+
i++;
|
|
1716
|
+
}
|
|
1717
|
+
} else {
|
|
1718
|
+
positional.push(arg);
|
|
1719
|
+
i++;
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
return { subcommand, options, positional };
|
|
1723
|
+
}
|
|
1724
|
+
function ensureInitialized() {
|
|
1725
|
+
if (isSyncInitialized()) {
|
|
1726
|
+
return true;
|
|
1727
|
+
}
|
|
1728
|
+
return initSyncRepo();
|
|
1729
|
+
}
|
|
1730
|
+
function handleAdd(options) {
|
|
1731
|
+
if (!ensureInitialized()) {
|
|
1732
|
+
console.error("Failed to initialize sync repository");
|
|
1733
|
+
process.exit(1);
|
|
1734
|
+
}
|
|
1735
|
+
const existingConfig = loadSyncConfig();
|
|
1736
|
+
if (existingConfig?.enabled) {
|
|
1737
|
+
console.log(`Already added to sync with id: ${existingConfig.id}`);
|
|
1738
|
+
return;
|
|
1739
|
+
}
|
|
1740
|
+
const syncId = (typeof options.id === "string" ? options.id : null) || generateSyncId();
|
|
1741
|
+
const syncConfig = {
|
|
1742
|
+
id: syncId,
|
|
1743
|
+
enabled: true,
|
|
1744
|
+
auto: false
|
|
1745
|
+
};
|
|
1746
|
+
saveSyncConfig(syncConfig);
|
|
1747
|
+
console.log(`Added to sync with id: ${syncId}`);
|
|
1748
|
+
if (options.save) {
|
|
1749
|
+
const globalConfig = loadGlobalConfig();
|
|
1750
|
+
const { recipient } = resolveEncryptSettings(syncConfig, globalConfig);
|
|
1751
|
+
const store = loadStore();
|
|
1752
|
+
if (pushToSync(syncId, store, recipient)) {
|
|
1753
|
+
console.log("Saved to sync directory.");
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
function handleRemove() {
|
|
1758
|
+
const existingConfig = loadSyncConfig();
|
|
1759
|
+
if (!existingConfig?.enabled) {
|
|
1760
|
+
console.log("Not currently synced.");
|
|
1761
|
+
return;
|
|
1762
|
+
}
|
|
1763
|
+
const syncConfig = {
|
|
1764
|
+
...existingConfig,
|
|
1765
|
+
enabled: false
|
|
1766
|
+
};
|
|
1767
|
+
saveSyncConfig(syncConfig);
|
|
1768
|
+
console.log(`Removed from sync. (id was: ${existingConfig.id})`);
|
|
1769
|
+
}
|
|
1770
|
+
function resolveEncryptSettings(syncConfig, globalConfig) {
|
|
1771
|
+
const enabled = syncConfig.encryptEnabled ?? globalConfig.defaultEncryptEnabled ?? false;
|
|
1772
|
+
if (!enabled)
|
|
1773
|
+
return { enabled: false, recipient: undefined, identityFile: undefined };
|
|
1774
|
+
return {
|
|
1775
|
+
enabled: true,
|
|
1776
|
+
recipient: syncConfig.encryptRecipient ?? globalConfig.defaultEncryptRecipient,
|
|
1777
|
+
identityFile: syncConfig.encryptIdentityFile ?? globalConfig.defaultEncryptIdentityFile
|
|
1778
|
+
};
|
|
1779
|
+
}
|
|
1780
|
+
function handleSave() {
|
|
1781
|
+
const syncConfig = loadSyncConfig();
|
|
1782
|
+
if (!syncConfig?.enabled) {
|
|
1783
|
+
console.error('Not synced. Run "tm sync add" first.');
|
|
1784
|
+
process.exit(1);
|
|
1785
|
+
}
|
|
1786
|
+
const globalConfig = loadGlobalConfig();
|
|
1787
|
+
const { recipient } = resolveEncryptSettings(syncConfig, globalConfig);
|
|
1788
|
+
const store = loadStore();
|
|
1789
|
+
if (pushToSync(syncConfig.id, store, recipient)) {
|
|
1790
|
+
console.log(`Saved to sync directory. (id: ${syncConfig.id})`);
|
|
1791
|
+
} else {
|
|
1792
|
+
process.exit(1);
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
function handleLoad(options) {
|
|
1796
|
+
const syncConfig = loadSyncConfig();
|
|
1797
|
+
if (!syncConfig?.enabled) {
|
|
1798
|
+
console.error('Not synced. Run "tm sync add" first.');
|
|
1799
|
+
process.exit(1);
|
|
1800
|
+
}
|
|
1801
|
+
const globalConfig = loadGlobalConfig();
|
|
1802
|
+
const { identityFile } = resolveEncryptSettings(syncConfig, globalConfig);
|
|
1803
|
+
const remoteStore = pullFromSync(syncConfig.id, identityFile);
|
|
1804
|
+
if (!remoteStore) {
|
|
1805
|
+
process.exit(1);
|
|
1806
|
+
}
|
|
1807
|
+
const currentStore = loadStore();
|
|
1808
|
+
const merge = options.merge === true;
|
|
1809
|
+
if (merge) {
|
|
1810
|
+
const result = mergeTasks(currentStore.tasks, remoteStore.tasks);
|
|
1811
|
+
currentStore.tasks = result.tasks;
|
|
1812
|
+
saveStore(currentStore);
|
|
1813
|
+
let msg = `Merged from sync directory. (${remoteStore.tasks.length} tasks)`;
|
|
1814
|
+
if (result.idCollisions > 0)
|
|
1815
|
+
msg += ` ID collisions resolved: ${result.idCollisions}.`;
|
|
1816
|
+
if (result.conflictsResolved > 0)
|
|
1817
|
+
msg += ` Conflicts resolved: ${result.conflictsResolved}.`;
|
|
1818
|
+
console.log(msg);
|
|
1819
|
+
} else {
|
|
1820
|
+
currentStore.tasks = remoteStore.tasks;
|
|
1821
|
+
saveStore(currentStore);
|
|
1822
|
+
console.log(`Loaded from sync directory. (${remoteStore.tasks.length} tasks)`);
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
function handleSetKey(positional, options) {
|
|
1826
|
+
const isGlobal = options.global === true;
|
|
1827
|
+
const isGenerate = options.generate === true;
|
|
1828
|
+
const pathArg = positional[1] ?? null;
|
|
1829
|
+
if (!isGenerate && !pathArg) {
|
|
1830
|
+
console.error("Usage: tm sync set key <path> [--global]");
|
|
1831
|
+
console.error(" tm sync set key --generate [--global]");
|
|
1832
|
+
process.exit(1);
|
|
1833
|
+
}
|
|
1834
|
+
let identityFile;
|
|
1835
|
+
if (isGenerate) {
|
|
1836
|
+
identityFile = join4(getSyncDir(), "age.key");
|
|
1837
|
+
if (!existsSync4(identityFile)) {
|
|
1838
|
+
if (!generateAgeKey(identityFile)) {
|
|
1839
|
+
console.error("Failed to generate age key.");
|
|
1840
|
+
process.exit(1);
|
|
1841
|
+
}
|
|
1842
|
+
console.log(`Generated key file: ${identityFile}`);
|
|
1843
|
+
} else {
|
|
1844
|
+
console.log(`Using existing key file: ${identityFile}`);
|
|
1845
|
+
}
|
|
1846
|
+
} else {
|
|
1847
|
+
identityFile = pathArg;
|
|
1848
|
+
if (!existsSync4(identityFile)) {
|
|
1849
|
+
console.error(`Key file not found: ${identityFile}`);
|
|
1850
|
+
process.exit(1);
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
const pubkey = getPublicKeyFromIdentityFile(identityFile);
|
|
1854
|
+
if (!pubkey) {
|
|
1855
|
+
console.error(`Could not extract public key from: ${identityFile}`);
|
|
1856
|
+
process.exit(1);
|
|
1857
|
+
}
|
|
1858
|
+
if (isGlobal) {
|
|
1859
|
+
const globalConfig = loadGlobalConfig();
|
|
1860
|
+
globalConfig.defaultEncryptIdentityFile = identityFile;
|
|
1861
|
+
globalConfig.defaultEncryptRecipient = pubkey;
|
|
1862
|
+
saveGlobalConfig(globalConfig);
|
|
1863
|
+
console.log(`Global key file set: ${identityFile}`);
|
|
1864
|
+
console.log(`Global recipient set to: ${pubkey}`);
|
|
1865
|
+
} else {
|
|
1866
|
+
const syncConfig = loadSyncConfig();
|
|
1867
|
+
if (!syncConfig?.enabled) {
|
|
1868
|
+
console.error('Not synced. Run "tm sync add" first.');
|
|
1869
|
+
process.exit(1);
|
|
1870
|
+
}
|
|
1871
|
+
syncConfig.encryptIdentityFile = identityFile;
|
|
1872
|
+
syncConfig.encryptRecipient = pubkey;
|
|
1873
|
+
saveSyncConfig(syncConfig);
|
|
1874
|
+
console.log(`Key file set: ${identityFile}`);
|
|
1875
|
+
console.log(`Recipient set to: ${pubkey}`);
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
function handleSetEncrypt(positional, options) {
|
|
1879
|
+
const isGlobal = options.global === true;
|
|
1880
|
+
const value = positional[1];
|
|
1881
|
+
if (value !== "on" && value !== "off") {
|
|
1882
|
+
console.error("Usage: tm sync set encrypt <on|off> [--global]");
|
|
1883
|
+
process.exit(1);
|
|
1884
|
+
}
|
|
1885
|
+
const enabled = value === "on";
|
|
1886
|
+
if (isGlobal) {
|
|
1887
|
+
const globalConfig = loadGlobalConfig();
|
|
1888
|
+
globalConfig.defaultEncryptEnabled = enabled;
|
|
1889
|
+
saveGlobalConfig(globalConfig);
|
|
1890
|
+
console.log(`Global encryption ${enabled ? "enabled" : "disabled"}.`);
|
|
1891
|
+
} else {
|
|
1892
|
+
const syncConfig = loadSyncConfig();
|
|
1893
|
+
if (!syncConfig?.enabled) {
|
|
1894
|
+
console.error('Not synced. Run "tm sync add" first.');
|
|
1895
|
+
process.exit(1);
|
|
1896
|
+
}
|
|
1897
|
+
syncConfig.encryptEnabled = enabled;
|
|
1898
|
+
saveSyncConfig(syncConfig);
|
|
1899
|
+
console.log(`Encryption ${enabled ? "enabled" : "disabled"}.`);
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
function handleSet(positional, options) {
|
|
1903
|
+
const mode = positional[0];
|
|
1904
|
+
if (mode === "key") {
|
|
1905
|
+
handleSetKey(positional, options);
|
|
1906
|
+
return;
|
|
1907
|
+
}
|
|
1908
|
+
if (mode === "encrypt") {
|
|
1909
|
+
handleSetEncrypt(positional, options);
|
|
1910
|
+
return;
|
|
1911
|
+
}
|
|
1912
|
+
if (mode !== "auto" && mode !== "manual") {
|
|
1913
|
+
console.error("Usage: tm sync set <auto|manual|encrypt|key>");
|
|
1914
|
+
process.exit(1);
|
|
1915
|
+
}
|
|
1916
|
+
const syncConfig = loadSyncConfig();
|
|
1917
|
+
if (!syncConfig?.enabled) {
|
|
1918
|
+
console.error('Not synced. Run "tm sync add" first.');
|
|
1919
|
+
process.exit(1);
|
|
1920
|
+
}
|
|
1921
|
+
syncConfig.auto = mode === "auto";
|
|
1922
|
+
saveSyncConfig(syncConfig);
|
|
1923
|
+
console.log(`Sync mode set to: ${mode}`);
|
|
1924
|
+
}
|
|
1925
|
+
function handleStatus() {
|
|
1926
|
+
const syncConfig = loadSyncConfig();
|
|
1927
|
+
console.log("=== Sync Status ===");
|
|
1928
|
+
console.log(`Sync Directory: ${getSyncDir()}`);
|
|
1929
|
+
console.log(`Initialized: ${isSyncInitialized() ? "Yes" : "No"}`);
|
|
1930
|
+
console.log("");
|
|
1931
|
+
const globalConfig = loadGlobalConfig();
|
|
1932
|
+
if (globalConfig.defaultEncryptEnabled !== undefined || globalConfig.defaultEncryptIdentityFile || globalConfig.defaultEncryptRecipient) {
|
|
1933
|
+
console.log("=== Global Defaults ===");
|
|
1934
|
+
if (globalConfig.defaultEncryptEnabled !== undefined) {
|
|
1935
|
+
console.log(`Encrypt: ${globalConfig.defaultEncryptEnabled ? "on" : "off"}`);
|
|
1936
|
+
}
|
|
1937
|
+
if (globalConfig.defaultEncryptIdentityFile) {
|
|
1938
|
+
console.log(`Key: ${globalConfig.defaultEncryptIdentityFile}`);
|
|
1939
|
+
}
|
|
1940
|
+
if (globalConfig.defaultEncryptRecipient) {
|
|
1941
|
+
console.log(`Recipient: ${globalConfig.defaultEncryptRecipient}`);
|
|
1942
|
+
}
|
|
1943
|
+
console.log("");
|
|
1944
|
+
}
|
|
1945
|
+
if (syncConfig) {
|
|
1946
|
+
console.log("=== Current Project ===");
|
|
1947
|
+
console.log(`ID: ${syncConfig.id}`);
|
|
1948
|
+
console.log(`Enabled: ${syncConfig.enabled ? "Yes" : "No"}`);
|
|
1949
|
+
console.log(`Auto: ${syncConfig.auto ? "Yes" : "No"}`);
|
|
1950
|
+
const { enabled: encryptEnabled, identityFile: identity, recipient } = resolveEncryptSettings(syncConfig, globalConfig);
|
|
1951
|
+
console.log(`Encrypt: ${encryptEnabled ? "on" : "off"}${syncConfig.encryptEnabled === undefined ? " (global)" : ""}`);
|
|
1952
|
+
if (encryptEnabled) {
|
|
1953
|
+
if (identity)
|
|
1954
|
+
console.log(`Key: ${identity}${syncConfig.encryptIdentityFile === undefined ? " (global)" : ""}`);
|
|
1955
|
+
if (recipient)
|
|
1956
|
+
console.log(`Recipient: ${recipient}${syncConfig.encryptRecipient === undefined ? " (global)" : ""}`);
|
|
1957
|
+
}
|
|
1958
|
+
} else {
|
|
1959
|
+
console.log("Current project is not synced.");
|
|
1960
|
+
}
|
|
1961
|
+
console.log("");
|
|
1962
|
+
const projects = listSyncedProjects();
|
|
1963
|
+
if (projects.length > 0) {
|
|
1964
|
+
console.log("=== Synced Projects ===");
|
|
1965
|
+
for (const p of projects) {
|
|
1966
|
+
console.log(` - ${p}`);
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
function checkGhAuth() {
|
|
1971
|
+
const ghCheck = spawnSync2("gh", ["auth", "status"], { encoding: "utf-8" });
|
|
1972
|
+
if (ghCheck.error || ghCheck.status !== 0) {
|
|
1973
|
+
console.error('gh command is not available or not authenticated. Run "gh auth login" first.');
|
|
1974
|
+
process.exit(1);
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
function handleUpload(options) {
|
|
1978
|
+
const syncConfig = loadSyncConfig();
|
|
1979
|
+
if (!syncConfig?.enabled) {
|
|
1980
|
+
console.error('Not synced. Run "tm sync add" first.');
|
|
1981
|
+
process.exit(1);
|
|
1982
|
+
}
|
|
1983
|
+
const globalConfig = loadGlobalConfig();
|
|
1984
|
+
const { recipient } = resolveEncryptSettings(syncConfig, globalConfig);
|
|
1985
|
+
const store = loadStore();
|
|
1986
|
+
if (!pushToSync(syncConfig.id, store, recipient)) {
|
|
1987
|
+
process.exit(1);
|
|
1988
|
+
}
|
|
1989
|
+
console.log(`Saved. (id: ${syncConfig.id})`);
|
|
1990
|
+
runGitCommand(["add", "."]);
|
|
1991
|
+
const now = new Date;
|
|
1992
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
1993
|
+
const defaultMessage = `sync: ${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
|
1994
|
+
const message = typeof options.message === "string" ? options.message : defaultMessage;
|
|
1995
|
+
const commitResult = spawnSync2("git", ["commit", "-m", message], {
|
|
1996
|
+
cwd: getSyncDir(),
|
|
1997
|
+
encoding: "utf-8",
|
|
1998
|
+
stdio: "pipe"
|
|
1999
|
+
});
|
|
2000
|
+
if (commitResult.status !== 0) {
|
|
2001
|
+
if (commitResult.stdout.includes("nothing to commit")) {
|
|
2002
|
+
console.log("Nothing to commit.");
|
|
2003
|
+
} else {
|
|
2004
|
+
console.error("Failed to commit.");
|
|
2005
|
+
console.error(commitResult.stderr);
|
|
2006
|
+
process.exit(1);
|
|
2007
|
+
}
|
|
2008
|
+
} else {
|
|
2009
|
+
console.log(`Committed: ${message}`);
|
|
2010
|
+
}
|
|
2011
|
+
const pushResult = spawnSync2("git", ["push", "--set-upstream", "origin", "HEAD"], {
|
|
2012
|
+
cwd: getSyncDir(),
|
|
2013
|
+
stdio: "inherit"
|
|
2014
|
+
});
|
|
2015
|
+
if (pushResult.status !== 0) {
|
|
2016
|
+
console.error("Failed to push.");
|
|
2017
|
+
process.exit(1);
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
function handleRepoCreate(options) {
|
|
2021
|
+
checkGhAuth();
|
|
2022
|
+
if (!ensureInitialized()) {
|
|
2023
|
+
console.error("Failed to initialize sync repository");
|
|
2024
|
+
process.exit(1);
|
|
2025
|
+
}
|
|
2026
|
+
const isPublic = options.public === true;
|
|
2027
|
+
const repoName = typeof options.name === "string" ? options.name : "sync-task-memory";
|
|
2028
|
+
const visibility = isPublic ? "--public" : "--private";
|
|
2029
|
+
console.log(`Creating ${isPublic ? "public" : "private"} repository: ${repoName}`);
|
|
2030
|
+
const createResult = spawnSync2("gh", ["repo", "create", repoName, visibility], {
|
|
2031
|
+
encoding: "utf-8",
|
|
2032
|
+
stdio: "pipe"
|
|
2033
|
+
});
|
|
2034
|
+
if (createResult.status !== 0) {
|
|
2035
|
+
console.error("Failed to create repository.");
|
|
2036
|
+
console.error(createResult.stderr);
|
|
2037
|
+
process.exit(1);
|
|
2038
|
+
}
|
|
2039
|
+
const repoUrl = createResult.stdout.trim();
|
|
2040
|
+
console.log(`Repository created: ${repoUrl}`);
|
|
2041
|
+
spawnSync2("git", ["remote", "remove", "origin"], { cwd: getSyncDir(), encoding: "utf-8" });
|
|
2042
|
+
spawnSync2("git", ["remote", "add", "origin", repoUrl], { cwd: getSyncDir(), stdio: "inherit" });
|
|
2043
|
+
console.log(`Remote set. Use "tm git push" to push your data.`);
|
|
2044
|
+
}
|
|
2045
|
+
function handleRepoSet(positional) {
|
|
2046
|
+
const url = positional[1];
|
|
2047
|
+
if (!url) {
|
|
2048
|
+
console.error("Usage: tm sync repo set <url>");
|
|
2049
|
+
process.exit(1);
|
|
2050
|
+
}
|
|
2051
|
+
if (!ensureInitialized()) {
|
|
2052
|
+
console.error("Failed to initialize sync repository");
|
|
2053
|
+
process.exit(1);
|
|
2054
|
+
}
|
|
2055
|
+
spawnSync2("git", ["remote", "remove", "origin"], { cwd: getSyncDir(), encoding: "utf-8" });
|
|
2056
|
+
const addResult = spawnSync2("git", ["remote", "add", "origin", url], {
|
|
2057
|
+
cwd: getSyncDir(),
|
|
2058
|
+
stdio: "inherit"
|
|
2059
|
+
});
|
|
2060
|
+
if (addResult.status !== 0) {
|
|
2061
|
+
console.error("Failed to set remote.");
|
|
2062
|
+
process.exit(1);
|
|
2063
|
+
}
|
|
2064
|
+
console.log(`Remote set: ${url}`);
|
|
2065
|
+
}
|
|
2066
|
+
function handleRepoShow() {
|
|
2067
|
+
const result = spawnSync2("git", ["remote", "-v"], {
|
|
2068
|
+
cwd: getSyncDir(),
|
|
2069
|
+
encoding: "utf-8"
|
|
2070
|
+
});
|
|
2071
|
+
if (result.status !== 0 || !result.stdout.trim()) {
|
|
2072
|
+
console.log('No remote configured. Use "tm sync repo create" or "tm sync repo set <url>".');
|
|
2073
|
+
return;
|
|
2074
|
+
}
|
|
2075
|
+
console.log(result.stdout.trim());
|
|
2076
|
+
}
|
|
2077
|
+
function handleRepo(positional, options) {
|
|
2078
|
+
const subcommand = positional[0];
|
|
2079
|
+
switch (subcommand) {
|
|
2080
|
+
case "create":
|
|
2081
|
+
handleRepoCreate(options);
|
|
2082
|
+
break;
|
|
2083
|
+
case "set":
|
|
2084
|
+
handleRepoSet(positional);
|
|
2085
|
+
break;
|
|
2086
|
+
case "show":
|
|
2087
|
+
handleRepoShow();
|
|
2088
|
+
break;
|
|
2089
|
+
default:
|
|
2090
|
+
console.error("Usage: tm sync repo <create|set|show>");
|
|
2091
|
+
process.exit(1);
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
function handleList2() {
|
|
2095
|
+
const projects = listSyncedProjects();
|
|
2096
|
+
if (projects.length === 0) {
|
|
2097
|
+
console.log("No projects synced.");
|
|
2098
|
+
return;
|
|
2099
|
+
}
|
|
2100
|
+
console.log("Synced projects:");
|
|
2101
|
+
for (const p of projects) {
|
|
2102
|
+
console.log(` - ${p}`);
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
function showHelp() {
|
|
2106
|
+
console.log(`
|
|
2107
|
+
Usage: tm sync <subcommand> [options]
|
|
2108
|
+
|
|
2109
|
+
Subcommands:
|
|
2110
|
+
add [--id <name>] [--save]
|
|
2111
|
+
Add current project to sync
|
|
2112
|
+
remove Remove current project from sync
|
|
2113
|
+
save Save tasks to sync directory
|
|
2114
|
+
upload [--message <msg>]
|
|
2115
|
+
Save, git commit, and git push
|
|
2116
|
+
load [--merge] Load tasks from sync directory
|
|
2117
|
+
set <auto|manual> Set sync mode
|
|
2118
|
+
set encrypt <on|off> Enable/disable encryption for current project
|
|
2119
|
+
set encrypt <on|off> --global
|
|
2120
|
+
Enable/disable encryption globally (used as fallback)
|
|
2121
|
+
set key <path> Set key file (private key); recipient auto-extracted
|
|
2122
|
+
set key --generate Generate a new key file; recipient auto-extracted
|
|
2123
|
+
set key [--generate] --global
|
|
2124
|
+
Apply setting globally (used as fallback)
|
|
2125
|
+
repo create [--name <name>] [--public]
|
|
2126
|
+
Create GitHub repository and set as remote (requires gh)
|
|
2127
|
+
Default name: sync-task-memory
|
|
2128
|
+
repo set <url> Set existing repository as remote
|
|
2129
|
+
repo show Show current remote
|
|
2130
|
+
status Show sync status
|
|
2131
|
+
list List synced projects
|
|
2132
|
+
|
|
2133
|
+
Examples:
|
|
2134
|
+
tm sync add --id my-project --save
|
|
2135
|
+
tm sync set key --generate --global # age鍵を生成しグローバルに設定
|
|
2136
|
+
tm sync set key ~/.age.key --global # 既存の鍵をグローバルに設定
|
|
2137
|
+
tm sync set encrypt on --global # 暗号化をグローバルでデフォルト有効化
|
|
2138
|
+
tm sync set encrypt off # 現プロジェクトだけ暗号化を無効化
|
|
2139
|
+
tm sync repo create # privateリポジトリを作成
|
|
2140
|
+
tm sync repo create --public # publicリポジトリを作成
|
|
2141
|
+
tm sync repo create --name my-tasks # リポジトリ名を指定
|
|
2142
|
+
tm sync save
|
|
2143
|
+
tm sync load --merge
|
|
2144
|
+
tm sync set auto
|
|
2145
|
+
`);
|
|
2146
|
+
}
|
|
2147
|
+
function syncCommand(args) {
|
|
2148
|
+
const { subcommand, options, positional } = parseArgs(args);
|
|
2149
|
+
switch (subcommand) {
|
|
2150
|
+
case "add":
|
|
2151
|
+
handleAdd(options);
|
|
2152
|
+
break;
|
|
2153
|
+
case "remove":
|
|
2154
|
+
case "rm":
|
|
2155
|
+
handleRemove();
|
|
2156
|
+
break;
|
|
2157
|
+
case "save":
|
|
2158
|
+
handleSave();
|
|
2159
|
+
break;
|
|
2160
|
+
case "upload":
|
|
2161
|
+
handleUpload(options);
|
|
2162
|
+
break;
|
|
2163
|
+
case "load":
|
|
2164
|
+
handleLoad(options);
|
|
2165
|
+
break;
|
|
2166
|
+
case "set":
|
|
2167
|
+
handleSet(positional, options);
|
|
2168
|
+
break;
|
|
2169
|
+
case "repo":
|
|
2170
|
+
handleRepo(positional, options);
|
|
2171
|
+
break;
|
|
2172
|
+
case "status":
|
|
2173
|
+
handleStatus();
|
|
2174
|
+
break;
|
|
2175
|
+
case "list":
|
|
2176
|
+
case "ls":
|
|
2177
|
+
handleList2();
|
|
2178
|
+
break;
|
|
2179
|
+
case "help":
|
|
2180
|
+
case "--help":
|
|
2181
|
+
case "-h":
|
|
2182
|
+
showHelp();
|
|
2183
|
+
break;
|
|
2184
|
+
default:
|
|
2185
|
+
if (!subcommand) {
|
|
2186
|
+
showHelp();
|
|
2187
|
+
} else {
|
|
2188
|
+
console.error(`Unknown subcommand: ${subcommand}`);
|
|
2189
|
+
showHelp();
|
|
2190
|
+
process.exit(1);
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
// src/commands/git.ts
|
|
2196
|
+
function gitCommand(args) {
|
|
2197
|
+
if (!isSyncInitialized()) {
|
|
2198
|
+
console.error('Sync repository not initialized. Run "tm sync add" first.');
|
|
2199
|
+
process.exit(1);
|
|
2200
|
+
}
|
|
2201
|
+
if (args.length === 0) {
|
|
2202
|
+
console.log(`Git repository at: ${getSyncDir()}`);
|
|
2203
|
+
console.log("Usage: tm git <git-command> [args]");
|
|
2204
|
+
console.log("");
|
|
2205
|
+
console.log("Examples:");
|
|
2206
|
+
console.log(" tm git status");
|
|
2207
|
+
console.log(" tm git remote add origin git@github.com:user/task-memory.git");
|
|
2208
|
+
console.log(" tm git push");
|
|
2209
|
+
console.log(" tm git pull");
|
|
2210
|
+
return;
|
|
2211
|
+
}
|
|
2212
|
+
const exitCode = runGitCommand(args);
|
|
2213
|
+
process.exit(exitCode);
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
// docs/usage/index.md
|
|
2217
|
+
var usage_default = `# ユーザーガイド
|
|
2218
|
+
|
|
2219
|
+
\`task-memory\` (tm) は、AIエージェントや開発者がタスクの状態を管理し、コンテキストの喪失を防ぐためのツールです。
|
|
2220
|
+
|
|
2221
|
+
このドキュメントは \`tm docs\` コマンドでいつでも参照できます。
|
|
2222
|
+
|
|
2223
|
+
## インストール方法
|
|
2224
|
+
|
|
2225
|
+
### ローカルインストール
|
|
2226
|
+
|
|
2227
|
+
このリポジトリをクローンし、ローカルコマンドとしてリンクします。
|
|
2228
|
+
|
|
2229
|
+
\`\`\`bash
|
|
2230
|
+
git clone <repository-url> task-memory
|
|
2231
|
+
cd task-memory
|
|
2232
|
+
bun install
|
|
2233
|
+
bun link
|
|
2234
|
+
\`\`\`
|
|
2235
|
+
|
|
2236
|
+
これで \`tm\` コマンドがグローバルに使用可能になります(Bunのbinパスが通っている必要があります)。
|
|
2237
|
+
|
|
2238
|
+
または、エイリアスを設定して使用することも可能です。
|
|
2239
|
+
|
|
2240
|
+
\`\`\`bash
|
|
2241
|
+
alias tm="bun run /path/to/task-memory/src/index.ts"
|
|
2242
|
+
\`\`\`
|
|
2243
|
+
|
|
2244
|
+
## 基本的な使い方
|
|
2245
|
+
|
|
2246
|
+
### 1. タスクの作成 (\`tm new\`)
|
|
2247
|
+
|
|
2248
|
+
新しいタスクを開始する際に使用します。
|
|
2249
|
+
|
|
2250
|
+
\`\`\`bash
|
|
2251
|
+
tm new "認証機能のリファクタリング"
|
|
2252
|
+
# 出力: TASK-1 認証機能のリファクタリング
|
|
2253
|
+
\`\`\`
|
|
2254
|
+
|
|
2255
|
+
#### 進行順序の設定
|
|
2256
|
+
|
|
2257
|
+
タスクに進行順序(order)を設定することで、作業の優先順位を階層的に管理できます。
|
|
2258
|
+
|
|
2259
|
+
**基本的な使い方:**
|
|
2260
|
+
\`\`\`bash
|
|
2261
|
+
# 順番に並べる
|
|
2262
|
+
tm new "Task 1" --order 1
|
|
2263
|
+
tm new "Task 2" --order 2
|
|
2264
|
+
tm new "Task 3" --order 3
|
|
2265
|
+
\`\`\`
|
|
2266
|
+
|
|
2267
|
+
**階層的な順序:**
|
|
2268
|
+
\`\`\`bash
|
|
2269
|
+
# 親タスク
|
|
2270
|
+
tm new "Database Migration" --order 1
|
|
2271
|
+
|
|
2272
|
+
# 子タスク(1の下位タスク)
|
|
2273
|
+
tm new "Backup current DB" --order 1-1
|
|
2274
|
+
tm new "Run migration script" --order 1-2
|
|
2275
|
+
tm new "Verify data integrity" --order 1-3
|
|
2276
|
+
|
|
2277
|
+
# 孫タスク(1-2の下位タスク)
|
|
2278
|
+
tm new "Test migration locally" --order 1-2-1
|
|
2279
|
+
tm new "Deploy to staging" --order 1-2-2
|
|
2280
|
+
|
|
2281
|
+
# 別の親タスク
|
|
2282
|
+
tm new "Documentation" --order 2
|
|
2283
|
+
\`\`\`
|
|
2284
|
+
|
|
2285
|
+
**小数での挿入:**
|
|
2286
|
+
\`\`\`bash
|
|
2287
|
+
# すでに 1, 2 がある場合、間に挿入
|
|
2288
|
+
tm new "Insert between 1 and 2" --order 1.5
|
|
2289
|
+
# 保存時に自動的に正規化されて 1, 2, 3 になります
|
|
2290
|
+
\`\`\`
|
|
2291
|
+
|
|
2292
|
+
**order の特徴:**
|
|
2293
|
+
- \`todo\`, \`wip\` ステータスのみ order を保持
|
|
2294
|
+
- \`done\`, \`closed\`, \`pending\`, \`long\` に変更すると order は自動的に解除
|
|
2295
|
+
- 欠番は自動的に詰められる(1, 3, 5 → 1, 2, 3)
|
|
2296
|
+
- 孫タスクがある場合、その親番号は確保される
|
|
2297
|
+
- 既存の order と同じ値を設定すると、新しいタスクが優先され、既存タスクは後ろにシフトされる
|
|
2298
|
+
|
|
2299
|
+
**既存 order への挿入(インサートセマンティクス):**
|
|
2300
|
+
\`\`\`bash
|
|
2301
|
+
# order=1 が A, B, C に割り当てられている場合に新タスクへ order=1 を設定すると
|
|
2302
|
+
# → 新タスク=1, A=2, B=3, C=4 と全体がシフトする
|
|
2303
|
+
tm update <新タスクID> --order 1
|
|
2304
|
+
\`\`\`
|
|
2305
|
+
|
|
2306
|
+
### 2. タスク一覧の表示 (\`tm list\`)
|
|
2307
|
+
|
|
2308
|
+
現在進行中(\`todo\`, \`wip\`)のタスクを表示します。
|
|
2309
|
+
|
|
2310
|
+
\`\`\`bash
|
|
2311
|
+
tm list
|
|
2312
|
+
# または短縮形
|
|
2313
|
+
tm ls
|
|
2314
|
+
tm l
|
|
2315
|
+
# 出力: 1: 認証機能のリファクタリング [todo]
|
|
2316
|
+
\`\`\`
|
|
2317
|
+
|
|
2318
|
+
#### フィルタリングオプション
|
|
2319
|
+
|
|
2320
|
+
タスク一覧は様々な条件でフィルタリングできます。
|
|
2321
|
+
|
|
2322
|
+
**ステータスによるフィルタリング:**
|
|
2323
|
+
\`\`\`bash
|
|
2324
|
+
# すべてのタスク(done/closed含む)を表示
|
|
2325
|
+
tm list --status-all
|
|
2326
|
+
tm ls -a
|
|
2327
|
+
|
|
2328
|
+
# オープンなタスク(todo, wip, pending, long)を表示
|
|
2329
|
+
tm list --open
|
|
2330
|
+
|
|
2331
|
+
# 特定のステータスのタスクのみ表示
|
|
2332
|
+
tm list --status pending
|
|
2333
|
+
tm ls -s wip
|
|
2334
|
+
tm ls -s done
|
|
2335
|
+
\`\`\`
|
|
2336
|
+
|
|
2337
|
+
**優先度によるフィルタリング:**
|
|
2338
|
+
\`\`\`bash
|
|
2339
|
+
tm list --priority high
|
|
2340
|
+
tm list --priority medium
|
|
2341
|
+
\`\`\`
|
|
2342
|
+
|
|
2343
|
+
**バージョンによるフィルタリング:**
|
|
2344
|
+
\`\`\`bash
|
|
2345
|
+
# 特定のバージョン
|
|
2346
|
+
tm list --version 1.0.0
|
|
2347
|
+
|
|
2348
|
+
# TBD(未リリース)のタスク
|
|
2349
|
+
tm list --tbd
|
|
2350
|
+
|
|
2351
|
+
# リリース済みのタスク(version が tbd 以外)
|
|
2352
|
+
tm list --released
|
|
2353
|
+
\`\`\`
|
|
2354
|
+
|
|
2355
|
+
**表示件数の制限:**
|
|
2356
|
+
\`\`\`bash
|
|
2357
|
+
# 最初の5件のみ表示
|
|
2358
|
+
tm list --head 5
|
|
2359
|
+
|
|
2360
|
+
# 最後の10件のみ表示
|
|
2361
|
+
tm list --tail 10
|
|
2362
|
+
|
|
2363
|
+
# デフォルト値(10件)で制限
|
|
2364
|
+
tm list --head
|
|
2365
|
+
tm list --tail
|
|
2366
|
+
\`\`\`
|
|
2367
|
+
|
|
2368
|
+
**組み合わせ:**
|
|
2369
|
+
\`\`\`bash
|
|
2370
|
+
# リリース済みタスクの最初の3件
|
|
2371
|
+
tm list --released --head 3
|
|
2372
|
+
|
|
2373
|
+
# 高優先度のpendingタスク
|
|
2374
|
+
tm list --priority high --status pending
|
|
2375
|
+
\`\`\`
|
|
2376
|
+
|
|
2377
|
+
#### ソート順の指定
|
|
2378
|
+
|
|
2379
|
+
デフォルトでは進行順序(order)でソートされます。
|
|
2380
|
+
|
|
2381
|
+
\`\`\`bash
|
|
2382
|
+
# 進行順序でソート(デフォルト)
|
|
2383
|
+
tm list --sort order
|
|
2384
|
+
# order が未設定のタスクは後ろに表示されます
|
|
2385
|
+
# 1 < 1-1 < 1-2 < 2 < 2-1 の順
|
|
2386
|
+
|
|
2387
|
+
# タスクID順
|
|
2388
|
+
tm list --sort id
|
|
2389
|
+
|
|
2390
|
+
# 作成日時順
|
|
2391
|
+
tm list --sort created
|
|
2392
|
+
\`\`\`
|
|
2393
|
+
|
|
2394
|
+
### 3. タスクの更新 (\`tm update\`)
|
|
2395
|
+
|
|
2396
|
+
タスクの状態を更新したり、作業ログ(body)を追記したりします。
|
|
2397
|
+
複数のタスクを一度に更新することも可能です。
|
|
2398
|
+
|
|
2399
|
+
**ステータスの変更:**
|
|
2400
|
+
|
|
2401
|
+
\`\`\`bash
|
|
2402
|
+
tm update 1 --status wip
|
|
2403
|
+
\`\`\`
|
|
2404
|
+
|
|
2405
|
+
**作業ログの追記:**
|
|
2406
|
+
|
|
2407
|
+
\`\`\`bash
|
|
2408
|
+
tm update 1 --body "JWTの実装を開始"
|
|
2409
|
+
\`\`\`
|
|
2410
|
+
|
|
2411
|
+
**関連ファイルの追加(AIエージェント向け):**
|
|
2412
|
+
|
|
2413
|
+
\`\`\`bash
|
|
2414
|
+
tm update 1 --add-file src/auth.ts
|
|
2415
|
+
\`\`\`
|
|
2416
|
+
|
|
2417
|
+
**進行順序の更新:**
|
|
2418
|
+
|
|
2419
|
+
\`\`\`bash
|
|
2420
|
+
# order を設定
|
|
2421
|
+
tm update 1 --order 1-2
|
|
2422
|
+
|
|
2423
|
+
# order を解除
|
|
2424
|
+
tm update 1 --order null
|
|
2425
|
+
\`\`\`
|
|
2426
|
+
|
|
2427
|
+
**複数タスクの同時更新(コンテキストスイッチ):**
|
|
2428
|
+
|
|
2429
|
+
\`\`\`bash
|
|
2430
|
+
tm update 1 --status done 2 --status wip --body "バグ調査中"
|
|
2431
|
+
# タスク1を完了にし、タスク2をWIPにしてログを追記
|
|
2432
|
+
\`\`\`
|
|
2433
|
+
|
|
2434
|
+
### 4. タスク詳細の確認 (\`tm get\`)
|
|
2435
|
+
|
|
2436
|
+
タスクの詳細情報(JSON形式)を取得します。AIエージェントがコンテキストを復元するのに役立ちます。
|
|
2437
|
+
|
|
2438
|
+
\`\`\`bash
|
|
2439
|
+
tm get 1
|
|
2440
|
+
\`\`\`
|
|
2441
|
+
|
|
2442
|
+
履歴をすべて表示するには \`--history\` または \`--all\` オプションを使用します。
|
|
2443
|
+
|
|
2444
|
+
\`\`\`bash
|
|
2445
|
+
tm get 1 --history
|
|
2446
|
+
\`\`\`
|
|
2447
|
+
|
|
2448
|
+
### 5. タスクの完了 (\`tm finish\`)
|
|
2449
|
+
|
|
2450
|
+
タスクを完了状態(\`done\`)にします。
|
|
2451
|
+
|
|
2452
|
+
\`\`\`bash
|
|
2453
|
+
tm finish 1
|
|
2454
|
+
\`\`\`
|
|
2455
|
+
|
|
2456
|
+
## データの保存場所
|
|
2457
|
+
|
|
2458
|
+
以下の優先順位でデータの保存先が決定されます。
|
|
2459
|
+
|
|
2460
|
+
| 優先度 | 条件 | 保存先 |
|
|
2461
|
+
|--------|------|--------|
|
|
2462
|
+
| 1 | 環境変数 \`TASK_MEMORY_PATH\` が設定されている | 環境変数で指定したパス |
|
|
2463
|
+
| 2 | \`.git\` ディレクトリが存在し、かつ \`.git/task-memory.json\` が存在する | \`.git/task-memory.json\` |
|
|
2464
|
+
| 3 | \`.git\`(ファイル・ディレクトリ問わず)と同じ階層に \`task-memory.json\` が存在する | \`<プロジェクトルート>/task-memory.json\` |
|
|
2465
|
+
| 4 | \`.git\` と同じ階層に \`.task-memory.json\` が存在する | \`<プロジェクトルート>/.task-memory.json\` |
|
|
2466
|
+
| 5 | \`.git\` が存在するが保存ファイルがない(新規) | \`.git/task-memory.json\`(\`.git\` がディレクトリの場合)または \`<プロジェクトルート>/task-memory.json\` |
|
|
2467
|
+
| 6 | \`.git\` が存在しない | \`~/.task-memory.json\` |
|
|
2468
|
+
|
|
2469
|
+
### git worktree での利用
|
|
2470
|
+
|
|
2471
|
+
\`git worktree\` 使用時は \`.git\` がファイルになりますが、そのファイルと同じ階層の \`task-memory.json\` または \`.task-memory.json\` が自動的に参照されます。これにより、worktree ごとに独立したタスク管理が可能です。
|
|
2472
|
+
|
|
2473
|
+
## 同期機能 (\`tm sync\`)
|
|
2474
|
+
|
|
2475
|
+
タスクデータを \`~/.local/task-memory/\` 経由で複数環境間で同期します。
|
|
2476
|
+
|
|
2477
|
+
### セットアップ
|
|
2478
|
+
|
|
2479
|
+
\`\`\`bash
|
|
2480
|
+
# 同期を有効化(sync ID を自動生成)
|
|
2481
|
+
tm sync add
|
|
2482
|
+
|
|
2483
|
+
# 同期 ID を指定(複数マシン間で同じIDを使う場合)
|
|
2484
|
+
tm sync add --id my-project-sync
|
|
2485
|
+
\`\`\`
|
|
2486
|
+
|
|
2487
|
+
### 基本操作
|
|
2488
|
+
|
|
2489
|
+
\`\`\`bash
|
|
2490
|
+
# タスクを同期ディレクトリに保存
|
|
2491
|
+
tm sync save
|
|
2492
|
+
|
|
2493
|
+
# 同期ディレクトリからタスクを読み込み(上書き)
|
|
2494
|
+
tm sync load
|
|
2495
|
+
|
|
2496
|
+
# 読み込み時にローカルとマージ
|
|
2497
|
+
tm sync load --merge
|
|
2498
|
+
|
|
2499
|
+
# 保存・コミット・プッシュを一括実行
|
|
2500
|
+
tm sync upload
|
|
2501
|
+
|
|
2502
|
+
# コミットメッセージを指定
|
|
2503
|
+
tm sync upload --message "作業完了"
|
|
2504
|
+
|
|
2505
|
+
# 同期状態を確認
|
|
2506
|
+
tm sync status
|
|
2507
|
+
|
|
2508
|
+
# 同期を無効化
|
|
2509
|
+
tm sync remove
|
|
2510
|
+
\`\`\`
|
|
2511
|
+
|
|
2512
|
+
### GitHubリポジトリとの連携
|
|
2513
|
+
|
|
2514
|
+
\`\`\`bash
|
|
2515
|
+
# GitHubにprivateリポジトリを作成してremoteに設定(gh コマンド必要)
|
|
2516
|
+
tm sync repo create
|
|
2517
|
+
|
|
2518
|
+
# リポジトリ名を指定
|
|
2519
|
+
tm sync repo create --name my-sync-repo
|
|
2520
|
+
|
|
2521
|
+
# publicリポジトリとして作成
|
|
2522
|
+
tm sync repo create --public
|
|
2523
|
+
|
|
2524
|
+
# 既存のリポジトリをremoteに設定
|
|
2525
|
+
tm sync repo set git@github.com:user/repo.git
|
|
2526
|
+
|
|
2527
|
+
# 現在のremoteを確認
|
|
2528
|
+
tm sync repo show
|
|
2529
|
+
\`\`\`
|
|
2530
|
+
|
|
2531
|
+
リポジトリ設定後は \`tm git push\` / \`tm git pull\` で同期できます。
|
|
2532
|
+
|
|
2533
|
+
### マージ動作
|
|
2534
|
+
|
|
2535
|
+
\`--merge\` オプションでは以下のルールでマージされます:
|
|
2536
|
+
|
|
2537
|
+
- **同一タスク**(ID と作成日時が一致): \`updated_at\` が新しい方を優先
|
|
2538
|
+
- **ID 衝突**(同じ ID だが別のタスク): リモートのタスクに新しい ID を割り当てて追加
|
|
2539
|
+
- **新規タスク**: そのまま追加
|
|
2540
|
+
|
|
2541
|
+
### 暗号化
|
|
2542
|
+
|
|
2543
|
+
[age](https://age-encryption.org/) による非対称暗号化をサポートします。
|
|
2544
|
+
|
|
2545
|
+
\`\`\`bash
|
|
2546
|
+
# identity ファイルを生成してグローバルに設定(公開鍵も自動設定)
|
|
2547
|
+
tm sync set key --generate --global
|
|
2548
|
+
|
|
2549
|
+
# 既存の identity ファイルをグローバルに設定
|
|
2550
|
+
tm sync set key ~/.age.key --global
|
|
2551
|
+
|
|
2552
|
+
# 暗号化を有効化(グローバル)
|
|
2553
|
+
tm sync set encrypt on --global
|
|
2554
|
+
|
|
2555
|
+
# 特定プロジェクトのみ暗号化を無効化
|
|
2556
|
+
tm sync set encrypt off
|
|
2557
|
+
\`\`\`
|
|
2558
|
+
|
|
2559
|
+
- 暗号化時: \`identity\` ファイルから公開鍵を抽出して暗号化(push 側)
|
|
2560
|
+
- 復号時: \`identity\` ファイルの秘密鍵で復号(pull 側)
|
|
2561
|
+
- 同期ファイルは \`.json.age\` として保存される
|
|
2562
|
+
|
|
2563
|
+
**注意**: identity ファイルを紛失するとデータを復元できません。安全な場所にバックアップしてください。
|
|
2564
|
+
`;
|
|
2565
|
+
|
|
2566
|
+
// src/index.ts
|
|
2567
|
+
setAfterSaveCallback((store) => {
|
|
2568
|
+
tryAutoSync(store.sync, store);
|
|
2569
|
+
});
|
|
2570
|
+
var args = process.argv.slice(2);
|
|
2571
|
+
var command = args[0];
|
|
2572
|
+
var commandArgs = args.slice(1);
|
|
2573
|
+
switch (command) {
|
|
2574
|
+
case "new":
|
|
2575
|
+
newCommand(commandArgs);
|
|
2576
|
+
break;
|
|
2577
|
+
case "list":
|
|
2578
|
+
case "ls":
|
|
2579
|
+
case "l":
|
|
2580
|
+
listCommand(commandArgs);
|
|
2581
|
+
break;
|
|
2582
|
+
case "get":
|
|
2583
|
+
case "g":
|
|
2584
|
+
getCommand(commandArgs);
|
|
2585
|
+
break;
|
|
2586
|
+
case "finish":
|
|
2587
|
+
case "fin":
|
|
2588
|
+
case "f":
|
|
2589
|
+
finishCommand(commandArgs);
|
|
2590
|
+
break;
|
|
2591
|
+
case "update":
|
|
2592
|
+
case "up":
|
|
2593
|
+
case "u":
|
|
2594
|
+
updateCommand(commandArgs);
|
|
2595
|
+
break;
|
|
2596
|
+
case "env":
|
|
2597
|
+
envCommand(commandArgs);
|
|
2598
|
+
break;
|
|
2599
|
+
case "review":
|
|
2600
|
+
case "rev":
|
|
2601
|
+
case "tmr":
|
|
2602
|
+
reviewCommand(commandArgs);
|
|
2603
|
+
break;
|
|
2604
|
+
case "release":
|
|
2605
|
+
releaseCommand(commandArgs);
|
|
2606
|
+
break;
|
|
2607
|
+
case "close":
|
|
2608
|
+
closeCommand(commandArgs);
|
|
2609
|
+
break;
|
|
2610
|
+
case "sync":
|
|
2611
|
+
syncCommand(commandArgs);
|
|
2612
|
+
break;
|
|
2613
|
+
case "git":
|
|
2614
|
+
gitCommand(commandArgs);
|
|
2615
|
+
break;
|
|
2616
|
+
case "docs":
|
|
2617
|
+
console.log(usage_default);
|
|
2618
|
+
break;
|
|
2619
|
+
case "help":
|
|
2620
|
+
case "--help":
|
|
2621
|
+
case "-h":
|
|
2622
|
+
console.log(`
|
|
2623
|
+
Usage: tm <command> [args]
|
|
2624
|
+
|
|
2625
|
+
Commands:
|
|
2626
|
+
new <summary> [options]
|
|
2627
|
+
Create a new task.
|
|
2628
|
+
Options:
|
|
2629
|
+
--status, -s <status> Set initial status (todo, wip, done, pending, long, closed)
|
|
2630
|
+
--priority, -p <value> Set priority
|
|
2631
|
+
--goal, -g <text> Set completion goal
|
|
2632
|
+
--body, -b <text> Add initial body text
|
|
2633
|
+
--add-file, -a <path> Add editable file
|
|
2634
|
+
--read-file, -r <path> Add read-only file
|
|
2635
|
+
|
|
2636
|
+
list (ls, l) [options]
|
|
2637
|
+
List active tasks (todo, wip).
|
|
2638
|
+
Options:
|
|
2639
|
+
--status-all, -a Show all tasks (including done/closed)
|
|
2640
|
+
--open Show all open tasks (todo, wip, pending, long)
|
|
2641
|
+
--priority <p> Filter by priority
|
|
2642
|
+
--status, -s <status> Filter by status
|
|
2643
|
+
--version <v> Filter by version
|
|
2644
|
+
--tbd Filter by version 'tbd' (includes closed/done)
|
|
2645
|
+
--released Filter by released tasks (non-tbd version)
|
|
2646
|
+
--head [N] Show first N tasks (default: 10)
|
|
2647
|
+
--tail [N] Show last N tasks (default: 10)
|
|
2648
|
+
|
|
2649
|
+
get (g) <id...> [options]
|
|
2650
|
+
Get task details (JSON).
|
|
2651
|
+
Options:
|
|
2652
|
+
--all, -a, --history, -h Show full history of bodies
|
|
2653
|
+
|
|
2654
|
+
finish (fin, f) <id...>
|
|
2655
|
+
Mark task(s) as done.
|
|
2656
|
+
|
|
2657
|
+
update (up, u) <id...> [options]
|
|
2658
|
+
Update task(s). Supports context switching.
|
|
2659
|
+
Options:
|
|
2660
|
+
--status, -s <status> Update status (todo, wip, done, pending, long, closed)
|
|
2661
|
+
--priority, -p <value> Update priority
|
|
2662
|
+
--goal, -g <text> Update completion goal
|
|
2663
|
+
--body, -b <text> Append body text
|
|
2664
|
+
--add-file, -a <path> Add editable file
|
|
2665
|
+
--rm-file, -d <path> Remove editable file
|
|
2666
|
+
--read-file, -r <path> Add read-only file
|
|
2667
|
+
|
|
2668
|
+
env
|
|
2669
|
+
Show the current task data file path.
|
|
2670
|
+
|
|
2671
|
+
review (rev, tmr) <subcommand> [args]
|
|
2672
|
+
Manage reviews.
|
|
2673
|
+
Subcommands: new, list, get, update, return, accept, reject
|
|
2674
|
+
|
|
2675
|
+
release <id...> --version <v>
|
|
2676
|
+
Set version for task(s).
|
|
2677
|
+
|
|
2678
|
+
close <id...> [--body <text>]
|
|
2679
|
+
Close task(s). Alias for update --status closed.
|
|
2680
|
+
|
|
2681
|
+
sync <subcommand> [options]
|
|
2682
|
+
Sync tasks to ~/.local/task-memory/ repository.
|
|
2683
|
+
Subcommands: add, remove, push, pull, set, status, list
|
|
2684
|
+
|
|
2685
|
+
git <git-command> [args]
|
|
2686
|
+
Run git commands in ~/.local/task-memory/ repository.
|
|
2687
|
+
|
|
2688
|
+
docs
|
|
2689
|
+
Show usage documentation.
|
|
2690
|
+
|
|
2691
|
+
Examples:
|
|
2692
|
+
tm new "Refactor auth" --status wip --body "Starting now" --priority high
|
|
2693
|
+
tm update 1 --status done 2 --status wip --body "Fixing bug"
|
|
2694
|
+
tm get 1 --history
|
|
2695
|
+
`);
|
|
2696
|
+
break;
|
|
2697
|
+
default:
|
|
2698
|
+
if (!command) {
|
|
2699
|
+
console.log(`
|
|
2700
|
+
Usage: tm <command> [args]
|
|
2701
|
+
|
|
2702
|
+
Run 'tm help' for detailed usage and examples.
|
|
2703
|
+
`);
|
|
2704
|
+
} else {
|
|
2705
|
+
console.error(`Error: Unknown command '${command}'. Run 'tm help' for usage.`);
|
|
2706
|
+
process.exit(1);
|
|
2707
|
+
}
|
|
2708
|
+
}
|