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 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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ task = task_notes.cli:main