task-notes 0.1.0__py3-none-any.whl
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.
- task_notes/__init__.py +0 -0
- task_notes/cli.py +568 -0
- task_notes/core.py +331 -0
- task_notes-0.1.0.dist-info/METADATA +146 -0
- task_notes-0.1.0.dist-info/RECORD +7 -0
- task_notes-0.1.0.dist-info/WHEEL +4 -0
- task_notes-0.1.0.dist-info/entry_points.txt +2 -0
task_notes/__init__.py
ADDED
|
File without changes
|
task_notes/cli.py
ADDED
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import select
|
|
8
|
+
import sys
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.prompt import Prompt
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
from rich.tree import Tree
|
|
16
|
+
|
|
17
|
+
from .core import (
|
|
18
|
+
ProjectExistsError,
|
|
19
|
+
ProjectSummary,
|
|
20
|
+
ProjectStore,
|
|
21
|
+
SelectorError,
|
|
22
|
+
TaskDocument,
|
|
23
|
+
TaskError,
|
|
24
|
+
TaskNode,
|
|
25
|
+
TaskNotFoundError,
|
|
26
|
+
iter_selectors,
|
|
27
|
+
slugify,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
DEFAULT_TASKS_DIRNAME = ".task-notes"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(slots=True)
|
|
35
|
+
class _TaskRow:
|
|
36
|
+
selector: str
|
|
37
|
+
text: str
|
|
38
|
+
done: bool
|
|
39
|
+
depth: int
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def default_tasks_dir() -> Path:
|
|
43
|
+
return Path.home() / DEFAULT_TASKS_DIRNAME
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def normalize_tasks_dir(path: Path) -> Path:
|
|
47
|
+
return path.expanduser().resolve()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def validate_tasks_dir(path: Path, allow_external: bool) -> None:
|
|
51
|
+
if allow_external:
|
|
52
|
+
return
|
|
53
|
+
safe_root = normalize_tasks_dir(default_tasks_dir())
|
|
54
|
+
if path != safe_root:
|
|
55
|
+
raise TaskError(
|
|
56
|
+
f"external tasks dir is blocked: {path}. "
|
|
57
|
+
"Use --unsafe-external-dir to allow non-default directories."
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _render_ls(projects: list[ProjectSummary]) -> None:
|
|
62
|
+
console = Console()
|
|
63
|
+
if not projects:
|
|
64
|
+
console.print("[dim]No projects yet.[/dim]")
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
table = Table(
|
|
68
|
+
show_header=True,
|
|
69
|
+
header_style="bold cyan",
|
|
70
|
+
box=None,
|
|
71
|
+
pad_edge=False,
|
|
72
|
+
)
|
|
73
|
+
table.add_column("#", style="dim", no_wrap=True, justify="right")
|
|
74
|
+
table.add_column("Project", style="bold", no_wrap=True)
|
|
75
|
+
table.add_column("Progress", justify="right", no_wrap=True)
|
|
76
|
+
table.add_column("Path", overflow="fold")
|
|
77
|
+
|
|
78
|
+
for index, item in enumerate(projects, start=1):
|
|
79
|
+
progress_style = "green" if item.total > 0 and item.done == item.total else "yellow"
|
|
80
|
+
table.add_row(
|
|
81
|
+
f"{index}.",
|
|
82
|
+
item.name,
|
|
83
|
+
Text(f"{item.done}/{item.total}", style=progress_style),
|
|
84
|
+
str(item.path),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
console.print(table)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _render_show(document: TaskDocument) -> None:
|
|
91
|
+
console = Console()
|
|
92
|
+
done, total = document.progress
|
|
93
|
+
title = Text()
|
|
94
|
+
title.append(document.title, style="bold")
|
|
95
|
+
title.append(f" {done}/{total} done", style="dim")
|
|
96
|
+
|
|
97
|
+
root = Tree(title, guide_style="bright_black")
|
|
98
|
+
if not document.nodes:
|
|
99
|
+
root.add(Text("[ ] no tasks", style="dim"))
|
|
100
|
+
else:
|
|
101
|
+
for index, node in enumerate(document.nodes, start=1):
|
|
102
|
+
_add_tree_node(root, node, str(index))
|
|
103
|
+
|
|
104
|
+
console.print(root)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _add_tree_node(parent: Tree, node: TaskNode, selector: str) -> None:
|
|
108
|
+
label = Text()
|
|
109
|
+
label.append(f"{selector} ", style="cyan")
|
|
110
|
+
if node.done:
|
|
111
|
+
label.append("[x] ", style="green")
|
|
112
|
+
label.append(node.text, style="green")
|
|
113
|
+
else:
|
|
114
|
+
label.append("[ ] ", style="yellow")
|
|
115
|
+
label.append(node.text)
|
|
116
|
+
|
|
117
|
+
branch = parent.add(label)
|
|
118
|
+
for index, child in enumerate(node.children, start=1):
|
|
119
|
+
_add_tree_node(branch, child, f"{selector}.{index}")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _interactive_edit(store: ProjectStore, project: str) -> tuple[str | None, str | None]:
|
|
123
|
+
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
|
124
|
+
raise TaskError("interactive edit requires a TTY terminal")
|
|
125
|
+
|
|
126
|
+
document = store.load_project(project)
|
|
127
|
+
rows = _task_rows(document)
|
|
128
|
+
if not rows:
|
|
129
|
+
raise TaskError("project has no tasks to edit")
|
|
130
|
+
|
|
131
|
+
selected = _pick_task_row(rows, project)
|
|
132
|
+
if selected is None:
|
|
133
|
+
return None, None
|
|
134
|
+
|
|
135
|
+
prompt = f"Edit {selected.selector}"
|
|
136
|
+
updated = Prompt.ask(prompt, default=selected.text)
|
|
137
|
+
return selected.selector, updated
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _task_rows(document: TaskDocument) -> list[_TaskRow]:
|
|
141
|
+
rows: list[_TaskRow] = []
|
|
142
|
+
for selector, node in iter_selectors(document.nodes):
|
|
143
|
+
rows.append(
|
|
144
|
+
_TaskRow(
|
|
145
|
+
selector=selector,
|
|
146
|
+
text=node.text,
|
|
147
|
+
done=node.done,
|
|
148
|
+
depth=selector.count("."),
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
return rows
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _pick_task_row(rows: list[_TaskRow], project: str) -> _TaskRow | None:
|
|
155
|
+
console = Console()
|
|
156
|
+
cursor = 0
|
|
157
|
+
selected_index: int | None = None
|
|
158
|
+
|
|
159
|
+
while True:
|
|
160
|
+
_render_edit_picker(console, rows, project, cursor, selected_index)
|
|
161
|
+
key = _read_key()
|
|
162
|
+
|
|
163
|
+
if key in {"up"}:
|
|
164
|
+
cursor = max(0, cursor - 1)
|
|
165
|
+
elif key in {"down"}:
|
|
166
|
+
cursor = min(len(rows) - 1, cursor + 1)
|
|
167
|
+
elif key == "space":
|
|
168
|
+
selected_index = cursor
|
|
169
|
+
elif key == "enter":
|
|
170
|
+
if selected_index is None:
|
|
171
|
+
selected_index = cursor
|
|
172
|
+
return rows[selected_index]
|
|
173
|
+
elif key in {"q", "escape"}:
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _render_edit_picker(
|
|
178
|
+
console: Console,
|
|
179
|
+
rows: list[_TaskRow],
|
|
180
|
+
project: str,
|
|
181
|
+
cursor: int,
|
|
182
|
+
selected_index: int | None,
|
|
183
|
+
) -> None:
|
|
184
|
+
console.clear()
|
|
185
|
+
console.print(f"[bold]Edit project:[/bold] {project}")
|
|
186
|
+
console.print("[dim]Use ↑/↓ or j/k to move, Space to select, Enter to confirm, q to cancel[/dim]")
|
|
187
|
+
console.print()
|
|
188
|
+
|
|
189
|
+
table = Table(show_header=True, box=None, pad_edge=False)
|
|
190
|
+
table.add_column(" ", no_wrap=True)
|
|
191
|
+
table.add_column("Pick", no_wrap=True)
|
|
192
|
+
table.add_column("Selector", style="cyan", no_wrap=True)
|
|
193
|
+
table.add_column("Task")
|
|
194
|
+
|
|
195
|
+
for index, row in enumerate(rows):
|
|
196
|
+
pointer = ">" if index == cursor else " "
|
|
197
|
+
picked = "[x]" if index == selected_index else "[ ]"
|
|
198
|
+
|
|
199
|
+
task_text = Text(" " * row.depth)
|
|
200
|
+
task_text.append(row.text, style="green" if row.done else "")
|
|
201
|
+
if row.done:
|
|
202
|
+
task_text.append(" (done)", style="dim")
|
|
203
|
+
|
|
204
|
+
table.add_row(pointer, picked, row.selector, task_text)
|
|
205
|
+
|
|
206
|
+
console.print(table)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _read_key() -> str:
|
|
210
|
+
if os.name == "nt":
|
|
211
|
+
import msvcrt
|
|
212
|
+
|
|
213
|
+
first = msvcrt.getwch()
|
|
214
|
+
if first in ("\r", "\n"):
|
|
215
|
+
return "enter"
|
|
216
|
+
if first == " ":
|
|
217
|
+
return "space"
|
|
218
|
+
if first in ("\x00", "\xe0"):
|
|
219
|
+
second = msvcrt.getwch()
|
|
220
|
+
if second == "H":
|
|
221
|
+
return "up"
|
|
222
|
+
if second == "P":
|
|
223
|
+
return "down"
|
|
224
|
+
return "other"
|
|
225
|
+
if first == "\x1b":
|
|
226
|
+
return "escape"
|
|
227
|
+
if first.lower() == "k":
|
|
228
|
+
return "up"
|
|
229
|
+
if first.lower() == "j":
|
|
230
|
+
return "down"
|
|
231
|
+
return first.lower()
|
|
232
|
+
|
|
233
|
+
import termios
|
|
234
|
+
import tty
|
|
235
|
+
|
|
236
|
+
fd = sys.stdin.fileno()
|
|
237
|
+
old_settings = termios.tcgetattr(fd)
|
|
238
|
+
try:
|
|
239
|
+
tty.setraw(fd)
|
|
240
|
+
first = sys.stdin.read(1)
|
|
241
|
+
if first in ("\r", "\n"):
|
|
242
|
+
return "enter"
|
|
243
|
+
if first == " ":
|
|
244
|
+
return "space"
|
|
245
|
+
if first == "\x1b":
|
|
246
|
+
if _stdin_ready(fd):
|
|
247
|
+
second = sys.stdin.read(1)
|
|
248
|
+
if second == "[" and _stdin_ready(fd):
|
|
249
|
+
third = sys.stdin.read(1)
|
|
250
|
+
if third == "A":
|
|
251
|
+
return "up"
|
|
252
|
+
if third == "B":
|
|
253
|
+
return "down"
|
|
254
|
+
return "escape"
|
|
255
|
+
if first.lower() == "k":
|
|
256
|
+
return "up"
|
|
257
|
+
if first.lower() == "j":
|
|
258
|
+
return "down"
|
|
259
|
+
return first.lower()
|
|
260
|
+
finally:
|
|
261
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _stdin_ready(fd: int) -> bool:
|
|
265
|
+
ready, _, _ = select.select([fd], [], [], 0.02)
|
|
266
|
+
return bool(ready)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
270
|
+
parser = argparse.ArgumentParser(prog="task", description="Task Notes CLI")
|
|
271
|
+
parser.add_argument("--tasks-dir", type=Path, default=default_tasks_dir())
|
|
272
|
+
parser.add_argument(
|
|
273
|
+
"--unsafe-external-dir",
|
|
274
|
+
action="store_true",
|
|
275
|
+
help="allow using a tasks directory outside the default ~/.task-notes",
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
279
|
+
|
|
280
|
+
ls_parser = subparsers.add_parser("ls", help="List projects")
|
|
281
|
+
ls_parser.add_argument("--json", action="store_true")
|
|
282
|
+
|
|
283
|
+
show_parser = subparsers.add_parser("show", help="Show project tree")
|
|
284
|
+
show_parser.add_argument("project")
|
|
285
|
+
show_parser.add_argument("--json", action="store_true")
|
|
286
|
+
|
|
287
|
+
next_parser = subparsers.add_parser("next", help="Find next open leaf task")
|
|
288
|
+
next_parser.add_argument("project")
|
|
289
|
+
next_parser.add_argument("--json", action="store_true")
|
|
290
|
+
|
|
291
|
+
new_parser = subparsers.add_parser("new", help="Create project")
|
|
292
|
+
new_parser.add_argument("project")
|
|
293
|
+
new_parser.add_argument("contents", nargs="+")
|
|
294
|
+
new_parser.add_argument("--json", action="store_true")
|
|
295
|
+
|
|
296
|
+
add_parser = subparsers.add_parser("add", help="Add tasks")
|
|
297
|
+
add_parser.add_argument("project")
|
|
298
|
+
add_parser.add_argument("contents", nargs="+")
|
|
299
|
+
add_parser.add_argument("--parent")
|
|
300
|
+
add_parser.add_argument("--json", action="store_true")
|
|
301
|
+
|
|
302
|
+
edit_parser = subparsers.add_parser("edit", help="Edit task content")
|
|
303
|
+
edit_parser.add_argument("project")
|
|
304
|
+
edit_parser.add_argument("--path")
|
|
305
|
+
edit_parser.add_argument("content", nargs="?")
|
|
306
|
+
edit_parser.add_argument("-i", "--interactive", action="store_true")
|
|
307
|
+
edit_parser.add_argument("--json", action="store_true")
|
|
308
|
+
|
|
309
|
+
del_parser = subparsers.add_parser("del", help="Delete task by selector")
|
|
310
|
+
del_parser.add_argument("project")
|
|
311
|
+
del_parser.add_argument("--path", required=True)
|
|
312
|
+
del_parser.add_argument("--json", action="store_true")
|
|
313
|
+
|
|
314
|
+
rm_parser = subparsers.add_parser("rm", help="Delete project")
|
|
315
|
+
rm_parser.add_argument("project")
|
|
316
|
+
rm_parser.add_argument("--json", action="store_true")
|
|
317
|
+
|
|
318
|
+
check_parser = subparsers.add_parser("check", help="Mark a task as done")
|
|
319
|
+
check_parser.add_argument("project")
|
|
320
|
+
check_parser.add_argument("--path", required=True)
|
|
321
|
+
check_parser.add_argument("--json", action="store_true")
|
|
322
|
+
|
|
323
|
+
uncheck_parser = subparsers.add_parser("uncheck", help="Mark a task as todo")
|
|
324
|
+
uncheck_parser.add_argument("project")
|
|
325
|
+
uncheck_parser.add_argument("--path", required=True)
|
|
326
|
+
uncheck_parser.add_argument("--json", action="store_true")
|
|
327
|
+
|
|
328
|
+
toggle_parser = subparsers.add_parser("toggle", help="Toggle task status")
|
|
329
|
+
toggle_parser.add_argument("project")
|
|
330
|
+
toggle_parser.add_argument("--path", required=True)
|
|
331
|
+
toggle_parser.add_argument("--json", action="store_true")
|
|
332
|
+
|
|
333
|
+
return parser
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def main() -> None:
|
|
337
|
+
raise SystemExit(run_cli(sys.argv[1:]))
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def run_cli(argv: list[str]) -> int:
|
|
341
|
+
parser = build_parser()
|
|
342
|
+
args = parser.parse_args(argv)
|
|
343
|
+
emit_json = bool(getattr(args, "json", False))
|
|
344
|
+
|
|
345
|
+
try:
|
|
346
|
+
tasks_dir = normalize_tasks_dir(args.tasks_dir)
|
|
347
|
+
validate_tasks_dir(tasks_dir, allow_external=args.unsafe_external_dir)
|
|
348
|
+
store = ProjectStore(tasks_dir)
|
|
349
|
+
match args.command:
|
|
350
|
+
case "ls":
|
|
351
|
+
projects = store.list_projects()
|
|
352
|
+
if emit_json:
|
|
353
|
+
payload = {
|
|
354
|
+
"ok": True,
|
|
355
|
+
"action": "ls",
|
|
356
|
+
"projects": [
|
|
357
|
+
{
|
|
358
|
+
"index": index,
|
|
359
|
+
"project": item.name,
|
|
360
|
+
"done": item.done,
|
|
361
|
+
"total": item.total,
|
|
362
|
+
"path": str(item.path),
|
|
363
|
+
}
|
|
364
|
+
for index, item in enumerate(projects, start=1)
|
|
365
|
+
],
|
|
366
|
+
}
|
|
367
|
+
print(json.dumps(payload, ensure_ascii=False))
|
|
368
|
+
else:
|
|
369
|
+
_render_ls(projects)
|
|
370
|
+
return 0
|
|
371
|
+
case "show":
|
|
372
|
+
document = store.load_project(args.project)
|
|
373
|
+
rows = []
|
|
374
|
+
for selector, node in iter_selectors(document.nodes):
|
|
375
|
+
rows.append(
|
|
376
|
+
{
|
|
377
|
+
"selector": selector,
|
|
378
|
+
"done": node.done,
|
|
379
|
+
"text": node.text,
|
|
380
|
+
}
|
|
381
|
+
)
|
|
382
|
+
if emit_json:
|
|
383
|
+
payload = {
|
|
384
|
+
"ok": True,
|
|
385
|
+
"action": "show",
|
|
386
|
+
"project": slugify(args.project),
|
|
387
|
+
"rows": rows,
|
|
388
|
+
}
|
|
389
|
+
print(json.dumps(payload, ensure_ascii=False))
|
|
390
|
+
else:
|
|
391
|
+
_render_show(document)
|
|
392
|
+
return 0
|
|
393
|
+
case "next":
|
|
394
|
+
task = store.next_task(args.project)
|
|
395
|
+
if emit_json:
|
|
396
|
+
payload = {
|
|
397
|
+
"ok": True,
|
|
398
|
+
"action": "next",
|
|
399
|
+
"project": slugify(args.project),
|
|
400
|
+
"selector": task.selector if task else None,
|
|
401
|
+
"message": task.text if task else "all tasks completed",
|
|
402
|
+
}
|
|
403
|
+
print(json.dumps(payload, ensure_ascii=False))
|
|
404
|
+
else:
|
|
405
|
+
if task:
|
|
406
|
+
print(f"OK next: {task.selector} {task.text}")
|
|
407
|
+
else:
|
|
408
|
+
print("OK next: all tasks completed")
|
|
409
|
+
return 0
|
|
410
|
+
case "new":
|
|
411
|
+
path = store.new_project(args.project, args.contents)
|
|
412
|
+
if emit_json:
|
|
413
|
+
payload = {
|
|
414
|
+
"ok": True,
|
|
415
|
+
"action": "new",
|
|
416
|
+
"project": slugify(args.project),
|
|
417
|
+
"path": str(path),
|
|
418
|
+
"message": "project created",
|
|
419
|
+
}
|
|
420
|
+
print(json.dumps(payload, ensure_ascii=False))
|
|
421
|
+
else:
|
|
422
|
+
print(f"OK new: {slugify(args.project)}")
|
|
423
|
+
return 0
|
|
424
|
+
case "add":
|
|
425
|
+
store.add_tasks(args.project, args.contents, parent_selector=args.parent)
|
|
426
|
+
if emit_json:
|
|
427
|
+
payload = {
|
|
428
|
+
"ok": True,
|
|
429
|
+
"action": "add",
|
|
430
|
+
"project": slugify(args.project),
|
|
431
|
+
"selector": args.parent,
|
|
432
|
+
"message": "tasks added",
|
|
433
|
+
}
|
|
434
|
+
print(json.dumps(payload, ensure_ascii=False))
|
|
435
|
+
else:
|
|
436
|
+
target = args.parent if args.parent else "root"
|
|
437
|
+
print(f"OK add: {slugify(args.project)} {target}")
|
|
438
|
+
return 0
|
|
439
|
+
case "edit":
|
|
440
|
+
selector: str | None = args.path
|
|
441
|
+
content: str | None = args.content
|
|
442
|
+
|
|
443
|
+
if args.interactive or (args.path is None and args.content is None):
|
|
444
|
+
selector, content = _interactive_edit(store, args.project)
|
|
445
|
+
if selector is None or content is None:
|
|
446
|
+
if emit_json:
|
|
447
|
+
payload = {
|
|
448
|
+
"ok": True,
|
|
449
|
+
"action": "edit",
|
|
450
|
+
"project": slugify(args.project),
|
|
451
|
+
"selector": None,
|
|
452
|
+
"message": "edit canceled",
|
|
453
|
+
}
|
|
454
|
+
print(json.dumps(payload, ensure_ascii=False))
|
|
455
|
+
else:
|
|
456
|
+
print(f"OK edit: {slugify(args.project)} canceled")
|
|
457
|
+
return 0
|
|
458
|
+
|
|
459
|
+
if selector is None or content is None:
|
|
460
|
+
raise TaskError("edit requires both --path and content, or use --interactive")
|
|
461
|
+
|
|
462
|
+
store.edit_task(args.project, selector, content)
|
|
463
|
+
if emit_json:
|
|
464
|
+
payload = {
|
|
465
|
+
"ok": True,
|
|
466
|
+
"action": "edit",
|
|
467
|
+
"project": slugify(args.project),
|
|
468
|
+
"selector": selector,
|
|
469
|
+
"message": "task updated",
|
|
470
|
+
}
|
|
471
|
+
print(json.dumps(payload, ensure_ascii=False))
|
|
472
|
+
else:
|
|
473
|
+
print(f"OK edit: {slugify(args.project)} {selector}")
|
|
474
|
+
return 0
|
|
475
|
+
case "del":
|
|
476
|
+
store.delete_task(args.project, args.path)
|
|
477
|
+
if emit_json:
|
|
478
|
+
payload = {
|
|
479
|
+
"ok": True,
|
|
480
|
+
"action": "del",
|
|
481
|
+
"project": slugify(args.project),
|
|
482
|
+
"selector": args.path,
|
|
483
|
+
"message": "task deleted",
|
|
484
|
+
}
|
|
485
|
+
print(json.dumps(payload, ensure_ascii=False))
|
|
486
|
+
else:
|
|
487
|
+
print(f"OK del: {slugify(args.project)} {args.path}")
|
|
488
|
+
return 0
|
|
489
|
+
case "rm":
|
|
490
|
+
store.remove_project(args.project)
|
|
491
|
+
if emit_json:
|
|
492
|
+
payload = {
|
|
493
|
+
"ok": True,
|
|
494
|
+
"action": "rm",
|
|
495
|
+
"project": slugify(args.project),
|
|
496
|
+
"message": "project deleted",
|
|
497
|
+
}
|
|
498
|
+
print(json.dumps(payload, ensure_ascii=False))
|
|
499
|
+
else:
|
|
500
|
+
print(f"OK rm: {slugify(args.project)}")
|
|
501
|
+
return 0
|
|
502
|
+
case "check":
|
|
503
|
+
store.set_done(args.project, args.path, done=True)
|
|
504
|
+
if emit_json:
|
|
505
|
+
payload = {
|
|
506
|
+
"ok": True,
|
|
507
|
+
"action": "check",
|
|
508
|
+
"project": slugify(args.project),
|
|
509
|
+
"selector": args.path,
|
|
510
|
+
"message": "task checked",
|
|
511
|
+
}
|
|
512
|
+
print(json.dumps(payload, ensure_ascii=False))
|
|
513
|
+
else:
|
|
514
|
+
print(f"OK check: {slugify(args.project)} {args.path}")
|
|
515
|
+
return 0
|
|
516
|
+
case "uncheck":
|
|
517
|
+
store.set_done(args.project, args.path, done=False)
|
|
518
|
+
if emit_json:
|
|
519
|
+
payload = {
|
|
520
|
+
"ok": True,
|
|
521
|
+
"action": "uncheck",
|
|
522
|
+
"project": slugify(args.project),
|
|
523
|
+
"selector": args.path,
|
|
524
|
+
"message": "task unchecked",
|
|
525
|
+
}
|
|
526
|
+
print(json.dumps(payload, ensure_ascii=False))
|
|
527
|
+
else:
|
|
528
|
+
print(f"OK uncheck: {slugify(args.project)} {args.path}")
|
|
529
|
+
return 0
|
|
530
|
+
case "toggle":
|
|
531
|
+
store.toggle_done(args.project, args.path)
|
|
532
|
+
if emit_json:
|
|
533
|
+
payload = {
|
|
534
|
+
"ok": True,
|
|
535
|
+
"action": "toggle",
|
|
536
|
+
"project": slugify(args.project),
|
|
537
|
+
"selector": args.path,
|
|
538
|
+
"message": "task toggled",
|
|
539
|
+
}
|
|
540
|
+
print(json.dumps(payload, ensure_ascii=False))
|
|
541
|
+
else:
|
|
542
|
+
print(f"OK toggle: {slugify(args.project)} {args.path}")
|
|
543
|
+
return 0
|
|
544
|
+
case _:
|
|
545
|
+
parser.error("unknown command")
|
|
546
|
+
return 2
|
|
547
|
+
except (TaskError, TaskNotFoundError, SelectorError, ProjectExistsError) as error:
|
|
548
|
+
return _emit_error(args, error)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _emit_error(args: argparse.Namespace, error: Exception) -> int:
|
|
552
|
+
emit_json = bool(getattr(args, "json", False))
|
|
553
|
+
if emit_json:
|
|
554
|
+
payload = {
|
|
555
|
+
"ok": False,
|
|
556
|
+
"action": args.command,
|
|
557
|
+
"project": slugify(args.project) if hasattr(args, "project") else None,
|
|
558
|
+
"error_code": error.__class__.__name__,
|
|
559
|
+
"error_message": str(error),
|
|
560
|
+
}
|
|
561
|
+
print(json.dumps(payload, ensure_ascii=False))
|
|
562
|
+
else:
|
|
563
|
+
print(f"ERR {error.__class__.__name__}: {error}")
|
|
564
|
+
return 1
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
if __name__ == "__main__":
|
|
568
|
+
main()
|
task_notes/core.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
import fcntl
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import re
|
|
8
|
+
from typing import Callable
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
TASK_LINE_RE = re.compile(r"^(?P<indent>\s*)- \[(?P<mark>[ xX])\] (?P<text>.*)$")
|
|
12
|
+
HEADING_RE = re.compile(r"^# (?P<title>.+)$")
|
|
13
|
+
SELECTOR_RE = re.compile(r"^\[?(?P<value>\d+(?:\.\d+)*)\]?$")
|
|
14
|
+
SLUG_RE = re.compile(r"[^a-z0-9\-]+")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TaskError(Exception):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TaskNotFoundError(TaskError):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SelectorError(TaskError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ProjectExistsError(TaskError):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(slots=True)
|
|
34
|
+
class TaskNode:
|
|
35
|
+
text: str
|
|
36
|
+
done: bool = False
|
|
37
|
+
children: list["TaskNode"] = field(default_factory=list)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(slots=True)
|
|
41
|
+
class TaskDocument:
|
|
42
|
+
title: str
|
|
43
|
+
nodes: list[TaskNode] = field(default_factory=list)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def progress(self) -> tuple[int, int]:
|
|
47
|
+
done = 0
|
|
48
|
+
total = 0
|
|
49
|
+
for node in iter_nodes(self.nodes):
|
|
50
|
+
total += 1
|
|
51
|
+
if node.done:
|
|
52
|
+
done += 1
|
|
53
|
+
return done, total
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(slots=True)
|
|
57
|
+
class ProjectSummary:
|
|
58
|
+
name: str
|
|
59
|
+
path: Path
|
|
60
|
+
done: int
|
|
61
|
+
total: int
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(slots=True)
|
|
65
|
+
class NextTask:
|
|
66
|
+
selector: str
|
|
67
|
+
text: str
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def slugify(name: str) -> str:
|
|
71
|
+
raw = name.strip().lower().replace("_", "-").replace(" ", "-")
|
|
72
|
+
slug = SLUG_RE.sub("-", raw).strip("-")
|
|
73
|
+
if not slug:
|
|
74
|
+
raise TaskError("project name cannot be empty")
|
|
75
|
+
return slug
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_selector(selector: str) -> list[int]:
|
|
79
|
+
match = SELECTOR_RE.match(selector.strip())
|
|
80
|
+
if not match:
|
|
81
|
+
raise SelectorError(f"invalid selector: {selector}")
|
|
82
|
+
parts = [int(part) for part in match.group("value").split(".")]
|
|
83
|
+
if any(part <= 0 for part in parts):
|
|
84
|
+
raise SelectorError(f"invalid selector: {selector}")
|
|
85
|
+
return parts
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def parse_markdown(content: str, default_title: str) -> TaskDocument:
|
|
89
|
+
title = default_title
|
|
90
|
+
seen_heading = False
|
|
91
|
+
roots: list[TaskNode] = []
|
|
92
|
+
stack: list[TaskNode] = []
|
|
93
|
+
|
|
94
|
+
for line in content.splitlines():
|
|
95
|
+
heading_match = HEADING_RE.match(line)
|
|
96
|
+
if heading_match and not seen_heading and not roots:
|
|
97
|
+
title = heading_match.group("title").strip()
|
|
98
|
+
seen_heading = True
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
line_match = TASK_LINE_RE.match(line)
|
|
102
|
+
if not line_match:
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
indent = len(line_match.group("indent"))
|
|
106
|
+
if indent % 2 != 0:
|
|
107
|
+
raise TaskError("invalid indentation: checklist indentation must be 2 spaces")
|
|
108
|
+
depth = indent // 2
|
|
109
|
+
if depth > len(stack):
|
|
110
|
+
raise TaskError("invalid indentation: depth jump is not allowed")
|
|
111
|
+
|
|
112
|
+
node = TaskNode(
|
|
113
|
+
text=line_match.group("text").strip(),
|
|
114
|
+
done=line_match.group("mark").lower() == "x",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
if depth == 0:
|
|
118
|
+
roots.append(node)
|
|
119
|
+
stack = [node]
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
parent = stack[depth - 1]
|
|
123
|
+
parent.children.append(node)
|
|
124
|
+
if len(stack) == depth:
|
|
125
|
+
stack.append(node)
|
|
126
|
+
else:
|
|
127
|
+
stack[depth] = node
|
|
128
|
+
del stack[depth + 1 :]
|
|
129
|
+
|
|
130
|
+
return TaskDocument(title=title, nodes=roots)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def to_markdown(document: TaskDocument) -> str:
|
|
134
|
+
lines = [f"# {document.title}", ""]
|
|
135
|
+
for node in document.nodes:
|
|
136
|
+
_append_node_lines(lines, node, depth=0)
|
|
137
|
+
if lines and lines[-1] != "":
|
|
138
|
+
lines.append("")
|
|
139
|
+
return "\n".join(lines)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _append_node_lines(lines: list[str], node: TaskNode, depth: int) -> None:
|
|
143
|
+
indent = " " * depth
|
|
144
|
+
mark = "x" if node.done else " "
|
|
145
|
+
lines.append(f"{indent}- [{mark}] {node.text}")
|
|
146
|
+
for child in node.children:
|
|
147
|
+
_append_node_lines(lines, child, depth + 1)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def iter_nodes(nodes: list[TaskNode]) -> list[TaskNode]:
|
|
151
|
+
output: list[TaskNode] = []
|
|
152
|
+
for node in nodes:
|
|
153
|
+
output.append(node)
|
|
154
|
+
output.extend(iter_nodes(node.children))
|
|
155
|
+
return output
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def iter_selectors(nodes: list[TaskNode], prefix: str = "") -> list[tuple[str, TaskNode]]:
|
|
159
|
+
output: list[tuple[str, TaskNode]] = []
|
|
160
|
+
for index, node in enumerate(nodes, start=1):
|
|
161
|
+
selector = f"{prefix}.{index}" if prefix else str(index)
|
|
162
|
+
output.append((selector, node))
|
|
163
|
+
output.extend(iter_selectors(node.children, selector))
|
|
164
|
+
return output
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def resolve_selector(nodes: list[TaskNode], selector: str) -> tuple[list[TaskNode], int, TaskNode]:
|
|
168
|
+
parts = parse_selector(selector)
|
|
169
|
+
current_list = nodes
|
|
170
|
+
for depth, part in enumerate(parts):
|
|
171
|
+
index = part - 1
|
|
172
|
+
if index < 0 or index >= len(current_list):
|
|
173
|
+
raise SelectorError(f"selector not found: {selector}")
|
|
174
|
+
node = current_list[index]
|
|
175
|
+
if depth == len(parts) - 1:
|
|
176
|
+
return current_list, index, node
|
|
177
|
+
current_list = node.children
|
|
178
|
+
raise SelectorError(f"selector not found: {selector}")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def first_open_leaf(nodes: list[TaskNode], prefix: str = "") -> NextTask | None:
|
|
182
|
+
for index, node in enumerate(nodes, start=1):
|
|
183
|
+
selector = f"{prefix}.{index}" if prefix else str(index)
|
|
184
|
+
if not node.children and not node.done:
|
|
185
|
+
return NextTask(selector=selector, text=node.text)
|
|
186
|
+
candidate = first_open_leaf(node.children, selector)
|
|
187
|
+
if candidate is not None:
|
|
188
|
+
return candidate
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class ProjectStore:
|
|
193
|
+
def __init__(self, base_dir: Path) -> None:
|
|
194
|
+
self.base_dir = base_dir
|
|
195
|
+
|
|
196
|
+
def list_projects(self) -> list[ProjectSummary]:
|
|
197
|
+
if not self.base_dir.exists():
|
|
198
|
+
return []
|
|
199
|
+
projects: list[ProjectSummary] = []
|
|
200
|
+
for path in sorted(self.base_dir.glob("*.md")):
|
|
201
|
+
document = self._read_document(path)
|
|
202
|
+
done, total = document.progress
|
|
203
|
+
projects.append(ProjectSummary(name=path.stem, path=path, done=done, total=total))
|
|
204
|
+
return projects
|
|
205
|
+
|
|
206
|
+
def new_project(self, project: str, contents: list[str]) -> Path:
|
|
207
|
+
name = slugify(project)
|
|
208
|
+
path = self._project_path(name)
|
|
209
|
+
self.base_dir.mkdir(parents=True, exist_ok=True)
|
|
210
|
+
with self._lock_for(path):
|
|
211
|
+
if path.exists():
|
|
212
|
+
raise ProjectExistsError(f"project already exists: {name}")
|
|
213
|
+
document = TaskDocument(title=name, nodes=[TaskNode(text=item) for item in contents])
|
|
214
|
+
self._write_document(path, document)
|
|
215
|
+
return path
|
|
216
|
+
|
|
217
|
+
def remove_project(self, project: str) -> None:
|
|
218
|
+
path = self._existing_project_path(project)
|
|
219
|
+
lock_path = path.with_suffix(path.suffix + ".lock")
|
|
220
|
+
with self._lock_for(path):
|
|
221
|
+
if not path.exists():
|
|
222
|
+
raise TaskNotFoundError(f"project not found: {slugify(project)}")
|
|
223
|
+
path.unlink()
|
|
224
|
+
if lock_path.exists():
|
|
225
|
+
lock_path.unlink(missing_ok=True)
|
|
226
|
+
|
|
227
|
+
def load_project(self, project: str) -> TaskDocument:
|
|
228
|
+
path = self._existing_project_path(project)
|
|
229
|
+
with self._lock_for(path):
|
|
230
|
+
return self._read_document(path)
|
|
231
|
+
|
|
232
|
+
def add_tasks(self, project: str, contents: list[str], parent_selector: str | None = None) -> TaskDocument:
|
|
233
|
+
return self._update_project(
|
|
234
|
+
project,
|
|
235
|
+
lambda document: self._apply_add(document, contents, parent_selector),
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
def edit_task(self, project: str, selector: str, content: str) -> TaskDocument:
|
|
239
|
+
def update(document: TaskDocument) -> None:
|
|
240
|
+
_, _, node = resolve_selector(document.nodes, selector)
|
|
241
|
+
node.text = content
|
|
242
|
+
|
|
243
|
+
return self._update_project(project, update)
|
|
244
|
+
|
|
245
|
+
def delete_task(self, project: str, selector: str) -> TaskDocument:
|
|
246
|
+
def update(document: TaskDocument) -> None:
|
|
247
|
+
parent, index, _ = resolve_selector(document.nodes, selector)
|
|
248
|
+
del parent[index]
|
|
249
|
+
|
|
250
|
+
return self._update_project(project, update)
|
|
251
|
+
|
|
252
|
+
def set_done(self, project: str, selector: str, done: bool) -> TaskDocument:
|
|
253
|
+
def update(document: TaskDocument) -> None:
|
|
254
|
+
_, _, node = resolve_selector(document.nodes, selector)
|
|
255
|
+
node.done = done
|
|
256
|
+
|
|
257
|
+
return self._update_project(project, update)
|
|
258
|
+
|
|
259
|
+
def toggle_done(self, project: str, selector: str) -> TaskDocument:
|
|
260
|
+
def update(document: TaskDocument) -> None:
|
|
261
|
+
_, _, node = resolve_selector(document.nodes, selector)
|
|
262
|
+
node.done = not node.done
|
|
263
|
+
|
|
264
|
+
return self._update_project(project, update)
|
|
265
|
+
|
|
266
|
+
def next_task(self, project: str) -> NextTask | None:
|
|
267
|
+
path = self._existing_project_path(project)
|
|
268
|
+
with self._lock_for(path):
|
|
269
|
+
document = self._read_document(path)
|
|
270
|
+
return first_open_leaf(document.nodes)
|
|
271
|
+
|
|
272
|
+
def _apply_add(self, document: TaskDocument, contents: list[str], parent_selector: str | None) -> None:
|
|
273
|
+
if parent_selector is None:
|
|
274
|
+
document.nodes.extend(TaskNode(text=item) for item in contents)
|
|
275
|
+
return
|
|
276
|
+
_, _, node = resolve_selector(document.nodes, parent_selector)
|
|
277
|
+
node.children.extend(TaskNode(text=item) for item in contents)
|
|
278
|
+
|
|
279
|
+
def _update_project(self, project: str, update: Callable[[TaskDocument], None]) -> TaskDocument:
|
|
280
|
+
path = self._existing_project_path(project)
|
|
281
|
+
with self._lock_for(path):
|
|
282
|
+
document = self._read_document(path)
|
|
283
|
+
update(document)
|
|
284
|
+
self._write_document(path, document)
|
|
285
|
+
return document
|
|
286
|
+
|
|
287
|
+
def _existing_project_path(self, project: str) -> Path:
|
|
288
|
+
name = slugify(project)
|
|
289
|
+
path = self._project_path(name)
|
|
290
|
+
if not path.exists():
|
|
291
|
+
raise TaskNotFoundError(f"project not found: {name}")
|
|
292
|
+
return path
|
|
293
|
+
|
|
294
|
+
def _project_path(self, project: str) -> Path:
|
|
295
|
+
return self.base_dir / f"{project}.md"
|
|
296
|
+
|
|
297
|
+
def _read_document(self, path: Path) -> TaskDocument:
|
|
298
|
+
if not path.exists():
|
|
299
|
+
raise TaskNotFoundError(f"project not found: {path.stem}")
|
|
300
|
+
content = path.read_text(encoding="utf-8")
|
|
301
|
+
return parse_markdown(content, default_title=path.stem)
|
|
302
|
+
|
|
303
|
+
def _write_document(self, path: Path, document: TaskDocument) -> None:
|
|
304
|
+
self.base_dir.mkdir(parents=True, exist_ok=True)
|
|
305
|
+
content = to_markdown(document)
|
|
306
|
+
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
|
307
|
+
tmp_path.write_text(content, encoding="utf-8")
|
|
308
|
+
os.replace(tmp_path, path)
|
|
309
|
+
|
|
310
|
+
def _lock_for(self, path: Path):
|
|
311
|
+
self.base_dir.mkdir(parents=True, exist_ok=True)
|
|
312
|
+
lock_path = path.with_suffix(path.suffix + ".lock")
|
|
313
|
+
return _FileLock(lock_path)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class _FileLock:
|
|
317
|
+
def __init__(self, lock_path: Path) -> None:
|
|
318
|
+
self.lock_path = lock_path
|
|
319
|
+
self.handle = None
|
|
320
|
+
|
|
321
|
+
def __enter__(self) -> "_FileLock":
|
|
322
|
+
self.lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
323
|
+
self.handle = self.lock_path.open("a+", encoding="utf-8")
|
|
324
|
+
fcntl.flock(self.handle.fileno(), fcntl.LOCK_EX)
|
|
325
|
+
return self
|
|
326
|
+
|
|
327
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
328
|
+
if self.handle is not None:
|
|
329
|
+
fcntl.flock(self.handle.fileno(), fcntl.LOCK_UN)
|
|
330
|
+
self.handle.close()
|
|
331
|
+
self.handle = None
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: task-notes
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: LLM-friendly Markdown task notes CLI
|
|
5
|
+
Keywords: agent,cli,llm,markdown,tasks
|
|
6
|
+
Classifier: Environment :: Console
|
|
7
|
+
Classifier: Intended Audience :: Developers
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
10
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
11
|
+
Classifier: Topic :: Utilities
|
|
12
|
+
Requires-Python: >=3.13
|
|
13
|
+
Requires-Dist: rich>=14.1.0
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# task-notes
|
|
17
|
+
|
|
18
|
+
一个面向 LLM/Agent 的任务管理 CLI。
|
|
19
|
+
用 Markdown checklist 保存任务,支持多级子任务、稳定 selector(如 `1.2.3`)、文本/JSON 双输出,以及 Rich 终端展示。
|
|
20
|
+
|
|
21
|
+
## 功能
|
|
22
|
+
|
|
23
|
+
- 单项目单文件:`<project>.md`
|
|
24
|
+
- 多级树状任务
|
|
25
|
+
- selector 定位:`1`、`1.2`、`2.1.3`
|
|
26
|
+
- 原子写入 + 文件锁(并发安全)
|
|
27
|
+
- Rich 展示(`ls` 表格、`show` 树)
|
|
28
|
+
- `edit` 支持交互选择(方向键/jk + Space + Enter)
|
|
29
|
+
- `--json` 机读输出
|
|
30
|
+
|
|
31
|
+
## 安装与运行
|
|
32
|
+
|
|
33
|
+
### 使用 uv(推荐)
|
|
34
|
+
|
|
35
|
+
在项目目录内:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
uv run task --help
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
临时执行(不安装到全局):
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
uvx --from . task --help
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
安装为全局工具:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
uv tool install --editable .
|
|
51
|
+
task --help
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### 使用 pip
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install .
|
|
58
|
+
task --help
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## 默认数据目录与安全策略
|
|
62
|
+
|
|
63
|
+
- 默认目录:`~/.task-notes`
|
|
64
|
+
- 可自定义目录:`--tasks-dir <path>`
|
|
65
|
+
- 安全限制:默认禁止写入非 `~/.task-notes` 目录;若要使用其他目录,必须显式加:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
--unsafe-external-dir
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
示例:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
task --unsafe-external-dir --tasks-dir ./tasks ls
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## 快速开始
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
task new app-redesign "梳理需求" "实现功能" "验收发布"
|
|
81
|
+
task add app-redesign --parent 2 "实现 task ls/show"
|
|
82
|
+
task show app-redesign
|
|
83
|
+
task next app-redesign
|
|
84
|
+
task check app-redesign --path 1
|
|
85
|
+
task edit app-redesign --path 2.1 "实现 ls/show/tree 输出"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## 命令概览
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
task ls [--json]
|
|
92
|
+
task show <project> [--json]
|
|
93
|
+
task next <project> [--json]
|
|
94
|
+
task new <project> <content...> [--json]
|
|
95
|
+
task add <project> [--parent <selector>] <content...> [--json]
|
|
96
|
+
task edit <project> --path <selector> <content> [--json]
|
|
97
|
+
task edit <project> -i [--json]
|
|
98
|
+
task del <project> --path <selector> [--json]
|
|
99
|
+
task rm <project> [--json]
|
|
100
|
+
task check <project> --path <selector> [--json]
|
|
101
|
+
task uncheck <project> --path <selector> [--json]
|
|
102
|
+
task toggle <project> --path <selector> [--json]
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## 交互式编辑
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
task edit <project> -i
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
按键:
|
|
112
|
+
|
|
113
|
+
- `↑/↓` 或 `j/k`:移动光标
|
|
114
|
+
- `Space`:选择当前任务
|
|
115
|
+
- `Enter`:确认并编辑文本
|
|
116
|
+
- `q` / `Esc`:取消
|
|
117
|
+
|
|
118
|
+
## JSON 输出
|
|
119
|
+
|
|
120
|
+
适合 tools/LLM 集成:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
task show app-redesign --json
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
错误时返回非 0,并输出:
|
|
127
|
+
|
|
128
|
+
- `ok: false`
|
|
129
|
+
- `error_code`
|
|
130
|
+
- `error_message`
|
|
131
|
+
|
|
132
|
+
## 开发
|
|
133
|
+
|
|
134
|
+
运行测试:
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
uv run python -m unittest discover -s tests -p 'test_*.py' -v
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## 发布到 PyPI(建议流程)
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
uv build
|
|
144
|
+
uvx twine check dist/*
|
|
145
|
+
# uvx twine upload dist/*
|
|
146
|
+
```
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
task_notes/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
task_notes/cli.py,sha256=ew32YrMNPZezVbHMC5NaGAXuLR22AQWtcM-vIXPNUqE,19827
|
|
3
|
+
task_notes/core.py,sha256=KDtyWAGcDcBWD93r_7XUqcIppoMveL7dwMou78bIuSo,11076
|
|
4
|
+
task_notes-0.1.0.dist-info/METADATA,sha256=3aJ10xy_UbkxR73bDMYKYXd_yCHV3nPVnVP-gz1NdzE,3052
|
|
5
|
+
task_notes-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
6
|
+
task_notes-0.1.0.dist-info/entry_points.txt,sha256=gonuMoyT-ZKBD6ZC6S-kCYkX9hSpIl71pvYQDmahW-8,45
|
|
7
|
+
task_notes-0.1.0.dist-info/RECORD,,
|